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