Fix web UI messaging, alias resolution, and onboarding
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 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,3 +13,4 @@ launch-tui.sh
|
||||
.DS_Store
|
||||
*.swp
|
||||
/tui
|
||||
launch-web.sh
|
||||
|
||||
@@ -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"},
|
||||
MasterAlias: master.Alias,
|
||||
MasterID: string(master.PeerID()),
|
||||
}
|
||||
|
||||
var netInfos []proto.NetworkInfo
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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%; }
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:<b64> waste: invite string (anchor + network name)
|
||||
// ?n=<name> network name shorthand
|
||||
// ?network=<name> network name
|
||||
// ?a=<url> 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<HTMLFormElement>) {
|
||||
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 (
|
||||
<div className="onboarding">
|
||||
<h1>waste</h1>
|
||||
<p className="status">{label}</p>
|
||||
|
||||
{status === 'connected' && (
|
||||
<form onSubmit={joinNetwork} className="join-form">
|
||||
<input name="network" placeholder="network name" autoComplete="off" autoFocus />
|
||||
<button type="submit">Join</button>
|
||||
</form>
|
||||
{status === 'connecting' ? (
|
||||
<p className="status connecting">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>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── connected — join form ────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="onboarding">
|
||||
<h1>waste</h1>
|
||||
|
||||
{masterAlias && (
|
||||
<div className="onboarding-identity">
|
||||
<span className="alias">{masterAlias}</span>
|
||||
{shortId && <span className="peer-id mono">{shortId}</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>
|
||||
)}
|
||||
<input
|
||||
value={network}
|
||||
onChange={e => setNetwork(e.target.value)}
|
||||
placeholder="network name"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
{inviteString && (
|
||||
<input
|
||||
value={inviteString}
|
||||
readOnly
|
||||
className="muted mono"
|
||||
style={{ fontSize: '0.72rem' }}
|
||||
title="invite string"
|
||||
/>
|
||||
)}
|
||||
<button type="submit" className="primary" disabled={!network.trim()}>
|
||||
Join
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="onboarding-section">
|
||||
<button
|
||||
className="toggle-link"
|
||||
onClick={() => setShowBackup(b => !b)}
|
||||
>
|
||||
{showBackup ? '▾' : '▸'} Identity backup
|
||||
</button>
|
||||
|
||||
{showBackup && (
|
||||
<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>.
|
||||
</p>
|
||||
|
||||
<form onSubmit={exportIdentity} className="backup-form">
|
||||
<input
|
||||
type="password"
|
||||
value={exportPass}
|
||||
onChange={e => setExportPass(e.target.value)}
|
||||
placeholder="passphrase for backup"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button type="submit" disabled={!exportPass.trim()}>Export</button>
|
||||
</form>
|
||||
|
||||
{exportedBackup && (
|
||||
<div className="export-result">
|
||||
<textarea
|
||||
readOnly
|
||||
value={exportedBackup}
|
||||
rows={4}
|
||||
className="mono"
|
||||
onClick={e => (e.target as HTMLTextAreaElement).select()}
|
||||
/>
|
||||
<button onClick={() => {
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(new Blob([exportedBackup], { type: 'application/json' }))
|
||||
a.download = `waste-identity-${masterId?.slice(0, 8) ?? 'backup'}.json`
|
||||
a.click()
|
||||
}}>Download file</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<details style={{ marginTop: '1rem' }}>
|
||||
<summary className="onboarding-hint" style={{ cursor: 'pointer' }}>Restore from backup</summary>
|
||||
<form onSubmit={importIdentity} className="backup-form" style={{ marginTop: '0.5rem' }}>
|
||||
<textarea
|
||||
value={importJson}
|
||||
onChange={e => setImportJson(e.target.value)}
|
||||
placeholder='{"yaw":"yaw-key-backup-1", …}'
|
||||
rows={3}
|
||||
className="mono"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={importPass}
|
||||
onChange={e => setImportPass(e.target.value)}
|
||||
placeholder="passphrase"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button type="submit" disabled={!importJson.trim() || !importPass.trim()}>
|
||||
Verify & restore
|
||||
</button>
|
||||
{importStatus && <p className="onboarding-hint">{importStatus}</p>}
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ interface WasteState {
|
||||
daemonStatus: 'disconnected' | 'connecting' | 'connected'
|
||||
|
||||
// identity
|
||||
masterAlias: string | null
|
||||
masterId: string | null
|
||||
localPeer: PeerInfo | null
|
||||
|
||||
// networks
|
||||
@@ -24,6 +26,9 @@ interface WasteState {
|
||||
// file listings — keyed by peer id
|
||||
fileLists: Record<string, FileEntry[]>
|
||||
|
||||
// identity backup export result (cleared when consumed)
|
||||
exportedBackup: string | null
|
||||
|
||||
// actions
|
||||
connect: (url: string) => void
|
||||
disconnect: () => void
|
||||
@@ -36,6 +41,8 @@ interface WasteState {
|
||||
export const useWaste = create<WasteState>((set, get) => ({
|
||||
adapter: null,
|
||||
daemonStatus: 'disconnected',
|
||||
masterAlias: null,
|
||||
masterId: null,
|
||||
localPeer: null,
|
||||
networks: [],
|
||||
activeNetworkId: null,
|
||||
@@ -43,6 +50,7 @@ export const useWaste = create<WasteState>((set, get) => ({
|
||||
messages: {},
|
||||
activeRoom: 'general',
|
||||
fileLists: {},
|
||||
exportedBackup: null,
|
||||
|
||||
connect(url: string) {
|
||||
const adapter = new DaemonAdapter(url)
|
||||
@@ -74,6 +82,8 @@ export const useWaste = create<WasteState>((set, get) => ({
|
||||
case 'state_snapshot': {
|
||||
const networks = msg.networks ?? []
|
||||
set({
|
||||
masterAlias: msg.master_alias ?? null,
|
||||
masterId: msg.master_id ?? null,
|
||||
localPeer: msg.local_peer ?? null,
|
||||
networks,
|
||||
connectedPeers: msg.connected_peers ?? [],
|
||||
@@ -113,6 +123,16 @@ export const useWaste = create<WasteState>((set, get) => ({
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'session_ready': {
|
||||
if (msg.peer_id && msg.nick) {
|
||||
set(s => ({
|
||||
connectedPeers: s.connectedPeers.map(p =>
|
||||
p.id === msg.peer_id ? { ...p, alias: msg.nick! } : p
|
||||
),
|
||||
}))
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'peer_disconnected': {
|
||||
set(s => ({
|
||||
connectedPeers: s.connectedPeers.filter(p => p.id !== msg.peer_id),
|
||||
@@ -121,16 +141,20 @@ export const useWaste = create<WasteState>((set, get) => ({
|
||||
}
|
||||
case 'message_received': {
|
||||
if (msg.message) {
|
||||
const room = msg.message.room
|
||||
set(s => ({
|
||||
messages: {
|
||||
...s.messages,
|
||||
[room]: [...(s.messages[room] ?? []), msg.message!],
|
||||
},
|
||||
}))
|
||||
const m = msg.message
|
||||
const room = m.room
|
||||
set(s => {
|
||||
const existing = s.messages[room] ?? []
|
||||
if (m.mid && existing.some(e => e.mid === m.mid)) return s
|
||||
return { messages: { ...s.messages, [room]: [...existing, m] } }
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'identity_exported': {
|
||||
if (msg.backup) set({ exportedBackup: msg.backup })
|
||||
break
|
||||
}
|
||||
case 'file_list': {
|
||||
if (msg.peer_id && msg.files) {
|
||||
set(s => ({
|
||||
|
||||
@@ -94,6 +94,8 @@ export interface IpcMessage {
|
||||
transfer_id?: string
|
||||
bytes_received?: number
|
||||
total_bytes?: number
|
||||
master_alias?: string
|
||||
master_id?: string
|
||||
local_peer?: PeerInfo
|
||||
connected_peers?: PeerInfo[]
|
||||
rooms?: string[]
|
||||
|
||||
Reference in New Issue
Block a user