detached db3b9a5

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 UnverifiedB5690EEEBB952194 The key is unknown
42 files · +3163 -9
M README.md +7 -0
@@ -36,6 +36,7 @@ vev ls [<host>|--all] list sessions
3636 vev host add|rm <host> manage remote hosts
3737 vev host list list remote hosts
3838 vev kill <name>|--all kill sessions
39+vev --web-daemon start the private browser terminal
3940 vev --ui-driver [options] drive a headless attachment over JSONL
4041 vev --ui-observe ... expose passive observation for an attach
4142 vev --ui-control ... expose observation and input control
@@ -82,6 +83,12 @@ vev attach user@host[:session]
8283
8384 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.
8485
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+
8592 ## UI driver
8693
8794 `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 hosts36 vev host add|rm <host> manage remote hosts
37 vev host list list remote hosts37 vev host list list remote hosts
38 vev kill <name>|--all kill sessions38 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 JSONL40 vev --ui-driver [options] drive a headless attachment over JSONL
40 vev --ui-observe ... expose passive observation for an attach41 vev --ui-observe ... expose passive observation for an attach
41 vev --ui-control ... expose observation and input control42 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 driver92 ## 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
1212 - `internal/usecase`: client, daemon, and supporting application behavior. Production use cases may consume semantic protocol packages but never `protocol/wire` or concrete adapters.
1313 - `internal/adapters/sessionwire`: translates between typed session connections and raw wire transports, including direction checks and decode-failure classification.
1414 - `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.
1516 - `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.
1617 - `internal/adapters`: IPC, UDP, SSH stdio, PTY, terminal, VT-backed UI terminal/observation, JSONL UI-driver sockets, persistence-facing, and observability implementations.
1718 - `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
3738
3839 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.
3940
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+
4045 ## Session composition
4146
4247 ```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 composition45 ## Session composition
41 46
42 ```text47 ```text
M docs/configuration.md +5 -1
@@ -1,8 +1,12 @@
11 # Configuration
22
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.
44
55 ```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+
610 # Theme: auto follows the client; dark/light use neutral built-in defaults.
711 theme = auto
812 # In auto mode with palette inheritance enabled, infer a terminal accent.
@@ -1,8 +1,12 @@
1 # Configuration1 # 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 ```text5 ```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 = auto11 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
33 go 1.27
44
55 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
78 github.com/stretchr/testify v1.11.1
89 golang.org/x/sys v0.42.0
910 )
@@ -3,7 +3,8 @@ module github.com/bnema/vev
3 go 1.273 go 1.27
4 4
5 require (5 require (
6- github.com/bnema/vev-vt v0.5.2-0.20260906085439-e69c4686b8bb6+ 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.18 github.com/stretchr/testify v1.11.1
8 golang.org/x/sys v0.42.09 golang.org/x/sys v0.42.0
9 )10 )
M go.sum +6 -2 generated file: collapsed
M internal/adapters/config/config.go +9 -0
@@ -36,6 +36,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
3636 seenNavKeys := make(map[string]bool)
3737 seenTabsKeys := make(map[string]bool)
3838 seenScrollbackKeys := make(map[string]bool)
39+ seenWebKeys := make(map[string]bool)
3940
4041 scanner := bufio.NewScanner(r)
4142 lineNo := 0
@@ -63,6 +64,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
6364 }
6465
6566 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+ }
6675 case key == "theme":
6776 mode, ok := parseTheme(value)
6877 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 := 042 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
366366 }
367367 }
368368 cursor := screen.Cursor()
369- modes := screen.Modes()
370369 return ports.UISnapshot{
371370 Revision: revision, Context: context,
372371 Columns: geometry.Cols, Rows: geometry.Rows,
373372 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(),
375374 }
376375 }
377376
@@ -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 (
7373 kindRemotePreview
7474 kindUIDriver
7575 kindUIRemoteCleanup
76+ kindWebDaemon
77+ kindWebRenew
78+ kindWebServe
7679 kindHelp
7780 kindVersion
7881 )
@@ -96,6 +99,7 @@ type command struct {
9699 uiObserve bool
97100 uiControl bool
98101 uiSocket string
102+ web webOptions
99103 }
100104
101105 // usageError is a user-facing argument error; the app prints it (with usage)
@@ -130,6 +134,9 @@ usage:
130134 (optional: --ui-socket PATH)
131135 vev --ui-control expose observation and input control for this client
132136 (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]
133140 vev --help show this help
134141 vev --version show version`
135142
@@ -214,6 +221,13 @@ parsedUIFlags:
214221 }
215222
216223 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
217231 case "--daemon":
218232 return command{kind: kindDaemon}, nil
219233 case "--daemon-launcher":
@@ -332,6 +346,12 @@ func dispatch(ctx context.Context, cmd command) error {
332346 case kindVersion:
333347 fmt.Println(versionLine())
334348 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)
335355 case kindDaemon:
336356 return runDaemon()
337357 case kindDaemonLauncher:
@@ -73,6 +73,9 @@ const (
73 kindRemotePreview73 kindRemotePreview
74 kindUIDriver74 kindUIDriver
75 kindUIRemoteCleanup75 kindUIRemoteCleanup
76+ kindWebDaemon
77+ kindWebRenew
78+ kindWebServe
76 kindHelp79 kindHelp
77 kindVersion80 kindVersion
78 )81 )
@@ -96,6 +99,7 @@ type command struct {
96 uiObserve bool99 uiObserve bool
97 uiControl bool100 uiControl bool
98 uiSocket string101 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 client135 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 help140 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}, nil232 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 nil348 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 {
108108 // keys are preserved here (in BindingEntries, in file order) so the usecase
109109 // layer can decide which actions it understands.
110110 type Config struct {
111+ WebListen string
112+ WebOrigin string
111113 Theme ThemeMode
112114 ThemePalette bool
113115 ThemeAccent ThemeAccent
@@ -108,6 +108,8 @@ func (c ScrollbackConfig) Valid() bool {
108 // keys are preserved here (in BindingEntries, in file order) so the usecase108 // 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 ThemeMode113 Theme ThemeMode
112 ThemePalette bool114 ThemePalette bool
113 ThemeAccent ThemeAccent115 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) {
121121 // PickerSeparator is now a semantic role with the SurfaceBar
122122 // background; it was never a compatibility alias.
123123 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)
124126 for name, style := range want {
125127 require.Equalf(t, style, legacyAndSemanticStyles(got)[name], "%s", name)
126128 }
@@ -121,6 +121,8 @@ func TestLegacyAliasesPaletteOffAndForcedThemesRemainExact(t *testing.T) {
121 // PickerSeparator is now a semantic role with the SurfaceBar121 // 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 {
160160 // PaletteDesc is a Phase 3 compatibility alias. It retains its legacy
161161 // foreground-only bytes; rendered palette descriptions use PickerDescription.
162162 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
166182 }
167183
168184 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 legacy160 // 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; });

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close