summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/refactor-groups.md657
-rw-r--r--packages/meshbay-common/src/meshbay_common/__init__.py2
-rw-r--r--packages/meshbay-common/src/meshbay_common/adminop.py3
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/apps.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js100
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js35
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js449
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css36
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js62
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js5
-rw-r--r--packages/meshbay-node/src/meshbay_node/config.py42
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py185
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/indexer.py38
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py293
-rw-r--r--packages/meshbay-node/src/meshbay_node/roots.py62
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py14
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py276
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py32
32 files changed, 1934 insertions, 495 deletions
diff --git a/docs/refactor-groups.md b/docs/refactor-groups.md
new file mode 100644
index 0000000..11956ce
--- /dev/null
+++ b/docs/refactor-groups.md
@@ -0,0 +1,657 @@
+# Groups Refactor — Per-Root Permissions & App Plugin Architecture
+
+> Status: **Phase 1 implemented.** Phase 2 and 3 not started.
+>
+> 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.
+
+---
+
+## 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 runs a **reconciliation** (not a full rescan) — compares frozen
+ entries against current filesystem state. New/changed/deleted files are
+ handled normally
+6. `index_sync` update propagates — entries reappear in all apps
+
+**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)
+- 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
+
+```
+# Group creation (first root defaults to RW)
+meshbay-node group add <name> --dir <path> # first root, RW
+meshbay-node group add <name> --dir <path> --read-only # first root, RO
+
+# Root management
+meshbay-node root add <group> <path> [--name <name>] [--writable] [--removable]
+meshbay-node root remove <group> <name>
+meshbay-node root set <group> <name> --writable # toggle to RW
+meshbay-node root set <group> <name> --read-only # toggle to RO
+meshbay-node root set <group> <name> --removable # mark as removable
+meshbay-node root set <group> <name> --no-removable # unmark
+meshbay-node root eject <group> <name> # safe eject
+meshbay-node root plug <group> <name> # re-plug
+meshbay-node root list <group>
+
+# Deprecated (removed with warning)
+--upload-dir → "use --writable on the target root instead"
+member upload → "use 'root set --read-only' / 'root set --writable' instead"
+```
+
+### 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.
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..1d2e2b2 100644
--- a/packages/meshbay-common/src/meshbay_common/adminop.py
+++ b/packages/meshbay-common/src/meshbay_common/adminop.py
@@ -106,6 +106,9 @@ OP_AUDIO_ROOT = "audio_root"
OP_PHOTO_ROOTS = "photo_roots"
OP_ROOT_ADD = "root_add"
OP_ROOT_REMOVE = "root_remove"
+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..7ee5df1 100644
--- a/packages/meshbay-common/src/meshbay_common/protocol.py
+++ b/packages/meshbay-common/src/meshbay_common/protocol.py
@@ -164,6 +164,12 @@ 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
+ 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/static/apps.js b/packages/meshbay-hub/src/meshbay_hub/static/apps.js
index 47b5bba..03f85ae 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/apps.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/apps.js
@@ -18,7 +18,7 @@ import { PhotosApp } from './photos-app.js';
*/
const APPS = [
{ key: 'chat', icon: 'chat', labelKey: 'group.tab_chat', Component: ChatPanel },
- { key: 'files', icon: 'folder', labelKey: 'group.tab_files', Component: FilesPanel },
+ { key: 'files', icon: 'folder', labelKey: 'group.tab_files', Component: FilesPanel, alwaysEnabled: true },
{ 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 },
@@ -28,7 +28,7 @@ const APPS = [
function visibleApps(enabledKeys) {
const enabled = new Set(
enabledKeys && enabledKeys.length ? enabledKeys : APPS.map(a => a.key));
- return APPS.filter(a => enabled.has(a.key));
+ return APPS.filter(a => a.alwaysEnabled || enabled.has(a.key));
}
export { APPS, visibleApps };
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..a5676ba 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,8 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
// 2. Attach to node with first root
update('running');
- const mainRoot = roots[uploadIdx] || roots[0];
+ const mainRoot = roots[0];
const attachBody = { name: name.trim(), shared_dir: mainRoot.path };
- if (roots.length === 1 || uploadIdx === 0) {
- attachBody.upload_dir = mainRoot.path;
- }
await platform.node.call('POST', '/api/groups/attach', attachBody);
await platform.node.call('POST', '/api/reload');
update('done');
@@ -247,22 +225,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 +239,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 +262,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 +318,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 +367,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..56311ae 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
@@ -212,6 +212,9 @@ 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;
+ const currentRootWritable = currentRoot ? currentRoot.writable : 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
@@ -338,7 +341,7 @@ 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"
@@ -419,16 +422,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/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index 466af53..1b2661c 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';
@@ -88,9 +88,8 @@ 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.
+ // DEPRECATED: memberUpload is now derived from per-root writable flags.
+ // Kept as state only for backward compat with nodes that still send it.
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
@@ -348,6 +347,11 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
transport.onPhotoRoots = (roots) => setPhotoRoots(roots);
transport.onMusicbrainzEnabled = (enabled) =>
setMusicbrainzConfig((prev) => ({ ...(prev || {}), enabled }));
+ transport.onRootsChanged = (msg) => {
+ if (msg.roots) {
+ 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
@@ -536,8 +540,10 @@ 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;
+ // answered once. Per-root writable flags replace the old binary toggle;
+ // falls back to the legacy memberUpload for old nodes.
+ const hasWritableRoot = nodeRoots.some((r) => r.writable);
+ const mayUpload = hasWritableRoot || memberUpload || isNodeAdmin;
// 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,9 +572,21 @@ 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]);
+
const commonProps = {
groupId, transportRef, gekRef, status, username,
- entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex,
+ entries, availableEntries, nodeDirs, nodeRoots,
+ setEntries, setNodeDirs, setNodeRoots, applyIndex,
isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview,
onRefreshIndex: refreshIndex, onActivity: touchActivity,
videoRoot, onVideoRoot: (path) => setVideoRoot(path),
@@ -699,8 +717,6 @@ 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)}
enabledApps=${enabledApps}
onEnabledApps=${(keys) => setEnabledApps(keys)}
scanSettings=${scanSettings}
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..de6f8c0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -174,6 +174,254 @@ function PhotoRootsRow({ folders, value, busy, msg, onSave }) {
`;
}
+// ── Shared Directories Table ────────────────────────────────────────────
+
+/**
+ * Reusable table of a group's root directories with per-root controls.
+ *
+ * Used in both the Settings page (with full edit controls) and the Create
+ * Group wizard (with add-only). Each root shows its name, a writable
+ * toggle, a removable badge, and eject/plug buttons for removable roots.
+ *
+ * Props:
+ * roots — array of { name, writable, removable, ejected, available, kind }
+ * groupId — the group id
+ * transport — MeshBayTransport instance (null when not connected)
+ * signFn — signing function for admin ops
+ * platform — platform bridge (for Electron root picker)
+ * nodeDetected — whether local node API is available
+ * readOnly — suppress edit controls (default false)
+ * onRootsChange — callback(roots) after a change
+ * onRefreshIndex — trigger a full index refresh after add/remove
+ */
+/**
+ * Two modes:
+ * mode="live" — connected to a node, persists changes via MNP/loopback API
+ * mode="local" — during group creation, manages a local array, reports changes
+ * via onLocalRootsChange(roots)
+ */
+function SharedDirectoriesTable({ roots, groupId, transport, signFn,
+ nodeDetected: nodeAvail, readOnly,
+ onRootsChange, onRefreshIndex,
+ mode = 'live',
+ localRoots, onLocalRootsChange }) {
+ const isLocal = mode === 'local';
+ const [optimistic, setOptimistic] = useState({});
+ const serverRoots = isLocal ? (localRoots || []) : roots;
+ const displayRoots = serverRoots.map(r =>
+ optimistic[r.name] ? { ...r, ...optimistic[r.name] } : r);
+ const [busy, setBusy] = useState(false);
+ const [msg, setMsg] = useState('');
+ const [indexProgress, setIndexProgress] = useState(null);
+
+ 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 } }));
+ setBusy(true); setMsg('');
+ try {
+ if (transport && transport.connected) {
+ await transport.updateRoot(groupId, rootName, updates, signFn);
+ } else if (nodeAvail) {
+ await platform.node.call('PATCH',
+ '/api/groups/' + groupId + '/roots/' + encodeURIComponent(rootName),
+ updates);
+ }
+ if (onRootsChange) await onRootsChange();
+ } catch (err) { setMsg(err.message); }
+ finally {
+ setOptimistic(prev => { const next = { ...prev }; delete next[rootName]; return next; });
+ setBusy(false);
+ }
+ }, [isLocal, localRoots, onLocalRootsChange, transport, groupId, signFn, nodeAvail, onRootsChange]);
+
+ const doEjectRoot = useCallback(async (rootName) => {
+ if (isLocal) return;
+ setBusy(true); setMsg('');
+ try {
+ if (transport && transport.connected) {
+ await transport.ejectRoot(groupId, rootName, signFn);
+ } else if (nodeAvail) {
+ await platform.node.call('PUT',
+ '/api/groups/' + groupId + '/roots/' + encodeURIComponent(rootName) + '/eject');
+ }
+ if (onRootsChange) onRootsChange();
+ } catch (err) { setMsg(err.message); }
+ finally { setBusy(false); }
+ }, [isLocal, transport, groupId, signFn, nodeAvail, onRootsChange]);
+
+ const doPlugRoot = useCallback(async (rootName) => {
+ if (isLocal) return;
+ setBusy(true); setMsg('');
+ try {
+ if (transport && transport.connected) {
+ await transport.plugRoot(groupId, rootName, signFn);
+ } else if (nodeAvail) {
+ await platform.node.call('PUT',
+ '/api/groups/' + groupId + '/roots/' + encodeURIComponent(rootName) + '/plug');
+ }
+ if (onRootsChange) onRootsChange();
+ } catch (err) { setMsg(err.message); }
+ finally { setBusy(false); }
+ }, [isLocal, transport, groupId, signFn, nodeAvail, onRootsChange]);
+
+ 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;
+ setBusy(true); setMsg('');
+ try {
+ if (transport && transport.connected) {
+ await transport.removeRoot(groupId, rootName, signFn);
+ } else if (nodeAvail) {
+ await platform.node.call('DELETE',
+ '/api/groups/' + groupId + '/roots/' + encodeURIComponent(rootName));
+ await platform.node.call('POST', '/api/reload');
+ }
+ setMsg(t('node.root_removed'));
+ if (onRootsChange) onRootsChange();
+ if (onRefreshIndex) await onRefreshIndex();
+ } catch (err) { setMsg(err.message); }
+ finally { setBusy(false); }
+ }, [isLocal, localRoots, onLocalRootsChange, transport, groupId, signFn, nodeAvail, onRootsChange, onRefreshIndex]);
+
+ const doAddRoot = useCallback(async () => {
+ const chosen = await platform.rootPicker.choose();
+ if (!chosen) return;
+ if (isLocal) {
+ if ((localRoots || []).some(r => r.path === chosen.path)) return;
+ const isFirst = (localRoots || []).length === 0;
+ const newRoot = {
+ name: chosen.name, path: chosen.path,
+ writable: isFirst, removable: false,
+ };
+ if (onLocalRootsChange) onLocalRootsChange([...(localRoots || []), newRoot]);
+ return;
+ }
+ setBusy(true); setMsg(''); setIndexProgress(null);
+ try {
+ if (nodeAvail) {
+ await platform.node.call('POST',
+ '/api/groups/' + groupId + '/roots',
+ { path: chosen.path, name: chosen.name });
+ await platform.node.call('POST', '/api/reload');
+ await platform.watchIndexProgress(groupId, setIndexProgress);
+ }
+ setMsg(t('node.root_added'));
+ if (onRootsChange) onRootsChange();
+ if (onRefreshIndex) await onRefreshIndex();
+ } catch (err) { setMsg(platform.bridgeMessage(err)); }
+ finally { setBusy(false); }
+ }, [isLocal, localRoots, onLocalRootsChange, groupId, nodeAvail, onRootsChange, onRefreshIndex]);
+
+ if (!displayRoots || displayRoots.length === 0) {
+ return html`
+ <div class="shared-directories-table">
+ <p class="settings-hint">${t('settings_node.shared_directories_hint')}</p>
+ <button class="btn btn-small btn-secondary" style="margin-top:8px"
+ onClick=${doAddRoot}>
+ <${Icon} name="folder-plus" /> ${t('node.add_root')}
+ </button>
+ </div>
+ `;
+ }
+
+ return html`
+ <div class="shared-directories-table">
+ ${msg && html`<p class="settings-hint">${msg}</p>`}
+ <table class="shared-dirs-tbl">
+ <thead>
+ <tr>
+ <th class="sdt-col-dir">${t('node.directory')}</th>
+ ${!readOnly && html`<th class="sdt-col-toggle">${t('node.root_rw')}</th>`}
+ ${!readOnly && !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) ? '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 && !r.ejected && html`
+ <span class="node-root-badge node-root-badge-warn">${t('node.unavailable')}</span>`}
+ </td>
+ ${!readOnly && html`
+ <td class="sdt-col-toggle">
+ <${ToggleSwitch} checked=${!!r.writable} disabled=${busy || !!r.ejected}
+ onChange=${(v) => doUpdateRoot(r.name, { writable: v })} />
+ </td>
+ `}
+ ${!readOnly && !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">
+ ${!readOnly && !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>
+ <button class="sdt-action-btn sdt-action-danger" disabled=${busy}
+ title=${t('node.remove_root')}
+ onClick=${() => doRemoveRoot(r.name)}>
+ \u{2715}
+ </button>
+ `}
+ </td>
+ </tr>
+ `; })}
+ </tbody>
+ </table>
+ ${!readOnly && (isLocal || nodeAvail) && html`
+ <button class="btn btn-small btn-secondary" style="margin-top:8px"
+ disabled=${busy}
+ onClick=${doAddRoot}>
+ <${Icon} name="folder-plus" /> ${t('node.add_root')}
+ </button>
+ `}
+ ${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>
+ `;
+}
+
+
// ── Members Panel ────────────────────────────────────────────────────────
/**
@@ -187,7 +435,6 @@ function PhotoRootsRow({ folders, value, busy, msg, onSave }) {
*/
function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
isNodeAdmin, userId, operatorPaired, connected,
- memberUpload, onMemberUpload,
enabledApps, onEnabledApps,
scanSettings, onScanSettings,
tmdbConfig, onTmdbConfig, onTmdbEnabled,
@@ -332,36 +579,7 @@ 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('');
@@ -884,6 +1102,30 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
</div>
`}
+ ${/* Shared directories — the group's root folders. Shown to the
+ operator when the node is detected locally (Electron) or a live
+ MNP connection is available, so root properties can be toggled.
+ Appears early because it is the fundamental structural control. */
+ isNodeAdmin && (connected || nodeDetected) && nodeRoots.length > 0 && html`
+ <${CollapsibleSection} titleKey="settings_node.shared_directories_title">
+ <p class="settings-hint">${t('settings_node.shared_directories_hint')}</p>
+ <${SharedDirectoriesTable}
+ roots=${nodeRoots}
+ groupId=${groupId}
+ transport=${transportRef.current}
+ signFn=${(() => {
+ const sk = transportRef.current && transportRef.current.sessionKeys
+ && transportRef.current.sessionKeys.skEdB64;
+ return (sk && window.MeshBayKeys)
+ ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
+ : null;
+ })()}
+ nodeDetected=${nodeDetected}
+ onRootsChange=${loadNodeInfo}
+ onRefreshIndex=${onRefreshIndex} />
+ </${CollapsibleSection}>
+ `}
+
${/* 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. */
@@ -891,7 +1133,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
<${CollapsibleSection} titleKey="members.apps_title">
<p class="settings-hint">${t('members.apps_hint')}</p>
<ul class="apps-toggle-list">
- ${APPS.map(a => html`
+ ${APPS.filter(a => !a.alwaysEnabled).map(a => html`
<li key=${a.key} class="settings-row">
<label class="settings-label">
<input type="checkbox" checked=${activeApps.includes(a.key)}
@@ -1049,146 +1291,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
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>
+ ${/* Root management moved to SharedDirectoriesTable above. */''}
</${CollapsibleSection}>
`}
@@ -1334,4 +1437,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/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 46168cf..f929873 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -805,7 +805,9 @@ 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.',
+ '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 +911,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..f6c47fe 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -169,6 +169,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}?',
@@ -599,8 +603,10 @@ 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.',
+ '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',
@@ -754,6 +760,10 @@ export default {
'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',
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..a8c1296 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -801,7 +801,9 @@ 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.',
+ '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 +907,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..6d36e41 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -168,6 +168,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} ?',
@@ -731,6 +735,10 @@ export default {
'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 +824,10 @@ 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.',
+ '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..508d69c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -815,7 +815,9 @@ 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.',
+ '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 +921,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..cfd65da 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -799,7 +799,9 @@ 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': 'このグループと共有されているフォルダー。読み書きを切り替えてアップロードを許可し、外付けドライブにはリムーバブルを設定します。',
+ 'settings_node.directories_title': 'アプリのディレクトリ',
'settings_node.directories_hint': '共有フォルダと、動画・音楽・写真の各アプリがそれぞれの起点として使用するフォルダです。',
// Wizard
@@ -903,4 +905,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..0569efb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -817,7 +817,9 @@ 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.',
+ '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 +923,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..92c853b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -843,7 +843,9 @@ 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.',
+ '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 +949,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..cd5f7e8 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
@@ -802,7 +802,9 @@ 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.',
+ '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 +908,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..b80ac08 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
@@ -786,7 +786,9 @@ 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': '与此群组共享的文件夹。切换读写以允许上传,标记为可移除用于外置驱动器。',
+ 'settings_node.directories_title': '应用目录',
'settings_node.directories_hint': '共享文件夹,以及“视频”“音乐”和“照片”应用各自使用哪个(些)作为入口。',
// Create group wizard
@@ -891,4 +893,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.js b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
index 616f169..212745a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
@@ -453,7 +453,7 @@ function FlatList({ tracks, artists, onPlayQueue }) {
// -- shell --------------------------------------------------------------------
function MusicApp({
- groupId, transportRef, gekRef, status, entries, audioRoot, musicbrainzConfig, onPlayQueue,
+ groupId, transportRef, gekRef, status, entries, availableEntries, audioRoot, musicbrainzConfig, onPlayQueue,
hideFilter,
}) {
const [mode, setMode] = useState(loadViewMode);
@@ -465,8 +465,9 @@ function MusicApp({
const setModeAndSave = (m) => { setMode(m); saveViewMode(m); };
+ const musicEntries = availableEntries || entries;
const { tracks, artists, albums } = useMemo(
- () => groupMusicEntries(entries, audioRoot), [entries, audioRoot]);
+ () => groupMusicEntries(musicEntries, audioRoot), [musicEntries, audioRoot]);
const needle = filter.trim().toLowerCase();
const filteredArtists = useMemo(() => {
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..211c836 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,7 @@ 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, photoRoots, setError,
hideFilter, readOnly,
}) {
const [openDir, setOpenDir] = useState(null);
@@ -330,8 +330,9 @@ function PhotosApp({
useEffect(() => { setOpenDir(null); setFilter(''); }, [groupId]);
+ const photoEntries = availableEntries || entries;
const albums = useMemo(
- () => groupPhotoAlbums(entries, photoRoots), [entries, photoRoots]);
+ () => groupPhotoAlbums(photoEntries, photoRoots), [photoEntries, photoRoots]);
const needle = filter.trim().toLowerCase();
const filteredAlbums = useMemo(() => (!needle ? albums : albums.filter(
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index c183d7e..51f9d70 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -1267,6 +1267,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;
@@ -2542,6 +2543,41 @@ 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; }
+.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-hover); color: var(--text); }
+.sdt-action-btn:disabled { opacity: 0.3; cursor: default; }
+.sdt-action-danger:hover:not(:disabled) { color: var(--danger, #ef4444); border-color: var(--danger, #ef4444); }
+.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-hover); }
+
.node-root {
display: flex;
align-items: center;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index ea1e70a..482574f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -77,7 +77,8 @@ const ADMIN_OP_TYPES = new Set([
'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',
+ 'root_add', 'root_remove', 'root_update', 'root_eject', 'root_plug',
+ 'member_unpin', 'gek_rotate', 'group_attach',
'group_detach', 'invite_create',
]);
@@ -217,7 +218,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,6 +326,7 @@ 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; }
set onAppsEnabled(fn) { this._onAppsEnabled = fn; }
set onTmdbConfig(fn) { this._onTmdbConfig = fn; }
set onTmdbEnabled(fn) { this._onTmdbEnabled = fn; }
@@ -1452,11 +1454,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 +1480,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,
@@ -2277,6 +2322,13 @@ class MeshBayTransport {
this._onMusicbrainzEnabled(Boolean(msg.enabled));
}
+ // A root's writable/removable flags changed, or a root was ejected/plugged.
+ // Broadcast to all peers so everyone sees the change.
+ if ((msg.type === 'root_update_ack' || msg.type === 'root_eject_ack'
+ || msg.type === 'root_plug_ack') && 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.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
index 4f52b16..d03d843 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
@@ -1040,7 +1040,7 @@ 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, videoRoot, tmdbConfig, isNodeAdmin,
hideFilter, onNeedConn,
}) {
const [mode, setMode] = useState(loadViewMode);
@@ -1056,8 +1056,9 @@ function VideoApp({
const setModeAndSave = (m) => { setMode(m); saveViewMode(m); };
+ const videoEntries = availableEntries || entries;
const { movies, shows } = useMemo(
- () => groupVideoEntries(entries, videoRoot), [entries, videoRoot]);
+ () => groupVideoEntries(videoEntries, videoRoot), [videoEntries, videoRoot]);
const needle = filter.trim().toLowerCase();
const filteredMovies = useMemo(() => (typeFilter === 'series' ? [] : !needle ? movies : movies.filter(
diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py
index 0e5a7de..4712312 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,7 +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
+ writable: bool = False # RW roots accept uploads from group members
+ removable: bool = False # operator can eject this root before unplugging the device
direct: bool = False # uploads land at root path, not in a subdirectory
@@ -201,7 +197,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 +220,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, direct=True))
@dataclass
@@ -285,15 +281,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..f45101f 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -1663,15 +1663,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 +1689,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 "
@@ -1710,14 +1714,29 @@ def main() -> 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="mark root as read-write (root set/add)")
+ parser.add_argument("--no-writable", action="store_false",
+ dest="writable",
+ help="mark root as read-only (root set)")
+ 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 +1980,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.
@@ -2408,11 +2434,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']}")
@@ -2450,10 +2483,18 @@ def main() -> None:
cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
body = {"name": args.target, "shared_dir": args.dir}
if args.upload_dir:
+ import warnings
+ warnings.warn(
+ "--upload-dir is deprecated; the main root is writable by "
+ "default. Use 'meshbay-node root add' for additional roots.",
+ DeprecationWarning, stacklevel=1)
+ print("WARNING: --upload-dir is deprecated. The shared directory is "
+ "writable by default. Use 'meshbay-node root add' for "
+ "additional roots.")
body["upload_dir"] = args.upload_dir
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']}")
+ print(f" shared_dir {out['shared_dir']} (writable)")
if out.get("upload_dir"):
print(f" upload_dir {out['upload_dir']}")
print()
@@ -2465,6 +2506,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, f"/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..2911278 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
@@ -723,6 +723,44 @@ 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)
+ self._drop_root_entries(root)
+ await self._scan_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..c3a3f9c 100644
--- a/packages/meshbay-node/src/meshbay_node/ops.py
+++ b/packages/meshbay-node/src/meshbay_node/ops.py
@@ -429,20 +429,16 @@ 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
+ # Legacy: upload_dir becomes a second writable root
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:
@@ -451,9 +447,8 @@ async def attach_group(state: dict, name: str, shared_dir: str,
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\n')
try:
with conf_path.open("a", encoding="utf-8", newline="\n") as f:
f.write(block)
@@ -463,8 +458,6 @@ 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),
"note": "restart the node to pick it up"}
- if separate_upload:
- result["upload_dir"] = str(upload_path)
return result
@@ -641,7 +634,8 @@ def _remove_roots_block(conf_path: Path, group_id: str,
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 +649,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,14 +669,17 @@ 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 += f'\n writable = true'
+ if removable:
+ root_block += f'\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,
+ direct=added.direct))
log.info("Root added: %s → group %s", added.name, group_id[:8])
return {"status": "added", "name": added.name, "path": str(added.path),
@@ -714,25 +712,242 @@ 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
+
+ # Update the live RootSet so GET /api/groups returns correct data
+ # immediately, without waiting for the async reload.
+ live_roots = state.get("groups_ctx", {}).get(
+ group_id, {}).get("roots")
+ if live_roots:
+ live_roots.roots = [
+ r for r in live_roots.roots if fold(r.name) != target]
+
+ result_roots = live_roots.describe() if live_roots else []
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
+
+ result_roots = live_roots.describe() if live_roots else []
+
+ 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()}
+
+ root.ejected = True
+ root.available = False
+
+ roster = _roster(state)
+ if roster:
+ await roster.set_setting(
+ group_id, f"root_ejected:{fold(root_name)}", "1",
+ set_by=state.get("node_user_id", ""))
+
+ indexer = state.get("indexers", {}).get(group_id)
+ if indexer:
+ indexer.eject_root(root_name)
+
+ 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)
+
+ root.ejected = False
+ root.available = True
+
+ roster = _roster(state)
+ if roster:
+ await roster.set_setting(
+ group_id, f"root_ejected:{fold(root_name)}", "0",
+ set_by=state.get("node_user_id", ""))
+
+ indexer = state.get("indexers", {}).get(group_id)
+ if indexer:
+ await indexer.plug_root(root_name)
+
+ 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(f"Root path not found in config", status=404)
# ── Files ────────────────────────────────────────────────────────────────────
@@ -806,26 +1021,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 +1119,14 @@ 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)
+ 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 +1143,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
@@ -1086,7 +1283,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..a83b729 100644
--- a/packages/meshbay-node/src/meshbay_node/roots.py
+++ b/packages/meshbay-node/src/meshbay_node/roots.py
@@ -120,10 +120,10 @@ class Root:
name: str
path: Path
kind: str = "generic"
- upload: bool = False
+ writable: bool = False
+ removable: 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.
+ ejected: bool = False
available: bool = True
@property
@@ -172,8 +172,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 +210,18 @@ 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)))
root = Root(name=name, path=path, kind=kind,
- upload=bool(spec.get("upload", False)),
+ writable=writable,
+ removable=bool(spec.get("removable", False)),
direct=bool(spec.get("direct", 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 +232,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 +318,23 @@ 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 and not root.ejected:
+ root.ejected = True
+ log.warning("Root %r auto-ejected (device disappeared): %s",
+ root.name, root.path)
if live != root.available:
root.available = live
changed.append((root, live))
@@ -352,7 +347,12 @@ class RootSet:
out = []
for r in self.roots:
d: dict = {"name": r.name, "kind": r.kind,
- "available": r.available, "upload": r.upload}
+ "available": r.available,
+ "writable": r.writable,
+ "removable": r.removable,
+ "ejected": r.ejected,
+ # Backward compat for MNP 1.0 clients
+ "upload": r.writable}
if r.direct:
d["direct"] = True
out.append(d)
diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py
index c78281b..e2f749f 100644
--- a/packages/meshbay-node/src/meshbay_node/roster.py
+++ b/packages/meshbay-node/src/meshbay_node/roster.py
@@ -587,11 +587,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]:
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..4d6dd34 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -78,6 +78,9 @@ from meshbay_common.adminop import (
OP_PHOTO_ROOTS,
OP_ROOT_ADD,
OP_ROOT_REMOVE,
+ OP_ROOT_UPDATE,
+ OP_ROOT_EJECT,
+ OP_ROOT_PLUG,
OP_GROUP_ATTACH,
OP_GROUP_DETACH,
admin_transcript,
@@ -507,6 +510,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 +763,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
@@ -1756,52 +1767,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
@@ -1828,6 +1799,8 @@ class WebRTCPeerSession:
self._send({"type": "error",
"detail": f"Unknown app(s): {', '.join(sorted(unknown))}"})
return
+ if "files" not in apps:
+ apps.append("files")
if not self._has_admin_authority():
self._send({"type": "error", "detail": "No authorized key for this"})
return
@@ -2409,7 +2382,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 +2399,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 +2448,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.
@@ -3678,35 +3788,44 @@ 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 target root. If absent, pick the first writable
+ # one (backward compat with old clients that don't send it).
+ target_root_name = msg.get("root")
+ upload_root = None
+ if target_root_name:
+ from meshbay_common.paths import fold
+ target_folded = fold(target_root_name)
+ for r in roots:
+ if fold(r.name) == target_folded:
+ upload_root = r
+ break
+ 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 found for uploads",
+ "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",
"filename": filename})
return
@@ -3981,8 +4100,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 +4140,15 @@ class WebRTCPeerSession:
elif pending["op"] == OP_ROOT_REMOVE:
self._spawn(
self._admin_exec_root_remove(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/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index 6fdc78f..3d24000 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -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,13 +419,13 @@ def create_ui_app(state: dict) -> FastAPI:
"current_dir": progress.current_dir,
}
- # ── Upload toggle (operator only, localhost) ─────────────────────────
+ # ── Upload toggle (DEPRECATED — per-root writable replaces this) ────
@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)),
- ))
+ log.warning("PUT member-upload is deprecated — use PATCH roots/{name} "
+ "with writable instead")
+ return {"deprecated": True, "message": "Use per-root writable flag"}
# ── Enabled apps (operator only, localhost) ────────────────────────────
#