diff options
Diffstat (limited to 'docs/apps.md')
| -rw-r--r-- | docs/apps.md | 333 |
1 files changed, 0 insertions, 333 deletions
diff --git a/docs/apps.md b/docs/apps.md deleted file mode 100644 index 991af1b..0000000 --- a/docs/apps.md +++ /dev/null @@ -1,333 +0,0 @@ -# Group applications — adding one - -> **Superseded by `MESHBAY_DESIGN.md`.** This was the group-application framework; its design -> content now lives in §9.1–§9.4. -> -> It is kept because code comments, tests and other documents cite its -> sections and its labels, and because it records reasoning a synthesis -> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design -> document is right; where either disagrees with the code, the code is.** -> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its -> replacement, and §13 defines every label. - -> Status: **superseded, and accurate as far as it goes.** 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, 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 `<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. -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_<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`), `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/<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. |