aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js53
-rw-r--r--packages/meshbay-hub/tests/test_app_settings_plugin.py7
-rw-r--r--packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py175
16 files changed, 288 insertions, 6 deletions
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 6825c8d..a6a3ca4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
@@ -221,7 +221,14 @@ function FilesPanel({
: 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;
+ // `upload` is the same answer under the name a node speaking MNP 1.0 uses;
+ // reading only `writable` there means the Upload button disappears on every
+ // node that has not been upgraded yet, which is most of them on the day the
+ // page ships.
+ const currentRootWritable = currentRoot
+ ? (currentRoot.writable !== undefined ? currentRoot.writable
+ : Boolean(currentRoot.upload))
+ : 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
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 e762fc8..513792b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -140,6 +140,10 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// destination rather than a set of folders it reads.
const [chatDirectory, setChatDirectory] = useState('');
const [chatLinkPreview, setChatLinkPreview] = useState(true);
+ // Whether this node speaks the operations MNP 1.1 added. False for one that
+ // predates them, and the Settings page then offers what that node can
+ // actually do rather than controls whose messages it drops unanswered.
+ const [nodeSupportsAppOps, setNodeSupportsAppOps] = useState(false);
// MusicBrainz on/off (per-group) — docs/musicbay.md §3.2.
const [musicbrainzConfig, setMusicbrainzConfig] = useState(null);
const onPlayQueue = useCallback((tracks, startIndex) => {
@@ -342,6 +346,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
|| (ack.audio_root ? [ack.audio_root] : []),
photo: ack.photo_directories || ack.photo_roots || [],
});
+ setNodeSupportsAppOps(transport.supportsAppOps);
setChatDirectory(ack.chat_directory || '');
setChatLinkPreview(ack.chat_link_preview !== false);
setMusicbrainzConfig({
@@ -790,6 +795,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
onScanSettings=${(s) => setScanSettings(s)}
entries=${entries} nodeDirs=${nodeDirs}
appSettings=${appSettings}
+ nodeSupportsAppOps=${nodeSupportsAppOps}
${/* The saving pane already knows what it asked for; this is so
the page's own copy moves at the same time, rather than
waiting for the ack it will not be handed (transport.js
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 5d0ab07..f8b4aa5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -361,7 +361,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
enabledApps, onEnabledApps,
scanSettings, onScanSettings,
entries, nodeDirs,
- appSettings, onAppDirectories, onRefreshIndex,
+ appSettings, nodeSupportsAppOps,
+ onAppDirectories, onRefreshIndex,
onPaired, onLeft }) {
const [members, setMembers] = useState([]);
const [adminId, setAdminId] = useState('');
@@ -647,7 +648,14 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
if (!transport || !transport.connected) {
throw new Error(t('node.root_no_route'));
}
- await transport.setAppDirectories(appKey, paths, adminSignFn);
+ // A node too old for the generic op still answers the three per-app
+ // messages that came before it, so an operator on one keeps the ability
+ // they had rather than being handed a control that times out.
+ if (transport.supportsAppOps) {
+ await transport.setAppDirectories(appKey, paths, adminSignFn);
+ } else {
+ await transport.setAppDirectoriesLegacy(appKey, paths, adminSignFn);
+ }
if (onAppDirectories) onAppDirectories(appKey, paths);
}, [transportRef, adminSignFn, onAppDirectories]);
@@ -845,11 +853,19 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
<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>`}
+ ${/* Read-only against a node that predates the root operations:
+ writable, removable, eject and plug have no older equivalent
+ to fall back to, and an unknown message type is dropped
+ unanswered — a thirty-second wait ending in a timeout, with
+ nothing on screen to say the node simply cannot do it. */''}
+ ${connected && !nodeSupportsAppOps && !nodeDetected && html`
+ <p class="settings-hint">${t('settings_node.roots_node_too_old')}</p>`}
<${SharedDirectoriesTable}
roots=${effectiveRoots}
groupId=${groupId}
transport=${transportRef.current}
signFn=${adminSignFn}
+ readOnly=${connected && !nodeSupportsAppOps && !nodeDetected}
nodeDetected=${nodeDetected}
onRootsChange=${loadNodeInfo}
onRefreshIndex=${onRefreshIndex} />
@@ -876,7 +892,9 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
disabled=${appsBusy}
onChange=${() => toggleApp(app.key)} />
`}>
- ${activeApps.includes(app.key)
+ ${!nodeSupportsAppOps && app.key === 'chat'
+ ? html`<p class="settings-hint">${t('settings_node.app_node_too_old')}</p>`
+ : activeApps.includes(app.key)
? html`<${app.Settings}
roots=${effectiveRoots} dirs=${folderOptions}
settings=${appSettings}
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 d5d1429..edaa70a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -846,6 +846,8 @@ export default {
'settings_app.tmdb_token_prompt': 'Registrieren Sie sich bei TMDB, um einen eigenen API-Schlüssel zu erzeugen.',
'settings_app.tmdb_token_link': 'Schlüssel holen',
'settings_node.roots_offline_hint': 'Nicht mit dem Node verbunden — Änderungen laufen über den lokalen Node und greifen beim nächsten Neuladen.',
+ 'settings_node.roots_node_too_old': 'Dieser Node ist älter als diese Seite: Er kann seine Verzeichnisse anzeigen, aber hier nicht ändern. Aktualisieren Sie ihn oder nutzen Sie die meshbay-node root-Befehle.',
+ 'settings_node.app_node_too_old': 'Dieser Node ist älter als diese Seite und hat für diese App noch keine Einstellung. Aktualisieren Sie ihn, um sie hier zu konfigurieren.',
'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 8c5cbe5..d1aca93 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -634,6 +634,8 @@ export default {
'settings_app.tmdb_token_prompt': 'Sign up on TMDB to generate your own API key.',
'settings_app.tmdb_token_link': 'Get a key',
'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.roots_node_too_old': 'This node is older than this page: it can show its directories but not change them here. Update it, or use the meshbay-node root commands.',
+ 'settings_node.app_node_too_old': 'This node is older than this page and has no setting for this app yet. Update it to configure this here.',
'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).',
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 4a7384f..880de2e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -842,6 +842,8 @@ export default {
'settings_app.tmdb_token_prompt': 'Regístrate en TMDB para generar tu propia clave de API.',
'settings_app.tmdb_token_link': 'Obtener una clave',
'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.roots_node_too_old': 'Este nodo es más antiguo que esta página: puede mostrar sus directorios pero no cambiarlos aquí. Actualízalo o usa los comandos meshbay-node root.',
+ 'settings_node.app_node_too_old': 'Este nodo es más antiguo que esta página y aún no tiene ajustes para esta aplicación. Actualízalo para configurarla aquí.',
'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 bee4ae9..eb977f3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -860,6 +860,8 @@ export default {
'settings_app.tmdb_token_prompt': 'Créez un compte TMDB pour générer votre propre clé d\'API.',
'settings_app.tmdb_token_link': 'Obtenir une clé',
'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.roots_node_too_old': 'Ce nœud est plus ancien que cette page : il peut afficher ses répertoires mais pas les modifier ici. Mettez-le à jour, ou utilisez les commandes meshbay-node root.',
+ 'settings_node.app_node_too_old': 'Ce nœud est plus ancien que cette page et n\'a pas encore de réglage pour cette application. Mettez-le à jour pour la configurer ici.',
'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 e39d91d..2f58015 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -856,6 +856,8 @@ export default {
'settings_app.tmdb_token_prompt': 'Registrati su TMDB per generare la tua chiave API.',
'settings_app.tmdb_token_link': 'Ottieni una chiave',
'settings_node.roots_offline_hint': 'Non connesso al nodo: le modifiche passano dal nodo locale e hanno effetto al successivo ricaricamento.',
+ 'settings_node.roots_node_too_old': 'Questo nodo è più vecchio di questa pagina: può mostrare le sue directory ma non modificarle qui. Aggiornalo, oppure usa i comandi meshbay-node root.',
+ 'settings_node.app_node_too_old': 'Questo nodo è più vecchio di questa pagina e non ha ancora impostazioni per questa applicazione. Aggiornalo per configurarla qui.',
'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 75a3aa2..f22ce21 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -840,6 +840,8 @@ export default {
'settings_app.tmdb_token_prompt': 'TMDB に登録して、自分の API キーを発行してください。',
'settings_app.tmdb_token_link': 'キーを取得',
'settings_node.roots_offline_hint': 'ノードに接続していません — 変更はローカルノード経由で行われ、次回の再読み込みで反映されます。',
+ 'settings_node.roots_node_too_old': 'このノードはこのページより古く、ディレクトリの表示はできますがここでの変更はできません。更新するか、meshbay-node root コマンドを使ってください。',
+ 'settings_node.app_node_too_old': 'このノードはこのページより古く、このアプリの設定をまだ持っていません。ここで設定するには更新してください。',
'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 7070288..306cf44 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -858,6 +858,8 @@ export default {
'settings_app.tmdb_token_prompt': 'Meld u aan bij TMDB om uw eigen API-sleutel te maken.',
'settings_app.tmdb_token_link': 'Sleutel ophalen',
'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.roots_node_too_old': 'Deze node is ouder dan deze pagina: hij kan zijn mappen tonen maar hier niet wijzigen. Werk hem bij, of gebruik de meshbay-node root-opdrachten.',
+ 'settings_node.app_node_too_old': 'Deze node is ouder dan deze pagina en heeft nog geen instelling voor deze app. Werk hem bij om die hier in te stellen.',
'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 c252e82..f2b3faf 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -884,6 +884,8 @@ export default {
'settings_app.tmdb_token_prompt': 'Zarejestruj się w TMDB, aby wygenerować własny klucz API.',
'settings_app.tmdb_token_link': 'Pobierz klucz',
'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.roots_node_too_old': 'Ten węzeł jest starszy niż ta strona: może pokazać swoje katalogi, ale nie zmieni ich tutaj. Zaktualizuj go albo użyj poleceń meshbay-node root.',
+ 'settings_node.app_node_too_old': 'Ten węzeł jest starszy niż ta strona i nie ma jeszcze ustawień tej aplikacji. Zaktualizuj go, aby skonfigurować ją tutaj.',
'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 7fb6c8c..e608fbd 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
@@ -843,6 +843,8 @@ export default {
'settings_app.tmdb_token_prompt': 'Cadastre-se no TMDB para gerar sua própria chave de API.',
'settings_app.tmdb_token_link': 'Obter uma chave',
'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.roots_node_too_old': 'Este nó é mais antigo que esta página: ele pode mostrar seus diretórios, mas não alterá-los aqui. Atualize-o ou use os comandos meshbay-node root.',
+ 'settings_node.app_node_too_old': 'Este nó é mais antigo que esta página e ainda não tem configuração para este aplicativo. Atualize-o para configurá-lo aqui.',
'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 e0886a2..68d794e 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
@@ -827,6 +827,8 @@ export default {
'settings_app.tmdb_token_prompt': '在 TMDB 注册以生成你自己的 API 密钥。',
'settings_app.tmdb_token_link': '获取密钥',
'settings_node.roots_offline_hint': '未连接到节点 — 变更将通过本地节点进行,并在其下次重新加载时生效。',
+ 'settings_node.roots_node_too_old': '该节点比本页面旧:它能显示自己的目录,但无法在此更改。请更新节点,或使用 meshbay-node root 命令。',
+ 'settings_node.app_node_too_old': '该节点比本页面旧,尚不支持此应用的设置。请更新节点后在此配置。',
'settings_node.directories_title': '应用目录',
'settings_node.directories_hint': '共享文件夹,以及“视频”“音乐”和“照片”应用各自使用哪个(些)作为入口。',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 785b926..32f8539 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -335,6 +335,25 @@ class MeshBayTransport {
set onIndexDelta(fn) { this._onIndexDelta = fn; }
set onUploadPolicy(fn) { this._onUploadPolicy = fn; }
set onRootsChanged(fn) { this._onRootsChanged = fn; }
+
+ /** The MNP version the connected node declared, or '' before a handshake. */
+ get nodeVersion() { return this._nodeVersion || ''; }
+
+ /**
+ * Whether the node speaks the per-root and per-app operations MNP 1.1 added:
+ * `root_update`/`root_eject`/`root_plug`, `app_directories`,
+ * `chat_directory`, `chat_link_preview`.
+ *
+ * An older node has no equivalent for the root ones at all, and answers the
+ * app ones through their three predecessors (`video_root`, `audio_root`,
+ * `photo_roots`). The caller chooses which; what it must not do is send a
+ * 1.1 message and wait, because an unknown type is logged and dropped.
+ */
+ get supportsAppOps() {
+ const m = /^(\d+)\.(\d+)$/.exec(this._nodeVersion || '');
+ if (!m) return false;
+ return (Number(m[1]) > 1) || (Number(m[1]) === 1 && Number(m[2]) >= 1);
+ }
set onAppsEnabled(fn) { this._onAppsEnabled = fn; }
set onAppDirectories(fn) { this._onAppDirectories = fn; }
set onChatDirectory(fn) { this._onChatDirectory = fn; }
@@ -600,6 +619,12 @@ class MeshBayTransport {
// block, because everything below — the join, the proof, the sealed ack
// — assumes both sides mean the same thing by each message.
_checkNodeVersion(reply);
+ // Kept, not just checked. Several controls exist only on a node new
+ // enough to have them, and the alternative to asking is offering a
+ // button whose message an older node logs as unknown and never answers
+ // — a 30-second wait ending in a timeout, with nothing on screen to say
+ // the node simply cannot do this.
+ this._nodeVersion = String(reply.v || '');
if (!window.MeshBayCrypto) {
throw new Error('Node requires GEK proof but no crypto available');
}
@@ -1195,6 +1220,34 @@ class MeshBayTransport {
* Cleaned and sorted the same way the node does, so both sides build the
* same bytes to sign.
*/
+ /**
+ * The same instruction a node too old for `app_directories` understands.
+ *
+ * Videos, Music and Photos each had their own message before this, and they
+ * still work — so an operator on an un-upgraded node keeps the ability they
+ * had, rather than being handed a control that silently times out. Chat has
+ * no predecessor, which is why its settings are hidden rather than routed.
+ */
+ async setAppDirectoriesLegacy(appKey, directories, signFn) {
+ const clean = [...new Set(
+ (directories || []).map((d) => (d || '').replace(/^\/+|\/+$/g, '')).filter(Boolean),
+ )].sort();
+ if (appKey === 'photo') return this.setPhotoRoots(clean, signFn);
+ // One folder was all these two could carry. Sending several would store
+ // the first and silently drop the rest, so it is refused instead.
+ if (clean.length > 1) {
+ throw new Error(
+ 'This node is older than this page and can hold one folder per app. '
+ + 'Update it, or choose a single folder.');
+ }
+ const one = clean[0] || '';
+ if (appKey === 'video') return this.setVideoRoot(one, signFn);
+ if (appKey === 'music') return this.setAudioRoot(one, signFn);
+ throw new Error(
+ 'This node is older than this page and cannot store this app\'s '
+ + 'folders. Its operator has to update it.');
+ }
+
async setAppDirectories(appKey, directories, signFn) {
const clean = [...new Set(
(directories || []).map((d) => (d || '').replace(/^\/+|\/+$/g, '')).filter(Boolean),
diff --git a/packages/meshbay-hub/tests/test_app_settings_plugin.py b/packages/meshbay-hub/tests/test_app_settings_plugin.py
index 00a07b0..1837c28 100644
--- a/packages/meshbay-hub/tests/test_app_settings_plugin.py
+++ b/packages/meshbay-hub/tests/test_app_settings_plugin.py
@@ -226,9 +226,12 @@ def test_the_page_performs_exactly_one_app_specific_operation():
# The page's own settings, which belong to no app: which apps are enabled
# at all, and how hard the node works watching its disk.
page_level = {"setAppsEnabled", "setScanSettings"}
- assert calls - page_level == {"setAppDirectories"}, (
+ # Both are the same generic operation; the second is what a node too old
+ # for it understands, chosen by version rather than by app.
+ generic = {"setAppDirectories", "setAppDirectoriesLegacy"}
+ assert calls - page_level == generic, (
f"the settings page performs app-specific operations: "
- f"{sorted(calls - page_level - {'setAppDirectories'})}")
+ f"{sorted(calls - page_level - generic)}")
# ── The apps read a list ────────────────────────────────────────────────────
diff --git a/packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py b/packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py
new file mode 100644
index 0000000..63390af
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py
@@ -0,0 +1,175 @@
+"""
+The page ships before the nodes do.
+
+The SPA is served by the hub, so deploying the hub puts this version of the
+client in front of *every* node, including the ones still running MNP 1.0. That
+window is not a corner case — it is the normal state for as long as it takes an
+operator to update, and for a node someone else runs it may be indefinite.
+
+The failure mode is specific and quiet: a node logs an unknown message type and
+sends **nothing back**, so a control that speaks MNP 1.1 to it produces a
+thirty-second wait ending in a timeout, with nothing on screen to say the node
+simply cannot do this. Three of them were like that before these tests:
+
+* Files' Upload button read `root.writable`, which a 1.0 node does not send —
+ it says `upload`. The button disappeared on every un-upgraded node.
+* The shared-directories toggles, eject and plug have no older equivalent at
+ all.
+* The per-app folder pickers spoke `app_directories`, where a 1.0 node
+ understands `video_root` / `audio_root` / `photo_roots`.
+
+Source-reading, like the other SPA guards. What it cannot check is that the
+degraded path is pleasant; what it does check is that each of the three exists.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+TRANSPORT = STATIC / "transport.js"
+FILES_APP = STATIC / "files-app.js"
+GROUP_PAGE = STATIC / "group-page.js"
+GROUP_SETTINGS = STATIC / "group-settings.js"
+
+pytestmark = pytest.mark.skipif(not TRANSPORT.exists(),
+ reason="SPA sources unavailable")
+
+
+def _component(source: str, name: str) -> str:
+ start = source.index(f"\nfunction {name}(")
+ end = source.find("\nfunction ", start + 1)
+ return source[start:end if end != -1 else len(source)]
+
+
+# ── Knowing which node you are talking to ───────────────────────────────────
+
+def test_the_client_keeps_the_version_it_checked():
+ """
+ `_checkNodeVersion` parsed the node's version and threw it away, so nothing
+ downstream could ask. Refusing to connect is not the only thing a version
+ is good for.
+ """
+ source = TRANSPORT.read_text(encoding="utf-8")
+ assert "this._nodeVersion = String(reply.v" in source
+ assert "get supportsAppOps()" in source
+
+
+def test_the_capability_reads_the_version_rather_than_guessing():
+ """
+ Inferring it from whether some field happens to be present is how two
+ unrelated things end up coupled — the flag would flip because a payload
+ changed shape for another reason entirely.
+ """
+ source = TRANSPORT.read_text(encoding="utf-8")
+ getter = source[source.index("get supportsAppOps()"):]
+ getter = getter[:getter.index("\n }") + 4]
+ assert "_nodeVersion" in getter
+ assert "1" in getter, "no version comparison in the capability check"
+
+
+# ── The three degraded paths ────────────────────────────────────────────────
+
+def test_the_upload_button_reads_the_older_flag_too():
+ """
+ A 1.0 node's roots carry `upload`; `writable` is the same answer renamed.
+ Reading only the new name hides the Upload button on every node that has
+ not been updated, which on the day the page ships is all of them.
+ """
+ page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel")
+ decl = page[page.index("const currentRootWritable"):]
+ decl = decl[:decl.index(";") + 1]
+ assert "currentRoot.upload" in decl, (
+ "the Upload button ignores the flag an older node actually sends")
+ assert "writable !== undefined" in decl, (
+ "a root that is explicitly writable=false must stay read-only — "
+ "falling through to `upload` there would reopen it")
+
+
+def test_app_directories_fall_back_to_the_three_older_messages():
+ """
+ Videos, Music and Photos each had their own message before the generic op,
+ and those still work — so an operator on an un-upgraded node keeps the
+ ability they had rather than being handed a control that times out.
+ """
+ source = TRANSPORT.read_text(encoding="utf-8")
+ legacy = source[source.index("async setAppDirectoriesLegacy("):]
+ legacy = legacy[:legacy.index("\n async ", 1)]
+ for call in ("setPhotoRoots", "setVideoRoot", "setAudioRoot"):
+ assert call in legacy, f"{call} is not reachable on the older path"
+
+ panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "GroupSettingsPanel")
+ assert "transport.supportsAppOps" in panel, (
+ "the settings page sends the 1.1 message unconditionally")
+
+
+def test_the_older_path_refuses_what_it_cannot_carry():
+ """
+ `video_root` and `audio_root` hold one folder. Sending several would store
+ the first and drop the rest silently, which is worse than refusing — the
+ operator would see a saved setting that is not what they chose.
+ """
+ source = TRANSPORT.read_text(encoding="utf-8")
+ legacy = source[source.index("async setAppDirectoriesLegacy("):]
+ legacy = legacy[:legacy.index("\n async ", 1)]
+ assert "clean.length > 1" in legacy
+ assert "throw new Error" in legacy
+
+
+def test_root_management_is_read_only_against_an_older_node():
+ """
+ Unlike the app directories, `writable`, `removable`, eject and plug have no
+ older equivalent to route to. The controls are shown without being
+ offered, with the reason, rather than accepting a click that goes nowhere.
+ """
+ panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "GroupSettingsPanel")
+ table_call = panel[panel.index("<${SharedDirectoriesTable}"):]
+ table_call = table_call[:table_call.index("/>")]
+ assert "readOnly=" in table_call
+ assert "nodeSupportsAppOps" in table_call
+ assert "settings_node.roots_node_too_old" in panel, (
+ "nothing says why the controls are inert")
+
+
+def test_chat_settings_are_hidden_rather_than_routed():
+ """
+ Chat's directory and link-preview switch are new in 1.1 with nothing
+ before them, so there is no older message to fall back to.
+ """
+ panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "GroupSettingsPanel")
+ assert "settings_node.app_node_too_old" in panel
+
+
+# ── Reading an older node's handshake ───────────────────────────────────────
+
+def test_the_ack_is_read_in_both_shapes(app=None):
+ """
+ A 1.0 ack has `video_root` and no `video_directories`, and no
+ `chat_link_preview` at all. Reading a missing plural as "nothing
+ configured" empties a working Videos tab; reading a missing switch as off
+ silently changes what a group's chat does.
+ """
+ page = GROUP_PAGE.read_text(encoding="utf-8")
+ block = page[page.index("setAppDirectories({"):]
+ block = block[:block.index("setNodeSupportsAppOps")]
+ for legacy in ("ack.video_root", "ack.audio_root", "ack.photo_roots"):
+ assert legacy in block, f"{legacy} is not read as a fallback"
+ assert "ack.chat_link_preview !== false" in page, (
+ "an absent link-preview switch must read as on, not off")
+
+
+def test_the_attachment_root_falls_back_to_the_older_answer():
+ """
+ A 1.0 node's roots carry no `writable`, so nothing looks writable and the
+ paperclip would vanish. The group-wide `member_upload` flag is the only
+ answer such a node gives, and it is what gets used.
+ """
+ page = GROUP_PAGE.read_text(encoding="utf-8")
+ block = page[page.index("const writableRoots"):]
+ block = block[:block.index("const commonProps")]
+ assert "legacyNode" in block and "memberUpload" in block
+ assert "writable === undefined" in block