ci: proper Rust+Python CI workflow; cargo fmt + clippy clean
Replace the stale Django CI template with a CI workflow that builds and tests both codebases: cargo fmt/clippy/build/test for the hh client and pytest across Python 3.10-3.12 for the server. Apply cargo fmt and fix all clippy lints so the gates pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cf92b358c4
commit
8eacf4d27b
42
.github/workflows/ci.yml
vendored
Normal file
42
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, hack-house]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
name: rust client (hh)
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: hh
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: hh
|
||||
- run: cargo fmt --all --check
|
||||
- run: cargo clippy --all-targets -- -D warnings
|
||||
- run: cargo build --verbose
|
||||
- run: cargo test --verbose
|
||||
|
||||
python:
|
||||
name: python server
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: pip
|
||||
- run: pip install -r requirements.txt
|
||||
- run: pytest -q
|
||||
30
.github/workflows/django.yml
vendored
30
.github/workflows/django.yml
vendored
|
|
@ -1,30 +0,0 @@
|
|||
name: Django CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
python-version: [3.7, 3.8, 3.9]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
- name: Run Tests
|
||||
run: |
|
||||
python manage.py test
|
||||
236
hh/src/app.rs
236
hh/src/app.rs
|
|
@ -59,16 +59,37 @@ struct ActiveSend {
|
|||
|
||||
/// Decoded events arriving from the websocket reader task.
|
||||
pub enum Net {
|
||||
Init { lines: Vec<ChatLine>, users: Vec<User> },
|
||||
Init {
|
||||
lines: Vec<ChatLine>,
|
||||
users: Vec<User>,
|
||||
},
|
||||
Message(ChatLine),
|
||||
Roster { users: Vec<User>, capacity: usize },
|
||||
Roster {
|
||||
users: Vec<User>,
|
||||
capacity: usize,
|
||||
},
|
||||
Joined(String),
|
||||
Left(String),
|
||||
SbxStatus { backend: String, ready: bool, rows: u16, cols: u16 },
|
||||
SbxResize { rows: u16, cols: u16 },
|
||||
SbxStatus {
|
||||
backend: String,
|
||||
ready: bool,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
},
|
||||
SbxResize {
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
},
|
||||
SbxData(Vec<u8>),
|
||||
SbxInput { from: String, bytes: Vec<u8> },
|
||||
Perm { owner: String, drivers: Vec<String>, sudoers: Vec<String> },
|
||||
SbxInput {
|
||||
from: String,
|
||||
bytes: Vec<u8>,
|
||||
},
|
||||
Perm {
|
||||
owner: String,
|
||||
drivers: Vec<String>,
|
||||
sudoers: Vec<String>,
|
||||
},
|
||||
Ft(ft::Ft),
|
||||
Err(String),
|
||||
Closed,
|
||||
|
|
@ -195,7 +216,12 @@ impl App {
|
|||
self.sys(format!("{name} left"));
|
||||
}
|
||||
}
|
||||
Net::SbxStatus { backend, ready, rows, cols } => {
|
||||
Net::SbxStatus {
|
||||
backend,
|
||||
ready,
|
||||
rows,
|
||||
cols,
|
||||
} => {
|
||||
if ready {
|
||||
self.sandbox = Some(SbxView {
|
||||
parser: vt100::Parser::new(rows.max(1), cols.max(1), 2000),
|
||||
|
|
@ -223,19 +249,29 @@ impl App {
|
|||
}
|
||||
}
|
||||
Net::SbxInput { .. } => {} // broker enforces + writes in the run loop
|
||||
Net::Perm { owner, drivers, sudoers } => {
|
||||
Net::Perm {
|
||||
owner,
|
||||
drivers,
|
||||
sudoers,
|
||||
} => {
|
||||
let new: std::collections::HashSet<String> = drivers.into_iter().collect();
|
||||
let sudo: std::collections::HashSet<String> = sudoers.into_iter().collect();
|
||||
if !owner.is_empty() && self.owner.as_deref() != Some(owner.as_str()) {
|
||||
self.sys(format!("⛧ {owner} is the superuser (sandbox owner)"));
|
||||
}
|
||||
if new.contains(&self.me) && !self.drivers.contains(&self.me) && self.owner.is_some() {
|
||||
if new.contains(&self.me)
|
||||
&& !self.drivers.contains(&self.me)
|
||||
&& self.owner.is_some()
|
||||
{
|
||||
self.sys("⛧ you were granted drive (F2 to take the shell)");
|
||||
} else if !new.contains(&self.me) && self.drivers.contains(&self.me) {
|
||||
self.driving = false;
|
||||
self.sys("⛧ your drive permission was revoked");
|
||||
}
|
||||
if sudo.contains(&self.me) && !self.sudoers.contains(&self.me) && self.owner.is_some() {
|
||||
if sudo.contains(&self.me)
|
||||
&& !self.sudoers.contains(&self.me)
|
||||
&& self.owner.is_some()
|
||||
{
|
||||
self.sys("⛧ you were granted sudo (superuser) in the VM");
|
||||
}
|
||||
self.owner = Some(owner).filter(|o| !o.is_empty());
|
||||
|
|
@ -255,7 +291,10 @@ impl App {
|
|||
fn sbx_dims(term_w: u16, term_h: u16) -> (u16, u16) {
|
||||
let body_h = term_h.saturating_sub(4);
|
||||
let sbx_h = (body_h as u32 * 55 / 100) as 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
|
||||
|
|
@ -297,17 +336,29 @@ fn send_frame(out: &UnboundedSender<WsMsg>, room: &fernet::Fernet, value: serde_
|
|||
fn broadcast_acl(out: &UnboundedSender<WsMsg>, room: &fernet::Fernet, app: &App) {
|
||||
let drivers: Vec<&String> = app.drivers.iter().collect();
|
||||
let sudoers: Vec<&String> = app.sudoers.iter().collect();
|
||||
send_frame(out, room, json!({
|
||||
"_perm":"acl","owner": app.owner, "drivers": drivers, "sudoers": sudoers
|
||||
}));
|
||||
send_frame(
|
||||
out,
|
||||
room,
|
||||
json!({
|
||||
"_perm":"acl","owner": app.owner, "drivers": drivers, "sudoers": sudoers
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
for (seq, chunk) in payload.chunks(ft::CHUNK).enumerate() {
|
||||
let frame = json!({"_ft":"chunk","id": id,"seq": seq,"data": STANDARD.encode(chunk)});
|
||||
if out.send(WsMsg::Text(room.encrypt(frame.to_string().as_bytes()))).is_err() {
|
||||
if out
|
||||
.send(WsMsg::Text(room.encrypt(frame.to_string().as_bytes())))
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
|
|
@ -331,10 +382,19 @@ fn handle_ft(
|
|||
}
|
||||
app.sys(format!(
|
||||
"⛧ {} offers {} ({}{}) — /accept or /reject",
|
||||
o.from, o.name, ft::human(o.size as usize),
|
||||
o.from,
|
||||
o.name,
|
||||
ft::human(o.size as usize),
|
||||
if o.dir { ", directory" } else { "" }
|
||||
));
|
||||
app.transfers.insert(o.id.clone(), Transfer { meta: o.clone(), buf: Vec::new(), accepted: false });
|
||||
app.transfers.insert(
|
||||
o.id.clone(),
|
||||
Transfer {
|
||||
meta: o.clone(),
|
||||
buf: Vec::new(),
|
||||
accepted: false,
|
||||
},
|
||||
);
|
||||
app.pending_offer = Some(o);
|
||||
}
|
||||
ft::Ft::Accept(id) => {
|
||||
|
|
@ -366,13 +426,22 @@ fn handle_ft(
|
|||
app.err(format!("{} — SHA-256 mismatch, discarded", t.meta.name));
|
||||
} else {
|
||||
match ft::save(downloads, &t.meta, &t.buf) {
|
||||
Ok(p) => app.sys(format!("⛧ saved {} ({}) — verified ✓", p.display(), ft::human(t.buf.len()))),
|
||||
Ok(p) => app.sys(format!(
|
||||
"⛧ saved {} ({}) — verified ✓",
|
||||
p.display(),
|
||||
ft::human(t.buf.len())
|
||||
)),
|
||||
Err(e) => app.err(format!("save failed: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if app.pending_offer.as_ref().map(|o| o.id == id).unwrap_or(false) {
|
||||
if app
|
||||
.pending_offer
|
||||
.as_ref()
|
||||
.map(|o| o.id == id)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
app.pending_offer = None;
|
||||
}
|
||||
}
|
||||
|
|
@ -410,7 +479,8 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
|||
// All outgoing frames funnel through here so background tasks (file chunks,
|
||||
// PTY relay) can transmit without owning the socket.
|
||||
let (out_tx, mut out_rx) = unbounded_channel::<WsMsg>();
|
||||
let (pty_tx, mut pty_rx): (UnboundedSender<Vec<u8>>, UnboundedReceiver<Vec<u8>>) = unbounded_channel();
|
||||
let (pty_tx, mut pty_rx): (UnboundedSender<Vec<u8>>, UnboundedReceiver<Vec<u8>>) =
|
||||
unbounded_channel();
|
||||
let (broker_tx, mut broker_rx) = unbounded_channel::<BrokerMsg>();
|
||||
let mut broker: Option<sbx::Sandbox> = None;
|
||||
let mut broker_meta: Option<(sbx::Backend, String)> = None;
|
||||
|
|
@ -472,7 +542,11 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
|||
if let Some(sb) = &broker {
|
||||
let _ = sb.resize(dims.0, dims.1);
|
||||
}
|
||||
send_frame(&out_tx, &session.room, json!({"_sbx":"resize","rows":dims.0,"cols":dims.1}));
|
||||
send_frame(
|
||||
&out_tx,
|
||||
&session.room,
|
||||
json!({"_sbx":"resize","rows":dims.0,"cols":dims.1}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -702,22 +776,19 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
|||
}
|
||||
}
|
||||
outgoing = out_rx.recv() => {
|
||||
match outgoing {
|
||||
Some(first) => {
|
||||
// Flush a batch to keep file-chunk bursts from redrawing per frame.
|
||||
let mut batch = vec![first];
|
||||
while let Ok(m) = out_rx.try_recv() {
|
||||
batch.push(m);
|
||||
if batch.len() >= 64 { break; }
|
||||
}
|
||||
for m in batch {
|
||||
if write.send(m).await.is_err() {
|
||||
app.connected = false;
|
||||
break;
|
||||
}
|
||||
if let Some(first) = outgoing {
|
||||
// Flush a batch to keep file-chunk bursts from redrawing per frame.
|
||||
let mut batch = vec![first];
|
||||
while let Ok(m) = out_rx.try_recv() {
|
||||
batch.push(m);
|
||||
if batch.len() >= 64 { break; }
|
||||
}
|
||||
for m in batch {
|
||||
if write.send(m).await.is_err() {
|
||||
app.connected = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
_ = sigterm.recv() => { break Ok(()); }
|
||||
|
|
@ -733,13 +804,23 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
|||
}
|
||||
}
|
||||
disable_raw_mode()?;
|
||||
execute!(term.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
|
||||
execute!(
|
||||
term.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
term.show_cursor()?;
|
||||
result
|
||||
}
|
||||
|
||||
enum BrokerMsg {
|
||||
Ready { sb: sbx::Sandbox, backend: sbx::Backend, name: String, rows: u16, cols: u16 },
|
||||
Ready {
|
||||
sb: sbx::Sandbox,
|
||||
backend: sbx::Backend,
|
||||
name: String,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
},
|
||||
Failed,
|
||||
}
|
||||
|
||||
|
|
@ -777,7 +858,10 @@ fn handle_command(
|
|||
// 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(" · ")));
|
||||
app.sys(format!(
|
||||
"vestments: {} — /theme <name>",
|
||||
Theme::available().join(" · ")
|
||||
));
|
||||
} else {
|
||||
match Theme::by_name(name) {
|
||||
Ok(t) => {
|
||||
|
|
@ -800,18 +884,33 @@ fn handle_command(
|
|||
} else {
|
||||
app.sys("you don't have drive permission — the owner can /grant you");
|
||||
}
|
||||
} else if let Some(path) = line.strip_prefix("/sendd ").or_else(|| line.strip_prefix("/send ")) {
|
||||
} else if let Some(path) = line
|
||||
.strip_prefix("/sendd ")
|
||||
.or_else(|| line.strip_prefix("/send "))
|
||||
{
|
||||
let path = path.trim();
|
||||
match ft::read_payload(path) {
|
||||
Ok((name, bytes, dir)) => {
|
||||
*send_seq += 1;
|
||||
let id = format!("{}-{}", app.me, send_seq);
|
||||
let (size, sha) = (bytes.len(), ft::sha256_hex(&bytes));
|
||||
*active_send = Some(ActiveSend { id: id.clone(), payload: Arc::new(bytes), sending: false });
|
||||
send_frame(out_tx, room, json!({
|
||||
"_ft":"offer","id": id,"name": name,"size": size,"sha256": sha,"dir": dir
|
||||
}));
|
||||
app.sys(format!("offered {} ({}) — waiting for an /accept", name, ft::human(size)));
|
||||
*active_send = Some(ActiveSend {
|
||||
id: id.clone(),
|
||||
payload: Arc::new(bytes),
|
||||
sending: false,
|
||||
});
|
||||
send_frame(
|
||||
out_tx,
|
||||
room,
|
||||
json!({
|
||||
"_ft":"offer","id": id,"name": name,"size": size,"sha256": sha,"dir": dir
|
||||
}),
|
||||
);
|
||||
app.sys(format!(
|
||||
"offered {} ({}) — waiting for an /accept",
|
||||
name,
|
||||
ft::human(size)
|
||||
));
|
||||
}
|
||||
Err(e) => app.err(format!("send failed: {e}")),
|
||||
}
|
||||
|
|
@ -843,21 +942,44 @@ fn handle_command(
|
|||
// `--start` (alias `--start-daemon` / `-y`) opts in to booting
|
||||
// a stopped Docker daemon; everything else is positional.
|
||||
let args: Vec<&str> = p.collect();
|
||||
let start_daemon = args.iter().any(|a| matches!(*a, "--start" | "--start-daemon" | "-y"));
|
||||
let start_daemon = args
|
||||
.iter()
|
||||
.any(|a| matches!(*a, "--start" | "--start-daemon" | "-y"));
|
||||
let mut pos = args.iter().copied().filter(|a| !a.starts_with('-'));
|
||||
let backend = pos.next().and_then(sbx::Backend::parse).unwrap_or(sbx::Backend::Local);
|
||||
let image = pos.next().map(str::to_string).unwrap_or_else(|| backend.default_image().to_string());
|
||||
let backend = pos
|
||||
.next()
|
||||
.and_then(sbx::Backend::parse)
|
||||
.unwrap_or(sbx::Backend::Local);
|
||||
let image = pos
|
||||
.next()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| backend.default_image().to_string());
|
||||
|
||||
if backend == sbx::Backend::Docker && !start_daemon && !sbx::docker_daemon_up() {
|
||||
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());
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -975,7 +1097,13 @@ 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) => {
|
||||
let _ = app_tx.send(Net::Err(format!("sandbox launch failed: {e}")));
|
||||
|
|
|
|||
|
|
@ -133,11 +133,7 @@ impl SrpClient {
|
|||
|
||||
/// Process the server challenge (salt, B). Returns (M, K, H_AMK_expected).
|
||||
/// `M` is sent to the server; `h_amk` is compared to the server's reply.
|
||||
pub fn process_challenge(
|
||||
&self,
|
||||
salt: &[u8],
|
||||
b_bytes: &[u8],
|
||||
) -> anyhow::Result<Challenge> {
|
||||
pub fn process_challenge(&self, salt: &[u8], b_bytes: &[u8]) -> anyhow::Result<Challenge> {
|
||||
let n = &self.n;
|
||||
let width = long_to_bytes(n).len();
|
||||
let big_b = bytes_to_long(b_bytes);
|
||||
|
|
@ -218,7 +214,7 @@ mod tests {
|
|||
|
||||
fn a_bytes() -> Vec<u8> {
|
||||
let mut v = vec![0x80u8];
|
||||
v.extend(std::iter::repeat(0x22u8).take(31));
|
||||
v.extend(std::iter::repeat_n(0x22u8, 31));
|
||||
v
|
||||
}
|
||||
|
||||
|
|
@ -245,7 +241,9 @@ mod fernet_interop {
|
|||
#[test]
|
||||
fn rust_decrypts_python_fernet() {
|
||||
let f = fernet::Fernet::new(KEY).unwrap();
|
||||
let pt = f.decrypt(TOK).expect("rust must decrypt python fernet token");
|
||||
let pt = f
|
||||
.decrypt(TOK)
|
||||
.expect("rust must decrypt python fernet token");
|
||||
assert_eq!(pt, b"hello from python fernet");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
69
hh/src/ft.rs
69
hh/src/ft.rs
|
|
@ -55,13 +55,24 @@ pub fn read_payload(path: &str) -> Result<(String, Vec<u8>, bool)> {
|
|||
let meta = std::fs::metadata(p).with_context(|| format!("not found: {path}"))?;
|
||||
if meta.is_dir() {
|
||||
let bytes = tar_dir(p)?;
|
||||
anyhow::ensure!(bytes.len() <= MAX_SIZE, "directory too large ({})", human(bytes.len()));
|
||||
anyhow::ensure!(
|
||||
bytes.len() <= MAX_SIZE,
|
||||
"directory too large ({})",
|
||||
human(bytes.len())
|
||||
);
|
||||
let base = p.file_name().and_then(|s| s.to_str()).unwrap_or("dir");
|
||||
Ok((format!("{base}.tar"), bytes, true))
|
||||
} else {
|
||||
anyhow::ensure!(meta.len() as usize <= MAX_SIZE, "file too large (max 50 MB)");
|
||||
anyhow::ensure!(
|
||||
meta.len() as usize <= MAX_SIZE,
|
||||
"file too large (max 50 MB)"
|
||||
);
|
||||
let bytes = std::fs::read(p)?;
|
||||
let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("file").to_string();
|
||||
let name = p
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("file")
|
||||
.to_string();
|
||||
Ok((name, bytes, false))
|
||||
}
|
||||
}
|
||||
|
|
@ -89,17 +100,29 @@ pub fn save(downloads: &Path, offer: &Offer, data: &[u8]) -> Result<PathBuf> {
|
|||
let mut e = entry?;
|
||||
let path = e.path()?.into_owned();
|
||||
// Explicit zip-slip guard (belt-and-suspenders; unpack_in also refuses).
|
||||
anyhow::ensure!(safe_entry(&path), "unsafe tar entry rejected: {}", path.display());
|
||||
anyhow::ensure!(
|
||||
safe_entry(&path),
|
||||
"unsafe tar entry rejected: {}",
|
||||
path.display()
|
||||
);
|
||||
if top.is_none() {
|
||||
top = path.components().next().map(|c| c.as_os_str().to_owned());
|
||||
}
|
||||
e.unpack_in(downloads)
|
||||
.with_context(|| format!("extract {}", path.display()))?;
|
||||
}
|
||||
Ok(top.map(|t| downloads.join(t)).unwrap_or_else(|| downloads.to_path_buf()))
|
||||
Ok(top
|
||||
.map(|t| downloads.join(t))
|
||||
.unwrap_or_else(|| downloads.to_path_buf()))
|
||||
} else {
|
||||
let stem = Path::new(&offer.name).file_stem().and_then(|s| s.to_str()).unwrap_or("file");
|
||||
let ext = Path::new(&offer.name).extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||
let stem = Path::new(&offer.name)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("file");
|
||||
let ext = Path::new(&offer.name)
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("");
|
||||
let dest = unique(downloads, stem, ext);
|
||||
std::fs::write(&dest, data)?;
|
||||
Ok(dest)
|
||||
|
|
@ -113,8 +136,16 @@ fn safe_entry(path: &Path) -> bool {
|
|||
|
||||
fn unique(dir: &Path, stem: &str, ext: &str) -> PathBuf {
|
||||
let mk = |n: usize| {
|
||||
let base = if n == 0 { stem.to_string() } else { format!("{stem}_{n}") };
|
||||
if ext.is_empty() { dir.join(base) } else { dir.join(format!("{base}.{ext}")) }
|
||||
let base = if n == 0 {
|
||||
stem.to_string()
|
||||
} else {
|
||||
format!("{stem}_{n}")
|
||||
};
|
||||
if ext.is_empty() {
|
||||
dir.join(base)
|
||||
} else {
|
||||
dir.join(format!("{base}.{ext}"))
|
||||
}
|
||||
};
|
||||
(0..).map(mk).find(|p| !p.exists()).unwrap()
|
||||
}
|
||||
|
|
@ -157,8 +188,14 @@ mod tests {
|
|||
let (name, bytes, is_dir) = read_payload(src.to_str().unwrap()).unwrap();
|
||||
assert_eq!(name, "note.txt");
|
||||
assert!(!is_dir);
|
||||
let offer = Offer { id: "1".into(), name, size: bytes.len() as u64,
|
||||
sha256: sha256_hex(&bytes), dir: false, from: "x".into() };
|
||||
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 out = save(&dl, &offer, &bytes).unwrap();
|
||||
assert_eq!(std::fs::read(&out).unwrap(), b"offering to the clergy");
|
||||
|
|
@ -176,8 +213,14 @@ mod tests {
|
|||
let (name, bytes, is_dir) = read_payload(proj.to_str().unwrap()).unwrap();
|
||||
assert_eq!(name, "proj.tar");
|
||||
assert!(is_dir);
|
||||
let offer = Offer { id: "1".into(), name, size: bytes.len() as u64,
|
||||
sha256: sha256_hex(&bytes), dir: true, from: "x".into() };
|
||||
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 out = save(&dl, &offer, &bytes).unwrap(); // -> dl/proj
|
||||
assert!(out.ends_with("proj"));
|
||||
|
|
|
|||
|
|
@ -45,7 +45,10 @@ enum Cmd {
|
|||
theme: Option<String>,
|
||||
},
|
||||
/// Debug: print the derived room Fernet key for a password + room_salt(hex).
|
||||
Roomkey { password: String, room_salt_hex: String },
|
||||
Roomkey {
|
||||
password: String,
|
||||
room_salt_hex: String,
|
||||
},
|
||||
/// Run the offline SRP golden-vector self-test.
|
||||
Selftest,
|
||||
/// Debug: compute A and M from explicit a/salt/B hex (parity check vs python).
|
||||
|
|
@ -98,7 +101,10 @@ fn main() -> Result<()> {
|
|||
};
|
||||
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 f = crypto::room_fernet(password.as_bytes(), &salt)?;
|
||||
// fernet crate doesn't expose the key; re-derive + print the b64 key.
|
||||
|
|
@ -143,7 +149,7 @@ fn selftest() -> Result<()> {
|
|||
// Re-derive the golden vectors at runtime as a smoke check.
|
||||
let c = crypto::SrpClient::with_a(b"chat", b"labtest", &{
|
||||
let mut v = vec![0x80u8];
|
||||
v.extend(std::iter::repeat(0x22u8).take(31));
|
||||
v.extend(std::iter::repeat_n(0x22u8, 31));
|
||||
v
|
||||
});
|
||||
let a = hex::encode(c.a_bytes());
|
||||
|
|
@ -212,7 +218,10 @@ 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();
|
||||
let ws_token = verify["ws_token"]
|
||||
.as_str()
|
||||
.context("no ws_token")?
|
||||
.to_string();
|
||||
println!("/srp/verify ok — server identity proven (H_AMK ✓)");
|
||||
|
||||
// Room key + encrypt a message the Python clients can read.
|
||||
|
|
@ -221,9 +230,7 @@ fn handshake(
|
|||
|
||||
// Connect WS and send the ciphertext.
|
||||
let ws_scheme = if no_tls { "ws" } else { "wss" };
|
||||
let ws_url = format!(
|
||||
"{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}"
|
||||
);
|
||||
let ws_url = format!("{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}");
|
||||
let (mut sock, _resp) =
|
||||
tungstenite::connect(&ws_url).context("ws connect (insecure wss not yet wired)")?;
|
||||
println!("websocket attached to the house");
|
||||
|
|
|
|||
|
|
@ -84,13 +84,15 @@ pub fn authenticate(
|
|||
.json()?;
|
||||
|
||||
let server_hamk = STANDARD.decode(verify["H_AMK"].as_str().context("no H_AMK")?)?;
|
||||
anyhow::ensure!(server_hamk == ch.h_amk, "server identity check failed (H_AMK) — MITM?");
|
||||
anyhow::ensure!(
|
||||
server_hamk == ch.h_amk,
|
||||
"server identity check failed (H_AMK) — MITM?"
|
||||
);
|
||||
let ws_token = verify["ws_token"].as_str().context("no ws_token")?;
|
||||
|
||||
let fernet = crypto::room_fernet(password.as_bytes(), &room_salt)?;
|
||||
let ws_scheme = if no_tls { "ws" } else { "wss" };
|
||||
let ws_url =
|
||||
format!("{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}");
|
||||
let ws_url = format!("{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}");
|
||||
|
||||
Ok(Session {
|
||||
username: user.to_string(),
|
||||
|
|
@ -160,14 +162,18 @@ fn decode_msg(room: &fernet::Fernet, m: &Value, live: bool) -> Decoded {
|
|||
// Control frames are live-only — never replayed from the stored snapshot.
|
||||
if t.starts_with("{\"_sbx\":") {
|
||||
return if live {
|
||||
parse_sbx(&t, sender).map(Decoded::Sbx).unwrap_or(Decoded::Skip)
|
||||
parse_sbx(&t, sender)
|
||||
.map(Decoded::Sbx)
|
||||
.unwrap_or(Decoded::Skip)
|
||||
} else {
|
||||
Decoded::Skip
|
||||
};
|
||||
}
|
||||
if t.starts_with("{\"_ft\":") {
|
||||
return if live {
|
||||
crate::ft::parse(&t, sender).map(|f| Decoded::Sbx(Net::Ft(f))).unwrap_or(Decoded::Skip)
|
||||
crate::ft::parse(&t, sender)
|
||||
.map(|f| Decoded::Sbx(Net::Ft(f)))
|
||||
.unwrap_or(Decoded::Skip)
|
||||
} else {
|
||||
Decoded::Skip
|
||||
};
|
||||
|
|
@ -177,7 +183,11 @@ fn decode_msg(room: &fernet::Fernet, m: &Value, live: bool) -> Decoded {
|
|||
Err(_) => ("[unreadable — wrong room password?]".to_string(), true),
|
||||
};
|
||||
let stamp = m["timestamp"].as_str().unwrap_or("");
|
||||
let ts = if stamp.len() >= 19 { stamp[11..19].to_string() } else { String::new() };
|
||||
let ts = if stamp.len() >= 19 {
|
||||
stamp[11..19].to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
Decoded::Chat(ChatLine {
|
||||
ts,
|
||||
username: m["username"].as_str().unwrap_or("?").to_string(),
|
||||
|
|
@ -232,7 +242,11 @@ fn parse_perm(text: &str) -> Option<Net> {
|
|||
}
|
||||
|
||||
/// Read websocket frames forever, forwarding decoded `Net` events to the UI.
|
||||
pub async fn reader(mut read: impl StreamExt<Item = Result<WsMsg, tokio_tungstenite::tungstenite::Error>> + Unpin, room: Arc<fernet::Fernet>, tx: UnboundedSender<Net>) {
|
||||
pub async fn reader(
|
||||
mut read: impl StreamExt<Item = Result<WsMsg, tokio_tungstenite::tungstenite::Error>> + Unpin,
|
||||
room: Arc<fernet::Fernet>,
|
||||
tx: UnboundedSender<Net>,
|
||||
) {
|
||||
while let Some(frame) = read.next().await {
|
||||
let txt = match frame {
|
||||
Ok(WsMsg::Text(t)) => t,
|
||||
|
|
@ -254,7 +268,10 @@ pub async fn reader(mut read: impl StreamExt<Item = Result<WsMsg, tokio_tungsten
|
|||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
tx.send(Net::Init { lines, users: parse_users(&v["users"]) })
|
||||
tx.send(Net::Init {
|
||||
lines,
|
||||
users: parse_users(&v["users"]),
|
||||
})
|
||||
}
|
||||
"message" => match decode_msg(&room, &v["data"], true) {
|
||||
Decoded::Chat(l) => tx.send(Net::Message(l)),
|
||||
|
|
@ -265,7 +282,9 @@ pub async fn reader(mut read: impl StreamExt<Item = Result<WsMsg, tokio_tungsten
|
|||
users: parse_users(&v["users"]),
|
||||
capacity: v["capacity"].as_u64().unwrap_or(0) as usize,
|
||||
}),
|
||||
"user_joined" => tx.send(Net::Joined(v["username"].as_str().unwrap_or("?").to_string())),
|
||||
"user_joined" => tx.send(Net::Joined(
|
||||
v["username"].as_str().unwrap_or("?").to_string(),
|
||||
)),
|
||||
"user_left" => tx.send(Net::Left(v["user_id"].as_str().unwrap_or("").to_string())),
|
||||
_ => Ok(()),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -36,7 +36,10 @@ fn start_docker_daemon() -> Result<()> {
|
|||
.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");
|
||||
let last = err
|
||||
.lines()
|
||||
.last()
|
||||
.unwrap_or("could not start the docker daemon");
|
||||
anyhow::bail!("{last}");
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -93,16 +96,25 @@ pub fn prepare(backend: Backend, name: &str, image: &str, start_daemon: bool) ->
|
|||
// 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,
|
||||
])
|
||||
.output()
|
||||
.context("multipass launch (is multipass installed?)")?;
|
||||
if !out.status.success() {
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
anyhow::bail!("multipass launch failed: {}", err.lines().last().unwrap_or("").trim());
|
||||
anyhow::bail!(
|
||||
"multipass launch failed: {}",
|
||||
err.lines().last().unwrap_or("").trim()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let _ = Command::new("multipass").args(["start", name])
|
||||
.stdout(Stdio::null()).stderr(Stdio::null()).status();
|
||||
let _ = Command::new("multipass")
|
||||
.args(["start", name])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -120,17 +132,35 @@ pub fn prepare(backend: Backend, name: &str, image: &str, start_daemon: bool) ->
|
|||
}
|
||||
}
|
||||
// Persistent container so we can exec in to provision users + shells.
|
||||
let _ = Command::new("docker").args(["rm", "-f", name])
|
||||
.stdout(Stdio::null()).stderr(Stdio::null()).status();
|
||||
let _ = Command::new("docker")
|
||||
.args(["rm", "-f", name])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
// Capture output so a failure can't paint over the TUI; the reason is
|
||||
// surfaced through the returned error (shown in the error popup).
|
||||
let out = Command::new("docker")
|
||||
.args(["run", "-d", "--name", name, "--hostname", name, "-w", "/root", image, "sleep", "infinity"])
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
name,
|
||||
"--hostname",
|
||||
name,
|
||||
"-w",
|
||||
"/root",
|
||||
image,
|
||||
"sleep",
|
||||
"infinity",
|
||||
])
|
||||
.output()
|
||||
.context("docker run (is docker installed?)")?;
|
||||
if !out.status.success() {
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
anyhow::bail!("docker run failed: {}", err.lines().last().unwrap_or("").trim());
|
||||
anyhow::bail!(
|
||||
"docker run failed: {}",
|
||||
err.lines().last().unwrap_or("").trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -142,12 +172,18 @@ pub fn prepare(backend: Backend, name: &str, image: &str, start_daemon: bool) ->
|
|||
pub fn teardown(backend: Backend, name: &str) {
|
||||
match backend {
|
||||
Backend::Multipass => {
|
||||
let _ = Command::new("multipass").args(["delete", name, "--purge"])
|
||||
.stdout(Stdio::null()).stderr(Stdio::null()).status();
|
||||
let _ = Command::new("multipass")
|
||||
.args(["delete", name, "--purge"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
Backend::Docker => {
|
||||
let _ = Command::new("docker").args(["rm", "-f", name])
|
||||
.stdout(Stdio::null()).stderr(Stdio::null()).status();
|
||||
let _ = Command::new("docker")
|
||||
.args(["rm", "-f", name])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
Backend::Local => {}
|
||||
}
|
||||
|
|
@ -163,7 +199,11 @@ fn command_for(backend: Backend, name: &str, run_user: &str) -> CommandBuilder {
|
|||
c
|
||||
}
|
||||
Backend::Docker => {
|
||||
let user = if run_user.is_empty() { "root" } else { run_user };
|
||||
let user = if run_user.is_empty() {
|
||||
"root"
|
||||
} else {
|
||||
run_user
|
||||
};
|
||||
let mut c = CommandBuilder::new("docker");
|
||||
c.args(["exec", "-it", "-u", user, name, "bash", "-il"]);
|
||||
c
|
||||
|
|
@ -196,14 +236,20 @@ fn mp(name: &str, args: &[&str]) {
|
|||
let mut a = vec!["exec", name, "--"];
|
||||
a.extend_from_slice(args);
|
||||
// Null stdio so provisioning chatter never bleeds onto the TUI surface.
|
||||
let _ = Command::new("multipass").args(a)
|
||||
.stdout(Stdio::null()).stderr(Stdio::null()).status();
|
||||
let _ = Command::new("multipass")
|
||||
.args(a)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
fn dk(name: &str, args: &[&str]) {
|
||||
let mut a = vec!["exec", name];
|
||||
a.extend_from_slice(args);
|
||||
let _ = Command::new("docker").args(a)
|
||||
.stdout(Stdio::null()).stderr(Stdio::null()).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)
|
||||
|
|
@ -362,8 +408,8 @@ mod tests {
|
|||
#[test]
|
||||
fn local_shell_pty_roundtrip() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut sb = Sandbox::launch(Backend::Local, "test", "", 24, 80, tx)
|
||||
.expect("launch local shell");
|
||||
let mut sb =
|
||||
Sandbox::launch(Backend::Local, "test", "", 24, 80, tx).expect("launch local shell");
|
||||
sb.write_input(b"echo HELLO_PTY_42\n").unwrap();
|
||||
|
||||
let mut acc = String::new();
|
||||
|
|
@ -401,8 +447,7 @@ mod relay_tests {
|
|||
use base64::Engine;
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut sb = Sandbox::launch(Backend::Local, "test", "", 24, 80, tx)
|
||||
.expect("launch");
|
||||
let mut sb = Sandbox::launch(Backend::Local, "test", "", 24, 80, tx).expect("launch");
|
||||
sb.write_input(b"echo RELAY_MARKER_7\n").unwrap();
|
||||
|
||||
// Remote client side: a vt100 parser fed from decoded data frames.
|
||||
|
|
|
|||
|
|
@ -41,18 +41,18 @@ impl Default for Theme {
|
|||
fn default() -> Self {
|
||||
Self {
|
||||
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
|
||||
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 cross / crypt
|
||||
sigil: "✝".into(), // inverted cross / crypt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
121
hh/src/ui.rs
121
hh/src/ui.rs
|
|
@ -12,7 +12,10 @@ 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());
|
||||
f.render_widget(
|
||||
Block::default().style(Style::default().bg(theme.bg)),
|
||||
f.area(),
|
||||
);
|
||||
|
||||
let rows = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
|
|
@ -53,22 +56,33 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
|
|||
/// 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 w = area.width.saturating_sub(2).clamp(16, 48);
|
||||
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 };
|
||||
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))
|
||||
.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),
|
||||
Style::default()
|
||||
.fg(theme.accent)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
)
|
||||
.wrap(Wrap { trim: false });
|
||||
|
|
@ -93,7 +107,9 @@ fn centered(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
|
|||
}
|
||||
|
||||
fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) {
|
||||
let acc = Style::default().fg(theme.accent).add_modifier(Modifier::BOLD);
|
||||
let acc = Style::default()
|
||||
.fg(theme.accent)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let key = Style::default().fg(theme.title);
|
||||
let dim = Style::default().fg(theme.system);
|
||||
let kv = |k: &str, v: &str| {
|
||||
|
|
@ -106,17 +122,35 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) {
|
|||
let head = |s: &str| Line::from(Span::styled(format!("{sig} {s}"), acc));
|
||||
let lines = vec![
|
||||
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("/drive", "type into the shared shell (Esc releases)"),
|
||||
kv("/grant <user>", "let a member drive the shell (owner)"),
|
||||
kv("/revoke <user>", "take back drive permission (owner)"),
|
||||
kv("/sudo <user>", "delegate VM superuser (real sudo) (owner)"),
|
||||
kv("/unsudo <user>", "revoke VM superuser (owner)"),
|
||||
kv(
|
||||
"/grant <user>",
|
||||
"let a member drive the shell (owner)",
|
||||
),
|
||||
kv(
|
||||
"/revoke <user>",
|
||||
"take back drive permission (owner)",
|
||||
),
|
||||
kv(
|
||||
"/sudo <user>",
|
||||
"delegate VM superuser (real sudo) (owner)",
|
||||
),
|
||||
kv(
|
||||
"/unsudo <user>",
|
||||
"revoke VM superuser (owner)",
|
||||
),
|
||||
kv("/send <file>", "offer a file to the room"),
|
||||
kv("/sendd <dir>", "offer a directory (sent as a tar)"),
|
||||
kv("/accept · /reject", "respond to an incoming file offer"),
|
||||
kv("/theme [name]", "change vestments live: church | neon | crypt"),
|
||||
kv(
|
||||
"/theme [name]",
|
||||
"change vestments live: church | neon | crypt",
|
||||
),
|
||||
kv("/pw", "show this room's password (local only)"),
|
||||
kv("/help", "show / hide this menu"),
|
||||
Line::from(""),
|
||||
|
|
@ -126,17 +160,31 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) {
|
|||
kv("F2 · /drive", "take the shell · Esc releases it"),
|
||||
kv("Ctrl-C (while driving)", "interrupt the running command"),
|
||||
kv("PgUp / PgDn", "scroll chat · Home/End = oldest/live"),
|
||||
kv("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(
|
||||
"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-C · Ctrl-Q", "quit hack-house"),
|
||||
Line::from(""),
|
||||
head("ROSTER GLYPHS"),
|
||||
kv(&format!("{} owner ⚡ sudoer", theme.sigil), "◆ 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",
|
||||
Style::default().fg(theme.dim).add_modifier(Modifier::ITALIC),
|
||||
Style::default()
|
||||
.fg(theme.dim)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
)),
|
||||
];
|
||||
let w = centered(78, 90, area);
|
||||
|
|
@ -148,7 +196,9 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) {
|
|||
.border_style(Style::default().fg(theme.accent))
|
||||
.title(Span::styled(
|
||||
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),
|
||||
)),
|
||||
)
|
||||
.wrap(Wrap { trim: false });
|
||||
|
|
@ -173,7 +223,11 @@ fn draw_sandbox(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &T
|
|||
" · /drive (or F2) · ↑/↓/wheel scroll".to_string()
|
||||
};
|
||||
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
|
||||
};
|
||||
let pane = Paragraph::new(lines).block(
|
||||
Block::bordered()
|
||||
.border_style(Style::default().fg(border))
|
||||
|
|
@ -183,7 +237,11 @@ fn draw_sandbox(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &T
|
|||
}
|
||||
|
||||
fn draw_top(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) {
|
||||
let cap = if app.capacity > 0 { app.capacity } else { app.users.len() };
|
||||
let cap = if app.capacity > 0 {
|
||||
app.capacity
|
||||
} else {
|
||||
app.users.len()
|
||||
};
|
||||
let status = if app.connected {
|
||||
"🔒 e2e"
|
||||
} else if app.reconnecting {
|
||||
|
|
@ -194,7 +252,9 @@ fn draw_top(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme
|
|||
let bar = Line::from(vec![
|
||||
Span::styled(
|
||||
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(
|
||||
|
|
@ -213,10 +273,16 @@ fn fmt_line<'a>(l: &'a ChatLine, app: &App, theme: &Theme) -> Line<'a> {
|
|||
let text = l.text.replace('⛧', &theme.sigil);
|
||||
return Line::from(Span::styled(
|
||||
format!(" {} {}", theme.sigil, text),
|
||||
Style::default().fg(theme.system).add_modifier(Modifier::ITALIC),
|
||||
Style::default()
|
||||
.fg(theme.system)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
));
|
||||
}
|
||||
let name_color = if l.username == app.me { theme.me } else { theme.other };
|
||||
let name_color = if l.username == app.me {
|
||||
theme.me
|
||||
} else {
|
||||
theme.other
|
||||
};
|
||||
Line::from(vec![
|
||||
Span::styled(format!("{} ", l.ts), Style::default().fg(theme.dim)),
|
||||
Span::styled(
|
||||
|
|
@ -305,8 +371,13 @@ 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 ", theme.sigil, o.name),
|
||||
None if app.driving => format!(" {} DRIVING the shell — Esc to release ", theme.sigil),
|
||||
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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user