feat(hh): /pw command, RAM-only direnv autostart, robust lets-hack; coven→clergy
- add /pw (alias /password): reveal this room's password locally (never broadcast); surfaced in the F1 help overlay and the join hint - direnv-autostart/: cd-to-launch a single real-user session via direnv; password is minted in memory at launch (never written to disk, matching the RAM-only model) and scoped to the child process. setup.sh installs direnv, hooks bash/zsh, and `direnv allow`s the dir - lets-hack.sh: boot a FRESH server by default (replacing any live one) with a --reuse opt-out; add -h/--help/-help; guard against killing the tmux session you're attached to; switch-client into the coven when run inside tmux - rename coven→clergy across rust/python/scripts; tests/test_coven.py→test_clergy.py - snapshots in-progress hack-house client work (sandbox, themes, net, ui) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8e6365a649
commit
5de493e895
|
|
@ -24,7 +24,7 @@ def create_app(password: str = "", name: str = "cmd-chat-server") -> Sanic:
|
||||||
app.ctx.ws_secret = os.urandom(32)
|
app.ctx.ws_secret = os.urandom(32)
|
||||||
app.ctx.admin_token = secrets.token_hex(16)
|
app.ctx.admin_token = secrets.token_hex(16)
|
||||||
app.ctx.rate_limiter = RateLimiter(max_requests=10, window_seconds=60)
|
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)).
|
# 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.max_users = int(os.environ.get("CMD_CHAT_MAX_USERS", "4"))
|
||||||
app.ctx.cleanup_task = None
|
app.ctx.cleanup_task = None
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ def generate_ws_token(user_id: str, secret: bytes) -> str:
|
||||||
|
|
||||||
|
|
||||||
def _roster_frame(app: Sanic) -> 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()
|
users = app.ctx.session_store.get_all()
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
{
|
{
|
||||||
|
|
@ -46,7 +46,7 @@ async def srp_init(request: Request, app: Sanic) -> HTTPResponse:
|
||||||
return response.json({"error": "Username taken"}, status=409)
|
return response.json({"error": "Username taken"}, status=409)
|
||||||
|
|
||||||
if app.ctx.session_store.count() >= app.ctx.max_users:
|
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)
|
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
|
# Authoritative capacity gate — the slot is only consumed once a session
|
||||||
# is actually added here (init is best-effort / racy).
|
# is actually added here (init is best-effort / racy).
|
||||||
if app.ctx.session_store.count() >= app.ctx.max_users:
|
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)
|
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
|
pass
|
||||||
finally:
|
finally:
|
||||||
await manager.disconnect(user_id)
|
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).
|
# held until the 1h stale sweep, which also blocked the name).
|
||||||
app.ctx.session_store.remove(user_id)
|
app.ctx.session_store.remove(user_id)
|
||||||
await manager.broadcast(
|
await manager.broadcast(
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ tar = "0.4"
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] }
|
||||||
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
||||||
futures-util = "0.3"
|
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"] }
|
crossterm = { version = "0.28", features = ["event-stream"] }
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
|
|
||||||
|
|
|
||||||
35
hh/direnv-autostart/.envrc
Normal file
35
hh/direnv-autostart/.envrc
Normal file
|
|
@ -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
|
||||||
69
hh/direnv-autostart/setup.sh
Executable file
69
hh/direnv-autostart/setup.sh
Executable file
|
|
@ -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)."
|
||||||
|
|
@ -1 +1 @@
|
||||||
secret offering to the coven — marker XYZ123
|
secret offering to the clergy — marker XYZ123
|
||||||
|
|
|
||||||
82
hh/ensure-docker.sh
Executable file
82
hh/ensure-docker.sh
Executable file
|
|
@ -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
|
||||||
209
hh/lets-hack.sh
Executable file
209
hh/lets-hack.sh
Executable file
|
|
@ -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 <<EOF
|
||||||
|
lets-hack.sh — spin up a local hack-house test clergy in tmux ⛧
|
||||||
|
|
||||||
|
usage:
|
||||||
|
./lets-hack.sh [USERS...] [--theme NAME] [--fresh]
|
||||||
|
./lets-hack.sh --kill
|
||||||
|
./lets-hack.sh -h | --help
|
||||||
|
|
||||||
|
arguments:
|
||||||
|
USERS... one tmux pane per name (default: alice bob)
|
||||||
|
|
||||||
|
flags:
|
||||||
|
--theme NAME dress every pane in themes/NAME.toml (church | neon | crypt)
|
||||||
|
--reuse keep an already-live server instead of booting a fresh one
|
||||||
|
--fresh force a fresh server (the default — kept for clarity)
|
||||||
|
--kill tear down the tmux session and the server we started
|
||||||
|
-h, --help show this help and exit
|
||||||
|
|
||||||
|
note: by default a *fresh* server is booted every run; an existing one on PORT
|
||||||
|
is stopped first so you never inherit stale history. Use --reuse to keep it.
|
||||||
|
|
||||||
|
environment (override any default):
|
||||||
|
SESSION tmux session name (default: hh-test)
|
||||||
|
HOST server bind host (default: 127.0.0.1)
|
||||||
|
PORT server port (default: 4173)
|
||||||
|
PW shared room password (default: malware-bless)
|
||||||
|
THEME theme name (church | neon | crypt)
|
||||||
|
|
||||||
|
examples:
|
||||||
|
./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 --theme neon # neon vestments for every pane
|
||||||
|
PORT=4200 PW=hunter2 ./lets-hack.sh # throwaway server you can kill/restart
|
||||||
|
./lets-hack.sh --kill # tear the test setup down
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
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-test}"
|
||||||
|
HOST="${HOST:-127.0.0.1}"
|
||||||
|
PORT="${PORT:-4173}"
|
||||||
|
PW="${PW:-malware-bless}"
|
||||||
|
SRV_LOG="/tmp/hh-${SESSION}-server.log"
|
||||||
|
SRV_PIDFILE="/tmp/hh-${SESSION}-server.pid"
|
||||||
|
THEMES_DIR="$HERE/themes"
|
||||||
|
THEME="${THEME:-}" # name (church|neon|crypt) or empty for built-in default
|
||||||
|
|
||||||
|
is_up() { curl -s --max-time 2 "http://$HOST:$PORT/health" 2>/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
|
||||||
215
hh/src/app.rs
215
hh/src/app.rs
|
|
@ -8,7 +8,10 @@ use crate::ui;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use base64::engine::general_purpose::STANDARD;
|
use base64::engine::general_purpose::STANDARD;
|
||||||
use base64::Engine;
|
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::execute;
|
||||||
use crossterm::terminal::{
|
use crossterm::terminal::{
|
||||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||||
|
|
@ -67,7 +70,7 @@ pub enum Net {
|
||||||
SbxInput { from: String, bytes: Vec<u8> },
|
SbxInput { from: String, bytes: Vec<u8> },
|
||||||
Perm { owner: String, drivers: Vec<String>, sudoers: Vec<String> },
|
Perm { owner: String, drivers: Vec<String>, sudoers: Vec<String> },
|
||||||
Ft(ft::Ft),
|
Ft(ft::Ft),
|
||||||
Sys(String),
|
Err(String),
|
||||||
Closed,
|
Closed,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,6 +100,12 @@ pub struct App {
|
||||||
pub sbx_scroll: usize,
|
pub sbx_scroll: usize,
|
||||||
/// Whether the help overlay is showing.
|
/// Whether the help overlay is showing.
|
||||||
pub show_help: bool,
|
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<String>,
|
||||||
|
/// The room password this client authenticated with (shown by `/pw`).
|
||||||
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
|
|
@ -118,6 +127,9 @@ impl App {
|
||||||
chat_scroll: 0,
|
chat_scroll: 0,
|
||||||
sbx_scroll: 0,
|
sbx_scroll: 0,
|
||||||
show_help: false,
|
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<String>) {
|
||||||
|
let t = text.into();
|
||||||
|
self.sys(format!("✖ {t}"));
|
||||||
|
self.error = Some(t);
|
||||||
|
}
|
||||||
|
|
||||||
fn apply(&mut self, n: Net) {
|
fn apply(&mut self, n: Net) {
|
||||||
match n {
|
match n {
|
||||||
Net::Init { lines, users } => {
|
Net::Init { lines, users } => {
|
||||||
|
|
@ -160,7 +181,7 @@ impl App {
|
||||||
self.connected = true;
|
self.connected = true;
|
||||||
self.chat_scroll = 0;
|
self.chat_scroll = 0;
|
||||||
self.sys(format!("joined as {} ⛧", self.me));
|
self.sys(format!("joined as {} ⛧", self.me));
|
||||||
self.sys("/sbx launch · /drive (Esc releases) · /send <file> · PgUp/PgDn scroll chat · ctrl-q quit");
|
self.sys("/sbx launch · /drive (Esc releases) · /send <file> · /pw show password · PgUp/PgDn scroll chat · ctrl-q quit");
|
||||||
}
|
}
|
||||||
Net::Message(l) => self.push_line(l),
|
Net::Message(l) => self.push_line(l),
|
||||||
Net::Roster { users, capacity } => {
|
Net::Roster { users, capacity } => {
|
||||||
|
|
@ -222,10 +243,10 @@ impl App {
|
||||||
self.sudoers = sudo;
|
self.sudoers = sudo;
|
||||||
}
|
}
|
||||||
Net::Ft(_) => {} // handled in the run loop (needs out channel + disk)
|
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 => {
|
Net::Closed => {
|
||||||
self.connected = false;
|
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))
|
(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<Vec<u8>> {
|
fn key_to_pty(code: KeyCode, mods: KeyModifiers) -> Option<Vec<u8>> {
|
||||||
match code {
|
match code {
|
||||||
KeyCode::Char(c) => {
|
KeyCode::Char(c) => {
|
||||||
|
|
@ -271,7 +302,7 @@ fn broadcast_acl(out: &UnboundedSender<WsMsg>, 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<Vec<u8>>, out: UnboundedSender<WsMsg>, room: Arc<fernet::Fernet>) {
|
fn spawn_send(id: String, payload: Arc<Vec<u8>>, out: UnboundedSender<WsMsg>, room: Arc<fernet::Fernet>) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
for (seq, chunk) in payload.chunks(ft::CHUNK).enumerate() {
|
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 let Some(t) = app.transfers.remove(&id) {
|
||||||
if t.accepted {
|
if t.accepted {
|
||||||
if ft::sha256_hex(&t.buf) != t.meta.sha256 {
|
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 {
|
} else {
|
||||||
match ft::save(downloads, &t.meta, &t.buf) {
|
match ft::save(downloads, &t.meta, &t.buf) {
|
||||||
Ok(p) => app.sys(format!("⛧ saved {} ({}) — verified ✓", p.display(), ft::human(t.buf.len()))),
|
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<()> {
|
pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme) -> Result<()> {
|
||||||
let ws = net::connect(&session).await?;
|
|
||||||
let (mut write, read) = ws.split();
|
|
||||||
let (tx, mut rx) = unbounded_channel::<Net>();
|
let (tx, mut rx) = unbounded_channel::<Net>();
|
||||||
let app_tx = tx.clone();
|
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::<std::result::Result<Session, String>>();
|
||||||
|
|
||||||
// All outgoing frames funnel through here so background tasks (file chunks,
|
// All outgoing frames funnel through here so background tasks (file chunks,
|
||||||
// PTY relay) can transmit without owning the socket.
|
// PTY relay) can transmit without owning the socket.
|
||||||
|
|
@ -370,18 +401,25 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
||||||
|
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
let mut stdout = std::io::stdout();
|
let mut stdout = std::io::stdout();
|
||||||
execute!(stdout, EnterAlternateScreen)?;
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||||
let mut term = Terminal::new(CrosstermBackend::new(stdout))?;
|
let mut term = Terminal::new(CrosstermBackend::new(stdout))?;
|
||||||
|
|
||||||
let mut app = App::new(session.username.clone());
|
let mut app = App::new(session.username.clone());
|
||||||
|
app.password = params.password.clone();
|
||||||
let mut events = EventStream::new();
|
let mut events = EventStream::new();
|
||||||
let mut tick = tokio::time::interval(Duration::from_millis(50));
|
let mut tick = tokio::time::interval(Duration::from_millis(50));
|
||||||
|
|
||||||
let result = loop {
|
let result = loop {
|
||||||
// Apply the sandbox scrollback offset (0 = follow live).
|
// 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 {
|
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)) {
|
if let Err(e) = term.draw(|f| ui::draw(f, &app, &theme)) {
|
||||||
break Err(e.into());
|
break Err(e.into());
|
||||||
|
|
@ -405,10 +443,30 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
||||||
maybe = events.next() => {
|
maybe = events.next() => {
|
||||||
match maybe {
|
match maybe {
|
||||||
Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => {
|
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')) {
|
if k.modifiers.contains(KeyModifiers::CONTROL) && matches!(k.code, KeyCode::Char('q')) {
|
||||||
break Ok(());
|
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
|
app.show_help = false; // any key dismisses the overlay
|
||||||
} else if k.code == KeyCode::F(1) {
|
} else if k.code == KeyCode::F(1) {
|
||||||
app.show_help = true; // F1 from any mode
|
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 {
|
} else if app.driving {
|
||||||
if k.code == KeyCode::Esc {
|
if k.code == KeyCode::Esc {
|
||||||
app.driving = false;
|
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) {
|
} else if let Some(bytes) = key_to_pty(k.code, k.modifiers) {
|
||||||
if let Some(sb) = &mut broker {
|
if let Some(sb) = &mut broker {
|
||||||
// I own the sandbox: write straight to the PTY — instant,
|
// 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();
|
let line = app.input.trim().to_string();
|
||||||
app.input.clear();
|
app.input.clear();
|
||||||
app.chat_scroll = 0; // jump back to live on send
|
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,
|
&mut broker, &mut broker_meta, &mut launching, &mut announced_dims,
|
||||||
&out_tx, &pty_tx, &broker_tx, &app_tx, &session, &term);
|
&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()),
|
Some(Err(e)) => break Err(e.into()),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
@ -529,6 +614,36 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
||||||
None => {}
|
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() => {
|
pty = pty_rx.recv() => {
|
||||||
if let Some(mut bytes) = pty {
|
if let Some(mut bytes) = pty {
|
||||||
// Coalesce a burst (e.g. `tree`) into one frame: fewer round-trips,
|
// 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()?;
|
disable_raw_mode()?;
|
||||||
execute!(term.backend_mut(), LeaveAlternateScreen)?;
|
execute!(term.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
|
||||||
term.show_cursor()?;
|
term.show_cursor()?;
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
@ -585,6 +700,7 @@ enum BrokerMsg {
|
||||||
fn handle_command(
|
fn handle_command(
|
||||||
line: &str,
|
line: &str,
|
||||||
app: &mut App,
|
app: &mut App,
|
||||||
|
theme: &mut Theme,
|
||||||
active_send: &mut Option<ActiveSend>,
|
active_send: &mut Option<ActiveSend>,
|
||||||
send_seq: &mut u64,
|
send_seq: &mut u64,
|
||||||
broker: &mut Option<sbx::Sandbox>,
|
broker: &mut Option<sbx::Sandbox>,
|
||||||
|
|
@ -601,6 +717,32 @@ fn handle_command(
|
||||||
let room = &session.room;
|
let room = &session.room;
|
||||||
if line == "/help" || line == "/?" {
|
if line == "/help" || line == "/?" {
|
||||||
app.show_help = true;
|
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 <name>`, or bare `/theme` to list options.
|
||||||
|
let name = rest.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
app.sys(format!("vestments: {} — /theme <name>", 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" {
|
} else if line == "/drive" {
|
||||||
// Mobile-friendly alternative to F2 (no function key needed).
|
// Mobile-friendly alternative to F2 (no function key needed).
|
||||||
if app.sandbox.is_none() {
|
if app.sandbox.is_none() {
|
||||||
|
|
@ -624,7 +766,7 @@ fn handle_command(
|
||||||
}));
|
}));
|
||||||
app.sys(format!("offered {} ({}) — waiting for an /accept", name, ft::human(size)));
|
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" {
|
} else if line == "/accept" {
|
||||||
if let Some(o) = app.pending_offer.take() {
|
if let Some(o) = app.pending_offer.take() {
|
||||||
|
|
@ -651,15 +793,25 @@ fn handle_command(
|
||||||
if app.sandbox.is_some() || broker.is_some() || *launching {
|
if app.sandbox.is_some() || broker.is_some() || *launching {
|
||||||
app.sys("a sandbox is already running");
|
app.sys("a sandbox is already running");
|
||||||
} else {
|
} else {
|
||||||
let backend = p.next().and_then(sbx::Backend::parse).unwrap_or(sbx::Backend::Local);
|
// `--start` (alias `--start-daemon` / `-y`) opts in to booting
|
||||||
let image = p.next().map(str::to_string).unwrap_or_else(|| backend.default_image().to_string());
|
// a stopped Docker daemon; everything else is positional.
|
||||||
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
|
let args: Vec<&str> = p.collect();
|
||||||
let (rows, cols) = sbx_dims(sz.0, sz.1);
|
let start_daemon = args.iter().any(|a| matches!(*a, "--start" | "--start-daemon" | "-y"));
|
||||||
*launching = true;
|
let mut pos = args.iter().copied().filter(|a| !a.starts_with('-'));
|
||||||
let members: Vec<String> = app.users.iter().map(|u| u.username.clone()).collect();
|
let backend = pos.next().and_then(sbx::Backend::parse).unwrap_or(sbx::Backend::Local);
|
||||||
app.sys(format!("summoning {} sandbox… (provisioning unix users; multipass boot ~30s)", backend.label()));
|
let image = pos.next().map(str::to_string).unwrap_or_else(|| backend.default_image().to_string());
|
||||||
spawn_launch(backend, image, app.me.clone(), members, rows, cols,
|
|
||||||
pty_tx.clone(), broker_tx.clone(), app_tx.clone());
|
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<String> = 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") => {
|
Some("stop") => {
|
||||||
|
|
@ -743,6 +895,7 @@ fn spawn_launch(
|
||||||
members: Vec<String>,
|
members: Vec<String>,
|
||||||
rows: u16,
|
rows: u16,
|
||||||
cols: u16,
|
cols: u16,
|
||||||
|
start_daemon: bool,
|
||||||
pty_tx: UnboundedSender<Vec<u8>>,
|
pty_tx: UnboundedSender<Vec<u8>>,
|
||||||
broker_tx: UnboundedSender<BrokerMsg>,
|
broker_tx: UnboundedSender<BrokerMsg>,
|
||||||
app_tx: UnboundedSender<Net>,
|
app_tx: UnboundedSender<Net>,
|
||||||
|
|
@ -751,10 +904,10 @@ fn spawn_launch(
|
||||||
let name = SBX_NAME.to_string();
|
let name = SBX_NAME.to_string();
|
||||||
let prep = {
|
let prep = {
|
||||||
let (n, img) = (name.clone(), image.clone());
|
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}"))) {
|
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);
|
let _ = broker_tx.send(BrokerMsg::Failed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -778,7 +931,7 @@ fn spawn_launch(
|
||||||
let _ = broker_tx.send(BrokerMsg::Ready { sb, backend, name, rows, cols });
|
let _ = broker_tx.send(BrokerMsg::Ready { sb, backend, name, rows, cols });
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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);
|
let _ = broker_tx.send(BrokerMsg::Failed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ A099ED8193E0757767A13DD52312AB4B03310DCD7F48A9DA04FD50E8083969EDB767B0CF60\
|
||||||
60279004E57AE6AF874E7303CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8E9DB\
|
60279004E57AE6AF874E7303CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8E9DB\
|
||||||
FBB694B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F9E4AFF73";
|
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.
|
/// The user's chosen display name is independent of this value.
|
||||||
pub const SRP_IDENTITY: &[u8] = b"chat";
|
pub const SRP_IDENTITY: &[u8] = b"chat";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,7 @@ mod tests {
|
||||||
let dir = std::env::temp_dir().join(format!("hh-ft-{}", std::process::id()));
|
let dir = std::env::temp_dir().join(format!("hh-ft-{}", std::process::id()));
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
let src = dir.join("note.txt");
|
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();
|
let (name, bytes, is_dir) = read_payload(src.to_str().unwrap()).unwrap();
|
||||||
assert_eq!(name, "note.txt");
|
assert_eq!(name, "note.txt");
|
||||||
|
|
@ -161,7 +161,7 @@ mod tests {
|
||||||
sha256: sha256_hex(&bytes), dir: false, from: "x".into() };
|
sha256: sha256_hex(&bytes), dir: false, from: "x".into() };
|
||||||
let dl = dir.join("dl");
|
let dl = dir.join("dl");
|
||||||
let out = save(&dl, &offer, &bytes).unwrap();
|
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();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,11 +84,19 @@ fn main() -> Result<()> {
|
||||||
theme,
|
theme,
|
||||||
} => {
|
} => {
|
||||||
let session = net::authenticate(&ip, port, &user, &password, no_tls, insecure)?;
|
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 {
|
let theme = match theme {
|
||||||
Some(p) => theme::Theme::load(&p)?,
|
Some(p) => theme::Theme::load(&p)?,
|
||||||
None => theme::Theme::default(),
|
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 } => {
|
Cmd::Roomkey { password, room_salt_hex } => {
|
||||||
let salt = hex::decode(room_salt_hex)?;
|
let salt = hex::decode(room_salt_hex)?;
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,21 @@ pub struct Session {
|
||||||
pub insecure: bool,
|
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<Ws, WsMsg>;
|
||||||
|
|
||||||
/// Full SRP handshake against the Sanic server. Returns a ready Session
|
/// Full SRP handshake against the Sanic server. Returns a ready Session
|
||||||
/// (room key derived, ws url built) but does not open the websocket.
|
/// (room key derived, ws url built) but does not open the websocket.
|
||||||
pub fn authenticate(
|
pub fn authenticate(
|
||||||
|
|
@ -99,6 +114,15 @@ pub async fn connect(session: &Session) -> Result<Ws> {
|
||||||
Ok(ws)
|
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<Net>) -> Result<WsSink> {
|
||||||
|
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<User> {
|
fn parse_users(v: &Value) -> Vec<User> {
|
||||||
v.as_array()
|
v.as_array()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,46 @@
|
||||||
//!
|
//!
|
||||||
//! The broker (owner's client) spawns a sandbox shell inside a PTY. Output bytes
|
//! 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
|
//! 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
|
//! Input frames (`sbx pty_input`) are written back into the PTY. The server only
|
||||||
//! ever sees ciphertext — identical trust model to chat/file transfer.
|
//! ever sees ciphertext — identical trust model to chat/file transfer.
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
|
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::process::Command;
|
use std::process::{Command, Stdio};
|
||||||
use std::sync::mpsc;
|
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),
|
/// Which sandbox to summon. Multipass = strong isolation (default for real use),
|
||||||
/// Docker = fast, Local = no isolation (dev/testing only).
|
/// Docker = fast, Local = no isolation (dev/testing only).
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[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
|
/// 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
|
/// thread (Multipass boots a real VM, ~20-30s). Idempotent: reuses an instance
|
||||||
/// that already exists.
|
/// 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 {
|
match backend {
|
||||||
Backend::Local => Ok(()),
|
Backend::Local => Ok(()),
|
||||||
Backend::Multipass => {
|
Backend::Multipass => {
|
||||||
|
|
@ -60,24 +90,48 @@ pub fn prepare(backend: Backend, name: &str, image: &str) -> Result<()> {
|
||||||
.map(|o| o.status.success())
|
.map(|o| o.status.success())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if !exists {
|
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])
|
.args(["launch", "--name", name, "--cpus", "1", "--memory", "1G", "--disk", "5G", image])
|
||||||
.status()
|
.output()
|
||||||
.context("multipass launch (is multipass installed?)")?;
|
.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 {
|
} else {
|
||||||
let _ = Command::new("multipass").args(["start", name]).status();
|
let _ = Command::new("multipass").args(["start", name])
|
||||||
|
.stdout(Stdio::null()).stderr(Stdio::null()).status();
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Backend::Docker => {
|
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.
|
// Persistent container so we can exec in to provision users + shells.
|
||||||
let _ = Command::new("docker").args(["rm", "-f", name]).status();
|
let _ = Command::new("docker").args(["rm", "-f", name])
|
||||||
let st = Command::new("docker")
|
.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"])
|
.args(["run", "-d", "--name", name, "--hostname", name, "-w", "/root", image, "sleep", "infinity"])
|
||||||
.status()
|
.output()
|
||||||
.context("docker run (is docker installed?)")?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -88,10 +142,12 @@ pub fn prepare(backend: Backend, name: &str, image: &str) -> Result<()> {
|
||||||
pub fn teardown(backend: Backend, name: &str) {
|
pub fn teardown(backend: Backend, name: &str) {
|
||||||
match backend {
|
match backend {
|
||||||
Backend::Multipass => {
|
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 => {
|
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 => {}
|
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 {
|
pub fn unix_name(name: &str) -> String {
|
||||||
let s: String = name
|
let s: String = name
|
||||||
.to_lowercase()
|
.to_lowercase()
|
||||||
|
|
@ -139,12 +195,15 @@ pub fn unix_name(name: &str) -> String {
|
||||||
fn mp(name: &str, args: &[&str]) {
|
fn mp(name: &str, args: &[&str]) {
|
||||||
let mut a = vec!["exec", name, "--"];
|
let mut a = vec!["exec", name, "--"];
|
||||||
a.extend_from_slice(args);
|
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]) {
|
fn dk(name: &str, args: &[&str]) {
|
||||||
let mut a = vec!["exec", name];
|
let mut a = vec!["exec", name];
|
||||||
a.extend_from_slice(args);
|
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)
|
/// 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]);
|
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
|
/// make the owner a superuser (sudoer). Returns the unix user the shared shell
|
||||||
/// should run as. Blocking — call off the UI thread.
|
/// should run as. Blocking — call off the UI thread.
|
||||||
pub fn provision(backend: Backend, name: &str, owner: &str, members: &[String]) -> String {
|
pub fn provision(backend: Backend, name: &str, owner: &str, members: &[String]) -> String {
|
||||||
|
|
@ -219,7 +278,7 @@ pub struct Sandbox {
|
||||||
|
|
||||||
impl Sandbox {
|
impl Sandbox {
|
||||||
/// Spawn the backend in a PTY. A reader thread pushes raw output bytes onto
|
/// 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(
|
pub fn launch(
|
||||||
backend: Backend,
|
backend: Backend,
|
||||||
name: &str,
|
name: &str,
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,14 @@
|
||||||
//! occult-monochrome: black ground, white/grey ink, ⛧ accents. Override with a
|
//! occult-monochrome: black ground, white/grey ink, ⛧ accents. Override with a
|
||||||
//! TOML file via `--theme <path>`.
|
//! TOML file via `--theme <path>`.
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
use ratatui::style::Color;
|
use ratatui::style::Color;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// Where the bundled `*.toml` vestments live, so `/theme <name>` 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)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Theme {
|
pub struct Theme {
|
||||||
|
|
@ -18,8 +23,14 @@ pub struct Theme {
|
||||||
pub system: Color,
|
pub system: Color,
|
||||||
pub input: Color,
|
pub input: Color,
|
||||||
pub roster_me: 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.
|
/// Width of the roster column.
|
||||||
pub roster_width: u16,
|
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 {
|
impl Default for Theme {
|
||||||
|
|
@ -37,7 +48,9 @@ impl Default for Theme {
|
||||||
system: Color::Rgb(0xb4, 0x6c, 0xff), // system / occult = purple
|
system: Color::Rgb(0xb4, 0x6c, 0xff), // system / occult = purple
|
||||||
input: Color::Rgb(0x39, 0xff, 0x14),
|
input: Color::Rgb(0x39, 0xff, 0x14),
|
||||||
roster_me: Color::Rgb(0xff, 0x39, 0xc0), // you / owner = hot magenta
|
roster_me: Color::Rgb(0xff, 0x39, 0xc0), // you / owner = hot magenta
|
||||||
|
bg: Color::Reset, // ride the terminal's own black
|
||||||
roster_width: 22,
|
roster_width: 22,
|
||||||
|
sigil: "⛧".into(), // inverted pentagram
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -47,6 +60,30 @@ impl Theme {
|
||||||
let s = std::fs::read_to_string(path)?;
|
let s = std::fs::read_to_string(path)?;
|
||||||
Ok(toml::from_str(&s)?)
|
Ok(toml::from_str(&s)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a bare vestment name (e.g. "neon") to a bundled `themes/<name>.toml`.
|
||||||
|
/// Used by the in-session `/theme` command for live switching.
|
||||||
|
pub fn by_name(name: &str) -> anyhow::Result<Self> {
|
||||||
|
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<String> {
|
||||||
|
let mut names: Vec<String> = 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)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
95
hh/src/ui.rs
95
hh/src/ui.rs
|
|
@ -9,6 +9,11 @@ use ratatui::widgets::{Block, Clear, List, ListItem, Paragraph, Wrap};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
|
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([
|
let rows = Layout::vertical([
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
Constraint::Min(1),
|
Constraint::Min(1),
|
||||||
|
|
@ -39,6 +44,35 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
|
||||||
if app.show_help {
|
if app.show_help {
|
||||||
draw_help(f, f.area(), theme);
|
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 {
|
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),
|
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![
|
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 launch [backend]", "summon a sandbox: local | docker | multipass"),
|
||||||
kv("/sbx stop", "tear down the sandbox (purges the VM)"),
|
kv("/sbx stop", "tear down the sandbox (purges the VM)"),
|
||||||
kv("/drive", "type into the shared shell (Esc releases)"),
|
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 <file>", "offer a file to the room"),
|
kv("/send <file>", "offer a file to the room"),
|
||||||
kv("/sendd <dir>", "offer a directory (sent as a tar)"),
|
kv("/sendd <dir>", "offer a directory (sent as a tar)"),
|
||||||
kv("/accept · /reject", "respond to an incoming file offer"),
|
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"),
|
kv("/help", "show / hide this menu"),
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
head("⛧ KEYS"),
|
head("KEYS"),
|
||||||
kv("Enter", "send chat message"),
|
kv("Enter", "send chat message"),
|
||||||
kv("F1 · /help", "toggle this help (any key closes it)"),
|
kv("F1 · /help", "toggle this help (any key closes it)"),
|
||||||
kv("F2 · /drive", "take the shell · Esc releases it"),
|
kv("F2 · /drive", "take the shell · Esc releases it"),
|
||||||
kv("Ctrl-C (while driving)", "interrupt the running command"),
|
kv("Ctrl-C (while driving)", "interrupt the running command"),
|
||||||
kv("PgUp / PgDn", "scroll chat · Home/End = oldest/live"),
|
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"),
|
kv("Ctrl-Q", "quit hack-house"),
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
head("⛧ ROSTER GLYPHS"),
|
head("ROSTER GLYPHS"),
|
||||||
kv("⛧ owner ⚡ sudoer", "◆ may drive • member"),
|
kv("⛧ owner ⚡ sudoer", "◆ may drive • member"),
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
|
|
@ -103,11 +142,12 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) {
|
||||||
let w = centered(78, 90, area);
|
let w = centered(78, 90, area);
|
||||||
f.render_widget(Clear, w);
|
f.render_widget(Clear, w);
|
||||||
let help = Paragraph::new(lines)
|
let help = Paragraph::new(lines)
|
||||||
|
.style(Style::default().bg(theme.bg)) // fill the popup with the theme surface
|
||||||
.block(
|
.block(
|
||||||
Block::bordered()
|
Block::bordered()
|
||||||
.border_style(Style::default().fg(theme.accent))
|
.border_style(Style::default().fg(theme.accent))
|
||||||
.title(Span::styled(
|
.title(Span::styled(
|
||||||
" ⛧ hack-house — help ⛧ ",
|
format!(" {0} hack-house — help {0} ", theme.sigil),
|
||||||
Style::default().fg(theme.title).add_modifier(Modifier::BOLD),
|
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)
|
.rows(0, cols)
|
||||||
.map(|r| Line::from(Span::styled(r, Style::default().fg(theme.title))))
|
.map(|r| Line::from(Span::styled(r, Style::default().fg(theme.title))))
|
||||||
.collect();
|
.collect();
|
||||||
let drive = if app.driving {
|
let drive = if app.driving && app.sbx_scroll > 0 {
|
||||||
" · DRIVING — type here · Esc to release".to_string()
|
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 {
|
} else if app.sbx_scroll > 0 {
|
||||||
format!(" · ↑{} scrollback (↓/End=live)", app.sbx_scroll)
|
format!(" · ↑{} scrollback (↓/End=live)", app.sbx_scroll)
|
||||||
} else {
|
} else {
|
||||||
" · /drive (or F2) · ↑/↓ scroll".to_string()
|
" · /drive (or F2) · ↑/↓/wheel scroll".to_string()
|
||||||
};
|
};
|
||||||
let title = format!(" sandbox · {}{} ", sv.backend, drive);
|
let title = format!(" sandbox · {}{} ", sv.backend, drive);
|
||||||
let border = if app.driving { theme.accent } else { theme.border };
|
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) {
|
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 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![
|
let bar = Line::from(vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
" ⛧ hack-house ⛧ ",
|
format!(" {0} hack-house {0} ", theme.sigil),
|
||||||
Style::default().fg(theme.accent).add_modifier(Modifier::BOLD),
|
Style::default().fg(theme.accent).add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
Span::styled(format!("· {status} "), Style::default().fg(theme.dim)),
|
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) {
|
fn draw_chat(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) {
|
||||||
let visible = area.height.saturating_sub(2) as usize;
|
let inner_h = area.height.saturating_sub(2) as usize; // rows inside the border
|
||||||
let len = app.lines.len();
|
let text_w = area.width.saturating_sub(2).max(1); // wrap width inside the border
|
||||||
// Window ends `chat_scroll` lines above the live bottom.
|
let lines: Vec<Line> = app.lines.iter().map(|l| fmt_line(l, app, theme)).collect();
|
||||||
let end = len.saturating_sub(app.chat_scroll);
|
|
||||||
let start = end.saturating_sub(visible);
|
// Measure the TRUE wrapped height and scroll to the bottom. Selecting N
|
||||||
let lines: Vec<Line> = app.lines[start..end].iter().map(|l| fmt_line(l, app, theme)).collect();
|
// 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 {
|
let title = if app.chat_scroll > 0 {
|
||||||
format!(" chat ↑{} (End=live) ", app.chat_scroll)
|
format!(" chat ↑{} (End=live) ", app.chat_scroll)
|
||||||
} else {
|
} 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))
|
.border_style(Style::default().fg(theme.border))
|
||||||
.title(Span::styled(title, Style::default().fg(theme.title))),
|
.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);
|
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(
|
let roster = List::new(items).block(
|
||||||
Block::bordered()
|
Block::bordered()
|
||||||
.border_style(Style::default().fg(theme.border))
|
.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);
|
f.render_widget(roster, area);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
230
hh/test-features.sh
Executable file
230
hh/test-features.sh
Executable file
|
|
@ -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 <pane> <fixed-substring> <label>
|
||||||
|
want() {
|
||||||
|
local pane="$1" needle="$2" label="$3"
|
||||||
|
if cap "$pane" | grep -qF -- "$needle"; then
|
||||||
|
echo " ✓ PASS — $label"; PASS=$((PASS+1)); RESULTS+=("PASS $label")
|
||||||
|
else
|
||||||
|
echo " ✗ FAIL — $label (looked for: '$needle')"; FAIL=$((FAIL+1)); RESULTS+=("FAIL $label")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
# wantnot <pane> <fixed-substring> <label>
|
||||||
|
wantnot() {
|
||||||
|
local pane="$1" needle="$2" label="$3"
|
||||||
|
if cap "$pane" | grep -qF -- "$needle"; then
|
||||||
|
echo " ✗ FAIL — $label (did NOT want: '$needle')"; FAIL=$((FAIL+1)); RESULTS+=("FAIL $label")
|
||||||
|
else
|
||||||
|
echo " ✓ PASS — $label"; PASS=$((PASS+1)); RESULTS+=("PASS $label")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# input helpers (type into the TUI input box, then Enter)
|
||||||
|
o() { tmux send-keys -t "$OWNER" -- "$*"; tmux send-keys -t "$OWNER" Enter; sleep "${TYPE_PAUSE:-0.5}"; }
|
||||||
|
m() { tmux send-keys -t "$MEMBER" -- "$*"; tmux send-keys -t "$MEMBER" Enter; sleep "${TYPE_PAUSE:-0.5}"; }
|
||||||
|
key() { tmux send-keys -t "$1" "$2"; sleep "${KEY_PAUSE:-0.7}"; } # named key (PageUp, F2, …)
|
||||||
|
raw() { tmux send-keys -t "$1" -l "$2"; sleep "${KEY_PAUSE:-0.7}"; } # literal bytes (mouse SGR)
|
||||||
|
|
||||||
|
# ─── 1. build ───────────────────────────────────────────────────────────────
|
||||||
|
echo "⛧ building client…"
|
||||||
|
( cd "$HERE" && cargo build --quiet ) || { echo "✖ build failed"; exit 1; }
|
||||||
|
[[ -x "$PY" ]] || { echo "✖ no python at $PY"; exit 1; }
|
||||||
|
|
||||||
|
# ─── 2. server ───────────────────────────────────────────────────────────────
|
||||||
|
if is_up; then
|
||||||
|
echo "⛧ reusing server on $HOST:$PORT"
|
||||||
|
else
|
||||||
|
echo "⛧ booting 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"))"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── 3. tmux session: OWNER | bob ─────────────────────────────────────────────
|
||||||
|
CONNECT="$BIN connect $HOST $PORT"
|
||||||
|
FLAGS="--password $PW --no-tls"
|
||||||
|
tmux kill-session -t "$SESSION" 2>/dev/null || true
|
||||||
|
# Big window so the TUI never clips; owner connects first → becomes room owner.
|
||||||
|
tmux new-session -d -s "$SESSION" -x 200 -y 50 -c "$HERE" "$CONNECT owner $FLAGS; read _"
|
||||||
|
sleep 2
|
||||||
|
tmux split-window -h -t "$SESSION" -c "$HERE" "$CONNECT bob $FLAGS; read _"
|
||||||
|
sleep 2
|
||||||
|
tmux select-layout -t "$SESSION" tiled >/dev/null
|
||||||
|
tmux set -t "$SESSION" pane-border-status top >/dev/null
|
||||||
|
tmux set -t "$SESSION" pane-border-format " #{pane_index} " >/dev/null
|
||||||
|
|
||||||
|
mapfile -t PANES < <(tmux list-panes -t "$SESSION" -F '#{pane_id}')
|
||||||
|
OWNER="${PANES[0]}"; MEMBER="${PANES[1]}"
|
||||||
|
echo "⛧ panes: OWNER=$OWNER MEMBER=$MEMBER (attach: tmux attach -t $SESSION)"
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
|
echo " RUNNING FEATURE SUITE"
|
||||||
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
|
|
||||||
|
# ─── T1. connect + roster ─────────────────────────────────────────────────────
|
||||||
|
echo "[T1] connect + e2e + roster"
|
||||||
|
want "$OWNER" "hack-house" "owner UI is up"
|
||||||
|
want "$OWNER" "e2e" "owner shows e2e-encrypted status"
|
||||||
|
want "$OWNER" "house 2/" "roster shows 2 members"
|
||||||
|
|
||||||
|
# ─── T2. chat round-trip (owner → bob) ────────────────────────────────────────
|
||||||
|
echo "[T2] chat message round-trip"
|
||||||
|
o "MARKER_CHAT_OWNER_42"
|
||||||
|
sleep 0.6
|
||||||
|
want "$MEMBER" "MARKER_CHAT_OWNER_42" "bob received owner's chat message"
|
||||||
|
m "MARKER_CHAT_BOB_99"
|
||||||
|
sleep 0.6
|
||||||
|
want "$OWNER" "MARKER_CHAT_BOB_99" "owner received bob's chat message"
|
||||||
|
|
||||||
|
# a few more lines so there's chat history to scroll
|
||||||
|
for i in 1 2 3 4 5; do o "chat-history-line-$i"; done
|
||||||
|
|
||||||
|
# ─── T3. chat scrollback (PgUp/PgDn/Home/End) ─────────────────────────────────
|
||||||
|
echo "[T3] chat scrollback via PgUp/PgDn"
|
||||||
|
key "$OWNER" PageUp
|
||||||
|
want "$OWNER" "End=live" "PgUp scrolled chat (shows 'End=live' hint)"
|
||||||
|
wantnot "$OWNER" "scrollback" "PgUp did NOT touch sandbox (no sandbox yet)"
|
||||||
|
key "$OWNER" End
|
||||||
|
wantnot "$OWNER" "End=live" "End returned chat to live"
|
||||||
|
|
||||||
|
# ─── T4. help overlay (F1 open / any-key close) ───────────────────────────────
|
||||||
|
echo "[T4] help overlay F1"
|
||||||
|
key "$OWNER" F1
|
||||||
|
want "$OWNER" "hack-house — help" "F1 opened the help overlay"
|
||||||
|
key "$OWNER" Escape
|
||||||
|
wantnot "$OWNER" "hack-house — help" "a keypress closed the help overlay"
|
||||||
|
|
||||||
|
# ─── T5. live theme switch ────────────────────────────────────────────────────
|
||||||
|
echo "[T5] /theme switch stays alive"
|
||||||
|
o "/theme neon"
|
||||||
|
o "/theme church"
|
||||||
|
want "$OWNER" "hack-house" "UI still alive after theme swap"
|
||||||
|
|
||||||
|
# ─── T6. sandbox launch (local backend = no docker) ───────────────────────────
|
||||||
|
echo "[T6] /sbx launch local (allowing time to summon)"
|
||||||
|
o "/sbx launch local"
|
||||||
|
echo " …waiting for the sandbox to summon"
|
||||||
|
sleep 5
|
||||||
|
want "$OWNER" "local-shell" "owner shows the sandbox pane (local-shell)"
|
||||||
|
|
||||||
|
# ─── T7. drive the shell (F2) + run a command ─────────────────────────────────
|
||||||
|
echo "[T7] drive shell + command output"
|
||||||
|
key "$OWNER" F2
|
||||||
|
want "$OWNER" "DRIVING" "F2 entered DRIVING mode"
|
||||||
|
o "echo HELLOSBX_OK_7"
|
||||||
|
sleep 1
|
||||||
|
want "$OWNER" "HELLOSBX_OK_7" "command output rendered in the sandbox terminal"
|
||||||
|
o "seq 1 60"
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# ─── T8. NEW: PgUp scrolls sandbox scrollback WHILE DRIVING ────────────────────
|
||||||
|
echo "[T8] PgUp scrolls sandbox scrollback while driving (the new wiring)"
|
||||||
|
key "$OWNER" PageUp
|
||||||
|
key "$OWNER" PageUp
|
||||||
|
want "$OWNER" "DRIVING" "still driving after PgUp (drive not released)"
|
||||||
|
want "$OWNER" "scrollback" "PgUp scrolled the sandbox scrollback while driving"
|
||||||
|
key "$OWNER" PageDown
|
||||||
|
key "$OWNER" PageDown
|
||||||
|
key "$OWNER" PageDown
|
||||||
|
|
||||||
|
# ─── T9. release drive (Esc) ──────────────────────────────────────────────────
|
||||||
|
echo "[T9] Esc releases the drive"
|
||||||
|
key "$OWNER" Escape
|
||||||
|
wantnot "$OWNER" "DRIVING" "Esc released DRIVING mode"
|
||||||
|
|
||||||
|
# ─── T10. arrow keys scroll sandbox when NOT driving ──────────────────────────
|
||||||
|
echo "[T10] Up arrow scrolls sandbox (not driving)"
|
||||||
|
key "$OWNER" Up
|
||||||
|
key "$OWNER" Up
|
||||||
|
want "$OWNER" "scrollback" "Up arrow scrolled the sandbox scrollback"
|
||||||
|
key "$OWNER" End
|
||||||
|
|
||||||
|
# ─── T11. PgUp scrolls CHAT (not sandbox) when not driving ────────────────────
|
||||||
|
echo "[T11] PgUp scrolls chat (not sandbox) when not driving"
|
||||||
|
key "$OWNER" PageUp
|
||||||
|
want "$OWNER" "End=live" "PgUp scrolled chat while a sandbox is up"
|
||||||
|
wantnot "$OWNER" "scrollback" "PgUp left the sandbox at live (no 'scrollback')"
|
||||||
|
key "$OWNER" End
|
||||||
|
|
||||||
|
# ─── T12. mouse wheel (best-effort: raw SGR sequence) ─────────────────────────
|
||||||
|
echo "[T12] mouse wheel scroll (best-effort SGR injection)"
|
||||||
|
raw "$OWNER" $'\033[<64;20;20M' # SGR wheel-up at (20,20)
|
||||||
|
raw "$OWNER" $'\033[<64;20;20M'
|
||||||
|
if cap "$OWNER" | grep -qF "scrollback"; then
|
||||||
|
echo " ✓ PASS — wheel-up scrolled the sandbox (SGR mouse recognised)"; PASS=$((PASS+1)); RESULTS+=("PASS mouse wheel scroll")
|
||||||
|
else
|
||||||
|
echo " ⚠ INFO — wheel-up did not register via send-keys (mouse wheel needs a real terminal; verify by hand)"; RESULTS+=("INFO mouse wheel scroll (manual)")
|
||||||
|
fi
|
||||||
|
key "$OWNER" End
|
||||||
|
|
||||||
|
# ─── T13. grant a member drive permission ─────────────────────────────────────
|
||||||
|
echo "[T13] /grant lets bob drive"
|
||||||
|
o "/grant bob"
|
||||||
|
sleep 0.6
|
||||||
|
key "$MEMBER" F2
|
||||||
|
want "$MEMBER" "DRIVING" "bob can drive after /grant"
|
||||||
|
key "$MEMBER" Escape
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
|
echo " RESULTS"
|
||||||
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
|
for r in "${RESULTS[@]}"; do echo " $r"; done
|
||||||
|
echo "----------------------------------------------------------------"
|
||||||
|
echo " PASS=$PASS FAIL=$FAIL"
|
||||||
|
echo
|
||||||
|
echo "⛧ session left up for inspection: tmux attach -t $SESSION"
|
||||||
|
echo "⛧ tear down with: $0 --kill"
|
||||||
|
|
||||||
|
exit $(( FAIL > 0 ? 1 : 0 ))
|
||||||
|
|
@ -10,3 +10,4 @@ system = "#b46cff" # system / occult lines (purple)
|
||||||
input = "#39ff14"
|
input = "#39ff14"
|
||||||
roster_me = "#ff39c0" # you / owner (hot magenta)
|
roster_me = "#ff39c0" # you / owner (hot magenta)
|
||||||
roster_width = 22
|
roster_width = 22
|
||||||
|
sigil = "⛧" # inverted pentagram
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
# crypt — churchofmalware occult monochrome (default)
|
# crypt — occult monochrome: white ink on a lifted slate surface
|
||||||
name = "crypt"
|
name = "crypt"
|
||||||
border = "darkgray"
|
bg = "#1c1c22" # slate panel — lifts the text off pure-black for legibility
|
||||||
title = "white"
|
border = "#6e6e7a" # gray chrome, defined against the surface
|
||||||
accent = "white"
|
title = "#ffffff"
|
||||||
dim = "darkgray"
|
accent = "#ffffff"
|
||||||
me = "white"
|
dim = "#9a9aa6" # timestamps — readable mid-gray (was near-invisible darkgray)
|
||||||
other = "gray"
|
me = "#ffffff"
|
||||||
system = "darkgray"
|
other = "#c8c8d0" # others' messages — bright gray
|
||||||
input = "white"
|
system = "#b0b0bc" # system / occult lines — legible, still muted
|
||||||
roster_me = "white"
|
input = "#ffffff"
|
||||||
|
roster_me = "#ffffff"
|
||||||
roster_width = 22
|
roster_width = 22
|
||||||
|
sigil = "✝" # inverted cross / crypt
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,4 @@ system = "#7a5cff"
|
||||||
input = "#39ff14"
|
input = "#39ff14"
|
||||||
roster_me = "#ff2fd0"
|
roster_me = "#ff2fd0"
|
||||||
roster_width = 24
|
roster_width = 24
|
||||||
|
sigil = "⚡" # cyberpunk bolt
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Use-case tests for the hack-house multi-user coven features (capacity cap,
|
"""Use-case tests for the hack-house multi-user clergy features (capacity cap,
|
||||||
roster, username + slot lifecycle). In-process via sanic-testing.
|
roster, username + slot lifecycle). In-process via sanic-testing.
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
|
|
@ -27,7 +27,7 @@ def _init(test_client, name):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestCovenCapacity:
|
class TestClergyCapacity:
|
||||||
def test_accepts_up_to_capacity(self, app, test_client):
|
def test_accepts_up_to_capacity(self, app, test_client):
|
||||||
app.ctx.max_users = 4
|
app.ctx.max_users = 4
|
||||||
for n in ("a", "b", "c"):
|
for n in ("a", "b", "c"):
|
||||||
Loading…
Reference in New Issue
Block a user