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

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>quicproquo -- Browser Crypto Demo</title>
<title>quicproquo -- E2E Encrypted Messenger Demo</title>
<style>
:root {
--bg: #0d1117;
@@ -14,7 +14,10 @@
--accent: #58a6ff;
--green: #3fb950;
--red: #f85149;
--yellow: #d29922;
--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; }
body {
@@ -40,6 +43,7 @@
.ok { color: var(--green); }
.err { color: var(--red); }
.info { color: var(--muted); }
.warn { color: var(--yellow); }
button {
background: var(--accent);
color: #fff;
@@ -52,6 +56,7 @@
}
button:hover { opacity: 0.9; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
button.danger { background: var(--red); }
pre {
background: var(--bg);
border: 1px solid var(--border);
@@ -77,145 +82,297 @@
margin-bottom: 0.5rem;
}
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; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@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;
}
.log-line { font-family: var(--mono); font-size: 0.8rem; padding: 0.1rem 0; }
.bridge-note {
background: #1c1f26;
border-left: 3px solid var(--accent);
padding: 0.6rem 0.8rem;
font-size: 0.85rem;
margin-top: 0.5rem;
color: var(--muted);
.log-line {
font-family: var(--mono);
font-size: 0.75rem;
padding: 0.1rem 0;
border-bottom: 1px solid #1c2028;
}
details summary {
cursor: pointer;
user-select: none;
}
details summary h2 {
display: inline;
}
</style>
</head>
<body>
<h1>quicproquo</h1>
<p class="subtitle">E2E Encrypted Messenger -- Browser Crypto Demo</p>
<p class="subtitle">E2E Encrypted Messenger -- Browser Demo</p>
<!-- WASM Init -->
<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 -->
<!-- Connection Bar -->
<div class="card">
<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">
<button id="btn-connect" disabled>Connect</button>
<button id="btn-disconnect" disabled>Disconnect</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.
<input type="text" id="reg-username" placeholder="Choose a username" style="flex:1;margin-bottom:0;">
<button id="btn-register" disabled>Register</button>
</div>
<div id="identity-info"></div>
</div>
<!-- Chat -->
<div class="card">
<h2>Chat</h2>
<div class="grid">
<input type="text" id="chat-me" placeholder="Your username (sender)">
<input type="text" id="chat-user" placeholder="Recipient username">
<div style="margin-bottom:0.5rem;">
<input type="text" id="chat-recipient" placeholder="Recipient username" style="margin-bottom:0;">
</div>
<input type="text" id="chat-msg" placeholder="Message">
<div class="row">
<button id="btn-send" disabled>Send</button>
<button id="btn-recv" disabled>Receive</button>
<div class="chat-area" id="chat-area">
<div class="msg system">Connect and register to start chatting.</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>
<!-- 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">
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
// ---------------------------------------------------------------------------
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 bobSeed = null, bobPub = null;
let lastSignature = null;
@@ -223,47 +380,312 @@ let hybridKeypair = null, hybridPub = null;
let lastEnvelope = null;
let lastSealed = null;
let lastPadded = null;
let ws = null;
// 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 log(msg, cls = '') {
const el = document.createElement('div');
el.className = 'log-line' + (cls ? ' ' + cls : '');
el.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
$('log').prepend(el);
// ---------------------------------------------------------------------------
// RPC
// ---------------------------------------------------------------------------
function rpc(method, params) {
return new Promise((resolve, reject) => {
if (!ws || ws.readyState !== WebSocket.OPEN) {
const err = new Error('Not connected');
log('RPC error: not connected', 'err');
reject(err);
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() {
const ids = [
'btn-gen-alice', 'btn-gen-bob', 'btn-safety', 'btn-sign', 'btn-verify',
'btn-hybrid-gen', 'btn-hybrid-enc', 'btn-hybrid-dec',
'btn-seal', 'btn-unseal', 'btn-pad', 'btn-unpad', 'btn-connect'
];
ids.forEach(id => $(id).disabled = false);
function handleRpcResponse(data) {
let resp;
try { resp = JSON.parse(data); } catch {
log('Server (unparseable): ' + data, 'warn');
return;
}
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 {
$('wasm-status').textContent = 'Loading...';
$('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();
ws = new WebSocket(url);
} catch (e) {
$('wasm-status').textContent = 'Error: ' + e.message;
$('wasm-status').className = 'status err';
log('Connection failed: ' + e.message, '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', () => {
aliceSeed = wasm.generate_identity();
alicePub = wasm.identity_public_key(aliceSeed);
@@ -278,7 +700,7 @@ $('btn-gen-bob').addEventListener('click', () => {
`seed: ${trunc(hex(bobSeed))}\npubkey: ${trunc(hex(bobPub))}`;
});
// -- Safety Number --
// Safety Number
$('btn-safety').addEventListener('click', () => {
if (!alicePub || !bobPub) {
$('safety-output').textContent = 'Generate both Alice and Bob first.';
@@ -288,7 +710,7 @@ $('btn-safety').addEventListener('click', () => {
$('safety-output').textContent = sn;
});
// -- Sign / Verify --
// Sign / Verify
$('btn-sign').addEventListener('click', () => {
if (!aliceSeed) {
$('sign-output').textContent = 'Generate Alice first.';
@@ -310,7 +732,7 @@ $('btn-verify').addEventListener('click', () => {
$('sign-output').textContent += `\nVerification: ${valid ? 'VALID' : 'INVALID'}`;
});
// -- Hybrid Encryption --
// Hybrid Encryption
$('btn-hybrid-gen').addEventListener('click', () => {
const kp = wasm.hybrid_generate_keypair();
hybridKeypair = kp;
@@ -342,7 +764,7 @@ $('btn-hybrid-dec').addEventListener('click', () => {
$('hybrid-output').textContent += `\nDecrypted: "${text}"`;
});
// -- Sealed Sender --
// Sealed Sender
$('btn-seal').addEventListener('click', () => {
if (!aliceSeed) {
$('seal-output').textContent = 'Generate Alice first.';
@@ -369,7 +791,7 @@ $('btn-unseal').addEventListener('click', () => {
`Inner payload: "${text}"`;
});
// -- Message Padding --
// Message Padding
$('btn-pad').addEventListener('click', () => {
const msg = new TextEncoder().encode($('pad-msg').value);
lastPadded = wasm.pad_message(msg);
@@ -388,93 +810,27 @@ $('btn-unpad').addEventListener('click', () => {
$('pad-output').textContent += `\nUnpadded: "${text}" (${recovered.length} bytes)`;
});
// -- Server Connection --
$('btn-connect').addEventListener('click', () => {
const addr = $('server-addr').value;
if (!addr) return;
// ---------------------------------------------------------------------------
// WASM auto-init on page load
// ---------------------------------------------------------------------------
(async () => {
try {
$('conn-status').textContent = 'Connecting...';
$('conn-status').className = 'status info';
ws = new WebSocket(addr);
ws.binaryType = 'arraybuffer';
ws.addEventListener('open', () => {
$('conn-status').textContent = 'Connected';
$('conn-status').className = 'status ok';
$('btn-disconnect').disabled = false;
$('btn-send').disabled = false;
$('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');
}
});
$('wasm-status').textContent = 'WASM: loading...';
$('wasm-status').className = 'status warn';
await init();
wasmReady = true;
$('wasm-status').textContent = 'WASM: ready';
$('wasm-status').className = 'status ok';
$('btn-connect').disabled = false;
$('btn-register').disabled = true; // need connection first
enableCryptoButtons();
log('WASM module initialized', 'ok');
} catch (e) {
$('conn-status').textContent = 'Error: ' + e.message;
$('conn-status').className = 'status err';
$('wasm-status').textContent = 'WASM: failed -- ' + e.message;
$('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>
</body>
</html>