app
The Headless Deskman
Public Unreviewedby barrett-land
A browser desktop for a headless Adom container. Runs Xvfb+fluxbox+x11vnc+noVNC inside the container and maps a *.adom.cloud port so any browser (or a Hydrogen webview tab) shows the container's GUI — no SSH tunnel, no local viewer. The container hosts the page itself. (Formerly Headview.)
← Commit history
Publish 0.1.0
8 files changed
+486
SKILL.mdadded+74@@ -0,0 +1,74 @@+---+name: headview+description: "Turn a headless Adom container into a browser-accessible desktop. Runs Xvfb+fluxbox+x11vnc+noVNC inside the container and maps a *.adom.cloud port so any browser (or a Hydrogen webview tab) shows the container's GUI. Trigger words: headless desktop, container desktop, view GUI from container, noVNC, run a GUI app in the container, remote desktop for container, browser desktop, X server in container, see kicad/gui in browser."+---++# headview++`headview` gives a **headless** Adom container a real desktop you can open in a+browser. It starts a virtual X display in the container, scrapes it with VNC,+bridges that to WebSocket with noVNC, and maps a `*.adom.cloud` port so+**Cloudflare serves the page — the container hosts it itself**, no SSH tunnel and+no local VNC viewer. Open the URL anywhere, or drop it into a Hydrogen webview tab.++Why not `ssh -Y`? X11 forwarding does **not** work through the `adom.cloud` SSH+gateway (the gateway relays the connection, so the X11 channel never reaches the+container). `headview` sidesteps that entirely by serving over HTTP/WebSocket,+which the gateway/Cloudflare pass fine.++## Stack++`Xvfb (:99)` → `fluxbox` → `x11vnc (localhost:5900)` → `noVNC/websockify (:6080)` → port-mapping → `https://<prefix>-*.adom.cloud/vnc.html`++Rendering is **software only** (Xvfb has no GPU) — great for 2D GUIs, not for+OpenGL/3D views.++## Commands++### `headview up`+Install deps if missing, start the desktop, map the port, print the URL + password. Idempotent — safe to re-run.++| Flag | Default | Description |+|------|---------|-------------|+| `--display :N` | `:99` | X display number |+| `--geometry WxH` | `1600x1000` | screen size |+| `--vnc-port N` | `5900` | internal VNC port (loopback only) |+| `--web-port N` | `6080` | internal noVNC port that gets mapped |+| `--prefix NAME` | `desktop` | subdomain prefix → `NAME-<rand>.adom.cloud` |+| `--app "CMD"` | — | launch CMD on the desktop after starting |+| `--no-password` | — | passwordless (only behind other access control) |++```bash+headview up # bare desktop+headview up --app "kicad" # desktop with KiCad already open+headview up --geometry 1920x1080+```++### `headview open`+Open the running desktop as a Hydrogen webview tab named "Container Desktop".+Must be run from a **Hydrogen workspace terminal** (needs `VSCODE_PROXY_URI`).+From anywhere else, use `headview url` and open it in a browser.++### `headview status` / `headview url` / `headview password`+Inspect state: what's running, the URL, and the VNC password.++### `headview down`+Stop Xvfb/fluxbox/x11vnc/noVNC and **remove the port mapping**. Add `--keep-mapping` to leave the mapping in place.++### `headview install`+Install/refresh this skill from the wiki into `~/.claude/skills/headview/`.++## Launching apps onto the desktop+Anything you run with `DISPLAY=:99` appears in the browser window:+```bash+DISPLAY=:99 pcbnew &+DISPLAY=:99 xterm &+```++## Notes+- Run `headview` **inside the target container** — it auto-detects the container+ slug via `adom-cli carbon containers current` and maps its own port.+- The VNC server binds `localhost` only; the public face is the noVNC HTTPS URL,+ protected by the generated VNC password (shown by `headview up`/`password`).+- WebSocket idle: Cloudflare can drop very idle WS connections; noVNC reconnects,+ and any desktop activity keeps it warm.
docs/HERO.mdadded+8@@ -0,0 +1,8 @@+Generate `hero.png` for this folder in Hero Studio. It is the page-header image, reused on the homepage and the screensaver; the page overlays live stats on its lower third, so the image carries the title and artwork only.++New app/skill heroes MUST come from Hero Studio (the server rejects anything else with HERO_STUDIO_REQUIRED; the studio stamps provenance into the PNG and exports at 2000x1250):++1. `adom-wiki pkg install adom/hero-studio`+2. start it (`hero-studio`, port 8846) and open the webview so the human sees it+3. pose the hero (the UI, or POST /state), then Generate+4. copy `generated/latest.png` here as `docs/hero.png`
docs/hero.pngadded⋯ 1 unchanged line ⋯
docs/screenshot-desktop.pngadded⋯ 1 unchanged line ⋯
headviewadded+306@@ -0,0 +1,306 @@+#!/usr/bin/env bash+# headview — turn a headless Adom container into a browser-accessible desktop.+#+# Spins up Xvfb + fluxbox + x11vnc + noVNC inside the container, maps a+# *.adom.cloud port so Cloudflare serves it, and prints a URL you can open+# from any browser (or as a Hydrogen webview tab). The container hosts the+# site itself — no SSH tunnel, no local viewer.+#+# Design: AI-oriented output — every line starts with OK: or ERROR:, errors+# carry a Hint:. Colors only on a TTY (stripped when piped/logged).++set -uo pipefail++HEADVIEW_VERSION="0.1.0"++# ---------------------------------------------------------------- output helpers+if [ -t 1 ]; then C_OK=$'\033[32m\033[1m'; C_CYAN=$'\033[36m'; C_YEL=$'\033[33m'; C_DIM=$'\033[2m'; C_RST=$'\033[0m'+else C_OK=""; C_CYAN=""; C_YEL=""; C_DIM=""; C_RST=""; fi+if [ -t 2 ]; then E_ERR=$'\033[31m\033[1m'; E_DIM=$'\033[2m'; E_RST=$'\033[0m'; else E_ERR=""; E_DIM=""; E_RST=""; fi++ok() { printf '%sOK:%s %s\n' "$C_OK" "$C_RST" "$*"; }+note() { printf '%s%s%s\n' "$C_DIM" "$*" "$C_RST"; }+err() { printf '%sERROR:%s %s\n' "$E_ERR" "$E_RST" "$*" >&2; }+hint() { printf '%sHint:%s %s\n' "$E_DIM" "$E_RST" "$*" >&2; }+die() { err "$1"; [ -n "${2:-}" ] && hint "$2"; exit 1; }++# ---------------------------------------------------------------- state+STATE_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/headview"+STATE_FILE="$STATE_DIR/state.env"+NOVNC_HOME="$HOME/.local/share/novnc" # git-clone fallback web root+mkdir -p "$STATE_DIR"++save_state() { # key value+ mkdir -p "$STATE_DIR"; touch "$STATE_FILE"+ grep -v "^$1=" "$STATE_FILE" 2>/dev/null > "$STATE_FILE.tmp" || true+ echo "$1=$2" >> "$STATE_FILE.tmp"; mv "$STATE_FILE.tmp" "$STATE_FILE"+}+load_state() { [ -f "$STATE_FILE" ] && set -a && . "$STATE_FILE" && set +a || true; }++# ---------------------------------------------------------------- defaults+DISPLAY_NUM=":99"+GEOMETRY="1600x1000"+DEPTH="24"+VNC_PORT="5900"+WEB_PORT="6080"+PREFIX="desktop"+APP=""+USE_PASSWORD="1"++# ---------------------------------------------------------------- deps+have() { command -v "$1" >/dev/null 2>&1; }++novnc_webroot() {+ if [ -f /usr/share/novnc/vnc.html ]; then echo /usr/share/novnc+ elif [ -f "$NOVNC_HOME/vnc.html" ]; then echo "$NOVNC_HOME"+ else echo ""; fi+}++ensure_deps() {+ local need=()+ have Xvfb || need+=(xvfb)+ have x11vnc || need+=(x11vnc)+ have fluxbox || need+=(fluxbox)+ have xdpyinfo|| need+=(x11-utils)+ # noVNC + websockify: prefer apt packages+ if [ -z "$(novnc_webroot)" ] || ! have websockify; then need+=(novnc websockify); fi+ if [ ${#need[@]} -gt 0 ]; then+ note "installing: ${need[*]}"+ if have sudo; then sudo apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "${need[@]}" >/dev/null 2>&1+ else apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "${need[@]}" >/dev/null 2>&1; fi+ fi+ # git-clone fallback if apt didn't provide a web root+ if [ -z "$(novnc_webroot)" ]; then+ note "apt novnc unavailable — cloning noVNC from GitHub"+ have git || die "git not available and noVNC not installed" "sudo apt-get install -y git novnc"+ [ -d "$NOVNC_HOME" ] || git clone --depth 1 -q https://github.com/novnc/noVNC "$NOVNC_HOME" || \+ die "failed to fetch noVNC" "check the container has outbound network to github.com"+ [ -d "$NOVNC_HOME/utils/websockify" ] || git clone --depth 1 -q https://github.com/novnc/websockify "$NOVNC_HOME/utils/websockify" || true+ fi+ for b in Xvfb x11vnc fluxbox; do have "$b" || die "$b still missing after install" "install it manually: sudo apt-get install -y $b"; done+}++# ---------------------------------------------------------------- self slug+container_slug() {+ local j; j="$(adom-cli carbon containers current 2>/dev/null)" || return 1+ printf '%s' "$j" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("slug",""))' 2>/dev/null+}++# find an existing mapped hostname whose internal port == WEB_PORT+existing_hostname_for_port() {+ local port="$1" j+ j="$(adom-cli carbon containers current 2>/dev/null)" || return 1+ printf '%s' "$j" | python3 -c '+import sys,json+p=int(sys.argv[1]); d=json.load(sys.stdin)+for host,port in (d.get("port_mappings") or {}).items():+ if int(port)==p: print(host); break+' "$port" 2>/dev/null+}++start_websockify() {+ local webroot="$1"+ pkill -f "websockify.*:$WEB_PORT" 2>/dev/null; pkill -f "novnc_proxy.*$WEB_PORT" 2>/dev/null; sleep 1+ if have websockify; then+ nohup websockify --web="$webroot" "0.0.0.0:$WEB_PORT" "localhost:$VNC_PORT" >/tmp/headview-web.log 2>&1 &+ else+ nohup "$NOVNC_HOME/utils/novnc_proxy" --vnc "localhost:$VNC_PORT" --listen "$WEB_PORT" >/tmp/headview-web.log 2>&1 &+ fi+}++# ---------------------------------------------------------------- commands+cmd_up() {+ ensure_deps+ local webroot; webroot="$(novnc_webroot)"+ [ -n "$webroot" ] || die "noVNC web root not found" "run: sudo apt-get install -y novnc"++ export DISPLAY="$DISPLAY_NUM"++ # 1) Xvfb+ if ! pgrep -f "Xvfb $DISPLAY_NUM" >/dev/null; then+ nohup Xvfb "$DISPLAY_NUM" -screen 0 "${GEOMETRY}x${DEPTH}" >/tmp/headview-xvfb.log 2>&1 &+ sleep 2+ fi+ pgrep -f "Xvfb $DISPLAY_NUM" >/dev/null || die "Xvfb failed to start" "check /tmp/headview-xvfb.log"++ # 2) window manager+ pgrep -f "fluxbox" >/dev/null || { nohup fluxbox >/tmp/headview-fluxbox.log 2>&1 & sleep 1; }++ # 3) password+ local pw=""+ if [ "$USE_PASSWORD" = "1" ]; then+ pw="${HEADVIEW_PASSWORD:-}"+ if [ -z "$pw" ]; then pw="hv$(od -An -N4 -tx1 /dev/urandom | tr -d ' \n')"; fi+ mkdir -p "$HOME/.vnc"+ x11vnc -storepasswd "$pw" "$HOME/.vnc/passwd" >/dev/null 2>&1+ save_state PASSWORD "$pw"+ else+ save_state PASSWORD ""+ fi++ # 4) x11vnc (loopback only — noVNC is the public face)+ pkill -f "x11vnc.*:$VNC_PORT" 2>/dev/null; pkill -f "x11vnc -display $DISPLAY_NUM" 2>/dev/null; sleep 1+ if [ "$USE_PASSWORD" = "1" ]; then+ nohup x11vnc -display "$DISPLAY_NUM" -localhost -rfbauth "$HOME/.vnc/passwd" -forever -shared -rfbport "$VNC_PORT" -bg >/tmp/headview-x11vnc.log 2>&1+ else+ nohup x11vnc -display "$DISPLAY_NUM" -localhost -nopw -forever -shared -rfbport "$VNC_PORT" -bg >/tmp/headview-x11vnc.log 2>&1+ fi+ sleep 2+ grep -q "Listening for VNC" /tmp/headview-x11vnc.log || die "x11vnc failed to listen on $VNC_PORT" "check /tmp/headview-x11vnc.log"++ # 5) noVNC web bridge+ start_websockify "$webroot"; sleep 3+ curl -s -o /dev/null --max-time 6 "http://127.0.0.1:$WEB_PORT/vnc.html" || die "noVNC not serving on $WEB_PORT" "check /tmp/headview-web.log"++ # 6) port mapping+ local host; host="$(existing_hostname_for_port "$WEB_PORT")"+ if [ -z "$host" ]; then+ local slug; slug="$(container_slug)"+ [ -n "$slug" ] || die "could not determine this container's slug" "run inside an Adom container; check: adom-cli carbon containers current"+ note "adding port mapping ${WEB_PORT} -> ${PREFIX}-*.adom.cloud"+ local out; out="$(adom-cli carbon containers port-add --port "$WEB_PORT" --prefix "$PREFIX" "$slug" 2>&1)" || die "port-add failed: $out" "check adom-cli auth"+ host="$(printf '%s' "$out" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("hostname",""))' 2>/dev/null)"+ fi+ [ -n "$host" ] || die "no mapped hostname" "run: adom-cli carbon containers current # to inspect mappings"++ # 7) optional app+ if [ -n "$APP" ]; then DISPLAY="$DISPLAY_NUM" nohup $APP >/tmp/headview-app.log 2>&1 & note "launched app: $APP"; fi++ save_state DISPLAY_NUM "$DISPLAY_NUM"+ save_state GEOMETRY "$GEOMETRY"+ save_state VNC_PORT "$VNC_PORT"+ save_state WEB_PORT "$WEB_PORT"+ save_state HV_HOST "$host"++ local url="https://$host/vnc.html?autoconnect=1&resize=scale"+ ok "Container desktop is live and hosted by the container itself."+ note " URL: $url"+ [ "$USE_PASSWORD" = "1" ] && note " Password: $pw"+ note " Display: $DISPLAY_NUM (${GEOMETRY}) VNC:$VNC_PORT web:$WEB_PORT"+ note "Open it in any browser, or as a Hydrogen tab: headview open"+ note "Launch apps onto it with: DISPLAY=$DISPLAY_NUM your-app &"+}++cmd_down() {+ load_state+ local removed_map=""+ pkill -f "websockify.*:${WEB_PORT:-6080}" 2>/dev/null+ pkill -f "novnc_proxy" 2>/dev/null+ pkill -f "x11vnc.*:${VNC_PORT:-5900}" 2>/dev/null+ pkill -f "x11vnc -display ${DISPLAY_NUM:-:99}" 2>/dev/null+ pkill -f "fluxbox" 2>/dev/null+ pkill -f "Xvfb ${DISPLAY_NUM:-:99}" 2>/dev/null+ if [ "${1:-}" = "--keep-mapping" ]; then :; else+ if [ -n "${HV_HOST:-}" ]; then+ local slug sub; slug="$(container_slug)"; sub="${HV_HOST%.adom.cloud}"+ if [ -n "$slug" ] && [ -n "$sub" ]; then+ adom-cli carbon containers port-remove "$slug" "$sub" >/dev/null 2>&1 && removed_map=" and removed port mapping ($sub)" || true+ fi+ fi+ fi+ ok "Tore down headview desktop$removed_map."+}++cmd_status() {+ load_state+ local up=0+ printf 'Xvfb: ' ; pgrep -f "Xvfb ${DISPLAY_NUM:-:99}" >/dev/null && { echo running; up=1; } || echo stopped+ printf 'x11vnc: ' ; pgrep -f "x11vnc" >/dev/null && echo running || echo stopped+ printf 'websockify: ' ; pgrep -f "websockify|novnc_proxy" >/dev/null && echo running || echo stopped+ if [ -n "${HV_HOST:-}" ]; then note "URL: https://$HV_HOST/vnc.html?autoconnect=1&resize=scale"; [ -n "${PASSWORD:-}" ] && note "Password: $PASSWORD"; fi+ [ "$up" = 1 ] && ok "headview is up." || ok "headview is not running."+}++cmd_url() {+ load_state+ [ -n "${HV_HOST:-}" ] || die "no desktop is running" "start one with: headview up"+ echo "https://$HV_HOST/vnc.html?autoconnect=1&resize=scale"+}++cmd_password() {+ load_state+ [ -n "${PASSWORD:-}" ] && ok "VNC password: $PASSWORD" || ok "no password set (passwordless mode)"+}++cmd_open() {+ load_state+ [ -n "${HV_HOST:-}" ] || die "no desktop is running" "start one with: headview up"+ [ -n "${VSCODE_PROXY_URI:-}" ] || die "Hydrogen editor not detected (VSCODE_PROXY_URI unset)" "run this from a Hydrogen workspace terminal, or open the URL in a browser: headview url"+ local url="https://$HV_HOST/vnc.html?autoconnect=1&resize=scale"+ adom-cli hydrogen webview open-or-refresh --name "Container Desktop" --url "$url" && ok "Opened 'Container Desktop' webview tab." || die "failed to open webview" "check adom-cli hydrogen webview"+}++cmd_install() {+ local skill_dir="$HOME/.claude/skills/headview"+ mkdir -p "$skill_dir"+ local url="https://wiki.adom.inc/static/apps/headview/SKILL.md"+ if curl -fsSL --max-time 5 "$url" -o "$skill_dir/SKILL.md" 2>/dev/null; then+ ok "Installed headview skill (fetched from wiki)."+ elif [ -f "$(dirname "$0")/SKILL.md" ]; then+ cp "$(dirname "$0")/SKILL.md" "$skill_dir/SKILL.md"; ok "Installed headview skill (bundled copy; wiki unreachable)."+ else+ die "could not install skill" "SKILL.md not found next to the binary and wiki unreachable"+ fi+}++usage() {+ cat <<EOF+headview v$HEADVIEW_VERSION — a browser desktop for a headless Adom container++USAGE: headview <command> [options]++COMMANDS:+ up Start the desktop (Xvfb+fluxbox+x11vnc+noVNC), map a port, print the URL+ down Stop everything and remove the port mapping (--keep-mapping to keep it)+ status Show what's running and the URL/password+ url Print the desktop URL+ password Print the VNC password+ open Open the desktop as a Hydrogen webview tab (needs a Hydrogen terminal)+ install Install the headview skill into ~/.claude/skills/+ version Print version++'up' OPTIONS:+ --display :N X display (default :99)+ --geometry WxH screen size (default 1600x1000)+ --vnc-port N internal VNC port (default 5900)+ --web-port N internal noVNC/web port to map (default 6080)+ --prefix NAME subdomain prefix for the mapping (default desktop)+ --app "CMD" launch CMD on the desktop after starting+ --no-password run passwordless (only do this behind other access control)++EXAMPLES:+ headview up+ headview up --geometry 1920x1080 --app "kicad"+ headview url+ headview down+EOF+}++# ---------------------------------------------------------------- arg parse+cmd="${1:-}"; shift || true+while [ $# -gt 0 ]; do+ case "$1" in+ --display) DISPLAY_NUM="$2"; shift 2;;+ --geometry) GEOMETRY="$2"; shift 2;;+ --vnc-port) VNC_PORT="$2"; shift 2;;+ --web-port) WEB_PORT="$2"; shift 2;;+ --prefix) PREFIX="$2"; shift 2;;+ --app) APP="$2"; shift 2;;+ --no-password) USE_PASSWORD="0"; shift;;+ --keep-mapping) KEEP_MAPPING="--keep-mapping"; shift;;+ *) shift;;+ esac+done++case "$cmd" in+ up) cmd_up;;+ down) cmd_down "${KEEP_MAPPING:-}";;+ status) cmd_status;;+ url) cmd_url;;+ password) cmd_password;;+ open) cmd_open;;+ install) cmd_install;;+ version|--version|-V) ok "headview v$HEADVIEW_VERSION";;+ ""|help|-h|--help) usage;;+ *) err "unknown command: $cmd"; hint "run 'headview help' for usage"; exit 1;;+esac
install.shadded+26@@ -0,0 +1,26 @@+#!/bin/sh+# headview install — put the CLI on PATH. Skills are placed by the wiki installer.+set -e+SRC="$(cd "$(dirname "$0")" && pwd)"++install_to() {+ cp "$SRC/headview" "$1/headview"+ chmod +x "$1/headview"+ echo "installed headview -> $1/headview"+}++if command -v sudo >/dev/null 2>&1 && sudo -n cp "$SRC/headview" /usr/local/bin/headview 2>/dev/null; then+ sudo chmod +x /usr/local/bin/headview+ echo "installed headview -> /usr/local/bin/headview"+elif [ -w /usr/local/bin ]; then+ install_to /usr/local/bin+else+ mkdir -p "$HOME/.local/bin"+ install_to "$HOME/.local/bin"+ case ":$PATH:" in+ *":$HOME/.local/bin:"*) : ;;+ *) echo "note: add \$HOME/.local/bin to your PATH to run headview" ;;+ esac+fi++echo "run: headview up"
package.jsonadded+60@@ -0,0 +1,60 @@+{+ "slug": "headview",+ "title": "Headview",+ "type": "app",+ "version": "0.1.0",+ "bundled": true,+ "description": "A browser desktop for a headless Adom container. Runs Xvfb+fluxbox+x11vnc+noVNC inside the container and maps a *.adom.cloud port so any browser (or a Hydrogen webview tab) shows the container's GUI — no SSH tunnel, no local viewer. The container hosts the page itself.",+ "tags": [+ "cli",+ "desktop",+ "vnc",+ "novnc",+ "xvfb",+ "container",+ "gui",+ "remote-desktop"+ ],+ "dependencies": {},+ "discovery_triggers": [+ "headless desktop",+ "container desktop",+ "view a GUI from my container",+ "see kicad in a browser",+ "run a GUI app in the container",+ "novnc desktop",+ "remote desktop for a container",+ "x11 forwarding doesn't work",+ "browser desktop for headless container"+ ],+ "discovery_pitch": "When a headless container needs a real GUI you can open in a browser (X11 forwarding is blocked by the adom.cloud gateway), reach for headview.",+ "sample_prompts": [+ {+ "label": "See a GUI",+ "prompt": "I want to see a GUI app running in my headless container from my laptop"+ },+ {+ "label": "Container desktop",+ "prompt": "give my headless container a desktop I can open in a browser"+ },+ {+ "label": "KiCad in browser",+ "prompt": "open KiCad running in my container in a browser tab"+ }+ ],+ "metadata": {+ "activate": {+ "kind": "skill",+ "skill": "headview",+ "requires": [],+ "verbPrefixes": []+ }+ },+ "scripts": {+ "install": "./install.sh",+ "uninstall": "./uninstall.sh"+ },+ "hero": {+ "path": "docs/hero.png"+ }+}
uninstall.shadded+12@@ -0,0 +1,12 @@+#!/bin/sh+# headview uninstall — remove the CLI from PATH.+set -e+for d in /usr/local/bin "$HOME/.local/bin"; do+ if [ -e "$d/headview" ]; then+ if [ -w "$d" ]; then rm -f "$d/headview"+ elif command -v sudo >/dev/null 2>&1; then sudo rm -f "$d/headview"+ fi+ echo "removed $d/headview"+ fi+done+echo "uninstalled headview"