| Commit message (Collapse) | Author | Age | Files | Lines |
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Point 1 (directory list not updating without a full page reload) turned
out to still reproduce after the earlier onRefreshIndex fix — that one
addressed the Videos/Music root pickers (nodeDirs), but this section's own
list reads a different field entirely (ops.list_groups returns the
*runtime* root set, groups_ctx[gid]["roots"]) that only gets replaced once
_reload_config_inner's retarget actually finishes. /api/reload itself is
fire-and-forget on the node by design (ops.start_reload's own docstring —
a brand-new group's initial scan can take minutes, the caller must not
block on it), so a single loadNodeInfo() call right after can land in the
gap before that replacement happens and show the pre-change count.
Both the add and remove handlers now poll /api/groups (up to ~4s, every
400ms) until the root count actually matches what the action should have
produced, rather than fetching once and hoping the timing worked out.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A real report showed audio_root timing out with *nothing* logged in
between the connection handshake and the timeout — no admin_challenge, no
error, nothing. The previous fix made an unmatched admin_challenge return
silently (correctly, to stop it stealing an unrelated pending request —
see the earlier commit), but that silence is indistinguishable from "the
request never reached the node at all", which is exactly the ambiguity
blocking this investigation. An unmatched admin_challenge is now logged
(op, op_id, and the full set of currently-pending keys) instead of
dropped quietly, and setAudioRoot/_authorizeAdminOp trace both hops of
the round trip explicitly. Node-side, _do_audio_root gets a debug log at
entry — cheap, and the only way to know from server logs alone whether
the request was ever received if the client-side trail comes up empty.
Diagnostic only: no routing behavior changed from the previous fix,
verified against the same reproduction script.
|
| |
|
|
|
|
|
|
|
|
| |
Flagged directly: "Saving" ran for a few seconds then just stopped, with
nothing telling the operator whether it had worked. Both outcomes used
the same dim .settings-hint styling, so a real failure and a real success
looked identical at a glance. Success and failure are now tracked
separately (previously one plain string held either) and rendered with
the same success-msg/error-msg styling already used elsewhere on this
page, so which one happened is unambiguous.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Reproduced from a real report: enabling the Music app and saving its root
folder in the same Settings visit (the new merged Directories section
makes this a fast, natural back-to-back sequence) fired two signed admin
ops within milliseconds. Neither the admin_challenge reply nor the
admin_response ack two steps later was keyed by anything — both were
matched purely by "whichever request happens to be oldest pending"
(transport.js's own documented last-resort guess). apps_enabled's
challenge stole audio_root's pending slot; audio_root's own request never
received a challenge at all and just sat there until its 30s timeout.
Both hops are now keyed by op name: admin_challenge already carries `op`
from the node, and admin_response is given one client-side purely for
this (the node's _do_admin_response never reads it — only op_id and
signature). A stray admin_challenge with no matching request is dropped
outright rather than guessed at — it is never a broadcast (one
`self._send`, no peer loop, docs/webrtc_server.py), so a session with no
matching key genuinely has nothing to do with it. A domain ack (an actual
broadcast — every connected client gets audio_root_ack, not just the
requester) still falls through to the existing per-type handling when
nobody here is waiting on it, unchanged.
Verified against a standalone reproduction of the exact race (two admin
ops racing, reordered replies) — this codebase has no browser-JS test
runner to add as a real regression test, so the repro lived in a scratch
script rather than the suite.
|
| |
|
|
|
|
|
|
|
|
|
|
| |
Folders (unlike files) only ever arrive over MNP as part of a full
index_sync — the ongoing index_delta push has no `dirs` field at all
(daemon.py never puts one there for incremental updates) — so a directory
added or removed via the Electron-local add/remove flow never showed up
in the Videos/Music root pickers until the whole page was reloaded. The
merged Directories section made this easy to hit: add a shared folder,
then immediately try to pick it as a root, in the same visit. Both
actions now call the same onRefreshIndex a chat upload already uses to
pick up its own effect on the index.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Five related pieces of polish against the Music app and Settings, all
from the same conversation:
- Music app now requires audio_root, same as Videos requires video_root:
an empty-state message until one is set, and grouping filtered to only
what's under it (underAudioRoot, mirroring video-app.js's
underVideoRoot). Wires the new audio_root/audio_root_ack pair through
transport.js and group-page.js state the same way video_root already
flows.
- Starting one player now stops the other — opening a film closes the
music queue, starting a track closes the video modal. Both used to run
at once, found live.
- Group Settings reworked: every section but a bare form (invite,
pair-operator, approve-device) is now collapsible (CollapsibleSection);
the uploads on/off button is a real toggle switch (ToggleSwitch,
reused for TMDB/MusicBrainz's enabled switches too, each now with an
icon + status badge in its header instead of a plain checkbox row);
and shared directories, the Videos root picker, and the new Music root
picker are merged into one "Directories" section (RootFolderRow) instead
of three separate ones scattered down the page — the root pickers only
show once their app is actually enabled.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Two complaints against real use: the artist -> album -> track hierarchy
was invisible (every depth sat flush left, distinguishable only by which
chevron happened to be open — Videos' own flat list never needed more
than one level, so there was nothing to reuse for this), and a filled-in
album unfolded into a wall of identical little icon-box squares, one per
track, carrying no information a track row can actually use (unlike
Videos' per-episode thumbnail).
Track rows now reuse Mode A's own numbered tracklist style
(.music-track-row: number, title, duration, no icon box) instead of
Videos' boxy thumb-slot row. A folder's expanded contents get wrapped in a
new .music-flat-children indent + rule line, so nesting reads as visible
steps into the tree rather than same-level siblings. Folder rows
(artist/album headers) still reuse Videos' flat-row style, which fits them
fine — this is not a wholesale rewrite, only what didn't actually work.
|
| |
|
|
|
|
|
| |
Opening a film while a track was playing left both audio tracks running
together — nothing closed the music queue when a video opened, and nothing
closed the video modal when a track started. Both directions now stop
whichever player wasn't just asked for.
|
| |
|
|
|
|
|
|
| |
The panel's header said "Playing now" but only the panel itself was named
that — the current track was just a highlighted row you had to spot in
the list, easy to miss on a long queue and often scrolled out of view
entirely on open. Now shows the track's own title/artist right under the
header and scrolls the highlighted row into view when the panel opens.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Tagging and covers for these two formats landed already, but neither one
decodes in any mainstream browser's <audio> element at all — a real
library scan turned up 273 such files that would show up correctly in the
Music app and then simply fail on click. This closes that gap: the node
transcodes to AAC/M4A on request (a one-shot whole-file conversion, not
live-piped like video's fMP4 segments — an audio file is small enough that
streaming it buys nothing), caches the result under its own content hash
the same way a TMDB poster or a MusicBrainz cover is cached, and serves it
back through the ordinary file_req/chunk path. That path used to assume
anything in the media cache was thumbnail-sized (single chunk, always);
generalized it to slice a cached blob the same way a real file on disk
gets sliced, since a transcoded track can be several MB.
New MNP pair (`audio_transcode_req`/`_resp`, version bump to 0.9), shares
its concurrency cap with video's transcode pool rather than getting its
own — both are real ffmpeg processes on the same node. Every other audio
format is untouched: this only fires for .wma/.mpc, the two extensions
that need it.
|
| |
|
|
|
|
|
|
|
|
| |
A genuinely corrupt source file (found via a real "1 track, fails to play"
report) would stop a "play all" queue dead with no way forward except
manually picking the next track. The player now counts consecutive load
failures and advances past them on its own, capped so a pathological queue
(everything broken) doesn't spin forever; the existing Promise-rejection
catch and a new <audio> onerror handler both feed the same counter, since a
decode failure can surface either way depending on the browser.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Audio files were invisible to file-utils.js's canPreview, so their
name never rendered as a clickable link and the toolbar's Play/View
buttons never enabled for them - the only way to do anything with an
audio file in Files was to download it.
- file-utils.js: canPreview now includes 'audio' alongside image/
video/document.
- files-app.js: the toolbar's canPlay/canView split treats audio like
video (Play, not View) rather than falling into the generic preview
path, which was never built for it anyway.
- group-page.js: onPreview routes an audio entry to onPlayQueue - the
same persistent player Music uses - instead of the preview modal.
Deliberately not Music's artist/album grouping: the queue is every
audio entry sharing the clicked file's literal containing directory
(entry.path), sorted by filename, so previous/next in Explorer stays
scoped to what's actually in that folder, tags or no tags. onPlayQueue
already replaces whatever queue is playing unconditionally, so
starting a track from Files while Music (or another Files folder) is
already playing needs no special handling - it's the same "just a new
queue" path either way.
Client-side only, no protocol/index change. npm run sync-ui re-run.
Full suite: 1129 passed, no regressions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Album-grid readability, part two:
- groupMusicEntries (music-app.js): an album bucket left with exactly
one track - a real album tag, but only one song from it, not the
whole release - clutters the grid the same way an untagged loose
track does. Both kinds now fold into one "<artist> - Various" tile
per artist, unless there is only one leftover track overall, where
relabeling buys nothing and the track keeps its own name (or the
generic placeholder, if it never had one).
- foldKey also normalizes "&" vs "and" ("Artist & The Band" / "Artist
and The Band" is one act, tagged both ways across different rips of
the same catalogue) alongside the existing case/whitespace fold.
- music-player.js: a close button pauses and tears the player down;
an unmount cleanup effect (pause, revoke every cached blob URL)
fires either way, whether that's the close button or the shell
tearing the bar down on its own. A "current queue" button opens an
overlay listing the whole playing queue with the current track
highlighted, click any to jump to it - works identically regardless
of how the queue was built (an album, the consolidated misc bucket,
a single standalone track), since it only ever reads the player's
own live tracks/order/pos.
- group-page.js: this component is not remounted when switching to a
*different* group on the same /group/:id route (only the groupId
prop changes) - so without an explicit reset, music from one group
would carry into the next one opened. Resets musicQueue to null on
groupId change; a tab switch inside one group still leaves it alone.
- Scrubbed real artist/band names that had leaked into code comments
and test fixtures (enrich_audio.py's docstrings, several
test_enrich_audio.py assertions, a music-app.js comment) - replaced
with generic placeholders, no behavioural change.
- i18n: music.various, music.player_close, music.player_queue,
music.queue_title added across all ten locales.
Client-side only except none of this touches the node at all. npm run
sync-ui re-run. Full suite: 1129 passed, no regressions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The monochrome disc icon from the previous commit read as placeholder
UI chrome, not as artwork - too plain for something that is, for most
tiles in a real library, the default look of the grid.
- DiscPlaceholder (music-app.js): a small inline SVG illustration -
dark disc base, an iridescent radial-gradient sheen mimicking the
rainbow reflection a real CD's data side has, two faint groove
rings, a light label ring, a dark spindle hole. Each instance gets
its own gradient id (a module-level counter) rather than one literal
id repeated - a grid renders many of these at once, and a shared id
would leave every disc after the first pointing at whichever
<radialGradient> the browser happened to resolve.
- AlbumCard and MusicDetailModal now branch on coverHash directly:
MediaThumb (the real chunk-path image) when there is one,
DiscPlaceholder when there isn't - rather than routing "no cover"
through MediaThumb's own generic small-icon fallback, which is still
right for its other callers (list rows, Videos).
- icon.js: the flat monochrome "disc" icon this replaces is removed -
nothing else used it.
- style.css: .music-disc-empty/.music-disc-svg replace the old
.video-thumb-empty overrides; still sized relative to the tile (82%)
so it scales with .music-grid's auto-fill columns.
Client-side only. npm run sync-ui re-run. Full suite: 1129 passed, no
regressions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Album tiles/detail covers with no embedded art, no sibling image file,
and no MusicBrainz match (or MusicBrainz off) used the same small
music-note icon list rows use elsewhere - fine as a rare fallback, but
most tiles in a real library land here, so it read as broken rather
than as the default look of the grid.
- icon.js: a "disc" icon (two concentric circles, same stroked style
as the rest of the set) - a plain CD/vinyl glyph.
- music-app.js: AlbumCard and MusicDetailModal's cover now pass
emptyIcon="disc" instead of "music".
- style.css: sized at 55% of the tile via CSS rather than a fixed px
value, so it scales with .music-grid's auto-fill columns; color
stays var(--text-dim), the same neutral tone every other empty state
already uses - monochrome, not a new accent.
Client-side only. npm run sync-ui re-run. Full suite: 1129 passed, no
regressions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Two spots in music-app.js held a literal NUL byte where a plain space
was intended - present since the file's very first commit (the
LazyTile key's template literal joining artist and album) and
reintroduced by the previous commit's sentinel key for the untagged
bucket. Root cause looks like a tool/transport artifact rather than
anything in the logic itself: git rendering the file as binary
("0 insertions/0 deletions" on a real content change) was the tell,
caught before it reached anyone actually running this code.
Fixed by rewriting the file clean and, while at it, removing the
sentinel-key approach that produced the second NUL entirely: the
untagged-tracks bucket is now a plain array on each artist entry,
appended as a synthetic album only at the end, rather than a Map key
that had to be guaranteed to never collide with a real folded album
name. No behavioural change from the previous commit's intent - same
grouping, same flat-mode rendering - just without the fragile
mechanism that broke.
Verified: zero NUL bytes anywhere in the file (checked the whole repo
for the same class of corruption - only genuine binaries, PNG/WASM,
have any), valid UTF-8, node --check passes, git diff renders as text
again. npm run sync-ui re-run. test_hook_ordering.py,
test_transport_contracts.py, test_locales.py: 31 passed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
- groupMusicEntries: artist/album grouping keys are now case- and
whitespace-folded ("Racing With The Sun" vs "Racing with the sun" —
a single mistagged track split a real album into two cards, reported
live). The first spelling seen stays the display string; nothing is
rewritten. Validated against a real ~5700-file library: 22 distinct
(artist, album) pairs had more than one raw spelling before this,
including the reported Chinese Man case.
- The untagged-tracks bucket per artist gets a dedicated, non-foldable
key rather than folding the translated placeholder text, so it stays
one bucket regardless of UI language, and sorts after every real
album rather than wherever the placeholder's spelling lands
alphabetically.
- Flat mode: an artist folder whose only "album" is that untagged
bucket — a real, common shape here, a pile of loose singles with no
album layer at all — no longer nests them behind an always-empty
"Unknown album" row to expand first. They render directly under the
artist. onPlayQueue already builds its queue from the whole bucket
regardless of nesting, so previous/next already spanned the full
pile; this only removes the pointless extra click to reach it.
Client-side only, no protocol/index change — a page reload picks it
up, no node restart needed. `npm run sync-ui` re-run. Full suite: 1129
passed, no regressions (JS structural checks — test_hook_ordering.py,
test_transport_contracts.py — included).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
"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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Implements the client half of docs/musicbay.md against MNP 0.8:
- music-app.js: album grid (grouped by artist -> album, from index-time
artist/album fields) or flat folder view, per-group localStorage
toggle like Videos. MusicBrainz (music_meta_req) is only looked up
when a track has no embedded cover at all — well-tagged files never
trigger a network call, unlike Videos where TMDB is unconditional.
Reuses video-app.js's MediaThumb/LazyTile (now exported) rather than
duplicating the chunk-path thumbnail decode + virtualization.
- music-player.js: the persistent player bar — queue, shuffle (Fisher-
Yates, keeps the current track in place when toggled), repeat (off/
all/one), volume (localStorage), prev/next, a one-track prefetch
cache. No MSE, no node-side streaming: a track is downloaded and
decrypted once via file-utils.js's pipelinedDownload, same chunk
pipeline Files already uses, then played from a blob URL.
- group-page.js: owns musicQueue/musicbrainzConfig state and renders
MusicPlayerBar outside the tab-switched area — deliberately, so
playback survives navigating to Chat/Files, the same reasoning the
video/preview modals are shell-owned rather than app-owned.
- apps.js: registers "music". transport.js: fetchMusicMeta (keyed by
path, same reordering-hazard fix as fetchMediaMeta),
setMusicbrainzConfig/setMusicbrainzEnabled (signed ops, mirroring
TMDB's), and the three new ack handlers. group-settings.js: a
MusicBrainz settings section (contact string, per-group toggle) —
the existing Applications checklist already picks up "music" for
free, per apps.md's own claim.
- icon.js: music/pause/skip-next/skip-prev/shuffle/repeat/volume,
drawn in the same stroked style as the existing set.
- i18n: group.tab_music, the music.* and settings_node.musicbrainz_*
keys, translated (not just copied) across all ten locales, Polish
carrying full one/few/many/other plural forms for music.n_tracks.
- webapp.py's _ASSETS, test_hook_ordering.py's STATIC_FILES and
test_transport_contracts.py's SPLIT_FILES gain the two new files.
Full suite (common + hub + node): 1116 passed, no regressions.
`npm run sync-ui` in meshbay-client confirmed both files copied.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Three bugs found live testing the Videos app against a real HEVC/EAC3 show,
plus a design change requested afterward:
- Streaming always did "-c:v copy", which faithfully reports a source's real
hev1 codec string but is unplayable in a browser with no HEVC decoder
(most Chrome/Linux builds). The node now transcodes to H264 whenever the
probed codec is browser-incompatible (media_probe.py's new
BROWSER_INCOMPATIBLE_VIDEO_CODECS), with a `transcode_incompatible_video`
node.toml opt-out for operators who know their viewers already decode it.
- Dropping a whole season into an already-watched folder gave no scanning
indicator and no progress bar: IndexProgress was only ever updated by the
two bulk scan paths, never by the real-time per-file watchdog path
(_schedule_update/_debounce/_update_entry). That path now accounts a
"burst" the same way, without double-counting a file rewritten mid-debounce.
- A stray literal "0" rendered in the video detail modal when there was no
TMDB match (`meta.confidence` is 0, and `0 && x` renders "0" in JSX/htm,
not nothing) — `confident` is now a real boolean.
- Whether TMDB is used at all moves from a node-wide setting to per-group
(OP_TMDB_ENABLED/tmdb_enabled/tmdb_enabled_ack, scoped like OP_VIDEO_ROOT):
an operator running a real media-library group alongside test/demo groups
on one node wants outbound TMDB traffic for the one that needs it, not all
of them. The custom API token and query language stay node-wide, one
shared credential/cache (tmdb_config/OP_TMDB_CONFIG, unchanged reasoning).
MNP_VERSION 0.6 -> 0.7, additive.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmyXtc6dAADsH23ydXQpY
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
MSE only decodes AAC/Opus, so copying a source's real audio codec left
non-AAC files silently unplayable in-browser (E-AC-3 additionally made
ffmpeg itself refuse to write the fragmented MP4 header). Audio is now
always transcoded to AAC and downmixed to stereo — multichannel AAC is
accepted by ffprobe/VLC but silently rejected by some browsers' MSE
decoder once real fragments are appended, which forces the SourceBuffer
out of its MediaSource with no explicit error. Video stays copy-only.
Also: report a clear client-side error instead of a bare STREAM_END when
ffmpeg exits nonzero before producing any output, add video-element/
MediaSource error logging on the client for the next time this class of
bug needs diagnosing, and fix a hub test that had grown too broad a scan
window after an earlier, unrelated transport.js change.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
The group UI's applications split, and the two missing-import bugs it
surfaced and fixed along the way.
MNP goes to 0.4: apps_enabled/apps_enabled_ack, and enabled_apps on the
handshake ack, for the group-applications registry. Additive — a node that
predates it is never sent the op, and a client that predates it never looks
for the field.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
- test_transport_contracts: CreateGroupPage was refactored into a
routing wrapper; assertions now read CreateGroupFormSimple
- test_task_lifetime: _spawn now uses an _on_done wrapper instead of
a bare self._tasks.discard callback; assertion checks both parts
- test_video_buffer_ceiling: target the real updateend handler, not
the settled() utility; add awaitingInitRef to the MSE harness scope
- test_video_seek: silence debug console.log in window_leak harness
so it does not pollute the JSON output
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
| |
The test reads index.html and sync-ui.js to verify security properties.
Update the paths to match the directory rename from the prior commit.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
| |
node.*, wizard.*, sidebar.node, settings_node.*, and group.retry
were present in en.js but missing from fr/es/de/it/nl/pl/pt-BR/zh-CN/ja.
All 10 locales now have identical key sets (test_key_sets_match_english passes).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
| |
The DELETE /v1/groups/{id} endpoint only deleted group_members before
removing the group row, causing a FK violation (500) when notifications
or content_reports referenced the group. Delete notifications outright
and nullify group_id on content_reports to preserve moderation history.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Six columns (users.pw_version, users.pk_node_ed25519, users.role,
groups.description, refresh_tokens.family_id) and two tables
(notifications, hub_peers) were never created by migrations — they
relied on create_all(), which creates missing tables but never a
missing column. This passed every test (fresh SQLite each run) and
broke every deploy to a wiped PostgreSQL database.
One migration now creates the complete schema. The seven incremental
files are removed; their changes are folded into the initial schema.
Safe on a fresh database only, which is the only case that exists
after the meshbay.org wipe.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
| |
Without it the three tests in test_bundle_kdf_parity.py silently skip
instead of failing — a coverage gap the test file itself flags.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
**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>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
**Full-screen was denied, and the denial was invisible.** The permission
handler was written from a true sentence — nothing here needs a camera, a
microphone or a location — and implemented as `callback(false)` for everything.
Chromium's own video controls ask for the `fullscreen` permission, so a film
could not be watched full-screen.
What made it hard to find, and what the test now pins: **a denied `fullscreen`
does not reject.** `requestFullscreen()` returns a promise that never settles.
No exception, no console message, nothing in the renderer that mentions a
permission — the button just does nothing. Measured rather than reasoned: the
probe reported `NEVER SETTLED` while the main process, instrumented for one run,
logged `PERMISSION ASKED: fullscreen`. After the fix the same probe reports
`granted` with `document.fullscreenElement` set.
The handler now enumerates what is *granted* — `fullscreen`, and nothing else —
so a camera, a microphone, a location, notifications and MIDI are still refused
and whatever Chromium adds next arrives refused rather than quietly allowed.
`Permissions.query` takes the other handler, so both now answer from the one
list instead of eventually disagreeing.
The old test asserted `callback(false)`, which is to say it locked in the bug.
It is replaced by three: what must stay denied, that `fullscreen` is granted,
and that both handlers read the same list.
**"Save automatically" opened a dialog.** The automatic path required a folder
to have been chosen first, and on a new profile nobody has chosen one — so the
very first download fell through to Save As, which is the one thing the setting
promises not to do. A browser does not make you pick a folder before it will
save a file; the system Downloads folder is the answer when there is no other.
Verified on a fresh profile with a home of its own: no dialog, 1024 bytes on
disk, destination reported as the default (`/home/…/Téléchargements` on this
machine, via the localized XDG directory).
A folder that *was* chosen and has since gone still asks. Silently redirecting
those files is worse than a dialog: someone who picked an external drive wants
to be told it is not there, not to find the film in their home directory a week
later. Settings shows the effective destination either way, and offers "forget"
only for a folder somebody actually chose.
813 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
**Downloads were going through RAM.** `_openDownloadTarget` tries a granted
folder, then a service worker, then its floor: collect the whole file in the
page and hand the browser a blob. Both of the first two are absent in the
desktop application — `showDirectoryPicker` does not exist, and Chromium refuses
a service worker on a custom scheme — so every download under 512 MB took the
floor. A gigabyte of film meant a gigabyte of RAM, and the only visible symptom
was a Save As dialog at the *end* rather than the start, which is what the
operator noticed and asked about.
The main process now streams to disk: it honours "save automatically" with a
folder chosen once and no dialog, never overwrites (a colliding name gets a
suffix), awaits each write so the renderer cannot outrun the disk and queue the
file in memory anyway, and unlinks a cancelled download rather than leaving a
truncated file that looks complete to whoever opens it next. Settings now offers
the native folder picker instead of saying downloads are unsupported.
Measured in the running application: the file on disk grows 256 KB → 512 KB →
768 KB → 1 MB as the chunks arrive, and an aborted download leaves nothing
behind.
**A permanent scrollbar on sign-in.** `.layout` and `.page-center` each reserved
`100vh - 52px`, and `.page-center` sits inside `main`'s 24px vertical padding —
so the page overflowed by exactly 48px at every window size. Found by measuring
in the app rather than reading the stylesheet: `scrollHeight` 819 against a 771
viewport, then the bottom edge of every element. The centring page brings its
own padding, so main's is dropped for it and the duplicated arithmetic goes
rather than growing a third term. Now `scrollHeight == innerHeight`, no
overflowing elements.
**The first-run screen was unstyled.** It used a class name I invented
(`auth-page`) that appears nowhere in the stylesheet, so it had no card and the
button sat against the input. It now uses the same `page-center` + `login-card`
markup as sign-in, which is where the 12px gap comes from. The sign-in link in
the nav is hidden until a hub is chosen — it led to a page that could not work.
809 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
All three came from the operator running the application on Ubuntu GNOME. None
would have been found by anything already in the suite.
**A second copy of the hub address.** `keyderive.js` carried
`const HUB = '' // same origin` — true of a page the hub served, false of one
loaded from a package, where the origin is `app://meshbay` and `/v1/users/register`
resolves against the application's own protocol handler. **Sign-up and sign-in,
the first two things anybody does, failed with "Not found."** The seam was
changed in `app.js` and in the signalling call and this was missed: the same
shape as the duplicate `MNP_VERSION` in `protocol.py`, a second copy of a
constant that is harmless until the context changes. `test_hub_address_seam.py`
refuses any file that decides where the hub is, and any `fetch('/v1/…')`
relative to the page origin.
**A window handler reading a variable another path reassigns.** Changing the hub
closes one window and opens another; `closed` arrives *after* the replacement is
assigned, so the outgoing window nulled the reference to the incoming one and
its `ready-to-show` crashed on it — a modal "A JavaScript error occurred in the
main process". Every handler now belongs to the window it was created with. The
CDP test wrote `config.json` in advance, so it never took the one path that
creates a second window; it does now, starting from an empty user-data dir.
**A first run that could not be undone.** The hub address was accepted on
anything URL-shaped and there was no way to change it afterwards — the prompt
only appears when none is set, so a typo meant editing JSON by hand. `https`
typed at a hub speaking `http` produced `TypeError: fetch failed`, which names
nothing. Now: the address is probed before being written, failures say which
URL and why ("does not speak https. If this hub is on your own machine, it is
probably http"), Settings can change it, and Electron's
"Error invoking remote method" wrapper is stripped from what a person reads.
Verified on the operator's desktop: **safeStorage really uses the GNOME
keyring** — Settings reports `gnome-libsecret`, and `secrets.bin` is written
0600 with Chromium's `v11` prefix, the marker for keyring-backed encryption
(the fixed-key fallback writes `v10`). Headless, the same code reports
`unavailable` and refuses to store rather than downgrading in silence, which is
now explained in Settings instead of shown as a bare word.
Unrelated but found while testing: `test_locales.py` assigned to
`globalThis.navigator`, which is read-only from Node 22. The client's build
already requires Node 22+, so the first CI machine configured for it would have
failed these tests for no visible reason. 809 tests pass on Node 18 and Node 24.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
D4. The passphrase stays the account's credential and its only recovery path;
what changes is that it is not asked for on every launch.
**The renderer never holds the device key.** It is generated, stored and used
entirely in the main process, which signs `meshbay:user_auth:<username>:<ts>` on
request. Same rule as the save dialog, for the same reason: the renderer is the
part of this application that parses decrypted content from nodes, which is
attacker-controlled input. And this key is *not* a per-node identity key — those
are generated per node and never leave that relationship, so nothing here
correlates a person across operators.
First run asks which hub, with no default. A client that picks its own hub is a
client that can be pointed at one, and the address is the whole of what this
application trusts a hub for — the interface comes from the package.
Verified against a hub running this code, not against the deployed one:
register 201 → passphrase login 200 → device register 201 → **device sign-in 200
with a real session** → `/v1/users/me` 200 → a stranger's key 401. The
signature was also checked directly against the hub's own Python verifier before
any of that.
Inside the running application, over the debugging protocol: the bridge reaches
the main process, the renderer calls the hub **through it** (200 — the CORS fix
working end to end), and a call to a host that is not the configured hub is
refused.
**Not verified:** safeStorage persisting the key. This session has no secret
service, and standing one up in xvfb did not succeed. The application behaves
correctly there — it *refuses* rather than storing unprotected, and now says so
in Settings, which is a real case rather than a hypothetical one since it is
exactly what a headless or minimal desktop looks like.
Worth remembering for next time: meshbay.org runs whatever was last deployed. It
answered 405 on the Stage-C endpoints and reported MNP 0.2 while the tree had
0.3, so a local `uvicorn meshbay_hub.app:create_app --factory` on SQLite is what
tests hub changes. Nothing was deployed to production for this.
799 tests pass; e2e.py passes end to end.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|