2026-06-23 00:06:41 +02:00
|
|
|
// BrowserAdapter — standalone in-browser waste client.
|
|
|
|
|
// Speaks the yaw/2.1 signaling + WebRTC protocol; emits IpcMessage events
|
|
|
|
|
// identical to DaemonAdapter so the store and UI need no changes.
|
|
|
|
|
//
|
|
|
|
|
// No daemon required. Identity is Ed25519, stored as hex seed in localStorage.
|
|
|
|
|
// Crypto via libsodium-wrappers.
|
|
|
|
|
|
|
|
|
|
import sodium from 'libsodium-wrappers'
|
|
|
|
|
import type { IpcMessage, PeerInfo } from '../types'
|
|
|
|
|
|
|
|
|
|
type Listener = (msg: IpcMessage) => void
|
|
|
|
|
type Status = 'disconnected' | 'connecting' | 'connected'
|
|
|
|
|
|
|
|
|
|
const BIND_PREFIX = 'yaw/2 bind'
|
|
|
|
|
const EKEY_PREFIX = 'yaw/2.1 ekey'
|
|
|
|
|
const FS_TIMEOUT = 2000
|
|
|
|
|
const STUN = 'stun:stun.l.google.com:19302'
|
|
|
|
|
|
|
|
|
|
const enc = (s: string) => new TextEncoder().encode(s)
|
|
|
|
|
|
|
|
|
|
function concat(...arrs: Uint8Array[]): Uint8Array {
|
|
|
|
|
const n = arrs.reduce((a, b) => a + b.length, 0)
|
|
|
|
|
const out = new Uint8Array(n); let o = 0
|
|
|
|
|
for (const a of arrs) { out.set(a, o); o += a.length }
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 13:21:12 +02:00
|
|
|
function toHex(bytes: Uint8Array): string {
|
|
|
|
|
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function netHash(name: string): Promise<string> {
|
|
|
|
|
const input = enc('yaw2-net:' + name)
|
|
|
|
|
|
|
|
|
|
// Use native SHA-256 for protocol-compatible network IDs.
|
|
|
|
|
if (globalThis.crypto?.subtle) {
|
|
|
|
|
const digest = await globalThis.crypto.subtle.digest('SHA-256', input)
|
|
|
|
|
return toHex(new Uint8Array(digest))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback for environments where subtle crypto is unavailable.
|
|
|
|
|
const sha256 = (sodium as unknown as { crypto_hash_sha256?: (m: Uint8Array) => Uint8Array }).crypto_hash_sha256
|
|
|
|
|
if (sha256) return sodium.to_hex(sha256(input))
|
|
|
|
|
|
|
|
|
|
throw new Error('SHA-256 not available in this browser runtime')
|
2026-06-23 00:06:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Identity ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
class Identity {
|
|
|
|
|
pub: Uint8Array
|
|
|
|
|
priv: Uint8Array
|
|
|
|
|
id: string
|
|
|
|
|
curvePriv: Uint8Array
|
|
|
|
|
nick: string
|
|
|
|
|
|
|
|
|
|
constructor(kp: { publicKey: Uint8Array; privateKey: Uint8Array }) {
|
|
|
|
|
this.pub = kp.publicKey
|
|
|
|
|
this.priv = kp.privateKey
|
|
|
|
|
this.id = sodium.to_hex(this.pub)
|
|
|
|
|
this.curvePriv = sodium.crypto_sign_ed25519_sk_to_curve25519(this.priv)
|
|
|
|
|
this.nick = localStorage.getItem('waste_nick') || ''
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static load(): Identity {
|
|
|
|
|
const seedHex = localStorage.getItem('waste_seed')
|
|
|
|
|
let kp
|
|
|
|
|
if (seedHex) {
|
|
|
|
|
kp = sodium.crypto_sign_seed_keypair(sodium.from_hex(seedHex))
|
|
|
|
|
} else {
|
|
|
|
|
kp = sodium.crypto_sign_keypair()
|
|
|
|
|
localStorage.setItem('waste_seed', sodium.to_hex(kp.privateKey.slice(0, 32)))
|
|
|
|
|
}
|
|
|
|
|
return new Identity(kp)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setNick(nick: string) {
|
|
|
|
|
this.nick = nick.trim().slice(0, 40)
|
|
|
|
|
localStorage.setItem('waste_nick', this.nick)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sign(data: Uint8Array): Uint8Array {
|
|
|
|
|
return sodium.crypto_sign_detached(data, this.priv)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static verify(idHex: string, data: Uint8Array, sig: Uint8Array): boolean {
|
|
|
|
|
try { return sodium.crypto_sign_verify_detached(sig, data, sodium.from_hex(idHex)) }
|
|
|
|
|
catch { return false }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
seal(recipIdHex: string, plaintext: Uint8Array): string {
|
|
|
|
|
const pub = sodium.crypto_sign_ed25519_pk_to_curve25519(sodium.from_hex(recipIdHex))
|
|
|
|
|
const nonce = sodium.randombytes_buf(24)
|
|
|
|
|
const ct = sodium.crypto_box_easy(plaintext, nonce, pub, this.curvePriv)
|
|
|
|
|
return sodium.to_base64(concat(nonce, ct), sodium.base64_variants.ORIGINAL)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
open(senderIdHex: string, boxB64: string): Uint8Array | null {
|
|
|
|
|
const pub = sodium.crypto_sign_ed25519_pk_to_curve25519(sodium.from_hex(senderIdHex))
|
|
|
|
|
const box = sodium.from_base64(boxB64, sodium.base64_variants.ORIGINAL)
|
|
|
|
|
try { return sodium.crypto_box_open_easy(box.slice(24), box.slice(0, 24), pub, this.curvePriv) }
|
|
|
|
|
catch { return null }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
exportBackup(passphrase: string): object {
|
|
|
|
|
const BK_OPS = 2, BK_MEM = 67108864
|
|
|
|
|
const b64 = (b: Uint8Array) => sodium.to_base64(b, sodium.base64_variants.ORIGINAL)
|
|
|
|
|
const seed = this.priv.slice(0, 32)
|
|
|
|
|
const salt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES)
|
|
|
|
|
const key = sodium.crypto_pwhash(
|
|
|
|
|
sodium.crypto_secretbox_KEYBYTES, passphrase, salt,
|
|
|
|
|
BK_OPS, BK_MEM, sodium.crypto_pwhash_ALG_ARGON2ID13
|
|
|
|
|
)
|
|
|
|
|
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES)
|
|
|
|
|
const ct = sodium.crypto_secretbox_easy(seed, nonce, key)
|
2026-06-25 13:21:12 +02:00
|
|
|
return {
|
|
|
|
|
yaw: 'yaw-key-backup-1', id: this.id, alg: 'argon2id-secretbox',
|
|
|
|
|
ops: BK_OPS, mem: BK_MEM, salt: b64(salt), nonce: b64(nonce), ct: b64(ct)
|
|
|
|
|
}
|
2026-06-23 00:06:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static importBackup(b: Record<string, unknown>, passphrase: string): Identity {
|
|
|
|
|
if (!b || b['yaw'] !== 'yaw-key-backup-1') throw new Error('not a yaw key backup')
|
|
|
|
|
const ub = (s: unknown) => sodium.from_base64(s as string, sodium.base64_variants.ORIGINAL)
|
|
|
|
|
const key = sodium.crypto_pwhash(
|
|
|
|
|
sodium.crypto_secretbox_KEYBYTES, passphrase, ub(b['salt']),
|
|
|
|
|
b['ops'] as number | 0, b['mem'] as number | 0, sodium.crypto_pwhash_ALG_ARGON2ID13
|
|
|
|
|
)
|
|
|
|
|
const seed = sodium.crypto_secretbox_open_easy(ub(b['ct']), ub(b['nonce']), key)
|
|
|
|
|
localStorage.setItem('waste_seed', sodium.to_hex(seed))
|
|
|
|
|
return new Identity(sodium.crypto_sign_seed_keypair(seed))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get short(): string {
|
|
|
|
|
return this.id.slice(0, 16).replace(/(.{4})/g, '$1 ').trim()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toPeerInfo(networkId = ''): PeerInfo {
|
2026-06-25 13:21:12 +02:00
|
|
|
return {
|
|
|
|
|
id: this.id, alias: this.nick || this.short, public_key: this.id,
|
|
|
|
|
created_at: new Date().toISOString(), network_id: networkId
|
|
|
|
|
} as PeerInfo & { network_id?: string }
|
2026-06-23 00:06:41 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Signaling ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
class Signaling {
|
|
|
|
|
private ws: WebSocket | null = null
|
|
|
|
|
private _closed = false
|
|
|
|
|
private _backoff = 1000
|
|
|
|
|
private _cbs: {
|
|
|
|
|
onFrom?: (from: string, box: string) => void
|
|
|
|
|
onJoin?: (id: string) => void
|
|
|
|
|
onLeave?: (id: string) => void
|
|
|
|
|
onReconnect?: (peers: string[]) => void
|
|
|
|
|
} = {}
|
|
|
|
|
|
|
|
|
|
private url: string
|
|
|
|
|
private identity: Identity
|
|
|
|
|
private net: string
|
|
|
|
|
|
|
|
|
|
constructor(url: string, identity: Identity, net: string) {
|
|
|
|
|
this.url = url
|
|
|
|
|
this.identity = identity
|
|
|
|
|
this.net = net
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
connect(
|
|
|
|
|
onFrom: (from: string, box: string) => void,
|
|
|
|
|
onJoin: (id: string) => void,
|
|
|
|
|
onLeave: (id: string) => void,
|
|
|
|
|
onReconnect: (peers: string[]) => void,
|
|
|
|
|
): Promise<string[]> {
|
|
|
|
|
this._cbs = { onFrom, onJoin, onLeave, onReconnect }
|
|
|
|
|
return this._open(true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private _open(initial: boolean): Promise<string[]> {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const ws = new WebSocket(this.url)
|
|
|
|
|
this.ws = ws
|
|
|
|
|
let joined = false
|
|
|
|
|
|
|
|
|
|
ws.onerror = () => { if (initial && !joined) reject(new Error('signaling connection failed')) }
|
|
|
|
|
ws.onclose = () => { if (!this._closed) this._scheduleReconnect() }
|
|
|
|
|
ws.onmessage = (ev) => {
|
|
|
|
|
let m: Record<string, unknown>
|
|
|
|
|
try { m = JSON.parse(ev.data) } catch { return }
|
|
|
|
|
|
|
|
|
|
if (m['type'] === 'challenge') {
|
|
|
|
|
const nonce = sodium.from_hex(m['nonce'] as string)
|
|
|
|
|
const sig = sodium.to_hex(this.identity.sign(concat(nonce, enc(this.net))))
|
|
|
|
|
ws.send(JSON.stringify({ type: 'join', id: this.identity.id, net: this.net, sig }))
|
|
|
|
|
} else if (m['type'] === 'joined') {
|
|
|
|
|
joined = true; this._backoff = 1000
|
|
|
|
|
const peers = (m['peers'] as string[]) || []
|
|
|
|
|
if (initial) resolve(peers)
|
|
|
|
|
else this._cbs.onReconnect?.(peers)
|
|
|
|
|
} else if (m['type'] === 'from') {
|
|
|
|
|
this._cbs.onFrom?.(m['from'] as string, m['box'] as string)
|
|
|
|
|
} else if (m['type'] === 'peer-join') {
|
|
|
|
|
this._cbs.onJoin?.(m['id'] as string)
|
|
|
|
|
} else if (m['type'] === 'peer-leave') {
|
|
|
|
|
this._cbs.onLeave?.(m['id'] as string)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private _scheduleReconnect() {
|
|
|
|
|
if (this._closed) return
|
|
|
|
|
const delay = this._backoff
|
|
|
|
|
this._backoff = Math.min(this._backoff * 2, 30000)
|
2026-06-25 13:21:12 +02:00
|
|
|
setTimeout(() => { if (!this._closed) this._open(false).catch(() => { }) }, delay)
|
2026-06-23 00:06:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sendTo(toId: string, box: string) {
|
2026-06-25 13:21:12 +02:00
|
|
|
try { this.ws?.send(JSON.stringify({ type: 'to', to: toId, box })) } catch { }
|
2026-06-23 00:06:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
close() { this._closed = true; this.ws?.close() }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Peer connection (yaw/2.1) ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
type PeerCallback = (event: string, data: Record<string, unknown>) => void
|
|
|
|
|
|
|
|
|
|
class PeerConn {
|
|
|
|
|
pc: RTCPeerConnection
|
|
|
|
|
dc: RTCDataChannel | null = null
|
|
|
|
|
verified = false
|
|
|
|
|
peerAuthed = false
|
|
|
|
|
created = Date.now()
|
|
|
|
|
|
|
|
|
|
private _esk: Uint8Array | null
|
|
|
|
|
private _epk: Uint8Array | null
|
|
|
|
|
peer_epk: Uint8Array | null = null
|
|
|
|
|
private _ekeySent = false
|
|
|
|
|
private _offerPending = false
|
|
|
|
|
private _offered = false
|
|
|
|
|
|
|
|
|
|
private identity: Identity
|
|
|
|
|
private sig: Signaling
|
|
|
|
|
public peerId: string
|
|
|
|
|
private on: PeerCallback
|
|
|
|
|
private nick: string
|
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>
2026-06-25 20:29:54 +02:00
|
|
|
share: Map<string, File>
|
2026-06-23 00:06:41 +02:00
|
|
|
|
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>
2026-06-25 20:29:54 +02:00
|
|
|
// 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>) {
|
2026-06-23 00:06:41 +02:00
|
|
|
this.identity = identity
|
|
|
|
|
this.sig = sig
|
|
|
|
|
this.peerId = peerId
|
|
|
|
|
this.on = on
|
|
|
|
|
this.nick = nick
|
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>
2026-06-25 20:29:54 +02:00
|
|
|
this.share = share
|
2026-06-23 00:06:41 +02:00
|
|
|
this.pc = new RTCPeerConnection({ iceServers: [{ urls: STUN }] })
|
|
|
|
|
const kp = sodium.crypto_box_keypair()
|
|
|
|
|
this._esk = kp.privateKey
|
|
|
|
|
this._epk = kp.publicKey
|
|
|
|
|
this.pc.ondatachannel = (ev) => this._wire(ev.channel)
|
2026-06-25 13:28:40 +02:00
|
|
|
this.pc.onconnectionstatechange = () => {
|
2026-06-23 00:06:41 +02:00
|
|
|
this.on('status', { peer: this.peerId, state: this.pc.connectionState })
|
2026-06-25 13:28:40 +02:00
|
|
|
if (this.pc.connectionState === 'connected') this.reportStats()
|
|
|
|
|
}
|
2026-06-23 00:06:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private _seal(obj: object, preferEph: boolean): [string, boolean] {
|
|
|
|
|
const data = enc(JSON.stringify(obj))
|
|
|
|
|
if (preferEph && this.peer_epk && this._esk) {
|
|
|
|
|
const nonce = sodium.randombytes_buf(24)
|
|
|
|
|
const ct = sodium.crypto_box_easy(data, nonce, this.peer_epk, this._esk)
|
|
|
|
|
return [sodium.to_base64(concat(nonce, ct), sodium.base64_variants.ORIGINAL), true]
|
|
|
|
|
}
|
|
|
|
|
return [this.identity.seal(this.peerId, data), false]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private _open(box: string): [Uint8Array | null, boolean] {
|
|
|
|
|
if (this.peer_epk && this._esk) {
|
|
|
|
|
try {
|
|
|
|
|
const raw = sodium.from_base64(box, sodium.base64_variants.ORIGINAL)
|
|
|
|
|
return [sodium.crypto_box_open_easy(raw.slice(24), raw.slice(0, 24), this.peer_epk, this._esk), true]
|
2026-06-25 13:21:12 +02:00
|
|
|
} catch { }
|
2026-06-23 00:06:41 +02:00
|
|
|
}
|
|
|
|
|
return [this.identity.open(this.peerId, box), false]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private _sendEkey() {
|
|
|
|
|
if (this._ekeySent || !this._epk) return
|
|
|
|
|
this._ekeySent = true
|
|
|
|
|
const signed = concat(enc(EKEY_PREFIX), sodium.from_hex(this.identity.id),
|
2026-06-25 13:21:12 +02:00
|
|
|
sodium.from_hex(this.peerId), this._epk)
|
|
|
|
|
const msg = {
|
|
|
|
|
kind: 'ekey', v: 'yaw/2.1', epk: sodium.to_hex(this._epk),
|
|
|
|
|
sig: sodium.to_hex(this.identity.sign(signed))
|
|
|
|
|
}
|
2026-06-23 00:06:41 +02:00
|
|
|
const [box] = this._seal(msg, false)
|
|
|
|
|
this.sig.sendTo(this.peerId, box)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async _onEkey(obj: Record<string, unknown>) {
|
|
|
|
|
if (this.peer_epk) return
|
|
|
|
|
try {
|
|
|
|
|
const epkRaw = sodium.from_hex(obj['epk'] as string)
|
|
|
|
|
const sig = sodium.from_hex(obj['sig'] as string)
|
|
|
|
|
const signed = concat(enc(EKEY_PREFIX), sodium.from_hex(this.peerId),
|
2026-06-25 13:21:12 +02:00
|
|
|
sodium.from_hex(this.identity.id), epkRaw)
|
2026-06-23 00:06:41 +02:00
|
|
|
if (epkRaw.length !== 32 || !Identity.verify(this.peerId, signed, sig)) return
|
|
|
|
|
this.peer_epk = epkRaw
|
|
|
|
|
} catch { return }
|
|
|
|
|
this._sendEkey()
|
|
|
|
|
if (this._offerPending) await this._doOffer()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async startOffer() {
|
|
|
|
|
this._sendEkey()
|
|
|
|
|
this._offerPending = true
|
|
|
|
|
setTimeout(() => { if (this._offerPending) this._doOffer() }, FS_TIMEOUT)
|
|
|
|
|
if (this.peer_epk) await this._doOffer()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async _doOffer() {
|
|
|
|
|
if (this._offered) return
|
|
|
|
|
this._offered = true; this._offerPending = false
|
|
|
|
|
this.dc = this.pc.createDataChannel('yaw')
|
|
|
|
|
this._wire(this.dc)
|
|
|
|
|
await this.pc.setLocalDescription(await this.pc.createOffer())
|
|
|
|
|
await gatherComplete(this.pc)
|
|
|
|
|
const [box] = this._seal({ kind: 'offer', sdp: this.pc.localDescription!.sdp }, true)
|
|
|
|
|
this.sig.sendTo(this.peerId, box)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async onBox(box: string) {
|
|
|
|
|
const [plain, usedEph] = this._open(box)
|
|
|
|
|
if (!plain) return
|
|
|
|
|
this.peerAuthed = true
|
|
|
|
|
let obj: Record<string, unknown>
|
|
|
|
|
try { obj = JSON.parse(new TextDecoder().decode(plain)) } catch { return }
|
|
|
|
|
|
|
|
|
|
if (obj['kind'] === 'ekey') {
|
|
|
|
|
await this._onEkey(obj)
|
|
|
|
|
} else if (obj['kind'] === 'offer') {
|
|
|
|
|
await this.pc.setRemoteDescription({ type: 'offer', sdp: obj['sdp'] as string })
|
|
|
|
|
await this.pc.setLocalDescription(await this.pc.createAnswer())
|
|
|
|
|
await gatherComplete(this.pc)
|
|
|
|
|
const [box2] = this._seal({ kind: 'answer', sdp: this.pc.localDescription!.sdp }, usedEph)
|
|
|
|
|
this.sig.sendTo(this.peerId, box2)
|
|
|
|
|
} else if (obj['kind'] === 'answer') {
|
|
|
|
|
await this.pc.setRemoteDescription({ type: 'answer', sdp: obj['sdp'] as string })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private _wire(channel: RTCDataChannel) {
|
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>
2026-06-25 20:29:54 +02:00
|
|
|
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]
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-23 00:06:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private _sendHello() {
|
|
|
|
|
const bind = enc(BIND_PREFIX)
|
|
|
|
|
const caps: string[] = []
|
2026-06-25 13:21:12 +02:00
|
|
|
this._dc({
|
|
|
|
|
type: 'hello', id: this.identity.id, nick: this.nick, caps,
|
|
|
|
|
sig: sodium.to_hex(this.identity.sign(bind))
|
|
|
|
|
})
|
2026-06-23 00:06:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private _onControl(data: string) {
|
|
|
|
|
let m: Record<string, unknown>
|
|
|
|
|
try { m = JSON.parse(data) } catch { return }
|
|
|
|
|
|
|
|
|
|
if (m['type'] === 'hello') {
|
|
|
|
|
let reason = ''
|
|
|
|
|
if (m['id'] !== this.peerId) reason = 'id mismatch'
|
|
|
|
|
else if (!this.peerAuthed) reason = 'unauthenticated signaling'
|
|
|
|
|
this.verified = !reason
|
2026-06-25 13:21:12 +02:00
|
|
|
this.on('connected', {
|
|
|
|
|
peer: this.peerId, verified: this.verified,
|
|
|
|
|
nick: m['nick'] as string || '', caps: m['caps'] || []
|
|
|
|
|
})
|
2026-06-23 00:06:41 +02:00
|
|
|
} else if (m['type'] === 'chat') {
|
2026-06-25 13:21:12 +02:00
|
|
|
this.on('chat', {
|
|
|
|
|
peer: this.peerId, room: m['room'] as string || 'general',
|
|
|
|
|
text: m['text'] as string, ts: m['ts'] as number || Date.now()
|
|
|
|
|
})
|
2026-06-23 00:06:41 +02:00
|
|
|
} else if (m['type'] === 'pm') {
|
|
|
|
|
this.on('pm', { peer: this.peerId, text: m['text'] as string, ts: m['ts'] as number || Date.now() })
|
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>
2026-06-25 20:29:54 +02:00
|
|
|
} 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
|
2026-06-23 00:06:41 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-06-25 20:29:54 +02:00
|
|
|
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 })
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 13:28:40 +02:00
|
|
|
async reportStats() {
|
|
|
|
|
try {
|
|
|
|
|
const stats = await this.pc.getStats()
|
|
|
|
|
let candidateType: string = 'unknown'
|
|
|
|
|
let remoteAddress = ''
|
|
|
|
|
stats.forEach((s) => {
|
|
|
|
|
if (s.type === 'candidate-pair' && (s as RTCIceCandidatePairStats).state === 'succeeded') {
|
|
|
|
|
const pair = s as RTCIceCandidatePairStats & { nominated?: boolean }
|
|
|
|
|
if (pair.nominated) {
|
|
|
|
|
stats.forEach((c) => {
|
|
|
|
|
if (c.type === 'remote-candidate' && c.id === (pair as unknown as Record<string,string>)['remoteCandidateId']) {
|
|
|
|
|
candidateType = (c as unknown as Record<string,string>)['candidateType'] || 'unknown'
|
|
|
|
|
remoteAddress = (c as unknown as Record<string,string>)['address'] || ''
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
this.on('ice_stats', { peer: this.peerId, candidateType, remoteAddress })
|
|
|
|
|
} catch { /* ignore */ }
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 00:06:41 +02:00
|
|
|
sendChat(room: string, text: string) {
|
|
|
|
|
this._dc({ type: 'chat', room, text, ts: Date.now() })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sendPm(text: string) {
|
|
|
|
|
this._dc({ type: 'pm', text, ts: Date.now() })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private _dc(obj: object) {
|
|
|
|
|
if (this.dc?.readyState === 'open') this.dc.send(JSON.stringify(obj))
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 13:21:12 +02:00
|
|
|
close() { try { this.pc.close() } catch { } }
|
2026-06-23 00:06:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function gatherComplete(pc: RTCPeerConnection): Promise<void> {
|
|
|
|
|
if (pc.iceGatheringState === 'complete') return Promise.resolve()
|
|
|
|
|
return new Promise((res) => {
|
|
|
|
|
const check = () => {
|
|
|
|
|
if (pc.iceGatheringState === 'complete') {
|
|
|
|
|
pc.removeEventListener('icegatheringstatechange', check)
|
|
|
|
|
res()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
pc.addEventListener('icegatheringstatechange', check)
|
|
|
|
|
setTimeout(res, 6000)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── BrowserAdapter ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export class BrowserAdapter {
|
|
|
|
|
private listeners: Listener[] = []
|
|
|
|
|
private identity: Identity | null = null
|
|
|
|
|
private sig: Signaling | null = null
|
|
|
|
|
private peers: Map<string, PeerConn> = new Map()
|
|
|
|
|
private present: Set<string> = new Set()
|
|
|
|
|
private networkId = ''
|
|
|
|
|
private networkName = ''
|
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>
2026-06-25 20:29:54 +02:00
|
|
|
sharedFiles: Map<string, File> = new Map()
|
2026-06-23 00:06:41 +02:00
|
|
|
|
|
|
|
|
status: Status = 'disconnected'
|
|
|
|
|
onStatusChange?: (s: Status) => void
|
|
|
|
|
|
|
|
|
|
private setStatus(s: Status) {
|
|
|
|
|
this.status = s
|
|
|
|
|
this.onStatusChange?.(s)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private emit(msg: IpcMessage) {
|
|
|
|
|
this.listeners.forEach(l => l(msg))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
on(listener: Listener) {
|
|
|
|
|
this.listeners.push(listener)
|
|
|
|
|
return () => { this.listeners = this.listeners.filter(l => l !== listener) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Call after connect() to set/update the nick shown to peers
|
|
|
|
|
setNick(nick: string) {
|
|
|
|
|
this.identity?.setNick(nick)
|
|
|
|
|
if (this.identity) {
|
|
|
|
|
this.emit({
|
|
|
|
|
type: 'state_snapshot',
|
|
|
|
|
master_alias: this.identity.nick || this.identity.short,
|
|
|
|
|
master_id: this.identity.id,
|
|
|
|
|
rooms: ['general'],
|
|
|
|
|
networks: this.networkId ? [{
|
|
|
|
|
network_id: this.networkId,
|
|
|
|
|
network_name: this.networkName,
|
|
|
|
|
local_peer: this.identity.toPeerInfo(this.networkId),
|
|
|
|
|
}] : [],
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async connect() {
|
|
|
|
|
await sodium.ready
|
|
|
|
|
this.identity = Identity.load()
|
|
|
|
|
this.setStatus('connecting')
|
|
|
|
|
|
|
|
|
|
// Emit identity immediately — UI can show alias before network join
|
|
|
|
|
this.emit({
|
|
|
|
|
type: 'state_snapshot',
|
|
|
|
|
master_alias: this.identity.nick || this.identity.short,
|
|
|
|
|
master_id: this.identity.id,
|
|
|
|
|
rooms: ['general'],
|
|
|
|
|
networks: [],
|
|
|
|
|
})
|
|
|
|
|
// Mark connected — onboarding shows join form
|
|
|
|
|
this.setStatus('connected')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
disconnect() {
|
|
|
|
|
this.sig?.close()
|
|
|
|
|
this.sig = null
|
|
|
|
|
this.peers.forEach(p => p.close())
|
|
|
|
|
this.peers.clear()
|
|
|
|
|
this.present.clear()
|
|
|
|
|
this.networkId = ''
|
|
|
|
|
this.networkName = ''
|
|
|
|
|
this.setStatus('disconnected')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async joinNetwork(anchorUrl: string, nameOrHash: string, isHash = false) {
|
|
|
|
|
if (!this.identity) return
|
|
|
|
|
|
2026-06-25 13:21:12 +02:00
|
|
|
const hash = isHash ? nameOrHash : await netHash(nameOrHash)
|
2026-06-23 00:06:41 +02:00
|
|
|
this.networkId = hash.slice(0, 16)
|
|
|
|
|
this.networkName = isHash ? hash.slice(0, 16) : nameOrHash
|
|
|
|
|
|
|
|
|
|
this.sig?.close()
|
|
|
|
|
this.peers.forEach(p => p.close())
|
|
|
|
|
this.peers.clear()
|
|
|
|
|
this.present.clear()
|
|
|
|
|
|
|
|
|
|
const sig = new Signaling(anchorUrl, this.identity, hash)
|
|
|
|
|
this.sig = sig
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const present = await sig.connect(
|
|
|
|
|
(from, box) => this._onFrom(from, box),
|
|
|
|
|
(pid) => { this.present.add(pid); this._tryConnect(pid) },
|
|
|
|
|
(pid) => {
|
|
|
|
|
this.present.delete(pid)
|
|
|
|
|
this.peers.get(pid)?.close()
|
|
|
|
|
this.peers.delete(pid)
|
|
|
|
|
this.emit({ type: 'peer_disconnected', peer_id: pid as unknown as import('../types').PeerID })
|
|
|
|
|
},
|
|
|
|
|
(peers) => {
|
|
|
|
|
this.present = new Set(peers)
|
|
|
|
|
peers.forEach(pid => this._tryConnect(pid))
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
for (const pid of present) {
|
|
|
|
|
this.present.add(pid)
|
|
|
|
|
await this._tryConnect(pid)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const localPeer = this.identity.toPeerInfo(this.networkId)
|
|
|
|
|
this.emit({
|
|
|
|
|
type: 'network_joined',
|
|
|
|
|
network_id: this.networkId,
|
|
|
|
|
network_name: this.networkName,
|
|
|
|
|
local_peer: localPeer,
|
|
|
|
|
networks: [{ network_id: this.networkId, network_name: this.networkName, local_peer: localPeer }],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
setInterval(() => { this.present.forEach(pid => this._tryConnect(pid)) }, 4000)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
this.emit({ type: 'error', error_message: `signaling: ${err}` })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async _tryConnect(pid: string) {
|
|
|
|
|
if (!this.identity || pid === this.identity.id) return
|
|
|
|
|
if (this.identity.id >= pid) return // lower ID answers; higher ID offers
|
|
|
|
|
|
|
|
|
|
const existing = this.peers.get(pid)
|
|
|
|
|
if (existing) {
|
|
|
|
|
const open = existing.dc?.readyState === 'open'
|
|
|
|
|
const state = existing.pc.connectionState
|
|
|
|
|
const alive = state !== 'failed' && state !== 'closed'
|
|
|
|
|
const fresh = Date.now() - existing.created < 12000
|
|
|
|
|
if (existing.verified || open || (alive && fresh)) return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const peer = new PeerConn(this.identity, this.sig!, pid,
|
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>
2026-06-25 20:29:54 +02:00
|
|
|
(ev, data) => this._onPeerEvent(ev, data), this.identity.nick, this.sharedFiles)
|
2026-06-23 00:06:41 +02:00
|
|
|
this.peers.set(pid, peer)
|
|
|
|
|
await peer.startOffer()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async _onFrom(from: string, box: string) {
|
|
|
|
|
if (!this.identity || from === this.identity.id) return
|
|
|
|
|
let peer = this.peers.get(from)
|
|
|
|
|
if (!peer || peer.pc.connectionState === 'failed' || peer.pc.connectionState === 'closed') {
|
|
|
|
|
peer = new PeerConn(this.identity, this.sig!, from,
|
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>
2026-06-25 20:29:54 +02:00
|
|
|
(ev, data) => this._onPeerEvent(ev, data), this.identity.nick, this.sharedFiles)
|
2026-06-23 00:06:41 +02:00
|
|
|
this.peers.set(from, peer)
|
|
|
|
|
}
|
|
|
|
|
await peer.onBox(box)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private _onPeerEvent(event: string, data: Record<string, unknown>) {
|
|
|
|
|
if (event === 'connected') {
|
|
|
|
|
const nick = (data['nick'] as string) || (data['peer'] as string).slice(0, 16)
|
|
|
|
|
this.emit({
|
|
|
|
|
type: 'peer_connected',
|
2026-06-25 13:21:12 +02:00
|
|
|
peer: {
|
|
|
|
|
id: data['peer'] as string, alias: nick || (data['peer'] as string).slice(0, 8),
|
|
|
|
|
public_key: data['peer'] as string, created_at: new Date().toISOString()
|
|
|
|
|
},
|
2026-06-23 00:06:41 +02:00
|
|
|
})
|
|
|
|
|
} else if (event === 'chat') {
|
|
|
|
|
const ts = (data['ts'] as number) || Date.now()
|
|
|
|
|
const mid = `${data['peer']}-${ts}`
|
|
|
|
|
this.emit({
|
|
|
|
|
type: 'message_received',
|
|
|
|
|
network_id: this.networkId,
|
|
|
|
|
message: {
|
|
|
|
|
mid,
|
|
|
|
|
from: data['peer'] as unknown as import('../types').PeerID,
|
|
|
|
|
room: (data['room'] as string) || 'general',
|
|
|
|
|
text: data['text'] as string,
|
|
|
|
|
ts,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
} else if (event === 'pm') {
|
|
|
|
|
const ts = (data['ts'] as number) || Date.now()
|
|
|
|
|
const from = data['peer'] as string
|
|
|
|
|
this.emit({
|
|
|
|
|
type: 'message_received',
|
|
|
|
|
network_id: this.networkId,
|
|
|
|
|
message: {
|
|
|
|
|
mid: `${from}-${ts}`,
|
|
|
|
|
from: from as unknown as import('../types').PeerID,
|
|
|
|
|
room: `dm:${from.slice(0, 8)}`,
|
|
|
|
|
text: data['text'] as string,
|
|
|
|
|
ts,
|
|
|
|
|
},
|
|
|
|
|
})
|
2026-06-25 13:28:40 +02:00
|
|
|
} else if (event === 'status') {
|
|
|
|
|
this.emit({
|
|
|
|
|
type: 'peer_status',
|
|
|
|
|
peer_id: data['peer'] as unknown as import('../types').PeerID,
|
|
|
|
|
conn_state: data['state'] as import('../types').PeerConnState,
|
|
|
|
|
})
|
|
|
|
|
} else if (event === 'ice_stats') {
|
|
|
|
|
this.emit({
|
|
|
|
|
type: 'peer_status',
|
|
|
|
|
peer_id: data['peer'] as unknown as import('../types').PeerID,
|
|
|
|
|
candidate_type: data['candidateType'] as import('../types').CandidateType,
|
|
|
|
|
remote_address: data['remoteAddress'] as string,
|
|
|
|
|
})
|
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>
2026-06-25 20:29:54 +02:00
|
|
|
} 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: '' },
|
|
|
|
|
})
|
2026-06-23 00:06:41 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-06-25 20:29:54 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 00:06:41 +02:00
|
|
|
send(msg: IpcMessage) {
|
|
|
|
|
if (!this.identity) return
|
|
|
|
|
|
|
|
|
|
if (msg.type === 'join_network') {
|
|
|
|
|
const cfg = (window as unknown as { WASTE_CONFIG?: { signalURL?: string } })['WASTE_CONFIG']
|
|
|
|
|
const anchorUrl = cfg?.signalURL
|
|
|
|
|
|| localStorage.getItem('waste_anchor_url')
|
2026-06-23 08:52:21 +02:00
|
|
|
|| ''
|
2026-06-23 00:06:41 +02:00
|
|
|
const isHash = !msg.network_name && !!msg.network_hash
|
|
|
|
|
const nameOrHash = msg.network_name || msg.network_hash || ''
|
|
|
|
|
if (nameOrHash) this.joinNetwork(anchorUrl, nameOrHash, isHash)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (msg.type === 'send_message') {
|
|
|
|
|
const room = msg.room || 'general'
|
|
|
|
|
const text = msg.body || ''
|
|
|
|
|
if (!text) return
|
|
|
|
|
const ts = Date.now()
|
|
|
|
|
const mid = `local-${ts}`
|
|
|
|
|
|
|
|
|
|
if (msg.to) {
|
|
|
|
|
// DM
|
|
|
|
|
const pid = msg.to as string
|
|
|
|
|
this.peers.get(pid)?.sendPm(text)
|
|
|
|
|
this.emit({
|
|
|
|
|
type: 'message_received',
|
|
|
|
|
network_id: this.networkId,
|
2026-06-25 13:21:12 +02:00
|
|
|
message: {
|
|
|
|
|
mid, from: this.identity.id as unknown as import('../types').PeerID,
|
|
|
|
|
to: msg.to, room: `dm:${pid.slice(0, 8)}`, text, ts
|
|
|
|
|
},
|
2026-06-23 00:06:41 +02:00
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
// Broadcast
|
|
|
|
|
this.peers.forEach(p => p.sendChat(room, text))
|
|
|
|
|
this.emit({
|
|
|
|
|
type: 'message_received',
|
|
|
|
|
network_id: this.networkId,
|
2026-06-25 13:21:12 +02:00
|
|
|
message: {
|
|
|
|
|
mid, from: this.identity.id as unknown as import('../types').PeerID,
|
|
|
|
|
room, text, ts
|
|
|
|
|
},
|
2026-06-23 00:06:41 +02:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (msg.type === 'export_identity') {
|
|
|
|
|
if (!msg.passphrase) return
|
|
|
|
|
try {
|
|
|
|
|
const backup = this.identity.exportBackup(msg.passphrase)
|
|
|
|
|
this.emit({ type: 'identity_exported', backup: JSON.stringify(backup, null, 2) })
|
|
|
|
|
} catch (err) {
|
|
|
|
|
this.emit({ type: 'error', error_message: `export_identity: ${err}` })
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (msg.type === 'import_identity') {
|
|
|
|
|
if (!msg.passphrase || !msg.backup) return
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(msg.backup)
|
|
|
|
|
const newId = Identity.importBackup(parsed, msg.passphrase)
|
|
|
|
|
this.identity = newId
|
|
|
|
|
this.emit({ type: 'identity_imported' })
|
|
|
|
|
this.emit({
|
|
|
|
|
type: 'state_snapshot',
|
|
|
|
|
master_alias: newId.nick || newId.short,
|
|
|
|
|
master_id: newId.id,
|
|
|
|
|
rooms: ['general'],
|
|
|
|
|
networks: [],
|
|
|
|
|
})
|
|
|
|
|
} catch (err) {
|
|
|
|
|
this.emit({ type: 'error', error_message: `import_identity: ${err}` })
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (msg.type === 'generate_invite') {
|
|
|
|
|
const anchor = localStorage.getItem('waste_anchor_url') || 'wss://waste.dev.xplwd.com/ws'
|
|
|
|
|
const name = this.networkName
|
|
|
|
|
if (!name || name === this.networkId) {
|
|
|
|
|
this.emit({ type: 'error', error_message: 'generate_invite: joined by hash — no network name available' })
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const h = netHash(name)
|
|
|
|
|
const payload = btoa(JSON.stringify({ anchor, network: name, net: h }))
|
|
|
|
|
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
|
|
|
|
|
this.emit({ type: 'invite_generated', invite: `waste:${payload}`, network_id: this.networkId })
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|