From 7fe02e946359888f3b7cecc3f3ec9ea7a1e4d3e5 Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Mon, 22 Jun 2026 23:22:32 +0200 Subject: [PATCH] Fix web UI messaging, alias resolution, and onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Messaging: - Append \n to WS IPC sends so bufio.Scanner fires (messages from web were silently dropped — the line scanner never saw a newline) - Handle session_ready in web store to update peer aliases after hello Alias resolution: - Seed AddPeer alias from SQLite cache so returning peers resolve immediately, before hello exchange completes - Remove stale SavePeer call from AddPeer (was writing placeholder IDs) - Add PeerAlias() point-lookup to store Onboarding redesign: - Three-state UI: disconnected (daemon instructions), connecting, connected-no-network (join form) - Read ?n= / ?network= / ?anchor= / ?invite=waste:... URL params to pre-fill the join form for invite links - Show daemon alias + peer ID before any network is joined (master_alias + master_id now included in state_snapshot) - Identity backup: export (passphrase-protected yaw-key-backup-1 JSON, download button) and restore (paste + passphrase verify) Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + internal/ipc/ipc.go | 7 +- internal/mesh/mesh.go | 13 +-- internal/proto/proto.go | 2 + internal/store/store.go | 7 ++ web/src/App.css | 30 +++++- web/src/adapter/daemon.ts | 2 +- web/src/pages/Onboarding.tsx | 197 ++++++++++++++++++++++++++++++++--- web/src/store/index.ts | 38 +++++-- web/src/types.ts | 2 + 10 files changed, 265 insertions(+), 34 deletions(-) diff --git a/.gitignore b/.gitignore index b6d0331..d2bd79a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ launch-tui.sh .DS_Store *.swp /tui +launch-web.sh diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index c27095a..b0d5f00 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -345,9 +345,12 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) { func stateSnapshot(mgr *netmgr.Manager) proto.IpcMessage { all := mgr.All() + master := mgr.MasterIdentity() msg := proto.IpcMessage{ - Type: proto.EvtStateSnapshot, - Rooms: []string{"general"}, + Type: proto.EvtStateSnapshot, + Rooms: []string{"general"}, + MasterAlias: master.Alias, + MasterID: string(master.PeerID()), } var netInfos []proto.NetworkInfo diff --git a/internal/mesh/mesh.go b/internal/mesh/mesh.go index 385574c..e1489e8 100644 --- a/internal/mesh/mesh.go +++ b/internal/mesh/mesh.go @@ -89,16 +89,17 @@ func (m *Mesh) ScanShareDir() []proto.FileEntry { // AddPeer registers a connected peer and notifies subscribers. func (m *Mesh) AddPeer(conn *PeerConn) { + // Seed alias from cache so returning peers resolve immediately (before hello). + if m.Store != nil { + if cached := m.Store.PeerAlias(conn.Info.ID); cached != "" { + conn.Info.Alias = cached + } + } + m.mu.Lock() m.peers[conn.Info.ID] = conn m.mu.Unlock() - if m.Store != nil && conn.Info.Alias != "" { - if err := m.Store.SavePeer(conn.Info.ID, conn.Info.Alias); err != nil { - log.Printf("mesh: store peer %s: %v", conn.Info.ID.Short(), err) - } - } - m.emit(proto.IpcMessage{ Type: proto.EvtPeerConnected, Peer: &conn.Info, diff --git a/internal/proto/proto.go b/internal/proto/proto.go index 2e78317..4748de7 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -280,6 +280,8 @@ type IpcMessage struct { BytesReceived int64 `json:"bytes_received,omitempty"` TotalBytes int64 `json:"total_bytes,omitempty"` // state_snapshot fields (existing shape preserved for backward compat) + MasterAlias string `json:"master_alias,omitempty"` // daemon's alias (available before any network join) + MasterID string `json:"master_id,omitempty"` // daemon's master public key hex LocalPeer *PeerInfo `json:"local_peer,omitempty"` ConnectedPeers []PeerInfo `json:"connected_peers,omitempty"` Rooms []string `json:"rooms,omitempty"` diff --git a/internal/store/store.go b/internal/store/store.go index bdf61e6..ab9759e 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -77,6 +77,13 @@ func (s *Store) SavePeer(peerID proto.PeerID, alias string) error { return err } +// PeerAlias returns the cached alias for a peer, or "" if unknown. +func (s *Store) PeerAlias(peerID proto.PeerID) string { + var alias string + s.db.QueryRow(`SELECT alias FROM peers WHERE peer_id = ?`, string(peerID)).Scan(&alias) //nolint:errcheck + return alias +} + // RecentMessages returns up to limit messages for a room, oldest first. func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, error) { rows, err := s.db.Query( diff --git a/web/src/App.css b/web/src/App.css index da59767..5eb00d7 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -20,11 +20,31 @@ input { background: var(--surface); color: var(--text); border: 1px solid var(-- input:focus { border-color: var(--accent); } /* ── onboarding ── */ -.onboarding { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 16px; } -.onboarding h1 { font-size: 2rem; letter-spacing: 0.1em; color: var(--accent); } -.onboarding .status { color: var(--muted); } -.join-form { display: flex; gap: 8px; } -.join-form input { width: 220px; } +.onboarding { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 12px; max-width: 380px; margin: 0 auto; padding: 2rem; } +.onboarding h1 { font-size: 2rem; letter-spacing: 0.1em; color: var(--accent); margin-bottom: 4px; } +.onboarding .status { color: var(--muted); font-size: 13px; } +.onboarding .status.connecting { color: var(--accent); } +.onboarding .status.disconnected { color: #e06060; } +.onboarding-identity { text-align: center; } +.onboarding-identity .alias { display: block; font-size: 1.1rem; font-weight: 600; } +.onboarding-identity .peer-id { display: block; color: var(--muted); font-size: 11px; margin-top: 2px; } +.onboarding-hint { color: var(--muted); font-size: 12px; text-align: center; } +.onboarding-hint.muted { color: var(--muted); } +.onboarding-code { background: var(--surface); border: 1px solid var(--border); border-radius: 4px; padding: 8px 12px; font-size: 12px; font-family: monospace; color: var(--text); width: 100%; text-align: left; } +.onboarding-section { width: 100%; border-top: 1px solid var(--border); padding-top: 12px; } +.join-form { display: flex; flex-direction: column; gap: 8px; width: 100%; } +.join-form input { width: 100%; } +.join-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); } +button.primary { background: var(--accent); width: 100%; padding: 8px; font-size: 14px; } +.toggle-link { background: none; color: var(--muted); font-size: 12px; padding: 4px 0; text-align: left; } +.toggle-link:hover { color: var(--text); } +.backup-panel { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; } +.backup-form { display: flex; flex-direction: column; gap: 6px; } +.backup-form textarea { background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 6px 8px; font-size: 11px; resize: vertical; } +.export-result { display: flex; flex-direction: column; gap: 6px; } +.export-result textarea { font-size: 10px; } +.mono { font-family: monospace; } +details summary { color: var(--muted); font-size: 12px; cursor: pointer; } /* ── chat layout ── */ .chat-layout { display: grid; grid-template-columns: var(--sidebar-w) 1fr var(--peerlist-w); height: 100%; } diff --git a/web/src/adapter/daemon.ts b/web/src/adapter/daemon.ts index bb7aa7e..683160f 100644 --- a/web/src/adapter/daemon.ts +++ b/web/src/adapter/daemon.ts @@ -58,7 +58,7 @@ export class DaemonAdapter { send(msg: IpcMessage) { if (this.ws?.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify(msg)) + this.ws.send(JSON.stringify(msg) + '\n') } } diff --git a/web/src/pages/Onboarding.tsx b/web/src/pages/Onboarding.tsx index 94acc94..ba9947b 100644 --- a/web/src/pages/Onboarding.tsx +++ b/web/src/pages/Onboarding.tsx @@ -1,35 +1,206 @@ +import { useState, useEffect } from 'react' import { useWaste } from '../store' interface Props { status: 'disconnected' | 'connecting' | 'connected' } +// Parse URL search params for pre-filling the join form. +// Supports: +// ?invite=waste: waste: invite string (anchor + network name) +// ?n= network name shorthand +// ?network= network name +// ?a= anchor URL hint (informational — daemon controls its anchor) +function parseInviteParams(): { network: string; anchor: string; inviteString: string } { + const p = new URLSearchParams(window.location.search) + const inviteString = p.get('invite') ?? '' + let network = p.get('n') ?? p.get('network') ?? '' + let anchor = p.get('a') ?? p.get('anchor') ?? '' + + if (inviteString.startsWith('waste:')) { + try { + const json = JSON.parse(atob(inviteString.slice(6).replace(/-/g, '+').replace(/_/g, '/'))) + network = network || json.network || '' + anchor = anchor || json.anchor || '' + } catch { /* ignore bad invite */ } + } + + return { network, anchor, inviteString } +} + export function Onboarding({ status }: Props) { - const { send } = useWaste() + const { send, masterAlias, masterId, exportedBackup } = useWaste() + const [network, setNetwork] = useState('') + const [inviteString, setInviteString] = useState('') + const [anchorHint, setAnchorHint] = useState('') + const [exportPass, setExportPass] = useState('') + const [importJson, setImportJson] = useState('') + const [importPass, setImportPass] = useState('') + const [importStatus, setImportStatus] = useState('') + const [showBackup, setShowBackup] = useState(false) - const label = status === 'connecting' - ? 'Connecting to daemon…' - : 'Waiting for daemon — is waste-daemon running?' + useEffect(() => { + const { network: n, anchor: a, inviteString: inv } = parseInviteParams() + if (n) setNetwork(n) + if (a) setAnchorHint(a) + if (inv) setInviteString(inv) + }, []) - function joinNetwork(e: React.FormEvent) { + function joinNetwork(e: React.FormEvent) { e.preventDefault() - const form = e.currentTarget - const name = (form.elements.namedItem('network') as HTMLInputElement).value.trim() + const name = network.trim() if (!name) return send({ type: 'join_network', network_name: name }) } + function exportIdentity(e: React.FormEvent) { + e.preventDefault() + if (!exportPass.trim()) return + setExportBlob('') + send({ type: 'export_identity', passphrase: exportPass }) + } + + function importIdentity(e: React.FormEvent) { + e.preventDefault() + if (!importJson.trim() || !importPass.trim()) return + setImportStatus('Verifying…') + send({ type: 'import_identity', backup: importJson, passphrase: importPass }) + } + + const shortId = masterId ? masterId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim() : null + + // ── disconnected / connecting ──────────────────────────────────────────────── + if (status !== 'connected') { + return ( +
+

waste

+ {status === 'connecting' ? ( +

Connecting to daemon…

+ ) : ( + <> +

Daemon not running

+

+ 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
+ + )} +
+ ) + } + + // ── connected — join form ──────────────────────────────────────────────────── return (

waste

-

{label}

- {status === 'connected' && ( -
- - -
+ {masterAlias && ( +
+ {masterAlias} + {shortId && {shortId}} +
)} + +
+ + {anchorHint && ( +

via {anchorHint}

+ )} + setNetwork(e.target.value)} + placeholder="network name" + autoComplete="off" + autoFocus + /> + {inviteString && ( + + )} + +
+ +
+ + + {showBackup && ( +
+

+ Export your identity as an encrypted file. You can import it on + any machine or in the TUI with --import-identity. +

+ +
+ setExportPass(e.target.value)} + placeholder="passphrase for backup" + autoComplete="new-password" + /> + +
+ + {exportedBackup && ( +
+