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:
@@ -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 & 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 & 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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user