diff options
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) ──────────────────────────── # |