diff --git a/web/src/App.css b/web/src/App.css index 20ee3d5..db17295 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -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); } diff --git a/web/src/App.tsx b/web/src/App.tsx index d92f3f9..95b321c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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]) diff --git a/web/src/adapter/browser.ts b/web/src/adapter/browser.ts index b0dabf6..40d9b9c 100644 --- a/web/src/adapter/browser.ts +++ b/web/src/adapter/browser.ts @@ -245,13 +245,22 @@ class PeerConn { public peerId: string private on: PeerCallback private nick: string + share: Map - constructor(identity: Identity, sig: Signaling, peerId: string, on: PeerCallback, nick: string) { + // in-flight receives: xid → {name, size, buf, have, done} + private _recv: Record = {} + // in-flight sends: xid → Uint8Array + private _send: Record = {} + // push-initiated files waiting for file-accept: xid → File + private _pushQueue: Map = new Map() + + constructor(identity: Identity, sig: Signaling, peerId: string, on: PeerCallback, nick: string, share: Map) { 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(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 = new Set() private networkId = '' private networkName = '' + sharedFiles: Map = 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) { + 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 diff --git a/web/src/components/FileBrowser.tsx b/web/src/components/FileBrowser.tsx new file mode 100644 index 0000000..d58b571 --- /dev/null +++ b/web/src/components/FileBrowser.tsx @@ -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 ( +
+
+ Files · {peer?.alias ?? activeFilePeer.slice(0, 8)} + +
+ + {files === null ? ( +
Loading…
+ ) : files.length === 0 ? ( +
No files shared
+ ) : ( +
    + {files.map(f => ( +
  • + {f.name} + {fmt(f.size_bytes)} + +
  • + ))} +
+ )} +
+ ) +} diff --git a/web/src/components/FolderPicker.tsx b/web/src/components/FolderPicker.tsx new file mode 100644 index 0000000..bbc0459 --- /dev/null +++ b/web/src/components/FolderPicker.tsx @@ -0,0 +1,43 @@ +import { useRef, useState } from 'react' +import { useWaste } from '../store' + +export function FolderPicker() { + const { setSharedFiles } = useWaste() + const inputRef = useRef(null) + const [label, setLabel] = useState(null) + + function onChange(e: React.ChangeEvent) { + const fileList = e.target.files + if (!fileList || fileList.length === 0) return + const map = new Map() + 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 ( +
+ + +
+ ) +} diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 696c721..92f331c 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -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() { ))} + {adapterMode === 'browser' && ( +
+ Sharing +
+
+ )} +
Peers · {connectedPeers.length + 1} @@ -116,7 +124,15 @@ export function Sidebar() { {p.id.slice(0, 8)} - + +
) diff --git a/web/src/pages/Chat.tsx b/web/src/pages/Chat.tsx index b5e6d2e..c219d97 100644 --- a/web/src/pages/Chat.tsx +++ b/web/src/pages/Chat.tsx @@ -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 ( -
+
+ {activeFilePeer && }
) } diff --git a/web/src/store/index.ts b/web/src/store/index.ts index a73eb50..be73784 100644 --- a/web/src/store/index.ts +++ b/web/src/store/index.ts @@ -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) => void + browseFiles: (peerId: string) => void + sendFileTo: (peerId: string, file: File) => void handleEvent: (msg: IpcMessage) => void } @@ -68,6 +75,7 @@ export const useWaste = create((set, get) => ({ fileLists: {}, exportedBackup: null, peerStatus: {}, + activeFilePeer: null, connect(url: string) { const adapter = new DaemonAdapter(url) @@ -104,6 +112,30 @@ export const useWaste = create((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((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 + } } }, }))