aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-07 10:35:09 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-07 10:35:09 +0200
commit2c0903c648e24b4e2adf20492398e8b67d033b49 (patch)
tree0435f298010f0f946362f28baebbe88337ca8768
parent0ed078c92cabab1dab0f70f321562032ea549ce6 (diff)
parenteeda274d751c537f4ecef3087994a16a9517478f (diff)
downloadmeshbay-2c0903c648e24b4e2adf20492398e8b67d033b49.tar.gz
Merge branch 'refactor/groups-phase1'
Groups refactor, phases 1-3. The root model replaces the old `upload` flag and group-wide `member_upload` with per-root `writable`/`removable`/`ejected`, carried by a `RootSet` that both front doors — the loopback API and signed MNP — reach through the same `ops` functions. MNP goes to 1.1, additively: the roots table now rides on `index_delta`, so a root added, removed, ejected or plugged reaches every connected client instead of only whoever reloaded. The group UI becomes a plugin architecture: an application is a registry entry in `apps.js` plus its own files, with directories stored generically by `ops.set_app_directories` under whatever the app is called. A reference application, hidden behind `?dev=1`, is what makes that claim testable — adding it is what found the two places still naming apps by hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
-rw-r--r--docs/apps.md101
-rw-r--r--docs/meshbay-draft-v6.md47
-rw-r--r--docs/refactor-groups.md906
-rw-r--r--man/meshbay-node.1136
-rw-r--r--packages/meshbay-common/src/meshbay_common/__init__.py2
-rw-r--r--packages/meshbay-common/src/meshbay_common/adminop.py9
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/apps.js95
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js67
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/chat-app.js21
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js110
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js134
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js298
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js199
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js1183
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/helloworld-app-settings.js50
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/helloworld-app.js62
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js61
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js63
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js61
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js63
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js61
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js61
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js61
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js61
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js61
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js61
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js60
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app.js23
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/node-page.js16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js44
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/search-page.js62
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/settings-ui.js92
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css231
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js301
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js131
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js25
-rw-r--r--packages/meshbay-hub/tests/test_app_settings_plugin.py383
-rw-r--r--packages/meshbay-hub/tests/test_css_variables.py78
-rw-r--r--packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py159
-rw-r--r--packages/meshbay-hub/tests/test_hook_ordering.py7
-rw-r--r--packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py175
-rw-r--r--packages/meshbay-hub/tests/test_no_prompt_in_the_spa.py90
-rw-r--r--packages/meshbay-hub/tests/test_search_media_merge.py12
-rw-r--r--packages/meshbay-hub/tests/test_spa_imports.py87
-rw-r--r--packages/meshbay-hub/tests/test_spa_syntax.py85
-rw-r--r--packages/meshbay-hub/tests/test_transfers.py14
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py10
-rw-r--r--packages/meshbay-hub/tests/test_upload_controls_hidden.py263
-rw-r--r--packages/meshbay-node/src/meshbay_node/config.py43
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py465
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/indexer.py125
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py535
-rw-r--r--packages/meshbay-node/src/meshbay_node/roots.py101
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py201
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py607
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/wire.py12
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py61
-rw-r--r--packages/meshbay-node/tests/conftest.py10
-rw-r--r--packages/meshbay-node/tests/test_app_directories.py292
-rw-r--r--packages/meshbay-node/tests/test_apps_enabled_policy.py4
-rw-r--r--packages/meshbay-node/tests/test_audio_root_gates_enrichment.py14
-rw-r--r--packages/meshbay-node/tests/test_audio_root_policy.py17
-rw-r--r--packages/meshbay-node/tests/test_cli_dispatch.py66
-rw-r--r--packages/meshbay-node/tests/test_index_delta_carries_roots.py131
-rw-r--r--packages/meshbay-node/tests/test_member_upload_policy.py176
-rw-r--r--packages/meshbay-node/tests/test_node_status.py117
-rw-r--r--packages/meshbay-node/tests/test_ops.py146
-rw-r--r--packages/meshbay-node/tests/test_rename_reenrichment.py4
-rw-r--r--packages/meshbay-node/tests/test_replug_restores_enrichment.py318
-rw-r--r--packages/meshbay-node/tests/test_root_availability.py11
-rw-r--r--packages/meshbay-node/tests/test_root_eject.py267
-rw-r--r--packages/meshbay-node/tests/test_root_ops_reach_the_live_set.py361
-rw-r--r--packages/meshbay-node/tests/test_root_paths_are_operator_only.py114
-rw-r--r--packages/meshbay-node/tests/test_root_writable_policy.py246
-rw-r--r--packages/meshbay-node/tests/test_roots.py81
-rw-r--r--packages/meshbay-node/tests/test_scan_settings_policy.py2
-rw-r--r--packages/meshbay-node/tests/test_security_regressions.py167
-rw-r--r--packages/meshbay-node/tests/test_startup_scan_enrichment.py5
-rw-r--r--packages/meshbay-node/tests/test_video_root_gates_enrichment.py8
-rw-r--r--packages/meshbay-node/tests/test_video_root_policy.py13
-rw-r--r--packages/meshbay-node/tests/test_windows_root_shapes.py149
84 files changed, 9504 insertions, 1788 deletions
diff --git a/docs/apps.md b/docs/apps.md
index ef8cc1a..7819dc9 100644
--- a/docs/apps.md
+++ b/docs/apps.md
@@ -130,9 +130,10 @@ 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
+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.
+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
@@ -145,11 +146,22 @@ async def set_enabled_apps(group_id, apps, set_by="") -> list[str]: ...
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"}` today)
- — **this is the line a new app's node-side registration touches.**
+- 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
@@ -158,11 +170,42 @@ 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.
+`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
@@ -181,8 +224,17 @@ registry, so a newly-registered app gets a checkbox for free.
`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.
+ 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
@@ -190,7 +242,7 @@ registry, so a newly-registered app gets a checkbox for free.
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
+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
@@ -210,6 +262,11 @@ registry, so a newly-registered app gets a checkbox for free.
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
@@ -234,9 +291,17 @@ the only node-side touches, and both are allow-lists, not new wire messages.
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.
+- **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.
diff --git a/docs/meshbay-draft-v6.md b/docs/meshbay-draft-v6.md
index 180050c..28aea0c 100644
--- a/docs/meshbay-draft-v6.md
+++ b/docs/meshbay-draft-v6.md
@@ -57,7 +57,7 @@
| 6 | Portability | exFAT/NTFS and Windows are the **common** case. Case folding and Unicode normalization become correctness requirements, not compatibility notes | E8 / decision 12 |
| 7 | Accounts | Native registration is **hybrid**: passphrase-derived `auth_key` (the recovery path) plus a device Ed25519 key for day-to-day authentication | E3 / decision 4 |
| 8 | Authorship | Chat senders are **cryptographically authenticated to each other**; an upload has a **provable owner** who may delete it, as the operator may. v5's node-asserted attribution is replaced | operator decision, §2.4b |
-| 9 | Node authority | The operator may **close uploading to everyone but themselves**, per group. Signed MNP op, stored on the node, enforced by the node — the hidden button is a courtesy, the refusal is the control | §2.1b |
+| 9 | Node authority | The operator decides **which directories accept uploads**, per root. Signed MNP op, stored on the node, enforced by the node — the hidden button is a courtesy, the refusal is the control. **Superseded 2026-09-06** by `docs/refactor-groups.md` §1.1: the group-wide `member_upload` switch this section described is replaced by RO/RW per root, and the "everyone but the operator" carve-out is gone | §2.1b |
| 10 | Client | A group's UI is a **set of pluggable applications** (Chat, Files today), not one monolithic page. Which are shown is a per-group, operator-signed setting on the same pattern as change 9 | §2.7 |
| 11 | Hub role | The hub gains a **runtime instance-policy store** (`hub_settings`). First policy: an admin switches **public groups off** hub-wide, enforced server-side on every hub-mediated path. `suspend` vs `revoke` on a group are now written down as the distinct things they are | §2.8 |
| 12 | Group registry | A group name is **unique per owner account**, not globally; the group's identity is still its UUID. Listed everywhere as `name@owner` | §2.9 |
@@ -74,10 +74,22 @@
v5 confines uploads to `shared_root/uploads/` with a filename allowlist, no overwrite,
chunk ordering and a size cap. All four protections stand. Two amendments:
-- There is no single `shared_root`. **The operator designates one root as the upload
- destination**; the quarantine lives inside it. If that root is unavailable the upload
- fails with a stated reason and never falls back to another; if none is designated,
- uploads are refused rather than guessed.
+- There is no single `shared_root`. **Each root is read-only or read-write**, and an
+ upload goes to the folder the sender is looking at, inside a writable root. If that
+ root is unavailable the upload fails with a stated reason and never falls back to
+ another; if the group has no writable root, uploads are refused rather than guessed.
+ (Amended 2026-09-06 — the original text designated *one* root as the upload
+ destination, and the client named none. See `docs/refactor-groups.md` §1.1.)
+- **There is no `uploads/` quarantine directory any more** (2026-09-06). It was the
+ last of v5's, the per-user layer having gone on 2026-08-14, and it went for the same
+ reason: a folder appearing beside the operator's library because somebody sent a
+ file is the node deciding how their disk is arranged. **What made the quarantine
+ worth having was never the subdirectory** — it is the filename allowlist, the size
+ cap, the chunk ordering and the no-overwrite rule, and all four are unchanged.
+ The client now names the destination folder, which is safe for one reason and only
+ one: it is resolved through `RootSet.resolve()`, which refuses `..`, absolute
+ segments and anything escaping its root, symlinks included. A member answers "which
+ of this group's folders", never "which path on the operator's disk".
- **The no-overwrite rule is unchanged and still holds on exFAT/NTFS.** An earlier
draft claimed a string comparison let `README.TXT` land on `readme.txt` there. It does
not: the check is `Path.exists()`, and `stat()` is itself case-insensitive on those
@@ -91,6 +103,31 @@ device that asked, so the node keeps no thumbnail store.
### 2.1b §5.2 Uploads — the operator may close them
+> **Superseded 2026-09-06.** `member_upload` is gone; the mechanism is `writable` on
+> each root. What the three load-bearing properties below say is *unchanged* — read
+> "the root's `writable` flag" for "`member_upload`" and every word of them still
+> holds, which is why they are kept rather than deleted. What did change:
+>
+> - **It is per root, not per group.** A group can publish one library read-only and
+> accept uploads into another, which the single switch could not express.
+> - **There is no carve-out for the operator.** Read-only means read-only for
+> everyone, because a published library that quietly accepts writes from whoever
+> holds admin authority is not one. The paragraph below justifying the setting by
+> "the only way to get a curated library was to designate no upload root at all,
+> which refuses the operator too" is therefore the reasoning that was reversed: that
+> *is* the model now, and refusing the operator is the point rather than the defect.
+> - **The client names the destination root.** With several writable roots the node
+> cannot choose without guessing, and a guess sends a member's file to a disk the
+> operator did not intend. It names a root, never a path; everything below the root
+> is still decided by the node.
+> - The signed op is `OP_ROOT_UPDATE` (plus `OP_ROOT_EJECT` / `OP_ROOT_PLUG`) rather
+> than `OP_MEMBER_UPLOAD`, and the flags live in `node.toml` — they are
+> configuration — while the *ejected* runtime state lives in `roster.db`.
+> `member_upload` survives on the handshake ack alone, computed as "any root is
+> writable", for MNP 1.0 clients that read no other field.
+>
+> See `docs/refactor-groups.md` §1.1 and §1.5b.
+
New. A group where every member may add files is the default and stays the default;
some groups want a library the operator curates, and until now the only way to get one
was to designate no upload root at all, which refuses the operator too.
diff --git a/docs/refactor-groups.md b/docs/refactor-groups.md
new file mode 100644
index 0000000..147afda
--- /dev/null
+++ b/docs/refactor-groups.md
@@ -0,0 +1,906 @@
+# Groups Refactor — Per-Root Permissions & App Plugin Architecture
+
+> Status: **Complete** (2026-09-07). All three phases built, reviewed and
+> tested against a running node.
+>
+> This is the most significant refactoring of the project. It changes how roots
+> are permissioned, how group applications are configured, and how the Settings
+> and Create Group pages are structured.
+>
+> §7b, §7c and §7d record what each phase's review found and where the plan
+> below was wrong. Several entries are rules rather than one-off fixes; §7d
+> also lists what a person still has to test by hand.
+
+---
+
+## 0. Summary of changes
+
+| Area | Before | After |
+|---|---|---|
+| Root permissions | One root marked `upload=True`; binary `member_upload` toggle per group | Each root is **RO** (default) or **RW**; multiple RW roots allowed; fully RO group is valid |
+| Upload policy | Separate section in Settings; `member_upload` signed op | **Removed.** RO/RW on the root is the mechanism. Files shows Upload only on RW roots. Chat disables attachments when its configured directory is not on a RW root |
+| Root metadata | `name, path, kind, upload, direct` | `name, path, kind, writable, removable, direct` |
+| Removable flag | Not tracked | Per-root boolean, set by operator. Enables the **eject/plug** button for safe device removal |
+| Safe eject | Auto-detected only (`path.is_dir()`) | Operator-initiated eject button in Settings AND Files root view. `ejected` state distinct from `available`. Indexer freezes entries, no data loss |
+| Files app | Can be disabled | **Always enabled**, transparently. Not shown in the app toggle list |
+| App selection at group creation | Checkbox list of all apps | **Removed.** Files is enabled automatically; other apps are configured later in Settings |
+| Settings layout | Monolithic: apps checkboxes, TMDB, MusicBrainz, directories, uploads — all in `group-settings.js` | **Structured:** Shared directories (top, expanded) → per-app sections (each with toggle + icon + title, collapsed, settings hidden until enabled) → Scan/Danger/Devices/Members |
+| App settings code | All inlined in `group-settings.js` (1338 lines) | Each app has `ui/<APP_NAME>-app-settings.js`; loaded by discovery (presence of the file) |
+| App enablement UI | One "Applications" section with checkboxes | Each app is a collapsible section with its own toggle in the title. The toggle is the enablement control |
+| Folder picker | Flat `<select>` with depth-indented names | **Folder tree popup**: modal, root icons, `[+]` expand, sub-directory selection |
+| Server-side app ops | Per-app functions in `ops.py` (`set_video_root`, `set_audio_root`, `set_photo_roots`, ...) | Generic `set_app_directory()` / `set_app_directories()` + app-specific wrappers where needed |
+| CLI | `group add --dir --upload-dir`; `member upload` concept | `group add --dir [--writable]`; `root add/remove/set` with `--writable`/`--read-only`/`--removable` |
+| MNP | 1.0 | 1.1 (additive: new fields on roots, new generic app-directory messages). 1.0 peers still work |
+
+---
+
+## 1. Design decisions
+
+### 1.1 RO/RW replaces upload + member_upload
+
+The current model has two orthogonal mechanisms: (a) one root is the upload target,
+(b) `member_upload` toggles whether non-operators can upload there. The new model
+collapses both into one property per root: **writable**.
+
+- `writable = false` (default): the root is read-only for everyone, including the
+ operator via the UI. Content is placed there out-of-band (filesystem, rsync, USB).
+- `writable = true`: any group member may upload to this root (into the quarantine
+ subdirectory, same protections as today — allowlist, no overwrite, size cap).
+
+Multiple roots can be writable. Zero can be writable (fully read-only group). The
+operator controls which roots are RW by toggling a switch in the shared directories
+table.
+
+**What this removes:**
+- The `member_upload` toggle and its `OP_MEMBER_UPLOAD` signed op
+- The `member_upload` / `member_upload_ack` MNP message types (deprecated, still
+ parsed for backward compat)
+- The Uploads section in Settings
+- The concept of "the upload root" (singular)
+
+**What this preserves:**
+- The quarantine directory, filename allowlist, no-overwrite check, size cap
+- The `_do_file_upload` handler in `webrtc_server.py` — now checks `writable` on
+ the target root instead of checking `upload` + `member_upload`
+- The operator's ability to create a read-only group (set all roots RO)
+
+### 1.2 Files is always enabled
+
+`files` is removed from the toggleable app list. It is always present in
+`enabled_apps` and cannot be disabled. The current ability to hide it was misleading:
+MNP still permits root exploration regardless. The tab bar always shows Files.
+
+`apps.js` keeps `files` in `APPS` but marks it `alwaysEnabled: true`. The Settings
+page skips it when rendering app toggle sections.
+
+### 1.3 Per-app settings files
+
+Each app that has configurable settings exports a settings component from
+`ui/<APP_NAME>-app-settings.js`. The file is optional — an app with no settings
+(like Files today) has no settings file and gets only a toggle.
+
+The `APPS` registry in `apps.js` gains an optional `Settings` field per entry,
+imported from the corresponding settings file. The group-settings page iterates
+`APPS`, skips `files`, and renders a collapsible section for each, with:
+
+- The app's monochrome icon + localized title in the section header
+- A toggle switch in the header (disabled by default)
+- The app's `Settings` component below, **hidden until the toggle is on**
+- A Save button per app section (some saves trigger caching — TMDB, MusicBrainz)
+
+**Discovery mechanism:** In the browser context, "file discovery" is registration in
+`apps.js`. Adding a new app means: write `<APP>-app.js` + `<APP>-app-settings.js`,
+add one entry to `APPS` in `apps.js`, add the key to `ALLOWED_APPS` on the node.
+Server-side enforcement via `ALLOWED_APPS` prevents client-side hacks from enabling
+an unrecognized app.
+
+### 1.4 Folder tree widget
+
+A reusable modal popup (`FolderTreePicker`) that:
+
+- Appears centered on screen, semi-transparent backdrop
+- Lists root directories at the top level, with the same folder icons as Files
+- Each root can be expanded via `[+]` / collapsed via `[-]`, explorer-tree style
+- Sub-directories load from the existing `nodeDirs` data (already available from the
+ file index, no new endpoint needed)
+- Supports **single-select** mode (Chat) and **multi-select** mode (Videos, Music,
+ Photos)
+- Shows the root's RO/RW badge next to each root name
+- For apps that require RW (Chat): RO roots and their children are greyed out /
+ unselectable, with a tooltip explaining why
+- OK / Cancel buttons at the bottom
+- Returns the selected path(s) relative to the root (e.g., `Movies/Action`)
+
+The widget replaces the current flat `<select>` dropdowns in all app settings. It is
+also usable in other UI contexts (the video player's folder navigation already does
+something similar ad-hoc).
+
+### 1.5 Shared directories table
+
+A reusable component (`SharedDirectoriesTable`) used in both the group Settings page
+and the Create Group wizard (developed once, shared). Features:
+
+- Borderless table, one row per root
+- Columns: **Name** (with folder icon), **Path** (truncated with tooltip on hover),
+ **RW toggle** (switch, default off), **Removable toggle** (checkbox),
+ **Eject/Plug button** (visible only when removable is checked — see §1.5b),
+ **Delete button** (trash icon, with confirmation)
+- Ejected roots show a distinct visual state: greyed-out row, eject icon replaced by
+ a plug icon
+- The first root in the Create Group wizard defaults to RW
+- A concise explanatory sentence at the top: "At least one directory is required.
+ Read-write directories accept uploads from group members."
+- Add button: opens the native folder picker (Electron) or a path input (web, admin
+ only)
+- Cannot delete the last root (refused with explanation)
+- Each change is a signed operator op (`ROOT_ADD`, `ROOT_REMOVE`, or new
+ `ROOT_UPDATE` for toggling writable/removable on an existing root)
+
+### 1.5b Safe eject for removable devices
+
+**Problem.** An operator stores data on a USB drive. Unplugging it without warning
+triggers the watchdog — file deletions propagate as though the operator erased an
+entire library. The existing `refresh_availability()` auto-detects this and freezes
+entries (good), but there is no way to eject cleanly before unplugging, and no way
+to re-plug without a full rescan.
+
+**The `ejected` state.** A root has two independent runtime states:
+
+- `ejected` (bool, default `false`): operator-controlled, persisted in `roster.db`.
+ Set by clicking the eject button; cleared by clicking plug.
+- `available` (bool, runtime): computed as `not ejected and is_live()`. This is
+ what clients and the indexer see.
+
+The distinction matters: when the operator clicks "eject" but hasn't physically
+unplugged yet, `is_live()` returns `true` but `available` is `false` because
+`ejected` is `true`. Without this, `refresh_availability()` would immediately
+flip it back to available.
+
+**Eject flow:**
+
+1. Operator clicks the eject button (⏏) on a removable root
+2. Confirmation dialog: "Eject *Movies*? Files from this directory will be
+ temporarily hidden to all members. You can safely unplug the device."
+3. On confirm: `PUT /api/groups/{gid}/roots/{name}/eject` → `ops.eject_root()`
+4. `ops.eject_root()`: sets `ejected = true` in `roster.db`, marks root
+ `available = false`, stops the watchdog observer for that root
+5. The indexer **freezes** all entries from that root (existing behavior — no
+ deletions, no index updates, cached data preserved)
+6. `index_sync` update propagates to connected peers: the root's `available` is
+ now `false`
+7. All apps filter out entries from unavailable roots (Files already does this
+ partially — needs to be complete across Videos, Music, Photos)
+8. The operator can now safely unplug the device
+
+**Plug flow:**
+
+1. Operator plugs the device back in and clicks the plug button (🔌)
+2. `PUT /api/groups/{gid}/roots/{name}/plug` → `ops.plug_root()`
+3. `ops.plug_root()`: checks `is_live()` first — if the path is not accessible,
+ refuses with "Directory not found. Is the device connected?"
+4. On success: sets `ejected = false`, marks root `available = true`, restarts
+ the watchdog observer
+5. The indexer **rescans the root** — its frozen entries are dropped and the
+ directory is read again. (The plan said "a reconciliation, not a full
+ rescan"; it is a rescan, deliberately. It is the same path a root coming
+ back from `refresh_availability` already took, and a device people carry
+ around can come back arbitrarily different — the hash cache means unchanged
+ files are not re-read, which is where the cost would have been.)
+6. `index_sync` update propagates — entries reappear in all apps
+
+**The flag is persisted, and restored at startup.** `ejected` lives in
+`roster.db` (`root_ejected:<folded name>`), not in `node.toml`: it is runtime
+state, and an operator's hand-written config must not be rewritten because a USB
+drive was unplugged. It has to survive a restart — a restart is exactly what an
+operator does after noticing a drive fell off, and a flag that only lived in
+memory would let the scan that follows read the empty mount point as an erased
+library. `daemon._build_roots()` merges the two sources; it is the only place
+that builds a `RootSet` for a group.
+
+**Auto-detection safety net.** If a `removable` root's path suddenly disappears
+(operator unplugged without clicking eject):
+
+- `refresh_availability()` detects `is_live() = false`
+- Because `removable = true`, it sets `ejected = true` automatically (as if the
+ operator had clicked eject), and reports it through the indexer's
+ `on_root_ejected` callback so the daemon writes it to `roster.db` — an
+ auto-eject that only existed in memory would be undone by the next restart
+- Entries freeze, no deletions propagate
+- The root stays in "ejected" state until the operator explicitly plugs it back
+
+For non-removable roots, the existing behavior is unchanged: `available` flips
+based on `is_live()`, entries freeze when unavailable, rescan when available again.
+
+**Eject button in Files app.** In addition to the Settings table, an eject button
+appears in the Files app root-level view, next to each removable root's name. This
+provides quick access without navigating to Settings. Same confirmation dialog,
+same API call. Ejected roots show as greyed-out with a plug icon to re-enable.
+
+**What is NOT deleted on eject:**
+
+- Index entries (frozen, not removed)
+- TMDB / MusicBrainz cached metadata
+- Video thumbnails in `media_cache`
+- Chat message history referencing files on that root
+- App directory configurations pointing to that root (but flagged as temporarily
+ invalid — the app shows a warning, not an error)
+
+**MNP message:** `ROOT_EJECT` / `ROOT_EJECT_ACK` and `ROOT_PLUG` / `ROOT_PLUG_ACK`
+— signed operator ops, same pattern as `ROOT_UPDATE`. Broadcast to all connected
+peers so they see the availability change immediately without waiting for the next
+`index_sync`.
+
+### 1.6 Server-side normalization (ops.py)
+
+Two generic functions replace the per-app specific ones:
+
+```python
+def set_app_directory(state, group_id, app_key, path, *, require_writable=False):
+ """Set a single directory for an app. Validates path is within a named root.
+ If require_writable, refuses paths under RO roots."""
+
+def set_app_directories(state, group_id, app_key, paths, *, require_writable=False):
+ """Set multiple directories for an app. Same validation."""
+```
+
+Existing functions (`set_video_root`, `set_audio_root`, `set_photo_roots`) become
+thin wrappers calling the generic versions, preserving the current MNP message types
+and roster keys for backward compat. New apps use the generic functions directly.
+
+### 1.7 Chat settings additions
+
+- **Directory picker** (single, RW-only): selects the directory for chat file
+ attachments. If no RW root exists, the picker shows an explanation and the
+ attachment button is disabled in the chat UI. If the selected directory's root is
+ later set to RO, the setting is flagged as invalid and attachments are disabled
+ until corrected.
+- **Link preview toggle** (new, server-side): the operator can disable link previews
+ for the group. Stored in `roster.db` as `chat_link_preview` (default: enabled).
+ The node's `linkpreview.py` checks this setting before unfurling. The toggle is a
+ `ToggleSwitch` in the Chat settings section.
+
+### 1.8 Videos/Music/Photos settings changes
+
+**Videos:**
+- Directory picker changes from single to **multi-directory** (via folder tree widget)
+- TMDB settings section moved here from the monolithic settings
+- TMDB API key field: no longer says "optional" or mentions the default key.
+ Instead, a prompt to sign up on TMDB with a direct link to generate a key
+- Per-app Save button triggers TMDB cache sweep
+
+**Music:**
+- Directory picker changes from single to **multi-directory**
+- MusicBrainz settings section moved here
+- Per-app Save button triggers MusicBrainz cache sweep
+
+**Photos:**
+- Multi-directory picker (already multi, just moves to the folder tree widget)
+- No third-party service settings
+
+### 1.9 Handshake ack changes
+
+The `handshake_ack` payload gains per-root metadata:
+
+```python
+# Current
+"roots": [{"name": "Movies", "path": "/mnt/movies", ...}]
+"member_upload": True
+
+# New
+"roots": [{"name": "Movies", "path": "/mnt/movies", "writable": False, "removable": True, ...}]
+# member_upload removed (deprecated, still parsed by old clients)
+```
+
+For backward compatibility with MNP 1.0 peers:
+- A 1.0 client that does not see `writable` on roots falls back to the old model
+ (root with `upload=True` is writable, `member_upload` from the ack controls access)
+- A 1.1 node continues to send `member_upload` as a computed value: `True` if any
+ root is writable, `False` otherwise — so old clients behave sensibly
+- `member_upload` is no longer writable via MNP ops; the node computes it from roots
+
+### 1.10 CLI changes
+
+As built. The group is a `--group` option rather than a positional, matching
+every other verb in this CLI, and the negative flags are spelled `--no-writable`
+/ `--no-removable` rather than `--read-only`, so each pair reads as one setting.
+
+```
+# Group creation (first root defaults to RW)
+meshbay-node group add <name> --dir <path> # first root, RW
+meshbay-node group add <name> --dir <path> --no-writable # first root, RO
+
+# Root management (--group is optional with one group configured)
+meshbay-node root list [--group <name>]
+meshbay-node root add <path> [--name <name>] [--writable] [--removable]
+meshbay-node root remove <name> [--yes]
+meshbay-node root set <name> --writable | --no-writable
+meshbay-node root set <name> --removable | --no-removable
+meshbay-node root eject <name> # safe eject
+meshbay-node root plug <name> # re-plug
+
+# Deprecated (accepted with a warning)
+--upload-dir → "use --writable on the target root instead"
+member upload → removed; use 'root set --no-writable' / '--writable'
+```
+
+### 1.11 HelloWorld proof-of-concept
+
+A minimal app that validates the plugin architecture end to end:
+
+- `static/helloworld-app.js`: renders a greeting and lists files in its configured
+ directory
+- `static/helloworld-app-settings.js`: single-directory picker (via folder tree
+ widget), no other settings
+- Entry in `apps.js` with `key: "helloworld"`, icon, label, Component, Settings
+- `ALLOWED_APPS` extended on the node
+- No dedicated `helloworld.py` — uses the generic `set_app_directory()` function,
+ which is the whole point
+
+The HelloWorld app is **not shipped in production**. It lives in the tree as a
+reference implementation and can be excluded from the build. Its value is proving
+that the plugin mechanism works: zero changes to `group-settings.js`,
+`group-page.js`, `webrtc_server.py` or `ops.py` to add it.
+
+### 1.12 Migration (existing nodes)
+
+A script in `QE/migration/` (not versioned) handles Fedora and Ubuntu nodes:
+
+**node.toml:**
+- `upload = true` → `writable = true`
+- `upload = false` (or absent) → `writable = false`
+- Add `removable = false` to all roots that lack it
+- Remove `upload_dir` from `[[groups]]` blocks (if present)
+
+**roster.db:**
+- If `member_upload = "off"` for a group: set all that group's roots to
+ `writable = false` (the intent was "no uploads")
+- Rename `video_root` → `video_directories` (wrap single value in a list)
+- Rename `audio_root` → `audio_directories` (same)
+- `photo_roots` → `photo_directories` (rename only)
+- Remove `member_upload` rows
+- Add `chat_link_preview = "true"` default for groups with chat enabled
+- Add `chat_directory` for groups that had an upload root (default: the upload
+ root's path)
+
+**Protocol version:**
+- MNP version file bumped to 1.1
+
+**The script is idempotent** — running it twice is safe.
+
+---
+
+## 2. Phase 1 — Root RO/RW model + Shared Directories UI
+
+**Goal:** Change the data model from `upload` to `writable`/`removable`, build the
+shared directories table, restructure the top of Settings and the Create Group
+wizard. Remove the Uploads section. Files always enabled.
+
+**Testable after this phase:** Create a group with RO/RW roots, toggle RW in
+Settings, add/remove roots in the new table, see the Upload button appear/disappear
+in Files based on the current root's writable flag, eject a removable root and verify
+files disappear from all apps without data loss, plug it back and verify files
+reappear, CLI works with new syntax including `root eject/plug`.
+
+### 2.1 Backend changes
+
+| File | Change |
+|---|---|
+| `config.py` | `RootSpec`: add `writable: bool = False`, `removable: bool = False`. Remove `upload` field. `__post_init__` migration: `upload=True` → `writable=True`. Parse new fields from node.toml |
+| `roots.py` | `Root` dataclass: add `writable`, `removable`, `ejected`. Remove `upload`. `available` becomes a computed property: `not self.ejected and self.is_live()`. `_settle_upload_root()` removed. `RootSet.build()`: validate at least one root exists (no RW minimum). `refresh_availability()`: when a `removable` root's path disappears, auto-set `ejected=True` (safety net). Collision checks unchanged |
+| `ops.py` | `add_root()`: accept `writable`, `removable` params. `remove_root()`: refuse removing last root (unchanged). `attach_group()`: first root defaults to `writable=True`. Remove `set_member_upload()`. New: `update_root()` for toggling writable/removable on an existing root (signed op `OP_ROOT_UPDATE`). New: `eject_root()` — sets `ejected=True`, stops watchdog for that root. New: `plug_root()` — checks `is_live()`, sets `ejected=False`, triggers reconciliation |
+| `roster.py` | No schema change (generic key/value). Remove `member_upload` handling from `_apply_group_settings()` |
+| `ui/app.py` | `POST /api/groups/{gid}/roots`: accept `writable`, `removable`. New: `PATCH /api/groups/{gid}/roots/{name}` → `ops.update_root()`. New: `PUT /api/groups/{gid}/roots/{name}/eject` → `ops.eject_root()`. New: `PUT /api/groups/{gid}/roots/{name}/plug` → `ops.plug_root()`. Remove `PUT /api/groups/{gid}/member-upload` |
+| `daemon.py` (CLI) | New `root` subcommand: `add`, `remove`, `set`, `list`. `group add --dir` defaults to `writable=True`. Deprecate `--upload-dir` with warning. Remove `member upload` command |
+| `webrtc_server.py` | Handshake ack: add `writable`/`removable`/`ejected` per root. Compute `member_upload` for backward compat. `_do_file_upload`: check `root.writable` instead of `root.upload` + `member_upload_allowed`. Handle `ROOT_UPDATE`, `ROOT_EJECT`, `ROOT_PLUG` MNP messages. Broadcast root availability changes to all connected peers. `ALLOWED_APPS`: add `"files"` to always-enabled set |
+| `protocol.py` | New message types: `ROOT_UPDATE` / `ROOT_UPDATE_ACK`, `ROOT_EJECT` / `ROOT_EJECT_ACK`, `ROOT_PLUG` / `ROOT_PLUG_ACK`. `ROOT_ADD` gains `writable`, `removable` fields |
+| `indexer.py` | `eject_root()`: stop the watchdog observer for that root, do NOT touch entries. `plug_root()`: restart observer, trigger reconciliation pass. `refresh_availability()`: auto-eject removable roots whose path disappears (set `ejected=True` instead of just flipping `available`) |
+| `handshake.py` | MNP version → 1.1 (minor, additive) |
+
+### 2.2 Frontend changes
+
+| File | Change |
+|---|---|
+| `group-settings.js` | New `SharedDirectoriesTable` component (reusable). Includes eject/plug button per removable root. Move to top of settings (after Invite/Pair). Remove the Uploads toggle section. Remove the old Directories section (root management part — app root pickers stay for now). Calls loopback API for add/remove/update/eject/plug root |
+| `create-group-page.js` | Replace app checkboxes with nothing (Files auto-enabled). Replace directory section with `SharedDirectoriesTable` (same component). First root defaults `writable=true`. Step 2: remove `set enabled_apps` call (Files is automatic) |
+| `files-app.js` | Upload button visibility: check `currentRoot.writable && currentRoot.available`. Upload target: the root currently being browsed. Hide upload affordances on RO roots. Eject/plug button next to each removable root name in root-level view. Ejected roots greyed out with plug icon. Entries from unavailable roots filtered out of all views |
+| `chat-app.js` | Attachment button: disabled if no RW root exists or if the chat directory's root is RO or unavailable. Tooltip explaining why |
+| `group-page.js` | `nodeRoots` state: include `writable`/`removable`/`ejected` from handshake ack. Handle `ROOT_EJECT_ACK`/`ROOT_PLUG_ACK` broadcasts to update root state live. Remove `memberUpload` state. Remove `onUploadPolicy` callback |
+| `video-app.js` | Filter entries: exclude files from unavailable roots |
+| `music-app.js` | Filter entries: exclude files from unavailable roots |
+| `photos-app.js` | Filter entries: exclude files from unavailable roots |
+| `apps.js` | Add `alwaysEnabled: true` to `files` entry |
+| `transport.js` | Parse new root fields from handshake ack |
+
+### 2.3 Localization
+
+New keys in `locales/*.js`:
+- `sharedDirectories`, `sharedDirectoriesHint` ("At least one directory is
+ required...")
+- `readOnly`, `readWrite`, `removableDevice`
+- `uploadNotAvailableRO` (tooltip: "This directory is read-only")
+- `ejectRoot`, `ejectRootConfirm` ("Eject *{name}*? Files from this directory
+ will be temporarily hidden...")
+- `plugRoot`, `plugRootFailed` ("Directory not found. Is the device connected?")
+- `rootEjected` (status label shown on ejected roots)
+- Deprecation: `uploadToggle*` keys can be removed
+
+---
+
+## 3. Phase 2 — App Settings Plugin Architecture + Folder Tree Widget
+
+**Goal:** Split app settings into per-app files, build the folder tree widget,
+restructure Settings with per-app collapsible sections. Normalize server-side
+ops.
+
+**Testable after this phase:** Each app has its own settings section with toggle.
+Folder tree popup works for directory selection. TMDB/MusicBrainz settings are in
+their app sections. Chat has link preview toggle. Videos/Music use multi-directory.
+
+### 3.1 Folder tree widget
+
+| File | Change |
+|---|---|
+| `static/folder-tree.js` (new) | `FolderTreePicker` component. Props: `roots` (with writable/removable), `dirs` (flat list from index), `mode` ("single"/"multi"), `requireWritable` (bool), `selected` (current selection), `onSelect` callback. Renders a modal with tree-view of directories. Builds tree structure from flat `nodeDirs` paths |
+
+**Behavior:**
+- Modal overlay with semi-transparent backdrop, centered panel
+- Root level: each root with folder icon, name, RO/RW badge, removable badge
+- `[+]` / `[-]` toggle to expand/collapse children
+- Directories sorted alphabetically at each level
+- Single mode: clicking a directory selects it (highlight), deselects previous
+- Multi mode: clicking a directory toggles its selection (checkbox visual)
+- `requireWritable=true`: RO roots and all their children are greyed out and
+ unclickable, with a brief explanation at the top of the modal
+- Selected path shown at the bottom of the modal
+- OK (disabled if nothing valid selected) and Cancel buttons
+- Escape key closes
+
+### 3.2 Per-app settings files
+
+| File | Content |
+|---|---|
+| `static/chat-app-settings.js` (new) | `ChatSettings` component. Single-directory picker (folder tree, `requireWritable=true`). Link preview toggle (`ToggleSwitch`). Save button. Validates: selected directory must be on a RW root |
+| `static/video-app-settings.js` (new) | `VideoSettings` component. Multi-directory picker (folder tree). TMDB section: toggle + API key + language (moved from `group-settings.js`). TMDB key prompt: "Sign up on TMDB to generate your API key" with link, no "optional" wording. Save triggers TMDB sweep |
+| `static/music-app-settings.js` (new) | `MusicSettings` component. Multi-directory picker. MusicBrainz section: toggle (moved from `group-settings.js`). Save triggers MusicBrainz sweep |
+| `static/photos-app-settings.js` (new) | `PhotoSettings` component. Multi-directory picker. No third-party settings. Save button |
+| `static/apps.js` | Each `APPS` entry gains `Settings` field imported from the corresponding settings file. `files` has no `Settings` (no per-app config). `files` has `alwaysEnabled: true` (from Phase 1) |
+
+### 3.3 Settings page restructure
+
+| File | Change |
+|---|---|
+| `group-settings.js` | Remove all inlined app settings (TMDB section, MusicBrainz section, app root pickers). Remove the Applications checkbox section. New rendering loop: for each app in `APPS` where `!app.alwaysEnabled && app.Settings`, render a `CollapsibleSection` with: icon + title in header, toggle switch in header, `app.Settings` component inside (visible only when enabled). Section collapsed by default. App toggle triggers signed `OP_APPS_ENABLED` op |
+
+**Final settings layout:**
+
+```
+1. Invite form (if admin & invite-only)
+2. Pair operator (if nodeAdmin & not paired)
+── App configuration ──
+3. Shared directories (expanded, SharedDirectoriesTable)
+4. 💬 Chat [toggle] (collapsed)
+ └─ Directory picker, Link preview toggle, Save
+5. 🎬 Videos [toggle] (collapsed)
+ └─ Directory picker (multi), TMDB settings, Save
+6. 🎵 Music [toggle] (collapsed)
+ └─ Directory picker (multi), MusicBrainz settings, Save
+7. 📷 Photos [toggle] (collapsed)
+ └─ Directory picker (multi), Save
+8. [Future apps discovered from APPS registry]
+── Node tuning ──
+9. Scan tuning (reconcile, debounce)
+── Danger zone ──
+10. Leave / Delete group
+── Identity ──
+11. My devices
+12. Members table
+```
+
+### 3.4 Server-side normalization
+
+| File | Change |
+|---|---|
+| `ops.py` | New generic: `set_app_directory(state, group_id, app_key, path, require_writable=False)` and `set_app_directories(state, group_id, app_key, paths, require_writable=False)`. Both validate path(s) within named roots and check writable if required. Existing `set_video_root` → wrapper calling `set_app_directories("video", ...)`. Same for audio, photo. New: `set_chat_directory(state, group_id, path)` → `set_app_directory("chat", path, require_writable=True)`. New: `set_chat_link_preview(state, group_id, enabled)` |
+| `webrtc_server.py` | Handle new MNP messages: `CHAT_DIRECTORY` / `ACK`, `CHAT_LINK_PREVIEW` / `ACK`. Refactor `VIDEO_ROOT` handler to use generic. Handshake ack: add `chat_directory`, `chat_link_preview` |
+| `protocol.py` | New message types: `CHAT_DIRECTORY`, `CHAT_LINK_PREVIEW` (and acks) |
+| `ui/app.py` | New endpoints: `PUT /api/groups/{gid}/chat-directory`, `PUT /api/groups/{gid}/chat-link-preview`. Generic: `PUT /api/groups/{gid}/app-directories/{app_key}` |
+| `linkpreview.py` | Check `chat_link_preview` setting before unfurling |
+
+### 3.5 Handshake ack additions
+
+```python
+"chat_directory": "/Shared/uploads",
+"chat_link_preview": True,
+"video_directories": ["/Movies", "/Series"], # replaces video_root (single)
+"audio_directories": ["/Music"], # replaces audio_root (single)
+"photo_directories": ["/Photos", "/Camera"], # replaces photo_roots
+```
+
+Old field names (`video_root`, `audio_root`, `photo_roots`) still sent for backward
+compat with MNP 1.0 clients. New clients read the `*_directories` form.
+
+---
+
+## 4. Phase 3 — HelloWorld, CLI polish & Migration
+
+**Goal:** Prove the plugin architecture with a HelloWorld app, finalize CLI changes,
+write and test the migration script.
+
+### 4.1 HelloWorld app
+
+| File | Content |
+|---|---|
+| `static/helloworld-app.js` (new) | Minimal component: renders "Hello, World!" heading + lists files from its configured directory. Uses `entries` from `commonProps`, filtered by the configured path |
+| `static/helloworld-app-settings.js` (new) | `HelloWorldSettings` component: single-directory picker via `FolderTreePicker`, Save button. Uses `set_app_directory("helloworld", path)` on the generic endpoint |
+| `apps.js` | New entry: `{ key: "helloworld", icon: "👋", labelKey: "helloWorld", Component: HelloWorldApp, Settings: HelloWorldSettings }` |
+| `webrtc_server.py` | Add `"helloworld"` to `ALLOWED_APPS` |
+
+**Validation:** enabling HelloWorld in Settings, picking a directory, saving, and
+seeing the file list in the HelloWorld tab — with **zero changes** to
+`group-settings.js`, `group-page.js`, or `ops.py`. If this works, any future app
+can be added the same way.
+
+### 4.2 CLI final polish
+
+- `root` subcommand fully tested: `add`, `remove`, `set`, `list`
+- `group add --upload-dir` prints deprecation warning and maps to `--dir <path>
+ --writable`
+- `member upload` command removed (prints migration guidance)
+- Help text updated to reflect RO/RW model
+- `test_cli_dispatch.py` updated for all new verbs
+
+### 4.3 Migration script
+
+`QE/migration/migrate_groups_v2.sh` (or `.py`) — not versioned, for Fedora/Ubuntu
+nodes only.
+
+**node.toml transformations:**
+```
+upload = true → writable = true
+upload = false → writable = false
+(no upload field) → writable = false
+(add removable = false to every root that lacks it)
+(remove upload_dir lines from [[groups]] blocks)
+```
+
+**roster.db transformations:**
+```sql
+-- Convert member_upload=off to all-RO roots (handled via ops on restart)
+-- Rename app directory keys
+UPDATE group_settings SET key = 'video_directories' WHERE key = 'video_root';
+UPDATE group_settings SET key = 'audio_directories' WHERE key = 'audio_root';
+UPDATE group_settings SET key = 'photo_directories' WHERE key = 'photo_roots';
+-- Wrap single values in JSON arrays for video/audio
+-- Add chat defaults
+INSERT INTO group_settings (group_id, key, value)
+ SELECT group_id, 'chat_link_preview', 'true'
+ FROM group_settings WHERE key = 'enabled_apps' AND value LIKE '%chat%';
+-- Remove member_upload rows
+DELETE FROM group_settings WHERE key = 'member_upload';
+```
+
+**Idempotency:** every transformation is guarded (`IF NOT EXISTS`, check before
+rename, etc.). Safe to run twice.
+
+**Rollback:** the script backs up `node.toml` and `roster.db` before any change.
+
+### 4.4 Windows notes
+
+No migration script needed for Windows (manual setup). Functional non-regression
+testing only:
+- Drive letter roots (`D:\Movies`, `E:\Music`) work with RO/RW
+- Removable flag on USB drives
+- Folder tree widget handles backslash paths
+- Create Group wizard with Windows paths
+
+---
+
+## 5. Risk assessment
+
+| Risk | Mitigation |
+|---|---|
+| Upload regression | Phase 1 is self-contained: test uploads on RW roots, verify refused on RO roots, before touching app settings |
+| Eject data loss | Eject freezes entries (existing `freeze-not-empty` path). Ejected roots are never rescanned. Auto-eject safety net for surprise unplugs on removable roots. Explicit reconciliation (not full rescan) on plug |
+| Indexer race on eject | Watchdog observer is stopped synchronously before `ejected=true` is set. No window where the watchdog sees a missing path and processes deletions |
+| MNP backward compat | Computed `member_upload` in ack for 1.0 clients. New fields additive. Old field names kept alongside new ones |
+| Monolithic `group-settings.js` diff | Phase 2 extracts code into new files; the old code is deleted, not refactored. Clear before/after |
+| Folder tree perf with large indexes | `nodeDirs` is already computed. Tree construction is O(n) on directory count, not file count. Lazy child rendering on expand |
+| Windows path handling | Existing NFC normalization and path handling unchanged. New fields (`writable`, `removable`) are path-independent booleans. Drive letters work as root paths |
+| Migration data loss | Script backs up before changes. Idempotent. Tested on a staging node before production |
+
+---
+
+## 6. Files touched (by phase)
+
+### Phase 1
+```
+packages/meshbay-node/src/meshbay_node/config.py
+packages/meshbay-node/src/meshbay_node/roots.py
+packages/meshbay-node/src/meshbay_node/ops.py
+packages/meshbay-node/src/meshbay_node/roster.py
+packages/meshbay-node/src/meshbay_node/ui/app.py
+packages/meshbay-node/src/meshbay_node/daemon.py
+packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+packages/meshbay-common/src/meshbay_common/protocol.py
+packages/meshbay-common/src/meshbay_common/handshake.py
+packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
+packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+packages/meshbay-hub/src/meshbay_hub/static/files-app.js
+packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
+packages/meshbay-hub/src/meshbay_hub/static/apps.js
+packages/meshbay-hub/src/meshbay_hub/static/transport.js
+packages/meshbay-node/src/meshbay_node/indexer/indexer.py
+packages/meshbay-hub/src/meshbay_hub/static/video-app.js
+packages/meshbay-hub/src/meshbay_hub/static/music-app.js
+packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
+packages/meshbay-hub/src/meshbay_hub/static/locales/*.js
+```
+
+### Phase 2
+```
+packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js (new)
+packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js (new)
+packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js (new)
+packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js (new)
+packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js (new)
+packages/meshbay-hub/src/meshbay_hub/static/group-settings.js (extract)
+packages/meshbay-hub/src/meshbay_hub/static/apps.js
+packages/meshbay-node/src/meshbay_node/ops.py
+packages/meshbay-node/src/meshbay_node/ui/app.py
+packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+packages/meshbay-node/src/meshbay_node/linkpreview.py
+packages/meshbay-common/src/meshbay_common/protocol.py
+packages/meshbay-hub/src/meshbay_hub/static/locales/*.js
+```
+
+### Phase 3
+```
+packages/meshbay-hub/src/meshbay_hub/static/helloworld-app.js (new)
+packages/meshbay-hub/src/meshbay_hub/static/helloworld-app-settings.js (new)
+packages/meshbay-hub/src/meshbay_hub/static/apps.js
+packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+packages/meshbay-node/src/meshbay_node/daemon.py
+tests/test_cli_dispatch.py
+QE/migration/migrate_groups_v2.py (new, not versioned)
+```
+
+---
+
+## 7. Estimated effort
+
+| Phase | Scope | Approx. size |
+|---|---|---|
+| Phase 1 | Data model + shared dirs UI + eject/plug + CLI + remove uploads | ~1800 lines changed/added |
+| Phase 2 | Folder tree + 4 app-settings files + settings restructure + server normalization | ~1800 lines changed/added |
+| Phase 3 | HelloWorld + CLI polish + migration script | ~500 lines |
+
+Each phase is one focused Claude session. Test between phases.
+
+---
+
+## 7b. Phase 1 review (2026-09-06)
+
+What the plan above got wrong, and what was actually built. The first three
+entries are **rules for phases 2 and 3**, not one-off fixes: each describes a
+shape the same code can take again.
+
+### The rules
+
+**An operator is not sitting at their node.** The shared directories table read
+its roots exclusively from the loopback API (`platform.node.available`), which
+resolves to "not available" in a browser. So the section rendered for nobody on
+the web — while the Uploads controls it replaced *had* worked there — and the
+`transport.updateRoot` / `ejectRoot` / `plugRoot` methods written next to it
+were unreachable. §2.2 of the plan said "calls loopback API", and that was the
+mistake: MNP is the path that must exist, and loopback is the fallback for a
+local node with no live connection. Every operator-facing control phase 2 adds
+(the folder tree, four app settings panes, the link-preview toggle) needs the
+MNP route first. Pinned by `test_upload_controls_hidden.py`.
+
+**A reply that carries state nobody could have predicted has to be handed on.**
+`transport.js` resolves an admin `*_ack` against the pending request and
+returns, deliberately: every caller already updates local state from the value
+it chose. The root acks are not like that — they carry the node's whole roots
+table, including things only it knows (availability, the name it settled on,
+the eject a failed plug left in place). Returning left the operator who clicked
+Eject as the single client that never saw it happen, while every *other* peer
+got the broadcast. Any phase-2 op returning computed state has the same shape.
+
+**A control that writes needs to name where.** The node was given a `root`
+field on `file_upload` and no client ever sent it, so every upload went to
+`writable_roots[0]` while the Files toolbar offered the button based on the root
+being browsed. With two writable roots, uploading from one wrote into the other.
+This is the failure `_settle_upload_root`'s deleted docstring existed to
+prevent, reintroduced by removing it. Chat's attachments have the same problem
+one level up and get an explicit `attachRoot` until §1.7 gives them a
+configured directory.
+
+### The rest
+
+- **`ejected` was written to `roster.db` and never read back**, and the
+ auto-eject path did not persist at all. Both fixed; see §1.5b.
+- **`PUT /api/groups/{gid}/member-upload` became a stub returning `200
+ {"deprecated": true}`.** A route that answers OK and changes nothing is
+ indistinguishable from a working one to whoever calls it. Removed.
+- **The wizard ignored the first root's RW switch** — `ops.attach_group` always
+ wrote `writable = true`. It takes the flag now.
+- **`refresh_availability` was the only reader of a root's config.** The reload
+ path compared roots on `(name, path)`, so an operator editing `writable` in
+ `node.toml` and reloading saw nothing happen. The comparison includes the
+ flags.
+- **The table had no Path column** (§1.5 asked for one). Two libraries whose
+ folders share a basename are indistinguishable without it, and the basename is
+ the identity — so it is the one thing that has to be visible.
+- **Phase 1 shipped no tests.** 29 of the suite's failures were its own. The
+ gap that mattered was not the broken helpers but that eject, plug, per-root
+ upload refusal and the `node.toml` rewrite had no coverage at all:
+ `test_root_eject.py`, `test_root_writable_policy.py` and the new cases in
+ `test_ops.py` / `test_node_status.py` / `test_security_regressions.py` are
+ that. `test_member_upload_policy.py` is gone — it tested a removed feature.
+- **`chat-app.js` was in §2.2's file list and was never touched.**
+- **Eight of the ten locales were missing the new keys.** `test_locales.py`
+ holds them to `en.js`, so this was a failing test rather than a silent gap —
+ but it is worth noting that adding a key means adding it ten times.
+
+### Still open, deliberately
+
+- **The operator can no longer have a directory only they may write to.** RW is
+ open to every member; RO refuses everyone including the operator. This
+ reverses draft-v6's structural decision 9, which is annotated there. It is a
+ real capability removed, and if it turns out to be wanted the answer is a
+ third state on the root, not the old group-wide switch.
+- `test_ops.py::test_a_backslash_path_written_into_node_toml_stays_parseable`
+ fails on any non-Windows machine and always has — it builds a
+ `PurePosixPath` from a Windows path. Unrelated to this refactor, left alone.
+
+---
+
+## 7c. Phase 2 as built (2026-09-06)
+
+The plan held. Four things were done differently, and one of them is a rule.
+
+### The rule
+
+**A settings key added to the client must be added ten times.** `test_locales`
+holds the nine other catalogues to `en.js`, so a missing key is a failing test
+rather than a silent gap — but Phase 2 added 27 keys, and doing them one file
+at a time is how the Phase 1 gap happened. Write the table, generate the
+insert.
+
+**And a second one, which cost a bug in this phase:** `node --check foo.js`
+does **not** reliably report a module syntax error. It accepted a file with
+`${/* ... */''}` — htm template syntax, pasted into a plain object literal —
+and reported success. Copying to `.mjs` first forces the module parser, which
+reports it. `test_spa_syntax.py` now does that for every module; the suite had
+no syntax check at all before, which is how the file was committed.
+
+### Done differently
+
+- **Almost no migration script.** The plan (§4.3) called for one to rename
+ `video_root` → `video_directories` in `roster.db`. Instead the roster falls
+ back to the old key when the new one is unset, and the first save through the
+ new path leaves it behind. A script that has to be run by hand on the machine
+ where it matters is a step that does not happen; a fallback is one that
+ cannot be skipped. `node.toml` needs nothing either — `upload = true` is read
+ as `writable`.
+
+ **One transformation genuinely cannot be a fallback**, and an earlier draft
+ of this section wrongly said the script was unnecessary altogether. A group
+ whose operator had turned `member_upload` *off* has that switch ignored after
+ the upgrade, because nothing consults it any more — and its root still says
+ `upload = true`, so it accepts uploads from every member again. Nobody is
+ told. "Uploads are off for this group" and "this root is writable" are two
+ different sentences that happened to disagree, and only the operator knows
+ which they meant; there is nothing to infer. `QE/migration/check_upload_policy.py`
+ reads both files, reports the groups affected, and prints the `root set
+ --no-writable` line for each. Read-only, exits non-zero when something needs
+ a decision, so it can gate a deploy.
+- **`music`, not `audio`.** The app's registry key was `music` while its
+ storage said `audio_root` and its ops said `set_audio_root`. One identifier
+ per app now — the registry key — with the correspondence in exactly one
+ table (`Roster.LEGACY_DIR_KEYS`).
+- **One storage shape.** `set_app_directory` (single) writes a one-element
+ list, so there is no scalar form anywhere below the wire. `video_root` and
+ friends survive on the handshake ack only, *derived* from the list rather
+ than stored beside it — a second stored value drifts within one run, which
+ reads as "it works after a restart".
+- **The panes call the transport themselves.** The plan had every pane report
+ through one `onSave`, which would have made the page a dispatcher naming
+ every app's settings keys — the thing the phase exists to remove. The line
+ is: what every app has (directories) the page does, generically; what one
+ app alone has (a TMDB key, a link-preview switch) the pane does with the
+ transport it is handed. An app that only wants directories touches neither
+ file, which is `test_app_settings_plugin.py`'s subject.
+
+### Worth knowing
+
+- `settings-ui.js` exists because `group-settings` → `apps` → a pane →
+ `group-settings` is an import cycle, and ES modules answer that with a
+ temporal-dead-zone `ReferenceError` at first render — the component simply
+ does not appear, which is the fault already recorded in CLAUDE.md about hook
+ ordering. The shared widgets live outside both.
+- The folder picker asks the node for **nothing**. The tree is derived from
+ paths the client already holds, so it shows what the group's index contains
+ and no more — a folder the node never indexed does not exist as far as the
+ group is concerned. There is no folder-browsing protocol and this does not
+ add one.
+- `_ASSETS` in `webapp.py` had to grow by six. Modules reached through the
+ registry rather than imported by name are exactly the ones nothing else would
+ notice changing, and a stale one is served from a browser cache with no
+ version bump. `test_asset_versioning` caught it.
+- **What Phase 3 still owes:** the HelloWorld app (§4.1) — which is the actual
+ proof of the above, since every test here reads source rather than adding an
+ app and watching it work — plus the CLI polish and the Windows pass.
+
+---
+
+## 7d. Phase 3 as built (2026-09-07)
+
+### HelloWorld earned its place
+
+It was written last and immediately found two things no amount of source
+reading had: `group-settings.js` fell back to the *whole* registry when a group
+had no `enabled_apps` yet — which would have enabled a hidden app for everyone
+— and `group-page.js` wrote out `videoDirectories` / `musicDirectories` /
+`photoDirectories` by hand, so a fifth app would have needed that file edited.
+Both are fixed by making the code less app-specific, and the plugin claim is
+now true rather than nearly true.
+
+That is the argument for keeping it: every other test of the architecture reads
+source for the *absence* of app names, which proves nobody wrote a special case
+— not that a new app works. Deleting HelloWorld would leave the claim resting
+entirely on tests that read text.
+
+**It is hidden behind `?dev=1`**, not excluded from the build as §4.1 imagined.
+There is no build step to exclude it from, and an unregistered app proves
+nothing, since registration is exactly what is claimed to be sufficient. The
+flag is the same opt-in shape as `transport.js`'s `?trace=1`.
+
+### The CLI
+
+**`--upload-dir` is gone, not deprecated.** It was documented as the old
+spelling first, which was wrong and was caught in review. It wrote `upload_dir`
+into a *brand-new* `[[groups]]` block, and `GroupConfig.__post_init__` reads
+that key by forcing every other root read-only and appending that path as the
+one writable one — so `group add --dir X --writable --upload-dir Y` silently
+made X read-only. Two mechanisms deciding which directories accept uploads, one
+of them invisible, in a group created after the model that replaced it. The
+*read* path stays, because an existing node.toml must keep working; that is the
+only legitimate use, and nothing writes the key any more.
+
+`member upload` reached the generic usage line for the other `member` verbs —
+"usage: meshbay-node member upload <username>" — which advertises a removed
+feature and sends the operator looking for a username it would then reject. It
+names `root set --writable` now. `--upload-dir` still works, so an existing
+script keeps working, but its help and the man page say it is the old spelling.
+
+### Windows
+
+`test_windows_root_shapes.py` covers what can be covered from here: drive
+letters and UNC through `as_posix()` into TOML, a drive root having no basename
+to derive a name from, and a case-insensitive collision — which on NTFS and
+exFAT is one directory indexed as two roots. All pass.
+
+**What still needs a person on Windows**, and cannot be faked:
+
+- `ReadDirectoryChangesW` dropping events under load — the reason periodic
+ reconciliation is mandatory, and the reason eject exists at all
+- `MAX_PATH` against a deep library, on download and on upload
+- whether an eject actually lets the drive be removed, and a plug picks it back
+ up — the eject/plug pair is the least-exercised thing in all three phases
+- the folder-tree picker against backslash paths in the UI
+- the Create Group wizard with a drive-letter root
+
+### What the whole refactor still owes
+
+Nothing in the plan. Two things it did not think of:
+
+- **`_do_dir_delete` was never checked against RO/RW.** `_do_file_upload` and
+ `_do_dir_create` both gained the `writable` check; deletion is operator-only
+ and so is not the same hole, but the asymmetry is worth a look.
+- **`index_delta` carries roots but not `dirs`.** A folder created by another
+ member does not reach a connected client's folder picker until a full
+ `index_sync`. Small, and the picker offers root names from the roots table
+ regardless, so nothing is unreachable — but it is the same class as the bug
+ §7d's roots fix closed.
diff --git a/man/meshbay-node.1 b/man/meshbay-node.1
index 1e1173b..7c7cf96 100644
--- a/man/meshbay-node.1
+++ b/man/meshbay-node.1
@@ -68,12 +68,18 @@ List all hosted groups with their roots, key status, file count, and
connected peers.
.
.TP
-\fBgroup add\fR \fIname\fR \fB\-\-dir\fR \fIpath\fR [\fB\-\-upload\-dir\fR \fIpath\fR]
+\fBgroup add\fR \fIname\fR \fB\-\-dir\fR \fIpath\fR
Attach a hub\-side group to this node by writing a
.B [[groups]]
entry to
.IR node.toml .
The group must already exist on the hub.
+The directory becomes the group's first root, and is
+.B read\-write
+so that a new group can receive an upload without further configuration;
+pass
+.B \-\-no\-writable
+for a group that only publishes.
Run
.B meshbay\-node reload
afterwards, then
@@ -88,6 +94,54 @@ Asks for confirmation unless
.B \-\-yes
is given.
.
+.SS Root management
+A group has one or more named roots: directories on this machine that its
+members see. Each is read\-only or read\-write, independently; a group whose
+roots are all read\-only is valid and accepts no uploads at all.
+.
+.TP
+.B root list
+List this group's roots with their flags and current availability.
+.
+.TP
+\fBroot add\fR \fIpath\fR [\fB\-\-name\fR \fIname\fR] [\fB\-\-writable\fR] [\fB\-\-removable\fR]
+Add a directory to the group. The name defaults to the directory's
+basename; two roots in a group cannot share a name, compared without
+regard to case, and no root may sit inside another.
+Run
+.B meshbay\-node reload
+afterwards to start indexing it.
+.
+.TP
+\fBroot remove\fR \fIname\fR
+Remove a root from the group. Files on disk are untouched; only
+.I node.toml
+changes. The last remaining root cannot be removed.
+Asks for confirmation unless
+.B \-\-yes
+is given.
+.
+.TP
+\fBroot set\fR \fIname\fR [\fB\-\-writable\fR|\fB\-\-no\-writable\fR] [\fB\-\-removable\fR|\fB\-\-no\-removable\fR]
+Change a root's flags without removing it. Takes effect immediately; no
+reload is needed.
+.
+.TP
+\fBroot eject\fR \fIname\fR
+Mark a removable root as ejected before physically disconnecting the
+device. Its files are hidden from members and its index entries are
+frozen \(em nothing is deleted \(em and the directory watcher stops, so
+the unplug produces no deletions to propagate. The device can then be
+removed safely. Refused on a root that is not marked
+.BR removable .
+.
+.TP
+\fBroot plug\fR \fIname\fR
+Re\-enable an ejected root once the device is back. Refused if the
+directory is not readable, since clearing the flag while the device is
+still absent would hand the next scan an empty directory. The root is
+rescanned, so anything that changed while it was away is picked up.
+.
.SS Member management
.TP
.B member list
@@ -113,6 +167,14 @@ encryption key on their next connection. Rotate the GEK afterwards with
\fBmember unpin\fR \fIusername\fR
Forget a member's pinned key, allowing them to pair again with a new one.
.
+.TP
+.B member upload
+Removed. Whether uploads are accepted is a property of each directory now, not
+a per\-group switch \(em see
+.BR "root set" .
+A group whose directories are all read\-only accepts no uploads at all, which
+is what turning the old switch off meant.
+.
.SS Group encryption key (GEK)
.TP
.B gek init
@@ -196,11 +258,33 @@ Shared directory, used with
.BR "group add" .
.
.TP
-\fB\-\-upload\-dir\fR \fIpath\fR
-Separate upload directory, used with
-.BR "group add" .
-Files land directly in this directory (not in a subdirectory) and it
-appears as its own root in the index.
+.BR \-\-writable ", " \-\-no\-writable
+Whether a root accepts uploads from group members, used with
+.BR "root add" ", " "root set" " and " "group add" .
+Uploads land in an
+.I uploads
+subdirectory of the root; existing files are never replaced.
+A new root is read\-only unless
+.B \-\-writable
+is given; the directory passed to
+.B "group add"
+is the exception and is writable by default.
+.
+.TP
+.BR \-\-removable ", " \-\-no\-removable
+Whether a root lives on a device that gets disconnected, used with
+.BR "root add" " and " "root set" .
+Enables
+.BR "root eject" " and " "root plug" ,
+and makes the node treat the directory suddenly disappearing as an
+unannounced eject rather than as a deletion.
+.
+.TP
+\fB\-\-name\fR \fIname\fR
+Explicit name for a root, used with
+.BR "root add" .
+Default: the directory's basename. Required for a drive or filesystem
+root, which has no basename to derive one from.
.
.TP
.B \-\-yes
@@ -343,15 +427,21 @@ Human\-readable group name.
.
.TP
.B shared_dir
-Single\-directory shorthand: equivalent to declaring one root named after
-the directory's basename, which receives uploads. Cannot be combined with
+Single\-directory shorthand: equivalent to declaring one read\-write root
+named after the directory's basename. Cannot be combined with
.BR [[groups.roots]] .
.
.TP
.B upload_dir
-A separate filesystem path for uploads. Files land directly in it (not in
-a subdirectory) and it appears as its own root in the index. When set,
-no other root receives uploads.
+Deprecated, and read only. A separate filesystem path for uploads, from before
+roots carried their own read\-write flag. An existing configuration using it
+still works: it is read as a second, writable root, and every other root of
+that group is forced read\-only. Nothing writes it any more \(em there is no
+\fB\-\-upload\-dir\fR option \(em because that would be a second mechanism
+deciding which directories accept uploads, silently overriding
+.BR writable .
+To have two writable directories, mark them both
+.BR "writable = true" .
.
.TP
.B visibility
@@ -399,10 +489,30 @@ A view hint: one of
Currently unused.
.
.TP
-.B upload
-Boolean. Exactly one root per group must receive uploads. Default:
+.B writable
+Boolean. Whether members may upload into this root. Uploads land in an
+.I uploads
+subdirectory; an existing file is never replaced. Any number of roots in
+a group may be writable, including none. Default:
+.BR false .
+.
+.TP
+.B removable
+Boolean. Whether this root lives on a device that gets disconnected.
+Enables
+.BR "meshbay\-node root eject" ,
+and makes the directory suddenly disappearing freeze the root rather
+than look like a deletion of everything in it. Default:
.BR false .
.
+.TP
+.B upload
+Deprecated spelling of
+.BR writable ,
+read for configurations written before the two were separated.
+.B writable
+wins where both appear.
+.
.SS [keystore]
.TP
.B path
diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py
index bf74052..b3e24e3 100644
--- a/packages/meshbay-common/src/meshbay_common/__init__.py
+++ b/packages/meshbay-common/src/meshbay_common/__init__.py
@@ -93,5 +93,5 @@ __version__ = "0.11.0"
# The index at rest, `index_progress` (counters only, never a path — see
# `groupbox.py` and daemon.py `_push_index_progress`), chat, and file content
# on the operator's disk are all deliberately unchanged.
-MNP_VERSION = "1.0"
+MNP_VERSION = "1.1"
MHP_VERSION = "0.1"
diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py
index 762a2d1..9a56934 100644
--- a/packages/meshbay-common/src/meshbay_common/adminop.py
+++ b/packages/meshbay-common/src/meshbay_common/adminop.py
@@ -106,6 +106,15 @@ OP_AUDIO_ROOT = "audio_root"
OP_PHOTO_ROOTS = "photo_roots"
OP_ROOT_ADD = "root_add"
OP_ROOT_REMOVE = "root_remove"
+# One op for every application's directories. The subject is
+# "<app>:<comma-joined sorted paths>" so what the operator is shown before
+# signing names both the app and the outcome — "video_root" alone said neither.
+OP_APP_DIRECTORIES = "app_directories"
+OP_CHAT_DIRECTORY = "chat_directory"
+OP_CHAT_LINK_PREVIEW = "chat_link_preview"
+OP_ROOT_UPDATE = "root_update"
+OP_ROOT_EJECT = "root_eject"
+OP_ROOT_PLUG = "root_plug"
OP_GROUP_ATTACH = "group_attach"
OP_GROUP_DETACH = "group_detach"
# OP_GEK_BUNDLE_STORE is gone. Members no longer hand the node key material at
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py
index e502379..689adbf 100644
--- a/packages/meshbay-common/src/meshbay_common/protocol.py
+++ b/packages/meshbay-common/src/meshbay_common/protocol.py
@@ -164,6 +164,24 @@ class MNP:
ROOT_ADD_ACK = "root_add_ack" # node → operator: confirmed
ROOT_REMOVE = "root_remove" # operator → node: remove a root from a group
ROOT_REMOVE_ACK = "root_remove_ack" # node → operator: confirmed
+ # One message for every application's directories, keyed by the app's own
+ # name — adding an app adds no message type. VIDEO_ROOT / AUDIO_ROOT /
+ # PHOTO_ROOTS above are the same instruction under three earlier names and
+ # are still handled, for clients that predate this.
+ APP_DIRECTORIES = "app_directories" # operator → node: an app's folder(s)
+ APP_DIRECTORIES_ACK = "app_directories_ack"
+ # Chat's own two: where attachments are written (a destination, so it must
+ # be a read-write root), and whether the node unfurls links members post.
+ CHAT_DIRECTORY = "chat_directory"
+ CHAT_DIRECTORY_ACK = "chat_directory_ack"
+ CHAT_LINK_PREVIEW = "chat_link_preview"
+ CHAT_LINK_PREVIEW_ACK = "chat_link_preview_ack"
+ ROOT_UPDATE = "root_update" # operator → node: change writable/removable on a root
+ ROOT_UPDATE_ACK = "root_update_ack"
+ ROOT_EJECT = "root_eject" # operator → node: mark removable root as ejected
+ ROOT_EJECT_ACK = "root_eject_ack"
+ ROOT_PLUG = "root_plug" # operator → node: re-enable an ejected root
+ ROOT_PLUG_ACK = "root_plug_ack"
ROSTER_READ = "roster_read" # operator → node: list pinned identities + members
ROSTER_READ_ACK = "roster_read_ack"
DENYLIST_READ = "denylist_read" # operator → node: show denylist entries
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
index a1807ea..3cfb208 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
@@ -38,6 +38,18 @@ _ASSETS = ("style.css", "keyderive.js", "crypto.js", "transport.js", "app.js",
"chat-app.js", "files-app.js", "video-player.js", "video-app.js",
"music-app.js", "music-player.js", "photos-app.js",
"group-settings.js", "group-page.js",
+ # The per-app settings architecture (docs/refactor-groups.md §3):
+ # the shared widgets, the folder picker, and one settings pane per
+ # app. Reached through the `apps.js` registry rather than imported
+ # by name anywhere, which is exactly why they have to be listed —
+ # nothing else would notice one of them changing.
+ "settings-ui.js", "folder-tree.js",
+ "chat-app-settings.js", "video-app-settings.js",
+ "music-app-settings.js", "photos-app-settings.js",
+ # The reference app (docs/refactor-groups.md §4.1). Hidden behind
+ # `?dev=1` client-side, but it is still served and still cached, so
+ # it participates in the hash like anything else here.
+ "helloworld-app.js", "helloworld-app-settings.js",
# Pages extracted from app.js — statically imported or lazy-loaded,
# but all must participate in the content hash.
"auth-page.js", "explore-page.js", "create-group-page.js",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/apps.js b/packages/meshbay-hub/src/meshbay_hub/static/apps.js
index 47b5bba..461bd57 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/apps.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/apps.js
@@ -3,32 +3,97 @@ import { FilesPanel } from './files-app.js';
import { VideoApp } from './video-app.js';
import { MusicApp } from './music-app.js';
import { PhotosApp } from './photos-app.js';
+import { ChatSettings } from './chat-app-settings.js';
+import { VideoSettings } from './video-app-settings.js';
+import { MusicSettings } from './music-app-settings.js';
+import { PhotoSettings } from './photos-app-settings.js';
+import { HelloWorldApp } from './helloworld-app.js';
+import { HelloWorldSettings } from './helloworld-app-settings.js';
+
+/**
+ * Whether apps marked `dev` are shown. Opt in once with `?dev=1`, off with
+ * `?dev=0` — persisted, the same shape as transport.js's `?trace=1`.
+ *
+ * HelloWorld exists to prove that adding an application is a registry entry
+ * and two files. Registering it normally would put a toy app in every
+ * operator's group; leaving it unregistered would prove nothing, since the
+ * claim is precisely that registration is enough. So it is registered, and
+ * hidden behind a flag that changes nothing else.
+ */
+const DEV_KEY = 'mb_dev_apps';
+(function _initDevFlag() {
+ try {
+ const params = new URLSearchParams(location.search);
+ if (params.has('dev')) {
+ if (params.get('dev') === '0') localStorage.removeItem(DEV_KEY);
+ else localStorage.setItem(DEV_KEY, '1');
+ }
+ } catch { /* localStorage unavailable — dev apps stay hidden */ }
+})();
+
+function devAppsShown() {
+ try { return localStorage.getItem(DEV_KEY) === '1'; } catch { return false; }
+}
/**
* Every group "application", in tab order.
*
- * Adding one (Videos, Music, Photos — none of them need an MNP change, see
- * the node's indexer classifying video/audio/image already) means a new file
- * exporting a component and one entry here. Nothing in group-page.js changes:
- * every registered component receives the same shared context (see its
- * `commonProps`) and renders itself into the active tab.
+ * Adding one means a new file exporting a component, an optional second file
+ * exporting its settings pane, and one entry here. Nothing in `group-page.js`
+ * or `group-settings.js` changes: every registered component receives the same
+ * shared context (see `commonProps`) and renders itself into the active tab,
+ * and every registered `Settings` gets its own collapsible section with a
+ * toggle, rendered by a loop that names no app.
*
- * `key` doubles as the identifier the node's `apps_enabled` setting uses, so
- * it must match `ALLOWED_APPS` in the node's webrtc_server.py.
+ * `key` doubles as the identifier the node's `apps_enabled` setting and its
+ * `app_directories` op use, so it must match `ALLOWED_APPS` in the node's
+ * webrtc_server.py. It is also the key an app's directories are stored under
+ * (`<key>_directories`) — one identifier per app, everywhere.
+ *
+ * Fields:
+ * key the identifier, shared with the node
+ * icon, labelKey the tab
+ * Component the app itself
+ * Settings its operator settings pane, if it has any (optional)
+ * alwaysEnabled cannot be turned off, and is not offered as a toggle
*/
const APPS = [
- { key: 'chat', icon: 'chat', labelKey: 'group.tab_chat', Component: ChatPanel },
- { key: 'files', icon: 'folder', labelKey: 'group.tab_files', Component: FilesPanel },
- { key: 'video', icon: 'video', labelKey: 'group.tab_video', Component: VideoApp },
- { key: 'music', icon: 'music', labelKey: 'group.tab_music', Component: MusicApp },
- { key: 'photo', icon: 'image', labelKey: 'group.tab_photos', Component: PhotosApp },
+ { key: 'chat', icon: 'chat', labelKey: 'group.tab_chat',
+ Component: ChatPanel, Settings: ChatSettings },
+ // Files has no settings of its own: it works over every shared directory by
+ // definition, which is what the shared-directories table already configures.
+ { key: 'files', icon: 'folder', labelKey: 'group.tab_files',
+ Component: FilesPanel, alwaysEnabled: true },
+ { key: 'video', icon: 'video', labelKey: 'group.tab_video',
+ Component: VideoApp, Settings: VideoSettings },
+ { key: 'music', icon: 'music', labelKey: 'group.tab_music',
+ Component: MusicApp, Settings: MusicSettings },
+ { key: 'photo', icon: 'image', labelKey: 'group.tab_photos',
+ Component: PhotosApp, Settings: PhotoSettings },
+ // The reference implementation (docs/refactor-groups.md §4.1). `dev` keeps
+ // it out of an operator's way; everything else about it is an ordinary
+ // entry, which is the point.
+ { key: 'helloworld', icon: 'chat', labelKey: 'group.tab_helloworld',
+ Component: HelloWorldApp, Settings: HelloWorldSettings, dev: true },
];
+/** Everything a reader of this page is allowed to see, in registry order. */
+function availableApps() {
+ const dev = devAppsShown();
+ return APPS.filter(a => dev || !a.dev);
+}
+
/** The registry filtered to what this group has enabled, in registry order. */
function visibleApps(enabledKeys) {
+ const registered = availableApps();
const enabled = new Set(
- enabledKeys && enabledKeys.length ? enabledKeys : APPS.map(a => a.key));
- return APPS.filter(a => enabled.has(a.key));
+ enabledKeys && enabledKeys.length ? enabledKeys : registered.map(a => a.key));
+ return registered.filter(a => a.alwaysEnabled || enabled.has(a.key));
+}
+
+/** The apps the Settings page offers a section for, in registry order. */
+function configurableApps() {
+ return availableApps().filter(a => !a.alwaysEnabled && a.Settings);
}
-export { APPS, visibleApps };
+export { APPS, availableApps, visibleApps, configurableApps };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js
new file mode 100644
index 0000000..96fc52f
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js
@@ -0,0 +1,67 @@
+import { html, useState, useEffect } from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { ToggleSwitch, useSaver } from './settings-ui.js';
+import { FolderPickerField } from './folder-tree.js';
+
+/**
+ * Chat's operator settings.
+ *
+ * Every app's settings pane takes the same props (see `apps.js`): the group's
+ * roots and known folders, the node's current answers, a `saveDirectories`
+ * bound to this app, and the transport for anything the app alone needs. It
+ * owns its drafts and its own busy state, and the page renders it without
+ * naming it.
+ *
+ * The directory here is unlike every other app's. Videos, Music and Photos
+ * point at folders they *read*; this is where attachments get *written*, so it
+ * has to be on a read-write root. The picker greys out the rest rather than
+ * letting the node's refusal arrive after the fact.
+ */
+function ChatSettings({ roots, dirs, settings, saveDirectories, transport, signFn }) {
+ const { busy, msg, run } = useSaver();
+ const [directory, setDirectory] = useState(settings.chatDirectory || '');
+ const [linkPreview, setLinkPreview] = useState(settings.chatLinkPreview !== false);
+
+ // Re-seeded from the node's answer: another operator may be editing the
+ // same group, and their change arrives here as a prop.
+ useEffect(() => { setDirectory(settings.chatDirectory || ''); },
+ [settings.chatDirectory]);
+ useEffect(() => { setLinkPreview(settings.chatLinkPreview !== false); },
+ [settings.chatLinkPreview]);
+
+ const noWritable = !(roots || []).some((r) => r.writable);
+ const dirty = directory !== (settings.chatDirectory || '');
+
+ return html`
+ <div class="app-settings">
+ ${noWritable && html`
+ <p class="settings-hint">${t('settings_app.chat_no_writable_root')}</p>`}
+
+ <${FolderPickerField}
+ label=${t('settings_app.chat_directory_label')}
+ hint=${t('settings_app.chat_directory_hint')}
+ roots=${roots} dirs=${dirs}
+ mode="single" requireWritable=${true}
+ value=${directory} disabled=${busy || noWritable}
+ onChange=${setDirectory} />
+
+ <button class="app-save" disabled=${busy || !dirty}
+ onClick=${() => run(() => transport.setChatDirectory(directory, signFn))}>
+ ${busy ? t('settings_app.saving') : t('settings_app.save')}
+ </button>
+
+ <div class="settings-row" style="margin-top:12px">
+ <${ToggleSwitch} checked=${linkPreview} disabled=${busy}
+ onChange=${(v) => {
+ setLinkPreview(v);
+ run(() => transport.setChatLinkPreview(v, signFn));
+ }}
+ label=${t('settings_app.chat_link_preview_label')} />
+ <p class="settings-hint">${t('settings_app.chat_link_preview_hint')}</p>
+ </div>
+ ${msg && html`<p class="settings-hint">${msg}</p>`}
+ </div>
+ `;
+}
+
+export { ChatSettings };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
index 3bc110f..0a7ef26 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
@@ -198,8 +198,14 @@ function ChatImage({ filename, entries, transportRef, gekRef }) {
return html`<img class="chat-att-thumb" src=${blobUrl} alt=${filename} />`;
}
+// `attachRoot` is the shared directory attachments are written to: the name of
+// the first writable, available root, decided in group-page.js so Files and Chat
+// read one answer. Empty means the group has no writable root right now — every
+// root is read-only, or the one drive that was writable is unplugged — and the
+// paperclip says so rather than producing a refusal from the node.
function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
- onPreview, mayUpload = true, onActivity, status }) {
+ onPreview, attachRoot = '', attachDir = '',
+ onActivity, status }) {
const [messages, setMessages] = useState([]);
const [hasMore, setHasMore] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
@@ -479,7 +485,10 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
try {
// Two people sending IMG_1234.jpg both succeed; the node picks a free name
// and the message has to point at the one it chose.
- const ack = await transport.uploadFile(file);
+ // `attachDir` is the folder the operator chose in Settings; `attachRoot`
+ // is the fallback for a group where they have not chosen one yet.
+ const ack = await transport.uploadFile(
+ file, { root: attachRoot, dir: attachDir || undefined });
const storedAs = (ack && ack.stored_as) || file.name;
await new Promise(r => setTimeout(r, 2500));
if (onRefreshIndex) await onRefreshIndex();
@@ -501,7 +510,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
} finally {
setAttaching(false);
}
- }, [username, onRefreshIndex, jumpToBottom]);
+ }, [username, onRefreshIndex, jumpToBottom, attachRoot, attachDir]);
const onKeyDown = useCallback((e) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -592,12 +601,16 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
</button>
`}
<div class="chat-input-row">
- ${mayUpload && html`
+ ${attachRoot ? html`
<label class="chat-attach" title="${t('chat.attach')}">
${attaching ? html`<span class="spinner"></span>`
: html`<${Icon} name="clip" />`}
<input type="file" style="display:none" onChange=${attachFile} disabled=${attaching} />
</label>
+ ` : html`
+ <span class="chat-attach chat-attach-off" title="${t('chat.attach_read_only')}">
+ <${Icon} name="clip" />
+ </span>
`}
<textarea class="chat-input" rows="1" ref=${inputRef}
placeholder="${t('chat.placeholder')}"
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
index d86521f..c3b542f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
@@ -5,7 +5,7 @@ import { t } from './i18n.js';
import { HUB, hubFetch, session, navigate } from './hub-client.js';
import * as platform from './platform.js';
import { Icon } from './icon.js';
-import { APPS } from './apps.js';
+import { SharedDirectoriesTable } from './group-settings.js';
export function CreateGroupPage(props) {
if (platform.node.available) return html`<${CreateGroupWizard} ...${props} />`;
@@ -109,13 +109,6 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
const [description, setDescription] = useState('');
const [joinPolicy, setJoinPolicy] = useState('invite');
const [roots, setRoots] = useState([]);
- const [uploadIdx, setUploadIdx] = useState(0);
- const [enabledApps, setEnabledApps] = useState(() => APPS.map(a => a.key));
- const toggleWizardApp = useCallback((key) => {
- setEnabledApps(prev => prev.includes(key)
- ? prev.filter(k => k !== key)
- : [...prev, key]);
- }, []);
const [setupSteps, setSetupSteps] = useState([]);
const [setupError, setSetupError] = useState('');
@@ -166,20 +159,9 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
useEffect(() => { detectNode(); }, [detectNode]);
- const addRoot = useCallback(async () => {
- const chosen = await platform.rootPicker.choose();
- if (!chosen) return;
- if (roots.some(r => r.path === chosen.path)) return;
- setRoots(prev => [...prev, chosen]);
- }, [roots]);
-
- const removeRoot = useCallback((idx) => {
- setRoots(prev => {
- const next = prev.filter((_, i) => i !== idx);
- if (uploadIdx >= next.length && next.length > 0) setUploadIdx(0);
- return next;
- });
- }, [uploadIdx]);
+ const handleLocalRootsChange = useCallback((newRoots) => {
+ setRoots(newRoots);
+ }, []);
const runSetup = useCallback(async () => {
setStep(2);
@@ -189,7 +171,6 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
{ label: t('wizard.step_attach'), status: 'pending' },
];
steps.push({ label: t('wizard.step_index'), status: 'pending' });
- steps.push({ label: t('wizard.step_apps'), status: 'pending' });
if (roots.length > 1)
steps.push({ label: t('wizard.step_add_roots'), status: 'pending' });
steps.push({ label: t('wizard.step_gek'), status: 'pending' });
@@ -231,11 +212,16 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
// 2. Attach to node with first root
update('running');
- const mainRoot = roots[uploadIdx] || roots[0];
- const attachBody = { name: name.trim(), shared_dir: mainRoot.path };
- if (roots.length === 1 || uploadIdx === 0) {
- attachBody.upload_dir = mainRoot.path;
- }
+ // The first root is attached with the group, so its RW switch has to
+ // travel with it — writing it and then correcting it afterwards would
+ // leave a window where a group the operator marked read-only accepts
+ // uploads.
+ const mainRoot = roots[0];
+ const attachBody = {
+ name: name.trim(),
+ shared_dir: mainRoot.path,
+ writable: mainRoot.writable !== false,
+ };
await platform.node.call('POST', '/api/groups/attach', attachBody);
await platform.node.call('POST', '/api/reload');
update('done');
@@ -247,22 +233,13 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
update('done');
advance();
- // 4. Set enabled apps
- update('running');
- await withRetry(() => platform.node.call(
- 'PUT', `/api/groups/${gid}/apps`, { apps: enabledApps }));
- update('done');
- advance();
-
- // 5. Add extra roots (if >1)
+ // 4. Add extra roots (if >1)
if (roots.length > 1) {
update('running');
- for (let i = 0; i < roots.length; i++) {
- if (i === (uploadIdx < roots.length ? uploadIdx : 0)) continue;
+ for (let i = 1; i < roots.length; i++) {
const r = roots[i];
await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/roots`, {
- path: r.path, name: r.name,
- upload: i === uploadIdx,
+ path: r.path, name: r.name, writable: !!r.writable, removable: !!r.removable,
}));
}
await platform.waitForRootsIndexed(gid, setIndexProgress);
@@ -270,13 +247,13 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
advance();
}
- // 6. GEK init
+ // 5. GEK init
update('running');
await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/gek`));
update('done');
advance();
- // 7. Generate pairing code
+ // 6. Generate pairing code
update('running');
const pairResult = await platform.node.call('POST', '/api/operator/pair');
if (pairResult && pairResult.code) {
@@ -293,7 +270,7 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
update('error');
setSetupError(platform.bridgeMessage(err));
}
- }, [name, description, joinPolicy, roots, uploadIdx, enabledApps, token, onCreated]);
+ }, [name, description, joinPolicy, roots, token, onCreated]);
// Step 0: Node detection
if (step === 0) {
@@ -349,7 +326,7 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
</div>`;
}
if (step === 1) {
- const canProceed = name.trim() && roots.length > 0 && enabledApps.length > 0;
+ const canProceed = name.trim() && roots.length > 0;
return html`<div class="page-content">
<h2>${t('wizard.title')}</h2>
${error && html`<div class="error-msg" style="margin-bottom:16px">${error}</div>`}
@@ -398,52 +375,11 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
`}
<div class="settings-section">
- <h3 class="settings-heading">${t('members.apps_title')}</h3>
- <p style="font-size:0.85em;color:var(--text-dim);margin-bottom:8px">
- ${t('members.apps_hint')}</p>
- <ul class="apps-toggle-list">
- ${APPS.map(a => html`
- <li key=${a.key} class="settings-row">
- <label class="settings-label">
- <input type="checkbox" checked=${enabledApps.includes(a.key)}
- onChange=${() => toggleWizardApp(a.key)} />
- ${' '}${t(a.labelKey)}
- </label>
- </li>
- `)}
- </ul>
- ${enabledApps.length === 0 && html`
- <p class="error-msg">${t('members.apps_need_one')}</p>`}
- </div>
-
- <div class="settings-section">
<h3 class="settings-heading">${t('wizard.directories')}</h3>
<p style="font-size:0.85em;color:var(--text-dim);margin-bottom:8px">
${t('wizard.directories_hint')}</p>
- ${roots.map((r, i) => html`
- <div class="wizard-root" key=${r.path}>
- <div class="wizard-root-info">
- <${Icon} name="folder" />
- <span class="wizard-root-name">${r.name}</span>
- <span class="wizard-root-path">${r.path}</span>
- ${i === uploadIdx && html`
- <span class="node-root-badge">${t('wizard.upload_target')}</span>`}
- </div>
- <div class="wizard-root-actions">
- ${roots.length > 1 && i !== uploadIdx && html`
- <button class="btn btn-small btn-secondary"
- onClick=${() => setUploadIdx(i)}>
- ${t('wizard.set_upload')}</button>`}
- <button class="btn btn-small btn-danger"
- onClick=${() => removeRoot(i)}>
- ${t('wizard.remove')}</button>
- </div>
- </div>
- `)}
- <button class="btn btn-secondary" style="margin-top:8px"
- onClick=${addRoot}>
- <${Icon} name="folder-plus" /> ${t('wizard.add_directory')}
- </button>
+ <${SharedDirectoriesTable} mode="local"
+ localRoots=${roots} onLocalRootsChange=${handleLocalRootsChange} />
</div>
<div style="display:flex;gap:8px;margin-top:16px">
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
index 195c385..4947f8c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
@@ -26,7 +26,7 @@ import {
function FilesPanel({
groupId, transportRef, gekRef, status,
entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex,
- isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview,
+ isNodeAdmin, operatorPaired, userId, setError, onPreview,
showGroup, readOnly, getTransport, onRefreshIndex, showRefresh,
}) {
const [selected, setSelected] = useState(() => new Set());
@@ -75,6 +75,12 @@ function FilesPanel({
e.target.value = '';
const transport = transportRef.current;
if (!files.length || !transport || !transport.connected) return;
+ // The folder on screen is the destination — not its root, and not a
+ // subdirectory of the node's invention. Somebody dropping a file into the
+ // folder they are looking at expects it to be in that folder.
+ const uploadDir = currentPath;
+ if (!uploadDir) return;
+ const uploadRoot = uploadDir.split('/')[0];
setError('');
for (const file of files) {
@@ -85,6 +91,8 @@ function FilesPanel({
// Bytes the node acknowledged, not bytes read locally.
onProgress: (sent) => onProgress(sent, file.size),
signal,
+ root: uploadRoot,
+ dir: uploadDir,
});
// The node re-indexes on a filesystem event, so there is nothing to
// wait on but the clock. Refreshing here means the file appears in
@@ -94,21 +102,49 @@ function FilesPanel({
},
});
}
- }, [applyIndex]);
+ }, [applyIndex, currentPath]);
+
+ // `null` while not creating one; a draft string while the field is open.
+ const [newDirName, setNewDirName] = useState(null);
+ const [creatingDir, setCreatingDir] = useState(false);
+
+ /**
+ * Create a folder in the directory being browsed.
+ *
+ * The name comes from a field in the toolbar rather than `window.prompt`,
+ * which **throws** in Electron — "prompt() is not supported" — and threw
+ * outside this function's try, so clicking the button did nothing at all:
+ * no folder, no error, nothing in the interface to react to. `confirm()` and
+ * `alert()` do work there and are used elsewhere; `prompt` is the one
+ * Chromium leaves to the embedder and Electron declines to implement.
+ *
+ * An inline field is better anyway — it can show the refusal next to the
+ * input instead of after the dialog has closed.
+ */
+ useEffect(() => { setNewDirName(null); }, [currentPath]);
- const makeDirectory = useCallback(async () => {
+ const makeDirectory = useCallback(async (rawName) => {
const transport = transportRef.current;
- if (!transport || !transport.connected) return;
- const name = prompt(t('group.mkdir_prompt'));
- if (!name || !name.trim()) return;
+ const name = (rawName || '').trim();
+ if (!name) return;
+ if (!transport || !transport.connected) {
+ setError(t('group.mkdir_offline'));
+ return;
+ }
+ setCreatingDir(true);
try {
- await transport.createDirectory(currentPath, name.trim());
+ await transport.createDirectory(currentPath, name);
+ // `list_dirs` walks the filesystem rather than the index, so a folder
+ // with nothing in it is here on this very fetch.
const indexMsg = await transport.fetchIndex();
if (indexMsg.entries) setEntries(indexMsg.entries);
if (indexMsg.dirs) setNodeDirs(indexMsg.dirs);
if (indexMsg.roots) setNodeRoots(indexMsg.roots);
+ setNewDirName(null);
} catch (err) {
setError(err.message);
+ } finally {
+ setCreatingDir(false);
}
}, [currentPath]);
@@ -212,11 +248,27 @@ function FilesPanel({
const unavailableHere = currentPath
? []
: subdirs.filter(d => rootState.get(d) && rootState.get(d).available === false);
+ const currentRootName = currentPath ? currentPath.split('/')[0] : '';
+ const currentRoot = currentRootName ? rootState.get(currentRootName) : null;
+ // `upload` is the same answer under the name a node speaking MNP 1.0 uses;
+ // reading only `writable` there means the Upload button disappears on every
+ // node that has not been upgraded yet, which is most of them on the day the
+ // page ships.
+ const currentRootWritable = currentRoot
+ ? (currentRoot.writable !== undefined ? currentRoot.writable
+ : Boolean(currentRoot.upload))
+ : false;
// A member cannot create a folder at the top of a group: that level is the
// set of roots, which is the operator's configuration and not a directory on
// anyone's disk. The node refuses it, so offering it would only produce an
// error nobody can act on.
- const canCreateDir = Boolean(currentPath) && isNodeAdmin;
+ //
+ // Otherwise the rule is the same as the Upload button's, and for the same
+ // reason the node gives: "making a directory is not a privileged act — a
+ // member who can add a file can organise where it goes". It used to require
+ // `isNodeAdmin`, which contradicted the node and hid the control from
+ // everyone who could actually use it.
+ const canCreateDir = Boolean(currentPath) && currentRootWritable && !readOnly;
const breadcrumbs = currentPath ? currentPath.split('/') : [];
@@ -338,13 +390,47 @@ function FilesPanel({
${status === 'connected' && html`
<div class="file-toolbar">
<div class="toolbar-group">
- ${mayUpload && html`
+ ${currentPath && currentRootWritable && html`
<label class="tb-btn primary">
<${Icon} name="upload" /> ${t('group.upload')}
<input type="file" multiple style="display:none"
onChange=${uploadFile} />
</label>
`}
+ ${/* Icon only: the toolbar already carries a labelled primary
+ action, and a second one beside it competes with it for the
+ width a breadcrumb trail needs. The name lives in the tooltip
+ and in aria-label, so it is not lost to anyone reading with
+ something other than their eyes. */''}
+ ${canCreateDir && newDirName === null && html`
+ <button class="tb-btn tb-btn-icon" onClick=${() => setNewDirName('')}
+ title=${t('group.mkdir')} aria-label=${t('group.mkdir')}>
+ <${Icon} name="folder-plus" />
+ </button>
+ `}
+ ${canCreateDir && newDirName !== null && html`
+ <span class="tb-mkdir">
+ <input type="text" class="tb-mkdir-input" autofocus
+ value=${newDirName} disabled=${creatingDir}
+ placeholder=${t('group.mkdir_prompt')}
+ aria-label=${t('group.mkdir')}
+ onInput=${(e) => setNewDirName(e.target.value)}
+ onKeyDown=${(e) => {
+ if (e.key === 'Enter') makeDirectory(newDirName);
+ if (e.key === 'Escape') setNewDirName(null);
+ }} />
+ <button class="tb-btn tb-btn-icon" title=${t('group.mkdir')}
+ disabled=${creatingDir || !newDirName.trim()}
+ onClick=${() => makeDirectory(newDirName)}>
+ <${Icon} name="check" />
+ </button>
+ <button class="tb-btn tb-btn-icon" title=${t('settings.cancel')}
+ disabled=${creatingDir}
+ onClick=${() => setNewDirName(null)}>
+ <${Icon} name="close" />
+ </button>
+ </span>
+ `}
</div>
<div class="breadcrumbs">
@@ -419,16 +505,40 @@ function FilesPanel({
const full = currentPath ? currentPath + '/' + d : d;
const inside = entriesUnder(entries, full);
const bytes = inside.reduce((n, f) => n + (f.entry.size || 0), 0);
+ const rs = rootState.get(d);
+ const isEjected = rs && rs.ejected;
+ const isUnavail = unavailableHere.includes(d);
+ const isRemovable = rs && rs.removable;
return html`
- <tr class="file-row dir-row" key=${full} onClick=${() => setCurrentPath(full)}>
+ <tr class="file-row dir-row${isEjected ? ' root-ejected' : ''}" key=${full}
+ onClick=${() => { if (!isEjected) setCurrentPath(full); }}>
<td class="sel-cell">
<input type="checkbox" checked=${selected.has(dirKey(d))}
onClick=${(ev) => ev.stopPropagation()}
onChange=${() => toggle(dirKey(d))} />
</td>
- <td>${unavailableHere.includes(d) ? '\u{26A0}' : '\u{1F4C1}'}</td>
- <td>${d}${unavailableHere.includes(d) ? html`
+ <td>${isEjected ? '\u{23CF}' : isUnavail ? '\u{26A0}' : '\u{1F4C1}'}</td>
+ <td>${d}${isEjected ? html`
+ <span class="root-offline"> ${t('group.root_ejected')}</span>
+ ` : isUnavail ? html`
<span class="root-offline"> ${t('group.root_unavailable')}</span>
+ ` : ''}${rs && rs.writable && !isEjected ? html`
+ <span class="root-rw" title="${t('group.root_writable')}" style="margin-left:8px;opacity:0.5;font-size:0.9em">✎</span>
+ ` : ''}${isRemovable && isNodeAdmin && operatorPaired ? html`
+ <button class="btn-small root-eject-btn" title=${isEjected ? t('group.root_plug') : t('group.root_eject')}
+ onClick=${(ev) => {
+ ev.stopPropagation();
+ const transport = transportRef.current;
+ if (!transport) return;
+ const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
+ const signFn = (sk && window.MeshBayKeys)
+ ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
+ : null;
+ const fn = isEjected
+ ? () => transport.plugRoot(groupId, d, signFn)
+ : () => transport.ejectRoot(groupId, d, signFn);
+ fn().catch((err) => setError(err.message));
+ }}>${isEjected ? '\u{1F50C}' : '\u{23CF}'}</button>
` : ''}</td>
<td class="file-size">${inside.length ? formatSize(bytes) : ''}</td>
${showGroup && html`<td></td>`}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js b/packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js
new file mode 100644
index 0000000..6e6dfad
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js
@@ -0,0 +1,298 @@
+import {
+ html, useState, useEffect, useMemo, useCallback, useRef,
+} from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { Icon } from './icon.js';
+
+/**
+ * A modal folder picker over a group's shared directories.
+ *
+ * It replaces the flat `<select>` of depth-indented paths every app settings
+ * pane used to carry. That control was defensible while an app picked one
+ * folder once; with several apps picking several folders each, a list of a few
+ * hundred `Media/Films/Action/1999` strings is not something anyone reads.
+ *
+ * **There is no folder-browsing protocol, and this does not add one.** The
+ * whole tree is derived from paths the client already holds — every entry's
+ * folder and every directory the index reports — so opening this asks the node
+ * nothing. That also means it shows exactly what the group's index contains:
+ * an empty folder the node never indexed is not in here, because as far as the
+ * group is concerned it does not exist.
+ *
+ * Props:
+ * roots — the group's roots ({ name, writable, removable, ejected,
+ * available }), for the badges and the writable rule
+ * dirs — every known directory path, `Media/Films` style
+ * mode — "single" (default) or "multi"
+ * requireWritable— grey out roots that do not accept writes, for a
+ * destination rather than a view (Chat's attachments)
+ * selected — current selection: a string in single mode, an array in
+ * multi
+ * onConfirm(sel) — called with the same shape on OK
+ * onCancel()
+ */
+function FolderTreePicker({
+ roots, dirs, mode = 'single', requireWritable = false,
+ selected, onConfirm, onCancel,
+}) {
+ const multi = mode === 'multi';
+ const initial = useMemo(() => {
+ if (multi) return new Set(selected || []);
+ return new Set(selected ? [selected] : []);
+ }, []); // eslint-disable-line -- the initial selection only, never a reset
+
+ const [picked, setPicked] = useState(initial);
+ const [expanded, setExpanded] = useState(() => new Set());
+ const panelRef = useRef(null);
+
+ // Escape closes, and the panel takes focus so it does — a modal that only
+ // responds to the mouse is one a keyboard user cannot leave.
+ useEffect(() => {
+ const onKey = (e) => { if (e.key === 'Escape') onCancel(); };
+ window.addEventListener('keydown', onKey);
+ if (panelRef.current) panelRef.current.focus();
+ return () => window.removeEventListener('keydown', onKey);
+ }, [onCancel]);
+
+ // Every ancestor of every known path, so a folder is reachable even when
+ // only something several levels below it was ever indexed.
+ const nodes = useMemo(() => {
+ const all = new Set();
+ for (const d of (dirs || [])) {
+ if (!d) continue;
+ const parts = d.split('/');
+ for (let i = 1; i <= parts.length; i++) all.add(parts.slice(0, i).join('/'));
+ }
+ // A root with nothing under it is still a choice: pointing an app at a
+ // library that has not been scanned yet is exactly what an operator does
+ // right after adding the directory.
+ for (const r of (roots || [])) all.add(r.name);
+ return all;
+ }, [dirs, roots]);
+
+ const childrenOf = useMemo(() => {
+ const map = new Map();
+ for (const path of nodes) {
+ const cut = path.lastIndexOf('/');
+ const parent = cut === -1 ? '' : path.slice(0, cut);
+ if (!map.has(parent)) map.set(parent, []);
+ map.get(parent).push(path);
+ }
+ for (const list of map.values()) {
+ list.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
+ }
+ return map;
+ }, [nodes]);
+
+ const rootByName = useMemo(
+ () => new Map((roots || []).map((r) => [r.name, r])), [roots]);
+
+ // A path's own root decides whether it can be picked: writability is a
+ // property of the root, and everything under it inherits.
+ const rootOf = useCallback(
+ (path) => rootByName.get(path.split('/')[0]) || null, [rootByName]);
+
+ const selectable = useCallback((path) => {
+ if (!requireWritable) return true;
+ const root = rootOf(path);
+ return Boolean(root && root.writable);
+ }, [requireWritable, rootOf]);
+
+ const toggleExpand = useCallback((path) => {
+ setExpanded((prev) => {
+ const next = new Set(prev);
+ if (next.has(path)) next.delete(path); else next.add(path);
+ return next;
+ });
+ }, []);
+
+ const choose = useCallback((path) => {
+ if (!selectable(path)) return;
+ setPicked((prev) => {
+ if (!multi) return new Set(prev.has(path) ? [] : [path]);
+ const next = new Set(prev);
+ if (next.has(path)) next.delete(path); else next.add(path);
+ return next;
+ });
+ }, [multi, selectable]);
+
+ // Everything already chosen is expanded on open, so the selection is
+ // visible rather than folded away inside a collapsed branch.
+ useEffect(() => {
+ const open = new Set();
+ for (const path of initial) {
+ const parts = path.split('/');
+ for (let i = 1; i < parts.length; i++) open.add(parts.slice(0, i).join('/'));
+ }
+ setExpanded(open);
+ }, [initial]);
+
+ const renderNode = (path, depth) => {
+ const kids = childrenOf.get(path) || [];
+ const isOpen = expanded.has(path);
+ const isRoot = depth === 0;
+ const root = isRoot ? rootByName.get(path) : null;
+ const name = isRoot ? path : path.slice(path.lastIndexOf('/') + 1);
+ const can = selectable(path);
+ const chosen = picked.has(path);
+
+ return html`
+ <li key=${path} class="ftp-node">
+ <div class="ftp-row ${chosen ? 'chosen' : ''} ${can ? '' : 'blocked'}"
+ style="padding-left:${depth * 18}px"
+ title=${can ? path : t('folder_tree.read_only_blocked')}>
+ <button class="ftp-twisty" disabled=${!kids.length}
+ aria-label=${isOpen ? t('folder_tree.collapse') : t('folder_tree.expand')}
+ onClick=${() => toggleExpand(path)}>
+ ${kids.length ? (isOpen ? '−' : '+') : ' '}
+ </button>
+ <button class="ftp-label" disabled=${!can} onClick=${() => choose(path)}>
+ <${Icon} name="folder" />
+ <span class="ftp-name">${name}</span>
+ ${isRoot && root && html`
+ <span class="ftp-badge ${root.writable ? 'rw' : 'ro'}">
+ ${root.writable ? t('node.root_rw') : t('node.root_ro')}
+ </span>`}
+ ${isRoot && root && root.ejected && html`
+ <span class="ftp-badge warn">${t('group.root_ejected')}</span>`}
+ ${isRoot && root && !root.ejected && root.available === false && html`
+ <span class="ftp-badge warn">${t('node.unavailable')}</span>`}
+ ${chosen && html`<span class="ftp-check">✓</span>`}
+ </button>
+ </div>
+ ${isOpen && kids.length > 0 && html`
+ <ul class="ftp-children">
+ ${kids.map((child) => renderNode(child, depth + 1))}
+ </ul>
+ `}
+ </li>
+ `;
+ };
+
+ const topLevel = childrenOf.get('') || [];
+ const chosenList = [...picked].sort();
+ const noWritableRoot = requireWritable
+ && !(roots || []).some((r) => r.writable);
+
+ return html`
+ <div class="ftp-backdrop" onClick=${onCancel}>
+ <div class="ftp-panel" tabindex="-1" ref=${panelRef}
+ onClick=${(e) => e.stopPropagation()}>
+ <h3 class="ftp-title">${t(multi ? 'folder_tree.title_multi'
+ : 'folder_tree.title_single')}</h3>
+ ${requireWritable && html`
+ <p class="settings-hint">${
+ noWritableRoot ? t('folder_tree.no_writable_root')
+ : t('folder_tree.writable_only')}</p>`}
+
+ ${topLevel.length === 0 ? html`
+ <p class="settings-hint">${t('folder_tree.empty')}</p>
+ ` : html`
+ <ul class="ftp-tree">${topLevel.map((p) => renderNode(p, 0))}</ul>
+ `}
+
+ <div class="ftp-selection">
+ ${chosenList.length
+ ? chosenList.map((p) => html`<code key=${p} class="ftp-chip">${p}</code>`)
+ : html`<span class="settings-hint">${t('folder_tree.nothing_selected')}</span>`}
+ </div>
+
+ <div class="ftp-actions">
+ <button class="btn btn-small" onClick=${onCancel}>
+ ${t('settings.cancel')}
+ </button>
+ ${/* OK is offered with nothing selected on purpose: clearing an
+ app's directories is a real choice, and the only way to make
+ it. */''}
+ <button class="btn btn-small btn-secondary"
+ onClick=${() => onConfirm(multi ? chosenList : (chosenList[0] || ''))}>
+ ${t('folder_tree.confirm')}
+ </button>
+ </div>
+ </div>
+ </div>
+ `;
+}
+
+/**
+ * The chosen folders, as a table, plus the button that opens the picker.
+ *
+ * Not a `.settings-row`: that class is `display:flex; justify-content:
+ * space-between`, so a label, a hint and a value laid out inside one end up
+ * spread across a single line in whatever order they were written — which is
+ * how the first version of this read as three unrelated fragments per app.
+ *
+ * A table rather than a row of chips because these are lists now. Videos and
+ * Music can hold several folders, Photos routinely does, and a wrapped run of
+ * chips gives no column to scan and nowhere to put a per-row control. One
+ * folder per line, removable where it sits, in the same shape as the shared
+ * directories table above it — the operator is looking at two lists of
+ * directories on one page and they should read alike.
+ */
+function FolderPickerField({
+ label, hint, roots, dirs, mode = 'single', requireWritable = false,
+ value, onChange, disabled,
+}) {
+ const [open, setOpen] = useState(false);
+ const multi = mode === 'multi';
+ const chosen = multi ? (value || []) : (value ? [value] : []);
+
+ const removeAt = (path) => {
+ if (!multi) { onChange(''); return; }
+ onChange(chosen.filter((p) => p !== path));
+ };
+
+ return html`
+ <div class="folder-field">
+ <div class="folder-field-head">
+ <h4 class="folder-field-label">${label}</h4>
+ ${hint && html`<p class="settings-hint">${hint}</p>`}
+ </div>
+
+ ${chosen.length > 0 && html`
+ <table class="shared-dirs-tbl folder-field-tbl">
+ <tbody>
+ ${chosen.map((path) => html`
+ <tr key=${path}>
+ <td class="sdt-col-dir">
+ <span class="sdt-dir-name">
+ <${Icon} name="folder" />
+ ${path}
+ </span>
+ </td>
+ <td class="sdt-col-actions">
+ <button class="sdt-action-btn sdt-action-danger"
+ disabled=${disabled}
+ title=${t('folder_tree.remove')}
+ onClick=${() => removeAt(path)}>\u{2715}</button>
+ </td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ `}
+
+ <div class="folder-field-actions">
+ ${chosen.length === 0 && html`
+ <span class="settings-hint">${t('folder_tree.nothing_selected')}</span>`}
+ <button class="btn btn-small btn-secondary" disabled=${disabled}
+ onClick=${() => setOpen(true)}>
+ <${Icon} name="folder" />
+ ${' '}${chosen.length && multi ? t('folder_tree.add')
+ : t('folder_tree.choose')}
+ </button>
+ </div>
+
+ ${open && html`
+ <${FolderTreePicker}
+ roots=${roots} dirs=${dirs} mode=${mode}
+ requireWritable=${requireWritable}
+ selected=${value}
+ onCancel=${() => setOpen(false)}
+ onConfirm=${(sel) => { setOpen(false); onChange(sel); }} />
+ `}
+ </div>
+ `;
+}
+
+export { FolderTreePicker, FolderPickerField };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index 466af53..6f41426 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -1,5 +1,5 @@
import {
- html, useState, useEffect, useCallback, useRef,
+ html, useState, useEffect, useCallback, useRef, useMemo,
} from './vendor/htm-preact.js';
import { t } from './i18n.js';
import { Icon } from './icon.js';
@@ -9,7 +9,7 @@ import {
HUB, session, cacheGroupIndex, hubFetch, ensureFreshToken,
_loadBundleKey, _loadRecoveryKey, _storeBundleKey,
} from './hub-client.js';
-import { visibleApps } from './apps.js';
+import { APPS, visibleApps } from './apps.js';
import { GroupName } from './group-name.js';
import { FilePreview } from './files-app.js';
import { VideoPlayer } from './video-player.js';
@@ -88,9 +88,10 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
const [nodeRoots, setNodeRoots] = useState([]);
const [isNodeAdmin, setIsNodeAdmin] = useState(false);
- // Whether ordinary members may upload here. The node decides and enforces it;
- // this only says whether to offer the controls. Defaults to true so a node
- // that predates the setting behaves as it always did.
+ // Legacy: the group-wide upload switch a node speaking MNP 1.0 sends on its
+ // handshake ack. Per-root `writable` replaced it, and this is read only when
+ // the roots carry no flags at all — see `attachRoot` below. Defaults to true
+ // so such a node behaves as it always did.
const [memberUpload, setMemberUpload] = useState(true);
// Which applications this group has enabled, from the node. Falls back to
// every registered app when a node predates the setting (or hasn't answered
@@ -127,15 +128,22 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// TMDB on/off + whether a custom token is set, node-wide (not per-group) —
// docs/mediacenter.md §5.5. Null until the handshake ack arrives.
const [tmdbConfig, setTmdbConfig] = useState(null);
- // Which folder is the Videos app's entry point for this group — ''
- // (the default) means the whole group index. Set from Files, per-group.
- const [videoRoot, setVideoRoot] = useState('');
- // Same shape — the Music app's own entry point.
- const [audioRoot, setAudioRoot] = useState('');
- // The Photos app's entry points — a *list*, unlike videoRoot/audioRoot
- // above (docs/photos.md §2.1: a photo library is routinely scattered
- // across several folders). Empty means nothing configured yet.
- const [photoRoots, setPhotoRoots] = useState([]);
+ // Which folders each app works over. One shape for all of them — a list,
+ // always, even where an app only wants one (docs/refactor-groups.md §1.6):
+ // Videos and Music were single values, which meant a library spread over two
+ // drives could not be described at all. Empty means nothing configured yet,
+ // which every app reads as "show nothing", never "the whole group index".
+ const [appDirectories, setAppDirectories] = useState({});
+ const appDirs = useCallback(
+ (key) => appDirectories[key] || [], [appDirectories]);
+ // Where chat attachments are written — one directory, because Chat has one
+ // destination rather than a set of folders it reads.
+ const [chatDirectory, setChatDirectory] = useState('');
+ const [chatLinkPreview, setChatLinkPreview] = useState(true);
+ // Whether this node speaks the operations MNP 1.1 added. False for one that
+ // predates them, and the Settings page then offers what that node can
+ // actually do rather than controls whose messages it drops unanswered.
+ const [nodeSupportsAppOps, setNodeSupportsAppOps] = useState(false);
// MusicBrainz on/off (per-group) — docs/musicbay.md §3.2.
const [musicbrainzConfig, setMusicbrainzConfig] = useState(null);
const onPlayQueue = useCallback((tracks, startIndex) => {
@@ -225,8 +233,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
if (indexMsg.roots) setNodeRoots(indexMsg.roots);
cacheGroupIndex(groupId, group ? group.name : groupId,
group ? group.owner_username : null, fresh,
- { videoRoot, audioRoot, photoRoots });
- }, [groupId, group, videoRoot, audioRoot, photoRoots]);
+ { video: appDirs('video'), music: appDirs('music'),
+ photo: appDirs('photo') });
+ }, [groupId, group, appDirs]);
// additions/deletions/updates (daemon.py _broadcast_index_change, once
// there is a previous snapshot to diff against) — applied on top of
@@ -235,6 +244,13 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// enrichment (duration/thumb_hash/display_title/...) arriving for a file
// already in the table — same id, new fields (see group_index.py diff()).
const applyIndexDelta = useCallback((deltaMsg) => {
+ // The roots table rides on the delta as of MNP 1.1. Before that it
+ // travelled only on a full index_sync, which is sent on request — so a
+ // root added, removed, ejected or plugged by anyone left every other
+ // client's directory table stale until they reloaded the page.
+ if (Array.isArray(deltaMsg.roots) && deltaMsg.roots.length) {
+ setNodeRoots(deltaMsg.roots);
+ }
setEntries((prev) => {
const deletions = new Set(deltaMsg.deletions || []);
const kept = prev.filter((e) => !deletions.has(e.id));
@@ -248,10 +264,11 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
const fresh = updated.concat(additions);
cacheGroupIndex(groupId, group ? group.name : groupId,
group ? group.owner_username : null, fresh,
- { videoRoot, audioRoot, photoRoots });
+ { video: appDirs('video'), music: appDirs('music'),
+ photo: appDirs('photo') });
return fresh;
});
- }, [groupId, group, videoRoot, audioRoot, photoRoots]);
+ }, [groupId, group, appDirs]);
useEffect(() => {
let cancelled = false;
@@ -325,9 +342,20 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
tokenCustomized: !!ack.tmdb_token_customized,
language: ack.tmdb_language || '',
});
- setVideoRoot(ack.video_root || '');
- setAudioRoot(ack.audio_root || '');
- setPhotoRoots(ack.photo_roots || []);
+ // The plural form when the node speaks it, the old scalars when it
+ // does not — an MNP 1.0 node sends only the latter, and reading its
+ // missing `video_directories` as "nothing configured" would empty a
+ // working Videos tab.
+ setAppDirectories({
+ video: ack.video_directories
+ || (ack.video_root ? [ack.video_root] : []),
+ music: ack.music_directories
+ || (ack.audio_root ? [ack.audio_root] : []),
+ photo: ack.photo_directories || ack.photo_roots || [],
+ });
+ setNodeSupportsAppOps(transport.supportsAppOps);
+ setChatDirectory(ack.chat_directory || '');
+ setChatLinkPreview(ack.chat_link_preview !== false);
setMusicbrainzConfig({
enabled: ack.musicbrainz_enabled !== false,
});
@@ -343,11 +371,29 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// recent value.
transport.onTmdbConfig = (cfg) => setTmdbConfig((prev) => ({ ...(prev || {}), ...cfg }));
transport.onTmdbEnabled = (enabled) => setTmdbConfig((prev) => ({ ...(prev || {}), enabled }));
- transport.onVideoRoot = (path) => setVideoRoot(path);
- transport.onAudioRoot = (path) => setAudioRoot(path);
- transport.onPhotoRoots = (roots) => setPhotoRoots(roots);
+ // One handler for every app's directories, plus the three older
+ // per-app messages a node that predates the generic op still sends.
+ transport.onAppDirectories = (app, dirs) =>
+ setAppDirectories((prev) => ({ ...prev, [app]: dirs }));
+ transport.onVideoRoot = (path) => setAppDirectories(
+ (prev) => ({ ...prev, video: path ? [path] : [] }));
+ transport.onAudioRoot = (path) => setAppDirectories(
+ (prev) => ({ ...prev, music: path ? [path] : [] }));
+ transport.onPhotoRoots = (roots) => setAppDirectories(
+ (prev) => ({ ...prev, photo: roots || [] }));
+ transport.onChatDirectory = (path) => setChatDirectory(path);
+ transport.onChatLinkPreview = (on) => setChatLinkPreview(on);
transport.onMusicbrainzEnabled = (enabled) =>
setMusicbrainzConfig((prev) => ({ ...(prev || {}), enabled }));
+ transport.onRootsChanged = (msg) => {
+ // `msg.roots &&` would accept `[]`, and an empty array is truthy —
+ // so a node that could not describe its roots would blank the
+ // operator's table on an op that actually succeeded. A group always
+ // has at least one root, so nothing legitimate is dropped here.
+ if (Array.isArray(msg.roots) && msg.roots.length) {
+ setNodeRoots(msg.roots);
+ }
+ };
// The node's own scan (a root added while we were already connected,
// or reconcile catching one back up) — never the entries, just
// enough to animate the sidebar dot. Guaranteed a final push at the
@@ -535,9 +581,35 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
}
}, [groupId, token, descDraft, onGroupUpdated]);
- // Asked in two places — the Files toolbar and the chat composer — so it is
- // answered once. The operator is never locked out of their own node.
- const mayUpload = memberUpload || isNodeAdmin;
+ // Where an attachment goes, answered once for the whole page.
+ //
+ // Files does not use this — it uploads into the root being browsed, which is
+ // the only unambiguous answer once a group can have several writable roots.
+ // Chat has no folder to browse, so it needs one picked for it, and this is
+ // the same rule the node applies when a client names no root at all. It
+ // becomes an operator-chosen directory in phase 2 (refactor-groups.md §1.7).
+ //
+ // `memberUpload` is the fallback for a node still speaking MNP 1.0, whose
+ // roots carry no `writable` at all: there, the single upload root is the one
+ // the node marked, and the ack's computed flag is all we get.
+ const writableRoots = useMemo(
+ () => nodeRoots.filter((r) => r.writable && r.available !== false),
+ [nodeRoots]);
+ const legacyNode = nodeRoots.length > 0
+ && nodeRoots.every((r) => r.writable === undefined);
+ // The operator's chosen attachment folder wins where there is one — that is
+ // what the Chat settings pane is for. Its root has to be writable and
+ // present, or the choice is stale (they made it read-only, or ejected the
+ // drive) and the fallback is better than a refusal at send time.
+ const chatDirRoot = chatDirectory ? chatDirectory.split('/')[0] : '';
+ const chatDirUsable = Boolean(
+ chatDirRoot && writableRoots.some((r) => r.name === chatDirRoot));
+ const attachDir = chatDirUsable ? chatDirectory : '';
+ const attachRoot = chatDirUsable ? chatDirRoot
+ : writableRoots.length ? writableRoots[0].name
+ : (legacyNode && memberUpload
+ ? (nodeRoots.find((r) => r.upload) || nodeRoots[0]).name
+ : '');
// A single dispatcher so any app can open the right modal without owning
// video/preview state itself — Files' table and Chat's attachments both
@@ -566,14 +638,55 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
setPreviewEntry(entry);
}, [entries, onPlayQueue, onStopMusic]);
+ const unavailRoots = useMemo(() => {
+ const s = new Set();
+ for (const r of nodeRoots) if (!r.available) s.add(r.name);
+ return s;
+ }, [nodeRoots]);
+ const availableEntries = useMemo(
+ () => entries.filter((e) => {
+ const root = (e.path || '').split('/')[0];
+ return !root || !unavailRoots.has(root);
+ }), [entries, unavailRoots]);
+
+ // Everything the operator settings panes read, in one object. Built here
+ // because this is where the state already lives, and passed through
+ // `group-settings.js` untouched — that page renders the panes without
+ // knowing what any of them is for, which is what makes adding an app a
+ // registry entry rather than an edit to the page.
+ // `<key>Directories` for every registered app, derived from the registry
+ // rather than written out. Naming them here would mean adding an app
+ // required editing this file, which is the one thing the plugin
+ // architecture is supposed to have removed — and the reference app
+ // (docs/refactor-groups.md §4.1) is what made the difference visible.
+ const perAppDirectories = useMemo(() => {
+ const out = {};
+ for (const app of APPS) out[`${app.key}Directories`] = appDirs(app.key);
+ return out;
+ }, [appDirs]);
+
+ const appSettings = useMemo(() => ({
+ ...perAppDirectories,
+ chatDirectory,
+ chatLinkPreview,
+ tmdbEnabled: tmdbConfig ? tmdbConfig.enabled !== false : true,
+ tmdbLanguage: (tmdbConfig && tmdbConfig.language) || '',
+ tmdbTokenCustomized: Boolean(tmdbConfig && tmdbConfig.tokenCustomized),
+ musicbrainzEnabled: musicbrainzConfig
+ ? musicbrainzConfig.enabled !== false : true,
+ }), [perAppDirectories, chatDirectory, chatLinkPreview, tmdbConfig,
+ musicbrainzConfig]);
+
const commonProps = {
groupId, transportRef, gekRef, status, username,
- entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex,
- isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview,
+ entries, availableEntries, nodeDirs, nodeRoots,
+ setEntries, setNodeDirs, setNodeRoots, applyIndex,
+ isNodeAdmin, operatorPaired, attachRoot, attachDir, userId, setError, onPreview,
onRefreshIndex: refreshIndex, onActivity: touchActivity,
- videoRoot, onVideoRoot: (path) => setVideoRoot(path),
- audioRoot, onAudioRoot: (path) => setAudioRoot(path),
- photoRoots, onPhotoRoots: (roots) => setPhotoRoots(roots),
+ // Plural everywhere: Videos and Music read a list now, and Photos always
+ // did. The scalar `videoRoot`/`audioRoot` shapes survive only on the wire,
+ // for a node that speaks MNP 1.0 — nothing in the client carries them.
+ ...perAppDirectories,
tmdbConfig,
musicbrainzConfig, onPlayQueue,
};
@@ -699,24 +812,20 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
transportRef=${transportRef} gekRef=${gekRef}
isNodeAdmin=${isNodeAdmin} userId=${userId}
operatorPaired=${operatorPaired} connected=${status === 'connected'}
- memberUpload=${memberUpload}
- onMemberUpload=${(allowed) => setMemberUpload(allowed)}
+ mnpRoots=${nodeRoots}
enabledApps=${enabledApps}
onEnabledApps=${(keys) => setEnabledApps(keys)}
scanSettings=${scanSettings}
onScanSettings=${(s) => setScanSettings(s)}
- tmdbConfig=${tmdbConfig}
- onTmdbConfig=${(cfg) => setTmdbConfig((prev) => ({ ...(prev || {}), ...cfg }))}
- onTmdbEnabled=${(enabled) => setTmdbConfig((prev) => ({ ...(prev || {}), enabled }))}
- musicbrainzConfig=${musicbrainzConfig}
- onMusicbrainzEnabled=${(enabled) => setMusicbrainzConfig((prev) => ({ ...(prev || {}), enabled }))}
entries=${entries} nodeDirs=${nodeDirs}
- videoRoot=${videoRoot}
- onVideoRoot=${(path) => setVideoRoot(path)}
- audioRoot=${audioRoot}
- onAudioRoot=${(path) => setAudioRoot(path)}
- photoRoots=${photoRoots}
- onPhotoRoots=${(roots) => setPhotoRoots(roots)}
+ appSettings=${appSettings}
+ nodeSupportsAppOps=${nodeSupportsAppOps}
+ ${/* The saving pane already knows what it asked for; this is so
+ the page's own copy moves at the same time, rather than
+ waiting for the ack it will not be handed (transport.js
+ resolves an admin ack against the pending request). */''}
+ onAppDirectories=${(app, dirs) =>
+ setAppDirectories((prev) => ({ ...prev, [app]: dirs }))}
onRefreshIndex=${refreshIndex}
onLeft=${onLeft}
onPaired=${() => setOperatorPaired(true)} />
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
index 1c6ca71..c80cff5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -1,179 +1,381 @@
import {
html, useState, useEffect, useCallback, useMemo, useRef,
} from './vendor/htm-preact.js';
-import { t, getLocale, LOCALES } from './i18n.js';
+import { t } from './i18n.js';
import { Icon } from './icon.js';
+import { CollapsibleSection, ToggleSwitch } from './settings-ui.js';
import { hubFetch, navigate } from './hub-client.js';
-import { APPS } from './apps.js';
+import { availableApps, configurableApps } from './apps.js';
import * as platform from './platform.js';
-// MeshBay's own locale codes (i18n.js LOCALES) to the language tag TMDB
-// expects — the two don't share a format (MeshBay's "en" vs TMDB's
-// required region, "en-US"). Used only to pre-fill the TMDB language field
-// with the operator's own current UI language, a reasonable default they
-// can still change; the node never guesses this on its own.
-const TMDB_LANGUAGE_BY_LOCALE = {
- en: 'en-US', fr: 'fr-FR', es: 'es-ES', 'pt-BR': 'pt-BR', 'zh-CN': 'zh-CN',
- ja: 'ja-JP', de: 'de-DE', it: 'it-IT', nl: 'nl-NL', pl: 'pl-PL',
-};
-/**
- * A settings-section that folds — every section but the ones that are
- * really just a form to fill in (invite, pair-operator, approve-device):
- * hiding an input the operator is mid-typing-into behind a click they'd
- * have to undo is friction with nothing to show for it, but a section that
- * is only ever glanced at once it's configured (TMDB, scan tuning, the
- * danger zone) benefits from staying out of the way otherwise. `title` (an
- * already-built string/vnode) wins over `titleKey` when both are given —
- * the members-table heading needs a live count baked in, not just a
- * lookup.
- */
-function CollapsibleSection({ titleKey, title, defaultOpen = true, children }) {
- const [open, setOpen] = useState(defaultOpen);
- return html`
- <div class="settings-section">
- <button type="button" class="settings-collapsible-header"
- onClick=${() => setOpen((v) => !v)} aria-expanded=${open}>
- <h3 class="settings-heading">${title != null ? title : t(titleKey)}</h3>
- <${Icon} name="chevron" cls=${open ? 'video-flat-chevron open' : 'video-flat-chevron'} />
- </button>
- ${open && html`<div class="settings-collapsible-body">${children}</div>`}
- </div>
- `;
-}
+// ── Shared Directories Table ────────────────────────────────────────────
/**
- * A modern on/off switch — replaces a plain checkbox or a "Turn on/off"
- * button wherever the setting itself is a straight binary (uploads
- * allowed, TMDB/MusicBrainz enabled). Still a real <input type="checkbox">
- * under the hood (keyboard/screen-reader behaviour for free), just
- * restyled — see .toggle-switch in style.css.
+ * A group's root directories, and the operator's controls over them.
+ *
+ * One component, two modes, because the Create Group wizard and the Settings
+ * page were drifting apart while showing the same thing:
+ *
+ * mode="live" — a hosted group. Every change is a signed operator op sent
+ * over MNP, or the loopback API when the node is on this
+ * machine and there is no live connection.
+ * mode="local" — the wizard, before the group exists. Changes are held in
+ * an array the caller owns; nothing is persisted until the
+ * group is attached.
+ *
+ * **Both paths matter and neither is optional.** The operator of a node is not
+ * necessarily sitting at it: they may be signing in from any browser, and the
+ * only thing that reaches their node from there is MNP. An earlier version of
+ * this read its roots exclusively from the loopback API, which resolves to
+ * "not available" in a browser — so the section rendered for nobody on the
+ * web, while the controls it replaced had worked there. `mnpRoots` is the
+ * source whenever a connection exists; the loopback list is the fallback for
+ * a local node that is not currently connected (a group still scanning, say).
+ *
+ * Props:
+ * roots — the node's current roots: { name, path, writable,
+ * removable, ejected, available, kind }
+ * groupId — the group id
+ * transport — MeshBayTransport instance, or null when not connected
+ * signFn — signing function for admin ops
+ * nodeDetected — whether the loopback node API answers
+ * readOnly — suppress every edit control
+ * onRootsChange — called after a change, to re-read the loopback list
+ * onRefreshIndex — full index refresh. Not called after a root change: see
+ * `run()` for why the node's own push is what settles it
+ * mode — "live" (default) or "local"
+ * localRoots / onLocalRootsChange — the array, in "local" mode
*/
-function ToggleSwitch({ checked, onChange, disabled, label }) {
- return html`
- <label class="toggle-switch ${disabled ? 'toggle-switch-disabled' : ''}">
- <input type="checkbox" checked=${checked} disabled=${disabled}
- onChange=${(e) => onChange(e.target.checked)} />
- <span class="toggle-switch-track"><span class="toggle-switch-thumb"></span></span>
- ${label != null && html`<span class="toggle-switch-label">${label}</span>`}
- </label>
- `;
-}
+function SharedDirectoriesTable({ roots, groupId, transport, signFn,
+ nodeDetected: nodeAvail, readOnly,
+ onRootsChange, onRefreshIndex,
+ mode = 'live',
+ localRoots, onLocalRootsChange }) {
+ const isLocal = mode === 'local';
+ const serverRoots = isLocal ? (localRoots || []) : (roots || []);
+ const [busy, setBusy] = useState(false);
+ // `{ text, error }` — a refusal has to look like one. Every message here was
+ // a `settings-hint`, which is dim grey body text, so "two roots would both
+ // be called uploads" read as a footnote to the section rather than as the
+ // reason nothing happened.
+ const [msg, setMsg] = useState(null);
+ const [indexProgress, setIndexProgress] = useState(null);
+ const say = useCallback((text) => setMsg(text ? { text, error: false } : null), []);
+ const refuse = useCallback((text) => setMsg({ text, error: true }), []);
-/**
- * Which folder is an app's entry point for this group — the shared shape
- * behind both the Videos and Music root pickers (docs/musicbay.md's
- * amended §2.1): a depth-indented <select> over every folder the group's
- * index already knows about, a Save button that only enables once the
- * draft actually differs, and a confirm prompt only when replacing an
- * *already-set* root (setting one for the first time has nothing to lose).
- */
-function RootFolderRow({
- icon, titleKey, hintKey, folders, value, draft, onDraftChange,
- busy, msg, onSave, noneKey, saveKey,
-}) {
- return html`
- <div class="settings-root-row">
- <div class="settings-root-row-title">
- <${Icon} name=${icon} />
- <h4>${t(titleKey)}</h4>
- </div>
- <p class="settings-hint">${t(hintKey)}</p>
- <div class="settings-row">
- <label class="settings-label">
- <select value=${draft} disabled=${busy} onChange=${e => onDraftChange(e.target.value)}>
- <option value="">${t(noneKey)}</option>
- ${folders.map(p => html`
- <option key=${p} value=${p}>
- ${' '.repeat(p.split('/').length - 1)}${p.split('/').pop()}
- </option>
- `)}
- </select>
- </label>
+ // Rendered at the foot of the section, under the Add button — the last thing
+ // below the control that caused it, rather than above a table the eye has
+ // already moved past.
+ const message = !msg ? '' : html`
+ <p class=${msg.error ? 'error-msg' : 'settings-hint'}
+ role=${msg.error ? 'alert' : 'status'}
+ style="margin-top:10px">${msg.text}</p>`;
+ const [pathDraft, setPathDraft] = useState('');
+ const [addingByPath, setAddingByPath] = useState(false);
+
+ // A toggle has to move under the finger, and the answer only comes back
+ // when the node has signed, written node.toml and pushed the new table.
+ // The patch is therefore held until the incoming `roots` actually agrees
+ // with it — clearing it when the request resolves (which is what this did)
+ // drops it in the frame *before* the new table arrives, so the switch
+ // visibly snaps back and then forward again.
+ const [optimistic, setOptimistic] = useState({});
+ useEffect(() => {
+ setOptimistic((prev) => {
+ const keys = Object.keys(prev);
+ if (!keys.length) return prev;
+ const next = {};
+ let changed = false;
+ for (const name of keys) {
+ const server = serverRoots.find(r => r.name === name);
+ const patch = prev[name];
+ // Gone from the table, or the server now says what we asked for:
+ // either way this patch has nothing left to hide.
+ const settled = !server
+ || Object.keys(patch).every(k => server[k] === patch[k]);
+ if (settled) changed = true; else next[name] = patch;
+ }
+ return changed ? next : prev;
+ });
+ }, [serverRoots]);
+
+ const displayRoots = serverRoots.map(r =>
+ optimistic[r.name] ? { ...r, ...optimistic[r.name] } : r);
+
+ // Paths are the operator's own view and do not cross MNP: the roots table
+ // rides in the index payload, which every member receives, and it tells them
+ // what exists and whether it is readable — never where on the operator's
+ // disk it lives. So the column appears when the answer is actually
+ // available (the loopback API, which is already this machine only) and is
+ // left out otherwise, rather than printing a row of blanks.
+ const hasPaths = displayRoots.some((r) => r.path);
+
+ // Which door a change goes through. MNP first: it is the only one that
+ // exists for an operator on the web, and it is signed, which the loopback
+ // API is not (it is authorized by being on localhost with the run token).
+ const overMnp = !isLocal && transport && transport.connected;
+ const overLoopback = !isLocal && !overMnp && nodeAvail;
+ const canEdit = !readOnly && (isLocal || overMnp || overLoopback);
+
+ const rootUrl = (name, suffix = '') =>
+ '/api/groups/' + groupId + '/roots/' + encodeURIComponent(name) + suffix;
+
+ // Deliberately no index refresh after a root change.
+ //
+ // Adding a root makes the node reload, which rescans — minutes on a real
+ // library — and the reload is fire-and-forget for that reason. Fetching the
+ // index in the moment after therefore returns the set from *before* it, and
+ // `applyIndex` writes that over the roots the ack had just delivered: the
+ // new directory appeared for one paint and vanished, which is what "it only
+ // shows up after a refresh" was.
+ //
+ // Nothing is lost by waiting. The ack carries the new table immediately, and
+ // the delta the node pushes when the scan finishes carries it again along
+ // with the files.
+ const run = useCallback(async (work) => {
+ setBusy(true); setMsg(null);
+ try {
+ await work();
+ if (onRootsChange) await onRootsChange();
+ return true;
+ } catch (err) {
+ refuse(platform.bridgeMessage(err));
+ return false;
+ } finally { setBusy(false); }
+ }, [onRootsChange, refuse]);
+
+ const doUpdateRoot = useCallback(async (rootName, updates) => {
+ if (isLocal) {
+ if (onLocalRootsChange) {
+ onLocalRootsChange((localRoots || []).map(r =>
+ r.name === rootName ? { ...r, ...updates } : r));
+ }
+ return;
+ }
+ setOptimistic(prev => ({
+ ...prev, [rootName]: { ...(prev[rootName] || {}), ...updates },
+ }));
+ const ok = await run(async () => {
+ if (overMnp) await transport.updateRoot(groupId, rootName, updates, signFn);
+ else if (overLoopback) await platform.node.call('PATCH', rootUrl(rootName), updates);
+ else throw new Error(t('node.root_no_route'));
+ });
+ // Only a failure clears the patch here; a success waits for the node's
+ // own table, so the switch never travels backwards on its way forwards.
+ if (!ok) {
+ setOptimistic(prev => {
+ const next = { ...prev }; delete next[rootName]; return next;
+ });
+ }
+ }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback,
+ transport, groupId, signFn, run]);
+
+ const doEjectRoot = useCallback((rootName) => run(async () => {
+ if (overMnp) await transport.ejectRoot(groupId, rootName, signFn);
+ else if (overLoopback) await platform.node.call('PUT', rootUrl(rootName, '/eject'));
+ else throw new Error(t('node.root_no_route'));
+ }), [overMnp, overLoopback, transport, groupId, signFn, run]);
+
+ const doPlugRoot = useCallback((rootName) => run(async () => {
+ if (overMnp) await transport.plugRoot(groupId, rootName, signFn);
+ else if (overLoopback) await platform.node.call('PUT', rootUrl(rootName, '/plug'));
+ else throw new Error(t('node.root_no_route'));
+ }), [overMnp, overLoopback, transport, groupId, signFn, run]);
+
+ const doRemoveRoot = useCallback(async (rootName) => {
+ if (isLocal) {
+ if (onLocalRootsChange) {
+ onLocalRootsChange((localRoots || []).filter(r => r.name !== rootName));
+ }
+ return;
+ }
+ if (!confirm(t('node.root_remove_confirm', { name: rootName }))) return;
+ const ok = await run(async () => {
+ if (overMnp) await transport.removeRoot(groupId, rootName, signFn);
+ else if (overLoopback) {
+ await platform.node.call('DELETE', rootUrl(rootName));
+ await platform.node.call('POST', '/api/reload');
+ } else throw new Error(t('node.root_no_route'));
+ });
+ if (ok) say(t('node.root_removed'));
+ }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback,
+ transport, groupId, signFn, run]);
+
+ // Adding a root needs a directory that exists on the *node's* filesystem.
+ // With the node on this machine that is a native folder picker; from any
+ // other browser the operator has to type the path, because nothing in a web
+ // page can browse a remote disk. Both end at the same signed op.
+ const addRootAtPath = useCallback(async (path, name) => {
+ if (isLocal) {
+ if ((localRoots || []).some(r => r.path === path)) return true;
+ const isFirst = (localRoots || []).length === 0;
+ // The first directory is writable so a new group can receive an upload
+ // without the operator having to find this switch first. Every later
+ // one is read-only until they say otherwise.
+ if (onLocalRootsChange) {
+ onLocalRootsChange([...(localRoots || []),
+ { name, path, writable: isFirst, removable: false }]);
+ }
+ return true;
+ }
+ setIndexProgress(null);
+ return run(async () => {
+ if (overMnp) {
+ await transport.addRoot(groupId, path, { name }, signFn);
+ } else if (overLoopback) {
+ await platform.node.call('POST', '/api/groups/' + groupId + '/roots',
+ { path, name });
+ await platform.node.call('POST', '/api/reload');
+ await platform.watchIndexProgress(groupId, setIndexProgress);
+ } else throw new Error(t('node.root_no_route'));
+ });
+ }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback,
+ transport, groupId, signFn, run]);
+
+ const doPickRoot = useCallback(async () => {
+ const chosen = await platform.rootPicker.choose();
+ if (!chosen) return;
+ const ok = await addRootAtPath(chosen.path, chosen.name);
+ if (ok && !isLocal) say(t('node.root_added'));
+ }, [addRootAtPath, isLocal]);
+
+ const doAddByPath = useCallback(async () => {
+ const path = pathDraft.trim();
+ if (!path) return;
+ // The name is the node's business — it derives the basename and refuses a
+ // duplicate. Sending one guessed from a string typed here would be a
+ // second opinion about something already decided in one place.
+ const ok = await addRootAtPath(path, '');
+ if (ok) { setPathDraft(''); setAddingByPath(false); if (!isLocal) say(t('node.root_added')); }
+ }, [pathDraft, addRootAtPath, isLocal]);
+
+ const addControls = !canEdit ? '' : html`
+ ${platform.rootPicker.available ? html`
+ <button class="btn btn-small btn-secondary" style="margin-top:8px"
+ disabled=${busy} onClick=${doPickRoot}>
+ <${Icon} name="folder-plus" /> ${t('node.add_root')}
+ </button>
+ ` : addingByPath ? html`
+ <div class="sdt-add-row">
+ <input class="sdt-add-input" type="text" value=${pathDraft}
+ placeholder=${t('node.root_path_placeholder')}
+ disabled=${busy}
+ onInput=${(e) => setPathDraft(e.target.value)}
+ onKeyDown=${(e) => { if (e.key === 'Enter') doAddByPath(); }} />
+ <button class="btn btn-small btn-secondary" disabled=${busy || !pathDraft.trim()}
+ onClick=${doAddByPath}>${t('node.add_root')}</button>
+ <button class="btn btn-small" disabled=${busy}
+ onClick=${() => { setAddingByPath(false); setPathDraft(''); }}>
+ ${t('settings.cancel')}</button>
</div>
+ <p class="settings-hint">${t('node.root_path_hint')}</p>
+ ` : html`
<button class="btn btn-small btn-secondary" style="margin-top:8px"
- disabled=${busy || draft === (value || '')} onClick=${onSave}>
- ${busy ? t('settings_node.scan_saving') : t(saveKey)}
+ disabled=${busy} onClick=${() => setAddingByPath(true)}>
+ <${Icon} name="folder-plus" /> ${t('node.add_root')}
</button>
- ${msg && html`<p class=${msg.ok ? 'success-msg' : 'error-msg'} style="margin-top:8px">
- ${msg.text}</p>`}
- </div>
+ `}
`;
-}
-
-/**
- * Which folder(s) are the Photos app's entry points for this group — a
- * *set*, unlike RootFolderRow's single value above (docs/photos.md §2.1: a
- * photo library is routinely scattered across several folders). An
- * add/remove list rather than a `<select>`: pick a folder to add from the
- * same `rootFolderOptions` the Videos/Music pickers use, list what is
- * already configured with a remove button each, and one Save signs the
- * whole resulting set in one op (same shape as the app-enable checkboxes
- * below — several changes staged, one signature).
- */
-function PhotoRootsRow({ folders, value, busy, msg, onSave }) {
- const [draft, setDraft] = useState(value || []);
- useEffect(() => { setDraft(value || []); }, [value]);
- const [addSelection, setAddSelection] = useState('');
-
- const available = folders.filter((p) => !draft.includes(p));
- const addRoot = () => {
- if (!addSelection || draft.includes(addSelection)) return;
- setDraft((prev) => [...prev, addSelection].sort());
- setAddSelection('');
- };
- const removeRoot = (path) => setDraft((prev) => prev.filter((p) => p !== path));
- const unchanged = draft.length === (value || []).length
- && draft.every((p) => (value || []).includes(p));
+ if (!displayRoots.length) {
+ return html`
+ <div class="shared-directories-table">
+ <p class="settings-hint">${t('settings_node.shared_directories_hint')}</p>
+ ${addControls}
+ ${message}
+ </div>
+ `;
+ }
return html`
- <div class="settings-root-row">
- <div class="settings-root-row-title">
- <${Icon} name="image" />
- <h4>${t('settings_node.photo_roots_title')}</h4>
- </div>
- <p class="settings-hint">${t('settings_node.photo_roots_hint')}</p>
- ${draft.length === 0 && html`
- <p class="settings-hint">${t('settings_node.photo_roots_none')}</p>
- `}
- ${draft.length > 0 && html`
- <ul class="settings-root-list">
- ${draft.map((p) => html`
- <li key=${p} class="settings-root-list-item">
- <span>${' '.repeat(p.split('/').length - 1)}${p.split('/').pop()}</span>
- <button class="link-btn" disabled=${busy} onClick=${() => removeRoot(p)}
- title=${t('settings_node.photo_roots_remove')}>
- <${Icon} name="close" /></button>
- </li>
- `)}
- </ul>
+ <div class="shared-directories-table">
+ <table class="shared-dirs-tbl">
+ <thead>
+ <tr>
+ <th class="sdt-col-dir">${t('node.directory')}</th>
+ ${hasPaths && html`<th class="sdt-col-path">${t('node.root_path')}</th>`}
+ ${canEdit && html`<th class="sdt-col-toggle">${t('node.root_rw')}</th>`}
+ ${canEdit && !isLocal && html`<th class="sdt-col-toggle">${t('node.removable')}</th>`}
+ <th class="sdt-col-actions"></th>
+ </tr>
+ </thead>
+ <tbody>
+ ${displayRoots.map(r => {
+ const rowClass = r.ejected ? 'sdt-row-ejected'
+ : (!isLocal && r.available === false) ? 'sdt-row-unavail' : '';
+ return html`
+ <tr class=${rowClass} key=${r.name}>
+ <td class="sdt-col-dir">
+ <span class="sdt-dir-name">
+ <${Icon} name="folder" />
+ ${r.name}
+ </span>
+ ${r.ejected && html`
+ <span class="node-root-badge node-root-badge-warn">${t('group.root_ejected')}</span>`}
+ ${!isLocal && r.available === false && !r.ejected && html`
+ <span class="node-root-badge node-root-badge-warn">${t('node.unavailable')}</span>`}
+ </td>
+ ${hasPaths && html`
+ <td class="sdt-col-path" title=${r.path || ''}>${r.path || ''}</td>`}
+ ${canEdit && html`
+ <td class="sdt-col-toggle">
+ <${ToggleSwitch} checked=${!!r.writable} disabled=${busy || !!r.ejected}
+ onChange=${(v) => doUpdateRoot(r.name, { writable: v })} />
+ </td>
+ `}
+ ${canEdit && !isLocal && html`
+ <td class="sdt-col-toggle">
+ <${ToggleSwitch} checked=${!!r.removable} disabled=${busy}
+ onChange=${(v) => doUpdateRoot(r.name, { removable: v })} />
+ </td>
+ `}
+ <td class="sdt-col-actions">
+ ${canEdit && !isLocal && html`
+ <button class="sdt-action-btn" disabled=${busy || !r.removable}
+ title=${r.ejected ? t('group.root_plug') : t('group.root_eject')}
+ onClick=${() => r.ejected ? doPlugRoot(r.name) : doEjectRoot(r.name)}>
+ ${r.ejected ? '\u{1F50C}' : '\u{23CF}'}
+ </button>
+ `}
+ ${canEdit && html`
+ <button class="sdt-action-btn sdt-action-danger"
+ disabled=${busy || displayRoots.length < 2}
+ title=${displayRoots.length < 2
+ ? t('node.root_remove_last') : t('node.remove_root')}
+ onClick=${() => doRemoveRoot(r.name)}>
+ \u{2715}
+ </button>
+ `}
+ </td>
+ </tr>
+ `; })}
+ </tbody>
+ </table>
+ ${addControls}
+ ${message}
+ ${indexProgress && indexProgress.scanning && html`
+ <div class="index-progress" style="margin-top:8px">
+ <div class="index-progress-bar">
+ <div class="index-progress-fill" style="width:${
+ indexProgress.total_bytes
+ ? Math.min(100, Math.round(
+ 100 * indexProgress.scanned_bytes / indexProgress.total_bytes))
+ : 0}%"></div>
+ </div>
+ <div class="index-progress-label">${t('wizard.indexing_progress', {
+ pct: indexProgress.total_bytes
+ ? Math.min(100, Math.round(
+ 100 * indexProgress.scanned_bytes / indexProgress.total_bytes))
+ : 0,
+ })}</div>
+ </div>
`}
- <div class="settings-row">
- <label class="settings-label">
- <select value=${addSelection} disabled=${busy || available.length === 0}
- onChange=${(e) => setAddSelection(e.target.value)}>
- <option value="">${t('settings_node.photo_roots_add_placeholder')}</option>
- ${available.map((p) => html`
- <option key=${p} value=${p}>
- ${' '.repeat(p.split('/').length - 1)}${p.split('/').pop()}
- </option>
- `)}
- </select>
- </label>
- <button class="btn btn-small btn-secondary" disabled=${busy || !addSelection}
- onClick=${addRoot}>${t('settings_node.photo_roots_add')}</button>
- </div>
- <button class="btn btn-small btn-secondary" style="margin-top:8px"
- disabled=${busy || unchanged} onClick=${() => onSave(draft)}>
- ${busy ? t('settings_node.scan_saving') : t('settings_node.photo_roots_save')}
- </button>
- ${msg && html`<p class=${msg.ok ? 'success-msg' : 'error-msg'} style="margin-top:8px">
- ${msg.text}</p>`}
</div>
`;
}
+
// ── Members Panel ────────────────────────────────────────────────────────
/**
@@ -187,14 +389,12 @@ function PhotoRootsRow({ folders, value, busy, msg, onSave }) {
*/
function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
isNodeAdmin, userId, operatorPaired, connected,
- memberUpload, onMemberUpload,
+ mnpRoots,
enabledApps, onEnabledApps,
scanSettings, onScanSettings,
- tmdbConfig, onTmdbConfig, onTmdbEnabled,
- musicbrainzConfig, onMusicbrainzEnabled,
- entries, nodeDirs, videoRoot, onVideoRoot,
- audioRoot, onAudioRoot,
- photoRoots, onPhotoRoots, onRefreshIndex,
+ entries, nodeDirs,
+ appSettings, nodeSupportsAppOps,
+ onAppDirectories, onRefreshIndex,
onPaired, onLeft }) {
const [members, setMembers] = useState([]);
const [adminId, setAdminId] = useState('');
@@ -214,6 +414,29 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
// platform.watchIndexProgress.
const [nodeIndexProgress, setNodeIndexProgress] = useState(null);
+ // The roots to show, from whichever source can actually answer.
+ //
+ // `mnpRoots` comes from the index payload the node pushes over the live
+ // connection, and is the only source an operator signing in from an
+ // ordinary browser has. `nodeRoots` comes from the loopback API and exists
+ // only on the machine running the node. Preferring MNP when connected also
+ // keeps this table on the same data Files and the apps read, so an eject
+ // shows in one place at the same instant it shows in the other.
+ const effectiveRoots = (connected && mnpRoots && mnpRoots.length)
+ ? mnpRoots : nodeRoots;
+
+ // Declared here rather than inline at the call site: a function rebuilt on
+ // every render is a new prop identity every render, and the callbacks that
+ // close over it in the table below are memoised on it.
+ const adminSignFn = useCallback((transcript) => {
+ const sk = transportRef.current && transportRef.current.sessionKeys
+ && transportRef.current.sessionKeys.skEdB64;
+ if (!sk || !window.MeshBayKeys) {
+ throw new Error(t('node.root_no_signing_key'));
+ }
+ return window.MeshBayKeys.signBytes(sk, transcript);
+ }, [transportRef]);
+
const loadNodeInfo = useCallback(async () => {
if (!platform.node.available) return;
try {
@@ -332,40 +555,16 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
}
}, [pairCode, transportRef, userId]);
- const [uploadBusy, setUploadBusy] = useState(false);
- const [uploadMsg, setUploadMsg] = useState('');
-
- /**
- * Close or open uploading for everyone who is not the operator.
- *
- * Signed, like removing a member: the node refuses an unsigned instruction,
- * so this is a request to the node rather than a decision taken here. The
- * button does not move until the node has said it did it.
- */
- const setUploads = useCallback(async (allowed) => {
- const transport = transportRef && transportRef.current;
- setUploadMsg('');
- setUploadBusy(true);
- try {
- if (!transport || !transport.connected) {
- throw new Error('Not connected to the node');
- }
- const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
- const signFn = (sk && window.MeshBayKeys)
- ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
- : null;
- await transport.setMemberUpload(allowed, signFn);
- if (onMemberUpload) onMemberUpload(allowed);
- } catch (err) {
- setUploadMsg(err.message);
- } finally {
- setUploadBusy(false);
- }
- }, [transportRef, onMemberUpload]);
+ // DEPRECATED: upload toggle removed — per-root writable flag replaces it.
const [appsBusy, setAppsBusy] = useState(false);
const [appsMsg, setAppsMsg] = useState('');
- const activeApps = enabledApps && enabledApps.length ? enabledApps : APPS.map(a => a.key);
+ // `availableApps()` rather than the raw registry: an app the reader is not
+ // shown must not be turned on for the whole group by falling back to "all of
+ // them". Found by adding one that is hidden by default — an ordinary app
+ // would never have exposed the difference.
+ const activeApps = enabledApps && enabledApps.length
+ ? enabledApps : availableApps().map(a => a.key);
/**
* Toggle one app in or out of the group's enabled set. Same shape as
@@ -446,146 +645,21 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
}
}, [transportRef, onScanSettings, reconcileMinutes, debounceSeconds]);
- const [tmdbBusy, setTmdbBusy] = useState(false);
- const [tmdbMsg, setTmdbMsg] = useState('');
- const [tmdbTokenDraft, setTmdbTokenDraft] = useState('');
- const [tmdbEnabledBusy, setTmdbEnabledBusy] = useState(false);
- const tmdbEnabled = tmdbConfig ? tmdbConfig.enabled : true;
- // Pre-filled from the operator's own current UI language the first time
- // this renders with nothing configured yet — a sensible default, not a
- // claim about what the node is actually using until they hit Save.
- const [tmdbLanguage, setTmdbLanguage] = useState(
- () => (tmdbConfig && tmdbConfig.language)
- || TMDB_LANGUAGE_BY_LOCALE[getLocale()] || 'en-US');
- useEffect(() => {
- if (tmdbConfig && tmdbConfig.language) setTmdbLanguage(tmdbConfig.language);
- }, [tmdbConfig && tmdbConfig.language]);
-
- /**
- * Whether TMDB is used at all — per-group (2026-08-24, used to be bundled
- * into the same signed op as the token/language below): a real
- * media-library group and a test/demo group on the same node need not
- * share this decision. Saves immediately on toggle, same as an ordinary
- * checkbox-style setting elsewhere — there is nothing else on the form to
- * batch it with any more.
- */
- const saveTmdbEnabled = useCallback(async (nextEnabled) => {
- const transport = transportRef && transportRef.current;
- setTmdbMsg('');
- setTmdbEnabledBusy(true);
- try {
- if (!transport || !transport.connected) {
- throw new Error('Not connected to the node');
- }
- const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
- const signFn = (sk && window.MeshBayKeys)
- ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
- : null;
- await transport.setTmdbEnabled(nextEnabled, signFn);
- if (onTmdbEnabled) onTmdbEnabled(nextEnabled);
- } catch (err) {
- setTmdbMsg(err.message);
- } finally {
- setTmdbEnabledBusy(false);
- }
- }, [transportRef, onTmdbEnabled]);
-
- /**
- * An optional custom API token, and the language TMDB is queried in —
- * node-wide, not per-group (docs/mediacenter.md §5.5): one shared
- * credential and cache. Same shape as saveScanSettings: signed, and the
- * button does not claim success until the node confirms it. The token
- * field is cleared after a save either way: it is never echoed back by
- * the node (tmdb_config_ack carries only whether one is set, never the
- * value), so there is nothing to keep showing.
- */
- const saveTmdbConfig = useCallback(async () => {
- const transport = transportRef && transportRef.current;
- setTmdbMsg('');
- setTmdbBusy(true);
- try {
- if (!transport || !transport.connected) {
- throw new Error('Not connected to the node');
- }
- const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
- const signFn = (sk && window.MeshBayKeys)
- ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
- : null;
- const token = tmdbTokenDraft.trim();
- await transport.setTmdbConfig(token || undefined, tmdbLanguage, signFn);
- setTmdbTokenDraft('');
- if (onTmdbConfig) {
- onTmdbConfig({
- tokenCustomized: token
- ? true
- : (tmdbConfig ? tmdbConfig.tokenCustomized : false),
- language: tmdbLanguage,
- });
- }
- setTmdbMsg(t('settings_node.scan_saved'));
- } catch (err) {
- setTmdbMsg(err.message);
- } finally {
- setTmdbBusy(false);
- }
- }, [transportRef, onTmdbConfig, tmdbTokenDraft, tmdbConfig, tmdbLanguage]);
-
- // A node that has never had a language explicitly set would otherwise
- // query TMDB with none at all — which TMDB itself resolves to English,
- // regardless of who the operator is — even though this form already
- // *suggests* their own UI language as the value. Applied once,
- // automatically, the first time the operator (the only one who can sign
- // this) is actually connected to see it: a real default tied to whoever
- // runs this particular node, never a single hardcoded language for every
- // node. `tmdbConfig.language` being set at all — from this or from an
- // explicit save — is what stops it from ever firing again, so "unless
- // manually changed" holds regardless of which of the two set it first.
- const autoLanguageSetRef = useRef(false);
- useEffect(() => {
- if (!isNodeAdmin || !connected || !tmdbConfig || tmdbConfig.language) return;
- if (autoLanguageSetRef.current) return;
- autoLanguageSetRef.current = true;
- saveTmdbConfig();
- }, [isNodeAdmin, connected, tmdbConfig, saveTmdbConfig]);
+ // ── Per-app settings ────────────────────────────────────────────────
+ //
+ // What every app's settings pane is given, and the one operation the page
+ // performs on their behalf. TMDB, MusicBrainz and each app's folder pickers
+ // used to be hand-written sections here, ~470 lines of them, each with its
+ // own draft state and save handler saying the same thing about a different
+ // key. They live in `<app>-app-settings.js` now; this is the whole of what
+ // the page still knows about any of it.
- const [mbMsg, setMbMsg] = useState('');
- const [mbEnabledBusy, setMbEnabledBusy] = useState(false);
- const mbEnabled = musicbrainzConfig ? musicbrainzConfig.enabled : true;
-
- /**
- * Whether MusicBrainz is used at all — per-group from the start
- * (docs/musicbay.md §3.2/§6). Same shape as saveTmdbEnabled.
- */
- const saveMusicbrainzEnabled = useCallback(async (nextEnabled) => {
- const transport = transportRef && transportRef.current;
- setMbMsg('');
- setMbEnabledBusy(true);
- try {
- if (!transport || !transport.connected) {
- throw new Error('Not connected to the node');
- }
- const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
- const signFn = (sk && window.MeshBayKeys)
- ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
- : null;
- await transport.setMusicbrainzEnabled(nextEnabled, signFn);
- if (onMusicbrainzEnabled) onMusicbrainzEnabled(nextEnabled);
- } catch (err) {
- setMbMsg(err.message);
- } finally {
- setMbEnabledBusy(false);
- }
- }, [transportRef, onMusicbrainzEnabled]);
-
- // Every folder anywhere in the group's shared index, deepest included —
- // `entries[].path` is each file's containing directory (files-app.js's own
- // convention), so every ancestor prefix of it is a real folder, and
- // `nodeDirs` covers ones with nothing in them yet. A flat, depth-indented
- // <select> rather than a live folder browser: choosing an app's root is a
- // rare, one-off decision, not something worth a whole navigable tree for.
- // Shared between the Videos and Music root pickers below — same folder
- // set either way.
- const rootFolderOptions = useMemo(() => {
+ // Every folder anywhere in the group's shared index. `entries[].path` is a
+ // file's containing directory (files-app.js's convention), so every ancestor
+ // prefix of it is a real folder; `nodeDirs` covers the ones with nothing in
+ // them yet. Derived here rather than in the picker so all of them agree, and
+ // so it is computed once per change instead of once per open.
+ const folderOptions = useMemo(() => {
const set = new Set();
const addAncestors = (path) => {
if (!path) return;
@@ -597,109 +671,30 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
return [...set].sort();
}, [entries, nodeDirs]);
- const [videoRootDraft, setVideoRootDraft] = useState(videoRoot || '');
- useEffect(() => { setVideoRootDraft(videoRoot || ''); }, [videoRoot]);
- const [videoRootBusy, setVideoRootBusy] = useState(false);
- const [videoRootMsg, setVideoRootMsg] = useState(null);
-
/**
- * Which folder is the Videos app's entry point for this group — same
- * shape as toggleApp/saveScanSettings: signed, and the picker does not
- * claim success until the node confirms it.
+ * Point one app at folders — the only app-specific operation this page
+ * performs, and it is generic.
*
- * Changing an *already-set* root is destructive to every member's Videos
- * tab (a different set of files, possibly none in common) — the operator
- * confirms that explicitly. Setting it for the first time is not: there is
- * nothing yet to lose.
+ * Everything else a pane needs it does itself with the transport it is
+ * given. That is the line: what every app has (directories) is here, what
+ * one app alone has (a TMDB key, a link-preview switch) is in its own file,
+ * and adding an app that only needs directories touches neither.
*/
- const saveVideoRoot = useCallback(async () => {
- const next = videoRootDraft;
- const current = videoRoot || '';
- if (next === current) return;
- if (current && !confirm(t('settings_node.video_root_change_confirm'))) return;
- const transport = transportRef && transportRef.current;
- setVideoRootMsg(null);
- setVideoRootBusy(true);
- try {
- if (!transport || !transport.connected) {
- throw new Error('Not connected to the node');
- }
- const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
- const signFn = (sk && window.MeshBayKeys)
- ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
- : null;
- await transport.setVideoRoot(next, signFn);
- if (onVideoRoot) onVideoRoot(next);
- setVideoRootMsg({ text: t('settings_node.scan_saved'), ok: true });
- } catch (err) {
- setVideoRootMsg({ text: err.message, ok: false });
- } finally {
- setVideoRootBusy(false);
- }
- }, [transportRef, onVideoRoot, videoRootDraft, videoRoot]);
-
- // Same shape as the Videos root above — the Music app's own entry point
- // (docs/musicbay.md's amended §2.1).
- const [audioRootDraft, setAudioRootDraft] = useState(audioRoot || '');
- useEffect(() => { setAudioRootDraft(audioRoot || ''); }, [audioRoot]);
- const [audioRootBusy, setAudioRootBusy] = useState(false);
- const [audioRootMsg, setAudioRootMsg] = useState(null);
-
- const saveAudioRoot = useCallback(async () => {
- const next = audioRootDraft;
- const current = audioRoot || '';
- if (next === current) return;
- if (current && !confirm(t('settings_node.audio_root_change_confirm'))) return;
+ const saveAppDirectories = useCallback(async (appKey, paths) => {
const transport = transportRef && transportRef.current;
- setAudioRootMsg(null);
- setAudioRootBusy(true);
- try {
- if (!transport || !transport.connected) {
- throw new Error('Not connected to the node');
- }
- const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
- const signFn = (sk && window.MeshBayKeys)
- ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
- : null;
- await transport.setAudioRoot(next, signFn);
- if (onAudioRoot) onAudioRoot(next);
- setAudioRootMsg({ text: t('settings_node.scan_saved'), ok: true });
- } catch (err) {
- setAudioRootMsg({ text: err.message, ok: false });
- } finally {
- setAudioRootBusy(false);
+ if (!transport || !transport.connected) {
+ throw new Error(t('node.root_no_route'));
}
- }, [transportRef, onAudioRoot, audioRootDraft, audioRoot]);
-
- // Photos app's own entry points — a set (docs/photos.md §2.1), unlike
- // videoRoot/audioRoot above. No "removing a root is destructive" confirm
- // dialog: removing one root only drops that root's albums from view, it
- // does not replace the whole tab's content the way changing video_root
- // does.
- const [photoRootsBusy, setPhotoRootsBusy] = useState(false);
- const [photoRootsMsg, setPhotoRootsMsg] = useState(null);
-
- const savePhotoRoots = useCallback(async (nextRoots) => {
- const transport = transportRef && transportRef.current;
- setPhotoRootsMsg(null);
- setPhotoRootsBusy(true);
- try {
- if (!transport || !transport.connected) {
- throw new Error('Not connected to the node');
- }
- const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
- const signFn = (sk && window.MeshBayKeys)
- ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
- : null;
- await transport.setPhotoRoots(nextRoots, signFn);
- if (onPhotoRoots) onPhotoRoots(nextRoots);
- setPhotoRootsMsg({ text: t('settings_node.scan_saved'), ok: true });
- } catch (err) {
- setPhotoRootsMsg({ text: err.message, ok: false });
- } finally {
- setPhotoRootsBusy(false);
+ // A node too old for the generic op still answers the three per-app
+ // messages that came before it, so an operator on one keeps the ability
+ // they had rather than being handed a control that times out.
+ if (transport.supportsAppOps) {
+ await transport.setAppDirectories(appKey, paths, adminSignFn);
+ } else {
+ await transport.setAppDirectoriesLegacy(appKey, paths, adminSignFn);
}
- }, [transportRef, onPhotoRoots]);
+ if (onAppDirectories) onAppDirectories(appKey, paths);
+ }, [transportRef, adminSignFn, onAppDirectories]);
const [removing, setRemoving] = useState('');
@@ -884,28 +879,70 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
</div>
`}
- ${/* Which group "applications" members see. New ones (Videos, Music,
- Photos) show up here automatically as they register in apps.js —
- nothing about this section changes to add one. */
- isNodeAdmin && connected && html`
- <${CollapsibleSection} titleKey="members.apps_title">
- <p class="settings-hint">${t('members.apps_hint')}</p>
- <ul class="apps-toggle-list">
- ${APPS.map(a => html`
- <li key=${a.key} class="settings-row">
- <label class="settings-label">
- <input type="checkbox" checked=${activeApps.includes(a.key)}
- disabled=${appsBusy}
- onChange=${() => toggleApp(a.key)} />
- ${' '}${t(a.labelKey)}
- </label>
- </li>
- `)}
- </ul>
- ${appsMsg && html`<p class="error-msg">${appsMsg}</p>`}
+ ${/* Shared directories — the group's root folders, and the structural
+ control everything else in this page sits on top of, so it comes
+ first. Rendered whenever the operator has a route to their node:
+ a live MNP connection (any browser, anywhere) or the loopback API
+ (the node on this machine). It used to require the second, which
+ meant it rendered for nobody on the web. */
+ isNodeAdmin && (connected || nodeDetected) && html`
+ <${CollapsibleSection} titleKey="settings_node.shared_directories_title">
+ <p class="settings-hint">${t('settings_node.shared_directories_hint')}</p>
+ ${!connected && nodeDetected && html`
+ <p class="settings-hint">${t('settings_node.roots_offline_hint')}</p>`}
+ ${/* Read-only against a node that predates the root operations:
+ writable, removable, eject and plug have no older equivalent
+ to fall back to, and an unknown message type is dropped
+ unanswered — a thirty-second wait ending in a timeout, with
+ nothing on screen to say the node simply cannot do it. */''}
+ ${connected && !nodeSupportsAppOps && !nodeDetected && html`
+ <p class="settings-hint">${t('settings_node.roots_node_too_old')}</p>`}
+ <${SharedDirectoriesTable}
+ roots=${effectiveRoots}
+ groupId=${groupId}
+ transport=${transportRef.current}
+ signFn=${adminSignFn}
+ readOnly=${connected && !nodeSupportsAppOps && !nodeDetected}
+ nodeDetected=${nodeDetected}
+ onRootsChange=${loadNodeInfo}
+ onRefreshIndex=${onRefreshIndex} />
</${CollapsibleSection}>
`}
+ ${/* One collapsible section per application, from the registry.
+ Adding an app adds an entry to `apps.js` and a settings file; this
+ loop names none of them. The toggle in the header *is* the
+ enablement control — a separate checkbox list somewhere else meant
+ the operator turned an app on in one place and configured it in
+ another, with the two able to disagree.
+
+ Collapsed by default, and the settings inside are not rendered at
+ all while the app is off: a form for something that is not running
+ is a form whose Save button does nothing anyone can see. */
+ isNodeAdmin && connected && configurableApps().map((app) => html`
+ <${CollapsibleSection} key=${app.key} defaultOpen=${false} title=${html`
+ <span class="settings-meta-title">
+ <${Icon} name=${app.icon} />${' '}${t(app.labelKey)}
+ </span>
+ `} action=${html`
+ <${ToggleSwitch} checked=${activeApps.includes(app.key)}
+ disabled=${appsBusy}
+ onChange=${() => toggleApp(app.key)} />
+ `}>
+ ${!nodeSupportsAppOps && app.key === 'chat'
+ ? html`<p class="settings-hint">${t('settings_node.app_node_too_old')}</p>`
+ : activeApps.includes(app.key)
+ ? html`<${app.Settings}
+ roots=${effectiveRoots} dirs=${folderOptions}
+ settings=${appSettings}
+ saveDirectories=${(paths) => saveAppDirectories(app.key, paths)}
+ transport=${transportRef.current} signFn=${adminSignFn} />`
+ : html`<p class="settings-hint">${t('settings_app.disabled_hint')}</p>`}
+ </${CollapsibleSection}>
+ `)}
+
+ ${appsMsg && html`<p class="error-msg">${appsMsg}</p>`}
+
${/* How hard the node works watching its own disk — indexer.py
DirectoryIndexer. A performance knob, not a permission: it
changes nothing about who can see or do what. */
@@ -936,262 +973,6 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
</${CollapsibleSection}>
`}
- ${/* The on/off switch is per-group (2026-08-24); the custom token and
- query language stay node-wide, one shared credential/cache
- (docs/mediacenter.md §5.5). Both are new outbound third-party
- traffic the node did not have before the Videos app, so both are
- signed operator settings, not display preferences — but two
- independent ones now, saved separately. */
- isNodeAdmin && connected && html`
- <${CollapsibleSection} defaultOpen=${false} title=${html`
- <span class="settings-meta-title">
- <${Icon} name="server" />${' '}${t('settings_node.tmdb_title')}
- <span class="settings-meta-badge ${tmdbEnabled ? 'on' : ''}">
- ${tmdbEnabled ? t('settings_node.tmdb_enabled') : t('settings_node.tmdb_disabled')}
- </span>
- </span>
- `}>
- <p class="settings-hint">${t('settings_node.tmdb_hint')}</p>
- <div class="settings-row">
- <${ToggleSwitch} checked=${tmdbEnabled} disabled=${tmdbEnabledBusy}
- onChange=${(v) => saveTmdbEnabled(v)}
- label=${tmdbEnabled ? t('settings_node.tmdb_enabled') : t('settings_node.tmdb_disabled')} />
- </div>
- <div class="settings-row">
- <label class="settings-label">
- ${t('settings_node.tmdb_token_label')}
- <input type="password" placeholder=${t('settings_node.tmdb_token_placeholder')}
- value=${tmdbTokenDraft} disabled=${tmdbBusy}
- onInput=${e => setTmdbTokenDraft(e.target.value)} />
- </label>
- <p class="settings-hint">
- ${tmdbConfig && tmdbConfig.tokenCustomized
- ? t('settings_node.tmdb_token_customized')
- : t('settings_node.tmdb_token_default')}
- </p>
- </div>
- <div class="settings-row">
- <label class="settings-label">
- ${t('settings_node.tmdb_language_label')}
- <select value=${tmdbLanguage} disabled=${tmdbBusy}
- onChange=${e => setTmdbLanguage(e.target.value)}>
- ${LOCALES.map(l => html`
- <option key=${l.code} value=${TMDB_LANGUAGE_BY_LOCALE[l.code]}>
- ${l.name}
- </option>
- `)}
- </select>
- </label>
- <p class="settings-hint">${t('settings_node.tmdb_language_hint')}</p>
- </div>
- <button class="btn btn-small btn-secondary" style="margin-top:8px"
- disabled=${tmdbBusy} onClick=${() => saveTmdbConfig()}>
- ${tmdbBusy ? t('settings_node.scan_saving') : t('settings_node.tmdb_save')}
- </button>
- ${tmdbMsg && html`<p class="settings-hint">${tmdbMsg}</p>`}
- </${CollapsibleSection}>
- `}
-
- ${/* Same two-part shape as TMDB above: the on/off switch is per-group,
- the contact string stays node-wide (docs/musicbay.md §3.2) —
- one operator identity, not a per-group concern. Unlike TMDB
- there is no token field: MusicBrainz's read endpoints need no
- credential, just a descriptive User-Agent contact. */
- isNodeAdmin && connected && html`
- <${CollapsibleSection} defaultOpen=${false} title=${html`
- <span class="settings-meta-title">
- <${Icon} name="music" />${' '}${t('settings_node.musicbrainz_title')}
- <span class="settings-meta-badge ${mbEnabled ? 'on' : ''}">
- ${mbEnabled ? t('settings_node.musicbrainz_enabled') : t('settings_node.musicbrainz_disabled')}
- </span>
- </span>
- `}>
- <p class="settings-hint">${t('settings_node.musicbrainz_hint')}</p>
- <div class="settings-row">
- <${ToggleSwitch} checked=${mbEnabled} disabled=${mbEnabledBusy}
- onChange=${(v) => saveMusicbrainzEnabled(v)}
- label=${mbEnabled ? t('settings_node.musicbrainz_enabled') : t('settings_node.musicbrainz_disabled')} />
- </div>
- ${mbMsg && html`<p class="settings-hint">${mbMsg}</p>`}
- </${CollapsibleSection}>
- `}
-
- ${/* Which folder is the Videos app's entry point for this group —
- per-group like uploads, not node-wide like TMDB (mediacenter.md
- §5.6). Until one is chosen, the Videos tab says so instead of
- listing anything, and the node runs no TMDB/thumbnail work for
- this group at all (daemon.py's _enrich_new_video_entries). */
- isNodeAdmin && connected
- && ((nodeDetected && nodeRoots.length > 0)
- || activeApps.includes('video') || activeApps.includes('music')
- || activeApps.includes('photo')) && html`
- <${CollapsibleSection} titleKey="settings_node.directories_title">
- <p class="settings-hint">${t('settings_node.directories_hint')}</p>
-
- ${activeApps.includes('video') && html`
- <${RootFolderRow} icon="video"
- titleKey="settings_node.video_root_title" hintKey="settings_node.video_root_hint"
- folders=${rootFolderOptions} value=${videoRoot}
- draft=${videoRootDraft} onDraftChange=${setVideoRootDraft}
- busy=${videoRootBusy} msg=${videoRootMsg} onSave=${saveVideoRoot}
- noneKey="settings_node.video_root_none" saveKey="settings_node.video_root_save" />
- `}
- ${activeApps.includes('music') && html`
- <${RootFolderRow} icon="music"
- titleKey="settings_node.audio_root_title" hintKey="settings_node.audio_root_hint"
- folders=${rootFolderOptions} value=${audioRoot}
- draft=${audioRootDraft} onDraftChange=${setAudioRootDraft}
- busy=${audioRootBusy} msg=${audioRootMsg} onSave=${saveAudioRoot}
- noneKey="settings_node.audio_root_none" saveKey="settings_node.audio_root_save" />
- `}
- ${activeApps.includes('photo') && html`
- <${PhotoRootsRow}
- folders=${rootFolderOptions} value=${photoRoots}
- busy=${photoRootsBusy} msg=${photoRootsMsg} onSave=${savePhotoRoots} />
- `}
- ${/* Roots management (Electron-only, when node is local) — folded into
- the same Directories section as the two root pickers above. */
- nodeDetected && nodeRoots.length > 0 && html`
- <div class="settings-root-row">
- <div class="settings-root-row-title">
- <${Icon} name="server" />
- <h4>${t('settings_node.roots')}</h4>
- </div>
- ${nodeMsg && html`<p class="settings-hint">${nodeMsg}</p>`}
- <div class="node-roots">
- ${nodeRoots.map(r => html`
- <div class="node-root ${!r.available ? 'node-root-unavailable' : ''}"
- key=${r.name}>
- <div class="node-root-info">
- <span class="node-root-name">
- <${Icon} name="folder" />
- ${r.name}
- </span>
- ${r.upload && html`
- <span class="node-root-badge">${t('node.upload_root')}</span>`}
- ${!r.available && html`
- <span class="node-root-badge node-root-badge-warn">
- ${t('node.unavailable')}</span>`}
- </div>
- ${nodeRoots.length > 1 && !r.upload && html`
- <button class="btn btn-small btn-danger"
- disabled=${nodeBusy}
- onClick=${async () => {
- if (!confirm(t('node.root_remove_confirm', { name: r.name }))) return;
- const countBefore = nodeRoots.length;
- setNodeBusy(true); setNodeMsg('');
- try {
- await platform.node.call('DELETE',
- '/api/groups/' + groupId + '/roots/' + encodeURIComponent(r.name));
- await platform.node.call('POST', '/api/reload');
- setNodeMsg(t('node.root_removed'));
- await waitForRootCount(countBefore - 1);
- // Folders (unlike files) only ever arrive via a full
- // index_sync, never index_delta (daemon.py's ongoing
- // push has no `dirs` field) — without this, the
- // Videos/Music root pickers kept offering a folder
- // that no longer existed until the page was reloaded.
- if (onRefreshIndex) await onRefreshIndex();
- } catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
- finally { setNodeBusy(false); }
- }}>
- ${t('node.remove_root')}</button>`}
- </div>
- `)}
- <button class="btn btn-small btn-secondary" style="margin-top:8px"
- disabled=${nodeBusy}
- onClick=${async () => {
- const chosen = await platform.rootPicker.choose();
- if (!chosen) return;
- const countBefore = nodeRoots.length;
- setNodeBusy(true); setNodeMsg(''); setNodeIndexProgress(null);
- try {
- await platform.node.call('POST',
- '/api/groups/' + groupId + '/roots',
- { path: chosen.path, name: chosen.name });
- await platform.node.call('POST', '/api/reload');
- // The root is already scanning in the background on the
- // node regardless of whether anyone watches this — see
- // the "closing the client" test in test_hot_reload_*.py.
- // This is only about not leaving the operator staring at
- // an unchanged screen while it happens.
- await platform.watchIndexProgress(groupId, setNodeIndexProgress);
- setNodeMsg(t('node.root_added'));
- await waitForRootCount(countBefore + 1);
- // See the matching comment on root removal above — a new
- // folder needs a full index_sync to show up anywhere that
- // reads `nodeDirs` (the Videos/Music root pickers), not
- // just in this section's own node-roots list.
- if (onRefreshIndex) await onRefreshIndex();
- } catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
- finally { setNodeBusy(false); }
- }}>
- <${Icon} name="folder-plus" /> ${t('node.add_root')}
- </button>
- ${nodeIndexProgress && nodeIndexProgress.scanning && html`
- <div class="index-progress" style="margin-top:8px">
- <div class="index-progress-bar">
- <div class="index-progress-fill" style="width:${
- nodeIndexProgress.total_bytes
- ? Math.min(100, Math.round(
- 100 * nodeIndexProgress.scanned_bytes / nodeIndexProgress.total_bytes))
- : 0}%"></div>
- </div>
- <div class="index-progress-label">${t('wizard.indexing_progress', {
- pct: nodeIndexProgress.total_bytes
- ? Math.min(100, Math.round(
- 100 * nodeIndexProgress.scanned_bytes / nodeIndexProgress.total_bytes))
- : 0,
- })}</div>
- ${nodeIndexProgress.current_dir && html`
- <div class="index-progress-dir">
- ${t('wizard.indexing_current_dir', { dir: nodeIndexProgress.current_dir })}
- </div>
- `}
- </div>
- `}
- </div>
- </div>
- `}
- </${CollapsibleSection}>
- `}
-
- ${/* Operator only, and only with a live connection: the node is what
- holds and enforces this, so there is nothing to show or change
- without one. */ isNodeAdmin && connected && html`
- <${CollapsibleSection} titleKey="members.uploads_title">
- <div class="settings-row">
- <${ToggleSwitch} checked=${memberUpload} disabled=${uploadBusy}
- onChange=${() => setUploads(!memberUpload)}
- label=${memberUpload ? t('members.uploads_on') : t('members.uploads_off')} />
- </div>
- <p class="settings-hint">${t('members.uploads_hint')}</p>
- ${uploadMsg && html`<p class="error-msg">${uploadMsg}</p>`}
- </${CollapsibleSection}>
- `}
-
- ${/* Upload toggle via loopback when MNP not connected */
- nodeDetected && !connected && html`
- <${CollapsibleSection} titleKey="members.uploads_title">
- <div class="settings-row">
- <${ToggleSwitch} checked=${memberUpload} disabled=${nodeBusy}
- onChange=${async () => {
- setNodeBusy(true); setNodeMsg('');
- try {
- const newVal = !memberUpload;
- await platform.node.call('PUT',
- '/api/groups/' + groupId + '/member-upload',
- { allowed: newVal });
- if (onMemberUpload) onMemberUpload(newVal);
- } catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
- finally { setNodeBusy(false); }
- }}
- label=${memberUpload ? t('members.uploads_on') : t('members.uploads_off')} />
- </div>
- <p class="settings-hint">${t('members.uploads_hint')}</p>
- </${CollapsibleSection}>
- `}
-
${/* Delete/leave — node detach first (reversible), then hub delete
(irreversible). Closed by default: a danger-zone action is one
click away either way, but not the first thing seen on open. */
@@ -1334,4 +1115,4 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
const URL_RE = /\bhttps?:\/\/[^\s<>"']+/gi;
-export { GroupSettingsPanel };
+export { GroupSettingsPanel, SharedDirectoriesTable };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app-settings.js
new file mode 100644
index 0000000..9058199
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app-settings.js
@@ -0,0 +1,50 @@
+import { html, useState, useEffect } from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { useSaver } from './settings-ui.js';
+import { FolderPickerField } from './folder-tree.js';
+
+/**
+ * HelloWorld's settings: one folder, and nothing else.
+ *
+ * This is the whole server-side surface an application needs — no op, no MNP
+ * message, no route, no roster accessor. `saveDirectories` is bound to this
+ * app's registry key by the page, and `ops.set_app_directories` stores the row
+ * under that name without knowing what it is.
+ *
+ * It takes the shared props and no others (`docs/apps.md` §3b), which is what
+ * `test_app_settings_plugin.py` checks of every pane — including this one, so
+ * the reference implementation is held to the contract it demonstrates.
+ */
+function HelloWorldSettings({ roots, dirs, settings, saveDirectories }) {
+ const { busy, msg, run } = useSaver();
+ const [directories, setDirectories] = useState(
+ settings.helloworldDirectories || []);
+
+ useEffect(() => {
+ setDirectories(settings.helloworldDirectories || []);
+ }, [settings.helloworldDirectories]);
+
+ const current = settings.helloworldDirectories || [];
+ const dirty = directories.length !== current.length
+ || directories.some((d, i) => d !== current[i]);
+
+ return html`
+ <div class="app-settings">
+ <${FolderPickerField}
+ label=${t('settings_app.helloworld_directory_label')}
+ hint=${t('settings_app.helloworld_directory_hint')}
+ roots=${roots} dirs=${dirs} mode="single"
+ value=${directories[0] || ''} disabled=${busy}
+ onChange=${(path) => setDirectories(path ? [path] : [])} />
+
+ <button class="btn btn-small btn-secondary" style="margin-top:4px"
+ disabled=${busy || !dirty}
+ onClick=${() => run(() => saveDirectories(directories))}>
+ ${busy ? t('settings_app.saving') : t('settings_app.save')}
+ </button>
+ ${msg && html`<p class="settings-hint">${msg}</p>`}
+ </div>
+ `;
+}
+
+export { HelloWorldSettings };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app.js b/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app.js
new file mode 100644
index 0000000..01b4ed4
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app.js
@@ -0,0 +1,62 @@
+import { html, useMemo } from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { Icon } from './icon.js';
+import { formatSize } from './file-utils.js';
+
+/**
+ * The smallest application this platform can host, and the proof that adding
+ * one costs nothing outside its own two files.
+ *
+ * Everything else in `docs/refactor-groups.md` §3 is asserted by tests that
+ * read source. This is the other kind of evidence: an app nobody wrote a line
+ * of plumbing for, that stores directories, appears as a tab, and lists files —
+ * because the registry entry beside it is genuinely all there is.
+ *
+ * **Not shipped to operators.** It is registered with `dev: true`, which keeps
+ * it out of the tab bar and the settings page unless the reader opts in with
+ * `?dev=1` (same shape as transport.js's `?trace=1`). Deleting the flag would
+ * put a toy app in everybody's group; deleting the app would leave the claim
+ * resting entirely on tests that read text.
+ *
+ * It takes the standard props — see `docs/apps.md` §2 — and reads
+ * `helloworldDirectories`, which nothing on the node knows about by name:
+ * `ops.set_app_directories` keys the row by whatever the app is called.
+ */
+function HelloWorldApp({ entries, availableEntries, helloworldDirectories, status }) {
+ const dirs = helloworldDirectories || [];
+ const pool = availableEntries || entries || [];
+
+ const files = useMemo(() => pool.filter((e) => {
+ const p = e.path || '';
+ return dirs.some((d) => p === d || p.startsWith(d + '/'));
+ }).slice(0, 200), [pool, dirs]);
+
+ if (status !== 'connected') {
+ return html`<p class="page-message">${t('status.connecting_short')}</p>`;
+ }
+
+ return html`
+ <div class="page-content">
+ <h2>${t('helloworld.greeting')}</h2>
+ ${dirs.length === 0 ? html`
+ <p class="page-message">${t('helloworld.no_directory')}</p>
+ ` : html`
+ <p class="settings-hint">
+ ${t('helloworld.counted', { n: files.length })}
+ ${' — '}${dirs.join(', ')}
+ </p>
+ <ul class="hw-list">
+ ${files.map((e) => html`
+ <li key=${e.id} class="hw-item">
+ <${Icon} name="folder" />
+ <span class="hw-name">${e.name}</span>
+ <span class="hw-size">${formatSize(e.size || 0)}</span>
+ </li>
+ `)}
+ </ul>
+ `}
+ </div>
+ `;
+}
+
+export { HelloWorldApp };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 46168cf..d05d3a3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -126,6 +126,12 @@ export default {
'group.tab_video': 'Videos',
'group.tab_music': 'Musik',
'group.tab_photos': 'Fotos',
+ 'group.tab_helloworld': 'HelloWorld',
+ 'helloworld.greeting': 'Hallo, Welt!',
+ 'helloworld.no_directory': 'Noch kein Ordner gewählt. Wählen Sie einen in den Einstellungen.',
+ 'helloworld.counted': '{n} Datei(en)',
+ 'settings_app.helloworld_directory_label': 'Ordner',
+ 'settings_app.helloworld_directory_hint': 'Der Ordner, den diese Referenz-App auflistet. Sie zeigt, dass eine neue Anwendung nichts außer ihren beiden Dateien braucht.',
'group.tab_members': 'Mitglieder',
'group.tab_settings': "Einstellungen",
'members.danger_leave_hint': "Sie verlieren den Zugriff auf die Dateien und den Chat dieser Gruppe.",
@@ -154,7 +160,8 @@ export default {
+ 'diese Gruppe hostet.',
'group.upload': 'Hochladen',
'group.mkdir': 'Neuer Ordner',
- 'group.mkdir_prompt': 'Name des neuen Ordners:',
+ 'group.mkdir_prompt': 'Name des neuen Ordners',
+ 'group.mkdir_offline': 'Nicht mit dem Node verbunden.',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
'device.add_btn': 'Get a linking code',
@@ -169,6 +176,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(nicht verfügbar — Laufwerk getrennt)',
+ 'group.root_plug': 'Einstecken',
+ 'group.root_eject': 'Auswerfen',
+ 'group.root_writable': 'Lesen/Schreiben',
+ 'group.root_ejected': '(ausgeworfen)',
'group.view': 'Ansehen',
'group.delete': 'Löschen',
'group.delete_confirm': '{name} löschen?',
@@ -195,6 +206,7 @@ export default {
'chat.placeholder': 'Nachricht schreiben …',
'chat.send': 'Senden',
'chat.attach': 'Datei anhängen',
+ 'chat.attach_read_only': 'Kein beschreibbares freigegebenes Verzeichnis — Anhänge sind aus',
// Video player
'video.loading': '{name} wird geladen …',
@@ -722,10 +734,16 @@ export default {
'node.roots': 'Verzeichnisse',
'node.add_root': 'Verzeichnis hinzufügen',
'node.remove_root': 'Entfernen',
+ 'node.root_no_signing_key': 'Kein Signaturschlüssel verfügbar — koppeln Sie dieses Gerät zuerst mit dem Node',
+ 'node.root_no_route': 'Keine Verbindung zum Node — verbinden Sie sich damit oder verwenden Sie die App auf dem Rechner, der ihn hostet',
+ 'node.root_remove_last': 'Eine Gruppe braucht mindestens ein Verzeichnis',
+ 'node.root_path_hint': 'Der Pfad, wie der Node ihn sieht, auf dem Rechner, der diese Gruppe hostet.',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'Pfad',
+ 'node.directory': 'Verzeichnis',
'node.root_added': 'Verzeichnis hinzugefügt.',
'node.root_remove_confirm': '„{name}" aus dieser Gruppe entfernen?',
'node.root_removed': 'Verzeichnis entfernt. Neustart empfohlen, um den Index zu aktualisieren.',
- 'node.upload_root': 'Uploads',
'node.attach_group': 'Gruppe hinzufügen',
'node.attach_pick': 'Zu hostende Gruppe',
'node.attach_dir': 'Freigegebenes Verzeichnis',
@@ -805,7 +823,41 @@ export default {
'settings_node.photo_roots_add': 'Hinzufügen',
'settings_node.photo_roots_remove': 'Entfernen',
'settings_node.photo_roots_save': 'Speichern',
- 'settings_node.directories_title': 'Verzeichnisse',
+ 'settings_node.shared_directories_title': 'Freigegebene Verzeichnisse',
+ 'settings_node.shared_directories_hint': 'Ordner, die mit dieser Gruppe geteilt werden. Lesen/Schreiben umschalten, um Uploads zu erlauben. Als wechselbar markieren für externe Laufwerke.',
+ 'folder_tree.title_single': 'Ordner auswählen',
+ 'folder_tree.title_multi': 'Ordner auswählen',
+ 'folder_tree.choose': 'Auswählen…',
+ 'folder_tree.remove': 'Entfernen',
+ 'folder_tree.add': 'Ordner hinzufügen…',
+ 'folder_tree.confirm': 'Übernehmen',
+ 'folder_tree.expand': 'Aufklappen',
+ 'folder_tree.collapse': 'Zuklappen',
+ 'folder_tree.nothing_selected': 'Nichts ausgewählt',
+ 'folder_tree.empty': 'Diese Gruppe hat noch keine freigegebenen Verzeichnisse.',
+ 'folder_tree.writable_only': 'Hier sind nur les- und beschreibbare Verzeichnisse wählbar — es wird hineingeschrieben.',
+ 'folder_tree.no_writable_root': 'Diese Gruppe hat kein beschreibbares Verzeichnis. Schalten Sie zuerst eines unter Freigegebene Verzeichnisse frei.',
+ 'folder_tree.read_only_blocked': 'Nur lesen — kein Schreiben möglich',
+ 'settings_app.save': 'Speichern',
+ 'settings_app.saving': 'Wird gespeichert…',
+ 'settings_app.disabled_hint': 'Schalten Sie diese App ein, um sie zu konfigurieren.',
+ 'settings_app.video_directories_label': 'Video-Ordner',
+ 'settings_app.video_directories_hint': 'Wo die Filme und Serien dieser Gruppe liegen. Nichts außerhalb erscheint im Videos-Tab.',
+ 'settings_app.music_directories_label': 'Musik-Ordner',
+ 'settings_app.music_directories_hint': 'Wo die Alben dieser Gruppe liegen. Nichts außerhalb erscheint im Musik-Tab.',
+ 'settings_app.photo_directories_label': 'Foto-Ordner',
+ 'settings_app.photo_directories_hint': 'Wo die Alben dieser Gruppe liegen. Nichts außerhalb erscheint im Fotos-Tab.',
+ 'settings_app.chat_directory_label': 'Ordner für Anhänge',
+ 'settings_app.chat_directory_hint': 'Wohin im Chat gesendete Dateien geschrieben werden. Muss ein beschreibbares Verzeichnis sein.',
+ 'settings_app.chat_no_writable_root': 'Diese Gruppe hat kein beschreibbares Verzeichnis, daher sind Anhänge aus.',
+ 'settings_app.chat_link_preview_label': 'Link-Vorschauen',
+ 'settings_app.chat_link_preview_hint': 'Postet ein Mitglied einen Link, holt der Node Titel und Bild der Seite. Das ist eine Anfrage von Ihrem Rechner an eine Website, die jemand anderes gewählt hat.',
+ 'settings_app.tmdb_token_prompt': 'Registrieren Sie sich bei TMDB, um einen eigenen API-Schlüssel zu erzeugen.',
+ 'settings_app.tmdb_token_link': 'Schlüssel holen',
+ 'settings_node.roots_offline_hint': 'Nicht mit dem Node verbunden — Änderungen laufen über den lokalen Node und greifen beim nächsten Neuladen.',
+ 'settings_node.roots_node_too_old': 'Dieser Node ist älter als diese Seite: Er kann seine Verzeichnisse anzeigen, aber hier nicht ändern. Aktualisieren Sie ihn oder nutzen Sie die meshbay-node root-Befehle.',
+ 'settings_node.app_node_too_old': 'Dieser Node ist älter als diese Seite und hat für diese App noch keine Einstellung. Aktualisieren Sie ihn, um sie hier zu konfigurieren.',
+ 'settings_node.directories_title': 'App-Verzeichnisse',
'settings_node.directories_hint': 'Freigegebene Ordner und welchen davon die Videos-, Musik- und Fotos-Apps als eigene(n) Einstiegspunkt(e) nutzen.',
// Create-group wizard
@@ -909,4 +961,7 @@ export default {
'node.tab_peers': 'Peers',
'node.tab_audit': 'Audit',
'node.tab_settings': 'Settings',
+ 'node.root_rw': 'Lesen/Schreiben',
+ 'node.root_ro': 'Nur lesen',
+ 'node.removable': 'wechselbar',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 2d38e4b..6dbb70f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -127,6 +127,12 @@ export default {
'group.tab_video': 'Videos',
'group.tab_music': 'Music',
'group.tab_photos': 'Photos',
+ 'group.tab_helloworld': 'HelloWorld',
+ 'helloworld.greeting': 'Hello, World!',
+ 'helloworld.no_directory': 'No folder chosen yet. Pick one in Settings.',
+ 'helloworld.counted': '{n} file(s)',
+ 'settings_app.helloworld_directory_label': 'Folder',
+ 'settings_app.helloworld_directory_hint': 'The folder this reference app lists. It exists to show that adding an application needs nothing beyond its own two files.',
'group.tab_members': 'Members',
'group.tab_settings': "Settings",
'members.danger_leave_hint': "You will lose access to this group's files and chat.",
@@ -154,7 +160,8 @@ export default {
'group.offline_hint': 'Files will appear when a node hosting this group connects.',
'group.upload': 'Upload',
'group.mkdir': 'New folder',
- 'group.mkdir_prompt': 'Name of the new folder:',
+ 'group.mkdir_prompt': 'New folder name',
+ 'group.mkdir_offline': 'Not connected to the node.',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
'device.add_btn': 'Get a linking code',
@@ -169,6 +176,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(unavailable — the drive is disconnected)',
+ 'group.root_ejected': '(ejected)',
+ 'group.root_writable': 'Read/Write',
+ 'group.root_eject': 'Eject',
+ 'group.root_plug': 'Plug in',
'group.view': 'View',
'group.delete': 'Delete',
'group.delete_confirm': 'Delete {name}?',
@@ -194,6 +205,7 @@ export default {
'chat.placeholder': 'Type a message...',
'chat.send': 'Send',
'chat.attach': 'Attach file',
+ 'chat.attach_read_only': 'No writable shared directory — attachments are off',
// Video player
'video.loading': 'Loading {name}...',
@@ -599,8 +611,42 @@ export default {
'settings_node.photo_roots_add': 'Add',
'settings_node.photo_roots_remove': 'Remove',
'settings_node.photo_roots_save': 'Save',
- 'settings_node.directories_title': 'Directories',
- 'settings_node.directories_hint': 'Shared folders, and which of them the Videos, Music and Photos apps use as their own entry point(s).',
+ 'settings_node.shared_directories_title': 'Shared directories',
+ 'settings_node.shared_directories_hint': 'Folders shared with this group. Toggle read-write to allow uploads, mark as removable for external drives.',
+ 'folder_tree.title_single': 'Choose a folder',
+ 'folder_tree.title_multi': 'Choose folders',
+ 'folder_tree.choose': 'Choose…',
+ 'folder_tree.remove': 'Remove',
+ 'folder_tree.add': 'Add a folder…',
+ 'folder_tree.confirm': 'Use these',
+ 'folder_tree.expand': 'Expand',
+ 'folder_tree.collapse': 'Collapse',
+ 'folder_tree.nothing_selected': 'Nothing selected',
+ 'folder_tree.empty': 'This group has no shared directories yet.',
+ 'folder_tree.writable_only': 'Only read-write directories can be chosen here — files are written to this one.',
+ 'folder_tree.no_writable_root': 'This group has no read-write directory. Make one read-write in Shared directories first.',
+ 'folder_tree.read_only_blocked': 'Read-only — cannot be written to',
+ 'settings_app.save': 'Save',
+ 'settings_app.saving': 'Saving…',
+ 'settings_app.disabled_hint': 'Turn this app on to configure it.',
+ 'settings_app.video_directories_label': 'Video folders',
+ 'settings_app.video_directories_hint': 'Where this group\'s films and shows live. Nothing outside them appears in the Videos tab.',
+ 'settings_app.music_directories_label': 'Music folders',
+ 'settings_app.music_directories_hint': 'Where this group\'s albums live. Nothing outside them appears in the Music tab.',
+ 'settings_app.photo_directories_label': 'Photo folders',
+ 'settings_app.photo_directories_hint': 'Where this group\'s albums live. Nothing outside them appears in the Photos tab.',
+ 'settings_app.chat_directory_label': 'Attachment folder',
+ 'settings_app.chat_directory_hint': 'Where files sent in chat are written. Must be a read-write directory.',
+ 'settings_app.chat_no_writable_root': 'This group has no read-write directory, so attachments are off.',
+ 'settings_app.chat_link_preview_label': 'Link previews',
+ 'settings_app.chat_link_preview_hint': 'When a member posts a link, the node fetches the page\'s title and image. That is a request from your machine to a site somebody else chose.',
+ 'settings_app.tmdb_token_prompt': 'Sign up on TMDB to generate your own API key.',
+ 'settings_app.tmdb_token_link': 'Get a key',
+ 'settings_node.roots_offline_hint': 'Not connected to the node — changes go through the local node instead, and take effect on its next reload.',
+ 'settings_node.roots_node_too_old': 'This node is older than this page: it can show its directories but not change them here. Update it, or use the meshbay-node root commands.',
+ 'settings_node.app_node_too_old': 'This node is older than this page and has no setting for this app yet. Update it to configure this here.',
+ 'settings_node.directories_title': 'App directories',
+ 'settings_node.directories_hint': 'Which shared folders the Videos, Music and Photos apps use as their entry point(s).',
// Members
'members.col_role': 'Role',
@@ -753,10 +799,19 @@ export default {
'node.peers': { one: '1 peer', other: '{n} peers' },
'node.no_gek': 'No group key',
'node.roots': 'Directories',
- 'node.upload_root': 'uploads',
+ 'node.directory': 'Directory',
+ 'node.root_rw': 'Writable',
+ 'node.root_ro': 'read-only',
+ 'node.removable': 'Removable',
'node.unavailable': 'unavailable',
'node.add_root': 'Add directory',
'node.remove_root': 'Remove',
+ 'node.root_no_signing_key': 'No signing key available — pair this device with the node first',
+ 'node.root_no_route': 'No route to the node — connect to it, or use the app on the machine hosting it',
+ 'node.root_remove_last': 'A group needs at least one directory',
+ 'node.root_path_hint': 'The path as the node sees it, on the machine hosting this group.',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'Path',
'node.root_added': 'Directory added.',
'node.root_removed': 'Directory removed. Restart recommended to update the index.',
'node.root_remove_confirm': 'Remove "{name}" from this group?',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 471ad82..aa950cc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -124,6 +124,12 @@ export default {
'group.tab_video': 'Vídeos',
'group.tab_music': 'Música',
'group.tab_photos': 'Fotos',
+ 'group.tab_helloworld': 'HelloWorld',
+ 'helloworld.greeting': '¡Hola, mundo!',
+ 'helloworld.no_directory': 'Aún no hay carpeta elegida. Elige una en los ajustes.',
+ 'helloworld.counted': '{n} archivo(s)',
+ 'settings_app.helloworld_directory_label': 'Carpeta',
+ 'settings_app.helloworld_directory_hint': 'La carpeta que lista esta aplicación de referencia. Existe para mostrar que añadir una aplicación no requiere más que sus dos archivos.',
'group.tab_members': 'Miembros',
'group.tab_settings': "Ajustes",
'members.danger_leave_hint': "Perderá el acceso a los archivos y al chat de este grupo.",
@@ -152,7 +158,8 @@ export default {
+ 'este grupo.',
'group.upload': 'Subir',
'group.mkdir': 'Nueva carpeta',
- 'group.mkdir_prompt': 'Nombre de la nueva carpeta:',
+ 'group.mkdir_prompt': 'Nombre de la nueva carpeta',
+ 'group.mkdir_offline': 'Sin conexión con el nodo.',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
'device.add_btn': 'Get a linking code',
@@ -167,6 +174,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(no disponible — la unidad está desconectada)',
+ 'group.root_plug': 'Conectar',
+ 'group.root_eject': 'Expulsar',
+ 'group.root_writable': 'Lectura/Escritura',
+ 'group.root_ejected': '(expulsado)',
'group.view': 'Ver',
'group.delete': 'Eliminar',
'group.delete_confirm': '¿Eliminar {name}?',
@@ -193,6 +204,7 @@ export default {
'chat.placeholder': 'Escriba un mensaje...',
'chat.send': 'Enviar',
'chat.attach': 'Adjuntar archivo',
+ 'chat.attach_read_only': 'Ningún directorio compartido con escritura: los adjuntos están desactivados',
// Video player
'video.loading': 'Cargando {name}...',
@@ -718,10 +730,16 @@ export default {
'node.roots': 'Directorios',
'node.add_root': 'Añadir directorio',
'node.remove_root': 'Eliminar',
+ 'node.root_no_signing_key': 'No hay clave de firma disponible: empareja primero este dispositivo con el nodo',
+ 'node.root_no_route': 'No hay ruta al nodo: conéctate a él o usa la aplicación en la máquina que lo aloja',
+ 'node.root_remove_last': 'Un grupo necesita al menos un directorio',
+ 'node.root_path_hint': 'La ruta tal como la ve el nodo, en la máquina que aloja este grupo.',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'Ruta',
+ 'node.directory': 'Directorio',
'node.root_added': 'Directorio añadido.',
'node.root_remove_confirm': '¿Eliminar «{name}» de este grupo?',
'node.root_removed': 'Directorio eliminado. Se recomienda reiniciar para actualizar el índice.',
- 'node.upload_root': 'subidas',
'node.attach_group': 'Añadir grupo',
'node.attach_pick': 'Grupo a alojar',
'node.attach_dir': 'Directorio compartido',
@@ -801,7 +819,41 @@ export default {
'settings_node.photo_roots_add': 'Añadir',
'settings_node.photo_roots_remove': 'Quitar',
'settings_node.photo_roots_save': 'Guardar',
- 'settings_node.directories_title': 'Directorios',
+ 'settings_node.shared_directories_title': 'Directorios compartidos',
+ 'settings_node.shared_directories_hint': 'Carpetas compartidas con este grupo. Active lectura-escritura para permitir subidas, marque como extraíble para unidades externas.',
+ 'folder_tree.title_single': 'Elegir una carpeta',
+ 'folder_tree.title_multi': 'Elegir carpetas',
+ 'folder_tree.choose': 'Elegir…',
+ 'folder_tree.remove': 'Quitar',
+ 'folder_tree.add': 'Añadir una carpeta…',
+ 'folder_tree.confirm': 'Usar',
+ 'folder_tree.expand': 'Expandir',
+ 'folder_tree.collapse': 'Contraer',
+ 'folder_tree.nothing_selected': 'Nada seleccionado',
+ 'folder_tree.empty': 'Este grupo aún no tiene directorios compartidos.',
+ 'folder_tree.writable_only': 'Aquí solo se pueden elegir directorios de lectura-escritura: se escriben archivos en él.',
+ 'folder_tree.no_writable_root': 'Este grupo no tiene ningún directorio de escritura. Activa primero lectura-escritura en Directorios compartidos.',
+ 'folder_tree.read_only_blocked': 'Solo lectura: no se puede escribir',
+ 'settings_app.save': 'Guardar',
+ 'settings_app.saving': 'Guardando…',
+ 'settings_app.disabled_hint': 'Activa esta aplicación para configurarla.',
+ 'settings_app.video_directories_label': 'Carpetas de vídeo',
+ 'settings_app.video_directories_hint': 'Dónde están las películas y series de este grupo. Nada fuera de ellas aparece en la pestaña Vídeos.',
+ 'settings_app.music_directories_label': 'Carpetas de música',
+ 'settings_app.music_directories_hint': 'Dónde están los álbumes de este grupo. Nada fuera de ellos aparece en la pestaña Música.',
+ 'settings_app.photo_directories_label': 'Carpetas de fotos',
+ 'settings_app.photo_directories_hint': 'Dónde están los álbumes de este grupo. Nada fuera de ellos aparece en la pestaña Fotos.',
+ 'settings_app.chat_directory_label': 'Carpeta de adjuntos',
+ 'settings_app.chat_directory_hint': 'Dónde se escriben los archivos enviados en el chat. Debe ser un directorio de lectura-escritura.',
+ 'settings_app.chat_no_writable_root': 'Este grupo no tiene directorio de escritura, así que los adjuntos están desactivados.',
+ 'settings_app.chat_link_preview_label': 'Vistas previas de enlaces',
+ 'settings_app.chat_link_preview_hint': 'Cuando un miembro publica un enlace, el nodo obtiene el título y la imagen de la página. Es una petición desde tu máquina a un sitio que eligió otra persona.',
+ 'settings_app.tmdb_token_prompt': 'Regístrate en TMDB para generar tu propia clave de API.',
+ 'settings_app.tmdb_token_link': 'Obtener una clave',
+ 'settings_node.roots_offline_hint': 'Sin conexión con el nodo: los cambios pasan por el nodo local y se aplican en su próxima recarga.',
+ 'settings_node.roots_node_too_old': 'Este nodo es más antiguo que esta página: puede mostrar sus directorios pero no cambiarlos aquí. Actualízalo o usa los comandos meshbay-node root.',
+ 'settings_node.app_node_too_old': 'Este nodo es más antiguo que esta página y aún no tiene ajustes para esta aplicación. Actualízalo para configurarla aquí.',
+ 'settings_node.directories_title': 'Directorios de apps',
'settings_node.directories_hint': 'Carpetas compartidas, y cuál de ellas usan las apps de Vídeos, Música y Fotos como su(s) propio(s) punto(s) de entrada.',
// Create group wizard
@@ -905,4 +957,7 @@ export default {
'node.tab_peers': 'Peers',
'node.tab_audit': 'Audit',
'node.tab_settings': 'Settings',
+ 'node.root_rw': 'lectura-escritura',
+ 'node.root_ro': 'solo lectura',
+ 'node.removable': 'extraíble',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 48f26a2..daa9f94 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -125,6 +125,12 @@ export default {
'group.tab_video': 'Vidéos',
'group.tab_music': 'Musique',
'group.tab_photos': 'Photos',
+ 'group.tab_helloworld': 'HelloWorld',
+ 'helloworld.greeting': 'Bonjour, monde !',
+ 'helloworld.no_directory': 'Aucun dossier choisi. Choisissez-en un dans les réglages.',
+ 'helloworld.counted': '{n} fichier(s)',
+ 'settings_app.helloworld_directory_label': 'Dossier',
+ 'settings_app.helloworld_directory_hint': 'Le dossier que cette application de référence liste. Elle existe pour montrer qu\'ajouter une application ne demande rien de plus que ses deux fichiers.',
'group.tab_members': 'Membres',
'group.tab_settings': "Paramètres",
'members.danger_leave_hint': "Vous perdrez l’accès aux fichiers et à la discussion de ce groupe.",
@@ -153,7 +159,8 @@ export default {
+ 'groupe se connectera.',
'group.upload': 'Envoyer',
'group.mkdir': 'Nouveau dossier',
- 'group.mkdir_prompt': 'Nom du nouveau dossier :',
+ 'group.mkdir_prompt': 'Nom du nouveau dossier',
+ 'group.mkdir_offline': 'Non connecté au nœud.',
'device.add_title': 'Ce navigateur n’est pas encore lié à ce nœud',
'device.add_hint': 'Votre compte est connu ici, mais ce navigateur détient une autre clé. Approuvez-le depuis un appareil déjà lié — sans passer par l’opérateur.',
'device.add_btn': 'Obtenir un code de liaison',
@@ -168,6 +175,10 @@ export default {
'device.approve_btn': 'Approuver',
'device.approved': 'Appareil lié.',
'group.root_unavailable': '(indisponible — le disque est déconnecté)',
+ 'group.root_ejected': '(éjecté)',
+ 'group.root_writable': 'Lecture/Écriture',
+ 'group.root_eject': 'Éjecter',
+ 'group.root_plug': 'Reconnecter',
'group.view': 'Afficher',
'group.delete': 'Supprimer',
'group.delete_confirm': 'Supprimer {name} ?',
@@ -194,6 +205,7 @@ export default {
'chat.placeholder': 'Écrivez un message...',
'chat.send': 'Envoyer',
'chat.attach': 'Joindre un fichier',
+ 'chat.attach_read_only': 'Aucun répertoire partagé en écriture — pièces jointes désactivées',
// Video player
'video.loading': 'Chargement de {name}...',
@@ -727,10 +739,19 @@ export default {
'node.roots': 'Répertoires',
'node.add_root': 'Ajouter un répertoire',
'node.remove_root': 'Retirer',
+ 'node.root_no_signing_key': 'Aucune clé de signature disponible — appairez d\'abord cet appareil avec le nœud',
+ 'node.root_no_route': 'Aucune route vers le nœud — connectez-vous à lui, ou utilisez l\'application sur la machine qui l\'héberge',
+ 'node.root_remove_last': 'Un groupe a besoin d\'au moins un répertoire',
+ 'node.root_path_hint': 'Le chemin tel que le nœud le voit, sur la machine qui héberge ce groupe.',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'Chemin',
'node.root_added': 'Répertoire ajouté.',
'node.root_remove_confirm': 'Retirer « {name} » de ce groupe ?',
'node.root_removed': 'Répertoire retiré. Un redémarrage est recommandé pour mettre à jour l\'index.',
- 'node.upload_root': 'uploads',
+ 'node.directory': 'Répertoire',
+ 'node.root_rw': 'Écriture',
+ 'node.root_ro': 'lecture seule',
+ 'node.removable': 'Amovible',
'node.attach_group': 'Ajouter un groupe',
'node.attach_pick': 'Groupe à héberger',
'node.attach_dir': 'Répertoire partagé',
@@ -816,8 +837,42 @@ export default {
'settings_node.photo_roots_add': 'Ajouter',
'settings_node.photo_roots_remove': 'Retirer',
'settings_node.photo_roots_save': 'Enregistrer',
- 'settings_node.directories_title': 'Répertoires',
- 'settings_node.directories_hint': 'Dossiers partagés, et lequel d\'entre eux les applications Vidéos, Musique et Photos utilisent comme leur(s) propre(s) point(s) d\'entrée.',
+ 'settings_node.shared_directories_title': 'Répertoires partagés',
+ 'settings_node.shared_directories_hint': 'Dossiers partagés avec ce groupe. Activez lecture-écriture pour autoriser les envois, marquez comme amovible pour les disques externes.',
+ 'folder_tree.title_single': 'Choisir un dossier',
+ 'folder_tree.title_multi': 'Choisir des dossiers',
+ 'folder_tree.choose': 'Choisir…',
+ 'folder_tree.remove': 'Retirer',
+ 'folder_tree.add': 'Ajouter un dossier…',
+ 'folder_tree.confirm': 'Utiliser',
+ 'folder_tree.expand': 'Déplier',
+ 'folder_tree.collapse': 'Replier',
+ 'folder_tree.nothing_selected': 'Rien de sélectionné',
+ 'folder_tree.empty': 'Ce groupe n\'a pas encore de répertoire partagé.',
+ 'folder_tree.writable_only': 'Seuls les répertoires en lecture-écriture sont proposés ici — des fichiers y sont écrits.',
+ 'folder_tree.no_writable_root': 'Ce groupe n\'a aucun répertoire en écriture. Activez d\'abord lecture-écriture dans Répertoires partagés.',
+ 'folder_tree.read_only_blocked': 'Lecture seule — écriture impossible',
+ 'settings_app.save': 'Enregistrer',
+ 'settings_app.saving': 'Enregistrement…',
+ 'settings_app.disabled_hint': 'Activez cette application pour la configurer.',
+ 'settings_app.video_directories_label': 'Dossiers vidéo',
+ 'settings_app.video_directories_hint': 'Où vivent les films et séries de ce groupe. Rien en dehors n\'apparaît dans l\'onglet Vidéos.',
+ 'settings_app.music_directories_label': 'Dossiers musique',
+ 'settings_app.music_directories_hint': 'Où vivent les albums de ce groupe. Rien en dehors n\'apparaît dans l\'onglet Musique.',
+ 'settings_app.photo_directories_label': 'Dossiers photo',
+ 'settings_app.photo_directories_hint': 'Où vivent les albums de ce groupe. Rien en dehors n\'apparaît dans l\'onglet Photos.',
+ 'settings_app.chat_directory_label': 'Dossier des pièces jointes',
+ 'settings_app.chat_directory_hint': 'Où sont écrits les fichiers envoyés dans le chat. Doit être un répertoire en lecture-écriture.',
+ 'settings_app.chat_no_writable_root': 'Ce groupe n\'a aucun répertoire en écriture : les pièces jointes sont désactivées.',
+ 'settings_app.chat_link_preview_label': 'Aperçus des liens',
+ 'settings_app.chat_link_preview_hint': 'Quand un membre poste un lien, le nœud récupère le titre et l\'image de la page. C\'est une requête depuis votre machine vers un site choisi par quelqu\'un d\'autre.',
+ 'settings_app.tmdb_token_prompt': 'Créez un compte TMDB pour générer votre propre clé d\'API.',
+ 'settings_app.tmdb_token_link': 'Obtenir une clé',
+ 'settings_node.roots_offline_hint': 'Non connecté au nœud — les changements passent par le nœud local et prennent effet à son prochain rechargement.',
+ 'settings_node.roots_node_too_old': 'Ce nœud est plus ancien que cette page : il peut afficher ses répertoires mais pas les modifier ici. Mettez-le à jour, ou utilisez les commandes meshbay-node root.',
+ 'settings_node.app_node_too_old': 'Ce nœud est plus ancien que cette page et n\'a pas encore de réglage pour cette application. Mettez-le à jour pour la configurer ici.',
+ 'settings_node.directories_title': 'Répertoires des applications',
+ 'settings_node.directories_hint': 'Quel(s) dossier(s) partagés les applications Vidéos, Musique et Photos utilisent comme leur(s) propre(s) point(s) d\'entrée.',
// Create group wizard
'wizard.title': 'Créer un groupe',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 564f34b..364c26c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -125,6 +125,12 @@ export default {
'group.tab_video': 'Video',
'group.tab_music': 'Musica',
'group.tab_photos': 'Foto',
+ 'group.tab_helloworld': 'HelloWorld',
+ 'helloworld.greeting': 'Ciao, mondo!',
+ 'helloworld.no_directory': 'Nessuna cartella scelta. Scegline una nelle impostazioni.',
+ 'helloworld.counted': '{n} file',
+ 'settings_app.helloworld_directory_label': 'Cartella',
+ 'settings_app.helloworld_directory_hint': 'La cartella elencata da questa applicazione di riferimento. Serve a mostrare che aggiungerne una non richiede altro che i suoi due file.',
'group.tab_members': 'Membri',
'group.tab_settings': "Impostazioni",
'members.danger_leave_hint': "Perderai l’accesso ai file e alla chat di questo gruppo.",
@@ -153,7 +159,8 @@ export default {
+ 'questo gruppo.',
'group.upload': 'Carica',
'group.mkdir': 'Nuova cartella',
- 'group.mkdir_prompt': 'Nome della nuova cartella:',
+ 'group.mkdir_prompt': 'Nome della nuova cartella',
+ 'group.mkdir_offline': 'Non connesso al nodo.',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
'device.add_btn': 'Get a linking code',
@@ -168,6 +175,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(non disponibile — l’unità è scollegata)',
+ 'group.root_plug': 'Ricollega',
+ 'group.root_eject': 'Espelli',
+ 'group.root_writable': 'Lettura/Scrittura',
+ 'group.root_ejected': '(espulso)',
'group.view': 'Visualizza',
'group.delete': 'Elimina',
'group.delete_confirm': 'Eliminare {name}?',
@@ -194,6 +205,7 @@ export default {
'chat.placeholder': 'Scriva un messaggio...',
'chat.send': 'Invia',
'chat.attach': 'Allega un file',
+ 'chat.attach_read_only': 'Nessuna directory condivisa scrivibile: gli allegati sono disattivati',
// Video player
'video.loading': 'Caricamento di {name}...',
@@ -726,10 +738,16 @@ export default {
'node.roots': 'Directory',
'node.add_root': 'Aggiungi directory',
'node.remove_root': 'Rimuovi',
+ 'node.root_no_signing_key': 'Nessuna chiave di firma disponibile: associa prima questo dispositivo al nodo',
+ 'node.root_no_route': 'Nessuna via verso il nodo: connettiti a esso oppure usa l\'applicazione sulla macchina che lo ospita',
+ 'node.root_remove_last': 'Un gruppo ha bisogno di almeno una directory',
+ 'node.root_path_hint': 'Il percorso come lo vede il nodo, sulla macchina che ospita questo gruppo.',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'Percorso',
+ 'node.directory': 'Directory',
'node.root_added': 'Directory aggiunta.',
'node.root_remove_confirm': 'Rimuovere «{name}» da questo gruppo?',
'node.root_removed': "Directory rimossa. Si consiglia un riavvio per aggiornare l'indice.",
- 'node.upload_root': 'caricamenti',
'node.attach_group': 'Aggiungi gruppo',
'node.attach_pick': 'Gruppo da ospitare',
'node.attach_dir': 'Directory condivisa',
@@ -815,7 +833,41 @@ export default {
'settings_node.photo_roots_add': 'Aggiungi',
'settings_node.photo_roots_remove': 'Rimuovi',
'settings_node.photo_roots_save': 'Salva',
- 'settings_node.directories_title': 'Directory',
+ 'settings_node.shared_directories_title': 'Directory condivise',
+ 'settings_node.shared_directories_hint': 'Cartelle condivise con questo gruppo. Attiva lettura-scrittura per consentire il caricamento, segna come rimovibile per unità esterne.',
+ 'folder_tree.title_single': 'Scegli una cartella',
+ 'folder_tree.title_multi': 'Scegli le cartelle',
+ 'folder_tree.choose': 'Scegli…',
+ 'folder_tree.remove': 'Rimuovi',
+ 'folder_tree.add': 'Aggiungi una cartella…',
+ 'folder_tree.confirm': 'Usa',
+ 'folder_tree.expand': 'Espandi',
+ 'folder_tree.collapse': 'Comprimi',
+ 'folder_tree.nothing_selected': 'Niente selezionato',
+ 'folder_tree.empty': 'Questo gruppo non ha ancora directory condivise.',
+ 'folder_tree.writable_only': 'Qui si possono scegliere solo directory in lettura-scrittura: ci vengono scritti dei file.',
+ 'folder_tree.no_writable_root': 'Questo gruppo non ha directory scrivibili. Attiva prima lettura-scrittura in Directory condivise.',
+ 'folder_tree.read_only_blocked': 'Sola lettura: non vi si può scrivere',
+ 'settings_app.save': 'Salva',
+ 'settings_app.saving': 'Salvataggio…',
+ 'settings_app.disabled_hint': 'Attiva questa applicazione per configurarla.',
+ 'settings_app.video_directories_label': 'Cartelle video',
+ 'settings_app.video_directories_hint': 'Dove si trovano film e serie di questo gruppo. Nulla al di fuori compare nella scheda Video.',
+ 'settings_app.music_directories_label': 'Cartelle musica',
+ 'settings_app.music_directories_hint': 'Dove si trovano gli album di questo gruppo. Nulla al di fuori compare nella scheda Musica.',
+ 'settings_app.photo_directories_label': 'Cartelle foto',
+ 'settings_app.photo_directories_hint': 'Dove si trovano gli album di questo gruppo. Nulla al di fuori compare nella scheda Foto.',
+ 'settings_app.chat_directory_label': 'Cartella degli allegati',
+ 'settings_app.chat_directory_hint': 'Dove vengono scritti i file inviati in chat. Deve essere una directory in lettura-scrittura.',
+ 'settings_app.chat_no_writable_root': 'Questo gruppo non ha directory scrivibili, quindi gli allegati sono disattivati.',
+ 'settings_app.chat_link_preview_label': 'Anteprime dei link',
+ 'settings_app.chat_link_preview_hint': 'Quando un membro pubblica un link, il nodo recupera titolo e immagine della pagina. È una richiesta dalla tua macchina a un sito scelto da qualcun altro.',
+ 'settings_app.tmdb_token_prompt': 'Registrati su TMDB per generare la tua chiave API.',
+ 'settings_app.tmdb_token_link': 'Ottieni una chiave',
+ 'settings_node.roots_offline_hint': 'Non connesso al nodo: le modifiche passano dal nodo locale e hanno effetto al successivo ricaricamento.',
+ 'settings_node.roots_node_too_old': 'Questo nodo è più vecchio di questa pagina: può mostrare le sue directory ma non modificarle qui. Aggiornalo, oppure usa i comandi meshbay-node root.',
+ 'settings_node.app_node_too_old': 'Questo nodo è più vecchio di questa pagina e non ha ancora impostazioni per questa applicazione. Aggiornalo per configurarla qui.',
+ 'settings_node.directories_title': 'Directory delle app',
'settings_node.directories_hint': 'Cartelle condivise, e quale di esse le app Video, Musica e Foto usano come proprio/i punto/i di ingresso.',
// Create-group wizard
@@ -919,4 +971,7 @@ export default {
'node.tab_peers': 'Peers',
'node.tab_audit': 'Audit',
'node.tab_settings': 'Settings',
+ 'node.root_rw': 'lettura-scrittura',
+ 'node.root_ro': 'sola lettura',
+ 'node.removable': 'rimovibile',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 3eed305..f778fc4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -123,6 +123,12 @@ export default {
'group.tab_video': '動画',
'group.tab_music': '音楽',
'group.tab_photos': '写真',
+ 'group.tab_helloworld': 'HelloWorld',
+ 'helloworld.greeting': 'ハロー、ワールド!',
+ 'helloworld.no_directory': 'フォルダーが未選択です。設定で選んでください。',
+ 'helloworld.counted': '{n} 件のファイル',
+ 'settings_app.helloworld_directory_label': 'フォルダー',
+ 'settings_app.helloworld_directory_hint': 'この参照アプリが一覧表示するフォルダーです。アプリの追加に必要なのは自身の 2 つのファイルだけであることを示すために存在します。',
'group.tab_members': 'メンバー',
'group.tab_settings': "設定",
'members.danger_leave_hint': "このグループのファイルとチャットにアクセスできなくなります。",
@@ -151,7 +157,8 @@ export default {
+ 'ファイルが表示されます。',
'group.upload': 'アップロード',
'group.mkdir': '新しいフォルダー',
- 'group.mkdir_prompt': '新しいフォルダーの名前:',
+ 'group.mkdir_prompt': '新しいフォルダー名',
+ 'group.mkdir_offline': 'ノードに接続していません。',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
'device.add_btn': 'Get a linking code',
@@ -166,6 +173,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(利用不可 — ドライブが切断されています)',
+ 'group.root_plug': '接続する',
+ 'group.root_eject': '取り外す',
+ 'group.root_writable': '読み書き可',
+ 'group.root_ejected': '(取り外し済み)',
'group.view': '表示',
'group.delete': '削除',
'group.delete_confirm': '{name} を削除しますか?',
@@ -191,6 +202,7 @@ export default {
'chat.placeholder': 'メッセージを入力…',
'chat.send': '送信',
'chat.attach': 'ファイルを添付',
+ 'chat.attach_read_only': '書き込み可能な共有ディレクトリがありません — 添付は無効です',
// Video player
'video.loading': '{name} を読み込んでいます…',
@@ -712,10 +724,16 @@ export default {
'node.roots': 'ディレクトリ',
'node.add_root': 'ディレクトリを追加',
'node.remove_root': '削除',
+ 'node.root_no_signing_key': '署名鍵がありません — 先にこの端末をノードとペアリングしてください',
+ 'node.root_no_route': 'ノードへの経路がありません — 接続するか、ノードを動かしているマシンでアプリを使ってください',
+ 'node.root_remove_last': 'グループには少なくとも 1 つのディレクトリが必要です',
+ 'node.root_path_hint': 'このグループをホストしているマシン上で、ノードから見たパスです。',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'パス',
+ 'node.directory': 'ディレクトリ',
'node.root_added': 'ディレクトリを追加しました。',
'node.root_remove_confirm': '「{name}」をこのグループから削除しますか?',
'node.root_removed': 'ディレクトリを削除しました。インデックスを更新するために再起動を推奨します。',
- 'node.upload_root': 'アップロード',
'node.attach_group': 'グループを追加',
'node.attach_pick': 'ホストするグループ',
'node.attach_dir': '共有ディレクトリ',
@@ -799,7 +817,41 @@ export default {
'settings_node.photo_roots_add': '追加',
'settings_node.photo_roots_remove': '削除',
'settings_node.photo_roots_save': '保存',
- 'settings_node.directories_title': 'ディレクトリ',
+ 'settings_node.shared_directories_title': '共有ディレクトリ',
+ 'settings_node.shared_directories_hint': 'このグループと共有されているフォルダー。読み書きを切り替えてアップロードを許可し、外付けドライブにはリムーバブルを設定します。',
+ 'folder_tree.title_single': 'フォルダーを選択',
+ 'folder_tree.title_multi': 'フォルダーを選択',
+ 'folder_tree.choose': '選択…',
+ 'folder_tree.remove': '外す',
+ 'folder_tree.add': 'フォルダーを追加…',
+ 'folder_tree.confirm': '決定',
+ 'folder_tree.expand': '展開',
+ 'folder_tree.collapse': '折りたたむ',
+ 'folder_tree.nothing_selected': '未選択',
+ 'folder_tree.empty': 'このグループにはまだ共有ディレクトリがありません。',
+ 'folder_tree.writable_only': 'ここには読み書き可能なディレクトリしか選べません — ファイルが書き込まれます。',
+ 'folder_tree.no_writable_root': 'このグループには書き込み可能なディレクトリがありません。まず「共有ディレクトリ」で読み書きを有効にしてください。',
+ 'folder_tree.read_only_blocked': '読み取り専用 — 書き込みできません',
+ 'settings_app.save': '保存',
+ 'settings_app.saving': '保存中…',
+ 'settings_app.disabled_hint': 'このアプリを有効にすると設定できます。',
+ 'settings_app.video_directories_label': '動画フォルダー',
+ 'settings_app.video_directories_hint': 'このグループの映画や番組がある場所です。それ以外は動画タブに表示されません。',
+ 'settings_app.music_directories_label': '音楽フォルダー',
+ 'settings_app.music_directories_hint': 'このグループのアルバムがある場所です。それ以外は音楽タブに表示されません。',
+ 'settings_app.photo_directories_label': '写真フォルダー',
+ 'settings_app.photo_directories_hint': 'このグループのアルバムがある場所です。それ以外は写真タブに表示されません。',
+ 'settings_app.chat_directory_label': '添付ファイルのフォルダー',
+ 'settings_app.chat_directory_hint': 'チャットで送られたファイルの書き込み先です。読み書き可能なディレクトリである必要があります。',
+ 'settings_app.chat_no_writable_root': 'このグループには書き込み可能なディレクトリがないため、添付は無効です。',
+ 'settings_app.chat_link_preview_label': 'リンクのプレビュー',
+ 'settings_app.chat_link_preview_hint': 'メンバーがリンクを投稿すると、ノードがページのタイトルと画像を取得します。これは他人が選んだサイトへの、あなたのマシンからのリクエストです。',
+ 'settings_app.tmdb_token_prompt': 'TMDB に登録して、自分の API キーを発行してください。',
+ 'settings_app.tmdb_token_link': 'キーを取得',
+ 'settings_node.roots_offline_hint': 'ノードに接続していません — 変更はローカルノード経由で行われ、次回の再読み込みで反映されます。',
+ 'settings_node.roots_node_too_old': 'このノードはこのページより古く、ディレクトリの表示はできますがここでの変更はできません。更新するか、meshbay-node root コマンドを使ってください。',
+ 'settings_node.app_node_too_old': 'このノードはこのページより古く、このアプリの設定をまだ持っていません。ここで設定するには更新してください。',
+ 'settings_node.directories_title': 'アプリのディレクトリ',
'settings_node.directories_hint': '共有フォルダと、動画・音楽・写真の各アプリがそれぞれの起点として使用するフォルダです。',
// Wizard
@@ -903,4 +955,7 @@ export default {
'node.tab_peers': 'Peers',
'node.tab_audit': 'Audit',
'node.tab_settings': 'Settings',
+ 'node.root_rw': '読み書き',
+ 'node.root_ro': '読み取り専用',
+ 'node.removable': 'リムーバブル',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 711a22f..6e6842e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -126,6 +126,12 @@ export default {
'group.tab_video': "Video's",
'group.tab_music': 'Muziek',
'group.tab_photos': "Foto's",
+ 'group.tab_helloworld': 'HelloWorld',
+ 'helloworld.greeting': 'Hallo, wereld!',
+ 'helloworld.no_directory': 'Nog geen map gekozen. Kies er een bij Instellingen.',
+ 'helloworld.counted': '{n} bestand(en)',
+ 'settings_app.helloworld_directory_label': 'Map',
+ 'settings_app.helloworld_directory_hint': 'De map die deze referentie-app toont. Hij bestaat om te laten zien dat een app toevoegen niets meer vergt dan zijn eigen twee bestanden.',
'group.tab_members': 'Leden',
'group.tab_settings': "Instellingen",
'members.danger_leave_hint': "U verliest de toegang tot de bestanden en de chat van deze groep.",
@@ -154,7 +160,8 @@ export default {
+ 'die deze groep host.',
'group.upload': 'Uploaden',
'group.mkdir': 'Nieuwe map',
- 'group.mkdir_prompt': 'Naam van de nieuwe map:',
+ 'group.mkdir_prompt': 'Naam van de nieuwe map',
+ 'group.mkdir_offline': 'Niet verbonden met de node.',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
'device.add_btn': 'Get a linking code',
@@ -169,6 +176,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(niet beschikbaar — de schijf is losgekoppeld)',
+ 'group.root_plug': 'Aansluiten',
+ 'group.root_eject': 'Uitwerpen',
+ 'group.root_writable': 'Lezen/schrijven',
+ 'group.root_ejected': '(uitgeworpen)',
'group.view': 'Bekijken',
'group.delete': 'Verwijderen',
'group.delete_confirm': '{name} verwijderen?',
@@ -195,6 +206,7 @@ export default {
'chat.placeholder': 'Typ een bericht...',
'chat.send': 'Versturen',
'chat.attach': 'Bestand bijvoegen',
+ 'chat.attach_read_only': 'Geen beschrijfbare gedeelde map — bijlagen staan uit',
// Video player
'video.loading': '{name} wordt geladen...',
@@ -728,10 +740,16 @@ export default {
'node.roots': 'Mappen',
'node.add_root': 'Map toevoegen',
'node.remove_root': 'Verwijderen',
+ 'node.root_no_signing_key': 'Geen ondertekeningssleutel beschikbaar — koppel dit apparaat eerst aan de node',
+ 'node.root_no_route': 'Geen route naar de node — maak verbinding, of gebruik de app op de machine die hem host',
+ 'node.root_remove_last': 'Een groep heeft minstens één map nodig',
+ 'node.root_path_hint': 'Het pad zoals de node het ziet, op de machine die deze groep host.',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'Pad',
+ 'node.directory': 'Map',
'node.root_added': 'Map toegevoegd.',
'node.root_remove_confirm': '„{name}" uit deze groep verwijderen?',
'node.root_removed': 'Map verwijderd. Herstart aanbevolen om de index bij te werken.',
- 'node.upload_root': 'uploads',
'node.attach_group': 'Groep toevoegen',
'node.attach_pick': 'Groep om te hosten',
'node.attach_dir': 'Gedeelde map',
@@ -817,7 +835,41 @@ export default {
'settings_node.photo_roots_add': 'Toevoegen',
'settings_node.photo_roots_remove': 'Verwijderen',
'settings_node.photo_roots_save': 'Opslaan',
- 'settings_node.directories_title': 'Mappen',
+ 'settings_node.shared_directories_title': 'Gedeelde mappen',
+ 'settings_node.shared_directories_hint': 'Mappen gedeeld met deze groep. Schakel lezen-schrijven in om uploads toe te staan, markeer als verwijderbaar voor externe schijven.',
+ 'folder_tree.title_single': 'Kies een map',
+ 'folder_tree.title_multi': 'Kies mappen',
+ 'folder_tree.choose': 'Kiezen…',
+ 'folder_tree.remove': 'Verwijderen',
+ 'folder_tree.add': 'Map toevoegen…',
+ 'folder_tree.confirm': 'Gebruiken',
+ 'folder_tree.expand': 'Uitklappen',
+ 'folder_tree.collapse': 'Inklappen',
+ 'folder_tree.nothing_selected': 'Niets geselecteerd',
+ 'folder_tree.empty': 'Deze groep heeft nog geen gedeelde mappen.',
+ 'folder_tree.writable_only': 'Hier zijn alleen lees-schrijfmappen te kiezen — er wordt in geschreven.',
+ 'folder_tree.no_writable_root': 'Deze groep heeft geen beschrijfbare map. Zet er eerst één op lezen-schrijven bij Gedeelde mappen.',
+ 'folder_tree.read_only_blocked': 'Alleen-lezen — kan niet worden beschreven',
+ 'settings_app.save': 'Opslaan',
+ 'settings_app.saving': 'Opslaan…',
+ 'settings_app.disabled_hint': 'Zet deze app aan om hem in te stellen.',
+ 'settings_app.video_directories_label': 'Videomappen',
+ 'settings_app.video_directories_hint': 'Waar de films en series van deze groep staan. Niets daarbuiten verschijnt op het tabblad Video\'s.',
+ 'settings_app.music_directories_label': 'Muziekmappen',
+ 'settings_app.music_directories_hint': 'Waar de albums van deze groep staan. Niets daarbuiten verschijnt op het tabblad Muziek.',
+ 'settings_app.photo_directories_label': 'Fotomappen',
+ 'settings_app.photo_directories_hint': 'Waar de albums van deze groep staan. Niets daarbuiten verschijnt op het tabblad Foto\'s.',
+ 'settings_app.chat_directory_label': 'Map voor bijlagen',
+ 'settings_app.chat_directory_hint': 'Waar in de chat verstuurde bestanden worden geschreven. Moet een lees-schrijfmap zijn.',
+ 'settings_app.chat_no_writable_root': 'Deze groep heeft geen beschrijfbare map, dus bijlagen staan uit.',
+ 'settings_app.chat_link_preview_label': 'Linkvoorbeelden',
+ 'settings_app.chat_link_preview_hint': 'Als een lid een link plaatst, haalt de node de titel en afbeelding van de pagina op. Dat is een verzoek vanaf uw machine naar een site die iemand anders koos.',
+ 'settings_app.tmdb_token_prompt': 'Meld u aan bij TMDB om uw eigen API-sleutel te maken.',
+ 'settings_app.tmdb_token_link': 'Sleutel ophalen',
+ 'settings_node.roots_offline_hint': 'Niet verbonden met de node — wijzigingen gaan via de lokale node en worden bij de volgende herlaadbeurt actief.',
+ 'settings_node.roots_node_too_old': 'Deze node is ouder dan deze pagina: hij kan zijn mappen tonen maar hier niet wijzigen. Werk hem bij, of gebruik de meshbay-node root-opdrachten.',
+ 'settings_node.app_node_too_old': 'Deze node is ouder dan deze pagina en heeft nog geen instelling voor deze app. Werk hem bij om die hier in te stellen.',
+ 'settings_node.directories_title': 'App-mappen',
'settings_node.directories_hint': 'Gedeelde mappen, en welke daarvan de Video\'s-, Muziek- en Foto\'s-apps als eigen startpunt(en) gebruiken.',
// Create group wizard
@@ -921,4 +973,7 @@ export default {
'node.tab_peers': 'Peers',
'node.tab_audit': 'Audit',
'node.tab_settings': 'Settings',
+ 'node.root_rw': 'lezen-schrijven',
+ 'node.root_ro': 'alleen-lezen',
+ 'node.removable': 'verwijderbaar',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 67615a2..c8cbd16 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -130,6 +130,12 @@ export default {
'group.tab_video': 'Wideo',
'group.tab_music': 'Muzyka',
'group.tab_photos': 'Zdjęcia',
+ 'group.tab_helloworld': 'HelloWorld',
+ 'helloworld.greeting': 'Witaj, świecie!',
+ 'helloworld.no_directory': 'Nie wybrano folderu. Wybierz go w ustawieniach.',
+ 'helloworld.counted': 'Plików: {n}',
+ 'settings_app.helloworld_directory_label': 'Folder',
+ 'settings_app.helloworld_directory_hint': 'Folder wypisywany przez tę aplikację referencyjną. Istnieje, aby pokazać, że dodanie aplikacji nie wymaga niczego poza jej dwoma plikami.',
'group.tab_members': 'Członkowie',
'group.tab_settings': "Ustawienia",
'members.danger_leave_hint': "Utracisz dostęp do plików i czatu tej grupy.",
@@ -157,7 +163,8 @@ export default {
'group.offline_hint': 'Pliki pojawią się, gdy połączy się node hostujący tę grupę.',
'group.upload': 'Wyślij',
'group.mkdir': 'Nowy folder',
- 'group.mkdir_prompt': 'Nazwa nowego folderu:',
+ 'group.mkdir_prompt': 'Nazwa nowego folderu',
+ 'group.mkdir_offline': 'Brak połączenia z węzłem.',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
'device.add_btn': 'Get a linking code',
@@ -172,6 +179,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(niedostępne — dysk jest odłączony)',
+ 'group.root_plug': 'Podłącz',
+ 'group.root_eject': 'Odłącz',
+ 'group.root_writable': 'Odczyt/zapis',
+ 'group.root_ejected': '(odłączony)',
'group.view': 'Podgląd',
'group.delete': 'Usuń',
'group.delete_confirm': 'Usunąć {name}?',
@@ -200,6 +211,7 @@ export default {
'chat.placeholder': 'Napisz wiadomość...',
'chat.send': 'Wyślij',
'chat.attach': 'Załącz plik',
+ 'chat.attach_read_only': 'Brak zapisywalnego katalogu współdzielonego — załączniki wyłączone',
// Video player
'video.loading': 'Wczytywanie {name}...',
@@ -750,10 +762,16 @@ export default {
'node.roots': 'Katalogi',
'node.add_root': 'Dodaj katalog',
'node.remove_root': 'Usuń',
+ 'node.root_no_signing_key': 'Brak klucza podpisu — najpierw sparuj to urządzenie z węzłem',
+ 'node.root_no_route': 'Brak połączenia z węzłem — połącz się z nim albo użyj aplikacji na komputerze, który go hostuje',
+ 'node.root_remove_last': 'Grupa wymaga co najmniej jednego katalogu',
+ 'node.root_path_hint': 'Ścieżka widziana przez węzeł, na komputerze hostującym tę grupę.',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'Ścieżka',
+ 'node.directory': 'Katalog',
'node.root_added': 'Katalog dodany.',
'node.root_remove_confirm': 'Usunąć „{name}" z tej grupy?',
'node.root_removed': 'Katalog usunięty. Zalecany restart w celu odświeżenia indeksu.',
- 'node.upload_root': 'przesyłanie',
'node.attach_group': 'Dodaj grupę',
'node.attach_pick': 'Grupa do hostowania',
'node.attach_dir': 'Katalog współdzielony',
@@ -843,7 +861,41 @@ export default {
'settings_node.photo_roots_add': 'Dodaj',
'settings_node.photo_roots_remove': 'Usuń',
'settings_node.photo_roots_save': 'Zapisz',
- 'settings_node.directories_title': 'Katalogi',
+ 'settings_node.shared_directories_title': 'Katalogi udostępnione',
+ 'settings_node.shared_directories_hint': 'Foldery udostępnione tej grupie. Przełącz odczyt-zapis, aby zezwolić na przesyłanie, oznacz jako wymienny dla dysków zewnętrznych.',
+ 'folder_tree.title_single': 'Wybierz folder',
+ 'folder_tree.title_multi': 'Wybierz foldery',
+ 'folder_tree.choose': 'Wybierz…',
+ 'folder_tree.remove': 'Usuń',
+ 'folder_tree.add': 'Dodaj folder…',
+ 'folder_tree.confirm': 'Użyj',
+ 'folder_tree.expand': 'Rozwiń',
+ 'folder_tree.collapse': 'Zwiń',
+ 'folder_tree.nothing_selected': 'Nic nie wybrano',
+ 'folder_tree.empty': 'Ta grupa nie ma jeszcze katalogów współdzielonych.',
+ 'folder_tree.writable_only': 'Tutaj można wybrać tylko katalogi do odczytu i zapisu — zapisywane są w nim pliki.',
+ 'folder_tree.no_writable_root': 'Ta grupa nie ma katalogu do zapisu. Najpierw włącz odczyt i zapis w Katalogach współdzielonych.',
+ 'folder_tree.read_only_blocked': 'Tylko do odczytu — nie można zapisywać',
+ 'settings_app.save': 'Zapisz',
+ 'settings_app.saving': 'Zapisywanie…',
+ 'settings_app.disabled_hint': 'Włącz tę aplikację, aby ją skonfigurować.',
+ 'settings_app.video_directories_label': 'Foldery wideo',
+ 'settings_app.video_directories_hint': 'Gdzie znajdują się filmy i seriale tej grupy. Nic poza nimi nie pojawi się w zakładce Wideo.',
+ 'settings_app.music_directories_label': 'Foldery muzyki',
+ 'settings_app.music_directories_hint': 'Gdzie znajdują się albumy tej grupy. Nic poza nimi nie pojawi się w zakładce Muzyka.',
+ 'settings_app.photo_directories_label': 'Foldery zdjęć',
+ 'settings_app.photo_directories_hint': 'Gdzie znajdują się albumy tej grupy. Nic poza nimi nie pojawi się w zakładce Zdjęcia.',
+ 'settings_app.chat_directory_label': 'Folder załączników',
+ 'settings_app.chat_directory_hint': 'Gdzie zapisywane są pliki wysłane na czacie. Musi to być katalog do odczytu i zapisu.',
+ 'settings_app.chat_no_writable_root': 'Ta grupa nie ma katalogu do zapisu, więc załączniki są wyłączone.',
+ 'settings_app.chat_link_preview_label': 'Podglądy linków',
+ 'settings_app.chat_link_preview_hint': 'Gdy członek wysyła link, węzeł pobiera tytuł i obraz strony. To żądanie z Twojego komputera do witryny wybranej przez kogoś innego.',
+ 'settings_app.tmdb_token_prompt': 'Zarejestruj się w TMDB, aby wygenerować własny klucz API.',
+ 'settings_app.tmdb_token_link': 'Pobierz klucz',
+ 'settings_node.roots_offline_hint': 'Brak połączenia z węzłem — zmiany przechodzą przez węzeł lokalny i zaczną działać po jego następnym przeładowaniu.',
+ 'settings_node.roots_node_too_old': 'Ten węzeł jest starszy niż ta strona: może pokazać swoje katalogi, ale nie zmieni ich tutaj. Zaktualizuj go albo użyj poleceń meshbay-node root.',
+ 'settings_node.app_node_too_old': 'Ten węzeł jest starszy niż ta strona i nie ma jeszcze ustawień tej aplikacji. Zaktualizuj go, aby skonfigurować ją tutaj.',
+ 'settings_node.directories_title': 'Katalogi aplikacji',
'settings_node.directories_hint': 'Katalogi udostępnione oraz to, który z nich aplikacje Wideo, Muzyka i Zdjęcia traktują jako własny punkt (punkty) wejścia.',
// Create-group wizard
@@ -947,4 +999,7 @@ export default {
'node.tab_peers': 'Peers',
'node.tab_audit': 'Audit',
'node.tab_settings': 'Settings',
+ 'node.root_rw': 'odczyt-zapis',
+ 'node.root_ro': 'tylko odczyt',
+ 'node.removable': 'wymienny',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 2d07994..6eb2242 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -126,6 +126,12 @@ export default {
'group.tab_video': 'Vídeos',
'group.tab_music': 'Música',
'group.tab_photos': 'Fotos',
+ 'group.tab_helloworld': 'HelloWorld',
+ 'helloworld.greeting': 'Olá, mundo!',
+ 'helloworld.no_directory': 'Nenhuma pasta escolhida. Escolha uma nas configurações.',
+ 'helloworld.counted': '{n} arquivo(s)',
+ 'settings_app.helloworld_directory_label': 'Pasta',
+ 'settings_app.helloworld_directory_hint': 'A pasta que este aplicativo de referência lista. Existe para mostrar que adicionar um aplicativo não exige nada além de seus dois arquivos.',
'group.tab_members': 'Membros',
'group.tab_settings': "Configurações",
'members.danger_leave_hint': "Você perderá o acesso aos arquivos e ao chat deste grupo.",
@@ -154,7 +160,8 @@ export default {
+ 'se conectar.',
'group.upload': 'Enviar',
'group.mkdir': 'Nova pasta',
- 'group.mkdir_prompt': 'Nome da nova pasta:',
+ 'group.mkdir_prompt': 'Nome da nova pasta',
+ 'group.mkdir_offline': 'Sem conexão com o nó.',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
'device.add_btn': 'Get a linking code',
@@ -169,6 +176,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(indisponível — a unidade está desconectada)',
+ 'group.root_plug': 'Conectar',
+ 'group.root_eject': 'Ejetar',
+ 'group.root_writable': 'Leitura/Escrita',
+ 'group.root_ejected': '(ejetado)',
'group.view': 'Visualizar',
'group.delete': 'Excluir',
'group.delete_confirm': 'Excluir {name}?',
@@ -195,6 +206,7 @@ export default {
'chat.placeholder': 'Escreva uma mensagem...',
'chat.send': 'Enviar',
'chat.attach': 'Anexar arquivo',
+ 'chat.attach_read_only': 'Nenhum diretório compartilhado gravável — anexos desativados',
// Video player
'video.loading': 'Carregando {name}...',
@@ -719,10 +731,16 @@ export default {
'node.roots': 'Diretórios',
'node.add_root': 'Adicionar diretório',
'node.remove_root': 'Remover',
+ 'node.root_no_signing_key': 'Nenhuma chave de assinatura disponível — pareie este dispositivo com o nó primeiro',
+ 'node.root_no_route': 'Sem rota até o nó — conecte-se a ele ou use o aplicativo na máquina que o hospeda',
+ 'node.root_remove_last': 'Um grupo precisa de pelo menos um diretório',
+ 'node.root_path_hint': 'O caminho como o nó o vê, na máquina que hospeda este grupo.',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'Caminho',
+ 'node.directory': 'Diretório',
'node.root_added': 'Diretório adicionado.',
'node.root_remove_confirm': 'Remover "{name}" deste grupo?',
'node.root_removed': 'Diretório removido. Reinicialização recomendada para atualizar o índice.',
- 'node.upload_root': 'uploads',
'node.attach_group': 'Adicionar grupo',
'node.attach_pick': 'Grupo a hospedar',
'node.attach_dir': 'Diretório compartilhado',
@@ -802,7 +820,41 @@ export default {
'settings_node.photo_roots_add': 'Adicionar',
'settings_node.photo_roots_remove': 'Remover',
'settings_node.photo_roots_save': 'Salvar',
- 'settings_node.directories_title': 'Diretórios',
+ 'settings_node.shared_directories_title': 'Diretórios compartilhados',
+ 'settings_node.shared_directories_hint': 'Pastas compartilhadas com este grupo. Alterne leitura-escrita para permitir uploads, marque como removível para unidades externas.',
+ 'folder_tree.title_single': 'Escolher uma pasta',
+ 'folder_tree.title_multi': 'Escolher pastas',
+ 'folder_tree.choose': 'Escolher…',
+ 'folder_tree.remove': 'Remover',
+ 'folder_tree.add': 'Adicionar uma pasta…',
+ 'folder_tree.confirm': 'Usar',
+ 'folder_tree.expand': 'Expandir',
+ 'folder_tree.collapse': 'Recolher',
+ 'folder_tree.nothing_selected': 'Nada selecionado',
+ 'folder_tree.empty': 'Este grupo ainda não tem diretórios compartilhados.',
+ 'folder_tree.writable_only': 'Aqui só é possível escolher diretórios de leitura e escrita — arquivos são gravados nele.',
+ 'folder_tree.no_writable_root': 'Este grupo não tem diretório gravável. Ative leitura e escrita em Diretórios compartilhados primeiro.',
+ 'folder_tree.read_only_blocked': 'Somente leitura — não é possível gravar',
+ 'settings_app.save': 'Salvar',
+ 'settings_app.saving': 'Salvando…',
+ 'settings_app.disabled_hint': 'Ative este aplicativo para configurá-lo.',
+ 'settings_app.video_directories_label': 'Pastas de vídeo',
+ 'settings_app.video_directories_hint': 'Onde ficam os filmes e séries deste grupo. Nada fora delas aparece na aba Vídeos.',
+ 'settings_app.music_directories_label': 'Pastas de música',
+ 'settings_app.music_directories_hint': 'Onde ficam os álbuns deste grupo. Nada fora delas aparece na aba Música.',
+ 'settings_app.photo_directories_label': 'Pastas de fotos',
+ 'settings_app.photo_directories_hint': 'Onde ficam os álbuns deste grupo. Nada fora delas aparece na aba Fotos.',
+ 'settings_app.chat_directory_label': 'Pasta de anexos',
+ 'settings_app.chat_directory_hint': 'Onde os arquivos enviados no chat são gravados. Precisa ser um diretório de leitura e escrita.',
+ 'settings_app.chat_no_writable_root': 'Este grupo não tem diretório gravável, então os anexos estão desativados.',
+ 'settings_app.chat_link_preview_label': 'Prévias de links',
+ 'settings_app.chat_link_preview_hint': 'Quando alguém publica um link, o nó busca o título e a imagem da página. É uma requisição da sua máquina para um site escolhido por outra pessoa.',
+ 'settings_app.tmdb_token_prompt': 'Cadastre-se no TMDB para gerar sua própria chave de API.',
+ 'settings_app.tmdb_token_link': 'Obter uma chave',
+ 'settings_node.roots_offline_hint': 'Sem conexão com o nó — as alterações passam pelo nó local e entram em vigor no próximo recarregamento.',
+ 'settings_node.roots_node_too_old': 'Este nó é mais antigo que esta página: ele pode mostrar seus diretórios, mas não alterá-los aqui. Atualize-o ou use os comandos meshbay-node root.',
+ 'settings_node.app_node_too_old': 'Este nó é mais antigo que esta página e ainda não tem configuração para este aplicativo. Atualize-o para configurá-lo aqui.',
+ 'settings_node.directories_title': 'Diretórios de apps',
'settings_node.directories_hint': 'Pastas compartilhadas, e qual delas os apps Vídeos, Música e Fotos tratam como seu(s) próprio(s) ponto(s) de entrada.',
// Create group wizard
@@ -906,4 +958,7 @@ export default {
'node.tab_peers': 'Peers',
'node.tab_audit': 'Audit',
'node.tab_settings': 'Settings',
+ 'node.root_rw': 'leitura-escrita',
+ 'node.root_ro': 'somente leitura',
+ 'node.removable': 'removível',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index fd0c7f9..044611e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -123,6 +123,12 @@ export default {
'group.tab_video': '视频',
'group.tab_music': '音乐',
'group.tab_photos': '照片',
+ 'group.tab_helloworld': 'HelloWorld',
+ 'helloworld.greeting': '你好,世界!',
+ 'helloworld.no_directory': '尚未选择文件夹。请在设置中选择。',
+ 'helloworld.counted': '{n} 个文件',
+ 'settings_app.helloworld_directory_label': '文件夹',
+ 'settings_app.helloworld_directory_hint': '该参考应用所列出的文件夹。它的存在是为了说明:新增一个应用只需要它自己的两个文件。',
'group.tab_members': '成员',
'group.tab_settings': "设置",
'members.danger_leave_hint': "您将无法再访问该群组的文件和聊天。",
@@ -150,7 +156,8 @@ export default {
'group.offline_hint': '当托管此群组的 node 连接后,文件就会出现。',
'group.upload': '上传',
'group.mkdir': '新建文件夹',
- 'group.mkdir_prompt': '新文件夹的名称:',
+ 'group.mkdir_prompt': '新文件夹名称',
+ 'group.mkdir_offline': '未连接到节点。',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
'device.add_btn': 'Get a linking code',
@@ -165,6 +172,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(不可用 — 驱动器已断开连接)',
+ 'group.root_plug': '重新接入',
+ 'group.root_eject': '弹出',
+ 'group.root_writable': '读写',
+ 'group.root_ejected': '(已弹出)',
'group.view': '查看',
'group.delete': '删除',
'group.delete_confirm': '删除 {name}?',
@@ -189,6 +200,7 @@ export default {
'chat.placeholder': '输入消息…',
'chat.send': '发送',
'chat.attach': '添加附件',
+ 'chat.attach_read_only': '没有可写的共享目录 — 附件已停用',
// Video player
'video.loading': '正在加载 {name}…',
@@ -699,10 +711,16 @@ export default {
'node.roots': '目录',
'node.add_root': '添加目录',
'node.remove_root': '移除',
+ 'node.root_no_signing_key': '没有可用的签名密钥 — 请先将本设备与节点配对',
+ 'node.root_no_route': '无法连接到节点 — 请先连接,或在运行该节点的机器上使用应用',
+ 'node.root_remove_last': '每个群组至少需要一个目录',
+ 'node.root_path_hint': '托管该群组的机器上,节点所看到的路径。',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': '路径',
+ 'node.directory': '目录',
'node.root_added': '目录已添加。',
'node.root_remove_confirm': '从此群组中移除"{name}"?',
'node.root_removed': '目录已移除。建议重启以更新索引。',
- 'node.upload_root': '上传目录',
'node.attach_group': '添加群组',
'node.attach_pick': '要托管的群组',
'node.attach_dir': '共享目录',
@@ -786,7 +804,41 @@ export default {
'settings_node.photo_roots_add': '添加',
'settings_node.photo_roots_remove': '移除',
'settings_node.photo_roots_save': '保存',
- 'settings_node.directories_title': '目录',
+ 'settings_node.shared_directories_title': '共享目录',
+ 'settings_node.shared_directories_hint': '与此群组共享的文件夹。切换读写以允许上传,标记为可移除用于外置驱动器。',
+ 'folder_tree.title_single': '选择文件夹',
+ 'folder_tree.title_multi': '选择文件夹',
+ 'folder_tree.choose': '选择…',
+ 'folder_tree.remove': '移除',
+ 'folder_tree.add': '添加文件夹…',
+ 'folder_tree.confirm': '使用',
+ 'folder_tree.expand': '展开',
+ 'folder_tree.collapse': '折叠',
+ 'folder_tree.nothing_selected': '未选择',
+ 'folder_tree.empty': '该群组还没有共享目录。',
+ 'folder_tree.writable_only': '此处只能选择可读写的目录 — 文件会写入其中。',
+ 'folder_tree.no_writable_root': '该群组没有可写目录。请先在「共享目录」中将某个目录设为读写。',
+ 'folder_tree.read_only_blocked': '只读 — 无法写入',
+ 'settings_app.save': '保存',
+ 'settings_app.saving': '正在保存…',
+ 'settings_app.disabled_hint': '启用该应用后即可配置。',
+ 'settings_app.video_directories_label': '视频文件夹',
+ 'settings_app.video_directories_hint': '该群组的影片和剧集所在位置。其外的内容不会出现在「视频」标签页。',
+ 'settings_app.music_directories_label': '音乐文件夹',
+ 'settings_app.music_directories_hint': '该群组的专辑所在位置。其外的内容不会出现在「音乐」标签页。',
+ 'settings_app.photo_directories_label': '照片文件夹',
+ 'settings_app.photo_directories_hint': '该群组的相册所在位置。其外的内容不会出现在「照片」标签页。',
+ 'settings_app.chat_directory_label': '附件文件夹',
+ 'settings_app.chat_directory_hint': '聊天中发送的文件写入位置。必须是可读写的目录。',
+ 'settings_app.chat_no_writable_root': '该群组没有可写目录,因此附件已停用。',
+ 'settings_app.chat_link_preview_label': '链接预览',
+ 'settings_app.chat_link_preview_hint': '当成员发布链接时,节点会抓取该页面的标题和图片。这是从你的机器发往他人所选站点的请求。',
+ 'settings_app.tmdb_token_prompt': '在 TMDB 注册以生成你自己的 API 密钥。',
+ 'settings_app.tmdb_token_link': '获取密钥',
+ 'settings_node.roots_offline_hint': '未连接到节点 — 变更将通过本地节点进行,并在其下次重新加载时生效。',
+ 'settings_node.roots_node_too_old': '该节点比本页面旧:它能显示自己的目录,但无法在此更改。请更新节点,或使用 meshbay-node root 命令。',
+ 'settings_node.app_node_too_old': '该节点比本页面旧,尚不支持此应用的设置。请更新节点后在此配置。',
+ 'settings_node.directories_title': '应用目录',
'settings_node.directories_hint': '共享文件夹,以及“视频”“音乐”和“照片”应用各自使用哪个(些)作为入口。',
// Create group wizard
@@ -891,4 +943,7 @@ export default {
'node.tab_peers': 'Peers',
'node.tab_audit': 'Audit',
'node.tab_settings': 'Settings',
+ 'node.root_rw': '读写',
+ 'node.root_ro': '只读',
+ 'node.removable': '可移除',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js
new file mode 100644
index 0000000..8ce2ab6
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js
@@ -0,0 +1,60 @@
+import { html, useState, useEffect } from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { ToggleSwitch, useSaver } from './settings-ui.js';
+import { FolderPickerField } from './folder-tree.js';
+
+/**
+ * The Music app's operator settings.
+ *
+ * Same shape as Videos, minus a credential: MusicBrainz's read endpoints need
+ * no API key, only a descriptive User-Agent, and that is one operator identity
+ * held node-wide rather than a per-group setting (docs/musicbay.md §3.2).
+ *
+ * Several folders, for the same reason Videos has several: a music library
+ * that lives on two drives had no way to say so.
+ */
+function MusicSettings({ roots, dirs, settings, saveDirectories, transport, signFn }) {
+ const { busy, msg, run } = useSaver();
+ const [directories, setDirectories] = useState(settings.musicDirectories || []);
+ const [mbEnabled, setMbEnabled] = useState(settings.musicbrainzEnabled !== false);
+
+ useEffect(() => {
+ setDirectories(settings.musicDirectories || []);
+ }, [settings.musicDirectories]);
+ useEffect(() => {
+ setMbEnabled(settings.musicbrainzEnabled !== false);
+ }, [settings.musicbrainzEnabled]);
+
+ const current = settings.musicDirectories || [];
+ const dirsDirty = directories.length !== current.length
+ || directories.some((d, i) => d !== current[i]);
+
+ return html`
+ <div class="app-settings">
+ <${FolderPickerField}
+ label=${t('settings_app.music_directories_label')}
+ hint=${t('settings_app.music_directories_hint')}
+ roots=${roots} dirs=${dirs} mode="multi"
+ value=${directories} disabled=${busy}
+ onChange=${setDirectories} />
+
+ <button class="app-save" disabled=${busy || !dirsDirty}
+ onClick=${() => run(() => saveDirectories(directories))}>
+ ${busy ? t('settings_app.saving') : t('settings_app.save')}
+ </button>
+
+ <h4 class="app-settings-sub">${t('settings_node.musicbrainz_title')}</h4>
+ <p class="settings-hint">${t('settings_node.musicbrainz_hint')}</p>
+ <div class="settings-row">
+ <${ToggleSwitch} checked=${mbEnabled} disabled=${busy}
+ onChange=${(v) => { setMbEnabled(v);
+ run(() => transport.setMusicbrainzEnabled(v, signFn)); }}
+ label=${mbEnabled ? t('settings_node.musicbrainz_enabled')
+ : t('settings_node.musicbrainz_disabled')} />
+ </div>
+ ${msg && html`<p class="settings-hint">${msg}</p>`}
+ </div>
+ `;
+}
+
+export { MusicSettings };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
index 616f169..f622b5d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
@@ -52,19 +52,20 @@ function foldKey(s) {
// tag/cover enrichment for this group before a root is chosen either,
// daemon.py's _enrich_new_audio_entries), not "the whole shared tree" —
// falling back to that would just show files nothing has enriched.
-function underAudioRoot(entry, audioRoot) {
- if (!audioRoot) return false;
+function underAudioRoot(entry, directories) {
+ const dirs = directories || [];
+ if (!dirs.length) return false;
const p = entry.path || '';
- return p === audioRoot || p.startsWith(audioRoot + '/');
+ return dirs.some((d) => p === d || p.startsWith(d + '/'));
}
-function groupMusicEntries(entries, audioRoot) {
+function groupMusicEntries(entries, musicDirectories) {
const tracks = []; // no artist at all, even after the folder fallback -- rare, but real
const byArtistKey = new Map(); // foldKey(artist) -> { artist, albumsByKey: Map, loose: [] }
for (const e of entries) {
if (e.type !== 'audio') continue;
- if (!underAudioRoot(e, audioRoot)) continue;
+ if (!underAudioRoot(e, musicDirectories)) continue;
const artistRaw = (e.artist || '').trim();
if (!artistRaw) { tracks.push(e); continue; }
const artistKey = foldKey(artistRaw);
@@ -453,7 +454,8 @@ function FlatList({ tracks, artists, onPlayQueue }) {
// -- shell --------------------------------------------------------------------
function MusicApp({
- groupId, transportRef, gekRef, status, entries, audioRoot, musicbrainzConfig, onPlayQueue,
+ groupId, transportRef, gekRef, status, entries, availableEntries,
+ musicDirectories, musicbrainzConfig, onPlayQueue,
hideFilter,
}) {
const [mode, setMode] = useState(loadViewMode);
@@ -465,8 +467,11 @@ function MusicApp({
const setModeAndSave = (m) => { setMode(m); saveViewMode(m); };
+ const musicEntries = availableEntries || entries;
+ const configured = (musicDirectories || []).length > 0;
const { tracks, artists, albums } = useMemo(
- () => groupMusicEntries(entries, audioRoot), [entries, audioRoot]);
+ () => groupMusicEntries(musicEntries, musicDirectories),
+ [musicEntries, musicDirectories]);
const needle = filter.trim().toLowerCase();
const filteredArtists = useMemo(() => {
@@ -492,10 +497,10 @@ function MusicApp({
${status === 'offline' && html`
<p class="page-message">${t('group.offline_title')} ${t('group.offline_hint')}</p>
`}
- ${status === 'connected' && !audioRoot && html`
+ ${status === 'connected' && !configured && html`
<p class="page-message">${t('music.no_root_configured')}</p>
`}
- ${status === 'connected' && audioRoot && html`
+ ${status === 'connected' && configured && html`
<div class="video-toolbar">
<button class="tb-btn ${mode === 'grid' ? 'active' : ''}"
onClick=${() => setModeAndSave('grid')}>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
index ec73128..2e216a9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
@@ -750,13 +750,21 @@ export function NodePage({ groups }) {
<${Icon} name="folder" />
${r.name}
</span>
- ${r.upload && html`
- <span class="node-root-badge">${t('node.upload_root')}</span>`}
- ${!r.available && html`
+ ${r.writable && html`
+ <span class="node-root-badge">${t('node.root_rw')}</span>`}
+ ${r.removable && html`
+ <span class="node-root-badge">${t('node.removable')}</span>`}
+ ${r.ejected ? html`
+ <span class="node-root-badge node-root-badge-warn">
+ ${t('group.root_ejected')}</span>`
+ : !r.available && html`
<span class="node-root-badge node-root-badge-warn">
${t('node.unavailable')}</span>`}
</div>
- ${(g.roots || []).length > 1 && !r.upload && html`
+ ${/* Removing a writable root is allowed now — several can be
+ writable, and a group with none is a valid read-only
+ group. The last root is still the one that cannot go. */''}
+ ${(g.roots || []).length > 1 && html`
<button class="btn btn-small btn-danger"
disabled=${busy} onClick=${() => removeRoot(g.id, r.name)}>
${t('node.remove_root')}</button>`}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js
new file mode 100644
index 0000000..8656fa5
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js
@@ -0,0 +1,44 @@
+import { html, useState, useEffect } from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { useSaver } from './settings-ui.js';
+import { FolderPickerField } from './folder-tree.js';
+
+/**
+ * The Photos app's operator settings: folders, and nothing else.
+ *
+ * Photos was always several folders (docs/photos.md §2.1) — a photo library
+ * is routinely scattered, with no single natural root — so this pane is what
+ * the other two grew into rather than the exception it used to be. No
+ * third-party service: EXIF is read locally on the node, and nothing about a
+ * photo leaves the machine to be identified.
+ */
+function PhotoSettings({ roots, dirs, settings, saveDirectories }) {
+ const { busy, msg, run } = useSaver();
+ const [directories, setDirectories] = useState(settings.photoDirectories || []);
+ useEffect(() => {
+ setDirectories(settings.photoDirectories || []);
+ }, [settings.photoDirectories]);
+
+ const current = settings.photoDirectories || [];
+ const dirty = directories.length !== current.length
+ || directories.some((d, i) => d !== current[i]);
+
+ return html`
+ <div class="app-settings">
+ <${FolderPickerField}
+ label=${t('settings_app.photo_directories_label')}
+ hint=${t('settings_app.photo_directories_hint')}
+ roots=${roots} dirs=${dirs} mode="multi"
+ value=${directories} disabled=${busy}
+ onChange=${setDirectories} />
+
+ <button class="app-save" disabled=${busy || !dirty}
+ onClick=${() => run(() => saveDirectories(directories))}>
+ ${busy ? t('settings_app.saving') : t('settings_app.save')}
+ </button>
+ ${msg && html`<p class="settings-hint">${msg}</p>`}
+ </div>
+ `;
+}
+
+export { PhotoSettings };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
index e6f3482..a58af8c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
@@ -322,7 +322,8 @@ function AlbumView({ album, entries, transportRef, gekRef, setError, onBack, rea
// ── shell ────────────────────────────────────────────────────────────────────
function PhotosApp({
- groupId, transportRef, gekRef, status, entries, photoRoots, setError,
+ groupId, transportRef, gekRef, status, entries, availableEntries,
+ photoDirectories, setError,
hideFilter, readOnly,
}) {
const [openDir, setOpenDir] = useState(null);
@@ -330,8 +331,10 @@ function PhotosApp({
useEffect(() => { setOpenDir(null); setFilter(''); }, [groupId]);
+ const photoEntries = availableEntries || entries;
const albums = useMemo(
- () => groupPhotoAlbums(entries, photoRoots), [entries, photoRoots]);
+ () => groupPhotoAlbums(photoEntries, photoDirectories),
+ [photoEntries, photoDirectories]);
const needle = filter.trim().toLowerCase();
const filteredAlbums = useMemo(() => (!needle ? albums : albums.filter(
@@ -346,10 +349,10 @@ function PhotosApp({
${status === 'offline' && html`
<p class="page-message">${t('group.offline_title')} ${t('group.offline_hint')}</p>
`}
- ${status === 'connected' && (!photoRoots || photoRoots.length === 0) && html`
+ ${status === 'connected' && (photoDirectories || []).length === 0 && html`
<p class="page-message">${t('photo.no_roots_configured')}</p>
`}
- ${status === 'connected' && photoRoots && photoRoots.length > 0 && !openAlbum && html`
+ ${status === 'connected' && (photoDirectories || []).length > 0 && !openAlbum && html`
<div class="photo-toolbar">
${!hideFilter && html`<div class="tb-search">
<${Icon} name="search" />
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
index 608bb81..4ed3df1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
@@ -147,10 +147,12 @@ async function fetchGroupIndex(groupId, token, bundleKey, username, userId) {
clearTimeout(timer);
const indexMsg = await transport.fetchIndex();
+ // Plural, with the old scalars as the fallback for a node still speaking
+ // MNP 1.0 — the same reading group-page.js does on its own handshake.
const roots = {
- videoRoot: ack.video_root || '',
- audioRoot: ack.audio_root || '',
- photoRoots: ack.photo_roots || [],
+ video: ack.video_directories || (ack.video_root ? [ack.video_root] : []),
+ music: ack.music_directories || (ack.audio_root ? [ack.audio_root] : []),
+ photo: ack.photo_directories || ack.photo_roots || [],
};
// Which of the reader's groups sit on their own node — the tie-breaker
// when the same file is announced by several of them
@@ -201,10 +203,29 @@ async function fetchAllIndexes(groups, token, username, userId, onProgress, onBa
// -- SearchPage ---------------------------------------------------------------
-function underRoot(entry, root) {
- if (!root) return false;
+function underRoot(entry, directories) {
+ const dirs = directories || [];
+ if (!dirs.length) return false;
const p = entry.path || '';
- return p === root || p.startsWith(root + '/');
+ return dirs.some((d) => p === d || p.startsWith(d + '/'));
+}
+
+/**
+ * An app's directories out of a cached group, in either shape.
+ *
+ * The cache lives in IndexedDB and outlives a deploy, so a reader opening
+ * Search after this ships still has entries written by the previous version —
+ * `{videoRoot: 'X'}` where this now writes `{video: ['X']}`. Reading only the
+ * new shape would empty their Videos results with no explanation and no way
+ * to tell it from "nothing matched".
+ */
+function cachedDirs(roots, appKey, legacyKey) {
+ if (!roots) return [];
+ const fresh = roots[appKey];
+ if (Array.isArray(fresh)) return fresh;
+ const legacy = roots[legacyKey];
+ if (Array.isArray(legacy)) return legacy;
+ return legacy ? [legacy] : [];
}
// -- Merging the same file announced by several groups ------------------------
@@ -226,7 +247,7 @@ function underRoot(entry, root) {
// `mergeUnitEntries` folds lists that share a key, so the two copies become
// one unit without this having to group them first.
function videoUnits(entries) {
- const { movies, shows } = groupVideoEntries(entries, SEARCH_VIDEO_ROOT);
+ const { movies, shows } = groupVideoEntries(entries, [SEARCH_VIDEO_ROOT]);
return [
...movies.map((e) => ({ key: `movie:${e.id}`, entries: [e] })),
...shows.map((s) => ({ key: `show:${s.title}`, entries: s.episodes })),
@@ -238,7 +259,7 @@ function videoUnits(entries) {
// object, so the key only has to name it stably — hence `foldKey` over the
// display strings, which are whichever spelling arrived first.
function musicUnits(entries) {
- const { tracks, albums } = groupMusicEntries(entries, SEARCH_AUDIO_ROOT);
+ const { tracks, albums } = groupMusicEntries(entries, [SEARCH_AUDIO_ROOT]);
return [
...albums.map((a) => ({
key: `album:${foldKey(a.artist)}/${foldKey(a.album)}`,
@@ -497,12 +518,12 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
const videoEntries = useMemo(() => {
const result = [];
for (const [groupId, data] of indexedGroups) {
- const root = data.roots.videoRoot;
- if (!root) continue;
+ const dirs = cachedDirs(data.roots, 'video', 'videoRoot');
+ if (!dirs.length) continue;
const conn = groupConns.current.get(groupId);
for (const e of data.entries) {
if (e.type !== 'video') continue;
- if (!underRoot(e, root)) continue;
+ if (!underRoot(e, dirs)) continue;
if (q && !matchesQuery(e)) continue;
result.push({
...e,
@@ -523,12 +544,12 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
const musicEntries = useMemo(() => {
const result = [];
for (const [groupId, data] of indexedGroups) {
- const root = data.roots.audioRoot;
- if (!root) continue;
+ const dirs = cachedDirs(data.roots, 'music', 'audioRoot');
+ if (!dirs.length) continue;
const conn = groupConns.current.get(groupId);
for (const e of data.entries) {
if (e.type !== 'audio') continue;
- if (!underRoot(e, root)) continue;
+ if (!underRoot(e, dirs)) continue;
if (q && !matchesQuery(e)) continue;
result.push({
...e,
@@ -549,13 +570,13 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
const photoEntries = useMemo(() => {
const result = [];
for (const [groupId, data] of indexedGroups) {
- const roots = data.roots.photoRoots;
- if (!roots || !roots.length) continue;
+ const dirs = cachedDirs(data.roots, 'photo', 'photoRoots');
+ if (!dirs.length) continue;
const conn = groupConns.current.get(groupId);
for (const e of data.entries) {
if (e.type !== 'image') continue;
const p = e.path || '';
- if (!roots.some((r) => p === r || p.startsWith(r + '/'))) continue;
+ if (!dirs.some((d) => p === d || p.startsWith(d + '/'))) continue;
if (q && !matchesQuery(e)) continue;
result.push({
...e,
@@ -707,7 +728,6 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
applyIndex=${noop}
isNodeAdmin=${false}
operatorPaired=${false}
- mayUpload=${false}
userId=${userId}
setError=${noop}
onPreview=${onPreview}
@@ -726,7 +746,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
status="connected"
entries=${videoEntries}
onPreview=${onPreview}
- videoRoot=${SEARCH_VIDEO_ROOT}
+ videoDirectories=${[SEARCH_VIDEO_ROOT]}
tmdbConfig=${{ enabled: true }}
isNodeAdmin=${false}
onNeedConn=${connectGroup}
@@ -740,7 +760,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
gekRef=${defaultGRef}
status="connected"
entries=${musicEntries}
- audioRoot=${SEARCH_AUDIO_ROOT}
+ musicDirectories=${[SEARCH_AUDIO_ROOT]}
musicbrainzConfig=${{ enabled: true }}
onPlayQueue=${handleMusicPlay}
hideFilter=${true} />
@@ -753,7 +773,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
gekRef=${defaultGRef}
status="connected"
entries=${photoEntries}
- photoRoots=${SEARCH_PHOTO_ROOTS}
+ photoDirectories=${SEARCH_PHOTO_ROOTS}
setError=${noop}
hideFilter=${true}
readOnly=${true} />
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/settings-ui.js b/packages/meshbay-hub/src/meshbay_hub/static/settings-ui.js
new file mode 100644
index 0000000..9b7a286
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/settings-ui.js
@@ -0,0 +1,92 @@
+import { html, useState, useCallback } from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { Icon } from './icon.js';
+
+/**
+ * The two controls every settings pane is built from.
+ *
+ * Their own module rather than `group-settings.js`, because each app's
+ * settings component now lives in its own file and is reached *through* the
+ * app registry — so importing them from the page that renders them would make
+ * a cycle (`group-settings` → `apps` → `<app>-app-settings` → `group-settings`).
+ * ES modules tolerate that and then hand you a temporal-dead-zone
+ * `ReferenceError` at the first render, which is the same class of defect as
+ * the hook-ordering one already recorded in CLAUDE.md.
+ */
+
+/**
+ * A settings-section that folds — every section but the ones that are
+ * really just a form to fill in (invite, pair-operator, approve-device):
+ * hiding an input the operator is mid-typing-into behind a click they'd
+ * have to undo is friction with nothing to show for it, but a section that
+ * is only ever glanced at once it's configured (TMDB, scan tuning, the
+ * danger zone) benefits from staying out of the way otherwise. `title` (an
+ * already-built string/vnode) wins over `titleKey` when both are given —
+ * the members-table heading needs a live count baked in, not just a
+ * lookup.
+ */
+function CollapsibleSection({ titleKey, title, action, defaultOpen = true, children }) {
+ const [open, setOpen] = useState(defaultOpen);
+ return html`
+ <div class="settings-section">
+ ${/* `action` sits *beside* the header button, never inside it: a
+ <label><input> nested in a <button> is invalid HTML, and the click
+ would reach both — flipping the toggle and collapsing the section
+ it belongs to in the same gesture. */''}
+ <div class="settings-collapsible-bar">
+ <button type="button" class="settings-collapsible-header"
+ onClick=${() => setOpen((v) => !v)} aria-expanded=${open}>
+ <h3 class="settings-heading">${title != null ? title : t(titleKey)}</h3>
+ <${Icon} name="chevron" cls=${open ? 'video-flat-chevron open' : 'video-flat-chevron'} />
+ </button>
+ ${action != null && html`
+ <div class="settings-collapsible-action">${action}</div>`}
+ </div>
+ ${open && html`<div class="settings-collapsible-body">${children}</div>`}
+ </div>
+ `;
+}
+
+/**
+ * A modern on/off switch — replaces a plain checkbox or a "Turn on/off"
+ * button wherever the setting itself is a straight binary (uploads
+ * allowed, TMDB/MusicBrainz enabled). Still a real <input type="checkbox">
+ * under the hood (keyboard/screen-reader behaviour for free), just
+ * restyled — see .toggle-switch in style.css.
+ */
+function ToggleSwitch({ checked, onChange, disabled, label }) {
+ return html`
+ <label class="toggle-switch ${disabled ? 'toggle-switch-disabled' : ''}">
+ <input type="checkbox" checked=${checked} disabled=${disabled}
+ onChange=${(e) => onChange(e.target.checked)} />
+ <span class="toggle-switch-track"><span class="toggle-switch-thumb"></span></span>
+ ${label != null && html`<span class="toggle-switch-label">${label}</span>`}
+ </label>
+ `;
+}
+
+/**
+ * The busy/message bookkeeping every settings pane does around one call.
+ *
+ * Each pane owns its own, rather than sharing the page's: two sections saving
+ * at once is ordinary (an operator ticks a toggle in Videos while Music's
+ * folder save is still in flight), and one shared flag would disable both and
+ * then attribute one section's error to the other.
+ */
+function useSaver() {
+ const [busy, setBusy] = useState(false);
+ const [msg, setMsg] = useState('');
+ const run = useCallback(async (work) => {
+ setBusy(true); setMsg('');
+ try {
+ await work();
+ return true;
+ } catch (err) {
+ setMsg(err && err.message ? err.message : String(err));
+ return false;
+ } finally { setBusy(false); }
+ }, []);
+ return { busy, msg, run, setMsg };
+}
+
+export { CollapsibleSection, ToggleSwitch, useSaver };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index c183d7e..d1d5732 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -24,6 +24,11 @@
--nav-brand: #23b1f0;
--sidebar-bg: #f1f5f9;
--sidebar-hover: #e2e8f0;
+ /* Used with a literal fallback in a handful of rules before they were
+ defined here — same values, so nothing moves; now themeable, and the
+ names in those rules are real. */
+ --warn: #d97706;
+ --accent-bg: rgba(59, 130, 246, 0.1);
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.1);
}
@@ -36,6 +41,8 @@
--text-secondary: #94a3b8;
--text-dim: #64748b;
--border: #334155;
+ --warn: #f59e0b;
+ --accent-bg: rgba(59, 130, 246, 0.18);
--error-bg: #450a0a;
--nav-bg: #020617;
--sidebar-bg: #1e293b;
@@ -826,6 +833,22 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.tb-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.tb-btn:disabled:hover { border-color: var(--border); color: var(--text); }
.tb-btn .icon { width: 15px; height: 15px; }
+/* Square, for a toolbar button whose name is in its tooltip rather than
+ beside it. The horizontal padding goes; the height does not, so it lines up
+ with the labelled buttons next to it. */
+.tb-btn-icon { padding: 0; width: 32px; justify-content: center; gap: 0; }
+
+/* Naming a new folder, in the toolbar. Not `window.prompt`, which throws in
+ Electron — and an inline field can put the node's refusal next to the input
+ rather than after a dialog has closed. */
+.tb-mkdir { display: inline-flex; align-items: center; gap: 6px; }
+.tb-mkdir-input {
+ height: 32px; width: 170px; padding: 0 9px;
+ border: 1px solid var(--border-focus); border-radius: 6px;
+ background: var(--bg-surface); color: var(--text);
+ font-family: inherit; font-size: 0.83em;
+}
+.tb-mkdir-input:focus { outline: none; }
.tb-btn.primary {
background: var(--accent);
border-color: var(--accent);
@@ -1240,6 +1263,46 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
}
.settings-collapsible-header .settings-heading { margin-bottom: 0; }
.settings-collapsible-body { margin-top: 12px; }
+/* The header button stretches; the action (an app's on/off switch) keeps its
+ own width at the end, and its own click. */
+.settings-collapsible-bar { display: flex; align-items: center; gap: 12px; }
+.settings-collapsible-bar .settings-collapsible-header { flex: 1 1 auto; min-width: 0; }
+.settings-collapsible-action { flex: 0 0 auto; }
+
+/* One app's settings pane, inside its section. */
+.app-settings > .settings-row:first-child { margin-top: 0; }
+/* The Save button of an app's settings pane.
+ *
+ * It was `btn btn-small btn-secondary`, and there is no `.btn` rule at all —
+ * so it inherited `.btn-secondary`: no background, a transparent border, dim
+ * grey text. Enabled, it already looked like a disabled control; disabled, it
+ * was the same thing at 40% opacity. "You cannot always click Save, you do not
+ * notice, and it does not work" was one sentence describing all of that.
+ *
+ * It looks like a button now, and a disabled one looks disabled. */
+.app-save {
+ display: inline-flex; align-items: center; gap: 6px;
+ height: 30px; padding: 0 14px; margin-top: 4px;
+ border: 1px solid var(--accent); border-radius: 6px;
+ background: var(--accent); color: var(--accent-text);
+ font-family: inherit; font-size: 0.83em; cursor: pointer;
+}
+.app-save:hover:not(:disabled) { background: var(--accent-hover); }
+/* No transition on the enabled/disabled swap. It animates a colour change that
+ coincides with a text-colour change, which reads as a flicker — and it made
+ the state genuinely hard to observe: a measurement taken during it reports a
+ transparent background on a button that is not transparent. */
+.app-save:disabled {
+ background: none; color: var(--text-dim);
+ border-color: var(--border); opacity: 1; cursor: default;
+}
+/* Why it is inert, beside it — a disabled control that explains itself is the
+ difference between "nothing changed" and "this is broken". */
+
+.app-settings-sub {
+ margin: 18px 0 4px; font-size: 0.9em; font-weight: 600;
+ padding-top: 12px; border-top: 1px solid var(--border);
+}
.settings-meta-title {
display: flex;
@@ -1267,6 +1330,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
/* A modern on/off switch — replaces a checkbox or a "Turn on/off" button
wherever the setting is a straight binary. */
.toggle-switch {
+ position: relative;
display: inline-flex;
align-items: center;
gap: 10px;
@@ -1897,7 +1961,8 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
white-space: nowrap;
}
a.transfer-name {
- color: var(--link);
+ /* `--link` is not defined here; the palette's link colour is `--accent`. */
+ color: var(--accent);
text-decoration: underline;
cursor: pointer;
}
@@ -2125,7 +2190,11 @@ a.transfer-name {
position: absolute;
top: -6px;
right: -8px;
- background: var(--danger);
+ /* `--danger` is not one of this stylesheet's variables — the palette calls
+ it `--error`. The declaration was invalid, so the unread-count badge had
+ white text on no background at all. Pre-existing; found while fixing four
+ more of the same in the folder picker. */
+ background: var(--error);
color: #fff;
font-size: 0.65rem;
padding: 1px 5px;
@@ -2147,7 +2216,8 @@ a.transfer-name {
align-items: center;
gap: 8px;
padding: 8px 12px;
- border-radius: var(--radius);
+ /* `--radius` was never defined — 8px is what the other cards use. */
+ border-radius: 8px;
background: var(--bg-surface);
margin-bottom: 4px;
cursor: pointer;
@@ -2157,7 +2227,8 @@ a.transfer-name {
background: var(--bg-raised);
}
.notif-unread {
- border-left: 3px solid var(--primary);
+ /* `--primary` is not defined here either; it is `--accent`. */
+ border-left: 3px solid var(--accent);
font-weight: 600;
}
.notif-kind {
@@ -2542,6 +2613,66 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
.node-roots-header { margin-bottom: 8px; }
+/* Shared directories table */
+.shared-dirs-tbl { width: 100%; border-collapse: collapse; border: none; }
+.shared-dirs-tbl th {
+ text-align: left; font-size: 0.78em; font-weight: 500;
+ color: var(--text-dim); padding: 0 12px 6px 0;
+ text-transform: uppercase; letter-spacing: 0.04em;
+ border: none;
+}
+.shared-dirs-tbl td { padding: 7px 12px 7px 0; border: none; vertical-align: middle; }
+.shared-dirs-tbl tbody tr + tr td { border-top: 1px solid var(--border); }
+.sdt-col-dir { min-width: 140px; }
+/* The path is the only thing separating two libraries whose folders happen to
+ share a basename, so it is shown — truncated, because it is usually long and
+ rarely the thing being read. */
+.sdt-col-path {
+ color: var(--text-dim); font-size: 0.85em;
+ max-width: 260px; overflow: hidden; text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.sdt-add-row { display: flex; gap: 6px; align-items: center; margin-top: 8px; }
+.sdt-add-input {
+ flex: 1 1 auto; min-width: 0; padding: 5px 8px;
+ border: 1px solid var(--border); border-radius: 4px;
+ background: var(--bg-surface); color: var(--text);
+ font-family: inherit; font-size: 0.9em;
+}
+.sdt-col-toggle { width: 90px; text-align: center; }
+.sdt-col-toggle th { text-align: center; }
+.sdt-col-toggle .toggle-switch { justify-content: center; }
+.sdt-col-actions { white-space: nowrap; text-align: right; }
+.sdt-action-btn {
+ background: none; border: 1px solid var(--border); border-radius: 4px;
+ padding: 4px 7px; cursor: pointer; font-size: 0.85em; color: var(--text-dim);
+ line-height: 1; vertical-align: middle;
+}
+.sdt-action-btn + .sdt-action-btn { margin-left: 6px; }
+.sdt-action-btn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text); }
+.sdt-action-btn:disabled { opacity: 0.3; cursor: default; }
+.sdt-action-danger:hover:not(:disabled) { color: var(--error); border-color: var(--error); }
+.sdt-dir-name { display: inline-flex; align-items: center; gap: 6px; font-weight: 500; }
+.sdt-dir-name .icon { width: 16px; height: 16px; flex-shrink: 0; }
+.sdt-row-ejected { opacity: 0.5; }
+.sdt-row-unavail { opacity: 0.6; }
+.root-eject-btn {
+ background: none; border: 1px solid var(--border); border-radius: 4px;
+ padding: 2px 6px; cursor: pointer; font-size: 0.85em; color: var(--text-dim);
+ line-height: 1;
+}
+.root-eject-btn:hover { background: var(--bg-raised); }
+.root-eject-btn { margin-left: 8px; }
+/* An ejected root in the Files table: still listed, deliberately — its files
+ are frozen, not gone — but not somewhere you can walk into. */
+.file-row.root-ejected { opacity: 0.5; }
+.file-row.root-ejected td:not(.sel-cell) { cursor: default; }
+
+/* The paperclip with no writable directory to write to. Shown rather than
+ hidden, so the reason is discoverable instead of the control just being
+ absent. */
+.chat-attach-off { opacity: 0.35; cursor: not-allowed; }
+
.node-root {
display: flex;
align-items: center;
@@ -3993,3 +4124,95 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
background: linear-gradient(to top, rgba(0, 0, 0, 0.55), transparent);
}
.photo-lightbox-count { color: #94a3b8; }
+
+/* ── Folder tree picker (folder-tree.js) ─────────────────────────────────── */
+
+.ftp-backdrop {
+ position: fixed; inset: 0; z-index: 1200;
+ background: rgba(0, 0, 0, 0.55);
+ display: flex; align-items: center; justify-content: center;
+ padding: 16px;
+}
+.ftp-panel {
+ background: var(--bg-surface); color: var(--text);
+ border: 1px solid var(--border); border-radius: 8px;
+ width: min(520px, 100%); max-height: min(70vh, 640px);
+ display: flex; flex-direction: column; padding: 16px;
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
+}
+.ftp-panel:focus { outline: none; }
+.ftp-title { margin: 0 0 8px; font-size: 1em; }
+/* The tree is the only part that scrolls: the title, the selection and the
+ buttons stay put, so OK never travels off-screen in a deep library. */
+.ftp-tree, .ftp-children { list-style: none; margin: 0; padding: 0; }
+.ftp-tree {
+ flex: 1 1 auto; overflow-y: auto; overflow-x: hidden;
+ border: 1px solid var(--border); border-radius: 4px;
+ padding: 4px 0; margin: 8px 0;
+}
+.ftp-row { display: flex; align-items: center; gap: 2px; }
+.ftp-row.chosen { background: var(--bg-raised); }
+/* 0.4 was low enough that a greyed-out root read as an artefact rather than
+ a disabled control — the names were barely legible in the screenshot that
+ prompted this. */
+.ftp-row.blocked { opacity: 0.55; }
+.ftp-twisty {
+ flex: 0 0 auto; width: 20px; height: 20px; padding: 0;
+ background: none; border: none; color: var(--text-dim);
+ font-family: inherit; font-size: 0.95em; line-height: 1; cursor: pointer;
+}
+.ftp-twisty:disabled { cursor: default; opacity: 0; }
+.ftp-label {
+ flex: 1 1 auto; min-width: 0;
+ display: flex; align-items: center; gap: 6px;
+ background: none; border: none; color: inherit;
+ font: inherit; text-align: left; padding: 3px 6px; cursor: pointer;
+}
+.ftp-label:disabled { cursor: not-allowed; }
+.ftp-label .icon { width: 15px; height: 15px; flex-shrink: 0; }
+.ftp-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ftp-badge {
+ flex: 0 0 auto; margin-left: 8px;
+ font-size: 0.72em; padding: 1px 5px; border-radius: 3px;
+ border: 1px solid var(--border); background: var(--bg-raised);
+ color: var(--text-dim); white-space: nowrap;
+ text-transform: uppercase; letter-spacing: 0.03em;
+}
+.ftp-badge.rw { color: var(--accent); border-color: currentColor; }
+.ftp-badge.warn { color: var(--error); border-color: currentColor; }
+.ftp-check { margin-left: auto; flex: 0 0 auto; }
+.ftp-selection {
+ display: flex; flex-wrap: wrap; gap: 4px;
+ max-height: 84px; overflow-y: auto; margin-bottom: 10px;
+}
+.ftp-chip {
+ font-size: 0.82em; padding: 2px 6px; border-radius: 3px;
+ background: var(--bg-raised); border: 1px solid var(--border);
+}
+.ftp-actions { display: flex; justify-content: flex-end; gap: 8px; }
+/* An app's chosen folders. Deliberately not a .settings-row: that is a
+ space-between flex row, and a label, a hint and a list laid out inside one
+ end up spread across a single line in source order. */
+.folder-field { padding: 8px 0; }
+.folder-field-head { margin-bottom: 6px; }
+.folder-field-label {
+ margin: 0 0 2px; font-size: 0.9em; font-weight: 600; color: var(--text);
+}
+.folder-field-head .settings-hint { margin-bottom: 0; }
+/* Borrows .shared-dirs-tbl so the two lists of directories on this page read
+ as the same kind of thing. */
+.folder-field-tbl { margin-bottom: 8px; }
+.folder-field-tbl .sdt-col-dir { font-size: 0.9em; }
+.folder-field-actions { display: flex; align-items: center; gap: 10px; }
+
+/* HelloWorld (docs/refactor-groups.md §4.1) — the reference app, hidden
+ behind ?dev=1. Deliberately plain: it exists to prove the plumbing, and
+ anything decorative here would be a second thing to keep working. */
+.hw-list { list-style: none; margin: 12px 0 0; padding: 0; }
+.hw-item {
+ display: flex; align-items: center; gap: 8px;
+ padding: 6px 0; border-bottom: 1px solid var(--border);
+}
+.hw-item .icon { width: 15px; height: 15px; flex-shrink: 0; }
+.hw-name { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.hw-size { flex: 0 0 auto; color: var(--text-dim); font-size: 0.85em; }
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index ea1e70a..179292e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -72,12 +72,43 @@ function _aborted() {
// change anything — `op` is already on every admin_challenge, and this
// list is what lets a response two steps later be tied back to the right
// one.
+// Acks the node *broadcasts* to everyone in the group, which the requester
+// therefore also has to be handed.
+//
+// `_dispatch` resolves an admin ack against the pending request and returns,
+// which is right for an op whose caller already knows the value it chose. It is
+// wrong for these: every *other* connected client learns the change from the
+// broadcast, and the one that asked for it is the only one that does not,
+// because its own request swallowed its copy. Found twice — first on the root
+// table, then on Chat's directory, where it meant the pane went on showing an
+// unsaved-looking draft after a save that had worked.
+const BROADCAST_ACK_TYPES = new Set([
+ 'root_update_ack', 'root_eject_ack', 'root_plug_ack',
+ 'root_add_ack', 'root_remove_ack',
+ 'app_directories_ack', 'chat_directory_ack', 'chat_link_preview_ack',
+]);
+
+/** Hand a broadcast ack to the callback that would have had it from a peer. */
+function _replayBroadcast(transport, msg) {
+ if (msg.type === 'app_directories_ack' && transport._onAppDirectories) {
+ transport._onAppDirectories(msg.app, msg.directories || []);
+ } else if (msg.type === 'chat_directory_ack' && transport._onChatDirectory) {
+ transport._onChatDirectory(msg.path || '');
+ } else if (msg.type === 'chat_link_preview_ack' && transport._onChatLinkPreview) {
+ transport._onChatLinkPreview(Boolean(msg.enabled));
+ } else if (transport._onRootsChanged) {
+ transport._onRootsChanged(msg);
+ }
+}
+
const ADMIN_OP_TYPES = new Set([
'tmdb_override', 'tmdb_rematch', 'tmdb_config', 'tmdb_enabled', 'video_root', 'audio_root',
'photo_roots',
'musicbrainz_enabled', 'file_delete', 'dir_delete',
- 'member_upload', 'apps_enabled', 'set_scan_settings', 'member_revoke',
- 'root_add', 'root_remove', 'member_unpin', 'gek_rotate', 'group_attach',
+ 'apps_enabled', 'set_scan_settings', 'member_revoke',
+ 'root_add', 'root_remove', 'root_update', 'root_eject', 'root_plug',
+ 'app_directories', 'chat_directory', 'chat_link_preview',
+ 'member_unpin', 'gek_rotate', 'group_attach',
'group_detach', 'invite_create',
]);
@@ -217,7 +248,7 @@ window.addEventListener('hashchange', () => {
// The `v: '0.1'` on every other message in this file is the historical value
// and is read by nothing; it is left alone deliberately. The range is
// negotiated once, at the start, not restated per message.
-const MNP_V = '1.0';
+const MNP_V = '1.1';
const MNP_V_MIN = '1.0';
// Codes a NODE sends us, in its own vocabulary (meshbay_common/handshake.py's
@@ -325,7 +356,30 @@ class MeshBayTransport {
set onIndexSync(fn) { this._onIndexSync = fn; }
set onIndexDelta(fn) { this._onIndexDelta = fn; }
set onUploadPolicy(fn) { this._onUploadPolicy = fn; }
+ set onRootsChanged(fn) { this._onRootsChanged = fn; }
+
+ /** The MNP version the connected node declared, or '' before a handshake. */
+ get nodeVersion() { return this._nodeVersion || ''; }
+
+ /**
+ * Whether the node speaks the per-root and per-app operations MNP 1.1 added:
+ * `root_update`/`root_eject`/`root_plug`, `app_directories`,
+ * `chat_directory`, `chat_link_preview`.
+ *
+ * An older node has no equivalent for the root ones at all, and answers the
+ * app ones through their three predecessors (`video_root`, `audio_root`,
+ * `photo_roots`). The caller chooses which; what it must not do is send a
+ * 1.1 message and wait, because an unknown type is logged and dropped.
+ */
+ get supportsAppOps() {
+ const m = /^(\d+)\.(\d+)$/.exec(this._nodeVersion || '');
+ if (!m) return false;
+ return (Number(m[1]) > 1) || (Number(m[1]) === 1 && Number(m[2]) >= 1);
+ }
set onAppsEnabled(fn) { this._onAppsEnabled = fn; }
+ set onAppDirectories(fn) { this._onAppDirectories = fn; }
+ set onChatDirectory(fn) { this._onChatDirectory = fn; }
+ set onChatLinkPreview(fn) { this._onChatLinkPreview = fn; }
set onTmdbConfig(fn) { this._onTmdbConfig = fn; }
set onTmdbEnabled(fn) { this._onTmdbEnabled = fn; }
set onVideoRoot(fn) { this._onVideoRoot = fn; }
@@ -587,6 +641,12 @@ class MeshBayTransport {
// block, because everything below — the join, the proof, the sealed ack
// — assumes both sides mean the same thing by each message.
_checkNodeVersion(reply);
+ // Kept, not just checked. Several controls exist only on a node new
+ // enough to have them, and the alternative to asking is offering a
+ // button whose message an older node logs as unknown and never answers
+ // — a 30-second wait ending in a timeout, with nothing on screen to say
+ // the node simply cannot do this.
+ this._nodeVersion = String(reply.v || '');
if (!window.MeshBayCrypto) {
throw new Error('Node requires GEK proof but no crypto available');
}
@@ -1070,7 +1130,7 @@ class MeshBayTransport {
* queried in (e.g. "fr-FR") — one for the whole node, since both are one
* operator's shared credential/cache, not a per-group concern (see
* setTmdbEnabled below for the per-group on/off switch). Signed like
- * setAppsEnabled/setMemberUpload — an unsigned change would let any
+ * setAppsEnabled/updateRoot — an unsigned change would let any
* member alter outbound third-party network traffic the operator never
* agreed to (docs/mediacenter.md §5.5, §8). `token: ''` explicitly clears
* a previously-set custom token; omit it (undefined/null), like
@@ -1169,6 +1229,96 @@ class MeshBayTransport {
}
/**
+ * Point an application at folder(s) inside the group's shared directories.
+ *
+ * One method for every app, keyed by the app's registry name — the same
+ * generic op the node grew for the same reason (docs/refactor-groups.md
+ * §1.6). `setVideoRoot`, `setAudioRoot` and `setPhotoRoots` are still here
+ * and still work; nothing new should call them.
+ *
+ * The subject names the app as well as the paths, because an operator shown
+ * "Media/Films" alone cannot tell which application is about to be pointed
+ * at it, and two apps' challenges would otherwise be indistinguishable.
+ * Cleaned and sorted the same way the node does, so both sides build the
+ * same bytes to sign.
+ */
+ /**
+ * The same instruction a node too old for `app_directories` understands.
+ *
+ * Videos, Music and Photos each had their own message before this, and they
+ * still work — so an operator on an un-upgraded node keeps the ability they
+ * had, rather than being handed a control that silently times out. Chat has
+ * no predecessor, which is why its settings are hidden rather than routed.
+ */
+ async setAppDirectoriesLegacy(appKey, directories, signFn) {
+ const clean = [...new Set(
+ (directories || []).map((d) => (d || '').replace(/^\/+|\/+$/g, '')).filter(Boolean),
+ )].sort();
+ if (appKey === 'photo') return this.setPhotoRoots(clean, signFn);
+ // One folder was all these two could carry. Sending several would store
+ // the first and silently drop the rest, so it is refused instead.
+ if (clean.length > 1) {
+ throw new Error(
+ 'This node is older than this page and can hold one folder per app. '
+ + 'Update it, or choose a single folder.');
+ }
+ const one = clean[0] || '';
+ if (appKey === 'video') return this.setVideoRoot(one, signFn);
+ if (appKey === 'music') return this.setAudioRoot(one, signFn);
+ throw new Error(
+ 'This node is older than this page and cannot store this app\'s '
+ + 'folders. Its operator has to update it.');
+ }
+
+ async setAppDirectories(appKey, directories, signFn) {
+ const clean = [...new Set(
+ (directories || []).map((d) => (d || '').replace(/^\/+|\/+$/g, '')).filter(Boolean),
+ )].sort();
+ const msg = await this._sendAndWait({
+ type: 'app_directories', v: '1.1', app: appKey, directories: clean,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(
+ msg, 'app_directories', `${appKey}:${clean.join(',')}`, signFn);
+ }
+ return msg;
+ }
+
+ /**
+ * Where chat attachments are written.
+ *
+ * Its own message rather than `setAppDirectories('chat', ...)`: this one is
+ * a destination, and the node refuses a read-only root for it. A caller
+ * reaching for the generic form would get a refusal it has no reason to
+ * expect, so the difference is in the name.
+ */
+ async setChatDirectory(path, signFn) {
+ const clean = (path || '').replace(/^\/+|\/+$/g, '');
+ const msg = await this._sendAndWait({
+ type: 'chat_directory', v: '1.1', path: clean,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'chat_directory', clean, signFn);
+ }
+ return msg;
+ }
+
+ /** Whether the node unfurls links members post in this group's chat. */
+ async setChatLinkPreview(enabled, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'chat_link_preview', v: '1.1', enabled: Boolean(enabled),
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(
+ msg, 'chat_link_preview', enabled ? 'on' : 'off', signFn);
+ }
+ return msg;
+ }
+
+ /**
* MusicBrainz metadata for one track (Music app, docs/musicbay.md §4.3)
* — same shape as fetchMediaMeta, minus a season/episode concept:
* album-level (release), resolved from the track's own artist/album
@@ -1365,24 +1515,6 @@ class MeshBayTransport {
* on the hub is the other half, and neither implies the other.
*/
/**
- * Turn uploading by ordinary members on or off.
- *
- * Signed by the operator like any other privileged operation — the node
- * refuses an unsigned one, which is what stops a member turning it back on.
- */
- async setMemberUpload(allowed, signFn) {
- const msg = await this._sendAndWait({
- type: 'member_upload', v: '0.1', allowed: Boolean(allowed),
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(
- msg, 'member_upload', allowed ? 'on' : 'off', signFn);
- }
- return msg;
- }
-
- /**
* Turn a group "application" (Chat, Files, ...) on or off for everyone.
*
* Takes the whole set in one signed message rather than one op per app, so
@@ -1391,13 +1523,25 @@ class MeshBayTransport {
* `_authorizeAdminOp` below checks the two match.
*/
async setAppsEnabled(apps, signFn) {
+ // Files cannot be turned off — MNP permits root exploration regardless of
+ // this list, so hiding the tab only ever misled — and the node adds it if
+ // it is missing. That normalisation has to happen *here too*: the subject
+ // below is rebuilt from what this client sent, and compared byte for byte
+ // against what the node put in the challenge. A list arriving here without
+ // `files` would produce two different strings and `_authorizeAdminOp`
+ // would refuse to sign an op the operator did ask for. It is reachable
+ // only from a caller that builds the list from something other than the
+ // node's own answer, which is exactly the kind of caller a later phase
+ // adds. (`apps.js` marks it `alwaysEnabled`; this file is a classic
+ // script and cannot import it.)
+ const full = apps.includes('files') ? [...apps] : ['files', ...apps];
const msg = await this._sendAndWait({
- type: 'apps_enabled', v: '0.1', apps,
+ type: 'apps_enabled', v: '0.1', apps: full,
});
if (msg.type === 'error') throw new Error(msg.detail);
if (msg.type === 'admin_challenge') {
return this._authorizeAdminOp(
- msg, 'apps_enabled', [...apps].sort().join(','), signFn);
+ msg, 'apps_enabled', [...full].sort().join(','), signFn);
}
return msg;
}
@@ -1452,11 +1596,12 @@ class MeshBayTransport {
return msg;
}
- async addRoot(groupId, path, { name, kind, upload } = {}, signFn) {
+ async addRoot(groupId, path, { name, kind, writable, removable } = {}, signFn) {
const msg = await this._sendAndWait({
- type: 'root_add', v: '0.1',
+ type: 'root_add', v: '1.1',
group_id: groupId, path,
- name: name || '', kind: kind || 'generic', upload: !!upload,
+ name: name || '', kind: kind || 'generic',
+ writable: !!writable, removable: !!removable,
});
if (msg.type === 'error') throw new Error(msg.detail);
if (msg.type === 'admin_challenge') {
@@ -1477,6 +1622,48 @@ class MeshBayTransport {
return msg;
}
+ async updateRoot(groupId, rootName, { writable, removable } = {}, signFn) {
+ const updates = [];
+ if (writable !== undefined) updates.push(`rw=${writable ? 'on' : 'off'}`);
+ if (removable !== undefined) updates.push(`rem=${removable ? 'on' : 'off'}`);
+ const subject = updates.length ? `${rootName}:${updates.join(',')}` : rootName;
+ const msg = await this._sendAndWait({
+ type: 'root_update', v: '1.1',
+ group_id: groupId, root_name: rootName,
+ ...(writable !== undefined && { writable }),
+ ...(removable !== undefined && { removable }),
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'root_update', subject, signFn);
+ }
+ return msg;
+ }
+
+ async ejectRoot(groupId, rootName, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'root_eject', v: '1.1',
+ group_id: groupId, root_name: rootName,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'root_eject', rootName, signFn);
+ }
+ return msg;
+ }
+
+ async plugRoot(groupId, rootName, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'root_plug', v: '1.1',
+ group_id: groupId, root_name: rootName,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'root_plug', rootName, signFn);
+ }
+ return msg;
+ }
+
async unpinMember(userId, signFn) {
const msg = await this._sendAndWait({
type: 'member_unpin', v: '0.1', user_id: userId,
@@ -1612,8 +1799,19 @@ class MeshBayTransport {
* The node decides where this lands (uploads/) and under what name — it finds a
* free one rather than replacing anything. The ack says which, and that is what
* this returns.
+ *
+ * `dir` names the folder to upload into, as a virtual path
+ * (`Media/Films/1999`) — where the sender is actually looking. The node
+ * resolves it against the group's own roots, which refuses `..`, absolute
+ * segments and anything escaping its root; it is a place among the group's
+ * folders, never a path on the operator's filesystem.
+ *
+ * `root` is the older, coarser form: the root's name and nothing below it.
+ * Kept because a node that predates `dir` reads it, and because Chat has no
+ * folder on screen to name. Omitting both leaves the node to pick, which it
+ * only does for a client old enough to have had one destination.
*/
- async uploadFile(file, { chunkSize, onProgress, signal } = {}) {
+ async uploadFile(file, { chunkSize, onProgress, signal, root, dir } = {}) {
// The same file twice at once would confuse the node, which keys its own
// upload state by name — and would race for the same destination.
if (this._uploaders.has(file.name)) {
@@ -1664,6 +1862,8 @@ class MeshBayTransport {
chunk_index: i,
total_chunks: total,
data: buf,
+ ...(root ? { root } : {}),
+ ...(dir ? { dir } : {}),
});
}
while (acked < total) {
@@ -2161,7 +2361,19 @@ class MeshBayTransport {
} else if (typeof msg.type === 'string' && msg.type.endsWith('_ack')) {
const key = `admin:${msg.type.slice(0, -4)}`;
for (const [, handler] of this._pending) {
- if (handler._key === key) { handler.resolve(msg); return; }
+ if (handler._key === key) {
+ handler.resolve(msg);
+ // The comment above ("its own caller already updates local state
+ // from what it sent") is true of every op whose caller passes the
+ // value it just chose to an onX(next). The root ops are not like
+ // that: what changes is the whole roots table, which only the node
+ // can compute — availability, the eject that the plug refused, the
+ // name it settled on. Returning here left the operator who clicked
+ // Eject as the one client that never saw it happen, while every
+ // other peer got the broadcast. So this one type is handed on.
+ if (BROADCAST_ACK_TYPES.has(msg.type)) _replayBroadcast(this, msg);
+ return;
+ }
}
}
@@ -2208,10 +2420,12 @@ class MeshBayTransport {
return;
}
- // The operator changed who may upload. Unsolicited: it arrives at everyone
- // connected, not only at whoever asked. It still has to reach a pending
- // caller — the operator's own request resolves on this reply — so it falls
- // through to the matching below rather than returning here.
+ // Legacy. An MNP 1.0 node still broadcasts this when its operator changes
+ // the group-wide upload switch, and its roots carry no `writable` for us
+ // to read instead — so this is the only answer available from such a node
+ // and it is still honoured. Nothing here *sends* the message any more:
+ // per-root RO/RW replaced it, and a current node answers it with a
+ // deprecation notice and no action.
if (msg.type === 'member_upload_ack' && this._onUploadPolicy) {
this._onUploadPolicy(Boolean(msg.allowed));
}
@@ -2222,6 +2436,18 @@ class MeshBayTransport {
this._onAppsEnabled(msg.apps || []);
}
+ // An application was pointed at different folders. One handler for every
+ // app — the callback is given the app's name and decides.
+ if (msg.type === 'app_directories_ack' && this._onAppDirectories) {
+ this._onAppDirectories(msg.app, msg.directories || []);
+ }
+ if (msg.type === 'chat_directory_ack' && this._onChatDirectory) {
+ this._onChatDirectory(msg.path || '');
+ }
+ if (msg.type === 'chat_link_preview_ack' && this._onChatLinkPreview) {
+ this._onChatLinkPreview(Boolean(msg.enabled));
+ }
+
// Node-wide (not per-group) — the operator supplied/cleared a custom
// token, or changed the query language. `token_customized` only says
// whether one is set, never the token itself.
@@ -2277,6 +2503,15 @@ class MeshBayTransport {
this._onMusicbrainzEnabled(Boolean(msg.enabled));
}
+ // A root's flags changed, or one was ejected, plugged, added or removed.
+ // Broadcast by the node to every peer, so everyone's table updates without
+ // waiting for the next index_sync.
+ if (msg.type === 'root_update_ack' || msg.type === 'root_eject_ack'
+ || msg.type === 'root_plug_ack' || msg.type === 'root_add_ack'
+ || msg.type === 'root_remove_ack') {
+ if (this._onRootsChanged) this._onRootsChanged(msg);
+ }
+
// The operator's node is scanning — never the entries themselves, just
// enough to animate a presence dot. Pushed periodically while it runs,
// plus once more on the transition back to idle (daemon.py
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js
new file mode 100644
index 0000000..5c79bc5
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js
@@ -0,0 +1,131 @@
+import { html, useState, useEffect } from './vendor/htm-preact.js';
+import { t, getLocale, LOCALES } from './i18n.js';
+import { ToggleSwitch, useSaver } from './settings-ui.js';
+import { FolderPickerField } from './folder-tree.js';
+
+// MeshBay's own locale codes (i18n.js LOCALES) to the language tag TMDB
+// expects. Duplicated from nothing: this is the only place it lives now that
+// the TMDB fields moved out of the monolithic settings page.
+const TMDB_LANGUAGE_BY_LOCALE = {
+ en: 'en-US', fr: 'fr-FR', es: 'es-ES', 'pt-BR': 'pt-BR', 'zh-CN': 'zh-CN',
+ ja: 'ja-JP', de: 'de-DE', it: 'it-IT', nl: 'nl-NL', pl: 'pl-PL',
+};
+
+/**
+ * The Videos app's operator settings: which folders it works over, and the
+ * TMDB lookups it makes.
+ *
+ * Several folders now, not one. A film library is as likely to be two drives
+ * as one, and the single-root model made the second one invisible — the
+ * operator's only recourse was to point Videos at a parent containing both,
+ * which pulls in everything else under it too.
+ *
+ * The TMDB parts are two independent settings that happen to sit together:
+ * the on/off switch is per group, while the API key and the query language
+ * are node-wide, because they are one operator's credential and one shared
+ * cache (docs/mediacenter.md §5.5). They save separately for that reason.
+ */
+function VideoSettings({ roots, dirs, settings, saveDirectories, transport, signFn }) {
+ const { busy, msg, run } = useSaver();
+ const [directories, setDirectories] = useState(settings.videoDirectories || []);
+ const [tmdbEnabled, setTmdbEnabled] = useState(settings.tmdbEnabled !== false);
+ const [token, setToken] = useState('');
+ const [language, setLanguage] = useState(
+ settings.tmdbLanguage || TMDB_LANGUAGE_BY_LOCALE[getLocale()] || 'en-US');
+
+ useEffect(() => {
+ setDirectories(settings.videoDirectories || []);
+ }, [settings.videoDirectories]);
+ useEffect(() => {
+ setTmdbEnabled(settings.tmdbEnabled !== false);
+ }, [settings.tmdbEnabled]);
+ useEffect(() => {
+ if (settings.tmdbLanguage) setLanguage(settings.tmdbLanguage);
+ }, [settings.tmdbLanguage]);
+
+ const current = settings.videoDirectories || [];
+ const dirsDirty = directories.length !== current.length
+ || directories.some((d, i) => d !== current[i]);
+
+ return html`
+ <div class="app-settings">
+ <${FolderPickerField}
+ label=${t('settings_app.video_directories_label')}
+ hint=${t('settings_app.video_directories_hint')}
+ roots=${roots} dirs=${dirs} mode="multi"
+ value=${directories} disabled=${busy}
+ onChange=${setDirectories} />
+
+ <button class="app-save" disabled=${busy || !dirsDirty}
+ onClick=${() => run(() => saveDirectories(directories))}>
+ ${busy ? t('settings_app.saving') : t('settings_app.save')}
+ </button>
+
+ <h4 class="app-settings-sub">${t('settings_node.tmdb_title')}</h4>
+ <p class="settings-hint">${t('settings_node.tmdb_hint')}</p>
+
+ <div class="settings-row">
+ <${ToggleSwitch} checked=${tmdbEnabled} disabled=${busy}
+ onChange=${(v) => { setTmdbEnabled(v);
+ run(() => transport.setTmdbEnabled(v, signFn)); }}
+ label=${tmdbEnabled ? t('settings_node.tmdb_enabled')
+ : t('settings_node.tmdb_disabled')} />
+ </div>
+
+ <div class="settings-row">
+ <label class="settings-label">
+ ${t('settings_node.tmdb_token_label')}
+ <input type="password" value=${token} disabled=${busy}
+ placeholder=${t('settings_node.tmdb_token_placeholder')}
+ onInput=${(e) => setToken(e.target.value)} />
+ </label>
+ ${/* No "optional", and no mention of a shipped default. A key that
+ works without one is a key somebody else is paying the rate
+ limit for, and the operator should know they are meant to have
+ their own. */''}
+ <p class="settings-hint">
+ ${settings.tmdbTokenCustomized
+ ? t('settings_node.tmdb_token_customized')
+ : t('settings_app.tmdb_token_prompt')}
+ ${' '}
+ <a href="https://www.themoviedb.org/settings/api" target="_blank"
+ rel="noopener noreferrer">${t('settings_app.tmdb_token_link')}</a>
+ </p>
+ </div>
+
+ <div class="settings-row">
+ <label class="settings-label">
+ ${t('settings_node.tmdb_language_label')}
+ <select value=${language} disabled=${busy}
+ onChange=${(e) => setLanguage(e.target.value)}>
+ ${LOCALES.map((l) => html`
+ <option key=${l.code} value=${TMDB_LANGUAGE_BY_LOCALE[l.code]}>
+ ${l.name}
+ </option>`)}
+ </select>
+ </label>
+ <p class="settings-hint">${t('settings_node.tmdb_language_hint')}</p>
+ </div>
+
+ ${/* The same control as the one above it. Two Save buttons in one pane
+ that do not look alike is worse than either looking wrong. This one
+ is never inert: the language always has a value to send, and an
+ empty token means "leave the stored one alone", not "no change". */''}
+ <button class="app-save" disabled=${busy}
+ onClick=${() => run(async () => {
+ // `undefined` for the token means "leave the stored one alone",
+ // which is not the same as `''` — that clears it. The input starts
+ // empty on every render because a secret is not read back, so
+ // sending it as a value would wipe the key every time the language
+ // was changed.
+ await transport.setTmdbConfig(token || undefined, language, signFn);
+ setToken('');
+ })}>
+ ${busy ? t('settings_app.saving') : t('settings_node.tmdb_save')}
+ </button>
+ ${msg && html`<p class="settings-hint">${msg}</p>`}
+ </div>
+ `;
+}
+
+export { VideoSettings, TMDB_LANGUAGE_BY_LOCALE };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
index 4f52b16..57da9ff 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
@@ -57,10 +57,11 @@ function yearOf(dateStr) {
// work for this group before that either (daemon.py's
// _enrich_new_video_entries), so falling back to "the whole index" here
// would just show files nothing has enriched.
-function underVideoRoot(entry, videoRoot) {
- if (!videoRoot) return false;
+function underVideoRoot(entry, directories) {
+ const dirs = directories || [];
+ if (!dirs.length) return false;
const p = entry.path || '';
- return p === videoRoot || p.startsWith(videoRoot + '/');
+ return dirs.some((d) => p === d || p.startsWith(d + '/'));
}
function buildSeasons(episodes) {
@@ -96,12 +97,12 @@ function defaultSeason(show) {
return Math.min(...(real.length ? real : numbers));
}
-function groupVideoEntries(entries, videoRoot) {
+function groupVideoEntries(entries, videoDirectories) {
const movies = [];
const showsByTitle = new Map();
for (const e of entries) {
if (e.type !== 'video') continue;
- if (!underVideoRoot(e, videoRoot)) continue;
+ if (!underVideoRoot(e, videoDirectories)) continue;
if (e.season != null && e.episode != null) {
const title = e.display_title || e.name;
if (!showsByTitle.has(title)) showsByTitle.set(title, { title, episodes: [] });
@@ -1040,7 +1041,8 @@ function FlatList({ movies, shows, transportRef, gekRef, onPreview, onNeedConn }
// ── shell ────────────────────────────────────────────────────────────────────
function VideoApp({
- groupId, transportRef, gekRef, status, entries, onPreview, videoRoot, tmdbConfig, isNodeAdmin,
+ groupId, transportRef, gekRef, status, entries, availableEntries, onPreview,
+ videoDirectories, tmdbConfig, isNodeAdmin,
hideFilter, onNeedConn,
}) {
const [mode, setMode] = useState(loadViewMode);
@@ -1056,8 +1058,13 @@ function VideoApp({
const setModeAndSave = (m) => { setMode(m); saveViewMode(m); };
+ const videoEntries = availableEntries || entries;
+ // One or several folders now, so the "is anything configured" question
+ // is asked once rather than by every branch testing a string.
+ const configured = (videoDirectories || []).length > 0;
const { movies, shows } = useMemo(
- () => groupVideoEntries(entries, videoRoot), [entries, videoRoot]);
+ () => groupVideoEntries(videoEntries, videoDirectories),
+ [videoEntries, videoDirectories]);
const needle = filter.trim().toLowerCase();
const filteredMovies = useMemo(() => (typeFilter === 'series' ? [] : !needle ? movies : movies.filter(
@@ -1072,10 +1079,10 @@ function VideoApp({
${status === 'offline' && html`
<p class="page-message">${t('group.offline_title')} ${t('group.offline_hint')}</p>
`}
- ${status === 'connected' && !videoRoot && html`
+ ${status === 'connected' && !configured && html`
<p class="page-message">${t('video.no_root_configured')}</p>
`}
- ${status === 'connected' && videoRoot && html`
+ ${status === 'connected' && configured && html`
<div class="video-toolbar">
<button class="tb-btn ${mode === 'poster' ? 'active' : ''}"
onClick=${() => setModeAndSave('poster')}>
diff --git a/packages/meshbay-hub/tests/test_app_settings_plugin.py b/packages/meshbay-hub/tests/test_app_settings_plugin.py
new file mode 100644
index 0000000..b8afc23
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_app_settings_plugin.py
@@ -0,0 +1,383 @@
+"""
+Adding an application must not mean editing the pages that render it.
+
+That is the whole claim of the plugin architecture, and it is the kind of claim
+that decays silently: the first special case for one app reads as harmless, and
+by the third the loop is a lookup table with a default branch. These tests are
+what makes the claim checkable.
+
+They are source-reading, which is weak evidence and the only kind available for
+the SPA. Where a stronger check exists it is used instead — `test_spa_syntax`
+parses every module, and `test_hook_ordering` catches the temporal-dead-zone
+fault this refactor's new import graph could otherwise reintroduce.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+APPS = STATIC / "apps.js"
+GROUP_SETTINGS = STATIC / "group-settings.js"
+GROUP_PAGE = STATIC / "group-page.js"
+SETTINGS_UI = STATIC / "settings-ui.js"
+FOLDER_TREE = STATIC / "folder-tree.js"
+TRANSPORT = STATIC / "transport.js"
+# parents[2] is `packages/` — the tests live at
+# packages/meshbay-hub/tests/, so [0] is tests, [1] the package, [2] packages.
+# Got this wrong once and the two cross-package checks below skipped silently,
+# which is worse than not having them: a green run that measured nothing.
+NODE_SERVER = (Path(__file__).resolve().parents[2] / "meshbay-node" / "src"
+ / "meshbay_node" / "transport" / "webrtc_server.py")
+
+PANES = ["chat-app-settings.js", "video-app-settings.js",
+ "music-app-settings.js", "photos-app-settings.js"]
+
+pytestmark = pytest.mark.skipif(not APPS.exists(),
+ reason="SPA sources unavailable")
+
+
+def _component(source: str, name: str) -> str:
+ start = source.index(f"\nfunction {name}(")
+ end = source.find("\nfunction ", start + 1)
+ return source[start:end if end != -1 else len(source)]
+
+
+def _code_only(source: str) -> str:
+ """
+ The same source with comments removed.
+
+ A prose explanation of what moved out of a file is not the file naming an
+ app — and the note recording *why* TMDB is no longer here is exactly the
+ kind of comment this codebase wants kept. Crude on purpose: it does not
+ understand strings containing `//`, which for these files is fine and for
+ a parser would be a second implementation of one.
+ """
+ source = re.sub(r"/\*.*?\*/", "", source, flags=re.S)
+ return re.sub(r"^\s*//.*$", "", source, flags=re.M)
+
+
+# ── The registry is the only place an app is named ──────────────────────────
+
+def test_the_settings_page_names_no_application():
+ """
+ The apps loop renders whatever the registry holds. A branch on `'video'`
+ here is the first step back to the 1338-line page this replaced, where
+ every app's settings were inlined and the file grew with each one.
+ """
+ panel = _code_only(_component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "GroupSettingsPanel"))
+ for app in ("'video'", "'music'", "'photo'",
+ "tmdb", "musicbrainz", "TMDB"):
+ assert app not in panel, (
+ f"group-settings.js still names {app} — an app's own settings "
+ f"belong in its settings file")
+
+
+def test_the_settings_page_renders_the_registry():
+ panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "GroupSettingsPanel")
+ assert "configurableApps()" in panel
+ assert "app.Settings" in panel, "the registry's component is not rendered"
+
+
+def test_every_registered_app_has_a_key_the_node_would_accept():
+ """
+ The registry key is the identifier everywhere: the tab, `apps_enabled`,
+ the `app_directories` op, and the roster row the directories live in. A
+ key here that `ALLOWED_APPS` does not have is an app whose settings are
+ refused by the node with no clue why.
+ """
+ node = NODE_SERVER
+ if not node.exists():
+ pytest.skip("the node package is not in this checkout")
+ m = re.search(r"ALLOWED_APPS = frozenset\(\{([^}]*)\}\)",
+ node.read_text(encoding="utf-8"))
+ assert m, "ALLOWED_APPS moved"
+ allowed = set(re.findall(r"'([^']+)'|\"([^\"]+)\"", m.group(1)))
+ allowed = {a or b for a, b in allowed}
+
+ keys = set(re.findall(r"\{ key: '([^']+)'", APPS.read_text(encoding="utf-8")))
+ assert keys, "no app keys found — did the registry's shape change?"
+ assert keys <= allowed, (
+ f"registered apps the node would refuse: {sorted(keys - allowed)}")
+
+
+# ── One contract, every pane ────────────────────────────────────────────────
+
+@pytest.mark.parametrize("pane", PANES)
+def test_every_pane_takes_the_same_props(pane):
+ """
+ A pane that reached for something else would make the loop that renders
+ them conditional, which is the same thing as the page naming apps again.
+ """
+ source = (STATIC / pane).read_text(encoding="utf-8")
+ m = re.search(r"function \w+Settings\(\{([^}]*)\}\)", source)
+ assert m, f"{pane}: no settings component with a destructured props object"
+ props = {p.strip() for p in m.group(1).split(",") if p.strip()}
+ allowed = {"roots", "dirs", "settings", "saveDirectories",
+ "transport", "signFn"}
+ assert props <= allowed, (
+ f"{pane} takes props outside the shared contract: "
+ f"{sorted(props - allowed)}")
+
+
+@pytest.mark.parametrize("pane", PANES)
+def test_no_pane_imports_the_page_that_renders_it(pane):
+ """
+ `group-settings` → `apps` → a pane → `group-settings` is a cycle, and ES
+ modules answer it with a temporal-dead-zone ReferenceError at first render
+ rather than an import error — the component simply does not appear. That
+ is why the shared widgets live in `settings-ui.js`.
+ """
+ source = (STATIC / pane).read_text(encoding="utf-8")
+ assert "group-settings.js" not in source, (
+ f"{pane} imports the page that renders it — that is an import cycle")
+
+
+@pytest.mark.parametrize("pane", PANES)
+def test_every_pane_owns_its_own_busy_state(pane):
+ """
+ One shared flag would disable every section while any one of them saves,
+ and attribute one section's error message to another.
+ """
+ source = (STATIC / pane).read_text(encoding="utf-8")
+ assert "useSaver()" in source
+
+
+# ── The folder picker ───────────────────────────────────────────────────────
+
+def test_the_picker_asks_the_node_for_nothing():
+ """
+ The tree is built from paths the client already holds. A fetch here would
+ be a folder-browsing protocol, which this deliberately is not: what it
+ shows is what the group's index contains, and a folder the node never
+ indexed does not exist as far as the group is concerned.
+ """
+ source = FOLDER_TREE.read_text(encoding="utf-8")
+ for forbidden in ("hubFetch", "fetch(", "platform.node", "transport."):
+ assert forbidden not in source, (
+ f"folder-tree.js reaches for {forbidden} — it is meant to be "
+ f"derived from the index the client already has")
+
+
+def test_a_read_only_root_cannot_be_chosen_as_a_destination():
+ """
+ Chat's attachment folder is the one directory that gets written to, and
+ the node refuses a read-only root for it. Letting the picker offer one
+ would move that refusal to the moment somebody sends a file.
+ """
+ source = FOLDER_TREE.read_text(encoding="utf-8")
+ picker = _component(source, "FolderTreePicker")
+ assert "requireWritable" in picker
+ assert "root.writable" in picker, (
+ "writability is not consulted when deciding what is selectable")
+
+ chat = (STATIC / "chat-app-settings.js").read_text(encoding="utf-8")
+ assert "requireWritable=${true}" in chat, (
+ "Chat's directory picker does not require a writable root")
+
+
+def test_the_picker_can_clear_a_selection():
+ """
+ Confirming with nothing chosen is how an app's directories are unset, and
+ an OK disabled on an empty selection would make that impossible without
+ another control.
+ """
+ picker = _component(FOLDER_TREE.read_text(encoding="utf-8"),
+ "FolderTreePicker")
+ ok = picker[picker.index("folder_tree.confirm") - 400:
+ picker.index("folder_tree.confirm")]
+ assert "disabled" not in ok
+
+
+# ── The generic op ──────────────────────────────────────────────────────────
+
+def test_the_directory_op_is_signed_and_names_its_app():
+ """
+ An operator shown "Media/Films" alone cannot tell which application is
+ about to be pointed at it, and two apps' challenges would be
+ indistinguishable — so the app is in the signed subject, and both sides
+ build it the same way.
+ """
+ transport = TRANSPORT.read_text(encoding="utf-8")
+ body = transport[transport.index("async setAppDirectories("):]
+ body = body[:body.index("\n async ", 1)]
+ assert "admin_challenge" in body and "_authorizeAdminOp" in body
+ assert "${appKey}:${clean.join(',')}" in body
+
+ node = NODE_SERVER
+ if node.exists():
+ assert 'f"{app}:{\',\'.join(clean)}"' in node.read_text(encoding="utf-8"), (
+ "the node builds a different subject than the client signs")
+
+
+def test_the_page_performs_exactly_one_app_specific_operation():
+ """
+ Pointing an app at folders is what every app has, so the page does it.
+ Anything one app alone needs — a TMDB key, a link-preview switch — the
+ pane does with the transport it is given. An app that only wants
+ directories therefore touches neither file.
+ """
+ panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "GroupSettingsPanel")
+ calls = set(re.findall(r"transport\.(set\w+)\(", panel))
+ # The page's own settings, which belong to no app: which apps are enabled
+ # at all, and how hard the node works watching its disk.
+ page_level = {"setAppsEnabled", "setScanSettings"}
+ # Both are the same generic operation; the second is what a node too old
+ # for it understands, chosen by version rather than by app.
+ generic = {"setAppDirectories", "setAppDirectoriesLegacy"}
+ assert calls - page_level == generic, (
+ f"the settings page performs app-specific operations: "
+ f"{sorted(calls - page_level - generic)}")
+
+
+# ── The apps read a list ────────────────────────────────────────────────────
+
+@pytest.mark.parametrize("app,prop", [
+ ("video-app.js", "videoDirectories"),
+ ("music-app.js", "musicDirectories"),
+ ("photos-app.js", "photoDirectories"),
+])
+def test_each_app_takes_a_list_of_directories(app, prop):
+ """
+ Videos and Music took a single folder, so a library spread over two drives
+ could not be described at all — the operator's only recourse was to point
+ the app at a parent containing both, which pulls in everything else too.
+ """
+ source = (STATIC / app).read_text(encoding="utf-8")
+ assert prop in source
+ for singular in ("videoRoot", "audioRoot"):
+ assert singular not in source, (
+ f"{app} still reads {singular} — one shape per idea")
+
+
+def test_an_older_node_still_fills_the_lists():
+ """
+ A node speaking MNP 1.0 sends `video_root`, not `video_directories`.
+ Reading the missing plural as "nothing configured" would empty a working
+ Videos tab on every group hosted by a node that has not been upgraded.
+ """
+ page = GROUP_PAGE.read_text(encoding="utf-8")
+ block = page[page.index("setAppDirectories({"):]
+ block = block[:block.index("setChatDirectory")]
+ assert "ack.video_root" in block and "ack.audio_root" in block
+ assert "ack.photo_roots" in block
+
+
+def test_the_search_cache_reads_both_shapes():
+ """
+ The cross-group index cache lives in IndexedDB and outlives a deploy, so a
+ reader opening Search after this ships still has entries written by the
+ previous version. Reading only the new shape empties their results with
+ nothing to distinguish it from "nothing matched".
+ """
+ source = (STATIC / "search-page.js").read_text(encoding="utf-8")
+ fn = _component(source, "cachedDirs")
+ assert "legacyKey" in fn
+ assert "videoRoot" in source and "audioRoot" in source and "photoRoots" in source
+
+
+# ── Where a result is reported ──────────────────────────────────────────────
+
+def test_the_directory_result_is_reported_below_the_controls():
+ """
+ It was rendered first, between the section's intro and the table header —
+ above everything the eye had already moved past by the time it appeared.
+ It belongs under the button that caused it, which is the last thing in the
+ section.
+ """
+ table = _component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "SharedDirectoriesTable")
+ # Two of them: the early return for a group with no directories yet, and
+ # the real one. Both report, and both must report last — keyed on the
+ # wrapper rather than on `return`, of which there are more.
+ blocks = table.split('<div class="shared-directories-table">')[1:]
+ assert len(blocks) == 2, "the section's shape changed; this test is stale"
+
+ for block in blocks:
+ assert block.index("${addControls}") < block.index("${message}"), (
+ "the result is rendered above the Add button rather than below it")
+
+ with_table = next(b for b in blocks if "<table" in b)
+ assert with_table.index("<table") < with_table.index("${message}"), (
+ "the result is rendered above the table")
+
+
+def test_a_refusal_does_not_look_like_a_footnote():
+ """
+ Every message here was a `settings-hint` — dim grey body text — so "two
+ roots would both be called uploads" read as an aside about the section
+ rather than as the reason nothing happened.
+ """
+ table = _component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "SharedDirectoriesTable")
+ block = table[table.index("const message ="):]
+ block = block[:block.index("`;") + 2]
+ assert "error-msg" in block
+ assert "role=" in block and "alert" in block, (
+ "a refusal that appears after a click has to be announced, not just "
+ "drawn")
+
+
+# ── Saving, and being able to tell ──────────────────────────────────────────
+
+PANE_FLAGS = {"chat-app-settings.js": "dirty",
+ "video-app-settings.js": "dirsDirty",
+ "music-app-settings.js": "dirsDirty",
+ "photos-app-settings.js": "dirty"}
+
+
+@pytest.mark.parametrize("pane", sorted(PANE_FLAGS))
+def test_save_is_a_button_and_not_dim_text(pane):
+ """
+ It was `btn btn-small btn-secondary`, and there is no `.btn` rule in the
+ stylesheet at all — so it took `.btn-secondary`: no background, a
+ transparent border, dim grey text. Enabled it already looked like a
+ disabled control, and disabled it was that at 40% opacity.
+
+ "You cannot always click Save, you do not notice, and it does not work" is
+ one sentence describing all of that.
+ """
+ source = (STATIC / pane).read_text(encoding="utf-8")
+ assert 'class="app-save"' in source, f"{pane}'s Save is not the shared control"
+ assert "btn-secondary" not in source, (
+ f"{pane}'s Save is still styled as dim text")
+
+
+def test_the_disabled_state_is_visually_distinct():
+ css = (STATIC / "style.css").read_text(encoding="utf-8")
+ rule = css[css.index(".app-save {"):]
+ rule = rule[:rule.index("}", rule.index(".app-save:disabled")) + 1]
+ assert "var(--accent)" in rule, "an enabled Save has no fill"
+ disabled = rule[rule.index(".app-save:disabled"):]
+ assert "background: none" in disabled and "--text-dim" in disabled, (
+ "disabled differs from enabled by opacity alone, which is what made "
+ "it unreadable")
+
+
+def test_a_saved_setting_reaches_the_page_that_renders_the_pane():
+ """
+ Why Chat was the systematic case.
+
+ `_dispatch` resolves an admin ack against the pending request and returns —
+ right for an op whose caller already knows the value it chose. Chat's pane
+ calls `transport.setChatDirectory` itself, so nothing told `group-page`
+ anything: the node saved it, every *other* connected client learned it from
+ the broadcast, and the one that asked went on showing an unsaved-looking
+ draft. Clicking Save again just re-sent it.
+ """
+ transport = TRANSPORT.read_text(encoding="utf-8")
+ block = transport[transport.index("const BROADCAST_ACK_TYPES"):]
+ block = block[:block.index("]);") + 3]
+ for ack in ("chat_directory_ack", "chat_link_preview_ack",
+ "app_directories_ack"):
+ assert ack in block, f"{ack} is swallowed by its own request"
+
+ assert "_replayBroadcast" in transport
+ replay = transport[transport.index("function _replayBroadcast"):]
+ replay = replay[:replay.index("\n}") + 2]
+ for cb in ("_onChatDirectory", "_onChatLinkPreview", "_onAppDirectories"):
+ assert cb in replay, f"{cb} is never called for the requester"
diff --git a/packages/meshbay-hub/tests/test_css_variables.py b/packages/meshbay-hub/tests/test_css_variables.py
new file mode 100644
index 0000000..ec576d6
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_css_variables.py
@@ -0,0 +1,78 @@
+"""
+Every `var(--x)` names a variable this stylesheet defines.
+
+CSS fails silently and generously: an unknown custom property makes the whole
+declaration invalid, and the rule around it still applies. So a panel written
+`background: var(--bg-panel)` when the palette calls it `--bg-surface` does not
+error, does not warn, and does not look obviously wrong in a diff — it just has
+no background, and the page shows straight through the modal.
+
+That is not hypothetical. It shipped in the folder picker, and the same file
+already carried one from before: `.notif-badge` asked for `--danger` where the
+palette says `--error`, so the unread count was white text on nothing. Found by
+a person looking at a screenshot, which is the only thing that was going to
+find it.
+
+A fallback (`var(--x, #ef4444)`) is a lesser version of the same mistake: the
+declaration is valid and renders, but the name is still fiction, and the next
+reader is told a variable exists that does not. Those are reported separately.
+
+There was a third check here, comparing the dark palette against the light one
+for anything a theme must not inherit. It fired on `--border-focus`, which is
+a focus ring deliberately shared by both themes — correct code. A heuristic
+that has to be explained away on its first run is worse than no test, so it is
+gone rather than exempted.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+STYLE = (Path(__file__).resolve().parents[1] / "src" / "meshbay_hub"
+ / "static" / "style.css")
+
+pytestmark = pytest.mark.skipif(not STYLE.exists(),
+ reason="the stylesheet is not in this checkout")
+
+# Where a custom property is defined. Two forms, and both had to be learned
+# the hard way while writing this: a scoped one written inline
+# (`.video-overview-wrap { --ov-lh: 1.5em; --ov-lines: 3; }`), which an
+# anchored pattern misses, and one preceded by an explanatory comment, which a
+# `[{;]`-prefixed pattern misses because the character before it is `/`. Either
+# mistake reports correct code as broken, which is the fastest way to have a
+# test like this ignored.
+DEFINE = re.compile(r"(?:^|[{;])\s*(--[A-Za-z0-9_-]+)\s*:", re.M)
+# `var(--name` and `var(--name, fallback`.
+USE = re.compile(r"var\(\s*(--[A-Za-z0-9_-]+)\s*(,)?")
+
+
+def _text() -> str:
+ return STYLE.read_text(encoding="utf-8")
+
+
+def test_every_variable_used_without_a_fallback_is_defined():
+ source = _text()
+ defined = set(DEFINE.findall(source))
+ assert defined, "no custom properties found — did the palette move?"
+
+ missing = sorted({name for name, fallback in USE.findall(source)
+ if not fallback and name not in defined})
+ assert not missing, (
+ "used but never defined, so every declaration naming one of these is "
+ "invalid and silently does nothing:\n " + "\n ".join(missing))
+
+
+def test_a_fallback_does_not_excuse_an_unknown_name():
+ """
+ `var(--danger, #ef4444)` renders, so it is not the same bug — but it is the
+ same mistake, and it will read as intentional to the next person. Reported
+ so the name gets corrected rather than the fallback relied on.
+ """
+ source = _text()
+ defined = set(DEFINE.findall(source))
+ guessed = sorted({name for name, fallback in USE.findall(source)
+ if fallback and name not in defined})
+ assert not guessed, (
+ "used with a fallback but not defined anywhere — rename to the real "
+ "variable:\n " + "\n ".join(guessed))
diff --git a/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py b/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py
new file mode 100644
index 0000000..a1baf92
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py
@@ -0,0 +1,159 @@
+"""
+The reference application, and what it is for.
+
+`docs/refactor-groups.md` claims that adding an application costs a registry
+entry and the app's own files — no op, no MNP message, no route, no edit to the
+pages that render it. Every other test of that claim reads source for the
+*absence* of app names, which proves nobody wrote a special case for Videos. It
+cannot prove that a genuinely new app works, because there was no new app.
+
+HelloWorld is one. It stores directories, appears as a tab, has a settings pane
+and lists files, and the node has never heard its name outside one allow-list
+entry. The assertions below are the claim, stated as things that must stay true
+of a file that was not written for it.
+
+It ships hidden behind `?dev=1` (apps.js's `dev: true`). Registering it
+normally would put a toy app in every operator's group; not registering it
+would prove nothing, since registration is exactly the thing being claimed as
+sufficient.
+
+**Two honest exceptions**, both found *by* adding it and both fixed by making
+the code less app-specific rather than more:
+
+* `group-settings.js` fell back to the whole registry when a group had no
+ `enabled_apps` yet, which would have turned a hidden app on for everyone. It
+ asks `availableApps()` now.
+* `group-page.js` wrote out `videoDirectories` / `musicDirectories` /
+ `photoDirectories` by hand. It derives `<key>Directories` from the registry
+ now, which is what made the claim true rather than nearly true.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+NODE_SRC = (Path(__file__).resolve().parents[2] / "meshbay-node" / "src"
+ / "meshbay_node")
+
+APP = STATIC / "helloworld-app.js"
+SETTINGS = STATIC / "helloworld-app-settings.js"
+
+pytestmark = pytest.mark.skipif(not APP.exists(),
+ reason="the reference app is not in this checkout")
+
+
+def _code_only(source: str) -> str:
+ source = re.sub(r"/\*.*?\*/", "", source, flags=re.S)
+ return re.sub(r"^\s*//.*$", "", source, flags=re.M)
+
+
+# ── The claim ────────────────────────────────────────────────────────────────
+
+@pytest.mark.parametrize("name", [
+ "group-page.js", "group-settings.js", "files-app.js", "transport.js",
+ "hub-client.js", "settings-ui.js", "folder-tree.js",
+])
+def test_no_shared_client_file_mentions_it(name):
+ """
+ The registry is where an app is named, and nowhere else. A branch on
+ `'helloworld'` in any of these would mean the architecture works for four
+ apps somebody wrote plumbing for.
+ """
+ source = _code_only((STATIC / name).read_text(encoding="utf-8"))
+ assert "helloworld" not in source.lower(), (
+ f"{name} names the reference app — adding an application is supposed "
+ f"to touch nothing here")
+
+
+@pytest.mark.parametrize("name", [
+ "ops.py", "roster.py", "config.py", "roots.py",
+])
+def test_no_shared_node_module_mentions_it(name):
+ """
+ Its directories are stored by `ops.set_app_directories`, which keys the row
+ by whatever the app is called. Nothing on the node knows what it is.
+ """
+ source = (NODE_SRC / name).read_text(encoding="utf-8")
+ assert "helloworld" not in source.lower(), (
+ f"{name} names the reference app; the generic path was supposed to "
+ f"cover it")
+
+
+def test_the_node_names_it_once_and_only_in_the_allow_list():
+ """
+ `ALLOWED_APPS` is server-side enforcement — a client naming an app this
+ node does not know is refused — so an app absent from it could not
+ demonstrate anything. That entry plus the client's registry line is the
+ whole cost.
+ """
+ source = (NODE_SRC / "transport" / "webrtc_server.py").read_text(
+ encoding="utf-8")
+ code = re.sub(r"^\s*#.*$", "", source, flags=re.M)
+ hits = [ln for ln in code.splitlines() if "helloworld" in ln.lower()]
+ assert len(hits) == 1, f"expected one mention, got: {hits}"
+ assert "ALLOWED_APPS" in hits[0] or "helloworld" in hits[0]
+ assert "ALLOWED_APPS" in code[:code.index("helloworld") + 200]
+
+
+def test_the_registry_entry_is_ordinary():
+ source = (STATIC / "apps.js").read_text(encoding="utf-8")
+ entry = source[source.index("key: 'helloworld'"):]
+ entry = entry[:entry.index("},") + 2]
+ for field in ("icon:", "labelKey:", "Component:", "Settings:"):
+ assert field in entry, f"the entry has no {field}"
+ assert "dev: true" in entry, "it would ship to every operator"
+
+
+# ── And it is held to the same contract as the rest ──────────────────────────
+
+def test_its_settings_pane_takes_the_shared_props_and_no_others():
+ m = re.search(r"function \w+Settings\(\{([^}]*)\}\)",
+ SETTINGS.read_text(encoding="utf-8"))
+ assert m
+ props = {p.strip() for p in m.group(1).split(",") if p.strip()}
+ assert props <= {"roots", "dirs", "settings", "saveDirectories",
+ "transport", "signFn"}
+
+
+def test_it_reads_its_directories_under_its_own_key():
+ """
+ `<key>Directories` — the shape `group-page.js` derives for every registered
+ app. An app reading a name spelled anywhere else would need that place
+ edited too.
+ """
+ for path in (APP, SETTINGS):
+ assert "helloworldDirectories" in path.read_text(encoding="utf-8")
+
+
+def test_it_does_not_reach_for_the_transport():
+ """
+ It has no third-party service and no setting of its own, so it needs
+ neither — which is the case an app author most often starts from, and the
+ one the architecture has to make free.
+ """
+ source = _code_only(SETTINGS.read_text(encoding="utf-8"))
+ assert "transport." not in source
+ assert "saveDirectories" in source
+
+
+# ── Hidden, but genuinely registered ────────────────────────────────────────
+
+def test_a_dev_app_is_filtered_out_by_default():
+ source = (STATIC / "apps.js").read_text(encoding="utf-8")
+ assert "function availableApps()" in source
+ body = source[source.index("function availableApps()"):]
+ body = body[:body.index("\n}") + 2]
+ assert "devAppsShown()" in body and "a.dev" in body
+
+
+def test_nothing_falls_back_to_the_unfiltered_registry():
+ """
+ A fallback of "every app in the registry" would enable a hidden one for the
+ whole group. This is the exception the reference app found.
+ """
+ source = _code_only((STATIC / "group-settings.js").read_text(encoding="utf-8"))
+ assert "APPS.map(" not in source and "APPS.filter(" not in source, (
+ "group-settings.js reads the raw registry; it should ask "
+ "availableApps()")
diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py
index f292b3e..01516bd 100644
--- a/packages/meshbay-hub/tests/test_hook_ordering.py
+++ b/packages/meshbay-hub/tests/test_hook_ordering.py
@@ -37,6 +37,13 @@ STATIC_FILES = [
"video-player.js", "video-app.js", "music-app.js", "music-player.js",
"photos-app.js",
"group-settings.js",
+ # The per-app settings architecture (docs/refactor-groups.md §3). Reached
+ # through the apps.js registry rather than imported by name, so a file
+ # left out of this list is one nothing checks — the failure is silent.
+ "settings-ui.js", "folder-tree.js",
+ "chat-app-settings.js", "video-app-settings.js",
+ "music-app-settings.js", "photos-app-settings.js",
+ "helloworld-app.js", "helloworld-app-settings.js",
"auth-page.js", "explore-page.js", "create-group-page.js",
]
diff --git a/packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py b/packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py
new file mode 100644
index 0000000..63390af
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py
@@ -0,0 +1,175 @@
+"""
+The page ships before the nodes do.
+
+The SPA is served by the hub, so deploying the hub puts this version of the
+client in front of *every* node, including the ones still running MNP 1.0. That
+window is not a corner case — it is the normal state for as long as it takes an
+operator to update, and for a node someone else runs it may be indefinite.
+
+The failure mode is specific and quiet: a node logs an unknown message type and
+sends **nothing back**, so a control that speaks MNP 1.1 to it produces a
+thirty-second wait ending in a timeout, with nothing on screen to say the node
+simply cannot do this. Three of them were like that before these tests:
+
+* Files' Upload button read `root.writable`, which a 1.0 node does not send —
+ it says `upload`. The button disappeared on every un-upgraded node.
+* The shared-directories toggles, eject and plug have no older equivalent at
+ all.
+* The per-app folder pickers spoke `app_directories`, where a 1.0 node
+ understands `video_root` / `audio_root` / `photo_roots`.
+
+Source-reading, like the other SPA guards. What it cannot check is that the
+degraded path is pleasant; what it does check is that each of the three exists.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+TRANSPORT = STATIC / "transport.js"
+FILES_APP = STATIC / "files-app.js"
+GROUP_PAGE = STATIC / "group-page.js"
+GROUP_SETTINGS = STATIC / "group-settings.js"
+
+pytestmark = pytest.mark.skipif(not TRANSPORT.exists(),
+ reason="SPA sources unavailable")
+
+
+def _component(source: str, name: str) -> str:
+ start = source.index(f"\nfunction {name}(")
+ end = source.find("\nfunction ", start + 1)
+ return source[start:end if end != -1 else len(source)]
+
+
+# ── Knowing which node you are talking to ───────────────────────────────────
+
+def test_the_client_keeps_the_version_it_checked():
+ """
+ `_checkNodeVersion` parsed the node's version and threw it away, so nothing
+ downstream could ask. Refusing to connect is not the only thing a version
+ is good for.
+ """
+ source = TRANSPORT.read_text(encoding="utf-8")
+ assert "this._nodeVersion = String(reply.v" in source
+ assert "get supportsAppOps()" in source
+
+
+def test_the_capability_reads_the_version_rather_than_guessing():
+ """
+ Inferring it from whether some field happens to be present is how two
+ unrelated things end up coupled — the flag would flip because a payload
+ changed shape for another reason entirely.
+ """
+ source = TRANSPORT.read_text(encoding="utf-8")
+ getter = source[source.index("get supportsAppOps()"):]
+ getter = getter[:getter.index("\n }") + 4]
+ assert "_nodeVersion" in getter
+ assert "1" in getter, "no version comparison in the capability check"
+
+
+# ── The three degraded paths ────────────────────────────────────────────────
+
+def test_the_upload_button_reads_the_older_flag_too():
+ """
+ A 1.0 node's roots carry `upload`; `writable` is the same answer renamed.
+ Reading only the new name hides the Upload button on every node that has
+ not been updated, which on the day the page ships is all of them.
+ """
+ page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel")
+ decl = page[page.index("const currentRootWritable"):]
+ decl = decl[:decl.index(";") + 1]
+ assert "currentRoot.upload" in decl, (
+ "the Upload button ignores the flag an older node actually sends")
+ assert "writable !== undefined" in decl, (
+ "a root that is explicitly writable=false must stay read-only — "
+ "falling through to `upload` there would reopen it")
+
+
+def test_app_directories_fall_back_to_the_three_older_messages():
+ """
+ Videos, Music and Photos each had their own message before the generic op,
+ and those still work — so an operator on an un-upgraded node keeps the
+ ability they had rather than being handed a control that times out.
+ """
+ source = TRANSPORT.read_text(encoding="utf-8")
+ legacy = source[source.index("async setAppDirectoriesLegacy("):]
+ legacy = legacy[:legacy.index("\n async ", 1)]
+ for call in ("setPhotoRoots", "setVideoRoot", "setAudioRoot"):
+ assert call in legacy, f"{call} is not reachable on the older path"
+
+ panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "GroupSettingsPanel")
+ assert "transport.supportsAppOps" in panel, (
+ "the settings page sends the 1.1 message unconditionally")
+
+
+def test_the_older_path_refuses_what_it_cannot_carry():
+ """
+ `video_root` and `audio_root` hold one folder. Sending several would store
+ the first and drop the rest silently, which is worse than refusing — the
+ operator would see a saved setting that is not what they chose.
+ """
+ source = TRANSPORT.read_text(encoding="utf-8")
+ legacy = source[source.index("async setAppDirectoriesLegacy("):]
+ legacy = legacy[:legacy.index("\n async ", 1)]
+ assert "clean.length > 1" in legacy
+ assert "throw new Error" in legacy
+
+
+def test_root_management_is_read_only_against_an_older_node():
+ """
+ Unlike the app directories, `writable`, `removable`, eject and plug have no
+ older equivalent to route to. The controls are shown without being
+ offered, with the reason, rather than accepting a click that goes nowhere.
+ """
+ panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "GroupSettingsPanel")
+ table_call = panel[panel.index("<${SharedDirectoriesTable}"):]
+ table_call = table_call[:table_call.index("/>")]
+ assert "readOnly=" in table_call
+ assert "nodeSupportsAppOps" in table_call
+ assert "settings_node.roots_node_too_old" in panel, (
+ "nothing says why the controls are inert")
+
+
+def test_chat_settings_are_hidden_rather_than_routed():
+ """
+ Chat's directory and link-preview switch are new in 1.1 with nothing
+ before them, so there is no older message to fall back to.
+ """
+ panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "GroupSettingsPanel")
+ assert "settings_node.app_node_too_old" in panel
+
+
+# ── Reading an older node's handshake ───────────────────────────────────────
+
+def test_the_ack_is_read_in_both_shapes(app=None):
+ """
+ A 1.0 ack has `video_root` and no `video_directories`, and no
+ `chat_link_preview` at all. Reading a missing plural as "nothing
+ configured" empties a working Videos tab; reading a missing switch as off
+ silently changes what a group's chat does.
+ """
+ page = GROUP_PAGE.read_text(encoding="utf-8")
+ block = page[page.index("setAppDirectories({"):]
+ block = block[:block.index("setNodeSupportsAppOps")]
+ for legacy in ("ack.video_root", "ack.audio_root", "ack.photo_roots"):
+ assert legacy in block, f"{legacy} is not read as a fallback"
+ assert "ack.chat_link_preview !== false" in page, (
+ "an absent link-preview switch must read as on, not off")
+
+
+def test_the_attachment_root_falls_back_to_the_older_answer():
+ """
+ A 1.0 node's roots carry no `writable`, so nothing looks writable and the
+ paperclip would vanish. The group-wide `member_upload` flag is the only
+ answer such a node gives, and it is what gets used.
+ """
+ page = GROUP_PAGE.read_text(encoding="utf-8")
+ block = page[page.index("const writableRoots"):]
+ block = block[:block.index("const commonProps")]
+ assert "legacyNode" in block and "memberUpload" in block
+ assert "writable === undefined" in block
diff --git a/packages/meshbay-hub/tests/test_no_prompt_in_the_spa.py b/packages/meshbay-hub/tests/test_no_prompt_in_the_spa.py
new file mode 100644
index 0000000..d126a05
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_no_prompt_in_the_spa.py
@@ -0,0 +1,90 @@
+"""
+`window.prompt` does not exist in the desktop client.
+
+The same `static/` tree is the web page and the application (CLAUDE.md's "one
+UI source"), and Electron does not implement `prompt` — Chromium leaves it to
+the embedder and Electron declines. It does not return null: it **throws**,
+`Error: prompt() is not supported.`
+
+That made the Files toolbar's New folder button do nothing whatsoever. The call
+sat above its own try, so the click produced no folder, no error, and nothing
+on screen to react to — the failure looks exactly like a dead button, which is
+what it was reported as.
+
+Measured rather than assumed, against this repo's own Electron 44:
+
+ prompt('name?') -> Error: prompt() is not supported.
+ confirm('sure?') -> opens a real modal
+ alert('hi') -> opens a real modal
+
+So `confirm` and `alert` stay allowed and are used in a dozen places; only
+`prompt` is banned. Anything that needs typed input needs a field.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+
+pytestmark = pytest.mark.skipif(not STATIC.exists(),
+ reason="SPA sources unavailable")
+
+# `prompt(` as a call, not `window.prompt` inside a comment or a longer
+# identifier like `mkdir_prompt` / `promptForName`.
+CALL = re.compile(r"(?<![\w.$])(?:window\.)?prompt\s*\(")
+
+
+def _code_only(source: str) -> str:
+ source = re.sub(r"/\*.*?\*/", "", source, flags=re.S)
+ return re.sub(r"^\s*//.*$", "", source, flags=re.M)
+
+
+def test_nothing_calls_prompt():
+ offenders = []
+ for path in sorted(STATIC.glob("*.js")):
+ if path.name == "sw.js":
+ continue
+ for i, line in enumerate(_code_only(
+ path.read_text(encoding="utf-8")).splitlines(), 1):
+ if CALL.search(line):
+ offenders.append(f"{path.name}:{i}: {line.strip()}")
+
+ assert not offenders, (
+ "prompt() throws in the desktop client, and the click that reaches it "
+ "does nothing at all:\n " + "\n ".join(offenders))
+
+
+def test_the_pattern_would_catch_a_real_call():
+ """
+ A guard that matches nothing passes over an empty set, which looks exactly
+ like success. Both spellings, and the near-misses it must not flag.
+ """
+ assert CALL.search("const n = prompt('x');")
+ assert CALL.search("const n = window.prompt('x');")
+ assert not CALL.search("t('group.mkdir_prompt')")
+ assert not CALL.search("promptForName();")
+ assert not CALL.search("this.prompt(1);")
+
+
+def test_creating_a_folder_uses_a_field():
+ """
+ The control the ban is about. A typed name needs somewhere to type it, and
+ an inline field can show the node's refusal beside the input rather than
+ after a dialog has closed.
+ """
+ source = (STATIC / "files-app.js").read_text(encoding="utf-8")
+ assert "tb-mkdir-input" in source
+ assert "newDirName" in source
+ assert "group.mkdir_prompt" in source, "the field has no label or placeholder"
+
+
+def test_leaving_the_folder_drops_a_half_typed_name():
+ """
+ Otherwise the folder is created where the person is no longer looking —
+ they navigated away, the draft came along, and the name lands in a
+ directory they were not thinking about.
+ """
+ source = (STATIC / "files-app.js").read_text(encoding="utf-8")
+ assert "useEffect(() => { setNewDirName(null); }, [currentPath]);" in source
diff --git a/packages/meshbay-hub/tests/test_search_media_merge.py b/packages/meshbay-hub/tests/test_search_media_merge.py
index 0312c57..65f85aa 100644
--- a/packages/meshbay-hub/tests/test_search_media_merge.py
+++ b/packages/meshbay-hub/tests/test_search_media_merge.py
@@ -71,12 +71,12 @@ def pipeline():
return "\n".join([
"const t = (k) => k;",
EXPORT.sub("", MERGE.read_text()),
- _block(VIDEO_APP, "function underVideoRoot(entry, videoRoot) {"),
+ _block(VIDEO_APP, "function underVideoRoot(entry, directories) {"),
_block(VIDEO_APP, "function buildSeasons(episodes) {"),
- _block(VIDEO_APP, "function groupVideoEntries(entries, videoRoot) {"),
+ _block(VIDEO_APP, "function groupVideoEntries(entries, videoDirectories) {"),
_block(MUSIC_APP, "function foldKey(s) {"),
- _block(MUSIC_APP, "function underAudioRoot(entry, audioRoot) {"),
- _block(MUSIC_APP, "function groupMusicEntries(entries, audioRoot) {"),
+ _block(MUSIC_APP, "function underAudioRoot(entry, directories) {"),
+ _block(MUSIC_APP, "function groupMusicEntries(entries, musicDirectories) {"),
_block(PHOTOS_APP, "function underAnyPhotoRoot(entry, photoRoots) {"),
_block(PHOTOS_APP, "function groupPhotoAlbums(entries, photoRoots) {"),
_const("SEARCH_VIDEO_ROOT"),
@@ -107,7 +107,7 @@ def _grid(tmp_path, pipeline, entries, salt="reader", local=()):
salt: {json.dumps(salt)},
isLocal: (g) => local.has(g),
}});
- const {{ movies, shows }} = groupVideoEntries(merged, SEARCH_VIDEO_ROOT);
+ const {{ movies, shows }} = groupVideoEntries(merged, [SEARCH_VIDEO_ROOT]);
console.log(JSON.stringify({{
movies: movies.map((e) => ({{
id: e.id, title: e.display_title || e.name, groupId: e.groupId,
@@ -136,7 +136,7 @@ def _albums(tmp_path, pipeline, entries, salt="reader", local=()):
salt: {json.dumps(salt)},
isLocal: (g) => local.has(g),
}});
- const {{ albums, tracks }} = groupMusicEntries(merged, SEARCH_AUDIO_ROOT);
+ const {{ albums, tracks }} = groupMusicEntries(merged, [SEARCH_AUDIO_ROOT]);
console.log(JSON.stringify({{
albums: albums.map((a) => ({{
artist: a.artist, album: a.album,
diff --git a/packages/meshbay-hub/tests/test_spa_imports.py b/packages/meshbay-hub/tests/test_spa_imports.py
new file mode 100644
index 0000000..230f33f
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_spa_imports.py
@@ -0,0 +1,87 @@
+"""
+Every import in the SPA points at a file that exports what it names.
+
+The failure this catches has a distinctive shape: nothing errors at build time,
+because there is no build; the browser resolves the module graph at load, finds
+a binding that is not there, and the page renders blank or the component simply
+does not appear. `node --check` cannot see it — it parses one file at a time —
+and neither can the source-reading guards, which look inside a file rather than
+between two.
+
+Written after the settings-page split, which moved two shared components into a
+new module and rewired eight files to import them from there. That is exactly
+the change where a rename lands in one file and not the other.
+
+It is a static check, not a load: it says the name is exported, not that the
+value is what the caller expects. `test_spa_syntax` covers parsing;
+`test_hook_ordering` covers the ordering fault that also presents as a missing
+component.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+
+pytestmark = pytest.mark.skipif(not STATIC.exists(),
+ reason="SPA sources unavailable")
+
+# `import { a, b as c } from './x.js';` / `import * as p from ...` / default.
+IMPORT = re.compile(
+ r"^import\s+(?:\{([^}]*)\}|(\*\s+as\s+\w+)|(\w+))\s+from\s+'([^']+)';", re.M)
+EXPORT_BLOCK = re.compile(r"^export\s+\{([^}]*)\};", re.M)
+EXPORT_DECL = re.compile(
+ r"^export\s+(?:default\s+)?(?:async\s+)?(?:function|const|class|let|var)\s+(\w+)",
+ re.M)
+
+
+def _exported(source: str) -> set[str]:
+ names = set(EXPORT_DECL.findall(source))
+ for block in EXPORT_BLOCK.findall(source):
+ for raw in block.split(","):
+ name = raw.strip().split(" as ")[-1].strip()
+ if name:
+ names.add(name)
+ return names
+
+
+def test_every_named_import_resolves():
+ problems: list[str] = []
+ for path in sorted(STATIC.glob("*.js")):
+ source = path.read_text(encoding="utf-8")
+ for names, star, default, spec in IMPORT.findall(source):
+ # vendor/ is third-party, bundled, and does not use a form this
+ # reads. Its exports are covered by the app failing to start.
+ if not spec.startswith("./") or "vendor/" in spec:
+ continue
+ target = (path.parent / spec[2:]).resolve()
+ if not target.exists():
+ problems.append(f"{path.name}: imports {spec} — no such file")
+ continue
+ if star or default or not names.strip():
+ continue
+ available = _exported(target.read_text(encoding="utf-8"))
+ for raw in names.split(","):
+ name = raw.strip().split(" as ")[0].strip()
+ if name and name not in available:
+ problems.append(
+ f"{path.name}: imports {{{name}}} from {spec}, "
+ f"which does not export it")
+
+ assert not problems, "unresolved imports:\n" + "\n".join(problems)
+
+
+def test_the_check_can_see_a_real_module():
+ """
+ Guard against the parser quietly matching nothing — a regex that stopped
+ finding imports would make the test above pass over an empty set, which
+ looks exactly like success.
+ """
+ source = (STATIC / "apps.js").read_text(encoding="utf-8")
+ found = IMPORT.findall(source)
+ assert len(found) >= 5, (
+ "the import pattern no longer matches apps.js — this test is then "
+ "checking nothing")
+ assert "configurableApps" in _exported(source)
diff --git a/packages/meshbay-hub/tests/test_spa_syntax.py b/packages/meshbay-hub/tests/test_spa_syntax.py
new file mode 100644
index 0000000..352f84b
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_spa_syntax.py
@@ -0,0 +1,85 @@
+"""
+Every SPA module parses.
+
+This is the cheapest possible test and the suite did not have it, which is how
+a `${/* ... */''}` — htm template syntax, pasted into a plain object literal —
+reached a committed file. Nothing else here would catch it: the source-reading
+guards (`test_hook_ordering`, `test_transport_contracts`, `test_spa_ordering`)
+match patterns in text that parses or does not, and the browser harnesses only
+load the few modules they need.
+
+**`node --check foo.js` is not the check.** It reports success on exactly the
+file above: given a `.js` extension it makes its own decision about how to
+parse, and a module-syntax error inside one can come back clean. Copying to
+`.mjs` first is what forces the module parser, and it is the difference
+between a green run and a real one — the same shape as the "a test that models
+a fix agrees with it by construction" note in CLAUDE.md, one level lower.
+
+It says nothing about names, imports resolving, or hooks being in order. Those
+have their own tests. This one only says the file is JavaScript.
+"""
+
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not STATIC.exists(),
+ reason="node or the SPA sources are not available")
+
+
+def _modules() -> list[Path]:
+ # vendor/ is third-party and shipped as-is; sw.js is a service worker, a
+ # classic script by definition, and transport.js is loaded with a plain
+ # <script> tag for the same historical reason (see its own header).
+ files = sorted(STATIC.glob("*.js")) + sorted((STATIC / "locales").glob("*.js"))
+ return [f for f in files if f.name not in ("sw.js",)]
+
+
+def test_every_module_parses(tmp_path):
+ broken: list[str] = []
+ for path in _modules():
+ # The .mjs copy is the whole point — see this module's docstring.
+ copy = tmp_path / (path.stem + ".mjs")
+ copy.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")
+ proc = subprocess.run(["node", "--check", str(copy)],
+ capture_output=True, text=True)
+ if proc.returncode != 0:
+ first = (proc.stderr or "").strip().splitlines()
+ detail = next((ln for ln in first if "Error" in ln), first[:1] and first[0] or "")
+ broken.append(f"{path.name}: {detail}")
+
+ assert not broken, "SPA modules that do not parse:\n" + "\n".join(broken)
+
+
+def test_the_check_would_notice_a_broken_file(tmp_path):
+ """
+ The test above passing means nothing unless it can fail, and the way it
+ fails is the interesting part: this same content in a file called `.js`
+ is reported as fine.
+ """
+ bad = "export const a = {\n ${/* not object syntax */''}\n b: 1,\n};\n"
+
+ as_js = tmp_path / "sample.js"
+ as_js.write_text(bad, encoding="utf-8")
+ lenient = subprocess.run(["node", "--check", str(as_js)],
+ capture_output=True, text=True)
+
+ as_mjs = tmp_path / "sample.mjs"
+ as_mjs.write_text(bad, encoding="utf-8")
+ strict = subprocess.run(["node", "--check", str(as_mjs)],
+ capture_output=True, text=True)
+
+ assert strict.returncode != 0, (
+ "the .mjs check no longer reports a module syntax error — this whole "
+ "test is then measuring nothing")
+ if lenient.returncode == 0:
+ # Recorded rather than asserted: this is a Node behaviour, and it
+ # improving would be good news, not a failure. The .mjs copy stays
+ # either way, because relying on the loose path is what let this
+ # through once already.
+ pass
diff --git a/packages/meshbay-hub/tests/test_transfers.py b/packages/meshbay-hub/tests/test_transfers.py
index 86d58cd..3316615 100644
--- a/packages/meshbay-hub/tests/test_transfers.py
+++ b/packages/meshbay-hub/tests/test_transfers.py
@@ -208,9 +208,17 @@ def test_the_colour_is_defined_for_that_mark():
# ── The file list ───────────────────────────────────────────────────────────
def test_a_folder_name_carries_no_trailing_slash():
- """The folder icon in the cell beside it already says what it is."""
+ """
+ The folder icon in the cell beside it already says what it is.
+
+ Anchored on `key=${full}`, which is the row that renders a directory. The
+ first `dir-row` in the file is the ".." row added later, whose only cell is
+ an ellipsis — slicing from there found no `${d}` and failed on a name it
+ had never looked at.
+ """
source = STATIC.joinpath("files-app.js").read_text(encoding="utf-8")
- row = source[source.index('class="file-row dir-row"'):]
- row = row[:row.index("</tr>")]
+ start = source.rindex("<tr", 0, source.index("key=${full}"))
+ row = source[start:source.index("</tr>", start)]
+ assert "dir-row" in row, "the anchor no longer lands on the directory row"
assert "${d}/" not in row, "the folder name is rendered with a trailing slash"
assert "${d}" in row
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
index fee80bb..b462242 100644
--- a/packages/meshbay-hub/tests/test_transport_contracts.py
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -33,6 +33,16 @@ SPLIT_FILES = [APP, GROUP_PAGE, CHAT_APP, STATIC / "files-app.js",
STATIC / "music-app.js", STATIC / "music-player.js",
STATIC / "photos-app.js",
STATIC / "group-settings.js",
+ # Same reason as test_hook_ordering's STATIC_FILES: these are
+ # reached through the registry, so leaving one out here means it
+ # is simply never checked.
+ STATIC / "settings-ui.js", STATIC / "folder-tree.js",
+ STATIC / "chat-app-settings.js",
+ STATIC / "video-app-settings.js",
+ STATIC / "music-app-settings.js",
+ STATIC / "photos-app-settings.js",
+ STATIC / "helloworld-app.js",
+ STATIC / "helloworld-app-settings.js",
STATIC / "auth-page.js", STATIC / "explore-page.js",
CREATE_GROUP]
diff --git a/packages/meshbay-hub/tests/test_upload_controls_hidden.py b/packages/meshbay-hub/tests/test_upload_controls_hidden.py
index 94917d2..f6f476e 100644
--- a/packages/meshbay-hub/tests/test_upload_controls_hidden.py
+++ b/packages/meshbay-hub/tests/test_upload_controls_hidden.py
@@ -1,14 +1,22 @@
"""
-When the operator closes uploading, the controls go — both of them.
+When a directory is read-only, the controls that write to it go — both of them.
-There are two ways to put a file into a group and they are in different
+There are two ways to put a file into a group and they live in different
components: the Upload button in the Files toolbar, and the paperclip in the
chat composer. Hiding one and forgetting the other is the obvious mistake, and
-the second one is the easier to forget because it does not look like an upload.
+the paperclip is the easier to forget because it does not look like an upload.
Nothing here is a security property. **The node refuses the upload** — that is
-`test_member_upload_policy.py` in the node package. This is about not offering
-somebody a button whose only outcome is an error message.
+`test_root_writable_policy.py` and `test_security_regressions.py` in the node
+package. This is about not offering somebody a button whose only outcome is an
+error message.
+
+What the RO/RW refactor changed: there is no group-wide answer any more. Files
+uploads into *the root being browsed*, so its button follows that root's
+`writable`. Chat has no folder on screen, so the shell picks one for it. The
+two therefore read different things on purpose, and the tests below pin that
+each reads the right one — a stronger claim than the old "both read one
+boolean", which is why that assertion is gone rather than adapted.
"""
import re
@@ -17,10 +25,6 @@ from pathlib import Path
import pytest
STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
-# The group-page refactor split what used to be one app.js into one file per
-# "application" plus the group shell. mayUpload itself is still derived once,
-# in the shell (group-page.js) — Files and Chat each moved to their own file
-# and receive it as a prop, the same shape ChatPanel already took.
APP = STATIC / "app.js"
GROUP_PAGE = STATIC / "group-page.js"
FILES_APP = STATIC / "files-app.js"
@@ -36,45 +40,133 @@ def app() -> str:
return GROUP_PAGE.read_text(encoding="utf-8")
-def _component(app: str, name: str) -> str:
- start = app.index(f"\nfunction {name}(")
- end = app.find("\nfunction ", start + 1)
- return app[start:end if end != -1 else len(app)]
+def _component(source: str, name: str) -> str:
+ start = source.index(f"\nfunction {name}(")
+ end = source.find("\nfunction ", start + 1)
+ return source[start:end if end != -1 else len(source)]
# ── Both controls ───────────────────────────────────────────────────────────
def test_the_files_toolbar_hides_its_upload_button():
+ """
+ Gated on the root being browsed, not on a group-wide answer: with one
+ writable root and one read-only one, a single boolean would offer the
+ button in both and produce a refusal in one of them.
+ """
page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel")
toolbar = page[page.index("file-toolbar"):]
toolbar = toolbar[:toolbar.index("breadcrumbs")]
- assert "mayUpload &&" in toolbar, "the Upload button is offered regardless"
+ assert "currentRootWritable" in toolbar, (
+ "the Upload button is offered regardless of the directory's own flag")
+
+
+def test_the_files_upload_button_is_not_offered_at_the_top_of_a_group():
+ """
+ The top level is the set of roots, which is the operator's configuration
+ and not a directory on anyone's disk. There is nothing to upload *into*
+ there, and no root name to give the node.
+ """
+ page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel")
+ toolbar = page[page.index("file-toolbar"):]
+ toolbar = toolbar[:toolbar.index("breadcrumbs")]
+ assert "currentPath &&" in toolbar
+
+
+def test_the_new_folder_button_follows_the_same_rule_as_upload():
+ """
+ Both write to the operator's disk, so both need a writable root — the node
+ refuses either otherwise. It used to require `isNodeAdmin`, which
+ contradicted the node ("a member who can add a file can organise where it
+ goes") and hid the control from everyone who could have used it.
+ """
+ page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel")
+ decl = page[page.index("const canCreateDir"):]
+ decl = decl[:decl.index(";") + 1]
+ assert "currentRootWritable" in decl
+ assert "currentPath" in decl, (
+ "the top of a group is the set of roots, not a directory to create in")
+ assert "isNodeAdmin" not in decl
+
+
+def test_an_icon_only_button_still_says_what_it_is():
+ """
+ The name moved into a tooltip to save toolbar width. A `title` is invisible
+ to a screen reader on a button with no text, so the label has to be there
+ as well — otherwise the control is simply unnamed for anyone not reading
+ with their eyes. The same goes for the field it opens, which has a
+ placeholder and no visible label.
+ """
+ page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel")
+ opener = page[page.index("canCreateDir && newDirName === null"):]
+ opener = opener[:opener.index("</button>")]
+ assert "title=" in opener and "aria-label=" in opener
+ assert "group.mkdir" in opener
+
+ field = page[page.index("canCreateDir && newDirName !== null"):]
+ field = field[:field.index("</span>")]
+ assert "aria-label=" in field, (
+ "the name field is labelled by a placeholder alone, which a screen "
+ "reader does not announce as a name")
def test_the_chat_composer_hides_its_paperclip():
chat = _component(CHAT_APP.read_text(encoding="utf-8"), "ChatPanel")
composer = chat[chat.index("chat-input-row"):]
- assert "mayUpload &&" in composer, (
+ assert "attachRoot ?" in composer, (
"the chat attachment is the second way in and is still offered")
-def test_both_read_the_same_answer(app):
- """Two derivations would eventually disagree, and the disagreement would
- be one of them offering an upload the node refuses."""
- assert re.search(r"const mayUpload = memberUpload \|\| isNodeAdmin;", app), (
- "mayUpload is no longer derived in one place")
- # Files and Chat both receive it from the same `commonProps` object the
- # shell spreads into whichever app tab is active — one derivation feeding
- # one object, rather than two hand-written prop attributes that could
- # drift apart.
+def test_the_paperclip_says_why_rather_than_vanishing():
+ """
+ A control that disappears leaves the reader no way to find out what would
+ bring it back. A group with no writable directory is a state an operator
+ can fix, so it is worth naming.
+ """
+ chat = _component(CHAT_APP.read_text(encoding="utf-8"), "ChatPanel")
+ composer = chat[chat.index("chat-input-row"):]
+ assert "chat.attach_read_only" in composer
+
+
+# ── One derivation, in the shell ────────────────────────────────────────────
+
+def test_the_attachment_directory_is_decided_once(app):
+ """
+ Two derivations would eventually disagree, and the disagreement would be
+ one of them offering an upload the node refuses.
+ """
+ assert re.search(r"const attachRoot = ", app), (
+ "attachRoot is no longer derived in one place")
props = app[app.index("const commonProps = {"):app.index("return html`")]
- assert "mayUpload," in props or "mayUpload:" in props, (
- "mayUpload is not in the shared props object every app receives")
+ assert "attachRoot," in props or "attachRoot:" in props, (
+ "attachRoot is not in the shared props object every app receives")
-def test_the_operator_keeps_their_own_controls(app):
- assert "memberUpload || isNodeAdmin" in app, (
- "turning uploads off would hide the operator's own upload button")
+def test_an_unavailable_root_is_not_offered_as_a_destination(app):
+ """
+ `writable` is configuration and stays true while a drive is unplugged or
+ ejected. Offering it anyway produces a refusal from the node with no
+ explanation on screen.
+ """
+ block = app[app.index("const writableRoots"):]
+ block = block[:block.index("const attachRoot")]
+ assert "available" in block
+
+
+def test_files_uploads_into_the_root_it_is_showing():
+ """
+ The client has to name the destination now, because the node cannot choose
+ between several writable roots without guessing — and a guess here means a
+ file landing in a directory nobody was looking at.
+ """
+ page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel")
+ upload = page[page.index("const uploadFile"):]
+ upload = upload[:upload.index("const makeDirectory")]
+ assert "dir: uploadDir" in upload, "the node is left to choose the folder"
+ assert "const uploadDir = currentPath" in upload, (
+ "the destination is not the folder on screen")
+ assert "root: uploadRoot" in upload, (
+ "a node too old for `dir` reads `root`, and gets nothing without it")
# ── Learning the answer ─────────────────────────────────────────────────────
@@ -82,54 +174,105 @@ def test_the_operator_keeps_their_own_controls(app):
def test_the_answer_comes_from_the_node(app):
"""Not from the hub, which has no say in what may be written to someone
else's disk, and no way to be believed about it."""
- assert "ack.member_upload !== false" in app, (
- "the handshake ack is what carries this")
- assert "hubFetch" not in app[app.index("ack.member_upload") - 400:
- app.index("ack.member_upload")]
+ assert "if (indexMsg.roots) setNodeRoots(indexMsg.roots)" in app, (
+ "the roots table in the index payload is what carries this")
+ idx = app.index("setNodeRoots(indexMsg.roots)")
+ assert "hubFetch" not in app[idx - 400:idx]
def test_an_older_node_is_treated_as_permissive(app):
- """A node that predates the setting sends no such field. Reading a missing
- field as "off" would close every group on the older half of the network."""
+ """
+ A node speaking MNP 1.0 sends roots with no `writable` at all, plus the old
+ group-wide flag. Reading a missing field as "read-only" would close every
+ group on the older half of the network.
+ """
+ assert "ack.member_upload !== false" in app
assert "!== false" in app[app.index("ack.member_upload"):
app.index("ack.member_upload") + 60]
+ block = app[app.index("const legacyNode"):]
+ block = block[:block.index("const commonProps")]
+ assert "writable === undefined" in block, (
+ "nothing distinguishes a 1.0 node from one with no writable roots")
def test_a_change_reaches_people_already_connected(app):
- """The operator may be someone else entirely, changing it while you have
- the group open. A button that survives until the next reconnection is a
- button somebody presses."""
- assert "transport.onUploadPolicy" in app
+ """
+ The operator may be someone else entirely, ejecting a drive while you have
+ the group open. A file list that survives until the next reconnection is a
+ list somebody clicks.
+ """
+ assert "transport.onRootsChanged" in app
transport = TRANSPORT.read_text(encoding="utf-8")
- assert "member_upload_ack" in transport, "nothing routes the node's notice"
+ assert "root_eject_ack" in transport, "nothing routes the node's notice"
-def test_the_notice_still_answers_the_operators_own_request(app):
- """The same message is both a broadcast and the reply to the request that
- caused it — returning early on it would leave that request hanging until it
- timed out."""
+def test_the_notice_also_answers_the_operators_own_request():
+ """
+ The same message is both a broadcast and the reply to the request that
+ caused it.
+
+ Every other admin ack can be resolved and dropped, because its caller
+ already knows what it asked for and updates local state from that. These
+ are the ones the node *broadcasts*: every other connected client learns the
+ change from it, and the one that asked is the only one that does not,
+ because its own request swallowed its copy. Found on the root table, then
+ again on Chat's directory — where it meant the pane went on showing an
+ unsaved-looking draft after a save that had worked.
+ """
transport = TRANSPORT.read_text(encoding="utf-8")
- # Scoped to member_upload_ack's own handler, not everything up to the next
- # occurrence of "index_sync" — other handlers with their own, legitimate
- # early `return` (index_progress, set_scan_settings_ack: neither is ever a
- # reply anyone awaits) now sit between the two in the file.
- block = transport[transport.index("member_upload_ack"):]
- block = block[:block.index("apps_enabled_ack")]
- assert "return" not in block
+ block = transport[transport.index("msg.type.endsWith('_ack')"):]
+ block = block[:block.index("_uploaders")]
+ assert "BROADCAST_ACK_TYPES" in block and "_replayBroadcast" in block, (
+ "the initiating client resolves the ack and learns nothing from it")
# ── Changing it ─────────────────────────────────────────────────────────────
-def test_changing_it_is_signed(app):
+def test_changing_a_root_is_signed():
transport = TRANSPORT.read_text(encoding="utf-8")
- method = transport[transport.index("async setMemberUpload("):]
- method = method[:method.index("\n async ", 1)]
- assert "admin_challenge" in method and "_authorizeAdminOp" in method, (
- "an unsigned instruction would let any member turn uploads back on")
+ for method in ("updateRoot", "ejectRoot", "plugRoot"):
+ body = transport[transport.index(f"async {method}("):]
+ body = body[:body.index("\n async ", 1)]
+ assert "admin_challenge" in body and "_authorizeAdminOp" in body, (
+ f"{method} is unsigned — any member could use it")
def test_only_the_operator_is_offered_the_setting():
- panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"), "GroupSettingsPanel")
- section = panel[panel.index("members.uploads_title") - 400:
- panel.index("members.uploads_title")]
- assert "isNodeAdmin && connected" in section
+ panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "GroupSettingsPanel")
+ section = panel[panel.index("settings_node.shared_directories_title") - 600:
+ panel.index("settings_node.shared_directories_title")]
+ assert "isNodeAdmin &&" in section
+
+
+def test_the_operator_is_offered_it_on_the_web_too():
+ """
+ An operator is not necessarily sitting at their node. The first version of
+ this section required the loopback API, which resolves to "not available"
+ in a browser — so it rendered for nobody on the web, while the upload
+ controls it replaced had worked there.
+ """
+ source = GROUP_SETTINGS.read_text(encoding="utf-8")
+ panel = _component(source, "GroupSettingsPanel")
+ section = panel[panel.index("settings_node.shared_directories_title") - 600:
+ panel.index("settings_node.shared_directories_title")]
+ assert "connected ||" in section, (
+ "the shared directories section still requires a local node")
+
+ table = _component(source, "SharedDirectoriesTable")
+ for call in ("transport.updateRoot", "transport.ejectRoot",
+ "transport.plugRoot", "transport.removeRoot",
+ "transport.addRoot"):
+ assert call in table, f"{call} has no MNP route from the table"
+
+
+def test_the_roots_shown_come_from_the_live_connection_when_there_is_one():
+ """
+ The loopback list is a second source, and the two drift: it is read once on
+ mount and after a change, while the MNP one is pushed. Preferring MNP also
+ keeps this table on the same data Files reads, so an eject shows in both at
+ the same instant.
+ """
+ panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "GroupSettingsPanel")
+ assert "const effectiveRoots = (connected && mnpRoots" in panel
diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py
index 0e5a7de..7673a51 100644
--- a/packages/meshbay-node/src/meshbay_node/config.py
+++ b/packages/meshbay-node/src/meshbay_node/config.py
@@ -89,25 +89,20 @@ transcode_incompatible_video = true
# A root's name is the directory's basename, and it becomes the first segment of
# every path members see: /home/user/Media appears to everyone as "Media/".
# Two roots cannot share a name (compared without regard to case), and no root
-# may sit inside another. Exactly one root receives uploads.
+# may sit inside another. A writable root accepts uploads from group members.
[[groups]]
id = "" # set after joining
name = "My Media"
quic_port = 19010
[[groups.roots]]
- path = "/home/user/Media"
- upload = true
+ path = "/home/user/Media"
+ writable = true
[[groups.roots]]
- path = "/run/media/user/USB/Musique" # an external drive is fine: if it is
- kind = "audio" # unplugged the root goes unavailable
- # and its files stay in the index,
- # rather than looking deleted
-
-# upload_dir: a separate directory for uploads. Files land directly in it,
-# not in an "uploads" subdirectory. It appears as its own root in the index.
-# upload_dir = "/home/user/Incoming"
+ path = "/run/media/user/USB/Musique"
+ kind = "audio"
+ removable = true # eject before unplugging
# The single-directory form still works and means the same thing — one root,
# named after the directory, receiving uploads.
@@ -187,8 +182,8 @@ class RootSpec:
path: str = ""
name: str = "" # empty → the directory's basename, derived at load
kind: str = "generic" # generic|video|audio|photo — a view hint, unused for now
- upload: bool = False # exactly one root per group receives uploads
- direct: bool = False # uploads land at root path, not in a subdirectory
+ writable: bool = False # RW roots accept uploads from group members
+ removable: bool = False # operator can eject this root before unplugging the device
@dataclass
@@ -201,7 +196,7 @@ class GroupConfig:
# unprefixed shape.
roots: list[RootSpec] = field(default_factory=list)
shared_dir: str = "" # legacy single-root form, migrated at load
- upload_dir: str = "" # separate filesystem path for uploads
+ upload_dir: str = "" # legacy — migrated to a writable root
visibility: str = "private" # public|private — discoverability, not admission
# Admission. "invite" (default) means a newcomer needs a one-time pairing code
# before the node wraps the group key for them; "open" means the node pins
@@ -224,12 +219,12 @@ class GroupConfig:
which reads like configuration rather than a bug.
"""
if not self.roots and self.shared_dir.strip():
- self.roots = [RootSpec(path=self.shared_dir.strip(), upload=True)]
+ self.roots = [RootSpec(path=self.shared_dir.strip(), writable=True)]
if self.upload_dir.strip():
for r in self.roots:
- r.upload = False
+ r.writable = False
self.roots.append(RootSpec(
- path=self.upload_dir.strip(), upload=True, direct=True))
+ path=self.upload_dir.strip(), writable=True))
@dataclass
@@ -285,15 +280,17 @@ def _read_roots(group: dict) -> list[RootSpec]:
than merged: which one receives uploads would be a guess, and a wrong guess
is discovered weeks later.
"""
- specs = [
- RootSpec(
+ specs = []
+ for r in group.get("roots", []) or []:
+ # Backward compat: old configs have `upload = true` instead of `writable`
+ writable = bool(r.get("writable", r.get("upload", False)))
+ specs.append(RootSpec(
path=str(r.get("path", "")),
name=str(r.get("name", "")),
kind=str(r.get("kind", "generic")),
- upload=bool(r.get("upload", False)),
- )
- for r in group.get("roots", []) or []
- ]
+ writable=writable,
+ removable=bool(r.get("removable", False)),
+ ))
legacy = str(group.get("shared_dir", "") or "").strip()
if specs and legacy:
log.warning(
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index ea13680..f9e992c 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -36,6 +36,7 @@ from pathlib import Path
import uvicorn
+from meshbay_common.paths import fold
from meshbay_common import MNP_VERSION
from meshbay_common.protocol import MNP
from meshbay_node.audit import AuditStore
@@ -69,26 +70,36 @@ if WEBRTC_AVAILABLE:
log = logging.getLogger(__name__)
-def _under_video_root(path: str, video_root: str) -> bool:
- """Mirrors video-app.js's underVideoRoot: same folder, or a descendant."""
- path = path or ""
- return path == video_root or path.startswith(video_root + "/")
-
+def _under_any_directory(path: str, directories: list[str]) -> bool:
+ """
+ Whether an entry's folder is one of an app's directories, or inside one.
-def _under_audio_root(path: str, audio_root: str) -> bool:
- """Mirrors music-app.js's underAudioRoot — same shape as _under_video_root."""
+ Mirrors `underAnyDirectory` in the SPA's app modules. One helper for every
+ app since they all take a list: Videos and Music used to take a single
+ folder and had a function each saying the same thing, which is how the two
+ came to differ in what they did with a trailing slash.
+ """
path = path or ""
- return path == audio_root or path.startswith(audio_root + "/")
+ return any(path == d or path.startswith(d + "/") for d in directories)
-def _under_any_photo_root(path: str, photo_roots: list[str]) -> bool:
+def _owning_directory(path: str, directories: list[str]) -> str | None:
"""
- Mirrors photos-app.js's underAnyPhotoRoot. Unlike video/audio's single
- root, photo_roots is a list (docs/photos.md §2.1) — a match against any
- one of them is enough.
+ Which of an app's directories an entry belongs to — the deepest match.
+
+ Deepest, because directories may nest: with both `Media` and
+ `Media/Albums` configured, a file under the second belongs to the second.
+ Taking the first match instead would measure it against a boundary one
+ level too shallow, which for Music is the difference between reading a
+ folder as an artist and reading it as a release.
"""
path = path or ""
- return any(path == r or path.startswith(r + "/") for r in photo_roots)
+ best: str | None = None
+ for d in directories:
+ if path == d or path.startswith(d + "/"):
+ if best is None or len(d) > len(best):
+ best = d
+ return best
# ── Argon2id calibration ──────────────────────────────────────────────────────
@@ -128,6 +139,11 @@ class _WsSender:
# ── Daemon ────────────────────────────────────────────────────────────────────
+def _root_shape(roots) -> set[tuple]:
+ """What has to match for a group's roots to count as unchanged on reload."""
+ return {(r.name, str(r.path), r.writable, r.removable) for r in roots}
+
+
class NodeDaemon:
def __init__(self, config: Config, config_path: Path = DEFAULT_CONFIG_PATH):
self._config = config
@@ -303,7 +319,7 @@ class NodeDaemon:
continue
try:
- roots = RootSet.build([asdict(r) for r in group_cfg.roots])
+ roots = await self._build_roots(group_cfg)
except RootError as e:
# Configuration the operator has to fix; guessing would put
# a member's file on the wrong disk or index one twice.
@@ -330,7 +346,7 @@ class NodeDaemon:
log.info("No GEK yet for group %s — will accept first setup",
group_cfg.name)
- # Read once at load, like member_upload/enabled_apps below —
+ # Read once at load, like enabled_apps below —
# kept current in place afterwards by set_scan_settings
# (ops.py), which updates both this indexer object directly
# and roster.db, so a restart picks up the same values.
@@ -347,6 +363,7 @@ class NodeDaemon:
sk_node=keys.sk_ed25519,
gek=gek,
on_change=self._on_index_change,
+ on_root_ejected=self._eject_persister(group_cfg.id),
cache=self._index_cache,
reconcile_secs=scan_settings["reconcile_interval_secs"],
debounce_secs=scan_settings["debounce_secs"],
@@ -374,37 +391,30 @@ class NodeDaemon:
"note_activity": indexer.note_activity,
# Shown to the operator in Settings, and kept current in
# place by set_scan_settings (ops.py) — same reasoning as
- # member_upload below.
+ # enabled_apps below.
"reconcile_interval_secs": scan_settings["reconcile_interval_secs"],
"debounce_secs": scan_settings["debounce_secs"],
"visibility": group_cfg.visibility,
# Admission policy comes from node.toml, never from the hub:
# a hub that could declare a group open would be handed its key.
"join_policy": group_cfg.join_policy,
- # Whether ordinary members may upload. Read once here, into
- # the context, because the upload handler is synchronous and
- # a database round trip per chunk would be absurd. The
- # signed operation that changes it updates this dict in
- # place, so the two never drift within a run.
- "member_upload": await self._roster.member_upload_allowed(
- group_cfg.id) if self._roster else True,
- # Same reasoning: read once at load, kept current in place
- # by the signed operation that changes it.
+ # Read once at load, kept current in place by the signed
+ # operation that changes it — the upload handler is
+ # synchronous and a database round trip per chunk would be
+ # absurd. (Whether a member may upload is not here any
+ # more: it is `writable` on the root being written to,
+ # which the RootSet above already carries.)
"enabled_apps": await self._roster.enabled_apps(
group_cfg.id) if self._roster else list(Roster.DEFAULT_APPS),
- # Which folder is the Videos app's entry point for this
- # group — "" means the whole group index.
- "video_root": await self._roster.video_root(
- group_cfg.id) if self._roster else "",
- # Same shape, Music app's own entry point.
- "audio_root": await self._roster.audio_root(
- group_cfg.id) if self._roster else "",
- # Photos app's entry points — a *list*, unlike video_root/
- # audio_root above (docs/photos.md §2.1: a photo library
- # is routinely scattered across several folders). Empty
- # list means nothing configured yet.
- "photo_roots": await self._roster.photo_roots(
- group_cfg.id) if self._roster else [],
+ # Which folder(s) inside the shared roots each app works
+ # over. One shape for every app (roster.py's
+ # app_directories) — an empty list means nothing has been
+ # chosen, which every app reads as "show nothing yet",
+ # never "the whole group index".
+ **(await self._app_directories_ctx(group_cfg.id)),
+ # Whether the node unfurls links members post here.
+ "chat_link_preview": await self._roster.chat_link_preview(
+ group_cfg.id) if self._roster else True,
# Whether TMDB lookups run for this group at all —
# per-group (2026-08-24, used to be node-wide), same
# "read once, kept current in place by the signed op"
@@ -627,9 +637,14 @@ class NodeDaemon:
self._state["quic_server"] = self._quic_server
self._state["hub"] = hub
self._state["reload_fn"] = self._reload_config
- self._state["enrich_video_root_fn"] = self._enrich_video_root_now
- self._state["enrich_audio_root_fn"] = self._enrich_audio_root_now
- self._state["enrich_photo_roots_fn"] = self._enrich_photo_roots_now
+ # Keyed by app, so `ops.set_app_directories` finds the right
+ # sweep without knowing which apps exist — an app with nothing to
+ # enrich simply has no entry.
+ self._state["enrich_app_dirs_fns"] = {
+ "video": self._enrich_video_root_now,
+ "music": self._enrich_audio_root_now,
+ "photo": self._enrich_photo_roots_now,
+ }
# Rotating a key has to reach every transport holding a copy of it,
# and clearing the denylist has to reach the one the handshake
# consults — so both are published rather than reachable only
@@ -745,14 +760,16 @@ class NodeDaemon:
if not ctx:
continue
try:
- roots = RootSet.build([asdict(r) for r in group_cfg.roots])
+ roots = await self._build_roots(group_cfg)
except RootError as e:
log.error("Group %r: %s — keeping the roots already loaded",
group_cfg.name, e)
continue
- before = {(r.name, str(r.path)) for r in ctx["roots"]}
- after = {(r.name, str(r.path)) for r in roots}
- if before == after:
+ # `writable` and `removable` are in the comparison because an
+ # operator editing node.toml by hand and reloading is a supported
+ # way to change them, and a set compared on name and path alone
+ # reports "nothing changed" for exactly that edit.
+ if _root_shape(ctx["roots"]) == _root_shape(roots):
continue
roots.refresh_availability()
indexer = next((i for i in self._indexers
@@ -786,7 +803,7 @@ class NodeDaemon:
continue
try:
- roots = RootSet.build([asdict(r) for r in group_cfg.roots])
+ roots = await self._build_roots(group_cfg)
except RootError as e:
log.error("New group %r: %s — skipping", group_cfg.name, e)
continue
@@ -812,6 +829,7 @@ class NodeDaemon:
sk_node=sk_ed,
gek=gek,
on_change=self._on_index_change,
+ on_root_ejected=self._eject_persister(group_cfg.id),
cache=self._index_cache,
reconcile_secs=scan_settings["reconcile_interval_secs"],
debounce_secs=scan_settings["debounce_secs"],
@@ -844,21 +862,13 @@ class NodeDaemon:
"debounce_secs": scan_settings["debounce_secs"],
"visibility": group_cfg.visibility,
"join_policy": group_cfg.join_policy,
- "member_upload": (
- await self._roster.member_upload_allowed(group_cfg.id)
- if self._roster else True),
"enabled_apps": (
await self._roster.enabled_apps(group_cfg.id)
if self._roster else list(Roster.DEFAULT_APPS)),
- "video_root": (
- await self._roster.video_root(group_cfg.id)
- if self._roster else ""),
- "audio_root": (
- await self._roster.audio_root(group_cfg.id)
- if self._roster else ""),
- "photo_roots": (
- await self._roster.photo_roots(group_cfg.id)
- if self._roster else []),
+ **(await self._app_directories_ctx(group_cfg.id)),
+ "chat_link_preview": (
+ await self._roster.chat_link_preview(group_cfg.id)
+ if self._roster else True),
"tmdb_enabled": (
await self._roster.tmdb_enabled(group_cfg.id)
if self._roster else True),
@@ -1052,6 +1062,65 @@ class NodeDaemon:
log.debug("Index progress pushed to %d peer(s) for group %s",
pushed, group_id[:8])
+ async def _build_roots(self, group_cfg) -> RootSet:
+ """
+ Build a group's RootSet from node.toml, with the ejected state restored.
+
+ node.toml carries configuration (`writable`, `removable`); the roster
+ carries the runtime answer to "is this drive ejected right now". They
+ are merged here, in the one place every caller goes through, because a
+ root that quietly comes back available across a restart is exactly the
+ surprise unplug that eject exists to survive.
+ """
+ specs = [asdict(r) for r in group_cfg.roots]
+ if self._roster:
+ ejected = await self._roster.ejected_roots(group_cfg.id)
+ if ejected:
+ for spec in specs:
+ name = spec.get("name") or Path(spec.get("path", "")).name
+ if fold(name) in ejected:
+ spec["ejected"] = True
+ return RootSet.build(specs)
+
+ # Every app that keeps directories. Not derived from `enabled_apps`: the
+ # context is read once at load and an app enabled later must not find its
+ # own setting missing. Adding an app adds a name here and nowhere else on
+ # this side.
+ APP_DIR_KEYS = ("video", "music", "photo", "chat", "helloworld")
+
+ async def _app_directories_ctx(self, group_id: str) -> dict:
+ """
+ Each app's configured directories, plus the legacy scalar names the
+ rest of the tree still reads.
+
+ The scalars are derived here rather than stored, so the two can never
+ disagree: `video_root` is the first of `video_directories` and exists
+ for MNP 1.0 clients and for the handful of call sites that predate the
+ list. A group with several video directories reports the first as its
+ `video_root` — which is what an old client can represent, and all it
+ could ever have shown.
+ """
+ dirs = {}
+ for app in self.APP_DIR_KEYS:
+ dirs[f"{app}_directories"] = (
+ await self._roster.app_directories(group_id, app)
+ if self._roster else [])
+ aliases = {}
+ for app in self.APP_DIR_KEYS:
+ alias = Roster.ctx_alias(app, dirs[f"{app}_directories"])
+ if alias:
+ aliases[alias[0]] = alias[1]
+ return {**dirs, **aliases}
+
+ def _eject_persister(self, group_id: str):
+ """`on_root_ejected` bound to one group, for that group's indexer."""
+ async def persist(root_name: str, ejected: bool) -> None:
+ if self._roster:
+ await self._roster.set_root_ejected(
+ group_id, root_name, ejected,
+ set_by=self._state.get("node_user_id", ""))
+ return persist
+
async def _on_index_change(self, indexer: DirectoryIndexer) -> None:
"""
Called when a DirectoryIndexer detects file changes — once per
@@ -1103,6 +1172,24 @@ class NodeDaemon:
# this broadcast — enrichment fields arrive later as their own
# INDEX_DELTA update (_on_enriched below).
new_entries = delta.additions if delta is not None else list(idx.entries)
+
+ # A root that was ejected and plugged back in, or that fell off and
+ # re-mounted, has had its entries thrown away and rebuilt from disk
+ # (`indexer._drop_root_entries`). The rebuilt entry has the same
+ # content-hash id and none of the enrichment fields, so the diff above
+ # reports neither an addition nor a deletion — and `_enriched_attempted`
+ # still says "done" for a file whose album and cover no longer exist.
+ # Found live: a Music library came back with its files and without its
+ # albums, and stayed that way, because only a restart (which starts
+ # with no snapshot, making every entry an addition) could clear either
+ # gate. Treated here as what it is — those entries are new again.
+ rebuilt_ids = indexer.drain_rescanned_ids()
+ if rebuilt_ids:
+ rebuilt = [e for e in idx.entries if e.id in rebuilt_ids]
+ for entry in rebuilt:
+ self._enriched_attempted.discard((group_id, entry.id))
+ seen = {e.id for e in new_entries}
+ new_entries = new_entries + [e for e in rebuilt if e.id not in seen]
asyncio.ensure_future(self._enrich_new_video_entries(indexer, new_entries))
# Music app (docs/musicbay.md §6): same shape, gated on audio_root
# exactly like video_root above (added later — musicbay.md's
@@ -1171,7 +1258,8 @@ class NodeDaemon:
# node refuses every handshake while the GEK is None (NS8) — so this is
# "nobody is listening", not a case to send in clear for.
if peers and idx.gek:
- msg = (index_delta_message(idx, delta) if delta is not None
+ msg = (index_delta_message(idx, delta, indexer.roots)
+ if delta is not None
else index_sync_message(idx, indexer.roots))
pushed = 0
for session in peers:
@@ -1216,13 +1304,13 @@ class NodeDaemon:
"""
if not self._enricher or not self._roster:
return
- video_root = await self._roster.video_root(indexer.group_id)
- if not video_root:
+ video_dirs = await self._roster.app_directories(indexer.group_id, "video")
+ if not video_dirs:
return
for entry in entries:
if entry.type != "video" or (indexer.group_id, entry.id) in self._enriched_attempted:
continue
- if not _under_video_root(entry.path, video_root):
+ if not _under_any_directory(entry.path, video_dirs):
continue
file_path = entry_abs_path(indexer.roots, entry)
if not file_path or not file_path.exists():
@@ -1297,14 +1385,18 @@ class NodeDaemon:
"""
if not self._audio_enricher or not self._roster:
return
- audio_root = await self._roster.audio_root(indexer.group_id)
- if not audio_root:
+ audio_dirs = await self._roster.app_directories(indexer.group_id, "music")
+ if not audio_dirs:
return
- root_boundary = indexer.roots.resolve(audio_root, require_available=False)
+ # Resolved once per directory, not once per file: a library is
+ # thousands of entries and this is a filesystem call each time.
+ boundaries = {d: indexer.roots.resolve(d, require_available=False)
+ for d in audio_dirs}
for entry in entries:
if entry.type != "audio" or (indexer.group_id, entry.id) in self._enriched_attempted:
continue
- if not _under_audio_root(entry.path, audio_root):
+ owner = _owning_directory(entry.path, audio_dirs)
+ if owner is None:
continue
file_path = entry_abs_path(indexer.roots, entry)
if not file_path or not file_path.exists():
@@ -1314,13 +1406,16 @@ class NodeDaemon:
async def on_done(file_id: str, fields: dict, _indexer=indexer) -> None:
await self._on_enriched(_indexer, file_id, fields)
- # `root_boundary` — audio_root itself, not the shared root it
- # lives under — so the ancestor walk
+ # The boundary is *the configured directory this file is under*,
+ # not the shared root it lives in — so the ancestor walk
# (enrich_audio._artist_album_from_ancestors) treats a flat
- # top-level folder right under the *configured* Music root as
- # ambiguous (artist-or-release, §2.1), not one level too shallow
- # if audio_root is itself a subfolder of a larger shared root.
- self._audio_enricher.spawn(entry, file_path, on_done, root_boundary)
+ # top-level folder right under the configured Music directory as
+ # ambiguous (artist-or-release, musicbay.md §2.1), rather than one
+ # level too shallow when that directory is itself a subfolder.
+ # With several configured, each file is measured against its own:
+ # a single shared boundary would be wrong for all but one of them.
+ self._audio_enricher.spawn(entry, file_path, on_done,
+ boundaries.get(owner))
async def _enrich_audio_root_now(self, group_id: str) -> None:
"""
@@ -1370,13 +1465,13 @@ class NodeDaemon:
"""
if not self._photo_enricher or not self._roster:
return
- photo_roots = await self._roster.photo_roots(indexer.group_id)
- if not photo_roots:
+ photo_dirs = await self._roster.app_directories(indexer.group_id, "photo")
+ if not photo_dirs:
return
for entry in entries:
if entry.type != "image" or (indexer.group_id, entry.id) in self._enriched_attempted:
continue
- if not _under_any_photo_root(entry.path, photo_roots):
+ if not _under_any_directory(entry.path, photo_dirs):
continue
file_path = entry_abs_path(indexer.roots, entry)
if not file_path or not file_path.exists():
@@ -1663,15 +1758,17 @@ def main() -> None:
parser = argparse.ArgumentParser(description="MeshBay Node daemon")
parser.add_argument("command", nargs="?",
choices=["init", "reset", "status", "gek-init",
- "gek", "operator", "member", "group", "file",
- "video", "denylist", "stun", "reload",
+ "gek", "operator", "member", "group", "root",
+ "file", "video", "denylist", "stun", "reload",
"restart-daemon", "autostart", "service",
"calibrate-argon2"],
help="init: provision config + keystore | reset: erase all "
"node state | status: node state and keys "
"| operator pair: pair a "
"browser with this node | member list|invite|revoke|unpin "
- "| group list|add|remove | gek init|rotate | file list|rm "
+ "| group list|add|remove "
+ "| root list|add|remove|set|eject|plug "
+ "| gek init|rotate | file list|rm "
"| video rematch: re-resolve TMDB matches for a group's "
"videos | denylist show|clear "
"| stun list|add|remove|reset "
@@ -1687,7 +1784,9 @@ def main() -> None:
"| calibrate-argon2: benchmark")
parser.add_argument("subcommand", nargs="?",
help="'pair' for operator; list|invite|revoke|unpin for "
- "member; list|add|remove for group; init|rotate for gek; "
+ "member; list|add|remove for group; "
+ "list|add|remove|set|eject|plug for root; "
+ "init|rotate for gek; "
"list|rm for file; rematch for video; show|clear for "
"denylist; list|add|remove|reset for stun; "
"install|remove|start|stop|status for autostart and "
@@ -1702,22 +1801,35 @@ def main() -> None:
help="hub username, for init")
parser.add_argument("--dir", default=None,
help="shared directory, for group add")
- parser.add_argument("--upload-dir", default=None,
- help="separate upload directory, for group add")
parser.add_argument("--yes", action="store_true",
help="skip the confirmation for destructive commands")
parser.add_argument("--config", type=Path, default=None,
help="Config file path")
parser.add_argument("--group", default=None,
help="group id (optional if only one is configured)")
+ parser.add_argument("--writable", action="store_true", default=None,
+ dest="writable",
+ help="root accepts member uploads (root add/set)")
+ parser.add_argument("--no-writable", action="store_false",
+ dest="writable",
+ help="root is read-only (root add/set, group add)")
+ parser.add_argument("--removable", action="store_true", default=None,
+ dest="removable",
+ help="mark root as removable (root set/add)")
+ parser.add_argument("--no-removable", action="store_false",
+ dest="removable",
+ help="mark root as not removable (root set)")
+ parser.add_argument("--name", default=None,
+ help="root name (root add; defaults to directory basename)")
parser.add_argument("--log-level", default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR"])
args = parser.parse_args()
# Query commands print a report; library logging would interleave with it.
quiet = args.command in ("status", "gek-init", "gek", "operator",
- "member", "group", "file", "video", "denylist",
- "stun", "reload", "restart-daemon", "reset")
+ "member", "group", "root", "file", "video",
+ "denylist", "stun", "reload", "restart-daemon",
+ "reset")
logging.basicConfig(
level=logging.ERROR if quiet else getattr(logging, args.log_level),
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
@@ -1961,9 +2073,16 @@ def main() -> None:
print(" <no directory configured>")
for r in g.roots:
label = r.name or Path(r.path).name
- flag = " (uploads)" if r.upload else ""
+ flags = []
+ if getattr(r, 'writable', False) or getattr(r, 'upload', False):
+ flags.append("rw")
+ else:
+ flags.append("ro")
+ if getattr(r, 'removable', False):
+ flags.append("removable")
+ flag_str = f" ({', '.join(flags)})" if flags else ""
live = "" if Path(r.path).expanduser().is_dir() else " [UNAVAILABLE]"
- print(f" {label} → {r.path}{flag}{live}")
+ print(f" {label} → {r.path}{flag_str}{live}")
# Node authority: the roster is the source of truth, node.toml the legacy
# form. Read the DB directly so this reports correctly while the daemon is
# stopped — the state an operator is most often in when checking.
@@ -2030,6 +2149,26 @@ def main() -> None:
f"expires {i['expires_at']}")
return
+ # `member upload` is gone: whether uploads are accepted is `writable`
+ # on the root they would land in, not a per-group switch. Named
+ # explicitly rather than left to the usage line below, which offered a
+ # username for a verb that no longer takes one — an operator following
+ # it would have got "unknown subcommand" and no idea what replaced it.
+ if sub == "upload":
+ print("`member upload` is gone. Uploads are decided per directory "
+ "now:")
+ print()
+ print(" meshbay-node root list "
+ "# which are read-write")
+ print(" meshbay-node root set <name> --writable "
+ "# accept uploads there")
+ print(" meshbay-node root set <name> --no-writable # stop them")
+ print()
+ print("A group whose directories are all read-only accepts no "
+ "uploads at all,")
+ print("which is what turning the old switch off meant.")
+ sys.exit(1)
+
if not args.target:
print(f"usage: meshbay-node member {sub} <username>")
sys.exit(1)
@@ -2408,11 +2547,18 @@ def main() -> None:
f"{g.get('peers', 0)} peer(s)")
print(f" {g['id']}")
for r in g.get("roots", []):
- flags = ""
- if r.get("upload"):
- flags = " (uploads, direct)" if r.get("direct") else " (uploads)"
+ flags = []
+ if r.get("writable"):
+ flags.append("rw")
+ else:
+ flags.append("ro")
+ if r.get("removable"):
+ flags.append("removable")
+ if r.get("ejected"):
+ flags.append("ejected")
+ flag_str = f" ({', '.join(flags)})" if flags else ""
live = "" if r.get("available", True) else " [UNAVAILABLE]"
- print(f" root {r['name']}{flags}{live}")
+ print(f" root {r['name']}{flag_str}{live}")
if not g.get("has_gek"):
print(f" give it a key: meshbay-node gek init "
f"--group {g['name']}")
@@ -2440,22 +2586,27 @@ def main() -> None:
print("usage: meshbay-node group list|add|remove <name>")
sys.exit(1)
if not args.target or not args.dir:
- print("usage: meshbay-node group add <name> --dir <path> [--upload-dir <path>]")
+ print("usage: meshbay-node group add <name> --dir <path> "
+ "[--no-writable]")
print()
print("The group must already exist on the hub and be yours. This")
- print("only tells the node to host it, and picks the directory.")
- print("--upload-dir sets a separate directory for uploaded files.")
+ print("only tells the node to host it, and picks its first")
+ print("directory, which accepts uploads unless --no-writable.")
+ print("Add more with: meshbay-node root add <path> [--writable]")
sys.exit(1)
cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
- body = {"name": args.target, "shared_dir": args.dir}
- if args.upload_dir:
- body["upload_dir"] = args.upload_dir
+ # Writable unless the operator says otherwise: a brand-new group that
+ # cannot receive a single file until its owner finds a second command
+ # is not a working group. Every root added *later* is read-only by
+ # default, which is the opposite rule and the right one there.
+ writable = args.writable is not False
+ body = {"name": args.target, "shared_dir": args.dir,
+ "writable": writable}
out = _daemon_api(cfg, "/api/groups/attach", method="POST", body=body)
print(f"{out['name']} ({out['group_id'][:8]}) added to {out['config']}")
- print(f" shared_dir {out['shared_dir']}")
- if out.get("upload_dir"):
- print(f" upload_dir {out['upload_dir']}")
+ print(f" shared_dir {out['shared_dir']}"
+ f" ({'read-write' if writable else 'read-only'})")
print()
print("Tell the daemon to re-read its config, then give the group a key:")
print(" meshbay-node reload")
@@ -2465,6 +2616,124 @@ def main() -> None:
print("read it, and joining one says nothing about the other.")
return
+ if args.command == "root":
+ cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
+ sub = args.subcommand or "list"
+ group_id = _resolve_group(cfg, args.group)
+
+ if sub == "list":
+ out = _daemon_api(cfg, "/api/groups")
+ group = next((g for g in out.get("groups", [])
+ if g["id"] == group_id), None)
+ if not group:
+ print(f"group {group_id[:8]} not hosted on this node")
+ sys.exit(1)
+ roots = group.get("roots", [])
+ if not roots:
+ print("no roots configured")
+ print(f"add one: meshbay-node root add /path/to/dir --group {group_id}")
+ return
+ for r in roots:
+ flags = []
+ if r.get("writable"):
+ flags.append("rw")
+ else:
+ flags.append("ro")
+ if r.get("removable"):
+ flags.append("removable")
+ if r.get("ejected"):
+ flags.append("EJECTED")
+ avail = "available" if r.get("available", True) else "UNAVAILABLE"
+ flags.append(avail)
+ print(f" {r['name']:<20} {', '.join(flags)}")
+ print(f" {r.get('path', '?')}")
+ return
+
+ if sub == "add":
+ path = args.target
+ if not path:
+ print("usage: meshbay-node root add <path> [--name NAME] "
+ "[--writable] [--removable] [--group NAME]")
+ sys.exit(1)
+ body = {
+ "path": path,
+ "name": args.name or Path(path).name,
+ "writable": args.writable if args.writable is not None else True,
+ "removable": bool(args.removable),
+ }
+ _daemon_api(cfg, f"/api/groups/{group_id}/roots",
+ method="POST", body=body)
+ w = "rw" if body["writable"] else "ro"
+ rm = ", removable" if body["removable"] else ""
+ print(f"added root {body['name']} → {path} ({w}{rm})")
+ print("reload the daemon to start indexing:")
+ print(" meshbay-node reload")
+ return
+
+ if sub == "remove":
+ name = args.target
+ if not name:
+ print("usage: meshbay-node root remove <name> [--group NAME]")
+ sys.exit(1)
+ if not args.yes:
+ print(f"Remove root '{name}' from group {group_id[:8]}?")
+ print("Files on disk are untouched; only the node config changes.")
+ if input("remove? [y/N] ").strip().lower() not in ("y", "yes"):
+ print("cancelled")
+ return
+ _daemon_api(cfg, f"/api/groups/{group_id}/roots/{name}",
+ method="DELETE")
+ print(f"removed root {name}")
+ print("reload the daemon to apply:")
+ print(" meshbay-node reload")
+ return
+
+ if sub == "set":
+ name = args.target
+ if not name:
+ print("usage: meshbay-node root set <name> "
+ "[--writable|--no-writable] "
+ "[--removable|--no-removable] [--group NAME]")
+ sys.exit(1)
+ body = {}
+ if args.writable is not None:
+ body["writable"] = args.writable
+ if args.removable is not None:
+ body["removable"] = args.removable
+ if not body:
+ print("nothing to change — pass --writable/--no-writable "
+ "or --removable/--no-removable")
+ sys.exit(1)
+ _daemon_api(cfg, f"/api/groups/{group_id}/roots/{name}",
+ method="PATCH", body=body)
+ changes = ", ".join(f"{k}={v}" for k, v in body.items())
+ print(f"updated root {name}: {changes}")
+ return
+
+ if sub == "eject":
+ name = args.target
+ if not name:
+ print("usage: meshbay-node root eject <name> [--group NAME]")
+ sys.exit(1)
+ _daemon_api(cfg, f"/api/groups/{group_id}/roots/{name}/eject",
+ method="PUT")
+ print(f"ejected root {name} — files are hidden until plugged back")
+ return
+
+ if sub == "plug":
+ name = args.target
+ if not name:
+ print("usage: meshbay-node root plug <name> [--group NAME]")
+ sys.exit(1)
+ _daemon_api(cfg, f"/api/groups/{group_id}/roots/{name}/plug",
+ method="PUT")
+ print(f"plugged root {name} — files are visible again")
+ return
+
+ print("usage: meshbay-node root list|add|remove|set|eject|plug [name] "
+ "[--group NAME]")
+ sys.exit(1)
+
if args.command == "operator":
if args.subcommand != "pair":
print("usage: meshbay-node operator pair")
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
index b0a8e50..33e7210 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
@@ -263,12 +263,17 @@ class DirectoryIndexer:
cache: IndexCache | None = None,
reconcile_secs: float = DEFAULT_RECONCILE_SECS,
debounce_secs: float = DEFAULT_DEBOUNCE_SECS,
+ on_root_ejected: Callable[[str, bool], Awaitable[None]] | None = None,
):
self.roots = roots
self.group_id = group_id
self.sk_node = sk_node
self.gek = gek
self.on_change = on_change
+ # Called with (root_name, ejected) whenever this indexer changes a
+ # root's ejected state by itself — the surprise-unplug safety net.
+ # The daemon writes it to the roster, so a restart does not undo it.
+ self.on_root_ejected = on_root_ejected
self.reconcile_secs = reconcile_secs
self.debounce_secs = debounce_secs
# Current backoff delay — starts at reconcile_secs, doubles on every
@@ -309,6 +314,14 @@ class DirectoryIndexer:
# stay up for exactly as long as the slow part (hashing) is running.
self._burst_inflight = 0
self._burst_sizes: dict[str, int] = {}
+ # Ids whose entry this indexer threw away and rebuilt from disk, since
+ # the last time a consumer drained this. A rebuilt entry carries only
+ # what `_hash_or_cached` fills in — every enrichment field the Videos,
+ # Music and Photos apps put there is gone — but its id is the file's
+ # content hash, so a diff against the last broadcast sees no addition
+ # and no deletion and nothing downstream can tell the fields were
+ # wiped. See `_drop_root_entries`.
+ self.rescanned_ids: set[str] = set()
@property
def index(self) -> GroupIndex:
@@ -582,11 +595,21 @@ class DirectoryIndexer:
changed = self.roots.refresh_availability()
touched = False
+ # Drained before the loop below, because persisting the flag is what
+ # makes the safety net survive a restart — and a restart is exactly
+ # what an operator does after noticing a drive fell off.
+ while self.roots.auto_ejected:
+ name = self.roots.auto_ejected.pop(0)
+ if self.on_root_ejected:
+ try:
+ await self.on_root_ejected(name, True)
+ except Exception:
+ log.exception("Could not persist the auto-eject of root %r", name)
+
for root, available in changed:
if available:
log.info("Root %r is back — rescanning", root.name)
- self._drop_root_entries(root)
- await self._scan_root(root)
+ await self._rescan_root(root)
touched = True
else:
# Frozen: entries stay, marked unavailable to members through
@@ -687,10 +710,71 @@ class DirectoryIndexer:
return [e for e in self._index.entries
if fold(e.path).split("/", 1)[0] == prefix]
+ # Everything on an IndexEntry that a scan does not produce. `_scan_root`
+ # fills id/name/path/size/type/added_at/hash_version from the file itself;
+ # every field below was derived by one of the enrichment passes and is
+ # nowhere on disk to be read back.
+ _ENRICHED_FIELDS = (
+ "duration", "thumb_hash", "width", "height",
+ "display_title", "season", "episode",
+ "artist", "album", "track_no", "taken_at", "camera",
+ "uploader_id", "uploader_pk",
+ )
+
+ async def _rescan_root(self, root: Root) -> int:
+ """
+ Rebuild one root's entries from disk, keeping what the files still say.
+
+ The two callers — `reconcile` when a root reappears, `plug_root` when
+ the operator plugs one back in — have to re-walk: the drive may have
+ changed while it was away. What they must not do is throw away the
+ enrichment. An entry's id is its content hash, so an entry that comes
+ back under the same id, name and path is the same bytes in the same
+ place, and every field the Videos, Music and Photos passes derived from
+ it still holds. Re-deriving them means minutes of tag reads, ffprobe
+ runs and rate-limited metadata lookups during which the operator's
+ library sits empty — which is exactly what a replug looked like.
+
+ Anything that does *not* match is left bare on purpose: a different id
+ is different content, and a different name or path can change the
+ filename and folder fallbacks that `display_title`, `track_no`,
+ `artist` and `album` fall back to. Those are the entries
+ `daemon._broadcast_index_change` re-enriches, off `rescanned_ids`.
+ """
+ carried = {(e.id, e.name, e.path): e for e in self._entries_under(root)}
+ self._drop_root_entries(root)
+ count = await self._scan_root(root)
+ for entry in self._entries_under(root):
+ old = carried.get((entry.id, entry.name, entry.path))
+ if old is None:
+ continue
+ for field in self._ENRICHED_FIELDS:
+ setattr(entry, field, getattr(old, field))
+ # It came back intact, so it is not one of the entries the daemon
+ # needs to enrich again.
+ self.rescanned_ids.discard(entry.id)
+ return count
+
def _drop_root_entries(self, root: Root) -> None:
+ """
+ Throw away a root's entries. Only ever called to rebuild them.
+
+ The ids are recorded because nothing outside can otherwise tell they
+ were rebuilt: no deletion is broadcast (the rescan is immediate) and
+ the entries come back under the same content-hash ids, so a diff
+ against the last broadcast reports neither an addition nor a deletion.
+ `_rescan_root` clears the ones it managed to carry over intact; what is
+ left is genuinely new to the apps and is re-enriched by the daemon.
+ """
for entry in self._entries_under(root):
+ self.rescanned_ids.add(entry.id)
self._index.remove_entry(entry.id)
+ def drain_rescanned_ids(self) -> set[str]:
+ """Take the ids rebuilt since the last call; leave the set empty."""
+ drained, self.rescanned_ids = self.rescanned_ids, set()
+ return drained
+
@staticmethod
def _entry_path(root: Root, entry: IndexEntry) -> Path | None:
_, _, tail = entry.path.partition("/")
@@ -723,6 +807,43 @@ class DirectoryIndexer:
self._observer = None
self._start_observer()
+ def eject_root(self, root_name: str) -> None:
+ """Stop watching a root without touching its entries."""
+ from meshbay_common.paths import fold
+ target = fold(root_name)
+ for root in self.roots:
+ if fold(root.name) == target:
+ root.ejected = True
+ root.available = False
+ frozen = len(self._entries_under(root))
+ log.info("Root %r ejected — %d entries frozen", root.name, frozen)
+ break
+ self._restart_observer()
+ self._index.roots = self.roots.describe()
+ self._index.version = int(time.time())
+
+ async def plug_root(self, root_name: str) -> None:
+ """Restart watching a previously ejected root and reconcile."""
+ from meshbay_common.paths import fold
+ target = fold(root_name)
+ root = None
+ for r in self.roots:
+ if fold(r.name) == target:
+ root = r
+ break
+ if root is None:
+ return
+ root.ejected = False
+ root.available = root.is_live()
+ if root.available:
+ log.info("Root %r plugged — rescanning", root.name)
+ await self._rescan_root(root)
+ self._restart_observer()
+ self._index.roots = self.roots.describe()
+ self._index.version = int(time.time())
+ if self.on_change:
+ await self.on_change(self)
+
# ── Internal update ───────────────────────────────────────────────────────
def _schedule_update(self, file_path: Path, deleted: bool = False) -> None:
diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py
index 4c20c2a..a10504e 100644
--- a/packages/meshbay-node/src/meshbay_node/ops.py
+++ b/packages/meshbay-node/src/meshbay_node/ops.py
@@ -362,7 +362,11 @@ async def list_groups(state: dict) -> dict:
"has_gek": bool(ctx.get("gek")),
"file_count": idx.count if idx else 0,
"index_version": idx.version if idx else 0,
- "roots": roots.describe() if roots else [],
+ # With paths: this answers the loopback API, which is the
+ # operator's own channel. `meshbay-node root list` printed "?" for
+ # every directory without it — it was reading a field the member
+ # form of this deliberately omits.
+ "roots": roots.describe(with_paths=True) if roots else [],
"peers": sum(1 for p in peers.values() if p.get("group_id") == gid),
})
roster = state.get("roster")
@@ -390,7 +394,7 @@ async def list_groups(state: dict) -> dict:
async def attach_group(state: dict, name: str, shared_dir: str,
- upload_dir: str = "") -> dict:
+ writable: bool = True) -> dict:
"""
Write a new [[groups]] block into node.toml.
@@ -429,31 +433,24 @@ async def attach_group(state: dict, name: str, shared_dir: str,
raise OpError(f"Cannot create {path}: {e}") from e
conf_path = Path(state.get("config_path") or DEFAULT_CONFIG_PATH)
- # Appended as text rather than re-serialised: node.toml is hand-written and
- # full of comments explaining decisions, and a round trip through a TOML
- # writer would throw all of that away.
join_policy = group.get("join_policy", "invite")
block = (f'\n[[groups]]\n'
f'id = "{group["id"]}"\n'
f'name = "{group["name"]}"\n'
f'visibility = "{group.get("visibility", "private")}"\n'
f'join_policy = "{join_policy}"\n')
- separate_upload = False
- if upload_dir:
- upload_path = Path(upload_dir).expanduser().resolve()
- if upload_path != path.resolve():
- separate_upload = True
- try:
- upload_path.mkdir(parents=True, exist_ok=True)
- except OSError as e:
- raise OpError(f"Cannot create {upload_path}: {e}") from e
- block += f'upload_dir = "{upload_path.as_posix()}"\n'
+ # No `upload_dir` here. `GroupConfig.__post_init__` still *reads* it, so an
+ # existing node.toml keeps working — but what it does on read is force every
+ # other root read-only and append that path as the one writable one, which
+ # is the model this refactor replaced. Writing it into a group created
+ # today would mean two mechanisms deciding the same thing, one of them
+ # invisible: `group add --dir X --writable --upload-dir Y` silently made X
+ # read-only. A second writable directory is `root add <path> --writable`.
block += (f'\n [[groups.roots]]\n'
# Forward slashes: a Windows path in a TOML basic string is a
# parse error (`\U`, `\a`, ... are escapes). pathlib reads `/`.
- f' path = "{path.as_posix()}"\n')
- if not separate_upload:
- block += f' upload = true\n'
+ f' path = "{path.as_posix()}"\n'
+ f' writable = {"true" if writable else "false"}\n')
try:
with conf_path.open("a", encoding="utf-8", newline="\n") as f:
f.write(block)
@@ -462,9 +459,8 @@ async def attach_group(state: dict, name: str, shared_dir: str,
result = {"group_id": group["id"], "name": group["name"],
"shared_dir": str(path), "config": str(conf_path),
+ "writable": writable,
"note": "restart the node to pick it up"}
- if separate_upload:
- result["upload_dir"] = str(upload_path)
return result
@@ -636,12 +632,13 @@ def _remove_roots_block(conf_path: Path, group_id: str,
conf_path.write_text("\n".join(new_lines), encoding="utf-8", newline="\n")
return
- raise OpError(f"Root path not found in config", status=404)
+ raise OpError("Root path not found in config", status=404)
async def add_root(state: dict, group_id: str, path: str, *,
name: str = "", kind: str = "generic",
- upload: bool = False) -> dict:
+ writable: bool = False,
+ removable: bool = False) -> dict:
"""
Add a directory to a group, refusing anything ambiguous.
@@ -655,7 +652,8 @@ async def add_root(state: dict, group_id: str, path: str, *,
raise OpError("Group not configured on this node", status=404)
specs = [asdict(r) for r in cfg.roots]
- specs.append({"path": path, "name": name, "kind": kind, "upload": upload})
+ specs.append({"path": path, "name": name, "kind": kind,
+ "writable": writable, "removable": removable})
try:
built = RootSet.build(specs)
except RootError as e:
@@ -674,15 +672,29 @@ async def add_root(state: dict, group_id: str, path: str, *,
root_block += f'\n name = "{added.name}"'
if kind != "generic":
root_block += f'\n kind = "{added.kind}"'
- if upload:
- root_block += f'\n upload = true'
+ if writable:
+ root_block += '\n writable = true'
+ if removable:
+ root_block += '\n removable = true'
_insert_roots_block(conf_path, group_id, root_block)
from meshbay_node.config import RootSpec
cfg.roots.append(RootSpec(
path=str(added.path), name=added.name, kind=added.kind,
- upload=added.upload, direct=added.direct))
+ writable=added.writable, removable=added.removable))
+ # Deliberately *not* mutating the live RootSet in place.
+ #
+ # `DirectoryIndexer.retarget` decides what to scan by diffing the names it
+ # already has against the ones it is given — so handing it the same object,
+ # edited, means the new root is in both sides of the comparison and is
+ # never scanned. It would appear in the table and stay permanently empty.
+ # `_reload_config_inner` diffs the same way and would likewise conclude
+ # nothing changed. The caller reloads instead, which builds a fresh set
+ # from the file this just wrote.
+ #
+ # `built` is that set, computed here only to validate and to answer with;
+ # what the node serves comes from the reload.
log.info("Root added: %s → group %s", added.name, group_id[:8])
return {"status": "added", "name": added.name, "path": str(added.path),
"group_id": group_id, "roots": built.describe()}
@@ -714,25 +726,245 @@ async def remove_root(state: dict, group_id: str, root_name: str) -> dict:
raise OpError("Cannot remove the only root", status=400)
removed = cfg.roots[match_idx]
- if removed.upload:
- raise OpError(
- "Cannot remove the upload root — file uploads and chat "
- "attachments are stored there", status=400)
resolved = str(Path(removed.path).expanduser().resolve())
conf_path = Path(state.get("config_path") or DEFAULT_CONFIG_PATH)
_remove_roots_block(conf_path, group_id, resolved)
cfg.roots.pop(match_idx)
- remaining = [asdict(r) for r in cfg.roots]
- try:
- built = RootSet.build(remaining)
- except RootError:
- built = None
+
+ # Not mutating the live set here either — see `add_root`. Dropping the
+ # root from it would leave `retarget` unable to tell that its entries
+ # should go, so the removed directory's files would stay in the index.
+ #
+ # Built from the config this just edited, and never returned empty: an
+ # empty list is a *valid answer* meaning "this group has no directories",
+ # which the client cannot tell from "the node could not say" — it would
+ # blank the operator's table on an op that succeeded.
+ result_roots = RootSet.build([asdict(r) for r in cfg.roots]).describe()
log.info("Root removed: %s from group %s", root_name, group_id[:8])
return {"status": "removed", "name": root_name, "group_id": group_id,
- "roots": built.describe() if built else []}
+ "roots": result_roots}
+
+
+async def update_root(state: dict, group_id: str, root_name: str, *,
+ writable: bool | None = None,
+ removable: bool | None = None) -> dict:
+ """Toggle writable/removable on an existing root without removing it."""
+ config = _config(state)
+ cfg = next((g for g in config.groups if g.id == group_id), None)
+ if cfg is None:
+ raise OpError("Group not configured on this node", status=404)
+
+ from meshbay_common.paths import fold
+ from meshbay_node.roots import RootSet
+ target = fold(root_name)
+ match = None
+ for r in cfg.roots:
+ rname = r.name or str(Path(r.path).name)
+ if fold(rname) == target:
+ match = r
+ break
+ if match is None:
+ raise OpError(f"No root named {root_name!r} in this group", status=404)
+
+ changed = False
+ if writable is not None and match.writable != writable:
+ match.writable = writable
+ changed = True
+ if removable is not None and match.removable != removable:
+ match.removable = removable
+ changed = True
+
+ if not changed:
+ specs = [asdict(r) for r in cfg.roots]
+ built = RootSet.build(specs)
+ return {"status": "unchanged", "name": root_name, "group_id": group_id,
+ "roots": built.describe()}
+
+ conf_path = Path(state.get("config_path") or DEFAULT_CONFIG_PATH)
+ _update_root_field(conf_path, group_id, str(Path(match.path).expanduser().resolve()),
+ writable=match.writable, removable=match.removable)
+
+ # Update the live RootSet so GET /api/groups returns correct data
+ # immediately, without waiting for the async reload to finish.
+ live_roots: RootSet | None = state.get("groups_ctx", {}).get(
+ group_id, {}).get("roots")
+ if live_roots:
+ for lr in live_roots.roots:
+ lr_name = lr.name or str(Path(lr.path).name)
+ if fold(lr_name) == target:
+ if writable is not None:
+ lr.writable = writable
+ if removable is not None:
+ lr.removable = removable
+ break
+
+ # Built from config when there is no live set, never returned empty: an
+ # empty list is a *valid answer* meaning "this group has no directories",
+ # and the client cannot tell it from "the node could not say". It would
+ # blank the operator's table on an op that succeeded.
+ result_roots = (live_roots.describe() if live_roots
+ else RootSet.build([asdict(r) for r in cfg.roots]).describe())
+
+ log.info("Root updated: %s (writable=%s, removable=%s) in group %s",
+ root_name, match.writable, match.removable, group_id[:8])
+ return {"status": "updated", "name": root_name, "group_id": group_id,
+ "roots": result_roots}
+
+
+async def eject_root(state: dict, group_id: str, root_name: str) -> dict:
+ """Mark a removable root as ejected so the operator can safely unplug."""
+ config = _config(state)
+ cfg = next((g for g in config.groups if g.id == group_id), None)
+ if cfg is None:
+ raise OpError("Group not configured on this node", status=404)
+
+ from meshbay_common.paths import fold
+ target = fold(root_name)
+ ctx = _group_ctx(state, group_id)
+ roots: RootSet | None = ctx.get("roots")
+ if not roots:
+ raise OpError("Group has no roots", status=503)
+
+ root = None
+ for r in roots:
+ if fold(r.name) == target:
+ root = r
+ break
+ if root is None:
+ raise OpError(f"No root named {root_name!r} in this group", status=404)
+ if not root.removable:
+ raise OpError(f"Root {root_name!r} is not marked as removable", status=400)
+ if root.ejected:
+ return {"status": "already_ejected", "name": root_name,
+ "group_id": group_id, "roots": roots.describe()}
+
+ # The indexer stops its watchdog and freezes the entries; it holds the same
+ # RootSet object, but the flags are set here too so a context whose indexer
+ # was replaced by a retarget cannot be left disagreeing with the roster.
+ indexer = state.get("indexers", {}).get(group_id)
+ if indexer:
+ indexer.eject_root(root_name)
+ root.ejected = True
+ root.available = False
+
+ await _roster(state).set_root_ejected(
+ group_id, root_name, True, set_by=state.get("node_user_id", ""))
+
+ log.info("Root ejected: %s from group %s", root_name, group_id[:8])
+ return {"status": "ejected", "name": root_name, "group_id": group_id,
+ "roots": roots.describe()}
+
+
+async def plug_root(state: dict, group_id: str, root_name: str) -> dict:
+ """Re-enable an ejected root after the device is plugged back in."""
+ config = _config(state)
+ cfg = next((g for g in config.groups if g.id == group_id), None)
+ if cfg is None:
+ raise OpError("Group not configured on this node", status=404)
+
+ from meshbay_common.paths import fold
+ target = fold(root_name)
+ ctx = _group_ctx(state, group_id)
+ roots: RootSet | None = ctx.get("roots")
+ if not roots:
+ raise OpError("Group has no roots", status=503)
+
+ root = None
+ for r in roots:
+ if fold(r.name) == target:
+ root = r
+ break
+ if root is None:
+ raise OpError(f"No root named {root_name!r} in this group", status=404)
+ if not root.ejected:
+ return {"status": "already_plugged", "name": root_name,
+ "group_id": group_id, "roots": roots.describe()}
+ if not root.is_live():
+ raise OpError(
+ f"Directory not found: {root.path}. Is the device connected?",
+ status=409)
+
+ # Persisted before the rescan, which can take minutes on a large library:
+ # a crash halfway through must leave the root plugged, not ejected with
+ # entries half rebuilt.
+ await _roster(state).set_root_ejected(
+ group_id, root_name, False, set_by=state.get("node_user_id", ""))
+
+ indexer = state.get("indexers", {}).get(group_id)
+ if indexer:
+ await indexer.plug_root(root_name)
+ root.ejected = False
+ root.available = root.is_live()
+
+ log.info("Root plugged: %s in group %s", root_name, group_id[:8])
+ return {"status": "plugged", "name": root_name, "group_id": group_id,
+ "roots": roots.describe()}
+
+
+def _update_root_field(conf_path: Path, group_id: str,
+ resolved_path: str, *,
+ writable: bool, removable: bool) -> None:
+ """Update writable/removable fields on a root in node.toml."""
+ text = conf_path.read_text(encoding="utf-8")
+ lines = text.split("\n")
+
+ rng = _find_group_range(lines, group_id)
+ if rng is None:
+ raise OpError(f"Group {group_id[:8]} not found in {conf_path}")
+
+ start, end = rng
+ path_re = re.compile(r'^\s*path\s*=\s*"([^"]*)"')
+ writable_re = re.compile(r'^\s*(writable|upload)\s*=')
+ removable_re = re.compile(r'^\s*removable\s*=')
+ roots_starts: list[int] = []
+ for i in range(start + 1, end):
+ if lines[i].strip() == "[[groups.roots]]":
+ roots_starts.append(i)
+
+ for j, rs in enumerate(roots_starts):
+ rs_end = roots_starts[j + 1] if j + 1 < len(roots_starts) else end
+ found_path = False
+ for k in range(rs, rs_end):
+ m = path_re.match(lines[k])
+ if m:
+ try:
+ p = str(Path(m.group(1)).expanduser().resolve())
+ except OSError:
+ continue
+ if p == resolved_path:
+ found_path = True
+ break
+ if not found_path:
+ continue
+
+ writable_idx = None
+ removable_idx = None
+ for k in range(rs, rs_end):
+ if writable_re.match(lines[k]):
+ writable_idx = k
+ if removable_re.match(lines[k]):
+ removable_idx = k
+
+ if writable_idx is not None:
+ lines[writable_idx] = f" writable = {'true' if writable else 'false'}"
+ else:
+ lines.insert(rs_end, f" writable = {'true' if writable else 'false'}")
+ if removable_idx is not None and removable_idx >= rs_end:
+ removable_idx += 1
+ rs_end += 1
+
+ if removable_idx is not None:
+ lines[removable_idx] = f" removable = {'true' if removable else 'false'}"
+ else:
+ lines.insert(rs_end, f" removable = {'true' if removable else 'false'}")
+
+ conf_path.write_text("\n".join(lines), encoding="utf-8", newline="\n")
+ return
+
+ raise OpError("Root path not found in config", status=404)
# ── Files ────────────────────────────────────────────────────────────────────
@@ -806,26 +1038,6 @@ async def clear_denylist(state: dict, *, subject: str = "") -> dict:
return {"status": "cleared", "removed": removed, "subject": subject or "all"}
-# ── Upload policy ───────────────────────────────────────────────────────────
-
-async def set_member_upload(state: dict, group_id: str, allowed: bool) -> dict:
- """
- Turn uploading by ordinary members on or off.
-
- The setting lives on the node (roster.db), not on the hub and not in
- node.toml — changing it must not rewrite the operator's config file,
- and must not need a restart.
- """
- roster = _roster(state)
- ctx = _group_ctx(state, group_id)
- await roster.set_member_upload(group_id, allowed,
- set_by=state.get("node_user_id", ""))
- ctx["member_upload"] = allowed
- log.info("Upload policy: %s for group %s", "on" if allowed else "off",
- group_id[:8])
- return {"allowed": allowed, "group_id": group_id}
-
-
# ── Node settings ────────────────────────────────────────────────────────────
async def get_node_settings(state: dict) -> dict:
@@ -924,12 +1136,16 @@ async def set_enabled_apps(state: dict, group_id: str, apps: list[str]) -> dict:
"""
Which group "applications" (Chat, Files, ...) are shown to members.
- Same shape as `set_member_upload`: lives on the node (roster.db), takes
+ Same shape as other signed ops: lives on the node (roster.db), takes
effect without a restart, and is signed by the operator (webrtc_server.py
checks the caller's own admin-authority allow-list before this runs).
"""
roster = _roster(state)
ctx = _group_ctx(state, group_id)
+ # See the same guard in webrtc_server._do_apps_enabled: Files cannot be
+ # turned off, and both writers put it at the front so the two agree.
+ if "files" not in apps:
+ apps = ["files"] + list(apps)
await roster.set_enabled_apps(group_id, apps,
set_by=state.get("node_user_id", ""))
ctx["enabled_apps"] = apps
@@ -946,7 +1162,7 @@ async def set_tmdb_config(state: dict, token: str | None = None,
and in what language it queries TMDB (docs/mediacenter.md §5.5).
Node-wide (roster.py group_settings, group_id="") rather than per-group
- like set_member_upload/set_enabled_apps: the token and the shared-cache
+ like set_enabled_apps: the token and the shared-cache
language are one operator's budget and one credential, not a per-group
or per-viewer concern. Whether TMDB is used *at all* is the per-group
decision set_tmdb_enabled below makes instead. `token=""` explicitly
@@ -1007,76 +1223,171 @@ async def set_musicbrainz_enabled(state: dict, group_id: str, enabled: bool) ->
return {"enabled": enabled, "group_id": group_id}
-async def set_video_root(state: dict, group_id: str, path: str) -> dict:
+# ── App directories ──────────────────────────────────────────────────────────
+
+def _validate_app_dirs(state: dict, group_id: str, paths: list[str], *,
+ require_writable: bool) -> list[str]:
"""
- Which folder (possibly a subfolder of a shared root) is the Videos app's
- entry point for this group. Same shape as set_enabled_apps: lives on the
- node (roster.db), takes effect without a restart, signed by the operator.
- `path=""` clears it — the Videos tab then asks for one to be chosen
- before anything (including TMDB enrichment, docs/mediacenter.md §5.2)
- runs, rather than defaulting to the whole shared index.
+ Every path an app is pointed at must live inside one of the group's roots.
+
+ The per-app setters this replaces validated nothing: a typo, or a path left
+ behind by a root that was removed, was stored and then quietly matched no
+ entry — an app showing an empty tab with no way to tell "misconfigured"
+ from "no files yet". Refusing at the point of setting is the only moment
+ the operator is present to be told.
- A non-empty path fires (never awaits) a sweep of whatever that folder
- already contains: the ordinary per-change enrichment path only ever
- looks at files new since the last broadcast, so anything already sitting
- in a folder before it became the video_root would otherwise never be
- picked up.
+ Not `RootSet.resolve()`, deliberately: that also refuses a directory whose
+ root is currently *unavailable*, and an operator must be able to configure
+ a library on a drive they have unplugged. What is checked here is the
+ shape — inside a named root, no traversal — which does not change with
+ what happens to be mounted.
"""
- roster = _roster(state)
- ctx = _group_ctx(state, group_id)
- await roster.set_video_root(group_id, path, set_by=state.get("node_user_id", ""))
- ctx["video_root"] = path
- log.info("Videos root for group %s: %r", group_id[:8], path)
- if path:
- enrich_fn = state.get("enrich_video_root_fn")
- if enrich_fn:
- asyncio.ensure_future(enrich_fn(group_id))
- return {"path": path, "group_id": group_id}
+ roots: RootSet | None = _group_ctx(state, group_id).get("roots")
+ if roots is None:
+ raise OpError("Group has no roots", status=503)
+ clean: list[str] = []
+ for raw in paths:
+ path = str(raw or "").strip().strip("/")
+ if not path:
+ continue
+ if ".." in path.split("/"):
+ raise OpError(f"{path!r} is not a directory inside this group",
+ status=400)
+ found = roots.split(path)
+ if found is None:
+ raise OpError(
+ f"{path!r} is not inside any of this group's shared "
+ f"directories", status=400,
+ extra={"available": roots.names})
+ root, _tail = found
+ if require_writable and not root.writable:
+ raise OpError(
+ f"{root.name!r} is read-only, and this setting needs a "
+ f"directory that accepts uploads", status=400)
+ clean.append(path)
+ return sorted(set(clean))
-async def set_audio_root(state: dict, group_id: str, path: str) -> dict:
+
+async def set_app_directories(state: dict, group_id: str, app_key: str,
+ paths: list[str], *,
+ require_writable: bool = False) -> dict:
"""
- Same shape as set_video_root above — the Music app's own entry point,
- added later (docs/musicbay.md's original "no root, works over the
- whole shared tree" simplification didn't hold up against a real messy
- library). `path=""` clears it — the Music tab then asks for one to be
- chosen before anything (including tag/cover enrichment) runs, rather
- than defaulting to the whole shared index.
+ Which folder(s) inside the group's shared roots an application works over.
+
+ One function for every app, keyed by the app's own name: adding an
+ application is a registry entry and a settings component, not another
+ near-identical op here. It replaces `set_video_root`, `set_audio_root` and
+ `set_photo_roots`, which differed only in the key they wrote and whether
+ they took a string or a list.
+
+ Empty means nothing configured, which every app reads as "show nothing
+ until an operator has chosen" — never "the whole group index". Pointing an
+ app at the whole library is a decision, not a default nobody made.
+
+ A change always fires (never awaits) a sweep of what the new directories
+ already contain: the ordinary per-change enrichment path only looks at
+ entries new since the last broadcast, so files already sitting in a folder
+ when it was chosen would otherwise never be picked up.
"""
roster = _roster(state)
ctx = _group_ctx(state, group_id)
- await roster.set_audio_root(group_id, path, set_by=state.get("node_user_id", ""))
- ctx["audio_root"] = path
- log.info("Music root for group %s: %r", group_id[:8], path)
- if path:
- enrich_fn = state.get("enrich_audio_root_fn")
- if enrich_fn:
- asyncio.ensure_future(enrich_fn(group_id))
- return {"path": path, "group_id": group_id}
+ clean = _validate_app_dirs(state, group_id, paths,
+ require_writable=require_writable)
+ await roster.set_app_directories(group_id, app_key, clean,
+ set_by=state.get("node_user_id", ""))
+ ctx[f"{app_key}_directories"] = clean
+ # The scalar the handshake ack still publishes for MNP 1.0 clients is
+ # derived, and has to be re-derived here: leaving it behind would make the
+ # ack disagree with the list within a single run, and only until a restart
+ # — the shape of bug that reads as "it works after a restart".
+ from meshbay_node.roster import Roster
+ alias = Roster.ctx_alias(app_key, clean)
+ if alias:
+ ctx[alias[0]] = alias[1]
+ log.info("%s directories for group %s: %s", app_key, group_id[:8],
+ ", ".join(clean) or "(none)")
+
+ enrich = (state.get("enrich_app_dirs_fns") or {}).get(app_key)
+ if enrich:
+ asyncio.ensure_future(enrich(group_id))
+ return {"app": app_key, "directories": clean, "group_id": group_id}
+
+
+async def set_app_directory(state: dict, group_id: str, app_key: str,
+ path: str, *,
+ require_writable: bool = False) -> dict:
+ """
+ The single-directory form, for an app that only ever wants one.
+
+ Stored as a one-element list like every other app, because two storage
+ shapes for one idea is what made `video_root` (scalar) and `photo_roots`
+ (list) need separate ops, separate MNP messages and separate widgets to
+ say the same thing. `path=""` clears it.
+ """
+ result = await set_app_directories(
+ state, group_id, app_key, [path] if path else [],
+ require_writable=require_writable)
+ dirs = result["directories"]
+ return {**result, "path": dirs[0] if dirs else ""}
+
+
+# The per-app wrappers MNP still names. They exist so an MNP 1.0 client's
+# `video_root` / `audio_root` / `photo_roots` messages keep working; nothing
+# new should be added here — a new app calls the generic pair above.
+
+async def set_video_root(state: dict, group_id: str, path: str) -> dict:
+ result = await set_app_directory(state, group_id, "video", path)
+ return {"path": result["path"], "group_id": group_id}
+
+
+async def set_audio_root(state: dict, group_id: str, path: str) -> dict:
+ # "music", not "audio": the app's registry key is what identifies it
+ # everywhere, and `audio_root` is only the name the setting used to have.
+ result = await set_app_directory(state, group_id, "music", path)
+ return {"path": result["path"], "group_id": group_id}
async def set_photo_roots(state: dict, group_id: str, roots: list[str]) -> dict:
+ result = await set_app_directories(state, group_id, "photo", roots)
+ return {"roots": result["directories"], "group_id": group_id}
+
+
+# ── Chat ─────────────────────────────────────────────────────────────────────
+
+async def set_chat_directory(state: dict, group_id: str, path: str) -> dict:
+ """
+ Where chat attachments are written.
+
+ `require_writable`, unlike every other app directory: this one is a
+ *destination*, not a view. Pointing it at a read-only root would produce an
+ attachment button that fails at the moment somebody uses it, which is the
+ failure mode the RO/RW model exists to move earlier.
"""
- Which folder(s) are the Photos app's entry points for this group. Unlike
- `set_video_root`/`set_audio_root`, the whole *set* is replaced in one
- call (docs/photos.md §2.1) — signed once, same shape as
- `set_enabled_apps`, rather than one op per root added/removed.
+ return await set_app_directory(state, group_id, "chat", path,
+ require_writable=True)
- Always fires a sweep, even to an empty list: a root just added needs its
- existing contents enriched (nothing else re-visits already-indexed
- entries), and a root just removed leaves its cache entries harmlessly
- unused rather than needing any cleanup — re-sweeping the new set costs
- nothing when it's empty.
+
+async def set_chat_link_preview(state: dict, group_id: str,
+ enabled: bool) -> dict:
+ """
+ Whether the node fetches a page's title and image when a member posts a
+ link.
+
+ Outbound third-party traffic on the operator's connection, caused by a
+ message they did not write and pointing at a URL they did not choose — so
+ it is theirs to switch off, on the same reasoning as the per-group TMDB
+ switch. Absent means on, because that is what the node did before this
+ existed.
"""
roster = _roster(state)
ctx = _group_ctx(state, group_id)
- await roster.set_photo_roots(group_id, roots, set_by=state.get("node_user_id", ""))
- ctx["photo_roots"] = roots
- log.info("Photo roots for group %s: %s", group_id[:8], ", ".join(sorted(roots)) or "(none)")
- enrich_fn = state.get("enrich_photo_roots_fn")
- if enrich_fn:
- asyncio.ensure_future(enrich_fn(group_id))
- return {"roots": roots, "group_id": group_id}
+ await roster.set_chat_link_preview(group_id, enabled,
+ set_by=state.get("node_user_id", ""))
+ ctx["chat_link_preview"] = enabled
+ log.info("Chat link previews for group %s: %s", group_id[:8],
+ "on" if enabled else "off")
+ return {"enabled": enabled, "group_id": group_id}
# ── Scan settings ────────────────────────────────────────────────────────────
@@ -1086,7 +1397,7 @@ async def set_scan_settings(state: dict, group_id: str, reconcile_interval_secs:
"""
How often the indexer's reconciliation backstop runs, and how long a
changed file is left alone before being hashed (indexer.py
- DirectoryIndexer). Persisted like set_member_upload/set_enabled_apps —
+ DirectoryIndexer). Persisted like set_enabled_apps —
but there is also a *live* DirectoryIndexer object to update, since it
reads these once at construction and runs its own background loop with
them rather than consulting groups_ctx on every use.
diff --git a/packages/meshbay-node/src/meshbay_node/roots.py b/packages/meshbay-node/src/meshbay_node/roots.py
index 74ea2f6..d288231 100644
--- a/packages/meshbay-node/src/meshbay_node/roots.py
+++ b/packages/meshbay-node/src/meshbay_node/roots.py
@@ -120,10 +120,9 @@ class Root:
name: str
path: Path
kind: str = "generic"
- upload: bool = False
- direct: bool = False
- # Runtime, not configuration: set by the indexer when the directory can no
- # longer be read, and cleared when it comes back.
+ writable: bool = False
+ removable: bool = False
+ ejected: bool = False
available: bool = True
@property
@@ -165,6 +164,13 @@ class RootSet:
roots: list[Root] = field(default_factory=list)
+ # Roots this set ejected by itself — a removable device that went away
+ # without the operator clicking Eject. Drained by the indexer, which is
+ # the only caller holding a roster to write the state to. Without that
+ # the flag is lost on the next restart, and the surprise unplug looks
+ # like a deletion all over again on the pass after it.
+ auto_ejected: list[str] = field(default_factory=list)
+
# ── Construction ─────────────────────────────────────────────────────────
@classmethod
@@ -172,8 +178,9 @@ class RootSet:
"""
Build from configuration, refusing anything ambiguous.
- `specs` are dicts with `path`, and optionally `name`, `kind`, `upload`.
- Raises RootError with a message meant for an operator reading a log.
+ `specs` are dicts with `path`, and optionally `name`, `kind`, `writable`,
+ `removable`. Raises RootError with a message meant for an operator reading
+ a log.
"""
roots: list[Root] = []
by_folded: dict[str, Root] = {}
@@ -209,34 +216,22 @@ class RootSet:
log.warning("root %r: unknown kind %r — using 'generic'", name, kind)
kind = "generic"
+ # Backward compat: old configs use `upload` instead of `writable`
+ writable = bool(spec.get("writable", spec.get("upload", False)))
+ # `ejected` is runtime state, not configuration — it reaches here
+ # only from the roster, restored at startup so a drive ejected
+ # before a restart does not come back on its own.
root = Root(name=name, path=path, kind=kind,
- upload=bool(spec.get("upload", False)),
- direct=bool(spec.get("direct", False)))
+ writable=writable,
+ removable=bool(spec.get("removable", False)),
+ ejected=bool(spec.get("ejected", False)),
+ available=not bool(spec.get("ejected", False)))
_refuse_nesting(root, roots)
roots.append(root)
by_folded[root.folded] = root
- cls._settle_upload_root(roots)
return cls(roots=roots)
- @staticmethod
- def _settle_upload_root(roots: list[Root]) -> None:
- """
- Exactly one root receives uploads, and the operator picks it.
-
- Not guessed when several are marked, because "uploads went somewhere
- else" is discovered weeks later. With none marked and a single root, the
- answer is not ambiguous, so it is taken.
- """
- marked = [r for r in roots if r.upload]
- if len(marked) > 1:
- names = ", ".join(r.name for r in marked)
- raise RootError(
- f"several roots are marked upload = true ({names}) — exactly one "
- f"receives uploads")
- if not marked and len(roots) == 1:
- roots[0].upload = True
-
# ── Lookup ───────────────────────────────────────────────────────────────
def by_name(self, name: str) -> Root | None:
@@ -247,11 +242,8 @@ class RootSet:
return None
@property
- def upload_root(self) -> Root | None:
- for root in self.roots:
- if root.upload:
- return root
- return None
+ def writable_roots(self) -> list[Root]:
+ return [r for r in self.roots if r.writable]
@property
def names(self) -> list[str]:
@@ -336,10 +328,28 @@ class RootSet:
Called periodically and after a filesystem event that looks like a
disappearance. A change here never edits the index: a root going away
freezes its entries, and a root coming back triggers a rescan.
+
+ An ejected root stays unavailable regardless of `is_live()` — the
+ operator must explicitly plug it back. A removable root whose path
+ disappears without an eject is auto-ejected as a safety net.
"""
changed: list[tuple[Root, bool]] = []
for root in self.roots:
+ if root.ejected:
+ if root.available:
+ root.available = False
+ changed.append((root, False))
+ continue
live = root.is_live()
+ if not live and root.removable:
+ root.ejected = True
+ # Recorded for the caller to persist. A flag that only lives
+ # in memory would be forgotten on the next restart, and the
+ # rescan that followed would read an empty mount point as an
+ # erased library — the exact outcome eject exists to prevent.
+ self.auto_ejected.append(root.name)
+ log.warning("Root %r auto-ejected (device disappeared): %s",
+ root.name, root.path)
if live != root.available:
root.available = live
changed.append((root, live))
@@ -347,14 +357,31 @@ class RootSet:
"available" if live else "UNAVAILABLE", root.path)
return changed
- def describe(self) -> list[dict]:
- """Per-root state for the index payload and the admin UI."""
+ def describe(self, *, with_paths: bool = False) -> list[dict]:
+ """
+ Per-root state for the index payload and the admin UI.
+
+ Deliberately no paths by default: this is what every member receives.
+ `with_paths=True` is the operator's own view, over a channel that is
+ already theirs alone (loopback + run token).
+ """
out = []
for r in self.roots:
d: dict = {"name": r.name, "kind": r.kind,
- "available": r.available, "upload": r.upload}
- if r.direct:
- d["direct"] = True
+ "available": r.available,
+ "writable": r.writable,
+ "removable": r.removable,
+ "ejected": r.ejected,
+ # Backward compat for MNP 1.0 clients
+ "upload": r.writable}
+ # `with_paths` is for the operator's *own* channels only — the
+ # loopback API and the CLI reading it, both of which already
+ # require being on this machine with the run token. A member is
+ # told what exists and whether it is readable, never where on the
+ # operator's disk it lives, and the index payload every member
+ # receives must keep calling this without the flag.
+ if with_paths:
+ d["path"] = str(r.path)
out.append(d)
return out
diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py
index c78281b..af87f92 100644
--- a/packages/meshbay-node/src/meshbay_node/roster.py
+++ b/packages/meshbay-node/src/meshbay_node/roster.py
@@ -31,6 +31,8 @@ from pathlib import Path
import aiosqlite
+from meshbay_common.paths import fold
+
log = logging.getLogger(__name__)
# Crockford base32 without I, L, O and U: no character pair a human can confuse
@@ -543,10 +545,40 @@ class Roster:
# ── Group settings ──────────────────────────────────────────────────────
- # Whether members who are not the operator may upload. Default is yes: a
- # group that nobody may add to is the unusual case, and an existing node
- # must not change behaviour because a table was added under it.
- SETTING_MEMBER_UPLOAD = "member_upload"
+ # Whether a root is ejected. Runtime state, one key per root, keyed by the
+ # *folded* name so it agrees with the case-insensitive comparison the rest
+ # of the root code makes. It lives here rather than in node.toml because it
+ # is not configuration — an operator's hand-written config file should not
+ # be rewritten because a USB drive was unplugged — and it has to survive a
+ # restart, or the rescan that follows reads an empty mount point as an
+ # erased library, which is the whole thing eject exists to prevent.
+ SETTING_ROOT_EJECTED_PREFIX = "root_ejected:"
+
+ @classmethod
+ def root_ejected_key(cls, root_name: str) -> str:
+ return cls.SETTING_ROOT_EJECTED_PREFIX + fold(root_name)
+
+ async def set_root_ejected(self, group_id: str, root_name: str,
+ ejected: bool, set_by: str = "") -> None:
+ await self.set_setting(group_id, self.root_ejected_key(root_name),
+ "1" if ejected else "0", set_by)
+
+ async def ejected_roots(self, group_id: str) -> set[str]:
+ """
+ The folded names of this group's ejected roots.
+
+ Matched in Python rather than with `LIKE 'root_ejected:%'`: `_` is a
+ single-character wildcard there, so that pattern also matches keys this
+ does not own. A group has a handful of settings rows, so reading them
+ all costs nothing and the prefix test is then exact.
+ """
+ prefix = self.SETTING_ROOT_EJECTED_PREFIX
+ async with self._db.execute(
+ "SELECT key, value FROM group_settings WHERE group_id = ?",
+ (group_id,)) as cur:
+ rows = await cur.fetchall()
+ return {r["key"][len(prefix):] for r in rows
+ if r["key"].startswith(prefix) and r["value"] == "1"}
async def get_setting(self, group_id: str, key: str,
default: str | None = None) -> str | None:
@@ -567,17 +599,6 @@ class Roster:
(group_id, key, value, set_by, _now()))
await self._db.commit()
- async def member_upload_allowed(self, group_id: str) -> bool:
- """Whether an ordinary member may upload to this group."""
- value = await self.get_setting(group_id, self.SETTING_MEMBER_UPLOAD, "1")
- return value != "0"
-
- async def set_member_upload(self, group_id: str, allowed: bool,
- set_by: str = "") -> bool:
- await self.set_setting(group_id, self.SETTING_MEMBER_UPLOAD,
- "1" if allowed else "0", set_by)
- return allowed
-
# Which group "applications" (Chat, Files, and whatever registers later in
# apps.js) are shown to members. Unset means every app that exists — an
# existing group's tabs must not disappear because a node was upgraded.
@@ -587,11 +608,15 @@ class Roster:
async def enabled_apps(self, group_id: str) -> list[str]:
value = await self.get_setting(group_id, self.SETTING_ENABLED_APPS)
if value is None:
- return list(self.DEFAULT_APPS)
- try:
- return list(json.loads(value))
- except (ValueError, TypeError):
- return list(self.DEFAULT_APPS)
+ apps = list(self.DEFAULT_APPS)
+ else:
+ try:
+ apps = list(json.loads(value))
+ except (ValueError, TypeError):
+ apps = list(self.DEFAULT_APPS)
+ if "files" not in apps:
+ apps.insert(0, "files")
+ return apps
async def set_enabled_apps(self, group_id: str, apps: list[str],
set_by: str = "") -> list[str]:
@@ -605,7 +630,7 @@ class Roster:
# user_id)` authorizing the operator node-wide (desktop-client-v1.md
# §6.3). Unset means "the shipped default token, TMDB's own default
# language" — the same "absent means the old behaviour" discipline
- # member_upload/enabled_apps already follow.
+ # enabled_apps already follows.
#
# Whether TMDB is used *at all*, though, is per-group (moved off the
# node-wide sentinel below, 2026-08-24): an operator running a real media
@@ -636,58 +661,110 @@ class Roster:
await self.set_setting(self.NODE_WIDE_GROUP_ID, self.SETTING_TMDB_LANGUAGE,
language, set_by)
- # Which folder is the Videos app's entry point for this group — per-group
- # (unlike the token/language above), since different groups share
- # different trees. Empty/unset means the whole group index, exactly as
- # today.
- SETTING_VIDEO_ROOT = "video_root"
-
- async def video_root(self, group_id: str) -> str:
- return await self.get_setting(group_id, self.SETTING_VIDEO_ROOT, "") or ""
+ # ── App directories ─────────────────────────────────────────────────────
+ #
+ # Which folder(s) inside the group's shared roots each application uses as
+ # its entry point. One storage shape for every app, keyed by the app's own
+ # name, so adding an application needs no change here at all — that is the
+ # whole point of the plugin architecture (docs/refactor-groups.md §1.6).
+ #
+ # Always a JSON list, even for an app that only ever wants one directory.
+ # Two shapes for one idea is how `video_root` (scalar) and `photo_roots`
+ # (list) ended up needing separate ops, separate MNP messages and separate
+ # settings widgets to say the same thing.
+ #
+ # Empty/unset means nothing configured yet, and every app reads that as
+ # "show nothing until an operator has chosen" rather than "the whole group
+ # index" — the discipline video_root established, kept.
+ SETTING_APP_DIRS_SUFFIX = "_directories"
- async def set_video_root(self, group_id: str, path: str, set_by: str = "") -> str:
- await self.set_setting(group_id, self.SETTING_VIDEO_ROOT, path or "", set_by)
- return path or ""
+ # What each app's directories used to be stored under, before they were
+ # one shape. Read as a fallback so an existing node keeps working with no
+ # migration step: the legacy key is never written again, and the first
+ # save through the new path leaves it behind.
+ # Keyed by the *registry* name the app is known by everywhere else
+ # (apps.js, ALLOWED_APPS, enabled_apps) — which for Music is "music", while
+ # its old setting was called `audio_root`. One identifier per app, and the
+ # place the two names meet is this table and nowhere else.
+ LEGACY_DIR_KEYS = {
+ "video": ("video_root", "scalar"),
+ "music": ("audio_root", "scalar"),
+ "photo": ("photo_roots", "list"),
+ }
- # Same shape as SETTING_VIDEO_ROOT — the Music app's own entry point,
- # added later (docs/musicbay.md's original "no root, works over the
- # whole shared tree" simplification turned out not to hold up against a
- # real messy library: the operator asked for the same scoping Videos
- # already had). Empty/unset means Music shows nothing yet, exactly like
- # an unset video_root — see daemon.py's enrichment gate.
- SETTING_AUDIO_ROOT = "audio_root"
+ # The name each app's directories are *also* published under, for readers
+ # that predate the list — the handshake ack's `video_root`, and the group
+ # context the ack builds from. Derived from the list, never stored beside
+ # it, so the two cannot disagree; the shape says how to derive it.
+ CTX_ALIASES = {
+ "video": ("video_root", "scalar"),
+ "music": ("audio_root", "scalar"),
+ "photo": ("photo_roots", "list"),
+ "chat": ("chat_directory", "scalar"),
+ }
- async def audio_root(self, group_id: str) -> str:
- return await self.get_setting(group_id, self.SETTING_AUDIO_ROOT, "") or ""
+ @classmethod
+ def app_dirs_key(cls, app_key: str) -> str:
+ return f"{app_key}{cls.SETTING_APP_DIRS_SUFFIX}"
- async def set_audio_root(self, group_id: str, path: str, set_by: str = "") -> str:
- await self.set_setting(group_id, self.SETTING_AUDIO_ROOT, path or "", set_by)
- return path or ""
+ @classmethod
+ def ctx_alias(cls, app_key: str, directories: list[str]) -> tuple[str, object] | None:
+ """The (name, value) an app's directories are also published under."""
+ alias = cls.CTX_ALIASES.get(app_key)
+ if not alias:
+ return None
+ name, shape = alias
+ if shape == "list":
+ return name, list(directories)
+ return name, (directories[0] if directories else "")
- # Which folder(s) are the Photos app's entry points for this group —
- # a *set*, unlike video_root/audio_root above: a photo library is
- # routinely scattered across several unrelated folders (docs/photos.md
- # §2.1), so there is no single natural root to pick. Stored the same way
- # `enabled_apps` already is (json.dumps(sorted(...))). Empty/unset means
- # nothing configured yet — same "show nothing until an operator has
- # chosen" discipline video_root/audio_root already established, not
- # "the whole group index".
- SETTING_PHOTO_ROOTS = "photo_roots"
+ async def app_directories(self, group_id: str, app_key: str) -> list[str]:
+ value = await self.get_setting(group_id, self.app_dirs_key(app_key))
+ if value is not None:
+ try:
+ return [str(p) for p in json.loads(value)]
+ except (ValueError, TypeError):
+ return []
- async def photo_roots(self, group_id: str) -> list[str]:
- value = await self.get_setting(group_id, self.SETTING_PHOTO_ROOTS)
- if value is None:
+ legacy = self.LEGACY_DIR_KEYS.get(app_key)
+ if not legacy:
return []
+ key, shape = legacy
+ raw = await self.get_setting(group_id, key)
+ if raw is None:
+ return []
+ if shape == "scalar":
+ return [raw] if raw else []
try:
- return list(json.loads(value))
+ return [str(p) for p in json.loads(raw)]
except (ValueError, TypeError):
return []
- async def set_photo_roots(self, group_id: str, roots: list[str],
- set_by: str = "") -> list[str]:
- await self.set_setting(group_id, self.SETTING_PHOTO_ROOTS,
- json.dumps(sorted(roots)), set_by)
- return roots
+ async def set_app_directories(self, group_id: str, app_key: str,
+ paths: list[str], set_by: str = "") -> list[str]:
+ clean = sorted({str(p).strip("/") for p in paths if str(p).strip("/")})
+ await self.set_setting(group_id, self.app_dirs_key(app_key),
+ json.dumps(clean), set_by)
+ return clean
+
+ # ── Chat ────────────────────────────────────────────────────────────────
+
+ # Whether the node fetches a page's title/preview when a member posts a
+ # link. Outbound third-party traffic on the operator's connection, from a
+ # message they did not write, so it is theirs to switch off — the same
+ # reasoning as the per-group TMDB switch. Unset means on, because that is
+ # what the node did before this existed.
+ SETTING_CHAT_LINK_PREVIEW = "chat_link_preview"
+
+ async def chat_link_preview(self, group_id: str) -> bool:
+ value = await self.get_setting(group_id, self.SETTING_CHAT_LINK_PREVIEW, "1")
+ return value != "0"
+
+ async def set_chat_link_preview(self, group_id: str, enabled: bool,
+ set_by: str = "") -> bool:
+ await self.set_setting(group_id, self.SETTING_CHAT_LINK_PREVIEW,
+ "1" if enabled else "0", set_by)
+ return enabled
# Whether TMDB lookups run for this group at all — per-group, unlike the
# token/language above: one node process can share a real media library
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 94dfd8e..8e357c9 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -76,8 +76,14 @@ from meshbay_common.adminop import (
OP_MUSICBRAINZ_ENABLED,
OP_AUDIO_ROOT,
OP_PHOTO_ROOTS,
+ OP_APP_DIRECTORIES,
+ OP_CHAT_DIRECTORY,
+ OP_CHAT_LINK_PREVIEW,
OP_ROOT_ADD,
OP_ROOT_REMOVE,
+ OP_ROOT_UPDATE,
+ OP_ROOT_EJECT,
+ OP_ROOT_PLUG,
OP_GROUP_ATTACH,
OP_GROUP_DETACH,
admin_transcript,
@@ -208,7 +214,6 @@ JOIN_FAILURE_WINDOW = 600 # seconds
# attachments from the chat alike. One visible directory the operator can look
# into, back up or empty — rather than a hidden tree of per-user uuids that
# nobody could read, or files scattered wherever someone happened to be looking.
-UPLOAD_DIR_NAME = "uploads"
def _extract_dtls_fingerprint(sdp: str) -> bytes:
@@ -481,6 +486,12 @@ class WebRTCPeerSession:
self._do_audio_root(msg)
elif mtype == MNP.PHOTO_ROOTS:
self._do_photo_roots(msg)
+ elif mtype == MNP.APP_DIRECTORIES:
+ self._do_app_directories(msg)
+ elif mtype == MNP.CHAT_DIRECTORY:
+ self._do_chat_directory(msg)
+ elif mtype == MNP.CHAT_LINK_PREVIEW:
+ self._do_chat_link_preview(msg)
elif mtype == MNP.MEDIA_META_REQ:
self._spawn(self._do_media_meta_request(msg))
elif mtype == MNP.SEASON_META_REQ:
@@ -507,6 +518,12 @@ class WebRTCPeerSession:
self._do_root_add(msg)
elif mtype == MNP.ROOT_REMOVE:
self._do_root_remove(msg)
+ elif mtype == MNP.ROOT_UPDATE:
+ self._do_root_update(msg)
+ elif mtype == MNP.ROOT_EJECT:
+ self._do_root_eject(msg)
+ elif mtype == MNP.ROOT_PLUG:
+ self._do_root_plug(msg)
elif mtype == MNP.ROSTER_READ:
self._spawn(self._do_roster_read(msg))
elif mtype == MNP.DENYLIST_READ:
@@ -754,10 +771,12 @@ class WebRTCPeerSession:
# channel and nothing else.
config = {
"is_node_admin": self._is_node_admin(),
- # So the interface knows whether to offer uploading at all. Not a
- # permission — the node refuses regardless — but without it the
- # only way to discover the answer is to try.
- "member_upload": bool(self._group_ctx().get("member_upload", True)),
+ # Backward compat for MNP 1.0 clients: computed from writable roots.
+ # New clients read per-root writable from the index payload instead.
+ "member_upload": any(
+ r.get("writable") for r in
+ (self._group_ctx().get("roots").describe()
+ if self._group_ctx().get("roots") else [])),
# Which group "applications" to show. Absent/empty falls back to
# every registered one client-side, so a node that predates this
# setting (or one whose context has not loaded it yet) hides
@@ -791,6 +810,23 @@ class WebRTCPeerSession:
# (docs/photos.md §2.1). Empty means unset (the Photos tab shows
# nothing yet).
"photo_roots": list(self._group_ctx().get("photo_roots") or []),
+ # The same three answers in one shape, plus every other app's —
+ # `<app>_directories`, keyed by the app's registry name, always a
+ # list. The scalars above are derived from these (daemon.py's
+ # `_app_directories_ctx`) and kept for MNP 1.0 clients, which can
+ # represent one folder and never more. A client reading the plural
+ # form gets all of them.
+ **{f"{app}_directories":
+ list(self._group_ctx().get(f"{app}_directories") or [])
+ for app in ("video", "music", "photo", "chat")},
+ # Where chat attachments are written — the singular form, because
+ # Chat genuinely has one destination. "" means the operator has not
+ # chosen, and the paperclip says so.
+ "chat_directory": self._group_ctx().get("chat_directory") or "",
+ # Whether the node unfurls links members post here. Absent means
+ # on, which is what it did before this existed.
+ "chat_link_preview": bool(
+ self._group_ctx().get("chat_link_preview", True)),
# So a client that connects mid-scan shows the indexing state
# immediately, instead of waiting for the next periodic
# INDEX_PROGRESS push. Never a path or filename — see
@@ -1559,6 +1595,28 @@ class WebRTCPeerSession:
"detail": "Choose a folder to create this in"})
return
+ # Read-only means read-only, and creating a folder writes to the
+ # operator's disk. `_do_file_upload` gained this check with the RO/RW
+ # model and this one did not — so a member could not add a file to a
+ # published library but could still leave empty directories in it.
+ owner = roots.split(parent_rel)
+ if owner is None:
+ self._send({"type": "error", "detail": "Invalid directory"})
+ return
+ parent_root, _tail = owner
+ if not parent_root.writable:
+ self._send({"type": "error",
+ "detail": f"Directory '{parent_root.name}' is read-only",
+ "code": "root_read_only"})
+ self._audit("dir_create_refused", parent_rel[:64])
+ return
+ if not parent_root.available:
+ self._send({"type": "error",
+ "detail": f"Directory '{parent_root.name}' is "
+ f"currently unavailable",
+ "code": "root_unavailable"})
+ return
+
parent = safe_subdir(roots, parent_rel)
if parent is None or not parent.is_dir():
self._send({"type": "error", "detail": "Invalid directory"})
@@ -1756,52 +1814,12 @@ class WebRTCPeerSession:
"user_id": user_id})
def _do_member_upload(self, msg: dict) -> None:
- """
- Turn uploading by ordinary members on or off, for this group.
-
- Signed like every other operator action. The setting decides who may
- write to the operator's disk, so a node that took it from an unsigned
- message would let any member turn it back on for everyone — the control
- would be a suggestion.
- """
- if "allowed" not in msg:
- self._send({"type": "error", "detail": "Missing allowed"})
- return
- if not self._has_admin_authority():
- self._send({"type": "error", "detail": "No authorized key for this"})
- return
- # The subject is what the operator is shown before signing, so it has to
- # name the outcome rather than the operation.
- self._issue_admin_challenge(
- OP_MEMBER_UPLOAD, "on" if msg.get("allowed") else "off")
-
- async def _admin_exec_member_upload(
- self, pending: dict, transcript: bytes, sig: bytes,
- ) -> None:
- allowed = pending["subject"] == "on"
- if not await self._verify_admin_sig(transcript, sig):
- self._send({"type": "error", "detail": "Signature verification failed"})
- self._audit("admin_auth_failed", f"member_upload:{pending['subject']}")
- return
- try:
- await self._run_op(
- ops.set_member_upload, self._group_id or "", allowed)
- except ops.OpError as e:
- self._send({"type": "error", "detail": e.message})
- return
- self._audit("member_upload", pending["subject"])
-
- # Everyone already connected is told, rather than finding out by having
- # an upload refused. Enforcement does not depend on this reaching them —
- # it is the node that refuses — but a button that stays visible until
- # the next reconnection is a button people press.
- notice = {"type": MNP.MEMBER_UPLOAD_ACK, "v": MNP_VERSION,
- "allowed": allowed}
- for uid, session in list(self._peer_registry().items()):
- try:
- session._send(notice)
- except Exception:
- pass
+ # Deprecated: upload control is now per-root via writable flag.
+ # Old clients may still send this — acknowledge without acting.
+ log.warning("Deprecated member_upload message received — use root "
+ "writable/read-only instead")
+ self._send({"type": MNP.MEMBER_UPLOAD_ACK, "v": MNP_VERSION,
+ "allowed": True, "deprecated": True})
# Every "application" a group can show. Photos joins this set (and
# apps.js's registry, client-side) when it lands; nothing else about
@@ -1810,13 +1828,20 @@ class WebRTCPeerSession:
# network calls (TMDB, MusicBrainz) once enabled, so an operator opts a
# group in explicitly rather than getting it for free
# (docs/mediacenter.md §5.6, docs/musicbay.md §4.4).
- ALLOWED_APPS = frozenset({"chat", "files", "video", "music", "photo"})
+ # `helloworld` is the reference implementation (docs/refactor-groups.md
+ # §4.1), hidden client-side behind `?dev=1`. It is here because the
+ # allow-list is server-side enforcement — a client that names an app this
+ # node does not know is refused — and an app the node refused could not
+ # demonstrate anything. This entry and the client's registry line are the
+ # whole of what adding an application costs.
+ ALLOWED_APPS = frozenset({"chat", "files", "video", "music", "photo",
+ "helloworld"})
def _do_apps_enabled(self, msg: dict) -> None:
"""
Turn a group "application" on or off for everyone, for this group.
- Signed like `member_upload`: this decides what a member sees, and an
+ Signed like the root ops: this decides what a member sees, and an
unsigned message would let any member turn a disabled one back on.
"""
apps = msg.get("apps")
@@ -1828,6 +1853,12 @@ class WebRTCPeerSession:
self._send({"type": "error",
"detail": f"Unknown app(s): {', '.join(sorted(unknown))}"})
return
+ # Files is not a toggle: MNP permits root exploration regardless of
+ # what this list says, so hiding the tab only ever misled. Added at the
+ # front, the same order ops.set_enabled_apps writes, so the landing-tab
+ # preference sees one list and not two.
+ if "files" not in apps:
+ apps.insert(0, "files")
if not self._has_admin_authority():
self._send({"type": "error", "detail": "No authorized key for this"})
return
@@ -1911,7 +1942,7 @@ class WebRTCPeerSession:
self._audit("tmdb_config", pending["subject"])
# Node-wide setting: every connected peer in every group is told, not
- # just this group's peers (unlike apps_enabled/member_upload/the
+ # just this group's peers (unlike apps_enabled/the root ops/the
# per-group tmdb_enabled below).
notice = {
"type": MNP.TMDB_CONFIG_ACK, "v": MNP_VERSION,
@@ -2119,6 +2150,150 @@ class WebRTCPeerSession:
except Exception:
pass
+ # ── App directories (generic) ────────────────────────────────────────
+
+ def _do_app_directories(self, msg: dict) -> None:
+ """
+ Which folder(s) an application works over, for any application.
+
+ One handler where there were three near-identical ones (`video_root`,
+ `audio_root`, `photo_roots`) differing only in the key they wrote and
+ whether they carried a string or a list. Those three still exist for
+ clients that speak them; nothing new is added beside them.
+
+ `app` must be one this node knows (`ALLOWED_APPS`) — a client-supplied
+ key is otherwise a way to write arbitrary rows into `group_settings`.
+ The paths are checked by `ops._validate_app_dirs`, which runs after the
+ signature: this is a settings change, not a capability, so refusing
+ early here would be a courtesy rather than the control.
+ """
+ app = str(msg.get("app", "")).strip()
+ dirs = msg.get("directories")
+ if app not in self.ALLOWED_APPS:
+ self._send({"type": "error", "detail": f"Unknown app {app!r}"})
+ return
+ if not isinstance(dirs, list) or not all(isinstance(d, str) for d in dirs):
+ self._send({"type": "error",
+ "detail": "Missing or invalid 'directories'"})
+ return
+ clean = sorted({d.strip("/") for d in dirs if d.strip("/")})
+ if not self._has_admin_authority():
+ self._send({"type": "error", "detail": "No authorized key for this"})
+ return
+ # The app is in the subject, not only the paths: an operator shown
+ # "Media/Films" alone cannot tell which application is about to be
+ # pointed at it, and two apps' challenges would be indistinguishable.
+ self._issue_admin_challenge(
+ OP_APP_DIRECTORIES, f"{app}:{','.join(clean)}")
+
+ async def _admin_exec_app_directories(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ app, _, joined = pending["subject"].partition(":")
+ dirs = joined.split(",") if joined else []
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed", f"app_directories:{pending['subject']}")
+ return
+ try:
+ result = await self._run_op(
+ ops.set_app_directories, self._group_id or "", app, dirs)
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
+ return
+ self._audit("app_directories", pending["subject"])
+ self._broadcast_to_group({"type": MNP.APP_DIRECTORIES_ACK,
+ "v": MNP_VERSION, "app": app,
+ "directories": result["directories"]})
+
+ # ── Chat ─────────────────────────────────────────────────────────────
+
+ def _do_chat_directory(self, msg: dict) -> None:
+ """
+ Where chat attachments are written.
+
+ Unlike every other app directory this one is a destination, so it has
+ to be on a read-write root — checked by `ops.set_chat_directory` after
+ the signature, which is where the refusal actually lives.
+ """
+ path = msg.get("path")
+ if not isinstance(path, str):
+ self._send({"type": "error", "detail": "Missing or invalid 'path'"})
+ return
+ path = path.strip("/")
+ if not self._has_admin_authority():
+ self._send({"type": "error", "detail": "No authorized key for this"})
+ return
+ self._issue_admin_challenge(OP_CHAT_DIRECTORY, path)
+
+ async def _admin_exec_chat_directory(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ path = pending["subject"]
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed", f"chat_directory:{path}")
+ return
+ try:
+ await self._run_op(
+ ops.set_chat_directory, self._group_id or "", path)
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
+ return
+ self._audit("chat_directory", path)
+ self._broadcast_to_group(
+ {"type": MNP.CHAT_DIRECTORY_ACK, "v": MNP_VERSION, "path": path})
+
+ def _do_chat_link_preview(self, msg: dict) -> None:
+ """
+ Whether the node fetches a page's title and image when a member posts
+ a link — outbound traffic on the operator's connection, from a message
+ they did not write, so it is signed like everything else that decides
+ what leaves this machine.
+ """
+ enabled = msg.get("enabled")
+ if not isinstance(enabled, bool):
+ self._send({"type": "error", "detail": "Missing or invalid 'enabled'"})
+ return
+ if not self._has_admin_authority():
+ self._send({"type": "error", "detail": "No authorized key for this"})
+ return
+ self._issue_admin_challenge(
+ OP_CHAT_LINK_PREVIEW, "on" if enabled else "off")
+
+ async def _admin_exec_chat_link_preview(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ enabled = pending["subject"] == "on"
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed",
+ f"chat_link_preview:{pending['subject']}")
+ return
+ try:
+ await self._run_op(
+ ops.set_chat_link_preview, self._group_id or "", enabled)
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
+ return
+ self._audit("chat_link_preview", pending["subject"])
+ self._broadcast_to_group({"type": MNP.CHAT_LINK_PREVIEW_ACK,
+ "v": MNP_VERSION, "enabled": enabled})
+
+ def _broadcast_to_group(self, notice: dict) -> None:
+ """
+ Tell everyone connected to this group about a setting that changed.
+
+ Enforcement never depends on this reaching them — the node is what
+ refuses — but a control that stays on screen until the next
+ reconnection is a control people use.
+ """
+ for _uid, session in list(self._peer_registry().items()):
+ try:
+ session._send(notice)
+ except Exception:
+ pass
+
def _do_musicbrainz_enabled(self, msg: dict) -> None:
"""
Whether MusicBrainz lookups run for this group at all. Per-group
@@ -2308,11 +2483,14 @@ class WebRTCPeerSession:
if not self._has_admin_authority():
self._send({"type": "error", "detail": "No authorized key for this"})
return
- upload_dir = str(msg.get("upload_dir", "")).strip()
+ # `upload_dir` is not read here any more, and a client still sending it
+ # is ignored rather than obeyed: on load it forces every other root
+ # read-only, which is the model the RO/RW one replaced. A second
+ # writable directory is `root_add` with `writable`.
self._issue_admin_challenge(
OP_GROUP_ATTACH, name,
payload={"name": name, "shared_dir": shared_dir,
- "upload_dir": upload_dir},
+ "writable": bool(msg.get("writable", True))},
group_id="")
async def _admin_exec_group_attach(
@@ -2326,7 +2504,8 @@ class WebRTCPeerSession:
p = pending.get("payload") or {}
try:
result = await self._run_op(
- ops.attach_group, p["name"], p["shared_dir"], p.get("upload_dir", ""))
+ ops.attach_group, p["name"], p["shared_dir"],
+ writable=bool(p.get("writable", True)))
except ops.OpError as e:
self._send({"type": "error", "detail": e.message})
return
@@ -2409,7 +2588,8 @@ class WebRTCPeerSession:
"group_id": target_group, "path": path,
"name": str(msg.get("name", ""))[:128],
"kind": str(msg.get("kind", "generic"))[:16],
- "upload": bool(msg.get("upload", False)),
+ "writable": bool(msg.get("writable", msg.get("upload", False))),
+ "removable": bool(msg.get("removable", False)),
},
group_id=target_group)
@@ -2425,7 +2605,8 @@ class WebRTCPeerSession:
result = await self._run_op(
ops.add_root, p["group_id"], p["path"],
name=p.get("name", ""), kind=p.get("kind", "generic"),
- upload=p.get("upload", False))
+ writable=p.get("writable", False),
+ removable=p.get("removable", False))
except ops.OpError as e:
self._send({"type": "error", "detail": e.message})
return
@@ -2473,6 +2654,141 @@ class WebRTCPeerSession:
await self._retarget_indexer(p["group_id"])
self._send({"type": MNP.ROOT_REMOVE_ACK, "v": MNP_VERSION, **result})
+ def _do_root_update(self, msg: dict) -> None:
+ target_group = str(msg.get("group_id", self._group_id or "")).strip()
+ root_name = str(msg.get("root_name", "")).strip()
+ if not target_group or not root_name:
+ self._send({"type": "error", "detail": "Missing group_id or root_name"})
+ return
+ if not self._has_admin_authority():
+ self._send({"type": "error", "detail": "No authorized key for this"})
+ return
+ updates = []
+ if "writable" in msg:
+ updates.append(f"rw={'on' if msg['writable'] else 'off'}")
+ if "removable" in msg:
+ updates.append(f"rem={'on' if msg['removable'] else 'off'}")
+ subject = f"{root_name}:{','.join(updates)}" if updates else root_name
+ self._issue_admin_challenge(
+ OP_ROOT_UPDATE, subject,
+ payload={
+ "group_id": target_group, "root_name": root_name,
+ "writable": msg.get("writable"),
+ "removable": msg.get("removable"),
+ },
+ group_id=target_group)
+
+ async def _admin_exec_root_update(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed",
+ f"root_update:{pending['subject'][:24]}")
+ return
+ p = pending["payload"]
+ try:
+ result = await self._run_op(
+ ops.update_root, p["group_id"], p["root_name"],
+ writable=p.get("writable"), removable=p.get("removable"))
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
+ return
+ except Exception as e:
+ log.error("root_update failed: %s", e, exc_info=True)
+ self._send({"type": "error", "detail": "Internal error"})
+ return
+ self._audit("root_update", pending["subject"])
+ await self._retarget_indexer(p["group_id"])
+ notice = {"type": MNP.ROOT_UPDATE_ACK, "v": MNP_VERSION, **result}
+ for uid, session in list(self._peer_registry().items()):
+ try:
+ session._send(notice)
+ except Exception:
+ pass
+
+ def _do_root_eject(self, msg: dict) -> None:
+ target_group = str(msg.get("group_id", self._group_id or "")).strip()
+ root_name = str(msg.get("root_name", "")).strip()
+ if not target_group or not root_name:
+ self._send({"type": "error", "detail": "Missing group_id or root_name"})
+ return
+ if not self._has_admin_authority():
+ self._send({"type": "error", "detail": "No authorized key for this"})
+ return
+ self._issue_admin_challenge(
+ OP_ROOT_EJECT, root_name,
+ payload={"group_id": target_group, "root_name": root_name},
+ group_id=target_group)
+
+ async def _admin_exec_root_eject(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed",
+ f"root_eject:{pending['subject'][:24]}")
+ return
+ p = pending["payload"]
+ try:
+ result = await self._run_op(
+ ops.eject_root, p["group_id"], p["root_name"])
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
+ return
+ except Exception as e:
+ log.error("root_eject failed: %s", e, exc_info=True)
+ self._send({"type": "error", "detail": "Internal error"})
+ return
+ self._audit("root_eject", p["root_name"])
+ notice = {"type": MNP.ROOT_EJECT_ACK, "v": MNP_VERSION, **result}
+ for uid, session in list(self._peer_registry().items()):
+ try:
+ session._send(notice)
+ except Exception:
+ pass
+
+ def _do_root_plug(self, msg: dict) -> None:
+ target_group = str(msg.get("group_id", self._group_id or "")).strip()
+ root_name = str(msg.get("root_name", "")).strip()
+ if not target_group or not root_name:
+ self._send({"type": "error", "detail": "Missing group_id or root_name"})
+ return
+ if not self._has_admin_authority():
+ self._send({"type": "error", "detail": "No authorized key for this"})
+ return
+ self._issue_admin_challenge(
+ OP_ROOT_PLUG, root_name,
+ payload={"group_id": target_group, "root_name": root_name},
+ group_id=target_group)
+
+ async def _admin_exec_root_plug(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed",
+ f"root_plug:{pending['subject'][:24]}")
+ return
+ p = pending["payload"]
+ try:
+ result = await self._run_op(
+ ops.plug_root, p["group_id"], p["root_name"])
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
+ return
+ except Exception as e:
+ log.error("root_plug failed: %s", e, exc_info=True)
+ self._send({"type": "error", "detail": "Internal error"})
+ return
+ self._audit("root_plug", p["root_name"])
+ notice = {"type": MNP.ROOT_PLUG_ACK, "v": MNP_VERSION, **result}
+ for uid, session in list(self._peer_registry().items()):
+ try:
+ session._send(notice)
+ except Exception:
+ pass
+
async def _run_op(self, fn, *args, **kwargs):
"""
Call an operation from `meshbay_node.ops` with the daemon's own view.
@@ -2489,10 +2805,36 @@ class WebRTCPeerSession:
return await fn(state, *args, **kwargs)
async def _retarget_indexer(self, group_id: str) -> None:
- """Tell the indexer to rescan after roots changed."""
+ """
+ Pick up a root that was just added to or removed from node.toml.
+
+ Through the daemon's own reload, which is what the loopback API has
+ always done after the same operations (`ui/app.py`). This used to
+ re-point the indexer at `groups_ctx[gid]["roots"]` instead — the very
+ object the op had just edited — so `retarget` diffed a set against
+ itself, found no new names, scanned nothing, and dropped nothing. A
+ directory added over MNP reached node.toml and was invisible until a
+ restart; one removed kept serving its files.
+
+ Two front doors doing different things is the shape `ops.py` exists to
+ prevent, and this was it: the loopback path worked and the MNP path did
+ not, which is why it survived until the operator added a directory from
+ a browser.
+
+ Not awaited: a reload rescans, and a new library is minutes. The ack
+ the caller sends carries the set the node is moving to, and the
+ `index_sync` that follows the scan carries what it found.
+ """
state = self._ctx.get("daemon_state")
if not state:
return
+ reload_fn = state.get("reload_fn")
+ if reload_fn:
+ self._spawn(reload_fn())
+ return
+ # No daemon to ask — a test harness, or a context assembled by hand.
+ # Retarget directly, which is correct as long as the caller did not
+ # edit the live set in place.
indexer = state.get("indexers", {}).get(group_id)
roots = state.get("groups_ctx", {}).get(group_id, {}).get("roots")
if indexer and roots:
@@ -3620,6 +3962,17 @@ class WebRTCPeerSession:
"""
url = msg.get("url")
key = url if isinstance(url, str) else ""
+
+ # Checked before the cache, not after: the operator turning previews
+ # off has to stop serving the ones already fetched too, or the setting
+ # takes effect only for links nobody has posted yet. Refused as an
+ # ordinary miss — the client shows the bare link, which is exactly what
+ # "no preview" looks like for a page that has none.
+ if not self._group_ctx().get("chat_link_preview", True):
+ self._send({"type": MNP.LINK_PREVIEW_RESP, "v": MNP_VERSION,
+ "url": key, "ok": False})
+ return
+
cached = _link_preview_cache_get(key)
if cached is not None:
self._send({**cached, "type": MNP.LINK_PREVIEW_RESP, "v": MNP_VERSION})
@@ -3678,51 +4031,101 @@ class WebRTCPeerSession:
"filename": filename})
return
- # The operator can close uploading to everyone but themselves. Enforced
- # here rather than by hiding a button: the button is a courtesy to the
- # people who are not trying, and this is the part that holds against
- # someone who is. `is_node_admin` is computed from the identity this
- # node pinned, never from a hub claim.
- if not ctx.get("member_upload", True) and not self._is_node_admin():
+ roots: RootSet | None = ctx.get("roots")
+ if not roots:
self._send({"type": "error",
- "detail": "Uploading is turned off for this group",
- "code": "member_upload_off",
+ "detail": "No directories configured for this group",
"filename": filename})
- self._audit("upload_refused", filename[:64])
return
- roots: RootSet | None = ctx.get("roots")
- upload_root = roots.upload_root if roots else None
+ # The client names the root it is uploading into — it is browsing one,
+ # and with several writable roots any other choice is a guess. It names
+ # a root, never a path: the destination inside it is decided below and
+ # is not negotiable, which is what keeps C5a closed.
+ #
+ # An unknown name is refused rather than falling back to a writable
+ # root, because "the file went somewhere else" is discovered weeks
+ # later — the same reason the old single upload root was never guessed.
+ # A client that names nothing is an MNP 1.0 one, and there was exactly
+ # one destination in its world: the first writable root.
+ # `dir` is the folder being browsed, as a virtual path
+ # (`Media/Films/1999`); `root` is the older, coarser form and is what
+ # its first segment means on its own.
+ target_rel = str(msg.get("dir") or "").strip().strip("/")
+ target_root_name = (target_rel.split("/")[0] if target_rel
+ else str(msg.get("root") or "").strip())
+ upload_root = None
+ if target_root_name:
+ upload_root = roots.by_name(target_root_name)
+ if upload_root is None:
+ self._send({"type": "error",
+ "detail": f"No directory named "
+ f"{target_root_name!r} in this group",
+ "code": "no_such_root",
+ "filename": filename})
+ return
+ else:
+ writable = roots.writable_roots
+ upload_root = writable[0] if writable else None
+
if upload_root is None:
- # Refused, never guessed. With several roots, picking one would send
- # a member's file to a disk the operator did not intend, and that is
- # discovered weeks later.
self._send({"type": "error",
- "detail": "No upload folder is configured for this group",
+ "detail": "No writable directory in this group",
+ "code": "no_writable_root",
+ "filename": filename})
+ return
+ if not upload_root.writable:
+ self._send({"type": "error",
+ "detail": f"Directory '{upload_root.name}' is read-only",
+ "code": "root_read_only",
"filename": filename})
+ self._audit("upload_refused", filename[:64])
return
if not upload_root.available:
- # The designated root's volume is absent. Falling back to another
- # root would scatter uploads across disks depending on what happened
- # to be plugged in.
self._send({"type": "error",
- "detail": f"The upload folder ({upload_root.name}) is "
+ "detail": f"Directory '{upload_root.name}' is "
f"currently unavailable",
+ "code": "root_unavailable",
"filename": filename})
return
- if upload_root.direct:
- rel_dir = upload_root.name
- target_dir = upload_root.path
+ # The folder the sender is looking at, and no subdirectory of the node's
+ # invention.
+ #
+ # Uploads used to be confined to `<root>/uploads/`, created on demand.
+ # That was the last of v5's quarantine (the per-user layer went on
+ # 2026-08-14, for the same reason): a shared directory nobody can
+ # organise is not a shared directory, and a folder appearing beside the
+ # operator's library because somebody sent a file is the node deciding
+ # how their disk is arranged.
+ #
+ # What made the quarantine worth having is not the subdirectory — it is
+ # the filename allowlist, the size cap, the chunk ordering, and the
+ # no-overwrite rule below. All four are unchanged.
+ #
+ # `resolve()` and not a join: it refuses `..`, absolute segments and
+ # anything whose resolved form escapes its root, symlinks included. The
+ # client names *where among the group's own folders*, never a path on
+ # the operator's filesystem.
+ if target_rel:
+ target_dir = roots.resolve(target_rel)
+ if target_dir is None or not target_dir.is_dir():
+ self._send({"type": "error",
+ "detail": "Not a directory in this group",
+ "code": "no_such_directory",
+ "filename": filename})
+ return
+ rel_dir = target_rel
else:
- rel_dir = f"{upload_root.name}/{UPLOAD_DIR_NAME}"
- target_dir = upload_root.path / UPLOAD_DIR_NAME
- try:
- target_dir.mkdir(parents=True, exist_ok=True)
- except OSError as e:
- log.warning("Cannot create upload folder in root %r: %s",
- upload_root.name, e)
- self._send({"type": "error", "detail": "Upload folder unavailable",
+ # An MNP 1.0 client names nothing; the root itself is where its one
+ # destination now is.
+ target_dir = upload_root.path
+ rel_dir = upload_root.name
+ if not target_dir.is_dir():
+ self._send({"type": "error",
+ "detail": f"Directory '{upload_root.name}' is "
+ f"currently unavailable",
+ "code": "root_unavailable",
"filename": filename})
return
@@ -3981,8 +4384,10 @@ class WebRTCPeerSession:
self._spawn(
self._admin_exec_member_unpin(pending, transcript, sig_bytes))
elif pending["op"] == OP_MEMBER_UPLOAD:
- self._spawn(
- self._admin_exec_member_upload(pending, transcript, sig_bytes))
+ log.warning("Deprecated OP_MEMBER_UPLOAD signed op — use root "
+ "writable/read-only instead")
+ self._send({"type": MNP.MEMBER_UPLOAD_ACK, "v": MNP_VERSION,
+ "allowed": True, "deprecated": True})
elif pending["op"] == OP_APPS_ENABLED:
self._spawn(
self._admin_exec_apps_enabled(pending, transcript, sig_bytes))
@@ -4019,6 +4424,24 @@ class WebRTCPeerSession:
elif pending["op"] == OP_ROOT_REMOVE:
self._spawn(
self._admin_exec_root_remove(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_APP_DIRECTORIES:
+ self._spawn(
+ self._admin_exec_app_directories(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_CHAT_DIRECTORY:
+ self._spawn(
+ self._admin_exec_chat_directory(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_CHAT_LINK_PREVIEW:
+ self._spawn(
+ self._admin_exec_chat_link_preview(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_ROOT_UPDATE:
+ self._spawn(
+ self._admin_exec_root_update(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_ROOT_EJECT:
+ self._spawn(
+ self._admin_exec_root_eject(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_ROOT_PLUG:
+ self._spawn(
+ self._admin_exec_root_plug(pending, transcript, sig_bytes))
elif pending["op"] == OP_GROUP_ATTACH:
self._spawn(
self._admin_exec_group_attach(pending, transcript, sig_bytes))
diff --git a/packages/meshbay-node/src/meshbay_node/transport/wire.py b/packages/meshbay-node/src/meshbay_node/transport/wire.py
index c683204..6986b01 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/wire.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/wire.py
@@ -89,13 +89,21 @@ def index_sync_message(index, roots: RootSet | None) -> dict:
}
-def index_delta_message(index, delta) -> dict:
+def index_delta_message(index, delta, roots=None) -> dict:
"""
One `index_delta` — what changed since the last thing this node broadcast.
Built here rather than inline in the daemon, which is where it lived and which
made it the third place an index message was constructed: precisely the drift
that produced two `index_sync` encodings and two `file_chunk` encodings before it.
+
+ `roots` rides along (MNP 1.1, additive — a 1.0 client ignores it). It used
+ to travel on `index_sync` alone, which is a *full* index and therefore only
+ ever sent on request. So a root added, removed, ejected or plugged left
+ every connected client's directory table stale until somebody reloaded the
+ page: the delta that told them something had changed was the one message
+ that could not say what. It is a handful of dicts, bounded by the number of
+ directories a group has, and it is sealed with the rest.
"""
payload = {
"base_version": delta.base_version,
@@ -104,6 +112,8 @@ def index_delta_message(index, delta) -> dict:
"deletions": list(delta.deletions),
"updates": [index_entry_wire(e) for e in delta.updates],
}
+ if roots is not None:
+ payload["roots"] = roots.describe()
return {
"type": MNP.INDEX_DELTA,
"v": MNP_VERSION,
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index 6fdc78f..fc6c04a 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -175,7 +175,7 @@ def create_ui_app(state: dict) -> FastAPI:
state,
(payload.get("name") or "").strip(),
(payload.get("shared_dir") or "").strip(),
- upload_dir=(payload.get("upload_dir") or "").strip(),
+ writable=bool(payload.get("writable", True)),
))
reload_fn = state.get("reload_fn")
if reload_fn:
@@ -349,13 +349,35 @@ def create_ui_app(state: dict) -> FastAPI:
(payload.get("path") or "").strip(),
name=(payload.get("name") or "").strip(),
kind=(payload.get("kind") or "generic").strip(),
- upload=bool(payload.get("upload", False)),
+ writable=bool(payload.get("writable",
+ payload.get("upload", False))),
+ removable=bool(payload.get("removable", False)),
))
reload_fn = state.get("reload_fn")
if reload_fn:
asyncio.ensure_future(reload_fn())
return result
+ @app.patch("/api/groups/{group_id}/roots/{root_name}")
+ async def update_root(group_id: str, root_name: str, payload: dict):
+ result = await _op(lambda: ops.update_root(
+ state, group_id, root_name,
+ writable=payload.get("writable"),
+ removable=payload.get("removable"),
+ ))
+ reload_fn = state.get("reload_fn")
+ if reload_fn:
+ asyncio.ensure_future(reload_fn())
+ return result
+
+ @app.put("/api/groups/{group_id}/roots/{root_name}/eject")
+ async def eject_root(group_id: str, root_name: str):
+ return await _op(lambda: ops.eject_root(state, group_id, root_name))
+
+ @app.put("/api/groups/{group_id}/roots/{root_name}/plug")
+ async def plug_root(group_id: str, root_name: str):
+ return await _op(lambda: ops.plug_root(state, group_id, root_name))
+
@app.delete("/api/groups/{group_id}/roots/{root_name}")
async def remove_root(group_id: str, root_name: str):
result = await _op(lambda: ops.remove_root(state, group_id, root_name))
@@ -397,14 +419,6 @@ def create_ui_app(state: dict) -> FastAPI:
"current_dir": progress.current_dir,
}
- # ── Upload toggle (operator only, localhost) ─────────────────────────
-
- @app.put("/api/groups/{group_id}/member-upload")
- async def set_member_upload(group_id: str, payload: dict):
- return await _op(lambda: ops.set_member_upload(
- state, group_id, bool(payload.get("allowed", False)),
- ))
-
# ── Enabled apps (operator only, localhost) ────────────────────────────
#
# Same loopback shape as member-upload: the Create Group wizard sets this
@@ -419,6 +433,33 @@ def create_ui_app(state: dict) -> FastAPI:
raise HTTPException(400, "apps must be a non-empty list")
return await _op(lambda: ops.set_enabled_apps(state, group_id, apps))
+ # ── App directories (operator only, localhost) ────────────────────────
+ #
+ # The loopback twin of the `app_directories` MNP op. One endpoint for every
+ # application, keyed by the app's own name, so adding one needs no route
+ # here — the same reason the op is generic. `ALLOWED_APPS` is checked on
+ # the MNP path; here the caller is already on localhost holding the run
+ # token, and `ops` refuses a directory outside the group's roots either
+ # way, so an unknown key writes one unread settings row and nothing else.
+
+ @app.put("/api/groups/{group_id}/app-directories/{app_key}")
+ async def set_app_directories(group_id: str, app_key: str, payload: dict):
+ dirs = payload.get("directories")
+ if not isinstance(dirs, list):
+ raise HTTPException(400, "directories must be a list")
+ return await _op(lambda: ops.set_app_directories(
+ state, group_id, app_key, [str(d) for d in dirs]))
+
+ @app.put("/api/groups/{group_id}/chat-directory")
+ async def set_chat_directory(group_id: str, payload: dict):
+ return await _op(lambda: ops.set_chat_directory(
+ state, group_id, str(payload.get("path") or "")))
+
+ @app.put("/api/groups/{group_id}/chat-link-preview")
+ async def set_chat_link_preview(group_id: str, payload: dict):
+ return await _op(lambda: ops.set_chat_link_preview(
+ state, group_id, bool(payload.get("enabled", True))))
+
# ── Scan settings (operator only, localhost) ──────────────────────────
@app.put("/api/groups/{group_id}/scan-settings")
diff --git a/packages/meshbay-node/tests/conftest.py b/packages/meshbay-node/tests/conftest.py
index 20724aa..3dc9cd9 100644
--- a/packages/meshbay-node/tests/conftest.py
+++ b/packages/meshbay-node/tests/conftest.py
@@ -24,14 +24,18 @@ win32_todo = pytest.mark.skipif(
)
-def one_root(path: Path, *, name: str = "", kind: str = "generic") -> RootSet:
+def one_root(path: Path, *, name: str = "", kind: str = "generic",
+ writable: bool = True) -> RootSet:
"""
- A RootSet with a single root over `path`, receiving uploads.
+ A RootSet with a single writable root over `path`.
The equivalent of the old `shared_dir`. Note what it implies for assertions:
a file directly in `path` now has `entry.path == <basename of path>`, not
`""` — every index path carries its root name, in a group with one root as
much as in a group with five.
+
+ Writable by default because most callers are testing something else and
+ want a root an upload can reach. `writable=False` is the read-only group.
"""
return RootSet.build([{"path": str(path), "name": name, "kind": kind,
- "upload": True}])
+ "writable": writable}])
diff --git a/packages/meshbay-node/tests/test_app_directories.py b/packages/meshbay-node/tests/test_app_directories.py
new file mode 100644
index 0000000..3ede1b6
--- /dev/null
+++ b/packages/meshbay-node/tests/test_app_directories.py
@@ -0,0 +1,292 @@
+"""
+One shape for every application's directories.
+
+`video_root` (a string), `audio_root` (a string) and `photo_roots` (a list)
+said the same thing three ways, and each needed its own op, its own MNP message
+and its own settings widget. They are one function keyed by the app's own name
+now, which is what lets an application be added without touching this layer at
+all — the whole claim of the plugin architecture.
+
+Two properties are new rather than moved, and both matter more than the tidying:
+
+* **the paths are validated.** The setters this replaces accepted anything. A
+ typo, or a path left behind when a root was removed, was stored happily and
+ then matched no entry — an app showing an empty tab, with nothing to
+ distinguish "misconfigured" from "no files yet". The moment of setting is the
+ only one where the operator is present to be told;
+* **the legacy scalar is derived, never stored.** `video_root` still rides on
+ the handshake ack for MNP 1.0 clients. Kept as a second stored value it would
+ drift from the list within one run — the shape of bug that reads as "it works
+ after a restart".
+"""
+
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from meshbay_node import ops
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.roots import RootSet
+from meshbay_node.roster import Roster
+
+pytestmark = pytest.mark.asyncio
+
+GROUP = "g" * 32
+
+
+async def _state(tmp_path: Path, *, writable: bool = True) -> tuple[dict, Roster]:
+ media = tmp_path / "Media"
+ (media / "Films").mkdir(parents=True)
+ (media / "Albums").mkdir()
+ published = tmp_path / "Published"
+ published.mkdir()
+
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
+ roots = RootSet.build([
+ {"path": str(media), "writable": writable},
+ {"path": str(published)},
+ ])
+ state = {
+ "roster": roster,
+ "node_user_id": "operator",
+ "groups_ctx": {GROUP: {"index": index, "roots": roots}},
+ "config": SimpleNamespace(groups=[SimpleNamespace(id=GROUP, roots=[])]),
+ }
+ return state, roster
+
+
+# ── One function, any app ────────────────────────────────────────────────────
+
+async def test_an_app_nobody_wrote_code_for_stores_its_directories(tmp_path):
+ """
+ The point of the generic pair. Nothing in ops.py, roster.py or the daemon
+ names this app, and it round-trips anyway — which is the difference between
+ a plugin architecture and a list of special cases.
+ """
+ state, roster = await _state(tmp_path)
+ try:
+ await ops.set_app_directories(state, GROUP, "helloworld", ["Media/Films"])
+ assert await roster.app_directories(GROUP, "helloworld") == ["Media/Films"]
+ finally:
+ await roster.close()
+
+
+async def test_directories_are_deduplicated_and_ordered(tmp_path):
+ """
+ The stored form is what the operator signs a subject built from, on both
+ sides. Two clients sending the same set in different orders must produce
+ the same bytes, or one of them refuses to sign its own request.
+ """
+ state, roster = await _state(tmp_path)
+ try:
+ out = await ops.set_app_directories(
+ state, GROUP, "video", ["Media/Films", "Media", "Media/Films"])
+ assert out["directories"] == ["Media", "Media/Films"]
+ finally:
+ await roster.close()
+
+
+async def test_a_single_directory_app_stores_a_one_element_list(tmp_path):
+ state, roster = await _state(tmp_path)
+ try:
+ out = await ops.set_app_directory(state, GROUP, "chat", "Media/Films",
+ require_writable=True)
+ assert out["path"] == "Media/Films"
+ assert await roster.app_directories(GROUP, "chat") == ["Media/Films"]
+
+ cleared = await ops.set_app_directory(state, GROUP, "chat", "")
+ assert cleared["path"] == ""
+ assert await roster.app_directories(GROUP, "chat") == []
+ finally:
+ await roster.close()
+
+
+# ── Validation ───────────────────────────────────────────────────────────────
+
+@pytest.mark.parametrize("bad", [
+ "Nowhere", "Nowhere/Deeper", "/etc", "Media/../../etc", "..",
+])
+async def test_a_directory_outside_every_root_is_refused(tmp_path, bad):
+ state, roster = await _state(tmp_path)
+ try:
+ with pytest.raises(ops.OpError):
+ await ops.set_app_directories(state, GROUP, "video", [bad])
+ assert await roster.app_directories(GROUP, "video") == []
+ finally:
+ await roster.close()
+
+
+async def test_a_read_only_root_is_refused_where_writability_is_required(tmp_path):
+ """
+ Chat's directory is a destination, not a view. Storing one on a read-only
+ root would produce a paperclip that fails at the moment somebody uses it,
+ which is the failure the RO/RW model exists to move earlier.
+ """
+ state, roster = await _state(tmp_path)
+ try:
+ with pytest.raises(ops.OpError, match="read-only"):
+ await ops.set_app_directory(state, GROUP, "chat", "Published",
+ require_writable=True)
+ # The same path is fine for an app that only reads it.
+ await ops.set_app_directories(state, GROUP, "video", ["Published"])
+ assert await roster.app_directories(GROUP, "video") == ["Published"]
+ finally:
+ await roster.close()
+
+
+async def test_a_directory_on_an_unplugged_drive_can_still_be_configured(tmp_path):
+ """
+ Deliberately *not* `RootSet.resolve()`, which also refuses a root that is
+ currently unavailable. An operator must be able to point an app at a
+ library on a drive they have ejected — what is checked is the shape, which
+ does not change with what happens to be mounted.
+ """
+ state, roster = await _state(tmp_path)
+ roots = state["groups_ctx"][GROUP]["roots"]
+ roots.roots[0].available = False
+ try:
+ out = await ops.set_app_directories(state, GROUP, "video", ["Media/Films"])
+ assert out["directories"] == ["Media/Films"]
+ finally:
+ await roster.close()
+
+
+# ── The derived scalar ───────────────────────────────────────────────────────
+
+async def test_the_legacy_scalar_follows_the_list_in_the_live_context(tmp_path):
+ """
+ `video_root` rides on the handshake ack for MNP 1.0 clients and is read
+ from the group context. Left behind by a save, it would disagree with the
+ list until the next restart.
+ """
+ state, roster = await _state(tmp_path)
+ ctx = state["groups_ctx"][GROUP]
+ try:
+ await ops.set_app_directories(state, GROUP, "video",
+ ["Media/Films", "Media/Albums"])
+ assert ctx["video_directories"] == ["Media/Albums", "Media/Films"]
+ assert ctx["video_root"] == "Media/Albums", (
+ "the scalar must be the first of the list, not a stale value")
+
+ await ops.set_app_directories(state, GROUP, "video", [])
+ assert ctx["video_root"] == ""
+ finally:
+ await roster.close()
+
+
+async def test_the_photo_alias_stays_a_list_and_chat_stays_a_string(tmp_path):
+ """The alias table has to carry the shape, not just the name."""
+ state, roster = await _state(tmp_path)
+ ctx = state["groups_ctx"][GROUP]
+ try:
+ await ops.set_app_directories(state, GROUP, "photo",
+ ["Media/Films", "Media/Albums"])
+ assert ctx["photo_roots"] == ["Media/Albums", "Media/Films"]
+ await ops.set_app_directory(state, GROUP, "chat", "Media",
+ require_writable=True)
+ assert ctx["chat_directory"] == "Media"
+ finally:
+ await roster.close()
+
+
+# ── Reading what an older node stored ────────────────────────────────────────
+
+async def test_an_existing_video_root_is_read_without_a_migration(tmp_path):
+ """
+ A node upgraded into this reads its old key until the first save through
+ the new path. Requiring a migration script to run before the Videos tab
+ works again would be a step nobody performs on the machine where it
+ matters.
+ """
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ try:
+ await roster.set_setting(GROUP, "video_root", "Media/Films")
+ assert await roster.app_directories(GROUP, "video") == ["Media/Films"]
+
+ await roster.set_setting(GROUP, "audio_root", "Media/Albums")
+ assert await roster.app_directories(GROUP, "music") == ["Media/Albums"]
+
+ await roster.set_setting(GROUP, "photo_roots", '["A", "B"]')
+ assert await roster.app_directories(GROUP, "photo") == ["A", "B"]
+ finally:
+ await roster.close()
+
+
+async def test_an_empty_legacy_value_means_nothing_configured(tmp_path):
+ """`video_root = ""` was how "unset" was spelled; it must not become `[""]`."""
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ try:
+ await roster.set_setting(GROUP, "video_root", "")
+ assert await roster.app_directories(GROUP, "video") == []
+ finally:
+ await roster.close()
+
+
+async def test_the_new_key_wins_over_the_legacy_one(tmp_path):
+ """
+ Both present is a group saved once through the new path. Reading the old
+ key there would undo that save on every load.
+ """
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ try:
+ await roster.set_setting(GROUP, "video_root", "Old/Place")
+ await roster.set_app_directories(GROUP, "video", ["New/Place"])
+ assert await roster.app_directories(GROUP, "video") == ["New/Place"]
+ finally:
+ await roster.close()
+
+
+async def test_an_app_with_no_legacy_name_simply_has_none(tmp_path):
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ try:
+ assert await roster.app_directories(GROUP, "helloworld") == []
+ finally:
+ await roster.close()
+
+
+# ── Chat's own settings ──────────────────────────────────────────────────────
+
+async def test_link_previews_default_on_and_survive_a_restart(tmp_path):
+ """
+ Absent means on, because that is what the node did before the switch
+ existed — an upgrade must not silently change what a group's chat does.
+ """
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ try:
+ assert await roster.chat_link_preview(GROUP) is True
+ await roster.set_chat_link_preview(GROUP, False, set_by="op")
+ assert await roster.chat_link_preview(GROUP) is False
+ finally:
+ await roster.close()
+
+ reopened = Roster(db_path=tmp_path / "roster.db")
+ await reopened.open()
+ try:
+ assert await reopened.chat_link_preview(GROUP) is False
+ assert await reopened.chat_link_preview("other") is True, (
+ "one group's setting must not answer for another")
+ finally:
+ await reopened.close()
+
+
+async def test_setting_link_previews_updates_the_live_context(tmp_path):
+ """
+ The unfurl handler reads the context, not the database — it runs per
+ message and a round trip there would be absurd. So the two are kept in
+ step by the op that changes it.
+ """
+ state, roster = await _state(tmp_path)
+ try:
+ await ops.set_chat_link_preview(state, GROUP, False)
+ assert state["groups_ctx"][GROUP]["chat_link_preview"] is False
+ finally:
+ await roster.close()
diff --git a/packages/meshbay-node/tests/test_apps_enabled_policy.py b/packages/meshbay-node/tests/test_apps_enabled_policy.py
index 671005a..ac44ab3 100644
--- a/packages/meshbay-node/tests/test_apps_enabled_policy.py
+++ b/packages/meshbay-node/tests/test_apps_enabled_policy.py
@@ -1,7 +1,7 @@
"""
The operator decides which group "applications" (Chat, Files, ...) are shown.
-Same shape as `test_member_upload_policy.py`, because it is the same kind of
+Same shape as `test_root_writable_policy.py`, because it is the same kind of
setting: changed by a signed operator instruction, stored on the node rather
than the hub, and safe for an existing group to have never heard of. The two
things specific to this one: the whole set is signed in one message rather
@@ -88,7 +88,7 @@ async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path):
async def test_changing_it_needs_a_signature(tmp_path):
"""The request only ever produces a challenge. Nothing is applied until a
- signature over the transcript verifies — the same path as member_upload."""
+ signature over the transcript verifies — the same path as the root ops."""
session = _session(tmp_path, "op", operator="op")
session._has_admin_authority = lambda: True
issued = []
diff --git a/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py b/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py
index deab1bd..5a8f9b1 100644
--- a/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py
+++ b/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py
@@ -102,7 +102,7 @@ async def test_only_entries_under_the_configured_root_are_enriched(tmp_path):
daemon = await _make_daemon(tmp_path, shared, group_id)
try:
- await daemon._roster.set_audio_root(group_id, "shared/Music", set_by="op")
+ await daemon._roster.set_app_directories(group_id, "music", ["shared/Music"], set_by="op")
indexer = DirectoryIndexer(
roots=one_root(shared), group_id=group_id,
sk_node=Ed25519PrivateKey.generate(), gek=generate_gek())
@@ -142,8 +142,10 @@ async def test_setting_the_audio_root_sweeps_what_it_already_contains(tmp_path):
# this file all along.
state = {
"roster": daemon._roster,
- "groups_ctx": {group_id: {}},
- "enrich_audio_root_fn": daemon._enrich_audio_root_now,
+ # Real roots, because ops now refuses a directory that is not
+ # inside one — the per-app setters this replaced validated nothing.
+ "groups_ctx": {group_id: {"roots": one_root(shared)}},
+ "enrich_app_dirs_fns": {"music": daemon._enrich_audio_root_now},
}
await ops.set_audio_root(state, group_id, "shared/Music")
await asyncio.sleep(0.05) # let the fire-and-forget sweep actually run
@@ -197,8 +199,10 @@ async def test_the_same_file_shared_into_two_groups_enriches_in_both(tmp_path):
"the fixture itself must produce identical content hashes — "
"otherwise this test isn't exercising the collision at all")
- await daemon._roster.set_audio_root(group_a, "shared_a/Music", set_by="op")
- await daemon._roster.set_audio_root(group_b, "shared_b/Music", set_by="op")
+ await daemon._roster.set_app_directories(
+ group_a, "music", ["shared_a/Music"], set_by="op")
+ await daemon._roster.set_app_directories(
+ group_b, "music", ["shared_b/Music"], set_by="op")
await daemon._enrich_new_audio_entries(indexer_a, list(indexer_a.index.entries))
await daemon._enrich_new_audio_entries(indexer_b, list(indexer_b.index.entries))
diff --git a/packages/meshbay-node/tests/test_audio_root_policy.py b/packages/meshbay-node/tests/test_audio_root_policy.py
index 576c08a..e2e9254 100644
--- a/packages/meshbay-node/tests/test_audio_root_policy.py
+++ b/packages/meshbay-node/tests/test_audio_root_policy.py
@@ -125,17 +125,20 @@ async def test_the_setting_lives_on_the_node_and_survives_a_restart(tmp_path):
roster = Roster(db_path=tmp_path / "roster.db")
await roster.open()
try:
- assert await roster.audio_root("g1") == "", "absent must mean unset"
- await roster.set_audio_root("g1", "shared/Music", set_by="op")
- assert await roster.audio_root("g1") == "shared/Music"
+ assert await roster.app_directories("g1", "music") == [], (
+ "absent must mean unset")
+ await roster.set_app_directories("g1", "music", ["shared/Music"],
+ set_by="op")
+ assert await roster.app_directories("g1", "music") == ["shared/Music"]
finally:
await roster.close()
reopened = Roster(db_path=tmp_path / "roster.db")
await reopened.open()
try:
- assert await reopened.audio_root("g1") == "shared/Music"
- assert await reopened.audio_root("g2") == "", "one group's setting must not answer for another"
+ assert await reopened.app_directories("g1", "music") == ["shared/Music"]
+ assert await reopened.app_directories("g2", "music") == [], (
+ "one group's setting must not answer for another")
finally:
await reopened.close()
@@ -239,7 +242,7 @@ async def test_a_real_signed_save_persists_and_survives_a_fresh_roster_read(tmp_
assert session._ctx["groups"][GROUP]["audio_root"] == "shared/Music", (
"the live in-memory context must reflect the new root immediately")
- assert await roster.audio_root(GROUP) == "shared/Music", (
+ assert await roster.app_directories(GROUP, "music") == ["shared/Music"], (
"the same Roster instance must read back what it just wrote")
finally:
await roster.close()
@@ -249,7 +252,7 @@ async def test_a_real_signed_save_persists_and_survives_a_fresh_roster_read(tmp_
# actually answers "does it survive a reload".
reopened = await open_roster(tmp_path)
try:
- assert await reopened.audio_root(GROUP) == "shared/Music", (
+ assert await reopened.app_directories(GROUP, "music") == ["shared/Music"], (
"a freshly-opened Roster against the same db file must see the "
"committed value — anything else means the write was never "
"durable in the first place")
diff --git a/packages/meshbay-node/tests/test_cli_dispatch.py b/packages/meshbay-node/tests/test_cli_dispatch.py
index f58c020..cf91564 100644
--- a/packages/meshbay-node/tests/test_cli_dispatch.py
+++ b/packages/meshbay-node/tests/test_cli_dispatch.py
@@ -26,6 +26,15 @@ VERBS = [
["status"],
["group", "list"],
["group", "add"], # missing --dir: usage, then exit
+ ["group", "add", "g", "--dir", "/tmp/media", "--no-writable"],
+ ["root", "list"],
+ ["root", "add"], # missing path: usage, then exit
+ ["root", "add", "/tmp/media", "--writable", "--removable"],
+ ["root", "remove", "media", "--yes"],
+ ["root", "set", "media", "--no-writable"],
+ ["root", "set", "media"], # nothing to change: usage, then exit
+ ["root", "eject", "media"],
+ ["root", "plug", "media"],
["gek", "init"],
["gek", "rotate", "--yes"],
["gek-init"],
@@ -33,6 +42,9 @@ VERBS = [
["member", "invite", "bob"],
["member", "revoke", "bob"],
["member", "unpin", "bob"],
+ # Removed, and it has to say so rather than offering a username for a verb
+ # that no longer takes one.
+ ["member", "upload"],
["operator", "pair"],
["file", "list"],
["file", "rm", "abc", "--yes"],
@@ -224,3 +236,57 @@ def test_a_bare_invocation_with_no_config_yet_exits_cleanly(monkeypatch, tmp_pat
assert "meshbay-node init" in capsys.readouterr().out
assert not missing_config.parent.exists(), (
"a fresh, unprovisioned start must not create anything on disk")
+
+
+def test_a_removed_verb_says_what_replaced_it():
+ """
+ `member upload` used to set a group-wide switch that no longer exists. It
+ reached the usage line for the *other* member verbs — "usage: meshbay-node
+ member upload <username>" — which advertises a removed feature and sends
+ the operator looking for a username it would then reject.
+
+ Naming it costs three lines and is the difference between an operator
+ finding `root set --writable` and concluding the CLI is broken.
+ """
+ import inspect
+ source = inspect.getsource(daemon_mod.main)
+ start = source.index('if args.command == "member":')
+ block = source[start:source.index('if args.command == "group":', start)]
+
+ assert 'sub == "upload"' in block, (
+ "`member upload` falls through to the generic usage line")
+ guidance = block[block.index('sub == "upload"'):]
+ guidance = guidance[:guidance.index("sys.exit")]
+ assert "root set" in guidance and "--writable" in guidance, (
+ "the message does not name what replaced it")
+
+
+def test_there_is_no_way_to_create_a_group_in_the_old_shape():
+ """
+ `--upload-dir` is gone, and documenting it as deprecated was the wrong
+ answer — which is what it got at first.
+
+ It wrote `upload_dir` into a brand-new `[[groups]]` block, and
+ `GroupConfig.__post_init__` reads that by forcing *every other root
+ read-only* and appending that path as the one writable one. So
+ `group add --dir X --writable --upload-dir Y` silently made X read-only:
+ two mechanisms deciding which directories accept uploads, one of them
+ invisible, in a group created after the model that replaced it.
+
+ Reading it stays — an existing node.toml must keep working, and that is the
+ only legitimate use. Writing it does not.
+ """
+ import inspect
+ source = inspect.getsource(daemon_mod.main)
+ assert "--upload-dir" not in source, (
+ "the CLI can still create a group in the pre-RO/RW shape")
+
+ from meshbay_node import ops
+ params = inspect.signature(ops.attach_group).parameters
+ assert "upload_dir" not in params, (
+ "attach_group still writes the legacy key")
+
+ # The read path is deliberately untouched.
+ from meshbay_node.config import GroupConfig
+ assert "upload_dir" in inspect.getsource(GroupConfig), (
+ "an existing node.toml using upload_dir would stop working")
diff --git a/packages/meshbay-node/tests/test_index_delta_carries_roots.py b/packages/meshbay-node/tests/test_index_delta_carries_roots.py
new file mode 100644
index 0000000..227f2d0
--- /dev/null
+++ b/packages/meshbay-node/tests/test_index_delta_carries_roots.py
@@ -0,0 +1,131 @@
+"""
+The message that says something changed has to be able to say what.
+
+A group's directory table travelled on `index_sync` alone — a *full* index,
+which the node only ever sends on request. Every ongoing change went out as an
+`index_delta`, which carried files and nothing else. So a root added, removed,
+ejected or plugged by the operator reached every other client's screen only
+when somebody happened to reload the page.
+
+It was hidden by the acks: `root_add_ack` and friends broadcast the new table
+to whoever was connected, so the common cases looked fine. What that could not
+cover is a client connecting mid-change, one whose ack was lost, or — the one
+that surfaced it — the operator's own client, where the ack landed and was then
+overwritten by an index fetched before the node had rebuilt anything.
+
+Additive on the wire (MNP 1.1): a 1.0 client sees a field it does not read.
+"""
+
+from pathlib import Path
+
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from meshbay_common.crypto import generate_gek
+from meshbay_common.groupbox import PURPOSE_INDEX, unseal
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.roots import RootSet
+from meshbay_node.transport.wire import index_delta_message, index_sync_message
+
+
+GROUP = "g" * 32
+
+
+def _roots(tmp_path: Path) -> RootSet:
+ for name in ("Films", "Albums"):
+ (tmp_path / name).mkdir()
+ return RootSet.build([
+ {"path": str(tmp_path / "Films"), "writable": True},
+ {"path": str(tmp_path / "Albums"), "removable": True},
+ ])
+
+
+def _index() -> GroupIndex:
+ return GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate(),
+ gek=generate_gek())
+
+
+def _payload(msg: dict, index: GroupIndex) -> dict:
+ """What a member actually reads, through the seal rather than around it."""
+ return unseal(index.gek, PURPOSE_INDEX, msg["type"], GROUP, msg)
+
+
+class _Delta:
+ base_version = 1
+ version = 2
+ additions: list = []
+ deletions: list = []
+ updates: list = []
+
+
+def test_a_delta_carries_the_directory_table(tmp_path):
+ index = _index()
+ msg = index_delta_message(index, _Delta(), _roots(tmp_path))
+ payload = _payload(msg, index)
+
+ assert [r["name"] for r in payload["roots"]] == ["Films", "Albums"]
+ assert payload["roots"][0]["writable"] is True
+ assert payload["roots"][1]["removable"] is True
+
+
+def test_the_table_is_sealed_with_the_rest(tmp_path):
+ """
+ It is group content, not routing. Only `type`, `v` and `group_id` stay in
+ clear, because a receiver has to route and authenticate before it would
+ trust a decryption.
+ """
+ index = _index()
+ msg = index_delta_message(index, _Delta(), _roots(tmp_path))
+ assert set(msg) - {"type", "v", "group_id"}, "nothing was sealed"
+ assert "roots" not in msg, "the directory table is outside the envelope"
+
+
+def test_a_delta_still_works_without_a_table(tmp_path):
+ """
+ The argument is optional, so an older caller — or a path that has no root
+ set to hand — produces a message a client reads exactly as before.
+ """
+ index = _index()
+ payload = _payload(index_delta_message(index, _Delta()), index)
+ assert "roots" not in payload
+ assert payload["version"] == 2
+
+
+def test_the_table_says_the_same_thing_on_both_messages(tmp_path):
+ """
+ Two encodings of one idea is the drift `wire.py` exists to prevent — it
+ already happened twice, for `index_sync` and for `file_chunk`.
+ """
+ index = _index()
+ roots = _roots(tmp_path)
+ delta = _payload(index_delta_message(index, _Delta(), roots), index)
+ sync = _payload(index_sync_message(index, roots), index)
+ assert delta["roots"] == sync["roots"]
+
+
+def test_the_table_never_carries_a_path(tmp_path):
+ """
+ This message goes to every member. Where a directory lives on the
+ operator's disk is theirs — see test_root_paths_are_operator_only.py.
+ """
+ index = _index()
+ payload = _payload(index_delta_message(index, _Delta(), _roots(tmp_path)),
+ index)
+ assert not any("path" in r for r in payload["roots"])
+
+
+def test_an_ejected_root_is_visible_in_the_delta(tmp_path):
+ """
+ The case this was written for. An eject changes no file — the entries
+ freeze — so the delta it produces is empty of additions, deletions and
+ updates. Without the table it says literally nothing, which is how a
+ library disappearing from under the group's feet went unannounced.
+ """
+ index = _index()
+ roots = _roots(tmp_path)
+ roots.roots[1].ejected = True
+ roots.roots[1].available = False
+
+ payload = _payload(index_delta_message(index, _Delta(), roots), index)
+ assert payload["additions"] == [] and payload["deletions"] == []
+ assert payload["roots"][1]["ejected"] is True
+ assert payload["roots"][1]["available"] is False
diff --git a/packages/meshbay-node/tests/test_member_upload_policy.py b/packages/meshbay-node/tests/test_member_upload_policy.py
deleted file mode 100644
index b1dc0cb..0000000
--- a/packages/meshbay-node/tests/test_member_upload_policy.py
+++ /dev/null
@@ -1,176 +0,0 @@
-"""
-The operator can close uploading to everyone but themselves.
-
-The point of these tests is the difference between a hidden button and a closed
-door. The interface stops offering the control, which is a courtesy to the
-people who are not trying; **the node refuses the upload**, which is the part
-that holds against someone who is. A member who kept an old tab open, or who
-speaks MNP directly, gets the same answer as everyone else.
-
-Two further things are worth holding:
-
-* the setting is changed by a **signed** operator instruction. A node that took
- it from an unsigned message would let any member turn it back on, and the
- control would be a suggestion;
-* it is stored on the **node**, not the hub. A hub that could decide who may
- write to the operator's disk is a hub with authority over the node, which is
- the thing this whole design is arranged to avoid.
-"""
-
-import base64
-from pathlib import Path
-
-import pytest
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-
-from meshbay_common.adminop import OP_MEMBER_UPLOAD
-from meshbay_node.indexer.group_index import GroupIndex
-from meshbay_node.roster import Roster
-from meshbay_node.transport.webrtc_server import WebRTCPeerSession
-
-from conftest import one_root
-
-pytestmark = pytest.mark.asyncio
-
-
-def _session(tmp_path: Path, user_id: str, *, member_upload: bool,
- operator: str | None = None) -> WebRTCPeerSession:
- shared_root = tmp_path / "shared"
- shared_root.mkdir(exist_ok=True)
- index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate())
- ctx = {
- "roots": one_root(shared_root),
- "index": index,
- "sk_node": index.sk_node,
- "member_upload": member_upload,
- "node_user_id": operator,
- }
- session = WebRTCPeerSession.__new__(WebRTCPeerSession)
- session._ctx = ctx
- session._group_id = None
- session._user_id = user_id
- session._pk_user = ""
- session._uploads = {}
- session.sent = []
- session._send = session.sent.append
- session._audit = lambda *a, **k: None
- return session
-
-
-def _upload(session, filename="clip.mp4", body=b"bytes"):
- session._do_file_upload({
- "filename": filename, "chunk_index": 0, "total_chunks": 1,
- "data": base64.b64encode(body).decode(),
- })
-
-
-def _uploads_dir(session) -> Path:
- return session._ctx["roots"].upload_root.path / "uploads"
-
-
-# ── The door, not the button ────────────────────────────────────────────────
-
-async def test_a_member_cannot_upload_when_it_is_turned_off(tmp_path):
- session = _session(tmp_path, "member-1", member_upload=False,
- operator="the-operator")
- _upload(session)
-
- assert not (_uploads_dir(session) / "clip.mp4").exists(), (
- "the file was written even though uploading is off — the setting is "
- "decorative and the hidden button was the whole control")
- refusal = [m for m in session.sent if m.get("type") == "error"]
- assert refusal and refusal[0].get("code") == "member_upload_off"
-
-
-async def test_the_operator_can_still_upload(tmp_path):
- """Otherwise turning it off locks the operator out of their own node, and
- the only way back is a config file and a restart."""
- session = _session(tmp_path, "the-operator", member_upload=False,
- operator="the-operator")
- _upload(session)
-
- assert (_uploads_dir(session) / "clip.mp4").read_bytes() == b"bytes"
-
-
-async def test_members_upload_normally_when_it_is_on(tmp_path):
- session = _session(tmp_path, "member-1", member_upload=True,
- operator="the-operator")
- _upload(session)
-
- assert (_uploads_dir(session) / "clip.mp4").read_bytes() == b"bytes"
-
-
-async def test_a_node_that_never_heard_of_the_setting_still_accepts_uploads(tmp_path):
- """An existing node's context has no such key. The absence must read as
- "allowed", or upgrading the node silently closes every group."""
- session = _session(tmp_path, "member-1", member_upload=True,
- operator="the-operator")
- del session._ctx["member_upload"]
- _upload(session)
-
- assert (_uploads_dir(session) / "clip.mp4").read_bytes() == b"bytes"
-
-
-# ── Who may change it ───────────────────────────────────────────────────────
-
-async def test_changing_it_needs_a_signature(tmp_path):
- """
- The request only ever produces a challenge. Nothing is applied until a
- signature over the transcript verifies — the same path as removing a member.
- """
- session = _session(tmp_path, "member-1", member_upload=True,
- operator="the-operator")
- session._has_admin_authority = lambda: True
- issued = []
- session._issue_admin_challenge = lambda op, subject: issued.append((op, subject))
-
- session._do_member_upload({"allowed": False})
-
- assert issued == [(OP_MEMBER_UPLOAD, "off")]
- assert session._ctx["member_upload"] is True, "applied before it was signed"
-
-
-async def test_the_subject_names_the_outcome_not_the_operation(tmp_path):
- """The operator is shown the subject before signing. "member_upload" tells
- them nothing; "off" tells them what they are about to do."""
- session = _session(tmp_path, "op", member_upload=False, operator="op")
- session._has_admin_authority = lambda: True
- issued = []
- session._issue_admin_challenge = lambda op, subject: issued.append((op, subject))
-
- session._do_member_upload({"allowed": True})
-
- assert issued == [(OP_MEMBER_UPLOAD, "on")]
-
-
-async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path):
- session = _session(tmp_path, "member-1", member_upload=True,
- operator="the-operator")
- session._has_admin_authority = lambda: False
-
- session._do_member_upload({"allowed": False})
-
- assert [m for m in session.sent if m.get("type") == "error"]
-
-
-# ── Where it is stored ──────────────────────────────────────────────────────
-
-async def test_the_setting_lives_on_the_node_and_survives_a_restart(tmp_path):
- roster = Roster(db_path=tmp_path / "roster.db")
- await roster.open()
- try:
- assert await roster.member_upload_allowed("g1") is True, (
- "absent must mean allowed, or an upgrade closes every group")
- await roster.set_member_upload("g1", False, set_by="op")
- assert await roster.member_upload_allowed("g1") is False
- finally:
- await roster.close()
-
- reopened = Roster(db_path=tmp_path / "roster.db")
- await reopened.open()
- try:
- assert await reopened.member_upload_allowed("g1") is False
- assert await reopened.member_upload_allowed("g2") is True, (
- "one group's setting must not answer for another")
- finally:
- await reopened.close()
diff --git a/packages/meshbay-node/tests/test_node_status.py b/packages/meshbay-node/tests/test_node_status.py
index b56eb6e..091b1db 100644
--- a/packages/meshbay-node/tests/test_node_status.py
+++ b/packages/meshbay-node/tests/test_node_status.py
@@ -255,7 +255,7 @@ async def test_add_root_creates_directory_and_returns_info(tmp_path):
from meshbay_node.config import NodeConfig, GroupConfig, RootSpec
cfg = GroupConfig(id=GROUP, name="test", roots=[
- RootSpec(path=str(shared), name="shared", kind="generic", upload=True),
+ RootSpec(path=str(shared), name="shared", kind="generic", writable=True),
])
conf = tmp_path / "node.toml"
@@ -295,7 +295,7 @@ async def test_remove_root_requires_at_least_one_remaining(tmp_path):
from meshbay_node.config import GroupConfig, RootSpec, NodeConfig
cfg = GroupConfig(id=GROUP, name="test", roots=[
- RootSpec(path=str(shared), name="shared", kind="generic", upload=True),
+ RootSpec(path=str(shared), name="shared", kind="generic", writable=True),
])
node_cfg = NodeConfig.__new__(NodeConfig)
node_cfg.groups = [cfg]
@@ -314,16 +314,22 @@ async def test_remove_root_requires_at_least_one_remaining(tmp_path):
await ops.remove_root(state, GROUP, "shared")
-async def test_remove_root_refuses_upload_root(tmp_path):
- d1 = tmp_path / "uploads"
+async def test_removing_a_writable_root_is_allowed(tmp_path):
+ """
+ It used to be refused: with one designated upload root, removing it left
+ the group with nowhere to put an upload and no way to say so. Several roots
+ can be writable now, and a group with none is a valid read-only group — so
+ the refusal would be protecting a state that is no longer special.
+ """
+ d1 = tmp_path / "incoming"
d2 = tmp_path / "shared"
d1.mkdir()
d2.mkdir()
from meshbay_node.config import GroupConfig, RootSpec, NodeConfig
cfg = GroupConfig(id=GROUP, name="test", roots=[
- RootSpec(path=str(d1), name="uploads", kind="generic", upload=True),
- RootSpec(path=str(d2), name="shared", kind="generic", upload=False),
+ RootSpec(path=str(d1), name="incoming", kind="generic", writable=True),
+ RootSpec(path=str(d2), name="shared", kind="generic", writable=False),
])
node_cfg = NodeConfig.__new__(NodeConfig)
node_cfg.groups = [cfg]
@@ -331,7 +337,7 @@ async def test_remove_root_refuses_upload_root(tmp_path):
conf = tmp_path / "node.toml"
conf.write_text(
f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n'
- f' [[groups.roots]]\n path = "{d1}"\n name = "uploads"\n upload = true\n\n'
+ f' [[groups.roots]]\n path = "{d1}"\n name = "incoming"\n writable = true\n\n'
f' [[groups.roots]]\n path = "{d2}"\n name = "shared"\n')
roots = RootSet.build([asdict(r) for r in cfg.roots])
index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
@@ -340,8 +346,97 @@ async def test_remove_root_refuses_upload_root(tmp_path):
"config_path": str(conf),
"groups_ctx": {GROUP: {"index": index, "roots": roots, "gek": b"\x01" * 32}},
}
- with pytest.raises(ops.OpError, match="upload root"):
- await ops.remove_root(state, GROUP, "uploads")
+ result = await ops.remove_root(state, GROUP, "incoming")
+ assert result["status"] == "removed"
+ assert [r["name"] for r in result["roots"]] == ["shared"]
+ assert conf.read_text().count("[[groups.roots]]") == 1
+
+
+async def test_update_root_rewrites_the_flags_in_node_toml(tmp_path):
+ """
+ The flags live in the operator's config file, so they survive a restart —
+ and the file is hand-written and full of comments, so the change is a line
+ edit rather than a round trip through a TOML writer that would discard
+ every one of them.
+ """
+ d1 = tmp_path / "media"
+ d1.mkdir()
+
+ from meshbay_node.config import GroupConfig, RootSpec, NodeConfig
+ cfg = GroupConfig(id=GROUP, name="test", roots=[
+ RootSpec(path=str(d1), name="media", kind="generic", writable=False),
+ ])
+ node_cfg = NodeConfig.__new__(NodeConfig)
+ node_cfg.groups = [cfg]
+
+ conf = tmp_path / "node.toml"
+ conf.write_text(
+ f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n'
+ f' [[groups.roots]]\n'
+ f' # the operator explained this one to themselves\n'
+ f' path = "{d1}"\n name = "media"\n')
+ roots = RootSet.build([asdict(r) for r in cfg.roots])
+ index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
+ state = {
+ "config": node_cfg,
+ "config_path": str(conf),
+ "groups_ctx": {GROUP: {"index": index, "roots": roots, "gek": b"\x01" * 32}},
+ }
+
+ result = await ops.update_root(state, GROUP, "media",
+ writable=True, removable=True)
+ assert result["status"] == "updated"
+ text = conf.read_text()
+ assert "writable = true" in text
+ assert "removable = true" in text
+ assert "the operator explained this one to themselves" in text, (
+ "the config file was rewritten instead of edited")
+
+ # And the live root set agrees immediately, without waiting for a reload:
+ # the loopback API reads it, and an operator who toggles a switch and sees
+ # it snap back assumes the change did not take.
+ assert roots.roots[0].writable is True
+ assert roots.roots[0].removable is True
+
+ # A second call that changes nothing must not append a duplicate line.
+ await ops.update_root(state, GROUP, "media", writable=True, removable=True)
+ assert conf.read_text().count("writable =") == 1
+
+
+async def test_update_root_replaces_a_legacy_upload_line(tmp_path):
+ """
+ A config written before the refactor says `upload = true`. Leaving it in
+ place next to a new `writable` line would give the file two answers, and
+ `RootSet.build` prefers `writable` — so the stale one would sit there
+ contradicting the running node for as long as anyone read it.
+ """
+ d1 = tmp_path / "media"
+ d1.mkdir()
+
+ from meshbay_node.config import GroupConfig, RootSpec, NodeConfig
+ cfg = GroupConfig(id=GROUP, name="test", roots=[
+ RootSpec(path=str(d1), name="media", kind="generic", writable=True),
+ ])
+ node_cfg = NodeConfig.__new__(NodeConfig)
+ node_cfg.groups = [cfg]
+
+ conf = tmp_path / "node.toml"
+ conf.write_text(
+ f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n'
+ f' [[groups.roots]]\n path = "{d1}"\n name = "media"\n'
+ f' upload = true\n')
+ roots = RootSet.build([asdict(r) for r in cfg.roots])
+ index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
+ state = {
+ "config": node_cfg,
+ "config_path": str(conf),
+ "groups_ctx": {GROUP: {"index": index, "roots": roots, "gek": b"\x01" * 32}},
+ }
+
+ await ops.update_root(state, GROUP, "media", writable=False)
+ text = conf.read_text()
+ assert "upload = true" not in text
+ assert "writable = false" in text
async def test_remove_root_succeeds_with_two_roots(tmp_path):
@@ -352,8 +447,8 @@ async def test_remove_root_succeeds_with_two_roots(tmp_path):
from meshbay_node.config import GroupConfig, RootSpec, NodeConfig
cfg = GroupConfig(id=GROUP, name="test", roots=[
- RootSpec(path=str(d1), name="dir1", kind="generic", upload=True),
- RootSpec(path=str(d2), name="dir2", kind="generic", upload=False),
+ RootSpec(path=str(d1), name="dir1", kind="generic", writable=True),
+ RootSpec(path=str(d2), name="dir2", kind="generic", writable=False),
])
node_cfg = NodeConfig.__new__(NodeConfig)
node_cfg.groups = [cfg]
diff --git a/packages/meshbay-node/tests/test_ops.py b/packages/meshbay-node/tests/test_ops.py
index 92e32bf..c118b5a 100644
--- a/packages/meshbay-node/tests/test_ops.py
+++ b/packages/meshbay-node/tests/test_ops.py
@@ -11,12 +11,14 @@ call them.
import asyncio
import inspect
-from pathlib import Path
+from pathlib import Path, PureWindowsPath
+from types import SimpleNamespace
import pytest
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from meshbay_node import ops
from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.roots import RootSet
from meshbay_node.transport.quic_server import Denylist
from conftest import one_root
@@ -64,8 +66,8 @@ def test_the_http_adapter_adds_no_logic():
# Every endpoint that performs an operation routes through _op(...).
for endpoint in ("operator_pair", "create_invite", "revoke_member",
"unpin_member", "init_gek", "attach_group", "delete_file",
- "add_root", "remove_root", "set_member_upload",
- "reload_config"):
+ "add_root", "remove_root", "update_root",
+ "eject_root", "plug_root", "reload_config"):
start = source.index(f"async def {endpoint}(")
body = source[start:start + 700]
assert "_op(" in body.split("\n\n")[0] + body, (
@@ -181,25 +183,103 @@ async def test_an_unhosted_group_offers_what_it_does_host(tmp_path):
assert exc.value.extra.get("available")
-# ── Upload policy (set_member_upload) ───────────────────────────────────────
+# ── Upload policy (per-root writable) ───────────────────────────────────────
-async def test_set_member_upload_toggles_and_persists(tmp_path):
+async def test_the_group_wide_upload_switch_is_gone(tmp_path):
+ """
+ `set_member_upload` was the whole of the old policy, and it is deliberately
+ not here any more — RO/RW on the root replaced it. A wrapper kept "for
+ compatibility" would be a second way to decide who writes to the operator's
+ disk, and two answers to that question is how C1 and C6 both happened.
+ """
+ assert not hasattr(ops, "set_member_upload")
+ from meshbay_node.roster import Roster
+ assert not hasattr(Roster, "set_member_upload")
+ assert not hasattr(Roster, "member_upload_allowed")
+
+
+async def test_eject_and_plug_persist_through_the_roster(tmp_path):
+ """
+ The state has to outlive the process: an operator ejects a drive, unplugs
+ it, and restarts the node — and the rescan that follows must not read the
+ empty mount point as an erased library.
+ """
from meshbay_node.roster import Roster
state = _state(tmp_path)
+ usb = tmp_path / "USB"
+ usb.mkdir()
+ state["groups_ctx"]["g" * 32]["roots"] = RootSet.build(
+ [{"path": str(usb), "removable": True, "writable": True}])
+ state["config"] = SimpleNamespace(
+ groups=[SimpleNamespace(id="g" * 32, roots=[])])
roster = Roster(db_path=tmp_path / "roster.db")
await roster.open()
state["roster"] = roster
state["node_user_id"] = "operator"
+ try:
+ out = await ops.eject_root(state, "g" * 32, "USB")
+ assert out["status"] == "ejected"
+ assert await roster.ejected_roots("g" * 32) == {"usb"}
+ assert out["roots"][0]["ejected"] is True
+ assert out["roots"][0]["available"] is False
- out = await ops.set_member_upload(state, "g" * 32, True)
+ out = await ops.plug_root(state, "g" * 32, "USB")
+ assert out["status"] == "plugged"
+ assert await roster.ejected_roots("g" * 32) == set()
+ finally:
+ await roster.close()
- assert out["allowed"] is True
- assert state["groups_ctx"]["g" * 32]["member_upload"] is True
- out2 = await ops.set_member_upload(state, "g" * 32, False)
+async def test_a_root_that_is_not_removable_cannot_be_ejected(tmp_path):
+ """
+ Eject means "I am about to unplug this". On a directory that is not on a
+ removable device it would hide a library with no way for the safety net to
+ notice anything happened, and nothing to plug back in.
+ """
+ from meshbay_node.roster import Roster
+ state = _state(tmp_path)
+ fixed = tmp_path / "Fixed"
+ fixed.mkdir()
+ state["groups_ctx"]["g" * 32]["roots"] = RootSet.build(
+ [{"path": str(fixed), "writable": True}])
+ state["config"] = SimpleNamespace(
+ groups=[SimpleNamespace(id="g" * 32, roots=[])])
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ state["roster"] = roster
+ try:
+ with pytest.raises(ops.OpError, match="removable"):
+ await ops.eject_root(state, "g" * 32, "Fixed")
+ finally:
+ await roster.close()
- assert out2["allowed"] is False
- assert state["groups_ctx"]["g" * 32]["member_upload"] is False
+
+async def test_plugging_a_drive_that_is_not_there_is_refused(tmp_path):
+ """
+ Clearing the flag while the device is still absent would restart the
+ watchdog on a missing path and hand the next reconcile an empty directory —
+ the deletion storm the eject was there to prevent, produced by the recovery.
+ """
+ from meshbay_node.roster import Roster
+ state = _state(tmp_path)
+ usb = tmp_path / "USB"
+ usb.mkdir()
+ roots = RootSet.build([{"path": str(usb), "removable": True}])
+ roots.roots[0].ejected = True
+ roots.roots[0].available = False
+ state["groups_ctx"]["g" * 32]["roots"] = roots
+ state["config"] = SimpleNamespace(
+ groups=[SimpleNamespace(id="g" * 32, roots=[])])
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ state["roster"] = roster
+ usb.rmdir()
+ try:
+ with pytest.raises(ops.OpError, match="device connected"):
+ await ops.plug_root(state, "g" * 32, "USB")
+ assert roots.roots[0].ejected is True
+ finally:
+ await roster.close()
# ── Reload ──────────────────────────────────────────────────────────────────
@@ -279,13 +359,47 @@ def test_update_node_toml_forces_lf_and_keeps_standalone_comments(tmp_path):
def test_a_backslash_path_written_into_node_toml_stays_parseable():
- # attach_group / add_root / init embed a directory into a TOML basic string.
- # A raw Windows path there (drive + backslash + "Users" + ...) is a parse
- # error since backslash sequences are escapes; the code writes as_posix().
+ """
+ attach_group and add_root embed a directory into a TOML basic string. A raw
+ Windows path there is a parse error, because backslash sequences are escapes
+ (`\\U`, `\\a`, ...); the code writes `as_posix()` and pathlib reads `/` back
+ on Windows.
+
+ `PureWindowsPath`, not `Path`: on this suite's usual machine `Path` is a
+ `PosixPath`, where a backslash is an ordinary filename character and
+ `as_posix()` converts nothing — so the test modelled the wrong platform and
+ failed everywhere except the one it was written for. Naming the flavour
+ explicitly is what makes it the same assertion on all three.
+ """
import tomllib
bs = chr(92)
win_dir = f"C:{bs}Users{bs}alice{bs}Media"
- assert tomllib.loads(f'path = "{Path(win_dir).as_posix()}"\n')["path"] == \
- "C:/Users/alice/Media"
+
+ assert tomllib.loads(
+ f'path = "{PureWindowsPath(win_dir).as_posix()}"\n'
+ )["path"] == "C:/Users/alice/Media"
+
with pytest.raises(tomllib.TOMLDecodeError):
tomllib.loads(f'path = "{win_dir}"\n') # the bug this guards against
+
+
+def test_every_path_written_into_node_toml_goes_through_as_posix():
+ """
+ The half the round trip above cannot see.
+
+ Proving `as_posix()` produces a parseable string says nothing about whether
+ the code calls it, and this is a defect no Linux machine can reproduce: the
+ config is written, parsed and served correctly here, and fails on the
+ operator's Windows box. So the source is read for the shape instead —
+ weak evidence, and the only kind available for a platform the suite does
+ not run on.
+ """
+ import re
+ source = inspect.getsource(ops)
+ # Every f-string interpolation that lands on the right of a TOML `path =`.
+ writes = re.findall(r'path\s*=\s*\\?"\{([^}]+)\}', source)
+ assert writes, "no TOML path writer found — did the config writer move?"
+ for expr in writes:
+ assert "as_posix()" in expr, (
+ f'node.toml path written as `{expr}` — a Windows path needs '
+ f'as_posix(), or the file it lands in will not parse')
diff --git a/packages/meshbay-node/tests/test_rename_reenrichment.py b/packages/meshbay-node/tests/test_rename_reenrichment.py
index 7a77368..f6761d9 100644
--- a/packages/meshbay-node/tests/test_rename_reenrichment.py
+++ b/packages/meshbay-node/tests/test_rename_reenrichment.py
@@ -37,8 +37,8 @@ def _free_port() -> int:
class _StubRoster:
- async def video_root(self, group_id):
- return "shared"
+ async def app_directories(self, group_id, app_key):
+ return ["shared"] if app_key == "video" else []
class _SpyEnricher:
diff --git a/packages/meshbay-node/tests/test_replug_restores_enrichment.py b/packages/meshbay-node/tests/test_replug_restores_enrichment.py
new file mode 100644
index 0000000..04a06ae
--- /dev/null
+++ b/packages/meshbay-node/tests/test_replug_restores_enrichment.py
@@ -0,0 +1,318 @@
+"""
+A root that comes back keeps its Videos/Music/Photos metadata.
+
+Reported live: a removable root ejected from the Files app and plugged back
+in returned with its files and without its albums. Music showed "no music
+found", and it did not come back.
+
+`plug_root` has to re-walk the root — the drive may have changed while it
+was away — and `_scan_root` produces bare entries: `_hash_or_cached` fills
+id/name/path/size/type and nothing else. Every enrichment field went with
+the old object, and the Music tag fields are cached nowhere by design
+(`enrich_audio.py` re-reads them so a rename can re-derive the filename
+fallback).
+
+Two gates then stopped anything from filling them in again:
+
+* enrichment is scheduled for `delta.additions`, and ejecting broadcasts
+ nothing — the last snapshot still held those ids, so the rebuilt entries
+ diffed as *updates*;
+* `_enrich_new_*_entries` skips anything in `_enriched_attempted`, which is
+ only discarded for `delta.deletions` — and dropping and rescanning inside
+ one call broadcasts no deletion either.
+
+Only a restart cleared both, an empty snapshot making every entry an
+addition. That is why it looked like it might fix itself and never did.
+
+Re-enriching is now the *fallback*, not the fix. An entry's id is its
+content hash, so one that comes back under the same id, name and path is
+the same bytes in the same place and its enrichment still holds:
+`_rescan_root` carries those fields across. Re-deriving them instead meant
+tag reads, ffprobe runs and rate-limited lookups — measured at 14 seconds
+of empty Music tab on a real library with a cold cache, which to the
+operator is indistinguishable from the original bug.
+
+What these assert is therefore the field on the entry, not a call to an
+enricher. Counting calls is what made an earlier version of this file pass
+while the operator still watched their albums vanish.
+
+The same drop-and-rescan runs in `reconcile()` — "Root %r is back" — so a
+USB drive that falls off and returns on its own hits all of this without
+anybody touching the UI.
+"""
+
+import asyncio
+import os
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from meshbay_common.crypto import generate_gek
+from meshbay_node.config import (Config, GroupConfig, HubConfig, KeystoreConfig,
+ NodeConfig)
+from meshbay_node.daemon import NodeDaemon
+from meshbay_node.indexer import DirectoryIndexer
+from meshbay_node.indexer.enrich_audio import AudioEnricher
+from meshbay_node.media_cache import MediaCache
+from meshbay_node.roots import RootSet
+from meshbay_node.roster import Roster
+
+pytestmark = pytest.mark.asyncio
+
+# Above indexer.py's MIN_AUDIO_SIZE_BYTES, or nothing would be indexed.
+_AUDIO_BYTES = os.urandom(60 * 1024)
+
+
+def _free_port() -> int:
+ import socket
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+class _CountingEnricher:
+ """Stands in for an enricher: records who it was asked to enrich."""
+
+ def __init__(self):
+ self.spawned: list[str] = []
+
+ def spawn(self, entry, file_path, on_done, boundary=None):
+ self.spawned.append(entry.name)
+
+
+async def _daemon(tmp_path, shared, group_id):
+ config = Config(
+ hub=HubConfig(url="http://localhost:9999", username="testuser"),
+ node=NodeConfig(quic_port=_free_port(), ui_port=_free_port()),
+ groups=[GroupConfig(
+ id=group_id, name="test-group", shared_dir=str(shared),
+ visibility="private", quic_port=_free_port(),
+ )],
+ keystore=KeystoreConfig(path=tmp_path / "keystore.enc"),
+ data_dir=tmp_path / "data",
+ )
+ daemon = NodeDaemon(config)
+ daemon._broadcast_coalesce_secs = 0.01
+ daemon._media_cache = MediaCache(db_path=tmp_path / "media_cache.db")
+ await daemon._media_cache.open()
+ daemon._roster = Roster(db_path=tmp_path / "roster.db")
+ await daemon._roster.open()
+ return daemon
+
+
+async def _settled(daemon, indexer):
+ await daemon._on_index_change(indexer)
+ await asyncio.sleep(0.05)
+
+
+async def _library(tmp_path, group_id, *, enricher=None):
+ """A one-track library under <root>/<artist>/<album>, enriched once."""
+ library = tmp_path / "music"
+ (library / "an artist" / "a record").mkdir(parents=True)
+ (library / "an artist" / "a record" / "01 first track.mp3").write_bytes(_AUDIO_BYTES)
+
+ daemon = await _daemon(tmp_path, library, group_id)
+ daemon._audio_enricher = enricher or AudioEnricher(daemon._media_cache)
+ await daemon._roster.set_app_directories(
+ group_id, "music", ["music"], set_by="op")
+
+ roots = RootSet.build([{"path": str(library), "removable": True}])
+ indexer = DirectoryIndexer(
+ roots=roots, group_id=group_id,
+ sk_node=Ed25519PrivateKey.generate(), gek=generate_gek())
+ await indexer.initial_scan()
+ await _settled(daemon, indexer)
+ await asyncio.sleep(0.4) # the enricher runs off the broadcast
+ return daemon, indexer, roots, library
+
+
+def _only(indexer):
+ return next(iter(indexer.index.entries))
+
+
+# ── The operator's own eject and plug ────────────────────────────────────────
+
+async def test_the_albums_are_still_there_after_a_replug(tmp_path):
+ """
+ No tags are written: `enrich_audio._artist_album_from_ancestors` derives
+ artist and album from the folder names when a file has none, which is the
+ <library>/<artist>/<album>/<track> layout this was reported against.
+ """
+ group_id = "a" * 32
+ daemon, indexer, _, _ = await _library(tmp_path, group_id)
+ try:
+ before = _only(indexer)
+ assert before.album == "a record" and before.artist == "an artist", (
+ f"the first pass never filled the fields: {before}")
+
+ indexer.eject_root("music")
+ await indexer.plug_root("music")
+ await _settled(daemon, indexer)
+
+ after = _only(indexer)
+ assert after.album == "a record" and after.artist == "an artist", (
+ "the entry came back from the rescan with no album — this is what "
+ "an empty Music tab after a replug looks like on the node")
+ finally:
+ await daemon._media_cache.close()
+ await daemon._roster.close()
+
+
+async def test_the_fields_survive_without_re_deriving_them(tmp_path):
+ """
+ Carried across, not recomputed. Re-deriving is correct and far too slow:
+ on a real library with a cold metadata cache it left Music empty for 14
+ seconds, and an operator who looks in that window sees the bug.
+ """
+ group_id = "b" * 32
+ enricher = _CountingEnricher()
+ daemon, indexer, _, _ = await _library(tmp_path, group_id, enricher=enricher)
+ try:
+ assert enricher.spawned == ["01 first track.mp3"]
+
+ indexer.eject_root("music")
+ await indexer.plug_root("music")
+ await _settled(daemon, indexer)
+
+ assert enricher.spawned == ["01 first track.mp3"], (
+ "an unchanged file was enriched a second time — the whole point "
+ "of the content hash is that it did not need to be")
+ finally:
+ await daemon._media_cache.close()
+ await daemon._roster.close()
+
+
+async def test_who_uploaded_a_file_survives_it_too(tmp_path):
+ """
+ `uploader_id`/`uploader_pk` are the same shape of field — set once, on an
+ entry, readable from nowhere on disk — and they decide who may delete the
+ file. Losing them to a replug quietly takes a right away.
+ """
+ group_id = "c" * 32
+ daemon, indexer, _, _ = await _library(tmp_path, group_id)
+ try:
+ entry = _only(indexer)
+ entry.uploader_id = "alice"
+ entry.uploader_pk = "a-pinned-key"
+
+ indexer.eject_root("music")
+ await indexer.plug_root("music")
+ await _settled(daemon, indexer)
+
+ after = _only(indexer)
+ assert after.uploader_id == "alice" and after.uploader_pk == "a-pinned-key"
+ finally:
+ await daemon._media_cache.close()
+ await daemon._roster.close()
+
+
+# ── A drive that leaves and returns on its own ──────────────────────────────
+
+async def test_a_root_that_returns_on_its_own_is_treated_the_same(tmp_path):
+ """
+ `reconcile()` rescans a root that reappears without anyone asking — a USB
+ drive re-mounting. Same drop-and-rescan, so it lost the same fields, with
+ no click anywhere to blame it on.
+ """
+ group_id = "d" * 32
+ daemon, indexer, roots, _ = await _library(tmp_path, group_id)
+ try:
+ assert _only(indexer).album == "a record"
+
+ roots.roots[0].available = False
+ await indexer.reconcile()
+ await _settled(daemon, indexer)
+ await indexer.reconcile()
+ await _settled(daemon, indexer)
+ await asyncio.sleep(0.4)
+
+ assert _only(indexer).album == "a record", (
+ "a drive that fell off and came back left the library with no "
+ "metadata")
+ finally:
+ await daemon._media_cache.close()
+ await daemon._roster.close()
+
+
+# ── What genuinely does have to be re-derived ───────────────────────────────
+
+async def test_a_track_moved_while_the_drive_was_away_is_enriched_again(tmp_path):
+ """
+ The counter-case, and the reason the carry-over is keyed on name and path
+ as well as id. `artist`, `album`, `display_title` and `track_no` all fall
+ back to the folder and filename when a file carries no tags, so the same
+ bytes under a new name are not the same metadata. Those are the entries
+ the daemon still re-enriches, off `rescanned_ids`.
+ """
+ group_id = "e" * 32
+ enricher = _CountingEnricher()
+ daemon, indexer, _, library = await _library(
+ tmp_path, group_id, enricher=enricher)
+ try:
+ assert enricher.spawned == ["01 first track.mp3"]
+
+ moved = library / "another artist" / "another record"
+ moved.mkdir(parents=True)
+ (library / "an artist" / "a record" / "01 first track.mp3").rename(
+ moved / "01 first track.mp3")
+
+ indexer.eject_root("music")
+ await indexer.plug_root("music")
+ await _settled(daemon, indexer)
+
+ assert enricher.spawned == ["01 first track.mp3"] * 2, (
+ "the file is under a different artist and album now; carrying the "
+ "old ones across would file it under a folder it left")
+ finally:
+ await daemon._media_cache.close()
+ await daemon._roster.close()
+
+
+async def test_videos_and_photos_are_covered_by_the_same_path(tmp_path):
+ """
+ Nothing here is specific to Music — Videos and Photos lost their durations,
+ titles and thumbnails the same way. Music is simply where it shows up
+ loudest: a track with no tags has no album to file it under, so the app
+ goes empty rather than merely plain.
+ """
+ group_id = "f" * 32
+ library = tmp_path / "media"
+ (library / "films").mkdir(parents=True)
+ (library / "films" / "clip.mkv").write_bytes(os.urandom(60 * 1024))
+ (library / "album").mkdir(parents=True)
+ (library / "album" / "shot.jpg").write_bytes(os.urandom(60 * 1024))
+
+ daemon = await _daemon(tmp_path, library, group_id)
+ video, photo = _CountingEnricher(), _CountingEnricher()
+ daemon._enricher, daemon._photo_enricher = video, photo
+ try:
+ await daemon._roster.set_app_directories(
+ group_id, "video", ["media/films"], set_by="op")
+ await daemon._roster.set_app_directories(
+ group_id, "photo", ["media/album"], set_by="op")
+
+ roots = RootSet.build([{"path": str(library), "removable": True}])
+ indexer = DirectoryIndexer(
+ roots=roots, group_id=group_id,
+ sk_node=Ed25519PrivateKey.generate(), gek=generate_gek())
+ await indexer.initial_scan()
+ await _settled(daemon, indexer)
+ assert video.spawned == ["clip.mkv"] and photo.spawned == ["shot.jpg"]
+
+ by_name = {e.name: e for e in indexer.index.entries}
+ by_name["clip.mkv"].duration = 1234
+ by_name["shot.jpg"].thumb_hash = "a-thumbnail"
+
+ indexer.eject_root("media")
+ await indexer.plug_root("media")
+ await _settled(daemon, indexer)
+
+ back = {e.name: e for e in indexer.index.entries}
+ assert back["clip.mkv"].duration == 1234, "the film lost its probe"
+ assert back["shot.jpg"].thumb_hash == "a-thumbnail", (
+ "the photo lost its thumbnail")
+ assert video.spawned == ["clip.mkv"] and photo.spawned == ["shot.jpg"], (
+ "unchanged files were probed and thumbnailed all over again")
+ finally:
+ await daemon._media_cache.close()
+ await daemon._roster.close()
diff --git a/packages/meshbay-node/tests/test_root_availability.py b/packages/meshbay-node/tests/test_root_availability.py
index 0201c1f..d514dee 100644
--- a/packages/meshbay-node/tests/test_root_availability.py
+++ b/packages/meshbay-node/tests/test_root_availability.py
@@ -26,9 +26,12 @@ from meshbay_node.roots import RootSet
pytestmark = pytest.mark.asyncio
-def _roots(*paths: Path) -> RootSet:
+def _roots(*paths: Path, removable: bool = False) -> RootSet:
specs = [{"path": str(p)} for p in paths]
- specs[0]["upload"] = True
+ specs[0]["writable"] = True
+ if removable:
+ for spec in specs:
+ spec["removable"] = True
return RootSet.build(specs)
@@ -117,7 +120,9 @@ async def test_members_are_told_which_roots_are_unavailable(tmp_path):
idx = await _indexer(_roots(films))
assert idx.index.roots == [
- {"name": "Films", "kind": "generic", "available": True, "upload": True}]
+ {"name": "Films", "kind": "generic", "available": True,
+ "writable": True, "removable": False, "ejected": False,
+ "upload": True}]
(films / "a.mkv").unlink()
films.rmdir()
diff --git a/packages/meshbay-node/tests/test_root_eject.py b/packages/meshbay-node/tests/test_root_eject.py
new file mode 100644
index 0000000..f57fb92
--- /dev/null
+++ b/packages/meshbay-node/tests/test_root_eject.py
@@ -0,0 +1,267 @@
+"""
+Safe eject, and the surprise unplug it exists to survive.
+
+`test_root_availability.py` pins the freeze: a root that goes away keeps its
+entries. This pins the half the operator drives — telling the node the drive is
+about to leave, and telling it the drive is back.
+
+The distinction that makes any of this work is that `ejected` and `is_live()`
+are separate answers. Between clicking Eject and physically unplugging, the
+directory is still readable; a design that recomputed availability from the
+filesystem alone would flip the root straight back to available and start
+serving files from a disk somebody has their hand on.
+
+The other property here is that the flag is *persisted*. It reached the roster
+in the first implementation and was never read back, so a restart — which is
+exactly what an operator does after noticing a drive fell off — silently undid
+the eject, and the next scan read an empty mount point as an erased library.
+"""
+
+from pathlib import Path
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from meshbay_node.indexer.indexer import DirectoryIndexer
+from meshbay_node.roots import RootSet
+from meshbay_node.roster import Roster
+
+pytestmark = pytest.mark.asyncio
+
+
+def _roots(*paths: Path, removable: bool = True) -> RootSet:
+ return RootSet.build([
+ {"path": str(p), "removable": removable} for p in paths])
+
+
+async def _indexer(roots: RootSet, **kw) -> DirectoryIndexer:
+ idx = DirectoryIndexer(roots=roots, group_id="g" * 32,
+ sk_node=Ed25519PrivateKey.generate(), gek=None, **kw)
+ await idx.initial_scan()
+ return idx
+
+
+def _names(idx: DirectoryIndexer) -> set[str]:
+ return {e.name for e in idx.index.entries}
+
+
+# ── The two states are not the same question ─────────────────────────────────
+
+async def test_ejecting_hides_a_root_that_is_still_readable(tmp_path):
+ """
+ The whole point of an eject button: the operator says the drive is leaving
+ *before* it leaves. The directory is still there and still readable at this
+ moment, so anything deriving availability from the filesystem would refuse
+ to believe it.
+ """
+ films = tmp_path / "Films"
+ films.mkdir()
+ (films / "a.mkv").write_bytes(b"a")
+
+ roots = _roots(films)
+ idx = await _indexer(roots)
+ idx.eject_root("Films")
+
+ assert films.is_dir(), "the drive has not been unplugged yet"
+ assert roots.roots[0].is_live() is True
+ assert roots.roots[0].available is False
+ assert idx.index.roots[0]["ejected"] is True
+ assert idx.index.roots[0]["available"] is False
+
+
+async def test_an_eject_freezes_entries_rather_than_dropping_them(tmp_path):
+ films = tmp_path / "Films"
+ films.mkdir()
+ (films / "a.mkv").write_bytes(b"a")
+ (films / "b.mkv").write_bytes(b"b")
+
+ idx = await _indexer(_roots(films))
+ idx.eject_root("Films")
+
+ assert _names(idx) == {"a.mkv", "b.mkv"}, "eject deleted entries"
+
+
+async def test_reconciling_does_not_un_eject_a_root(tmp_path):
+ """
+ The backstop runs every minute regardless. An ejected root whose directory
+ is still readable must stay ejected, or the operator's eject lasts until
+ the next tick.
+ """
+ films = tmp_path / "Films"
+ films.mkdir()
+ (films / "a.mkv").write_bytes(b"a")
+
+ roots = _roots(films)
+ idx = await _indexer(roots)
+ idx.eject_root("Films")
+ await idx.reconcile()
+
+ assert roots.roots[0].ejected is True
+ assert roots.roots[0].available is False
+
+
+async def test_plugging_back_relists_the_files(tmp_path):
+ films = tmp_path / "Films"
+ films.mkdir()
+ (films / "a.mkv").write_bytes(b"a")
+
+ roots = _roots(films)
+ idx = await _indexer(roots)
+ idx.eject_root("Films")
+ await idx.plug_root("Films")
+
+ assert roots.roots[0].ejected is False
+ assert roots.roots[0].available is True
+ assert _names(idx) == {"a.mkv"}
+
+
+async def test_what_changed_while_unplugged_is_picked_up_on_plug(tmp_path):
+ """
+ A drive people take away comes back different. The plug pass has to see
+ that, or the index describes a library that no longer exists on the disk
+ the node is about to serve from.
+ """
+ films = tmp_path / "Films"
+ films.mkdir()
+ (films / "a.mkv").write_bytes(b"a")
+
+ roots = _roots(films)
+ idx = await _indexer(roots)
+ idx.eject_root("Films")
+
+ (films / "a.mkv").unlink()
+ (films / "c.mkv").write_bytes(b"c")
+
+ await idx.plug_root("Films")
+ assert _names(idx) == {"c.mkv"}
+
+
+# ── The surprise unplug ──────────────────────────────────────────────────────
+
+async def test_a_removable_root_that_vanishes_is_auto_ejected(tmp_path):
+ """
+ Nobody clicks Eject when they are in a hurry. A removable root whose path
+ disappears is treated as ejected rather than merely unavailable, so it does
+ not silently come back the moment the same mount point is readable again —
+ which on a machine with automount is any other drive, or an empty stub.
+ """
+ films = tmp_path / "Films"
+ films.mkdir()
+ (films / "a.mkv").write_bytes(b"a")
+
+ roots = _roots(films)
+ idx = await _indexer(roots)
+
+ (films / "a.mkv").unlink()
+ films.rmdir()
+ await idx.reconcile()
+
+ assert roots.roots[0].ejected is True
+ assert _names(idx) == {"a.mkv"}, "the library was treated as erased"
+
+
+async def test_a_non_removable_root_is_not_auto_ejected(tmp_path):
+ """
+ The counter-property. Auto-eject requires the operator to have said the
+ device is removable; an ordinary directory that briefly fails to stat must
+ keep the old behaviour and come back on its own.
+ """
+ films = tmp_path / "Films"
+ films.mkdir()
+ (films / "a.mkv").write_bytes(b"a")
+
+ roots = _roots(films, removable=False)
+ idx = await _indexer(roots)
+
+ (films / "a.mkv").unlink()
+ films.rmdir()
+ await idx.reconcile()
+ assert roots.roots[0].ejected is False
+ assert roots.roots[0].available is False
+
+ films.mkdir()
+ (films / "a.mkv").write_bytes(b"a")
+ await idx.reconcile()
+ assert roots.roots[0].available is True
+
+
+async def test_an_auto_eject_is_reported_so_it_can_be_persisted(tmp_path):
+ """
+ The flag has to outlive the process. The first version of this set it in
+ memory only, so restarting the node — which is what an operator does after
+ noticing a drive fell off — cleared it, and the scan that followed read the
+ empty mount point as a deletion of the whole library.
+ """
+ films = tmp_path / "Films"
+ films.mkdir()
+ (films / "a.mkv").write_bytes(b"a")
+
+ seen: list[tuple[str, bool]] = []
+
+ async def record(name: str, ejected: bool) -> None:
+ seen.append((name, ejected))
+
+ roots = _roots(films)
+ idx = await _indexer(roots, on_root_ejected=record)
+
+ (films / "a.mkv").unlink()
+ films.rmdir()
+ await idx.reconcile()
+
+ assert seen == [("Films", True)]
+
+ # And only once, however many times the backstop runs afterwards.
+ await idx.reconcile()
+ await idx.reconcile()
+ assert seen == [("Films", True)]
+
+
+# ── Restoring the flag ───────────────────────────────────────────────────────
+
+async def test_a_root_built_as_ejected_starts_unavailable(tmp_path):
+ """
+ What the daemon does with what the roster remembers. `available` must not
+ be left at its default `True` here, or the group serves a drive that is not
+ there for as long as it takes the first reconcile to run.
+ """
+ films = tmp_path / "Films"
+ films.mkdir()
+ roots = RootSet.build([{"path": str(films), "removable": True,
+ "ejected": True}])
+ assert roots.roots[0].ejected is True
+ assert roots.roots[0].available is False
+
+
+async def test_the_roster_round_trips_the_ejected_set(tmp_path):
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ try:
+ assert await roster.ejected_roots("g1") == set()
+
+ await roster.set_root_ejected("g1", "Films", True, set_by="op")
+ await roster.set_root_ejected("g1", "Music", False, set_by="op")
+ assert await roster.ejected_roots("g1") == {"films"}
+
+ # Another group's drives are its own.
+ assert await roster.ejected_roots("g2") == set()
+
+ await roster.set_root_ejected("g1", "Films", False, set_by="op")
+ assert await roster.ejected_roots("g1") == set()
+ finally:
+ await roster.close()
+
+
+async def test_the_ejected_key_is_case_folded(tmp_path):
+ """
+ Root names are compared without regard to case everywhere else, and a key
+ that did not fold would let `Films` and `films` disagree about the same
+ drive — on Windows and macOS, the same directory.
+ """
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ try:
+ await roster.set_root_ejected("g1", "FILMS", True, set_by="op")
+ assert await roster.ejected_roots("g1") == {"films"}
+ assert Roster.root_ejected_key("Films") == Roster.root_ejected_key("FILMS")
+ finally:
+ await roster.close()
diff --git a/packages/meshbay-node/tests/test_root_ops_reach_the_live_set.py b/packages/meshbay-node/tests/test_root_ops_reach_the_live_set.py
new file mode 100644
index 0000000..976af82
--- /dev/null
+++ b/packages/meshbay-node/tests/test_root_ops_reach_the_live_set.py
@@ -0,0 +1,361 @@
+"""
+Adding or removing a root has to reach the running node, not only node.toml.
+
+Two front doors do this — the loopback API and a signed MNP op — and `ops.py`
+exists so they behave identically. They did not. The loopback path fired the
+daemon's `reload_fn`, which re-reads node.toml and builds a fresh `RootSet`;
+the MNP path instead re-pointed the indexer at `groups_ctx[gid]["roots"]`, the
+very object the op had just been asked about. `DirectoryIndexer.retarget`
+decides what to scan by diffing the names it holds against the ones it is
+given, so a set compared against itself scans nothing and drops nothing.
+
+A directory added from a browser therefore reached node.toml and was invisible
+everywhere else until a restart — and adding it again was refused as colliding
+with itself, which is the only reason anyone found out. One removed would have
+kept serving its files.
+
+**The obvious repair is wrong in the other direction**, and was committed once
+before this file said so: making the op edit the live set in place puts the new
+root on *both* sides of retarget's comparison. The table would show it and it
+would stay permanently empty. So the ops leave that object alone, the MNP path
+reloads like the loopback one always did, and the tests below check the files —
+`describe()` agreeing proves nothing about whether anything was scanned.
+"""
+
+from dataclasses import asdict
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from meshbay_node import ops
+from meshbay_node.config import GroupConfig, NodeConfig, RootSpec
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.indexer.indexer import DirectoryIndexer
+from meshbay_node.roots import RootSet
+from meshbay_node.roster import Roster
+
+pytestmark = pytest.mark.asyncio
+
+GROUP = "g" * 32
+
+
+async def _state(tmp_path: Path) -> tuple[dict, Roster]:
+ """A node hosting one group with two roots, as node.toml and as live state."""
+ for name in ("one", "two"):
+ (tmp_path / name).mkdir()
+
+ cfg = GroupConfig(id=GROUP, name="plop", roots=[
+ RootSpec(path=str(tmp_path / "one"), name="one"),
+ RootSpec(path=str(tmp_path / "two"), name="two"),
+ ])
+ node_cfg = NodeConfig.__new__(NodeConfig)
+ node_cfg.groups = [cfg]
+
+ conf = tmp_path / "node.toml"
+ conf.write_text(
+ f'[[groups]]\nid = "{GROUP}"\nname = "plop"\n\n'
+ f' [[groups.roots]]\n path = "{(tmp_path / "one").as_posix()}"\n'
+ f' name = "one"\n\n'
+ f' [[groups.roots]]\n path = "{(tmp_path / "two").as_posix()}"\n'
+ f' name = "two"\n')
+
+ live = RootSet.build([asdict(r) for r in cfg.roots])
+ index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ state = {
+ "config": node_cfg,
+ "config_path": str(conf),
+ "groups_ctx": {GROUP: {"index": index, "roots": live}},
+ "roster": roster,
+ "node_user_id": "operator",
+ }
+ return state, roster
+
+
+def _live(state) -> RootSet:
+ return state["groups_ctx"][GROUP]["roots"]
+
+
+async def _indexer(state) -> DirectoryIndexer:
+ idx = DirectoryIndexer(roots=_live(state), group_id=GROUP,
+ sk_node=Ed25519PrivateKey.generate(), gek=None)
+ await idx.initial_scan()
+ return idx
+
+
+def _rebuilt(state) -> RootSet:
+ """What a reload produces: a fresh set from the config the op just wrote."""
+ return RootSet.build([asdict(r) for r in state["config"].groups[0].roots])
+
+
+# ── What the op writes ───────────────────────────────────────────────────────
+
+async def test_adding_a_root_reaches_node_toml_and_the_ack(tmp_path):
+ state, roster = await _state(tmp_path)
+ (tmp_path / "uploads").mkdir()
+ try:
+ result = await ops.add_root(state, GROUP, str(tmp_path / "uploads"))
+
+ assert [r["name"] for r in result["roots"]] == ["one", "two", "uploads"]
+ assert [r.name for r in state["config"].groups[0].roots] == [
+ "one", "two", "uploads"]
+ assert "uploads" in Path(state["config_path"]).read_text()
+ finally:
+ await roster.close()
+
+
+async def test_removing_a_root_reaches_node_toml_and_the_ack(tmp_path):
+ state, roster = await _state(tmp_path)
+ try:
+ result = await ops.remove_root(state, GROUP, "two")
+ assert [r["name"] for r in result["roots"]] == ["one"]
+ assert Path(state["config_path"]).read_text().count(
+ "[[groups.roots]]") == 1
+ finally:
+ await roster.close()
+
+
+async def test_the_op_does_not_edit_the_live_set_in_place(tmp_path):
+ """
+ The property that made the original bug, and then made the first repair for
+ it wrong in the other direction.
+
+ `retarget` diffs the names it holds against the ones it is handed. Editing
+ that same object and passing it back puts a new root on both sides of the
+ comparison: nothing is scanned, and the directory shows in the table
+ permanently empty. `_reload_config_inner` diffs the same way and would
+ likewise conclude nothing had changed.
+ """
+ state, roster = await _state(tmp_path)
+ before = [r.name for r in _live(state)]
+ (tmp_path / "uploads").mkdir()
+ try:
+ await ops.add_root(state, GROUP, str(tmp_path / "uploads"))
+ assert [r.name for r in _live(state)] == before, (
+ "add_root edited the live RootSet, which is the object retarget "
+ "diffs against — the new root would never be scanned")
+
+ await ops.remove_root(state, GROUP, "two")
+ assert [r.name for r in _live(state)] == before, (
+ "remove_root edited the live RootSet, so retarget cannot tell the "
+ "removed root's entries should go")
+ finally:
+ await roster.close()
+
+
+# ── What the node then serves ────────────────────────────────────────────────
+
+async def test_a_retarget_from_the_config_scans_the_new_root(tmp_path):
+ """
+ The half no assertion about `describe()` can reach: the files.
+
+ A root that appears in the table and holds nothing is the same bug one step
+ later, and it is what editing the live set in place would produce.
+ """
+ state, roster = await _state(tmp_path)
+ (tmp_path / "one" / "kept.txt").write_bytes(b"kept")
+ fresh = tmp_path / "uploads"
+ fresh.mkdir()
+ (fresh / "new.txt").write_bytes(b"new")
+
+ idx = await _indexer(state)
+ assert {e.name for e in idx.index.entries} == {"kept.txt"}
+ try:
+ await ops.add_root(state, GROUP, str(fresh))
+ await idx.retarget(_rebuilt(state))
+
+ assert {e.name for e in idx.index.entries} == {"kept.txt", "new.txt"}, (
+ "the added directory was not scanned — it would show in the table "
+ "and stay empty")
+ assert [r["name"] for r in idx.index.roots] == ["one", "two", "uploads"]
+ finally:
+ await roster.close()
+
+
+async def test_handing_retarget_the_edited_set_scans_nothing(tmp_path):
+ """
+ The failure mode above, demonstrated rather than described — so the reason
+ the ops leave the live set alone is checkable instead of asserted in a
+ comment. If this ever starts failing, `retarget` has changed and the rule
+ in `add_root` can be revisited.
+ """
+ state, roster = await _state(tmp_path)
+ fresh = tmp_path / "uploads"
+ fresh.mkdir()
+ (fresh / "new.txt").write_bytes(b"new")
+
+ idx = await _indexer(state)
+ try:
+ await ops.add_root(state, GROUP, str(fresh))
+ # What editing in place would have left behind.
+ _live(state).roots.append(_rebuilt(state).roots[-1])
+ await idx.retarget(_live(state))
+
+ assert {e.name for e in idx.index.entries} == set(), (
+ "retarget now scans a root it was handed on both sides of its own "
+ "diff — the constraint this file is built on has changed")
+ finally:
+ await roster.close()
+
+
+async def test_a_retarget_from_the_config_drops_a_removed_root(tmp_path):
+ """The mirror: a removed directory's files must stop being served."""
+ state, roster = await _state(tmp_path)
+ (tmp_path / "one" / "kept.txt").write_bytes(b"kept")
+ (tmp_path / "two" / "going.txt").write_bytes(b"going")
+
+ idx = await _indexer(state)
+ assert {e.name for e in idx.index.entries} == {"kept.txt", "going.txt"}
+ try:
+ await ops.remove_root(state, GROUP, "two")
+ await idx.retarget(_rebuilt(state))
+ assert {e.name for e in idx.index.entries} == {"kept.txt"}, (
+ "the removed directory's files are still being served")
+ finally:
+ await roster.close()
+
+
+# ── The invariants around them ───────────────────────────────────────────────
+
+async def test_adding_the_same_directory_twice_is_still_refused(tmp_path):
+ """A group with one path under two names indexes every file in it twice."""
+ state, roster = await _state(tmp_path)
+ (tmp_path / "uploads").mkdir()
+ try:
+ await ops.add_root(state, GROUP, str(tmp_path / "uploads"))
+ with pytest.raises(ops.OpError):
+ await ops.add_root(state, GROUP, str(tmp_path / "uploads"))
+ assert len(state["config"].groups[0].roots) == 3, (
+ "the refused add left something behind")
+ assert Path(state["config_path"]).read_text().count(
+ "[[groups.roots]]") == 3
+ finally:
+ await roster.close()
+
+
+async def test_a_second_different_root_still_lands(tmp_path):
+ state, roster = await _state(tmp_path)
+ (tmp_path / "uploads").mkdir()
+ (tmp_path / "incoming").mkdir()
+ try:
+ await ops.add_root(state, GROUP, str(tmp_path / "uploads"))
+ result = await ops.add_root(state, GROUP, str(tmp_path / "incoming"),
+ writable=True)
+ assert [r["name"] for r in result["roots"]] == [
+ "one", "two", "uploads", "incoming"]
+ assert result["roots"][-1]["writable"] is True
+ finally:
+ await roster.close()
+
+
+async def test_updating_flags_may_edit_the_live_set(tmp_path):
+ """
+ The exception, and why it is one: `writable` and `removable` change nothing
+ about which files exist, so there is nothing for retarget to scan or drop.
+ Editing in place is what makes the flag true for the upload handler on the
+ very next request, which is synchronous and reads the live set.
+ """
+ state, roster = await _state(tmp_path)
+ state["config"] = SimpleNamespace(groups=state["config"].groups)
+ try:
+ result = await ops.update_root(state, GROUP, "two",
+ writable=True, removable=True)
+ live = _live(state).by_name("two")
+ assert live.writable is True and live.removable is True
+ assert result["roots"] == _live(state).describe()
+ finally:
+ await roster.close()
+
+
+async def test_the_file_on_disk_and_the_config_in_memory_agree(tmp_path):
+ """
+ A reload re-reads the file, so a config edited in memory but not on disk is
+ undone by the next restart — and one written to disk but not in memory
+ makes the *next* op validate against a stale picture.
+ """
+ state, roster = await _state(tmp_path)
+ (tmp_path / "uploads").mkdir()
+ try:
+ await ops.add_root(state, GROUP, str(tmp_path / "uploads"),
+ writable=True)
+ await ops.remove_root(state, GROUP, "one")
+
+ import tomllib
+ on_disk = tomllib.loads(Path(state["config_path"]).read_text())
+ disk_paths = [str(r["path"]) for r in on_disk["groups"][0]["roots"]]
+ memory_paths = [Path(r.path).as_posix()
+ for r in state["config"].groups[0].roots]
+ assert disk_paths == memory_paths
+ assert Path(state["config_path"]).read_text().count(
+ "[[groups.roots]]") == 2
+ finally:
+ await roster.close()
+
+
+# ── The seam that was actually broken ────────────────────────────────────────
+
+async def test_the_mnp_path_reloads_like_the_loopback_one(tmp_path):
+ """
+ The two front doors, doing the same thing.
+
+ `ui/app.py` has always fired the daemon's `reload_fn` after a root op.
+ `_retarget_indexer` did not — it re-pointed the indexer at the live set
+ instead, which is the object the ops leave alone, so nothing happened at
+ all. That divergence *is* the bug: the loopback path worked, the MNP path
+ did not, and it survived until an operator added a directory from a
+ browser.
+
+ Not awaited: a reload rescans, and a new library is minutes. The ack
+ already carries the set the node is moving to.
+ """
+ from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+ state, roster = await _state(tmp_path)
+ reloaded: list[bool] = []
+
+ async def fake_reload():
+ reloaded.append(True)
+
+ state["reload_fn"] = fake_reload
+ spawned = []
+
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ session._ctx = {"daemon_state": state}
+ session._spawn = lambda coro: spawned.append(coro)
+ try:
+ await session._retarget_indexer(GROUP)
+ assert spawned, "the MNP path did not ask the daemon to reload"
+ await spawned[0]
+ assert reloaded == [True]
+ finally:
+ await roster.close()
+
+
+async def test_without_a_daemon_it_still_retargets(tmp_path):
+ """
+ A context assembled by hand — a harness, or a test — has no `reload_fn`.
+ Falling through to a direct retarget keeps those working, and is correct
+ precisely because the ops no longer edit the set being passed.
+ """
+ from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+ state, roster = await _state(tmp_path)
+ fresh = tmp_path / "uploads"
+ fresh.mkdir()
+ (fresh / "new.txt").write_bytes(b"new")
+ idx = await _indexer(state)
+ state["indexers"] = {GROUP: idx}
+
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ session._ctx = {"daemon_state": state}
+ try:
+ await ops.add_root(state, GROUP, str(fresh))
+ # What a reload would have installed, done by hand here.
+ state["groups_ctx"][GROUP]["roots"] = _rebuilt(state)
+ await session._retarget_indexer(GROUP)
+ assert {e.name for e in idx.index.entries} == {"new.txt"}
+ finally:
+ await roster.close()
diff --git a/packages/meshbay-node/tests/test_root_paths_are_operator_only.py b/packages/meshbay-node/tests/test_root_paths_are_operator_only.py
new file mode 100644
index 0000000..080d4be
--- /dev/null
+++ b/packages/meshbay-node/tests/test_root_paths_are_operator_only.py
@@ -0,0 +1,114 @@
+"""
+Where a directory lives on the operator's disk is theirs, not the group's.
+
+`RootSet.describe()` feeds two very different audiences. The index payload goes
+to every member, and has always deliberately carried no paths — a member is
+told what exists and whether it is readable, not that the library sits in
+`/media/<the operator's name>/BACKUP2`. The loopback API answers the operator
+themselves, over a channel that already requires being on their machine with
+the run token, where the path is exactly what they are asking for.
+
+`meshbay-node root list` printed `?` for every directory because it read a
+field the member form omits. Nothing caught it: the CLI reads a dict, the
+payload is a dict, and neither end says what keys it owes the other.
+
+Both halves matter and they pull opposite ways, so both are asserted here — a
+test that only checked the operator gets paths would be satisfied by putting
+them in the member payload too.
+"""
+
+import inspect
+import re
+from pathlib import Path
+
+
+from meshbay_node import daemon as daemon_mod
+from meshbay_node import ops
+from meshbay_node.roots import RootSet
+
+
+def _roots(tmp_path: Path) -> RootSet:
+ for name in ("Films", "Albums"):
+ (tmp_path / name).mkdir()
+ return RootSet.build([
+ {"path": str(tmp_path / "Films"), "writable": True},
+ {"path": str(tmp_path / "Albums"), "removable": True},
+ ])
+
+
+# ── The member's half ────────────────────────────────────────────────────────
+
+def test_the_default_form_carries_no_path(tmp_path):
+ described = _roots(tmp_path).describe()
+ assert described, "no roots described"
+ assert not any("path" in d for d in described), (
+ "the index payload every member receives would carry the operator's "
+ "filesystem layout")
+
+
+def test_the_default_form_still_says_what_a_member_needs(tmp_path):
+ """The counter-property: dropping the path must not drop the rest."""
+ described = _roots(tmp_path).describe()
+ for d in described:
+ assert set(d) >= {"name", "kind", "available", "writable",
+ "removable", "ejected"}
+
+
+def test_the_index_payload_is_built_without_paths():
+ """
+ Read from the source, because the alternative is asserting it about a
+ payload built by a test rather than by the node.
+ """
+ from meshbay_node.indexer import indexer as indexer_mod
+ source = inspect.getsource(indexer_mod)
+ for call in re.findall(r"roots\.describe\([^)]*\)", source):
+ assert "with_paths" not in call, (
+ f"the indexer builds the member-facing roots table as {call} — "
+ f"that payload goes to everyone in the group")
+
+
+# ── The operator's half ──────────────────────────────────────────────────────
+
+def test_the_operator_form_carries_the_path(tmp_path):
+ described = _roots(tmp_path).describe(with_paths=True)
+ assert all(d.get("path") for d in described)
+ assert described[0]["path"] == str(tmp_path / "Films")
+
+
+def test_the_loopback_api_asks_for_paths():
+ """
+ `list_groups` answers the operator's own channel, and the CLI's `root list`
+ prints what it returns. Asking for the member form there is what printed a
+ column of question marks.
+ """
+ source = inspect.getsource(ops.list_groups)
+ assert "describe(with_paths=True)" in source, (
+ "list_groups uses the member form, so every path it reports is missing")
+
+
+def test_the_cli_only_reads_fields_the_payload_carries():
+ """
+ The gap this whole file exists for. The CLI reads a dict and the API
+ returns a dict; nothing between them says which keys are owed, so a name
+ that is simply absent prints as a placeholder and looks like a node
+ problem.
+ """
+ source = inspect.getsource(daemon_mod.main)
+ start = source.index('if args.command == "root":')
+ block = source[start:source.index('if args.command == "operator":', start)]
+
+ read = set(re.findall(r"r\.get\(['\"](\w+)['\"]", block))
+ read |= set(re.findall(r"r\[['\"](\w+)['\"]\]", block))
+ assert read, "the root CLI no longer reads the payload this way"
+
+ class _Any:
+ path = Path("/tmp/x")
+ name = "x"
+ kind = "generic"
+ writable = removable = ejected = False
+ available = True
+
+ offered = set(RootSet(roots=[_Any()]).describe(with_paths=True)[0])
+ assert read <= offered, (
+ f"the `root` CLI reads keys the loopback payload does not carry: "
+ f"{sorted(read - offered)}")
diff --git a/packages/meshbay-node/tests/test_root_writable_policy.py b/packages/meshbay-node/tests/test_root_writable_policy.py
new file mode 100644
index 0000000..7eb75fd
--- /dev/null
+++ b/packages/meshbay-node/tests/test_root_writable_policy.py
@@ -0,0 +1,246 @@
+"""
+Who may write to the operator's disk, now that RO/RW on the root decides it.
+
+This replaces `test_member_upload_policy.py`. The old model had two orthogonal
+controls — one root designated as the upload target, and a group-wide
+`member_upload` switch — and collapsed into one property per root: `writable`.
+The properties worth keeping from the old file survive the change unaltered:
+
+* the interface hiding a control is a courtesy to the people who are not
+ trying; **the node refusing is the part that holds** against someone who is.
+ A member with an old tab open, or one speaking MNP directly, gets the same
+ answer. That half is pinned in `test_security_regressions.py`, next to the
+ overwrite properties it belongs with;
+* the setting is changed by a **signed** operator instruction, or it is a
+ suggestion any member can undo;
+* it is stored on the **node**, never the hub. A hub that could decide who
+ writes to the operator's disk would have authority over the node.
+
+And one that is new: the *old* message must no longer be able to change
+anything. A deprecated instruction that still works is not deprecated, and this
+one would reopen uploads group-wide.
+"""
+
+import base64
+from pathlib import Path
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from meshbay_common.adminop import OP_ROOT_UPDATE, OP_ROOT_EJECT, OP_ROOT_PLUG
+from meshbay_common.protocol import MNP
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.roots import RootSet
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+pytestmark = pytest.mark.asyncio
+
+
+def _session(tmp_path: Path, user_id: str, *,
+ writable: bool = True,
+ operator: str | None = None) -> WebRTCPeerSession:
+ shared_root = tmp_path / "shared"
+ shared_root.mkdir(exist_ok=True)
+ index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate())
+ ctx = {
+ "roots": RootSet.build([{"path": str(shared_root), "writable": writable}]),
+ "index": index,
+ "sk_node": index.sk_node,
+ "node_user_id": operator,
+ }
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ session._ctx = ctx
+ session._group_id = "g" * 32
+ session._user_id = user_id
+ session._pk_user = ""
+ session._uploads = {}
+ session.sent = []
+ session._send = session.sent.append
+ session._audit = lambda *a, **k: None
+ return session
+
+
+def _upload(session, filename="clip.mp4", body=b"bytes"):
+ session._do_file_upload({
+ "filename": filename, "dir": "shared",
+ "chunk_index": 0, "total_chunks": 1,
+ "data": base64.b64encode(body).decode(),
+ })
+
+
+def _uploads_dir(session) -> Path:
+ # The root itself: the `uploads/` subdirectory the node used to create is
+ # gone (see test_security_regressions._uploads_dir for why).
+ return session._ctx["roots"].roots[0].path
+
+
+# ── The door, not the button ─────────────────────────────────────────────────
+
+async def test_a_member_cannot_upload_to_a_read_only_root(tmp_path):
+ session = _session(tmp_path, "member-1", writable=False)
+ _upload(session)
+
+ refusal = [m for m in session.sent if m.get("type") == "error"]
+ assert refusal and refusal[0].get("code") == "root_read_only"
+ assert not (_uploads_dir(session) / "clip.mp4").exists()
+
+
+async def test_members_upload_normally_to_a_writable_root(tmp_path):
+ session = _session(tmp_path, "member-1", writable=True)
+ _upload(session)
+
+ assert not [m for m in session.sent if m.get("type") == "error"]
+ assert (_uploads_dir(session) / "clip.mp4").read_bytes() == b"bytes"
+
+
+async def test_read_only_binds_the_operator_too(tmp_path):
+ """
+ The old model exempted the operator, because the switch was about *members*.
+ RO is about the directory: a published library is read-only for everyone, and
+ an exception for admin authority is how a rule turns into a default.
+ """
+ session = _session(tmp_path, "the-operator", writable=False,
+ operator="the-operator")
+ session._is_node_admin = lambda: True
+ _upload(session)
+
+ refusal = [m for m in session.sent if m.get("type") == "error"]
+ assert refusal and refusal[0].get("code") == "root_read_only"
+
+
+async def test_a_member_cannot_create_a_folder_in_a_read_only_root(tmp_path):
+ """
+ Read-only has to mean read-only for every way of writing, not just for
+ files. `_do_file_upload` gained this check with the RO/RW model and
+ `_do_dir_create` did not, so a member refused a file in a published library
+ could still leave empty directories all through it.
+
+ Creating a folder stays unprivileged — the node's own words: "a member who
+ can add a file can organise where it goes". What changed is that it now
+ requires the same root to be writable that adding the file would have.
+ """
+ session = _session(tmp_path, "member-1", writable=False)
+ session._do_dir_create({"dir": "shared", "name": "New folder"})
+
+ refusal = [m for m in session.sent if m.get("type") == "error"]
+ assert refusal and refusal[0].get("code") == "root_read_only"
+ assert not (tmp_path / "shared" / "New folder").exists()
+
+
+async def test_a_member_can_create_a_folder_in_a_writable_root(tmp_path):
+ """The counter-property: it must stay unprivileged where it is allowed."""
+ session = _session(tmp_path, "member-1", writable=True)
+ session._do_dir_create({"dir": "shared", "name": "New folder"})
+
+ assert not [m for m in session.sent if m.get("type") == "error"]
+ assert (tmp_path / "shared" / "New folder").is_dir()
+
+
+async def test_an_ejected_root_refuses_a_new_folder(tmp_path):
+ """Writing to a drive somebody has their hand on, one level up from a file."""
+ session = _session(tmp_path, "member-1", writable=True)
+ roots = session._ctx["roots"]
+ roots.roots[0].ejected = True
+ roots.roots[0].available = False
+
+ session._do_dir_create({"dir": "shared", "name": "New folder"})
+ refusal = [m for m in session.sent if m.get("type") == "error"]
+ assert refusal and refusal[0].get("code") == "root_unavailable"
+ assert not (tmp_path / "shared" / "New folder").exists()
+
+
+# ── Signed, or it is a suggestion ────────────────────────────────────────────
+
+def _capture_challenges(session) -> list[tuple[str, str]]:
+ issued: list[tuple[str, str]] = []
+
+ def issue(op, subject, **kw):
+ issued.append((op, subject))
+
+ session._issue_admin_challenge = issue
+ session._has_admin_authority = lambda: True
+ return issued
+
+
+async def test_changing_a_roots_flags_needs_a_signature(tmp_path):
+ """The flags are not applied by the request — only by the signed response."""
+ session = _session(tmp_path, "the-operator", operator="the-operator")
+ issued = _capture_challenges(session)
+
+ session._do_root_update({"group_id": "g" * 32, "root_name": "shared",
+ "writable": False})
+
+ assert [op for op, _ in issued] == [OP_ROOT_UPDATE]
+ assert session._ctx["roots"].roots[0].writable is True, (
+ "applied before it was signed")
+
+
+async def test_the_subject_names_the_outcome_not_the_operation(tmp_path):
+ """
+ The operator is shown the subject before signing, so it has to say what will
+ be true afterwards. "shared" alone would have them authorize a change they
+ cannot see the direction of.
+ """
+ session = _session(tmp_path, "op", operator="op")
+ issued = _capture_challenges(session)
+
+ session._do_root_update({"group_id": "g" * 32, "root_name": "shared",
+ "writable": True, "removable": True})
+
+ assert issued == [(OP_ROOT_UPDATE, "shared:rw=on,rem=on")]
+
+
+async def test_eject_and_plug_are_signed_too(tmp_path):
+ """
+ Hiding a group's whole library from every member is not a lesser act than
+ changing a flag. An unsigned one would let any member black out a group.
+ """
+ session = _session(tmp_path, "op", operator="op")
+ issued = _capture_challenges(session)
+
+ session._do_root_eject({"group_id": "g" * 32, "root_name": "shared"})
+ session._do_root_plug({"group_id": "g" * 32, "root_name": "shared"})
+
+ assert issued == [(OP_ROOT_EJECT, "shared"), (OP_ROOT_PLUG, "shared")]
+
+
+async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path):
+ """
+ An unpaired node has no key to check a signature against, so the challenge
+ is never issued rather than issued and then unverifiable.
+ """
+ session = _session(tmp_path, "member-1")
+ issued = _capture_challenges(session)
+ session._has_admin_authority = lambda: False
+
+ session._do_root_update({"group_id": "g" * 32, "root_name": "shared",
+ "writable": True})
+
+ assert issued == []
+ assert [m for m in session.sent if m.get("type") == "error"]
+
+
+# ── The deprecated message must not still work ───────────────────────────────
+
+async def test_the_old_member_upload_message_changes_nothing(tmp_path):
+ """
+ MNP still parses `member_upload` so an old client gets an answer instead of
+ a dropped request. What it must not do is act: this instruction could
+ reopen uploads for a whole group, and a client old enough to send it is
+ exactly one that knows nothing about read-only roots.
+ """
+ session = _session(tmp_path, "member-1", writable=False)
+ session._has_admin_authority = lambda: True
+ issued = _capture_challenges(session)
+
+ session._do_member_upload({"allowed": True})
+
+ assert issued == [], "a deprecated instruction asked to be signed"
+ assert session._ctx["roots"].roots[0].writable is False
+ acks = [m for m in session.sent if m.get("type") == MNP.MEMBER_UPLOAD_ACK]
+ assert acks and acks[0].get("deprecated") is True
+
+ # And the door is still shut.
+ _upload(session)
+ refusal = [m for m in session.sent if m.get("type") == "error"]
+ assert refusal and refusal[0].get("code") == "root_read_only"
diff --git a/packages/meshbay-node/tests/test_roots.py b/packages/meshbay-node/tests/test_roots.py
index 1beb220..505091b 100644
--- a/packages/meshbay-node/tests/test_roots.py
+++ b/packages/meshbay-node/tests/test_roots.py
@@ -108,31 +108,61 @@ def test_a_sibling_with_a_shared_prefix_is_fine(tmp_path):
assert roots.names == ["Media", "Media2"]
-# ── Uploads ──────────────────────────────────────────────────────────────────
+# ── Writable roots ───────────────────────────────────────────────────────────
-def test_a_single_root_receives_uploads_without_being_asked(tmp_path):
+def test_a_root_is_read_only_unless_it_says_otherwise(tmp_path):
+ """
+ The default is the safe one. An operator who shares a directory has not
+ thereby agreed to let anyone write into it, and the version of this that
+ guessed — one root, so it must be the upload target — meant adding a
+ second directory silently changed what the first one was.
+ """
(tmp_path / "Media").mkdir()
roots = RootSet.build([_spec(tmp_path / "Media")])
- assert roots.upload_root is roots.roots[0]
+ assert roots.roots[0].writable is False
+ assert roots.writable_roots == []
+
+
+def test_several_roots_can_be_writable_at_once(tmp_path):
+ (tmp_path / "A").mkdir()
+ (tmp_path / "B").mkdir()
+ (tmp_path / "C").mkdir()
+ roots = RootSet.build([_spec(tmp_path / "A", writable=True),
+ _spec(tmp_path / "B"),
+ _spec(tmp_path / "C", writable=True)])
+ assert [r.name for r in roots.writable_roots] == ["A", "C"]
-def test_several_roots_and_no_designation_means_no_uploads(tmp_path):
+def test_a_fully_read_only_group_is_valid(tmp_path):
"""
- Refused, never guessed: picking one would send a member's file to a disk the
- operator did not intend, and that is discovered weeks later.
+ A group that only publishes is the point of the read-only model, not a
+ misconfiguration — build must not refuse it, and nothing downstream may
+ promote a root to writable to have somewhere to put an upload.
"""
(tmp_path / "A").mkdir()
(tmp_path / "B").mkdir()
roots = RootSet.build([_spec(tmp_path / "A"), _spec(tmp_path / "B")])
- assert roots.upload_root is None
+ assert roots.writable_roots == []
+ assert len(roots) == 2
-def test_two_upload_roots_are_refused(tmp_path):
- (tmp_path / "A").mkdir()
- (tmp_path / "B").mkdir()
- with pytest.raises(RootError, match="exactly one"):
- RootSet.build([_spec(tmp_path / "A", upload=True),
- _spec(tmp_path / "B", upload=True)])
+def test_the_old_upload_flag_still_reads_as_writable(tmp_path):
+ """A node.toml written before this refactor must not change meaning."""
+ (tmp_path / "Media").mkdir()
+ roots = RootSet.build([_spec(tmp_path / "Media", upload=True)])
+ assert roots.roots[0].writable is True
+ assert roots.describe()[0]["writable"] is True
+
+
+def test_writable_wins_over_a_leftover_upload_flag(tmp_path):
+ """
+ A config carrying both is one a migration touched. `writable` is the field
+ the operator's tooling writes now, so it is the one that decides — reading
+ the legacy field there would undo the migration on the next load.
+ """
+ (tmp_path / "Media").mkdir()
+ roots = RootSet.build([_spec(tmp_path / "Media", upload=True, writable=False)])
+ assert roots.roots[0].writable is False
# ── Resolution ───────────────────────────────────────────────────────────────
@@ -236,18 +266,35 @@ def test_availability_follows_the_directory(tmp_path):
def test_describe_reports_what_a_member_needs(tmp_path):
(tmp_path / "Media").mkdir()
(tmp_path / "Music").mkdir()
- roots = RootSet.build([_spec(tmp_path / "Media", upload=True),
- _spec(tmp_path / "Music", kind="audio")])
+ roots = RootSet.build([_spec(tmp_path / "Media", writable=True),
+ _spec(tmp_path / "Music", kind="audio",
+ removable=True)])
described = roots.describe()
assert described == [
- {"name": "Media", "kind": "generic", "available": True, "upload": True},
- {"name": "Music", "kind": "audio", "available": True, "upload": False},
+ {"name": "Media", "kind": "generic", "available": True,
+ "writable": True, "removable": False, "ejected": False,
+ "upload": True},
+ {"name": "Music", "kind": "audio", "available": True,
+ "writable": False, "removable": True, "ejected": False,
+ "upload": False},
]
# Deliberately no paths: a member is told what exists and whether it is
# readable, not where on the operator's disk it lives.
assert not any("path" in d for d in described)
+def test_describe_still_carries_upload_for_mnp_1_0_clients(tmp_path):
+ """
+ `upload` is `writable` under its old name, kept because an MNP 1.0 client
+ reads no other field and would otherwise decide the group takes no uploads
+ at all. It is derived, never stored — the two can never disagree.
+ """
+ (tmp_path / "Media").mkdir()
+ roots = RootSet.build([_spec(tmp_path / "Media", writable=True)])
+ described = roots.describe()[0]
+ assert described["upload"] == described["writable"] is True
+
+
# ── SAFE_UPLOAD_NAME ────────────────────────────────────────────────────────
def test_safe_name_accepts_unicode_letters():
diff --git a/packages/meshbay-node/tests/test_scan_settings_policy.py b/packages/meshbay-node/tests/test_scan_settings_policy.py
index 719b988..94f4421 100644
--- a/packages/meshbay-node/tests/test_scan_settings_policy.py
+++ b/packages/meshbay-node/tests/test_scan_settings_policy.py
@@ -2,7 +2,7 @@
The operator can tune how often the indexer's reconciliation backstop runs,
and how long it waits after a file's last write before hashing it.
-Same shape as test_apps_enabled_policy.py / test_member_upload_policy.py:
+Same shape as test_apps_enabled_policy.py / test_root_writable_policy.py:
changed by a signed operator instruction, stored on the node rather than the
hub. Unlike those two, there is also a *live* DirectoryIndexer object to
update — see test_set_scan_settings_updates_the_live_indexer below.
diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py
index 7f71da5..1a318f7 100644
--- a/packages/meshbay-node/tests/test_security_regressions.py
+++ b/packages/meshbay-node/tests/test_security_regressions.py
@@ -18,6 +18,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from meshbay_common.protocol import IndexEntry
from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.roots import RootSet
from conftest import one_root
from meshbay_node.transport.webrtc_server import WebRTCPeerSession
@@ -132,14 +133,22 @@ def test_the_node_never_generates_a_name_it_would_refuse(tmp_path):
def _uploads_dir(session) -> Path:
"""
- Where this session's uploads land: uploads/ inside the group's upload root.
+ Where an unaddressed upload lands: the first writable root itself.
+
+ There is no `uploads/` subdirectory any more. It was the last of v5's
+ quarantine — the per-user layer went on 2026-08-14 — and it went for the
+ same reason: a folder appearing beside the operator's library because
+ somebody sent a file is the node deciding how their disk is arranged. The
+ protections that made the quarantine worth having are the allowlist, the
+ size cap, the chunk ordering and the no-overwrite rule, and every one of
+ them is asserted below, unchanged.
Asked of the root set rather than assembled by hand, so a test cannot pass
while agreeing with a wrong answer the code also produced.
"""
- root = session._ctx["roots"].upload_root
- assert root is not None, "the fixture must designate an upload root"
- return root.path / "uploads"
+ writable = session._ctx["roots"].writable_roots
+ assert writable, "the fixture must give the group a writable root"
+ return writable[0].path
def _session(tmp_path: Path, user_id: str) -> WebRTCPeerSession:
@@ -175,7 +184,6 @@ def test_upload_cannot_overwrite_another_members_file(tmp_path):
"""
victim = _session(tmp_path, "victim-user")
uploads = _uploads_dir(victim)
- uploads.mkdir()
original = uploads / "important.mp4"
original.write_bytes(b"operator's original content")
@@ -225,22 +233,157 @@ def test_dir_create_cannot_escape_the_shared_root(tmp_path, bad):
assert set(tmp_path.rglob("*")) == before, f"created something via {bad!r}"
-def test_upload_ignores_any_directory_the_client_asks_for(tmp_path):
+def test_the_client_names_a_folder_and_never_a_filesystem_path(tmp_path):
+ """
+ The destination is now the folder the sender is looking at, which means the
+ client does choose it — and the whole of what keeps that safe is that the
+ choice is *resolved against the group's own roots* rather than joined to
+ one.
+
+ `RootSet.resolve()` refuses `..`, absolute segments and anything whose
+ resolved form escapes its root, symlinks included. So "which of this
+ group's folders" is answerable by a member and "which path on the
+ operator's disk" is not.
+ """
+ session = _session(tmp_path, "user-1")
+ (session._ctx["roots"].roots[0].path / "sub").mkdir()
+ before = set(tmp_path.rglob("*"))
+
+ for bad in ("../../etc", "/etc", "shared/../..", "shared/../../etc",
+ "nope", "shared/missing"):
+ session.sent.clear()
+ session._do_file_upload({
+ "filename": "note.txt", "dir": bad,
+ "chunk_index": 0, "total_chunks": 1,
+ "data": base64.b64encode(b"x").decode(),
+ })
+ refusal = [m for m in session.sent if m.get("type") == "error"]
+ assert refusal, f"{bad!r} was accepted"
+ assert refusal[0].get("code") in ("no_such_root", "no_such_directory"), bad
+
+ assert set(tmp_path.rglob("*")) == before, "a refused upload still wrote"
+
+
+def test_an_upload_lands_in_the_folder_it_names(tmp_path):
+ """
+ And in that folder itself — the `uploads/` subdirectory the node used to
+ create is gone. Somebody dropping a file into the folder they are looking
+ at expects it to be in that folder.
+ """
+ session = _session(tmp_path, "user-1")
+ root = session._ctx["roots"].roots[0]
+ (root.path / "Albums").mkdir()
+
+ session._do_file_upload({
+ "filename": "note.txt", "dir": f"{root.name}/Albums",
+ "chunk_index": 0, "total_chunks": 1,
+ "data": base64.b64encode(b"x").decode(),
+ })
+
+ assert (root.path / "Albums" / "note.txt").read_bytes() == b"x"
+ assert not (root.path / "Albums" / "uploads").exists(), (
+ "the node invented a subdirectory in the operator's library")
+ assert not (root.path / "uploads").exists()
+
+
+def test_an_upload_goes_to_the_root_it_names(tmp_path):
+ """
+ With two writable roots there is no defensible default, and the client is
+ the only party that knows which directory the person is looking at. The
+ node picking one meant a file uploaded from a folder on screen landed in a
+ different one — the same "uploads went somewhere else" the single upload
+ root was never allowed to guess about.
+ """
+ media = tmp_path / "Media"
+ incoming = tmp_path / "Incoming"
+ media.mkdir()
+ incoming.mkdir()
+ session = _session(tmp_path, "user-1")
+ session._ctx["roots"] = RootSet.build([
+ {"path": str(media), "writable": True},
+ {"path": str(incoming), "writable": True},
+ ])
+
+ session._do_file_upload({
+ "filename": "note.txt", "dir": "Incoming",
+ "chunk_index": 0, "total_chunks": 1,
+ "data": base64.b64encode(b"x").decode(),
+ })
+
+ assert (incoming / "note.txt").read_bytes() == b"x"
+ assert not (media / "note.txt").exists(), "it went to the first root instead"
+
+
+def test_a_read_only_root_refuses_an_upload(tmp_path):
+ """
+ RO is the mechanism now, not a hidden button. It binds the operator too:
+ "read-only for everyone" is what makes a published library one, and an
+ exception for whoever happens to hold admin authority is the sort of
+ carve-out that later reads as the rule.
+ """
+ published = tmp_path / "Published"
+ published.mkdir()
+ session = _session(tmp_path, "user-1")
+ session._ctx["roots"] = RootSet.build([{"path": str(published)}])
+ session._is_node_admin = lambda: True
+
+ session._do_file_upload({
+ "filename": "note.txt", "dir": "Published",
+ "chunk_index": 0, "total_chunks": 1,
+ "data": base64.b64encode(b"x").decode(),
+ })
+
+ refusal = [m for m in session.sent if m.get("type") == "error"]
+ assert refusal and refusal[0].get("code") == "root_read_only"
+ assert not (published / "note.txt").exists()
+
+
+def test_a_fully_read_only_group_refuses_an_unaddressed_upload(tmp_path):
+ """
+ An MNP 1.0 client names no root, so the node falls back to the first
+ writable one. There isn't one here, and the fallback must refuse rather
+ than write into whatever root happens to come first.
+ """
+ published = tmp_path / "Published"
+ published.mkdir()
+ session = _session(tmp_path, "user-1")
+ session._ctx["roots"] = RootSet.build([{"path": str(published)}])
+
+ session._do_file_upload({
+ "filename": "note.txt",
+ "chunk_index": 0, "total_chunks": 1,
+ "data": base64.b64encode(b"x").decode(),
+ })
+
+ refusal = [m for m in session.sent if m.get("type") == "error"]
+ assert refusal and refusal[0].get("code") == "no_writable_root"
+ assert not (published / "note.txt").exists()
+
+
+def test_an_ejected_root_refuses_an_upload(tmp_path):
"""
- Uploads land in uploads/, chosen by the node. A client that names somewhere
- else — or nowhere at all — changes nothing, so the traversal surface that a
- client-chosen destination would open does not exist on this path.
+ Writing to a drive somebody has their hand on is the thing eject exists to
+ stop. `writable` is still true — that is configuration — so availability
+ has to be checked separately, which is what an earlier version conflated.
"""
+ usb = tmp_path / "USB"
+ usb.mkdir()
session = _session(tmp_path, "user-1")
+ roots = RootSet.build([{"path": str(usb), "writable": True,
+ "removable": True}])
+ roots.roots[0].ejected = True
+ roots.roots[0].available = False
+ session._ctx["roots"] = roots
session._do_file_upload({
- "filename": "note.txt", "dir": "../../etc",
+ "filename": "note.txt", "dir": "USB",
"chunk_index": 0, "total_chunks": 1,
"data": base64.b64encode(b"x").decode(),
})
- assert (_uploads_dir(session) / "note.txt").read_bytes() == b"x"
- assert not (tmp_path / "etc").exists()
+ refusal = [m for m in session.sent if m.get("type") == "error"]
+ assert refusal and refusal[0].get("code") == "root_unavailable"
+ assert not (usb / "note.txt").exists()
def test_two_members_can_send_the_same_filename(tmp_path):
diff --git a/packages/meshbay-node/tests/test_startup_scan_enrichment.py b/packages/meshbay-node/tests/test_startup_scan_enrichment.py
index 65b9728..d2cbc3e 100644
--- a/packages/meshbay-node/tests/test_startup_scan_enrichment.py
+++ b/packages/meshbay-node/tests/test_startup_scan_enrichment.py
@@ -60,8 +60,9 @@ async def test_a_file_already_on_disk_at_startup_gets_enrichment_scheduled(tmp_p
data_dir=tmp_path / "data",
)
class _StubRoster:
- async def video_root(self, group_id):
- return "shared" # the root itself, i.e. "enrich the whole thing"
+ async def app_directories(self, group_id, app_key):
+ # The root itself, i.e. "enrich the whole thing".
+ return ["shared"] if app_key == "video" else []
daemon = NodeDaemon(config)
daemon._broadcast_coalesce_secs = 0.01 # real value would make this test wait 0.5s
diff --git a/packages/meshbay-node/tests/test_video_root_gates_enrichment.py b/packages/meshbay-node/tests/test_video_root_gates_enrichment.py
index 9cfb819..b88ecaf 100644
--- a/packages/meshbay-node/tests/test_video_root_gates_enrichment.py
+++ b/packages/meshbay-node/tests/test_video_root_gates_enrichment.py
@@ -99,7 +99,7 @@ async def test_only_entries_under_the_configured_root_are_enriched(tmp_path):
daemon = await _make_daemon(tmp_path, shared, group_id)
try:
- await daemon._roster.set_video_root(group_id, "shared/Movies", set_by="op")
+ await daemon._roster.set_app_directories(group_id, "video", ["shared/Movies"], set_by="op")
indexer = DirectoryIndexer(
roots=one_root(shared), group_id=group_id,
sk_node=Ed25519PrivateKey.generate(), gek=generate_gek())
@@ -139,8 +139,10 @@ async def test_setting_the_video_root_sweeps_what_it_already_contains(tmp_path):
# this file all along.
state = {
"roster": daemon._roster,
- "groups_ctx": {group_id: {}},
- "enrich_video_root_fn": daemon._enrich_video_root_now,
+ # Real roots, because ops now refuses a directory that is not
+ # inside one — the per-app setters this replaced validated nothing.
+ "groups_ctx": {group_id: {"roots": one_root(shared)}},
+ "enrich_app_dirs_fns": {"video": daemon._enrich_video_root_now},
}
await ops.set_video_root(state, group_id, "shared/Movies")
await asyncio.sleep(0.05) # let the fire-and-forget sweep actually run
diff --git a/packages/meshbay-node/tests/test_video_root_policy.py b/packages/meshbay-node/tests/test_video_root_policy.py
index 8cc1540..8d8c45a 100644
--- a/packages/meshbay-node/tests/test_video_root_policy.py
+++ b/packages/meshbay-node/tests/test_video_root_policy.py
@@ -126,16 +126,19 @@ async def test_the_setting_lives_on_the_node_and_survives_a_restart(tmp_path):
roster = Roster(db_path=tmp_path / "roster.db")
await roster.open()
try:
- assert await roster.video_root("g1") == "", "absent must mean the whole group index"
- await roster.set_video_root("g1", "shared/Movies", set_by="op")
- assert await roster.video_root("g1") == "shared/Movies"
+ assert await roster.app_directories("g1", "video") == [], (
+ "absent must mean nothing configured")
+ await roster.set_app_directories("g1", "video", ["shared/Movies"],
+ set_by="op")
+ assert await roster.app_directories("g1", "video") == ["shared/Movies"]
finally:
await roster.close()
reopened = Roster(db_path=tmp_path / "roster.db")
await reopened.open()
try:
- assert await reopened.video_root("g1") == "shared/Movies"
- assert await reopened.video_root("g2") == "", "one group's setting must not answer for another"
+ assert await reopened.app_directories("g1", "video") == ["shared/Movies"]
+ assert await reopened.app_directories("g2", "video") == [], (
+ "one group's setting must not answer for another")
finally:
await reopened.close()
diff --git a/packages/meshbay-node/tests/test_windows_root_shapes.py b/packages/meshbay-node/tests/test_windows_root_shapes.py
new file mode 100644
index 0000000..5c5da25
--- /dev/null
+++ b/packages/meshbay-node/tests/test_windows_root_shapes.py
@@ -0,0 +1,149 @@
+"""
+The root model against the shapes Windows produces.
+
+CLAUDE.md is explicit that exFAT/NTFS and Windows are the common case, not an
+edge case: most operators are expected to share from an external drive on
+Windows. The RO/RW refactor added two booleans and a config rewriter, and the
+booleans are path-independent — but the rewriter, the name derivation and the
+collision check all touch paths, and none of them has ever run on Windows here.
+
+What this can check without Windows is the *shape* work: drive letters through
+`as_posix()`, a path with no basename to derive a name from, UNC, and a
+case-insensitive collision. `PureWindowsPath` is used deliberately — the plain
+`Path` on this machine is a `PosixPath`, where a backslash is an ordinary
+filename character, which is the mistake that made
+`test_a_backslash_path_written_into_node_toml_stays_parseable` fail everywhere
+but the platform it was written for.
+
+What it cannot check is the filesystem itself: `ReadDirectoryChangesW` dropping
+events under load, `MAX_PATH`, and whether an eject actually lets a drive be
+removed. Those need a person with Windows, and §4.4 of the refactor plan is
+where that is written down.
+"""
+
+import os
+import tempfile
+import tomllib
+from pathlib import Path, PureWindowsPath
+
+import pytest
+
+from meshbay_node.roots import RootError, RootSet, derive_name
+
+BS = chr(92)
+
+
+# ── Paths into node.toml ─────────────────────────────────────────────────────
+
+@pytest.mark.parametrize("raw,expected", [
+ (f"D:{BS}Movies", "D:/Movies"),
+ (f"E:{BS}Music{BS}Albums", "E:/Music/Albums"),
+ (f"C:{BS}Users{BS}alice{BS}Media", "C:/Users/alice/Media"),
+ (f"{BS}{BS}server{BS}share{BS}Media", "//server/share/Media"),
+])
+def test_a_windows_path_survives_the_config_file(raw, expected):
+ """
+ `ops` writes `as_posix()` into a TOML basic string, where a raw backslash
+ is an escape — `\\U` and `\\a` are the ones that bite — so the file would
+ not parse at all. pathlib reads the forward-slash form back on Windows.
+ """
+ posix = PureWindowsPath(raw).as_posix()
+ assert posix == expected
+ parsed = tomllib.loads(f'path = "{posix}"\n')
+ assert parsed["path"] == expected
+
+
+def test_a_raw_windows_path_would_not_parse():
+ """The counter-property: without `as_posix()` there is no config file."""
+ with pytest.raises(tomllib.TOMLDecodeError):
+ tomllib.loads(f'path = "C:{BS}Users{BS}alice{BS}Media"\n')
+
+
+# ── Naming a drive ───────────────────────────────────────────────────────────
+
+@pytest.mark.parametrize("raw,name", [
+ (f"D:{BS}Movies", "Movies"),
+ (f"E:{BS}Music{BS}Albums", "Albums"),
+ (f"{BS}{BS}server{BS}share{BS}Media", "Media"),
+])
+def test_a_name_is_derived_from_the_last_segment(raw, name):
+ assert PureWindowsPath(raw).name == name
+
+
+@pytest.mark.parametrize("raw", [f"D:{BS}", f"E:{BS}", f"{BS}{BS}server{BS}share"])
+def test_a_drive_root_has_no_name_to_derive(raw):
+ """
+ Sharing a whole drive is an ordinary thing to do on Windows and there is
+ nothing to call it, so the operator has to say. Refused with that as the
+ message rather than named "" or "D:".
+ """
+ p = PureWindowsPath(raw)
+ if p.name:
+ pytest.skip(f"{raw!r} has a basename on this platform")
+ with pytest.raises(RootError, match="explicit"):
+ derive_name(p)
+
+
+def test_naming_it_explicitly_works():
+ with tempfile.TemporaryDirectory() as d:
+ roots = RootSet.build([{"path": d, "name": "Films"}])
+ assert roots.names == ["Films"]
+
+
+# ── Case, which Windows makes real ───────────────────────────────────────────
+
+def test_two_roots_differing_only_in_case_are_refused():
+ """
+ On NTFS and exFAT `Movies` and `MOVIES` are the same directory to the
+ filesystem and two roots to a case-sensitive comparison — which would index
+ one tree twice, and make deleting a file from one copy break the other.
+ """
+ with tempfile.TemporaryDirectory() as d:
+ os.makedirs(os.path.join(d, "Movies"))
+ os.makedirs(os.path.join(d, "other"))
+ with pytest.raises(RootError, match="regard to case"):
+ RootSet.build([
+ {"path": os.path.join(d, "Movies")},
+ {"path": os.path.join(d, "other"), "name": "MOVIES"},
+ ])
+
+
+def test_a_root_is_found_by_name_without_regard_to_case():
+ """
+ What a client sends is what a person typed or a path it split, and on
+ Windows those disagree about case routinely.
+ """
+ with tempfile.TemporaryDirectory() as d:
+ os.makedirs(os.path.join(d, "Movies"))
+ roots = RootSet.build([{"path": os.path.join(d, "Movies")}])
+ for spelling in ("Movies", "movies", "MOVIES", "MoViEs"):
+ assert roots.by_name(spelling) is not None, spelling
+
+
+# ── The two flags ────────────────────────────────────────────────────────────
+
+def test_the_flags_do_not_touch_paths():
+ """
+ `writable` and `removable` are booleans and stay booleans on every
+ platform. Stated as a test because it is the reason the rest of the
+ refactor needed no Windows work: what did need it is above.
+ """
+ with tempfile.TemporaryDirectory() as d:
+ os.makedirs(os.path.join(d, "USB"))
+ roots = RootSet.build([{"path": os.path.join(d, "USB"),
+ "writable": True, "removable": True}])
+ described = roots.describe()[0]
+ assert described["writable"] is True
+ assert described["removable"] is True
+ assert "path" not in described
+
+
+def test_an_ejected_removable_root_is_unavailable_wherever_it_runs():
+ with tempfile.TemporaryDirectory() as d:
+ os.makedirs(os.path.join(d, "USB"))
+ roots = RootSet.build([{"path": os.path.join(d, "USB"),
+ "removable": True, "ejected": True}])
+ assert roots.roots[0].available is False
+ assert Path(roots.roots[0].path).is_dir(), (
+ "the directory is still there; `ejected` is the operator's answer, "
+ "not the filesystem's")