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:
7
web/config.example.js
Normal file
7
web/config.example.js
Normal 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',
|
||||||
|
}
|
||||||
@@ -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
16
web/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
673
web/src/adapter/browser.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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!,
|
||||||
|
|||||||
Reference in New Issue
Block a user