From e76e27868b30a2b00b1ba42dd8e7ee6071e0c0d7 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 6 Sep 2026 16:05:39 +0200 Subject: feat: groups refactor Phase 1 — root RO/RW model + shared directories UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the upload boolean with per-root writable/removable/ejected flags. Backend: new ops (update_root, eject_root, plug_root), MNP 1.1 protocol messages, live RootSet updates so API always reflects current state, CLI root subcommand (add/remove/set/list/eject/plug). Frontend: SharedDirectoriesTable with optimistic toggle switches, eject/plug in Files and Settings, upload gated on root.writable, ejected-root filtering in all media apps, updated Create Group wizard, 10-locale i18n. Co-Authored-By: Claude Opus 4.6 --- docs/refactor-groups.md | 657 +++++++++++++++++++++ .../meshbay-common/src/meshbay_common/__init__.py | 2 +- .../meshbay-common/src/meshbay_common/adminop.py | 3 + .../meshbay-common/src/meshbay_common/protocol.py | 6 + .../meshbay-hub/src/meshbay_hub/static/apps.js | 4 +- .../src/meshbay_hub/static/create-group-page.js | 100 +--- .../src/meshbay_hub/static/files-app.js | 35 +- .../src/meshbay_hub/static/group-page.js | 34 +- .../src/meshbay_hub/static/group-settings.js | 449 ++++++++------ .../src/meshbay_hub/static/locales/de.js | 7 +- .../src/meshbay_hub/static/locales/en.js | 14 +- .../src/meshbay_hub/static/locales/es.js | 7 +- .../src/meshbay_hub/static/locales/fr.js | 14 +- .../src/meshbay_hub/static/locales/it.js | 7 +- .../src/meshbay_hub/static/locales/ja.js | 7 +- .../src/meshbay_hub/static/locales/nl.js | 7 +- .../src/meshbay_hub/static/locales/pl.js | 7 +- .../src/meshbay_hub/static/locales/pt-BR.js | 7 +- .../src/meshbay_hub/static/locales/zh-CN.js | 7 +- .../src/meshbay_hub/static/music-app.js | 5 +- .../src/meshbay_hub/static/photos-app.js | 5 +- .../meshbay-hub/src/meshbay_hub/static/style.css | 36 ++ .../src/meshbay_hub/static/transport.js | 62 +- .../src/meshbay_hub/static/video-app.js | 5 +- packages/meshbay-node/src/meshbay_node/config.py | 42 +- packages/meshbay-node/src/meshbay_node/daemon.py | 185 +++++- .../src/meshbay_node/indexer/indexer.py | 38 ++ packages/meshbay-node/src/meshbay_node/ops.py | 293 +++++++-- packages/meshbay-node/src/meshbay_node/roots.py | 62 +- packages/meshbay-node/src/meshbay_node/roster.py | 14 +- .../src/meshbay_node/transport/webrtc_server.py | 276 ++++++--- packages/meshbay-node/src/meshbay_node/ui/app.py | 32 +- 32 files changed, 1934 insertions(+), 495 deletions(-) create mode 100644 docs/refactor-groups.md 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-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 `` 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 --dir # first root, RW +meshbay-node group add --dir --read-only # first root, RO + +# Root management +meshbay-node root add [--name ] [--writable] [--removable] +meshbay-node root remove +meshbay-node root set --writable # toggle to RW +meshbay-node root set --read-only # toggle to RO +meshbay-node root set --removable # mark as removable +meshbay-node root set --no-removable # unmark +meshbay-node root eject # safe eject +meshbay-node root plug # re-plug +meshbay-node root list + +# 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 + --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 `; } if (step === 1) { - const canProceed = name.trim() && roots.length > 0 && enabledApps.length > 0; + const canProceed = name.trim() && roots.length > 0; return html`

${t('wizard.title')}

${error && html`
${error}
`} @@ -397,53 +366,12 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
`} -
-

${t('members.apps_title')}

-

- ${t('members.apps_hint')}

-
    - ${APPS.map(a => html` -
  • - -
  • - `)} -
- ${enabledApps.length === 0 && html` -

${t('members.apps_need_one')}

`} -
-

${t('wizard.directories')}

${t('wizard.directories_hint')}

- ${roots.map((r, i) => html` -
-
- <${Icon} name="folder" /> - ${r.name} - ${r.path} - ${i === uploadIdx && html` - ${t('wizard.upload_target')}`} -
-
- ${roots.length > 1 && i !== uploadIdx && html` - `} - -
-
- `)} - + <${SharedDirectoriesTable} mode="local" + localRoots=${roots} onLocalRootsChange=${handleLocalRootsChange} />
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`
- ${mayUpload && html` + ${currentPath && currentRootWritable && html`
`} + ${/* 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"> +

${t('settings_node.shared_directories_hint')}

+ <${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} /> + + `} + ${/* 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">

${t('members.apps_hint')}

    - ${APPS.map(a => html` + ${APPS.filter(a => !a.alwaysEnabled).map(a => html`