灵符lingfu

ling-lang

↓ 0 downloads

Ling - The Omniglot Systems Language

Keywords systems, polyglot, ai, crypto, game
lingfu install ling-lang
LicenseApache-2.0 OR MIT
AuthorsLing Lin, Sanny Lin
READMECode / FilesVersionsDependency GraphDependentsSecurityBuildsSettings

Code / Files (995)

crates/ling-kernel/src/drivers/messenger.rs
//! The messenger embedder: LAN discovery, transport, identity persistence,
//! and the desktop UI for `ling-messenger` (its own package --
//! ../../../ling-messenger). All the actual cryptography (hybrid Ed25519 +
//! ML-DSA-87 identity, hybrid X25519 + ML-KEM-768 handshake, encrypted
//! framing) lives there and is host-tested; this module only decides *when*
//! to send, dispatches arrivals, and draws the sidebar/chat window. Same
//! division of labor as `drivers/browser.rs` and `drivers/horizon.rs`.
//!
//! Transport, LAN-only for v0.1:
//!   * **Discovery** rides UDP: a small beacon carries the short, shareable
//!     `LingId` fingerprint (+ handle) as a *presence* announcement (the full
//!     ~2.6KB post-quantum identity is far too big for a datagram).
//!   * **Handshake + chat** ride TCP (`MSG_TCP_PORT`), now that the netstack has
//!     server sockets and holds several connections at once. The hybrid
//!     handshake messages (1.2KB-6KB) and the identity exchange are length-framed
//!     over the stream. On connect the two sides swap full public identities
//!     **trust-on-first-use** (a beacon only proves presence, not identity), add
//!     each other to the roster, then run the mutual hybrid handshake -- so
//!     "add a nearby peer" and "start a chat" are the same action. Compare the
//!     `LingId` shown in the UI out of band to be sure a first contact is really
//!     them (the crate's documented first-contact caveat).
//!
//! Honest limit: chat only runs while the Messenger window is open on both
//! sides (the accept + receive happen in `tick`, called from `draw`); a
//! background listener that receives while away is future work. Verifying it
//! needs two LingOS machines on a LAN (it's LAN peer-to-peer, mouse-driven).

use crate::arch::timer;
use crate::drivers::netstack;
#[cfg(target_arch = "x86_64")]
use crate::drivers::{font8x8, framebuffer, mixer, theme};
use crate::fs::lingfs;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use ling_messenger::identity::PublicIdentity;
use ling_messenger::{handshake, wire, Identity, LingId, Roster, Session};

/// Fixed UDP port for discovery beacons and the handshake/chat traffic that
/// follows -- one well-known port, same spirit as `bring`'s fixed HTTP
/// assumptions.
const MSG_PORT: u16 = 41337;

// Discovery beacon tag (UDP). The handshake + chat moved to TCP, so the old
// PKT_HS_*/PKT_FRAME datagram tags are gone.
const PKT_BEACON: u8 = 0;

const BEACON_INTERVAL_MS: u64 = 2_000;
const NEARBY_TIMEOUT_MS: u64 = 10_000;

/// TCP port for the chat handshake + messages. Discovery beacons stay on the
/// UDP `MSG_PORT`; the handshake (hybrid keys ~1.2KB, ciphertext + PQ signature
/// up to ~6KB) and identity exchange (~2.6KB) are far too big for one UDP
/// datagram, so they ride TCP -- now that the netstack has server sockets and
/// can hold several connections at once (an SSH session, a browser fetch, and a
/// chat all on their own sockets).
const MSG_TCP_PORT: u16 = 41338;

/// Presence learned from a beacon: we know a `LingId` is reachable at an IP,
/// but NOT its full public identity (too big to broadcast -- see module doc).
struct NearbyPeer {
    ip: [u8; 4],
    ling_id: LingId,
    handle: String,
    last_seen_ms: u64,
}

struct Conversation {
    peer: LingId,
    handle: usize,     // TCP socket for this chat
    rx: Vec<u8>,       // stream-reassembly buffer for length-framed messages
    session: Session,
    lines: Vec<(bool, String)>, // (from_me, text)
}

static mut IDENTITY: Option<Identity> = None;
static mut ROSTER: Roster = Roster::new();
static mut ROSTER_LOADED: bool = false;
static mut NEARBY: Vec<NearbyPeer> = Vec::new();
static mut CONVERSATIONS: Vec<Conversation> = Vec::new();
static mut LAST_BEACON_MS: u64 = 0;
// The background listener owns all chat network I/O + CONVERSATIONS mutation.
// The UI thread never touches sockets or CONVERSATIONS structurally -- it only
// queues work here (drained by the task) and reads lines for display. This keeps
// the two cooperative tasks from ever holding a `&mut` into the same state
// across a network yield (the only points a context switch can happen).
static mut OUTBOX: Vec<(LingId, String)> = Vec::new(); // (peer, text) to send
static mut CONNECT_REQ: Vec<(LingId, [u8; 4])> = Vec::new(); // (peer, ip) to dial
static mut PING_OUTBOX: Vec<LingId> = Vec::new(); // peers to nudge with a /ping
static mut LAST_PING_MS: u64 = 0; // sender-side rate limit for /ping
const PING_COOLDOWN_MS: u64 = 60_000; // at most one ping per minute
static mut NICK_OUTBOX: Vec<String> = Vec::new(); // new handle to broadcast to all peers

// Encrypted chat history, unlocked at login. CHAT_KEY is derived from the
// account password (see `set_chat_key`, called from `ling_kernel_user_login`);
// while it's None, persistence is simply off. CHAT_HISTORY mirrors every
// conversation's lines so they survive a reboot, encrypted at rest.
static mut CHAT_KEY: Option<[u8; 32]> = None;
static mut CHAT_HISTORY: Vec<(LingId, Vec<(bool, String)>)> = Vec::new();
/// Lines that arrived while no one was signed in: sealed into the history
/// the moment the keyring opens ("the messages while we were gone").
static mut PENDING: Vec<(LingId, bool, String)> = Vec::new();
/// The pre-keyring log at the lingfs root (sealed under SHA-256 of the
/// password): imported into the per-user log once, then removed.
const LEGACY_CHATLOG_FILE: &str = "chatlog.enc";

/// The signed-in user's log, under the keyring.
fn chatlog_path() -> Option<String> {
    crate::keyring::user().map(|u| alloc::format!("home/{u}/.messenger/chatlog.enc"))
}
static mut MSG_TASK_SPAWNED: bool = false;
static mut STATUS: &'static str = "arrows select, Enter chats/adds a friend; type + Enter with nothing selected sets your handle";

// -- UI state (mirrors browser.rs/horizon.rs's WEB_*/HZ_* pattern) ---------
static mut SELECTED: Option<usize> = None; // index into a merged nearby+friends list, see selectable()
const COMPOSE_MAX: usize = 400;
static mut COMPOSE: [u8; COMPOSE_MAX] = [0; COMPOSE_MAX];
static mut COMPOSE_LEN: usize = 0;

fn identity() -> &'static Identity {
    unsafe {
        let slot = &mut *&raw mut IDENTITY;
        if slot.is_none() {
            *slot = Some(load_or_create_identity());
        }
        slot.as_ref().unwrap()
    }
}

fn load_or_create_identity() -> Identity {
    let mut buf = [0u8; 32];
    if let Ok(Some(n)) = lingfs::read_file_all("messenger_identity", &mut buf) {
        if n == 32 {
            return Identity::from_seed(buf);
        }
    }
    let mut seed = [0u8; 32];
    // RDRAND -- the same real CSPRNG `crypto.rs` already uses for TLS's
    // ephemeral X25519 secrets (see its module doc). If the CPU somehow lacks
    // RDRAND, fall back to whatever `random_bytes` leaves in `seed` (zeroed)
    // rather than panicking the desktop -- a degraded identity is still better
    // than the window failing to open.
    crate::crypto::random_bytes(&mut seed);
    let _ = lingfs::write_file("messenger_identity", &seed);
    Identity::from_seed(seed)
}

fn roster() -> &'static mut Roster {
    unsafe {
        if !ROSTER_LOADED {
            ROSTER_LOADED = true;
            load_roster();
        }
        &mut *&raw mut ROSTER
    }
}

// -- Serialization ---------------------------------------------------------

