| Commit message (Collapse) | Author | Age | Files | Lines |
| ... | |
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A subtitle button that spins for ever and never shows a track could not be
told apart from a node that answered, a blob that never arrived, or a track
attached with no cues in it: the whole path was silent. Every step of it
happens on someone else's machine, over a link, against a file that may be
gigabytes, so the only question worth asking when it does not finish is which
step did not — and nothing recorded that.
It now logs the request, the node's answer with hash, size and elapsed time,
the fetched blob and its chunk count, the attachment, and the live TextTrack's
mode and cue count. A failure says how long it took before failing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
The comment claimed a <track> appended after playback started is not shown by
its `default` attribute. Chrome shows it: in a headless run the TextTrack read
back "showing" before the effect had touched it, with its cues already parsed.
The effect stays, for the two reasons that are true — `default` says nothing
about turning subtitles off again, and an explicitly assigned mode means the
same thing in every engine — but the reason written beside it was not one of
them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
MSE decodes no in-band text track, so a subtitle cannot ride inside the
fragmented MP4 the player is fed. The node extracts one track whole, converts
it to WebVTT and caches it under its own hash; the client pulls that blob
through the ordinary file_req/chunk path and hangs a <track> on the video
element — the same indirection as a TMDB poster or an audio transcode, which
is what makes a film's subtitles extracted once in the life of the file rather
than once per viewing. Whole-file also makes the cues absolute, so a seek and
an audio-language change both leave the track untouched.
**The ordinal counts every subtitle stream, including the ones never listed.**
Only text codecs are offered: a bitmap track (PGS, VOBSUB — about a fifth of a
real library) has no path to WebVTT without OCR, and one extracted anyway
yields a header with no cues, which is a menu entry that shows nothing and
reports no error. Numbering the survivors of that filter would give a
PGS/SRT/SRT file the ordinals 0 and 1 for its text tracks and `-map 0:s:0`
would then extract the PGS — the same trap `AudioTrack.ordinal` exists for,
one level deeper. A fixture whose first subtitle stream cannot be decoded pins
it, and the handler checks membership of the probed list, never a range.
Additive and MINOR: the selector is drawn from `subtitle_tracks` in the node's
own `stream_init` and from no version number, so `subtitle_req` is never sent
to a peer that would not answer it. The floor stays at 3.0.
Also here: a failed extraction never touches playback, a superseded reply
cannot install its blob over a newer choice, and `_languageName` is shared
with the audio labels — lifted by both label harnesses, since a lift that
names one function stops covering the rule the moment logic moves out of it.
Tests: 9 node (tracks told apart by the words in the extracted cues, not by
tags), 10 client. Full suite green: 1545 node/common, 1252 hub.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The audio-track selector was drawn with `volume` — the same glyph the music
player uses for its level control, and the same one the browser draws a few
dozen pixels below it, since the video player mounts `<video controls>` and
gets the native volume speaker with it. One drawing, two meanings, in one
window.
`speech-pair` is two offset bubbles: the same line, said twice, which is what
the button chooses. Nothing crosses anything — the set is stroked with no
fill, and two overlapping outlines read as a tangle at 20 px — and it leaves
the conventional subtitle glyph free for the button planned beside it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
|
| |
|
|
|
|
|
|
|
|
|
|
| |
`{ ..._auth }` after the await spreads a null _auth to {}, so the renewal
stored a token with no username and no userId. The app renders the
signed-in interface from that and throws on user.username[0] — a blank
page on every load, in localStorage, until the site's data is cleared.
The sign-out wins the race now, and loadAuth treats an identity-less
object as signed out so a browser already holding one heals itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
The streaming path mapped 0:a:0 unconditionally, so a dubbed film played in
whichever language was muxed first and the others were unreachable. The node
now enumerates the tracks in stream_init and honours audio_track in
stream_req; switching is the seek path, since one ffmpeg carries one track.
MNP 3.2, additive: the player draws its selector from the node's own list and
never from a version number, so an older node is never asked for a track it
would ignore. MNP_MIN_SUPPORTED does not move.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
boot-guard.js is a classic script loaded before the module graph, so it
survives the graph failing to link. If #app is still empty after ten
seconds it names what failed and offers a reset of this origin — cache,
storage, databases and the service worker, which clearing the cache does
not touch.
Two real defects found building it: openDB never settled when an upgrade
was blocked by another tab, and a connection it gave up on stayed open
and squatted the database.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
| |
The ceiling is 1200 tracks on a library that repeats and 660 on one that
never does; the message said 1000, which would send a reader back into the
wall at 660. It says 500, measured at both ends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
| |
A DataChannel send() throws above the max-message-size the node advertises
(aiortc: 65536), so the 1 MB body cap was unreachable and each body push
swallowed the difference in a bare catch. Cap at 62 KB after sealing, name
the playlist in the sync result, keep syncing the rest.
Measured: 1000 tracks seal to 53 276 bytes, so the ceiling is ~1200.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
| |
`max-height: calc(100vh - 16px)` says how tall the menu may be and nothing
about where its bottom lands, so one opened partway down the window ran
past it and its last rows scrolled out of reach. Measure from where the
panel was placed, and re-measure when a submenu opens.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
| |
The dismiss-on-scroll listener is on the capture phase, because `scroll`
does not bubble — so it also heard the menu scrolling itself, and a long
tracklist closed the moment it was wheeled. Filter on the event's origin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Reported from a phone: signing in with the same account showed no
playlists. syncWith was called from exactly one place in the interface,
so creating a playlist, deleting one, removing a track and saving the
queue all wrote to IndexedDB and stopped there. The store pushes itself
now, coalesced, so a new mutation cannot forget to.
Silence was the real defect. The node audited only successes, so a
refusal left no trace and user_blob_list none at all; the background
push swallowed its reason; the interface said nothing. All three report
now, and "Sync now" says what happened either way.
An unreadable blob on a node was treated as a fetch failure and returned
before the push — permanent, once the node held anything. It is an
absence: the client is the authority, and it gets overwritten.
A sign-in reconciles whatever this browser already holds, a pending push
is flushed when the page goes away, and a push that did not land is
retried once.
no_key is spelled out: a client that signs in with its remembered device
key only ever has a bundle key persisted before the playlist subkey
existed, and an AES handle is non-extractable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
An artist with one album got a heading and one cover on a row that fits
five, and a library is mostly single-album artists. Consecutive singles
share one grid, in place, so the page stays in artist order.
Each pooled cover keeps its artist's name above it in the same type as
a section heading. Dropping it was the first version and it was wrong:
scrolling then alternates between artists written large and small.
Measured on the probe's fixture: 4208px to 1895px, and a walk of the
page reaches all 21 covers instead of 9.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
One button in Music's sticky toolbar — load, create, delete, remove a
track, sync now — and "add to playlist" on every cover and row. Both
surfaces share one list, read from the manifest, so they open instantly
with every node offline and no body is fetched until one is wanted.
Submenus expand in place rather than flying out: the account menu's
language list already does this, and a flyout has nowhere to go at 400px.
The tracklist under "remove a track" loads when it is expanded.
A name is typed into a field. Electron has no prompt — it throws.
Also splits the two playback failures: a decode failure belongs to that
file and keeps the bounded counter, a connection failure belongs to the
group and skips all of its queued tracks at once. Six dead tracks are
one more than the bound, which is where a playlist would otherwise stop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
playlists.js is IndexedDB, WebCrypto and a transport, and node has no
IndexedDB — so it is driven in Chrome against a node stubbed to record
what it was handed, which is also how what leaves the browser is
checked to be sealed.
Sync asks the node what it holds (user_blob_list) rather than comparing
against the merged watermark, which says nothing about that node: the
first version pushed every body on every sync. A tombstoned playlist's
body is deleted as each node is reached, or the quota fills with graves.
The database version and its stores stay in hub-client.js — two modules
opening one database at versions of their own is a VersionError thrown
at whichever runs second.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
playlist-merge.js and playlist-crypto.js have no imports and are run by
their tests, which is the only real evidence this feature can have.
Two revision counters per playlist, not one: a rename on one device and
a track added on another both write n+1, and a single counter makes two
edits that do not overlap collide.
One Argon2 run at sign-in, two handles. The AES handle is imported
non-extractably, so nothing can be derived from it — hence a second
import of the same bytes as HKDF rather than a derivation.
Measured: ~270 bytes a track, deflate worth 4.5x on realistic data, so
the 1 MB body cap holds about 17000 tracks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
One row per playlist plus a manifest, so starring a track rewrites that
playlist rather than the whole collection. blob_enc is a BLOB, not
base64 TEXT: these run to hundreds of kilobytes.
user_id comes from the session and never from the message; kind is
validated against a pattern; every cap refuses with a stated reason
rather than truncating.
Additive, so MNP_MIN_SUPPORTED does not move — a 3.0 node answers
"unknown message type" and the client writes to the next one it reaches.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The player's queue could only be replaced: every onPlayQueue reset
tracks/order/pos together. It becomes one reducer (queue-ops.js) with
an `op`, because two appends batched into one tick cannot both read the
track count out of separate useStates.
A shared pop-up menu (menu.js) carries the three verbs, on right-click
and on a dots button. A track row is now a div holding two buttons: a
button cannot contain a button.
Found by the browser probe: both music wrappers took two arguments and
forwarded two, so every "add to queue" arrived as a plain play.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
| |
Folders were always ordered by name, whatever the column and direction, so
reversing the name sort or sorting by size moved only the files. A folder's
size is what it holds and its date its newest file; ties go to the name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
| |
Into the folder on screen, under the Upload button's rule. A name already
there, or one the node would refuse, cancels the whole drop with a message.
Folders are recreated level by level; files go out a few at a time. The
in-flight upload guard is keyed by folder and name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
| |
Previous/next arrows in the pinned toolbar, on group pages and in Search.
Page size is an account preference (Settings → Defaults), 50 by default,
10 to 200 in steps of 10.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
| |
load
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
|
| |
|
|
|
| |
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
|
| |
|
|
|
|
|
|
|
|
|
| |
when idle
Browser idle sign-out (media playback counts as activity; not the desktop app),
refresh idle window and maximum session length, in hours. Sign-out now revokes
on the hub, and the profile has "sign out everywhere".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
|
| |
|
|
|
|
|
| |
Existing shorter accounts keep signing in. Test usernames padded to match.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
|
| |
|
|
|
| |
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Two live-reproduced bugs in Windows node start/stop, found sideloading the
0.14.0 build:
- node:start's crash-recovery step killed a service-mode daemon with
taskkill/CTRL_BREAK, both of which fail with "Access is denied" against a
process running under the Scheduled Task's own S4U logon session (a
different session from the Electron app's). The daemon it was meant to
replace just kept running, unreplaced, and schtasks /run on a task Windows
still considered Running was then a silent no-op too. Route through
winServiceTaskEnd() (schtasks /end) first, the way nodeServiceStop/
nodeServiceRestart already correctly do. service-mode.ps1 also now starts
the task right after registering it -- Register-ScheduledTask's own
AtStartup trigger does not run it immediately, so nothing was listening
until the next reboot.
- The Node page's Start button called node.start() with no arguments, so an
unlinked node (a fresh install, or one whose hub-side link was lost) could
never link on Start alone -- only create-group-page.js's own call passed
{hubUrl, username, token}. Reproduced on a fresh non-service install signed
in to the real hub: Start hung for ~105s and failed with "could not link",
pointing at a "Link Node" control that lives on Settings, not the Node
page (that message is fixed too).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`search_listed` is a per-group setting on the node, changed by a signed
operator op and carried in the sealed handshake ack. Search reads it after
the handshake and stops there: no index is fetched, cached or merged, in any
of the four views, and the page says how many groups it left out. The switch
is a "Search" section in the group's settings, shown to the operator.
Absent means listed, at every layer: roster default, ack default, and the
client only drops a group on an explicit `false` — so an upgrade or an older
node removes nothing from anyone's Search.
It is a listing preference and protects nothing: the node serves the same
index to Search and to the group page and cannot tell them apart, every
member lists the group by opening it, and a client that ignores the flag
lists it in Search too. Design §9.11 says so, so it is never described as
private. The cost is one handshake per unlisted group, because only the node
knows the setting.
Search also took `nodes[0]` twice — for the index and for the pooled
connection — the defect 4cce50f fixed on the group page only. One
`connectToGroup` now walks the list the same way: a refusal about this
browser stops, `not_hosted` or a failed connection moves on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The router rendered `#/login`, `#/register` and `#/reset` before it checked
for a user. A browser signed in as one account, opening on a `#/login` left
in the address bar, drew the login form (prefilled by the browser with
another account) under a navigation bar and a sidebar already showing the
first account and its Administration entry.
`#/login` and `#/register` now need no one signed in, and a signed-in person
landing on either is sent home with `location.replace`, so Back does not lead
to the form again. `#/reset` stays reachable: that flow signs in half-way and
still has its progress and result to show.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6jPTeocXA1BePekdsgPya
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Adding a large directory left the operator nothing to look at once they left
the Settings panel that started it, and nothing at all when it was added from
another machine. A band now sits above the music bar on every page: one row
per group with indexing under way, naming the root being walked, percent,
bytes and files, and the roots waiting their turn; "indexing finished" for a
few seconds at the end. A click opens the group's Settings, and × hides the
row until that group is idle.
Two sources feed it. On the node's own machine the desktop client polls the
loopback `GET /api/index-status` for every group, whatever the route. An
operator's group page forwards MNP `index_progress` pushes, resolving the
root from the roots table it opened; an ordinary member keeps the sidebar dot
only, and a page clears its row when it lets go of the group. Where both
describe a group, loopback wins.
Reconcile passes and watchdog bursts show only past 1 GB or 5 s, so a single
dropped file does not flash a bar. The logic lives in index-dock-model.js,
which has no imports and is tested under node. The dock publishes
`--index-dock-h` and the sidebar stops above it and the music bar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6jPTeocXA1BePekdsgPya
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Passphrase sign-in locks per username: after `login.max_failures` wrong
passphrases (default 4) the name is refused with `429 account_locked` and a
`Retry-After` for `login.lockout_minutes` (default 60), without the passphrase
being checked. Both numbers are instance policy an admin sets from the panel;
zero failures turns it off. The per-IP limit bounds one address, and IPv6
gives every subscriber a /64 of them — an online guess targets an account, so
the account is what is counted.
- Counted by the name as typed, existing or not, so `login` stays uniform (M1).
The key is a hash: people type passphrases into the username field.
- The attempt is taken before the check in one `INSERT … ON CONFLICT DO UPDATE
… WHERE … RETURNING`, so a concurrent burst gets no more than the limit.
- Sign-in, passphrase change and account deletion count on the same row; the
last had no rate limit at all.
- A lockout refuses passphrase sign-in and nothing else: sessions, renewal and
device sign-in continue, and a reset code clears it (AV26). A session learns
its own lockout from `/v1/users/me`, and the passphrase change checks it
before re-wrapping any node's bundle — the hub accepts the new passphrase
only after the nodes have it.
The SPA now shows what the hub said. `loginAndRecover` threw "Login failed:
{json}", so `email_verification_required` never matched and was never shown;
the passphrase-change form rendered no error at all in its first phase.
The unauthenticated surface, reviewed route by route:
- No `/docs`, `/redoc` or `/openapi.json`, in the code. The Caddyfile hid them
on meshbay.org only; a packaged hub behind any other proxy published all three.
- The node socket's first message must arrive within ten seconds. It is
accepted before anyone is known, and an unbounded read is a connection any
stranger holds for free.
- `/v1/relays` answers 503 behind `relay.RELAYS_ENABLED`, as federation does:
nothing in the tree calls it and two of its routes take no account.
- `test_unauthenticated_surface.py` walks every route and fails on one without
an authentication dependency that is not listed with its reason.
Verified in Chrome against a local hub: the lockout and wrong-passphrase
messages, the admin section saving both lockout and mail limits, and the
passphrase change refused while locked. Not verified in Firefox (a running
instance blocks the headless one), nor the upsert's concurrency on PostgreSQL.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LcF3QKWii7uQ2kSyXErzCt
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The figure was a line beside the settings form, which is where it is changed
and not where it is watched. It sits with the other live figures under
Statistics now — four cards and, above them, a banner saying which of the two
ceilings has fallen. The two states are not the same to whoever is reading:
one means newcomers are turned away, the other means somebody locked out of
their account cannot get back in. The settings block keeps a line pointing at
it.
And an operator no longer has to be looking. When a global ceiling is reached
the administrators are notified — in `mail.py`, in its own session, never
raising, because this runs while a request is being refused and an alert that
fails must not turn a refusal into a 500. Once per hour, keyed on a row
rather than a flag in memory: a flood is what spends the budget, so one alert
per refusal would bury the message under its own cause, and a hub that is
refusing mail is a hub somebody is about to restart.
`/v1/admin/mail` gains `general_exhausted` and `all_exhausted` rather than
leaving the panel to compare two numbers.
Labels in all ten catalogues; `.warn-msg` for the middle state, on the
`--warn` token both themes already define.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
They were constants in two modules, so an operator could not touch them
without editing code and redeploying — and the hour a budget runs out is not
when anyone wants to do that.
`[mail]` in hub.toml carries the defaults; the live values live in
`hub_settings`, read at each use. A missing row falls back to what the
configuration file says, so an instance that never opens the panel behaves as
its file describes. The panel sends only what changed, the hub clamps each
value to a stated range and refuses a key it does not know, and the response
is what gets rendered — so a clamped value is never shown as stored.
`GET /v1/admin/mail` is the other half. There was no way to see any of this:
a refusal was a line in the journal, so an instance that had stopped sending
sign-up codes looked, from the panel, exactly like one with no sign-ups. It
reports the hour's use, what is left for sign-ups, and what is left for
recovery — the difference between those two being the reserved share made
visible.
Labels in all ten catalogues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
An availability review, prompted by the group claim above: a participant
supplies input — who else bears the cost? Six answers where the cost fell on
someone other than the sender, and none of them needs an attacker.
AV3 `chat_notify` carried a `group_id` the hub believed, so any connected
node could write a notification to every member of any group on the
hub, carrying a display string of its choosing, with its account
having no relation to that group. This is the group claim again, two
hundred lines further down the same socket. Gated on what the node is
registered for, and metered: the fan-out is one write per member. The
budget expires by time rather than on disconnect, or reconnecting
would refill it and a node token is good for an hour.
AV4 A swarm source named its own `endpoint` as free text documented as
"ip:port", so an account could publish a third party's address — H6's
`peer_ip` defect, never applied here. Nothing dials a swarm source
today, which is the only reason it was not already a reflection
primitive. It is a transport and a port now, never a host, and the
number of hashes one account may claim is bounded: rows were keyed
(hash, account) with no cap at all.
AV5 `handle_webrtc_answer` resolved any pending `peer_id` from any node's
socket. The answer is the SDP a browser then connects to. That this
had not happened rested on a uuid4 being unguessable.
AV6 `relay_register` had no authentication of any kind: it compared
`pk_relay` against the approved value, which is a *public* key, so
anyone who could read it could rewrite where the hub tells nodes to
send relayed traffic. The module docstring promised signed JWTs and
`jwt` was imported and never used.
AV7 The node held unlimited peer connections and kept one that never
completed a handshake for the life of the daemon. H6 bounded what one
unauthenticated peer costs; the hub's cap is three offers in flight
per *account*, a limit on each caller and not on the machine, so an
operator's exposure grew with the size of their groups.
AV8 `invite-notify` put a request-supplied `group_name` into the subject
of an email the hub sends under its own domain, to any account, with
no rate limit. The name comes from the group row now.
The tests are two accounts each, in one file that says why: a one-member test
proves a one-member property, and every finding here needed a second person
to exist at all. Each was checked against the unfixed code. Two did not
survive that check and were rewritten — one re-enacted the disconnect path
instead of running it (hence `forget_node`), the other called the reaper
itself and would have passed with the call removed from `handle_offer`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A node that hosts no groups sends no `group_ids` on its hub socket, and the
hub resolved the claim with `set(claimed_groups or authorized)` — so "I host
nothing" arrived as "I host every group this account belongs to", other
members' included. Such a node can serve none of them: it holds no GEK, and
its own handshake refuses them with "Group not hosted on this node".
`/v1/groups/{id}/nodes` answers in registration order and `_node_groups` is
in-memory, so which node a client was sent to depended on who reconnected
first after a hub restart. GroupPage took `nodes[0]` with no fallback. On
2026-09-11 a hub deploy at 20:14 reshuffled the registry, a second member's
unconfigured node won the race, and a group stopped opening for everyone in
it with its only real host online throughout. Any member could take one of
their groups down, by accident, by leaving an empty node running.
Four changes, because no one of them is sufficient:
- the hub never widens an absent claim, and `update_groups` goes through
the same ceiling as registration — it assigned its list verbatim, so the
bound that makes C2 hold at authentication was one message wide
- the node states the empty set rather than omitting the field
- the refusal carries `not_hosted`, so a client can tell "try the next
node" from "you, here, must do something first"
- GroupPage walks the list instead of indexing into it
The three lines involved date from 13, 20 and 23 August and each is
defensible alone. The defect is in the seam, which is where the last two
also were: a falsy empty collection must never mean "unspecified".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A second-machine sideload of the MSIX target surfaced three things the
earlier verification round (which only proved the package installs and
runs) had missed:
1. meshbay-node missing from PATH. installer.nsh's customInstall adds
node-runtime\ to HKCU\Environment at install time -- an unelevated
per-user write, never blocked by MSIX's no-elevation rule, only by the
more basic fact that an AppX/MSIX install runs no custom code at all.
packaging/win/ensure-node-path.ps1 (idempotent, no admin verb) plus
main.js's winEnsureNodeOnPath() do it from the app itself instead, once
per launch, shipped to Full and MSIX (not Light, nothing to add there).
Verified live via the Node inspector protocol: the entry was in
HKCU\Environment\Path after a launch, absent before.
2. A daemon that crashes on startup failed silently. spawnNodeDetached()
used stdio: 'ignore', so a real crash reproduced live (a second instance
colliding with the first on 127.0.0.1:18000) left waitForNode()'s
generic 60s timeout as the only failure ever shown. spawnNodeDetachedWatched()
pipes stdio and watches ~2.5s, rejecting immediately with the daemon's
own stderr on an early exit; a survivor has its streams released and
runs fully detached exactly as before. First version bounded the
captured text by line count and a live test showed that cut the actual
OSError line -- two uvicorn/asyncio tracebacks followed it in the real
capture -- so it is bounded by characters instead.
3. No hint that a startup-mode choice exists. The install-time radio page
was the only place this was ever offered, and nothing replaces it now
that no install-time page can exist at all. SetupWelcome (the existing
first-run banner) grew a conditional hint, shown only while a bundled
node is present and neither autostart nor service mode is configured
yet. Considered and rejected: linking straight to the Node page -- its
route is gated on a linked hub node key, false on the exact fresh-install
screen this hint targets, so the link would have been dead on arrival.
New key setup.node_startup_hint, added to all ten locale catalogues.
test_packaging_win.py gained six tests pinning all three (69 total).
Full plan and verification detail: C:\Users\admin\devel\msix-installer.md
section 13 (out of repo).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
An administrator's deletion answered 409 for any account owning a group,
so an erasure ordered by an authority had to wait on the person it was
about. It now deletes the account's groups with it, then pushes a signed
revocation for the account and for each group to every connected node:
an access token already issued stays valid on a node until it expires,
and the revocation is what makes the nodes refuse the account and close
the groups' sessions now. The action is written to the IP log, and the
confirmation dialog says the groups go too, in all ten catalogues.
The owner's own deletion is unchanged: refused while they own groups,
which they can hand over first (CGU 3.4, privacy statement).
Deleting a group had three partial cascades. The owner's route left
email_verifications behind, and the cleanup of unhosted groups left
notifications, invitations and reports - each an IntegrityError on
PostgreSQL, invisible on SQLite, which does not enforce foreign keys by
default. db/purge.py is now the one implementation: it finds every table
referencing groups.id from the schema, deletes the group's rows and
detaches content reports, which are evidence and outlive the group.
test_group_purge.py turns foreign-key enforcement on for its connection,
seeds every referencing table, and fails without the fix on all three
routes. MESHBAY_DESIGN.md 7.7 states the rule, and now lists the device
keys and swarm sources that e3c68b3 erases.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D9MCBBWSm9GhBESmqzJxNy
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
MeshBay Light has no bundled meshbay-node.exe, so the create-group wizard
(which assumes it can start a local node) needs its own signal, not just
platform.node.available. main.js exposes it over IPC (node:bundled) by
checking the packaged resources directory rather than trusting a build-time
constant; preload.js and platform.js carry it through the usual
contextBridge/wrapper path.
winCanElevateServiceMode() replaces the two prior 'app.isPackaged' checks
for whether the app can offer service-mode elevation -- Light is packaged
but has no service-mode.ps1 to elevate into, so packaged alone was already
the wrong test even before this target existed.
create-group-page.js gates the wizard step that starts a node on the new
capability instead of hiding the whole feature; node-page.js's comment fix
is unrelated cosmetic drift caught in the same pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A small link to the hub's legal pages, pinned to the bottom of the
sidebar once signed in. It opens in a new tab: the desktop application
refuses to navigate away from its interface and hands a new window to
the system browser, and in a browser it keeps the session on screen. The
address comes from hubBase(), so it is the legal pages of the hub in use.
The sidebar now sticks under the navigation bar at the window's height;
otherwise, on a long file list, the link would sit at the bottom of the
page. The music bar, pinned to the bottom of the window as well,
publishes its height through useStickyBand as --music-bar-h and the
sidebar stops above it. On a phone the slide-out panel does the same.
test_sidebar_legal_measured.py measures the real stylesheet: the link at
the bottom of the window on a 3000px page, and above the music bar, at
phone and desktop widths. Checked signed in on a local hub in Chrome and
Firefox 155.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D9MCBBWSm9GhBESmqzJxNy
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The browser sign-in page now carries the project's pitch beside the form:
what MeshBay is, the applications, what it is for, and what meshbay.org
does and never sees, with links to the downloads and the legal pages.
Text on the left and the form on the right on a desktop; one column,
form first, below 1000px. Not shown in the desktop application, whose
user has already downloaded it. All ten catalogues carry the text.
Every claim is held to MESHBAY_DESIGN.md 2.3: the page says content never
reaches the hub, not that the hub can read nothing (T3), and "end-to-end"
means device to node.
Signed out there is no sidebar, so the 960px main column sat at the left
of the window and the sign-in, register and reset forms were centred in
it (x=290 in 1440). `.page-center` pages now lift that cap.
test_welcome_layout_measured.py measures the real stylesheet in Chrome:
no horizontal overflow from 320 to 1440px, form first on narrow screens,
form right of the text on desktops, pair centred in the window.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D9MCBBWSm9GhBESmqzJxNy
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The ack was assembled from its own tuple of application names, a copy of the
daemon's `APP_DIR_KEYS`, and the two had drifted: the copy was missing
`helloworld`. So the reference application — the one that exists to prove a new
application needs no special-casing — was the single application whose
configured folders never reached a client, which made the plugin claim false
exactly where it is demonstrated.
Fixed by removing the copy rather than syncing it. The ack now emits whatever
`<app>_directories` the group context carries, and `_app_directories_ctx` is the
only thing that puts one there, so the two cannot disagree again. The transport
names an application in one place, `ALLOWED_APPS`, which is enforcement rather
than a directory list.
The client had the same fault one layer up: `group-page.js` read three names by
hand from the ack while the live-update path beside it was already generic. It
derives the map from the ack's own keys now, so the fix reaches the settings
pane instead of stopping at the wire.
A first attempt moved the list to `roster.py`, where directory *storage* lives,
and `test_helloworld_proves_the_plugin_claim.py` refused it: the roster, the ops,
the config and the root set must name no application at all. That test is the
architecture's own guard and it was right — the list belongs on the daemon, which
is what wires a group's context, and everything downstream is derived from it.
Two new tests, both verified to fail against the previous shape: the ack carries
an application the node names nowhere else, and the ack keeps no list of its own.
`test_the_lists_are_read_under_one_name_each` now asserts the shell names no
application rather than that it names exactly three.
Two stale comments went with it — the ack's, which described scalars removed in
07ff8b4, and the client's, which said those scalars still rode the wire for
MNP 1.0 peers that can no longer connect.
Full suite: 2258 passed, 4 skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVoHVCcfBqud6ZjG4db3y7
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`video_root`, `audio_root` and `photo_roots` are gone — the messages, the
signed operations, the handlers, the `ops` wrappers, the three scalars on the
handshake ack, and the client's handlers for their acks. `app_directories`
does the same thing for every application, keyed by the app's own registry
name, and it is what the SPA has been sending.
The three were the same instruction three times, differing only in the key they
wrote and whether they carried a string or a list. That shape is what made
adding an application mean adding a message type, an op, a handler and a widget;
it also meant three validation paths, and the older ones validated nothing —
a typo was stored and then quietly matched no entry, an app showing an empty tab
with no way to tell "misconfigured" from "no files yet".
**What stays, and why.** `Roster.LEGACY_DIR_KEYS` still reads `video_root` and
friends out of `group_settings`: that is a key on an operator's disk, not on the
wire, and a node upgraded into this must find its own configuration. The Search
page still reads its own older cache keys, for the same reason — the cache
outlives a deploy. `CTX_ALIASES` keeps only `chat`, which is the one app whose
second name something still reads.
The two per-app policy test files go with the messages. What only they held —
the real challenge/response path from message to database, which no other test
exercises — is retargeted at `app_directories` in
`test_app_directories_signed.py`, and the handler's own refusals (unknown app,
malformed `directories`, nobody to authorize it) join `test_app_directories.py`.
Node and common suites 1368 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Seven comments pointed at sections of `~/next/improve-downloads.md`, which is
not in the tree and not anywhere a reader of this repository can follow. Each
now states the thing it was citing: why a paused transfer holds nothing, why the
lease is taken after the save target and not before, why a chunk request marks
a lease alive, where the leaseless bound's number comes from.
The leaseless comment also said "two files at a time" three paragraphs under
`MAX_LEASELESS_IN_FLIGHT = 12`, left behind when the bound was raised. A comment
that contradicts the constant beside it is worse than no comment: one of them is
wrong and the reader cannot tell which.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`MNP_MIN_SUPPORTED` is the version this build speaks, so `check_version`
refuses everything below it at the handshake. Every capability the client was
gating on the node's version is therefore true of every peer it can reach:
* `supportsSealedUpload` — an upload is sealed or it is not sent;
* `supportsAppOps` — one `app_directories` op, and no `setVideoRoot` /
`setAudioRoot` / `setPhotoRoots` wrappers behind it;
* `supportsTransferSlots` and `Lease._skip()` — a lease is always real, so
there is no branch where a transfer runs without one;
* `legacyNode`, the read-only shared-directories table, and the two hints
telling an operator their node is too old to configure an app.
The version the node declares is still recorded, for diagnostics. Nothing
branches on it, and the comment says so, because a field kept "just in case" is
how the branches came back last time.
`test_mnp_1_0_node_compat.py` goes with them: it existed to hold the fallbacks
in place, and holding a fallback that cannot execute is how a suite starts
lying. The two locale strings for those hints are removed from all ten
catalogues.
Hub suite 872 passed (test_sticky_header deselected — failing before this).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The group-wide `member_upload` switch is gone: the message, the signed
operation, the field on the handshake ack, the `upload` alias on every root in
the index payload, and the client's fallback path to it.
Whether a member may write has been a property of each root for a while, and
that is the model that survives: a single flag over the group cannot express
"this library is published read-only and that folder is a drop box", which is
the ordinary arrangement. What was left of the switch was a handler that logged
a deprecation and acted on nothing, and a client that read `ack.member_upload`
whenever the roots carried no `writable` — a second source for one question,
with whichever the code consulted first deciding it.
`roots.describe()` drops `upload` for the same reason: it was `writable` under
an older name, and two names for one boolean is one too many.
The paperclip now says "nowhere to write" rather than picking a root, in a group
that has none writable. That is the honest answer; the fallback picked whatever
came first and failed at send time.
Node suite 1215 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The tab bar paints an opaque ring of page colour around itself, `--band-margin`
wide, so the gap it keeps in the flow is still there once it pins. A box-shadow
spread goes out on all four sides, and above the tab bar there is only whatever
the element before it happened to leave: the join-code form under a group's
title leaves 12px, the ring is 16px, and the form came back with the bottom 4px
of its field and its button painted over — page colour at z-index 30, against
content that has none to answer with. Reported as "the form is slightly cut
off", which is exactly what it looks like and says nothing about a stylesheet.
The same 4px went off the bottom of the "could not reach this node" banner, the
other thing that stands between a group's title and its tabs.
The band reserves that room itself now. `* +`, so it is the gap between two
elements rather than a margin the band always carries: `.search-bar` is a first
child on the Search page, and a margin-top there would collapse through the
page root and take the whole page down with it. Between siblings the two
margins collapse to the larger of the pair, so everywhere that already leaves
enough is untouched and only what was being painted over moves.
Only the two bands that pin against the navigation bar, and that is the rule
rather than an economy. Written for all six it fails 22 of the sticky-header
cases: the gap *between* two bands is the upper one's `--band-margin` and
nothing else — the number `--chrome-h` carries and the offset the lower band
pins at — so a lower band's own margin-top wins the collapse wherever it is the
bigger of the two and leaves the flow layout wider than the pinned one, at
every phone width in every media view. A band under another band needs no room
above it anyway: what is there is a band of higher z-index, which a ring cannot
paint over.
Measured against the shipped GroupPage in the state that was reported — a node
answering `code_required` — in Chrome: 12px of clearance under a 16px ring
before, 16 against 16 after. test_sticky_band_ring.py holds the two selector
lists together out of the source rather than in a browser, because what a
browser shows is the 4px at one width in one of the states that happen to put
something above a band, while what has to hold is which bands are in which
list. docs/apps.md sends the author of a new application to that section to
make its toolbar pin; this is what says the toolbar they add does not get the
gap, and why.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAJawZ25MZPBJ7TKgnVA1n
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Files, Videos, Music and Photos are read by scrolling, and everything that
steers that reading left with the first screenful. Three bands now pin under
the navigation bar, in a group and on the Search page alike: the tab bar (the
search field, on Search), the application's own toolbar, and the file table's
column heads. The group's name and description still scroll — they say nothing
a reader needs while walking a directory, and the height they would cost is
height the list does not get.
A band's offset is the heights of the bands above it, and those are not
constants: the toolbar wraps to three rows on a phone, grows a field while a
folder is being named, and loses its filter on Search. So each band measures
itself and publishes `--chrome-h` / `--toolbar-h` (static/sticky.js) and the
stylesheet does the arithmetic in calc(), rather than a number written down
twice — the fault CLAUDE.md already records against this layout twice over.
A band publishes height *plus its own bottom margin*, and paints that margin
as a ring of page colour, so the pinned layout is pixel-identical to the flow
layout and nothing shifts at the moment a band pins.
Three overflow faults came out of it, all of the same class and all of them
what "the header does not stay" actually meant on Android — a document wider
than the screen leaves everything pinned attached to a viewport the reader can
no longer see, the navigation bar included:
- a directory's name cell was a bare <td>, so an unbreakable folder name
(`Rage_Against_The_Machine_Discography_1992-2000_FLAC`) set the column's
minimum: a 527px table in a 390px window
- Search's group column did the same at 442px with an underscored group
name. It also goes entirely below 768px, where there is no room for it and
the breadcrumb already names the group
- the shared-directories table has four columns of controls with a combined
minimum near 440px, none of it compressible. On a phone the row stops
being a row: the name and its eject/remove pair on one line, the two
switches — each carrying the column head's own string as a label — on the
next
- and, found by measuring at 360px, the tab bar itself was 19px too wide
`.file-table` moves to separated borders: a collapsed border belongs to the
table rather than to the cell, so the column heads lost their rule the moment
they pinned.
Measured, not read. tests/harness/sticky_header_probe.py drives the shipped
GroupPage and SearchPage against a stub node, walks to each application,
scrolls to the end and reports every rectangle — 11 views x 4 widths x 2
engines. Its fixture says what real data says: the first version used
`note-007.txt` and `un groupe`, which fit any screen, and found none of the
above. A fixture narrower than real data tests the fixture.
Also: `test_desktop_shell` no longer looks for the CSP after the first `-->`,
which made it fail on correct markup as soon as a comment was added above it,
and `search-page.js` joins test_hook_ordering's file list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tx16FhyD2BUdpooGb5jcyN
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The reconnect scenario added with the fix proved the composer follows
`_setDevicePk`, and it poked that method itself at both ends. That is a
narrower claim than it reads as: it says nothing about whether a reconnect
*reaches* it, and the harness's own `Host` stands in for group-page.js, so a
green probe did not mean the page joins the two.
Both halves are real now. The scenario calls `connect()` with the arguments
`_reconnectLoop` calls it with; it stops at signaling, because there is no hub
in the harness, and the identity has to be gone by then — connect() drops it
before it touches the network. The restore is the shipped `_announceDevice`,
answered by the stand-in node with a `device_hello_ack` as `_do_device_hello`
answers it, and the key it settles on is the one the following send seals and
signs with. Three assertions check the scenario went that way rather than
through a variable set by the test.
The seam the harness cannot drive gets its own check: the wiring exists, the
prop is in `commonProps`, and the callback is set *before* `connect()` — after
it, device_hello's answer is missed and the composer starts closed. That check
first passed with the wiring deleted, on the strength of a comment naming the
callback; it matches the assignment now.
Two things the harness turned up. `do_POST` answered every path, so the offer
connect() posts to the hub was swallowed as the measurement and put the
machine's own SDP, public address included, into the probe's output — it
answers `/log` and nothing else now. And a connect() that gives up before
`await channelReady` left that promise rejected with nobody attached, so
closing the peer connection printed "Uncaught (in promise) DataChannel closed"
on every failed reconnect attempt — noise in exactly the log a freeze is read
from.
Checked against the unfixed source both ways: with the clear removed from
connect() and the wiring removed from group-page.js, three cases fail; with
them back, 15 pass. Hub and node suites 2266 passed, 4 skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GXmScYB1uR29YCt74si9J
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The Chat tab froze about every other day — the textbox stopped taking clicks —
and it never recovered on its own: no timeout ends this one, only leaving the
group or restarting the client. A console dump of a session it happened in ruled
out everything it could and named nothing.
What that dump established was almost entirely negative, and that was the useful
part. No `Response timeout`, no `unsolicited`/`unrouted`/`with nothing waiting`
— so the 2026-08-30 routing defect, which produces this exact symptom for thirty
seconds, had not recurred. No `PC state: disconnected|failed`, no second ICE
cycle, no `Reconnected after N attempt(s)` — so the connection was alive and
untouched. The freeze was in the page, and no path that logs anything had run.
The composer is `disabled=${sending || cannotSend}`, and `cannotSend` was
`transport.connected && !transport.devicePk`, read off a **ref** during render.
`devicePk` is settled inside connect(), so every reconnect clears it and settles
it again; a ref changing re-renders nothing, and nothing else announced it. So
the panel went disabled on whatever unrelated re-render came next — a message
arriving — long after the identity was actually lost, and had no event that
would open it again. group-page.js never touches `status` after 'connected', and
`onReconnected` is claimed by video-player.js, so there was no second chance.
It was silent as well as sticky. `_announceDevice` had three exits that wrote
`devicePk` without a word: two early returns that left the *previous*
connection's value standing, and a reply that is not `device_hello_ack` — an
`error` reply does not throw, so the `.catch()` at the call site never saw it.
Reproduced in chat_send_probe.py, which mounts the real ChatPanel over the real
transport: with the old code, identity cleared leaves the composer open, an
arriving message latches it shut, and restoring the identity does not reopen it.
Every write to `devicePk` now goes through `_setDevicePk(pk, why)`, which logs,
traces and calls `onDeviceIdentity`; group-page holds the answer as state and
ChatPanel takes it as `deviceReady`. Defaulting that prop to `true` fails open —
a wiring mistake here must not be able to leave anyone with a dead textbox.
Two things found on the same path and fixed with it. `_send` throwing inside
_sendAndWait's executor left the pending entry and its 30s timer behind, so a
request that never reached the wire still logged a "Response timeout" half a
minute later. And the instrumentation this was meant to be diagnosed with
(3be8bd2) writes to localStorage behind ?trace=1, not to the console, so the
dump could not have carried it: the two lines that decide the composer's state
are now logged unconditionally, and MeshBayTrace gains `record` so the composer
writes into the same timeline as the channel events.
Hub suite 2264 passed, 4 skipped. chat_send_probe.py gains a `reconnect`
scenario and test_chat_send.py four cases, each checked against the unfixed
source.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GXmScYB1uR29YCt74si9J
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Reported from Chrome, with a screenshot: four downloads with no pause button
and an upload beside them with one, and nothing anywhere saying why.
The reason is real. Without a granted download folder the browser writes
through the service worker — a download it already owns, which cannot be paused
without stalling it somewhere we can neither see nor resume. An upload writes to
the node, which keeps the position, so it is always pausable. But that was
stated only in a Settings line nobody reads on the way to a download, and a gap
where the row above has a button is not an explanation.
So a download that cannot be paused now shows a dimmed pause icon where the
button would be, carrying the reason and the remedy in its tooltip. Not a
button: there is nothing to click, and a disabled one invites the click anyway.
And only where the advice can be taken. Firefox and Safari have no folder to
choose — the streamed path is the only target they have, which is what §6.5 of
~/next/improve-downloads.md costs out — so telling someone there to choose one
would be advice they cannot follow. Nothing is drawn.
Hub suite 866 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
|