feat: improve webvnc bridge ergonomics

This commit is contained in:
Peter Steinberger 2026-05-05 10:16:55 +01:00
parent 6ab061716c
commit 0ca412a8d5
No known key found for this signature in database
15 changed files with 383 additions and 17 deletions

View File

@ -5,11 +5,14 @@
### Added
- Added `crabbox desktop launch --webvnc --open` to launch a desktop browser/app and immediately bridge the same lease into the WebVNC portal.
- Added `crabbox webvnc --daemon`/`--background` plus `--status`/`--stop` for background WebVNC bridges without tmux.
- Added `crabbox media preview` for creating motion-trimmed GIF previews and optional trimmed MP4 clips from desktop recordings.
### Fixed
- Fixed auto-shell command reconstruction so arguments with spaces stay quoted when shell operators such as `&&` are present.
- Fixed human WebVNC desktop launches to keep browser windows windowed by default and reserve fullscreen for explicit capture/video workflows.
- Fixed WebVNC portal status text and bridge commands so waiting/reset states explain the exact local bridge command to run.
- Fixed Windows WebVNC credential handling so generated portal links preserve special characters and managed TightVNC sessions copy service passwords into the logged-in user's registry profile.
- Fixed managed Linux browser setup so Chrome/Chromium launches skip first-run and default-browser prompts.
- Fixed WebVNC portal passwords with escaped special characters and kept the bridge alive across viewer resets and transient coordinator EOFs.

View File

@ -16,6 +16,8 @@ With `--browser`, Crabbox probes the target browser the same way `run --browser`
does and launches `BROWSER` when no explicit command is provided.
With `--webvnc`, the command keeps running after launch and bridges the desktop
into the authenticated WebVNC portal. Add `--open` to open that portal locally.
Browser launches default to a windowed human desktop with the remote panel and
title bar visible; use `--fullscreen` only for capture/video workflows.
On Windows, SSH sessions cannot directly own the visible console desktop, so
Crabbox writes a one-shot PowerShell launcher under `C:\ProgramData\crabbox` and
@ -39,5 +41,6 @@ Flags:
--url <url>
--webvnc
--open
--fullscreen
--reclaim
```

View File

@ -8,6 +8,7 @@ crabbox warmup --desktop
crabbox webvnc --id blue-lobster
crabbox webvnc --id blue-lobster --network tailscale
crabbox webvnc --id blue-lobster --open
crabbox webvnc --id blue-lobster --daemon --open
```
The command resolves the lease like `crabbox vnc`, verifies that the lease has
@ -27,6 +28,11 @@ This keeps the security boundary the same as `crabbox vnc`:
- The local `crabbox webvnc` process must keep running while the browser uses
the desktop.
Use `--daemon` (or `--background`) to keep the bridge running without a tmux or
foreground shell. Crabbox writes the bridge log and pid file under its local
state directory and prints both paths. Use `--status` to print those paths
again, and `--stop` to kill the background bridge for that lease.
`--network tailscale` changes only the SSH endpoint used for the local tunnel.
The runner VNC service stays bound to loopback.
@ -52,6 +58,10 @@ Flags:
--network auto|tailscale|public
--local-port <port>
--open
--daemon
--background
--status
--stop
--reclaim
```

View File

@ -100,6 +100,11 @@ session without keeping the SSH command attached:
crabbox desktop launch --id blue-lobster --browser --url https://example.com --webvnc --open
```
For human demos, Crabbox keeps launched browsers windowed so the remote desktop
panel, title bar, and surrounding session remain visible. Use
`desktop launch --fullscreen` only when you intentionally want browser-only
video or capture output.
## Network Model
Managed VNC is tunnel-first:

View File