/// A hybrid public identity on the wire / on disk: `ed25519_pk (32B) ‖
/// mldsa87_encoded_vk`. `PublicIdentity`'s fields are public and it carries
/// the ML-DSA key as raw encoded bytes, so this is a straight concatenation;
/// `verify` re-parses/validates the ML-DSA half, so a wrong length simply
/// fails to authenticate later rather than needing a check here.
fn pub_id_to_bytes(pi: &PublicIdentity) -> Vec<u8> {
    let mut v = Vec::with_capacity(32 + pi.mldsa87.len());
    v.extend_from_slice(pi.ed25519.as_bytes());
    v.extend_from_slice(&pi.mldsa87);
    v
}

fn pub_id_from_bytes(b: &[u8]) -> Option<PublicIdentity> {
    if b.len() <= 32 {
        return None;
    }
    let mut ed = [0u8; 32];
    ed.copy_from_slice(&b[..32]);
    let ed_vk = ed25519_dalek::VerifyingKey::from_bytes(&ed).ok()?;
    Some(PublicIdentity { ed25519: ed_vk, mldsa87: b[32..].to_vec() })
}

/// One friend per line: `hex(pub_identity)<TAB>handle`. A hybrid identity is
/// large (~2.6KB -> ~5.2KB hex), so the read buffer is sized generously.
fn load_roster() {
    let mut buf = alloc::vec![0u8; 128 * 1024];
    let Ok(Some(n)) = lingfs::read_file_all("messenger_friends", &mut buf) else { return };
    let Ok(text) = core::str::from_utf8(&buf[..n]) else { return };
    for line in text.lines() {
        let Some((hex, handle)) = line.split_once('\t') else { continue };
        let Some(bytes) = hex_decode(hex) else { continue };
        let Some(pi) = pub_id_from_bytes(&bytes) else { continue };
        unsafe { (&mut *&raw mut ROSTER).add(handle.to_string(), pi) };
    }
}

fn save_roster() {
    let mut out = String::new();
    for f in &roster().friends {
        out.push_str(&hex_encode(&pub_id_to_bytes(&f.public_identity)));
        out.push('\t');
        out.push_str(&f.handle);
        out.push('\n');
    }
    // Multi-block: each friend's hybrid PQ identity (Ed25519 + ML-DSA-87 vk,
    // hex-encoded) is ~5KB, well over the single-block write_file cap -- so
    // write_file here silently dropped the whole roster.
    let _ = lingfs::write_file_any("messenger_friends", out.as_bytes());
}

// -- Encrypted chat history -------------------------------------------------

/// Unlock chat persistence with a key derived from the account password.
/// Called from `ling_kernel_user_login` right after a successful sign-in, so
/// history is loaded once you've proven who you are -- and is unreadable
/// (ChaCha20-Poly1305) to anyone who only has the disk. Idempotent.
pub fn unlock_for_session(password: &[u8]) {
    let Some(key) = crate::keyring::key("messenger-chat") else { return };
    unsafe {
        CHAT_KEY = Some(key);
        (*(&raw mut CHAT_HISTORY)).clear();
    }
    load_history();
    import_legacy(password);
    // what came in while the screen was locked / before sign-in
    let pending = unsafe { core::mem::take(&mut *(&raw mut PENDING)) };
    let had = !pending.is_empty();
    for (peer, from_me, text) in pending {
        push_history(&peer, from_me, &text);
    }
    if had {
        save_history();
    }
    let n: usize = unsafe { (*(&raw const CHAT_HISTORY)).iter().map(|(_, l)| l.len()).sum() };
    crate::console_write(b"messenger: history unlocked (");
    crate::print_decimal(n as u64);
    crate::console_write(b" lines under the keyring)\n");
}

/// Screen lock / log out: forget the key and the decrypted history.
pub fn lock_session() {
    unsafe {
        CHAT_KEY = None;
        (*(&raw mut CHAT_HISTORY)).clear();
    }
}

/// The old root-level log, opened with the old password-only key and folded
/// into the per-user log; then removed from the disk.
fn import_legacy(password: &[u8]) {
    let mut raw = alloc::vec![0u8; 256 * 1024];
    let n = match lingfs::read_file_all(LEGACY_CHATLOG_FILE, &mut raw) {
        Ok(Some(n)) if n >= 28 => n,
        _ => return,
    };
    let mut ikm = Vec::with_capacity(12 + password.len());
    ikm.extend_from_slice(b"lingchat-v1:");
    ikm.extend_from_slice(password);
    let old_key = crate::crypto::sha256(&ikm);
    let nonce: [u8; 12] = raw[0..12].try_into().unwrap();
    let tag: [u8; 16] = raw[12..28].try_into().unwrap();
    let mut buf = raw[28..n].to_vec();
    if !crate::crypto::chachapoly_open(&old_key, &nonce, &[], &mut buf, &tag) {
        return; // another user's, or the Pi's serial-keyed log: leave it
    }
    for (peer, lines) in parse_history(&buf) {
        for (from_me, text) in lines {
            push_history(&peer, from_me, &text);
        }
    }
    save_history();
    let _ = lingfs::remove_path(LEGACY_CHATLOG_FILE);
    crate::console_write(b"messenger: older chat log moved under the keyring\n");
}

fn push_history(peer: &LingId, from_me: bool, text: &str) {
    unsafe {
        let hist = &mut *&raw mut CHAT_HISTORY;
        match hist.iter_mut().find(|(l, _)| l == peer) {
            Some(e) => e.1.push((from_me, text.to_string())),
            None => hist.push((peer.clone(), alloc::vec![(from_me, text.to_string())])),
        }
    }
}

fn serialize_history() -> Vec<u8> {
    let mut out = Vec::new();
    for (lid, lines) in unsafe { (&*&raw const CHAT_HISTORY).iter() } {
        let idb = lid.0.as_bytes();
        let idl = idb.len().min(255);
        out.push(idl as u8);
        out.extend_from_slice(&idb[..idl]);
        out.extend_from_slice(&(lines.len().min(u16::MAX as usize) as u16).to_be_bytes());
        for (from_me, text) in lines.iter().take(u16::MAX as usize) {
            out.push(*from_me as u8);
            let tb = text.as_bytes();
            let tl = tb.len().min(u16::MAX as usize);
            out.extend_from_slice(&(tl as u16).to_be_bytes());
            out.extend_from_slice(&tb[..tl]);
        }
    }
    out
}

fn save_history() {
    let Some(key) = (unsafe { CHAT_KEY }) else { return };
    let mut buf = serialize_history();
    let mut nonce = [0u8; 12];
    crate::crypto::random_bytes(&mut nonce);
    let Some(tag) = crate::crypto::chachapoly_seal(&key, &nonce, &[], &mut buf) else { return };
    let mut file = Vec::with_capacity(28 + buf.len());
    file.extend_from_slice(&nonce);
    file.extend_from_slice(&tag);
    file.extend_from_slice(&buf);
    let Some(path) = chatlog_path() else { return };
    let _ = lingfs::write_path_any(&path, &file);
}

fn load_history() {
    let Some(key) = (unsafe { CHAT_KEY }) else { return };
    let Some(path) = chatlog_path() else { return };
    let mut raw = alloc::vec![0u8; 256 * 1024];
    let n = match lingfs::read_file_all(&path, &mut raw) {
        Ok(Some(n)) if n >= 28 => n,
        _ => return,
    };
    let nonce: [u8; 12] = raw[0..12].try_into().unwrap();
    let tag: [u8; 16] = raw[12..28].try_into().unwrap();
    let mut buf = raw[28..n].to_vec();
    if !crate::crypto::chachapoly_open(&key, &nonce, &[], &mut buf, &tag) {
        return; // wrong password or tampered -- leave history empty
    }
    unsafe { CHAT_HISTORY = parse_history(&buf) };
}

fn parse_history(b: &[u8]) -> Vec<(LingId, Vec<(bool, String)>)> {
    let mut out = Vec::new();
    let mut i = 0usize;
    while i < b.len() {
        let idlen = b[i] as usize;
        i += 1;
        if i + idlen + 2 > b.len() {
            break;
        }
        let lid = LingId(core::str::from_utf8(&b[i..i + idlen]).unwrap_or("").to_string());
        i += idlen;
        let nlines = u16::from_be_bytes([b[i], b[i + 1]]) as usize;
        i += 2;
        let mut lines = Vec::with_capacity(nlines);
        for _ in 0..nlines {
            if i + 3 > b.len() {
                break;
            }
            let from_me = b[i] != 0;
            let tl = u16::from_be_bytes([b[i + 1], b[i + 2]]) as usize;
            i += 3;
            if i + tl > b.len() {
                break;
            }
            lines.push((from_me, core::str::from_utf8(&b[i..i + tl]).unwrap_or("").to_string()));
            i += tl;
        }
        out.push((lid, lines));
    }
    out
}

