Add peer-to-peer file transfer in browser mode

Implements both pull (browse & download from a shared folder) and push
(send a file directly to a peer) using the yaw2 file protocol over WebRTC
DataChannels.

- PeerConn: handle browse/files/get/file-offer/file-accept/file-done
  control messages; stream files in 64KB chunks over f:xid binary
  DataChannels; auto-accept incoming offers (push and pull)
- PeerConn.sendFilePush(): initiate an outbound file transfer without
  requiring the peer to browse first
- BrowserAdapter: sharedFiles map, setSharedFiles(), requestBrowse(),
  requestGet(), sendFileTo() — propagates share map to all active peers
- Store: activeFilePeer, setActiveFilePeer, setSharedFiles, browseFiles,
  sendFileTo; file_complete handler triggers browser download automatically
- FileBrowser component: third-column panel listing a peer's shared files
  with name, size, and a download button
- FolderPicker component: webkitdirectory input in the sidebar (browser
  mode only) for selecting a local folder to share
- Sidebar: 📎 send-file button on peer rows (hover) with inline file picker
- App.tsx: use browser mode when WASTE_CONFIG.signalURL is set, even on
  localhost — allows testing browser mode via npm run dev with config.js
- deploy-daemon.sh: kill existing daemon before scp to avoid upload failure
  when the binary is locked; remove duplicate kill block in restart step

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-06-25 20:29:54 +02:00
parent ea66e2eb58
commit 91b7406d01
8 changed files with 317 additions and 18 deletions

View File