@ -528,7 +528,7 @@ func cloudInitOptionalBootstrap(cfg Config) string {
parts = append(parts, cloudInitTailscaleBootstrap(cfg))
}
if cfg.Desktop {
parts = append(parts, ` retry apt-get install -y --no-install-recommends xvfb xfce4 xfce4-terminal x11vnc xauth dbus-x11 x11-xserver-utils xterm scrot fonts-dejavu-core fonts-liberation iproute2 openssl
parts = append(parts, ` retry apt-get install -y --no-install-recommends xvfb xfce4 xfce4-terminal x11vnc xauth dbus-x11 x11-xserver-utils xterm scrot xdotool wmctrl fonts-dejavu-core fonts-liberation iproute2 openssl
install -d -m 0750 -o crabbox -g crabbox /var/lib/crabbox
if [ ! -s /var/lib/crabbox/vnc.password ]; then
(umask 077 && openssl rand -base64 18 > /var/lib/crabbox/vnc.password)

View File

@ -39,7 +39,7 @@ func TestCloudInitDesktopProfile(t *testing.T) {
got := cloudInit(cfg, "ssh-ed25519 test")
for _, want := range []string{
"xvfb xfce4 xfce4-terminal x11vnc xauth dbus-x11",
"x11-xserver-utils xterm scrot",
"x11-xserver-utils xterm scrot xdotool wmctrl",
"/etc/systemd/system/crabbox-xvfb.service",
"/etc/systemd/system/crabbox-desktop.service",
"/usr/local/bin/crabbox-desktop-session",

View File

@ -16,6 +16,7 @@ func (a App) desktopLaunch(ctx context.Context, args []string) error {
url := fs.String("url", "", "URL to pass to the launched browser")
webvnc := fs.Bool("webvnc", false, "bridge the launched desktop into the authenticated WebVNC portal")
openPortal := fs.Bool("open", false, "open the WebVNC portal when --webvnc is set")
fullscreen := fs.Bool("fullscreen", false, "leave launched browser fullscreen for capture/video workflows")
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")
targetFlags := registerTargetFlags(fs, defaults)
if err := parseFlags(fs, args); err != nil {
@ -91,7 +92,7 @@ func (a App) desktopLaunch(ctx context.Context, args []string) error {
return exit(2, "usage: crabbox desktop launch --id <lease-id-or-slug> -- <command...>")
}
workdir := remoteJoin(cfg, leaseID, repo.Name)
if err := runSSHQuiet(ctx, target, desktopLaunchRemoteCommand(target, workdir, env, command)); err != nil {
if err := runSSHQuiet(ctx, target, desktopLaunchRemoteCommand(target, workdir, env, command, *browser && !*fullscreen)); err != nil {
return exit(5, "launch desktop command: %v", err)
}
fmt.Fprintf(a.Stdout, "launched: %s\n", strings.Join(command, " "))
@ -123,17 +124,17 @@ func firstNonBlank(values ...string) string {
return ""
}
func desktopLaunchRemoteCommand(target SSHTarget, workdir string, env map[string]string, command []string) string {
func desktopLaunchRemoteCommand(target SSHTarget, workdir string, env map[string]string, command []string, windowedBrowser bool) string {
if isWindowsNativeTarget(target) {
return windowsDesktopLaunchRemoteCommand(workdir, env, command)
}
if target.TargetOS == targetMacOS {
return posixDesktopLaunchRemoteCommand(workdir, env, command)
return posixDesktopLaunchRemoteCommand(workdir, env, command, windowedBrowser)
}
return posixDesktopLaunchRemoteCommand(workdir, env, command)
return posixDesktopLaunchRemoteCommand(workdir, env, command, windowedBrowser)
}
func posixDesktopLaunchRemoteCommand(workdir string, env map[string]string, command []string) string {
func posixDesktopLaunchRemoteCommand(workdir string, env map[string]string, command []string, windowedBrowser bool) string {
var b bytes.Buffer
b.WriteString("set -eu\n")
if workdir != "" {
@ -154,9 +155,32 @@ func posixDesktopLaunchRemoteCommand(workdir string, env map[string]string, comm
writeShellArgv(&b, command)
b.WriteString(" >\"$log\" 2>&1 < /dev/null &\n")
b.WriteString("fi\n")
if windowedBrowser {
b.WriteString(posixWindowBrowserCommand())
}
return b.String()
}
func posixWindowBrowserCommand() string {
return `(
sleep 2
export DISPLAY="${DISPLAY:-:99}"
if command -v wmctrl >/dev/null 2>&1; then
wmctrl -r :ACTIVE: -b remove,fullscreen,maximized_vert,maximized_horz >/dev/null 2>&1 || true
fi
if command -v xdotool >/dev/null 2>&1; then
window="$(xdotool search --onlyvisible --class google-chrome 2>/dev/null | tail -1 || true)"
if [ -z "$window" ]; then
window="$(xdotool search --onlyvisible --class chromium 2>/dev/null | tail -1 || true)"
fi
if [ -n "$window" ]; then
xdotool windowactivate "$window" windowmove "$window" 80 80 windowsize "$window" 1500 900 >/dev/null 2>&1 || true
fi
fi
) >/dev/null 2>&1 &
`
}
func writeShellArgv(b *bytes.Buffer, command []string) {
for i, arg := range command {
if i > 0 {

View File

@ -11,6 +11,7 @@ func TestDesktopLaunchRemoteCommandUsesDetachedPOSIXSession(t *testing.T) {
"/work/crabbox/cbx_1/repo",
map[string]string{"DISPLAY": ":99", "BROWSER": "/usr/bin/chromium"},
[]string{"/usr/bin/chromium", "https://example.com"},
true,
)
for _, want := range []string{
"mkdir -p '/work/crabbox/cbx_1/repo'",
@ -19,6 +20,9 @@ func TestDesktopLaunchRemoteCommandUsesDetachedPOSIXSession(t *testing.T) {
"BROWSER='/usr/bin/chromium'",
"setsid '/usr/bin/chromium' 'https://example.com'",
"crabbox-desktop-launch.log",
"wmctrl -r :ACTIVE: -b remove,fullscreen",
"xdotool search --onlyvisible --class google-chrome",
"windowsize \"$window\" 1500 900",
} {
if !strings.Contains(got, want) {
t.Fatalf("desktop launch command missing %q:\n%s", want, got)
@ -53,6 +57,7 @@ func TestWindowsDesktopLaunchRemoteCommandUsesInteractiveTask(t *testing.T) {
`C:\crabbox\cbx_1\repo`,
map[string]string{"BROWSER": `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`},
[]string{`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`, "https://example.com"},
true,
)
for _, want := range []string{
"CrabboxDesktopLaunch-",

View File

@ -7,7 +7,10 @@ import (
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
@ -22,6 +25,10 @@ func (a App) webvnc(ctx context.Context, args []string) error {
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")
localPort := fs.String("local-port", "", "local VNC tunnel port")
openPortal := fs.Bool("open", false, "open the web portal VNC page")
daemon := fs.Bool("daemon", false, "start the WebVNC bridge in the background")
background := fs.Bool("background", false, "alias for --daemon")
daemonStatus := fs.Bool("status", false, "show WebVNC background bridge pid/log paths")
stopDaemon := fs.Bool("stop", false, "stop the WebVNC background bridge for this lease")
networkFlags := registerNetworkModeFlag(fs, defaults)
targetFlags := registerTargetFlags(fs, defaults)
if err := parseFlags(fs, args); err != nil {
@ -31,6 +38,15 @@ func (a App) webvnc(ctx context.Context, args []string) error {
if *id == "" {
return exit(2, "usage: crabbox webvnc --id <lease-id-or-slug>")
}
if *daemonStatus {
return a.webVNCDaemonStatus(*id)
}
if *stopDaemon {
return a.stopWebVNCDaemon(*id)
}
if *daemon || *background {
return a.startWebVNCDaemon(args, *id)
}
cfg, err := loadLeaseTargetConfig(fs, *provider, targetFlags, networkFlags, leaseTargetConfigOptions{Desktop: true})
if err != nil {
return err
@ -132,6 +148,168 @@ func (a App) webvnc(ctx context.Context, args []string) error {
}
}
func (a App) startWebVNCDaemon(args []string, leaseID string) error {
exe, err := os.Executable()
if err != nil {
return exit(2, "resolve crabbox executable: %v", err)
}
logPath, pidPath, err := webVNCDaemonPaths(leaseID)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(logPath), 0o700); err != nil {
return exit(2, "create WebVNC daemon directory: %v", err)
}
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return exit(2, "open WebVNC daemon log: %v", err)
}
defer logFile.Close()
childArgs := append([]string{"webvnc"}, stripWebVNCDaemonFlags(args)...)
cmd := exec.Command(exe, childArgs...)
cmd.Stdin = nil
cmd.Stdout = logFile
cmd.Stderr = logFile
if err := cmd.Start(); err != nil {
return exit(5, "start WebVNC daemon: %v", err)
}
pid := cmd.Process.Pid
if err := os.WriteFile(pidPath, []byte(fmt.Sprintf("%d\n", pid)), 0o600); err != nil {
_ = cmd.Process.Kill()
return exit(2, "write WebVNC daemon pid: %v", err)
}
if err := cmd.Process.Release(); err != nil {
return exit(5, "release WebVNC daemon process: %v", err)
}
fmt.Fprintf(a.Stdout, "webvnc daemon: pid=%d log=%s\n", pid, logPath)
fmt.Fprintf(a.Stdout, "webvnc daemon: stop with kill $(cat %s)\n", pidPath)
return nil
}
func (a App) webVNCDaemonStatus(leaseID string) error {
logPath, pidPath, err := webVNCDaemonPaths(leaseID)
if err != nil {
return err
}
pid, err := readWebVNCDaemonPID(pidPath)
if err != nil {
if os.IsNotExist(err) {
fmt.Fprintf(a.Stdout, "webvnc daemon: no pid file for %s\n", leaseID)
fmt.Fprintf(a.Stdout, "webvnc daemon: expected log=%s\n", logPath)
return nil
}
return err
}
command, alive := webVNCDaemonProcessCommand(pid)
if !alive {
fmt.Fprintf(a.Stdout, "webvnc daemon: stale pid=%d log=%s\n", pid, logPath)
return nil
}
fmt.Fprintf(a.Stdout, "webvnc daemon: pid=%d log=%s\n", pid, logPath)
if strings.TrimSpace(command) != "" {
fmt.Fprintf(a.Stdout, "webvnc daemon: command=%s\n", strings.TrimSpace(command))
}
return nil
}
func (a App) stopWebVNCDaemon(leaseID string) error {
_, pidPath, err := webVNCDaemonPaths(leaseID)
if err != nil {
return err
}
pid, err := readWebVNCDaemonPID(pidPath)
if err != nil {
if os.IsNotExist(err) {
fmt.Fprintf(a.Stdout, "webvnc daemon: no pid file for %s\n", leaseID)
return nil
}
return err
}
command, alive := webVNCDaemonProcessCommand(pid)
if !alive {
_ = os.Remove(pidPath)
fmt.Fprintf(a.Stdout, "webvnc daemon: removed stale pid=%d\n", pid)
return nil
}
if !isWebVNCDaemonCommand(command) {
return exit(5, "refusing to stop pid %d; command does not look like crabbox webvnc: %s", pid, strings.TrimSpace(command))
}
process, err := os.FindProcess(pid)
if err != nil {
return exit(5, "find WebVNC daemon pid %d: %v", pid, err)
}
if err := process.Kill(); err != nil {
return exit(5, "stop WebVNC daemon pid %d: %v", pid, err)
}
_ = os.Remove(pidPath)
fmt.Fprintf(a.Stdout, "webvnc daemon: stopped pid=%d\n", pid)
return nil
}
func webVNCDaemonProcessCommand(pid int) (string, bool) {
out, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "command=").Output()
if err != nil {
return "", false
}
command := strings.TrimSpace(string(out))
return command, command != ""
}
func isWebVNCDaemonCommand(command string) bool {
command = strings.ToLower(command)
return strings.Contains(command, "crabbox") && strings.Contains(command, "webvnc")
}
func readWebVNCDaemonPID(pidPath string) (int, error) {
data, err := os.ReadFile(pidPath)
if err != nil {
return 0, err
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil || pid <= 0 {
return 0, exit(2, "invalid WebVNC daemon pid file %s", pidPath)
}
return pid, nil
}
func stripWebVNCDaemonFlags(args []string) []string {
out := make([]string, 0, len(args))
for _, arg := range args {
if arg == "--daemon" || arg == "--background" ||
strings.HasPrefix(arg, "--daemon=") || strings.HasPrefix(arg, "--background=") {
continue
}
out = append(out, arg)
}
return out
}
func webVNCDaemonPaths(leaseID string) (string, string, error) {
dir, err := crabboxStateDir()
if err != nil {
return "", "", err
}
name := safeWebVNCDaemonName(leaseID)
bridgeDir := filepath.Join(dir, "webvnc")
return filepath.Join(bridgeDir, name+".log"), filepath.Join(bridgeDir, name+".pid"), nil
}
func safeWebVNCDaemonName(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "bridge"
}
var b strings.Builder
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
return b.String()
}
func startVNCForegroundTunnel(ctx context.Context, target SSHTarget, localPort, remoteHost, remotePort string) (*exec.Cmd, error) {
cmd := exec.CommandContext(ctx, "ssh", vncTunnelArgs(target, localPort, remoteHost, remotePort)...)
if err := cmd.Start(); err != nil {

View File

@ -8,6 +8,8 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
@ -153,3 +155,49 @@ func TestRetryableWebVNCBridgeErrors(t *testing.T) {
})
}
}
func TestWebVNCDaemonArgsStripBackgroundFlags(t *testing.T) {
got := strings.Join(stripWebVNCDaemonFlags([]string{
"--provider",
"aws",
"--daemon",
"--target",
"linux",
"--background=true",
"--id",
"pearl-krill",
"--open",
}), " ")
if got != "--provider aws --target linux --id pearl-krill --open" {
t.Fatalf("stripped args=%q", got)
}
}
func TestSafeWebVNCDaemonName(t *testing.T) {
if got := safeWebVNCDaemonName("pearl/krill :99"); got != "pearl_krill__99" {
t.Fatalf("safe daemon name=%q", got)
}
}
func TestReadWebVNCDaemonPID(t *testing.T) {
path := filepath.Join(t.TempDir(), "bridge.pid")
if err := os.WriteFile(path, []byte("12345\n"), 0o600); err != nil {
t.Fatal(err)
}
got, err := readWebVNCDaemonPID(path)
if err != nil {
t.Fatal(err)
}
if got != 12345 {
t.Fatalf("pid=%d", got)
}
}
func TestIsWebVNCDaemonCommand(t *testing.T) {
if !isWebVNCDaemonCommand("/usr/local/bin/crabbox webvnc --id pearl-krill") {
t.Fatal("expected crabbox webvnc command")
}
if isWebVNCDaemonCommand("/bin/sleep 999") {
t.Fatal("sleep must not be treated as WebVNC daemon")
}
}

View File

@ -408,7 +408,7 @@ function optionalBootstrap(config: LeaseConfig): string {
parts.push(tailscaleBootstrap(config));
}
if (config.desktop) {
parts.push(` retry apt-get install -y --no-install-recommends xvfb xfce4 xfce4-terminal x11vnc xauth dbus-x11 x11-xserver-utils xterm scrot fonts-dejavu-core fonts-liberation iproute2 openssl
parts.push(` retry apt-get install -y --no-install-recommends xvfb xfce4 xfce4-terminal x11vnc xauth dbus-x11 x11-xserver-utils xterm scrot xdotool wmctrl fonts-dejavu-core fonts-liberation iproute2 openssl
install -d -m 0750 -o crabbox -g crabbox /var/lib/crabbox
if [ ! -s /var/lib/crabbox/vnc.password ]; then
(umask 077 && openssl rand -base64 18 > /var/lib/crabbox/vnc.password)

View File

@ -4,7 +4,7 @@ import { leaseConfig, validCIDRs } from "./config";
import { HetznerClient } from "./hetzner";
import { errorMessage, json, pathParts, readJson, requestOwner } from "./http";
import { githubAuthRoute, githubPortalLogin, githubPortalLogout } from "./oauth";
import { portalError, portalHome, portalVNC } from "./portal";
import { portalError, portalHome, portalVNC, webVNCBridgeCommand } from "./portal";
import { leaseSlugFromID, normalizeLeaseSlug, slugWithCollisionSuffix } from "./slug";
import {
createTailscaleAuthKey,
@ -423,6 +423,15 @@ export class FleetDurableObject implements DurableObject {
}
return portalVNC(lease);
}
if (
method === "GET" &&
parts[1] === "leases" &&
parts[2] &&
parts[3] === "vnc" &&
parts[4] === "status"
) {
return await this.webVNCStatus(request, parts[2]);
}
if (
method === "GET" &&
parts[1] === "leases" &&
@ -508,6 +517,32 @@ export class FleetDurableObject implements DurableObject {
});
}
private async webVNCStatus(request: Request, identifier: string): Promise<Response> {
const lease = await this.resolveLease(identifier, request, false);
if (!lease) {
return notFound();
}
const error = webVNCLeaseError(lease);
if (error) {
return json({ error: "webvnc_unavailable", message: error }, { status: 409 });
}
const agent = this.webVNCAgents.get(lease.id);
const viewer = this.webVNCViewers.get(lease.id);
const bridgeConnected = agent?.readyState === WebSocket.OPEN;
const viewerConnected = viewer?.readyState === WebSocket.OPEN;
const command = webVNCBridgeCommand(lease);
return json({
bridgeConnected,
viewerConnected,
command,
message: bridgeConnected
? viewerConnected
? "bridge connected; another viewer is active"
: "bridge connected"
: `no bridge connected; run: ${command}`,
});
}
private async webVNCViewer(request: Request, identifier: string): Promise<Response> {
if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
return json(
@ -525,10 +560,12 @@ export class FleetDurableObject implements DurableObject {
}
const agent = this.webVNCAgents.get(lease.id);
if (!agent || agent.readyState !== WebSocket.OPEN) {
const command = webVNCBridgeCommand(lease);
return json(
{
error: "webvnc_bridge_missing",
message: `start the bridge with: crabbox webvnc --id ${lease.slug || lease.id}`,
message: `start the bridge with: ${command}`,
command,
},
{ status: 409 },
);
@ -537,10 +574,12 @@ export class FleetDurableObject implements DurableObject {
if (existingViewer) {
closeSocket(existingViewer, 1012, "replaced by a newer WebVNC viewer");
this.clearWebVNCViewer(lease.id, existingViewer);
const command = webVNCBridgeCommand(lease);
return json(
{
error: "webvnc_bridge_reset",
message: `restart the bridge with: crabbox webvnc --id ${lease.slug || lease.id}`,
message: `another viewer was active; restart or wait for the bridge to reconnect with: ${command}`,
command,
},
{ status: 409 },
);

View File

@ -45,6 +45,7 @@ export function portalVNC(lease: LeaseRecord): Response {
const nonce = scriptNonce();
const title = `WebVNC ${lease.slug || lease.id}`;
const wsPath = `/portal/leases/${encodeURIComponent(lease.id)}/vnc/viewer`;
const statusPath = `/portal/leases/${encodeURIComponent(lease.id)}/vnc/status`;
const bridgeCmd = webVNCBridgeCommand(lease);
return html(
title,
@ -59,12 +60,15 @@ export function portalVNC(lease: LeaseRecord): Response {
<a class="button secondary" href="/portal/logout">log out</a>
</nav>
</header>
<section id="status" class="status">waiting for bridge</section>
<section id="status" class="status">checking bridge</section>
<section id="screen" class="screen" aria-label="WebVNC display"></section>
<section class="panel commands">
<h2>bridge</h2>
<p>run this locally while the browser tab is open:</p>
<code>${escapeHTML(bridgeCmd)}</code>
<div class="command-row">
<code id="bridgeCommand">${escapeHTML(bridgeCmd)}</code>
<button id="copyBridge" class="button secondary" type="button">copy</button>
</div>
</section>
</main>
<script type="module" nonce="${nonce}">
@ -72,8 +76,11 @@ export function portalVNC(lease: LeaseRecord): Response {
const RFB = RFBModule.default || RFBModule;
const status = document.getElementById("status");
const screen = document.getElementById("screen");
const copyBridge = document.getElementById("copyBridge");
const bridgeCommand = document.getElementById("bridgeCommand")?.textContent || "";
const wsURL = new URL(${JSON.stringify(wsPath)}, window.location.href);
wsURL.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const statusURL = new URL(${JSON.stringify(statusPath)}, window.location.href);
const fragment = new URLSearchParams(window.location.hash.slice(1));
const username = fragment.get("username") || "";
const password = fragment.get("password") || "";
@ -93,6 +100,14 @@ export function portalVNC(lease: LeaseRecord): Response {
function retryDelay() {
return Math.min(5000, 500 * 2 ** retryAttempt);
}
async function bridgeState() {
try {
const response = await fetch(statusURL, { cache: "no-store" });
return response.ok ? await response.json() : undefined;
} catch {
return undefined;
}
}
function scheduleRetry(label) {
if (stopped) return;
const delay = retryDelay();
@ -101,12 +116,21 @@ export function portalVNC(lease: LeaseRecord): Response {
window.clearTimeout(retryTimer);
retryTimer = window.setTimeout(connect, delay);
}
function connect() {
async function connect() {
if (stopped) return;
connected = false;
screen.replaceChildren();
try {
setStatus(retryAttempt ? "waiting for bridge" : "connecting");
const state = await bridgeState();
if (state && !state.bridgeConnected) {
scheduleRetry(state.message || "no bridge connected; run the bridge command below");
return;
}
if (state?.viewerConnected) {
setStatus("another viewer is connected; close stale tabs if this resets", "warn");
} else {
setStatus(retryAttempt ? "bridge connected; opening viewer" : "connecting");
}
rfb = new RFB(screen, wsURL.toString(), options);
rfb.scaleViewport = true;
rfb.resizeSession = false;
@ -139,6 +163,16 @@ export function portalVNC(lease: LeaseRecord): Response {
scheduleRetry(error instanceof Error ? error.message : String(error));
}
}
copyBridge?.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(bridgeCommand);
copyBridge.textContent = "copied";
window.setTimeout(() => { copyBridge.textContent = "copy"; }, 1400);
} catch {
copyBridge.textContent = "failed";
window.setTimeout(() => { copyBridge.textContent = "copy"; }, 1400);
}
});
window.addEventListener("beforeunload", () => {
stopped = true;
window.clearTimeout(retryTimer);
@ -165,7 +199,7 @@ export function portalError(title: string, message: string, status = 400): Respo
);
}
function webVNCBridgeCommand(lease: LeaseRecord): string {
export function webVNCBridgeCommand(lease: LeaseRecord): string {
const target = lease.target || "linux";
const args = [
"crabbox",
@ -244,6 +278,8 @@ function html(title: string, body: string, status = 200, nonce = ""): Response {
.status[data-tone="warn"] { color:var(--warn); }
.status[data-tone="bad"] { color:var(--bad); }
.commands { padding:12px; display:grid; gap:8px; }
.command-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:stretch; }
.command-row code { min-width:0; }
.error { margin-top:20vh; padding:24px; display:grid; gap:12px; }
@media (max-width: 760px) { main { width:min(100vw - 20px, 1180px); padding:10px 0; } th:nth-child(4),td:nth-child(4),th:nth-child(6),td:nth-child(6){ display:none; } .top{align-items:flex-start;} }
</style>

View File

@ -79,7 +79,7 @@ describe("cloud-init bootstrap", () => {
expect(got).toContain("ExecStart=/usr/bin/startxfce4");
expect(got).toContain("systemctl is-active --quiet crabbox-desktop.service");
expect(got).toContain("systemctl is-active --quiet crabbox-desktop-session.service");
expect(got).toContain("x11-xserver-utils xterm scrot");
expect(got).toContain("x11-xserver-utils xterm scrot xdotool wmctrl");
expect(got).toContain("xsetroot -solid '#20242b'");
expect(got).toContain("xterm -title 'Crabbox Desktop'");
expect(got).toContain("(umask 077 && openssl rand -base64 18 > /var/lib/crabbox/vnc.password)");

View File

@ -541,10 +541,25 @@ describe("fleet lease identity and idle", () => {
);
expect(pageBody).toContain("/portal/assets/novnc/rfb.js");
expect(pageBody).toContain("function scheduleRetry");
expect(pageBody).toContain("/portal/leases/cbx_000000000001/vnc/status");
expect(pageBody).toContain("copyBridge");
expect(pageBody).toContain("no bridge connected; run the bridge command below");
expect(pageBody).toContain('fragment.get("username")');
expect(pageBody).toContain('types.includes("username")');
expect(pageBody).not.toContain("cdn.jsdelivr.net");
const status = await fleet.fetch(
request("GET", "/portal/leases/blue-lobster/vnc/status", { headers }),
);
expect(status.status).toBe(200);
await expect(status.json()).resolves.toMatchObject({
bridgeConnected: false,
viewerConnected: false,
command: "crabbox webvnc --provider hetzner --target linux --id blue-lobster --open",
message:
"no bridge connected; run: crabbox webvnc --provider hetzner --target linux --id blue-lobster --open",
});
const plain = await fleet.fetch(
request("GET", "/portal/leases/plain-lobster/vnc", { headers }),
);