/// Append one line to a peer's persisted history and re-save (encrypted).
fn remember(peer: &LingId, from_me: bool, text: &str) {
    if unsafe { CHAT_KEY.is_none() } {
        // nobody signed in: keep it for the moment the keyring opens
        unsafe { (*(&raw mut PENDING)).push((peer.clone(), from_me, text.to_string())) };
        return;
    }
    push_history(peer, from_me, text);
    save_history();
}

/// A peer's saved messages, for prefilling a freshly opened conversation.
fn history_for(peer: &LingId) -> Vec<(bool, String)> {
    unsafe {
        (&*&raw const CHAT_HISTORY)
            .iter()
            .find(|(l, _)| l == peer)
            .map(|(_, lines)| lines.clone())
            .unwrap_or_default()
    }
}

fn hex_encode(bytes: &[u8]) -> String {
    let hex = b"0123456789abcdef";
    let mut s = String::with_capacity(bytes.len() * 2);
    for &b in bytes {
        s.push(hex[(b >> 4) as usize] as char);
        s.push(hex[(b & 0xF) as usize] as char);
    }
    s
}

fn hex_decode(s: &str) -> Option<Vec<u8>> {
    let b = s.as_bytes();
    if b.len() % 2 != 0 {
        return None;
    }
    let nibble = |c: u8| -> Option<u8> {
        match c {
            b'0'..=b'9' => Some(c - b'0'),
            b'a'..=b'f' => Some(c - b'a' + 10),
            b'A'..=b'F' => Some(c - b'A' + 10),
            _ => None,
        }
    };
    let mut out = Vec::with_capacity(b.len() / 2);
    let mut i = 0;
    while i < b.len() {
        out.push((nibble(b[i])? << 4) | nibble(b[i + 1])?);
        i += 2;
    }
    Some(out)
}

pub fn status() -> &'static str {
    unsafe { STATUS }
}

/// `N` bytes of fresh RDRAND randomness, or `None` if the CPU lacks RDRAND.
/// Callers must NOT proceed with a zeroed/predictable seed in that case --
/// that would silently destroy the forward secrecy the handshake exists to
/// provide, not just degrade gracefully.
fn fresh_bytes<const N: usize>() -> Option<[u8; N]> {
    let mut s = [0u8; N];
    if crate::crypto::random_bytes(&mut s) {
        Some(s)
    } else {
        None
    }
}

pub fn my_ling_id() -> LingId {
    identity().ling_id()
}

// -- Handshake wire framing (u16-length-prefixed for the variable fields) ---

fn put_lp(out: &mut Vec<u8>, field: &[u8]) {
    out.extend_from_slice(&(field.len() as u16).to_be_bytes());
    out.extend_from_slice(field);
}

fn take_lp<'a>(body: &'a [u8], off: &mut usize) -> Option<&'a [u8]> {
    if *off + 2 > body.len() {
        return None;
    }
    let len = u16::from_be_bytes([body[*off], body[*off + 1]]) as usize;
    *off += 2;
    if *off + len > body.len() {
        return None;
    }
    let s = &body[*off..*off + len];
    *off += len;
    Some(s)
}

fn take_nonce(body: &[u8], off: &mut usize) -> Option<[u8; handshake::NONCE_LEN]> {
    if *off + handshake::NONCE_LEN > body.len() {
        return None;
    }
    let mut n = [0u8; handshake::NONCE_LEN];
    n.copy_from_slice(&body[*off..*off + handshake::NONCE_LEN]);
    *off += handshake::NONCE_LEN;
    Some(n)
}

// -- Discovery + dispatch (call every frame the window is open) ------------

/// Non-blocking: send a beacon if due, process at most one incoming datagram,
/// prune stale `NEARBY` entries. Safe to call every frame.
/// UI-side per-frame work (called from `draw`): make sure the background
/// listener is running, receive discovery beacons into `NEARBY` for the peer
/// list, and prune stale peers. All chat sockets + `CONVERSATIONS` are handled
/// by the background task; this only reads beacons and never touches a chat
/// socket, so the two tasks don't collide.
pub fn tick() {
    ensure_task();
    let now = timer::now_ms();
    // The background task is the SOLE consumer of the NIC receive queue; its
    // pump captures discovery beacons into the netstack UDP inbox. The UI just
    // reads that inbox (no NIC access -> no race with the task that was
    // dropping frames) and prunes stale peers.
    let mut buf = [0u8; 512];
    if let Some((src_ip, n)) = netstack::udp_recv_beacon(&mut buf) {
        handle_packet(src_ip, &buf[..n]);
    }
    unsafe {
        (&mut *&raw mut NEARBY).retain(|p| now.wrapping_sub(p.last_seen_ms) < NEARBY_TIMEOUT_MS);
    }
}

/// The background chat listener: a cooperative task (spawned once by `tick`)
/// that owns everything network for chat -- beaconing presence, accepting
/// inbound connections, dialing outbound ones the UI queued, sending queued
/// messages, and receiving frames -- so chats arrive even when the Messenger
/// window is closed. Discipline: network calls (which yield) run on LOCAL data;
/// every `CONVERSATIONS`/queue mutation is a non-yielding burst, so no `&mut`
/// into shared state is ever live across a context switch.
extern "C" fn messenger_task() {
    loop {
        task_step();
        crate::proc::sched::yield_now();
    }
}

/// Chat lines (and pings) received since the Messenger window last drew:
/// the desktop's dock badge.
static mut UNREAD: u32 = 0;

pub fn unread() -> u32 {
    unsafe { UNREAD }
}

pub fn clear_unread() {
    unsafe { UNREAD = 0 };
}

/// One round of the chat listener's work (what the background task loops on).
fn task_step() {
    {
        let now = timer::now_ms();
        if now.wrapping_sub(unsafe { LAST_BEACON_MS }) >= BEACON_INTERVAL_MS {
            send_beacon();
            unsafe { LAST_BEACON_MS = now };
        }
        // Capture a discovery beacon the pump routed into the inbox, so NEARBY
        // stays fresh even with the Messenger window closed.
        {
            let mut bbuf = [0u8; 512];
            if let Some((src_ip, n)) = netstack::udp_recv_beacon(&mut bbuf) {
                handle_packet(src_ip, &bbuf[..n]);
            }
        }
        // Accept one inbound connection (brief window; its own socket).
        if let Some(h) = netstack::tcp_listen_accept(MSG_TCP_PORT, 50_000) {
            responder_handshake(h);
        }
        // Dial any connections the UI queued.
        let reqs = unsafe { core::mem::take(&mut *&raw mut CONNECT_REQ) };
        for (lid, ip) in reqs {
            connect_and_chat(lid, ip);
        }
        // Send any messages the UI queued.
        let out = unsafe { core::mem::take(&mut *&raw mut OUTBOX) };
        for (lid, text) in out {
            send_to(&lid, &text);
        }
        // Send any pings the UI queued.
        let pings = unsafe { core::mem::take(&mut *&raw mut PING_OUTBOX) };
        for lid in pings {
            send_ping_to(&lid);
        }
        // Broadcast any nick changes the UI queued (to every open peer).
        let nicks = unsafe { core::mem::take(&mut *&raw mut NICK_OUTBOX) };
        for new in nicks {
            broadcast_nick(&new);
        }
        // Receive frames on open conversations.
        recv_all_conversations();
    }
}

/// Spawn the background chat listener once (idempotent). Called both at boot
/// (so LingOS is reachable for chat without opening the window) and from the
/// first Messenger draw (belt and suspenders).
pub fn start() {
    unsafe {
        if MSG_TASK_SPAWNED {
            return;
        }
        MSG_TASK_SPAWNED = true;
    }
    // Capture discovery beacons via the netstack UDP inbox (filled by the RX
    // pump), so the desktop's draw never has to poll the NIC itself.
    netstack::udp_listen(MSG_PORT);
    crate::console_write(b"messenger: background listener online (chat port 41338)\n");
    crate::proc::sched::spawn(messenger_task as *const () as usize as u64);
    // The history stays sealed until someone signs in (the keyring opens it);
    // lines that arrive before that wait in memory.
}

fn ensure_task() {
    start();
}

