release: v1.1.0-alpha.4 — first-run setup wizard, LiveKit auto-download, WAF & CI fixes (#1292)

* fix(admin): accept same-origin first-run setup requests

A freshly generated config.yaml leaves allowed_origins commented out, so the
list is empty. The setup handler's CSRF guard assumed "no Origin header means
same-origin", but browsers send Origin on same-origin POSTs too — Chrome and
Edge always, Firefox since 70. The admin panel's own setup call is one of those
POSTs, so every new install hit "cross-origin setup request blocked" and could
never create an owner account.

The guard now accepts a request whose Origin names the same host:port as the
request's own Host header, falling back to the allowlist otherwise. That is what
the original comment intended. CSRF protection is unaffected: a cross-site
attacker cannot set Origin, the browser does, and a foreign origin still needs
an explicit allowlist entry.

Scheme is not compared. Nothing in this server derives the external scheme (no
r.TLS or X-Forwarded-Proto handling exists anywhere), so a scheme check would
reject legitimate requests behind a TLS-terminating proxy.

Tests: isSameOrigin table covering port/host/suffix/schemeless/opaque-origin
cases, plus two handler-level tests pinning both halves — same-origin succeeds
against an empty allowlist, a foreign origin still 403s and creates no user.

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

* feat(identity): implement identity keypair caching and error handling

* fix(client): use the real OS credential store, not keyring's mock (#1281)

The `keyring` crate declares no `default` feature. Every platform arm in
its lib.rs selects a backend only when that platform's feature is on and
otherwise falls through to `pub use mock as default`, so the client's
bare `keyring = "3"` compiled the in-memory mock store on Windows, macOS
and Linux alike.

The mock keeps its secret in the `Entry` object itself, and each command
built its own `Entry`:

  save_identity_key -> Entry::new(..) -> set_password -> Ok(())
  load_identity_key -> Entry::new(..) -> get_password -> NoEntry

So a save reported success, the very next read in the same process
returned nothing, `NoEntry` was mapped to `Ok(None)` so neither side
logged anything, and no entry was ever written to Credential Manager on
any machine. Downstream, the voice-E2EE identity keypair was regenerated
on reconnect, the published identity key stopped matching the key that
signed the announce, and peers correctly rejected it as a possible MITM.

Name the platform backends explicitly, and stop trusting a store that
reports a write it did not keep:

- secret_store: read every write back and compare before reporting
  success. If the store returns a value we did not write, purge it so it
  cannot shadow the fallback on the next read.
- On Windows only, fall back to a DPAPI-protected file in the app data
  dir, engaged solely after a proven round-trip failure and cleared as
  soon as the real store works again. The account name is mixed into the
  DPAPI entropy so a blob cannot be moved between entries and decrypt.
  macOS/Linux report an error instead of writing secrets to plaintext.
- Log the compiled backend at startup and add `probe_credential_store`
  so an affected machine can be diagnosed from its own log file.
- Guard the regression: `compiled_keyring_backend_is_persistent` fails
  the build if the features are ever dropped again. Verified to fail
  against `keyring = "3"`.

The E2EE fail-closed posture is unchanged: a peer whose announce
signature does not verify is still rejected.

Linux builds now need `libdbus-1-dev` for the Secret Service backend.


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

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

* fix(client, admin): make the settings panel, client, and admin panel do what they say (#1282)

* fix(client): make the settings panel do what it says

Functional review of every control in the settings overlay. Each fix below
closes a gap between what a control promised and what it did.

- Appearance: picking a theme no longer drops a saved accent colour.
  applyThemeByName strips every inline custom property from <body>, which
  includes the accent override; under neon-glow (whose body class sets
  --accent) the user's colour silently reverted until restart.
- Overlay: reopening the panel rebuilds the active tab. The Voice & Audio
  mic meter and camera preview are torn down on close, so a reopened panel
  showed a dead meter and a black preview; tabs also now re-read prefs.
  The Logs tab's live listener is released when you switch away from it.
- Status: the UserBar picker always started at "online" and never persisted,
  while the Account tab read a pref nobody else wrote — the two surfaces
  disagreed. Both now go through lib/userStatus, sync live via the
  pref-change event, and the saved status is re-asserted on connect.
- Notifications: Do Not Disturb now suppresses the desktop notification and
  the chime, as its description in the panel claims. The taskbar flash, a
  passive cue, stays.
- Keybinds: Ctrl+F, Ctrl+M, Ctrl+D, Ctrl+Shift+V and Ctrl+U were listed but
  unimplemented. They are wired now (voice ones only while in voice, all of
  them suspended while the settings panel is open). "Mark as Read" had no
  feature behind it at all and is replaced by the Escape behaviour that
  actually exists.
- Account: backup codes now carry a "you won't see them again" warning and a
  copy button; the change-password form requires the current password before
  spending a server attempt and disables itself while in flight.
- Advanced: removed the Hardware Acceleration toggle. Nothing read the
  preference it wrote — the webview decides GPU compositing before any JS
  runs, so honouring it needs a Rust startup change.
- The settings sidebar name/avatar follow a rename instead of going stale,
  and settings/helpers no longer keeps a drifted copy of lib/preferences
  (the copy lacked the write guard, so a failed save could throw).

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

* fix(client): close silent-failure gaps in the inline admin surface

Continuation of the settings-panel review into the rest of the client.

- Member context menu had no styling at all: AdminActions renders BEM class
  names (context-menu__item and friends) that appear nowhere in the CSS, so
  the menu had no hover, no danger colour, and the "Change Role" submenu
  pushed the menu open instead of flying out. Added the missing rules.
- The submenu offered a hardcoded admin/moderator/member list. On a server
  with custom roles those roles were unreachable, and picking a name that
  didn't resolve to a role id silently did nothing. Roles now come from the
  server's ready payload (owner excluded), and an unresolvable role reports
  an error instead of dead-ending.
- Kick / ban / delete-channel now show an in-flight state, and the two-click
  confirm disarms after a few seconds so a menu left open can't turn a stray
  click into a ban (docs/architecture/ux/settings-and-admin.md §3).
- Ban collects a reason, which the server already stores and displays
  (adminBanMember has always accepted one; the menu never passed it).
- Copying an invite code was silent: no confirmation, and a clipboard
  rejection looked identical to success. It now toasts either way.
- Creating an invite double-click-minted two of them, and revoking — which
  kills a live link — had neither a confirm nor an in-flight guard.

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

* fix(client): restore moderator message deletion and formatting

- The delete affordance was offered only on your own messages, so a
  moderator could not moderate anything from the client. It now also
  appears when the signed-in user's role carries MANAGE_MESSAGES, derived
  from the role bitmasks the server already sends in `ready` (this is what
  docs/architecture/ux/messaging.md §4 specifies as "Delete (own /
  moderator)"). lib/permissions.ts existed for exactly this and had no
  callers at all.
- Developer-mode "Copy ID" was silent on success and swallowed clipboard
  failures; it toasts either way now.
- prettier --write on AdminActions.ts (Client Static Checks).

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

* fix(admin): stop the panel reporting success it didn't have

Functional review of the server admin web panel.

- An expired admin session left the panel on screen toasting "invalid or
  expired session" for every action, with no way back to the login form —
  only the log-stream code handled it. api() now handles 401 centrally:
  clear the token, return to login, and say why.
- Deleting a backup called fetch() without looking at the response, so a
  failed delete reported "Backup deleted" and left the file in place. It
  now goes through api(), and — like every other destructive action here —
  asks for confirmation first.
- A failed update check rendered as "Up to date. You're running the latest
  version", which is a lie that hides a broken update path. It now says the
  check failed and why. A failed apply no longer leaves the button stuck on
  "Applying...".
- The Edit Channel modal could only rename. PATCH /channels/{id} accepts
  topic, slow_mode, position and archived, and the channel table has an
  Archived column — which was read-only state with no control behind it.
  All four are editable now.
- Banned users showed "Yes" with no reason, even though the ban reason is
  collected on ban and returned by the API. It's now displayed.
- Login and first-run setup had no in-flight guard, so a double-click spent
  two attempts against the login lockout / setup rate limit. Settings' Save
  stayed enabled after a successful save, implying unsaved changes.
- Clipboard copies (invite code, new API token) had no rejection path: a
  refused clipboard looked exactly like a successful copy.
- Backup names in inline onclick handlers go through jsq() like every other
  interpolated string.

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

* feat(admin): add the plugin management UI the backend already had

/api/v1/admin/plugins has exposed list/install/enable/disable/uninstall since
Phase C Step 9 — its own header says it "exposes plugin lifecycle operations
to the admin panel", and docs/architecture/ux/settings-and-admin.md tells
operators plugin management lives in the web panel. The panel had no Plugins
section at all, so installing a plugin meant hand-crafting a multipart POST.

Panel:
- Plugins section: installed table (name, manifest description and requested
  permissions, version, enabled state, install date), zip upload with the
  16 MB server cap stated up front, enable/disable, and uninstall behind a
  confirm. One lifecycle call at a time.
- The lifecycle API sits under a different prefix than the rest of the panel
  and answers errors as plain text (http.Error), not JSON, so it gets its own
  fetch helper — sharing api() would have surfaced "unexpected token" instead
  of the server's reason. 401 still routes back to login.

Server:
- PluginRow had no JSON tags, so the list marshalled Go field names and every
  column would have rendered empty. Now snake_case like the rest of the API.
- GET /plugins returns X-Plugin-Runtime: enabled|disabled. An empty list means
  "nothing installed" on a live runtime and "you can't install anything" on a
  disabled one; the body can't tell them apart, so the panel's empty state
  had no way to be honest about it.

The plugin-store test helper now hands back the database the registry writes
to — the existing happy-path test wired a *different* in-memory DB into the
handler, which is why nothing noticed the list was always empty.

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

* feat(client): gate the composer on slow mode instead of failing the send

Verified the optimistic message lifecycle against docs/architecture/ux —
pending → chat_send_ok → sent, failed rows with mapped reasons, retry and
delete-draft all behave as documented. One thing did not: slow mode.

The UX spec (§5) says slow mode should "disable send with a live countdown in
the composer; do not drop the drafted message". In practice the composer knew
nothing about it: you typed, sent, and got a red failed row back — the exact
enabled-then-rejected pattern §6.2 forbids. The client never even received the
channel's slow_mode value.

- Server: channel payloads (ready, channel_create, channel_update) now carry
  slow_mode alongside can_send, for the same reason can_send is there — the
  client can express the limit as affordance. The server still enforces.
- Client: after an accepted send the composer disables itself for the channel's
  cooldown with a per-second countdown, and a SLOW_MODE refusal restarts the
  full window (the server's limiter is the authority on when the next send is
  allowed). The draft stays in the textarea. Moderators, who bypass slow mode
  server-side, are not gated.
- The MANAGE_MESSAGES lookup added for moderator deletes moves into
  lib/permissions as currentUserPermissions/currentUserHasPermission/
  canManageMessages, so the composer and the message renderer share one
  definition instead of two.
- WsErrorCode listed 9 of the server's 16 codes: SLOW_MODE, CONFLICT,
  BAD_REQUEST, INVALID_JSON, UNKNOWN_TYPE, BAD_PAYLOAD, NOT_KEY_HOLDER and
  ALREADY_JOINED were missing, so code switching on it could not name cases
  the server actually sends. Now mirrors Server/ws/errors.go.

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

* fix(admin): make backup restore actually restart, and fail closed without a safety copy

Verification pass over the remaining review items. Two real defects in restore,
one duplicate resolved; cert TOFU and the replay path checked out as-is.

Restore:
- The handler closed the database, swapped the file underneath it, told the
  admin "database restored — server restarting", broadcast a 5-second restart
  countdown to every client... and then kept running. Nothing restarted it, so
  the server answered every subsequent request against a closed DB until an
  operator noticed. It now respawns for real, reusing the update-apply pattern
  (SpawnDetached → SIGTERM → os.Exit backstop) behind a test seam.
- A failed pre-restore backup was a warning, and the irreversible overwrite
  went ahead anyway — removing the safety net the panel explicitly promises
  ("A pre-restore backup will be created"), precisely when it matters. It now
  aborts with the database untouched.
- The safety copy was written to a cwd-relative "data/backups" while every
  other backup handler uses the absolute backupBaseDir, so a server started
  from another directory filed it somewhere the operator would never find.

Both new tests were confirmed to fail against the previous behaviour.

Client:
- SidebarArea kept a private 140-line copy of the member-list wiring that
  SidebarMemberSection already provides (the extracted, tested one was never
  imported). Fixing the silent role-change failure earlier meant patching both;
  now there is one copy.

Verified without changes: the optimistic send lifecycle (pending →
chat_send_ok → sent, failed rows with mapped reasons, retry, delete-draft),
reconnect replay (monotonic last_seq, dedup on reconnect, replay suppression
of unread/notifications), and cert TOFU (first-use and mismatch modals, accept
re-pins and reconnects, reject disconnects back to connect).

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

* fix(admin): remove the data race in the restart test hook

CI (-race) failed identically on ubuntu and windows: TestHandleRestoreBackup_
Success polled a plain bool that the restore handler's goroutine wrote, and
swapped the restartSelf package var from the test goroutine while that handler
read it.

The hook is now behind a mutex with an atomic flag in StubRestart. Production
behaviour is unchanged — the race was entirely in the test seam I added.

Verified with `go test -race -count=2 ./admin/`.

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

---------

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

* refactor + perf: split largest source files into modules; optimize hot paths (#1283)

* refactor(updater): split updater.go into cohesive files

Split the 1070-line updater.go into four files within the same package:
updater.go (core types, release checking), download.go (download and
tarball extraction), verify.go (signatures, checksums, staged binary),
and assets.go (client assets, text-asset cache, HTTP fetching).

Pure mechanical move — no behavior or API changes.

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

* refactor(ws): split hub.go into cohesive files

Split the 1289-line hub.go into five files within the same package:
hub.go (Hub struct, lifecycle, register/unregister), hub_broadcast.go
(broadcast fan-out and per-user sends), hub_events.go (sequencing,
replay, persistence), hub_sweep.go (stale client/session/voice
sweepers), and hub_livekit.go (LiveKit accessors).

Also optimizes wrapWithSeq on the hot broadcast path: build the seq
prefix with a single preallocated append + strconv.AppendUint instead
of fmt.Sprintf, halving allocations per broadcast message.

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

* refactor(client): extract E2EEManager from livekitSession

Move all client-side E2EE key-exchange logic (~550 lines) out of
LiveKitSession into a new E2EEManager class in livekitE2EE.ts: ECDH
keypair management, identity signing and TOFU pin verification,
announce/offer handling, key-holder election, membership rekeying, and
periodic key rotation. Dependencies are injected following the existing
roomEventHandlers pattern.

LiveKitSession keeps thin public delegates (handleE2EEAnnounce,
handleE2EEOffer, handleParticipantLeft, rePinPeerIdentity) so the
module-level bound exports and the public API are unchanged.
livekitSession.ts shrinks from 1955 to 1409 lines.

Adds focused unit tests for E2EEManager (key-holder setup, pending
announce queue, offer resolution, clearState, rotation).

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

* perf(server): hot-path and query optimizations

Logging (biggest win): rewrite the admin log RingBuffer as a true ring
(fixed array + head/count) instead of allocating a fresh 2000-entry
slice + full copy per log line; gate the ring handler on a configurable
level instead of unconditional DEBUG capture; move the broadcast debug
log out of the seqMu critical section; drop the per-message slog.With
clone in the WS handler.

Database: new migration 019 adds idx_attachments_message (message pages
no longer scan the attachments table), a covering role-leading index on
channel_overrides (replacing a duplicate of the UNIQUE auto-index), a
partial index for pinned messages, and narrows the FTS trigger to
content changes only; ANALYZE runs after migrations. Rewrite
GetChannelUnreadCounts and GetUserDMChannels to correlated subqueries
that range-scan idx_messages_channel — O(unread) instead of O(all
messages) per WS connect. New GetUserDMChannelIDs replaces the full DM
query where only IDs are needed. CreateMessage/EditMessageContent use
RETURNING, removing the re-read after every send/edit.

Write-path contention: TouchSession throttled to once per minute per
session (was one UPDATE per authenticated request); EventPersister
flushes its batch in a single transaction with per-row fallback;
revoked-session and stale-voice sweeps run off the hub dispatch
goroutine with an in-flight guard, and session checks are batched into
one IN query; the rate limiter is sharded into 32 buckets with
allocation-free strconv key building (auth.Key).

WS structural: voice E2EE channel fan-out goes through the existing
pubsub voice topic instead of scanning every connected client under
h.mu; channelReadAudience memoizes role lookups per call;
hasChannelAccess drops its redundant duplicate permission check;
voice_join batches SPEAK/VIDEO/SCREENSHARE checks via
HasChannelPermBatch. Also: pubsub topic builders and NewAppMetrics
stop allocating via Sprintf/global mutex.

Verified with go test -race across all packages, go vet, gofmt, and
sqlc generate idempotency.

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

* perf(client): render-path, logging, and bundle optimizations

Logging: the logger no longer runs permanently at debug — level is set
from the environment at startup (debug in dev, info in prod), so every
hot-path debug entry stops being serialized, buffered, consoled, and
persisted to disk; per-URL debug logs in embed rendering removed.

Render path: MessageList's store selector is scoped to the mounted
channel, so messages in other channels no longer trigger re-renders,
and a new incremental tail-append fast path appends rows instead of
tearing down the whole window; Intl.DateTimeFormat instances are cached
at module level; parseTimestamp memoizes epoch millis; media prefs
(showEmbeds/inlineMedia/showLinkPreviews/animateGifs) are cached with
pref-change invalidation; members store gains a roleRevision counter so
MessageList stops rebuilding a role map on every presence/typing event.

MemberList patches presence changes in place (status dot + offline
class) via a row map instead of rebuilding every row, with single-pass
role grouping. ChannelSidebar splits its voice subscription into a
structural selector (excluding speaking) and a speaking-only patcher
using a cached element map instead of per-user querySelector on every
speaker event.

Memory: GIF/media elements are unobserved before the message window
discards them, fixing unbounded IntersectionObserver retention of
detached DOM (including frozen-frame data URLs).

Bundle: livekit-client (1.3 MB) moves to its own chunk via dynamic
imports and manualChunks; the READY handler's stale-voice check reads
the voice store instead of requiring the module synchronously.

Adds 11 focused tests (different-channel no-rerender, append fast path,
media release, presence patch, speaking patch). Full unit suite:
3606/3606 passing; typecheck, lint, and production build clean.

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

---------

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

* fix(ci): skip alloc test under deadlock tag; cut bcrypt cost in tests (#1284)

The deadlock-tag CI pass failed on TestRingBuffer_WriteDoesNotAllocate:
under -tags deadlock, syncutil.Mutex is the go-deadlock mutex whose Lock
allocates, so the steady-state ring write measures 1 alloc/call. Extend
the build constraint to !race && !deadlock — the test's guarantee is
about the ring buffer itself, which the -race-less default pass covers.

Make bcryptCost a var with an exported SetCostForTesting hook that also
resets the dummy timing pad, and call it with bcrypt.MinCost from the
api, auth, and admin TestMains. Password hashing at production cost 12
dominated those suites (~264 hashes): with the race detector the api
package alone took ~860s; it now runs in ~33s. Nothing under test
depends on hash strength, and no test asserts the cost.

Hygiene in the same pass: migration 020 drops idx_sessions_token and
idx_invites_code (exact duplicates of their UNIQUE auto-indexes, pure
write overhead) with updated db_test assertions; remove the dead
tar.TypeRegA comparison in the updater (stdlib normalises it to TypeReg
since Go 1.11); gofmt storage/storage.go comment alignment.


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

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

* perf(ws): route hot-path permission checks through the cached PermissionService (#1285)

The ws package was the only major subsystem still doing live per-check
permission queries (GetRoleForUser + GetChannelPermissions per check):
a V2 voice join cost 9+ DB reads across its four gates, and every
channel broadcast resolved one role query per connected client.

Hub now holds svc.Permissions and the voice deps carry it (nil-safe:
bare test fixtures fall back to the existing live path, fail-closed
semantics preserved everywhere). Converted sites: the voice join and
token-refresh permission gates, USE_VIDEO/SHARE_SCREEN controls,
requireChannelAccess, channelReadAudience, and RefreshChannelVisibility.

Caching these is revocation-correct: every permission-changing mutation
already invalidates synchronously before hub fan-out (InvalidateUser on
role change, InvalidateAll on override change), the 30s TTL is only a
backstop, and the service's gen-counter guard prevents a populate that
races an invalidation from caching stale data — the audience-resolution
comments now document that invariant. The stale-voice sweeper's check
deliberately stays live: it is the last-line backstop for revocations
that might bypass an invalidation hook, runs once a minute for only
in-voice clients, and its eviction test pins exactly that guarantee.

requirePerm keeps its INTERNAL-vs-FORBIDDEN distinction by using the
cache only for positive verdicts and falling through to the live path
on denial.

Adds perm_cache_test.go: role-change invalidation is immediate (no TTL
wait), and a counting-store test proving the second check is served
from cache. All pinning tests (authz, voice_perm_stale, channel
visibility agreement, sweep eviction) pass unmodified.


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

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

* perf + refactor: SQLite reader pool, async audits, real lazy-livekit, test splits, eslint 10 (#1286)

* perf(db): batch audit writes through an async writer

Audit inserts ran synchronously on the request path — including one
INSERT per WebSocket connect — each an implicit transaction on the
single SQLite connection.

WriteAudit keeps its exact signature and D8 policy (never fail the
caller, never silently discard): it now upgrades to an async path when
the passed Auditor also implements AsyncAuditor. *DB implements that
via an atomic pointer that main.go populates at server startup with an
AuditWriter modeled on the event persister (bounded queue, batched
single-transaction flush with per-row fallback, drain-on-stop, atomic
counters, non-blocking enqueue that error-logs drops without leaking
the detail field). The token CLI and tests never install a writer, so
they keep today's synchronous behavior with zero call-site changes.

The writer's Stop defer registers after database.Close's so the LIFO
unwind drains the queue before the DB shuts.

Adds audit_writer_test.go: batch flush, D8 drop logging, drain-on-stop,
flush-failure accounting, poison-row fallback, concurrent enqueue, and
seam tests pinning sync-without-writer vs async-with-writer behavior.

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

* perf(client): actually defer livekit-client; honor saved log level at startup

The manualChunks split was cosmetic: index.html modulepreloaded the
531 kB livekit chunk and the entry statically imported it. All four
import chains from startup are now cut — auth.store's logout leaveVoice
and ptt's setMuted go through dynamic imports, applyStoredAppearance
moved to lib/appearance.ts so main.ts and ConnectPage stop pulling the
settings tree (whose overlay now loads on first open), and MainPage
itself is a dynamic import in renderPage, guarded against the
destroy-before-mount race by a navigation-generation helper and
pre-warmed once the socket connects.

Entry chunk drops 387 kB -> 114 kB (gzip 36 kB); index.html has no
modulepreload links; livekit/MainPage/SettingsOverlay/livekitSession
load as lazy chunks.

The logger now honors the Logs tab's saved minimum level at startup
(applyStoredLogLevel with the legacy-key migration moved into
lib/preferences.ts) and re-applies it live on pref changes.

Dead code: remove unreachable VoiceChannel.ts (superseded by
ChannelSidebar's renderer) and its test, plus all knip-flagged unused
re-exports in message-list/renderers.ts and ConnectPage's unused form
types — knip is now clean apart from pre-existing config hints.

Tests: +12 (navigation guard incl. stale-mount discard; logger startup
pref, migration, and live re-apply); ptt/stored-appearance updated for
dynamic-import plumbing only. Full suite 3593 passing; typecheck, lint,
and production build clean.

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

* perf(db): split SQLite into single-writer + multi-reader connection pools

The entire server serialized on one SQLite connection: every read
queued behind every other read and every write, throwing away WAL's
concurrent-reader capability.

File-backed databases now open two pools from a DSN that carries all
seven PRAGMAs as per-connection _pragma parameters (an Exec'd PRAGMA
only configures one arbitrary pooled connection — moving them into the
DSN is what makes >1 connection safe, foreign_keys included): a
single-connection writer with _txlock=immediate, and a reader pool
sized max(4, NumCPU). In-memory databases keep the exact historical
single-connection behavior, which preserves every :memory: test site
and the connection-scoped PRAGMA-toggle tests untouched.

Routing lives in a dbtx router implementing sqlc's DBTX: statements go
to the reader only when provably read-only (leading SELECT/PRAGMA after
skipping comments — necessary because sqlc routes INSERT/UPDATE/DELETE
... RETURNING through QueryRowContext/QueryContext, which must stay on
the writer); Exec, transactions, migrations, ANALYZE, VACUUM INTO, and
the SQLDb() escape hatch all pin to the writer. Every former sqlDB
reference across the package was re-pointed deliberately.

New pool_test.go pins the properties the split must preserve on a
file-backed DB: foreign_keys=1 across many reader connections, WAL
journal mode, FK enforcement through both write paths, 8x8
concurrent reader/writer hammering with exact row counts, and a read
completing against the pre-tx snapshot while a write transaction is
open — the property this change exists to unlock.

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

* test(client)+chore: split the two largest test files; eslint 10; audit clean

Split tests/unit/ws.test.ts (3340 lines) into ws-cert / ws-reconnect /
ws-messaging / ws-lifecycle plus a shared helpers/ws-mocks.ts module,
and tests/unit/audio-pipeline.test.ts (2547 lines) into core / gain /
vad-worklet / vad-fallback files. Test bodies moved verbatim; the
suite count is unchanged at 3593 passing.

Upgrade eslint 9 -> 10 (with @eslint/js 10; typescript-eslint's peer
range already covers v10, flat config unchanged, zero new findings)
and pin test-exclude ^8 via the existing overrides block so the
coverage chain picks up patched glob/minimatch/brace-expansion.
npm audit: 8 high -> 0 vulnerabilities.

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

* refactor(server): split remaining large files; dependency hygiene notes

Split ws/coverage_boost_test.go (2856 lines) into coverage_helpers /
chat / voice / voice_lifecycle / misc test files — bodies verbatim,
746 passing tests before and after. Split service/message.go (781)
into message_crud / message_reactions / message_query / message_perms
with types and the constructor staying put, and ws/serve.go (754) into
serve / serve_pumps / serve_auth / serve_ready.

Dependency findings (no changes needed): coraza-coreruleset's stale
Feb-2024 pseudo-version is unreachable from our code — it enters the
module graph only through coraza's own internal tests, and our WAF uses
inline directives, never the CRS (fresher rules would require adopting
the /v4 module and rewiring the WAF config — deliberate follow-up, not
hygiene); gogo/protobuf is likewise graph-only via the livekit SDK and
never built into our binaries.

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

* style: satisfy golangci-lint modernize/staticcheck in new pool and audit code

CI's golangci-lint pass (not run locally until now) flagged the
Phase 3/4 additions: range-over-int loops, interface{} -> any on the
dbtx router, WaitGroup.Go in the pool tests, and a De Morgan
simplification in isReadOnlySQL's identifier-boundary check. Pure
style — verified against the same golangci-lint v2.11.3 binary CI
uses (0 issues) and re-ran db/ws race + deadlock suites green.

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

---------

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

* feat(waf) + fix(deps) + test(ws): OWASP CRS, Dependabot fixes, sleep-free ws tests (#1287)

* fix(deps): clear quick-xml RUSTSEC advisories in Tauri lockfile

cargo-audit identified the two Dependabot alerts on the default branch:
quick-xml 0.37.5 and 0.38.4 both carry RUSTSEC-2026-0194 (quadratic
runtime on duplicate-attribute checks) and RUSTSEC-2026-0195 (unbounded
namespace allocation DoS), fixed in >=0.41. Both were transitive:
plist 1.8.0 (via tauri) and tauri-winrt-notification 0.7.2 (via
notify-rust). Semver-compatible updates fix both — plist 1.10.0 moves
to quick-xml 0.41, and tauri-winrt-notification 0.7.3 drops quick-xml
entirely. cargo-audit is now clean of vulnerabilities; the remaining
20 informational notices are the unmaintained GTK3-binding crates
inherent to Tauri v2 on Linux. Verified plist compiles against
quick-xml 0.41 (full Tauri build needs the GTK/WebKit system libs CI
installs).

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

* feat(waf): layer the maintained OWASP Core Rule Set onto the WAF

The WAF previously ran six inline directives only — the CRS never
loaded (the old coreruleset dep was a stale graph-only pseudo-version).
A second Coraza engine now loads the embedded CRS from
coraza-coreruleset/v4 (v4.25.0), layered on top of the inline rules,
which stay byte-identical and keep blocking exactly as before.

CRS ships in a new server.waf_crs_mode knob (off|detect|block),
defaulting to detect: chat traffic is CRS-false-positive-prone (a new
test pins that block mode rejects benign SQL-ish chat prose at the
default threshold), so operators get rule-match visibility via
structured logs first and opt into blocking after tuning. Setup
mirrors the official connector: Host/Transfer-Encoding restored to the
transaction (else 920280 fires on everything), phase 2 always runs so
query-string attacks are scored, PUT/PATCH/DELETE added to the CRS
method policy for this REST API, body limits matched to the app's
1 MiB cap with uploads excluded from body access and the content-type
policy.

Also fixes a latent middleware bug: the body was previously swapped
for the buffered reader even when nothing was buffered, which would
have handed body-access-off routes an empty body; now pinned by a test
across all modes.

Adds waf_crs_test.go (load, mode wiring, XSS/traversal detection
without blocking, block-mode blocking + benign passthrough, upload
body preservation); waf_test.go passes unmodified.

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

* test(ws): replace fixed sleeps with condition-based waits

The ws suite paced async hub effects with 537 fixed time.Sleep calls —
slow at best, flaky under load at worst. They are now condition-based:
a small waitFor/waitRegistered/waitClientCount/waitMsgOfType helper set
(waitRegistered exploits the hub's in-order client-event processing),
plus blocking decode-scans for the DM tests.

The bulk deletion is grounded in verified production facts, unchanged
by this commit: sendMsg is a synchronous buffered send (error replies
are already buffered when the handler returns), the voice control /
rollback / cleanup / sweep paths are synchronous, and serve.go
registers the client before writing the ready frame. Absence
assertions were deliberately NOT inverted into polling — they keep
bounded windows, each commented.

20 sleeps remain, all justified in place: poll intervals inside
condition loops, absence windows, clock-granularity pacing, and the
event-pruner's inherently time-based no-prune-after-cancel assertion.

Suite: 746 tests before and after; 62.6s -> 46.1s (30s of the
remainder is GracefulStop's hard-coded production 5s drain, out of
scope here); race flake check passes 3 consecutive iterations;
deadlock pass and golangci-lint clean.

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

---------

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

* fix: audit-driven fixes — client leaks/lazy-load, WAF detect logging, audit shutdown race (#1288)

* fix(waf,db): aggregate CRS detect-mode logging; make audit Stop await goroutine exit

WAF detect mode wired logCRSMatch as the engine-level error callback, which
fires one slog.Warn per matched rule on the request goroutine. In the default
detect mode ordinary chat prose trips several CRS SQLi/XSS rules plus anomaly
scoring, so each request logged a burst of Warn lines in the hot path.

Aggregate per request from per-transaction state instead of the shared global
callback: in the default detect path leave the engine error callback nil and,
in the existing crsTx defer, emit at most one Warn per request that had matches
(count + highest-severity rule), demoting the full rule-id list to Debug.
Block mode keeps per-rule logging (blocked requests are rare and their detail
is wanted), and a caller-supplied onCRSMatch callback keeps per-rule delivery
so existing tests stay unmodified. Detection, interruption, and body handling
are unchanged — only the detect-path logging shape.

The audit writer's Stop selected between <-done and <-ctx.Done(); on a slow
flush the 5s ctx could win, returning while run() was still flushing. main.go's
LIFO defers then closed the DB pool under a live flusher, losing audits. Stop
now always waits on done (the goroutine has stopped touching the store) while
ctx bounds only the drain inside run() via a published stopCtxDone channel, so
a slow store delays shutdown by at most one in-flight flush and the pool is
never closed under a live writer.

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

* fix(client): plug listener leaks, guard lazy livekit load, honor saved log level

Follow-up audit of the recently-landed lazy-livekit and session wiring found
three real issues:

- clearAuth unconditionally dynamic-imported livekitSession to call leaveVoice
  on every logout, pulling the ~531 kB livekit chunk into the logout path even
  when no voice session was ever active. Guard the import on an active voice
  session (currentChannelId set and status not idle) and add a .catch so a
  failed teardown import can't reject unhandled.

- The onStateChange handler unsubscribed session listeners only on the ready
  transition, not on disconnected; user_update and ready listeners registered
  per session were never collected for cleanup. Collect them into a
  sessionUnsubs array cleaned up on both ready and disconnected, preventing
  duplicate handlers accumulating across reconnects.

- The Logs tab min-level select ignored the persisted log level when no
  explicit dropdown preference was saved. Add logger.getLogLevel() and default
  the select to it so the UI reflects the level actually in effect.

Also add .catch to the ptt setMuted dynamic import. New unit tests cover the
clearAuth guard, getLogLevel, and the LogsTab default.

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

---------

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

* fix(waf): load embedded OWASP CRS ruleset correctly on Windows (#1289)

The CRS WAF engine failed to initialize on Windows, taking the whole api
package's test suite red there. coraza's seclang parser resolves Include
globs through path/filepath: for every match of `Include @owasp_crs/*.conf`
it calls filepath.Join(currentDir, match), which on Windows rewrites the
forward slashes to backslashes. It then feeds names like
`@owasp_crs\REQUEST-901-INITIALIZATION.conf` back into the root fs.FS. That FS
is the ruleset's embed.FS, which is always forward-slash and rejects a
backslash name, so newCRSWAF returned "file does not exist" and no CRS rule
under a subdirectory was ever loaded.

Wrap coreruleset.FS in a small slash-normalizing fs.FS (Open/ReadFile/ReadDir/
Glob) that converts backslashes to forward slashes before delegating. This
fixes CRS loading on Windows without patching coraza or the ruleset module and
is a no-op where the separator is already "/". The Linux-only local
verification for the CRS work missed this because coraza never emits
backslashes there.

The new test reproduces the failure mode on any OS by constructing the exact
backslash name coraza produces on Windows: the raw ruleset FS fails to read
it, the wrapper resolves it, and a forward-slash path still works.


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

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

* fix(e2e): repair the Playwright suite so the CI job stops timing out (#1291)

The Client E2E CI job never completed: every run hit its 25-minute cap and
was cancelled. ~229 of the 255 web tests were failing, all cascading from
the shared login helper, and 255 tests x 3 attempts x 20-45s of timeout
burn on 1 worker deterministically exceeds the cap.

Root cause: the e2e Tauri mock predates the Rust HTTP TOFU proxy. api.ts
now awaits invoke("start_http_proxy") and builds REST URLs as
http://127.0.0.1:{port}/api/v1/..., but the mock's invoke returned null for
the unstubbed command, so every URL got a literal "null" port and Request
construction threw before the mocked plugin:http transport was consulted.
Login rejected, [data-testid='app-layout'] never mounted, and every
logged-in test burned its full timeout. Stubbing start_http_proxy with any
numeric port fixes the cascade because route matching is substring-based.

The tail of failures after that fix were tests asserting behavior the app
intentionally changed:

- The ready payload can no longer pre-connect the local user to voice: the
  dispatcher treats "self in ready.voice_states while idle" as stale state
  from a reload and immediately leaves. MOCK_VOICE_STATE now seeds remote
  users only (2, 3), and widget tests join through the real click path via
  a new joinVoiceChannelByName helper.
- The mock's voice_join reply no longer includes a voice_token: a token
  starts a real LiveKit session that deterministically self-destructs in
  the browser mock (E2EE key exchange timeout ~15s / connect-refused
  retries), tearing the widget down mid-test. These web tests validate the
  WS/UI layer only; real LiveKit is covered by the native suite. The reply
  also gained the full VoiceStatePayload shape — the sidebar renders
  user.username directly, and the omitted field broke the whole voice-user
  list render.
- Message-load failure now renders an inline region error + Retry instead
  of a toast (UX spec 2), so the toast specs assert the inline UI and get
  their auto-dismiss vehicle from the delete-confirmation toast.

CI hardening so a future systemic breakage can never burn the full cap
again: maxFailures 20 and a 20-minute globalTimeout in CI (Playwright now
self-terminates with a usable report instead of being SIGKILLed), with the
workflow's timeout-minutes 25 as the outer backstop. The job stays
continue-on-error until it has proven stably green across a few pushes;
the ci.yml comment documents that flip trigger.

Full suite: 255/255 passing locally (~7.5 min at 1 worker, ~4 min at 2).
Unit tests (3598), typecheck, and prettier all clean.


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

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

* feat(admin): first-run setup wizard with config.yaml write-back + LiveKit auto-download (#1290)

* feat(admin): first-run setup wizard with config.yaml write-back

Turn the single-screen owner-account setup into a guided multi-step wizard
so non-technical operators never have to hand-edit YAML:

- config: new comment-preserving config.Save (yaml.Node round-trip, atomic
  temp+rename write, verified loadable before replacing the file) plus a
  shared config.DefaultPath. Persists the runtime-generated LiveKit
  credentials so voice tokens survive restarts.
- admin: POST /admin/api/setup accepts an optional "wizard" object
  (server name, MOTD, registration, port, TLS mode/domain, upload limit,
  voice quality). Values are validated before the account is created; DB
  settings and config.yaml are written after; failures downgrade to
  warnings so the created owner is never orphaned behind a 5xx. When a
  startup-only value changed the server restarts itself (reusing the
  backup/update restart machinery) and returns the new admin URL.
- admin: GET /admin/api/setup/status now returns secret-free prefill
  defaults while setup is pending.
- admin panel: six-step wizard UI (welcome, account, server basics,
  uploads & voice, access, review) with plain-language explanations, a
  restart/reconnect screen, and a "skip" path that keeps the legacy
  account-only flow byte-for-byte.
- legacy payload {username,password} and all existing call sites keep
  working (SetupOptions is a trailing variadic parameter).

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

* fix(lint): satisfy modernize — any over interface{}, new(expr) over ptr helper

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

* feat(voice): auto-download the LiveKit server binary

Voice now works with zero manual setup: when voice.auto_download_livekit
is enabled and no voice.livekit_binary is configured, the server fetches
the pinned livekit-server release (v1.13.5, overridable via
voice.livekit_version) from the official LiveKit GitHub releases in the
background at startup, verifies it against the release's checksums.txt,
extracts it into data/livekit/, and manages it as the existing companion
process (crash recovery, health checks, graceful shutdown).

- ws: new livekit_download.go — pinned version, per-platform asset
  mapping (linux/windows × amd64/arm64/armv7, matching LiveKit's
  goreleaser config), size-capped downloads, hash verification and
  extraction through one open handle (TOCTOU-safe), O_EXCL staging,
  atomic rename, stale-version cleanup. LiveKitProcess.Start resolves
  the binary asynchronously with retries so boot is never blocked.
- config: voice.auto_download_livekit + voice.livekit_version; enabled
  in the generated default config so fresh installs get working voice
  out of the box, while the compiled-in default stays off for existing
  configs. config.Load now loads the default file it just wrote, so the
  first boot runs with exactly the configuration the file documents.
- wizard: "Voice chat" toggle (on by default) in the Uploads & voice
  step; the choice is written to config.yaml and factored into the
  restart decision.
- docs: livekit-setup, server-configuration, deployment, README.

Verified end-to-end against the real v1.13.5 release: download,
checksum match, extraction, and process spawn all succeed.

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

* chore: remove stray server.log, ignore local run logs

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

---------

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-07-31 15:41:57 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 675ed230f3
commit 49595e48d7
250 changed files with 24879 additions and 16195 deletions
+12 -9
View File
@@ -207,6 +207,7 @@ jobs:
libgtk-3-dev \
libayatana-appindicator3-dev \
libsecret-1-dev \
libdbus-1-dev \
libasound2-dev \
libssl-dev \
librsvg2-dev
@@ -227,16 +228,17 @@ jobs:
- name: Rust unit tests
run: cargo test --lib
# Playwright e2e against the mocked-Tauri dev server. Non-blocking, and it
# will very likely be RED at first: the suite has never run in CI, and a
# local run of the 255 web tests on `main` itself fails ~229 of them, all
# cascading from the shared login helper in tests/e2e/helpers.ts
# (navigateToMainPage never sees [data-testid='app-layout']). That breakage
# predates this PR — it reproduces on a clean 70caa6c worktree.
# 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.
#
# The job is wired up anyway so the breakage is visible instead of invisible,
# but it MUST stay continue-on-error until the suite is repaired, and
# timeout-minutes caps the minutes it can burn while it is failing.
# 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.
# 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.
@@ -331,6 +333,7 @@ jobs:
libgtk-3-dev \
libayatana-appindicator3-dev \
libsecret-1-dev \
libdbus-1-dev \
libasound2-dev \
libssl-dev \
patchelf \
+2
View File
@@ -79,6 +79,7 @@ jobs:
libgtk-3-dev \
libayatana-appindicator3-dev \
libsecret-1-dev \
libdbus-1-dev \
libasound2-dev \
libssl-dev \
patchelf \
@@ -207,6 +208,7 @@ jobs:
libgtk-3-dev \
libayatana-appindicator3-dev \
libsecret-1-dev \
libdbus-1-dev \
libasound2-dev \
libssl-dev \
patchelf \
+3
View File
@@ -88,3 +88,6 @@ Client/tauri-client/.env
# Claude Code worktrees (local scratch, never commit)
.claude/worktrees/
# local server run logs
server.log
+16
View File
@@ -82,6 +82,22 @@ behavioural changes operators must know about.
### Behavioural changes operators must know about
- **The desktop client now actually uses the OS credential store.** The
`keyring` crate declares no `default` feature, so the previous
`keyring = "3"` dependency compiled its in-memory *mock* store on
Windows, macOS and Linux alike: saves reported success and the next
read in the same process returned nothing, and no credential was ever
written to Credential Manager / Keychain / Secret Service. The visible
symptom was the voice-E2EE identity keypair being regenerated, so the
published identity key stopped matching the key that signed the voice
announce and peers rejected it as a possible MITM. The platform
backends are now enabled explicitly and every write is read back
before it is reported as saved. See
[docs/credential-storage.md](docs/credential-storage.md).
- **Linux builds need a new system package, `libdbus-1-dev`**, for the
Secret Service backend. CI and release workflows install it already.
- Users on an affected machine are logged in again and re-verified by
their peers once, then persist normally.
- **`event_persistence.enabled` defaults to `true`.** Every broadcast
WebSocket event is written to the `events` table, retained for
24 hours by default, and pruned by a background goroutine every hour.
+112 -851
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -30,7 +30,7 @@
"test:mutate:dry": "stryker run --dryRunOnly"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1",
"@stryker-mutator/api": "^9.6.1",
"@stryker-mutator/core": "^9.6.1",
@@ -39,7 +39,7 @@
"@tauri-apps/cli": "^2",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-v8": "^3",
"eslint": "^9.39.4",
"eslint": "^10.8.0",
"jsdom": "^29.1.1",
"knip": "^6.1.1",
"oxlint": "^1.76.0",
@@ -72,6 +72,7 @@
"livekit-client": "^2.21.0"
},
"overrides": {
"qs": "^6.15.3"
"qs": "^6.15.3",
"test-exclude": "^8.0.0"
}
}
+12 -1
View File
@@ -11,8 +11,19 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 1,
workers: process.env.CI ? 1 : undefined,
// CI fail-fast: a systemic breakage (e.g. the shared login helper) makes
// most of the 255 tests burn their full timeout × retries — hours of runner
// time at 1 worker. Abort after 20 failures instead so the job reports a
// usable red quickly. 0 = unlimited (local runs see every failure).
maxFailures: process.env.CI ? 20 : 0,
// Self-terminate before the workflow's timeout-minutes (25) SIGKILLs the
// runner, so the HTML/JUnit report still gets written and uploaded.
globalTimeout: process.env.CI ? 20 * 60 * 1000 : 0,
reporter: process.env.CI
? [["html", { open: "never" }], ["junit", { outputFile: "test-results/junit.xml" }]]
? [
["html", { open: "never" }],
["junit", { outputFile: "test-results/junit.xml" }],
]
: "html",
use: {
+349 -33
View File
@@ -8,6 +8,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures 0.2.17",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -351,6 +362,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.6.2"
@@ -507,6 +527,15 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.2.57"
@@ -603,6 +632,16 @@ dependencies = [
"phf_codegen 0.11.3",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clap"
version = "4.6.0"
@@ -975,6 +1014,24 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "dbus-secret-service"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
dependencies = [
"aes",
"block-padding",
"cbc",
"dbus",
"fastrand",
"hkdf",
"num",
"once_cell",
"sha2",
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.8"
@@ -1059,6 +1116,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@@ -1973,6 +2031,24 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "html5ever"
version = "0.29.1"
@@ -2119,7 +2195,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.61.2",
"windows-core 0.58.0",
]
[[package]]
@@ -2316,6 +2392,16 @@ dependencies = [
"cfb",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -2457,7 +2543,13 @@ version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"byteorder",
"dbus-secret-service",
"log",
"secret-service",
"security-framework 2.11.1",
"security-framework 3.7.0",
"windows-sys 0.60.2",
"zeroize",
]
@@ -2760,6 +2852,19 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"cfg_aliases",
"libc",
"memoffset",
]
[[package]]
name = "nodrop"
version = "0.1.14"
@@ -2777,7 +2882,40 @@ dependencies = [
"mac-notification-sys",
"serde",
"tauri-winrt-notification",
"zbus",
"zbus 5.14.0",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
@@ -2786,6 +2924,36 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -3108,6 +3276,7 @@ dependencies = [
name = "owncord-client"
version = "1.1.0-alpha.3"
dependencies = [
"base64 0.22.1",
"device_query",
"futures-util",
"keyring",
@@ -3139,6 +3308,8 @@ dependencies = [
"url",
"webpki-roots 1.0.9",
"windows 0.58.0",
"windows-sys 0.60.2",
"zeroize",
]
[[package]]
@@ -3469,13 +3640,13 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "plist"
version = "1.8.0"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [
"base64 0.22.1",
"indexmap 2.13.0",
"quick-xml 0.38.4",
"quick-xml",
"serde",
"time",
]
@@ -3652,18 +3823,9 @@ dependencies = [
[[package]]
name = "quick-xml"
version = "0.37.5"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
@@ -4169,7 +4331,7 @@ dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
"security-framework 3.7.0",
]
[[package]]
@@ -4197,7 +4359,7 @@ dependencies = [
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
@@ -4307,6 +4469,38 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "secret-service"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4"
dependencies = [
"aes",
"cbc",
"futures-util",
"generic-array",
"hkdf",
"num",
"once_cell",
"rand 0.8.7",
"serde",
"sha2",
"zbus 4.4.0",
]
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -4705,6 +4899,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "string_cache"
version = "0.8.9"
@@ -5222,7 +5422,7 @@ dependencies = [
"thiserror 2.0.18",
"url",
"windows 0.61.3",
"zbus",
"zbus 5.14.0",
]
[[package]]
@@ -5249,7 +5449,7 @@ dependencies = [
"tokio",
"tracing",
"windows-sys 0.60.2",
"zbus",
"zbus 5.14.0",
]
[[package]]
@@ -5441,11 +5641,10 @@ dependencies = [
[[package]]
name = "tauri-winrt-notification"
version = "0.7.2"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade"
dependencies = [
"quick-xml 0.37.5",
"thiserror 2.0.18",
"windows 0.61.3",
"windows-version",
@@ -7168,6 +7367,16 @@ dependencies = [
"rustix",
]
[[package]]
name = "xdg-home"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "yoke"
version = "0.8.1"
@@ -7191,6 +7400,38 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbus"
version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
dependencies = [
"async-broadcast",
"async-process",
"async-recursion",
"async-trait",
"enumflags2",
"event-listener",
"futures-core",
"futures-sink",
"futures-util",
"hex",
"nix",
"ordered-stream",
"rand 0.8.7",
"serde",
"serde_repr",
"sha1",
"static_assertions",
"tracing",
"uds_windows",
"windows-sys 0.52.0",
"xdg-home",
"zbus_macros 4.4.0",
"zbus_names 3.0.0",
"zvariant 4.2.0",
]
[[package]]
name = "zbus"
version = "5.14.0"
@@ -7221,9 +7462,22 @@ dependencies = [
"uuid",
"windows-sys 0.61.2",
"winnow 0.7.15",
"zbus_macros",
"zbus_names",
"zvariant",
"zbus_macros 5.14.0",
"zbus_names 4.3.1",
"zvariant 5.10.0",
]
[[package]]
name = "zbus_macros"
version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
"zvariant_utils 2.1.0",
]
[[package]]
@@ -7236,9 +7490,20 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"zbus_names",
"zvariant",
"zvariant_utils",
"zbus_names 4.3.1",
"zvariant 5.10.0",
"zvariant_utils 3.3.0",
]
[[package]]
name = "zbus_names"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
dependencies = [
"serde",
"static_assertions",
"zvariant 4.2.0",
]
[[package]]
@@ -7249,7 +7514,7 @@ checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
dependencies = [
"serde",
"winnow 0.7.15",
"zvariant",
"zvariant 5.10.0",
]
[[package]]
@@ -7298,6 +7563,20 @@ name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "zerotrie"
@@ -7350,6 +7629,19 @@ version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zvariant"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe"
dependencies = [
"endi",
"enumflags2",
"serde",
"static_assertions",
"zvariant_derive 4.2.0",
]
[[package]]
name = "zvariant"
version = "5.10.0"
@@ -7360,8 +7652,21 @@ dependencies = [
"enumflags2",
"serde",
"winnow 0.7.15",
"zvariant_derive",
"zvariant_utils",
"zvariant_derive 5.10.0",
"zvariant_utils 3.3.0",
]
[[package]]
name = "zvariant_derive"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
"zvariant_utils 2.1.0",
]
[[package]]
@@ -7374,7 +7679,18 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"zvariant_utils",
"zvariant_utils 3.3.0",
]
[[package]]
name = "zvariant_utils"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
+38 -1
View File
@@ -63,7 +63,37 @@ log = "0.4"
# TS client-logs) so a shipped user can retrieve them — a release build detaches
# the console, so stdout/stderr logging is otherwise unreachable.
tauri-plugin-log = "2"
keyring = "3"
# The backend features are NOT optional extras — keyring 3.x declares no
# `default` feature at all, and every platform arm in its lib.rs falls back to
# `pub use mock as default` when its backend feature is off. A bare
# `keyring = "3"` therefore compiles the in-memory mock store on Windows, macOS
# AND Linux: `set_password` succeeds into a per-Entry cell that is dropped when
# the Entry goes out of scope, and the next `Entry::new(..).get_password()`
# returns NoEntry. Nothing ever reaches Credential Manager / Keychain /
# Secret Service. Removing any of these silently reverts a platform to that
# store — `secret_store::tests::compiled_keyring_backend_is_persistent` fails
# the build if that happens.
# windows-native -> Windows Credential Manager (DPAPI-backed)
# apple-native -> macOS Keychain
# sync-secret-service -> Secret Service (GNOME Keyring / KWallet) over libdbus.
# Chosen over async-secret-service because our Tauri
# commands are blocking `fn`s on Tauri's worker pool;
# the async backend would need a nested runtime.
# Build-time system dep: libdbus-1-dev.
# crypto-rust -> pure-Rust session crypto for the Secret Service
# transport (avoids linking OpenSSL for it).
keyring = { version = "3", default-features = false, features = [
"windows-native",
"apple-native",
"sync-secret-service",
"crypto-rust",
] }
# Scrubs the plaintext secret copies that the DPAPI fallback has to materialize
# as `Vec<u8>` for the Win32 call.
zeroize = "1"
# Encodes the DPAPI ciphertext for the JSON fallback store. Already in the tree
# via the tauri/rustls stack, so this costs no extra build.
base64 = "0.22"
rfd = { version = "0.16", default-features = false }
# Desktop-only plugins (no mobile bundle target). single-instance carries the
@@ -77,6 +107,13 @@ tauri-plugin-deep-link = "2"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = ["Win32_UI_Input_KeyboardAndMouse"] }
# DPAPI (CryptProtectData/CryptUnprotectData) for the last-resort credential
# fallback in secret_store. Version tracks keyring's own windows-sys dep so the
# two share one build of the crate.
windows-sys = { version = "0.60", features = [
"Win32_Foundation",
"Win32_Security_Cryptography",
] }
[target.'cfg(target_os = "linux")'.dependencies]
device_query = "2"
@@ -6,3 +6,8 @@ pub const IDENTITY_PINS_STORE: &str = "identity_pins.json";
/// Tauri store file for user settings and preferences.
pub const SETTINGS_STORE: &str = "settings.json";
/// Tauri store file for the degraded-mode credential fallback (see
/// `secret_store`). Values are DPAPI ciphertext, never plaintext, and the file
/// only exists on a machine whose OS credential store failed a round-trip.
pub const CREDENTIAL_FALLBACK_STORE: &str = "credential_fallback.json";
+187 -126
View File
@@ -1,7 +1,7 @@
use keyring::Entry;
use serde::Serialize;
use tauri::AppHandle;
const SERVICE: &str = "com.owncord.client";
use crate::secret_store::{self, Backend};
/// Data returned from `load_credential`.
#[derive(Serialize, Clone)]
@@ -24,6 +24,35 @@ impl std::fmt::Debug for CredentialData {
}
}
// ---------------------------------------------------------------------------
// Account naming
// ---------------------------------------------------------------------------
//
// Both secrets live in the same credential-store service
// (`secret_store::SERVICE`) and are told apart by their account name. Changing
// either function orphans every credential already stored under the old name,
// so they are pure and covered by tests.
/// Account holding the login credential for `host`.
fn login_account(host: &str) -> String {
host.to_string()
}
/// Account holding the voice-E2EE identity private key for `host`.
///
/// The `identity:` prefix keeps it distinct from the login credential for the
/// same host; a collision would make one secret overwrite the other.
fn identity_account(host: &str) -> String {
format!("identity:{host}")
}
fn require_non_empty(value: &str, field: &str) -> Result<(), String> {
if value.is_empty() {
return Err(format!("{field} must not be empty"));
}
Ok(())
}
// ---------------------------------------------------------------------------
// Tauri commands
// ---------------------------------------------------------------------------
@@ -36,23 +65,20 @@ impl std::fmt::Debug for CredentialData {
///
/// On Windows the secret is protected by DPAPI via Windows Credential Manager.
/// On Linux it is stored in the Secret Service (GNOME Keyring / KWallet).
/// On macOS it is stored in the system Keychain.
/// 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]
pub fn save_credential(
app: AppHandle,
host: String,
username: String,
token: String,
password: Option<String>,
) -> Result<(), String> {
if host.is_empty() {
return Err("host must not be empty".into());
}
if token.is_empty() {
return Err("token must not be empty".into());
}
if username.is_empty() {
return Err("username must not be empty".into());
}
require_non_empty(&host, "host")?;
require_non_empty(&token, "token")?;
require_non_empty(&username, "username")?;
let mut payload = serde_json::json!({
"username": username,
@@ -62,12 +88,8 @@ pub fn save_credential(
payload["password"] = serde_json::Value::String(pw.clone());
}
let entry =
Entry::new(SERVICE, &host).map_err(|e| format!("keyring entry error: {e}"))?;
entry
.set_password(&payload.to_string())
secret_store::set(&app, &login_account(&host), &payload.to_string())
.map_err(|e| format!("save_credential failed: {e}"))?;
Ok(())
}
@@ -75,21 +97,24 @@ pub fn save_credential(
///
/// Returns `None` when no credential exists for the given host.
#[tauri::command]
pub fn load_credential(host: String) -> Result<Option<CredentialData>, String> {
if host.is_empty() {
return Err("host must not be empty".into());
}
pub fn load_credential(app: AppHandle, host: String) -> Result<Option<CredentialData>, String> {
require_non_empty(&host, "host")?;
let entry =
Entry::new(SERVICE, &host).map_err(|e| format!("keyring entry error: {e}"))?;
let json_str = match entry.get_password() {
Ok(s) => s,
Err(keyring::Error::NoEntry) => return Ok(None),
Err(e) => return Err(format!("load_credential failed: {e}")),
let Some(json_str) = secret_store::get(&app, &login_account(&host))
.map_err(|e| format!("load_credential failed: {e}"))?
else {
return Ok(None);
};
let parsed: serde_json::Value = serde_json::from_str(&json_str)
parse_credential_blob(&json_str).map(Some)
}
/// Parse the stored credential JSON blob.
///
/// Split out from the command so the blob contract is testable without a
/// credential store.
fn parse_credential_blob(json_str: &str) -> Result<CredentialData, String> {
let parsed: serde_json::Value = serde_json::from_str(json_str)
.map_err(|e| format!("credential blob is not valid JSON: {e}"))?;
let username = parsed
@@ -107,26 +132,21 @@ pub fn load_credential(host: String) -> Result<Option<CredentialData>, String> {
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Ok(Some(CredentialData { username, token, password }))
Ok(CredentialData {
username,
token,
password,
})
}
/// Delete a credential from the system credential store.
///
/// Deleting a non-existent credential is not treated as an error.
#[tauri::command]
pub fn delete_credential(host: String) -> Result<(), String> {
if host.is_empty() {
return Err("host must not be empty".into());
}
let entry =
Entry::new(SERVICE, &host).map_err(|e| format!("keyring entry error: {e}"))?;
match entry.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(format!("delete_credential failed: {e}")),
}
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}"))
}
// ---------------------------------------------------------------------------
@@ -134,28 +154,23 @@ pub fn delete_credential(host: String) -> Result<(), String> {
// ---------------------------------------------------------------------------
//
// Mirrors save/load/delete_credential, but the secret is a single opaque
// key blob (base64 PKCS8 private key) rather than a JSON credential struct,
// key blob (base64 JWK private key) rather than a JSON credential struct,
// and it is stored under account `identity:{host}` to keep it distinct from
// the login credential entry (account `{host}`) in the same keyring service.
// the login credential entry (account `{host}`) in the same service.
/// Save the long-term identity private key for `host` to the system credential
/// store, under account `identity:{host}`.
/// Save the long-term identity private key for `host`.
///
/// The write is read back before this returns. A machine whose credential store
/// accepts writes without keeping them falls through to the DPAPI file; 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]
pub fn save_identity_key(host: String, key: String) -> Result<(), String> {
if host.is_empty() {
return Err("host must not be empty".into());
}
if key.is_empty() {
return Err("key must not be empty".into());
}
pub fn save_identity_key(app: AppHandle, host: String, key: String) -> Result<(), String> {
require_non_empty(&host, "host")?;
require_non_empty(&key, "key")?;
let account = format!("identity:{host}");
let entry =
Entry::new(SERVICE, &account).map_err(|e| format!("keyring entry error: {e}"))?;
entry
.set_password(&key)
secret_store::set(&app, &identity_account(&host), &key)
.map_err(|e| format!("save_identity_key failed: {e}"))?;
Ok(())
}
@@ -163,39 +178,81 @@ pub fn save_identity_key(host: String, key: String) -> Result<(), String> {
///
/// Returns `None` when no identity key exists for the given host.
#[tauri::command]
pub fn load_identity_key(host: String) -> Result<Option<String>, String> {
if host.is_empty() {
return Err("host must not be empty".into());
}
let account = format!("identity:{host}");
let entry =
Entry::new(SERVICE, &account).map_err(|e| format!("keyring entry error: {e}"))?;
match entry.get_password() {
Ok(s) => Ok(Some(s)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(format!("load_identity_key failed: {e}")),
}
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}"))
}
/// Delete the identity private key for `host`.
///
/// Deleting a non-existent key is not treated as an error.
#[tauri::command]
pub fn delete_identity_key(host: String) -> Result<(), String> {
if host.is_empty() {
return Err("host must not be empty".into());
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}"))
}
let account = format!("identity:{host}");
let entry =
Entry::new(SERVICE, &account).map_err(|e| format!("keyring entry error: {e}"))?;
// ---------------------------------------------------------------------------
// Diagnostics
// ---------------------------------------------------------------------------
match entry.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(format!("delete_identity_key failed: {e}")),
/// Result of [`probe_credential_store`].
#[derive(Serialize, Debug)]
pub struct CredentialStoreProbe {
/// Whether a write/read/delete cycle completed with the value intact.
pub ok: bool,
/// Which store served the probe, when it succeeded.
pub backend: Option<Backend>,
/// Failure detail, for the log and the support bundle.
pub error: Option<String>,
}
/// Write, read back and delete a throwaway secret to prove the credential store
/// works on this machine.
///
/// This is the check to run when a user reports peers rejecting their voice
/// 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]
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";
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}");
}
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),
}
}
}
}
@@ -208,66 +265,70 @@ mod tests {
use super::*;
#[test]
fn save_credential_rejects_empty_host() {
let result = save_credential("".into(), "user".into(), "tok".into(), None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("host must not be empty"));
fn require_non_empty_rejects_empty_and_names_the_field() {
let err = require_non_empty("", "host").unwrap_err();
assert_eq!(err, "host must not be empty");
assert_eq!(
require_non_empty("", "token").unwrap_err(),
"token must not be empty"
);
assert_eq!(
require_non_empty("", "username").unwrap_err(),
"username must not be empty"
);
assert_eq!(
require_non_empty("", "key").unwrap_err(),
"key must not be empty"
);
}
#[test]
fn save_credential_rejects_empty_token() {
let result = save_credential("host".into(), "user".into(), "".into(), None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("token must not be empty"));
fn require_non_empty_accepts_a_value() {
assert!(require_non_empty("chat.example.com", "host").is_ok());
}
#[test]
fn save_credential_rejects_empty_username() {
let result = save_credential("host".into(), "".into(), "tok".into(), None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("username must not be empty"));
fn login_and_identity_accounts_never_collide() {
// Both secrets share one credential-store service, so a collision would
// silently overwrite one with the other.
let host = "chat.example.com";
assert_eq!(login_account(host), "chat.example.com");
assert_eq!(identity_account(host), "identity:chat.example.com");
assert_ne!(login_account(host), identity_account(host));
}
#[test]
fn load_credential_rejects_empty_host() {
let result = load_credential("".into());
assert!(result.is_err());
assert!(result.unwrap_err().contains("host must not be empty"));
fn account_names_keep_the_port_that_distinguishes_hosts() {
// Two servers on one machine differ only by port; dropping it would
// make them share an identity key.
assert_ne!(login_account("localhost:8443"), login_account("localhost:9443"));
assert_eq!(identity_account("localhost:8443"), "identity:localhost:8443");
}
#[test]
fn delete_credential_rejects_empty_host() {
let result = delete_credential("".into());
assert!(result.is_err());
assert!(result.unwrap_err().contains("host must not be empty"));
fn parse_credential_blob_reads_all_fields() {
let data =
parse_credential_blob(r#"{"username":"alice","token":"tok","password":"pw"}"#).unwrap();
assert_eq!(data.username, "alice");
assert_eq!(data.token, "tok");
assert_eq!(data.password.as_deref(), Some("pw"));
}
#[test]
fn save_identity_key_rejects_empty_host() {
let result = save_identity_key("".into(), "key".into());
assert!(result.is_err());
assert!(result.unwrap_err().contains("host must not be empty"));
fn parse_credential_blob_allows_missing_password() {
let data = parse_credential_blob(r#"{"username":"alice","token":"tok"}"#).unwrap();
assert_eq!(data.password, None);
}
#[test]
fn save_identity_key_rejects_empty_key() {
let result = save_identity_key("host".into(), "".into());
assert!(result.is_err());
assert!(result.unwrap_err().contains("key must not be empty"));
}
#[test]
fn load_identity_key_rejects_empty_host() {
let result = load_identity_key("".into());
assert!(result.is_err());
assert!(result.unwrap_err().contains("host must not be empty"));
}
#[test]
fn delete_identity_key_rejects_empty_host() {
let result = delete_identity_key("".into());
assert!(result.is_err());
assert!(result.unwrap_err().contains("host must not be empty"));
fn parse_credential_blob_rejects_malformed_input() {
assert!(parse_credential_blob("not json").unwrap_err().contains("not valid JSON"));
assert!(parse_credential_blob(r#"{"token":"tok"}"#)
.unwrap_err()
.contains("missing 'username'"));
assert!(parse_credential_blob(r#"{"username":"alice"}"#)
.unwrap_err()
.contains("missing 'token'"));
}
#[test]
+146
View File
@@ -0,0 +1,146 @@
//! Windows DPAPI (Data Protection API) wrappers.
//!
//! Used only by [`crate::secret_store`]'s last-resort fallback: when the OS
//! credential store accepts a write but will not return it, the secret is
//! encrypted here and parked in a file under the app data dir instead.
//!
//! Protection is **user-scoped** (no `CRYPTPROTECT_LOCAL_MACHINE`), so the
//! ciphertext is only decryptable by the same Windows user account on the same
//! machine. `CRYPTPROTECT_UI_FORBIDDEN` guarantees the call never blocks on a
//! prompt — this runs inside a Tauri command, not on a UI thread.
//!
//! This module holds no Tauri types on purpose: it is pure bytes-in/bytes-out
//! so the Win32 surface can be compiled and reviewed on its own.
use windows_sys::Win32::Foundation::{GetLastError, LocalFree};
use windows_sys::Win32::Security::Cryptography::{
CryptProtectData, CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB,
};
use zeroize::Zeroize;
/// A Win32 error code from `GetLastError`.
pub type Win32Error = u32;
/// Owns a `CRYPT_INTEGER_BLOB` that DPAPI allocated for us.
///
/// DPAPI hands back a `LocalAlloc`ed buffer the caller must release. Wrapping it
/// means an early return or a panic while copying the payload out still frees
/// it, and lets the scrub-then-free order live in one place.
struct OutBlob(CRYPT_INTEGER_BLOB);
impl OutBlob {
fn to_vec(&self) -> Vec<u8> {
if self.0.pbData.is_null() || self.0.cbData == 0 {
return Vec::new();
}
// SAFETY: only constructed after DPAPI reported success, which means
// pbData points at cbData initialized bytes.
unsafe { std::slice::from_raw_parts(self.0.pbData, self.0.cbData as usize) }.to_vec()
}
}
impl Drop for OutBlob {
fn drop(&mut self) {
if self.0.pbData.is_null() {
return;
}
// Scrub first: on the unprotect path this buffer holds the plaintext
// identity key, and LocalFree does not zero what it releases.
// SAFETY: as in `to_vec`, plus the range is ours alone to write.
let bytes = unsafe { std::slice::from_raw_parts_mut(self.0.pbData, self.0.cbData as usize) };
bytes.zeroize();
// SAFETY: pbData came from DPAPI's LocalAlloc, and `Drop` runs at most
// once, so it is freed exactly once.
unsafe { LocalFree(self.0.pbData.cast()) };
}
}
/// Build an input blob over `buf`.
///
/// `CRYPT_INTEGER_BLOB::pbData` is `*mut u8` even for inputs DPAPI only reads,
/// so callers lend a mutable buffer rather than casting away a `&`.
fn in_blob(buf: &mut [u8]) -> CRYPT_INTEGER_BLOB {
CRYPT_INTEGER_BLOB {
cbData: buf.len() as u32,
pbData: buf.as_mut_ptr(),
}
}
fn empty_out() -> CRYPT_INTEGER_BLOB {
CRYPT_INTEGER_BLOB {
cbData: 0,
pbData: std::ptr::null_mut(),
}
}
/// Encrypt `plaintext` with the current user's DPAPI master key.
///
/// `entropy` is mixed into the key derivation, so a blob protected for one
/// account cannot be decrypted as another even if the file is edited by hand.
pub fn protect(plaintext: &[u8], entropy: &[u8]) -> Result<Vec<u8>, Win32Error> {
// Both buffers must be mutable to be addressed by CRYPT_INTEGER_BLOB, and
// `plaintext` is key material, so these are scrubbed local copies.
let mut input = plaintext.to_vec();
let mut entropy = entropy.to_vec();
let mut out = empty_out();
let in_b = in_blob(&mut input);
let ent_b = in_blob(&mut entropy);
// SAFETY: `in_b`/`ent_b` borrow live, correctly sized buffers that outlive
// the call; the description, reserved and prompt-struct pointers are null,
// which the API documents as "not supplied"; `out` is a valid destination
// that is only read after the return value is checked.
let ok = unsafe {
CryptProtectData(
&in_b,
std::ptr::null(),
&ent_b,
std::ptr::null(),
std::ptr::null(),
CRYPTPROTECT_UI_FORBIDDEN,
&mut out,
)
};
input.zeroize();
entropy.zeroize();
finish(ok, out)
}
/// Inverse of [`protect`]. Fails if the blob was protected by a different user,
/// on a different machine, or with different `entropy`.
pub fn unprotect(ciphertext: &[u8], entropy: &[u8]) -> Result<Vec<u8>, Win32Error> {
let mut input = ciphertext.to_vec();
let mut entropy = entropy.to_vec();
let mut out = empty_out();
let in_b = in_blob(&mut input);
let ent_b = in_blob(&mut entropy);
// SAFETY: as in `protect`. The extra `*mut PWSTR` out-param is the
// description string, which is null here to decline it.
let ok = unsafe {
CryptUnprotectData(
&in_b,
std::ptr::null_mut(),
&ent_b,
std::ptr::null(),
std::ptr::null(),
CRYPTPROTECT_UI_FORBIDDEN,
&mut out,
)
};
entropy.zeroize();
finish(ok, out)
}
/// Turn a Win32 `BOOL` plus its out-blob into a `Result`.
///
/// On failure DPAPI allocates nothing, so there is no blob to release.
fn finish(ok: i32, out: CRYPT_INTEGER_BLOB) -> Result<Vec<u8>, Win32Error> {
if ok == 0 {
// SAFETY: no preconditions; reads the calling thread's last error.
return Err(unsafe { GetLastError() });
}
Ok(OutBlob(out).to_vec())
}
+7
View File
@@ -1,9 +1,12 @@
mod commands;
mod constants;
mod credentials;
#[cfg(windows)]
mod dpapi;
mod http_proxy;
mod livekit_proxy;
mod ptt;
mod secret_store;
mod tofu;
mod tray;
mod update_commands;
@@ -111,6 +114,7 @@ pub fn run() {
credentials::save_identity_key,
credentials::load_identity_key,
credentials::delete_identity_key,
credentials::probe_credential_store,
update_commands::check_client_update,
update_commands::download_and_install_update,
ptt::ptt_start,
@@ -127,6 +131,9 @@ pub fn run() {
])
.setup(|app| {
// Rust logging is initialized by tauri_plugin_log (registered above).
// Record the credential backend first: if this build has no
// persistent store, every later credential symptom follows from it.
secret_store::log_compiled_backend();
tray::create_tray(app.handle())?;
Ok(())
})
@@ -0,0 +1,391 @@
//! Secret storage with a verified round-trip and a degraded-mode fallback.
//!
//! Every secret the client persists (the login credential and the voice-E2EE
//! long-term identity key) goes through here. The OS credential store is always
//! tried first and is the only store used on a healthy machine.
//!
//! # Why a write is verified
//!
//! `Entry::set_password` returning `Ok(())` does not mean the secret is
//! readable. This bit us for real: `keyring` 3.x declares no `default` feature,
//! and every platform arm in its `lib.rs` falls back to `pub use mock as
//! default` when the platform's backend feature is off. Built as a bare
//! `keyring = "3"`, the client shipped with the **mock** store on all three
//! desktop platforms — an in-memory cell owned by the `Entry` itself:
//!
//! ```text
//! save_identity_key -> Entry::new(..) -> set_password -> Ok(()) // Entry dropped here
//! load_identity_key -> Entry::new(..) -> get_password -> NoEntry // brand-new empty cell
//! ```
//!
//! So a save reported success and the very next read in the same process
//! returned nothing, with no error anywhere and nothing ever written to
//! Credential Manager. Downstream, the identity keypair was regenerated on
//! every reconnect, the published key stopped matching the key that signed the
//! voice announce, and peers correctly rejected the announce as a forged
//! signature. `Cargo.toml` now names the backend features explicitly and
//! [`tests::compiled_keyring_backend_is_persistent`] fails the build if they
//! are ever dropped again — but a store that lies about a write is exactly the
//! failure a `Result` cannot express, so writes are read back regardless.
//!
//! # Fallback policy
//!
//! The keychain is the right store; the fallback is damage control, not a
//! default. It engages only after a write has been proven not to round-trip,
//! and only on Windows, where DPAPI can protect the file at rest with a
//! user-scoped key. On macOS and Linux a failing Keychain / Secret Service is
//! reported as an error rather than silently downgraded to a file — writing a
//! login password or an identity private key to plaintext disk there would be a
//! worse outcome than not persisting it.
use serde::Serialize;
// Only the DPAPI fallback stores JSON values, and that is Windows-only.
#[cfg(windows)]
use serde_json::Value;
use tauri::AppHandle;
use tauri_plugin_store::StoreExt;
use crate::constants::CREDENTIAL_FALLBACK_STORE;
/// Credential-store service name. Shared by every account this module stores.
pub const SERVICE: &str = "com.owncord.client";
/// Which store actually holds a secret.
///
/// Variant names are serialized verbatim: `tauri-typegen` does not read serde
/// rename attributes, so a `rename_all` here would silently make the generated
/// TypeScript union disagree with the values actually sent over IPC.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Backend {
/// The OS credential store. The expected answer on every healthy machine.
Keyring,
/// DPAPI-protected file under the app data dir, used only after the OS
/// credential store accepted a write and then failed to return it.
DpapiFile,
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Store `secret` under `account`, and prove it can be read back.
///
/// Returns which backend ended up holding it. An `Err` means no store kept the
/// 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> {
match keyring_set(account, secret) {
Ok(()) => match keyring_get(account) {
// The normal path: written and read back byte-for-byte.
Ok(Some(ref got)) if got == secret => {
// 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);
return Ok(Backend::Keyring);
}
Ok(Some(_)) => {
log::error!(
"{SERVICE}: credential store returned a different secret than was written \
for account '{account}' — falling back"
);
// Purge it. `get` reads the credential store first, so leaving
// a value we did not write in place would shadow the fallback
// copy written below — handing the caller an identity key whose
// public half was never published, which is the exact failure
// this module exists to prevent.
if let Err(e) = keyring_delete(account) {
log::warn!(
"{SERVICE}: could not remove the mismatched entry for '{account}': {e}"
);
}
}
Ok(None) => log::error!(
"{SERVICE}: credential store accepted the write for account '{account}' \
but reports no entry on read-back — falling back"
),
Err(e) => log::error!(
"{SERVICE}: credential store accepted the write for account '{account}' \
but the read-back failed: {e} — falling back"
),
},
Err(e) => log::error!("{SERVICE}: credential store write failed for '{account}': {e}"),
}
set_fallback(app, account, secret)?;
log::warn!(
"{SERVICE}: account '{account}' is stored in the DPAPI fallback file, not the OS \
credential store. See docs/credential-storage.md"
);
Ok(Backend::DpapiFile)
}
/// Load the secret for `account`, or `None` when nothing is stored.
///
/// 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> {
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(get_fallback(app, account))
}
/// Remove `account` from every store. Absent entries are not an error.
///
/// Both stores are cleared even if one errors: a delete that left the fallback
/// copy behind would resurrect a "deleted" secret on the next read.
pub fn delete(app: &AppHandle, account: &str) -> Result<(), String> {
let keyring_result = keyring_delete(account);
clear_fallback(app, account);
keyring_result
}
// ---------------------------------------------------------------------------
// Compiled-backend introspection
// ---------------------------------------------------------------------------
/// Whether the `keyring` backend compiled into this build keeps secrets on disk.
///
/// `keyring` picks its backend at compile time and falls back to the in-memory
/// mock when a platform's feature is missing, so this is a property of the
/// build, not of the machine. `CredentialPersistence` is `#[non_exhaustive]`
/// and carries no `Debug`, hence the explicit description.
fn compiled_backend_persistence() -> (bool, &'static str) {
// `CredentialBuilderApi` needs no import: `default_credential_builder`
// returns a `dyn` trait object, whose methods resolve without it.
use keyring::credential::CredentialPersistence;
match keyring::default::default_credential_builder().persistence() {
CredentialPersistence::UntilDelete => (true, "persists until deleted (on disk)"),
CredentialPersistence::UntilReboot => (false, "vanishes on reboot (kernel memory)"),
CredentialPersistence::ProcessOnly => (false, "vanishes when the process exits"),
CredentialPersistence::EntryOnly => {
(false, "vanishes with the entry object (the in-memory mock store)")
}
_ => (false, "unrecognized persistence class"),
}
}
/// Record the compiled credential backend in the log file at startup.
///
/// A shipped release build has no console, so the log file is the only place a
/// user can be asked to look. Stating the backend there turns "my identity key
/// keeps changing" into a one-line answer.
pub fn log_compiled_backend() {
let (persistent, description) = compiled_backend_persistence();
if persistent {
log::info!("credential store: OS keyring, {description}");
} else {
log::error!(
"credential store: NO persistent backend compiled in — {description}. Credentials \
and the voice-E2EE identity key will not survive a restart. This is a build \
configuration fault, not a machine fault: check the keyring backend features in \
src-tauri/Cargo.toml."
);
}
}
// ---------------------------------------------------------------------------
// OS credential store
// ---------------------------------------------------------------------------
fn entry(account: &str) -> Result<keyring::Entry, String> {
keyring::Entry::new(SERVICE, account).map_err(|e| format!("keyring entry error: {e}"))
}
fn keyring_set(account: &str, secret: &str) -> Result<(), String> {
entry(account)?
.set_password(secret)
.map_err(|e| format!("{e}"))
}
fn keyring_get(account: &str) -> Result<Option<String>, String> {
match entry(account)?.get_password() {
Ok(secret) => Ok(Some(secret)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(format!("{e}")),
}
}
fn keyring_delete(account: &str) -> Result<(), String> {
match entry(account)?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(format!("delete failed: {e}")),
}
}
// ---------------------------------------------------------------------------
// Degraded-mode fallback (Windows only, DPAPI-protected)
// ---------------------------------------------------------------------------
/// Entropy bound into the DPAPI blob for `account`.
///
/// Including the service and account means a ciphertext lifted from one entry
/// cannot be pasted over another and still decrypt — the identity key for one
/// host cannot be made to load as another's.
#[cfg(windows)]
fn dpapi_entropy(account: &str) -> Vec<u8> {
format!("{SERVICE}\u{1}{account}").into_bytes()
}
#[cfg(windows)]
fn set_fallback(app: &AppHandle, account: &str, secret: &str) -> Result<(), String> {
use base64::Engine as _;
let blob = crate::dpapi::protect(secret.as_bytes(), &dpapi_entropy(account))
.map_err(|code| format!("DPAPI protect failed (Win32 error {code})"))?;
let encoded = base64::engine::general_purpose::STANDARD.encode(blob);
let store = app
.store(CREDENTIAL_FALLBACK_STORE)
.map_err(|e| format!("failed to open credential fallback store: {e}"))?;
let old = store.get(account);
store.set(account, Value::String(encoded));
if let Err(e) = store.save() {
// Restore the previous in-memory state so a failed flush cannot drop a
// credential that was already parked here.
match old {
Some(v) => store.set(account, v),
None => {
let _ = store.delete(account);
}
}
return Err(format!("failed to persist credential fallback: {e}"));
}
Ok(())
}
#[cfg(not(windows))]
fn set_fallback(_app: &AppHandle, account: &str, _secret: &str) -> Result<(), String> {
// Deliberately no file fallback here: see the module header. The Keychain
// and Secret Service are the right stores on these platforms, and a
// plaintext file holding a login password or an identity private key is a
// worse outcome than failing to persist.
Err(format!(
"the OS credential store did not accept '{account}' and there is no fallback store on \
this platform — check that the Keychain (macOS) or a Secret Service provider such as \
gnome-keyring / KWallet (Linux) is running and unlocked"
))
}
#[cfg(windows)]
fn get_fallback(app: &AppHandle, account: &str) -> Option<String> {
use base64::Engine as _;
let store = app
.store(CREDENTIAL_FALLBACK_STORE)
.map_err(|e| log::warn!("failed to open credential fallback store: {e}"))
.ok()?;
let encoded = match store.get(account) {
Some(Value::String(s)) => s,
_ => return None,
};
let blob = base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|e| log::warn!("credential fallback entry for '{account}' is not base64: {e}"))
.ok()?;
let plaintext = crate::dpapi::unprotect(&blob, &dpapi_entropy(account))
.map_err(|code| {
log::warn!("DPAPI unprotect failed for '{account}' (Win32 error {code}) — the entry \
was written by a different Windows user or on a different machine")
})
.ok()?;
String::from_utf8(plaintext)
.map_err(|_| log::warn!("credential fallback entry for '{account}' is not valid UTF-8"))
.ok()
}
#[cfg(not(windows))]
fn get_fallback(_app: &AppHandle, _account: &str) -> Option<String> {
None
}
/// Drop any fallback copy of `account`. Best-effort: a failure here is logged,
/// never propagated, because it must not mask the outcome of the real store.
fn clear_fallback(app: &AppHandle, account: &str) {
let Ok(store) = app.store(CREDENTIAL_FALLBACK_STORE) else {
return;
};
// `delete` reports whether a key was present; only flush when one was, so
// the common healthy path does not rewrite the file on every save.
if store.delete(account) {
if let Err(e) = store.save() {
log::warn!("failed to flush credential fallback removal for '{account}': {e}");
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// Regression guard for the bug this module exists to prevent.
///
/// `keyring` has no `default` feature: with the backend features missing it
/// silently compiles the in-memory mock store, whose writes never survive
/// the `Entry` that made them. This asserts the backend linked into *this*
/// build persists to disk, so dropping the features from `Cargo.toml` is a
/// test failure rather than a silent loss of credential storage on a user's
/// machine. It needs no live keychain — it inspects the compiled backend.
#[test]
fn compiled_keyring_backend_is_persistent() {
let (persistent, description) = compiled_backend_persistence();
assert!(
persistent,
"keyring compiled a non-persistent backend ({description}); the platform backend \
features in Cargo.toml (windows-native / apple-native / sync-secret-service) are \
missing or a platform arm fell through to `mock`"
);
}
#[test]
fn service_name_is_stable() {
// The service name is half of the credential's identity; changing it
// orphans every already-stored credential.
assert_eq!(SERVICE, "com.owncord.client");
}
/// Pins the IPC wire format to the variant names, which is what
/// `tauri-typegen` emits into `generated/types.ts` as
/// `type Backend = "Keyring" | "DpapiFile"`. Renaming a variant, or adding
/// a serde rename, desyncs the generated union from the runtime value.
#[test]
fn backend_serializes_as_its_variant_name() {
assert_eq!(
serde_json::to_string(&Backend::Keyring).unwrap(),
"\"Keyring\""
);
assert_eq!(
serde_json::to_string(&Backend::DpapiFile).unwrap(),
"\"DpapiFile\""
);
}
#[cfg(windows)]
#[test]
fn dpapi_entropy_is_account_specific() {
assert_ne!(dpapi_entropy("host.example"), dpapi_entropy("identity:host.example"));
assert_eq!(dpapi_entropy("host.example"), dpapi_entropy("host.example"));
}
#[cfg(windows)]
#[test]
fn dpapi_round_trips_and_rejects_foreign_entropy() {
let secret = b"eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2In0";
let blob = crate::dpapi::protect(secret, &dpapi_entropy("identity:a.example")).unwrap();
assert_ne!(blob.as_slice(), secret.as_slice(), "blob must not be plaintext");
let back = crate::dpapi::unprotect(&blob, &dpapi_entropy("identity:a.example")).unwrap();
assert_eq!(back, secret);
// A blob moved to another account's slot must not decrypt.
assert!(crate::dpapi::unprotect(&blob, &dpapi_entropy("identity:b.example")).is_err());
}
}
@@ -15,7 +15,8 @@ export interface MemberContextMenuOptions {
currentRole: string;
availableRoles: readonly string[];
onKick(): Promise<void>;
onBan(): Promise<void>;
/** The reason is stored and displayed by the server; empty means "no reason given". */
onBan(reason: string): Promise<void>;
onChangeRole(newRole: string): Promise<void>;
}
@@ -51,26 +52,70 @@ function createSeparator(): HTMLDivElement {
return createElement("div", { class: "context-menu__separator" });
}
/** How long a "Are you sure?" state stays armed before reverting. */
const CONFIRM_TIMEOUT_MS = 4000;
/**
* Two-click confirm with an in-flight state.
*
* The armed state auto-disarms after a few seconds so a menu left open doesn't
* turn a stray second click into a ban, and the item shows progress while the
* request is running — a slow kick used to look like nothing happened.
*/
function withConfirmation(
item: HTMLDivElement,
confirmLabel: string,
onConfirm: () => void,
onConfirm: () => void | Promise<void>,
signal: AbortSignal,
pendingLabel = "Working...",
): void {
let confirming = false;
let running = false;
let disarmTimer: ReturnType<typeof setTimeout> | null = null;
const originalLabel = item.textContent ?? "";
function disarm(): void {
confirming = false;
if (disarmTimer !== null) {
clearTimeout(disarmTimer);
disarmTimer = null;
}
setText(item, originalLabel);
}
signal.addEventListener("abort", () => {
if (disarmTimer !== null) clearTimeout(disarmTimer);
});
item.addEventListener(
"click",
(e) => {
e.stopPropagation();
if (confirming) {
confirming = false;
setText(item, originalLabel);
onConfirm();
} else {
if (running) return;
if (!confirming) {
confirming = true;
setText(item, confirmLabel);
disarmTimer = setTimeout(disarm, CONFIRM_TIMEOUT_MS);
return;
}
if (disarmTimer !== null) {
clearTimeout(disarmTimer);
disarmTimer = null;
}
confirming = false;
running = true;
setText(item, pendingLabel);
item.classList.add("context-menu__item--pending");
const done = (): void => {
running = false;
item.classList.remove("context-menu__item--pending");
setText(item, originalLabel);
};
const result = onConfirm();
if (result instanceof Promise) {
void result.then(done, done);
} else {
done();
}
},
{ signal },
@@ -142,17 +187,10 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont
},
"Kick",
);
withConfirmation(
kickItem,
"Are you sure?",
() => {
void options.onKick();
},
ac.signal,
);
withConfirmation(kickItem, "Are you sure?", () => options.onKick(), ac.signal, "Kicking...");
menu.appendChild(kickItem);
// Ban with confirmation
// Ban — collects the reason the server stores and displays alongside the ban.
const banItem = createElement(
"div",
{
@@ -160,15 +198,74 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont
},
"Ban",
);
withConfirmation(
banItem,
"Are you sure?",
() => {
void options.onBan();
},
ac.signal,
const banReasonRow = createElement("div", {
class: "context-menu__reason",
style: "display:none;padding:6px 8px",
});
const banReasonInput = createElement("input", {
class: "form-input",
type: "text",
placeholder: "Reason (optional)",
maxlength: "200",
"data-testid": "ban-reason-input",
style: "width:100%;font-size:12px",
});
const banConfirm = createElement(
"div",
{ class: "context-menu__item context-menu__item--danger", "data-testid": "ban-confirm" },
"Confirm Ban",
);
menu.appendChild(banItem);
appendChildren(banReasonRow, banReasonInput, banConfirm);
banItem.addEventListener(
"click",
(e) => {
e.stopPropagation();
banItem.style.display = "none";
banReasonRow.style.display = "";
banReasonInput.focus();
},
{ signal: ac.signal },
);
// Typing a reason must not close the menu or trigger the outside-click guard.
banReasonInput.addEventListener("click", (e) => e.stopPropagation(), { signal: ac.signal });
banReasonInput.addEventListener("mousedown", (e) => e.stopPropagation(), { signal: ac.signal });
let banRunning = false;
function submitBan(): void {
if (banRunning) return;
banRunning = true;
setText(banConfirm, "Banning...");
banConfirm.classList.add("context-menu__item--pending");
const done = (): void => {
banRunning = false;
banConfirm.classList.remove("context-menu__item--pending");
setText(banConfirm, "Confirm Ban");
};
void options.onBan(banReasonInput.value.trim()).then(done, done);
}
banConfirm.addEventListener(
"click",
(e) => {
e.stopPropagation();
submitBan();
},
{ signal: ac.signal },
);
banReasonInput.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
submitBan();
}
},
{ signal: ac.signal },
);
appendChildren(menu, banItem, banReasonRow);
function destroy(): void {
ac.abort();
@@ -214,14 +311,7 @@ export function createChannelContextMenu(options: ChannelContextMenuOptions): Co
},
"Delete Channel",
);
withConfirmation(
deleteItem,
"Are you sure?",
() => {
void options.onDelete();
},
ac.signal,
);
withConfirmation(deleteItem, "Are you sure?", () => options.onDelete(), ac.signal, "Deleting...");
menu.appendChild(deleteItem);
function destroy(): void {
@@ -562,11 +562,26 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
const unsubscribers: Array<() => void> = [];
/** Voice-user rows from the last render, keyed by user id — lets the
* speaking-only subscription patch classes without per-user querySelector. */
const voiceRowByUserId = new Map<number, HTMLElement>();
function rebuildVoiceRowCache(): void {
voiceRowByUserId.clear();
if (channelList === null) return;
for (const row of channelList.querySelectorAll<HTMLElement>(
".voice-user-item[data-voice-uid]",
)) {
voiceRowByUserId.set(Number(row.dataset.voiceUid), row);
}
}
function renderChannels(): void {
if (channelList === null) {
return;
}
clearChildren(channelList);
voiceRowByUserId.clear();
const grouped = getChannelsByCategory();
const state = channelsStore.getState();
@@ -601,6 +616,8 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
),
);
}
rebuildVoiceRowCache();
}
function mount(container: Element): void {
@@ -659,13 +676,15 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
);
unsubscribers.push(unsubConnStatus);
// Subscribe to voice store — only full re-render when users join/leave
// or mute/deafen/camera changes. Speaking state is patched in-place via
// CSS class toggle to avoid destroying DOM elements (which kills hover).
let prevVoiceStructureSig = "";
const unsubVoice = voiceStore.subscribe((state) => {
// Structural signature: who is in which channel + mute/deafen/camera.
// Excludes speaking — that's patched in-place below.
// Subscribe to voice store, split in two:
// (a) a structural selector (who is in which channel + mute/deafen/camera/
// screenshare + E2EE verification, excluding `speaking`) that does a
// full re-render;
// (b) a speaking-only patcher that toggles CSS classes on rows cached at
// render time, so a speaker event never destroys DOM elements (which
// kills hover) and never pays a per-user querySelector.
const unsubVoiceStructure = voiceStore.subscribeSelector(
(state) => {
let structSig = String(state.currentChannelId ?? "");
for (const [chId, users] of state.voiceUsers) {
structSig += `|${chId}`;
@@ -676,26 +695,25 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
structSig += `:${uid}${u.muted ? "m" : ""}${u.deafened ? "d" : ""}${u.camera ? "c" : ""}${u.screenshare ? "s" : ""}${verif ? `@${verif.status}` : ""}`;
}
}
if (structSig !== prevVoiceStructureSig) {
prevVoiceStructureSig = structSig;
renderChannels();
return;
}
return structSig;
},
() => renderChannels(),
);
unsubscribers.push(unsubVoiceStructure);
// Patch speaking state in-place — toggle CSS class without re-rendering.
if (channelList === null) return;
// Registered after the structural subscription so a structural change in
// the same notification re-renders (and refreshes the row cache) first.
const unsubSpeaking = voiceStore.subscribe((state) => {
for (const [, users] of state.voiceUsers) {
for (const [uid, u] of users) {
const row = channelList.querySelector<HTMLElement>(
`.voice-user-item[data-voice-uid="${uid}"]`,
);
if (row !== null) {
const row = voiceRowByUserId.get(uid);
if (row !== undefined) {
row.classList.toggle("speaking", u.speaking);
}
}
}
});
unsubscribers.push(unsubVoice);
unsubscribers.push(unsubSpeaking);
}
function destroy(): void {
@@ -705,6 +723,7 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
unsub();
}
unsubscribers.length = 0;
voiceRowByUserId.clear();
if (root !== null) {
root.remove();
root = null;
@@ -33,6 +33,9 @@ export interface InviteManagerOptions {
// Helpers
// ---------------------------------------------------------------------------
/** How long a "Sure?" revoke stays armed before reverting. */
const CONFIRM_TIMEOUT_MS = 4000;
function maskCode(code: string): string {
if (code.length <= 6) return code;
return `${code.slice(0, 3)}...${code.slice(-3)}`;
@@ -85,12 +88,43 @@ export function createInviteManager(options: InviteManagerOptions): MountableCom
{ signal: ac.signal },
);
// Revoking kills a live invite link — two-click confirm, then an
// in-flight state so a slow revoke isn't clicked twice.
const revokeBtn = createElement("button", { class: "invite-item__revoke" });
const revokeLabel = document.createTextNode(" Revoke");
revokeBtn.appendChild(createIcon("trash-2", 14));
revokeBtn.appendChild(document.createTextNode(" Revoke"));
revokeBtn.appendChild(revokeLabel);
let confirming = false;
let revoking = false;
let disarmTimer: ReturnType<typeof setTimeout> | null = null;
const disarm = (): void => {
confirming = false;
if (disarmTimer !== null) {
clearTimeout(disarmTimer);
disarmTimer = null;
}
revokeLabel.nodeValue = " Revoke";
revokeBtn.classList.remove("invite-item__revoke--confirming");
};
revokeBtn.addEventListener(
"click",
() => {
if (revoking) return;
if (!confirming) {
confirming = true;
revokeLabel.nodeValue = " Sure?";
revokeBtn.classList.add("invite-item__revoke--confirming");
disarmTimer = setTimeout(disarm, CONFIRM_TIMEOUT_MS);
return;
}
if (disarmTimer !== null) {
clearTimeout(disarmTimer);
disarmTimer = null;
}
confirming = false;
revoking = true;
revokeBtn.disabled = true;
revokeLabel.nodeValue = " Revoking...";
void options
.onRevokeInvite(invite.code)
.then(() => {
@@ -98,6 +132,10 @@ export function createInviteManager(options: InviteManagerOptions): MountableCom
renderList();
})
.catch(() => {
revoking = false;
revokeBtn.disabled = false;
revokeBtn.classList.remove("invite-item__revoke--confirming");
revokeLabel.nodeValue = " Revoke";
options.onError?.("Failed to revoke invite");
});
},
@@ -142,17 +180,28 @@ export function createInviteManager(options: InviteManagerOptions): MountableCom
const footer = createElement("div", { class: "modal-footer" });
const createBtn = createElement("button", { class: "invite-manager__create btn-modal-save" });
createBtn.appendChild(createIcon("external-link", 14));
createBtn.appendChild(document.createTextNode(" Create Invite"));
const createLabel = document.createTextNode(" Create Invite");
createBtn.appendChild(createLabel);
createBtn.addEventListener(
"click",
() => {
// Without this guard an impatient double-click mints two invites.
if (createBtn.disabled) return;
createBtn.disabled = true;
createLabel.nodeValue = " Creating...";
const done = (): void => {
createBtn.disabled = false;
createLabel.nodeValue = " Create Invite";
};
void options
.onCreateInvite()
.then((newInvite) => {
invites = [...invites, newInvite];
renderList();
done();
})
.catch(() => {
done();
options.onError?.("Failed to create invite");
});
},
+112 -15
View File
@@ -7,8 +7,9 @@
import { createElement, appendChildren, clearChildren, setText } from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
import { Disposable } from "@lib/disposable";
import { membersStore, type Member } from "@stores/members.store";
import { membersStore, type Member, type MembersState } from "@stores/members.store";
import { authStore } from "@stores/auth.store";
import { channelsStore } from "@stores/channels.store";
import { createMemberContextMenu } from "@components/AdminActions";
import type { UserStatus } from "@lib/types";
@@ -16,10 +17,25 @@ import type { UserStatus } from "@lib/types";
export interface MemberListOptions {
readonly currentUserRole: string;
readonly onKick: (userId: number, username: string) => Promise<void>;
readonly onBan: (userId: number, username: string) => Promise<void>;
readonly onBan: (userId: number, username: string, reason: string) => Promise<void>;
readonly onChangeRole: (userId: number, username: string, newRole: string) => Promise<void>;
}
/** Roles offered in the "Change Role" submenu when the server hasn't sent any. */
const FALLBACK_ASSIGNABLE_ROLES: readonly string[] = ["admin", "moderator", "member"];
/**
* Role names an admin can assign, taken from the server's role list. "owner" is
* excluded — ownership transfer isn't a context-menu action.
*/
function assignableRoleNames(): readonly string[] {
const roles = channelsStore
.getState()
.roles.map((r) => r.name.toLowerCase())
.filter((name) => name !== "owner");
return roles.length > 0 ? roles : FALLBACK_ASSIGNABLE_ROLES;
}
/** Ordered role groups with display names and CSS color variables. */
const ROLE_GROUPS: readonly {
readonly role: string;
@@ -127,7 +143,10 @@ function createMemberItem(
closeActiveMenu();
document.removeEventListener("mousedown", handleOutsideClick);
const availableRoles = ["admin", "moderator", "member"];
// Roles come from the server's `ready` payload — a hardcoded list made
// custom roles unreachable and, worse, unresolvable to a role id, so
// picking one silently did nothing.
const availableRoles = assignableRoleNames();
activeMenu = createMemberContextMenu({
userId: member.id,
@@ -135,7 +154,7 @@ function createMemberItem(
currentRole: member.role.toLowerCase(),
availableRoles,
onKick: () => opts.onKick(member.id, member.username),
onBan: () => opts.onBan(member.id, member.username),
onBan: (reason: string) => opts.onBan(member.id, member.username, reason),
onChangeRole: (newRole: string) => opts.onChangeRole(member.id, member.username, newRole),
});
@@ -157,13 +176,18 @@ function createMemberItem(
return item;
}
function renderList(root: HTMLDivElement, opts: MemberListOptions, signal: AbortSignal): void {
function renderList(
root: HTMLDivElement,
opts: MemberListOptions,
signal: AbortSignal,
rowsByUserId: Map<number, HTMLDivElement>,
): void {
clearChildren(root);
rowsByUserId.clear();
const state = membersStore.getState();
const allMembers = Array.from(state.members.values());
if (allMembers.length === 0) {
if (state.members.size === 0) {
const emptyState = createElement("div", { class: "member-list-empty" });
const msg = createElement("p", { class: "member-list-empty-text" }, "No members online");
emptyState.appendChild(msg);
@@ -171,10 +195,23 @@ function renderList(root: HTMLDivElement, opts: MemberListOptions, signal: Abort
return;
}
// Single pass: bucket members by (lowercased) role, then sort each bucket
// by status \u2014 instead of one filter + toSorted sweep per role group.
const buckets = new Map<string, Member[]>();
for (const member of state.members.values()) {
const role = member.role.toLowerCase();
const bucket = buckets.get(role);
if (bucket === undefined) {
buckets.set(role, [member]);
} else {
bucket.push(member);
}
}
for (const group of ROLE_GROUPS) {
const groupMembers = allMembers
.filter((m) => m.role.toLowerCase() === group.role)
.toSorted((a, b) => statusPriority(a.status) - statusPriority(b.status));
const groupMembers = (buckets.get(group.role) ?? []).toSorted(
(a, b) => statusPriority(a.status) - statusPriority(b.status),
);
if (groupMembers.length === 0) continue;
@@ -186,7 +223,57 @@ function renderList(root: HTMLDivElement, opts: MemberListOptions, signal: Abort
root.appendChild(header);
for (const member of groupMembers) {
root.appendChild(createMemberItem(member, group.colorVar, opts, signal));
const item = createMemberItem(member, group.colorVar, opts, signal);
rowsByUserId.set(member.id, item);
root.appendChild(item);
}
}
}
/** True when the only difference between two member maps is presence status \u2014
* same ids with identical username/role/avatar/identity key. Such updates can
* be patched into the existing rows instead of rebuilding the list. */
function isPresenceOnlyChange(
prev: ReadonlyMap<number, Member>,
next: ReadonlyMap<number, Member>,
): boolean {
if (prev.size === 0 || prev.size !== next.size) return false;
for (const [id, member] of next) {
const before = prev.get(id);
if (before === undefined) return false;
if (before === member) continue;
if (
before.username !== member.username ||
before.role !== member.role ||
before.avatar !== member.avatar ||
before.identityPublicKey !== member.identityPublicKey
) {
return false;
}
}
return true;
}
/** Patch status dots/classes in place for members whose presence changed.
* Row identity (and therefore hover/context-menu state) is preserved; the
* status-priority sort order is deliberately not reshuffled until the next
* structural render. */
function patchPresence(
prev: ReadonlyMap<number, Member>,
next: ReadonlyMap<number, Member>,
rowsByUserId: ReadonlyMap<number, HTMLDivElement>,
): void {
for (const [id, member] of next) {
const before = prev.get(id);
if (before === undefined || before.status === member.status) continue;
const row = rowsByUserId.get(id);
if (row === undefined) continue;
row.classList.toggle("offline", member.status === "offline");
const dot = row.querySelector<HTMLDivElement>(".mi-status");
if (dot !== null) {
dot.style.background = statusColor(member.status);
dot.setAttribute("aria-label", member.status);
dot.title = member.status;
}
}
}
@@ -194,18 +281,27 @@ function renderList(root: HTMLDivElement, opts: MemberListOptions, signal: Abort
export function createMemberList(opts: MemberListOptions): MountableComponent {
const disposable = new Disposable();
let root: HTMLDivElement | null = null;
/** Rendered rows by user id \u2014 lets presence-only updates patch in place. */
const rowsByUserId = new Map<number, HTMLDivElement>();
let prevMembers: ReadonlyMap<number, Member> = new Map();
function mount(container: Element): void {
root = createElement("div", { class: "member-list", "data-testid": "member-list" });
renderList(root, opts, disposable.signal);
prevMembers = membersStore.getState().members;
renderList(root, opts, disposable.signal, rowsByUserId);
disposable.onStoreChange(
disposable.onStoreChange<MembersState, ReadonlyMap<number, Member>>(
membersStore,
(s) => s.members,
() => {
(members) => {
if (root !== null) {
renderList(root, opts, disposable.signal);
if (isPresenceOnlyChange(prevMembers, members)) {
patchPresence(prevMembers, members, rowsByUserId);
} else {
renderList(root, opts, disposable.signal, rowsByUserId);
}
}
prevMembers = members;
},
);
@@ -216,6 +312,7 @@ export function createMemberList(opts: MemberListOptions): MountableComponent {
closeActiveMenu();
document.removeEventListener("mousedown", handleOutsideClick);
disposable.destroy();
rowsByUserId.clear();
if (root !== null) {
root.remove();
root = null;
@@ -42,6 +42,12 @@ export type MessageInputComponent = MountableComponent & {
* the server would refuse is prevented here, not attempted and rejected.
*/
setDisabled(reason: string | null): void;
/**
* Open the attachment file picker, as the "+" button does. Backs the
* Ctrl+U shortcut. No-op while the composer is disabled or when the host
* didn't wire an upload handler.
*/
openFilePicker(): void;
};
const TYPING_THROTTLE_MS = 3_000;
@@ -86,6 +92,8 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
let gifUnavailable = options.gifApi === undefined;
const controlButtons: HTMLButtonElement[] = [];
let attachmentPreviewBar: HTMLDivElement | null = null;
/** Set by mount() when file uploads are wired; backs openFilePicker(). */
let openPicker: (() => void) | null = null;
/** Pending attachment IDs to send with the next message. */
const pendingAttachments: { id: string; filename: string; readonly previewEl: HTMLDivElement }[] =
@@ -425,6 +433,10 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
{ signal },
);
attachBtn.addEventListener("click", () => fileInput.click(), { signal });
openPicker = () => {
if (disabledReason !== null) return;
fileInput.click();
};
root?.appendChild(fileInput);
} else {
attachBtn.setAttribute("disabled", "true");
@@ -667,7 +679,21 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
replyText = null;
editBar = null;
attachmentPreviewBar = null;
openPicker = null;
}
return { mount, destroy, setReplyTo, clearReply, startEdit, cancelEdit, setDisabled };
function openFilePicker(): void {
openPicker?.();
}
return {
mount,
destroy,
setReplyTo,
clearReply,
startEdit,
cancelEdit,
setDisabled,
openFilePicker,
};
}
+122 -10
View File
@@ -14,6 +14,7 @@ import {
} from "@stores/messages.store";
import type { Message } from "@stores/messages.store";
import { membersStore } from "@stores/members.store";
import { unobserveMedia } from "@lib/media-visibility";
const log = createLogger("message-list");
import { shouldGroup, isSameDay, renderDayDivider, renderMessage } from "./message-list/renderers";
@@ -100,10 +101,17 @@ function estimateItemHeight(item: VirtualItem): number {
// -- Pre-process messages into virtual items ----------------------------------
function buildVirtualItems(messages: readonly Message[]): readonly VirtualItem[] {
/** Build virtual items for `messages`. The optional seed (`prevMsg` /
* `lastTimestamp`) lets the incremental tail-append path continue grouping and
* day-divider logic from an already-built item list. */
function buildVirtualItems(
messages: readonly Message[],
seedPrevMsg: Message | null = null,
seedLastTimestamp: string | null = null,
): readonly VirtualItem[] {
const items: VirtualItem[] = [];
let lastTimestamp: string | null = null;
let prevMsg: Message | null = null;
let lastTimestamp: string | null = seedLastTimestamp;
let prevMsg: Message | null = seedPrevMsg;
for (const msg of messages) {
if (lastTimestamp === null || !isSameDay(lastTimestamp, msg.timestamp)) {
@@ -319,6 +327,17 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
}
}
/** Release IntersectionObserver tracking, pending freeze timers, and frozen-
* frame data URLs for GIFs in rows that are about to be discarded — without
* this, media-visibility retains every <img> ever rendered. Must run before
* every clearChildren(contentContainer) and on destroy. */
function releaseTrackedMedia(): void {
if (contentContainer === null) return;
for (const img of contentContainer.querySelectorAll("img")) {
unobserveMedia(img);
}
}
let renderWindowCount = 0;
let renderWindowResetTimer = 0;
@@ -330,6 +349,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
const clientHeight = root.clientHeight;
if (virtualItems.length === 0) {
releaseTrackedMedia();
clearChildren(contentContainer);
// With no rows, the region shows the fetch state: an in-region loading
// placeholder, an inline error + Retry, or the welcome/empty state once
@@ -386,6 +406,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
renderedEnd = end;
// Rebuild content
releaseTrackedMedia();
clearChildren(contentContainer);
const fragment = document.createDocumentFragment();
for (let i = start; i < end; i++) {
@@ -429,6 +450,95 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
}
}
// ---------------------------------------------------------------------------
// Incremental tail append (fast path)
// ---------------------------------------------------------------------------
/** Cap on rendered rows for the append fast path. Once the window grows past
* this, fall back to renderAll so it is re-trimmed to the visible range. */
const MAX_INCREMENTAL_WINDOW = 200;
/**
* Fast path for the common "new message arrived at the tail" update: when
* the store's array is a pure suffix extension of `allMessages`, append the
* new rows and re-seed the Fenwick tree instead of tearing down the whole
* rendered window (renderAll → renderWindow REBUILD). Anything else (edits,
* deletes, history prepends, confirmations replacing optimistic rows)
* returns false so the caller does a full rebuild.
*
* Scroll-anchor/spacer safety: no existing row is touched, so the anchor
* item's offset only changes via the bottom spacer/appended rows below it;
* the ResizeObserver's RAF pass re-measures and restores the anchor exactly
* as it does for image loads. The renderWindow oscillation guard is not
* consumed — this path never rebuilds.
*/
function tryAppendMessages(): boolean {
if (root === null || contentContainer === null || tree === null) return false;
if (renderAllRunning || renderedStart < 0) return false;
const prev = allMessages;
const next = getChannelMessages(options.channelId);
if (prev.length === 0 || next.length <= prev.length) return false;
for (let i = 0; i < prev.length; i++) {
if (next[i] !== prev[i]) return false;
}
const prevLast = prev[prev.length - 1]!;
const appendedItems = buildVirtualItems(next.slice(prev.length), prevLast, prevLast.timestamp);
const oldItemCount = virtualItems.length;
const windowAtTail = renderedEnd === oldItemCount;
if (
windowAtTail &&
renderedEnd - renderedStart + appendedItems.length > MAX_INCREMENTAL_WINDOW
) {
return false; // window has grown too large — let renderAll re-trim it
}
const atBottom = isNearBottom();
// Capture measured heights of the currently rendered rows before swapping
// trees so the rebuilt tree starts from real measurements.
measureRendered();
allMessages = next;
virtualItems = [...virtualItems, ...appendedItems];
// Extend the height index. FenwickTree is fixed-size, so re-seed a fresh
// one from the height cache — cheap relative to the DOM teardown this
// path avoids.
tree = new FenwickTree(virtualItems.length);
for (let i = 0; i < virtualItems.length; i++) {
const cached = heightCache.get(itemKey(i));
tree.set(i, cached !== undefined ? cached : estimateItemHeight(virtualItems[i]!));
}
if (windowAtTail) {
// The rendered window includes the old tail — append the new rows.
const fragment = document.createDocumentFragment();
for (const item of appendedItems) {
if (item.kind === "divider") {
fragment.appendChild(renderDayDivider(item.timestamp));
} else {
fragment.appendChild(
renderMessage(item.message, item.isGrouped, allMessages, options, ac.signal),
);
}
}
contentContainer.appendChild(fragment);
renderedEnd = virtualItems.length;
measureRendered();
}
// Otherwise the user has scrolled up past the tail: the new items only
// grow the bottom spacer; renderWindow picks them up on the next rebuild.
updateSpacers();
if (atBottom) {
scrollToBottom();
updateScrollToBottomBtn();
}
return true;
}
// Guard against re-entrant renderAll calls (e.g. if a subscriber fires
// during rendering). Also detects rapid-fire loops.
let renderAllRunning = false;
@@ -618,9 +728,13 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
unsubscribers.push(
messagesStore.subscribeSelector(
(s) => s.messagesByChannel,
// Scoped to the mounted channel so updates to OTHER channels (their
// array references are unchanged) never trigger a re-render here.
(s) => s.messagesByChannel.get(options.channelId),
() => {
if (!tryAppendMessages()) {
renderAll();
}
},
),
);
@@ -637,14 +751,11 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
);
// Only re-render when member roles change, not on presence/typing updates.
// Extract a role-only map so shallowEqual ignores status changes.
// The store bumps roleRevision solely on membership/role mutations, so
// selecting the counter avoids rebuilding a role map per notification.
unsubscribers.push(
membersStore.subscribeSelector(
(s) => {
const roles = new Map<number, string>();
for (const [id, m] of s.members) roles.set(id, m.role);
return roles;
},
(s) => s.roleRevision ?? 0,
() => {
renderAll();
},
@@ -681,6 +792,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
unsubscribers.length = 0;
heightCache.clear();
tree = null;
releaseTrackedMedia();
if (root !== null) {
root.remove();
root = null;
@@ -11,10 +11,6 @@ import type { MountableComponent } from "@lib/safe-render";
import type { UserStatus } from "@lib/types";
import { uiStore } from "@stores/ui.store";
import { authStore } from "@stores/auth.store";
import { loadPref, applyTheme, THEMES } from "./settings/helpers";
import type { ThemeName } from "./settings/helpers";
import { getActiveThemeName, restoreTheme } from "@lib/themes";
import { syncOsMotionListener } from "@lib/os-motion";
import { buildAccountTab } from "./settings/AccountTab";
import { buildAppearanceTab } from "./settings/AppearanceTab";
import { buildNotificationsTab } from "./settings/NotificationsTab";
@@ -66,54 +62,6 @@ const TAB_ICONS: Record<TabName, IconName> = {
Logs: "scroll-text",
};
// ---------------------------------------------------------------------------
// Apply stored appearance (called at app startup)
// ---------------------------------------------------------------------------
/**
* Apply stored appearance preferences (theme, font size, compact mode).
* Call at app startup so the UI doesn't flash default styles.
*/
export function applyStoredAppearance(): void {
const activeThemeName = getActiveThemeName();
if (activeThemeName in THEMES) {
applyTheme(activeThemeName as ThemeName);
} else {
restoreTheme();
}
try {
const rawAccent = localStorage.getItem("owncord:settings:accentColor");
if (rawAccent !== null) {
const accent = JSON.parse(rawAccent);
if (typeof accent === "string" && /^#[\da-fA-F]{3,8}$/.test(accent)) {
document.documentElement.style.setProperty("--accent", accent);
document.body.style.setProperty("--accent", accent);
}
}
} catch {
// Corrupted localStorage — keep the theme default accent.
}
document.documentElement.style.setProperty(
"--font-size",
`${loadPref<number>("fontSize", 16)}px`,
);
document.documentElement.classList.toggle(
"compact-mode",
loadPref<boolean>("compactMode", false),
);
document.documentElement.classList.toggle(
"reduced-motion",
loadPref<boolean>("reducedMotion", false),
);
document.documentElement.classList.toggle(
"high-contrast",
loadPref<boolean>("highContrast", false),
);
document.documentElement.classList.toggle("large-font", loadPref<boolean>("largeFont", false));
syncOsMotionListener(loadPref<boolean>("syncOsMotion", false));
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
@@ -127,8 +75,11 @@ export function createSettingsOverlay(
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;
const tabButtons = new Map<TabName, HTMLButtonElement>();
let unsubUi: (() => void) | null = null;
let unsubAuth: (() => void) | null = null;
// Stateful tabs — create via factory for proper cleanup on tab switch
const logsTab = createLogsTab(() => activeTab, ac.signal);
@@ -158,12 +109,21 @@ export function createSettingsOverlay(
contentArea.appendChild(pageTitle);
const builder = TAB_BUILDERS[activeTab];
contentArea.appendChild(builder());
contentLive = true;
}
/** Release resources held by the tab currently on screen. */
function cleanupActiveTab(): void {
if (activeTab === "Voice & Audio") voiceTab.cleanup();
// The Logs tab keeps a live log listener pointed at its (now discarded)
// list element — drop it so it isn't re-rendering a detached tree.
if (activeTab === "Logs") logsTab.cleanup();
}
function setActiveTab(tab: TabName): void {
if (tab === activeTab) return;
// Clean up stateful tabs when switching away
if (activeTab === "Voice & Audio") voiceTab.cleanup();
cleanupActiveTab();
activeTab = tab;
for (const [name, btn] of tabButtons) {
btn.classList.toggle("active", name === tab);
@@ -174,12 +134,17 @@ export function createSettingsOverlay(
function show(): void {
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();
}
function hide(): void {
root?.classList.remove("open");
// Stop camera preview and mic meter when settings overlay closes
voiceTab.cleanup();
// Stop camera preview, mic meter, and the log listener when the overlay closes
cleanupActiveTab();
contentLive = false;
}
// ---- MountableComponent ---------------------------------------------------
@@ -220,6 +185,16 @@ export function createSettingsOverlay(
appendChildren(profileSection, avatarEl, profileInfo);
sidebar.appendChild(profileSection);
// Keep the sidebar identity in step with the store — renaming yourself on
// the Account tab used to leave the old name sitting here until restart.
unsubAuth = authStore.subscribeSelector(
(s) => s.user?.username,
(name) => {
profileName.textContent = name ?? "Unknown";
avatarEl.textContent = (name ?? "U").charAt(0).toUpperCase();
},
);
// "User Settings" category — only Account belongs here (hidden when not authenticated)
if (authenticated) {
const userSettingsCat = createElement("div", { class: "settings-cat" }, "User Settings");
@@ -320,6 +295,9 @@ export function createSettingsOverlay(
root.appendChild(panel);
renderActiveTab();
// Content built while the panel is closed is only a placeholder: opening
// rebuilds it so the first view is as fresh as every later one.
contentLive = uiStore.getState().settingsOpen;
// Subscribe to uiStore for open/close
unsubUi = uiStore.subscribeSelector(
@@ -347,6 +325,10 @@ export function createSettingsOverlay(
unsubUi();
unsubUi = null;
}
if (unsubAuth !== null) {
unsubAuth();
unsubAuth = null;
}
logsTab.cleanup();
voiceTab.cleanup();
tabButtons.clear();
+12 -1
View File
@@ -11,6 +11,7 @@ import { authStore } from "@stores/auth.store";
import { openSettings, uiStore } from "@stores/ui.store";
import { createStatusPicker, type StatusPickerComponent } from "@components/StatusPicker";
import type { UserStatus } from "@lib/types";
import { loadUserStatus, onUserStatusChange, saveUserStatus } from "@lib/userStatus";
import type { WsClient } from "@lib/ws";
export interface UserBarOptions {
@@ -82,8 +83,11 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
};
statusPicker = createStatusPicker({
currentStatus: "online",
// Start from the stored selection, not a hardcoded "online" — otherwise
// this picker and the settings Account tab show different statuses.
currentStatus: loadUserStatus(),
onStatusChange: (status: UserStatus) => {
saveUserStatus(status);
const ws = options?.ws;
if (ws !== null && ws !== undefined && canSetStatus()) {
ws.send({ type: "presence_update", payload: { status } } as never);
@@ -92,6 +96,13 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
});
statusPicker.mount(statusPickerWrap);
// Reflect status changes made on the settings Account tab.
disposable.addCleanup(
onUserStatusChange((status) => statusPicker?.setStatus(status), {
signal: disposable.signal,
}),
);
// Disable picker (with a reason) when the connection is down
const updatePickerDisabled = (): void => {
const enabled = canSetStatus();
@@ -1,302 +0,0 @@
/**
* VoiceChannel component — renders a voice channel item with connected users.
* Returns an HTMLDivElement (not a MountableComponent).
* Step 6.51
*/
import { createElement, appendChildren, clearChildren, setText } from "@lib/dom";
import { createIcon } from "@lib/icons";
import { voiceStore } from "@stores/voice.store";
import type { VoiceUser } from "@stores/voice.store";
import { membersStore } from "@stores/members.store";
import { setUserVolume, getUserVolume } from "@lib/livekitSession";
import { authStore } from "@stores/auth.store";
import { attachStreamPreview, attachScrollCollapse } from "@lib/streamPreview";
import { SCREENSHARE_TILE_ID_OFFSET } from "@lib/constants";
export interface VoiceChannelOptions {
channelId: number;
channelName: string;
onJoin(): void;
onClickWatch?(tileId: number): void;
}
export interface VoiceChannelResult {
element: HTMLDivElement;
update(): void;
destroy(): void;
}
const AVATAR_COLORS = ["#5865f2", "#57f287", "#fee75c", "#eb459e", "#ed4245"];
function pickAvatarColor(username: string): string {
let hash = 0;
for (let i = 0; i < username.length; i++) {
hash = (hash * 31 + username.charCodeAt(i)) | 0;
}
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length] ?? "#5865f2";
}
export function createVoiceChannel(options: VoiceChannelOptions): VoiceChannelResult {
const ac = new AbortController();
const unsubs: Array<() => void> = [];
// Wrapper div to hold the channel-item and voice-users-list as siblings
const root = createElement("div");
// Channel item row (same structure as text channels)
const channelItem = createElement("div", { class: "channel-item voice" });
const icon = createElement("span", { class: "ch-icon" });
icon.appendChild(createIcon("volume-2", 16));
const nameEl = createElement("span", { class: "ch-name" }, options.channelName);
appendChildren(channelItem, icon, nameEl);
// Users container
const usersContainer = createElement("div", { class: "voice-users-list" });
appendChildren(root, channelItem, usersContainer);
// BUG-104: Attach scroll collapse once (not per-update) to avoid listener accumulation.
attachScrollCollapse(usersContainer, ac.signal);
// Click to join
channelItem.addEventListener("click", options.onJoin, { signal: ac.signal });
// Track active context menu for cleanup
let activeCtxMenu: HTMLDivElement | null = null;
let menuDismissAc: AbortController | null = null;
function closeContextMenu(): void {
if (menuDismissAc !== null) {
menuDismissAc.abort();
menuDismissAc = null;
}
if (activeCtxMenu !== null) {
activeCtxMenu.remove();
activeCtxMenu = null;
}
}
function showVolumeMenu(userId: number, username: string, x: number, y: number): void {
closeContextMenu();
const menu = createElement("div", { class: "context-menu" });
// Header
const header = createElement(
"div",
{
class: "context-menu-item",
style: "font-weight:600;cursor:default;pointer-events:none",
},
username,
);
menu.appendChild(header);
const sep = createElement("div", { class: "context-menu-sep" });
menu.appendChild(sep);
// Volume label
const currentVol = getUserVolume(userId);
const volLabel = createElement(
"div",
{
class: "context-menu-item",
style: "font-size:12px;color:var(--text-muted);cursor:default;pointer-events:none",
},
`User Volume: ${currentVol}%`,
);
menu.appendChild(volLabel);
// Volume slider (0-200%, like Discord)
const sliderRow = createElement("div", {
style: "padding:4px 10px;display:flex;align-items:center;gap:8px",
});
const slider = createElement("input", {
type: "range",
class: "settings-slider",
min: "0",
max: "200",
value: String(currentVol),
style: "flex:1",
});
const valLabel = createElement(
"span",
{
class: "slider-val",
style: "min-width:40px;text-align:right;font-size:12px;color:var(--text-muted)",
},
`${currentVol}%`,
);
slider.addEventListener("input", () => {
const val = Number(slider.value);
setText(valLabel, `${val}%`);
setText(volLabel, `User Volume: ${val}%`);
setUserVolume(userId, val);
});
appendChildren(sliderRow, slider, valLabel);
menu.appendChild(sliderRow);
// Reset button
const resetBtn = createElement("div", { class: "context-menu-item" }, "Reset Volume");
resetBtn.addEventListener("click", () => {
setUserVolume(userId, 100);
slider.value = "100";
setText(valLabel, "100%");
setText(volLabel, "User Volume: 100%");
});
menu.appendChild(resetBtn);
// Position and show
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
document.body.appendChild(menu);
activeCtxMenu = menu;
// Close on click outside — uses AbortController so cleanup on destroy works
menuDismissAc = new AbortController();
const dismissSignal = menuDismissAc.signal;
setTimeout(() => {
if (dismissSignal.aborted) return;
document.addEventListener(
"mousedown",
(e: MouseEvent) => {
if (!menu.contains(e.target as Node)) {
closeContextMenu();
}
},
{ signal: dismissSignal },
);
}, 0);
}
function createUserRow(user: VoiceUser, username: string): HTMLDivElement {
const classes = user.speaking ? "voice-user-item speaking" : "voice-user-item";
const row = createElement("div", { class: classes });
const initial = username.length > 0 ? username.charAt(0).toUpperCase() : "?";
const color = pickAvatarColor(username);
const avatar = createElement("div", { class: "vu-avatar" }, initial);
avatar.style.background = color;
row.appendChild(avatar);
const name = createElement("span", { class: "vu-name" }, username);
row.appendChild(name);
if (user.camera) {
const cameraEl = createElement("span", { class: "vu-status" });
cameraEl.appendChild(createIcon("camera", 14));
row.appendChild(cameraEl);
}
if (user.muted || user.deafened) {
const mutedEl = createElement("span", { class: "vu-muted" });
mutedEl.appendChild(createIcon(user.deafened ? "headphones-off" : "mic-off", 14));
row.appendChild(mutedEl);
}
// Right-click for per-user volume (skip for own user)
const currentUser = authStore.getState().user;
if (currentUser === null || currentUser.id !== user.userId) {
row.addEventListener(
"contextmenu",
(e) => {
e.preventDefault();
e.stopPropagation();
showVolumeMenu(user.userId, username, e.clientX, e.clientY);
},
{ signal: ac.signal },
);
}
return row;
}
// Track previous Map reference to skip unnecessary re-renders
let prevChannelUsers: ReadonlyMap<number, VoiceUser> | undefined;
let prevMembers: ReadonlyMap<number, unknown> | undefined;
function update(): void {
const channelUsers = voiceStore.getState().voiceUsers.get(options.channelId);
const members = membersStore.getState().members;
// Skip re-render if neither the channel's user map nor members changed
if (channelUsers === prevChannelUsers && members === prevMembers) return;
prevChannelUsers = channelUsers;
prevMembers = members;
clearChildren(usersContainer);
if (channelUsers === undefined) {
channelItem.classList.remove("active");
return;
}
for (const user of channelUsers.values()) {
const member = members.get(user.userId);
const username = (member as { username?: string } | undefined)?.username ?? "Unknown";
const row = createUserRow(user, username);
usersContainer.appendChild(row);
// Attach stream preview for remote users with active video
const currentUser = authStore.getState().user;
if (
(currentUser === null || currentUser.id !== user.userId) &&
(user.camera || user.screenshare)
) {
const tileId = user.screenshare ? user.userId + SCREENSHARE_TILE_ID_OFFSET : user.userId;
attachStreamPreview(
row,
user.userId,
username,
user.screenshare,
user.camera,
ac.signal,
() => {
// Only join if not already in this channel
if (voiceStore.getState().currentChannelId !== options.channelId) {
options.onJoin();
}
if (options.onClickWatch !== undefined) options.onClickWatch(tileId);
},
options.onClickWatch !== undefined ? () => options.onClickWatch!(tileId) : undefined,
);
}
}
// Mark channel-item active if there are users
if (channelUsers.size > 0) {
channelItem.classList.add("active");
} else {
channelItem.classList.remove("active");
}
}
// Initial render and subscribe
update();
unsubs.push(
voiceStore.subscribeSelector(
(s) => s.voiceUsers,
() => update(),
),
);
unsubs.push(
membersStore.subscribeSelector(
(s) => s.members,
() => update(),
),
);
function destroy(): void {
closeContextMenu();
ac.abort();
for (const unsub of unsubs) {
unsub();
}
unsubs.length = 0;
}
return { element: root, update, destroy };
}
@@ -17,6 +17,15 @@ import { writeFile } from "@tauri-apps/plugin-fs";
import type { Attachment } from "@lib/types";
import { openImageLightbox } from "./media";
/** Cached value of the animateGifs preference. Invalidated on pref change
* (same pattern as roleColors in formatting.ts). */
let animateGifsPref = loadPref<boolean>("animateGifs", true);
window.addEventListener("owncord:pref-change", ((e: CustomEvent<{ key: string }>) => {
if (e.detail.key === "animateGifs") {
animateGifsPref = loadPref<boolean>("animateGifs", true);
}
}) as EventListener);
// -- Server host state --------------------------------------------------------
/** Module-level server host for resolving relative attachment URLs. */
@@ -336,7 +345,7 @@ export function renderAttachment(att: Attachment): HTMLDivElement {
"load",
() => {
clearReservation();
if (isGif) observeMedia(img, cached, wrap, !loadPref("animateGifs", true));
if (isGif) observeMedia(img, cached, wrap, !animateGifsPref);
},
{ once: true },
);
@@ -357,7 +366,7 @@ export function renderAttachment(att: Attachment): HTMLDivElement {
"load",
() => {
clearReservation();
if (isGif) observeMedia(img, dataUrl, wrap, !loadPref("animateGifs", true));
if (isGif) observeMedia(img, dataUrl, wrap, !animateGifsPref);
},
{ once: true },
);
@@ -13,16 +13,51 @@ export const GROUP_THRESHOLD_MS = 5 * 60 * 1000;
// -- Timestamp helpers --------------------------------------------------------
/** Memoized epoch millis per raw timestamp string. Timestamps are immutable,
* and buildVirtualItems re-parses each one several times per render — the
* memo removes thousands of Date constructions + regex runs. Bounded FIFO. */
const parsedTimestampCache = new Map<string, number>();
const PARSED_TIMESTAMP_CACHE_MAX = 2000;
/** Parse a timestamp string, appending 'Z' if no timezone info is present
* so that UTC timestamps from SQLite are correctly interpreted. */
export function parseTimestamp(raw: string): Date {
const cached = parsedTimestampCache.get(raw);
if (cached !== undefined) return new Date(cached);
// SQLite datetime('now') produces "2026-03-19 08:29:41" (UTC, no suffix).
// If there's no Z, +, or T with offset, treat as UTC by appending Z.
if (!raw.endsWith("Z") && !raw.includes("+") && !/T\d{2}:\d{2}:\d{2}[+-]/.test(raw)) {
return new Date(raw.replace(" ", "T") + "Z");
const date =
!raw.endsWith("Z") && !raw.includes("+") && !/T\d{2}:\d{2}:\d{2}[+-]/.test(raw)
? new Date(raw.replace(" ", "T") + "Z")
: new Date(raw);
const ms = date.getTime();
if (!Number.isNaN(ms)) {
if (parsedTimestampCache.size >= PARSED_TIMESTAMP_CACHE_MAX) {
// Evict oldest entry (first inserted key)
const firstKey = parsedTimestampCache.keys().next().value;
if (firstKey !== undefined) parsedTimestampCache.delete(firstKey);
}
return new Date(raw);
parsedTimestampCache.set(raw, ms);
}
return date;
}
// Cached formatters — Intl.DateTimeFormat construction is expensive and these
// run for every rendered message. Only the FORMATTER is cached, never a
// formatted string: "Today"/"Yesterday" flips at midnight, so strings are
// recomputed per call from the cached formatter.
const FULL_DATE_FORMAT = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
const CLOCK_TIME_FORMAT = new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
export function formatTime(iso: string): string {
const d = parseTimestamp(iso);
@@ -30,11 +65,7 @@ export function formatTime(iso: string): string {
}
export function formatFullDate(iso: string): string {
return parseTimestamp(iso).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
return FULL_DATE_FORMAT.format(parseTimestamp(iso));
}
/** Discord-style relative timestamp: "Today at 2:34 PM", "Yesterday at 2:34 PM",
@@ -43,11 +74,7 @@ export function formatMessageTimestamp(iso: string): string {
const date = parseTimestamp(iso);
const now = new Date();
const timeStr = date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
const timeStr = CLOCK_TIME_FORMAT.format(date);
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterdayStart = new Date(todayStart.getTime() - 86_400_000);
@@ -16,6 +16,30 @@ import { renderGenericLinkPreview } from "./embeds";
const log = createLogger("media");
// Cached embed/media preferences — read once and invalidated on pref change
// instead of hitting localStorage for every rendered message (same pattern as
// roleColors in formatting.ts and developerMode in renderers.ts).
let showEmbedsPref = loadPref<boolean>("showEmbeds", true);
let inlineMediaPref = loadPref<boolean>("inlineMedia", true);
let showLinkPreviewsPref = loadPref<boolean>("showLinkPreviews", true);
let animateGifsPref = loadPref<boolean>("animateGifs", true);
window.addEventListener("owncord:pref-change", ((e: CustomEvent<{ key: string }>) => {
switch (e.detail.key) {
case "showEmbeds":
showEmbedsPref = loadPref<boolean>("showEmbeds", true);
break;
case "inlineMedia":
inlineMediaPref = loadPref<boolean>("inlineMedia", true);
break;
case "showLinkPreviews":
showLinkPreviewsPref = loadPref<boolean>("showLinkPreviews", true);
break;
case "animateGifs":
animateGifsPref = loadPref<boolean>("animateGifs", true);
break;
}
}) as EventListener);
/**
* Cache of rendered image heights keyed by URL. When virtual scroll rebuilds
* DOM elements, new images use the cached height as min-height instead of the
@@ -255,7 +279,7 @@ export function renderInlineImage(url: string): HTMLDivElement {
img.addEventListener(
"load",
() => {
log.info("Image loaded", {
log.debug("Image loaded", {
url: url.slice(0, 80),
naturalW: img.naturalWidth,
naturalH: img.naturalHeight,
@@ -286,10 +310,7 @@ export function renderInlineImage(url: string): HTMLDivElement {
img.addEventListener(
"load",
() => {
log.debug("Calling observeMedia for GIF", { url: url.slice(0, 80) });
const startFrozen = !loadPref("animateGifs", true);
observeMedia(img, url, wrap, startFrozen);
log.debug("observeMedia complete", { startFrozen });
observeMedia(img, url, wrap, !animateGifsPref);
},
{ once: true },
);
@@ -483,14 +504,8 @@ export function extractUrls(content: string): string[] {
export function renderUrlEmbeds(content: string): DocumentFragment {
const fragment = document.createDocumentFragment();
const urls = extractUrls(content);
log.debug("renderUrlEmbeds", { urlCount: urls.length, urls });
const seen = new Set<string>();
// Read preferences once before the loop to avoid per-URL localStorage reads
const showEmbeds = loadPref("showEmbeds", true);
const inlineMedia = loadPref("inlineMedia", true);
const showLinkPreviews = loadPref("showLinkPreviews", true);
for (const url of urls) {
if (seen.has(url)) continue;
seen.add(url);
@@ -498,7 +513,7 @@ export function renderUrlEmbeds(content: string): DocumentFragment {
// YouTube embed
const ytId = extractYouTubeId(url);
if (ytId !== null) {
if (!showEmbeds) continue;
if (!showEmbedsPref) continue;
fragment.appendChild(renderYouTubeEmbed(ytId, url));
continue;
}
@@ -506,26 +521,18 @@ export function renderUrlEmbeds(content: string): DocumentFragment {
// Direct image/GIF URL — render inline
const isDirect = isDirectImageUrl(url);
const isSafe = isSafeUrl(url);
log.debug("URL classification", {
url: url.slice(0, 80),
isDirect,
isSafe,
isGif: isGifUrl(url),
});
if (isDirect && isSafe) {
if (!inlineMedia) continue;
if (!inlineMediaPref) continue;
fragment.appendChild(renderInlineImage(url));
continue;
}
// Generic URL preview (compact link card)
if (isSafe) {
if (!showLinkPreviews) continue;
log.debug("Falling through to generic link preview", { url: url.slice(0, 80) });
if (!showLinkPreviewsPref) continue;
fragment.appendChild(renderGenericLinkPreview(url));
}
}
log.debug("renderUrlEmbeds complete");
return fragment;
}
@@ -1,12 +1,15 @@
/**
* Message rendering barrel — re-exports all rendering helpers and contains
* the composite functions (renderMessage, renderDayDivider, renderReplyRef,
* renderSystemMessage) that orchestrate pieces from the split modules.
* Message rendering barrel — re-exports the rendering helpers consumers use
* and contains the composite functions (renderMessage, renderDayDivider,
* renderReplyRef, renderSystemMessage) that orchestrate pieces from the
* split modules.
*/
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import { loadPref } from "@components/settings/helpers";
import { loadPref } from "@lib/preferences";
import { canManageMessages } from "@lib/permissions";
import { showToast } from "@lib/toast";
import type { Message } from "@stores/messages.store";
import type { MessageListOptions } from "../MessageList";
@@ -18,11 +21,11 @@ window.addEventListener("owncord:pref-change", ((e: CustomEvent<{ key: string }>
}
}) as EventListener);
// -- Re-exports (preserve all existing public API) ----------------------------
// -- Re-exports (only the names consumers actually import; everything else is
// -- available directly from the split modules) -------------------------------
export {
GROUP_THRESHOLD_MS,
parseTimestamp,
formatTime,
formatFullDate,
formatMessageTimestamp,
@@ -33,42 +36,13 @@ export {
} from "./formatting";
export {
MENTION_REGEX,
CODE_BLOCK_REGEX,
INLINE_CODE_REGEX,
URL_REGEX,
renderInlineContent,
renderMentions,
renderMentionSegment,
renderMessageContent,
} from "./content-parser";
export {
extractYouTubeId,
renderYouTubeEmbed,
isDirectImageUrl,
renderInlineImage,
openImageLightbox,
extractUrls,
renderUrlEmbeds,
} from "./media";
export type { OgMeta } from "./embeds";
export { parseOgTags, renderGenericLinkPreview, applyOgMeta } from "./embeds";
export {
formatFileSize,
isImageMime,
isSafeUrl,
openCacheDb,
uint8ToBase64,
fetchImageAsDataUrl,
renderAttachment,
setServerHost,
resolveServerUrl,
} from "./attachments";
export { renderReactions } from "./reactions";
export { setServerHost } from "./attachments";
// -- Imports for composite functions ------------------------------------------
@@ -307,7 +281,8 @@ export function renderMessage(
actionsBar.appendChild(editBtn);
}
if (msg.user.id === opts.currentUserId) {
// Own message, or a moderator acting on someone else's.
if (msg.user.id === opts.currentUserId || canManageMessages()) {
const deleteBtn = createElement("button", {
"data-testid": `msg-delete-${msg.id}`,
"aria-label": "Delete",
@@ -328,9 +303,12 @@ export function renderMessage(
copyIdBtn.addEventListener(
"click",
() => {
void navigator.clipboard.writeText(String(msg.id)).catch(() => {
/* clipboard unavailable */
});
// No silent success: a copy with no feedback is indistinguishable
// from a clipboard that refused.
void navigator.clipboard.writeText(String(msg.id)).then(
() => showToast("Message ID copied", "success"),
() => showToast("Couldn't copy the message ID", "error"),
);
},
{ signal },
);
@@ -7,8 +7,8 @@
import { createElement, appendChildren, setText } from "@lib/dom";
import type { UserStatus } from "@lib/types";
import { authStore } from "@stores/auth.store";
import { loadUserStatus, saveUserStatus } from "@lib/userStatus";
import type { SettingsOverlayOptions } from "../SettingsOverlay";
import { loadPref, savePref } from "./helpers";
// ---------------------------------------------------------------------------
// Types
@@ -110,6 +110,11 @@ function buildPasswordSection(
const newVal = newPw.value;
const confirmVal = confirmPw.value;
pwError.style.color = "var(--red)";
if (oldVal.length === 0) {
setText(pwError, "Enter your current password.");
return;
}
if (newVal.length < 8) {
setText(pwError, "New password must be at least 8 characters.");
return;
@@ -119,6 +124,14 @@ function buildPasswordSection(
return;
}
setText(pwError, "");
// In-flight state: a second click would burn an attempt against the
// server's lockout counter with the same credentials.
pwBtn.disabled = true;
setText(pwBtn, "Changing...");
const finish = (): void => {
pwBtn.disabled = false;
setText(pwBtn, "Change Password");
};
void options
.onChangePassword(oldVal, newVal)
.then(() => {
@@ -133,9 +146,11 @@ function buildPasswordSection(
pwError.style.color = "var(--red)";
pwSuccessTimer = null;
}, 3000);
finish();
})
.catch((err: unknown) => {
setText(pwError, err instanceof Error ? err.message : "Failed to change password.");
finish();
});
},
{ signal },
@@ -273,24 +288,56 @@ function buildTotpConfirmArea(
const elements: HTMLElement[] = [qrLabel, qrUri];
if (result.backup_codes.length > 0) {
// These codes are shown exactly once — the confirm step replaces this view.
// Say so, and give a one-click way to keep them.
const backupLabel = createElement(
"div",
{
style: "color:var(--text-muted);font-size:13px;margin-bottom:8px",
style: "color:var(--yellow, #faa61a);font-size:13px;margin-bottom:8px;font-weight:600",
},
"Save these backup codes in a safe place:",
"Save these backup codes now — you won't see them again:",
);
const codesText = result.backup_codes.join("\n");
const backupList = createElement(
"code",
{
style:
"display:block;background:var(--bg-active);padding:8px 12px;border-radius:6px;" +
"font-family:monospace;font-size:12px;white-space:pre-wrap;margin-bottom:12px;" +
"font-family:monospace;font-size:12px;white-space:pre-wrap;margin-bottom:8px;" +
"color:var(--text-primary);user-select:all",
"data-testid": "totp-backup-codes",
},
result.backup_codes.join("\n"),
codesText,
);
elements.push(backupLabel, backupList);
const copyBtn = createElement(
"button",
{
class: "ac-btn",
style: "margin-bottom:12px",
"data-testid": "totp-copy-backup-codes",
},
"Copy Codes",
);
let copyResetTimer: ReturnType<typeof setTimeout> | null = null;
copyBtn.addEventListener(
"click",
() => {
const restore = (label: string): void => {
setText(copyBtn, label);
if (copyResetTimer !== null) clearTimeout(copyResetTimer);
copyResetTimer = setTimeout(() => {
setText(copyBtn, "Copy Codes");
copyResetTimer = null;
}, 1500);
};
void navigator.clipboard
.writeText(codesText)
.then(() => restore("Copied!"))
.catch(() => restore("Copy failed"));
},
{ signal },
);
elements.push(backupLabel, backupList, copyBtn);
}
const codeInput = createElement("input", {
@@ -546,7 +593,7 @@ function buildStatusSelector(options: SettingsOverlayOptions, signal: AbortSigna
const sectionTitle = createElement("div", { class: "settings-section-title" }, "Status");
const optionsList = createElement("div", { class: "settings-status-options" });
const currentStatus = loadPref<UserStatus>("userStatus", "online");
const currentStatus = loadUserStatus();
const rowElements = new Map<UserStatus, HTMLDivElement>();
for (const opt of STATUS_OPTIONS) {
@@ -578,7 +625,7 @@ function buildStatusSelector(options: SettingsOverlayOptions, signal: AbortSigna
}
row.classList.add("active");
row.setAttribute("aria-pressed", "true");
savePref("userStatus", opt.value);
saveUserStatus(opt.value);
options.onStatusChange(opt.value);
};
@@ -22,6 +22,11 @@ export function buildAdvancedTab(signal: AbortSignal): HTMLDivElement {
// ---- Toggles ---------------------------------------------------------------
// NOTE: a "Hardware Acceleration" toggle used to sit here. Nothing read the
// preference it wrote — GPU compositing is decided by the webview before any
// JavaScript runs — so it was a switch that did nothing. Re-adding it means
// persisting the choice where the Rust startup path can read it before the
// webview is created; until then the panel doesn't claim the capability.
const toggles: ReadonlyArray<{ key: string; label: string; desc: string; fallback: boolean }> = [
{
key: "developerMode",
@@ -29,12 +34,6 @@ export function buildAdvancedTab(signal: AbortSignal): HTMLDivElement {
desc: "Show message IDs, user IDs, and channel IDs on context menus",
fallback: false,
},
{
key: "hardwareAcceleration",
label: "Hardware Acceleration",
desc: "Use GPU for rendering. Requires restart to take effect",
fallback: true,
},
];
for (const item of toggles) {
@@ -54,7 +54,13 @@ export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement {
}
btn.classList.add("active");
btn.setAttribute("aria-checked", "true");
if (!hasStoredAccent) {
if (hasStoredAccent) {
// applyThemeByName clears every inline custom property on <body>,
// which includes the accent override applyAccent puts there. Without
// re-applying it, a theme that sets --accent on its body class
// (neon-glow) silently reverts the user's accent until restart.
applyAccent(loadPref<string>("accentColor", getDefaultAccent(name)));
} else {
syncDisplayedAccent(getDefaultAccent(name));
}
};
@@ -111,8 +111,8 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement {
const navBinds: [string, string][] = [
["Quick Switcher", "Ctrl + K"],
["Mark as Read", "Escape"],
["Search Messages", "Ctrl + F"],
["Close Overlay / Cancel", "Escape"],
];
for (const [label, shortcut] of navBinds) {
const row = createElement("div", { class: "keybind-row" });
@@ -151,6 +151,16 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement {
section.appendChild(row);
}
section.appendChild(
createElement(
"div",
{
style: "font-size: 11px; color: var(--text-micro); margin: 4px 0 0 0; line-height: 1.4;",
},
"Voice shortcuts apply while you are connected to a voice channel.",
),
);
// ── Messages section ───────────────────────────────────────
section.appendChild(createElement("div", { class: "settings-separator" }));
@@ -3,11 +3,17 @@
*/
import { createElement, appendChildren, clearChildren } from "@lib/dom";
import { getLogBuffer, clearLogBuffer, addLogListener, setLogLevel } from "@lib/logger";
import {
getLogBuffer,
clearLogBuffer,
addLogListener,
setLogLevel,
getLogLevel,
} from "@lib/logger";
import type { LogEntry, LogLevel } from "@lib/logger";
import type { TabName } from "../SettingsOverlay";
import { getSessionDebugInfo } from "@lib/livekitSession";
import { savePref } from "./helpers";
import { savePref, readMigratedStringPref } from "./helpers";
// ---------------------------------------------------------------------------
// Constants
@@ -61,40 +67,6 @@ function formatLogEntry(entry: LogEntry): HTMLDivElement {
return row;
}
function readMigratedStringPref<T extends string>(
key: string,
fallback: T,
allowedValues: readonly T[],
): T {
const currentRaw = localStorage.getItem(`owncord:settings:${key}`);
if (currentRaw !== null) {
try {
const currentValue: unknown = JSON.parse(currentRaw);
if (typeof currentValue === "string" && allowedValues.includes(currentValue as T)) {
return currentValue as T;
}
} catch {
// Ignore corrupted current storage and fall back below.
}
}
const legacyRaw = localStorage.getItem(key);
if (legacyRaw !== null) {
let legacyValue: unknown = legacyRaw;
try {
legacyValue = JSON.parse(legacyRaw);
} catch {
// Legacy values were previously stored as raw strings.
}
if (typeof legacyValue === "string" && allowedValues.includes(legacyValue as T)) {
savePref(key, legacyValue);
return legacyValue as T;
}
}
return fallback;
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
@@ -201,6 +173,12 @@ export function createLogsTab(getActiveTab: () => TabName, signal: AbortSignal):
if (savedMinLevel !== "") {
levelSelect.value = savedMinLevel;
setLogLevel(savedMinLevel);
} else {
// No saved pref: reflect the actual effective runtime level (the
// applyStoredLogLevel fallback — info in prod, debug in dev) instead of
// leaving the select on its first option (DEBUG). Purely cosmetic — no
// save/apply, so the runtime level is unchanged.
levelSelect.value = getLogLevel();
}
levelSelect.addEventListener(
"change",
@@ -332,46 +332,42 @@ function buildVoiceAudioTabInner(
previewWrap.appendChild(previewVideo);
section.appendChild(previewWrap);
// Populate devices asynchronously
void (async () => {
/**
* (Re)fill the three device dropdowns from the current device list.
*
* Called on build and again on every `devicechange`, so unplugging a headset
* with the panel open removes it from the list instead of leaving a dead
* entry the user can select. A saved device that has vanished falls back to
* "Default" — the same thing the voice session does on hot-swap.
*/
async function populateDevices(): Promise<void> {
const selects: Array<[HTMLSelectElement, MediaDeviceKind, string, string]> = [
[inputSelect, "audioinput", "audioInputDevice", "Microphone"],
[outputSelect, "audiooutput", "audioOutputDevice", "Speaker"],
[videoSelect, "videoinput", "videoInputDevice", "Camera"],
];
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const savedInput = loadPref<string>("audioInputDevice", "");
const savedOutput = loadPref<string>("audioOutputDevice", "");
const savedVideo = loadPref<string>("videoInputDevice", "");
if (signal.aborted) return;
for (const [select, kind, prefKey, label] of selects) {
const saved = loadPref<string>(prefKey, "");
// Keep the leading "Default" option, replace the rest.
while (select.options.length > 1) select.remove(1);
let savedStillPresent = false;
for (const d of devices) {
if (d.kind === "audioinput") {
const opt = createElement(
if (d.kind !== kind) continue;
if (d.deviceId === saved) savedStillPresent = true;
select.appendChild(
createElement(
"option",
{ value: d.deviceId },
d.label || `Microphone (${d.deviceId.slice(0, 8)})`,
d.label || `${label} (${d.deviceId.slice(0, 8)})`,
),
);
if (d.deviceId === savedInput) opt.setAttribute("selected", "");
inputSelect.appendChild(opt);
} else if (d.kind === "audiooutput") {
const opt = createElement(
"option",
{ value: d.deviceId },
d.label || `Speaker (${d.deviceId.slice(0, 8)})`,
);
if (d.deviceId === savedOutput) opt.setAttribute("selected", "");
outputSelect.appendChild(opt);
} else if (d.kind === "videoinput") {
const opt = createElement(
"option",
{ value: d.deviceId },
d.label || `Camera (${d.deviceId.slice(0, 8)})`,
);
if (d.deviceId === savedVideo) opt.setAttribute("selected", "");
videoSelect.appendChild(opt);
}
select.value = saved !== "" && savedStillPresent ? saved : "";
}
// Restore saved selections
if (savedInput) inputSelect.value = savedInput;
if (savedOutput) outputSelect.value = savedOutput;
if (savedVideo) videoSelect.value = savedVideo;
} catch {
const errOpt = createElement(
"option",
@@ -380,7 +376,22 @@ function buildVoiceAudioTabInner(
);
inputSelect.appendChild(errOpt);
}
})();
}
void populateDevices();
// MediaDevices is an EventTarget everywhere this ships, but a webview that
// exposes enumerateDevices without the event target shouldn't take the tab
// down with it — it just loses live refresh.
if (typeof navigator.mediaDevices?.addEventListener === "function") {
navigator.mediaDevices.addEventListener(
"devicechange",
() => {
void populateDevices();
},
{ signal },
);
}
inputSelect.addEventListener(
"change",
@@ -5,12 +5,16 @@
import { createElement } from "@lib/dom";
import { applyThemeByName } from "@lib/themes";
// Preference persistence lives in `@lib/preferences` so `lib/` modules can use
// it without importing from the component layer. Re-exported here so the
// settings tabs keep a single import site — and, critically, so both layers
// share one implementation (they used to be copy-pasted and had drifted).
export { STORAGE_PREFIX, loadPref, savePref, readMigratedStringPref } from "@lib/preferences";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export const STORAGE_PREFIX = "owncord:settings:";
export const THEMES = {
dark: {
"--bg-primary": "#313338",
@@ -40,31 +44,6 @@ export const THEMES = {
export type ThemeName = keyof typeof THEMES;
// ---------------------------------------------------------------------------
// Preference helpers
// ---------------------------------------------------------------------------
export function loadPref<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(STORAGE_PREFIX + key);
if (raw === null) return fallback;
const parsed: unknown = JSON.parse(raw);
// Basic typeof guard against corrupted localStorage (covers boolean,
// number, string fallbacks used by current call sites).
if (typeof parsed !== typeof fallback) return fallback;
return parsed as T;
} catch {
return fallback;
}
}
export function savePref(key: string, value: unknown): void {
localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(value));
// Dispatch a custom event so same-window listeners can invalidate caches.
// The native `storage` event only fires for cross-tab changes.
window.dispatchEvent(new CustomEvent("owncord:pref-change", { detail: { key } }));
}
// ---------------------------------------------------------------------------
// Accessible toggle creation
// ---------------------------------------------------------------------------
+56
View File
@@ -0,0 +1,56 @@
/**
* Stored appearance preferences — applied at app startup.
*
* Extracted from SettingsOverlay so the startup path (main.ts, ConnectPage)
* can apply the stored theme/font/compact prefs without pulling in the full
* settings overlay (whose tabs statically import the LiveKit stack).
*/
import { loadPref, applyTheme, THEMES } from "@components/settings/helpers";
import type { ThemeName } from "@components/settings/helpers";
import { getActiveThemeName, restoreTheme } from "@lib/themes";
import { syncOsMotionListener } from "@lib/os-motion";
/**
* Apply stored appearance preferences (theme, font size, compact mode).
* Call at app startup so the UI doesn't flash default styles.
*/
export function applyStoredAppearance(): void {
const activeThemeName = getActiveThemeName();
if (activeThemeName in THEMES) {
applyTheme(activeThemeName as ThemeName);
} else {
restoreTheme();
}
try {
const rawAccent = localStorage.getItem("owncord:settings:accentColor");
if (rawAccent !== null) {
const accent = JSON.parse(rawAccent);
if (typeof accent === "string" && /^#[\da-fA-F]{3,8}$/.test(accent)) {
document.documentElement.style.setProperty("--accent", accent);
document.body.style.setProperty("--accent", accent);
}
}
} catch {
// Corrupted localStorage — keep the theme default accent.
}
document.documentElement.style.setProperty(
"--font-size",
`${loadPref<number>("fontSize", 16)}px`,
);
document.documentElement.classList.toggle(
"compact-mode",
loadPref<boolean>("compactMode", false),
);
document.documentElement.classList.toggle(
"reduced-motion",
loadPref<boolean>("reducedMotion", false),
);
document.documentElement.classList.toggle(
"high-contrast",
loadPref<boolean>("highContrast", false),
);
document.documentElement.classList.toggle("large-font", loadPref<boolean>("largeFont", false));
syncOsMotionListener(loadPref<boolean>("syncOsMotion", false));
}
+14 -1
View File
@@ -146,7 +146,20 @@ export class DeviceManager {
}
async switchOutputDevice(deviceId: string): Promise<void> {
if (this.room !== null) await this.room.switchActiveDevice("audiooutput", deviceId);
if (this.room === null) {
log.debug("Skipping output device switch — no active voice session");
return;
}
// Mirrors switchInputDevice: switchActiveDevice rejects where setSinkId
// isn't available, and the settings tab fires this as a bare `void` call,
// so an unhandled rejection would leave the user staring at a selection
// that never took effect.
try {
await this.room.switchActiveDevice("audiooutput", deviceId);
log.info("Switched output device", { deviceId });
} catch (err) {
log.error("Failed to switch output device", err);
this.onErrorCallback?.("Failed to switch speaker");
}
}
}
+31 -15
View File
@@ -35,6 +35,7 @@ import {
setTyping,
} from "@stores/members.store";
import {
voiceStore,
setVoiceStates,
updateVoiceState,
removeVoiceUser,
@@ -55,13 +56,6 @@ import type { DmChannel } from "@stores/dm.store";
import { setBlockedByMe, setUserBlockedByThem, clearBlockedByThem } from "@stores/blocks.store";
import type { DmChannelPayload } from "./types";
import type { ApiClient } from "./api";
import {
handleVoiceToken,
handleE2EEAnnounce,
handleE2EEOffer,
handleParticipantLeft,
isVoiceConnected,
} from "@lib/livekitSession";
import { notifyIncomingMessage } from "./notifications";
import { ensureIdentityKeyPublished } from "@lib/identity";
import { createLogger } from "./logger";
@@ -69,6 +63,13 @@ import { ServerMessageType as S } from "./protocolTypes";
const log = createLogger("dispatcher");
/** Lazily import the LiveKit session module. livekit-client (~1.3 MB) is kept
* out of the entry chunk; voice handlers load it on first use. Once a voice
* flow has started the module is cached, so this resolves in a microtask. */
function livekitSession(): Promise<typeof import("@lib/livekitSession")> {
return import("@lib/livekitSession");
}
/** Map a server DM channel payload to the client DmChannel type. */
function mapDmPayload(p: DmChannelPayload): DmChannel {
return {
@@ -138,13 +139,20 @@ export function wireDispatcher(
setVoiceStates(payload.voice_states);
// Defense-in-depth: if the ready payload shows us in a voice channel
// but we have no LiveKit room connection (e.g. after F5 reload),
// send voice_leave to clean up the stale state. The server should
// have already cleaned this up, but this handles edge cases.
// but we have no LiveKit session (e.g. after F5 reload), send
// voice_leave to clean up the stale state. The server should have
// already cleaned this up, but this handles edge cases.
//
// livekitSession is lazily imported, so instead of the synchronous
// isVoiceConnected() the check reads the voice store's lifecycle
// status: "idle" means no live or pending LiveKit session (a fresh
// reload always starts idle — exactly the stale case), while any other
// status means livekitSession is driving a session right now.
const currentUserId = authStore.getState().user?.id ?? 0;
const inVoicePerReady =
currentUserId !== 0 && payload.voice_states.some((vs) => vs.user_id === currentUserId);
if (inVoicePerReady && !isVoiceConnected()) {
const voiceSessionActive = voiceStore.getState().voiceStatus !== "idle";
if (inVoicePerReady && !voiceSessionActive) {
log.warn("Stale voice state detected in ready payload — sending voice_leave");
ws.send({ type: "voice_leave", payload: {} });
leaveVoiceChannel();
@@ -413,7 +421,9 @@ export function wireDispatcher(
ws.on(S.VOICE_LEAVE, (payload) => {
removeVoiceUser(payload);
// Notify E2EE state machine so key holder can rotate the room key.
void handleParticipantLeft(payload.user_id);
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) {
@@ -436,12 +446,14 @@ export function wireDispatcher(
unsubs.push(
ws.on(S.VOICE_TOKEN, (payload) => {
void handleVoiceToken(
void livekitSession().then(({ handleVoiceToken }) =>
handleVoiceToken(
payload.token,
payload.url,
payload.channel_id,
payload.direct_url,
payload.is_key_holder,
),
);
}),
);
@@ -450,13 +462,17 @@ export function wireDispatcher(
unsubs.push(
ws.on(S.VOICE_E2EE_ANNOUNCE, (payload) => {
void handleE2EEAnnounce(payload.user_id, payload.public_key, payload.signature);
void livekitSession().then(({ handleE2EEAnnounce }) =>
handleE2EEAnnounce(payload.user_id, payload.public_key, payload.signature),
);
}),
);
unsubs.push(
ws.on(S.VOICE_E2EE_OFFER, (payload) => {
void handleE2EEOffer(payload.from_user_id, payload.encrypted_key, payload.iv);
void livekitSession().then(({ handleE2EEOffer }) =>
handleE2EEOffer(payload.from_user_id, payload.encrypted_key, payload.iv),
);
}),
);
+7 -4
View File
@@ -177,10 +177,13 @@ async function loadOrGenerateIdentityKeyPair(host: string): Promise<CryptoKeyPai
const keyPair = await generateIdentityKeyPair();
const blob = await exportIdentityKeyPair(keyPair.privateKey);
if (await saveIdentityKey(host, blob)) {
// The store reported success — verify it actually kept the value. Windows
// can accept a CredWrite and persist nothing (Credential Manager disabled,
// or the "do not allow storage of passwords and credentials" policy), which
// otherwise surfaces to the user only as peers flagging them as a MITM.
// 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) {
log.error(
"Identity key did not persist — the credential store accepted the write but did not return it. " +
+717
View File
@@ -0,0 +1,717 @@
// LiveKit E2EE manager — client-side ECDH key exchange extracted from livekitSession.ts.
// Owns the E2EE state (ephemeral keypair, room key, peer keys, rotation timers)
// and the key-exchange protocol: identity signing / TOFU pin verification (F3),
// announce/offer handling, and room-key generation/rotation.
import { ExternalE2EEKeyProvider } from "livekit-client";
import type { WsClient } from "@lib/ws";
import {
generateECDHKeyPair,
exportPublicKey,
importPublicKey,
generateRoomKey,
roomKeyToBase64,
wrapRoomKey,
unwrapRoomKey,
signEphemeralKey,
verifyEphemeralKeySignature,
importIdentityPublicKey,
computeKeyFingerprint,
} from "@lib/e2eeCrypto";
import { getOrCreateIdentityKeyPair, getIdentityPin, storeIdentityPin } from "@lib/identity";
import { authStore } from "@stores/auth.store";
import { membersStore } from "@stores/members.store";
import {
voiceStore,
setPeerVerification,
clearPeerVerification,
clearPeerVerifications,
} from "@stores/voice.store";
import { createLogger } from "@lib/logger";
const log = createLogger("livekitE2EE");
// --- Dependencies passed from LiveKitSession ---
export interface E2EEDeps {
getWs: () => WsClient | null;
getServerHost: () => string | null;
getCurrentChannelId: () => number | null;
}
// --- E2EEManager class ---
export class E2EEManager {
/** E2EE key provider — shared across Room instances. The room key is generated
* and exchanged client-side via ECDH; the server never sees it. */
readonly keyProvider = new ExternalE2EEKeyProvider();
// ── Client-side E2EE state (ECDH key exchange) ───────────────────────────
/** Ephemeral ECDH P-256 keypair for the current voice session. */
private _ecdhKeyPair: CryptoKeyPair | null = null;
/** The 256-bit symmetric room key (plaintext). Only held by the key holder
* initially; other participants receive it via ECDH-wrapped offers. */
private _roomKey: Uint8Array | null = null;
/** Peer ECDH public keys indexed by userId. */
private _peerPublicKeys: Map<number, CryptoKey> = new Map();
/** This client's long-term ECDSA identity keypair (F3 TOFU), used to sign our
* ephemeral announces. Loaded lazily from the OS keyring, cached per session. */
private _identityKeyPair: CryptoKeyPair | null = null;
/** True if this client is the key holder (longest-present participant). */
private _isKeyHolder = false;
/** 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;
/** Guard: true while a key rotation is in progress (prevents concurrent rotations). */
private _rotatingKey = false;
/** Set when a keyed-peer leave coincides with an in-flight rotation: the rekey
* is deferred (not dropped) and re-run when the current rotation finishes, so
* a member that left mid-rotation is excluded from the fresh room key. */
private _rotationPending = false;
/** Monotonic counter incremented on every key rotation. handleOffer captures the
* epoch before async work and discards the result if epoch changed (stale offer). */
private _e2eeEpoch = 0;
/** Announces that arrived before our ECDH keypair was ready. Drained after keypair init. */
private _pendingAnnounces: Array<{
userId: number;
publicKeyBase64: string;
signatureBase64?: string;
}> = [];
/** Periodic key rotation timer — fires every KEY_ROTATION_INTERVAL_MS when key holder. */
private _keyRotationTimer: ReturnType<typeof setTimeout> | null = null;
/** Interval between periodic key rotations (5 minutes). */
private static readonly KEY_ROTATION_INTERVAL_MS = 5 * 60 * 1000;
constructor(private deps: E2EEDeps) {}
// --- Internal state accessors (used by LiveKitSession's test-compat proxies) ---
get peerPublicKeys(): Map<number, CryptoKey> {
return this._peerPublicKeys;
}
get epoch(): number {
return this._e2eeEpoch;
}
get rotatingKey(): boolean {
return this._rotatingKey;
}
set rotatingKey(value: boolean) {
this._rotatingKey = value;
}
get rotationPending(): boolean {
return this._rotationPending;
}
set rotationPending(value: boolean) {
this._rotationPending = value;
}
get pendingAnnounces(): Array<{
userId: number;
publicKeyBase64: string;
signatureBase64?: string;
}> {
return this._pendingAnnounces;
}
// ── Join-time key exchange ───────────────────────────────────────────────
/**
* Run the client-side E2EE key exchange for a join (called from
* connectAndSetup before room.connect). Generates a fresh ECDH keypair,
* drains queued announces, then either generates the room key (key holder)
* or announces and waits for the key holder's offer.
*
* Returns false when the key exchange timed out after retry — the caller
* 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();
this._peerPublicKeys.clear();
clearPeerVerifications();
const myPubKeyBase64 = await exportPublicKey(this._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;
// Drain any announces that arrived before our keypair was ready. These
// are existing participants whose keys the server relayed during
// voice_join sync — run them through the normal verifying receive path
// so a server-substituted peer key is caught here too.
const queued = this._pendingAnnounces.splice(0);
for (const { userId: qId, publicKeyBase64: qKey, signatureBase64: qSig } of queued) {
// oxlint-disable-next-line no-await-in-loop -- sequential drain: verify each queued announce
await this.handleAnnounce(qId, qKey, qSig);
log.info("E2EE: drained queued announce", { userId: qId });
}
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 {
// Wait for the key holder to send us the room key via voice_e2ee_offer.
// This promise resolves when handleOffer() sets _roomKey.
log.info("E2EE: waiting for room key from key holder", { channelId });
const roomKeyPromise = new Promise<void>((resolve, reject) => {
this._roomKeyResolver = resolve;
this._roomKeyRejector = reject;
});
// Announce BEFORE waiting (moved earlier per F3) so the key holder can
// offer immediately. The resolver is set above, so an immediate offer
// won't be missed.
this.deps.getWs()?.send({ type: "voice_e2ee_announce", payload: announcePayload });
// Wait up to 10s for the key holder to send an offer. If the first
// attempt times out, re-announce our public key (the offer may have been
// lost if the key holder disconnected mid-send) and wait 5s more.
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const makeTimeout = (ms: number) =>
new Promise<void>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error("E2EE key exchange timeout")), ms);
});
try {
await Promise.race([roomKeyPromise, makeTimeout(10_000)]);
} catch {
// First attempt timed out — re-announce and retry once.
if (timeoutId !== null) clearTimeout(timeoutId);
log.warn("E2EE: first key exchange attempt timed out, re-announcing", { channelId });
this.deps.getWs()?.send({ type: "voice_e2ee_announce", payload: announcePayload });
try {
await Promise.race([roomKeyPromise, makeTimeout(5_000)]);
} catch {
log.error("E2EE: key exchange timed out after retry — disconnecting", { channelId });
this._roomKeyResolver = null;
this._roomKeyRejector = null;
if (timeoutId !== null) clearTimeout(timeoutId);
return false;
}
} finally {
if (timeoutId !== null) clearTimeout(timeoutId);
}
this._roomKeyResolver = null;
this._roomKeyRejector = null;
}
return true;
}
/**
* E2EE re-setup for auto-reconnect: regenerate the ECDH keypair for the new
* session (forward secrecy) and re-announce so other participants can re-wrap
* the room key for us. If we still have the room key from before disconnect,
* re-apply it now so audio works immediately; the key holder will send a
* fresh offer if the key was rotated during our absence.
*/
async reannounceForReconnect(): Promise<void> {
this._ecdhKeyPair = await generateECDHKeyPair();
this._peerPublicKeys.clear();
clearPeerVerifications();
if (this._roomKey) {
await this.keyProvider.setKey(roomKeyToBase64(this._roomKey));
}
const reconnectPubKey = await exportPublicKey(this._ecdhKeyPair.publicKey);
const reconnectAnnounce = await this.buildAnnouncePayload(reconnectPubKey);
this.deps.getWs()?.send({ type: "voice_e2ee_announce", payload: reconnectAnnounce });
}
// ── Identity signing (F3 TOFU) ──────────────────────────────────────────
/** Decode a base64 raw-key string to bytes for sign/verify. Throws on bad
* input (callers verifying a peer key already run inside try/catch). */
private rawFromBase64(base64: string): Uint8Array {
return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
}
/** 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. */
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);
return this._identityKeyPair;
}
/** Identity keys are host-scoped — the session drops the cached keypair when
* the host changes (and on cleanupAll) so we never sign an announce with
* another host's identity key. */
clearIdentityKeyPair(): void {
this._identityKeyPair = null;
}
/** Build the voice_e2ee_announce payload, signing the ephemeral public key
* with our identity key (F3). Signing failures degrade to an unsigned
* announce rather than blocking the join. */
private async buildAnnouncePayload(
ephemeralPubBase64: string,
): Promise<{ public_key: string; signature?: string }> {
try {
const idKeyPair = await this.ensureIdentityKeyPair();
if (idKeyPair) {
const myUserId = authStore.getState().user?.id ?? 0;
const ephemeralRaw = this.rawFromBase64(ephemeralPubBase64);
const signature = await signEphemeralKey(idKeyPair.privateKey, myUserId, ephemeralRaw);
return { public_key: ephemeralPubBase64, signature };
}
} catch (err) {
log.error("E2EE: failed to sign announce — sending unsigned", err);
}
return { public_key: ephemeralPubBase64 };
}
/**
* F3 TOFU: resolve a peer's identity key and verify their ephemeral-announce
* signature. Pins the identity key on first sight; on a later change it emits
* an identity-tofu "mismatch" (via the voice store) and blocks the peer until
* the user re-pins. Returns true when the announce may be accepted (verified,
* or a legacy peer with no identity key), false to reject/block. The store
* write is the surfaced verification state the voice panel reads.
*
* Compatibility posture (transition):
* - peer HAS a published identity key, signature missing/invalid → reject
* (fail closed);
* - peer has NO identity key (legacy client) → accept, mark unverified
* (pin-pending).
*/
private async verifyPeerAnnounce(
userId: number,
publicKeyBase64: string,
signatureBase64?: string,
): Promise<boolean> {
const publishedIdentity =
membersStore.getState().members.get(userId)?.identityPublicKey ?? null;
const host = this.deps.getServerHost();
// 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;
// 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 });
log.error("E2EE: pinned peer identity key missing/changed — blocking (identity-tofu)", {
userId,
});
return false;
}
// Genuine legacy peer: never pinned AND no published identity key — accept
// but mark unverified (pin-pending). This is the only case the compatibility
// posture keeps open.
if (!publishedIdentity) {
setPeerVerification({ userId, status: "unverified", safetyNumber: null });
log.warn("E2EE: peer has no identity key — accepting as unverified (legacy)", { userId });
return true;
}
// Verify the ephemeral-key signature against the trusted identity key
// (the pin when we have one, else the first-sight published key).
const anchorBase64 = pin ?? publishedIdentity;
const identityKey = await importIdentityPublicKey(anchorBase64);
const ephemeralRaw = this.rawFromBase64(publicKeyBase64);
const ok = signatureBase64
? await verifyEphemeralKeySignature(identityKey, userId, ephemeralRaw, signatureBase64)
: false;
if (!ok) {
// Fail closed: peer has an identity key but no valid signature (MITM).
setPeerVerification({ 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.
if (pin === null && host) {
await storeIdentityPin(host, String(userId), publishedIdentity);
log.info("E2EE: pinned peer identity key on first sight", { userId });
}
const safetyNumber = await computeKeyFingerprint(identityKey);
setPeerVerification({ userId, status: "verified", safetyNumber });
return true;
}
/**
* F3 TOFU re-pin recovery (finding #4). Pin the EXACT identity key
* `verifiedKey` — the bytes whose fingerprint the caller displayed and the
* user confirmed out-of-band — overwriting the stored pin for {host,userId}
* and clearing the mismatch block (the identity-key analogue of accepting a
* changed TLS cert). A legitimate key rotation (reinstall / new device /
* wiped keyring) is thus recoverable instead of a permanent lockout; the next
* announce re-verifies against the new pin.
*
* The verified key MUST be passed in, never re-read from membersStore here:
* the store is server-writable (a `user_update` mutates it), so re-reading it
* would let a malicious server swap in an attacker key during the human
* out-of-band verification window and have us pin THAT — a TOCTOU that
* silently defeats the mismatch prompt. Returns false when there is no host
* or no key to pin.
*/
async rePinPeerIdentity(userId: number, verifiedKey: string): Promise<boolean> {
const host = this.deps.getServerHost();
if (!host || !verifiedKey) {
log.warn("E2EE: cannot re-pin peer without a host and the verified identity key", { userId });
return false;
}
await storeIdentityPin(host, String(userId), verifiedKey);
clearPeerVerification(userId);
log.info("E2EE: re-pinned peer identity key (TOFU recovery)", { userId });
return true;
}
// ── Client-side E2EE handlers (ECDH key exchange) ───────────────────────
/**
* Handle a voice_e2ee_announce from the server — another participant has
* announced their ECDH public key. 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(
userId: number,
publicKeyBase64: string,
signatureBase64?: string,
): Promise<void> {
// Queue if our keypair isn't ready yet (announce arrived during connectAndSetup).
if (!this._ecdhKeyPair) {
this._pendingAnnounces.push({ userId, publicKeyBase64, signatureBase64 });
log.info("E2EE: queued announce (keypair not ready)", { userId });
return;
}
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))) {
return; // rejected/blocked — do not store or wrap
}
// 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).
const existingKey = this._peerPublicKeys.get(userId);
let peerKey: CryptoKey;
let isDuplicate = false;
if (existingKey) {
const existingB64 = await exportPublicKey(existingKey);
if (existingB64 === publicKeyBase64) {
peerKey = existingKey;
isDuplicate = true;
log.debug("E2EE: duplicate announce — will re-send offer if key holder", { userId });
} else {
peerKey = await importPublicKey(publicKeyBase64);
log.warn("E2EE: peer public key changed (reconnect?)", { userId });
}
} else {
peerKey = await importPublicKey(publicKeyBase64);
}
if (!isDuplicate) {
this._peerPublicKeys.set(userId, peerKey);
log.info("E2EE: received peer public key", { userId });
}
// If we're the key holder and have a room key, wrap it for the new peer.
// Capture keypair + roomKey before async work to avoid null dereference if
// clearState() runs concurrently.
const keypair = this._ecdhKeyPair;
const currentRoomKey = this._roomKey;
if (this._isKeyHolder && currentRoomKey && keypair) {
const { encryptedKey, iv } = await wrapRoomKey(keypair.privateKey, peerKey, currentRoomKey);
this.deps.getWs()?.send({
type: "voice_e2ee_offer",
payload: { target_user_id: userId, encrypted_key: encryptedKey, iv },
});
log.info("E2EE: sent room key offer to peer", { userId });
}
} catch (err) {
log.error("E2EE: failed to handle announce", err);
}
}
/**
* 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.
*/
async handleOffer(
fromUserId: number,
encryptedKeyBase64: string,
ivBase64: string,
): Promise<void> {
try {
const peerKey = this._peerPublicKeys.get(fromUserId);
if (!peerKey) {
log.warn("E2EE: received offer from unknown peer", { fromUserId });
return;
}
const keypair = this._ecdhKeyPair;
if (!keypair) {
log.warn("E2EE: received offer but no ECDH keypair");
return;
}
// Capture epoch before async work — if a key rotation occurs during
// unwrap, the epoch will have advanced and we discard this stale result.
const epochBefore = this._e2eeEpoch;
const unwrapped = await unwrapRoomKey(
keypair.privateKey,
peerKey,
encryptedKeyBase64,
ivBase64,
);
if (this._e2eeEpoch !== epochBefore) {
log.info("E2EE: discarding stale offer (epoch changed during unwrap)", {
fromUserId,
epochBefore,
epochNow: this._e2eeEpoch,
});
return;
}
this._roomKey = unwrapped;
await this.keyProvider.setKey(roomKeyToBase64(this._roomKey));
log.info("E2EE: room key received and applied", { fromUserId });
// Resolve the pending connect promise if we were waiting for the key.
if (this._roomKeyResolver) {
this._roomKeyResolver();
this._roomKeyResolver = null;
this._roomKeyRejector = null;
}
} catch (err) {
log.error("E2EE: failed to handle offer", err);
// Propagate decryption failure so the waiting setupKeyExchange unblocks.
if (this._roomKeyRejector) {
this._roomKeyRejector(err instanceof Error ? err : new Error(String(err)));
this._roomKeyResolver = null;
this._roomKeyRejector = null;
}
}
}
/**
* 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
* ALREADY the key holder and a peer that held the room key left, we also
* rotate — so the departed member's copy can no longer decrypt future audio
* against the untrusted SFU (membership forward secrecy).
*
* Key holder election: the participant with the lowest user ID among remaining
* participants is elected. This is deterministic and does not depend on Map
* insertion order (which is not guaranteed to match server join order).
*/
async handleParticipantLeft(userId: number): Promise<void> {
const hadPeerKey = this._peerPublicKeys.has(userId);
this._peerPublicKeys.delete(userId);
clearPeerVerification(userId);
const channelId = this.deps.getCurrentChannelId();
if (!channelId) return;
const state = voiceStore.getState();
const channelUsers = state.voiceUsers.get(channelId);
if (!channelUsers || channelUsers.size === 0) return;
// Elect key holder: lowest user_id among remaining participants.
let lowestUserId = Infinity;
for (const uid of channelUsers.keys()) {
if (uid < lowestUserId) lowestUserId = uid;
}
const wasKeyHolder = this._isKeyHolder;
const myUserId = authStore.getState().user?.id ?? 0;
if (myUserId !== 0 && lowestUserId === myUserId && !wasKeyHolder) {
// Prevent concurrent rotations (e.g. two participants leave in rapid succession).
if (this._rotatingKey) {
log.warn("E2EE: key rotation already in progress, skipping", { userId, channelId });
return;
}
this._rotatingKey = true;
this._isKeyHolder = true;
log.info("E2EE: became key holder after participant left", { userId, channelId });
// Rotate the room key — generate a new one and distribute to all remaining peers.
try {
this._e2eeEpoch++;
this._roomKey = generateRoomKey();
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.
const keypair = this._ecdhKeyPair;
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 },
});
}
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 });
}
}
}
}
} catch (err) {
log.error("E2EE: failed to rotate room key", err);
} finally {
this._rotatingKey = false;
}
// If a keyed peer left while this become-holder rotation was in flight, its
// rekey was deferred (not dropped) — run it now so the departed member is
// excluded from the fresh key; otherwise re-arm the periodic timer.
await this.drainPendingRotationOrArmTimer();
} else if (wasKeyHolder && hadPeerKey) {
// Membership forward secrecy: I remain the key holder and a peer that held
// the room key left, so rotate + redistribute to the CURRENT peer set
// (which already excludes the leaver, deleted above) — otherwise the
// departed member keeps a valid room key against the untrusted SFU until
// the next periodic rotation.
if (this._rotatingKey) {
// A rotation is already in flight and may already have sent the current
// key to this leaver before they left. Don't DROP the rekey (that would
// leave the departed member holding a live key) — defer it so it re-runs
// when the in-flight rotation completes, excluding them.
this._rotationPending = true;
} else {
await this.rotateKeyPeriodically();
}
}
}
// ── Periodic key rotation ──────────────────────────────────────────────────
/** Start the periodic key rotation timer (only meaningful for key holders). */
private startKeyRotationTimer(): void {
this.clearKeyRotationTimer();
if (!this._isKeyHolder) return;
this._keyRotationTimer = setTimeout(() => {
this._keyRotationTimer = null;
void this.rotateKeyPeriodically();
}, E2EEManager.KEY_ROTATION_INTERVAL_MS);
log.debug("E2EE: key rotation timer started", {
intervalMs: E2EEManager.KEY_ROTATION_INTERVAL_MS,
});
}
private clearKeyRotationTimer(): void {
if (this._keyRotationTimer !== null) {
clearTimeout(this._keyRotationTimer);
this._keyRotationTimer = null;
}
}
/** 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();
if (!channelId) return;
this._rotatingKey = true;
try {
this._e2eeEpoch++;
this._roomKey = generateRoomKey();
await this.keyProvider.setKey(roomKeyToBase64(this._roomKey));
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,
});
}
} catch (err) {
log.error("E2EE: periodic key rotation failed", err);
} finally {
this._rotatingKey = false;
}
// Re-arm the periodic timer, or run a rotation deferred by a keyed-peer leave
// that coincided with this one.
await this.drainPendingRotationOrArmTimer();
}
/** After a rotation completes: if a keyed-peer leave coincided with it (its
* rekey was deferred, not dropped), run one more rotation to exclude the
* departed member; otherwise re-arm the periodic rotation timer. */
private async drainPendingRotationOrArmTimer(): Promise<void> {
if (this._rotationPending) {
this._rotationPending = false;
await this.rotateKeyPeriodically();
return;
}
this.startKeyRotationTimer();
}
/** Clear all E2EE state (called on voice leave). The long-term identity
* keypair is intentionally NOT cleared here — it persists across calls to
* the same host (cleared only on host change / cleanupAll). */
clearState(): void {
this._ecdhKeyPair = null;
this._roomKey = null;
this._peerPublicKeys.clear();
clearPeerVerifications();
this._isKeyHolder = false;
this._rotatingKey = false;
this._rotationPending = false;
this._e2eeEpoch = 0;
this._pendingAnnounces.length = 0;
this.clearKeyRotationTimer();
// Reject (not resolve) so waiting setupKeyExchange sees a failure, not a
// silent success with no room key.
if (this._roomKeyRejector) {
this._roomKeyRejector(new Error("Voice session ended"));
}
this._roomKeyResolver = null;
this._roomKeyRejector = null;
}
}
+57 -602
View File
@@ -1,5 +1,5 @@
// LiveKit Session — lifecycle orchestrator for voice chat via LiveKit
import { Room, RoomEvent, ExternalE2EEKeyProvider } from "livekit-client";
import { Room, RoomEvent } from "livekit-client";
import type { WsClient } from "@lib/ws";
import {
voiceStore,
@@ -11,32 +11,12 @@ import {
setListenOnly,
setVoiceStatus,
} from "@stores/voice.store";
import { authStore } from "@stores/auth.store";
import { loadPref } from "@components/settings/helpers";
import { createLogger } from "@lib/logger";
import { invoke } from "@tauri-apps/api/core";
import { AudioPipeline } from "@lib/audioPipeline";
import { AudioElements } from "@lib/audioElements";
import {
generateECDHKeyPair,
exportPublicKey,
importPublicKey,
generateRoomKey,
roomKeyToBase64,
wrapRoomKey,
unwrapRoomKey,
signEphemeralKey,
verifyEphemeralKeySignature,
importIdentityPublicKey,
computeKeyFingerprint,
} from "@lib/e2eeCrypto";
import { getOrCreateIdentityKeyPair, getIdentityPin, storeIdentityPin } from "@lib/identity";
import { membersStore } from "@stores/members.store";
import {
setPeerVerification,
clearPeerVerification,
clearPeerVerifications,
} from "@stores/voice.store";
import { E2EEManager } from "@lib/livekitE2EE";
import { DeviceManager } from "@lib/deviceManager";
import {
type VideoTrackDeps,
@@ -147,45 +127,47 @@ export class LiveKitSession {
/** Cached port for the local LiveKit TLS proxy (Rust-side, for self-signed cert support). */
private liveKitProxyPort: number | null = null;
/** E2EE key provider — shared across Room instances. The room key is generated
* and exchanged client-side via ECDH; the server never sees it. */
private _e2eeKeyProvider = new ExternalE2EEKeyProvider();
// ── Client-side E2EE (ECDH key exchange) — extracted to E2EEManager ──────
/** Owns all E2EE state and the key-exchange protocol: ECDH keypair, room-key
* generation/rotation, identity signing / TOFU verification (F3), and the
* announce/offer handlers. See livekitE2EE.ts. */
private _e2ee = new E2EEManager({
getWs: () => this.ws,
getServerHost: () => this.serverHost,
getCurrentChannelId: () => this._currentChannelId,
});
// ── Client-side E2EE state (ECDH key exchange) ───────────────────────────
/** Ephemeral ECDH P-256 keypair for the current voice session. */
private _ecdhKeyPair: CryptoKeyPair | null = null;
/** The 256-bit symmetric room key (plaintext). Only held by the key holder
* initially; other participants receive it via ECDH-wrapped offers. */
private _roomKey: Uint8Array | null = null;
/** Peer ECDH public keys indexed by userId. */
private _peerPublicKeys: Map<number, CryptoKey> = new Map();
/** This client's long-term ECDSA identity keypair (F3 TOFU), used to sign our
* ephemeral announces. Loaded lazily from the OS keyring, cached per session. */
private _identityKeyPair: CryptoKeyPair | null = null;
/** True if this client is the key holder (longest-present participant). */
private _isKeyHolder = false;
/** 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;
/** Guard: true while a key rotation is in progress (prevents concurrent rotations). */
private _rotatingKey = false;
/** Set when a keyed-peer leave coincides with an in-flight rotation: the rekey
* is deferred (not dropped) and re-run when the current rotation finishes, so
* a member that left mid-rotation is excluded from the fresh room key. */
private _rotationPending = false;
/** Monotonic counter incremented on every key rotation. handleE2EEOffer captures the
* epoch before async work and discards the result if epoch changed (stale offer). */
private _e2eeEpoch = 0;
/** Announces that arrived before our ECDH keypair was ready. Drained after keypair init. */
private _pendingAnnounces: Array<{
// --- Test-visibility proxies (E2EE state lives in E2EEManager; unit tests
// reach these via `(session as any)` — keep the field names stable) ---
private get _peerPublicKeys(): Map<number, CryptoKey> {
return this._e2ee.peerPublicKeys;
}
private get _e2eeEpoch(): number {
return this._e2ee.epoch;
}
private get _rotatingKey(): boolean {
return this._e2ee.rotatingKey;
}
private set _rotatingKey(value: boolean) {
this._e2ee.rotatingKey = value;
}
private get _rotationPending(): boolean {
return this._e2ee.rotationPending;
}
private set _rotationPending(value: boolean) {
this._e2ee.rotationPending = value;
}
private get _pendingAnnounces(): Array<{
userId: number;
publicKeyBase64: string;
signatureBase64?: string;
}> = [];
/** Periodic key rotation timer — fires every KEY_ROTATION_INTERVAL_MS when key holder. */
private _keyRotationTimer: ReturnType<typeof setTimeout> | null = null;
/** Interval between periodic key rotations (5 minutes). */
private static readonly KEY_ROTATION_INTERVAL_MS = 5 * 60 * 1000;
}> {
return this._e2ee.pendingAnnounces;
}
/** Test-visibility delegate: periodic rotation lives on the E2EEManager. */
private rotateKeyPeriodically(): Promise<void> {
return this._e2ee.rotateKeyPeriodically();
}
// --- State transition (single writer) ---
@@ -380,7 +362,7 @@ export class LiveKitSession {
// End-to-end encryption: SFrame-based E2EE using a server-distributed
// per-channel symmetric key. The SFU only sees encrypted frames.
e2ee: {
keyProvider: this._e2eeKeyProvider,
keyProvider: this._e2ee.keyProvider,
worker: new Worker(new URL("livekit-client/e2ee-worker", import.meta.url)),
},
});
@@ -478,18 +460,7 @@ export class LiveKitSession {
// so audio works immediately; the key holder will send a fresh offer if
// the key was rotated during our absence.
// oxlint-disable-next-line no-await-in-loop -- must set up E2EE before connect
this._ecdhKeyPair = await generateECDHKeyPair();
this._peerPublicKeys.clear();
clearPeerVerifications();
if (this._roomKey) {
// oxlint-disable-next-line no-await-in-loop -- must set key before connect
await this._e2eeKeyProvider.setKey(roomKeyToBase64(this._roomKey));
}
// oxlint-disable-next-line no-await-in-loop -- must export before connect
const reconnectPubKey = await exportPublicKey(this._ecdhKeyPair.publicKey);
// oxlint-disable-next-line no-await-in-loop -- must sign the announce before connect
const reconnectAnnounce = await this.buildAnnouncePayload(reconnectPubKey);
this.ws?.send({ type: "voice_e2ee_announce", payload: reconnectAnnounce });
await this._e2ee.reannounceForReconnect();
// oxlint-disable-next-line no-await-in-loop -- sequential reconnect: must connect before restoring state
await newRoom.connect(resolvedUrl, token);
@@ -789,7 +760,7 @@ export class LiveKitSession {
// Identity keys are host-scoped — drop the cached keypair when the host
// changes so we never sign an announce with another host's identity key.
if (host !== this.serverHost) {
this._identityKeyPair = null;
this._e2ee.clearIdentityKeyPair();
}
this.serverHost = host;
}
@@ -868,83 +839,12 @@ export class LiveKitSession {
// Non-key-holders block here waiting for the key holder's offer (up to
// ~15s); key holders pass through near-instantly.
setVoiceStatus("securing");
// Generate a fresh ECDH keypair for this session.
this._ecdhKeyPair = await generateECDHKeyPair();
this._peerPublicKeys.clear();
clearPeerVerifications();
const myPubKeyBase64 = await exportPublicKey(this._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 ?? false;
// Drain any announces that arrived before our keypair was ready. These
// are existing participants whose keys the server relayed during
// voice_join sync — run them through the normal verifying receive path
// so a server-substituted peer key is caught here too.
const queued = this._pendingAnnounces.splice(0);
for (const { userId: qId, publicKeyBase64: qKey, signatureBase64: qSig } of queued) {
// oxlint-disable-next-line no-await-in-loop -- sequential drain: verify each queued announce
await this.handleE2EEAnnounce(qId, qKey, qSig);
log.info("E2EE: drained queued announce", { userId: qId });
}
if (this._isKeyHolder) {
// We're the first participant — generate the room key.
this._e2eeEpoch++;
this._roomKey = generateRoomKey();
await this._e2eeKeyProvider.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.ws?.send({ type: "voice_e2ee_announce", payload: announcePayload });
} else {
// Wait for the key holder to send us the room key via voice_e2ee_offer.
// This promise resolves when handleE2EEOffer() sets _roomKey.
log.info("E2EE: waiting for room key from key holder", { channelId });
const roomKeyPromise = new Promise<void>((resolve, reject) => {
this._roomKeyResolver = resolve;
this._roomKeyRejector = reject;
});
// Announce BEFORE waiting (moved earlier per F3) so the key holder can
// offer immediately. The resolver is set above, so an immediate offer
// won't be missed.
this.ws?.send({ type: "voice_e2ee_announce", payload: announcePayload });
// Wait up to 10s for the key holder to send an offer. If the first
// attempt times out, re-announce our public key (the offer may have been
// lost if the key holder disconnected mid-send) and wait 5s more.
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const makeTimeout = (ms: number) =>
new Promise<void>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error("E2EE key exchange timeout")), ms);
});
try {
await Promise.race([roomKeyPromise, makeTimeout(10_000)]);
} catch {
// First attempt timed out — re-announce and retry once.
if (timeoutId !== null) clearTimeout(timeoutId);
log.warn("E2EE: first key exchange attempt timed out, re-announcing", { channelId });
this.ws?.send({ type: "voice_e2ee_announce", payload: announcePayload });
try {
await Promise.race([roomKeyPromise, makeTimeout(5_000)]);
} catch {
log.error("E2EE: key exchange timed out after retry — disconnecting", { channelId });
this._roomKeyResolver = null;
this._roomKeyRejector = null;
if (timeoutId !== null) clearTimeout(timeoutId);
const keyExchangeOk = await this._e2ee.setupKeyExchange(isKeyHolder ?? false, channelId);
if (!keyExchangeOk) {
this.onErrorCallback?.("e2ee_timeout");
this.leaveVoice(false);
return false;
}
} finally {
if (timeoutId !== null) clearTimeout(timeoutId);
}
this._roomKeyResolver = null;
this._roomKeyRejector = null;
}
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
@@ -1188,491 +1088,46 @@ export class LiveKitSession {
}
}
// ── Identity signing (F3 TOFU) ──────────────────────────────────────────
/** Decode a base64 raw-key string to bytes for sign/verify. Throws on bad
* input (callers verifying a peer key already run inside try/catch). */
private rawFromBase64(base64: string): Uint8Array {
return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
}
/** 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. */
private async ensureIdentityKeyPair(): Promise<CryptoKeyPair | null> {
if (this._identityKeyPair) return this._identityKeyPair;
if (this.serverHost === null) return null;
this._identityKeyPair = await getOrCreateIdentityKeyPair(this.serverHost);
return this._identityKeyPair;
}
/** Build the voice_e2ee_announce payload, signing the ephemeral public key
* with our identity key (F3). Signing failures degrade to an unsigned
* announce rather than blocking the join. */
private async buildAnnouncePayload(
ephemeralPubBase64: string,
): Promise<{ public_key: string; signature?: string }> {
try {
const idKeyPair = await this.ensureIdentityKeyPair();
if (idKeyPair) {
const myUserId = authStore.getState().user?.id ?? 0;
const ephemeralRaw = this.rawFromBase64(ephemeralPubBase64);
const signature = await signEphemeralKey(idKeyPair.privateKey, myUserId, ephemeralRaw);
return { public_key: ephemeralPubBase64, signature };
}
} catch (err) {
log.error("E2EE: failed to sign announce — sending unsigned", err);
}
return { public_key: ephemeralPubBase64 };
}
// ── Client-side E2EE delegates (state + protocol live in E2EEManager) ───
/**
* F3 TOFU: resolve a peer's identity key and verify their ephemeral-announce
* signature. Pins the identity key on first sight; on a later change it emits
* an identity-tofu "mismatch" (via the voice store) and blocks the peer until
* the user re-pins. Returns true when the announce may be accepted (verified,
* or a legacy peer with no identity key), false to reject/block. The store
* write is the surfaced verification state the voice panel reads.
*
* Compatibility posture (transition):
* - peer HAS a published identity key, signature missing/invalid → reject
* (fail closed);
* - peer has NO identity key (legacy client) → accept, mark unverified
* (pin-pending).
*/
private async verifyPeerAnnounce(
userId: number,
publicKeyBase64: string,
signatureBase64?: string,
): Promise<boolean> {
const publishedIdentity =
membersStore.getState().members.get(userId)?.identityPublicKey ?? null;
const host = this.serverHost;
// 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;
// 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 });
log.error("E2EE: pinned peer identity key missing/changed — blocking (identity-tofu)", {
userId,
});
return false;
}
// Genuine legacy peer: never pinned AND no published identity key — accept
// but mark unverified (pin-pending). This is the only case the compatibility
// posture keeps open.
if (!publishedIdentity) {
setPeerVerification({ userId, status: "unverified", safetyNumber: null });
log.warn("E2EE: peer has no identity key — accepting as unverified (legacy)", { userId });
return true;
}
// Verify the ephemeral-key signature against the trusted identity key
// (the pin when we have one, else the first-sight published key).
const anchorBase64 = pin ?? publishedIdentity;
const identityKey = await importIdentityPublicKey(anchorBase64);
const ephemeralRaw = this.rawFromBase64(publicKeyBase64);
const ok = signatureBase64
? await verifyEphemeralKeySignature(identityKey, userId, ephemeralRaw, signatureBase64)
: false;
if (!ok) {
// Fail closed: peer has an identity key but no valid signature (MITM).
setPeerVerification({ 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.
if (pin === null && host) {
await storeIdentityPin(host, String(userId), publishedIdentity);
log.info("E2EE: pinned peer identity key on first sight", { userId });
}
const safetyNumber = await computeKeyFingerprint(identityKey);
setPeerVerification({ userId, status: "verified", safetyNumber });
return true;
}
/**
* F3 TOFU re-pin recovery (finding #4). Pin the EXACT identity key
* `verifiedKey` — the bytes whose fingerprint the caller displayed and the
* user confirmed out-of-band — overwriting the stored pin for {host,userId}
* and clearing the mismatch block (the identity-key analogue of accepting a
* changed TLS cert). A legitimate key rotation (reinstall / new device /
* wiped keyring) is thus recoverable instead of a permanent lockout; the next
* announce re-verifies against the new pin.
*
* The verified key MUST be passed in, never re-read from membersStore here:
* the store is server-writable (a `user_update` mutates it), so re-reading it
* would let a malicious server swap in an attacker key during the human
* out-of-band verification window and have us pin THAT — a TOCTOU that
* silently defeats the mismatch prompt. Returns false when there is no host
* or no key to pin.
* F3 TOFU re-pin recovery: pin the exact identity key the user verified
* out-of-band, clearing a mismatch block. See E2EEManager.rePinPeerIdentity.
*/
async rePinPeerIdentity(userId: number, verifiedKey: string): Promise<boolean> {
const host = this.serverHost;
if (!host || !verifiedKey) {
log.warn("E2EE: cannot re-pin peer without a host and the verified identity key", { userId });
return false;
return this._e2ee.rePinPeerIdentity(userId, verifiedKey);
}
await storeIdentityPin(host, String(userId), verifiedKey);
clearPeerVerification(userId);
log.info("E2EE: re-pinned peer identity key (TOFU recovery)", { userId });
return true;
}
// ── Client-side E2EE handlers (ECDH key exchange) ───────────────────────
/**
* Handle a voice_e2ee_announce from the server — another participant has
* announced their ECDH public key. 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.
* announced their ECDH public key. See E2EEManager.handleAnnounce.
*/
async handleE2EEAnnounce(
userId: number,
publicKeyBase64: string,
signatureBase64?: string,
): Promise<void> {
// Queue if our keypair isn't ready yet (announce arrived during connectAndSetup).
if (!this._ecdhKeyPair) {
this._pendingAnnounces.push({ userId, publicKeyBase64, signatureBase64 });
log.info("E2EE: queued announce (keypair not ready)", { userId });
return;
}
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))) {
return; // rejected/blocked — do not store or wrap
}
// 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).
const existingKey = this._peerPublicKeys.get(userId);
let peerKey: CryptoKey;
let isDuplicate = false;
if (existingKey) {
const existingB64 = await exportPublicKey(existingKey);
if (existingB64 === publicKeyBase64) {
peerKey = existingKey;
isDuplicate = true;
log.debug("E2EE: duplicate announce — will re-send offer if key holder", { userId });
} else {
peerKey = await importPublicKey(publicKeyBase64);
log.warn("E2EE: peer public key changed (reconnect?)", { userId });
}
} else {
peerKey = await importPublicKey(publicKeyBase64);
}
if (!isDuplicate) {
this._peerPublicKeys.set(userId, peerKey);
log.info("E2EE: received peer public key", { userId });
}
// If we're the key holder and have a room key, wrap it for the new peer.
// Capture keypair + roomKey before async work to avoid null dereference if
// clearE2EEState() runs concurrently.
const keypair = this._ecdhKeyPair;
const currentRoomKey = this._roomKey;
if (this._isKeyHolder && currentRoomKey && keypair) {
const { encryptedKey, iv } = await wrapRoomKey(keypair.privateKey, peerKey, currentRoomKey);
this.ws?.send({
type: "voice_e2ee_offer",
payload: { target_user_id: userId, encrypted_key: encryptedKey, iv },
});
log.info("E2EE: sent room key offer to peer", { userId });
}
} catch (err) {
log.error("E2EE: failed to handle announce", err);
}
return this._e2ee.handleAnnounce(userId, publicKeyBase64, signatureBase64);
}
/**
* 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.
* the encrypted room key. See E2EEManager.handleOffer.
*/
async handleE2EEOffer(
fromUserId: number,
encryptedKeyBase64: string,
ivBase64: string,
): Promise<void> {
try {
const peerKey = this._peerPublicKeys.get(fromUserId);
if (!peerKey) {
log.warn("E2EE: received offer from unknown peer", { fromUserId });
return;
}
const keypair = this._ecdhKeyPair;
if (!keypair) {
log.warn("E2EE: received offer but no ECDH keypair");
return;
}
// Capture epoch before async work — if a key rotation occurs during
// unwrap, the epoch will have advanced and we discard this stale result.
const epochBefore = this._e2eeEpoch;
const unwrapped = await unwrapRoomKey(
keypair.privateKey,
peerKey,
encryptedKeyBase64,
ivBase64,
);
if (this._e2eeEpoch !== epochBefore) {
log.info("E2EE: discarding stale offer (epoch changed during unwrap)", {
fromUserId,
epochBefore,
epochNow: this._e2eeEpoch,
});
return;
}
this._roomKey = unwrapped;
await this._e2eeKeyProvider.setKey(roomKeyToBase64(this._roomKey));
log.info("E2EE: room key received and applied", { fromUserId });
// Resolve the pending connect promise if we were waiting for the key.
if (this._roomKeyResolver) {
this._roomKeyResolver();
this._roomKeyResolver = null;
this._roomKeyRejector = null;
}
} catch (err) {
log.error("E2EE: failed to handle offer", err);
// Propagate decryption failure so the waiting connectAndSetup unblocks.
if (this._roomKeyRejector) {
this._roomKeyRejector(err instanceof Error ? err : new Error(String(err)));
this._roomKeyResolver = null;
this._roomKeyRejector = null;
}
}
return this._e2ee.handleOffer(fromUserId, encryptedKeyBase64, ivBase64);
}
/**
* 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
* ALREADY the key holder and a peer that held the room key left, we also
* rotate — so the departed member's copy can no longer decrypt future audio
* against the untrusted SFU (membership forward secrecy).
*
* Key holder election: the participant with the lowest user ID among remaining
* participants is elected. This is deterministic and does not depend on Map
* insertion order (which is not guaranteed to match server join order).
* Handle a participant leaving the voice channel (key-holder election and
* membership-forward-secrecy rekey). See E2EEManager.handleParticipantLeft.
*/
async handleParticipantLeft(userId: number): Promise<void> {
const hadPeerKey = this._peerPublicKeys.has(userId);
this._peerPublicKeys.delete(userId);
clearPeerVerification(userId);
const channelId = this._currentChannelId;
if (!channelId) return;
const state = voiceStore.getState();
const channelUsers = state.voiceUsers.get(channelId);
if (!channelUsers || channelUsers.size === 0) return;
// Elect key holder: lowest user_id among remaining participants.
let lowestUserId = Infinity;
for (const uid of channelUsers.keys()) {
if (uid < lowestUserId) lowestUserId = uid;
}
const wasKeyHolder = this._isKeyHolder;
const myUserId = authStore.getState().user?.id ?? 0;
if (myUserId !== 0 && lowestUserId === myUserId && !wasKeyHolder) {
// Prevent concurrent rotations (e.g. two participants leave in rapid succession).
if (this._rotatingKey) {
log.warn("E2EE: key rotation already in progress, skipping", { userId, channelId });
return;
}
this._rotatingKey = true;
this._isKeyHolder = true;
log.info("E2EE: became key holder after participant left", { userId, channelId });
// Rotate the room key — generate a new one and distribute to all remaining peers.
try {
this._e2eeEpoch++;
this._roomKey = generateRoomKey();
await this._e2eeKeyProvider.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.
const keypair = this._ecdhKeyPair;
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.ws?.send({
type: "voice_e2ee_offer",
payload: { target_user_id: peerId, encrypted_key: encryptedKey, iv },
});
}
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.ws?.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 });
}
}
}
}
} catch (err) {
log.error("E2EE: failed to rotate room key", err);
} finally {
this._rotatingKey = false;
}
// If a keyed peer left while this become-holder rotation was in flight, its
// rekey was deferred (not dropped) — run it now so the departed member is
// excluded from the fresh key; otherwise re-arm the periodic timer.
await this.drainPendingRotationOrArmTimer();
} else if (wasKeyHolder && hadPeerKey) {
// Membership forward secrecy: I remain the key holder and a peer that held
// the room key left, so rotate + redistribute to the CURRENT peer set
// (which already excludes the leaver, deleted above) — otherwise the
// departed member keeps a valid room key against the untrusted SFU until
// the next periodic rotation.
if (this._rotatingKey) {
// A rotation is already in flight and may already have sent the current
// key to this leaver before they left. Don't DROP the rekey (that would
// leave the departed member holding a live key) — defer it so it re-runs
// when the in-flight rotation completes, excluding them.
this._rotationPending = true;
} else {
await this.rotateKeyPeriodically();
}
}
}
// ── Periodic key rotation ──────────────────────────────────────────────────
/** Start the periodic key rotation timer (only meaningful for key holders). */
private startKeyRotationTimer(): void {
this.clearKeyRotationTimer();
if (!this._isKeyHolder) return;
this._keyRotationTimer = setTimeout(() => {
this._keyRotationTimer = null;
void this.rotateKeyPeriodically();
}, LiveKitSession.KEY_ROTATION_INTERVAL_MS);
log.debug("E2EE: key rotation timer started", {
intervalMs: LiveKitSession.KEY_ROTATION_INTERVAL_MS,
});
}
private clearKeyRotationTimer(): void {
if (this._keyRotationTimer !== null) {
clearTimeout(this._keyRotationTimer);
this._keyRotationTimer = null;
}
}
/** Rotate the room key on a timer tick (forward secrecy improvement). */
private async rotateKeyPeriodically(): Promise<void> {
if (!this._isKeyHolder || this._rotatingKey) return;
const channelId = this._currentChannelId;
if (!channelId) return;
this._rotatingKey = true;
try {
this._e2eeEpoch++;
this._roomKey = generateRoomKey();
await this._e2eeKeyProvider.setKey(roomKeyToBase64(this._roomKey));
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.ws?.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,
});
}
} catch (err) {
log.error("E2EE: periodic key rotation failed", err);
} finally {
this._rotatingKey = false;
}
// Re-arm the periodic timer, or run a rotation deferred by a keyed-peer leave
// that coincided with this one.
await this.drainPendingRotationOrArmTimer();
}
/** After a rotation completes: if a keyed-peer leave coincided with it (its
* rekey was deferred, not dropped), run one more rotation to exclude the
* departed member; otherwise re-arm the periodic rotation timer. */
private async drainPendingRotationOrArmTimer(): Promise<void> {
if (this._rotationPending) {
this._rotationPending = false;
await this.rotateKeyPeriodically();
return;
}
this.startKeyRotationTimer();
}
/** Clear all E2EE state (called on voice leave). The long-term identity
* keypair is intentionally NOT cleared here — it persists across calls to
* the same host (cleared only on host change / cleanupAll). */
private clearE2EEState(): void {
this._ecdhKeyPair = null;
this._roomKey = null;
this._peerPublicKeys.clear();
clearPeerVerifications();
this._isKeyHolder = false;
this._rotatingKey = false;
this._rotationPending = false;
this._e2eeEpoch = 0;
this._pendingAnnounces.length = 0;
this.clearKeyRotationTimer();
// Reject (not resolve) so waiting connectAndSetup sees a failure, not a
// silent success with no room key.
if (this._roomKeyRejector) {
this._roomKeyRejector(new Error("Voice session ended"));
}
this._roomKeyResolver = null;
this._roomKeyRejector = null;
return this._e2ee.handleParticipantLeft(userId);
}
/** Retry microphone permission after being in listen-only mode. */
@@ -1728,7 +1183,7 @@ export class LiveKitSession {
room.disconnect().catch((err) => log.warn("room.disconnect() error (non-fatal)", err));
}
// Clear client-side E2EE state (ECDH keypair, room key, peer keys).
this.clearE2EEState();
this._e2ee.clearState();
// 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.
@@ -1750,7 +1205,7 @@ export class LiveKitSession {
this.ws = null;
this.serverHost = null;
this.liveKitProxyPort = null;
this._identityKeyPair = null;
this._e2ee.clearIdentityKeyPair();
// Stop the Rust-side TLS proxy (fire-and-forget).
invoke("stop_livekit_proxy").catch((err) => log.warn("Failed to stop LiveKit proxy", err));
}
+41
View File
@@ -1,5 +1,7 @@
// Step 1.12 — Structured client-side logger
import { readMigratedStringPref } from "./preferences";
export type LogLevel = "debug" | "info" | "warn" | "error";
export interface LogEntry {
@@ -115,6 +117,45 @@ export function setLogLevel(level: LogLevel): void {
currentLevel = level;
}
/**
* Get the current effective minimum log level (as applied by applyStoredLogLevel
* / setLogLevel). Reflects the real runtime level, which may differ from any
* saved "logs_min_level" pref when none is stored (dev defaults to debug,
* production to info).
*/
export function getLogLevel(): LogLevel {
return currentLevel;
}
const LOG_LEVELS: readonly LogLevel[] = ["debug", "info", "warn", "error"];
/** Pref key for the minimum level persisted by the Logs settings tab. */
const MIN_LEVEL_PREF_KEY = "logs_min_level";
function readStoredLogLevel(): LogLevel | "" {
return readMigratedStringPref<LogLevel | "">(MIN_LEVEL_PREF_KEY, "", ["", ...LOG_LEVELS]);
}
/**
* Apply the minimum level persisted by the Logs settings tab ("logs_min_level"
* pref, including legacy-key migration), falling back to `fallback` when no
* level is stored. Call once at startup before anything logs.
*/
export function applyStoredLogLevel(fallback: LogLevel): void {
const stored = readStoredLogLevel();
currentLevel = stored === "" ? fallback : stored;
}
// Live updates: the Logs settings tab persists level changes via savePref,
// which dispatches "owncord:pref-change" for same-window listeners.
if (typeof window !== "undefined") {
window.addEventListener("owncord:pref-change", ((e: CustomEvent<{ key: string }>) => {
if (e.detail?.key !== MIN_LEVEL_PREF_KEY) return;
const stored = readStoredLogLevel();
if (stored !== "") currentLevel = stored;
}) as EventListener);
}
/**
* Add a listener for log entries (e.g., to write to file via Tauri).
*/
@@ -0,0 +1,23 @@
/**
* Navigation generation guard — protects async page mounts against the
* destroy-before-mount race. A render that awaits a dynamic import captures
* a generation via begin(); when the import resolves it asks the returned
* predicate whether it is still the latest navigation, and discards the
* mount if a newer navigation superseded it.
*/
export interface NavigationGuard {
/** Start a new navigation. Returns a predicate that reports whether this
* navigation is still the latest one. */
begin(): () => boolean;
}
export function createNavigationGuard(): NavigationGuard {
let generation = 0;
return {
begin(): () => boolean {
const started = ++generation;
return () => started === generation;
},
};
}
+9 -3
View File
@@ -3,7 +3,8 @@
* and plays sounds for incoming messages based on user preferences.
*/
import { loadPref } from "@components/settings/helpers";
import { loadPref } from "./preferences";
import { loadUserStatus } from "./userStatus";
import { authStore } from "@stores/auth.store";
import { channelsStore } from "@stores/channels.store";
import type { ChatMessagePayload } from "./types";
@@ -51,6 +52,11 @@ export function notifyIncomingMessage(payload: ChatMessagePayload): void {
return;
}
// Do Not Disturb — the settings panel promises "You will not receive desktop
// notifications", so honour it for the popup and the chime. The taskbar
// flash stays: it's a passive hint, not a notification.
const dnd = loadUserStatus() === "dnd";
const channelName = getChannelName(payload.channel_id);
// oxlint-disable-next-line consistent-function-scoping -- co-located with its sole caller for readability
@@ -64,7 +70,7 @@ export function notifyIncomingMessage(payload: ChatMessagePayload): void {
const body = sanitizeNotif(payload.content, 100);
// Desktop notification
if (loadPref<boolean>("desktopNotifications", true)) {
if (!dnd && loadPref<boolean>("desktopNotifications", true)) {
fireDesktopNotification(title, body);
}
@@ -74,7 +80,7 @@ export function notifyIncomingMessage(payload: ChatMessagePayload): void {
}
// Notification sound
if (loadPref<boolean>("notificationSounds", true)) {
if (!dnd && loadPref<boolean>("notificationSounds", true)) {
playNotificationSound();
}
}
@@ -1,4 +1,6 @@
import { Permission } from "./types";
import { authStore } from "@stores/auth.store";
import { channelsStore } from "@stores/channels.store";
/** Bitmask with every permission bit set. */
const ALL_PERMISSIONS = 0x7fffffff;
@@ -55,3 +57,30 @@ export function computeEffective(basePerms: number, allow: number, deny: number)
export function isAdministrator(userPerms: number): boolean {
return (userPerms & Permission.ADMINISTRATOR) === Permission.ADMINISTRATOR;
}
/**
* Effective permission bits for the signed-in user, from the role list the
* server sends in `ready`. Returns 0 when the role is unknown (pre-`ready`,
* or a role the server didn't send) — deny by default.
*/
export function currentUserPermissions(): number {
const roleName = authStore.getState().user?.role;
if (roleName === undefined || roleName === null) return 0;
const role = channelsStore
.getState()
.roles.find((r) => r.name.toLowerCase() === roleName.toLowerCase());
return role?.permissions ?? 0;
}
/**
* Whether the signed-in user holds `perm`. Drives affordances only — the
* server is still the authority on every action.
*/
export function currentUserHasPermission(perm: Permission): boolean {
return hasPermission(currentUserPermissions(), perm);
}
/** Shorthand for the MANAGE_MESSAGES bit (delete others' messages, bypass slow mode). */
export function canManageMessages(): boolean {
return currentUserHasPermission(Permission.MANAGE_MESSAGES);
}
@@ -39,3 +39,43 @@ export function savePref(key: string, value: unknown): void {
// localStorage may throw on quota exceeded or when storage is disabled.
}
}
/**
* Read a string-valued pref restricted to `allowedValues`, migrating a legacy
* unprefixed localStorage entry forward when the prefixed key is unset.
* Legacy values were stored either raw or JSON-encoded; a migrated value is
* re-saved under the prefixed key via savePref.
*/
export function readMigratedStringPref<T extends string>(
key: string,
fallback: T,
allowedValues: readonly T[],
): T {
const currentRaw = localStorage.getItem(STORAGE_PREFIX + key);
if (currentRaw !== null) {
try {
const currentValue: unknown = JSON.parse(currentRaw);
if (typeof currentValue === "string" && allowedValues.includes(currentValue as T)) {
return currentValue as T;
}
} catch {
// Ignore corrupted current storage and fall back below.
}
}
const legacyRaw = localStorage.getItem(key);
if (legacyRaw !== null) {
let legacyValue: unknown = legacyRaw;
try {
legacyValue = JSON.parse(legacyRaw);
} catch {
// Legacy values were previously stored as raw strings.
}
if (typeof legacyValue === "string" && allowedValues.includes(legacyValue as T)) {
savePref(key, legacyValue);
return legacyValue as T;
}
}
return fallback;
}
+8 -1
View File
@@ -6,7 +6,6 @@
import { loadPref, savePref } from "@components/settings/helpers";
import { voiceStore } from "@stores/voice.store";
import { setMuted } from "./livekitSession";
import { createLogger } from "./logger";
const log = createLogger("ptt");
@@ -100,8 +99,16 @@ export async function initPtt(): Promise<void> {
const channelId = voiceStore.getState().currentChannelId;
if (channelId === null) return;
// 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");
})
.catch((e) => log.warn("Failed to apply PTT mute", e));
});
pttUnsubscribe = unsub;
+28 -5
View File
@@ -21,16 +21,32 @@ export type VoiceQuality = "low" | "medium" | "high";
export type ReactionAction = "add" | "remove";
/** WebSocket error codes returned by the server. */
/**
* Error codes the server can send over the socket. Mirrors
* `Server/ws/errors.go` — the union was missing more than half of them
* (SLOW_MODE, CONFLICT, BAD_REQUEST…), so code that switched on it could not
* name the cases the server actually emits.
*/
export type WsErrorCode =
| "BANNED"
| "FORBIDDEN"
| "BAD_REQUEST"
| "INTERNAL"
| "NOT_FOUND"
| "FORBIDDEN"
| "RATE_LIMITED"
| "INVALID_INPUT"
| "SERVER_ERROR"
| "ALREADY_JOINED"
| "CHANNEL_FULL"
| "VOICE_ERROR"
| "VIDEO_LIMIT";
| "VIDEO_LIMIT"
| "BANNED"
| "INVALID_JSON"
| "UNKNOWN_TYPE"
| "SLOW_MODE"
| "CONFLICT"
| "BAD_PAYLOAD"
| "NOT_KEY_HOLDER"
// Kept for older servers / existing call sites.
| "INVALID_INPUT"
| "SERVER_ERROR";
/** REST API error codes. */
export type ApiErrorCode =
@@ -104,6 +120,11 @@ export interface ReadyChannel {
* server still enforces. Absent from older servers.
*/
readonly can_send?: boolean;
/**
* Per-channel cooldown in seconds (0 = off). Drives the composer's
* slow-mode countdown; the server still enforces. Absent from older servers.
*/
readonly slow_mode?: number;
}
/** Member object in the ready payload. */
@@ -243,12 +264,14 @@ export interface ChannelCreatePayload {
readonly type: ChannelType;
readonly category: string | null;
readonly position: number;
readonly slow_mode?: number;
}
export interface ChannelUpdatePayload {
readonly id: number;
readonly name?: string;
readonly position?: number;
readonly slow_mode?: number;
}
export interface ChannelDeletePayload {
+49
View File
@@ -0,0 +1,49 @@
/**
* Selected presence status — the single client-side source of truth.
*
* Both status surfaces (the settings Account tab and the UserBar picker) read
* and write through here, so they can't drift apart, and consumers such as the
* notification service can ask "is the user in Do Not Disturb?" without
* reaching into a store that only tracks *other* members' presence.
*/
import type { UserStatus } from "./types";
import { loadPref, savePref } from "./preferences";
export const USER_STATUS_PREF_KEY = "userStatus";
const VALID_STATUSES: readonly UserStatus[] = ["online", "idle", "dnd", "offline"];
function isUserStatus(value: string): value is UserStatus {
return (VALID_STATUSES as readonly string[]).includes(value);
}
/** The status the user last selected, defaulting to "online". */
export function loadUserStatus(): UserStatus {
const raw = loadPref<string>(USER_STATUS_PREF_KEY, "online");
return isUserStatus(raw) ? raw : "online";
}
/** Persist the selected status and notify same-window listeners. */
export function saveUserStatus(status: UserStatus): void {
savePref(USER_STATUS_PREF_KEY, status);
}
/**
* Run `onChange` whenever the selected status changes anywhere in this window.
* Returns an unsubscribe function.
*/
export function onUserStatusChange(
onChange: (status: UserStatus) => void,
options?: { signal?: AbortSignal },
): () => void {
const handler = (e: Event): void => {
const detail = (e as CustomEvent<{ key?: string }>).detail;
if (detail?.key !== USER_STATUS_PREF_KEY) return;
onChange(loadUserStatus());
};
window.addEventListener("owncord:pref-change", handler, { signal: options?.signal });
return () => {
window.removeEventListener("owncord:pref-change", handler);
};
}
+82 -9
View File
@@ -14,15 +14,14 @@ import { wireDispatcher, wireConnectionStatus } from "@lib/dispatcher";
import { authStore, clearAuth } from "@stores/auth.store";
import { setTransientError } from "@stores/ui.store";
import { voiceStore, leaveVoiceChannel } from "@stores/voice.store";
import { leaveVoice as voiceSessionLeave } from "@lib/livekitSession";
import { createConnectPage } from "@pages/ConnectPage";
import { createMainPage } from "@pages/MainPage";
import { applyStoredAppearance } from "@components/SettingsOverlay";
import { applyStoredAppearance } from "@lib/appearance";
import { restoreTheme } from "@lib/themes";
import { initPtt } from "@lib/ptt";
import { createNavigationGuard } from "@lib/navigation-guard";
import { createConnectedOverlay } from "@components/ConnectedOverlay";
import type { ConnectedOverlayControl } from "@components/ConnectedOverlay";
import { createLogger } from "@lib/logger";
import { createLogger, applyStoredLogLevel } from "@lib/logger";
import { initLogPersistence, flushLogs } from "@lib/logPersistence";
import { saveCredential, loadCredential, deleteCredential } from "@lib/credentials";
import { initWindowState } from "@lib/window-state";
@@ -33,8 +32,24 @@ import type { CertTofuEvent } from "@lib/ws";
import { openUrl } from "@tauri-apps/plugin-opener";
// Gate the log level before anything logs: debug entries are serialized and
// persisted to disk, so in production the level must filter real work, not
// just console noise. Honors the level saved on the Logs settings tab; when
// unset, dev builds keep full debug output and production defaults to info.
applyStoredLogLevel(import.meta.env.DEV ? "debug" : "info");
const log = createLogger("main");
// livekitSession (and the ~1.3 MB livekit-client SDK behind it) is loaded
// lazily so it stays out of the startup path. When a voice session exists the
// module is necessarily already loaded, so this import resolves from the
// module cache in a microtask.
function voiceSessionLeave(sendWsLeave: boolean): void {
void import("@lib/livekitSession")
.then(({ leaveVoice }) => leaveVoice(sendWsLeave))
.catch((e) => log.warn("Failed to leave voice session", e));
}
// Disable the default browser context menu globally.
document.addEventListener("contextmenu", (e) => {
e.preventDefault();
@@ -103,6 +118,11 @@ const ws = createWsClient();
wireConnectionStatus(ws);
const profileManager = createProfileManager(createTauriBackend());
let dispatcherCleanup: (() => void) | null = null;
// Tears down the session-scoped WS listeners registered in wirePostAuth
// (user_update, onStateChange, ready). dispatcherCleanup only clears
// dispatcher-registered handlers, so these need their own teardown to avoid
// accumulating across login/logout/retry cycles.
let sessionCleanup: (() => void) | null = null;
let connectedOverlay: ConnectedOverlayControl | null = null;
let lastConnectHost = "";
let lastConnectToken = "";
@@ -241,8 +261,13 @@ function runHealthChecks(
}
}
// Guards the async MainPage mount below against the destroy-before-mount race:
// a stale mount is discarded when a newer navigation supersedes it.
const navGuard = createNavigationGuard();
// Render the appropriate page based on router state
function renderPage(pageId: "connect" | "main"): void {
async function renderPage(pageId: "connect" | "main"): Promise<void> {
const isCurrentNavigation = navGuard.begin();
log.info("Navigating to page", { pageId });
// Destroy previous page
currentPage?.destroy?.();
@@ -261,6 +286,15 @@ function renderPage(pageId: "connect" | "main"): void {
rememberPassword = true,
): void {
log.info("Post-auth wiring", { host, username });
// Tear down any prior session wiring so listeners and the connected
// overlay never stack across a retry (a second wirePostAuth without an
// intervening logout).
sessionCleanup?.();
sessionCleanup = null;
dispatcherCleanup?.();
dispatcherCleanup = null;
connectedOverlay?.destroy();
connectedOverlay = null;
api.setConfig({ token });
// Store token in authStore so the dispatcher's auth_ok handler has it
authStore.setState((prev) => ({ ...prev, token }));
@@ -270,6 +304,10 @@ function renderPage(pageId: "connect" | "main"): void {
dispatcherCleanup = wireDispatcher(ws, api);
log.info("Dispatcher wired, connecting WS");
// Session-scoped WS listeners — collected so they're all removed together
// on logout/disconnect (or the next wirePostAuth).
const sessionUnsubs: Array<() => void> = [];
// BUG-135: Only persist credentials when the user opted in.
if (rememberPassword) {
saveCredential(host, username, token, password)
@@ -285,6 +323,7 @@ function renderPage(pageId: "connect" | "main"): void {
}
// Update saved credentials when the current user changes their username.
sessionUnsubs.push(
ws.on("user_update", (payload) => {
const currentUserId = authStore.getState().user?.id ?? 0;
if (payload.user_id === currentUserId) {
@@ -293,13 +332,22 @@ function renderPage(pageId: "connect" | "main"): void {
void saveCredential(host, payload.username, currentToken);
}
}
});
}),
);
const unsubState = ws.onStateChange((wsState) => {
log.debug("WS state change", { state: wsState });
if (wsState === "connected") {
// Stop listening once connected so a later transition can't fire this
// handler again (which would append a second overlay).
unsubState();
// Pre-warm the lazily-loaded MainPage chunk (and the LiveKit stack
// behind it) so navigating past the connected overlay doesn't wait
// on a dynamic import.
void import("@pages/MainPage");
const auth = authStore.getState();
// Ensure exactly one overlay exists at a time.
connectedOverlay?.destroy();
connectedOverlay = createConnectedOverlay({
serverName: auth.serverName ?? host,
username: auth.user?.username ?? username,
@@ -317,8 +365,20 @@ function renderPage(pageId: "connect" | "main"): void {
unsubReady();
connectedOverlay?.markReady();
});
sessionUnsubs.push(unsubReady);
} else if (wsState === "disconnected") {
// Terminal non-connected transition (auth_error, cert-mismatch reject,
// or intentional disconnect before ever connecting): drop the handler
// so it doesn't linger and fire on a later connect.
unsubState();
}
});
sessionUnsubs.push(unsubState);
sessionCleanup = () => {
for (const unsub of sessionUnsubs) unsub();
sessionUnsubs.length = 0;
};
}
// Track partial auth state for TOTP flow
@@ -523,6 +583,14 @@ function renderPage(pageId: "connect" | "main"): void {
}
})();
} else {
// MainPage (and the LiveKit voice stack it statically imports) loads
// lazily so it stays out of the startup path. The chunk is pre-warmed as
// soon as the WS connect succeeds, so this normally resolves from the
// module cache.
const { createMainPage } = await import("@pages/MainPage");
// A newer navigation may have superseded this one while the chunk loaded;
// mounting now would fight the page that navigation rendered.
if (!isCurrentNavigation()) return;
const mainPage = createMainPage({ ws, api });
safeMount(mainPage, appEl!);
currentPage = mainPage;
@@ -530,7 +598,9 @@ function renderPage(pageId: "connect" | "main"): void {
}
// Listen for navigation changes
router.onNavigate(renderPage);
router.onNavigate((pageId) => {
void renderPage(pageId);
});
// Handle logout / disconnect
authStore.subscribeSelector(
@@ -546,6 +616,8 @@ authStore.subscribeSelector(
}
dispatcherCleanup?.();
dispatcherCleanup = null;
sessionCleanup?.();
sessionCleanup = null;
ws.disconnect();
lastConnectToken = "";
lastConnectHost = "";
@@ -570,8 +642,9 @@ window.addEventListener("beforeunload", () => {
void flushLogs();
});
// Initial render
renderPage(router.getCurrentPage());
// Initial render (fire-and-forget — the initial page is "connect", whose
// render branch is synchronous)
void renderPage(router.getCurrentPage());
// Initialize window state persistence (fire-and-forget)
void initWindowState();
+35 -10
View File
@@ -4,7 +4,6 @@
import { createElement, appendChildren } from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
import { openSettings, closeSettings, uiStore, setTransientError } from "@stores/ui.store";
import { createSettingsOverlay } from "@components/SettingsOverlay";
import type { HealthStatus } from "@lib/profiles";
import { createServerPanel } from "./connect-page/ServerPanel";
import { createLoginForm } from "./connect-page/LoginForm";
@@ -14,7 +13,6 @@ import { loadCredential } from "@lib/credentials";
// Re-exports (public API must not change)
// ---------------------------------------------------------------------------
export type { FormState, FormMode } from "./connect-page/LoginForm";
export type { SimpleProfile } from "./connect-page/ServerPanel";
import type { SimpleProfile } from "./connect-page/ServerPanel";
@@ -208,14 +206,23 @@ export function createConnectPage(
// MountableComponent
// ---------------------------------------------------------------------------
let settingsOverlay: ReturnType<typeof createSettingsOverlay> | null = null;
let settingsOverlay: ReturnType<
typeof import("@components/SettingsOverlay").createSettingsOverlay
> | null = null;
let settingsOverlayLoading = false;
let unsubSettingsOpen: (() => void) | null = null;
function mount(target: Element): void {
container = target;
const rootEl = buildRoot();
container.appendChild(rootEl);
// Mount settings overlay on the connect page (unauthenticated — account actions are no-ops)
// The settings overlay (whose tabs pull in the LiveKit stack) is created
// lazily on first open so it stays out of the startup path. Once created it
// manages its own show/hide off uiStore.settingsOpen.
function ensureSettingsOverlay(): void {
if (settingsOverlay !== null || settingsOverlayLoading) return;
settingsOverlayLoading = true;
void import("@components/SettingsOverlay").then(({ createSettingsOverlay }) => {
settingsOverlayLoading = false;
// The page may have been destroyed while the chunk loaded.
if (signal.aborted) return;
// Unauthenticated on the connect page — account actions are no-ops.
settingsOverlay = createSettingsOverlay({
isAuthenticated: false,
onClose: () => closeSettings(),
@@ -228,7 +235,23 @@ export function createConnectPage(
onConfirmTotp: () => Promise.reject(new Error("Not authenticated")),
onDisableTotp: () => Promise.reject(new Error("Not authenticated")),
});
settingsOverlay.mount(rootEl);
settingsOverlay.mount(root);
});
}
function mount(target: Element): void {
container = target;
const rootEl = buildRoot();
container.appendChild(rootEl);
// Create the settings overlay the first time settings are opened.
unsubSettingsOpen = uiStore.subscribeSelector(
(s) => s.settingsOpen,
(settingsOpen) => {
if (settingsOpen) ensureSettingsOverlay();
},
);
if (uiStore.getState().settingsOpen) ensureSettingsOverlay();
// Show any pending auth error (e.g. "already connected from another client")
const pendingError = uiStore.getState().transientError;
@@ -244,6 +267,8 @@ export function createConnectPage(
function destroy(): void {
// Abort all event listeners registered with the signal
abortController.abort();
unsubSettingsOpen?.();
unsubSettingsOpen = null;
settingsOverlay?.destroy?.();
settingsOverlay = null;
+36
View File
@@ -19,6 +19,7 @@ import { logout } from "@lib/logout";
import { authStore, clearAuth, updateUser } from "@stores/auth.store";
import { closeSettings, uiStore } from "@stores/ui.store";
import { updatePresence } from "@stores/members.store";
import { loadUserStatus } from "@lib/userStatus";
import { channelsStore, getActiveChannel } from "@stores/channels.store";
import { dmStore } from "@stores/dm.store";
import { voiceStore } from "@stores/voice.store";
@@ -33,6 +34,8 @@ import {
} from "@lib/livekitSession";
import { setServerHost } from "@components/message-list/renderers";
import { createQuickSwitcherManager } from "./main-page/OverlayManagers";
import { attachGlobalKeybinds } from "./main-page/GlobalKeybinds";
import { createVoiceWidgetCallbacks } from "./main-page/VoiceCallbacks";
import { createMessageController, createPendingDeleteManager } from "./main-page/MessageController";
import type { MessageController } from "./main-page/MessageController";
import { createReactionController } from "./main-page/ReactionController";
@@ -115,6 +118,23 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
return authStore.getState().user?.id ?? 0;
}
/**
* Re-assert the status the user picked in settings. The server starts every
* session as "online", so without this a saved "Do Not Disturb" would show
* as selected in the panel while everyone else saw the user as online.
*/
function restoreSavedPresence(): void {
const status = loadUserStatus();
if (status === "online") return;
const userId = getCurrentUserId();
if (userId !== 0) {
updatePresence(userId, status);
}
if (limiters.presence.tryConsume()) {
ws.send({ type: "presence_update", payload: { status } });
}
}
/** Resolve display name for a channel — for DMs, use recipient username from DM store. */
function resolveChannelName(
channelId: number,
@@ -205,6 +225,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
(s) => s.connectionStatus,
(status) => {
try {
if (status === "connected") restoreSavedPresence();
if (banner === null) return;
applyConnectionStatus(banner, status);
} catch (err) {
@@ -218,6 +239,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
// (status already "reconnecting") would otherwise never show the banner —
// the whole retry cycle maps to the same 3-state value.
applyConnectionStatus(banner, uiStore.getState().connectionStatus);
if (uiStore.getState().connectionStatus === "connected") restoreSavedPresence();
unsubscribers.push(
ws.on("server_restart", (payload) => {
@@ -358,6 +380,20 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
const qsManager = createQuickSwitcherManager(() => root);
unsubscribers.push(qsManager.attach());
// The rest of the shortcuts listed on the settings Keybinds tab.
const voiceKeybindActions = createVoiceWidgetCallbacks(ws, limiters);
unsubscribers.push(
attachGlobalKeybinds({
onSearch: () => chatAreaResult.searchCtrl.open(),
onToggleMute: () => voiceKeybindActions.onMuteToggle(),
onToggleDeafen: () => voiceKeybindActions.onDeafenToggle(),
onToggleCamera: () => voiceKeybindActions.onCameraToggle(),
onUploadFile: () => channelCtrl?.openFilePicker(),
// Don't fire app shortcuts while the settings panel is on top of them.
isSuspended: () => uiStore.getState().settingsOpen,
}),
);
// Toast container
toast = createToastContainer();
toast.mount(root);
@@ -30,6 +30,7 @@ import type { ReactionController } from "./ReactionController";
import { updateChatHeaderForDm } from "./ChatHeader";
import type { ChatHeaderRefs } from "./ChatHeader";
import { dmStore } from "@stores/dm.store";
import { canManageMessages } from "@lib/permissions";
import { blocksStore, dmComposerBlockReason } from "@stores/blocks.store";
import { membersStore } from "@stores/members.store";
import { channelsStore } from "@stores/channels.store";
@@ -68,6 +69,8 @@ export interface ChannelController {
readonly currentChannelId: number | null;
/** Currently mounted message list (for scroll-to-message). */
readonly messageList: MessageListComponent | null;
/** Open the composer's attachment picker (Ctrl+U). No-op with no composer. */
openFilePicker(): void;
}
// ---------------------------------------------------------------------------
@@ -322,6 +325,23 @@ export function createChannelController(opts: ChannelControllerOptions): Channel
channelType === "dm"
? (dmStore.getState().channels.find((c) => c.channelId === channelId)?.recipient.id ?? null)
: null;
// Slow mode as affordance: after an accepted send the composer disables
// itself for the channel's cooldown with a live countdown, instead of
// taking a message the server will bounce with SLOW_MODE (UX spec §5,
// "do not drop the drafted message" — the draft stays in the textarea).
let slowModeUntil = 0;
let slowModeTicker: ReturnType<typeof setInterval> | null = null;
const stopSlowModeTicker = (): void => {
if (slowModeTicker !== null) {
clearInterval(slowModeTicker);
slowModeTicker = null;
}
};
const slowModeRemaining = (): number =>
slowModeUntil === 0 ? 0 : Math.max(0, Math.ceil((slowModeUntil - Date.now()) / 1000));
const computeComposerReason = (): string | null => {
const status = uiStore.getState().connectionStatus;
if (status === "reconnecting") return "Reconnecting…";
@@ -337,11 +357,52 @@ export function createChannelController(opts: ChannelControllerOptions): Channel
? "Only moderators can post in announcement channels"
: "You don't have permission to send messages here";
}
const remaining = slowModeRemaining();
if (remaining > 0) return `Slow mode — ${remaining}s`;
return null;
};
const refreshComposerState = (): void => {
messageInput?.setDisabled(computeComposerReason());
};
/**
* Begin (or restart) the slow-mode cooldown for this channel. Moderators
* bypass slow mode server-side, so they never get gated here either.
*/
const startSlowMode = (seconds: number): void => {
if (seconds <= 0 || canManageMessages()) return;
slowModeUntil = Date.now() + seconds * 1000;
refreshComposerState();
stopSlowModeTicker();
slowModeTicker = setInterval(() => {
if (slowModeRemaining() <= 0) {
slowModeUntil = 0;
stopSlowModeTicker();
}
refreshComposerState();
}, 1000);
};
composerGatingUnsubs.push(stopSlowModeTicker);
// The server accepted a message — the next one is subject to the cooldown.
composerGatingUnsubs.push(
ws.on("chat_send_ok", () => {
const ch = channelsStore.getState().channels.get(channelId);
if (ch !== undefined && ch.id === channelsStore.getState().activeChannelId) {
startSlowMode(ch.slowMode);
}
}),
);
// A refused send restarts the full window: the server's limiter is the
// authority on when the next one is allowed.
composerGatingUnsubs.push(
ws.on("error", (payload) => {
if (payload.code !== "SLOW_MODE") return;
const ch = channelsStore.getState().channels.get(channelId);
if (ch !== undefined) startSlowMode(ch.slowMode);
}),
);
refreshComposerState();
composerGatingUnsubs.push(
uiStore.subscribeSelector(
@@ -407,6 +468,7 @@ export function createChannelController(opts: ChannelControllerOptions): Channel
return {
mountChannel,
destroyChannel,
openFilePicker: () => messageInput?.openFilePicker(),
get currentChannelId() {
return _currentChannelId;
},
@@ -0,0 +1,84 @@
/**
* GlobalKeybinds — the app-wide shortcuts the settings Keybinds tab advertises.
*
* Quick Switcher (Ctrl+K) is owned by its own manager; everything else the
* Keybinds tab lists lives here so the panel and the behaviour can't drift.
* Voice actions no-op outside a voice channel rather than firing signalling
* messages into a session that doesn't exist.
*/
import { createLogger } from "@lib/logger";
import { voiceStore } from "@stores/voice.store";
const log = createLogger("global-keybinds");
export interface GlobalKeybindHandlers {
/** Ctrl+F — open the message search overlay. */
readonly onSearch: () => void;
/** Ctrl+M — toggle microphone mute (voice only). */
readonly onToggleMute: () => void;
/** Ctrl+D — toggle deafen (voice only). */
readonly onToggleDeafen: () => void;
/** Ctrl+Shift+V — toggle the camera (voice only). */
readonly onToggleCamera: () => void;
/** Ctrl+U — open the composer's attachment picker. */
readonly onUploadFile: () => void;
/** Whether shortcuts should be ignored right now (e.g. settings overlay open). */
readonly isSuspended?: () => boolean;
}
/** True while the user is connected to a voice channel. */
function inVoice(): boolean {
return voiceStore.getState().currentChannelId !== null;
}
/**
* Register the shortcuts on `document`. Returns a detach function.
*/
export function attachGlobalKeybinds(handlers: GlobalKeybindHandlers): () => void {
const handler = (e: KeyboardEvent): void => {
if (!(e.ctrlKey || e.metaKey) || e.altKey) return;
if (handlers.isSuspended?.() === true) return;
// `e.key` is layout-dependent and uppercases with Shift held — compare
// case-insensitively so Ctrl+Shift+V arrives as "V", not a missed "v".
const key = e.key.toLowerCase();
const run = (label: string, action: () => void): void => {
e.preventDefault();
try {
action();
} catch (err) {
log.error("Keybind handler failed", { key: label, error: String(err) });
}
};
if (e.shiftKey) {
// Only Ctrl+Shift+V is claimed; other Shift combos fall through to the app.
if (key === "v" && inVoice()) run("toggle-camera", handlers.onToggleCamera);
return;
}
switch (key) {
case "f":
run("search", handlers.onSearch);
break;
case "m":
if (inVoice()) run("toggle-mute", handlers.onToggleMute);
break;
case "d":
if (inVoice()) run("toggle-deafen", handlers.onToggleDeafen);
break;
case "u":
run("upload-file", handlers.onUploadFile);
break;
default:
break;
}
};
document.addEventListener("keydown", handler);
return () => {
document.removeEventListener("keydown", handler);
};
}
@@ -165,7 +165,12 @@ export function createInviteManagerController(opts: {
}
},
onCopyLink: (code: string) => {
void navigator.clipboard.writeText(code);
// No silent success: a copy the user can't see is indistinguishable
// from a clipboard permission failure.
void navigator.clipboard.writeText(code).then(
() => showToast("Invite code copied", "success"),
() => showToast("Couldn't copy the invite code", "error"),
);
},
onClose: close,
onError: (message: string) => {
@@ -12,7 +12,6 @@ import type { ApiClient } from "@lib/api";
import type { RateLimiterSet } from "@lib/rate-limiter";
import type { ToastContainer } from "@components/Toast";
import { createChannelSidebar } from "@components/ChannelSidebar";
import { createMemberList } from "@components/MemberList";
import { createDmSidebar } from "@components/DmSidebar";
import { createCreateChannelModal } from "@components/CreateChannelModal";
import { createEditChannelModal } from "@components/EditChannelModal";
@@ -22,6 +21,7 @@ import { createVoiceWidget } from "@components/VoiceWidget";
import { createQuickSwitchOverlay } from "@components/QuickSwitchOverlay";
import type { QuickSwitchProfile } from "@components/QuickSwitchOverlay";
import { createVoiceWidgetCallbacks, createSidebarVoiceCallbacks } from "./VoiceCallbacks";
import { createSidebarMemberSection } from "./SidebarMemberSection";
import { createInviteManagerController } from "./OverlayManagers";
import {
selectDmConversation,
@@ -33,7 +33,7 @@ import { createSidebarDmSection } from "./SidebarDmSection";
import { uiStore, setSidebarMode, loadCollapsedCategories } from "@stores/ui.store";
import { authStore, clearAuth } from "@stores/auth.store";
import { membersStore, getOnlineMembers } from "@stores/members.store";
import { channelsStore, setActiveChannel, getRoleIdByName } from "@stores/channels.store";
import { channelsStore, setActiveChannel } from "@stores/channels.store";
import { dmStore, removeDmChannel } from "@stores/dm.store";
import { createProfileManager, createTauriBackend } from "@lib/profiles";
import type { ProfileManager } from "@lib/profiles";
@@ -495,141 +495,12 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
}
// --- Member list (below DM section) ---
const memberListContainer = createElement("div", {
class: "sidebar-members-section",
"data-testid": "sidebar-members",
});
// Member header (styled like category headers)
const memberHeader = createElement("div", { class: "category sidebar-members-header" });
const memberArrow = createElement("span", { class: "category-arrow" }, "\u25BC");
const memberLabelEl = createElement("span", { class: "category-name" }, "MEMBERS");
appendChildren(memberHeader, memberArrow, memberLabelEl);
memberListContainer.appendChild(memberHeader);
// Resize handle
const resizeHandle = createElement("div", { class: "sidebar-resize-handle" });
memberListContainer.appendChild(resizeHandle);
// Restore saved height
const savedHeight = localStorage.getItem("owncord:member-list-height");
if (savedHeight !== null) {
memberListContainer.style.height = `${savedHeight}px`;
}
// Drag-to-resize logic
const resizeAbort = new AbortController();
let isDragging = false;
let startY = 0;
let startHeight = 0;
resizeHandle.addEventListener(
"mousedown",
(e: MouseEvent) => {
isDragging = true;
startY = e.clientY;
startHeight = memberListContainer.offsetHeight;
e.preventDefault();
},
{ signal: resizeAbort.signal },
);
document.addEventListener(
"mousemove",
(e: MouseEvent) => {
if (!isDragging) return;
const delta = startY - e.clientY;
const maxH = window.innerHeight * 0.65;
const newHeight = Math.max(80, Math.min(startHeight + delta, maxH));
memberListContainer.style.height = `${newHeight}px`;
},
{ signal: resizeAbort.signal },
);
document.addEventListener(
"mouseup",
() => {
if (!isDragging) return;
isDragging = false;
localStorage.setItem(
"owncord:member-list-height",
String(memberListContainer.offsetHeight),
);
},
{ signal: resizeAbort.signal },
);
channelModeUnsubs.push(() => {
resizeAbort.abort();
});
// Restore collapsed state from localStorage
const savedCollapsed = localStorage.getItem("owncord:member-list-collapsed");
let membersCollapsed = savedCollapsed === "true";
const memberContent = createElement("div", { class: "sidebar-members-content" });
function applyMembersCollapsed(): void {
memberHeader.classList.toggle("collapsed", membersCollapsed);
memberArrow.textContent = membersCollapsed ? "\u25B6" : "\u25BC";
memberContent.style.display = membersCollapsed ? "none" : "";
resizeHandle.style.display = membersCollapsed ? "none" : "";
if (membersCollapsed) {
memberListContainer.style.height = "auto";
} else {
const h = localStorage.getItem("owncord:member-list-height");
if (h !== null) {
memberListContainer.style.height = `${h}px`;
} else {
memberListContainer.style.height = "";
}
}
}
// Apply initial state
applyMembersCollapsed();
memberHeader.addEventListener("click", () => {
membersCollapsed = !membersCollapsed;
localStorage.setItem("owncord:member-list-collapsed", String(membersCollapsed));
applyMembersCollapsed();
});
const memberList = createMemberList({
currentUserRole: authStore.getState().user?.role ?? "member",
onKick: async (userId, username) => {
try {
await api.adminKickMember(userId);
getToast()?.show(`Kicked ${username}`, "success");
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to kick member";
getToast()?.show(msg, "error");
}
},
onBan: async (userId, username) => {
try {
await api.adminBanMember(userId);
getToast()?.show(`Banned ${username}`, "success");
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to ban member";
getToast()?.show(msg, "error");
}
},
onChangeRole: async (userId, username, newRole) => {
const roleId = getRoleIdByName(newRole);
if (roleId === undefined) return;
try {
await api.adminChangeRole(userId, roleId);
getToast()?.show(`Changed ${username}'s role to ${newRole}`, "success");
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to change role";
getToast()?.show(msg, "error");
}
},
});
memberList.mount(memberContent);
memberListContainer.appendChild(memberContent);
contentSlot.appendChild(memberListContainer);
channelModeExtras.push(memberList);
// Same wiring lives in SidebarMemberSection; this used to be a private
// copy of it, and a fix to one silently missed the other.
const memberSection = createSidebarMemberSection({ api, getToast });
contentSlot.appendChild(memberSection.element);
channelModeExtras.push(memberSection.memberListComponent);
channelModeUnsubs.push(memberSection.destroy);
} else {
const dmSidebar = buildDmSidebar();
dmSidebar.mount(innerSlot);
@@ -75,6 +75,7 @@ export function addDmToChannelsStore(dmChannel: DmChannel): void {
// Channel-level permission is always true for DMs; block state is layered on
// top by the composer via blocks.store (see ChannelController), not canSend.
canSend: true,
slowMode: 0,
};
channelsStore.setState((prev) => {
const next = new Map(prev.channels);
@@ -156,9 +156,9 @@ export function createSidebarMemberSection(
getToast()?.show(msg, "error");
}
},
onBan: async (userId, username) => {
onBan: async (userId, username, reason) => {
try {
await api.adminBanMember(userId);
await api.adminBanMember(userId, reason);
getToast()?.show(`Banned ${username}`, "success");
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to ban member";
@@ -167,7 +167,11 @@ export function createSidebarMemberSection(
},
onChangeRole: async (userId, username, newRole) => {
const roleId = getRoleIdByName(newRole);
if (roleId === undefined) return;
if (roleId === undefined) {
// No silent failures: the role vanished from the server's list.
getToast()?.show(`Unknown role "${newRole}" — try reconnecting`, "error");
return;
}
try {
await api.adminChangeRole(userId, roleId);
getToast()?.show(`Changed ${username}'s role to ${newRole}`, "success");
+16 -3
View File
@@ -5,9 +5,11 @@
import { createStore } from "@lib/store";
import type { UserWithRole } from "@lib/types";
import { resetVoiceStore } from "@stores/voice.store";
import { leaveVoice } from "@lib/livekitSession";
import { resetVoiceStore, voiceStore } from "@stores/voice.store";
import { cleanupNotificationAudio } from "@lib/notifications";
import { createLogger } from "@lib/logger";
const log = createLogger("auth.store");
export interface AuthState {
readonly token: string | null;
@@ -42,7 +44,18 @@ export function setAuth(token: string, user: UserWithRole, serverName: string, m
* session (WebRTC, AudioContext, streams) and clears voice store state.
* Safe to call even if no voice session is active — leaveVoice is idempotent. */
export function clearAuth(): void {
leaveVoice(false);
// livekitSession (and the ~1.3 MB livekit-client SDK behind it) is loaded
// lazily so it stays out of the startup path. Only import it when there is
// actually a voice session to leave — otherwise a text-only user who never
// joined voice would pull in the whole LiveKit SDK on every logout/401.
// When a voice session exists the module is necessarily already loaded, so
// this import resolves from the module cache in a microtask.
const voice = voiceStore.getState();
if (voice.currentChannelId !== null && voice.voiceStatus !== "idle") {
void import("@lib/livekitSession")
.then(({ leaveVoice }) => leaveVoice(false))
.catch((e) => log.warn("Failed to leave voice session during clearAuth", e));
}
resetVoiceStore();
cleanupNotificationAudio();
authStore.setState(() => ({ ...INITIAL_STATE }));
@@ -22,6 +22,8 @@ export interface Channel {
readonly lastMessageId: number | null;
/** Whether the current user may post here (drives the composer affordance). */
readonly canSend: boolean;
/** Per-channel cooldown in seconds (0 = off). Drives the composer countdown. */
readonly slowMode: number;
}
export interface ChannelsState {
@@ -53,6 +55,7 @@ export function setChannels(channels: readonly ReadyChannel[]): void {
// The current server always sends can_send; older servers omit it, in
// which case we default permissive (no gating) rather than guessing.
canSend: ch.can_send ?? true,
slowMode: ch.slow_mode ?? 0,
});
}
channelsStore.setState((prev) => ({
@@ -88,6 +91,7 @@ export function addChannel(channel: ChannelCreatePayload): void {
// Broadcasts carry no per-user data; default permissive. The next ready
// payload delivers the authoritative can_send. Server enforces regardless.
canSend: true,
slowMode: channel.slow_mode ?? 0,
});
return { ...prev, channels: next };
});
@@ -104,6 +108,7 @@ export function updateChannel(update: ChannelUpdatePayload): void {
...existing,
...(update.name !== undefined ? { name: update.name } : {}),
...(update.position !== undefined ? { position: update.position } : {}),
...(update.slow_mode !== undefined ? { slowMode: update.slow_mode } : {}),
};
const next = new Map(prev.channels);
next.set(update.id, updated);
@@ -21,11 +21,18 @@ export interface Member {
export interface MembersState {
readonly members: ReadonlyMap<number, Member>;
readonly typingUsers: ReadonlyMap<number, ReadonlySet<number>>; // channelId -> Set<userId>
/** Monotonic counter bumped only when membership or a member's role changes
* (setMembers/addMember/removeMember/updateMemberRole). Subscribers that
* only care about role composition (e.g. MessageList role colors) select
* this instead of rebuilding a role map on every presence/typing update.
* Optional only so the many inline test fixtures need not restate it. */
readonly roleRevision?: number;
}
const INITIAL_STATE: MembersState = {
members: new Map(),
typingUsers: new Map(),
roleRevision: 0,
};
export const membersStore = createStore<MembersState>(INITIAL_STATE);
@@ -57,9 +64,10 @@ export function setMembers(members: readonly ReadyMember[]): void {
clearTimeout(timer);
}
typingTimers.clear();
membersStore.setState(() => ({
membersStore.setState((prev) => ({
members: map,
typingUsers: new Map(),
roleRevision: (prev.roleRevision ?? 0) + 1,
}));
}
@@ -75,7 +83,7 @@ export function addMember(payload: MemberJoinPayload): void {
status: "online",
identityPublicKey: payload.user.identity_public_key ?? null,
});
return { ...prev, members: next };
return { ...prev, members: next, roleRevision: (prev.roleRevision ?? 0) + 1 };
});
}
@@ -84,7 +92,7 @@ export function removeMember(userId: number): void {
membersStore.setState((prev) => {
const next = new Map(prev.members);
next.delete(userId);
return { ...prev, members: next };
return { ...prev, members: next, roleRevision: (prev.roleRevision ?? 0) + 1 };
});
}
@@ -95,7 +103,7 @@ export function updateMemberRole(userId: number, role: string): void {
if (!existing) return prev;
const next = new Map(prev.members);
next.set(userId, { ...existing, role });
return { ...prev, members: next };
return { ...prev, members: next, roleRevision: (prev.roleRevision ?? 0) + 1 };
});
}
+36
View File
@@ -1476,6 +1476,33 @@
.context-menu-item.danger:hover { background: var(--red); color: white; }
.context-menu-sep { height: 1px; background: var(--border); margin: 4px 0; }
/* AdminActions (member/channel menus) uses BEM names — these had no rules at
all, so those menus rendered unstyled: no hover, no danger colour, and a
"submenu" that pushed the menu open instead of flying out. */
.context-menu__item {
position: relative;
display: flex; align-items: center; gap: 8px;
padding: 8px 10px; border-radius: var(--radius-sm);
cursor: pointer; font-size: 13px; color: var(--text-normal);
background: transparent; width: 100%; text-align: left;
transition: background 0.1s ease, color 0.1s ease;
}
.context-menu__item:hover { background: var(--accent); color: white; }
.context-menu__item--active { color: var(--accent); font-weight: 600; }
.context-menu__item--active:hover { color: white; }
.context-menu__item--danger { color: var(--red); }
.context-menu__item--danger:hover { background: var(--red); color: white; }
.context-menu__item--pending { opacity: 0.7; cursor: default; pointer-events: none; }
.context-menu__separator { height: 1px; background: var(--border); margin: 4px 0; }
.context-menu__submenu {
position: absolute; left: 100%; top: 0;
background: var(--bg-primary); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 4px;
box-shadow: 0 8px 24px rgba(0,0,0,.5);
min-width: 140px; z-index: 1;
}
.context-menu__reason { display: flex; flex-direction: column; gap: 6px; }
/* ── Toast Notification ── */
.toast-container {
position: fixed; bottom: 24px; left: 50%;
@@ -2413,6 +2440,15 @@
.invite-item__revoke:hover {
background: rgba(242, 63, 67, 0.15);
}
.invite-item__revoke--confirming {
background: rgba(242, 63, 67, 0.2);
font-weight: 600;
}
.invite-item__revoke:disabled,
.invite-manager__create:disabled {
opacity: 0.6;
cursor: default;
}
.invite-item__meta {
font-size: 12px;
color: var(--text-muted);
+55 -8
View File
@@ -198,9 +198,16 @@ export const MOCK_MESSAGES_RICH = {
has_more: true,
};
// Remote users only — the ready payload must never claim the LOCAL user
// (id 1) is in a voice channel: the dispatcher treats "self in
// ready.voice_states while voiceStatus is idle" as stale state from a
// reload and immediately sends voice_leave + clears the local store
// (dispatcher.ts stale-voice cleanup), which would hide the widget again.
// Tests that need the widget visible must join via the real click path
// (see joinVoiceChannelByName).
export const MOCK_VOICE_STATE = [
{ user_id: 1, channel_id: 10, muted: false, deafened: false },
{ user_id: 2, channel_id: 10, muted: true, deafened: false },
{ user_id: 3, channel_id: 10, muted: false, deafened: false },
];
export const MOCK_PINNED_MESSAGES = {
@@ -350,17 +357,21 @@ export function voiceWsHandlers(): Array<{ type: string; handler: string }> {
handler: `
var p = parsed.payload;
setTimeout(function() {
// Full VoiceStatePayload shape — the server always sends username
// (and the flag fields); the sidebar renders user.username directly,
// so an omitted username breaks the voice-user list render.
__tauriEmitEvent("ws-message", JSON.stringify({
type: "voice_state",
payload: { user_id: 1, channel_id: p.channel_id, muted: false, deafened: false }
payload: { user_id: 1, channel_id: p.channel_id, username: "testuser", muted: false, deafened: false, speaking: false, camera: false, screenshare: false }
}));
}, 50);
setTimeout(function() {
__tauriEmitEvent("ws-message", JSON.stringify({
type: "voice_token",
payload: { token: "mock-livekit-token", url: "ws://localhost:7880", channel_id: p.channel_id, direct_url: "" }
}));
}, 100);
// Deliberately NO voice_token reply: a token makes the client start a
// real LiveKit session, which in the browser mock deterministically
// self-destructs (E2EE key exchange times out after ~15s, and
// Room.connect to the fake port fails after ~3 retries), tearing the
// widget down mid-test. These web tests validate the WS/UI layer only
// (see voice-lifecycle.spec.ts header); real LiveKit is covered by the
// native suite.
`,
},
{
@@ -607,6 +618,18 @@ export function buildTauriMockScript(opts: {
}
if (cmd === "ws_disconnect") return;
// ---- HTTP TOFU proxy ----
// api.ts routes all REST calls through the Rust loopback proxy:
// baseUrl() awaits start_http_proxy and builds
// http://127.0.0.1:{port}/api/v1/... — if this returns null (the
// unhandled-command fallback), the URL gets a literal "null" port and
// Request construction throws before the plugin:http mock above is
// ever consulted, failing every login. Any numeric port works: the
// transport is still plugin:http|fetch and route matching is
// substring-based, so the fake origin never has to be listened on.
if (cmd === "start_http_proxy") return 45123;
if (cmd === "stop_http_proxy") return;
// ---- LiveKit proxy ----
if (cmd === "start_livekit_proxy") return { port: 7880 };
if (cmd === "stop_livekit_proxy") return;
@@ -620,6 +643,14 @@ export function buildTauriMockScript(opts: {
// ---- Certs ----
if (cmd === "store_cert_fingerprint" || cmd === "get_cert_fingerprint") return null;
if (cmd === "accept_cert_fingerprint") return null;
// ---- E2EE identity (keyring blob + TOFU pins) ----
// null = "no stored key/pin". ensureIdentityKeyPublished on the ready
// event is fire-and-forget (void), so a null store is safe and just
// exercises the fresh-key path.
if (cmd === "save_identity_key" || cmd === "load_identity_key" || cmd === "delete_identity_key") return null;
if (cmd === "store_identity_pin" || cmd === "get_identity_pin") return null;
// ---- Window/webview plugin stubs ----
if (cmd.startsWith("plugin:window|") || cmd.startsWith("plugin:webview|")) return null;
@@ -889,6 +920,22 @@ export async function navigateToMainPageReady(page: Page): Promise<void> {
await waitForWsReady(page);
}
/**
* Join a voice channel through the real click path and wait for the voice
* widget to become visible. This is the only supported way for tests to get
* the local user into voice: pre-seeding the ready payload with user 1 no
* longer works (the dispatcher's stale-voice cleanup immediately leaves).
*/
export async function joinVoiceChannelByName(
page: Page,
channelName = "Voice Chat",
): Promise<void> {
await page.locator(".channel-item.voice", { hasText: channelName }).click();
await expect(page.locator("[data-testid='voice-widget']")).toHaveClass(/visible/, {
timeout: 10_000,
});
}
/**
* Emit a WS message and wait for a DOM change to confirm it was processed.
* Prevents flakiness from tests asserting before the message handler runs.
+20 -17
View File
@@ -1,6 +1,7 @@
import { test, expect } from "@playwright/test";
import {
mockTauriFullSession,
mockTauriFullSessionWithMessagesAndEcho,
mockTauriFullSessionWithFailingMessages,
navigateToMainPage,
emitWsEvent,
@@ -11,35 +12,37 @@ import {
// ---------------------------------------------------------------------------
test.describe("Toast Notifications", () => {
test("toast appears when message load fails (500 response)", async ({ page }) => {
// Message-load failure no longer toasts: the app renders an inline
// section error + Retry in the message region instead (UX spec §2 — a
// toast would vanish and leave the region silently empty). See
// MessageController.loadMessages.
test("message load failure (500) shows inline error with Retry", async ({ page }) => {
await mockTauriFullSessionWithFailingMessages(page);
await page.goto("/");
await navigateToMainPage(page);
// The toast container should exist in the DOM
const toastContainer = page.locator("[data-testid='toast-container']");
await expect(toastContainer).toBeAttached({ timeout: 5_000 });
const loadError = page.locator(".messages-load-error");
await expect(loadError).toBeVisible({ timeout: 10_000 });
await expect(loadError).toContainText(/couldn't load messages/i);
// An error toast should appear because /messages returns 500
const toast = page.locator("[data-testid='toast']");
await expect(toast.first()).toBeVisible({ timeout: 10_000 });
// Toast should have the error type class
await expect(toast.first()).toHaveClass(/toast-error/);
// Toast text should mention failure
const text = await toast.first().textContent();
expect(text).toMatch(/fail/i);
const retryBtn = page.locator("[data-testid='messages-retry']");
await expect(retryBtn).toBeVisible();
});
test("toast auto-dismisses after timeout", async ({ page }) => {
await mockTauriFullSessionWithFailingMessages(page);
// Trigger a real toast through the delete-confirmation flow: the first
// click on a message's Delete action shows the info toast
// "Click delete again to confirm".
await mockTauriFullSessionWithMessagesAndEcho(page);
await page.goto("/");
await navigateToMainPage(page);
// Wait for the error toast to appear
const ownMessage = page.locator("[data-testid='message-101']");
await ownMessage.hover();
await page.locator("[data-testid='msg-delete-101']").click();
const toast = page.locator("[data-testid='toast']");
await expect(toast.first()).toBeVisible({ timeout: 10_000 });
await expect(toast.first()).toBeVisible({ timeout: 5_000 });
// Default duration is 5000ms; toast gets .show removed then transitions out.
// Wait for toast to disappear (5s timeout + 400ms fallback removal)
@@ -4,7 +4,11 @@
* VoiceWidget shows connected users when in a voice channel.
*/
import { test, expect } from "@playwright/test";
import { mockTauriFullSessionWithVoice, navigateToMainPage } from "./helpers";
import {
mockTauriFullSessionWithVoice,
navigateToMainPage,
joinVoiceChannelByName,
} from "./helpers";
test.describe("Voice Channel Items", () => {
test.beforeEach(async ({ page }) => {
@@ -31,16 +35,20 @@ test.describe("Voice Channel Items", () => {
});
test("voice widget shows when connected", async ({ page }) => {
// VoiceWidget should be visible (mock connects user to voice channel)
// Join through the real click path — the ready payload can no longer
// pre-connect the local user (stale-voice cleanup would leave again).
await joinVoiceChannelByName(page);
const widget = page.locator(".voice-widget.visible");
await expect(widget).toBeVisible({ timeout: 5000 });
});
test("voice widget shows connected users", async ({ page }) => {
// Mock voice state has 2 users in channel 10 (Voice Chat)
// Two remote users (2, 3) are in channel 10 from the ready payload;
// joining adds the local user for a total of three.
await joinVoiceChannelByName(page);
const voiceUsers = page.locator(".voice-user-item");
await expect(voiceUsers.first()).toBeVisible({ timeout: 5000 });
await expect(voiceUsers).toHaveCount(2);
await expect(voiceUsers).toHaveCount(3);
});
test("voice user item shows avatar", async ({ page }) => {
@@ -55,16 +63,19 @@ test.describe("Voice Channel Items", () => {
});
test("voice widget shows channel name header", async ({ page }) => {
await joinVoiceChannelByName(page);
const channelName = page.locator(".vw-channel");
await expect(channelName).toContainText("Voice Chat");
});
test("voice widget has disconnect control", async ({ page }) => {
await joinVoiceChannelByName(page);
const disconnectBtn = page.locator("button[aria-label='Disconnect']");
await expect(disconnectBtn).toBeVisible({ timeout: 5000 });
});
test("mute button toggles active state on click", async ({ page }) => {
await joinVoiceChannelByName(page);
const controls = page.locator(".vw-controls");
await expect(controls).toBeVisible({ timeout: 5000 });
@@ -78,6 +89,7 @@ test.describe("Voice Channel Items", () => {
});
test("deafen button toggles active state on click", async ({ page }) => {
await joinVoiceChannelByName(page);
const controls = page.locator(".vw-controls");
await expect(controls).toBeVisible({ timeout: 5000 });
@@ -90,6 +102,7 @@ test.describe("Voice Channel Items", () => {
});
test("all five voice control buttons are present", async ({ page }) => {
await joinVoiceChannelByName(page);
const controls = page.locator(".vw-controls");
await expect(controls).toBeVisible({ timeout: 5000 });
@@ -16,6 +16,7 @@ import {
mockTauriFullSessionWithVoice,
mockTauriFullSessionWithVoiceFailure,
navigateToMainPageReady,
joinVoiceChannelByName,
emitWsMessage,
} from "./helpers";
@@ -27,7 +28,7 @@ test.describe("Voice lifecycle", () => {
});
test("shows voice users in voice channel sidebar", async ({ page }) => {
// MOCK_VOICE_STATE has users 1 and 2 in channel 10 ("Voice Chat")
// MOCK_VOICE_STATE has remote users 2 and 3 in channel 10 ("Voice Chat")
const voiceChannel = page.locator(".channel-item", { hasText: "Voice Chat" });
await expect(voiceChannel).toBeVisible();
@@ -72,12 +73,12 @@ test.describe("Voice lifecycle", () => {
// Wait for voice users to render
await expect(page.locator(".voice-user-item")).toHaveCount(2, { timeout: 5000 });
// Emit speakers event — user 1 is speaking
// Emit speakers event — user 3 (in channel 10 per the ready payload) speaks
await emitWsMessage(page, {
type: "voice_speakers",
payload: {
channel_id: 10,
speakers: [1],
speakers: [3],
},
});
@@ -92,7 +93,7 @@ test.describe("Voice lifecycle", () => {
// User starts speaking
await emitWsMessage(page, {
type: "voice_speakers",
payload: { channel_id: 10, speakers: [1] },
payload: { channel_id: 10, speakers: [3] },
});
await expect(page.locator(".voice-user-item.speaking")).toBeVisible({ timeout: 5000 });
@@ -143,6 +144,7 @@ test.describe("Voice widget", () => {
});
test("voice widget stats pane toggles on signal click", async ({ page }) => {
await joinVoiceChannelByName(page);
const signal = page.locator(".vw-signal");
const statsPane = page.locator(".vw-stats");
@@ -160,9 +162,11 @@ test.describe("Voice widget", () => {
});
test.describe("Voice WS flow", () => {
// MOCK_VOICE_STATE puts user 1 in channel 10 ("Voice Chat") during the
// ready payload, so the widget is ALREADY visible when tests start.
// Clicking "Voice Chat" toggles (leaves), clicking "Music" joins channel 11.
// The local user starts OUTSIDE voice: pre-seeding ready.voice_states with
// user 1 no longer works — the dispatcher's stale-voice cleanup would send
// voice_leave and clear the store immediately. Tests join via the real
// click path (joinVoiceChannelByName) and the widget shows because
// joinVoiceChannel() sets currentChannelId synchronously on click.
test.beforeEach(async ({ page }) => {
await mockTauriFullSessionWithVoice(page);
@@ -170,30 +174,29 @@ test.describe("Voice WS flow", () => {
await navigateToMainPageReady(page);
});
// 1. Voice join flow — leave first, then join a different channel.
// 1. Voice join flow — join, leave, then join a different channel.
test("joining a voice channel shows the widget", async ({ page }) => {
const widget = page.locator("[data-testid='voice-widget']");
// Widget is already visible (user 1 in channel 10 from MOCK_VOICE_STATE)
await expect(widget).toHaveClass(/visible/, { timeout: 5_000 });
// Not in voice at start
await expect(widget).not.toHaveClass(/visible/);
// Leave current channel via Disconnect
// Join "Voice Chat" (channel 10)
await joinVoiceChannelByName(page, "Voice Chat");
// Leave via Disconnect
const disconnectBtn = widget.locator("button[aria-label='Disconnect']");
await disconnectBtn.click();
await expect(widget).not.toHaveClass(/visible/, { timeout: 5_000 });
// Join "Music" (channel 11, user is NOT in it)
const musicChannel = page.locator(".channel-item.voice", { hasText: "Music" });
await musicChannel.click();
// joinVoiceChannel sets currentChannelId immediately → widget gets .visible
await expect(widget).toHaveClass(/visible/, { timeout: 10_000 });
// Join "Music" (channel 11)
await joinVoiceChannelByName(page, "Music");
});
// 2. Voice leave flow — widget is already visible; clicking Disconnect hides it.
// 2. Voice leave flow — join, then clicking Disconnect hides the widget.
test("clicking disconnect hides voice widget", async ({ page }) => {
await joinVoiceChannelByName(page);
const widget = page.locator("[data-testid='voice-widget']");
await expect(widget).toHaveClass(/visible/, { timeout: 5_000 });
const disconnectBtn = widget.locator("button[aria-label='Disconnect']");
await disconnectBtn.click();
@@ -207,7 +210,7 @@ test.describe("Voice WS flow", () => {
await emitWsMessage(page, {
type: "voice_speakers",
payload: { channel_id: 10, speakers: [1] },
payload: { channel_id: 10, speakers: [3] },
});
await expect(page.locator(".voice-user-item.speaking")).toBeVisible({ timeout: 5000 });
@@ -216,8 +219,8 @@ test.describe("Voice WS flow", () => {
// 4. Permission recovery button — grant mic button appears when
// listenOnly is true (display toggled via voice store subscription).
test("grant mic button appears in listen-only mode", async ({ page }) => {
await joinVoiceChannelByName(page);
const widget = page.locator("[data-testid='voice-widget']");
await expect(widget).toHaveClass(/visible/, { timeout: 5_000 });
// Set listen-only mode by manipulating the DOM directly (store isn't
// exposed on window; listenOnly is set by livekitSession on mic failure).
@@ -250,8 +253,8 @@ test.describe("Voice WS flow", () => {
// 6. Connection quality warning — stats pane auto-expands on quality degradation.
test("quality degradation auto-expands stats pane", async ({ page }) => {
await joinVoiceChannelByName(page);
const widget = page.locator("[data-testid='voice-widget']");
await expect(widget).toHaveClass(/visible/, { timeout: 5_000 });
const statsPane = page.locator(".vw-stats");
await expect(statsPane).not.toHaveClass(/visible/);
@@ -268,8 +271,8 @@ test.describe("Voice WS flow", () => {
// 7. Mute/deafen toggle — buttons use aria-pressed and .active-ctrl class.
test("mute and deafen buttons toggle state", async ({ page }) => {
await joinVoiceChannelByName(page);
const widget = page.locator("[data-testid='voice-widget']");
await expect(widget).toHaveClass(/visible/, { timeout: 5_000 });
const muteBtn = widget.locator("button[aria-label='Mute']");
await expect(muteBtn).toHaveAttribute("aria-pressed", "false", { timeout: 5000 });
@@ -284,11 +287,10 @@ test.describe("Voice WS flow", () => {
await expect(deafenBtn).toHaveClass(/active-ctrl/);
});
// 8. Voice timer — joinedAt is set during ready payload processing,
// so the timer is already running when the test starts.
// 8. Voice timer — joinedAt is set by joinVoiceChannel() on click.
test("voice timer shows elapsed time", async ({ page }) => {
await joinVoiceChannelByName(page);
const widget = page.locator("[data-testid='voice-widget']");
await expect(widget).toHaveClass(/visible/, { timeout: 5_000 });
const timer = widget.locator(".vw-timer");
await expect(timer).toBeVisible({ timeout: 5000 });
@@ -297,8 +299,8 @@ test.describe("Voice WS flow", () => {
// 9. Token refresh — emitting a new voice_token doesn't disconnect.
test("token refresh does not disconnect session", async ({ page }) => {
await joinVoiceChannelByName(page);
const widget = page.locator("[data-testid='voice-widget']");
await expect(widget).toHaveClass(/visible/, { timeout: 5_000 });
await emitWsMessage(page, {
type: "voice_token",
@@ -321,9 +323,9 @@ test.describe("Voice WS flow", () => {
await emitWsMessage(page, {
type: "voice_state",
payload: {
user_id: 1,
user_id: 3,
channel_id: 10,
username: "testuser",
username: "member1",
muted: false,
deafened: false,
speaking: false,
@@ -336,26 +338,24 @@ test.describe("Voice WS flow", () => {
await expect(cameraIndicator).toBeVisible({ timeout: 5000 });
});
// 11. Re-join after leave — leave via Disconnect, then re-join.
// 11. Re-join after leave — join, leave via Disconnect, then re-join.
test("can rejoin voice channel after leaving", async ({ page }) => {
await joinVoiceChannelByName(page);
const widget = page.locator("[data-testid='voice-widget']");
await expect(widget).toHaveClass(/visible/, { timeout: 5_000 });
// Leave voice
const disconnectBtn = widget.locator("button[aria-label='Disconnect']");
await disconnectBtn.click();
await expect(widget).not.toHaveClass(/visible/, { timeout: 5_000 });
// Re-join by clicking "Voice Chat" (now user is NOT in it)
const voiceChannel = page.locator(".channel-item.voice", { hasText: "Voice Chat" });
await voiceChannel.click();
await expect(widget).toHaveClass(/visible/, { timeout: 10_000 });
// Re-join
await joinVoiceChannelByName(page, "Voice Chat");
});
// 12. Channel switch — already in Voice Chat, click Music to switch.
// 12. Channel switch — join Voice Chat, click Music to switch.
test("switching voice channels updates channel name", async ({ page }) => {
await joinVoiceChannelByName(page, "Voice Chat");
const widget = page.locator("[data-testid='voice-widget']");
await expect(widget).toHaveClass(/visible/, { timeout: 5_000 });
// Verify initial channel name
await expect(widget.locator(".vw-channel")).toHaveText("Voice Chat", { timeout: 5000 });
@@ -378,26 +378,19 @@ test.describe("Voice WS flow — failure", () => {
await navigateToMainPageReady(page);
});
// 13. Voice join failure — leave first (user starts in channel 10),
// then join Music which triggers the failure handler.
// 13. Voice join failure — join Music, which triggers the failure handler.
test("voice join failure does not crash and disconnect still works", async ({ page }) => {
const widget = page.locator("[data-testid='voice-widget']");
await expect(widget).not.toHaveClass(/visible/);
// User starts in channel 10 from MOCK_VOICE_STATE — leave first
await expect(widget).toHaveClass(/visible/, { timeout: 5_000 });
const disconnectBtn = widget.locator("button[aria-label='Disconnect']");
await disconnectBtn.click();
await expect(widget).not.toHaveClass(/visible/, { timeout: 5_000 });
// Now join Music — the failure handler will respond with an error
const musicChannel = page.locator(".channel-item.voice", { hasText: "Music" });
await musicChannel.click();
// joinVoiceChannel is called synchronously, so the widget shows immediately
await expect(widget).toHaveClass(/visible/, { timeout: 10_000 });
// Join Music — the failure handler responds with a VOICE_JOIN_FAILED error
// event; joinVoiceChannel is called synchronously on click, so the widget
// shows immediately regardless.
await joinVoiceChannelByName(page, "Music");
// Wait for the error event to be processed — verify app is still functional
// by checking the disconnect button remains clickable
const disconnectBtn = widget.locator("button[aria-label='Disconnect']");
await expect(disconnectBtn).toBeEnabled({ timeout: 5_000 });
await disconnectBtn.click();
await expect(widget).not.toHaveClass(/visible/, { timeout: 5_000 });
@@ -87,11 +87,12 @@ test.describe("Voice Widget", () => {
const usersBefore = await page.locator(".voice-user-item").count();
// Another user joins the voice channel
// Another user joins the voice channel (id 4 — NOT already in
// MOCK_VOICE_STATE, so this is a genuine join, not an in-place update)
await emitWsMessage(page, {
type: "voice_state",
payload: {
user_id: 3,
user_id: 4,
username: "newvoiceuser",
channel_id: 10,
muted: false,
@@ -95,7 +95,7 @@ describe("AdminActions", () => {
result.destroy();
});
it("Ban requires double-click confirmation", () => {
it("Ban asks for a reason before it fires", () => {
const onBan = vi.fn(async () => {});
const { result } = makeMenu({ onBan });
@@ -105,13 +105,97 @@ describe("AdminActions", () => {
) as HTMLDivElement;
banItem.click();
expect(banItem.textContent).toBe("Are you sure?");
expect(onBan).not.toHaveBeenCalled();
banItem.click();
expect(onBan).toHaveBeenCalledOnce();
const reasonInput = result.element.querySelector(
"[data-testid='ban-reason-input']",
) as HTMLInputElement;
expect(reasonInput).not.toBeNull();
reasonInput.value = " spamming ";
const confirm = result.element.querySelector("[data-testid='ban-confirm']") as HTMLDivElement;
confirm.click();
// The server stores and displays the reason, so it's trimmed, not raw.
expect(onBan).toHaveBeenCalledWith("spamming");
result.destroy();
});
it("Ban sends an empty reason when none is typed", () => {
const onBan = vi.fn(async () => {});
const { result } = makeMenu({ onBan });
const banItem = Array.from(
result.element.querySelectorAll(".context-menu__item--danger"),
).find((i) => i.textContent === "Ban") as HTMLDivElement;
banItem.click();
(result.element.querySelector("[data-testid='ban-confirm']") as HTMLDivElement).click();
expect(onBan).toHaveBeenCalledWith("");
result.destroy();
});
it("Ban ignores a second click while the request is in flight", () => {
let release: (() => void) | null = null;
const onBan = vi.fn(
() =>
new Promise<void>((resolve) => {
release = resolve;
}),
);
const { result } = makeMenu({ onBan });
const banItem = Array.from(
result.element.querySelectorAll(".context-menu__item--danger"),
).find((i) => i.textContent === "Ban") as HTMLDivElement;
banItem.click();
const confirm = result.element.querySelector("[data-testid='ban-confirm']") as HTMLDivElement;
confirm.click();
expect(confirm.textContent).toBe("Banning...");
confirm.click();
expect(onBan).toHaveBeenCalledTimes(1);
release!();
result.destroy();
});
it("Kick shows an in-flight state and disarms after a pause", async () => {
vi.useFakeTimers();
try {
let release: (() => void) | null = null;
const onKick = vi.fn(
() =>
new Promise<void>((resolve) => {
release = resolve;
}),
);
const { result } = makeMenu({ onKick });
const kickItem = Array.from(
result.element.querySelectorAll(".context-menu__item--danger"),
).find((i) => i.textContent === "Kick") as HTMLDivElement;
// Armed, then left alone — a stray later click must not kick anyone.
kickItem.click();
expect(kickItem.textContent).toBe("Are you sure?");
vi.advanceTimersByTime(5000);
expect(kickItem.textContent).toBe("Kick");
kickItem.click();
expect(onKick).not.toHaveBeenCalled();
kickItem.click();
expect(onKick).toHaveBeenCalledOnce();
expect(kickItem.textContent).toBe("Kicking...");
release!();
await vi.waitFor(() => {
expect(kickItem.textContent).toBe("Kick");
});
result.destroy();
} finally {
vi.useRealTimers();
}
});
it("renders separator between role and danger items", () => {
const { result } = makeMenu();
const separator = result.element.querySelector(".context-menu__separator");
@@ -360,18 +360,14 @@ describe("AdvancedTab — Toggles & Structure", () => {
expect(toggle.getAttribute("aria-checked")).toBe("false");
});
it("renders Hardware Acceleration toggle defaulting to on", () => {
it("does not offer a Hardware Acceleration toggle that nothing honours", () => {
const section = buildAdvancedTab(ac.signal);
container.appendChild(section);
const rows = container.querySelectorAll(".setting-row");
const hwRow = rows[1]!;
const label = hwRow.querySelector(".setting-label")!;
expect(label.textContent).toBe("Hardware Acceleration");
const toggle = hwRow.querySelector(".toggle")!;
expect(toggle.classList.contains("on")).toBe(true);
expect(toggle.getAttribute("aria-checked")).toBe("true");
const labels = Array.from(container.querySelectorAll(".setting-label")).map(
(l) => l.textContent,
);
expect(labels).not.toContain("Hardware Acceleration");
});
it("toggles Developer Mode on and persists to localStorage", () => {
@@ -170,6 +170,31 @@ describe("AppearanceTab — Accessibility", () => {
expect(hexInput.placeholder).toBe("5865f2");
});
it("keeps a saved accent applied after switching themes", () => {
// applyThemeByName strips every inline custom property from <body>, so the
// accent override has to be re-applied or the theme's own --accent wins.
mockApplyThemeByName.mockImplementation(() => {
const style = document.body.style;
for (let i = style.length - 1; i >= 0; i--) {
const prop = style.item(i);
if (prop.startsWith("--")) style.removeProperty(prop);
}
});
const section = buildAppearanceTab(ac.signal);
container.appendChild(section);
const swatches = container.querySelectorAll(".accent-swatch");
(swatches[1] as HTMLElement).click(); // #57f287
expect(document.body.style.getPropertyValue("--accent")).toBe("#57f287");
const tiles = container.querySelectorAll(".theme-opt");
(tiles[0] as HTMLElement).click(); // switch to "dark"
expect(document.body.style.getPropertyValue("--accent")).toBe("#57f287");
expect(document.documentElement.style.getPropertyValue("--accent")).toBe("#57f287");
});
it("restores a custom active theme without forcing a built-in tile active", () => {
mockGetActiveThemeName.mockReturnValue("custom-sunrise");
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,373 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const { mockLoadPref, mockSavePref } = vi.hoisted(() => ({
mockLoadPref: vi.fn((_key: string, defaultVal: unknown) => defaultVal),
mockSavePref: vi.fn(),
}));
vi.mock("@components/settings/helpers", () => ({
loadPref: (key: string, defaultVal: unknown) => mockLoadPref(key, defaultVal),
savePref: (key: string, val: unknown) => mockSavePref(key, val),
}));
vi.mock("@lib/logger", () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
vi.mock("@lib/noise-suppression", () => ({
createRNNoiseProcessor: vi.fn(),
}));
vi.mock("livekit-client", () => ({
Track: {
Source: {
Microphone: "microphone",
Camera: "camera",
ScreenShare: "screenShare",
ScreenShareAudio: "screenShareAudio",
},
},
}));
import { AudioPipeline } from "../../src/lib/audioPipeline";
describe("AudioPipeline", () => {
let pipeline: AudioPipeline;
beforeEach(() => {
vi.clearAllMocks();
pipeline = new AudioPipeline();
});
describe("setInputVolume", () => {
it("saves clamped volume to preferences", () => {
pipeline.setInputVolume(75);
expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 75);
});
it("clamps to 0-200 range", () => {
pipeline.setInputVolume(-10);
expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 0);
expect(pipeline.inputGain).toBe(0);
pipeline.setInputVolume(250);
expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 200);
expect(pipeline.inputGain).toBe(2.0);
});
it("updates inputGain property", () => {
pipeline.setInputVolume(150);
expect(pipeline.inputGain).toBe(1.5);
});
});
describe("setVoiceSensitivity", () => {
it("saves clamped sensitivity to preferences", () => {
pipeline.setVoiceSensitivity(50);
expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 50);
});
it("clamps to 0-100 range", () => {
pipeline.setVoiceSensitivity(-5);
expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 0);
pipeline.setVoiceSensitivity(150);
expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 100);
});
it("persists sensitivity value even when no pipeline is active", () => {
pipeline.setVoiceSensitivity(50);
expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 50);
// Pipeline is not active so VAD gating remains off
expect(pipeline.isVadGated).toBe(false);
expect(pipeline.isActive).toBe(false);
});
});
describe("updatePipelineGain", () => {
it("leaves gainValue null when no pipeline exists", () => {
pipeline.updatePipelineGain();
expect(pipeline.gainValue).toBeNull();
});
});
describe("setVoiceSensitivity edge cases", () => {
it("sensitivity 100 ungates if previously gated", () => {
(pipeline as any).vadGated = true;
pipeline.setVoiceSensitivity(100);
expect(pipeline.isVadGated).toBe(false);
});
it("sensitivity below 100 does not change gated state without active pipeline", () => {
pipeline.setVoiceSensitivity(50);
// No crash, no active pipeline to start VAD on
expect(pipeline.isVadGated).toBe(false);
});
});
describe("setInputVolume boundary and arithmetic precision", () => {
it("volume 0 produces inputGain exactly 0", () => {
pipeline.setInputVolume(0);
expect(pipeline.inputGain).toBe(0);
expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 0);
});
it("volume 200 produces inputGain exactly 2.0", () => {
pipeline.setInputVolume(200);
expect(pipeline.inputGain).toBe(2.0);
expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 200);
});
it("volume 100 produces inputGain exactly 1.0", () => {
pipeline.setInputVolume(100);
expect(pipeline.inputGain).toBe(1.0);
});
it("volume 1 produces inputGain 0.01", () => {
pipeline.setInputVolume(1);
expect(pipeline.inputGain).toBeCloseTo(0.01, 5);
});
it("negative volume clamps to 0 (not negative)", () => {
pipeline.setInputVolume(-100);
expect(pipeline.inputGain).toBe(0);
expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 0);
});
it("volume above 200 clamps to 200 (not raw value)", () => {
pipeline.setInputVolume(500);
expect(pipeline.inputGain).toBe(2.0);
expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 200);
});
it("volume exactly at lower boundary (0) is saved as 0, not clamped further", () => {
pipeline.setInputVolume(0);
expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 0);
});
it("volume exactly at upper boundary (200) is saved as 200, not clamped further", () => {
pipeline.setInputVolume(200);
expect(mockSavePref).toHaveBeenCalledWith("inputVolume", 200);
});
});
describe("setVoiceSensitivity boundary and arithmetic precision", () => {
it("sensitivity 0 clamps to 0 and saves", () => {
pipeline.setVoiceSensitivity(0);
expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 0);
});
it("sensitivity exactly 100 saves 100", () => {
pipeline.setVoiceSensitivity(100);
expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 100);
});
it("sensitivity exactly 99 saves 99 (below 100 threshold)", () => {
pipeline.setVoiceSensitivity(99);
expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 99);
});
it("sensitivity above 100 clamps to 100", () => {
pipeline.setVoiceSensitivity(200);
expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 100);
});
it("sensitivity below 0 clamps to 0", () => {
pipeline.setVoiceSensitivity(-50);
expect(mockSavePref).toHaveBeenCalledWith("voiceSensitivity", 0);
});
it("sensitivity 100 does NOT ungate when already ungated", () => {
// vadGated is false by default; sensitivity 100 should not crash or change state
expect(pipeline.isVadGated).toBe(false);
pipeline.setVoiceSensitivity(100);
expect(pipeline.isVadGated).toBe(false);
});
it("sensitivity < 100 calls stopVadPolling which ungates, then restarts polling", () => {
(pipeline as any).vadGated = true;
// setVoiceSensitivity calls stopVadPolling() first, which ungates
pipeline.setVoiceSensitivity(99);
// stopVadPolling always ungates if gated
expect(pipeline.isVadGated).toBe(false);
});
it("sensitivity >= 100 ungates immediately without starting VAD", () => {
(pipeline as any).vadGated = true;
pipeline.setVoiceSensitivity(100);
expect(pipeline.isVadGated).toBe(false);
});
});
describe("updatePipelineGain effective gain logic", () => {
let mockGainNode: any;
let mockAudioCtx: any;
beforeEach(() => {
mockGainNode = {
gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() },
connect: vi.fn(),
disconnect: vi.fn(),
};
mockAudioCtx = {
currentTime: 0.5,
resume: vi.fn().mockResolvedValue(undefined),
createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }),
createAnalyser: vi.fn().mockReturnValue({
fftSize: 0,
smoothingTimeConstant: 0,
connect: vi.fn(),
disconnect: vi.fn(),
getFloatTimeDomainData: vi.fn(),
}),
createGain: vi.fn().mockReturnValue(mockGainNode),
createMediaStreamDestination: vi.fn().mockReturnValue({
stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "t" }]) },
disconnect: vi.fn(),
}),
close: vi.fn().mockResolvedValue(undefined),
state: "running",
audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no")) },
};
vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx));
vi.stubGlobal(
"MediaStream",
vi.fn().mockImplementation(() => ({})),
);
});
afterEach(() => {
pipeline.teardownAudioPipeline();
vi.unstubAllGlobals();
});
it("uses setTargetAtTime with smoothing constant 0.015", () => {
const mockRoom = {
localParticipant: {
getTrackPublication: vi.fn().mockReturnValue({
track: {
mediaStreamTrack: { id: "t" },
sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) },
getProcessor: vi.fn(),
},
}),
},
} as any;
pipeline.setRoom(mockRoom);
pipeline.setupAudioPipeline();
mockGainNode.gain.setTargetAtTime.mockClear();
pipeline.setInputVolume(80);
const lastCall =
mockGainNode.gain.setTargetAtTime.mock.calls[
mockGainNode.gain.setTargetAtTime.mock.calls.length - 1
];
expect(lastCall[2]).toBe(0.015); // smoothing time constant
});
it("uses ctx.currentTime as the start time for setTargetAtTime", () => {
const mockRoom = {
localParticipant: {
getTrackPublication: vi.fn().mockReturnValue({
track: {
mediaStreamTrack: { id: "t" },
sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) },
getProcessor: vi.fn(),
},
}),
},
} as any;
pipeline.setRoom(mockRoom);
pipeline.setupAudioPipeline();
mockGainNode.gain.setTargetAtTime.mockClear();
pipeline.setInputVolume(60);
const lastCall =
mockGainNode.gain.setTargetAtTime.mock.calls[
mockGainNode.gain.setTargetAtTime.mock.calls.length - 1
];
expect(lastCall[1]).toBe(0.5); // ctx.currentTime
});
it("gain is currentInputGain when not vadGated", () => {
const mockRoom = {
localParticipant: {
getTrackPublication: vi.fn().mockReturnValue({
track: {
mediaStreamTrack: { id: "t" },
sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) },
getProcessor: vi.fn(),
},
}),
},
} as any;
pipeline.setRoom(mockRoom);
pipeline.setupAudioPipeline();
pipeline.setInputVolume(130);
mockGainNode.gain.setTargetAtTime.mockClear();
pipeline.updatePipelineGain();
const lastCall = mockGainNode.gain.setTargetAtTime.mock.calls[0];
expect(lastCall[0]).toBe(1.3); // 130 / 100
});
it("gain is exactly 0 when vadGated, regardless of inputGain", () => {
const mockRoom = {
localParticipant: {
getTrackPublication: vi.fn().mockReturnValue({
track: {
mediaStreamTrack: { id: "t" },
sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) },
getProcessor: vi.fn(),
},
}),
},
} as any;
pipeline.setRoom(mockRoom);
pipeline.setupAudioPipeline();
pipeline.setInputVolume(200);
(pipeline as any).vadGated = true;
mockGainNode.gain.setTargetAtTime.mockClear();
pipeline.updatePipelineGain();
const lastCall = mockGainNode.gain.setTargetAtTime.mock.calls[0];
expect(lastCall[0]).toBe(0);
});
it("does nothing when audioPipelineGain is null but ctx is not", () => {
// Set pipeline state to have ctx but no gain — simulates partial teardown
(pipeline as any).audioPipelineCtx = mockAudioCtx;
(pipeline as any).audioPipelineGain = null;
mockGainNode.gain.setTargetAtTime.mockClear();
pipeline.updatePipelineGain();
expect(mockGainNode.gain.setTargetAtTime).not.toHaveBeenCalled();
});
it("does nothing when audioPipelineCtx is null but gain is not", () => {
(pipeline as any).audioPipelineGain = mockGainNode;
(pipeline as any).audioPipelineCtx = null;
mockGainNode.gain.setTargetAtTime.mockClear();
pipeline.updatePipelineGain();
expect(mockGainNode.gain.setTargetAtTime).not.toHaveBeenCalled();
});
});
describe("setInputVolume calls updatePipelineGain", () => {
afterEach(() => {
pipeline.teardownAudioPipeline();
vi.unstubAllGlobals();
});
it("calls updatePipelineGain which is no-op without active pipeline", () => {
// No active pipeline — updatePipelineGain should not throw
pipeline.setInputVolume(50);
expect(pipeline.inputGain).toBe(0.5);
expect(pipeline.gainValue).toBeNull(); // no pipeline
});
});
});
@@ -0,0 +1,599 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const { mockLoadPref, mockSavePref } = vi.hoisted(() => ({
mockLoadPref: vi.fn((_key: string, defaultVal: unknown) => defaultVal),
mockSavePref: vi.fn(),
}));
vi.mock("@components/settings/helpers", () => ({
loadPref: (key: string, defaultVal: unknown) => mockLoadPref(key, defaultVal),
savePref: (key: string, val: unknown) => mockSavePref(key, val),
}));
vi.mock("@lib/logger", () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
vi.mock("@lib/noise-suppression", () => ({
createRNNoiseProcessor: vi.fn(),
}));
vi.mock("livekit-client", () => ({
Track: {
Source: {
Microphone: "microphone",
Camera: "camera",
ScreenShare: "screenShare",
ScreenShareAudio: "screenShareAudio",
},
},
}));
import { AudioPipeline } from "../../src/lib/audioPipeline";
describe("AudioPipeline", () => {
let pipeline: AudioPipeline;
beforeEach(() => {
vi.clearAllMocks();
pipeline = new AudioPipeline();
});
describe("VAD fallback polling", () => {
afterEach(() => {
// Stop VAD first to clear the setTimeout chain before teardown
pipeline.stopVadPolling();
pipeline.teardownAudioPipeline();
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("gates audio after sustained silence", async () => {
vi.useFakeTimers();
const dataArray = new Float32Array(2048);
// Fill with silence
dataArray.fill(0);
const mockAnalyser = {
fftSize: 2048,
smoothingTimeConstant: 0.3,
connect: vi.fn(),
disconnect: vi.fn(),
getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => {
arr.set(dataArray);
}),
};
const mockGainNode = {
gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() },
connect: vi.fn(),
disconnect: vi.fn(),
};
const mockAudioCtx = {
resume: vi.fn().mockResolvedValue(undefined),
createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }),
createAnalyser: vi.fn().mockReturnValue(mockAnalyser),
createGain: vi.fn().mockReturnValue(mockGainNode),
createMediaStreamDestination: vi.fn().mockReturnValue({
stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "track" }]) },
disconnect: vi.fn(),
}),
currentTime: 0,
close: vi.fn().mockResolvedValue(undefined),
state: "running",
audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no worklet")) },
};
vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx));
vi.stubGlobal(
"MediaStream",
vi.fn().mockImplementation(() => ({})),
);
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
if (key === "voiceSensitivity") return 50;
if (key === "inputVolume") return 100;
return defaultVal;
});
const mockRoom = {
localParticipant: {
getTrackPublication: vi.fn().mockReturnValue({
track: {
mediaStreamTrack: { id: "track" },
sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) },
getProcessor: vi.fn(),
setProcessor: vi.fn(),
stopProcessor: vi.fn(),
},
}),
},
} as any;
pipeline.setRoom(mockRoom);
pipeline.setupAudioPipeline();
// Wait for worklet to fail and fallback to start
await vi.advanceTimersByTimeAsync(100);
// Run enough frames to pass startup grace (30 frames * 16ms = 480ms)
// and then enough silent frames to trigger gate (12 frames * 16ms = 192ms)
await vi.advanceTimersByTimeAsync(1200);
expect(pipeline.isVadGated).toBe(true);
});
it("ungates audio after speech is detected", async () => {
vi.useFakeTimers();
let isSilent = true;
const mockAnalyser = {
fftSize: 2048,
smoothingTimeConstant: 0.3,
connect: vi.fn(),
disconnect: vi.fn(),
getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => {
if (isSilent) {
arr.fill(0);
} else {
// Fill with loud signal
for (let i = 0; i < arr.length; i++) arr[i] = 0.5;
}
}),
};
const mockGainNode = {
gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() },
connect: vi.fn(),
disconnect: vi.fn(),
};
const mockAudioCtx = {
resume: vi.fn().mockResolvedValue(undefined),
createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }),
createAnalyser: vi.fn().mockReturnValue(mockAnalyser),
createGain: vi.fn().mockReturnValue(mockGainNode),
createMediaStreamDestination: vi.fn().mockReturnValue({
stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "track" }]) },
disconnect: vi.fn(),
}),
currentTime: 0,
close: vi.fn().mockResolvedValue(undefined),
state: "running",
audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no worklet")) },
};
vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx));
vi.stubGlobal(
"MediaStream",
vi.fn().mockImplementation(() => ({})),
);
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
if (key === "voiceSensitivity") return 50;
if (key === "inputVolume") return 100;
return defaultVal;
});
const mockRoom = {
localParticipant: {
getTrackPublication: vi.fn().mockReturnValue({
track: {
mediaStreamTrack: { id: "track" },
sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) },
getProcessor: vi.fn(),
setProcessor: vi.fn(),
stopProcessor: vi.fn(),
},
}),
},
} as any;
pipeline.setRoom(mockRoom);
pipeline.setupAudioPipeline();
await vi.advanceTimersByTimeAsync(100);
// Gate first with silence
await vi.advanceTimersByTimeAsync(1200);
expect(pipeline.isVadGated).toBe(true);
// Now simulate speech
isSilent = false;
await vi.advanceTimersByTimeAsync(200);
expect(pipeline.isVadGated).toBe(false);
});
});
// --- Mutation-killing tests: boundary conditions, arithmetic, boolean logic ---
describe("VAD fallback frame counters and RMS reporting", () => {
afterEach(() => {
pipeline.stopVadPolling();
pipeline.teardownAudioPipeline();
vi.useRealTimers();
vi.unstubAllGlobals();
});
function setupFallbackPipeline(): { mockAnalyser: any; mockGainNode: any } {
const mockAnalyser = {
fftSize: 2048,
smoothingTimeConstant: 0.3,
connect: vi.fn(),
disconnect: vi.fn(),
getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => {
// Moderate signal — above threshold so we can test non-gating
for (let i = 0; i < arr.length; i++) arr[i] = 0.3;
}),
};
const mockGainNode = {
gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() },
connect: vi.fn(),
disconnect: vi.fn(),
};
const mockAudioCtx = {
resume: vi.fn().mockResolvedValue(undefined),
createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }),
createAnalyser: vi.fn().mockReturnValue(mockAnalyser),
createGain: vi.fn().mockReturnValue(mockGainNode),
createMediaStreamDestination: vi.fn().mockReturnValue({
stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "t" }]) },
disconnect: vi.fn(),
}),
currentTime: 0,
close: vi.fn().mockResolvedValue(undefined),
state: "running",
audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no")) },
};
vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx));
vi.stubGlobal(
"MediaStream",
vi.fn().mockImplementation(() => ({})),
);
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
if (key === "voiceSensitivity") return 50;
if (key === "inputVolume") return 100;
return defaultVal;
});
const mockRoom = {
localParticipant: {
getTrackPublication: vi.fn().mockReturnValue({
track: {
mediaStreamTrack: { id: "t" },
sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) },
getProcessor: vi.fn(),
},
}),
},
} as any;
pipeline.setRoom(mockRoom);
return { mockAnalyser, mockGainNode };
}
it("updates _lastVadRms every 3 frames (frameCounter >= 3 resets)", async () => {
vi.useFakeTimers();
setupFallbackPipeline();
pipeline.setupAudioPipeline();
await vi.advanceTimersByTimeAsync(100); // worklet fails
// RMS for constant 0.3 signal: sqrt(0.09) = 0.3
// After startup grace (30 frames), frameCounter increments 1,2,3 -> reset + update
await vi.advanceTimersByTimeAsync(1000);
// lastVadRms should have been updated to ~0.3 (the RMS of constant 0.3 signal)
expect(pipeline.lastVadRms).toBeGreaterThan(0);
expect(pipeline.lastVadRms).toBeCloseTo(0.3, 1);
});
it("does not gate when rms is above threshold (speech frames accumulate)", async () => {
vi.useFakeTimers();
setupFallbackPipeline(); // signal at 0.3, threshold = 0.05
pipeline.setupAudioPipeline();
await vi.advanceTimersByTimeAsync(100);
await vi.advanceTimersByTimeAsync(1200);
// rms 0.3 > threshold 0.05, so silentFrames never accumulate, no gating
expect(pipeline.isVadGated).toBe(false);
});
it("gate requires exactly GATE_ON_FRAMES (12) consecutive silent frames", async () => {
vi.useFakeTimers();
let frameCount = 0;
const mockAnalyser = {
fftSize: 2048,
smoothingTimeConstant: 0.3,
connect: vi.fn(),
disconnect: vi.fn(),
getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => {
frameCount++;
// After startup grace (30 frames), be silent for exactly 11 frames, then loud
if (frameCount > 30 && frameCount <= 41) {
arr.fill(0); // silent
} else if (frameCount === 42) {
for (let i = 0; i < arr.length; i++) arr[i] = 0.5; // loud — resets counter
} else if (frameCount > 42) {
arr.fill(0); // silent again — needs 12 more to gate
} else {
arr.fill(0); // startup grace
}
}),
};
const mockGainNode = {
gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() },
connect: vi.fn(),
disconnect: vi.fn(),
};
const mockAudioCtx = {
resume: vi.fn().mockResolvedValue(undefined),
createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }),
createAnalyser: vi.fn().mockReturnValue(mockAnalyser),
createGain: vi.fn().mockReturnValue(mockGainNode),
createMediaStreamDestination: vi.fn().mockReturnValue({
stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "t" }]) },
disconnect: vi.fn(),
}),
currentTime: 0,
close: vi.fn().mockResolvedValue(undefined),
state: "running",
audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no")) },
};
vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx));
vi.stubGlobal(
"MediaStream",
vi.fn().mockImplementation(() => ({})),
);
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
if (key === "voiceSensitivity") return 50;
if (key === "inputVolume") return 100;
return defaultVal;
});
const mockRoom = {
localParticipant: {
getTrackPublication: vi.fn().mockReturnValue({
track: {
mediaStreamTrack: { id: "t" },
sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) },
getProcessor: vi.fn(),
},
}),
},
} as any;
pipeline.setRoom(mockRoom);
pipeline.setupAudioPipeline();
await vi.advanceTimersByTimeAsync(100); // worklet fails
// Run through startup (30 frames) + 11 silent + 1 loud = 42 frames * 16ms = 672ms
await vi.advanceTimersByTimeAsync(700);
// After 11 silent frames then 1 loud: should NOT be gated yet (needs 12 consecutive)
// The loud frame resets silentFrames to 0
// Now run 12 more silent frames to trigger gating
await vi.advanceTimersByTimeAsync(250); // 12+ frames * 16ms
expect(pipeline.isVadGated).toBe(true);
});
it("ungate requires GATE_OFF_FRAMES (2) consecutive speech frames after gating", async () => {
vi.useFakeTimers();
let isSilent = true;
const mockAnalyser = {
fftSize: 2048,
smoothingTimeConstant: 0.3,
connect: vi.fn(),
disconnect: vi.fn(),
getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => {
if (isSilent) {
arr.fill(0);
} else {
for (let i = 0; i < arr.length; i++) arr[i] = 0.5;
}
}),
};
const mockGainNode = {
gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() },
connect: vi.fn(),
disconnect: vi.fn(),
};
const mockAudioCtx = {
resume: vi.fn().mockResolvedValue(undefined),
createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }),
createAnalyser: vi.fn().mockReturnValue(mockAnalyser),
createGain: vi.fn().mockReturnValue(mockGainNode),
createMediaStreamDestination: vi.fn().mockReturnValue({
stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "t" }]) },
disconnect: vi.fn(),
}),
currentTime: 0,
close: vi.fn().mockResolvedValue(undefined),
state: "running",
audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no")) },
};
vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx));
vi.stubGlobal(
"MediaStream",
vi.fn().mockImplementation(() => ({})),
);
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
if (key === "voiceSensitivity") return 50;
if (key === "inputVolume") return 100;
return defaultVal;
});
const mockRoom = {
localParticipant: {
getTrackPublication: vi.fn().mockReturnValue({
track: {
mediaStreamTrack: { id: "t" },
sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) },
getProcessor: vi.fn(),
},
}),
},
} as any;
pipeline.setRoom(mockRoom);
pipeline.setupAudioPipeline();
// Wait for worklet to fail and fallback to start
await vi.advanceTimersByTimeAsync(100);
// Gate with silence: startup grace (30*16=480ms) + gate frames (12*16=192ms)
await vi.advanceTimersByTimeAsync(1200);
expect(pipeline.isVadGated).toBe(true);
// Switch to speech — need 2 consecutive speech frames (GATE_OFF_FRAMES) to ungate
isSilent = false;
await vi.advanceTimersByTimeAsync(200); // 2+ frames * 16ms
expect(pipeline.isVadGated).toBe(false);
});
it("startup grace period skips first 30 frames without gating", async () => {
vi.useFakeTimers();
const mockAnalyser = {
fftSize: 2048,
smoothingTimeConstant: 0.3,
connect: vi.fn(),
disconnect: vi.fn(),
getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => {
arr.fill(0); // always silent
}),
};
const mockGainNode = {
gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() },
connect: vi.fn(),
disconnect: vi.fn(),
};
const mockAudioCtx = {
resume: vi.fn().mockResolvedValue(undefined),
createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }),
createAnalyser: vi.fn().mockReturnValue(mockAnalyser),
createGain: vi.fn().mockReturnValue(mockGainNode),
createMediaStreamDestination: vi.fn().mockReturnValue({
stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "t" }]) },
disconnect: vi.fn(),
}),
currentTime: 0,
close: vi.fn().mockResolvedValue(undefined),
state: "running",
audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no")) },
};
vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx));
vi.stubGlobal(
"MediaStream",
vi.fn().mockImplementation(() => ({})),
);
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
if (key === "voiceSensitivity") return 50;
if (key === "inputVolume") return 100;
return defaultVal;
});
const mockRoom = {
localParticipant: {
getTrackPublication: vi.fn().mockReturnValue({
track: {
mediaStreamTrack: { id: "t" },
sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) },
getProcessor: vi.fn(),
},
}),
},
} as any;
pipeline.setRoom(mockRoom);
pipeline.setupAudioPipeline();
await vi.advanceTimersByTimeAsync(100); // worklet fails
// Only run startup grace period: 30 frames * 16ms = 480ms
// Gate needs 12 more frames after grace
await vi.advanceTimersByTimeAsync(480);
// During grace period, no gating should occur despite silence
// But after grace + ~12 frames (192ms), gating occurs
// So at ~580ms from fallback start, should not yet be gated
// (480ms grace + only a few post-grace frames)
// Let's check at exactly the grace boundary
expect(pipeline.isVadGated).toBe(false);
// Now advance past grace + 12 gate frames
await vi.advanceTimersByTimeAsync(300);
expect(pipeline.isVadGated).toBe(true);
});
});
describe("VAD fallback stops when analyser is torn down mid-poll", () => {
afterEach(() => {
pipeline.stopVadPolling();
pipeline.teardownAudioPipeline();
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("poll stops iterating when analyser becomes null", async () => {
vi.useFakeTimers();
const mockAnalyser = {
fftSize: 2048,
smoothingTimeConstant: 0.3,
connect: vi.fn(),
disconnect: vi.fn(),
getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => arr.fill(0)),
};
const mockGainNode = {
gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() },
connect: vi.fn(),
disconnect: vi.fn(),
};
const mockAudioCtx = {
resume: vi.fn().mockResolvedValue(undefined),
createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }),
createAnalyser: vi.fn().mockReturnValue(mockAnalyser),
createGain: vi.fn().mockReturnValue(mockGainNode),
createMediaStreamDestination: vi.fn().mockReturnValue({
stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "t" }]) },
disconnect: vi.fn(),
}),
currentTime: 0,
close: vi.fn().mockResolvedValue(undefined),
state: "running",
audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no")) },
};
vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx));
vi.stubGlobal(
"MediaStream",
vi.fn().mockImplementation(() => ({})),
);
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
if (key === "voiceSensitivity") return 50;
if (key === "inputVolume") return 100;
return defaultVal;
});
const mockRoom = {
localParticipant: {
getTrackPublication: vi.fn().mockReturnValue({
track: {
mediaStreamTrack: { id: "t" },
sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) },
getProcessor: vi.fn(),
},
}),
},
} as any;
pipeline.setRoom(mockRoom);
pipeline.setupAudioPipeline();
await vi.advanceTimersByTimeAsync(100);
// Null out the analyser mid-poll
(pipeline as any).audioPipelineAnalyser = null;
const callsBefore = mockAnalyser.getFloatTimeDomainData.mock.calls.length;
await vi.advanceTimersByTimeAsync(200);
// No new calls should happen since analyser is null
expect(mockAnalyser.getFloatTimeDomainData.mock.calls.length).toBe(callsBefore);
});
});
});
@@ -0,0 +1,698 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const { mockLoadPref, mockSavePref } = vi.hoisted(() => ({
mockLoadPref: vi.fn((_key: string, defaultVal: unknown) => defaultVal),
mockSavePref: vi.fn(),
}));
vi.mock("@components/settings/helpers", () => ({
loadPref: (key: string, defaultVal: unknown) => mockLoadPref(key, defaultVal),
savePref: (key: string, val: unknown) => mockSavePref(key, val),
}));
vi.mock("@lib/logger", () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
vi.mock("@lib/noise-suppression", () => ({
createRNNoiseProcessor: vi.fn(),
}));
vi.mock("livekit-client", () => ({
Track: {
Source: {
Microphone: "microphone",
Camera: "camera",
ScreenShare: "screenShare",
ScreenShareAudio: "screenShareAudio",
},
},
}));
import { AudioPipeline } from "../../src/lib/audioPipeline";
describe("AudioPipeline", () => {
let pipeline: AudioPipeline;
beforeEach(() => {
vi.clearAllMocks();
pipeline = new AudioPipeline();
});
describe("startVadPolling", () => {
it("does not activate VAD without an analyser", () => {
pipeline.startVadPolling();
expect(pipeline.vadUsingWorklet).toBe(false);
expect(pipeline.lastVadRms).toBe(0);
});
});
describe("stopVadPolling", () => {
it("is idempotent when no VAD is running", () => {
pipeline.stopVadPolling();
pipeline.stopVadPolling();
expect(pipeline.lastVadRms).toBe(0);
});
it("resets lastVadRms to 0", () => {
(pipeline as any)._lastVadRms = 0.5;
pipeline.stopVadPolling();
expect(pipeline.lastVadRms).toBe(0);
});
it("ungates if was gated", () => {
(pipeline as any).vadGated = true;
pipeline.stopVadPolling();
expect(pipeline.isVadGated).toBe(false);
});
});
describe("VAD worklet path", () => {
let mockGainNode: any;
let mockAnalyserNode: any;
let mockDestNode: any;
let mockSourceNode: any;
let mockAudioCtx: any;
let mockRoom: any;
afterEach(() => {
pipeline.teardownAudioPipeline();
vi.unstubAllGlobals();
});
function setupPipelineWithWorklet(workletBehavior: "success" | "fail"): void {
mockGainNode = {
gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() },
connect: vi.fn(),
disconnect: vi.fn(),
};
mockAnalyserNode = {
fftSize: 0,
smoothingTimeConstant: 0,
connect: vi.fn(),
disconnect: vi.fn(),
getFloatTimeDomainData: vi.fn(),
};
mockDestNode = {
stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "track" }]) },
disconnect: vi.fn(),
};
mockSourceNode = { connect: vi.fn() };
mockAudioCtx = {
resume: vi.fn().mockResolvedValue(undefined),
createMediaStreamSource: vi.fn().mockReturnValue(mockSourceNode),
createAnalyser: vi.fn().mockReturnValue(mockAnalyserNode),
createGain: vi.fn().mockReturnValue(mockGainNode),
createMediaStreamDestination: vi.fn().mockReturnValue(mockDestNode),
currentTime: 0,
close: vi.fn().mockResolvedValue(undefined),
state: "running",
audioWorklet: {
addModule:
workletBehavior === "success"
? vi.fn().mockResolvedValue(undefined)
: vi.fn().mockRejectedValue(new Error("no worklet")),
},
};
// Mock AudioWorkletNode
vi.stubGlobal(
"AudioWorkletNode",
vi.fn().mockImplementation(() => ({
port: {
postMessage: vi.fn(),
onmessage: null as ((event: MessageEvent) => void) | null,
},
connect: vi.fn(),
disconnect: vi.fn(),
})),
);
vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx));
vi.stubGlobal(
"MediaStream",
vi.fn().mockImplementation(() => ({})),
);
mockRoom = {
localParticipant: {
getTrackPublication: vi.fn().mockReturnValue({
track: {
mediaStreamTrack: { id: "track" },
sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) },
getProcessor: vi.fn(),
setProcessor: vi.fn(),
stopProcessor: vi.fn(),
},
}),
},
};
// Set sensitivity < 100 so VAD polling starts
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
if (key === "voiceSensitivity") return 50;
if (key === "inputVolume") return 100;
return defaultVal;
});
}
it("starts VAD worklet when AudioWorklet addModule succeeds", async () => {
setupPipelineWithWorklet("success");
pipeline.setRoom(mockRoom);
pipeline.setupAudioPipeline();
// Wait for the async addModule to resolve
await vi.waitFor(() => {
expect(pipeline.vadUsingWorklet).toBe(true);
});
});
it("falls back to setTimeout VAD when AudioWorklet addModule fails", async () => {
setupPipelineWithWorklet("fail");
pipeline.setRoom(mockRoom);
pipeline.setupAudioPipeline();
await vi.waitFor(() => {
// After worklet failure, falls back to setTimeout
expect(pipeline.vadUsingWorklet).toBe(false);
});
});
it("worklet gate message toggles VAD gate", async () => {
setupPipelineWithWorklet("success");
pipeline.setRoom(mockRoom);
pipeline.setupAudioPipeline();
await vi.waitFor(() => {
expect(pipeline.vadUsingWorklet).toBe(true);
});
// Get the AudioWorkletNode mock and simulate a gate message
const WorkletNodeConstructor = (globalThis as any).AudioWorkletNode;
const workletInstance = WorkletNodeConstructor.mock.results[0].value;
// Simulate gate message
workletInstance.port.onmessage({ data: { type: "gate", gated: true } } as any);
expect(pipeline.isVadGated).toBe(true);
workletInstance.port.onmessage({ data: { type: "gate", gated: false } } as any);
expect(pipeline.isVadGated).toBe(false);
});
it("worklet rms message updates lastVadRms", async () => {
setupPipelineWithWorklet("success");
pipeline.setRoom(mockRoom);
pipeline.setupAudioPipeline();
await vi.waitFor(() => {
expect(pipeline.vadUsingWorklet).toBe(true);
});
const WorkletNodeConstructor = (globalThis as any).AudioWorkletNode;
const workletInstance = WorkletNodeConstructor.mock.results[0].value;
workletInstance.port.onmessage({ data: { type: "rms", value: 0.42 } } as any);
expect(pipeline.lastVadRms).toBe(0.42);
});
it("stopVadPolling disconnects worklet node", async () => {
setupPipelineWithWorklet("success");
pipeline.setRoom(mockRoom);
pipeline.setupAudioPipeline();
await vi.waitFor(() => {
expect(pipeline.vadUsingWorklet).toBe(true);
});
const WorkletNodeConstructor = (globalThis as any).AudioWorkletNode;
const workletInstance = WorkletNodeConstructor.mock.results[0].value;
pipeline.stopVadPolling();
expect(workletInstance.port.postMessage).toHaveBeenCalledWith({ type: "stop" });
expect(workletInstance.disconnect).toHaveBeenCalled();
expect(pipeline.vadUsingWorklet).toBe(false);
});
it("falls back to setTimeout when AudioWorkletNode constructor throws", async () => {
setupPipelineWithWorklet("success");
// Override AudioWorkletNode to throw
vi.stubGlobal(
"AudioWorkletNode",
vi.fn().mockImplementation(() => {
throw new Error("AudioWorkletNode not supported");
}),
);
pipeline.setRoom(mockRoom);
pipeline.setupAudioPipeline();
await vi.waitFor(() => {
// Should have fallen back to setTimeout
expect(pipeline.vadUsingWorklet).toBe(false);
});
});
});
describe("startVadPolling threshold calculation and sensitivity guard", () => {
let mockAnalyser: any;
let mockGainNode: any;
let mockAudioCtx: any;
afterEach(() => {
pipeline.stopVadPolling();
pipeline.teardownAudioPipeline();
vi.useRealTimers();
vi.unstubAllGlobals();
});
function setupPipelineForVad(sensitivity: number): void {
mockAnalyser = {
fftSize: 2048,
smoothingTimeConstant: 0.3,
connect: vi.fn(),
disconnect: vi.fn(),
getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => arr.fill(0)),
};
mockGainNode = {
gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() },
connect: vi.fn(),
disconnect: vi.fn(),
};
mockAudioCtx = {
resume: vi.fn().mockResolvedValue(undefined),
createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }),
createAnalyser: vi.fn().mockReturnValue(mockAnalyser),
createGain: vi.fn().mockReturnValue(mockGainNode),
createMediaStreamDestination: vi.fn().mockReturnValue({
stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "t" }]) },
disconnect: vi.fn(),
}),
currentTime: 0,
close: vi.fn().mockResolvedValue(undefined),
state: "running",
audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no")) },
};
vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx));
vi.stubGlobal(
"MediaStream",
vi.fn().mockImplementation(() => ({})),
);
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
if (key === "voiceSensitivity") return sensitivity;
if (key === "inputVolume") return 100;
return defaultVal;
});
const mockRoom = {
localParticipant: {
getTrackPublication: vi.fn().mockReturnValue({
track: {
mediaStreamTrack: { id: "t" },
sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) },
getProcessor: vi.fn(),
},
}),
},
} as any;
pipeline.setRoom(mockRoom);
}
it("sensitivity 100 prevents VAD from starting (no polling)", async () => {
vi.useFakeTimers();
setupPipelineForVad(100);
pipeline.setupAudioPipeline();
// Wait for async paths to settle
await vi.advanceTimersByTimeAsync(200);
// VAD should not be running - no gate should happen even after lots of silence
await vi.advanceTimersByTimeAsync(2000);
expect(pipeline.isVadGated).toBe(false);
});
it("sensitivity 99 allows VAD to start and eventually gate silence", async () => {
vi.useFakeTimers();
setupPipelineForVad(99);
pipeline.setupAudioPipeline();
await vi.advanceTimersByTimeAsync(100); // worklet fails, fallback starts
await vi.advanceTimersByTimeAsync(1200); // startup grace + gate frames
expect(pipeline.isVadGated).toBe(true);
});
it("sensitivity 0 produces high threshold that gates easily", async () => {
vi.useFakeTimers();
setupPipelineForVad(0);
// threshold = ((100 - 0) / 100) * 0.1 = 0.1
pipeline.setupAudioPipeline();
await vi.advanceTimersByTimeAsync(100);
await vi.advanceTimersByTimeAsync(1200);
expect(pipeline.isVadGated).toBe(true);
});
it("sensitivity 50 produces threshold 0.05", async () => {
vi.useFakeTimers();
setupPipelineForVad(50);
// threshold = ((100 - 50) / 100) * 0.1 = 0.05
// silence (rms=0) < 0.05, so should gate
pipeline.setupAudioPipeline();
await vi.advanceTimersByTimeAsync(100);
await vi.advanceTimersByTimeAsync(1200);
expect(pipeline.isVadGated).toBe(true);
});
});
describe("pipeline generation prevents stale async results", () => {
afterEach(() => {
pipeline.teardownAudioPipeline();
vi.unstubAllGlobals();
});
it("discards worklet addModule result if pipeline torn down during load", async () => {
let resolveAddModule: () => void;
const addModulePromise = new Promise<void>((resolve) => {
resolveAddModule = resolve;
});
const mockAnalyser = {
fftSize: 0,
smoothingTimeConstant: 0,
connect: vi.fn(),
disconnect: vi.fn(),
getFloatTimeDomainData: vi.fn(),
};
const mockGainNode = {
gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() },
connect: vi.fn(),
disconnect: vi.fn(),
};
const mockAudioCtx = {
resume: vi.fn().mockResolvedValue(undefined),
createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }),
createAnalyser: vi.fn().mockReturnValue(mockAnalyser),
createGain: vi.fn().mockReturnValue(mockGainNode),
createMediaStreamDestination: vi.fn().mockReturnValue({
stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "t" }]) },
disconnect: vi.fn(),
}),
currentTime: 0,
close: vi.fn().mockResolvedValue(undefined),
state: "running",
audioWorklet: { addModule: vi.fn().mockReturnValue(addModulePromise) },
};
vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx));
vi.stubGlobal(
"MediaStream",
vi.fn().mockImplementation(() => ({})),
);
vi.stubGlobal(
"AudioWorkletNode",
vi.fn().mockImplementation(() => ({
port: { postMessage: vi.fn(), onmessage: null },
connect: vi.fn(),
disconnect: vi.fn(),
})),
);
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
if (key === "voiceSensitivity") return 50;
if (key === "inputVolume") return 100;
return defaultVal;
});
const mockRoom = {
localParticipant: {
getTrackPublication: vi.fn().mockReturnValue({
track: {
mediaStreamTrack: { id: "t" },
sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) },
getProcessor: vi.fn(),
},
}),
},
} as any;
pipeline.setRoom(mockRoom);
pipeline.setupAudioPipeline();
// Teardown increments generation, making the pending addModule stale
pipeline.teardownAudioPipeline();
// Now resolve addModule — should be discarded because generation changed
resolveAddModule!();
await addModulePromise;
// Yield to microtasks
await new Promise((r) => setTimeout(r, 0));
// Worklet should NOT have been started (generation mismatch)
expect(pipeline.vadUsingWorklet).toBe(false);
});
});
describe("worklet gate message deduplication", () => {
afterEach(() => {
pipeline.teardownAudioPipeline();
vi.unstubAllGlobals();
});
it("does not call updatePipelineGain when gate state unchanged", async () => {
const mockGainNode = {
gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() },
connect: vi.fn(),
disconnect: vi.fn(),
};
const mockAnalyser = {
fftSize: 0,
smoothingTimeConstant: 0,
connect: vi.fn(),
disconnect: vi.fn(),
getFloatTimeDomainData: vi.fn(),
};
const mockAudioCtx = {
resume: vi.fn().mockResolvedValue(undefined),
createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }),
createAnalyser: vi.fn().mockReturnValue(mockAnalyser),
createGain: vi.fn().mockReturnValue(mockGainNode),
createMediaStreamDestination: vi.fn().mockReturnValue({
stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "t" }]) },
disconnect: vi.fn(),
}),
currentTime: 0,
close: vi.fn().mockResolvedValue(undefined),
state: "running",
audioWorklet: { addModule: vi.fn().mockResolvedValue(undefined) },
};
vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx));
vi.stubGlobal(
"MediaStream",
vi.fn().mockImplementation(() => ({})),
);
vi.stubGlobal(
"AudioWorkletNode",
vi.fn().mockImplementation(() => ({
port: { postMessage: vi.fn(), onmessage: null },
connect: vi.fn(),
disconnect: vi.fn(),
})),
);
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
if (key === "voiceSensitivity") return 50;
if (key === "inputVolume") return 100;
return defaultVal;
});
const mockRoom = {
localParticipant: {
getTrackPublication: vi.fn().mockReturnValue({
track: {
mediaStreamTrack: { id: "t" },
sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) },
getProcessor: vi.fn(),
},
}),
},
} as any;
pipeline.setRoom(mockRoom);
pipeline.setupAudioPipeline();
await vi.waitFor(() => {
expect(pipeline.vadUsingWorklet).toBe(true);
});
const WorkletNodeConstructor = (globalThis as any).AudioWorkletNode;
const workletInstance = WorkletNodeConstructor.mock.results[0].value;
mockGainNode.gain.setTargetAtTime.mockClear();
// Send gate=false when already ungated — should NOT trigger updatePipelineGain
workletInstance.port.onmessage({ data: { type: "gate", gated: false } } as any);
expect(mockGainNode.gain.setTargetAtTime).not.toHaveBeenCalled();
// Send gate=true — should trigger
workletInstance.port.onmessage({ data: { type: "gate", gated: true } } as any);
expect(mockGainNode.gain.setTargetAtTime).toHaveBeenCalled();
mockGainNode.gain.setTargetAtTime.mockClear();
// Send gate=true again — should NOT trigger (already gated)
workletInstance.port.onmessage({ data: { type: "gate", gated: true } } as any);
expect(mockGainNode.gain.setTargetAtTime).not.toHaveBeenCalled();
});
});
describe("worklet sends config with threshold", () => {
afterEach(() => {
pipeline.teardownAudioPipeline();
vi.unstubAllGlobals();
});
it("posts config message with correct threshold to worklet port", async () => {
const mockGainNode = {
gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() },
connect: vi.fn(),
disconnect: vi.fn(),
};
const mockAnalyser = {
fftSize: 0,
smoothingTimeConstant: 0,
connect: vi.fn(),
disconnect: vi.fn(),
getFloatTimeDomainData: vi.fn(),
};
const postMessageSpy = vi.fn();
const mockAudioCtx = {
resume: vi.fn().mockResolvedValue(undefined),
createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }),
createAnalyser: vi.fn().mockReturnValue(mockAnalyser),
createGain: vi.fn().mockReturnValue(mockGainNode),
createMediaStreamDestination: vi.fn().mockReturnValue({
stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "t" }]) },
disconnect: vi.fn(),
}),
currentTime: 0,
close: vi.fn().mockResolvedValue(undefined),
state: "running",
audioWorklet: { addModule: vi.fn().mockResolvedValue(undefined) },
};
vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx));
vi.stubGlobal(
"MediaStream",
vi.fn().mockImplementation(() => ({})),
);
vi.stubGlobal(
"AudioWorkletNode",
vi.fn().mockImplementation(() => ({
port: { postMessage: postMessageSpy, onmessage: null },
connect: vi.fn(),
disconnect: vi.fn(),
})),
);
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
if (key === "voiceSensitivity") return 50; // threshold = ((100-50)/100)*0.1 = 0.05
if (key === "inputVolume") return 100;
return defaultVal;
});
const mockRoom = {
localParticipant: {
getTrackPublication: vi.fn().mockReturnValue({
track: {
mediaStreamTrack: { id: "t" },
sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) },
getProcessor: vi.fn(),
},
}),
},
} as any;
pipeline.setRoom(mockRoom);
pipeline.setupAudioPipeline();
await vi.waitFor(() => {
expect(pipeline.vadUsingWorklet).toBe(true);
});
expect(postMessageSpy).toHaveBeenCalledWith({ type: "config", threshold: 0.05 });
});
});
describe("stopVadPolling clears vadTimer", () => {
afterEach(() => {
pipeline.teardownAudioPipeline();
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("clears the setTimeout-based vadTimer on stop", async () => {
vi.useFakeTimers();
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
const mockAnalyser = {
fftSize: 2048,
smoothingTimeConstant: 0.3,
connect: vi.fn(),
disconnect: vi.fn(),
getFloatTimeDomainData: vi.fn().mockImplementation((arr: Float32Array) => arr.fill(0)),
};
const mockGainNode = {
gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() },
connect: vi.fn(),
disconnect: vi.fn(),
};
const mockAudioCtx = {
resume: vi.fn().mockResolvedValue(undefined),
createMediaStreamSource: vi.fn().mockReturnValue({ connect: vi.fn() }),
createAnalyser: vi.fn().mockReturnValue(mockAnalyser),
createGain: vi.fn().mockReturnValue(mockGainNode),
createMediaStreamDestination: vi.fn().mockReturnValue({
stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "t" }]) },
disconnect: vi.fn(),
}),
currentTime: 0,
close: vi.fn().mockResolvedValue(undefined),
state: "running",
audioWorklet: { addModule: vi.fn().mockRejectedValue(new Error("no")) },
};
vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx));
vi.stubGlobal(
"MediaStream",
vi.fn().mockImplementation(() => ({})),
);
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
if (key === "voiceSensitivity") return 50;
if (key === "inputVolume") return 100;
return defaultVal;
});
const mockRoom = {
localParticipant: {
getTrackPublication: vi.fn().mockReturnValue({
track: {
mediaStreamTrack: { id: "t" },
sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) },
getProcessor: vi.fn(),
},
}),
},
} as any;
pipeline.setRoom(mockRoom);
pipeline.setupAudioPipeline();
await vi.advanceTimersByTimeAsync(100); // fallback starts
clearTimeoutSpy.mockClear();
pipeline.stopVadPolling();
expect(clearTimeoutSpy).toHaveBeenCalled();
clearTimeoutSpy.mockRestore();
});
});
});
File diff suppressed because it is too large Load Diff
@@ -7,8 +7,18 @@ import {
getCurrentUser,
updateUser,
} from "../../src/stores/auth.store";
import { resetVoiceStore, joinVoiceChannel, setVoiceStatus } from "../../src/stores/voice.store";
import { leaveVoice } from "@lib/livekitSession";
import type { UserWithRole } from "../../src/lib/types";
// Mock the lazily-imported voice SDK module so we can assert clearAuth() only
// pulls it in (loading the ~1.3 MB LiveKit chunk) when a voice session exists.
vi.mock("@lib/livekitSession", () => ({
leaveVoice: vi.fn(),
}));
const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0));
const TEST_USER: UserWithRole = {
id: 42,
username: "testuser",
@@ -204,6 +214,30 @@ describe("auth store", () => {
});
});
// clearAuth voice-session cleanup (regression: don't force-load the LiveKit
// chunk on every logout/401 for a text-only user).
describe("clearAuth voice cleanup", () => {
beforeEach(() => {
resetVoiceStore();
vi.mocked(leaveVoice).mockClear();
});
it("does NOT load livekitSession when there is no active voice session", async () => {
// Voice store is idle (currentChannelId null, voiceStatus "idle").
clearAuth();
await flushMicrotasks();
expect(leaveVoice).not.toHaveBeenCalled();
});
it("leaves voice when a voice session is active", async () => {
joinVoiceChannel(7); // currentChannelId=7, voiceStatus="joining"
setVoiceStatus("connected");
clearAuth();
await flushMicrotasks();
expect(leaveVoice).toHaveBeenCalledWith(false);
});
});
// 6. Subscribe receives updates on setAuth/clearAuth
describe("subscribe", () => {
it("notifies on setAuth", () => {
@@ -102,6 +102,8 @@ const { mockSetMessagePinned, mockAddOptimistic, mockMarkSendFailed, mockRemoveO
mockRemoveOptimistic: vi.fn(),
}));
const { mockRole } = vi.hoisted(() => ({ mockRole: { value: "member" } }));
vi.mock("@stores/messages.store", () => ({
getChannelMessages: mockGetChannelMessages,
setMessagePinned: mockSetMessagePinned,
@@ -112,7 +114,7 @@ vi.mock("@stores/messages.store", () => ({
vi.mock("@stores/auth.store", () => ({
authStore: {
getState: () => ({ user: { id: 1, username: "tester", avatar: null } }),
getState: () => ({ user: { id: 1, username: "tester", avatar: null, role: mockRole.value } }),
},
}));
@@ -172,6 +174,7 @@ vi.mock("@stores/blocks.store", () => ({
import { createChannelController } from "../../src/pages/main-page/ChannelController";
import type { ChannelControllerOptions } from "../../src/pages/main-page/ChannelController";
import { setConnectionStatus } from "@stores/ui.store";
import { channelsStore, setChannels, setActiveChannel, setRoles } from "@stores/channels.store";
// ---------------------------------------------------------------------------
// Helpers
@@ -191,6 +194,8 @@ function makeOpts(overrides: Partial<ChannelControllerOptions> = {}): ChannelCon
send: vi.fn(),
getState: vi.fn(() => "connected"),
onStateChange: vi.fn(() => vi.fn()),
// The composer subscribes to chat_send_ok / error to drive slow mode.
on: vi.fn(() => vi.fn()),
} as unknown as ChannelControllerOptions["ws"],
api: {
uploadFile: vi.fn().mockResolvedValue({ id: 1, url: "/f/1", filename: "f.txt" }),
@@ -865,6 +870,123 @@ describe("createChannelController", () => {
});
});
describe("slow mode", () => {
/** Pull a ws.on handler registered by the controller. */
function wsHandler(opts: ChannelControllerOptions, event: string): (payload: never) => void {
const calls = (opts.ws.on as ReturnType<typeof vi.fn>).mock.calls;
const entry = calls.find((c) => c[0] === event);
expect(entry).toBeDefined();
return entry![1] as (payload: never) => void;
}
function seedChannel(slowMode: number): void {
setChannels([
{
id: 42,
name: "general",
type: "text",
category: null,
position: 0,
can_send: true,
slow_mode: slowMode,
},
]);
setActiveChannel(42);
}
beforeEach(() => {
mockRole.value = "member";
setRoles([{ id: 4, name: "member", color: null, permissions: 0 }]);
setConnectionStatus("connected");
});
it("disables the composer for the cooldown after an accepted send", () => {
vi.useFakeTimers();
try {
seedChannel(5);
const opts = makeOpts();
const ctrl = createChannelController(opts);
ctrl.mountChannel(42, "general");
expect(mockSetDisabled).toHaveBeenLastCalledWith(null);
wsHandler(opts, "chat_send_ok")({} as never);
expect(mockSetDisabled).toHaveBeenLastCalledWith("Slow mode — 5s");
vi.advanceTimersByTime(3000);
expect(mockSetDisabled).toHaveBeenLastCalledWith("Slow mode — 2s");
vi.advanceTimersByTime(2000);
expect(mockSetDisabled).toHaveBeenLastCalledWith(null);
} finally {
vi.useRealTimers();
}
});
it("leaves the composer alone in a channel without slow mode", () => {
seedChannel(0);
const opts = makeOpts();
const ctrl = createChannelController(opts);
ctrl.mountChannel(42, "general");
wsHandler(opts, "chat_send_ok")({} as never);
expect(mockSetDisabled).toHaveBeenLastCalledWith(null);
});
it("restarts the cooldown when the server refuses with SLOW_MODE", () => {
vi.useFakeTimers();
try {
seedChannel(10);
const opts = makeOpts();
const ctrl = createChannelController(opts);
ctrl.mountChannel(42, "general");
wsHandler(opts, "error")({ code: "SLOW_MODE", message: "slow mode" } as never);
expect(mockSetDisabled).toHaveBeenLastCalledWith("Slow mode — 10s");
// An unrelated error must not gate the composer.
vi.advanceTimersByTime(10_000);
mockSetDisabled.mockClear();
wsHandler(opts, "error")({ code: "FORBIDDEN", message: "nope" } as never);
expect(mockSetDisabled).not.toHaveBeenCalledWith(expect.stringContaining("Slow mode"));
} finally {
vi.useRealTimers();
}
});
it("does not gate a moderator, who bypasses slow mode server-side", () => {
seedChannel(5);
mockRole.value = "moderator";
setRoles([{ id: 3, name: "moderator", color: null, permissions: 0x10000 }]);
const opts = makeOpts();
const ctrl = createChannelController(opts);
ctrl.mountChannel(42, "general");
wsHandler(opts, "chat_send_ok")({} as never);
expect(mockSetDisabled).toHaveBeenLastCalledWith(null);
});
it("stops the countdown when the channel unmounts", () => {
vi.useFakeTimers();
try {
seedChannel(5);
const opts = makeOpts();
const ctrl = createChannelController(opts);
ctrl.mountChannel(42, "general");
wsHandler(opts, "chat_send_ok")({} as never);
ctrl.destroyChannel();
mockSetDisabled.mockClear();
vi.advanceTimersByTime(5000);
expect(mockSetDisabled).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
});
describe("DM composer block gating", () => {
function mountDm(reason: string | null): void {
mockDmStoreGetState.mockReturnValue({
@@ -655,6 +655,60 @@ describe("ChannelSidebar", () => {
expect(updatedRow!.classList.contains("speaking")).toBe(true);
});
it("speaking patch keeps the exact row element (cached map, no rebuild)", () => {
setChannels(testChannels);
updateVoiceState({
channel_id: 3,
user_id: 61,
username: "Talker2",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
});
sidebar.mount(container);
const rowBefore = container.querySelector('.voice-user-item[data-voice-uid="61"]');
expect(rowBefore).not.toBeNull();
// speaking-only flip → patched via the cached row map, not re-rendered
updateVoiceState({
channel_id: 3,
user_id: 61,
username: "Talker2",
muted: false,
deafened: false,
speaking: true,
camera: false,
screenshare: false,
});
voiceStore.flush();
const rowAfter = container.querySelector('.voice-user-item[data-voice-uid="61"]');
expect(rowAfter).toBe(rowBefore); // same element instance
expect(rowAfter!.classList.contains("speaking")).toBe(true);
// …and a structural change (mute) still re-renders with a fresh row.
updateVoiceState({
channel_id: 3,
user_id: 61,
username: "Talker2",
muted: true,
deafened: false,
speaking: true,
camera: false,
screenshare: false,
});
voiceStore.flush();
const rowRebuilt = container.querySelector('.voice-user-item[data-voice-uid="61"]');
expect(rowRebuilt).not.toBe(rowBefore);
expect(rowRebuilt!.querySelector(".vu-muted")).not.toBeNull();
// The rebuilt row keeps the speaking class (patch runs after re-render).
expect(rowRebuilt!.classList.contains("speaking")).toBe(true);
});
// ── Voice user avatar ──
it("renders first-letter avatar with deterministic color for voice user", () => {
@@ -74,6 +74,7 @@ describe("channels store", () => {
unreadCount: 3,
lastMessageId: 100,
canSend: true,
slowMode: 0,
});
const voice = state.channels.get(2);
@@ -86,6 +87,7 @@ describe("channels store", () => {
unreadCount: 0,
lastMessageId: null,
canSend: true,
slowMode: 0,
});
});
@@ -123,6 +125,7 @@ describe("channels store", () => {
unreadCount: 0,
lastMessageId: null,
canSend: true,
slowMode: 0,
});
});
@@ -286,6 +289,7 @@ describe("channels store", () => {
unreadCount: 0,
lastMessageId: 100,
canSend: true,
slowMode: 0,
});
});
@@ -271,6 +271,18 @@ describe("DeviceManager", () => {
await dm.switchOutputDevice("device-1");
expect(mockRoom.switchActiveDevice).toHaveBeenCalledWith("audiooutput", "device-1");
});
it("reports a failed switch instead of rejecting into the void", async () => {
// The settings tab calls this as a bare `void` — an unhandled rejection
// would leave the user with a selection that silently never applied.
const onError = vi.fn();
dm.setOnError(onError);
dm.setRoom(mockRoom);
mockRoom.switchActiveDevice.mockRejectedValueOnce(new Error("setSinkId unsupported"));
await expect(dm.switchOutputDevice("device-1")).resolves.toBeUndefined();
expect(onError).toHaveBeenCalledWith("Failed to switch speaker");
});
});
// -----------------------------------------------------------------------
@@ -36,9 +36,7 @@ vi.mock("@lib/identity", () => ({
ensureIdentityKeyPublished: vi.fn(async () => true),
}));
import { isVoiceConnected as _isVoiceConnected } from "../../src/lib/livekitSession";
import { ensureIdentityKeyPublished as _ensureIdentityKeyPublished } from "../../src/lib/identity";
const mockIsVoiceConnected = vi.mocked(_isVoiceConnected);
const mockEnsurePublished = vi.mocked(_ensureIdentityKeyPublished);
// Suppress console output
@@ -217,6 +215,7 @@ describe("WS Dispatcher", () => {
unreadCount: 0,
lastMessageId: null,
canSend: true,
slowMode: 0,
});
return { ...prev, channels: ch, activeChannelId: 1 }; // active is channel 1
});
@@ -277,6 +276,7 @@ describe("WS Dispatcher", () => {
unreadCount: 0,
lastMessageId: null,
canSend: true,
slowMode: 0,
});
return { ...prev, channels: ch };
});
@@ -530,6 +530,7 @@ describe("WS Dispatcher", () => {
unreadCount: 0,
lastMessageId: null,
canSend: true,
slowMode: 0,
});
return { ...prev, channels: ch };
});
@@ -558,6 +559,7 @@ describe("WS Dispatcher", () => {
unreadCount: 0,
lastMessageId: null,
canSend: true,
slowMode: 0,
});
ch.set(20, {
id: 20,
@@ -568,6 +570,7 @@ describe("WS Dispatcher", () => {
unreadCount: 0,
lastMessageId: null,
canSend: true,
slowMode: 0,
});
return { ...prev, channels: ch, activeChannelId: 10 };
});
@@ -590,6 +593,7 @@ describe("WS Dispatcher", () => {
unreadCount: 0,
lastMessageId: null,
canSend: true,
slowMode: 0,
});
return { ...prev, channels: ch, activeChannelId: 10 };
});
@@ -725,6 +729,9 @@ describe("WS Dispatcher", () => {
direct_url: "wss://direct.example.com",
});
// livekitSession is dynamically imported by the handler, so the call
// lands after the import promise resolves.
await vi.waitFor(() => {
expect(handleVoiceToken).toHaveBeenCalledWith(
"lk-token",
"wss://livekit.example.com",
@@ -733,6 +740,7 @@ describe("WS Dispatcher", () => {
undefined,
);
});
});
it("wires server_restart to transient error", () => {
mock.dispatch("server_restart", {
@@ -1004,6 +1012,7 @@ describe("WS Dispatcher", () => {
unreadCount: 0,
lastMessageId: null,
canSend: true,
slowMode: 0,
});
return { ...prev, channels: ch, activeChannelId: 1 };
});
@@ -1035,6 +1044,7 @@ describe("WS Dispatcher", () => {
unreadCount: 0,
lastMessageId: null,
canSend: true,
slowMode: 0,
});
return { ...prev, channels: ch, activeChannelId: 1 };
});
@@ -1273,7 +1283,8 @@ describe("WS Dispatcher", () => {
});
it("ready sends voice_leave when user appears in voice_states but LiveKit is disconnected", () => {
mockIsVoiceConnected.mockReturnValue(false);
// A fresh reload always starts with an idle voice session — the stale case.
voiceStore.setState((prev) => ({ ...prev, voiceStatus: "idle" }));
// Set up auth so the current user ID is 42
authStore.setState(() => ({
@@ -1301,7 +1312,9 @@ describe("WS Dispatcher", () => {
});
it("ready does NOT send voice_leave when LiveKit IS connected", () => {
mockIsVoiceConnected.mockReturnValue(true);
// A non-idle voice status means livekitSession is driving a live/pending
// session (the lazily-loaded module's store-backed "connected" flag).
voiceStore.setState((prev) => ({ ...prev, voiceStatus: "connected" }));
authStore.setState(() => ({
token: "test-token",
@@ -1328,7 +1341,7 @@ describe("WS Dispatcher", () => {
});
it("ready does NOT send voice_leave when user is NOT in voice_states", () => {
mockIsVoiceConnected.mockReturnValue(false);
voiceStore.setState((prev) => ({ ...prev, voiceStatus: "idle" }));
authStore.setState(() => ({
token: "test-token",
@@ -47,6 +47,7 @@ function makeCh(id: number, position: number, name = `ch-${id}`): Channel {
unreadCount: 0,
lastMessageId: null,
canSend: true,
slowMode: 0,
};
}
@@ -0,0 +1,152 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const { mockVoiceGetState } = vi.hoisted(() => ({
mockVoiceGetState: vi.fn(() => ({ currentChannelId: null as number | null })),
}));
vi.mock("@lib/logger", () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
vi.mock("@stores/voice.store", () => ({
voiceStore: { getState: mockVoiceGetState },
}));
const { attachGlobalKeybinds } = await import("../../src/pages/main-page/GlobalKeybinds");
function makeHandlers(): {
onSearch: ReturnType<typeof vi.fn>;
onToggleMute: ReturnType<typeof vi.fn>;
onToggleDeafen: ReturnType<typeof vi.fn>;
onToggleCamera: ReturnType<typeof vi.fn>;
onUploadFile: ReturnType<typeof vi.fn>;
} {
return {
onSearch: vi.fn(),
onToggleMute: vi.fn(),
onToggleDeafen: vi.fn(),
onToggleCamera: vi.fn(),
onUploadFile: vi.fn(),
};
}
function press(key: string, opts: Partial<KeyboardEventInit> = {}): KeyboardEvent {
const event = new KeyboardEvent("keydown", {
key,
ctrlKey: true,
cancelable: true,
...opts,
});
document.dispatchEvent(event);
return event;
}
describe("global keybinds", () => {
let detach: (() => void) | null = null;
beforeEach(() => {
mockVoiceGetState.mockReturnValue({ currentChannelId: null });
});
afterEach(() => {
detach?.();
detach = null;
});
it("Ctrl+F opens search and swallows the browser default", () => {
const h = makeHandlers();
detach = attachGlobalKeybinds(h);
const event = press("f");
expect(h.onSearch).toHaveBeenCalledOnce();
expect(event.defaultPrevented).toBe(true);
});
it("Ctrl+U opens the file picker", () => {
const h = makeHandlers();
detach = attachGlobalKeybinds(h);
press("u");
expect(h.onUploadFile).toHaveBeenCalledOnce();
});
it("ignores voice shortcuts outside a voice channel", () => {
const h = makeHandlers();
detach = attachGlobalKeybinds(h);
const mute = press("m");
const deafen = press("d");
const camera = press("V", { shiftKey: true });
expect(h.onToggleMute).not.toHaveBeenCalled();
expect(h.onToggleDeafen).not.toHaveBeenCalled();
expect(h.onToggleCamera).not.toHaveBeenCalled();
// Untouched keys keep their default behaviour.
expect(mute.defaultPrevented).toBe(false);
expect(deafen.defaultPrevented).toBe(false);
expect(camera.defaultPrevented).toBe(false);
});
it("fires voice shortcuts while connected to voice", () => {
mockVoiceGetState.mockReturnValue({ currentChannelId: 7 });
const h = makeHandlers();
detach = attachGlobalKeybinds(h);
press("m");
press("d");
// Shift uppercases the key — the handler must not miss it.
press("V", { shiftKey: true });
expect(h.onToggleMute).toHaveBeenCalledOnce();
expect(h.onToggleDeafen).toHaveBeenCalledOnce();
expect(h.onToggleCamera).toHaveBeenCalledOnce();
});
it("does nothing while suspended (settings overlay open)", () => {
const h = makeHandlers();
detach = attachGlobalKeybinds({ ...h, isSuspended: () => true });
press("f");
press("u");
expect(h.onSearch).not.toHaveBeenCalled();
expect(h.onUploadFile).not.toHaveBeenCalled();
});
it("ignores plain keys and Alt combos", () => {
const h = makeHandlers();
detach = attachGlobalKeybinds(h);
press("f", { ctrlKey: false });
press("f", { altKey: true });
expect(h.onSearch).not.toHaveBeenCalled();
});
it("keeps a handler error from escaping to the document", () => {
const h = makeHandlers();
h.onSearch.mockImplementation(() => {
throw new Error("boom");
});
detach = attachGlobalKeybinds(h);
expect(() => press("f")).not.toThrow();
});
it("detaching stops the shortcuts", () => {
const h = makeHandlers();
const stop = attachGlobalKeybinds(h);
stop();
press("f");
expect(h.onSearch).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,56 @@
import { vi } from "vitest";
/**
* Shared Tauri mocks for the ws client test files (ws-*.test.ts).
*
* vi.mock() is hoisted per test file, so each split file must call
* vi.mock("@tauri-apps/api/core") / vi.mock("@tauri-apps/api/event") itself
* with factories that resolve to the handles exported from this module:
*
* vi.mock("@tauri-apps/api/core", async () => ({
* invoke: (await import("./helpers/ws-mocks")).mockInvoke,
* }));
* vi.mock("@tauri-apps/api/event", async () => ({
* listen: (await import("./helpers/ws-mocks")).mockListen,
* }));
*/
/** Registry of handlers registered through the mocked Tauri listen(). */
export const eventHandlers = new Map<string, Array<(e: { payload: unknown }) => void>>();
export const mockInvoke = vi.fn();
export const mockListen = vi.fn(
async (event: string, handler: (e: { payload: unknown }) => void) => {
if (!eventHandlers.has(event)) eventHandlers.set(event, []);
eventHandlers.get(event)!.push(handler);
return () => {
const arr = eventHandlers.get(event);
if (arr) {
const idx = arr.indexOf(handler);
if (idx >= 0) arr.splice(idx, 1);
}
};
},
);
// Mock crypto.randomUUID
vi.stubGlobal("crypto", {
randomUUID: () => "test-uuid-1234",
});
// Suppress console output
vi.spyOn(console, "debug").mockImplementation(() => {});
vi.spyOn(console, "info").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
/** Simulate Tauri emitting an event to JS */
export function emitTauriEvent(event: string, payload: unknown): void {
const handlers = eventHandlers.get(event);
if (handlers) {
for (const h of handlers) {
h({ payload });
}
}
}
@@ -112,6 +112,52 @@ describe("InviteManager", () => {
mgr.destroy?.();
});
it("only mints one invite per click, even on a double-click", async () => {
let release: ((v: InviteItem) => void) | null = null;
const onCreateInvite = vi.fn(
() =>
new Promise<InviteItem>((resolve) => {
release = resolve;
}),
);
const opts = makeOptions({ invites: [], onCreateInvite });
const mgr = createInviteManager(opts);
mgr.mount(container);
const createBtn = container.querySelector(".invite-manager__create") as HTMLButtonElement;
createBtn.click();
expect(createBtn.disabled).toBe(true);
createBtn.click();
expect(onCreateInvite).toHaveBeenCalledTimes(1);
release!(makeInvite({ code: "newcode123" }));
await vi.waitFor(() => {
expect(createBtn.disabled).toBe(false);
});
mgr.destroy?.();
});
it("disarms the revoke confirm if it is left alone", () => {
vi.useFakeTimers();
try {
const opts = makeOptions({ invites: [makeInvite({ code: "abc123xyz" })] });
const mgr = createInviteManager(opts);
mgr.mount(container);
const revokeBtn = container.querySelector(".invite-item__revoke") as HTMLButtonElement;
revokeBtn.click();
vi.advanceTimersByTime(5000);
revokeBtn.click();
// The second click re-arms rather than revoking a link the user forgot about.
expect(opts.onRevokeInvite).not.toHaveBeenCalled();
mgr.destroy?.();
} finally {
vi.useRealTimers();
}
});
it("click revoke calls onRevokeInvite and removes from list on resolve", async () => {
const opts = makeOptions({ invites: [makeInvite({ code: "abc123xyz" })] });
const mgr = createInviteManager(opts);
@@ -120,6 +166,9 @@ describe("InviteManager", () => {
expect(container.querySelectorAll(".invite-item").length).toBe(1);
const revokeBtn = container.querySelector(".invite-item__revoke") as HTMLButtonElement;
// Revoking kills a live link — first click only arms the confirm.
revokeBtn.click();
expect(opts.onRevokeInvite).not.toHaveBeenCalled();
revokeBtn.click();
expect(opts.onRevokeInvite).toHaveBeenCalledWith("abc123xyz");
@@ -192,6 +241,7 @@ describe("InviteManager", () => {
const revokeBtn = container.querySelector(".invite-item__revoke") as HTMLButtonElement;
revokeBtn.click();
revokeBtn.click();
await vi.waitFor(() => {
expect(opts.onError).toHaveBeenCalledWith("Failed to revoke invite");
@@ -213,10 +213,12 @@ describe("KeybindsTab", () => {
// --- All keybinds present ---
it("renders Mark as Read, Search Messages, Upload File, Edit Last Message keybinds", () => {
it("renders Close Overlay, Search Messages, Upload File, Edit Last Message keybinds", () => {
const el = buildKeybindsTab(new AbortController().signal);
const labels = Array.from(el.querySelectorAll(".setting-label")).map((l) => l.textContent);
expect(labels).toContain("Mark as Read");
// "Mark as Read" used to be listed here with no feature behind it.
expect(labels).not.toContain("Mark as Read");
expect(labels).toContain("Close Overlay / Cancel");
expect(labels).toContain("Search Messages");
expect(labels).toContain("Upload File");
expect(labels).toContain("Edit Last Message");
@@ -0,0 +1,192 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// --- Mocks must be declared before imports ---
// The full E2EE protocol (announce verification, TOFU pinning, rotation-on-leave,
// timeout paths) is exercised end-to-end through the LiveKitSession facade in
// livekit-session.test.ts. This file is a focused smoke test of the extracted
// E2EEManager module surface.
const mockSetKey = vi.hoisted(() => vi.fn());
vi.mock("livekit-client", () => ({
ExternalE2EEKeyProvider: vi.fn(() => ({
setKey: mockSetKey,
getKeys: vi.fn().mockReturnValue([]),
})),
}));
const mockKeyPair = vi.hoisted(() => ({
publicKey: { type: "public" } as unknown as CryptoKey,
privateKey: { type: "private" } as unknown as CryptoKey,
}));
const mockIdentityKeyPair = vi.hoisted(() => ({
publicKey: { type: "id-public" } as unknown as CryptoKey,
privateKey: { type: "id-private" } as unknown as CryptoKey,
}));
vi.mock("@lib/e2eeCrypto", () => ({
generateECDHKeyPair: vi.fn(async () => mockKeyPair),
exportPublicKey: vi.fn(async () => "bW9ja2VwaGVtZXJhbA=="),
importPublicKey: vi.fn(async () => ({ type: "public" }) as unknown as CryptoKey),
generateRoomKey: vi.fn(() => new Uint8Array(32)),
roomKeyToBase64: vi.fn(() => "mock-room-key-base64"),
wrapRoomKey: vi.fn(async () => ({ encryptedKey: "enc", iv: "iv" })),
unwrapRoomKey: vi.fn(async () => new Uint8Array(32)),
signEphemeralKey: vi.fn(async () => "mock-signature"),
verifyEphemeralKeySignature: vi.fn(async () => true),
importIdentityPublicKey: vi.fn(
async () => ({ type: "id-public-imported" }) as unknown as CryptoKey,
),
computeKeyFingerprint: vi.fn(async () => "AB12 CD34 EF56 7890"),
}));
vi.mock("@lib/identity", () => ({
getOrCreateIdentityKeyPair: vi.fn(async () => mockIdentityKeyPair),
getIdentityPin: vi.fn(async () => null),
storeIdentityPin: vi.fn(async () => true),
}));
vi.mock("@stores/auth.store", () => ({
authStore: { getState: vi.fn(() => ({ user: { id: 1 } })) },
}));
const mockMembers = vi.hoisted(() => new Map<number, { identityPublicKey: string | null }>());
vi.mock("@stores/members.store", () => ({
membersStore: { getState: vi.fn(() => ({ members: mockMembers })) },
}));
const mockVoiceState = vi.hoisted(() => ({
voiceUsers: new Map<number, Map<number, unknown>>(),
}));
vi.mock("@stores/voice.store", () => ({
voiceStore: { getState: vi.fn(() => mockVoiceState) },
setPeerVerification: vi.fn(),
clearPeerVerification: vi.fn(),
clearPeerVerifications: vi.fn(),
}));
vi.mock("@lib/logger", () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
// Now import
import { E2EEManager } from "../../src/lib/livekitE2EE";
import { setPeerVerification } from "@stores/voice.store";
const PEER_ID = 42;
function createManager(ws: { send: ReturnType<typeof vi.fn> }): E2EEManager {
return new E2EEManager({
getWs: () => ws as never,
getServerHost: () => "localhost:7880",
getCurrentChannelId: () => 1,
});
}
function sendsOfType(ws: { send: ReturnType<typeof vi.fn> }, type: string): unknown[] {
return ws.send.mock.calls.map((c) => c[0]).filter((m: any) => m?.type === type);
}
describe("E2EEManager", () => {
beforeEach(() => {
vi.clearAllMocks();
mockMembers.clear();
mockMembers.set(PEER_ID, { identityPublicKey: "peer-identity-b64" });
});
it("setupKeyExchange as key holder generates the room key and sends a signed announce", async () => {
const ws = { send: vi.fn() };
const mgr = createManager(ws);
const ok = await mgr.setupKeyExchange(true, 1);
expect(ok).toBe(true);
expect(mgr.epoch).toBe(1);
expect(mockSetKey).toHaveBeenCalledWith("mock-room-key-base64");
const announces = sendsOfType(ws, "voice_e2ee_announce");
expect(announces).toHaveLength(1);
expect((announces[0] as any).payload.signature).toBe("mock-signature");
});
it("queues an announce before the keypair exists and drains it on setup, sending an offer", async () => {
const ws = { send: vi.fn() };
const mgr = createManager(ws);
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
expect(mgr.pendingAnnounces).toHaveLength(1);
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(false);
await mgr.setupKeyExchange(true, 1);
// Drained through the verifying receive path and stored. No offer yet:
// the drain runs before the room key is generated.
expect(mgr.pendingAnnounces).toHaveLength(0);
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(true);
expect(setPeerVerification).toHaveBeenCalledWith(
expect.objectContaining({ userId: PEER_ID, status: "verified" }),
);
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(0);
// A repeat announce after keying (dedupe path) re-sends the room-key offer.
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(1);
});
it("setupKeyExchange as non-key-holder resolves once the key holder's offer arrives", async () => {
const ws = { send: vi.fn() };
const mgr = createManager(ws);
// Seed the peer's ECDH key so the offer sender is known.
await mgr.setupKeyExchange(true, 1);
mgr.clearState();
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
ws.send.mockClear();
const setupPromise = mgr.setupKeyExchange(false, 1);
// Announce goes out first so the key holder can offer immediately.
await vi.waitFor(() => {
expect(sendsOfType(ws, "voice_e2ee_announce").length).toBeGreaterThan(0);
});
await mgr.handleOffer(PEER_ID, "enc", "iv");
await expect(setupPromise).resolves.toBe(true);
expect(mockSetKey).toHaveBeenCalledWith("mock-room-key-base64");
});
it("clearState aborts a waiting key exchange so setup fails instead of hanging", async () => {
const ws = { send: vi.fn() };
const mgr = createManager(ws);
const setupPromise = mgr.setupKeyExchange(false, 1);
await vi.waitFor(() => {
expect(sendsOfType(ws, "voice_e2ee_announce").length).toBeGreaterThan(0);
});
mgr.clearState();
await expect(setupPromise).resolves.toBe(false);
expect(mgr.epoch).toBe(0);
expect(mgr.peerPublicKeys.size).toBe(0);
});
it("rotateKeyPeriodically advances the epoch and redistributes the key to peers", async () => {
const ws = { send: vi.fn() };
const mgr = createManager(ws);
await mgr.setupKeyExchange(true, 1);
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
ws.send.mockClear();
await mgr.rotateKeyPeriodically();
expect(mgr.epoch).toBe(2);
const offers = sendsOfType(ws, "voice_e2ee_offer");
expect(offers).toHaveLength(1);
expect((offers[0] as any).payload.target_user_id).toBe(PEER_ID);
});
});
+142 -1
View File
@@ -1,7 +1,9 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
createLogger,
setLogLevel,
getLogLevel,
applyStoredLogLevel,
addLogListener,
getLogBuffer,
clearLogBuffer,
@@ -153,6 +155,19 @@ describe("logger", () => {
expect(getLogBuffer().length).toBe(0);
});
it("getLogLevel reflects the current effective level", () => {
setLogLevel("warn");
expect(getLogLevel()).toBe("warn");
setLogLevel("error");
expect(getLogLevel()).toBe("error");
});
it("getLogLevel reflects the applyStoredLogLevel fallback when no pref is stored", () => {
localStorage.clear();
applyStoredLogLevel("info");
expect(getLogLevel()).toBe("info");
});
it("passes empty string instead of undefined when no data", () => {
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
@@ -163,3 +178,129 @@ describe("logger", () => {
expect(infoSpy).toHaveBeenCalledWith(expect.any(String), "no data", "");
});
});
describe("applyStoredLogLevel", () => {
beforeEach(() => {
localStorage.clear();
setLogLevel("debug");
vi.restoreAllMocks();
});
afterEach(() => {
localStorage.clear();
setLogLevel("debug");
});
it("falls back to the given default when no pref is stored", () => {
const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
applyStoredLogLevel("info");
const log = createLogger("test");
log.debug("filtered");
log.info("kept");
expect(debugSpy).not.toHaveBeenCalled();
expect(infoSpy).toHaveBeenCalledTimes(1);
});
it("honors the saved logs_min_level pref over the fallback", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
localStorage.setItem("owncord:settings:logs_min_level", JSON.stringify("error"));
applyStoredLogLevel("debug");
const log = createLogger("test");
log.warn("filtered");
log.error("kept");
expect(warnSpy).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledTimes(1);
});
it("migrates a legacy unprefixed logs_min_level key and honors it", () => {
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
// Legacy values were stored raw under the unprefixed key.
localStorage.setItem("logs_min_level", "warn");
applyStoredLogLevel("debug");
const log = createLogger("test");
log.info("filtered");
log.warn("kept");
expect(infoSpy).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
// The legacy value is migrated forward to the prefixed key.
expect(localStorage.getItem("owncord:settings:logs_min_level")).toBe('"warn"');
});
it("ignores invalid stored values and uses the fallback", () => {
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
localStorage.setItem("owncord:settings:logs_min_level", JSON.stringify("verbose"));
applyStoredLogLevel("warn");
const log = createLogger("test");
log.info("filtered");
expect(infoSpy).not.toHaveBeenCalled();
});
});
describe("log level pref-change live updates", () => {
beforeEach(() => {
localStorage.clear();
setLogLevel("debug");
vi.restoreAllMocks();
});
afterEach(() => {
localStorage.clear();
setLogLevel("debug");
});
it("applies a new logs_min_level when owncord:pref-change fires", () => {
const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
localStorage.setItem("owncord:settings:logs_min_level", JSON.stringify("error"));
window.dispatchEvent(
new CustomEvent("owncord:pref-change", { detail: { key: "logs_min_level" } }),
);
const log = createLogger("test");
log.debug("filtered");
log.error("kept");
expect(debugSpy).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledTimes(1);
});
it("ignores pref-change events for other keys", () => {
const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
localStorage.setItem("owncord:settings:logs_min_level", JSON.stringify("error"));
window.dispatchEvent(
new CustomEvent("owncord:pref-change", { detail: { key: "compactMode" } }),
);
const log = createLogger("test");
log.debug("kept — level unchanged");
expect(debugSpy).toHaveBeenCalledTimes(1);
});
it("keeps the current level when the pref is cleared", () => {
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
setLogLevel("info");
window.dispatchEvent(
new CustomEvent("owncord:pref-change", { detail: { key: "logs_min_level" } }),
);
const log = createLogger("test");
log.info("kept");
expect(infoSpy).toHaveBeenCalledTimes(1);
});
});
@@ -6,12 +6,14 @@ const {
mockClearLogBuffer,
mockAddLogListener,
mockSetLogLevel,
mockGetLogLevel,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} = vi.hoisted(() => ({
mockGetLogBuffer: vi.fn<any>(),
mockClearLogBuffer: vi.fn<any>(),
mockAddLogListener: vi.fn<any>(),
mockSetLogLevel: vi.fn<any>(),
mockGetLogLevel: vi.fn<any>(),
}));
vi.mock("@lib/logger", () => ({
@@ -19,6 +21,7 @@ vi.mock("@lib/logger", () => ({
clearLogBuffer: mockClearLogBuffer,
addLogListener: mockAddLogListener,
setLogLevel: mockSetLogLevel,
getLogLevel: mockGetLogLevel,
createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
}));
@@ -46,6 +49,7 @@ describe("LogsTab", () => {
controller = new AbortController();
mockGetLogBuffer.mockReturnValue([]);
mockAddLogListener.mockReturnValue(() => {});
mockGetLogLevel.mockReturnValue("info");
});
afterEach(() => {
@@ -248,6 +252,20 @@ describe("LogsTab", () => {
expect(localStorage.getItem("owncord:settings:logs_filter_level")).toBe('"warn"');
});
it("defaults min-level select to the effective runtime level when no pref is saved", () => {
mockGetLogBuffer.mockReturnValue([]);
localStorage.clear();
mockGetLogLevel.mockReturnValue("info");
const handle = createLogsTab(() => "Logs" as TabName, controller.signal);
const el = handle.build();
const levelSelect = el.querySelectorAll("select")[1]!;
// Reflects getLogLevel() rather than the first option (DEBUG); no save/apply.
expect(levelSelect.value).toBe("info");
expect(mockSetLogLevel).not.toHaveBeenCalled();
});
it("restores legacy unprefixed min level and migrates it", () => {
mockGetLogBuffer.mockReturnValue([]);
localStorage.clear();
@@ -93,6 +93,18 @@ function oembedFail() {
};
}
/**
* media.ts caches showEmbeds/inlineMedia/showLinkPreviews/animateGifs at
* module level and re-reads them on "owncord:pref-change" (the event savePref
* dispatches). After changing loadPrefMock, dispatch those events so the
* module's cached values pick up the new mock implementation.
*/
function syncPrefCache(): void {
for (const key of ["showEmbeds", "inlineMedia", "showLinkPreviews", "animateGifs"]) {
window.dispatchEvent(new CustomEvent("owncord:pref-change", { detail: { key } }));
}
}
/** Simulate image load event on the first <img> found inside an element. */
function fireImgLoad(parent: HTMLElement): void {
const img = parent.querySelector("img") as HTMLImageElement | null;
@@ -130,6 +142,7 @@ describe("media.ts", () => {
observeMediaMock.mockReset();
loadPrefMock.mockReset();
loadPrefMock.mockImplementation((_key: string, fallback: unknown) => fallback);
syncPrefCache();
clearMediaCaches();
document.body.innerHTML = "";
});
@@ -310,6 +323,7 @@ describe("media.ts", () => {
if (key === "animateGifs") return true;
return fallback;
});
syncPrefCache();
const url = "https://example.com/animated.gif";
const wrap = renderInlineImage(url);
@@ -327,6 +341,7 @@ describe("media.ts", () => {
if (key === "animateGifs") return false;
return fallback;
});
syncPrefCache();
const url = "https://example.com/frozen.gif";
const wrap = renderInlineImage(url);
@@ -1263,6 +1278,7 @@ describe("media.ts", () => {
if (key === "showEmbeds") return false;
return true;
});
syncPrefCache();
const fragment = renderUrlEmbeds("https://www.youtube.com/watch?v=skip1");
@@ -1276,6 +1292,7 @@ describe("media.ts", () => {
if (key === "inlineMedia") return false;
return true;
});
syncPrefCache();
const fragment = renderUrlEmbeds("https://example.com/photo.png");
@@ -1289,6 +1306,7 @@ describe("media.ts", () => {
if (key === "showLinkPreviews") return false;
return true;
});
syncPrefCache();
const fragment = renderUrlEmbeds("https://example.com/article");
@@ -1299,6 +1317,7 @@ describe("media.ts", () => {
it("produces empty fragment when all preferences are disabled", () => {
loadPrefMock.mockReturnValue(false);
syncPrefCache();
const fragment = renderUrlEmbeds(
"https://www.youtube.com/watch?v=abc https://example.com/pic.png https://example.com/page",
@@ -1,9 +1,10 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createMemberList } from "@components/MemberList";
import type { MemberListOptions } from "@components/MemberList";
import { membersStore } from "@stores/members.store";
import { membersStore, updatePresence, updateMemberRole } from "@stores/members.store";
import type { Member } from "@stores/members.store";
import { authStore } from "@stores/auth.store";
import { channelsStore, setRoles } from "@stores/channels.store";
import type { UserStatus } from "../../src/lib/types";
function resetStore(): void {
@@ -220,6 +221,38 @@ describe("MemberList", () => {
expect(bobDot.style.background).toBe("var(--red)");
});
it("offers the server's own roles in the Change Role submenu", () => {
// A hardcoded list left custom roles unassignable — and unresolvable to a
// role id, so choosing one silently did nothing.
setRoles([
{ id: 1, name: "Owner", color: null, permissions: 0 },
{ id: 2, name: "Staff", color: null, permissions: 0 },
{ id: 3, name: "VIP", color: null, permissions: 0 },
]);
authStore.setState(() => ({
token: "tok",
user: { id: 99, username: "Admin", avatar: null, role: "admin" },
serverName: "Test",
motd: null,
isAuthenticated: true,
}));
setTestMembers(testMembers);
memberList.mount(container);
const memberItem = container.querySelector('[data-testid="member-3"]') as HTMLDivElement;
memberItem.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
const submenu = document.body.querySelector(".context-menu__submenu");
expect(submenu).not.toBeNull();
const roleLabels = Array.from(submenu!.querySelectorAll(".context-menu__item")).map(
(i) => i.textContent,
);
// "owner" is not a context-menu action.
expect(roleLabels).toEqual(["staff", "vip"]);
document.body.querySelector(".context-menu")?.remove();
});
it("context menu does not appear for non-admin/non-owner roles", () => {
setTestMembers(testMembers);
const opts: MemberListOptions = {
@@ -309,6 +342,68 @@ describe("MemberList", () => {
expect(names).toEqual(["Online", "Idle", "Dnd", "Offline"]);
});
it("patches a presence-only change in place, keeping row identity", () => {
setTestMembers(testMembers);
memberList.mount(container);
const eveRowBefore = container.querySelector('[data-testid="member-5"]') as HTMLDivElement;
expect(eveRowBefore).not.toBeNull();
const allRowsBefore = Array.from(container.querySelectorAll(".member-item"));
// Presence-only update (same username/role/avatar) — via the real action.
updatePresence(5, "dnd");
membersStore.flush();
// Same DOM element — no rebuild, status dot patched in place.
const eveRowAfter = container.querySelector('[data-testid="member-5"]');
expect(eveRowAfter).toBe(eveRowBefore);
const dot = eveRowAfter!.querySelector(".mi-status") as HTMLDivElement;
expect(dot.style.background).toBe("var(--red)");
expect(dot.getAttribute("aria-label")).toBe("dnd");
expect(dot.title).toBe("dnd");
// Every other row also kept its identity.
const allRowsAfter = Array.from(container.querySelectorAll(".member-item"));
expect(allRowsAfter).toEqual(allRowsBefore);
});
it("toggles the offline class in place when presence flips to/from offline", () => {
setTestMembers(testMembers);
memberList.mount(container);
const eveRow = container.querySelector('[data-testid="member-5"]') as HTMLDivElement;
expect(eveRow.classList.contains("offline")).toBe(false);
updatePresence(5, "offline");
membersStore.flush();
expect(container.querySelector('[data-testid="member-5"]')).toBe(eveRow);
expect(eveRow.classList.contains("offline")).toBe(true);
updatePresence(5, "online");
membersStore.flush();
expect(container.querySelector('[data-testid="member-5"]')).toBe(eveRow);
expect(eveRow.classList.contains("offline")).toBe(false);
});
it("still fully rebuilds when a member's role changes", () => {
setTestMembers(testMembers);
memberList.mount(container);
const eveRowBefore = container.querySelector('[data-testid="member-5"]');
updateMemberRole(5, "admin");
membersStore.flush();
// Structural change → rebuild: new row element, Eve now in the ADMIN group.
const eveRowAfter = container.querySelector('[data-testid="member-5"]');
expect(eveRowAfter).not.toBeNull();
expect(eveRowAfter).not.toBe(eveRowBefore);
const headerTexts = Array.from(container.querySelectorAll(".member-role-group")).map(
(h) => h.textContent,
);
expect(headerTexts.find((t) => t?.includes("ADMIN"))).toContain("3");
});
it("re-renders when store updates to a different member set", () => {
setTestMembers(testMembers);
memberList.mount(container);
@@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// jsdom does not provide ResizeObserver — stub it so MessageList can mount.
if (typeof globalThis.ResizeObserver === "undefined") {
globalThis.ResizeObserver = class {
observe(): void {
/* noop */
}
unobserve(): void {
/* noop */
}
disconnect(): void {
/* noop */
}
} as unknown as typeof ResizeObserver;
}
// Spy on the media-visibility manager: MessageList must release every tracked
// <img> (unobserveMedia) before discarding rendered rows, otherwise the
// IntersectionObserver + allTracked set + pending timers retain every GIF
// ever rendered.
const { observeMediaMock, unobserveMediaMock } = vi.hoisted(() => ({
observeMediaMock: vi.fn(),
unobserveMediaMock: vi.fn(),
}));
vi.mock("@lib/media-visibility", () => ({
observeMedia: observeMediaMock,
unobserveMedia: unobserveMediaMock,
}));
import { createMessageList } from "@components/MessageList";
import type { MessageListOptions } from "@components/MessageList";
import { messagesStore } from "@stores/messages.store";
import { membersStore } from "@stores/members.store";
import type { Message } from "@stores/messages.store";
function resetStores(): void {
messagesStore.setState(() => ({
messagesByChannel: new Map(),
pendingSends: new Map(),
loadedChannels: new Set(),
hasMore: new Map(),
historyLoadState: new Map(),
}));
membersStore.setState(() => ({
members: new Map(),
typingUsers: new Map(),
}));
}
function makeMessage(overrides: Partial<Message> & { id: number }): Message {
return {
channelId: 1,
user: { id: 1, username: "Alice", avatar: null },
content: `Message ${overrides.id}`,
replyTo: null,
attachments: [],
reactions: [],
pinned: false,
editedAt: null,
deleted: false,
timestamp: "2024-01-15T12:00:00Z",
status: "sent",
correlationId: null,
errorCode: null,
...overrides,
};
}
function setMessages(channelId: number, messages: Message[]): void {
messagesStore.setState((prev) => {
const next = new Map(prev.messagesByChannel);
next.set(channelId, messages);
return { ...prev, messagesByChannel: next };
});
}
describe("MessageList media release (GIF observer leak fix)", () => {
let container: HTMLDivElement;
let msgList: ReturnType<typeof createMessageList>;
let options: MessageListOptions;
beforeEach(() => {
resetStores();
observeMediaMock.mockClear();
unobserveMediaMock.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
options = {
channelId: 1,
channelName: "general",
currentUserId: 1,
onScrollTop: vi.fn(),
onReplyClick: vi.fn(),
onEditClick: vi.fn(),
onDeleteClick: vi.fn(),
onReactionClick: vi.fn(),
onPinClick: vi.fn(),
};
msgList = createMessageList(options);
});
afterEach(() => {
msgList.destroy?.();
container.remove();
});
it("unobserves rendered <img> elements before a full re-render discards them", () => {
setMessages(1, [makeMessage({ id: 2, content: "look https://example.com/anim.gif" })]);
msgList.mount(container);
const img = container.querySelector(".virtual-content img");
expect(img).not.toBeNull();
unobserveMediaMock.mockClear();
// Prepend an older message — NOT a suffix extension, so the list takes
// the full-rebuild path that tears the rendered rows down.
setMessages(1, [
makeMessage({ id: 1, content: "older", timestamp: "2024-01-15T11:00:00Z" }),
makeMessage({ id: 2, content: "look https://example.com/anim.gif" }),
]);
messagesStore.flush();
expect(unobserveMediaMock).toHaveBeenCalledWith(img);
});
it("unobserves rendered <img> elements on destroy", () => {
setMessages(1, [makeMessage({ id: 1, content: "look https://example.com/anim.gif" })]);
msgList.mount(container);
const img = container.querySelector(".virtual-content img");
expect(img).not.toBeNull();
unobserveMediaMock.mockClear();
msgList.destroy?.();
expect(unobserveMediaMock).toHaveBeenCalledWith(img);
});
it("does not unobserve retained rows on the incremental append fast path", () => {
const gifMessage = makeMessage({ id: 1, content: "look https://example.com/anim.gif" });
setMessages(1, [gifMessage]);
msgList.mount(container);
expect(container.querySelector(".virtual-content img")).not.toBeNull();
unobserveMediaMock.mockClear();
// Suffix extension (same leading references) → rows are kept, so nothing
// must be released.
setMessages(1, [
gifMessage,
makeMessage({ id: 2, content: "plain follow-up", timestamp: "2024-01-15T12:01:00Z" }),
]);
messagesStore.flush();
expect(unobserveMediaMock).not.toHaveBeenCalled();
});
});
@@ -374,4 +374,116 @@ describe("MessageList", () => {
expect(() => msgList.destroy?.()).not.toThrow();
expect(container.querySelector(".messages-container")).toBeNull();
});
it("does not re-render when a DIFFERENT channel's messages update", () => {
setMessages(1, [makeMessage({ id: 1, content: "Mine" })]);
msgList.mount(container);
const rowBefore = container.querySelector("[data-testid='message-1']");
expect(rowBefore).not.toBeNull();
// Update another channel — this list (channel 1) must not rebuild.
setMessages(2, [makeMessage({ id: 50, channelId: 2, content: "Other channel" })]);
messagesStore.flush();
const rowAfter = container.querySelector("[data-testid='message-1']");
expect(rowAfter).toBe(rowBefore); // same element instance — no re-render
});
describe("incremental tail append", () => {
it("appends new rows without rebuilding existing ones", () => {
setMessages(1, [
makeMessage({ id: 1, content: "First" }),
makeMessage({ id: 2, content: "Second", timestamp: "2024-01-15T12:01:00Z" }),
]);
msgList.mount(container);
const row1Before = container.querySelector("[data-testid='message-1']");
const row2Before = container.querySelector("[data-testid='message-2']");
expect(row1Before).not.toBeNull();
expect(row2Before).not.toBeNull();
// Pure suffix extension → fast path: existing rows keep their identity.
setMessages(1, [
...(messagesStore.getState().messagesByChannel.get(1) ?? []),
makeMessage({ id: 3, content: "Third", timestamp: "2024-01-15T12:02:00Z" }),
]);
messagesStore.flush();
expect(container.querySelector("[data-testid='message-1']")).toBe(row1Before);
expect(container.querySelector("[data-testid='message-2']")).toBe(row2Before);
expect(container.querySelector("[data-testid='message-3']")).not.toBeNull();
});
it("appended rows preserve order, grouping, and day dividers vs a full rebuild", () => {
const initial = [
makeMessage({ id: 1, content: "First", timestamp: "2024-01-15T12:00:00Z" }),
makeMessage({ id: 2, content: "Second", timestamp: "2024-01-15T12:01:00Z" }),
];
setMessages(1, initial);
msgList.mount(container);
const appended = [
// Same user within threshold → must render grouped.
makeMessage({ id: 3, content: "Third", timestamp: "2024-01-15T12:02:00Z" }),
// Next day, different user → must be preceded by a day divider.
makeMessage({
id: 4,
content: "Fourth",
user: { id: 2, username: "Bob", avatar: null },
timestamp: "2024-01-16T09:00:00Z",
}),
];
const finalMessages = [...initial, ...appended];
setMessages(1, finalMessages);
messagesStore.flush();
const content = container.querySelector(".virtual-content")!;
// Reference render: a fresh list mounted with the final message set
// (full rebuild path) must produce the same structure.
const refContainer = document.createElement("div");
document.body.appendChild(refContainer);
const refList = createMessageList(options);
refList.mount(refContainer);
const refContent = refContainer.querySelector(".virtual-content")!;
const describeChildren = (el: Element): string[] =>
Array.from(el.children).map((c) => `${c.className}|${c.getAttribute("data-testid") ?? ""}`);
expect(describeChildren(content)).toEqual(describeChildren(refContent));
// Explicit semantic checks on the appended tail.
expect(container.querySelectorAll(".msg-day-divider").length).toBe(2);
const row3 = container.querySelector("[data-testid='message-3']")!;
expect(row3.classList.contains("grouped")).toBe(true);
const row4 = container.querySelector("[data-testid='message-4']")!;
expect(row4.classList.contains("grouped")).toBe(false);
const ids = Array.from(content.querySelectorAll("[data-testid^='message-']")).map((el) =>
el.getAttribute("data-testid"),
);
expect(ids).toEqual(["message-1", "message-2", "message-3", "message-4"]);
refList.destroy?.();
refContainer.remove();
});
it("falls back to a full rebuild for non-append updates (edit)", () => {
setMessages(1, [
makeMessage({ id: 1, content: "Original" }),
makeMessage({ id: 2, content: "Second", timestamp: "2024-01-15T12:01:00Z" }),
]);
msgList.mount(container);
// Replace message 1's object (an edit) — not a suffix extension.
setMessages(1, [
makeMessage({ id: 1, content: "Edited" }),
makeMessage({ id: 2, content: "Second", timestamp: "2024-01-15T12:01:00Z" }),
]);
messagesStore.flush();
const row1 = container.querySelector("[data-testid='message-1']");
expect(row1).not.toBeNull();
expect(row1!.textContent).toContain("Edited");
});
});
});
@@ -0,0 +1,63 @@
import { describe, it, expect } from "vitest";
import { createNavigationGuard } from "../../src/lib/navigation-guard";
describe("createNavigationGuard", () => {
it("reports the only navigation as current", () => {
const guard = createNavigationGuard();
const isCurrent = guard.begin();
expect(isCurrent()).toBe(true);
});
it("supersedes an earlier navigation when a newer one begins", () => {
const guard = createNavigationGuard();
const first = guard.begin();
const second = guard.begin();
expect(first()).toBe(false);
expect(second()).toBe(true);
});
it("only the latest of many navigations is current", () => {
const guard = createNavigationGuard();
const predicates = [guard.begin(), guard.begin(), guard.begin()];
expect(predicates.map((p) => p())).toEqual([false, false, true]);
});
it("discards a stale async mount: a navigation that awaited across a newer begin() is superseded", async () => {
const guard = createNavigationGuard();
const mounted: string[] = [];
// Simulates renderPage: destroy happens synchronously, mount only after an
// awaited dynamic import — and only if still the current navigation.
async function renderPage(pageId: string, importDelay: Promise<void>): Promise<void> {
const isCurrent = guard.begin();
await importDelay; // dynamic import boundary
if (!isCurrent()) return;
mounted.push(pageId);
}
let resolveSlow!: () => void;
const slowImport = new Promise<void>((resolve) => {
resolveSlow = resolve;
});
const slowRender = renderPage("main", slowImport);
// A newer navigation begins (and mounts) while the first import is pending.
await renderPage("connect", Promise.resolve());
resolveSlow();
await slowRender;
expect(mounted).toEqual(["connect"]);
});
it("independent guards do not interfere", () => {
const a = createNavigationGuard();
const b = createNavigationGuard();
const aFirst = a.begin();
b.begin();
expect(aFirst()).toBe(true);
});
});
@@ -9,13 +9,11 @@ const { testPrefs } = vi.hoisted(() => ({
testPrefs: new Map<string, unknown>(),
}));
// Mock the settings helpers
vi.mock("../../src/components/settings/helpers", () => ({
// Mock the preference store shared by the settings panel and lib modules
vi.mock("../../src/lib/preferences", () => ({
STORAGE_PREFIX: "owncord:settings:",
loadPref: (key: string, fallback: unknown) => testPrefs.get(key) ?? fallback,
savePref: (key: string, value: unknown) => testPrefs.set(key, value),
THEMES: { dark: {}, midnight: {}, light: {} },
applyTheme: vi.fn(),
}));
// Mock livekitSession (imported transitively by auth.store)
@@ -125,6 +123,7 @@ describe("notifyIncomingMessage", () => {
unreadCount: 0,
lastMessageId: null,
canSend: true,
slowMode: 0,
},
],
]),
@@ -934,6 +933,45 @@ describe("notifyIncomingMessage", () => {
});
});
describe("Do Not Disturb", () => {
it("suppresses the desktop notification and the sound while DND", async () => {
const { sendNotification } = await import("@tauri-apps/plugin-notification");
const { getCurrentWindow } = await import("@tauri-apps/api/window");
(sendNotification as ReturnType<typeof vi.fn>).mockClear();
mockOscillator.start.mockClear();
testPrefs.set("desktopNotifications", true);
testPrefs.set("notificationSounds", true);
testPrefs.set("flashTaskbar", true);
testPrefs.set("userStatus", "dnd");
notifyIncomingMessage(makePayload());
// The taskbar flash still fires — it's the one passive cue DND keeps.
await vi.waitFor(() => {
const win = getCurrentWindow();
expect(win.requestUserAttention).toHaveBeenCalled();
});
expect(sendNotification).not.toHaveBeenCalled();
expect(mockOscillator.start).not.toHaveBeenCalled();
});
it("still notifies for other statuses", async () => {
const { sendNotification } = await import("@tauri-apps/plugin-notification");
(sendNotification as ReturnType<typeof vi.fn>).mockClear();
testPrefs.set("desktopNotifications", true);
testPrefs.set("userStatus", "idle");
notifyIncomingMessage(makePayload());
await vi.waitFor(() => {
expect(sendNotification).toHaveBeenCalled();
});
});
});
describe("playNotificationSound: oscillator params", () => {
it("sets frequency to 800 then 600", () => {
mockOscillator.frequency.setValueAtTime.mockClear();
@@ -485,8 +485,11 @@ describe("ptt-state event listener", () => {
expect(capturedCallback).not.toBeNull();
capturedCallback!({ payload: true }); // key pressed
// setMuted is reached via a dynamic import of livekitSession
await vi.waitFor(() => {
expect(mockSetMuted).toHaveBeenCalledWith(false);
});
});
it("calls setMuted(true) when PTT is released (payload false) and in a voice channel", async () => {
const { setMuted } = await import("../../src/lib/livekitSession");
@@ -506,8 +509,11 @@ describe("ptt-state event listener", () => {
capturedCallback!({ payload: false }); // key released
// setMuted is reached via a dynamic import of livekitSession
await vi.waitFor(() => {
expect(mockSetMuted).toHaveBeenCalledWith(true);
});
});
it("does not call setMuted when not in a voice channel", async () => {
const { setMuted } = await import("../../src/lib/livekitSession");
@@ -527,6 +533,9 @@ describe("ptt-state event listener", () => {
capturedCallback!({ payload: true });
// Flush pending microtasks so a (wrong) dynamic-import path would have
// had the chance to call setMuted before we assert it never happens.
await new Promise((r) => setTimeout(r, 0));
expect(mockSetMuted).not.toHaveBeenCalled();
});
});
@@ -18,6 +18,8 @@ import {
} from "../../src/components/message-list/renderers";
import type { Message } from "../../src/stores/messages.store";
import { membersStore } from "../../src/stores/members.store";
import { channelsStore, setRoles } from "../../src/stores/channels.store";
import { authStore } from "../../src/stores/auth.store";
import type { MessageListOptions } from "../../src/components/MessageList";
function resetStores(): void {
@@ -25,6 +27,14 @@ function resetStores(): void {
members: new Map(),
typingUsers: new Map(),
}));
channelsStore.setState((prev) => ({ ...prev, roles: [] }));
authStore.setState(() => ({
token: null,
user: null,
serverName: null,
motd: null,
isAuthenticated: false,
}));
}
function makeMessage(overrides: Partial<Message> = {}): Message {
@@ -1033,6 +1043,49 @@ describe("renderers", () => {
ac.abort();
});
it("offers delete on others' messages to a role with MANAGE_MESSAGES", () => {
// MANAGE_MESSAGES = 0x10000 (see lib/types Permission).
setRoles([{ id: 2, name: "moderator", color: null, permissions: 0x10000 }]);
authStore.setState(() => ({
token: "tok",
user: { id: 999, username: "Mod", avatar: null, role: "moderator" },
serverName: null,
motd: null,
isAuthenticated: true,
}));
const opts = makeOpts({ currentUserId: 999 });
const msg = makeMessage({ user: { id: 10, username: "Alice", avatar: null } });
const ac = new AbortController();
container.appendChild(renderMessage(msg, false, [msg], opts, ac.signal));
expect(container.querySelector("[data-testid='msg-delete-1']")).not.toBeNull();
// Editing someone else's message is still not a thing.
expect(container.querySelector("[data-testid='msg-edit-1']")).toBeNull();
ac.abort();
});
it("withholds delete from a role without MANAGE_MESSAGES", () => {
setRoles([{ id: 3, name: "member", color: null, permissions: 0 }]);
authStore.setState(() => ({
token: "tok",
user: { id: 999, username: "Nobody", avatar: null, role: "member" },
serverName: null,
motd: null,
isAuthenticated: true,
}));
const opts = makeOpts({ currentUserId: 999 });
const msg = makeMessage({ user: { id: 10, username: "Alice", avatar: null } });
const ac = new AbortController();
container.appendChild(renderMessage(msg, false, [msg], opts, ac.signal));
expect(container.querySelector("[data-testid='msg-delete-1']")).toBeNull();
ac.abort();
});
});
// ---------------------------------------------------------------------------
@@ -79,6 +79,7 @@ function setVoiceConnected(screenshare = false): void {
unreadCount: 0,
lastMessageId: null,
canSend: true,
slowMode: 0,
},
],
]),
@@ -41,6 +41,7 @@ vi.mock("@stores/auth.store", () => ({
getState: () => ({
user: { id: 1, username: "testuser", totp_enabled: false },
}),
subscribeSelector: vi.fn(() => () => {}),
},
updateUser: vi.fn(),
}));
@@ -375,6 +376,63 @@ describe("SettingsOverlay", () => {
overlay.destroy?.();
});
it("requires the current password before calling the server", () => {
const overlay = createSettingsOverlay(defaultOptions);
overlay.mount(container);
const inputs = container.querySelectorAll("input[type='password']");
(inputs[0] as HTMLInputElement).value = "";
(inputs[1] as HTMLInputElement).value = "newpassword123";
(inputs[2] as HTMLInputElement).value = "newpassword123";
const changePwBtn = Array.from(container.querySelectorAll(".ac-btn")).find(
(b) => b.textContent === "Change Password",
) as HTMLElement;
changePwBtn.click();
// An empty current password is a guaranteed 403 — and each one counts
// against the server's lockout counter.
expect(defaultOptions.onChangePassword).not.toHaveBeenCalled();
expect(container.textContent).toContain("Enter your current password.");
overlay.destroy?.();
});
it("blocks a double submit while the password change is in flight", async () => {
let resolveChange: (() => void) | null = null;
const onChangePassword = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveChange = resolve;
}),
);
const overlay = createSettingsOverlay({ ...defaultOptions, onChangePassword });
overlay.mount(container);
const inputs = container.querySelectorAll("input[type='password']");
(inputs[0] as HTMLInputElement).value = "oldpass123";
(inputs[1] as HTMLInputElement).value = "newpassword123";
(inputs[2] as HTMLInputElement).value = "newpassword123";
const changePwBtn = Array.from(container.querySelectorAll(".ac-btn")).find(
(b) => b.textContent === "Change Password",
) as HTMLButtonElement;
changePwBtn.click();
expect(changePwBtn.disabled).toBe(true);
expect(changePwBtn.textContent).toBe("Changing...");
changePwBtn.click();
expect(onChangePassword).toHaveBeenCalledTimes(1);
resolveChange!();
await vi.waitFor(() => {
expect(changePwBtn.disabled).toBe(false);
expect(changePwBtn.textContent).toBe("Change Password");
});
overlay.destroy?.();
});
it("calls onChangePassword and clears inputs on success", async () => {
const onChangePassword = vi.fn().mockResolvedValue(undefined);
const overlay = createSettingsOverlay({ ...defaultOptions, onChangePassword });
@@ -852,6 +910,49 @@ describe("SettingsOverlay", () => {
overlay.destroy?.();
});
// --- Reopen rebuilds live content ---
it("rebuilds the active tab when the panel is reopened", () => {
const overlay = createSettingsOverlay(defaultOptions);
overlay.mount(container);
overlay.open();
const firstPane = container.querySelector(".settings-content .settings-pane");
expect(firstPane).not.toBeNull();
// Closing tears down the live parts of the tab (mic meter, camera preview,
// log listener) — reopening must build a fresh pane, not show the corpse.
overlay.close();
overlay.open();
const secondPane = container.querySelector(".settings-content .settings-pane");
expect(secondPane).not.toBeNull();
expect(secondPane).not.toBe(firstPane);
// Exactly one pane — the old one was replaced, not appended to.
expect(container.querySelectorAll(".settings-content .settings-pane").length).toBe(1);
});
it("re-reads preferences when reopened", () => {
const overlay = createSettingsOverlay(defaultOptions);
overlay.mount(container);
overlay.open();
const appearanceTab = Array.from(
container.querySelectorAll(".settings-sidebar > button.settings-nav-item"),
).find((b) => b.textContent === "Appearance") as HTMLElement;
appearanceTab.click();
let slider = container.querySelector(".settings-slider") as HTMLInputElement;
expect(slider.value).toBe("16");
overlay.close();
localStorage.setItem("owncord:settings:fontSize", JSON.stringify(20));
overlay.open();
slider = container.querySelector(".settings-slider") as HTMLInputElement;
expect(slider.value).toBe("20");
});
// --- Cleanup ---
it("destroy removes root from DOM", () => {
@@ -1033,6 +1033,7 @@ describe("SidebarArea", () => {
unreadCount: 0,
lastMessageId: null,
canSend: true,
slowMode: 0,
});
return { ...prev, channels: next, activeChannelId: 1 };
});
@@ -1062,6 +1063,7 @@ describe("SidebarArea", () => {
unreadCount: 0,
lastMessageId: null,
canSend: true,
slowMode: 0,
});
return { ...prev, channels: next, activeChannelId: 50 };
});
@@ -1284,6 +1286,7 @@ describe("SidebarArea", () => {
unreadCount: 0,
lastMessageId: null,
canSend: true,
slowMode: 0,
});
return { ...prev, channels: next, activeChannelId: 1 };
});
@@ -1321,6 +1324,7 @@ describe("SidebarArea", () => {
unreadCount: 0,
lastMessageId: null,
canSend: true,
slowMode: 0,
});
next.set(2, {
id: 2,
@@ -1331,6 +1335,7 @@ describe("SidebarArea", () => {
unreadCount: 0,
lastMessageId: null,
canSend: true,
slowMode: 0,
});
return { ...prev, channels: next };
});
@@ -1393,6 +1398,7 @@ describe("SidebarArea", () => {
unreadCount: 0,
lastMessageId: null,
canSend: true,
slowMode: 0,
});
return { ...prev, channels: next, activeChannelId: 100 };
});
@@ -1797,6 +1803,7 @@ describe("SidebarArea", () => {
unreadCount: 0,
lastMessageId: null,
canSend: true,
slowMode: 0,
});
return { ...prev, channels: next };
});
@@ -1839,7 +1846,7 @@ describe("SidebarArea", () => {
/** Extract callbacks passed to createMemberList */
function getMemberListCallbacks(): {
onKick: (userId: number, username: string) => Promise<void>;
onBan: (userId: number, username: string) => Promise<void>;
onBan: (userId: number, username: string, reason: string) => Promise<void>;
onChangeRole: (userId: number, username: string, newRole: string) => Promise<void>;
} {
const calls = (createMemberList as MockedFn).mock.calls;
@@ -1906,9 +1913,9 @@ describe("SidebarArea", () => {
container.appendChild(result.sidebarWrapper);
const callbacks = getMemberListCallbacks();
await callbacks.onBan(3, "Bob");
await callbacks.onBan(3, "Bob", "spamming");
expect(opts.api.adminBanMember).toHaveBeenCalledWith(3);
expect(opts.api.adminBanMember).toHaveBeenCalledWith(3, "spamming");
expect(mockShow).toHaveBeenCalledWith("Banned Bob", "success");
cleanup(result);
@@ -1924,7 +1931,7 @@ describe("SidebarArea", () => {
container.appendChild(result.sidebarWrapper);
const callbacks = getMemberListCallbacks();
await callbacks.onBan(3, "Bob");
await callbacks.onBan(3, "Bob", "");
expect(mockShow).toHaveBeenCalledWith("Ban denied", "error");
@@ -1941,7 +1948,7 @@ describe("SidebarArea", () => {
container.appendChild(result.sidebarWrapper);
const callbacks = getMemberListCallbacks();
await callbacks.onBan(3, "Bob");
await callbacks.onBan(3, "Bob", "");
expect(mockShow).toHaveBeenCalledWith("Failed to ban member", "error");
@@ -2019,7 +2026,7 @@ describe("SidebarArea", () => {
cleanup(result);
});
it("onChangeRole does nothing when role name not found", async () => {
it("onChangeRole reports an unresolvable role instead of failing silently", async () => {
const mockShow = vi.fn();
const opts = defaultOpts();
(opts.getToast as MockedFn).mockReturnValue({ show: mockShow });
@@ -2031,7 +2038,10 @@ describe("SidebarArea", () => {
await callbacks.onChangeRole(4, "Charlie", "nonexistent");
expect(opts.api.adminChangeRole).not.toHaveBeenCalled();
expect(mockShow).not.toHaveBeenCalled();
expect(mockShow).toHaveBeenCalledWith(
'Unknown role "nonexistent" — try reconnecting',
"error",
);
cleanup(result);
});
@@ -119,6 +119,7 @@ describe("SidebarDmHelpers", () => {
unreadCount: 0,
lastMessageId: null,
canSend: true,
slowMode: 0,
});
return { ...prev, channels: next };
});
@@ -144,6 +145,7 @@ describe("SidebarDmHelpers", () => {
unreadCount: 0,
lastMessageId: null,
canSend: true,
slowMode: 0,
});
return { ...prev, channels: next };
});
@@ -175,6 +177,7 @@ describe("SidebarDmHelpers", () => {
unreadCount: 0,
lastMessageId: null,
canSend: true,
slowMode: 0,
});
return { ...prev, channels: next, activeChannelId: 1 };
});
@@ -199,6 +202,7 @@ describe("SidebarDmHelpers", () => {
unreadCount: 0,
lastMessageId: null,
canSend: true,
slowMode: 0,
});
return { ...prev, channels: next, activeChannelId: 50 };
});

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