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:
48
web/src/components/FileBrowser.tsx
Normal file
48
web/src/components/FileBrowser.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
web/src/components/FolderPicker.tsx
Normal file
43
web/src/components/FolderPicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user