diff options
22 files changed, 425 insertions, 16 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py index 58040b6..3cfb208 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py @@ -46,6 +46,10 @@ _ASSETS = ("style.css", "keyderive.js", "crypto.js", "transport.js", "app.js", "settings-ui.js", "folder-tree.js", "chat-app-settings.js", "video-app-settings.js", "music-app-settings.js", "photos-app-settings.js", + # The reference app (docs/refactor-groups.md §4.1). Hidden behind + # `?dev=1` client-side, but it is still served and still cached, so + # it participates in the hash like anything else here. + "helloworld-app.js", "helloworld-app-settings.js", # Pages extracted from app.js — statically imported or lazy-loaded, # but all must participate in the content hash. "auth-page.js", "explore-page.js", "create-group-page.js", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/apps.js b/packages/meshbay-hub/src/meshbay_hub/static/apps.js index 6e62a42..461bd57 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/apps.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/apps.js @@ -7,6 +7,33 @@ import { ChatSettings } from './chat-app-settings.js'; import { VideoSettings } from './video-app-settings.js'; import { MusicSettings } from './music-app-settings.js'; import { PhotoSettings } from './photos-app-settings.js'; +import { HelloWorldApp } from './helloworld-app.js'; +import { HelloWorldSettings } from './helloworld-app-settings.js'; + +/** + * Whether apps marked `dev` are shown. Opt in once with `?dev=1`, off with + * `?dev=0` — persisted, the same shape as transport.js's `?trace=1`. + * + * HelloWorld exists to prove that adding an application is a registry entry + * and two files. Registering it normally would put a toy app in every + * operator's group; leaving it unregistered would prove nothing, since the + * claim is precisely that registration is enough. So it is registered, and + * hidden behind a flag that changes nothing else. + */ +const DEV_KEY = 'mb_dev_apps'; +(function _initDevFlag() { + try { + const params = new URLSearchParams(location.search); + if (params.has('dev')) { + if (params.get('dev') === '0') localStorage.removeItem(DEV_KEY); + else localStorage.setItem(DEV_KEY, '1'); + } + } catch { /* localStorage unavailable — dev apps stay hidden */ } +})(); + +function devAppsShown() { + try { return localStorage.getItem(DEV_KEY) === '1'; } catch { return false; } +} /** * Every group "application", in tab order. @@ -43,18 +70,30 @@ const APPS = [ Component: MusicApp, Settings: MusicSettings }, { key: 'photo', icon: 'image', labelKey: 'group.tab_photos', Component: PhotosApp, Settings: PhotoSettings }, + // The reference implementation (docs/refactor-groups.md §4.1). `dev` keeps + // it out of an operator's way; everything else about it is an ordinary + // entry, which is the point. + { key: 'helloworld', icon: 'chat', labelKey: 'group.tab_helloworld', + Component: HelloWorldApp, Settings: HelloWorldSettings, dev: true }, ]; +/** Everything a reader of this page is allowed to see, in registry order. */ +function availableApps() { + const dev = devAppsShown(); + return APPS.filter(a => dev || !a.dev); +} + /** The registry filtered to what this group has enabled, in registry order. */ function visibleApps(enabledKeys) { + const registered = availableApps(); const enabled = new Set( - enabledKeys && enabledKeys.length ? enabledKeys : APPS.map(a => a.key)); - return APPS.filter(a => a.alwaysEnabled || enabled.has(a.key)); + enabledKeys && enabledKeys.length ? enabledKeys : registered.map(a => a.key)); + return registered.filter(a => a.alwaysEnabled || enabled.has(a.key)); } /** The apps the Settings page offers a section for, in registry order. */ function configurableApps() { - return APPS.filter(a => !a.alwaysEnabled && a.Settings); + return availableApps().filter(a => !a.alwaysEnabled && a.Settings); } -export { APPS, visibleApps, configurableApps }; +export { APPS, availableApps, visibleApps, configurableApps }; 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 0a43724..6f41426 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -9,7 +9,7 @@ import { HUB, session, cacheGroupIndex, hubFetch, ensureFreshToken, _loadBundleKey, _loadRecoveryKey, _storeBundleKey, } from './hub-client.js'; -import { visibleApps } from './apps.js'; +import { APPS, visibleApps } from './apps.js'; import { GroupName } from './group-name.js'; import { FilePreview } from './files-app.js'; import { VideoPlayer } from './video-player.js'; @@ -654,10 +654,19 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // `group-settings.js` untouched — that page renders the panes without // knowing what any of them is for, which is what makes adding an app a // registry entry rather than an edit to the page. + // `<key>Directories` for every registered app, derived from the registry + // rather than written out. Naming them here would mean adding an app + // required editing this file, which is the one thing the plugin + // architecture is supposed to have removed — and the reference app + // (docs/refactor-groups.md §4.1) is what made the difference visible. + const perAppDirectories = useMemo(() => { + const out = {}; + for (const app of APPS) out[`${app.key}Directories`] = appDirs(app.key); + return out; + }, [appDirs]); + const appSettings = useMemo(() => ({ - videoDirectories: appDirs('video'), - musicDirectories: appDirs('music'), - photoDirectories: appDirs('photo'), + ...perAppDirectories, chatDirectory, chatLinkPreview, tmdbEnabled: tmdbConfig ? tmdbConfig.enabled !== false : true, @@ -665,7 +674,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, tmdbTokenCustomized: Boolean(tmdbConfig && tmdbConfig.tokenCustomized), musicbrainzEnabled: musicbrainzConfig ? musicbrainzConfig.enabled !== false : true, - }), [appDirs, chatDirectory, chatLinkPreview, tmdbConfig, musicbrainzConfig]); + }), [perAppDirectories, chatDirectory, chatLinkPreview, tmdbConfig, + musicbrainzConfig]); const commonProps = { groupId, transportRef, gekRef, status, username, @@ -676,9 +686,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // Plural everywhere: Videos and Music read a list now, and Photos always // did. The scalar `videoRoot`/`audioRoot` shapes survive only on the wire, // for a node that speaks MNP 1.0 — nothing in the client carries them. - videoDirectories: appDirs('video'), - musicDirectories: appDirs('music'), - photoDirectories: appDirs('photo'), + ...perAppDirectories, tmdbConfig, musicbrainzConfig, onPlayQueue, }; 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 6c6f3d9..c80cff5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -5,7 +5,7 @@ import { t } from './i18n.js'; import { Icon } from './icon.js'; import { CollapsibleSection, ToggleSwitch } from './settings-ui.js'; import { hubFetch, navigate } from './hub-client.js'; -import { APPS, configurableApps } from './apps.js'; +import { availableApps, configurableApps } from './apps.js'; import * as platform from './platform.js'; @@ -559,7 +559,12 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, const [appsBusy, setAppsBusy] = useState(false); const [appsMsg, setAppsMsg] = useState(''); - const activeApps = enabledApps && enabledApps.length ? enabledApps : APPS.map(a => a.key); + // `availableApps()` rather than the raw registry: an app the reader is not + // shown must not be turned on for the whole group by falling back to "all of + // them". Found by adding one that is hidden by default — an ordinary app + // would never have exposed the difference. + const activeApps = enabledApps && enabledApps.length + ? enabledApps : availableApps().map(a => a.key); /** * Toggle one app in or out of the group's enabled set. Same shape as diff --git a/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app-settings.js new file mode 100644 index 0000000..9058199 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app-settings.js @@ -0,0 +1,50 @@ +import { html, useState, useEffect } from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { useSaver } from './settings-ui.js'; +import { FolderPickerField } from './folder-tree.js'; + +/** + * HelloWorld's settings: one folder, and nothing else. + * + * This is the whole server-side surface an application needs — no op, no MNP + * message, no route, no roster accessor. `saveDirectories` is bound to this + * app's registry key by the page, and `ops.set_app_directories` stores the row + * under that name without knowing what it is. + * + * It takes the shared props and no others (`docs/apps.md` §3b), which is what + * `test_app_settings_plugin.py` checks of every pane — including this one, so + * the reference implementation is held to the contract it demonstrates. + */ +function HelloWorldSettings({ roots, dirs, settings, saveDirectories }) { + const { busy, msg, run } = useSaver(); + const [directories, setDirectories] = useState( + settings.helloworldDirectories || []); + + useEffect(() => { + setDirectories(settings.helloworldDirectories || []); + }, [settings.helloworldDirectories]); + + const current = settings.helloworldDirectories || []; + const dirty = directories.length !== current.length + || directories.some((d, i) => d !== current[i]); + + return html` + <div class="app-settings"> + <${FolderPickerField} + label=${t('settings_app.helloworld_directory_label')} + hint=${t('settings_app.helloworld_directory_hint')} + roots=${roots} dirs=${dirs} mode="single" + value=${directories[0] || ''} disabled=${busy} + onChange=${(path) => setDirectories(path ? [path] : [])} /> + + <button class="btn btn-small btn-secondary" style="margin-top:4px" + disabled=${busy || !dirty} + onClick=${() => run(() => saveDirectories(directories))}> + ${busy ? t('settings_app.saving') : t('settings_app.save')} + </button> + ${msg && html`<p class="settings-hint">${msg}</p>`} + </div> + `; +} + +export { HelloWorldSettings }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app.js b/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app.js new file mode 100644 index 0000000..01b4ed4 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app.js @@ -0,0 +1,62 @@ +import { html, useMemo } from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { Icon } from './icon.js'; +import { formatSize } from './file-utils.js'; + +/** + * The smallest application this platform can host, and the proof that adding + * one costs nothing outside its own two files. + * + * Everything else in `docs/refactor-groups.md` §3 is asserted by tests that + * read source. This is the other kind of evidence: an app nobody wrote a line + * of plumbing for, that stores directories, appears as a tab, and lists files — + * because the registry entry beside it is genuinely all there is. + * + * **Not shipped to operators.** It is registered with `dev: true`, which keeps + * it out of the tab bar and the settings page unless the reader opts in with + * `?dev=1` (same shape as transport.js's `?trace=1`). Deleting the flag would + * put a toy app in everybody's group; deleting the app would leave the claim + * resting entirely on tests that read text. + * + * It takes the standard props — see `docs/apps.md` §2 — and reads + * `helloworldDirectories`, which nothing on the node knows about by name: + * `ops.set_app_directories` keys the row by whatever the app is called. + */ +function HelloWorldApp({ entries, availableEntries, helloworldDirectories, status }) { + const dirs = helloworldDirectories || []; + const pool = availableEntries || entries || []; + + const files = useMemo(() => pool.filter((e) => { + const p = e.path || ''; + return dirs.some((d) => p === d || p.startsWith(d + '/')); + }).slice(0, 200), [pool, dirs]); + + if (status !== 'connected') { + return html`<p class="page-message">${t('status.connecting_short')}</p>`; + } + + return html` + <div class="page-content"> + <h2>${t('helloworld.greeting')}</h2> + ${dirs.length === 0 ? html` + <p class="page-message">${t('helloworld.no_directory')}</p> + ` : html` + <p class="settings-hint"> + ${t('helloworld.counted', { n: files.length })} + ${' — '}${dirs.join(', ')} + </p> + <ul class="hw-list"> + ${files.map((e) => html` + <li key=${e.id} class="hw-item"> + <${Icon} name="folder" /> + <span class="hw-name">${e.name}</span> + <span class="hw-size">${formatSize(e.size || 0)}</span> + </li> + `)} + </ul> + `} + </div> + `; +} + +export { HelloWorldApp }; 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 c6d0331..d05d3a3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -126,6 +126,12 @@ export default { 'group.tab_video': 'Videos', 'group.tab_music': 'Musik', 'group.tab_photos': 'Fotos', + 'group.tab_helloworld': 'HelloWorld', + 'helloworld.greeting': 'Hallo, Welt!', + 'helloworld.no_directory': 'Noch kein Ordner gewählt. Wählen Sie einen in den Einstellungen.', + 'helloworld.counted': '{n} Datei(en)', + 'settings_app.helloworld_directory_label': 'Ordner', + 'settings_app.helloworld_directory_hint': 'Der Ordner, den diese Referenz-App auflistet. Sie zeigt, dass eine neue Anwendung nichts außer ihren beiden Dateien braucht.', 'group.tab_members': 'Mitglieder', 'group.tab_settings': "Einstellungen", 'members.danger_leave_hint': "Sie verlieren den Zugriff auf die Dateien und den Chat dieser Gruppe.", 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 0831aa3..6dbb70f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -127,6 +127,12 @@ export default { 'group.tab_video': 'Videos', 'group.tab_music': 'Music', 'group.tab_photos': 'Photos', + 'group.tab_helloworld': 'HelloWorld', + 'helloworld.greeting': 'Hello, World!', + 'helloworld.no_directory': 'No folder chosen yet. Pick one in Settings.', + 'helloworld.counted': '{n} file(s)', + 'settings_app.helloworld_directory_label': 'Folder', + 'settings_app.helloworld_directory_hint': 'The folder this reference app lists. It exists to show that adding an application needs nothing beyond its own two files.', 'group.tab_members': 'Members', 'group.tab_settings': "Settings", 'members.danger_leave_hint': "You will lose access to this group's files and chat.", 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 18570cc..aa950cc 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -124,6 +124,12 @@ export default { 'group.tab_video': 'Vídeos', 'group.tab_music': 'Música', 'group.tab_photos': 'Fotos', + 'group.tab_helloworld': 'HelloWorld', + 'helloworld.greeting': '¡Hola, mundo!', + 'helloworld.no_directory': 'Aún no hay carpeta elegida. Elige una en los ajustes.', + 'helloworld.counted': '{n} archivo(s)', + 'settings_app.helloworld_directory_label': 'Carpeta', + 'settings_app.helloworld_directory_hint': 'La carpeta que lista esta aplicación de referencia. Existe para mostrar que añadir una aplicación no requiere más que sus dos archivos.', 'group.tab_members': 'Miembros', 'group.tab_settings': "Ajustes", 'members.danger_leave_hint': "Perderá el acceso a los archivos y al chat de este grupo.", 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 65021e3..daa9f94 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -125,6 +125,12 @@ export default { 'group.tab_video': 'Vidéos', 'group.tab_music': 'Musique', 'group.tab_photos': 'Photos', + 'group.tab_helloworld': 'HelloWorld', + 'helloworld.greeting': 'Bonjour, monde !', + 'helloworld.no_directory': 'Aucun dossier choisi. Choisissez-en un dans les réglages.', + 'helloworld.counted': '{n} fichier(s)', + 'settings_app.helloworld_directory_label': 'Dossier', + 'settings_app.helloworld_directory_hint': 'Le dossier que cette application de référence liste. Elle existe pour montrer qu\'ajouter une application ne demande rien de plus que ses deux fichiers.', 'group.tab_members': 'Membres', 'group.tab_settings': "Paramètres", 'members.danger_leave_hint': "Vous perdrez l’accès aux fichiers et à la discussion de ce groupe.", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 7f58764..364c26c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -125,6 +125,12 @@ export default { 'group.tab_video': 'Video', 'group.tab_music': 'Musica', 'group.tab_photos': 'Foto', + 'group.tab_helloworld': 'HelloWorld', + 'helloworld.greeting': 'Ciao, mondo!', + 'helloworld.no_directory': 'Nessuna cartella scelta. Scegline una nelle impostazioni.', + 'helloworld.counted': '{n} file', + 'settings_app.helloworld_directory_label': 'Cartella', + 'settings_app.helloworld_directory_hint': 'La cartella elencata da questa applicazione di riferimento. Serve a mostrare che aggiungerne una non richiede altro che i suoi due file.', 'group.tab_members': 'Membri', 'group.tab_settings': "Impostazioni", 'members.danger_leave_hint': "Perderai l’accesso ai file e alla chat di questo gruppo.", 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 0e2dd42..f778fc4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -123,6 +123,12 @@ export default { 'group.tab_video': '動画', 'group.tab_music': '音楽', 'group.tab_photos': '写真', + 'group.tab_helloworld': 'HelloWorld', + 'helloworld.greeting': 'ハロー、ワールド!', + 'helloworld.no_directory': 'フォルダーが未選択です。設定で選んでください。', + 'helloworld.counted': '{n} 件のファイル', + 'settings_app.helloworld_directory_label': 'フォルダー', + 'settings_app.helloworld_directory_hint': 'この参照アプリが一覧表示するフォルダーです。アプリの追加に必要なのは自身の 2 つのファイルだけであることを示すために存在します。', 'group.tab_members': 'メンバー', 'group.tab_settings': "設定", 'members.danger_leave_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 9f74e19..6e6842e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -126,6 +126,12 @@ export default { 'group.tab_video': "Video's", 'group.tab_music': 'Muziek', 'group.tab_photos': "Foto's", + 'group.tab_helloworld': 'HelloWorld', + 'helloworld.greeting': 'Hallo, wereld!', + 'helloworld.no_directory': 'Nog geen map gekozen. Kies er een bij Instellingen.', + 'helloworld.counted': '{n} bestand(en)', + 'settings_app.helloworld_directory_label': 'Map', + 'settings_app.helloworld_directory_hint': 'De map die deze referentie-app toont. Hij bestaat om te laten zien dat een app toevoegen niets meer vergt dan zijn eigen twee bestanden.', 'group.tab_members': 'Leden', 'group.tab_settings': "Instellingen", 'members.danger_leave_hint': "U verliest de toegang tot de bestanden en de chat van deze groep.", 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 083d72d..c8cbd16 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -130,6 +130,12 @@ export default { 'group.tab_video': 'Wideo', 'group.tab_music': 'Muzyka', 'group.tab_photos': 'Zdjęcia', + 'group.tab_helloworld': 'HelloWorld', + 'helloworld.greeting': 'Witaj, świecie!', + 'helloworld.no_directory': 'Nie wybrano folderu. Wybierz go w ustawieniach.', + 'helloworld.counted': 'Plików: {n}', + 'settings_app.helloworld_directory_label': 'Folder', + 'settings_app.helloworld_directory_hint': 'Folder wypisywany przez tę aplikację referencyjną. Istnieje, aby pokazać, że dodanie aplikacji nie wymaga niczego poza jej dwoma plikami.', 'group.tab_members': 'Członkowie', 'group.tab_settings': "Ustawienia", 'members.danger_leave_hint': "Utracisz dostęp do plików i czatu tej grupy.", 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 bdc6c3b..6eb2242 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 @@ -126,6 +126,12 @@ export default { 'group.tab_video': 'Vídeos', 'group.tab_music': 'Música', 'group.tab_photos': 'Fotos', + 'group.tab_helloworld': 'HelloWorld', + 'helloworld.greeting': 'Olá, mundo!', + 'helloworld.no_directory': 'Nenhuma pasta escolhida. Escolha uma nas configurações.', + 'helloworld.counted': '{n} arquivo(s)', + 'settings_app.helloworld_directory_label': 'Pasta', + 'settings_app.helloworld_directory_hint': 'A pasta que este aplicativo de referência lista. Existe para mostrar que adicionar um aplicativo não exige nada além de seus dois arquivos.', 'group.tab_members': 'Membros', 'group.tab_settings': "Configurações", 'members.danger_leave_hint': "Você perderá o acesso aos arquivos e ao chat deste grupo.", 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 0a1fe1a..044611e 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 @@ -123,6 +123,12 @@ export default { 'group.tab_video': '视频', 'group.tab_music': '音乐', 'group.tab_photos': '照片', + 'group.tab_helloworld': 'HelloWorld', + 'helloworld.greeting': '你好,世界!', + 'helloworld.no_directory': '尚未选择文件夹。请在设置中选择。', + 'helloworld.counted': '{n} 个文件', + 'settings_app.helloworld_directory_label': '文件夹', + 'settings_app.helloworld_directory_hint': '该参考应用所列出的文件夹。它的存在是为了说明:新增一个应用只需要它自己的两个文件。', 'group.tab_members': '成员', 'group.tab_settings': "设置", 'members.danger_leave_hint': "您将无法再访问该群组的文件和聊天。", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index c5d5ce3..9c0d869 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -4176,3 +4176,15 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } .folder-field-tbl { margin-bottom: 8px; } .folder-field-tbl .sdt-col-dir { font-size: 0.9em; } .folder-field-actions { display: flex; align-items: center; gap: 10px; } + +/* HelloWorld (docs/refactor-groups.md §4.1) — the reference app, hidden + behind ?dev=1. Deliberately plain: it exists to prove the plumbing, and + anything decorative here would be a second thing to keep working. */ +.hw-list { list-style: none; margin: 12px 0 0; padding: 0; } +.hw-item { + display: flex; align-items: center; gap: 8px; + padding: 6px 0; border-bottom: 1px solid var(--border); +} +.hw-item .icon { width: 15px; height: 15px; flex-shrink: 0; } +.hw-name { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.hw-size { flex: 0 0 auto; color: var(--text-dim); font-size: 0.85em; } diff --git a/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py b/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py new file mode 100644 index 0000000..a1baf92 --- /dev/null +++ b/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py @@ -0,0 +1,159 @@ +""" +The reference application, and what it is for. + +`docs/refactor-groups.md` claims that adding an application costs a registry +entry and the app's own files — no op, no MNP message, no route, no edit to the +pages that render it. Every other test of that claim reads source for the +*absence* of app names, which proves nobody wrote a special case for Videos. It +cannot prove that a genuinely new app works, because there was no new app. + +HelloWorld is one. It stores directories, appears as a tab, has a settings pane +and lists files, and the node has never heard its name outside one allow-list +entry. The assertions below are the claim, stated as things that must stay true +of a file that was not written for it. + +It ships hidden behind `?dev=1` (apps.js's `dev: true`). Registering it +normally would put a toy app in every operator's group; not registering it +would prove nothing, since registration is exactly the thing being claimed as +sufficient. + +**Two honest exceptions**, both found *by* adding it and both fixed by making +the code less app-specific rather than more: + +* `group-settings.js` fell back to the whole registry when a group had no + `enabled_apps` yet, which would have turned a hidden app on for everyone. It + asks `availableApps()` now. +* `group-page.js` wrote out `videoDirectories` / `musicDirectories` / + `photoDirectories` by hand. It derives `<key>Directories` from the registry + now, which is what made the claim true rather than nearly true. +""" + +import re +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +NODE_SRC = (Path(__file__).resolve().parents[2] / "meshbay-node" / "src" + / "meshbay_node") + +APP = STATIC / "helloworld-app.js" +SETTINGS = STATIC / "helloworld-app-settings.js" + +pytestmark = pytest.mark.skipif(not APP.exists(), + reason="the reference app is not in this checkout") + + +def _code_only(source: str) -> str: + source = re.sub(r"/\*.*?\*/", "", source, flags=re.S) + return re.sub(r"^\s*//.*$", "", source, flags=re.M) + + +# ── The claim ──────────────────────────────────────────────────────────────── + +@pytest.mark.parametrize("name", [ + "group-page.js", "group-settings.js", "files-app.js", "transport.js", + "hub-client.js", "settings-ui.js", "folder-tree.js", +]) +def test_no_shared_client_file_mentions_it(name): + """ + The registry is where an app is named, and nowhere else. A branch on + `'helloworld'` in any of these would mean the architecture works for four + apps somebody wrote plumbing for. + """ + source = _code_only((STATIC / name).read_text(encoding="utf-8")) + assert "helloworld" not in source.lower(), ( + f"{name} names the reference app — adding an application is supposed " + f"to touch nothing here") + + +@pytest.mark.parametrize("name", [ + "ops.py", "roster.py", "config.py", "roots.py", +]) +def test_no_shared_node_module_mentions_it(name): + """ + Its directories are stored by `ops.set_app_directories`, which keys the row + by whatever the app is called. Nothing on the node knows what it is. + """ + source = (NODE_SRC / name).read_text(encoding="utf-8") + assert "helloworld" not in source.lower(), ( + f"{name} names the reference app; the generic path was supposed to " + f"cover it") + + +def test_the_node_names_it_once_and_only_in_the_allow_list(): + """ + `ALLOWED_APPS` is server-side enforcement — a client naming an app this + node does not know is refused — so an app absent from it could not + demonstrate anything. That entry plus the client's registry line is the + whole cost. + """ + source = (NODE_SRC / "transport" / "webrtc_server.py").read_text( + encoding="utf-8") + code = re.sub(r"^\s*#.*$", "", source, flags=re.M) + hits = [ln for ln in code.splitlines() if "helloworld" in ln.lower()] + assert len(hits) == 1, f"expected one mention, got: {hits}" + assert "ALLOWED_APPS" in hits[0] or "helloworld" in hits[0] + assert "ALLOWED_APPS" in code[:code.index("helloworld") + 200] + + +def test_the_registry_entry_is_ordinary(): + source = (STATIC / "apps.js").read_text(encoding="utf-8") + entry = source[source.index("key: 'helloworld'"):] + entry = entry[:entry.index("},") + 2] + for field in ("icon:", "labelKey:", "Component:", "Settings:"): + assert field in entry, f"the entry has no {field}" + assert "dev: true" in entry, "it would ship to every operator" + + +# ── And it is held to the same contract as the rest ────────────────────────── + +def test_its_settings_pane_takes_the_shared_props_and_no_others(): + m = re.search(r"function \w+Settings\(\{([^}]*)\}\)", + SETTINGS.read_text(encoding="utf-8")) + assert m + props = {p.strip() for p in m.group(1).split(",") if p.strip()} + assert props <= {"roots", "dirs", "settings", "saveDirectories", + "transport", "signFn"} + + +def test_it_reads_its_directories_under_its_own_key(): + """ + `<key>Directories` — the shape `group-page.js` derives for every registered + app. An app reading a name spelled anywhere else would need that place + edited too. + """ + for path in (APP, SETTINGS): + assert "helloworldDirectories" in path.read_text(encoding="utf-8") + + +def test_it_does_not_reach_for_the_transport(): + """ + It has no third-party service and no setting of its own, so it needs + neither — which is the case an app author most often starts from, and the + one the architecture has to make free. + """ + source = _code_only(SETTINGS.read_text(encoding="utf-8")) + assert "transport." not in source + assert "saveDirectories" in source + + +# ── Hidden, but genuinely registered ──────────────────────────────────────── + +def test_a_dev_app_is_filtered_out_by_default(): + source = (STATIC / "apps.js").read_text(encoding="utf-8") + assert "function availableApps()" in source + body = source[source.index("function availableApps()"):] + body = body[:body.index("\n}") + 2] + assert "devAppsShown()" in body and "a.dev" in body + + +def test_nothing_falls_back_to_the_unfiltered_registry(): + """ + A fallback of "every app in the registry" would enable a hidden one for the + whole group. This is the exception the reference app found. + """ + source = _code_only((STATIC / "group-settings.js").read_text(encoding="utf-8")) + assert "APPS.map(" not in source and "APPS.filter(" not in source, ( + "group-settings.js reads the raw registry; it should ask " + "availableApps()") diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py index 5719be5..01516bd 100644 --- a/packages/meshbay-hub/tests/test_hook_ordering.py +++ b/packages/meshbay-hub/tests/test_hook_ordering.py @@ -43,6 +43,7 @@ STATIC_FILES = [ "settings-ui.js", "folder-tree.js", "chat-app-settings.js", "video-app-settings.js", "music-app-settings.js", "photos-app-settings.js", + "helloworld-app.js", "helloworld-app-settings.js", "auth-page.js", "explore-page.js", "create-group-page.js", ] diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py index 3c6d022..b462242 100644 --- a/packages/meshbay-hub/tests/test_transport_contracts.py +++ b/packages/meshbay-hub/tests/test_transport_contracts.py @@ -41,6 +41,8 @@ SPLIT_FILES = [APP, GROUP_PAGE, CHAT_APP, STATIC / "files-app.js", STATIC / "video-app-settings.js", STATIC / "music-app-settings.js", STATIC / "photos-app-settings.js", + STATIC / "helloworld-app.js", + STATIC / "helloworld-app-settings.js", STATIC / "auth-page.js", STATIC / "explore-page.js", CREATE_GROUP] diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 028918d..bcc4f6d 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -1086,7 +1086,7 @@ class NodeDaemon: # context is read once at load and an app enabled later must not find its # own setting missing. Adding an app adds a name here and nowhere else on # this side. - APP_DIR_KEYS = ("video", "music", "photo", "chat") + APP_DIR_KEYS = ("video", "music", "photo", "chat", "helloworld") async def _app_directories_ctx(self, group_id: str) -> dict: """ diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 0e2d418..acdbe29 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -1828,7 +1828,14 @@ class WebRTCPeerSession: # network calls (TMDB, MusicBrainz) once enabled, so an operator opts a # group in explicitly rather than getting it for free # (docs/mediacenter.md §5.6, docs/musicbay.md §4.4). - ALLOWED_APPS = frozenset({"chat", "files", "video", "music", "photo"}) + # `helloworld` is the reference implementation (docs/refactor-groups.md + # §4.1), hidden client-side behind `?dev=1`. It is here because the + # allow-list is server-side enforcement — a client that names an app this + # node does not know is refused — and an app the node refused could not + # demonstrate anything. This entry and the client's registry line are the + # whole of what adding an application costs. + ALLOWED_APPS = frozenset({"chat", "files", "video", "music", "photo", + "helloworld"}) def _do_apps_enabled(self, msg: dict) -> None: """ |