#!/usr/bin/env bash # Per-bot X11 screens inside one Team computer. # Slot 0 is DISPLAY :1 / VNC 5900 / view 6080 (primary). # Slot N is DISPLAY :(N+1) / VNC 5900+N / view 6080+N. set -euo pipefail export HOME="${HOME:-/home/lazyboy}" export SHELL=/bin/zsh export TERM="${TERM:-xterm-256color}" export LANG="${LANG:-zh_TW.UTF-8}" export LC_ALL="${LC_ALL:-zh_TW.UTF-8}" export LANGUAGE="${LANGUAGE:-zh_TW:zh:en}" export GTK_MODULES="${GTK_MODULES:-atk-bridge}" export GTK_A11Y="${GTK_A11Y:-atspi}" export GNOME_ACCESSIBILITY="${GNOME_ACCESSIBILITY:-1}" export NO_AT_BRIDGE="${NO_AT_BRIDGE:-0}" export PATH="$HOME/.local/bin:/usr/local/bin:$PATH" ROOT=/tmp/lazyboy LIMIT=8 cmd="${1:-}" shift || true mkdir -p "$ROOT/screens" /tmp/.X11-unix "$HOME/.browser-profiles" hydrate_home() { mkdir -p "$HOME/.config/xfce4/terminal" "$HOME/.cache" "$HOME/.local/bin" if [[ ! -f "$HOME/.zshrc" && -f /usr/share/lazyboy/skel/zshrc ]]; then cp /usr/share/lazyboy/skel/zshrc "$HOME/.zshrc" fi if [[ ! -f "$HOME/.p10k.zsh" && -f /usr/share/lazyboy/skel/p10k.zsh ]]; then cp /usr/share/lazyboy/skel/p10k.zsh "$HOME/.p10k.zsh" fi if [[ ! -f "$HOME/.config/xfce4/terminal/terminalrc" && -f /usr/share/lazyboy/skel/terminalrc ]]; then cp /usr/share/lazyboy/skel/terminalrc "$HOME/.config/xfce4/terminal/terminalrc" fi } hydrate_xfce() { local xfce_home="$1" mkdir -p \ "$xfce_home/.config/xfce4/xfconf/xfce-perchannel-xml" \ "$xfce_home/.config/xfce4/panel" \ "$xfce_home/.cache" \ "$xfce_home/.local/share" \ "$xfce_home/runtime" chmod 700 "$xfce_home/runtime" 2>/dev/null || true local xml="$xfce_home/.config/xfce4/xfconf/xfce-perchannel-xml" local skel_xml=/usr/share/lazyboy/xfce-skel/xfce4/xfconf/xfce-perchannel-xml local f for f in xfce4-panel.xml xfwm4.xml xfce4-desktop.xml thunar.xml; do if [[ -r "$skel_xml/$f" ]]; then cp -f "$skel_xml/$f" "$xml/$f" || true fi done if [[ -r /etc/xdg/xfce4/helpers.rc ]]; then cp -f /etc/xdg/xfce4/helpers.rc "$xfce_home/.config/xfce4/helpers.rc" || true fi # Terminals launched by this desktop (boot, panel, menu) inherit # XDG_CONFIG_HOME=$xfce_home/.config, not $HOME/.config. if [[ -r /usr/share/lazyboy/skel/terminalrc ]]; then mkdir -p "$xfce_home/.config/xfce4/terminal" cp -f /usr/share/lazyboy/skel/terminalrc "$xfce_home/.config/xfce4/terminal/terminalrc" || true fi } wait_display() { local display="$1" local n for n in $(seq 1 100); do if xdpyinfo -display "$display" >/dev/null 2>&1; then return 0 fi sleep 0.1 done return 1 } port_open() { local port="$1" python3 - "$port" <<'PY' import socket, sys port = int(sys.argv[1]) try: s = socket.create_connection(("127.0.0.1", port), 0.2) s.close() except OSError: sys.exit(1) PY } wait_port() { local port="$1" local n for n in $(seq 1 50); do if port_open "$port"; then return 0 fi sleep 0.1 done return 1 } display_backend() { printf '%s' "${LAZYBOY_DISPLAY_BACKEND:-xvfb_x11vnc}" } start_xvfb() { local display="$1" local number="$2" local log="$3" if xdpyinfo -display "$display" >/dev/null 2>&1; then return 0 fi rm -f "/tmp/.X${number}-lock" "/tmp/.X11-unix/X${number}" # T59: keep -ac until Xauthority is given to Cua/AT-SPI/x11vnc in-image. # Public VNC is still the authenticated screen proxy, not a naked 5900. Xvfb "$display" -screen 0 1280x800x24 -ac +extension RANDR +render -noreset >>"${log}-xvfb.log" 2>&1 9>&- & echo $! > "${log}-xvfb.pid" wait_display "$display" } # Candidate A: TigerVNC Xvnc provides X + RFB in one process. rfbport is # explicit so slot 0 stays 5900 even though display is :1. start_xvnc() { local display="$1" local number="$2" local rfb_port="$3" local log="$4" if xdpyinfo -display "$display" >/dev/null 2>&1; then return 0 fi local xvnc="" for c in Xvnc Xtigervnc; do if command -v "$c" >/dev/null 2>&1; then xvnc="$c" break fi done if [[ -z "$xvnc" ]]; then echo "TigerVNC Xvnc is not installed; falling back to Xvfb" >&2 start_xvfb "$display" "$number" "$log" return fi rm -f "/tmp/.X${number}-lock" "/tmp/.X11-unix/X${number}" "$xvnc" "$display" -geometry 1280x800 -depth 24 -rfbport "$rfb_port" -localhost \ -SecurityTypes None >>"${log}-xvnc.log" 2>&1 9>&- & echo $! > "${log}-xvfb.pid" wait_display "$display" } alive_pidfile() { local file="$1" [[ -f "$file" ]] || return 1 local pid pid="$(cat "$file" 2>/dev/null || true)" [[ "$pid" =~ ^[0-9]+$ ]] && (( pid > 0 )) || return 1 kill -0 "$pid" 2>/dev/null || return 1 local stat stat="$(cat "/proc/$pid/stat" 2>/dev/null)" || return 1 stat="${stat##*) }" [[ "${stat%% *}" != Z && "${stat%% *}" != X ]] || return 1 if [[ -n "${2:-}" ]]; then [[ "$(cat "/proc/$pid/comm" 2>/dev/null)" == "$2" ]] || return 1 fi } start_atspi() { local display="$1" local log="$2" local number="${display#:}" if [[ -n "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then printf '%s\n' "$DBUS_SESSION_BUS_ADDRESS" >"$ROOT/screen-${number}.dbus" fi if [[ -n "${XDG_RUNTIME_DIR:-}" ]]; then printf '%s\n' "$XDG_RUNTIME_DIR" >"$ROOT/screen-${number}.runtime" fi local launcher="" registry="" local c for c in /usr/libexec/at-spi-bus-launcher /usr/lib/at-spi2-core/at-spi-bus-launcher at-spi-bus-launcher; do if [[ -x "$c" ]] || command -v "$c" >/dev/null 2>&1; then launcher="$c" break fi done for c in /usr/libexec/at-spi2-registryd /usr/lib/at-spi2-core/at-spi2-registryd at-spi2-registryd; do if [[ -x "$c" ]] || command -v "$c" >/dev/null 2>&1; then registry="$c" break fi done if [[ -n "$launcher" ]] && ! alive_pidfile "${log}-atspi-bus.pid"; then DISPLAY="$display" "$launcher" --launch-immediately >>"${log}-atspi-bus.log" 2>&1 9>&- & echo $! >"${log}-atspi-bus.pid" fi if [[ -n "$registry" ]] && ! alive_pidfile "${log}-atspi-registry.pid"; then DISPLAY="$display" "$registry" >>"${log}-atspi-registry.log" 2>&1 9>&- & echo $! >"${log}-atspi-registry.pid" fi sleep 0.2 } start_desktop() { local display="$1" local xfce_home="$2" local log="$3" if alive_pidfile "${log}-wm.pid" xfwm4 && alive_pidfile "${log}-desktop.pid" xfdesktop && alive_pidfile "${log}-panel.pid" xfce4-panel; then return 0 fi hydrate_xfce "$xfce_home" export DISPLAY="$display" export XDG_CONFIG_HOME="$xfce_home/.config" export XDG_CACHE_HOME="$xfce_home/.cache" export XDG_DATA_HOME="$xfce_home/.local/share" export XDG_RUNTIME_DIR="$xfce_home/runtime" export XDG_CURRENT_DESKTOP=XFCE local number="${display#:}" if [[ -s "$ROOT/screen-${number}.dbus" ]]; then export DBUS_SESSION_BUS_ADDRESS="$(cat "$ROOT/screen-${number}.dbus")" else unset DBUS_SESSION_BUS_ADDRESS fi if [[ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]] || ! dbus-send --session --print-reply --reply-timeout=1000 --dest=org.freedesktop.DBus \ /org/freedesktop/DBus org.freedesktop.DBus.ListNames >/dev/null 2>&1; then eval "$(dbus-launch --sh-syntax)" echo "${DBUS_SESSION_BUS_PID:-}" >"${log}-dbus.pid" fi start_atspi "$display" "$log" if command -v xfconfd >/dev/null 2>&1; then xfconfd --daemon >/dev/null 2>&1 || true fi if command -v xfwm4 >/dev/null 2>&1; then if ! alive_pidfile "${log}-wm.pid" xfwm4; then xfwm4 --compositor=off --display="$display" --sm-client-disable >>"${log}-wm.log" 2>&1 9>&- & echo $! >"${log}-wm.pid" fi sleep 0.3 if ! alive_pidfile "${log}-desktop.pid" xfdesktop; then xfdesktop --disable-wm-check --sm-client-disable >>"${log}-desktop.log" 2>&1 9>&- & echo $! >"${log}-desktop.pid" fi if ! alive_pidfile "${log}-panel.pid" xfce4-panel; then xfce4-panel --disable-wm-check --sm-client-disable >>"${log}-panel.log" 2>&1 9>&- & echo $! >"${log}-panel.pid" fi else echo "XFCE is missing" >&2 return 1 fi local attempt for attempt in $(seq 1 30); do if alive_pidfile "${log}-wm.pid" xfwm4 && alive_pidfile "${log}-desktop.pid" xfdesktop && alive_pidfile "${log}-panel.pid" xfce4-panel; then return 0 fi sleep 0.1 done echo "XFCE services did not become ready on $display" >&2 return 1 } start_vnc() { local display="$1" local vnc_port="$2" local view_port="$3" local log="$4" if ! port_open "$vnc_port"; then x11vnc -display "$display" -forever -shared -nopw -listen 127.0.0.1 -rfbport "$vnc_port" \ -xkb -repeat -cursor arrow -noxdamage -ncache 0 >>"${log}-x11vnc.log" 2>&1 9>&- & fi if ! port_open "$view_port"; then local novnc=/usr/share/novnc if [[ ! -d "$novnc" ]]; then echo "noVNC is missing" >&2 return 1 fi websockify --heartbeat=30 --web="$novnc" "0.0.0.0:${view_port}" "127.0.0.1:${vnc_port}" \ >>"${log}-novnc.log" 2>&1 9>&- & fi wait_port "$vnc_port" wait_port "$view_port" } start_cua_driver() { local display="$1" local log="$2" local number="${display#:}" if ! command -v cua-driver >/dev/null 2>&1; then echo "Cua backend selected but cua-driver is not installed" >&2 return 1 fi local sock="$ROOT/cua-${number}.sock" if [[ "$number" == "1" ]]; then sock="$ROOT/cua.sock"; fi if alive_pidfile "${log}-cua.pid"; then if [[ -S "$sock" ]]; then return 0; fi echo "Cua process exists but its display socket is missing" >&2 return 1 fi export CUA_DRIVER_RS_HOME="$ROOT/cua-home-${number}" export LAZYBOY_CURSOR_COLOR_FILE="$ROOT/screen-${number}.agent-color" if [[ -s "$ROOT/screen-${number}.dbus" ]]; then export DBUS_SESSION_BUS_ADDRESS="$(cat "$ROOT/screen-${number}.dbus")" fi if [[ -s "$ROOT/screen-${number}.runtime" ]]; then export XDG_RUNTIME_DIR="$(cat "$ROOT/screen-${number}.runtime")" fi mkdir -p "$CUA_DRIVER_RS_HOME" cua-driver telemetry disable >>"${log}-cua.log" 2>&1 || true rm -f "$sock" DISPLAY="$display" cua-driver serve \ --grant existing-profile \ --socket "$sock" \ --pid-file "${log}-cua.pid" \ >>"${log}-cua.log" 2>&1 9>&- & local bg=$! # serve does not persist --pid-file in the pinned driver release. printf '%s\n' "$bg" > "${log}-cua.pid" local n for n in $(seq 1 50); do if [[ -S "$sock" ]]; then return 0 fi if ! kill -0 "$bg" 2>/dev/null; then echo "cua-driver serve exited" >&2 cat "${log}-cua.log" >&2 || true return 1 fi sleep 0.1 done echo "cua-driver socket was not ready" >&2 cat "${log}-cua.log" >&2 || true return 1 } start_xterm() { local display="$1" local log="$2" if alive_pidfile "${log}-xterm.pid"; then return 0 fi DISPLAY="$display" SHELL=/bin/zsh lazyboy-terminal >>"${log}-xterm.log" 2>&1 9>&- & echo $! > "${log}-xterm.pid" } start_browser() { local display="$1" local profile="$2" local log="$3" local number="${display#:}" mkdir -p "$profile" "$ROOT" if [[ -n "$profile" ]]; then printf '%s\n' "$profile" >"$ROOT/screen-${number}.profile" fi if pgrep -f "chromium.*--user-data-dir=${profile}" >/dev/null 2>&1; then return 0 fi rm -f "$profile/SingletonLock" "$profile/SingletonCookie" "$profile/SingletonSocket" DISPLAY="$display" HOME="$HOME" LAZYBOY_BROWSER_PROFILE="$profile" \ lazyboy-browser https://duckduckgo.com >>"${log}-browser.log" 2>&1 9>&- & echo $! > "${log}-browser.pid" } # Cosmetic update only: saving a color must not boot or focus a desktop. set_agent_color() { local slot="$1" color="$2" if ! [[ "$slot" =~ ^[0-9]+$ ]] || (( slot < 0 || slot >= LIMIT )); then echo "invalid screen slot" >&2 return 1 fi if ! [[ "$color" =~ ^#[[:xdigit:]]{6}$ ]]; then echo "invalid agent color: expected #RRGGBB" >&2 return 1 fi local number=$((slot + 1)) color_file color_file="$(mktemp "$ROOT/screen-${number}.agent-color.XXXXXX")" printf '%s' "$color" > "$color_file" mv -f "$color_file" "$ROOT/screen-${number}.agent-color" } ensure_slot() { local slot="$1" local profile="${2:-}" local agent_name="${3:-}" local agent_color="${4:-}" if ! [[ "$slot" =~ ^[0-9]+$ ]] || (( slot < 0 || slot >= LIMIT )); then echo "invalid screen slot" >&2 return 1 fi local number=$((slot + 1)) if [[ -n "$agent_color" ]]; then set_agent_color "$slot" "$agent_color" || return 1 fi # Cua renders the public session label beside its synthetic cursor. Keep # the label per display; an atomic replace avoids partial names during calls. if [[ -n "$agent_name" ]]; then local name_file name_file="$(mktemp "$ROOT/screen-${number}.agent-name.XXXXXX")" printf '%s' "$agent_name" > "$name_file" mv -f "$name_file" "$ROOT/screen-${number}.agent-name" fi local display=":${number}" local vnc_port=$((5900 + slot)) local view_port=$((6080 + slot)) local log="$ROOT/screen-${number}" local xfce_home="/tmp/xfce-home" if (( slot != 0 )); then xfce_home="/tmp/xfce-home-${number}" fi mkdir -p "$ROOT" if [[ -n "$profile" ]]; then printf '%s\n' "$profile" >"$ROOT/screen-${number}.profile" fi ( flock -w 25 9 || { echo "screen ${slot} is busy starting" >&2 exit 1 } if [[ "$(display_backend)" == "tigervnc_xvnc" ]]; then start_xvnc "$display" "$number" "$vnc_port" "$log" || { echo "Xvnc failed on ${display}" >&2 cat "${log}-xvnc.log" >&2 || true exit 1 } else start_xvfb "$display" "$number" "$log" || { echo "Xvfb failed on ${display}" >&2 cat "${log}-xvfb.log" >&2 || true exit 1 } fi start_desktop "$display" "$xfce_home" "$log" if [[ "$(display_backend)" != "tigervnc_xvnc" ]] || ! port_open "$vnc_port"; then start_vnc "$display" "$vnc_port" "$view_port" "$log" || exit 1 else if ! port_open "$view_port"; then local novnc=/usr/share/novnc websockify --heartbeat=30 --web="$novnc" "0.0.0.0:${view_port}" "127.0.0.1:${vnc_port}" \ >>"${log}-novnc.log" 2>&1 9>&- & fi wait_port "$vnc_port" wait_port "$view_port" fi start_cua_driver "$display" "$log" || exit 1 start_xterm "$display" "$log" if [[ -n "$profile" ]]; then start_browser "$display" "$profile" "$log" fi if (( slot == 0 )) && [[ -f "${log}-xvfb.pid" ]]; then cp "${log}-xvfb.pid" "$ROOT/xvfb-1.pid" fi printf '{"ok":true,"slot":%s,"display":"%s","viewPort":%s,"vncPort":%s}\n' \ "$slot" "$display" "$view_port" "$vnc_port" ) 9>"$ROOT/screen-${slot}.lock" } boot_primary() { mkdir -p "$HOME" "$HOME/.local/bin" "$HOME/.config" "$HOME/.browser-profiles" "$ROOT" cd "$HOME" hydrate_home printf '這是 Docker 裡的 Debian 桌面。家目錄會保存在 /home/lazyboy。\n' > "$HOME/README.txt" mkdir -p "$HOME/shared" "$HOME/bots" ensure_slot 0 "" } case "$cmd" in color) set_agent_color "${1:-}" "${2:-}" ;; boot-primary) boot_primary ;; ensure) ensure_slot "${1:-0}" "${2:-}" "${3:-}" "${4:-}" ;; *) echo "usage: lazyboy-screen color <#RRGGBB> | boot-primary | ensure [profile] [agent-name] [agent-color]" >&2 exit 2 ;; esac