diff options
Diffstat (limited to 'docs/apps.md')
| -rw-r--r-- | docs/apps.md | 192 |
1 files changed, 192 insertions, 0 deletions
diff --git a/docs/apps.md b/docs/apps.md new file mode 100644 index 0000000..94116c6 --- /dev/null +++ b/docs/apps.md @@ -0,0 +1,192 @@ +# Group applications — adding one + +> Status: **current, as built.** Describes the plug-in architecture that +> replaced the monolithic `static/app.js`, landed 2026-08-23. See +> `meshbay-draft-v6.md` §2.7 for why this exists and what it changes; this +> document is the how-to. + +A group has "applications" — Chat and Files today, Videos/Music/Photos planned +(Netflix-style browsing, Spotify-style playback, an album viewer). None of the +planned ones need an MNP protocol change: video/audio/image files are already +classified by the node's indexer (`meshbay_node/indexer/indexer.py`, `type: +video|audio|image`) and flow through the same `index_sync`/`file_req`/ +`stream_req` messages Files and `VideoPlayer` already use. Adding one is a new +file plus one registry entry — nothing about the group shell changes. + +--- + +## 1. The shape + +``` +group-page.js ─┬─ owns: connection (transportRef/gekRef), the file index + (the shell) │ (entries/nodeDirs/nodeRoots), admin flags, which apps are + │ enabled, the tab bar, the video/preview modals + │ + ├─ apps.js ─── the registry: [{ key, icon, labelKey, Component }] + │ + ├─ chat-app.js ──────── ChatPanel + ├─ files-app.js ─────── FilesPanel, FilePreview + └─ (video-app.js, music-app.js, photos-app.js — not built) + +group-settings.js ─── not an app. Always present, not toggleable — disabling + it would strand an operator with no way to re-enable + anything. Holds the "Applications" checkbox list. + +Shared infrastructure (imported by app.js AND every per-app file — this is +why they exist as separate modules rather than being re-exported from app.js, +which would make a circular import): + icon.js — the <Icon> component and its SVG path table + file-utils.js — formatSize/formatDate/canPreview/FILE_ICONS, the + download/decrypt pipeline (pipelinedDownload, downloadEntry, + _openDownloadTarget, _saveBlob), CHUNK_SIZE + hub-client.js — HUB, hubFetch, the auth/session/token-renewal machinery, + the group-index IndexedDB cache, the keypair-bundle cache, + `session` (mutable {bundleKey, pendingJoinCode}), navigate +``` + +`app.js` itself is what's left after the split: routing, every *other* page +(Login/Register/Home/Explore/Search/Profile/Settings/Admin/Node/ +CreateGroupWizard), and nothing group-application-specific. + +## 2. What every app receives + +`group-page.js` builds one `commonProps` object per render and spreads it into +whichever app is active: + +```js +const commonProps = { + groupId, transportRef, gekRef, status, username, + entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex, + isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview, + onRefreshIndex: refreshIndex, onActivity: touchActivity, +}; +... +${apps.map(a => tab === a.key && html`<${a.Component} key=${a.key} ...${commonProps} />`)} +``` + +Every registered component gets the same context and destructures what it +needs — a new app does not get a bespoke prop list. Notable ones: + +| Prop | What it is | Why it's here, not local state | +|---|---|---| +| `entries`, `nodeDirs`, `nodeRoots` | the group's file index | Chat needs it too, for image attachments — lifting it avoids two copies going stale against each other | +| `applyIndex(indexMsg)` | writes a fresh index into the three above, plus the search cache | anything that mutates files (upload, delete, mkdir) calls this so every app sees the result | +| `onPreview(entry)` | opens the shell's video/preview modal | `entry.type === 'video'` routes to `VideoPlayer`, anything else to `FilePreview` — an app just calls this, it does not own modal state | +| `transportRef`, `gekRef` | refs to the live MNP transport and the imported group key | never state — a ref, so reconnects don't force a re-render of every app | +| `mayUpload` | `memberUpload || isNodeAdmin`, computed once | Files' toolbar and Chat's composer both gate on it; a second derivation would eventually disagree with the first | + +An app that needs **local** state (Files' `selecting`/`sortKey`/`currentPath`, +for instance) owns it itself with `useState`, same as before the split. One +thing worth keeping if you add a tab with a notion of "current location within +the group" the way Files has a path: reset it on `groupId` change. +`files-app.js` does this — + +```js +useEffect(() => { setCurrentPath(''); setSelected(new Set()); setFilter(''); }, [groupId]); +``` + +— because a directory from the group just left rarely exists in the one just +entered, and without the reset the panel shows a stale path and lists +nothing. This was a real bug, fixed before the split; carry the pattern into +any app with similar per-group local state. + +## 3. Enable/disable: the mechanism + +Same shape as `member_upload` (`meshbay-draft-v6.md` §2.1b) — an +operator-signed setting, stored on the node, enforced by absence rather than +by the client's honesty. + +**Node side** (`meshbay_node/roster.py`): +```python +SETTING_ENABLED_APPS = "enabled_apps" # in the existing group_settings table +DEFAULT_APPS = ("chat", "files") # what an unset group gets +async def enabled_apps(group_id) -> list[str]: ... +async def set_enabled_apps(group_id, apps, set_by="") -> list[str]: ... +``` +`meshbay_node/ops.py` has `set_enabled_apps(state, group_id, apps)`, called +from exactly one place: `webrtc_server.py`'s `_admin_exec_apps_enabled`, after +`_verify_admin_sig` — nothing is applied before the signature checks out. + +`_do_apps_enabled` in `webrtc_server.py` validates before it ever issues a +challenge: +- `apps` non-empty — the operator can never lock a group down to nothing. +- every entry in `WebRTCPeerSession.ALLOWED_APPS` (`{"chat", "files"}` today) + — **this is the line a new app's node-side registration touches.** + +The whole set is signed in one message (`apps_enabled`, `OP_APPS_ENABLED` in +`meshbay_common.adminop`) rather than one op per app — ticking several boxes +in Settings costs one signature, not N. The transcript's subject is the +sorted, comma-joined app list (`"chat,files"`), built the same way on both +sides so the operator's browser and the node arrive at identical bytes to +sign/verify. + +`enabled_apps` rides in `handshake_ack` and `node_status`, next to +`member_upload`. Changing it broadcasts `apps_enabled_ack` to everyone already +connected — `transport.js`'s `onAppsEnabled` — so a disabled tab disappears +without waiting for a reconnection, the same as `member_upload`'s live +broadcast. + +**Client side:** `apps.js`'s `visibleApps(enabledKeys)` filters the registry; +`group-page.js` calls it with `enabledApps` state (from the ack, `null` until +one arrives, which `visibleApps` reads as "show everything registered" — a +node that predates an app, or hasn't answered yet, hides nothing). The +Settings toggle list in `group-settings.js` iterates the *same* `APPS` +registry, so a newly-registered app gets a checkbox for free. + +## 4. Adding an app — checklist + +1. **`<name>-app.js`**, exporting a component with the standard props shape + (§2). Use `files-app.js` as the reference if the app is file/media-centric + (it will be, for Videos/Music/Photos — all three are views over `entries` + filtered by `type`), or `chat-app.js` if it needs its own local realtime + state. Import shared helpers from `file-utils.js`/`hub-client.js`/ + `icon.js` — do not re-implement `formatSize`, the download pipeline, or + `Icon`. +2. **Register it** in `apps.js`'s `APPS` array: `{ key, icon, labelKey, + Component }`. `key` is the wire identifier — it must match what you add to + the node's allow-list next. +3. **Node-side allow-list**: add the key to `ALLOWED_APPS` in + `webrtc_server.py`. Without this the node refuses `apps_enabled` for any + set naming it (`"Unknown app(s): ..."`), so an operator can never turn it + on. +4. **i18n**: at minimum, a `group.tab_<name>` key (the tab's tooltip/label, + reused as the Settings checkbox label) in all ten `static/locales/*.js` + files. `test_locales.py` holds them to the same key set. +5. **`webapp.py`'s `_ASSETS`** tuple: add the new file. This is the + cache-busting hash's input list — a file imported by the page but missing + here can change without the served URL changing, which is the exact bug + class `test_asset_versioning.py` exists for. Forgetting this step is + silent: nothing errors, a browser just keeps an old copy. +6. **Test coverage that scans the file set**: `test_hook_ordering.py` + (`STATIC_FILES`) and `test_transport_contracts.py` + (`test_no_setter_survives_the_state_it_belonged_to`, `SPLIT_FILES`) walk a + fixed list of files looking for a whole class of bug each — add the new + file to both lists, or it is simply never checked, which fails silently + rather than loudly. +7. **`sync-ui.js`** needs no change — it copies the whole `static/` tree + verbatim. Run `npm run sync-ui` in `meshbay-client` after adding the file + and confirm it's reported. + +No protocol change, no hub change, no `daemon.py` change — steps 3 and 6 are +the only node-side touches, and both are allow-lists, not new wire messages. + +## 5. What does not exist yet + +- **Thumbnails/posters.** No generation mechanism, client or node side. A + Netflix-style grid or a photo album needs *something* here; the pragmatic + v1 (decided but not built) is lazy, client-side: decrypt the image, or a + video's first segment, only for tiles actually visible, with an in-memory + cache — no new MNP message, no node-side store. This is real per-tile cost + (a full chunk-pipelined decrypt per thumbnail), acceptable for a browsing + grid, not for hundreds of tiles rendered at once — a virtualized grid is + part of building Videos/Photos, not optional. +- **Videos, Music, Photos themselves.** Deliberately out of scope for the + refactor that built this — see `meshbay-draft-v6.md` §2.7. The + infrastructure above is proven end-to-end with the two apps that already + existed (Chat, Files); a new one is additive. +- **The offline/loopback settings path.** `member_upload` can be toggled two + ways: over a live MNP connection, or (Electron only) via the node's local + HTTP API when MNP isn't connected (`platform.node.call('PUT', .../member- + upload')`, `group-settings.js`). `apps_enabled` only has the MNP path today. + Adding the loopback twin is a `meshbay_node.ui` endpoint plus a + `group-settings.js` branch, mirroring the existing `member_upload` one. |