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

View File

@@ -10,8 +10,8 @@ interface Props {
// ?invite=waste:<b64> waste: invite string (anchor + network name + net hash)
// ?n=<name> network name shorthand
// ?network=<name> network name
// ?net=<64hex> yaw2-style full network hash (joined without plaintext name)
// ?a=<url> anchor URL hint (informational — daemon controls its anchor)
// ?net=<64hex> yaw2-style full network hash
// ?a=<url> 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<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) {
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) {
<div className="onboarding">
<h1>waste</h1>
{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="onboarding-hint">
Start the daemon to use the web UI:
</p>
<p className="onboarding-hint">Start the daemon to use the web UI:</p>
<pre className="onboarding-code">./launch-web.sh</pre>
<p className="onboarding-hint muted">
Or pass a custom alias and network:
</p>
<pre className="onboarding-code">ALIAS=alice NETWORK=friends ./launch-web.sh</pre>
<p className="onboarding-hint muted">Or use browser mode (no install required):</p>
<button className="primary" onClick={switchToBrowser}>Use browser mode</button>
</>
)}
</div>
@@ -121,14 +135,25 @@ export function Onboarding({ status }: Props) {
<div className="onboarding-identity">
<span className="alias">{masterAlias}</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>
)}
<form onSubmit={joinNetwork} className="join-form">
<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 ? (
<input
value={netHash}
@@ -148,6 +173,18 @@ export function Onboarding({ status }: Props) {
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 && (
<p className="onboarding-hint muted mono" style={{ fontSize: '0.68rem', wordBreak: 'break-all' }}>
{inviteString.slice(0, 48)}
@@ -158,6 +195,31 @@ export function Onboarding({ status }: Props) {
</button>
</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">
<button
className="toggle-link"
@@ -170,7 +232,7 @@ export function Onboarding({ status }: Props) {
<div className="backup-panel">
<p className="onboarding-hint">
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>
<form onSubmit={exportIdentity} className="backup-form">