aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/app.js
Commit message (Collapse)AuthorAgeFilesLines
* fix(packaging): three MSIX first-run regressions found by a real sideloadChristophe Besson2026-09-121-0/+28
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A second-machine sideload of the MSIX target surfaced three things the earlier verification round (which only proved the package installs and runs) had missed: 1. meshbay-node missing from PATH. installer.nsh's customInstall adds node-runtime\ to HKCU\Environment at install time -- an unelevated per-user write, never blocked by MSIX's no-elevation rule, only by the more basic fact that an AppX/MSIX install runs no custom code at all. packaging/win/ensure-node-path.ps1 (idempotent, no admin verb) plus main.js's winEnsureNodeOnPath() do it from the app itself instead, once per launch, shipped to Full and MSIX (not Light, nothing to add there). Verified live via the Node inspector protocol: the entry was in HKCU\Environment\Path after a launch, absent before. 2. A daemon that crashes on startup failed silently. spawnNodeDetached() used stdio: 'ignore', so a real crash reproduced live (a second instance colliding with the first on 127.0.0.1:18000) left waitForNode()'s generic 60s timeout as the only failure ever shown. spawnNodeDetachedWatched() pipes stdio and watches ~2.5s, rejecting immediately with the daemon's own stderr on an early exit; a survivor has its streams released and runs fully detached exactly as before. First version bounded the captured text by line count and a live test showed that cut the actual OSError line -- two uvicorn/asyncio tracebacks followed it in the real capture -- so it is bounded by characters instead. 3. No hint that a startup-mode choice exists. The install-time radio page was the only place this was ever offered, and nothing replaces it now that no install-time page can exist at all. SetupWelcome (the existing first-run banner) grew a conditional hint, shown only while a bundled node is present and neither autostart nor service mode is configured yet. Considered and rejected: linking straight to the Node page -- its route is gated on a linked hub node key, false on the exact fresh-install screen this hint targets, so the link would have been dead on arrival. New key setup.node_startup_hint, added to all ten locale catalogues. test_packaging_win.py gained six tests pinning all three (69 total). Full plan and verification detail: C:\Users\admin\devel\msix-installer.md section 13 (out of repo). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(hub): legal information link at the foot of the sidebarChristophe Besson2026-09-111-0/+10
| | | | | | | | | | | | | | | | | | | | | | A small link to the hub's legal pages, pinned to the bottom of the sidebar once signed in. It opens in a new tab: the desktop application refuses to navigate away from its interface and hands a new window to the system browser, and in a browser it keeps the session on screen. The address comes from hubBase(), so it is the legal pages of the hub in use. The sidebar now sticks under the navigation bar at the window's height; otherwise, on a long file list, the link would sit at the bottom of the page. The music bar, pinned to the bottom of the window as well, publishes its height through useStickyBand as --music-bar-h and the sidebar stops above it. On a phone the slide-out panel does the same. test_sidebar_legal_measured.py measures the real stylesheet: the link at the bottom of the window on a 3000px page, and above the music bar, at phone and desktop widths. Checked signed in on a local hub in Chrome and Firefox 155. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9MCBBWSm9GhBESmqzJxNy
* fix(spa): say why a download cannot be pausedChristophe Besson2026-09-091-0/+14
| | | | | | | | | | | | | | | | | | | | | | | | | | Reported from Chrome, with a screenshot: four downloads with no pause button and an upload beside them with one, and nothing anywhere saying why. The reason is real. Without a granted download folder the browser writes through the service worker — a download it already owns, which cannot be paused without stalling it somewhere we can neither see nor resume. An upload writes to the node, which keeps the position, so it is always pausable. But that was stated only in a Settings line nobody reads on the way to a download, and a gap where the row above has a button is not an explanation. So a download that cannot be paused now shows a dimmed pause icon where the button would be, carrying the reason and the remedy in its tooltip. Not a button: there is nothing to click, and a disabled one invites the click anyway. And only where the advice can be taken. Firefox and Safari have no folder to choose — the streamed path is the only target they have, which is what §6.5 of ~/next/improve-downloads.md costs out — so telling someone there to choose one would be advice they cannot follow. Nothing is drawn. Hub suite 866 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* fix(spa): a paused transfer is not a finished oneChristophe Besson2026-09-091-2/+13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Reported while testing the flag day: pausing an upload put it under "Finished". "Finished" was defined by exclusion — everything that is not running, queued or preparing — so it swallowed `paused` the day pausing shipped. A transfer somebody stopped on purpose then sat beside the ones that are actually over, offering a resume button in the section of things that cannot be resumed, and dropped out of the badge, which announced less activity than there was. Paused is now its own group, in all ten catalogues, and counts as active: it is not over, the person means to come back to it. The three filters are lifted out of `app.js` and executed rather than described in the test, and one case asserts that every status lands in exactly one group — a state added later that falls into none is a transfer the panel simply does not show, which is how this one got in. The same report also said the three running downloads lost their pause buttons when the upload was paused. That part is **not** explained and **not** fixed: the store returns `pausable` true and status `running` for all three (new test), closing an upload lease pumps only the upload queue, and the button's condition is a pure function of those two. All three say the buttons should have stayed, so an observation is missing rather than a cause. Hub suite 864 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* feat(spa): pause and resume a download, in sessionChristophe Besson2026-09-091-1/+31
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Stage 7a of ~/next/improve-downloads.md: pausing within a session, on the targets that can actually do it. Resuming across a reload is 7b. A paused transfer holds **nothing**. Its slot goes back to the node the moment it stops and resuming rejoins the queue at the tail, because anything else lets one member close a node by pausing four downloads and going to lunch. So the lease is taken inside the run loop rather than before it, and pause is refused outright for a transfer that could not ask for another one. Resuming is exact rather than approximate: the pipeline stops between two chunks and never inside one, so what is on disk is always a whole number of chunks and `fromChunk` is a verified position. The failure mode being avoided is a file that looks complete and is quietly corrupt. The target has to survive it, so a pause no longer reaches the `abort()` that a failure does -- that would delete Electron's `.part` or the file just created in the granted folder, leaving nothing to continue. And the in-memory fallback keeps its accumulated chunks rather than starting a second array. The button is drawn only where the target says it can. A service-worker stream says no, in its own code and for its own reasons: the browser is already writing an HTTP response into its own download folder, not feeding it stalls that download where we cannot see or resume it, and an idle worker is terminated within seconds. Firefox and Safari therefore keep cancel and get no pause, which is the decision recorded in §6.5. Cancelling a paused transfer ends it. A paused run is parked on a promise; without waking it the row said "cancelled" over work that had not stopped and a target that was still open. Six cases, each checked against the unfixed source. Hub suite 842 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* fix(hub): the transfers row exists from the clickChristophe Besson2026-09-081-4/+19
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Clicking Download produced nothing — no row, no icon, no panel — for as long as it took to open somewhere to write, and then several rows at once. The streamed path waits for the worker twice; a Save As dialog waits for a person. The row was created after that, so the slowest part of a download happened with nothing on screen to say it had begun. The store gains a `prepare` step, distinct from `run`, and the order is now: row, then target, then slot. That last part is why the obvious fix was wrong. Taking the slot first would let the row appear immediately, and it was tried this morning: a granted slot has to be taken up within the node's deadline, opening a target can outlast it, and three downloads became one. (The diagnosis at the time blamed that ordering for revocations which were in fact a missing `touch()` call — the revert was right for the wrong reason.) `makeLease` is called after `prepare` succeeds, never before. Three behaviours fall out, each with a test: - a dismissed dialog leaves nothing behind. `prepare` returning false drops the row: nothing started, so nothing should remain on screen to explain it; - the row takes the name the file was actually saved under, once known; - a refusal above the memory ceiling fails the row that is already there, rather than creating one to kill it. `preparing` counts as live everywhere — badge, cancel, clearFinished, and `_busy`, since closing a transport under a preparing transfer strands it exactly as under a queued one. Six places asked "is this finished?" and were drifting apart; there is one definition now. Two mistakes in the tests, worth the note: one counted positions in an output array by hand and was one out, which reads exactly like a failing assertion about the code — the values are tagged now, not indexed. And test_zip_size_limit.py's stub did not run `prepare`, so it no longer reached the size check the file is about; it now behaves like the real store. 819 hub, 1169 node, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* feat(hub): client-side transfer leases and the transfers panelChristophe Besson2026-09-081-51/+122
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Steps 5 and 6 of ~/next/improve-downloads.md. The node has handed out slots since step 2 and nothing asked for one; now the client does, and the panel shows what is happening. `transport.openTransfer()` returns a Lease: `acquire()` resolves when the node grants, `release()` gives it back exactly once, and nothing else in the client speaks to the node about slots. Whether a node hands out slots is read from the handshake ack rather than guessed from a timeout — "no answer yet" and "this node will never answer" are indistinguishable in time, and guessing wrong either stalls every download or defeats the cap. Two things exist only because a queue can lie: a watchdog re-asks when a pushed grant does not arrive (the node is idempotent on `tr`, so asking again is free), and a grant for a transfer the page has forgotten is handed straight back rather than held until the node's deadline. The slot is asked for **after** there is somewhere to write, and that ordering is load-bearing: opening a target takes thirty seconds of streamed-download timeouts, or as long as somebody leaves a Save As dialog open, and a grant not taken up in time is revoked. Moving it earlier looked better and broke three downloads into one. Pinned by a test. The panel groups by state — running, waiting, finished — rather than re-sorting a flat list, so a row moves only when its own state does. The ETA is withheld until the speed window holds real measurement: a figure from the first two chunks swings between four seconds and an hour, and people plan around the first number they see. One live region announces state changes and not progress. Three silent paths closed on the way: a download refused for want of a user gesture (a browser grants one file picker per gesture, and downloading three files is one gesture) now falls back to the streamed path, which needs none; a click with no connection says so instead of doing nothing at all; and a queued transfer counts as busy, so a transport is never closed under one that is waiting for a grant that could then never arrive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* fix(hub): make the streamed download path reliable, and clean up after an abortChristophe Besson2026-09-081-0/+9
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | On Firefox and Safari the service worker is the only unbounded way to write a download to disk: neither has the File System Access API, and OPFS is not a substitute — measured on Firefox 154, its quota is exactly 10% of the volume's size (389,233,459 bytes of a 3,892,334,592-byte volume, refused to the byte), which a film exceeds. So when this path declines, a large download has nowhere left to go, which makes its reliability a correctness property. Four ways it declined, all of them avoidable: - it was registered inside the first click on Download, so that click paid install, activate and claim while somebody watched a button do nothing; - `_swReady` cached a null for the life of the page. One slow first click left the tab unable to stream anything again, curable only by a reload nobody knew to do. Only a successful controller is remembered now; - control was waited for with a 3 s cap. It is 15 s, and a page that is active but not controlled asks the worker to claim again (`mbdl-claim`) instead of declaring the path unavailable; - a missed navigation gave up at once. It gets a second attempt with a fresh id and iframe, the failed one torn down completely first. Also closes a MessagePort leaked per download, and gives the reason a name (`lastStreamFailure`) so a refusal can say what happened. The timeouts became parameters: the defaults are the production values, no caller passes any, and the tests do not spend a minute waiting. `openTarget` gets an unrelated but adjacent fix, in the same file: it creates the destination with `getFileHandle({create: true})`, so an empty file exists before the first byte, and `abort()` leaves the target untouched — every cancelled download left a 0-byte file behind, and since `freeName` avoids collisions, three cancels left film.mkv, film (2).mkv and film (3).mkv, all empty. Its `abort()` now removes the entry. Safe here and only here, because `freeName` guarantees the name was not taken: the `showSaveFilePicker` path must not do the same, where the person may have picked an existing file whose contents `abort()` correctly preserves. Verified by hand in Chrome. test_streamed_download_reliability.py runs the real module under Node against a stubbed browser — it fails if the null is cached again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
* feat(client): create the system tray at launch, not on first minimiseChristophe Besson2026-09-081-4/+17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | ensureTray() was reachable only from the window:minimize-to-tray handler, so the indicator did not exist until you had already hidden the window into it. That is backwards on both desktops — most of what a tray is for is finding an application that is not in front of you — and on Windows it read as the app having no tray presence at all. Created during app.whenReady(), after registerBridge() and before createWindow(). The order matters: buildTrayMenu reads the nodeService that registerBridge assigns, so the other way round puts the Start/Stop entry on the menu one five-second poll late. The menu's labels were the one thing that came *from* the minimise call, since the main process has no i18n. A new tray:labels IPC (platform.setTrayLabels) carries them instead, sent from the renderer's boot once initLocale() has a catalogue; a language change reloads the page, so the same call covers it. The window between launch and that first message shows TRAY_FALLBACK, in English. §5.10's two platform gates become one — trayOS() in main.js, which every tray path calls. test_desktop_shell.py's existing test is rewritten against it and two are added: the launch ordering, and that no tray path tests process.platform inline instead of calling the gate. Windows still files a new tray icon under hidden icons until the person drags it onto the taskbar. No API promotes it; documented in WINDOWS-PORT.md §5.11 rather than worked around. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8EDjk6pkYZrCbo63m2x87
* fix(hub): drop the flag emoji from the language menuChristophe Besson2026-09-081-1/+1
| | | | | | | | | | | | | | | | | | | The flags were regional-indicator pairs, which Windows has never shipped a glyph for: Segoe UI Emoji renders the pair as its two letters, so the menu read "GB", "FR", "NL" down the left-hand side instead of a flag. It only ever looked right on a system with an emoji font that draws them, such as GNOME's. Names only, rather than vendoring ten flag images. A flag is a country and not a language anyway — none of them is the right answer for "Português (Brasil)" — and each name is already written in its own language, which is the self-identifying signal the flag was standing in for. The `flag` field is removed from LOCALES rather than left unread; app.js's menu was its only consumer. The submenu keeps its indent from .user-menu-sub's padding, not from the icon box that is now gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8EDjk6pkYZrCbo63m2x87
* feat(hub): name a group's host on its card, not its visibilityChristophe Besson2026-09-081-5/+11
| | | | | | | | | | | | | | | | | | | | | | "private" and "invite" were on every card because they are the default for every group on the hub — two badges that never varied and so never told anyone anything. Who hosts a group does vary, and it is the thing that tells two groups of the same name apart: the hub enforces name uniqueness per owner account, not globally. - The "My groups" cards (app.js) drop both badges and render the name through GroupName, which puts the owner beside it as a smaller "@handle" — the same way the group header and Explore already write it. "admin" stays; it varies. /v1/groups/mine already returns owner_username, so no API change. - Explore drops its join_policy badge for the same reason. An open group still announces itself through the Join button, which is the useful half, and its @host was already there. - .group-card h3 .gn-owner is set to 0.8em: a card's h3 is reset to 1em, so the 0.55em meant for a page heading rendered the handle at about half the body size — smaller than the name, as it should be, but past readable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8EDjk6pkYZrCbo63m2x87
* feat(client): tray menu starts and stops the node, and a clearer iconChristophe Besson2026-09-051-2/+3
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | The first icon was an arrow dropping into a receptacle, which is the download glyph -- a vertical stem above a container reads that way whatever the context. Replaced with a window folding a chevron into itself: no stem, and the frame says which object is being minimised. Applied to both the nav button and the panel indicator, which carried the same wrong shape. The menu now offers Start or Stop for the node daemon, chosen from its actual state and shown only when there is a daemon to act on: `supported && installed`, so a machine with no node installed gets no entry rather than a control that fails when used. `restart` is the start verb -- systemd's restart starts a stopped unit, and there is no separate one to call. The three service handlers become named functions so the tray drives exactly what the Node page drives, instead of a second copy of the systemctl and Task Scheduler branches. A read of the state that throws is treated as no control at all. The menu is rebuilt on a 5s timer while an indicator exists, and again straight after an action. libappindicator has no "menu is about to open" event, so a menu built once would show a stale Start/Stop for the life of the process; `systemctl --user show` costs a few milliseconds. Locales: tray.start_node / tray.stop_node in all ten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtfG7z6wHWj8RKHCvxQtY1
* feat(client): minimise to a system tray indicator (GNOME)Christophe Besson2026-09-051-0/+7
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A dedicated monochrome button in the nav, immediately left of the notification bell, hides the window to a tray indicator. Linux only for now; Windows is being done on that OS, and the capability is declared per platform so the button never appears where the desktop shows no indicator -- there it would hide the window for good. Hides, never closes: `window-all-closed` quits the app, so closing here would make "minimise" mean "exit" and drop the session, the transfers and the node connection. `second-instance` now calls the same restore path, since focusing a hidden window does nothing visible. The context menu is not decoration. Under libappindicator -- how GNOME shows a tray at all, via the AppIndicator extension -- `tray.on('click')` never fires; the indicator only opens its menu. A tray whose sole affordance was a click would be inert on the one desktop this targets. The click handler is kept for desktops that do send it. Menu labels come from the renderer with the IPC call: the locale files are the interface's, the main process has no i18n, and a second string table is how two of them start disagreeing. English fallbacks if none arrive. The icon lives in src/, not build/: package.json `files` packages only `src/**` and `ui/**`, so an icon under build/ is present in a dev run and missing from every installed one. Monochrome, stroked, matching the nav glyph. Verified on this host: Ubuntu GNOME with ubuntu-appindicators@ubuntu.com and libayatana-appindicator3 present, so the indicator has somewhere to appear. Not yet run end to end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtfG7z6wHWj8RKHCvxQtY1
* fix(hub): dismissing a notification deletes itChristophe Besson2026-09-021-3/+5
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The previous commit filtered the list to unread, which corrected what the reader saw and left every dismissed row in the table, invisible for ever. That is a place to hide the disagreement rather than a resolution, and the operator said so: "elles s'accumulent pour rien." So dismissing drops the row. It is the reasoning `purge_notifications` has carried all along — "these are signals, not a record: the group is still there, the message is still in the chat, the invitation is still an invitation" — applied one at a time instead of only in bulk. - `DELETE /v1/notifications/{id}` is the honest name and what the SPA calls. - `POST /{id}/read` reaches the same handler and now deletes too. It has to keep working: the interface ships inside the desktop package, so a hub is always answering some client older than itself, and giving the old path the new behaviour means those clients stop hoarding as well rather than only the updated ones. - `read-all` deletes rather than marking, which makes it `DELETE ""` under an older name. Marking would have made it the one route still filling the table. Nothing in this repo calls it, but a reachable endpoint is one that can be called. `Notification.read` is now vestigial — nothing stored can be read, because reading it deletes it. It stays because dropping a column is a migration for no gain, and `unread_only` stays because a SPA newer than its hub still needs it to be right. Both are said in the module docstring rather than left to be worked out. Two existing tests encoded the old semantics and now assert the opposite; test_notification_dismissal.py gains one for the old `/read` path, because version skew is the normal case here and not the exception. 617 hub tests pass. docs/USERGUIDE.md's endpoint table updated in both places it lists them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
* fix(hub): a dismissed notification stays dismissed across a restartChristophe Besson2026-09-021-2/+8
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Clicking a notification navigated to the group and the entry disappeared — the intended behaviour — and it was back on the next launch. Neither half was wrong on its own, which is why it survived. `markRead` drops the entry locally *and* marks it read on the hub, deliberately: "Reading it is the point of clicking it: it goes, here and in the count, rather than sitting there greyed out." The hub honoured that and persisted it. But the startup fetch asked for `/v1/notifications?limit=20` with no filter, and the endpoint returns read and unread alike, so every dismissed notification came straight back. It asks for `unread_only=true` now — a parameter the endpoint already had and already tested. The feed still carried the fossil of the older intent: `class="notif-item ${n.read ? '' : 'notif-unread'}"`, styling for a read entry rendered greyed out, from before clicking meant dismissing. Nothing read reaches the feed any more, so that branch was dead code describing behaviour the application had abandoned — and noticing it is what made the two halves' disagreement visible. Removed. `unread_count` is computed server-side over the whole table and is unaffected by the filter, so the bell is unchanged. test_notification_dismissal.py holds both halves: the API round trip that is the reported bug (list, read, list again), its mirror showing the unfiltered endpoint still returns it — so the fix cannot read as a coincidence — and a static check that the SPA asks for the filter, which is the only one of the three that catches the defect that actually happened. Verified by dropping the parameter again: that one fails, the API tests do not. Read notifications now accumulate unread in the table rather than being deleted. Purge removes them; the volume is small. Making dismissal a delete would suit `purge_notifications`' own docstring — "these are signals, not a record" — but it would leave `/read` a misnomer and `read-all` inconsistent, so it is a separate decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
* fix(hub): adjust no-group prompt and post-create wizard stepChristophe Besson2026-09-011-2/+6
| | | | | | | | | | | | | | | No-group home/explore message: when the hub offers no public groups, drop the "browse public groups" invitation and just ask to be invited by an admin. New home.invite_only key added to all ten catalogues. Create-group wizard done step: reword the message to point at inviting members, and send the button to the group's Settings tab (where the invite form lives) instead of a stale /groups/<id> path that never matched the router. The landing tab is a one-shot session hint, so the usual per-user default-tab preference is left untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QNPfgH6VWcRzJDZGuzy1jJ
* feat: passphrase change and account recovery (auth-confirm)Christophe Besson2026-09-011-3/+5
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The passphrase derives two independent client-side values: auth_key (the hub verifier) and bundle_key (AES-GCM key for the per-node identity bundles, which live on nodes and never on the hub). Changing or recovering a passphrase is therefore two operations — swap the hub verifier, and re-wrap every reachable node's identity bundle. Flow A — change a known passphrase (Profile page) - POST /v1/users/password re-proves the current passphrase, swaps pw_hash/salt/version, revokes every refresh token and returns a fresh pair so the tab that made the change stays signed in. - MeshBayTransport.rewrapAllNodes: for every group's online node, connect with the old key, read the identity off the handshake, store it back under the new key. Returns updated / unreachable / failed so the UI can point at the operator-unpin fallback for the gaps. Always-shown confirmation dialog listing reachable and unreachable groups. Recovery key - keyderive.js generateRecoveryKey (32 random bytes, grouped Base32) and deriveRecoveryKey (HKDF-SHA256, domain meshbay:recovery:v1:<username>). - Every per-node identity gets a second copy wrapped under the recovery key: keypair_bundles.bundle_enc_recovery (node-only column, added in _SCHEMA_KEYPAIR and via a PRAGMA-guarded ALTER for existing DBs), carried on keypair_bundle_store / _resp. MNP 0.13 -> 0.14, additive. - session.recoveryKey is persisted in IndexedDB (slot rk) and lazy-loaded on connect, so a group joined in any later session still leaves a recovery copy. - Shown once at registration; optionally folded into the verification e-mail as a pass-through the hub never stores or logs, with an opt-out. - Profile -> Recovery key re-loads R and backfills every reachable node via rewrapAllNodes in bundleKey mode (no passphrase re-entry). Flow B — recover a lost passphrase (#/reset, linked from sign-in) - POST /v1/users/password/reset-request {username, email}: both must be the pair on file, checked against the blind email_hash (never decrypted). A mismatch — wrong e-mail, unknown username, non-active account — takes the identical no-op path (no code, no mail, same 200), so it reveals nothing and cannot be used to spray reset mail from a username alone. 5/min, 1-hour single-use code. - POST /v1/users/password/reset {username, code, new_auth_key}: same expiry / attempts / single-use checks as e-mail verification; revokes every session and deletes every registered device key so a stored one cannot sign back in past the reset. - ResetPasswordPage: request code -> code + optional recovery key + new passphrase -> reset + sign-in -> fan-out. connect() falls back to the recovery-wrapped copy when the passphrase key cannot open bundle_enc. Without a recovery key: sign-in is restored and each group needs the operator-unpin fallback. Supporting fixes (found in live testing) - member unpin now also deletes the keypair bundle; connect() mints a fresh identity when handed a bundle it cannot open (unless _rewrapOnly, set by rewrapAllNodes), so a rejoin completes instead of dead-ending before the invite-code prompt. - A browser with no bundle key gets a passphrase prompt on the group page instead of a "go back to the browser you registered on" message. - RegisterPage / LoginPage / ResetPasswordPage trim the username so every key derivation matches the hub's stored form. Docs: docs/auth-confirm.md. Locale keys across all ten catalogues. Tests: test_password_change, test_password_reset, test_recovery_email, test_recovery_key, test_rewrap_fanout, test_bundle_store_recovery, plus additions to test_admin_ops_mnp and test_webrtc_transport. Hub suite 492 passed; node suite 741 passed (the lone test_packaging_units failure is a pre-existing RPM-spec flake, reproducible on main). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GGkxJW9br8Y9bhT8ywJ3oc
* refactor(ui): extract Explore, Login, Register and CreateGroup from app.jsChristophe Besson2026-08-311-904/+15
| | | | | | | | | | | | | ExplorePage → explore-page.js (static import), LoginPage/RegisterPage/ FirstRunPage → auth-page.js (static import, LoginPage receives onLogin as a prop), CreateGroupPage/wizard → create-group-page.js (lazy-loaded via dynamic import(), same pattern as AdminPage/NodePage). app.js goes from 1803 to 914 lines. webapp.py _ASSETS extended with the three new files and the previously missing extracted pages. Test fixtures updated to follow the moved components. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(ui): extract Settings, Profile and Admin pages from app.jsChristophe Besson2026-08-301-951/+12
| | | | | | Admin page is lazy-loaded so non-admin users never fetch it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(ui): use toggle switches in Settings page, trim verbose hintsChristophe Besson2026-08-301-5/+8
| | | | | | | | | Replace checkboxes with the existing toggle-switch component for notifications, keep-screen-on, and allow-public-groups. Remove the "Notifications" label from per-group lines, and shorten the music-keep-screen-on and hub-URL hints. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(ui): extract Node page to dedicated module, use local APIChristophe Besson2026-08-301-737/+10
| | | | | | | | | | | | Move NodePage and NodeServicePanel from app.js into node-page.js, following the existing pattern (group-page.js, search-page.js). Lazy-loaded via dynamic import so browser users never fetch it. Replace all MNP/WebRTC calls with platform.node.call() (loopback HTTP API), eliminating the ~6s ICE gathering delay. The MNP protocol types and server-side handlers are kept for potential future browser use. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(node): editable node settings in the Node page (D5)Christophe Besson2026-08-291-0/+77
| | | | | | | | | | | | | Expose invite_ttl_hours, pair_ttl_hours, device_request_ttl_minutes, max_concurrent_streams and transcode_incompatible_video in the Node management panel. Changes are applied immediately via roster.db and written back to node.toml so they survive a DB wipe. On startup, roster overrides take precedence over node.toml defaults. Draft v6 §2.11 documents the design; MNP gains node_settings_set / node_settings_set_ack for the browser path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: node workflow redesign — wizard auto-config, reset, MusicBrainz contactChristophe Besson2026-08-291-1/+68
| | | | | | | | | | | | | | | | | | | | | | | | Wizard (Electron): - Auto-provisions node config (hub URL + username) from logged-in user - node:start handles both cold start and restart of misconfigured daemon - Waits for daemon to reach 'running', auto-links node key on hub - probeNode accepts intermediate states for wizard progress feedback Reset (meshbay-node reset): - Unlinks node key from hub (DELETE /me/node_key, best-effort) - Stops and disables daemon (systemctl --user disable --now) - Erases ~/.config/meshbay, ~/.local/share/meshbay, ~/.local/state/meshbay MusicBrainz contact: - Resolved from owner's hub email instead of per-node roster config - Removed musicbrainz_contact UI and WebRTC handshake field - Removed set_musicbrainz_contact/musicbrainz_contact from roster Node pairing: - Added operator pairing banner on NodePage - Added operator_paired flag to list_groups Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(hub): cross-group search with reuse of existing viewsChristophe Besson2026-08-281-116/+59
| | | | | | | | | | | | | Search page fetches indexes from all groups, then renders consolidated entries through the existing FilesPanel, VideoApp, and MusicApp components — no reimplemented views. Groups appear as top-level directories in the file browser; video/music entries use a synthetic root with per-entry transport refs for thumbnails and metadata across groups. Music player lifted to app.js with getConnection(groupId) for cross-group playback. Connection pool (max 3, LRU eviction) manages lazy WebRTC connections. All 10 locales updated with search keys. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(hub): Node page — "Remove group" only for a group the hub droppedChristophe Besson2026-08-281-5/+12
| | | | | | | | | | | The button detaches a group from this node's config. For a group that still exists on the hub that strands it with no host; deleting such a group is the group Settings tab's job (detach + hub delete together). It now shows only when the group is `stale` — present on the node, gone from the hub — which the page already computes for the "not on hub" badge. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* fix(hub): sidebar — status dot on the name line, not between name and @ownerChristophe Besson2026-08-281-6/+8
| | | | | | | | | | | The @owner line turned the sidebar name into a two-line block, and the status dot (a flex sibling, align-items:center) floated to its vertical middle. A group entry is now a flex column: dot+name on one row (.si-head), @owner on a subordinate line indented past the dot. Non-group sidebar links are untouched (.sidebar-item stays a row; only .sidebar-group flips). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* feat(hub): group names unique per owner, shown as name@ownerChristophe Besson2026-08-281-8/+11
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A group's identity stays its UUID. What changes is that "the name is unique" — until now an unenforced expectation — becomes real, scoped to the owner account, and the owner's username is surfaced so two groups called "photos" on different nodes can be told apart. Hub: - `groups` gains a functional unique index `uq_groups_owner_name` on `(admin_id, lower(name))` (model + migration c3d4e5f6a7b8). The migration pre-flights: if the data already clashes it aborts and lists the offending (admin_id, name) pairs rather than renaming anyone's group. meshbay.org checked clean. - `create_group` trims the name, rejects blank (422) and an owner-scoped case-insensitive clash (409), with an IntegrityError backstop for the race, and returns `owner_username`. - `owner_username` added to `/v1/groups/mine`, `GET /v1/groups` (local rows), `POST /v1/groups/{id}/join`, `GET /v1/admin/groups`. SPA: - new `static/group-name.js` — `<GroupName name owner [inline]>` renders the name with the `@owner` handle on a smaller grey line under it. - used in the sidebar, the group-page header, Explore cards, the Admin groups table, and cross-group Search (via a widened `cacheGroupIndex` carrying the owner). Delete/leave confirmations show `name@owner` inline. - federated Explore rows show `@<source_hub>` instead of an account. Design record and the locked decisions: ~/next/groupnames.md (out of repo). MNP unchanged. Tests: test_group_name_unique.py, test_group_name_migration.py. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* fix(hub): Explore page says nothing about public groups when they're offChristophe Besson2026-08-281-2/+10
| | | | | | | | | | When the hub has public groups disabled, /explore showed an empty "Public Groups" screen with a search box and a Create button that do nothing. It now renders a single line — "This hub does not have public groups." — and the server already returns an empty directory regardless. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* feat(hub): let a hub admin disable public groups instance-wideChristophe Besson2026-08-281-54/+133
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A new General tab in Administration carries one switch, allow_public_groups, stored in a hub_settings key/value table (runtime-editable, unlike hub.toml). Default is on; an absent row means on, so an upgrade changes nothing. Enforcement is server-side on every hub-mediated path, not just the SPA: - create_group refuses visibility=public (403), staff included - list_public_groups the directory returns nothing (local + federated) - join_group open-joining a public group is refused - group_online_nodes a non-member of a public group is handed no node - signaling.webrtc_offer drops the "node hosts an open group" fallback - federation.export_directory advertises nothing to peer hubs The switch is read live, so flipping it back restores every path. Existing members of a group that predates the switch keep their membership row and their access — this is plan A, not a purge. GET /v1/hub/info exposes the flag (unauthenticated) so the create-group form and the sidebar's "Public groups" link render correctly. Also in the admin Groups tab: a Revoke action beside Suspend. Suspend is the reversible hub flag; Revoke calls POST /v1/admin/revoke, which sets status=revoked and broadcasts a signed revocation every node enforces (denylist + dropped live sessions). It is confirm-guarded and names the group. And a message fix the revoke work surfaced: group_online_nodes, join_group and webrtc_offer answered "Group is suspended" for any non-active status. They now report the real state, so a member of a revoked group is told "Group is revoked" rather than something reversible-sounding. Tests: test_public_groups_toggle.py (10) covers the switch end to end and the five enforcement paths; test_revocation.py gains the status-message assertion. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* feat(music): network-adaptive prefetch depth, opt-in keep-screen-on toggleChristophe Besson2026-08-261-0/+33
| | | | | | | | | | | | | | | | | Prefetch depth (music-player.js): 5 tracks ahead on Wi-Fi, 3 on cellular — more runway through a screen-lock network gap when the connection is cheap and fast, less when it's metered. navigator.connection is Chromium-only; Firefox/Safari (where it's undefined) get the same conservative tier as an unrecognized connection type, never assumed fast. MAX_CACHED_BLOBS raised to 6 to hold the largest case (current + 5). Keep-screen-on-during-audio (new user preference, off by default): a Settings toggle, backed by a new whitelisted key on /v1/users/me/preferences (music_keep_screen_on). music-player.js holds a Screen Wake Lock only while a track is playing and only when the user has opted in — unlike the video player's unconditional lock, this must not fight the ordinary expectation (matching Spotify/Deezer) that the phone locks on its own while listening.
* fix(hub,node): Create Group wizard silently skipped apps, and lost track of ↵Christophe Besson2026-08-251-13/+24
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | scanning progress Two real-world bugs found together while testing multi-root group creation: - CreateGroupWizard only sent the enabled-apps PUT when the operator had *unchecked* something, assuming "every box left checked" already matched the node's own default (Roster.DEFAULT_APPS = chat, files). It doesn't — so leaving every app checked, the common case, silently left Videos/Music/Photos disabled on the node. Now sent unconditionally. - The wizard's "add extra roots" step never polled index-status, so once step 3 (which only watches the first/upload root) finished, the progress bar froze while the node kept scanning the remaining roots for minutes, unwatched. Added waitForRootsIndexed (platform.js), mirroring waitForGroupHosted's own race handling. That fix exposed a deeper one: indexer.py's _scan_root() only flipped `progress.scanning` on *after* walking the directory and stat()-ing every file — both off-loop, but slow enough on a large root that a poller's grace period (waitForRootsIndexed's 5s) could expire before ever observing `scanning: true` (confirmed against production logs: a GEK-init step fired 5.058s after a root started scanning, matching the grace period almost exactly). The stat() pass was also a synchronous loop directly on the asyncio event loop — blocking the whole daemon (WebRTC, chat, admin UI) for as long as it took on a root with many files. Both fixed: `scanning` now flips on before the walk starts, and stat()-ing is now off-loop too (_size_files). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XSohfUQQiaE77qyFLgSv3
* fix(hub): Create Group wizard steps app selection before the scan waitChristophe Besson2026-08-241-19/+27
| | | | | | | | | | | | | | | | | | | | | | | | | | | "Choosing applications" 404ed with "Group not hosted on this node" on any real (multi-thousand-file) library — found live creating a group against a 5787-file MP3 collection while testing the Music app. daemon.py registers a brand-new group in groups_ctx only once its initial scan finishes (ui/app.py's index-status docstring already says so); nothing group-scoped can succeed before that, however many times it's retried. The wizard's apps step ran *before* platform.waitForGroupHosted() (which correctly waits up to 30 minutes for exactly this), protected only by a 5×400ms withRetry meant for a sub-second race — nowhere close to covering a real scan. The stated reason for running it early (so a mid-scan joiner never sees a not-yet-disabled app) doesn't hold either: nobody can join before the group is hosted, same gate. Fix: move the apps step after waitForGroupHosted, matching where the roots/GEK steps already correctly run. No node-side change — the scan-then-register ordering in daemon.py is intentional and untouched. npm run sync-ui re-run to propagate to the Electron client. Hub suite: 429 passed, no regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
* feat(node,hub): season-specific overviews, manual TMDB match correction, and ↵Christophe Besson2026-08-241-10/+56
| | | | | | | | | | | | | | | | | | | | | | | | | wizard polish Two operator-facing fixes for a real 3-season show whose automatic TMDB match was wrong at the show level: per-season overview/air_date tabs in the detail modal (falling back to the show-level text when a season's own is empty), and a "Fix match…" search-and-correct affordance that re-resolves every file sharing the corrected show's display_title. New signed op OP_TMDB_OVERRIDE and two read-only pairs (season_meta_req/resp, tmdb_search_req/resp), MNP_VERSION 0.5 -> 0.6. Also: the create-group wizard gets a spinning indexing indicator and an app-selection step, group settings default the TMDB language to the operator's own locale (never as a global default), and a file renamed mid-session now re-triggers title parsing instead of being silently skipped by the enrichment dedup guard. Fixes two bugs found during this work: the search overlay's z-index lost to the base video-overlay class and rendered invisibly, and season_meta's own empty overview didn't fall back to the show-level one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAmyXtc6dAADsH23ydXQpY
* feat(node,hub): add Videos group app (poster grid, flat list, TMDB metadata)Christophe Besson2026-08-241-2/+2
| | | | | | | | | | | | | | | | | | | | | Implements docs/mediacenter.md: a "Videos" group application built on the existing files index rather than a separate catalogue. On the node side, new indexer enrichment (technical probe, filename/season parsing, thumbnail generation) runs per-file once an operator has chosen a video_root for the group, plus a TMDB client for on-demand poster/metadata lookups (never client-side, thumbnails delivered over the existing chunk path). On the hub side, a new video-app.js renders a lazily-mounted poster grid or a thumbnail-only flat list, with TMDB entirely optional per group. Along the way: the global apps registry now drives Settings' default-tab picker instead of a hardcoded list, and the video_root is configured from group Settings (like uploads) rather than from Files, with the node refusing to run any TMDB/thumbnail work until one is set. Fixes several bugs found via live testing against a real library, notably a race between two effects writing the same "image ready" state that could leave a poster grid spinning forever on a same-tab revisit — see mediacenter.md §5.4 for the full account of each one.
* feat(node): persistent index cache, visible scan progress, adaptive ↵Christophe Besson2026-08-231-11/+80
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | reconcile, and delta sync Indexer performance work, in four parts: - Persistent (path, size, mtime) -> hash cache (indexer/cache.py) so a node restart no longer re-hashes every file — measured at 23 minutes for a 114 GB library on a slow disk before this, near-instant after. Hashing is deliberately kept sequential (max_workers=1): it was never actually concurrent despite the pool size, and two interleaved reads seek-thrash a spinning disk instead of going faster. - Byte-based scan progress (IndexProgress), surfaced via the loopback index-status route, the handshake ack, and a periodic INDEX_PROGRESS push to connected peers — drives a progress bar in the Create Group wizard and "add a directory" in Settings, and an animated presence dot. Guaranteed to settle back to idle via try/finally and a final push on the scanning->false transition. - The reconcile backstop's directory walks now run in the executor instead of blocking the daemon's event loop; its interval defaults to 10 min (was 60s) with adaptive backoff to 2h when nothing changes, reset on a real change or a peer connecting, and is now a per-group operator setting (signed op + group Settings UI). - INDEX_DELTA wired up (protocol support existed, nothing called it): _on_index_change now sends additions/deletions instead of rebuilding the full entries list, coalesced over a short window so a burst of file events produces one push, and the hub swarm registration for public groups only (re-)registers newly added hashes. Also fixes several bugs found while testing the above against real libraries (a 114 GB and a 100+ GB group on a USB HDD): - /api/reload blocked until the reload — including a brand-new group's full initial scan — finished, which the Electron bridge's fixed 30s call timeout turned into a hard failure on any real library. The route now fires the reload without waiting (ops.start_reload), matching add_root/remove_root's existing pattern; the wizard's own step order was fixed to wait for the group to actually appear hosted before the steps that need it (extra roots, GEK), with retries for the residual race between that and the daemon's own bookkeeping. - transport.js's hand-rolled msgpack codec had no case for uint64/int64 (0xcf/0xd3) and crashed decoding any message containing one — hit by IndexProgress.scanned_bytes/total_bytes for any group over ~4.3 GB. Verified against real msgpack-encoded bytes from the Python side. - chat_hist_resp, and this change's own index_progress and set_scan_settings_ack pushes, were not routed by message type and could be handed to an unrelated pending request by the transport's "oldest pending" fallback, stalling it until its own 30s timeout and corrupting whatever received the wrong reply in its place. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
* feat(hub): split the group UI into a pluggable "applications" architectureChristophe Besson2026-08-231-3523/+16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | GroupPage's 6620-line app.js carried Chat and Files wedged in directly, with no way to add another group-level app without touching the shell itself. It is now app.js (routing, non-group pages) plus nine focused files — apps.js (the registry), chat-app.js, files-app.js, video-player.js, group-page.js (the shell), group-settings.js, hub-client.js, icon.js and file-utils.js — with docs/apps.md as the checklist for adding one (Videos/Music/Photos are sketched there, not built). Node side gained the matching enablement mechanism, mirroring member_upload exactly: a roster setting, a signed apps_enabled op enforced by _has_admin_authority, exposed in the handshake ack. Operators toggle applications per group from Settings, which also gained a small reorder: Invite, Pairing, Applications, Shared directories, Uploads, danger zone, Your devices, Members. Two bugs surfaced during the split, both missing an import across the new file boundary and invisible to node --check or a module-load probe since they only throw when the code path actually runs: - group-page.js called onRefreshAuth on a stale-token handshake rejection, but app.js never imported refreshAccessToken from hub-client.js — so a brand new member (including a group's own creator) hit "Not a member of this group" and the retry silently failed, throwing before it could refresh the token. - chat-app.js called getLocale() for message timestamps without importing it from i18n.js. Opening Chat on a group with real messages threw mid- render; uncaught, that appears to wedge Preact's render scheduler, so every button on the page stopped responding until reload. Caught the second class of bug with a proper no-undef audit across all split files (a temporarily installed ESLint 9, since the system one is too old to parse this codebase's syntax) rather than trusting grep. 827 tests pass; 6 new ones cover the apps_enabled policy. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
* feat(node): systemd service panel on the Node page, and a clean CLI restartChristophe Besson2026-08-221-0/+90
| | | | | | | | | | | | | | | | | | | | | Add a status panel at the top of the Node page — always visible, even before an MNP connection exists — showing the meshbay-node systemd unit's own state (via `systemctl --user show`, main process only) with Start/Stop/Restart controls. This is the piece the rest of the page cannot provide: it has to work while the daemon is stopped or crash- looping, which the MNP-based sections require the daemon to already answer. While touching node lifecycle: `reload` and `restart-daemon` in the CLI shelled out to pgrep + SIGTERM/SIGHUP and respawned the process by hand, logging to a hardcoded /tmp path. That pattern already SIGHUPed a developer's own running node by accident once (see the old test_cli_dispatch.py comment). Both now delegate to `systemctl --user reload|restart meshbay-node`, which the unit already supports correctly (ExecReload=, Restart=on-failure). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
* fix(ui): reset Files navigation state when switching groupsChristophe Besson2026-08-221-0/+4
| | | | | | | | | | GroupPage keeps the same component instance across a group switch (no key at the router), so currentPath/selected/filter were leaking from the group just left. A directory that exists in one group but not the other (e.g. "outputs") then showed an empty listing after switching. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
* fix: first-run wizard reliability and node startup performanceChristophe Besson2026-08-221-24/+25
| | | | | | | | | | | | | Node daemon no longer blocks startup on slow directory scans — initial indexing runs in the background so the node reaches "running" immediately after transports are up. Fixes the wizard failing to detect the node when large USB/NAS roots take minutes to scan. Also: wizard key-linking deadlock resolved (main.js links during poll), invite form stays in DOM during reconnects (disabled instead of destroyed), pairing code bridges to renderer, and firewall docs for LAN casting added. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: node:start auto-provisions config and unlock keyChristophe Besson2026-08-221-3/+3
| | | | | | | | | | | | When the wizard calls node:start with {hubUrl, username}, the handler writes a minimal node.toml and a random unlock.key if they don't exist. The daemon then creates the keystore on first start using the unlock file — fully non-interactive. Existing configs are left untouched: if node.toml or unlock.key already exist, provisioning is skipped and the node starts as before. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: D.6 first-run wizard — detect, start, link, group, gek-init, pairChristophe Besson2026-08-211-15/+67
| | | | | | | | | | | | | | | | | | | Desktop client guides new users through the full onboarding sequence without a terminal: detect local node → start daemon (systemd with direct-start fallback for dev) → link node key to hub → create group → attach directories → gek-init → operator pair. - node:detect returns configured field to distinguish missing vs stopped - node:start tries systemctl first, checks is-active, falls back to spawning the binary directly when the unit crashes (dev mode) - probeNode() extracts the shared config→token→fetch pattern - SetupWelcome on empty HomePage links to /create-group - CreateGroupWizard step 0 handles node detection and start - platform.js exposes node.installed() and node.start() - 12 setup.* i18n keys added to all 10 locales - Integration test for node:start fallback logic Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: LAN Wi-Fi casting to Chromecast via local HTTP relayChristophe Besson2026-08-211-9/+190
| | | | | | | | | | | | | | | | | | | | | | Re-serve decrypted fMP4 video over HTTP on the LAN so a Chromecast can play the stream. The relay runs in the Electron main process — same trust boundary as downloads and MSE playback. - cast-relay.js: HTTP server with BoxAccumulator (reassembles WebRTC chunks into moof+mdat pairs), ring buffer, backpressure, finish() for clean end-of-stream, fixed port range 19550-19553 - cast-chromecast.js: mDNS discovery (bonjour-service) + CASTV2 protocol (castv2-client), connect/reload/disconnect lifecycle - Seek-aware: relay restarts on every seek, Chromecast reloads new URL; generation counter prevents stale async errors from killing active restarts; landingPlayheadRef suppresses programmatic seeking events - Device picker in video top bar with scan, device selection, copy-URL fallback, and cast status indicator - IPC bridge (main/preload/platform) for start/push/stop/finish/status/ discover/chromecastConnect/chromecastReload/chromecastDisconnect - Phase 3 design doc for DLNA/Smart TV in docs/cast-smart-tv.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: unified group management, public groups, and activity-based sidebarChristophe Besson2026-08-201-34/+467
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Create Group wizard (Electron-only) consolidates 6 steps across 4 interfaces into a single multi-step page: group creation on hub, node attachment, root selection via folder picker, GEK initialization, and auto-pairing — all in one flow. Browser SPA keeps its current behavior unchanged. Public group support (Option A — GEK for all groups): - All groups have GEK regardless of visibility; open-join groups auto-admit via TOFU when join_policy is "open" - Key rotation blocked for public groups (API guard + UI hidden) - Hub signaling allows WebRTC offers for nodes hosting open-join groups even when the caller isn't a member yet - attach_group writes join_policy to node.toml - Daemon loads GEK for all groups, not just private ones - Known-device path in join_request now auto-admits to open-join groups Node loopback API bridge (Electron IPC): - node:detect, node:call, node:pairing-code IPC handlers in main process - Renderer never sees tokens, paths, or keys (session token = physical access) - platform.js node namespace for UI consumption - Loopback endpoints: roots CRUD, member-upload toggle, reload Bug fixes: - Root change detection: removed premature ctx["roots"] updates from add_root and remove_root that prevented indexer retarget on reload - Duplicate offline message: global fallback now gated on !group - Signaling membership check: fallback to open-join groups for non-members Sidebar groups sorted by last_activity_at (most recent first): - New Group.last_activity_at column with Alembic migration - POST /v1/groups/{id}/activity endpoint, called on connect and chat send - Client-side sort + throttled hub updates (1/min) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(node): full Node admin panel — CLI parity, hot-reload, group lifecycleChristophe Besson2026-08-201-7/+566
| | | | | | | | | | | | | | | | | | | | Node admin panel (NodePage) now covers every CLI operation over MNP: group attach/detach, roster, member unpin, GEK rotate, denylist, reload. Daemon hot-loads new groups and tears down removed ones on config reload instead of requiring a full restart. Group attach/detach via MNP or local API triggers an automatic reload so the group is live immediately. Fixed GroupPage hang on first visit to a newly created group: the JWT issued at login didn't include the new group, the node rejected with not_a_member, and the token-refresh path returned without re-triggering the connect effect (Boolean(token) didn't change). Now bumps retryKey after a successful refresh so the effect re-runs with the fresh token. NodePage marks groups hosted by the node but absent from the hub with a "not on hub" badge so stale groups are visible and easy to remove. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(ui): 11-point UI overhaul — tabs, transfers, settings, uploadsChristophe Besson2026-08-191-56/+235
| | | | | | | | | | | | | | | | | | | | | | | | | | | | Hub/UI: - Icon-only group tabs (chat, files, settings) with per-group default tab - Transfer widget: filename becomes a clickable link to open completed downloads - Pulse animation on transfer icon (pale→dark green) while active - Download button feedback in FilePreview (spinner, auto-reset) - Group mute toggle persists across navigation - Login page autofocus, chat refocus after send - Theme toggle closes menu, status badge and duplicate connecting removed - Create-folder restricted to operators, download-path note removed - User preferences API (CRUD) with Alembic migration - Profile: email display/edit via PATCH /v1/users/me - Settings: "Defaults" section for default tab selector - All 10 locale files updated Node: - upload_dir in node.toml: separate filesystem path for uploads - Root.direct flag: uploads land at root path, no subdirectory - CLI --upload-dir flag on `group add` - Admin UI accepts upload_dir Client (Electron): - shell.openPath bridge for opening completed downloads - platform.js passes open callback from native save Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(ui): the M of the wordmark is the logoChristophe Besson2026-08-191-1/+8
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The stylised M replaces the letter M in "MeshBay" at the top left; `eshBay` beside it stays text. **The source artwork is now in git.** It was in `QE/`, which is gitignored, so it would not have survived a clone — and the tree is about to be moved to another machine. `assets/brand/` holds the original and the `convert` command that derives the 10 KB nav asset from it (trimmed, 72px tall for a picture shown at 30, so it survives a 2× screen). Verified: the documented command reproduces the committed file byte for byte. The derived file is in `_ASSETS`, which is what the `/a/<hash>/` fingerprint is computed from. A file missing from that list is a file whose change never moves the URL, so a browser holding the old one never asks again — the comment on that list says so, and a logo is exactly the kind of asset one would forget. Its URL is resolved from `import.meta.url`, so the hub's fingerprinted path and the application's `app://` scheme both come out right without either being named in the source. **The two sizes are independent, and that took four rounds to get right.** The picture's height was written in `em` — a fraction of the lettering — so every adjustment to the text resized the picture by the same stroke and the ratio between them could never change. The operator spotted the coupling before I did ("si on change la taille du M, tu vas encore tout décaler"). The height is in pixels now: `font-size: 1.375em` (22px) and `height: 30px`, two knobs that move one thing each. The vertical nudge went with it — it was correcting a misalignment I was causing myself. Also: the wordmark blue goes one step down from sky-400 towards sky-500. It sat brighter than the picture next to it, which made the two read as different materials rather than one object. A note on how this went wrong first: I checked the result on a mock HTML page I wrote, not in the application. The picture was loading the whole time, but at 25px against a 29px line box it was *smaller* than the letters, so the wordmark read as unchanged and the report was "none of what I asked for was done". Every size since has been measured in the running window over CDP. 883 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(node): the operator can close uploading to everyone but themselvesChristophe Besson2026-08-181-2/+75
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A group where every member may add files stays the default. Some groups want a library the operator curates, and until now the only way to get one was to designate no upload root at all — which refuses the operator too. **The node enforces it; the interface merely stops offering it.** The Upload button in the Files toolbar and the paperclip in the chat composer both disappear, which is a courtesy to the people who are not trying. The control is `_do_file_upload` refusing with `member_upload_off`, so a member on an old tab, or one speaking MNP directly, gets the same answer. There is a test for each, and the enforcement test is in the node package rather than beside the UI one so nobody reads the hidden button as the mechanism. **Changing it is a signed operator instruction** — `OP_MEMBER_UPLOAD`, on the same path as removing a member. An unsigned one would let any member turn it back on and make the setting a suggestion. The transcript's subject is `on` or `off`: what the operator is shown before signing has to name the outcome, not the operation. **It lives on the node**, in a new `group_settings` table in `roster.db`. Not the hub, which has no business deciding who may write to someone else's disk. Not `node.toml` either: that file is hand-written and full of comments recording decisions, `ops.py` appends to it rather than round-tripping it through a writer, and a setting toggled from a panel must not rewrite the operator's file or need a restart. The value is cached in the group context because the upload path is synchronous, and the signed operation updates both — storing it without applying it would make the panel say one thing while the node did another. **Absent means allowed**, at every layer: no row in the table, no key in the context, no field in `handshake_ack`. An older node and an older client both behave exactly as before, and upgrading never silently closes a group. Each of those three has its own test, because they fail independently. The operator is always exempt — otherwise turning it off locks them out of their own node with a config file and a restart as the only way back. `is_node_admin` was being computed in two places by then and is now one function, since two copies of "is this the operator" is how the ack and the gate come to disagree. A change reaches everyone already connected via `member_upload_ack`, so the button goes without a reconnection. That message is both a broadcast and the reply to the request that caused it, which is why the client does not return early on it. Docs updated for a cold start: draft-v6 §2.1b and change 9, a new "Where Phase 13 stands" section in CLAUDE.md recording what is built, deployed and still missing, the module map row, and desktop-client-v1 §10b on the Settings tab and where group settings live. 883 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: the chat tab no longer scrolls, and a group is listed or invite-onlyChristophe Besson2026-08-181-43/+37
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | **The chat tab was 8px too tall, at every window size.** The panel is sized from JS to `viewport - top - 16`, which puts its bottom 16px above the fold — but it sits inside `.main`, which adds 24px of padding below it. Eight pixels of document past the window, whatever the window. Measured at 700, 900 and 1200: `scrollHeight` 708, 908, 1208. This is the second one of these — the sign-in card was `.page-center` and `.layout` each reserving `100vh - 52px` — so it is now measured in the suite rather than reasoned about. `tests/harness/scroll_probe.py` renders the real markup against the real stylesheet and **runs the real `fit()` lifted out of `app.js`**: a copy of the formula in a test would go on passing after the original changed, which is exactly the bug being guarded. The fix does not encode 24 anywhere. The first pass runs as before, then the leftover is measured and taken off, so anything added below the panel later is absorbed the same way. Now `scrollHeight == innerHeight` at all three heights, nothing below the fold, and the panel still fills the room it has — that last one has its own test, because shrinking the chat to 240px would satisfy every other assertion here and be useless. The Settings tab was measured too and is **not** a bug: it fits at 1200px and overflows only when its content is genuinely taller than the window. **Group creation asked one question twice.** Visibility and admission were separate selectors that could only ever be set together — picking Public reached over and set the policy — and two of the four combinations are meaningless. The API already refused public+invite with a 422, so the form could build a request that could not succeed. Private+open was accepted and should not have been: a group anyone may join that nobody can find is a listing with the listing removed, since joining goes through the node and there is no link to pass around. So: one selector, "who can join", and the request derives the rest. The API now refuses the other impossible pair as well, with a message that says which way to resolve it. Six locale strings the visibility box owned are deleted rather than left unread in ten files, and the two surviving descriptions now say what each choice means for who can *find* the group — with the word "public" gone from the page, nothing else would have said it, and someone would publish a group without meaning to. 865 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(ui): name the section after its button, drop the folder slash, colour ↵Christophe Besson2026-08-181-4/+7
| | | | | | | | | | | | | | | | | | | | | | | | | | | | the widget **"Zone sensible" said nothing.** The heading is now the name of the action in it — "Quitter le groupe", or "Supprimer le groupe" for the owner, who sees a different button. Worth stating because it is not quite what was asked for: a fixed "Quitter le groupe" would have sat above a delete button for whoever owns the group. The red goes with it; only the button is red, which is where the warning belongs. `members.danger_title` is gone from all ten locales rather than left behind unread. **A folder name no longer ends in a slash.** The folder icon in the cell beside it already says what it is. **The transfers widget turns green while transfers run.** The badge counts them, but a count has to be read; colour is what carries from across the room, which is the point of a widget in the nav bar rather than on the page. Derived from the live list on every render, so there is no state that can forget to clear when the last transfer ends. The class is set in `app.js` and coloured in `style.css` — either alone does nothing and neither fails loudly, so there is a test for each half. 848 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: two waits with no deadline, resume positions per account, group ↵Christophe Besson2026-08-181-136/+242
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | settings tab **Joining a group could hang.** Reported after a first attempt that never finished and a later one that worked — the shape of a network wait with no deadline, and there were two. Signaling here is non-trickle: the offer is not sent until ICE gathering says it is done. A STUN server that is slow, filtered, or resolved through a DNS that is not answering means `icegatheringstatechange` never reaches `complete`, and `connect()` never returns. Same shape as the fullscreen denial fixed yesterday: a promise that never settles leaves no error to find. Gathering now has four seconds, after which the offer goes out with what it has — host candidates are already there, which is enough on a LAN, and giving up instead would turn a slow STUN server into a refusal to connect. The second: `hub:fetch` in the desktop client had no timeout, so a host that accepts a connection and then says nothing holds the request for as long as the OS allows. `hub:probe` had one; the handler that carries signaling did not. Now thirty seconds — longer than the hub's own fifteen-second signaling wait, so it cannot abort a call that was about to succeed — and it says the hub did not answer rather than "fetch failed". **Resume positions belonged to the machine, not the account.** Stored as `mb:pos:<file>`, so a second account signing in on the same computer was offered "resume where you left off" in a film it had never opened. Wrong on its own terms, and a small disclosure of what the other person watches, since the offer only appears for files someone has actually been through. The account is in the key now. Positions written before this are deleted rather than re-keyed: there is no record of whose they were, and guessing hands them to whoever signs in next, which is the bug. **The staggered rules in the members table.** `display: flex` on the actions `<td>` — a flex table cell stops being a table cell, so it no longer stretches to its row and its bottom border is drawn wherever its own content ends. Measured: in a row whose other cells were `top 76, height 40`, that cell was `top 77, height 30`, its rule nine pixels above the rest. It is a table cell again, held open by a zero-width strut so the owner's row — which has no remove button — stays as tall as the others. Every cell now shares its row's top and bottom exactly, at 420px and 900px. **Members became Settings.** It was a list with three unrelated forms stacked above it, laid out with inline styles on whichever element needed them, and the group's own controls somewhere else entirely — leaving or deleting a group sat in the page header beside the title. Now one tab in sections: invitations, operator pairing, your devices on this node, leaving or deleting, and the roster last, since it is the only part with no upper bound. One consequence worth stating: the tab bar no longer waits for the node. Membership is hub-side, and gating it on a live connection would have made "leave this group" unreachable exactly when a node is down — which is when someone most wants it. Files and chat still need the node and say so. **A download button in the viewer**, beside the close button and in the same style, for both the video player and the file preview. 844 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>