/// Receive at most one frame per open conversation. Takes the socket handle +
/// reassembly buffer out (non-yield), reads off the wire (yields on locals),
/// then re-attaches + decrypts + appends (non-yield) -- so the UI can read
/// `lines` safely during the network wait.
fn recv_all_conversations() {
    let n = unsafe { (&*&raw const CONVERSATIONS).len() };
    for i in 0..n {
        let (handle, mut rx) = unsafe {
            let convos = &mut *&raw mut CONVERSATIONS;
            if i >= convos.len() {
                return;
            }
            (convos[i].handle, core::mem::take(&mut convos[i].rx))
        };
        let frame = recv_framed(handle, &mut rx, 10_000);
        // A line to persist, captured under the CONVERSATIONS borrow and
        // written out after it's released (remember() touches CHAT_HISTORY +
        // the disk, not CONVERSATIONS).
        let mut remembered: Option<(LingId, bool, String)> = None;
        // A peer rename to apply after the borrow (rename_peer touches ROSTER +
        // the disk, not CONVERSATIONS).
        let mut renamed: Option<(LingId, String)> = None;
        unsafe {
            let convos = &mut *&raw mut CONVERSATIONS;
            if i < convos.len() && convos[i].handle == handle {
                convos[i].rx = rx;
                if let Some(f) = frame {
                    if let Some((kind, msg)) = wire::open(&mut convos[i].session, &f) {
                        let peer = convos[i].peer.clone();
                        match kind {
                            wire::KIND_TEXT => {
                                if let Ok(text) = core::str::from_utf8(&msg) {
                                    convos[i].lines.push((false, text.to_string()));
                                    remembered = Some((peer, false, text.to_string()));
                                    UNREAD = UNREAD.saturating_add(1);
                                }
                            },
                            wire::KIND_PING => {
                                // Attention nudge: note it in the log and chime,
                                // even if the window is closed (the task runs in
                                // the background).
                                convos[i].lines.push((false, "*ping*".to_string()));
                                UNREAD = UNREAD.saturating_add(1);
                                #[cfg(target_arch = "x86_64")]
                                mixer::jingle(mixer::EVENT_PING);
                                remembered = Some((peer, false, "*ping*".to_string()));
                            },
                            wire::KIND_NICK => {
                                // Peer renamed themselves. Re-label them in the
                                // roster (keyed by this session's stable LingId,
                                // so messages keep routing) + note it in-stream.
                                if let Ok(new) = core::str::from_utf8(&msg) {
                                    let new = new.trim();
                                    if !new.is_empty() {
                                        let mut note = String::from("* now known as ");
                                        note.push_str(new);
                                        note.push_str(" *");
                                        convos[i].lines.push((false, note));
                                        renamed = Some((peer, new.to_string()));
                                    }
                                }
                            },
                            _ => {},
                        }
                    }
                }
            }
        }
        if let Some((peer, from_me, text)) = remembered {
            remember(&peer, from_me, &text);
        }
        if let Some((peer, new)) = renamed {
            rename_peer(&peer, &new);
        }
    }
}

/// Seal + send one queued message to a peer, appending the local echo on
/// success. Seals under a non-yield burst, sends on the local frame (yields),
/// then appends non-yield.
fn send_to(lid: &LingId, text: &str) {
    let sealed = unsafe {
        let convos = &mut *&raw mut CONVERSATIONS;
        convos
            .iter_mut()
            .find(|c| &c.peer == lid)
            .map(|c| (c.handle, wire::seal(&mut c.session, wire::KIND_TEXT, text.as_bytes())))
    };
    let Some((handle, frame)) = sealed else { return };
    let ok = send_framed(handle, &frame);
    if ok {
        unsafe {
            let convos = &mut *&raw mut CONVERSATIONS;
            if let Some(c) = convos.iter_mut().find(|c| &c.peer == lid) {
                c.lines.push((true, text.to_string()));
            }
        }
        remember(lid, true, text);
    }
}

/// Presence beacon: `PKT_BEACON, ling_id_len (1B), ling_id, handle`. Small on
/// purpose -- the full hybrid identity is too big to broadcast (module doc).
fn send_beacon() {
    let lid = my_ling_id().0;
    let handle = local_handle();
    let mut payload = Vec::with_capacity(2 + lid.len() + handle.len());
    payload.push(PKT_BEACON);
    payload.push(lid.len().min(255) as u8);
    payload.extend_from_slice(lid.as_bytes());
    payload.extend_from_slice(handle.as_bytes());
    netstack::udp_broadcast(MSG_PORT, MSG_PORT, &payload);
}

fn local_handle() -> String {
    // A custom handle, if one was ever set (see `set_local_handle`).
    let mut buf = [0u8; 64];
    if let Ok(Some(n)) = lingfs::read_file_all("messenger_handle", &mut buf) {
        if let Ok(s) = core::str::from_utf8(&buf[..n]) {
            if !s.is_empty() {
                return s.to_string();
            }
        }
    }
    // Otherwise default to the logged-in account name -- so signing in as
    // "Sanny" shows you to peers as "Sanny" with no extra setup. Falls back to
    // the short LingId fingerprint only if there's somehow no session name.
    let user = lingfs::current_user();
    if !user.is_empty() {
        return user.to_string();
    }
    identity().ling_id().0
}

/// Set and persist a custom display handle (shown to nearby peers in
/// discovery beacons instead of the raw LingId).
pub fn set_local_handle(handle: &str) {
    let _ = lingfs::write_file("messenger_handle", handle.as_bytes());
}

fn handle_packet(src_ip: [u8; 4], pkt: &[u8]) {
    if pkt.is_empty() {
        return;
    }
    // Only discovery beacons ride UDP now; the handshake + chat are over TCP.
    if pkt[0] == PKT_BEACON {
        handle_beacon(src_ip, &pkt[1..]);
    }
}

fn handle_beacon(src_ip: [u8; 4], body: &[u8]) {
    if body.is_empty() {
        return;
    }
    let idlen = body[0] as usize;
    if body.len() < 1 + idlen {
        return;
    }
    let Ok(lid_s) = core::str::from_utf8(&body[1..1 + idlen]) else { return };
    let ling_id = LingId(lid_s.to_string());
    let handle = core::str::from_utf8(&body[1 + idlen..]).unwrap_or("?").to_string();
    let now = timer::now_ms();
    unsafe {
        // If this peer is already a friend and just advertised a new nick, keep
        // their in-memory roster label current so the sidebar/chat show it even
        // before any KIND_NICK frame (persisted when that frame arrives). This
        // also covers a peer who renamed while offline and has now reconnected.
        let roster_mut = &mut *&raw mut ROSTER;
        if let Some(f) = roster_mut.friends.iter_mut().find(|f| f.ling_id == ling_id) {
            if f.handle != handle {
                f.handle = handle.clone();
            }
        }
        let nearby = &mut *&raw mut NEARBY;
        if let Some(p) = nearby.iter_mut().find(|p| p.ling_id == ling_id) {
            p.ip = src_ip;
            p.handle = handle;
            p.last_seen_ms = now;
        } else {
            nearby.push(NearbyPeer { ip: src_ip, ling_id, handle, last_seen_ms: now });
        }
    }
}

// -- TCP chat transport (handshake + framed messages) -------------------------

/// Send `data` as one length-framed message (u32 big-endian length + bytes),
/// segmented to fit the netstack's single-segment write.
fn send_framed(handle: usize, data: &[u8]) -> bool {
    let mut buf = Vec::with_capacity(4 + data.len());
    buf.extend_from_slice(&(data.len() as u32).to_be_bytes());
    buf.extend_from_slice(data);
    let mut off = 0usize;
    while off < buf.len() {
        let end = (off + 1400).min(buf.len());
        if !netstack::tcp_write_h(handle, &buf[off..end]) {
            return false;
        }
        off = end;
    }
    true
}

/// Read one length-framed message, accumulating stream bytes in `rx` across
/// calls. None if the peer is silent this budget (a partial message stays in
/// `rx` for the next call, so this is safe to poll incrementally).
fn recv_framed(handle: usize, rx: &mut Vec<u8>, budget_us: u64) -> Option<Vec<u8>> {
    while rx.len() < 4 {
        let mut tmp = [0u8; 2048];
        let n = netstack::tcp_read_h(handle, &mut tmp, budget_us);
        if n == 0 {
            return None;
        }
        rx.extend_from_slice(&tmp[..n]);
    }
    let len = u32::from_be_bytes([rx[0], rx[1], rx[2], rx[3]]) as usize;
    if len > 1 << 20 {
        return None;
    }
    while rx.len() < 4 + len {
        let mut tmp = [0u8; 2048];
        let n = netstack::tcp_read_h(handle, &mut tmp, budget_us);
        if n == 0 {
            return None;
        }
        rx.extend_from_slice(&tmp[..n]);
    }
    let msg = rx[4..4 + len].to_vec();
    rx.drain(0..4 + len);
    Some(msg)
}

