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.
|
/// Decoded events arriving from the websocket reader task.
|
||||||
pub enum Net {
|
pub enum Net {
|
||||||
Init { lines: Vec<ChatLine>, users: Vec<User> },
|
Init {
|
||||||
|
lines: Vec<ChatLine>,
|
||||||
|
users: Vec<User>,
|
||||||
|
},
|
||||||
Message(ChatLine),
|
Message(ChatLine),
|
||||||
Roster { users: Vec<User>, capacity: usize },
|
Roster {
|
||||||
|
users: Vec<User>,
|
||||||
|
capacity: usize,
|
||||||
|
},
|
||||||
Joined(String),
|
Joined(String),
|
||||||
Left(String),
|
Left(String),
|
||||||
SbxStatus { backend: String, ready: bool, rows: u16, cols: u16 },
|
SbxStatus {
|
||||||
SbxResize { rows: u16, cols: u16 },
|
backend: String,
|
||||||
|
ready: bool,
|
||||||
|
rows: u16,
|
||||||
|
cols: u16,
|
||||||
|
},
|
||||||
|
SbxResize {
|
||||||
|
rows: u16,
|
||||||
|
cols: u16,
|
||||||
|
},
|
||||||
SbxData(Vec<u8>),
|
SbxData(Vec<u8>),
|
||||||
SbxInput { from: String, bytes: Vec<u8> },
|
SbxInput {
|
||||||
Perm { owner: String, drivers: Vec<String>, sudoers: Vec<String> },
|
from: String,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
},
|
||||||
|
Perm {
|
||||||
|
owner: String,
|
||||||
|
drivers: Vec<String>,
|
||||||
|
sudoers: Vec<String>,
|
||||||
|
},
|
||||||
Ft(ft::Ft),
|
Ft(ft::Ft),
|
||||||
Err(String),
|
Err(String),
|
||||||
Closed,
|
Closed,
|
||||||
|
|
@ -195,7 +216,12 @@ impl App {
|
||||||
self.sys(format!("{name} left"));
|
self.sys(format!("{name} left"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Net::SbxStatus { backend, ready, rows, cols } => {
|
Net::SbxStatus {
|
||||||
|
backend,
|
||||||
|
ready,
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
} => {
|
||||||
if ready {
|
if ready {
|
||||||
self.sandbox = Some(SbxView {
|
self.sandbox = Some(SbxView {
|
||||||
parser: vt100::Parser::new(rows.max(1), cols.max(1), 2000),
|
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::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 new: std::collections::HashSet<String> = drivers.into_iter().collect();
|
||||||
let sudo: std::collections::HashSet<String> = sudoers.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()) {
|
if !owner.is_empty() && self.owner.as_deref() != Some(owner.as_str()) {
|
||||||
self.sys(format!("⛧ {owner} is the superuser (sandbox owner)"));
|
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)");
|
self.sys("⛧ you were granted drive (F2 to take the shell)");
|
||||||
} else if !new.contains(&self.me) && self.drivers.contains(&self.me) {
|
} else if !new.contains(&self.me) && self.drivers.contains(&self.me) {
|
||||||
self.driving = false;
|
self.driving = false;
|
||||||
self.sys("⛧ your drive permission was revoked");
|
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.sys("⛧ you were granted sudo (superuser) in the VM");
|
||||||
}
|
}
|
||||||
self.owner = Some(owner).filter(|o| !o.is_empty());
|
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) {
|
fn sbx_dims(term_w: u16, term_h: u16) -> (u16, u16) {
|
||||||
let body_h = term_h.saturating_sub(4);
|
let body_h = term_h.saturating_sub(4);
|
||||||
let sbx_h = (body_h as u32 * 55 / 100) as u16;
|
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
|
/// 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) {
|
fn broadcast_acl(out: &UnboundedSender<WsMsg>, room: &fernet::Fernet, app: &App) {
|
||||||
let drivers: Vec<&String> = app.drivers.iter().collect();
|
let drivers: Vec<&String> = app.drivers.iter().collect();
|
||||||
let sudoers: Vec<&String> = app.sudoers.iter().collect();
|
let sudoers: Vec<&String> = app.sudoers.iter().collect();
|
||||||
send_frame(out, room, json!({
|
send_frame(
|
||||||
"_perm":"acl","owner": app.owner, "drivers": drivers, "sudoers": sudoers
|
out,
|
||||||
}));
|
room,
|
||||||
|
json!({
|
||||||
|
"_perm":"acl","owner": app.owner, "drivers": drivers, "sudoers": sudoers
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stream a payload to the clergy as `_ft` chunks (background, paced).
|
/// Stream a payload to the clergy as `_ft` chunks (background, paced).
|
||||||
fn spawn_send(id: String, payload: Arc<Vec<u8>>, out: UnboundedSender<WsMsg>, room: Arc<fernet::Fernet>) {
|
fn spawn_send(
|
||||||
|
id: String,
|
||||||
|
payload: Arc<Vec<u8>>,
|
||||||
|
out: UnboundedSender<WsMsg>,
|
||||||
|
room: Arc<fernet::Fernet>,
|
||||||
|
) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
for (seq, chunk) in payload.chunks(ft::CHUNK).enumerate() {
|
for (seq, chunk) in payload.chunks(ft::CHUNK).enumerate() {
|
||||||
let frame = json!({"_ft":"chunk","id": id,"seq": seq,"data": STANDARD.encode(chunk)});
|
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;
|
return;
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||||
|
|
@ -331,10 +382,19 @@ fn handle_ft(
|
||||||
}
|
}
|
||||||
app.sys(format!(
|
app.sys(format!(
|
||||||
"⛧ {} offers {} ({}{}) — /accept or /reject",
|
"⛧ {} 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 { "" }
|
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);
|
app.pending_offer = Some(o);
|
||||||
}
|
}
|
||||||
ft::Ft::Accept(id) => {
|
ft::Ft::Accept(id) => {
|
||||||
|
|
@ -366,13 +426,22 @@ fn handle_ft(
|
||||||
app.err(format!("{} — SHA-256 mismatch, discarded", t.meta.name));
|
app.err(format!("{} — SHA-256 mismatch, discarded", t.meta.name));
|
||||||
} else {
|
} else {
|
||||||
match ft::save(downloads, &t.meta, &t.buf) {
|
match ft::save(downloads, &t.meta, &t.buf) {
|
||||||
Ok(p) => app.sys(format!("⛧ saved {} ({}) — verified ✓", p.display(), ft::human(t.buf.len()))),
|
Ok(p) => app.sys(format!(
|
||||||
|
"⛧ saved {} ({}) — verified ✓",
|
||||||
|
p.display(),
|
||||||
|
ft::human(t.buf.len())
|
||||||
|
)),
|
||||||
Err(e) => app.err(format!("save failed: {e}")),
|
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;
|
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,
|
// All outgoing frames funnel through here so background tasks (file chunks,
|
||||||
// PTY relay) can transmit without owning the socket.
|
// PTY relay) can transmit without owning the socket.
|
||||||
let (out_tx, mut out_rx) = unbounded_channel::<WsMsg>();
|
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 (broker_tx, mut broker_rx) = unbounded_channel::<BrokerMsg>();
|
||||||
let mut broker: Option<sbx::Sandbox> = None;
|
let mut broker: Option<sbx::Sandbox> = None;
|
||||||
let mut broker_meta: Option<(sbx::Backend, String)> = 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 {
|
if let Some(sb) = &broker {
|
||||||
let _ = sb.resize(dims.0, dims.1);
|
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() => {
|
outgoing = out_rx.recv() => {
|
||||||
match outgoing {
|
if let Some(first) = outgoing {
|
||||||
Some(first) => {
|
// Flush a batch to keep file-chunk bursts from redrawing per frame.
|
||||||
// Flush a batch to keep file-chunk bursts from redrawing per frame.
|
let mut batch = vec![first];
|
||||||
let mut batch = vec![first];
|
while let Ok(m) = out_rx.try_recv() {
|
||||||
while let Ok(m) = out_rx.try_recv() {
|
batch.push(m);
|
||||||
batch.push(m);
|
if batch.len() >= 64 { break; }
|
||||||
if batch.len() >= 64 { break; }
|
}
|
||||||
}
|
for m in batch {
|
||||||
for m in batch {
|
if write.send(m).await.is_err() {
|
||||||
if write.send(m).await.is_err() {
|
app.connected = false;
|
||||||
app.connected = false;
|
break;
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = sigterm.recv() => { break Ok(()); }
|
_ = sigterm.recv() => { break Ok(()); }
|
||||||
|
|
@ -733,13 +804,23 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
execute!(term.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
|
execute!(
|
||||||
|
term.backend_mut(),
|
||||||
|
LeaveAlternateScreen,
|
||||||
|
DisableMouseCapture
|
||||||
|
)?;
|
||||||
term.show_cursor()?;
|
term.show_cursor()?;
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
enum BrokerMsg {
|
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,
|
Failed,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -777,7 +858,10 @@ fn handle_command(
|
||||||
// Live vestment switch: `/theme <name>`, or bare `/theme` to list options.
|
// Live vestment switch: `/theme <name>`, or bare `/theme` to list options.
|
||||||
let name = rest.trim();
|
let name = rest.trim();
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
app.sys(format!("vestments: {} — /theme <name>", Theme::available().join(" · ")));
|
app.sys(format!(
|
||||||
|
"vestments: {} — /theme <name>",
|
||||||
|
Theme::available().join(" · ")
|
||||||
|
));
|
||||||
} else {
|
} else {
|
||||||
match Theme::by_name(name) {
|
match Theme::by_name(name) {
|
||||||
Ok(t) => {
|
Ok(t) => {
|
||||||
|
|
@ -800,18 +884,33 @@ fn handle_command(
|
||||||
} else {
|
} else {
|
||||||
app.sys("you don't have drive permission — the owner can /grant you");
|
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();
|
let path = path.trim();
|
||||||
match ft::read_payload(path) {
|
match ft::read_payload(path) {
|
||||||
Ok((name, bytes, dir)) => {
|
Ok((name, bytes, dir)) => {
|
||||||
*send_seq += 1;
|
*send_seq += 1;
|
||||||
let id = format!("{}-{}", app.me, send_seq);
|
let id = format!("{}-{}", app.me, send_seq);
|
||||||
let (size, sha) = (bytes.len(), ft::sha256_hex(&bytes));
|
let (size, sha) = (bytes.len(), ft::sha256_hex(&bytes));
|
||||||
*active_send = Some(ActiveSend { id: id.clone(), payload: Arc::new(bytes), sending: false });
|
*active_send = Some(ActiveSend {
|
||||||
send_frame(out_tx, room, json!({
|
id: id.clone(),
|
||||||
"_ft":"offer","id": id,"name": name,"size": size,"sha256": sha,"dir": dir
|
payload: Arc::new(bytes),
|
||||||
}));
|
sending: false,
|
||||||
app.sys(format!("offered {} ({}) — waiting for an /accept", name, ft::human(size)));
|
});
|
||||||
|
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}")),
|
Err(e) => app.err(format!("send failed: {e}")),
|
||||||
}
|
}
|
||||||
|
|
@ -843,21 +942,44 @@ fn handle_command(
|
||||||
// `--start` (alias `--start-daemon` / `-y`) opts in to booting
|
// `--start` (alias `--start-daemon` / `-y`) opts in to booting
|
||||||
// a stopped Docker daemon; everything else is positional.
|
// a stopped Docker daemon; everything else is positional.
|
||||||
let args: Vec<&str> = p.collect();
|
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 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 backend = pos
|
||||||
let image = pos.next().map(str::to_string).unwrap_or_else(|| backend.default_image().to_string());
|
.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");
|
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 {
|
} else {
|
||||||
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
|
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
|
||||||
let (rows, cols) = sbx_dims(sz.0, sz.1);
|
let (rows, cols) = sbx_dims(sz.0, sz.1);
|
||||||
*launching = true;
|
*launching = true;
|
||||||
let members: Vec<String> = app.users.iter().map(|u| u.username.clone()).collect();
|
let members: Vec<String> =
|
||||||
app.sys(format!("summoning {} sandbox… (provisioning unix users; multipass boot ~30s)", backend.label()));
|
app.users.iter().map(|u| u.username.clone()).collect();
|
||||||
spawn_launch(backend, image, app.me.clone(), members, rows, cols, start_daemon,
|
app.sys(format!(
|
||||||
pty_tx.clone(), broker_tx.clone(), app_tx.clone());
|
"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) => {
|
Err(e) => {
|
||||||
let _ = app_tx.send(Net::Err(format!("sandbox launch failed: {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).
|
/// 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.
|
/// `M` is sent to the server; `h_amk` is compared to the server's reply.
|
||||||
pub fn process_challenge(
|
pub fn process_challenge(&self, salt: &[u8], b_bytes: &[u8]) -> anyhow::Result<Challenge> {
|
||||||
&self,
|
|
||||||
salt: &[u8],
|
|
||||||
b_bytes: &[u8],
|
|
||||||
) -> anyhow::Result<Challenge> {
|
|
||||||
let n = &self.n;
|
let n = &self.n;
|
||||||
let width = long_to_bytes(n).len();
|
let width = long_to_bytes(n).len();
|
||||||
let big_b = bytes_to_long(b_bytes);
|
let big_b = bytes_to_long(b_bytes);
|
||||||
|
|
@ -218,7 +214,7 @@ mod tests {
|
||||||
|
|
||||||
fn a_bytes() -> Vec<u8> {
|
fn a_bytes() -> Vec<u8> {
|
||||||
let mut v = vec![0x80u8];
|
let mut v = vec![0x80u8];
|
||||||
v.extend(std::iter::repeat(0x22u8).take(31));
|
v.extend(std::iter::repeat_n(0x22u8, 31));
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -245,7 +241,9 @@ mod fernet_interop {
|
||||||
#[test]
|
#[test]
|
||||||
fn rust_decrypts_python_fernet() {
|
fn rust_decrypts_python_fernet() {
|
||||||
let f = fernet::Fernet::new(KEY).unwrap();
|
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");
|
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}"))?;
|
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 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");
|
let base = p.file_name().and_then(|s| s.to_str()).unwrap_or("dir");
|
||||||
Ok((format!("{base}.tar"), bytes, true))
|
Ok((format!("{base}.tar"), bytes, true))
|
||||||
} else {
|
} 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 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))
|
Ok((name, bytes, false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -89,17 +100,29 @@ pub fn save(downloads: &Path, offer: &Offer, data: &[u8]) -> Result<PathBuf> {
|
||||||
let mut e = entry?;
|
let mut e = entry?;
|
||||||
let path = e.path()?.into_owned();
|
let path = e.path()?.into_owned();
|
||||||
// Explicit zip-slip guard (belt-and-suspenders; unpack_in also refuses).
|
// 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() {
|
if top.is_none() {
|
||||||
top = path.components().next().map(|c| c.as_os_str().to_owned());
|
top = path.components().next().map(|c| c.as_os_str().to_owned());
|
||||||
}
|
}
|
||||||
e.unpack_in(downloads)
|
e.unpack_in(downloads)
|
||||||
.with_context(|| format!("extract {}", path.display()))?;
|
.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 {
|
} else {
|
||||||
let stem = Path::new(&offer.name).file_stem().and_then(|s| s.to_str()).unwrap_or("file");
|
let stem = Path::new(&offer.name)
|
||||||
let ext = Path::new(&offer.name).extension().and_then(|s| s.to_str()).unwrap_or("");
|
.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);
|
let dest = unique(downloads, stem, ext);
|
||||||
std::fs::write(&dest, data)?;
|
std::fs::write(&dest, data)?;
|
||||||
Ok(dest)
|
Ok(dest)
|
||||||
|
|
@ -113,8 +136,16 @@ fn safe_entry(path: &Path) -> bool {
|
||||||
|
|
||||||
fn unique(dir: &Path, stem: &str, ext: &str) -> PathBuf {
|
fn unique(dir: &Path, stem: &str, ext: &str) -> PathBuf {
|
||||||
let mk = |n: usize| {
|
let mk = |n: usize| {
|
||||||
let base = if n == 0 { stem.to_string() } else { format!("{stem}_{n}") };
|
let base = if n == 0 {
|
||||||
if ext.is_empty() { dir.join(base) } else { dir.join(format!("{base}.{ext}")) }
|
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()
|
(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();
|
let (name, bytes, is_dir) = read_payload(src.to_str().unwrap()).unwrap();
|
||||||
assert_eq!(name, "note.txt");
|
assert_eq!(name, "note.txt");
|
||||||
assert!(!is_dir);
|
assert!(!is_dir);
|
||||||
let offer = Offer { id: "1".into(), name, size: bytes.len() as u64,
|
let offer = Offer {
|
||||||
sha256: sha256_hex(&bytes), dir: false, from: "x".into() };
|
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 = save(&dl, &offer, &bytes).unwrap();
|
||||||
assert_eq!(std::fs::read(&out).unwrap(), b"offering to the clergy");
|
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();
|
let (name, bytes, is_dir) = read_payload(proj.to_str().unwrap()).unwrap();
|
||||||
assert_eq!(name, "proj.tar");
|
assert_eq!(name, "proj.tar");
|
||||||
assert!(is_dir);
|
assert!(is_dir);
|
||||||
let offer = Offer { id: "1".into(), name, size: bytes.len() as u64,
|
let offer = Offer {
|
||||||
sha256: sha256_hex(&bytes), dir: true, from: "x".into() };
|
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 = save(&dl, &offer, &bytes).unwrap(); // -> dl/proj
|
||||||
assert!(out.ends_with("proj"));
|
assert!(out.ends_with("proj"));
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,10 @@ enum Cmd {
|
||||||
theme: Option<String>,
|
theme: Option<String>,
|
||||||
},
|
},
|
||||||
/// Debug: print the derived room Fernet key for a password + room_salt(hex).
|
/// 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.
|
/// Run the offline SRP golden-vector self-test.
|
||||||
Selftest,
|
Selftest,
|
||||||
/// Debug: compute A and M from explicit a/salt/B hex (parity check vs python).
|
/// 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))
|
tokio::runtime::Runtime::new()?.block_on(app::run(params, session, theme))
|
||||||
}
|
}
|
||||||
Cmd::Roomkey { password, room_salt_hex } => {
|
Cmd::Roomkey {
|
||||||
|
password,
|
||||||
|
room_salt_hex,
|
||||||
|
} => {
|
||||||
let salt = hex::decode(room_salt_hex)?;
|
let salt = hex::decode(room_salt_hex)?;
|
||||||
let f = crypto::room_fernet(password.as_bytes(), &salt)?;
|
let f = crypto::room_fernet(password.as_bytes(), &salt)?;
|
||||||
// fernet crate doesn't expose the key; re-derive + print the b64 key.
|
// 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.
|
// Re-derive the golden vectors at runtime as a smoke check.
|
||||||
let c = crypto::SrpClient::with_a(b"chat", b"labtest", &{
|
let c = crypto::SrpClient::with_a(b"chat", b"labtest", &{
|
||||||
let mut v = vec![0x80u8];
|
let mut v = vec![0x80u8];
|
||||||
v.extend(std::iter::repeat(0x22u8).take(31));
|
v.extend(std::iter::repeat_n(0x22u8, 31));
|
||||||
v
|
v
|
||||||
});
|
});
|
||||||
let a = hex::encode(c.a_bytes());
|
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")?)?;
|
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?");
|
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 ✓)");
|
println!("/srp/verify ok — server identity proven (H_AMK ✓)");
|
||||||
|
|
||||||
// Room key + encrypt a message the Python clients can read.
|
// Room key + encrypt a message the Python clients can read.
|
||||||
|
|
@ -221,9 +230,7 @@ fn handshake(
|
||||||
|
|
||||||
// Connect WS and send the ciphertext.
|
// Connect WS and send the ciphertext.
|
||||||
let ws_scheme = if no_tls { "ws" } else { "wss" };
|
let ws_scheme = if no_tls { "ws" } else { "wss" };
|
||||||
let ws_url = format!(
|
let ws_url = format!("{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}");
|
||||||
"{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}"
|
|
||||||
);
|
|
||||||
let (mut sock, _resp) =
|
let (mut sock, _resp) =
|
||||||
tungstenite::connect(&ws_url).context("ws connect (insecure wss not yet wired)")?;
|
tungstenite::connect(&ws_url).context("ws connect (insecure wss not yet wired)")?;
|
||||||
println!("websocket attached to the house");
|
println!("websocket attached to the house");
|
||||||
|
|
|
||||||
|
|
@ -84,13 +84,15 @@ pub fn authenticate(
|
||||||
.json()?;
|
.json()?;
|
||||||
|
|
||||||
let server_hamk = STANDARD.decode(verify["H_AMK"].as_str().context("no H_AMK")?)?;
|
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 ws_token = verify["ws_token"].as_str().context("no ws_token")?;
|
||||||
|
|
||||||
let fernet = crypto::room_fernet(password.as_bytes(), &room_salt)?;
|
let fernet = crypto::room_fernet(password.as_bytes(), &room_salt)?;
|
||||||
let ws_scheme = if no_tls { "ws" } else { "wss" };
|
let ws_scheme = if no_tls { "ws" } else { "wss" };
|
||||||
let ws_url =
|
let ws_url = format!("{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}");
|
||||||
format!("{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}");
|
|
||||||
|
|
||||||
Ok(Session {
|
Ok(Session {
|
||||||
username: user.to_string(),
|
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.
|
// Control frames are live-only — never replayed from the stored snapshot.
|
||||||
if t.starts_with("{\"_sbx\":") {
|
if t.starts_with("{\"_sbx\":") {
|
||||||
return if live {
|
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 {
|
} else {
|
||||||
Decoded::Skip
|
Decoded::Skip
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if t.starts_with("{\"_ft\":") {
|
if t.starts_with("{\"_ft\":") {
|
||||||
return if live {
|
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 {
|
} else {
|
||||||
Decoded::Skip
|
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),
|
Err(_) => ("[unreadable — wrong room password?]".to_string(), true),
|
||||||
};
|
};
|
||||||
let stamp = m["timestamp"].as_str().unwrap_or("");
|
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 {
|
Decoded::Chat(ChatLine {
|
||||||
ts,
|
ts,
|
||||||
username: m["username"].as_str().unwrap_or("?").to_string(),
|
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.
|
/// 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 {
|
while let Some(frame) = read.next().await {
|
||||||
let txt = match frame {
|
let txt = match frame {
|
||||||
Ok(WsMsg::Text(t)) => t,
|
Ok(WsMsg::Text(t)) => t,
|
||||||
|
|
@ -254,7 +268,10 @@ pub async fn reader(mut read: impl StreamExt<Item = Result<WsMsg, tokio_tungsten
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect();
|
.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) {
|
"message" => match decode_msg(&room, &v["data"], true) {
|
||||||
Decoded::Chat(l) => tx.send(Net::Message(l)),
|
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"]),
|
users: parse_users(&v["users"]),
|
||||||
capacity: v["capacity"].as_u64().unwrap_or(0) as usize,
|
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())),
|
"user_left" => tx.send(Net::Left(v["user_id"].as_str().unwrap_or("").to_string())),
|
||||||
_ => Ok(()),
|
_ => Ok(()),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,10 @@ fn start_docker_daemon() -> Result<()> {
|
||||||
.context("running ensure-docker.sh")?;
|
.context("running ensure-docker.sh")?;
|
||||||
if !out.status.success() {
|
if !out.status.success() {
|
||||||
let err = String::from_utf8_lossy(&out.stderr);
|
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}");
|
anyhow::bail!("{last}");
|
||||||
}
|
}
|
||||||
Ok(())
|
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
|
// Capture output so it can't bleed onto the TUI surface; surface
|
||||||
// the failure reason through the returned error instead.
|
// the failure reason through the returned error instead.
|
||||||
let out = Command::new("multipass")
|
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()
|
.output()
|
||||||
.context("multipass launch (is multipass installed?)")?;
|
.context("multipass launch (is multipass installed?)")?;
|
||||||
if !out.status.success() {
|
if !out.status.success() {
|
||||||
let err = String::from_utf8_lossy(&out.stderr);
|
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 {
|
} else {
|
||||||
let _ = Command::new("multipass").args(["start", name])
|
let _ = Command::new("multipass")
|
||||||
.stdout(Stdio::null()).stderr(Stdio::null()).status();
|
.args(["start", name])
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status();
|
||||||
}
|
}
|
||||||
Ok(())
|
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.
|
// Persistent container so we can exec in to provision users + shells.
|
||||||
let _ = Command::new("docker").args(["rm", "-f", name])
|
let _ = Command::new("docker")
|
||||||
.stdout(Stdio::null()).stderr(Stdio::null()).status();
|
.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
|
// Capture output so a failure can't paint over the TUI; the reason is
|
||||||
// surfaced through the returned error (shown in the error popup).
|
// surfaced through the returned error (shown in the error popup).
|
||||||
let out = Command::new("docker")
|
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()
|
.output()
|
||||||
.context("docker run (is docker installed?)")?;
|
.context("docker run (is docker installed?)")?;
|
||||||
if !out.status.success() {
|
if !out.status.success() {
|
||||||
let err = String::from_utf8_lossy(&out.stderr);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -142,12 +172,18 @@ pub fn prepare(backend: Backend, name: &str, image: &str, start_daemon: bool) ->
|
||||||
pub fn teardown(backend: Backend, name: &str) {
|
pub fn teardown(backend: Backend, name: &str) {
|
||||||
match backend {
|
match backend {
|
||||||
Backend::Multipass => {
|
Backend::Multipass => {
|
||||||
let _ = Command::new("multipass").args(["delete", name, "--purge"])
|
let _ = Command::new("multipass")
|
||||||
.stdout(Stdio::null()).stderr(Stdio::null()).status();
|
.args(["delete", name, "--purge"])
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status();
|
||||||
}
|
}
|
||||||
Backend::Docker => {
|
Backend::Docker => {
|
||||||
let _ = Command::new("docker").args(["rm", "-f", name])
|
let _ = Command::new("docker")
|
||||||
.stdout(Stdio::null()).stderr(Stdio::null()).status();
|
.args(["rm", "-f", name])
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status();
|
||||||
}
|
}
|
||||||
Backend::Local => {}
|
Backend::Local => {}
|
||||||
}
|
}
|
||||||
|
|
@ -163,7 +199,11 @@ fn command_for(backend: Backend, name: &str, run_user: &str) -> CommandBuilder {
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
Backend::Docker => {
|
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");
|
let mut c = CommandBuilder::new("docker");
|
||||||
c.args(["exec", "-it", "-u", user, name, "bash", "-il"]);
|
c.args(["exec", "-it", "-u", user, name, "bash", "-il"]);
|
||||||
c
|
c
|
||||||
|
|
@ -196,14 +236,20 @@ fn mp(name: &str, args: &[&str]) {
|
||||||
let mut a = vec!["exec", name, "--"];
|
let mut a = vec!["exec", name, "--"];
|
||||||
a.extend_from_slice(args);
|
a.extend_from_slice(args);
|
||||||
// Null stdio so provisioning chatter never bleeds onto the TUI surface.
|
// Null stdio so provisioning chatter never bleeds onto the TUI surface.
|
||||||
let _ = Command::new("multipass").args(a)
|
let _ = Command::new("multipass")
|
||||||
.stdout(Stdio::null()).stderr(Stdio::null()).status();
|
.args(a)
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status();
|
||||||
}
|
}
|
||||||
fn dk(name: &str, args: &[&str]) {
|
fn dk(name: &str, args: &[&str]) {
|
||||||
let mut a = vec!["exec", name];
|
let mut a = vec!["exec", name];
|
||||||
a.extend_from_slice(args);
|
a.extend_from_slice(args);
|
||||||
let _ = Command::new("docker").args(a)
|
let _ = Command::new("docker")
|
||||||
.stdout(Stdio::null()).stderr(Stdio::null()).status();
|
.args(a)
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Grant a Multipass user real *passwordless* sudo (group + sudoers.d drop-in)
|
/// Grant a Multipass user real *passwordless* sudo (group + sudoers.d drop-in)
|
||||||
|
|
@ -362,8 +408,8 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn local_shell_pty_roundtrip() {
|
fn local_shell_pty_roundtrip() {
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
let mut sb = Sandbox::launch(Backend::Local, "test", "", 24, 80, tx)
|
let mut sb =
|
||||||
.expect("launch local shell");
|
Sandbox::launch(Backend::Local, "test", "", 24, 80, tx).expect("launch local shell");
|
||||||
sb.write_input(b"echo HELLO_PTY_42\n").unwrap();
|
sb.write_input(b"echo HELLO_PTY_42\n").unwrap();
|
||||||
|
|
||||||
let mut acc = String::new();
|
let mut acc = String::new();
|
||||||
|
|
@ -401,8 +447,7 @@ mod relay_tests {
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
let mut sb = Sandbox::launch(Backend::Local, "test", "", 24, 80, tx)
|
let mut sb = Sandbox::launch(Backend::Local, "test", "", 24, 80, tx).expect("launch");
|
||||||
.expect("launch");
|
|
||||||
sb.write_input(b"echo RELAY_MARKER_7\n").unwrap();
|
sb.write_input(b"echo RELAY_MARKER_7\n").unwrap();
|
||||||
|
|
||||||
// Remote client side: a vt100 parser fed from decoded data frames.
|
// Remote client side: a vt100 parser fed from decoded data frames.
|
||||||
|
|
|
||||||
|
|
@ -41,18 +41,18 @@ impl Default for Theme {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
name: "crypt".into(),
|
name: "crypt".into(),
|
||||||
border: Color::Rgb(0x6e, 0x6e, 0x7a), // gray chrome, defined against the surface
|
border: Color::Rgb(0x6e, 0x6e, 0x7a), // gray chrome, defined against the surface
|
||||||
title: Color::Rgb(0xff, 0xff, 0xff), // white
|
title: Color::Rgb(0xff, 0xff, 0xff), // white
|
||||||
accent: Color::Rgb(0xff, 0xff, 0xff), // white (sigil / prompt)
|
accent: Color::Rgb(0xff, 0xff, 0xff), // white (sigil / prompt)
|
||||||
dim: Color::Rgb(0x9a, 0x9a, 0xa6), // timestamps — readable mid-gray
|
dim: Color::Rgb(0x9a, 0x9a, 0xa6), // timestamps — readable mid-gray
|
||||||
me: Color::Rgb(0xff, 0xff, 0xff), // your messages = white
|
me: Color::Rgb(0xff, 0xff, 0xff), // your messages = white
|
||||||
other: Color::Rgb(0xc8, 0xc8, 0xd0), // others = bright gray
|
other: Color::Rgb(0xc8, 0xc8, 0xd0), // others = bright gray
|
||||||
system: Color::Rgb(0xb0, 0xb0, 0xbc), // system / occult = legible muted gray
|
system: Color::Rgb(0xb0, 0xb0, 0xbc), // system / occult = legible muted gray
|
||||||
input: Color::Rgb(0xff, 0xff, 0xff),
|
input: Color::Rgb(0xff, 0xff, 0xff),
|
||||||
roster_me: Color::Rgb(0xff, 0xff, 0xff), // you / owner = white
|
roster_me: Color::Rgb(0xff, 0xff, 0xff), // you / owner = white
|
||||||
bg: Color::Rgb(0x1c, 0x1c, 0x22), // slate panel lifts text off pure-black
|
bg: Color::Rgb(0x1c, 0x1c, 0x22), // slate panel lifts text off pure-black
|
||||||
roster_width: 22,
|
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
|
// 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
|
// gaps between them) sits on the same surface. With bg = Reset this is a no-op
|
||||||
// and we ride the terminal's own colour.
|
// 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([
|
let rows = Layout::vertical([
|
||||||
Constraint::Length(1),
|
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).
|
/// 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) {
|
fn draw_error(f: &mut Frame, area: Rect, theme: &Theme, msg: &str) {
|
||||||
let text = format!("⚠ {msg}");
|
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 inner = w.saturating_sub(2).max(1);
|
||||||
let rows = (text.chars().count() as u16).div_ceil(inner) + 2;
|
let rows = (text.chars().count() as u16).div_ceil(inner) + 2;
|
||||||
let h = rows.min(area.height.saturating_sub(2)).max(3);
|
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 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 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);
|
f.render_widget(Clear, rect);
|
||||||
let popup = Paragraph::new(text)
|
let popup = Paragraph::new(text)
|
||||||
.style(Style::default().fg(theme.title).bg(theme.bg))
|
.style(Style::default().fg(theme.title).bg(theme.bg))
|
||||||
.block(
|
.block(
|
||||||
Block::bordered()
|
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(
|
.title(Span::styled(
|
||||||
format!(" {} error · any key ", theme.sigil),
|
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 });
|
.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) {
|
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 key = Style::default().fg(theme.title);
|
||||||
let dim = Style::default().fg(theme.system);
|
let dim = Style::default().fg(theme.system);
|
||||||
let kv = |k: &str, v: &str| {
|
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 head = |s: &str| Line::from(Span::styled(format!("{sig} {s}"), acc));
|
||||||
let lines = vec![
|
let lines = vec![
|
||||||
head("COMMANDS (type in the input bar)"),
|
head("COMMANDS (type in the input bar)"),
|
||||||
kv("/sbx launch [backend]", "summon a sandbox: local | docker | multipass"),
|
kv(
|
||||||
|
"/sbx launch [backend]",
|
||||||
|
"summon a sandbox: local | docker | multipass",
|
||||||
|
),
|
||||||
kv("/sbx stop", "tear down the sandbox (purges the VM)"),
|
kv("/sbx stop", "tear down the sandbox (purges the VM)"),
|
||||||
kv("/drive", "type into the shared shell (Esc releases)"),
|
kv("/drive", "type into the shared shell (Esc releases)"),
|
||||||
kv("/grant <user>", "let a member drive the shell (owner)"),
|
kv(
|
||||||
kv("/revoke <user>", "take back drive permission (owner)"),
|
"/grant <user>",
|
||||||
kv("/sudo <user>", "delegate VM superuser (real sudo) (owner)"),
|
"let a member drive the shell (owner)",
|
||||||
kv("/unsudo <user>", "revoke VM superuser (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("/send <file>", "offer a file to the room"),
|
||||||
kv("/sendd <dir>", "offer a directory (sent as a tar)"),
|
kv("/sendd <dir>", "offer a directory (sent as a tar)"),
|
||||||
kv("/accept · /reject", "respond to an incoming file offer"),
|
kv("/accept · /reject", "respond to an incoming file offer"),
|
||||||
kv("/theme [name]", "change vestments live: church | neon | crypt"),
|
kv(
|
||||||
|
"/theme [name]",
|
||||||
|
"change vestments live: church | neon | crypt",
|
||||||
|
),
|
||||||
kv("/pw", "show this room's password (local only)"),
|
kv("/pw", "show this room's password (local only)"),
|
||||||
kv("/help", "show / hide this menu"),
|
kv("/help", "show / hide this menu"),
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
|
|
@ -126,17 +160,31 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) {
|
||||||
kv("F2 · /drive", "take the shell · Esc releases it"),
|
kv("F2 · /drive", "take the shell · Esc releases it"),
|
||||||
kv("Ctrl-C (while driving)", "interrupt the running command"),
|
kv("Ctrl-C (while driving)", "interrupt the running command"),
|
||||||
kv("PgUp / PgDn", "scroll chat · Home/End = oldest/live"),
|
kv("PgUp / PgDn", "scroll chat · Home/End = oldest/live"),
|
||||||
kv("PgUp / PgDn (driving)", "scroll the sandbox terminal's scrollback"),
|
kv(
|
||||||
kv("Up / Down · wheel", "scroll the sandbox terminal (mouse works while driving)"),
|
"PgUp / PgDn (driving)",
|
||||||
kv("Ctrl-R (when closed)", "reconnect to the house after a drop / AFK"),
|
"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"),
|
kv("Ctrl-C · Ctrl-Q", "quit hack-house"),
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
head("ROSTER GLYPHS"),
|
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(""),
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
" malware bless · press any key to close",
|
" 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);
|
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))
|
.border_style(Style::default().fg(theme.accent))
|
||||||
.title(Span::styled(
|
.title(Span::styled(
|
||||||
format!(" {0} hack-house — help {0} ", theme.sigil),
|
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 });
|
.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()
|
" · /drive (or F2) · ↑/↓/wheel scroll".to_string()
|
||||||
};
|
};
|
||||||
let title = format!(" sandbox · {}{} ", sv.backend, drive);
|
let title = format!(" sandbox · {}{} ", sv.backend, drive);
|
||||||
let border = if app.driving { theme.accent } else { theme.border };
|
let border = if app.driving {
|
||||||
|
theme.accent
|
||||||
|
} else {
|
||||||
|
theme.border
|
||||||
|
};
|
||||||
let pane = Paragraph::new(lines).block(
|
let pane = Paragraph::new(lines).block(
|
||||||
Block::bordered()
|
Block::bordered()
|
||||||
.border_style(Style::default().fg(border))
|
.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) {
|
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 {
|
let status = if app.connected {
|
||||||
"🔒 e2e"
|
"🔒 e2e"
|
||||||
} else if app.reconnecting {
|
} 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![
|
let bar = Line::from(vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!(" {0} hack-house {0} ", theme.sigil),
|
format!(" {0} hack-house {0} ", theme.sigil),
|
||||||
Style::default().fg(theme.accent).add_modifier(Modifier::BOLD),
|
Style::default()
|
||||||
|
.fg(theme.accent)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
Span::styled(format!("· {status} "), Style::default().fg(theme.dim)),
|
Span::styled(format!("· {status} "), Style::default().fg(theme.dim)),
|
||||||
Span::styled(
|
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);
|
let text = l.text.replace('⛧', &theme.sigil);
|
||||||
return Line::from(Span::styled(
|
return Line::from(Span::styled(
|
||||||
format!(" {} {}", theme.sigil, text),
|
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![
|
Line::from(vec![
|
||||||
Span::styled(format!("{} ", l.ts), Style::default().fg(theme.dim)),
|
Span::styled(format!("{} ", l.ts), Style::default().fg(theme.dim)),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
|
|
@ -305,8 +371,13 @@ fn draw_input(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &The
|
||||||
}))
|
}))
|
||||||
.title(Span::styled(
|
.title(Span::styled(
|
||||||
match &app.pending_offer {
|
match &app.pending_offer {
|
||||||
Some(o) => format!(" {} incoming: {} — /accept or /reject ", theme.sigil, o.name),
|
Some(o) => format!(
|
||||||
None if app.driving => format!(" {} DRIVING the shell — Esc to release ", theme.sigil),
|
" {} 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(),
|
None => " message · enter send · /drive for shell · ctrl-q quit ".to_string(),
|
||||||
},
|
},
|
||||||
Style::default().fg(theme.title),
|
Style::default().fg(theme.title),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user