diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs
index de29d52..cad7e18 100644
--- a/warzone/crates/warzone-server/src/routes/web.rs
+++ b/warzone/crates/warzone-server/src/routes/web.rs
@@ -213,6 +213,7 @@ const WEB_HTML: &str = r##"
+
@@ -485,6 +486,14 @@ async function handleIncomingMessage(bytes) {
updateReceiptDisplay(result.message_id, result.receipt_type);
return;
}
+ if (result.type === 'file_header') {
+ handleFileHeader(result);
+ return;
+ }
+ if (result.type === 'file_chunk') {
+ handleFileChunk(result);
+ return;
+ }
// It was a KeyExchange
dbg('Decrypted (KeyExchange) from:', result.sender);
@@ -522,6 +531,8 @@ async function handleIncomingMessage(bytes) {
updateReceiptDisplay(result.message_id, result.receipt_type);
return;
}
+ if (result.type === 'file_header') { handleFileHeader(result); return; }
+ if (result.type === 'file_chunk') { handleFileChunk(result); return; }
dbg('Decrypted with session', senderFP);
@@ -547,6 +558,14 @@ async function handleIncomingMessage(bytes) {
}
}
+ // Last try: raw JSON file messages (from web file upload)
+ try {
+ const str = new TextDecoder().decode(bytes);
+ const json = JSON.parse(str);
+ if (json.type === 'file_header') { handleFileHeader(json); return; }
+ if (json.type === 'file_chunk') { handleFileChunk(json); return; }
+ } catch(e) {}
+
dbg('ALL decrypt attempts failed');
addSys('[message could not be decrypted]');
}
@@ -582,6 +601,66 @@ function peerColor(name) {
return PEER_COLORS[Math.abs(h) % PEER_COLORS.length];
}
+// ── File transfer handling ──
+
+function handleFileHeader(result) {
+ const id = result.id;
+ pendingFiles[id] = {
+ filename: result.filename,
+ file_size: result.file_size,
+ total_chunks: result.total_chunks,
+ sha256: result.sha256,
+ sender: result.sender,
+ chunks: new Array(result.total_chunks).fill(null),
+ received: 0,
+ };
+ addSys('Incoming file "' + result.filename + '" from ' + result.sender.slice(0,12) + ' (' + formatSize(result.file_size) + ', ' + result.total_chunks + ' chunks)');
+}
+
+function handleFileChunk(result) {
+ const pf = pendingFiles[result.id];
+ if (!pf) {
+ dbg('Received chunk for unknown file', result.id);
+ return;
+ }
+ // Decode hex data
+ const data = new Uint8Array(result.data.match(/.{1,2}/g).map(b => parseInt(b, 16)));
+ pf.chunks[result.chunk_index] = data;
+ pf.received++;
+
+ addSys('Receiving "' + pf.filename + '" [' + pf.received + '/' + pf.total_chunks + ']');
+
+ if (pf.received === pf.total_chunks) {
+ // Assemble file
+ let totalLen = 0;
+ for (const c of pf.chunks) totalLen += c.length;
+ const assembled = new Uint8Array(totalLen);
+ let offset = 0;
+ for (const c of pf.chunks) {
+ assembled.set(c, offset);
+ offset += c.length;
+ }
+
+ // Trigger download
+ const blob = new Blob([assembled]);
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = pf.filename;
+ a.click();
+ URL.revokeObjectURL(url);
+
+ addSys('File "' + pf.filename + '" downloaded (' + formatSize(assembled.length) + ')');
+ delete pendingFiles[result.id];
+ }
+}
+
+function formatSize(n) {
+ if (n < 1024) return n + ' B';
+ if (n < 1048576) return (n/1024).toFixed(1) + ' KB';
+ return (n/1048576).toFixed(1) + ' MB';
+}
+
function addMsg(from, text, isSelf, messageId) {
const d = document.createElement('div');
d.className = 'msg';
@@ -636,6 +715,7 @@ async function doRecover() {
let currentGroup = null; // if set, messages go to group
let lastDmPeer = null; // for /r reply
+let pendingFiles = {}; // file_id -> { filename, chunks: [], total, received, sha256, sender }
async function enterChat() {
document.getElementById('setup').classList.remove('active');
@@ -943,6 +1023,49 @@ document.getElementById('btn-show-recover').onclick = () => document.getElementB
document.getElementById('btn-recover').onclick = () => doRecover();
document.getElementById('btn-enter').onclick = () => enterChat();
document.getElementById('send-btn').onclick = () => doSend();
+document.getElementById('file-input').onchange = async function() {
+ if (!this.files.length) return;
+ const file = this.files[0];
+ if (file.size > 10 * 1024 * 1024) { addSys('File too large (max 10MB)'); this.value=''; return; }
+
+ const peer = $peerInput.value.trim();
+ if (!peer || peer.startsWith('#')) { addSys('Set a peer first'); this.value=''; return; }
+ const fp = normFP(peer);
+
+ const data = new Uint8Array(await file.arrayBuffer());
+ const CHUNK_SIZE = 65536;
+ const totalChunks = Math.ceil(data.length / CHUNK_SIZE);
+ const fileId = crypto.randomUUID();
+
+ // SHA-256 hash
+ const hashBuf = await crypto.subtle.digest('SHA-256', data);
+ const sha256 = Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2,'0')).join('');
+
+ addSys('Sending "' + file.name + '" (' + formatSize(data.length) + ', ' + totalChunks + ' chunks)...');
+
+ // Send header via HTTP (file messages are large, WS might choke)
+ const headerWire = { type: 'file_header', id: fileId, sender: normFP(myFingerprint), filename: file.name, file_size: data.length, total_chunks: totalChunks, sha256: sha256 };
+ // For now send as raw JSON message (server treats as opaque bytes)
+ const headerBytes = new TextEncoder().encode(JSON.stringify(headerWire));
+ await fetch(SERVER + '/v1/messages/send', {
+ method: 'POST', headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({ to: fp, from: normFP(myFingerprint), message: Array.from(headerBytes) })
+ });
+
+ for (let i = 0; i < totalChunks; i++) {
+ const chunk = data.slice(i * CHUNK_SIZE, (i+1) * CHUNK_SIZE);
+ const chunkWire = { type: 'file_chunk', id: fileId, sender: normFP(myFingerprint), filename: file.name, chunk_index: i, total_chunks: totalChunks, data: Array.from(chunk).map(b => b.toString(16).padStart(2,'0')).join('') };
+ const chunkBytes = new TextEncoder().encode(JSON.stringify(chunkWire));
+ await fetch(SERVER + '/v1/messages/send', {
+ method: 'POST', headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({ to: fp, from: normFP(myFingerprint), message: Array.from(chunkBytes) })
+ });
+ addSys('Sent chunk ' + (i+1) + '/' + totalChunks);
+ }
+
+ addSys('File "' + file.name + '" sent');
+ this.value = '';
+};
// PWA: service worker + install prompt
if ('serviceWorker' in navigator) {
diff --git a/warzone/crates/warzone-wasm/src/lib.rs b/warzone/crates/warzone-wasm/src/lib.rs
index bff860f..0ee4f58 100644
--- a/warzone/crates/warzone-wasm/src/lib.rs
+++ b/warzone/crates/warzone-wasm/src/lib.rs
@@ -448,8 +448,33 @@ pub fn decrypt_wire_message(
"receipt_type": rt_str,
}).to_string())
}
+ WireMessage::FileHeader {
+ id, sender_fingerprint, filename, file_size, total_chunks, sha256,
+ } => {
+ Ok(serde_json::json!({
+ "type": "file_header",
+ "id": id,
+ "sender": sender_fingerprint,
+ "filename": filename,
+ "file_size": file_size,
+ "total_chunks": total_chunks,
+ "sha256": sha256,
+ }).to_string())
+ }
+ WireMessage::FileChunk {
+ id, sender_fingerprint, filename, chunk_index, total_chunks, data,
+ } => {
+ Ok(serde_json::json!({
+ "type": "file_chunk",
+ "id": id,
+ "sender": sender_fingerprint,
+ "filename": filename,
+ "chunk_index": chunk_index,
+ "total_chunks": total_chunks,
+ "data": hex::encode(&data),
+ }).to_string())
+ }
_ => {
- // File transfer messages not yet handled in WASM
Ok(serde_json::json!({
"type": "unsupported",
}).to_string())