/// Trust-on-first-use: add a peer's full public identity to the roster if new,
/// so future sessions authenticate against it. The `LingId` shown in the UI is
/// what the user compares out of band to confirm it's really them -- the same
/// first-contact caveat the crate documents.
fn roster_add_tofu(peer: &PublicIdentity) {
    if roster().find(peer).is_some() {
        return;
    }
    let lid = LingId::from_public(peer);
    let handle = unsafe {
        (&*&raw const NEARBY)
            .iter()
            .find(|p| p.ling_id == lid)
            .map(|p| p.handle.clone())
    }
    .unwrap_or_else(|| lid.0.clone());
    roster().add(handle, peer.clone());
    save_roster();
}

/// Responder half of a chat handshake over an accepted TCP connection: exchange
/// identities (TOFU), run the hybrid handshake, verify the initiator's finish,
/// then keep the socket for the conversation.
fn responder_handshake(handle: usize) {
    let mut rx = Vec::new();
    let Some(peer_bytes) = recv_framed(handle, &mut rx, 3_000_000) else {
        netstack::tcp_close_h(handle);
        return;
    };
    let Some(peer_id) = pub_id_from_bytes(&peer_bytes) else {
        netstack::tcp_close_h(handle);
        return;
    };
    if !send_framed(handle, &pub_id_to_bytes(&identity().public())) {
        netstack::tcp_close_h(handle);
        return;
    }
    roster_add_tofu(&peer_id);
    let Some(initmsg) = recv_framed(handle, &mut rx, 3_000_000) else {
        netstack::tcp_close_h(handle);
        return;
    };
    let mut off = 0usize;
    let (Some(hybrid_pub), Some(nonce)) = (
        take_lp(&initmsg, &mut off).map(|s| s.to_vec()),
        take_nonce(&initmsg, &mut off),
    ) else {
        netstack::tcp_close_h(handle);
        return;
    };
    let init = handshake::HandshakeInit { hybrid_pub, nonce };
    let (Some(eph), Some(rnonce)) =
        (fresh_bytes::<64>(), fresh_bytes::<{ handshake::NONCE_LEN }>())
    else {
        netstack::tcp_close_h(handle);
        return;
    };
    let Some((resp, session)) = handshake::respond(identity(), &init, eph, rnonce) else {
        netstack::tcp_close_h(handle);
        return;
    };
    let mut respmsg = Vec::new();
    put_lp(&mut respmsg, &resp.hybrid_ct);
    respmsg.extend_from_slice(&resp.nonce);
    put_lp(&mut respmsg, &resp.sig);
    if !send_framed(handle, &respmsg) {
        netstack::tcp_close_h(handle);
        return;
    }
    let Some(finmsg) = recv_framed(handle, &mut rx, 3_000_000) else {
        netstack::tcp_close_h(handle);
        return;
    };
    let mut fo = 0usize;
    let Some(sig) = take_lp(&finmsg, &mut fo).map(|s| s.to_vec()) else {
        netstack::tcp_close_h(handle);
        return;
    };
    let finish = handshake::HandshakeFinish { sig };
    if !handshake::finish_responder(&peer_id, &init, &resp, &finish) {
        unsafe { STATUS = "a chat handshake failed authentication -- rejected" };
        netstack::tcp_close_h(handle);
        return;
    }
    let peer_lid = LingId::from_public(&peer_id);
    open_conversation(peer_lid, handle, session, rx);
    unsafe { STATUS = "friend connected" };
}

/// Initiator half: dial the peer, exchange identities (TOFU), run the hybrid
/// handshake, and keep the socket for the conversation.
fn connect_and_chat(peer_lid: LingId, ip: [u8; 4]) {
    if unsafe { (&*&raw const CONVERSATIONS).iter().any(|c| c.peer == peer_lid) } {
        return; // already connected
    }
    unsafe { STATUS = "connecting..." };
    let Some(handle) = netstack::tcp_connect_h(ip, MSG_TCP_PORT, 3_000_000) else {
        unsafe { STATUS = "couldn't reach that peer -- is Messenger open on their side?" };
        return;
    };
    let mut rx = Vec::new();
    if !send_framed(handle, &pub_id_to_bytes(&identity().public())) {
        netstack::tcp_close_h(handle);
        return;
    }
    let Some(peer_bytes) = recv_framed(handle, &mut rx, 3_000_000) else {
        netstack::tcp_close_h(handle);
        unsafe { STATUS = "no reply from peer" };
        return;
    };
    let Some(peer_id) = pub_id_from_bytes(&peer_bytes) else {
        netstack::tcp_close_h(handle);
        return;
    };
    roster_add_tofu(&peer_id);
    let (Some(eph), Some(nonce)) = (fresh_bytes::<96>(), fresh_bytes::<{ handshake::NONCE_LEN }>())
    else {
        netstack::tcp_close_h(handle);
        return;
    };
    let (kp, init) = handshake::initiate(eph, nonce);
    let mut initmsg = Vec::new();
    put_lp(&mut initmsg, &init.hybrid_pub);
    initmsg.extend_from_slice(&init.nonce);
    if !send_framed(handle, &initmsg) {
        netstack::tcp_close_h(handle);
        return;
    }
    let Some(respmsg) = recv_framed(handle, &mut rx, 3_000_000) else {
        netstack::tcp_close_h(handle);
        unsafe { STATUS = "handshake timed out" };
        return;
    };
    let mut ro = 0usize;
    let (Some(hybrid_ct), Some(rnonce), Some(sig)) = (
        take_lp(&respmsg, &mut ro).map(|s| s.to_vec()),
        take_nonce(&respmsg, &mut ro),
        take_lp(&respmsg, &mut ro).map(|s| s.to_vec()),
    ) else {
        netstack::tcp_close_h(handle);
        return;
    };
    let resp = handshake::HandshakeResp { hybrid_ct, nonce: rnonce, sig };
    let Some((finish, session)) =
        handshake::finish_initiator(identity(), &kp, &init, &peer_id, &resp)
    else {
        unsafe { STATUS = "handshake auth failed -- wrong key answered" };
        netstack::tcp_close_h(handle);
        return;
    };
    let mut finmsg = Vec::new();
    put_lp(&mut finmsg, &finish.sig);
    if !send_framed(handle, &finmsg) {
        netstack::tcp_close_h(handle);
        return;
    }
    let real_lid = LingId::from_public(&peer_id);
    open_conversation(real_lid, handle, session, rx);
    unsafe { STATUS = "connected" };
}

fn open_conversation(peer: LingId, handle: usize, session: Session, rx: Vec<u8>) {
    unsafe {
        let convos = &mut *&raw mut CONVERSATIONS;
        // Duplicate connection: both peers can dial/accept near-simultaneously.
        // If we already hold a LIVE socket to this peer, keep it and drop the
        // newcomer -- tearing down a working socket to install a fresh one is
        // exactly what dropped the chat (the sender then wrote to a closed
        // socket). Only replace a conversation whose socket is already dead.
        if let Some(c) = convos.iter().find(|c| c.peer == peer) {
            if netstack::tcp_established_h(c.handle) {
                netstack::tcp_close_h(handle);
                return;
            }
        }
        convos.retain(|c| {
            if c.peer == peer {
                netstack::tcp_close_h(c.handle);
                false
            } else {
                true
            }
        });
        let lines = history_for(&peer);
        convos.push(Conversation { peer, handle, rx, session, lines });
    }
}

// -- Actions driven by the UI -----------------------------------------------

/// Connect to a nearby peer (Enter on a `+handle` row). Connecting runs the
/// TOFU identity exchange + hybrid handshake, which adds them to the roster on
/// success -- so "add" and "chat" are the same action now (compare the LingId
/// shown out of band to be sure it's really them).
/// Queue a dial to a nearby peer (Enter on a `+handle` row). The background
/// task runs the TOFU identity exchange + hybrid handshake, which adds them to
/// the roster on success -- so "add nearby" and "start chat" are one action.
/// Compare the LingId shown out of band to be sure a first contact is them.
pub fn add_nearby_as_friend(nearby_index: usize) {
    let Some((lid, ip)) = (unsafe {
        (&*&raw const NEARBY)
            .get(nearby_index)
            .map(|p| (p.ling_id.clone(), p.ip))
    }) else {
        return;
    };
    queue_connect(lid, ip);
}

/// Queue a chat with roster friend `friend_index` (the background task dials).
pub fn start_chat(friend_index: usize) {
    let Some(friend_lid) = roster().friends.get(friend_index).map(|f| f.ling_id.clone()) else {
        return;
    };
    let Some(ip) =
        (unsafe { (&*&raw const NEARBY).iter().find(|p| p.ling_id == friend_lid).map(|p| p.ip) })
    else {
        unsafe { STATUS = "friend not seen on this LAN recently -- ask them to open Messenger too" };
        return;
    };
    queue_connect(friend_lid, ip);
}

