Add standalone browser mode: no daemon required

BrowserAdapter speaks yaw/2.1 signaling + WebRTC protocol entirely in
the browser (libsodium-wrappers for Ed25519/X25519). Identity stored in
localStorage; peers connect via any waste/yaw2 anchor.

When served from a non-localhost origin the app defaults to browser
mode. On localhost the daemon adapter is tried first with a one-click
switch to browser mode. config.js (gitignored, served by anchor) lets
the host pre-inject signalURL and other defaults.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-06-23 00:06:41 +02:00
parent add7c5fea8
commit 5bc16daae1
8 changed files with 820 additions and 35 deletions

7
web/config.example.js Normal file
View File

@@ -0,0 +1,7 @@
// Copy to config.js (gitignored) on the anchor host.
// Served at /config.js; loaded before the React bundle in index.html.
window.WASTE_CONFIG = {
signalURL: 'wss://waste.dev.xplwd.com/ws',
// Uncomment to pre-fill the network name on this invite URL:
// defaultNetwork: 'friends',
}

View File

@@ -4,10 +4,12 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title> <title>waste</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<!-- Optional runtime config (anchor host puts config.js here; silently absent in dev) -->
<script src="/config.js" onerror="void 0"></script>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

16
web/package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "web", "name": "web",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"libsodium-wrappers": "^0.8.4",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"zustand": "^5.0.14" "zustand": "^5.0.14"
@@ -1883,6 +1884,21 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/libsodium": {
"version": "0.8.4",
"resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.8.4.tgz",
"integrity": "sha512-lMcYaRi0zcs7tarATsQUYC7rstliIXZuoq0c6zXSgNtSNtdvBgkSegjWhpMJAXzKX3SUSwIp7+zEsob+j3LuRw==",
"license": "ISC"
},
"node_modules/libsodium-wrappers": {
"version": "0.8.4",
"resolved": "https://registry.npmjs.org/libsodium-wrappers/-/libsodium-wrappers-0.8.4.tgz",
"integrity": "sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw==",
"license": "ISC",
"dependencies": {
"libsodium": "^0.8.0"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",

View File

@@ -10,6 +10,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"libsodium-wrappers": "^0.8.4",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"zustand": "^5.0.14" "zustand": "^5.0.14"

View File

@@ -4,15 +4,21 @@ import { Onboarding } from './pages/Onboarding'
import { Chat } from './pages/Chat' import { Chat } from './pages/Chat'
import './App.css' import './App.css'
// Default to local daemon WS IPC. Override with VITE_DAEMON_WS env var. // 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 DAEMON_WS = import.meta.env.VITE_DAEMON_WS ?? 'ws://127.0.0.1:17338'
export default function App() { export default function App() {
const { connect, daemonStatus, localPeer } = useWaste() const { connect, connectBrowser, daemonStatus, localPeer } = useWaste()
useEffect(() => { useEffect(() => {
connect(DAEMON_WS) if (isLocal) {
}, [connect]) connect(DAEMON_WS)
} else {
connectBrowser()
}
}, [connect, connectBrowser])
if (daemonStatus !== 'connected' || !localPeer) { if (daemonStatus !== 'connected' || !localPeer) {
return <Onboarding status={daemonStatus} /> return <Onboarding status={daemonStatus} />

673
web/src/adapter/browser.ts Normal file
View File

@@ -0,0 +1,673 @@
// 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
}
function netHash(name: string): string {
return sodium.to_hex(sodium.crypto_hash_sha256(enc('yaw2-net:' + name)))
}
// ── 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)
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) }
}
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 {
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 }
}
}
// ── 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)
setTimeout(() => { if (!this._closed) this._open(false).catch(() => {}) }, delay)
}
sendTo(toId: string, box: string) {
try { this.ws?.send(JSON.stringify({ type: 'to', to: toId, box })) } catch {}
}
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
constructor(identity: Identity, sig: Signaling, peerId: string, on: PeerCallback, nick: string) {
this.identity = identity
this.sig = sig
this.peerId = peerId
this.on = on
this.nick = nick
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)
this.pc.onconnectionstatechange = () =>
this.on('status', { peer: this.peerId, state: this.pc.connectionState })
}
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]
} catch {}
}
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),
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)) }
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),
sodium.from_hex(this.identity.id), epkRaw)
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) {
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()
}
private _sendHello() {
const bind = enc(BIND_PREFIX)
const caps: string[] = []
this._dc({ type: 'hello', id: this.identity.id, nick: this.nick, caps,
sig: sodium.to_hex(this.identity.sign(bind)) })
}
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
this.on('connected', { peer: this.peerId, verified: this.verified,
nick: m['nick'] as string || '', caps: m['caps'] || [] })
} else if (m['type'] === 'chat') {
this.on('chat', { peer: this.peerId, room: m['room'] as string || 'general',
text: m['text'] as string, ts: m['ts'] as number || Date.now() })
} else if (m['type'] === 'pm') {
this.on('pm', { peer: this.peerId, text: m['text'] as string, ts: m['ts'] as number || Date.now() })
}
}
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))
}
close() { try { this.pc.close() } catch {} }
}
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 = ''
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
const hash = isHash ? nameOrHash : netHash(nameOrHash)
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,
(ev, data) => this._onPeerEvent(ev, data), this.identity.nick)
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,
(ev, data) => this._onPeerEvent(ev, data), this.identity.nick)
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',
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() },
})
} 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,
},
})
}
}
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')
|| 'wss://waste.dev.xplwd.com/ws'
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,
message: { mid, from: this.identity.id as unknown as import('../types').PeerID,
to: msg.to, room: `dm:${pid.slice(0, 8)}`, text, ts },
})
} else {
// Broadcast
this.peers.forEach(p => p.sendChat(room, text))
this.emit({
type: 'message_received',
network_id: this.networkId,
message: { mid, from: this.identity.id as unknown as import('../types').PeerID,
room, text, ts },
})
}
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
}
}
}

