aboutsummaryrefslogtreecommitdiffstats
path: root/packages
Commit message (Collapse)AuthorAgeFilesLines
* fix(client): report a directory result under the button that caused itChristophe Besson2026-09-072-9/+65
| | | | | | | | | | | | | | | | | | The message was the first thing in the section, wedged between the intro and the table header — above everything the eye has already moved past by the time it appears. It goes last now, under Add directory. And it was a `settings-hint`: dim grey body text. So "two roots would both be called uploads" read as a footnote about the section rather than as the reason nothing happened. A refusal is styled as one and carries `role="alert"`, so it is announced rather than only drawn; a success stays quiet. Measured in a real Electron window rather than assumed — the failing add driven through the typed-path form, then the message's box compared against the table's and the button's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* fix(client): New folder did nothing — prompt() throws in the desktop clientChristophe Besson2026-09-0714-22/+192
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The same `static/` tree is the web page and the application, and Electron does not implement `window.prompt`: Chromium leaves it to the embedder and Electron declines. It does not return null — it **throws**. The call sat above its own try, so clicking produced no folder, no error and nothing on screen to react to. A dead button, which is exactly how it was reported. Measured against this repo's own Electron 44 rather than assumed, because the first two diagnoses this session were reasoned and wrong: prompt('name?') -> Error: prompt() is not supported. confirm('sure?') -> opens a real modal alert('hi') -> opens a real modal So `confirm` and `alert` stay — a dozen call sites depend on them — and only `prompt` is banned. `test_no_prompt_in_the_spa.py` holds the whole tree to it, with the near-misses it must not flag (`mkdir_prompt`, `promptForName`). The name now comes from a field in the toolbar, which works in both clients and can show the node's refusal beside the input instead of after a dialog has closed. Navigating away drops a half-typed name: it would otherwise create the folder somewhere the person is no longer looking. The rest of the chain was verified end to end and was sound: `dir_create` {dir,name} → the node's handler → `dir_create_ack`, and `list_dirs` walks the filesystem rather than the index, so a folder with nothing in it appears on the very fetch that follows. The field itself was then driven inside a real Electron window — typing, Enter, the click, and the icon rendering. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* feat(client): bring back New folder, icon-only — and close the hole it openedChristophe Besson2026-09-065-1/+115
| | | | | | | | | | | | | | | | | | | | | | | | | The control was hidden and its `canCreateDir` left computed and unused. It is back in the Files toolbar as an icon: the toolbar already carries one labelled primary action, and a second beside it competes for the width the breadcrumb trail needs. The name is in `title` *and* `aria-label` — a title is invisible to a screen reader on a button with no text, so an icon-only control without both is simply unnamed for anyone not reading with their eyes. Its gate changes. It required `isNodeAdmin`, which contradicted the node's own rule — "making a directory is not a privileged act; a member who can add a file can organise where it goes" — and hid the control from everyone who could have used it. It now follows the Upload button: a writable root, and not at the top of a group, where the level is the set of roots rather than a directory on anyone's disk. Restoring it surfaced a real gap. `_do_dir_create` never learned about RO/RW: `_do_file_upload` gained the `writable` check with the model and this one did not, so a member refused a file in a published library could still leave empty directories all through it, and could write to a drive mid-eject. Read-only has to mean read-only for every way of writing, not just for files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* fix: a root change reaches every client without a page reloadChristophe Besson2026-09-066-9/+169
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The directory table travelled on `index_sync` alone — a *full* index, which the node only ever sends on request. Every ongoing change went out as an `index_delta`, which carried files and nothing else. So the message that says "something changed" was the one message that could not say a root had. The acks hid it: `root_add_ack` and friends broadcast the new table to whoever is connected, so the common cases looked right. What that could not cover was the operator's own client, where the ack landed and was then overwritten — the table calls `onRefreshIndex` after an add, that fetch returns the set from *before* the node's reload (fire-and-forget, because a rescan is minutes on a real library), and `applyIndex` writes it over what the ack had just delivered. The new directory appeared for one paint and vanished. Two halves. `index_delta` now carries the roots table, sealed with the rest and identical to `index_sync`'s — additive, so a 1.0 client sees a field it does not read. And the table no longer refreshes the index after a root change: the ack gives it the new set immediately, and the delta the node pushes when the scan finishes gives it again, along with the files. The test that pins it uses an *eject* as its case, because an eject changes no file at all — the entries freeze — so its delta is empty of additions, deletions and updates. Without the table it says literally nothing, which is how a library disappearing from under a group went unannounced to everyone but whoever pressed the button. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* fix(node): `root list` printed "?" for every pathChristophe Besson2026-09-064-8/+147
| | | | | | | | | | | | | | | | | | | | | | | | | | | `RootSet.describe()` feeds two audiences that want opposite things. The index payload goes to every member and has always deliberately carried no paths — a member is told what exists and whether it is readable, not that the library sits under someone's home directory. The loopback API answers the operator themselves, over a channel that already requires their machine and the run token, and the path is exactly what they asked for. The `root list` CLI I added in Phase 1 read `path` from the member form, so it printed a placeholder for every directory. Nothing caught it: the CLI reads a dict, the API returns a dict, and neither end states which keys it owes. `describe(with_paths=True)` is the operator's view and `list_groups` is the only caller. The shared-directories table has the same hole over MNP — the roots there come from the index payload — so its Path column now appears only when a path is actually present, rather than rendering a column of blanks. The test asserts both halves, because they pull opposite ways: one that only checked the operator sees paths would be satisfied by leaking them to every member. It reads the indexer's source for the member side, and compares the CLI's key reads against what the payload offers for the other — checked to fail in each direction independently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* feat(node): an upload lands in the folder it was sent toChristophe Besson2026-09-0611-82/+144
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | There is no `uploads/` subdirectory any more, and the client names the folder rather than the root. It was the last of v5's quarantine — the per-user layer went on 2026-08-14 for the same reason — and it goes on the same grounds: a folder appearing beside the operator's library because somebody sent a file is the node deciding how their disk is arranged. Somebody dropping a file into the folder they are looking at expects it to be in that folder. **What made the quarantine worth having was never the subdirectory.** It is the filename allowlist, the size cap, the chunk ordering and the no-overwrite rule, and all four are untouched: an existing file is never replaced, the second sender of IMG_1234.jpg gets a free name, and the check still sits at the write. Letting the client choose the destination is safe for one reason and only one: it is resolved through `RootSet.resolve()`, which refuses `..`, absolute segments and anything whose resolved form escapes its root, symlinks included. A member answers "which of this group's folders", never "which path on the operator's disk" — and the test that used to assert the node chose now asserts that, with six shapes of escape. `direct` goes with it. Its only job was to say "no subdirectory for this root", which is now every root, and a config flag that does nothing is worse than none. Chat's attachment folder finally does something: the directory the operator picks in the Chat settings pane is where attachments are written, falling back to the first writable root while they have not chosen one, or if the one they chose has since been made read-only or ejected — a stale choice should not become a refusal at send time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* fix(node): the MNP root path never reloaded, and my first repair made it worseChristophe Besson2026-09-063-96/+277
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The previous commit was the wrong fix. `add_root` did leave the running node unchanged, but editing the live `RootSet` in place — which is what I did — is wrong in the other direction. `DirectoryIndexer.retarget` decides what to scan by diffing the names it already holds against the ones it is handed, and `_retarget_indexer` hands it `groups_ctx[gid]["roots"]`: the very object the op had edited. So the new root sat on both sides of the comparison, nothing was scanned, and the directory would have appeared in the table permanently empty. `_reload_config_inner` diffs the same way and would have concluded nothing changed. `remove_root` had the same shape and would have kept serving a removed directory's files. The real defect is that two front doors did different things. `ui/app.py` has always fired the daemon's `reload_fn` after these ops, which re-reads node.toml and builds a *fresh* set; the MNP path retargeted a stale object instead. That asymmetry is exactly what `ops.py` exists to prevent, and it is why the bug survived until an operator added a directory from a browser — the loopback path worked all along. So: the ops leave the live set alone, `_retarget_indexer` asks the daemon to reload, and `update_root` keeps editing in place because flags change no files and the synchronous upload handler reads that object on the next request. The tests now check the files rather than `describe()`, which proves nothing about whether anything was scanned. One of them demonstrates the failure mode instead of describing it, so the rule is checkable and will say so if `retarget` ever changes. Two more cover the seam itself — that the MNP path reloads, and that a context with no daemon still retargets. Diagnosed by reading the running node's journal rather than the source: the first add logged "Reloading config" and a rescan, the two later ones logged neither. I should have looked there before the first attempt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* fix(node): adding a root reached node.toml but not the running nodeChristophe Besson2026-09-062-1/+214
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `add_root` appended to the config and to node.toml and stopped there. `_retarget_indexer` — the MNP path — then re-points the indexer at `groups_ctx[gid]["roots"]`, an object nobody had touched, so it was retargeted at exactly what it already had. The directory was in the config file and invisible everywhere else until a restart. Worse than invisible: the ack does carry the new set, so the client showed the directory for one paint and the next index_sync took it away again — which reads as a UI bug and is not one. Adding it a second time was then refused as colliding with itself, which is the only reason anyone found out. `remove_root` and `update_root` already updated the live set; this one was missed. The loopback API hid it, because `ui/app.py` fires `reload_fn()` after the op and that re-reads node.toml from disk. The MNP path does not, and the shared-directories table only started offering Add over MNP in this refactor — a latent bug made reachable. The ack now describes the set the node will actually serve rather than one built on the side, so the two cannot disagree. test_root_ops_reach_the_live_set.py holds all three ops to it, including the counter-property that the same directory is still refused twice and that node.toml and the live set stay in step — the two halves drifting is how an operator's next restart silently undoes their last change. Four of its seven fail against the code above. Recovery on a node already in this state is `meshbay-node reload`: node.toml has everything, nothing was lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* fix(client): the folder picker had no background, and its field no layoutChristophe Besson2026-09-0613-29/+201
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two defects from one screenshot, and a test for the class behind the first. **The modal was transparent.** `.ftp-panel` asked for `var(--bg-panel, var(--bg))` and this stylesheet defines neither — the palette is `--bg-base`, `--bg-surface`, `--bg-raised`. An unknown custom property makes the whole declaration invalid while the rule around it still applies, so the panel simply had no background and the page showed straight through it. Three more of mine were the same: `--bg-hover`, `--bg-input`, `--danger`. **The field had no layout.** It was built on `.settings-row`, which is `display:flex; justify-content:space-between` — so a label, a hint and a value inside one end up spread across a single line in source order, which is how it read as three unrelated fragments per app. It is its own block now, and the chosen folders are a table borrowing `.shared-dirs-tbl`: these are lists, and a wrapped run of chips gives nothing to scan and nowhere to put a per-row remove. The operator is looking at two lists of directories on one page and they should read alike. `test_css_variables.py` is the general form. CSS fails silently and generously here, and nothing checked. It found four more that predate this branch: the unread-count badge (`--danger`) had white text on nothing, a transfer link had no colour, a notification card had no rounding and no unread marker. Fixed, and `--warn` and `--accent-bg` are promoted from literal fallbacks to real palette entries at exactly their current light values. Two of its own regexes were wrong before they were right — a scoped definition written inline, and one preceded by a comment, were both reported as undefined. A third check comparing the two palettes fired on `--border-focus`, a focus ring the themes share deliberately; a heuristic that has to be explained away on its first run is worse than no test, so it is gone rather than exempted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* fix(client): degrade against a node still speaking MNP 1.0Christophe Besson2026-09-0616-6/+288
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The SPA is served by the hub, so deploying the hub puts this client in front of every node — including the ones not updated yet. That window is the normal state for as long as an operator takes, and for a node someone else runs it may be indefinite. Three controls were broken across it, and the failure mode is quiet: an unknown message type is logged by the node and never answered, so the click produces a thirty-second wait ending in a timeout with nothing on screen to say the node simply cannot do this. Files' Upload button read `root.writable`, which a 1.0 node does not send — it says `upload`, the same answer under the older name. The button disappeared on every un-upgraded node. It reads both now, and still respects an explicit `writable: false` rather than falling through to the legacy flag. The per-app folder pickers spoke `app_directories`. Videos, Music and Photos each had their own message before that and those still work, so the page chooses by version: an operator on an older node keeps the ability they had. `video_root` and `audio_root` hold one folder, so several are refused with a reason rather than stored as the first and silently truncated. Root management — writable, removable, eject, plug — has no older equivalent to route to, so the table goes read-only with a line saying why and pointing at the `meshbay-node root` commands. Chat's two settings are new with nothing before them and are hidden the same way. None of this was inferred from a payload's shape: `_checkNodeVersion` already parsed the node's version and threw it away, and it is kept now. Coupling a capability to whether some field happens to be present is how a flag flips because an unrelated payload changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* fix(client): a successful root op must never blank the operator's tableChristophe Besson2026-09-063-3/+104
| | | | | | | | | | | | | | | | | | | | | | | `ops.update_root` and `remove_root` returned `[]` when the group context had no live RootSet, and `group-page.js` accepted it: `if (msg.roots)` is true for an empty array. An op that succeeded would have emptied the shared-directories table, and "the node says this group has no directories" is not something the client can tell from "the node could not say". Both ends now refuse it — the node builds from config rather than answering empty, and the client requires a non-empty array. Found while adding `test_spa_imports.py`, which is the other half of this: it resolves every named import across the SPA against what the target actually exports. That failure has a shape nothing else here catches — no build step to fail, so the browser resolves the graph at load, finds a missing binding, and the page renders blank or the component just does not appear. `node --check` parses one file at a time and the source-reading guards look inside a file rather than between two. The settings split moved two shared components into a new module and rewired eight files to import them, which is exactly the change where a rename lands in one file and not the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* feat(client): Phase 2 — per-app settings panes, folder tree, multi-directoryChristophe Besson2026-09-0632-641/+1738
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Each app's settings were inlined in `group-settings.js` — TMDB, MusicBrainz, and one folder picker per app, each with its own draft state and save handler saying the same thing about a different key. They are one file per app now, reached through the `apps.js` registry, and the page that renders them names no application at all: adding one is a registry entry and a settings file. The line between the two is what makes that true. What every app has — folders — the page does generically, through one `saveDirectories` bound to the app. What one app alone has, its pane does itself with the transport it is handed. An app that only needs directories touches neither `group-settings.js` nor `group-page.js`, which is `test_app_settings_plugin.py`'s subject. `settings-ui.js` exists because a pane importing the page that renders it is a cycle, and ES modules answer that with a temporal-dead-zone ReferenceError at first render — a component that silently does not appear, the fault already recorded in CLAUDE.md about hook ordering. The flat depth-indented `<select>` of every folder in the library becomes a modal tree. It asks the node for nothing: the tree is derived from paths the client already holds, so it shows exactly what the group's index contains and adds no folder-browsing protocol. For Chat's attachment folder — the one directory that is written to rather than read — read-only roots are greyed out, so the node's refusal arrives before the operator picks rather than when somebody sends a file. Videos and Music take a list of folders. A library on two drives could not be described before; the only recourse was pointing the app at a parent containing both, which pulls in everything else under it. The scalar shapes survive on the wire alone, for a node speaking MNP 1.0, and the client reads them as a one-element list. Two things the tests caught that I would not have: `test_asset_versioning` — six new modules were missing from `_ASSETS`. Reached through the registry rather than imported by name, they are exactly the files nothing else would notice changing, and a stale one is served from cache with no version bump. And `node --check foo.js` does **not** reliably report a module syntax error: it accepted `${/* ... */''}` — htm template syntax pasted into a plain object literal — and reported success. A `.mjs` copy forces the module parser and reports it. The suite had no syntax check at all, which is how that reached a file; `test_spa_syntax.py` does it for every module now, and pins that the loose path is not what it uses. Suite: 12 failures, all pre-existing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* feat(node): Phase 2 server side — one directory setting for every appChristophe Besson2026-09-0614-173/+875
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `video_root` (a string), `audio_root` (a string) and `photo_roots` (a list) said the same thing three ways: three roster accessors, three ops, three MNP messages, three admin-op subjects. They become `set_app_directories(app_key, paths)` and its single-directory wrapper, stored under `<app>_directories` and keyed by the app's registry name — so an application can be added without touching this layer, which is the whole claim of the plugin architecture. The three old names still work. Their MNP messages are handled, and the roster falls back to the old key when the new one is unset, so a node upgraded into this keeps working with no migration step — the plan called for a script, and a script nobody runs on the machine where it matters is worse than a fallback. Two things are new rather than moved: The paths are validated. The setters this replaces accepted anything, so a typo — or a path left behind when a root was removed — was stored happily and then matched no entry, leaving an app showing an empty tab with nothing to distinguish "misconfigured" from "no files yet". Deliberately not `RootSet.resolve()`: that also refuses a currently-unavailable root, and an operator must be able to point an app at a library on a drive they ejected. The legacy scalar is derived, never stored. `video_root` still rides on the handshake ack for MNP 1.0 clients; kept as a second stored value it would drift from the list within one run, which reads as "it works after a restart". Also here: chat's own two settings (a directory, which must be on a read-write root because it is a destination rather than a view, and a link-preview switch gating the unfurl path — checked before the cache, or turning it off would still serve every preview already fetched), the `app_directories`, `chat_directory` and `chat_link_preview` MNP messages, the plural `<app>_directories` on the handshake ack, and `music` as the app's one identifier where storage said `audio` and the registry said `music`. The Music enricher now resolves a boundary per configured directory rather than one for the group: with several, a single boundary is wrong for all but one of them, and for Music that is the difference between reading a folder as an artist and reading it as a release. Suite: 11 failures, all pre-existing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* test(node): the backslash-path test modelled the wrong platformChristophe Besson2026-09-061-6/+40
| | | | | | | | | | | | | | | | | | | | It asserted `Path(win_dir).as_posix() == "C:/Users/alice/Media"`, which is only true where `Path` is a `WindowsPath`. On every other machine a backslash is an ordinary filename character, `as_posix()` converts nothing, and the test failed against correct code — so it has never passed on this suite's usual host, and never guarded anything there. `PureWindowsPath` names the flavour and makes it the same assertion on all three platforms. The round trip also only ever proved that `as_posix()` produces a parseable string, never that the config writer calls it — which is the defect, and one no Linux machine can reproduce: the file is written, parsed and served correctly here and fails on the operator's Windows box. A second test reads ops.py for every f-string landing on the right of a TOML `path =` and requires `as_posix()` in it. Weak evidence, and the only kind available for a platform the suite does not run on; checked to fail with the call removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* fix(groups): finish Phase 1 — MNP root management, upload targets, eject stateChristophe Besson2026-09-0639-588/+1769
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Review of the Phase 1 commit found the RO/RW model sound but three paths unfinished, each of which broke the flow the phase exists to deliver. Plus 29 test failures it introduced and no coverage for anything it added. Uploads went to the wrong directory. The node read a `root` field on file_upload that no client ever sent, so every upload landed in the first writable root while the Files toolbar offered its button based on the root being browsed — with two writable roots, uploading from one wrote into the other. Files now names the root it is showing; Chat names one chosen in the shell (an operator-configured directory arrives in Phase 2); the node refuses an unknown name rather than falling back, and refuses read-only and ejected roots by code. Shared directories were unreachable on the web. The table read its roots only from the loopback API, which resolves to "not available" in a browser, so the section rendered for nobody there — while the Uploads controls it replaced had worked — and the transport.updateRoot/ejectRoot/plugRoot methods beside it were dead. MNP is now the path, loopback the fallback for a local node with no live connection, and adding a root over MNP takes a typed path since no web page can browse a remote disk. Ejecting updated nobody's screen. transport.js resolves an admin ack against the pending request and returns, which is right for every op whose caller knows the value it chose; the root acks carry state only the node can compute, so the operator who clicked Eject was the one client that never saw it happen. And the ejected flag reached roster.db but was never read back, so a restart undid it and the next scan read an empty mount point as an erased library. Also: the member-upload endpoint answered 200 and did nothing (removed); the wizard ignored the first root's RW switch; reload compared roots on name and path, so editing writable in node.toml did nothing; the table had no path column, which is the only thing separating two libraries sharing a basename; apps_enabled normalisation differed between the two sides of a signed subject. Tests: eject/plug, per-root upload refusal and the node.toml rewrite had no coverage at all. test_member_upload_policy.py is replaced by test_root_writable_policy.py — it tested a removed feature — and every property worth keeping from it moved rather than being dropped. Docs: draft-v6 structural decision 9 is annotated as superseded (the operator can no longer have a directory only they may write to — a real capability removed, flagged rather than hidden), the man page documents the root verb and the RO/RW fields, and refactor-groups.md §7b records what the plan got wrong. Suite: 41 failures before, 13 after — all 13 pre-existing on main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
* feat: groups refactor Phase 1 — root RO/RW model + shared directories UIChristophe Besson2026-09-0631-495/+1277
| | | | | | | | | | | | Replace the upload boolean with per-root writable/removable/ejected flags. Backend: new ops (update_root, eject_root, plug_root), MNP 1.1 protocol messages, live RootSet updates so API always reflects current state, CLI root subcommand (add/remove/set/list/eject/plug). Frontend: SharedDirectoriesTable with optimistic toggle switches, eject/plug in Files and Settings, upload gated on root.writable, ejected-root filtering in all media apps, updated Create Group wizard, 10-locale i18n. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore(client): bump electron 44.1.1 → 44.2.0 in package.jsonChristophe Besson2026-09-061-2/+2
| | | | Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(node): inline @owner next to group name, fix buffering layout shiftChristophe Besson2026-09-062-13/+16
| | | | | | | | | Put the @owner handle on the same line as the group name (flex baseline) to save a row on mobile. Move the video buffering indicator inside the video-container as an absolute overlay so it no longer pushes the video down when it appears briefly during seeks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(node): add ".." parent row in file explorer, hide mkdir buttonChristophe Besson2026-09-062-6/+15
| | | | | | | | | Show a ".." row at the top of subdirectory listings to navigate back to the parent. No path traversal risk — currentPath is internal state built from the node's own index. Hide the mkdir button (not useful yet); update the upload-controls test marker accordingly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(node): prevent watchdog from overwriting index entries with duplicate ↵Christophe Besson2026-09-061-3/+9
| | | | | | | | | | | | | content The real-time watchdog path lacked the duplicate-content guard that reconciliation already had. When a file with identical content appeared (e.g. browser download appending " (2)"), add_entry overwrote the original's index entry — making it vanish from the file list despite still being on disk. Now check get_entry before adding, matching the reconciliation logic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(node): always-on file selection with select-all checkboxChristophe Besson2026-09-061-27/+30
| | | | | | | | | | Remove the Select/Done toggle — checkboxes and action buttons are always visible. A select-all checkbox in the header row follows the standard pattern: check selects all visible items, uncheck clears the entire selection, indeterminate when partially selected. Navigation and file preview are unaffected (only the checkbox selects). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore(client): update lock file (electron 44.2.0, version 1.0.0)Christophe Besson2026-09-061-6/+6
| | | | Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(node): indexing v2 — partial-read hashing for files above 40 MBChristophe Besson2026-09-065-40/+307
| | | | | | | | | | | | | | | | | Files above 40 MB are no longer read in full. Instead, blake3 hashes 45 MB of samples (first 20 MB + last 20 MB + 5 MB at 50% offset). Files at or below 40 MB are unchanged (full read, hash_version 1). A new `hash_version` field on IndexEntry (default 1) travels on the wire and through the cache so both versions coexist without breaking existing nodes or clients. The IndexCache auto-migrates its schema on open (ALTER TABLE), so no manual step is required on upgrade. A standalone migration script is available in QE/migration/ for operators who want to preview or force a full re-hash. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(win): finish the desktop setup flow — node-key link + service taskChristophe Besson2026-09-052-1/+88
| | | | | | | | | | | | | | | | | | | | | Two independent breaks in the Windows first-run path: - node:start's win32 branch never linked the node's Ed25519 key to the hub account, so the daemon sat at waiting_for_account and the Create Group wizard span on "Detecting local node…" for ever — the only way through was pasting the key by hand on the Profile page. The Linux branch has always done this inline; factor it into linkNodeKeyAndAwaitRunning() and call it from win32 too. PUT /v1/users/me/node_key overwrites, so this also recovers an account still carrying a previous machine's node key. - service.ps1's install branch did `$action = New-ScheduledTaskAction`, shadowing its own [ValidateSet(...)][string]$Action parameter (PowerShell variable names are case-insensitive). The CimInstance was coerced to the string "MSFT_TaskExecAction", Register-ScheduledTask -Action rejected it, and "background service" mode never created the task — reproduced live. Rename the locals to $taskAction / $bootTrigger / $taskPrincipal. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* chore: bump all packages to 0.11.00.11Christophe Besson2026-09-056-6/+6
| | | | Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(client): system tray on Windows, and a clearer tray iconChristophe Besson2026-09-055-8/+42
| | | | | | | | | | | | | | | The tray's Start/Stop already drove nodeService.status/stop/restart, which had full win32 branches for both startup modes from the Node page work -- so enabling it on Windows is widening two platform gates (the `tray` capability in preload.js, the window:minimize-to-tray handler in main.js), not new logic. Replaced the tray icon: the previous white chevron-in-a-box read as an envelope at tray size. New icon is a small "M" drawn as mesh nodes and edges, echoing the app icon's own motif, in the brand blue instead of plain white so it stays legible on both light and dark taskbars/panels. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(node): register the service-mode task with Register-ScheduledTask ↵Christophe Besson2026-09-053-37/+114
| | | | | | | | | | | | | | | | -LogonType S4U schtasks.exe has no flag naming the logon type directly -- it only infers S4U vs Interactive from whether /rp is present, and both readings broke live on a blank-password account: /rp "" fails schtasks' own credential validation, and omitting /rp registers "Interactive only", which never launches the process at boot or on demand despite installing cleanly. Register-ScheduledTask -LogonType S4U names the logon type explicitly, no inference. Confirmed live: install, manual start, and unattended boot-time start all now work. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(client): hide the tray button on the narrow layoutChristophe Besson2026-09-051-0/+5
| | | | | | | | | | | | Same 768px breakpoint the hamburger appears at, where the nav is tightest and the button has least to offer. Belt and braces rather than the only guard: `capabilities.tray` is false without the Electron bridge, so no browser has ever rendered this. What this covers is the app's own window dragged narrow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtfG7z6wHWj8RKHCvxQtY1
* feat(client): tray menu starts and stops the node, and a clearer iconChristophe Besson2026-09-0515-26/+105
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-0518-7/+152
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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(win): graceful shutdown, one startup-mode control, and a stray-\r bugChristophe Besson2026-09-0516-103/+558
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Windows-only changes, all found by actually running the previous session's work rather than by review alone: - CTRL_CLOSE_EVENT/LOGOFF/SHUTDOWN handler (platform.py, ctypes SetConsoleCtrlHandler) so closing a console window, signing off, or a system shutdown runs the daemon's real _shutdown() instead of Windows just ending the process — closing WebRTC sessions and any in-flight ffmpeg transcode instead of orphaning it. `taskkill /F` itself stays uncatchable (like SIGKILL), so autostart_run() now spawns with CREATE_NEW_PROCESS_GROUP instead of DETACHED_PROCESS and autostart_end() tries CTRL_BREAK_EVENT against the recorded pid first, falling back to the hard kill only if that doesn't stop it in time. - Replaced the Node page's two independent autostart/service-mode toggles with one "start automatically" select (off / at sign-in / as a background service). The old pair let both be active at once — starting the daemon twice, at boot and at sign-in — and their layout broke wrapping inside .node-service's flex row. The new control always removes whichever mechanism is active before installing the target; platform.py's service_install() does the same on the CLI side. The "background service" option disables itself (with a hint pointing at the CLI) when running unpackaged, since service-mode.ps1/service.ps1/firewall.ps1 all assume an installed build's layout — verified live rather than assumed by actually running those scripts unelevated. - findNodeBinary() no longer bakes a stray \r into resolved paths. Found by rebooting after enabling per-user autostart: where.exe listed two matches, and stdout.trim().split('\n')[0] only strips the whole string's ends, leaving line one's own trailing \r attached — which landed inside the Startup .vbs's quoted path and broke it with "Unterminated string constant" at boot. Fixed by splitting on \r?\n and trimming every line. - Dependency audit for the Windows installer (docs/WINDOWS-PORT.md): no VC++ Redistributable needed, confirmed by inspecting the built node-runtime's actual import table rather than assuming. New docs/windows-build.md: a concise clone-to-installer build guide. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(client): a Node-page toggle to switch into/out of service modeChristophe Besson2026-09-0515-0/+200
| | | | | | | | | | | | | | | | | | | | | | | | | | | | The installer's own mode question is effectively one-shot: customInstall skips it entirely once the firewall rules already exist, for any reason -- and per-user mode sets those up on its own, with no Scheduled Task involved. So declining once (or the rules existing from something unrelated, as happened on a dev machine this session) was a dead end: no reinstall, repair, or uninstall/reinstall cycle could ever bring the question back, since uninstall defaults to leaving both alone. Add the other door in (and out): a checkbox on the Node page, next to the existing per-user autostart toggle, wired main.js -> preload.js -> platform.js -> node-page.js. It runs packaging/win/service-mode.ps1 -- the exact script installer.nsh already runs -- via one Start-Process -Verb RunAs elevation, so the two paths can never disagree about what service mode means. The elevation helper writes a tiny param()-based .ps1 to %TEMP% so the target script path and its arguments bind through real PowerShell parameters instead of nested string-quoting. Also fixes a real pre-existing gap found while checking this: 8 of the 10 locale catalogues (all but en/fr) were missing the autostart/service-mode keys added in an earlier commit this session (b782886) -- test_locales.py's key-set-parity check uses a for-loop with an inline assert, so it stopped at the first mismatch (fr) and never actually reached the other eight. Backfilled all five keys (three pre-existing, two new) in de/es/it/ja/nl/pl/pt-BR/zh-CN. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(packaging): bundle ffmpeg in the Windows installer by defaultChristophe Besson2026-09-052-1/+90
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | winget install ffmpeg was considered and rejected as the mechanism: it needs network access and winget/App Installer present at the exact moment setup runs, and its failure mode is silent -- video just does not stream, with nothing pointing back at ffmpeg. Not viable for a non-technical install. MeshBay transcodes browser-incompatible video to H.264 (-c:v libx264, webrtc_server.py) -- a real encode, not remux -- so this needs a genuine GPL ffmpeg build; no LGPL-only build includes an H.264 encoder, since libx264 itself is GPL. packaging/win/fetch-ffmpeg.ps1 (new) Downloads, checksum-verifies and stages ffmpeg for the build. Source: BtbN/FFmpeg-Builds' Windows x86_64 gpl-shared preset -- shared DLLs rather than two independent static binaries, which is what nearly tripled this: the "full" static build many devs already have via winget is ~220 MB *per executable*. Pinned to one dated release tag (immutable once published) and its own sha256, not the "latest" alias BtbN repoints on every auto-build -- verified by hand first (downloaded, hash matched, ran a real encode+probe with libx264) before pinning. ffplay.exe (an SDL2 player, ~17 MB) is dropped; MeshBay never invokes it. Cached after the first build. Runs its own smoke test (encode + probe a real clip) so a broken fetch fails at build time, not for the first user who tries to watch something. packaging/win/LICENSE-ffmpeg.txt (new) GPLv3 notice + where the corresponding source is, required because this redistributes a GPL binary even though it is unmodified and only ever invoked as a subprocess. Ships alongside ffmpeg.exe in the installer. build-node-runtime.ps1 / build-win.ps1 Bundling is now the DEFAULT, replacing the old opt-in -FfmpegDir (which copied from a local directory and left most builds without ffmpeg at all). -SkipFfmpeg opts out for a smaller, streaming-less local-iteration build. Also fixes a real bug the ffmpeg change exposed rather than caused: the final `--help` smoke test did `$help -notmatch "meshbay-node"` against $help captured as a PowerShell ARRAY (one element per line) -- -notmatch on a collection is a FILTER, not a boolean test, and returns the non-matching elements; any non-empty array is truthy in if() regardless of content. Once --help wrapped past one line (it now does, with autostart/service in the verb list) this threw unconditionally. Fixed by joining to one string before matching, and pinned by a new test so a future edit cannot silently reintroduce the collection-vs-scalar trap. Verified: downloaded and hashed the pinned release by hand (matches), ran a real libx264 encode + ffprobe against the extracted build, fetch-ffmpeg.ps1 end to end (161 MB staged), a full build-node-runtime.ps1 run (308 MB node-runtime/) and a full installer build (MeshBay-Setup- 1.0.0.exe, 210.8 MB with ffmpeg bundled). Node suite 850 pass / 25 skip. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* test(node): pin the fresh-install-before-provisioning safety propertyChristophe Besson2026-09-042-0/+61
| | | | | | | | | | | | | | | | | | | | | | | | | | | Two properties discussed but not yet pinned by a test, both load-bearing for service mode: 1. A bare `meshbay-node` with no config yet -- exactly what the W3 Startup .vbs and the service-mode Scheduled Task both run unattended, on the very first boot after a fresh install, quite possibly before the user has ever opened the client -- must fail closed, fast, and without a trace. Measured by hand first (under a second, zero bytes written against a real empty %LOCALAPPDATA%); this pins it as a test so it can't regress silently. load_config() already returns an empty Config on a missing path rather than raising, so main() reaches its own "hub.username not set" exit before ever touching NodeDaemon() or asyncio.run() -- nothing here has to mock the daemon startup. 2. node:start (main.js) must call provisionNode() before it ever checks for the service task or spawns -- reversed, the wizard's first Start on a fresh service-mode install would run/query the daemon before node.toml exists for it to read. Source-read, same technique as test_desktop_shell.py: the only evidence available without a live Electron run. Node suite 845 pass / 25 skip. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat: opt-in Windows service mode (boot-time, one elevation) + v1.0.0Christophe Besson2026-09-0410-74/+430
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The per-user Startup-folder launcher (W3) only ever runs after this user signs in. A real Windows Service would start earlier, but under LocalSystem/NetworkService -- accounts with no normal profile, so %LOCALAPPDATA%\meshbay\ (config, keystore, data) would not exist for it. Relocating storage to make that work is real surgery, deliberately not done here. Instead: a Scheduled Task, created once with admin rights, that runs AS THIS USER at boot without needing them to sign in first. `schtasks /create ... /ru <user> /rp ""` with no `/it` registers an S4U (Service For User) logon -- no password stored anywhere, and unlike LocalSystem it loads this account's own profile, so config_dir()/ data_dir() need zero changes. The cost: S4U carries no network credential, which the node never needed -- everything it touches is local disk plus outbound internet. Creating the task needs admin (a boot trigger touches system-wide scheduler state, the same reason /sc onlogon needed it); querying/starting/stopping an existing one does not -- Task Scheduler grants the owning user that much itself, which is what lets the Node page's Start/Stop/Restart drive it with no further UAC prompts. meshbay_node/platform.py service_install/_remove/_status/_run/_end -- mirrors autostart_* but for the Scheduled Task; TASK_NAME moved here (was decorative before) meshbay_node/daemon.py new `service install|remove|start|stop|status` verb; restart-daemon and reset now check for the service task too packaging/win/service.ps1 the installer-side equivalent (extraResource); status/run/end never self-elevate -- only install/remove do, exactly matching what Task Scheduler itself requires packaging/win/service-mode.ps1 ONE elevated helper running service.ps1 + firewall.ps1 together, so choosing service mode costs exactly one UAC prompt, not two build/installer.nsh the install-time choice: "run as a background service?" (one elevation, both jobs) vs the existing per-user + separate firewall question. Checked first, unelevated, so re-running setup with everything already configured asks nothing. Uninstall offers the matching one-elevation cleanup, default No. src/main.js winServiceTaskStatus/Run/End, wired into node:installed, node:service-status/-stop/-restart and node:start: when the Scheduled Task exists, drive it; otherwise fall back to the existing per-user spawn/kill path. This is the hard requirement -- Start/Stop/Restart from the Node page must work in either mode. node-page.js / locales a hint explaining why the per-user autostart toggle is absent when service mode is active (info.mode from the backend, no new field to gate on -- it just isn't sent in that case) package.json: 0.1.0 -> 1.0.0. Verified: electron-builder compiles the new NSIS choice logic and ships all three scripts; service.ps1's S4U install fails cleanly (Access denied) when run unelevated, and its status/run/end never touch "runas". Cannot verify the elevated success path myself (no admin in this session) -- that needs a real UAC click. Node suite 843 pass / 25 skip; test_packaging_win.py pins the one-elevation property, the S4U flags, and that main.js actually checks the service task in all three handlers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(client): Stop/Restart buttons and an autostart toggle on the Node pageChristophe Besson2026-09-045-2/+46
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | Root cause of both reports: node:service-status's `installed` field was winAutostartInstalled() -- whether the Startup-folder launcher exists -- not whether the daemon can be managed at all. The Node page gates Stop/Restart on `installed`, so with no autostart configured (the default -- nothing installs it automatically) those buttons silently never rendered, leaving only Start. A perfectly manageable daemon looked unmanageable because a different, unrelated setting was off. installed now reflects the actual daemon binary (findNodeBinary()), same definition node:installed already used; autostart moves to its own field carrying what installed used to mean. That field also fixes the other half: there was no way to turn autostart on except the CLI. NodeServicePanel now shows a toggle-switch next to Start/Stop/Restart, wired through a new platform.node.autostart (install/remove) that mirrors the existing service.* pattern -- the preload/main.js bridge (W3) was already there, just never called from the interface. English and French strings; other locales fall back to English per the project's own stated policy (test_locales.py's own docstring). Verified: node --check on every edited file; hub/node suites green (863 pass, 0 fail — pre-existing test_locales.py encoding failures on this Windows checkout are unrelated, reproduced identically on the clean tree). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(client): a failed spawn() of the node no longer crashes the appChristophe Besson2026-09-041-0/+11
| | | | | | | | | | | | | | | | | spawnNodeDetached() and the Linux dev-mode fallback in node:start called spawn() with no 'error' listener. A spawn failure -- bad path, a stale PATH entry, antivirus interference -- is delivered on that event asynchronously; with nothing listening, Node rethrows it as an uncaught exception and takes the whole Electron main process down with it, instead of the caller's own waitForNode() timeout turning "never came up" into a clean message. Hit directly: ENOENT spawning a stale dev-venv meshbay-node.exe (not the installed one -- where.exe correctly resolves to the bundled exe now). Whatever the trigger, a daemon that fails to start must never be able to take the renderer down with it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(packaging): re-running setup no longer re-prompts for firewall accessChristophe Besson2026-09-042-13/+39
| | | | | | | | | | | | | | | | | | | Every run of customInstall showed the "Allow MeshBay through Windows Firewall?" question and, on Yes, a fresh UAC prompt -- an upgrade or repair install would ask again even with all four rules already in place. customInstall now checks first: firewall.ps1 check, unelevated (Get-NetFirewallRule needs no admin, only New/Remove do), exits 0 if every rule already exists. Only a nonzero result reaches the MessageBox and the elevated add. A second run of setup on an already-configured machine now asks nothing. Verified unelevated: check exits 1 and logs which rules are missing on a machine with none of them (the fresh-install case); electron-builder compiles the nsExec::Exec / Pop $0 / ${If} wiring. Node suite 837 pass / 25 skip. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(packaging): cover LAN casting in the Windows firewall step, tooChristophe Besson2026-09-041-0/+24
| | | | | | | | | | | | | | | | | | | | | | | firewall.ps1 only handled WebRTC. The cast HTTP relay (src/cast-relay.js, fixed TCP 19550-19553) and Chromecast/Smart TV mDNS discovery (src/cast-chromecast.js, bonjour-service, UDP 5353) are a separate surface on the client and were still hitting Windows' own "Allow access" dialog. Now four named rules: MeshBay / MeshBay Node stay program-scoped with no port restriction (there is no fixed WebRTC port to name -- the Windows equivalent of the broad 1024-65535/udp range the Linux packaging needs, since netfilter has no program scoping); MeshBay Cast / MeshBay Cast Discovery add the program AND the fixed port, matching packaging/firewall/*/meshbay-cast.xml exactly. test_packaging_win.py cross-checks the port numbers against cast-relay.js's own constants and the Linux firewalld definition, so the three descriptions of one port range can't quietly drift apart. Verified: rebuilt MeshBay-Setup-0.1.0.exe; the deployed firewall.ps1 carries all four rules. Node suite 836 pass / 25 skip. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(packaging): offer one elevated firewall step instead of two dialogsChristophe Besson2026-09-043-4/+97
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Installing used to mean clicking through two separate Windows "Allow access" prompts later — one for MeshBay.exe, one for meshbay-node.exe — each confusing on its own and worse before the exe carried a version resource. Adding a firewall rule needs admin, and the installer is deliberately per-user with no elevation, so this can only ever be opt-in. packaging/win/firewall.ps1 (new, shipped as an extraResource at resources\firewall.ps1): idempotent add/remove of the two inbound UDP rules ("MeshBay", "MeshBay Node"), grouped, logged to %TEMP%\meshbay-firewall.log. Locates both executables from its own path, no arguments needed beyond the action. build/installer.nsh: customInstall asks "Allow MeshBay through Windows Firewall now?" and runs firewall.ps1 via NSIS ExecShellWait "runas" — one UAC prompt — only when not ${Silent}; declining or dismissing UAC falls back to Windows' own per-process prompts, unchanged. customUnInstall offers the same in reverse, defaulted to No (a stale rule for a deleted exe is inert, so this should not nag on the way out) and skipped for a silent uninstall. Verified: rebuilt MeshBay-Setup-0.1.0.exe (electron-builder compiles the new LogicLib.nsh / ExecShellWait NSIS successfully); firewall.ps1 run unelevated fails cleanly into its log ("Access is denied") rather than silently doing nothing, confirming the fallback path. Node suite 835 pass / 25 skip. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(packaging): stamp the frozen node exe, document Windows networkingChristophe Besson2026-09-041-0/+13
| | | | | | | | | | | | | | | | | meshbay-node.spec now builds a VSVersionInfo (ProductName "MeshBay Node", version read from the installed package so it tracks pyproject) and passes it to EXE(version=...). The Windows Firewall prompt, Task Manager and the file's Properties then show "MeshBay Node" instead of a bare exe name — the prompt the operator has to answer on first run. packaging/win/README.md gains a Networking section: the first-run firewall prompt (allow Private AND Public — a VM adapter is Public), that a flat LAN needs nothing else, what a routed/multi-subnet LAN additionally needs, the libvirt-NAT caveat, and a pointer to the packaged Linux firewall profiles. test_packaging_win.py covers the version resource. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(node): log the host candidates the WebRTC answer offersChristophe Besson2026-09-041-2/+15
| | | | | | | | | | | "DataChannel closed" from a peer and a clean node log look identical: the answer-ready line reported only the srflx count, not the host addresses. On a NAT'd host or a VM the sole host candidate is an address no other machine can route to, and that is exactly the case you cannot see. The line now reads `... host: 192.168.200.173, 1 srflx`, so "did the node offer anything routable" is answerable from the journal. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix: drop the retired Mozilla STUN server from the defaultsChristophe Besson2026-09-043-3/+6
| | | | | | | | | | | stun.services.mozilla.com no longer resolves — Mozilla shut the service down — so every ICE gather waited out a DNS timeout on it. Removed from the node defaults (config.py), the browser defaults (transport.js) and the Node page's "reset to defaults" (node-page.js). Google (two endpoints) plus Cloudflare still give two-provider coverage against a single outage, which is the §2.12 resilience claim. draft-v6 §2.12 updated. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(node): say so when MusicBrainz lookups are inertChristophe Besson2026-09-042-1/+30
| | | | | | | | | | | | | | Staying inert without a contact is the documented policy (module docstring, musicbay.md §3.1): the usage policy wants a contact in the User-Agent, so an unidentified client is never sent. Staying *silent* about it was not a decision — the operator sees Music tiles with no metadata or cover art and has nothing to search the logs for. Warns once per client rather than once per lookup, since the condition is constant for the client's lifetime. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtfG7z6wHWj8RKHCvxQtY1
* fix(packaging): actually ship the TMDB token, on Linux and WindowsChristophe Besson2026-09-043-5/+176
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | default.env was empty in every build, for three independent reasons: 1. build-node.sh read QE/node.env, which does not exist. Even pointed at the real file it would have failed: its `grep MESHBAY_TMDB_DEFAULT_TOKEN=` cannot match QE/tmdb.txt, which is a free-form note, not KEY=VALUE. 2. Nothing consumed default.env. packaging/README.md and build-node.sh both claimed `meshbay-node init` copies it to <config>/node.env; grep found the name in exactly two places, the README and the script that writes it. No code implemented the copy, and `EnvironmentFile=-` hid the absence. 3. build-win.ps1 had no env handling at all, so Windows was empty for a different reason than Linux. Now: the build extracts the v4 read token -- tmdb.py sends `Authorization: Bearer`, so it is the JWT, not the 32-char v3 key beside it in the same file -- matching KEY=VALUE first and then by shape, from MESHBAY_TMDB_TOKEN, MESHBAY_TMDB_TOKEN_FILE, QE/node.env, QE/tmdb.txt. It writes default.env 0600 and *fails the build* if no token resolves; MESHBAY_ALLOW_NO_TMDB=1 opts out. An empty default.env is invisible until a user opens Videos and finds no metadata, which is how this shipped empty on two platforms at once. platform.py gains packaged_default_env()/install_node_env()/load_node_env(). init copies the packaged file once, never overwriting an existing node.env, and the daemon loads node.env itself at startup: systemd does this on Linux via EnvironmentFile, but Windows autostart is a Startup-folder .vbs with no equivalent. Already-set variables always win. Also fixes an UnboundLocalError in main(): `config_dir` was assigned at the top of the init branch, which made it function-local for all of main(), while the reset branch calls `config_dir()` as the imported function. init returns before that line, so `meshbay-node reset` could only ever raise. The local is now cfg_dir. Verified end to end on Linux: token baked (239 chars), init writes <config>/node.env 0600 with it. The PowerShell half is written but unrun -- no pwsh on this machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtfG7z6wHWj8RKHCvxQtY1
* fix(node): make ice_interfaces match adapters on Windows (W9)Christophe Besson2026-09-043-19/+267
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `ice_interfaces` compared the operator's entry against ifaddr's `adapter.name` only -- the kernel name on Linux (`wlp3s0f0`), but the adapter GUID on Windows (`{846EE342-...}`). A setting written on Linux, or copied into a Windows node's node.toml, matched no adapter at all. The failure was silent and total rather than partial: aioice binds one socket per host address, so an empty list means no sockets, no host candidates, and an SDP offering only a reflexive address. The settings field is free text with no picker, and on Windows the operator sees neither the GUID nor the description -- `ipconfig` shows the connection name -- so an entry now matches the adapter name, the device description, or one of the adapter's own IPv4 addresses, case-insensitively. An address is the one identifier visible on every platform. A filter that matches nothing now falls back to the unfiltered list with a warning. Losing the 5 s timeout saving is a regression; being silently unconnectable is a defect. Also fixes IPv4/IPv6 discrimination in the same loop: the two were told apart by falling through to an `elif` that index-probed `ip.ip[0]` and `ip.ip[2]`, which on an IPv4 str yields characters that compared unequal by luck rather than by design. Now discriminated by isinstance. WINDOWS-PORT.md claimed Transport had "no platform dependency"; it does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtfG7z6wHWj8RKHCvxQtY1
* fix(client): publish the real local IP on every platform, not just win32Christophe Besson2026-09-041-10/+16
| | | | | | | | | | | | | | | | | | | | | | | | Chromium replaces host candidates with random `<uuid>.local` mDNS names. aioice has no mDNS resolver on any platform: it logs `Remote candidate "<uuid>.local" could not be resolved` and drops the candidate. Concealment therefore does not degrade for us, it deletes the only LAN-routable candidate and leaves reflexive pairs -- which fail whenever both peers are behind the same NAT, because that pair needs the router to hairpin. Measured with a Linux client and a node in a libvirt guest: exactly one pair formed, host -> srflx on a shared public address, five binding requests, zero responses, FAILED after 63 s. The same log shows a peer on the guest's own subnet succeeding in 24 ms with a real-IP host candidate. 5022f2b scoped this to win32 from a session where the guest ran the *client*, so the condition encoded "the machine being debugged" rather than "the peer is a node". The peer is always a node, on every platform. The trade is that our private address reaches the hub and the node in the SDP -- the user's own infrastructure, not an arbitrary page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtfG7z6wHWj8RKHCvxQtY1
* feat(packaging): put the bundled meshbay-node on the per-user PATHChristophe Besson2026-09-042-5/+55
| | | | | | | | | | | | | | | | The installer had no console entry point, so running the CLI meant cd-ing into resources\node-runtime\ every time. installer.nsh now adds that dir to HKCU\Environment\Path on install and removes it on uninstall, via stock WordFunc.nsh (electron-builder's NSIS bundle has no EnVar plugin). It points at the real meshbay-node.exe dir — not a shim — so `where meshbay-node`, findNodeBinary and the W3 autostart launcher all resolve to the same binary. A WM_SETTINGCHANGE broadcast nudges open shells; new ones just work. Rebuilt: electron-builder --win nsis compiles the WordFunc includes fine, MeshBay-Setup-0.1.0.exe (155 MB). test_packaging_win.py covers the add/remove symmetry. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat: Windows installer (W4) — one per-user NSIS package, client + nodeChristophe Besson2026-09-045-3/+211
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `npm run dist:win` produces MeshBay-Setup-<version>.exe: the Electron client and, beside it under resources/node-runtime/, the frozen meshbay-node daemon (meshbay-common inside it). No hub. Per-user, no elevation — matches the W3 constraint that a logon-triggered scheduled task needs admin. electron-builder / package.json build.win nsis, build/icon.ico, extraResources -> node-runtime/ build.nsis oneClick:false perMachine:false allowElevation:false allowToChangeInstallationDirectory:true dist:win -> packaging/win/build-win.ps1 (mirrors dist -> build-client.sh) packaging/win/ meshbay-node.spec + node-entry.py PyInstaller freeze of meshbay_node.daemon:main. The awkward deps (aiortc, av, aioquic, pydantic_core, uvicorn, watchdog, guessit, blake3, tzdata) are pulled in whole with collect_all — that list is expected to grow when a frozen run raises ModuleNotFoundError. build-node-runtime.ps1 throwaway venv -> pip install -> PyInstaller -> packages/meshbay-client/node-runtime/ (gitignored) build-win.ps1 Node>=22 check, npm ci, Electron bump, sync-ui, node runtime, electron-builder --win nsis bump-electron.mjs the Chromium-CVE "build against latest Electron" policy, out of the PS script (5.1 here-string terminator rules) README.md PyInstaller, not the python-embed zip: the frozen meshbay-node.exe is a genuine relocatable single binary, which is what src/main.js:findNodeBinary spawns (process.resourcesPath/node-runtime/meshbay-node.exe when packaged) and what the W3 autostart launcher points at. The embeddable zip needs pip to make that wrapper and the wrapper bakes in an absolute interpreter path. build/installer.nsh: on uninstall, taskkill meshbay-node.exe and delete the W3 Startup .vbs (it would point wscript at a deleted binary every sign-in). %LOCALAPPDATA%\meshbay\ — node.toml, keystore.enc — is never touched. ffmpeg is not bundled by default (node finds it on PATH); build-win.ps1 -FfmpegDir copies ffmpeg.exe/ffprobe.exe in for a self-contained installer. Verified on the Windows guest: PyInstaller freeze builds first try (node-runtime 147 MB), frozen `meshbay-node status` talks to the live daemon's loopback API; electron-builder --win nsis produces MeshBay-Setup-0.1.0.exe (155 MB), oneClick/perMachine flags applied, node-runtime bundled at the path findNodeBinary expects. test_packaging_win.py (14) pins the config invariants and the NSIS <-> platform.py autostart seam. Node suite 798 pass / 34 skip. Open: Authenticode signing (13.9 — unsigned => SmartScreen), Windows CI (18.3), electron-updater. First clean-machine install + DPAPI + autostart round-trip is a manual check. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat: Windows daemon lifecycle (W3) — Startup-folder autostartChristophe Besson2026-09-046-17/+348
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The Linux node runs under `systemctl --user`. Windows has no per-user equivalent that works without elevation: `schtasks /create /sc ONLOGON` (even `/rl LIMITED /it`) fails with "Access is denied" for a non-admin user, because a logon trigger touches machine-wide scheduler state. So autostart is a `.vbs` in the per-user Startup folder instead: CreateObject("WScript.Shell").Run Chr(34) & "<exe>" & Chr(34), 0, False wscript runs it at every sign-in, hidden (0) and non-blocking. No admin, no console window, no new dependency. Verified end to end: the launcher brings the daemon up with no window and it answers its loopback API. node/platform.py autostart_install/remove/status — write / delete / detect the launcher autostart_run/end — start now (DETACHED|NO_WINDOW) / taskkill _node_exe — PATH, then next to sys.executable, then argv[0] node/daemon.py new `autostart install|remove|start|stop|status` verb reload (win32) -> POST /api/reload on the loopback API restart-daemon (win32) -> autostart_end + autostart_run reset (win32) -> also removes the launcher client/main.js, preload.js node:autostart handler + winAutostart* helpers (kept in step with platform.py) node:service-status (win32) probes the daemon; stop/restart/start use taskkill + a detached, windowless spawn Tests: 8 autostart cases in test_platform.py (mocked sys.platform, APPDATA pointed at tmp); `autostart status` added to the CLI dispatch sweep. Full meshbay-node suite green on Windows (784 passed / 34 skipped). Still open: no CTRL_CLOSE_EVENT handler, so a bare taskkill / window close does not run _shutdown() (SetConsoleCtrlHandler, follow-up). Service mode (pywin32/NSSM) stays Phase 2. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>