diff --git a/cmd_chat/server/factory.py b/cmd_chat/server/factory.py index c6d779f..7b48660 100644 --- a/cmd_chat/server/factory.py +++ b/cmd_chat/server/factory.py @@ -24,7 +24,7 @@ def create_app(password: str = "", name: str = "cmd-chat-server") -> Sanic: app.ctx.ws_secret = os.urandom(32) app.ctx.admin_token = secrets.token_hex(16) app.ctx.rate_limiter = RateLimiter(max_requests=10, window_seconds=60) - # Coven capacity. 4 by default; raise via CMD_CHAT_MAX_USERS — infra-for-more, + # Clergy capacity. 4 by default; raise via CMD_CHAT_MAX_USERS — infra-for-more, # the cap is data not architecture (broadcast fan-out is O(N)). app.ctx.max_users = int(os.environ.get("CMD_CHAT_MAX_USERS", "4")) app.ctx.cleanup_task = None diff --git a/cmd_chat/server/views.py b/cmd_chat/server/views.py index a59cba5..df99c0e 100644 --- a/cmd_chat/server/views.py +++ b/cmd_chat/server/views.py @@ -16,7 +16,7 @@ def generate_ws_token(user_id: str, secret: bytes) -> str: def _roster_frame(app: Sanic) -> str: - """Authoritative presence snapshot — all coven members converge on this.""" + """Authoritative presence snapshot — all clergy members converge on this.""" users = app.ctx.session_store.get_all() return json.dumps( { @@ -46,7 +46,7 @@ async def srp_init(request: Request, app: Sanic) -> HTTPResponse: return response.json({"error": "Username taken"}, status=409) if app.ctx.session_store.count() >= app.ctx.max_users: - return response.json({"error": "Coven full"}, status=409) + return response.json({"error": "Clergy full"}, status=409) user_id, B, salt = app.ctx.srp_manager.init_auth(username, client_public) @@ -82,7 +82,7 @@ async def srp_verify(request: Request, app: Sanic) -> HTTPResponse: # Authoritative capacity gate — the slot is only consumed once a session # is actually added here (init is best-effort / racy). if app.ctx.session_store.count() >= app.ctx.max_users: - return response.json({"error": "Coven full"}, status=409) + return response.json({"error": "Clergy full"}, status=409) H_AMK, session_key = app.ctx.srp_manager.verify_auth(user_id, client_proof) @@ -173,7 +173,7 @@ async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None: pass finally: await manager.disconnect(user_id) - # Free the slot + username so the coven can be rejoined (was previously + # Free the slot + username so the clergy can be rejoined (was previously # held until the 1h stale sweep, which also blocked the name). app.ctx.session_store.remove(user_id) await manager.broadcast( diff --git a/hh/Cargo.toml b/hh/Cargo.toml index 0ff169a..296e21b 100644 --- a/hh/Cargo.toml +++ b/hh/Cargo.toml @@ -37,7 +37,7 @@ tar = "0.4" tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } futures-util = "0.3" -ratatui = { version = "0.29", features = ["serde"] } +ratatui = { version = "0.29", features = ["serde", "unstable-rendered-line-info"] } crossterm = { version = "0.28", features = ["event-stream"] } toml = "0.8" diff --git a/hh/direnv-autostart/.envrc b/hh/direnv-autostart/.envrc new file mode 100644 index 0000000..bfc4b00 --- /dev/null +++ b/hh/direnv-autostart/.envrc @@ -0,0 +1,35 @@ +# .envrc — cd into this directory to summon your hack-house ⛧ +# +# Powered by direnv (https://direnv.net). Run ./setup.sh once to install direnv, +# hook your shell, and `direnv allow` this file. +# +# Paths resolve RELATIVE TO THIS FILE — no hardcoded paths, so it works wherever +# the repo is cloned (your own $universal/path/hackerhouse). + +# hh/ lives one level up from this autostart directory. +export HH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$PWD/.envrc}")/.." && pwd)" + +# A single session, joined as the ACTUAL logged-in user (not the alice/bob demo). +export SESSION="${HH_SESSION:-hackerhouse}" +HH_USER="${HH_USER:-${USER:-$(id -un)}}" + +# Mint a strong room password — in MEMORY ONLY, at launch time. Nothing is ever +# written to disk (matches the project's RAM-only secret model) and it is not +# left in your shell environment: it is scoped to the lets-hack child process, +# which boots the server and your client together so they share it. Reveal or +# share it in-app with /pw. Honors a PW you export yourself. +gen_pw() { + if command -v openssl >/dev/null 2>&1; then openssl rand -base64 24 + else head -c 18 /dev/urandom | base64; fi +} + +# Autostart needs tmux. lets-hack.sh boots a fresh server and a single pane for +# $HH_USER, then (inside tmux) switches your client into it. Guard against +# stacking: if the session is already live, just point at it. +if ! command -v tmux >/dev/null 2>&1; then + echo "⛧ install tmux to autostart your hack-house" +elif tmux has-session -t "$SESSION" 2>/dev/null; then + echo "⛧ '$SESSION' already live — tmux attach -t $SESSION (reveal its password in-app with /pw)" +else + PW="${PW:-$(gen_pw)}" "$HH_DIR/lets-hack.sh" "$HH_USER" +fi diff --git a/hh/direnv-autostart/setup.sh b/hh/direnv-autostart/setup.sh new file mode 100755 index 0000000..7ffd6c9 --- /dev/null +++ b/hh/direnv-autostart/setup.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# setup.sh — one-time setup for the hack-house direnv autostart ⛧ +# +# Installs direnv (if missing), hooks your shell, and allows the .envrc in this +# directory so that simply `cd`-ing here launches your hack-house session. +# +# usage: +# ./setup.sh # set up autostart for THIS directory +# ./setup.sh --help +# +# After it finishes: open a new shell (or `source ~/.bashrc`), then `cd` into +# this directory — your single-user clergy boots automatically. +set -uo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" # the autostart dir (holds .envrc) + +case "${1:-}" in + -h|--help|-help) + sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//' + exit 0 ;; + "") : ;; + *) echo "✖ unknown argument: $1 (try --help)" >&2; exit 2 ;; +esac + +# 1. ensure direnv is installed +if command -v direnv >/dev/null 2>&1; then + echo "⛧ direnv already installed ($(direnv version 2>/dev/null))" +elif command -v apt-get >/dev/null 2>&1; then + echo "⛧ installing direnv via apt…" + sudo apt-get update -qq && sudo apt-get install -y direnv +elif command -v brew >/dev/null 2>&1; then + echo "⛧ installing direnv via brew…" + brew install direnv +else + echo "⛧ installing direnv via the official script (→ ~/.local/bin)…" + mkdir -p "$HOME/.local/bin" + export bin_path="$HOME/.local/bin" + curl -sfL https://direnv.net/install.sh | bash +fi +command -v direnv >/dev/null 2>&1 || { echo "✖ direnv install failed — see https://direnv.net/docs/installation.html" >&2; exit 1; } + +# 2. hook direnv into the shell rc (idempotent). Covers bash and zsh. +hook_line_bash='eval "$(direnv hook bash)"' +hook_line_zsh='eval "$(direnv hook zsh)"' +add_hook() { # $1 = rc file, $2 = hook line + local rc="$1" line="$2" + [[ -f "$rc" ]] || return 0 + if grep -qF 'direnv hook' "$rc"; then + echo "⛧ shell hook already present in $rc" + else + printf '\n# direnv (hack-house autostart)\n%s\n' "$line" >> "$rc" + echo "⛧ added direnv hook to $rc" + fi +} +add_hook "$HOME/.bashrc" "$hook_line_bash" +[[ -n "${ZDOTDIR:-}" && -f "$ZDOTDIR/.zshrc" ]] && add_hook "$ZDOTDIR/.zshrc" "$hook_line_zsh" +[[ -f "$HOME/.zshrc" ]] && add_hook "$HOME/.zshrc" "$hook_line_zsh" + +# 3. allow this directory's .envrc +if [[ ! -f "$HERE/.envrc" ]]; then + echo "✖ no .envrc in $HERE — is this the autostart directory?" >&2 + exit 1 +fi +direnv allow "$HERE" +echo "⛧ allowed $HERE/.envrc" + +echo +echo "⛧ done. open a new shell (or: source ~/.bashrc), then: cd $HERE" +echo " your single-user hack-house boots automatically (fresh server, generated password)." diff --git a/hh/downloads/hh-payload.txt b/hh/downloads/hh-payload.txt index 35cffdb..43e065c 100644 --- a/hh/downloads/hh-payload.txt +++ b/hh/downloads/hh-payload.txt @@ -1 +1 @@ -secret offering to the coven — marker XYZ123 +secret offering to the clergy — marker XYZ123 diff --git a/hh/ensure-docker.sh b/hh/ensure-docker.sh new file mode 100755 index 0000000..ace6a75 --- /dev/null +++ b/hh/ensure-docker.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# ensure-docker.sh — make sure the Docker daemon is up before /sbx launch docker. +# +# Without this, `docker run` fails with "Cannot connect to the Docker daemon" +# and the sandbox launch dies with a raw error. This script detects a dead +# daemon and — after confirmation — starts it, then waits until it's accepting +# connections. +# +# usage: +# ./ensure-docker.sh # interactive: prompt before starting the daemon +# ./ensure-docker.sh --yes # start without prompting (used by hack-house --start) +# ./ensure-docker.sh --check # test only; exit 0 if up, 1 if down (no changes) +set -uo pipefail + +ASSUME_YES=0 +CHECK_ONLY=0 +for arg in "$@"; do + case "$arg" in + -y|--yes) ASSUME_YES=1 ;; + --check) CHECK_ONLY=1 ;; + -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "✖ unknown arg: $arg" >&2; exit 2 ;; + esac +done + +daemon_up() { docker info >/dev/null 2>&1; } + +if daemon_up; then + [[ $CHECK_ONLY -eq 1 ]] || echo "⛧ docker daemon already running" >&2 + exit 0 +fi +[[ $CHECK_ONLY -eq 1 ]] && exit 1 + +if ! command -v docker >/dev/null 2>&1; then + echo "✖ docker is not installed — install Docker first" >&2 + exit 127 +fi + +# Work out how to start the daemon on this platform. +start_cmd="" +need_sudo=0 +case "$(uname -s)" in + Linux) + if command -v systemctl >/dev/null 2>&1; then + start_cmd="systemctl start docker"; need_sudo=1 + elif command -v service >/dev/null 2>&1; then + start_cmd="service docker start"; need_sudo=1 + fi + ;; + Darwin) + # Docker Desktop: opening the app boots the daemon VM. + start_cmd="open -a Docker" + ;; +esac + +if [[ -z "$start_cmd" ]]; then + echo "✖ don't know how to start the docker daemon here — start it manually" >&2 + exit 1 +fi +[[ $need_sudo -eq 1 ]] && start_cmd="sudo $start_cmd" + +# Confirmation (skipped with --yes). +if [[ $ASSUME_YES -ne 1 ]]; then + printf '⛧ docker daemon is not running. Start it with "%s"? [y/N] ' "$start_cmd" >&2 + read -r reply + case "$reply" in + y|Y|yes|YES) ;; + *) echo "✖ aborted — docker daemon left stopped" >&2; exit 1 ;; + esac +fi + +echo "⛧ starting docker daemon: $start_cmd" >&2 +eval "$start_cmd" || { echo "✖ failed to start docker daemon (sudo password needed? run it in a terminal)" >&2; exit 1; } + +# Wait for it to accept connections (Desktop / a fresh VM can take a while). +for _ in $(seq 1 60); do + daemon_up && { echo "⛧ docker daemon is up ⛧" >&2; exit 0; } + sleep 1 +done + +echo "✖ docker daemon did not come up in time" >&2 +exit 1 diff --git a/hh/lets-hack.sh b/hh/lets-hack.sh new file mode 100755 index 0000000..aac5f1a --- /dev/null +++ b/hh/lets-hack.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +# lets-hack.sh — spin up a local hack-house test clergy in tmux ⛧ +# +# Builds the client, makes sure a --no-tls server is running, then opens one +# tmux pane per user (default: alice bob) and attaches. Each pane is a real +# TTY, so the ratatui UI is fully interactive. +# +# usage: +# ./lets-hack.sh # alice + bob on 127.0.0.1:4173 +# ./lets-hack.sh neo trinity dozer # one pane per name (tiled) +# ./lets-hack.sh --reuse # keep an already-live server (don't reboot) +# ./lets-hack.sh --theme neon # dress every pane in themes/neon.toml +# THEME=crypt ./lets-hack.sh # same, via env (church | neon | crypt) +# PORT=4200 PW=hunter2 ./lets-hack.sh # throwaway server you can kill/restart +# ./lets-hack.sh --kill # tear the test setup down +# ./lets-hack.sh -h # full usage (also --help / -help) +# +# A fresh server is booted on every run by default (an existing one on PORT is +# stopped first), so you never inherit stale message history. Pass --reuse to +# keep a live server — useful for reconnect tests (Ctrl-R rejoins after --kill). +# +# Run from inside tmux: the clergy is built in its own session and your client is +# switched to it (switch-client). Run from a plain shell: the script attaches. +# Either way you land in the clergy — no manual `tmux attach` needed. +# +# Reconnect test: use a dedicated port (e.g. PORT=4200) so `--kill` can stop +# the server; press Ctrl-R in a pane after the drop to rejoin. +set -uo pipefail + +usage() { + cat </dev/null | grep -q '"status":"ok"'; } + +# Stop the server on $PORT: the one we started (pidfile) and any process still +# holding the port (covers an untracked/stale server). Used by --kill / --fresh. +stop_server() { + if [[ -f "$SRV_PIDFILE" ]]; then + kill "$(cat "$SRV_PIDFILE")" 2>/dev/null && echo "⛧ stopped tracked server (pid $(cat "$SRV_PIDFILE"))" + rm -f "$SRV_PIDFILE" + fi + local pid + pid="$(ss -ltnpH "sport = :$PORT" 2>/dev/null | grep -oP 'pid=\K[0-9]+' | head -1)" + if [[ -n "$pid" ]]; then + kill "$pid" 2>/dev/null && echo "⛧ stopped server holding :$PORT (pid $pid)" + fi +} + +# Parse flags and collect usernames (flags may appear anywhere). +# REUSE=1 keeps an already-live server; the default is to boot a fresh one. +REUSE="${REUSE:-0}" +DO_KILL=0 +USERS=() +want_theme=0 # set when --theme consumed the next arg as its value +for a in "$@"; do + if [[ $want_theme -eq 1 ]]; then THEME="$a"; want_theme=0; continue; fi + case "$a" in + -h|--help|-help) usage; exit 0 ;; + --kill) DO_KILL=1 ;; + --reuse) REUSE=1 ;; + --fresh) REUSE=0 ;; # explicit fresh server (this is the default) + --theme) want_theme=1 ;; + --theme=*) THEME="${a#--theme=}" ;; + -*) echo "✖ unknown flag: $a (try --reuse / --kill / --theme / --help)" >&2; exit 2 ;; + *) USERS+=("$a") ;; + esac +done +[[ $want_theme -eq 1 ]] && { echo "✖ --theme needs a name (e.g. --theme neon)" >&2; exit 2; } + +# --kill: tear down the tmux session and the server we started. (Done before +# theme resolution so teardown never depends on a valid --theme.) +if [[ $DO_KILL -eq 1 ]]; then + tmux kill-session -t "$SESSION" 2>/dev/null && echo "⛧ killed tmux session $SESSION" + stop_server + exit 0 +fi + +# Resolve a theme name → TOML path, or empty for the client's built-in default. +THEME_PATH="" +if [[ -n "$THEME" ]]; then + THEME_PATH="$THEMES_DIR/$THEME.toml" + if [[ ! -f "$THEME_PATH" ]]; then + avail="$(cd "$THEMES_DIR" 2>/dev/null && ls -1 *.toml 2>/dev/null | sed 's/\.toml$//' | paste -sd' ' -)" + echo "✖ no theme '$THEME' in $THEMES_DIR (available: ${avail:-none})" >&2 + exit 2 + fi +fi + +[[ ${#USERS[@]} -eq 0 ]] && USERS=(alice bob) + +# 1. build the client +echo "⛧ building client…" +( cd "$HERE" && cargo build --quiet ) || { echo "✖ build failed"; exit 1; } + +# 2. (re)boot the server on $PORT. Default behaviour: always create a *fresh* +# server — if one is already live we stop it first so we never inherit stale +# message history / ghost users. Pass --reuse to keep an existing live server +# (handy for reconnect tests where the server must outlive a client restart). +if [[ $REUSE -eq 1 ]] && is_up; then + echo "⛧ --reuse: keeping server already live on $HOST:$PORT" +else + if is_up; then + echo "⛧ replacing server already live on $HOST:$PORT…" + stop_server + for _ in $(seq 1 20); do is_up || break; sleep 0.5; done + is_up && { echo "✖ could not free port $PORT — is another process holding it?"; exit 1; } + fi + echo "⛧ booting fresh server on $HOST:$PORT…" + "$PY" "$ROOT/cmd_chat.py" serve "$HOST" "$PORT" --password "$PW" --no-tls >"$SRV_LOG" 2>&1 & + echo $! > "$SRV_PIDFILE" + for _ in $(seq 1 20); do is_up && break; sleep 1; done + is_up || { echo "✖ server did not come up — see $SRV_LOG"; exit 1; } + echo "⛧ server up (pid $(cat "$SRV_PIDFILE"), log $SRV_LOG)" +fi + +# 3. (re)build the tmux session: one pane per user, tiled. +# +# The client is launched as each pane's OWN command (run by tmux via `sh -c`), +# never with `send-keys`. send-keys raced the interactive shell's startup — on a +# shell with heavy rc init (conda/pyenv/nvm) the keystrokes land mid-init and get +# swallowed, leaving blank panes. Running it as the pane command sidesteps the +# shell entirely; the trailing `read` keeps the pane open so an auth error (e.g. +# name already taken) stays on screen instead of the pane vanishing. +launch_cmd() { # $1 = username → command string for the pane's `sh -c` + local theme_arg="" + [[ -n "$THEME_PATH" ]] && theme_arg="$(printf -- '--theme %q' "$THEME_PATH")" + printf '%q connect %q %q %q --password %q --no-tls %s; ec=$?; printf "\n⛧ %s left the house (exit %%s) — press enter to close\n" "$ec"; read _' \ + "$BIN" "$HOST" "$PORT" "$1" "$PW" "$theme_arg" "$1" +} + +# Guard: if we're attached to a tmux session with this exact name, the +# kill-session below would tear down the session we're sitting in and drop us to +# a bare shell ("closing" tmux). Refuse with a clear fix instead of yanking it. +if [[ -n "${TMUX:-}" && "$(tmux display-message -p '#S' 2>/dev/null)" == "$SESSION" ]]; then + echo "✖ you're inside the tmux session '$SESSION' — rebuilding it would close it." >&2 + echo " use a different name (e.g. SESSION=hh-test $0 ${USERS[*]}) or run --kill from another window." >&2 + exit 2 +fi + +tmux kill-session -t "$SESSION" 2>/dev/null +tmux new-session -d -s "$SESSION" -x 220 -y 50 -c "$HERE" "$(launch_cmd "${USERS[0]}")" +for ((i = 1; i < ${#USERS[@]}; i++)); do + tmux split-window -h -t "$SESSION" -c "$HERE" "$(launch_cmd "${USERS[i]}")" + tmux select-layout -t "$SESSION" tiled >/dev/null +done +tmux select-layout -t "$SESSION" tiled >/dev/null + +echo "⛧ clergy: ${USERS[*]} · session: $SESSION · $HOST:$PORT · vestments: ${THEME:-church (default)}" +echo "⛧ tear down later with: $0 --kill" + +# 4. land in the clergy. From a plain shell we replace this process with `attach`. +# From inside tmux we can't nest an attach, so switch the current client to +# the new session — that actually drops you into the clergy instead of leaving +# it orphaned with a hint you have to act on yourself. +if [[ -z "${TMUX:-}" ]]; then + exec tmux attach -t "$SESSION" +else + echo "⛧ inside tmux — switching this client to '$SESSION' (detach with Ctrl-b d)" + tmux switch-client -t "$SESSION" +fi diff --git a/hh/src/app.rs b/hh/src/app.rs index cf07c23..5e7c25e 100644 --- a/hh/src/app.rs +++ b/hh/src/app.rs @@ -8,7 +8,10 @@ use crate::ui; use anyhow::Result; use base64::engine::general_purpose::STANDARD; use base64::Engine; -use crossterm::event::{Event, EventStream, KeyCode, KeyEventKind, KeyModifiers}; +use crossterm::event::{ + DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEventKind, + KeyModifiers, MouseEventKind, +}; use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, @@ -67,7 +70,7 @@ pub enum Net { SbxInput { from: String, bytes: Vec }, Perm { owner: String, drivers: Vec, sudoers: Vec }, Ft(ft::Ft), - Sys(String), + Err(String), Closed, } @@ -97,6 +100,12 @@ pub struct App { pub sbx_scroll: usize, /// Whether the help overlay is showing. pub show_help: bool, + /// A reconnect handshake is in flight (Ctrl-R after a disconnect). + pub reconnecting: bool, + /// Transient error shown as a popup over the clergy (cleared on next keypress). + pub error: Option, + /// The room password this client authenticated with (shown by `/pw`). + pub password: String, } impl App { @@ -118,6 +127,9 @@ impl App { chat_scroll: 0, sbx_scroll: 0, show_help: false, + reconnecting: false, + error: None, + password: String::new(), } } @@ -152,6 +164,15 @@ impl App { }); } + /// Surface an error: kept in chat scrollback for history AND shown as a + /// popup over the clergy so it can't bleed onto / be overwritten at the + /// input box. Dismissed by the next keypress. + fn err(&mut self, text: impl Into) { + let t = text.into(); + self.sys(format!("✖ {t}")); + self.error = Some(t); + } + fn apply(&mut self, n: Net) { match n { Net::Init { lines, users } => { @@ -160,7 +181,7 @@ impl App { self.connected = true; self.chat_scroll = 0; self.sys(format!("joined as {} ⛧", self.me)); - self.sys("/sbx launch · /drive (Esc releases) · /send · PgUp/PgDn scroll chat · ctrl-q quit"); + self.sys("/sbx launch · /drive (Esc releases) · /send · /pw show password · PgUp/PgDn scroll chat · ctrl-q quit"); } Net::Message(l) => self.push_line(l), Net::Roster { users, capacity } => { @@ -222,10 +243,10 @@ impl App { self.sudoers = sudo; } Net::Ft(_) => {} // handled in the run loop (needs out channel + disk) - Net::Sys(t) => self.sys(t), + Net::Err(t) => self.err(t), Net::Closed => { self.connected = false; - self.sys("connection closed"); + self.sys("connection closed — press Ctrl-R to reconnect"); } } } @@ -237,6 +258,16 @@ fn sbx_dims(term_w: u16, term_h: u16) -> (u16, u16) { (sbx_h.saturating_sub(2).max(1), term_w.saturating_sub(2).max(1)) } +/// One page of sandbox scrollback = the visible grid height (defaults to 10 if +/// no sandbox is up). The run loop clamps `sbx_scroll` to the grid height anyway. +fn sbx_page(app: &App) -> usize { + app.sandbox + .as_ref() + .map(|v| v.parser.screen().size().0 as usize) + .unwrap_or(10) + .max(1) +} + fn key_to_pty(code: KeyCode, mods: KeyModifiers) -> Option> { match code { KeyCode::Char(c) => { @@ -271,7 +302,7 @@ fn broadcast_acl(out: &UnboundedSender, room: &fernet::Fernet, app: &App) })); } -/// Stream a payload to the coven as `_ft` chunks (background, paced). +/// Stream a payload to the clergy as `_ft` chunks (background, paced). fn spawn_send(id: String, payload: Arc>, out: UnboundedSender, room: Arc) { tokio::spawn(async move { for (seq, chunk) in payload.chunks(ft::CHUNK).enumerate() { @@ -332,11 +363,11 @@ fn handle_ft( if let Some(t) = app.transfers.remove(&id) { if t.accepted { if ft::sha256_hex(&t.buf) != t.meta.sha256 { - app.sys(format!("✖ {} — SHA-256 mismatch, discarded", t.meta.name)); + app.err(format!("{} — SHA-256 mismatch, discarded", t.meta.name)); } else { match ft::save(downloads, &t.meta, &t.buf) { Ok(p) => app.sys(format!("⛧ saved {} ({}) — verified ✓", p.display(), ft::human(t.buf.len()))), - Err(e) => app.sys(format!("save failed: {e}")), + Err(e) => app.err(format!("save failed: {e}")), } } } @@ -348,12 +379,12 @@ fn handle_ft( } } -pub async fn run(session: Session, theme: Theme) -> Result<()> { - let ws = net::connect(&session).await?; - let (mut write, read) = ws.split(); +pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme) -> Result<()> { let (tx, mut rx) = unbounded_channel::(); let app_tx = tx.clone(); - tokio::spawn(net::reader(read, session.room.clone(), tx)); + let mut write = net::open(&session, tx.clone()).await?; + // Carries the result of a background reconnect handshake back to the loop. + let (recon_tx, mut recon_rx) = unbounded_channel::>(); // All outgoing frames funnel through here so background tasks (file chunks, // PTY relay) can transmit without owning the socket. @@ -370,18 +401,25 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> { enable_raw_mode()?; let mut stdout = std::io::stdout(); - execute!(stdout, EnterAlternateScreen)?; + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let mut term = Terminal::new(CrosstermBackend::new(stdout))?; let mut app = App::new(session.username.clone()); + app.password = params.password.clone(); let mut events = EventStream::new(); let mut tick = tokio::time::interval(Duration::from_millis(50)); let result = loop { // Apply the sandbox scrollback offset (0 = follow live). - let sbs = app.sbx_scroll; + // + // vt100 0.15.2 panics ("subtract with overflow" in grid::visible_rows) + // if the scrollback offset ever exceeds the visible grid height: it does + // `rows_len - offset` on usize without clamping. Cap our offset to the + // grid height so a fast scroll can never cross that line and crash us. if let Some(v) = &mut app.sandbox { - v.parser.set_scrollback(sbs); + let rows = v.parser.screen().size().0 as usize; + app.sbx_scroll = app.sbx_scroll.min(rows); + v.parser.set_scrollback(app.sbx_scroll); } if let Err(e) = term.draw(|f| ui::draw(f, &app, &theme)) { break Err(e.into()); @@ -405,10 +443,30 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> { maybe = events.next() => { match maybe { Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => { + app.error = None; // any keypress dismisses the error popup if k.modifiers.contains(KeyModifiers::CONTROL) && matches!(k.code, KeyCode::Char('q')) { break Ok(()); } - if app.show_help { + if k.modifiers.contains(KeyModifiers::CONTROL) + && matches!(k.code, KeyCode::Char('r')) + && !app.connected + { + // Reconnect: re-run the SRP handshake off-thread so the UI + // stays responsive, then re-attach the websocket on success. + if !app.reconnecting { + app.reconnecting = true; + app.sys("⛧ reconnecting…"); + let p = params.clone(); + let rtx = recon_tx.clone(); + tokio::task::spawn_blocking(move || { + let r = net::authenticate( + &p.ip, p.port, &p.user, &p.password, p.no_tls, p.insecure, + ) + .map_err(|e| e.to_string()); + let _ = rtx.send(r); + }); + } + } else if app.show_help { app.show_help = false; // any key dismisses the overlay } else if k.code == KeyCode::F(1) { app.show_help = true; // F1 from any mode @@ -422,6 +480,12 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> { } else if app.driving { if k.code == KeyCode::Esc { app.driving = false; + } else if k.code == KeyCode::PageUp { + // Scroll the shared shell's scrollback without releasing the + // drive: PgUp/PgDn aren't forwarded to the PTY anyway. + app.sbx_scroll = (app.sbx_scroll + sbx_page(&app)).min(2000); + } else if k.code == KeyCode::PageDown { + app.sbx_scroll = app.sbx_scroll.saturating_sub(sbx_page(&app)); } else if let Some(bytes) = key_to_pty(k.code, k.modifiers) { if let Some(sb) = &mut broker { // I own the sandbox: write straight to the PTY — instant, @@ -437,7 +501,7 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> { let line = app.input.trim().to_string(); app.input.clear(); app.chat_scroll = 0; // jump back to live on send - handle_command(&line, &mut app, &mut active_send, &mut send_seq, + handle_command(&line, &mut app, &mut theme, &mut active_send, &mut send_seq, &mut broker, &mut broker_meta, &mut launching, &mut announced_dims, &out_tx, &pty_tx, &broker_tx, &app_tx, &session, &term); } @@ -476,6 +540,27 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> { } } } + Some(Ok(Event::Mouse(m))) => { + // Mouse wheel scrolls the sandbox terminal if one is up + // (incl. while driving), otherwise the chat — mirrors ↑/↓. + match m.kind { + MouseEventKind::ScrollUp => { + if app.sandbox.is_some() { + app.sbx_scroll = (app.sbx_scroll + 3).min(2000); + } else { + app.chat_scroll = (app.chat_scroll + 3).min(app.lines.len().saturating_sub(1)); + } + } + MouseEventKind::ScrollDown => { + if app.sandbox.is_some() { + app.sbx_scroll = app.sbx_scroll.saturating_sub(3); + } else { + app.chat_scroll = app.chat_scroll.saturating_sub(3); + } + } + _ => {} + } + } Some(Err(e)) => break Err(e.into()), _ => {} } @@ -529,6 +614,36 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> { None => {} } } + recon = recon_rx.recv() => { + if let Some(result) = recon { + app.reconnecting = false; + match result { + Ok(s) => { + session = s; + match net::open(&session, tx.clone()).await { + Ok(w) => { + write = w; + app.sys("⛧ websocket re-attached — syncing…"); + // If we host the sandbox, re-announce it so the + // rest of the house re-syncs the shared shell. + if let Some((be, _)) = &broker_meta { + if let Some(v) = &app.sandbox { + let (rows, cols) = v.parser.screen().size(); + send_frame(&out_tx, &session.room, json!({ + "_sbx":"status","state":"ready", + "backend": be.label(),"rows": rows,"cols": cols + })); + broadcast_acl(&out_tx, &session.room, &app); + } + } + } + Err(e) => app.err(format!("reconnect failed: {e}")), + } + } + Err(e) => app.sys(format!("reconnect failed: {e}")), + } + } + } pty = pty_rx.recv() => { if let Some(mut bytes) = pty { // Coalesce a burst (e.g. `tree`) into one frame: fewer round-trips, @@ -571,7 +686,7 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> { } } disable_raw_mode()?; - execute!(term.backend_mut(), LeaveAlternateScreen)?; + execute!(term.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?; term.show_cursor()?; result } @@ -585,6 +700,7 @@ enum BrokerMsg { fn handle_command( line: &str, app: &mut App, + theme: &mut Theme, active_send: &mut Option, send_seq: &mut u64, broker: &mut Option, @@ -601,6 +717,32 @@ fn handle_command( let room = &session.room; if line == "/help" || line == "/?" { app.show_help = true; + } else if line == "/pw" || line == "/password" { + // Show the room password locally (never broadcast). Handy when the + // server's password was autogenerated and you need to read it off / share + // it out-of-band to invite someone into the room. + if app.password.is_empty() { + app.sys("⛧ no room password (joined without one)"); + } else { + app.sys(format!("⛧ room password: {}", app.password)); + } + } else if let Some(rest) = line.strip_prefix("/theme") { + // Live vestment switch: `/theme `, or bare `/theme` to list options. + let name = rest.trim(); + if name.is_empty() { + app.sys(format!("vestments: {} — /theme ", Theme::available().join(" · "))); + } else { + match Theme::by_name(name) { + Ok(t) => { + *theme = t; + app.sys(format!("donned the {name} vestments")); + } + Err(_) => app.err(format!( + "no theme '{name}' — try: {}", + Theme::available().join(" · ") + )), + } + } } else if line == "/drive" { // Mobile-friendly alternative to F2 (no function key needed). if app.sandbox.is_none() { @@ -624,7 +766,7 @@ fn handle_command( })); app.sys(format!("offered {} ({}) — waiting for an /accept", name, ft::human(size))); } - Err(e) => app.sys(format!("send failed: {e}")), + Err(e) => app.err(format!("send failed: {e}")), } } else if line == "/accept" { if let Some(o) = app.pending_offer.take() { @@ -651,15 +793,25 @@ fn handle_command( if app.sandbox.is_some() || broker.is_some() || *launching { app.sys("a sandbox is already running"); } else { - let backend = p.next().and_then(sbx::Backend::parse).unwrap_or(sbx::Backend::Local); - let image = p.next().map(str::to_string).unwrap_or_else(|| backend.default_image().to_string()); - let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24)); - let (rows, cols) = sbx_dims(sz.0, sz.1); - *launching = true; - let members: Vec = app.users.iter().map(|u| u.username.clone()).collect(); - app.sys(format!("summoning {} sandbox… (provisioning unix users; multipass boot ~30s)", backend.label())); - spawn_launch(backend, image, app.me.clone(), members, rows, cols, - pty_tx.clone(), broker_tx.clone(), app_tx.clone()); + // `--start` (alias `--start-daemon` / `-y`) opts in to booting + // a stopped Docker daemon; everything else is positional. + let args: Vec<&str> = p.collect(); + let start_daemon = args.iter().any(|a| matches!(*a, "--start" | "--start-daemon" | "-y")); + let mut pos = args.iter().copied().filter(|a| !a.starts_with('-')); + let backend = pos.next().and_then(sbx::Backend::parse).unwrap_or(sbx::Backend::Local); + let image = pos.next().map(str::to_string).unwrap_or_else(|| backend.default_image().to_string()); + + if backend == sbx::Backend::Docker && !start_daemon && !sbx::docker_daemon_up() { + app.err("docker daemon is not running — retry with `/sbx launch docker --start` to boot it (sudo), or run ./ensure-docker.sh in a terminal first"); + } else { + let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24)); + let (rows, cols) = sbx_dims(sz.0, sz.1); + *launching = true; + let members: Vec = app.users.iter().map(|u| u.username.clone()).collect(); + app.sys(format!("summoning {} sandbox… (provisioning unix users; multipass boot ~30s)", backend.label())); + spawn_launch(backend, image, app.me.clone(), members, rows, cols, start_daemon, + pty_tx.clone(), broker_tx.clone(), app_tx.clone()); + } } } Some("stop") => { @@ -743,6 +895,7 @@ fn spawn_launch( members: Vec, rows: u16, cols: u16, + start_daemon: bool, pty_tx: UnboundedSender>, broker_tx: UnboundedSender, app_tx: UnboundedSender, @@ -751,10 +904,10 @@ fn spawn_launch( let name = SBX_NAME.to_string(); let prep = { let (n, img) = (name.clone(), image.clone()); - tokio::task::spawn_blocking(move || sbx::prepare(backend, &n, &img)).await + tokio::task::spawn_blocking(move || sbx::prepare(backend, &n, &img, start_daemon)).await }; if let Err(e) = prep.unwrap_or_else(|e| Err(anyhow::anyhow!("join: {e}"))) { - let _ = app_tx.send(Net::Sys(format!("sandbox prepare failed: {e}"))); + let _ = app_tx.send(Net::Err(format!("sandbox prepare failed: {e}"))); let _ = broker_tx.send(BrokerMsg::Failed); return; } @@ -778,7 +931,7 @@ fn spawn_launch( let _ = broker_tx.send(BrokerMsg::Ready { sb, backend, name, rows, cols }); } Err(e) => { - let _ = app_tx.send(Net::Sys(format!("sandbox launch failed: {e}"))); + let _ = app_tx.send(Net::Err(format!("sandbox launch failed: {e}"))); let _ = broker_tx.send(BrokerMsg::Failed); } } diff --git a/hh/src/crypto.rs b/hh/src/crypto.rs index 204aa61..be17b16 100644 --- a/hh/src/crypto.rs +++ b/hh/src/crypto.rs @@ -28,7 +28,7 @@ A099ED8193E0757767A13DD52312AB4B03310DCD7F48A9DA04FD50E8083969EDB767B0CF60\ 60279004E57AE6AF874E7303CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8E9DB\ FBB694B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F9E4AFF73"; -/// The SRP identity used by every cmd-chat / coven room (server hardcodes this). +/// The SRP identity used by every cmd-chat / clergy room (server hardcodes this). /// The user's chosen display name is independent of this value. pub const SRP_IDENTITY: &[u8] = b"chat"; diff --git a/hh/src/ft.rs b/hh/src/ft.rs index 9d04315..7178543 100644 --- a/hh/src/ft.rs +++ b/hh/src/ft.rs @@ -152,7 +152,7 @@ mod tests { let dir = std::env::temp_dir().join(format!("hh-ft-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let src = dir.join("note.txt"); - std::fs::write(&src, b"offering to the coven").unwrap(); + std::fs::write(&src, b"offering to the clergy").unwrap(); let (name, bytes, is_dir) = read_payload(src.to_str().unwrap()).unwrap(); assert_eq!(name, "note.txt"); @@ -161,7 +161,7 @@ mod tests { sha256: sha256_hex(&bytes), dir: false, from: "x".into() }; let dl = dir.join("dl"); let out = save(&dl, &offer, &bytes).unwrap(); - assert_eq!(std::fs::read(&out).unwrap(), b"offering to the coven"); + assert_eq!(std::fs::read(&out).unwrap(), b"offering to the clergy"); std::fs::remove_dir_all(&dir).ok(); } diff --git a/hh/src/main.rs b/hh/src/main.rs index 17a1ca3..df25b3e 100644 --- a/hh/src/main.rs +++ b/hh/src/main.rs @@ -84,11 +84,19 @@ fn main() -> Result<()> { theme, } => { let session = net::authenticate(&ip, port, &user, &password, no_tls, insecure)?; + let params = net::ConnParams { + ip, + port, + user, + password, + no_tls, + insecure, + }; let theme = match theme { Some(p) => theme::Theme::load(&p)?, None => theme::Theme::default(), }; - tokio::runtime::Runtime::new()?.block_on(app::run(session, theme)) + tokio::runtime::Runtime::new()?.block_on(app::run(params, session, theme)) } Cmd::Roomkey { password, room_salt_hex } => { let salt = hex::decode(room_salt_hex)?; diff --git a/hh/src/net.rs b/hh/src/net.rs index 369244f..152e7bf 100644 --- a/hh/src/net.rs +++ b/hh/src/net.rs @@ -24,6 +24,21 @@ pub struct Session { pub insecure: bool, } +/// The credentials needed to (re)authenticate a Session — kept so the UI can +/// re-run the SRP handshake and rejoin after a disconnect (AFK / server blip). +#[derive(Clone)] +pub struct ConnParams { + pub ip: String, + pub port: u16, + pub user: String, + pub password: String, + pub no_tls: bool, + pub insecure: bool, +} + +/// The write half of a split websocket; outgoing frames are sent here. +pub type WsSink = futures_util::stream::SplitSink; + /// Full SRP handshake against the Sanic server. Returns a ready Session /// (room key derived, ws url built) but does not open the websocket. pub fn authenticate( @@ -99,6 +114,15 @@ pub async fn connect(session: &Session) -> Result { Ok(ws) } +/// Open the websocket for a session, spawn the reader task feeding `tx`, and +/// hand back the write half. Used for the initial connect and every reconnect. +pub async fn open(session: &Session, tx: UnboundedSender) -> Result { + let ws = connect(session).await?; + let (write, read) = ws.split(); + tokio::spawn(reader(read, session.room.clone(), tx)); + Ok(write) +} + fn parse_users(v: &Value) -> Vec { v.as_array() .into_iter() diff --git a/hh/src/sbx.rs b/hh/src/sbx.rs index 17673fd..c31bd59 100644 --- a/hh/src/sbx.rs +++ b/hh/src/sbx.rs @@ -2,16 +2,46 @@ //! //! The broker (owner's client) spawns a sandbox shell inside a PTY. Output bytes //! are pumped out of a reader thread onto an mpsc channel; the broker encrypts -//! them with the room key and relays them to the coven as `sbx pty_data` frames. +//! them with the room key and relays them to the clergy as `sbx pty_data` frames. //! Input frames (`sbx pty_input`) are written back into the PTY. The server only //! ever sees ciphertext — identical trust model to chat/file transfer. use anyhow::{Context, Result}; use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; use std::io::{Read, Write}; -use std::process::Command; +use std::process::{Command, Stdio}; use std::sync::mpsc; +/// Helper that ensures the Docker daemon is running (ships beside this source). +const ENSURE_DOCKER: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/ensure-docker.sh"); + +/// Is the Docker daemon accepting connections? (`docker info` succeeds.) +pub fn docker_daemon_up() -> bool { + Command::new("docker") + .arg("info") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Start the Docker daemon via `ensure-docker.sh --yes`, waiting until it's +/// ready. Returns the script's last error line on failure (e.g. needs sudo). +fn start_docker_daemon() -> Result<()> { + let out = Command::new("bash") + .arg(ENSURE_DOCKER) + .arg("--yes") + .output() + .context("running ensure-docker.sh")?; + if !out.status.success() { + let err = String::from_utf8_lossy(&out.stderr); + let last = err.lines().last().unwrap_or("could not start the docker daemon"); + anyhow::bail!("{last}"); + } + Ok(()) +} + /// Which sandbox to summon. Multipass = strong isolation (default for real use), /// Docker = fast, Local = no isolation (dev/testing only). #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -50,7 +80,7 @@ impl Backend { /// One-time setup before the PTY shell is spawned. Blocking — run off the UI /// thread (Multipass boots a real VM, ~20-30s). Idempotent: reuses an instance /// that already exists. -pub fn prepare(backend: Backend, name: &str, image: &str) -> Result<()> { +pub fn prepare(backend: Backend, name: &str, image: &str, start_daemon: bool) -> Result<()> { match backend { Backend::Local => Ok(()), Backend::Multipass => { @@ -60,24 +90,48 @@ pub fn prepare(backend: Backend, name: &str, image: &str) -> Result<()> { .map(|o| o.status.success()) .unwrap_or(false); if !exists { - let st = Command::new("multipass") + // Capture output so it can't bleed onto the TUI surface; surface + // the failure reason through the returned error instead. + let out = Command::new("multipass") .args(["launch", "--name", name, "--cpus", "1", "--memory", "1G", "--disk", "5G", image]) - .status() + .output() .context("multipass launch (is multipass installed?)")?; - anyhow::ensure!(st.success(), "multipass launch failed"); + if !out.status.success() { + let err = String::from_utf8_lossy(&out.stderr); + anyhow::bail!("multipass launch failed: {}", err.lines().last().unwrap_or("").trim()); + } } else { - let _ = Command::new("multipass").args(["start", name]).status(); + let _ = Command::new("multipass").args(["start", name]) + .stdout(Stdio::null()).stderr(Stdio::null()).status(); } Ok(()) } Backend::Docker => { + // The daemon must be up before any `docker` call. Rather than fail + // with a raw connection error, start it (the caller confirmed via + // `/sbx launch docker --start`). + if !docker_daemon_up() { + if start_daemon { + start_docker_daemon().context("starting docker daemon")?; + } else { + anyhow::bail!( + "docker daemon is not running — retry with `/sbx launch docker --start`" + ); + } + } // Persistent container so we can exec in to provision users + shells. - let _ = Command::new("docker").args(["rm", "-f", name]).status(); - let st = Command::new("docker") + let _ = Command::new("docker").args(["rm", "-f", name]) + .stdout(Stdio::null()).stderr(Stdio::null()).status(); + // Capture output so a failure can't paint over the TUI; the reason is + // surfaced through the returned error (shown in the error popup). + let out = Command::new("docker") .args(["run", "-d", "--name", name, "--hostname", name, "-w", "/root", image, "sleep", "infinity"]) - .status() + .output() .context("docker run (is docker installed?)")?; - anyhow::ensure!(st.success(), "docker run failed"); + if !out.status.success() { + let err = String::from_utf8_lossy(&out.stderr); + anyhow::bail!("docker run failed: {}", err.lines().last().unwrap_or("").trim()); + } Ok(()) } } @@ -88,10 +142,12 @@ pub fn prepare(backend: Backend, name: &str, image: &str) -> Result<()> { pub fn teardown(backend: Backend, name: &str) { match backend { Backend::Multipass => { - let _ = Command::new("multipass").args(["delete", name, "--purge"]).status(); + let _ = Command::new("multipass").args(["delete", name, "--purge"]) + .stdout(Stdio::null()).stderr(Stdio::null()).status(); } Backend::Docker => { - let _ = Command::new("docker").args(["rm", "-f", name]).status(); + let _ = Command::new("docker").args(["rm", "-f", name]) + .stdout(Stdio::null()).stderr(Stdio::null()).status(); } Backend::Local => {} } @@ -125,7 +181,7 @@ fn command_for(backend: Backend, name: &str, run_user: &str) -> CommandBuilder { } } -/// Sanitize a coven display name into a safe unix username. +/// Sanitize a clergy display name into a safe unix username. pub fn unix_name(name: &str) -> String { let s: String = name .to_lowercase() @@ -139,12 +195,15 @@ pub fn unix_name(name: &str) -> String { fn mp(name: &str, args: &[&str]) { let mut a = vec!["exec", name, "--"]; a.extend_from_slice(args); - let _ = Command::new("multipass").args(a).status(); + // Null stdio so provisioning chatter never bleeds onto the TUI surface. + let _ = Command::new("multipass").args(a) + .stdout(Stdio::null()).stderr(Stdio::null()).status(); } fn dk(name: &str, args: &[&str]) { let mut a = vec!["exec", name]; a.extend_from_slice(args); - let _ = Command::new("docker").args(a).status(); + let _ = Command::new("docker").args(a) + .stdout(Stdio::null()).stderr(Stdio::null()).status(); } /// Grant a Multipass user real *passwordless* sudo (group + sudoers.d drop-in) @@ -160,7 +219,7 @@ fn mp_revoke_sudo(name: &str, u: &str) { mp(name, &["sudo", "bash", "-c", &script]); } -/// Provision a real unix account per coven member inside the VM/container and +/// Provision a real unix account per clergy member inside the VM/container and /// make the owner a superuser (sudoer). Returns the unix user the shared shell /// should run as. Blocking — call off the UI thread. pub fn provision(backend: Backend, name: &str, owner: &str, members: &[String]) -> String { @@ -219,7 +278,7 @@ pub struct Sandbox { impl Sandbox { /// Spawn the backend in a PTY. A reader thread pushes raw output bytes onto - /// `out`; the caller relays them (encrypted) to the coven. + /// `out`; the caller relays them (encrypted) to the clergy. pub fn launch( backend: Backend, name: &str, diff --git a/hh/src/theme.rs b/hh/src/theme.rs index b5ca485..b3dab25 100644 --- a/hh/src/theme.rs +++ b/hh/src/theme.rs @@ -2,9 +2,14 @@ //! occult-monochrome: black ground, white/grey ink, ⛧ accents. Override with a //! TOML file via `--theme `. +use anyhow::Context; use ratatui::style::Color; use serde::Deserialize; +/// Where the bundled `*.toml` vestments live, so `/theme ` can resolve a +/// bare name to a file at runtime (mirrors sbx.rs's script path const). +pub const THEMES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/themes"); + #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct Theme { @@ -18,8 +23,14 @@ pub struct Theme { pub system: Color, pub input: Color, pub roster_me: Color, + /// Panel/background fill. `Reset` = the terminal's own background (no fill); + /// a solid colour lifts the text onto a contrasting surface for legibility. + pub bg: Color, /// Width of the roster column. pub roster_width: u16, + /// Glyph flanking the "hack-house" title (and used for occult accents). + /// Defaults to ⛧ (inverted pentagram); themes can pick their own sigil. + pub sigil: String, } impl Default for Theme { @@ -37,7 +48,9 @@ impl Default for Theme { system: Color::Rgb(0xb4, 0x6c, 0xff), // system / occult = purple input: Color::Rgb(0x39, 0xff, 0x14), roster_me: Color::Rgb(0xff, 0x39, 0xc0), // you / owner = hot magenta + bg: Color::Reset, // ride the terminal's own black roster_width: 22, + sigil: "⛧".into(), // inverted pentagram } } } @@ -47,6 +60,30 @@ impl Theme { let s = std::fs::read_to_string(path)?; Ok(toml::from_str(&s)?) } + + /// Resolve a bare vestment name (e.g. "neon") to a bundled `themes/.toml`. + /// Used by the in-session `/theme` command for live switching. + pub fn by_name(name: &str) -> anyhow::Result { + let path = format!("{THEMES_DIR}/{name}.toml"); + Self::load(&path).with_context(|| format!("theme '{name}' ({path})")) + } + + /// Names of the bundled vestments, sorted, for `/theme` with no argument. + pub fn available() -> Vec { + let mut names: Vec = std::fs::read_dir(THEMES_DIR) + .into_iter() + .flatten() + .flatten() + .filter_map(|e| { + e.file_name() + .to_str() + .and_then(|f| f.strip_suffix(".toml")) + .map(str::to_string) + }) + .collect(); + names.sort(); + names + } } #[cfg(test)] diff --git a/hh/src/ui.rs b/hh/src/ui.rs index d4487fc..a9af15f 100644 --- a/hh/src/ui.rs +++ b/hh/src/ui.rs @@ -9,6 +9,11 @@ use ratatui::widgets::{Block, Clear, List, ListItem, Paragraph, Wrap}; use ratatui::Frame; pub fn draw(f: &mut Frame, app: &App, theme: &Theme) { + // Paint the whole frame in the theme background first so every panel (and the + // gaps between them) sits on the same surface. With bg = Reset this is a no-op + // and we ride the terminal's own colour. + f.render_widget(Block::default().style(Style::default().bg(theme.bg)), f.area()); + let rows = Layout::vertical([ Constraint::Length(1), Constraint::Min(1), @@ -39,6 +44,35 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) { if app.show_help { draw_help(f, f.area(), theme); } + if let Some(msg) = &app.error { + draw_error(f, f.area(), theme, msg); + } +} + +/// Transient error popup, anchored top-right over the clergy so it never bleeds +/// onto the input box. Cleared by the next keypress (see the run loop). +fn draw_error(f: &mut Frame, area: Rect, theme: &Theme, msg: &str) { + let text = format!("⚠ {msg}"); + let w = area.width.saturating_sub(2).min(48).max(16); + let inner = w.saturating_sub(2).max(1); + let rows = (text.chars().count() as u16).div_ceil(inner) + 2; + let h = rows.min(area.height.saturating_sub(2)).max(3); + let x = area.x + area.width.saturating_sub(w + 1); // hug the right edge + let y = area.y + 1; // just under the top bar, over the clergy + let rect = Rect { x, y, width: w, height: h }; + f.render_widget(Clear, rect); + let popup = Paragraph::new(text) + .style(Style::default().fg(theme.title).bg(theme.bg)) + .block( + Block::bordered() + .border_style(Style::default().fg(theme.accent).add_modifier(Modifier::BOLD)) + .title(Span::styled( + format!(" {} error · any key ", theme.sigil), + Style::default().fg(theme.accent).add_modifier(Modifier::BOLD), + )), + ) + .wrap(Wrap { trim: false }); + f.render_widget(popup, rect); } fn centered(percent_x: u16, percent_y: u16, area: Rect) -> Rect { @@ -68,9 +102,10 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) { Span::styled(v.to_string(), dim), ]) }; - let head = |s: &str| Line::from(Span::styled(s.to_string(), acc)); + let sig = &theme.sigil; + let head = |s: &str| Line::from(Span::styled(format!("{sig} {s}"), acc)); let lines = vec![ - head("⛧ COMMANDS (type in the input bar)"), + head("COMMANDS (type in the input bar)"), kv("/sbx launch [backend]", "summon a sandbox: local | docker | multipass"), kv("/sbx stop", "tear down the sandbox (purges the VM)"), kv("/drive", "type into the shared shell (Esc releases)"), @@ -81,18 +116,22 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) { kv("/send ", "offer a file to the room"), kv("/sendd ", "offer a directory (sent as a tar)"), kv("/accept · /reject", "respond to an incoming file offer"), + kv("/theme [name]", "change vestments live: church | neon | crypt"), + kv("/pw", "show this room's password (local only)"), kv("/help", "show / hide this menu"), Line::from(""), - head("⛧ KEYS"), + head("KEYS"), kv("Enter", "send chat message"), kv("F1 · /help", "toggle this help (any key closes it)"), kv("F2 · /drive", "take the shell · Esc releases it"), kv("Ctrl-C (while driving)", "interrupt the running command"), kv("PgUp / PgDn", "scroll chat · Home/End = oldest/live"), - kv("Up / Down", "scroll the sandbox terminal (when not driving)"), + kv("PgUp / PgDn (driving)", "scroll the sandbox terminal's scrollback"), + kv("Up / Down · wheel", "scroll the sandbox terminal (mouse works while driving)"), + kv("Ctrl-R (when closed)", "reconnect to the house after a drop / AFK"), kv("Ctrl-Q", "quit hack-house"), Line::from(""), - head("⛧ ROSTER GLYPHS"), + head("ROSTER GLYPHS"), kv("⛧ owner ⚡ sudoer", "◆ may drive • member"), Line::from(""), Line::from(Span::styled( @@ -103,11 +142,12 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) { let w = centered(78, 90, area); f.render_widget(Clear, w); let help = Paragraph::new(lines) + .style(Style::default().bg(theme.bg)) // fill the popup with the theme surface .block( Block::bordered() .border_style(Style::default().fg(theme.accent)) .title(Span::styled( - " ⛧ hack-house — help ⛧ ", + format!(" {0} hack-house — help {0} ", theme.sigil), Style::default().fg(theme.title).add_modifier(Modifier::BOLD), )), ) @@ -123,12 +163,14 @@ fn draw_sandbox(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &T .rows(0, cols) .map(|r| Line::from(Span::styled(r, Style::default().fg(theme.title)))) .collect(); - let drive = if app.driving { - " · DRIVING — type here · Esc to release".to_string() + let drive = if app.driving && app.sbx_scroll > 0 { + format!(" · DRIVING · ↑{} scrollback (PgDn=live)", app.sbx_scroll) + } else if app.driving { + " · DRIVING — type here · Esc · PgUp/wheel scroll".to_string() } else if app.sbx_scroll > 0 { format!(" · ↑{} scrollback (↓/End=live)", app.sbx_scroll) } else { - " · /drive (or F2) · ↑/↓ scroll".to_string() + " · /drive (or F2) · ↑/↓/wheel scroll".to_string() }; let title = format!(" sandbox · {}{} ", sv.backend, drive); let border = if app.driving { theme.accent } else { theme.border }; @@ -142,10 +184,16 @@ fn draw_sandbox(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &T fn draw_top(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) { let cap = if app.capacity > 0 { app.capacity } else { app.users.len() }; - let status = if app.connected { "🔒 e2e" } else { "✖ closed" }; + let status = if app.connected { + "🔒 e2e" + } else if app.reconnecting { + "… reconnecting" + } else { + "✖ closed · Ctrl-R to reconnect" + }; let bar = Line::from(vec![ Span::styled( - " ⛧ hack-house ⛧ ", + format!(" {0} hack-house {0} ", theme.sigil), Style::default().fg(theme.accent).add_modifier(Modifier::BOLD), ), Span::styled(format!("· {status} "), Style::default().fg(theme.dim)), @@ -177,12 +225,20 @@ fn fmt_line<'a>(l: &'a ChatLine, app: &App, theme: &Theme) -> Line<'a> { } fn draw_chat(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) { - let visible = area.height.saturating_sub(2) as usize; - let len = app.lines.len(); - // Window ends `chat_scroll` lines above the live bottom. - let end = len.saturating_sub(app.chat_scroll); - let start = end.saturating_sub(visible); - let lines: Vec = app.lines[start..end].iter().map(|l| fmt_line(l, app, theme)).collect(); + let inner_h = area.height.saturating_sub(2) as usize; // rows inside the border + let text_w = area.width.saturating_sub(2).max(1); // wrap width inside the border + let lines: Vec = app.lines.iter().map(|l| fmt_line(l, app, theme)).collect(); + + // Measure the TRUE wrapped height and scroll to the bottom. Selecting N + // logical lines and letting the Paragraph top-anchor them clips any wrapped + // line off the bottom — so the newest messages stay hidden until later ones + // push them up into view (worse when the sandbox shrinks the chat pane). + let total_rows = Paragraph::new(lines.clone()) + .wrap(Wrap { trim: false }) + .line_count(text_w); + let max_scroll = total_rows.saturating_sub(inner_h); + let scroll = max_scroll.saturating_sub(app.chat_scroll) as u16; + let title = if app.chat_scroll > 0 { format!(" chat ↑{} (End=live) ", app.chat_scroll) } else { @@ -194,7 +250,8 @@ fn draw_chat(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Them .border_style(Style::default().fg(theme.border)) .title(Span::styled(title, Style::default().fg(theme.title))), ) - .wrap(Wrap { trim: false }); + .wrap(Wrap { trim: false }) + .scroll((scroll, 0)); f.render_widget(chat, area); } @@ -225,7 +282,7 @@ fn draw_roster(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Th let roster = List::new(items).block( Block::bordered() .border_style(Style::default().fg(theme.border)) - .title(Span::styled(" coven ", Style::default().fg(theme.title))), + .title(Span::styled(" clergy ", Style::default().fg(theme.title))), ); f.render_widget(roster, area); } diff --git a/hh/test-features.sh b/hh/test-features.sh new file mode 100755 index 0000000..0adf456 --- /dev/null +++ b/hh/test-features.sh @@ -0,0 +1,230 @@ +#!/usr/bin/env bash +# test-features.sh — drive a headless hack-house clergy via `tmux send-keys` and +# assert each feature by scraping the rendered TUI with `tmux capture-pane`. +# +# It boots a --no-tls server, opens an OWNER pane and a MEMBER (bob) pane in a +# detached tmux session, fires deterministic keystrokes, and greps the captured +# screen for the state markers the UI paints (DRIVING, scrollback, chat ↑, …). +# +# ./test-features.sh # run the suite, leave the session up to inspect +# ./test-features.sh --kill # tear the session + server down +# PORT=4199 PW=test-bless ./test-features.sh +# +# While it runs you can watch live in another terminal: +# tmux attach -t hh-autotest +set -uo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" # .../hh +ROOT="$(cd "$HERE/.." && pwd)" # repo root +PY="$ROOT/.venv/bin/python" +BIN="$HERE/target/debug/hack-house" + +SESSION="${SESSION:-hh-autotest}" +HOST="${HOST:-127.0.0.1}" +PORT="${PORT:-4199}" +PW="${PW:-test-bless}" +SRV_LOG="/tmp/hh-${SESSION}-server.log" +SRV_PIDFILE="/tmp/hh-${SESSION}-server.pid" + +is_up() { curl -s --max-time 2 "http://$HOST:$PORT/health" 2>/dev/null | grep -q '"status":"ok"'; } + +stop_server() { + if [[ -f "$SRV_PIDFILE" ]]; then + kill "$(cat "$SRV_PIDFILE")" 2>/dev/null && echo "⛧ stopped server (pid $(cat "$SRV_PIDFILE"))" + rm -f "$SRV_PIDFILE" + fi +} + +if [[ "${1:-}" == "--kill" || "${1:-}" == "--teardown" ]]; then + tmux kill-session -t "$SESSION" 2>/dev/null && echo "⛧ killed tmux $SESSION" || echo "⛧ no tmux session" + stop_server + exit 0 +fi + +# ─── result tracking ────────────────────────────────────────────────────────── +PASS=0; FAIL=0 +declare -a RESULTS=() + +cap() { tmux capture-pane -pt "$1" 2>/dev/null; } + +# want