View File

@@ -10,8 +10,8 @@ interface Props {
// ?invite=waste:<b64> waste: invite string (anchor + network name + net hash) // ?invite=waste:<b64> waste: invite string (anchor + network name + net hash)
// ?n=<name> network name shorthand // ?n=<name> network name shorthand
// ?network=<name> network name // ?network=<name> network name
// ?net=<64hex> yaw2-style full network hash (joined without plaintext name) // ?net=<64hex> yaw2-style full network hash
// ?a=<url> anchor URL hint (informational — daemon controls its anchor) // ?a=<url> anchor URL hint
function parseInviteParams(): { network: string; netHash: string; anchor: string; inviteString: string } { function parseInviteParams(): { network: string; netHash: string; anchor: string; inviteString: string } {
const p = new URLSearchParams(window.location.search) const p = new URLSearchParams(window.location.search)
const inviteString = p.get('invite') ?? '' const inviteString = p.get('invite') ?? ''
@@ -21,7 +21,6 @@ function parseInviteParams(): { network: string; netHash: string; anchor: string
if (inviteString.startsWith('waste:')) { if (inviteString.startsWith('waste:')) {
try { try {
// URL-safe base64 → standard base64
const json = JSON.parse(atob(inviteString.slice(6).replace(/-/g, '+').replace(/_/g, '/'))) const json = JSON.parse(atob(inviteString.slice(6).replace(/-/g, '+').replace(/_/g, '/')))
network = network || json.network || '' network = network || json.network || ''
netHash = netHash || json.net || '' netHash = netHash || json.net || ''
@@ -29,33 +28,36 @@ function parseInviteParams(): { network: string; netHash: string; anchor: string
} catch { /* ignore bad invite */ } } catch { /* ignore bad invite */ }
} }
// URL path: /<16hex>/ — short network ID from anchor-served URL
const pathMatch = window.location.pathname.match(/\/([0-9a-f]{16})\/?$/i)
if (pathMatch && !netHash && !network) {
// Only the short ID — can't join by short ID alone (need full hash for HKDF).
// Store it as a hint; the user must supply the network name OR a full 64-char hash.
}
return { network, netHash, anchor, inviteString } return { network, netHash, anchor, inviteString }
} }
const DEFAULT_ANCHOR = (() => {
const cfg = (window as unknown as Record<string, unknown>)['WASTE_CONFIG'] as Record<string, string> | undefined
return cfg?.['signalURL'] ?? localStorage.getItem('waste_anchor_url') ?? 'wss://waste.dev.xplwd.com/ws'
})()
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
export function Onboarding({ status }: Props) { export function Onboarding({ status }: Props) {
const { send, masterAlias, masterId, exportedBackup } = useWaste() const { send, connect, connectBrowser, adapterMode, masterAlias, masterId, exportedBackup } = useWaste()
const [network, setNetwork] = useState('') const [network, setNetwork] = useState('')
const [netHash, setNetHash] = useState('') const [netHash, setNetHash] = useState('')
const [inviteString, setInviteString] = useState('') const [inviteString, setInviteString] = useState('')
const [anchorHint, setAnchorHint] = useState('') const [anchorUrl, setAnchorUrl] = useState(DEFAULT_ANCHOR)
const [nick, setNick] = useState(localStorage.getItem('waste_nick') || '')
const [exportPass, setExportPass] = useState('') const [exportPass, setExportPass] = useState('')
const [importJson, setImportJson] = useState('') const [importJson, setImportJson] = useState('')
const [importPass, setImportPass] = useState('') const [importPass, setImportPass] = useState('')
const [importStatus, setImportStatus] = useState('') const [importStatus, setImportStatus] = useState('')
const [showBackup, setShowBackup] = useState(false) const [showBackup, setShowBackup] = useState(false)
const [daemonUrl, setDaemonUrl] = useState(localStorage.getItem('waste_daemon_ws') || 'ws://127.0.0.1:17338')
useEffect(() => { useEffect(() => {
const { network: n, netHash: nh, anchor: a, inviteString: inv } = parseInviteParams() const { network: n, netHash: nh, anchor: a, inviteString: inv } = parseInviteParams()
if (n) setNetwork(n) if (n) setNetwork(n)
if (nh) setNetHash(nh) if (nh) setNetHash(nh)
if (a) setAnchorHint(a) if (a) setAnchorUrl(a)
if (inv) setInviteString(inv) if (inv) setInviteString(inv)
}, []) }, [])
@@ -64,8 +66,14 @@ export function Onboarding({ status }: Props) {
const name = network.trim() const name = network.trim()
const hash = netHash.trim() const hash = netHash.trim()
if (!name && !hash) return if (!name && !hash) return
// In browser mode: persist anchor URL choice and pass it via localStorage
if (adapterMode === 'browser') {
localStorage.setItem('waste_anchor_url', anchorUrl)
if (nick.trim()) localStorage.setItem('waste_nick', nick.trim())
}
if (hash.length === 64 && !name) { if (hash.length === 64 && !name) {
// yaw2-style: join by full network hash without plaintext name
send({ type: 'join_network', network_hash: hash }) send({ type: 'join_network', network_hash: hash })
} else { } else {
send({ type: 'join_network', network_name: name }) send({ type: 'join_network', network_name: name })
@@ -75,7 +83,6 @@ export function Onboarding({ status }: Props) {
function exportIdentity(e: React.FormEvent) { function exportIdentity(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
if (!exportPass.trim()) return if (!exportPass.trim()) return
setExportBlob('')
send({ type: 'export_identity', passphrase: exportPass }) send({ type: 'export_identity', passphrase: exportPass })
} }
@@ -86,6 +93,15 @@ export function Onboarding({ status }: Props) {
send({ type: 'import_identity', backup: importJson, passphrase: importPass }) send({ type: 'import_identity', backup: importJson, passphrase: importPass })
} }
function switchToDaemon() {
localStorage.setItem('waste_daemon_ws', daemonUrl)
connect(daemonUrl)
}
function switchToBrowser() {
connectBrowser()
}
const shortId = masterId ? masterId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim() : null const shortId = masterId ? masterId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim() : null
// ── disconnected / connecting ──────────────────────────────────────────────── // ── disconnected / connecting ────────────────────────────────────────────────
@@ -94,18 +110,16 @@ export function Onboarding({ status }: Props) {
<div className="onboarding"> <div className="onboarding">
<h1>waste</h1> <h1>waste</h1>
{status === 'connecting' ? ( {status === 'connecting' ? (
<p className="status connecting">Connecting to daemon</p> <p className="status connecting">
{adapterMode === 'browser' ? 'Loading crypto…' : 'Connecting to daemon…'}
</p>
) : ( ) : (
<> <>
<p className="status disconnected">Daemon not running</p> <p className="status disconnected">Daemon not running</p>
<p className="onboarding-hint"> <p className="onboarding-hint">Start the daemon to use the web UI:</p>
Start the daemon to use the web UI:
</p>
<pre className="onboarding-code">./launch-web.sh</pre> <pre className="onboarding-code">./launch-web.sh</pre>
<p className="onboarding-hint muted"> <p className="onboarding-hint muted">Or use browser mode (no install required):</p>
Or pass a custom alias and network: <button className="primary" onClick={switchToBrowser}>Use browser mode</button>
</p>
<pre className="onboarding-code">ALIAS=alice NETWORK=friends ./launch-web.sh</pre>
</> </>
)} )}
</div> </div>
@@ -121,14 +135,25 @@ export function Onboarding({ status }: Props) {
<div className="onboarding-identity"> <div className="onboarding-identity">
<span className="alias">{masterAlias}</span> <span className="alias">{masterAlias}</span>
{shortId && <span className="peer-id mono">{shortId}</span>} {shortId && <span className="peer-id mono">{shortId}</span>}
<span className="peer-id" style={{ opacity: 0.4, fontSize: '9px' }}>
{adapterMode === 'browser' ? 'browser mode' : 'daemon mode'}
</span>
</div> </div>
)} )}
<form onSubmit={joinNetwork} className="join-form"> <form onSubmit={joinNetwork} className="join-form">
<label className="join-label">Join a network</label> <label className="join-label">Join a network</label>
{anchorHint && (
<p className="onboarding-hint muted">via {anchorHint}</p> {adapterMode === 'browser' && (
<input
value={nick}
onChange={e => setNick(e.target.value)}
placeholder="your name (optional)"
autoComplete="nickname"
style={{ marginBottom: '0.4rem' }}
/>
)} )}
{netHash && !network ? ( {netHash && !network ? (
<input <input
value={netHash} value={netHash}
@@ -148,6 +173,18 @@ export function Onboarding({ status }: Props) {
autoFocus autoFocus
/> />
)} )}
{adapterMode === 'browser' && (
<input
value={anchorUrl}
onChange={e => setAnchorUrl(e.target.value)}
placeholder="signal server (wss://…)"
autoComplete="url"
className="mono"
style={{ fontSize: '0.78rem' }}
/>
)}
{inviteString && ( {inviteString && (
<p className="onboarding-hint muted mono" style={{ fontSize: '0.68rem', wordBreak: 'break-all' }}> <p className="onboarding-hint muted mono" style={{ fontSize: '0.68rem', wordBreak: 'break-all' }}>
{inviteString.slice(0, 48)} {inviteString.slice(0, 48)}
@@ -158,6 +195,31 @@ export function Onboarding({ status }: Props) {
</button> </button>
</form> </form>
{/* Mode switcher — only shown when on localhost */}
{isLocal && adapterMode !== null && (
<div className="onboarding-section">
{adapterMode === 'daemon' ? (
<button className="toggle-link" onClick={switchToBrowser}>
Switch to browser mode
</button>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
<button className="toggle-link" onClick={() => {}}>
Switch to daemon mode
</button>
<input
value={daemonUrl}
onChange={e => setDaemonUrl(e.target.value)}
placeholder="ws://127.0.0.1:17338"
className="mono"
style={{ fontSize: '0.78rem' }}
/>
<button className="primary" onClick={switchToDaemon}>Connect</button>
</div>
)}
</div>
)}
<div className="onboarding-section"> <div className="onboarding-section">
<button <button
className="toggle-link" className="toggle-link"
@@ -170,7 +232,7 @@ export function Onboarding({ status }: Props) {
<div className="backup-panel"> <div className="backup-panel">
<p className="onboarding-hint"> <p className="onboarding-hint">
Export your identity as an encrypted file. You can import it on Export your identity as an encrypted file. You can import it on
any machine or in the TUI with <code>--import-identity</code>. any device browser or TUI.
</p> </p>
<form onSubmit={exportIdentity} className="backup-form"> <form onSubmit={exportIdentity} className="backup-form">

View File

@@ -1,10 +1,14 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { PeerInfo, NetworkInfo, ChatMessage, FileEntry, IpcMessage } from '../types' import type { PeerInfo, NetworkInfo, ChatMessage, FileEntry, IpcMessage } from '../types'
import { DaemonAdapter } from '../adapter/daemon' import { DaemonAdapter } from '../adapter/daemon'
import { BrowserAdapter } from '../adapter/browser'
type AnyAdapter = DaemonAdapter | BrowserAdapter
interface WasteState { interface WasteState {
// connection // connection
adapter: DaemonAdapter | null adapter: AnyAdapter | null
adapterMode: 'daemon' | 'browser' | null
daemonStatus: 'disconnected' | 'connecting' | 'connected' daemonStatus: 'disconnected' | 'connecting' | 'connected'
// identity // identity
@@ -31,6 +35,7 @@ interface WasteState {
// actions // actions
connect: (url: string) => void connect: (url: string) => void
connectBrowser: () => void
disconnect: () => void disconnect: () => void
send: (msg: IpcMessage) => void send: (msg: IpcMessage) => void
setActiveNetwork: (id: string) => void setActiveNetwork: (id: string) => void
@@ -40,6 +45,7 @@ interface WasteState {
export const useWaste = create<WasteState>((set, get) => ({ export const useWaste = create<WasteState>((set, get) => ({
adapter: null, adapter: null,
adapterMode: null,
daemonStatus: 'disconnected', daemonStatus: 'disconnected',
masterAlias: null, masterAlias: null,
masterId: null, masterId: null,
@@ -57,12 +63,22 @@ export const useWaste = create<WasteState>((set, get) => ({
adapter.onStatusChange = (s) => set({ daemonStatus: s }) adapter.onStatusChange = (s) => set({ daemonStatus: s })
adapter.on((msg) => get().handleEvent(msg)) adapter.on((msg) => get().handleEvent(msg))
adapter.connect() adapter.connect()
set({ adapter }) set({ adapter, adapterMode: 'daemon' })
},
connectBrowser() {
const adapter = new BrowserAdapter()
adapter.onStatusChange = (s) => set({ daemonStatus: s })
adapter.on((msg) => get().handleEvent(msg))
adapter.connect()
set({ adapter, adapterMode: 'browser' })
}, },
disconnect() { disconnect() {
get().adapter?.disconnect() const a = get().adapter
set({ adapter: null, daemonStatus: 'disconnected' }) if (a instanceof DaemonAdapter) a.disconnect()
else if (a instanceof BrowserAdapter) a.disconnect()
set({ adapter: null, adapterMode: null, daemonStatus: 'disconnected' })
}, },
send(msg) { send(msg) {
@@ -93,11 +109,13 @@ export const useWaste = create<WasteState>((set, get) => ({
} }
case 'network_joined': { case 'network_joined': {
set(s => ({ set(s => ({
localPeer: s.localPeer ?? msg.local_peer ?? null,
networks: s.networks.some(n => n.network_id === msg.network_id) networks: s.networks.some(n => n.network_id === msg.network_id)
? s.networks ? s.networks
: [...s.networks, { : [...s.networks, {
network_id: msg.network_id!, network_id: msg.network_id!,
network_name: msg.network_name!, network_name: msg.network_name!,
local_peer: msg.local_peer,
share_dir: msg.share_dir, share_dir: msg.share_dir,
}], }],
activeNetworkId: s.activeNetworkId ?? msg.network_id!, activeNetworkId: s.activeNetworkId ?? msg.network_id!,