aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-06 17:48:36 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-06 17:48:36 +0200
commitea56b8c79538323875c00db2e7006b255f7cd494 (patch)
treeee08835bc190a75e49a6a8e78755111aef0e678f /packages/meshbay-hub/src/meshbay_hub
parente76e27868b30a2b00b1ba42dd8e7ee6071e0c0d7 (diff)
downloadmeshbay-ea56b8c79538323875c00db2e7006b255f7cd494.tar.gz
fix(groups): finish Phase 1 — MNP root management, upload targets, eject state
Review of the Phase 1 commit found the RO/RW model sound but three paths unfinished, each of which broke the flow the phase exists to deliver. Plus 29 test failures it introduced and no coverage for anything it added. Uploads went to the wrong directory. The node read a `root` field on file_upload that no client ever sent, so every upload landed in the first writable root while the Files toolbar offered its button based on the root being browsed — with two writable roots, uploading from one wrote into the other. Files now names the root it is showing; Chat names one chosen in the shell (an operator-configured directory arrives in Phase 2); the node refuses an unknown name rather than falling back, and refuses read-only and ejected roots by code. Shared directories were unreachable on the web. The table read its roots only from the loopback API, which resolves to "not available" in a browser, so the section rendered for nobody there — while the Uploads controls it replaced had worked — and the transport.updateRoot/ejectRoot/plugRoot methods beside it were dead. MNP is now the path, loopback the fallback for a local node with no live connection, and adding a root over MNP takes a typed path since no web page can browse a remote disk. Ejecting updated nobody's screen. transport.js resolves an admin ack against the pending request and returns, which is right for every op whose caller knows the value it chose; the root acks carry state only the node can compute, so the operator who clicked Eject was the one client that never saw it happen. And the ejected flag reached roster.db but was never read back, so a restart undid it and the next scan read an empty mount point as an erased library. Also: the member-upload endpoint answered 200 and did nothing (removed); the wizard ignored the first root's RW switch; reload compared roots on name and path, so editing writable in node.toml did nothing; the table had no path column, which is the only thing separating two libraries sharing a basename; apps_enabled normalisation differed between the two sides of a signed subject. Tests: eject/plug, per-root upload refusal and the node.toml rewrite had no coverage at all. test_member_upload_policy.py is replaced by test_root_writable_policy.py — it tested a removed feature — and every property worth keeping from it moved rather than being dropped. Docs: draft-v6 structural decision 9 is annotated as superseded (the operator can no longer have a directory only they may write to — a real capability removed, flagged rather than hidden), the man page documents the root verb and the RO/RW fields, and refactor-groups.md §7b records what the plan got wrong. Suite: 41 failures before, 13 after — all 13 pre-existing on main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/chat-app.js17
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js393
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/node-page.js16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/search-page.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css25
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js88
19 files changed, 521 insertions, 204 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
index 3bc110f..4714674 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
@@ -198,8 +198,13 @@ function ChatImage({ filename, entries, transportRef, gekRef }) {
return html`<img class="chat-att-thumb" src=${blobUrl} alt=${filename} />`;
}
+// `attachRoot` is the shared directory attachments are written to: the name of
+// the first writable, available root, decided in group-page.js so Files and Chat
+// read one answer. Empty means the group has no writable root right now — every
+// root is read-only, or the one drive that was writable is unplugged — and the
+// paperclip says so rather than producing a refusal from the node.
function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
- onPreview, mayUpload = true, onActivity, status }) {
+ onPreview, attachRoot = '', onActivity, status }) {
const [messages, setMessages] = useState([]);
const [hasMore, setHasMore] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
@@ -479,7 +484,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
try {
// Two people sending IMG_1234.jpg both succeed; the node picks a free name
// and the message has to point at the one it chose.
- const ack = await transport.uploadFile(file);
+ const ack = await transport.uploadFile(file, { root: attachRoot });
const storedAs = (ack && ack.stored_as) || file.name;
await new Promise(r => setTimeout(r, 2500));
if (onRefreshIndex) await onRefreshIndex();
@@ -501,7 +506,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
} finally {
setAttaching(false);
}
- }, [username, onRefreshIndex, jumpToBottom]);
+ }, [username, onRefreshIndex, jumpToBottom, attachRoot]);
const onKeyDown = useCallback((e) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -592,12 +597,16 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
</button>
`}
<div class="chat-input-row">
- ${mayUpload && html`
+ ${attachRoot ? html`
<label class="chat-attach" title="${t('chat.attach')}">
${attaching ? html`<span class="spinner"></span>`
: html`<${Icon} name="clip" />`}
<input type="file" style="display:none" onChange=${attachFile} disabled=${attaching} />
</label>
+ ` : html`
+ <span class="chat-attach chat-attach-off" title="${t('chat.attach_read_only')}">
+ <${Icon} name="clip" />
+ </span>
`}
<textarea class="chat-input" rows="1" ref=${inputRef}
placeholder="${t('chat.placeholder')}"
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
index a5676ba..c3b542f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
@@ -212,8 +212,16 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
// 2. Attach to node with first root
update('running');
+ // The first root is attached with the group, so its RW switch has to
+ // travel with it — writing it and then correcting it afterwards would
+ // leave a window where a group the operator marked read-only accepts
+ // uploads.
const mainRoot = roots[0];
- const attachBody = { name: name.trim(), shared_dir: mainRoot.path };
+ const attachBody = {
+ name: name.trim(),
+ shared_dir: mainRoot.path,
+ writable: mainRoot.writable !== false,
+ };
await platform.node.call('POST', '/api/groups/attach', attachBody);
await platform.node.call('POST', '/api/reload');
update('done');
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 56311ae..6825c8d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
@@ -26,7 +26,7 @@ import {
function FilesPanel({
groupId, transportRef, gekRef, status,
entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex,
- isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview,
+ isNodeAdmin, operatorPaired, userId, setError, onPreview,
showGroup, readOnly, getTransport, onRefreshIndex, showRefresh,
}) {
const [selected, setSelected] = useState(() => new Set());
@@ -75,6 +75,12 @@ function FilesPanel({
e.target.value = '';
const transport = transportRef.current;
if (!files.length || !transport || !transport.connected) return;
+ // The root being browsed is the destination. A group can have several
+ // writable roots, so leaving the node to pick one means a file uploaded
+ // from a folder the operator is looking at lands in a different one —
+ // which is only noticed much later, if at all.
+ const uploadRoot = currentPath ? currentPath.split('/')[0] : '';
+ if (!uploadRoot) return;
setError('');
for (const file of files) {
@@ -85,6 +91,7 @@ function FilesPanel({
// Bytes the node acknowledged, not bytes read locally.
onProgress: (sent) => onProgress(sent, file.size),
signal,
+ root: uploadRoot,
});
// The node re-indexes on a filesystem event, so there is nothing to
// wait on but the clock. Refreshing here means the file appears in
@@ -94,7 +101,7 @@ function FilesPanel({
},
});
}
- }, [applyIndex]);
+ }, [applyIndex, currentPath]);
const makeDirectory = useCallback(async () => {
const transport = transportRef.current;
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 1b2661c..dfde172 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -88,8 +88,10 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
const [nodeRoots, setNodeRoots] = useState([]);
const [isNodeAdmin, setIsNodeAdmin] = useState(false);
- // DEPRECATED: memberUpload is now derived from per-root writable flags.
- // Kept as state only for backward compat with nodes that still send it.
+ // Legacy: the group-wide upload switch a node speaking MNP 1.0 sends on its
+ // handshake ack. Per-root `writable` replaced it, and this is read only when
+ // the roots carry no flags at all — see `attachRoot` below. Defaults to true
+ // so such a node behaves as it always did.
const [memberUpload, setMemberUpload] = useState(true);
// Which applications this group has enabled, from the node. Falls back to
// every registered app when a node predates the setting (or hasn't answered
@@ -539,11 +541,26 @@ 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. 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;
+ // Where an attachment goes, answered once for the whole page.
+ //
+ // Files does not use this — it uploads into the root being browsed, which is
+ // the only unambiguous answer once a group can have several writable roots.
+ // Chat has no folder to browse, so it needs one picked for it, and this is
+ // the same rule the node applies when a client names no root at all. It
+ // becomes an operator-chosen directory in phase 2 (refactor-groups.md §1.7).
+ //
+ // `memberUpload` is the fallback for a node still speaking MNP 1.0, whose
+ // roots carry no `writable` at all: there, the single upload root is the one
+ // the node marked, and the ack's computed flag is all we get.
+ const writableRoots = useMemo(
+ () => nodeRoots.filter((r) => r.writable && r.available !== false),
+ [nodeRoots]);
+ const legacyNode = nodeRoots.length > 0
+ && nodeRoots.every((r) => r.writable === undefined);
+ const attachRoot = writableRoots.length ? writableRoots[0].name
+ : (legacyNode && memberUpload
+ ? (nodeRoots.find((r) => r.upload) || nodeRoots[0]).name
+ : '');
// A single dispatcher so any app can open the right modal without owning
// video/preview state itself — Files' table and Chat's attachments both
@@ -587,7 +604,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
groupId, transportRef, gekRef, status, username,
entries, availableEntries, nodeDirs, nodeRoots,
setEntries, setNodeDirs, setNodeRoots, applyIndex,
- isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview,
+ isNodeAdmin, operatorPaired, attachRoot, userId, setError, onPreview,
onRefreshIndex: refreshIndex, onActivity: touchActivity,
videoRoot, onVideoRoot: (path) => setVideoRoot(path),
audioRoot, onAudioRoot: (path) => setAudioRoot(path),
@@ -717,6 +734,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
transportRef=${transportRef} gekRef=${gekRef}
isNodeAdmin=${isNodeAdmin} userId=${userId}
operatorPaired=${operatorPaired} connected=${status === 'connected'}
+ mnpRoots=${nodeRoots}
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 de6f8c0..eeed786 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -177,42 +177,104 @@ function PhotoRootsRow({ folders, value, busy, msg, onSave }) {
// ── Shared Directories Table ────────────────────────────────────────────
/**
- * Reusable table of a group's root directories with per-root controls.
+ * A group's root directories, and the operator's controls over them.
*
- * 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.
+ * One component, two modes, because the Create Group wizard and the Settings
+ * page were drifting apart while showing the same thing:
+ *
+ * mode="live" — a hosted group. Every change is a signed operator op sent
+ * over MNP, or the loopback API when the node is on this
+ * machine and there is no live connection.
+ * mode="local" — the wizard, before the group exists. Changes are held in
+ * an array the caller owns; nothing is persisted until the
+ * group is attached.
+ *
+ * **Both paths matter and neither is optional.** The operator of a node is not
+ * necessarily sitting at it: they may be signing in from any browser, and the
+ * only thing that reaches their node from there is MNP. An earlier version of
+ * this read its roots exclusively from the loopback API, which resolves to
+ * "not available" in a browser — so the section rendered for nobody on the
+ * web, while the controls it replaced had worked there. `mnpRoots` is the
+ * source whenever a connection exists; the loopback list is the fallback for
+ * a local node that is not currently connected (a group still scanning, say).
*
* Props:
- * roots — 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)
+ * roots — the node's current roots: { name, path, writable,
+ * removable, ejected, available, kind }
+ * groupId — the group id
+ * transport — MeshBayTransport instance, or null when not connected
+ * signFn — signing function for admin ops
+ * nodeDetected — whether the loopback node API answers
+ * readOnly — suppress every edit control
+ * onRootsChange — called after a change, to re-read the loopback list
+ * onRefreshIndex — full index refresh, needed after an add or a remove
+ * mode — "live" (default) or "local"
+ * localRoots / onLocalRootsChange — the array, in "local" mode
*/
function SharedDirectoriesTable({ roots, groupId, transport, signFn,
- nodeDetected: nodeAvail, readOnly,
- onRootsChange, onRefreshIndex,
- mode = 'live',
- localRoots, onLocalRootsChange }) {
+ 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 serverRoots = isLocal ? (localRoots || []) : (roots || []);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState('');
const [indexProgress, setIndexProgress] = useState(null);
+ const [pathDraft, setPathDraft] = useState('');
+ const [addingByPath, setAddingByPath] = useState(false);
+
+ // A toggle has to move under the finger, and the answer only comes back
+ // when the node has signed, written node.toml and pushed the new table.
+ // The patch is therefore held until the incoming `roots` actually agrees
+ // with it — clearing it when the request resolves (which is what this did)
+ // drops it in the frame *before* the new table arrives, so the switch
+ // visibly snaps back and then forward again.
+ const [optimistic, setOptimistic] = useState({});
+ useEffect(() => {
+ setOptimistic((prev) => {
+ const keys = Object.keys(prev);
+ if (!keys.length) return prev;
+ const next = {};
+ let changed = false;
+ for (const name of keys) {
+ const server = serverRoots.find(r => r.name === name);
+ const patch = prev[name];
+ // Gone from the table, or the server now says what we asked for:
+ // either way this patch has nothing left to hide.
+ const settled = !server
+ || Object.keys(patch).every(k => server[k] === patch[k]);
+ if (settled) changed = true; else next[name] = patch;
+ }
+ return changed ? next : prev;
+ });
+ }, [serverRoots]);
+
+ const displayRoots = serverRoots.map(r =>
+ optimistic[r.name] ? { ...r, ...optimistic[r.name] } : r);
+
+ // Which door a change goes through. MNP first: it is the only one that
+ // exists for an operator on the web, and it is signed, which the loopback
+ // API is not (it is authorized by being on localhost with the run token).
+ const overMnp = !isLocal && transport && transport.connected;
+ const overLoopback = !isLocal && !overMnp && nodeAvail;
+ const canEdit = !readOnly && (isLocal || overMnp || overLoopback);
+
+ const rootUrl = (name, suffix = '') =>
+ '/api/groups/' + groupId + '/roots/' + encodeURIComponent(name) + suffix;
+
+ const run = useCallback(async (work, { refreshIndex = false } = {}) => {
+ setBusy(true); setMsg('');
+ try {
+ await work();
+ if (onRootsChange) await onRootsChange();
+ if (refreshIndex && onRefreshIndex) await onRefreshIndex();
+ return true;
+ } catch (err) {
+ setMsg(platform.bridgeMessage(err));
+ return false;
+ } finally { setBusy(false); }
+ }, [onRootsChange, onRefreshIndex]);
const doUpdateRoot = useCallback(async (rootName, updates) => {
if (isLocal) {
@@ -222,53 +284,35 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,
}
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);
+ setOptimistic(prev => ({
+ ...prev, [rootName]: { ...(prev[rootName] || {}), ...updates },
+ }));
+ const ok = await run(async () => {
+ if (overMnp) await transport.updateRoot(groupId, rootName, updates, signFn);
+ else if (overLoopback) await platform.node.call('PATCH', rootUrl(rootName), updates);
+ else throw new Error(t('node.root_no_route'));
+ });
+ // Only a failure clears the patch here; a success waits for the node's
+ // own table, so the switch never travels backwards on its way forwards.
+ if (!ok) {
+ setOptimistic(prev => {
+ const next = { ...prev }; delete next[rootName]; return next;
+ });
}
- }, [isLocal, localRoots, onLocalRootsChange, transport, groupId, signFn, nodeAvail, onRootsChange]);
+ }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback,
+ transport, groupId, signFn, run]);
- 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 doEjectRoot = useCallback((rootName) => run(async () => {
+ if (overMnp) await transport.ejectRoot(groupId, rootName, signFn);
+ else if (overLoopback) await platform.node.call('PUT', rootUrl(rootName, '/eject'));
+ else throw new Error(t('node.root_no_route'));
+ }), [overMnp, overLoopback, transport, groupId, signFn, run]);
- const doPlugRoot = useCallback(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 doPlugRoot = useCallback((rootName) => run(async () => {
+ if (overMnp) await transport.plugRoot(groupId, rootName, signFn);
+ else if (overLoopback) await platform.node.call('PUT', rootUrl(rootName, '/plug'));
+ else throw new Error(t('node.root_no_route'));
+ }), [overMnp, overLoopback, transport, groupId, signFn, run]);
const doRemoveRoot = useCallback(async (rootName) => {
if (isLocal) {
@@ -278,59 +322,99 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,
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));
+ const ok = await run(async () => {
+ if (overMnp) await transport.removeRoot(groupId, rootName, signFn);
+ else if (overLoopback) {
+ await platform.node.call('DELETE', rootUrl(rootName));
await platform.node.call('POST', '/api/reload');
- }
- 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]);
+ } else throw new Error(t('node.root_no_route'));
+ }, { refreshIndex: true });
+ if (ok) setMsg(t('node.root_removed'));
+ }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback,
+ transport, groupId, signFn, run]);
- const doAddRoot = useCallback(async () => {
- const chosen = await platform.rootPicker.choose();
- if (!chosen) return;
+ // Adding a root needs a directory that exists on the *node's* filesystem.
+ // With the node on this machine that is a native folder picker; from any
+ // other browser the operator has to type the path, because nothing in a web
+ // page can browse a remote disk. Both end at the same signed op.
+ const addRootAtPath = useCallback(async (path, name) => {
if (isLocal) {
- if ((localRoots || []).some(r => r.path === chosen.path)) return;
+ if ((localRoots || []).some(r => r.path === path)) return true;
const isFirst = (localRoots || []).length === 0;
- const newRoot = {
- name: chosen.name, path: chosen.path,
- writable: isFirst, removable: false,
- };
- if (onLocalRootsChange) onLocalRootsChange([...(localRoots || []), newRoot]);
- return;
+ // The first directory is writable so a new group can receive an upload
+ // without the operator having to find this switch first. Every later
+ // one is read-only until they say otherwise.
+ if (onLocalRootsChange) {
+ onLocalRootsChange([...(localRoots || []),
+ { name, path, writable: isFirst, removable: false }]);
+ }
+ return true;
}
- setBusy(true); setMsg(''); setIndexProgress(null);
- try {
- if (nodeAvail) {
- await platform.node.call('POST',
- '/api/groups/' + groupId + '/roots',
- { path: chosen.path, name: chosen.name });
+ setIndexProgress(null);
+ return run(async () => {
+ if (overMnp) {
+ await transport.addRoot(groupId, path, { name }, signFn);
+ } else if (overLoopback) {
+ await platform.node.call('POST', '/api/groups/' + groupId + '/roots',
+ { path, name });
await platform.node.call('POST', '/api/reload');
await platform.watchIndexProgress(groupId, setIndexProgress);
- }
- 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]);
+ } else throw new Error(t('node.root_no_route'));
+ }, { refreshIndex: true });
+ }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback,
+ transport, groupId, signFn, run]);
+
+ const doPickRoot = useCallback(async () => {
+ const chosen = await platform.rootPicker.choose();
+ if (!chosen) return;
+ const ok = await addRootAtPath(chosen.path, chosen.name);
+ if (ok && !isLocal) setMsg(t('node.root_added'));
+ }, [addRootAtPath, isLocal]);
+
+ const doAddByPath = useCallback(async () => {
+ const path = pathDraft.trim();
+ if (!path) return;
+ // The name is the node's business — it derives the basename and refuses a
+ // duplicate. Sending one guessed from a string typed here would be a
+ // second opinion about something already decided in one place.
+ const ok = await addRootAtPath(path, '');
+ if (ok) { setPathDraft(''); setAddingByPath(false); if (!isLocal) setMsg(t('node.root_added')); }
+ }, [pathDraft, addRootAtPath, isLocal]);
- if (!displayRoots || displayRoots.length === 0) {
+ const addControls = !canEdit ? '' : html`
+ ${platform.rootPicker.available ? html`
+ <button class="btn btn-small btn-secondary" style="margin-top:8px"
+ disabled=${busy} onClick=${doPickRoot}>
+ <${Icon} name="folder-plus" /> ${t('node.add_root')}
+ </button>
+ ` : addingByPath ? html`
+ <div class="sdt-add-row">
+ <input class="sdt-add-input" type="text" value=${pathDraft}
+ placeholder=${t('node.root_path_placeholder')}
+ disabled=${busy}
+ onInput=${(e) => setPathDraft(e.target.value)}
+ onKeyDown=${(e) => { if (e.key === 'Enter') doAddByPath(); }} />
+ <button class="btn btn-small btn-secondary" disabled=${busy || !pathDraft.trim()}
+ onClick=${doAddByPath}>${t('node.add_root')}</button>
+ <button class="btn btn-small" disabled=${busy}
+ onClick=${() => { setAddingByPath(false); setPathDraft(''); }}>
+ ${t('settings.cancel')}</button>
+ </div>
+ <p class="settings-hint">${t('node.root_path_hint')}</p>
+ ` : html`
+ <button class="btn btn-small btn-secondary" style="margin-top:8px"
+ disabled=${busy} onClick=${() => setAddingByPath(true)}>
+ <${Icon} name="folder-plus" /> ${t('node.add_root')}
+ </button>
+ `}
+ `;
+
+ if (!displayRoots.length) {
return html`
<div class="shared-directories-table">
+ ${msg && html`<p class="settings-hint">${msg}</p>`}
<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>
+ ${addControls}
</div>
`;
}
@@ -342,15 +426,16 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,
<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-path">${t('node.root_path')}</th>
+ ${canEdit && html`<th class="sdt-col-toggle">${t('node.root_rw')}</th>`}
+ ${canEdit && !isLocal && html`<th class="sdt-col-toggle">${t('node.removable')}</th>`}
<th class="sdt-col-actions"></th>
</tr>
</thead>
<tbody>
${displayRoots.map(r => {
const rowClass = r.ejected ? 'sdt-row-ejected'
- : (!isLocal && !r.available) ? 'sdt-row-unavail' : '';
+ : (!isLocal && r.available === false) ? 'sdt-row-unavail' : '';
return html`
<tr class=${rowClass} key=${r.name}>
<td class="sdt-col-dir">
@@ -360,30 +445,38 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,
</span>
${r.ejected && html`
<span class="node-root-badge node-root-badge-warn">${t('group.root_ejected')}</span>`}
- ${!isLocal && !r.available && !r.ejected && html`
+ ${!isLocal && r.available === false && !r.ejected && html`
<span class="node-root-badge node-root-badge-warn">${t('node.unavailable')}</span>`}
</td>
- ${!readOnly && html`
+ ${/* Two roots can never share a name, so the name is the identity —
+ but it is the *basename*, and two libraries under different
+ parents look identical without this. */''}
+ <td class="sdt-col-path" title=${r.path || ''}>${r.path || ''}</td>
+ ${canEdit && html`
<td class="sdt-col-toggle">
<${ToggleSwitch} checked=${!!r.writable} disabled=${busy || !!r.ejected}
onChange=${(v) => doUpdateRoot(r.name, { writable: v })} />
</td>
`}
- ${!readOnly && !isLocal && html`
+ ${canEdit && !isLocal && html`
<td class="sdt-col-toggle">
<${ToggleSwitch} checked=${!!r.removable} disabled=${busy}
onChange=${(v) => doUpdateRoot(r.name, { removable: v })} />
</td>
`}
<td class="sdt-col-actions">
- ${!readOnly && !isLocal && html`
+ ${canEdit && !isLocal && html`
<button class="sdt-action-btn" disabled=${busy || !r.removable}
title=${r.ejected ? t('group.root_plug') : t('group.root_eject')}
onClick=${() => r.ejected ? doPlugRoot(r.name) : doEjectRoot(r.name)}>
${r.ejected ? '\u{1F50C}' : '\u{23CF}'}
</button>
- <button class="sdt-action-btn sdt-action-danger" disabled=${busy}
- title=${t('node.remove_root')}
+ `}
+ ${canEdit && html`
+ <button class="sdt-action-btn sdt-action-danger"
+ disabled=${busy || displayRoots.length < 2}
+ title=${displayRoots.length < 2
+ ? t('node.root_remove_last') : t('node.remove_root')}
onClick=${() => doRemoveRoot(r.name)}>
\u{2715}
</button>
@@ -393,13 +486,7 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,
`; })}
</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>
- `}
+ ${addControls}
${indexProgress && indexProgress.scanning && html`
<div class="index-progress" style="margin-top:8px">
<div class="index-progress-bar">
@@ -435,6 +522,7 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,
*/
function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
isNodeAdmin, userId, operatorPaired, connected,
+ mnpRoots,
enabledApps, onEnabledApps,
scanSettings, onScanSettings,
tmdbConfig, onTmdbConfig, onTmdbEnabled,
@@ -461,6 +549,29 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
// platform.watchIndexProgress.
const [nodeIndexProgress, setNodeIndexProgress] = useState(null);
+ // The roots to show, from whichever source can actually answer.
+ //
+ // `mnpRoots` comes from the index payload the node pushes over the live
+ // connection, and is the only source an operator signing in from an
+ // ordinary browser has. `nodeRoots` comes from the loopback API and exists
+ // only on the machine running the node. Preferring MNP when connected also
+ // keeps this table on the same data Files and the apps read, so an eject
+ // shows in one place at the same instant it shows in the other.
+ const effectiveRoots = (connected && mnpRoots && mnpRoots.length)
+ ? mnpRoots : nodeRoots;
+
+ // Declared here rather than inline at the call site: a function rebuilt on
+ // every render is a new prop identity every render, and the callbacks that
+ // close over it in the table below are memoised on it.
+ const adminSignFn = useCallback((transcript) => {
+ const sk = transportRef.current && transportRef.current.sessionKeys
+ && transportRef.current.sessionKeys.skEdB64;
+ if (!sk || !window.MeshBayKeys) {
+ throw new Error(t('node.root_no_signing_key'));
+ }
+ return window.MeshBayKeys.signBytes(sk, transcript);
+ }, [transportRef]);
+
const loadNodeInfo = useCallback(async () => {
if (!platform.node.available) return;
try {
@@ -1102,24 +1213,22 @@ 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`
+ ${/* Shared directories — the group's root folders, and the structural
+ control everything else in this page sits on top of, so it comes
+ first. Rendered whenever the operator has a route to their node:
+ a live MNP connection (any browser, anywhere) or the loopback API
+ (the node on this machine). It used to require the second, which
+ meant it rendered for nobody on the web. */
+ isNodeAdmin && (connected || nodeDetected) && html`
<${CollapsibleSection} titleKey="settings_node.shared_directories_title">
<p class="settings-hint">${t('settings_node.shared_directories_hint')}</p>
+ ${!connected && nodeDetected && html`
+ <p class="settings-hint">${t('settings_node.roots_offline_hint')}</p>`}
<${SharedDirectoriesTable}
- roots=${nodeRoots}
+ roots=${effectiveRoots}
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;
- })()}
+ signFn=${adminSignFn}
nodeDetected=${nodeDetected}
onRootsChange=${loadNodeInfo}
onRefreshIndex=${onRefreshIndex} />
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 f929873..ea14814 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -169,6 +169,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(nicht verfügbar — Laufwerk getrennt)',
+ 'group.root_plug': 'Einstecken',
+ 'group.root_eject': 'Auswerfen',
+ 'group.root_writable': 'Lesen/Schreiben',
+ 'group.root_ejected': '(ausgeworfen)',
'group.view': 'Ansehen',
'group.delete': 'Löschen',
'group.delete_confirm': '{name} löschen?',
@@ -195,6 +199,7 @@ export default {
'chat.placeholder': 'Nachricht schreiben …',
'chat.send': 'Senden',
'chat.attach': 'Datei anhängen',
+ 'chat.attach_read_only': 'Kein beschreibbares freigegebenes Verzeichnis — Anhänge sind aus',
// Video player
'video.loading': '{name} wird geladen …',
@@ -722,10 +727,16 @@ export default {
'node.roots': 'Verzeichnisse',
'node.add_root': 'Verzeichnis hinzufügen',
'node.remove_root': 'Entfernen',
+ 'node.root_no_signing_key': 'Kein Signaturschlüssel verfügbar — koppeln Sie dieses Gerät zuerst mit dem Node',
+ 'node.root_no_route': 'Keine Verbindung zum Node — verbinden Sie sich damit oder verwenden Sie die App auf dem Rechner, der ihn hostet',
+ 'node.root_remove_last': 'Eine Gruppe braucht mindestens ein Verzeichnis',
+ 'node.root_path_hint': 'Der Pfad, wie der Node ihn sieht, auf dem Rechner, der diese Gruppe hostet.',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'Pfad',
+ 'node.directory': 'Verzeichnis',
'node.root_added': 'Verzeichnis hinzugefügt.',
'node.root_remove_confirm': '„{name}" aus dieser Gruppe entfernen?',
'node.root_removed': 'Verzeichnis entfernt. Neustart empfohlen, um den Index zu aktualisieren.',
- 'node.upload_root': 'Uploads',
'node.attach_group': 'Gruppe hinzufügen',
'node.attach_pick': 'Zu hostende Gruppe',
'node.attach_dir': 'Freigegebenes Verzeichnis',
@@ -807,6 +818,7 @@ export default {
'settings_node.photo_roots_save': 'Speichern',
'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.roots_offline_hint': 'Nicht mit dem Node verbunden — Änderungen laufen über den lokalen Node und greifen beim nächsten Neuladen.',
'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.',
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 f6c47fe..cbb790f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -198,6 +198,7 @@ export default {
'chat.placeholder': 'Type a message...',
'chat.send': 'Send',
'chat.attach': 'Attach file',
+ 'chat.attach_read_only': 'No writable shared directory — attachments are off',
// Video player
'video.loading': 'Loading {name}...',
@@ -605,6 +606,7 @@ export default {
'settings_node.photo_roots_save': 'Save',
'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.roots_offline_hint': 'Not connected to the node — changes go through the local node instead, and take effect on its next reload.',
'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).',
@@ -759,7 +761,6 @@ export default {
'node.peers': { one: '1 peer', other: '{n} peers' },
'node.no_gek': 'No group key',
'node.roots': 'Directories',
- 'node.upload_root': 'uploads',
'node.directory': 'Directory',
'node.root_rw': 'Writable',
'node.root_ro': 'read-only',
@@ -767,6 +768,12 @@ export default {
'node.unavailable': 'unavailable',
'node.add_root': 'Add directory',
'node.remove_root': 'Remove',
+ 'node.root_no_signing_key': 'No signing key available — pair this device with the node first',
+ 'node.root_no_route': 'No route to the node — connect to it, or use the app on the machine hosting it',
+ 'node.root_remove_last': 'A group needs at least one directory',
+ 'node.root_path_hint': 'The path as the node sees it, on the machine hosting this group.',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'Path',
'node.root_added': 'Directory added.',
'node.root_removed': 'Directory removed. Restart recommended to update the index.',
'node.root_remove_confirm': 'Remove "{name}" from this group?',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index a8c1296..3921532 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -167,6 +167,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(no disponible — la unidad está desconectada)',
+ 'group.root_plug': 'Conectar',
+ 'group.root_eject': 'Expulsar',
+ 'group.root_writable': 'Lectura/Escritura',
+ 'group.root_ejected': '(expulsado)',
'group.view': 'Ver',
'group.delete': 'Eliminar',
'group.delete_confirm': '¿Eliminar {name}?',
@@ -193,6 +197,7 @@ export default {
'chat.placeholder': 'Escriba un mensaje...',
'chat.send': 'Enviar',
'chat.attach': 'Adjuntar archivo',
+ 'chat.attach_read_only': 'Ningún directorio compartido con escritura: los adjuntos están desactivados',
// Video player
'video.loading': 'Cargando {name}...',
@@ -718,10 +723,16 @@ export default {
'node.roots': 'Directorios',
'node.add_root': 'Añadir directorio',
'node.remove_root': 'Eliminar',
+ 'node.root_no_signing_key': 'No hay clave de firma disponible: empareja primero este dispositivo con el nodo',
+ 'node.root_no_route': 'No hay ruta al nodo: conéctate a él o usa la aplicación en la máquina que lo aloja',
+ 'node.root_remove_last': 'Un grupo necesita al menos un directorio',
+ 'node.root_path_hint': 'La ruta tal como la ve el nodo, en la máquina que aloja este grupo.',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'Ruta',
+ 'node.directory': 'Directorio',
'node.root_added': 'Directorio añadido.',
'node.root_remove_confirm': '¿Eliminar «{name}» de este grupo?',
'node.root_removed': 'Directorio eliminado. Se recomienda reiniciar para actualizar el índice.',
- 'node.upload_root': 'subidas',
'node.attach_group': 'Añadir grupo',
'node.attach_pick': 'Grupo a alojar',
'node.attach_dir': 'Directorio compartido',
@@ -803,6 +814,7 @@ export default {
'settings_node.photo_roots_save': 'Guardar',
'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.roots_offline_hint': 'Sin conexión con el nodo: los cambios pasan por el nodo local y se aplican en su próxima recarga.',
'settings_node.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.',
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 6d36e41..4e60eb9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -198,6 +198,7 @@ export default {
'chat.placeholder': 'Écrivez un message...',
'chat.send': 'Envoyer',
'chat.attach': 'Joindre un fichier',
+ 'chat.attach_read_only': 'Aucun répertoire partagé en écriture — pièces jointes désactivées',
// Video player
'video.loading': 'Chargement de {name}...',
@@ -731,10 +732,15 @@ export default {
'node.roots': 'Répertoires',
'node.add_root': 'Ajouter un répertoire',
'node.remove_root': 'Retirer',
+ 'node.root_no_signing_key': 'Aucune clé de signature disponible — appairez d\'abord cet appareil avec le nœud',
+ 'node.root_no_route': 'Aucune route vers le nœud — connectez-vous à lui, ou utilisez l\'application sur la machine qui l\'héberge',
+ 'node.root_remove_last': 'Un groupe a besoin d\'au moins un répertoire',
+ 'node.root_path_hint': 'Le chemin tel que le nœud le voit, sur la machine qui héberge ce groupe.',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'Chemin',
'node.root_added': 'Répertoire ajouté.',
'node.root_remove_confirm': 'Retirer « {name} » de ce groupe ?',
'node.root_removed': 'Répertoire retiré. Un redémarrage est recommandé pour mettre à jour l\'index.',
- 'node.upload_root': 'uploads',
'node.directory': 'Répertoire',
'node.root_rw': 'Écriture',
'node.root_ro': 'lecture seule',
@@ -826,6 +832,7 @@ export default {
'settings_node.photo_roots_save': 'Enregistrer',
'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.roots_offline_hint': 'Non connecté au nœud — les changements passent par le nœud local et prennent effet à son prochain rechargement.',
'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.',
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 508d69c..38fc241 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -168,6 +168,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(non disponibile — l’unità è scollegata)',
+ 'group.root_plug': 'Ricollega',
+ 'group.root_eject': 'Espelli',
+ 'group.root_writable': 'Lettura/Scrittura',
+ 'group.root_ejected': '(espulso)',
'group.view': 'Visualizza',
'group.delete': 'Elimina',
'group.delete_confirm': 'Eliminare {name}?',
@@ -194,6 +198,7 @@ export default {
'chat.placeholder': 'Scriva un messaggio...',
'chat.send': 'Invia',
'chat.attach': 'Allega un file',
+ 'chat.attach_read_only': 'Nessuna directory condivisa scrivibile: gli allegati sono disattivati',
// Video player
'video.loading': 'Caricamento di {name}...',
@@ -726,10 +731,16 @@ export default {
'node.roots': 'Directory',
'node.add_root': 'Aggiungi directory',
'node.remove_root': 'Rimuovi',
+ 'node.root_no_signing_key': 'Nessuna chiave di firma disponibile: associa prima questo dispositivo al nodo',
+ 'node.root_no_route': 'Nessuna via verso il nodo: connettiti a esso oppure usa l\'applicazione sulla macchina che lo ospita',
+ 'node.root_remove_last': 'Un gruppo ha bisogno di almeno una directory',
+ 'node.root_path_hint': 'Il percorso come lo vede il nodo, sulla macchina che ospita questo gruppo.',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'Percorso',
+ 'node.directory': 'Directory',
'node.root_added': 'Directory aggiunta.',
'node.root_remove_confirm': 'Rimuovere «{name}» da questo gruppo?',
'node.root_removed': "Directory rimossa. Si consiglia un riavvio per aggiornare l'indice.",
- 'node.upload_root': 'caricamenti',
'node.attach_group': 'Aggiungi gruppo',
'node.attach_pick': 'Gruppo da ospitare',
'node.attach_dir': 'Directory condivisa',
@@ -817,6 +828,7 @@ export default {
'settings_node.photo_roots_save': 'Salva',
'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.roots_offline_hint': 'Non connesso al nodo: le modifiche passano dal nodo locale e hanno effetto al successivo ricaricamento.',
'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.',
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 cfd65da..7890ab7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -166,6 +166,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(利用不可 — ドライブが切断されています)',
+ 'group.root_plug': '接続する',
+ 'group.root_eject': '取り外す',
+ 'group.root_writable': '読み書き可',
+ 'group.root_ejected': '(取り外し済み)',
'group.view': '表示',
'group.delete': '削除',
'group.delete_confirm': '{name} を削除しますか?',
@@ -191,6 +195,7 @@ export default {
'chat.placeholder': 'メッセージを入力…',
'chat.send': '送信',
'chat.attach': 'ファイルを添付',
+ 'chat.attach_read_only': '書き込み可能な共有ディレクトリがありません — 添付は無効です',
// Video player
'video.loading': '{name} を読み込んでいます…',
@@ -712,10 +717,16 @@ export default {
'node.roots': 'ディレクトリ',
'node.add_root': 'ディレクトリを追加',
'node.remove_root': '削除',
+ 'node.root_no_signing_key': '署名鍵がありません — 先にこの端末をノードとペアリングしてください',
+ 'node.root_no_route': 'ノードへの経路がありません — 接続するか、ノードを動かしているマシンでアプリを使ってください',
+ 'node.root_remove_last': 'グループには少なくとも 1 つのディレクトリが必要です',
+ 'node.root_path_hint': 'このグループをホストしているマシン上で、ノードから見たパスです。',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'パス',
+ 'node.directory': 'ディレクトリ',
'node.root_added': 'ディレクトリを追加しました。',
'node.root_remove_confirm': '「{name}」をこのグループから削除しますか?',
'node.root_removed': 'ディレクトリを削除しました。インデックスを更新するために再起動を推奨します。',
- 'node.upload_root': 'アップロード',
'node.attach_group': 'グループを追加',
'node.attach_pick': 'ホストするグループ',
'node.attach_dir': '共有ディレクトリ',
@@ -801,6 +812,7 @@ export default {
'settings_node.photo_roots_save': '保存',
'settings_node.shared_directories_title': '共有ディレクトリ',
'settings_node.shared_directories_hint': 'このグループと共有されているフォルダー。読み書きを切り替えてアップロードを許可し、外付けドライブにはリムーバブルを設定します。',
+ 'settings_node.roots_offline_hint': 'ノードに接続していません — 変更はローカルノード経由で行われ、次回の再読み込みで反映されます。',
'settings_node.directories_title': 'アプリのディレクトリ',
'settings_node.directories_hint': '共有フォルダと、動画・音楽・写真の各アプリがそれぞれの起点として使用するフォルダです。',
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 0569efb..cdd4720 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -169,6 +169,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(niet beschikbaar — de schijf is losgekoppeld)',
+ 'group.root_plug': 'Aansluiten',
+ 'group.root_eject': 'Uitwerpen',
+ 'group.root_writable': 'Lezen/schrijven',
+ 'group.root_ejected': '(uitgeworpen)',
'group.view': 'Bekijken',
'group.delete': 'Verwijderen',
'group.delete_confirm': '{name} verwijderen?',
@@ -195,6 +199,7 @@ export default {
'chat.placeholder': 'Typ een bericht...',
'chat.send': 'Versturen',
'chat.attach': 'Bestand bijvoegen',
+ 'chat.attach_read_only': 'Geen beschrijfbare gedeelde map — bijlagen staan uit',
// Video player
'video.loading': '{name} wordt geladen...',
@@ -728,10 +733,16 @@ export default {
'node.roots': 'Mappen',
'node.add_root': 'Map toevoegen',
'node.remove_root': 'Verwijderen',
+ 'node.root_no_signing_key': 'Geen ondertekeningssleutel beschikbaar — koppel dit apparaat eerst aan de node',
+ 'node.root_no_route': 'Geen route naar de node — maak verbinding, of gebruik de app op de machine die hem host',
+ 'node.root_remove_last': 'Een groep heeft minstens één map nodig',
+ 'node.root_path_hint': 'Het pad zoals de node het ziet, op de machine die deze groep host.',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'Pad',
+ 'node.directory': 'Map',
'node.root_added': 'Map toegevoegd.',
'node.root_remove_confirm': '„{name}" uit deze groep verwijderen?',
'node.root_removed': 'Map verwijderd. Herstart aanbevolen om de index bij te werken.',
- 'node.upload_root': 'uploads',
'node.attach_group': 'Groep toevoegen',
'node.attach_pick': 'Groep om te hosten',
'node.attach_dir': 'Gedeelde map',
@@ -819,6 +830,7 @@ export default {
'settings_node.photo_roots_save': 'Opslaan',
'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.roots_offline_hint': 'Niet verbonden met de node — wijzigingen gaan via de lokale node en worden bij de volgende herlaadbeurt actief.',
'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.',
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 92c853b..d3826f5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -172,6 +172,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(niedostępne — dysk jest odłączony)',
+ 'group.root_plug': 'Podłącz',
+ 'group.root_eject': 'Odłącz',
+ 'group.root_writable': 'Odczyt/zapis',
+ 'group.root_ejected': '(odłączony)',
'group.view': 'Podgląd',
'group.delete': 'Usuń',
'group.delete_confirm': 'Usunąć {name}?',
@@ -200,6 +204,7 @@ export default {
'chat.placeholder': 'Napisz wiadomość...',
'chat.send': 'Wyślij',
'chat.attach': 'Załącz plik',
+ 'chat.attach_read_only': 'Brak zapisywalnego katalogu współdzielonego — załączniki wyłączone',
// Video player
'video.loading': 'Wczytywanie {name}...',
@@ -750,10 +755,16 @@ export default {
'node.roots': 'Katalogi',
'node.add_root': 'Dodaj katalog',
'node.remove_root': 'Usuń',
+ 'node.root_no_signing_key': 'Brak klucza podpisu — najpierw sparuj to urządzenie z węzłem',
+ 'node.root_no_route': 'Brak połączenia z węzłem — połącz się z nim albo użyj aplikacji na komputerze, który go hostuje',
+ 'node.root_remove_last': 'Grupa wymaga co najmniej jednego katalogu',
+ 'node.root_path_hint': 'Ścieżka widziana przez węzeł, na komputerze hostującym tę grupę.',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'Ścieżka',
+ 'node.directory': 'Katalog',
'node.root_added': 'Katalog dodany.',
'node.root_remove_confirm': 'Usunąć „{name}" z tej grupy?',
'node.root_removed': 'Katalog usunięty. Zalecany restart w celu odświeżenia indeksu.',
- 'node.upload_root': 'przesyłanie',
'node.attach_group': 'Dodaj grupę',
'node.attach_pick': 'Grupa do hostowania',
'node.attach_dir': 'Katalog współdzielony',
@@ -845,6 +856,7 @@ export default {
'settings_node.photo_roots_save': 'Zapisz',
'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.roots_offline_hint': 'Brak połączenia z węzłem — zmiany przechodzą przez węzeł lokalny i zaczną działać po jego następnym przeładowaniu.',
'settings_node.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.',
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 cd5f7e8..6706c13 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
@@ -169,6 +169,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(indisponível — a unidade está desconectada)',
+ 'group.root_plug': 'Conectar',
+ 'group.root_eject': 'Ejetar',
+ 'group.root_writable': 'Leitura/Escrita',
+ 'group.root_ejected': '(ejetado)',
'group.view': 'Visualizar',
'group.delete': 'Excluir',
'group.delete_confirm': 'Excluir {name}?',
@@ -195,6 +199,7 @@ export default {
'chat.placeholder': 'Escreva uma mensagem...',
'chat.send': 'Enviar',
'chat.attach': 'Anexar arquivo',
+ 'chat.attach_read_only': 'Nenhum diretório compartilhado gravável — anexos desativados',
// Video player
'video.loading': 'Carregando {name}...',
@@ -719,10 +724,16 @@ export default {
'node.roots': 'Diretórios',
'node.add_root': 'Adicionar diretório',
'node.remove_root': 'Remover',
+ 'node.root_no_signing_key': 'Nenhuma chave de assinatura disponível — pareie este dispositivo com o nó primeiro',
+ 'node.root_no_route': 'Sem rota até o nó — conecte-se a ele ou use o aplicativo na máquina que o hospeda',
+ 'node.root_remove_last': 'Um grupo precisa de pelo menos um diretório',
+ 'node.root_path_hint': 'O caminho como o nó o vê, na máquina que hospeda este grupo.',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'Caminho',
+ 'node.directory': 'Diretório',
'node.root_added': 'Diretório adicionado.',
'node.root_remove_confirm': 'Remover "{name}" deste grupo?',
'node.root_removed': 'Diretório removido. Reinicialização recomendada para atualizar o índice.',
- 'node.upload_root': 'uploads',
'node.attach_group': 'Adicionar grupo',
'node.attach_pick': 'Grupo a hospedar',
'node.attach_dir': 'Diretório compartilhado',
@@ -804,6 +815,7 @@ export default {
'settings_node.photo_roots_save': 'Salvar',
'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.roots_offline_hint': 'Sem conexão com o nó — as alterações passam pelo nó local e entram em vigor no próximo recarregamento.',
'settings_node.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.',
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 b80ac08..f62d6fd 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
@@ -165,6 +165,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(不可用 — 驱动器已断开连接)',
+ 'group.root_plug': '重新接入',
+ 'group.root_eject': '弹出',
+ 'group.root_writable': '读写',
+ 'group.root_ejected': '(已弹出)',
'group.view': '查看',
'group.delete': '删除',
'group.delete_confirm': '删除 {name}?',
@@ -189,6 +193,7 @@ export default {
'chat.placeholder': '输入消息…',
'chat.send': '发送',
'chat.attach': '添加附件',
+ 'chat.attach_read_only': '没有可写的共享目录 — 附件已停用',
// Video player
'video.loading': '正在加载 {name}…',
@@ -699,10 +704,16 @@ export default {
'node.roots': '目录',
'node.add_root': '添加目录',
'node.remove_root': '移除',
+ 'node.root_no_signing_key': '没有可用的签名密钥 — 请先将本设备与节点配对',
+ 'node.root_no_route': '无法连接到节点 — 请先连接,或在运行该节点的机器上使用应用',
+ 'node.root_remove_last': '每个群组至少需要一个目录',
+ 'node.root_path_hint': '托管该群组的机器上,节点所看到的路径。',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': '路径',
+ 'node.directory': '目录',
'node.root_added': '目录已添加。',
'node.root_remove_confirm': '从此群组中移除"{name}"?',
'node.root_removed': '目录已移除。建议重启以更新索引。',
- 'node.upload_root': '上传目录',
'node.attach_group': '添加群组',
'node.attach_pick': '要托管的群组',
'node.attach_dir': '共享目录',
@@ -788,6 +799,7 @@ export default {
'settings_node.photo_roots_save': '保存',
'settings_node.shared_directories_title': '共享目录',
'settings_node.shared_directories_hint': '与此群组共享的文件夹。切换读写以允许上传,标记为可移除用于外置驱动器。',
+ 'settings_node.roots_offline_hint': '未连接到节点 — 变更将通过本地节点进行,并在其下次重新加载时生效。',
'settings_node.directories_title': '应用目录',
'settings_node.directories_hint': '共享文件夹,以及“视频”“音乐”和“照片”应用各自使用哪个(些)作为入口。',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
index ec73128..2e216a9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
@@ -750,13 +750,21 @@ export function NodePage({ groups }) {
<${Icon} name="folder" />
${r.name}
</span>
- ${r.upload && html`
- <span class="node-root-badge">${t('node.upload_root')}</span>`}
- ${!r.available && html`
+ ${r.writable && html`
+ <span class="node-root-badge">${t('node.root_rw')}</span>`}
+ ${r.removable && html`
+ <span class="node-root-badge">${t('node.removable')}</span>`}
+ ${r.ejected ? html`
+ <span class="node-root-badge node-root-badge-warn">
+ ${t('group.root_ejected')}</span>`
+ : !r.available && html`
<span class="node-root-badge node-root-badge-warn">
${t('node.unavailable')}</span>`}
</div>
- ${(g.roots || []).length > 1 && !r.upload && html`
+ ${/* Removing a writable root is allowed now — several can be
+ writable, and a group with none is a valid read-only
+ group. The last root is still the one that cannot go. */''}
+ ${(g.roots || []).length > 1 && html`
<button class="btn btn-small btn-danger"
disabled=${busy} onClick=${() => removeRoot(g.id, r.name)}>
${t('node.remove_root')}</button>`}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
index 608bb81..6e4e5b9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
@@ -707,7 +707,6 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
applyIndex=${noop}
isNodeAdmin=${false}
operatorPaired=${false}
- mayUpload=${false}
userId=${userId}
setError=${noop}
onPreview=${onPreview}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 51f9d70..eaf1298 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -2554,6 +2554,21 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
.shared-dirs-tbl td { padding: 7px 12px 7px 0; border: none; vertical-align: middle; }
.shared-dirs-tbl tbody tr + tr td { border-top: 1px solid var(--border); }
.sdt-col-dir { min-width: 140px; }
+/* The path is the only thing separating two libraries whose folders happen to
+ share a basename, so it is shown — truncated, because it is usually long and
+ rarely the thing being read. */
+.sdt-col-path {
+ color: var(--text-dim); font-size: 0.85em;
+ max-width: 260px; overflow: hidden; text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.sdt-add-row { display: flex; gap: 6px; align-items: center; margin-top: 8px; }
+.sdt-add-input {
+ flex: 1 1 auto; min-width: 0; padding: 5px 8px;
+ border: 1px solid var(--border); border-radius: 4px;
+ background: var(--bg-input, transparent); color: var(--text);
+ font-family: inherit; font-size: 0.9em;
+}
.sdt-col-toggle { width: 90px; text-align: center; }
.sdt-col-toggle th { text-align: center; }
.sdt-col-toggle .toggle-switch { justify-content: center; }
@@ -2577,6 +2592,16 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
line-height: 1;
}
.root-eject-btn:hover { background: var(--bg-hover); }
+.root-eject-btn { margin-left: 8px; }
+/* An ejected root in the Files table: still listed, deliberately — its files
+ are frozen, not gone — but not somewhere you can walk into. */
+.file-row.root-ejected { opacity: 0.5; }
+.file-row.root-ejected td:not(.sel-cell) { cursor: default; }
+
+/* The paperclip with no writable directory to write to. Shown rather than
+ hidden, so the reason is discoverable instead of the control just being
+ absent. */
+.chat-attach-off { opacity: 0.35; cursor: not-allowed; }
.node-root {
display: flex;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 482574f..8df7700 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -72,11 +72,18 @@ function _aborted() {
// change anything — `op` is already on every admin_challenge, and this
// list is what lets a response two steps later be tied back to the right
// one.
+// The acks whose payload is state no caller could have predicted: they carry
+// the node's whole roots table back. See the note where they are dispatched.
+const ROOT_ACK_TYPES = new Set([
+ 'root_update_ack', 'root_eject_ack', 'root_plug_ack',
+ 'root_add_ack', 'root_remove_ack',
+]);
+
const ADMIN_OP_TYPES = new Set([
'tmdb_override', 'tmdb_rematch', 'tmdb_config', 'tmdb_enabled', 'video_root', 'audio_root',
'photo_roots',
'musicbrainz_enabled', 'file_delete', 'dir_delete',
- 'member_upload', 'apps_enabled', 'set_scan_settings', 'member_revoke',
+ 'apps_enabled', 'set_scan_settings', 'member_revoke',
'root_add', 'root_remove', 'root_update', 'root_eject', 'root_plug',
'member_unpin', 'gek_rotate', 'group_attach',
'group_detach', 'invite_create',
@@ -1072,7 +1079,7 @@ class MeshBayTransport {
* queried in (e.g. "fr-FR") — one for the whole node, since both are one
* operator's shared credential/cache, not a per-group concern (see
* setTmdbEnabled below for the per-group on/off switch). Signed like
- * setAppsEnabled/setMemberUpload — an unsigned change would let any
+ * setAppsEnabled/updateRoot — an unsigned change would let any
* member alter outbound third-party network traffic the operator never
* agreed to (docs/mediacenter.md §5.5, §8). `token: ''` explicitly clears
* a previously-set custom token; omit it (undefined/null), like
@@ -1367,24 +1374,6 @@ class MeshBayTransport {
* on the hub is the other half, and neither implies the other.
*/
/**
- * Turn uploading by ordinary members on or off.
- *
- * Signed by the operator like any other privileged operation — the node
- * refuses an unsigned one, which is what stops a member turning it back on.
- */
- async setMemberUpload(allowed, signFn) {
- const msg = await this._sendAndWait({
- type: 'member_upload', v: '0.1', allowed: Boolean(allowed),
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(
- msg, 'member_upload', allowed ? 'on' : 'off', signFn);
- }
- return msg;
- }
-
- /**
* Turn a group "application" (Chat, Files, ...) on or off for everyone.
*
* Takes the whole set in one signed message rather than one op per app, so
@@ -1393,13 +1382,25 @@ class MeshBayTransport {
* `_authorizeAdminOp` below checks the two match.
*/
async setAppsEnabled(apps, signFn) {
+ // Files cannot be turned off — MNP permits root exploration regardless of
+ // this list, so hiding the tab only ever misled — and the node adds it if
+ // it is missing. That normalisation has to happen *here too*: the subject
+ // below is rebuilt from what this client sent, and compared byte for byte
+ // against what the node put in the challenge. A list arriving here without
+ // `files` would produce two different strings and `_authorizeAdminOp`
+ // would refuse to sign an op the operator did ask for. It is reachable
+ // only from a caller that builds the list from something other than the
+ // node's own answer, which is exactly the kind of caller a later phase
+ // adds. (`apps.js` marks it `alwaysEnabled`; this file is a classic
+ // script and cannot import it.)
+ const full = apps.includes('files') ? [...apps] : ['files', ...apps];
const msg = await this._sendAndWait({
- type: 'apps_enabled', v: '0.1', apps,
+ type: 'apps_enabled', v: '0.1', apps: full,
});
if (msg.type === 'error') throw new Error(msg.detail);
if (msg.type === 'admin_challenge') {
return this._authorizeAdminOp(
- msg, 'apps_enabled', [...apps].sort().join(','), signFn);
+ msg, 'apps_enabled', [...full].sort().join(','), signFn);
}
return msg;
}
@@ -1657,8 +1658,14 @@ class MeshBayTransport {
* The node decides where this lands (uploads/) and under what name — it finds a
* free one rather than replacing anything. The ack says which, and that is what
* this returns.
+ *
+ * `root` names which shared directory to upload into — a name, never a path;
+ * the node picks the destination inside it. Since a group can have several
+ * writable roots, leaving it out is a guess, and the node's fallback ("the
+ * first writable one") exists only for MNP 1.0 clients, which had exactly one
+ * destination. Every caller here browses a root and knows which one it is.
*/
- async uploadFile(file, { chunkSize, onProgress, signal } = {}) {
+ async uploadFile(file, { chunkSize, onProgress, signal, root } = {}) {
// The same file twice at once would confuse the node, which keys its own
// upload state by name — and would race for the same destination.
if (this._uploaders.has(file.name)) {
@@ -1709,6 +1716,7 @@ class MeshBayTransport {
chunk_index: i,
total_chunks: total,
data: buf,
+ ...(root ? { root } : {}),
});
}
while (acked < total) {
@@ -2206,7 +2214,21 @@ class MeshBayTransport {
} else if (typeof msg.type === 'string' && msg.type.endsWith('_ack')) {
const key = `admin:${msg.type.slice(0, -4)}`;
for (const [, handler] of this._pending) {
- if (handler._key === key) { handler.resolve(msg); return; }
+ if (handler._key === key) {
+ handler.resolve(msg);
+ // The comment above ("its own caller already updates local state
+ // from what it sent") is true of every op whose caller passes the
+ // value it just chose to an onX(next). The root ops are not like
+ // that: what changes is the whole roots table, which only the node
+ // can compute — availability, the eject that the plug refused, the
+ // name it settled on. Returning here left the operator who clicked
+ // Eject as the one client that never saw it happen, while every
+ // other peer got the broadcast. So this one type is handed on.
+ if (ROOT_ACK_TYPES.has(msg.type) && this._onRootsChanged) {
+ this._onRootsChanged(msg);
+ }
+ return;
+ }
}
}
@@ -2253,10 +2275,12 @@ class MeshBayTransport {
return;
}
- // The operator changed who may upload. Unsolicited: it arrives at everyone
- // connected, not only at whoever asked. It still has to reach a pending
- // caller — the operator's own request resolves on this reply — so it falls
- // through to the matching below rather than returning here.
+ // Legacy. An MNP 1.0 node still broadcasts this when its operator changes
+ // the group-wide upload switch, and its roots carry no `writable` for us
+ // to read instead — so this is the only answer available from such a node
+ // and it is still honoured. Nothing here *sends* the message any more:
+ // per-root RO/RW replaced it, and a current node answers it with a
+ // deprecation notice and no action.
if (msg.type === 'member_upload_ack' && this._onUploadPolicy) {
this._onUploadPolicy(Boolean(msg.allowed));
}
@@ -2322,10 +2346,10 @@ 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) {
+ // A root's flags changed, or one was ejected, plugged, added or removed.
+ // Broadcast by the node to every peer, so everyone's table updates without
+ // waiting for the next index_sync.
+ if (ROOT_ACK_TYPES.has(msg.type) && this._onRootsChanged) {
this._onRootsChanged(msg);
}