@@ -245,13 +245,22 @@ class PeerConn {
public peerId: string
private on: PeerCallback
private nick: string
share: Map<string, File>
constructor(identity: Identity, sig: Signaling, peerId: string, on: PeerCallback, nick: string) {
// in-flight receives: xid → {name, size, buf, have, done}
private _recv: Record<string, { name: string; size: number; sha: string; buf: ArrayBuffer[]; have: number; done: boolean }> = {}
// in-flight sends: xid → Uint8Array
private _send: Record<string, Uint8Array> = {}
// push-initiated files waiting for file-accept: xid → File
private _pushQueue: Map<string, File> = new Map()
constructor(identity: Identity, sig: Signaling, peerId: string, on: PeerCallback, nick: string, share: Map<string, File>) {
this.identity = identity
this.sig = sig
this.peerId = peerId
this.on = on
this.nick = nick
this.share = share
this.pc = new RTCPeerConnection({ iceServers: [{ urls: STUN }] })
const kp = sodium.crypto_box_keypair()
this._esk = kp.privateKey
@@ -349,11 +358,30 @@ class PeerConn {
}
private _wire(channel: RTCDataChannel) {
if (channel.label !== 'yaw') return
this.dc = channel
channel.onopen = () => this._sendHello()
channel.onmessage = (ev) => this._onControl(ev.data)
if (channel.readyState === 'open') this._sendHello()
if (channel.label === 'yaw') {
this.dc = channel
channel.onopen = () => this._sendHello()
channel.onmessage = (ev) => this._onControl(ev.data)
if (channel.readyState === 'open') this._sendHello()
return
}
if (channel.label.startsWith('f:')) {
const xid = channel.label.slice(2)
const rx = this._recv[xid]
if (!rx) return
channel.binaryType = 'arraybuffer'
channel.onmessage = (ev) => {
if (!(ev.data instanceof ArrayBuffer)) return
rx.buf.push(ev.data)
rx.have += ev.data.byteLength
}
channel.onclose = async () => {
const blob = new Blob(rx.buf)
const url = URL.createObjectURL(blob)
this.on('file_recv', { peer: this.peerId, name: rx.name, size: rx.size, url })
delete this._recv[xid]
}
}
}
private _sendHello() {
@@ -385,9 +413,73 @@ class PeerConn {
})
} else if (m['type'] === 'pm') {
this.on('pm', { peer: this.peerId, text: m['text'] as string, ts: m['ts'] as number || Date.now() })
} else if (m['type'] === 'browse') {
const path = (m['path'] as string) || '/'
void path // path-based subdirs not supported; always return root
const files = Array.from(this.share.values()).map(f => ({
name: f.name, size: f.size, mime: f.type
}))
this._dc({ type: 'files', files })
} else if (m['type'] === 'files') {
const files = m['files'] as Array<{ name: string; size: number; mime?: string }>
this.on('files', { peer: this.peerId, files })
} else if (m['type'] === 'get') {
const name = m['name'] as string
const file = this.share.get(name)
if (!file) return
this._dc({ type: 'file-offer', name: file.name, size: file.size, xid: name })
} else if (m['type'] === 'file-offer') {
const name = m['name'] as string
const size = m['size'] as number
const xid = m['xid'] as string
this._recv[xid] = { name, size, sha: '', buf: [], have: 0, done: false }
this._dc({ type: 'file-accept', xid })
} else if (m['type'] === 'file-accept') {
const xid = m['xid'] as string
if (this._pushQueue.has(xid) || this.share.has(xid)) void this._stream(xid)
} else if (m['type'] === 'file-done') {
const xid = m['xid'] as string
if (this._recv[xid]) this._recv[xid].done = true
}
}
requestBrowse() {
this._dc({ type: 'browse', path: '/' })
}
requestGet(name: string) {
this._dc({ type: 'get', name })
}
sendFilePush(file: File) {
const xid = `push-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
this._pushQueue.set(xid, file)
this._dc({ type: 'file-offer', name: file.name, size: file.size, xid })
}
private async _stream(xid: string) {
const file = this._pushQueue.get(xid) ?? this.share.get(xid)
if (!file) return
this._pushQueue.delete(xid)
const dc = this.pc.createDataChannel(`f:${xid}`)
dc.binaryType = 'arraybuffer'
this._send[xid] = new Uint8Array(0) // marker
await new Promise<void>(res => { dc.onopen = () => res() })
const CHUNK = 64 * 1024
const buf = await file.arrayBuffer()
let offset = 0
while (offset < buf.byteLength) {
while (dc.bufferedAmount > 1024 * 1024) {
await new Promise(r => setTimeout(r, 10))
}
dc.send(buf.slice(offset, offset + CHUNK))
offset += CHUNK
}
dc.close()
delete this._send[xid]
this._dc({ type: 'file-done', xid })
}
async reportStats() {
try {
const stats = await this.pc.getStats()
@@ -449,6 +541,7 @@ export class BrowserAdapter {
private present: Set<string> = new Set()
private networkId = ''
private networkName = ''
sharedFiles: Map<string, File> = new Map()
status: Status = 'disconnected'
onStatusChange?: (s: Status) => void
@@ -578,7 +671,7 @@ export class BrowserAdapter {
}
const peer = new PeerConn(this.identity, this.sig!, pid,
(ev, data) => this._onPeerEvent(ev, data), this.identity.nick)
(ev, data) => this._onPeerEvent(ev, data), this.identity.nick, this.sharedFiles)
this.peers.set(pid, peer)
await peer.startOffer()
}
@@ -588,7 +681,7 @@ export class BrowserAdapter {
let peer = this.peers.get(from)
if (!peer || peer.pc.connectionState === 'failed' || peer.pc.connectionState === 'closed') {
peer = new PeerConn(this.identity, this.sig!, from,
(ev, data) => this._onPeerEvent(ev, data), this.identity.nick)
(ev, data) => this._onPeerEvent(ev, data), this.identity.nick, this.sharedFiles)
this.peers.set(from, peer)
}
await peer.onBox(box)
@@ -645,9 +738,40 @@ export class BrowserAdapter {
candidate_type: data['candidateType'] as import('../types').CandidateType,
remote_address: data['remoteAddress'] as string,
})
} else if (event === 'files') {
const raw = data['files'] as Array<{ name: string; size: number; mime?: string }>
this.emit({
type: 'file_list',
peer_id: data['peer'] as string,
files: raw.map(f => ({ name: f.name, size_bytes: f.size })),
})
} else if (event === 'file_recv') {
this.emit({
type: 'file_complete',
peer_id: data['peer'] as string,
path: data['url'] as string,
offer: { xid: data['name'] as string, name: data['name'] as string, size: data['size'] as number, sha256: '' },
})
}
}
setSharedFiles(files: Map<string, File>) {
this.sharedFiles = files
this.peers.forEach(p => { p.share = files })
}
requestBrowse(peerId: string) {
this.peers.get(peerId)?.requestBrowse()
}
requestGet(peerId: string, name: string) {
this.peers.get(peerId)?.requestGet(name)
}
sendFileTo(peerId: string, file: File) {
this.peers.get(peerId)?.sendFilePush(file)
}
send(msg: IpcMessage) {
if (!this.identity) return