Web file transfer: send + receive with auto-download

Web client:
- Paperclip file upload button in chat bar
- Chunked upload: 64KB chunks, SHA-256 integrity
- Progress display during send/receive
- Auto-download on complete (browser save dialog)
- Max 10MB per file

WASM:
- decrypt_wire_message now returns file_header and file_chunk
  with type, id, filename, chunk data (hex encoded)

Receive flow:
- FileHeader: registers pending transfer
- FileChunk: stores chunk, shows progress
- All chunks received: assembles, triggers blob download

Send flow (web→web or web→CLI):
- File sent as JSON messages (not bincode, for simplicity)
- Receiver handles both JSON and bincode formats

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-27 16:07:17 +04:00
parent c9f3e338a7
commit ebaf5df671
2 changed files with 149 additions and 1 deletions

View File

@@ -213,6 +213,7 @@ const WEB_HTML: &str = r##"<!DOCTYPE html>
</div>
<div id="messages"></div>
<div id="bottom">
<label id="file-btn" title="Send file" style="padding:10px;background:#1a1a2e;border:1px solid #333;color:#c8d6e5;border-radius:50%;cursor:pointer;min-width:40px;min-height:40px;text-align:center;line-height:20px;font-size:1.1em">&#128206;<input type="file" id="file-input" style="display:none"></label>
<textarea id="msg-input" placeholder="Message... (Enter to send)" rows="1"></textarea>
<button id="send-btn">&#9654;</button>
</div>
@@ -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) {

View File

@@ -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())