summaryrefslogtreecommitdiffstats
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
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>
-rw-r--r--CLAUDE.md15
-rw-r--r--devel-phases-next.md64
-rw-r--r--devel-phases.md70
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py4
-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
-rw-r--r--packages/meshbay-node/src/meshbay_node/hub_client.py8
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/group_index.py8
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/indexer.py51
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/http_server.py5
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/quic_client.py7
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/quic_server.py7
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/server.py2
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py125
-rw-r--r--packages/meshbay-node/tests/test_http_server.py2
-rw-r--r--packages/meshbay-node/tests/test_indexer.py2
-rw-r--r--packages/meshbay-node/tests/test_webrtc_transport.py439
27 files changed, 3039 insertions, 316 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 8cc9db9..518dcea 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -132,7 +132,7 @@ SFR residential Fedora 44 → meshbay.org OVH VPS:
### WebRTC browser clients (Phase 9 spike, 2026-08-10)
-Mobile 4G SFR → node behind SFR residential NAT (Port-Restricted Cone + CGNAT 4G):
+**SFR residential NAT** — Mobile 4G SFR → node behind SFR residential (Port-Restricted Cone + CGNAT 4G):
| Test | ICE path | Result |
|---|---|---|
@@ -140,6 +140,15 @@ Mobile 4G SFR → node behind SFR residential NAT (Port-Restricted Cone + CGNAT
| 4G + IPv6 | IPv6 inter-network | OK, ~600ms |
| 4G + IPv4 only (IPv6 disabled) | STUN hole-punch IPv4 | OK, ~650ms |
+**Orange Livebox NAT** — Firefox/Chrome laptop (SFR) → node behind Orange residential NAT:
+
+| Test | ICE path | Result |
+|---|---|---|
+| Chrome laptop → Orange node | IPv6 inter-network | OK, ~7000ms |
+| Firefox laptop → Orange node | IPv6 inter-network | OK, ~6700ms |
+| Firefox laptop → Orange node (IPv6 disabled) | STUN hole-punch IPv4 | OK, ~6900ms |
+
+- **Two ISPs validated** — SFR + Orange residential NAT, both work without TURN
- **No TURN relay needed** — ICE/STUN handles both NAT types automatically
- **Hub role**: signaling only (SDP/ICE relay via WebSocket, <1 KB)
- **Data path**: browser ↔ node P2P via WebRTC DataChannel
@@ -162,7 +171,9 @@ Mobile 4G SFR → node behind SFR residential NAT (Port-Restricted Cone + CGNAT
| WebRTC signaling (hub) | `meshbay_hub.api.signaling` | Phase 9.2 — SDP/ICE relay |
| Browser transport client | `static/transport.js` | Phase 9.4 — WebRTC DataChannel |
| Web SPA | `static/app.js` | Phase 9.6 — Preact + preact-router |
-| Demo scripts | — | `QE/demo-v1/*.py`, `QE/demo-v2/*.py` (not versioned) |
+| File download (large) | `static/app.js` | File System Access API (`showSaveFilePicker`) — stream to disk |
+| i18n (browser) | `static/i18n.js` | `t()` lookup, ESM, localStorage lang selection |
+| Demo scripts | — | `QE/demo-v1/*.py`, `QE/demo-v2/*.py`, `QE/demo-v3/*.py` (not versioned) |
## meshbay.org server (état cible)
diff --git a/devel-phases-next.md b/devel-phases-next.md
index 09d07fa..b2806bd 100644
--- a/devel-phases-next.md
+++ b/devel-phases-next.md
@@ -1,6 +1,6 @@
# MeshBay — Next Implementation Phases
-> Base: Phases 1–8 complete. 117 tests. demo-v3 validated against meshbay.org.
+> Base: Phases 1–9 complete. 132 tests. Web SPA live on meshbay.org.
> Architecture reference: docs/meshbay-draft-v4.md
> First security review: first-review.md (2026-08-10)
@@ -42,7 +42,10 @@ Deployed to meshbay.org. Existing emails encrypted. DB schema migrated.
---
-## Phase 9 — Web client: WebRTC transport + core SPA
+## Phase 9 — Web client: WebRTC transport + core SPA ✅ DONE
+
+Commit: TBD — 23 files, ~2000 lines, 132 tests.
+Deployed to meshbay.org + Orange node. Tested browser → node P2P through two ISP NATs.
**Objective:** a web browser can connect P2P to a node behind residential NAT,
browse files, download, stream video, and chat — with zero data through the hub.
@@ -134,17 +137,23 @@ Content is not indexed on the hub. Search works client-side:
| 9.3 | WebRTC→MNP transport adapter on node | `node/transport/webrtc_server.py` + hub_client WebRTC handler | ✅ |
| 9.4 | `transport.js` — browser WebRTC client | `static/transport.js` — connect, handshake, fetch, msgpack | ✅ |
| 9.5 | **Spike: E2E browser→NAT→node file transfer** | Mobile 4G → SFR NAT → node, IPv4 STUN + IPv6 validated | ✅ |
-| 9.6 | Preact SPA shell (login, routing, theme) | `static/app.js`, `static/css/style.css` | High |
-| 9.7 | Group list + file explorer UI | `static/components/GroupList.js`, `FileExplorer.js` | High |
-| 9.8 | File download via DataChannel | `static/components/Download.js` — chunk reassembly | High |
-| 9.9 | Video streaming via DataChannel | HLS segments → MediaSource API | Medium |
-| 9.10 | Chat/forum UI via DataChannel | `static/components/ChatView.js` — Sender Keys | Medium |
-| 9.11 | i18n framework + English strings | `static/i18n/en.json` | Medium |
-| 9.12 | Settings UI (profile, theme, notifications) | `static/components/Settings.js` | Medium |
-| 9.13 | Tests: unit + integration | WebRTC transport, MNP over DataChannel | High |
+| 9.6 | Preact SPA shell (login, routing, theme) | `static/app.js`, `static/style.css`, `static/vendor/htm-preact.js` | ✅ |
+| 9.7 | Group list + file explorer UI | `app.js` GroupPage, `groups.py` nodes endpoint, `revocation.py` group tracking | ✅ |
+| 9.8 | File download via DataChannel | AES-GCM chunks, GEK delivery, progress bar, browser download | ✅ |
+| 9.9 | Video streaming via DataChannel | Chunk download → Blob URL, video overlay with native controls | ✅ |
+| 9.10 | Chat/forum UI via DataChannel | ChatPanel component, chat history MNP, peer broadcast, tabs UI | ✅ |
+| 9.11 | i18n framework + English strings | `static/i18n.js` — t() lookup, ESM, localStorage lang, all strings extracted | ✅ |
+| 9.12 | Settings UI (profile, theme, language) | SettingsPage component, system theme support, sidebar link | ✅ |
+| 9.13 | Tests: unit + integration | WebRTC transport, MNP over DataChannel | ✅ |
+| 9.14 | Performance: pipelined download | sliding window (8 concurrent chunks) | ✅ |
+| 9.15 | Performance: binary wire format | raw bytes via msgpack, no base64 (+33%) | ✅ |
+| 9.16 | Performance: avoid redundant I/O | file_hash from index, not re-read per chunk | ✅ |
+| 9.17 | Large file download to disk | File System Access API (`showSaveFilePicker`) | ✅ |
**Critical path validated (2026-08-10):** 9.1 → 9.5 all pass. WebRTC DataChannel
-works browser → node through SFR residential NAT, confirmed with three scenarios:
+works browser → node through two different ISP residential NATs:
+
+**SFR residential NAT** (mobile 4G → node behind SFR Port-Restricted Cone + CGNAT):
| Test | ICE path | Result |
|---|---|---|
@@ -152,8 +161,37 @@ works browser → node through SFR residential NAT, confirmed with three scenari
| Mobile 4G SFR + IPv6 | IPv6 inter-network | OK, ~600ms |
| Mobile 4G SFR + IPv4 only | STUN hole-punch IPv4 | OK, ~650ms |
-Node behind SFR Port-Restricted Cone NAT + mobile behind SFR CGNAT 4G.
-No TURN relay needed. ICE/STUN handles both NAT types automatically.
+**Orange Livebox NAT** (laptop browser → node behind Orange residential NAT, cross-site):
+
+| Test | ICE path | Result |
+|---|---|---|
+| Chrome laptop → Orange node | IPv6 inter-network | OK, ~7000ms |
+| Firefox laptop → Orange node | IPv6 inter-network | OK, ~6700ms |
+| Firefox laptop → Orange node (IPv6 disabled) | STUN hole-punch IPv4 | OK, ~6900ms |
+
+Two ISPs validated, both Chrome and Firefox. No TURN relay needed.
+ICE/STUN handles all tested NAT types automatically.
+
+**Performance optimizations (2026-08-11):**
+- Initial transfer speed: ~2 MB/s (sequential, base64, redundant I/O)
+- After file_hash fix (9.16): ~3 MB/s (eliminated 78 GB redundant reads on 279 MB file)
+- After pipelining (9.14): ~5 MB/s (8-chunk sliding window, concurrent requests)
+- After binary wire format (9.15): eliminated 33% base64 inflation + removed
+ redundant per-chunk fields (sig, hashes, pk_node) — AES-GCM tag already
+ authenticates ciphertext, DTLS authenticates transport
+- Large file support (9.17): `showSaveFilePicker` (Chrome/Edge) streams decrypted
+ chunks directly to disk — flat ~8 MB RAM regardless of file size. Firefox/Safari
+ fall back to Blob-in-RAM approach.
+
+**Indexer debounce (2026-08-11):**
+- File copy triggers multiple watchdog events at different file sizes → duplicate
+ index entries with different blake3 hashes. Fixed with 2-second debounce +
+ path-based dedup (remove old entry before adding new).
+
+**Known remaining items for future phases:**
+- True video streaming (MSE or Service Worker) — currently downloads full file first
+- Multiple shared directories per node (UI + config)
+- Multi-node per user support
**Dependencies added:**
- `aiortc>=1.9` in `meshbay-node/pyproject.toml` ✅
diff --git a/devel-phases.md b/devel-phases.md
index f1735e4..b5bef49 100644
--- a/devel-phases.md
+++ b/devel-phases.md
@@ -325,6 +325,76 @@ persistent MHP federation, content replication, and browser private group decryp
---
+## Phase 7 — Node v2: production, streaming, chat ✅ DONE
+
+**Goal:** multi-group node, Sender Keys chat, QUIC 0-RTT, jti denylist push, HLS streaming.
+
+Commit: fc56585 — 26 files, +2155/−159 lines, 109 tests.
+
+See `devel-phases-next.md` for details.
+
+---
+
+## Phase 8 — Hub v2: admin, federation, security ✅ DONE
+
+**Goal:** admin roles, email encryption, refresh token rotation, rate limiting, healthcheck.
+
+Commit: 46918ec — 20 files, +508/−90 lines, 117 tests.
+Deployed to meshbay.org. All security review items S1/S2/S5 resolved.
+
+See `devel-phases-next.md` for details.
+
+---
+
+## Phase 9 — Web client: WebRTC transport + core SPA ✅ DONE
+
+**Goal:** browser connects P2P to a node behind residential NAT via WebRTC DataChannel.
+Full SPA: login, groups, file browser, download, video playback, chat, i18n, dark/light.
+
+### Milestones
+
+| # | Component | Status |
+|---|---|---|
+| 9.1–9.5 | WebRTC DataChannel spike + E2E NAT validation | ✅ |
+| 9.6–9.12 | Preact SPA (login, groups, files, video, chat, i18n, settings) | ✅ |
+| 9.13 | Tests: 132 passing | ✅ |
+| 9.14–9.16 | Performance: pipelining, binary wire format, I/O reduction | ✅ |
+| 9.17 | Large file download: File System Access API (stream to disk) | ✅ |
+
+**Total: 132/132 tests. Deployed to meshbay.org + Orange node (2026-08-11).**
+
+### Key technical decisions
+
+- **Transport:** WebRTC DataChannel (aiortc on node) — browsers can't use QUIC for NAT traversal
+- **Wire format:** length-prefixed msgpack, binary chunk fields (no base64)
+- **UI:** Preact + htm ESM (vendored, no build step, no CDN, no npm)
+- **Crypto:** WebCrypto SubtleCrypto AES-256-GCM for E2E chunk decryption in browser
+- **Large files:** File System Access API (`showSaveFilePicker`) — stream to disk, ~8 MB RAM
+- **Indexer:** 2s debounce + path-based dedup for file copy events
+
+### NAT traversal validated
+
+Two ISPs (SFR + Orange residential NAT), Chrome + Firefox, IPv4 STUN + IPv6 direct.
+No TURN relay needed. See `devel-phases-next.md` for detailed test matrix.
+
+### QE deployment state (2026-08-11)
+
+- **Hub (meshbay.org):** running as `meshbay-hub.service`, DB has 3 users
+ (admin, cbesson, grenet), 1 group (`d3bbd90b`), `admin_usernames = ["admin"]`
+- **Node (Orange host via `ssh cbesson@localhost -p 2222`):** running as
+ `nohup .venv/bin/python3 QE/demo-v3/run_node_simple.py`, user grenet,
+ connected via WS to hub, WebRTC + QUIC dual transport
+- **Credentials:** `QE/demo-v3/creds.json` (not versioned)
+- **Shared dir on node:** `~/meshbay/QE/demo-v3/shared/`
+- All 3 users password: see creds.json
+
+### Dependencies added
+
+- `aiortc>=1.9` in meshbay-node (WebRTC DataChannel)
+- `preact` + `htm` vendored as `static/vendor/htm-preact.js` (ESM, ~3 KB gzipped)
+
+---
+
## Conventions
- Commits: `feat(node):`, `fix(hub):`, `chore(common):`, `docs:`, `test(node):`
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py
index c3f2b1a..fbf871a 100644
--- a/packages/meshbay-common/src/meshbay_common/protocol.py
+++ b/packages/meshbay-common/src/meshbay_common/protocol.py
@@ -27,6 +27,10 @@ class MNP:
STREAM_SEGMENT = "stream_seg" # HLS/DASH segment
CHAT_MESSAGE = "chat_msg" # Double Ratchet message
CHAT_ATTACHMENT = "chat_attach" # attachment metadata
+ CHAT_HISTORY = "chat_hist" # request message history
+ CHAT_HISTORY_RESPONSE = "chat_hist_resp" # history response with messages
+ GEK_REQUEST = "gek_req" # browser requests group GEK
+ GEK_RESPONSE = "gek_resp" # node delivers GEK over secure channel
EPHEMERAL_STREAM = "ephemeral_stream" # reserved — mobile live push
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
diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py
index 3e77ed0..a9a1d6c 100644
--- a/packages/meshbay-node/src/meshbay_node/hub_client.py
+++ b/packages/meshbay-node/src/meshbay_node/hub_client.py
@@ -251,6 +251,7 @@ class HubClient:
on_incoming: Any = None,
on_revocation: Any = None,
on_webrtc_offer: Any = None,
+ group_ids: list[str] | None = None,
) -> None:
"""
Maintain a persistent WebSocket connection to the hub.
@@ -268,11 +269,14 @@ class HubClient:
while True:
try:
async with websockets.connect(ws_url) as ws:
- await ws.send(json.dumps({
+ auth_msg = {
"type": "auth",
"token": self._session.access_token,
"node_id": self._session.node_id,
- }))
+ }
+ if group_ids:
+ auth_msg["group_ids"] = group_ids
+ await ws.send(json.dumps(auth_msg))
auth_resp = json.loads(await ws.recv())
if auth_resp.get("type") != "auth_ok":
log.error("WS auth failed: %s", auth_resp)
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/group_index.py b/packages/meshbay-node/src/meshbay_node/indexer/group_index.py
index 4edeeab..a69429c 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/group_index.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/group_index.py
@@ -25,14 +25,16 @@ import zstandard as zstd
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from meshbay_common.crypto import (
- chunk_key as derive_chunk_key,
- encrypt_chunk,
- decrypt_chunk,
sign_chunk,
verify_chunk_signature,
pk_to_b64,
generate_gek,
)
+from meshbay_common.webcrypto import (
+ chunk_key_aes as derive_chunk_key,
+ encrypt_chunk_aes as encrypt_chunk,
+ decrypt_chunk_aes as decrypt_chunk,
+)
from meshbay_common.protocol import IndexEntry, IndexDelta
log = logging.getLogger(__name__)
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
index 4bb1527..60dc04b 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
@@ -169,30 +169,49 @@ class DirectoryIndexer:
# ── Internal update ───────────────────────────────────────────────────────
+ _DEBOUNCE_SECS = 2.0
+
def _schedule_update(self, file_path: Path, deleted: bool = False) -> None:
- """Called from watchdog thread — schedule async update on the event loop."""
- if self._loop:
- self._loop.call_soon_threadsafe(
- lambda: asyncio.ensure_future(
- self._update_entry(file_path, deleted)))
+ """Called from watchdog thread — schedule debounced async update."""
+ if not self._loop:
+ return
+ key = str(file_path.resolve())
+ self._loop.call_soon_threadsafe(
+ self._debounce, key, file_path, deleted)
+
+ def _debounce(self, key: str, file_path: Path, deleted: bool) -> None:
+ if not hasattr(self, "_pending_timers"):
+ self._pending_timers: dict[str, asyncio.TimerHandle] = {}
+ old = self._pending_timers.pop(key, None)
+ if old:
+ old.cancel()
+ handle = self._loop.call_later(
+ self._DEBOUNCE_SECS,
+ lambda: asyncio.ensure_future(self._update_entry(file_path, deleted)),
+ )
+ self._pending_timers[key] = handle
+
+ def _remove_by_path(self, file_path: Path) -> None:
+ """Remove any existing entries that match this file's path + name."""
+ resolved = file_path.resolve()
+ to_remove = [
+ e.id for e in self._index.entries
+ if (self.root / e.path / e.name).resolve() == resolved
+ ]
+ for fid in to_remove:
+ self._index.remove_entry(fid)
async def _update_entry(self, file_path: Path, deleted: bool) -> None:
- if deleted:
- # Remove by matching path (hash not available after deletion)
- to_remove = [
- e.id for e in self._index.entries
- if (self.root / e.path / e.name).resolve() == file_path.resolve()
- ]
- for fid in to_remove:
- self._index.remove_entry(fid)
- log.debug("Removed from index: %s", file_path.name)
- else:
+ self._remove_by_path(file_path)
+
+ if not deleted:
loop = asyncio.get_event_loop()
entry = await loop.run_in_executor(
self._executor, _scan_file, self.root, file_path)
if entry:
self._index.add_entry(entry)
- log.debug("Indexed: %s (%s)", file_path.name, entry.id[:8])
+ log.debug("Indexed: %s (%s, %d bytes)",
+ file_path.name, entry.id[:8], entry.size)
self._index.version = int(time.time())
if self.on_change:
diff --git a/packages/meshbay-node/src/meshbay_node/transport/http_server.py b/packages/meshbay-node/src/meshbay_node/transport/http_server.py
index 7554086..151c2e8 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/http_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/http_server.py
@@ -37,7 +37,8 @@ from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from meshbay_common import MNP_VERSION
-from meshbay_common.crypto import chunk_key as derive_chunk_key, encrypt_chunk, sign_chunk, pk_to_b64
+from meshbay_common.crypto import sign_chunk, pk_to_b64
+from meshbay_common.webcrypto import chunk_key_aes as derive_chunk_key, encrypt_chunk_aes as encrypt_chunk
from meshbay_node import __version__
from meshbay_node.indexer import GroupIndex
from meshbay_node.indexer.group_index import GroupIndex
@@ -189,7 +190,7 @@ def create_http_app(
if not plaintext:
raise HTTPException(status_code=416, detail="Chunk out of range")
- file_hash = blake3.blake3(file_path.read_bytes()).digest()
+ file_hash = bytes.fromhex(entry.id)
pt_hash = blake3.blake3(plaintext).digest()
if gek:
diff --git a/packages/meshbay-node/src/meshbay_node/transport/quic_client.py b/packages/meshbay-node/src/meshbay_node/transport/quic_client.py
index 83b729e..288465f 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/quic_client.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/quic_client.py
@@ -23,11 +23,8 @@ from aioquic.quic.events import QuicEvent, StreamDataReceived
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from meshbay_common import MNP_VERSION
-from meshbay_common.crypto import (
- chunk_key as derive_chunk_key,
- decrypt_chunk,
- verify_chunk_signature,
-)
+from meshbay_common.crypto import verify_chunk_signature
+from meshbay_common.webcrypto import chunk_key_aes as derive_chunk_key, decrypt_chunk_aes as decrypt_chunk
from meshbay_common.protocol import MNP
log = logging.getLogger(__name__)
diff --git a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
index 43c1026..f439e62 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
@@ -35,11 +35,10 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from meshbay_common import MNP_VERSION
from meshbay_common.crypto import (
- chunk_key as derive_chunk_key,
- encrypt_chunk,
sign_chunk,
pk_to_b64,
)
+from meshbay_common.webcrypto import chunk_key_aes as derive_chunk_key, encrypt_chunk_aes as encrypt_chunk
from meshbay_common.protocol import MNP
from meshbay_node.indexer import GroupIndex
from meshbay_node.transport.tls_cert import server_ssl_context
@@ -217,11 +216,13 @@ class _MNPServerProtocol(QuicConnectionProtocol):
self._send(stream_id, {"type": "error", "detail": "File not on disk"})
return
+ file_hash = bytes.fromhex(entry.id)
chunk_data = _read_and_encrypt(
self._ctx["sk_node"],
ctx["gek"],
file_path,
chunk_index,
+ file_hash,
)
self._send(stream_id, chunk_data)
@@ -304,13 +305,13 @@ def _read_and_encrypt(
gek: bytes,
file_path: Path,
chunk_index: int,
+ file_hash: bytes,
) -> dict:
"""Read and encrypt one chunk (blocking — runs in executor)."""
with open(file_path, "rb") as f:
f.seek(chunk_index * CHUNK_SIZE)
plaintext = f.read(CHUNK_SIZE)
- file_hash = blake3.blake3(file_path.read_bytes()).digest()
pt_hash = blake3.blake3(plaintext).digest()
ckey = derive_chunk_key(gek, file_hash, chunk_index)
nonce, ct = encrypt_chunk(ckey, plaintext)
diff --git a/packages/meshbay-node/src/meshbay_node/transport/server.py b/packages/meshbay-node/src/meshbay_node/transport/server.py
index 76ac13a..b77f1f2 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/server.py
@@ -197,7 +197,7 @@ class _ConnectionHandler:
"type": "error", "detail": "File not on disk"})
continue
- file_hash = blake3.blake3(file_path.read_bytes()).digest()
+ file_hash = bytes.fromhex(entry.id)
chunk = await loop.run_in_executor(
None, _serve_chunk,
self._sk_node, self._gek, file_path, file_hash, chunk_index)
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 89391b9..7da6623 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -29,19 +29,14 @@ import struct
from pathlib import Path
from typing import Any
-import blake3
import jwt
import msgpack
from aiortc import RTCPeerConnection, RTCSessionDescription, RTCDataChannel
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from meshbay_common import MNP_VERSION
-from meshbay_common.crypto import (
- chunk_key as derive_chunk_key,
- encrypt_chunk,
- sign_chunk,
- pk_to_b64,
-)
+from meshbay_common.crypto import pk_to_b64
+from meshbay_common.webcrypto import chunk_key_aes, encrypt_chunk_aes
from meshbay_common.protocol import MNP
from meshbay_node.indexer import GroupIndex
@@ -101,6 +96,7 @@ class WebRTCPeerSession:
def _handle_message(self, msg: dict) -> None:
mtype = msg.get("type")
+ log.debug("WebRTC recv: %s", mtype)
try:
if mtype == MNP.HANDSHAKE:
self._do_handshake(msg)
@@ -112,8 +108,12 @@ class WebRTCPeerSession:
self._do_file_request(msg)
elif mtype == MNP.STREAM_SEGMENT:
self._do_stream_segment(msg)
+ elif mtype == MNP.GEK_REQUEST:
+ self._do_gek_request()
elif mtype == MNP.CHAT_MESSAGE:
self._do_chat_message(msg)
+ elif mtype == MNP.CHAT_HISTORY:
+ self._do_chat_history(msg)
else:
log.warning("Unknown MNP message type on DataChannel: %s", mtype)
except Exception as e:
@@ -145,6 +145,10 @@ class WebRTCPeerSession:
self._user_id = decoded["sub"]
self._group_id = group_id
+ peers = self._ctx.get("_peers")
+ if peers is not None:
+ peers[self._user_id] = self
+
log.info("WebRTC handshake OK — user=%s group=%s",
self._user_id[:8], group_id[:8] if group_id else "none")
self._send({
@@ -160,11 +164,32 @@ class WebRTCPeerSession:
def _do_index_sync(self) -> None:
ctx = self._group_ctx()
- wire = ctx["index"].serialize()
+ idx = ctx["index"]
+ entries = [
+ {
+ "id": e.id, "name": e.name, "path": e.path,
+ "size": e.size, "type": e.type, "added_at": e.added_at,
+ }
+ for e in idx.entries
+ ]
self._send({
"type": MNP.INDEX_SYNC,
"v": MNP_VERSION,
- "index_b64": base64.b64encode(wire).decode(),
+ "group_id": idx.group_id,
+ "version": idx.version,
+ "entries": entries,
+ })
+
+ def _do_gek_request(self) -> None:
+ ctx = self._group_ctx()
+ gek = ctx.get("gek")
+ if not gek:
+ self._send({"type": "error", "detail": "No GEK available"})
+ return
+ self._send({
+ "type": MNP.GEK_RESPONSE,
+ "v": MNP_VERSION,
+ "gek_b64": base64.b64encode(gek).decode(),
})
def _do_file_request(self, msg: dict) -> None:
@@ -173,6 +198,7 @@ class WebRTCPeerSession:
chunk_index = msg["chunk_index"]
entry = ctx["index"].get_entry(file_id)
if not entry:
+ log.warning("File not found: %s", file_id[:16])
self._send({"type": "error", "detail": "File not found"})
return
@@ -181,11 +207,13 @@ class WebRTCPeerSession:
self._send({"type": "error", "detail": "File not on disk"})
return
+ file_hash = bytes.fromhex(entry.id)
chunk_data = _read_and_encrypt(
self._ctx["sk_node"],
ctx["gek"],
file_path,
chunk_index,
+ file_hash,
)
self._send(chunk_data)
@@ -235,21 +263,77 @@ class WebRTCPeerSession:
def _do_chat_message(self, msg: dict) -> None:
chat_store = self._ctx.get("chat_store")
+ payload = msg.get("payload", "")
if chat_store:
+ raw = payload.encode() if isinstance(payload, str) else payload
asyncio.ensure_future(chat_store.save_message(
sender_id=msg.get("sender_id", self._user_id),
iteration=msg.get("iteration", 0),
- payload=msg.get("payload", b"").encode()
- if isinstance(msg.get("payload"), str) else msg.get("payload", b""),
+ payload=raw,
thread_id=msg.get("thread_id"),
))
+
+ peers = self._ctx.get("_peers", {})
+ broadcast = {
+ "type": MNP.CHAT_MESSAGE,
+ "v": MNP_VERSION,
+ "sender_id": msg.get("sender_id", self._user_id),
+ "payload": payload,
+ "thread_id": msg.get("thread_id"),
+ "timestamp": __import__("time").time(),
+ }
+ for uid, session in peers.items():
+ if uid != self._user_id and session is not self:
+ try:
+ session._send(broadcast)
+ except Exception:
+ pass
+
self._send({"type": "ack", "v": MNP_VERSION})
+ def _do_chat_history(self, msg: dict) -> None:
+ chat_store = self._ctx.get("chat_store")
+ if not chat_store:
+ self._send({
+ "type": MNP.CHAT_HISTORY_RESPONSE,
+ "v": MNP_VERSION,
+ "messages": [],
+ })
+ return
+
+ since = msg.get("since", 0)
+ limit = msg.get("limit", 100)
+ asyncio.ensure_future(self._send_chat_history(chat_store, since, limit))
+
+ async def _send_chat_history(self, chat_store, since: float, limit: int) -> None:
+ msgs = await chat_store.get_messages(since=since, limit=limit)
+ self._send({
+ "type": MNP.CHAT_HISTORY_RESPONSE,
+ "v": MNP_VERSION,
+ "messages": [
+ {
+ "id": m.id,
+ "sender_id": m.sender_id,
+ "payload": m.payload.decode("utf-8", errors="replace")
+ if isinstance(m.payload, bytes) else m.payload,
+ "timestamp": m.timestamp,
+ "thread_id": m.thread_id,
+ }
+ for m in msgs
+ ],
+ })
+
def _send(self, obj: dict) -> None:
if self._channel and self._channel.readyState == "open":
self._channel.send(_pack(obj))
+ else:
+ log.warning("WebRTC send skipped: channel=%s",
+ self._channel.readyState if self._channel else "none")
async def close(self) -> None:
+ peers = self._ctx.get("_peers")
+ if peers and self._user_id:
+ peers.pop(self._user_id, None)
await self._pc.close()
@@ -258,30 +342,22 @@ def _read_and_encrypt(
gek: bytes,
file_path: Path,
chunk_index: int,
+ file_hash: bytes,
) -> dict:
with open(file_path, "rb") as f:
f.seek(chunk_index * CHUNK_SIZE)
plaintext = f.read(CHUNK_SIZE)
- file_hash = blake3.blake3(file_path.read_bytes()).digest()
- pt_hash = blake3.blake3(plaintext).digest()
- ckey = derive_chunk_key(gek, file_hash, chunk_index)
- nonce, ct = encrypt_chunk(ckey, plaintext)
- ct_hash = blake3.blake3(ct).digest()
- sig = sign_chunk(sk_node, chunk_index, nonce, ct_hash)
+ ckey = chunk_key_aes(gek, file_hash, chunk_index)
+ nonce, ct = encrypt_chunk_aes(ckey, plaintext)
return {
"type": MNP.FILE_CHUNK,
"v": MNP_VERSION,
"chunk_index": chunk_index,
"plaintext_size": len(plaintext),
- "nonce_b64": base64.b64encode(nonce).decode(),
- "ct_b64": base64.b64encode(ct).decode(),
- "ct_hash_b64": base64.b64encode(ct_hash).decode(),
- "pt_hash_b64": base64.b64encode(pt_hash).decode(),
- "sig_b64": base64.b64encode(sig).decode(),
- "pk_node_b64": pk_to_b64(sk_node.public_key()),
- "file_hash_b64": base64.b64encode(file_hash).decode(),
+ "nonce": nonce,
+ "ct": ct,
}
@@ -312,6 +388,7 @@ class WebRTCTransport:
"gek": gek,
"shared_root": shared_root,
"index": index,
+ "_peers": {},
}
if groups:
self._ctx["groups"] = groups
diff --git a/packages/meshbay-node/tests/test_http_server.py b/packages/meshbay-node/tests/test_http_server.py
index 1483a1d..d4ccc32 100644
--- a/packages/meshbay-node/tests/test_http_server.py
+++ b/packages/meshbay-node/tests/test_http_server.py
@@ -156,7 +156,7 @@ async def test_chunk_public_group(sk_node, sk_hub, hub_pk_pem, shared_dir):
@pytest.mark.asyncio
async def test_chunk_private_group(sk_node, sk_hub, hub_pk_pem, gek, shared_dir):
"""Private group chunk: encrypted with GEK."""
- from meshbay_common.crypto import chunk_key as derive_chunk_key, decrypt_chunk
+ from meshbay_common.webcrypto import chunk_key_aes as derive_chunk_key, decrypt_chunk_aes as decrypt_chunk
import blake3
indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek)
diff --git a/packages/meshbay-node/tests/test_indexer.py b/packages/meshbay-node/tests/test_indexer.py
index 60f9b01..380d752 100644
--- a/packages/meshbay-node/tests/test_indexer.py
+++ b/packages/meshbay-node/tests/test_indexer.py
@@ -175,7 +175,7 @@ async def test_on_change_callback(shared_dir, sk_node, gek):
await asyncio.sleep(0.1)
(shared_dir / "newfile.mp4").write_bytes(os.urandom(256))
- await asyncio.sleep(0.5) # let watchdog detect the change
+ await asyncio.sleep(3.0) # watchdog detect + 2s debounce
await indexer.stop()
assert len(changes) >= 1, "on_change should have been called"
diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py
index 4c0fdbf..d3847ff 100644
--- a/packages/meshbay-node/tests/test_webrtc_transport.py
+++ b/packages/meshbay-node/tests/test_webrtc_transport.py
@@ -13,7 +13,6 @@ import os
import struct
import time
-import blake3
import jwt
import msgpack
import pytest
@@ -25,10 +24,8 @@ from meshbay_common import MNP_VERSION
from meshbay_common.crypto import (
generate_gek,
pk_to_b64,
- chunk_key as derive_chunk_key,
- decrypt_chunk,
- verify_chunk_signature,
)
+from meshbay_common.webcrypto import chunk_key_aes, decrypt_chunk_aes
from meshbay_common.protocol import MNP
from meshbay_node.indexer import DirectoryIndexer
from meshbay_node.transport.webrtc_server import WebRTCTransport
@@ -197,7 +194,8 @@ async def test_webrtc_datachannel_file_transfer(sk_node, sk_hub, gek, shared_dir
channel.send(_pack({"type": MNP.INDEX_SYNC, "v": MNP_VERSION}))
idx_msg = await asyncio.wait_for(received.get(), timeout=5.0)
assert idx_msg["type"] == MNP.INDEX_SYNC
- assert "index_b64" in idx_msg
+ assert "entries" in idx_msg
+ assert len(idx_msg["entries"]) > 0
# 3) Request file chunk
entry = next(e for e in indexer.index.entries if e.name == "test.bin")
@@ -211,25 +209,24 @@ async def test_webrtc_datachannel_file_transfer(sk_node, sk_hub, gek, shared_dir
chunk_msg = await asyncio.wait_for(received.get(), timeout=5.0)
assert chunk_msg["type"] == MNP.FILE_CHUNK
- # 4) Verify and decrypt
- ct = base64.b64decode(chunk_msg["ct_b64"])
- nonce = base64.b64decode(chunk_msg["nonce_b64"])
- ct_hash = base64.b64decode(chunk_msg["ct_hash_b64"])
- pt_hash = base64.b64decode(chunk_msg["pt_hash_b64"])
- sig = base64.b64decode(chunk_msg["sig_b64"])
- file_hash = base64.b64decode(chunk_msg["file_hash_b64"])
+ # 4) Verify and decrypt (binary fields — no base64, minimal envelope)
+ ct = chunk_msg["ct"]
+ nonce = chunk_msg["nonce"]
+ file_hash = bytes.fromhex(entry.id)
- pk_node = sk_node.public_key()
- verify_chunk_signature(pk_node, 0, nonce, ct_hash, sig)
- assert blake3.blake3(ct).digest() == ct_hash
-
- ckey = derive_chunk_key(gek, file_hash, 0)
- plaintext = decrypt_chunk(ckey, nonce, ct)
- assert blake3.blake3(plaintext).digest() == pt_hash
+ ckey = chunk_key_aes(gek, file_hash, 0)
+ plaintext = decrypt_chunk_aes(ckey, nonce, ct)
original = (shared_dir / "test.bin").read_bytes()
assert plaintext == original
+ # 5) Request GEK over DataChannel
+ channel.send(_pack({"type": MNP.GEK_REQUEST, "v": MNP_VERSION}))
+ gek_msg = await asyncio.wait_for(received.get(), timeout=5.0)
+ assert gek_msg["type"] == MNP.GEK_RESPONSE
+ received_gek = base64.b64decode(gek_msg["gek_b64"])
+ assert received_gek == gek
+
await browser_pc.close()
await transport.close_all()
@@ -324,3 +321,407 @@ async def test_webrtc_request_before_handshake_rejected(sk_node, sk_hub, gek, sh
await browser_pc.close()
await transport.close_all()
+
+
+@pytest.mark.asyncio
+async def test_webrtc_chat_send_and_history(sk_node, sk_hub, gek, shared_dir, tmp_path):
+ """WebRTC DataChannel: send chat message, then retrieve history."""
+ from meshbay_node.chat.store import ChatStore
+
+ hub_pk_pem = _hub_pk_pem(sk_hub)
+ indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek)
+ await indexer.initial_scan()
+
+ chat_store = ChatStore(db_path=tmp_path / "chat_test.db")
+ await chat_store.open()
+
+ transport = WebRTCTransport(
+ sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek,
+ shared_root=shared_dir, index=indexer.index,
+ stun_servers=[],
+ )
+ transport._ctx["chat_store"] = chat_store
+
+ browser_pc = RTCPeerConnection()
+ received = asyncio.Queue()
+ buf = bytearray()
+
+ channel = browser_pc.createDataChannel("mnp")
+
+ @channel.on("open")
+ def on_open():
+ token = _make_jwt(sk_hub)
+ channel.send(_pack({
+ "type": MNP.HANDSHAKE,
+ "v": MNP_VERSION,
+ "token": token,
+ }))
+
+ @channel.on("message")
+ def on_msg(message):
+ if isinstance(message, str):
+ message = message.encode()
+ buf.extend(message)
+ while len(buf) >= 4:
+ length = struct.unpack(">I", buf[:4])[0]
+ if len(buf) < 4 + length:
+ break
+ msg_bytes = bytes(buf[4:4 + length])
+ del buf[:4 + length]
+ received.put_nowait(msgpack.unpackb(msg_bytes, raw=False))
+
+ offer = await browser_pc.createOffer()
+ await browser_pc.setLocalDescription(offer)
+
+ answer_sdp, _ = await transport.handle_offer(
+ browser_pc.localDescription.sdp, "peer-chat")
+ await browser_pc.setRemoteDescription(
+ RTCSessionDescription(sdp=answer_sdp, type="answer"))
+
+ ack = await asyncio.wait_for(received.get(), timeout=5.0)
+ assert ack["type"] == MNP.HANDSHAKE_ACK
+
+ channel.send(_pack({
+ "type": MNP.CHAT_MESSAGE,
+ "v": MNP_VERSION,
+ "payload": "hello from browser",
+ }))
+ chat_ack = await asyncio.wait_for(received.get(), timeout=5.0)
+ assert chat_ack["type"] == "ack"
+
+ await asyncio.sleep(0.2)
+
+ channel.send(_pack({
+ "type": MNP.CHAT_HISTORY,
+ "v": MNP_VERSION,
+ "since": 0,
+ "limit": 50,
+ }))
+ hist = await asyncio.wait_for(received.get(), timeout=5.0)
+ assert hist["type"] == MNP.CHAT_HISTORY_RESPONSE
+ assert len(hist["messages"]) == 1
+ assert hist["messages"][0]["payload"] == "hello from browser"
+ assert hist["messages"][0]["sender_id"] == "user-001"
+
+ await chat_store.close()
+ await browser_pc.close()
+ await transport.close_all()
+
+
+@pytest.mark.asyncio
+async def test_webrtc_chat_history_no_store(sk_node, sk_hub, gek, shared_dir):
+ """WebRTC DataChannel: chat history without chat_store returns empty list."""
+ hub_pk_pem = _hub_pk_pem(sk_hub)
+ indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek)
+ await indexer.initial_scan()
+
+ transport = WebRTCTransport(
+ sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek,
+ shared_root=shared_dir, index=indexer.index,
+ stun_servers=[],
+ )
+
+ browser_pc = RTCPeerConnection()
+ received = asyncio.Queue()
+ buf = bytearray()
+
+ channel = browser_pc.createDataChannel("mnp")
+
+ @channel.on("open")
+ def on_open():
+ channel.send(_pack({
+ "type": MNP.HANDSHAKE, "v": MNP_VERSION,
+ "token": _make_jwt(sk_hub),
+ }))
+
+ @channel.on("message")
+ def on_msg(message):
+ if isinstance(message, str):
+ message = message.encode()
+ buf.extend(message)
+ while len(buf) >= 4:
+ length = struct.unpack(">I", buf[:4])[0]
+ if len(buf) < 4 + length:
+ break
+ msg_bytes = bytes(buf[4:4 + length])
+ del buf[:4 + length]
+ received.put_nowait(msgpack.unpackb(msg_bytes, raw=False))
+
+ offer = await browser_pc.createOffer()
+ await browser_pc.setLocalDescription(offer)
+ answer_sdp, _ = await transport.handle_offer(
+ browser_pc.localDescription.sdp, "peer-no-store")
+ await browser_pc.setRemoteDescription(
+ RTCSessionDescription(sdp=answer_sdp, type="answer"))
+
+ ack = await asyncio.wait_for(received.get(), timeout=5.0)
+ assert ack["type"] == MNP.HANDSHAKE_ACK
+
+ channel.send(_pack({
+ "type": MNP.CHAT_HISTORY, "v": MNP_VERSION, "since": 0, "limit": 50,
+ }))
+ hist = await asyncio.wait_for(received.get(), timeout=5.0)
+ assert hist["type"] == MNP.CHAT_HISTORY_RESPONSE
+ assert hist["messages"] == []
+
+ await browser_pc.close()
+ await transport.close_all()
+
+
+@pytest.mark.asyncio
+async def test_webrtc_chat_broadcast(sk_node, sk_hub, gek, shared_dir, tmp_path):
+ """WebRTC DataChannel: chat message from peer A is broadcast to peer B."""
+ from meshbay_node.chat.store import ChatStore
+
+ hub_pk_pem = _hub_pk_pem(sk_hub)
+ indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek)
+ await indexer.initial_scan()
+
+ chat_store = ChatStore(db_path=tmp_path / "chat_bc.db")
+ await chat_store.open()
+
+ transport = WebRTCTransport(
+ sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek,
+ shared_root=shared_dir, index=indexer.index,
+ stun_servers=[],
+ )
+ transport._ctx["chat_store"] = chat_store
+
+ async def _connect_peer(peer_id, jwt_sub, groups=None):
+ pc = RTCPeerConnection()
+ q = asyncio.Queue()
+ b = bytearray()
+ ch = pc.createDataChannel("mnp")
+
+ sk_h_pem = sk_hub.private_bytes(
+ serialization.Encoding.PEM,
+ serialization.PrivateFormat.PKCS8,
+ serialization.NoEncryption(),
+ )
+ now = int(time.time())
+ token = jwt.encode({
+ "iss": "test-hub", "sub": jwt_sub,
+ "pk_user": "test", "hub_id": "test-hub",
+ "jti": f"jti-{peer_id}", "iat": now, "exp": now + 3600,
+ "groups": groups or [],
+ }, sk_h_pem, algorithm="EdDSA")
+
+ @ch.on("open")
+ def on_open():
+ ch.send(_pack({
+ "type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": token,
+ }))
+
+ @ch.on("message")
+ def on_msg(message):
+ if isinstance(message, str):
+ message = message.encode()
+ b.extend(message)
+ while len(b) >= 4:
+ length = struct.unpack(">I", b[:4])[0]
+ if len(b) < 4 + length:
+ break
+ msg_bytes = bytes(b[4:4 + length])
+ del b[:4 + length]
+ q.put_nowait(msgpack.unpackb(msg_bytes, raw=False))
+
+ offer = await pc.createOffer()
+ await pc.setLocalDescription(offer)
+ answer_sdp, _ = await transport.handle_offer(
+ pc.localDescription.sdp, peer_id)
+ await pc.setRemoteDescription(
+ RTCSessionDescription(sdp=answer_sdp, type="answer"))
+
+ ack = await asyncio.wait_for(q.get(), timeout=5.0)
+ assert ack["type"] == MNP.HANDSHAKE_ACK
+ return pc, ch, q
+
+ pc_a, ch_a, q_a = await _connect_peer("peer-A", "user-A")
+ pc_b, ch_b, q_b = await _connect_peer("peer-B", "user-B")
+
+ ch_a.send(_pack({
+ "type": MNP.CHAT_MESSAGE, "v": MNP_VERSION, "payload": "hi from A",
+ }))
+
+ ack_a = await asyncio.wait_for(q_a.get(), timeout=5.0)
+ assert ack_a["type"] == "ack"
+
+ broadcast = await asyncio.wait_for(q_b.get(), timeout=5.0)
+ assert broadcast["type"] == MNP.CHAT_MESSAGE
+ assert broadcast["sender_id"] == "user-A"
+ assert broadcast["payload"] == "hi from A"
+
+ await chat_store.close()
+ await pc_a.close()
+ await pc_b.close()
+ await transport.close_all()
+
+
+@pytest.mark.asyncio
+async def test_webrtc_group_membership_enforced(sk_node, sk_hub, gek, shared_dir):
+ """WebRTC DataChannel: JWT without matching group claim is rejected."""
+ hub_pk_pem = _hub_pk_pem(sk_hub)
+ indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek)
+ await indexer.initial_scan()
+
+ transport = WebRTCTransport(
+ sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek,
+ shared_root=shared_dir, index=indexer.index,
+ stun_servers=[],
+ )
+
+ browser_pc = RTCPeerConnection()
+ received = asyncio.Queue()
+
+ channel = browser_pc.createDataChannel("mnp")
+
+ @channel.on("message")
+ def on_msg(message):
+ if isinstance(message, str):
+ message = message.encode()
+ received.put_nowait(_unpack(message))
+
+ offer = await browser_pc.createOffer()
+ await browser_pc.setLocalDescription(offer)
+ answer_sdp, _ = await transport.handle_offer(
+ browser_pc.localDescription.sdp, "peer-group-test")
+ await browser_pc.setRemoteDescription(
+ RTCSessionDescription(sdp=answer_sdp, type="answer"))
+
+ await asyncio.sleep(0.5)
+
+ token = _make_jwt(sk_hub, groups=["other-group"])
+ channel.send(_pack({
+ "type": MNP.HANDSHAKE, "v": MNP_VERSION,
+ "token": token, "group_id": "my-group",
+ }))
+
+ msg = await asyncio.wait_for(received.get(), timeout=5.0)
+ assert msg["type"] == "error"
+ assert "Not a member" in msg["detail"]
+
+ await browser_pc.close()
+ await transport.close_all()
+
+
+@pytest.mark.asyncio
+async def test_webrtc_peer_cleanup_on_close(sk_node, sk_hub, gek, shared_dir):
+ """WebRTC DataChannel: peer removed from _peers dict on session close."""
+ hub_pk_pem = _hub_pk_pem(sk_hub)
+ indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek)
+ await indexer.initial_scan()
+
+ transport = WebRTCTransport(
+ sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek,
+ shared_root=shared_dir, index=indexer.index,
+ stun_servers=[],
+ )
+
+ browser_pc = RTCPeerConnection()
+ received = asyncio.Queue()
+ buf = bytearray()
+
+ channel = browser_pc.createDataChannel("mnp")
+
+ @channel.on("open")
+ def on_open():
+ channel.send(_pack({
+ "type": MNP.HANDSHAKE, "v": MNP_VERSION,
+ "token": _make_jwt(sk_hub),
+ }))
+
+ @channel.on("message")
+ def on_msg(message):
+ if isinstance(message, str):
+ message = message.encode()
+ buf.extend(message)
+ while len(buf) >= 4:
+ length = struct.unpack(">I", buf[:4])[0]
+ if len(buf) < 4 + length:
+ break
+ msg_bytes = bytes(buf[4:4 + length])
+ del buf[:4 + length]
+ received.put_nowait(msgpack.unpackb(msg_bytes, raw=False))
+
+ offer = await browser_pc.createOffer()
+ await browser_pc.setLocalDescription(offer)
+ answer_sdp, _ = await transport.handle_offer(
+ browser_pc.localDescription.sdp, "peer-cleanup")
+ await browser_pc.setRemoteDescription(
+ RTCSessionDescription(sdp=answer_sdp, type="answer"))
+
+ ack = await asyncio.wait_for(received.get(), timeout=5.0)
+ assert ack["type"] == MNP.HANDSHAKE_ACK
+
+ assert "user-001" in transport._ctx["_peers"]
+ assert transport.active_peers == 1
+
+ await transport.close_peer("peer-cleanup")
+
+ assert "user-001" not in transport._ctx["_peers"]
+ assert transport.active_peers == 0
+
+ await browser_pc.close()
+
+
+@pytest.mark.asyncio
+async def test_webrtc_stream_segment_missing_file(sk_node, sk_hub, gek, shared_dir):
+ """WebRTC DataChannel: stream_segment for non-existent file returns error."""
+ hub_pk_pem = _hub_pk_pem(sk_hub)
+ indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek)
+ await indexer.initial_scan()
+
+ transport = WebRTCTransport(
+ sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek,
+ shared_root=shared_dir, index=indexer.index,
+ stun_servers=[],
+ )
+
+ browser_pc = RTCPeerConnection()
+ received = asyncio.Queue()
+ buf = bytearray()
+
+ channel = browser_pc.createDataChannel("mnp")
+
+ @channel.on("open")
+ def on_open():
+ channel.send(_pack({
+ "type": MNP.HANDSHAKE, "v": MNP_VERSION,
+ "token": _make_jwt(sk_hub),
+ }))
+
+ @channel.on("message")
+ def on_msg(message):
+ if isinstance(message, str):
+ message = message.encode()
+ buf.extend(message)
+ while len(buf) >= 4:
+ length = struct.unpack(">I", buf[:4])[0]
+ if len(buf) < 4 + length:
+ break
+ msg_bytes = bytes(buf[4:4 + length])
+ del buf[:4 + length]
+ received.put_nowait(msgpack.unpackb(msg_bytes, raw=False))
+
+ offer = await browser_pc.createOffer()
+ await browser_pc.setLocalDescription(offer)
+ answer_sdp, _ = await transport.handle_offer(
+ browser_pc.localDescription.sdp, "peer-stream")
+ await browser_pc.setRemoteDescription(
+ RTCSessionDescription(sdp=answer_sdp, type="answer"))
+
+ ack = await asyncio.wait_for(received.get(), timeout=5.0)
+ assert ack["type"] == MNP.HANDSHAKE_ACK
+
+ channel.send(_pack({
+ "type": MNP.STREAM_SEGMENT, "v": MNP_VERSION,
+ "file_id": "nonexistent-file-id",
+ "segment_index": 0, "segment_duration": 4,
+ }))
+
+ msg = await asyncio.wait_for(received.get(), timeout=5.0)
+ assert msg["type"] == "error"
+ assert "not found" in msg["detail"].lower()
+
+ await browser_pc.close()
+ await transport.close_all()