# 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, Files, and Videos today (a poster-grid browser; see `docs/mediacenter.md`), Music/Photos planned (a music player, an album viewer). 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 — Music/Photos need no MNP change beyond that. Videos itself did need one: TMDB metadata (`media_meta_req`/`resp`), per-season overview (`season_meta_req`/`resp`), and operator match correction (`tmdb_search_req`/`resp`, `tmdb_override`/`_ack`) are all additive message pairs on top of the same index/chunk plumbing, not a replacement for it. Adding a new app is still 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 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 | ### 2b. The same app, rendered by the Search page Videos, Music and Photos are mounted twice: by `group-page.js` for one group, and by `search-page.js` across every group the reader belongs to. The second caller passes the same prop shape, and the difference lives entirely on the **entries**, in underscore-prefixed fields the group page never sets: | Field | What it is | |---|---| | `groupId`, `groupName`, `groupOwner` | which group serves this entry | | `_tRef`, `_gRef` | that group's transport and key — read as `entry._tRef \|\| transportRef`, which is why a single-group mount needs no special case | | `_connGen` | bumped when that group reconnects; use it as a refetch key so a tile recovers instead of staying a spinner | | `_sources` | every group that has this file, after the de-duplication below | **A file shared by two groups is one entry, not two** (`source-merge.js`, `docs/refactoring-search.md`). Entries are folded on their content hash and one source is resolved per *unit* — a film, a show, an album — using each app's own grouping function to decide what a unit is. Two consequences for a new app: - if it renders a group name, use **`SourceTag`** from `group-name.js` rather than `entry.groupName`: a merged entry has several groups and must say `N sources` instead of naming one. Pass it the **whole unit** (a show's episodes, an album's tracks), not the entry the card was drawn from — that entry is usually chosen for its thumbnail, and would under-report; - if it needs a merge unit key of its own, add a `Units()` helper to `search-page.js` that calls the app's **exported** grouping function. Never re-derive the keys there: a copy keeps agreeing until one of them changes, and the symptom is a show whose episodes stream from two different nodes. The Files explorer is deliberately **not** merged — there each group is a top-level folder, and merging would remove a file from one of them. `test_search_files_unmerged.py` refuses a build that changes this. 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. **`-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_` 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 used to be silent: nothing errors, a browser just keeps an old copy. It is now caught — `test_every_static_script_participates_in_the_fingerprint` holds `_ASSETS` to every `.js` in `static/` (`sw.js` excepted, unversioned on purpose). Written after `source-merge.js` shipped missing from the list. 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 — built for Videos, 2026-08-23, see `docs/mediacenter.md`.** The plan below (lazy, client-side, no node-side store) turned out to be wrong once a real design pass ran the numbers: `docs/mediacenter.md` §2 revises `desktop-client-v1.md`'s O12 and has the node generate thumbnails (an `ffmpeg` frame grab, its own bounded worker pool) and cache them durably in its own `data_dir`, delivered over the existing `file_req`/ chunk path addressed by their own blake3 hash. TMDB posters/metadata are fetched and cached by the node the same way — no client ever talks to TMDB directly. A virtualized grid (`IntersectionObserver`-based lazy mount) is built in `video-app.js`, per the note below. A future Photos app can reuse the same node-side machinery (thumbnail cache, chunk-path delivery) without re-deciding any of this. - **Videos, Music, Photos themselves.** Videos is now built (`video-app.js`, `docs/mediacenter.md`). Music is now built (`music-app.js`, `docs/musicbay.md`) and reuses Videos' node-side thumbnail/chunk-delivery machinery, with no new streaming path (a track is small enough to download-then-play, unlike a film). Photos is **designed, not built** — see `docs/photos.md` — and reuses the same `thumb_hash`/chunk-delivery machinery again; unlike Videos/Music it needs several root folders per group rather than one, has a single album-grid view with no third-party matching step, and reads EXIF locally on the node instead. - **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.