fn queue_connect(lid: LingId, ip: [u8; 4]) {
    unsafe {
        if (&*&raw const CONVERSATIONS).iter().any(|c| c.peer == lid) {
            return; // already connected
        }
        if (&*&raw const CONNECT_REQ).iter().any(|(l, _)| *l == lid) {
            return; // already queued
        }
        (&mut *&raw mut CONNECT_REQ).push((lid, ip));
        STATUS = "connecting...";
    }
}

/// Queue the composed line for delivery (the background task seals + sends and
/// echoes it locally once sent).
pub fn send_current_compose() {
    let Some(sel) = (unsafe { SELECTED }) else { return };
    let Some(friend_lid) = roster().friends.get(sel).map(|f| f.ling_id.clone()) else { return };
    let text =
        unsafe { core::str::from_utf8(&(&*&raw const COMPOSE)[..COMPOSE_LEN]).unwrap_or("").to_string() };
    if text.is_empty() {
        return;
    }
    unsafe {
        if !(&*&raw const CONVERSATIONS).iter().any(|c| c.peer == friend_lid) {
            STATUS = "not connected yet -- select the friend and press Enter to connect";
            return;
        }
        (&mut *&raw mut OUTBOX).push((friend_lid, text));
        COMPOSE_LEN = 0;
    }
}

// -- Slash commands ---------------------------------------------------------

/// Handle a `/command arg` typed into the compose box.
fn run_command(cmd: &str, arg: &str) {
    match cmd {
        "nick" => unsafe {
            if arg.is_empty() {
                STATUS = "usage: /nick <name>";
            } else if nick_in_use(arg) {
                STATUS = "nick already in use online";
            } else {
                set_local_handle(arg);
                // Tell every open peer right away (over the live session) so
                // they re-label us without waiting for a beacon; the periodic
                // beacon carries the new handle to not-yet-connected peers.
                (&mut *&raw mut NICK_OUTBOX).push(arg.to_string());
                STATUS = "handle updated";
            }
        },
        "add" => {
            if arg.is_empty() {
                unsafe { STATUS = "usage: /add <name>" };
                return;
            }
            // Match a nearby peer by handle and add + start chatting with them.
            let found = unsafe {
                (&*&raw const NEARBY)
                    .iter()
                    .position(|p| p.handle.eq_ignore_ascii_case(arg))
                    .map(|i| (i, (&*&raw const NEARBY)[i].ling_id.clone()))
            };
            match found {
                Some((i, lid)) => {
                    add_nearby_as_friend(i);
                    // Select them so what you type next goes to THIS chat (not
                    // the set-your-handle path).
                    select_peer(&lid);
                    unsafe { STATUS = "adding peer..." };
                },
                None => unsafe { STATUS = "no nearby peer by that name" },
            }
        },
        "ping" => send_ping(),
        _ => unsafe { STATUS = "unknown command (try /nick /add /ping)" },
    }
}

/// Queue a ping to the selected friend (the background task seals + sends it).
/// Rate-limited to one per minute so it can't be used to spam a sound.
fn send_ping() {
    let Some(friend_lid) =
        (unsafe { SELECTED }).and_then(|s| roster().friends.get(s).map(|f| f.ling_id.clone()))
    else {
        unsafe { STATUS = "select a friend to ping" };
        return;
    };
    unsafe {
        if !(&*&raw const CONVERSATIONS).iter().any(|c| c.peer == friend_lid) {
            STATUS = "not connected yet";
            return;
        }
        let now = timer::now_ms();
        if now.wrapping_sub(LAST_PING_MS) < PING_COOLDOWN_MS {
            STATUS = "ping on cooldown (60s)";
            return;
        }
        LAST_PING_MS = now;
        (&mut *&raw mut PING_OUTBOX).push(friend_lid);
        STATUS = "ping sent";
    }
}

/// Task-side: seal a `KIND_PING` frame to `lid`, send it, and echo it locally.
fn send_ping_to(lid: &LingId) {
    let sealed = unsafe {
        let convos = &mut *&raw mut CONVERSATIONS;
        convos
            .iter_mut()
            .find(|c| &c.peer == lid)
            .map(|c| (c.handle, wire::seal(&mut c.session, wire::KIND_PING, &[])))
    };
    let Some((handle, frame)) = sealed else { return };
    let ok = send_framed(handle, &frame);
    if ok {
        unsafe {
            let convos = &mut *&raw mut CONVERSATIONS;
            if let Some(c) = convos.iter_mut().find(|c| &c.peer == lid) {
                c.lines.push((true, "*ping*".to_string()));
            }
        }
        remember(lid, true, "*ping*");
    }
}

/// Is `name` already taken by a *different* peer we can see -- a nearby peer or
/// an existing friend? Blocks `/nick` from creating a confusing duplicate. We
/// can only judge peers we know about ("online"/discovered), which is exactly
/// what the user asked to guard against.
fn nick_in_use(name: &str) -> bool {
    let me = my_ling_id();
    let nearby_hit = unsafe {
        (&*&raw const NEARBY)
            .iter()
            .any(|p| p.ling_id != me && p.handle.eq_ignore_ascii_case(name))
    };
    nearby_hit || roster().handle_taken_by_other(name, &me)
}

/// Task-side: broadcast our new handle to every open conversation (KIND_NICK).
/// Collects (socket, sealed frame) under a non-yield borrow, then sends on
/// locals -- same discipline as `send_ping_to` (never hold a `&mut` into
/// CONVERSATIONS across the network yield).
fn broadcast_nick(new: &str) {
    let sends: Vec<(usize, Vec<u8>)> = unsafe {
        let convos = &mut *&raw mut CONVERSATIONS;
        convos
            .iter_mut()
            .map(|c| (c.handle, wire::seal(&mut c.session, wire::KIND_NICK, new.as_bytes())))
            .collect()
    };
    for (handle, frame) in sends {
        let _ = send_framed(handle, &frame);
    }
}

/// Apply a peer's nick change to the roster (keyed by their stable LingId, so
/// routing is unaffected) and persist it -- the sidebar, chat header, and
/// message author labels all read the handle from the roster at draw time, so
/// this retroactively renames them everywhere. Also updates the nearby list.
fn rename_peer(peer: &LingId, new: &str) {
    unsafe {
        if (&mut *&raw mut ROSTER).rename_by_ling_id(peer, new) {
            save_roster();
        }
        if let Some(p) = (&mut *&raw mut NEARBY).iter_mut().find(|p| &p.ling_id == peer) {
            p.handle = new.to_string();
        }
    }
}

// -- Input ------------------------------------------------------------------

/// A flat, selectable list: friends first (each can be chatted with), then
/// nearby-but-not-yet-added peers (Enter tries to add them). Returns
/// `(is_friend, index_into_that_list, label)`.
fn selectable_rows() -> Vec<(bool, usize, String)> {
    let mut rows = Vec::new();
    for (i, f) in roster().friends.iter().enumerate() {
        let online = unsafe { (&*&raw const NEARBY).iter().any(|p| p.ling_id == f.ling_id) };
        let mark = if online { "*" } else { " " };
        rows.push((true, i, alloc::format!("{}{}", mark, f.handle)));
    }
    unsafe {
        for (i, p) in (&*&raw const NEARBY).iter().enumerate() {
            if roster().find_by_ling_id(&p.ling_id).is_none() {
                rows.push((false, i, alloc::format!("+{}", p.handle)));
            }
        }
    }
    rows
}

