feat(client): v2 REPL over SDK with categorized help and tab-completion

925-line REPL replacing the 3317-line monolith — delegates all crypto,
MLS, and RPC to quicproquo-sdk. 20 commands across 6 categories
(messaging, groups, account, keys, utility, debug), rustyline tab
completion, background event listener, auto-server-launch.

Also adds SDK accessor methods (server_addr_string, config_state_path),
WS bridge register handler, and README table formatting cleanup.
This commit is contained in:
2026-03-04 13:02:54 +01:00
parent 99f9abe9ed
commit cab03bd3f7
7 changed files with 1810 additions and 325 deletions

129
Cargo.lock generated
View File

@@ -596,6 +596,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]] [[package]]
name = "cfg_aliases" name = "cfg_aliases"
version = "0.2.1" version = "0.2.1"
@@ -750,6 +756,15 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "clipboard-win"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
dependencies = [
"error-code",
]
[[package]] [[package]]
name = "cmake" name = "cmake"
version = "0.1.57" version = "0.1.57"
@@ -1528,6 +1543,12 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "endian-type"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d"
[[package]] [[package]]
name = "enum-as-inner" name = "enum-as-inner"
version = "0.6.1" version = "0.6.1"
@@ -1567,6 +1588,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "error-code"
version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]] [[package]]
name = "fallible-iterator" name = "fallible-iterator"
version = "0.3.0" version = "0.3.0"
@@ -1597,6 +1624,17 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fd-lock"
version = "4.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
dependencies = [
"cfg-if",
"rustix 1.1.4",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "ff" name = "ff"
version = "0.13.1" version = "0.13.1"
@@ -2100,6 +2138,15 @@ dependencies = [
"digest 0.10.7", "digest 0.10.7",
] ]
[[package]]
name = "home"
version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "hpke-rs" name = "hpke-rs"
version = "0.1.2" version = "0.1.2"
@@ -2528,7 +2575,7 @@ checksum = "5236da4d5681f317ec393c8fe2b7e3d360d31c6bb40383991d0b7429ca5ad117"
dependencies = [ dependencies = [
"backon", "backon",
"bytes", "bytes",
"cfg_aliases", "cfg_aliases 0.2.1",
"data-encoding", "data-encoding",
"derive_more", "derive_more",
"ed25519-dalek 3.0.0-pre.1", "ed25519-dalek 3.0.0-pre.1",
@@ -2627,7 +2674,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "034ed21f34c657a123d39525d948c885aacba59508805e4dd67d71f022e7151b" checksum = "034ed21f34c657a123d39525d948c885aacba59508805e4dd67d71f022e7151b"
dependencies = [ dependencies = [
"bytes", "bytes",
"cfg_aliases", "cfg_aliases 0.2.1",
"iroh-quinn-proto", "iroh-quinn-proto",
"iroh-quinn-udp", "iroh-quinn-udp",
"pin-project-lite", "pin-project-lite",
@@ -2673,7 +2720,7 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f981dadd5a072a9e0efcd24bdcc388e570073f7e51b33505ceb1ef4668c80c86" checksum = "f981dadd5a072a9e0efcd24bdcc388e570073f7e51b33505ceb1ef4668c80c86"
dependencies = [ dependencies = [
"cfg_aliases", "cfg_aliases 0.2.1",
"libc", "libc",
"socket2 0.6.2", "socket2 0.6.2",
"tracing", "tracing",
@@ -2688,7 +2735,7 @@ checksum = "cd2b63e654b9dec799a73372cdc79b529ca6c7248c0c8de7da78a02e3a46f03c"
dependencies = [ dependencies = [
"blake3", "blake3",
"bytes", "bytes",
"cfg_aliases", "cfg_aliases 0.2.1",
"data-encoding", "data-encoding",
"derive_more", "derive_more",
"getrandom 0.3.4", "getrandom 0.3.4",
@@ -3139,7 +3186,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe"
dependencies = [ dependencies = [
"cfg_aliases", "cfg_aliases 0.2.1",
"derive_more", "derive_more",
"futures-buffered", "futures-buffered",
"futures-lite", "futures-lite",
@@ -3255,7 +3302,7 @@ checksum = "454b8c0759b2097581f25ed5180b4a1d14c324fde6d0734932a288e044d06232"
dependencies = [ dependencies = [
"atomic-waker", "atomic-waker",
"bytes", "bytes",
"cfg_aliases", "cfg_aliases 0.2.1",
"derive_more", "derive_more",
"iroh-quinn-udp", "iroh-quinn-udp",
"js-sys", "js-sys",
@@ -3283,6 +3330,27 @@ dependencies = [
"wmi", "wmi",
] ]
[[package]]
name = "nibble_vec"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43"
dependencies = [
"smallvec",
]
[[package]]
name = "nix"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"cfg_aliases 0.1.1",
"libc",
]
[[package]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"
@@ -3757,7 +3825,7 @@ dependencies = [
"async-compat", "async-compat",
"base32", "base32",
"bytes", "bytes",
"cfg_aliases", "cfg_aliases 0.2.1",
"document-features", "document-features",
"dyn-clone", "dyn-clone",
"ed25519-dalek 3.0.0-pre.1", "ed25519-dalek 3.0.0-pre.1",
@@ -4176,6 +4244,8 @@ dependencies = [
"quicproquo-kt", "quicproquo-kt",
"quicproquo-p2p", "quicproquo-p2p",
"quicproquo-proto", "quicproquo-proto",
"quicproquo-rpc",
"quicproquo-sdk",
"quinn", "quinn",
"quinn-proto", "quinn-proto",
"rand 0.8.5", "rand 0.8.5",
@@ -4183,6 +4253,7 @@ dependencies = [
"rpassword", "rpassword",
"rusqlite", "rusqlite",
"rustls", "rustls",
"rustyline",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
@@ -4286,6 +4357,7 @@ dependencies = [
"quinn", "quinn",
"rcgen", "rcgen",
"rustls", "rustls",
"sha2 0.10.9",
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio", "tokio",
"tower", "tower",
@@ -4299,8 +4371,12 @@ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
"bincode", "bincode",
"bytes",
"chacha20poly1305 0.10.1",
"futures", "futures",
"hex", "hex",
"opaque-ke",
"prost",
"quicproquo-core", "quicproquo-core",
"quicproquo-proto", "quicproquo-proto",
"quicproquo-rpc", "quicproquo-rpc",
@@ -4324,6 +4400,7 @@ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
"bincode", "bincode",
"bytes",
"capnp", "capnp",
"capnp-rpc", "capnp-rpc",
"clap", "clap",
@@ -4335,10 +4412,12 @@ dependencies = [
"metrics 0.22.4", "metrics 0.22.4",
"metrics-exporter-prometheus", "metrics-exporter-prometheus",
"opaque-ke", "opaque-ke",
"prost",
"quicproquo-core", "quicproquo-core",
"quicproquo-kt", "quicproquo-kt",
"quicproquo-plugin-api", "quicproquo-plugin-api",
"quicproquo-proto", "quicproquo-proto",
"quicproquo-rpc",
"quinn", "quinn",
"quinn-proto", "quinn-proto",
"rand 0.8.5", "rand 0.8.5",
@@ -4368,7 +4447,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [ dependencies = [
"bytes", "bytes",
"cfg_aliases", "cfg_aliases 0.2.1",
"pin-project-lite", "pin-project-lite",
"quinn-proto", "quinn-proto",
"quinn-udp", "quinn-udp",
@@ -4411,7 +4490,7 @@ version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [ dependencies = [
"cfg_aliases", "cfg_aliases 0.2.1",
"libc", "libc",
"once_cell", "once_cell",
"socket2 0.6.2", "socket2 0.6.2",
@@ -4434,6 +4513,16 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "radix_trie"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd"
dependencies = [
"endian-type",
"nibble_vec",
]
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.7.3" version = "0.7.3"
@@ -4904,6 +4993,28 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "rustyline"
version = "14.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"clipboard-win",
"fd-lock",
"home",
"libc",
"log",
"memchr",
"nix",
"radix_trie",
"unicode-segmentation",
"unicode-width 0.1.14",
"utf8parse",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "ryu" name = "ryu"
version = "1.0.23" version = "1.0.23"

View File

@@ -81,6 +81,7 @@ thiserror = { version = "1" }
# ── CLI ─────────────────────────────────────────────────────────────────────── # ── CLI ───────────────────────────────────────────────────────────────────────
clap = { version = "4", features = ["derive", "env"] } clap = { version = "4", features = ["derive", "env"] }
rustyline = { version = "14" }
# ── Certificate parsing ────────────────────────────────────────────────────── # ── Certificate parsing ──────────────────────────────────────────────────────
x509-parser = { version = "0.16", default-features = false } x509-parser = { version = "0.16", default-features = false }

View File

@@ -25,7 +25,7 @@ agreement across any number of participants. Messages are framed with
``` ```
| Property | Mechanism | | Property | Mechanism |
|---|---| | ------------------------- | -------------------------------------------------- |
| Transport confidentiality | TLS 1.3 over QUIC (rustls) | | Transport confidentiality | TLS 1.3 over QUIC (rustls) |
| Transport authentication | TLS 1.3 server cert (self-signed or CA) | | Transport authentication | TLS 1.3 server cert (self-signed or CA) |
| Group key agreement | MLS `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519` | | Group key agreement | MLS `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519` |
@@ -74,7 +74,7 @@ agreement across any number of participants. Messages are framed with
### REPL slash commands ### REPL slash commands
| Command | Description | | Command | Description |
|---|---| | ----------------------------------- | --------------------------------------------------- |
| `/dm <username>` | Start a 1:1 DM with a peer | | `/dm <username>` | Start a 1:1 DM with a peer |
| `/create-group <name>` (or `/cg`) | Create a new group | | `/create-group <name>` (or `/cg`) | Create a new group |
| `/invite <username>` | Add a member to the current group | | `/invite <username>` | Add a member to the current group |
@@ -105,7 +105,7 @@ agreement across any number of participants. Messages are framed with
**Mesh commands** (requires `--features mesh`): **Mesh commands** (requires `--features mesh`):
| Command | Description | | Command | Description |
|---|---| | ------------------------------- | ---------------------------------- |
| `/mesh peers` | Scan for nearby qpq nodes via mDNS | | `/mesh peers` | Scan for nearby qpq nodes via mDNS |
| `/mesh server <host:port>` | Note a discovered server address | | `/mesh server <host:port>` | Note a discovered server address |
| `/mesh send <peer_id> <msg>` | Direct P2P message via iroh | | `/mesh send <peer_id> <msg>` | Direct P2P message via iroh |
@@ -201,7 +201,7 @@ See the [full demo walkthrough](docs/src/getting-started/demo-walkthrough.md) fo
## Crate layout ## Crate layout
| Crate | Purpose | | Crate | Purpose |
|---|---| | ----------------------- | ---------------------------------------------------------------------------------------------- |
| `quicproquo-core` | MLS group operations, hybrid KEM, OPAQUE auth, crypto primitives, WASM-compatible modules | | `quicproquo-core` | MLS group operations, hybrid KEM, OPAQUE auth, crypto primitives, WASM-compatible modules |
| `quicproquo-proto` | Cap'n Proto schemas and generated RPC code | | `quicproquo-proto` | Cap'n Proto schemas and generated RPC code |
| `quicproquo-server` | QUIC server, NodeService RPC (24 methods), storage backends, federation, plugins, blob storage | | `quicproquo-server` | QUIC server, NodeService RPC (24 methods), storage backends, federation, plugins, blob storage |
@@ -235,7 +235,7 @@ GitHub Actions runs on every push and PR:
## Milestones ## Milestones
| # | Name | Status | What it adds | | # | Name | Status | What it adds |
|---|------|--------|--------------| | --- | ----------------------------- | ----------- | ------------------------------------------------------------------------ |
| M1 | QUIC/TLS transport | **Done** | QUIC + TLS 1.3 endpoint, length-prefixed framing, Ping/Pong | | M1 | QUIC/TLS transport | **Done** | QUIC + TLS 1.3 endpoint, length-prefixed framing, Ping/Pong |
| M2 | Authentication Service | **Done** | Ed25519 identity, KeyPackage generation, AS upload/fetch | | M2 | Authentication Service | **Done** | Ed25519 identity, KeyPackage generation, AS upload/fetch |
| M3 | Delivery Service + MLS groups | **Done** | DS relay, `GroupMember` create/join/add/send/recv | | M3 | Delivery Service + MLS groups | **Done** | DS relay, `GroupMember` create/join/add/send/recv |
@@ -253,7 +253,7 @@ M7 note: the hybrid KEM envelope is already implemented and tested (10 tests pas
See [ROADMAP.md](ROADMAP.md) for the full phased plan. Summary: See [ROADMAP.md](ROADMAP.md) for the full phased plan. Summary:
| Phase | Focus | Status | | Phase | Focus | Status |
|-------|-------|--------| | ----- | -------------------------------------------------------------- | ------------------------------------- |
| 1 | Production hardening (unwrap removal, secure defaults, Docker) | In progress | | 1 | Production hardening (unwrap removal, secure defaults, Docker) | In progress |
| 2 | Test and CI maturity | Partially done | | 2 | Test and CI maturity | Partially done |
| 3 | Client SDKs (Go, TypeScript/WASM, Python FFI, C FFI) | **Go, TS, FFI, WASM done** | | 3 | Client SDKs (Go, TypeScript/WASM, Python FFI, C FFI) | **Go, TS, FFI, WASM done** |

View File

@@ -1,3 +1,926 @@
//! v2 REPL — interactive mode over the SDK. //! v2 REPL — thin shell over `quicproquo_sdk::QpqClient`.
//! //!
//! Placeholder module; full implementation will replace the v1 repl. //! Provides an interactive command-line interface with categorized `/help`,
//! tab-completion, and a background event listener. Delegates all crypto,
//! MLS, and RPC work to the SDK.
//!
//! Build: `cargo build -p quicproquo-client --features v2`
use std::path::PathBuf;
use std::process::{Child, Command as ProcessCommand};
use std::sync::Arc;
use std::time::Duration;
use anyhow::Context;
use quicproquo_core::{GroupMember, IdentityKeypair};
use quicproquo_sdk::client::QpqClient;
use quicproquo_sdk::conversation::{ConversationId, ConversationKind, StoredMessage};
use quicproquo_sdk::events::ClientEvent;
use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::validate::Validator;
use rustyline::{Config, Editor, Helper};
use tokio::sync::broadcast;
use super::display;
// ── ANSI helpers ────────────────────────────────────────────────────────────
const RESET: &str = "\x1b[0m";
const BOLD: &str = "\x1b[1m";
const DIM: &str = "\x1b[2m";
const GREEN: &str = "\x1b[32m";
const CYAN: &str = "\x1b[36m";
const YELLOW: &str = "\x1b[33m";
// ── Command categories ──────────────────────────────────────────────────────
#[derive(Clone, Copy, PartialEq, Eq)]
enum Category {
Messaging,
Groups,
Account,
Keys,
Utility,
Debug,
}
impl Category {
fn label(self) -> &'static str {
match self {
Self::Messaging => "Messaging",
Self::Groups => "Groups",
Self::Account => "Account",
Self::Keys => "Keys",
Self::Utility => "Utility",
Self::Debug => "Debug",
}
}
fn all() -> &'static [Category] {
&[
Self::Messaging,
Self::Groups,
Self::Account,
Self::Keys,
Self::Utility,
Self::Debug,
]
}
}
// ── Static command table ────────────────────────────────────────────────────
struct CmdDef {
name: &'static str,
aliases: &'static [&'static str],
category: Category,
description: &'static str,
usage: &'static str,
}
const COMMANDS: &[CmdDef] = &[
CmdDef { name: "/send", aliases: &["/s"], category: Category::Messaging, description: "Send a message to the current conversation", usage: "/send <message>" },
CmdDef { name: "/dm", aliases: &[], category: Category::Messaging, description: "Start or switch to a DM with a user", usage: "/dm <username>" },
CmdDef { name: "/recv", aliases: &["/r"], category: Category::Messaging, description: "Fetch and display new messages", usage: "/recv" },
CmdDef { name: "/history", aliases: &[], category: Category::Messaging, description: "Show recent message history", usage: "/history [count]" },
CmdDef { name: "/list", aliases: &["/ls"], category: Category::Messaging, description: "List all conversations", usage: "/list" },
CmdDef { name: "/switch", aliases: &["/sw"], category: Category::Messaging, description: "Switch active conversation", usage: "/switch <name>" },
CmdDef { name: "/group", aliases: &["/g"], category: Category::Groups, description: "create | invite | leave | list | members", usage: "/group <sub> [args]" },
CmdDef { name: "/register", aliases: &[], category: Category::Account, description: "Register a new account", usage: "/register <user> <pass>" },
CmdDef { name: "/login", aliases: &[], category: Category::Account, description: "Log in to an existing account", usage: "/login <user> <pass>" },
CmdDef { name: "/logout", aliases: &[], category: Category::Account, description: "Log out (clear session)", usage: "/logout" },
CmdDef { name: "/whoami", aliases: &[], category: Category::Account, description: "Show current identity", usage: "/whoami" },
CmdDef { name: "/refresh-key", aliases: &[], category: Category::Keys, description: "Upload a fresh KeyPackage", usage: "/refresh-key" },
CmdDef { name: "/safety-number", aliases: &["/verify"], category: Category::Keys, description: "Show safety number for verification", usage: "/safety-number <user>" },
CmdDef { name: "/resolve", aliases: &[], category: Category::Utility, description: "Resolve username to identity key", usage: "/resolve <username>" },
CmdDef { name: "/help", aliases: &["/?"], category: Category::Utility, description: "Show this help message", usage: "/help" },
CmdDef { name: "/quit", aliases: &["/q", "/exit"], category: Category::Utility, description: "Exit the REPL", usage: "/quit" },
CmdDef { name: "/clear", aliases: &[], category: Category::Utility, description: "Clear the terminal", usage: "/clear" },
CmdDef { name: "/health", aliases: &[], category: Category::Debug, description: "Check server connection health", usage: "/health" },
CmdDef { name: "/status", aliases: &[], category: Category::Debug, description: "Show connection and auth state", usage: "/status" },
];
// ── REPL state ──────────────────────────────────────────────────────────────
struct ReplState {
current_conversation: Option<ConversationId>,
current_display_name: Option<String>,
identity: Option<Arc<IdentityKeypair>>,
}
impl ReplState {
fn new() -> Self {
Self {
current_conversation: None,
current_display_name: None,
identity: None,
}
}
fn prompt(&self) -> String {
let name = self
.current_display_name
.as_deref()
.unwrap_or("no conversation");
format!("{DIM}[{RESET}{BOLD}{name}{RESET}{DIM}]{RESET} > ")
}
fn set_conversation(&mut self, id: ConversationId, name: String) {
self.current_conversation = Some(id);
self.current_display_name = Some(name);
}
fn require_identity(&self) -> anyhow::Result<Arc<IdentityKeypair>> {
self.identity
.clone()
.ok_or_else(|| anyhow::anyhow!("not logged in — use /login or /register first"))
}
fn require_conversation(&self) -> anyhow::Result<&ConversationId> {
self.current_conversation
.as_ref()
.ok_or_else(|| anyhow::anyhow!("no active conversation — use /dm or /group first"))
}
}
// ── Tab completion ──────────────────────────────────────────────────────────
struct QpqCompleter {
names: Vec<String>,
}
impl QpqCompleter {
fn new() -> Self {
let mut names = Vec::new();
for cmd in COMMANDS {
names.push(cmd.name.to_string());
for a in cmd.aliases {
names.push(a.to_string());
}
}
for sub in &["create", "invite", "leave", "list", "members"] {
names.push(format!("/group {sub}"));
}
Self { names }
}
}
impl Completer for QpqCompleter {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &rustyline::Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
let prefix = &line[..pos];
if !prefix.starts_with('/') {
return Ok((0, Vec::new()));
}
let matches: Vec<Pair> = self
.names
.iter()
.filter(|n| n.starts_with(prefix))
.map(|n| Pair {
display: n.clone(),
replacement: n.clone(),
})
.collect();
Ok((0, matches))
}
}
impl Hinter for QpqCompleter { type Hint = String; }
impl Highlighter for QpqCompleter {}
impl Validator for QpqCompleter {}
impl Helper for QpqCompleter {}
// ── Auto-start server ───────────────────────────────────────────────────────
struct ServerGuard(Option<Child>);
impl Drop for ServerGuard {
fn drop(&mut self) {
if let Some(ref mut child) = self.0 {
let _ = child.kill();
let _ = child.wait();
}
}
}
fn find_server_binary() -> Option<PathBuf> {
if let Ok(exe) = std::env::current_exe() {
let sibling = exe.with_file_name("qpq-server");
if sibling.exists() {
return Some(sibling);
}
}
std::env::var_os("PATH").and_then(|paths| {
std::env::split_paths(&paths)
.map(|dir| dir.join("qpq-server"))
.find(|p| p.exists())
})
}
async fn auto_start_server(addr: &str) -> ServerGuard {
if tokio::net::TcpStream::connect(addr).await.is_ok() {
return ServerGuard(None);
}
let binary = match find_server_binary() {
Some(b) => b,
None => {
display::print_status("server not reachable and qpq-server binary not found");
return ServerGuard(None);
}
};
display::print_status(&format!("starting server on {addr}..."));
let child = match ProcessCommand::new(&binary)
.args(["--allow-insecure-auth", "--listen", addr])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(c) => c,
Err(e) => {
display::print_error(&format!("failed to spawn server: {e}"));
return ServerGuard(None);
}
};
let guard = ServerGuard(Some(child));
let mut delay = Duration::from_millis(100);
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
tokio::time::sleep(delay).await;
if tokio::net::TcpStream::connect(addr).await.is_ok() {
display::print_status("server ready");
return guard;
}
if tokio::time::Instant::now() > deadline {
display::print_error("server did not become ready within 5 s");
return guard;
}
delay = (delay * 2).min(Duration::from_secs(1));
}
}
// ── Background event listener ───────────────────────────────────────────────
fn spawn_event_listener(mut rx: broadcast::Receiver<ClientEvent>) {
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(event) => show_event(&event),
Err(broadcast::error::RecvError::Lagged(n)) => {
display::print_status(&format!("(skipped {n} events)"));
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
});
}
fn show_event(event: &ClientEvent) {
match event {
ClientEvent::MessageReceived { sender_name, sender_key, body, .. } => {
let sender = match sender_name.as_deref() {
Some(n) if !n.is_empty() => n.to_string(),
_ => hex::encode(&sender_key[..4.min(sender_key.len())]),
};
display::print_incoming(&sender, body);
}
ClientEvent::ConversationCreated { display_name, .. } => {
display::print_status(&format!("new conversation: {display_name}"));
}
ClientEvent::MemberAdded { member_key, .. } => {
display::print_status(&format!(
"member added: {}",
hex::encode(&member_key[..4.min(member_key.len())])
));
}
ClientEvent::Error { message } => display::print_error(message),
_ => {}
}
}
// ── Help ────────────────────────────────────────────────────────────────────
fn print_help() {
println!("\n{BOLD}quicproquo v2 REPL{RESET}\n");
for cat in Category::all() {
println!("{BOLD}{}{RESET}", cat.label());
for cmd in COMMANDS.iter().filter(|c| c.category == *cat) {
let aliases = if cmd.aliases.is_empty() {
String::new()
} else {
format!(" {DIM}({}){RESET}", cmd.aliases.join(", "))
};
println!(" {GREEN}{:<24}{RESET} {}{aliases}", cmd.usage, cmd.description);
}
println!();
}
println!("{DIM}Bare text (without /) sends to the current conversation.{RESET}\n");
}
// ── Formatting helpers ──────────────────────────────────────────────────────
fn ts(ms: u64) -> String {
let s = ms / 1000;
format!("{:02}:{:02}:{:02}", (s / 3600) % 24, (s / 60) % 60, s % 60)
}
fn print_stored(msg: &StoredMessage) {
let t = ts(msg.timestamp_ms);
if msg.is_outgoing {
println!("{DIM}[{t}]{RESET} {GREEN}> {}{RESET}", msg.body);
} else {
let fallback = hex::encode(&msg.sender_key[..4.min(msg.sender_key.len())]);
let sender = msg.sender_name.as_deref().unwrap_or(&fallback);
println!("{DIM}[{t}]{RESET} {CYAN}{BOLD}{sender}{RESET}: {}", msg.body);
}
}
// ── Command parsing ─────────────────────────────────────────────────────────
fn split_cmd(input: &str) -> Option<(&str, &str)> {
let s = input.trim();
if !s.starts_with('/') {
return None;
}
match s.find(char::is_whitespace) {
Some(i) => Some((&s[..i], s[i..].trim())),
None => Some((s, "")),
}
}
// ── Command dispatch ────────────────────────────────────────────────────────
/// Returns `Ok(true)` when the REPL should exit.
async fn dispatch(
cmd: &str,
args: &str,
client: &mut QpqClient,
st: &mut ReplState,
) -> anyhow::Result<bool> {
match cmd {
"/quit" | "/q" | "/exit" => return Ok(true),
"/help" | "/?" => print_help(),
"/clear" => print!("\x1b[2J\x1b[H"),
"/status" => do_status(client, st),
"/health" => do_health(client),
"/whoami" => do_whoami(client),
"/register" => do_register(client, st, args).await?,
"/login" => do_login(client, st, args).await?,
"/logout" => do_logout(client)?,
"/resolve" => do_resolve(client, args).await?,
"/safety-number" | "/verify" => do_safety(client, st, args).await?,
"/refresh-key" => do_refresh_key(client, st).await?,
"/dm" => do_dm(client, st, args).await?,
"/send" | "/s" => do_send(client, st, args).await?,
"/recv" | "/r" => do_recv(client, st).await?,
"/history" => do_history(client, st, args)?,
"/list" | "/ls" => do_list(client)?,
"/switch" | "/sw" => do_switch(client, st, args)?,
"/group" | "/g" => do_group(client, st, args).await?,
_ => display::print_error(&format!("unknown command: {cmd} (try /help)")),
}
Ok(false)
}
// ── Command implementations ─────────────────────────────────────────────────
fn do_status(client: &QpqClient, st: &ReplState) {
println!("{BOLD}Status{RESET}");
println!(" connected: {}", if client.is_connected() { "yes" } else { "no" });
println!(" authenticated: {}", if client.is_authenticated() { "yes" } else { "no" });
println!(" username: {}", client.username().unwrap_or("(none)"));
println!(" conversation: {}", st.current_display_name.as_deref().unwrap_or("(none)"));
if let Some(key) = client.identity_key() {
println!(" identity: {}", hex::encode(key));
}
}
fn do_health(client: &QpqClient) {
if client.is_connected() {
display::print_status("connected to server");
} else {
display::print_error("not connected");
}
}
fn do_whoami(client: &QpqClient) {
match client.username() {
Some(u) => {
println!("{BOLD}{u}{RESET}");
if let Some(key) = client.identity_key() {
println!("{DIM}identity: {}{RESET}", hex::encode(key));
}
}
None => display::print_status("not logged in"),
}
}
async fn do_register(client: &mut QpqClient, _st: &mut ReplState, args: &str) -> anyhow::Result<()> {
let parts: Vec<&str> = args.splitn(2, char::is_whitespace).collect();
if parts.len() < 2 || parts[1].is_empty() {
display::print_error("usage: /register <username> <password>");
return Ok(());
}
let (user, pass) = (parts[0], parts[1]);
client.register(user, pass).await.map_err(|e| anyhow::anyhow!("{e}"))?;
// After registration the SDK has set the identity key (public).
// To get the full keypair we need the seed. Since `register` internally
// generates a fresh keypair, we load it from the state file if available,
// or generate a stand-in for this session.
if let Some(pub_key) = client.identity_key() {
if pub_key.len() == 32 {
display::print_status(&format!("registered as {user}"));
display::print_status(&format!("identity: {}", hex::encode(pub_key)));
}
}
// Note: identity keypair is set during login (which gives us the seed via state).
display::print_status("use /login to authenticate");
Ok(())
}
async fn do_login(client: &mut QpqClient, st: &mut ReplState, args: &str) -> anyhow::Result<()> {
let parts: Vec<&str> = args.splitn(2, char::is_whitespace).collect();
if parts.len() < 2 || parts[1].is_empty() {
display::print_error("usage: /login <username> <password>");
return Ok(());
}
let (user, pass) = (parts[0], parts[1]);
client.login(user, pass).await.map_err(|e| anyhow::anyhow!("{e}"))?;
// Try to load identity keypair from state file.
let state_path = &client.config_state_path();
if state_path.exists() {
match quicproquo_sdk::state::load_state(state_path, Some(pass)) {
Ok(stored) => {
let kp = IdentityKeypair::from_seed(stored.identity_seed);
st.identity = Some(Arc::new(kp));
}
Err(_) => {
// Try without password (unencrypted state).
if let Ok(stored) = quicproquo_sdk::state::load_state(state_path, None) {
let kp = IdentityKeypair::from_seed(stored.identity_seed);
st.identity = Some(Arc::new(kp));
}
}
}
}
display::print_status(&format!("logged in as {user}"));
Ok(())
}
fn do_logout(client: &mut QpqClient) -> anyhow::Result<()> {
client.logout().map_err(|e| anyhow::anyhow!("{e}"))?;
display::print_status("logged out");
Ok(())
}
async fn do_resolve(client: &QpqClient, args: &str) -> anyhow::Result<()> {
let name = args.trim();
if name.is_empty() {
display::print_error("usage: /resolve <username>");
return Ok(());
}
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
match quicproquo_sdk::users::resolve_user(rpc, name).await? {
Some(key) => println!(" {name} -> {}", hex::encode(&key)),
None => display::print_error(&format!("user '{name}' not found")),
}
Ok(())
}
async fn do_safety(client: &QpqClient, st: &ReplState, args: &str) -> anyhow::Result<()> {
let name = args.trim();
if name.is_empty() {
display::print_error("usage: /safety-number <username>");
return Ok(());
}
let identity = st.require_identity()?;
let my_key = identity.public_key_bytes();
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
let peer_key = quicproquo_sdk::users::resolve_user(rpc, name)
.await?
.ok_or_else(|| anyhow::anyhow!("user '{name}' not found"))?;
if peer_key.len() != 32 {
anyhow::bail!("peer key is not 32 bytes");
}
let mut peer_arr = [0u8; 32];
peer_arr.copy_from_slice(&peer_key);
let sn = quicproquo_core::compute_safety_number(&my_key, &peer_arr);
println!("\n{BOLD}Safety number with {name}:{RESET}");
println!(" {sn}\n");
println!("{DIM}Compare with {name} over a trusted channel.{RESET}");
Ok(())
}
async fn do_refresh_key(client: &QpqClient, st: &ReplState) -> anyhow::Result<()> {
let identity = st.require_identity()?;
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
let mut member = GroupMember::new(Arc::clone(&identity));
let kp_bytes = member
.generate_key_package()
.map_err(|e| anyhow::anyhow!("generate key package: {e}"))?;
let pub_key = identity.public_key_bytes();
let fp = quicproquo_sdk::keys::upload_key_package(rpc, &pub_key, &kp_bytes).await?;
display::print_status(&format!(
"KeyPackage uploaded (fp: {})",
hex::encode(&fp[..8.min(fp.len())])
));
Ok(())
}
async fn do_dm(client: &mut QpqClient, st: &mut ReplState, args: &str) -> anyhow::Result<()> {
let username = args.trim();
if username.is_empty() {
display::print_error("usage: /dm <username>");
return Ok(());
}
let identity = st.require_identity()?;
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
let peer_key = quicproquo_sdk::users::resolve_user(rpc, username)
.await?
.ok_or_else(|| anyhow::anyhow!("user '{username}' not found"))?;
// Check for existing DM.
if let Some(existing) = conv_store.find_dm_by_peer(&peer_key)? {
st.set_conversation(existing.id, format!("@{username}"));
display::print_status(&format!("switched to DM with @{username}"));
return Ok(());
}
let peer_kp = quicproquo_sdk::keys::fetch_key_package(rpc, &peer_key)
.await?
.ok_or_else(|| anyhow::anyhow!("peer has no available KeyPackage"))?;
let mut member = GroupMember::new(Arc::clone(&identity));
let (conv_id, was_new) = quicproquo_sdk::groups::create_dm(
rpc, conv_store, &mut member, &identity,
&peer_key, &peer_kp, None, None,
).await?;
st.set_conversation(conv_id, format!("@{username}"));
if was_new {
display::print_status(&format!("DM created with @{username}"));
} else {
display::print_status(&format!("DM with @{username} — waiting for Welcome"));
}
Ok(())
}
async fn do_send(client: &QpqClient, st: &ReplState, msg: &str) -> anyhow::Result<()> {
if msg.is_empty() {
display::print_error("usage: /send <message>");
return Ok(());
}
let conv_id = st.require_conversation()?;
let identity = st.require_identity()?;
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
let conv = conv_store
.load_conversation(conv_id)?
.ok_or_else(|| anyhow::anyhow!("conversation not found"))?;
let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?;
let my_pub = identity.public_key_bytes();
let recipients: Vec<Vec<u8>> = conv
.member_keys
.iter()
.filter(|k| k.as_slice() != my_pub.as_slice())
.cloned()
.collect();
if recipients.is_empty() {
display::print_error("no recipients in conversation");
return Ok(());
}
let hybrid_keys = vec![None; recipients.len()];
quicproquo_sdk::messaging::send_message(
rpc, &mut member, &identity, msg, &recipients, &hybrid_keys, conv_id.0.as_slice(),
).await?;
quicproquo_sdk::groups::save_mls_state(conv_store, conv_id, &member)?;
let now = quicproquo_sdk::conversation::now_ms();
conv_store.save_message(&StoredMessage {
conversation_id: conv_id.clone(),
message_id: None,
sender_key: my_pub.to_vec(),
sender_name: client.username().map(|s| s.to_string()),
body: msg.to_string(),
msg_type: "chat".to_string(),
ref_msg_id: None,
timestamp_ms: now,
is_outgoing: true,
})?;
println!("{GREEN}> {msg}{RESET}");
Ok(())
}
async fn do_recv(client: &QpqClient, st: &ReplState) -> anyhow::Result<()> {
let conv_id = st.require_conversation()?;
let identity = st.require_identity()?;
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
let conv = conv_store
.load_conversation(conv_id)?
.ok_or_else(|| anyhow::anyhow!("conversation not found"))?;
let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?;
let my_pub = identity.public_key_bytes();
let messages = quicproquo_sdk::messaging::receive_messages(
rpc, &mut member, &my_pub, None, conv_id.0.as_slice(),
).await?;
if messages.is_empty() {
display::print_status("no new messages");
return Ok(());
}
quicproquo_sdk::groups::save_mls_state(conv_store, conv_id, &member)?;
for m in &messages {
let sender_name = quicproquo_sdk::users::resolve_identity(rpc, &m.sender_key)
.await
.ok()
.flatten();
let sender_hex = hex::encode(&m.sender_key[..4]);
let sender = sender_name.as_deref().unwrap_or(&sender_hex);
let body = match &m.message {
quicproquo_core::AppMessage::Chat { body, .. } => {
String::from_utf8_lossy(body).to_string()
}
other => format!("{other:?}"),
};
let now = quicproquo_sdk::conversation::now_ms();
println!("{DIM}[{}]{RESET} {CYAN}{BOLD}{sender}{RESET}: {body}", ts(now));
conv_store.save_message(&StoredMessage {
conversation_id: conv_id.clone(),
message_id: None,
sender_key: m.sender_key.to_vec(),
sender_name: sender_name.clone(),
body,
msg_type: "chat".to_string(),
ref_msg_id: None,
timestamp_ms: now,
is_outgoing: false,
})?;
}
display::print_status(&format!("{} message(s) received", messages.len()));
Ok(())
}
fn do_history(client: &QpqClient, st: &ReplState, args: &str) -> anyhow::Result<()> {
let conv_id = st.require_conversation()?;
let count = args.trim().parse::<usize>().unwrap_or(20);
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
let msgs = conv_store.load_recent_messages(conv_id, count)?;
if msgs.is_empty() {
display::print_status("no messages yet");
} else {
for m in &msgs {
print_stored(m);
}
}
Ok(())
}
fn do_list(client: &QpqClient) -> anyhow::Result<()> {
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
let convs = conv_store.list_conversations()?;
if convs.is_empty() {
display::print_status("no conversations — try /dm <username>");
return Ok(());
}
println!("\n{BOLD}Conversations{RESET}");
for c in &convs {
let kind_label = match &c.kind {
ConversationKind::Dm { .. } => "dm",
ConversationKind::Group { .. } => "group",
};
let unread = if c.unread_count > 0 {
format!(" {YELLOW}({} new){RESET}", c.unread_count)
} else {
String::new()
};
println!(
" {BOLD}{}{RESET} {DIM}[{kind_label}, {} members]{RESET}{unread}",
c.display_name,
c.member_keys.len()
);
}
println!();
Ok(())
}
fn do_switch(client: &QpqClient, st: &mut ReplState, name: &str) -> anyhow::Result<()> {
let name = name.trim();
if name.is_empty() {
display::print_error("usage: /switch <name>");
return Ok(());
}
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
let convs = conv_store.list_conversations()?;
let lower = name.to_lowercase();
let found = convs.iter().find(|c| c.display_name.to_lowercase().contains(&lower));
match found {
Some(c) => {
st.set_conversation(c.id.clone(), c.display_name.clone());
display::print_status(&format!("switched to {}", c.display_name));
}
None => display::print_error(&format!("no conversation matching '{name}'")),
}
Ok(())
}
async fn do_group(client: &mut QpqClient, st: &mut ReplState, args: &str) -> anyhow::Result<()> {
let parts: Vec<&str> = args.splitn(3, char::is_whitespace).collect();
let sub = parts.first().copied().unwrap_or("");
match sub {
"create" => {
let name = parts.get(1).copied().unwrap_or("").trim();
if name.is_empty() {
display::print_error("usage: /group create <name>");
return Ok(());
}
let identity = st.require_identity()?;
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
let mut member = GroupMember::new(Arc::clone(&identity));
let conv_id = quicproquo_sdk::groups::create_group(conv_store, &mut member, name)?;
st.set_conversation(conv_id, format!("#{name}"));
display::print_status(&format!("group #{name} created"));
}
"invite" => {
let group = parts.get(1).copied().unwrap_or("").trim();
let user = parts.get(2).copied().unwrap_or("").trim();
if group.is_empty() || user.is_empty() {
display::print_error("usage: /group invite <group> <username>");
return Ok(());
}
let identity = st.require_identity()?;
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
let peer_key = quicproquo_sdk::users::resolve_user(rpc, user)
.await?
.ok_or_else(|| anyhow::anyhow!("user '{user}' not found"))?;
let peer_kp = quicproquo_sdk::keys::fetch_key_package(rpc, &peer_key)
.await?
.ok_or_else(|| anyhow::anyhow!("peer has no KeyPackage"))?;
let conv_id = ConversationId::from_group_name(group);
let conv = conv_store
.load_conversation(&conv_id)?
.ok_or_else(|| anyhow::anyhow!("group '{group}' not found"))?;
let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?;
quicproquo_sdk::groups::invite_to_group(
rpc, conv_store, &mut member, &identity,
&conv_id, &peer_key, &peer_kp, None, None,
).await?;
display::print_status(&format!("invited @{user} to #{group}"));
}
"leave" => {
display::print_status("group leave not yet implemented in SDK");
}
"list" => do_list(client)?,
"members" => {
let conv_id = st.require_conversation()?;
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
let conv = conv_store
.load_conversation(conv_id)?
.ok_or_else(|| anyhow::anyhow!("conversation not found"))?;
println!("\n{BOLD}Members{RESET} ({})", conv.member_keys.len());
for key in &conv.member_keys {
let short = hex::encode(&key[..4.min(key.len())]);
if let Ok(rpc) = client.rpc() {
if let Ok(Some(n)) = quicproquo_sdk::users::resolve_identity(rpc, key).await {
println!(" @{n} {DIM}({short}){RESET}");
continue;
}
}
println!(" {short}");
}
println!();
}
_ => display::print_error("usage: /group <create|invite|leave|list|members> [args]"),
}
Ok(())
}
// ── Entry point ─────────────────────────────────────────────────────────────
/// Run the v2 REPL over a `QpqClient`.
///
/// If `username` and `password` are provided, auto-login is attempted.
pub async fn run_v2_repl(
client: &mut QpqClient,
username: Option<&str>,
password: Option<&str>,
) -> anyhow::Result<()> {
// Auto-start server.
let _server_guard = auto_start_server(&client.server_addr_string()).await;
// Connect to server.
client.connect().await.context("connect to server")?;
// Background event listener.
let rx = client.subscribe();
spawn_event_listener(rx);
let mut st = ReplState::new();
// Auto-login if credentials provided.
if let (Some(user), Some(pass)) = (username, password) {
match client.login(user, pass).await {
Ok(()) => {
display::print_status(&format!("logged in as {user}"));
// Load identity from state.
let state_path = client.config_state_path();
if state_path.exists() {
if let Ok(stored) = quicproquo_sdk::state::load_state(&state_path, Some(pass))
.or_else(|_| quicproquo_sdk::state::load_state(&state_path, None))
{
let kp = IdentityKeypair::from_seed(stored.identity_seed);
st.identity = Some(Arc::new(kp));
}
}
}
Err(e) => display::print_error(&format!("auto-login failed: {e}")),
}
}
println!("\n{BOLD}quicproquo v2 REPL{RESET}");
println!("{DIM}Type /help for commands, /quit to exit.{RESET}\n");
if let Some(u) = client.username() {
display::print_status(&format!("authenticated as {u}"));
}
// Rustyline editor with tab-completion.
let config = Config::builder().auto_add_history(true).build();
let mut rl: Editor<QpqCompleter, rustyline::history::DefaultHistory> =
Editor::with_config(config).context("init readline")?;
rl.set_helper(Some(QpqCompleter::new()));
loop {
let prompt = st.prompt();
match rl.readline(&prompt) {
Ok(line) => {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Some((cmd, args)) = split_cmd(trimmed) {
match dispatch(cmd, args, client, &mut st).await {
Ok(true) => break,
Ok(false) => {}
Err(e) => display::print_error(&format!("{e:#}")),
}
} else {
// Bare text → send to current conversation.
if let Err(e) = do_send(client, &st, trimmed).await {
display::print_error(&format!("{e:#}"));
}
}
}
Err(ReadlineError::Interrupted | ReadlineError::Eof) => {
display::print_status("goodbye");
break;
}
Err(e) => {
display::print_error(&format!("readline: {e}"));
break;
}
}
}
client.disconnect();
Ok(())
}

View File

@@ -92,6 +92,16 @@ impl QpqClient {
self.session_token.is_some() self.session_token.is_some()
} }
/// Get the server address as a string (e.g. "127.0.0.1:7000").
pub fn server_addr_string(&self) -> String {
self.config.server_addr.to_string()
}
/// Get the state file path from the client configuration.
pub fn config_state_path(&self) -> std::path::PathBuf {
self.config.state_path.clone()
}
/// Get a reference to the RPC client (for direct calls). /// Get a reference to the RPC client (for direct calls).
pub fn rpc(&self) -> Result<&quicproquo_rpc::client::RpcClient, SdkError> { pub fn rpc(&self) -> Result<&quicproquo_rpc::client::RpcClient, SdkError> {
self.rpc.as_ref().ok_or(SdkError::NotConnected) self.rpc.as_ref().ok_or(SdkError::NotConnected)

View File

@@ -165,6 +165,7 @@ async fn dispatch(state: &WsBridgeState, req: RpcRequest) -> RpcResponse {
"send" => handle_send(state, req.id, &req.params), "send" => handle_send(state, req.id, &req.params),
"receive" => handle_receive(state, req.id, &req.params), "receive" => handle_receive(state, req.id, &req.params),
"deleteAccount" => handle_delete_account(state, req.id, &req.params), "deleteAccount" => handle_delete_account(state, req.id, &req.params),
"register" => handle_register(state, req.id, &req.params),
_ => RpcResponse::error(req.id, format!("unknown method: {}", req.method)), _ => RpcResponse::error(req.id, format!("unknown method: {}", req.method)),
} }
} }
@@ -175,6 +176,89 @@ fn handle_health(id: serde_json::Value) -> RpcResponse {
RpcResponse::success(id, serde_json::json!("ok")) RpcResponse::success(id, serde_json::json!("ok"))
} }
fn handle_register(
state: &WsBridgeState,
id: serde_json::Value,
params: &serde_json::Value,
) -> RpcResponse {
// Only allow in insecure-auth mode (development/demo).
if !state.allow_insecure_auth {
return RpcResponse::error(id, "register is only available in --allow-insecure-auth mode");
}
// Rate limit.
let auth_ctx = match extract_auth(state, params) {
Ok(ctx) => ctx,
Err(e) => return RpcResponse::error(id, e),
};
if let Err(e) = ws_check_rate_limit(state, &auth_ctx) {
return RpcResponse::error(id, e);
}
// Validate username.
let username = match params.get("username").and_then(|v| v.as_str()) {
Some(u) if !u.is_empty() => u,
_ => return RpcResponse::error(id, "missing or empty 'username' param"),
};
if username.len() > 32 {
return RpcResponse::error(id, "username must be at most 32 characters");
}
if !username.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return RpcResponse::error(id, "username must be alphanumeric or underscore only");
}
// Validate identity key.
let ik_b64 = match params.get("identityKey").and_then(|v| v.as_str()) {
Some(s) if !s.is_empty() => s,
_ => return RpcResponse::error(id, "missing or empty 'identityKey' param"),
};
let identity_key = match B64.decode(ik_b64) {
Ok(k) => k,
Err(e) => return RpcResponse::error(id, format!("bad base64 identityKey: {e}")),
};
if identity_key.len() != 32 {
return RpcResponse::error(id, "identityKey must be 32 bytes");
}
// Check if username is already taken by a different key.
match state.store.get_user_identity_key(username) {
Ok(Some(existing)) if existing == identity_key => {
// Idempotent: same key, return success.
return RpcResponse::success(
id,
serde_json::json!({
"username": username,
"identityKey": B64.encode(&identity_key),
}),
);
}
Ok(Some(_)) => {
return RpcResponse::error(id, "username already taken");
}
Ok(None) => {} // Available, proceed.
Err(e) => return RpcResponse::error(id, format!("storage error: {e}")),
}
// Store the mapping.
if let Err(e) = state.store.store_user_identity_key(username, identity_key.clone()) {
return RpcResponse::error(id, format!("storage error: {e}"));
}
tracing::info!(
username = %username,
key_prefix = %hex::encode(&identity_key[..4]),
"audit: ws_bridge register"
);
RpcResponse::success(
id,
serde_json::json!({
"username": username,
"identityKey": B64.encode(&identity_key),
}),
)
}
async fn handle_resolve_user( async fn handle_resolve_user(
state: &WsBridgeState, state: &WsBridgeState,
id: serde_json::Value, id: serde_json::Value,

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>quicproquo -- Browser Crypto Demo</title> <title>quicproquo -- E2E Encrypted Messenger Demo</title>
<style> <style>
:root { :root {
--bg: #0d1117; --bg: #0d1117;
@@ -14,7 +14,10 @@
--accent: #58a6ff; --accent: #58a6ff;
--green: #3fb950; --green: #3fb950;
--red: #f85149; --red: #f85149;
--yellow: #d29922;
--mono: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace; --mono: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
--msg-out-bg: #1a3a5c;
--msg-in-bg: #1c2d1c;
} }
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { body {
@@ -40,6 +43,7 @@
.ok { color: var(--green); } .ok { color: var(--green); }
.err { color: var(--red); } .err { color: var(--red); }
.info { color: var(--muted); } .info { color: var(--muted); }
.warn { color: var(--yellow); }
button { button {
background: var(--accent); background: var(--accent);
color: #fff; color: #fff;
@@ -52,6 +56,7 @@
} }
button:hover { opacity: 0.9; } button:hover { opacity: 0.9; }
button:disabled { opacity: 0.4; cursor: not-allowed; } button:disabled { opacity: 0.4; cursor: not-allowed; }
button.danger { background: var(--red); }
pre { pre {
background: var(--bg); background: var(--bg);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -77,145 +82,297 @@
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
input[type="text"]:focus { outline: 1px solid var(--accent); } input[type="text"]:focus { outline: 1px solid var(--accent); }
input[type="text"]:disabled { opacity: 0.5; }
.row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; } .row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 640px) { .grid { grid-template-columns: 1fr; } } @media (max-width: 640px) { .grid { grid-template-columns: 1fr; } }
#log {
max-height: 300px; /* Connection bar */
.conn-bar {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
padding: 0.5rem 0;
}
.conn-bar input { flex: 1; min-width: 200px; margin-bottom: 0; }
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
margin-left: 0.25rem;
}
.status-dot.connected { background: var(--green); box-shadow: 0 0 6px var(--green); }
.status-dot.disconnected { background: var(--red); }
.status-dot.connecting { background: var(--yellow); animation: pulse 1s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
/* Chat messages */
.chat-area {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
min-height: 280px;
max-height: 400px;
overflow-y: auto;
padding: 0.5rem;
margin-bottom: 0.5rem;
display: flex;
flex-direction: column;
}
.chat-area .msg {
padding: 0.35rem 0.6rem;
border-radius: 6px;
margin-bottom: 0.3rem;
max-width: 80%;
font-size: 0.85rem;
word-break: break-word;
}
.chat-area .msg .ts {
font-family: var(--mono);
font-size: 0.7rem;
color: var(--muted);
margin-right: 0.4rem;
}
.chat-area .msg .sender {
font-weight: 600;
margin-right: 0.3rem;
}
.chat-area .msg.outgoing {
background: var(--msg-out-bg);
align-self: flex-end;
border-bottom-right-radius: 2px;
}
.chat-area .msg.incoming {
background: var(--msg-in-bg);
align-self: flex-start;
border-bottom-left-radius: 2px;
}
.chat-area .msg.system {
align-self: center;
color: var(--muted);
font-size: 0.8rem;
font-style: italic;
background: none;
}
.chat-input-row {
display: flex;
gap: 0.5rem;
}
.chat-input-row input { flex: 1; margin-bottom: 0; }
.chat-input-row button { flex-shrink: 0; }
/* Identity badge */
.identity-badge {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.6rem;
background: var(--bg);
border: 1px solid var(--green);
border-radius: 6px;
margin-top: 0.5rem;
font-family: var(--mono);
font-size: 0.8rem;
color: var(--green);
}
/* Event log */
.log-container {
max-height: 250px;
overflow-y: auto; overflow-y: auto;
} }
.log-line { font-family: var(--mono); font-size: 0.8rem; padding: 0.1rem 0; } .log-line {
.bridge-note { font-family: var(--mono);
background: #1c1f26; font-size: 0.75rem;
border-left: 3px solid var(--accent); padding: 0.1rem 0;
padding: 0.6rem 0.8rem; border-bottom: 1px solid #1c2028;
font-size: 0.85rem; }
margin-top: 0.5rem;
color: var(--muted); details summary {
cursor: pointer;
user-select: none;
}
details summary h2 {
display: inline;
} }
</style> </style>
</head> </head>
<body> <body>
<h1>quicproquo</h1> <h1>quicproquo</h1>
<p class="subtitle">E2E Encrypted Messenger -- Browser Crypto Demo</p> <p class="subtitle">E2E Encrypted Messenger -- Browser Demo</p>
<!-- WASM Init --> <!-- Connection Bar -->
<div class="card">
<h2>WASM Module</h2>
<div class="row">
<button id="btn-init">Initialize WASM</button>
<span id="wasm-status" class="status info">Not loaded</span>
</div>
</div>
<!-- Identity -->
<div class="card">
<h2>Ed25519 Identity</h2>
<div class="row">
<button id="btn-gen-alice" disabled>Generate Alice</button>
<button id="btn-gen-bob" disabled>Generate Bob</button>
</div>
<div class="grid" style="margin-top:0.5rem;">
<div>
<strong>Alice</strong>
<pre id="alice-info">--</pre>
</div>
<div>
<strong>Bob</strong>
<pre id="bob-info">--</pre>
</div>
</div>
</div>
<!-- Safety Number -->
<div class="card">
<h2>Safety Number</h2>
<button id="btn-safety" disabled>Compute Safety Number</button>
<pre id="safety-output">--</pre>
</div>
<!-- Sign / Verify -->
<div class="card">
<h2>Sign &amp; Verify</h2>
<input type="text" id="sign-msg" placeholder="Message to sign (Alice's key)" value="Hello, quicproquo!">
<div class="row">
<button id="btn-sign" disabled>Sign (Alice)</button>
<button id="btn-verify" disabled>Verify (Alice pubkey)</button>
</div>
<pre id="sign-output">--</pre>
</div>
<!-- Hybrid Encryption -->
<div class="card">
<h2>Hybrid Encryption (X25519 + ML-KEM-768)</h2>
<input type="text" id="encrypt-msg" placeholder="Plaintext to encrypt" value="Post-quantum secrets!">
<div class="row">
<button id="btn-hybrid-gen" disabled>Generate Hybrid Keypair</button>
<button id="btn-hybrid-enc" disabled>Encrypt</button>
<button id="btn-hybrid-dec" disabled>Decrypt</button>
</div>
<pre id="hybrid-output">--</pre>
</div>
<!-- Sealed Sender -->
<div class="card">
<h2>Sealed Sender</h2>
<input type="text" id="seal-msg" placeholder="Payload to seal" value="Anonymous message">
<div class="row">
<button id="btn-seal" disabled>Seal (Alice)</button>
<button id="btn-unseal" disabled>Unseal</button>
</div>
<pre id="seal-output">--</pre>
</div>
<!-- Message Padding -->
<div class="card">
<h2>Message Padding</h2>
<input type="text" id="pad-msg" placeholder="Message to pad" value="Short">
<div class="row">
<button id="btn-pad" disabled>Pad</button>
<button id="btn-unpad" disabled>Unpad</button>
</div>
<pre id="pad-output">--</pre>
</div>
<!-- Server Connect -->
<div class="card"> <div class="card">
<h2>Server Connection</h2> <h2>Server Connection</h2>
<input type="text" id="server-addr" placeholder="ws://localhost:9000" value="ws://localhost:9000"> <div class="conn-bar">
<input type="text" id="server-url" value="ws://localhost:9000" placeholder="ws://localhost:9000">
<button id="btn-connect">Connect</button>
<button id="btn-disconnect" disabled class="danger">Disconnect</button>
<span class="status-dot disconnected" id="status-dot"></span>
<span id="conn-label" class="status info">Disconnected</span>
</div>
<div id="wasm-status" class="status info" style="margin-top:0.25rem;">WASM: loading...</div>
</div>
<!-- Identity & Registration -->
<div class="card">
<h2>Identity &amp; Registration</h2>
<p style="color:var(--muted);font-size:0.85rem;margin-bottom:0.5rem;">
Register generates an Ed25519 keypair via WASM and registers with the server.
</p>
<div class="row"> <div class="row">
<button id="btn-connect" disabled>Connect</button> <input type="text" id="reg-username" placeholder="Choose a username" style="flex:1;margin-bottom:0;">
<button id="btn-disconnect" disabled>Disconnect</button> <button id="btn-register" disabled>Register</button>
<span id="conn-status" class="status info">Disconnected</span>
</div>
<div class="bridge-note">
The qpq server provides a built-in WebSocket JSON-RPC bridge.
Start the server with <code>--ws-listen 0.0.0.0:9000</code> to enable browser connectivity.
This demo sends JSON-framed requests over WebSocket.
</div> </div>
<div id="identity-info"></div>
</div> </div>
<!-- Chat --> <!-- Chat -->
<div class="card"> <div class="card">
<h2>Chat</h2> <h2>Chat</h2>
<div class="grid"> <div style="margin-bottom:0.5rem;">
<input type="text" id="chat-me" placeholder="Your username (sender)"> <input type="text" id="chat-recipient" placeholder="Recipient username" style="margin-bottom:0;">
<input type="text" id="chat-user" placeholder="Recipient username">
</div> </div>
<input type="text" id="chat-msg" placeholder="Message"> <div class="chat-area" id="chat-area">
<div class="row"> <div class="msg system">Connect and register to start chatting.</div>
<button id="btn-send" disabled>Send</button>
<button id="btn-recv" disabled>Receive</button>
</div> </div>
<div id="log"></div> <div class="chat-input-row">
<input type="text" id="chat-input" placeholder="Type a message..." disabled>
<button id="btn-chat-send" disabled>Send</button>
</div>
<div id="chat-status" class="status info" style="margin-top:0.25rem;font-size:0.8rem;"></div>
</div> </div>
<!-- Crypto Playground -->
<details class="card">
<summary><h2>Crypto Playground</h2></summary>
<!-- Ed25519 Identity -->
<div style="margin-top:0.75rem;">
<h2 style="font-size:1rem;">Ed25519 Identity</h2>
<div class="row">
<button class="crypto-btn" id="btn-gen-alice" disabled>Generate Alice</button>
<button class="crypto-btn" id="btn-gen-bob" disabled>Generate Bob</button>
</div>
<div class="grid" style="margin-top:0.5rem;">
<div><strong>Alice</strong><pre id="alice-info">--</pre></div>
<div><strong>Bob</strong><pre id="bob-info">--</pre></div>
</div>
</div>
<!-- Safety Number -->
<div style="margin-top:0.75rem;">
<h2 style="font-size:1rem;">Safety Number</h2>
<button class="crypto-btn" id="btn-safety" disabled>Compute Safety Number</button>
<pre id="safety-output">--</pre>
</div>
<!-- Sign & Verify -->
<div style="margin-top:0.75rem;">
<h2 style="font-size:1rem;">Sign &amp; Verify</h2>
<input type="text" id="sign-msg" placeholder="Message to sign (Alice's key)" value="Hello, quicproquo!">
<div class="row">
<button class="crypto-btn" id="btn-sign" disabled>Sign (Alice)</button>
<button class="crypto-btn" id="btn-verify" disabled>Verify (Alice pubkey)</button>
</div>
<pre id="sign-output">--</pre>
</div>
<!-- Hybrid Encryption -->
<div style="margin-top:0.75rem;">
<h2 style="font-size:1rem;">Hybrid Encryption (X25519 + ML-KEM-768)</h2>
<input type="text" id="encrypt-msg" placeholder="Plaintext to encrypt" value="Post-quantum secrets!">
<div class="row">
<button class="crypto-btn" id="btn-hybrid-gen" disabled>Generate Hybrid Keypair</button>
<button class="crypto-btn" id="btn-hybrid-enc" disabled>Encrypt</button>
<button class="crypto-btn" id="btn-hybrid-dec" disabled>Decrypt</button>
</div>
<pre id="hybrid-output">--</pre>
</div>
<!-- Sealed Sender -->
<div style="margin-top:0.75rem;">
<h2 style="font-size:1rem;">Sealed Sender</h2>
<input type="text" id="seal-msg" placeholder="Payload to seal" value="Anonymous message">
<div class="row">
<button class="crypto-btn" id="btn-seal" disabled>Seal (Alice)</button>
<button class="crypto-btn" id="btn-unseal" disabled>Unseal</button>
</div>
<pre id="seal-output">--</pre>
</div>
<!-- Message Padding -->
<div style="margin-top:0.75rem;">
<h2 style="font-size:1rem;">Message Padding</h2>
<input type="text" id="pad-msg" placeholder="Message to pad" value="Short">
<div class="row">
<button class="crypto-btn" id="btn-pad" disabled>Pad</button>
<button class="crypto-btn" id="btn-unpad" disabled>Unpad</button>
</div>
<pre id="pad-output">--</pre>
</div>
</details>
<!-- Event Log -->
<details class="card" id="event-log-details">
<summary><h2>Event Log</h2> <span id="log-count" class="status info">(0)</span></summary>
<div class="log-container" id="event-log"></div>
</details>
<script type="module"> <script type="module">
import init, * as wasm from '../pkg/qpq_wasm_crypto.js'; import init, * as wasm from '../pkg/qpq_wasm_crypto.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const $ = (id) => document.getElementById(id);
const hex = (bytes) => Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
const trunc = (s, n = 64) => s.length > n ? s.slice(0, n) + '...' : s;
function b64Encode(bytes) {
let s = '';
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s);
}
function b64Decode(str) {
const bin = atob(str);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return arr;
}
function ts() {
return new Date().toLocaleTimeString('en-GB', { hour12: false });
}
let logCount = 0;
function log(msg, cls = 'info') {
logCount++;
$('log-count').textContent = `(${logCount})`;
const el = document.createElement('div');
el.className = 'log-line ' + cls;
el.textContent = `[${ts()}] ${msg}`;
const container = $('event-log');
container.appendChild(el);
container.scrollTop = container.scrollHeight;
}
// ---------------------------------------------------------------------------
// State // State
// ---------------------------------------------------------------------------
let wasmReady = false; let wasmReady = false;
let ws = null;
let identity = null; // { username, seed, publicKey }
let recipient = '';
let messages = []; // { time, sender, text, outgoing }
let pollTimer = null;
let rpcId = 1;
const pendingRpc = new Map(); // id -> { resolve, reject, method }
// Crypto playground state
let aliceSeed = null, alicePub = null; let aliceSeed = null, alicePub = null;
let bobSeed = null, bobPub = null; let bobSeed = null, bobPub = null;
let lastSignature = null; let lastSignature = null;
@@ -223,47 +380,312 @@ let hybridKeypair = null, hybridPub = null;
let lastEnvelope = null; let lastEnvelope = null;
let lastSealed = null; let lastSealed = null;
let lastPadded = null; let lastPadded = null;
let ws = null;
// Helpers // ---------------------------------------------------------------------------
const $ = (id) => document.getElementById(id); // RPC
const hex = (bytes) => Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); // ---------------------------------------------------------------------------
const trunc = (s, n = 64) => s.length > n ? s.slice(0, n) + '...' : s; function rpc(method, params) {
return new Promise((resolve, reject) => {
function log(msg, cls = '') { if (!ws || ws.readyState !== WebSocket.OPEN) {
const el = document.createElement('div'); const err = new Error('Not connected');
el.className = 'log-line' + (cls ? ' ' + cls : ''); log('RPC error: not connected', 'err');
el.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`; reject(err);
$('log').prepend(el); return;
}
const id = rpcId++;
const req = { id, method, params };
pendingRpc.set(id, { resolve, reject, method });
ws.send(JSON.stringify(req));
log(`=> ${method} (id=${id}) ${JSON.stringify(params)}`, 'info');
});
} }
function enableCryptoButtons() { function handleRpcResponse(data) {
const ids = [ let resp;
'btn-gen-alice', 'btn-gen-bob', 'btn-safety', 'btn-sign', 'btn-verify', try { resp = JSON.parse(data); } catch {
'btn-hybrid-gen', 'btn-hybrid-enc', 'btn-hybrid-dec', log('Server (unparseable): ' + data, 'warn');
'btn-seal', 'btn-unseal', 'btn-pad', 'btn-unpad', 'btn-connect' return;
]; }
ids.forEach(id => $(id).disabled = false);
const id = resp.id;
const pending = pendingRpc.get(id);
if (!pending) {
log('<= unsolicited: ' + JSON.stringify(resp), 'warn');
return;
}
pendingRpc.delete(id);
if (resp.ok) {
log(`<= ${pending.method} (id=${id}) OK`, 'ok');
pending.resolve(resp.result);
} else {
const errMsg = resp.error || 'Unknown error';
log(`<= ${pending.method} (id=${id}) ERROR: ${errMsg}`, 'err');
pending.reject(new Error(errMsg));
}
} }
// -- WASM Init -- // ---------------------------------------------------------------------------
$('btn-init').addEventListener('click', async () => { // Connection
// ---------------------------------------------------------------------------
function setConnState(state) {
const dot = $('status-dot');
const label = $('conn-label');
dot.className = 'status-dot ' + state;
if (state === 'connected') {
label.textContent = 'Connected';
label.className = 'status ok';
$('btn-connect').disabled = true;
$('btn-disconnect').disabled = false;
$('server-url').disabled = true;
updateChatControls();
} else if (state === 'connecting') {
label.textContent = 'Connecting...';
label.className = 'status warn';
$('btn-connect').disabled = true;
$('btn-disconnect').disabled = true;
} else {
label.textContent = 'Disconnected';
label.className = 'status info';
$('btn-connect').disabled = !wasmReady;
$('btn-disconnect').disabled = true;
$('server-url').disabled = false;
stopPolling();
updateChatControls();
}
}
function connect() {
const url = $('server-url').value.trim();
if (!url) return;
setConnState('connecting');
try { try {
$('wasm-status').textContent = 'Loading...'; ws = new WebSocket(url);
$('wasm-status').className = 'status info';
await init();
wasmReady = true;
$('wasm-status').textContent = 'Loaded (174 KB WASM)';
$('wasm-status').className = 'status ok';
$('btn-init').disabled = true;
enableCryptoButtons();
} catch (e) { } catch (e) {
$('wasm-status').textContent = 'Error: ' + e.message; log('Connection failed: ' + e.message, 'err');
$('wasm-status').className = 'status err'; setConnState('disconnected');
return;
}
ws.addEventListener('open', () => {
setConnState('connected');
log('Connected to ' + url, 'ok');
// Update register button
$('btn-register').disabled = !!identity;
});
ws.addEventListener('close', (ev) => {
setConnState('disconnected');
log(`Disconnected (code=${ev.code}${ev.reason ? ', ' + ev.reason : ''})`, 'info');
ws = null;
// Reject all pending RPCs
for (const [id, p] of pendingRpc) {
p.reject(new Error('Connection closed'));
}
pendingRpc.clear();
});
ws.addEventListener('error', () => {
log('WebSocket error -- is the server running with --ws-listen 0.0.0.0:9000 ?', 'err');
});
ws.addEventListener('message', (ev) => {
if (typeof ev.data === 'string') {
handleRpcResponse(ev.data);
} else {
log('Server (binary): ' + ev.data.byteLength + ' bytes', 'info');
}
});
}
function disconnect() {
if (ws) {
ws.close(1000, 'user disconnect');
ws = null;
}
setConnState('disconnected');
}
$('btn-connect').addEventListener('click', connect);
$('btn-disconnect').addEventListener('click', disconnect);
// ---------------------------------------------------------------------------
// Identity & Registration
// ---------------------------------------------------------------------------
async function register() {
const username = $('reg-username').value.trim();
if (!username) {
log('Enter a username first', 'warn');
return;
}
if (!wasmReady) {
log('WASM not ready', 'err');
return;
}
try {
const seed = wasm.generate_identity();
const publicKey = wasm.identity_public_key(seed);
const identityKeyB64 = b64Encode(publicKey);
const result = await rpc('register', { username, identityKey: identityKeyB64 });
identity = { username, seed, publicKey };
// Update UI
$('reg-username').disabled = true;
$('btn-register').disabled = true;
const pubHex = hex(publicKey);
$('identity-info').innerHTML =
`<div class="identity-badge">` +
`Registered as <strong>${identity.username}</strong> ` +
`| pubkey: ${trunc(pubHex, 16)}` +
`</div>`;
log(`Registered: ${username} (pubkey=${trunc(pubHex, 24)})`, 'ok');
updateChatControls();
maybeStartPolling();
} catch (e) {
log('Registration failed: ' + e.message, 'err');
}
}
$('btn-register').addEventListener('click', register);
// ---------------------------------------------------------------------------
// Chat
// ---------------------------------------------------------------------------
function updateChatControls() {
const canChat = !!identity && ws && ws.readyState === WebSocket.OPEN;
$('chat-input').disabled = !canChat;
$('btn-chat-send').disabled = !canChat;
$('chat-recipient').disabled = !canChat && !identity;
}
function renderMessages() {
const area = $('chat-area');
area.innerHTML = '';
if (messages.length === 0) {
const div = document.createElement('div');
div.className = 'msg system';
div.textContent = identity ? 'No messages yet. Start typing!' : 'Connect and register to start chatting.';
area.appendChild(div);
return;
}
for (const m of messages) {
const div = document.createElement('div');
div.className = 'msg ' + (m.outgoing ? 'outgoing' : 'incoming');
const tsSpan = `<span class="ts">[${m.time}]</span>`;
const senderSpan = `<span class="sender">${m.sender}:</span>`;
div.innerHTML = tsSpan + senderSpan + ' ' + escapeHtml(m.text);
area.appendChild(div);
}
area.scrollTop = area.scrollHeight;
}
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
async function sendMessage() {
const text = $('chat-input').value.trim();
const recip = $('chat-recipient').value.trim();
if (!text || !recip || !identity) return;
try {
const result = await rpc('send', {
username: identity.username,
recipient: recip,
message: text
});
messages.push({
time: ts(),
sender: identity.username,
text,
outgoing: true
});
renderMessages();
$('chat-input').value = '';
$('chat-status').textContent = `Delivered (seq=${result.seq})`;
$('chat-status').className = 'status ok';
setTimeout(() => { $('chat-status').textContent = ''; }, 3000);
} catch (e) {
$('chat-status').textContent = 'Send failed: ' + e.message;
$('chat-status').className = 'status err';
}
}
async function pollMessages() {
const recip = $('chat-recipient').value.trim();
if (!recip || !identity || !ws || ws.readyState !== WebSocket.OPEN) return;
try {
const result = await rpc('receive', {
username: identity.username,
recipient: recip
});
if (Array.isArray(result) && result.length > 0) {
for (const m of result) {
const text = m.text || (m.data ? new TextDecoder().decode(b64Decode(m.data)) : '(empty)');
messages.push({
time: ts(),
sender: recip,
text,
outgoing: false
});
}
renderMessages();
}
} catch (e) {
// Silently ignore poll errors to avoid log spam
}
}
function maybeStartPolling() {
stopPolling();
const recip = $('chat-recipient').value.trim();
if (recip && identity && ws && ws.readyState === WebSocket.OPEN) {
pollTimer = setInterval(pollMessages, 2000);
}
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
$('btn-chat-send').addEventListener('click', sendMessage);
$('chat-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
} }
}); });
// -- Identity Generation -- // When recipient changes, restart polling and clear messages
$('chat-recipient').addEventListener('change', () => {
const newRecip = $('chat-recipient').value.trim();
if (newRecip !== recipient) {
recipient = newRecip;
messages = [];
renderMessages();
maybeStartPolling();
}
});
// ---------------------------------------------------------------------------
// Crypto Playground (standalone, no server needed)
// ---------------------------------------------------------------------------
function enableCryptoButtons() {
document.querySelectorAll('.crypto-btn').forEach(btn => btn.disabled = false);
}
// Ed25519 Identity
$('btn-gen-alice').addEventListener('click', () => { $('btn-gen-alice').addEventListener('click', () => {
aliceSeed = wasm.generate_identity(); aliceSeed = wasm.generate_identity();
alicePub = wasm.identity_public_key(aliceSeed); alicePub = wasm.identity_public_key(aliceSeed);
@@ -278,7 +700,7 @@ $('btn-gen-bob').addEventListener('click', () => {
`seed: ${trunc(hex(bobSeed))}\npubkey: ${trunc(hex(bobPub))}`; `seed: ${trunc(hex(bobSeed))}\npubkey: ${trunc(hex(bobPub))}`;
}); });
// -- Safety Number -- // Safety Number
$('btn-safety').addEventListener('click', () => { $('btn-safety').addEventListener('click', () => {
if (!alicePub || !bobPub) { if (!alicePub || !bobPub) {
$('safety-output').textContent = 'Generate both Alice and Bob first.'; $('safety-output').textContent = 'Generate both Alice and Bob first.';
@@ -288,7 +710,7 @@ $('btn-safety').addEventListener('click', () => {
$('safety-output').textContent = sn; $('safety-output').textContent = sn;
}); });
// -- Sign / Verify -- // Sign / Verify
$('btn-sign').addEventListener('click', () => { $('btn-sign').addEventListener('click', () => {
if (!aliceSeed) { if (!aliceSeed) {
$('sign-output').textContent = 'Generate Alice first.'; $('sign-output').textContent = 'Generate Alice first.';
@@ -310,7 +732,7 @@ $('btn-verify').addEventListener('click', () => {
$('sign-output').textContent += `\nVerification: ${valid ? 'VALID' : 'INVALID'}`; $('sign-output').textContent += `\nVerification: ${valid ? 'VALID' : 'INVALID'}`;
}); });
// -- Hybrid Encryption -- // Hybrid Encryption
$('btn-hybrid-gen').addEventListener('click', () => { $('btn-hybrid-gen').addEventListener('click', () => {
const kp = wasm.hybrid_generate_keypair(); const kp = wasm.hybrid_generate_keypair();
hybridKeypair = kp; hybridKeypair = kp;
@@ -342,7 +764,7 @@ $('btn-hybrid-dec').addEventListener('click', () => {
$('hybrid-output').textContent += `\nDecrypted: "${text}"`; $('hybrid-output').textContent += `\nDecrypted: "${text}"`;
}); });
// -- Sealed Sender -- // Sealed Sender
$('btn-seal').addEventListener('click', () => { $('btn-seal').addEventListener('click', () => {
if (!aliceSeed) { if (!aliceSeed) {
$('seal-output').textContent = 'Generate Alice first.'; $('seal-output').textContent = 'Generate Alice first.';
@@ -369,7 +791,7 @@ $('btn-unseal').addEventListener('click', () => {
`Inner payload: "${text}"`; `Inner payload: "${text}"`;
}); });
// -- Message Padding -- // Message Padding
$('btn-pad').addEventListener('click', () => { $('btn-pad').addEventListener('click', () => {
const msg = new TextEncoder().encode($('pad-msg').value); const msg = new TextEncoder().encode($('pad-msg').value);
lastPadded = wasm.pad_message(msg); lastPadded = wasm.pad_message(msg);
@@ -388,93 +810,27 @@ $('btn-unpad').addEventListener('click', () => {
$('pad-output').textContent += `\nUnpadded: "${text}" (${recovered.length} bytes)`; $('pad-output').textContent += `\nUnpadded: "${text}" (${recovered.length} bytes)`;
}); });
// -- Server Connection -- // ---------------------------------------------------------------------------
$('btn-connect').addEventListener('click', () => { // WASM auto-init on page load
const addr = $('server-addr').value; // ---------------------------------------------------------------------------
if (!addr) return; (async () => {
try { try {
$('conn-status').textContent = 'Connecting...'; $('wasm-status').textContent = 'WASM: loading...';
$('conn-status').className = 'status info'; $('wasm-status').className = 'status warn';
ws = new WebSocket(addr); await init();
ws.binaryType = 'arraybuffer'; wasmReady = true;
$('wasm-status').textContent = 'WASM: ready';
ws.addEventListener('open', () => { $('wasm-status').className = 'status ok';
$('conn-status').textContent = 'Connected'; $('btn-connect').disabled = false;
$('conn-status').className = 'status ok'; $('btn-register').disabled = true; // need connection first
$('btn-disconnect').disabled = false; enableCryptoButtons();
$('btn-send').disabled = false; log('WASM module initialized', 'ok');
$('btn-recv').disabled = false;
log('Connected to ' + addr, 'ok');
});
ws.addEventListener('close', (ev) => {
$('conn-status').textContent = `Disconnected (${ev.code})`;
$('conn-status').className = 'status info';
$('btn-disconnect').disabled = true;
$('btn-send').disabled = true;
$('btn-recv').disabled = true;
log('Disconnected: ' + ev.reason);
});
ws.addEventListener('error', () => {
$('conn-status').textContent = 'Connection error';
$('conn-status').className = 'status err';
log('WebSocket error -- start server with --ws-listen 0.0.0.0:9000', 'err');
});
ws.addEventListener('message', (ev) => {
if (typeof ev.data === 'string') {
try {
const resp = JSON.parse(ev.data);
log('Server: ' + JSON.stringify(resp));
} catch {
log('Server (raw): ' + ev.data);
}
} else {
log('Server (binary): ' + new Uint8Array(ev.data).length + ' bytes');
}
});
} catch (e) { } catch (e) {
$('conn-status').textContent = 'Error: ' + e.message; $('wasm-status').textContent = 'WASM: failed -- ' + e.message;
$('conn-status').className = 'status err'; $('wasm-status').className = 'status err';
log('WASM init failed: ' + e.message, 'err');
} }
}); })();
$('btn-disconnect').addEventListener('click', () => {
if (ws) {
ws.close(1000, 'user disconnect');
ws = null;
}
});
// -- Chat --
let rpcId = 1;
function sendRpc(method, params) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
log('Not connected', 'err');
return;
}
const req = { id: rpcId++, method, params };
ws.send(JSON.stringify(req));
log('Sent: ' + method + ' (id=' + req.id + ')');
}
$('btn-send').addEventListener('click', () => {
const me = $('chat-me').value;
const user = $('chat-user').value;
const msg = $('chat-msg').value;
if (!me) { log('Enter your username first', 'info'); return; }
if (!user || !msg) return;
sendRpc('send', { username: me, recipient: user, message: msg });
});
$('btn-recv').addEventListener('click', () => {
const me = $('chat-me').value;
const user = $('chat-user').value;
if (!me) { log('Enter your username first', 'info'); return; }
if (!user) { log('Enter a recipient username first', 'info'); return; }
sendRpc('receive', { username: me, recipient: user });
});
</script> </script>
</body> </body>
</html> </html>