feat(hh): P5 — file & directory uploads ⛧
Wire-compatible with the Python client's _ft protocol (offer/accept/reject/ chunk/done, 64KB chunks, SHA-256 verified), over the encrypted channel: - ft.rs: read_payload (file or tar'd directory), save/extract with a zip-slip guard (relative-only, no .. / absolute; unpack_in double-checks), SHA-256. - /send <file> and /sendd <dir>; receiver sees an offer banner, /accept or /reject; chunks stream in the background and the result is verified + saved to ./downloads (directories extracted in place). - Refactor: all outgoing ws frames now funnel through an mpsc channel drained (batched) by the run loop, so the background chunk-sender and the PTY relay transmit without owning the socket. - ui.rs: pending-offer banner on the input bar. Tests: 7 cargo tests (file + dir tar round-trip, traversal guard, + crypto/sbx). Verified live: two TUIs, file and directory transfer, SHA-256 verified, saved. Note: dropping accepted files into the active sandbox VM dir is a future add-on. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f73c23bf57
commit
1dfc614cc5
55
hh/Cargo.lock
generated
55
hh/Cargo.lock
generated
|
|
@ -281,7 +281,7 @@ dependencies = [
|
|||
"futures-core",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
"rustix 0.38.44",
|
||||
"signal-hook",
|
||||
"signal-hook-mio",
|
||||
"winapi",
|
||||
|
|
@ -426,6 +426,16 @@ dependencies = [
|
|||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
|
|
@ -588,6 +598,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tar",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"toml",
|
||||
|
|
@ -958,6 +969,12 @@ version = "0.4.15"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
|
|
@ -1442,10 +1459,23 @@ dependencies = [
|
|||
"bitflags 2.11.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.40"
|
||||
|
|
@ -1782,6 +1812,17 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termios"
|
||||
version = "0.2.2"
|
||||
|
|
@ -2493,6 +2534,16 @@ version = "0.6.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix 1.1.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.2"
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ url = "2"
|
|||
portable-pty = "0.8"
|
||||
vt100 = "0.15"
|
||||
|
||||
# file/dir transfer (P5)
|
||||
tar = "0.4"
|
||||
|
||||
# async + tui
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] }
|
||||
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
||||
|
|
|
|||
1
hh/downloads/hh-payload.txt
Normal file
1
hh/downloads/hh-payload.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
secret offering to the coven — marker XYZ123
|
||||
1
hh/downloads/hh-proj/a.txt
Normal file
1
hh/downloads/hh-proj/a.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
AAA
|
||||
1
hh/downloads/hh-proj/sub/b.txt
Normal file
1
hh/downloads/hh-proj/sub/b.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
BBB
|
||||
419
hh/src/app.rs
419
hh/src/app.rs
|
|
@ -1,5 +1,6 @@
|
|||
//! TUI application state, network event model, and the async run loop.
|
||||
|
||||
use crate::ft;
|
||||
use crate::net::{self, Session};
|
||||
use crate::sbx;
|
||||
use crate::theme::Theme;
|
||||
|
|
@ -16,13 +17,15 @@ use futures_util::{SinkExt, StreamExt};
|
|||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
|
||||
use tokio_tungstenite::tungstenite::Message as WsMsg;
|
||||
|
||||
const SBX_NAME: &str = "hack-house";
|
||||
|
||||
/// One rendered chat row.
|
||||
#[derive(Clone)]
|
||||
pub struct ChatLine {
|
||||
pub ts: String,
|
||||
|
|
@ -37,6 +40,20 @@ pub struct User {
|
|||
pub username: String,
|
||||
}
|
||||
|
||||
/// An in-progress incoming transfer we accepted.
|
||||
struct Transfer {
|
||||
meta: ft::Offer,
|
||||
buf: Vec<u8>,
|
||||
accepted: bool,
|
||||
}
|
||||
|
||||
/// An outgoing transfer awaiting / serving an accept.
|
||||
struct ActiveSend {
|
||||
id: String,
|
||||
payload: Arc<Vec<u8>>,
|
||||
sending: bool,
|
||||
}
|
||||
|
||||
/// Decoded events arriving from the websocket reader task.
|
||||
pub enum Net {
|
||||
Init { lines: Vec<ChatLine>, users: Vec<User> },
|
||||
|
|
@ -49,23 +66,11 @@ pub enum Net {
|
|||
SbxData(Vec<u8>),
|
||||
SbxInput { from: String, bytes: Vec<u8> },
|
||||
Perm { owner: String, drivers: Vec<String> },
|
||||
Ft(ft::Ft),
|
||||
Sys(String),
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Sandbox handoff from the async launch task to the run loop.
|
||||
enum BrokerMsg {
|
||||
Ready {
|
||||
sb: sbx::Sandbox,
|
||||
backend: sbx::Backend,
|
||||
name: String,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
},
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Local view of the shared sandbox terminal (everyone renders from `data`).
|
||||
pub struct SbxView {
|
||||
pub parser: vt100::Parser,
|
||||
pub backend: String,
|
||||
|
|
@ -80,10 +85,10 @@ pub struct App {
|
|||
pub connected: bool,
|
||||
pub sandbox: Option<SbxView>,
|
||||
pub driving: bool,
|
||||
/// Sandbox owner (the initiator / superuser). Empty until a sandbox launches.
|
||||
pub owner: Option<String>,
|
||||
/// Members allowed to drive the shared shell (always includes the owner).
|
||||
pub drivers: std::collections::HashSet<String>,
|
||||
pub pending_offer: Option<ft::Offer>,
|
||||
transfers: HashMap<String, Transfer>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
|
|
@ -99,13 +104,14 @@ impl App {
|
|||
driving: false,
|
||||
owner: None,
|
||||
drivers: std::collections::HashSet::new(),
|
||||
pending_offer: None,
|
||||
transfers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_owner(&self) -> bool {
|
||||
self.owner.as_deref() == Some(self.me.as_str())
|
||||
}
|
||||
|
||||
pub fn can_drive(&self) -> bool {
|
||||
self.drivers.contains(&self.me)
|
||||
}
|
||||
|
|
@ -126,7 +132,7 @@ impl App {
|
|||
self.users = users;
|
||||
self.connected = true;
|
||||
self.sys(format!("joined as {} ⛧", self.me));
|
||||
self.sys("/sbx launch [local|docker|multipass] · /sbx stop · F2 to drive");
|
||||
self.sys("/send <file> · /sendd <dir> · /sbx launch · F2 drive · ctrl-q quit");
|
||||
}
|
||||
Net::Message(l) => self.lines.push(l),
|
||||
Net::Roster { users, capacity } => {
|
||||
|
|
@ -155,21 +161,6 @@ impl App {
|
|||
self.sys("⛧ sandbox dismissed");
|
||||
}
|
||||
}
|
||||
Net::Perm { owner, drivers } => {
|
||||
let new: std::collections::HashSet<String> = drivers.into_iter().collect();
|
||||
// Surface changes that affect me.
|
||||
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() {
|
||||
self.sys("⛧ you were granted drive (you can drive — F2)");
|
||||
} else if !new.contains(&self.me) && self.drivers.contains(&self.me) {
|
||||
self.driving = false;
|
||||
self.sys("⛧ your drive permission was revoked");
|
||||
}
|
||||
self.owner = Some(owner).filter(|o| !o.is_empty());
|
||||
self.drivers = new;
|
||||
}
|
||||
Net::SbxResize { rows, cols } => {
|
||||
if let Some(v) = &mut self.sandbox {
|
||||
v.parser.set_size(rows.max(1), cols.max(1));
|
||||
|
|
@ -180,7 +171,22 @@ impl App {
|
|||
v.parser.process(&bytes);
|
||||
}
|
||||
}
|
||||
Net::SbxInput { .. } => {} // broker enforces + writes to PTY in the run loop
|
||||
Net::SbxInput { .. } => {} // broker enforces + writes in the run loop
|
||||
Net::Perm { owner, drivers } => {
|
||||
let new: std::collections::HashSet<String> = drivers.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() {
|
||||
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");
|
||||
}
|
||||
self.owner = Some(owner).filter(|o| !o.is_empty());
|
||||
self.drivers = new;
|
||||
}
|
||||
Net::Ft(_) => {} // handled in the run loop (needs out channel + disk)
|
||||
Net::Sys(t) => self.sys(t),
|
||||
Net::Closed => {
|
||||
self.connected = false;
|
||||
|
|
@ -190,15 +196,12 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
/// Approximate inner dimensions of the sandbox pane for a terminal of this size
|
||||
/// (mirrors ui.rs layout: top bar 1, input 3, sandbox = 55% of the body).
|
||||
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))
|
||||
}
|
||||
|
||||
/// Translate a key event into the bytes a PTY expects (drive mode).
|
||||
fn key_to_pty(code: KeyCode, mods: KeyModifiers) -> Option<Vec<u8>> {
|
||||
match code {
|
||||
KeyCode::Char(c) => {
|
||||
|
|
@ -220,26 +223,91 @@ fn key_to_pty(code: KeyCode, mods: KeyModifiers) -> Option<Vec<u8>> {
|
|||
}
|
||||
}
|
||||
|
||||
async fn send_frame<S>(write: &mut S, room: &fernet::Fernet, value: serde_json::Value)
|
||||
where
|
||||
S: SinkExt<WsMsg> + Unpin,
|
||||
{
|
||||
let ct = room.encrypt(value.to_string().as_bytes());
|
||||
let _ = write.send(WsMsg::Text(ct)).await;
|
||||
/// Queue an encrypted JSON frame for transmission (drained by the run loop).
|
||||
fn send_frame(out: &UnboundedSender<WsMsg>, room: &fernet::Fernet, value: serde_json::Value) {
|
||||
let _ = out.send(WsMsg::Text(room.encrypt(value.to_string().as_bytes())));
|
||||
}
|
||||
|
||||
/// Broadcast the current access-control list (owner + permitted drivers).
|
||||
async fn broadcast_acl<S>(write: &mut S, room: &fernet::Fernet, app: &App)
|
||||
where
|
||||
S: SinkExt<WsMsg> + Unpin,
|
||||
{
|
||||
fn broadcast_acl(out: &UnboundedSender<WsMsg>, room: &fernet::Fernet, app: &App) {
|
||||
let drivers: Vec<&String> = app.drivers.iter().collect();
|
||||
send_frame(
|
||||
write,
|
||||
room,
|
||||
json!({"_perm":"acl","owner": app.owner, "drivers": drivers}),
|
||||
)
|
||||
.await;
|
||||
send_frame(out, room, json!({"_perm":"acl","owner": app.owner, "drivers": drivers}));
|
||||
}
|
||||
|
||||
/// Stream a payload to the coven as `_ft` chunks (background, paced).
|
||||
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() {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
}
|
||||
send_frame(&out, &room, json!({"_ft":"done","id": id}));
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_ft(
|
||||
f: ft::Ft,
|
||||
app: &mut App,
|
||||
active: &mut Option<ActiveSend>,
|
||||
out: &UnboundedSender<WsMsg>,
|
||||
room: &Arc<fernet::Fernet>,
|
||||
downloads: &std::path::Path,
|
||||
) {
|
||||
match f {
|
||||
ft::Ft::Offer(o) => {
|
||||
if o.from == app.me {
|
||||
return; // our own offer echo
|
||||
}
|
||||
app.sys(format!(
|
||||
"⛧ {} offers {} ({}{}) — /accept or /reject",
|
||||
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.pending_offer = Some(o);
|
||||
}
|
||||
ft::Ft::Accept(id) => {
|
||||
if let Some(a) = active.as_mut() {
|
||||
if a.id == id && !a.sending {
|
||||
a.sending = true;
|
||||
spawn_send(id, a.payload.clone(), out.clone(), room.clone());
|
||||
app.sys("transfer accepted — sending…");
|
||||
}
|
||||
}
|
||||
}
|
||||
ft::Ft::Reject(id) => {
|
||||
if active.as_ref().map(|a| a.id == id).unwrap_or(false) {
|
||||
app.sys("transfer rejected");
|
||||
*active = None;
|
||||
}
|
||||
}
|
||||
ft::Ft::Chunk { id, data } => {
|
||||
if let Some(t) = app.transfers.get_mut(&id) {
|
||||
if t.accepted {
|
||||
t.buf.extend_from_slice(&data);
|
||||
}
|
||||
}
|
||||
}
|
||||
ft::Ft::Done(id) => {
|
||||
if let Some(t) = app.transfers.remove(&id) {
|
||||
if t.accepted {
|
||||
if ft::sha256_hex(&t.buf) != t.meta.sha256 {
|
||||
app.sys(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()))),
|
||||
Err(e) => app.sys(format!("save failed: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if app.pending_offer.as_ref().map(|o| o.id == id).unwrap_or(false) {
|
||||
app.pending_offer = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
||||
|
|
@ -249,14 +317,18 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
|||
let app_tx = tx.clone();
|
||||
tokio::spawn(net::reader(read, session.room.clone(), tx));
|
||||
|
||||
// Broker-owned sandbox PTY output, and async-launch handoff.
|
||||
let (pty_tx, mut pty_rx): (UnboundedSender<Vec<u8>>, UnboundedReceiver<Vec<u8>>) =
|
||||
unbounded_channel();
|
||||
// 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 (broker_tx, mut broker_rx) = unbounded_channel::<BrokerMsg>();
|
||||
let mut broker: Option<sbx::Sandbox> = None;
|
||||
let mut broker_meta: Option<(sbx::Backend, String)> = None;
|
||||
let mut launching = false;
|
||||
let mut announced_dims: Option<(u16, u16)> = None;
|
||||
let mut active_send: Option<ActiveSend> = None;
|
||||
let mut send_seq: u64 = 0;
|
||||
let downloads = PathBuf::from("./downloads");
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = std::io::stdout();
|
||||
|
|
@ -272,7 +344,6 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
|||
break Err(e.into());
|
||||
}
|
||||
|
||||
// Broker: keep the PTY sized to our pane; broadcast size changes.
|
||||
if broker.is_some() {
|
||||
if let Ok(sz) = term.size() {
|
||||
let dims = sbx_dims(sz.width, sz.height);
|
||||
|
|
@ -281,8 +352,7 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
|||
if let Some(sb) = &broker {
|
||||
let _ = sb.resize(dims.0, dims.1);
|
||||
}
|
||||
send_frame(&mut write, &session.room,
|
||||
json!({"_sbx":"resize","rows":dims.0,"cols":dims.1})).await;
|
||||
send_frame(&out_tx, &session.room, json!({"_sbx":"resize","rows":dims.0,"cols":dims.1}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -291,13 +361,11 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
|||
maybe = events.next() => {
|
||||
match maybe {
|
||||
Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => {
|
||||
if k.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& matches!(k.code, KeyCode::Char('q')) {
|
||||
if k.modifiers.contains(KeyModifiers::CONTROL) && matches!(k.code, KeyCode::Char('q')) {
|
||||
break Ok(());
|
||||
}
|
||||
if k.code == KeyCode::F(2) {
|
||||
if app.sandbox.is_none() {
|
||||
// nothing to drive
|
||||
} else if app.can_drive() {
|
||||
app.driving = !app.driving;
|
||||
} else {
|
||||
|
|
@ -307,8 +375,7 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
|||
if k.code == KeyCode::Esc {
|
||||
app.driving = false;
|
||||
} else if let Some(bytes) = key_to_pty(k.code, k.modifiers) {
|
||||
send_frame(&mut write, &session.room,
|
||||
json!({"_sbx":"input","b64": STANDARD.encode(&bytes)})).await;
|
||||
send_frame(&out_tx, &session.room, json!({"_sbx":"input","b64": STANDARD.encode(&bytes)}));
|
||||
}
|
||||
} else {
|
||||
match k.code {
|
||||
|
|
@ -316,74 +383,9 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
|||
KeyCode::Enter => {
|
||||
let line = app.input.trim().to_string();
|
||||
app.input.clear();
|
||||
if let Some(rest) = line.strip_prefix("/sbx") {
|
||||
let mut p = rest.split_whitespace();
|
||||
match p.next() {
|
||||
Some("launch") => {
|
||||
if app.sandbox.is_some() || broker.is_some() || launching {
|
||||
app.sys("a sandbox is already running");
|
||||
} else {
|
||||
let backend = p.next()
|
||||
.and_then(sbx::Backend::parse)
|
||||
.unwrap_or(sbx::Backend::Local);
|
||||
let image = p.next()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| backend.default_image().to_string());
|
||||
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;
|
||||
app.sys(format!(
|
||||
"summoning {} sandbox… (multipass boot can take ~30s)",
|
||||
backend.label()));
|
||||
spawn_launch(backend, image, rows, cols,
|
||||
pty_tx.clone(), broker_tx.clone(), app_tx.clone());
|
||||
}
|
||||
}
|
||||
Some("stop") => {
|
||||
if let Some(mut sb) = broker.take() {
|
||||
sb.stop();
|
||||
if let Some((be, name)) = broker_meta.take() {
|
||||
tokio::task::spawn_blocking(move || sbx::teardown(be, &name));
|
||||
}
|
||||
announced_dims = None;
|
||||
send_frame(&mut write, &session.room,
|
||||
json!({"_sbx":"status","state":"stopped"})).await;
|
||||
} else {
|
||||
app.sys("you are not hosting a sandbox");
|
||||
}
|
||||
}
|
||||
_ => app.sys("usage: /sbx launch [local|docker|multipass] [image] | /sbx stop"),
|
||||
}
|
||||
} else if let Some(rest) = line.strip_prefix("/grant") {
|
||||
let target = rest.trim();
|
||||
if !app.is_owner() {
|
||||
app.sys("only the sandbox owner can /grant");
|
||||
} else if target.is_empty() {
|
||||
app.sys("usage: /grant <user>");
|
||||
} else {
|
||||
app.drivers.insert(target.to_string());
|
||||
broadcast_acl(&mut write, &session.room, &app).await;
|
||||
app.sys(format!("granted drive to {target}"));
|
||||
}
|
||||
} else if let Some(rest) = line.strip_prefix("/revoke") {
|
||||
let target = rest.trim();
|
||||
if !app.is_owner() {
|
||||
app.sys("only the sandbox owner can /revoke");
|
||||
} else if target == app.me {
|
||||
app.sys("the owner cannot revoke themselves");
|
||||
} else if target.is_empty() {
|
||||
app.sys("usage: /revoke <user>");
|
||||
} else {
|
||||
app.drivers.remove(target);
|
||||
broadcast_acl(&mut write, &session.room, &app).await;
|
||||
app.sys(format!("revoked drive from {target}"));
|
||||
}
|
||||
} else if !line.is_empty() && app.connected {
|
||||
let ct = session.room.encrypt(line.as_bytes());
|
||||
if write.send(WsMsg::Text(ct)).await.is_err() {
|
||||
app.connected = false;
|
||||
}
|
||||
}
|
||||
handle_command(&line, &mut app, &mut active_send, &mut send_seq,
|
||||
&mut broker, &mut broker_meta, &mut launching, &mut announced_dims,
|
||||
&out_tx, &pty_tx, &broker_tx, &app_tx, &session, &term);
|
||||
}
|
||||
KeyCode::Backspace => { app.input.pop(); }
|
||||
KeyCode::Char(c) => app.input.push(c),
|
||||
|
|
@ -398,14 +400,13 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
|||
net = rx.recv() => {
|
||||
match net {
|
||||
Some(Net::SbxInput { from, bytes }) => {
|
||||
// Broker authority: only honor input from a permitted driver
|
||||
// (sender is server-authenticated via the message username).
|
||||
if let Some(sb) = &mut broker {
|
||||
if app.drivers.contains(&from) {
|
||||
let _ = sb.write_input(&bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Net::Ft(f)) => handle_ft(f, &mut app, &mut active_send, &out_tx, &session.room, &downloads),
|
||||
Some(n) => app.apply(n),
|
||||
None => break Ok(()),
|
||||
}
|
||||
|
|
@ -417,15 +418,13 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
|||
broker_meta = Some((backend, name));
|
||||
announced_dims = Some((rows, cols));
|
||||
launching = false;
|
||||
// The launcher is the owner / superuser and the first driver.
|
||||
app.owner = Some(app.me.clone());
|
||||
app.drivers.clear();
|
||||
app.drivers.insert(app.me.clone());
|
||||
send_frame(&mut write, &session.room, json!({
|
||||
"_sbx":"status","state":"ready",
|
||||
"backend": backend.label(), "rows": rows, "cols": cols
|
||||
})).await;
|
||||
broadcast_acl(&mut write, &session.room, &app).await;
|
||||
send_frame(&out_tx, &session.room, json!({
|
||||
"_sbx":"status","state":"ready","backend": backend.label(), "rows": rows, "cols": cols
|
||||
}));
|
||||
broadcast_acl(&out_tx, &session.room, &app);
|
||||
}
|
||||
Some(BrokerMsg::Failed) => { launching = false; }
|
||||
None => {}
|
||||
|
|
@ -433,8 +432,26 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
|||
}
|
||||
pty = pty_rx.recv() => {
|
||||
if let Some(bytes) = pty {
|
||||
send_frame(&mut write, &session.room,
|
||||
json!({"_sbx":"data","b64": STANDARD.encode(&bytes)})).await;
|
||||
send_frame(&out_tx, &session.room, json!({"_sbx":"data","b64": STANDARD.encode(&bytes)}));
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
_ = tick.tick() => {}
|
||||
|
|
@ -453,7 +470,121 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
|||
result
|
||||
}
|
||||
|
||||
/// Boot a sandbox off the UI thread (prepare → spawn PTY → hand back the handle).
|
||||
enum BrokerMsg {
|
||||
Ready { sb: sbx::Sandbox, backend: sbx::Backend, name: String, rows: u16, cols: u16 },
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_command(
|
||||
line: &str,
|
||||
app: &mut App,
|
||||
active_send: &mut Option<ActiveSend>,
|
||||
send_seq: &mut u64,
|
||||
broker: &mut Option<sbx::Sandbox>,
|
||||
broker_meta: &mut Option<(sbx::Backend, String)>,
|
||||
launching: &mut bool,
|
||||
announced_dims: &mut Option<(u16, u16)>,
|
||||
out_tx: &UnboundedSender<WsMsg>,
|
||||
pty_tx: &UnboundedSender<Vec<u8>>,
|
||||
broker_tx: &UnboundedSender<BrokerMsg>,
|
||||
app_tx: &UnboundedSender<Net>,
|
||||
session: &Session,
|
||||
term: &Terminal<CrosstermBackend<std::io::Stdout>>,
|
||||
) {
|
||||
let room = &session.room;
|
||||
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)));
|
||||
}
|
||||
Err(e) => app.sys(format!("send failed: {e}")),
|
||||
}
|
||||
} else if line == "/accept" {
|
||||
if let Some(o) = app.pending_offer.take() {
|
||||
send_frame(out_tx, room, json!({"_ft":"accept","id": o.id}));
|
||||
if let Some(t) = app.transfers.get_mut(&o.id) {
|
||||
t.accepted = true;
|
||||
}
|
||||
app.sys(format!("accepting {}…", o.name));
|
||||
} else {
|
||||
app.sys("no pending offer");
|
||||
}
|
||||
} else if line == "/reject" {
|
||||
if let Some(o) = app.pending_offer.take() {
|
||||
send_frame(out_tx, room, json!({"_ft":"reject","id": o.id}));
|
||||
app.transfers.remove(&o.id);
|
||||
app.sys("rejected the offer");
|
||||
} else {
|
||||
app.sys("no pending offer");
|
||||
}
|
||||
} else if let Some(rest) = line.strip_prefix("/sbx") {
|
||||
let mut p = rest.split_whitespace();
|
||||
match p.next() {
|
||||
Some("launch") => {
|
||||
if app.sandbox.is_some() || broker.is_some() || *launching {
|
||||
app.sys("a sandbox is already running");
|
||||
} else {
|
||||
let backend = p.next().and_then(sbx::Backend::parse).unwrap_or(sbx::Backend::Local);
|
||||
let image = p.next().map(str::to_string).unwrap_or_else(|| backend.default_image().to_string());
|
||||
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;
|
||||
app.sys(format!("summoning {} sandbox… (multipass boot can take ~30s)", backend.label()));
|
||||
spawn_launch(backend, image, rows, cols, pty_tx.clone(), broker_tx.clone(), app_tx.clone());
|
||||
}
|
||||
}
|
||||
Some("stop") => {
|
||||
if let Some(mut sb) = broker.take() {
|
||||
sb.stop();
|
||||
if let Some((be, name)) = broker_meta.take() {
|
||||
tokio::task::spawn_blocking(move || sbx::teardown(be, &name));
|
||||
}
|
||||
*announced_dims = None;
|
||||
send_frame(out_tx, room, json!({"_sbx":"status","state":"stopped"}));
|
||||
} else {
|
||||
app.sys("you are not hosting a sandbox");
|
||||
}
|
||||
}
|
||||
_ => app.sys("usage: /sbx launch [local|docker|multipass] [image] | /sbx stop"),
|
||||
}
|
||||
} else if let Some(rest) = line.strip_prefix("/grant") {
|
||||
let target = rest.trim();
|
||||
if !app.is_owner() {
|
||||
app.sys("only the sandbox owner can /grant");
|
||||
} else if target.is_empty() {
|
||||
app.sys("usage: /grant <user>");
|
||||
} else {
|
||||
app.drivers.insert(target.to_string());
|
||||
broadcast_acl(out_tx, room, app);
|
||||
app.sys(format!("granted drive to {target}"));
|
||||
}
|
||||
} else if let Some(rest) = line.strip_prefix("/revoke") {
|
||||
let target = rest.trim();
|
||||
if !app.is_owner() {
|
||||
app.sys("only the sandbox owner can /revoke");
|
||||
} else if target == app.me {
|
||||
app.sys("the owner cannot revoke themselves");
|
||||
} else if target.is_empty() {
|
||||
app.sys("usage: /revoke <user>");
|
||||
} else {
|
||||
app.drivers.remove(target);
|
||||
broadcast_acl(out_tx, room, app);
|
||||
app.sys(format!("revoked drive from {target}"));
|
||||
}
|
||||
} else if !line.is_empty() && app.connected {
|
||||
let _ = out_tx.send(WsMsg::Text(room.encrypt(line.as_bytes())));
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_launch(
|
||||
backend: sbx::Backend,
|
||||
image: String,
|
||||
|
|
|
|||
198
hh/src/ft.rs
Normal file
198
hh/src/ft.rs
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
//! File & directory transfer over the encrypted channel — wire-compatible with
|
||||
//! the Python client's `_ft` protocol (offer/accept/reject/chunk/done, 64 KB
|
||||
//! chunks, SHA-256 verified). Directories are streamed as a tar with `dir:true`
|
||||
//! and extracted on receipt (with a path-traversal guard); a Python receiver
|
||||
//! just saves the `.tar`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const MAX_SIZE: usize = 50 * 1024 * 1024;
|
||||
pub const CHUNK: usize = 64 * 1024;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Offer {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub size: u64,
|
||||
pub sha256: String,
|
||||
pub dir: bool,
|
||||
pub from: String,
|
||||
}
|
||||
|
||||
pub enum Ft {
|
||||
Offer(Offer),
|
||||
Accept(String),
|
||||
Reject(String),
|
||||
Chunk { id: String, data: Vec<u8> },
|
||||
Done(String),
|
||||
}
|
||||
|
||||
pub fn sha256_hex(data: &[u8]) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(data);
|
||||
hex::encode(h.finalize())
|
||||
}
|
||||
|
||||
pub fn human(size: usize) -> String {
|
||||
let (mut s, units) = (size as f64, ["B", "KB", "MB", "GB"]);
|
||||
for u in units {
|
||||
if s < 1024.0 {
|
||||
return format!("{s:.1} {u}");
|
||||
}
|
||||
s /= 1024.0;
|
||||
}
|
||||
format!("{s:.1} TB")
|
||||
}
|
||||
|
||||
/// Read the payload to offer for a path → (name, bytes, is_dir).
|
||||
pub fn read_payload(path: &str) -> Result<(String, Vec<u8>, bool)> {
|
||||
let p = Path::new(path);
|
||||
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()));
|
||||
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)");
|
||||
let bytes = std::fs::read(p)?;
|
||||
let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("file").to_string();
|
||||
Ok((name, bytes, false))
|
||||
}
|
||||
}
|
||||
|
||||
fn tar_dir(dir: &Path) -> Result<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut tb = tar::Builder::new(&mut buf);
|
||||
let base = dir.file_name().unwrap_or_default();
|
||||
tb.append_dir_all(base, dir).context("tar directory")?;
|
||||
tb.finish()?;
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Persist received bytes under `downloads`. Directories (tar) are extracted
|
||||
/// with a guard rejecting absolute paths and `..` escapes (zip-slip).
|
||||
pub fn save(downloads: &Path, offer: &Offer, data: &[u8]) -> Result<PathBuf> {
|
||||
std::fs::create_dir_all(downloads)?;
|
||||
if offer.dir {
|
||||
// Extract the tar's own top-level dir directly under downloads/.
|
||||
let mut ar = tar::Archive::new(data);
|
||||
let mut top: Option<std::ffi::OsString> = None;
|
||||
for entry in ar.entries()? {
|
||||
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());
|
||||
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()))
|
||||
} 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 dest = unique(downloads, stem, ext);
|
||||
std::fs::write(&dest, data)?;
|
||||
Ok(dest)
|
||||
}
|
||||
}
|
||||
|
||||
/// A tar entry path is safe to extract iff it's relative and has no `..` escape.
|
||||
fn safe_entry(path: &Path) -> bool {
|
||||
!path.is_absolute() && !path.components().any(|c| matches!(c, Component::ParentDir))
|
||||
}
|
||||
|
||||
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}")) }
|
||||
};
|
||||
(0..).map(mk).find(|p| !p.exists()).unwrap()
|
||||
}
|
||||
|
||||
/// Parse a decrypted `{"_ft":...}` frame. `sender` is the server-authenticated
|
||||
/// username of the offerer (so receivers can show who's sending).
|
||||
pub fn parse(text: &str, sender: &str) -> Option<Ft> {
|
||||
let v: Value = serde_json::from_str(text).ok()?;
|
||||
match v["_ft"].as_str()? {
|
||||
"offer" => Some(Ft::Offer(Offer {
|
||||
id: v["id"].as_str()?.to_string(),
|
||||
name: v["name"].as_str().unwrap_or("file").to_string(),
|
||||
size: v["size"].as_u64().unwrap_or(0),
|
||||
sha256: v["sha256"].as_str().unwrap_or("").to_string(),
|
||||
dir: v["dir"].as_bool().unwrap_or(false),
|
||||
from: sender.to_string(),
|
||||
})),
|
||||
"accept" => Some(Ft::Accept(v["id"].as_str()?.to_string())),
|
||||
"reject" => Some(Ft::Reject(v["id"].as_str()?.to_string())),
|
||||
"chunk" => Some(Ft::Chunk {
|
||||
id: v["id"].as_str()?.to_string(),
|
||||
data: STANDARD.decode(v["data"].as_str()?).ok()?,
|
||||
}),
|
||||
"done" => Some(Ft::Done(v["id"].as_str()?.to_string())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn file_payload_roundtrip() {
|
||||
let dir = std::env::temp_dir().join(format!("hh-ft-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let src = dir.join("note.txt");
|
||||
std::fs::write(&src, b"offering to the coven").unwrap();
|
||||
|
||||
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 dl = dir.join("dl");
|
||||
let out = save(&dl, &offer, &bytes).unwrap();
|
||||
assert_eq!(std::fs::read(&out).unwrap(), b"offering to the coven");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir_tar_roundtrip() {
|
||||
let dir = std::env::temp_dir().join(format!("hh-ftd-{}", std::process::id()));
|
||||
let proj = dir.join("proj");
|
||||
std::fs::create_dir_all(proj.join("sub")).unwrap();
|
||||
std::fs::write(proj.join("a.txt"), b"AAA").unwrap();
|
||||
std::fs::write(proj.join("sub/b.txt"), b"BBB").unwrap();
|
||||
|
||||
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 dl = dir.join("dl");
|
||||
let out = save(&dl, &offer, &bytes).unwrap(); // -> dl/proj
|
||||
assert!(out.ends_with("proj"));
|
||||
assert_eq!(std::fs::read(out.join("a.txt")).unwrap(), b"AAA");
|
||||
assert_eq!(std::fs::read(out.join("sub/b.txt")).unwrap(), b"BBB");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traversal_guard_blocks_escapes() {
|
||||
// The zip-slip guard: relative-only, no `..` escape, no absolute paths.
|
||||
assert!(!safe_entry(Path::new("../escape.txt")));
|
||||
assert!(!safe_entry(Path::new("a/../../etc/passwd")));
|
||||
assert!(!safe_entry(Path::new("/etc/passwd")));
|
||||
assert!(safe_entry(Path::new("proj/sub/ok.txt")));
|
||||
assert!(safe_entry(Path::new("file.txt")));
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
mod app;
|
||||
mod crypto;
|
||||
mod ft;
|
||||
mod net;
|
||||
mod sbx;
|
||||
mod theme;
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ enum Decoded {
|
|||
}
|
||||
|
||||
/// Decrypt + classify one stored/broadcast message object.
|
||||
fn decode_msg(room: &fernet::Fernet, m: &Value, allow_sbx: bool) -> Decoded {
|
||||
fn decode_msg(room: &fernet::Fernet, m: &Value, live: bool) -> Decoded {
|
||||
let ct = match m["text"].as_str() {
|
||||
Some(c) if !c.is_empty() => c,
|
||||
_ => return Decoded::Skip,
|
||||
|
|
@ -128,22 +128,26 @@ fn decode_msg(room: &fernet::Fernet, m: &Value, allow_sbx: bool) -> Decoded {
|
|||
let (text, system) = match room.decrypt(ct) {
|
||||
Ok(pt) => {
|
||||
let t = String::from_utf8_lossy(&pt).to_string();
|
||||
if t.starts_with("{\"_ft\":") {
|
||||
return Decoded::Skip; // file-transfer control frame (P5)
|
||||
}
|
||||
// Server-stamped (authenticated) sender of this message.
|
||||
let sender = m["username"].as_str().unwrap_or("?");
|
||||
if t.starts_with("{\"_perm\":") {
|
||||
return parse_perm(&t).map(Decoded::Sbx).unwrap_or(Decoded::Skip);
|
||||
}
|
||||
// Control frames are live-only — never replayed from the stored snapshot.
|
||||
if t.starts_with("{\"_sbx\":") {
|
||||
// Don't replay terminal history from the stored snapshot.
|
||||
return if allow_sbx {
|
||||
return if live {
|
||||
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)
|
||||
} else {
|
||||
Decoded::Skip
|
||||
};
|
||||
}
|
||||
(t, false)
|
||||
}
|
||||
Err(_) => ("[unreadable — wrong room password?]".to_string(), true),
|
||||
|
|
|
|||
13
hh/src/ui.rs
13
hh/src/ui.rs
|
|
@ -147,10 +147,17 @@ fn draw_input(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &The
|
|||
]))
|
||||
.block(
|
||||
Block::bordered()
|
||||
.border_style(Style::default().fg(theme.border))
|
||||
.border_style(Style::default().fg(if app.pending_offer.is_some() {
|
||||
theme.accent
|
||||
} else {
|
||||
theme.border
|
||||
}))
|
||||
.title(Span::styled(
|
||||
" message · enter send · esc quit ",
|
||||
Style::default().fg(theme.dim),
|
||||
match &app.pending_offer {
|
||||
Some(o) => format!(" ⛧ incoming: {} — /accept or /reject ", o.name),
|
||||
None => " message · enter send · esc quit ".to_string(),
|
||||
},
|
||||
Style::default().fg(theme.title),
|
||||
)),
|
||||
);
|
||||
f.render_widget(input, area);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user