summaryrefslogtreecommitdiffstats
path: root/docs/apps.md
blob: 7819dc99672e8e7f52c64c588b70c4b782aeaf30 (plain) (blame)
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
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
# 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 <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 |

### 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 `<name>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
`<app_key>_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>-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. **`<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, 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 (`<key>_directories`). One identifier per app,
   everywhere; `test_app_settings_plugin.py` checks the registry against
   `ALLOWED_APPS`.
2b. **`<name>-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.
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 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`) 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/<id>/roots/<name>')`,
  `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.