summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-11 04:13:53 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-11 04:13:53 +0200
commite23e33adeaf8ee7439187d4451c856b37816a51f (patch)
treea41eef1fba34cdd642d25576395b4ad484a748ad /packages/meshbay-hub
parent60c4570e72e36c2a9720593c8baec74ee2ab52d6 (diff)
downloadmeshbay-e23e33adeaf8ee7439187d4451c856b37816a51f.tar.gz
feat: Phase 9 — Web client SPA with WebRTC P2P transport
Complete browser-based client: Preact SPA with login, group file browser, encrypted download, video playback, group chat, i18n, and dark/light theme. Browser connects P2P to nodes behind residential NAT via WebRTC DataChannel (aiortc). Hub handles signaling only — all data flows E2E. Performance: pipelined downloads (8-chunk sliding window), binary msgpack wire format (no base64), redundant I/O elimination. Large file downloads stream to disk via File System Access API (showSaveFilePicker). Validated on SFR + Orange residential NATs, Chrome + Firefox, IPv4/IPv6. 132 tests passing. Deployed to meshbay.org + Orange node. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py51
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py73
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js1210
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/crypto.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js172
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css802
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js47
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/vendor/htm-preact.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html19
-rw-r--r--packages/meshbay-hub/tests/test_hub_api.py144
12 files changed, 2322 insertions, 224 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
index 5bccf71..6dd4275 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
@@ -15,6 +15,57 @@ from meshbay_hub.db.models import (
router = APIRouter(prefix="/v1/groups", tags=["groups"])
+@router.get("/mine")
+async def my_groups(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """List groups the current user belongs to."""
+ result = await db.execute(
+ select(Group)
+ .join(GroupMember, Group.id == GroupMember.group_id)
+ .where(GroupMember.user_id == current_user.id, Group.status == "active")
+ .order_by(Group.name)
+ )
+ groups = result.scalars().all()
+ return {
+ "groups": [
+ {
+ "id": g.id,
+ "name": g.name,
+ "visibility": g.visibility,
+ "join_policy": g.join_policy,
+ "created_at": g.created_at.isoformat(),
+ "is_admin": g.admin_id == current_user.id,
+ }
+ for g in groups
+ ]
+ }
+
+
+@router.get("/{group_id}/nodes")
+async def group_online_nodes(
+ group_id: str,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Return online nodes that serve a group (for WebRTC connection)."""
+ from meshbay_hub.api.revocation import get_online_nodes_for_group
+ from meshbay_hub.db.models import Node
+
+ group = await db.get(Group, group_id)
+ if not group:
+ raise HTTPException(status_code=404, detail="Group not found")
+
+ node_ids = get_online_nodes_for_group(group_id)
+ nodes = []
+ for nid in node_ids:
+ node = await db.get(Node, nid)
+ if node:
+ nodes.append({"node_id": nid, "pk_node": node.pk_node})
+ return {"nodes": nodes}
+
+
@router.get("")
async def list_public_groups(
db: AsyncSession = Depends(get_db),
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
index 8f30745..8f65f89 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
@@ -51,6 +51,7 @@ router = APIRouter(tags=["revocation"])
# ── Connected node registry ───────────────────────────────────────────────────
_connected_nodes: dict[str, WebSocket] = {} # node_id → websocket
+_node_groups: dict[str, list[str]] = {} # node_id → [group_id, ...]
_punch_events: dict[str, asyncio.Event] = {} # node_id → signaling event
@@ -58,6 +59,10 @@ def get_connected_node_count() -> int:
return len(_connected_nodes)
+def get_online_nodes_for_group(group_id: str) -> list[str]:
+ return [nid for nid, gids in _node_groups.items() if group_id in gids]
+
+
async def broadcast_revocation(token: str) -> int:
"""Push a signed revocation token to all connected nodes. Returns count sent."""
payload = json.dumps({"type": "revocation", "token": token})
@@ -121,7 +126,10 @@ async def node_websocket(ws: WebSocket):
node_id = msg.get("node_id") or decoded.get("sub", "unknown")
_connected_nodes[node_id] = ws
- log.info("Node WS connected: %s", node_id[:8])
+ group_ids = msg.get("group_ids", [])
+ if group_ids:
+ _node_groups[node_id] = group_ids
+ log.info("Node WS connected: %s (groups=%d)", node_id[:8], len(group_ids))
await ws.send_text(json.dumps({"type": "auth_ok", "node_id": node_id}))
# Message loop — handle ping, punch_ready, etc.
@@ -145,6 +153,7 @@ async def node_websocket(ws: WebSocket):
finally:
if node_id:
_connected_nodes.pop(node_id, None)
+ _node_groups.pop(node_id, None)
# ── Admin revocation endpoint ─────────────────────────────────────────────────
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
index 6005927..62917f4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
@@ -1,47 +1,26 @@
"""
-Hub web application — serves the MeshBay web client at /.
+Hub web application — serves the MeshBay SPA and static assets.
-The web client (HTML/JS) is a single-page app that:
- - Logs in via the hub API
- - Discovers public groups
- - Connects to a node URL entered by the user
- - Browses the node's file index
- - Downloads or streams files via the node HTTP API
+The SPA (Preact + htm) handles:
+ - Authentication (login, register, token refresh)
+ - Group discovery and browsing
+ - WebRTC connection to nodes for P2P file transfer
+ - Dark/light theme with system preference detection
-Static files are served from meshbay_hub/static/.
-API routes remain at /v1/*.
+Static files are served from meshbay_hub/static/ via Starlette StaticFiles.
+The root route (/) returns the SPA HTML shell.
"""
from pathlib import Path
from fastapi import APIRouter
-from fastapi.responses import FileResponse, HTMLResponse
+from fastapi.responses import HTMLResponse
STATIC_DIR = Path(__file__).parent.parent / "static"
router = APIRouter(tags=["webapp"])
-@router.get("/app.js")
-async def app_js():
- return FileResponse(STATIC_DIR / "app.js", media_type="application/javascript")
-
-
-@router.get("/transport.js")
-async def transport_js():
- return FileResponse(STATIC_DIR / "transport.js", media_type="application/javascript")
-
-
-@router.get("/crypto.js")
-async def crypto_js():
- return FileResponse(STATIC_DIR / "crypto.js", media_type="application/javascript")
-
-
-@router.get("/webrtc-test.html")
-async def webrtc_test():
- return FileResponse(STATIC_DIR / "webrtc-test.html", media_type="text/html")
-
-
@router.get("/", response_class=HTMLResponse)
async def index():
return HTMLResponse(_HTML)
@@ -54,36 +33,14 @@ _HTML = """\
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MeshBay</title>
- <style>
- *, *::before, *::after { box-sizing: border-box; }
- body { font-family: system-ui, sans-serif; margin: 0; background: #f8fafc; color: #1e293b; }
- #nav { background: #0f172a; color: #e2e8f0; padding: 12px 24px; }
- #nav b { color: #38bdf8; font-size: 1.2em; }
- #main { max-width: 960px; margin: 32px auto; padding: 0 16px; }
- h2 { color: #0f172a; margin-top: 1.5em; }
- input { padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px;
- font-size: 1em; margin: 4px; }
- button { padding: 8px 16px; background: #0ea5e9; color: #fff; border: none;
- border-radius: 6px; cursor: pointer; font-size: 0.9em; margin: 4px; }
- button:hover { background: #0284c7; }
- .card { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px;
- padding: 16px; margin: 8px 0; cursor: pointer; }
- .card:hover { border-color: #0ea5e9; }
- .badge { background: #e0f2fe; color: #0284c7; padding: 2px 8px;
- border-radius: 12px; font-size: 0.8em; margin-left: 8px; }
- table { width: 100%; border-collapse: collapse; background: #fff;
- border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; }
- th { background: #f1f5f9; padding: 10px; text-align: left; }
- td { padding: 10px; border-top: 1px solid #f1f5f9; }
- video { border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,.15); }
- a { color: #0ea5e9; text-decoration: none; }
- a:hover { text-decoration: underline; }
- </style>
+ <link rel="stylesheet" href="/style.css">
</head>
<body>
- <div id="nav"></div>
- <div id="main"><p>Loading…</p></div>
- <script src="/app.js"></script>
+ <div id="app"></div>
+ <script src="/keyderive.js"></script>
+ <script src="/crypto.js"></script>
+ <script src="/transport.js"></script>
+ <script type="module" src="/app.js"></script>
</body>
</html>
"""
diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py
index 7bd5ee3..1b12a37 100644
--- a/packages/meshbay-hub/src/meshbay_hub/app.py
+++ b/packages/meshbay-hub/src/meshbay_hub/app.py
@@ -33,7 +33,7 @@ from meshbay_hub.csam import csam_router
from meshbay_hub.api.health import router as health_router
from meshbay_hub.api.relay import router as relay_router
from meshbay_hub.api.signaling import router as signaling_router
-from meshbay_hub.api.webapp import router as webapp_router
+from meshbay_hub.api.webapp import router as webapp_router, STATIC_DIR
from meshbay_hub.api.middleware import limiter
@@ -97,4 +97,7 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI:
app.include_router(signaling_router)
app.include_router(webapp_router)
+ from starlette.staticfiles import StaticFiles
+ app.mount("/", StaticFiles(directory=STATIC_DIR), name="static")
+
return app
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 1b284bb..2179e99 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -1,198 +1,1108 @@
-/* MeshBay Web Client — Phase 4
- * Communicates with: Hub API (auth, groups) + Node HTTP API (files, streaming)
- * Requires: hub at same origin, node at configured URL
- */
+import {
+ html, render, useState, useEffect, useCallback, useRef,
+ createContext, useContext,
+} from './vendor/htm-preact.js';
+import { t, getLocale, setLocale, LOCALES } from './i18n.js';
-const HUB = ''; // same origin — hub serves this file
+// ── Constants ────────────────────────────────────────────────────────────────
-// ── State ─────────────────────────────────────────────────────────────────────
+const HUB = '';
+const AUTH_KEY = 'mb_auth';
+const THEME_KEY = 'mb_theme';
-let state = {
- token: localStorage.getItem('mb_token') || null,
- refreshToken: localStorage.getItem('mb_rt') || null,
- username: localStorage.getItem('mb_user') || null,
- nodeUrl: localStorage.getItem('mb_node') || null,
-};
+// ── Auth persistence ─────────────────────────────────────────────────────────
-// ── Hub API helpers ───────────────────────────────────────────────────────────
+let _sessionKeys = null;
-async function hubGet(path) {
- const headers = state.token ? { Authorization: `Bearer ${state.token}` } : {};
- const r = await fetch(HUB + path, { headers });
- if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
- return r.json();
+function loadAuth() {
+ try {
+ return JSON.parse(localStorage.getItem(AUTH_KEY));
+ } catch {
+ return null;
+ }
}
-async function hubPost(path, body) {
- const headers = {
- 'Content-Type': 'application/json',
- ...(state.token ? { Authorization: `Bearer ${state.token}` } : {}),
- };
- const r = await fetch(HUB + path, { method: 'POST', headers, body: JSON.stringify(body) });
- if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
+function saveAuth(auth) {
+ if (auth) {
+ localStorage.setItem(AUTH_KEY, JSON.stringify(auth));
+ } else {
+ localStorage.removeItem(AUTH_KEY);
+ _sessionKeys = null;
+ }
+}
+
+// ── Theme ────────────────────────────────────────────────────────────────────
+
+function getInitialTheme() {
+ const stored = localStorage.getItem(THEME_KEY);
+ if (stored === 'dark' || stored === 'light' || stored === 'system') return stored;
+ return 'system';
+}
+
+function resolveTheme(pref) {
+ if (pref === 'system') {
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+ }
+ return pref;
+}
+
+// ── Hub API ──────────────────────────────────────────────────────────────────
+
+async function hubFetch(path, { method = 'GET', body, token } = {}) {
+ const headers = {};
+ if (body) headers['Content-Type'] = 'application/json';
+ if (token) headers['Authorization'] = `Bearer ${token}`;
+ const opts = { method, headers };
+ if (body) opts.body = JSON.stringify(body);
+ const r = await fetch(HUB + path, opts);
+ if (!r.ok) {
+ const err = await r.json().catch(() => ({ detail: r.statusText }));
+ throw new Error(err.detail || r.statusText);
+ }
return r.json();
}
-// ── Auth ──────────────────────────────────────────────────────────────────────
+// ── Router ───────────────────────────────────────────────────────────────────
-async function login(username, password) {
- const data = await hubPost('/v1/users/login', { username, password });
- state.token = data.access_token;
- state.refreshToken = data.refresh_token;
- state.username = username;
- localStorage.setItem('mb_token', state.token);
- localStorage.setItem('mb_rt', state.refreshToken);
- localStorage.setItem('mb_user', username);
- return data;
+function useRoute() {
+ const [hash, setHash] = useState(window.location.hash.slice(1) || '/');
+ useEffect(() => {
+ const onHash = () => setHash(window.location.hash.slice(1) || '/');
+ window.addEventListener('hashchange', onHash);
+ return () => window.removeEventListener('hashchange', onHash);
+ }, []);
+ return hash;
}
-async function register(username, email, password, pkEd, pkX) {
- return hubPost('/v1/users/register', {
- username, email, password,
- pk_user_ed25519: pkEd,
- pk_user_x25519: pkX,
- });
+function navigate(path) {
+ window.location.hash = path;
}
-function logout() {
- state = { token: null, refreshToken: null, username: null, nodeUrl: null };
- localStorage.clear();
- render();
+// ── Context ──────────────────────────────────────────────────────────────────
+
+const AuthContext = createContext(null);
+function useAuth() { return useContext(AuthContext); }
+
+// ── Nav ──────────────────────────────────────────────────────────────────────
+
+function Nav({ user, theme, onThemeToggle, onLogout, onMenuToggle }) {
+ return html`
+ <nav class="nav">
+ <div class="nav-left">
+ ${user && html`
+ <button class="nav-hamburger" onClick=${onMenuToggle}
+ aria-label="${t('nav.toggle_menu')}">≡</button>
+ `}
+ <a class="nav-brand" href="#/">MeshBay</a>
+ </div>
+ <div class="nav-right">
+ <button class="nav-theme" onClick=${onThemeToggle}
+ aria-label="${t('nav.toggle_menu')}" title=${theme === 'dark' ? t('nav.light_mode') : t('nav.dark_mode')}>
+ ${theme === 'dark' ? '☀' : '☾'}
+ </button>
+ ${user ? html`
+ <span class="nav-user">${user.username}</span>
+ <button class="nav-btn" onClick=${onLogout}>${t('nav.logout')}</button>
+ ` : html`
+ <a class="nav-btn" href="#/login">${t('nav.login')}</a>
+ `}
+ </div>
+ </nav>
+ `;
}
-// ── Node API helpers ──────────────────────────────────────────────────────────
+// ── Sidebar ──────────────────────────────────────────────────────────────────
-async function nodeGet(path) {
- if (!state.nodeUrl) throw new Error('No node configured');
- const sep = path.includes('?') ? '&' : '?';
- const url = state.nodeUrl + path + (state.token ? `${sep}token=${state.token}` : '');
- const r = await fetch(url);
- if (!r.ok) throw new Error(`Node ${r.status}`);
- return r.json();
+function Sidebar({ groups, route, menuOpen }) {
+ return html`
+ <aside class="sidebar ${menuOpen ? 'open' : ''}">
+ <div class="sidebar-section">
+ <div class="sidebar-heading">${t('sidebar.my_groups')}</div>
+ ${groups.length === 0
+ ? html`<div class="sidebar-empty">${t('sidebar.no_groups')}</div>`
+ : groups.map(g => html`
+ <a key=${g.id}
+ class="sidebar-item ${route === '/group/' + g.id ? 'active' : ''}"
+ href="#/group/${g.id}">
+ ${g.name}
+ </a>
+ `)
+ }
+ </div>
+ <div class="sidebar-section">
+ <div class="sidebar-heading">${t('sidebar.discover')}</div>
+ <a class="sidebar-item ${route === '/explore' ? 'active' : ''}"
+ href="#/explore">${t('sidebar.public_groups')}</a>
+ <a class="sidebar-item ${route === '/settings' ? 'active' : ''}"
+ href="#/settings">${t('sidebar.settings')}</a>
+ </div>
+ </aside>
+ `;
}
-// ── Pages ─────────────────────────────────────────────────────────────────────
+// ── Login Page ───────────────────────────────────────────────────────────────
-async function pageHome() {
- const groups = await hubGet('/v1/groups');
- const items = groups.groups.map(g => `
- <div class="card" onclick="pageGroup('${g.id}')">
- <b>${esc(g.name)}</b>
- <span class="badge">${g.join_policy}</span>
- </div>`).join('');
+function LoginPage() {
+ const auth = useAuth();
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ const onSubmit = async (e) => {
+ e.preventDefault();
+ if (!username || !password) return;
+ setError('');
+ setLoading(true);
+ try {
+ await auth.login(username, password);
+ navigate('/');
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
- return `
- <h2>Public Groups</h2>
- ${items || '<p>No public groups yet.</p>'}
- ${state.nodeUrl ? `
- <h2>My Node (<a href="${esc(state.nodeUrl)}" target="_blank">${esc(state.nodeUrl)}</a>)</h2>
- <button onclick="pageNodeBrowser()">Browse My Node</button>
- ` : `
- <h2>Connect to a Node</h2>
- <input id="nodeUrl" placeholder="http://node-ip:19001" style="width:300px">
- <button onclick="connectNode()">Connect</button>
- `}`;
+ return html`
+ <div class="page-center">
+ <div class="card login-card">
+ <h2>${t('login.title')}</h2>
+ <form onSubmit=${onSubmit}>
+ <input type="text" placeholder="${t('login.username')}" value=${username}
+ onInput=${e => setUsername(e.target.value)}
+ autocomplete="username" required />
+ <input type="password" placeholder="${t('login.password')}" value=${password}
+ onInput=${e => setPassword(e.target.value)}
+ autocomplete="current-password" required />
+ ${error && html`<div class="error-msg">${error}</div>`}
+ <button type="submit" disabled=${loading}>
+ ${loading ? t('login.loading') : t('login.submit')}
+ </button>
+ </form>
+ <div class="login-footer">
+ ${t('login.no_account')} <a href="#/register">${t('login.register_link')}</a>
+ </div>
+ </div>
+ </div>
+ `;
}
-async function pageGroup(groupId) {
- // TODO: fetch group info + node from hub
- return `<p>Group ${groupId} — coming soon</p><button onclick="render()">Back</button>`;
+// ── Register Page ────────────────────────────────────────────────────────────
+
+function RegisterPage() {
+ const [username, setUsername] = useState('');
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [confirm, setConfirm] = useState('');
+ const [error, setError] = useState('');
+ const [success, setSuccess] = useState(false);
+ const [loading, setLoading] = useState(false);
+
+ const onSubmit = async (e) => {
+ e.preventDefault();
+ if (password !== confirm) { setError(t('register.err_mismatch')); return; }
+ if (password.length < 8) { setError(t('register.err_min_len')); return; }
+ setError('');
+ setLoading(true);
+ try {
+ if (window.MeshBayKeys) {
+ await window.MeshBayKeys.registerUser(username, email, password);
+ } else {
+ await hubFetch('/v1/users/register', {
+ method: 'POST',
+ body: { username, email, password, pk_user_ed25519: '', pk_user_x25519: '' },
+ });
+ }
+ setSuccess(true);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (success) {
+ return html`
+ <div class="page-center">
+ <div class="card login-card">
+ <h2>${t('register.success_title')}</h2>
+ <p style="text-align:center; margin-bottom:16px; color:var(--text-secondary)">
+ ${t('register.success_msg')}
+ </p>
+ <a href="#/login" style="display:block; text-align:center">${t('register.go_login')}</a>
+ </div>
+ </div>
+ `;
+ }
+
+ return html`
+ <div class="page-center">
+ <div class="card login-card">
+ <h2>${t('register.title')}</h2>
+ <form onSubmit=${onSubmit}>
+ <input type="text" placeholder="${t('register.username')}" value=${username}
+ onInput=${e => setUsername(e.target.value)}
+ autocomplete="username" required />
+ <input type="email" placeholder="${t('register.email')}" value=${email}
+ onInput=${e => setEmail(e.target.value)}
+ autocomplete="email" required />
+ <input type="password" placeholder="${t('register.password')}" value=${password}
+ onInput=${e => setPassword(e.target.value)}
+ autocomplete="new-password" required minlength="8" />
+ <input type="password" placeholder="${t('register.confirm')}" value=${confirm}
+ onInput=${e => setConfirm(e.target.value)}
+ autocomplete="new-password" required />
+ ${error && html`<div class="error-msg">${error}</div>`}
+ <button type="submit" disabled=${loading}>
+ ${loading ? t('register.loading') : t('register.submit')}
+ </button>
+ </form>
+ <div class="login-footer">
+ ${t('register.has_account')} <a href="#/login">${t('register.login_link')}</a>
+ </div>
+ </div>
+ </div>
+ `;
}
-async function pageNodeBrowser() {
- const data = await nodeGet('/index');
- const rows = data.entries.map(e => `
- <tr>
- <td>${esc(e.name)}</td>
- <td>${e.type}</td>
- <td>${fmtSize(e.size)}</td>
- <td>
- ${e.type === 'video' ? `<button onclick="streamVideo('${e.id}','${esc(e.name)}')">▶ Play</button>` : ''}
- <a href="${state.nodeUrl}/file/${e.id}" target="_blank">⬇ Download</a>
- </td>
- </tr>`).join('');
+// ── Home Page ────────────────────────────────────────────────────────────────
+
+function HomePage({ groups }) {
+ if (groups.length === 0) {
+ return html`
+ <div>
+ <h2>${t('home.welcome')}</h2>
+ <p class="page-message">
+ ${t('home.no_groups')}
+ ${' '}${t('home.browse_prefix')}<a href="#/explore">${t('home.browse_link')}</a>${t('home.browse_suffix')}
+ </p>
+ </div>
+ `;
+ }
- return `
- <h2>📁 ${esc(data.group_name)}</h2>
- <p>${data.entries.length} files — index v${data.version}</p>
- <table>
- <thead><tr><th>Name</th><th>Type</th><th>Size</th><th>Actions</th></tr></thead>
- <tbody>${rows}</tbody>
- </table>
- <button onclick="render()">← Back</button>`;
+ return html`
+ <div>
+ <h2>${t('home.my_groups')}</h2>
+ <div class="group-grid">
+ ${groups.map(g => html`
+ <a key=${g.id} class="group-card" href="#/group/${g.id}">
+ <h3>${g.name}</h3>
+ <span class="badge">${g.visibility}</span>
+ ${' '}
+ <span class="badge">${g.join_policy}</span>
+ ${g.is_admin && html`${' '}<span class="badge">admin</span>`}
+ </a>
+ `)}
+ </div>
+ </div>
+ `;
}
-function pageStream(fileId, name) {
- const src = `${state.nodeUrl}/hls/${fileId}/playlist.m3u8${state.token ? '?token=' + state.token : ''}`;
- return `
- <h2>▶ ${esc(name)}</h2>
- <video controls autoplay style="max-width:100%;width:800px">
- <source src="${esc(src)}" type="application/vnd.apple.mpegurl">
- Your browser does not support HLS. <a href="${state.nodeUrl}/file/${fileId}">Download instead</a>.
- </video>
- <br><button onclick="pageNodeBrowser().then(setMain)">← Back to files</button>`;
+// ── Explore Page ─────────────────────────────────────────────────────────────
+
+function ExplorePage({ token }) {
+ const [groups, setGroups] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ hubFetch('/v1/groups', { token })
+ .then(data => setGroups(data.groups || []))
+ .catch(() => {})
+ .finally(() => setLoading(false));
+ }, [token]);
+
+ return html`
+ <div>
+ <h2>${t('explore.title')}</h2>
+ ${loading
+ ? html`<p class="page-message">${t('explore.loading')}</p>`
+ : groups.length === 0
+ ? html`<p class="page-message">${t('explore.empty')}</p>`
+ : html`
+ <div class="group-grid">
+ ${groups.map(g => html`
+ <a key=${g.id} class="group-card" href="#/group/${g.id}">
+ <h3>${g.name}</h3>
+ <span class="badge">${g.join_policy}</span>
+ ${g.source && g.source !== 'local' && html`
+ ${' '}<span class="badge">${g.source}</span>
+ `}
+ </a>
+ `)}
+ </div>
+ `
+ }
+ </div>
+ `;
}
-// ── Actions ───────────────────────────────────────────────────────────────────
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+const FILE_ICONS = {
+ video: '\u{1F3AC}', audio: '\u{1F3B5}', image: '\u{1F5BC}',
+ document: '\u{1F4C4}', archive: '\u{1F4E6}', other: '\u{1F4CE}',
+};
-async function connectNode() {
- const url = document.getElementById('nodeUrl')?.value?.trim();
- if (!url) return;
- state.nodeUrl = url;
- localStorage.setItem('mb_node', url);
- await pageNodeBrowser().then(setMain);
+function formatSize(bytes) {
+ if (bytes < 1024) return bytes + ' B';
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
+ if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
+ return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
-async function streamVideo(fileId, name) {
- setMain(pageStream(fileId, name));
+function formatDate(ts) {
+ return new Date(ts * 1000).toLocaleDateString(undefined, {
+ year: 'numeric', month: 'short', day: 'numeric',
+ });
}
-async function doLogin() {
- const u = document.getElementById('lu').value;
- const p = document.getElementById('lp').value;
- try {
- await login(u, p);
- render();
- } catch(e) { alert('Login failed: ' + e.message); }
+// ── Group Page ──────────────────────────────────────────────────────────────
+
+const CHUNK_SIZE = 1024 * 1024;
+const PIPELINE_WINDOW = 8;
+
+async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk, writable) {
+ const results = writable ? null : new Array(totalChunks);
+ let nextSend = 0, nextRecv = 0;
+ const inflight = new Array(totalChunks);
+
+ const fire = () => {
+ while (nextSend < totalChunks && nextSend - nextRecv < PIPELINE_WINDOW) {
+ inflight[nextSend] = transport.fetchChunk(fileId, nextSend);
+ nextSend++;
+ }
+ };
+
+ fire();
+ while (nextRecv < totalChunks) {
+ const chunkMsg = await inflight[nextRecv];
+ let plaintext;
+ if (gekKey && chunkMsg.ct) {
+ plaintext = await window.MeshBayCrypto.decryptChunkBin(
+ gekKey, fileId, nextRecv, chunkMsg.nonce, chunkMsg.ct);
+ } else if (gekKey && chunkMsg.ct_b64) {
+ plaintext = await window.MeshBayCrypto.decryptChunk(
+ gekKey, fileId, nextRecv, chunkMsg.nonce_b64, chunkMsg.ct_b64);
+ } else {
+ plaintext = _b64ToU8(chunkMsg.ct_b64 || chunkMsg.data_b64);
+ }
+ if (writable) {
+ await writable.write(plaintext);
+ } else {
+ results[nextRecv] = plaintext;
+ }
+ nextRecv++;
+ fire();
+ if (onChunk) onChunk(plaintext.byteLength, nextRecv, totalChunks);
+ }
+ return results;
+}
+
+function GroupPage({ groupId, group, token, username }) {
+ const [status, setStatus] = useState('idle');
+ const [entries, setEntries] = useState([]);
+ const [error, setError] = useState('');
+ const [sortKey, setSortKey] = useState('name');
+ const [sortAsc, setSortAsc] = useState(true);
+ const [filter, setFilter] = useState('');
+ const [currentPath, setCurrentPath] = useState('');
+ const [dlState, setDlState] = useState(null);
+ const [videoEntry, setVideoEntry] = useState(null);
+ const [tab, setTab] = useState('files');
+ const transportRef = useRef(null);
+ const gekRef = useRef(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ const connect = async () => {
+ setStatus('discovering');
+ setError('');
+ setEntries([]);
+ gekRef.current = null;
+ try {
+ const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token });
+ if (cancelled) return;
+ if (!nodesData.nodes || nodesData.nodes.length === 0) {
+ setStatus('offline');
+ return;
+ }
+
+ setStatus('connecting');
+ const nodeId = nodesData.nodes[0].node_id;
+ const transport = new window.MeshBayTransport('', token);
+ transportRef.current = transport;
+
+ await transport.connect(nodeId, token, groupId);
+ if (cancelled) return;
+ setStatus('fetching');
+
+ const indexMsg = await transport.fetchIndex();
+ if (cancelled) return;
+ setEntries(indexMsg.entries || []);
+ setStatus('connected');
+ } catch (err) {
+ if (!cancelled) {
+ setError(err.message);
+ setStatus('error');
+ }
+ }
+ };
+
+ if (token && window.MeshBayTransport) {
+ connect();
+ } else if (!window.MeshBayTransport) {
+ setStatus('error');
+ setError(t('group.err_transport'));
+ }
+
+ return () => {
+ cancelled = true;
+ if (transportRef.current) {
+ transportRef.current.close();
+ transportRef.current = null;
+ }
+ };
+ }, [groupId, token]);
+
+ const downloadFile = useCallback(async (entry) => {
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) return;
+
+ setDlState({ fileId: entry.id, name: entry.name, progress: 0, total: entry.size });
+
+ try {
+ if (!gekRef.current && window.MeshBayCrypto) {
+ const gekB64 = await transport.fetchGEK();
+ gekRef.current = await window.MeshBayCrypto.importGEK(gekB64);
+ }
+
+ const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
+ let downloaded = 0;
+ const onProgress = (bytes) => {
+ downloaded += bytes;
+ setDlState(prev => ({ ...prev, progress: downloaded }));
+ };
+
+ if (window.showSaveFilePicker) {
+ const handle = await window.showSaveFilePicker({ suggestedName: entry.name });
+ const writable = await handle.createWritable();
+ try {
+ await pipelinedDownload(
+ transport, gekRef.current, entry.id, totalChunks, onProgress, writable);
+ await writable.close();
+ } catch (err) {
+ await writable.abort();
+ throw err;
+ }
+ } else {
+ const chunks = await pipelinedDownload(
+ transport, gekRef.current, entry.id, totalChunks, onProgress);
+ const blob = new Blob(chunks);
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = entry.name;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ }
+ setDlState(null);
+ } catch (err) {
+ setDlState(null);
+ if (err.name === 'AbortError') return;
+ setError(t('group.dl_failed', { err: err.message }));
+ }
+ }, []);
+
+ const toggleSort = useCallback((key) => {
+ setSortAsc(prev => sortKey === key ? !prev : true);
+ setSortKey(key);
+ }, [sortKey]);
+
+ const dirs = new Set();
+ const filteredEntries = entries.filter(e => {
+ const ePath = e.path || '';
+ if (ePath === currentPath) {
+ return !filter || e.name.toLowerCase().includes(filter.toLowerCase());
+ }
+ if (!currentPath && ePath) {
+ dirs.add(ePath.split('/')[0]);
+ } else if (currentPath && ePath.startsWith(currentPath + '/')) {
+ const rest = ePath.slice(currentPath.length + 1);
+ dirs.add(rest.split('/')[0]);
+ }
+ return false;
+ });
+
+ const sorted = [...filteredEntries].sort((a, b) => {
+ let cmp = 0;
+ if (sortKey === 'name') cmp = a.name.localeCompare(b.name);
+ else if (sortKey === 'size') cmp = a.size - b.size;
+ else if (sortKey === 'type') cmp = a.type.localeCompare(b.type);
+ else if (sortKey === 'date') cmp = a.added_at - b.added_at;
+ return sortAsc ? cmp : -cmp;
+ });
+
+ const subdirs = [...dirs].sort();
+
+ const statusLabel = {
+ idle: t('status.idle'),
+ discovering: t('status.discovering'),
+ connecting: t('status.connecting'),
+ fetching: t('status.fetching'),
+ connected: t('status.files', { n: entries.length }),
+ offline: t('status.offline'),
+ error: t('status.error'),
+ }[status] || status;
+
+ const statusClass = status === 'connected' ? 'status-ok'
+ : status === 'error' || status === 'offline' ? 'status-err' : 'status-busy';
+
+ const breadcrumbs = currentPath ? currentPath.split('/') : [];
+
+ return html`
+ <div>
+ <div class="group-header">
+ <h2>${group ? group.name : t('group.default_name')}</h2>
+ <span class="status-badge ${statusClass}">${statusLabel}</span>
+ </div>
+ ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`}
+ ${dlState && html`
+ <div class="dl-bar">
+ <span class="dl-name">${dlState.name}</span>
+ <div class="dl-progress">
+ <div class="dl-fill" style="width:${Math.round(dlState.progress / dlState.total * 100)}%"></div>
+ </div>
+ <span class="dl-pct">${formatSize(dlState.progress)} / ${formatSize(dlState.total)}</span>
+ </div>
+ `}
+ ${status === 'connected' && html`
+ <div class="group-tabs">
+ <button class="group-tab ${tab === 'files' ? 'active' : ''}"
+ onClick=${() => setTab('files')}>${t('group.tab_files')}</button>
+ <button class="group-tab ${tab === 'chat' ? 'active' : ''}"
+ onClick=${() => setTab('chat')}>${t('group.tab_chat')}</button>
+ </div>
+
+ ${tab === 'files' && html`
+ <div class="file-toolbar">
+ <div class="breadcrumbs">
+ <a class="crumb" onClick=${() => setCurrentPath('')}>/</a>
+ ${breadcrumbs.map((seg, i) => {
+ const path = breadcrumbs.slice(0, i + 1).join('/');
+ return html`
+ <span class="crumb-sep">/</span>
+ <a class="crumb" onClick=${() => setCurrentPath(path)}>${seg}</a>
+ `;
+ })}
+ </div>
+ <input type="text" class="file-search" placeholder="${t('group.filter')}"
+ value=${filter} onInput=${e => setFilter(e.target.value)} />
+ </div>
+ <table class="file-table">
+ <thead>
+ <tr>
+ <th></th>
+ <th class="sortable" onClick=${() => toggleSort('name')}>
+ ${t('group.col_name')} ${sortKey === 'name' ? (sortAsc ? '▲' : '▼') : ''}
+ </th>
+ <th class="sortable" onClick=${() => toggleSort('size')}>
+ ${t('group.col_size')} ${sortKey === 'size' ? (sortAsc ? '▲' : '▼') : ''}
+ </th>
+ <th class="sortable th-type" onClick=${() => toggleSort('type')}>
+ ${t('group.col_type')} ${sortKey === 'type' ? (sortAsc ? '▲' : '▼') : ''}
+ </th>
+ <th class="sortable th-date" onClick=${() => toggleSort('date')}>
+ ${t('group.col_date')} ${sortKey === 'date' ? (sortAsc ? '▲' : '▼') : ''}
+ </th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ ${subdirs.map(d => html`
+ <tr class="file-row dir-row" onClick=${() =>
+ setCurrentPath(currentPath ? currentPath + '/' + d : d)}>
+ <td>\u{1F4C1}</td>
+ <td>${d}/</td>
+ <td></td>
+ <td class="td-type"></td>
+ <td class="td-date"></td>
+ <td></td>
+ </tr>
+ `)}
+ ${sorted.map(e => html`
+ <tr class="file-row" key=${e.id}>
+ <td>${FILE_ICONS[e.type] || FILE_ICONS.other}</td>
+ <td class="file-name">${e.name}</td>
+ <td class="file-size">${formatSize(e.size)}</td>
+ <td class="file-type td-type">${e.type}</td>
+ <td class="file-date td-date">${formatDate(e.added_at)}</td>
+ <td>
+ ${e.type === 'video' && html`
+ <button class="play-btn" onClick=${() => setVideoEntry(e)}
+ disabled=${!!dlState || !!videoEntry} title="${t('group.play')}"
+ \u{25B6}
+ </button>
+ `}
+ <button class="dl-btn" onClick=${() => downloadFile(e)}
+ disabled=${!!dlState} title="${t('group.download')}"
+ \u{2B07}
+ </button>
+ </td>
+ </tr>
+ `)}
+ ${sorted.length === 0 && subdirs.length === 0 && html`
+ <tr><td colspan="6" class="file-empty">
+ ${filter ? t('group.empty_filter') : t('group.empty_dir')}
+ </td></tr>
+ `}
+ </tbody>
+ </table>
+ `}
+
+ ${tab === 'chat' && html`
+ <${ChatPanel} transportRef=${transportRef} username=${username} />
+ `}
+ `}
+ ${status === 'offline' && html`
+ <p class="page-message">
+ ${t('group.offline_title')}
+ ${' '}${t('group.offline_hint')}
+ </p>
+ `}
+ ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html`
+ <p class="page-message">${statusLabel}</p>
+ `}
+ ${videoEntry && html`
+ <${VideoPlayer}
+ entry=${videoEntry}
+ transportRef=${transportRef}
+ gekRef=${gekRef}
+ onClose=${() => setVideoEntry(null)} />
+ `}
+ </div>
+ `;
}
-// ── Router / render ───────────────────────────────────────────────────────────
+function _b64ToU8(b64) {
+ const bin = atob(b64);
+ const arr = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
+ return arr;
+}
+
+// ── Chat Panel ──────────────────────────────────────────────────────────
-function setMain(html) {
- document.getElementById('main').innerHTML = html;
+function formatTime(ts) {
+ const d = new Date(ts * 1000);
+ const now = new Date();
+ const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
+ if (d.toDateString() === now.toDateString()) return time;
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + time;
}
-async function render() {
- const nav = document.getElementById('nav');
- if (state.username) {
- nav.innerHTML = `<b>MeshBay</b> | Logged in as <b>${esc(state.username)}</b>
- <button onclick="logout()" style="float:right">Logout</button>`;
- setMain('<p>Loading…</p>');
- setMain(await pageHome());
- } else {
- nav.innerHTML = '<b>MeshBay</b>';
- setMain(`
- <h2>Login</h2>
- <input id="lu" placeholder="Username" autocomplete="username">
- <input id="lp" type="password" placeholder="Password" autocomplete="current-password">
- <button onclick="doLogin()">Login</button>
- <p><small>No account? Register via the API for now.</small></p>`);
- }
+function ChatPanel({ transportRef, username }) {
+ const [messages, setMessages] = useState([]);
+ const [input, setInput] = useState('');
+ const [sending, setSending] = useState(false);
+ const listRef = useRef(null);
+ const bottomRef = useRef(null);
+ const loadedRef = useRef(false);
+
+ useEffect(() => {
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) return;
+
+ if (!loadedRef.current) {
+ loadedRef.current = true;
+ transport.fetchChatHistory(0, 200)
+ .then(msgs => setMessages(msgs))
+ .catch(() => {});
+ }
+
+ transport.onChat = (msg) => {
+ setMessages(prev => [...prev, {
+ sender_id: msg.sender_id,
+ payload: msg.payload,
+ timestamp: msg.timestamp || Date.now() / 1000,
+ thread_id: msg.thread_id,
+ }]);
+ };
+
+ return () => { transport.onChat = null; };
+ }, [transportRef.current?.connected]);
+
+ useEffect(() => {
+ if (bottomRef.current) {
+ bottomRef.current.scrollIntoView({ behavior: 'smooth' });
+ }
+ }, [messages.length]);
+
+ const sendMessage = useCallback(async () => {
+ const text = input.trim();
+ if (!text) return;
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) return;
+
+ setSending(true);
+ setInput('');
+ try {
+ await transport.sendChat(text, 0, null);
+ setMessages(prev => [...prev, {
+ sender_id: username,
+ payload: text,
+ timestamp: Date.now() / 1000,
+ thread_id: null,
+ }]);
+ } catch {
+ setInput(text);
+ } finally {
+ setSending(false);
+ }
+ }, [input, username]);
+
+ const onKeyDown = useCallback((e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ sendMessage();
+ }
+ }, [sendMessage]);
+
+ return html`
+ <div class="chat-panel">
+ <div class="chat-messages" ref=${listRef}>
+ ${messages.length === 0 && html`
+ <div class="chat-empty">${t('chat.empty')}</div>
+ `}
+ ${messages.map((m, i) => {
+ const isOwn = m.sender_id === username;
+ const showSender = !isOwn && (i === 0 || messages[i - 1].sender_id !== m.sender_id);
+ return html`
+ <div key=${i} class="chat-msg ${isOwn ? 'chat-msg-own' : ''}">
+ ${showSender && html`
+ <div class="chat-sender">${m.sender_id}</div>
+ `}
+ <div class="chat-bubble ${isOwn ? 'chat-bubble-own' : ''}">
+ <span class="chat-text">${m.payload}</span>
+ <span class="chat-time">${formatTime(m.timestamp)}</span>
+ </div>
+ </div>
+ `;
+ })}
+ <div ref=${bottomRef} />
+ </div>
+ <div class="chat-input-row">
+ <textarea class="chat-input" rows="1"
+ placeholder="${t('chat.placeholder')}"
+ value=${input}
+ onInput=${e => setInput(e.target.value)}
+ onKeyDown=${onKeyDown}
+ disabled=${sending} />
+ <button class="chat-send" onClick=${sendMessage}
+ disabled=${sending || !input.trim()}>
+ ${t('chat.send')}
+ </button>
+ </div>
+ </div>
+ `;
}
-// ── Utils ─────────────────────────────────────────────────────────────────────
+// ── Video Player ────────────────────────────────────────────────────────
+
+const VIDEO_MIMES = {
+ '.mp4': 'video/mp4', '.webm': 'video/webm', '.mkv': 'video/x-matroska',
+ '.avi': 'video/x-msvideo', '.mov': 'video/quicktime', '.m4v': 'video/mp4',
+ '.flv': 'video/x-flv', '.wmv': 'video/x-ms-wmv',
+};
-function esc(s) {
- return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
- .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
+function videoMime(name) {
+ const dot = name.lastIndexOf('.');
+ if (dot < 0) return 'video/mp4';
+ return VIDEO_MIMES[name.slice(dot).toLowerCase()] || 'video/mp4';
}
-function fmtSize(bytes) {
- if (bytes < 1024) return bytes + ' B';
- if (bytes < 1024**2) return (bytes/1024).toFixed(1) + ' KB';
- if (bytes < 1024**3) return (bytes/1024**2).toFixed(1) + ' MB';
- return (bytes/1024**3).toFixed(2) + ' GB';
+function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
+ const [phase, setPhase] = useState('loading');
+ const [progress, setProgress] = useState(0);
+ const [error, setError] = useState('');
+ const videoRef = useRef(null);
+ const blobUrlRef = useRef(null);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const load = async () => {
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) {
+ setError(t('video.err_transport'));
+ setPhase('error');
+ return;
+ }
+
+ try {
+ if (!gekRef.current && window.MeshBayCrypto) {
+ const gekB64 = await transport.fetchGEK();
+ gekRef.current = await window.MeshBayCrypto.importGEK(gekB64);
+ }
+
+ const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
+ let downloaded = 0;
+ const chunks = await pipelinedDownload(
+ transport, gekRef.current, entry.id, totalChunks,
+ (bytes) => { downloaded += bytes; setProgress(downloaded / entry.size); },
+ );
+
+ if (cancelled) return;
+
+ const blob = new Blob(chunks, { type: videoMime(entry.name) });
+ const url = URL.createObjectURL(blob);
+ blobUrlRef.current = url;
+ setPhase('ready');
+ } catch (err) {
+ if (!cancelled) {
+ setError(err.message);
+ setPhase('error');
+ }
+ }
+ };
+
+ load();
+ return () => { cancelled = true; };
+ }, [entry]);
+
+ useEffect(() => {
+ if (phase === 'ready' && videoRef.current && blobUrlRef.current) {
+ videoRef.current.src = blobUrlRef.current;
+ videoRef.current.play().catch(() => {});
+ }
+ }, [phase]);
+
+ useEffect(() => {
+ return () => {
+ if (blobUrlRef.current) {
+ URL.revokeObjectURL(blobUrlRef.current);
+ blobUrlRef.current = null;
+ }
+ };
+ }, []);
+
+ useEffect(() => {
+ const onKey = (e) => { if (e.key === 'Escape') onClose(); };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [onClose]);
+
+ return html`
+ <div class="video-overlay" onClick=${(e) => {
+ if (e.target.classList.contains('video-overlay')) onClose();
+ }}>
+ <div class="video-top-bar">
+ <span class="video-title">${entry.name}</span>
+ <button class="video-close" onClick=${onClose} title="${t('video.close')}">✕</button>
+ </div>
+
+ ${phase === 'loading' && html`
+ <div class="video-loading">
+ <div class="video-loading-label">${t('video.loading', { name: entry.name })}</div>
+ <div class="video-progress-bar">
+ <div class="video-progress-fill"
+ style="width:${Math.round(progress * 100)}%"></div>
+ </div>
+ <div class="video-progress-text">
+ ${formatSize(Math.round(progress * entry.size))} / ${formatSize(entry.size)}
+ </div>
+ </div>
+ `}
+
+ ${phase === 'ready' && html`
+ <div class="video-container">
+ <video ref=${videoRef} controls autoplay />
+ </div>
+ `}
+
+ ${phase === 'error' && html`
+ <div class="video-error">${error}</div>
+ `}
+ </div>
+ `;
+}
+
+// ── Settings Page ───────────────────────────────────────────────────────────
+
+const THEME_OPTIONS = ['light', 'dark', 'system'];
+
+function SettingsPage({ user, theme, onThemeChange }) {
+ const [locale, setLoc] = useState(getLocale);
+
+ const onLocaleChange = useCallback((e) => {
+ const code = e.target.value;
+ setLocale(code);
+ setLoc(code);
+ window.location.reload();
+ }, []);
+
+ const onThemeSelect = useCallback((e) => {
+ onThemeChange(e.target.value);
+ }, [onThemeChange]);
+
+ return html`
+ <div>
+ <h2>${t('settings.title')}</h2>
+
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings.profile')}</h3>
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.username')}</span>
+ <span class="settings-value">${user.username}</span>
+ </div>
+ </div>
+
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings.appearance')}</h3>
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.theme')}</span>
+ <select class="settings-select" value=${theme} onChange=${onThemeSelect}>
+ <option value="light">${t('settings.theme_light')}</option>
+ <option value="dark">${t('settings.theme_dark')}</option>
+ <option value="system">${t('settings.theme_system')}</option>
+ </select>
+ </div>
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.language')}</span>
+ <select class="settings-select" value=${locale} onChange=${onLocaleChange}>
+ ${LOCALES.map(l => html`
+ <option key=${l.code} value=${l.code}>${l.name}</option>
+ `)}
+ </select>
+ </div>
+ </div>
+
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings.about')}</h3>
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.version')}</span>
+ <span class="settings-value">0.1.0</span>
+ </div>
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.protocol')}</span>
+ <span class="settings-value">MNP 0.1 / MHP 0.1</span>
+ </div>
+ </div>
+ </div>
+ `;
}
-// ── Boot ──────────────────────────────────────────────────────────────────────
-document.addEventListener('DOMContentLoaded', render);
+// ── App ──────────────────────────────────────────────────────────────────────
+
+function App() {
+ const route = useRoute();
+ const [theme, setTheme] = useState(getInitialTheme);
+ const [user, setUser] = useState(loadAuth);
+ const [groups, setGroups] = useState([]);
+ const [menuOpen, setMenuOpen] = useState(false);
+
+ const resolved = resolveTheme(theme);
+
+ useEffect(() => {
+ document.documentElement.className = `theme-${resolved}`;
+ localStorage.setItem(THEME_KEY, theme);
+ }, [theme, resolved]);
+
+ useEffect(() => {
+ if (!user) { setGroups([]); return; }
+ hubFetch('/v1/groups/mine', { token: user.token })
+ .then(data => setGroups(data.groups || []))
+ .catch(() => setGroups([]));
+ }, [user]);
+
+ useEffect(() => { setMenuOpen(false); }, [route]);
+
+ const toggleTheme = useCallback(() => {
+ setTheme(prev => resolveTheme(prev) === 'dark' ? 'light' : 'dark');
+ }, []);
+
+ const authCtx = {
+ user,
+ login: async (username, password) => {
+ if (window.MeshBayKeys) {
+ const data = await window.MeshBayKeys.loginAndRecover(username, password);
+ _sessionKeys = { skXB64: data.skXB64, skEdB64: data.skEdB64 };
+ const u = {
+ username,
+ token: data.accessToken,
+ refreshToken: data.refreshToken,
+ };
+ setUser(u);
+ saveAuth(u);
+ } else {
+ const data = await hubFetch('/v1/users/login', {
+ method: 'POST',
+ body: { username, password },
+ });
+ const u = {
+ username,
+ token: data.access_token,
+ refreshToken: data.refresh_token,
+ };
+ setUser(u);
+ saveAuth(u);
+ }
+ },
+ logout: () => {
+ setUser(null);
+ saveAuth(null);
+ setGroups([]);
+ navigate('/login');
+ },
+ };
+
+ let page;
+ if (route === '/login' || route === '/register') {
+ page = route === '/register'
+ ? html`<${RegisterPage} />`
+ : html`<${LoginPage} />`;
+ } else if (!user) {
+ page = html`<${LoginPage} />`;
+ } else if (route === '/explore') {
+ page = html`<${ExplorePage} token=${user.token} />`;
+ } else if (route.startsWith('/group/')) {
+ const groupId = route.slice(7);
+ const group = groups.find(g => g.id === groupId);
+ page = html`<${GroupPage}
+ groupId=${groupId} group=${group} token=${user.token}
+ username=${user.username} />`;
+ } else if (route === '/settings') {
+ page = html`<${SettingsPage} user=${user} theme=${theme}
+ onThemeChange=${setTheme} />`;
+ } else {
+ page = html`<${HomePage} groups=${groups} />`;
+ }
+
+ return html`
+ <${AuthContext.Provider} value=${authCtx}>
+ <${Nav}
+ user=${user}
+ theme=${resolved}
+ onThemeToggle=${toggleTheme}
+ onLogout=${authCtx.logout}
+ onMenuToggle=${() => setMenuOpen(o => !o)} />
+ <div class="layout">
+ ${user && html`<${Sidebar}
+ groups=${groups}
+ route=${route}
+ menuOpen=${menuOpen} />`}
+ ${menuOpen && html`<div class="overlay visible"
+ onClick=${() => setMenuOpen(false)} />`}
+ <main class="main">
+ ${page}
+ </main>
+ </div>
+ <//>
+ `;
+}
+
+// ── Boot ─────────────────────────────────────────────────────────────────────
+
+render(html`<${App} />`, document.getElementById('app'));
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
index 7862283..ee442fb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
@@ -136,7 +136,7 @@ function b64decode(b64) {
function hexToBytes(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2)
- bytes[i / 2] = parseInt(hex.substring(i, 2), 16);
+ bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
return bytes;
}
@@ -151,5 +151,12 @@ function concatBuffers(arrays) {
return result;
}
+async function decryptChunkBin(gek, fileHashHex, chunkIndex, nonce, ct) {
+ const chunkKey = await deriveChunkKey(gek, fileHashHex, chunkIndex);
+ const plaintext = await crypto.subtle.decrypt(
+ { name: 'AES-GCM', iv: nonce }, chunkKey, ct);
+ return new Uint8Array(plaintext);
+}
+
// Export for use in app.js
-window.MeshBayCrypto = { importGEK, deriveChunkKey, decryptChunk, decryptFile };
+window.MeshBayCrypto = { importGEK, deriveChunkKey, decryptChunk, decryptChunkBin, decryptFile };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
new file mode 100644
index 0000000..e0242cf
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
@@ -0,0 +1,172 @@
+/**
+ * MeshBay i18n — lightweight string localization.
+ *
+ * Usage:
+ * import { t, setLocale, getLocale, LOCALES } from './i18n.js';
+ * t('nav.logout') // "Logout"
+ * t('status.files', { n: 42 }) // "42 files"
+ */
+
+const LANG_KEY = 'mb_lang';
+
+// ── English strings ─────────────────────────────────────────────────────────
+
+const en = {
+ // Nav
+ 'nav.toggle_menu': 'Toggle menu',
+ 'nav.light_mode': 'Light mode',
+ 'nav.dark_mode': 'Dark mode',
+ 'nav.logout': 'Logout',
+ 'nav.login': 'Login',
+
+ // Sidebar
+ 'sidebar.my_groups': 'My Groups',
+ 'sidebar.no_groups': 'No groups yet',
+ 'sidebar.discover': 'Discover',
+ 'sidebar.public_groups': 'Public groups',
+
+ // Login
+ 'login.title': 'Login',
+ 'login.username': 'Username',
+ 'login.password': 'Password',
+ 'login.submit': 'Login',
+ 'login.loading': 'Logging in...',
+ 'login.no_account': 'No account?',
+ 'login.register_link': 'Register',
+
+ // Register
+ 'register.title': 'Register',
+ 'register.username': 'Username',
+ 'register.email': 'Email',
+ 'register.password': 'Password (min 8 chars)',
+ 'register.confirm': 'Confirm password',
+ 'register.submit': 'Register',
+ 'register.loading': 'Creating account...',
+ 'register.has_account': 'Already have an account?',
+ 'register.login_link': 'Login',
+ 'register.success_title': 'Account created',
+ 'register.success_msg': 'You can now log in with your credentials.',
+ 'register.go_login': 'Go to login',
+ 'register.err_mismatch': 'Passwords do not match',
+ 'register.err_min_len': 'Password must be at least 8 characters',
+
+ // Home
+ 'home.welcome': 'Welcome to MeshBay',
+ 'home.no_groups': 'You are not a member of any group yet.',
+ 'home.browse_prefix': 'Browse ',
+ 'home.browse_link': 'public groups',
+ 'home.browse_suffix': ' or ask a group admin to invite you.',
+ 'home.my_groups': 'My Groups',
+
+ // Explore
+ 'explore.title': 'Public Groups',
+ 'explore.loading': 'Loading...',
+ 'explore.empty': 'No public groups available.',
+
+ // Group page
+ 'group.default_name': 'Group',
+ 'group.tab_files': 'Files',
+ 'group.tab_chat': 'Chat',
+ 'group.filter': 'Filter files...',
+ 'group.col_name': 'Name',
+ 'group.col_size': 'Size',
+ 'group.col_type': 'Type',
+ 'group.col_date': 'Date',
+ 'group.empty_filter': 'No files match the filter',
+ 'group.empty_dir': 'This directory is empty',
+ 'group.download': 'Download',
+ 'group.play': 'Play',
+ 'group.dl_failed': 'Download failed: {err}',
+ 'group.offline_title': 'No nodes are currently online for this group.',
+ 'group.offline_hint': 'Files will appear when a node hosting this group connects.',
+ 'group.err_transport': 'Transport module not loaded',
+
+ // Status
+ 'status.idle': 'Idle',
+ 'status.discovering': 'Finding nodes...',
+ 'status.connecting': 'Connecting via WebRTC...',
+ 'status.fetching': 'Fetching index...',
+ 'status.files': '{n} files',
+ 'status.offline': 'No nodes online',
+ 'status.error': 'Connection failed',
+
+ // Chat
+ 'chat.empty': 'No messages yet. Start the conversation!',
+ 'chat.placeholder': 'Type a message...',
+ 'chat.send': 'Send',
+
+ // Video player
+ 'video.loading': 'Loading {name}...',
+ 'video.close': 'Close (Esc)',
+ 'video.err_transport': 'Transport not connected',
+
+ // Settings
+ 'settings.title': 'Settings',
+ 'settings.coming_soon': 'Coming soon.',
+ 'settings.profile': 'Profile',
+ 'settings.username': 'Username',
+ 'settings.appearance': 'Appearance',
+ 'settings.theme': 'Theme',
+ 'settings.theme_light': 'Light',
+ 'settings.theme_dark': 'Dark',
+ 'settings.theme_system': 'System',
+ 'settings.language': 'Language',
+ 'settings.about': 'About',
+ 'settings.version': 'Version',
+ 'settings.protocol': 'Protocol',
+
+ // Sidebar
+ 'sidebar.settings': 'Settings',
+};
+
+// ── Locale registry ─────────────────────────────────────────────────────────
+
+const _strings = { en };
+let _locale = 'en';
+
+export const LOCALES = [
+ { code: 'en', name: 'English' },
+];
+
+export function getLocale() { return _locale; }
+
+export function setLocale(code) {
+ if (_strings[code]) {
+ _locale = code;
+ localStorage.setItem(LANG_KEY, code);
+ return true;
+ }
+ return false;
+}
+
+export function addLocale(code, strings) {
+ _strings[code] = strings;
+}
+
+export function t(key, params) {
+ let s = (_strings[_locale] && _strings[_locale][key])
+ || _strings.en[key]
+ || key;
+ if (params) {
+ for (const [k, v] of Object.entries(params)) {
+ s = s.replace(`{${k}}`, v);
+ }
+ }
+ return s;
+}
+
+// ── Init ────────────────────────────────────────────────────────────────────
+
+function _init() {
+ const stored = localStorage.getItem(LANG_KEY);
+ if (stored && _strings[stored]) {
+ _locale = stored;
+ return;
+ }
+ const browserLang = (navigator.language || '').split('-')[0];
+ if (_strings[browserLang]) {
+ _locale = browserLang;
+ }
+}
+
+_init();
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
new file mode 100644
index 0000000..27e645e
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -0,0 +1,802 @@
+/* MeshBay Web Client — Phase 9.6 */
+
+/* ── Theme variables ──────────────────────────────────────────────────────── */
+
+:root, .theme-light {
+ --bg-base: #f8fafc;
+ --bg-surface: #ffffff;
+ --bg-raised: #f1f5f9;
+ --text: #0f172a;
+ --text-secondary: #475569;
+ --text-dim: #94a3b8;
+ --border: #e2e8f0;
+ --border-focus: #0ea5e9;
+ --accent: #0ea5e9;
+ --accent-hover: #0284c7;
+ --accent-text: #ffffff;
+ --error: #ef4444;
+ --error-bg: #fef2f2;
+ --success: #22c55e;
+ --nav-bg: #0f172a;
+ --nav-text: #e2e8f0;
+ --nav-brand: #38bdf8;
+ --sidebar-bg: #f1f5f9;
+ --sidebar-hover: #e2e8f0;
+ --shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
+ --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.1);
+}
+
+.theme-dark {
+ --bg-base: #0f172a;
+ --bg-surface: #1e293b;
+ --bg-raised: #334155;
+ --text: #f1f5f9;
+ --text-secondary: #94a3b8;
+ --text-dim: #64748b;
+ --border: #334155;
+ --error-bg: #450a0a;
+ --nav-bg: #020617;
+ --sidebar-bg: #1e293b;
+ --sidebar-hover: #334155;
+ --shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
+ --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.3);
+}
+
+/* ── Reset ────────────────────────────────────────────────────────────────── */
+
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+body {
+ font-family: system-ui, -apple-system, sans-serif;
+ background: var(--bg-base);
+ color: var(--text);
+ line-height: 1.6;
+ min-height: 100vh;
+}
+
+a { color: var(--accent); text-decoration: none; }
+a:hover { text-decoration: underline; }
+
+/* ── Navigation ───────────────────────────────────────────────────────────── */
+
+.nav {
+ background: var(--nav-bg);
+ color: var(--nav-text);
+ padding: 0 16px;
+ height: 52px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ position: sticky;
+ top: 0;
+ z-index: 100;
+}
+
+.nav-left, .nav-right { display: flex; align-items: center; gap: 12px; }
+
+.nav-brand {
+ color: var(--nav-brand);
+ font-weight: 700;
+ font-size: 1.15em;
+ text-decoration: none;
+}
+.nav-brand:hover { text-decoration: none; }
+
+.nav-hamburger {
+ display: none;
+ background: none;
+ border: none;
+ color: var(--nav-text);
+ font-size: 1.4em;
+ cursor: pointer;
+ padding: 4px 8px;
+ line-height: 1;
+}
+
+.nav-user { color: var(--text-dim); font-size: 0.9em; }
+
+.nav-theme {
+ background: none;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ color: var(--nav-text);
+ width: 32px;
+ height: 32px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 1.1em;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.nav-theme:hover { border-color: rgba(255, 255, 255, 0.3); background: none; }
+
+.nav-btn {
+ background: rgba(255, 255, 255, 0.1);
+ color: var(--nav-text);
+ border: none;
+ padding: 6px 14px;
+ border-radius: 6px;
+ font-size: 0.85em;
+ cursor: pointer;
+ text-decoration: none;
+}
+.nav-btn:hover { background: rgba(255, 255, 255, 0.18); text-decoration: none; }
+
+/* ── Layout ───────────────────────────────────────────────────────────────── */
+
+.layout { display: flex; min-height: calc(100vh - 52px); }
+
+/* ── Sidebar ──────────────────────────────────────────────────────────────── */
+
+.sidebar {
+ width: 240px;
+ background: var(--sidebar-bg);
+ border-right: 1px solid var(--border);
+ padding: 16px 0;
+ flex-shrink: 0;
+ overflow-y: auto;
+}
+
+.sidebar-section { padding: 0 12px; margin-bottom: 24px; }
+
+.sidebar-heading {
+ font-size: 0.7em;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-dim);
+ padding: 4px 8px;
+ margin-bottom: 4px;
+ font-weight: 600;
+}
+
+.sidebar-item {
+ display: block;
+ padding: 7px 12px;
+ border-radius: 6px;
+ color: var(--text);
+ text-decoration: none;
+ font-size: 0.9em;
+ transition: background 0.12s;
+}
+.sidebar-item:hover { background: var(--sidebar-hover); text-decoration: none; }
+.sidebar-item.active { background: var(--accent); color: var(--accent-text); }
+
+.sidebar-empty {
+ padding: 8px 12px;
+ color: var(--text-dim);
+ font-size: 0.85em;
+ font-style: italic;
+}
+
+/* ── Main content ─────────────────────────────────────────────────────────── */
+
+.main {
+ flex: 1;
+ padding: 24px 32px;
+ max-width: 960px;
+ min-width: 0;
+}
+
+.main h2 { font-size: 1.25em; margin-bottom: 16px; }
+
+.page-message {
+ color: var(--text-secondary);
+ margin-bottom: 24px;
+}
+
+/* ── Page center (login / register) ───────────────────────────────────────── */
+
+.page-center {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: calc(100vh - 52px);
+ padding: 24px;
+}
+
+/* ── Cards ────────────────────────────────────────────────────────────────── */
+
+.card {
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 24px;
+ box-shadow: var(--shadow);
+}
+
+.login-card { width: 100%; max-width: 380px; }
+.login-card h2 { text-align: center; margin-bottom: 20px; }
+.login-card form { display: flex; flex-direction: column; gap: 12px; }
+
+.login-footer {
+ text-align: center;
+ margin-top: 16px;
+ font-size: 0.85em;
+ color: var(--text-dim);
+}
+
+/* ── Form elements ────────────────────────────────────────────────────────── */
+
+input[type="text"],
+input[type="password"],
+input[type="email"] {
+ padding: 10px 12px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg-base);
+ color: var(--text);
+ font-size: 0.95em;
+ width: 100%;
+ transition: border-color 0.15s;
+}
+input:focus { outline: none; border-color: var(--border-focus); }
+
+button {
+ padding: 10px 20px;
+ background: var(--accent);
+ color: var(--accent-text);
+ border: none;
+ border-radius: 6px;
+ font-size: 0.95em;
+ cursor: pointer;
+ transition: background 0.15s;
+}
+button:hover { background: var(--accent-hover); }
+button:disabled { opacity: 0.5; cursor: not-allowed; }
+
+/* ── Alerts ───────────────────────────────────────────────────────────────── */
+
+.error-msg {
+ background: var(--error-bg);
+ color: var(--error);
+ border: 1px solid var(--error);
+ border-radius: 6px;
+ padding: 8px 12px;
+ font-size: 0.85em;
+}
+
+/* ── Group cards (9.7 prep) ───────────────────────────────────────────────── */
+
+.group-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ gap: 16px;
+}
+
+.group-card {
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 16px;
+ cursor: pointer;
+ transition: border-color 0.12s, box-shadow 0.12s;
+ text-decoration: none;
+ color: var(--text);
+ display: block;
+}
+.group-card:hover {
+ border-color: var(--accent);
+ box-shadow: var(--shadow-lg);
+ text-decoration: none;
+}
+.group-card h3 { font-size: 1em; margin-bottom: 4px; }
+
+.badge {
+ display: inline-block;
+ background: var(--bg-raised);
+ color: var(--text-secondary);
+ padding: 2px 8px;
+ border-radius: 12px;
+ font-size: 0.75em;
+}
+
+/* ── Group page header ───────────────────────────────────────────────────── */
+
+.group-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 16px;
+}
+.group-header h2 { margin-bottom: 0; }
+
+.status-badge {
+ display: inline-block;
+ padding: 3px 10px;
+ border-radius: 12px;
+ font-size: 0.75em;
+ font-weight: 500;
+}
+.status-ok { background: #16a34a20; color: var(--success); }
+.status-err { background: var(--error-bg); color: var(--error); }
+.status-busy { background: var(--bg-raised); color: var(--text-secondary); }
+
+/* ── Group tabs ──────────────────────────────────────────────────────────── */
+
+.group-tabs {
+ display: flex;
+ gap: 0;
+ margin-bottom: 16px;
+ border-bottom: 2px solid var(--border);
+}
+
+.group-tab {
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ margin-bottom: -2px;
+ padding: 8px 20px;
+ color: var(--text-secondary);
+ font-size: 0.9em;
+ font-weight: 500;
+ cursor: pointer;
+ border-radius: 0;
+ transition: color 0.12s, border-color 0.12s;
+}
+.group-tab:hover { color: var(--text); background: none; }
+.group-tab.active {
+ color: var(--accent);
+ border-bottom-color: var(--accent);
+}
+
+/* ── Chat panel ──────────────────────────────────────────────────────────── */
+
+.chat-panel {
+ display: flex;
+ flex-direction: column;
+ height: calc(100vh - 220px);
+ min-height: 300px;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.chat-messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.chat-empty {
+ text-align: center;
+ color: var(--text-dim);
+ font-style: italic;
+ padding: 40px 16px;
+}
+
+.chat-msg {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ max-width: 75%;
+}
+
+.chat-msg-own {
+ align-self: flex-end;
+ align-items: flex-end;
+}
+
+.chat-sender {
+ font-size: 0.72em;
+ color: var(--text-dim);
+ margin-bottom: 2px;
+ padding-left: 10px;
+ font-weight: 600;
+}
+
+.chat-bubble {
+ background: var(--bg-raised);
+ padding: 8px 12px;
+ border-radius: 12px 12px 12px 4px;
+ font-size: 0.9em;
+ line-height: 1.45;
+ word-break: break-word;
+ display: inline-flex;
+ align-items: baseline;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.chat-bubble-own {
+ background: var(--accent);
+ color: var(--accent-text);
+ border-radius: 12px 12px 4px 12px;
+}
+
+.chat-text { white-space: pre-wrap; }
+
+.chat-time {
+ font-size: 0.65em;
+ color: var(--text-dim);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+.chat-bubble-own .chat-time { color: rgba(255, 255, 255, 0.6); }
+
+.chat-input-row {
+ display: flex;
+ gap: 8px;
+ padding: 12px;
+ border-top: 1px solid var(--border);
+ background: var(--bg-base);
+}
+
+.chat-input {
+ flex: 1;
+ resize: none;
+ padding: 8px 12px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--bg-surface);
+ color: var(--text);
+ font-size: 0.9em;
+ font-family: inherit;
+ line-height: 1.4;
+ min-height: 38px;
+ max-height: 100px;
+}
+.chat-input:focus { outline: none; border-color: var(--border-focus); }
+
+.chat-send {
+ align-self: flex-end;
+ padding: 8px 16px;
+ font-size: 0.85em;
+ border-radius: 8px;
+ white-space: nowrap;
+}
+
+/* ── File toolbar ────────────────────────────────────────────────────────── */
+
+.file-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 12px;
+ flex-wrap: wrap;
+}
+
+.breadcrumbs {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ font-size: 0.85em;
+ min-width: 0;
+ flex-wrap: wrap;
+}
+.crumb {
+ cursor: pointer;
+ color: var(--accent);
+ padding: 2px 4px;
+ border-radius: 4px;
+}
+.crumb:hover { background: var(--bg-raised); text-decoration: none; }
+.crumb-sep { color: var(--text-dim); }
+
+.file-search {
+ max-width: 220px;
+ padding: 6px 10px !important;
+ font-size: 0.85em !important;
+}
+
+/* ── File table ──────────────────────────────────────────────────────────── */
+
+.file-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.9em;
+}
+
+.file-table th {
+ text-align: left;
+ padding: 8px 12px;
+ border-bottom: 2px solid var(--border);
+ color: var(--text-secondary);
+ font-size: 0.8em;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ white-space: nowrap;
+ user-select: none;
+}
+
+.file-table th.sortable { cursor: pointer; }
+.file-table th.sortable:hover { color: var(--text); }
+
+.file-row td {
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--border);
+ vertical-align: middle;
+}
+
+.file-row:hover { background: var(--bg-raised); }
+.dir-row { cursor: pointer; }
+.dir-row td { font-weight: 500; }
+
+.file-name {
+ word-break: break-word;
+ min-width: 0;
+}
+.file-size { white-space: nowrap; color: var(--text-secondary); }
+.file-type { color: var(--text-dim); }
+.file-date { white-space: nowrap; color: var(--text-dim); }
+.file-empty {
+ text-align: center;
+ padding: 24px 12px !important;
+ color: var(--text-dim);
+ font-style: italic;
+}
+
+/* ── Download button + progress ──────────────────────────────────────────── */
+
+.dl-btn {
+ background: none;
+ border: 1px solid var(--border);
+ color: var(--accent);
+ width: 30px;
+ height: 30px;
+ border-radius: 6px;
+ font-size: 0.9em;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+}
+.dl-btn:hover { background: var(--bg-raised); border-color: var(--accent); }
+.dl-btn:disabled { opacity: 0.3; cursor: not-allowed; }
+
+.dl-bar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 14px;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ margin-bottom: 12px;
+ font-size: 0.85em;
+}
+
+.dl-name {
+ flex-shrink: 0;
+ max-width: 200px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 500;
+}
+
+.dl-progress {
+ flex: 1;
+ height: 6px;
+ background: var(--bg-raised);
+ border-radius: 3px;
+ overflow: hidden;
+}
+
+.dl-fill {
+ height: 100%;
+ background: var(--accent);
+ border-radius: 3px;
+ transition: width 0.2s;
+}
+
+.dl-pct {
+ flex-shrink: 0;
+ color: var(--text-secondary);
+ font-size: 0.85em;
+ white-space: nowrap;
+}
+
+/* ── Settings page ───────────────────────────────────────────────────────── */
+
+.settings-section {
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 16px 20px;
+ margin-bottom: 16px;
+}
+
+.settings-heading {
+ font-size: 0.8em;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text-dim);
+ font-weight: 600;
+ margin-bottom: 12px;
+}
+
+.settings-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 0;
+}
+.settings-row + .settings-row {
+ border-top: 1px solid var(--border);
+}
+
+.settings-label {
+ font-size: 0.9em;
+ color: var(--text);
+}
+
+.settings-value {
+ font-size: 0.9em;
+ color: var(--text-secondary);
+}
+
+.settings-select {
+ padding: 6px 10px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg-base);
+ color: var(--text);
+ font-size: 0.9em;
+ cursor: pointer;
+ min-width: 120px;
+}
+.settings-select:focus { outline: none; border-color: var(--border-focus); }
+
+/* ── Video player overlay ─────────────────────────────────────────────────── */
+
+.video-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 200;
+ background: rgba(0, 0, 0, 0.92);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+.video-top-bar {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 20px;
+ z-index: 210;
+}
+
+.video-title {
+ color: #e2e8f0;
+ font-size: 0.9em;
+ font-weight: 500;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: calc(100% - 60px);
+}
+
+.video-close {
+ background: rgba(255, 255, 255, 0.12);
+ border: none;
+ color: #e2e8f0;
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ font-size: 1.2em;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+.video-close:hover { background: rgba(255, 255, 255, 0.25); }
+
+.video-container {
+ width: 100%;
+ max-width: min(90vw, 1280px);
+ max-height: calc(100vh - 120px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.video-container video {
+ width: 100%;
+ max-height: calc(100vh - 120px);
+ border-radius: 4px;
+ outline: none;
+}
+
+.video-loading {
+ text-align: center;
+ color: #94a3b8;
+}
+
+.video-loading-label {
+ margin-bottom: 16px;
+ font-size: 0.95em;
+}
+
+.video-progress-bar {
+ width: min(400px, 80vw);
+ height: 6px;
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 3px;
+ overflow: hidden;
+ margin-bottom: 8px;
+}
+
+.video-progress-fill {
+ height: 100%;
+ background: var(--accent);
+ border-radius: 3px;
+ transition: width 0.15s;
+}
+
+.video-progress-text {
+ font-size: 0.8em;
+ color: #64748b;
+}
+
+.video-error {
+ color: var(--error);
+ font-size: 0.9em;
+ text-align: center;
+ max-width: 400px;
+}
+
+.play-btn {
+ background: none;
+ border: 1px solid var(--border);
+ color: var(--success);
+ width: 30px;
+ height: 30px;
+ border-radius: 6px;
+ font-size: 0.85em;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ margin-right: 4px;
+}
+.play-btn:hover { background: var(--bg-raised); border-color: var(--success); }
+.play-btn:disabled { opacity: 0.3; cursor: not-allowed; }
+
+/* ── Overlay (mobile sidebar backdrop) ────────────────────────────────────── */
+
+.overlay {
+ display: none;
+ position: fixed;
+ inset: 0;
+ top: 52px;
+ background: rgba(0, 0, 0, 0.4);
+ z-index: 40;
+}
+.overlay.visible { display: block; }
+
+/* ── Responsive ───────────────────────────────────────────────────────────── */
+
+@media (max-width: 768px) {
+ .nav-hamburger { display: flex; }
+
+ .sidebar {
+ position: fixed;
+ left: -240px;
+ top: 52px;
+ height: calc(100vh - 52px);
+ z-index: 50;
+ transition: left 0.2s;
+ box-shadow: none;
+ }
+ .sidebar.open { left: 0; box-shadow: var(--shadow-lg); }
+
+ .main { padding: 16px; }
+ .page-center { padding: 16px; }
+ .th-type, .td-type { display: none; }
+ .th-date, .td-date { display: none; }
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 9112734..de93b2d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -41,21 +41,36 @@ class MeshBayTransport {
this._channel = this._pc.createDataChannel('mnp', { ordered: true });
this._channel.binaryType = 'arraybuffer';
+ let channelReject = null;
const channelReady = new Promise((resolve, reject) => {
+ channelReject = reject;
const timeout = setTimeout(() => reject(new Error('DataChannel open timeout')), 30000);
this._channel.onopen = () => {
clearTimeout(timeout);
this._connected = true;
resolve();
};
- this._channel.onerror = (e) => {
- clearTimeout(timeout);
- reject(new Error('DataChannel error: ' + e.message));
- };
});
this._channel.onmessage = (event) => this._onMessage(event.data);
- this._channel.onclose = () => { this._connected = false; };
+ this._channel.onclose = (ev) => {
+ console.warn('[MeshBay] DataChannel closed', this._channel?.readyState, ev);
+ this._connected = false;
+ if (channelReject) channelReject(new Error('DataChannel closed'));
+ for (const [, p] of this._pending) p.reject(new Error('DataChannel closed'));
+ this._pending.clear();
+ };
+ this._channel.onerror = (ev) => {
+ console.error('[MeshBay] DataChannel error', ev);
+ if (channelReject) channelReject(new Error('DataChannel error'));
+ };
+
+ this._pc.onconnectionstatechange = () => {
+ console.log('[MeshBay] PC state:', this._pc.connectionState);
+ };
+ this._pc.oniceconnectionstatechange = () => {
+ console.log('[MeshBay] ICE state:', this._pc.iceConnectionState);
+ };
const offer = await this._pc.createOffer();
await this._pc.setLocalDescription(offer);
@@ -106,7 +121,13 @@ class MeshBayTransport {
async fetchIndex() {
const msg = await this._sendAndWait({ type: 'index_sync', v: '0.1' });
if (msg.type === 'error') throw new Error(msg.detail);
- return _b64decode(msg.index_b64);
+ return msg;
+ }
+
+ async fetchGEK() {
+ const msg = await this._sendAndWait({ type: 'gek_req', v: '0.1' });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg.gek_b64;
}
async fetchChunk(fileId, chunkIndex) {
@@ -132,6 +153,17 @@ class MeshBayTransport {
return _b64decode(msg.data_b64);
}
+ async fetchChatHistory(since, limit) {
+ const msg = await this._sendAndWait({
+ type: 'chat_hist',
+ v: '0.1',
+ since: since || 0,
+ limit: limit || 100,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg.messages || [];
+ }
+
async sendChat(payload, iteration, threadId) {
const msg = await this._sendAndWait({
type: 'chat_msg',
@@ -169,6 +201,9 @@ class MeshBayTransport {
}
_send(obj) {
+ if (!this._channel || this._channel.readyState !== 'open') {
+ throw new Error(`DataChannel not open (state: ${this._channel?.readyState})`);
+ }
const encoded = msgpack_encode(obj);
const header = new Uint8Array(4);
new DataView(header.buffer).setUint32(0, encoded.byteLength, false);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/vendor/htm-preact.js b/packages/meshbay-hub/src/meshbay_hub/static/vendor/htm-preact.js
new file mode 100644
index 0000000..e24f87b
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/vendor/htm-preact.js
@@ -0,0 +1 @@
+var e,n,_,t,o,r,u,l={},i=[],c=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i;function s(e,n){for(var _ in n)e[_]=n[_];return e}function f(e){var n=e.parentNode;n&&n.removeChild(e)}function a(n,_,t){var o,r,u,l={};for(u in _)"key"==u?o=_[u]:"ref"==u?r=_[u]:l[u]=_[u];if(arguments.length>2&&(l.children=arguments.length>3?e.call(arguments,2):t),"function"==typeof n&&null!=n.defaultProps)for(u in n.defaultProps)void 0===l[u]&&(l[u]=n.defaultProps[u]);return p(n,l,o,r,null)}function p(e,t,o,r,u){var l={type:e,props:t,key:o,ref:r,__k:null,__:null,__b:0,__e:null,__d:void 0,__c:null,__h:null,constructor:void 0,__v:null==u?++_:u};return null!=n.vnode&&n.vnode(l),l}function h(e){return e.children}function d(e,n){this.props=e,this.context=n}function v(e,n){if(null==n)return e.__?v(e.__,e.__.__k.indexOf(e)+1):null;for(var _;n<e.__k.length;n++)if(null!=(_=e.__k[n])&&null!=_.__e)return _.__e;return"function"==typeof e.type?v(e):null}function y(e){var n,_;if(null!=(e=e.__)&&null!=e.__c){for(e.__e=e.__c.base=null,n=0;n<e.__k.length;n++)if(null!=(_=e.__k[n])&&null!=_.__e){e.__e=e.__c.base=_.__e;break}return y(e)}}function m(e){(!e.__d&&(e.__d=!0)&&t.push(e)&&!g.__r++||r!==n.debounceRendering)&&((r=n.debounceRendering)||o)(g)}function g(){for(var e;g.__r=t.length;)e=t.sort(function(e,n){return e.__v.__b-n.__v.__b}),t=[],e.some(function(e){var n,_,t,o,r,u;e.__d&&(r=(o=(n=e).__v).__e,(u=n.__P)&&(_=[],(t=s({},o)).__v=o.__v+1,P(u,o,t,n.__n,void 0!==u.ownerSVGElement,null!=o.__h?[r]:null,_,null==r?v(o):r,o.__h),D(_,o),o.__e!=r&&y(o)))})}function k(e,n,_,t,o,r,u,c,s,f){var a,d,y,m,g,k,x,H=t&&t.__k||i,E=H.length;for(_.__k=[],a=0;a<n.length;a++)if(null!=(m=_.__k[a]=null==(m=n[a])||"boolean"==typeof m?null:"string"==typeof m||"number"==typeof m||"bigint"==typeof m?p(null,m,null,null,m):Array.isArray(m)?p(h,{children:m},null,null,null):m.__b>0?p(m.type,m.props,m.key,null,m.__v):m)){if(m.__=_,m.__b=_.__b+1,null===(y=H[a])||y&&m.key==y.key&&m.type===y.type)H[a]=void 0;else for(d=0;d<E;d++){if((y=H[d])&&m.key==y.key&&m.type===y.type){H[d]=void 0;break}y=null}P(e,m,y=y||l,o,r,u,c,s,f),g=m.__e,(d=m.ref)&&y.ref!=d&&(x||(x=[]),y.ref&&x.push(y.ref,null,m),x.push(d,m.__c||g,m)),null!=g?(null==k&&(k=g),"function"==typeof m.type&&null!=m.__k&&m.__k===y.__k?m.__d=s=b(m,s,e):s=C(e,m,y,H,g,s),f||"option"!==_.type?"function"==typeof _.type&&(_.__d=s):e.value=""):s&&y.__e==s&&s.parentNode!=e&&(s=v(y))}for(_.__e=k,a=E;a--;)null!=H[a]&&("function"==typeof _.type&&null!=H[a].__e&&H[a].__e==_.__d&&(_.__d=v(t,a+1)),U(H[a],H[a]));if(x)for(a=0;a<x.length;a++)T(x[a],x[++a],x[++a])}function b(e,n,_){var t,o;for(t=0;t<e.__k.length;t++)(o=e.__k[t])&&(o.__=e,n="function"==typeof o.type?b(o,n,_):C(_,o,o,e.__k,o.__e,n));return n}function C(e,n,_,t,o,r){var u,l,i;if(void 0!==n.__d)u=n.__d,n.__d=void 0;else if(null==_||o!=r||null==o.parentNode)e:if(null==r||r.parentNode!==e)e.appendChild(o),u=null;else{for(l=r,i=0;(l=l.nextSibling)&&i<t.length;i+=2)if(l==o)break e;e.insertBefore(o,r),u=r}return void 0!==u?u:o.nextSibling}function x(e,n,_){"-"===n[0]?e.setProperty(n,_):e[n]=null==_?"":"number"!=typeof _||c.test(n)?_:_+"px"}function H(e,n,_,t,o){var r;e:if("style"===n)if("string"==typeof _)e.style.cssText=_;else{if("string"==typeof t&&(e.style.cssText=t=""),t)for(n in t)_&&n in _||x(e.style,n,"");if(_)for(n in _)t&&_[n]===t[n]||x(e.style,n,_[n])}else if("o"===n[0]&&"n"===n[1])r=n!==(n=n.replace(/Capture$/,"")),n=n.toLowerCase()in e?n.toLowerCase().slice(2):n.slice(2),e.l||(e.l={}),e.l[n+r]=_,_?t||e.addEventListener(n,r?S:E,r):e.removeEventListener(n,r?S:E,r);else if("dangerouslySetInnerHTML"!==n){if(o)n=n.replace(/xlink[H:h]/,"h").replace(/sName$/,"s");else if("href"!==n&&"list"!==n&&"form"!==n&&"tabIndex"!==n&&"download"!==n&&n in e)try{e[n]=null==_?"":_;break e}catch(e){}"function"==typeof _||(null!=_&&(!1!==_||"a"===n[0]&&"r"===n[1])?e.setAttribute(n,_):e.removeAttribute(n))}}function E(e){this.l[e.type+!1](n.event?n.event(e):e)}function S(e){this.l[e.type+!0](n.event?n.event(e):e)}function P(e,_,t,o,r,u,l,i,c){var f,a,p,v,y,m,g,b,C,x,H,E=_.type;if(void 0!==_.constructor)return null;null!=t.__h&&(c=t.__h,i=_.__e=t.__e,_.__h=null,u=[i]),(f=n.__b)&&f(_);try{e:if("function"==typeof E){if(b=_.props,C=(f=E.contextType)&&o[f.__c],x=f?C?C.props.value:f.__:o,t.__c?g=(a=_.__c=t.__c).__=a.__E:("prototype"in E&&E.prototype.render?_.__c=a=new E(b,x):(_.__c=a=new d(b,x),a.constructor=E,a.render=A),C&&C.sub(a),a.props=b,a.state||(a.state={}),a.context=x,a.__n=o,p=a.__d=!0,a.__h=[]),null==a.__s&&(a.__s=a.state),null!=E.getDerivedStateFromProps&&(a.__s==a.state&&(a.__s=s({},a.__s)),s(a.__s,E.getDerivedStateFromProps(b,a.__s))),v=a.props,y=a.state,p)null==E.getDerivedStateFromProps&&null!=a.componentWillMount&&a.componentWillMount(),null!=a.componentDidMount&&a.__h.push(a.componentDidMount);else{if(null==E.getDerivedStateFromProps&&b!==v&&null!=a.componentWillReceiveProps&&a.componentWillReceiveProps(b,x),!a.__e&&null!=a.shouldComponentUpdate&&!1===a.shouldComponentUpdate(b,a.__s,x)||_.__v===t.__v){a.props=b,a.state=a.__s,_.__v!==t.__v&&(a.__d=!1),a.__v=_,_.__e=t.__e,_.__k=t.__k,_.__k.forEach(function(e){e&&(e.__=_)}),a.__h.length&&l.push(a);break e}null!=a.componentWillUpdate&&a.componentWillUpdate(b,a.__s,x),null!=a.componentDidUpdate&&a.__h.push(function(){a.componentDidUpdate(v,y,m)})}a.context=x,a.props=b,a.state=a.__s,(f=n.__r)&&f(_),a.__d=!1,a.__v=_,a.__P=e,f=a.render(a.props,a.state,a.context),a.state=a.__s,null!=a.getChildContext&&(o=s(s({},o),a.getChildContext())),p||null==a.getSnapshotBeforeUpdate||(m=a.getSnapshotBeforeUpdate(v,y)),H=null!=f&&f.type===h&&null==f.key?f.props.children:f,k(e,Array.isArray(H)?H:[H],_,t,o,r,u,l,i,c),a.base=_.__e,_.__h=null,a.__h.length&&l.push(a),g&&(a.__E=a.__=null),a.__e=!1}else null==u&&_.__v===t.__v?(_.__k=t.__k,_.__e=t.__e):_.__e=w(t.__e,_,t,o,r,u,l,c);(f=n.diffed)&&f(_)}catch(e){_.__v=null,(c||null!=u)&&(_.__e=i,_.__h=!!c,u[u.indexOf(i)]=null),n.__e(e,_,t)}}function D(e,_){n.__c&&n.__c(_,e),e.some(function(_){try{e=_.__h,_.__h=[],e.some(function(e){e.call(_)})}catch(e){n.__e(e,_.__v)}})}function w(n,_,t,o,r,u,i,c){var s,a,p,h=t.props,d=_.props,y=_.type,m=0;if("svg"===y&&(r=!0),null!=u)for(;m<u.length;m++)if((s=u[m])&&(s===n||(y?s.localName==y:3==s.nodeType))){n=s,u[m]=null;break}if(null==n){if(null===y)return document.createTextNode(d);n=r?document.createElementNS("http://www.w3.org/2000/svg",y):document.createElement(y,d.is&&d),u=null,c=!1}if(null===y)h===d||c&&n.data===d||(n.data=d);else{if(u=u&&e.call(n.childNodes),a=(h=t.props||l).dangerouslySetInnerHTML,p=d.dangerouslySetInnerHTML,!c){if(null!=u)for(h={},m=0;m<n.attributes.length;m++)h[n.attributes[m].name]=n.attributes[m].value;(p||a)&&(p&&(a&&p.__html==a.__html||p.__html===n.innerHTML)||(n.innerHTML=p&&p.__html||""))}if(function(e,n,_,t,o){var r;for(r in _)"children"===r||"key"===r||r in n||H(e,r,null,_[r],t);for(r in n)o&&"function"!=typeof n[r]||"children"===r||"key"===r||"value"===r||"checked"===r||_[r]===n[r]||H(e,r,n[r],_[r],t)}(n,d,h,r,c),p)_.__k=[];else if(m=_.props.children,k(n,Array.isArray(m)?m:[m],_,t,o,r&&"foreignObject"!==y,u,i,u?u[0]:t.__k&&v(t,0),c),null!=u)for(m=u.length;m--;)null!=u[m]&&f(u[m]);c||("value"in d&&void 0!==(m=d.value)&&(m!==n.value||"progress"===y&&!m)&&H(n,"value",m,h.value,!1),"checked"in d&&void 0!==(m=d.checked)&&m!==n.checked&&H(n,"checked",m,h.checked,!1))}return n}function T(e,_,t){try{"function"==typeof e?e(_):e.current=_}catch(e){n.__e(e,t)}}function U(e,_,t){var o,r;if(n.unmount&&n.unmount(e),(o=e.ref)&&(o.current&&o.current!==e.__e||T(o,null,_)),null!=(o=e.__c)){if(o.componentWillUnmount)try{o.componentWillUnmount()}catch(e){n.__e(e,_)}o.base=o.__P=null}if(o=e.__k)for(r=0;r<o.length;r++)o[r]&&U(o[r],_,"function"!=typeof e.type);t||null==e.__e||f(e.__e),e.__e=e.__d=void 0}function A(e,n,_){return this.constructor(e,_)}function M(_,t,o){var r,u,i;n.__&&n.__(_,t),u=(r="function"==typeof o)?null:o&&o.__k||t.__k,i=[],P(t,_=(!r&&o||t).__k=a(h,null,[_]),u||l,l,void 0!==t.ownerSVGElement,!r&&o?[o]:u?null:t.firstChild?e.call(t.childNodes):null,i,!r&&o?o:u?u.__e:t.firstChild,r),D(i,_)}function F(e,n){var _={__c:n="__cC"+u++,__:e,Consumer:function(e,n){return e.children(n)},Provider:function(e){var _,t;return this.getChildContext||(_=[],(t={})[n]=this,this.getChildContext=function(){return t},this.shouldComponentUpdate=function(e){this.props.value!==e.value&&_.some(m)},this.sub=function(e){_.push(e);var n=e.componentWillUnmount;e.componentWillUnmount=function(){_.splice(_.indexOf(e),1),n&&n.call(e)}}),e.children}};return _.Provider.__=_.Consumer.contextType=_}e=i.slice,n={__e:function(e,n){for(var _,t,o;n=n.__;)if((_=n.__c)&&!_.__)try{if((t=_.constructor)&&null!=t.getDerivedStateFromError&&(_.setState(t.getDerivedStateFromError(e)),o=_.__d),null!=_.componentDidCatch&&(_.componentDidCatch(e),o=_.__d),o)return _.__E=_}catch(n){e=n}throw e}},_=0,d.prototype.setState=function(e,n){var _;_=null!=this.__s&&this.__s!==this.state?this.__s:this.__s=s({},this.state),"function"==typeof e&&(e=e(s({},_),this.props)),e&&s(_,e),null!=e&&this.__v&&(n&&this.__h.push(n),m(this))},d.prototype.forceUpdate=function(e){this.__v&&(this.__e=!0,e&&this.__h.push(e),m(this))},d.prototype.render=h,t=[],o="function"==typeof Promise?Promise.prototype.then.bind(Promise.resolve()):setTimeout,g.__r=0,u=0;var L,N,W,R=0,I=[],O=n.__b,V=n.__r,q=n.diffed,B=n.__c,$=n.unmount;function j(e,_){n.__h&&n.__h(N,e,R||_),R=0;var t=N.__H||(N.__H={__:[],__h:[]});return e>=t.__.length&&t.__.push({}),t.__[e]}function G(e){return R=1,z(ie,e)}function z(e,n,_){var t=j(L++,2);return t.t=e,t.__c||(t.__=[_?_(n):ie(void 0,n),function(e){var n=t.t(t.__[0],e);t.__[0]!==n&&(t.__=[n,t.__[1]],t.__c.setState({}))}],t.__c=N),t.__}function J(e,_){var t=j(L++,3);!n.__s&&le(t.__H,_)&&(t.__=e,t.__H=_,N.__H.__h.push(t))}function K(e,_){var t=j(L++,4);!n.__s&&le(t.__H,_)&&(t.__=e,t.__H=_,N.__h.push(t))}function Q(e){return R=5,Y(function(){return{current:e}},[])}function X(e,n,_){R=6,K(function(){"function"==typeof e?e(n()):e&&(e.current=n())},null==_?_:_.concat(e))}function Y(e,n){var _=j(L++,7);return le(_.__H,n)&&(_.__=e(),_.__H=n,_.__h=e),_.__}function Z(e,n){return R=8,Y(function(){return e},n)}function ee(e){var n=N.context[e.__c],_=j(L++,9);return _.c=e,n?(null==_.__&&(_.__=!0,n.sub(N)),n.props.value):e.__}function ne(e,_){n.useDebugValue&&n.useDebugValue(_?_(e):e)}function _e(e){var n=j(L++,10),_=G();return n.__=e,N.componentDidCatch||(N.componentDidCatch=function(e){n.__&&n.__(e),_[1](e)}),[_[0],function(){_[1](void 0)}]}function te(){I.forEach(function(e){if(e.__P)try{e.__H.__h.forEach(re),e.__H.__h.forEach(ue),e.__H.__h=[]}catch(_){e.__H.__h=[],n.__e(_,e.__v)}}),I=[]}n.__b=function(e){N=null,O&&O(e)},n.__r=function(e){V&&V(e),L=0;var n=(N=e.__c).__H;n&&(n.__h.forEach(re),n.__h.forEach(ue),n.__h=[])},n.diffed=function(e){q&&q(e);var _=e.__c;_&&_.__H&&_.__H.__h.length&&(1!==I.push(_)&&W===n.requestAnimationFrame||((W=n.requestAnimationFrame)||function(e){var n,_=function(){clearTimeout(t),oe&&cancelAnimationFrame(n),setTimeout(e)},t=setTimeout(_,100);oe&&(n=requestAnimationFrame(_))})(te)),N=void 0},n.__c=function(e,_){_.some(function(e){try{e.__h.forEach(re),e.__h=e.__h.filter(function(e){return!e.__||ue(e)})}catch(t){_.some(function(e){e.__h&&(e.__h=[])}),_=[],n.__e(t,e.__v)}}),B&&B(e,_)},n.unmount=function(e){$&&$(e);var _=e.__c;if(_&&_.__H)try{_.__H.__.forEach(re)}catch(e){n.__e(e,_.__v)}};var oe="function"==typeof requestAnimationFrame;function re(e){var n=N;"function"==typeof e.__c&&e.__c(),N=n}function ue(e){var n=N;e.__c=e.__(),N=n}function le(e,n){return!e||e.length!==n.length||n.some(function(n,_){return n!==e[_]})}function ie(e,n){return"function"==typeof n?n(e):n}var ce=function(e,n,_,t){var o;n[0]=0;for(var r=1;r<n.length;r++){var u=n[r++],l=n[r]?(n[0]|=u?1:2,_[n[r++]]):n[++r];3===u?t[0]=l:4===u?t[1]=Object.assign(t[1]||{},l):5===u?(t[1]=t[1]||{})[n[++r]]=l:6===u?t[1][n[++r]]+=l+"":u?(o=e.apply(l,ce(e,l,_,["",null])),t.push(o),l[0]?n[0]|=2:(n[r-2]=0,n[r]=o)):t.push(l)}return t},se=new Map,fe=function(e){var n=se.get(this);return n||(n=new Map,se.set(this,n)),(n=ce(this,n.get(e)||(n.set(e,n=function(e){for(var n,_,t=1,o="",r="",u=[0],l=function(e){1===t&&(e||(o=o.replace(/^\s*\n\s*|\s*\n\s*$/g,"")))?u.push(0,e,o):3===t&&(e||o)?(u.push(3,e,o),t=2):2===t&&"..."===o&&e?u.push(4,e,0):2===t&&o&&!e?u.push(5,0,!0,o):t>=5&&((o||!e&&5===t)&&(u.push(t,0,o,_),t=6),e&&(u.push(t,e,0,_),t=6)),o=""},i=0;i<e.length;i++){i&&(1===t&&l(),l(i));for(var c=0;c<e[i].length;c++)n=e[i][c],1===t?"<"===n?(l(),u=[u],t=3):o+=n:4===t?"--"===o&&">"===n?(t=1,o=""):o=n+o[0]:r?n===r?r="":o+=n:'"'===n||"'"===n?r=n:">"===n?(l(),t=1):t&&("="===n?(t=5,_=o,o=""):"/"===n&&(t<5||">"===e[i][c+1])?(l(),3===t&&(u=u[0]),t=u,(u=u[0]).push(2,0,t),t=0):" "===n||"\t"===n||"\n"===n||"\r"===n?(l(),t=2):o+=n),3===t&&"!--"===o&&(t=4,u=u[0])}return l(),u}(e)),n),arguments,[])).length>1?n:n[0]}.bind(a);export{a as h,fe as html,M as render,d as Component,F as createContext,G as useState,z as useReducer,J as useEffect,K as useLayoutEffect,Q as useRef,X as useImperativeHandle,Y as useMemo,Z as useCallback,ee as useContext,ne as useDebugValue,_e as useErrorBoundary};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html b/packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html
index 0d5750b..46003a7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html
+++ b/packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html
@@ -68,7 +68,7 @@
<div id="log"></div>
</div>
-<script src="/transport.js"></script>
+<script src="/transport.js?v=2"></script>
<script>
const HUB_URL = window.location.origin;
const params = new URLSearchParams(window.location.search);
@@ -76,13 +76,14 @@ let accessToken = null;
let jwtToken = null;
let transport = null;
let fileIndex = null;
+let connecting = false;
// Pre-fill from URL params
if (params.get('user')) document.getElementById('username').value = params.get('user');
if (params.get('pass')) document.getElementById('password').value = params.get('pass');
if (params.get('node')) document.getElementById('node-id').value = params.get('node');
if (params.get('group')) document.getElementById('group-id').value = params.get('group');
-if (params.get('file')) document.getElementById('file-id').value = params.get('file');
+if (params.get('file')) document.getElementById('file-id').value = params.get('file').replace(/\s+/g, '');
// Auto-run if all params provided
if (params.get('auto')) {
@@ -143,10 +144,14 @@ async function doLogin() {
}
async function doConnect() {
+ if (connecting) { logMsg('warn', 'Connect already in progress'); return; }
const nodeId = document.getElementById('node-id').value;
const groupId = document.getElementById('group-id').value;
if (!nodeId) { logMsg('warn', 'Enter a node ID'); return; }
+ connecting = true;
+ if (transport) { transport.close(); transport = null; }
+
logMsg('info', `Connecting to node ${nodeId.substring(0, 8)}... via WebRTC`);
setStep('step-connect', 'active');
@@ -164,7 +169,7 @@ async function doConnect() {
logMsg('ok', `WebRTC connected in ${elapsed}ms`);
logMsg('ok', ` MNP handshake_ack — node_pk: ${ack.node_pk?.substring(0, 16)}...`);
- logMsg('ok', ` DataChannel: open, ordered, reliable`);
+ logMsg('ok', ` DataChannel state: ${transport._channel?.readyState}`);
setStep('step-connect', 'done');
document.getElementById('connect-status').innerHTML = '<span class="badge">P2P OK</span>';
document.getElementById('btn-index').disabled = false;
@@ -173,11 +178,13 @@ async function doConnect() {
logMsg('err', `Connection FAILED: ${e.message}`);
setStep('step-connect', 'fail');
document.getElementById('connect-status').innerHTML = '<span class="badge fail">FAIL</span>';
+ } finally {
+ connecting = false;
}
}
async function doFetchIndex() {
- logMsg('info', 'Fetching Mesh Group Index via DataChannel...');
+ logMsg('info', `Fetching Mesh Group Index... (channel: ${transport?._channel?.readyState})`);
try {
const t0 = performance.now();
const indexBytes = await transport.fetchIndex();
@@ -210,7 +217,7 @@ async function doFetchIndex() {
}
async function doFetchChunk() {
- let fileId = document.getElementById('file-id').value.trim();
+ let fileId = document.getElementById('file-id').value.replace(/\s+/g, '');
if (!fileId) {
logMsg('warn', 'Enter a file_id (blake3 hex hash from node indexer log)');
@@ -219,7 +226,7 @@ async function doFetchChunk() {
return;
}
- logMsg('info', `Fetching chunk 0 of file ${fileId.substring(0, 16)}... via DataChannel...`);
+ logMsg('info', `Fetching chunk 0 of ${fileId.substring(0, 16)}... (channel: ${transport?._channel?.readyState})`);
try {
const t0 = performance.now();
diff --git a/packages/meshbay-hub/tests/test_hub_api.py b/packages/meshbay-hub/tests/test_hub_api.py
index a9c97a3..f7c499e 100644
--- a/packages/meshbay-hub/tests/test_hub_api.py
+++ b/packages/meshbay-hub/tests/test_hub_api.py
@@ -351,6 +351,131 @@ async def test_jwt_contains_groups_claim(client):
assert group_id in decoded_alice["groups"]
+# ── My groups (9.6) ─────────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_my_groups(client):
+ """GET /v1/groups/mine returns groups the user belongs to."""
+ pk_ed_a, pk_x_a, _ = _gen_user_keys()
+ pk_ed_b, pk_x_b, _ = _gen_user_keys()
+
+ await client.post("/v1/users/register", json={
+ "username": "mg_alice", "email": "mga@x.com", "password": "alicepass99",
+ "pk_user_ed25519": pk_ed_a, "pk_user_x25519": pk_x_a})
+ await client.post("/v1/users/register", json={
+ "username": "mg_bob", "email": "mgb@x.com", "password": "bobpass99",
+ "pk_user_ed25519": pk_ed_b, "pk_user_x25519": pk_x_b})
+
+ alice_token = (await client.post("/v1/users/login",
+ json={"username": "mg_alice", "password": "alicepass99"})).json()["access_token"]
+ bob_token = (await client.post("/v1/users/login",
+ json={"username": "mg_bob", "password": "bobpass99"})).json()["access_token"]
+
+ # Bob has no groups initially
+ r = await client.get("/v1/groups/mine",
+ headers={"Authorization": f"Bearer {bob_token}"})
+ assert r.status_code == 200
+ assert r.json()["groups"] == []
+
+ # Alice creates a group and adds Bob
+ r = await client.post("/v1/groups", json={"name": "mg-group"},
+ headers={"Authorization": f"Bearer {alice_token}"})
+ group_id = r.json()["group_id"]
+ gek = generate_gek()
+ bundle = wrap_gek(gek, base64.b64decode(pk_x_b))
+ await client.post(f"/v1/groups/{group_id}/members/mg_bob/gek",
+ json=bundle,
+ headers={"Authorization": f"Bearer {alice_token}"})
+
+ # Re-login to get fresh token with group claims
+ bob_token = (await client.post("/v1/users/login",
+ json={"username": "mg_bob", "password": "bobpass99"})).json()["access_token"]
+
+ # Now Bob should see the group
+ r = await client.get("/v1/groups/mine",
+ headers={"Authorization": f"Bearer {bob_token}"})
+ assert r.status_code == 200
+ groups = r.json()["groups"]
+ assert len(groups) == 1
+ assert groups[0]["id"] == group_id
+ assert groups[0]["name"] == "mg-group"
+ assert groups[0]["is_admin"] is False
+
+ # Alice should see it too, with is_admin=True
+ alice_token = (await client.post("/v1/users/login",
+ json={"username": "mg_alice", "password": "alicepass99"})).json()["access_token"]
+ r = await client.get("/v1/groups/mine",
+ headers={"Authorization": f"Bearer {alice_token}"})
+ groups = r.json()["groups"]
+ assert any(g["id"] == group_id and g["is_admin"] for g in groups)
+
+ # Unauthenticated → rejected
+ r = await client.get("/v1/groups/mine")
+ assert r.status_code >= 400
+
+
+@pytest.mark.asyncio
+async def test_group_online_nodes(client):
+ """GET /v1/groups/{id}/nodes returns online nodes serving the group."""
+ import json
+ from meshbay_hub.api.revocation import _connected_nodes, _node_groups
+
+ pk_ed, pk_x, _ = _gen_user_keys()
+ await client.post("/v1/users/register", json={
+ "username": "gn_user", "email": "gn@x.com", "password": "gnpass999",
+ "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x})
+ r = await client.post("/v1/users/login",
+ json={"username": "gn_user", "password": "gnpass999"})
+ token = r.json()["access_token"]
+
+ r = await client.post("/v1/groups", json={"name": "gn-group"},
+ headers={"Authorization": f"Bearer {token}"})
+ group_id = r.json()["group_id"]
+
+ # Re-login to get fresh token with group claims
+ token = (await client.post("/v1/users/login",
+ json={"username": "gn_user", "password": "gnpass999"})).json()["access_token"]
+
+ # Announce a node
+ r = await client.post("/v1/nodes/announce", json={
+ "pk_node": pk_ed, "endpoint_hint": "1.2.3.4:19000"},
+ headers={"Authorization": f"Bearer {token}"})
+ node_id = r.json()["node_id"]
+
+ # No nodes online yet
+ r = await client.get(f"/v1/groups/{group_id}/nodes",
+ headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 200
+ assert r.json()["nodes"] == []
+
+ # Simulate node connecting via WS with group_ids
+ class FakeWS:
+ async def send_text(self, text): pass
+ _connected_nodes[node_id] = FakeWS()
+ _node_groups[node_id] = [group_id]
+
+ try:
+ r = await client.get(f"/v1/groups/{group_id}/nodes",
+ headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 200
+ nodes = r.json()["nodes"]
+ assert len(nodes) == 1
+ assert nodes[0]["node_id"] == node_id
+ assert nodes[0]["pk_node"] == pk_ed
+ finally:
+ _connected_nodes.pop(node_id, None)
+ _node_groups.pop(node_id, None)
+
+ # 404 for nonexistent group
+ r = await client.get("/v1/groups/fake-id/nodes",
+ headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 404
+
+ # Unauthenticated → rejected
+ r = await client.get(f"/v1/groups/{group_id}/nodes")
+ assert r.status_code >= 400
+
+
# ── Admin authz (8.1) ───────────────────────────────────────────────────────
@pytest.mark.asyncio
@@ -578,3 +703,22 @@ async def test_ip_log_cleanup(app):
)).scalar_one()
assert count == 1
break
+
+
+# ── Webapp HTML shell (9.13) ─────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_webapp_html_includes_scripts(client):
+ """SPA HTML shell includes all required script tags."""
+ r = await client.get("/")
+ assert r.status_code == 200
+ html = r.text
+ assert "<!DOCTYPE html>" in html
+ assert '<div id="app">' in html
+ assert 'src="/keyderive.js"' in html
+ assert 'src="/crypto.js"' in html
+ assert 'src="/transport.js"' in html
+ assert 'src="/app.js"' in html
+ assert 'type="module"' in html
+ assert 'rel="stylesheet"' in html
+ assert 'href="/style.css"' in html