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:
@@ -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: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-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-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 input { flex: 1; width: auto; }
|
||||
.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); }
|
||||
|
||||
@@ -4,19 +4,20 @@ import { Onboarding } from './pages/Onboarding'
|
||||
import { Chat } from './pages/Chat'
|
||||
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 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() {
|
||||
const { connect, connectBrowser, daemonStatus, localPeer } = useWaste()
|
||||
|
||||
useEffect(() => {
|
||||
if (isLocal) {
|
||||
connect(DAEMON_WS)
|
||||
} else {
|
||||
if (useBrowser) {
|
||||
connectBrowser()
|
||||
} else {
|
||||
connect(DAEMON_WS)
|
||||
}
|
||||
}, [connect, connectBrowser])
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
|
||||
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>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Sidebar } from '../components/Sidebar'
|
||||
import { MessagePane } from '../components/MessagePane'
|
||||
import { FileBrowser } from '../components/FileBrowser'
|
||||
import { useWaste } from '../store'
|
||||
|
||||
export function Chat() {
|
||||
const { activeFilePeer } = useWaste()
|
||||
return (
|
||||
<div className="chat-layout">
|
||||
<div className={`chat-layout${activeFilePeer ? ' has-file-browser' : ''}`}>
|
||||
<Sidebar />
|
||||
<MessagePane />
|
||||
{activeFilePeer && <FileBrowser />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ interface WasteState {
|
||||
// identity backup export result (cleared when consumed)
|
||||
exportedBackup: string | null
|
||||
|
||||
// file browser state
|
||||
activeFilePeer: string | null
|
||||
|
||||
// actions
|
||||
connect: (url: string) => void
|
||||
connectBrowser: () => void
|
||||
@@ -50,6 +53,10 @@ interface WasteState {
|
||||
send: (msg: IpcMessage) => void
|
||||
setActiveNetwork: (id: 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
|
||||
}
|
||||
|
||||
@@ -68,6 +75,7 @@ export const useWaste = create<WasteState>((set, get) => ({
|
||||
fileLists: {},
|
||||
exportedBackup: null,
|
||||
peerStatus: {},
|
||||
activeFilePeer: null,
|
||||
|
||||
connect(url: string) {
|
||||
const adapter = new DaemonAdapter(url)
|
||||
@@ -104,6 +112,30 @@ export const useWaste = create<WasteState>((set, get) => ({
|
||||
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) {
|
||||
switch (msg.type) {
|
||||
case 'state_snapshot': {
|
||||
@@ -215,6 +247,16 @@ export const useWaste = create<WasteState>((set, get) => ({
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user