warzone-wasm crate: - Compiles warzone-protocol to WebAssembly via wasm-pack - Exposes WasmIdentity, WasmSession, decrypt_wire_message to JS - Same X25519 + ChaCha20-Poly1305 + X3DH + Double Ratchet as CLI - 344KB WASM binary (optimized with wasm-opt) WireMessage moved to warzone-protocol: - Shared type used by CLI client, WASM bridge, and TUI - Guarantees identical bincode serialization across all clients Web client rewritten: - Loads WASM module on startup (/wasm/warzone_wasm.js) - Identity: WasmIdentity generates same key types as CLI - Registration: sends bincode PreKeyBundle (same format as CLI) - Encrypt: WasmSession.encrypt/encrypt_key_exchange - Decrypt: decrypt_wire_message (handles KeyExchange + Message) - Sessions persisted in localStorage (base64 ratchet state) - Groups: per-member WASM encryption (interop with CLI members) Server routes: - GET /wasm/warzone_wasm.js — serves WASM JS glue - GET /wasm/warzone_wasm_bg.wasm — serves WASM binary - Both embedded at compile time via include_str!/include_bytes! Web ↔ CLI interop now works: - Same key exchange (X3DH with X25519) - Same ratchet (Double Ratchet with ChaCha20-Poly1305) - Same wire format (bincode WireMessage) - Web user can message CLI user and vice versa Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
609 lines
22 KiB
Rust
609 lines
22 KiB
Rust
use axum::{
|
|
http::header,
|
|
response::{Html, IntoResponse},
|
|
routing::get,
|
|
Router,
|
|
};
|
|
|
|
use crate::state::AppState;
|
|
|
|
pub fn routes() -> Router<AppState> {
|
|
Router::new()
|
|
.route("/", get(web_ui))
|
|
.route("/wasm/warzone_wasm.js", get(wasm_js))
|
|
.route("/wasm/warzone_wasm_bg.wasm", get(wasm_binary))
|
|
}
|
|
|
|
async fn web_ui() -> Html<&'static str> {
|
|
Html(WEB_HTML)
|
|
}
|
|
|
|
async fn wasm_js() -> impl IntoResponse {
|
|
(
|
|
[(header::CONTENT_TYPE, "application/javascript")],
|
|
include_str!("../../../../wasm-pkg/warzone_wasm.js"),
|
|
)
|
|
}
|
|
|
|
async fn wasm_binary() -> impl IntoResponse {
|
|
(
|
|
[(header::CONTENT_TYPE, "application/wasm")],
|
|
include_bytes!("../../../../wasm-pkg/warzone_wasm_bg.wasm").as_slice(),
|
|
)
|
|
}
|
|
|
|
const WEB_HTML: &str = r##"<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
|
|
<meta name="theme-color" content="#0a0a1a">
|
|
<title>Warzone</title>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
html, body { height: 100%; overflow: hidden; }
|
|
body { background: #0a0a1a; color: #c8d6e5; font-family: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
|
|
display: flex; flex-direction: column; height: 100dvh; font-size: 14px; }
|
|
|
|
/* Setup screen */
|
|
.screen { display: none; flex-direction: column; flex: 1; }
|
|
.screen.active { display: flex; }
|
|
|
|
#setup { align-items: center; justify-content: center; padding: 20px; }
|
|
#setup h1 { color: #e94560; font-size: 1.8em; margin-bottom: 4px; }
|
|
#setup .sub { color: #555; margin-bottom: 24px; font-size: 0.8em; }
|
|
.fp-display { background: #111; border: 1px solid #333; padding: 12px 20px; border-radius: 6px;
|
|
font-size: 1.1em; color: #4ade80; letter-spacing: 1px; margin: 8px 0; font-family: monospace;
|
|
user-select: all; cursor: pointer; }
|
|
.seed-display { background: #111; border: 1px solid #333; padding: 12px; border-radius: 6px;
|
|
margin: 8px 0; max-width: 420px; width: 100%; color: #e6a23c; font-size: 0.8em;
|
|
line-height: 1.8; text-align: center; user-select: all; word-break: break-all; }
|
|
.warn { color: #e94560; font-size: 0.75em; margin: 6px 0; }
|
|
.btn { padding: 10px 24px; background: #e94560; border: none; color: #fff; border-radius: 6px;
|
|
cursor: pointer; font-family: inherit; font-size: 0.9em; margin: 4px; }
|
|
.btn:hover { background: #c73e54; }
|
|
.btn-alt { background: #1a1a3e; border: 1px solid #444; }
|
|
.btn-alt:hover { background: #252550; }
|
|
#recover-area { display: none; margin-top: 12px; max-width: 420px; width: 100%; }
|
|
#recover-area textarea { width: 100%; padding: 10px; background: #1a1a2e; border: 1px solid #333;
|
|
color: #c8d6e5; border-radius: 6px; font-family: inherit; min-height: 50px;
|
|
resize: none; margin-bottom: 8px; font-size: 14px; }
|
|
|
|
/* Chat screen */
|
|
#chat-header { padding: 6px 10px; background: #111; border-bottom: 1px solid #222;
|
|
display: flex; align-items: center; gap: 8px; font-size: 0.8em; flex-wrap: wrap; }
|
|
#chat-header .tag { padding: 1px 6px; border-radius: 3px; font-size: 0.85em; }
|
|
.tag-fp { background: #0a2e0a; color: #4ade80; }
|
|
.tag-peer { background: #2e2e0a; color: #e6a23c; }
|
|
.tag-server { color: #444; }
|
|
#chat-header input { background: #1a1a2e; border: 1px solid #333; color: #e6a23c; padding: 2px 6px;
|
|
border-radius: 3px; font-family: inherit; font-size: 0.85em; width: 280px; }
|
|
|
|
#messages { flex: 1; overflow-y: auto; padding: 8px 10px; -webkit-overflow-scrolling: touch; }
|
|
.msg { padding: 2px 0; font-size: 0.85em; white-space: pre-wrap; word-wrap: break-word; }
|
|
.msg .ts { color: #333; margin-right: 4px; }
|
|
.msg .from-self { color: #4ade80; font-weight: bold; }
|
|
.msg .from-peer { color: #e6a23c; font-weight: bold; }
|
|
.msg .from-sys { color: #5e9ca0; font-style: italic; }
|
|
.msg .lock { color: #ff6b9d; }
|
|
|
|
#bottom { display: flex; padding: 6px; gap: 6px; border-top: 1px solid #222; background: #111;
|
|
align-items: flex-end; }
|
|
#msg-input { flex: 1; padding: 10px; background: #1a1a2e; border: 1px solid #333; color: #c8d6e5;
|
|
border-radius: 20px; font-family: inherit; font-size: 14px; resize: none;
|
|
min-height: 40px; max-height: 120px; line-height: 1.4; }
|
|
#send-btn { padding: 10px 16px; background: #e94560; border: none; color: #fff; border-radius: 20px;
|
|
cursor: pointer; font-size: 14px; min-height: 40px; }
|
|
#send-btn:hover { background: #c73e54; }
|
|
|
|
@media (max-width: 500px) {
|
|
.msg { font-size: 0.8em; }
|
|
#chat-header input { width: 180px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- Setup screen -->
|
|
<div id="setup" class="screen active">
|
|
<h1>WARZONE</h1>
|
|
<div class="sub">end-to-end encrypted messenger</div>
|
|
|
|
<div id="gen-section">
|
|
<button class="btn" onclick="doGenerate()">Generate Identity</button>
|
|
<button class="btn btn-alt" onclick="document.getElementById('recover-area').style.display='block'">Recover</button>
|
|
<div id="recover-area">
|
|
<textarea id="recover-input" placeholder="Paste your hex seed (64 chars)..." rows="2"></textarea>
|
|
<button class="btn" onclick="doRecover()">Recover Identity</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="id-section" style="display:none">
|
|
<div>Your fingerprint:</div>
|
|
<div class="fp-display" id="my-fp" title="Click to copy"></div>
|
|
<div class="seed-display" id="my-seed"></div>
|
|
<div class="warn">SAVE YOUR SEED — only way to recover your identity</div>
|
|
<br>
|
|
<button class="btn" onclick="enterChat()">Enter Chat</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Chat screen -->
|
|
<div id="chat" class="screen">
|
|
<div id="chat-header">
|
|
<span class="tag tag-fp" id="hdr-fp"></span>
|
|
<span>→</span>
|
|
<input id="peer-input" placeholder="Paste peer fingerprint..." autocomplete="off">
|
|
<span class="tag-server" id="hdr-server"></span>
|
|
</div>
|
|
<div id="messages"></div>
|
|
<div id="bottom">
|
|
<textarea id="msg-input" placeholder="Message... (Enter to send)" rows="1"></textarea>
|
|
<button id="send-btn" onclick="doSend()">▶</button>
|
|
</div>
|
|
</div>
|
|
|
|
<script type="module">
|
|
import init, { WasmIdentity, WasmSession, decrypt_wire_message } from '/wasm/warzone_wasm.js';
|
|
|
|
const SERVER = window.location.origin;
|
|
const $messages = document.getElementById('messages');
|
|
const $input = document.getElementById('msg-input');
|
|
const $peerInput = document.getElementById('peer-input');
|
|
|
|
// ── State ──
|
|
let wasmIdentity = null; // WasmIdentity from WASM
|
|
let myFingerprint = '';
|
|
let mySeedHex = '';
|
|
let sessions = {}; // peerFP -> { session: WasmSession, data: base64 }
|
|
let peerBundles = {}; // peerFP -> bundle bytes
|
|
let pollTimer = null;
|
|
let wasmReady = false;
|
|
|
|
function normFP(fp) {
|
|
return fp.replace(/[^0-9a-fA-F]/g, '').toLowerCase();
|
|
}
|
|
|
|
// ── WASM-based crypto (same as CLI: X25519 + ChaCha20 + Double Ratchet) ──
|
|
|
|
async function initWasm() {
|
|
await init('/wasm/warzone_wasm_bg.wasm');
|
|
wasmReady = true;
|
|
}
|
|
|
|
function initIdentityFromSeed(hexSeed) {
|
|
wasmIdentity = WasmIdentity.from_hex_seed(hexSeed);
|
|
myFingerprint = wasmIdentity.fingerprint();
|
|
mySeedHex = wasmIdentity.seed_hex();
|
|
localStorage.setItem('wz-seed', mySeedHex);
|
|
localStorage.setItem('wz-fp', myFingerprint);
|
|
}
|
|
|
|
function generateNewIdentity() {
|
|
wasmIdentity = new WasmIdentity();
|
|
myFingerprint = wasmIdentity.fingerprint();
|
|
mySeedHex = wasmIdentity.seed_hex();
|
|
localStorage.setItem('wz-seed', mySeedHex);
|
|
localStorage.setItem('wz-fp', myFingerprint);
|
|
}
|
|
|
|
function loadSavedIdentity() {
|
|
const saved = localStorage.getItem('wz-seed');
|
|
if (!saved) return false;
|
|
try {
|
|
initIdentityFromSeed(saved);
|
|
return true;
|
|
} catch(e) {
|
|
localStorage.removeItem('wz-seed');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function registerKey() {
|
|
const fp = normFP(myFingerprint);
|
|
const bundleBytes = wasmIdentity.bundle_bytes();
|
|
await fetch(SERVER + '/v1/keys/register', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fingerprint: fp, bundle: Array.from(bundleBytes) })
|
|
});
|
|
}
|
|
|
|
async function fetchPeerBundle(peerFP) {
|
|
const fp = normFP(peerFP);
|
|
if (peerBundles[fp]) return peerBundles[fp];
|
|
|
|
const resp = await fetch(SERVER + '/v1/keys/' + fp);
|
|
if (!resp.ok) throw new Error('Peer not registered');
|
|
const data = await resp.json();
|
|
const bytes = Uint8Array.from(atob(data.bundle), c => c.charCodeAt(0));
|
|
peerBundles[fp] = bytes;
|
|
return bytes;
|
|
}
|
|
|
|
async function sendEncrypted(peerFP, plaintext) {
|
|
const fp = normFP(peerFP);
|
|
const bundleBytes = await fetchPeerBundle(fp);
|
|
|
|
let wireBytes;
|
|
if (sessions[fp]) {
|
|
// Existing session
|
|
const sess = WasmSession.restore(sessions[fp].data);
|
|
wireBytes = sess.encrypt(wasmIdentity, plaintext);
|
|
sessions[fp].data = sess.save();
|
|
localStorage.setItem('wz-sessions', JSON.stringify(
|
|
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
|
|
));
|
|
} else {
|
|
// New session via X3DH
|
|
const sess = WasmSession.initiate(wasmIdentity, bundleBytes);
|
|
wireBytes = sess.encrypt_key_exchange(wasmIdentity, bundleBytes, plaintext);
|
|
sessions[fp] = { data: sess.save() };
|
|
localStorage.setItem('wz-sessions', JSON.stringify(
|
|
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
|
|
));
|
|
}
|
|
|
|
await fetch(SERVER + '/v1/messages/send', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
to: fp,
|
|
from: normFP(myFingerprint),
|
|
message: Array.from(wireBytes)
|
|
})
|
|
});
|
|
}
|
|
|
|
async function pollMessages() {
|
|
if (!wasmReady) return;
|
|
try {
|
|
const fp = normFP(myFingerprint);
|
|
const resp = await fetch(SERVER + '/v1/messages/poll/' + fp);
|
|
if (!resp.ok) return;
|
|
const msgs = await resp.json();
|
|
|
|
for (const b64 of msgs) {
|
|
try {
|
|
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
|
|
|
// Find existing session for this sender (we don't know sender yet)
|
|
// Try decrypt with each session, or let decrypt_wire_message handle it
|
|
const existingSessionsJson = {};
|
|
for (const [k, v] of Object.entries(sessions)) {
|
|
existingSessionsJson[k] = v.data;
|
|
}
|
|
|
|
// Try to decrypt (WASM handles both KeyExchange and Message)
|
|
const resultStr = decrypt_wire_message(
|
|
mySeedHex,
|
|
bytes,
|
|
null // existing session found internally
|
|
);
|
|
const result = JSON.parse(resultStr);
|
|
|
|
// Save updated session
|
|
const senderFP = normFP(result.sender);
|
|
sessions[senderFP] = { data: result.session_data };
|
|
localStorage.setItem('wz-sessions', JSON.stringify(
|
|
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
|
|
));
|
|
|
|
// Resolve alias
|
|
let fromLabel = result.sender.slice(0, 19);
|
|
try {
|
|
const ar = await fetch(SERVER + '/v1/alias/whois/' + senderFP);
|
|
const ad = await ar.json();
|
|
if (ad.alias) fromLabel = '@' + ad.alias;
|
|
} catch(e) {}
|
|
|
|
addMsg(fromLabel, result.text, false);
|
|
} catch(e) {
|
|
// Try with existing session
|
|
try {
|
|
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
|
// Brute-force try each session
|
|
let decrypted = false;
|
|
for (const [senderFP, sessData] of Object.entries(sessions)) {
|
|
try {
|
|
const resultStr = decrypt_wire_message(mySeedHex, bytes, sessData.data);
|
|
const result = JSON.parse(resultStr);
|
|
sessions[senderFP] = { data: result.session_data };
|
|
localStorage.setItem('wz-sessions', JSON.stringify(
|
|
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
|
|
));
|
|
let fromLabel = result.sender.slice(0, 19);
|
|
try {
|
|
const ar = await fetch(SERVER + '/v1/alias/whois/' + normFP(result.sender));
|
|
const ad = await ar.json();
|
|
if (ad.alias) fromLabel = '@' + ad.alias;
|
|
} catch(e2) {}
|
|
addMsg(fromLabel, result.text, false);
|
|
decrypted = true;
|
|
break;
|
|
} catch(e2) { continue; }
|
|
}
|
|
if (!decrypted) addSys('[message could not be decrypted]');
|
|
} catch(e2) {
|
|
addSys('[failed to process message]');
|
|
}
|
|
}
|
|
}
|
|
} catch(e) { /* server offline */ }
|
|
}
|
|
|
|
// Load saved sessions
|
|
try {
|
|
const saved = localStorage.getItem('wz-sessions');
|
|
if (saved) {
|
|
const parsed = JSON.parse(saved);
|
|
for (const [k, v] of Object.entries(parsed)) {
|
|
sessions[k] = { data: v };
|
|
}
|
|
}
|
|
} catch(e) {}
|
|
|
|
// ── UI ──
|
|
|
|
function ts() {
|
|
return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
function esc(s) {
|
|
const d = document.createElement('div');
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function addMsg(from, text, isSelf) {
|
|
const d = document.createElement('div');
|
|
d.className = 'msg';
|
|
const cls = isSelf ? 'from-self' : 'from-peer';
|
|
const lock = isSelf ? '' : '<span class="lock">🔒 </span>';
|
|
d.innerHTML = '<span class="ts">' + ts() + '</span> ' + lock + '<span class="' + cls + '">' + esc(from) + '</span>: ' + esc(text);
|
|
$messages.appendChild(d);
|
|
$messages.scrollTop = $messages.scrollHeight;
|
|
}
|
|
|
|
function addSys(text) {
|
|
const d = document.createElement('div');
|
|
d.className = 'msg';
|
|
d.innerHTML = '<span class="ts">' + ts() + '</span> <span class="from-sys">' + esc(text) + '</span>';
|
|
$messages.appendChild(d);
|
|
$messages.scrollTop = $messages.scrollHeight;
|
|
}
|
|
|
|
// ── Actions ──
|
|
|
|
async function doGenerate() {
|
|
generateNewIdentity();
|
|
document.getElementById('my-fp').textContent = myFingerprint;
|
|
document.getElementById('my-seed').textContent = mySeedHex;
|
|
document.getElementById('gen-section').style.display = 'none';
|
|
document.getElementById('id-section').style.display = 'block';
|
|
}
|
|
|
|
async function doRecover() {
|
|
const input = document.getElementById('recover-input').value.trim().replace(/\s+/g, '');
|
|
try {
|
|
initIdentityFromSeed(input);
|
|
document.getElementById('my-fp').textContent = myFingerprint;
|
|
document.getElementById('my-seed').textContent = '(recovered)';
|
|
document.getElementById('gen-section').style.display = 'none';
|
|
document.getElementById('id-section').style.display = 'block';
|
|
} catch(e) {
|
|
alert('Invalid seed: ' + e.message);
|
|
}
|
|
}
|
|
|
|
let currentGroup = null; // if set, messages go to group
|
|
|
|
async function enterChat() {
|
|
document.getElementById('setup').classList.remove('active');
|
|
document.getElementById('chat').classList.add('active');
|
|
document.getElementById('hdr-fp').textContent = myFingerprint.slice(0, 19);
|
|
document.getElementById('hdr-server').textContent = SERVER;
|
|
|
|
await registerKey();
|
|
addSys('Identity loaded: ' + myFingerprint);
|
|
addSys('Key registered with server');
|
|
addSys('DM: paste peer fingerprint or @alias above');
|
|
addSys('/alias <name> · /g <group> · /glist · /info · /clear');
|
|
|
|
const savedPeer = localStorage.getItem('wz-peer');
|
|
if (savedPeer) $peerInput.value = savedPeer;
|
|
|
|
pollTimer = setInterval(pollMessages, 2000);
|
|
$input.focus();
|
|
}
|
|
|
|
// ── Group helpers ──
|
|
|
|
async function groupCreate(name) {
|
|
const resp = await fetch(SERVER + '/v1/groups/create', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name, creator: normFP(myFingerprint) })
|
|
});
|
|
const data = await resp.json();
|
|
if (data.error) { addSys('Error: ' + data.error); return; }
|
|
addSys('Group "' + name + '" created. Join with /gjoin ' + name);
|
|
}
|
|
|
|
async function groupJoin(name) {
|
|
const resp = await fetch(SERVER + '/v1/groups/' + name + '/join', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fingerprint: normFP(myFingerprint) })
|
|
});
|
|
const data = await resp.json();
|
|
if (data.error) { addSys('Error: ' + data.error); return; }
|
|
addSys('Joined group "' + name + '" (' + data.members + ' members)');
|
|
}
|
|
|
|
async function groupSwitch(name) {
|
|
// Auto-join
|
|
await groupJoin(name);
|
|
const resp = await fetch(SERVER + '/v1/groups/' + name);
|
|
const data = await resp.json();
|
|
if (data.error) { addSys('Error: ' + data.error); return; }
|
|
currentGroup = name;
|
|
$peerInput.value = '#' + name;
|
|
addSys('Switched to group "' + name + '" (' + data.count + ' members: ' + data.members.map(m => m.slice(0,8)).join(', ') + ')');
|
|
}
|
|
|
|
async function groupList() {
|
|
const resp = await fetch(SERVER + '/v1/groups');
|
|
const data = await resp.json();
|
|
if (!data.groups || data.groups.length === 0) { addSys('No groups'); return; }
|
|
for (const g of data.groups) {
|
|
addSys(' #' + g.name + ' (' + g.members + ' members)');
|
|
}
|
|
}
|
|
|
|
async function sendToGroup(groupName, text) {
|
|
// Get member list
|
|
const resp = await fetch(SERVER + '/v1/groups/' + groupName);
|
|
const data = await resp.json();
|
|
if (data.error) { addSys('Error: ' + data.error); return; }
|
|
|
|
const myFP = normFP(myFingerprint);
|
|
const members = data.members.filter(m => m !== myFP);
|
|
if (members.length === 0) { addSys('No other members in group'); return; }
|
|
|
|
// Encrypt for each member using WASM (same crypto as CLI)
|
|
const messages = [];
|
|
for (const memberFP of members) {
|
|
try {
|
|
const bundleBytes = await fetchPeerBundle(memberFP);
|
|
let wireBytes;
|
|
if (sessions[memberFP]) {
|
|
const sess = WasmSession.restore(sessions[memberFP].data);
|
|
wireBytes = sess.encrypt(wasmIdentity, text);
|
|
sessions[memberFP].data = sess.save();
|
|
} else {
|
|
const sess = WasmSession.initiate(wasmIdentity, bundleBytes);
|
|
wireBytes = sess.encrypt_key_exchange(wasmIdentity, bundleBytes, text);
|
|
sessions[memberFP] = { data: sess.save() };
|
|
}
|
|
messages.push({ to: memberFP, message: Array.from(wireBytes) });
|
|
} catch(e) {
|
|
// Skip members we can't encrypt for
|
|
}
|
|
}
|
|
// Save sessions
|
|
localStorage.setItem('wz-sessions', JSON.stringify(
|
|
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
|
|
));
|
|
|
|
if (messages.length === 0) { addSys('No members to send to'); return; }
|
|
|
|
await fetch(SERVER + '/v1/groups/' + groupName + '/send', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ from: myFP, messages })
|
|
});
|
|
|
|
addMsg(myFingerprint.slice(0, 19) + ' [' + groupName + ']', text, true);
|
|
}
|
|
|
|
// ── Send handler ──
|
|
|
|
async function doSend() {
|
|
const text = $input.value.trim();
|
|
$input.value = '';
|
|
$input.style.height = 'auto';
|
|
if (!text) return;
|
|
|
|
// Commands
|
|
if (text === '/info') {
|
|
const aliasResp = await fetch(SERVER + '/v1/alias/whois/' + normFP(myFingerprint));
|
|
const aliasData = await aliasResp.json();
|
|
const aliasStr = aliasData.alias ? ' (@' + aliasData.alias + ')' : '';
|
|
addSys('Fingerprint: ' + myFingerprint + aliasStr);
|
|
return;
|
|
}
|
|
if (text === '/clear') { $messages.innerHTML = ''; return; }
|
|
if (text === '/quit') { window.close(); return; }
|
|
if (text === '/glist') { await groupList(); return; }
|
|
if (text === '/dm') { currentGroup = null; addSys('Switched to DM mode'); $peerInput.value = localStorage.getItem('wz-peer') || ''; return; }
|
|
if (text === '/aliases') {
|
|
const resp = await fetch(SERVER + '/v1/alias/list');
|
|
const data = await resp.json();
|
|
if (data.aliases.length === 0) { addSys('No aliases registered'); }
|
|
else { for (const a of data.aliases) addSys(' @' + a.alias + ' → ' + a.fingerprint.slice(0,16) + '...'); }
|
|
return;
|
|
}
|
|
|
|
if (text.startsWith('/alias ')) {
|
|
const name = text.slice(7).trim();
|
|
const resp = await fetch(SERVER + '/v1/alias/register', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ alias: name, fingerprint: normFP(myFingerprint) })
|
|
});
|
|
const data = await resp.json();
|
|
if (data.error) { addSys('Error: ' + data.error); }
|
|
else { addSys('Alias @' + data.alias + ' registered'); }
|
|
return;
|
|
}
|
|
if (text.startsWith('/gcreate ')) { await groupCreate(text.slice(9).trim()); return; }
|
|
if (text.startsWith('/gjoin ')) { await groupJoin(text.slice(7).trim()); return; }
|
|
if (text.startsWith('/g ')) { await groupSwitch(text.slice(3).trim()); return; }
|
|
|
|
// Send to group or DM
|
|
if (currentGroup) {
|
|
try {
|
|
await sendToGroup(currentGroup, text);
|
|
} catch(e) {
|
|
addSys('Group send failed: ' + e.message);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// DM — resolve @alias if needed
|
|
let peer = $peerInput.value.trim();
|
|
if (!peer || peer.startsWith('#')) { addSys('Set a peer fingerprint/@alias or use /g <group>'); return; }
|
|
|
|
if (peer.startsWith('@')) {
|
|
const aliasName = peer.slice(1);
|
|
const resp = await fetch(SERVER + '/v1/alias/resolve/' + aliasName);
|
|
const data = await resp.json();
|
|
if (data.error) { addSys('Unknown alias @' + aliasName); return; }
|
|
peer = data.fingerprint;
|
|
addSys('Resolved @' + aliasName + ' → ' + peer.slice(0,16) + '...');
|
|
}
|
|
|
|
localStorage.setItem('wz-peer', $peerInput.value.trim());
|
|
|
|
try {
|
|
await sendEncrypted(peer, text);
|
|
addMsg(myFingerprint.slice(0, 19), text, true);
|
|
} catch(e) {
|
|
addSys('Send failed: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// Keyboard
|
|
$input.onkeydown = function(e) {
|
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doSend(); }
|
|
};
|
|
$input.addEventListener('input', function() {
|
|
this.style.height = 'auto';
|
|
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
|
|
});
|
|
|
|
// Initialize WASM and auto-load
|
|
(async function() {
|
|
try {
|
|
await initWasm();
|
|
if (loadSavedIdentity()) {
|
|
enterChat();
|
|
}
|
|
} catch(e) {
|
|
addSys('Failed to load WASM crypto: ' + e.message);
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>"##;
|