Compare commits
2 Commits
1fa8c332ed
...
4a73eb04b9
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a73eb04b9 | |||
| 98e202e0fe |
Executable
+203
@@ -0,0 +1,203 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# host-house.sh — host a hack-house room AND take your own GUI seat, all in tmux.
|
||||||
|
#
|
||||||
|
# All-in-one launcher: spins up the server (via host-room.sh) in a 'server' window
|
||||||
|
# and the ratatui client — already joined to that room — in its own window, both in
|
||||||
|
# a new, distinctly named tmux session (default: hh-house). Others on your
|
||||||
|
# Tailscale/LAN join with the command host-room.sh prints in the server window.
|
||||||
|
#
|
||||||
|
# usage:
|
||||||
|
# ./host-house.sh # host as $USER on 0.0.0.0:4173 (--no-tls)
|
||||||
|
# ./host-house.sh neo # host, take your seat as "neo"
|
||||||
|
# ./host-house.sh 4200 # custom port (also --port)
|
||||||
|
# PW=hunter2 ./host-house.sh # pin a known password (or --password)
|
||||||
|
# ./host-house.sh --theme neon # vestments for your client pane
|
||||||
|
# ./host-house.sh --tls --cert c.pem --key k.pem # real TLS instead of --no-tls
|
||||||
|
# ./host-house.sh --kill # tear down the session + the server we started
|
||||||
|
# ./host-house.sh -h # full usage
|
||||||
|
#
|
||||||
|
# The password lives in memory only; it's handed to the server via $CMD_CHAT_PASSWORD
|
||||||
|
# (host-room.sh), never on its command line. Reveal/share it in-app with /pw.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
host-house.sh — host a room + open your GUI seat, all in one tmux session
|
||||||
|
|
||||||
|
usage:
|
||||||
|
./host-house.sh [NAME] [PORT] [--host ADDR] [--theme NAME]
|
||||||
|
./host-house.sh --tls --cert CERT --key KEY [NAME] [PORT]
|
||||||
|
./host-house.sh --kill
|
||||||
|
./host-house.sh -h | --help
|
||||||
|
|
||||||
|
arguments:
|
||||||
|
NAME your seat in the room (default: \$USER)
|
||||||
|
PORT listen port (positional) (default: 4173)
|
||||||
|
|
||||||
|
flags:
|
||||||
|
--host ADDR server bind address (default: 0.0.0.0 — all NICs)
|
||||||
|
--port PORT listen port (default: 4173)
|
||||||
|
--password PW room password (default: random; or PW=…)
|
||||||
|
--theme NAME vestments for your client pane (church | neon | crypt)
|
||||||
|
--tls serve real TLS (needs --cert and --key)
|
||||||
|
--cert CERT TLS certificate path (implies --tls)
|
||||||
|
--key KEY TLS private key path (implies --tls)
|
||||||
|
--kill tear down the tmux session and the server we started
|
||||||
|
-h, --help show this help and exit
|
||||||
|
|
||||||
|
environment (override any default):
|
||||||
|
SESSION tmux session name (default: hh-house)
|
||||||
|
HOST bind address (default: 0.0.0.0)
|
||||||
|
PORT listen port (default: 4173)
|
||||||
|
PW room password (default: random, openssl-generated)
|
||||||
|
THEME theme name (church | neon | crypt)
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Two windows in one session: 'server' (host-room.sh — join banner + logs) and
|
||||||
|
your client window (the ratatui GUI). Ctrl-b n/p to switch, Ctrl-b d detach.
|
||||||
|
- Default transport is --no-tls (the norm over a Tailscale tunnel); pass --tls
|
||||||
|
with a cert/key for a public/untrusted network.
|
||||||
|
|
||||||
|
examples:
|
||||||
|
./host-house.sh # host as \$USER on 0.0.0.0:4173
|
||||||
|
./host-house.sh neo 4200 # seat "neo", port 4200
|
||||||
|
./host-house.sh --kill # tear it all 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"
|
||||||
|
HOST_ROOM="$HERE/scripts/host-room.sh"
|
||||||
|
|
||||||
|
SESSION="${SESSION:-hh-house}"
|
||||||
|
HOST="${HOST:-0.0.0.0}"
|
||||||
|
PORT="${PORT:-4173}"
|
||||||
|
PW="${PW:-$(openssl rand -hex 12)}"
|
||||||
|
THEMES_DIR="$HERE/themes"
|
||||||
|
THEME="${THEME:-}"
|
||||||
|
USE_TLS=0; CERT=""; KEY=""
|
||||||
|
DO_KILL=0
|
||||||
|
NAME=""
|
||||||
|
|
||||||
|
# Parse flags. A bare number is the port; the first bare word is your seat name.
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-h|--help|-help) usage; exit 0 ;;
|
||||||
|
--kill) DO_KILL=1; shift ;;
|
||||||
|
--host) HOST="$2"; shift 2 ;;
|
||||||
|
--host=*) HOST="${1#--host=}"; shift ;;
|
||||||
|
--port) PORT="$2"; shift 2 ;;
|
||||||
|
--port=*) PORT="${1#--port=}"; shift ;;
|
||||||
|
--password) PW="$2"; shift 2 ;;
|
||||||
|
--password=*) PW="${1#--password=}"; shift ;;
|
||||||
|
--theme) THEME="$2"; shift 2 ;;
|
||||||
|
--theme=*) THEME="${1#--theme=}"; shift ;;
|
||||||
|
--tls) USE_TLS=1; shift ;;
|
||||||
|
--cert) CERT="$2"; USE_TLS=1; shift 2 ;;
|
||||||
|
--cert=*) CERT="${1#--cert=}"; USE_TLS=1; shift ;;
|
||||||
|
--key) KEY="$2"; USE_TLS=1; shift 2 ;;
|
||||||
|
--key=*) KEY="${1#--key=}"; USE_TLS=1; shift ;;
|
||||||
|
[0-9]*) PORT="$1"; shift ;;
|
||||||
|
-*) echo "✖ unknown flag: $1 (try --help)" >&2; exit 2 ;;
|
||||||
|
*) [[ -z "$NAME" ]] && NAME="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
NAME="${NAME:-${USER:-$(id -un)}}"
|
||||||
|
|
||||||
|
# Stop the server on $PORT (the one holding the port), used by --kill and before
|
||||||
|
# we (re)launch so only the room we start answers on this port.
|
||||||
|
stop_server() {
|
||||||
|
local pid
|
||||||
|
pid="$(ss -ltnpH "sport = :$PORT" 2>/dev/null | grep -oP 'pid=\K[0-9]+' | head -1)"
|
||||||
|
[[ -n "$pid" ]] && kill "$pid" 2>/dev/null && echo "stopped server holding :$PORT (pid $pid)"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ $DO_KILL -eq 1 ]]; then
|
||||||
|
tmux kill-session -t "$SESSION" 2>/dev/null && echo "killed tmux session $SESSION"
|
||||||
|
stop_server
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
command -v tmux >/dev/null 2>&1 || { echo "✖ tmux not found — install it first" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Resolve transport: --no-tls by default; --tls needs a cert and key.
|
||||||
|
if [[ $USE_TLS -eq 1 ]]; then
|
||||||
|
[[ -n "$CERT" && -n "$KEY" ]] || { echo "✖ --tls needs both --cert and --key" >&2; exit 2; }
|
||||||
|
[[ -f "$CERT" ]] || { echo "✖ cert not found: $CERT" >&2; exit 2; }
|
||||||
|
[[ -f "$KEY" ]] || { echo "✖ key not found: $KEY" >&2; exit 2; }
|
||||||
|
SCHEME="https"; CURLK="-k"; CLIENT_FLAG="--insecure"
|
||||||
|
TLS_ARGS=(--tls --cert "$CERT" --key "$KEY")
|
||||||
|
else
|
||||||
|
SCHEME="http"; CURLK=""; CLIENT_FLAG="--no-tls"
|
||||||
|
TLS_ARGS=()
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The local client connects over loopback when the server binds all interfaces.
|
||||||
|
case "$HOST" in
|
||||||
|
0.0.0.0|::|"") CLIENT_HOST="127.0.0.1" ;;
|
||||||
|
*) CLIENT_HOST="$HOST" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Resolve a theme name → TOML path (empty = 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#.*/##; s/\.toml$//' | paste -sd' ' -)"
|
||||||
|
echo "✖ no theme '$THEME' in $THEMES_DIR (available: ${avail:-none})" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Guard: don't rebuild the session we're sitting in (it would close on us).
|
||||||
|
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-host $0 $NAME) or run --kill from another window." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
is_up() { curl -s $CURLK --max-time 2 "$SCHEME://$CLIENT_HOST:$PORT/health" 2>/dev/null | grep -q '"status":"ok"'; }
|
||||||
|
|
||||||
|
# 1. build the client so the GUI pane has a binary to run.
|
||||||
|
echo "building client…"
|
||||||
|
( cd "$HERE" && cargo build --quiet ) || { echo "✖ build failed"; exit 1; }
|
||||||
|
|
||||||
|
# 2. clear the port so only the room we start answers on it.
|
||||||
|
stop_server
|
||||||
|
|
||||||
|
# 3. server pane: run host-room.sh (foreground, shows the join banner + logs).
|
||||||
|
# host-room reads HOST/PORT/PW from the env; --tls flags are passed through.
|
||||||
|
server_cmd="$(printf 'env HOST=%q PORT=%q PW=%q %q' "$HOST" "$PORT" "$PW" "$HOST_ROOM")"
|
||||||
|
for arg in "${TLS_ARGS[@]}"; do server_cmd+=" $(printf '%q' "$arg")"; done
|
||||||
|
|
||||||
|
tmux kill-session -t "$SESSION" 2>/dev/null
|
||||||
|
tmux new-session -d -s "$SESSION" -n server -x 220 -y 50 -c "$HERE" "$server_cmd"
|
||||||
|
|
||||||
|
# 4. wait for the room to answer before seating the client.
|
||||||
|
echo "waiting for the room on $CLIENT_HOST:$PORT…"
|
||||||
|
for _ in $(seq 1 30); do is_up && break; sleep 0.5; done
|
||||||
|
is_up || echo "⚠ server not up yet — the client pane will keep its own error on screen" >&2
|
||||||
|
|
||||||
|
# 5. client window: the ratatui GUI, joined to the room — a separate window in
|
||||||
|
# the SAME session (server stays on its own window). The trailing read keeps
|
||||||
|
# the window open if connect exits (e.g. auth error) instead of vanishing.
|
||||||
|
theme_arg=""
|
||||||
|
[[ -n "$THEME_PATH" ]] && theme_arg="$(printf -- '--theme %q' "$THEME_PATH")"
|
||||||
|
client_cmd="$(printf '%q connect %q %q %q --password %q %s %s; ec=$?; printf "\n%s left the house (exit %%s) — press enter to close\n" "$ec"; read _' \
|
||||||
|
"$BIN" "$CLIENT_HOST" "$PORT" "$NAME" "$PW" "$CLIENT_FLAG" "$theme_arg" "$NAME")"
|
||||||
|
tmux new-window -t "$SESSION" -n "$NAME" -c "$HERE" "$client_cmd"
|
||||||
|
tmux select-window -t "$SESSION:$NAME" # land on the GUI; Ctrl-b p → server logs
|
||||||
|
|
||||||
|
echo "house: $NAME · session: $SESSION · $SCHEME://$HOST:$PORT · vestments: ${THEME:-church (default)}"
|
||||||
|
echo "room password: $PW (share with joiners; reveal in-app with /pw)"
|
||||||
|
echo "tear down later with: $0 --kill"
|
||||||
|
|
||||||
|
# 6. land in the house: attach from a plain shell, switch-client from inside tmux.
|
||||||
|
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
|
||||||
@@ -103,6 +103,22 @@ else
|
|||||||
PROTO="http"; CLIENT_FLAG="--no-tls"
|
PROTO="http"; CLIENT_FLAG="--no-tls"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Free the port if a stale listener is squatting on it (e.g. a server left over
|
||||||
|
# from a previous run), so the bind can't fail with "address already in use".
|
||||||
|
# We only target processes LISTENing on this TCP port, then escalate to -9.
|
||||||
|
free_port() {
|
||||||
|
local port="$1" pids
|
||||||
|
pids="$(lsof -ti "tcp:$port" -sTCP:LISTEN 2>/dev/null || true)"
|
||||||
|
[[ -z "$pids" ]] && pids="$(fuser "$port/tcp" 2>/dev/null | tr -s ' ' '\n' | grep -E '^[0-9]+$' || true)"
|
||||||
|
[[ -z "$pids" ]] && return 0
|
||||||
|
echo "⚠ port $port already in use by PID(s): $(echo "$pids" | tr '\n' ' ')— killing" >&2
|
||||||
|
kill $pids 2>/dev/null || true
|
||||||
|
sleep 0.5
|
||||||
|
pids="$(lsof -ti "tcp:$port" -sTCP:LISTEN 2>/dev/null || true)"
|
||||||
|
[[ -n "$pids" ]] && { echo " still alive — SIGKILL" >&2; kill -9 $pids 2>/dev/null || true; }
|
||||||
|
}
|
||||||
|
free_port "$PORT"
|
||||||
|
|
||||||
# Sanity-check deps so failures are an actionable hint, not a stack trace.
|
# Sanity-check deps so failures are an actionable hint, not a stack trace.
|
||||||
if ! "$PY" -c "import sanic" >/dev/null 2>&1; then
|
if ! "$PY" -c "import sanic" >/dev/null 2>&1; then
|
||||||
echo "✖ Python deps missing (sanic not importable with $PY)." >&2
|
echo "✖ Python deps missing (sanic not importable with $PY)." >&2
|
||||||
|
|||||||
+5
-1
@@ -12,10 +12,14 @@ cd "$(dirname "$0")/.."
|
|||||||
# are best-effort and fast-forward only: an unreachable remote or diverged
|
# are best-effort and fast-forward only: an unreachable remote or diverged
|
||||||
# history just warns and is skipped — it never blocks you from joining.
|
# history just warns and is skipped — it never blocks you from joining.
|
||||||
BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)"
|
BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)"
|
||||||
|
# Cap each pull so an unreachable/slow remote fails fast instead of hanging on
|
||||||
|
# the OS TCP timeout; GIT_TERMINAL_PROMPT=0 stops it blocking on a credential
|
||||||
|
# prompt. (No `timeout` on this box? fall back to a plain pull.)
|
||||||
|
TO=""; command -v timeout >/dev/null 2>&1 && TO="timeout 10"
|
||||||
for remote in gitea origin; do
|
for remote in gitea origin; do
|
||||||
if git remote get-url "$remote" >/dev/null 2>&1; then
|
if git remote get-url "$remote" >/dev/null 2>&1; then
|
||||||
echo "⛧ syncing $BRANCH from $remote…"
|
echo "⛧ syncing $BRANCH from $remote…"
|
||||||
git pull --ff-only "$remote" "$BRANCH" 2>&1 \
|
GIT_TERMINAL_PROMPT=0 $TO git pull --ff-only "$remote" "$BRANCH" 2>&1 \
|
||||||
|| echo " (skipped — couldn't fast-forward from $remote/$BRANCH)"
|
|| echo " (skipped — couldn't fast-forward from $remote/$BRANCH)"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|||||||
+471
-306
File diff suppressed because it is too large
Load Diff
+198
-56
@@ -9,9 +9,17 @@ use base64::engine::general_purpose::STANDARD;
|
|||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::io::{Read, Write};
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
|
/// In-memory ceiling — used only by `tar_path` (sandbox injection), which builds
|
||||||
|
/// the archive in RAM. Streamed `/send` transfers use `STREAM_MAX` instead.
|
||||||
pub const MAX_SIZE: usize = 50 * 1024 * 1024;
|
pub const MAX_SIZE: usize = 50 * 1024 * 1024;
|
||||||
|
/// Streamed-transfer ceiling (disk-to-disk). Far larger than `MAX_SIZE` because
|
||||||
|
/// `/send` writes chunks straight to a temp file and reads them back the same
|
||||||
|
/// way — memory stays flat regardless of size. This only guards against a sender
|
||||||
|
/// filling the receiver's disk (e.g. a multi-GB VM appliance is fine).
|
||||||
|
pub const STREAM_MAX: u64 = 16 * 1024 * 1024 * 1024; // 16 GiB
|
||||||
pub const CHUNK: usize = 64 * 1024;
|
pub const CHUNK: usize = 64 * 1024;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -22,6 +30,11 @@ pub struct Offer {
|
|||||||
pub sha256: String,
|
pub sha256: String,
|
||||||
pub dir: bool,
|
pub dir: bool,
|
||||||
pub from: String,
|
pub from: String,
|
||||||
|
/// Direct-send recipient: `Some(username)` means only that member should be
|
||||||
|
/// prompted; `None` (or absent/empty on the wire) means the whole room. The
|
||||||
|
/// relay still broadcasts to everyone, so this is an advisory app-layer
|
||||||
|
/// filter — non-recipients drop the offer instead of prompting.
|
||||||
|
pub to: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Ft {
|
pub enum Ft {
|
||||||
@@ -32,12 +45,6 @@ pub enum Ft {
|
|||||||
Done(String),
|
Done(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sha256_hex(data: &[u8]) -> String {
|
|
||||||
let mut h = Sha256::new();
|
|
||||||
h.update(data);
|
|
||||||
hex::encode(h.finalize())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn human(size: usize) -> String {
|
pub fn human(size: usize) -> String {
|
||||||
let (mut s, units) = (size as f64, ["B", "KB", "MB", "GB"]);
|
let (mut s, units) = (size as f64, ["B", "KB", "MB", "GB"]);
|
||||||
for u in units {
|
for u in units {
|
||||||
@@ -49,43 +56,103 @@ pub fn human(size: usize) -> String {
|
|||||||
format!("{s:.1} TB")
|
format!("{s:.1} TB")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the payload to offer for a path → (name, bytes, is_dir).
|
/// What to stream for an outgoing `/send`: a single file on disk — the original
|
||||||
pub fn read_payload(path: &str) -> Result<(String, Vec<u8>, bool)> {
|
/// file, or a temp `.tar` we built for a directory. The sender reads `src` in
|
||||||
|
/// `CHUNK` blocks, so memory stays flat no matter how large the payload is.
|
||||||
|
pub struct SendSrc {
|
||||||
|
pub name: String,
|
||||||
|
pub src: PathBuf,
|
||||||
|
/// `src` is a temp tar we created and should delete once sending finishes.
|
||||||
|
pub temp: bool,
|
||||||
|
pub size: u64,
|
||||||
|
pub sha256: String,
|
||||||
|
pub dir: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prepare a path for streamed sending: stat + stream-hash a file directly, or
|
||||||
|
/// tar a directory to a temp file first (so even large trees stream from disk).
|
||||||
|
/// Enforces `STREAM_MAX`. This walks the bytes once to hash — call it off the UI
|
||||||
|
/// thread for large payloads.
|
||||||
|
pub fn prepare_send(path: &str) -> Result<SendSrc> {
|
||||||
let p = Path::new(path);
|
let p = Path::new(path);
|
||||||
let meta = std::fs::metadata(p).with_context(|| format!("not found: {path}"))?;
|
let meta = std::fs::metadata(p).with_context(|| format!("not found: {path}"))?;
|
||||||
if meta.is_dir() {
|
if meta.is_dir() {
|
||||||
let bytes = tar_dir(p)?;
|
let base = p
|
||||||
anyhow::ensure!(
|
.file_name()
|
||||||
bytes.len() <= MAX_SIZE,
|
.and_then(|s| s.to_str())
|
||||||
"directory too large ({})",
|
.unwrap_or("dir")
|
||||||
human(bytes.len())
|
.to_string();
|
||||||
);
|
let tmp = std::env::temp_dir().join(format!(
|
||||||
let base = p.file_name().and_then(|s| s.to_str()).unwrap_or("dir");
|
"hh-send-{}-{}.tar",
|
||||||
Ok((format!("{base}.tar"), bytes, true))
|
std::process::id(),
|
||||||
|
sanitize(&base)
|
||||||
|
));
|
||||||
|
tar_to_file(p, &tmp)?;
|
||||||
|
let (size, sha256) = hash_file(&tmp)?;
|
||||||
|
Ok(SendSrc {
|
||||||
|
name: format!("{base}.tar"),
|
||||||
|
src: tmp,
|
||||||
|
temp: true,
|
||||||
|
size,
|
||||||
|
sha256,
|
||||||
|
dir: true,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
anyhow::ensure!(
|
let (size, sha256) = hash_file(p)?;
|
||||||
meta.len() as usize <= MAX_SIZE,
|
|
||||||
"file too large (max 50 MB)"
|
|
||||||
);
|
|
||||||
let bytes = std::fs::read(p)?;
|
|
||||||
let name = p
|
let name = p
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|s| s.to_str())
|
.and_then(|s| s.to_str())
|
||||||
.unwrap_or("file")
|
.unwrap_or("file")
|
||||||
.to_string();
|
.to_string();
|
||||||
Ok((name, bytes, false))
|
Ok(SendSrc {
|
||||||
|
name,
|
||||||
|
src: p.to_path_buf(),
|
||||||
|
temp: false,
|
||||||
|
size,
|
||||||
|
sha256,
|
||||||
|
dir: false,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tar_dir(dir: &Path) -> Result<Vec<u8>> {
|
/// Tar a directory to a file on disk (constant memory, unlike `tar_path`).
|
||||||
let mut buf = Vec::new();
|
fn tar_to_file(dir: &Path, out: &Path) -> Result<()> {
|
||||||
{
|
let f = std::fs::File::create(out).with_context(|| format!("create {}", out.display()))?;
|
||||||
let mut tb = tar::Builder::new(&mut buf);
|
let mut tb = tar::Builder::new(std::io::BufWriter::new(f));
|
||||||
let base = dir.file_name().unwrap_or_default();
|
let base = dir.file_name().unwrap_or_default();
|
||||||
tb.append_dir_all(base, dir).context("tar directory")?;
|
tb.append_dir_all(base, dir).context("tar directory")?;
|
||||||
tb.finish()?;
|
tb.finish()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream-hash a file → `(size, sha256-hex)`, enforcing `STREAM_MAX`. Reads in
|
||||||
|
/// `CHUNK` blocks so memory stays flat.
|
||||||
|
fn hash_file(path: &Path) -> Result<(u64, String)> {
|
||||||
|
let mut f = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
let mut buf = vec![0u8; CHUNK];
|
||||||
|
let mut total: u64 = 0;
|
||||||
|
loop {
|
||||||
|
let n = f.read(&mut buf)?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
Ok(buf)
|
total += n as u64;
|
||||||
|
anyhow::ensure!(
|
||||||
|
total <= STREAM_MAX,
|
||||||
|
"too large (max {})",
|
||||||
|
human(STREAM_MAX as usize)
|
||||||
|
);
|
||||||
|
hasher.update(&buf[..n]);
|
||||||
|
}
|
||||||
|
Ok((total, hex::encode(hasher.finalize())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filesystem-safe slug for temp-file names (transfer ids, dir basenames).
|
||||||
|
fn sanitize(s: &str) -> String {
|
||||||
|
s.chars()
|
||||||
|
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { c } else { '_' })
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tar any local path (file or directory) to bytes for injecting into a
|
/// Tar any local path (file or directory) to bytes for injecting into a
|
||||||
@@ -113,13 +180,67 @@ pub fn tar_path(path: &Path) -> Result<(String, Vec<u8>)> {
|
|||||||
Ok((base, buf))
|
Ok((base, buf))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist received bytes under `downloads`. Directories (tar) are extracted
|
/// Disk-backed receiver: incoming chunks are written straight to a temp `.part`
|
||||||
/// with a guard rejecting absolute paths and `..` escapes (zip-slip).
|
/// file under `downloads/` and hashed as they arrive, so a multi-GB transfer
|
||||||
pub fn save(downloads: &Path, offer: &Offer, data: &[u8]) -> Result<PathBuf> {
|
/// never sits in RAM. `finish` returns the temp path + computed digest for
|
||||||
|
/// verification; `commit` then moves/extracts it into place.
|
||||||
|
pub struct Sink {
|
||||||
|
file: std::fs::File,
|
||||||
|
path: PathBuf,
|
||||||
|
hasher: Sha256,
|
||||||
|
written: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sink {
|
||||||
|
/// Create the temp file under `downloads/` (same filesystem as the final
|
||||||
|
/// destination → atomic rename on commit). Named from the transfer id.
|
||||||
|
pub fn create(downloads: &Path, id: &str) -> Result<Self> {
|
||||||
|
std::fs::create_dir_all(downloads)?;
|
||||||
|
let path = downloads.join(format!(".hh-{}.part", sanitize(id)));
|
||||||
|
let file =
|
||||||
|
std::fs::File::create(&path).with_context(|| format!("create {}", path.display()))?;
|
||||||
|
Ok(Self {
|
||||||
|
file,
|
||||||
|
path,
|
||||||
|
hasher: Sha256::new(),
|
||||||
|
written: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write(&mut self, data: &[u8]) -> Result<()> {
|
||||||
|
self.file.write_all(data)?;
|
||||||
|
self.hasher.update(data);
|
||||||
|
self.written += data.len() as u64;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn written(&self) -> u64 {
|
||||||
|
self.written
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flush and return `(temp_path, sha256-hex)`.
|
||||||
|
pub fn finish(mut self) -> Result<(PathBuf, String)> {
|
||||||
|
self.file.flush()?;
|
||||||
|
let sha = hex::encode(self.hasher.finalize());
|
||||||
|
Ok((self.path, sha))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort temp-file cleanup (reject / overflow / write error).
|
||||||
|
pub fn abort(self) {
|
||||||
|
drop(self.file);
|
||||||
|
let _ = std::fs::remove_file(&self.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finalize a verified streamed transfer sitting at `tmp` into `downloads/`:
|
||||||
|
/// extract a directory tar (zip-slip guarded) or move the file to a unique name.
|
||||||
|
/// Consumes `tmp` (renamed away or removed).
|
||||||
|
pub fn commit(downloads: &Path, offer: &Offer, tmp: &Path) -> Result<PathBuf> {
|
||||||
std::fs::create_dir_all(downloads)?;
|
std::fs::create_dir_all(downloads)?;
|
||||||
if offer.dir {
|
if offer.dir {
|
||||||
// Extract the tar's own top-level dir directly under downloads/.
|
// Extract the tar's own top-level dir directly under downloads/.
|
||||||
let mut ar = tar::Archive::new(data);
|
let f = std::fs::File::open(tmp).with_context(|| format!("open {}", tmp.display()))?;
|
||||||
|
let mut ar = tar::Archive::new(std::io::BufReader::new(f));
|
||||||
let mut top: Option<std::ffi::OsString> = None;
|
let mut top: Option<std::ffi::OsString> = None;
|
||||||
for entry in ar.entries()? {
|
for entry in ar.entries()? {
|
||||||
let mut e = entry?;
|
let mut e = entry?;
|
||||||
@@ -136,6 +257,7 @@ pub fn save(downloads: &Path, offer: &Offer, data: &[u8]) -> Result<PathBuf> {
|
|||||||
e.unpack_in(downloads)
|
e.unpack_in(downloads)
|
||||||
.with_context(|| format!("extract {}", path.display()))?;
|
.with_context(|| format!("extract {}", path.display()))?;
|
||||||
}
|
}
|
||||||
|
let _ = std::fs::remove_file(tmp);
|
||||||
Ok(top
|
Ok(top
|
||||||
.map(|t| downloads.join(t))
|
.map(|t| downloads.join(t))
|
||||||
.unwrap_or_else(|| downloads.to_path_buf()))
|
.unwrap_or_else(|| downloads.to_path_buf()))
|
||||||
@@ -149,7 +271,11 @@ pub fn save(downloads: &Path, offer: &Offer, data: &[u8]) -> Result<PathBuf> {
|
|||||||
.and_then(|s| s.to_str())
|
.and_then(|s| s.to_str())
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
let dest = unique(downloads, stem, ext);
|
let dest = unique(downloads, stem, ext);
|
||||||
std::fs::write(&dest, data)?;
|
// Same-filesystem rename; fall back to copy for a cross-device temp dir.
|
||||||
|
if std::fs::rename(tmp, &dest).is_err() {
|
||||||
|
std::fs::copy(tmp, &dest)?;
|
||||||
|
let _ = std::fs::remove_file(tmp);
|
||||||
|
}
|
||||||
Ok(dest)
|
Ok(dest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,6 +313,10 @@ pub fn parse(text: &str, sender: &str) -> Option<Ft> {
|
|||||||
sha256: v["sha256"].as_str().unwrap_or("").to_string(),
|
sha256: v["sha256"].as_str().unwrap_or("").to_string(),
|
||||||
dir: v["dir"].as_bool().unwrap_or(false),
|
dir: v["dir"].as_bool().unwrap_or(false),
|
||||||
from: sender.to_string(),
|
from: sender.to_string(),
|
||||||
|
to: match v["to"].as_str() {
|
||||||
|
Some(s) if !s.is_empty() => Some(s.to_string()),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
})),
|
})),
|
||||||
"accept" => Some(Ft::Accept(v["id"].as_str()?.to_string())),
|
"accept" => Some(Ft::Accept(v["id"].as_str()?.to_string())),
|
||||||
"reject" => Some(Ft::Reject(v["id"].as_str()?.to_string())),
|
"reject" => Some(Ft::Reject(v["id"].as_str()?.to_string())),
|
||||||
@@ -203,6 +333,34 @@ pub fn parse(text: &str, sender: &str) -> Option<Ft> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Drive a full streamed transfer: prepare the source, feed `src` through a
|
||||||
|
/// disk-backed `Sink` in `CHUNK` blocks (as the wire would), verify the hash,
|
||||||
|
/// then commit into `downloads/`. Returns the committed path.
|
||||||
|
fn stream_roundtrip(downloads: &Path, src: &SendSrc, id: &str) -> PathBuf {
|
||||||
|
let mut sink = Sink::create(downloads, id).unwrap();
|
||||||
|
let mut f = std::fs::File::open(&src.src).unwrap();
|
||||||
|
let mut buf = vec![0u8; CHUNK];
|
||||||
|
loop {
|
||||||
|
let n = f.read(&mut buf).unwrap();
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
sink.write(&buf[..n]).unwrap();
|
||||||
|
}
|
||||||
|
let offer = Offer {
|
||||||
|
id: id.into(),
|
||||||
|
name: src.name.clone(),
|
||||||
|
size: src.size,
|
||||||
|
sha256: src.sha256.clone(),
|
||||||
|
dir: src.dir,
|
||||||
|
from: "x".into(),
|
||||||
|
to: None,
|
||||||
|
};
|
||||||
|
let (tmp, sha) = sink.finish().unwrap();
|
||||||
|
assert_eq!(sha, offer.sha256, "streamed hash matches the offer");
|
||||||
|
commit(downloads, &offer, &tmp).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn file_payload_roundtrip() {
|
fn file_payload_roundtrip() {
|
||||||
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()));
|
||||||
@@ -210,19 +368,11 @@ mod tests {
|
|||||||
let src = dir.join("note.txt");
|
let src = dir.join("note.txt");
|
||||||
std::fs::write(&src, b"offering to the clergy").unwrap();
|
std::fs::write(&src, b"offering to the clergy").unwrap();
|
||||||
|
|
||||||
let (name, bytes, is_dir) = read_payload(src.to_str().unwrap()).unwrap();
|
let prepared = prepare_send(src.to_str().unwrap()).unwrap();
|
||||||
assert_eq!(name, "note.txt");
|
assert_eq!(prepared.name, "note.txt");
|
||||||
assert!(!is_dir);
|
assert!(!prepared.dir);
|
||||||
let offer = Offer {
|
|
||||||
id: "1".into(),
|
|
||||||
name,
|
|
||||||
size: bytes.len() as u64,
|
|
||||||
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 = stream_roundtrip(&dl, &prepared, "1");
|
||||||
assert_eq!(std::fs::read(&out).unwrap(), b"offering to the clergy");
|
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();
|
||||||
}
|
}
|
||||||
@@ -235,19 +385,11 @@ mod tests {
|
|||||||
std::fs::write(proj.join("a.txt"), b"AAA").unwrap();
|
std::fs::write(proj.join("a.txt"), b"AAA").unwrap();
|
||||||
std::fs::write(proj.join("sub/b.txt"), b"BBB").unwrap();
|
std::fs::write(proj.join("sub/b.txt"), b"BBB").unwrap();
|
||||||
|
|
||||||
let (name, bytes, is_dir) = read_payload(proj.to_str().unwrap()).unwrap();
|
let prepared = prepare_send(proj.to_str().unwrap()).unwrap();
|
||||||
assert_eq!(name, "proj.tar");
|
assert_eq!(prepared.name, "proj.tar");
|
||||||
assert!(is_dir);
|
assert!(prepared.dir);
|
||||||
let offer = Offer {
|
|
||||||
id: "1".into(),
|
|
||||||
name,
|
|
||||||
size: bytes.len() as u64,
|
|
||||||
sha256: sha256_hex(&bytes),
|
|
||||||
dir: true,
|
|
||||||
from: "x".into(),
|
|
||||||
};
|
|
||||||
let dl = dir.join("dl");
|
let dl = dir.join("dl");
|
||||||
let out = save(&dl, &offer, &bytes).unwrap(); // -> dl/proj
|
let out = stream_roundtrip(&dl, &prepared, "1"); // -> dl/proj
|
||||||
assert!(out.ends_with("proj"));
|
assert!(out.ends_with("proj"));
|
||||||
assert_eq!(std::fs::read(out.join("a.txt")).unwrap(), b"AAA");
|
assert_eq!(std::fs::read(out.join("a.txt")).unwrap(), b"AAA");
|
||||||
assert_eq!(std::fs::read(out.join("sub/b.txt")).unwrap(), b"BBB");
|
assert_eq!(std::fs::read(out.join("sub/b.txt")).unwrap(), b"BBB");
|
||||||
|
|||||||
@@ -108,6 +108,45 @@ pub fn list_vms() -> Result<Vec<String>> {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Is a VM with this name registered locally? (i.e. present in `list_vms`).
|
||||||
|
/// Used to tell a "host" (already has the appliance imported) from someone who
|
||||||
|
/// still needs it pulled in before `gui_launch` can find it.
|
||||||
|
pub fn vm_registered(name: &str) -> bool {
|
||||||
|
list_vms()
|
||||||
|
.map(|vms| vms.iter().any(|v| v == name))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import a VirtualBox appliance (`.ova`/`.ovf`) so its VM registers locally and
|
||||||
|
/// becomes launchable via `gui_launch`. This is how a non-host "pulls" a shared
|
||||||
|
/// VM onto their own machine after the appliance arrives over the encrypted
|
||||||
|
/// channel. Blocking — run off the UI thread. Returns the imported VM's name.
|
||||||
|
pub fn import_appliance(ova: &std::path::Path) -> Result<String> {
|
||||||
|
let before = list_vms().unwrap_or_default();
|
||||||
|
let out = Command::new("VBoxManage")
|
||||||
|
.arg("import")
|
||||||
|
.arg(ova)
|
||||||
|
.output()
|
||||||
|
.context("VBoxManage import (is VirtualBox installed?)")?;
|
||||||
|
if !out.status.success() {
|
||||||
|
let err = String::from_utf8_lossy(&out.stderr);
|
||||||
|
anyhow::bail!(
|
||||||
|
"VBoxManage import failed: {}",
|
||||||
|
err.lines().last().unwrap_or("").trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The imported name is whatever's newly registered; fall back to the file's
|
||||||
|
// stem if the diff is ambiguous (e.g. a re-import of an existing name).
|
||||||
|
let after = list_vms().unwrap_or_default();
|
||||||
|
let added = after.into_iter().find(|v| !before.contains(v));
|
||||||
|
Ok(added.unwrap_or_else(|| {
|
||||||
|
ova.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("imported-vm")
|
||||||
|
.to_string()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/// Is a VM currently running? (`VBoxManage list runningvms`)
|
/// Is a VM currently running? (`VBoxManage list runningvms`)
|
||||||
pub fn vm_running(name: &str) -> bool {
|
pub fn vm_running(name: &str) -> bool {
|
||||||
Command::new("VBoxManage")
|
Command::new("VBoxManage")
|
||||||
|
|||||||
+90
-17
@@ -47,6 +47,9 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
|
|||||||
if app.show_help {
|
if app.show_help {
|
||||||
draw_help(f, f.area(), app, theme);
|
draw_help(f, f.area(), app, theme);
|
||||||
}
|
}
|
||||||
|
if app.vbox_picker.is_some() {
|
||||||
|
draw_vbox_picker(f, f.area(), app, theme);
|
||||||
|
}
|
||||||
if let Some(msg) = &app.error {
|
if let Some(msg) = &app.error {
|
||||||
draw_error(f, f.area(), theme, msg);
|
draw_error(f, f.area(), theme, msg);
|
||||||
}
|
}
|
||||||
@@ -150,8 +153,8 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
|
|||||||
title: "SANDBOX",
|
title: "SANDBOX",
|
||||||
items: vec![
|
items: vec![
|
||||||
kv(
|
kv(
|
||||||
"/sbx launch [backend]",
|
"/sbx launch <docker|multipass|vbox>",
|
||||||
"summon a sandbox: local | docker | multipass",
|
"summon a sandbox on YOUR machine — docker/multipass relay a shared shell; vbox <vm> opens a local GUI (or local)",
|
||||||
),
|
),
|
||||||
kv("/sbx stop", "tear down the sandbox (purges the VM)"),
|
kv("/sbx stop", "tear down the sandbox (purges the VM)"),
|
||||||
kv(
|
kv(
|
||||||
@@ -172,22 +175,26 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
|
|||||||
HelpCluster {
|
HelpCluster {
|
||||||
title: "VIRTUALBOX (local GUI VM)",
|
title: "VIRTUALBOX (local GUI VM)",
|
||||||
items: vec![
|
items: vec![
|
||||||
|
kv(
|
||||||
|
"/sbx launch vbox",
|
||||||
|
"open the arrow-navigable VM picker (↑↓ move · Enter/Tab boot · Esc dismiss)",
|
||||||
|
),
|
||||||
|
kv(
|
||||||
|
"/sbx launch vbox [gui] <vm>",
|
||||||
|
"boot a VM's GUI on YOUR machine — host with the VM already imported launches instantly",
|
||||||
|
),
|
||||||
|
kv(
|
||||||
|
"/sbx launch vbox gui <vm> yes",
|
||||||
|
"non-host opt-in: append yes to install VirtualBox and/or import the shared .ova, then boot",
|
||||||
|
),
|
||||||
|
kv(
|
||||||
|
"/sbx gui <vm> [yes]",
|
||||||
|
"alias of /sbx launch vbox gui <vm> [yes]",
|
||||||
|
),
|
||||||
kv("/sbx vms", "detect VirtualBox + list local VMs"),
|
kv("/sbx vms", "detect VirtualBox + list local VMs"),
|
||||||
kv(
|
|
||||||
"/sbx gui <vm> [--install]",
|
|
||||||
"open a shared VM locally — host & guest each get their own copy",
|
|
||||||
),
|
|
||||||
kv(
|
|
||||||
"/sbx gui yes",
|
|
||||||
"confirm a pending launch (advance the consent gate)",
|
|
||||||
),
|
|
||||||
kv(
|
|
||||||
"/sbx gui cancel",
|
|
||||||
"abort a pending launch (nothing stopped or installed)",
|
|
||||||
),
|
|
||||||
kv(
|
kv(
|
||||||
"/sbx vmsave <vm> [label] [--local]",
|
"/sbx vmsave <vm> [label] [--local]",
|
||||||
"snapshot a VM (--local also exports a portable .ova)",
|
"snapshot a VM (--local also exports a portable .ova to /send)",
|
||||||
),
|
),
|
||||||
kv("/sbx vmsnaps <vm>", "list a VM's snapshots"),
|
kv("/sbx vmsnaps <vm>", "list a VM's snapshots"),
|
||||||
],
|
],
|
||||||
@@ -238,8 +245,8 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
|
|||||||
HelpCluster {
|
HelpCluster {
|
||||||
title: "FILES",
|
title: "FILES",
|
||||||
items: vec![
|
items: vec![
|
||||||
kv("/send <file>", "offer a file to the room"),
|
kv("/send <user> <path>", "send a file/dir directly to one member"),
|
||||||
kv("/sendd <dir>", "offer a directory (sent as a tar)"),
|
kv("/sendroom <path>", "offer a file/dir to the whole room"),
|
||||||
kv("/accept · /reject", "respond to an incoming file offer"),
|
kv("/accept · /reject", "respond to an incoming file offer"),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -277,6 +284,7 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
|
|||||||
"reconnect to the house after a drop / AFK",
|
"reconnect to the house after a drop / AFK",
|
||||||
),
|
),
|
||||||
kv("/pw", "show this room's password (local only)"),
|
kv("/pw", "show this room's password (local only)"),
|
||||||
|
kv("/clear", "wipe your chat scrollback (local only)"),
|
||||||
kv("Ctrl-C · Ctrl-Q", "quit hack-house"),
|
kv("Ctrl-C · Ctrl-Q", "quit hack-house"),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -343,6 +351,71 @@ fn help_render_lines(app: &App, theme: &Theme) -> Vec<Line<'static>> {
|
|||||||
lines
|
lines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Arrow-navigable VirtualBox VM picker — a small dropdown anchored just above
|
||||||
|
/// the input box. The highlighted row is inverted (theme bg on accent); Enter or
|
||||||
|
/// Tab boots it, Esc dismisses. Key handling lives in the run loop.
|
||||||
|
fn draw_vbox_picker(f: &mut Frame, area: Rect, app: &App, theme: &Theme) {
|
||||||
|
let Some(picker) = &app.vbox_picker else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Width: widest VM name (plus a little chrome), clamped to the terminal.
|
||||||
|
let longest = picker
|
||||||
|
.vms
|
||||||
|
.iter()
|
||||||
|
.map(|v| v.chars().count())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0);
|
||||||
|
let w = (longest as u16 + 6).clamp(24, area.width.saturating_sub(2)).max(8);
|
||||||
|
// Height: one row per VM + borders, capped so it never swallows the screen.
|
||||||
|
let max_rows = area.height.saturating_sub(6).max(1);
|
||||||
|
let body = (picker.vms.len() as u16).min(max_rows);
|
||||||
|
let h = body + 2;
|
||||||
|
// Anchor bottom-left, riding just above the 3-row input box.
|
||||||
|
let x = area.x + 1;
|
||||||
|
let y = area
|
||||||
|
.y
|
||||||
|
.saturating_add(area.height.saturating_sub(h + 3));
|
||||||
|
let rect = Rect {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scroll the window so the selection stays visible in tall lists.
|
||||||
|
let first = picker.selected.saturating_sub(body.saturating_sub(1) as usize);
|
||||||
|
let items: Vec<ListItem> = picker
|
||||||
|
.vms
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.skip(first)
|
||||||
|
.take(body as usize)
|
||||||
|
.map(|(i, vm)| {
|
||||||
|
let style = if i == picker.selected {
|
||||||
|
Style::default()
|
||||||
|
.fg(theme.bg)
|
||||||
|
.bg(theme.accent)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(theme.title)
|
||||||
|
};
|
||||||
|
let marker = if i == picker.selected { "▸ " } else { " " };
|
||||||
|
ListItem::new(Line::from(Span::styled(format!("{marker}{vm}"), style)))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
f.render_widget(Clear, rect);
|
||||||
|
let list = List::new(items).style(Style::default().bg(theme.bg)).block(
|
||||||
|
Block::bordered()
|
||||||
|
.border_style(Style::default().fg(theme.accent))
|
||||||
|
.title(Span::styled(
|
||||||
|
format!(" {} pick a VM · ↑↓ ⏎ Esc ", theme.sigil),
|
||||||
|
Style::default().fg(theme.title).add_modifier(Modifier::BOLD),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
f.render_widget(list, rect);
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_help(f: &mut Frame, area: Rect, app: &App, theme: &Theme) {
|
fn draw_help(f: &mut Frame, area: Rect, app: &App, theme: &Theme) {
|
||||||
let w = help_popup(area);
|
let w = help_popup(area);
|
||||||
let inner_w = w.width.saturating_sub(2);
|
let inner_w = w.width.saturating_sub(2);
|
||||||
|
|||||||
Reference in New Issue
Block a user