# 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, deviceReady, 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 | | `deviceReady` | whether this connection has identified a device to the node (`device_hello`) | **the exception to the row above, and why it is a prop.** A ref not re-rendering is right for a transport an app reaches into on demand, and wrong for a *fact about the connection* an app renders from. Chat's composer gates on this one: a reconnect clears it and settles it again inside `connect()`, and while it was read off `transportRef.current.devicePk` during render, the panel latched shut on whatever unrelated re-render came next and had no event that would open it again. See `test_chat_send.py` | | `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 a root's `writable` flag (`refactor-groups.md` §1.1) — an operator-signed setting, stored on the node, enforced by absence rather than by the client's honesty. It used to be described against `member_upload`, which was the group-wide upload switch; that was removed in the same refactor. **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. **An app's directories are the same shape one level down** (2026-09-06): `ops.set_app_directories(state, group_id, app_key, paths)`, stored under `_directories`, reached by one MNP message (`app_directories`) and one loopback route. Adding an app adds no function, no message type and no route — which is what "plugin architecture" has to mean to be worth the phrase. `_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", "video", "music", "photo"}` today) — **this is the line a new app's node-side registration touches.** - `files` is added to the list if it is absent, at both writers (`_do_apps_enabled` and `ops.set_enabled_apps`, both at the front so the two agree). It is not a toggle: MNP permits root exploration regardless of what this list says, so hiding the tab only ever misled. 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 the roots table. 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 root ops (`root_update_ack`, `root_eject_ack`, `root_plug_ack`) broadcast the same way, through `onRootsChanged`. ### 3b. An app's settings Each app that has settings exports a component from `static/-app-settings.js` and names it in its `apps.js` entry. The Settings page renders one collapsible section per registry entry, with the app's own on/off switch in the header — the toggle *is* the enablement control, rather than a checkbox list somewhere else that could disagree with it. Every pane takes the same props, and nothing else: `roots`, `dirs`, `settings`, `saveDirectories` (bound to this app), `transport`, `signFn`. The split is the point — **what every app has, the page does generically; what one app alone has, the pane does itself.** Pointing an app at folders goes through `saveDirectories`; a TMDB credential or a link-preview switch is the pane's own business, made with the transport it is handed. An app that only needs directories therefore touches neither `group-settings.js` nor `group-page.js`, and `test_app_settings_plugin.py` fails if either of them starts naming apps again. Two constraints that are not obvious: - **A pane must not import `group-settings.js`.** That is a cycle (`group-settings` → `apps` → pane → `group-settings`), and ES modules answer it with a temporal-dead-zone `ReferenceError` at first render — the component does not appear, with nothing in the console to say why. The shared widgets (`CollapsibleSection`, `ToggleSwitch`, `useSaver`) live in `settings-ui.js` for this reason. - **A new module must be added to `_ASSETS`** in `meshbay_hub/api/webapp.py`. A file reached through the registry is not imported by name anywhere, so nothing else would notice it changing, and a browser would go on serving the cached copy. `test_asset_versioning` enforces it. **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, Settings? }`. `key` is the wire identifier — it must match what you add to the node's allow-list next, and it is also the row an app's directories are stored under (`_directories`). One identifier per app, everywhere; `test_app_settings_plugin.py` checks the registry against `ALLOWED_APPS`. 2b. **`-app-settings.js`**, if the app has anything to configure, exporting a component that takes `{ roots, dirs, settings, saveDirectories, transport, signFn }` and nothing else (§3b). Folders go through `saveDirectories`; anything only this app has, it does itself with the transport. **Do not import `group-settings.js`** — that is a cycle, and it fails as a component that silently does not render. 2c. **The toolbar pins.** If the app has a toolbar — a row of controls above whatever it is the app shows — give it `position: sticky` on the pattern `style.css`'s "Sticky chrome" section holds, so that scrolling a library does not take its own controls off the screen. Two conditions come with it, and both are structural rather than cosmetic: the toolbar must be a **direct child of the page root** (an app renders a fragment, so it already is — do not wrap it in a container of your own), and it must be **opaque**, or the content scrolls visibly through it. If anything of the app's pins *below* that toolbar, as Files' column heads do, the toolbar has to publish its own height with `useStickyBand` from `sticky.js` — its height is never a constant, since it wraps on a phone. An app with no toolbar renders none: an empty band still holds a strip of the page open, which is why Photos draws no toolbar on the Search page. 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 both new files. 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`), `test_sticky_header.py` (add a case to its probe if the app has a toolbar — a band that stopped pinning looks exactly like one that never did) 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. Directories in particular need nothing server-side at all: `app_directories` is one generic op keyed by the app's name (§3), and an app storing its folders under a key nobody wrote code for is the case `test_app_directories.py::test_an_app_nobody_wrote_code_for_stores_its_directories` pins. ## 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.** A root's flags can be changed two ways: over a live MNP connection (any browser, anywhere), or — Electron only, and only when MNP is not connected — via the node's local HTTP API (`platform.node.call('PATCH', '/api/groups//roots/')`, `SharedDirectoriesTable` in `group-settings.js`). `apps_enabled` only has the MNP path today. Adding the loopback twin is a `meshbay_node.ui` endpoint plus a branch in the table's `run()` helper, mirroring the root ops. **MNP is the path that must exist, not the fallback.** The operator of a node is not necessarily sitting at it. The first version of the shared directories table read its roots exclusively from the loopback API, which resolves to "not available" in a browser — so the whole section rendered for nobody on the web, while the controls it replaced had worked there. Any operator-facing setting added here needs the MNP route first.