feat(hh): graceful shutdown, crypt default theme, neutralize branding, share-prep

- Graceful shutdown: Ctrl+C quits in chat (interrupts PTY while driving),
  RAII TermGuard + panic hook + SIGTERM/SIGHUP always restore the terminal
- Default theme is now "crypt" (neutral monochrome); theme sigil mirrored in
  chat/roster/help so the pentagram only renders under the "church" theme
- Neutralize inverted-pentagram branding across CLI, scripts, docs, and Cargo
  metadata (kept only in themes/church.toml + the render-time placeholder)
- Rewrite root README around hack-house; add bootstrap.sh, SECURITY.md,
  CODE_OF_CONDUCT.md, CHANGELOG.md, and issue/PR templates
- .gitignore cleanup; stop tracking .venv

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-05-31 23:23:19 -07:00
parent 5de493e895
commit ff5186a9d3
2748 changed files with 589 additions and 713161 deletions
+1
View File
@@ -0,0 +1 @@
/target
Generated
+1
View File
@@ -1907,6 +1907,7 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
+2 -2
View File
@@ -2,7 +2,7 @@
name = "hack-house"
version = "0.1.0"
edition = "2021"
description = "hack-house — encrypted collaborative sessions with a summoned sandbox."
description = "hack-house — encrypted collaborative sessions with a summoned sandbox."
license = "MIT"
[[bin]]
@@ -34,7 +34,7 @@ vt100 = "0.15"
tar = "0.4"
# async + tui
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", "signal"] }
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
futures-util = "0.3"
ratatui = { version = "0.29", features = ["serde", "unstable-rendered-line-info"] }
+8 -8
View File
@@ -1,6 +1,6 @@
<div align="center">
# hack-house
# hack-house
### encrypted collaborative sessions with a summoned sandbox
@@ -29,19 +29,19 @@ compatible with the Python `srp` / `cryptography` stack.
| phase | feature | state |
|---|---|---|
| **P0** | Rust↔Python SRP / Fernet crypto parity | ✅ proven (golden vectors + live + cross-lang E2E) |
| **P2** | multi-user session (cap 4, infra for more) + authoritative roster | ✅ server-side done |
| **P1** | ratatui UI (chat, roster, themes) | 🚧 in progress |
| **P3** | sandbox box (multipass/docker) + shared PTY | designed (see `../docs/spec-collaborative-sandbox.md`) |
| **P4** | permissions (app RBAC + VM unix users / sudo) | designed |
| **P5** | file + directory uploads into the shared session | designed |
| **P2** | multi-user session (cap 4, infra for more) + authoritative roster | ✅ done |
| **P1** | ratatui UI (chat, roster, themes, help overlay) | ✅ done |
| **P3** | sandbox box (local / docker / multipass) + shared PTY | done |
| **P4** | permissions (app drive ACL + VM unix users / sudo) | done |
| **P5** | file + directory transfer into the shared session | done |
## crypto parity — the load-bearing proof
```
$ hack-house selftest # offline: Rust SRP ≡ Python srp golden vectors
$ hack-house handshake <ip> <port> <name> --password <pw> --no-tls
/srp/verify ok — server identity proven (H_AMK ✓)
round-trip ✓ decrypted: "the house is open"
/srp/verify ok — server identity proven (H_AMK ✓)
round-trip ✓ decrypted: "the house is open"
```
`tools/gen_vectors.py` regenerates the golden vectors from the live Python
+3 -3
View File
@@ -1,4 +1,4 @@
# .envrc — cd into this directory to summon your hack-house
# .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.
@@ -27,9 +27,9 @@ gen_pw() {
# $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"
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)"
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
+3
View File
@@ -0,0 +1,3 @@
# direnv local state — never commit. (The room password is generated in memory
# at launch and never written to disk, so there is no secret file to ignore.)
.direnv/
+9 -9
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# setup.sh — one-time setup for the hack-house direnv autostart
# 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.
@@ -24,15 +24,15 @@ esac
# 1. ensure direnv is installed
if command -v direnv >/dev/null 2>&1; then
echo "direnv already installed ($(direnv version 2>/dev/null))"
echo "direnv already installed ($(direnv version 2>/dev/null))"
elif command -v apt-get >/dev/null 2>&1; then
echo "installing direnv via apt…"
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…"
echo "installing direnv via brew…"
brew install direnv
else
echo "installing direnv via the official script (→ ~/.local/bin)…"
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
@@ -46,10 +46,10 @@ 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"
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"
echo "added direnv hook to $rc"
fi
}
add_hook "$HOME/.bashrc" "$hook_line_bash"
@@ -62,8 +62,8 @@ if [[ ! -f "$HERE/.envrc" ]]; then
exit 1
fi
direnv allow "$HERE"
echo "allowed $HERE/.envrc"
echo "allowed $HERE/.envrc"
echo
echo "done. open a new shell (or: source ~/.bashrc), then: cd $HERE"
echo "done. open a new shell (or: source ~/.bashrc), then: cd $HERE"
echo " your single-user hack-house boots automatically (fresh server, generated password)."
+4 -4
View File
@@ -26,7 +26,7 @@ done
daemon_up() { docker info >/dev/null 2>&1; }
if daemon_up; then
[[ $CHECK_ONLY -eq 1 ]] || echo "docker daemon already running" >&2
[[ $CHECK_ONLY -eq 1 ]] || echo "docker daemon already running" >&2
exit 0
fi
[[ $CHECK_ONLY -eq 1 ]] && exit 1
@@ -61,7 +61,7 @@ fi
# 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
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) ;;
@@ -69,12 +69,12 @@ if [[ $ASSUME_YES -ne 1 ]]; then
esac
fi
echo "starting docker daemon: $start_cmd" >&2
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; }
daemon_up && { echo "docker daemon is up" >&2; exit 0; }
sleep 1
done
+14 -14
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# lets-hack.sh — spin up a local hack-house test clergy in tmux
# 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
@@ -29,7 +29,7 @@ set -uo pipefail
usage() {
cat <<EOF
lets-hack.sh — spin up a local hack-house test clergy in tmux
lets-hack.sh — spin up a local hack-house test clergy in tmux
usage:
./lets-hack.sh [USERS...] [--theme NAME] [--fresh]
@@ -85,13 +85,13 @@ is_up() { curl -s --max-time 2 "http://$HOST:$PORT/health" 2>/dev/null | grep -q
# 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"))"
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)"
kill "$pid" 2>/dev/null && echo "stopped server holding :$PORT (pid $pid)"
fi
}
@@ -119,7 +119,7 @@ done
# --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"
tmux kill-session -t "$SESSION" 2>/dev/null && echo "killed tmux session $SESSION"
stop_server
exit 0
fi
@@ -138,7 +138,7 @@ fi
[[ ${#USERS[@]} -eq 0 ]] && USERS=(alice bob)
# 1. build the client
echo "building 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*
@@ -146,20 +146,20 @@ echo "⛧ building client…"
# 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"
echo "--reuse: keeping server already live on $HOST:$PORT"
else
if is_up; then
echo "replacing server already live on $HOST:$PORT"
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"
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)"
echo "server up (pid $(cat "$SRV_PIDFILE"), log $SRV_LOG)"
fi
# 3. (re)build the tmux session: one pane per user, tiled.
@@ -173,7 +173,7 @@ fi
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 _' \
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"
}
@@ -194,8 +194,8 @@ for ((i = 1; i < ${#USERS[@]}; i++)); do
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"
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
@@ -204,6 +204,6 @@ echo "⛧ tear down later with: $0 --kill"
if [[ -z "${TMUX:-}" ]]; then
exec tmux attach -t "$SESSION"
else
echo "inside tmux — switching this client to '$SESSION' (detach with Ctrl-b d)"
echo "inside tmux — switching this client to '$SESSION' (detach with Ctrl-b d)"
tmux switch-client -t "$SESSION"
fi
+2 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# hack-house smoke test
# hack-house smoke test
# Exercises the full use-case path end to end against a live server:
# rust unit tests → SRP self-test → boot server → rust client handshake +
# round-trip → cross-language (python decrypts what the rust client sent).
@@ -74,4 +74,4 @@ asyncio.run(main())
PYEOF
echo
echo "✓ SMOKE PASS — crypto · SRP · fernet · cross-language relay all green"
echo "✓ SMOKE PASS — crypto · SRP · fernet · cross-language relay all green"
+48 -1
View File
@@ -379,6 +379,27 @@ fn handle_ft(
}
}
/// Put the terminal back the way we found it: leave raw mode, leave the
/// alternate screen, stop mouse capture, show the cursor. Best-effort — every
/// step is independent so one failing (e.g. already-restored) can't strand the
/// rest. Safe to call more than once.
fn restore_terminal() {
let _ = disable_raw_mode();
let _ = execute!(std::io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
let _ = execute!(std::io::stdout(), crossterm::cursor::Show);
}
/// RAII: restores the terminal on drop. As long as a guard is alive, *any* exit
/// from `run` — normal return, `?` error, or a panic unwinding through the
/// frame — leaves the user's terminal (or tmux pane) usable instead of stuck in
/// raw/alt-screen mode.
struct TermGuard;
impl Drop for TermGuard {
fn drop(&mut self) {
restore_terminal();
}
}
pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme) -> Result<()> {
let (tx, mut rx) = unbounded_channel::<Net>();
let app_tx = tx.clone();
@@ -404,6 +425,24 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let mut term = Terminal::new(CrosstermBackend::new(stdout))?;
// From here on the terminal is in raw/alt-screen mode. The guard restores it
// on every exit path (return, `?`, panic); the panic hook additionally prints
// the panic *after* restoring, so a crash leaves a readable message in the
// pane instead of garbled raw-mode output.
let _term_guard = TermGuard;
let default_panic = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore_terminal();
default_panic(info);
}));
// Graceful shutdown on signals: a `kill`, a closed tmux pane (SIGHUP), or a
// Ctrl-C delivered while NOT in raw mode all break the loop cleanly so the
// guard above can restore the terminal — no more being "booted" with a
// broken pane. (In raw mode Ctrl-C arrives as a key event, handled below.)
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())?;
let mut app = App::new(session.username.clone());
app.password = params.password.clone();
let mut events = EventStream::new();
@@ -444,7 +483,13 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
match maybe {
Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => {
app.error = None; // any keypress dismisses the error popup
if k.modifiers.contains(KeyModifiers::CONTROL) && matches!(k.code, KeyCode::Char('q')) {
// Ctrl-Q always quits. Ctrl-C quits too — *unless* we're
// driving the sandbox, where it must reach the PTY as an
// interrupt (handled in the `app.driving` branch below).
if k.modifiers.contains(KeyModifiers::CONTROL)
&& (matches!(k.code, KeyCode::Char('q'))
|| (matches!(k.code, KeyCode::Char('c')) && !app.driving))
{
break Ok(());
}
if k.modifiers.contains(KeyModifiers::CONTROL)
@@ -675,6 +720,8 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
None => {}
}
}
_ = sigterm.recv() => { break Ok(()); }
_ = sighup.recv() => { break Ok(()); }
_ = tick.tick() => {}
}
};
+14 -14
View File
@@ -1,4 +1,4 @@
//! hack-house — encrypted collaborative sessions with a summoned sandbox.
//! hack-house — encrypted collaborative sessions with a summoned sandbox.
//!
//! This binary exposes the crypto-parity spike used to prove the Rust client
//! speaks the same SRP / Fernet dialect as the Python Sanic server, plus the
@@ -21,7 +21,7 @@ const STD: base64::engine::general_purpose::GeneralPurpose =
base64::engine::general_purpose::STANDARD;
#[derive(Parser)]
#[command(name = "hack-house", about = "encrypted collaborative sessions")]
#[command(name = "hack-house", about = "encrypted collaborative sessions")]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
@@ -147,14 +147,14 @@ fn selftest() -> Result<()> {
v
});
let a = hex::encode(c.a_bytes());
println!("A = {}", &a[..32]);
println!("A = {}", &a[..32]);
let salt = hex::decode("0a1b2c3d")?;
let b = hex::decode("047426a55963c70bc385c6a51f6e9dc0bfe5e16b0d1fee4f566fb54b60fa77144f15ed1ee6ade007bd92f2b90846e1ee083ab4290239420606f48a1d861f759543d7856cbce21fd7fec98c9961a66610b412fea2efc5be78f35b18fd48176ac80c3a1cbefacac81e25e7da8079fac4012d01c47d85b783c2ea7340819bfe73d29cd0953d47c8fade77caa5459fb77d88fb918c073a77c495fa884859142a270cb0b1668de06131b150df4dbc931953a381710b7fdb98a953d6f77a4bba847c4c62c15cca8e514dc13f531427966a553c461aa4ab0caec9665612861fef03d48676e5f6551fc8ca4317f3118e0294c949bd2f5821e5900e7f695225dafa0ba2d2")?;
let ch = c.process_challenge(&salt, &b)?;
let want_m = "6e733ba88eb86c52e3be89207d2815a65b4dea8116f668af5de1b66ce1f047dd";
assert_eq!(hex::encode(&ch.m), want_m, "M mismatch vs pysrp");
println!("M matches pysrp golden vector ✓");
println!("selftest passed — Rust SRP ≡ Python srp");
println!("M matches pysrp golden vector ✓");
println!("selftest passed — Rust SRP ≡ Python srp");
Ok(())
}
@@ -192,7 +192,7 @@ fn handshake(
let b = STD.decode(init["B"].as_str().context("no B")?)?;
let salt = STD.decode(init["salt"].as_str().context("no salt")?)?;
let room_salt = STD.decode(init["room_salt"].as_str().context("no room_salt")?)?;
println!("/srp/init ok — user_id={}", &user_id[..8]);
println!("/srp/init ok — user_id={}", &user_id[..8]);
let ch = client.process_challenge(&salt, &b)?;
@@ -213,11 +213,11 @@ fn handshake(
let server_hamk = STD.decode(verify["H_AMK"].as_str().context("no H_AMK")?)?;
anyhow::ensure!(server_hamk == ch.h_amk, "server H_AMK mismatch — MITM?");
let ws_token = verify["ws_token"].as_str().context("no ws_token")?.to_string();
println!("/srp/verify ok — server identity proven (H_AMK ✓)");
println!("/srp/verify ok — server identity proven (H_AMK ✓)");
// Room key + encrypt a message the Python clients can read.
let fernet = crypto::room_fernet(password.as_bytes(), &room_salt)?;
let ct = fernet.encrypt(b"the house is open \xE2\x9B\xA7");
let ct = fernet.encrypt(b"the house is open");
// Connect WS and send the ciphertext.
let ws_scheme = if no_tls { "ws" } else { "wss" };
@@ -226,18 +226,18 @@ fn handshake(
);
let (mut sock, _resp) =
tungstenite::connect(&ws_url).context("ws connect (insecure wss not yet wired)")?;
println!("websocket attached to the house");
println!("websocket attached to the house");
// First frame is the `init` state snapshot.
if let Ok(msg) = sock.read() {
if let Ok(txt) = msg.into_text() {
let v: serde_json::Value = serde_json::from_str(&txt).unwrap_or_default();
println!("recv: type={}", v["type"].as_str().unwrap_or("?"));
println!("recv: type={}", v["type"].as_str().unwrap_or("?"));
}
}
sock.send(tungstenite::Message::Text(ct))?;
sock.flush()?;
println!("sent encrypted offering");
println!("sent encrypted offering");
// Read frames until we see our broadcast echo, then decrypt it to prove the
// full round-trip (Rust encrypt → server relay → Rust decrypt).
@@ -250,19 +250,19 @@ fn handshake(
match fernet.decrypt(ct) {
Ok(pt) => {
println!(
"round-trip ✓ decrypted: {:?}",
"round-trip ✓ decrypted: {:?}",
String::from_utf8_lossy(&pt)
);
break;
}
Err(_) => println!("[decrypt failed]"),
Err(_) => println!("[decrypt failed]"),
}
}
}
}
Ok(_) => {}
Err(e) => {
println!("ws read ended: {e}");
println!("ws read ended: {e}");
break;
}
}
+23 -21
View File
@@ -1,6 +1,6 @@
//! Loadable colour/layout themes ("vestments"). Default is the churchofmalware
//! occult-monochrome: black ground, white/grey ink, accents. Override with a
//! TOML file via `--theme <path>`.
//! Loadable colour/layout themes ("vestments"). Default is "crypt": occult
//! monochrome — white/grey ink on a lifted slate surface, accents. Switch to
//! the neon "church" vestments with `/theme church` or a TOML via `--theme`.
use anyhow::Context;
use ratatui::style::Color;
@@ -29,28 +29,30 @@ pub struct Theme {
/// Width of the roster column.
pub roster_width: u16,
/// Glyph flanking the "hack-house" title (and used for occult accents).
/// Defaults to ⛧ (inverted pentagram); themes can pick their own sigil.
/// Each theme picks its own sigil (crypt: ✝, church: ⛧, …).
pub sigil: String,
}
impl Default for Theme {
/// "church" — Church of Malware: neon on black. Cyan window-chrome, acid-green
/// text/prompts, purple system/occult lines, hot-magenta self/owner accents.
/// "crypt" — occult monochrome: white ink on a lifted slate surface. Gray
/// window-chrome, readable mid-gray timestamps, muted-gray system lines, a
/// ✝ sigil. Neutral by default; switch to the neon "church" vestments with
/// `/theme church` or `--theme`.
fn default() -> Self {
Self {
name: "church".into(),
border: Color::Rgb(0x19, 0xb3, 0xff), // cyan window chrome
title: Color::Rgb(0x7d, 0xf9, 0xff), // bright cyan
accent: Color::Rgb(0x39, 0xff, 0x14), // acid green (⛧ glyphs, prompt)
dim: Color::Rgb(0x47, 0x5a, 0x7a), // muted slate-blue
me: Color::Rgb(0x39, 0xff, 0x14), // your messages = acid green
other: Color::Rgb(0x56, 0xc8, 0xff), // others = soft cyan
system: Color::Rgb(0xb4, 0x6c, 0xff), // system / occult = purple
input: Color::Rgb(0x39, 0xff, 0x14),
roster_me: Color::Rgb(0xff, 0x39, 0xc0), // you / owner = hot magenta
bg: Color::Reset, // ride the terminal's own black
name: "crypt".into(),
border: Color::Rgb(0x6e, 0x6e, 0x7a), // gray chrome, defined against the surface
title: Color::Rgb(0xff, 0xff, 0xff), // white
accent: Color::Rgb(0xff, 0xff, 0xff), // white (sigil / prompt)
dim: Color::Rgb(0x9a, 0x9a, 0xa6), // timestamps — readable mid-gray
me: Color::Rgb(0xff, 0xff, 0xff), // your messages = white
other: Color::Rgb(0xc8, 0xc8, 0xd0), // others = bright gray
system: Color::Rgb(0xb0, 0xb0, 0xbc), // system / occult = legible muted gray
input: Color::Rgb(0xff, 0xff, 0xff),
roster_me: Color::Rgb(0xff, 0xff, 0xff), // you / owner = white
bg: Color::Rgb(0x1c, 0x1c, 0x22), // slate panel lifts text off pure-black
roster_width: 22,
sigil: "".into(), // inverted pentagram
sigil: "".into(), // inverted cross / crypt
}
}
}
@@ -91,10 +93,10 @@ mod tests {
use super::*;
#[test]
fn default_is_church() {
fn default_is_crypt() {
let t = Theme::default();
assert_eq!(t.name, "church");
assert_eq!(t.accent, Color::Rgb(0x39, 0xff, 0x14));
assert_eq!(t.name, "crypt");
assert_eq!(t.accent, Color::Rgb(0xff, 0xff, 0xff));
}
#[test]
+11 -7
View File
@@ -129,10 +129,10 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) {
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-C · Ctrl-Q", "quit hack-house"),
Line::from(""),
head("ROSTER GLYPHS"),
kv(" owner ⚡ sudoer", "◆ may drive • member"),
kv(&format!("{} owner ⚡ sudoer", theme.sigil), "◆ may drive • member"),
Line::from(""),
Line::from(Span::styled(
" malware bless · press any key to close",
@@ -207,8 +207,12 @@ fn draw_top(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme
fn fmt_line<'a>(l: &'a ChatLine, app: &App, theme: &Theme) -> Line<'a> {
if l.system {
// System lines carry the canonical ⛧ as a placeholder for "the house
// sigil"; swap it for the active theme's sigil so e.g. crypt shows ✝,
// never a pentagram. User messages (l.system == false) are left as typed.
let text = l.text.replace('⛧', &theme.sigil);
return Line::from(Span::styled(
format!(" {}", l.text),
format!(" {} {}", theme.sigil, text),
Style::default().fg(theme.system).add_modifier(Modifier::ITALIC),
));
}
@@ -261,10 +265,10 @@ fn draw_roster(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Th
.iter()
.map(|u| {
let me = u.username == app.me;
// owner · ⚡ sudoer (VM superuser) · ◆ may drive · • member
// <sigil> owner · ⚡ sudoer (VM superuser) · ◆ may drive · • member
let owner = app.owner.as_deref() == Some(u.username.as_str());
let mark = if owner {
""
theme.sigil.as_str()
} else if app.sudoers.contains(&u.username) {
""
} else if app.drivers.contains(&u.username) {
@@ -301,8 +305,8 @@ fn draw_input(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &The
}))
.title(Span::styled(
match &app.pending_offer {
Some(o) => format!(" incoming: {} — /accept or /reject ", o.name),
None if app.driving => " DRIVING the shell — Esc to release ".to_string(),
Some(o) => format!(" {} incoming: {} — /accept or /reject ", theme.sigil, o.name),
None if app.driving => format!(" {} DRIVING the shell — Esc to release ", theme.sigil),
None => " message · enter send · /drive for shell · ctrl-q quit ".to_string(),
},
Style::default().fg(theme.title),
+9 -9
View File
@@ -30,13 +30,13 @@ is_up() { curl -s --max-time 2 "http://$HOST:$PORT/health" 2>/dev/null | grep -q
stop_server() {
if [[ -f "$SRV_PIDFILE" ]]; then
kill "$(cat "$SRV_PIDFILE")" 2>/dev/null && echo "stopped server (pid $(cat "$SRV_PIDFILE"))"
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"
tmux kill-session -t "$SESSION" 2>/dev/null && echo "killed tmux $SESSION" || echo "no tmux session"
stop_server
exit 0
fi
@@ -73,20 +73,20 @@ key() { tmux send-keys -t "$1" "$2"; sleep "${KEY_PAUSE:-0.7}"; }
raw() { tmux send-keys -t "$1" -l "$2"; sleep "${KEY_PAUSE:-0.7}"; } # literal bytes (mouse SGR)
# ─── 1. build ───────────────────────────────────────────────────────────────
echo "building client…"
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"
echo "reusing server on $HOST:$PORT"
else
echo "booting server on $HOST:$PORT"
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"))"
echo "server up (pid $(cat "$SRV_PIDFILE"))"
fi
# ─── 3. tmux session: OWNER | bob ─────────────────────────────────────────────
@@ -104,7 +104,7 @@ 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)"
echo "panes: OWNER=$OWNER MEMBER=$MEMBER (attach: tmux attach -t $SESSION)"
sleep 2
echo
@@ -224,7 +224,7 @@ 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"
echo "session left up for inspection: tmux attach -t $SESSION"
echo "tear down with: $0 --kill"
exit $(( FAIL > 0 ? 1 : 0 ))