1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
|
# 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
(a poster-grid browser, a music player, 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 — 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 and Photos remain deliberately out of scope
— see `meshbay-draft-v6.md` §2.7. The infrastructure in this document was
proven end-to-end first with Chat/Files, then with Videos; Music/Photos
are additive from here, and can reuse Videos' thumbnail/chunk-delivery
machinery rather than re-deciding it.
- **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.
|