diff --git a/.gitignore b/.gitignore index 7c6fec3..4adc34d 100644 --- a/.gitignore +++ b/.gitignore @@ -261,6 +261,11 @@ frontend/.desktop-export-stash-*/ backend/data/wormhole_stderr.log backend/data/wormhole_stdout.log +# Hermes Agent (operator-local runtime install — not project source) +.hermes/ +**/.hermes/ +hermes-agent/ + # Runtime caches that already slip through the backend/data/* blanket # (these are caught by the wildcard but listing for clarity) diff --git a/backend/Dockerfile b/backend/Dockerfile index bdb9868..5c47cf9 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -27,6 +27,7 @@ WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ + git \ tor \ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ @@ -72,7 +73,7 @@ ENV PRIVACY_CORE_LIB=/app/libprivacy_core.so # Create a non-root user for security # Grant write access to /app so the auto-updater can extract files # Pre-create /app/data so mounted volumes inherit correct ownership -RUN adduser --system --uid 1001 backenduser \ +RUN adduser --system --uid 1001 --home /app backenduser \ && mkdir -p /app/data \ && chown -R backenduser /app \ && chmod -R u+w /app diff --git a/backend/routers/agent_shell.py b/backend/routers/agent_shell.py index 56ddf6b..bc95cc4 100644 --- a/backend/routers/agent_shell.py +++ b/backend/routers/agent_shell.py @@ -43,9 +43,32 @@ def _set_winsize(fd: int, rows: int, cols: int) -> None: fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize) +def _published_local_dashboard_ws(ws: WebSocket) -> bool: + """Browser → published Docker port appears as a bridge IP, not loopback. + + For the operator shell only, also accept when the upgrade request clearly + targets the local dashboard (Host/Origin on localhost). + """ + host_header = str(ws.headers.get("host") or "").strip().lower() + host_name = host_header.split(":", 1)[0] + if host_name in {"127.0.0.1", "localhost", "::1"}: + return True + + origin = str(ws.headers.get("origin") or "").strip().lower() + if origin.startswith("http://127.0.0.1:") or origin.startswith("http://localhost:"): + return True + if origin.startswith("https://127.0.0.1:") or origin.startswith("https://localhost:"): + return True + return False + + async def _authorize_agent_shell_ws(ws: WebSocket, admin_key_query: str = "") -> None: host = (ws.client.host or "").lower() if ws.client else "" - if _is_trusted_local_runtime_host(host) or (_debug_mode_enabled() and host == "test"): + if ( + _is_trusted_local_runtime_host(host) + or _published_local_dashboard_ws(ws) + or (_debug_mode_enabled() and host == "test") + ): return admin_key = _current_admin_key() presented = str(admin_key_query or ws.headers.get("x-admin-key", "") or "").strip() @@ -164,6 +187,18 @@ async def agent_shell_websocket( env = os.environ.copy() env.setdefault("TERM", "xterm-256color") env.setdefault("COLORTERM", "truecolor") + home = shell_cwd if os.path.isdir(shell_cwd) else "/app" + env["HOME"] = home + env["USER"] = env.get("USER") or "operator" + path_prefixes = [ + os.path.join(home, ".local", "bin"), + os.path.join(home, ".hermes", "bin"), + ] + path = env.get("PATH", "") + for prefix in path_prefixes: + if os.path.isdir(prefix): + path = f"{prefix}:{path}" if path else prefix + env["PATH"] = path proc = await asyncio.create_subprocess_exec( shell, diff --git a/backend/services/agent_shell_settings.py b/backend/services/agent_shell_settings.py index 3fd50e2..018c575 100644 --- a/backend/services/agent_shell_settings.py +++ b/backend/services/agent_shell_settings.py @@ -16,7 +16,13 @@ _LOCK = threading.Lock() def _default_working_directory() -> str: - return os.environ.get("AGENT_SHELL_DEFAULT_CWD") or os.environ.get("HOME") or "/app" + explicit = str(os.environ.get("AGENT_SHELL_DEFAULT_CWD") or "").strip() + if explicit and os.path.isdir(explicit): + return explicit + home = str(os.environ.get("HOME") or "").strip() + if home and home != "/nonexistent" and os.path.isdir(home): + return home + return "/app" def get_agent_shell_settings() -> dict[str, Any]: diff --git a/desktop-shell/tauri-skeleton/build.ps1 b/desktop-shell/tauri-skeleton/build.ps1 index a250fd8..9811cb6 100644 --- a/desktop-shell/tauri-skeleton/build.ps1 +++ b/desktop-shell/tauri-skeleton/build.ps1 @@ -132,7 +132,7 @@ try { ) { $env:TAURI_SIGNING_PRIVATE_KEY = Get-Content -LiteralPath $localUpdaterKey -Raw if (($null -eq $env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD) -and (Test-Path $localUpdaterKeyPassword)) { - $env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = Get-Content -LiteralPath $localUpdaterKeyPassword -Raw + $env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = (Get-Content -LiteralPath $localUpdaterKeyPassword -Raw).Trim() } } diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..d3a62f3 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,10 @@ +# Auto-loaded by `docker compose` — build from local source instead of pulling stale GHCR images. +services: + backend: + build: + context: . + dockerfile: ./backend/Dockerfile + + frontend: + build: + context: ./frontend diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 0a67e19..76d858a 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -615,7 +615,7 @@ export default function Dashboard() { )} - {/* 2. MESH CHAT (Middle) */} + {/* 2. MESHTASTIC CHAT (Middle) */} {secondaryBootReady && (
setSettingsOpen(true)} onTerminalToggle={openSecureTerminalLauncher} + onOpenLiveGate={openLiveGateFromShell} + onOpenDeadDrop={openDeadDropFromShell} launchRequest={meshChatLaunchRequest} />
diff --git a/frontend/src/components/InfonetTerminal/InfonetShell.tsx b/frontend/src/components/InfonetTerminal/InfonetShell.tsx index adc6b5c..6e6bfb2 100644 --- a/frontend/src/components/InfonetTerminal/InfonetShell.tsx +++ b/frontend/src/components/InfonetTerminal/InfonetShell.tsx @@ -137,6 +137,9 @@ interface CommandHistory { interface InfonetShellProps { isOpen: boolean; + embedded?: boolean; + launchGate?: string; + onLaunchGateConsumed?: () => void; onClose: () => void; onOpenLiveGate?: (gate: string) => void; onOpenDeadDrop?: (peerId: string, options?: { showSas?: boolean }) => void; @@ -144,6 +147,9 @@ interface InfonetShellProps { export default function InfonetShell({ isOpen, + embedded = false, + launchGate = '', + onLaunchGateConsumed, onClose, onOpenLiveGate, onOpenDeadDrop, @@ -176,6 +182,7 @@ export default function InfonetShell({ const inputRef = useRef(null); const containerRef = useRef(null); const gateLaunchAttemptRef = useRef(0); + const launchGateConsumedRef = useRef(''); // Real mesh identity const nodeIdentity = useMemo(() => getNodeIdentity(), []); @@ -203,6 +210,7 @@ export default function InfonetShell({ setPendingGate(null); setInput(''); gateLaunchAttemptRef.current += 1; + launchGateConsumedRef.current = ''; setIsBooting(true); setBootText([]); @@ -600,6 +608,14 @@ export default function InfonetShell({ setHistory(prev => [...prev, { command: cmd, output }]); }; + useEffect(() => { + const gate = String(launchGate || '').trim().toLowerCase(); + if (!isOpen || isBooting || !gate || launchGateConsumedRef.current === gate) return; + launchGateConsumedRef.current = gate; + handleCommand(`join ${gate}`); + onLaunchGateConsumed?.(); + }, [isOpen, isBooting, launchGate, onLaunchGateConsumed]); + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { if (inputMode === 'normal' && input.startsWith('g/') && searchMatch) { @@ -635,7 +651,12 @@ export default function InfonetShell({ } return ( -
+
{currentView === 'terminal' && ( <> {/* Top Navigation / Quick Launch */} diff --git a/frontend/src/components/InfonetTerminal/NetworkStats.tsx b/frontend/src/components/InfonetTerminal/NetworkStats.tsx index bbe04dc..6ae3888 100644 --- a/frontend/src/components/InfonetTerminal/NetworkStats.tsx +++ b/frontend/src/components/InfonetTerminal/NetworkStats.tsx @@ -13,11 +13,12 @@ interface Stats { seedPeers: number; nodeEnabled: boolean; syncOutcome: string; + syncError: string; } const EMPTY: Stats = { meshtastic: 0, aprs: 0, ledgerNodes: 0, infonetEvents: 0, - syncPeers: 0, seedPeers: 0, nodeEnabled: false, syncOutcome: 'offline', + syncPeers: 0, seedPeers: 0, nodeEnabled: false, syncOutcome: 'offline', syncError: '', }; export default function NetworkStats() { @@ -41,6 +42,7 @@ export default function NetworkStats() { ?? infonet?.bootstrap?.default_sync_peer_count ?? 0, ); + const syncOutcome = String(infonet?.sync_runtime?.last_outcome || 'offline').toLowerCase(); setStats({ meshtastic: Number(channelsRes?.total_live || channelsRes?.total_nodes || meshRes?.signal_counts?.meshtastic || 0), aprs: Number(meshRes?.signal_counts?.aprs || 0), @@ -49,26 +51,36 @@ export default function NetworkStats() { syncPeers: syncPeerCount, seedPeers: seedPeerCount, nodeEnabled: Boolean(infonet?.node_enabled), - syncOutcome: String(infonet?.sync_runtime?.last_outcome || 'offline').toLowerCase(), + syncOutcome, + syncError: String(infonet?.sync_runtime?.last_error || '').trim(), }); } catch { /* ignore */ } }; poll(); - const interval = setInterval(poll, 15000); + const interval = setInterval(poll, 8000); return () => { alive = false; clearInterval(interval); }; }, []); - const nodeColor = stats.syncOutcome === 'ok' ? 'text-green-400' + const syncErrorLower = stats.syncError.toLowerCase(); + const artiBlocked = syncErrorLower.includes('arti') || syncErrorLower.includes('onion'); + const nodeColor = stats.syncOutcome === 'ok' || stats.syncOutcome === 'solo' ? 'text-green-400' : stats.syncOutcome === 'running' ? 'text-amber-400' : stats.nodeEnabled ? 'text-amber-400' : 'text-gray-600'; const nodeLabel = stats.syncOutcome === 'ok' ? 'SEED SYNCED' + : stats.syncOutcome === 'solo' ? 'SOLO' : stats.syncOutcome === 'running' ? 'SYNCING' - : stats.syncOutcome === 'error' || stats.syncOutcome === 'fork' ? 'RETRYING' + : stats.syncOutcome === 'error' || stats.syncOutcome === 'fork' + ? (artiBlocked ? 'ARTI WARMING' : 'SYNC BACKOFF') : stats.nodeEnabled ? 'WAITING' : 'OFFLINE'; + const nodeTitle = stats.syncError + ? `Infonet seed sync: ${stats.syncError}` + : stats.nodeEnabled + ? 'Participant node enabled; waiting for seed ledger sync.' + : 'Participant node offline.'; return (
- NODE {nodeLabel} + NODE {nodeLabel} | MESH 0 ? 'text-green-400' : 'text-gray-600'}>{stats.meshtastic.toLocaleString()} | diff --git a/frontend/src/components/InfonetTerminal/index.tsx b/frontend/src/components/InfonetTerminal/index.tsx index 4217c40..6e500b6 100644 --- a/frontend/src/components/InfonetTerminal/index.tsx +++ b/frontend/src/components/InfonetTerminal/index.tsx @@ -4,9 +4,7 @@ import React, { useEffect } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import { X } from 'lucide-react'; import { - fetchInfonetNodeStatusSnapshot, - setInfonetNodeEnabled, - startTorHiddenService, + ensureInfonetParticipantNodeReady, } from '@/mesh/controlPlaneStatusClient'; import InfonetShell from './InfonetShell'; @@ -39,16 +37,8 @@ export default function InfonetTerminal({ const connectParticipantNode = async () => { try { - const nodeStatus = await fetchInfonetNodeStatusSnapshot(true).catch(() => null); - if (cancelled || nodeStatus?.node_enabled) return; - - const torStatus = await startTorHiddenService().catch(() => null); - if (cancelled || !torStatus?.running || !torStatus?.onion_address) return; - - await setInfonetNodeEnabled(true); - if (!cancelled) { - await fetchInfonetNodeStatusSnapshot(true).catch(() => null); - } + if (cancelled) return; + await ensureInfonetParticipantNodeReady(); } catch { // Remote/shared viewers may not have local-operator rights. Leave manual controls intact. } diff --git a/frontend/src/components/MeshChat/AgentShellPanel.tsx b/frontend/src/components/MeshChat/AgentShellPanel.tsx index 04297da..3dc0e57 100644 --- a/frontend/src/components/MeshChat/AgentShellPanel.tsx +++ b/frontend/src/components/MeshChat/AgentShellPanel.tsx @@ -1,232 +1,651 @@ 'use client'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; + + +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; + import { Terminal } from 'lucide-react'; + import { Terminal as XTerm } from '@xterm/xterm'; + import { FitAddon } from '@xterm/addon-fit'; + import '@xterm/xterm/css/xterm.css'; + import { resolveAgentShellWsUrl } from '@/lib/agentShellWs'; + + const SHELL_FONT_PX = 14; + const CWD_STORAGE_KEY = 'sb_agent_shell_cwd'; +const INTRO_ACK_KEY = 'sb_agent_shell_intro_ack'; + + + type Props = { + active: boolean; + expanded: boolean; + onExpandedChange: (expanded: boolean) => void; + }; + + function readStoredCwd(): string { + if (typeof window === 'undefined') return ''; + try { + return window.localStorage.getItem(CWD_STORAGE_KEY) || ''; + } catch { + return ''; + } + } -export default function AgentShellPanel({ active, expanded, onExpandedChange }: Props) { - const hostRef = useRef(null); - const termRef = useRef(null); - const fitRef = useRef(null); - const wsRef = useRef(null); - const [status, setStatus] = useState<'idle' | 'connecting' | 'open' | 'error'>('idle'); - const [statusDetail, setStatusDetail] = useState(''); - const [cwd, setCwd] = useState(''); - const disconnect = useCallback(() => { - wsRef.current?.close(); - wsRef.current = null; - termRef.current?.dispose(); - termRef.current = null; - fitRef.current = null; - setStatus('idle'); - }, []); - const fitTerminal = useCallback(() => { - const fit = fitRef.current; - const term = termRef.current; - const ws = wsRef.current; - if (!fit || !term) return; - fit.fit(); - if (ws?.readyState === WebSocket.OPEN) { - ws.send( - JSON.stringify({ - type: 'resize', - cols: term.cols, - rows: term.rows, - }), - ); - } - }, []); +function readIntroAcknowledged(): boolean { - const connect = useCallback(() => { - if (!active || !hostRef.current) return; - disconnect(); + if (typeof window === 'undefined') return false; - const term = new XTerm({ - fontFamily: 'var(--font-roboto-mono), ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', - fontSize: SHELL_FONT_PX, - lineHeight: 1.35, - cursorBlink: true, - theme: { - background: '#04070b', - foreground: '#d9f7ff', - cursor: '#22d3ee', - selectionBackground: '#0e7490', - }, - scrollback: 5000, - }); - const fit = new FitAddon(); - term.loadAddon(fit); - term.open(hostRef.current); - fit.fit(); - termRef.current = term; - fitRef.current = fit; + try { - const storedCwd = readStoredCwd(); - setCwd(storedCwd); - setStatus('connecting'); - setStatusDetail(''); + return window.localStorage.getItem(INTRO_ACK_KEY) === '1'; - const ws = new WebSocket(resolveAgentShellWsUrl(storedCwd)); - ws.binaryType = 'arraybuffer'; - wsRef.current = ws; + } catch { - ws.onopen = () => { - setStatus('open'); - fit.fit(); - ws.send( - JSON.stringify({ - type: 'resize', - cols: term.cols, - rows: term.rows, - }), - ); - term.focus(); - }; + return false; - ws.onmessage = (event) => { - if (typeof event.data === 'string') { - try { - const payload = JSON.parse(event.data) as { type?: string; message?: string }; - if (payload.type === 'error') { - setStatus('error'); - setStatusDetail(payload.message || 'Shell unavailable'); - term.writeln(`\r\n\x1b[31m${payload.message || 'Shell unavailable'}\x1b[0m`); - return; - } - } catch { - term.write(event.data); - return; - } - } - if (event.data instanceof ArrayBuffer) { - term.write(new Uint8Array(event.data)); - } - }; - - ws.onerror = () => { - setStatus('error'); - setStatusDetail('Could not connect to the local agent shell endpoint.'); - term.writeln('\r\n\x1b[31mCould not connect to the local agent shell endpoint.\x1b[0m'); - }; - - ws.onclose = () => { - setStatus((prev) => (prev === 'error' ? prev : 'idle')); - term.writeln('\r\n\x1b[90m[session closed]\x1b[0m'); - }; - - term.onData((data) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(new TextEncoder().encode(data)); - } - }); - }, [active, disconnect]); - - useEffect(() => { - if (!active) { - disconnect(); - return; - } - connect(); - return () => disconnect(); - }, [active, connect, disconnect]); - - useEffect(() => { - if (!active) return; - const timer = window.setTimeout(() => fitTerminal(), expanded ? 220 : 0); - return () => window.clearTimeout(timer); - }, [active, expanded, fitTerminal]); - - useEffect(() => { - if (!active) return; - const onResize = () => fitTerminal(); - window.addEventListener('resize', onResize); - return () => window.removeEventListener('resize', onResize); - }, [active, fitTerminal]); - - if (!active) { - return ( -
- -
LOCAL SHELL
-
- Expand Mesh Chat to open your operator shell. -
-
- ); } +} + + + +function ShellIntro({ onAcknowledge }: { onAcknowledge: () => void }) { + return ( -
-
-
- {cwd ? cwd : 'operator shell'} -
-
- {!expanded ? ( - - ) : ( - - )} - + +
+ +
+ +
+ + +
+ +
OPERATOR SHELL
+ +

+ + Connect your own agent CLIs here — OpenClaw, Codex, Gemini, or whatever you run locally. + +

+ +

+ + The session opens in your Shadowbroker workspace by default. Use it for repo scripts, mesh + + tools, or any terminal workflow you already rely on. + +

+ + +
+
+ + ); + +} + + + +export default function AgentShellPanel({ active, expanded, onExpandedChange }: Props) { + + const hostRef = useRef(null); + + const termRef = useRef(null); + + const fitRef = useRef(null); + + const wsRef = useRef(null); + + const [introAcknowledged, setIntroAcknowledged] = useState(false); + + const [status, setStatus] = useState<'idle' | 'connecting' | 'open' | 'error'>('idle'); + + const [statusDetail, setStatusDetail] = useState(''); + + const [cwd, setCwd] = useState(''); + + + + const shellReady = active && introAcknowledged; + + + + useEffect(() => { + + setIntroAcknowledged(readIntroAcknowledged()); + + }, [active]); + + + + const acknowledgeIntro = useCallback(() => { + + try { + + window.localStorage.setItem(INTRO_ACK_KEY, '1'); + + } catch { + + // still allow in-session access if storage is blocked + + } + + setIntroAcknowledged(true); + + }, []); + + + + const disconnect = useCallback(() => { + + wsRef.current?.close(); + + wsRef.current = null; + + termRef.current?.dispose(); + + termRef.current = null; + + fitRef.current = null; + + setStatus('idle'); + + }, []); + + + + const fitTerminal = useCallback(() => { + + const fit = fitRef.current; + + const term = termRef.current; + + const ws = wsRef.current; + + if (!fit || !term) return; + + fit.fit(); + + if (ws?.readyState === WebSocket.OPEN) { + + ws.send( + + JSON.stringify({ + + type: 'resize', + + cols: term.cols, + + rows: term.rows, + + }), + + ); + + } + + }, []); + + + + const connect = useCallback(() => { + + if (!hostRef.current) return; + + if (wsRef.current) { + + wsRef.current.close(); + + wsRef.current = null; + + } + + if (termRef.current) { + + termRef.current.dispose(); + + termRef.current = null; + + fitRef.current = null; + + } + + + + const term = new XTerm({ + + fontFamily: 'var(--font-roboto-mono), ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', + + fontSize: SHELL_FONT_PX, + + lineHeight: 1.35, + + cursorBlink: true, + + theme: { + + background: '#04070b', + + foreground: '#d9f7ff', + + cursor: '#22d3ee', + + selectionBackground: '#0e7490', + + }, + + scrollback: 5000, + + }); + + const fit = new FitAddon(); + + term.loadAddon(fit); + + term.open(hostRef.current); + + fit.fit(); + + termRef.current = term; + + fitRef.current = fit; + + + + const storedCwd = readStoredCwd(); + + setCwd(storedCwd); + + setStatus('connecting'); + + setStatusDetail(''); + + + + const ws = new WebSocket(resolveAgentShellWsUrl(storedCwd)); + + ws.binaryType = 'arraybuffer'; + + wsRef.current = ws; + + + + ws.onopen = () => { + + setStatus('open'); + + fit.fit(); + + ws.send( + + JSON.stringify({ + + type: 'resize', + + cols: term.cols, + + rows: term.rows, + + }), + + ); + + term.focus(); + + }; + + + + ws.onmessage = (event) => { + + if (typeof event.data === 'string') { + + try { + + const payload = JSON.parse(event.data) as { type?: string; message?: string }; + + if (payload.type === 'error') { + + setStatus('error'); + + setStatusDetail(payload.message || 'Shell unavailable'); + + term.writeln(`\r\n\x1b[31m${payload.message || 'Shell unavailable'}\x1b[0m`); + + return; + + } + + } catch { + + term.write(event.data); + + return; + + } + + } + + if (event.data instanceof ArrayBuffer) { + + term.write(new Uint8Array(event.data)); + + } + + }; + + + + ws.onerror = () => { + + setStatus('error'); + + setStatusDetail('Could not connect to the local agent shell endpoint.'); + + term.writeln('\r\n\x1b[31mCould not connect to the local agent shell endpoint.\x1b[0m'); + + }; + + + + ws.onclose = (event) => { + + if (event.code === 4403) { + + setStatus('error'); + + setStatusDetail('Local operator access only — reload the dashboard on localhost and retry.'); + + term.writeln('\r\n\x1b[31mShell blocked: local operator access only.\x1b[0m'); + + return; + + } + + setStatus((prev) => (prev === 'error' ? prev : 'idle')); + + if (event.code !== 1000) { + + term.writeln(`\r\n\x1b[90m[session closed: ${event.code}]\x1b[0m`); + + } + + }; + + + + term.onData((data) => { + + if (ws.readyState === WebSocket.OPEN) { + + ws.send(new TextEncoder().encode(data)); + + } + + }); + + }, []); + + + + useLayoutEffect(() => { + + if (!shellReady) { + + disconnect(); + + return; + + } + + let cancelled = false; + + const start = () => { + + if (cancelled || !hostRef.current) return; + + if (!wsRef.current || wsRef.current.readyState === WebSocket.CLOSED) { + + connect(); + + } else { + + fitTerminal(); + + } + + }; + + const frame = requestAnimationFrame(() => requestAnimationFrame(start)); + + return () => { + + cancelled = true; + + cancelAnimationFrame(frame); + + }; + + }, [shellReady, connect, disconnect, fitTerminal]); + + + + useEffect(() => { + + if (!shellReady) return; + + const host = hostRef.current; + + if (!host) return; + + const ro = new ResizeObserver(() => fitTerminal()); + + ro.observe(host); + + const timer = window.setTimeout(() => fitTerminal(), expanded ? 240 : 32); + + return () => { + + ro.disconnect(); + + window.clearTimeout(timer); + + }; + + }, [shellReady, expanded, fitTerminal]); + + + + useEffect(() => { + + if (!shellReady) return; + + const onResize = () => fitTerminal(); + + window.addEventListener('resize', onResize); + + return () => window.removeEventListener('resize', onResize); + + }, [shellReady, fitTerminal]); + + + + if (!active) { + + return ( + +
+ + + +
LOCAL SHELL
+ +
+ + Expand Meshtastic Chat to open your operator shell. + +
+ +
+ + ); + + } + + + + if (!introAcknowledged) { + + return ; + + } + + + + return ( + +
+ +
+ +
+ + {cwd ? cwd : 'operator shell'} + +
+ +
+ + {!expanded ? ( + + + + ) : ( + + + + )} + + + +
+ +
+ + + {status === 'error' && statusDetail && ( +
+ {statusDetail} +
+ )} -
-
- {status === 'connecting' - ? 'Connecting…' - : status === 'open' - ? `${expanded ? 'Expanded' : 'Docked'} · ${SHELL_FONT_PX}px · map stays interactive` - : 'Local operator shell'} -
+ + {status === 'connecting' && ( + +
+ + Connecting… + +
+ + )} + + + +
+
+ ); + } + + diff --git a/frontend/src/components/MeshChat/InfonetTerminalPanel.tsx b/frontend/src/components/MeshChat/InfonetTerminalPanel.tsx new file mode 100644 index 0000000..d376e86 --- /dev/null +++ b/frontend/src/components/MeshChat/InfonetTerminalPanel.tsx @@ -0,0 +1,224 @@ +'use client'; + +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Shield } from 'lucide-react'; +import { ensureInfonetParticipantNodeReady } from '@/mesh/controlPlaneStatusClient'; +import InfonetShell from '@/components/InfonetTerminal/InfonetShell'; + +type Props = { + active: boolean; + expanded: boolean; + wormholeBusy: boolean; + launchGate?: string; + onExpandedChange: (expanded: boolean) => void; + onEnterWormhole: () => Promise; + onTeardown: () => void; + onLaunchGateConsumed?: () => void; + onOpenDeadDrop?: (peerId: string, options?: { showSas?: boolean }) => void; +}; + +function InfonetIntro({ + busy, + status, + error, + onEnter, +}: { + busy: boolean; + status: string; + error: string; + onEnter: () => void; +}) { + return ( +
+
+
INFONET TERMINAL
+

+ Obfuscated Wormhole lane for the Infonet shell. Leave this tab to shut Wormhole down. +

+ {status && !error && ( +
+ {status} +
+ )} + {error && ( +
+ {error} +
+ )} + +
+
+ ); +} + +export default function InfonetTerminalPanel({ + active, + expanded, + wormholeBusy, + launchGate, + onExpandedChange, + onEnterWormhole, + onTeardown, + onLaunchGateConsumed, + onOpenDeadDrop, +}: Props) { + const [sessionActive, setSessionActive] = useState(false); + const [laneBusy, setLaneBusy] = useState(false); + const [laneError, setLaneError] = useState(''); + const [laneStatus, setLaneStatus] = useState(''); + const prepStartedRef = useRef(false); + + const shellOpen = active && sessionActive; + + const resetSession = useCallback(() => { + setSessionActive(false); + setLaneBusy(false); + setLaneError(''); + setLaneStatus(''); + prepStartedRef.current = false; + }, []); + + useEffect(() => { + if (active) return; + resetSession(); + onTeardown(); + }, [active, onTeardown, resetSession]); + + useEffect(() => { + if (!shellOpen) return; + let cancelled = false; + + const connectParticipantNode = async () => { + try { + if (cancelled) return; + await ensureInfonetParticipantNodeReady(); + } catch { + // Remote viewers may not have local-operator rights. + } + }; + + void connectParticipantNode(); + return () => { + cancelled = true; + }; + }, [shellOpen]); + + const startWormholeLane = useCallback(async () => { + if (prepStartedRef.current) return; + prepStartedRef.current = true; + setLaneError(''); + setLaneStatus('Starting Wormhole obfuscated lane…'); + setLaneBusy(true); + try { + await onEnterWormhole(); + setLaneStatus(''); + } catch (err) { + prepStartedRef.current = false; + setSessionActive(false); + const message = + typeof err === 'object' && err !== null && 'message' in err + ? String((err as { message?: string }).message) + : 'Could not start Wormhole.'; + setLaneError(message); + setLaneStatus(''); + } finally { + setLaneBusy(false); + } + }, [onEnterWormhole]); + + const handleEnter = useCallback(() => { + setLaneError(''); + setLaneStatus(''); + setSessionActive(true); + onExpandedChange(true); + void startWormholeLane(); + }, [onExpandedChange, startWormholeLane]); + + const handleShellClose = useCallback(() => { + resetSession(); + onTeardown(); + }, [onTeardown, resetSession]); + + if (!active) { + return ( +
+ +
INFONET TERMINAL
+
+ Expand Meshtastic Chat to open the Wormhole terminal. +
+
+ ); + } + + if (!sessionActive) { + return ( + + ); + } + + return ( +
+
+
+ {laneBusy ? 'wormhole · obfuscated lane starting' : 'infonet terminal'} +
+
+ {!expanded ? ( + + ) : ( + + )} + +
+
+ + {laneError && ( +
+ {laneError} +
+ )} + +
+ +
+
+ ); +} diff --git a/frontend/src/components/MeshChat/index.tsx b/frontend/src/components/MeshChat/index.tsx index 0682343..62bbc83 100644 --- a/frontend/src/components/MeshChat/index.tsx +++ b/frontend/src/components/MeshChat/index.tsx @@ -1,7 +1,18 @@ 'use client'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import AgentShellPanel from './AgentShellPanel'; +import InfonetTerminalPanel from './InfonetTerminalPanel'; +import { + INFONET_FLYOUT_MIN_HEIGHT, + INFONET_FLYOUT_WIDTH, + measureMeshChatFlyout, + SHELL_FLYOUT_MIN_HEIGHT, + SHELL_FLYOUT_WIDTH, + type MeshChatFlyoutRect, +} from './meshChatFlyout'; +import { fetchWormholeState, leaveWormhole } from '@/mesh/wormholeClient'; +import { teardownWormholeOnClose } from '@/lib/wormholeTeardown'; import { motion, AnimatePresence } from 'framer-motion'; import { Antenna, @@ -93,6 +104,11 @@ function describeGateCompatReason(reason: string, gateId: string): string { const MeshChat = React.memo(function MeshChat(props: MeshChatProps) { const panelBoxRef = useRef(null); const [shellExpanded, setShellExpanded] = useState(false); + const [shellFlyout, setShellFlyout] = useState(null); + const [shellDockHeight, setShellDockHeight] = useState(0); + const [infonetExpanded, setInfonetExpanded] = useState(true); + const [infonetFlyout, setInfonetFlyout] = useState(null); + const [infonetDockHeight, setInfonetDockHeight] = useState(0); const ctrl = useMeshChatController(props); const { // UI state @@ -280,6 +296,9 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) { handleLeaveWormholeForPublicMesh, handleResetPublicIdentity, handleBootstrapPrivateIdentity, + enterInfonetWormholeLane, + infonetLaunchGate, + clearInfonetLaunchGate, handleRefreshSelectedContact, handleResetSelectedContact, handleTrustSelectedRemotePrekey, @@ -355,11 +374,11 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) { const meshActivationText = publicMeshBlockedByWormhole ? hasStoredPublicLaneIdentity - ? 'Wormhole is active. Turning MeshChat on will turn Wormhole off and use your saved public mesh key.' - : 'Wormhole is active. Turning MeshChat on will turn Wormhole off and mint a separate public mesh key.' + ? 'Wormhole is active. Turning Meshtastic Chat on will turn Wormhole off and use your saved Meshtastic key.' + : 'Wormhole is active. Turning Meshtastic Chat on will turn Wormhole off and mint a separate Meshtastic key.' : hasStoredPublicLaneIdentity - ? 'MeshChat is off. Turn it on to use your saved public mesh key.' - : 'Public mesh posting needs a mesh key. One tap gets you a fresh address.'; + ? 'Meshtastic Chat is off. Turn it on to use your saved Meshtastic key.' + : 'Meshtastic posting needs a radio key. One tap gets you a fresh address.'; const handleMeshActivationAction = () => { if (hasStoredPublicLaneIdentity) { void handleActivatePublicMeshSession(); @@ -387,12 +406,12 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) { window.setTimeout(() => inputRef.current?.focus(), 0); }; const meshActivationLabel = identityWizardBusy - ? 'GETTING MESH KEY' + ? 'GETTING MESHTASTIC KEY' : hasStoredPublicLaneIdentity - ? 'TURN ON MESH' + ? 'TURN ON MESHTASTIC' : publicMeshBlockedByWormhole - ? 'TURN OFF WORMHOLE FOR MESH' - : 'GET MESH KEY'; + ? 'TURN OFF WORMHOLE FOR MESHTASTIC' + : 'GET MESHTASTIC KEY'; const meshActivationSideLabel = identityWizardBusy ? 'WORKING...' : hasStoredPublicLaneIdentity @@ -401,20 +420,147 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) { ? 'AUTO DISABLE' : 'ONE TAP'; + const handleShellExpandedChange = useCallback((next: boolean) => { + if (next && panelBoxRef.current) { + const anchor = panelBoxRef.current.getBoundingClientRect(); + setShellDockHeight(anchor.height); + setShellFlyout(measureMeshChatFlyout(anchor, SHELL_FLYOUT_WIDTH, SHELL_FLYOUT_MIN_HEIGHT)); + } else { + setShellFlyout(null); + setShellDockHeight(0); + } + setShellExpanded(next); + }, []); + + const handleInfonetExpandedChange = useCallback((next: boolean) => { + if (next && panelBoxRef.current) { + const anchor = panelBoxRef.current.getBoundingClientRect(); + setInfonetDockHeight(anchor.height); + setInfonetFlyout(measureMeshChatFlyout(anchor, INFONET_FLYOUT_WIDTH, INFONET_FLYOUT_MIN_HEIGHT)); + } else { + setInfonetFlyout(null); + setInfonetDockHeight(0); + } + setInfonetExpanded(next); + }, []); + + const handleInfonetTeardown = useCallback(() => { + void teardownWormholeOnClose(fetchWormholeState, leaveWormhole); + }, []); + + const panelFlyout = + activeTab === 'infonet' ? infonetFlyout : activeTab === 'dms' ? shellFlyout : null; + const panelDockHeight = + activeTab === 'infonet' ? infonetDockHeight : activeTab === 'dms' ? shellDockHeight : 0; + const panelFlyoutMinHeight = + activeTab === 'infonet' ? INFONET_FLYOUT_MIN_HEIGHT : SHELL_FLYOUT_MIN_HEIGHT; + + useEffect(() => { + if (!shellExpanded) return; + const syncFlyout = () => { + setShellFlyout((prev) => { + if (!prev) return null; + return { + ...prev, + width: Math.min(SHELL_FLYOUT_WIDTH, Math.max(320, window.innerWidth - 48)), + height: Math.min( + Math.max(prev.height, SHELL_FLYOUT_MIN_HEIGHT), + window.innerHeight - prev.top - 36, + ), + }; + }); + }; + window.addEventListener('resize', syncFlyout); + return () => window.removeEventListener('resize', syncFlyout); + }, [shellExpanded]); + + useEffect(() => { + if (!infonetExpanded) return; + const syncFlyout = () => { + setInfonetFlyout((prev) => { + if (!prev) return null; + return { + ...prev, + width: Math.min(INFONET_FLYOUT_WIDTH, Math.max(320, window.innerWidth - 48)), + height: Math.min( + Math.max(prev.height, INFONET_FLYOUT_MIN_HEIGHT), + window.innerHeight - prev.top - 36, + ), + }; + }); + }; + window.addEventListener('resize', syncFlyout); + return () => window.removeEventListener('resize', syncFlyout); + }, [infonetExpanded]); + + useEffect(() => { + if (!expanded && shellExpanded) { + handleShellExpandedChange(false); + } + }, [expanded, shellExpanded, handleShellExpandedChange]); + + useEffect(() => { + if (activeTab !== 'dms' && shellExpanded) { + handleShellExpandedChange(false); + } + }, [activeTab, shellExpanded, handleShellExpandedChange]); + + useEffect(() => { + if (activeTab !== 'infonet' && infonetExpanded) { + handleInfonetExpandedChange(false); + } + }, [activeTab, infonetExpanded, handleInfonetExpandedChange]); + + useEffect(() => { + if (activeTab === 'infonet' && expanded && infonetExpanded && !infonetFlyout && panelBoxRef.current) { + handleInfonetExpandedChange(true); + } + }, [activeTab, expanded, infonetExpanded, infonetFlyout, handleInfonetExpandedChange]); + + const infonetSessionWasActiveRef = useRef(false); + useEffect(() => { + const infonetActive = activeTab === 'infonet' && expanded; + if (infonetActive) { + infonetSessionWasActiveRef.current = true; + return; + } + if (!infonetSessionWasActiveRef.current) return; + infonetSessionWasActiveRef.current = false; + handleInfonetTeardown(); + }, [activeTab, expanded, handleInfonetTeardown]); + return (
+ {panelFlyout && panelDockHeight > 0 && ( +
+ )} + {/* Single unified box — matches Data Layers panel skin */}
{/* HEADER */}
- MESH CHAT + MESHTASTIC CHAT {totalDmNotify > 0 && ( @@ -446,14 +592,14 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) { {/* TAB BAR */}
{[ - { key: 'infonet' as Tab, label: 'INFONET', icon: , badge: 0 }, - { key: 'meshtastic' as Tab, label: 'MESH', icon: , badge: 0 }, { key: 'dms' as Tab, label: 'SHELL', icon: , badge: 0, }, + { key: 'infonet' as Tab, label: 'INFONET', icon: , badge: 0 }, + { key: 'meshtastic' as Tab, label: 'MESHTASTIC', icon: , badge: 0 }, ].map((tab) => (
- {privacyProfile === 'high' && !wormholeEnabled && ( + {privacyProfile === 'high' && !wormholeEnabled && activeTab !== 'dms' && activeTab !== 'infonet' && (
High Privacy is ON but Wormhole is OFF. Private messaging is blocked until Wormhole is enabled.
)} - {activeTab !== 'dms' && activeTab !== 'meshtastic' && wormholeEnabled && !wormholeReadyState && ( + {activeTab !== 'dms' && activeTab !== 'infonet' && activeTab !== 'meshtastic' && wormholeEnabled && !wormholeReadyState && (
Wormhole secure mode is enabled but the local agent is not ready. Dead Drop is blocked until Wormhole is running.
)} - {activeTab !== 'dms' && activeTab !== 'meshtastic' && wormholeEnabled && wormholeReadyState && ( + {activeTab !== 'dms' && activeTab !== 'infonet' && activeTab !== 'meshtastic' && wormholeEnabled && wormholeReadyState && (
Wormhole secure mode is active. Experimental private-lane operations are routed through the local agent and current secure transport paths.
)} - {activeTab !== 'dms' && activeTab !== 'meshtastic' && wormholeEnabled && wormholeReadyState && !wormholeRnsReady && ( + {activeTab !== 'dms' && activeTab !== 'infonet' && activeTab !== 'meshtastic' && wormholeEnabled && wormholeReadyState && !wormholeRnsReady && (
TRANSITIONAL PRIVATE LANE. Wormhole is up and gate chat is available on the transitional lane. Reticulum is still warming — Dead Drop / DM requires the @@ -514,7 +660,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
)} - {activeTab !== 'dms' && activeTab !== 'meshtastic' && anonymousModeEnabled && !anonymousModeReady && ( + {activeTab !== 'dms' && activeTab !== 'infonet' && activeTab !== 'meshtastic' && anonymousModeEnabled && !anonymousModeReady && (
Anonymous mode is active, but hidden transport is not ready. Dead Drop is blocked until Wormhole is running over Tor, I2P, or Mixnet. @@ -522,7 +668,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) { )} {/* No identity warning */} - {shouldShowIdentityWarning && ( + {shouldShowIdentityWarning && activeTab !== 'dms' && activeTab !== 'infonet' && (
Run connect in MeshTerminal first, or open @@ -538,7 +684,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
)} - {privateLaneHint && ( + {privateLaneHint && activeTab !== 'dms' && activeTab !== 'infonet' && (
)} - {dashboardRestrictedTab && ( -
-
-
- -
-
- {dashboardRestrictedTitle} -
-
- {dashboardRestrictedDetail} -
-
- Use the terminal to enter Wormhole, join private gates, and work secure contact - flows until the dashboard client lands. -
-
-
- )} - {/* ─── InfoNet Tab ─── */} - {!dashboardRestrictedTab && activeTab === 'infonet' && ( - <> - {!privateInfonetReady ? ( -
-
-
- -
-
- PRIVATE INFONET LOCKED -
-
- Gate chat is available on the transitional private lane through Wormhole. -
-
- Use the unlock prompt below for the full private-lane brief. Dead Drop / - DM is a separate, stronger private lane for direct messaging. -
-
-
- ) : ( - <> -
- - -
- - {privateInfonetReady && !wormholeRnsReady && ( -
-
- TRANSITIONAL PRIVATE LANE -
-
- Gate chat is live on the transitional private lane. Timing and membership - activity remain visible to the service on this lane. -
-
- Dead Drop / DM is a separate, stronger lane requiring PRIVATE / STRONG - transport. Use Dead Drop for the strongest content and metadata privacy. -
-
- RNS peers {wormholeRnsPeers.active}/{wormholeRnsPeers.configured} - {wormholeRnsDirectReady - ? ' • direct private DM path ready' - : ' • direct peer paths still warming'} -
-
- )} - - {selectedGate && wormholeEnabled && wormholeReadyState && ( -
-
- GATE FACE -
- - - -
- )} - - {selectedGate && wormholeEnabled && wormholeReadyState && ( -
-
- {selectedGateActivePersona - ? `Active face: ${selectedGateActivePersona.label || selectedGateActivePersona.persona_id || selectedGateActivePersona.node_id}` - : 'Active face: anonymous session'} - {selectedGatePersonaList.length > 0 - ? ` | saved personas: ${selectedGatePersonaList.length}` - : ' | no saved personas yet'} -
- Anonymous gate entry rotates to a fresh gate-scoped session identity and - does not emit a public join/leave breadcrumb. -
- )} - - {selectedGate && wormholeEnabled && wormholeReadyState && selectedGateKeyStatus && ( -
-
- GATE KEY - / - EPOCH {selectedGateKeyStatus.current_epoch || 0} - {selectedGateKeyStatus.rekey_recommended && ( - - REKEY ADVISED - - )} - -
-
- {selectedGateKeyStatus.has_local_access - ? `Access live via ${selectedGateKeyStatus.identity_scope || 'member'} identity ${String(selectedGateKeyStatus.sender_ref || selectedGateKeyStatus.identity_node_id || '').slice(0, 16)}` - : selectedGateKeyStatus.identity_scope === 'anonymous' - ? 'Anonymous gate session is active, but this install has not synced gate access yet. Refresh or reopen the gate if it does not clear.' - : 'No local gate key access yet. Enter the gate through Wormhole to unwrap the current epoch.'} -
-
- {selectedGateKeyStatus.key_commitment - ? `KEY ${selectedGateKeyStatus.key_commitment.slice(0, 12)}` - : 'KEY PENDING'} - {selectedGateKeyStatus.previous_epoch - ? ` • previous epoch ${selectedGateKeyStatus.previous_epoch}` - : ''} - {selectedGateKeyStatus.last_rotated_at - ? ` • rotated ${timeAgo(selectedGateKeyStatus.last_rotated_at)}` - : ''} -
- {nativeAuditSummary && ( -
-
- NATIVE AUDIT - / - - {nativeAuditReport?.totalRecorded || nativeAuditReport?.totalEvents || 0} RECORDED - - {nativeAuditReport && - nativeAuditReport.totalRecorded > nativeAuditReport.totalEvents && ( - - ({nativeAuditReport.totalEvents} shown) - - )} - -
-
- {nativeAuditSummary.recent - ? `Last: ${nativeAuditSummary.recent.command}${nativeAuditSummary.recent.targetRef ? ` [${nativeAuditSummary.recent.targetRef}]` : ''} -> ${nativeAuditSummary.recent.outcome}` - : 'No native gate audit events yet.'} -
-
- Profile mismatches: {nativeAuditSummary.mismatchCount} • denied: {nativeAuditSummary.deniedCount} -
- {nativeAuditReport?.lastProfileMismatch && ( -
- {`Last mismatch: ${nativeAuditReport.lastProfileMismatch.command}${nativeAuditReport.lastProfileMismatch.targetRef ? ` [${nativeAuditReport.lastProfileMismatch.targetRef}]` : ''} (${nativeAuditReport.lastProfileMismatch.sessionProfile || 'unscoped'} -> ${nativeAuditReport.lastProfileMismatch.expectedCapability})`} -
- )} -
- )} - {selectedGateKeyStatus.rekey_recommended_reason && ( -
- Rekey recommendation: {selectedGateKeyStatus.rekey_recommended_reason.replace(/_/g, ' ')} -
- )} - {selectedGateKeyStatus.identity_scope === 'anonymous' && - !selectedGateKeyStatus.has_local_access && ( -
- - - {selectedGatePersonaList.length > 0 - ? 'Switch to a saved face if this install still cannot unlock the room anonymously.' - : 'Create a gate-local face only if anonymous unlock still fails on this install.'} - - {selectedContactInfo && ( - <> - {selectedContactInfo.remotePrekeyTransparencyConflict && ( -
- prekey history conflict observed and trust stays degraded until you - explicitly acknowledge the changed fingerprint. -
- )} - {selectedContactInfo.remotePrekeyLookupMode === 'legacy_agent_id' && ( -
- bootstrap path: legacy direct agent ID lookup. - {selectedContactInfo.invitePinnedPrekeyLookupHandle - ? ' Refresh from the signed invite to tighten lookup privacy.' - : ' Import or re-import a signed invite to avoid stable-ID lookup.'} -
- )} - {selectedContactInfo.remotePrekeyLookupMode === 'invite_lookup_handle' && ( -
- bootstrap path: invite-scoped lookup handle. Stable agent ID was not - required on the lookup path. -
- )} - {dmTrustPrimaryActionRequiresInviteImport && ( -
- next step: import or re-import a signed invite in Secure Messages before - trusting this contact as a verified first-contact anchor. -
- )} - {(selectedContactInfo.witness_count ?? 0) > 0 && ( -
- witness observations: {selectedContactInfo.witness_count} - {selectedContactInfo.witness_checked_at - ? `, last seen ${timeAgo( - selectedContactInfo.witness_checked_at > 1_000_000_000_000 - ? selectedContactInfo.witness_checked_at - : selectedContactInfo.witness_checked_at * 1000, - )}` - : ''} -
- )} - - )} -
- )} - {selectedGate && gateResyncTarget === selectedGate && ( -
-
- GATE STATE DRIFT -
-
- Native gate state changed on another path. Resync this gate locally before retrying decrypt or post actions. -
-
- - - Required only when native desktop fails closed on gate-state drift. - -
-
- )} - {selectedGate && gateError && !showCreateGate && !gateCompatConsentPrompt && ( -
- {gateError} -
- )} - {selectedGate && gateCompatConsentPrompt && !showCreateGate && ( -
-
- COMPAT MODE -
-
- {describeGateCompatConsentPrompt(gateCompatConsentPrompt.action)} -
-
- {describeGateCompatReason( - gateCompatConsentPrompt.reason, - gateCompatConsentPrompt.gateId, - )} -
-
- - - Weaker privacy on this device. - -
-
- )} -
- )} - - {selectedGateMeta && ( -
-
- {selectedGateMeta.fixed ? 'FIXED GATE' : 'PRIVATE GATE'} - / - {selectedGateMeta.display_name || selectedGateMeta.gate_id} - {selectedGateCompatActive ? ( - <> - / - - COMPAT - - - ) : null} -
- {selectedGateMeta.description && ( -
- {selectedGateMeta.description} -
- )} -
- {selectedGateMeta.rules?.min_overall_rep - ? `ENTRY FLOOR ${selectedGateMeta.rules.min_overall_rep} REP` - : 'ENTRY FLOOR OPEN'} - {' • '} - {selectedGateMeta.message_count} MSGS -
-
- )} - - {/* Create gate form */} - - {showCreateGate && ( - -
-
- Gates are rep-gated communities. Only nodes meeting the minimum - reputation can post. -
- { - setNewGateId(e.target.value); - setGateError(''); - }} - placeholder="gate-id (alphanumeric + hyphens, max 32)" - className="w-full bg-[var(--bg-secondary)]/50 border border-[var(--border-primary)] text-sm font-mono text-cyan-300 px-2 py-1 outline-none placeholder:text-[var(--text-muted)]" - /> - setNewGateName(e.target.value)} - placeholder="Display Name (optional)" - className="w-full bg-[var(--bg-secondary)]/50 border border-[var(--border-primary)] text-sm font-mono text-cyan-300 px-2 py-1 outline-none placeholder:text-[var(--text-muted)]" - /> -
- - setNewGateMinRep(parseInt(e.target.value) || 0)} - className="w-16 bg-[var(--bg-secondary)]/50 border border-[var(--border-primary)] text-sm font-mono text-cyan-300 px-2 py-1 outline-none" - /> - - {newGateMinRep === 0 ? 'open' : 'gated'} - - -
- {gateError && ( -
- {gateError} -
- )} -
-
- )} -
- - {/* Messages — terminal log style */} -
- {filteredInfoMessages.length === 0 && ( -
-
- {selectedGate ? 'No messages in this gate yet' : 'Select a gate or browse all'} -
- {selectedGateMeta && ( -
-
- SYSTEM WELCOME -
-
- {selectedGateMeta.welcome || selectedGateMeta.description || 'Private gate is live. Say something worth keeping.'} -
-
- Start with a source, a thesis, a clean question, or a useful observation. -
-
- )} -
- )} - {filteredInfoMessages.map((m, i) => ( - m.system_seed ? ( -
-
- {m.fixed_gate ? 'FIXED GATE NOTICE' : 'GATE NOTICE'} -
-
- {m.message} -
-
- ) : ( -
-
- - {m.node_id ? ( - - ) : null} - {isEncryptedGateEnvelope(m) && ( - - {gateEnvelopeState(m) === 'decrypted' ? 'DECRYPTED' : 'KEY LOCKED'} - - )} - {infoVerification[m.event_id] && ( - - {infoVerification[m.event_id] === 'verified' - ? 'VERIFIED' - : infoVerification[m.event_id] === 'failed' - ? 'FAILED' - : 'UNSIGNED'} - - )} - - {gateEnvelopeDisplayText(m)} - - - {timeAgo(m.timestamp)} - -
- {isEncryptedGateEnvelope(m) && ( -
- EPOCH {m.epoch ?? 0} - {m.sender_ref ? ` / ${m.sender_ref}` : ''} -
- )} - {hasId && m.node_id && m.node_id !== identity!.nodeId && ( -
- - - 0 - ? 'text-cyan-500' - : (reps[m.node_id] ?? 0) < 0 - ? 'text-red-400' - : 'text-cyan-600/60' - }`} - > - {reps[m.node_id] ?? 0} - - -
- )} -
- ) - ))} -
-
- - )} - + {activeTab === 'infonet' && ( + )} {/* ─── Meshtastic Tab ─── */} @@ -1287,7 +855,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
MESHTASTIC MQTT
- Public Mesh is separate from Wormhole. Turning MQTT on disables the private Wormhole lane for MeshChat. + Meshtastic MQTT is separate from Wormhole. Turning MQTT on disables the private Wormhole lane for Meshtastic Chat.
- MeshChat is off. Turn it on to connect the public mesh lane. + Meshtastic Chat is off. Turn it on to connect the Meshtastic MQTT lane.
)} {canUsePublicMeshInput && meshView === 'channel' && filteredMeshMessages.length === 0 && ( @@ -1509,42 +1077,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
{/* INPUT BAR */} - {activeTab === 'dms' ? ( -
- - SHELL - -
- Real local shell over PTY. EXPAND widens this panel; SNAP docks it back into Mesh Chat. -
-
- ) : dashboardRestrictedTab ? ( -
- - ACCESS - -
-
- → PRIVATE INFONET / TERMINAL ONLY -
-
- Private gate posting and reading are restricted to the terminal for now. Dashboard support is coming soon. -
- -
-
- ) : ( + {activeTab === 'dms' || activeTab === 'infonet' ? null : (
INPUT {/* Destination indicator / error */} @@ -1554,15 +1087,6 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) { ✕ {sendError} - {activeTab === 'infonet' && selectedGate && gateResyncTarget === selectedGate && ( - - )} {activeTab === 'meshtastic' && (
@@ -1609,20 +1129,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
)}
- {activeTab === 'infonet' && !privateInfonetReady ? ( - - ) : activeTab === 'meshtastic' && !canUsePublicMeshInput ? ( + {activeTab === 'meshtastic' && !canUsePublicMeshInput ? ( - ) : activeTab === 'infonet' && - privateInfonetReady && - selectedGateKeyStatus?.identity_scope === 'anonymous' && - !selectedGateKeyStatus?.has_local_access ? ( - ) : ( <> >
- {activeTab === 'infonet' && gateReplyContext && ( -
- - REPLYING TO {gateReplyContext.nodeId.slice(0, 12)} / {gateReplyContext.eventId.slice(0, 8)} - - -
- )} diff --git a/frontend/src/components/MeshChat/meshChatFlyout.ts b/frontend/src/components/MeshChat/meshChatFlyout.ts new file mode 100644 index 0000000..ee1aae7 --- /dev/null +++ b/frontend/src/components/MeshChat/meshChatFlyout.ts @@ -0,0 +1,29 @@ +export type MeshChatFlyoutRect = { + top: number; + left: number; + width: number; + height: number; +}; + +export function measureMeshChatFlyout( + anchor: DOMRect, + maxWidth: number, + minHeight: number, +): MeshChatFlyoutRect { + const width = Math.min(maxWidth, Math.max(320, window.innerWidth - 48)); + const height = Math.min( + Math.max(anchor.height, minHeight), + window.innerHeight - anchor.top - 36, + ); + return { + top: anchor.top, + left: anchor.left, + width, + height, + }; +} + +export const SHELL_FLYOUT_WIDTH = 760; +export const SHELL_FLYOUT_MIN_HEIGHT = 420; +export const INFONET_FLYOUT_WIDTH = 960; +export const INFONET_FLYOUT_MIN_HEIGHT = 480; diff --git a/frontend/src/components/MeshChat/types.ts b/frontend/src/components/MeshChat/types.ts index c1575d8..97f180b 100644 --- a/frontend/src/components/MeshChat/types.ts +++ b/frontend/src/components/MeshChat/types.ts @@ -173,6 +173,8 @@ export interface MeshChatProps { onExpandedChange?: (expanded: boolean) => void; onSettingsClick?: () => void; onTerminalToggle?: () => void; + onOpenLiveGate?: (gate: string) => void; + onOpenDeadDrop?: (peerId: string, options?: { showSas?: boolean }) => void; launchRequest?: { tab: Tab; gate?: string; peerId?: string; showSas?: boolean; nonce: number } | null; } diff --git a/frontend/src/components/MeshChat/useMeshChatController.ts b/frontend/src/components/MeshChat/useMeshChatController.ts index b7a3bf8..b3b3aac 100644 --- a/frontend/src/components/MeshChat/useMeshChatController.ts +++ b/frontend/src/components/MeshChat/useMeshChatController.ts @@ -91,6 +91,7 @@ import { rotateWormholePairwiseAlias, listWormholeGatePersonas, postWormholeGateMessage, + prepareWormholeInteractiveLane, recoverWormholeSasRootContinuity, resyncWormholeGateState, retireWormholeGatePersona, @@ -317,7 +318,7 @@ function errorMessage(err: unknown, fallback: string = 'unknown error'): string function describeMeshChatControlError(raw: string): string { const message = String(raw || '').trim(); - if (!message) return 'MeshChat could not update the local control plane.'; + if (!message) return 'Meshtastic Chat could not update the local control plane.'; if ( message === 'control_plane_request_failed:530' || message === 'HTTP 530' || @@ -336,7 +337,7 @@ function describeMeshChatControlError(raw: string): string { return 'This control action needs a local operator session. Open Settings or Node controls once so the app can authorize local changes, then try Mesh again.'; } if (message.startsWith('{') || message.startsWith('<')) { - return 'MeshChat could not update the local control plane. Check the backend log for the upstream error.'; + return 'Meshtastic Chat could not update the local control plane. Check the backend log for the upstream error.'; } return message; } @@ -413,7 +414,7 @@ export function useMeshChatController({ setInternalExpanded(newVal); onExpandedChange?.(newVal); }; - const [activeTab, setActiveTab] = useState('meshtastic'); + const [activeTab, setActiveTab] = useState('dms'); const openTerminal = useCallback(() => { if (onTerminalToggle) { onTerminalToggle(); @@ -471,7 +472,6 @@ export function useMeshChatController({ const privateInfonetReady = wormholeEnabled && wormholeReadyState; const publicMeshBlockedByWormhole = wormholeEnabled || wormholeReadyState; const dmSendQueue = useRef<(() => Promise)[]>([]); - const infonetAutoBootstrapRef = useRef(false); const meshMqttRuntime = meshMqttSettings?.runtime; const meshMqttEnabled = Boolean(meshMqttSettings?.enabled || meshMqttRuntime?.enabled); const canUsePublicMeshInput = Boolean(activePublicMeshAddress) && meshMqttEnabled && !publicMeshBlockedByWormhole; @@ -905,6 +905,7 @@ export function useMeshChatController({ // ─── InfoNet State ─────────────────────────────────────────────────────── const [gates, setGates] = useState([]); const [selectedGate, setSelectedGate] = useState(''); + const [infonetLaunchGate, setInfonetLaunchGate] = useState(''); const [infoMessages, setInfoMessages] = useState([]); const [infoVerification, setInfoVerification] = useState< Record @@ -1335,7 +1336,7 @@ export function useMeshChatController({ setExpanded(true); setActiveTab(launchRequest.tab); if (launchRequest.tab === 'infonet' && launchRequest.gate) { - setSelectedGate(String(launchRequest.gate || '').trim().toLowerCase()); + setInfonetLaunchGate(String(launchRequest.gate || '').trim().toLowerCase()); } if (launchRequest.tab === 'dms') { const peerId = String(launchRequest.peerId || '').trim(); @@ -2682,7 +2683,7 @@ export function useMeshChatController({ openIdentityWizard({ type: 'err', text: hasStoredPublicLaneIdentity - ? 'Quick fix: turn MeshChat on below, then retry your send.' + ? 'Quick fix: turn Meshtastic Chat on below, then retry your send.' : 'Quick fix: create a public mesh identity below, then retry your send.', }); setTimeout(() => setSendError(''), 4000); @@ -4122,10 +4123,9 @@ export function useMeshChatController({ const dmTrustHint = buildDmTrustHint(selectedContactInfo); const dmTrustPrimaryAction = dmTrustPrimaryActionLabel(selectedContactInfo); const wormholeDescriptor = getWormholeIdentityDescriptor(); - const dashboardRestrictedTab: boolean = activeTab === 'infonet'; - const dashboardRestrictedTitle = 'INFONET RESTRICTED'; - const dashboardRestrictedDetail = - 'Private Wormhole gate activity is staying in the terminal for this build. Dashboard integration is coming soon.'; + const dashboardRestrictedTab = false; + const dashboardRestrictedTitle = ''; + const dashboardRestrictedDetail = ''; const selectedGateKey = selectedGate.trim().toLowerCase(); const selectedGatePersonaList = selectedGateKey ? gatePersonas[selectedGateKey] || [] : []; const selectedGateActivePersonaId = selectedGateKey ? activeGatePersonaId[selectedGateKey] || '' : ''; @@ -4223,7 +4223,7 @@ export function useMeshChatController({ (wormholeEnabled && !wormholeReadyState) || anonymousDmBlocked)); const privateInfonetBlockedDetail = !wormholeEnabled - ? 'INFONET now lives behind Wormhole. Public mesh remains available under the MESH tab.' + ? 'INFONET now lives behind Wormhole. Meshtastic radio chat remains available under the MESHTASTIC tab.' : !wormholeReadyState ? 'Wormhole is enabled, but the local private agent is not ready yet. INFONET stays locked until the private lane is up.' : 'Wormhole is up, but Reticulum is still warming on the private lane. Gate chat can run in transitional mode while strongest transport posture comes online. For strongest content privacy, use Dead Drop.'; @@ -4383,13 +4383,13 @@ export function useMeshChatController({ setMeshSessionActive(true); setMeshMessages([]); setSendError(''); - const text = `MeshChat is on. Address ${readyAddress}.`; + const text = `Meshtastic Chat is on. Address ${readyAddress}.`; setIdentityWizardStatus({ type: 'ok', text }); setMeshQuickStatus(null); return { ok: true as const, text }; } catch (err) { const message = describeMeshChatControlError(errorMessage(err)); - const text = `Could not turn MeshChat on: ${message}`; + const text = `Could not turn Meshtastic Chat on: ${message}`; setIdentityWizardStatus({ type: 'err', text }); setMeshQuickStatus({ type: 'err', text }); return { ok: false as const, text }; @@ -4520,21 +4520,58 @@ export function useMeshChatController({ } }, [wormholeDescriptor?.nodeId, wormholeEnabled, wormholeReadyState]); - useEffect(() => { - if (!expanded || activeTab !== 'infonet') { - infonetAutoBootstrapRef.current = false; + const enterInfonetWormholeLane = useCallback(async () => { + setMeshSessionActive(false); + setMeshMessages([]); + if (wormholeEnabled && wormholeReadyState) { + try { + const wormholeIdentity = await fetchWormholeIdentity(); + setIdentity({ + publicKey: wormholeIdentity.public_key, + privateKey: '', + nodeId: wormholeIdentity.node_id, + }); + } catch { + // Lane is already up; shell can still open. + } return; } - if (privateInfonetReady) { - infonetAutoBootstrapRef.current = false; - return; + + setIdentityWizardBusy(true); + try { + const prepared = await prepareWormholeInteractiveLane({ bootstrapIdentity: true }); + const [settings, runtime] = await Promise.all([ + fetchWormholeSettings(true).catch(() => null), + fetchWormholeStatus().catch(() => null), + ]); + const enabled = Boolean( + settings?.enabled ?? prepared.settingsEnabled ?? runtime?.running ?? runtime?.ready, + ); + setSecureModeCached(enabled); + setWormholeEnabled(enabled); + setWormholeReadyState(Boolean(runtime?.ready ?? prepared.ready)); + setWormholeRnsReady(Boolean(runtime?.rns_ready)); + setWormholeRnsDirectReady(Boolean(runtime?.rns_private_dm_direct_ready)); + setWormholeRnsPeers({ + active: Number(runtime?.rns_active_peers ?? 0), + configured: Number(runtime?.rns_configured_peers ?? 0), + }); + if (prepared.identity) { + purgeBrowserSigningMaterial(); + purgeBrowserContactGraph(); + await purgeBrowserDmState(); + const hydratedContacts = await hydrateWormholeContacts(true); + setContacts(hydratedContacts); + setIdentity({ + publicKey: prepared.identity.public_key, + privateKey: '', + nodeId: prepared.identity.node_id, + }); + } + } finally { + setIdentityWizardBusy(false); } - if (identityWizardBusy || infonetAutoBootstrapRef.current) return; - infonetAutoBootstrapRef.current = true; - void handleBootstrapPrivateIdentity().catch(() => { - infonetAutoBootstrapRef.current = false; - }); - }, [activeTab, expanded, handleBootstrapPrivateIdentity, identityWizardBusy, privateInfonetReady]); + }, [wormholeEnabled, wormholeReadyState]); return { // UI state @@ -4723,6 +4760,9 @@ export function useMeshChatController({ handleLeaveWormholeForPublicMesh, handleResetPublicIdentity, handleBootstrapPrivateIdentity, + enterInfonetWormholeLane, + infonetLaunchGate, + clearInfonetLaunchGate: () => setInfonetLaunchGate(''), handleRefreshSelectedContact, handleResetSelectedContact, handleTrustSelectedRemotePrekey, diff --git a/frontend/src/components/PredictionsPanel.tsx b/frontend/src/components/PredictionsPanel.tsx index 6a40153..44f042d 100644 --- a/frontend/src/components/PredictionsPanel.tsx +++ b/frontend/src/components/PredictionsPanel.tsx @@ -1059,7 +1059,7 @@ const PredictionsPanel = React.memo(function PredictionsPanel() {
{!nodeId && (
- CONNECT WORMHOLE OR GENERATE IDENTITY IN MESH CHAT FIRST + CONNECT WORMHOLE OR GENERATE IDENTITY IN MESHTASTIC CHAT FIRST
)} {nodeId && predictions.length === 0 && ( @@ -1109,7 +1109,7 @@ const PredictionsPanel = React.memo(function PredictionsPanel() {
{!nodeId && (
- CONNECT WORMHOLE OR GENERATE IDENTITY IN MESH CHAT FIRST + CONNECT WORMHOLE OR GENERATE IDENTITY IN MESHTASTIC CHAT FIRST
)} {nodeId && !profile && ( diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 9fe8c44..5091e86 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -1905,7 +1905,7 @@ const SettingsPanel = React.memo(function SettingsPanel({
Mesh Terminal stays read-only for sensitive posting and DM actions while - the hidden transport policy is active. Use MeshChat for the hardened path. + the hidden transport policy is active. Use Meshtastic Chat for the hardened path.
Relay fallback reduces metadata protection compared with direct obfuscated diff --git a/frontend/src/i18n/translations/en.json b/frontend/src/i18n/translations/en.json index 714f4e2..e690d64 100644 --- a/frontend/src/i18n/translations/en.json +++ b/frontend/src/i18n/translations/en.json @@ -240,7 +240,7 @@ "disconnected": "Disconnected" }, "meshChat": { - "title": "Mesh Chat", + "title": "Meshtastic Chat", "infonet": "Infonet", "meshtastic": "Meshtastic", "deadDrop": "Dead Drop", diff --git a/frontend/src/i18n/translations/fr.json b/frontend/src/i18n/translations/fr.json index 8e7f8e3..b71cc72 100644 --- a/frontend/src/i18n/translations/fr.json +++ b/frontend/src/i18n/translations/fr.json @@ -240,7 +240,7 @@ "disconnected": "Déconnecté" }, "meshChat": { - "title": "Chat Mesh", + "title": "Chat Meshtastic", "infonet": "Infonet", "meshtastic": "Meshtastic", "deadDrop": "Dead Drop", diff --git a/frontend/src/i18n/translations/zh-CN.json b/frontend/src/i18n/translations/zh-CN.json index 00c7c7a..5947697 100644 --- a/frontend/src/i18n/translations/zh-CN.json +++ b/frontend/src/i18n/translations/zh-CN.json @@ -240,7 +240,7 @@ "disconnected": "未连接" }, "meshChat": { - "title": "Mesh 聊天", + "title": "Meshtastic 聊天", "infonet": "Infonet", "meshtastic": "Meshtastic", "deadDrop": "死信箱", diff --git a/frontend/src/lib/agentShellWs.ts b/frontend/src/lib/agentShellWs.ts index aa887f5..38c4d00 100644 --- a/frontend/src/lib/agentShellWs.ts +++ b/frontend/src/lib/agentShellWs.ts @@ -2,9 +2,12 @@ export function resolveAgentShellWsUrl(cwd?: string): string { if (typeof window === 'undefined') return ''; const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; const host = window.location.hostname || '127.0.0.1'; + const fePort = window.location.port; const port = process.env.NEXT_PUBLIC_BACKEND_PORT || - (window.location.port === '3000' ? '8000' : window.location.port || '8000'); + (fePort && fePort !== '3000' + ? String(Number(fePort) - 3000 + 8000) + : '8000'); const params = new URLSearchParams(); const trimmed = String(cwd || '').trim(); if (trimmed) params.set('cwd', trimmed); diff --git a/frontend/src/lib/meshTerminalPolicy.ts b/frontend/src/lib/meshTerminalPolicy.ts index eaa05cc..4181b68 100644 --- a/frontend/src/lib/meshTerminalPolicy.ts +++ b/frontend/src/lib/meshTerminalPolicy.ts @@ -10,13 +10,13 @@ export function getMeshTerminalWriteLockReason(state: MeshTerminalSecurityState) if (!state.anonymousModeReady) { return 'Mesh Terminal write commands are disabled until Wormhole hidden transport is ready for Anonymous Infonet mode.'; } - return 'Mesh Terminal write commands are disabled while Anonymous Infonet mode is active. Use MeshChat for gate chat (transitional lane) or Dead Drop (stronger private lane).'; + return 'Mesh Terminal write commands are disabled while Anonymous Infonet mode is active. Use Meshtastic Chat for gate chat (transitional lane) or Dead Drop (stronger private lane).'; } if (state.wormholeRequired) { if (!state.wormholeReady) { return 'Mesh Terminal write commands are disabled until Wormhole secure mode is ready.'; } - return 'Mesh Terminal write commands are disabled while Wormhole secure mode is active. Use MeshChat for gate chat (transitional lane) or Dead Drop (stronger private lane).'; + return 'Mesh Terminal write commands are disabled while Wormhole secure mode is active. Use Meshtastic Chat for gate chat (transitional lane) or Dead Drop (stronger private lane).'; } return ''; } diff --git a/frontend/src/mesh/controlPlaneStatusClient.ts b/frontend/src/mesh/controlPlaneStatusClient.ts index 64da8f6..896ec66 100644 --- a/frontend/src/mesh/controlPlaneStatusClient.ts +++ b/frontend/src/mesh/controlPlaneStatusClient.ts @@ -1,4 +1,5 @@ import { controlPlaneJson } from '@/lib/controlPlane'; +import { generateNodeKeys, getNodeIdentity } from '@/mesh/meshIdentity'; export interface PrivacyProfileSnapshot { profile?: string; @@ -236,3 +237,13 @@ export async function startTorHiddenService(): Promise requireAdminSession: false, }); } + +/** Warm Tor/Arti and (re)enable the participant node so Infonet seed sync can run. */ +export async function ensureInfonetParticipantNodeReady(): Promise { + if (!getNodeIdentity()) { + await generateNodeKeys().catch(() => null); + } + await startTorHiddenService().catch(() => null); + await setInfonetNodeEnabled(true); + await fetchInfonetNodeStatusSnapshot(true).catch(() => null); +}