Author SHA1 Message Date
J3vbandClaude Opus 5 ad0448df4d ci: stop the lint gate fetching its schema over the network (#1335)
golangci-lint-action verifies .golangci.yml against a JSONSchema it pulls
from https://golangci-lint.run before it lints anything. On d352696 that
fetch hit its client timeout and took the required ubuntu leg of Server
Build & Test red -- with zero linters run and nothing wrong with the code.

Turn the pass off. `golangci-lint run` already rejects a malformed config
on its own, so the only thing lost is a nicer error message for a config
typo, and the thing gained is a required gate that no longer depends on a
third-party website being reachable.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:24:57 +02:00
J3vbandClaude d3526968bb release: v1.2.0-alpha.2 (#1333)
* docs: add bug-detection improvements plan

Plan for mechanical bug detection alongside the agentic hunt: activate the 14
unused Go fuzz harnesses, the configured-but-never-run Stryker setup, and
browser-mode vitest; encode recurring bug classes as semgrep rules; add
model-based and fault-injected ordering tests; add a persistent seen-ledger
and sibling-sweep lens to the hunt.

All local-only and on demand - fuzz crashers are working reproducers, and this
repo is public.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* build: add make fuzz target and ignore mutation-test output

`go test ./...` runs each Fuzz* function against its committed seed corpus
only - one pass per seed, zero generated inputs - so the 17 fuzz harnesses in
Server/ have never actually fuzzed. `make fuzz` enumerates every target and
runs each with a time budget (Go fuzzes one target per package per
invocation, hence the loop). Local-only by design: a crasher is a working
reproducer and this repo is public.

Also gitignore Client/tauri-client/.stryker-tmp/ and reports/ - a Stryker run
left 200+ untracked files, and a surviving-mutant report maps exactly which
behaviour nothing tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(client): pin reconnect auth-frame and replay-dedup arming

Stryker found 14 surviving mutants across ws.ts:413/422/428 - the auth frame
built on reconnect. Every condition there could be flipped with all 4777
tests still green: the replay-dedup arming guard, the resume-vs-fresh-connect
ternary, and the conditional active_channel_id spread.

Seven tests through the public send/isReplaying surface, no new exports. Two
isolate each half of the `reconnectAttempt > 0 && lastSeq > 0` AND condition -
the combination no existing test reached, and the one an && -> || mutant
walked straight through.

Verified by flipping the line 413 guard to `if (true)`: 3 of 7 fail, revert
restores green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: record two fuzz corpus traps

Interrupting a fuzz run manufactures a false crasher: Go cannot distinguish a
worker that crashed on an input from one killed externally, so it saves the
in-flight input to testdata/fuzz/ as a suspect. It looks exactly like a real
security finding. Replay before believing it.

And committed seed corpus shares the testdata/fuzz/<Target>/ directory with
any false crasher, so clearing one by removing the directory deletes the
seeds too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(client): enforce three prose invariants as ESLint rules

CLAUDE.md documents the voice-supersession, E2EE staleness and dispatcher
invariants in English. English fails no build, and bug hunts keep rediscovering
the same classes. Five rules encode them as an inline flat-config plugin - no
new dependency, and `npx eslint src/` is already a blocking CI gate.

- no-leave-voice-when-superseded: a global leaveVoice() inside a branch that
  already confirmed supersession tears down the newer live session
- e2ee-epoch-needs-keypair-check: a non-key-holder never bumps the epoch, so
  an epoch-only staleness guard cannot see a restarted session
- e2ee-verified-status-literal: keeps "verified" tied to a hand-written call
  site that earned it, never a computed status
- no-identity-scope-fallback: a `?? 0` placeholder scope mints a keypair under
  the wrong account
- no-store-write-in-ws-on: page-local ws.on handlers may read stores, not
  write them

Each rule proven to fire by reintroducing the historical bug shape and
reverting; RuleTester cases cover both the real shapes that must stay clean
and the bug shapes that must not.

A fourth candidate - await-then-stale-snapshot - was declined as not
AST-expressible: whether an await needs a guard, and whether the guard is
sufficient, is intent rather than shape, and the rule would flag most of the
already-correct guard code in livekitSession.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: correct dispatcher invariant, record Tier 2 as shipped

The client CLAUDE.md claimed ws.on(...) appears only in dispatcher.ts. Eight
handlers across main.ts, MainPage.ts and ChannelController.ts say otherwise -
page-local UI (ringing, overlays, slow-mode timers) legitimately subscribes.
The real invariant is narrower: dispatcher is the single path by which server
events WRITE to domain stores. That is what local/no-store-write-in-ws-on
enforces, and the doc now matches the code.

Also record that Tier 2 shipped as ESLint rules rather than semgrep, and why
the fourth candidate was declined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): move the status-picker dot onto the avatar corner

The corner dot on the user bar avatar was a static hardcoded-green div —
never reflected real status and did nothing on click. Removed it and
relocated the actual StatusPicker trigger dot (real color, opens the
status dropdown) to that same corner instead of its own row. The
"Online"/"Idle"/... text label under the username is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(client): return the saved password over IPC again

The remember-password box saved a password the client could never read
back. Hardening had put #[serde(skip)] on CredentialData::password, so
load_credential returned a record whose password was always absent and
the login form could not prefill it — the box appeared to work and
silently did nothing.

Drop the skip and carry the field through the TS wrapper, which now maps
a non-string password to undefined rather than trusting the payload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(client): add an auto-connect checkbox to the login form

Auto-connect already existed end to end — ServerProfile.autoConnect,
setAutoLogin(), and the boot auto-login block with its cancel overlay —
but was only reachable through the zap button on a server card. This
surfaces the same state as a checkbox under Remember password, where
users look for it.

Ticking it forces Remember password on and disables it: boot auto-login
replays the stored token, which saveCredential only writes when the
password is remembered, so the two cannot be set independently without
producing a setting that silently does nothing.

Unticking is guarded. setAutoLogin(null) clears autoConnect on every
profile, so a bare toggle-off would wipe another server's setting; the
clear now only fires when this profile is the current holder. The guard
lives in ensureProfileExists, which all four auth paths already route
through.

Also consume the password restored in the previous commit, so selecting
a saved server prefills it instead of leaving the field blank.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(release): bump client to 1.2.0-alpha.2

The client version is not derived from the tag — release.yml's
verify-versions job compares the tag against package.json and
tauri.conf.json and fails the release if they drift, so all five
manifests (both lockfiles included) move together.

Also refreshes the literal version in the README and docs build
examples, and closes the Unreleased changelog section as v1.2.0-alpha.2.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(changelog): record the three bug-hunt sweeps in v1.2.0-alpha.2

PRs #1328, #1331 and #1332 merged to main after v1.2.0-alpha.1 was tagged
and closed 233 verified defects between them, but none of the three left
an entry in the curated changelog — the generated list covers commits,
this file covers behaviour, and nothing bridged the two.

Verified unreleased by ancestry rather than by date (none of the three
merge commits is an ancestor of v1.2.0-alpha.1), so all of it ships for
the first time in alpha.2.

Nine entries grouped by subsystem, leading with the changes an operator
or user would actually notice: the 24h-retention desync, the avatar-
deleting orphan sweep, the zero-byte restore truncation, the six hot-mic
paths, and the TOFU re-pin that would have warned every install at once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(client): drop the e2e assertion for the removed user-bar status dot (#1334)

26b46cc removed the hardcoded-green `.status-dot` div from the user bar
avatar and relocated the real StatusPicker trigger dot into that corner,
adding "status picker dot sits on the avatar" to cover the new element.
The old "user bar has status dot" test was left behind and now fails on
an element that no longer exists by design.

The replacement test already asserts the corner dot is present and
visible, so removing the stale one loses no coverage.


Claude-Session: https://claude.ai/code/session_01Rkv9dVo5YEYArqrDRfW41w

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:50:12 +02:00
J3vbandClaude Opus 5 82be103794 fix(client): resolve 94 verified defects across voice, identity, transport and UI (#1332)
* fix(client): gate every mic re-enable path on the user's mute state

Six separate paths republished the microphone without consulting whether
the user had muted themselves: the audio-device fallback, selecting the
"Default" input, un-deafening, retryMicPermission, a stale PTT ownership
latch, and auto-reconnect's restoreLocalVoiceState. Each one produced a
hot mic while every remote UI still showed the user as muted.

These were six findings but one missing guard. Adds isMicPolicyGated()
(localMuted || localDeafened || localServerMuted || pttGated) and routes
the device-switch cycle, applyMicMuteState's unmute branch and
retryMicPermission through it, which also covers setDeafened(false) --
a call site no finding named.

Also extracts reconnectSuperseded() so all five supersession checkpoints
in the auto-reconnect loop carry the state-type check that only the
give-up path had, and clears the PTT gate on stopPtt and on ptt-error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): stop camera/screenshare publishing after the user turns it off

enableCamera and enableScreenshare set the store flag before awaiting
getUserMedia/getDisplayMedia, so clicking off during the OS picker left
the track publishing to the SFU while the UI showed it off, with no stop
affordance. Adds one shared generation guard: disable bumps, enable
captures before the await and discards the track if it changed.

Also in this area:
- a server refusal of voice_screenshare (or a non-VIDEO_LIMIT refusal of
  voice_camera) never rolled back the published track; the dispatcher now
  correlates the error by envelope id rather than blanket-rolling-back.
- a full-ready resync left every loaded channel with a permanent hole in
  its history, because that tier never replays chat_message frames.
  Loaded windows are now invalidated on a resync (pending and failed rows
  carry through) and the active channel refetched.
- CHANNEL_FULL while joining left voiceStatus stuck; DM mirror rows kept
  phantom entries and stale unread counts across a resync; addMessage and
  setAroundMessages dropped offline/failed optimistic rows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): preserve a mid-setup key-holder promotion, and route the
audio graph through the noise suppressor

setupKeyExchange unconditionally wrote the server's key-holder value
captured at join, clobbering a handleParticipantLeft promotion that
landed during its pre-publish awaits. The joiner then waited for an offer
only it could send, timed out, and was ejected from voice. The write now
preserves an existing promotion; it sits after the existing
session-generation check, and clearState bumps that generation and resets
the flag synchronously, so stale state cannot survive a teardown.

Enhanced Noise Suppression silently disabled the input-volume slider and
the VAD gate: livekit-client's setProcessor() does its own internal
replaceTrack(processedTrack) after awaiting addModule and a fetch, so it
landed after ours and wired the sender straight to the raw mic. The
pipeline now sources from the processed track and re-runs after
attaching, so our replaceTrack wins.

Also scopes the voice identity keypair by host AND user id so two
accounts sharing one OS profile stop sharing an identity keypair, guards
peer-key and TOFU writes against a clearState during their IPC awaits,
and seeds VideoGrid tiles from the persisted per-user volume.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): drop the previous server's bearer token on a host switch

api.setConfig spread the new config over the old, so switching hosts
carried the previous server's session token forward and the login request
to the next server went out holding a live credential for the first one.
The token is now dropped in the shared setConfig when host changes
without an accompanying token, covering login, register and auto-connect
at once.

Also fixes a packaged-build-only failure: the CSP omitted blob: from
img-src, so avatar upload validation (which measures the image via
URL.createObjectURL) always failed in release and never in dev.

Smaller connection and IPC fixes: ws_disconnect now bumps the connection
generation instead of nulling the sender slot, so an in-flight handshake
cannot install after a disconnect; a dead LiveKit proxy listener
deregisters itself instead of being reused forever; httpProxy no longer
caches an origin the Rust side may have torn down; logPersistence stopped
looping on its own flush-failure logs; ConnectPage subscribes to
transientError instead of reading it once; cert-mismatch accept/reject
only act when the event host matches the live session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): guard the quick-switcher against a double-open

openQuickSwitch assigned its instance only after awaiting the profile
load, so a second click during that window mounted a second overlay and
orphaned the first. Every close affordance destroys only the tracked
instance, leaving a body-mounted position:fixed backdrop that blocks all
input until the app is reloaded. Adds the same `opening` flag the sibling
overlay controllers already use; audited every other opener in these
files and found no second instance of the race.

Also: loadOlderMessages and loadMessages now discard a response whose
window was replaced mid-fetch by a same-channel jump; the ArrowUp
edit-last-message scan skips unsent rows, matching the visual affordance;
unpinning from the pinned panel writes the store row; the pinned panel
forwards the channel it captured at open time rather than reading the
active one at click time; the reaction picker closes on channel teardown;
a non-voice channel switch dismisses the video grid; and destroy() closes
the settings overlay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): repair the status-picker stylesheet and a dozen UI defects

The .status-picker rules targeted a root element the component never
toggles, leaving the popup's own chrome unstyled and the root
display:none. Repointed at .status-picker-dropdown and dropped the dead
rules.

Component and store fixes, all test-first: the upload preview bar never
became visible so upload errors were invisible; replying while editing
left the edit text in the textarea; MessageList's load-older latch keyed
off a raw count so a live tail append refired the fetch; drag-reorder
renumbered channels into a 0..n-1 range instead of reusing the group's
own position slots; DM avatars bypassed the authenticated fetch path;
the member-list moderation gate read a mount-time role snapshot; mention
autocomplete offered usernames the mention grammar cannot express;
notifications titled DMs as "#channel"; the update-notifier catch
dereferenced a null banner; and the channel context menu leaked its node
on teardown.

Also resets authStore in member-list.test.ts's shared reset helper: one
test was leaving role="admin" set for every test after it, unnoticed
because no gate read authStore for role until now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): adopt the legacy identity key instead of re-minting one

Scoping the identity keypair by host and user id changed the keyring
account name, so every existing install would have found nothing at the
new account and generated a fresh identity key. Every peer who had
already pinned the old one would then see a TOFU mismatch, which raises
the re-pin modal telling the user to verify the safety number
out-of-band -- a MITM alarm fired at the whole alpha population at once,
which teaches people to click through the one warning meant to matter.

When the scoped account is empty, the legacy host-only account is now
adopted: saved under the scoped name, then the legacy account deleted.
Save happens before delete so a partial failure leaves the legacy key in
place for the next launch rather than stranding the user with neither.
A corrupt legacy blob falls through to fresh generation without throwing.

A second account on the same host still mints its own distinct keypair,
which was the point of the scoping fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): surface server errors that had no dedicated handler

The WebSocket error handler bannered only RATE_LIMITED and FORBIDDEN, so
every other code that reached the fallthrough was dropped in silence --
a rejected chat_edit reported nothing at all while the optimistic
"Message edited" toast still fired. Every specific branch above already
returns, so the fallthrough sees only genuinely unhandled codes; it now
banners all of them.

Also:
- reattachToPresent cleared the detached flag eagerly, so a failed tail
  refetch let a live broadcast splice onto the stale around-window with a
  silent gap. The flag now survives until setMessages lands the tail.
- a mixed-case host and its lowercase-normalized URL form resolved to
  different cert-store pin keys; tofu::cert_store_key and ws.ts's
  normalizeHostForCertCompare both lowercase now. attachments.ts already
  did the right thing and is unchanged.
- clearAuth left the channels store populated for the next login.
- capabilities/default.json was missing
  core:window:allow-request-user-attention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): tear down video tiles, focus and the lightbox on leave

Four defects an earlier pass could not finish because each spanned two
files:

- closeVideoGrid only hid the grid, so remote video tiles survived a
  channel leave and reappeared on the next join. VideoGrid grew a
  clearStreams(), called from the real-leave branch of checkVideoMode
  (not the reconnect branch).
- the grid kept its focused-tile state across a close; setFocusedTile now
  accepts null and closeVideoGrid clears it.
- the per-user volume preference key had no host component, so volumes
  set on one server applied to a different user with the same id on
  another. Scoped via setAudioVolumeHost, mirroring channel-mutes.
- the media lightbox stayed mounted after MainPage.destroy().

Also repairs tests/unit/audio-elements.test.ts, which was missing an
afterEach import and failing to compile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): close eight defects a review found in this batch

Three of these are incomplete fixes from earlier commits on this branch --
the diagnosis landed, the cure stopped short.

- main.ts kept a hand-copied normalizeHostForCertCompare that never got the
  .toLowerCase() its ws.ts original and tofu::cert_store_key both have. Since
  the Rust side always emits the lowercased host and a profile stores it
  verbatim, any uppercase in the hostname broke all three guards -- worst of
  them the mismatch modal's onReject, which then skipped disconnect/clearAuth
  and left the user connected to the server whose certificate they had just
  refused. ws.ts now exports the one implementation and the copy is gone.
- the status-picker stylesheet repair repointed the root and deleted the old
  .status-option rules without adding replacements under the names the
  component emits, so the trigger dot -- a bare div whose only style is an
  inline background -- stayed 0x0, invisible and unclickable. The picker still
  could not be opened.
- ungateMic's re-open branch was unreachable in the one scenario its comment
  described: a PTT release routes through setMuted(true), so localMuted is
  always true there. It now takes the pttOwnsMute latch read *before* each
  call site resets it; reading the module flag from inside would always see
  false and move the bug rather than fix it.

The rest:

- dispatcher.ts statically imported @lib/screenShare, which has value imports
  from livekit-client -- dragging ~1.3 MB into the entry chunk that the file's
  own comment says is deliberately kept out of it. Now lazy, like every other
  voice call site here.
- replay detection compared payload.timestamp (server clock) against
  Date.now() (client clock). A self-hosted server without NTP made every live
  message after a reconnect look like a replay, silently killing notifications
  for the whole drift window. Both sides are now in server time via an
  observed skew estimate; latency biases it toward treat-as-live, which is the
  side that costs a duplicate rather than a dropped notification.
- identity.ts and livekitE2EE.ts each derived the keyring scope with `?? 0`.
  A missing user id would have adopted-and-deleted the real legacy key into a
  bogus host:0 account, then minted a second keypair under host:<realId> --
  published key and signing key permanently disagreeing, which is a false MITM
  warning for every peer. Unreachable today, irreversible if reached.
- per-user volumes were scoped by host with a legacy fallback that only fired
  when currentHost was null, which MainPage never leaves it as -- so every
  saved volume silently read as the default on upgrade. Reads now fall through
  to the unscoped key once and persist under the scoped one.
- a post-resync invalidate ran unconditionally while its refetch was guarded,
  so a missing getMessages left every window dropped with nothing to reload it.

A ninth finding -- that the DM reconcile could strand activeChannelId -- was
checked and rejected: the block 40 lines above already clears it whenever the
id is absent from both channels and dm_channels.

Two test-suite notes: livekit-session's announce-signing test was joining
voice with no authenticated user, which production does not permit, so it now
sets one (below PEER_ID, leaving key-holder election unchanged) and clears it
after. status-picker-userbar reads app.css from disk rather than `?raw`, which
vitest stubs to an empty string for stylesheets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): repair the e2e cert test and four defects found verifying it

The e2e suite caught one behavioural divergence from this branch, and
hand-verifying the hunt's flagged-but-unchecked items turned up four more
defects.

E2E:

- cert-tofu's "disconnect on mismatch returns to the connect page" emitted the
  mismatch for myserver.example:8443 while the session was authenticated
  against localhost:8443, so it asserted the pre-fix behaviour: a certificate
  rotating on ANY unrelated saved profile logs you out of the server you are
  using. That is the bug 8917c28 deliberately fixed. The test now emits for the
  live host, and a new sibling pins the guard itself -- a mismatch for another
  host must leave the session alone. Verified by defeating the guard: only the
  new test goes red, which is why the old one never noticed the change.

Defects found verifying the ledger's open items:

- logging out fired delete_credential fire-and-forget and then navigated to the
  connect page, whose auto-login immediately read the same account back. Since
  B4-3 moved the credential commands to #[tauri::command(async)] they no longer
  serialize on the IPC thread, so a read that wins that race signs the user
  straight back into the server they just left. Two fixes, because the race and
  the intent are separate problems: a CREDENTIAL_LOCK mutex restores the
  one-operation-at-a-time property that also keeps secret_store::set's
  read-modify-write atomic, and the connect page now skips auto-login once
  after a logout that removed the credential -- mirroring the quick-switch
  sessionStorage idiom already in that file. A server_shutdown logout keeps its
  credential and deliberately does not set the flag, so restart auto-login
  still works. e2e-pinned: with the suppression defeated, the user is visibly
  back in the app after clicking Log Out.

- a post-resync history refetch that REJECTED left the active channel's window
  already invalidated but never marked errored, so MessageList fell into its
  "no messages yet" welcome branch -- rendering a failed reload as a genuinely
  empty channel, with no Retry, until the user navigated away and back. Now
  calls setChannelLoadError, reusing MessageController's existing plumbing.

- an invite deep link arriving during the connected overlay's 800ms ready
  countdown hit a gate that assumed isAuthenticated implies the router is on
  "main". It is not: clearAuth() ran without the teardown that only the
  authStore subscriber performs (and only while on "main"), so the overlay's
  timer then mounted MainPage over a nulled-out auth state, and the invite was
  dropped. Gated on the real invariant and the in-flight session is now torn
  down explicitly.

- channel mutes carried the same dead legacy-preference fallback that per-user
  volumes had -- guarded on currentHost === null, which MainPage never leaves
  it as -- so every saved mute was silently discarded on upgrade. Mutes are a
  list, where an empty saved value is real data, so this needed a presence
  probe rather than the volume fix's sentinel.

Also extends the e2e Tauri mock with storedSettings/storedCredential seeds so
auto-login paths are exercisable at all.

Verified clean: 4800 vitest, 293 Playwright, 97 cargo, tsc, tsc -p e2e, eslint,
prettier, clippy -D warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): stop Tab escaping every modal, and a duplicate row after a resync

Two bugs left open by the previous round.

The "flaky" a11y focus-trap test was not flaky -- it was a real accessibility
defect surfacing nondeterministically. FOCUSABLE_SELECTOR is structural and
says nothing about visibility, but this codebase hides controls with inline
`style.display = "none"` (MemberPickerModal's group-name field and confirm
button both start hidden). So focusDialog() picked a display:none input as the
dialog's first focusable and called .focus() on it -- which browsers silently
refuse -- and focus never entered the dialog at all. trapFocus() then computed
first/last as those same hidden elements, so neither Tab branch ever matched
document.activeElement, preventDefault() never fired, and Tab fell through to
the browser's native order and walked straight out of the dialog. Whether the
test noticed depended on how much async sidebar content happened to be
focusable at that moment, which is what made it look intermittent.

Fixed in the shared helper rather than in the one modal that exposed it: about
forty call sites hide controls the same way, so every factory modal had the
same hole. trapFocus and focusDialog now filter out inline-hidden elements.
Reproduced first at 3/10 failures under --repeat-each; 10/10 after, and 20/20
at --workers=4. Note the check reads inline styles only -- an element hidden by
a CSS class would still slip through, which no current call site does.

Second: a message the server persisted but whose chat_send_ok ack was lost to
the same disconnect that forced a resync was displayed twice. The optimistic
row keeps id 0 until confirmSend stamps it, so setMessages' id-based carry-over
could never collide it with the real row, while addMessage had solved exactly
this for the live path by matching on content. Extracted that predicate as
isUnreconciledEcho and used it in both, so the two cannot drift apart.

The dangerous direction here is over-merging, not under-merging: collapsing two
genuinely distinct sends of the same text loses a real message. Three things
bound it -- only rows still awaiting reconciliation qualify (pending, or failed
for OFFLINE specifically, since a SLOW_MODE rejection is never broadcast and
eating that row would kill a live retry draft), author and content must both
match, and each snapshot row is consumed at most once, so N identical pending
sends pair off against N identical real rows instead of collapsing onto one.
Both directions are tested.

Verified: 4804 vitest, 293 Playwright with zero flaky, tsc, tsc -p e2e, eslint,
prettier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 17:56:43 +02:00
J3vbandClaude Opus 5 4ff199e14f fix: resolve 107 verified defects across ws hub, voice/E2EE, db, and client (#1331)
* fix(client): style the user profile popup

The popup rendered unstyled: it appeared at the bottom of the page and
pushed the rest of the app up, with the avatar drawn as a full-width bar.

app.css carried a complete Discord-shaped card under `.user-popup` /
`.up-*`, but nothing in the codebase renders those classes — the
component emits `.upp-*`. The component had been rewritten with a new
prefix and the stylesheet was left pointing at a DOM that no longer
existed. With no rule matching, the card stayed `position: static`, so
the left/top it computes were discarded and both it and its overlay laid
out as ordinary blocks at the end of <body>.

Replace the orphaned block with rules for the classes actually rendered,
following the same anatomy: banner strip, avatar straddling the
banner/body seam inside a ring punched from the card background, panel
sections, action row. Everything routes through existing tokens, so the
card follows the theme contract.

Two latent bugs fixed while there:

  - Placement guessed a 300px card height and clamped only the top edge,
    so a member clicked low in the list opened a card that ran off the
    bottom of the window. Measure the card and clamp both edges.
  - The avatar has to hang off the body's top edge, but the body scrolls,
    and `overflow-y: auto` clips horizontally too. Make it a child of the
    card rather than the body.

The fade+scale moves from inline styles into CSS so a
`prefers-reduced-motion` override can drop it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): stop vite watching src-tauri

`npm run tauri dev` died on Windows partway through the cargo build:

    Error: EBUSY: resource busy or locked, watch
    'src-tauri\target\debug\deps\owncord_client_lib.dll'
    Error The "beforeDevCommand" terminated with a non-zero status code.

Vite's watcher recursed into `src-tauri/target/`, and the moment cargo
wrote the output DLL, node's FSWatcher raised EBUSY as an unhandled
error event and killed the vite process. Vite is tauri's
`beforeDevCommand`, so its death aborted the whole dev session.

The config matched the upstream Tauri vite template in every respect
except the `server.watch.ignored` block that template ships with. Add
it. Tauri already watches `src-tauri` itself for rebuilds, so nothing
is lost.

Windows-specific — EBUSY on an open handle is a Windows filesystem
semantic, and CI only ever runs `tauri build`, never `tauri dev`, so
neither caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(security): add 2026-08-04 whole-codebase security review (#1326)

Read-only security review of the full tree (Go server, admin panel, WASM
plugin host, LiveKit voice, Tauri client). No code changes.

Three findings, all the same defect class — a security predicate enforced
at some members of a handler family but not all:

- A-2026-08-01 (HIGH) handleDeleteChannelPermission omits the hierarchy and
  grantability guards its PUT twin carries, so a MANAGE_CHANNELS holder can
  clear their own role's channel deny and read private channels.
- A-2026-08-02 (HIGH) the admin channel list/patch/delete handlers omit the
  type == "dm" guard their sibling getPermChannel carries, so the same role
  can enumerate and irreversibly cascade-delete arbitrary DMs and group DMs.
- A-2026-08-03 (MEDIUM) DMService.RingTargets omits the block check the five
  other DM interaction sinks perform, so a blocked user can ring the person
  who blocked them.

Also records one non-vulnerability observation (backup restore writes to a
hardcoded database path, silently no-opping when database.path is
customised), the candidates rejected during verification, the areas verified
clean, and the areas not examined.


Claude-Session: https://claude.ai/code/session_01Q7GUJtdsHHHGs4pSiLn6LJ

Co-authored-by: Claude <noreply@anthropic.com>

* Full audit: docs/spec refresh + remediation (security fixes, dead-code removal, test & CI gaps) (#1327)

* docs: fix server reference docs (api, protocol, server-configuration, deployment)

api.md:
- Correct the login rate limit: 5/min per IP (was documented as 60/min);
  document the per-username lockout and lockout persistence
  (Server/api/constants.go, Server/api/auth_handler.go)
- Complete the middleware list to the real 9-entry chain incl. the
  opt-in Coraza WAF (Server/api/router.go)
- Add voice_sessions and broadcast_drops to the metrics sample
  (Server/api/metrics_handler.go) and document the otel-only
  Prometheus /metrics mount
- Add reference sections for the previously undocumented /admin/api
  endpoints: setup, stats, users, audit-log, settings, tokens,
  backups, updates, and the SSE log stream (Server/admin/api.go)

protocol.md:
- Fix type counts (client->server 26, server->client 37) and add the
  missing rows: call_ring, call_decline, emoji_update, call_incoming,
  call_declined
- Correct rate limits: voice join/leave 5/1s (was "None"), E2EE offer
  64/1s (was 5/1s), and add the call-ring limit (1/3s)
- Document the plugin command wire types (chat_command, command_reply,
  plugin_broadcast) and flag that they sit outside protocol-schema.json

server-configuration.md:
- Add missing keys: server.waf_* (3), database.type,
  telemetry.otlp_insecure, and the whole logging section +
  OWNCORD_LOGGING_LEVEL
- Correct plugin-disabled status code to 503 (was 501)

deployment.md:
- Drop the removed "version" field from the /health sample; add
  broadcast_drops to the metrics sample; note the distroless non-root
  image; refresh build version strings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(architecture): rewrite stale architecture pages against 5630aa1

All six pages carried "Verified against ddc49f0 (2026-07-19)" stamps and had
drifted:

- websocket.md: delete the false claim that docs/protocol-schema.json does
  not exist — it is the codegen source of truth (Server/scripts/genprotocol,
  CI-gated by make protocol-verify); note the hand-declared plugin command
  family as the one exception; refresh LOC
- server.md: fix the websocket dependency (github.com/coder/websocket, not
  nhooyr.io), refresh LOC (42k/71k), migrations 016 -> 028
- data-model.md: migrations 001-028, 23 -> 26 tables, add api_tokens and
  channel_user_overrides to the ER diagram, channels.type now includes
  announcement, note 017/024/027/028 columns; drop the claim that schema.md
  is 6 migrations behind (it is current)
- voice-e2ee.md: drop the stale claim that the E2EE flow is absent from
  protocol.md (it has a full section); document livekitE2EE.ts/identity.ts
  and identity-key pinning
- client.md: rewrite — Solid beachhead is gone; the HTTP path is now
  TOFU-pinned through http_proxy.rs (the doc claimed the opposite); shared
  tofu.rs core with explicit-consent pinning; 9 stores (roles store deleted,
  blocks + emoji added); refreshed LOC and tooling figures
- README.md: 26 tables/001-028; client-architecture.md described as the
  redirect stub it is; companion-audit links refreshed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(architecture/ux): align UI flow specs with current client behavior

- Cert first-use is a blocking trust modal on status "first_use" — the Rust
  proxy rejects the first connection until the user confirms (main.ts:146-176,
  tofu.rs); the specs described the pre-F4/F8 behavior (an 8s banner on
  "trusted_first_use"). Fixed in README.md and connection-and-auth.md.
- Dispatcher event table: add the five missing types (chat_bulk_deleted,
  roles_update, emoji_update, voice_moved, voice_disconnected) and note that
  call_incoming/call_declined are page-scoped listeners in MainPage.ts.
- channels-members-dms.md: the "no in-client block button" gap is closed
  (AdminActions.ts context-menu item -> SidebarMemberSection.ts:177-186);
  document group DMs (MemberPickerModal, 10-participant cap, rename/leave)
  and per-channel notification mutes (lib/channel-mutes.ts); refresh stale
  line anchors.
- voice-and-e2ee.md: document the actual E2EE verification surface (roster
  shield badge -> identity-mismatch modal -> rePinPeerIdentity with TOCTOU-
  safe key capture), noise suppression + fallback, device hot-swap, stream
  preview, and DM ring/incoming-call flow; drop the nonexistent
  VoiceChannel.ts reference.
- settings-and-admin.md: the "ban should collect a reason" gap is closed
  (appendBanFlow with reason + duration); document the admin-panel deep-link
  (lib/admin-panel.ts) and the tray status menu.
- messaging.md: correct the pinned-messages empty-state copy and drop the
  nonexistent components/message-input/ directory reference.
- Re-stamp all six specs "Verified against 5630aa1 (2026-08-04)".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs: refresh client-facing and top-level docs

- security.md: drop the stale "hardcoded Tenor API key" limitation (the GIF
  provider is Klipy, proxied server-side with an operator-supplied key —
  nothing ships in the client bundle); describe credential storage accurately
  (OS keyring primary, verified writes, DPAPI/ChaCha20 file fallback);
  complete the audit-log action list against the actual WriteAudit call sites
  and note that backup restore is not audit-logged; fix the firewall
  checklist to include the LiveKit media ports (7880-7881/TCP,
  50000-60000/UDP) and ACME port 80
- credential-storage.md: probe_credential_store sample now shows the real
  serialized backend value ("Keyring") and the full variant union
- quick-start.md + README.md: refresh build version strings to
  1.2.0-alpha.1; README "audits" section now points at the current audit
  documents
- contributing.md: sqlc rows no longer claim a PostgreSQL engine/pgdbgen
  (removed with the store layer); add the protocol-generate/verify targets

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(plans): add verified status headers and fix stale references

Every plan under docs/plans/ now carries a dated status verified against
5630aa1:

- Shipped: audit-2026-07-19-decisions (all 13 rows), channel-visibility-
  unification, http-tofu-proxy, permission-middleware-consolidation (the
  disclosed ws.channelCanSend copy is still open, now at serve_ready.go:119),
  security-hardening-remediation, sqlc-adoption, v2-dispatch-migration,
  tauri-capability-narrowing (DNS-rebinding follow-up still open)
- Shipped with corrections: discord-parity — Phase 1's gap table was never
  re-marked; all six rows have since shipped, including archived channels,
  which are filtered by permissions.VisibleChannelIDs (checker.go:116-121);
  named leftovers (role hoist/mentionable, @RoleName mentions, categories as
  entities, dead-code list) stay open. security-scan-2026-07-22 — all 8
  findings closed; two of the four F3 follow-ups have since shipped (safety
  number rendered in the roster badge; rePinPeerIdentity wired to the
  identity-mismatch modal), getIdentityPin fail-open remains open; noted the
  scan artifact directory is not in the repo
- Design-only: slash-commands — added staleness notes (migration number 016
  now taken, Server/store/ deleted, src/state/ never existed)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(audits): reconcile prior audit statuses with current CI and code

- audit-test-coverage-2026-07-25: T-2026-07-25-21 (HIGH, 229/255 web e2e
  failing) was fixed by the mock repair but the audit was never updated —
  now RESOLVED, re-verified by a local 270/270 run at 5630aa1; the CI gate
  table row updated to match
- audit-2026-07-19: carried-over item 11 ("no Playwright job in ci.yml") is
  resolved — client-e2e (non-blocking, every PR) and the blocking
  client-e2e-parity job both exist; backlog item 10 marked DONE
  (client-tests is blocking, Playwright wired)

Only status/closure cells were edited; original finding text is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(changelog): add Unreleased section for post-v1.2.0-alpha.1 fixes

Three fixes landed after the release with no changelog home (the file had no
Unreleased section at all): the profile-popup styling fix (a308f81), the
vite/src-tauri watch fix (cdcfc03), and the AppImage env-key signing fix
(9d75890). Also corrects the Deferred-work note that still described the
Solid.js removal in the present progressive — it completed 2026-07-19.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(tests): rewrite the e2e issues log against a real suite run

The old file was dated 2026-03-18, claimed 209/209 passing (the suite is now
270 tests), and pointed at a plan document that does not exist in the repo.
Rewritten from an actual run at 5630aa1: 270/270 web tests green (8.6 min),
15/15 @parity subset green (the blocking CI job), with the suite inventory,
CI wiring, the two real open issues (three native specs matched by no
playwright.config.native.ts project; client-e2e still non-blocking), and
dispositions for every claim the old file carried.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(audit): add 2026-08-04 docs-and-coverage audit report

Companion to the same-day security review (disjoint scope). Contains: the
verified architecture summary; real test-run results for every runnable
suite at 5630aa1 (Go race+deadlock, 4394 client unit tests at 94.66% stmt
coverage, 83 Rust tests + clippy, 270/270 web e2e, 15/15 parity, browser
smoke — with env-blocked suites named and their compensating CI evidence
cited); a 52-row UI/UX flow coverage matrix (30 covered / 21 partial /
1 untested / 0 broken, headline gaps: TOFU flow, E2EE verification, admin
panel, updater — all unit-only); per-doc drift findings with the commit that
fixed each; reconciliation of all four prior audits and eleven plans
(including the orphaned 2026-04-07 #8 resurfaced as DC-11); the dead-code
and TODO inventories; and a prioritized DC-01..DC-15 gap list with ordered
next steps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(admin): add hierarchy guard to channel role-override delete (A-2026-08-01)

Deleting an override is a permission mutation: removing a deny row restores
exactly the access the PUT path refuses to grant, so a MANAGE_CHANNELS holder
could unlock a private channel their own role was locked out of. Gate DELETE
identically to handlePutChannelPermission: resolve the role (404 when
missing), fail closed without an actor role, and refuse targets at or above
the actor's position.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(admin): exclude DM channels from the admin channel surface (A-2026-08-02)

DMs and group DMs share the channels table and id space with guild channels,
but they belong to their participants, not to MANAGE_CHANNELS holders:
listing exposed ids and group names of every private conversation, PATCH
could silently rename one, and DELETE cascade-destroyed one irreversibly.
List now filters type=dm; PATCH and DELETE resolve through getAdminChannel,
which answers 404 for DM ids so the surface does not confirm which ids are
private conversations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(service): enforce blocks on DM call rings (A-2026-08-03)

RingTargets checked participation but not blocks, so a blocked user could
still make the blocker's client ring. Route rings through
requireDMNotBlocked like every other DM sink; group DMs stay exempt inside
it, matching the send path (blocks are enforced at group creation instead).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* chore(client): delete dead modules (DC audit remediation)

All verified unreferenced by any import before deletion:
- ServerStrip.ts: removed from the layout when the unified sidebar header
  landed (SidebarArea.ts); only its own orphaned unit test still used it.
  The e2e spec already asserted .unified-sidebar-header, so it is renamed
  to sidebar-header.spec.ts and retitled honestly.
- FileUpload.ts: uploads go through api.uploadFile from MessageInput.
- lib/reconcile.ts: nothing imports it; the messages store carries its own
  pending-send reconciliation.
- public/rnnoise-worklet.ts: unreferenced duplicate of the .js worklet the
  runtime actually loads, and public/ ships verbatim into the bundle.
- api.getSounds/deleteSound + SoundResponse: the server has no /sounds
  routes; these called endpoints that do not exist (pairs with the
  sounds-table drop on the server side).
- dm.store incrementDmMention: zero callers; DM mention counts flow from
  the server mention_count via the dispatcher. This was the one live knip
  error the CI '|| true' was masking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* chore(client): retire the tauri-typegen ritual (DC-05)

src/generated/ was tauri-typegen output frozen on 2026-04-03: it covered 21
of the 29 IPC commands lib.rs registers, nothing ever imported it (0%
coverage), and CI carried a bespoke patch step solely to keep the unused
file lint-clean. Delete the directory and every part of the pipeline that
existed to feed it: the client-check patch step, the tauri-build
generate/patch steps, the tauri.conf.json plugin block, and the inert
Cargo.toml build-dependency (build.rs is bare tauri_build::build(); no Rust
source references the crate). Cargo.lock shrinks by exactly the typegen
subtree — no other resolution changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* ci: make knip blocking (DC-06 follow-through)

Pre-verified green locally after the dead-module deletions; the config
hints knip still prints do not affect its exit code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* chore(server): drop the dead sounds table (A-2026-07-13)

The table shipped in 001 for a soundboard that was never built: no query,
model, sqlc definition, route or handler ever referenced it. Migration 029
drops it; the sqlc model regenerates without the Sound struct (sqlc emits a
struct per schema table even with zero queries). schema.md, the data-model
blueprint, and the 2026-07-19 audit closure table are updated in the same
change per the docs maintenance rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* chore(server): remove dead WAF wrapper, use protocol constants, fix stale comments

- NewWAFMiddleware had no production caller (the router mounts the CRS
  variant); its doc text folds into NewWAFMiddlewareCRS and the tests call
  the survivor directly.
- serve_auth compares against MsgTypeAuth and the DM-close REST path builds
  its WS notification from MsgTypeDMChannelClose instead of restating the
  wire strings, so the generated constants are load-bearing again.
- Comment fixes: DatabaseConfig no longer claims Postgres scaffolding that
  main.go removed; host_ui.go no longer advertises a route that is not
  mounted (DC-09's sibling); buildReady cites docs/protocol.md, the file
  PROTOCOL.md was renamed to (DC-09).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(protocol): add the plugin command family to protocol-schema.json (DC-01)

chat_command, command_reply and plugin_broadcast were the only wire types
outside the schema: the first declared by hand in handlers_command.go, the
other two raw string literals, all bypassing the protocol-verify codegen
gate. Add the three schema entries (27 c2s / 39 s2c), regenerate both
constant files, and swap the hand-rolled declarations for the generated
constants. The ws protocol-contract test's exception list is empty now —
and stays that way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* test(client): wire orphaned native specs and typecheck the Playwright layer

- dm-system, reconnection and theme-persistence (14 tests) matched no
  project's testMatch in playwright.config.native.ts, so they had never
  executed (E2E-ISSUES open issue #1 / DC-03). All three use the persistent
  fixture + ensureLoggedIn, so they join native-authenticated.
- tests/e2e was excluded from tsconfig, leaving 47 spec files with no
  typechecking anywhere. New tsconfig.e2e.json project (+@types/node for
  the node-API fixtures), a typecheck:e2e script, and a CI step. The one
  real error it surfaced is fixed: mockTotpFailure omitted the required
  simulateWsFlow flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* test(client): cover createPromptModal and the external-abort close path

modalFactory.ts was the least-covered file in the repo (57.6%):
createPromptModal had no tests at all and createModal's external-abort
branch never ran with an onClose. Now 100% statements/branches/functions,
including the trimmed-submit, legitimate-empty-submit, Enter-preventDefault
and no-double-close contracts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* test(client): e2e-cover the TOFU certificate ceremony (DC-04 slice)

The first-use confirmation and mismatch warning are the client's core
security ceremony and had no e2e coverage. Six tests drive them through
the mocked Tauri event layer: first-use modal content, trust, cancel,
modal non-stacking, mismatch fingerprint rows, and disconnect-to-connect-
page. The mock now exposes its listener registry so tests can wait for
the async cert-tofu registration instead of racing it (validated with
--repeat-each=3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs: fix the inaccuracies the 2026-08-04 refresh missed

- contributing.md: drop the '-tags postgres' build row (no such tag exists
  anywhere in Server/), add the four Make targets the 07-25 audit created
  (test/test-deadlock/cover/cover-all), align the coverage statement with
  the real gates (client 70%, no Go floor by T-2026-07-25-19), point TS
  style at architecture/client.md instead of the tombstone, and describe
  the real dev-branch PR flow.
- docs/security.md: reporting section now defers to root SECURITY.md as
  the canonical policy (it said 48h where SECURITY.md promises 7 days, and
  described the maintainer's advisory path rather than the reporter's);
  fixed the updater-key link that resolved to docs/Server/... on GitHub.
- audit-2026-04-07.md closure table: #10 and #11 were long-resolved (#10
  verified in db/audit.go, #11 exceeded by per-PR e2e jobs), #6 written in
  future tense for work done 2026-07-19, #7 citing a 113-file count from
  months ago.
- README: Contributing section matched neither ci.yml nor contributing.md
  (branch from dev, not main); Docs Index gains the six missing live docs;
  the plugin system joins the feature list; the security row no longer
  anchors to an aging version string.
- server-configuration.md: the env-var table is explicitly a subset — the
  OWNCORD_<SECTION>_<KEY> scheme covers every key.
- mcp-introspect.md: index.mjs is 266 lines, not ~230.
- types.ts header cited PROTOCOL.md/API.md/SCHEMA.md, filenames that no
  longer exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* ci: pin claude.yml actions by SHA; add docs checkbox to the PR template

claude.yml was the only workflow with unpinned third-party actions —
checkout now uses the same v4.2.2 SHA the other workflows pin, and
claude-code-action pins the commit the v1 tag resolves to (Dependabot's
github-actions ecosystem keeps both fresh).

The PR template gains the docs checkbox A-2026-07-03 recommended: the
architecture/UX maintenance rule ('a PR changing a diagram's source-of-
truth files updates the diagram in the same PR') existed only as prose no
process step ever surfaced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* style(client): prettier-format the cert-tofu spec

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(audit): record the remediation pass and close finding statuses

- Security review: A-2026-08-01/02/03 -> RESOLVED with their pinning tests
  named.
- Docs-and-coverage audit: DC statuses updated in place (01/02/03/05
  resolved, 04/09 partial with the remainder named, 14's keep-decision
  recorded) and a remediation addendum added: what shipped, the decisions
  taken (plugin host API kept, reserved protocol entries kept, e2e soak not
  shortcut, the 404-on-missing-role semantics note), and the full
  verification table from real runs — Go race + deadlock suites green,
  4 tag builds, client 4360/4360 units at 95.35% coverage, Playwright
  276/276 in 8.9 min, parity 15/15.
- CHANGELOG Unreleased: security fixes, migration 029, protocol additions,
  dead-code retirement, CI gates.
- E2E-ISSUES: rewritten against the remediation HEAD (276/276), native
  orphan issue moved to resolved, mock listener-registry note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(admin): index the channel slice in the DM filter (gocritic rangeValCopy)

golangci-lint (CI-only gate) flags the range-value copy of the 152-byte
db.Channel struct in the admin list filter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* chore(client): add .nvmrc pinning Node 20 to match CI (DC-10)

Also re-triggers CI: the previous run's windows server job died to a Go
runtime unwinder fatal ('traceback did not unwind completely') with no
test failure — toolchain flake, and the integration lacks permission to
rerun failed jobs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix: 26 bug-hunt findings across WS hub, voice/E2EE, admin, roles, and client (runs 1-3) (#1328)

* fix(ws): keep pubsub subscriptions when a replaced client is unsubscribed

Both pubsub indexes are keyed by userID, but a reconnect registers a new
*Client under that same userID. UnsubscribeAll and Unsubscribe deleted by
userID alone, so a kick of the already-replaced connection stripped the live
one's topics. The live client stays in h.clients and keeps answering
ping/pong, so it never reconnects -- it just silently stops receiving every
global, user, and channel broadcast.

Guard the forward-index delete in unsubscribeLocked with an identity check and
route UnsubscribeAll through it, so the four Unsubscribe call sites
(voice_leave, hub_broadcast x2, handlers) and the three UnsubscribeAll ones
(kickClient, unregisterNow, registerNow) all share one rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): mark kicked clients offline instead of reporting them replaced

Every kick path deletes the hub entry via kickClient, so the readPump defer's
unregisterNow finds nothing and fell through to "return true", conflating
absent with replaced. serve_pumps.go then skipped MarkUserDisconnected, the
offline presence broadcast, and handleVoiceLeave -- already-connected peers
rendered every kicked user as online until that user reconnected and
disconnected cleanly.

Return exists instead: a different client in the slot is a genuine
replacement, an absent entry is a real disconnect. Only serve_pumps.go reads
the return value; the five serve.go/hub.go call sites discard it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): force a full ready when cold-tier replay hits the row cap

GetEventsSinceForChannels is "ORDER BY seq ASC LIMIT n", so a reconnect gap
larger than maxColdReplay returned the oldest 5000 rows and dropped the
newest. handleReconnect accepted any non-empty result as a successful resume,
and the client only tracks max(seq) with no gap detection -- so it accepted
the next live event and silently lost the range in between, including state
events (channel/role/member changes) that REST history fetches never repair.

Treat a result at the cap as overflow and fall through to the full ready
re-sync. An exactly-cap-remaining gap pays one unnecessary full ready.

maxColdReplay is hoisted to the package const block so the test can seed
exactly enough events to hit it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): re-elect the voice E2EE key holder on the two paths that skipped it

updateKeyHolder had only two callers (voice_join, voice_leave), so two paths
that remove a participant from voice left voiceKeyHolders naming someone who
is gone. IsVoiceKeyHolder then rejects the real lowest-uid participant's rekey
offers with NOT_KEY_HOLDER -- which the client does not handle -- after it has
already applied its rotated key locally, splitting keys across the room.

1. The LiveKit participant_left webhook (media-only loss, WS stays up) cleared
   voice state and broadcast voice_leave with no re-election.
2. registerNow's fresh-connect replacement (F5 reload) drops the old
   connection's voice state without transferring it. handleVoiceLeave never
   runs there: readPump skips it when replaced, and it early-returns on
   already-cleared state.

Both call updateKeyHolder outside h.mu, since it takes keyHolderMu then
h.mu.RLock. The recompute reads live client voice state, so it is idempotent
and stays correct when a network reconnect transfers voice state -- locked by
TestRegisterNow_KeepsKeyHolderWhenVoiceStateTransfers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): stand down as E2EE key holder on offer, keep peer keys on reconnect

Two independent key-holder desync bugs in E2EEManager:

1. _isKeyHolder had no demotion path -- set at join, promoted on participant
   leave, cleared only on voice leave. The server re-elects the lowest userID
   on every join, so a lower-ID joiner left the incumbent still believing it
   held the key with an armed 5-minute timer. Its rotations applied the new
   key locally before the server rejected the offers with NOT_KEY_HOLDER (which
   the client does not handle), so it went deaf and mute every rotation cycle.
   Accepting an offer proves the sender is the server-authoritative holder, so
   treat it as the demotion signal and clear the timer.

2. reannounceForReconnect cleared _peerPublicKeys and peer verifications with
   nothing able to refill them: handleAnnounce replies with an offer rather
   than a counter-announce, and the server relays stored peer keys only on
   voice_join, which an SFU-level reconnect never runs. handleOffer's
   unknown-peer guard then dropped every later rotation, stranding the
   reconnector on the pre-reconnect key. The clear was also unnecessary --
   peers' keys stay valid when we regenerate our own pair.

vitest 4396/4396; typecheck and prettier clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tauri): give the ws sender slot connection ownership, bound livekit TLS

ws_proxy: state.tx is one slot shared by every ws_connect, and both the
install and the teardown clear were unconditional while the mutex was only
held in short scoped blocks. A handshake pends up to CONNECT_TIMEOUT and a
profile switch starts a second ws_connect without awaiting or cancelling the
first, so a stale connect could complete after a newer one was live, emit an
untagged "open", and install its sender over the live one -- routing the next
auth send to the previously-trusted host, then tearing down the live socket
and emitting "closed" while JS believed it was connected.

Add a generation counter claimed at ws_connect entry and checked under the
slot lock before install, plus same_channel ownership on the teardown clear,
mirroring the Arc::ptr_eq guard ptt.rs already uses for ATOMICRACE-001.

livekit_proxy: the outbound TcpStream::connect and TLS handshake were bare
awaits, while the sibling http_proxy.rs bounds both at 10s. TCP connect is
OS-bounded, but a peer that accepts TCP and never answers the ClientHello
blocked the task forever. The task holds `local` without polling it, so the
SDK closing its side never cancels it, and the detached per-connection tasks
survive stop_livekit_proxy -- so they leaked on every SDK retry.

cargo test 80 passed; clippy --all-targets -D warnings clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): keep voice E2EE alive across WS resume and unify voice teardown

A network reconnect transferred voiceChID/joinToken to the new connection
but left it unsubscribed from voice:<id> (the only transport for
voice_e2ee_announce relays) and wiped the announced ECDH key, so a
resumed key holder could never offer the room key to later joiners and
voice_join replayed nothing for the resumed user. registerNow now
transfers the announced key with the voice state and re-subscribes
VoiceTopic unconditionally (it is CONNECT_VOICE-gated at join; only the
message-stream ChannelTopic needs the READ gate).

The LiveKit participant_left webhook and CleanupVoiceForChannel cleared
voice state without dropping the voice-topic subscription, leaving the
socket receiving another room's announces (which carry no channel_id to
filter on) for its lifetime. All take-out-of-voice paths now go through
one clearVoiceAndUnsubscribe helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): route sequenced DMs through the normal FIFO

writePump drains sendHigh to exhaustion before send, so a seq-stamped DM
on the high queue reached the socket ahead of lower-seq events still
queued behind a slow write. The client acks max(seq) and replay is
strictly seq > last_seq, so a disconnect in that window silently and
permanently lost the overtaken events while auth_ok reported a clean
resume. Sequenced frames now share the one per-client FIFO; the high
queue remains for unsequenced targeted messages (DM opens, voice tokens).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(storage): remove the partial file when Save fails after create

The io.Copy and f.Sync error paths returned without deleting the file
created for the upload, and the orphan sweep is DB-row-driven, so a
write-side failure (ENOSPC, disk I/O error) permanently leaked a partial
storage/<uuid> with no DB row. One success-flag deferred cleanup now
covers every failure path (the oversize branch folds into it), fixing
all three callers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): close three voice-E2EE ordering gaps and wire the DM mention badge

- setupKeyExchange generated the room key AFTER draining queued
  announces, so a key holder joining an ongoing call sent drained peers
  no offer — they waited on the 5-minute rotation timer. Keygen now
  precedes the drain and every drained peer gets its offer immediately.
- A key-holder re-election arriving while the elected client was still
  connecting was dropped (getCurrentChannelId is null for the whole
  key-exchange wait), stranding the client until timeout ejection. The
  manager now remembers its channel from setupKeyExchange, and the
  become-holder rotation resolves a pending room-key wait.
- Offers applied concurrently could finish out of order (no epoch on the
  receiver side), leaving the older key active. handleOffer now chains
  applications so offers apply strictly in WS delivery order.
- incrementDmMention had zero callers: the DM @mention badge (dmStore's
  mentionCount, the mute-immune signal DmSidebar renders) never fired
  live, only after a reconnect restored the server count. The dispatcher
  now bumps it under the same guards as the DM unread count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tauri): restart the LiveKit proxy when the TOFU pin changes

After the user accepted a rotated cert, two stale caches kept every
voice rejoin tunneling into the old pin until logout: the Rust reuse
branch returned the running listener (which bakes its fingerprint in at
spawn) without re-reading certs.json, and ensureLiveKitProxy's port
cache never invoked Rust again at all. start_livekit_proxy now loads the
stored fingerprint before the reuse check and tears down on host OR pin
change, and the TS side invokes it on every join — the reuse branch
dedups the unchanged case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(tauri): ignore two unreachable cargo-audit advisories

RUSTSEC-2024-0429 (glib 0.18, Linux-only, Variant::array_iter_str never
called; no semver-compatible fix exists) and RUSTSEC-2026-0097 (rand 0.7
as a phf_generator build-dep with a fixed seed and no log feature; the
pre-release kuchikiki pin blocks the upgrade path). Both entries document
their drop condition inline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): transfer the focused channel on WS resume so the message stream survives

registerNow's replaced-client branch moved voice state and the E2EE key to
the resumed connection but not the focused channel; newClient always starts
with channelID == 0 and the client never re-sends channel_focus on a resume,
so the ChannelTopic re-subscribe was a no-op and the user silently stopped
receiving chat_message until manually switching channels. Transfer the old
connection's focused channel, READ-gated and fail-closed like every other
ChannelTopic subscription.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): always include a voice room's participants in its voice-event audience

broadcastVoiceEvent filtered recipients on READ_MESSAGES while voice
membership is gated on CONNECT_VOICE alone, so a participant in the gap
(e.g. READ revoked mid-call by a channel override) never received the
room's voice_state/voice_leave. The client's E2EE key-holder election and
forward-secrecy rotation run only off the voice_leave WS event, so a
departing key holder was never replaced and new joiners hung until the
e2ee_timeout eject. Union the READ audience with the room's current
participants; what outsiders may observe is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): tear down the failed reconnect attempt's room instead of leaking it

The catch block read this._room, whose typed accessor returns null in the
"reconnecting" state — so the failed attempt's freshly created Room was
never disconnected and kept all its listeners. livekit-client emits
Disconnected synchronously on a failed connect, and in "reconnecting"
state the token/channel/url getters all return values, so each leaked room
spawned an additional concurrent reconnect loop whose AbortController was
discarded and unreachable from leaveVoice. Alias the attempt's room outside
the try and clean it up in the catch, mirroring cleanupAbortedReconnect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(admin): evict voice participants before deleting a channel

CleanupVoiceForChannel was doc-commented 'Called when a channel is
deleted' but had zero production callers, and the voice_states FK cascade
wipes the rows it reads — so deleting a voice channel stranded its
participants with live client voice state, a voice-topic subscription, and
a LiveKit session, and the stale sweeper could never recover them (a
nonexistent channel resolves base-role permission bits). Wire the cleanup
into handleDeleteChannel BEFORE the row delete, via HubBroadcaster.

Also harden the cleanup itself: the row delete and client-state clear are
now conditional on the participant still being in the deleted channel, so
a user who moved rooms mid-cleanup is untouched, and the evicted
participants are always included in their own voice_leave audience (their
client state is already cleared, so the participant union in
broadcastVoiceEvent cannot see them).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): stop leaking an E2EE worker and SetKey listener per voice join

createRoom spun up a fresh E2EE Worker per Room while the key provider
lives for the whole process; livekit's per-room E2EEManager registers a
SetKey listener on the provider with no matching removal and never
terminates the worker. Every join, channel switch, or failed reconnect
attempt therefore permanently added one running worker plus one listener,
and every later setKey posted the new room key into every orphaned worker
— key material outliving its session. Track the worker on the session:
clear provider listeners and terminate the stale worker before each Room,
and terminate it in leaveVoice so the last key does not stay resident.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(service): reject role position collisions on update, matching create

UpdateRole's position branch ran only validatePosition and let an explicit
position land on a slot another role holds — while CreateRole refuses
exactly that, with a comment explaining why: every hierarchy comparison
uses >=/<=, so tied positions read as equal rank and the two roles can no
longer manage each other's members. Refuse a position held by a different
role with the same ErrBadRequest; re-stating the role's own position stays
allowed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): close the window where a dying connection re-takes a pubsub topic

Subscribe had no counterpart to unsubscribeLocked's identity guard: an old
connection's in-flight handler (a channel_focus mid DB round-trip shares no
lock with registerNow) could Subscribe after UnsubscribeAll(old) had run,
stealing the topic from its replacement — whose own unsubscribes then skip
the entry while publishes go to the closed connection. Subscribe now
refuses a client whose send is closed (checked under ps.mu), and
registerNow closes the old client's send BEFORE stripping it, so a late
Subscribe either sees the closed send and is refused or slipped in earlier
and is removed by the subsequent UnsubscribeAll.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(ws): rewrite cold-replay if-else chain as switch (gocritic)

Fixes the ifElseChain lint failure on CI for both platforms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): restore incrementDmMention deleted as dead code on dev

The audit PR (#1327) removed it from dm.store.ts because its only caller
lives on this branch (the DM mention badge wiring), which was not merged
yet. The rebase was textually clean but left dispatcher.ts calling a
function that no longer existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Audit closure pass: DC-04/06/08/09/12/13/15 (E2EE + updater e2e, fail-closed pin lookup, a11y pass, UX polish) (#1329)

* ci(server): run the tag-gated wazero/otel Go tests (DC-06)

The build-tag matrix only compiled the otel/wazero variants; the tests
behind those tags (plugin/sandbox_wazero_test.go 462 lines,
telemetry/telemetry_otel_test.go 214 lines) ran nowhere since they were
written (T-2026-07-25-16). Scoped to the two packages that carry tagged
files; verified green locally before wiring:
go test -tags wazero ./plugin/... and -tags otel ./telemetry/... both pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(client): fail closed when the identity pin store is unreadable (DC-08)

getIdentityPin collapsed a keyring read error into "no pin stored", so a
transient failure sent a pinned peer down the TOFU first-sight path:
verifyPeerAnnounce verified against the server-delivered key and then
RE-PINNED it — a fail-open a malicious server could exploit by inducing
store errors (F3 follow-up 3, plans/security-scan-2026-07-22).

getIdentityPin now returns a three-state IdentityPinLookup
(pinned/unpinned/unavailable), mirroring how tofu.rs keeps Err distinct
from Ok(None) first-use. verifyPeerAnnounce rejects the announce on
"unavailable" without any pin write, records the new "unknown"
PeerVerification status, and the roster badge renders it as an amber
shield-question ("could not check") distinct from the legacy
"unverified" state.

Pinned by unit tests: pin present, no pin, store error (identity.ts),
the fail-closed rejection path (livekit-session), and the badge
presentation (channel-sidebar).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* feat(admin): write a backup_restore audit row that survives the restore (DC-09)

Backup restore was the one admin mutation with no audit_log row —
docs/security.md documented the gap as inherent ("the database is closed
as part of the restore"). The row IS writable durably: written
synchronously (LogAudit, deliberately not the async WriteAudit fast path)
before BackupTo takes the pre-restore safety copy, it is captured inside
pre_restore_*.db and survives the file swap forensically.

The extended restore test opens the pre-restore backup as a database and
asserts the backup_restore row is inside it — proving both the write and
its ordering. docs/security.md now documents where the row lives instead
of the gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* feat(client): channel-delete toast + optimistic reaction toggle (DC-12)

Two messaging-surface gaps the UX specs carried as open:

- channel_delete on the active channel now toasts "This channel was
  deleted" alongside the existing redirect (ux/channels-members-dms
  §1.2) — the redirect alone read as the app spontaneously changing
  channels. Non-active deletions stay silent.

- Reactions toggle optimistically (ux/messaging §5): the pill flips on
  the click, registered under the send's WS envelope id — the same
  correlation scheme as the optimistic message rows. updateReaction
  consumes the matching self-echo instead of re-applying it (the
  delta-based arithmetic would double-count), other users' echoes apply
  normally, and an error reply or local transport failure rolls back
  exactly that toggle via rollbackReaction in the dispatcher's error and
  send-failure handlers. The pill reverting is the failure feedback.

Both spec gap notes flipped to implemented; the stale §2 note claiming
the ready payload lacks slow_mode fell in the same edit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(client): guard the role-change submenu against double-fire (DC-12)

Every other destructive admin action already carried an in-flight guard
(withConfirmation, unblockRunning, banRunning, purge) — the role-change
submenu was the residual: currentRole only updates when the
member_update echoes, so a double-click (or a second option clicked
while the first PATCH was in flight) fired onChangeRole twice. One
shared guard now inerts the whole submenu while a change is running,
with the pending class on the clicked option.

The settings-and-admin spec's in-flight gap note flips to implemented,
and the stale messaging §8 slow-mode note is corrected in the same
docs sweep (the countdown shipped with the ready payload's slow_mode —
verified against ChannelController.startSlowMode and its tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(client): drag-reorder document listeners leak — own them per sidebar signal (DC-12)

The shared document mousemove/mouseup pair was reference-counted per
attached channel row: a sidebar with N channels took N refs (and N more
per re-render) while its single destroy returned exactly one, so the
count never reached zero and the listeners plus their activeDrag
closure lived for the rest of the process — the KNOWN BUG the
drag-reorder test pinned since the 2026-07-25 audit.

Ownership is now a Set of AbortSignals (the sidebar's lifetime
controller — the @lib/disposable teardown idiom): acquisition is
idempotent per signal no matter how many rows attach, release is the
signal's abort, and an owner aborted mid-drag clears the in-flight
visual state (which the old containerEl comparison never actually
matched in production — it compared the category container against
channelList). releaseGlobalDragListeners is gone; ChannelSidebar's
destroy releases via its existing ac.abort().

The pinning test changes with the fix, as its own comment instructed;
the lifecycle block now pins the fixed contract, including
re-registration after full teardown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* test(e2e): E2EE identity-verification and updater journeys (DC-04)

The two remaining client-side headline coverage gaps from the 2026-08-04
audit's flow matrix (rows 38 and 49), both driven through the web mock
harness:

voice-e2ee-verify.spec.ts (6 tests, ux/voice-and-e2ee §7): the peer's
announce is real crypto — an ECDSA P-256 identity key signing an ECDH
ephemeral key exactly as e2eeCrypto does — so the badge states come out
of the production verification path. Covers the verified badge with
safety number (+ TOFU pin on first sight), the legacy unverified badge,
the mismatch block, the mismatch modal's reject path (peer stays
blocked, nothing pinned) and Trust New Key (re-pins the displayed key),
and the DC-08 fail-closed 'could not check' badge when the pin store is
unreadable. The harness gap that kept this untestable is closed by a
voice_join handler that grants a key-holder voice_token plus a WebSocket
shim that parks LiveKit's room.connect forever, holding the session
stably in 'securing'.

updater.spec.ts (4 tests, ux/settings-and-admin §5): no-update silence,
banner + Later dismissal, the full banner → download progress (% and MB
fallback via real update-progress events) → automatic relaunch journey,
and the failure state with Dismiss. There is no restart prompt by design
— the applied state IS the relaunch, asserted via the recorded
plugin:process|restart invoke.

Harness: buildTauriMockScript gains per-test identity-pin config
(identityPins / identityPinError) and a window.__invokeLog recording
every IPC call so tests can assert side effects with no DOM footprint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* style(client): prettier-format the drag-reorder module and new e2e specs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* feat(client): accessibility pass over the modal/overlay stack (DC-13)

The repo had exactly one focus-trapped dialog (UserProfilePopup), one
aria-live region, and no role="dialog" anywhere else. This pass
generalizes that one good implementation into lib/a11y.ts
(applyDialogSemantics, trapFocus, focusDialog, setRovingTabindex,
enableRovingNavigation) and applies it across the stack — all additive:
no DOM classes, testids, structure, or visible text changed.

- modalFactory: every factory modal now carries role=dialog + aria-modal,
  moves focus in on open, restores it on every close path, and Tab-cycles
  inside; createPromptModal is labelled by its title.
- Hand-rolled modals (CertMismatch/CertFirstUse/IdentityMismatch,
  Create/Edit/DeleteChannel, InviteManager): dialog semantics labelled by
  their existing headings, focus trap + restore, aria-label on icon-only
  close buttons, and Escape mapped to each modal's SAFE action (reject on
  the trust prompts — dismissal must never grant trust; cancel on the
  channel modals — never the destructive/submit callback).
- SettingsOverlay: dialog on the panel, focus in on open/restore on
  close; the sidebar is a vertical role=tablist with roving tabindex and
  ArrowUp/Down/Home/End activate-on-focus; the content pane is a
  tabpanel labelled by the active tab.
- QuickSwitcher: dialog + combobox/listbox/option wiring with
  aria-activedescendant tracking the active row. QuickSwitchOverlay:
  dialog + keyboard-operable rows (the inert current-server row stays
  unfocusable on purpose).
- EmojiPicker/GifPicker: listbox/option cells with a roving tabindex
  (Arrow/Home/End move the single Tab stop, Enter/Space activate through
  the click path). inline-autocomplete: option ids + combobox attrs and
  aria-activedescendant on the composer textarea — deliberately NOT
  roving tabindex, since moving DOM focus out of the textarea would
  break typing (the combobox pattern).
- Toast and TypingIndicator are polite live regions (role=status).

Tests: +71 unit cases across 18 files (4474 total, all green) pinning
roles, traps, restores, Escape safety, and roving behavior; plus an
axe-style e2e smoke (a11y-smoke.spec.ts, 5 tests) proving the wiring in
the running app — settings tablist + focus restore to the opener, quick
switcher combobox, member-picker Tab containment, live regions, and the
cert first-use dialog where Escape rejects without trusting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(ux): replace file:line anchors with symbol references (DC-15)

The UX specs cited code as file:line anchors, and a three-week-old
snapshot already had 15 of 58 pointing at entirely wrong code (the audit
measured 200-700 lines of drift). All 55 remaining anchors across the
six spec files now reference the owning symbol instead
("validateForm() in pages/connect-page/LoginForm.ts"), each target
verified to exist before rewriting; the stale ones were re-aimed at the
correct symbol, not just de-numbered. Zero file:line references remain
under docs/architecture/ux/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(audit): record the 2026-08-05 closure pass; stamp DC statuses in place

- audit-2026-08-04-docs-and-coverage.md: DC-06/08/12/13/15 marked
  resolved, DC-04 and DC-09 further-resolved (admin-panel journey and the
  handleApplyUpdate TODO are the remainders), matrix rows 38/49 flipped
  from headline gaps to covered, the §4 UX-problem bullets closed, and a
  §12 closure addendum records what shipped and the verification runs.
- CHANGELOG Unreleased: operator-facing entries for the DC-08 fail-closed
  fix, the a11y pass, the UX polish, the backup_restore audit row, the
  tag-gated CI tests, and the new e2e journeys.
- E2E-ISSUES.md: fresh full-suite run recorded at this HEAD — 291/291
  passed in 9.2 min with zero flaky retries (276 baseline + 6 E2EE + 4
  updater + 5 a11y smoke), @parity 15/15; suite inventory now 40 files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* test(client): fix install-settle race in the updater e2e spec

The install settle handles are created only when the app's
download_and_install_update invoke reaches the mock wrapper, but the spec
called them right after asserting the banner text — which flips
synchronously on click, before the invoke's microtask runs. Local runners
won that race; CI lost it three attempts in a row
(window.__rejectInstall is not a function). Both settle sites now wait
for the handles, same pattern as the listener waits the file already uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Final audit closure: admin-panel e2e, container-safe updates, blocking e2e gate, dependency policy (#1330)

* ci(server): run the tag-gated wazero/otel Go tests (DC-06)

The build-tag matrix only compiled the otel/wazero variants; the tests
behind those tags (plugin/sandbox_wazero_test.go 462 lines,
telemetry/telemetry_otel_test.go 214 lines) ran nowhere since they were
written (T-2026-07-25-16). Scoped to the two packages that carry tagged
files; verified green locally before wiring:
go test -tags wazero ./plugin/... and -tags otel ./telemetry/... both pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(client): fail closed when the identity pin store is unreadable (DC-08)

getIdentityPin collapsed a keyring read error into "no pin stored", so a
transient failure sent a pinned peer down the TOFU first-sight path:
verifyPeerAnnounce verified against the server-delivered key and then
RE-PINNED it — a fail-open a malicious server could exploit by inducing
store errors (F3 follow-up 3, plans/security-scan-2026-07-22).

getIdentityPin now returns a three-state IdentityPinLookup
(pinned/unpinned/unavailable), mirroring how tofu.rs keeps Err distinct
from Ok(None) first-use. verifyPeerAnnounce rejects the announce on
"unavailable" without any pin write, records the new "unknown"
PeerVerification status, and the roster badge renders it as an amber
shield-question ("could not check") distinct from the legacy
"unverified" state.

Pinned by unit tests: pin present, no pin, store error (identity.ts),
the fail-closed rejection path (livekit-session), and the badge
presentation (channel-sidebar).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* feat(admin): write a backup_restore audit row that survives the restore (DC-09)

Backup restore was the one admin mutation with no audit_log row —
docs/security.md documented the gap as inherent ("the database is closed
as part of the restore"). The row IS writable durably: written
synchronously (LogAudit, deliberately not the async WriteAudit fast path)
before BackupTo takes the pre-restore safety copy, it is captured inside
pre_restore_*.db and survives the file swap forensically.

The extended restore test opens the pre-restore backup as a database and
asserts the backup_restore row is inside it — proving both the write and
its ordering. docs/security.md now documents where the row lives instead
of the gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* feat(client): channel-delete toast + optimistic reaction toggle (DC-12)

Two messaging-surface gaps the UX specs carried as open:

- channel_delete on the active channel now toasts "This channel was
  deleted" alongside the existing redirect (ux/channels-members-dms
  §1.2) — the redirect alone read as the app spontaneously changing
  channels. Non-active deletions stay silent.

- Reactions toggle optimistically (ux/messaging §5): the pill flips on
  the click, registered under the send's WS envelope id — the same
  correlation scheme as the optimistic message rows. updateReaction
  consumes the matching self-echo instead of re-applying it (the
  delta-based arithmetic would double-count), other users' echoes apply
  normally, and an error reply or local transport failure rolls back
  exactly that toggle via rollbackReaction in the dispatcher's error and
  send-failure handlers. The pill reverting is the failure feedback.

Both spec gap notes flipped to implemented; the stale §2 note claiming
the ready payload lacks slow_mode fell in the same edit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(client): guard the role-change submenu against double-fire (DC-12)

Every other destructive admin action already carried an in-flight guard
(withConfirmation, unblockRunning, banRunning, purge) — the role-change
submenu was the residual: currentRole only updates when the
member_update echoes, so a double-click (or a second option clicked
while the first PATCH was in flight) fired onChangeRole twice. One
shared guard now inerts the whole submenu while a change is running,
with the pending class on the clicked option.

The settings-and-admin spec's in-flight gap note flips to implemented,
and the stale messaging §8 slow-mode note is corrected in the same
docs sweep (the countdown shipped with the ready payload's slow_mode —
verified against ChannelController.startSlowMode and its tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(client): drag-reorder document listeners leak — own them per sidebar signal (DC-12)

The shared document mousemove/mouseup pair was reference-counted per
attached channel row: a sidebar with N channels took N refs (and N more
per re-render) while its single destroy returned exactly one, so the
count never reached zero and the listeners plus their activeDrag
closure lived for the rest of the process — the KNOWN BUG the
drag-reorder test pinned since the 2026-07-25 audit.

Ownership is now a Set of AbortSignals (the sidebar's lifetime
controller — the @lib/disposable teardown idiom): acquisition is
idempotent per signal no matter how many rows attach, release is the
signal's abort, and an owner aborted mid-drag clears the in-flight
visual state (which the old containerEl comparison never actually
matched in production — it compared the category container against
channelList). releaseGlobalDragListeners is gone; ChannelSidebar's
destroy releases via its existing ac.abort().

The pinning test changes with the fix, as its own comment instructed;
the lifecycle block now pins the fixed contract, including
re-registration after full teardown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* test(e2e): E2EE identity-verification and updater journeys (DC-04)

The two remaining client-side headline coverage gaps from the 2026-08-04
audit's flow matrix (rows 38 and 49), both driven through the web mock
harness:

voice-e2ee-verify.spec.ts (6 tests, ux/voice-and-e2ee §7): the peer's
announce is real crypto — an ECDSA P-256 identity key signing an ECDH
ephemeral key exactly as e2eeCrypto does — so the badge states come out
of the production verification path. Covers the verified badge with
safety number (+ TOFU pin on first sight), the legacy unverified badge,
the mismatch block, the mismatch modal's reject path (peer stays
blocked, nothing pinned) and Trust New Key (re-pins the displayed key),
and the DC-08 fail-closed 'could not check' badge when the pin store is
unreadable. The harness gap that kept this untestable is closed by a
voice_join handler that grants a key-holder voice_token plus a WebSocket
shim that parks LiveKit's room.connect forever, holding the session
stably in 'securing'.

updater.spec.ts (4 tests, ux/settings-and-admin §5): no-update silence,
banner + Later dismissal, the full banner → download progress (% and MB
fallback via real update-progress events) → automatic relaunch journey,
and the failure state with Dismiss. There is no restart prompt by design
— the applied state IS the relaunch, asserted via the recorded
plugin:process|restart invoke.

Harness: buildTauriMockScript gains per-test identity-pin config
(identityPins / identityPinError) and a window.__invokeLog recording
every IPC call so tests can assert side effects with no DOM footprint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* style(client): prettier-format the drag-reorder module and new e2e specs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* feat(client): accessibility pass over the modal/overlay stack (DC-13)

The repo had exactly one focus-trapped dialog (UserProfilePopup), one
aria-live region, and no role="dialog" anywhere else. This pass
generalizes that one good implementation into lib/a11y.ts
(applyDialogSemantics, trapFocus, focusDialog, setRovingTabindex,
enableRovingNavigation) and applies it across the stack — all additive:
no DOM classes, testids, structure, or visible text changed.

- modalFactory: every factory modal now carries role=dialog + aria-modal,
  moves focus in on open, restores it on every close path, and Tab-cycles
  inside; createPromptModal is labelled by its title.
- Hand-rolled modals (CertMismatch/CertFirstUse/IdentityMismatch,
  Create/Edit/DeleteChannel, InviteManager): dialog semantics labelled by
  their existing headings, focus trap + restore, aria-label on icon-only
  close buttons, and Escape mapped to each modal's SAFE action (reject on
  the trust prompts — dismissal must never grant trust; cancel on the
  channel modals — never the destructive/submit callback).
- SettingsOverlay: dialog on the panel, focus in on open/restore on
  close; the sidebar is a vertical role=tablist with roving tabindex and
  ArrowUp/Down/Home/End activate-on-focus; the content pane is a
  tabpanel labelled by the active tab.
- QuickSwitcher: dialog + combobox/listbox/option wiring with
  aria-activedescendant tracking the active row. QuickSwitchOverlay:
  dialog + keyboard-operable rows (the inert current-server row stays
  unfocusable on purpose).
- EmojiPicker/GifPicker: listbox/option cells with a roving tabindex
  (Arrow/Home/End move the single Tab stop, Enter/Space activate through
  the click path). inline-autocomplete: option ids + combobox attrs and
  aria-activedescendant on the composer textarea — deliberately NOT
  roving tabindex, since moving DOM focus out of the textarea would
  break typing (the combobox pattern).
- Toast and TypingIndicator are polite live regions (role=status).

Tests: +71 unit cases across 18 files (4474 total, all green) pinning
roles, traps, restores, Escape safety, and roving behavior; plus an
axe-style e2e smoke (a11y-smoke.spec.ts, 5 tests) proving the wiring in
the running app — settings tablist + focus restore to the opener, quick
switcher combobox, member-picker Tab containment, live regions, and the
cert first-use dialog where Escape rejects without trusting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(ux): replace file:line anchors with symbol references (DC-15)

The UX specs cited code as file:line anchors, and a three-week-old
snapshot already had 15 of 58 pointing at entirely wrong code (the audit
measured 200-700 lines of drift). All 55 remaining anchors across the
six spec files now reference the owning symbol instead
("validateForm() in pages/connect-page/LoginForm.ts"), each target
verified to exist before rewriting; the stale ones were re-aimed at the
correct symbol, not just de-numbered. Zero file:line references remain
under docs/architecture/ux/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(audit): record the 2026-08-05 closure pass; stamp DC statuses in place

- audit-2026-08-04-docs-and-coverage.md: DC-06/08/12/13/15 marked
  resolved, DC-04 and DC-09 further-resolved (admin-panel journey and the
  handleApplyUpdate TODO are the remainders), matrix rows 38/49 flipped
  from headline gaps to covered, the §4 UX-problem bullets closed, and a
  §12 closure addendum records what shipped and the verification runs.
- CHANGELOG Unreleased: operator-facing entries for the DC-08 fail-closed
  fix, the a11y pass, the UX polish, the backup_restore audit row, the
  tag-gated CI tests, and the new e2e journeys.
- E2E-ISSUES.md: fresh full-suite run recorded at this HEAD — 291/291
  passed in 9.2 min with zero flaky retries (276 baseline + 6 E2EE + 4
  updater + 5 a11y smoke), @parity 15/15; suite inventory now 40 files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* test(client): fix install-settle race in the updater e2e spec

The install settle handles are created only when the app's
download_and_install_update invoke reaches the mock wrapper, but the spec
called them right after asserting the banner text — which flips
synchronously on click, before the invoke's microtask runs. Local runners
won that race; CI lost it three attempts in a row
(window.__rejectInstall is not a function). Both settle sites now wait
for the handles, same pattern as the listener waits the file already uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* ci: promote client-e2e to blocking (DC-07)

The soak is decided: green full-suite runs at 270, 276 and 291 tests across
the audit branches, and the one hard failure in the window was a real spec
bug a non-blocking job would have let rot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* feat(server): refuse in-place self-update in container deployments

Resolves the long-standing handleApplyUpdate TODO. In a container the
running binary is image content: the staged replacement dies with the
container and the restart comes back as the old image. RunningInContainer
(OWNCORD_CONTAINER authoritative both ways — the shipped Dockerfile sets 1,
bind-mount operators can set 0 — with /.dockerenv//run/.containerenv as
fallback) now gates POST /admin/api/updates/apply with 503
CONTAINER_DEPLOYMENT before any updater logic, GET /admin/api/updates gains
can_apply, and the admin SPA swaps the apply button for an image-upgrade
note when it is false.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs: adopt the dependency pinning/review policy (DC-11, 2026-04-07 #8)

Writes down the policy the lockfiles already enforce: lockfiles
authoritative with npm ci-only installs, weekly Dependabot with majors
adopted deliberately, per-PR security gates (npm audit on shipped deps,
govulncheck, cargo audit, knip), and toolchain-level version pins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* test(admin): add the admin-panel e2e journey against a real server (DC-04)

The admin SPA was the one surface no suite could reach: it is served by the
Go server and mocked nowhere. start-server.sh builds and boots a real
server (fresh temp data dir, TLS off, loopback) and the journey drives the
SPA end to end — first-run wizard creating the owner, dashboard stats,
channel create/rename, audit-log rows for both mutations, and sign-out/
sign-in. One shared page keeps the localStorage session across the serial
steps, mirroring the native suite's persistent fixture and staying under
the 5-logins/min limiter; on a Playwright retry the wizard branch downgrades
to login since setup is one-shot server-side. New non-blocking admin-e2e CI
job on the same graduation convention client-e2e followed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(audit): final closure — every DC finding resolved or deliberately reserved

Records the owner-directed closure pass (§13): DC-04 fully (admin journey
was the last row), DC-07 (client-e2e blocking), DC-09 fully (container-
aware update refusal), DC-11 + 2026-04-07 #8 (dependency policy).
Remaining open items are all deliberate: DC-14 reserved protocol entries,
the admin-e2e soak graduation, and the accepted/tracked 2026-04 carryovers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(attachments): stop the orphan sweep destroying every avatar and the grace period

The 15-minute maintenance sweep deleted attachment rows and their files for
any attachment with message_id IS NULL. Avatars are exactly that by design:
users.avatar points at the attachment by URL and nothing ever links it to a
message (migration 027). Every avatar in the instance was therefore destroyed
on the first tick past the grace period, permanently 404ing every profile
picture. The query now excludes attachments a user's avatar still points at.

Independently, the cutoff was formatted RFC3339 while uploaded_at is written
by SQLite as 'YYYY-MM-DD HH:MM:SS'. TEXT comparison is bytewise and ' ' sorts
before 'T', so every unlinked upload sharing the cutoff's UTC date was swept
regardless of time -- the one-hour grace collapsed to 'immediately'. Rather
than fix the format at the one call site, DeleteOrphanedAttachments now takes
a time.Time and formats it internally, so no caller can reintroduce the class.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): send old_password so changing a password can succeed

The client posted {current_password, new_password} while the server decodes
json:"old_password" (Server/api/profile_handler.go:43). Go's encoding/json
does no alias matching, so OldPassword was always empty and every password
change returned 400 INVALID_INPUT -- the feature could never work for anyone.
docs/api.md and every server test already document old_password.

The existing unit test asserted the client's own broken payload, so it passed
while the feature was dead; it now asserts the documented server contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(admin): roll back and restart when a backup restore fails mid-copy

copyFile truncates the destination with os.Create before it can know whether
the read will succeed. On the restore path the destination is the live
database, already closed, so a failure in io.Copy or Sync left a zero-byte
chatserver.db, no rollback, and -- because the old code returned before the
restart -- a process still answering requests against a closed DB while the
response and the server_restart broadcast both claimed a restart was underway.

The failure branch now puts the pre-restore safety copy back (saying so
honestly in the error, including when the rollback itself fails) and requests
the restart the success path already did.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(voice): refuse client-side unmute while server-muted by a moderator

Push-to-talk called LiveKitSession.setMuted directly, which had no
server-mute guard -- only the voice widget's own handler checked. Unmuting
re-publishes a fresh mic track, and since MuteParticipantAudio only mutes the
track SIDs that exist at mute time while the LiveKit grant still carries the
microphone publish source, the SFU accepted it: holding PTT lifted a
moderator's mute and never told the server.

The guard now lives in setMuted itself, the one entry point every caller
shares, so PTT and any future caller are covered. Muting stays allowed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): force a full ready when a client's seq is ahead of the ring buffer

EventsSince/EventsSinceFiltered guarded only the lower bound, so a client
asking for events newer than anything the buffer ever held got a non-nil empty
slice -- which handleReconnect reads as a successful, complete replay. It then
registers the client, sends auth_ok with replay_source=buffer and skips ready
entirely, leaving stale members, channels and read state until the counter
climbs back past the client's remembered value.

That disagreement is reachable in normal operation: the hub seeds its counter
from GetMaxEventSeq, which is 0 once the 24h pruner has emptied the table, so
a restart can reseed seq below a lastSeq clients preserve across reconnects.

Both functions now return nil (the existing 'cannot guarantee coverage'
signal) when afterSeq exceeds the newest buffered seq, so the caller falls
through to the cold tier and the intended full ready. afterSeq == newestSeq
remains the legitimate caught-up case and still replays empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): re-send channel_focus on auth_ok so reconnects keep receiving messages

channel_focus was sent only by mountChannel, which early-returns when the
channel id is unchanged, so a reconnect into the same channel never re-sent it.
The server transfers the focused channel from the old connection, but only
while that connection is still registered -- readPump's defer unregisters it
and drops every topic subscription the moment the server observes the close,
about a second before the client's first retry. Any server-observed close
(restart, proxy close, network reset) therefore resumed with no ChannelTopic
subscription: server channel messages, edits and reactions are delivered
exclusively over that topic, so the message stream went silently dead while
global events kept arriving and made the connection look healthy.

auth_ok fires on every connection including resumes and the full-ready
fallback, and it also covers the server-restart case where there is no old
state to transfer from. The server's handler is idempotent, so the extra focus
on a fresh connect is harmless.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(messages): stop persisting HTML-escaped text, safely

bluemonday writes text tokens through html.EscapeString, so sanitizeContent
persisted and broadcast the escaped form: every apostrophe, quote, ampersand
and angle bracket reached other users as a literal entity, and because stored
quote lines began with '&gt;' the client's blockquote regex could never fire.
cleanText (display names, about, custom status, DM names) had the same bug.

Unescaping bluemonday's output alone would be a sanitizer bypass: surviving
text tokens can recombine into live markup -- '<<script>script>alert(1)<'
+ '</script>/script>' reassembles a real end tag. Instead the whole
unescape -> Sanitize -> unescape cycle now runs to a fixpoint, so the stored
result is by construction stable under re-sanitizing: any '<' that the
tokenizer would read as a tag start is stripped rather than re-encoded, and
only inert punctuation survives. The loop is bounded by the input length and
each pass is non-increasing; measured worst case over pathological tag/entity
soup at the 16 KiB input ceiling is under a millisecond.

The fuzz sinks are tightened to match the new contract rather than loosened:
they now require a tag-like start ('<' + letter or '/') because a bare '<'
followed by punctuation is inert plain text under every client render path.
The <script substring and idempotency checks are unchanged. Verified with
4.2M fuzz executions, zero crashers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): close the WS task and TLS socket on disconnect via generation-owned teardown

ws_disconnect dropped the slot's sender expecting the write task to end, but
the monitor task held my_tx — a Sender clone kept only to prove teardown
ownership — so rx.recv() could never yield None: the monitor waits on the
writer via join_next() while the writer waits on the monitor's clone
dropping. Every intentional disconnect or profile-switch reconnect leaked
the writer, the reader, and the TLS socket, and with no server-side read
deadline the connection stayed registered — the user remained presence-online
after logout, and the stale Rust reader kept injecting the old server's
events into the new session's stores.

Ownership is now proven by the connection generation that already guards
install: the monitor captures my_generation plus the generation Arc and
clears/announces only if the generation is still current, checked under the
slot lock (generation only advances inside begin_connection while that lock
is held, so check-and-clear is atomic against new attempts). install_sender
receives the only Sender, so dropping the slot's sender really closes the
channel: writer exits, join_next returns, abort_all reaps the reader, and
the socket drops.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(voice): send voice_leave when the E2EE key exchange times out

connectAndSetup's key-exchange failure branch called leaveVoice(false) — no
voice_leave frame, no leaveVoiceChannel(). The timeout fires BEFORE
room.connect(), so no SFU participant ever exists and no LiveKit webhook can
clean up, while the server already registered the join when it sent
voice_token. The orphaned voice_states row matches the connected client's
channel, so sweepStaleVoiceStates never reaps it; once the ghost has the
lowest uid it wins key-holder election with a cleared E2EE state, every
later joiner's exchange times out and ghosts too, and rejoining the same
channel bounces off ALREADY_JOINED.

Mirror the reconnect-exhausted give-up path: leaveVoice(true) +
leaveVoiceChannel(), so the server drops the row and the local store
converges. The supersession checkpoints keep leaveVoice(false) — there a
newer attempt owns the server-side state and a voice_leave would destroy it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): keep voice state when the replay-failure fallback will transfer it

handleFreshConnect's stale-voice cleanup ran unconditionally, but the
replay-failure fallback (lastSeq > 0, e.g. after a restart reset the seq
counter) reaches it while the old connection is still registered — and
registerNow then transfers that connection's live voice state into the new
client. The cleanup had already deleted the DB row, broadcast voice_leave,
and removed the live LiveKit participant (using the very JoinedAt token
being transferred), so the user ended up "in voice" on the hub only:
voice_join bounced off ALREADY_JOINED and sweepStaleVoiceStates never
reaps in-memory state without a row.

Skip the cleanup when lastSeq > 0 and the still-registered old client's
voiceChID matches the row — exactly the case registerNow transfers. All
other cases (F5 fresh connects, no old client, mismatched channel) keep
the existing cleanup, and if the old client unregisters in the window
before registerNow, the untransferred row is reaped by the next sweep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): stop the mic meter stream when it resolves after teardown

The mic-meter IIFE had no post-await guard: a getUserMedia resolving after
SettingsOverlay.hide() ran cleanup() (or after the tab's signal aborted)
opened the microphone anyway, started the rAF meter loop, and registerMic
re-armed state that cleanupMic() had already cleared — the mic stayed hot
for the rest of the session with nobody left to stop it.

Mirror the camera preview's request-id guard: cleanupMic()'s invalidation
callback now bumps a micRequestId alongside cameraRequestId, the IIFE
captures the id before the await, and a stale or aborted request stops the
just-acquired tracks and bails before touching the AudioContext.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api,db): close the API-token and stale-ban holes in 2FA and account deletion

Four related gaps around sessionless (API-token) principals and account
teardown, all found by the bughunt harvest:

- 2FA enable/disable skipped the BUG-108 "revoke other sessions" step
  entirely when the caller authenticated with an API token (nil session).
  Both handlers now use the change-password pattern: keep=0 matches no
  row, so every login session is revoked.
- verify-totp issued a session to a user banned after the password step;
  it now runs the same IsEffectivelyBanned refusal as login.
- DeleteAccount left API tokens active (they authenticate independently
  of the purged sessions) and left a stale lapsed ban_expires in place,
  which makes banned=1 read as NOT banned — together a previously
  temp-banned self-deleted account stayed fully usable through any
  owner-minted token. Tokens are now revoked in the purge and
  anonymiseUser sets ban_expires = NULL.
- The last-admin guard resolved admin-class roles by display name
  ('Owner','Admin'), so renaming the seeded Admin role silently disabled
  self-deletion protection for its holders. It now keys on the canonical
  OwnerRoleID/AdminRoleID plus any role holding the Administrator bit.
  (The harvest's suggested criterion — Owner ID or Administrator bit
  alone — would have DROPPED seeded Admins, whose 0x3FFFFFFF permissions
  lack bit 30; the ID-based form preserves existing guard semantics.)
- DeleteAccount also now applies LeaveGroupDM's invariant: DM channels
  left with zero participants are removed instead of becoming
  unreachable, undeletable rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api,ws): rate-limit gaps — bucket isolation, focus/mark_read, call_decline, reaper horizon

- Every empty-prefix RateLimitMiddleware mount shared one bare-IP bucket,
  and the limiter records a timestamp per call regardless of the limit
  passed — so unrelated endpoints capped each other at the minimum limit
  (five ordinary profile edits 429'd the password endpoint; NAT'd logins
  blocked register). The prefix is now a required parameter and every
  mount names its own bucket, mirroring the existing client_update:/
  livekit_proxy:/gif: pattern. The sessions-list handler also stops
  401ing API-token principals (nil session only ever fed IsCurrent).
- channel_focus and mark_read were the only user-facing V2 handlers with
  no rate limit, and each drives an unmetered SQLite write plus pubsub
  churn; they now share a 5/s per-user budget (same underlying service
  call), silently dropping over-budget frames like their siblings.
- call_decline gets the same limiter as its sibling call_ring — the
  identical participant-lookup-plus-fan-out cost shape.
- The rate-limiter reaper pruned any entry idle past 15 minutes, but slow
  mode passes windows up to the 6 h admin cap, so long slow modes were
  silently reset; the cleanup horizon now covers the largest real window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(server): unreachable route envelopes, dead env override, admin paging, plugin/updater defects

Seven harvest findings across router, config, admin, plugin and updater:

- The global 1 MiB body cap shadowed every route with a larger documented
  envelope: the 16 MiB plugin install 400'd at ~1 MiB and an at-limit
  avatar could never fit its multipart framing. The exemption list is now
  a named var covering uploads, plugin install, and avatar — each of
  which enforces its own cap at the route/handler level.
- queryInt clamped offset with the limit's 500 cap, so the admin audit
  log and user list could never page past row 550; the cap is now an
  explicit per-call bound (offset callers pass MaxInt32).
- OWNCORD_EVENT_PERSISTENCE_* env overrides were documented but dead:
  envKeyToKoanf cut at the first underscore, producing the unknown path
  event.persistence_* that koanf silently drops.
- InstallPlugin trusted LastInsertId, which SQLite does not update on the
  upsert's DO UPDATE branch — on the shared writer connection a reinstall
  returned the rowid of some unrelated prior INSERT, so EnablePlugin
  no-opped and plugin_kv wrote to a nonexistent plugin id. RETURNING id
  is correct on both branches.
- Every wazero plugin re-activation compiled the module again and leaked
  the previous CompiledModule; the handle is now retained on the instance
  and closed in deactivate, the lost-activation race, and the
  closed-module release path.
- Linux server self-update was gated on the Windows-only
  chatserver.exe.sig asset it never uses; the required-asset check and
  the signature fetch are now GOOS-aware.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): batch S4 — seq-gap shed, replay tail merge, drain-on-close, handshake teardown

Eight harvest findings in the hub/replay/pump paths, each locked by a
watched-red test in harvest_s4_internal_test.go / reconnect_db_test.go:

- kickClient closes the send channel BEFORE UnsubscribeAll so a racing
  Subscribe can never leave a dead client holding a topic.
- deliverBroadcast consults the topic limiter BEFORE allocating a seq: a
  shed frame no longer burns a sequence number that sits in the replay
  buffer forever unpublished.
- onStaleTick prunes idle topic-limiter buckets (Cleanup had no caller).
- dm_channel_open bumps the visibility watermark so a client resuming
  from an older seq takes the full-ready path instead of silently losing
  the targeted, unsequenced open.
- computeAllowedChannels treats a DM-lookup failure as fatal (full ready)
  instead of replaying with every DM event silently stripped.
- Cold-tier replay merges the ring-buffer tail past the newest persisted
  row; if the buffer cannot vouch for the flush gap it forces full ready.
- writePump drains queued frames (e.g. the BANNED kick reason) after
  closeSend instead of dropping them on the first closed channel.
- A failed post-registerNow handshake runs the offline teardown when no
  replacement connection holds the slot — no more users stuck online.

Declined by design: hoisting registerNow above the replay snapshot
(report L390) — every fallback path would re-register the same client
and registerNow self-kicks the slot holder; the µs dedup window does not
justify that risk in the hottest path.

The kickClient ordering test is a 300-iteration stress whose race window
is too narrow to hit reliably; it documents the invariant rather than
having been watched red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(client): batch C1 — store merges, ready-badge resync, pending-send teardown

Ten harvest findings in the stores/dispatcher layer, each locked by a
watched-red vitest test:

- setMessages merges instead of clobbering: live broadcasts and
  pending/failed optimistic rows that landed while the history GET was
  in flight survive the snapshot.
- addChannel is idempotent — the re-sent channel_create on role edits no
  longer wipes unread/mention counts, lastMessageId, or canSend.
- setChannels carries client-synthesized DM rows across the rebuild.
- READY marks the focused channel read after the store repopulation so
  stale server read_states cannot resurrect badges on the channel the
  user is actively reading (skipped on first connect).
- setVoiceStates maps the ready payload's camera/screenshare flags
  instead of blanking live streams on a mid-call resync.
- The dm_channels length guard is gone: an empty array is authoritative
  and clears ghost DMs.
- addMessage's defensive pending-row reconcile requires content equality
  so another session's replayed message cannot consume the pending row.
- performSend into a detached history window reattaches to present
  first, mirroring onJumpToPresent.
- prependMessages at the cap keeps the fetched older page and detaches
  the window instead of silently discarding the fetch (which refetched
  the same page forever); hasMore is the server's value again.
- A connection leaving "connected" fails every pending optimistic send
  (retry affordance) instead of letting rows spin forever.

One existing assertion updated to the corrected semantics: trimming on
prepend now drops rows below the window, so hasMore stays the server's
value and the test asserts the detach instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(server): batch S5 — voice eviction scoping, fail-closed camera cap, role-service races

Seven harvest findings in the voice/service layer, each locked by a
watched-red test:

- The CONNECT_VOICE revocation sweep evicts via a channel-conditional
  clear (the in-memory analogue of LeaveVoiceChannelIfMatch): a
  voice_join to a permitted channel that commits while the DB-backed
  permission check runs can no longer be torn down. The report's
  suggested pre-check guard was rejected — it leaves the same race open
  between guard and clear, proven by the interleaving test.
- A failed channel switch's abort branch re-subscribes the restored
  session to its VoiceTopic and re-elects the key holder; without them
  the session silently missed every voice_e2ee relay.
- voice_camera fails closed when the VoiceMaxVideo lookup errors instead
  of skipping the cap check and enabling unconditionally.
- LiveKitProcess starts the child inside the p.mu critical section that
  publishes p.cmd (Wait stays outside), removing the data race between
  Start's cmd.Process write and IsRunning/Stop reads.
- AffectedUserIDs reports lookup success; handlePatchRole falls back to
  a blanket permission-cache invalidation when the member list was
  unreadable, instead of evicting nobody and leaving revoked grants live.
- RoleService serializes its read-check-write mutations (position
  uniqueness and the role cap are snapshot-enforced, not DB-enforced);
  concurrent creates can no longer land on the same position and tie
  every hierarchy comparison.
- channel_focus writes the read state even when the channel has no
  undeleted messages — the upsert is what zeroes mention_count, so
  emptied channels finally clear their badge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(client): batch C2 — voice eviction teardown, supersession scoping, VAD generation

Seven harvest findings in the voice/session layer, each locked by a
watched-red vitest test:

- A server-initiated voice_leave for self tears down the LiveKit session
  (mic publish + E2EE key material), guarded on channel match so a
  late-arriving leave cannot kill a newer join.
- VIDEO_LIMIT refusal rolls back with disableCamera() — max_video has no
  SFU-level enforcement, so the already-published track kept streaming.
- teardownForReconnect sends voice_camera/voice_screenshare OFF frames
  before stopping local tracks, freeing the server-side max_video slot a
  reconnect otherwise occupies forever.
- Supersession checkpoints 3/4/5 disconnect only their own local room
  (mirroring checkpoint 2) instead of calling the global leaveVoice,
  which by then tears down the newer attempt's live session.
- retryMicPermission honors a moderator's server-mute like it honors
  deafen — granting mic while listen-only no longer hands the channel an
  unmuted track.
- handleDisconnected defers to the active reconnect loop (livekit-client
  fires Disconnected synchronously inside the loop's own connect call),
  preventing a second uncancellable retry loop.
- stopVadPolling invalidates an in-flight startVadPolling addModule via
  a VAD-scoped generation counter, so VAD cannot resurrect itself with a
  stale threshold.

Deliberately skipped: the report's optional RATE_LIMITED camera rollback
— that error code is shared by unrelated actions and the payload cannot
attribute it to a camera toggle, so a blind rollback would be wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(client): batch C3 — E2EE rotation races, pin-write tri-state, stale-offer guards

Six harvest findings in the voice-E2EE key-exchange layer, each locked
by a watched-red vitest test; the security-invariant sweep (re-pin
TOCTOU, forward-secrecy rekey, concurrent-rotation, blind-repin — 239
E2EE-adjacent tests) stays green:

- Re-election as key holder during an in-flight rotation defers (sets
  _isKeyHolder + _rotationPending, mirroring the sibling branch) instead
  of dropping the election and stranding the room without a holder.
- storeIdentityPin returns tri-state stored/no-store/failed; a FAILED
  pin write now marks the peer unverified instead of displaying
  "verified" with no pin persisted — an unpinned peer could never trip
  mismatch detection, the exact MITM window the pin exists to close.
- handleOfferInner discards a stale offer when the session keypair
  changed, not just the epoch — a non-key-holder never bumps epoch, so
  an offer surviving clearState() into the next session passed the
  epoch-only check.
- handleAnnounce's wrap-and-offer path gets the same epoch guard as the
  receive path, so a rotation landing mid-wrap cannot ship a dead key.
- The key-exchange retry races a FRESH promise (the first rejection had
  permanently settled the old one, making the retry window zero), and
  aborts cleanly when clearState() tore the session down mid-exchange.
- setupKeyExchange publishes _ecdhKeyPair only after _isKeyHolder and
  _roomKey are ready, so a concurrent announce is queued and drained
  through the offer-sending path instead of being consumed offer-less.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(client): batch R1 — secret store must not report a broken keyring as empty

Three harvest findings in the Tauri credential store, each locked by a
watched-red test:

- secret_store::get treated a keyring read error as "nothing stored"
  whenever the fallback file was also empty, which is indistinguishable
  from first login. loadOrGenerateIdentityKeyPair reads exactly that
  signal, so an unreadable keychain made the client mint and publish a
  fresh identity key over the existing one, invalidating every peer's
  TOFU pin. It now prefers a fallback copy and otherwise propagates the
  error; loadIdentityKey rethrows instead of swallowing to null.
- A failed keyring write left any older entry in place while the fresh
  secret went to the fallback file — and get() reads the keyring first,
  so the stale value shadowed the new one forever. The write-failure arm
  now purges the entry, mirroring the read-back-mismatch arm beside it.
- fallback_crypto deleted nothing when the key file's write or sync
  failed, leaving a short file that every later load rejects; since the
  key file is never rewritten once it exists, one ENOSPC poisoned the
  fallback store permanently.

Both Rust fixes needed a small injectable seam (get_with/set_with,
finish_new_key_file) because the keyring error branches are otherwise
unreachable without a live OS credential store.

The saved-login path is unaffected in behavior: loadCredential still
catches and degrades to "no saved credential" rather than surfacing the
new error. The persistence re-read in loadOrGenerateIdentityKeyPair
deliberately does not rethrow — the keypair already exists in memory by
then, so a transient failure keeps the existing "did not persist" warning.

fallback_crypto is cfg(not(windows)), so its test ran only under a
temporary local gate lift (reverted, verified no residual diff); it
executes for real on the Linux and macOS CI runners.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: rewrite CLAUDE.md per Claude 5 context guidance; commit skills + hunt workflow

The CLAUDE.md files were a manual: build commands, code style, layout Claude
can read off the filesystem. Rewritten so they are short and spend their
tokens on gotchas instead — the things that are invisible until they cost an
afternoon.

Moved out of CLAUDE.md into skills (progressive disclosure), which also fixes
three references to skills that never existed:
- ci-check: the full local CI mirror, including the four Go build-tag variants
  and the deadlock pass a plain build/test misses, and the windows-latest
  runtime.scanstack GC fault that should be rerun rather than investigated.
- db-change: the sqlc workflow plus three silent traps — non-ASCII query files
  truncating the NEXT query's emitted SQL, semicolons in migration comments
  orphaning statements, and LIMIT 1 mis-emitting on a :one query.
- protocol-change: regenerate both constant files and commit the pair.

Dropped: command lists duplicated from the Makefile and package.json, prettier
style rules the formatter already enforces, and layout facts a directory
listing answers. Added the subsystem invariants that keep getting rediscovered
the hard way — the ws seq/FIFO contract, voice-session supersession scoping,
E2EE staleness guards, and the Node 22 webstorage failure mode.

.claude/ is no longer ignored wholesale: skills and workflows are tracked so a
cloud session, which sees only tracked files, starts with instructions rather
than nothing. Machine-local settings and locks stay ignored. Deleted
bughunting.js, a superseded copy declaring the same workflow name as
bughunt.js, which left the registry ambiguous.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(client): batch C4 — cert-latch scoping, stale active channel, credential opt-out

Thirteen harvest findings across the client UI and wiring, each locked by a
watched-red vitest test:

- The TLS cert-mismatch latch fired on any host's event, so an unrelated
  saved profile's rotated certificate permanently killed this socket's
  reconnect loop. It now latches only for the connected host, cancels any
  pending reconnect (a timer armed before the mismatch would otherwise fire
  connect(), clearing the latch and resuming against that host), and resets
  on a fresh connect.
- ready never cleared activeChannelId when the channel vanished from the
  snapshot, and MainPage's subscriber had no else branch — the message list
  and composer stayed mounted and enabled against a channel the server no
  longer recognizes. Both sides fixed; the mark-read from batch C1 is
  suppressed when the clear happens.
- user_update re-saved the session token unconditionally, bypassing the
  remember-password opt-out, and dropped the stored password while doing it.
- A failed older-page fetch latched loadingOlder, permanently killing
  infinite scroll for that view; it now clears in a finally.
- Concurrent message jumps raced, letting the older response overwrite the
  newer window. Guarded by a generation counter.
- A FORBIDDEN send in a group DM flagged participants[0] as blocking, which
  disabled the unrelated 1:1 composer with that person; block gating is
  1:1-only.
- streamPreview added an abort listener per call instead of per signal.
- dm_channel_close had no fallback when the closed DM was being viewed;
  both call sites now share one closeDmLocally helper.
- The GIF picker routed through the textarea and discarded the draft.
- QuickSwitcher listed DM rows that the DM section already shows.
- Accepting a rotated certificate reconnected into a page with nothing left
  listening, stranding the user on the connect screen.
- Logout read voiceStore after clearAuth had already reset it, so the
  voice_leave was never sent; clearAuth now snapshots logoutWasInVoice.
- disconnect() left reconnectAttempt set, carrying a stale backoff ceiling
  into the next login.

Also fixes two lint errors this branch introduced earlier and that only a
full `npm run lint` catches: a useless spread in the C1 pending-send sweep
(now Array.from, which states the snapshot intent), and two floating
promises in C2's voice_leave handler, where converting an implicit-return
arrow to a block body stopped chaining them.

main.ts and MainPage.ts have no unit-test seam, so three focused pieces were
extracted to make the fixes testable: createUserUpdateCredentialSaver,
reconnectAfterCertAccept, and the logoutWasInVoice snapshot.

One existing assertion corrected: a dispatcher test claimed ready must keep
an active channel that was absent from the payload, which locked the bug.
It now keeps a channel that is present, with a sibling test for the absent
case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: resolve 107 verified defects across ws hub, voice/E2EE, db, and client

Findings came from a multi-round hunt, each verified against the tree by an
independent adversarial pass before any code changed, then fixed and re-reviewed.
Every behavioural fix carries a regression test that was confirmed to fail
against the pre-fix code.

Server (Go)
- Reconnect/replay: force a full ready when retention pruning has removed the
  events after a client's last_seq, rather than accepting the surviving suffix
  as a complete resume; close the snapshot-to-registration window under seqMu;
  restore the focused-channel subscription during the handshake via a new
  READ-gated active_channel_id auth field; supplement replay with the client's
  own voice room; tear down transferred voice sessions on a failed handshake.
- Hub: ratchet visibilityChangeSeq upward only (all three writers); make the
  stale-voice sweep error-aware so a transient DB failure no longer evicts
  every participant; re-elect the E2EE key holder on sweep and cleanup paths.
- Voice: preserve moderator mute/deafen across channel switches; deliver
  voice_leave to evicted users; gate camera/screenshare permission checks on
  the enabling direction only; reject joins to non-voice and archived channels.
- Permissions: archived channels are now read-only and unjoinable, and
  can_send is recomputed per client on role/override changes.
- Data: stop cascaded message deletes from stranding uploaded files
  (migration 030 unlinks instead); clear personal data on account deletion;
  exclude banned users from owner lookup; drop the silent 1000-member cap;
  advance the author's own read state on send.

Client (TypeScript / Rust)
- Voice: make joinGeneration monotonic so a superseded attempt can no longer
  pass supersession checks; scope aborted-path cleanup to the attempt's own
  room; send voice_leave on connect failure; stop push-to-talk from writing the
  user's explicit mute flag; gate join-time PTT muting on a new backend
  capability probe so platforms that cannot report key state are unaffected.
- E2EE: act on the tri-state pin-write result instead of reporting an
  unverified peer as verified; use keypair ownership rather than null checks.
- State: reset the message cache on logout; clear NSFW acknowledgements on
  logout; scope channel mutes, NSFW acks and DM notes per server host.
- UI: make the attachment remove button and the failed-send Retry/Discard
  buttons work; fix drag-reorder's phantom-drag latch and its permission gate.

Docs: protocol.md now documents can_send, active_channel_id, the archive
read-only contract, and the sequenced/unsequenced presence split.

Verified: all four Go build tag variants, go vet, go test -race, the ws
deadlock detector, sqlc and protocol generation, tsc, eslint, prettier, and
the full client suite (169 files, 4664 tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWUzUmsjCKNAzAfQzT4fz7

* fix(ci): satisfy golangci-lint, knip, and the host-scoped mute key in e2e

Three CI failures on the previous commit, all genuine fallout from it.

golangci-lint (v2.11.3) — 6 issues in tests added by that commit:
- contextcheck: the temp-ban subtest captured an outer ctx while calling
  seedTokenUser, which builds its own; declare ctx inside the subtest instead.
- modernize: use WaitGroup.Go and range-over-int in three tests.

knip — SessionResponse in lib/types.ts became unused. The getSessions fix
replaced it with SessionInfo in lib/api.ts, which documents why the old
declaration was wrong (it named ip_address/expires_at, which the server never
sends, and omitted ip/is_current, which it always does). Delete the dead type
rather than re-export it, and fold that reasoning into the surviving comment.

Client E2E — the per-channel-mute parity test asserted the pre-scoping
localStorage key. Channel mutes are now keyed mutedChannels:<host>, because
channel ids are per-server autoincrement integers sharing one webview origin;
verified in a browser that the app writes
owncord:settings:mutedChannels:localhost:8443. The test now resolves whichever
scoped key exists instead of pinning the test server's host, so it still
asserts the same thing: the id persists on mute and is gone on unmute.

Verified with the CI linter version built against Go 1.26 (0 issues), all four
build tag variants, go vet, go test -race, the ws deadlock detector, knip,
tsc for both tsconfigs, prettier, the full client unit suite, and the
previously-failing parity specs run in a real browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWUzUmsjCKNAzAfQzT4fz7

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 21:20:48 +02:00
dependabot[bot] b77c2790b0 chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#1324)
Bumps the npm_and_yarn group with 2 updates in the /tools/mcp-introspect directory: [fast-uri](https://github.com/fastify/fast-uri) and [hono](https://github.com/honojs/hono).


Updates `fast-uri` from 3.1.4 to 3.1.5
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.4...v3.1.5)

Updates `hono` from 4.12.32 to 4.13.0
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.32...v4.13.0)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.5
  dependency-type: indirect
- dependency-name: hono
  dependency-version: 4.13.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 15:42:26 +00:00
dependabot[bot] 931a2fd27e chore(deps): bump postcss from 8.5.19 to 8.5.25 in /Client/tauri-client (#1322)
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.19 to 8.5.25.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.19...8.5.25)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.25
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 15:22:06 +00:00
J3vbandClaude Opus 5 1486078265 ci: skip the Tauri full build on Dependabot PRs (#1325)
Dependabot PRs run under the separate `dependabot` secrets scope, so
TAURI_SIGNING_PRIVATE_KEY arrives empty and `npm run tauri build` always
aborted with "failed to decode secret key" while signing the updater
artifact -- after the compile and the NSIS/AppImage/deb bundle had both
already succeeded. Every dependency PR therefore burned ~50 min of runner
time across three platforms to produce a red check carrying no signal,
and the permanent red masked whether the job would have caught a real
break.

Granting Dependabot the signing secret would clear the symptom but hands
a release signing key to workflows triggered by third-party dependency
updates, so the job is skipped for that actor instead.

Coverage is preserved where it matters: `rust-tests` is a required check,
runs on every event, and compiles the crate via `cargo clippy
--all-targets` and `cargo test --lib`, so a dependency bump that breaks
the Rust build is still caught. Given up on Dependabot PRs only:
bundling, Windows/ARM-specific compilation, and the `cargo audit` step --
which overlaps with Dependabot's own cargo scanning.

`Tauri Full Build` is not among the required status checks on main
(Server Build & Test x2, Client Static Checks, Client Unit Tests, Rust
Unit Tests), so skipping it cannot leave a PR waiting on a status.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 15:19:33 +00:00
dependabot[bot] 3f8a26c904 chore(deps): bump fast-uri from 3.1.4 to 3.1.5 in /Client/tauri-client (#1323)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.4 to 3.1.5.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.4...v3.1.5)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 17:08:21 +02:00
dependabot[bot] 4a3b4e0cff ci(deps): bump docker/build-push-action from 6.16.0 to 6.19.2 (#1316)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.16.0 to 6.19.2.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/14487ce63c7a62a4a324b0bfb37086795e31c6c1...10e90e3645eae34f1e60eeb005ba3a3d33f178e8)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 6.19.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 17:05:12 +02:00
dependabot[bot] 15db18a3ac chore(deps): bump modernc.org/sqlite from 1.54.0 to 1.55.0 in /Server (#1315)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.54.0 to 1.55.0.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.54.0...v1.55.0)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.55.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:59:42 +02:00
dependabot[bot] 4582a601b3 chore(deps): bump knip from 6.29.0 to 6.31.0 in /Client/tauri-client (#1314)
Bumps [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) from 6.29.0 to 6.31.0.
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.31.0/packages/knip)

---
updated-dependencies:
- dependency-name: knip
  dependency-version: 6.31.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:59:37 +02:00
dependabot[bot] a3d03620e5 chore(deps): bump undici from 7.28.0 to 7.29.0 in /Client/tauri-client (#1321)
Bumps [undici](https://github.com/nodejs/undici) from 7.28.0 to 7.29.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.28.0...v7.29.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 7.29.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:49:55 +02:00
dependabot[bot] 5df58bb9c4 chore(deps): bump ip-address (#1320)
Bumps the npm_and_yarn group with 1 update in the /tools/mcp-introspect directory: [ip-address](https://github.com/beaugunderson/ip-address).


Updates `ip-address` from 10.2.0 to 10.4.0
- [Release notes](https://github.com/beaugunderson/ip-address/releases)
- [Commits](https://github.com/beaugunderson/ip-address/compare/v10.2.0...v10.4.0)

---
updated-dependencies:
- dependency-name: ip-address
  dependency-version: 10.4.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:49:51 +02:00
dependabot[bot] 4bda86d0cb chore(deps): bump zeroize in /Client/tauri-client/src-tauri (#1319)
Bumps [zeroize](https://github.com/RustCrypto/utils) from 1.8.2 to 1.9.0.
- [Commits](https://github.com/RustCrypto/utils/compare/zeroize-v1.8.2...zeroize-v1.9.0)

---
updated-dependencies:
- dependency-name: zeroize
  dependency-version: 1.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:49:47 +02:00
dependabot[bot] 6e841cc75f chore(deps): bump @playwright/test in /Client/tauri-client (#1318)
Bumps [@playwright/test](https://github.com/microsoft/playwright) from 1.62.0 to 1.62.1.
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.62.0...v1.62.1)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-version: 1.62.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:49:43 +02:00
dependabot[bot] d9c6094b5f chore(deps): bump rustls in /Client/tauri-client/src-tauri (#1317)
Bumps [rustls](https://github.com/rustls/rustls) from 0.23.42 to 0.23.43.
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.42...v/0.23.43)

---
updated-dependencies:
- dependency-name: rustls
  dependency-version: 0.23.43
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:49:39 +02:00
dependabot[bot] 4fa8f6f84d ci(deps): bump actions/setup-go from 5.5.0 to 5.6.0 (#1313)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5.5.0 to 5.6.0.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/d35c59abb061a4a6fb18e82ac0862c26744d6ab5...40f1582b2485089dde7abd97c1529aa768e1baff)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: 5.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:49:36 +02:00
dependabot[bot] b854f5a986 ci(deps): bump docker/setup-buildx-action from 3.10.0 to 3.12.0 (#1312)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.10.0 to 3.12.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2...8d2750c68a42422c14e847fe6c8ac0403b4cbd6f)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 3.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:49:32 +02:00
dependabot[bot] 3c78bcd2b2 ci(deps): bump swatinem/rust-cache from 2.7.8 to 2.9.1 (#1311)
Bumps [swatinem/rust-cache](https://github.com/swatinem/rust-cache) from 2.7.8 to 2.9.1.
- [Release notes](https://github.com/swatinem/rust-cache/releases)
- [Changelog](https://github.com/Swatinem/rust-cache/blob/master/CHANGELOG.md)
- [Commits](https://github.com/swatinem/rust-cache/compare/9d47c6ad4b02e050fd481d890b2ea34778fd09d6...c19371144df3bb44fab255c43d04cbc2ab54d1c4)

---
updated-dependencies:
- dependency-name: swatinem/rust-cache
  dependency-version: 2.9.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:49:28 +02:00
429 changed files with 31814 additions and 4386 deletions
+65
View File
@@ -0,0 +1,65 @@
---
name: ci-check
description: Run the local mirror of OwnCord's CI gates before pushing. Use when finishing a change, before a commit or push, or when asked to verify work — CI takes ~15 min and catches things a plain build/test does not.
---
# ci-check
`.github/workflows/ci.yml` is the source of truth. This mirrors it locally.
Run only the sections your change touches. Server and client are independent.
## Server (from `Server/`)
All four build-tag variants must compile — the tags gate whole files, so a
default-build pass proves nothing about the others:
```bash
go build ./... && go build -tags otel ./... && go build -tags wazero ./... && go build -tags otel,wazero ./...
go vet ./...
go test -race ./...
go test -tags deadlock -count=1 ./ws/ # deadlock detector; ws is where lock order actually varies
golangci-lint run # CI pins v2.11.3
make sqlc-verify protocol-verify # generated output must not be stale
```
Add `-tags wazero` to `go vet`/`go test` when you touched `plugin/`.
A `windows-latest` `-race` failure inside `ws` that matches `runtime.scanstack`
or `runtime.(*unwinder).next` is a Go 1.26.5 runtime GC fault, not your change.
Rerun the job (`gh run rerun --job <id>`); a job cannot be rerun while its
parent run is still in progress.
## Client (from `Client/tauri-client/`)
```bash
NODE_OPTIONS=--no-experimental-webstorage npm test
npm run typecheck
npm run lint
npm run format:check
```
The `NODE_OPTIONS` flag is mandatory on Node 22+ — see the client CLAUDE.md.
`npm audit --audit-level=high` and `knip` also run in CI but are advisory.
## Rust (from `Client/tauri-client/src-tauri/`)
```bash
cargo test
cargo clippy --all-targets -- -D warnings
```
`fallback_crypto` is `cfg(not(windows))`, so its tests compile to nothing on a
Windows box and only run on the Linux/macOS runners.
Do not attempt `npm run tauri build` locally — the full desktop build runs in
CI on PRs to `main` and pulls heavy system dependencies.
## Hooks
`npm run hooks:install` (once per clone) points `core.hooksPath` at
`.githooks/`: `pre-commit` runs fast staged-file checks, `pre-push` runs the
server build variants plus tsc and eslint. `OWNCORD_PREPUSH_TESTS=1` adds
server tests. Bypass with `--no-verify` or `OWNCORD_SKIP_HOOKS=1` — CI still
enforces everything.
+44
View File
@@ -0,0 +1,44 @@
---
name: db-change
description: Change OwnCord's SQLite schema or queries — add a migration, edit Server/db/queries/*.sql, and regenerate the sqlc layer. Use before touching anything under Server/db/ or Server/migrations/.
---
# db-change
`Server/db/dbgen/` is generated. Edit the inputs, regenerate, commit both.
1. Add the migration to `Server/migrations/` and/or edit
`Server/db/queries/sqlite/*.sql`.
2. Regenerate: `make sqlc-generate` from `Server/`.
3. Commit the regenerated `Server/db/dbgen/` alongside your inputs. CI runs
`make sqlc-verify` and fails on drift.
`sqlc.version` pins the binary (currently v1.30.0). If `make` is not on PATH:
```bash
go install github.com/sqlc-dev/sqlc/cmd/sqlc@$(cat sqlc.version)
$(go env GOPATH)/bin/sqlc generate
```
## Traps
These are silent — the code generates fine and fails at runtime.
**Query files must be ASCII-only.** sqlc v1.30.0 measures rune positions
against byte offsets, so one multi-byte character (an em-dash in a comment is
the usual culprit) truncates the *next* query's emitted SQL by that many
trailing bytes. Symptom: the `.sql` file looks right but the generated const
in `dbgen/*.sql.go` is cut short — `ORDER BY id ASC` becomes `ORDER BY id A`,
and SQLite reports "incomplete input".
**No semicolons inside migration `--` comments.** `splitStatements` in
`Server/db/migrate.go` splits on `;` before stripping comments, so a semicolon
in comment prose orphans the rest of that comment as a bogus statement
("near <word>: syntax error").
**Do not put `LIMIT 1` on a `:one` query.** It is emitted as a bare `LIMIT`.
A `:one` uses `QueryRow` and reads a single row regardless — use `ORDER BY` to
choose which one.
After regenerating, gopls diagnostics against `dbgen` go stale. Trust
`go build`, not the editor squiggles.
+23
View File
@@ -0,0 +1,23 @@
---
name: protocol-change
description: Add or change a WebSocket message type in OwnCord. Use before editing docs/protocol-schema.json, Server/ws/message_types.go, or Client/tauri-client/src/lib/protocolTypes.ts.
---
# protocol-change
`docs/protocol-schema.json` is the source of truth. Both constant files are
generated from it by `Server/scripts/genprotocol/`.
1. Edit `docs/protocol-schema.json`.
2. Run `make protocol-generate` from `Server/`.
3. Commit **both** outputs — `Server/ws/message_types.go` and
`Client/tauri-client/src/lib/protocolTypes.ts`. One run regenerates the
pair; committing only the Go side is the usual mistake, and CI's
`make protocol-verify` fails on either being stale.
Document the semantics in `docs/protocol.md` — the schema carries names and
shapes, not behaviour.
Adding a message type is not enough to make it work: a server handler must be
registered in the `ws` V1/V2 dispatch tables, and the client needs a
`ws.on(...)` subscription in `Client/tauri-client/src/lib/dispatcher.ts`.
+475
View File
@@ -0,0 +1,475 @@
// Offline harness for bughunt.js - mimics the workflow runtime: wraps the script
// body in an AsyncFunction with stubbed agent/parallel/pipeline/phase/log/args/budget.
// Run: node .claude/workflows/bughunt.harness.mjs [nameFilter]
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import assert from 'node:assert/strict'
const here = dirname(fileURLToPath(import.meta.url))
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
export async function run({ agentStub, args = undefined, budget = undefined }) {
const src = readFileSync(join(here, 'bughunt.js'), 'utf8')
const body = src.replace('export const meta', 'const meta')
const calls = []
const logs = []
const agent = async (prompt, opts = {}) => {
calls.push({ prompt, opts })
return agentStub(prompt, opts)
}
const parallel = (thunks) =>
Promise.all(thunks.map((t) => Promise.resolve().then(t).catch(() => null)))
const pipeline = (items, ...stages) =>
Promise.all(
items.map(async (item, i) => {
let v = item
for (const stage of stages) {
try {
v = await stage(v, item, i)
} catch {
return null
}
}
return v
}),
)
const log = (m) => logs.push(String(m))
const phase = () => {}
const budgetImpl = budget || { total: null, spent: () => 0, remaining: () => Infinity }
const fn = new AsyncFunction('agent', 'parallel', 'pipeline', 'phase', 'log', 'args', 'budget', body)
const result = await fn(agent, parallel, pipeline, phase, log, args, budgetImpl)
return { result, calls, logs }
}
// ---------- stub kit (used from Task 2 onward; harmless now) ----------
export function makeStub({ hunt, verify, report = () => 'REPORT_MD', recon = defaultRecon }) {
return (prompt, opts) => {
const label = opts.label || ''
if (label.startsWith('recon:')) return recon(label)
let m = /^r(\d+):hunt:([a-z0-9-]+):(opus|sonnet)$/.exec(label)
if (m) return hunt(Number(m[1]), m[2], m[3], prompt)
m = /^r(\d+):verify:([a-z0-9-]+?)(:retry)?$/.exec(label)
if (m) {
const candidates = JSON.parse(prompt.split('--- CANDIDATES ---')[1])
return verify(Number(m[1]), m[2], candidates, Boolean(m[3]), prompt)
}
if (label === 'report') return report(prompt)
throw new Error(`unexpected agent label: ${label}`)
}
}
export function defaultRecon() {
return 'Server/ws/hub.go 12\nServer/api/user.go 9\nClient/tauri-client/src/lib/dispatcher.ts 8'
}
export const none = { findings: [] }
export const finding = (n, over = {}) => ({
title: `distinct bug alpha${n} omega${n}`,
file: 'Server/ws/hub.go',
line: 100 + n * 40,
severity: 'high',
why: 'w',
repro: 'r',
evidence: 'e',
...over,
})
export const confirmAll = (cands) => ({
verdicts: cands.map((c) => ({
title: c.title, file: c.file, line: c.line,
refuted: false, reason: 'confirmed', confidence: 'high',
severity: c.severity || 'high', fix: 'fix',
})),
})
export const refuteAll = (cands) => ({
verdicts: cands.map((c) => ({
title: c.title, file: c.file, line: c.line,
refuted: true, reason: 'refuted', confidence: 'high',
severity: c.severity || 'high',
})),
})
// ---------- scenarios ----------
const scenarios = {}
// S1: happy convergence - one bug in round 1, rounds 2-3 dry -> converged.
scenarios.s1_convergence = async () => {
const reportPrompts = []
const { result, calls } = await run({
agentStub: makeStub({
hunt: (round, key, model) =>
round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none,
verify: (round, key, cands) => confirmAll(cands),
report: (prompt) => {
reportPrompts.push(prompt)
return 'REPORT_MD'
},
}),
})
for (const k of ['converged', 'stoppedOnBudget', 'rounds', 'confirmed', 'unverified', 'report'])
assert.ok(k in result, `missing key ${k}`)
assert.equal(result.converged, true)
assert.equal(result.rounds.length, 3)
assert.deepEqual(result.rounds.map((r) => r.dryAfter), [0, 1, 2])
assert.deepEqual(result.rounds.map((r) => r.family), ['surfaces', 'bug-classes', 'flows'])
assert.equal(result.confirmed.length, 1)
assert.equal(result.report, 'REPORT_MD')
assert.ok(!calls.some((c) => (c.opts.label || '').startsWith('r4:')), 'no round 4 after convergence')
assert.match(reportPrompts[0], /CONVERGED after 3 round\(s\)/)
assert.match(reportPrompts[0], /\| 1 \| surfaces \|/)
}
// S2: panel dedupe - opus and sonnet report the same bug -> one candidate, one verify call.
scenarios.s2_panel_dedupe = async () => {
const verifyBatches = []
const { result } = await run({
agentStub: makeStub({
hunt: (round, key, model) => {
if (round !== 1 || key !== 'ws-hub') return none
return model === 'opus'
? { findings: [finding(1, { line: 100 })] }
: { findings: [finding(1, { line: 105, title: 'distinct bug alpha1 omega1 variant' })] }
},
verify: (round, key, cands) => {
verifyBatches.push(cands)
return confirmAll(cands)
},
}),
})
assert.equal(verifyBatches.length, 1)
assert.equal(verifyBatches[0].length, 1)
assert.equal(result.confirmed.length, 1)
}
// S3: refuted findings stay dead - re-reported next round, never re-verified; refutes count toward dry.
scenarios.s3_refuted_permanence = async () => {
const { result, calls } = await run({
agentStub: makeStub({
hunt: (round, key, model) => {
if (round === 1 && key === 'ws-hub' && model === 'opus') return { findings: [finding(2)] }
if (round === 2 && key === 'state-desync' && model === 'opus') return { findings: [finding(2)] }
return none
},
verify: (round, key, cands) => refuteAll(cands),
}),
})
const verifyRounds = calls
.map((c) => /^r(\d+):verify:/.exec(c.opts.label || ''))
.filter(Boolean)
.map((m) => Number(m[1]))
assert.deepEqual(verifyRounds, [1], 'refuted candidate must not be re-verified in round 2')
assert.equal(result.rounds[0].refuted, 1)
assert.equal(result.confirmed.length, 0)
assert.equal(result.rounds.length, 2) // refute-only r1 is dry -> converged after r2
assert.equal(result.converged, true)
assert.ok(!calls.some((c) => c.opts.label === 'report'), 'zero confirmed -> code-built report')
assert.match(result.report, /Converged/i)
}
// S4: backstop - fresh confirmed bug every round with maxRounds=3 -> stops, NOT converged.
scenarios.s4_backstop = async () => {
const firstLens = { 1: 'ws-hub', 2: 'concurrency', 3: 'flow-reconnect' }
const { result } = await run({
args: { maxRounds: 3 },
agentStub: makeStub({
hunt: (round, key, model) =>
model === 'opus' && key === firstLens[round]
? { findings: [finding(round, { file: `Server/ws/f${round}.go` })] }
: none,
verify: (round, key, cands) => confirmAll(cands),
}),
})
assert.equal(result.rounds.length, 3)
assert.equal(result.converged, false)
assert.equal(result.stoppedOnBudget, false)
assert.equal(result.confirmed.length, 3)
assert.deepEqual(result.rounds.map((r) => r.dryAfter), [0, 0, 0])
}
// S5: failed finder -> round dry-ineligible; dry counter neither increments nor resets.
scenarios.s5_finder_failure_ineligible = async () => {
const { result } = await run({
args: { maxRounds: 3 },
agentStub: makeStub({
hunt: (round, key, model) => {
if (round === 1 && key === 'ws-hub' && model === 'opus') return { findings: [finding(1)] }
if (round === 2 && key === 'concurrency' && model === 'opus') return null // dead finder
return none
},
verify: (round, key, cands) => confirmAll(cands),
}),
})
assert.equal(result.rounds[1].dryEligible, false)
assert.deepEqual(result.rounds.map((r) => r.dryAfter), [0, 0, 1])
assert.equal(result.converged, false)
}
// S6: failed verifier retried once, retry succeeds.
scenarios.s6_verifier_retry = async () => {
const { result, calls } = await run({
agentStub: makeStub({
hunt: (round, key, model) =>
round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none,
verify: (round, key, cands, isRetry) => (isRetry ? confirmAll(cands) : null),
}),
})
assert.ok(calls.some((c) => (c.opts.label || '').endsWith(':retry')))
assert.equal(result.confirmed.length, 1)
assert.equal(result.converged, true)
}
// S6b: verifier fails twice -> candidate dropped unconfirmed, round ineligible;
// re-reported later, verified then, and scrubbed from the unverified list.
scenarios.s6b_verifier_double_failure = async () => {
const { result } = await run({
args: { maxRounds: 3 },
agentStub: makeStub({
hunt: (round, key, model) => {
if (round === 1 && key === 'ws-hub' && model === 'opus') return { findings: [finding(3)] }
if (round === 2 && key === 'state-desync' && model === 'opus') return { findings: [finding(3)] }
return none
},
verify: (round, key, cands) => (round === 1 ? null : confirmAll(cands)),
}),
})
assert.equal(result.rounds[0].dryEligible, false)
assert.equal(result.rounds[0].confirmed, 0)
assert.equal(result.confirmed.length, 1)
assert.equal(result.confirmed[0].round, 2)
assert.equal(result.unverified.length, 0, 'later-confirmed candidate must leave the unverified list')
}
// S7: rounds 1-3 each confirm a bug -> round 4 runs adaptive lenses built from the stats.
scenarios.s7_adaptive_lenses = async () => {
const A = finding(1, { file: 'Server/ws/hub.go', line: 120, title: 'alpha race window one' })
const B = finding(2, { file: 'Server/ws/pubsub.go', line: 60, title: 'beta subscription leak two' })
const C = finding(3, { file: 'Client/tauri-client/src/lib/livekitE2EE.ts', line: 200, title: 'gamma epoch desync three' })
const { result, calls } = await run({
agentStub: makeStub({
hunt: (round, key, model) => {
if (model !== 'opus') return none
if (round === 1 && key === 'ws-hub') return { findings: [A] }
if (round === 2 && key === 'concurrency') return { findings: [B] }
if (round === 3 && key === 'flow-voice') return { findings: [C] }
return none
},
verify: (round, key, cands) => confirmAll(cands),
}),
})
assert.equal(result.converged, true)
assert.equal(result.rounds.length, 5) // r4, r5 adaptive + dry
assert.equal(result.rounds[3].family, 'adaptive')
const r4Hunts = calls.filter((c) => /^r4:hunt:/.test(c.opts.label || ''))
const r4Keys = [...new Set(r4Hunts.map((c) => c.opts.label.split(':')[2]))]
assert.ok(r4Keys.includes('hotspot-server-ws'), `r4 keys: ${r4Keys}`)
assert.ok(r4Keys.includes('fresh-eyes'), `r4 keys: ${r4Keys}`)
const hotspot = r4Hunts.find((c) => c.opts.label.includes('hotspot-server-ws'))
assert.match(hotspot.prompt, /Server\/ws\/hub\.go/)
assert.match(hotspot.prompt, /alpha race window one/)
const freshEyes = r4Hunts.find((c) => c.opts.label.includes('fresh-eyes'))
assert.match(freshEyes.prompt, /Server\/api\/user\.go/) // churned, never a finding
assert.equal(result.confirmed.length, 3)
}
// S7b: a lens with 2 consecutive clean rounds is demoted from later rounds.
scenarios.s7b_demotion = async () => {
const { result, calls } = await run({
args: { maxRounds: 6 },
agentStub: makeStub({
hunt: (round, key, model) => {
if (model !== 'opus') return none
const src = { 1: 'ws-hub', 2: 'concurrency', 3: 'flow-reconnect', 4: 'hotspot-server-ws', 5: 'hotspot-server-ws' }
if (key === src[round])
return { findings: [finding(round, { file: `Server/ws/a${round}.go`, title: `unique bug number${round} zeta${round}` })] }
return none
},
verify: (round, key, cands) => confirmAll(cands),
}),
})
const labels = calls.map((c) => c.opts.label || '')
assert.ok(labels.some((l) => /^r5:hunt:fresh-eyes:/.test(l)), 'fresh-eyes still runs in r5 (streak 1)')
assert.ok(!labels.some((l) => /^r6:hunt:fresh-eyes:/.test(l)), 'fresh-eyes demoted in r6 (streak 2)')
assert.ok(labels.some((l) => /^r6:hunt:hotspot-server-ws:/.test(l)), 'producing hotspot keeps running')
assert.equal(result.converged, false)
assert.equal(result.confirmed.length, 5)
}
// S7c: a lens whose VERIFIER died is not demoted; a zero-candidate lens still is.
scenarios.s7c_verifier_failure_not_demoted = async () => {
const early = { 1: 'ws-hub', 2: 'concurrency', 3: 'flow-reconnect' }
const { result, calls } = await run({
args: { maxRounds: 6 },
agentStub: makeStub({
hunt: (round, key, model) => {
if (model !== 'opus') return none
if (round <= 3 && key === early[round])
return { findings: [finding(round, { file: `Server/ws/a${round}.go`, title: `early bug item${round} kappa${round}` })] }
if (round >= 4 && key === 'hotspot-server-ws')
return { findings: [finding(round + 10, { file: `Server/ws/b${round}.go`, title: `late bug item${round} sigma${round}` })] }
return none
},
verify: (round, key, cands) => (round <= 3 ? confirmAll(cands) : null),
}),
})
const labels = calls.map((c) => c.opts.label || '')
assert.ok(labels.some((l) => /^r6:hunt:hotspot-server-ws:/.test(l)), 'verifier-dead lens must NOT be demoted')
assert.ok(!labels.some((l) => /^r6:hunt:fresh-eyes:/.test(l)), 'zero-candidate lens still accrues streak and demotes')
assert.equal(result.confirmed.length, 3)
assert.equal(result.unverified.length, 3)
assert.equal(result.converged, false)
assert.ok(result.rounds.slice(3).every((r) => r.dryEligible === false))
}
// S12: empty adaptive family (no confirms, no churn) must break honestly, not count dry rounds.
scenarios.s12_empty_adaptive_family = async () => {
const { result } = await run({
agentStub: makeStub({
recon: () => 'no parseable churn output',
hunt: (round, key, model) => {
if (round <= 2 && key === (round === 1 ? 'ws-hub' : 'concurrency') && model === 'opus')
return { findings: [finding(round, { file: `Server/ws/c${round}.go`, title: `verifierless bug delta${round} theta${round}` })] }
return none
},
verify: () => null,
}),
})
assert.equal(result.rounds.length, 3)
assert.equal(result.converged, false)
assert.equal(result.confirmed.length, 0)
assert.equal(result.unverified.length, 2)
}
// S8: budget below the round floor before round 1 -> zero rounds, honest non-convergence.
scenarios.s8_budget_floor = async () => {
const { result, calls } = await run({
budget: { total: 1000000, spent: () => 900000, remaining: () => 100000 },
agentStub: makeStub({ hunt: () => none, verify: (r, k, c) => confirmAll(c) }),
})
assert.equal(result.rounds.length, 0)
assert.equal(result.stoppedOnBudget, true)
assert.equal(result.converged, false)
assert.ok(!calls.some((c) => /:hunt:/.test(c.opts.label || '')))
assert.match(result.report, /budget/i)
}
// S8b: budget runs low mid-hunt -> finishes the round it started, stops before the next.
scenarios.s8b_budget_midrun = async () => {
let n = 0
const { result } = await run({
budget: { total: 1000000, spent: () => 0, remaining: () => (n++ === 0 ? 200000 : 100000) },
agentStub: makeStub({
hunt: (round, key, model) =>
round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none,
verify: (round, key, cands) => confirmAll(cands),
}),
})
assert.equal(result.rounds.length, 1)
assert.equal(result.stoppedOnBudget, true)
assert.equal(result.converged, false)
assert.equal(result.confirmed.length, 1)
}
// S14: the title-word dedupe branch applies only near the prior's location.
// Dedupe is permanent, so merging two distinct same-file bugs that happen to
// share half their title words loses the second one forever.
scenarios.s14_title_dedupe_window = async () => {
const near = { file: 'Server/ws/hub.go', line: 140, title: 'hub client map race on register path' }
const far = { file: 'Server/ws/hub.go', line: 900, title: 'hub client map race on unregister' }
const verifyBatches = []
const { result } = await run({
agentStub: makeStub({
hunt: (round, key, model) => {
if (model !== 'opus') return none
if (round === 1 && key === 'ws-hub')
return { findings: [finding(1, { file: 'Server/ws/hub.go', line: 100, title: 'hub client map race on register' })] }
if (round === 2 && key === 'concurrency')
return { findings: [finding(2, far), finding(3, near)] }
return none
},
verify: (round, key, cands) => {
verifyBatches.push(cands.map((c) => c.line))
return confirmAll(cands)
},
}),
})
assert.deepEqual(verifyBatches, [[100], [900]], 'near-duplicate dropped, distant same-file bug kept')
assert.equal(result.confirmed.length, 2)
assert.ok(result.confirmed.some((c) => c.line === 900), 'the distant bug must survive dedupe')
}
// S13: JSON-stringified args must behave identically to object args (observed live: the
// runtime can deliver args as a string; maxRounds:1 silently fell back to 8 before the coercion).
scenarios.s13_string_args = async () => {
const { result, calls } = await run({
args: '{"maxRounds": 1}',
agentStub: makeStub({
hunt: (round, key, model) =>
round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none,
verify: (round, key, cands) => confirmAll(cands),
}),
})
assert.equal(result.rounds.length, 1, 'string maxRounds:1 must cap the loop at one round')
assert.equal(result.converged, false)
assert.equal(result.confirmed.length, 1)
assert.ok(!calls.some((c) => (c.opts.label || '').startsWith('r2:')), 'no round 2 under the cap')
}
// S10: verifier returns truncated (empty) verdict lists on both attempts ->
// candidates land in unverified, round ineligible, dry counter untouched.
scenarios.s10_truncated_verdicts = async () => {
const { result, calls } = await run({
args: { maxRounds: 2 },
agentStub: makeStub({
hunt: (round, key, model) =>
round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none,
verify: () => ({ verdicts: [] }),
}),
})
assert.ok(calls.some((c) => (c.opts.label || '').endsWith(':retry')), 'short verdict list must trigger the retry')
assert.equal(result.rounds[0].dryEligible, false)
assert.deepEqual(result.rounds.map((r) => r.dryAfter), [0, 1])
assert.equal(result.confirmed.length, 0)
assert.equal(result.unverified.length, 1)
assert.equal(result.converged, false)
}
// S11: verdict coordinates drift from the candidate's -> still pairs, confirms once,
// nothing listed unverified, and a round-2 re-report of the ORIGINAL coords is deduped.
scenarios.s11_drifted_verdict = async () => {
const orig = finding(4) // file Server/ws/hub.go, line 260
const { result, calls } = await run({
agentStub: makeStub({
hunt: (round, key, model) => {
if (round === 1 && key === 'ws-hub' && model === 'opus') return { findings: [orig] }
if (round === 2 && key === 'state-desync' && model === 'opus') return { findings: [orig] }
return none
},
verify: (round, key, cands) => ({
verdicts: cands.map((c) => ({
title: c.title, file: c.file, line: c.line + 5,
refuted: false, reason: 'confirmed', confidence: 'high', severity: 'high', fix: 'fix',
})),
}),
}),
})
const verifyRounds = calls
.map((c) => /^r(\d+):verify:/.exec(c.opts.label || ''))
.filter(Boolean)
.map((m) => Number(m[1]))
assert.deepEqual(verifyRounds, [1], 'drifted-but-paired verdict must still suppress the original coords')
assert.equal(result.confirmed.length, 1)
assert.equal(result.unverified.length, 0)
assert.equal(result.converged, true)
}
// ---------- runner ----------
const only = process.argv[2]
for (const [name, fn] of Object.entries(scenarios)) {
if (only && !name.includes(only)) continue
try {
await fn()
} catch (e) {
console.error(`FAIL ${name}`)
throw e
}
console.log(`PASS ${name}`)
}
console.log('all scenarios pass')
+565
View File
@@ -0,0 +1,565 @@
export const meta = {
name: 'bughunt',
description: 'Converging multi-round bug hunt: rotating lens families, dual-model panels, fable refute-by-default verification, dry-threshold stop',
whenToUse: 'Hunting real bugs across the Go server, Tauri Rust backend, and TS client until consecutive rounds go dry. Not a security-only scan.',
phases: [
{ title: 'Recon', detail: 'haiku: churn + concurrency-surface inventory' },
{ title: 'Report', detail: 'fable: ranked findings + convergence table' },
],
}
// ---------- config ----------
// args may arrive JSON-stringified (observed in run wf_9199e623-b83: maxRounds:1 never took) - coerce
const ARGS = (() => {
if (typeof args === 'string') {
try { return JSON.parse(args) || {} } catch { return {} }
}
return args || {}
})()
const MAX_ROUNDS = ARGS.maxRounds || 8
const DRY_THRESHOLD = ARGS.dryThreshold || 2
// ponytail: rough floor for one round (up to 12 high-effort finders + verifiers); tune after live runs
const ROUND_BUDGET_FLOOR = 150000
// The args channel has already been observed delivering something the script
// could not read; an unnoticed fallback here is an 8x cost surprise, so say out
// loud what the run is actually going to do.
log(`config: maxRounds=${MAX_ROUNDS} dryThreshold=${DRY_THRESHOLD}`)
// ---------- schemas: copied VERBATIM from the current bughunt.js ----------
const FINDINGS = {
type: 'object',
required: ['findings'],
properties: {
findings: {
type: 'array',
items: {
type: 'object',
required: ['title', 'file', 'line', 'severity', 'why', 'repro'],
properties: {
title: { type: 'string' },
file: { type: 'string', description: 'repo-relative path' },
line: { type: 'integer' },
severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
why: { type: 'string', description: 'the defect, one or two sentences' },
repro: { type: 'string', description: 'concrete inputs/interleaving -> wrong behavior' },
evidence: { type: 'string', description: 'the code lines that prove it' },
},
},
},
},
}
const VERDICTS = {
type: 'object',
required: ['verdicts'],
properties: {
verdicts: {
type: 'array',
items: {
type: 'object',
required: ['title', 'file', 'line', 'refuted', 'reason', 'confidence', 'severity'],
properties: {
title: { type: 'string' },
file: { type: 'string' },
line: { type: 'integer' },
refuted: { type: 'boolean' },
reason: { type: 'string', description: 'what refutes it, or what confirms it in the code' },
confidence: { type: 'string', enum: ['high', 'medium', 'low'] },
severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
fix: { type: 'string', description: 'smallest correct fix, if confirmed' },
},
},
},
},
}
// ---------- rules ----------
const RULES = `
Repo: OwnCord, at D:/Local-Lab/Repos/OwnCord. Go 1.26 server in Server/, Tauri v2 client in Client/tauri-client/
(Rust in src-tauri/src/, TypeScript in src/lib/ and src/stores/).
You are hunting REAL BUGS: wrong behavior, not style. In scope:
- logic errors, off-by-one, wrong operator, inverted condition, wrong default
- concurrency: data races, deadlocks, lock-order inversion, missed wakeups, goroutine leaks, TOCTOU
- lifecycle: use-after-close, double-close, nil deref on error paths, leaked resources/listeners/timers
- state machines that can reach an unintended state, or desync between two sources of truth
- error paths that silently swallow, lose data, or leave partial writes
- auth/authz checks reading stale state, or missing on one path while present on siblings
Out of scope, do not report: naming, formatting, missing tests, "consider adding", speculative hardening,
performance that is not a hang, anything you cannot point at specific lines for.
Method:
1. Read the actual files. Never report from a filename or a grep hit alone.
2. For every candidate, grep for ALL callers before judging - a guard may already live upstream.
3. Check whether an existing test already locks the behavior you think is wrong. If a test asserts it,
it is intended behavior, not a bug. Test files are *_test.go and tests/unit/*.test.ts.
4. Report EVERY finding you can prove - there is no cap. The quality bar stays: zero findings is a
valid, respectable answer, and each finding needs file, line, and a concrete repro.
You may run read-only shell commands (grep, git log, go doc). Do not modify any file. Do not run the test suite.
`
// ---------- lens catalog ----------
// keys must match /^[a-z0-9-]+$/ - they are embedded in agent labels the harness parses.
const SURFACE_LENSES = [
{
key: 'ws-hub',
prompt:
`Surface: the WebSocket hub and its client lifecycle. Files: Server/ws/*.go (skip *_test.go) - start with ` +
`client.go, hub*.go, emit.go, event.go, event_persister.go, event_pruner.go, handlers*.go, command.go.\n\n` +
`Hunt specifically for: send on closed channel; write to a client after unregister; hub map mutated without ` +
`the right lock held; lock ordering between hub and client; a goroutine that outlives its client; ` +
`read-pump/write-pump shutdown races; events emitted to a client mid-unregister; event ordering that can ` +
`invert under concurrent publish; pruner racing the persister over the same rows.\n` +
`Trace at least one full connect -> subscribe -> emit -> disconnect path end to end before reporting anything.`,
},
{
key: 'voice-e2ee',
prompt:
`Surface: voice/video E2EE key lifecycle, spanning three languages. Files: Server/ws/handler_v2_voice*.go and ` +
`any Server/ws/*voice*.go or *e2ee*.go; Client/tauri-client/src/lib/e2eeCrypto.ts, livekitE2EE.ts, ` +
`livekitSession.ts, identity.ts; Client/tauri-client/src-tauri/src/tofu.rs, secret_store.rs, fallback_crypto.rs, dpapi.rs.\n\n` +
`Hunt specifically for: a key-rotation window where a participant can decrypt after they should be excluded; ` +
`TOFU pin re-check that reads state captured before a rotation (time-of-check/time-of-use); a participant ` +
`joining mid-rotation getting the wrong epoch key; key material outliving the session; an error path that ` +
`falls back to unencrypted or to a zeroed/default key; sender/receiver epoch disagreement after reconnect.\n` +
`This area was hardened before - check git log for the relevant commits and do NOT re-report anything already fixed.`,
},
{
key: 'api-authz',
prompt:
`Surface: REST API auth and authorization. Files: Server/api/*.go (skip *_test.go), Server/auth/*.go, ` +
`Server/permissions/*.go.\n\n` +
`Hunt specifically for: a permission checked against a snapshot that can go stale before it is used; ` +
`a handler that checks channel access but not server/guild access (or vice versa); an ID taken from the ` +
`request body when it should come from the session; sibling handlers where one path has a guard and a ` +
`near-identical one does not; rate limiter keyed on something the caller controls; role/override resolution ` +
`that returns allow on error instead of deny.\n` +
`Compare handlers against each other - the strongest signal here is inconsistency between siblings.`,
},
{
key: 'db-storage',
prompt:
`Surface: persistence. Files: Server/db/*.go (NOT db/dbgen/, that is generated), Server/db/queries/*.sql, ` +
`Server/migrations/*.sql, Server/storage/*.go, Server/service/*.go.\n\n` +
`Hunt specifically for: a multi-statement operation that is not in one transaction and can leave partial state; ` +
`a tx that can be committed twice or leaked without rollback on an early return; sql.ErrNoRows treated as a ` +
`real error or swallowed as success; a query whose SQL semantics disagree with what the caller assumes ` +
`(LIMIT, ordering, NULL handling, JOIN dropping rows); a migration that is not idempotent or that breaks ` +
`an older row shape; unbounded result sets read fully into memory.\n` +
`Read the .sql alongside its Go caller - the bug is usually the gap between them.`,
},
{
key: 'tauri-rust',
prompt:
`Surface: the Tauri Rust backend. Files: Client/tauri-client/src-tauri/src/*.rs.\n\n` +
`Hunt specifically for: a panic reachable from a Tauri command (unwrap/expect on attacker- or ` +
`environment-controlled input) - a panic here can take down the app; a lock held across .await; ` +
`state in tauri::State mutated from two commands without coordination; the http_proxy / livekit_proxy / ` +
`ws_proxy forwarding a header, URL, or origin it should filter; credentials/secret_store material logged, ` +
`left in memory, or written unencrypted on a fallback path; ptt.rs global hook not released on shutdown.\n` +
`For each panic you find, state exactly which input reaches it.`,
},
{
key: 'client-state',
prompt:
`Surface: TypeScript client state and event handling. Files: Client/tauri-client/src/lib/*.ts and ` +
`src/stores/*.ts - prioritize dispatcher.ts, reconcile.ts, read-state.ts, router.ts, roomEventHandlers.ts, ` +
`navigation-guard.ts, rate-limiter.ts, channel-navigation.ts, and whatever the churn recon flagged.\n\n` +
`Hunt specifically for: a listener/interval/observer registered without a matching teardown (check ` +
`disposable.ts for the intended pattern and find who bypasses it); reconcile logic that drops or duplicates ` +
`an entity when events arrive out of order; read-state that can mark unread messages read, or lose an unread ` +
`count, across a reconnect; an async handler whose await lets stale state be written after a newer update ` +
`(last-write-wins race); a route guard bypassable by a rapid navigation sequence.\n` +
`Check tests/unit/ before reporting - much of this behavior is already test-locked.`,
},
]
const BUGCLASS_LENSES = [
{
key: 'concurrency',
prompt:
`Bug class: concurrency and interleaving - sweep the whole repo for THIS CLASS ONLY.\n` +
`Go (Server/): data races on maps/slices/fields shared between goroutines; lock-order inversion; ` +
`missed wakeups; TOCTOU between a check and its use; goroutines racing shutdown; send on closed channel.\n` +
`Rust (src-tauri/src/): a lock held across .await; tauri::State mutated from two commands without ` +
`coordination; Arc<Mutex<_>> cloned into tasks that outlive their owner.\n` +
`TS (src/lib/, src/stores/): two async handlers interleaving on the same store (last-write-wins after ` +
`an await); a stale closure writing state after a newer update already landed.\n` +
`Use the recon concurrency-surface inventory to pick files. For every candidate, name the exact interleaving.`,
},
{
key: 'lifecycle',
prompt:
`Bug class: lifecycle and teardown - sweep the whole repo for THIS CLASS ONLY.\n` +
`Every acquire must have a matching release on EVERY exit path: goroutines outliving their owner; ` +
`timers/intervals/listeners/workers registered without removal (client disposable.ts is the intended ` +
`pattern - find who bypasses it); double-close and use-after-close; teardown-order mistakes; ` +
`Rust Drop not running (mem::forget, leaked handles, the ptt.rs global hook); ` +
`partial teardown when an error interrupts the happy path halfway.`,
},
{
key: 'state-desync',
prompt:
`Bug class: two sources of truth drifting - sweep the whole repo for THIS CLASS ONLY.\n` +
`Pairs to audit: hub client maps vs pubsub registrations; server voice state vs LiveKit vs client ` +
`stores; client read-state vs server acked sequence numbers; DB rows vs in-memory caches; ` +
`any two structures updated by different code paths. Find the path that updates one and not the ` +
`other - reconnect, replacement, and error paths are where they diverge.`,
},
{
key: 'error-paths',
prompt:
`Bug class: error-path data loss - sweep the whole repo for THIS CLASS ONLY.\n` +
`Swallowed errors (err assigned and ignored, empty catch, unwrap_or(default) hiding failure); ` +
`partial writes left behind on early return; fallbacks that silently degrade to wrong behavior; ` +
`an error mapped to success upstream; cleanup skipped when the happy path is interrupted mid-way. ` +
`Read every 'if err != nil', catch block, and .catch in the hot files from recon.`,
},
{
key: 'ordering-boundary',
prompt:
`Bug class: ordering and boundaries - sweep the whole repo for THIS CLASS ONLY.\n` +
`Off-by-one and fence-post errors; LIMIT/pagination silently truncating; sequence-number gaps, ` +
`duplication, or inversion between assignment and delivery; sort-stability and tie assumptions; ` +
`first/last/empty-collection special cases; inclusive-vs-exclusive range disagreements between a ` +
`caller and its callee (read the SQL alongside its Go caller).`,
},
]
const FLOW_LENSES = [
{
key: 'flow-reconnect',
prompt:
`Flow: WebSocket drop -> reconnect -> resume. Trace it END TO END across all three languages before ` +
`reporting anything. Server: the serve handshake/resume path, hub client replacement and state ` +
`transfer (this transfer has needed four separate fixes: unsubscribe identity, VoiceTopic+E2EE key ` +
`transfer, focused-channel transfer, closeSend ordering - hunt for what it STILL misses), topic ` +
`re-subscription, cold/warm replay tiers. Client: the reconnect loop, seq ack tracking, store ` +
`reconcile after resume. Report any state that exists on the old connection and does not provably ` +
`reach the new one.`,
},
{
key: 'flow-voice',
prompt:
`Flow: voice join -> E2EE key announce/offer -> key-holder election -> rotation -> participant ` +
`leave -> LiveKit webhook -> cleanup. Trace it END TO END: Server/ws/*voice*, livekit_webhook.go, ` +
`client livekitE2EE.ts and livekitSession.ts, Rust livekit_proxy.rs. Hunt for: a participant who can ` +
`still decrypt after they should be excluded; holder-election stalls; epoch/key disagreement after ` +
`reconnect; the three take-out-of-voice paths (webhook, sweep, voice_leave) diverging.`,
},
{
key: 'flow-message',
prompt:
`Flow: message send -> permission gate -> persist -> sequence assign -> fan-out -> replay tiers -> ` +
`client store -> read-state/unread counts. Trace it END TO END and hunt the gaps BETWEEN layers: ` +
`persisted but never fanned out; delivered but sequence-skipped; acked via max(seq) while a lower ` +
`seq was dropped; unread counts drifting from actual unread messages across reconnect or channel switch.`,
},
{
key: 'flow-session',
prompt:
`Flow: login -> session/token issue -> per-connection auth -> revocation/sweep -> kick -> API-token ` +
`paths. Trace it END TO END and hunt stale-authorization windows: state checked at connect but not ` +
`re-checked at use; revocation that kicks the WS but leaves another surface authorized; the sweep ` +
`racing an in-flight request; API tokens diverging from session-token semantics on any path.`,
},
]
function lensesForRound(round) {
if (round === 1) return SURFACE_LENSES
if (round === 2) return BUGCLASS_LENSES
if (round === 3) return FLOW_LENSES
return buildAdaptiveLenses()
}
function familyName(round) {
return ['surfaces', 'bug-classes', 'flows'][round - 1] || 'adaptive'
}
function clusterOf(file) {
const parts = String(file).split('/')
return parts.slice(0, parts[0] === 'Client' ? 3 : 2).join('/')
}
function freshEyesLens() {
const files = churnFiles.filter((f) => !seen.some((s) => s.file === f)).slice(0, 10)
if (!files.length) return []
return [
{
key: 'fresh-eyes',
prompt:
`These files churned heavily in the last 8 weeks, yet no hunt round has confirmed or refuted a ` +
`single finding in them - either they are clean or every lens so far walked past them. Read each ` +
`one IN FULL with fresh eyes and hunt for real bugs of any class:\n` +
files.map((f) => ` - ${f}`).join('\n'),
},
]
}
function buildAdaptiveLenses() {
const byCluster = {}
for (const c of confirmedAll) {
const cl = clusterOf(c.file)
if (!byCluster[cl]) byCluster[cl] = []
byCluster[cl].push(c)
}
const top = Object.entries(byCluster)
.sort((a, b) => b[1].length - a[1].length)
.slice(0, 3)
const hotspots = top.map(([cluster, items]) => ({
key: ('hotspot ' + cluster).toLowerCase().replace(/[^a-z0-9]+/g, '-'),
prompt:
`Bugs cluster. Confirmed findings so far in ${cluster}:\n` +
items.map((i) => ` - ${i.file}:${i.line} ${i.title}`).join('\n') +
`\nHunt ADJACENT to these: the same functions' siblings, every caller, the counterpart operations ` +
`(subscribe/unsubscribe, open/close, register/transfer, acquire/release), and the paths a past fix ` +
`here did NOT cover. Do not re-report the findings listed above - they are already known.`,
}))
return [...hotspots, ...freshEyesLens()]
}
// ---------- dedupe + ledger helpers ----------
function normTitle(t) {
return String(t || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').split(/\s+/).filter((w) => w.length > 2)
}
// Dedupe is permanent: a candidate merged into an existing entry never comes
// back, so an over-eager match silently loses a real bug rather than deferring
// it. The title-word branch therefore only applies near the prior's location -
// two distinct bugs in one file often share half their title words ("hub client
// map race on register" vs "...on unregister"), and without a window the second
// one is suppressed forever, sometimes by a merely REFUTED namesake.
const TITLE_MATCH_WINDOW = 60
function isDup(a, b) {
if (a.file !== b.file) return false
const delta = Math.abs((a.line || 0) - (b.line || 0))
if (delta <= 10) return true
if (delta > TITLE_MATCH_WINDOW) return false
const aw = normTitle(a.title)
if (!aw.length) return false
const bw = new Set(normTitle(b.title))
const hits = aw.filter((w) => bw.has(w)).length
return hits * 2 >= aw.length
}
function dedupe(cands, priors) {
const kept = []
for (const c of cands) {
if (priors.some((p) => isDup(c, p)) || kept.some((k) => isDup(c, k))) continue
kept.push(c)
}
return kept
}
function seenBlock(seen) {
if (!seen.length) return ''
const lines = seen.map((s) => ` - ${s.file}:${s.line} [${s.status}] ${s.title}`)
return `\n--- KNOWN FINDINGS (already investigated - do NOT re-report; refuted means examined and rejected) ---\n${lines.join('\n')}\n`
}
function convergenceTable(stats, converged, stoppedOnBudget) {
const verdict = converged
? `CONVERGED after ${stats.length} round(s).`
: stoppedOnBudget
? 'NOT converged - stopped on budget.'
: 'NOT converged - hit the round backstop.'
const rows = stats.map(
(s) =>
`| ${s.round} | ${s.family} | ${s.lenses} | ${s.candidates} | ${s.fresh} | ${s.confirmed} | ${s.refuted} | ${s.dryEligible ? 'yes' : 'NO'} | ${s.dryAfter} |`,
)
return [
'## Convergence',
'',
verdict,
'',
'| round | family | lenses | candidates | fresh | confirmed | refuted | dry-eligible | dry after |',
'|---|---|---|---|---|---|---|---|---|',
...rows,
].join('\n')
}
// ---------- recon (verbatim from the current script, including both prompts) ----------
phase('Recon')
const recon = await parallel([
() =>
agent(
`${RULES}\n\nRECON TASK (mechanical, do not hunt bugs yourself):\n` +
`Run: git -C D:/Local-Lab/Repos/OwnCord log --since="8 weeks ago" --name-only --pretty=format: -- Server Client\n` +
`Count how often each non-test source file changed. Return the 25 most-churned files with their counts, ` +
`plus any file that changed in more than 6 distinct commits. High churn = where bugs concentrate.\n` +
`Return plain text: one "path count" per line, most-churned first. No commentary.`,
{ label: 'recon:churn', phase: 'Recon', model: 'haiku', effort: 'low' },
),
() =>
agent(
`${RULES}\n\nRECON TASK (mechanical, do not hunt bugs yourself):\n` +
`Inventory the concurrency and lifecycle surface so the finders know where to look. Report:\n` +
` (a) every Server/ non-test .go file containing "go func", "sync.", "chan ", "select {", or "context.WithCancel"\n` +
` (b) every Client/tauri-client/src/**/*.ts (non-test) containing "addEventListener", "setInterval", "setTimeout", or "new AbortController"\n` +
` (c) every Client/tauri-client/src-tauri/src/*.rs containing "unsafe", "Mutex", "RwLock", "spawn", or "unwrap()"\n` +
`For each file give the path and a rough hit count. Return plain text grouped under (a)/(b)/(c). No commentary, no analysis.`,
{ label: 'recon:surface', phase: 'Recon', model: 'haiku', effort: 'low' },
),
])
const CONTEXT = `\n\n--- RECON: most-churned files (last 8 weeks) ---\n${recon[0] || 'unavailable'}\n\n--- RECON: concurrency & lifecycle surface ---\n${recon[1] || 'unavailable'}\n`
const churnFiles = String(recon[0] || '')
.split('\n')
.map((l) => l.trim().split(/\s+/)[0])
.filter((p) => p.includes('/'))
log('Recon complete - starting converging rounds')
// ---------- round loop ----------
const seen = []
const confirmedAll = []
const unverified = []
const roundStats = []
const cleanStreak = {}
let dry = 0
let round = 0
let stoppedOnBudget = false
function finderPrompt(lens, rnd) {
return (
`${RULES}${CONTEXT}${seenBlock(seen)}\n\nThis is round ${rnd} of a converging hunt. Everything under ` +
`KNOWN FINDINGS has already been investigated - spend zero effort re-deriving those; hunt for what is ` +
`NOT on that list.\n\n${lens.prompt}`
)
}
function verifyPrompt(lensKey, candidates) {
return (
`${RULES}\n\nYou are an ADVERSARIAL VERIFIER. Another model hunted the "${lensKey}" lens of this repo and ` +
`produced the candidate findings below. Your job is to REFUTE them, not to agree with them.\n\n` +
`For each candidate, independently: open the cited file, read the surrounding function in full, grep every ` +
`caller, and look for an existing test that locks the current behavior. Then ask, in order:\n` +
` 1. Does the cited code actually say what the finding claims? (Misread code is the most common failure.)\n` +
` 2. Is the bad state actually reachable, or does an upstream guard/type/lock make it impossible?\n` +
` 3. Is the described repro real - can you name the concrete inputs or the exact interleaving?\n` +
` 4. Is this intended behavior that a test already asserts?\n\n` +
`Set refuted=true if ANY of those kills it. DEFAULT TO refuted=true when you are uncertain - a false ` +
`positive costs more than a miss here. Only set refuted=false when you can point at the specific lines ` +
`that prove the bug and describe how it fires.\n` +
`Re-rate severity yourself; do not inherit the hunter's rating. For each survivor, give the smallest ` +
`correct fix - one guard in the shared function beats a guard in every caller.\n\n` +
`Return one verdict per candidate, keeping title/file/line so they can be matched up.\n\n` +
`--- CANDIDATES ---\n${JSON.stringify(candidates, null, 2)}`
)
}
while (dry < DRY_THRESHOLD && round < MAX_ROUNDS) {
if (budget.total && budget.remaining() < ROUND_BUDGET_FLOOR) {
stoppedOnBudget = true
log(`Budget floor reached (${Math.round(budget.remaining() / 1000)}k left) - stopping before round ${round + 1}`)
break
}
const family = lensesForRound(round + 1)
if (!family || !family.length) break // nothing to hunt != everything demoted
round++
const lenses = family.filter((l) => (cleanStreak[l.key] || 0) < 2)
if (!lenses.length) {
dry++
roundStats.push({ round, family: familyName(round), lenses: 0, candidates: 0, fresh: 0, confirmed: 0, refuted: 0, dryEligible: true, dryAfter: dry })
log(`Round ${round}: every lens demoted - counts as a dry round (dry=${dry})`)
continue
}
const rnd = round
const seenAtStart = seen.slice()
const lensResults = await pipeline(
lenses,
(lens) =>
parallel([
() => agent(finderPrompt(lens, rnd), { label: `r${rnd}:hunt:${lens.key}:opus`, phase: `Round ${rnd}`, model: 'opus', effort: 'high', schema: FINDINGS }),
() => agent(finderPrompt(lens, rnd), { label: `r${rnd}:hunt:${lens.key}:sonnet`, phase: `Round ${rnd}`, model: 'sonnet', effort: 'high', schema: FINDINGS }),
]).then((pair) => ({ lens, pair })),
async (r) => {
const { lens, pair } = r
const finderFailed = pair.some((p) => p === null)
const union = pair.filter(Boolean).flatMap((p) => p.findings || [])
const fresh = dedupe(union, seenAtStart)
if (!fresh.length) return { lens, finderFailed, unionCount: union.length, fresh: [], verdicts: [] }
log(`r${rnd} ${lens.key}: ${fresh.length} fresh candidate(s) -> verification`)
const vopts = { phase: `Round ${rnd}`, model: 'fable', effort: 'high', schema: VERDICTS }
let v = await agent(verifyPrompt(lens.key, fresh), { ...vopts, label: `r${rnd}:verify:${lens.key}` })
if (!v || (v.verdicts || []).length < fresh.length) {
const retry = await agent(verifyPrompt(lens.key, fresh), { ...vopts, label: `r${rnd}:verify:${lens.key}:retry` })
if (((retry && retry.verdicts) || []).length > ((v && v.verdicts) || []).length) v = retry
}
return { lens, finderFailed, unionCount: union.length, fresh, verdicts: (v && v.verdicts) || [] }
},
)
let eligible = !lensResults.some((r) => !r)
let newConfirmed = 0
let newRefuted = 0
let candCount = 0
let freshCount = 0
for (const r of lensResults.filter(Boolean)) {
candCount += r.unionCount
freshCount += r.fresh.length
if (r.finderFailed) eligible = false
let lensConfirmed = 0
const unmatched = r.fresh.slice()
for (const v of r.verdicts) {
const vRec = { file: v.file, line: v.line, title: v.title }
const idx = unmatched.findIndex((f) => isDup(vRec, f) || isDup(f, vRec))
if (idx === -1) {
log(`r${round} ${r.lens.key}: verifier verdict "${v.title}" (${v.file}:${v.line}) matched no candidate - dropped`)
continue
}
unmatched.splice(idx, 1)
const rec = { file: v.file, line: v.line, title: v.title, status: v.refuted ? 'refuted' : 'confirmed' }
if (seen.some((p) => isDup(rec, p))) continue // cross-lens same-round duplicate
seen.push(rec)
if (v.refuted) newRefuted++
else {
newConfirmed++
lensConfirmed++
confirmedAll.push({ ...v, lens: r.lens.key, round })
}
}
if (unmatched.length) {
eligible = false // partial verifier failure: some candidates got no verdict at all
for (const f of unmatched) unverified.push({ ...f, lens: r.lens.key, round })
}
// a lens hunted at partial panel strength, or whose candidates never got a verdict, is not evidence of cleanliness
if (!r.finderFailed && !unmatched.length) cleanStreak[r.lens.key] = lensConfirmed > 0 ? 0 : (cleanStreak[r.lens.key] || 0) + 1
}
if (newConfirmed > 0) dry = 0
else if (eligible) dry++
// ineligible zero-confirm round: dry unchanged - "we didn't fully look" is not "it's clean"
roundStats.push({ round, family: familyName(round), lenses: lenses.length, candidates: candCount, fresh: freshCount, confirmed: newConfirmed, refuted: newRefuted, dryEligible: eligible, dryAfter: dry })
log(`Round ${round} (${familyName(round)}): ${newConfirmed} confirmed, ${newRefuted} refuted, dry=${dry}${eligible ? '' : ' (ineligible)'}`)
}
const converged = dry >= DRY_THRESHOLD
// ---------- report ----------
phase('Report')
const RANK = { critical: 0, high: 1, medium: 2, low: 3 }
const confirmedSorted = confirmedAll.slice().sort((a, b) => RANK[a.severity] - RANK[b.severity])
const unverifiedFinal = unverified.filter((u) => !seen.some((p) => isDup(u, p)))
const table = convergenceTable(roundStats, converged, stoppedOnBudget)
let report
if (!confirmedSorted.length && !unverifiedFinal.length) {
const outcome = converged ? 'Converged' : stoppedOnBudget ? 'Stopped on budget - NOT converged' : 'Hit the round backstop - NOT converged'
report = `${outcome} after ${round} round(s) with zero confirmed findings.\n\n${table}`
} else {
report = await agent(
`You are writing the final bug-hunt report for OwnCord (D:/Local-Lab/Repos/OwnCord).\n\n` +
`The findings below already survived adversarial verification - do NOT re-litigate them, and do NOT add new ` +
`ones. Your job is presentation and prioritization for a maintainer who will fix these today.\n\n` +
`Spot-check the two highest-severity findings against the real files to make sure file paths and line numbers ` +
`are accurate; correct them silently if they drifted.\n\n` +
`Write markdown:\n` +
` - Open with one paragraph: how many real bugs, in which subsystems, whether the hunt CONVERGED, and which ` +
`finding to fix first and why.\n` +
` - Then one section per finding, ordered by severity: a "### <severity> - <title>" heading, the ` +
`\`file:line\` reference, what breaks and under exactly what conditions, and the smallest correct fix.\n` +
(unverifiedFinal.length
? ` - Then an "## Unverified - re-run" section listing these candidates whose verification failed twice: ` +
`${JSON.stringify(unverifiedFinal)}\n`
: '') +
` - End with the convergence table below, VERBATIM.\n` +
`Write in complete sentences. No emoji, no "consider" hedging.\n\n` +
`--- CONFIRMED FINDINGS ---\n${JSON.stringify(confirmedSorted, null, 2)}\n\n` +
`--- CONVERGENCE TABLE ---\n${table}`,
{ label: 'report', phase: 'Report', model: 'fable', effort: 'high' },
)
}
return { converged, stoppedOnBudget, rounds: roundStats, confirmed: confirmedSorted, unverified: unverifiedFinal, report }
+4
View File
@@ -17,6 +17,10 @@
- [ ] Unit tests pass (`npm test` / `go test ./...`)
- [ ] TypeScript check passes (`npx tsc --noEmit`)
- [ ] Manual testing done (describe below)
- [ ] Docs updated — anything under `docs/architecture/` (incl. `ux/`) whose
"Source of truth" files this PR touches is updated in the same PR
(their maintenance rule), and reference docs (`api.md`, `protocol.md`,
`schema.md`, `server-configuration.md`) reflect any surface changes
## Screenshots
+113 -63
View File
@@ -37,7 +37,7 @@ jobs:
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: "1.26"
cache-dependency-path: Server/go.sum
@@ -75,6 +75,19 @@ jobs:
- name: Run tests with deadlock detection
run: go test -tags deadlock -count=1 ./...
# Tag-gated tests (DC-06 / T-2026-07-25-16). The build-tag matrix above
# only COMPILES the otel/wazero variants; the tests behind those tags
# (plugin/sandbox_wazero_test.go, telemetry/telemetry_otel_test.go) ran
# nowhere until this step. Scoped to the two packages that carry tagged
# files — every other package is tag-invariant and already covered by the
# race run above. One leg is enough; no -race (the runtime under the tag
# is the concern, not new concurrency).
- name: Run tag-gated tests (-tags wazero, -tags otel)
if: matrix.os == 'ubuntu-latest'
run: |
go test -tags wazero -count=1 ./plugin/...
go test -tags otel -count=1 ./telemetry/...
- name: Upload Go coverage
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
@@ -83,11 +96,18 @@ jobs:
path: Server/coverage.out
retention-days: 7
# verify: false — the action's default `config verify` pass fetches
# golangci-lint.run's JSONSchema over HTTPS before linting anything, so a
# timeout on that host fails a required job having run zero linters (it
# took main red on d352696). `golangci-lint run` rejects a bad config on
# its own; the schema pass only bought a prettier error message, priced
# at a third-party site inside the gate.
- name: Lint
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
with:
version: v2.11.3
working-directory: Server/
verify: false
client-check:
name: Client Static Checks
@@ -107,25 +127,6 @@ jobs:
- name: Install npm dependencies
run: npm ci
- name: Patch auto-generated Tauri TypeScript bindings
working-directory: Client/tauri-client/
# tauri-typegen generates an Event type that is intentionally unused in app code.
# Rename it to _Event so @typescript-eslint/no-unused-vars does not fail.
run: |
node -e "
const fs = require('fs');
const p = 'src/generated/events.ts';
if (fs.existsSync(p)) {
let c = fs.readFileSync(p, 'utf8');
c = c.replace(/^type Event\b/gm, 'type _Event').replace(/^interface Event\b/gm, 'interface _Event');
c = c.replace(/,\s*type Event\s*(?=\})/g, ' '); // unused named import from @tauri-apps/api/event
fs.writeFileSync(p, c);
console.log('Patched: renamed Event -> _Event in generated/events.ts');
} else {
console.log('src/generated/events.ts not found, skipping patch.');
}
"
# Scoped to shipped dependencies. The remaining high findings are all one
# advisory, brace-expansion <=5.0.7, reaching us only through dev tooling
# (eslint, @vitest/coverage-v8, stryker). Those are already on their
@@ -144,6 +145,12 @@ jobs:
- name: TypeScript check
run: npx tsc --noEmit
- name: TypeScript check (Playwright specs)
# The main tsconfig excludes tests/e2e from the app graph; this
# project typechecks the 47 spec files + fixtures + the three
# playwright configs so type rot cannot hide there.
run: npx tsc -p tsconfig.e2e.json --noEmit
- name: ESLint (type-aware rules)
run: npx eslint src/
@@ -151,7 +158,9 @@ jobs:
run: npx prettier --check "src/**/*.ts" "tests/**/*.ts"
- name: Knip (unused code & deps)
run: npx knip || true
# Blocking since the 2026-08-04 remediation: the '|| true' era let a
# real unused-export finding sit invisible in every green run.
run: npx knip
# Unit tests live in their own job so a suite failure is visible as exactly one
# failing check instead of masking the static gates above. The suite is GREEN
@@ -218,7 +227,7 @@ jobs:
components: clippy
- name: Rust cache
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: Client/tauri-client/src-tauri
@@ -228,24 +237,23 @@ jobs:
- name: Rust unit tests
run: cargo test --lib
# Playwright e2e against the mocked-Tauri dev server. The suite is green
# since the mock repair (start_http_proxy stub + voice-premise rewrite):
# a full 255-test run passes locally in ~7.5 min at 1 worker. Runaway
# protection lives in playwright.config.ts (maxFailures: 20 aborts a
# systemic cascade early; globalTimeout: 20 min self-terminates with a
# usable report) with timeout-minutes below as the outer backstop.
# Playwright e2e against the mocked-Tauri dev server. Runaway protection
# lives in playwright.config.ts (maxFailures: 20 aborts a systemic cascade
# early; globalTimeout: 20 min self-terminates with a usable report) with
# timeout-minutes below as the outer backstop.
#
# Still continue-on-error for now: a newly-revived 255-test browser suite
# may harbor rare flakes (retries: 2 covers them, but confidence needs a
# few green pushes first). Flip this job to blocking once it has been
# stably green across several pushes.
# BLOCKING since 2026-08-05 (DC-07): the post-repair soak recorded green
# full-suite runs at 270, 276 and 291 tests across the 08-04/08-05 audit
# branches, and the one hard CI failure in that window was a real spec bug
# (updater install-settle race), which a non-blocking job would have let
# rot. retries: 2 absorbs the known rare flake class (see E2E-ISSUES.md's
# flake accounting).
# See docs/audit-test-coverage-2026-07-25.md T-2026-07-25-21.
# The native config (playwright.config.native.ts) is deliberately not wired
# up — it needs a real server and a built desktop binary.
client-e2e:
name: Client E2E (Playwright, non-blocking)
name: Client E2E (Playwright)
runs-on: ubuntu-latest
continue-on-error: true
timeout-minutes: 25
defaults:
run:
@@ -278,6 +286,53 @@ jobs:
Client/tauri-client/test-results/
retention-days: 7
# Admin-panel journey against a REAL server (no mocks): start-server.sh
# builds the Go binary and boots it with a fresh temp data dir, and the
# suite drives the embedded SPA through the first-run wizard, dashboard,
# channel CRUD, audit log and re-login — the one DC-04 surface the mocked
# suites cannot reach. Non-blocking while it earns its soak, same
# graduation convention client-e2e followed.
admin-e2e:
name: Admin Panel E2E (real server, non-blocking)
runs-on: ubuntu-latest
continue-on-error: true
timeout-minutes: 20
defaults:
run:
working-directory: Client/tauri-client/
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: "1.26"
cache-dependency-path: Server/go.sum
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20
cache: npm
cache-dependency-path: Client/tauri-client/package-lock.json
- name: Install npm dependencies
run: npm ci
- name: Install Playwright browser
run: npx playwright install --with-deps chromium
- name: Run admin-panel journey
run: npx playwright test --config=playwright.config.admin.ts
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: admin-e2e-report
path: |
Client/tauri-client/playwright-report/
Client/tauri-client/test-results/
retention-days: 7
# Blocking e2e subset: the parity-feature specs (tagged "@parity"), covering
# the wire paths added in v1.2.0 (mentions/badges, per-channel mute, NSFW
# gate, group DMs, role change, custom-emoji autocomplete, voice moderation).
@@ -330,10 +385,10 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build image (no push)
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: Server/
push: false
@@ -341,11 +396,30 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
# Full Tauri build only on PRs to main (expensive: ~15 min x2 multiplier)
# Full Tauri build only on PRs to main (expensive: ~15 min x2 multiplier).
#
# Skipped for Dependabot: its PRs run under the separate `dependabot` secrets
# scope, so TAURI_SIGNING_PRIVATE_KEY arrives empty and `npm run tauri build`
# always aborts with "failed to decode secret key" while signing the updater
# artifact — after a successful compile and bundle. That burned ~50 min of
# runner time per dependency PR to produce a red check that never carried any
# signal. Granting Dependabot the signing key would fix the symptom but hands
# a release key to workflows triggered by third-party dependency updates.
#
# What still covers Dependabot PRs: the required `rust-tests` job compiles the
# crate (cargo clippy --all-targets + cargo test --lib), so a dependency bump
# that breaks the Rust build is still caught.
# What this gives up on those PRs: bundling (NSIS/AppImage/deb), Windows and
# ARM-specific compilation, and the `cargo audit` step below — that last one
# overlaps with Dependabot's own cargo scanning, which is what opens these PRs
# in the first place.
tauri-build:
name: Tauri Full Build (${{ matrix.os }})
needs: client-check
if: github.event_name == 'pull_request' && github.base_ref == 'main'
if: >-
github.event_name == 'pull_request'
&& github.base_ref == 'main'
&& github.actor != 'dependabot[bot]'
strategy:
fail-fast: false
matrix:
@@ -388,37 +462,13 @@ jobs:
components: clippy
- name: Rust cache
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: Client/tauri-client/src-tauri
- name: Install npm dependencies
run: npm ci
- name: Install tauri-typegen
run: cargo install tauri-typegen@0.5.0 --quiet
- name: Generate TypeScript IPC bindings
working-directory: Client/tauri-client/
run: cargo tauri-typegen generate
- name: Fix generated TypeScript bindings (tauri-typegen 0.5.0 workaround)
working-directory: Client/tauri-client/
# tauri-typegen 0.5.0 cannot map serde_json::Value to a TS type — patch post-generation.
# Duplicate events are avoided at source by using one emit() call site per event name.
run: |
node -e "
const fs = require('fs');
const tp = fs.readFileSync('src/generated/types.ts', 'utf8');
if (!tp.includes('export type Value')) {
fs.writeFileSync('src/generated/types.ts', tp.replace(
'export interface CredentialData',
'export type Value = unknown;\n\nexport interface CredentialData'
));
}
console.log('Generated bindings patched.');
"
- name: Clippy lint (Rust)
working-directory: Client/tauri-client/src-tauri/
run: cargo clippy -- -D warnings
+2 -2
View File
@@ -26,13 +26,13 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
uses: anthropics/claude-code-action@9db594c7a0e82298c121c18b7f08aa1579ce7341 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+6 -6
View File
@@ -53,7 +53,7 @@ jobs:
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: Rust cache
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: Client/tauri-client/src-tauri
@@ -120,7 +120,7 @@ jobs:
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: Rust cache
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: Client/tauri-client/src-tauri
@@ -201,7 +201,7 @@ jobs:
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: "1.26"
@@ -276,7 +276,7 @@ jobs:
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: Rust cache
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: Client/tauri-client/src-tauri
@@ -359,7 +359,7 @@ jobs:
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
@@ -379,7 +379,7 @@ jobs:
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: Server/
push: true
+12 -5
View File
@@ -2,11 +2,13 @@
.env
Server/.env
# Claude Code — local-only, never committed. A slashless pattern matches at any
# depth, so these also cover Server/CLAUDE.md, Client/**/CLAUDE.md and nested
# .claude/ dirs.
.claude/
CLAUDE.md
# Claude Code — local-only by default. The exceptions are committed on purpose:
# a cloud session clones this repo and sees ONLY tracked files, so the CLAUDE.md
# files, skills and workflows have to be here or it starts with no instructions.
# Machine-local state (settings.local.json, locks) stays ignored.
.claude/*
!.claude/skills/
!.claude/workflows/
CLAUDE.local.md
.mcp.json
@@ -25,6 +27,11 @@ docs/research/
docs/superpowers/
/skills/
# Mutation-testing output (npm run test:mutate). Local-only by design: a
# surviving-mutant report maps exactly which behaviour nothing tests.
Client/tauri-client/.stryker-tmp/
Client/tauri-client/reports/
# Server runtime artifacts
Server/chatserver.exe
Server/chatserver.exe~
+200 -3
View File
@@ -5,6 +5,203 @@ tooling (`npm run changelog`) auto-generates entries from commit messages
on each release; this file is the curated counterpart that calls out
behavioural changes operators must know about.
## v1.2.0-alpha.2
- **feat(client):** the login form has an **Auto connect** checkbox under
Remember password. Ticking it makes that server connect automatically on
launch — the same setting as the auto-login button on a server card, so
the two stay in sync, and as before only one server can be auto-connect
at a time.
Ticking it also forces Remember password on and locks it: auto-connect
replays the stored token, which is only written when the password is
remembered, so the two cannot be set independently without producing a
setting that silently does nothing.
- **fix(client):** Remember password works again. The password was saved to
the OS keyring but never returned to the client over IPC, so the login
form could not prefill it — the box appeared to work and did nothing.
- **fix:** three bug-hunt sweeps closed **233 verified defects** since
`v1.2.0-alpha.1` — 26 in #1328, 107 in #1331, 100 in #1332 — each fixed
test-first, with the failing assertion watched red against the unpatched
code before the patch landed. The behavioural consequences worth knowing
about are listed in the nine entries below.
- **server:** WS hub reconnect and replay hardening (#1328, #1331).
Cold-tier replay used to truncate silently instead of forcing a full
ready, and a retention-pruned event log was accepted outright as a
complete resume — the highest-impact fix in #1331, since any client whose
reconnect gap crossed the 24h retention default was permanently desynced.
Resume also silently dropped the focused channel's topic subscription,
stopping message delivery until the user manually switched channels; it
is now restored during the handshake. `visibilityChangeSeq` can now only
move forward across its three writers — it previously could regress and
skip a required resync.
- **server:** voice/E2EE key-holder election and audience gating (#1328,
#1331) — three key-holder desync bugs (no client demotion path, peer keys
cleared on reconnect, missing re-election on the webhook and
fresh-reconnect paths), plus re-election wired into the sweep and
channel-cleanup paths. Voice events were READ-filtered while membership
is CONNECT-only, so participants in that gap silently missed
`voice_leave`, stalling key-holder election and forward-secrecy rotation.
Deleting a channel now evicts its voice participants first — the cleanup
function existed but had zero production callers, so the FK cascade used
to strand them silently. Moderator mute/deafen now survives a
voice-channel switch; joins to non-voice channels are rejected; archived
channels are read-only and unjoinable.
- **security(server):** roles/permissions (#1328, #1331) — `UpdateRole`
allowed position collisions that `CreateRole` already rejected, so tied
positions could read as equal rank in every hierarchy comparison; it now
matches `CreateRole`'s validation. `can_send` is now recomputed per client
on every role/override change, so a permission change takes effect for
connected clients immediately rather than waiting on a reconnect.
- **server:** attachments and admin data-safety (#1331) — migration **030**
unlinks attachments on message delete instead of cascading, so a cascaded
channel/DM delete no longer strands uploaded files on disk with no
reclamation path. The 15-minute orphan-attachment sweep was deleting every
avatar in the instance (avatars are, by design, attachments with no
message link) on its first tick past the grace period, permanently 404ing
every profile picture; a second bug in the same sweep collapsed the
one-hour grace period to effectively zero, from a TEXT-comparison mismatch
between an RFC3339 cutoff and SQLite's own timestamp format. A failed
backup restore used to truncate the live database to zero bytes with no
rollback, while the server kept answering requests against the now-closed
DB and falsely claimed a restart was underway — it now restores the
pre-restore safety copy on failure and requests the restart honestly.
Also fixed: personal data is cleared on account deletion, banned users are
excluded from owner lookup, the silent 1000-member roster cap is gone, and
a sender's own read state now advances on send. Migration applies
automatically on first start; no operator action needed.
- **protocol:** a new READ-gated `active_channel_id` auth field (#1331)
restores the focused-channel subscription during the reconnect handshake
itself, closing the window before the post-`auth_ok` `channel_focus` round
trip lands. `protocol.md` also corrects the presence table, which had
incorrectly documented all presence events as sequenced. Older
clients/servers are unaffected — it is a new, ignorable field.
- **security(client):** identity/TOFU and transport (#1332) — an in-flight
change to scope the identity keypair by host *and* user id would have
re-minted a fresh key on every existing install, firing the TOFU "verify
out-of-band" re-pin warning at the entire alpha population simultaneously,
exactly the pattern that teaches users to click through the one warning
meant to matter. The legacy host-only key is now adopted into the scoped
name instead, saving before deleting so a partial failure cannot strand a
user with neither key. Switching hosts carried the previous server's
bearer token forward into the next login request; `api.setConfig` now
drops it when the host changes without a replacement. A hand-copied,
un-lowercased host normalizer in `main.ts` meant an uppercase hostname's
cert-mismatch *reject* path skipped `disconnect()`/`clearAuth()`, leaving
a user who refused a changed certificate still connected to that server —
the single lowercased implementation in `ws.ts` is now shared everywhere.
- **fix(client):** voice mic/camera reliability (#1331, #1332) — six
separate paths could republish the microphone without checking the user's
mute state (the audio-device fallback, selecting "Default" input,
un-deafening, `retryMicPermission`, a stale PTT ownership latch, and
auto-reconnect's `restoreLocalVoiceState`), each producing a hot mic while
every remote UI still showed the user muted; all now route through
`isMicPolicyGated()`. Camera and screenshare kept publishing to the SFU
after the user turned them off during the OS device picker. Enhanced Noise
Suppression silently disabled the input-volume slider and VAD gate because
`livekit-client`'s own `replaceTrack` call landed after ours. A key-holder
promotion arriving mid voice-setup was clobbered, ejecting the joiner
after a timeout only it could have resolved.
- **fix(client):** messaging and store reliability (#1328, #1331, #1332) —
sequenced DMs could jump the FIFO ahead of `sendHigh`, permanently losing
an event dropped before flush. A full-ready resync left every loaded
channel with a permanent hole in its history, because that tier never
replays `chat_message` frames; loaded windows are now invalidated and the
active channel refetched. The WS error handler only bannered
`RATE_LIMITED` and `FORBIDDEN`, so every other server error code — for
example a rejected `chat_edit` — was dropped in silence while the
optimistic "Message edited" toast still fired. A message whose
`chat_send_ok` was lost to the same disconnect that forced a resync could
render twice; the optimistic row's id-based dedup now shares the
content-based match predicate `addMessage` already used. Replay detection
compared the server's `created_at` against the client's own clock, so a
self-hosted server without NTP made every live message after a reconnect
look like a replay and silently killed its notification; both sides now
use an estimated server-time skew.
- **fix(client):** UI defects (#1331, #1332) — the quick-switcher could
mount a second overlay, orphaning a body-mounted backdrop that blocked all
input until reload. The status-picker stylesheet targeted a root element
the component never toggles; a same-branch repair then left the status dot
itself 0×0 and unclickable, now fixed together with a test pinning the
stylesheet to the classes the component actually emits. The attachment
remove button and the failed-send Retry/Discard buttons did nothing;
drag-reorder's phantom-drag latch and permission gate are fixed; keyboard
Tab could escape every modal because hidden (`display: none`) controls
were still counted as focusable.
- **fix(client):** the user profile popup is styled correctly again
(`a308f81`).
- **fix(client):** Vite no longer watches `src-tauri/`, so a running dev
server does not rebuild the frontend when Rust sources or build artifacts
change (`cdcfc03`).
- **fix(release):** the stripped Linux AppImage is signed from the
environment-provided key instead of a temporary key file (`9d75890`) —
release-pipeline only, no operator action needed.
- **docs:** full documentation audit against `5630aa1` — reference docs,
architecture pages, and UX specs corrected; plans and prior audits given
verified statuses; see `docs/audit-2026-08-04-docs-and-coverage.md`.
- **security(server):** closed the three 2026-08-04 review findings — the
channel role-override **DELETE** now enforces the same hierarchy guard as
PUT (A-2026-08-01); the admin channel list/edit/delete surface no longer
sees DM channels, answering 404 for their ids (A-2026-08-02); DM call
rings respect blocks like every other DM interaction (A-2026-08-03).
Behavioural note: deleting a channel override for a *nonexistent* role now
returns 404 (was 204), matching PUT.
- **server:** migration **029** drops the never-used `sounds` table (dead
since the initial schema; A-2026-07-13). Applies automatically on first
start; no operator action.
- **protocol:** the plugin command family (`chat_command`, `command_reply`,
`plugin_broadcast`) is now part of `protocol-schema.json` and the
generated constants (27 client→server / 39 server→client). Wire strings
are unchanged — no client or plugin impact.
- **chore(client):** dead modules deleted (`ServerStrip`, `FileUpload`,
`reconcile`, a stray worklet copy, orphan sounds API methods) and the
unused tauri-typegen pipeline retired (`src/generated/**`, its CI steps,
config block, and build-dependency).
- **ci:** knip is now blocking; Playwright specs are typechecked
(`typecheck:e2e`); three orphaned native e2e specs run again;
`claude.yml` actions are SHA-pinned; the PR template asks for docs
updates per the architecture maintenance rule.
- **tests(client):** the TOFU certificate ceremony has e2e coverage
(first-use + mismatch journeys), and `modalFactory` is fully covered.
- **security(client):** the voice-E2EE identity pin lookup fails **closed**
on keyring errors (DC-08): a transient store failure used to read as
"never pinned", silently sending a pinned peer down the first-sight path
and re-pinning whatever key the server delivered. An unreadable pin store
now rejects the peer's announce, writes nothing, and shows a distinct
amber "could not check" badge until the store recovers.
- **feat(client):** accessibility pass over the modal/overlay stack
(DC-13): every modal is a labelled `role="dialog"` with a focus trap and
focus restore, Escape maps to each dialog's safe action, the settings
sidebar is a keyboard-navigable tablist, the quick switcher and composer
autocompletes are wired as combobox/listbox, the emoji/GIF pickers are
keyboard-operable, and toasts/typing announce via polite live regions.
- **feat(client):** UX polish (DC-12): deleting the active channel now
says so in a toast; reactions toggle optimistically with rollback on
failure; the role-change menu can no longer double-fire; a document-level
listener leak in channel drag-reorder is fixed.
- **feat(admin):** restoring a backup now writes a `backup_restore`
audit-log row (DC-09). The row is written before the pre-restore safety
copy, so it lives inside the `pre_restore_*.db` backup — the restored
database itself cannot carry it (the restore replaces the file).
- **ci:** the `-tags wazero` / `-tags otel` Go tests now actually run in CI
(DC-06) — previously those variants were only compiled, leaving ~600
lines of plugin/telemetry tests permanently dark.
- **tests(client):** e2e journeys for voice-E2EE identity verification
(badge states + mismatch modal, driven through the real crypto path) and
the updater (banner → progress → auto-relaunch), plus an accessibility
smoke; full web suite now 291 tests.
- **server/admin:** in-place self-update is refused in container
deployments (503 `CONTAINER_DEPLOYMENT`; the shipped image sets
`OWNCORD_CONTAINER=1`, bind-mount operators can set `0` to opt back in).
Container upgrades are image pulls; `GET /admin/api/updates` now reports
`can_apply` and the admin panel says so instead of offering the button.
- **ci:** the full client e2e suite now blocks merges (DC-07); a new
non-blocking `admin-e2e` job drives the admin panel against a real server
(first-run wizard, channel CRUD, audit log, re-login).
- **docs:** the dependency pinning/review policy is written down in
`docs/contributing.md`, closing the last 2026-04 audit carryover that was
still undecided.
## v1.2.0-alpha.1 — Discord feature parity
> **Project reset note:** OwnCord has re-entered alpha. The `v1.0.0` release is
@@ -256,6 +453,6 @@ claimed behaviour — no product code changed and no assertion weakened.
The project is under a feature freeze until the beta reset completes.
Explicitly deferred (not abandoned unless noted): real OpenTelemetry SDK
wiring, the Postgres backend (scaffolding removed pending real demand),
the slash-command dispatcher (`docs/plans/slash-commands.md`), and the
Solid.js migration (abandoned — the experiment is being removed in favor
of the established vanilla component pattern).
and the slash-command dispatcher (`docs/plans/slash-commands.md`). The
Solid.js migration was abandoned and its experiment fully removed
(2026-07-19) in favor of the established vanilla component pattern.
+30
View File
@@ -0,0 +1,30 @@
# OwnCord
Self-hosted chat platform (alpha). `Server/` is a Go 1.26 REST + WebSocket
server over SQLite with LiveKit voice/video; `Client/tauri-client/` is a Tauri
v2 desktop app (TypeScript frontend, thin Rust backend). Per-component detail
lives in `Server/CLAUDE.md` and `Client/tauri-client/CLAUDE.md`; the protocol
and schema are documented in `docs/protocol.md`, `docs/schema.md`, and
`docs/architecture/README.md`.
## Generated code — never hand-edit
CI fails on drift, and the next generator run silently discards your edit.
| Generated | Source of truth | Workflow |
| --- | --- | --- |
| `Server/db/dbgen/` | `Server/db/queries/*.sql`, `Server/migrations/` | `db-change` skill |
| `Server/ws/message_types.go` **and** `Client/tauri-client/src/lib/protocolTypes.ts` | `docs/protocol-schema.json` | `protocol-change` skill |
| `Client/tauri-client/src/generated/` | `tauri-typegen` | CI patches known typegen bugs — see `.github/workflows/ci.yml` |
## Gotchas
- **Verify with the `ci-check` skill**, not with an ad-hoc `go build && go test`.
CI compiles four Go build-tag variants and runs a deadlock-detection pass;
the default build proves nothing about the tagged ones.
- **The client unit suite is green and must stay green.** Never make a failing
test pass by weakening its assertions.
- Security issues go through GitHub Security Advisories, never public issues
(`docs/security.md`). This repo is public — unfixed defects do not belong in
commits, issues, or PR descriptions.
- Branch from `main`, PR to `main`, squash merge, conventional commit subjects.
+1
View File
@@ -0,0 +1 @@
20
+38
View File
@@ -0,0 +1,38 @@
# OwnCord Client (Tauri v2)
TypeScript frontend (Vite, vanilla TS — no React/Vue) plus a deliberately thin
Rust backend in `src-tauri/` for native APIs only. LiveKit handles voice/video.
## Layout
- `src/stores/` observable stores · `src/lib/` protocol, WS, voice, E2EE ·
`src/pages/`, `src/components/` UI
- `src/lib/protocolTypes.ts` and `src/generated/` are generated — see the root
CLAUDE.md
- `tests/unit`, `tests/integration` (vitest, jsdom) · `tests/e2e` (Playwright) ·
`tests/browser` (vitest browser mode)
## Gotchas
- **On Node 22+ you must run `NODE_OPTIONS=--no-experimental-webstorage npm test`.**
Native Web Storage shadows jsdom's `localStorage` and fails ~478 tests that
have nothing to do with your change. That is a local toolchain artifact, not
a regression — do not "fix" those failures. CI pins Node 20.
- `src/lib/dispatcher.ts` is the single WS-event entry point **into the
stores**: server events reach domain stores only through a `ws.on(...)`
subscription registered there. Other modules do register their own
`ws.on(...)` handlers for page-local UI (`main.ts`, `MainPage.ts`,
`ChannelController.ts` — ringing, overlays, slow-mode timers); that is fine
as long as they only *read* store state. Writing a store from one of those
handlers is the violation, and `local/no-store-write-in-ws-on` now fails the
build on it.
- Voice sessions are superseded, not cancelled. `LiveKitSession` re-entry
points check whether a newer attempt owns the shared state before tearing
anything down, so cleanup in an aborted path must be scoped to that attempt's
own room — a global `leaveVoice()` there kills the live session.
- Voice E2EE is key-holder based with TOFU identity pinning. Anything touching
`livekitE2EE.ts` or `identity.ts` must preserve the epoch/keypair staleness
guards and must never report an unverified peer as verified.
- Do not run `npm run tauri build` locally; the desktop build is CI-only.
- Formatting is prettier-enforced; match the surrounding code rather than
reasoning about style.
+408
View File
@@ -0,0 +1,408 @@
// Custom ESLint rules that turn three of the invariants documented in prose in
// CLAUDE.md into enforced, test-covered lint rules. Each rule is scoped (via
// `files:` in eslint.config.js) to only the module(s) its invariant governs —
// see the per-rule `meta.docs.description` for the invariant it encodes and
// tests/unit/eslint-rules.test.ts for the real-code shapes it was proven
// against (both the shapes that must stay clean and the historical bug shapes
// it must catch).
//
// Plain JS, ESM, no build step — eslint.config.js imports this directly.
/** True when `node` is a `this.<methodName>(...)` call. */
function isThisMethodCall(node, methodName) {
return (
node !== null &&
node.type === "CallExpression" &&
node.callee.type === "MemberExpression" &&
node.callee.object.type === "ThisExpression" &&
!node.callee.computed &&
node.callee.property.type === "Identifier" &&
node.callee.property.name === methodName
);
}
/** True when `node` is a `this.<propertyName>` member access. */
function isThisMember(node, propertyName) {
return (
node !== null &&
node.type === "MemberExpression" &&
node.object.type === "ThisExpression" &&
!node.computed &&
node.property.type === "Identifier" &&
node.property.name === propertyName
);
}
function isFunctionNode(node) {
return (
node.type === "FunctionDeclaration" ||
node.type === "FunctionExpression" ||
node.type === "ArrowFunctionExpression"
);
}
// ─────────────────────────────────────────────────────────────────────────
// Rule: no-leave-voice-when-superseded
//
// Invariant (CLAUDE.md): "Voice sessions are superseded, not cancelled.
// LiveKitSession re-entry points check whether a newer attempt owns the
// shared state before tearing anything down, so cleanup in an aborted path
// must be scoped to that attempt's own room — a global leaveVoice() there
// kills the live session."
//
// livekitSession.ts encodes "this attempt was superseded" with exactly two
// guard predicates, always used the same way: `this.reconnectSuperseded(...)`
// (true = superseded) and `!this.isStateConnected(...)` (negated = true when
// superseded). Once either guard has confirmed supersession, the historical
// bug (see the fix that introduced disconnectSupersededLocalRoom /
// generation-guarded leaveVoice calls) was calling the global
// `this.leaveVoice()` inside that same branch, tearing down whichever session
// currently owns the shared state — which, once superseded, is a newer
// attempt's live session, not this one.
// ─────────────────────────────────────────────────────────────────────────
/** True when `test` (walking through &&/||) asserts "this attempt IS
* superseded" via one of the two named guards used throughout the file. */
function testSignalsSuperseded(test) {
if (test === null) return false;
if (test.type === "LogicalExpression") {
return testSignalsSuperseded(test.left) || testSignalsSuperseded(test.right);
}
if (isThisMethodCall(test, "reconnectSuperseded")) return true;
if (test.type === "UnaryExpression" && test.operator === "!") {
return isThisMethodCall(test.argument, "isStateConnected");
}
return false;
}
const noLeaveVoiceWhenSuperseded = {
meta: {
type: "problem",
docs: {
description:
"Disallow this.leaveVoice() inside a branch that already confirmed this connect/reconnect " +
"attempt was superseded. Voice sessions are superseded, not cancelled — once reconnectSuperseded() " +
"or !isStateConnected() is true, `_state` may already belong to a newer, live attempt, and " +
"leaveVoice() there tears that live session down instead of the aborted one.",
},
schema: [],
messages: {
unsafeLeaveVoice:
"this.leaveVoice() must not run once this attempt is known to be superseded — it acts on " +
"whichever session currently owns `_state`, which may now be a newer, live attempt. Disconnect " +
"only this attempt's own room instead (e.g. disconnectSupersededLocalRoom(localRoom) / " +
"localRoom.disconnect()), or simply return without calling it.",
},
},
create(context) {
return {
CallExpression(node) {
if (!isThisMethodCall(node, "leaveVoice")) return;
let child = node;
let parent = node.parent;
while (parent) {
if (isFunctionNode(parent)) return; // left the enclosing method — stop
if (
parent.type === "IfStatement" &&
child === parent.consequent &&
testSignalsSuperseded(parent.test)
) {
context.report({ node, messageId: "unsafeLeaveVoice" });
return;
}
child = parent;
parent = parent.parent;
}
},
};
},
};
// ─────────────────────────────────────────────────────────────────────────
// Rule: e2ee-epoch-needs-keypair-check
//
// Invariant (CLAUDE.md): "Anything touching livekitE2EE.ts ... must preserve
// the epoch/keypair staleness guards."
//
// Every async E2EE operation that resumes after an await re-checks it is
// still the current attempt before writing shared state. The historical bug
// (see the fix for handleOfferInner / handleAnnounceInner) compared only
// `this._e2eeEpoch !== epochBefore` — insufficient, because a non-key-holder
// never bumps the epoch, so a torn-down-then-restarted session can resume
// with the epoch unchanged in both the old and new session. The fix requires
// ALSO comparing keypair identity (`this._ecdhKeyPair !== keypair`). This
// rule requires both checks to appear together in the same guard.
// ─────────────────────────────────────────────────────────────────────────
/** True when `test` (walking through &&/||) contains `this.<prop> !== X`
* (in either operand order). */
function containsStrictInequality(test, prop) {
if (test === null) return false;
if (test.type === "LogicalExpression") {
return containsStrictInequality(test.left, prop) || containsStrictInequality(test.right, prop);
}
if (test.type === "BinaryExpression" && test.operator === "!==") {
return isThisMember(test.left, prop) || isThisMember(test.right, prop);
}
return false;
}
const e2eeEpochNeedsKeypairCheck = {
meta: {
type: "problem",
docs: {
description:
"Require this._ecdhKeyPair identity checks alongside this._e2eeEpoch staleness checks. A " +
"non-key-holder session never bumps the epoch, so an epoch-only comparison cannot detect a " +
"torn-down-then-restarted session resuming after an await — only the keypair identity can.",
},
schema: [],
messages: {
missingKeypairCheck:
"This staleness check compares this._e2eeEpoch but not this._ecdhKeyPair. A non-key-holder " +
"session never advances the epoch, so this guard alone cannot detect a torn-down-then-restarted " +
"session — add `|| this._ecdhKeyPair !== <the keypair captured before the await>` to the condition.",
},
},
create(context) {
return {
IfStatement(node) {
if (
containsStrictInequality(node.test, "_e2eeEpoch") &&
!containsStrictInequality(node.test, "_ecdhKeyPair")
) {
context.report({ node: node.test, messageId: "missingKeypairCheck" });
}
},
};
},
};
// ─────────────────────────────────────────────────────────────────────────
// Rule: e2ee-verified-status-literal
//
// Invariant (CLAUDE.md): "Anything touching livekitE2EE.ts ... must never
// report an unverified peer as verified."
//
// verifyPeerAnnounce's every write of peer-verification state goes through
// setPeerVerification/setPeerVerificationIfCurrent, and "verified" is reached
// exactly once, only after a real signature check. This rule keeps that
// structurally true: the `status` field at every call site must be a literal
// the author typed by hand at that call site, never a variable/expression —
// which would let a status be computed (and potentially manipulated) instead
// of asserted at the one audited call site that earned it.
// ─────────────────────────────────────────────────────────────────────────
function getCalleeName(node) {
if (node.callee.type === "Identifier") return node.callee.name;
if (
node.callee.type === "MemberExpression" &&
!node.callee.computed &&
node.callee.property.type === "Identifier"
) {
return node.callee.property.name;
}
return null;
}
const VERIFICATION_SETTERS = new Set(["setPeerVerification", "setPeerVerificationIfCurrent"]);
const e2eeVerifiedStatusLiteral = {
meta: {
type: "problem",
docs: {
description:
"Require the `status` field passed to setPeerVerification/setPeerVerificationIfCurrent to be a " +
"string literal. A peer must never be reported verified via a computed/derived status — each " +
"verification outcome is a distinct, hand-written call site that earned its status inline.",
},
schema: [],
messages: {
dynamicStatus:
"The `status` passed here must be a string literal ('verified' | 'unverified' | 'mismatch' | " +
"'unknown'), not a computed expression. Add a new literal call site for this outcome instead of " +
"deriving the status dynamically — that is what keeps 'verified' provably tied to a real signature check.",
},
},
create(context) {
return {
CallExpression(node) {
const name = getCalleeName(node);
if (name === null || !VERIFICATION_SETTERS.has(name)) return;
const objArg = node.arguments[node.arguments.length - 1];
if (objArg === undefined || objArg.type !== "ObjectExpression") return;
const statusProp = objArg.properties.find(
(p) =>
p.type === "Property" &&
!p.computed &&
p.key.type === "Identifier" &&
p.key.name === "status",
);
if (statusProp === undefined) return;
const value = statusProp.value;
if (value.type !== "Literal" || typeof value.value !== "string") {
context.report({ node: statusProp, messageId: "dynamicStatus" });
}
},
};
},
};
// ─────────────────────────────────────────────────────────────────────────
// Rule: no-identity-scope-fallback
//
// Invariant (CLAUDE.md): "Anything touching livekitE2EE.ts or identity.ts
// must preserve the epoch/keypair staleness guards." (Identity-scoping
// analogue: a documented, previously-real bug — see identity.ts's
// `identityKeyPairCache` comment — where a missing user id fell back to a
// placeholder scope like `?? 0`, silently minting/adopting a keypair under
// the wrong account and permanently desyncing the published key from the
// announce-signing key for every peer.)
//
// getOrCreateIdentityKeyPair's userId argument must come from a value that
// was already checked for `undefined` (the pattern both call sites use), not
// a `??`/`||` fallback that would substitute a placeholder id.
// ─────────────────────────────────────────────────────────────────────────
const noIdentityScopeFallback = {
meta: {
type: "problem",
docs: {
description:
"Disallow a ??/|| placeholder fallback as the userId argument to getOrCreateIdentityKeyPair. A " +
"missing user id must abort (see the `userId === undefined` guards at both call sites), never " +
"substitute a placeholder scope — that mints or adopts a keypair under the wrong account and " +
"permanently desyncs the published key from the announce-signing key.",
},
schema: [],
messages: {
placeholderFallback:
"Do not fall back with ??/|| when passing the user id to getOrCreateIdentityKeyPair — a missing " +
"id must abort instead (check `=== undefined` and return, as both existing call sites do). A " +
"placeholder id mints/adopts a keypair under the wrong account and desyncs it from the signing key.",
},
},
create(context) {
return {
CallExpression(node) {
if (
node.callee.type !== "Identifier" ||
node.callee.name !== "getOrCreateIdentityKeyPair"
) {
return;
}
const userIdArg = node.arguments[1];
if (userIdArg === undefined) return;
if (
userIdArg.type === "LogicalExpression" &&
(userIdArg.operator === "??" || userIdArg.operator === "||")
) {
context.report({ node: userIdArg, messageId: "placeholderFallback" });
}
},
};
},
};
// ─────────────────────────────────────────────────────────────────────────
// Rule: no-store-write-in-ws-on
//
// Invariant (CLAUDE.md): "src/lib/dispatcher.ts is the single WS-event entry
// point: server events reach the stores only through a ws.on(...)
// subscription registered there."
//
// Other modules DO register their own ws.on(...) handlers (page-local UI:
// slow-mode timers, the connected overlay, incoming-call ringing) — that
// itself is not the violation. What must never happen outside dispatcher.ts
// is one of those handlers writing to a domain store directly, bypassing the
// dispatcher. Store *reads* (`fooStore.getState()`) are unaffected; this only
// flags calls to an imported store-mutator function (set/add/update/... from
// a `*/stores/*` module) reached from inside a `ws.on(...)` callback.
// ─────────────────────────────────────────────────────────────────────────
const STORE_MUTATOR_PREFIX =
/^(set|add|remove|update|increment|clear|toggle|open|close|join|leave|mark|confirm|bulk|rollback|reset|prepend|reattach|invalidate|load)[A-Z_]/;
function isStoreModuleSource(source) {
// Matches both the "@stores/..." alias and relative "../stores/..." paths.
return typeof source === "string" && /(?:^|\/)@?stores\//.test(source);
}
function isWsOnCall(node) {
return (
node !== null &&
node !== undefined &&
node.type === "CallExpression" &&
node.callee.type === "MemberExpression" &&
!node.callee.computed &&
node.callee.object.type === "Identifier" &&
node.callee.object.name === "ws" &&
node.callee.property.type === "Identifier" &&
node.callee.property.name === "on" &&
node.arguments.length >= 2
);
}
const noStoreWriteInWsOn = {
meta: {
type: "problem",
docs: {
description:
"Disallow calling an imported store-mutator (set*/add*/update*/... from a stores/ module) from " +
"inside a ws.on(...) callback outside dispatcher.ts. dispatcher.ts is the single place server " +
"events are allowed to write into domain stores; a page-local ws.on(...) handler may read store " +
"state and drive its own local UI, but must not mutate a domain store itself.",
},
schema: [],
messages: {
storeWriteOutsideDispatcher:
"'{{name}}' is a store mutator called from a ws.on(...) handler outside dispatcher.ts. " +
"dispatcher.ts is the single WS-event entry point that may write to stores — move this update " +
"into a dispatcher.ts handler for this message type, or have this handler read the store instead " +
"of writing it.",
},
},
create(context) {
const storeMutatorImports = new Set();
return {
ImportDeclaration(node) {
if (!isStoreModuleSource(node.source.value)) return;
for (const spec of node.specifiers) {
if (spec.type === "ImportSpecifier" && STORE_MUTATOR_PREFIX.test(spec.local.name)) {
storeMutatorImports.add(spec.local.name);
}
}
},
CallExpression(node) {
if (node.callee.type !== "Identifier" || !storeMutatorImports.has(node.callee.name)) return;
let parent = node.parent;
while (parent) {
if (
isFunctionNode(parent) &&
isWsOnCall(parent.parent) &&
parent.parent.arguments[1] === parent
) {
context.report({
node,
messageId: "storeWriteOutsideDispatcher",
data: { name: node.callee.name },
});
return;
}
parent = parent.parent;
}
},
};
},
};
export default {
rules: {
"no-leave-voice-when-superseded": noLeaveVoiceWhenSuperseded,
"e2ee-epoch-needs-keypair-check": e2eeEpochNeedsKeypairCheck,
"e2ee-verified-status-literal": e2eeVerifiedStatusLiteral,
"no-identity-scope-fallback": noIdentityScopeFallback,
"no-store-write-in-ws-on": noStoreWriteInWsOn,
},
};
+39 -12
View File
@@ -1,5 +1,6 @@
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import localRules from "./eslint-rules.js";
export default tseslint.config(
eslint.configs.recommended,
@@ -32,10 +33,7 @@ export default tseslint.config(
// Empty functions are used for no-op callbacks
"@typescript-eslint/no-empty-function": "off",
// Project uses void for fire-and-forget promises intentionally
"@typescript-eslint/no-misused-promises": [
"error",
{ checksVoidReturn: false },
],
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: false }],
// Allow require() in config files
"@typescript-eslint/no-require-imports": "off",
// Unbound methods used in singleton export pattern (bind at export)
@@ -67,14 +65,43 @@ export default tseslint.config(
"consistent-return": "off",
},
},
// --- Local rules: three CLAUDE.md invariants enforced as lint rules ---
// See eslint-rules.js for each rule's rationale and the historical bug
// shape it catches. Each is scoped to only the module(s) its invariant
// governs.
{
ignores: [
"dist/",
"src-tauri/",
"node_modules/",
"public/",
"*.js",
"*.cjs",
],
files: ["src/lib/livekitSession.ts"],
plugins: { local: localRules },
rules: {
"local/no-leave-voice-when-superseded": "error",
},
},
{
files: ["src/lib/livekitE2EE.ts"],
plugins: { local: localRules },
rules: {
"local/e2ee-epoch-needs-keypair-check": "error",
"local/e2ee-verified-status-literal": "error",
"local/no-identity-scope-fallback": "error",
},
},
{
files: ["src/lib/identity.ts"],
plugins: { local: localRules },
rules: {
"local/no-identity-scope-fallback": "error",
},
},
{
// dispatcher.ts IS the allowed entry point, so it is exempt from its own rule.
files: ["src/**/*.ts"],
ignores: ["src/lib/dispatcher.ts"],
plugins: { local: localRules },
rules: {
"local/no-store-write-in-ws-on": "error",
},
},
{
ignores: ["dist/", "src-tauri/", "node_modules/", "public/", "*.js", "*.cjs"],
},
);
+141 -123
View File
@@ -1,12 +1,12 @@
{
"name": "owncord-client",
"version": "1.2.0-alpha.1",
"version": "1.2.0-alpha.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "owncord-client",
"version": "1.2.0-alpha.1",
"version": "1.2.0-alpha.2",
"dependencies": {
"@jitsi/rnnoise-wasm": "^0.2.1",
"@tauri-apps/api": "^2.10.1",
@@ -28,12 +28,13 @@
"@stryker-mutator/typescript-checker": "^9.6.1",
"@stryker-mutator/vitest-runner": "^9.6.1",
"@tauri-apps/cli": "^2",
"@types/node": "^20.19.43",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-v8": "^3",
"eslint": "^10.8.0",
"fast-check": "^4.9.0",
"jsdom": "^29.1.1",
"knip": "^6.1.1",
"knip": "^6.31.0",
"oxlint": "^1.76.0",
"prettier": "^3.9.6",
"typescript": "^5.7",
@@ -1952,9 +1953,9 @@
}
},
"node_modules/@oxc-parser/binding-android-arm-eabi": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.140.0.tgz",
"integrity": "sha512-ZfjDZ422mo7eo3b3VltqNsV9kmv1qt/sPEAMSl64iOSwhVfd0eIZ9LB79Mbs1xYXJnk7WSROwzBCKDIiVxPTvQ==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.142.0.tgz",
"integrity": "sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ==",
"cpu": [
"arm"
],
@@ -1969,9 +1970,9 @@
}
},
"node_modules/@oxc-parser/binding-android-arm64": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.140.0.tgz",
"integrity": "sha512-Ia8jSvikUX6Sf+Ht+KOCUF/k1HpR0VlmqIYymubmWDebOEGtsyliHDR6JxsZ4IX3/c/GbrB1uh09aVGQv/LQmQ==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.142.0.tgz",
"integrity": "sha512-WZkvGRLNQTz8lR9zP5nLjUdlroRCopBu3g9zF1p/laE6DzT1UbQo8Rdz5MWhaJUPYg/6gp+jo7HUgsyKaN1FtQ==",
"cpu": [
"arm64"
],
@@ -1986,9 +1987,9 @@
}
},
"node_modules/@oxc-parser/binding-darwin-arm64": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.140.0.tgz",
"integrity": "sha512-G6VK0nK61pH0d0mBjUqSZbVxGqqO5uzeginLDQj+gOO6ObfJjXRwgkD/ol0w1INcnFeAb6YGGO7qc3ueGHaycQ==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.142.0.tgz",
"integrity": "sha512-l4khS8LQOOVYsGRVARo1gSaCT/aBSceUVXgtovWc2+drnxVuDr082WA3OCHVdVzIz5JIrP/y9CWsSKxBDNmYGg==",
"cpu": [
"arm64"
],
@@ -2003,9 +2004,9 @@
}
},
"node_modules/@oxc-parser/binding-darwin-x64": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.140.0.tgz",
"integrity": "sha512-HazBOuZzd2pO1C2uMmp8Gv7mhzMHqKSKDS1OZfcLEvpIcgA+48J92HEtNanVHDIzRD9PRPCV6aS6fkZIWOVl8Q==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.142.0.tgz",
"integrity": "sha512-QBsNF3nqlXmcH2B1YOPqQYmCJoy4HuIjUxGbBO/k5JAJUl68ghU2psRY2zPk+RyBaWqKP/qfL4oaFgEMCdwskA==",
"cpu": [
"x64"
],
@@ -2020,9 +2021,9 @@
}
},
"node_modules/@oxc-parser/binding-freebsd-x64": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.140.0.tgz",
"integrity": "sha512-9hSUU+HmTUyOe4JzMHxNGgLWNY7rrO+6ShicZwImNJacEAACDMIkuEQQkvXSL+WJN50jaNtLYJv8s4OcBdpyUQ==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.142.0.tgz",
"integrity": "sha512-b7Q7m4Cqc6XqNhri3R+QhU+GVy646Pn+bkdhrDdWym/Fdi0ZUa+d73H9dm5H91JtbtAQ/z1d8XKMW3oOV8a4tQ==",
"cpu": [
"x64"
],
@@ -2037,9 +2038,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-arm-gnueabihf": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.140.0.tgz",
"integrity": "sha512-RAEuQsYtS0KcDFqN0ABTjyyNlokS91JeuDuoW9tEbG0JTbRNXnpQUdbYc/16JoA6Z/2ALbNrE3KmxtqDiuIjCQ==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.142.0.tgz",
"integrity": "sha512-3riVS5IhdH3uCZj1Y9ftDQlR0dvLsIlw/edrRqk8JhgNd5K0XSs+UBtgh50N13CAlW9/TXj6sVGXaKNBocd0Yg==",
"cpu": [
"arm"
],
@@ -2054,9 +2055,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-arm-musleabihf": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.140.0.tgz",
"integrity": "sha512-c4CkHvPvqfojouredJ0w3e6+jiBq0SbFyhH61kr/zPb/7XsaYTNKQ54vmlSsopfdQbNDX40ZeK9Abs2Qet6wcw==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.142.0.tgz",
"integrity": "sha512-NmXUOpgpTSkhl795TiXmWppTwmSJ92RC1qvD6e4XOF+slgmo3e6Ah+kEu+6AN8s7NAOEwqGmir58MgSQSWmBSA==",
"cpu": [
"arm"
],
@@ -2071,9 +2072,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-arm64-gnu": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.140.0.tgz",
"integrity": "sha512-yrjmLj8ixPB25yqvPGr28meGjb+keed7m1GqqY/0uqkhZIoT4t9zmfwUgFEtC33C7dtE+UQ7TU0IaVxf97SWJg==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.142.0.tgz",
"integrity": "sha512-gc0EXsKtXgerujmU2Bql3u1L1HsSQ2774R83idq/FoNMPVV/RY/1ErFsvnit7KoiP/sLvzQixeUo4Ut0ic0wmw==",
"cpu": [
"arm64"
],
@@ -2088,9 +2089,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-arm64-musl": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.140.0.tgz",
"integrity": "sha512-ggGMQTN8Agwxp2WiLMpdY671dt0qTDJWiWlJeig3HnUwTnerRl0J2JdGVghWBeDcss2D9S2V2Js6dZHEiVabVA==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.142.0.tgz",
"integrity": "sha512-F2XvmWSE0uWpie+jHKKIFgdVOe9ypGhkEZxKx5DuW215K6cbAC274yYaPkcM7EqY4Df3Weyhpcz3lsURyH2LVg==",
"cpu": [
"arm64"
],
@@ -2105,9 +2106,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-ppc64-gnu": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.140.0.tgz",
"integrity": "sha512-IgTs8xYAFgAUGNmR65tIqjlJ8vKgrfXzC515e9goSdfMyKQV4aJpd2pUUudU4u51G64H0/DSEJEXKOraxm9ZCA==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.142.0.tgz",
"integrity": "sha512-wLMbT21U/QxknQsk+VvNF0b9D2/aGWhcaQQQ+VYlE8FwD5+GoWZIPPXNzyHmkYyhm0KB3itL+TBavjMatqNnYA==",
"cpu": [
"ppc64"
],
@@ -2122,9 +2123,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-riscv64-gnu": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.140.0.tgz",
"integrity": "sha512-A1x+PMWZmSGaFVOx2YeNTFau8uD+QO14/vLP4GrcuvUPs3+nBkUOjy9Lus86ftHsDojjYMbvBelmKc3F7Rv08g==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.142.0.tgz",
"integrity": "sha512-+G8F/4ckwT7FCJV4H2bt09xEzJbjNCfuL4Sp1AYNaFtFMVtgIGMuJlteT82U+K0UIZ/DzAR/LDlMFnEuajG7Kw==",
"cpu": [
"riscv64"
],
@@ -2139,9 +2140,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-riscv64-musl": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.140.0.tgz",
"integrity": "sha512-zBqpfRo2myWPrPo5xUjeZqlnPXPXsX8BcWtWff66/eGRQdbPjhzPgXa/F+AtxT2afUViPxbuDlwscMKzQ5tg+g==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.142.0.tgz",
"integrity": "sha512-hTsHtTLxMAfCo+rpF5K3qZJKW2NpPN/CHd4mYB3y7XlSdspHkd2gehDIofP64AacA9nWQw2tY3O7wR6UY8IVOA==",
"cpu": [
"riscv64"
],
@@ -2156,9 +2157,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-s390x-gnu": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.140.0.tgz",
"integrity": "sha512-2M1DPm/8w9I//YzFlFC9qXw+r2tJFh5CYwRlYTq2vUJQS7qoQftEDeCZ8EnN7KHtvSiXvYj8mZI5pR7DpXmcEw==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.142.0.tgz",
"integrity": "sha512-6y7qYY3TCUDYjqswImdTGl92y+KA/80twALegQPN27kfY+bG7Ib1+L3jbmrCZQx6wrVnai9IPsEZp07I0hx7JQ==",
"cpu": [
"s390x"
],
@@ -2173,9 +2174,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-x64-gnu": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.140.0.tgz",
"integrity": "sha512-8aRDbZ/U/jO8N7go1MO72jtbpb4uswV8d7vOkMvt/BPgZiyEYvl1VIWK4ESxZZhnJ4tqwVldgX7dNiP/eB1Jdg==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.142.0.tgz",
"integrity": "sha512-i69kAWU+2LgoH5bR+zWiiu+UzAw7Oxkwv7COeJTeY19pn4e70nKQcr9Pm6cL2Z0Z54d+gl9qADlK/0yyuCPiBA==",
"cpu": [
"x64"
],
@@ -2190,9 +2191,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-x64-musl": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.140.0.tgz",
"integrity": "sha512-xRqpeI8U2sQQS1W5BMWRyMTxtagkuLG2dEWruet5lFsWHTvBth11/TpSaJatHdqVVwHN0q3uuoS9zRsGinq8hg==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.142.0.tgz",
"integrity": "sha512-4SQs678MmjYVrmhAgCWD4o0vpaFszXw9xLX5p2Z9MMFcltxiLkA88wQjh80YHjPrXtpyZ2CWI5m+1yNKM0m2Pw==",
"cpu": [
"x64"
],
@@ -2207,9 +2208,9 @@
}
},
"node_modules/@oxc-parser/binding-openharmony-arm64": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.140.0.tgz",
"integrity": "sha512-GbGRe26MqAKciFRvXeHNQJ6VAHYs9R4miP89sEAncysM3n+f4lnyLWgsa9kklJNpfnxdq2yRoNYHFqwBckVimw==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.142.0.tgz",
"integrity": "sha512-YHpx9N7Ln3a++Tc8rv+H7mrK1zyJQOAwCFg8LZ3lTs1T5afGWeZrLPhPT9HLnIwSjCyJqPWVMIrMxbjcmBr2oQ==",
"cpu": [
"arm64"
],
@@ -2224,9 +2225,9 @@
}
},
"node_modules/@oxc-parser/binding-wasm32-wasi": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.140.0.tgz",
"integrity": "sha512-vFiC1hqys+hkX1GnQkIoiTQJNiUm43Z0lO35ETKXTw0YtpW7+cN58YRRXFAQQ+TgpkIi3lrhcxdlnqz+Oi3ptQ==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.142.0.tgz",
"integrity": "sha512-3pLDyY3+oogW73RM5uehNgAiR/Xfb7fvO2Q1Z1gIqZ2+50XDVQmBVlRkHXZTU4gKnQHpwETNsYQVsJ3joVB2iA==",
"cpu": [
"wasm32"
],
@@ -2243,9 +2244,9 @@
}
},
"node_modules/@oxc-parser/binding-win32-arm64-msvc": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.140.0.tgz",
"integrity": "sha512-fGSQldwEYKhM+H8uLt76Op8hh5+FYaR6lvvQ1Txw3Mhn86DyQXLcI0fi1EkFlTK7F+46OCk/j0AJMzZQm6g5Xg==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.142.0.tgz",
"integrity": "sha512-Had/VeVY28Oyb0K+Q4FV8KCzoBycIh93oDK6pCbya9lkzdq+ikMHMgBubsdqqlybjJmQRawCQRrnBRHyQwYvcQ==",
"cpu": [
"arm64"
],
@@ -2260,9 +2261,9 @@
}
},
"node_modules/@oxc-parser/binding-win32-ia32-msvc": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.140.0.tgz",
"integrity": "sha512-sDS2Bai+g3ZWYwfZqmosiSuFDBcVnZ3Ta6pszzsiJoLMqsJEWKcxXXbGa7b7yXr++W2lQNPb3ZRJ8czseqL7RA==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.142.0.tgz",
"integrity": "sha512-GGi3+YphVHavvgs6gum2UXoNCqzHAmPt/nXkn8ZQZstV2Q1qZD1Mn8fz/nWrDkefHQtrG/+1/XrbMxsBTo6Svw==",
"cpu": [
"ia32"
],
@@ -2277,9 +2278,9 @@
}
},
"node_modules/@oxc-parser/binding-win32-x64-msvc": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.140.0.tgz",
"integrity": "sha512-kHbE1zWyb5OQgJA6/5P4WjiuB01sYdQwtZnSSyE58FQEXDAMnyeeq4vj7KgN75i5SlBzOs8A5MrtlD3gOlDKqQ==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.142.0.tgz",
"integrity": "sha512-Ny/Wv4Us1LGC/ljwNTp+Hx3r/pH15EFfeDF0p+n898gt+TtRd6C9SccHcuUhDiNTb8s5tt7jdeAMDRQZ4Vq6hg==",
"cpu": [
"x64"
],
@@ -2294,9 +2295,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.140.0.tgz",
"integrity": "sha512-h5LUOzGArYemnW1NMz/DuuQhBi96J6JL2Bk8zE4kvqxB5Sg3jxmCiH4uyOWHDkiKSt5vWlG4FIwCR/DbstcNRQ==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz",
"integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==",
"dev": true,
"license": "MIT",
"funding": {
@@ -2898,13 +2899,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.0"
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
@@ -3858,6 +3859,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz",
@@ -5091,9 +5102,9 @@
}
},
"node_modules/fast-uri": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"dev": true,
"funding": [
{
@@ -5755,9 +5766,9 @@
}
},
"node_modules/knip": {
"version": "6.29.0",
"resolved": "https://registry.npmjs.org/knip/-/knip-6.29.0.tgz",
"integrity": "sha512-A3kXqSBky1tWBAqiU9srdtu0Larhzkuyor0aD/gg+ToiqyBncCCs2Q60sLsxmcKhV0OsKss9LV0hMPpwLv711Q==",
"version": "6.31.0",
"resolved": "https://registry.npmjs.org/knip/-/knip-6.31.0.tgz",
"integrity": "sha512-NbeIEmUS2VUMjAkbiSNOKPJeV9wpCsr0660sUyKyMQbk4Iom0++nTLInVp4MJ+LfR4kORnw67bDi5tvO7YLnzA==",
"dev": true,
"funding": [
{
@@ -5775,13 +5786,13 @@
"formatly": "^0.3.0",
"get-tsconfig": "4.14.0",
"jiti": "^2.7.0",
"oxc-parser": "^0.140.0",
"oxc-parser": "^0.142.0",
"oxc-resolver": "11.24.2",
"picomatch": "^4.0.5",
"smol-toml": "^1.7.0",
"smol-toml": "^1.7.1",
"strip-json-comments": "5.0.3",
"tinyglobby": "^0.2.17",
"unbash": "^4.0.3",
"unbash": "^4.0.4",
"yaml": "^2.9.0",
"zod": "^4.4.3"
},
@@ -6153,13 +6164,13 @@
}
},
"node_modules/oxc-parser": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.140.0.tgz",
"integrity": "sha512-h6QFWd6lBMfjESqgQ27GjzrSDb0qbznp7VDQqp2zvgsrWut4vcchyMIzOVXvGQ2GMZgKw9RWrFNWv9WqGL0p7Q==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.142.0.tgz",
"integrity": "sha512-kKR+jPiRJYJDexVoziIg/FVGvr1fT1FZSSJOk6tVoMKKSlsf1Cso+cgGCJkOEDWOP174vRntCPFKg+AS7InWvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "^0.140.0"
"@oxc-project/types": "^0.142.0"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
@@ -6168,26 +6179,26 @@
"url": "https://github.com/sponsors/Boshen"
},
"optionalDependencies": {
"@oxc-parser/binding-android-arm-eabi": "0.140.0",
"@oxc-parser/binding-android-arm64": "0.140.0",
"@oxc-parser/binding-darwin-arm64": "0.140.0",
"@oxc-parser/binding-darwin-x64": "0.140.0",
"@oxc-parser/binding-freebsd-x64": "0.140.0",
"@oxc-parser/binding-linux-arm-gnueabihf": "0.140.0",
"@oxc-parser/binding-linux-arm-musleabihf": "0.140.0",
"@oxc-parser/binding-linux-arm64-gnu": "0.140.0",
"@oxc-parser/binding-linux-arm64-musl": "0.140.0",
"@oxc-parser/binding-linux-ppc64-gnu": "0.140.0",
"@oxc-parser/binding-linux-riscv64-gnu": "0.140.0",
"@oxc-parser/binding-linux-riscv64-musl": "0.140.0",
"@oxc-parser/binding-linux-s390x-gnu": "0.140.0",
"@oxc-parser/binding-linux-x64-gnu": "0.140.0",
"@oxc-parser/binding-linux-x64-musl": "0.140.0",
"@oxc-parser/binding-openharmony-arm64": "0.140.0",
"@oxc-parser/binding-wasm32-wasi": "0.140.0",
"@oxc-parser/binding-win32-arm64-msvc": "0.140.0",
"@oxc-parser/binding-win32-ia32-msvc": "0.140.0",
"@oxc-parser/binding-win32-x64-msvc": "0.140.0"
"@oxc-parser/binding-android-arm-eabi": "0.142.0",
"@oxc-parser/binding-android-arm64": "0.142.0",
"@oxc-parser/binding-darwin-arm64": "0.142.0",
"@oxc-parser/binding-darwin-x64": "0.142.0",
"@oxc-parser/binding-freebsd-x64": "0.142.0",
"@oxc-parser/binding-linux-arm-gnueabihf": "0.142.0",
"@oxc-parser/binding-linux-arm-musleabihf": "0.142.0",
"@oxc-parser/binding-linux-arm64-gnu": "0.142.0",
"@oxc-parser/binding-linux-arm64-musl": "0.142.0",
"@oxc-parser/binding-linux-ppc64-gnu": "0.142.0",
"@oxc-parser/binding-linux-riscv64-gnu": "0.142.0",
"@oxc-parser/binding-linux-riscv64-musl": "0.142.0",
"@oxc-parser/binding-linux-s390x-gnu": "0.142.0",
"@oxc-parser/binding-linux-x64-gnu": "0.142.0",
"@oxc-parser/binding-linux-x64-musl": "0.142.0",
"@oxc-parser/binding-openharmony-arm64": "0.142.0",
"@oxc-parser/binding-wasm32-wasi": "0.142.0",
"@oxc-parser/binding-win32-arm64-msvc": "0.142.0",
"@oxc-parser/binding-win32-ia32-msvc": "0.142.0",
"@oxc-parser/binding-win32-x64-msvc": "0.142.0"
}
},
"node_modules/oxc-resolver": {
@@ -6403,13 +6414,13 @@
}
},
"node_modules/playwright": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.0"
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
@@ -6422,9 +6433,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -6450,9 +6461,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.19",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
"integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
"version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"dev": true,
"funding": [
{
@@ -6470,7 +6481,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -6877,9 +6888,9 @@
}
},
"node_modules/smol-toml": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz",
"integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==",
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.1.tgz",
"integrity": "sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
@@ -7235,9 +7246,9 @@
}
},
"node_modules/unbash": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/unbash/-/unbash-4.0.3.tgz",
"integrity": "sha512-3cudTErfToSc4Ggv8XGXVNVli/xHKUtUZvaY5UVwhOcUPbQGz7PeaEnT/SAVgNziZtX67KEN9swMUYkLghxA1w==",
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/unbash/-/unbash-4.0.5.tgz",
"integrity": "sha512-EE9xv9cr93DSppe086Rnbq0jwG7MBCEe22JZ4UQ8Bn9RyUwDSoUpBmzdb5+7PzocdqBEb/K73FGPaQUMmLxTQQ==",
"dev": true,
"license": "ISC",
"engines": {
@@ -7252,15 +7263,22 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unicorn-magic": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
+5 -2
View File
@@ -1,7 +1,7 @@
{
"name": "owncord-client",
"private": true,
"version": "1.2.0-alpha.1",
"version": "1.2.0-alpha.2",
"type": "module",
"scripts": {
"dev": "vite",
@@ -14,12 +14,14 @@
"test:e2e": "playwright test",
"test:e2e:prod": "npm run build && playwright test --config playwright.config.prod.ts",
"test:e2e:native": "playwright test --config playwright.config.native.ts",
"test:e2e:admin": "playwright test --config playwright.config.admin.ts",
"test:e2e:ui": "playwright test --ui",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:browser": "vitest run --config vitest.config.browser.ts",
"typecheck": "tsc --noEmit",
"typecheck:build": "tsc -p tsconfig.build.json --noEmit",
"typecheck:e2e": "tsc -p tsconfig.e2e.json --noEmit",
"lint": "oxlint src/ && eslint src/",
"lint:fix": "eslint src/ --fix",
"lint:ox": "oxlint src/",
@@ -37,12 +39,13 @@
"@stryker-mutator/typescript-checker": "^9.6.1",
"@stryker-mutator/vitest-runner": "^9.6.1",
"@tauri-apps/cli": "^2",
"@types/node": "^20.19.43",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-v8": "^3",
"eslint": "^10.8.0",
"fast-check": "^4.9.0",
"jsdom": "^29.1.1",
"knip": "^6.1.1",
"knip": "^6.31.0",
"oxlint": "^1.76.0",
"prettier": "^3.9.6",
"typescript": "^5.7",
@@ -0,0 +1,45 @@
import { defineConfig } from "@playwright/test";
/**
* Playwright config for the ADMIN PANEL e2e suite — the server-embedded SPA
* (Server/admin/static/index.html), driven against a REAL server started by
* tests/e2e/admin/start-server.sh (fresh temp data dir, TLS off, loopback).
*
* Unlike the mocked-Tauri web suite this exercises the true stack: chi
* router, admin middleware/gates, SQLite, and the SPA itself. The journey is
* stateful by design (first-run wizard creates the owner the later tests log
* in as), so it runs serially in one worker against one server instance.
*
* Usage: npm run test:e2e:admin (requires the Go toolchain)
*/
const PORT = process.env.OWNCORD_ADMIN_E2E_PORT ?? "18446";
export default defineConfig({
testDir: "./tests/e2e/admin",
timeout: 30_000,
expect: { timeout: 5_000 },
fullyParallel: false,
workers: 1,
retries: process.env.CI ? 2 : 1,
reporter: process.env.CI
? [
["html", { open: "never" }],
["junit", { outputFile: "test-results/admin-junit.xml" }],
]
: "html",
use: {
baseURL: `http://127.0.0.1:${PORT}`,
screenshot: "only-on-failure",
trace: "on-first-retry",
contextOptions: { reducedMotion: "reduce" },
},
webServer: {
command: "bash tests/e2e/admin/start-server.sh",
url: `http://127.0.0.1:${PORT}/health`,
reuseExistingServer: !process.env.CI,
// First run compiles the Go server; CI cold caches need the headroom.
timeout: 240_000,
},
});
@@ -60,7 +60,10 @@ export default defineConfig({
"app-layout.spec.ts",
"channel-navigation.spec.ts",
"chat-operations.spec.ts",
"dm-system.spec.ts",
"reconnection.spec.ts",
"settings-overlay.spec.ts",
"theme-persistence.spec.ts",
"voice-controls.spec.ts",
"overlays.spec.ts",
],
@@ -9,7 +9,7 @@ import { defineConfig, devices } from "@playwright/test";
*/
export default defineConfig({
testDir: "./tests/e2e",
testIgnore: ["**/native/**"],
testIgnore: ["**/native/**", "**/admin/**"],
timeout: 30_000,
expect: {
timeout: 5_000,
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
testIgnore: ["**/native/**"],
testIgnore: ["**/native/**", "**/admin/**"],
timeout: 30_000,
expect: {
timeout: 5_000,
@@ -1,291 +0,0 @@
// =============================================================================
// RNNoise AudioWorklet Processor
//
// Runs on the audio rendering thread. Receives WASM module bytes from the
// main thread, initializes RNNoise, and processes 480-sample frames at 48kHz.
// =============================================================================
const FRAME_SIZE = 480;
const WASM_MEMORY_INITIAL_PAGES = 256;
const OUTPUT_RING_CAPACITY = 50;
const RN_NOISE_INT16_SCALE = 32768;
declare abstract class AudioWorkletProcessor {
readonly port: MessagePort;
}
declare function registerProcessor(
name: string,
processorCtor: typeof RNNoiseProcessor,
): void;
interface RNNoiseWasmExports extends WebAssembly.Exports {
rnnoise_create(): number;
rnnoise_destroy(state: number): void;
rnnoise_process_frame(state: number, outputPtr: number, inputPtr: number): void;
malloc(size: number): number;
free(ptr: number): void;
}
interface RNNoiseWasmInstance extends WebAssembly.Instance {
exports: RNNoiseWasmExports;
}
class RNNoiseProcessor extends AudioWorkletProcessor {
private _instance: RNNoiseWasmInstance | null = null;
private _state: number = 0;
private _inputPtr: number = 0;
private _outputPtr: number = 0;
private _heapF32: Float32Array | null = null;
private _ready: boolean = false;
private _destroyed: boolean = false;
// Ring buffer to accumulate 480-sample frames
private _inputRing: Float32Array;
private _inputRingOffset: number = 0;
// Output ring buffer (contiguous for efficiency)
private _outBuffer: Float32Array;
private _outWritePos: number = 0;
private _outReadPos: number = 0;
private _outAvailable: number = 0;
private _outSampleOffset: number = 0;
constructor() {
super();
this._inputRing = new Float32Array(FRAME_SIZE);
this._outBuffer = new Float32Array(OUTPUT_RING_CAPACITY * FRAME_SIZE);
this.port.onmessage = (event: MessageEvent) => {
if (event.data.type === "init") {
this._initWasm(event.data.wasmBytes);
} else if (event.data.type === "destroy") {
this._cleanup();
}
};
}
/**
* Reports an error to the main thread and logs it.
* @param message - Error message
* @param error - Optional error object
* @private
*/
private _reportError(message: string, error?: unknown): void {
console.error(`RNNoise Processor: ${message}`, error);
this.port.postMessage({ type: "error", message });
}
/**
* Initializes the WASM module and RNNoise state.
* @param wasmBytes - Raw WASM module bytes
* @private
*/
private async _initWasm(wasmBytes: ArrayBuffer): Promise<void> {
let allocated = false;
try {
// Basic validation: check for expected exports
const module = await WebAssembly.compile(wasmBytes);
const expectedExports = ['rnnoise_create', 'rnnoise_destroy', 'rnnoise_process_frame', 'malloc', 'free'];
const availableExports = WebAssembly.Module.exports(module).map(exp => exp.name);
const hasRequiredExports = expectedExports.every(exp => availableExports.includes(exp));
if (!hasRequiredExports) {
throw new Error('WASM module missing required RNNoise exports');
}
const memory = new WebAssembly.Memory({ initial: WASM_MEMORY_INITIAL_PAGES });
const importObject = {
env: {
memory,
emscripten_notify_memory_growth: () => {
this._heapF32 = new Float32Array(memory.buffer);
},
},
wasi_snapshot_preview1: {
proc_exit: () => {},
fd_close: () => 0,
fd_write: () => 0,
fd_seek: () => 0,
},
};
// Try instantiating with the raw WASM bytes
const { instance } = await WebAssembly.instantiate(wasmBytes, importObject);
this._instance = instance as RNNoiseWasmInstance;
this._heapF32 = new Float32Array(memory.buffer);
// Call RNNoise C API
const exports = instance.exports as unknown as RNNoiseWasmExports;
this._state = exports.rnnoise_create();
this._inputPtr = exports.malloc(FRAME_SIZE * 4);
this._outputPtr = exports.malloc(FRAME_SIZE * 4);
allocated = true;
this._ready = true;
this.port.postMessage({ type: "ready" });
} catch (err) {
// Cleanup allocated memory on failure
if (allocated && this._instance) {
try {
const exports = this._instance.exports;
if (this._inputPtr) exports.free(this._inputPtr);
if (this._outputPtr) exports.free(this._outputPtr);
if (this._state) exports.rnnoise_destroy(this._state);
} catch (cleanupErr) {
// Log cleanup errors but don't override original error
console.warn('Failed to cleanup WASM memory:', cleanupErr);
}
}
this._reportError(`WASM initialization failed: ${err instanceof Error ? err.message : String(err)}`, err);
}
}
/**
* Processes a complete 480-sample frame through RNNoise.
* Copies input ring buffer to WASM memory, runs noise suppression,
* and stores the result in the output ring buffer.
* @private
*/
private _processFrame(): void {
if (!this._instance || !this._heapF32) return;
const exports = this._instance.exports;
const inOff = this._inputPtr / 4;
const outOff = this._outputPtr / 4;
// CRITICAL: Bounds check before accessing heap
if (inOff + FRAME_SIZE > this._heapF32.length ||
outOff + FRAME_SIZE > this._heapF32.length) {
console.error('WASM heap bounds exceeded');
return;
}
for (let i = 0; i < FRAME_SIZE; i++) {
this._heapF32[inOff + i] = (this._inputRing[i] ?? 0) * RN_NOISE_INT16_SCALE;
}
exports.rnnoise_process_frame(this._state, this._outputPtr, this._inputPtr);
// Write to contiguous buffer
const writeStart = this._outWritePos * FRAME_SIZE;
for (let i = 0; i < FRAME_SIZE; i++) {
this._outBuffer[writeStart + i] = (this._heapF32[outOff + i] ?? 0) / RN_NOISE_INT16_SCALE;
}
this._outWritePos = (this._outWritePos + 1) % OUTPUT_RING_CAPACITY;
if (this._outAvailable < OUTPUT_RING_CAPACITY) {
this._outAvailable++;
} else {
// Overwrite oldest
this._outReadPos = (this._outReadPos + 1) % OUTPUT_RING_CAPACITY;
this._outSampleOffset = 0;
}
}
/**
* Cleans up WASM resources and marks the processor as destroyed.
* Safe to call multiple times.
* @private
*/
private _cleanup(): void {
if (this._instance && this._state) {
try {
const exports = this._instance.exports;
exports.rnnoise_destroy(this._state);
exports.free(this._inputPtr);
exports.free(this._outputPtr);
} catch (err) {
console.warn('RNNoise cleanup failed:', err);
// Continue cleanup even if individual steps fail
}
}
this._ready = false;
this._destroyed = true;
this._state = 0;
}
/**
* Processes input audio data into the ring buffer and triggers frame processing.
* @param inData - Input audio samples
* @private
*/
private _processInputRingBuffer(inData: Float32Array): void {
let inIdx = 0;
while (inIdx < inData.length) {
const needed = FRAME_SIZE - this._inputRingOffset;
const toCopy = Math.min(needed, inData.length - inIdx);
this._inputRing.set(inData.subarray(inIdx, inIdx + toCopy), this._inputRingOffset);
this._inputRingOffset += toCopy;
inIdx += toCopy;
if (this._inputRingOffset >= FRAME_SIZE) {
this._processFrame();
this._inputRingOffset = 0;
}
}
}
/**
* Fills output buffer from the processed frames ring buffer.
* @param outData - Output audio buffer to fill
* @private
*/
private _fillOutputFromRingBuffer(outData: Float32Array): void {
let outIdx = 0;
while (outIdx < outData.length && this._outAvailable > 0) {
const readStart = this._outReadPos * FRAME_SIZE;
const available = FRAME_SIZE - this._outSampleOffset;
const toWrite = Math.min(available, outData.length - outIdx);
outData.set(this._outBuffer.subarray(readStart + this._outSampleOffset, readStart + this._outSampleOffset + toWrite), outIdx);
outIdx += toWrite;
this._outSampleOffset += toWrite;
if (this._outSampleOffset >= FRAME_SIZE) {
this._outReadPos = (this._outReadPos + 1) % OUTPUT_RING_CAPACITY;
this._outAvailable--;
this._outSampleOffset = 0;
}
}
// Fill remaining with silence
if (outIdx < outData.length) {
outData.fill(0, outIdx);
}
}
process(inputs: Float32Array[][], outputs: Float32Array[][]): boolean {
if (this._destroyed) return false;
// Validate input/output structure
if (!inputs || !inputs[0] || !inputs[0][0] ||
!outputs || !outputs[0] || !outputs[0][0]) {
return true; // Pass through silence or existing data
}
const input = inputs[0];
const output = outputs[0];
const inData = input[0]!;
const outData = output[0]!;
// Validate buffer lengths
if (inData.length === 0 || outData.length === 0) {
return true;
}
if (!this._ready) {
// Pass through until WASM is ready
const copyLength = Math.min(inData.length, outData.length);
outData.set(inData.subarray(0, copyLength));
if (copyLength < outData.length) {
outData.fill(0, copyLength);
}
return true;
}
this._processInputRingBuffer(inData);
this._fillOutputFromRingBuffer(outData);
return true;
}
}
registerProcessor("rnnoise-processor", RNNoiseProcessor);
@@ -8,4 +8,28 @@ ignore = [
# Drop both entries when the notification chain moves to quick-xml >= 0.41.
"RUSTSEC-2026-0194",
"RUSTSEC-2026-0195",
# glib 0.18.5 is pinned by the whole Linux GTK stack: wry (even 0.56)
# requires `webkit2gtk =2.0.2`, which requires `glib ^0.18.0`. The fix
# landed in glib 0.20.0 and was never backported (0.18.5 is the last 0.18
# release; 0.19.x is still in range), so no semver-compatible route exists.
# The unsoundness is only reachable through `Variant::array_iter_str()` —
# nothing in the dependency tree or in src-tauri/src/ calls it, and the
# crate is Linux-only here (see the cfg(target_os = "linux") block in
# Cargo.toml). Drop this entry when webkit2gtk moves to gtk-rs 0.20.
"RUSTSEC-2024-0429",
# rand 0.7.3 arrives only as a BUILD dependency, three levels down:
# tauri-utils -> kuchikiki 0.8.8-speedreader -> selectors 0.24.0, whose
# build.rs uses phf_codegen -> phf_generator 0.8.0 (which requires
# rand ^0.7). It runs at codegen time and never links into a shipped
# binary. The advisory needs `ThreadRng` reseeding under a custom logger
# with rand's `log` feature on; phf_generator instead uses a fixed-seed
# `SmallRng::seed_from_u64(1234567890)` and never enables `log` — and no
# other crate here depends on rand 0.7, so feature unification cannot
# turn it on. Not upgradable: kuchikiki 0.8.9-speedreader would drop this
# chain, but cargo will not match a pre-release across patch versions
# (`^0.8.8-speedreader` rejects 0.8.9-speedreader) and tauri-utils 2.9.3
# is the latest release. Drop this entry when tauri-utils bumps kuchikiki.
"RUSTSEC-2026-0097",
]
+6 -401
View File
@@ -69,56 +69,6 @@ dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.102"
@@ -423,16 +373,6 @@ dependencies = [
"tinyvec",
]
[[package]]
name = "bstr"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "bumpalo"
version = "3.20.2"
@@ -603,35 +543,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link 0.2.1",
]
[[package]]
name = "chrono-tz"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
dependencies = [
"chrono",
"chrono-tz-build",
"phf 0.11.3",
]
[[package]]
name = "chrono-tz-build"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
dependencies = [
"parse-zoneinfo",
"phf 0.11.3",
"phf_codegen 0.11.3",
]
[[package]]
name = "cipher"
version = "0.4.4"
@@ -642,52 +558,6 @@ dependencies = [
"inout",
]
[[package]]
name = "clap"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "combine"
version = "4.6.7"
@@ -707,18 +577,6 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "console"
version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
dependencies = [
"encode_unicode",
"libc",
"unicode-width",
"windows-sys 0.61.2",
]
[[package]]
name = "const-random"
version = "0.1.18"
@@ -860,25 +718,6 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -1087,12 +926,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "deunicode"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
[[package]]
name = "device_query"
version = "2.1.0"
@@ -1310,12 +1143,6 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -1874,30 +1701,6 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "globset"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
dependencies = [
"aho-corasick",
"bstr",
"log",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "globwalk"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757"
dependencies = [
"bitflags 2.11.0",
"ignore",
"walkdir",
]
[[package]]
name = "gobject-sys"
version = "0.18.0"
@@ -2110,15 +1913,6 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "humansize"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
dependencies = [
"libm",
]
[[package]]
name = "hyper"
version = "1.8.1"
@@ -2195,7 +1989,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.58.0",
"windows-core 0.61.2",
]
[[package]]
@@ -2331,22 +2125,6 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "ignore"
version = "0.4.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a"
dependencies = [
"crossbeam-deque",
"globset",
"log",
"memchr",
"regex-automata",
"same-file",
"walkdir",
"winapi-util",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -2370,19 +2148,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "indicatif"
version = "0.18.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c"
dependencies = [
"console",
"portable-atomic",
"unicode-width",
"unit-prefix",
"web-time",
]
[[package]]
name = "infer"
version = "0.19.0"
@@ -2437,12 +2202,6 @@ dependencies = [
"once_cell",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
@@ -2626,12 +2385,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
version = "0.1.14"
@@ -3208,12 +2961,6 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "open"
version = "5.3.3"
@@ -3274,7 +3021,7 @@ dependencies = [
[[package]]
name = "owncord-client"
version = "1.2.0-alpha.1"
version = "1.2.0-alpha.2"
dependencies = [
"base64 0.22.1",
"device_query",
@@ -3301,7 +3048,6 @@ dependencies = [
"tauri-plugin-store",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"tauri-typegen",
"tokio",
"tokio-rustls",
"tokio-tungstenite",
@@ -3367,15 +3113,6 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "parse-zoneinfo"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24"
dependencies = [
"regex",
]
[[package]]
name = "pathdiff"
version = "0.2.3"
@@ -3388,49 +3125,6 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pest"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662"
dependencies = [
"memchr",
"ucd-trie",
]
[[package]]
name = "pest_derive"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77"
dependencies = [
"pest",
"pest_generator",
]
[[package]]
name = "pest_generator"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f"
dependencies = [
"pest",
"pest_meta",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "pest_meta"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220"
dependencies = [
"pest",
"sha2",
]
[[package]]
name = "phf"
version = "0.8.0"
@@ -3692,12 +3386,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -4311,9 +3999,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.42"
version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"once_cell",
"ring",
@@ -4582,12 +4270,6 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-rename-rule"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8a059d895f1a31dd928f40abbea4e7177e3d8ff3aa4152fdb7a396ae1ef63a3"
[[package]]
name = "serde-untagged"
version = "0.1.9"
@@ -4820,16 +4502,6 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "slug"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724"
dependencies = [
"deunicode",
"wasm-bindgen",
]
[[package]]
name = "smallvec"
version = "1.15.1"
@@ -5568,27 +5240,6 @@ dependencies = [
"wry",
]
[[package]]
name = "tauri-typegen"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e761d97bf6c90f13894383493485008d0c51f67ff7b12c0f95dc34b8a9e6e73"
dependencies = [
"chrono",
"clap",
"indicatif",
"proc-macro2",
"quote",
"regex",
"serde",
"serde-rename-rule",
"serde_json",
"syn 2.0.117",
"tera",
"thiserror 2.0.18",
"walkdir",
]
[[package]]
name = "tauri-utils"
version = "2.9.3"
@@ -5685,28 +5336,6 @@ dependencies = [
"utf-8",
]
[[package]]
name = "tera"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8004bca281f2d32df3bacd59bc67b312cb4c70cea46cbd79dbe8ac5ed206722"
dependencies = [
"chrono",
"chrono-tz",
"globwalk",
"humansize",
"lazy_static",
"percent-encoding",
"pest",
"pest_derive",
"rand 0.8.7",
"regex",
"serde",
"serde_json",
"slug",
"unicode-segmentation",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -6134,12 +5763,6 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "ucd-trie"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "uds_windows"
version = "1.2.1"
@@ -6204,24 +5827,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unit-prefix"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -6265,12 +5876,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.22.0"
@@ -7561,9 +7166,9 @@ dependencies = [
[[package]]
name = "zeroize"
version = "1.8.2"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"zeroize_derive",
]
+1 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "owncord-client"
version = "1.2.0-alpha.1"
version = "1.2.0-alpha.2"
edition = "2021"
# Effective minimum: tauri 2.11 declares rust-version = "1.77.2", so the crate
# cannot build below it. Declaring it here enables Cargo's MSRV-aware resolver
@@ -21,7 +21,6 @@ crate-type = ["lib", "cdylib", "staticlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
tauri-typegen = "0.5"
[features]
default = []
@@ -21,6 +21,7 @@
"core:window:allow-outer-size",
"core:window:allow-available-monitors",
"core:window:allow-center",
"core:window:allow-request-user-attention",
"notification:default",
"notification:allow-notify",
"notification:allow-request-permission",
+175 -78
View File
@@ -1,4 +1,5 @@
use serde::Serialize;
use std::sync::Mutex;
use tauri::AppHandle;
use crate::secret_store::{self, Backend};
@@ -8,9 +9,9 @@ use crate::secret_store::{self, Backend};
pub struct CredentialData {
pub username: String,
pub token: String,
// Password is stored in the credential blob for re-authentication but
// is never serialized back to the frontend over IPC to limit exposure.
#[serde(skip)]
// Password is stored in the credential blob for re-authentication and is
// serialized back to the frontend over IPC so the login form can prefill
// it when the user ticked "Remember password".
pub password: Option<String>,
}
@@ -53,6 +54,42 @@ fn require_non_empty(value: &str, field: &str) -> Result<(), String> {
Ok(())
}
// ---------------------------------------------------------------------------
// Cross-command serialization
// ---------------------------------------------------------------------------
//
// B4-3 moved every command below to `#[tauri::command(async)]` so the
// blocking keyring/DPAPI I/O runs off Tauri's IPC main thread instead of
// freezing the UI on it. Before that, Tauri ran all (sync) commands one at a
// time on that thread, so two overlapping invocations were always fully
// serialized in arrival order. `async` dispatches each invocation onto the
// async runtime's thread pool instead, so two overlapping calls can now
// genuinely run concurrently and interleave their OS credential-store
// operations.
//
// That is reachable, not hypothetical: `identity.ts`'s legacy-key migration
// does a save-then-delete pair for two different accounts, and logging out
// fires a fire-and-forget `delete_credential` for a host whose connect-page
// auto-login can immediately issue `load_credential` for the very same host.
// Nothing upstream awaits the delete before the read can start.
//
// This mutex restores the "only one credential-store operation in flight at
// a time" property that made ordering safe pre-`async`, without giving back
// the perf win: it guards the whole command body (not just the raw OS call),
// so the fallback file's read-modify-write in `secret_store::set_with` is
// still atomic with respect to a concurrent read or delete for the same or a
// different account.
static CREDENTIAL_LOCK: Mutex<()> = Mutex::new(());
/// Run `f` with every other credential-store command excluded. Poisoning is
/// recovered from (the guarded value is `()`, so there is nothing to
/// distrust) rather than propagated, so a panic inside one command cannot
/// permanently wedge every credential operation for the rest of the process.
fn with_credential_lock<T>(f: impl FnOnce() -> T) -> T {
let _guard = CREDENTIAL_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
f()
}
// ---------------------------------------------------------------------------
// Tauri commands
// ---------------------------------------------------------------------------
@@ -68,7 +105,7 @@ fn require_non_empty(value: &str, field: &str) -> Result<(), String> {
/// On macOS it is stored in the system Keychain. The write is read back before
/// this returns — see [`crate::secret_store`] for what happens when it does not
/// come back.
#[tauri::command]
#[tauri::command(async)]
pub fn save_credential(
app: AppHandle,
host: String,
@@ -76,37 +113,41 @@ pub fn save_credential(
token: String,
password: Option<String>,
) -> Result<(), String> {
require_non_empty(&host, "host")?;
require_non_empty(&token, "token")?;
require_non_empty(&username, "username")?;
with_credential_lock(|| {
require_non_empty(&host, "host")?;
require_non_empty(&token, "token")?;
require_non_empty(&username, "username")?;
let mut payload = serde_json::json!({
"username": username,
"token": token,
});
if let Some(ref pw) = password {
payload["password"] = serde_json::Value::String(pw.clone());
}
let mut payload = serde_json::json!({
"username": username,
"token": token,
});
if let Some(ref pw) = password {
payload["password"] = serde_json::Value::String(pw.clone());
}
secret_store::set(&app, &login_account(&host), &payload.to_string())
.map_err(|e| format!("save_credential failed: {e}"))?;
Ok(())
secret_store::set(&app, &login_account(&host), &payload.to_string())
.map_err(|e| format!("save_credential failed: {e}"))?;
Ok(())
})
}
/// Load a credential from the system credential store.
///
/// Returns `None` when no credential exists for the given host.
#[tauri::command]
#[tauri::command(async)]
pub fn load_credential(app: AppHandle, host: String) -> Result<Option<CredentialData>, String> {
require_non_empty(&host, "host")?;
with_credential_lock(|| {
require_non_empty(&host, "host")?;
let Some(json_str) = secret_store::get(&app, &login_account(&host))
.map_err(|e| format!("load_credential failed: {e}"))?
else {
return Ok(None);
};
let Some(json_str) = secret_store::get(&app, &login_account(&host))
.map_err(|e| format!("load_credential failed: {e}"))?
else {
return Ok(None);
};
parse_credential_blob(&json_str).map(Some)
parse_credential_blob(&json_str).map(Some)
})
}
/// Parse the stored credential JSON blob.
@@ -142,11 +183,13 @@ fn parse_credential_blob(json_str: &str) -> Result<CredentialData, String> {
/// Delete a credential from the system credential store.
///
/// Deleting a non-existent credential is not treated as an error.
#[tauri::command]
#[tauri::command(async)]
pub fn delete_credential(app: AppHandle, host: String) -> Result<(), String> {
require_non_empty(&host, "host")?;
secret_store::delete(&app, &login_account(&host))
.map_err(|e| format!("delete_credential failed: {e}"))
with_credential_lock(|| {
require_non_empty(&host, "host")?;
secret_store::delete(&app, &login_account(&host))
.map_err(|e| format!("delete_credential failed: {e}"))
})
}
// ---------------------------------------------------------------------------
@@ -165,34 +208,40 @@ pub fn delete_credential(app: AppHandle, host: String) -> Result<(), String> {
/// file (DPAPI on Windows, sealed per-install key elsewhere); if that is also
/// unavailable this returns an error rather than reporting a success that would
/// leave peers rejecting the user's voice announce after a restart.
#[tauri::command]
#[tauri::command(async)]
pub fn save_identity_key(app: AppHandle, host: String, key: String) -> Result<(), String> {
require_non_empty(&host, "host")?;
require_non_empty(&key, "key")?;
with_credential_lock(|| {
require_non_empty(&host, "host")?;
require_non_empty(&key, "key")?;
secret_store::set(&app, &identity_account(&host), &key)
.map_err(|e| format!("save_identity_key failed: {e}"))?;
Ok(())
secret_store::set(&app, &identity_account(&host), &key)
.map_err(|e| format!("save_identity_key failed: {e}"))?;
Ok(())
})
}
/// Load the identity private key for `host`.
///
/// Returns `None` when no identity key exists for the given host.
#[tauri::command]
#[tauri::command(async)]
pub fn load_identity_key(app: AppHandle, host: String) -> Result<Option<String>, String> {
require_non_empty(&host, "host")?;
secret_store::get(&app, &identity_account(&host))
.map_err(|e| format!("load_identity_key failed: {e}"))
with_credential_lock(|| {
require_non_empty(&host, "host")?;
secret_store::get(&app, &identity_account(&host))
.map_err(|e| format!("load_identity_key failed: {e}"))
})
}
/// Delete the identity private key for `host`.
///
/// Deleting a non-existent key is not treated as an error.
#[tauri::command]
#[tauri::command(async)]
pub fn delete_identity_key(app: AppHandle, host: String) -> Result<(), String> {
require_non_empty(&host, "host")?;
secret_store::delete(&app, &identity_account(&host))
.map_err(|e| format!("delete_identity_key failed: {e}"))
with_credential_lock(|| {
require_non_empty(&host, "host")?;
secret_store::delete(&app, &identity_account(&host))
.map_err(|e| format!("delete_identity_key failed: {e}"))
})
}
// ---------------------------------------------------------------------------
@@ -217,44 +266,46 @@ pub struct CredentialStoreProbe {
/// announce: it distinguishes "the credential store is fine" from "writes are
/// accepted and dropped" without touching any real credential. The probe
/// account is removed again whatever the outcome.
#[tauri::command]
#[tauri::command(async)]
pub fn probe_credential_store(app: AppHandle) -> CredentialStoreProbe {
// Underscores are not legal in DNS hostnames, so this cannot collide with a
// real `{host}` or `identity:{host}` account.
const PROBE_ACCOUNT: &str = "__diagnostic_probe__";
const PROBE_SECRET: &str = "owncord-credential-store-probe";
with_credential_lock(|| {
// Underscores are not legal in DNS hostnames, so this cannot collide
// with a real `{host}` or `identity:{host}` account.
const PROBE_ACCOUNT: &str = "__diagnostic_probe__";
const PROBE_SECRET: &str = "owncord-credential-store-probe";
let result = secret_store::set(&app, PROBE_ACCOUNT, PROBE_SECRET).and_then(|backend| {
match secret_store::get(&app, PROBE_ACCOUNT)? {
Some(ref got) if got == PROBE_SECRET => Ok(backend),
Some(_) => Err("read back a different value than was written".into()),
None => Err("the store reported a successful write but returned no entry".into()),
let result = secret_store::set(&app, PROBE_ACCOUNT, PROBE_SECRET).and_then(|backend| {
match secret_store::get(&app, PROBE_ACCOUNT)? {
Some(ref got) if got == PROBE_SECRET => Ok(backend),
Some(_) => Err("read back a different value than was written".into()),
None => Err("the store reported a successful write but returned no entry".into()),
}
});
// Always clean up, including when the probe failed part-way through.
if let Err(e) = secret_store::delete(&app, PROBE_ACCOUNT) {
log::warn!("failed to remove credential store probe entry: {e}");
}
});
// Always clean up, including when the probe failed part-way through.
if let Err(e) = secret_store::delete(&app, PROBE_ACCOUNT) {
log::warn!("failed to remove credential store probe entry: {e}");
}
match result {
Ok(backend) => {
log::info!("credential store probe succeeded (backend: {backend:?})");
CredentialStoreProbe {
ok: true,
backend: Some(backend),
error: None,
match result {
Ok(backend) => {
log::info!("credential store probe succeeded (backend: {backend:?})");
CredentialStoreProbe {
ok: true,
backend: Some(backend),
error: None,
}
}
Err(e) => {
log::error!("credential store probe failed: {e}");
CredentialStoreProbe {
ok: false,
backend: None,
error: Some(e),
}
}
}
Err(e) => {
log::error!("credential store probe failed: {e}");
CredentialStoreProbe {
ok: false,
backend: None,
error: Some(e),
}
}
}
})
}
// ---------------------------------------------------------------------------
@@ -347,14 +398,60 @@ mod tests {
}
#[test]
fn credential_data_skips_password_in_json() {
fn credential_data_serializes_password_for_prefill() {
let data = CredentialData {
username: "alice".into(),
token: "tok".into(),
password: Some("pw".into()),
};
let json = serde_json::to_string(&data).unwrap();
assert!(!json.contains("password"));
assert!(!json.contains("pw"));
assert!(json.contains("password"));
assert!(json.contains("pw"));
}
/// B4-3 follow-up: all 7 commands moved to `#[tauri::command(async)]`,
/// which runs each invocation on the async runtime's thread pool instead
/// of Tauri's single IPC main thread. Two overlapping invocations (e.g.
/// `identity.ts`'s save-then-delete legacy-key migration, or a logout's
/// `delete_credential` racing a connect-page auto-login's
/// `load_credential` for the same host) can now genuinely run
/// concurrently. `with_credential_lock` must serialize them: this proves
/// no two holders of the lock ever run their critical section at the
/// same time, regardless of which OS thread the runtime schedules them
/// on.
#[test]
fn credential_lock_serializes_overlapping_commands() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
let concurrent = Arc::new(AtomicUsize::new(0));
let max_concurrent = Arc::new(AtomicUsize::new(0));
let handles: Vec<_> = (0..8)
.map(|_| {
let concurrent = Arc::clone(&concurrent);
let max_concurrent = Arc::clone(&max_concurrent);
thread::spawn(move || {
with_credential_lock(|| {
let now = concurrent.fetch_add(1, Ordering::SeqCst) + 1;
max_concurrent.fetch_max(now, Ordering::SeqCst);
thread::sleep(Duration::from_millis(5));
concurrent.fetch_sub(1, Ordering::SeqCst);
});
})
})
.collect();
for h in handles {
h.join().unwrap();
}
assert_eq!(
max_concurrent.load(Ordering::SeqCst),
1,
"two credential-store commands ran their critical section concurrently"
);
}
}
@@ -67,12 +67,9 @@ pub fn load_or_create_key(dir: &Path) -> Result<[u8; KEY_LEN], String> {
options.mode(0o600);
}
match options.open(&path) {
Ok(mut file) => {
file.write_all(&key)
.and_then(|()| file.sync_all())
.map_err(|e| format!("failed to write credential fallback key: {e}"))?;
Ok(key)
}
Ok(mut file) => finish_new_key_file(&path, key, || {
file.write_all(&key).and_then(|()| file.sync_all())
}),
// Lost the create race to another thread — use the winner's key.
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
let bytes = fs::read(&path)
@@ -86,6 +83,28 @@ pub fn load_or_create_key(dir: &Path) -> Result<[u8; KEY_LEN], String> {
}
}
/// Finish writing a just-created (empty) key file: run `write_and_sync` — the
/// real write_all + sync_all in production, injected here so the failure
/// path is testable without forcing a genuine disk-full/IO error — and
/// delete the file again if it fails.
///
/// `create_new` above already created `path` with zero bytes in it. Left
/// behind, a write/sync failure leaves a short file that every future
/// `load_or_create_key` call reads back and rejects forever (see this
/// function's doc comment: "never rewritten once it exists") — silently
/// poisoning the fallback store on the first ENOSPC/IO hiccup.
fn finish_new_key_file(
path: &Path,
key: [u8; KEY_LEN],
write_and_sync: impl FnOnce() -> std::io::Result<()>,
) -> Result<[u8; KEY_LEN], String> {
if let Err(e) = write_and_sync() {
let _ = fs::remove_file(path);
return Err(format!("failed to write credential fallback key: {e}"));
}
Ok(key)
}
/// Seal `plaintext` under `key`, binding `aad` (the service + account name).
///
/// Output layout: `nonce (12 bytes) || ciphertext || tag`. The nonce is random
@@ -217,6 +236,34 @@ mod tests {
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn removes_the_partial_file_when_the_write_fails() {
// A crash / ENOSPC mid-write must not leave a short file behind:
// load_or_create_key's doc comment says the key file is "never
// rewritten once it exists", so a poisoned short file is permanent —
// every future load fails the length check forever.
let dir = std::env::temp_dir().join(format!(
"owncord-fallback-partial-write-test-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join(CREDENTIAL_FALLBACK_KEY_FILE);
// `create_new` in load_or_create_key already created this empty file
// before the write step (which is what's under test) runs.
fs::write(&path, b"").unwrap();
let err = finish_new_key_file(&path, [7u8; KEY_LEN], || {
Err(std::io::Error::other("disk full"))
})
.unwrap_err();
assert!(err.contains("failed to write"), "unexpected error: {err}");
assert!(!path.exists(), "a failed write must not leave a partial key file behind");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn rejects_a_corrupt_key_file() {
let dir = std::env::temp_dir().join(format!(
@@ -33,7 +33,7 @@ use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Arc;
use rustls::pki_types::ServerName;
use tauri::{AppHandle, Runtime};
use tauri::{AppHandle, Manager, Runtime};
use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Mutex;
@@ -57,6 +57,18 @@ impl HttpProxyState {
inner: Mutex::new(HashMap::new()),
}
}
/// Remove the `remote_host` entry, but only if it still points at `port`.
/// Used by `run_proxy_loop`'s accept-error exit path to deregister a dead
/// tunnel without racing a newer tunnel that may have already replaced it
/// (e.g. `stop_http_proxy` + a fresh `start_http_proxy` while this loop
/// was mid-shutdown).
async fn remove_if_port_matches(&self, remote_host: &str, port: u16) {
let mut inner = self.inner.lock().await;
if inner.get(remote_host).is_some_and(|entry| entry.port == port) {
inner.remove(remote_host);
}
}
}
/// Validate a remote host string before it is used in header rewriting and
@@ -111,6 +123,7 @@ pub async fn start_http_proxy<R: Runtime>(
app.clone(),
listener,
remote_host.clone(),
port,
shutdown_rx,
));
// Watch the loop so a panic is logged instead of vanishing silently (which
@@ -161,6 +174,7 @@ async fn run_proxy_loop<R: Runtime>(
app: AppHandle<R>,
listener: TcpListener,
remote_host: String,
port: u16,
mut shutdown_rx: tokio::sync::oneshot::Receiver<()>,
) {
let mut consecutive_errors: u32 = 0;
@@ -191,6 +205,21 @@ async fn run_proxy_loop<R: Runtime>(
"[http_proxy] {} consecutive accept errors, stopping proxy loop",
MAX_CONSECUTIVE_ACCEPT_ERRORS
);
// Deregister the dead tunnel BEFORE the break drops
// `listener`, so a future start_http_proxy rebinds a
// fresh port instead of handing back this closed one
// forever. Doing it here rather than after the loop
// returns matters: the listener still holds the port,
// so no newer tunnel can have been handed the same
// number and the port guard cannot misfire.
if let Some(state) = app.try_state::<HttpProxyState>() {
state.remove_if_port_matches(&remote_host, port).await;
} else {
warn!(
"[http_proxy] state unmanaged; cannot deregister dead tunnel for {}",
remote_host
);
}
break;
}
}
@@ -408,6 +437,45 @@ async fn handle_connection<R: Runtime>(
mod tests {
use super::*;
// Regression: the accept-error exit path in run_proxy_loop must be able to
// deregister its own dead entry, but must NOT clobber a newer tunnel that
// has since replaced it under the same remote_host key.
#[tokio::test]
async fn remove_if_port_matches_removes_only_matching_entry() {
let state = HttpProxyState::new();
{
let (tx, _rx) = tokio::sync::oneshot::channel::<()>();
let mut inner = state.inner.lock().await;
inner.insert(
"example.com:8443".to_string(),
ProxyEntry {
port: 4242,
shutdown_tx: tx,
},
);
}
// A stale loop reporting a port that no longer matches the live
// entry must leave the current entry alone.
state
.remove_if_port_matches("example.com:8443", 9999)
.await;
assert_eq!(
state.inner.lock().await.get("example.com:8443").map(|e| e.port),
Some(4242),
"mismatched port must not remove a newer tunnel's entry"
);
// A loop reporting its own still-current port must remove it.
state
.remove_if_port_matches("example.com:8443", 4242)
.await;
assert!(
state.inner.lock().await.get("example.com:8443").is_none(),
"matching port must deregister the dead tunnel"
);
}
#[test]
fn validate_rejects_crlf_and_null() {
assert!(validate_remote_host("evil\r\nhost").is_err());
+1
View File
@@ -125,6 +125,7 @@ pub fn run() {
ptt::ptt_stop,
ptt::ptt_set_key,
ptt::ptt_get_key,
ptt::ptt_polling_supported,
ptt::ptt_listen_for_key,
livekit_proxy::start_livekit_proxy,
livekit_proxy::stop_livekit_proxy,
@@ -18,7 +18,9 @@
// - Certificate validation uses the TOFU-pinned fingerprint from ws_proxy.
// The WebSocket proxy must connect first to establish trust; the LiveKit
// proxy then pins to that same certificate. If the cert changes between
// WS and LiveKit connections, the LiveKit handshake will fail.
// WS and LiveKit connections, the LiveKit handshake will fail (fail
// closed) until the user accepts the new cert — each start call reloads
// the stored pin and restarts the listener when it changed.
// - Only one proxy instance runs at a time (per remote host). Connecting to
// a different server replaces the proxy. Stale proxy ports are not reused.
// - If the TcpListener errors (extremely unlikely on loopback), the cached
@@ -29,7 +31,7 @@ use log::{debug, error, info, warn};
use std::net::IpAddr;
use std::sync::Arc;
use rustls::pki_types::ServerName;
use tauri::Runtime;
use tauri::{Manager, Runtime};
use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Mutex;
@@ -45,6 +47,9 @@ struct ProxyInner {
port: Option<u16>,
/// The remote host:port we're proxying to.
remote_host: String,
/// The TOFU fingerprint the running listener pins. Baked into the proxy
/// loop at spawn, so a re-pin in the cert store requires a restart.
pinned_fingerprint: String,
/// Shutdown signal sender.
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
}
@@ -55,10 +60,26 @@ impl LiveKitProxyState {
inner: Mutex::new(ProxyInner {
port: None,
remote_host: String::new(),
pinned_fingerprint: String::new(),
shutdown_tx: None,
}),
}
}
/// Clear the running-proxy state, but only if it still points at `port`.
/// Mirrors HttpProxyState::remove_if_port_matches; used by run_proxy_loop's
/// accept-error exit path so a dead listener doesn't keep being handed
/// back by start_livekit_proxy's reuse branch, and doesn't race a newer
/// proxy that may have already replaced it.
async fn clear_if_port_matches(&self, port: u16) {
let mut inner = self.inner.lock().await;
if inner.port == Some(port) {
inner.port = None;
inner.remote_host.clear();
inner.pinned_fingerprint.clear();
inner.shutdown_tx = None;
}
}
}
// ---------------------------------------------------------------------------
@@ -121,6 +142,21 @@ pub(crate) fn rewrite_proxy_headers(request: &str, remote_host: &str) -> String
modified
}
/// Decide whether an already-running proxy can serve a new start request:
/// only when both the remote host AND the TOFU-pinned fingerprint are
/// unchanged. The listener bakes its fingerprint in at spawn, so after the
/// user accepts a rotated cert (which rewrites the store), reusing the old
/// listener would fail every TLS handshake against the stale pin until
/// logout — the caller must tear down and restart instead.
pub(crate) fn can_reuse_proxy(
running_host: &str,
running_fingerprint: &str,
requested_host: &str,
stored_fingerprint: &str,
) -> bool {
running_host == requested_host && running_fingerprint == stored_fingerprint
}
/// Extract the TLS server name from a `host[:port]` string.
///
/// IPv6 literals arrive bracketed (`[::1]:8443`); the brackets are stripped and
@@ -162,24 +198,12 @@ pub async fn start_livekit_proxy<R: Runtime>(
info!("[livekit_proxy] start requested for {}", remote_host);
// Reuse existing proxy for same host.
if let Some(port) = inner.port {
if inner.remote_host == remote_host {
debug!("[livekit_proxy] reusing existing proxy on port {} for {}", port, remote_host);
return Ok(port);
}
// Different host — tear down old proxy.
info!("[livekit_proxy] stopping old proxy for {} (switching to {})", inner.remote_host, remote_host);
if let Some(tx) = inner.shutdown_tx.take() {
let _ = tx.send(());
}
inner.port = None;
}
// Load the TOFU-pinned fingerprint from the cert store. The ws_proxy must
// have connected first (establishing the TOFU trust), so the fingerprint
// should already be stored. If not, reject — we refuse to connect without
// a pinned cert.
// Load the TOFU-pinned fingerprint from the cert store BEFORE the reuse
// check — a running listener bakes its pin in at spawn, so a re-pin
// (user accepted a rotated cert) must force a restart, not a reuse. The
// ws_proxy must have connected first (establishing the TOFU trust), so
// the fingerprint should already be stored. If not, reject — we refuse
// to connect without a pinned cert.
let store_key = tofu::cert_store_key(&remote_host);
let fingerprint = tofu::load_stored_fingerprint(&app, &store_key)?
.ok_or_else(|| format!(
@@ -187,6 +211,23 @@ pub async fn start_livekit_proxy<R: Runtime>(
Connect via WebSocket first to establish TOFU trust."
))?;
// Reuse the existing proxy only when host AND pin are unchanged.
if let Some(port) = inner.port {
if can_reuse_proxy(&inner.remote_host, &inner.pinned_fingerprint, &remote_host, &fingerprint) {
debug!("[livekit_proxy] reusing existing proxy on port {} for {}", port, remote_host);
return Ok(port);
}
// Different host or re-pinned cert — tear down the old proxy.
info!(
"[livekit_proxy] stopping old proxy for {} (restarting for {})",
inner.remote_host, remote_host
);
if let Some(tx) = inner.shutdown_tx.take() {
let _ = tx.send(());
}
inner.port = None;
}
let listener = TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| format!("livekit proxy bind failed: {e}"))?;
@@ -198,7 +239,14 @@ pub async fn start_livekit_proxy<R: Runtime>(
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let host = remote_host.clone();
let loop_handle = tokio::spawn(run_proxy_loop(listener, host, fingerprint, shutdown_rx));
let loop_handle = tokio::spawn(run_proxy_loop(
app.clone(),
listener,
host,
port,
fingerprint.clone(),
shutdown_rx,
));
// Watch the loop so a panic is logged instead of vanishing silently.
tokio::spawn(async move {
match loop_handle.await {
@@ -212,6 +260,7 @@ pub async fn start_livekit_proxy<R: Runtime>(
inner.port = Some(port);
inner.remote_host = remote_host;
inner.pinned_fingerprint = fingerprint;
inner.shutdown_tx = Some(shutdown_tx);
Ok(port)
@@ -228,6 +277,7 @@ pub async fn stop_livekit_proxy(
}
inner.port = None;
inner.remote_host.clear();
inner.pinned_fingerprint.clear();
Ok(())
}
@@ -238,9 +288,11 @@ pub async fn stop_livekit_proxy(
/// Maximum consecutive accept errors before the proxy loop exits.
const MAX_CONSECUTIVE_ACCEPT_ERRORS: u32 = 5;
async fn run_proxy_loop(
async fn run_proxy_loop<R: Runtime>(
app: tauri::AppHandle<R>,
listener: TcpListener,
remote_host: String,
port: u16,
pinned_fingerprint: String,
mut shutdown_rx: tokio::sync::oneshot::Receiver<()>,
) {
@@ -272,6 +324,20 @@ async fn run_proxy_loop(
"[livekit_proxy] {} consecutive accept errors, stopping proxy loop",
MAX_CONSECUTIVE_ACCEPT_ERRORS
);
// Deregister the dead proxy BEFORE the break drops
// `listener`, so a future start_livekit_proxy
// rebinds a fresh port instead of handing back
// this closed one forever (the reuse branch keys
// only on host+pin, not liveness). Mirrors
// http_proxy.rs's identical fix.
if let Some(state) = app.try_state::<LiveKitProxyState>() {
state.clear_if_port_matches(port).await;
} else {
warn!(
"[livekit_proxy] state unmanaged; cannot deregister dead proxy for {}",
remote_host
);
}
break;
}
}
@@ -282,6 +348,35 @@ async fn run_proxy_loop(
}
}
/// Bound on the outbound dial and TLS handshake, matching http_proxy.rs.
const PROXY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// Dial `remote_host` and complete the TLS handshake, bounding each step by
/// `limit`.
///
/// Both steps must be bounded. A peer that accepts the TCP connection and then
/// never answers the ClientHello blocks the handshake forever, and the calling
/// task holds `local` without polling it — so the LiveKit SDK closing its side
/// never cancels it. Those tasks and their sockets accumulate on every SDK
/// retry and survive stop_livekit_proxy, whose shutdown oneshot only stops the
/// accept loop; the per-connection tasks are detached.
async fn connect_tls(
connector: &tokio_rustls::TlsConnector,
server_name: ServerName<'static>,
remote_host: &str,
limit: Duration,
) -> Result<tokio_rustls::client::TlsStream<TcpStream>, Box<dyn std::error::Error + Send + Sync>> {
debug!("[livekit_proxy] connecting TCP to {}", remote_host);
let tcp = timeout(limit, TcpStream::connect(remote_host))
.await
.map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from("TCP connect timed out"))??;
debug!("[livekit_proxy] starting TLS handshake with {}", remote_host);
let tls = timeout(limit, connector.connect(server_name, tcp))
.await
.map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from("TLS handshake timed out"))??;
Ok(tls)
}
/// Handle a single proxied connection:
/// 1. Read the HTTP request headers from the local (plain) side
/// 2. Rewrite Host/Origin so the remote server accepts the connection
@@ -344,10 +439,7 @@ async fn handle_connection(
let server_name = parse_server_name(remote_host)?;
debug!("[livekit_proxy] connecting TCP to {}", remote_host);
let tcp = TcpStream::connect(remote_host).await?;
debug!("[livekit_proxy] starting TLS handshake with {}", remote_host);
let mut tls = connector.connect(server_name, tcp).await?;
let mut tls = connect_tls(&connector, server_name, remote_host, PROXY_CONNECT_TIMEOUT).await?;
debug!("[livekit_proxy] TLS handshake complete, forwarding traffic");
// ── 4. Forward request + bidirectional copy ──────────────────────────
@@ -431,6 +523,27 @@ mod tests {
assert!(validate_remote_host("").is_ok());
}
// ── can_reuse_proxy ─────────────────────────────────────────────────────
#[test]
fn reuses_proxy_only_when_host_and_pin_are_unchanged() {
assert!(can_reuse_proxy("example.com:443", "aa:bb", "example.com:443", "aa:bb"));
}
#[test]
fn restarts_proxy_when_host_changes() {
assert!(!can_reuse_proxy("old.example:443", "aa:bb", "new.example:443", "aa:bb"));
}
#[test]
fn restarts_proxy_when_pin_changes() {
// The user accepted a rotated cert (accept_cert_fingerprint rewrote the
// store). The running listener still pins the old fingerprint, so every
// connection through it would fail the TLS handshake — reuse must be
// refused so the caller tears down and restarts with the new pin.
assert!(!can_reuse_proxy("example.com:443", "aa:bb", "example.com:443", "cc:dd"));
}
// ── rewrite_proxy_headers ───────────────────────────────────────────────
#[test]
@@ -558,4 +671,86 @@ mod tests {
fn rejects_an_invalid_dns_name() {
assert!(parse_server_name("not a hostname").is_err());
}
// A peer that accepts the TCP connection and then answers nothing must not
// hang the connection task forever — see connect_tls.
#[tokio::test]
async fn tls_handshake_is_bounded_by_its_timeout() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("local_addr");
tokio::spawn(async move {
let _accepted = listener.accept().await.expect("accept");
// Hold the connection open, answering nothing.
std::future::pending::<()>().await;
});
let tls_config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(tofu::PinnedVerifier::new(
"aa:bb:cc".to_string(),
)))
.with_no_client_auth();
let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
let server_name = ServerName::try_from("localhost").expect("server name");
// The outer bound exists only so a regression fails fast instead of
// hanging the suite; the assertion is that the inner limit fired.
let outcome = timeout(
Duration::from_secs(5),
connect_tls(
&connector,
server_name,
&addr.to_string(),
Duration::from_millis(100),
),
)
.await;
assert!(
outcome.is_ok(),
"connect_tls hung: the TLS handshake is not bounded by its own timeout"
);
assert!(
outcome.expect("bounded").is_err(),
"a silent peer must produce an error, not a usable TLS stream"
);
}
// ── LiveKitProxyState::clear_if_port_matches ────────────────────────────
//
// B4_conn_ipc-7: run_proxy_loop's accept-error exit path drops the
// listener without deregistering it, so ProxyInner.port stays set and
// start_livekit_proxy's reuse branch (unchanged host+pin) hands the dead
// port back forever. Mirrors http_proxy.rs's
// remove_if_port_matches_removes_only_matching_entry test.
#[tokio::test]
async fn clear_if_port_matches_clears_only_a_matching_entry() {
let state = LiveKitProxyState::new();
{
let (tx, _rx) = tokio::sync::oneshot::channel::<()>();
let mut inner = state.inner.lock().await;
inner.port = Some(4242);
inner.remote_host = "example.com:8443".to_string();
inner.pinned_fingerprint = "aa:bb".to_string();
inner.shutdown_tx = Some(tx);
}
// A stale loop reporting a port that no longer matches the live
// listener must leave the current entry alone.
state.clear_if_port_matches(9999).await;
assert_eq!(
state.inner.lock().await.port,
Some(4242),
"mismatched port must not clear a newer proxy's state"
);
// A loop reporting its own still-current port must clear it so the
// next start_livekit_proxy rebinds instead of reusing the dead listener.
state.clear_if_port_matches(4242).await;
let inner = state.inner.lock().await;
assert_eq!(inner.port, None, "matching port must deregister the dead proxy");
assert!(inner.remote_host.is_empty());
assert!(inner.pinned_fingerprint.is_empty());
}
}
+78 -6
View File
@@ -298,10 +298,55 @@ mod linux {
}
}
/// Decide whether the polling loop must emit a `ptt-state` event this tick.
///
/// Returns `Some(new_state)` on a press/release edge, `None` when nothing
/// changed.
///
/// The `vk == 0` (unbound) case is folded into `pressed` here rather than
/// guarding the whole tick: clearing the binding while the key is physically
/// held must still produce the `true -> false` falling edge. With the guard
/// outside, `was_pressed` freezes at `true`, no final `ptt-state=false` is
/// ever emitted, and the microphone stays published.
fn ptt_transition(vk: i32, key_down: bool, was_pressed: bool) -> Option<bool> {
let pressed = vk != 0 && key_down;
(pressed != was_pressed).then_some(pressed)
}
// ---------------------------------------------------------------------------
// Tauri commands
// ---------------------------------------------------------------------------
/// Whether this platform can actually observe global key state, i.e. whether
/// the polling loop can ever emit a `ptt-state` event.
///
/// `ptt_start` spawns its thread unconditionally, so a live thread is NOT
/// evidence that PTT works: on macOS `is_key_down` is a compile-time stub that
/// always returns false, and on a pure-Wayland Linux session
/// `DeviceState::checked_new()` returns None. The frontend gates its join-time
/// PTT mute on this, because muting at join where no event can ever arrive
/// would close the microphone for the whole session with no way to reopen it.
#[tauri::command]
pub fn ptt_polling_supported() -> bool {
#[cfg(windows)]
{
true
}
#[cfg(target_os = "linux")]
{
use device_query::DeviceState;
// Mirrors the availability check inside `is_key_down`: no reachable
// X11/XWayland display means key state is never observable.
DeviceState::checked_new().is_some()
}
#[cfg(not(any(windows, target_os = "linux")))]
{
false
}
}
/// Start the PTT polling loop. Emits `ptt-state` (bool) events.
///
/// Uses `PTT_THREAD`'s Mutex as the critical section to prevent duplicate
@@ -329,12 +374,14 @@ pub fn ptt_start<R: Runtime>(app: AppHandle<R>) {
while !thread_shutdown.load(Ordering::SeqCst) {
let vk = PTT_VKEY.load(Ordering::SeqCst);
if vk != 0 {
let pressed = is_key_down(vk);
if pressed != was_pressed {
was_pressed = pressed;
let _ = app.emit("ptt-state", pressed);
}
// Evaluated on every tick, including vk == 0: clearing the PTT
// key while it is physically held must still produce a falling
// edge, otherwise was_pressed freezes at true and the mic never
// gets its final `ptt-state=false`. `is_key_down` short-circuits
// to false for vk == 0 on every platform, so this costs nothing.
if let Some(pressed) = ptt_transition(vk, is_key_down(vk), was_pressed) {
was_pressed = pressed;
let _ = app.emit("ptt-state", pressed);
}
std::thread::sleep(Duration::from_millis(20));
}
@@ -523,6 +570,31 @@ mod tests {
assert!(g.is_none(), "slot must stay empty when nothing was running");
}
// The loop must emit only on edges, never on every tick — a repeat emit
// would re-run the mute logic (and its user-mute guard) 50x/second.
#[test]
fn ptt_transition_reports_edges_only() {
assert_eq!(ptt_transition(0x41, true, false), Some(true), "rising edge");
assert_eq!(ptt_transition(0x41, true, true), None, "still held");
assert_eq!(ptt_transition(0x41, false, true), Some(false), "falling edge");
assert_eq!(ptt_transition(0x41, false, false), None, "still idle");
}
// Regression for the "hot mic after Clear while the PTT key is held" bug:
// clearing the binding (vk -> 0) with the key still physically down must
// still yield the falling edge that emits the final ptt-state=false. The
// old loop wrapped the whole comparison in `if vk != 0`, so this case
// produced no transition at all and the mic stayed published.
#[test]
fn ptt_transition_emits_release_when_binding_cleared_while_key_held() {
assert_eq!(ptt_transition(0, true, true), Some(false));
// The release is reported once, then the unbound key stays quiet — an
// unbound key must never read as pressed no matter what the raw
// key-down probe says.
assert_eq!(ptt_transition(0, true, false), None);
assert_eq!(ptt_transition(0, false, false), None);
}
#[test]
fn allowed_capture_vk_accepts_safe_non_text_keys() {
assert!(is_allowed_ptt_capture_vk(0x70)); // F1
@@ -92,6 +92,28 @@ const FALLBACK_BACKEND: Backend = Backend::EncryptedFile;
/// secret — the caller's in-memory copy is all that is left, so the current
/// session still works but nothing survives a restart.
pub fn set(app: &AppHandle, account: &str, secret: &str) -> Result<Backend, String> {
set_with(
account,
secret,
keyring_set,
keyring_get,
keyring_delete,
|acct, sec| set_fallback(app, acct, sec),
|acct| clear_fallback(app, acct),
)
}
/// Core decision logic for [`set`], with the keyring and fallback operations
/// injected so the branching is testable without a live OS credential store.
fn set_with(
account: &str,
secret: &str,
keyring_set: impl Fn(&str, &str) -> Result<(), String>,
keyring_get: impl Fn(&str) -> Result<Option<String>, String>,
keyring_delete: impl Fn(&str) -> Result<(), String>,
fallback_set: impl FnOnce(&str, &str) -> Result<(), String>,
fallback_clear: impl FnOnce(&str),
) -> Result<Backend, String> {
match keyring_set(account, secret) {
Ok(()) => match keyring_get(account) {
// The normal path: written and read back byte-for-byte.
@@ -99,7 +121,7 @@ pub fn set(app: &AppHandle, account: &str, secret: &str) -> Result<Backend, Stri
// A machine that was previously degraded and has since been
// fixed must not keep a stale ciphertext shadowing the real
// store on the next read.
clear_fallback(app, account);
fallback_clear(account);
return Ok(Backend::Keyring);
}
Ok(Some(_)) => {
@@ -127,10 +149,23 @@ pub fn set(app: &AppHandle, account: &str, secret: &str) -> Result<Backend, Stri
but the read-back failed: {e} — falling back"
),
},
Err(e) => log::error!("{SERVICE}: credential store write failed for '{account}': {e}"),
Err(e) => {
log::error!("{SERVICE}: credential store write failed for '{account}': {e}");
// An older secret may already sit in the keyring from a prior
// successful write. get() reads the keyring first, so leaving
// that stale entry in place would shadow the fresh secret parked
// in the fallback below — mirrors the read-back-mismatch arm
// above, which purges for the same reason.
if let Err(de) = keyring_delete(account) {
log::warn!(
"{SERVICE}: could not remove a stale keyring entry for '{account}' after a \
failed write: {de}"
);
}
}
}
set_fallback(app, account, secret)?;
fallback_set(account, secret)?;
log::warn!(
"{SERVICE}: account '{account}' is stored in the encrypted fallback file, not the OS \
credential store. See docs/credential-storage.md"
@@ -143,12 +178,34 @@ pub fn set(app: &AppHandle, account: &str, secret: &str) -> Result<Backend, Stri
/// The OS credential store wins over the fallback file, so a machine that
/// recovers goes back to the real store without any migration step.
pub fn get(app: &AppHandle, account: &str) -> Result<Option<String>, String> {
get_with(account, keyring_get, |acct| get_fallback(app, acct))
}
/// Core decision logic for [`get`], with the keyring and fallback lookups
/// injected so the branching is testable without a live OS credential store.
fn get_with(
account: &str,
keyring_get: impl Fn(&str) -> Result<Option<String>, String>,
get_fallback: impl Fn(&str) -> Option<String>,
) -> Result<Option<String>, String> {
match keyring_get(account) {
Ok(Some(secret)) => return Ok(Some(secret)),
Ok(None) => {}
Err(e) => log::warn!("{SERVICE}: credential store read failed for '{account}': {e}"),
Ok(Some(secret)) => Ok(Some(secret)),
Ok(None) => Ok(get_fallback(account)),
Err(e) => {
log::warn!("{SERVICE}: credential store read failed for '{account}': {e}");
// A read error must not collapse to "nothing stored": on a
// healthy machine set() clears the fallback on every successful
// write, so an empty fallback here is indistinguishable from
// "never stored". Prefer a fallback copy if one exists; only
// report "nothing" when both stores genuinely have nothing, and
// otherwise propagate the error so the caller can tell a broken
// store apart from first login.
match get_fallback(account) {
Some(secret) => Ok(Some(secret)),
None => Err(e),
}
}
}
Ok(get_fallback(app, account))
}
/// Remove `account` from every store. Absent entries are not an error.
@@ -413,6 +470,90 @@ mod tests {
assert_eq!(fallback_aad("host.example"), fallback_aad("host.example"));
}
// -- get_with: finding "a keyring read error must not read as 'not stored'" --
#[test]
fn get_with_falls_back_when_the_keyring_errors_but_the_fallback_has_a_copy() {
let result = get_with(
"identity:chat.example",
|_| Err("keychain locked".to_string()),
|_| Some("fallback-secret".to_string()),
);
assert_eq!(result, Ok(Some("fallback-secret".to_string())));
}
#[test]
fn get_with_propagates_the_keyring_error_when_the_fallback_is_also_empty() {
// The bug: a keyring read failure must never be reported as "nothing
// stored" (Ok(None)) when the fallback is empty too — that is
// indistinguishable from first login, and the E2EE identity keypair
// loader mints and publishes a brand-new identity key on exactly that
// signal, invalidating every peer's TOFU pin.
let result = get_with("identity:chat.example", |_| Err("keychain locked".to_string()), |_| None);
assert_eq!(result, Err("keychain locked".to_string()));
}
#[test]
fn get_with_prefers_the_live_keyring_value_over_the_fallback() {
let result = get_with("acct", |_| Ok(Some("live".to_string())), |_| Some("stale".to_string()));
assert_eq!(result, Ok(Some("live".to_string())));
}
#[test]
fn get_with_uses_the_fallback_when_the_keyring_has_nothing_stored() {
let result = get_with("acct", |_| Ok(None), |_| Some("fallback".to_string()));
assert_eq!(result, Ok(Some("fallback".to_string())));
}
// -- set_with: finding "a failed keyring write must not leave a stale entry" --
#[test]
fn set_with_deletes_any_stale_keyring_entry_when_the_write_fails() {
// The bug: a write failure with an older secret already sitting in
// the keyring from a prior successful write must not leave that
// stale entry in place — get() reads the keyring first, so it would
// shadow the fresh secret parked in the fallback below forever.
use std::cell::Cell;
let delete_called = Cell::new(false);
let result = set_with(
"acct",
"new-secret",
|_, _| Err("write failed".to_string()),
|_| panic!("keyring_get must not run after a failed write"),
|_| {
delete_called.set(true);
Ok(())
},
|_, _| Ok(()),
|_| {},
);
assert_eq!(result, Ok(FALLBACK_BACKEND));
assert!(
delete_called.get(),
"a failed keyring write must delete any stale prior entry before falling back"
);
}
#[test]
fn set_with_returns_keyring_backend_when_the_write_round_trips() {
use std::cell::Cell;
let cleared = Cell::new(false);
let result = set_with(
"acct",
"secret",
|_, s| {
assert_eq!(s, "secret");
Ok(())
},
|_| Ok(Some("secret".to_string())),
|_| panic!("must not delete a keyring entry that round-tripped"),
|_, _| panic!("must not touch the fallback on a successful round trip"),
|_| cleared.set(true),
);
assert_eq!(result, Ok(Backend::Keyring));
assert!(cleared.get(), "a recovered machine must clear any stale fallback copy");
}
#[cfg(windows)]
#[test]
fn dpapi_round_trips_and_rejects_foreign_entropy() {
+20 -1
View File
@@ -283,8 +283,13 @@ impl rustls::client::danger::ServerCertVerifier for HostScopedVerifier {
/// Cert-store key for a host. Strips a default `:443` so the ws proxy (which
/// keys off `wss://host` with no explicit 443) and the http/livekit proxies
/// (which see `host:443`) resolve the SAME pin. Non-default ports are kept.
/// Case-folded (DNS names are case-insensitive): the host reaches this from
/// several places (a profile-entered host verbatim, a `wss://` URL, a URL
/// parsed on the TS side, which lowercases) — without folding case here, two
/// callers with the same server in different case would pin/read different
/// entries, opening a second, unpinned proxy tunnel.
pub(crate) fn cert_store_key(host: &str) -> String {
host.strip_suffix(":443").unwrap_or(host).to_string()
host.strip_suffix(":443").unwrap_or(host).to_ascii_lowercase()
}
/// Extract the host (with any non-default port) from a `wss://` URL.
@@ -390,6 +395,20 @@ mod tests {
assert_eq!(cert_store_key("example.com:8443"), "example.com:8443");
}
// DNS names are case-insensitive, but a raw host string (a profile-entered
// host, or one taken verbatim from a wss:// URL) is not normalized before
// reaching here. Two call sites can derive the SAME host in different
// case (e.g. login uses the host as typed, an attachment fetch resolves
// it through URL parsing, which lowercases) — without folding case here,
// they pin/read two different cert-store entries for the same server,
// opening a second, unpinned proxy tunnel.
#[test]
fn cert_store_key_folds_case() {
assert_eq!(cert_store_key("Example.COM"), "example.com");
assert_eq!(cert_store_key("MyServer.LAN:8443"), "myserver.lan:8443");
assert_eq!(cert_store_key("Example.COM:443"), "example.com");
}
#[test]
fn extract_host_variants() {
assert_eq!(extract_host("wss://example.com/chat"), "example.com");
+220 -27
View File
@@ -13,6 +13,7 @@
use futures_util::{SinkExt, StreamExt};
use log::{debug, error, info, warn};
use serde_json::Value;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, Emitter, Runtime};
@@ -32,14 +33,68 @@ use crate::tofu::{self, TofuOutcome};
/// into its closure and clear the sender even after a worker task panic.
pub struct WsState {
tx: Arc<Mutex<Option<mpsc::Sender<String>>>>,
/// Bumped once per `ws_connect` attempt. The handshake can pend for up to
/// CONNECT_TIMEOUT, and callers (profile switch) start a second connect
/// without awaiting the first, so an attempt must prove it is still the
/// current generation before it may touch the shared sender slot.
generation: Arc<AtomicU64>,
}
impl WsState {
pub fn new() -> Self {
Self {
tx: Arc::new(Mutex::new(None)),
generation: Arc::new(AtomicU64::new(0)),
}
}
/// Claim a generation for a new connection attempt, dropping any existing
/// sender. Every later step of that attempt is conditional on this value
/// still being current.
async fn begin_connection(&self) -> u64 {
let mut tx_lock = self.tx.lock().await;
if tx_lock.is_some() {
debug!("[ws_proxy] dropping existing connection");
}
*tx_lock = None;
self.generation.fetch_add(1, Ordering::SeqCst) + 1
}
/// Install `tx` as the live sender if `generation` is still current.
/// Returns false when a newer `ws_connect` superseded this attempt.
async fn install_sender(&self, generation: u64, tx: mpsc::Sender<String>) -> bool {
// Checked under the slot lock so the decision and the write cannot be
// split by a concurrent attempt.
let mut tx_lock = self.tx.lock().await;
if self.generation.load(Ordering::SeqCst) != generation {
return false;
}
*tx_lock = Some(tx);
true
}
}
/// Clear the live sender slot, but only if `my_generation` is still the
/// current connection generation. Returns false when a newer `ws_connect`
/// superseded this connection — that teardown must not clear the slot or
/// announce a close. Ownership is proven by generation, NOT by holding a
/// Sender clone: a clone kept alive in the monitor task would keep the
/// outbound channel open, so the write task could never observe closure
/// after `ws_disconnect` (circular wait — task, socket, and TLS session
/// would all leak). `generation` only advances inside `begin_connection`
/// while the slot lock is held, so checking it under the same lock makes
/// the check-and-clear atomic with respect to new attempts.
async fn clear_sender_if_current(
slot: &Mutex<Option<mpsc::Sender<String>>>,
generation: &AtomicU64,
my_generation: u64,
) -> bool {
let mut tx_lock = slot.lock().await;
if generation.load(Ordering::SeqCst) != my_generation {
return false;
}
*tx_lock = None;
true
}
/// Single call site for ws-state events — keeps tauri-typegen from generating duplicates.
@@ -69,14 +124,8 @@ pub async fn ws_connect<R: Runtime>(
) -> Result<(), String> {
info!("[ws_proxy] connecting to {}", url);
// Drop any existing connection
{
let mut tx_lock = state.tx.lock().await;
if tx_lock.is_some() {
debug!("[ws_proxy] dropping existing connection");
}
*tx_lock = None;
}
// Drop any existing connection and claim this attempt's generation.
let my_generation = state.begin_connection().await;
// Only allow secure WebSocket connections
if !url.starts_with("wss://") {
@@ -169,23 +218,26 @@ pub async fn ws_connect<R: Runtime>(
}
// ── End TOFU check ───────────────────────────────────────────────────
let (mut sink, mut stream) = ws_stream.split();
// Channel for JS → server messages (bounded for backpressure). The slot
// gets the ONLY Sender: teardown ownership is proven by generation, so no
// clone may outlive the slot — one would keep rx.recv() pending forever.
let (tx, mut rx) = mpsc::channel::<String>(256);
if !state.install_sender(my_generation, tx).await {
info!("[ws_proxy] handshake superseded by a newer connect; dropping stale socket");
return Err("superseded by a newer connection".into());
}
info!("[ws_proxy] connected to {}", host);
emit_ws_state(&app, "open");
let (mut sink, mut stream) = ws_stream.split();
// Channel for JS → server messages (bounded for backpressure)
let (tx, mut rx) = mpsc::channel::<String>(256);
{
let mut tx_lock = state.tx.lock().await;
*tx_lock = Some(tx);
}
let app_read = app.clone();
let app_state = app.clone();
// Clone the Arc so the monitoring closure can clear tx on any exit path,
// Clone the Arcs so the monitoring closure can clear tx on any exit path,
// including worker task panics, without needing tauri::State.
let tx_arc = Arc::clone(&state.tx);
let generation_arc = Arc::clone(&state.generation);
// Single outer task owns a JoinSet containing read and write workers.
// join_next() blocks until the first worker finishes (normally or via panic),
@@ -242,14 +294,16 @@ pub async fn ws_connect<R: Runtime>(
}
// Clear the sender so ws_send returns "not connected". This runs on
// every exit path — normal close, graceful disconnect, and panic.
{
let mut tx_lock = tx_arc.lock().await;
*tx_lock = None;
// every exit path — normal close, graceful disconnect, and panic — but
// only when this connection still owns the slot. Clearing
// unconditionally would kill a newer connection's sender and tell JS
// that the live connection had closed.
if clear_sender_if_current(&tx_arc, &generation_arc, my_generation).await {
// Always emit closed, even after a panic.
emit_ws_state(&app_state, "closed");
} else {
debug!("[ws_proxy] superseded connection torn down; leaving live sender in place");
}
// Always emit closed, even after a panic.
emit_ws_state(&app_state, "closed");
});
Ok(())
@@ -281,8 +335,13 @@ pub async fn ws_send(
/// Disconnect the proxy WebSocket.
#[tauri::command]
pub async fn ws_disconnect(state: tauri::State<'_, WsState>) -> Result<(), String> {
let mut tx_lock = state.tx.lock().await;
*tx_lock = None; // dropping the sender closes the channel → write task ends
// begin_connection() both clears the sender slot (dropping it closes the
// channel so the write task ends) AND bumps the generation counter, so a
// handshake still pending from before this disconnect fails install_sender
// instead of installing itself afterward — reusing the same invalidation
// path a superseding connect() already has. The returned generation is
// unused: nothing will ever install under it.
state.begin_connection().await;
Ok(())
}
@@ -427,4 +486,138 @@ mod tests {
let bad = format!("é{}", &VALID[..93]);
assert!(!is_valid_cert_fingerprint(&bad));
}
// ── Connection-generation ownership of the shared sender slot ───────────
//
// A handshake pends up to CONNECT_TIMEOUT, and a profile switch starts a
// second ws_connect without awaiting or cancelling the first, so two
// attempts can be in flight over one slot. Mirrors the ptt.rs
// ATOMICRACE-001 guard.
#[tokio::test]
async fn superseded_connect_does_not_take_the_sender_slot() {
let state = WsState::new();
// Connection A starts its handshake, then a profile switch starts B
// while A is still pending.
let gen_a = state.begin_connection().await;
let gen_b = state.begin_connection().await;
assert_ne!(gen_a, gen_b);
let (tx_b, _rx_b) = mpsc::channel::<String>(4);
assert!(
state.install_sender(gen_b, tx_b.clone()).await,
"the current generation must be able to install"
);
// A's handshake finally completes. Installing now would route the next
// auth send to the stale host and drop B's sender, ending B's write task.
let (tx_a, _rx_a) = mpsc::channel::<String>(4);
assert!(
!state.install_sender(gen_a, tx_a).await,
"a superseded attempt must not take the slot"
);
let slot = state.tx.lock().await;
assert!(
slot.as_ref().is_some_and(|t| t.same_channel(&tx_b)),
"the live connection's sender must still be installed"
);
}
// ── Generation-owned teardown ───────────────────────────────────────────
//
// Teardown ownership must be provable WITHOUT holding a Sender clone: any
// clone kept alive by the monitor task keeps the outbound channel open, so
// after ws_disconnect drops the slot's sender the write task never sees
// rx.recv() == None — writer, reader, and TLS socket all leak in a
// circular wait (monitor waits on writer, writer waits on the monitor's
// clone dropping).
#[tokio::test]
async fn owning_teardown_clears_the_slot_by_generation() {
let state = WsState::new();
let my_generation = state.begin_connection().await;
let (tx, _rx) = mpsc::channel::<String>(4);
state.install_sender(my_generation, tx).await;
assert!(
clear_sender_if_current(&state.tx, &state.generation, my_generation).await,
"the owning connection must clear its slot without a Sender clone"
);
assert!(
state.tx.lock().await.is_none(),
"ws_send must report not-connected after a real close"
);
}
#[tokio::test]
async fn superseded_teardown_by_generation_leaves_the_live_sender() {
let state = WsState::new();
let gen_a = state.begin_connection().await;
let (tx_a, _rx_a) = mpsc::channel::<String>(4);
state.install_sender(gen_a, tx_a).await;
let gen_b = state.begin_connection().await;
let (tx_b, _rx_b) = mpsc::channel::<String>(4);
assert!(state.install_sender(gen_b, tx_b.clone()).await);
// A's monitor task tears down after B is live. Clearing here would
// kill B's sender and emit "closed" while JS believes B is connected.
assert!(
!clear_sender_if_current(&state.tx, &state.generation, gen_a).await,
"a superseded teardown must not clear the slot or announce a close"
);
let slot = state.tx.lock().await;
assert!(
slot.as_ref().is_some_and(|t| t.same_channel(&tx_b)),
"the live connection's sender must survive a superseded teardown"
);
}
#[tokio::test]
async fn disconnect_closes_the_outbound_channel() {
// ws_disconnect's contract (the comment at its *tx_lock = None):
// dropping the slot's sender closes the channel so the write task
// ends. That holds only while install_sender receives the ONLY
// Sender — no teardown-ownership clone may exist.
let state = WsState::new();
let generation = state.begin_connection().await;
let (tx, mut rx) = mpsc::channel::<String>(4);
state.install_sender(generation, tx).await;
*state.tx.lock().await = None; // ws_disconnect
let got = tokio::time::timeout(Duration::from_secs(1), rx.recv())
.await
.expect("write task would hang forever: channel still open after disconnect");
assert_eq!(got, None, "rx.recv() must yield None so the write task exits");
}
// B4_conn_ipc-9: ws_disconnect must invalidate an in-flight ws_connect
// attempt, not just null the sender slot. A handshake can pend for up to
// CONNECT_TIMEOUT (10s) past a disconnect (JS calls connect fire-and-
// forget — logout during "connecting" is a real interleaving), and
// install_sender checks generation alone, so a manual `*tx_lock = None`
// leaves a "cancelled" connection free to install itself afterward and
// spawn its worker tasks against a socket JS believes closed.
#[tokio::test]
async fn disconnect_invalidates_an_in_flight_connect_attempt() {
let state = WsState::new();
// A's handshake is in flight: generation claimed, sender not yet
// installed (mirrors the pending window before install_sender runs).
let gen_a = state.begin_connection().await;
// ws_disconnect fires while A is still mid-handshake — this is
// ws_disconnect's real body (state.begin_connection().await).
state.begin_connection().await;
// A's handshake finally completes and tries to install its sender.
// It must be rejected: JS already believes the connection is closed.
let (tx_a, _rx_a) = mpsc::channel::<String>(4);
assert!(
!state.install_sender(gen_a, tx_a).await,
"a handshake pending during disconnect must not be able to install after it"
);
}
}
@@ -1,6 +1,6 @@
{
"productName": "OwnCord",
"version": "1.2.0-alpha.1",
"version": "1.2.0-alpha.2",
"identifier": "com.owncord.client",
"build": {
"frontendDist": "../dist",
@@ -24,7 +24,7 @@
],
"withGlobalTauri": false,
"security": {
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://ipc.localhost https: wss: http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:*; img-src 'self' https: data:; media-src 'self' blob:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-src https://www.youtube.com https://youtube.com; worker-src 'self' blob:"
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://ipc.localhost https: wss: http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:*; img-src 'self' blob: https: data:; media-src 'self' blob:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-src https://www.youtube.com https://youtube.com; worker-src 'self' blob:"
}
},
"bundle": {
@@ -65,12 +65,6 @@
}
},
"plugins": {
"tauri-typegen": {
"project_path": ".",
"output_path": "../src/generated",
"validation_library": "none",
"verbose": false
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEJCQkQ0NzM1MjkxRTlGQTIKUldTaW54NHBOVWU5dTRqYW0yalI5VTJBd0NXOUZwM2UrcDM4YkhCSmlMZWJKWWVXaGJWdHBaSHgK",
"endpoints": [],
@@ -231,6 +231,10 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont
);
const roleSub = createElement("div", { class: "context-menu__submenu" });
// One guard across every option: `currentRole` only updates when the
// member_update echoes, so without it a double-click (or a second option
// clicked while the first PATCH is in flight) fires twice.
let roleChangeRunning = false;
for (const role of options.availableRoles) {
const cls =
role === options.currentRole
@@ -240,9 +244,14 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont
role,
cls,
() => {
if (role !== options.currentRole) {
void options.onChangeRole(role);
}
if (roleChangeRunning || role === options.currentRole) return;
roleChangeRunning = true;
roleOption.classList.add("context-menu__item--pending");
const done = (): void => {
roleChangeRunning = false;
roleOption.classList.remove("context-menu__item--pending");
};
options.onChangeRole(role).then(done, done);
},
ac.signal,
);
@@ -8,6 +8,7 @@
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
import type { MountableComponent } from "@lib/safe-render";
export interface CertMismatchModalOptions {
@@ -21,17 +22,27 @@ export interface CertMismatchModalOptions {
export function createCertMismatchModal(options: CertMismatchModalOptions): MountableComponent {
const { host, storedFingerprint, newFingerprint, onAccept, onReject } = options;
let overlay: HTMLDivElement | null = null;
let restoreFocus: (() => void) | null = null;
const ac = new AbortController();
function mount(container: Element): void {
overlay = createElement("div", { class: "modal-overlay visible" });
const modal = createElement("div", { class: "modal" });
// Ids are unique per factory, not per instance — these three trust prompts
// never stack with each other in practice.
applyDialogSemantics(modal, { labelledBy: "cert-mismatch-title" });
trapFocus(modal, ac.signal);
// Header
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", {}, "Certificate Warning");
const closeBtn = createElement("button", { class: "modal-close", type: "button" });
const title = createElement("h3", { id: "cert-mismatch-title" }, "Certificate Warning");
const closeBtn = createElement("button", {
class: "modal-close",
type: "button",
// Icon-only control — the aria-label is its entire accessible name.
"aria-label": "Close",
});
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
closeBtn.addEventListener("click", onReject, { signal: ac.signal });
@@ -94,7 +105,18 @@ export function createCertMismatchModal(options: CertMismatchModalOptions): Moun
{ signal: ac.signal },
);
// Escape maps to reject because that is the fail-closed safe default
// (Disconnect) — dismissing a trust prompt must never grant trust.
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape" && overlay?.isConnected === true) onReject();
},
{ signal: ac.signal },
);
container.appendChild(overlay);
restoreFocus = focusDialog(modal);
}
function destroy(): void {
@@ -103,6 +125,8 @@ export function createCertMismatchModal(options: CertMismatchModalOptions): Moun
overlay.remove();
overlay = null;
}
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
@@ -124,15 +148,24 @@ export interface CertFirstUseModalOptions {
export function createCertFirstUseModal(options: CertFirstUseModalOptions): MountableComponent {
const { host, fingerprint, onAccept, onReject } = options;
let overlay: HTMLDivElement | null = null;
let restoreFocus: (() => void) | null = null;
const ac = new AbortController();
function mount(container: Element): void {
overlay = createElement("div", { class: "modal-overlay visible" });
const modal = createElement("div", { class: "modal" });
// Unique per factory, not per instance — the three trust prompts never
// stack with each other in practice.
applyDialogSemantics(modal, { labelledBy: "cert-first-use-title" });
trapFocus(modal, ac.signal);
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", {}, "New Server Certificate");
const closeBtn = createElement("button", { class: "modal-close", type: "button" });
const title = createElement("h3", { id: "cert-first-use-title" }, "New Server Certificate");
const closeBtn = createElement("button", {
class: "modal-close",
type: "button",
"aria-label": "Close",
});
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
closeBtn.addEventListener("click", onReject, { signal: ac.signal });
@@ -187,7 +220,18 @@ export function createCertFirstUseModal(options: CertFirstUseModalOptions): Moun
{ signal: ac.signal },
);
// Escape rejects (Cancel) — the fail-closed default: never trust a
// certificate because the prompt was dismissed.
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape" && overlay?.isConnected === true) onReject();
},
{ signal: ac.signal },
);
container.appendChild(overlay);
restoreFocus = focusDialog(modal);
}
function destroy(): void {
@@ -196,6 +240,8 @@ export function createCertFirstUseModal(options: CertFirstUseModalOptions): Moun
overlay.remove();
overlay = null;
}
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
@@ -223,15 +269,24 @@ export function createIdentityMismatchModal(
): MountableComponent {
const { username, fingerprint, onAccept, onReject } = options;
let overlay: HTMLDivElement | null = null;
let restoreFocus: (() => void) | null = null;
const ac = new AbortController();
function mount(container: Element): void {
overlay = createElement("div", { class: "modal-overlay visible" });
const modal = createElement("div", { class: "modal" });
// Unique per factory, not per instance — the three trust prompts never
// stack with each other in practice.
applyDialogSemantics(modal, { labelledBy: "identity-mismatch-title" });
trapFocus(modal, ac.signal);
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", {}, "Identity Warning");
const closeBtn = createElement("button", { class: "modal-close", type: "button" });
const title = createElement("h3", { id: "identity-mismatch-title" }, "Identity Warning");
const closeBtn = createElement("button", {
class: "modal-close",
type: "button",
"aria-label": "Close",
});
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
closeBtn.addEventListener("click", onReject, { signal: ac.signal });
@@ -287,7 +342,18 @@ export function createIdentityMismatchModal(
{ signal: ac.signal },
);
// Escape rejects (Cancel) — the fail-closed default: dismissing the
// prompt must never re-pin the new identity key.
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape" && overlay?.isConnected === true) onReject();
},
{ signal: ac.signal },
);
container.appendChild(overlay);
restoreFocus = focusDialog(modal);
}
function destroy(): void {
@@ -296,6 +362,8 @@ export function createIdentityMismatchModal(
overlay.remove();
overlay = null;
}
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
@@ -22,7 +22,7 @@ import { attachStreamPreview, attachScrollCollapse } from "@lib/streamPreview";
import { showUserVolumeMenu } from "./channel-sidebar/volume-menu";
import type { VoiceModMenuOptions } from "./channel-sidebar/volume-menu";
import { attachChannelContextMenu, CHANNEL_MUTE_CHANGED } from "./channel-sidebar/context-menu";
import { attachDragHandlers, releaseGlobalDragListeners } from "./channel-sidebar/drag-reorder";
import { attachDragHandlers } from "./channel-sidebar/drag-reorder";
import { rePinPeerIdentity } from "@lib/livekitSession";
import { createIdentityMismatchModal } from "./CertMismatchModal";
import { createLogger } from "@lib/logger";
@@ -34,10 +34,11 @@ import { importIdentityPublicKey, computeKeyFingerprint } from "@lib/e2eeCrypto"
const log = createLogger("ChannelSidebar");
/** Icon, color, and tooltip for a peer's E2EE identity verification badge
* (F3 TOFU). The three states mirror the voice store's PeerVerification:
* (F3 TOFU). The states mirror the voice store's PeerVerification:
* a green shield-check when the announce signature verified against the pinned
* key, a muted shield when the peer published no key (legacy), and a red
* shield-alert when the delivered key differs from the pinned one. */
* key, a muted shield when the peer published no key (legacy), a red
* shield-alert when the delivered key differs from the pinned one, and an
* amber shield-question when the local pin store could not be read (DC-08). */
function verifyPresentation(v: PeerVerification): {
icon: IconName;
color: string;
@@ -60,6 +61,15 @@ function verifyPresentation(v: PeerVerification): {
title: "Identity key changed — click to review and re-pin",
};
}
if (v.status === "unknown") {
return {
icon: "shield-question",
color: "var(--yellow, #f0b232)",
title:
"Could not check this participant's identity — key storage is unavailable, " +
"so they are blocked for E2EE until it recovers",
};
}
// "unverified" — the remaining status: peer published no identity key (legacy).
return {
icon: "shield",
@@ -495,6 +505,11 @@ function renderVoiceChannelItem(
// Don't trigger if the right-click menu is open
if (e.button !== 0) return;
e.stopPropagation();
// Watching a stream needs a live LiveKit room -- join first, same
// as the hover/focus preview's placeholder click below.
if (voiceStore.getState().currentChannelId !== channel.id) {
onVoiceJoin(channel.id);
}
const tileId = user.screenshare
? user.userId + SCREENSHARE_TILE_ID_OFFSET
: user.userId;
@@ -907,8 +922,9 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
}
function destroy(): void {
// ac.abort() also releases this sidebar's hold on the shared document-level
// drag listeners (drag-reorder.ts tracks owners by signal).
ac.abort();
releaseGlobalDragListeners(channelList ?? undefined);
for (const unsub of unsubscribers) {
unsub();
}
@@ -11,6 +11,7 @@
* under every category — the server agrees (it validates the type alone).
*/
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
@@ -44,6 +45,7 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo
const { category, onCreate, onClose } = options;
const ac = new AbortController();
let overlay: HTMLDivElement | null = null;
let restoreFocus: (() => void) | null = null;
function mount(container: Element): void {
overlay = createElement("div", {
@@ -52,13 +54,17 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo
});
const modal = createElement("div", { class: "modal" });
applyDialogSemantics(modal, { labelledBy: "create-channel-title" });
trapFocus(modal, ac.signal);
// Header
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", {}, "Create Channel");
const title = createElement("h3", { id: "create-channel-title" }, "Create Channel");
// Icon-only button: without a label a screen reader announces just "button".
const closeBtn = createElement("button", {
class: "modal-close",
type: "button",
"aria-label": "Close",
});
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
@@ -188,8 +194,25 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo
{ signal: ac.signal },
);
// Escape cancels — never creates. Document-level so it works wherever
// focus sits; guarded on the overlay still being attached because the
// listener lives until destroy() aborts it.
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape" && overlay?.isConnected === true) {
onClose();
}
},
{ signal: ac.signal },
);
container.appendChild(overlay);
// Capture where focus came from before anything inside the dialog takes
// it, so destroy() can hand it back to the opener.
restoreFocus = focusDialog(modal);
// Focus the name input
nameInput.focus();
}
@@ -200,6 +223,11 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo
overlay.remove();
overlay = null;
}
// Every close path (X, Cancel, backdrop, Escape) funnels through the
// caller's onClose, which calls destroy() — the single place focus
// returns to the opener.
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
@@ -3,6 +3,7 @@
* Shows channel name and requires explicit confirmation.
*/
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
@@ -18,6 +19,7 @@ export function createDeleteChannelModal(options: DeleteChannelModalOptions): Mo
const { channelName, onConfirm, onClose } = options;
const ac = new AbortController();
let overlay: HTMLDivElement | null = null;
let restoreFocus: (() => void) | null = null;
function mount(container: Element): void {
overlay = createElement("div", {
@@ -26,13 +28,17 @@ export function createDeleteChannelModal(options: DeleteChannelModalOptions): Mo
});
const modal = createElement("div", { class: "modal" });
applyDialogSemantics(modal, { labelledBy: "delete-channel-title" });
trapFocus(modal, ac.signal);
// Header
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", {}, "Delete Channel");
const title = createElement("h3", { id: "delete-channel-title" }, "Delete Channel");
// Icon-only button: without a label a screen reader announces just "button".
const closeBtn = createElement("button", {
class: "modal-close",
type: "button",
"aria-label": "Close",
});
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
@@ -109,7 +115,24 @@ export function createDeleteChannelModal(options: DeleteChannelModalOptions): Mo
{ signal: ac.signal },
);
// Escape cancels — it must never stand in for the destructive confirm.
// Document-level so it works wherever focus sits; guarded on the overlay
// still being attached because the listener lives until destroy() aborts it.
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape" && overlay?.isConnected === true) {
onClose();
}
},
{ signal: ac.signal },
);
container.appendChild(overlay);
// Move focus in (lands on the header's close button, safely away from the
// destructive confirm) and remember the opener for destroy() to restore.
restoreFocus = focusDialog(modal);
}
function destroy(): void {
@@ -118,6 +141,11 @@ export function createDeleteChannelModal(options: DeleteChannelModalOptions): Mo
overlay.remove();
overlay = null;
}
// Every close path (X, Cancel, backdrop, Escape) funnels through the
// caller's onClose, which calls destroy() — the single place focus
// returns to the opener.
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
@@ -13,7 +13,8 @@
import { createElement, appendChildren, setText } from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
import type { UserStatus } from "@lib/types";
import { isSafeUrl } from "./message-list/attachments";
import { isRenderableAvatar } from "@lib/avatar";
import { fetchImageAsDataUrl, resolveServerUrl } from "./message-list/attachments";
// ---------------------------------------------------------------------------
// Types
@@ -31,6 +32,16 @@ export interface DmProfileData {
export interface DmProfileSidebarOptions {
readonly user: DmProfileData;
readonly onClose: () => void;
/**
* The connected server's host, used to scope the note's localStorage key.
* User ids are per-server, so without this a note about user 5 on one
* server is shown for, and overwritten by, the unrelated user 5 on
* another — real in the multi-profile client (see profiles.ts). Optional,
* and falls back to the legacy unscoped key, so a caller that has not
* been updated to pass it yet keeps today's single-profile behavior
* exactly (including any note already saved under the old key).
*/
readonly host?: string;
}
export type DmProfileSidebarComponent = MountableComponent & {
@@ -67,17 +78,34 @@ const STATUS_LABELS: Readonly<Record<UserStatus, string>> = {
// Helpers
// ---------------------------------------------------------------------------
function loadNote(userId: number): string {
/** The legacy unscoped key, from before per-server notes (or when the caller
* has not yet been updated to pass a host). */
function legacyNoteKey(userId: number): string {
return NOTE_STORAGE_PREFIX + String(userId);
}
function scopedNoteKey(userId: number, host: string): string {
return `${NOTE_STORAGE_PREFIX}${host}:${userId}`;
}
function loadNote(userId: number, host: string): string {
try {
return localStorage.getItem(NOTE_STORAGE_PREFIX + String(userId)) ?? "";
if (host !== "") {
const scoped = localStorage.getItem(scopedNoteKey(userId, host));
if (scoped !== null) return scoped;
}
// Fall back to the legacy key so a note saved before per-server scoping
// (or while the host was unknown) is not silently lost.
return localStorage.getItem(legacyNoteKey(userId)) ?? "";
} catch {
return "";
}
}
function saveNote(userId: number, text: string): void {
function saveNote(userId: number, host: string, text: string): void {
try {
localStorage.setItem(NOTE_STORAGE_PREFIX + String(userId), text);
const key = host !== "" ? scopedNoteKey(userId, host) : legacyNoteKey(userId);
localStorage.setItem(key, text);
} catch {
// localStorage may be unavailable or full -- silently ignore
}
@@ -92,7 +120,7 @@ export function createDmProfileSidebar(
): DmProfileSidebarComponent {
const ac = new AbortController();
const { signal } = ac;
const { user, onClose } = options;
const { user, onClose, host = "" } = options;
let panel: HTMLDivElement | null = null;
let open = false;
@@ -119,22 +147,31 @@ export function createDmProfileSidebar(
wrapper.style.position = "relative";
wrapper.style.flexShrink = "0";
if (user.avatar !== null && user.avatar.length > 0 && isSafeUrl(user.avatar)) {
wrapper.style.background = "transparent";
const img = createElement("img", {
src: user.avatar,
alt: user.username,
class: "dps-avatar-img",
// The letter draws immediately; the picture (if any) is fetched through
// the same cert-pinned, bearer-token path attachments use and swapped in
// once the bytes arrive. `<img src>` cannot carry the auth header an
// `/api/v1/files/{id}` avatar needs, so the URL is never assigned raw.
wrapper.style.background = "var(--accent, #5865f2)";
const initial = user.username.charAt(0).toUpperCase() || "?";
const letter = createElement("span", {}, initial);
wrapper.appendChild(letter);
if (isRenderableAvatar(user.avatar)) {
const resolved = resolveServerUrl(user.avatar);
void fetchImageAsDataUrl(resolved).then((dataUrl) => {
if (dataUrl === null || !wrapper.isConnected) return;
const img = createElement("img", {
src: dataUrl,
alt: user.username,
class: "dps-avatar-img",
});
img.style.width = "80px";
img.style.height = "80px";
img.style.borderRadius = "50%";
letter.remove();
wrapper.style.background = "transparent";
wrapper.insertBefore(img, wrapper.firstChild);
});
img.style.width = "80px";
img.style.height = "80px";
img.style.borderRadius = "50%";
wrapper.appendChild(img);
} else {
wrapper.style.background = "var(--accent, #5865f2)";
const initial = user.username.charAt(0).toUpperCase() || "?";
const text = createElement("span", {}, initial);
wrapper.appendChild(text);
}
// Status dot overlay
@@ -328,12 +365,12 @@ export function createDmProfileSidebar(
noteInput.style.fontSize = "13px";
noteInput.style.padding = "8px";
noteInput.style.fontFamily = "inherit";
noteInput.value = loadNote(user.id);
noteInput.value = loadNote(user.id, host);
noteInput.addEventListener(
"input",
() => {
saveNote(user.id, noteInput.value);
saveNote(user.id, host, noteInput.value);
},
{ signal },
);
@@ -16,7 +16,8 @@ import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import { showContextMenu } from "@lib/context-menu";
import type { MountableComponent } from "@lib/safe-render";
import { isSafeUrl } from "./message-list/attachments";
import { isRenderableAvatar } from "@lib/avatar";
import { fetchImageAsDataUrl, resolveServerUrl } from "./message-list/attachments";
/** One member of a group DM, as far as the sidebar needs to draw them. */
export interface DmParticipant {
@@ -74,17 +75,26 @@ const STATUS_COLORS: Record<string, string> = {
offline: "var(--text-micro)",
};
/** Fill one avatar circle: the picture if it is safe to load, else the letter. */
/**
* Fill one avatar circle: the letter immediately, the picture swapped in once
* fetched. `<img src>` cannot carry the bearer token an authenticated
* `/api/v1/files/{id}` avatar needs, so the URL is always fetched through the
* same cert-pinned path attachments and custom emoji use rather than assigned
* directly.
*/
function paintAvatar(el: HTMLElement, avatar: string | null, label: string): void {
if (avatar !== null && isSafeUrl(avatar)) {
const img = createElement("img", { src: avatar, alt: label });
setText(el, label.charAt(0).toUpperCase());
if (!isRenderableAvatar(avatar)) return;
const resolved = resolveServerUrl(avatar);
void fetchImageAsDataUrl(resolved).then((dataUrl) => {
if (dataUrl === null || !el.isConnected) return;
const img = createElement("img", { src: dataUrl, alt: label });
img.style.width = "100%";
img.style.height = "100%";
img.style.borderRadius = "50%";
el.textContent = "";
el.appendChild(img);
return;
}
setText(el, label.charAt(0).toUpperCase());
});
}
/**
@@ -318,6 +328,18 @@ export function createDmSidebar(options: DmSidebarOptions): MountableComponent {
const items = sorted.map((convo) => renderDmItem(convo, options, ac.signal));
searchInput.addEventListener(
"input",
() => {
const q = searchInput.value.trim().toLowerCase();
items.forEach((el, i) => {
const match = q === "" || sorted[i]!.username.toLowerCase().includes(q);
el.style.display = match ? "" : "none";
});
},
{ signal: ac.signal },
);
appendChildren(root, header, sectionLabel, ...items);
container.appendChild(root);
}
@@ -16,6 +16,7 @@
* shown as its own option rather than being silently rounded to a neighbour.
*/
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
@@ -159,6 +160,7 @@ export function createEditChannelModal(options: EditChannelModalOptions): Mounta
const isVoice = channelType === "voice";
const ac = new AbortController();
let overlay: HTMLDivElement | null = null;
let restoreFocus: (() => void) | null = null;
function mount(container: Element): void {
overlay = createElement("div", {
@@ -167,13 +169,17 @@ export function createEditChannelModal(options: EditChannelModalOptions): Mounta
});
const modal = createElement("div", { class: "modal" });
applyDialogSemantics(modal, { labelledBy: "edit-channel-title" });
trapFocus(modal, ac.signal);
// Header
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", {}, "Edit Channel");
const title = createElement("h3", { id: "edit-channel-title" }, "Edit Channel");
// Icon-only button: without a label a screen reader announces just "button".
const closeBtn = createElement("button", {
class: "modal-close",
type: "button",
"aria-label": "Close",
});
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
@@ -395,7 +401,25 @@ export function createEditChannelModal(options: EditChannelModalOptions): Mounta
{ signal: ac.signal },
);
// Escape cancels — never saves. Document-level so it works wherever focus
// sits; guarded on the overlay still being attached because the listener
// lives until destroy() aborts it.
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape" && overlay?.isConnected === true) {
onClose();
}
},
{ signal: ac.signal },
);
container.appendChild(overlay);
// Capture where focus came from before anything inside the dialog takes
// it, so destroy() can hand it back to the opener.
restoreFocus = focusDialog(modal);
nameInput.focus();
nameInput.select();
}
@@ -406,6 +430,11 @@ export function createEditChannelModal(options: EditChannelModalOptions): Mounta
overlay.remove();
overlay = null;
}
// Every close path (X, Cancel, backdrop, Escape) funnels through the
// caller's onClose, which calls destroy() — the single place focus
// returns to the opener.
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
@@ -50,6 +50,11 @@ export interface EmojiAutocompleteOptions {
/** Called with the text to insert (`:wave:` or a unicode character). */
readonly onSelect: (insert: string) => void;
readonly onClose: () => void;
/**
* Composer textarea the popup completes for; carries combobox semantics and
* aria-activedescendant while the popup is open (see inline-autocomplete).
*/
readonly comboboxInput?: HTMLElement;
}
/** Same shape as the shared inline-autocomplete widget. */
@@ -153,5 +158,6 @@ export function createEmojiAutocomplete(
// MIN_EMOJI_QUERY, so there is nothing to prime on create.
onSelect: options.onSelect,
onClose: options.onClose,
comboboxInput: options.comboboxInput,
});
}
@@ -2,6 +2,7 @@
// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
import { createElement, setText, clearChildren } from "@lib/dom";
import { enableRovingNavigation, setRovingTabindex } from "@lib/a11y";
import { buildCustomEmojiNode } from "@components/message-list/custom-emoji";
// ---------------------------------------------------------------------------
@@ -559,11 +560,16 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
header.appendChild(searchInput);
root.appendChild(header);
// Scrollable content area (holds category labels + grids)
// Scrollable content area (holds category labels + grids). Announced as a
// single flat listbox — the category grids are visual grouping only, and
// roving tabindex (DC-13) treats every .ep-emoji cell as one list.
const scrollArea = createElement("div", {
style: "overflow-y: auto; max-height: 320px;",
role: "listbox",
"aria-label": "Emoji",
});
root.appendChild(scrollArea);
enableRovingNavigation(scrollArea, ".ep-emoji", signal);
// Build categories with recent + custom
function getAllCategories(): readonly EmojiCategory[] {
@@ -596,6 +602,10 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
const span = createElement("span", {
class: "ep-emoji",
title: emoji,
role: "option",
// Mirrors the title (the character or :shortcode: token) — e2e specs
// select cells by title, so the accessible name must never diverge.
"aria-label": emoji,
});
// A `:shortcode:` entry shows its image; everything else is the character
// itself. An unresolvable shortcode falls back to the text, which is what
@@ -652,6 +662,10 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
);
scrollArea.appendChild(empty);
}
// Every render rebuilds the cell set, so the single Tab stop must be
// re-established or filtering would leave zero tabbable cells.
setRovingTabindex(scrollArea, ".ep-emoji");
}
// Initial render
@@ -1,231 +0,0 @@
// Step 8.59 — File upload component with drag-and-drop, preview, and progress.
// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
/** Default allowed MIME types for file uploads. */
const DEFAULT_ALLOWED_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/avif",
"video/mp4",
"video/webm",
"audio/mpeg",
"audio/ogg",
"audio/wav",
"application/pdf",
"text/plain",
];
export interface FileUploadOptions {
readonly onUpload: (file: File) => Promise<void>;
readonly maxSizeMb?: number;
readonly allowedMimeTypes?: readonly string[];
}
const DEFAULT_MAX_SIZE_MB = 10;
export type FileUploadComponent = MountableComponent & { openPicker(): void };
export function createFileUpload(options: FileUploadOptions): FileUploadComponent {
const maxBytes = (options.maxSizeMb ?? DEFAULT_MAX_SIZE_MB) * 1024 * 1024;
const ac = new AbortController();
const signal = ac.signal;
let root: HTMLDivElement | null = null;
let dropzone: HTMLDivElement;
let fileInput: HTMLInputElement;
let preview: HTMLDivElement;
let thumb: HTMLImageElement;
let nameSpan: HTMLSpanElement;
let sizeSpan: HTMLSpanElement;
let progressBar: HTMLDivElement;
let cancelBtn: HTMLButtonElement;
let errorDiv: HTMLDivElement;
let uploadAbort: AbortController | null = null;
// oxlint-disable-next-line consistent-function-scoping -- co-located with its sole caller for readability
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function showError(message: string): void {
setText(errorDiv, message);
errorDiv.classList.remove("file-upload__error--hidden");
preview.classList.add("file-upload__preview--hidden");
}
function resetPreview(): void {
preview.classList.add("file-upload__preview--hidden");
thumb.src = "";
thumb.style.display = "none";
setText(nameSpan, "");
setText(sizeSpan, "");
progressBar.style.width = "0%";
uploadAbort = null;
errorDiv.classList.add("file-upload__error--hidden");
}
function showPreview(file: File): void {
resetPreview();
setText(nameSpan, file.name);
setText(sizeSpan, formatSize(file.size));
if (file.type.startsWith("image/")) {
const url = URL.createObjectURL(file);
thumb.src = url;
thumb.style.display = "block";
thumb.addEventListener("load", () => URL.revokeObjectURL(url));
}
preview.classList.remove("file-upload__preview--hidden");
}
async function handleFile(file: File): Promise<void> {
errorDiv.classList.add("file-upload__error--hidden");
const allowed = options.allowedMimeTypes ?? DEFAULT_ALLOWED_TYPES;
if (file.type && !allowed.includes(file.type)) {
showError(`File type "${file.type}" is not allowed.`);
return;
}
if (file.size > maxBytes) {
showError(
`File too large (${formatSize(file.size)}). Max ${options.maxSizeMb ?? DEFAULT_MAX_SIZE_MB} MB.`,
);
return;
}
showPreview(file);
uploadAbort = new AbortController();
try {
progressBar.style.width = "50%";
await options.onUpload(file);
progressBar.style.width = "100%";
setTimeout(() => resetPreview(), 1500);
} catch (err) {
if (uploadAbort?.signal.aborted) return;
showError(err instanceof Error ? err.message : "Upload failed");
resetPreview();
}
}
function buildDom(): void {
root = createElement("div", { class: "file-upload" });
dropzone = createElement("div", {
class: "file-upload__dropzone file-upload__dropzone--hidden",
});
appendChildren(
dropzone,
createElement("span", { class: "file-upload__droptext" }, "Drop files here"),
);
const allowed = options.allowedMimeTypes ?? DEFAULT_ALLOWED_TYPES;
fileInput = createElement("input", {
class: "file-upload__input",
type: "file",
accept: allowed.join(","),
});
fileInput.style.display = "none";
preview = createElement("div", { class: "file-upload__preview file-upload__preview--hidden" });
thumb = createElement("img", { class: "file-upload__thumb" });
thumb.style.display = "none";
thumb.alt = "";
nameSpan = createElement("span", { class: "file-upload__name" });
sizeSpan = createElement("span", { class: "file-upload__size" });
const progressContainer = createElement("div", { class: "file-upload__progress" });
progressBar = createElement("div", { class: "file-upload__progress-bar" });
progressBar.style.width = "0%";
appendChildren(progressContainer, progressBar);
cancelBtn = createElement("button", { class: "file-upload__cancel", type: "button" });
cancelBtn.appendChild(createIcon("x", 14));
appendChildren(preview, thumb, nameSpan, sizeSpan, progressContainer, cancelBtn);
errorDiv = createElement("div", { class: "file-upload__error file-upload__error--hidden" });
appendChildren(root, dropzone, fileInput, preview, errorDiv);
}
function attachListeners(): void {
fileInput.addEventListener(
"change",
() => {
const file = fileInput.files?.[0];
if (file) {
void handleFile(file);
fileInput.value = "";
}
},
{ signal },
);
cancelBtn.addEventListener(
"click",
() => {
if (uploadAbort !== null) uploadAbort.abort();
resetPreview();
},
{ signal },
);
let dragCounter = 0;
root!.addEventListener(
"dragenter",
(e) => {
e.preventDefault();
dragCounter++;
dropzone.classList.remove("file-upload__dropzone--hidden");
},
{ signal },
);
root!.addEventListener(
"dragleave",
(e) => {
e.preventDefault();
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
dropzone.classList.add("file-upload__dropzone--hidden");
}
},
{ signal },
);
root!.addEventListener("dragover", (e) => e.preventDefault(), { signal });
root!.addEventListener(
"drop",
(e) => {
e.preventDefault();
dragCounter = 0;
dropzone.classList.add("file-upload__dropzone--hidden");
const file = e.dataTransfer?.files[0];
if (file) void handleFile(file);
},
{ signal },
);
}
function mount(container: Element): void {
buildDom();
attachListeners();
container.appendChild(root!);
}
function destroy(): void {
ac.abort();
if (uploadAbort !== null) uploadAbort.abort();
root?.remove();
root = null;
}
function openPicker(): void {
fileInput.click();
}
return { mount, destroy, openPicker };
}
@@ -3,6 +3,7 @@
// innerHTML with user content.
import { createElement, setText, clearChildren } from "@lib/dom";
import { enableRovingNavigation, setRovingTabindex } from "@lib/a11y";
import { ApiClientError } from "@lib/api";
import { searchGifs, getTrendingGifs } from "@lib/gifProvider";
import type { GifApi, GifResult } from "@lib/gifProvider";
@@ -67,9 +68,15 @@ export function createGifPicker(options: GifPickerOptions): {
root.appendChild(header);
// Grid area (scrollable)
const gridArea = createElement("div", { class: "gp-grid-area" });
// Grid area (scrollable). Announced as a flat listbox of GIF options with
// roving tabindex (DC-13); the inner .gp-grid is layout only.
const gridArea = createElement("div", {
class: "gp-grid-area",
role: "listbox",
"aria-label": "GIFs",
});
root.appendChild(gridArea);
enableRovingNavigation(gridArea, ".gp-item", signal);
// Loading indicator
const loadingEl = createElement("div", { class: "gp-loading" });
@@ -92,7 +99,13 @@ export function createGifPicker(options: GifPickerOptions): {
const grid = createElement("div", { class: "gp-grid" });
for (const gif of gifs) {
const item = createElement("div", { class: "gp-item" });
const item = createElement("div", {
class: "gp-item",
role: "option",
// Same fallback as the img alt below — an untitled GIF still needs a
// pronounceable accessible name.
"aria-label": gif.title || "GIF",
});
const img = createElement("img", {
class: "gp-img",
src: gif.url,
@@ -114,6 +127,9 @@ export function createGifPicker(options: GifPickerOptions): {
}
gridArea.appendChild(grid);
// Each render replaces the cell set, so re-establish the single Tab stop.
setRovingTabindex(gridArea, ".gp-item");
}
function showLoading(): void {
@@ -3,6 +3,7 @@
* Create, copy, and revoke invite codes.
*/
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
import { createElement, appendChildren, clearChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
@@ -56,6 +57,7 @@ export function createInviteManager(options: InviteManagerOptions): MountableCom
let root: HTMLDivElement | null = null;
let listEl: HTMLDivElement | null = null;
let emptyEl: HTMLDivElement | null = null;
let restoreFocus: (() => void) | null = null;
let invites: readonly InviteItem[] = options.invites;
function renderList(): void {
@@ -161,11 +163,14 @@ export function createInviteManager(options: InviteManagerOptions): MountableCom
const modal = createElement("div", {
class: "modal",
});
applyDialogSemantics(modal, { labelledBy: "invite-manager-title" });
trapFocus(modal, ac.signal);
// Header
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", {}, "Server Invites");
const closeBtn = createElement("button", { class: "modal-close" });
const title = createElement("h3", { id: "invite-manager-title" }, "Server Invites");
// Icon-only button: without a label a screen reader announces just "button".
const closeBtn = createElement("button", { class: "modal-close", "aria-label": "Close" });
closeBtn.appendChild(createIcon("x", 14));
closeBtn.addEventListener("click", () => options.onClose(), { signal: ac.signal });
appendChildren(header, title, closeBtn);
@@ -236,6 +241,10 @@ export function createInviteManager(options: InviteManagerOptions): MountableCom
renderList();
container.appendChild(root);
// Capture where focus came from before anything inside the dialog takes
// it, so destroy() can hand it back to the opener.
restoreFocus = focusDialog(modal);
}
function destroy(): void {
@@ -246,6 +255,10 @@ export function createInviteManager(options: InviteManagerOptions): MountableCom
}
listEl = null;
emptyEl = null;
// Every close path (X, backdrop, Escape) funnels through the caller's
// onClose, which calls destroy() — the single place focus returns.
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
@@ -272,7 +272,11 @@ function createMemberItem(
// Moderation actions are permission-gated per item (a role name told us
// nothing about what its bits allow); block/unblock is open to everyone.
const gates = moderationGates(opts.currentUserRole);
// The role name is read live from authStore, not the opts snapshot
// taken once at mount -- dispatcher.ts keeps authStore.user.role
// current on every self MEMBER_UPDATE precisely so gates like this one
// see a promotion/demotion without waiting for the sidebar to rebuild.
const gates = moderationGates(authStore.getState().user?.role ?? opts.currentUserRole);
const showAdminActions = gates.canKick || gates.canBan || gates.canManageRoles;
closeActiveMenu();
@@ -32,6 +32,11 @@ export interface MentionAutocompleteOptions {
/** Called with the token to insert (without the leading "@"). */
readonly onSelect: (token: string) => void;
readonly onClose: () => void;
/**
* Composer textarea the popup completes for; carries combobox semantics and
* aria-activedescendant while the popup is open (see inline-autocomplete).
*/
readonly comboboxInput?: HTMLElement;
}
/** Same shape as the shared inline-autocomplete widget. */
@@ -55,6 +60,10 @@ export function filterMentionSuggestions(query: string): MentionSuggestion[] {
const substring: MentionSuggestion[] = [];
for (const member of membersStore.getState().members.values()) {
// Skip usernames the mention grammar cannot express (a space, an "@",
// etc. truncate the token on insert) -- picking one would insert a dead
// token that resolves to no mention and notifies nobody.
if (!/^[\p{L}\p{N}_.-]{1,64}$/u.test(member.username)) continue;
const lower = member.username.toLowerCase();
if (q !== "" && !lower.includes(q)) continue;
const entry: MentionSuggestion = {
@@ -121,5 +130,6 @@ export function createMentionAutocomplete(
primeOnCreate: true,
onSelect: options.onSelect,
onClose: options.onClose,
comboboxInput: options.comboboxInput,
});
}
@@ -90,7 +90,16 @@ export function wrapWithMarker(
const len = marker.length;
// Already wrapped — pressing the shortcut again takes the markers back off.
if (selected.length > 2 * len && selected.startsWith(marker) && selected.endsWith(marker)) {
// The interior must not itself contain the marker: otherwise a selection
// that merely starts and ends with it (e.g. multiple already-wrapped spans,
// or a longer marker like "**" matching the outer edge of "*x*") would be
// mistaken for a single wrapped span and have its interior markers stripped.
if (
selected.length > 2 * len &&
selected.startsWith(marker) &&
selected.endsWith(marker) &&
!selected.slice(len, selected.length - len).includes(marker)
) {
const inner = selected.slice(len, selected.length - len);
return {
value: value.slice(0, start) + inner + value.slice(end),
@@ -128,6 +137,22 @@ const ALLOWED_TYPES = [
"application/json",
];
/**
* Keys that move the caret without an open autocomplete popup claiming them,
* so the popup has to be resynced against the new caret on keyup. The popup's
* own keys are deliberately absent: it consumes ArrowUp/ArrowDown/Enter/Tab
* (so the caret does not move) and Escape closes it, and resyncing after any
* of those would reset the highlighted row or reopen what Escape dismissed.
*/
const CARET_MOVE_KEYS: ReadonlySet<string> = new Set([
"ArrowLeft",
"ArrowRight",
"Home",
"End",
"PageUp",
"PageDown",
]);
/** Disable the GIF button and say why, instead of silently doing nothing. */
function markGifUnavailable(gifBtn: HTMLButtonElement, reason: string): void {
gifBtn.setAttribute("disabled", "true");
@@ -198,7 +223,13 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
/** Replace the token under the caret with "@token ". */
function insertMention(token: string): void {
if (textarea === null || mentionStart < 0) {
// The popup can outlive the token it was opened over: a caret move the
// composer never observed (Ctrl+A, a programmatic selection) leaves
// mentionStart pointing at an offset the caret no longer follows, and
// splicing there garbles the draft instead of completing it. Re-derive
// the token and only commit while it still starts where the popup thinks.
const active = activeMentionToken();
if (textarea === null || mentionStart < 0 || active === null || active.start !== mentionStart) {
closeMentionPopup();
return;
}
@@ -242,7 +273,10 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
/** Replace the `:token` under the caret with the chosen emoji, plus a space. */
function insertEmoji(insert: string): void {
if (textarea === null || emojiStart < 0) {
// Same staleness guard as insertMention: never splice at an anchor the
// caret has since moved away from.
const active = activeEmojiToken();
if (textarea === null || emojiStart < 0 || active === null || active.start !== emojiStart) {
closeEmojiPopup();
return;
}
@@ -270,6 +304,9 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
emojiPopup = createEmojiAutocomplete({
onSelect: insertEmoji,
onClose: closeEmojiPopup,
// The popup manages combobox/aria-activedescendant state on the
// textarea for as long as it is open.
comboboxInput: textarea ?? undefined,
});
root?.appendChild(emojiPopup.element);
}
@@ -306,6 +343,9 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
mentionPopup = createMentionAutocomplete({
onSelect: insertMention,
onClose: closeMentionPopup,
// The popup manages combobox/aria-activedescendant state on the
// textarea for as long as it is open.
comboboxInput: textarea ?? undefined,
});
root?.appendChild(mentionPopup.element);
}
@@ -378,10 +418,21 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
},
message,
);
// app.css only shows the preview bar via .visible -- without this an
// error with no attachments already queued renders into a display:none
// container and is never seen.
attachmentPreviewBar.classList.add("visible");
attachmentPreviewBar.appendChild(errEl);
const t = setTimeout(() => {
activeTimers.delete(t);
errEl.remove();
if (
attachmentPreviewBar !== null &&
pendingAttachments.length === 0 &&
attachmentPreviewBar.childElementCount === 0
) {
attachmentPreviewBar.classList.remove("visible");
}
}, 4000);
activeTimers.add(t);
}
@@ -452,8 +503,8 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
/** Unique counter for preview items (before upload completes and we have a server ID). */
let previewCounter = 0;
function removePreviewItem(tempId: string): void {
const idx = pendingAttachments.findIndex((a) => a.id === tempId);
function removePreviewItem(el: HTMLDivElement): void {
const idx = pendingAttachments.findIndex((a) => a.previewEl === el);
const att = idx !== -1 ? pendingAttachments[idx] : undefined;
if (att !== undefined) {
const img = att.previewEl.querySelector("img");
@@ -482,6 +533,14 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
async function handlePasteFile(file: File): Promise<void> {
if (options.onUploadFile === undefined || attachmentPreviewBar === null) return;
// Attachments queued during an edit are neither sent (the edit branch
// never reads pendingAttachments) nor cleared -- they'd silently ride
// along with the next ordinary message. Refuse at the single entry point.
if (state.editing !== null) {
showUploadError("Can't attach files while editing a message");
return;
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
showUploadError(`File too large: ${file.name} exceeds 100 MB limit`);
@@ -540,7 +599,7 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
"click",
(e) => {
e.stopPropagation();
removePreviewItem(tempId);
removePreviewItem(item);
},
{ signal },
);
@@ -566,7 +625,7 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
}
} catch (err) {
// Upload failed — remove preview and show error
removePreviewItem(tempId);
removePreviewItem(item);
const errMsg = err instanceof Error ? err.message : "Upload failed";
showUploadError(`Upload failed: ${errMsg}`);
} finally {
@@ -575,7 +634,9 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
}
function setReplyTo(messageId: number, username: string): void {
if (state.editing !== null) hideEditBar();
// cancelEdit also clears the textarea -- without it the stale edit text
// survives into reply mode and Enter reposts it as a duplicate.
if (state.editing !== null) cancelEdit();
state = { replyTo: { messageId, username }, editing: null };
showReplyBar(username);
textarea?.focus();
@@ -643,7 +704,7 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
const fileInput = createElement("input", {
type: "file",
style: "display: none;",
accept: "image/*,video/*,audio/*,.pdf,.txt,.zip,.rar,.7z",
accept: "image/*,video/*,audio/*,.pdf,.txt,.zip",
});
fileInput.addEventListener(
"change",
@@ -764,8 +825,17 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
{ signal },
);
// Caret moves that aren't typing (click, blur) also decide the popup's fate.
// Caret moves that aren't typing (click, arrow/Home/End keys, blur) also
// decide the popup's fate — without this, completing a mention/emoji
// after moving the caret away with the keyboard splices at a stale offset.
textarea.addEventListener("click", syncAutocomplete, { signal });
textarea.addEventListener(
"keyup",
(e: KeyboardEvent) => {
if (CARET_MOVE_KEYS.has(e.key)) syncAutocomplete();
},
{ signal },
);
textarea.addEventListener(
"blur",
() => {
@@ -881,9 +951,20 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
markGifUnavailable(gifBtn, reason);
},
onSelect: (gifUrl: string) => {
if (textarea !== null) {
textarea.value = gifUrl;
handleSend();
// Send the GIF directly instead of routing it through the textarea
// (handleSend's read of textarea.value): that overwrote — and
// discarded — whatever draft the user had typed, and on slow
// mode / mid-upload / debounced sends left the raw GIF URL sitting
// in the composer instead of the draft. Guarded by the same
// disabledReason/debounce checks as a normal send; an in-progress
// edit and any typed draft are left untouched.
if (disabledReason === null) {
const now = Date.now();
if (now - lastSendTime >= SEND_DEBOUNCE_MS) {
lastSendTime = now;
options.onSend(gifUrl, state.replyTo?.messageId ?? null, []);
clearReply();
}
}
closeGifPicker();
},
@@ -36,7 +36,9 @@ export interface MessageListOptions {
readonly channelName: string;
readonly channelType?: string;
readonly currentUserId: number;
readonly onScrollTop: () => void;
/** May return a promise (e.g. the underlying fetch); MessageList clears its
* loadingOlder latch once it settles, success or failure. */
readonly onScrollTop: () => void | Promise<void>;
readonly onReplyClick: (messageId: number) => void;
readonly onEditClick: (messageId: number) => void;
readonly onDeleteClick: (messageId: number) => void;
@@ -156,7 +158,11 @@ function buildVirtualItems(
// A message directly under the NEW line starts a fresh block: rendering it
// as a grouped continuation of a message from before the line hides both
// its author and the fact that the line is there.
const isGrouped = !isFirstUnread && prevMsg !== null && shouldGroup(prevMsg, msg);
const isGrouped =
!isFirstUnread &&
prevMsg !== null &&
isSameDay(prevMsg.timestamp, msg.timestamp) &&
shouldGroup(prevMsg, msg);
items.push({ kind: "message", message: msg, isGrouped });
lastTimestamp = msg.timestamp;
prevMsg = msg;
@@ -266,6 +272,36 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
*/
const unreadOnOpen = isWindowDetached(options.channelId) ? 0 : getUnreadOnOpen(options.channelId);
/**
* Message id the NEW divider is anchored to, once one has been picked.
* `firstUnreadIndex` returns a count-from-the-end offset, which drifts
* whenever the loaded window grows (new messages arrive) between one full
* rebuild and the next — the exact thing unreadOnOpen's doc comment above
* promises won't happen. Latching onto the message id the first valid index
* pointed at keeps the divider glued to that message for the rest of the
* visit regardless of how the window grows around it.
*/
let newDividerAnchorId: number | null = null;
/**
* Resolve the NEW divider's position for this rebuild. Prefers the latched
* anchor id (stable across window growth); falls back to the count formula
* only until an anchor exists, then latches it — skipping id 0 (an
* unconfirmed optimistic row) since that id is not unique across pending
* sends and would anchor to the wrong message once reconciled.
*/
function resolveNewDividerIndex(messages: readonly Message[]): number {
if (newDividerAnchorId !== null) {
return messages.findIndex((m) => m.id === newDividerAnchorId);
}
const idx = firstUnreadIndex(messages, unreadOnOpen);
const anchor = idx !== -1 ? messages[idx] : undefined;
if (anchor !== undefined && anchor.id !== 0) {
newDividerAnchorId = anchor.id;
}
return idx;
}
// ---------------------------------------------------------------------------
// Height estimation (Fenwick tree backed)
// ---------------------------------------------------------------------------
@@ -512,12 +548,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
function rebuildItems(): void {
allMessages = getChannelMessages(options.channelId);
virtualItems = buildVirtualItems(
allMessages,
null,
null,
firstUnreadIndex(allMessages, unreadOnOpen),
);
virtualItems = buildVirtualItems(allMessages, null, null, resolveNewDividerIndex(allMessages));
// Build Fenwick tree initialized with smart estimates / cached heights
tree = new FenwickTree(virtualItems.length);
@@ -683,14 +714,22 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
// ---------------------------------------------------------------------------
let loadingOlder = false;
let prevMessageCount = 0;
// The oldest loaded message's id, not the count: a live tail append also
// changes the count while a history fetch is still in flight, and
// resetting the latch on that lets the next scroll refire loadOlderMessages
// with the same unchanged cursor -- the same page then lands twice. Only a
// prepend moves messages[0]. Seeded from the current state (not left at a
// placeholder) so the first change observed after construction is compared
// against reality, not an arbitrary initial value.
let prevOldestId: number | null = getChannelMessages(options.channelId)[0]?.id ?? null;
const unsubLoadingReset = messagesStore.subscribeSelector(
(s) => s.messagesByChannel,
() => {
const msgs = getChannelMessages(options.channelId);
if (msgs.length !== prevMessageCount) {
prevMessageCount = msgs.length;
const oldestId = msgs.length > 0 ? msgs[0]!.id : null;
if (oldestId !== prevOldestId) {
prevOldestId = oldestId;
loadingOlder = false;
}
},
@@ -710,7 +749,14 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
hasMoreMessages(options.channelId)
) {
loadingOlder = true;
options.onScrollTop();
// A failed fetch never changes the message count, so the subscriber
// below (which only reacts to a count change) would leave loadingOlder
// latched forever. Clear it once the load settles either way — the
// subscriber's reset still applies to the success path but is now just
// belt-and-braces.
void Promise.resolve(options.onScrollTop()).finally(() => {
loadingOlder = false;
});
}
// Update floating scroll-to-bottom button visibility
@@ -61,11 +61,17 @@ function renderPinnedItem(
// Hover actions
const actions = createElement("div", { class: "pinned-msg__actions" });
const jumpBtn = createElement("button", { title: "Jump to message" });
// Icon-only buttons: title= only tooltips for mouse users, so mirror it as
// an aria-label for screen readers.
const jumpBtn = createElement("button", {
title: "Jump to message",
"aria-label": "Jump to message",
});
jumpBtn.appendChild(createIcon("external-link", 14));
const unpinBtn = createElement("button", {
class: "pinned-msg__unpin",
title: "Unpin message",
"aria-label": "Unpin message",
});
unpinBtn.appendChild(createIcon("x", 14));
@@ -94,7 +100,13 @@ export function createPinnedMessages(options: PinnedMessagesOptions): MountableC
let root: HTMLDivElement | null = null;
function mount(container: Element): void {
root = createElement("div", { class: "pinned-panel" });
// A side panel, not a modal: complementary landmark (no aria-modal, no
// focus trap), matching DmProfileSidebar.
root = createElement("div", {
class: "pinned-panel",
role: "complementary",
"aria-label": "Pinned messages",
});
// Header
const header = createElement("div", { class: "pinned-panel__header" });
@@ -106,7 +118,10 @@ export function createPinnedMessages(options: PinnedMessagesOptions): MountableC
const count = createElement("span", { class: "pinned-panel__count" });
count.textContent = String(options.pinnedMessages.length);
const closeBtn = createElement("button", { class: "pinned-panel__close" });
const closeBtn = createElement("button", {
class: "pinned-panel__close",
"aria-label": "Close pinned messages",
});
closeBtn.appendChild(createIcon("x", 16));
closeBtn.addEventListener("click", () => options.onClose(), { signal: ac.signal });
@@ -4,6 +4,7 @@
* Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
*/
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
import { createElement, appendChildren, setText } from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
@@ -31,6 +32,7 @@ export interface QuickSwitchOverlayOptions {
export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): MountableComponent {
const ac = new AbortController();
let root: HTMLDivElement | null = null;
let restoreFocus: (() => void) | null = null;
function mount(container: Element): void {
root = createElement("div", {
@@ -48,6 +50,8 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
);
const modal = createElement("div", { class: "quick-switch-modal" });
applyDialogSemantics(modal, { label: "Switch server" });
trapFocus(modal, ac.signal);
// Header
const header = createElement("div", { class: "quick-switch-header" });
@@ -64,11 +68,18 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
for (const profile of options.profiles) {
const isCurrent = profile.host === options.currentHost;
const item = createElement("div", {
const attrs: Record<string, string> = {
class: `quick-switch-item${isCurrent ? " current" : ""}`,
"data-testid": "server-item",
"data-host": profile.host,
});
};
// Only actionable rows get button semantics — the connected row has no
// click handler, and a "button" that does nothing lies to screen readers.
if (!isCurrent) {
attrs["role"] = "button";
attrs["tabindex"] = "0";
}
const item = createElement("div", attrs);
const icon = createElement("div", { class: "quick-switch-icon" });
setText(icon, profile.name.charAt(0).toUpperCase());
@@ -94,6 +105,18 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
},
{ signal: ac.signal },
);
// Divs get no native key activation; Enter/Space mirrors the click
// so the row honors the button role it advertises.
item.addEventListener(
"keydown",
(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
options.onSwitch(profile.host, profile.name);
}
},
{ signal: ac.signal },
);
}
list.appendChild(item);
@@ -103,6 +126,8 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
const addItem = createElement("div", {
class: "quick-switch-item add-new",
"data-testid": "add-server-btn",
role: "button",
tabindex: "0",
});
const addIcon = createElement("div", { class: "quick-switch-icon add" }, "+");
const addInfo = createElement("div", { class: "quick-switch-info" });
@@ -115,6 +140,16 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
appendChildren(addInfo, addName, addHost);
appendChildren(addItem, addIcon, addInfo);
addItem.addEventListener("click", () => options.onAddServer(), { signal: ac.signal });
addItem.addEventListener(
"keydown",
(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
options.onAddServer();
}
},
{ signal: ac.signal },
);
list.appendChild(addItem);
// Footer
@@ -124,6 +159,10 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
root.appendChild(modal);
container.appendChild(root);
// Move focus onto the first actionable row (or the modal itself) and
// remember the opener — the UserBar switch button — for destroy().
restoreFocus = focusDialog(modal);
// Escape key closes overlay
document.addEventListener(
"keydown",
@@ -140,6 +179,10 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
root.remove();
root = null;
}
// Restore after removal so focus cannot land on a node inside the
// just-detached overlay.
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
@@ -1,6 +1,7 @@
// Step 8.60 — Quick switcher modal (Ctrl+K) for fast channel navigation.
// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
import { createElement, setText, appendChildren, clearChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import { channelsStore } from "@stores/channels.store";
@@ -22,6 +23,7 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
let activeIndex = 0;
let filteredChannels: readonly Channel[] = [];
let unsubscribe: (() => void) | null = null;
let restoreFocus: (() => void) | null = null;
function getChannelIcon(ch: Channel): SVGSVGElement {
return ch.type === "voice" ? createIcon("volume-2", 14) : createIcon("hash", 14);
@@ -29,7 +31,11 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
function getFilteredChannels(query: string): readonly Channel[] {
const state = channelsStore.getState();
const all = Array.from(state.channels.values());
// DM rows are synthesized into channelsStore once opened, but they have
// their own sidebar path (full clearDmUnread/setSidebarMode handling) —
// listing them here too would select via a bare setActiveChannel and
// leave their unread/mention badge lit forever.
const all = Array.from(state.channels.values()).filter((ch) => ch.type !== "dm");
const sorted = [...all].toSorted((a, b) => a.position - b.position);
if (query.length === 0) return sorted;
@@ -51,6 +57,12 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
? "quick-switcher__item quick-switcher__item--active"
: "quick-switcher__item",
"data-channelid": String(ch.id),
// Combobox option wiring: the id feeds aria-activedescendant so a
// screen reader tracks the roving --active highlight without the
// input ever losing DOM focus.
id: `qs-option-${i}`,
role: "option",
"aria-selected": isActive ? "true" : "false",
});
const icon = createElement("span", { class: "quick-switcher__icon" });
@@ -79,6 +91,16 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
resultsDiv.appendChild(item);
}
// Re-point aria-activedescendant on every render — arrow keys, filtering
// and store refreshes all funnel through here, so it can never go stale.
// An empty result set clears it; pointing at a missing id is worse than
// pointing at nothing.
if (filteredChannels.length > 0) {
input.setAttribute("aria-activedescendant", `qs-option-${activeIndex}`);
} else {
input.removeAttribute("aria-activedescendant");
}
}
function handleInput(): void {
@@ -154,21 +176,39 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
// Modal container
const modal = createElement("div", { class: "quick-switcher" });
applyDialogSemantics(modal, { label: "Quick switcher" });
trapFocus(modal, signal);
// Search input
// Search input — combobox over the results listbox: the input keeps DOM
// focus while aria-activedescendant (set in renderResults) names the row
// the arrow keys have highlighted. The list is always rendered, so
// aria-expanded is statically true.
input = createElement("input", {
class: "quick-switcher__input",
type: "text",
placeholder: "Where do you want to go?",
role: "combobox",
"aria-expanded": "true",
"aria-autocomplete": "list",
"aria-controls": "quick-switcher-results",
});
// Results list
resultsDiv = createElement("div", { class: "quick-switcher__results" });
resultsDiv = createElement("div", {
class: "quick-switcher__results",
id: "quick-switcher-results",
role: "listbox",
});
appendChildren(modal, input, resultsDiv);
root.appendChild(modal);
container.appendChild(root);
// Capture the opener before anything inside grabs focus — Ctrl+K comes
// from the composer, and a keyboard user needs destroy() to land them
// back there, not at the top of the document.
restoreFocus = focusDialog(modal);
// Initial render
filteredChannels = getFilteredChannels("");
renderResults();
@@ -194,6 +234,10 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
}
root?.remove();
root = null;
// Restore after the overlay is gone, so focus cannot land on a node the
// removal is about to detach.
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
@@ -1,48 +0,0 @@
/**
* ServerStrip component — vertical strip on the far left showing server icons.
* Single-server for now: Home button, separator, add server button.
*/
import { createElement, appendChildren } from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
export function createServerStrip(): MountableComponent {
const ac = new AbortController();
let root: HTMLDivElement | null = null;
function mount(container: Element): void {
root = createElement("div", { class: "server-strip", "data-testid": "server-strip" });
const homeIcon = createElement(
"div",
{ class: "server-icon active", style: "background: var(--accent)" },
"O",
);
const separator = createElement("div", { class: "server-separator" });
const addIcon = createElement("div", { class: "server-icon add" }, "+");
// Add server button click — placeholder for future multi-server support
addIcon.addEventListener(
"click",
() => {
// No-op for single-server mode
},
{ signal: ac.signal },
);
appendChildren(root, homeIcon, separator, addIcon);
container.appendChild(root);
}
function destroy(): void {
ac.abort();
if (root !== null) {
root.remove();
root = null;
}
}
return { mount, destroy };
}
@@ -4,6 +4,7 @@
* Subscribes to uiStore for settingsOpen state.
*/
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
import { createElement, appendChildren, clearChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { IconName } from "@lib/icons";
@@ -73,6 +74,11 @@ const TAB_ICONS: Record<TabName, IconName> = {
Logs: "scroll-text",
};
/** Stable DOM id for a tab button (aria-labelledby target), e.g. "settings-tab-text-images". */
function tabId(name: TabName): string {
return `settings-tab-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
@@ -83,11 +89,14 @@ export function createSettingsOverlay(
const ac = new AbortController();
const authenticated = options.isAuthenticated !== false;
let root: HTMLDivElement | null = null;
let panel: HTMLDivElement | null = null;
let contentArea: HTMLDivElement | null = null;
let pageTitle: HTMLHeadingElement | null = null;
let activeTab: TabName = authenticated ? "Account" : "Appearance";
/** False once the active tab's content has been torn down by `hide()`. */
let contentLive = false;
/** Puts focus back on whatever opened the panel; null while closed. */
let restoreFocus: (() => void) | null = null;
const tabButtons = new Map<TabName, HTMLButtonElement>();
let unsubUi: (() => void) | null = null;
let unsubAuth: (() => void) | null = null;
@@ -139,16 +148,25 @@ export function createSettingsOverlay(
for (const [name, btn] of tabButtons) {
btn.classList.toggle("active", name === tab);
btn.setAttribute("aria-selected", name === tab ? "true" : "false");
// Roving tabindex: only the active tab sits in the page Tab order.
btn.setAttribute("tabindex", name === tab ? "0" : "-1");
}
contentArea?.setAttribute("aria-labelledby", tabId(tab));
renderActiveTab();
}
function show(): void {
const wasOpen = root?.classList.contains("open") ?? false;
root?.classList.add("open");
// Closing tore down the live parts of the active tab (mic meter, camera
// preview, log listener). Rebuild it so a reopened panel shows live state
// instead of a frozen snapshot — and so every tab re-reads current prefs.
if (!contentLive) renderActiveTab();
// Move focus in only on the closed→open transition — a repeated show()
// would otherwise capture an element inside the panel as the "opener".
if (!wasOpen && panel !== null) {
restoreFocus = focusDialog(panel);
}
}
function hide(): void {
@@ -156,6 +174,9 @@ export function createSettingsOverlay(
// Stop camera preview, mic meter, and the log listener when the overlay closes
cleanupActiveTab();
contentLive = false;
// Hand focus back to whatever opened the panel.
restoreFocus?.();
restoreFocus = null;
}
// ---- MountableComponent ---------------------------------------------------
@@ -163,8 +184,42 @@ export function createSettingsOverlay(
function mount(container: Element): void {
root = createElement("div", { class: "settings-overlay", "data-testid": "settings-overlay" });
// Sidebar
const sidebar = createElement("div", { class: "settings-sidebar" });
// Sidebar. It doubles as the tablist: the profile block, category headings
// ("User Settings" / "App Settings") and the Log Out button also live in
// here, and a tablist should own only tabs — but moving them out would
// change the structure the e2e selectors pin down, so we accept that the
// non-tab children are presentational noise inside the tablist (DC-13).
const sidebar = createElement("div", {
class: "settings-sidebar",
role: "tablist",
"aria-orientation": "vertical",
"aria-label": "Settings sections",
});
// Arrow-key navigation between tabs, activate-on-focus (the simpler
// conformant flavor of the WAI-ARIA tabs pattern). Vertical list, so only
// Up/Down move; Home/End jump to the edges; both directions wrap.
sidebar.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") {
return;
}
const order = [...tabButtons.keys()];
const current = order.findIndex((name) => tabButtons.get(name) === e.target);
if (current === -1) return; // e.g. the Log Out button — not a tab
e.preventDefault();
let next: number;
if (e.key === "ArrowDown") next = (current + 1) % order.length;
else if (e.key === "ArrowUp") next = (current - 1 + order.length) % order.length;
else if (e.key === "Home") next = 0;
else next = order.length - 1;
const name = order[next]!;
setActiveTab(name);
tabButtons.get(name)?.focus();
},
{ signal: ac.signal },
);
// User profile section at top of sidebar
const user = authStore.getState().user;
@@ -213,8 +268,10 @@ export function createSettingsOverlay(
const accountBtn = createElement("button", {
class: `settings-nav-item${activeTab === "Account" ? " active" : ""}`,
id: tabId("Account"),
role: "tab",
"aria-selected": activeTab === "Account" ? "true" : "false",
tabindex: activeTab === "Account" ? "0" : "-1",
});
accountBtn.prepend(createIcon(TAB_ICONS["Account"], 18));
accountBtn.appendChild(document.createTextNode("Account"));
@@ -240,8 +297,10 @@ export function createSettingsOverlay(
for (const name of appTabs) {
const btn = createElement("button", {
class: `settings-nav-item${name === activeTab ? " active" : ""}`,
id: tabId(name),
role: "tab",
"aria-selected": name === activeTab ? "true" : "false",
tabindex: name === activeTab ? "0" : "-1",
});
btn.prepend(createIcon(TAB_ICONS[name], 18));
btn.appendChild(document.createTextNode(name));
@@ -263,8 +322,12 @@ export function createSettingsOverlay(
// Page title (h1) at top of content area — created here, inserted in renderActiveTab
pageTitle = createElement("h1", {}, activeTab);
// Content
contentArea = createElement("div", { class: "settings-content" });
// Content — the single tabpanel, renamed per switch via aria-labelledby
contentArea = createElement("div", {
class: "settings-content",
role: "tabpanel",
"aria-labelledby": tabId(activeTab),
});
// Close button wrapped with ESC label
const closeWrap = createElement("div", { class: "settings-close-wrap" });
@@ -292,7 +355,11 @@ export function createSettingsOverlay(
);
// Inner panel (Discord-style centered card)
const panel = createElement("div", { class: "settings-panel" });
panel = createElement("div", { class: "settings-panel" });
applyDialogSemantics(panel, { label: "Settings" });
// Arming the trap while hidden is safe: Tab can't land inside a
// display:none panel, so the handler only fires while the overlay is open.
trapFocus(panel, ac.signal);
appendChildren(panel, sidebar, contentArea, closeWrap);
// Click backdrop (outside panel) to close
@@ -343,10 +410,14 @@ export function createSettingsOverlay(
logsTab.cleanup();
voiceTab.cleanup();
tabButtons.clear();
// Tearing down while open still hands focus back to the opener.
restoreFocus?.();
restoreFocus = null;
if (root !== null) {
root.remove();
root = null;
}
panel = null;
contentArea = null;
pageTitle = null;
}
+10 -1
View File
@@ -100,7 +100,16 @@ export function createToastContainer(): ToastContainer {
}
function mount(container: Element): void {
root = createElement("div", { class: "toast-container", "data-testid": "toast-container" });
// One polite live region for all toasts (DC-13): screen readers announce
// each toast as it is appended without interrupting current speech.
// aria-atomic="false" so only the newly added toast is read, not the stack.
root = createElement("div", {
class: "toast-container",
"data-testid": "toast-container",
role: "status",
"aria-live": "polite",
"aria-atomic": "false",
});
container.appendChild(root);
}
@@ -56,7 +56,13 @@ export function createTypingIndicator(options: TypingIndicatorOptions): Mountabl
}
function mount(container: Element): void {
root = createElement("div", { class: "typing-bar" });
// Polite live region (DC-13): "X is typing" changes are announced without
// interrupting whatever the screen reader is currently speaking.
root = createElement("div", {
class: "typing-bar",
role: "status",
"aria-live": "polite",
});
updateFromState();
@@ -90,6 +90,10 @@ export function createUpdateNotifier(options: UpdateNotifierOptions): MountableC
// App will relaunch — this code won't execute after relaunch()
} catch (err) {
log.error("Update install failed", { error: String(err) });
// The component may have been destroyed while the download was in
// flight (page swap / logout) -- the banner it wanted to repaint is
// already gone, so there is nothing left to do.
if (banner === null) return;
while (banner.firstChild) banner.removeChild(banner.firstChild);
const errorText = createElement(
"span",
@@ -120,19 +120,15 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
});
avatarTextEl = createElement("span", {});
avatarEl.appendChild(avatarTextEl);
const statusDot = createElement("div", {
class: "status-dot",
style:
"background: var(--green); width: 10px; height: 10px; border-radius: 50%; position: absolute; bottom: 0; right: 0;",
});
avatarEl.appendChild(statusDot);
const info = createElement("div", { class: "ub-info" });
nameEl = createElement("span", { class: "ub-name", "data-testid": "user-bar-name" });
statusEl = createElement("span", { class: "ub-status" });
appendChildren(info, nameEl, statusEl);
// Status picker — anchored below username, opens upward
// Status picker — the dot itself lives in the avatar's corner (same spot
// the old plain status indicator occupied) so it doubles as the status
// display and its click target; the dropdown still opens upward from there.
const statusPickerWrap = createElement("div", {
class: "ub-status-picker-wrap",
"data-testid": "status-picker-wrap",
@@ -203,7 +199,7 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
() => updatePickerDisabled(),
);
info.appendChild(statusPickerWrap);
avatarEl.appendChild(statusPickerWrap);
const buttons = createElement("div", { class: "ub-controls" });
@@ -3,8 +3,9 @@
* in the chat or member list. Shows avatar, username, role badge, status dot,
* about section, join date, and Message/Call action buttons.
*
* Position: anchored to click point, flips if <100px from viewport edge.
* Animation: fade+scale 100ms.
* Position: anchored to the click point, flipped to the other side and clamped
* against the measured card height so it always lands fully on screen.
* Animation: fade+scale, defined in CSS so reduced-motion can drop it.
* Close: outside click or Escape.
* A11y: role="dialog", aria-label, focus trap, return focus on close.
*/
@@ -57,8 +58,10 @@ export type UserProfilePopupComponent = MountableComponent & {
// ---------------------------------------------------------------------------
const POPUP_WIDTH = 300;
const EDGE_THRESHOLD = 100;
const ANIMATION_DURATION_MS = 100;
/** Keeps the card clear of the window edges on both axes. */
const VIEWPORT_MARGIN = 8;
/** Breathing room between the click point and the card. */
const ANCHOR_GAP = 8;
const STATUS_COLORS: Record<UserStatus, string> = {
online: "#3ba55d",
@@ -110,28 +113,37 @@ export function createUserProfilePopup(
}
}
function computePosition(anchorX: number, anchorY: number): { left: number; top: number } {
/**
* Place the card beside the anchor, flipping and clamping so it always lands
* fully on screen — Discord opens its popout away from whichever edge the
* clicked row is nearest.
*
* The height is measured rather than assumed. The previous version guessed
* 300px and only clamped the top edge, so a member clicked low in the list
* opened a card that ran off the bottom of the window.
*/
function position(el: HTMLElement, anchorX: number, anchorY: number): void {
const vw = window.innerWidth;
const vh = window.innerHeight;
const height = el.offsetHeight;
let left = anchorX;
// Prefer the right of the anchor and flip left when there is no room. The
// member list sits against the right edge, so flipping is the usual case.
let left = anchorX + ANCHOR_GAP;
if (left + POPUP_WIDTH > vw - VIEWPORT_MARGIN) {
left = anchorX - POPUP_WIDTH - ANCHOR_GAP;
}
left = Math.max(VIEWPORT_MARGIN, Math.min(left, vw - POPUP_WIDTH - VIEWPORT_MARGIN));
// Align the top with the click, then lift the card just enough to fit.
let top = anchorY;
// Flip horizontally if too close to right edge
if (vw - anchorX < EDGE_THRESHOLD) {
left = anchorX - POPUP_WIDTH;
if (top + height > vh - VIEWPORT_MARGIN) {
top = vh - height - VIEWPORT_MARGIN;
}
top = Math.max(VIEWPORT_MARGIN, top);
// Flip vertically if too close to bottom edge
if (vh - anchorY < EDGE_THRESHOLD) {
top = anchorY - 300; // approximate popup height
}
// Clamp to viewport
left = Math.max(8, Math.min(left, vw - POPUP_WIDTH - 8));
top = Math.max(8, top);
return { left, top };
el.style.left = `${left}px`;
el.style.top = `${top}px`;
}
function buildAvatar(user: UserProfileData): HTMLDivElement {
@@ -182,17 +194,8 @@ export function createUserProfilePopup(
"data-testid": "user-profile-popup",
});
// Position the popup
const pos = computePosition(options.anchorX, options.anchorY);
popup.style.left = `${pos.left}px`;
popup.style.top = `${pos.top}px`;
popup.style.width = `${POPUP_WIDTH}px`;
// Animation: fade + scale
popup.style.opacity = "0";
popup.style.transform = "scale(0.95)";
popup.style.transition = `opacity ${ANIMATION_DURATION_MS}ms ease, transform ${ANIMATION_DURATION_MS}ms ease`;
// --- Content ---
// Avatar
@@ -298,10 +301,12 @@ export function createUserProfilePopup(
actions.appendChild(callBtn);
}
// Assemble popup
// Assemble the card: a banner strip and a body, with the avatar straddling
// the seam between them the way Discord's popout does.
const banner = createElement("div", { class: "upp-banner" });
const body = createElement("div", { class: "upp-body" });
appendChildren(
popup,
avatar,
body,
nameEl,
handleEl,
customStatusEl,
@@ -311,17 +316,24 @@ export function createUserProfilePopup(
joinSection,
);
if (actions.childElementCount > 0) {
appendChildren(popup, divider, actions);
appendChildren(body, divider, actions);
}
// The avatar hangs off the body's top edge, so it is a child of the card
// rather than the body — the body scrolls, and a scroll container clips.
// Appending it last puts it over the banner without needing a z-index.
appendChildren(popup, banner, body, avatar);
overlay.appendChild(popup);
container.appendChild(overlay);
// Trigger animation
// Measure, then place: the card has to be in the document before it has a
// height to clamp against.
position(popup, options.anchorX, options.anchorY);
// The fade+scale itself lives in CSS so `prefers-reduced-motion` can drop it.
requestAnimationFrame(() => {
if (popup !== null) {
popup.style.opacity = "1";
popup.style.transform = "scale(1)";
popup.classList.add("open");
}
});
@@ -8,6 +8,7 @@ import { createIcon } from "@lib/icons";
import {
getScreenshareAudioMuted,
getScreenshareAudioVolume,
getUserVolume,
muteScreenshareAudio,
setScreenshareAudioVolume,
setUserVolume,
@@ -26,8 +27,11 @@ export interface TileConfig {
export interface VideoGridComponent extends MountableComponent {
addStream(userId: number, username: string, stream: MediaStream, config?: TileConfig): void;
removeStream(userId: number): void;
/** Remove every tile — used on a real voice leave so stale remote tiles
* from the previous session don't survive into the next join. */
clearStreams(): void;
hasStreams(): boolean;
setFocusedTile(tileId: number): void;
setFocusedTile(tileId: number | null): void;
getFocusedTileId(): number | null;
}
@@ -224,7 +228,7 @@ export function createVideoGrid(): VideoGridComponent {
}
}
function setFocusedTile(tileId: number): void {
function setFocusedTile(tileId: number | null): void {
focusedTileId = tileId;
rebuildFocusLayout();
}
@@ -306,13 +310,18 @@ export function createVideoGrid(): VideoGridComponent {
// Add audio control overlay for remote tiles
if (config !== undefined && !config.isSelf) {
// Screenshare audio state survives tile rebuilds — initialize from it.
// Screenshare sliders are 0-100 (HTMLAudioElement.volume caps at 1.0);
// mic sliders keep 0-200 (LiveKit setVolume supports boost up to 2.0).
let muted = config.isScreenshare ? getScreenshareAudioMuted(config.audioUserId) : false;
let currentVolume = config.isScreenshare
// Mic and screenshare audio state both survive tile rebuilds —
// initialize from the same persisted values the sidebar volume menu
// reads, instead of hardcoding "unmuted at 100%" (B3-5). Screenshare
// sliders are 0-100 (HTMLAudioElement.volume caps at 1.0); mic sliders
// keep 0-200 (LiveKit setVolume supports boost up to 2.0).
const savedVolume = config.isScreenshare
? Math.round(getScreenshareAudioVolume(config.audioUserId) * 100)
: 100;
: getUserVolume(config.audioUserId);
let currentVolume = savedVolume;
let muted = config.isScreenshare
? getScreenshareAudioMuted(config.audioUserId)
: savedVolume === 0;
const overlay = createElement("div", { class: "video-tile-overlay" });
@@ -421,6 +430,15 @@ export function createVideoGrid(): VideoGridComponent {
}
}
/** Remove every tile (trackCleanup + srcObject=null via removeStream).
* Deleting the current key mid-iteration is well-defined for Map — no
* entries are skipped — so this needs no snapshot copy of the keys. */
function clearStreams(): void {
for (const userId of cells.keys()) {
removeStream(userId);
}
}
function hasStreams(): boolean {
return cells.size > 0;
}
@@ -470,6 +488,7 @@ export function createVideoGrid(): VideoGridComponent {
destroy,
addStream,
removeStream,
clearStreams,
hasStreams,
setFocusedTile,
getFocusedTileId: getFocusedTileIdFn,
@@ -182,7 +182,10 @@ export function attachChannelContextMenu(
menu.remove();
menuAc.abort();
};
signal.addEventListener("abort", () => menuAc.abort());
// Tie this bridge listener's own lifetime to menuAc so it does not
// outlive the menu it belongs to — closeMenu (which aborts menuAc)
// already fires far more often than the sidebar's own teardown.
signal.addEventListener("abort", closeMenu, { signal: menuAc.signal });
// Defer so this click event doesn't immediately close it
setTimeout(() => {
if (menuAc.signal.aborted) return;
@@ -1,13 +1,13 @@
/**
* Channel drag-reorder — mouse-based drag-and-drop for channel reordering.
* Uses mousedown/mousemove/mouseup (avoids WebView2 HTML5 DnD issues).
* Admin/owner only.
* Gated on MANAGE_CHANNELS, like every other channel-management affordance.
*/
import { getCurrentUser } from "@stores/auth.store";
import { updateChannelPosition } from "@stores/channels.store";
import type { Channel } from "@stores/channels.store";
import type { ChannelReorderData } from "../ChannelSidebar";
import { canManageChannels } from "@lib/permissions";
// ── Drag state (mouse-based, avoids WebView2 HTML5 DnD issues) ──
interface DragState {
@@ -16,17 +16,49 @@ interface DragState {
containerEl: HTMLElement;
channels: readonly Channel[];
onReorder: (reorders: readonly ChannelReorderData[]) => void;
/** The signal of the sidebar that started this drag, so its teardown can
* clear the in-flight visual state without touching another sidebar's. */
owner: AbortSignal;
}
let activeDrag: DragState | null = null;
/** Global mousemove/mouseup handlers for drag reordering. Registered once.
* Reference-counted so multiple sidebar instances share the same listeners
* and only the last destroy tears them down. */
/** Global mousemove/mouseup handlers for drag reordering, shared by every
* sidebar instance. Ownership is tracked per AbortSignal — the sidebar's
* lifetime controller — not per attached channel row: attachDragHandlers runs
* once per row per render, and the per-row ref-count this replaced meant a
* sidebar took N references its single destroy could never return, so the two
* document listeners lived for the rest of the process (the KNOWN BUG
* drag-reorder.test.ts pinned until this fix). An owner's release is its
* signal's abort — the same AbortController teardown idiom as
* {@link ../../lib/disposable} — so there is no separate release call to
* forget or miscount. */
const listenerOwners = new Set<AbortSignal>();
let globalDragAc: AbortController | null = null;
let globalDragRefCount = 0;
export function ensureGlobalDragListeners(): void {
globalDragRefCount++;
function releaseOwner(owner: AbortSignal): void {
listenerOwners.delete(owner);
// A sidebar destroyed mid-drag must not leave the row stuck in the dragging
// state or the body stuck in reorder mode.
if (activeDrag !== null && activeDrag.owner === owner) {
activeDrag.sourceEl.classList.remove("dragging");
document.body.classList.remove("channel-reordering");
activeDrag.containerEl.querySelectorAll(".channel-drop-indicator").forEach((x) => {
x.classList.remove("channel-drop-indicator");
});
activeDrag = null;
}
if (listenerOwners.size === 0 && globalDragAc !== null) {
globalDragAc.abort();
globalDragAc = null;
}
}
export function ensureGlobalDragListeners(owner: AbortSignal): void {
if (owner.aborted || listenerOwners.has(owner)) {
return;
}
listenerOwners.add(owner);
owner.addEventListener("abort", () => releaseOwner(owner), { once: true });
if (globalDragAc !== null) {
return;
}
@@ -111,17 +143,23 @@ export function ensureGlobalDragListeners(): void {
...withoutDrag.slice(insertIdx),
];
// Build reorder data and update store immediately
// Build reorder data and update store immediately. Reassign the
// group's own existing position slots, not a 0..n-1 range: the
// server's position space is global, so a category can sit at
// non-contiguous positions (interleaved with other categories), and
// renumbering from 0 would stomp another category's slots.
const slots = drag.channels.map((c) => c.position).sort((a, b) => a - b);
const reorders: ChannelReorderData[] = [];
for (let i = 0; i < reorderedIds.length; i++) {
const id = reorderedIds[i];
if (id === undefined) {
const newPosition = slots[i];
if (id === undefined || newPosition === undefined) {
continue;
}
const ch = drag.channels.find((c) => c.id === id);
if (ch !== undefined && ch.position !== i) {
reorders.push({ channelId: id, newPosition: i });
updateChannelPosition(id, i);
if (ch !== undefined && ch.position !== newPosition) {
reorders.push({ channelId: id, newPosition });
updateChannelPosition(id, newPosition);
}
}
@@ -133,7 +171,7 @@ export function ensureGlobalDragListeners(): void {
);
}
/** Make a channel element draggable via mousedown (admin/owner only). */
/** Make a channel element draggable via mousedown (MANAGE_CHANNELS only). */
export function attachDragHandlers(
el: HTMLElement,
channel: Channel,
@@ -145,13 +183,15 @@ export function attachDragHandlers(
if (onReorderChannel === undefined) {
return;
}
const user = getCurrentUser();
const role = user?.role?.toLowerCase() ?? "";
if (role !== "owner" && role !== "admin") {
// The one derivation for every channel-management affordance (create, edit,
// delete, reorder) — a custom role holding the bit gets the same rows the
// Edit/Delete menu already offers it, and a role merely *named* "admin"
// without the bit does not get a drag the server will 403.
if (!canManageChannels()) {
return;
}
ensureGlobalDragListeners();
ensureGlobalDragListeners(signal);
el.classList.add("channel-draggable");
el.dataset.dragChannelId = String(channel.id);
@@ -173,6 +213,15 @@ export function attachDragHandlers(
el.addEventListener(
"mousemove",
(e) => {
// Defuse a stale latch: pendingDrag is cleared only by a mouseup on
// this same row (see the listener below), so releasing the button
// anywhere else — off this row entirely, or via a fast flick — leaves
// it armed. A later button-free hover would otherwise promote it into
// a real drag on the next `if` below.
if (e.buttons === 0) {
pendingDrag = null;
return;
}
if (pendingDrag === null || activeDrag !== null) {
return;
}
@@ -189,6 +238,7 @@ export function attachDragHandlers(
containerEl,
channels,
onReorder: onReorderChannel,
owner: signal,
};
el.classList.add("dragging");
document.body.classList.add("channel-reordering");
@@ -204,18 +254,3 @@ export function attachDragHandlers(
{ signal },
);
}
/** Decrement global drag listener ref-count; tear down when no more sidebars. */
export function releaseGlobalDragListeners(containerEl?: HTMLElement): void {
// Clear stale drag state if the destroyed sidebar owns the active drag
if (containerEl !== undefined && activeDrag?.containerEl === containerEl) {
activeDrag.sourceEl.classList.remove("dragging");
document.body.classList.remove("channel-reordering");
activeDrag = null;
}
globalDragRefCount = Math.max(0, globalDragRefCount - 1);
if (globalDragRefCount === 0 && globalDragAc !== null) {
globalDragAc.abort();
globalDragAc = null;
}
}
@@ -128,11 +128,19 @@ export function showUserVolumeMenu(
);
}, 0);
// Also clean up if the parent component is destroyed
signal.addEventListener("abort", () => {
menu.remove();
dismissAc.abort();
});
// Also clean up if the parent component is destroyed. Tied to dismissAc's
// own signal (mirrors context-menu.ts's menuAc pattern) so this bridge
// listener is torn down with the menu itself — otherwise it never runs
// (the parent signal is long-lived) and every right-click permanently
// accumulates one closure retaining a detached .user-vol-menu subtree.
signal.addEventListener(
"abort",
() => {
menu.remove();
dismissAc.abort();
},
{ signal: dismissAc.signal },
);
}
/** Builds the moderation rows. close() runs after any action so the menu does
@@ -7,6 +7,13 @@
* navigation, Enter/Tab/Escape handling, AbortController cleanup — lives here
* once instead of being duplicated in each popup.
*
* Accessibility-wise this is a WAI-ARIA combobox, not a menu: DOM focus stays
* in the composer textarea the whole time (moving it into the list would stop
* keystrokes from reaching the textarea, so the rows deliberately get no
* roving tabindex) and the "focused" row is conveyed purely through
* aria-activedescendant on the textarea, pointing at per-row ids stamped on
* every render.
*
* Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
*/
@@ -40,6 +47,15 @@ export interface InlineAutocompleteConfig<T> {
readonly onSelect: (value: string) => void;
/** Called when the user dismisses the popup (Escape). */
readonly onClose: () => void;
/**
* The composer control this popup completes for (the textarea). While the
* popup exists it carries combobox semantics — role="combobox",
* aria-autocomplete="list", aria-expanded="true", aria-controls={list id} —
* plus aria-activedescendant tracking the active row; destroy() removes
* them all again. DOM focus never moves here: it must stay in the textarea
* so typing keeps working, which is why the rows have no tabindex.
*/
readonly comboboxInput?: HTMLElement;
}
export interface InlineAutocompleteComponent {
@@ -54,6 +70,15 @@ export interface InlineAutocompleteComponent {
destroy(): void;
}
/** The combobox state a popup stamps on its input, removed again on destroy. */
const COMBOBOX_ATTRS = [
"role",
"aria-autocomplete",
"aria-expanded",
"aria-controls",
"aria-activedescendant",
] as const;
export function createInlineAutocomplete<T>(
cfg: InlineAutocompleteConfig<T>,
): InlineAutocompleteComponent {
@@ -63,14 +88,29 @@ export function createInlineAutocomplete<T>(
let suggestions: T[] = [];
let activeIndex = 0;
// The testid is already unique per widget, so it doubles as a stable DOM id
// for aria-controls / aria-activedescendant to point at.
const rootId = cfg.rootTestId;
const root = createElement("div", {
class: cfg.rootClass,
id: rootId,
role: "listbox",
"data-testid": cfg.rootTestId,
});
const list = createElement("div", { class: "ma-list" });
root.appendChild(list);
const input = cfg.comboboxInput ?? null;
if (input !== null) {
input.setAttribute("role", "combobox");
input.setAttribute("aria-autocomplete", "list");
// The popup only exists while it is open (the composer destroys it to
// close), so "expanded" holds for this component's whole lifetime.
input.setAttribute("aria-expanded", "true");
input.setAttribute("aria-controls", rootId);
}
function choose(index: number): void {
const picked = suggestions[index];
if (picked === undefined) return;
@@ -83,6 +123,7 @@ export function createInlineAutocomplete<T>(
const s = suggestions[i]!;
const row = createElement("div", {
class: i === activeIndex ? "ma-item ma-item--active" : "ma-item",
id: `${rootId}-option-${i}`,
role: "option",
"aria-selected": i === activeIndex ? "true" : "false",
"data-testid": cfg.rowTestId(s),
@@ -100,6 +141,15 @@ export function createInlineAutocomplete<T>(
);
list.appendChild(row);
}
// Rows are rebuilt with index-based ids, so the pointer must be re-aimed
// on every render, not just when activeIndex moves.
if (input !== null) {
if (suggestions.length > 0) {
input.setAttribute("aria-activedescendant", `${rootId}-option-${activeIndex}`);
} else {
input.removeAttribute("aria-activedescendant");
}
}
}
function setQuery(query: string): boolean {
@@ -138,6 +188,12 @@ export function createInlineAutocomplete<T>(
function destroy(): void {
ac.abort();
// Another popup may have claimed the input between this one's open and
// close (the composer opens the mention popup before closing the emoji
// one), so only strip the combobox state while it still points here.
if (input !== null && input.getAttribute("aria-controls") === rootId) {
for (const attr of COMBOBOX_ATTRS) input.removeAttribute(attr);
}
root.remove();
}
@@ -346,6 +346,13 @@ export function renderInlineImage(url: string): HTMLDivElement {
// properly remove document-level listeners from the previous instance.
let activeLightboxClose: (() => void) | null = null;
/** Close the active lightbox, if any. Called on page teardown (logout, page
* swap) so an open overlay doesn't survive onto the next page with live
* document listeners and a revoked blob URL. */
export function closeActiveLightbox(): void {
activeLightboxClose?.();
}
/** Open a full-screen lightbox overlay with zoom and pan. */
export function openImageLightbox(src: string, alt: string): void {
// Close any existing lightbox (including its document listeners)
@@ -221,6 +221,33 @@ interface HoverState {
const hoverStates = new WeakMap<HTMLElement, HoverState>();
/**
* Chips currently mid-hover (debounce timer running or tooltip showing),
* keyed by the message list's AbortSignal. A single abort listener per signal
* hides whatever is in the set instead of registering a bare, never-removed
* `abort` listener per chip on every render — the latter permanently pinned
* every past chip (and, via parentNode, its whole detached row) in memory for
* the rest of the channel visit. start()/stop() add/remove the chip, so the
* set only ever holds the handful of chips actually being hovered.
*/
const hoveringChips = new WeakMap<AbortSignal, Set<HTMLElement>>();
function chipSetFor(signal: AbortSignal): Set<HTMLElement> {
const existing = hoveringChips.get(signal);
if (existing !== undefined) return existing;
const set = new Set<HTMLElement>();
hoveringChips.set(signal, set);
signal.addEventListener(
"abort",
() => {
for (const chip of set) hide(chip);
set.clear();
},
{ once: true },
);
return set;
}
function removeTooltip(chip: HTMLElement): void {
chip.querySelector(".reaction-tooltip")?.remove();
}
@@ -261,21 +288,25 @@ export function attachReactionTooltip(
});
};
const chips = chipSetFor(signal);
const start = (): void => {
hide(chip);
const existing = hoverStates.get(chip);
const generation = existing === undefined ? 0 : existing.generation;
const timer = window.setTimeout(show, REACTION_TOOLTIP_DEBOUNCE_MS);
hoverStates.set(chip, { timer, generation });
chips.add(chip);
};
const stop = (): void => hide(chip);
const stop = (): void => {
chips.delete(chip);
hide(chip);
};
chip.addEventListener("mouseenter", start, { signal });
chip.addEventListener("mouseleave", stop, { signal });
// Keyboard accessibility: focus mirrors hover.
chip.addEventListener("focusin", start, { signal });
chip.addEventListener("focusout", stop, { signal });
signal.addEventListener("abort", () => hide(chip));
}
@@ -179,9 +179,16 @@ function buildVoiceAudioTabInner(
const onUp = (): void => {
meterThreshold.removeEventListener("pointermove", onMove);
meterThreshold.removeEventListener("pointerup", onUp);
meterThreshold.removeEventListener("pointercancel", onUp);
};
meterThreshold.addEventListener("pointermove", onMove, { signal });
meterThreshold.addEventListener("pointerup", onUp, { signal });
// A touch/pen drag that the OS claims as a pan (or any other
// mid-drag pointer loss) fires pointercancel instead of pointerup.
// Without this, onMove stays attached for the tab's lifetime and
// every later hover over the handle silently rewrites and persists
// voiceSensitivity with no button held (v097).
meterThreshold.addEventListener("pointercancel", onUp, { signal });
},
{ signal },
);
@@ -411,10 +418,14 @@ function buildVoiceAudioTabInner(
{ signal },
);
// Race guard: prevent stale getUserMedia results from overwriting a newer request
// Race guard: prevent stale getUserMedia results from overwriting a newer
// request. cleanupMic() invalidates both counters, so a stream resolving
// after teardown is stopped instead of re-arming state nobody cleans up.
let cameraRequestId = 0;
let micRequestId = 0;
registerCameraInvalidation(() => {
cameraRequestId += 1;
micRequestId += 1;
});
function stopCameraPreview(): void {
@@ -482,6 +493,7 @@ function buildVoiceAudioTabInner(
// Start mic level monitoring for visual feedback
void (async () => {
const thisRequest = ++micRequestId;
try {
const savedDevice = loadPref<string>("audioInputDevice", "");
const constraints: MediaStreamConstraints = {
@@ -489,6 +501,13 @@ function buildVoiceAudioTabInner(
video: false,
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
// Race guard: teardown (cleanup or abort) may have run while we awaited
// — opening the mic now would leave it hot with nobody left to stop it,
// and registerMic would re-arm state cleanupMic() already cleared.
if (signal.aborted || thisRequest !== micRequestId) {
for (const track of stream.getTracks()) track.stop();
return;
}
const audioCtx = new AudioContext();
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
@@ -1,7 +0,0 @@
{
"version": 1,
"commands_hash": "ca3b770e3d69abf7",
"structs_hash": "2c0574a96a92e42f",
"config_hash": "c72a07caa5bc6ed4",
"combined_hash": "6a107ade235e2401"
}
@@ -1,107 +0,0 @@
/**
* Auto-generated TypeScript bindings for Tauri commands
* Generated by tauri-typegen v0.5.0
* Generated at: 2026-04-03T09:09:31.628896400+00:00
* Generator: none
*
* Do not edit manually - regenerate using: cargo tauri-typegen generate
*/
import { invoke } from "@tauri-apps/api/core";
import * as types from "./types";
export async function startLivekitProxy(params: types.StartLivekitProxyParams): Promise<number> {
return invoke("start_livekit_proxy", params);
}
export async function stopLivekitProxy(): Promise<void> {
return invoke("stop_livekit_proxy");
}
export async function checkClientUpdate(
params: types.CheckClientUpdateParams,
): Promise<types.UpdateCheckResult> {
return invoke("check_client_update", params);
}
export async function downloadAndInstallUpdate(
params: types.DownloadAndInstallUpdateParams,
): Promise<void> {
return invoke("download_and_install_update", params);
}
export async function pttStart(): Promise<void> {
return invoke("ptt_start");
}
export async function pttStop(): Promise<void> {
return invoke("ptt_stop");
}
export async function pttSetKey(params: types.PttSetKeyParams): Promise<void> {
return invoke("ptt_set_key", params);
}
export async function pttGetKey(): Promise<number> {
return invoke("ptt_get_key");
}
export async function pttListenForKey(): Promise<number> {
return invoke("ptt_listen_for_key");
}
export async function saveCredential(params: types.SaveCredentialParams): Promise<void> {
return invoke("save_credential", params);
}
export async function loadCredential(
params: types.LoadCredentialParams,
): Promise<types.CredentialData | null> {
return invoke("load_credential", params);
}
export async function deleteCredential(params: types.DeleteCredentialParams): Promise<void> {
return invoke("delete_credential", params);
}
export async function wsConnect(params: types.WsConnectParams): Promise<void> {
return invoke("ws_connect", params);
}
export async function wsSend(params: types.WsSendParams): Promise<void> {
return invoke("ws_send", params);
}
export async function wsDisconnect(): Promise<void> {
return invoke("ws_disconnect");
}
export async function acceptCertFingerprint(
params: types.AcceptCertFingerprintParams,
): Promise<void> {
return invoke("accept_cert_fingerprint", params);
}
export async function getSettings(): Promise<types.Value> {
return invoke("get_settings");
}
export async function saveSettings(params: types.SaveSettingsParams): Promise<void> {
return invoke("save_settings", params);
}
export async function storeCertFingerprint(
params: types.StoreCertFingerprintParams,
): Promise<void> {
return invoke("store_cert_fingerprint", params);
}
export async function getCertFingerprint(
params: types.GetCertFingerprintParams,
): Promise<string | null> {
return invoke("get_cert_fingerprint", params);
}
export async function openDevtools(): Promise<void> {
return invoke("open_devtools");
}
@@ -1,48 +0,0 @@
/**
* Auto-generated TypeScript bindings for Tauri commands
* Generated by tauri-typegen v0.5.0
* Generated at: 2026-04-03T09:09:31.629251800+00:00
* Generator: none
*
* Do not edit manually - regenerate using: cargo tauri-typegen generate
*/
/**
* Event Listeners
* Type-safe event listener helpers for Tauri events
*/
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import * as types from "./types";
/**
* Listen for 'status-change' events
* @param handler - Callback function to handle the event
* @returns Promise that resolves to an unlisten function
*/
export async function onStatusChange(handler: (payload: string) => void): Promise<UnlistenFn> {
return listen<string>("status-change", (event) => {
handler(event.payload);
});
}
/**
* Listen for 'ws-state' events
* @param handler - Callback function to handle the event
* @returns Promise that resolves to an unlisten function
*/
export async function onWsState(handler: (payload: string) => void): Promise<UnlistenFn> {
return listen<string>("ws-state", (event) => {
handler(event.payload);
});
}
/**
* Listen for 'cert-tofu' events
* @param handler - Callback function to handle the event
* @returns Promise that resolves to an unlisten function
*/
export async function onCertTofu(handler: (payload: types.Value) => void): Promise<UnlistenFn> {
return listen<types.Value>("cert-tofu", (event) => {
handler(event.payload);
});
}
@@ -1,12 +0,0 @@
/**
* Auto-generated TypeScript bindings for Tauri commands
* Generated by tauri-typegen v0.5.0
* Generated at: 2026-04-03T09:09:31.629428700+00:00
* Generator: none
*
* Do not edit manually - regenerate using: cargo tauri-typegen generate
*/
export * from "./types";
export * from "./commands";
export * from "./events";
@@ -1,92 +0,0 @@
/**
* Auto-generated TypeScript bindings for Tauri commands
* Generated by tauri-typegen v0.5.0
* Generated at: 2026-04-03T09:09:31.628377200+00:00
* Generator: none
*
* Do not edit manually - regenerate using: cargo tauri-typegen generate
*/
export interface UpdateCheckResult {
available: boolean;
version?: string | null;
body?: string | null;
}
export type Value = unknown;
export interface CredentialData {
username: string;
token: string;
}
export interface StartLivekitProxyParams {
remoteHost: string;
[key: string]: unknown;
}
export interface CheckClientUpdateParams {
serverUrl: string;
[key: string]: unknown;
}
export interface DownloadAndInstallUpdateParams {
serverUrl: string;
[key: string]: unknown;
}
export interface PttSetKeyParams {
vkCode: number;
[key: string]: unknown;
}
export interface SaveCredentialParams {
host: string;
username: string;
token: string;
password?: string | null;
[key: string]: unknown;
}
export interface LoadCredentialParams {
host: string;
[key: string]: unknown;
}
export interface DeleteCredentialParams {
host: string;
[key: string]: unknown;
}
export interface WsConnectParams {
url: string;
[key: string]: unknown;
}
export interface WsSendParams {
message: string;
[key: string]: unknown;
}
export interface AcceptCertFingerprintParams {
host: string;
fingerprint: string;
[key: string]: unknown;
}
export interface SaveSettingsParams {
key: string;
value: Value;
[key: string]: unknown;
}
export interface StoreCertFingerprintParams {
host: string;
fingerprint: string;
[key: string]: unknown;
}
export interface GetCertFingerprintParams {
host: string;
[key: string]: unknown;
}
+173
View File
@@ -0,0 +1,173 @@
/**
* Shared dialog accessibility helpers (DC-13).
*
* Generalizes the pattern UserProfilePopup pioneered — dialog semantics, a
* Tab-cycling focus trap, and focus save/restore — so every modal applies the
* same behavior instead of re-implementing (or forgetting) it. All listeners
* register against the caller's AbortSignal, matching the component teardown
* idiom used across the codebase.
*/
/** The elements a dialog's Tab cycle visits. */
const FOCUSABLE_SELECTOR =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
/**
* Elements the app hides via inline `style.display = "none"` (the codebase's
* standard show/hide idiom — e.g. a group-name field revealed only once a
* second member is picked) still match FOCUSABLE_SELECTOR: the selector is
* structural, not a visibility check. A browser silently refuses to move
* focus onto a display:none element, so treating one as the dialog's "first"
* or "last" focusable leaves .focus() a no-op and the Tab trap comparing
* against an edge focus never actually reached — Tab then falls through to
* the browser's native order and can walk out of the dialog entirely.
*/
function isFocusable(el: HTMLElement): boolean {
return el.style.display !== "none" && el.style.visibility !== "hidden";
}
function queryFocusable(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
isFocusable,
);
}
export interface DialogSemanticsOptions {
/** Accessible name for the dialog (aria-label). */
readonly label?: string;
/** Id of the element naming the dialog (aria-labelledby); wins over label. */
readonly labelledBy?: string;
}
/**
* Stamp WAI-ARIA dialog semantics on a modal container: role="dialog",
* aria-modal="true", and tabindex="-1" so the container itself can take
* initial focus when it holds no focusable control.
*/
export function applyDialogSemantics(el: HTMLElement, opts: DialogSemanticsOptions = {}): void {
el.setAttribute("role", "dialog");
el.setAttribute("aria-modal", "true");
el.setAttribute("tabindex", "-1");
if (opts.labelledBy !== undefined) {
el.setAttribute("aria-labelledby", opts.labelledBy);
} else if (opts.label !== undefined) {
el.setAttribute("aria-label", opts.label);
}
}
/**
* Trap Tab/Shift+Tab inside `container` for as long as `signal` lives:
* tabbing past the last focusable wraps to the first and vice versa. The
* focusable set is queried per keystroke, so contents may change freely.
*/
export function trapFocus(container: HTMLElement, signal: AbortSignal): void {
container.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const focusable = queryFocusable(container);
if (focusable.length === 0) {
// Nothing tabbable inside — keep focus on the container itself.
e.preventDefault();
return;
}
const first = focusable[0]!;
const last = focusable[focusable.length - 1]!;
// Focus outside the set (e.g. on the container) also wraps to an edge.
const active = document.activeElement;
if (e.shiftKey && (active === first || active === container)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && (active === last || active === container)) {
e.preventDefault();
first.focus();
}
},
{ signal },
);
}
/**
* Make exactly one cell in `container` tabbable (the first) and the rest
* focusable only programmatically. Call after every render that replaces the
* cell set — search results swap the cells out from under the tabindex, and a
* grid with zero (or many) Tab stops breaks the "Tab enters the grid once"
* contract.
*/
export function setRovingTabindex(container: HTMLElement, cellSelector: string): void {
const cells = container.querySelectorAll<HTMLElement>(cellSelector);
cells.forEach((cell, i) => {
cell.setAttribute("tabindex", i === 0 ? "0" : "-1");
});
}
/**
* Roving-tabindex keyboard support for a flat list of option cells:
* ArrowLeft/ArrowRight step, Home/End jump to the edges, and Enter/Space
* activate the focused cell through its own click handler so keyboard and
* mouse take the identical code path. The grid is deliberately treated as a
* flat list — row-aware Up/Down would need layout knowledge the DOM doesn't
* expose reliably.
*
* The listener lives on the container (which survives re-renders) and the
* cell set is queried per keystroke, so callers may rebuild cells freely as
* long as they re-run setRovingTabindex afterwards.
*/
export function enableRovingNavigation(
container: HTMLElement,
cellSelector: string,
signal: AbortSignal,
): void {
container.addEventListener(
"keydown",
(e: KeyboardEvent) => {
// Only keystrokes originating on a cell rove; the search input above
// the grid keeps its native caret behavior for arrows and Home/End.
const origin =
e.target instanceof HTMLElement ? e.target.closest<HTMLElement>(cellSelector) : null;
if (origin === null) return;
const cells = Array.from(container.querySelectorAll<HTMLElement>(cellSelector));
const from = cells.indexOf(origin);
if (from === -1) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
origin.click();
return;
}
let to: number;
if (e.key === "ArrowRight") to = Math.min(from + 1, cells.length - 1);
else if (e.key === "ArrowLeft") to = Math.max(from - 1, 0);
else if (e.key === "Home") to = 0;
else if (e.key === "End") to = cells.length - 1;
else return;
e.preventDefault();
// Move the single Tab stop along with focus so tabbing away and back
// returns to the last visited cell, not the first.
origin.setAttribute("tabindex", "-1");
const target = cells[to]!;
target.setAttribute("tabindex", "0");
target.focus();
},
{ signal },
);
}
/**
* Move initial focus into a just-opened dialog (its first focusable control,
* else the container itself) and return a restorer that puts focus back on
* whatever held it before — call the restorer on close. Capturing happens NOW,
* so call this before anything inside the dialog grabs focus.
*/
export function focusDialog(container: HTMLElement): () => void {
const previous = document.activeElement;
const firstFocusable = queryFocusable(container)[0];
(firstFocusable ?? container).focus();
return () => {
if (previous instanceof HTMLElement && previous.isConnected) {
previous.focus();
}
};
}
+43 -16
View File
@@ -17,9 +17,7 @@ import type {
ChannelType,
ChannelResponse,
EmojiResponse,
SoundResponse,
InviteResponse,
SessionResponse,
UploadResponse,
VoiceCredentialsResponse,
MemberResponse,
@@ -51,6 +49,30 @@ export class ApiClientError extends Error {
export type OnUnauthorized = () => void;
/**
* Single session object from GET /users/me/sessions, matching the server's
* wire shape (Server/api/profile_handler.go's sessionResponse, wrapped in a
* `{sessions: [...]}` envelope — docs/api.md). Defined here, next to its only
* consumer, rather than in `./types`: the declaration that used to live there
* had drifted from the actual contract (it declared `ip_address`/`expires_at`,
* which the server never sends, and omitted `ip`/`is_current`, which it always
* does), and nothing else needs this shape.
*/
export interface SessionInfo {
readonly id: number;
/** Never null: the server's fields are plain Go strings, so an unknown
* device or address arrives as "" rather than being omitted. */
readonly device: string;
readonly ip: string;
readonly created_at: string;
readonly last_used: string;
readonly is_current: boolean;
}
interface SessionsListResponse {
readonly sessions: SessionInfo[];
}
const log = createLogger("api");
/** Create the REST API client. */
@@ -189,7 +211,20 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
log.error("setConfig rejected invalid host", { host: newConfig.host });
throw new Error("Invalid host format");
}
config = { ...config, ...newConfig };
// Switching to a different host without an accompanying new token must
// not carry the previous host's bearer token forward — otherwise the
// login/register request to the new host rides a still-live session
// token for the old one. Callers that only rotate the token (post-auth)
// never pass `host`, so this never touches a same-host token refresh.
if (
newConfig.host !== undefined &&
newConfig.host !== config.host &&
newConfig.token === undefined
) {
config = { ...config, ...newConfig, token: undefined };
} else {
config = { ...config, ...newConfig };
}
},
/** Get current config (for debugging). Token is redacted. */
@@ -334,7 +369,7 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
return request<void>(
"PUT",
"/users/me/password",
{ current_password: currentPassword, new_password: newPassword },
{ old_password: currentPassword, new_password: newPassword },
signal,
);
},
@@ -354,8 +389,10 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
return request<void>("DELETE", "/users/me/totp", { password }, signal);
},
getSessions(signal?: AbortSignal): Promise<SessionResponse[]> {
return request<SessionResponse[]>("GET", "/users/me/sessions", undefined, signal);
getSessions(signal?: AbortSignal): Promise<SessionInfo[]> {
return request<SessionsListResponse>("GET", "/users/me/sessions", undefined, signal).then(
(r) => r.sessions,
);
},
revokeSession(sessionId: number, signal?: AbortSignal): Promise<void> {
@@ -589,16 +626,6 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
return request<void>("DELETE", `/emoji/${emojiId}`, undefined, signal);
},
// ── Sounds ────────────────────────────────────────────
getSounds(signal?: AbortSignal): Promise<SoundResponse[]> {
return request<SoundResponse[]>("GET", "/sounds", undefined, signal);
},
deleteSound(soundId: number, signal?: AbortSignal): Promise<void> {
return request<void>("DELETE", `/sounds/${soundId}`, undefined, signal);
},
// ── Direct Messages ─────────────────────────────────────
/** List user's open DM channels. */
+43 -3
View File
@@ -17,9 +17,49 @@ import { voiceStore } from "@stores/voice.store";
const log = createLogger("audioElements");
/** Get saved per-user volume (0-200 range, default 100). Applied via LiveKit's GainNode-backed setVolume(). */
/**
* Server host the per-user volume prefs below belong to. Mirrors
* channel-mutes.ts's currentHost — the client is multi-server (one webview
* origin means one localStorage) and userId is only unique per server, so
* without a host component a volume set for user 7 on one server would
* silence user 7 on every other server too. `setAudioVolumeHost` is always
* called with a real host before any volume is read (see MainPage.ts), so
* the `null` startup default is not what protects a pre-scoping install's
* saved volumes — `getSavedUserVolume` does that below by reading through to
* the original unscoped key on a miss at the scoped one.
*/
let currentHost: string | null = null;
/** Point per-user volume reads/writes at a specific server. Call on connect
* and on server switch — mirroring channel-mutes.ts's setChannelMutesHost. */
export function setAudioVolumeHost(host: string | null): void {
currentHost = host;
}
function userVolumeKey(userId: number): string {
return currentHost === null ? `userVolume_${userId}` : `userVolume_${userId}:${currentHost}`;
}
// setUserVolume always clamps to 0-200, so -1 is safe as a "nothing saved" sentinel.
const VOLUME_NOT_SET = -1;
/** Get saved per-user volume (0-200 range, default 100). Applied via LiveKit's
* GainNode-backed setVolume(). On a miss at the host-scoped key, reads
* through to the pre-scoping unscoped key once and persists the result
* under the scoped key so the read-through isn't repeated. */
function getSavedUserVolume(userId: number): number {
return loadPref<number>(`userVolume_${userId}`, 100);
const scopedKey = userVolumeKey(userId);
if (currentHost === null) return loadPref<number>(scopedKey, 100);
const scoped = loadPref<number>(scopedKey, VOLUME_NOT_SET);
if (scoped !== VOLUME_NOT_SET) return scoped;
const legacy = loadPref<number>(`userVolume_${userId}`, VOLUME_NOT_SET);
if (legacy !== VOLUME_NOT_SET) {
savePref(scopedKey, legacy);
return legacy;
}
return loadPref<number>(scopedKey, 100);
}
export class AudioElements {
@@ -185,7 +225,7 @@ export class AudioElements {
setUserVolume(userId: number, volume: number): void {
const clamped = Math.max(0, Math.min(200, volume));
savePref(`userVolume_${userId}`, clamped);
savePref(userVolumeKey(userId), clamped);
if (this.room !== null) {
for (const participant of this.room.remoteParticipants.values()) {
if (parseUserId(participant.identity) === userId) {
+30 -2
View File
@@ -21,6 +21,11 @@ export class AudioPipeline {
/** Monotonic counter incremented on teardown — used to discard stale async results. */
private _pipelineGeneration = 0;
/** Monotonic counter incremented on stopVadPolling — narrower than
* _pipelineGeneration (which only bumps on a full pipeline teardown), so it
* also invalidates an in-flight startVadPolling()'s addModule when VAD is
* stopped without tearing down the pipeline (e.g. setVoiceSensitivity(100)). */
private _vadGeneration = 0;
// Pipeline nodes
private audioPipelineCtx: AudioContext | null = null;
@@ -74,6 +79,10 @@ export class AudioPipeline {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- LocalTrack.setProcessor uses wide generic, but AudioProcessorOptions is guaranteed at runtime with webAudioMix
await micPub.track.setProcessor(processor as any);
log.info("RNNoise processor attached to mic track");
// Rebuild so the gain/VAD chain sources from the processor's output and
// its own sender.replaceTrack runs last, winning over setProcessor's
// internal replaceTrack to the raw processed track (B3-1).
this.setupAudioPipeline();
}
/** Remove RNNoise processor from the local mic track. Safe to call if none attached. */
@@ -84,6 +93,9 @@ export class AudioPipeline {
if (micPub.track.getProcessor() === undefined) return;
await micPub.track.stopProcessor();
log.info("RNNoise processor removed from mic track");
// Rebuild so the sender ends back on the gain/VAD chain over the raw mic,
// not whatever track stopProcessor's own internals left wired (B3-1).
this.setupAudioPipeline();
}
// --- Pipeline setup/teardown ---
@@ -96,7 +108,15 @@ export class AudioPipeline {
if (micPub?.track === undefined) return;
try {
const mediaTrack = micPub.track.mediaStreamTrack;
// Source from the NS processor's output when one is attached, not the
// raw mic track — livekit-client's LocalTrack.setProcessor() does its
// own (internal, unawaited) sender.replaceTrack(processedTrack) once
// the worklet loads, and that call lands AFTER this one (it awaits
// addModule+fetch first). Sourcing from mediaStreamTrack unconditionally
// meant that call always won, silently rewiring the sender straight to
// the raw mic and bypassing this pipeline's gain/VAD entirely (B3-1).
const mediaTrack =
micPub.track.getProcessor()?.processedTrack ?? micPub.track.mediaStreamTrack;
const ctx = new AudioContext({ sampleRate: 48000 });
void ctx.resume(); // Ensure not suspended (WebView2 autoplay policy)
@@ -163,7 +183,11 @@ export class AudioPipeline {
if (this.room !== null) {
const micPub = this.room.localParticipant.getTrackPublication(Track.Source.Microphone);
if (micPub?.track?.sender !== undefined) {
const originalTrack = micPub.track.mediaStreamTrack;
// Restore to the NS processor's output when one is still attached, not
// the raw mic — otherwise tearing down just the gain/VAD wrapper (e.g.
// muting) would also silently bypass an active noise suppressor (B3-1).
const originalTrack =
micPub.track.getProcessor()?.processedTrack ?? micPub.track.mediaStreamTrack;
void micPub.track.sender
.replaceTrack(originalTrack)
.then(() => {
@@ -270,15 +294,18 @@ export class AudioPipeline {
// Try AudioWorklet first
const gen = this._pipelineGeneration;
const vadGen = this._vadGeneration;
this.audioPipelineCtx.audioWorklet
.addModule("/vad-worklet.js")
.then(() => {
if (gen !== this._pipelineGeneration) return; // Torn down while loading
if (vadGen !== this._vadGeneration) return; // stopVadPolling() while loading
if (this.audioPipelineCtx === null) return;
this.startVadWorklet(threshold);
})
.catch((err) => {
if (gen !== this._pipelineGeneration) return;
if (vadGen !== this._vadGeneration) return;
log.warn("AudioWorklet unavailable, falling back to setTimeout VAD", err);
this.startVadFallback(threshold);
});
@@ -389,6 +416,7 @@ export class AudioPipeline {
/** Stop VAD (both worklet and fallback). Pipeline stays intact. */
stopVadPolling(): void {
this._vadGeneration++;
// Stop setTimeout fallback
if (this.vadTimer !== null) {
clearTimeout(this.vadTimer);
+5 -2
View File
@@ -86,8 +86,11 @@ export function startAutoIdle(options: AutoIdleOptions): AutoIdleController {
let destroyed = false;
/** True while the timer is the reason the status is idle. Kept in memory so
* the hot path (one mousemove per pixel) is a boolean check rather than a
* preference read. */
let idleByTimer = false;
* preference read. Seeded from the persisted status/origin so a session
* that starts already auto-idle (app restart, MainPage remount) can still
* be un-idled by activity — otherwise the latch starts false and apply(false)
* is unreachable until the user manually reselects a status. */
let idleByTimer = loadUserStatus() === "idle" && loadUserStatusOrigin() === "auto";
function apply(idle: boolean): void {
const next = nextAutoStatus(loadUserStatus(), loadUserStatusOrigin(), idle);
@@ -0,0 +1,47 @@
/**
* cert-reconnect — resume a WS connection after the user accepts a rotated
* TLS certificate fingerprint (TOFU mismatch flow).
*
* Extracted out of main.ts, which has no unit-test seam of its own (it wires
* the DOM, router and stores together at startup and is exercised at the
* e2e level — see vitest.config.ts's coverage excludes) so this one piece of
* retry logic can be tested directly.
*/
export interface CertReconnectWs {
connect(cfg: { readonly host: string; readonly token: string }): void;
onStateChange(listener: (state: string) => void): () => void;
}
export interface CertReconnectRouter {
getCurrentPage(): string;
navigate(page: string): void;
}
/**
* Reconnect after the user accepts a rotated certificate fingerprint.
*
* wirePostAuth's own onStateChange handler unsubscribes itself the moment it
* sees "disconnected" (so a later transition can't fire it a second time) —
* and the mismatch that triggered this retry is exactly the "disconnected"
* transition that did so. A bare `ws.connect()` here would therefore
* reconnect into a page with nothing left listening to leave the connect
* screen. Re-register a one-shot navigator first, unless the app already
* reached "main" (mismatch arrived after login, socket already live there).
*/
export function reconnectAfterCertAccept(
ws: CertReconnectWs,
router: CertReconnectRouter,
host: string,
token: string,
): void {
if (router.getCurrentPage() !== "main") {
const unsub = ws.onStateChange((state) => {
if (state === "connected") {
unsub();
router.navigate("main");
}
});
}
ws.connect({ host, token });
}
+65 -7
View File
@@ -18,11 +18,28 @@
* and `notificationSounds` live in localStorage next to it.
*/
import { loadPref, savePref } from "./preferences";
import { loadPref, savePref, STORAGE_PREFIX } from "./preferences";
/** localStorage key (under the shared settings prefix). */
const MUTED_KEY = "mutedChannels";
/**
* Server host the mutes below belong to. The app is multi-server (saved
* profiles keyed by host, all sharing one Tauri webview origin and therefore
* one localStorage), and channel ids are per-server SQLite autoincrement
* integers — without a host component in the key, muting channel 7 on one
* server silently mutes channel 7 on every other server too. `setChannelMutesHost`
* is always called with a real host before any mute is read (see MainPage.ts),
* so the `null` startup default is not what protects a pre-scoping install's
* saved mutes — `readMuted` does that below by reading through to the
* original unscoped key on a miss at the scoped one.
*/
let currentHost: string | null = null;
function mutedKey(): string {
return currentHost === null ? MUTED_KEY : `${MUTED_KEY}:${currentHost}`;
}
/**
* Cached parse of the stored list. Notification gating runs on every incoming
* message, and a JSON.parse per message for a list that changes on a menu
@@ -32,9 +49,20 @@ const MUTED_KEY = "mutedChannels";
*/
let cache: ReadonlySet<number> | null = null;
function readMuted(): ReadonlySet<number> {
if (cache !== null) return cache;
const raw = loadPref<unknown[]>(MUTED_KEY, []);
/**
* Point mute reads/writes at a specific server's key and drop the cache so
* the next read re-parses under the new key instead of returning the
* previous server's set. Call on connect and on server switch — mirroring
* how `read-state.ts`'s `setMarkReadSender` and `ui.store.ts`'s
* `loadCollapsedCategories` are wired from MainPage per-connection.
*/
export function setChannelMutesHost(host: string | null): void {
if (host === currentHost) return;
currentHost = host;
invalidateMuteCache();
}
function parseMutedIds(raw: unknown): Set<number> {
const ids = new Set<number>();
if (Array.isArray(raw)) {
for (const v of raw) {
@@ -43,13 +71,43 @@ function readMuted(): ReadonlySet<number> {
if (typeof v === "number" && Number.isInteger(v) && v > 0) ids.add(v);
}
}
cache = ids;
return ids;
}
/** Whether a raw localStorage entry exists at all under `key` (prefixed) —
* as opposed to `loadPref`'s fallback, which can't distinguish "absent" from
* "present but happens to equal the fallback". An empty saved mute list is
* real data (the user unmuted everything) and must not be treated as a miss. */
function keyExists(key: string): boolean {
return localStorage.getItem(STORAGE_PREFIX + key) !== null;
}
function readMuted(): ReadonlySet<number> {
if (cache !== null) return cache;
const scopedKey = mutedKey();
if (currentHost === null || keyExists(scopedKey)) {
cache = parseMutedIds(loadPref<unknown[]>(scopedKey, []));
return cache;
}
// Miss at the scoped key: read through to the pre-scoping legacy key once
// and persist the result under the scoped key so the read-through isn't
// repeated. A different host with its OWN explicit (even empty) mute list
// is not touched by this — it never reaches this branch.
if (keyExists(MUTED_KEY)) {
const legacy = parseMutedIds(loadPref<unknown[]>(MUTED_KEY, []));
writeMuted(legacy);
return legacy;
}
cache = new Set();
return cache;
}
function writeMuted(ids: ReadonlySet<number>): void {
cache = ids;
savePref(MUTED_KEY, [...ids]);
savePref(mutedKey(), [...ids]);
}
/** Drop the cached parse. Exported for tests and for logout. */
@@ -60,7 +118,7 @@ export function invalidateMuteCache(): void {
if (typeof window !== "undefined") {
window.addEventListener("owncord:pref-change", (e) => {
const detail = (e as CustomEvent<{ key?: string }>).detail;
if (detail?.key === MUTED_KEY) invalidateMuteCache();
if (detail?.key === mutedKey()) invalidateMuteCache();
});
// Cross-tab: the native storage event fires only in the *other* tab.
window.addEventListener("storage", () => invalidateMuteCache());
@@ -5,6 +5,7 @@
*/
import { setActiveChannel, clearUnread, channelsStore } from "@stores/channels.store";
import { clearDmUnread } from "@stores/dm.store";
/**
* Activate `channelId`, clearing its unread and mention badges.
@@ -17,6 +18,13 @@ export function navigateToChannel(channelId: number): void {
if (!channelsStore.getState().channels.has(channelId)) return;
setActiveChannel(channelId);
clearUnread(channelId);
// findChannelById does not filter out DM mirrors, so a jump (permalink,
// search, pinned, reply) can land on a `type: "dm"` channel. Its unread
// badge lives in dmStore, not channelsStore — clearUnread alone leaves the
// DM sidebar row lit while the user is reading it. No-op for a non-DM id
// (dmStore has no matching channel), mirroring markChannelRead's dual
// clear (read-state.ts).
clearDmUnread(channelId);
}
/**
+28 -2
View File
@@ -4,14 +4,14 @@
*/
import { createLogger } from "./logger";
import { authStore } from "@stores/auth.store";
const log = createLogger("credentials");
export interface SavedCredential {
readonly username: string;
readonly token: string;
// Note: password is no longer returned from the Rust backend over IPC
// to limit credential exposure in the JS heap.
readonly password?: string;
}
/** Dynamically import Tauri invoke to avoid errors in test/browser. */
@@ -50,6 +50,31 @@ export async function saveCredential(
}
}
/**
* Build a `user_update` listener that refreshes a session's stored
* credential when the local user's own profile changes (a username edit, or
* the identity-key PATCH) — mirroring the initial saveCredential call's
* remember-password opt-out (BUG-135) so a later profile edit can't silently
* persist a bearer token the user declined to store. Passes the session's
* password through on every call: save_credential replaces the whole stored
* blob, so omitting it (defaulting to null) would wipe out the password
* saved at login for a user who DID opt in.
*/
export function createUserUpdateCredentialSaver(
host: string,
rememberPassword: boolean,
password: string | undefined,
): (payload: { readonly user_id: number; readonly username: string }) => void {
return (payload) => {
if (!rememberPassword) return;
const currentUserId = authStore.getState().user?.id ?? 0;
if (payload.user_id !== currentUserId) return;
const currentToken = authStore.getState().token;
if (!currentToken) return;
void saveCredential(host, payload.username, currentToken, password);
};
}
/**
* Load a credential from Windows Credential Manager.
* Returns null if not found or Tauri unavailable.
@@ -67,6 +92,7 @@ export async function loadCredential(host: string): Promise<SavedCredential | nu
return {
username: cred.username,
token: cred.token,
password: typeof cred.password === "string" ? cred.password : undefined,
};
}
}
+59 -9
View File
@@ -5,6 +5,7 @@
// Monitors navigator.mediaDevices.ondevicechange for hot-swap (unplug/plug).
import { Room } from "livekit-client";
import { voiceStore } from "@stores/voice.store";
import { loadPref, savePref } from "@components/settings/helpers";
import { createLogger } from "@lib/logger";
import type { AudioPipeline } from "@lib/audioPipeline";
@@ -14,6 +15,25 @@ const log = createLogger("deviceManager");
/** Debounce interval for device change events (ms). */
const DEVICE_CHANGE_DEBOUNCE_MS = 500;
/** True when a mute/deafen/server-mute/push-to-talk gate means the mic must
* stay off regardless of a caller's own request to (re-)enable it.
* livekit-client's setMicrophoneEnabled(true) is a bare track.unmute() when
* a muted-but-published track survives a toggle (only ScreenShare actually
* unpublishes) — no LocalTrackPublished/TrackUnmuted event fires for
* anything downstream to catch and correct, so every re-enable path has to
* check this itself instead of relying on one. Exported so LiveKitSession's
* own re-enable paths (setDeafened's unmute branch, retryMicPermission)
* share the same gate instead of each re-deriving it. */
export function isMicPolicyGated(): boolean {
const s = voiceStore.getState();
return (
s.localMuted === true ||
s.localDeafened === true ||
s.localServerMuted === true ||
s.pttGated === true
);
}
export class DeviceManager {
private room: Room | null = null;
private audioPipeline: AudioPipeline | null = null;
@@ -43,6 +63,21 @@ export class DeviceManager {
this.onToast = cb;
}
/** Toggle the mic off/on to force a fresh capture after a device change,
* skipping the re-enable when a mute/deafen/server-mute/PTT gate is
* active. Shared by handleDeviceChange's device-removed fallback and
* switchInputDevice('') — both drive the exact same false/true cycle, and
* both were unconditionally republishing a gated mic before this guard. */
private async cycleMicForDeviceSwitch(room: Room): Promise<void> {
await room.localParticipant.setMicrophoneEnabled(false);
if (this.room !== room) return;
if (isMicPolicyGated()) {
log.debug("Skipping mic re-enable after device switch — muted/deafened/gated");
return;
}
await room.localParticipant.setMicrophoneEnabled(true);
}
// --- Device change detection (hot-swap) ---
private startDeviceChangeListener(): void {
@@ -70,11 +105,19 @@ export class DeviceManager {
}
private async handleDeviceChange(): Promise<void> {
if (this.room === null) return;
// Snapshot the room this attempt started for. `this.room` is a mutable
// field that a system-driven reconnect (or session teardown) can
// reassign out from under an in-flight await below — re-reading it
// after each await would apply the fallback to the wrong Room, or throw
// a null-deref that surfaces as a misleading "No audio input device
// available" error after the user already left voice (v096).
const room = this.room;
if (room === null) return;
log.info("Device change detected");
try {
const devices = await Room.getLocalDevices("audioinput");
if (this.room !== room) return;
const savedInput = loadPref<string>("audioInputDevice", "");
// Check if the saved input device was removed
@@ -84,8 +127,8 @@ export class DeviceManager {
savePref("audioInputDevice", "");
// Switch to default device
try {
await this.room.localParticipant.setMicrophoneEnabled(false);
await this.room.localParticipant.setMicrophoneEnabled(true);
await this.cycleMicForDeviceSwitch(room);
if (this.room !== room) return;
try {
this.audioPipeline?.setupAudioPipeline();
} catch (pipelineErr) {
@@ -94,6 +137,7 @@ export class DeviceManager {
}
this.onToast?.("Audio device disconnected — switched to default");
} catch (err) {
if (this.room !== room) return;
log.error("Failed to fallback to default input device", err);
this.onErrorCallback?.("No audio input device available");
}
@@ -101,6 +145,7 @@ export class DeviceManager {
// Check output device
const outputDevices = await Room.getLocalDevices("audiooutput");
if (this.room !== room) return;
const savedOutput = loadPref<string>("audioOutputDevice", "");
if (savedOutput !== "" && !outputDevices.some((d) => d.deviceId === savedOutput)) {
log.warn("Saved audio output device removed — falling back to default", { savedOutput });
@@ -113,17 +158,18 @@ export class DeviceManager {
}
async switchInputDevice(deviceId: string): Promise<void> {
if (this.room === null) {
const room = this.room;
if (room === null) {
log.debug("Skipping input device switch — no active voice session");
return;
}
try {
if (deviceId) {
await this.room.switchActiveDevice("audioinput", deviceId);
await room.switchActiveDevice("audioinput", deviceId);
} else {
await this.room.localParticipant.setMicrophoneEnabled(false);
await this.room.localParticipant.setMicrophoneEnabled(true);
await this.cycleMicForDeviceSwitch(room);
}
if (this.room !== room) return;
// Rebuild audio pipeline (source track changed after device switch)
try {
this.audioPipeline?.setupAudioPipeline();
@@ -140,13 +186,15 @@ export class DeviceManager {
}
log.info("Switched input device", { deviceId });
} catch (err) {
if (this.room !== room) return;
log.error("Failed to switch input device", err);
this.onErrorCallback?.("Failed to switch microphone");
}
}
async switchOutputDevice(deviceId: string): Promise<void> {
if (this.room === null) {
const room = this.room;
if (room === null) {
log.debug("Skipping output device switch — no active voice session");
return;
}
@@ -155,9 +203,11 @@ export class DeviceManager {
// so an unhandled rejection would leave the user staring at a selection
// that never took effect.
try {
await this.room.switchActiveDevice("audiooutput", deviceId);
await room.switchActiveDevice("audiooutput", deviceId);
if (this.room !== room) return;
log.info("Switched output device", { deviceId });
} catch (err) {
if (this.room !== room) return;
log.error("Failed to switch output device", err);
this.onErrorCallback?.("Failed to switch speaker");
}
+351 -29
View File
@@ -3,8 +3,8 @@
// Each server message type maps to one or more store actions.
import type { WsClient } from "./ws";
import { toConnectionStatus } from "./ws";
import { authStore, setAuth, clearAuth } from "@stores/auth.store";
import { toConnectionStatus, setActiveChannelProvider } from "./ws";
import { authStore, setAuth, clearAuth, updateUser } from "@stores/auth.store";
import { setTransientError, setConnectionStatus } from "@stores/ui.store";
import {
setChannels,
@@ -23,9 +23,13 @@ import {
deleteMessage,
bulkDeleteMessages,
updateReaction,
rollbackReaction,
confirmSend,
markSendFailed,
messagesStore,
setMessages,
invalidateLoadedMessageWindows,
setChannelLoadError,
} from "@stores/messages.store";
import {
setMembers,
@@ -50,10 +54,12 @@ import {
dmStore,
setDmChannels,
addDmChannel,
removeDmChannel,
closeDmLocally,
updateDmLastMessage,
updateDmLastMessagePreview,
incrementDmMention,
dmDisplayName,
updateDmParticipant,
} from "@stores/dm.store";
import type { DmChannel } from "@stores/dm.store";
import { setBlockedByMe, setUserBlockedByThem, clearBlockedByThem } from "@stores/blocks.store";
@@ -64,9 +70,15 @@ import { invalidateReactionUsers } from "@components/message-list/reaction-toolt
import { notifyIncomingMessage } from "./notifications";
import { highlightsCurrentUser } from "./mentions";
import { ensureIdentityKeyPublished } from "@lib/identity";
import { markChannelRead } from "./read-state";
import { createLogger } from "./logger";
import { showToast } from "./toast";
import { ServerMessageType as S } from "./protocolTypes";
// SidebarDmHelpers is page-level, but addDmToChannelsStore is the only
// place the DM->channelsStore mirror row is synthesized (selectDmConversation
// on open); the dm_channel_close fallback below needs the same synthesis for
// a DM it is activating that was never opened this session.
import { addDmToChannelsStore } from "@pages/main-page/SidebarDmHelpers";
const log = createLogger("dispatcher");
@@ -126,20 +138,74 @@ export function wireConnectionStatus(ws: Pick<WsClient, "onStateChange">): () =>
* Returns a cleanup function that removes all listeners.
*
* `api` is optional so tests can wire the dispatcher without a client; when
* present it is used to refresh DM block state (GET /blocks) on ready.
* present it is used to refresh DM block state (GET /blocks) on ready, and to
* refetch the active channel's history after a full-ready resync.
*/
export function wireDispatcher(
ws: WsClient,
api?: Pick<ApiClient, "listBlocks"> &
Partial<Pick<ApiClient, "updateProfile" | "getConfig" | "listEmoji">>,
Partial<Pick<ApiClient, "updateProfile" | "getConfig" | "listEmoji" | "getMessages">>,
): DispatcherCleanup {
const unsubs: Array<() => void> = [];
// A second (or later) auth_ok/ready in this call's lifetime is always a
// reconnect: wireDispatcher is called once per login (main.ts's
// wirePostAuth), and every automatic reconnect fires its events through
// these same long-lived listeners. Closure-scoped so a fresh login (a new
// wireDispatcher call) always starts clean.
let hasAuthenticatedBefore = false;
let hasReceivedReadyBefore = false;
// Set from the second-or-later auth_ok — the reconnect handshake time, in
// THIS CLIENT's clock. A chat_message replay frame the transport delivers
// after it is timestamped *before* it; a genuinely live message is
// timestamped after. But payload.timestamp is the SERVER's created_at, in
// the SERVER's clock — comparing it to this anchor directly mixes clock
// domains, so the comparison below shifts the anchor into server time
// using serverClockSkewMs first (see its declaration below).
let lastReconnectHandshakeAt: number | null = null;
// Running estimate of (this client's clock) minus (the server's clock),
// sampled from the most recently accepted live chat_message (Date.now() at
// receipt minus that frame's own server timestamp). A self-hosted server
// routinely runs without NTP or with a skewed TZ/clock, and comparing its
// timestamps against lastReconnectHandshakeAt without this correction means
// a lagging server clock makes every genuinely live message look like a
// replay for the whole drift window after every reconnect — and with
// persistent skew that never recovers. Network latency between the
// server's send and this receipt biases the estimate positive, which nudges
// the boundary computed below slightly EARLY relative to the server's true
// clock; that is the safe direction — a missed replay suppression is at
// worst a duplicate notification, while a false replay classification
// silently drops one.
let serverClockSkewMs = 0;
// ── Auth ──────────────────────────────────────────────
// Let the transport declare the open channel in the auth frame itself, so a
// resuming server can restore the ChannelTopic subscription during the
// handshake rather than only after the channel_focus round trip below —
// closing the window in which channel broadcasts reach nobody on this
// socket. The round trip stays as the fallback for older servers.
setActiveChannelProvider(() => channelsStore.select((s) => s.activeChannelId));
unsubs.push(() => setActiveChannelProvider(null));
unsubs.push(
ws.on(S.AUTH_OK, (payload) => {
if (hasAuthenticatedBefore) {
lastReconnectHandshakeAt = Date.now();
}
hasAuthenticatedBefore = true;
setAuth(authStore.getState().token ?? "", payload.user, payload.server_name, payload.motd);
// The resume path can land with no ChannelTopic subscription: the hub
// only transfers a focused channel from an old connection entry, but
// readPump's unregister deletes that entry as soon as the server
// observes the socket close — which happens well before the client's
// first reconnect attempt. Re-asserting focus here (idempotent on the
// server) covers that gap on every connect, resume included.
const activeChannelId = channelsStore.select((s) => s.activeChannelId);
if (activeChannelId !== null) {
ws.send({ type: "channel_focus", payload: { channel_id: activeChannelId } });
}
}),
);
@@ -196,19 +262,118 @@ export function wireDispatcher(
);
}
// Auto-select the first text channel if none is active
// Auto-select the first text channel if none is active; clear it when
// the channel this session was viewing is gone from the fresh snapshot
// (deleted, or a DM closed elsewhere while this client was offline) so
// the activeChannelId subscriber actually fires and tears down the
// stale message list/composer instead of leaving them mounted against
// a channel the server no longer recognizes. Checked against the raw
// payload (not the synthesized channelsStore row) so a still-open DM
// that was never locally synthesized this session isn't wrongly
// cleared.
const currentActive = channelsStore.select((s) => s.activeChannelId);
// Set only when the branch below clears a channel that was active
// before this ready — distinct from "no channel was active", which
// must NOT mark-read whatever the auto-select branch just picked.
let activeChannelCleared = false;
if (currentActive === null && payload.channels.length > 0) {
const firstText = payload.channels.find((ch) => ch.type === "text");
if (firstText !== undefined) {
setActiveChannel(firstText.id);
}
} else if (currentActive !== null) {
const stillPresent =
payload.channels.some((ch) => ch.id === currentActive) ||
(payload.dm_channels ?? []).some((dm) => dm.channel_id === currentActive);
if (!stillPresent) {
setActiveChannel(null);
activeChannelCleared = true;
}
}
// Populate DM channels if present in the ready payload
// A second (or later) `ready` in this dispatcher's lifetime only ever
// arrives from a full-ready resync (Server/ws/serve.go: a fresh connect
// and a full resync are the only paths that send `ready` at all — a
// successful seq-based replay reconnect does not), and that tier never
// replays missed chat_message frames. Every channel this session had
// already loaded would otherwise keep a permanent, silent hole in its
// history — invalidate them and refetch the one actually on screen.
if (hasReceivedReadyBefore) {
const activeAfterReady = channelsStore.select((s) => s.activeChannelId);
const getMessages = api?.getMessages;
// Only invalidate when the refetch below can actually happen — api is
// a Partial<...>, so getMessages may be absent, and there may be no
// resolvable active channel to refetch. Dropping every loaded window
// with nothing able to reload it would leave a mounted MessageList
// showing only carried-through pending rows until the user navigates
// away and back.
if (activeAfterReady !== null && getMessages !== undefined) {
invalidateLoadedMessageWindows();
getMessages(activeAfterReady, { limit: 50 })
.then((resp) => setMessages(activeAfterReady, resp.messages, resp.has_more))
.catch((err) => {
log.warn("Failed to reload message history after resync", { error: String(err) });
// The invalidate above already dropped this channel's window,
// so a silent catch would leave a mounted MessageList showing
// its "no messages yet" welcome state — indistinguishable from
// a genuinely empty channel. Route through the same
// historyLoadState the normal load path uses so the region
// shows the inline error + Retry instead (MessageController's
// loadMessages, wired to the Retry button, re-fetches because
// invalidate also cleared "loaded").
setChannelLoadError(activeAfterReady);
});
}
}
hasReceivedReadyBefore = true;
// Populate DM channels from the ready payload. The server always sends
// the field, so an empty array is an authoritative "no open DMs" (all
// closed/left on another device) and must clear ghosts from dmStore —
// skipping it would let a stale DM survive every reconnect.
const dmPayloads = payload.dm_channels ?? [];
if (dmPayloads.length > 0) {
setDmChannels(dmPayloads.map(mapDmPayload));
setDmChannels(dmPayloads.map(mapDmPayload));
// The channels-store mirror row for a DM (synthesized on open by
// addDmToChannelsStore) is deliberately carried across setChannels'
// rebuild above, because the ready payload never includes DM rows at
// all — but that means a DM closed elsewhere while this client was
// offline keeps a phantom row here (closeDmLocally fixes this exact
// shape for the live dm_channel_close path; this is its ready-time
// equivalent), and a DM read elsewhere keeps a stale unread/mention
// count (incrementUnread/incrementMention bump the mirror in parallel
// with dmStore once it exists, but only dmStore is restated above).
// Reconcile every dm-typed row against the just-restated payload.
channelsStore.setState((prev) => {
const dmById = new Map(dmPayloads.map((d) => [d.channel_id, d]));
const nextChannels = new Map(prev.channels);
let changed = false;
for (const [id, ch] of prev.channels) {
if (ch.type !== "dm") continue;
const dm = dmById.get(id);
if (dm === undefined) {
nextChannels.delete(id);
changed = true;
continue;
}
const mentionCount = dm.mention_count ?? 0;
if (ch.unreadCount !== dm.unread_count || ch.mentionCount !== mentionCount) {
nextChannels.set(id, { ...ch, unreadCount: dm.unread_count, mentionCount });
changed = true;
}
}
return changed ? { ...prev, channels: nextChannels } : prev;
});
// The server's read_states go stale while a channel stays focused
// (channel_focus is sent once per mount, mark_read only from the context
// menu), so a full-ready resync restates non-zero unread/mention counts
// for the very channel the user is reading. Mark it read: this advances
// the server read state and clears the local badges, for server channels
// and DMs alike. Skipped on first connect (nothing was active yet) and
// when the block above just cleared a channel that's gone.
if (currentActive !== null && !activeChannelCleared) {
markChannelRead(currentActive);
}
// Refresh DM block state (channels-members-dms.md §3.2). "Being blocked"
@@ -268,7 +433,28 @@ export function wireDispatcher(
unsubs.push(
ws.on(S.DM_CHANNEL_CLOSE, (payload) => {
log.info("DM channel closed", { channelId: payload.channel_id });
removeDmChannel(payload.channel_id);
// Delivered to a device that never ran the local close flow (closed
// from another signed-in device) — unlike the sidebar's closeOrLeaveDm,
// there is no "channel visited before this DM" to restore, so fall
// back to another open DM, else the first text channel.
closeDmLocally(payload.channel_id, () => {
const remaining = dmStore.getState().channels;
if (remaining.length > 0) {
// Synthesize the channelsStore mirror row before activating: it is
// only ever created by addDmToChannelsStore (on open, via
// selectDmConversation), so a DM present in dmStore from `ready`
// but never opened this session has none — without this,
// activating it lands on an id ChannelController can't resolve and
// blanks the chat area with no way to recover.
addDmToChannelsStore(remaining[0]!);
setActiveChannel(remaining[0]!.channelId);
return;
}
const firstText = [...channelsStore.getState().channels.values()]
.filter((ch) => ch.type === "text")
.toSorted((a, b) => a.position - b.position)[0];
setActiveChannel(firstText?.id ?? null);
});
}),
);
@@ -296,15 +482,15 @@ export function wireDispatcher(
// DM channel IDs are not in channelsStore (they use dmStore), so
// incrementUnread is a no-op for DMs, but the own-message guard is
// applied here for defence-in-depth.
const isMention = highlightsCurrentUser(payload.content, {
mentions: payload.mentions,
mentionsEveryone: payload.mentions_everyone,
});
if (payload.channel_id !== activeId && !isOwnMessage && !ws.isReplaying()) {
incrementUnread(payload.channel_id);
// A mention is an unread too — the mention badge just outranks it.
if (
highlightsCurrentUser(payload.content, {
mentions: payload.mentions,
mentionsEveryone: payload.mentions_everyone,
})
) {
if (isMention) {
incrementMention(payload.channel_id);
}
}
@@ -323,11 +509,32 @@ export function wireDispatcher(
);
} else {
updateDmLastMessage(payload.channel_id, payload.id, payload.content, payload.timestamp);
// The DM badge reads dmStore's mentionCount (mute-immune, rendered
// by DmSidebar) — incrementMention above no-ops for DM ids, which
// are absent from channelsStore. Same guards as the unread bump.
if (isMention) {
incrementDmMention(payload.channel_id);
}
}
}
// Fire desktop notification, taskbar flash, and sound
notifyIncomingMessage(payload);
// Fire desktop notification, taskbar flash, and sound — but not for a
// reconnect's replayed burst. ws.isReplaying() cannot gate this the way
// it gates the unread counter above: ws.ts clears it as soon as auth_ok
// is processed, before the replay burst itself even arrives. A replay
// frame's timestamp instead predates the reconnect handshake that
// preceded it, unlike a genuinely new live message — compared in
// server-clock terms (see serverClockSkewMs above) so a lagging or
// skewed server clock cannot make a live message look like a replay.
const isReplayFrame =
lastReconnectHandshakeAt !== null &&
Date.parse(payload.timestamp) < lastReconnectHandshakeAt - serverClockSkewMs;
if (!isReplayFrame) {
notifyIncomingMessage(payload);
// Refresh the skew estimate from this accepted-as-live frame so it
// stays current for the next reconnect.
serverClockSkewMs = Date.now() - Date.parse(payload.timestamp);
}
}),
);
@@ -385,6 +592,10 @@ export function wireDispatcher(
// store treats "field absent" as "leave the text alone", which is what
// an older server's presence event means.
updatePresence(payload.user_id, payload.status, payload.custom_status);
// dmStore keeps its own frozen copy of a DM partner's status for the
// sidebar row (see buildDmConversations) — membersStore alone does not
// reach it.
updateDmParticipant(payload.user_id, { status: payload.status });
}),
);
@@ -414,6 +625,9 @@ export function wireDispatcher(
.toSorted((a, b) => a.position - b.position);
const firstTextId = sorted.length > 0 ? sorted[0]!.id : null;
setActiveChannel(firstTextId);
// The redirect alone reads as the app spontaneously changing channels;
// say why (ux/channels-members-dms §1.2).
showToast("This channel was deleted", "info");
log.info("Active channel deleted, redirected", { deletedId: payload.id });
}
}),
@@ -446,6 +660,16 @@ export function wireDispatcher(
ws.on(S.MEMBER_UPDATE, (payload) => {
log.info("Member role updated", { userId: payload.user_id, role: payload.role });
updateMemberRole(payload.user_id, payload.role);
// Keep authStore in sync when the signed-in user's own role changed —
// every permission gate (canManageChannels, canViewAuditLog, ...) reads
// authStore.user.role, not membersStore, so without this a promotion or
// demotion of the current user would leave every affordance stale until
// the socket reconnects (mirrors the USER_UPDATE self-branch below).
const me = authStore.getState().user;
if (me && payload.user_id === me.id) {
updateUser({ role: payload.role });
}
}),
);
@@ -479,6 +703,17 @@ export function wireDispatcher(
displayName: payload.display_name,
identityPublicKey: payload.identity_public_key,
});
// Same reasoning as PRESENCE above: dmStore's copy of a DM partner's
// username/avatar/displayName is otherwise never refreshed. DmUser's
// avatar/displayName are non-nullable ("" = unset), so null (cleared)
// maps to "". display_name absent means "leave the nickname alone" —
// an older or partial payload must not blank it, exactly as
// updateMemberProfile above.
updateDmParticipant(payload.user_id, {
username: payload.username,
avatar: payload.avatar ?? "",
...(payload.display_name === undefined ? {} : { displayName: payload.display_name ?? "" }),
});
// Update auth store if the current user changed their own profile.
const currentUser = authStore.getState().user;
@@ -555,13 +790,26 @@ export function wireDispatcher(
unsubs.push(
ws.on(S.VOICE_LEAVE, (payload) => {
removeVoiceUser(payload);
// Notify E2EE state machine so key holder can rotate the room key.
void livekitSession().then(({ handleParticipantLeft }) =>
handleParticipantLeft(payload.user_id),
);
// Clear local voice state if the current user was removed (kick/disconnect)
const currentUserId = authStore.getState().user?.id ?? 0;
if (payload.user_id === currentUserId) {
const isSelf = payload.user_id === currentUserId;
// A server-initiated eviction (revocation sweep, channel delete) has no
// companion teardown message — this voice_leave IS the signal that
// drives our own LiveKit/E2EE teardown, or mic publish and key material
// stay live while the UI shows not-in-voice. Guard on channel match: a
// late-arriving voice_leave for a channel we already left (and rejoined
// elsewhere) must not kill a newer join. Read the store before
// leaveVoiceChannel() below clears currentChannelId.
const shouldTeardownSession =
isSelf && voiceStore.getState().currentChannelId === payload.channel_id;
// Notify E2EE state machine so key holder can rotate the room key, and
// (when applicable) tear down the media session — both through one lazy
// import so the two effects cannot land in different ticks.
void livekitSession().then(({ handleParticipantLeft, leaveVoice }) => {
void handleParticipantLeft(payload.user_id);
if (shouldTeardownSession) void leaveVoice(false);
});
// Clear local voice state if the current user was removed (kick/disconnect)
if (isSelf) {
leaveVoiceChannel();
}
}),
@@ -637,13 +885,38 @@ export function wireDispatcher(
// Local transport failures (proxy not open, outbound channel full/closed):
// fail the matching optimistic row exactly like a server error reply would.
// Fire-and-forget sends (typing, presence, voice) have no pendingSends entry
// and stay logged-only.
// An optimistic reaction toggle rolls back the same way. Fire-and-forget
// sends (typing, presence, voice) have no pending entry and stay logged-only.
// A connection that leaves "connected" can never deliver chat_send_ok for
// frames already handed to the transport: fail every pending optimistic
// send so its row offers retry instead of spinning forever (and the leaked
// pendingSends entries are cleared).
unsubs.push(
ws.onStateChange((state) => {
if (state !== "reconnecting" && state !== "disconnected") return;
// Snapshot the ids: markSendFailed deletes from pendingSends, so
// iterating the live Map's keys would mutate during iteration.
for (const id of Array.from(messagesStore.getState().pendingSends.keys())) {
markSendFailed(id, "OFFLINE");
}
// Same reasoning applies to optimistic reaction toggles: a reaction
// frame already handed to a dying socket can never deliver its
// chat_send_ok/error either, so roll back every pending toggle instead
// of leaving a permanently wrong pill and a stale pendingReactions
// entry that could later consume an unrelated self-echo.
for (const id of Array.from(messagesStore.getState().pendingReactions?.keys() ?? [])) {
rollbackReaction(id);
}
}),
);
unsubs.push(
ws.onSendFailure((id, code) => {
if (messagesStore.getState().pendingSends.has(id)) {
markSendFailed(id, code);
return;
}
rollbackReaction(id);
}),
);
@@ -675,11 +948,20 @@ export function wireDispatcher(
chId === undefined
? undefined
: dmStore.getState().channels.find((c) => c.channelId === chId);
if (dm !== undefined) setUserBlockedByThem(dm.recipient.id, true);
// Block gating is a 1:1-only rule (server exempts group DMs from
// block checks entirely — a group FORBIDDEN means something else,
// e.g. stale membership). recipient is just participants[0] for a
// group, so flagging it there would gate an unrelated 1:1 DM.
if (dm !== undefined && !dm.isGroup) setUserBlockedByThem(dm.recipient.id, true);
}
markSendFailed(id, payload.code);
return;
}
// A failed optimistic reaction toggle: the pill reverting is the
// feedback the spec asks for (ux/messaging §5) — no toast on top.
if (id !== undefined && rollbackReaction(id)) {
return;
}
// Voice capacity refusals. The server owns the limits (voice_max_users /
// voice_max_video) and refuses the join or the camera; the client never
// pre-blocks the click, because its copy of the participant list can lag
@@ -688,14 +970,54 @@ export function wireDispatcher(
// with an explanation buried in the log.
if (payload.code === "CHANNEL_FULL") {
showToast(payload.message || "That voice channel is full", "error");
// The sidebar/widget optimistically writes currentChannelId before
// the server answers (VoiceCallbacks.onVoiceJoin). A first-time join
// refusal earns no voice_leave (there was no previous channel to
// leave), so nothing else clears that optimistic state — the sidebar
// is left keyed on a channel with no LiveKit session. A channel
// *switch* refusal doesn't need this: the server always leaves the
// old channel first, whose self voice_leave already reset
// voiceStatus to idle before this error arrives, so the guard is a
// no-op there.
if (voiceStore.getState().voiceStatus === "joining") {
leaveVoiceChannel();
}
return;
}
if (payload.code === "VIDEO_LIMIT") {
showToast(payload.message || "That voice channel has reached its video limit", "error");
// max_video has no SFU-level enforcement — the server only refuses the
// DB write. Without this rollback the already-published camera track
// keeps streaming to everyone while voice_state says camera=false.
void livekitSession().then(({ disableCamera }) => disableCamera());
return;
}
if (payload.code === "RATE_LIMITED" || payload.code === "FORBIDDEN") {
setTransientError(payload.message || "Server error");
// Every remaining code has no dedicated handler above (not a pending
// send/reaction rollback, not a capacity refusal) — this is the one
// place every remaining server error lands (a rejected fire-and-forget
// chat_edit, for one), so it must not be silently dropped just because
// it isn't RATE_LIMITED/FORBIDDEN. Set synchronously, independent of
// the video-rollback lookup below: both paths produce this exact same
// message, so there is nothing left to gate on that lookup resolving.
setTransientError(payload.message || "Server error");
// A server refusal of a voice_camera/voice_screenshare enable other
// than VIDEO_LIMIT (FORBIDDEN, RATE_LIMITED, INTERNAL, ...): roll back
// the already-published track, or it keeps streaming to every peer
// while the store says it's off. Correlated by envelope id — exactly
// like pendingSends/pendingReactions above — so an unrelated
// FORBIDDEN/RATE_LIMITED on some other action never touches video
// state. screenShare.ts pulls in livekit-client at module scope, so —
// like livekitSession() above — it's loaded lazily here too, at its
// one call site in this file.
if (id !== undefined) {
void import("@lib/screenShare").then(({ rollbackPendingVideo }) => {
const kind = rollbackPendingVideo(id);
if (kind === undefined) return;
void livekitSession().then(({ disableCamera, disableScreenshare }) =>
kind === "camera" ? disableCamera() : disableScreenshare(),
);
});
}
}),
);
+10 -9
View File
@@ -15,26 +15,28 @@ import { createLogger } from "./logger";
const log = createLogger("http-proxy");
/** host → resolved loopback origin (e.g. "http://127.0.0.1:49812"). */
const origins = new Map<string, string>();
/** host → in-flight start so concurrent callers don't race the tunnel. */
const pending = new Map<string, Promise<string>>();
/**
* Ensure a tunnel exists for `host` and return its loopback origin
* (no trailing slash). Idempotent and concurrency-safe per host.
* (no trailing slash). Concurrency-safe per host.
*
* Always invokes start_http_proxy — never caches the resolved origin here.
* Only the Rust side knows whether its listener is still alive: after 5
* consecutive accept errors run_proxy_loop deregisters itself so the next
* start_http_proxy rebinds a fresh port (http_proxy.rs). A JS-side cache
* would keep pointing every REST call at that dead tunnel until app restart.
* The Rust reuse branch dedups an unchanged host cheaply, so the repeat
* invoke is inexpensive — mirroring livekitSession.ts's ensureLiveKitProxy.
*/
export async function ensureHttpProxy(host: string): Promise<string> {
const cached = origins.get(host);
if (cached) return cached;
const inFlight = pending.get(host);
if (inFlight) return inFlight;
const start = (async () => {
const port = await invoke<number>("start_http_proxy", { remoteHost: host });
const origin = `http://127.0.0.1:${port}`;
origins.set(host, origin);
log.debug("tunnel ready", { host, origin });
return origin;
})();
@@ -47,9 +49,8 @@ export async function ensureHttpProxy(host: string): Promise<string> {
}
}
/** Stop the tunnel for `host` and drop its cached origin (best-effort). */
/** Stop the tunnel for `host` (best-effort). */
export async function stopHttpProxy(host: string): Promise<void> {
origins.delete(host);
pending.delete(host);
try {
await invoke("stop_http_proxy", { remoteHost: host });
+2
View File
@@ -65,6 +65,7 @@ export type IconName =
| "shield"
| "shield-check"
| "shield-alert"
| "shield-question"
| "zap";
// ---------------------------------------------------------------------------
@@ -216,6 +217,7 @@ const ICON_PATHS: Record<IconName, string> = {
shield: `<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>`,
"shield-check": `<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>`,
"shield-alert": `<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="M12 8v4"/><path d="M12 16h.01"/>`,
"shield-question": `<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="M9.1 9a3 3 0 0 1 5.82 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>`,
};
// ---------------------------------------------------------------------------
+210 -40
View File
@@ -3,9 +3,11 @@
* layer (F3). Mirrors credentials.ts: dynamically imports Tauri `invoke` and
* no-ops in non-Tauri environments (tests, browser).
*
* Two backing stores, both keyed by connection host:
* - OS keyring (save/load/delete_identity_key, account `identity:{host}`):
* the client's own long-term identity PRIVATE key (base64 JWK blob).
* Two backing stores:
* - OS keyring (save/load/delete_identity_key, account `identity:{host}:{uid}`):
* the client's own long-term identity PRIVATE key (base64 JWK blob),
* scoped by host AND user id (see `identityKeyPairCache` below — two
* accounts must never share one identity keypair).
* - identity_pins.json (store/get_identity_pin, key `{host}:{userId}`):
* peers' pinned identity PUBLIC keys (base64), for TOFU verification.
*/
@@ -17,6 +19,7 @@ import {
generateIdentityKeyPair,
importIdentityKeyPair,
} from "./e2eeCrypto";
import { authStore } from "@stores/auth.store";
const log = createLogger("identity");
@@ -50,7 +53,19 @@ export async function saveIdentityKey(host: string, key: string): Promise<boolea
}
}
/** Load the identity private-key blob for a host, or null if absent/unavailable. */
/**
* Load the identity private-key blob for a host, or null when nothing is
* stored (a clean `load_identity_key` resolution with no value).
*
* A command REJECTION is rethrown, not swallowed to null: `secret_store::get`
* on the Rust side reports `Ok(None)` only when both the keyring and the
* fallback file genuinely hold nothing, and propagates a keyring read error
* as `Err` instead. A rejection here is therefore a real, unreadable store —
* not "nothing stored". Callers (see `loadOrGenerateIdentityKeyPair`) rely on
* that distinction to abort instead of minting and publishing a fresh
* identity keypair over an existing one, which would invalidate every peer's
* TOFU pin.
*/
export async function loadIdentityKey(host: string): Promise<string | null> {
const invoke = await getInvoke();
if (!invoke) {
@@ -60,8 +75,12 @@ export async function loadIdentityKey(host: string): Promise<string | null> {
const result = await invoke("load_identity_key", { host });
return typeof result === "string" ? result : null;
} catch (err) {
log.error("Failed to load identity key", { host, error: String(err) });
return null;
log.error(
"Failed to load identity key — propagating so the caller does not treat an unreadable " +
'store as "no key stored"',
{ host, error: String(err) },
);
throw err;
}
}
@@ -82,45 +101,92 @@ export async function deleteIdentityKey(host: string): Promise<boolean> {
// ── Peer identity pins (identity_pins.json, TOFU) ──────────────────────────
/**
* Result of a peer identity-pin write. Mirrors IdentityPinLookup's tri-state
* split: "no-store" (non-Tauri environment, no pin store by design) and
* "failed" (a real write error, e.g. disk full / unwritable pins file) are
* both falsy under a plain boolean, but callers that display a "verified"
* state on the strength of a pin write must be able to tell them apart —
* collapsing them let a write failure be silently treated the same as the
* no-store case and still show "verified" with no pin ever persisted.
*/
export type StoreIdentityPinResult = "stored" | "no-store" | "failed";
/** Pin a peer's identity public key (base64) under `{host}:{userId}`. */
export async function storeIdentityPin(
host: string,
userId: string,
pin: string,
): Promise<boolean> {
): Promise<StoreIdentityPinResult> {
const invoke = await getInvoke();
if (!invoke) {
log.warn("Tauri not available — identity pin not stored");
return false;
return "no-store";
}
try {
await invoke("store_identity_pin", { host, userId, pin });
return true;
return "stored";
} catch (err) {
log.error("Failed to store identity pin", { host, userId, error: String(err) });
return false;
return "failed";
}
}
/** Load a peer's pinned identity public key, or null if never pinned. */
export async function getIdentityPin(host: string, userId: string): Promise<string | null> {
/**
* Result of a peer identity-pin lookup. "unpinned" is a trust statement —
* the store was read and holds nothing for this peer (TOFU first sight) —
* while "unavailable" means the store could not be read at all, so NO trust
* statement can be made. Mirrors the Rust TLS-TOFU split (tofu.rs), where
* `load_stored_fingerprint` returns `Err` distinctly from `Ok(None)`.
*/
export type IdentityPinLookup =
| { readonly status: "pinned"; readonly pin: string }
| { readonly status: "unpinned" }
| { readonly status: "unavailable" };
/**
* Look up a peer's pinned identity public key.
*
* A store read error is returned as "unavailable", NOT "unpinned" (DC-08,
* F3 follow-up 3): collapsing the two let a transient keyring error send a
* pinned peer down the first-sight path — silently verifying against, and
* then re-pinning, whatever key the server delivered. Callers must fail
* closed on "unavailable". In non-Tauri environments (tests, browser) there
* is no pin store by design, so the result is "unpinned" — consistent with
* every other wrapper in this module no-oping there.
*/
export async function getIdentityPin(host: string, userId: string): Promise<IdentityPinLookup> {
const invoke = await getInvoke();
if (!invoke) {
return null;
return { status: "unpinned" };
}
try {
const result = await invoke("get_identity_pin", { host, userId });
return typeof result === "string" ? result : null;
return typeof result === "string" ? { status: "pinned", pin: result } : { status: "unpinned" };
} catch (err) {
log.error("Failed to load identity pin", { host, userId, error: String(err) });
return null;
log.error("Failed to load identity pin — treating as unavailable, not unpinned", {
host,
userId,
error: String(err),
});
return { status: "unavailable" };
}
}
// ── High-level lifecycle ───────────────────────────────────────────────────
/**
* One identity keypair per host, shared by every caller in this process.
* One identity keypair per host+user, shared by every caller in this process.
*
* Scoped by BOTH host and user id (B3-3), not host alone: two different
* accounts signed into the same host — including two people sharing one OS
* profile/keyring, or one client used to log into several accounts on the
* same server without a restart — must never share a voice-E2EE identity
* keypair. Sharing one would make their announces verify against each
* other's TOFU pin, silently defeating the identity model's distinctness
* guarantee. (Pre-existing installs mint a fresh per-account keypair the
* first time they run this scoping — a one-time re-verify for their peers,
* traded for closing the cross-account sharing hole.)
*
* The keypair has two independent consumers: the ready hook publishes its
* public half (`ensureIdentityKeyPublished`) and the voice session signs
@@ -138,57 +204,140 @@ export async function getIdentityPin(host: string, userId: string): Promise<stri
*/
const identityKeyPairCache = new Map<string, Promise<CryptoKeyPair>>();
/** Composite keyring/memo key scoping the identity keypair by host AND user
* id. The keyring commands only take a single opaque `host` string, so the
* scope is folded into that one field rather than requiring a Rust-side
* change. */
function identityScopeKey(host: string, userId: number): string {
return `${host}:${userId}`;
}
/**
* Load this host's identity keypair from the keyring, generating and saving a
* fresh one on first login (or when the stored blob is corrupt). In non-Tauri
* environments the keypair is in-memory only (not persisted).
* Load this host+user's identity keypair from the keyring, generating and
* saving a fresh one on first login (or when the stored blob is corrupt). In
* non-Tauri environments the keypair is in-memory only (not persisted).
*
* Stable for the lifetime of the process: repeat callers get the same keypair
* even when the keyring is unavailable (see `identityKeyPairCache`).
*/
export function getOrCreateIdentityKeyPair(host: string): Promise<CryptoKeyPair> {
let pending = identityKeyPairCache.get(host);
export function getOrCreateIdentityKeyPair(host: string, userId: number): Promise<CryptoKeyPair> {
const scope = identityScopeKey(host, userId);
let pending = identityKeyPairCache.get(scope);
if (pending === undefined) {
// A rejected load must not be cached, or the host is poisoned for the
// A rejected load must not be cached, or the scope is poisoned for the
// rest of the session; drop it so the next caller can retry.
pending = loadOrGenerateIdentityKeyPair(host).catch((err: unknown) => {
identityKeyPairCache.delete(host);
pending = loadOrGenerateIdentityKeyPair(host, userId).catch((err: unknown) => {
identityKeyPairCache.delete(scope);
throw err;
});
identityKeyPairCache.set(host, pending);
identityKeyPairCache.set(scope, pending);
}
return pending;
}
/** Test-only: drop the per-host keypair memo so each case starts clean. */
/** Test-only: drop the per-host+user keypair memo so each case starts clean. */
export function resetIdentityKeyPairCache(): void {
identityKeyPairCache.clear();
}
async function loadOrGenerateIdentityKeyPair(host: string): Promise<CryptoKeyPair> {
const stored = await loadIdentityKey(host);
/**
* One-time migration for pre-B3-3 installs (see `identityKeyPairCache` above):
* before that fix, the identity keypair lived under the host-only keyring
* account (`identity:{host}`, passed here as plain `host`) instead of the
* scoped `identity:{host}:{uid}`. Without this, every existing install finds
* nothing at the new scoped account and mints a fresh identity keypair, and
* every peer who already pinned the old key sees a TOFU mismatch — a MITM
* warning firing for the whole alpha population at once, training users to
* click through the one warning meant to matter.
*
* Only called when the scoped account is empty, so a genuine first login (or
* a second account on a host whose legacy key the first already adopted)
* still gets its own fresh keypair — that distinctness is the point of B3-3.
* On a host that really did have two accounts sharing one key, whichever logs
* in first adopts it and the other mints fresh: the legacy account records no
* user id, so there is nothing to match on. That leaves the old shared-key
* behaviour in place for exactly one account instead of two, and it resolves
* itself once both have signed in once.
* The scoped save happens before the legacy delete, so a failed save can't
* leave the user with neither key; the legacy account just stays put for the
* next launch to retry.
*
* Delete this once the alpha population has rolled onto the scoped account.
*/
async function migrateLegacyIdentityKey(
host: string,
scope: string,
): Promise<CryptoKeyPair | null> {
const legacyBlob = await loadIdentityKey(host);
if (!legacyBlob) {
return null;
}
let keyPair: CryptoKeyPair;
try {
keyPair = await importIdentityKeyPair(legacyBlob);
} catch (err) {
log.error("Legacy identity key is corrupt — generating fresh instead of migrating", {
host,
error: String(err),
});
return null;
}
if (await saveIdentityKey(scope, legacyBlob)) {
await deleteIdentityKey(host);
} else {
log.error(
"Failed to migrate legacy identity key to the scoped account — leaving the legacy " +
"key in place so the next launch can retry",
{ host },
);
}
return keyPair;
}
async function loadOrGenerateIdentityKeyPair(host: string, userId: number): Promise<CryptoKeyPair> {
const scope = identityScopeKey(host, userId);
const stored = await loadIdentityKey(scope);
if (stored) {
try {
return await importIdentityKeyPair(stored);
} catch (err) {
log.error("Stored identity key is corrupt — regenerating", { host, error: String(err) });
log.error("Stored identity key is corrupt — regenerating", {
host,
userId,
error: String(err),
});
}
} else {
const migrated = await migrateLegacyIdentityKey(host, scope);
if (migrated) {
return migrated;
}
}
const keyPair = await generateIdentityKeyPair();
const blob = await exportIdentityKeyPair(keyPair.privateKey);
if (await saveIdentityKey(host, blob)) {
if (await saveIdentityKey(scope, blob)) {
// Outer half of a two-layer check. `save_identity_key` already reads its own
// write back and falls through to the DPAPI file if the OS credential store
// does not return it (see src-tauri/src/secret_store.rs and
// docs/credential-storage.md), so reaching the branch below now means the
// secret survived neither store. Kept because this is the failure a
// resolved promise cannot express, and its only other symptom is peers
// flagging the user as a MITM after a restart.
if ((await loadIdentityKey(host)) !== blob) {
// flagging the user as a MITM after a restart. A read error here (as
// opposed to loadIdentityKey's first call above, which decides whether to
// regenerate) is treated the same as a mismatch, not rethrown — we
// already have a freshly generated keypair for this session, so there is
// nothing left to abort.
let persisted: boolean;
try {
persisted = (await loadIdentityKey(scope)) === blob;
} catch {
persisted = false;
}
if (!persisted) {
log.error(
"Identity key did not persist — the credential store accepted the write but did not return it. " +
"This session works, but peers will see a new identity (and prompt to re-verify) every restart.",
{ host },
{ host, userId },
);
}
}
@@ -218,11 +367,27 @@ export async function publishIdentityKey(
/**
* Login/ready hook: ensure the server holds this client's identity public key.
* Loads (or generates) the host keypair and publishes it via the REST profile
* update when the server's stored copy is absent or stale — idempotent, so it
* runs at most once per key. The server's PATCH /users/me requires a username,
* so `username` is sent alongside the key. Fire-and-forget: errors are logged
* and swallowed (returns false) so the connect/voice flow is never blocked.
* Loads (or generates) the host+user keypair and publishes it via the REST
* profile update when the server's stored copy is absent or stale —
* idempotent, so it runs at most once per key. The server's PATCH /users/me
* requires a username, so `username` is sent alongside the key. Fire-and-forget:
* errors are logged and swallowed (returns false) so the connect/voice flow is
* never blocked.
*
* The user id is read from `authStore` rather than taken as a parameter: this
* is called from the ready hook, by which point auth state is populated, and
* keeping the signature unchanged avoids threading the id through every call
* site just to scope the keyring lookup (B3-3).
*
* If auth state is NOT yet populated (no user id), this returns false
* without touching the keyring at all — it must never substitute a
* placeholder scope like `?? 0`. `getOrCreateIdentityKeyPair` is host+user
* scoped, and minting (or migrating) a keypair under a bogus `host:0`
* would adopt-and-DELETE the real legacy key into that wrong account (see
* `identityKeyPairCache` / `migrateLegacyIdentityKey` above); the next,
* correctly-authenticated call then mints a second, different keypair under
* `host:<realId>`, so the published key and the announce signing key
* permanently disagree — a false MITM warning for every peer.
*/
export async function ensureIdentityKeyPublished(
host: string,
@@ -231,7 +396,12 @@ export async function ensureIdentityKeyPublished(
updateProfile: (data: { username: string; identity_public_key: string }) => Promise<unknown>,
): Promise<boolean> {
try {
const keyPair = await getOrCreateIdentityKeyPair(host);
const userId = authStore.getState().user?.id;
if (userId === undefined) {
log.warn("No authenticated user id yet — not publishing identity key", { host });
return false;
}
const keyPair = await getOrCreateIdentityKeyPair(host, userId);
return await publishIdentityKey(
(data) => updateProfile({ username, ...data }),
serverCopy,
+426 -83
View File
@@ -58,6 +58,11 @@ export class E2EEManager {
private _identityKeyPair: CryptoKeyPair | null = null;
/** True if this client is the key holder (longest-present participant). */
private _isKeyHolder = false;
/** Channel this exchange runs in, set at setupKeyExchange entry. The session
* facade publishes its channel id only once "connected", which is after the
* whole key-exchange wait — key-holder re-elections arriving in that window
* must not be dropped for lack of a channel id. */
private _channelId: number | null = null;
/** Resolver/rejector for non-key-holders waiting to receive the room key via offer. */
private _roomKeyResolver: (() => void) | null = null;
private _roomKeyRejector: ((err: Error) => void) | null = null;
@@ -80,6 +85,13 @@ export class E2EEManager {
private _keyRotationTimer: ReturnType<typeof setTimeout> | null = null;
/** Interval between periodic key rotations (5 minutes). */
private static readonly KEY_ROTATION_INTERVAL_MS = 5 * 60 * 1000;
/** Bumped every time clearState() tears down a session. An in-flight
* setupKeyExchange/reannounceForReconnect captures this before its first
* await and re-checks it before publishing to this._ecdhKeyPair — a plain
* `this._ecdhKeyPair === null` check can't see a teardown-then-restart
* that happens entirely during those awaits, since nothing is null by the
* time the abandoned attempt resumes. */
private _sessionGeneration = 0;
constructor(private deps: E2EEDeps) {}
@@ -123,18 +135,86 @@ export class E2EEManager {
* surfaces the "e2ee_timeout" error and leaves voice.
*/
async setupKeyExchange(isKeyHolder: boolean, channelId: number): Promise<boolean> {
// Generate a fresh ECDH keypair for this session.
this._ecdhKeyPair = await generateECDHKeyPair();
// Captured before any await so a clearState() that lands anywhere below
// (before we publish this._ecdhKeyPair) can be detected even though
// nothing about our local state is null yet — see the field comment.
const myGeneration = this._sessionGeneration;
this._channelId = channelId;
// Generate a fresh ECDH keypair for this session, but keep it local and
// do NOT publish it to this._ecdhKeyPair until right before the drain
// below (after _isKeyHolder/_roomKey are ready). Until then,
// handleAnnounce's `!this._ecdhKeyPair` guard queues any announce that
// arrives concurrently instead of running it through the live path —
// where it would be stored in _peerPublicKeys but sent no offer (isKeyHolder
// /roomKey not set up yet) and then never seen by the drain either (it was
// never queued), stranding that peer until the next 5-minute rotation.
const ecdhKeyPair = await generateECDHKeyPair();
// Superseded already? Everything below this point mutates state a newer
// session owns, so bail before the first write — clearing the live
// session's peer keys/verifications would drop every subsequent rotation
// for those peers (handleOffer's unknown-peer guard).
if (this._sessionGeneration !== myGeneration) {
log.warn("E2EE: setup superseded during keypair generation — aborting", { channelId });
return false;
}
this._peerPublicKeys.clear();
clearPeerVerifications();
const myPubKeyBase64 = await exportPublicKey(this._ecdhKeyPair.publicKey);
const myPubKeyBase64 = await exportPublicKey(ecdhKeyPair.publicKey);
// Build the signed announce up front — this loads the identity key from
// the keyring once, so the added identity round-trip does NOT stack on
// the non-key-holder's 10s key-exchange stall below (F3).
const announcePayload = await this.buildAnnouncePayload(myPubKeyBase64);
// Use server-authoritative is_key_holder from voice_token payload.
this._isKeyHolder = isKeyHolder;
// Same check again after the keyring round trip — the widest window of
// the three, and the next statements install OUR role and room key over
// whatever session is live now: a superseded non-holder attempt would
// clear the live holder's _isKeyHolder (silently stopping its rotations
// and its offers to new peers), and a superseded holder attempt would
// push a room key nobody else has onto the shared key provider.
if (this._sessionGeneration !== myGeneration) {
log.warn("E2EE: setup superseded before key-holder setup — aborting", { channelId });
return false;
}
// Use server-authoritative is_key_holder from voice_token payload — OR'd
// with whatever this._isKeyHolder already is. The server value was
// captured when we started joining and cannot see a handleParticipantLeft
// promotion that landed during the awaits above: the generation check
// just above proves no clearState() ran since myGeneration was captured,
// so the only other writer of this field for THIS generation is that
// promotion — unconditionally overwriting it with the stale server value
// strands the newly-elected holder waiting for an offer nobody (least of
// all itself) will ever send, timing out and ejecting it from voice.
this._isKeyHolder = isKeyHolder || this._isKeyHolder;
if (this._isKeyHolder) {
// Generate the room key BEFORE draining queued announces, so the
// drain's handleAnnounce calls hit the wrap-and-offer branch and every
// drained peer receives the fresh key immediately. Mid-call peers never
// re-announce (handleAnnounce replies with an offer, not a
// counter-announce), so the only later delivery would be the 5-minute
// rotation timer — stranding them on a dead key whenever a new key
// holder joins an ongoing call.
this._e2eeEpoch++;
this._roomKey = generateRoomKey();
await this.keyProvider.setKey(roomKeyToBase64(this._roomKey));
log.info("E2EE: key holder — generated room key", { channelId });
this.startKeyRotationTimer();
}
// And once more after keyProvider.setKey's await: a torn-down attempt
// that resurrects this._ecdhKeyPair here would defeat the queue guard in
// handleAnnounceInner and go on to announce a dead ephemeral key over a
// live call (finding v043).
if (this._sessionGeneration !== myGeneration) {
log.warn("E2EE: setup superseded before keypair publish — aborting", { channelId });
return false;
}
// Publish the keypair now — right before the drain, so every announce
// that arrived during the awaits above was queued (not silently
// processed with no offer sent) and gets its offer sent below.
this._ecdhKeyPair = ecdhKeyPair;
// Drain any announces that arrived before our keypair was ready. These
// are existing participants whose keys the server relayed during
@@ -148,12 +228,6 @@ export class E2EEManager {
}
if (this._isKeyHolder) {
// We're the first participant — generate the room key.
this._e2eeEpoch++;
this._roomKey = generateRoomKey();
await this.keyProvider.setKey(roomKeyToBase64(this._roomKey));
log.info("E2EE: key holder — generated room key", { channelId });
this.startKeyRotationTimer();
// Announce our (signed) key so existing participants can see us.
this.deps.getWs()?.send({ type: "voice_e2ee_announce", payload: announcePayload });
} else {
@@ -179,12 +253,36 @@ export class E2EEManager {
try {
await Promise.race([roomKeyPromise, makeTimeout(10_000)]);
} catch {
// First attempt timed out — re-announce and retry once.
// First attempt failed — re-announce and retry once. This also
// catches a decrypt failure in handleOfferInner (which rejects
// roomKeyPromise directly), not just a genuine timeout.
if (timeoutId !== null) clearTimeout(timeoutId);
// clearState() (e.g. the user left voice) also rejects roomKeyPromise
// and, unlike a decrypt failure, nulls _ecdhKeyPair — there is nobody
// left to retry with. Stop here instead of re-announcing into a torn-
// down session and reinstalling a resolver nothing will ever call.
// Compare by identity, not just null: a torn-down-then-restarted
// session can leave this._ecdhKeyPair non-null but owned by a
// completely different (superseded) attempt — retrying would
// re-announce our dead ephemeral key over that live session and
// steal its single _roomKeyResolver slot (finding v043).
if (this._ecdhKeyPair !== ecdhKeyPair) {
log.warn("E2EE: key exchange aborted (session cleared or superseded)", { channelId });
return false;
}
log.warn("E2EE: first key exchange attempt timed out, re-announcing", { channelId });
this.deps.getWs()?.send({ type: "voice_e2ee_announce", payload: announcePayload });
// roomKeyPromise may already be SETTLED (rejected) at this point — a
// decrypt failure rejects it permanently, so racing the SAME promise
// again would resolve rejected on the very next microtask instead of
// giving the retry its intended 5s window. Create a fresh promise and
// reinstall the resolver/rejector before racing again.
const retryPromise = new Promise<void>((resolve, reject) => {
this._roomKeyResolver = resolve;
this._roomKeyRejector = reject;
});
try {
await Promise.race([roomKeyPromise, makeTimeout(5_000)]);
await Promise.race([retryPromise, makeTimeout(5_000)]);
} catch {
log.error("E2EE: key exchange timed out after retry — disconnecting", { channelId });
this._roomKeyResolver = null;
@@ -209,14 +307,37 @@ export class E2EEManager {
* fresh offer if the key was rotated during our absence.
*/
async reannounceForReconnect(): Promise<void> {
this._ecdhKeyPair = await generateECDHKeyPair();
this._peerPublicKeys.clear();
clearPeerVerifications();
// Captured before any await so a clearState() (e.g. the user hits
// Disconnect during auto-reconnect) that lands during this method's
// awaits can be detected instead of silently resurrecting
// this._ecdhKeyPair / re-announcing for a channel we already left
// (finding v093).
const myGeneration = this._sessionGeneration;
const pair = await generateECDHKeyPair();
if (this._sessionGeneration !== myGeneration) {
log.warn("E2EE: reconnect re-announce superseded before keypair publish — aborting");
return;
}
this._ecdhKeyPair = pair;
// Peers' ECDH public keys and their TOFU verifications survive: they are
// unaffected by regenerating OUR pair, and ECDH still works (our new
// private key against their existing public key). Clearing them here would
// be permanent — handleAnnounce replies with an offer rather than a
// counter-announce, and the server relays stored peer keys only on
// voice_join — so handleOffer's unknown-peer guard would drop every
// subsequent rotation, stranding us on the pre-reconnect key.
if (this._roomKey) {
await this.keyProvider.setKey(roomKeyToBase64(this._roomKey));
}
const reconnectPubKey = await exportPublicKey(this._ecdhKeyPair.publicKey);
const reconnectPubKey = await exportPublicKey(pair.publicKey);
const reconnectAnnounce = await this.buildAnnouncePayload(reconnectPubKey);
// Re-check ownership right before the send too: buildAnnouncePayload can
// itself await a keyring round trip, another window for clearState() (or
// a fresh setupKeyExchange) to have superseded this attempt.
if (this._ecdhKeyPair !== pair) {
log.warn("E2EE: reconnect re-announce superseded before send — discarding stray announce");
return;
}
this.deps.getWs()?.send({ type: "voice_e2ee_announce", payload: reconnectAnnounce });
}
@@ -230,13 +351,28 @@ export class E2EEManager {
/** Load (once per session) this client's long-term identity keypair from the
* OS keyring so we can sign ephemeral announces. Returns null when there is
* no server host (identity is host-scoped) — the announce then goes out
* unsigned and peers treat us as a legacy/unverified client. */
* no server host (identity is host-scoped) OR no authenticated user id yet
* (identity is host+user scoped, B3-3) — the announce then goes out
* unsigned and peers treat us as a legacy/unverified client. A missing user
* id must never fall back to a placeholder scope like `?? 0`:
* `getOrCreateIdentityKeyPair` would mint (or migrate-and-DELETE the real
* legacy key into) a bogus `host:0` keyring account, and a later
* authenticated call would then mint a second, different keypair under
* `host:<realId>` — so the published key and the announce signing key
* permanently disagree and every peer's verifyPeerAnnounce reports a false
* MITM "mismatch" (see identity.ts's `identityKeyPairCache` doc). */
private async ensureIdentityKeyPair(): Promise<CryptoKeyPair | null> {
if (this._identityKeyPair) return this._identityKeyPair;
const host = this.deps.getServerHost();
if (host === null) return null;
this._identityKeyPair = await getOrCreateIdentityKeyPair(host);
const myUserId = authStore.getState().user?.id;
if (myUserId === undefined) {
log.warn(
"E2EE: no authenticated user id yet — announcing unsigned instead of scoping under a placeholder id",
);
return null;
}
this._identityKeyPair = await getOrCreateIdentityKeyPair(host, myUserId);
return this._identityKeyPair;
}
@@ -284,7 +420,8 @@ export class E2EEManager {
private async verifyPeerAnnounce(
userId: number,
publicKeyBase64: string,
signatureBase64?: string,
signatureBase64: string | undefined,
myGeneration: number,
): Promise<boolean> {
const publishedIdentity =
membersStore.getState().members.get(userId)?.identityPublicKey ?? null;
@@ -293,12 +430,37 @@ export class E2EEManager {
// Resolve the persisted pin FIRST — before any legacy shortcut. A server
// must not be able to strip a pinned peer's published key (or swap it) to
// force it back onto the legacy accept path (finding #2: TOFU pin bypass).
const pin = host ? await getIdentityPin(host, String(userId)) : null;
const lookup = host
? await getIdentityPin(host, String(userId))
: ({ status: "unpinned" } as const);
// Fail closed when the pin store could not be read (DC-08): with the pin
// unknown, this peer might be pinned to a different key — proceeding down
// the first-sight path would verify against, and then RE-PIN, whatever key
// the server delivered. Reject the announce and surface the distinct
// "unknown" state; the peer stays blocked for E2EE until the store recovers.
if (lookup.status === "unavailable") {
this.setPeerVerificationIfCurrent(myGeneration, {
userId,
status: "unknown",
safetyNumber: null,
});
log.error("E2EE: identity pin store unreadable — rejecting announce (fail closed)", {
userId,
});
return false;
}
const pin = lookup.status === "pinned" ? lookup.pin : null;
// Pinned peer whose delivered key is absent or differs from the pin —
// possible server MITM. Block until the user re-pins.
if (pin !== null && publishedIdentity !== pin) {
setPeerVerification({ userId, status: "mismatch", safetyNumber: null });
this.setPeerVerificationIfCurrent(myGeneration, {
userId,
status: "mismatch",
safetyNumber: null,
});
log.error("E2EE: pinned peer identity key missing/changed — blocking (identity-tofu)", {
userId,
});
@@ -309,7 +471,11 @@ export class E2EEManager {
// but mark unverified (pin-pending). This is the only case the compatibility
// posture keeps open.
if (!publishedIdentity) {
setPeerVerification({ userId, status: "unverified", safetyNumber: null });
this.setPeerVerificationIfCurrent(myGeneration, {
userId,
status: "unverified",
safetyNumber: null,
});
log.warn("E2EE: peer has no identity key — accepting as unverified (legacy)", { userId });
return true;
}
@@ -324,21 +490,61 @@ export class E2EEManager {
: false;
if (!ok) {
// Fail closed: peer has an identity key but no valid signature (MITM).
setPeerVerification({ userId, status: "mismatch", safetyNumber: null });
this.setPeerVerificationIfCurrent(myGeneration, {
userId,
status: "mismatch",
safetyNumber: null,
});
log.error("E2EE: peer announce signature invalid — rejecting (MITM?)", { userId });
return false;
}
// First sight with a valid signature — pin the identity key now.
// First sight with a valid signature — pin the identity key now. A
// failed write (disk full, unwritable pins file) must not display
// "verified" with no pin ever persisted: the pin is what arms mismatch
// detection on a LATER announce, so a peer we call verified but never
// pinned can never have that check fire — the exact MITM window the pin
// exists to close. "no-store" (non-Tauri: no pin store by design) is not
// a failure and keeps the normal verified outcome below.
let pinWriteFailed = false;
if (pin === null && host) {
await storeIdentityPin(host, String(userId), publishedIdentity);
log.info("E2EE: pinned peer identity key on first sight", { userId });
const pinResult = await storeIdentityPin(host, String(userId), publishedIdentity);
if (pinResult === "failed") {
pinWriteFailed = true;
log.error("E2EE: failed to persist identity pin — marking unverified, not verified", {
userId,
});
} else {
log.info("E2EE: pinned peer identity key on first sight", { userId });
}
}
if (pinWriteFailed) {
this.setPeerVerificationIfCurrent(myGeneration, {
userId,
status: "unverified",
safetyNumber: null,
});
return true; // still accept the announce — the write failure alone shouldn't block the call
}
const safetyNumber = await computeKeyFingerprint(identityKey);
setPeerVerification({ userId, status: "verified", safetyNumber });
this.setPeerVerificationIfCurrent(myGeneration, { userId, status: "verified", safetyNumber });
return true;
}
/** setPeerVerification, but a no-op if a clearState() teardown happened
* since myGeneration was captured. verifyPeerAnnounce awaits a Tauri IPC
* (identity pin lookup) internally and writes verification state on every
* branch, so a Disconnect mid-await must not let the resumed continuation
* resurrect voice-store state for a session that no longer exists
* (finding B3-7). */
private setPeerVerificationIfCurrent(
myGeneration: number,
verification: Parameters<typeof setPeerVerification>[0],
): void {
if (this._sessionGeneration !== myGeneration) return;
setPeerVerification(verification);
}
/**
* F3 TOFU re-pin recovery (finding #4). Pin the EXACT identity key
* `verifiedKey` — the bytes whose fingerprint the caller displayed and the
@@ -361,7 +567,17 @@ export class E2EEManager {
log.warn("E2EE: cannot re-pin peer without a host and the verified identity key", { userId });
return false;
}
await storeIdentityPin(host, String(userId), verifiedKey);
const result = await storeIdentityPin(host, String(userId), verifiedKey);
if (result === "failed") {
// The old pin is still on disk — do NOT clear the mismatch block. If we
// did, the UI would report the peer trusted while nothing was actually
// re-pinned, and the peer's very next announce would re-fail
// verification against the stale pin with no error ever surfaced.
log.error("E2EE: failed to persist re-pinned identity key — mismatch block kept", {
userId,
});
return false;
}
clearPeerVerification(userId);
log.info("E2EE: re-pinned peer identity key (TOFU recovery)", { userId });
return true;
@@ -369,15 +585,35 @@ export class E2EEManager {
// ── Client-side E2EE handlers (ECDH key exchange) ───────────────────────
/** Serializes announce handling. Nothing chains concurrent invocations —
* dispatcher fires them unawaited and the queued-announce drain in
* setupKeyExchange is a separate, later pass — so two in-flight announces
* for the same peer could complete out of WS-delivery order, letting a
* stale announce's map write land after a fresher one and strand the peer
* on a dead key until the next rotation (finding v015). Mirrors
* _offerChain, whose identical ordering guarantee this file already
* relies on and tests; handleAnnounceInner swallows its own errors (see
* its try/catch below), so the chain cannot wedge on a failed announce. */
private _announceChain: Promise<void> = Promise.resolve();
/**
* Handle a voice_e2ee_announce from the server — another participant has
* announced their ECDH public key. Before trusting it we verify the peer's
* announced their ECDH public key. Applied strictly in WS delivery order
* (see _announceChain). Before trusting it we verify the peer's
* identity-key signature (F3 TOFU): resolve the peer's identity key (pinning
* it on first sight), reject on mismatch/invalid signature, and only then
* store the ECDH key + (if key holder) wrap the room key for them. Peers with
* no published identity key (legacy) are accepted but marked unverified.
*/
async handleAnnounce(
handleAnnounce(userId: number, publicKeyBase64: string, signatureBase64?: string): Promise<void> {
const run = this._announceChain.then(() =>
this.handleAnnounceInner(userId, publicKeyBase64, signatureBase64),
);
this._announceChain = run;
return run;
}
private async handleAnnounceInner(
userId: number,
publicKeyBase64: string,
signatureBase64?: string,
@@ -388,15 +624,27 @@ export class E2EEManager {
log.info("E2EE: queued announce (keypair not ready)", { userId });
return;
}
// Captured before verifyPeerAnnounce's awaits (a Tauri IPC pin lookup) so
// a clearState() that lands during them — e.g. Disconnect mid-verify —
// can be detected before this continuation writes into a session a newer
// (or no) attempt now owns (finding B3-7).
const myGeneration = this._sessionGeneration;
try {
// ── F3 TOFU verification gate ──────────────────────────────────────
// Resolve the peer's identity key and verify the announce signature
// BEFORE storing the ECDH key or wrapping the room key. A malicious
// server that swaps user_id↔ephemeral-key or forges keys fails here.
if (!(await this.verifyPeerAnnounce(userId, publicKeyBase64, signatureBase64))) {
if (
!(await this.verifyPeerAnnounce(userId, publicKeyBase64, signatureBase64, myGeneration))
) {
return; // rejected/blocked — do not store or wrap
}
if (this._sessionGeneration !== myGeneration) {
log.info("E2EE: discarding stale announce (session torn down during verify)", { userId });
return;
}
// Deduplicate: if the key is identical, skip the import but still
// re-send the room key offer (the peer may be re-requesting after a
// missed offer or reconnect).
@@ -427,7 +675,26 @@ export class E2EEManager {
const keypair = this._ecdhKeyPair;
const currentRoomKey = this._roomKey;
if (this._isKeyHolder && currentRoomKey && keypair) {
// Capture epoch before the wrap await — a rotation racing this
// announce already added the peer to _peerPublicKeys before we got
// here, so it offers them the fresh key on its own; if that
// happened, ship this pre-rotation wrap and the receiver's
// strictly-ordered _offerChain ends up on the dead key.
const epochBefore = this._e2eeEpoch;
const { encryptedKey, iv } = await wrapRoomKey(keypair.privateKey, peerKey, currentRoomKey);
// Discard if either the epoch advanced (a rotation landed during the
// wrap) OR the keypair no longer matches (a concurrent
// reannounceForReconnect() swapped it without bumping the epoch) —
// mirrors handleOfferInner's dual guard. An offer wrapped under an
// abandoned keypair is undecryptable by the peer (finding v101).
if (this._e2eeEpoch !== epochBefore || this._ecdhKeyPair !== keypair) {
log.info("E2EE: discarding stale announce-offer (epoch or keypair changed during wrap)", {
userId,
epochBefore,
epochNow: this._e2eeEpoch,
});
return;
}
this.deps.getWs()?.send({
type: "voice_e2ee_offer",
payload: { target_user_id: userId, encrypted_key: encryptedKey, iv },
@@ -439,11 +706,29 @@ export class E2EEManager {
}
}
/** Serializes offer application. The offer payload carries no epoch or
* sequence and WebCrypto gives no cross-operation ordering guarantee, so
* two in-flight offers could complete out of order — applying the older
* key last and stranding this receiver on a dead key until the next
* rotation. Chaining applies offers strictly in WS delivery order. */
private _offerChain: Promise<void> = Promise.resolve();
/**
* Handle a voice_e2ee_offer from the server — the key holder has sent us
* the encrypted room key. Unwrap it and apply to the E2EE key provider.
* Offers are applied one at a time, in delivery order.
*/
async handleOffer(
handleOffer(fromUserId: number, encryptedKeyBase64: string, ivBase64: string): Promise<void> {
// handleOfferInner never rejects (it catches internally), so the chain
// cannot wedge on a failed offer.
const run = this._offerChain.then(() =>
this.handleOfferInner(fromUserId, encryptedKeyBase64, ivBase64),
);
this._offerChain = run;
return run;
}
private async handleOfferInner(
fromUserId: number,
encryptedKeyBase64: string,
ivBase64: string,
@@ -471,8 +756,12 @@ export class E2EEManager {
ivBase64,
);
if (this._e2eeEpoch !== epochBefore) {
log.info("E2EE: discarding stale offer (epoch changed during unwrap)", {
// Discard if either the epoch advanced (a rotation landed during
// unwrap) OR the keypair no longer matches (clearState() ran and a new
// session generated a fresh one — possible when the epoch is 0 in both
// the old and new session, since a non-key-holder never bumps it).
if (this._e2eeEpoch !== epochBefore || this._ecdhKeyPair !== keypair) {
log.info("E2EE: discarding stale offer (epoch or session keypair changed during unwrap)", {
fromUserId,
epochBefore,
epochNow: this._e2eeEpoch,
@@ -484,6 +773,21 @@ export class E2EEManager {
await this.keyProvider.setKey(roomKeyToBase64(this._roomKey));
log.info("E2EE: room key received and applied", { fromUserId });
// Accepting an offer proves the sender is the server-authoritative key
// holder (the server gates outgoing offers on IsVoiceKeyHolder), so if we
// still think we hold the key, we have been re-elected away — a lower
// userID joined. Stand down: our rotations would be rejected with
// NOT_KEY_HOLDER, but only after we applied the new key locally, leaving
// us deaf and mute until the real holder rotates again.
// handleParticipantLeft can still re-promote us later.
if (this._isKeyHolder) {
this._isKeyHolder = false;
this.clearKeyRotationTimer();
log.info("E2EE: stood down as key holder — accepted an offer from the elected holder", {
fromUserId,
});
}
// Resolve the pending connect promise if we were waiting for the key.
if (this._roomKeyResolver) {
this._roomKeyResolver();
@@ -501,6 +805,41 @@ export class E2EEManager {
}
}
/**
* Wrap the room key for each peer and send an offer, one at a time. Bails
* out (without sending further offers) as soon as a concurrent keypair
* swap (reannounceForReconnect) or room-key change invalidates the wrap —
* an offer wrapped under an abandoned keypair/key is undecryptable by the
* peer and would otherwise silently strand them on the stale key until the
* next rotation (finding v045). Shared by the become-holder distribution,
* its late-arrival (H3) pass, and the periodic rotation loop.
*/
private async distributeRoomKey(
keypair: CryptoKeyPair,
roomKey: Uint8Array,
peers: Iterable<[number, CryptoKey]>,
): Promise<void> {
for (const [peerId, peerKey] of peers) {
if (this._ecdhKeyPair !== keypair || this._roomKey !== roomKey) {
log.warn("E2EE: aborting key distribution — keypair/room key changed mid-loop", {
peerId,
});
return;
}
const { encryptedKey, iv } = await wrapRoomKey(keypair.privateKey, peerKey, roomKey);
if (this._ecdhKeyPair !== keypair || this._roomKey !== roomKey) {
log.info("E2EE: discarding stale room-key offer (keypair/room key changed during wrap)", {
peerId,
});
return;
}
this.deps.getWs()?.send({
type: "voice_e2ee_offer",
payload: { target_user_id: peerId, encrypted_key: encryptedKey, iv },
});
}
}
/**
* Handle a participant leaving the voice channel. If we become the new key
* holder, rotate the room key and distribute to remaining peers. If we are
@@ -517,7 +856,7 @@ export class E2EEManager {
this._peerPublicKeys.delete(userId);
clearPeerVerification(userId);
const channelId = this.deps.getCurrentChannelId();
const channelId = this._channelId ?? this.deps.getCurrentChannelId();
if (!channelId) return;
const state = voiceStore.getState();
@@ -534,9 +873,20 @@ export class E2EEManager {
const myUserId = authStore.getState().user?.id ?? 0;
if (myUserId !== 0 && lowestUserId === myUserId && !wasKeyHolder) {
// Prevent concurrent rotations (e.g. two participants leave in rapid succession).
// A rotation is already in flight (e.g. we stood down mid-rotation
// after accepting another holder's offer, and are now re-elected
// because THEY left). Don't drop the re-election — that would strand
// the room with no key holder until the next voice_leave self-heals
// it. Mirror the sibling branch below: defer, don't drop. The
// in-flight rotation's finally -> drainPendingRotationOrArmTimer will
// run rotateKeyPeriodically as holder once it completes.
if (this._rotatingKey) {
log.warn("E2EE: key rotation already in progress, skipping", { userId, channelId });
this._isKeyHolder = true;
this._rotationPending = true;
log.warn("E2EE: key rotation already in progress — deferring re-election as holder", {
userId,
channelId,
});
return;
}
this._rotatingKey = true;
@@ -550,43 +900,39 @@ export class E2EEManager {
await this.keyProvider.setKey(roomKeyToBase64(this._roomKey));
log.info("E2EE: rotated room key", { channelId, epoch: this._e2eeEpoch });
// Snapshot peers before async loop — new peers that arrive during
// wrapping are handled by the post-rotation check below.
// A client elected while still waiting inside setupKeyExchange has a
// pending resolver — the offer it is waiting for will never arrive
// (we are the holder now), so unblock it with the key just generated.
if (this._roomKeyResolver) {
this._roomKeyResolver();
this._roomKeyResolver = null;
this._roomKeyRejector = null;
}
// Snapshot peers (and the keypair/room key) before the async loop —
// new peers that arrive during wrapping are handled by the
// post-rotation check below.
const keypair = this._ecdhKeyPair;
const roomKey = this._roomKey;
const peersSnapshot = new Map(this._peerPublicKeys);
if (keypair) {
for (const [peerId, peerKey] of peersSnapshot) {
const { encryptedKey, iv } = await wrapRoomKey(
keypair.privateKey,
peerKey,
this._roomKey,
);
this.deps.getWs()?.send({
type: "voice_e2ee_offer",
payload: { target_user_id: peerId, encrypted_key: encryptedKey, iv },
});
}
if (keypair && roomKey) {
await this.distributeRoomKey(keypair, roomKey, peersSnapshot);
log.info("E2EE: distributed rotated key to peers", {
peerCount: peersSnapshot.size,
});
// H3: Check for peers that arrived during the rotation loop and
// send them the new key too.
if (keypair === this._ecdhKeyPair && this._roomKey) {
for (const [peerId, peerKey] of this._peerPublicKeys) {
if (!peersSnapshot.has(peerId)) {
const { encryptedKey, iv } = await wrapRoomKey(
keypair.privateKey,
peerKey,
this._roomKey,
);
this.deps.getWs()?.send({
type: "voice_e2ee_offer",
payload: { target_user_id: peerId, encrypted_key: encryptedKey, iv },
});
log.info("E2EE: sent rotated key to late-arriving peer", { peerId });
}
if (keypair === this._ecdhKeyPair && this._roomKey === roomKey) {
const lateArrivals = [...this._peerPublicKeys].filter(
([peerId]) => !peersSnapshot.has(peerId),
);
if (lateArrivals.length > 0) {
await this.distributeRoomKey(keypair, roomKey, lateArrivals);
log.info("E2EE: sent rotated key to late-arriving peers", {
peerCount: lateArrivals.length,
});
}
}
}
@@ -642,7 +988,7 @@ export class E2EEManager {
/** Rotate the room key on a timer tick (forward secrecy improvement). */
async rotateKeyPeriodically(): Promise<void> {
if (!this._isKeyHolder || this._rotatingKey) return;
const channelId = this.deps.getCurrentChannelId();
const channelId = this._channelId ?? this.deps.getCurrentChannelId();
if (!channelId) return;
this._rotatingKey = true;
@@ -653,21 +999,14 @@ export class E2EEManager {
log.info("E2EE: periodic key rotation", { channelId, epoch: this._e2eeEpoch });
const keypair = this._ecdhKeyPair;
if (keypair && this._roomKey) {
for (const [peerId, peerKey] of this._peerPublicKeys) {
const { encryptedKey, iv } = await wrapRoomKey(
keypair.privateKey,
peerKey,
this._roomKey,
);
this.deps.getWs()?.send({
type: "voice_e2ee_offer",
payload: { target_user_id: peerId, encrypted_key: encryptedKey, iv },
});
}
log.info("E2EE: distributed periodically rotated key", {
peerCount: this._peerPublicKeys.size,
});
const roomKey = this._roomKey;
if (keypair && roomKey) {
const peerCount = this._peerPublicKeys.size;
// Pass the live map (not a snapshot): peers that arrive mid-loop are
// still visited, matching the original behavior — only the
// keypair/room-key ownership check is new here.
await this.distributeRoomKey(keypair, roomKey, this._peerPublicKeys);
log.info("E2EE: distributed periodically rotated key", { peerCount });
}
} catch (err) {
log.error("E2EE: periodic key rotation failed", err);
@@ -696,6 +1035,10 @@ export class E2EEManager {
* keypair is intentionally NOT cleared here — it persists across calls to
* the same host (cleared only on host change / cleanupAll). */
clearState(): void {
this._sessionGeneration++;
this._channelId = null;
this._offerChain = Promise.resolve();
this._announceChain = Promise.resolve();
this._ecdhKeyPair = null;
this._roomKey = null;
this._peerPublicKeys.clear();
+258 -37
View File
@@ -7,6 +7,8 @@ import {
setLocalDeafened,
setLocalCamera,
setLocalScreenshare,
setPttGated,
isPttPollingLive,
leaveVoiceChannel,
setListenOnly,
setVoiceStatus,
@@ -17,7 +19,7 @@ import { invoke } from "@tauri-apps/api/core";
import { AudioPipeline } from "@lib/audioPipeline";
import { AudioElements } from "@lib/audioElements";
import { E2EEManager } from "@lib/livekitE2EE";
import { DeviceManager } from "@lib/deviceManager";
import { DeviceManager, isMicPolicyGated } from "@lib/deviceManager";
import {
type VideoTrackDeps,
type CameraTrackState,
@@ -50,6 +52,13 @@ export type { StreamQuality } from "@lib/screenShare";
const log = createLogger("livekitSession");
// --- Push-to-talk liveness (cross-module signal, no instance state) ---
/** Re-exported from the voice store, which owns the flag so `ptt.ts` can write
* it at startup without importing this module (and the ~1.3 MB livekit-client
* SDK behind it). See `voice.store.ts` for the platform-capability contract. */
export { setPttPollingLive } from "@stores/voice.store";
// --- Pure helpers (no instance state) ---
/** Parse userId from LiveKit participant identity "user-{id}" or "user-{id}:{token}". Returns 0 if unparseable. */
@@ -110,6 +119,16 @@ export class LiveKitSession {
/** Single source of truth for all connection-lifecycle state. */
private _state: SessionState = { type: "idle" };
/** BUG-142 fix: the ONLY source of join generations. Must never be
* re-derived from `_state` — a transition through "idle" (e.g. leaveVoice()
* during an in-flight connect) would reset a derived counter back to the
* same value a still-running stale attempt is holding, letting the two
* attempts collide on one generation and defeating every supersession
* checkpoint. Monotonic increments here guarantee every connectAndSetup()
* call gets a value no other attempt has ever held, regardless of how
* many times the session has bounced through "idle" in between. */
private _joinGenerationCounter = 0;
// --- Non-connection fields (configuration / callbacks / infrastructure) ---
private ws: WsClient | null = null;
private onErrorCallback: ((message: string) => void) | null = null;
@@ -280,6 +299,7 @@ export class LiveKitSession {
getOnRemoteVideoRemovedCallback: () => this.onRemoteVideoRemovedCallback,
getOnErrorCallback: () => this.onErrorCallback,
isConnecting: () => this._connecting,
isReconnecting: () => this._state.type === "reconnecting",
getLatestToken: () => this._latestToken,
getLastUrl: () => this._lastUrl,
getLastDirectUrl: () => this._lastDirectUrl,
@@ -309,6 +329,21 @@ export class LiveKitSession {
teardownForReconnect: () => {
this._audioPipeline.teardownAudioPipeline();
this.clearTokenRefreshTimer();
// The WS session is independent of the LiveKit drop, so tell the
// server the camera/screenshare are off before the local tracks are
// stopped below — otherwise a successful reconnect leaves the
// server's voice_states row at camera=1/screenshare=1 forever (no
// webhook clears a reconnected, non-rogue participant), occupying a
// max_video slot the user can never free.
const { localCamera, localScreenshare } = voiceStore.getState();
if (this.ws !== null) {
if (localCamera) {
this.ws.send({ type: "voice_camera", payload: { enabled: false } });
}
if (localScreenshare) {
this.ws.send({ type: "voice_screenshare", payload: { enabled: false } });
}
}
// BUG-098: Stop leaked camera/screen tracks before room is nulled.
stopManualCameraTrack(this._cameraState, this._room);
stopManualScreenTracks(this._screenState, this._room);
@@ -333,7 +368,18 @@ export class LiveKitSession {
// --- Room factory ---
/** The current room's E2EE worker. livekit never terminates it, so the
* session must — a leaked worker keeps receiving every future room key
* through the process-lifetime key provider's setKey fan-out. */
private _e2eeWorker: Worker | null = null;
private createRoom(): Room {
// livekit's per-room E2EEManager registers a SetKey listener on the
// shared key provider and never removes it; only those managers
// subscribe, so clear them all before the new Room re-registers.
this._e2ee.keyProvider.removeAllListeners();
this._e2eeWorker?.terminate();
this._e2eeWorker = new Worker(new URL("livekit-client/e2ee-worker", import.meta.url));
const quality = getStreamQuality();
const isSource = quality === "source";
const newRoom = new Room({
@@ -363,7 +409,7 @@ export class LiveKitSession {
// per-channel symmetric key. The SFU only sees encrypted frames.
e2ee: {
keyProvider: this._e2ee.keyProvider,
worker: new Worker(new URL("livekit-client/e2ee-worker", import.meta.url)),
worker: this._e2eeWorker,
},
});
newRoom.on(RoomEvent.TrackSubscribed, this._eventHandlers.handleTrackSubscribed);
@@ -393,6 +439,24 @@ export class LiveKitSession {
this._deviceManager.setOnToast(this.onErrorCallback);
}
/** True when an in-flight reconnect attempt for `channelId` has been
* superseded and must stop touching shared state: the signal was
* aborted, OR a newer connectAndSetup() already claimed `_state` (whether
* by moving to "idle"/"connected" for a DIFFERENT channel, or — the
* airtight case — by reaching "connected" for the SAME channel, since
* connectAndSetup()'s entry-point leaveVoice(false) never runs while
* `_room` reads null during "reconnecting" and so never aborts our
* signal). State is always "reconnecting" during this loop's own
* legitimate run (it only transitions to "connected" at the end of a
* successful attempt), so the type check can never false-positive on a
* still-current attempt. Checked at every checkpoint in the loop, in the
* loop's own state-restore branch, and in the post-loop give-up path. */
private reconnectSuperseded(signal: AbortSignal, channelId: number): boolean {
return (
signal.aborted || this._state.type !== "reconnecting" || this._currentChannelId !== channelId
);
}
/** Attempt to auto-reconnect after unexpected disconnect using stored token.
* The signal is aborted by leaveVoice() to cancel the loop when the user
* voluntarily leaves voice during the reconnect delay. */
@@ -411,12 +475,16 @@ export class LiveKitSession {
// oxlint-disable-next-line no-await-in-loop -- intentional sequential polling with backoff delay
await new Promise((r) => setTimeout(r, LiveKitSession.RECONNECT_DELAY_MS));
// If user manually left or joined a different channel during the delay, abort.
if (signal.aborted || this._currentChannelId !== channelId) {
if (this.reconnectSuperseded(signal, channelId)) {
log.info("Auto-reconnect aborted — user left or channel changed");
return;
}
// Aliased outside the try so the catch can tear down the attempt's own
// room: this._room is null while state is "reconnecting".
let attemptRoom: Room | null = null;
try {
const newRoom = this.createRoom();
attemptRoom = newRoom;
const cleanupAbortedReconnect = async (): Promise<void> => {
newRoom.removeAllListeners();
try {
@@ -424,10 +492,14 @@ export class LiveKitSession {
} catch (disconnectErr) {
log.warn("Failed to disconnect room after reconnect abort", disconnectErr);
}
this._audioPipeline.setRoom(null);
this._audioElements.setRoom(null);
this._deviceManager.setRoom(null);
this._deviceManager.setAudioPipeline(null);
// Re-sync from the CURRENT shared state instead of unconditionally
// nulling: by the time this runs, a newer attempt may already own
// `_state` (and its room), and this attempt's own room is never the
// one referenced there (we are aborting before reaching "connected").
// syncModuleRooms() derives from `_room`, so it correctly nulls the
// modules when nothing newer has connected yet, and correctly leaves
// a newer session's wiring alone when one has.
this.syncModuleRooms();
};
// Set state to reconnecting with the fresh room-less attempt info;
// the actual room appears in "connected" state after connect succeeds.
@@ -439,7 +511,7 @@ export class LiveKitSession {
this._deviceManager.setRoom(newRoom);
this._deviceManager.setAudioPipeline(this._audioPipeline);
if (signal.aborted || this._currentChannelId !== channelId) {
if (this.reconnectSuperseded(signal, channelId)) {
log.info("Auto-reconnect aborted after room creation");
await cleanupAbortedReconnect();
return;
@@ -448,7 +520,7 @@ export class LiveKitSession {
// oxlint-disable-next-line no-await-in-loop -- sequential reconnect: resolve URL then connect
const resolvedUrl = await this.resolveLiveKitUrl(url, directUrl);
if (signal.aborted || this._currentChannelId !== channelId) {
if (this.reconnectSuperseded(signal, channelId)) {
log.info("Auto-reconnect aborted before room connect");
await cleanupAbortedReconnect();
return;
@@ -465,7 +537,7 @@ export class LiveKitSession {
// oxlint-disable-next-line no-await-in-loop -- sequential reconnect: must connect before restoring state
await newRoom.connect(resolvedUrl, token);
if (signal.aborted || this._currentChannelId !== channelId) {
if (this.reconnectSuperseded(signal, channelId)) {
log.info("Auto-reconnect aborted after room connect");
await cleanupAbortedReconnect();
return;
@@ -518,10 +590,13 @@ export class LiveKitSession {
return;
} catch (err) {
log.warn("Auto-reconnect failed", { attempt, url, error: err });
const failedRoom = this._room;
if (failedRoom !== null) {
failedRoom.removeAllListeners();
failedRoom
// Tear down this attempt's room (this._room is null in "reconnecting"
// state) — a leaked room keeps its listeners, and its synchronous
// Disconnected event would spawn a second, uncancellable reconnect
// loop. null only if createRoom() itself threw.
if (attemptRoom !== null) {
attemptRoom.removeAllListeners();
attemptRoom
.disconnect()
.catch((disconnectErr) =>
log.warn("Failed to disconnect room after reconnect failure", disconnectErr),
@@ -538,13 +613,27 @@ export class LiveKitSession {
ac: this._state.ac,
});
}
this._audioPipeline.setRoom(null);
this._audioElements.setRoom(null);
this._deviceManager.setRoom(null);
this._deviceManager.setAudioPipeline(null);
// See the matching comment in cleanupAbortedReconnect above: sync from
// the current shared state rather than unconditionally nulling, so a
// stale failed attempt cannot clobber a newer session's module wiring.
this.syncModuleRooms();
}
}
// All attempts exhausted — give up and clean up.
// All attempts exhausted — give up and clean up. But first check this
// loop is still current: the user may have left voice or joined a
// different channel during the last attempt's delay/connect, in which
// case `leaveVoice(true)` below would tear down the LIVE session that
// replaced this one (CLAUDE.md: voice sessions are superseded, not
// cancelled — cleanup here must be scoped to this attempt, not global).
// The state-type check is what catches a re-join of the SAME channel:
// connectAndSetup() overwrites `_state` without aborting our signal (the
// `_room` getter is null while "reconnecting", so its entry-point
// leaveVoice(false) never runs), leaving both `signal.aborted` false and
// `_currentChannelId` equal to ours once that join reaches "connected".
if (this.reconnectSuperseded(signal, channelId)) {
log.info("Auto-reconnect give-up skipped — superseded");
return;
}
// Send voice_leave over WS so the server removes our voice state;
// without this the server and other clients see us as a ghost participant.
log.error("Auto-reconnect exhausted all attempts, giving up");
@@ -589,9 +678,14 @@ export class LiveKitSession {
return proxyPath;
}
/** Start (or reuse) the Rust-side local TCP-to-TLS proxy for LiveKit. */
/** Start (or reuse) the Rust-side local TCP-to-TLS proxy for LiveKit.
*
* Always invokes start_livekit_proxy — never cache the port here. Only the
* Rust side can compare the running proxy's TOFU pin against certs.json,
* so after the user accepts a rotated cert a JS port cache would keep
* every voice rejoin tunneling into the stale pin until logout. The Rust
* reuse branch dedups unchanged host+pin, so the repeat call is cheap. */
private async ensureLiveKitProxy(): Promise<number> {
if (this.liveKitProxyPort !== null) return this.liveKitProxyPort;
if (this.serverHost === null) throw new Error("no server host for LiveKit proxy");
// Ensure host:port format — default to 443 (standard HTTPS) when the
// server is behind a reverse proxy. Without an explicit port, the Rust
@@ -706,7 +800,33 @@ export class LiveKitSession {
if (room === null) return;
const state = voiceStore.getState();
const muted = state.localMuted || state.localDeafened;
// A bound PTT key means transmission is gated by press/release, but the
// Rust poller only emits ptt-state on a state TRANSITION — an idle key
// produces no event at all, so without this the freshly published mic
// would stay hot and transmitting until the user's first press+release.
// Only arm this when the poller is confirmed live (setPttPollingLive) —
// gating on the stored key alone would close the mic permanently on
// platforms where PTT can never actually report state (macOS's
// is_key_down stub, pure-Wayland Linux with no XWayland).
// Record the gate in pttGated, NEVER in localMuted: localMuted means "the
// user muted themselves", and ptt.ts refuses to open the mic on a PTT
// press while it is set — writing it here would close the mic for the
// whole session instead of only until the first press.
// On reconnect, don't recompute pttArmed from scratch — that always
// yields false (mode !== "join") and ignores whatever pttGated the store
// is still carrying from before the disconnect. If the user joined with
// PTT armed and never pressed the key before the connection dropped, the
// gate is still supposed to be closed; reading it back here (instead of
// silently reopening the mic) is what keeps that promise across a
// reconnect.
const pttArmed =
mode === "join"
? isPttPollingLive() && loadPref<number>("pttVk", 0) !== 0
: state.pttGated === true;
if (mode === "join") {
setPttGated(pttArmed);
}
const muted = pttArmed || state.localMuted || state.localDeafened;
const deafened = state.localDeafened;
const shouldEnableMicrophone = !muted;
@@ -784,6 +904,20 @@ export class LiveKitSession {
this.onRemoteVideoRemovedCallback = null;
}
/** Post-connect-checkpoint cleanup for a superseded connectAndSetup attempt
* (checkpoints 3-5, after this attempt already installed its room into the
* shared "connected" state). By the time one of these fires, a NEWER
* attempt may have already claimed `_state` (and torn down THIS attempt's
* room via its own entry-point leaveVoice(false)) — so this must disconnect
* only the passed-in localRoom, mirroring checkpoint 2, and must never call
* the global leaveVoice()/touch `_state`, or it tears down whichever
* session currently occupies `_state`, which now belongs to the newer
* attempt. */
private disconnectSupersededLocalRoom(localRoom: Room): void {
localRoom.removeAllListeners();
localRoom.disconnect().catch((err) => log.debug("Failed to disconnect superseded room", err));
}
/** Shared connect-with-retry + post-connect setup used by both the primary
* handleVoiceToken path and the pending-join drain loop.
* Returns true if the room ended up connected and set up,
@@ -797,12 +931,13 @@ export class LiveKitSession {
isKeyHolder?: boolean,
): Promise<boolean | "superseded"> {
if (this._room !== null) this.leaveVoice(false);
// Increment the generation counter and embed it into the "connecting" state.
// Any newer call to connectAndSetup() will produce a larger generation,
// making myGeneration !== currentGeneration at each checkpoint.
const prevState = this._state;
const prevGeneration = prevState.type === "connecting" ? prevState.joinGeneration : 0;
const myGeneration = prevGeneration + 1;
// Draw the next generation from the monotonic instance counter (never
// re-derived from `_state`) and embed it into the "connecting" state.
// Any newer call to connectAndSetup() will produce a strictly larger
// generation, making myGeneration !== currentGeneration at each
// checkpoint even if this attempt's own state transitioned through
// "idle" in the meantime.
const myGeneration = ++this._joinGenerationCounter;
this.setState({ type: "connecting", pendingJoin: null, joinGeneration: myGeneration });
// "joining" = connecting to the room; the E2EE "securing" phase is set below.
setVoiceStatus("joining");
@@ -841,8 +976,30 @@ export class LiveKitSession {
setVoiceStatus("securing");
const keyExchangeOk = await this._e2ee.setupKeyExchange(isKeyHolder ?? false, channelId);
if (!keyExchangeOk) {
// setupKeyExchange() also returns false when clearState() aborted the
// wait (e.g. a supersession that ran leaveVoice() while we were
// blocked here) — indistinguishable from a genuine timeout by return
// value alone. Check ownership before treating it as a real failure:
// a superseded attempt must not fire a spurious toast, send
// voice_leave (it carries no channel id and would act on whichever
// channel the NEWER attempt just joined), or clear the store's
// currentChannelId that the newer join just set.
if (this._state.type !== "connecting" || this._state.joinGeneration !== myGeneration) {
log.info("connectAndSetup: superseded during key exchange — aborting", {
channelId,
myGeneration,
});
return "superseded";
}
this.onErrorCallback?.("e2ee_timeout");
this.leaveVoice(false);
// The exchange timed out BEFORE room.connect(): no SFU participant
// exists, so no LiveKit webhook will ever clean up, and the server
// registered the join when it sent voice_token. Send voice_leave and
// leave the store's voice channel (like the reconnect-exhausted give-up
// path) or the stale row ghosts forever and can wedge the channel's
// key-holder election.
this.leaveVoice(true);
leaveVoiceChannel();
return false;
}
@@ -954,7 +1111,7 @@ export class LiveKitSession {
log.info("connectAndSetup: superseded after restoreLocalVoiceState — aborting", {
channelId,
});
this.leaveVoice(false);
this.disconnectSupersededLocalRoom(localRoom);
return "superseded";
}
@@ -972,7 +1129,7 @@ export class LiveKitSession {
log.info("connectAndSetup: superseded after audioinput switch — aborting", {
channelId,
});
this.leaveVoice(false);
this.disconnectSupersededLocalRoom(localRoom);
return "superseded";
}
@@ -990,7 +1147,7 @@ export class LiveKitSession {
log.info("connectAndSetup: superseded after audiooutput switch — aborting", {
channelId,
});
this.leaveVoice(false);
this.disconnectSupersededLocalRoom(localRoom);
return "superseded";
}
@@ -1004,6 +1161,12 @@ export class LiveKitSession {
} catch (err) {
log.error("Failed to connect to LiveKit", { url: resolvedUrl, error: err });
if (localRoom !== null) {
// Drop this attempt's listeners BEFORE disconnecting: handleDisconnected
// acts on the shared session state, so a failed attempt's Disconnected
// event would otherwise tear down (or spawn a reconnect loop for)
// whichever session owns `_state` by then — which, when this attempt
// has been superseded, is a live one that belongs to a newer join.
localRoom.removeAllListeners();
try {
void localRoom.disconnect();
} catch {
@@ -1011,7 +1174,28 @@ export class LiveKitSession {
}
this.onErrorCallback?.("Failed to join voice — connection error");
}
this.leaveVoice(false);
// Only touch the shared session state if this attempt is still current.
// A superseded attempt must not clear a newer join's server-side voice
// membership — leaveVoice's voice_leave frame carries no channel id and
// acts on whichever channel the user currently occupies, so sending it
// here for a stale attempt would delete the NEW join's voice_states row
// — nor reset a live session back to idle (CLAUDE.md: voice sessions are
// superseded, not cancelled).
if (
this._state.type === "connecting" &&
this._state.joinGeneration === myGeneration &&
this._state.pendingJoin === null
) {
// The connect attempt failed entirely: no SFU participant was ever
// created, so no LiveKit webhook will ever clean up, and the server
// already registered the join when it sent voice_token. Send
// voice_leave and leave the store's voice channel (mirroring the
// e2ee-timeout and reconnect-exhausted give-up paths) or the stale
// voice_states row ghosts forever and can wedge the channel's
// key-holder election.
this.leaveVoice(true);
leaveVoiceChannel();
}
return false;
} finally {
// Only clear "connecting" back to "idle" if we are still in the connecting
@@ -1138,10 +1322,16 @@ export class LiveKitSession {
await room.localParticipant.setMicrophoneEnabled(true);
setListenOnly(false);
// BUG-103: Honor deafened state — keep mic muted if user is deafened.
const { localDeafened } = voiceStore.getState();
if (localDeafened) {
// Also honor a moderator's server-mute, a genuine self-mute, and an
// unpressed push-to-talk key the same way: a listen-only join publishes
// no audio track, so none of these have anything to act on and persist
// silently — republishing here must not hand the whole channel a
// fresh, unmuted track. Shares applyMicMuteState's own gate rather
// than re-deriving a narrower one (the setMuted() guard does not cover
// this direct setMicrophoneEnabled call).
if (isMicPolicyGated()) {
await this.applyMicMuteState(true);
log.info("Microphone acquired but muted (user is deafened)");
log.info("Microphone acquired but muted (mute/deafen/server-mute/PTT gate active)");
} else {
setLocalMuted(false);
log.info("Microphone permission granted — exited listen-only mode");
@@ -1182,8 +1372,11 @@ export class LiveKitSession {
room.removeAllListeners();
room.disconnect().catch((err) => log.warn("room.disconnect() error (non-fatal)", err));
}
// Clear client-side E2EE state (ECDH keypair, room key, peer keys).
// Clear client-side E2EE state (ECDH keypair, room key, peer keys), and
// kill the E2EE worker so the last room key does not stay resident in it.
this._e2ee.clearState();
this._e2eeWorker?.terminate();
this._e2eeWorker = null;
// Transition to idle — atomically clears room, channelId, tokens, reconnectAc,
// pendingJoin, and the joinGeneration (idle has none). Any in-flight
// connectAndSetup() will detect the state type change at its next checkpoint.
@@ -1211,11 +1404,31 @@ export class LiveKitSession {
}
setMuted(muted: boolean): void {
// A moderator-imposed mute is not ours to lift. The server only mutes the
// track SIDs that exist at mute time and the LiveKit grant still carries
// the microphone publish source, so unmuting here would publish a fresh
// track the SFU happily forwards — server-side muting relies on the client
// refusing its own unmute. The guard lives here rather than in the callers
// because push-to-talk calls straight into this method (ptt.ts), bypassing
// the voice widget's own check. Muting is always permitted.
if (!muted && voiceStore.getState().localServerMuted === true) {
log.debug("Ignoring unmute: server-muted by a moderator");
return;
}
setLocalMuted(muted);
this.applyMicMuteState(muted).catch((e) => log.warn("applyMicMuteState failed", e));
}
setDeafened(deafened: boolean): void {
// Mirror setMuted's guard: a moderator-imposed deafen is not ours to
// lift locally. Without this, undeafening while server-deafened
// resubscribes remote audio and unmutes the mic client-side even though
// the server still considers the user deafened — see setMuted() above
// for why the refusal must live in this shared entry point.
if (!deafened && voiceStore.getState().localServerDeafened === true) {
log.debug("Ignoring undeafen: server-deafened by a moderator");
return;
}
setLocalDeafened(deafened);
this._audioElements.applyRemoteAudioSubscriptionState(deafened);
const shouldMute = deafened || voiceStore.getState().localMuted;
@@ -1236,6 +1449,14 @@ export class LiveKitSession {
await room.localParticipant.setMicrophoneEnabled(false);
log.debug("Mic fully unpublished (muted)");
} else {
// A push-to-talk gate (or, defensively, a moderator's server-mute) is
// not this call's to lift — setMuted/setDeafened only guard their own
// flag before calling here, so this is the one place every re-enable
// path (present and future) shares the full policy check.
if (isMicPolicyGated()) {
log.debug("Skipping mic re-publish — still gated (mute/deafen/server-mute/PTT)");
return;
}
// Re-enable mic — this re-publishes the track to the SFU
await room.localParticipant.setMicrophoneEnabled(true);
// Rebuild the audio pipeline on the fresh track
@@ -107,6 +107,12 @@ async function rotateOldFiles(): Promise<void> {
/** Handle a log entry by serializing it and buffering for disk write. */
function onLogEntry(entry: LogEntry): void {
if (!initialized) return;
// Break the self-sustaining loop: a persistently failing flush logs
// through this module's own logger (flush failed / rotation failed),
// which would otherwise re-enter here and re-arm scheduleFlush every 2s
// forever. The entry still reaches console/the in-memory ring buffer —
// it just never gets queued for its own persistence.
if (entry.component === "logPersistence") return;
buffer.push(JSON.stringify(entry));
scheduleFlush();
}
+19 -1
View File
@@ -3,12 +3,17 @@
* Creates a modal with backdrop, optional click-outside and Escape key
* dismissal, and clean lifecycle management via AbortController.
*
* Every factory modal carries the dialog accessibility contract (DC-13):
* role="dialog" + aria-modal on the container, focus moved into the dialog on
* open and restored on close, and a Tab-cycling focus trap (lib/a11y.ts).
*
* CSS classes match the existing project convention:
* - div.modal-overlay.visible (backdrop)
* - div.modal (content container)
*/
import { createElement } from "./dom";
import { applyDialogSemantics, focusDialog, trapFocus } from "./a11y";
export interface ModalOptions {
/** The content element to place inside the modal container. */
@@ -23,6 +28,8 @@ export interface ModalOptions {
readonly className?: string;
/** Additional attributes on the overlay element (e.g. data-testid). */
readonly overlayAttrs?: Readonly<Record<string, string>>;
/** Accessible name for the dialog (aria-label on the .modal container). */
readonly ariaLabel?: string;
/** AbortSignal for automatic cleanup when the parent component is destroyed. */
readonly signal?: AbortSignal;
}
@@ -53,6 +60,7 @@ export function createModal(
closeOnEscape = true,
className,
overlayAttrs,
ariaLabel,
signal,
} = options;
@@ -70,16 +78,20 @@ export function createModal(
// Build modal container
const modalClass = className !== undefined ? `modal ${className}` : "modal";
const modal = createElement("div", { class: modalClass });
applyDialogSemantics(modal, ariaLabel !== undefined ? { label: ariaLabel } : {});
trapFocus(modal, ac.signal);
modal.appendChild(content);
overlay.appendChild(modal);
let closed = false;
let restoreFocus: (() => void) | null = null;
function handleClose(): void {
if (closed) return;
closed = true;
overlay.remove();
ac.abort();
restoreFocus?.();
if (onClose !== undefined) {
onClose();
}
@@ -119,6 +131,7 @@ export function createModal(
if (!closed) {
closed = true;
overlay.remove();
restoreFocus?.();
onClose?.();
if (!ac.signal.aborted) {
ac.abort();
@@ -131,6 +144,11 @@ export function createModal(
container.appendChild(overlay);
// After append: move focus into the dialog and remember where it came from.
// Callers that focus a specific control afterwards (e.g. the prompt input)
// simply override the initial target; the restore still works.
restoreFocus = focusDialog(modal);
return {
overlay,
modal,
@@ -211,7 +229,7 @@ export function createPromptModal(
content.appendChild(row);
const instance = createModal(
{ content, onClose: options.onClose, className: "modal-prompt" },
{ content, onClose: options.onClose, className: "modal-prompt", ariaLabel: options.title },
container,
);
+20 -8
View File
@@ -8,6 +8,7 @@ import { notificationAllowed } from "./channel-mutes";
import { loadUserStatus } from "./userStatus";
import { authStore } from "@stores/auth.store";
import { channelsStore } from "@stores/channels.store";
import { dmStore, dmDisplayName } from "@stores/dm.store";
import type { ChatMessagePayload } from "./types";
import { mentionsCurrentUser } from "./mentions";
import { createLogger } from "./logger";
@@ -19,11 +20,21 @@ function isWindowFocused(): boolean {
return document.hasFocus();
}
/** Get the channel name for a given channel ID. */
function getChannelName(channelId: number): string {
const channels = channelsStore.getState().channels;
const channel = channels.get(channelId);
return channel?.name ?? `Channel ${channelId}`;
/**
* The name to show for a given channel/DM id, and whether it is a DM (a DM
* gets no "#" prefix -- it is not a channel).
*
* DM ids are absent from channelsStore until the conversation is opened
* (dispatcher.ts), so they must be checked first or the fallback below always
* wins and a DM notification reads "Channel <id>". dmDisplayName is the one
* place every DM-labelling surface (sidebar, header, quick switcher, and
* this) agrees on what a conversation is called.
*/
function resolveNotificationChannel(channelId: number): { name: string; isDm: boolean } {
const dm = dmStore.getState().channels.find((c) => c.channelId === channelId);
if (dm !== undefined) return { name: dmDisplayName(dm), isDm: true };
const channel = channelsStore.getState().channels.get(channelId);
return { name: channel?.name ?? `Channel ${channelId}`, isDm: false };
}
/**
@@ -73,7 +84,8 @@ export function notifyIncomingMessage(payload: ChatMessagePayload): void {
// flash stays: it's a passive hint, not a notification.
const dnd = loadUserStatus() === "dnd";
const channelName = getChannelName(payload.channel_id);
const { name: channelName, isDm } = resolveNotificationChannel(payload.channel_id);
const channelLabel = isDm ? channelName : `#${channelName}`;
// oxlint-disable-next-line consistent-function-scoping -- co-located with its sole caller for readability
function sanitizeNotif(s: string, maxLen: number): string {
@@ -84,8 +96,8 @@ export function notifyIncomingMessage(payload: ChatMessagePayload): void {
const title = sanitizeNotif(
mentioned
? `${payload.user.username} mentioned you in #${channelName}`
: `${payload.user.username} in #${channelName}`,
? `${payload.user.username} mentioned you in ${channelLabel}`
: `${payload.user.username} in ${channelLabel}`,
80,
);
const body = sanitizeNotif(payload.content, 100);
+19 -1
View File
@@ -17,8 +17,26 @@
const STORAGE_PREFIX = "owncord:nsfw-ack:";
/**
* Server host the acknowledgements below belong to. The app is multi-server,
* a server/account switch is in-document SPA navigation (no reload, so
* sessionStorage survives it), and channel ids are per-server SQLite
* autoincrement integers — without a host component in the key, an ack for
* channel N on server A silently suppresses the gate for an unrelated
* channel N on server B. `null` (before any host is known, or in a context
* that never sets one) falls back to the original unscoped key.
*/
let currentHost: string | null = null;
/** Point acknowledgements at a specific server. Call on connect and on
* server switch, mirroring `channel-mutes.ts`'s `setChannelMutesHost`. */
export function setNsfwGateHost(host: string | null): void {
currentHost = host;
}
function storageKey(channelId: number): string {
return `${STORAGE_PREFIX}${channelId}`;
const suffix = currentHost === null ? `${channelId}` : `${channelId}:${currentHost}`;
return `${STORAGE_PREFIX}${suffix}`;
}
/**
@@ -49,6 +49,8 @@ export const ServerMessageType = {
CALL_DECLINED: "call_declined",
VOICE_E2EE_ANNOUNCE: "voice_e2ee_announce", // broadcast (same string as client msg)
VOICE_E2EE_OFFER: "voice_e2ee_offer", // relay (same string as client msg)
COMMAND_REPLY: "command_reply", // ephemeral plugin reply, sent only to the invoking client
PLUGIN_BROADCAST: "plugin_broadcast", // plugin channel broadcast, gated by the sender's SEND_MESSAGES
} as const;
export type ServerMessageTypeValue = (typeof ServerMessageType)[keyof typeof ServerMessageType];
@@ -84,6 +86,7 @@ export const ClientMessageType = {
VOICE_E2EE_OFFER: "voice_e2ee_offer",
CALL_RING: "call_ring",
CALL_DECLINE: "call_decline",
CHAT_COMMAND: "chat_command", // plugin slash-command dispatch (Phase C)
} as const;
export type ClientMessageTypeValue = (typeof ClientMessageType)[keyof typeof ClientMessageType];
+130 -5
View File
@@ -5,13 +5,60 @@
*/
import { loadPref, savePref } from "@components/settings/helpers";
import { voiceStore } from "@stores/voice.store";
import { voiceStore, setPttGated, setPttPollingLive } from "@stores/voice.store";
import { createLogger } from "./logger";
const log = createLogger("ptt");
let listening = false;
let pttUnsubscribe: (() => void) | null = null;
/** Unsubscribes from voiceStore so a non-PTT unmute (widget button,
* retryMicPermission, ...) can clear a stale `pttOwnsMute` latch. See its
* registration in initPtt for why. */
let pttStoreUnsubscribe: (() => void) | null = null;
/** Unsubscribes the 'ptt-error' listener registered in initPtt. */
let pttErrorUnsubscribe: (() => void) | null = null;
/** True when the mute currently in effect is the one a PTT release applied,
* rather than one the user asked for. livekitSession.setMuted() writes
* localMuted for every caller, so that flag alone cannot tell "the user
* muted themselves" (which a press must never lift — v006) from "the last
* release muted the mic" (which it must). Reset on init/stop so a mute that
* outlived the previous PTT binding is treated as the user's.
*
* This alone is not enough: the only writes are PTT's own (press/release),
* so a non-PTT unmute (the widget's mic button, retryMicPermission) never
* clears it. If the user then re-mutes, the stale `true` survives and the
* next PTT press wrongly treats their genuine self-mute as PTT's own to
* lift. The voiceStore subscription registered in initPtt closes that gap
* by clearing the latch on any observed unmute, not just PTT's. */
let pttOwnsMute = false;
/** Clear the PTT gate and, if the mute in effect is the one PTT's own last
* release applied (not one the user asked for) and nothing else
* independently wants the mic closed, re-open it. Used whenever the poller
* can no longer produce a future press/release edge to lift that mute —
* clearing the key binding (stopPtt) or the polling thread dying
* (ptt-error) — so a PTT-applied mute is never stranded gated with no
* recovery path.
*
* `mutedByPtt` must be the caller's `pttOwnsMute` latch read BEFORE it
* resets the latch to false: both call sites zero it ahead of calling this
* (a mute must not outlive its PTT binding), so by the time this body runs
* the module-level flag itself is already false and can't be consulted
* here — the pre-reset value has to be threaded through instead. */
function ungateMic(mutedByPtt: boolean): void {
if (voiceStore.getState().pttGated !== true) return;
setPttGated(false);
const { localMuted, localDeafened } = voiceStore.getState();
if (localDeafened) return;
// A mute the user asked for is never PTT's to lift (v006) — only lift it
// when it's the one PTT's own release applied.
if (localMuted && !mutedByPtt) return;
void import("./livekitSession")
.then(({ setMuted }) => setMuted(false))
.catch((e) => log.warn("Failed to re-open mic after clearing PTT gate", e));
}
// Well-known virtual key code names for display
const VK_NAMES: ReadonlyMap<number, string> = new Map([
@@ -89,9 +136,45 @@ export async function initPtt(): Promise<void> {
await invoke("ptt_set_key", { vkCode: vk });
await invoke("ptt_start");
// Clean up previous listener if any
// ptt_start spawns its thread unconditionally, so a running thread is NOT
// evidence that PTT works — on macOS is_key_down is a stub and on
// pure-Wayland Linux there is no reachable display. Ask the backend what
// it can actually observe, so livekitSession only applies its join-time
// PTT mute where a press can genuinely lift it again.
const supported = await invoke<boolean>("ptt_polling_supported");
setPttPollingLive(supported);
if (!supported) {
log.warn("PTT key polling unsupported on this platform — mic will not be gated at join");
}
// Clean up previous listeners if any
pttUnsubscribe?.();
pttUnsubscribe = null;
pttStoreUnsubscribe?.();
pttStoreUnsubscribe = null;
pttErrorUnsubscribe?.();
pttErrorUnsubscribe = null;
// A mute left over from a previous binding is no longer PTT's to lift.
pttOwnsMute = false;
// See pttOwnsMute's doc comment: a non-PTT unmute must clear the latch
// too, or a later genuine self-mute is mistaken for one PTT itself
// applied and a subsequent press republishes the mic over it.
pttStoreUnsubscribe = voiceStore.subscribe((s) => {
if (!s.localMuted) pttOwnsMute = false;
});
// Surface a backend polling-thread panic: no further ptt-state events can
// ever arrive afterward, so a mute the last release applied would
// otherwise be stranded with no way to lift it.
pttErrorUnsubscribe = await listen<string>("ptt-error", (event) => {
log.warn("PTT polling thread stopped unexpectedly", { error: event.payload });
setPttPollingLive(false);
// Capture before resetting — see ungateMic's doc comment.
const mutedByPtt = pttOwnsMute;
pttOwnsMute = false;
ungateMic(mutedByPtt);
});
// Listen for press/release events
const unsub = await listen<boolean>("ptt-state", (event) => {
@@ -99,14 +182,39 @@ export async function initPtt(): Promise<void> {
const channelId = voiceStore.getState().currentChannelId;
if (channelId === null) return;
const pressed = event.payload;
// Track the PTT gate in the store regardless of whether we end up
// calling setMuted below — this is the source of truth other code
// (e.g. the widget) can read without depending on localMuted.
setPttGated(!pressed);
// livekitSession (and the ~1.3 MB livekit-client SDK behind it) is
// loaded lazily so it stays out of the startup path. In a voice channel
// the module is necessarily already loaded, so this import resolves
// from the module cache in a microtask.
void import("./livekitSession")
.then(({ setMuted }) => {
setMuted(!event.payload);
log.debug(event.payload ? "PTT pressed — unmuted" : "PTT released — muted");
const { localMuted, localDeafened } = voiceStore.getState();
if (pressed) {
// Never let PTT lift a mute the user asked for — that would
// republish the mic to every peer while voice_states.muted (and
// every remote UI) still shows the user muted (v006). The mute a
// previous release applied is PTT's own, so lifting that is fine.
if (localDeafened || (localMuted && !pttOwnsMute)) {
log.debug("PTT pressed — staying muted (user is self-muted or deafened)");
return;
}
setMuted(false);
pttOwnsMute = false;
log.debug("PTT pressed — unmuted");
return;
}
// Muting is always safe. setMuted() writes localMuted, so record
// whether this release is what muted the mic — only then may the
// next press lift it.
setMuted(true);
pttOwnsMute = !localMuted;
log.debug("PTT released — muted");
})
.catch((e) => log.warn("Failed to apply PTT mute", e));
});
@@ -115,7 +223,10 @@ export async function initPtt(): Promise<void> {
listening = true;
log.info("PTT started", { vk, name: vkName(vk) });
} catch (err) {
// Not in Tauri environment (dev mode)
// Not in Tauri environment (dev mode), or the backend rejected a command.
// Either way no ptt-state event can arrive, so the poller is not live —
// leaving a stale `true` here would let a later join mute the mic for good.
setPttPollingLive(false);
log.debug("PTT not available", { error: err });
}
}
@@ -126,6 +237,20 @@ export async function stopPtt(): Promise<void> {
try {
pttUnsubscribe?.();
pttUnsubscribe = null;
pttStoreUnsubscribe?.();
pttStoreUnsubscribe = null;
pttErrorUnsubscribe?.();
pttErrorUnsubscribe = null;
// Capture before resetting — see ungateMic's doc comment.
const mutedByPtt = pttOwnsMute;
pttOwnsMute = false;
// No further ptt-state events once the loop is torn down; clear the flag
// before the await so a concurrent join cannot observe a stale `true`.
setPttPollingLive(false);
// With the key idle there is no press/release edge left to lift a mute
// PTT's last release applied — rearm now, or it stays stranded for the
// rest of the voice session (see ungateMic's doc comment).
ungateMic(mutedByPtt);
const { invoke } = await import("@tauri-apps/api/core");
await invoke("ptt_stop");
listening = false;
+46 -1
View File
@@ -24,6 +24,11 @@ let sender: MarkReadSender | null = null;
*/
export function setMarkReadSender(next: MarkReadSender | null): void {
sender = next;
// A re-registration means a new connection (MainPage mounts once per
// session), so anything `markAllRead` still had queued belongs to the
// previous server. Channel ids are per-server, so letting those fire would
// mark the *new* server's same-numbered channel read.
cancelPendingMarkAll();
}
/**
@@ -66,12 +71,52 @@ export function unreadChannelIds(): readonly number[] {
return [...ids];
}
/**
* The server's `mark_read` handler shares a 5-per-second-per-user budget with
* `channel_focus` (Server/ws/handlers_presence.go) and silently drops frames
* over that budget — no error reaches the client. A burst of `mark_read`
* sends larger than the budget would still clear every local badge (see
* `markChannelRead`), so the excess channels' badges would resurrect on the
* next `ready` once the server re-asserts its own unread counts. Pacing the
* burst to below the budget, with headroom for a `channel_focus` that may
* have already spent a slot, keeps every send inside a window the server
* actually honours.
*/
const MARK_ALL_READ_BURST_SIZE = 4;
const MARK_ALL_READ_BURST_INTERVAL_MS = 1100;
/** Timers for the not-yet-sent tail of the current `markAllRead` burst. Held so
* a second mark-all, or a new connection, can drop the stale ones instead of
* letting them land against a channel list that has since been replaced. */
let pendingMarkAll: Array<ReturnType<typeof setTimeout>> = [];
function cancelPendingMarkAll(): void {
for (const t of pendingMarkAll) clearTimeout(t);
pendingMarkAll = [];
}
/**
* Mark every unread channel and DM read. Returns how many were marked, so the
* caller can stay silent when there was nothing to do.
*
* Sent in bursts of `MARK_ALL_READ_BURST_SIZE` spaced `MARK_ALL_READ_BURST_INTERVAL_MS`
* apart — see the budget note above. Each channel's local badge is cleared at
* the moment its own frame actually goes out, not up front, so a channel
* whose send hasn't fired yet still shows unread rather than lying about it.
*/
export function markAllRead(): number {
// A second click supersedes the first: its own `unreadChannelIds()` already
// covers everything the earlier burst had not sent yet, so keeping the old
// timers would only duplicate sends and spend budget twice.
cancelPendingMarkAll();
const ids = unreadChannelIds();
for (const id of ids) markChannelRead(id);
for (const [i, id] of ids.entries()) {
const delay = Math.floor(i / MARK_ALL_READ_BURST_SIZE) * MARK_ALL_READ_BURST_INTERVAL_MS;
if (delay === 0) {
markChannelRead(id);
} else {
pendingMarkAll.push(setTimeout(() => markChannelRead(id), delay));
}
}
return ids.length;
}

Some files were not shown because too many files have changed in this diff Show More