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

@@ -81,9 +81,30 @@ details summary { color: var(--muted); font-size: 12px; cursor: pointer; }
.message { display: flex; align-items: baseline; gap: 0; padding: 2px 16px; line-height: 1.5; } .message { display: flex; align-items: baseline; gap: 0; padding: 2px 16px; line-height: 1.5; }
.message:hover { background: rgba(255,255,255,0.02); } .message:hover { background: rgba(255,255,255,0.02); }
.message-ts { color: var(--muted); font-size: 11px; white-space: nowrap; flex-shrink: 0; width: 52px; } .message-ts { color: var(--muted); font-size: 11px; white-space: nowrap; flex-shrink: 0; width: 52px; }
.message-alias { font-weight: 600; font-size: 13px; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; width: 90px; padding-right: 10px; } .message-alias { font-weight: 600; font-size: 13px; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; width: 90px; padding-right: 10px; }
.message.mine .message-alias { color: var(--accent); } .message.mine .message-alias { color: var(--accent); }
.message-text { word-break: break-word; font-size: 14px; color: var(--text); } .message-text { word-break: break-word; font-size: 14px; color: var(--text); }
.compose { display: flex; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--border); } .compose { display: flex; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--border); }
.compose input { flex: 1; width: auto; } .compose input { flex: 1; width: auto; }
.compose button { flex-shrink: 0; } .compose button { flex-shrink: 0; }
/* ── file browser panel ── */
.chat-layout.has-file-browser { grid-template-columns: var(--sidebar-w) 1fr 280px; }
.file-browser { display: flex; flex-direction: column; background: var(--surface); border-left: 1px solid var(--border); }
.file-browser-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--border); }
.file-browser-title { font-size: 13px; font-weight: 600; }
.file-browser-close { background: none; color: var(--muted); font-size: 14px; padding: 2px 6px; }
.file-browser-close:hover { color: var(--text); background: none; }
.file-browser-empty { padding: 16px 12px; color: var(--muted); font-size: 13px; }
.file-list { list-style: none; padding: 4px 0; overflow-y: auto; flex: 1; }
.file-entry { display: flex; align-items: center; gap: 6px; padding: 5px 12px; }
.file-entry:hover { background: rgba(255,255,255,0.04); }
.file-entry-name { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-entry-size { color: var(--muted); font-size: 11px; flex-shrink: 0; }
.file-entry-dl { background: none; color: var(--accent); font-size: 14px; padding: 1px 4px; flex-shrink: 0; }
.file-entry-dl:hover { background: rgba(124,106,247,0.15); }
/* ── folder picker ── */
.folder-picker { width: 100%; }
.folder-picker-btn { background: var(--surface); color: var(--muted); border: 1px solid var(--border); font-size: 12px; padding: 5px 10px; border-radius: 4px; width: 100%; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.folder-picker-btn:hover { color: var(--text); border-color: var(--accent); background: var(--surface); }

View File

@@ -4,19 +4,20 @@ import { Onboarding } from './pages/Onboarding'
import { Chat } from './pages/Chat' import { Chat } from './pages/Chat'
import './App.css' import './App.css'
// When served from the anchor (non-localhost), default to browser mode.
// When running locally, try the daemon first.
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
const DAEMON_WS = import.meta.env.VITE_DAEMON_WS ?? 'ws://127.0.0.1:17338' const DAEMON_WS = import.meta.env.VITE_DAEMON_WS ?? 'ws://127.0.0.1:17338'
const cfg = (window as unknown as { WASTE_CONFIG?: { signalURL?: string } }).WASTE_CONFIG
// Use browser mode if a signal URL is configured, even on localhost
const useBrowser = !isLocal || !!cfg?.signalURL
export default function App() { export default function App() {
const { connect, connectBrowser, daemonStatus, localPeer } = useWaste() const { connect, connectBrowser, daemonStatus, localPeer } = useWaste()
useEffect(() => { useEffect(() => {
if (isLocal) { if (useBrowser) {
connect(DAEMON_WS)
} else {
connectBrowser() connectBrowser()
} else {
connect(DAEMON_WS)
} }
}, [connect, connectBrowser]) }, [connect, connectBrowser])

View File

@@ -245,13 +245,22 @@ class PeerConn {
public peerId: string public peerId: string
private on: PeerCallback private on: PeerCallback
private nick: string 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.identity = identity
this.sig = sig this.sig = sig
this.peerId = peerId this.peerId = peerId
this.on = on this.on = on
this.nick = nick this.nick = nick
this.share = share
this.pc = new RTCPeerConnection({ iceServers: [{ urls: STUN }] }) this.pc = new RTCPeerConnection({ iceServers: [{ urls: STUN }] })
const kp = sodium.crypto_box_keypair() const kp = sodium.crypto_box_keypair()
this._esk = kp.privateKey this._esk = kp.privateKey
@@ -349,11 +358,30 @@ class PeerConn {
} }
private _wire(channel: RTCDataChannel) { private _wire(channel: RTCDataChannel) {
if (channel.label !== 'yaw') return if (channel.label === 'yaw') {
this.dc = channel this.dc = channel
channel.onopen = () => this._sendHello() channel.onopen = () => this._sendHello()
channel.onmessage = (ev) => this._onControl(ev.data) channel.onmessage = (ev) => this._onControl(ev.data)
if (channel.readyState === 'open') this._sendHello() 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() { private _sendHello() {
@@ -385,9 +413,73 @@ class PeerConn {
}) })
} else if (m['type'] === 'pm') { } else if (m['type'] === 'pm') {
this.on('pm', { peer: this.peerId, text: m['text'] as string, ts: m['ts'] as number || Date.now() }) 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() { async reportStats() {
try { try {
const stats = await this.pc.getStats() const stats = await this.pc.getStats()
@@ -449,6 +541,7 @@ export class BrowserAdapter {
private present: Set<string> = new Set() private present: Set<string> = new Set()
private networkId = '' private networkId = ''
private networkName = '' private networkName = ''
sharedFiles: Map<string, File> = new Map()
status: Status = 'disconnected' status: Status = 'disconnected'
onStatusChange?: (s: Status) => void onStatusChange?: (s: Status) => void
@@ -578,7 +671,7 @@ export class BrowserAdapter {
} }
const peer = new PeerConn(this.identity, this.sig!, pid, 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) this.peers.set(pid, peer)
await peer.startOffer() await peer.startOffer()
} }
@@ -588,7 +681,7 @@ export class BrowserAdapter {
let peer = this.peers.get(from) let peer = this.peers.get(from)
if (!peer || peer.pc.connectionState === 'failed' || peer.pc.connectionState === 'closed') { if (!peer || peer.pc.connectionState === 'failed' || peer.pc.connectionState === 'closed') {
peer = new PeerConn(this.identity, this.sig!, from, 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) this.peers.set(from, peer)
} }
await peer.onBox(box) await peer.onBox(box)
@@ -645,9 +738,40 @@ export class BrowserAdapter {
candidate_type: data['candidateType'] as import('../types').CandidateType, candidate_type: data['candidateType'] as import('../types').CandidateType,
remote_address: data['remoteAddress'] as string, 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) { send(msg: IpcMessage) {
if (!this.identity) return if (!this.identity) return

View File

@@ -0,0 +1,48 @@
import { useWaste } from '../store'
import { BrowserAdapter } from '../adapter/browser'
function fmt(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export function FileBrowser() {
const { activeFilePeer, fileLists, setActiveFilePeer, adapter, connectedPeers } = useWaste()
if (!activeFilePeer) return null
const peer = connectedPeers.find(p => p.id === activeFilePeer)
const files = fileLists[activeFilePeer] ?? null
function download(name: string) {
if (adapter instanceof BrowserAdapter) {
adapter.requestGet(activeFilePeer!, name)
}
}
return (
<div className="file-browser">
<div className="file-browser-header">
<span className="file-browser-title">Files · {peer?.alias ?? activeFilePeer.slice(0, 8)}</span>
<button className="file-browser-close" onClick={() => setActiveFilePeer(null)}></button>
</div>
{files === null ? (
<div className="file-browser-empty">Loading</div>
) : files.length === 0 ? (
<div className="file-browser-empty">No files shared</div>
) : (
<ul className="file-list">
{files.map(f => (
<li key={f.name} className="file-entry">
<span className="file-entry-name">{f.name}</span>
<span className="file-entry-size">{fmt(f.size_bytes)}</span>
<button className="file-entry-dl" onClick={() => download(f.name)}></button>
</li>
))}
</ul>
)}
</div>
)
}

View File

@@ -0,0 +1,43 @@
import { useRef, useState } from 'react'
import { useWaste } from '../store'
export function FolderPicker() {
const { setSharedFiles } = useWaste()
const inputRef = useRef<HTMLInputElement>(null)
const [label, setLabel] = useState<string | null>(null)
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
const fileList = e.target.files
if (!fileList || fileList.length === 0) return
const map = new Map<string, File>()
for (let i = 0; i < fileList.length; i++) {
const f = fileList[i]
map.set(f.name, f)
}
setSharedFiles(map)
const first = fileList[0]
const parts = (first.webkitRelativePath || first.name).split('/')
setLabel(`${parts[0]} (${map.size} file${map.size !== 1 ? 's' : ''})`)
}
return (
<div className="folder-picker">
<input
ref={inputRef}
type="file"
// @ts-expect-error webkitdirectory is non-standard
webkitdirectory=""
multiple
style={{ display: 'none' }}
onChange={onChange}
/>
<button
className="folder-picker-btn"
onClick={() => inputRef.current?.click()}
title="Share a folder with peers"
>
{label ?? '+ Share folder'}
</button>
</div>
)
}

View File

@@ -1,5 +1,6 @@
import { useWaste } from '../store' import { useWaste } from '../store'
import type { PeerStatus } from '../store' import type { PeerStatus } from '../store'
import { FolderPicker } from './FolderPicker'
function makeYawCard(id: string, alias: string): string { function makeYawCard(id: string, alias: string): string {
const nick = encodeURIComponent(alias.trim().slice(0, 40)) const nick = encodeURIComponent(alias.trim().slice(0, 40))
@@ -28,7 +29,7 @@ export function Sidebar() {
localPeer, masterId, masterAlias, localPeer, masterId, masterAlias,
networks, activeNetworkId, activeRoom, networks, activeNetworkId, activeRoom,
connectedPeers, peerStatus, connectedPeers, peerStatus,
setActiveRoom, setActiveNetwork, messages, send, setActiveRoom, setActiveNetwork, messages, browseFiles, sendFileTo, adapterMode,
} = useWaste() } = useWaste()
const rooms = ['general'] const rooms = ['general']
@@ -45,7 +46,7 @@ export function Sidebar() {
} }
function requestFiles(peerId: string) { function requestFiles(peerId: string) {
send({ type: 'get_file_list', network_id: activeNetworkId ?? undefined, peer_id: peerId }) browseFiles(peerId)
} }
return ( return (
@@ -86,6 +87,13 @@ export function Sidebar() {
))} ))}
</div> </div>
{adapterMode === 'browser' && (
<div className="sidebar-section">
<span className="sidebar-label">Sharing</span>
<div style={{ padding: '4px 12px' }}><FolderPicker /></div>
</div>
)}
<div className="sidebar-section sidebar-peers"> <div className="sidebar-section sidebar-peers">
<span className="sidebar-label">Peers · {connectedPeers.length + 1}</span> <span className="sidebar-label">Peers · {connectedPeers.length + 1}</span>
@@ -116,7 +124,15 @@ export function Sidebar() {
<span className="peer-row-id">{p.id.slice(0, 8)}</span> <span className="peer-row-id">{p.id.slice(0, 8)}</span>
<span className="peer-row-actions"> <span className="peer-row-actions">
<button className="peer-action" onClick={() => openDM(p.id)} title="DM"></button> <button className="peer-action" onClick={() => openDM(p.id)} title="DM"></button>
<button className="peer-action" onClick={() => requestFiles(p.id)} title="Files"></button> <button className="peer-action" onClick={() => requestFiles(p.id)} title="Browse files"></button>
<label className="peer-action" title="Send file" style={{ cursor: 'pointer' }}>
📎
<input type="file" style={{ display: 'none' }} onChange={e => {
const f = e.target.files?.[0]
if (f) sendFileTo(p.id, f)
e.target.value = ''
}} />
</label>
</span> </span>
</div> </div>
) )

View File

@@ -1,11 +1,15 @@
import { Sidebar } from '../components/Sidebar' import { Sidebar } from '../components/Sidebar'
import { MessagePane } from '../components/MessagePane' import { MessagePane } from '../components/MessagePane'
import { FileBrowser } from '../components/FileBrowser'
import { useWaste } from '../store'
export function Chat() { export function Chat() {
const { activeFilePeer } = useWaste()
return ( return (
<div className="chat-layout"> <div className={`chat-layout${activeFilePeer ? ' has-file-browser' : ''}`}>
<Sidebar /> <Sidebar />
<MessagePane /> <MessagePane />
{activeFilePeer && <FileBrowser />}
</div> </div>
) )
} }

View File

@@ -43,6 +43,9 @@ interface WasteState {
// identity backup export result (cleared when consumed) // identity backup export result (cleared when consumed)
exportedBackup: string | null exportedBackup: string | null
// file browser state
activeFilePeer: string | null
// actions // actions
connect: (url: string) => void connect: (url: string) => void
connectBrowser: () => void connectBrowser: () => void
@@ -50,6 +53,10 @@ interface WasteState {
send: (msg: IpcMessage) => void send: (msg: IpcMessage) => void
setActiveNetwork: (id: string) => void setActiveNetwork: (id: string) => void
setActiveRoom: (room: string) => void setActiveRoom: (room: string) => void
setActiveFilePeer: (peerId: string | null) => void
setSharedFiles: (files: Map<string, File>) => void
browseFiles: (peerId: string) => void
sendFileTo: (peerId: string, file: File) => void
handleEvent: (msg: IpcMessage) => void handleEvent: (msg: IpcMessage) => void
} }
@@ -68,6 +75,7 @@ export const useWaste = create<WasteState>((set, get) => ({
fileLists: {}, fileLists: {},
exportedBackup: null, exportedBackup: null,
peerStatus: {}, peerStatus: {},
activeFilePeer: null,
connect(url: string) { connect(url: string) {
const adapter = new DaemonAdapter(url) const adapter = new DaemonAdapter(url)
@@ -104,6 +112,30 @@ export const useWaste = create<WasteState>((set, get) => ({
set({ activeRoom: room }) set({ activeRoom: room })
}, },
setActiveFilePeer(peerId) {
set({ activeFilePeer: peerId })
},
setSharedFiles(files) {
const a = get().adapter
if (a instanceof BrowserAdapter) a.setSharedFiles(files)
},
sendFileTo(peerId, file) {
const a = get().adapter
if (a instanceof BrowserAdapter) a.sendFileTo(peerId, file)
},
browseFiles(peerId) {
const a = get().adapter
if (a instanceof BrowserAdapter) {
a.requestBrowse(peerId)
} else {
get().send({ type: 'get_file_list', peer_id: peerId as unknown as import('../types').PeerID })
}
set({ activeFilePeer: peerId })
},
handleEvent(msg) { handleEvent(msg) {
switch (msg.type) { switch (msg.type) {
case 'state_snapshot': { case 'state_snapshot': {
@@ -215,6 +247,16 @@ export const useWaste = create<WasteState>((set, get) => ({
} }
break break
} }
case 'file_complete': {
// path holds the blob URL, offer.name holds the filename
if (msg.path && msg.offer?.name) {
const a = document.createElement('a')
a.href = msg.path
a.download = msg.offer.name
a.click()
}
break
}
} }
}, },
})) }))