pub fn key(k: u8) {
    match k {
        10 => {
            let compose = unsafe {
                core::str::from_utf8(&(&*&raw const COMPOSE)[..COMPOSE_LEN]).unwrap_or("").to_string()
            };
            // Slash commands work from ANYWHERE (even with a chat open):
            //   /nick <name>  rename yourself (beacons out to peers)
            //   /add <name>   add + start chatting with a nearby peer by name
            //   /ping         nudge the selected friend with a sound (60s cooldown)
            if let Some(rest) = compose.strip_prefix('/') {
                let (cmd, arg) = match rest.find(' ') {
                    Some(i) => (&rest[..i], rest[i + 1..].trim()),
                    None => (rest, ""),
                };
                run_command(cmd, arg);
                unsafe { COMPOSE_LEN = 0 };
                return;
            }
            match unsafe { SELECTED } {
                Some(sel) if sel < roster().friends.len() => {
                    // A friend is selected: typed text sends, empty compose
                    // (re)starts the handshake.
                    if unsafe { COMPOSE_LEN > 0 } {
                        send_current_compose();
                    } else {
                        start_chat(sel);
                    }
                },
                Some(sel) => {
                    // A nearby-but-not-yet-friend row is selected: Enter tries add.
                    if let Some((false, idx, _)) = selectable_rows().get(sel) {
                        add_nearby_as_friend(*idx);
                    }
                },
                None => {
                    // Nothing selected: typed text sets your own display handle.
                    if !compose.is_empty() {
                        set_local_handle(&compose);
                        unsafe {
                            COMPOSE_LEN = 0;
                            STATUS = "handle updated";
                        }
                    }
                },
            }
        },
        0x1B => unsafe { SELECTED = None }, // Esc deselects, back to the no-chat view
        0x11 => move_selection(-1),
        0x12 => move_selection(1),
        0x08 => unsafe {
            if COMPOSE_LEN > 0 {
                COMPOSE_LEN -= 1;
            }
        },
        0x20..=0x7E => unsafe {
            if COMPOSE_LEN < COMPOSE_MAX {
                (&mut *&raw mut COMPOSE)[COMPOSE_LEN] = k;
                COMPOSE_LEN += 1;
            }
        },
        _ => {},
    }
}

/// Point `SELECTED` at the row for peer `lid` (friend or nearby), if present.
fn select_peer(lid: &LingId) {
    let rows = selectable_rows();
    for (row, (is_friend, idx, _)) in rows.iter().enumerate() {
        let matches = if *is_friend {
            roster().friends.get(*idx).map(|f| &f.ling_id == lid).unwrap_or(false)
        } else {
            unsafe { (&*&raw const NEARBY).get(*idx).map(|p| &p.ling_id == lid).unwrap_or(false) }
        };
        if matches {
            unsafe { SELECTED = Some(row) };
            return;
        }
    }
}

fn move_selection(delta: i32) {
    let rows = selectable_rows();
    if rows.is_empty() {
        unsafe { SELECTED = None };
        return;
    }
    let cur = unsafe { SELECTED }.unwrap_or(0) as i32;
    let next = (cur + delta).clamp(0, rows.len() as i32 - 1) as usize;
    unsafe { SELECTED = Some(next) };
}

pub fn click_row(row: usize) {
    let rows = selectable_rows();
    let Some((is_friend, idx, _)) = rows.get(row) else { return };
    if *is_friend {
        unsafe { SELECTED = Some(*idx) };
        start_chat(*idx);
    } else {
        add_nearby_as_friend(*idx);
    }
}

// -- A Ling window's view (LingOS/apps/messenger on the Pi) --------------------

/// Sidebar rows: (is_friend, index into that list, label without marker, online).
pub fn rows() -> Vec<(bool, usize, String, bool)> {
    selectable_rows()
        .into_iter()
        .map(|(f, i, label)| {
            let online = label.starts_with('*') || !f;
            (f, i, strip_marker(&label).to_string(), online)
        })
        .collect()
}

pub fn selected_row() -> Option<usize> {
    unsafe { SELECTED }
}

pub fn select_row(row: i64) {
    unsafe { SELECTED = if row < 0 { None } else { Some(row as usize) } };
}

/// Enter / click on a row: a friend starts (or restarts) the chat, a nearby
/// peer is added and dialed.
pub fn activate_row(row: usize) {
    click_row(row);
}

/// The selected friend's conversation (live lines, else the saved history).
pub fn selected_lines() -> Vec<(bool, String)> {
    let Some(sel) = (unsafe { SELECTED }) else { return Vec::new() };
    let Some(lid) = roster().friends.get(sel).map(|f| f.ling_id.clone()) else { return Vec::new() };
    unsafe {
        if let Some(c) = (&*&raw const CONVERSATIONS).iter().find(|c| c.peer == lid) {
            return c.lines.clone();
        }
    }
    history_for(&lid)
}

pub fn selected_connected() -> bool {
    let Some(sel) = (unsafe { SELECTED }) else { return false };
    let Some(lid) = roster().friends.get(sel).map(|f| f.ling_id.clone()) else { return false };
    unsafe { (&*&raw const CONVERSATIONS).iter().any(|c| c.peer == lid && netstack::tcp_established_h(c.handle)) }
}

/// A line typed into the compose box: `/commands`, a message to the selected
/// friend, or (nothing selected) a new display handle.
pub fn submit(text: &str) {
    let b = text.as_bytes();
    let n = b.len().min(COMPOSE_MAX);
    unsafe {
        (&mut *&raw mut COMPOSE)[..n].copy_from_slice(&b[..n]);
        COMPOSE_LEN = n;
    }
    key(10);
}

/// (display handle, LingId fingerprint) of this machine.
pub fn me() -> (String, String) {
    (local_handle(), my_ling_id().0)
}

// -- Drawing ------------------------------------------------------------------
// A Discord-style two-pane chat: a conversations sidebar with round avatars on
// the left, a header + word-wrapped message stream + compose box on the right.
// `wm.rs::window_click` mirrors SIDEBAR_W / LIST_TOP / ROW_H for the sidebar
// hit-test -- keep those three in sync there.
#[cfg(target_arch = "x86_64")]
const SIDEBAR_W: u32 = 190;
#[cfg(target_arch = "x86_64")]
const ROW_H: u32 = 40; // avatar row height in the sidebar
#[cfg(target_arch = "x86_64")]
const LIST_TOP: u32 = 34; // sidebar list starts below the "DIRECT MESSAGES" header
#[cfg(target_arch = "x86_64")]
const HEADER_H: u32 = 40; // chat-pane header height
#[cfg(target_arch = "x86_64")]
const LINE_H: u32 = 12; // one text line (8px glyph + 4px leading)
#[cfg(target_arch = "x86_64")]
const ONLINE_DOT: u32 = 0x3B_A5_5D; // Discord-ish presence green

/// A deterministic avatar color for `seed` (a handle), from a small palette --
/// the same handle always gets the same color, like Discord's default avatars.
#[cfg(target_arch = "x86_64")]
fn avatar_color(seed: &[u8]) -> u32 {
    const PAL: [u32; 6] = [0x58_65_F2, 0x3B_A5_5D, 0xFA_A6_1A, 0xED_42_45, 0x9B_59_B6, 0x1A_BC_9C];
    let mut hsh: u32 = 2166136261;
    for &b in seed {
        hsh = (hsh ^ b as u32).wrapping_mul(16777619);
    }
    PAL[(hsh as usize) % PAL.len()]
}

/// Draw a round default avatar: a color-hashed disc with the handle's first
/// letter centered. `scale` is the glyph scale (1 = 8px, 2 = 16px).
#[cfg(target_arch = "x86_64")]
fn draw_avatar(cx: u32, cy: u32, r: u32, scale: u32, name: &[u8]) {
    let initial = name
        .iter()
        .copied()
        .find(|c| c.is_ascii_alphanumeric())
        .unwrap_or(b'?')
        .to_ascii_uppercase();
    let col = avatar_color(name);
    framebuffer::back_fill_circle(cx, cy, r, col);
    let g = 4 * scale;
    font8x8::draw_char_scaled(cx - g, cy - g, initial, 0xFF_FF_FF, col, scale);
}

/// Strip the leading presence marker (`*`/`+`/space) a sidebar label carries.
fn strip_marker(label: &str) -> &str {
    match label.as_bytes().first() {
        Some(b'*') | Some(b'+') | Some(b' ') => &label[1..],
        _ => label,
    }
}

/// Word-wrap `s` into `max_w` pixels, drawing each line; returns the next `y`.
/// Breaks on spaces, hard-splits any single word longer than the line.
#[cfg(target_arch = "x86_64")]
fn draw_wrapped(x: u32, mut yy: u32, max_w: u32, y_limit: u32, s: &str, fg: u32, bg: u32) -> u32 {
    let maxch = (max_w / 8).max(1) as usize;
    let mut line = String::new();
    for word in s.split(' ') {
        if word.len() > maxch {
            if !line.is_empty() && yy + 8 <= y_limit {
                font8x8::draw_str(x, yy, line.as_bytes(), fg, bg);
                yy += LINE_H;
            }
            line.clear();
            let wb = word.as_bytes();
            let mut o = 0;
            while o < wb.len() {
                if yy + 8 > y_limit {
                    return yy;
                }
                let e = (o + maxch).min(wb.len());
                font8x8::draw_str(x, yy, &wb[o..e], fg, bg);
                yy += LINE_H;
                o = e;
            }
            continue;
        }
        let cand = if line.is_empty() { word.len() } else { line.len() + 1 + word.len() };
        if cand > maxch && !line.is_empty() {
            if yy + 8 <= y_limit {
                font8x8::draw_str(x, yy, line.as_bytes(), fg, bg);
                yy += LINE_H;
            }
            line.clear();
        }
        if !line.is_empty() {
            line.push(' ');
        }
        line.push_str(word);
    }
    if !line.is_empty() && yy + 8 <= y_limit {
        font8x8::draw_str(x, yy, line.as_bytes(), fg, bg);
        yy += LINE_H;
    }
    yy
}

