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

@@ -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 type { PeerStatus } from '../store'
import { FolderPicker } from './FolderPicker'
function makeYawCard(id: string, alias: string): string {
const nick = encodeURIComponent(alias.trim().slice(0, 40))
@@ -28,7 +29,7 @@ export function Sidebar() {
localPeer, masterId, masterAlias,
networks, activeNetworkId, activeRoom,
connectedPeers, peerStatus,
setActiveRoom, setActiveNetwork, messages, send,
setActiveRoom, setActiveNetwork, messages, browseFiles, sendFileTo, adapterMode,
} = useWaste()
const rooms = ['general']
@@ -45,7 +46,7 @@ export function Sidebar() {
}
function requestFiles(peerId: string) {
send({ type: 'get_file_list', network_id: activeNetworkId ?? undefined, peer_id: peerId })
browseFiles(peerId)
}
return (
@@ -86,6 +87,13 @@ export function Sidebar() {
))}
</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">
<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-actions">
<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>
</div>
)