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 --- .../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 +- 31 files changed, 1277 insertions(+), 495 deletions(-) (limited to 'packages') 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`