/// How many wrapped lines `s` needs at `max_w` -- for scrolling to the newest.
#[cfg(target_arch = "x86_64")]
fn wrapped_line_count(s: &str, max_w: u32) -> usize {
    let maxch = (max_w / 8).max(1) as usize;
    let mut lines = 0usize;
    let mut len = 0usize;
    for word in s.split(' ') {
        if word.len() > maxch {
            if len > 0 {
                lines += 1;
                len = 0;
            }
            lines += word.len().div_ceil(maxch);
            continue;
        }
        let cand = if len == 0 { word.len() } else { len + 1 + word.len() };
        if cand > maxch && len > 0 {
            lines += 1;
            len = 0;
        }
        len = if len == 0 { word.len() } else { len + 1 + word.len() };
    }
    if len > 0 {
        lines += 1;
    }
    lines.max(1)
}

#[cfg(target_arch = "x86_64")]
pub fn draw(x: u32, y: u32, w: u32, h: u32) {
    tick();
    let bg = theme::color(theme::SLOT_BG);
    let panel = theme::color(theme::SLOT_PANEL);
    let border = theme::color(theme::SLOT_PANEL_BORDER);
    let text = theme::color(theme::SLOT_TEXT);
    let dim = theme::color(theme::SLOT_DIM);
    let accent = theme::color(theme::SLOT_ACCENT);

    framebuffer::back_fill_rect(x, y, w, h, panel);

    // ---- Sidebar: conversations with round avatars ----
    framebuffer::back_fill_rect(x, y, SIDEBAR_W, h, bg);
    framebuffer::back_fill_rect(x + SIDEBAR_W, y, 1, h, border);
    font8x8::draw_str(x + 12, y + 12, b"DIRECT MESSAGES", dim, bg);

    // Bottom strip is the "you" panel (below), so stop the list above it.
    let self_h = 34u32;
    let list_bottom = (y + h).saturating_sub(self_h);
    let rows = selectable_rows();
    let sel = unsafe { SELECTED };
    for (i, (_is_friend, _idx, label)) in rows.iter().enumerate() {
        let ry = y + LIST_TOP + i as u32 * ROW_H;
        if ry + ROW_H > list_bottom {
            break;
        }
        let online = label.starts_with('*');
        let selected = sel == Some(i);
        let row_bg = if selected { border } else { bg };
        if selected {
            framebuffer::back_fill_rounded_rect(x + 4, ry + 3, SIDEBAR_W - 8, ROW_H - 6, 6, border);
        }
        let name = strip_marker(label);
        let acx = x + 24;
        let acy = ry + ROW_H / 2;
        draw_avatar(acx, acy, 13, 2, name.as_bytes());
        if online {
            framebuffer::back_fill_circle(acx + 10, acy + 10, 4, row_bg);
            framebuffer::back_fill_circle(acx + 10, acy + 10, 3, ONLINE_DOT);
        }
        let maxch = ((SIDEBAR_W - 48) / 8) as usize;
        let nb = name.as_bytes();
        font8x8::draw_str(
            x + 44,
            ry + ROW_H / 2 - 4,
            &nb[..nb.len().min(maxch)],
            if selected { text } else { dim },
            row_bg,
        );
    }

    // ---- "You" panel (bottom-left, like Discord's user area): shows your own
    // avatar + current handle, so `/nick` changes are visible at a glance. ----
    let me_name = local_handle();
    let sp_y = (y + h).saturating_sub(self_h);
    framebuffer::back_fill_rect(x, sp_y, SIDEBAR_W, self_h, border);
    draw_avatar(x + 20, sp_y + self_h / 2, 11, 2, me_name.as_bytes());
    let me_max = ((SIDEBAR_W - 44) / 8) as usize;
    let mnb = me_name.as_bytes();
    font8x8::draw_str(x + 38, sp_y + self_h / 2 - 8, &mnb[..mnb.len().min(me_max)], text, border);
    font8x8::draw_str(x + 38, sp_y + self_h / 2 + 2, b"you", dim, border);

    // ---- Chat pane ----
    let px = x + SIDEBAR_W + 1;
    let pw = w.saturating_sub(SIDEBAR_W + 1);

    // Header: selected peer's avatar + handle + status.
    framebuffer::back_fill_rect(px, y + HEADER_H, pw, 1, border);
    match sel {
        Some(s) if s < roster().friends.len() => {
            let fname = roster().friends[s].handle.clone();
            draw_avatar(px + 22, y + HEADER_H / 2, 12, 2, fname.as_bytes());
            font8x8::draw_str(px + 42, y + 10, fname.as_bytes(), text, panel);
            font8x8::draw_str(px + 42, y + 24, status().as_bytes(), dim, panel);
        },
        _ => {
            font8x8::draw_str(px + 16, y + HEADER_H / 2 - 4, b"Select a conversation to start chatting", dim, panel);
        },
    }

    // Message stream (word-wrapped, newest-anchored) + compose box.
    let compose_h = 30u32;
    let msg_x = px + 14;
    let msg_top = y + HEADER_H + 10;
    let msg_w = pw.saturating_sub(28);
    let msg_bottom = (y + h).saturating_sub(compose_h + 8);

    if let Some(s) = sel {
        if s < roster().friends.len() {
            let friend_lid = roster().friends[s].ling_id.clone();
            let friend_name = roster().friends[s].handle.clone();
            let me_name = local_handle();
            // Prefer the live conversation; otherwise fall back to the saved
            // (password-decrypted) history so past messages still show when the
            // peer is offline -- e.g. right after a reboot + sign-in.
            let lines = match unsafe {
                (&*&raw const CONVERSATIONS).iter().find(|c| c.peer == friend_lid)
            } {
                Some(convo) => convo.lines.clone(),
                None => history_for(&friend_lid),
            };
            if lines.is_empty() {
                font8x8::draw_str(msg_x, msg_top, b"Not connected -- press Enter to start a chat.", dim, panel);
            } else {
                // Anchor to the newest: count from the end until the window fills.
                let avail = ((msg_bottom.saturating_sub(msg_top)) / LINE_H) as usize;
                let body_w = msg_w.saturating_sub(26);
                let mut used = 0usize;
                let mut start = 0usize;
                for (i, (_, line)) in lines.iter().enumerate().rev() {
                    used += 1 + wrapped_line_count(line, body_w) + 1; // name + body + gap
                    if used > avail {
                        start = i + 1;
                        break;
                    }
                }
                let mut yy = msg_top;
                for (from_me, line) in lines[start..].iter() {
                    if yy + 8 > msg_bottom {
                        break;
                    }
                    let who = if *from_me { me_name.as_bytes() } else { friend_name.as_bytes() };
                    draw_avatar(msg_x + 9, yy + 8, 9, 1, who);
                    font8x8::draw_str(msg_x + 26, yy, who, accent, panel);
                    yy += LINE_H;
                    yy = draw_wrapped(msg_x + 26, yy, body_w, msg_bottom, line, text, panel);
                    yy += 6;
                }
            }
        }
    }

    // Compose box: rounded input, placeholder when empty.
    let cbx = px + 12;
    let cby = (y + h).saturating_sub(compose_h);
    let cbw = pw.saturating_sub(24);
    framebuffer::back_fill_rounded_rect(cbx, cby, cbw, compose_h - 6, 8, bg);
    let inner_w = ((cbw.saturating_sub(16)) / 8) as usize;
    let buf = unsafe { &(&*&raw const COMPOSE)[..COMPOSE_LEN] };
    if buf.is_empty() {
        let hint: &[u8] = match sel {
            Some(s) if s < roster().friends.len() => b"Message...   /ping  /nick <name>",
            _ => b"/nick <name>  /add <name>  -- or arrows + Enter to add a peer",
        };
        font8x8::draw_str(cbx + 8, cby + 8, &hint[..hint.len().min(inner_w)], dim, bg);
    } else {
        let shown = buf.len().min(inner_w);
        font8x8::draw_str(cbx + 8, cby + 8, &buf[buf.len() - shown..], text, bg);
    }
}

← Back to index