Decentralized, terminal-native chat rooms.
Open the TUI, browse rooms that other people on the same LAN are hosting (or that you've reached by relay across the internet), or start one yourself. Rooms can be public (cleartext over gossipsub) or encrypted (per-sender Megolm group sessions, session keys wrapped with an Argon2id-derived passphrase key).
No servers, no accounts, no cloud — by default, no internet required.
For peers across NATs, opt in to a Circuit Relay v2 host of your choice
via --relay or config.toml; AutoNAT v2 + DCUtR will hole-punch to
direct when possible.
This is a learning project, not production-audited chat. SQLCipher protects the database at rest under your master passphrase, Megolm sessions are persisted with an Argon2id-derived key, file bytes use ChaCha20-Poly1305, and SAS contact verification ships in v0.3 — but the protocol has not been audited and threat-modelling work is ongoing. Don't rely on it for real secrets without a careful review.
Requires Rust 1.75+ (edition 2021).
cargo build --release
./target/release/huddle- Launch — your Ed25519 identity loads (or generates) from disk
silently. The TUI opens on the Welcome pane with the sidebar
on the left. mDNS starts listening for room announcements on the
LAN. If you configured a relay (
--relayorconfig.toml), huddle dials it and reserves a/p2p-circuitso peers across the internet can dial you. - First launch only — a versioned onboarding card explains huddle's leaderless model (rooms outlive the creator), the master passphrase vs room passphrase distinction, the sidebar layout, and the new keybindings.
- Direct messages — press
m, type a partner's HD-ID or username, hitEnter. The DM appears in the Direct messages section of the sidebar on both peers. DMs are end-to-end encrypted on the room layer via an ECDH derivation between the two parties' identity keys (huddle 0.7.1+). - Group rooms — press
gto create a multi-peer room. Pick a name, choose public or encrypted (and a passphrase if encrypted). You become the room's first owner; only owners can kick, grant moderation, or rotate the room key. Discovered rooms you haven't joined appear under the Discover sub-row in the sidebar. - Inbound dial gate — if someone you don't know dials you, the TUI raises an Accept / Reject / Trust+Accept modal. The peer isn't added to your gossipsub mesh until you decide.
- Chat, verify, moderate — see the Key bindings
tables for SAS verification (
Ctrl+V → s), kick (Ctrl+K), grant owner (Ctrl+G), invite links (Shift+I), join codes (Ctrl+J/c), and verified-only-mode toggles (Settings pane,oper room).
+----------------------------------------------------------------------+
| huddle 0.7.1 · 745e-fe8a-… · 🌐 reachable 12:34 UTC |
+------------------------+---------------------------------------------+
| ▾ Profile | # general |
| alice HD-AAAA-… 🌐 | 4 members · 🔒 encrypted |
| ▾ Direct messages (2) | |
| ● bob 1m (1) | 12:32 bob hey |
| ○ dave offline | 12:33 carol ✓ same here |
| ▾ Group rooms (1) | 12:34 you looks good |
| # general 4 E | |
| + Discover (2) | |
| ▾ People | |
| eve HD-EEEE-… ✓ | > _ |
| ▸ Activity | |
| ▸ Settings | |
+------------------------+---------------------------------------------+
| ?help /type ^V verify ^F search ^A attach ^L leave ^I members |
+----------------------------------------------------------------------+
Six sidebar sections, top-to-bottom: Profile (you), Direct
messages, Group rooms (with a Discover row), People (known +
verified + blocked), Activity (status history + transfers),
Settings (toggles + go-dark). j/k moves the cursor; Tab /
Shift+Tab jumps between sections; Space / → / ← toggles
expand. Enter opens the selection in the right-hand pane. Esc
focuses the sidebar from a chat pane.
Single source of truth: crates/huddle/src/keybindings.rs. The Help
modal (?) renders the same table at runtime, so it can never drift
from the actual key map.
| Key | Action |
|---|---|
? |
Help |
: or Ctrl+P |
Command palette — fuzzy search every action |
Ctrl+H |
Notification history (last 100 status events) |
Shift+← / Shift+→ |
Focus sidebar / pane (tmux-style) |
Esc |
Close modal / blur input / focus sidebar |
q / Ctrl+C |
Quit (confirms first) |
About the focus-jump binding (huddle 0.7.3+):
Shift+←/Shift+→toggle keyboard focus between the sidebar and the pane, including while typing in chat input. Shift+arrows are unclaimed at OS and terminal level on macOS, Linux, and Windows — no Mission Control / Spaces conflict. (0.7.2 briefly usedCtrl+←/Ctrl+→but those collide with macOS's Move-between-Spaces shortcut.)
| Key | Action |
|---|---|
m |
Start a DM (Compose-DM modal) |
g |
Start a group room |
p |
Jump to the People pane |
, |
Jump to the Settings pane |
a |
Add friend by HD ID or username |
d |
Dial a peer by multiaddr or ip:port |
i |
Show your identity as a QR code |
Shift+I |
Generate an invite link (peer-only, or room-scoped from a chat pane) |
v |
Paste an invite link (huddle://invite#…) |
c |
Join with code (when an encrypted group is selected) |
j / k / arrows |
Move sidebar cursor |
Tab / Shift+Tab |
Jump to next / prev sidebar section |
Space / → / ← |
Toggle section expand |
Enter |
Open the selected row |
r |
Refresh / reconnect (context-sensitive) |
x |
Forget the selected peer |
R (Shift+r) |
Mark every room read |
| Key | Action |
|---|---|
/ |
Focus input |
Enter |
Send |
Alt+Enter / Ctrl+J |
Newline in input |
Esc |
Blur input (or focus sidebar) |
Ctrl+V |
Verify partner / member (SAS) |
Ctrl+F |
Search this room's history |
Ctrl+A |
Attach a file |
Ctrl+L |
Leave the room |
j / k |
Scroll messages (input blurred) |
g / G |
Scroll to top / bottom |
PageUp / PageDown |
Scroll a page |
f |
Focus file cards (j/k steps) |
| Key | Action |
|---|---|
Ctrl+I |
Toggle the right-margin member list |
Ctrl+K |
Kick a member (owners only) |
Ctrl+G |
Grant owner role (owners only) |
Ctrl+R |
Rotate the room key (owners only) |
Ctrl+J |
Generate a single-use join code (owners) |
Ctrl+M |
Mute / unmute this room |
Ctrl+O |
Per-room verified-only-join toggle |
Shift+B |
List bans for this room (owners) |
| Key | Action |
|---|---|
V |
Toggle "reject inbound from unverified" |
U |
Toggle the crates.io update check (opt-in) |
E |
Edit your username |
W |
Replay onboarding (what's new) |
B |
Manage blocked peers |
Alt+Shift+1 (Option+Shift+1 on macOS) |
Delete account (go dark) — passphrase-gated |
Every peer has a 96-bit fingerprint rendered as a branded
HD-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX ID. Same security as before, just a
friendlier format. The Profile pane (sidebar's top section) shows yours.
Set an optional username from the Profile or Settings pane (E). The username is
broadcast in a signed ProfileUpdate event — peers receiving it
verify the Ed25519 signature against the claimed fingerprint, so
nobody can spoof "alice" by stuffing a string into a packet. If you
clear the field (empty input), you broadcast as [anonymous].
In chat, your message label shows the username (or [anonymous]).
SAS-verified peers also get a green ✓ next to their name in chat,
matching the existing badge in the room member list.
Press a from the sidebar to open the add-friend modal. Takes either:
- an
HD-XXXX-XXXX-XXXX-XXXX-XXXX-XXXXID (or the bare 24-hex form with/without dashes — normalized internally), - or a username string (unique-match lookup in
peer_profiles).
Resolution: huddle looks the fingerprint up across recent room
announcements (creator_fingerprint + host_addrs) and the persisted
known_peers table. Every candidate multiaddr is then handed to libp2p
as a single DialOpts::peer_id().addresses() call — the swarm races
them in parallel (huddle 0.5.2+) and the first to complete wins. The
client also pre-sorts by transport preference (RFC1918 LAN ip4 →
loopback → public ip4 → ip6 / dns → /p2p-circuit) so when latencies
are close the LAN slot starts first. mDNS-discovered peers don't need
this path at all — they show up in the sidebar's People section
automatically.
The privacy trade-off worth knowing about: this works only for peers you've already seen on a shared gossipsub mesh — same LAN, a relay you both connect to, or a prior dial. There's deliberately no central "add by ID" directory; cold-start strangers must pass an invite link out-of-band first. Adding a directory (DHT, rendezvous server, central service) would either centralize the architecture or leak lookup metadata to bootstrap nodes — both fail the "trusted relay, absolute privacy" goal huddle's built around.
Both peers select each other in the Verify modal (^V), one presses
s to start. Each generates an ephemeral X25519 keypair, exchanges
pubkeys via signed envelopes, and derives a shared secret via ECDH.
HKDF produces a Matrix MSC 2241-aligned 7-emoji + three-4-digit-group
decimal code; both peers compare OOB (call/SMS/in-person) and press
m to match. A MITM substituting an ephemeral key gets a different
SAS code on each side — the OOB comparison catches it.
On match, the partner's fingerprint is marked verified (per-room +
global). With the global "verified-only inbound" toggle on (Settings
pane, V), unverified inbound dials auto-reject without prompting.
Press Shift+I to generate an invite. From a chat pane the invite
includes the current room; from anywhere else it's peer-only. The TUI
shows a huddle://invite#<base64-JSON> URL plus a QR. The base64 JSON
carries the host multiaddr (with /p2p/<peer-id> so libp2p enforces
the peer-id check on dial), the human-display fingerprint, and an
optional room summary.
Paste an invite from the sidebar with v. The TUI confirms the
claimed fingerprint and dials. After dial, the post-dial
fingerprint check (added in 0.3.x) re-derives the peer's fingerprint
from their Ed25519 pubkey on Identify and disconnects if it doesn't
match the invite's claim — defense in depth, since libp2p's
/p2p/<peer-id> already enforces the cryptographic match.
If the invite includes an encrypted room, you're prompted for the passphrase next.
The room's creator is the first owner; owners can grant the role to
others (Ctrl+G) or kick (Ctrl+K). Kick = signed BanMember broadcast +
immediate RotateRoomKey with a freshly-generated passphrase
(displayed to the owner for OOB re-share with the remaining members).
The banned peer still receives gossipsub bytes but can't decrypt the new outbound session key. Honest peers honour the ban (drop their messages); cryptographic enforcement is the key rotation, not the ban row itself. Soft owner model — kick is not a hard network quarantine.
B (Shift+b) lists the bans for the current room.
By default huddle uses LAN mDNS only. To accept dials across the internet, register with a Circuit Relay v2 host:
huddle --relay /dns4/relay.example.com/tcp/4001/p2p/12D3Koo...…or persist in config.toml:
# macOS: ~/Library/Application Support/huddle/config.toml
# Linux: ~/.config/huddle/config.toml
# Windows: %APPDATA%\huddle\config.toml
relays = [
"/dns4/relay.example.com/tcp/4001/p2p/12D3Koo...",
]CLI flags override the config file. No relays are configured by
default — you pick one explicitly. AutoNAT v2 probes test your
reachability against the connected peer pool; DCUtR attempts a
hole-punch upgrade to a direct connection whenever a relayed
connection forms. The Profile pane / sidebar badge shows the current
state (🌐 reachable / 🏠 LAN only / 🔍 detecting…).
Room announcements optionally carry a host_addrs field with up to 4
of the announcer's reachable addresses (relay-circuit and
AutoNAT-confirmed external). Peers receiving an announcement they
have no direct connection for will opportunistically dial the first
listed address (rate-limited per announcer). This lets cross-internet
peers bootstrap without invite links.
Owners press Ctrl+J in a Group pane to generate a single-use,
10-minute XXXX-XXXX code. The owner shares it OOB. The joiner
selects the encrypted group in the sidebar and presses c to enter
the code. The joiner's TUI generates an ephemeral X25519 keypair,
broadcasts a signed CodeJoinRequest, and waits for the owner's
CodeJoinResponse (which wraps the room's session key under an
ECDH-derived key). If no response arrives within 30 s, the TUI
surfaces a timeout error — usually meaning the code was wrong or
expired.
Code-joined members are read-only: they can read and send, but
without the passphrase they can't wrap session keys for newer
joiners. The Group pane header renders (read-only) next to the
encryption marker. To upgrade, an owner can re-onboard them with the
actual passphrase.
Press Alt+Shift+1 (the Option key on macOS — same physical key) from
anywhere — or use the labeled row on the Settings pane — to open the
go dark modal. Single-field gate (huddle 0.7.6+):
- If you have a master passphrase, that's the gate — re-derived via Argon2id and constant-time compared to the in-memory SQLCipher subkey. Wrong passphrase clears the field and shows an inline error.
- In
--no-master-passphrasesessions (no key to compare against), type the literal phraseDELETE EVERYTHING(case sensitive) instead.
On confirm, huddle:
- best-effort
MemberLeaves every joined room (2-second cap so a flapping transport can't hang the wipe), - shuts down the network task,
- zeroes-then-deletes
huddle.db,huddle.db-shm,huddle.db-wal,keychain.salt,huddle.log(and any rotated logs), andconfig.tomlfrom the data dir, - removes the now-empty data dir, and
- shows a brief goodbye modal before exiting.
There is no recovery. Restarting huddle after a go-dark generates a fresh identity from scratch.
huddle/
huddle-core library: rooms, crypto, network, storage
huddle terminal UI (the only frontend)
Networking — libp2p 0.56 with TCP+Noise+Yamux transport, mDNS for LAN discovery, gossipsub for both global room advertisement and per-room message broadcast, identify, ping, request-response, Circuit Relay v2 client, AutoNAT v2 (client + server), DCUtR. Mesh topology — every member of a room receives every message; there's no "host" with special powers, and rooms survive the original creator leaving (as long as someone else is in them). The owner role is client-enforced state, not a network-level privilege.
Encryption — vodozemac Megolm group sessions (one outbound per
peer). For group rooms entered via passphrase, you wrap your session
key with ChaCha20-Poly1305 under an Argon2id key derived from
(passphrase, salt) and broadcast that for every existing member to
pick up. For group rooms entered via code, ECDH between owner and
joiner gives a wrap key that delivers only the owner's session — the
joiner's own outbound goes unwrapped. For DMs (huddle 0.7.1+), the
wrap key comes from an Ed25519→X25519 ECDH between the two parties'
identity keys, expanded with HKDF-SHA256 bound to the canonical room
ID — both peers independently derive the same 32-byte wrap key.
App-level signing — every protocol message whose authenticity
matters (OwnerGrant, BanMember, RotateRoomKey, SAS handshake,
CodeJoinRequest/Response, JoinRefused) is wrapped in a
SignedRoomMessage Ed25519 envelope. Receivers verify the signature,
re-derive the fingerprint from the envelope's pubkey, and gate on
both verified_signer.is_some() and (where applicable) signer-is-owner.
Identity — Ed25519 keypair stored under your platform's data
directory. Fingerprint format: six groups of four hex chars
(a3b1-c2d4-e5f6-7890-1234-abcd).
Storage — SQLCipher (rusqlite + bundled SQLCipher + vendored
OpenSSL). On launch you enter a master passphrase; it's stretched
with Argon2id (m=64 MiB, t=3, p=4) against a per-installation salt
and used as PRAGMA key, plus an HKDF subkey replaces the older
hardcoded Megolm persistence key. Tables include identity,
rooms (with kind ∈ {direct, group}), room_members (with
role, ed25519_pubkey), room_megolm_sessions, room_messages,
room_attachments, known_peers (with fingerprint, trusted),
blocked_peers, room_bans, verified_peers, peer_profiles
(self-declared usernames, signed at the wire layer), app_settings.
Migrations are additive only and tracked via PRAGMA user_version.
Pass --no-master-passphrase to fall back to an unencrypted database
for testing.
File attachments — Ctrl+A opens a local file picker; selected
files are SHA-256-hashed, chunked into 64 KiB pieces, and broadcast
over the room's gossipsub topic with a FileOffer + N FileChunk
messages. In encrypted rooms (DM or group) the bytes are
ChaCha20-Poly1305-encrypted with a fresh file key that's
Megolm-wrapped in the offer. Receivers see a focusable file card in
chat — press f to enter card mode, j/k to step, Enter to save to
your platform's Downloads folder. Phase 2 cap is 1 MiB per file.
- The first launch creates
<data_dir>/keychain.salt. Don't move or delete it without your passphrase backed up — losing it forces a re-derive that won't unlock the existing DB. --no-master-passphraseopens an unencrypted DB. Testing only.--relay <multiaddr>(repeatable) registers a circuit-relay reservation. The relay's identify response is the cue to start listening on<relay>/p2p-circuit.--no-relayignores any relays inconfig.tomlfor this run.
- LAN-only by default. Cross-network use needs a configured relay
(Phase D), an invite link with a public multiaddr, or a manual
ddial to a port-forwardedip:port. - Code-joined members are read-only — they don't have the passphrase and can't onboard further members.
- Kick / ban are honest-client-enforced at the gossipsub layer; the cryptographic teeth come from the key rotation that follows.
- File transfer is capped at 1 MiB per file (Phase 2). Larger files defer to a dedicated libp2p stream protocol (planned).
- mDNS may not work on some corporate / restricted networks.
- Verified-only inbound mode trusts SAS-verified + previously-trusted fingerprints. Don't enable it before you've verified at least one peer you can re-bootstrap from.
- The SAS emoji table follows Matrix MSC 2241 for future cross-client compatibility but is not yet interop-tested against any other client.
- DM end-to-end encryption (huddle 0.7.1) re-derives the room wrap key from both peers' long-term Ed25519 identity keys via X25519 ECDH — it lacks forward secrecy at the room-key layer. A future identity- key compromise unlocks historical DM session keys between those two parties (Megolm message keys still ratchet, but the wrap key doesn't). Per-DM ephemeral ratchets (Double Ratchet-style) are a candidate follow-up.
A short follow-up after independent self-review of the 0.7.11 release caught three issues:
- Notification focus-default trade-off. 0.7.11 flipped the
"haven't observed a focus event yet" default from
false(always notify) totrue(assume focused, suppress). That fixed the audit's spam complaint for tmux-without-focus-events but caused the opposite regression for the same cohort — they got zero notifications instead of all of them. 0.7.12 splits the difference: assume focused during a 5-second startup grace window, then if noFocusGained/FocusLosthas ever fired, fall back tofalse(always notify). Terminals that DO speak focus events behave normally throughout. RelayReservationLostwas dead-wired. 0.7.11 declared the variant and a consumer inapp/mod.rs, but libp2p 0.56'srelay::client::Eventdoesn't expose aReservationReqFailedarm we can match on, so the producer never emitted it. 0.7.12 removes the dead variant and consumer rather than ship code that's silently unreachable. Reservation loss currently manifests as the next AutoNAT probe flipping to "private" once the circuit drops; a future health-check timer can re-introduce a dedicated signal when libp2p's API supports it.- SAS code incompatibility documented. 0.7.11's rejection
sampler is correct, but it produces different emoji codes than
0.7.10's
mod 49derivation in ~84% of pairings. A 0.7.11↔0.7.10 SAS verification will silently fail to match. This is a deliberate break (the new derivation is uniformly distributed; the old one wasn't), but it wasn't called out in the 0.7.11 notes. Both ends need to be on 0.7.11+ for SAS to succeed.
A wide audit pass on top of the 0.7.10 follow-up. The wire protocol, authorization gates, panic surface, modal handling, notifier, storage, clipboard, and SAS derivation all got tightened. Wire compat with 0.7.10 and earlier is broken on purpose — signed envelopes now carry a timestamp and several previously-plain messages now require a signature. The trade was deliberate: the 0.7.10 line had a few silent authentication failures that the audit caught.
MemberLeave,MemberAnnounce, andFileOffermust now arrive inside aSignedRoomMessagewhose signer matches the claimed sender. Pre-0.7.11 these were plain, so any peer subscribed to a room topic could spoof another member's leave (evicting them from honest rosters) or pin a fabricated Ed25519 pubkey under a victim's fingerprint via a TOFU race.SignedRoomMessagegained asigned_at_msfield. The verifier rejects envelopes outside a ±5 min window — closing the indefinite replay of capturedBanMember/OwnerGrant/SasConfirm/ProfileUpdate. The timestamp is signature-bound.- Switched from
Ed25519::verifytoverify_strict, which rejects low-order / mixed-order pubkeys.
- Bumped invite version to 2. v2 invites carry the creator's Ed25519
pubkey + an Ed25519 signature over the rest of the payload. Tampering
with
host_multiaddr,salt_b64,owner_fingerprints, or any other field is now detected before the receiver dials. v=1 invites still decode (with a "this invite is unsigned" hint) so older shared links keep working.
- The ban filter now applies to every content-bearing arm
(
Plain,Encrypted,FileOffer,FileChunk,Typing), not justMemberAnnounce. Banned peers in unencrypted rooms used to keep posting plaintext that honest clients rendered. - The outbound dial-then-auto-DM flow now consults the persistent
blocklist before opening a DM tab. Previously, dialing a blocked
peer's address still triggered
AutoOpenDm. send_filerejects read-only joiners (code-joined peers). Previously the read-only gate only coveredsend_room_message.- The Direct-announcement auto-bootstrap rejects messages from blocked peers before creating a DM row.
now_unixreturns 0 on a backwards clock instead of panicking. The network task used to crash on every encrypt/decrypt when the wall clock sat before 1970 (ARM SBCs without RTC, virt clones).wipe_filewrites zeros in a fixed 64 KiB scratch buffer rather than allocatingvec![0u8; meta.len()]. Go-dark used to OOM mid-wipe when a user had downloaded a multi-GB attachment.bootstrap_direct_roomreturns an error instead of.expect()-ing, so a transient DB write failure can't take down the spawned task.cleanup_expired_pending_friend_requestsusessaturating_subfor the cutoff so anow < TTLclock doesn't match every row.
- Settings → Privacy
copens a confirmation modal before wiping the blocklist. Pre-0.7.11 it cleared everything instantly — one keystroke from total data loss, and the samecopened the join-code modal in the lobby so muscle memory was destructive. - Clipboard yank now runs on a dedicated OS thread with a 2 s
timeout. Previously,
xclip/wl-copywith no display could hang the entire TUI on a routiney. - File-chunk receiver caps per-chunk size (256 KiB), bounds
chunk_index < total_chunks, and tracksbytes_receivedagainst the advertisedexpected_size. Pre-0.7.11 a hostile peer could advertise 1 MiB and stream multi-GB chunks before the SHA gate ran. - DM sidebar "online" dot now compares the partner's fingerprint to
known_peers[i].fingerprintinstead of.label. Every DM showed○offline even when the partner was connected. - The member-margin toggle is now bound to Alt+M (Ctrl+I was unreachable — terminals deliver it as Tab). Hint bar and help screen updated.
- Activity pane
cnow clears the status history, matching the hint text. Previously it fell through toOpenJoinWithCode.
- SAS emoji derivation switched from
mod 49(biased — indices 0..14 were twice as likely) to rejection sampling with HKDF re-expansion. Restores the full uniform distribution over the 49^7 table. - Argon2id-derived passphrase keys returned in a
Zeroizing<[u8; 32]>wrapper so they don't linger on the heap after their last use.
ConnectionClosednow emitsPeerDisconnectedso the lobby's "online" dots clear for relay / internet peers, not just mDNS expiries. Also cleans gossipsub's explicit-peers set.RelayClientevents are no longer swallowed — reservation status surfaces in the logs.- DCUtR failures cap at 6 warn-logs per peer so symmetric-NAT pairs don't spam.
Shift+?now opens the "what's new" card from the sidebar (the cheat sheet advertised this for a while; the handler was missing).- Inside the command palette, Ctrl+N / Ctrl+P navigate the result
list instead of typing literal
n/pinto the filter. Other Ctrl chords inside the palette are dropped instead of corrupting the query. Help/Info/QrIdentity/ShowJoinCode/ShowInvitenow dismiss only onEsc/Enter/q. Pre-0.7.11 any unbound key closed them — reflexive vim-hor?silently dismissed.Modal::Sasno longer cancels on barecorq. Common letters when reading emoji words aloud used to abort the verification.Modal::AttachPickerno longer ascends on bareh(typo hazard).Ctrl+Conly opens the quit-confirm modal when no modal is open. Mid-typing a passphrase / username / GoDark confirmation, an accidental Ctrl+C used to discard the typed buffer.- Settings tab digits 1-4 require pane focus, matching the 0.7.9 Tab/BackTab fix.
- The Onboarding modal degrades gracefully on tiny terminals instead of returning a zero-rect and silently disappearing.
- Migrations now run inside
BEGIN; …; PRAGMA user_version = N; COMMIT;so a partial-batch failure rolls back cleanly. Pre-0.7.11 a mid-migration error left the schema half-applied withuser_versionun-bumped and wedged every subsequent startup. - After
PRAGMA key, we runSELECT count(*) FROM sqlite_masteras a sentinel. A wrong master passphrase now returns a clean "wrong master passphrase, or DB file corrupt" instead of a cryptic downstreamCREATE TABLEerror.
- macOS / Linux / Windows notifier paths strip control characters from titles + bodies. Pre-0.7.11 a peer-controllable room name with a literal CR broke the AppleScript invocation silently.
notify-sendnow passes--category=im.receivedfor proper app-grouping in GNOME Shell / KDE.is_focused()defaults totruewhen no FocusChange event has ever been observed. tmux withoutset -g focus-events onand basic SSH shells no longer fire a desktop notification for every message regardless of focus.
- Removed dead
let r = app.active_room()shadow, unusedTheme.accent_dim, unusedUnreadCounts::unread_count/pending_count. Build now warning-free at warn level. - Mention detection bumped from a 4-hex-char prefix to 8 hex chars, cutting false positives from ~1/65 K to ~1/4 B per token and closing the trivial "include the victim's prefix to bell their terminal" weaponization.
- SAS double-fire race fixed via a
finalizedlatch onSasFlow. - Selected encrypted-room rows in the sidebar now preserve the magenta lock-marker color instead of stomping it to selection-yellow.
- Generate-join-code doc clarified: 31 chars / ~39.6 bits, not 32.
A follow-up to 0.7.9. Dropping the pane-focus gate on Profile's
j/k/y trapped sidebar navigation: when the cursor scrolled into the
Profile sub-item, sync_pane_from_selection live-previewed the pane
(intentional 0.7 design), and the ungated j/k/y handler then stole
every subsequent arrow/letter — so the cursor couldn't reach Direct,
Group, People, Activity, or Settings without Shift+Tab'ing past it.
0.7.10 reinstates the SidebarFocus::Pane gate on j/k/y. Capital-
case E / Q chords stay ungated — they don't conflict with
sidebar nav and the one-keystroke discovery flow is worth keeping.
The People analogy 0.7.9 cited turned out to be sharper than it
looked: People only captures j/k inside the Pending sub-tab, and
reaching Pending requires Tab'ing into the pane first. Profile
auto-switches on selection, so the equivalent gate is "user has
explicitly Shift+→'d into the pane".
A small follow-up patch from a self-review of the 0.7.8 release. No new features; three keybinding bugs that the 0.7.8 ship-checklist missed:
- Tab in Settings no longer swallows the focus toggle. In 0.7.8,
pressing Tab anywhere in
Pane::Settingscycled tabs even when the sidebar was focused, which silently disabled the universal "Tab = toggle sidebar↔pane focus" gesture for users in Settings. 0.7.9 only intercepts Tab / Shift+Tab for tab cycling when the pane itself is focused. From sidebar focus, Tab now correctly moves focus into the pane (one keystroke), then subsequent Tabs cycle. - Profile j/k/y match People's pattern. 0.7.8 required pane focus for the Profile field cursor; People's analogous sublist nav has always worked regardless of focus. 0.7.9 makes Profile consistent — pane-active is enough to claim j/k/y, no separate focus gate.
- Dead
Action::OpenSettingsremoved. The,chord routes throughJumpToSettingsPane(which now resets to the Account tab). The legacyOpenSettingsvariant was unreachable in 0.7.8; removed in 0.7.9 along with its dispatcher.
A round of UX polish that borrows the right things from neighbouring apps
without backsliding on huddle's privacy stance. Three discovery/connection
paths now read as co-equal parallel options instead of "mDNS first,
everything else as fallback", Settings became a tabbed pane that finally
includes the toggles that used to live in config.toml, the Profile pane
copies fields to the OS clipboard, and the People sidebar surfaces
pending friend-request counts where you can actually see them.
-
Three connection paths, equally surfaced. Welcome copy spells out the trio: LAN (mDNS) · direct IP dial · invite link. The Settings → Network tab shows the same three rows with their live status. A new
Mtoggle in Settings → Network lets you disable LAN broadcast entirely for privacy — peers can still reach you over direct dial or invite link with no LAN advertisement (restart-required to apply; flipping aToggle<Mdns>mid-run would have required a behaviour rebuild for negligible benefit). -
Tabbed Settings pane.
Modal::Settingsis gone. Pressing,lands you onPane::Settingswith four tabs cycled via Tab / Shift+Tab or numeric jumps1–4:- Account — username (
E), HD-ID, derived Safety Code (SAFE-XXXX-XXXX-XXXX), QR (Q), replay onboarding (W). - Network — LAN mDNS toggle (
M), reachability badge, listen addresses, relay list fromconfig.toml. - Appearance — placeholder (single read-only
theme: darkrow; light + high-contrast in a future release). - Privacy — verified-only inbound (
V), desktop notifications (N), update check (U), blocked peers (cclears all), and the Go DarkAlt+Shift+1chord.
- Account — username (
-
Copyable identity fields. The Profile pane is now a cursor- navigable list:
j/kmove,ycopies the highlighted field to the OS clipboard. Username, HD-ID, Safety Code, full fingerprint, and every listen address each get their own yankable row. Clipboard helper shells out topbcopy(macOS) /wl-copythenxclip/clip.exe(Windows) — no new crate dependency, failures degrade to a status message instead of crashing. -
Sidebar density. Direct messages and Group rooms each pin a
+ Add Friend/+ New Grouprow at the top so the action is a cursor-and-Enter away, not a chord lookup. Pending friend requests surface twice in the People section: as📩 Nnext to the section header, and as a dedicated row at the top of an expanded section when there's at least one outstanding request. -
Notifications opt-out.
Settings → Privacy → Ntoggles the OS-native toast notifications introduced in 0.7.4. Default ON; turning it OFF skips both the per-message path and the startup catch-up summary. Notifications remain 100% local — the toggle is for users who don't want any signal leaving the terminal at all. -
No protocol changes. Only new local rows in the existing
app_settingsKV table (mdns_enabled,notifications_enabled). Both default to ON so existing users see zero functional change until they opt out.
Three coordinated UX fixes around the "first contact" flow. Dialing a peer now actually opens a chat instead of dead-ending at a connection, the People pane shows real usernames, friend requests survive longer than 15 seconds, and inviting peers to a group no longer requires pasting a link into Signal.
- Dial → DM auto-open. When you initiate a dial (
dIP:port,aHD-ID, or paste-invite), the post-Identify handler now opens (or reuses) a DM with the peer and switches your pane to the newDm(room_id). No more "connected to 192.168.1.5" status with no way to chat. Auto- reconnects and announcement-driven opportunistic dials do NOT trigger this — only paths the user explicitly chose register an address inpending_auto_dm_addrs. - Usernames in Known peers. The People pane's Known sublist now
renders each peer as
username · HD-XXXX-XXXX · address · last, pulling the username from the cachedpeer_profilestable. Falls back to[anonymous] · HD-pendingfor peers we haven't yet seen a signedProfileUpdatefrom. - Row actions actually fire. The People pane header advertises
m message · r reconnect · b block · x forget · u unblock, but those keystrokes were previously hitting the global handlers (e.g.mopened an empty Compose-DM modal instead of DM'ing the selected peer). Now they route to the selection-aware row actions. Tab cycles the sub-tabs (Pending / Known / Verified / Blocked). - Friend requests survive 3 days. Previously an inbound dial modal
auto-rejected (with a
block_peer!) after 15 seconds. Now the 15-second timeout spills the request to a newpending_friend_requeststable and just disconnects the live socket; the user has up to 3 days to Accept (re-dial + trust) or Reject (delete + block) from the People pane's new "Pending requests" sublist. A startup sweep prunes rows older than the TTL. The pane header shows(N pending)so a forgotten request from yesterday is the first thing you see on landing. - Invite picker — pick peers and they get the link auto-DM'd. New
Modal::InvitePicker(Ctrl+I inside a group room; also reachable from the+ Add memberrow pinned at the bottom of the member margin, and from the command palette asinvite peers to room…). Lists candidates in three tiers — Verified (SAS-completed, safest), DM partners (existing trust), Known peers (weakest) — with checkboxes, live/filter, soft-cap of 20 selections per send. Enter sends: each selected peer gets an idempotent DM (start_direct) containing the same invite linkShift+Iproduces.Shift+I(OOB link copy) is unchanged — the picker is purely additive for peers you already have some trust relationship with.
The dial-then-DM auto-open is the load-bearing fix: huddle now behaves the way a "basic social app" intuition expects — add someone, chat with them, invite them places — without users needing to memorize the fingerprint resolution flow under Compose-DM.
A user report surfaced that the 0.5-era two-field Go Dark modal looked
like it "didn't work" even after typing DELETE EVERYTHING. Root cause
was UX, not logic: the modal required filling both a master
passphrase field AND a typed DELETE EVERYTHING field, with Tab to
switch between them. Default focus was the passphrase, so typing
DELETE EVERYTHING straight away put the phrase into the wrong field
and the validation error rendered at the bottom of an already-tall red
modal — easy to miss.
- Single field, mode-aware. Sessions with a master passphrase now
use the passphrase directly as the gate (the natural strong secret
the user already knows — Argon2id-derived, constant-time compared).
--no-master-passphrasesessions keep the typedDELETE EVERYTHINGphrase as their only available gate, since they have no key to compare against. - Loud error feedback. Wrong attempts now render
✗ <reason>with a bold red banner directly above the Enter/Esc hint bar, instead of being buried at the bottom of the modal. The input field also clears on failure so the next attempt starts fresh. - No more
Tab. RemovedGoDarkNextFieldaction and theKeyCode::Tabmapping inside the Go Dark modal arm — single field means nothing to switch to. - New accessor
AppHandle::has_master_passphrase() -> boolso the TUI can pick the right gate at modal-open time without leaking the in-memory subkey.
Self-review of 0.7.4 surfaced four follow-up items. All landed in 0.7.5:
- Conservative initial focus state. 0.7.4 defaulted
focused = true, which suppressed notifications if huddle launched in a terminal that was already in the background (noFocusGainedevent ever fired). 0.7.5 treats "no focus event observed yet" as unfocused — false positives (one extra notification) only. - Sliding catch-up grace. The 5-second post-launch summary window now extends by 2s on every inbound message during the window, capped at a hard 30s ceiling from start. Slow gossipsub backlogs are correctly batched into one summary instead of leaking into per-message alerts.
- Notification rate-limit. A 2-second cooldown coalesces bursts: the first notification in a burst fires immediately with full detail (room / sender / preview); within the next 2s, additional notifications are counted and a single "N more new messages" summary fires when the window closes. Prevents process / thread spam for busy rooms.
- ASCII chord labels.
⌥⇧1keycap glyphs were dropped in favor ofAlt+Shift+1(andOption+Shift+1 on macOScallouts where it helps) — fonts render the Unicode keycaps too inconsistently across terminals. The Mac runtime behavior is unchanged (the⁄glyph and theALT|SHIFT+!event both still trigger Go Dark).
- Desktop notifications when the terminal isn't focused. Every
inbound message fires a native notification (
osascripton macOS,notify-sendon Linux, PowerShell BalloonTip on Windows — no extra dependency) when crossterm reports the terminal as unfocused. Notifications include the room name, sender display name, and a trimmed message preview. When the terminal IS focused, no notification is sent — the message is already on screen and the unread badge does the work. - Catch-up summary on startup. When huddle reopens, messages
received during a 5-second catch-up window are batched into ONE
notification:
huddle · N new messages while you were away. After the window closes, real-time notifications kick in. - Focus reporting via crossterm
EnableFocusChange. Supported by iTerm2, Terminal.app, Alacritty, Kitty, wezterm, Windows Terminal, and GNOME Terminal. On a terminal that doesn't emitFocusGained/FocusLost, the app stays in "focused = true" mode and never fires per-message notifications — graceful degradation. - Go dark rebound to
Alt+Shift+1(Option+Shift+1 on macOS). Plain!was just Shift+1 — one accidental keystroke could open the destructive flow. The Mac chord works out of the box on Terminal.app via the unicode glyph⁄that Option+Shift+1 produces, AND via theALT|SHIFT+!event that Alt-as-Meta terminals emit. On Linux/Windows the same Alt+Shift+1 chord is uncontested. - First-time macOS notification permission prompt. macOS will ask to allow Script Editor (or Terminal) to send notifications the first time huddle fires one. Click Allow once and you're set.
- Focus-jump rebound to
Shift+←/Shift+→. 0.7.2'sCtrl+←/Ctrl+→collided with macOS Mission Control's Move-between-Spaces shortcut (andCmd+←/Cmd+→is Terminal/iTerm2 tab-switching, ruling that out too). Shift+arrows are unclaimed everywhere. - Sidebar cursor is visible again. The previous bg-only highlight
on the selected row used
Color::Rgb(40, 40, 60)which is near-indistinguishable from default terminal bg on Terminal.app. Selected rows now recolor every span's foreground to yellow (warn) when the sidebar is focused, dim text when not — readable on every dark theme. - 2-col gutter between sidebar and pane. Panes with
Borders::NONE(Welcome, Profile) used to render text flush against the sidebar separator line. The outer layout now inserts a 2-column gap before the pane rect, so every pane has visible breathing room. - Settings pane keybindings actually fire. The pane displays
V verified-only / U update check / E username / W replay onboarding / ! go dark— but 0.7.0–0.7.2 only dispatched those inside the Settings modal, leaving the pane rows inert. They now fire from the pane itself. !(go dark) is global. Previously only available from the Settings modal; now reachable from any non-chat pane. The modal's two-factor passphrase + "DELETE EVERYTHING" confirm protects against accidental triggers.
Ctrl+←/Ctrl+→focus jump between sidebar and pane (works from any context, including while typing in chat input). One keystroke instead ofEsc→Tab. macOS users may need to disable Mission Control's Move-left/right-a-space shortcut (System Settings → Keyboard → Keyboard Shortcuts → Mission Control). When focus jumps to a chat pane, the input is auto-activated so you can type immediately.- Settings pane padding fix. The value column was jammed flush
against the label column when a label was exactly 24 chars wide
(
update check (crates.io)onrendered with no gap). Labels now pad to 28 chars, guaranteeing visible whitespace before every value. - Sidebar focus border continues to highlight which region owns the keystrokes (already shipped in 0.7; surfaced more clearly with the new focus-jump bindings).
Direct messages are now end-to-end encrypted on the room layer.
- New
crate::crypto::dm::derive_dm_keyderives a 32-byte room key from one side's Ed25519 secret seed and the other side's Ed25519 public key via X25519 ECDH + HKDF-SHA256. start_directcreates DMs asencrypted = truewith the ECDH-derived key as the Megolm wrap key. The "passphrase salt" slot stores the canonical room_id so re-bootstraps re-derive identically.- When we don't yet have the partner's pubkey (e.g. fingerprint
resolved from a QR / invite / username), the room is created with
no wrap key. The next
MemberAnnouncefrom the partner carries their pubkey; we derive the key lazily, then re-broadcast our ownMemberAnnouncewith the wrapped Megolm session key. - Backward compatibility: DMs created against pre-0.7.1 peers stay
in their original
encrypted=falsemode (the rooms table records it). New 0.7.1+ DMs are always E2E.
0.7.0 rewrote the TUI around a sidebar + pane layout
(Discord/Slack-style), with explicit separation of Direct messages
from Group rooms. The legacy Screen::{Lobby, InRoom} flat-screen
model and the tab-bar were retired.
See TUI layout and Key bindings for the current state. Notable shipped items:
- New
RoomKind::{Direct, Group}persisted on the rooms table;RoomAnnouncement.kind(serde-default for back-compat) tags every wire announcement so 0.7 peers can split DMs from groups. - Canonical DM room IDs:
sha256("huddle-dm-v1\0" || min(fp_a, fp_b) || "\0" || max(fp_a, fp_b))— both peers, regardless of who pressesmfirst, derive identical IDs.start_directis idempotent across both peers and reinstalls. - DM-visibility filter at honest 0.7+ consumers: Direct announcements addressed to anyone else are dropped, so a DM never leaks past the two participants' sidebars.
- 2-member cap enforced locally on
RoomKind::Directrooms. - New panes: Profile, People (known + verified + blocked sublists), Activity (status history + transfers), Settings (toggles, blocked peers, go-dark).
- New
Modal::ComposeDmwith inline autocomplete fromknown_peers+peer_profiles; falls back toAddFriendsemantics on unrecognized input — no modal-on-modal. - Centralized
Thememodule so colors live in one place.
Retired in 0.7: Screen::{Lobby, InRoom}, the tab-bar, numeric
1..9 tab jumps, Ctrl+B (back-to-lobby in chat — Esc focuses
sidebar instead), LobbyFocus (replaced by SidebarFocus), the flat
discovered_rooms list (now split into DM / Group sections).
0.6.0 is a focused UX release. The protocol surface didn't change;
the TUI did.
- Command palette (
:orCtrl+P) — fuzzy-search every action. Drives discoverability without bloating the visible chrome. You no longer need to remembera/d/i/,/c/I/v/!/u/o/^J/^I/^K/^G/^Vto find things. - Notification history (
Ctrl+H) — the last 100 status-bar messages, scrollable, with timestamps. Replaces the "goldfish" status bar where two events in quick succession overwrote each other. - Help is now generated from
input.rs— every keybinding is documented, scroll withj/k. Help is sectioned by context (Lobby / In a room / Card focus / etc.) and can never drift from the actual key map again. - Onboarding versioning — the welcome card now re-fires only the
"what's new in X.Y" page when you upgrade between versions. You can
also replay it any time from
Settings → w. - Pending-modal indicator — when an async event (inbound dial,
rotation, error) arrives behind another modal, the status bar shows
[N pending · Ctrl+H to view]so it never silently disappears. Queue is FIFO and capped at 16. - Adaptive hint bar — the bottom-of-screen hints rotate based on what's most likely to be useful next (empty lobby surfaces "add friend"; unread tab surfaces "join"; etc.).
- Lobby header polish —
huddle 0.6.0version anchor, clock, live peer counter alongside the NAT reachability badge. - Scroll indicator + day separators in chat — the message pane
shows
N/M · live(orN/M · ↑ K above) at the bottom border, and date dividers (─── 2026-05-15 ───) appear when conversations span days. - Unread counts in tabs —
[2] room-name (3)shows the actual count instead of a vague*.R(shift-r) in the lobby zeros every tab at once. - Opt-in update detection — a tiny ureq-backed background task
pings
https://crates.io/api/v1/crates/huddleonce per 24 h. If a newer version exists, a banner appears under the lobby header. OFF by default; toggle viaSettings → Uor the command palette. huddle doctorCLI —huddle doctorprints version, data paths, file sizes, and config without touching the network or asking for the master passphrase. Paste it into bug reports.
cargo test --workspace -- --test-threads=1--test-threads=1 keeps the mDNS-based integration tests from
fighting each other on a single host. The suite covers two-node
plain + encrypted round-trip, Phase A inbound-dial accept and reject,
Phase B kick-and-rotate (3-node), and Phase F code-join. See
MANUAL_TESTING.md for the two-machine checklist.
- macOS:
~/Library/Application Support/huddle/ - Linux:
~/.local/share/huddle/ - Windows:
%APPDATA%\huddle\
Licensed under either of
- Apache License, Version 2.0 (
LICENSE-APACHEor http://www.apache.org/licenses/LICENSE-2.0) - MIT license (
LICENSE-MITor http://opensource.org/licenses/MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.