From 5bc16daae119204d82d173214220d1bcf765fc57 Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Tue, 23 Jun 2026 00:06:41 +0200 Subject: [PATCH] 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 --- web/config.example.js | 7 + web/index.html | 4 +- web/package-lock.json | 16 + web/package.json | 1 + web/src/App.tsx | 14 +- web/src/adapter/browser.ts | 673 +++++++++++++++++++++++++++++++++++ web/src/pages/Onboarding.tsx | 114 ++++-- web/src/store/index.ts | 26 +- 8 files changed, 820 insertions(+), 35 deletions(-) create mode 100644 web/config.example.js create mode 100644 web/src/adapter/browser.ts diff --git a/web/config.example.js b/web/config.example.js new file mode 100644 index 0000000..17192c3 --- /dev/null +++ b/web/config.example.js @@ -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', +} diff --git a/web/index.html b/web/index.html index 5e3836a..fe9c30c 100644 --- a/web/index.html +++ b/web/index.html @@ -4,10 +4,12 @@ - web + waste
+ + diff --git a/web/package-lock.json b/web/package-lock.json index 3c3a946..97ececd 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,7 @@ "name": "web", "version": "0.0.0", "dependencies": { + "libsodium-wrappers": "^0.8.4", "react": "^19.2.6", "react-dom": "^19.2.6", "zustand": "^5.0.14" @@ -1883,6 +1884,21 @@ "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": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", diff --git a/web/package.json b/web/package.json index 5db3bb1..403e3ad 100644 --- a/web/package.json +++ b/web/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "libsodium-wrappers": "^0.8.4", "react": "^19.2.6", "react-dom": "^19.2.6", "zustand": "^5.0.14" diff --git a/web/src/App.tsx b/web/src/App.tsx index d43e51b..d92f3f9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,15 +4,21 @@ import { Onboarding } from './pages/Onboarding' import { Chat } from './pages/Chat' 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' export default function App() { - const { connect, daemonStatus, localPeer } = useWaste() + const { connect, connectBrowser, daemonStatus, localPeer } = useWaste() useEffect(() => { - connect(DAEMON_WS) - }, [connect]) + if (isLocal) { + connect(DAEMON_WS) + } else { + connectBrowser() + } + }, [connect, connectBrowser]) if (daemonStatus !== 'connected' || !localPeer) { return diff --git a/web/src/adapter/browser.ts b/web/src/adapter/browser.ts new file mode 100644 index 0000000..e065e1a --- /dev/null +++ b/web/src/adapter/browser.ts @@ -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, 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 { + this._cbs = { onFrom, onJoin, onLeave, onReconnect } + return this._open(true) + } + + private _open(initial: boolean): Promise { + 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 + 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) => 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) { + 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 + 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 + 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 { + 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 = new Map() + private present: Set = 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) { + 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 + } + } +} diff --git a/web/src/pages/Onboarding.tsx b/web/src/pages/Onboarding.tsx index b979053..6e49b92 100644 --- a/web/src/pages/Onboarding.tsx +++ b/web/src/pages/Onboarding.tsx @@ -10,8 +10,8 @@ interface Props { // ?invite=waste: waste: invite string (anchor + network name + net hash) // ?n= network name shorthand // ?network= network name -// ?net=<64hex> yaw2-style full network hash (joined without plaintext name) -// ?a= anchor URL hint (informational — daemon controls its anchor) +// ?net=<64hex> yaw2-style full network hash +// ?a= anchor URL hint function parseInviteParams(): { network: string; netHash: string; anchor: string; inviteString: string } { const p = new URLSearchParams(window.location.search) const inviteString = p.get('invite') ?? '' @@ -21,7 +21,6 @@ function parseInviteParams(): { network: string; netHash: string; anchor: string if (inviteString.startsWith('waste:')) { try { - // URL-safe base64 → standard base64 const json = JSON.parse(atob(inviteString.slice(6).replace(/-/g, '+').replace(/_/g, '/'))) network = network || json.network || '' netHash = netHash || json.net || '' @@ -29,33 +28,36 @@ function parseInviteParams(): { network: string; netHash: string; anchor: string } 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 } } +const DEFAULT_ANCHOR = (() => { + const cfg = (window as unknown as Record)['WASTE_CONFIG'] as Record | 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) { - const { send, masterAlias, masterId, exportedBackup } = useWaste() + const { send, connect, connectBrowser, adapterMode, masterAlias, masterId, exportedBackup } = useWaste() + const [network, setNetwork] = useState('') const [netHash, setNetHash] = 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 [importJson, setImportJson] = useState('') const [importPass, setImportPass] = useState('') const [importStatus, setImportStatus] = useState('') const [showBackup, setShowBackup] = useState(false) + const [daemonUrl, setDaemonUrl] = useState(localStorage.getItem('waste_daemon_ws') || 'ws://127.0.0.1:17338') useEffect(() => { const { network: n, netHash: nh, anchor: a, inviteString: inv } = parseInviteParams() if (n) setNetwork(n) if (nh) setNetHash(nh) - if (a) setAnchorHint(a) + if (a) setAnchorUrl(a) if (inv) setInviteString(inv) }, []) @@ -64,8 +66,14 @@ export function Onboarding({ status }: Props) { const name = network.trim() const hash = netHash.trim() 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) { - // yaw2-style: join by full network hash without plaintext name send({ type: 'join_network', network_hash: hash }) } else { send({ type: 'join_network', network_name: name }) @@ -75,7 +83,6 @@ export function Onboarding({ status }: Props) { function exportIdentity(e: React.FormEvent) { e.preventDefault() if (!exportPass.trim()) return - setExportBlob('') send({ type: 'export_identity', passphrase: exportPass }) } @@ -86,6 +93,15 @@ export function Onboarding({ status }: Props) { 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 // ── disconnected / connecting ──────────────────────────────────────────────── @@ -94,18 +110,16 @@ export function Onboarding({ status }: Props) {

waste

{status === 'connecting' ? ( -

Connecting to daemon…

+

+ {adapterMode === 'browser' ? 'Loading crypto…' : 'Connecting to daemon…'} +

) : ( <>

Daemon not running

-

- Start the daemon to use the web UI: -

+

Start the daemon to use the web UI:

./launch-web.sh
-

- Or pass a custom alias and network: -

-
ALIAS=alice NETWORK=friends ./launch-web.sh
+

Or use browser mode (no install required):

+ )}
@@ -121,14 +135,25 @@ export function Onboarding({ status }: Props) {
{masterAlias} {shortId && {shortId}} + + {adapterMode === 'browser' ? 'browser mode' : 'daemon mode'} +
)}
- {anchorHint && ( -

via {anchorHint}

+ + {adapterMode === 'browser' && ( + setNick(e.target.value)} + placeholder="your name (optional)" + autoComplete="nickname" + style={{ marginBottom: '0.4rem' }} + /> )} + {netHash && !network ? ( )} + + {adapterMode === 'browser' && ( + setAnchorUrl(e.target.value)} + placeholder="signal server (wss://…)" + autoComplete="url" + className="mono" + style={{ fontSize: '0.78rem' }} + /> + )} + {inviteString && (

{inviteString.slice(0, 48)}… @@ -158,6 +195,31 @@ export function Onboarding({ status }: Props) {

+ {/* Mode switcher — only shown when on localhost */} + {isLocal && adapterMode !== null && ( +
+ {adapterMode === 'daemon' ? ( + + ) : ( +
+ + setDaemonUrl(e.target.value)} + placeholder="ws://127.0.0.1:17338" + className="mono" + style={{ fontSize: '0.78rem' }} + /> + +
+ )} +
+ )} +