mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad0448df4d | ||
|
|
d3526968bb | ||
|
|
82be103794 | ||
|
|
4ff199e14f | ||
|
|
b77c2790b0 | ||
|
|
931a2fd27e | ||
|
|
1486078265 | ||
|
|
3f8a26c904 | ||
|
|
4a3b4e0cff | ||
|
|
15db18a3ac | ||
|
|
4582a601b3 | ||
|
|
a3d03620e5 | ||
|
|
5df58bb9c4 | ||
|
|
4bda86d0cb | ||
|
|
6e841cc75f | ||
|
|
d9c6094b5f | ||
|
|
4fa8f6f84d | ||
|
|
b854f5a986 | ||
|
|
3c78bcd2b2 |
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: ci-check
|
||||
description: Run the local mirror of OwnCord's CI gates before pushing. Use when finishing a change, before a commit or push, or when asked to verify work — CI takes ~15 min and catches things a plain build/test does not.
|
||||
---
|
||||
|
||||
# ci-check
|
||||
|
||||
`.github/workflows/ci.yml` is the source of truth. This mirrors it locally.
|
||||
|
||||
Run only the sections your change touches. Server and client are independent.
|
||||
|
||||
## Server (from `Server/`)
|
||||
|
||||
All four build-tag variants must compile — the tags gate whole files, so a
|
||||
default-build pass proves nothing about the others:
|
||||
|
||||
```bash
|
||||
go build ./... && go build -tags otel ./... && go build -tags wazero ./... && go build -tags otel,wazero ./...
|
||||
go vet ./...
|
||||
go test -race ./...
|
||||
go test -tags deadlock -count=1 ./ws/ # deadlock detector; ws is where lock order actually varies
|
||||
golangci-lint run # CI pins v2.11.3
|
||||
make sqlc-verify protocol-verify # generated output must not be stale
|
||||
```
|
||||
|
||||
Add `-tags wazero` to `go vet`/`go test` when you touched `plugin/`.
|
||||
|
||||
A `windows-latest` `-race` failure inside `ws` that matches `runtime.scanstack`
|
||||
or `runtime.(*unwinder).next` is a Go 1.26.5 runtime GC fault, not your change.
|
||||
Rerun the job (`gh run rerun --job <id>`); a job cannot be rerun while its
|
||||
parent run is still in progress.
|
||||
|
||||
## Client (from `Client/tauri-client/`)
|
||||
|
||||
```bash
|
||||
NODE_OPTIONS=--no-experimental-webstorage npm test
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run format:check
|
||||
```
|
||||
|
||||
The `NODE_OPTIONS` flag is mandatory on Node 22+ — see the client CLAUDE.md.
|
||||
|
||||
`npm audit --audit-level=high` and `knip` also run in CI but are advisory.
|
||||
|
||||
## Rust (from `Client/tauri-client/src-tauri/`)
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
`fallback_crypto` is `cfg(not(windows))`, so its tests compile to nothing on a
|
||||
Windows box and only run on the Linux/macOS runners.
|
||||
|
||||
Do not attempt `npm run tauri build` locally — the full desktop build runs in
|
||||
CI on PRs to `main` and pulls heavy system dependencies.
|
||||
|
||||
## Hooks
|
||||
|
||||
`npm run hooks:install` (once per clone) points `core.hooksPath` at
|
||||
`.githooks/`: `pre-commit` runs fast staged-file checks, `pre-push` runs the
|
||||
server build variants plus tsc and eslint. `OWNCORD_PREPUSH_TESTS=1` adds
|
||||
server tests. Bypass with `--no-verify` or `OWNCORD_SKIP_HOOKS=1` — CI still
|
||||
enforces everything.
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: db-change
|
||||
description: Change OwnCord's SQLite schema or queries — add a migration, edit Server/db/queries/*.sql, and regenerate the sqlc layer. Use before touching anything under Server/db/ or Server/migrations/.
|
||||
---
|
||||
|
||||
# db-change
|
||||
|
||||
`Server/db/dbgen/` is generated. Edit the inputs, regenerate, commit both.
|
||||
|
||||
1. Add the migration to `Server/migrations/` and/or edit
|
||||
`Server/db/queries/sqlite/*.sql`.
|
||||
2. Regenerate: `make sqlc-generate` from `Server/`.
|
||||
3. Commit the regenerated `Server/db/dbgen/` alongside your inputs. CI runs
|
||||
`make sqlc-verify` and fails on drift.
|
||||
|
||||
`sqlc.version` pins the binary (currently v1.30.0). If `make` is not on PATH:
|
||||
|
||||
```bash
|
||||
go install github.com/sqlc-dev/sqlc/cmd/sqlc@$(cat sqlc.version)
|
||||
$(go env GOPATH)/bin/sqlc generate
|
||||
```
|
||||
|
||||
## Traps
|
||||
|
||||
These are silent — the code generates fine and fails at runtime.
|
||||
|
||||
**Query files must be ASCII-only.** sqlc v1.30.0 measures rune positions
|
||||
against byte offsets, so one multi-byte character (an em-dash in a comment is
|
||||
the usual culprit) truncates the *next* query's emitted SQL by that many
|
||||
trailing bytes. Symptom: the `.sql` file looks right but the generated const
|
||||
in `dbgen/*.sql.go` is cut short — `ORDER BY id ASC` becomes `ORDER BY id A`,
|
||||
and SQLite reports "incomplete input".
|
||||
|
||||
**No semicolons inside migration `--` comments.** `splitStatements` in
|
||||
`Server/db/migrate.go` splits on `;` before stripping comments, so a semicolon
|
||||
in comment prose orphans the rest of that comment as a bogus statement
|
||||
("near <word>: syntax error").
|
||||
|
||||
**Do not put `LIMIT 1` on a `:one` query.** It is emitted as a bare `LIMIT`.
|
||||
A `:one` uses `QueryRow` and reads a single row regardless — use `ORDER BY` to
|
||||
choose which one.
|
||||
|
||||
After regenerating, gopls diagnostics against `dbgen` go stale. Trust
|
||||
`go build`, not the editor squiggles.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: protocol-change
|
||||
description: Add or change a WebSocket message type in OwnCord. Use before editing docs/protocol-schema.json, Server/ws/message_types.go, or Client/tauri-client/src/lib/protocolTypes.ts.
|
||||
---
|
||||
|
||||
# protocol-change
|
||||
|
||||
`docs/protocol-schema.json` is the source of truth. Both constant files are
|
||||
generated from it by `Server/scripts/genprotocol/`.
|
||||
|
||||
1. Edit `docs/protocol-schema.json`.
|
||||
2. Run `make protocol-generate` from `Server/`.
|
||||
3. Commit **both** outputs — `Server/ws/message_types.go` and
|
||||
`Client/tauri-client/src/lib/protocolTypes.ts`. One run regenerates the
|
||||
pair; committing only the Go side is the usual mistake, and CI's
|
||||
`make protocol-verify` fails on either being stale.
|
||||
|
||||
Document the semantics in `docs/protocol.md` — the schema carries names and
|
||||
shapes, not behaviour.
|
||||
|
||||
Adding a message type is not enough to make it work: a server handler must be
|
||||
registered in the `ws` V1/V2 dispatch tables, and the client needs a
|
||||
`ws.on(...)` subscription in `Client/tauri-client/src/lib/dispatcher.ts`.
|
||||
@@ -0,0 +1,475 @@
|
||||
// Offline harness for bughunt.js - mimics the workflow runtime: wraps the script
|
||||
// body in an AsyncFunction with stubbed agent/parallel/pipeline/phase/log/args/budget.
|
||||
// Run: node .claude/workflows/bughunt.harness.mjs [nameFilter]
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
|
||||
|
||||
export async function run({ agentStub, args = undefined, budget = undefined }) {
|
||||
const src = readFileSync(join(here, 'bughunt.js'), 'utf8')
|
||||
const body = src.replace('export const meta', 'const meta')
|
||||
const calls = []
|
||||
const logs = []
|
||||
const agent = async (prompt, opts = {}) => {
|
||||
calls.push({ prompt, opts })
|
||||
return agentStub(prompt, opts)
|
||||
}
|
||||
const parallel = (thunks) =>
|
||||
Promise.all(thunks.map((t) => Promise.resolve().then(t).catch(() => null)))
|
||||
const pipeline = (items, ...stages) =>
|
||||
Promise.all(
|
||||
items.map(async (item, i) => {
|
||||
let v = item
|
||||
for (const stage of stages) {
|
||||
try {
|
||||
v = await stage(v, item, i)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return v
|
||||
}),
|
||||
)
|
||||
const log = (m) => logs.push(String(m))
|
||||
const phase = () => {}
|
||||
const budgetImpl = budget || { total: null, spent: () => 0, remaining: () => Infinity }
|
||||
const fn = new AsyncFunction('agent', 'parallel', 'pipeline', 'phase', 'log', 'args', 'budget', body)
|
||||
const result = await fn(agent, parallel, pipeline, phase, log, args, budgetImpl)
|
||||
return { result, calls, logs }
|
||||
}
|
||||
|
||||
// ---------- stub kit (used from Task 2 onward; harmless now) ----------
|
||||
export function makeStub({ hunt, verify, report = () => 'REPORT_MD', recon = defaultRecon }) {
|
||||
return (prompt, opts) => {
|
||||
const label = opts.label || ''
|
||||
if (label.startsWith('recon:')) return recon(label)
|
||||
let m = /^r(\d+):hunt:([a-z0-9-]+):(opus|sonnet)$/.exec(label)
|
||||
if (m) return hunt(Number(m[1]), m[2], m[3], prompt)
|
||||
m = /^r(\d+):verify:([a-z0-9-]+?)(:retry)?$/.exec(label)
|
||||
if (m) {
|
||||
const candidates = JSON.parse(prompt.split('--- CANDIDATES ---')[1])
|
||||
return verify(Number(m[1]), m[2], candidates, Boolean(m[3]), prompt)
|
||||
}
|
||||
if (label === 'report') return report(prompt)
|
||||
throw new Error(`unexpected agent label: ${label}`)
|
||||
}
|
||||
}
|
||||
export function defaultRecon() {
|
||||
return 'Server/ws/hub.go 12\nServer/api/user.go 9\nClient/tauri-client/src/lib/dispatcher.ts 8'
|
||||
}
|
||||
export const none = { findings: [] }
|
||||
export const finding = (n, over = {}) => ({
|
||||
title: `distinct bug alpha${n} omega${n}`,
|
||||
file: 'Server/ws/hub.go',
|
||||
line: 100 + n * 40,
|
||||
severity: 'high',
|
||||
why: 'w',
|
||||
repro: 'r',
|
||||
evidence: 'e',
|
||||
...over,
|
||||
})
|
||||
export const confirmAll = (cands) => ({
|
||||
verdicts: cands.map((c) => ({
|
||||
title: c.title, file: c.file, line: c.line,
|
||||
refuted: false, reason: 'confirmed', confidence: 'high',
|
||||
severity: c.severity || 'high', fix: 'fix',
|
||||
})),
|
||||
})
|
||||
export const refuteAll = (cands) => ({
|
||||
verdicts: cands.map((c) => ({
|
||||
title: c.title, file: c.file, line: c.line,
|
||||
refuted: true, reason: 'refuted', confidence: 'high',
|
||||
severity: c.severity || 'high',
|
||||
})),
|
||||
})
|
||||
|
||||
// ---------- scenarios ----------
|
||||
const scenarios = {}
|
||||
|
||||
// S1: happy convergence - one bug in round 1, rounds 2-3 dry -> converged.
|
||||
scenarios.s1_convergence = async () => {
|
||||
const reportPrompts = []
|
||||
const { result, calls } = await run({
|
||||
agentStub: makeStub({
|
||||
hunt: (round, key, model) =>
|
||||
round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none,
|
||||
verify: (round, key, cands) => confirmAll(cands),
|
||||
report: (prompt) => {
|
||||
reportPrompts.push(prompt)
|
||||
return 'REPORT_MD'
|
||||
},
|
||||
}),
|
||||
})
|
||||
for (const k of ['converged', 'stoppedOnBudget', 'rounds', 'confirmed', 'unverified', 'report'])
|
||||
assert.ok(k in result, `missing key ${k}`)
|
||||
assert.equal(result.converged, true)
|
||||
assert.equal(result.rounds.length, 3)
|
||||
assert.deepEqual(result.rounds.map((r) => r.dryAfter), [0, 1, 2])
|
||||
assert.deepEqual(result.rounds.map((r) => r.family), ['surfaces', 'bug-classes', 'flows'])
|
||||
assert.equal(result.confirmed.length, 1)
|
||||
assert.equal(result.report, 'REPORT_MD')
|
||||
assert.ok(!calls.some((c) => (c.opts.label || '').startsWith('r4:')), 'no round 4 after convergence')
|
||||
assert.match(reportPrompts[0], /CONVERGED after 3 round\(s\)/)
|
||||
assert.match(reportPrompts[0], /\| 1 \| surfaces \|/)
|
||||
}
|
||||
|
||||
// S2: panel dedupe - opus and sonnet report the same bug -> one candidate, one verify call.
|
||||
scenarios.s2_panel_dedupe = async () => {
|
||||
const verifyBatches = []
|
||||
const { result } = await run({
|
||||
agentStub: makeStub({
|
||||
hunt: (round, key, model) => {
|
||||
if (round !== 1 || key !== 'ws-hub') return none
|
||||
return model === 'opus'
|
||||
? { findings: [finding(1, { line: 100 })] }
|
||||
: { findings: [finding(1, { line: 105, title: 'distinct bug alpha1 omega1 variant' })] }
|
||||
},
|
||||
verify: (round, key, cands) => {
|
||||
verifyBatches.push(cands)
|
||||
return confirmAll(cands)
|
||||
},
|
||||
}),
|
||||
})
|
||||
assert.equal(verifyBatches.length, 1)
|
||||
assert.equal(verifyBatches[0].length, 1)
|
||||
assert.equal(result.confirmed.length, 1)
|
||||
}
|
||||
|
||||
// S3: refuted findings stay dead - re-reported next round, never re-verified; refutes count toward dry.
|
||||
scenarios.s3_refuted_permanence = async () => {
|
||||
const { result, calls } = await run({
|
||||
agentStub: makeStub({
|
||||
hunt: (round, key, model) => {
|
||||
if (round === 1 && key === 'ws-hub' && model === 'opus') return { findings: [finding(2)] }
|
||||
if (round === 2 && key === 'state-desync' && model === 'opus') return { findings: [finding(2)] }
|
||||
return none
|
||||
},
|
||||
verify: (round, key, cands) => refuteAll(cands),
|
||||
}),
|
||||
})
|
||||
const verifyRounds = calls
|
||||
.map((c) => /^r(\d+):verify:/.exec(c.opts.label || ''))
|
||||
.filter(Boolean)
|
||||
.map((m) => Number(m[1]))
|
||||
assert.deepEqual(verifyRounds, [1], 'refuted candidate must not be re-verified in round 2')
|
||||
assert.equal(result.rounds[0].refuted, 1)
|
||||
assert.equal(result.confirmed.length, 0)
|
||||
assert.equal(result.rounds.length, 2) // refute-only r1 is dry -> converged after r2
|
||||
assert.equal(result.converged, true)
|
||||
assert.ok(!calls.some((c) => c.opts.label === 'report'), 'zero confirmed -> code-built report')
|
||||
assert.match(result.report, /Converged/i)
|
||||
}
|
||||
|
||||
// S4: backstop - fresh confirmed bug every round with maxRounds=3 -> stops, NOT converged.
|
||||
scenarios.s4_backstop = async () => {
|
||||
const firstLens = { 1: 'ws-hub', 2: 'concurrency', 3: 'flow-reconnect' }
|
||||
const { result } = await run({
|
||||
args: { maxRounds: 3 },
|
||||
agentStub: makeStub({
|
||||
hunt: (round, key, model) =>
|
||||
model === 'opus' && key === firstLens[round]
|
||||
? { findings: [finding(round, { file: `Server/ws/f${round}.go` })] }
|
||||
: none,
|
||||
verify: (round, key, cands) => confirmAll(cands),
|
||||
}),
|
||||
})
|
||||
assert.equal(result.rounds.length, 3)
|
||||
assert.equal(result.converged, false)
|
||||
assert.equal(result.stoppedOnBudget, false)
|
||||
assert.equal(result.confirmed.length, 3)
|
||||
assert.deepEqual(result.rounds.map((r) => r.dryAfter), [0, 0, 0])
|
||||
}
|
||||
|
||||
// S5: failed finder -> round dry-ineligible; dry counter neither increments nor resets.
|
||||
scenarios.s5_finder_failure_ineligible = async () => {
|
||||
const { result } = await run({
|
||||
args: { maxRounds: 3 },
|
||||
agentStub: makeStub({
|
||||
hunt: (round, key, model) => {
|
||||
if (round === 1 && key === 'ws-hub' && model === 'opus') return { findings: [finding(1)] }
|
||||
if (round === 2 && key === 'concurrency' && model === 'opus') return null // dead finder
|
||||
return none
|
||||
},
|
||||
verify: (round, key, cands) => confirmAll(cands),
|
||||
}),
|
||||
})
|
||||
assert.equal(result.rounds[1].dryEligible, false)
|
||||
assert.deepEqual(result.rounds.map((r) => r.dryAfter), [0, 0, 1])
|
||||
assert.equal(result.converged, false)
|
||||
}
|
||||
|
||||
// S6: failed verifier retried once, retry succeeds.
|
||||
scenarios.s6_verifier_retry = async () => {
|
||||
const { result, calls } = await run({
|
||||
agentStub: makeStub({
|
||||
hunt: (round, key, model) =>
|
||||
round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none,
|
||||
verify: (round, key, cands, isRetry) => (isRetry ? confirmAll(cands) : null),
|
||||
}),
|
||||
})
|
||||
assert.ok(calls.some((c) => (c.opts.label || '').endsWith(':retry')))
|
||||
assert.equal(result.confirmed.length, 1)
|
||||
assert.equal(result.converged, true)
|
||||
}
|
||||
|
||||
// S6b: verifier fails twice -> candidate dropped unconfirmed, round ineligible;
|
||||
// re-reported later, verified then, and scrubbed from the unverified list.
|
||||
scenarios.s6b_verifier_double_failure = async () => {
|
||||
const { result } = await run({
|
||||
args: { maxRounds: 3 },
|
||||
agentStub: makeStub({
|
||||
hunt: (round, key, model) => {
|
||||
if (round === 1 && key === 'ws-hub' && model === 'opus') return { findings: [finding(3)] }
|
||||
if (round === 2 && key === 'state-desync' && model === 'opus') return { findings: [finding(3)] }
|
||||
return none
|
||||
},
|
||||
verify: (round, key, cands) => (round === 1 ? null : confirmAll(cands)),
|
||||
}),
|
||||
})
|
||||
assert.equal(result.rounds[0].dryEligible, false)
|
||||
assert.equal(result.rounds[0].confirmed, 0)
|
||||
assert.equal(result.confirmed.length, 1)
|
||||
assert.equal(result.confirmed[0].round, 2)
|
||||
assert.equal(result.unverified.length, 0, 'later-confirmed candidate must leave the unverified list')
|
||||
}
|
||||
|
||||
// S7: rounds 1-3 each confirm a bug -> round 4 runs adaptive lenses built from the stats.
|
||||
scenarios.s7_adaptive_lenses = async () => {
|
||||
const A = finding(1, { file: 'Server/ws/hub.go', line: 120, title: 'alpha race window one' })
|
||||
const B = finding(2, { file: 'Server/ws/pubsub.go', line: 60, title: 'beta subscription leak two' })
|
||||
const C = finding(3, { file: 'Client/tauri-client/src/lib/livekitE2EE.ts', line: 200, title: 'gamma epoch desync three' })
|
||||
const { result, calls } = await run({
|
||||
agentStub: makeStub({
|
||||
hunt: (round, key, model) => {
|
||||
if (model !== 'opus') return none
|
||||
if (round === 1 && key === 'ws-hub') return { findings: [A] }
|
||||
if (round === 2 && key === 'concurrency') return { findings: [B] }
|
||||
if (round === 3 && key === 'flow-voice') return { findings: [C] }
|
||||
return none
|
||||
},
|
||||
verify: (round, key, cands) => confirmAll(cands),
|
||||
}),
|
||||
})
|
||||
assert.equal(result.converged, true)
|
||||
assert.equal(result.rounds.length, 5) // r4, r5 adaptive + dry
|
||||
assert.equal(result.rounds[3].family, 'adaptive')
|
||||
const r4Hunts = calls.filter((c) => /^r4:hunt:/.test(c.opts.label || ''))
|
||||
const r4Keys = [...new Set(r4Hunts.map((c) => c.opts.label.split(':')[2]))]
|
||||
assert.ok(r4Keys.includes('hotspot-server-ws'), `r4 keys: ${r4Keys}`)
|
||||
assert.ok(r4Keys.includes('fresh-eyes'), `r4 keys: ${r4Keys}`)
|
||||
const hotspot = r4Hunts.find((c) => c.opts.label.includes('hotspot-server-ws'))
|
||||
assert.match(hotspot.prompt, /Server\/ws\/hub\.go/)
|
||||
assert.match(hotspot.prompt, /alpha race window one/)
|
||||
const freshEyes = r4Hunts.find((c) => c.opts.label.includes('fresh-eyes'))
|
||||
assert.match(freshEyes.prompt, /Server\/api\/user\.go/) // churned, never a finding
|
||||
assert.equal(result.confirmed.length, 3)
|
||||
}
|
||||
|
||||
// S7b: a lens with 2 consecutive clean rounds is demoted from later rounds.
|
||||
scenarios.s7b_demotion = async () => {
|
||||
const { result, calls } = await run({
|
||||
args: { maxRounds: 6 },
|
||||
agentStub: makeStub({
|
||||
hunt: (round, key, model) => {
|
||||
if (model !== 'opus') return none
|
||||
const src = { 1: 'ws-hub', 2: 'concurrency', 3: 'flow-reconnect', 4: 'hotspot-server-ws', 5: 'hotspot-server-ws' }
|
||||
if (key === src[round])
|
||||
return { findings: [finding(round, { file: `Server/ws/a${round}.go`, title: `unique bug number${round} zeta${round}` })] }
|
||||
return none
|
||||
},
|
||||
verify: (round, key, cands) => confirmAll(cands),
|
||||
}),
|
||||
})
|
||||
const labels = calls.map((c) => c.opts.label || '')
|
||||
assert.ok(labels.some((l) => /^r5:hunt:fresh-eyes:/.test(l)), 'fresh-eyes still runs in r5 (streak 1)')
|
||||
assert.ok(!labels.some((l) => /^r6:hunt:fresh-eyes:/.test(l)), 'fresh-eyes demoted in r6 (streak 2)')
|
||||
assert.ok(labels.some((l) => /^r6:hunt:hotspot-server-ws:/.test(l)), 'producing hotspot keeps running')
|
||||
assert.equal(result.converged, false)
|
||||
assert.equal(result.confirmed.length, 5)
|
||||
}
|
||||
|
||||
// S7c: a lens whose VERIFIER died is not demoted; a zero-candidate lens still is.
|
||||
scenarios.s7c_verifier_failure_not_demoted = async () => {
|
||||
const early = { 1: 'ws-hub', 2: 'concurrency', 3: 'flow-reconnect' }
|
||||
const { result, calls } = await run({
|
||||
args: { maxRounds: 6 },
|
||||
agentStub: makeStub({
|
||||
hunt: (round, key, model) => {
|
||||
if (model !== 'opus') return none
|
||||
if (round <= 3 && key === early[round])
|
||||
return { findings: [finding(round, { file: `Server/ws/a${round}.go`, title: `early bug item${round} kappa${round}` })] }
|
||||
if (round >= 4 && key === 'hotspot-server-ws')
|
||||
return { findings: [finding(round + 10, { file: `Server/ws/b${round}.go`, title: `late bug item${round} sigma${round}` })] }
|
||||
return none
|
||||
},
|
||||
verify: (round, key, cands) => (round <= 3 ? confirmAll(cands) : null),
|
||||
}),
|
||||
})
|
||||
const labels = calls.map((c) => c.opts.label || '')
|
||||
assert.ok(labels.some((l) => /^r6:hunt:hotspot-server-ws:/.test(l)), 'verifier-dead lens must NOT be demoted')
|
||||
assert.ok(!labels.some((l) => /^r6:hunt:fresh-eyes:/.test(l)), 'zero-candidate lens still accrues streak and demotes')
|
||||
assert.equal(result.confirmed.length, 3)
|
||||
assert.equal(result.unverified.length, 3)
|
||||
assert.equal(result.converged, false)
|
||||
assert.ok(result.rounds.slice(3).every((r) => r.dryEligible === false))
|
||||
}
|
||||
|
||||
// S12: empty adaptive family (no confirms, no churn) must break honestly, not count dry rounds.
|
||||
scenarios.s12_empty_adaptive_family = async () => {
|
||||
const { result } = await run({
|
||||
agentStub: makeStub({
|
||||
recon: () => 'no parseable churn output',
|
||||
hunt: (round, key, model) => {
|
||||
if (round <= 2 && key === (round === 1 ? 'ws-hub' : 'concurrency') && model === 'opus')
|
||||
return { findings: [finding(round, { file: `Server/ws/c${round}.go`, title: `verifierless bug delta${round} theta${round}` })] }
|
||||
return none
|
||||
},
|
||||
verify: () => null,
|
||||
}),
|
||||
})
|
||||
assert.equal(result.rounds.length, 3)
|
||||
assert.equal(result.converged, false)
|
||||
assert.equal(result.confirmed.length, 0)
|
||||
assert.equal(result.unverified.length, 2)
|
||||
}
|
||||
|
||||
// S8: budget below the round floor before round 1 -> zero rounds, honest non-convergence.
|
||||
scenarios.s8_budget_floor = async () => {
|
||||
const { result, calls } = await run({
|
||||
budget: { total: 1000000, spent: () => 900000, remaining: () => 100000 },
|
||||
agentStub: makeStub({ hunt: () => none, verify: (r, k, c) => confirmAll(c) }),
|
||||
})
|
||||
assert.equal(result.rounds.length, 0)
|
||||
assert.equal(result.stoppedOnBudget, true)
|
||||
assert.equal(result.converged, false)
|
||||
assert.ok(!calls.some((c) => /:hunt:/.test(c.opts.label || '')))
|
||||
assert.match(result.report, /budget/i)
|
||||
}
|
||||
|
||||
// S8b: budget runs low mid-hunt -> finishes the round it started, stops before the next.
|
||||
scenarios.s8b_budget_midrun = async () => {
|
||||
let n = 0
|
||||
const { result } = await run({
|
||||
budget: { total: 1000000, spent: () => 0, remaining: () => (n++ === 0 ? 200000 : 100000) },
|
||||
agentStub: makeStub({
|
||||
hunt: (round, key, model) =>
|
||||
round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none,
|
||||
verify: (round, key, cands) => confirmAll(cands),
|
||||
}),
|
||||
})
|
||||
assert.equal(result.rounds.length, 1)
|
||||
assert.equal(result.stoppedOnBudget, true)
|
||||
assert.equal(result.converged, false)
|
||||
assert.equal(result.confirmed.length, 1)
|
||||
}
|
||||
|
||||
// S14: the title-word dedupe branch applies only near the prior's location.
|
||||
// Dedupe is permanent, so merging two distinct same-file bugs that happen to
|
||||
// share half their title words loses the second one forever.
|
||||
scenarios.s14_title_dedupe_window = async () => {
|
||||
const near = { file: 'Server/ws/hub.go', line: 140, title: 'hub client map race on register path' }
|
||||
const far = { file: 'Server/ws/hub.go', line: 900, title: 'hub client map race on unregister' }
|
||||
const verifyBatches = []
|
||||
const { result } = await run({
|
||||
agentStub: makeStub({
|
||||
hunt: (round, key, model) => {
|
||||
if (model !== 'opus') return none
|
||||
if (round === 1 && key === 'ws-hub')
|
||||
return { findings: [finding(1, { file: 'Server/ws/hub.go', line: 100, title: 'hub client map race on register' })] }
|
||||
if (round === 2 && key === 'concurrency')
|
||||
return { findings: [finding(2, far), finding(3, near)] }
|
||||
return none
|
||||
},
|
||||
verify: (round, key, cands) => {
|
||||
verifyBatches.push(cands.map((c) => c.line))
|
||||
return confirmAll(cands)
|
||||
},
|
||||
}),
|
||||
})
|
||||
assert.deepEqual(verifyBatches, [[100], [900]], 'near-duplicate dropped, distant same-file bug kept')
|
||||
assert.equal(result.confirmed.length, 2)
|
||||
assert.ok(result.confirmed.some((c) => c.line === 900), 'the distant bug must survive dedupe')
|
||||
}
|
||||
|
||||
// S13: JSON-stringified args must behave identically to object args (observed live: the
|
||||
// runtime can deliver args as a string; maxRounds:1 silently fell back to 8 before the coercion).
|
||||
scenarios.s13_string_args = async () => {
|
||||
const { result, calls } = await run({
|
||||
args: '{"maxRounds": 1}',
|
||||
agentStub: makeStub({
|
||||
hunt: (round, key, model) =>
|
||||
round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none,
|
||||
verify: (round, key, cands) => confirmAll(cands),
|
||||
}),
|
||||
})
|
||||
assert.equal(result.rounds.length, 1, 'string maxRounds:1 must cap the loop at one round')
|
||||
assert.equal(result.converged, false)
|
||||
assert.equal(result.confirmed.length, 1)
|
||||
assert.ok(!calls.some((c) => (c.opts.label || '').startsWith('r2:')), 'no round 2 under the cap')
|
||||
}
|
||||
|
||||
// S10: verifier returns truncated (empty) verdict lists on both attempts ->
|
||||
// candidates land in unverified, round ineligible, dry counter untouched.
|
||||
scenarios.s10_truncated_verdicts = async () => {
|
||||
const { result, calls } = await run({
|
||||
args: { maxRounds: 2 },
|
||||
agentStub: makeStub({
|
||||
hunt: (round, key, model) =>
|
||||
round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none,
|
||||
verify: () => ({ verdicts: [] }),
|
||||
}),
|
||||
})
|
||||
assert.ok(calls.some((c) => (c.opts.label || '').endsWith(':retry')), 'short verdict list must trigger the retry')
|
||||
assert.equal(result.rounds[0].dryEligible, false)
|
||||
assert.deepEqual(result.rounds.map((r) => r.dryAfter), [0, 1])
|
||||
assert.equal(result.confirmed.length, 0)
|
||||
assert.equal(result.unverified.length, 1)
|
||||
assert.equal(result.converged, false)
|
||||
}
|
||||
|
||||
// S11: verdict coordinates drift from the candidate's -> still pairs, confirms once,
|
||||
// nothing listed unverified, and a round-2 re-report of the ORIGINAL coords is deduped.
|
||||
scenarios.s11_drifted_verdict = async () => {
|
||||
const orig = finding(4) // file Server/ws/hub.go, line 260
|
||||
const { result, calls } = await run({
|
||||
agentStub: makeStub({
|
||||
hunt: (round, key, model) => {
|
||||
if (round === 1 && key === 'ws-hub' && model === 'opus') return { findings: [orig] }
|
||||
if (round === 2 && key === 'state-desync' && model === 'opus') return { findings: [orig] }
|
||||
return none
|
||||
},
|
||||
verify: (round, key, cands) => ({
|
||||
verdicts: cands.map((c) => ({
|
||||
title: c.title, file: c.file, line: c.line + 5,
|
||||
refuted: false, reason: 'confirmed', confidence: 'high', severity: 'high', fix: 'fix',
|
||||
})),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
const verifyRounds = calls
|
||||
.map((c) => /^r(\d+):verify:/.exec(c.opts.label || ''))
|
||||
.filter(Boolean)
|
||||
.map((m) => Number(m[1]))
|
||||
assert.deepEqual(verifyRounds, [1], 'drifted-but-paired verdict must still suppress the original coords')
|
||||
assert.equal(result.confirmed.length, 1)
|
||||
assert.equal(result.unverified.length, 0)
|
||||
assert.equal(result.converged, true)
|
||||
}
|
||||
|
||||
// ---------- runner ----------
|
||||
const only = process.argv[2]
|
||||
for (const [name, fn] of Object.entries(scenarios)) {
|
||||
if (only && !name.includes(only)) continue
|
||||
try {
|
||||
await fn()
|
||||
} catch (e) {
|
||||
console.error(`FAIL ${name}`)
|
||||
throw e
|
||||
}
|
||||
console.log(`PASS ${name}`)
|
||||
}
|
||||
console.log('all scenarios pass')
|
||||
@@ -0,0 +1,565 @@
|
||||
export const meta = {
|
||||
name: 'bughunt',
|
||||
description: 'Converging multi-round bug hunt: rotating lens families, dual-model panels, fable refute-by-default verification, dry-threshold stop',
|
||||
whenToUse: 'Hunting real bugs across the Go server, Tauri Rust backend, and TS client until consecutive rounds go dry. Not a security-only scan.',
|
||||
phases: [
|
||||
{ title: 'Recon', detail: 'haiku: churn + concurrency-surface inventory' },
|
||||
{ title: 'Report', detail: 'fable: ranked findings + convergence table' },
|
||||
],
|
||||
}
|
||||
|
||||
// ---------- config ----------
|
||||
// args may arrive JSON-stringified (observed in run wf_9199e623-b83: maxRounds:1 never took) - coerce
|
||||
const ARGS = (() => {
|
||||
if (typeof args === 'string') {
|
||||
try { return JSON.parse(args) || {} } catch { return {} }
|
||||
}
|
||||
return args || {}
|
||||
})()
|
||||
const MAX_ROUNDS = ARGS.maxRounds || 8
|
||||
const DRY_THRESHOLD = ARGS.dryThreshold || 2
|
||||
// ponytail: rough floor for one round (up to 12 high-effort finders + verifiers); tune after live runs
|
||||
const ROUND_BUDGET_FLOOR = 150000
|
||||
// The args channel has already been observed delivering something the script
|
||||
// could not read; an unnoticed fallback here is an 8x cost surprise, so say out
|
||||
// loud what the run is actually going to do.
|
||||
log(`config: maxRounds=${MAX_ROUNDS} dryThreshold=${DRY_THRESHOLD}`)
|
||||
|
||||
// ---------- schemas: copied VERBATIM from the current bughunt.js ----------
|
||||
const FINDINGS = {
|
||||
type: 'object',
|
||||
required: ['findings'],
|
||||
properties: {
|
||||
findings: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['title', 'file', 'line', 'severity', 'why', 'repro'],
|
||||
properties: {
|
||||
title: { type: 'string' },
|
||||
file: { type: 'string', description: 'repo-relative path' },
|
||||
line: { type: 'integer' },
|
||||
severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
|
||||
why: { type: 'string', description: 'the defect, one or two sentences' },
|
||||
repro: { type: 'string', description: 'concrete inputs/interleaving -> wrong behavior' },
|
||||
evidence: { type: 'string', description: 'the code lines that prove it' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const VERDICTS = {
|
||||
type: 'object',
|
||||
required: ['verdicts'],
|
||||
properties: {
|
||||
verdicts: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['title', 'file', 'line', 'refuted', 'reason', 'confidence', 'severity'],
|
||||
properties: {
|
||||
title: { type: 'string' },
|
||||
file: { type: 'string' },
|
||||
line: { type: 'integer' },
|
||||
refuted: { type: 'boolean' },
|
||||
reason: { type: 'string', description: 'what refutes it, or what confirms it in the code' },
|
||||
confidence: { type: 'string', enum: ['high', 'medium', 'low'] },
|
||||
severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
|
||||
fix: { type: 'string', description: 'smallest correct fix, if confirmed' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ---------- rules ----------
|
||||
const RULES = `
|
||||
Repo: OwnCord, at D:/Local-Lab/Repos/OwnCord. Go 1.26 server in Server/, Tauri v2 client in Client/tauri-client/
|
||||
(Rust in src-tauri/src/, TypeScript in src/lib/ and src/stores/).
|
||||
|
||||
You are hunting REAL BUGS: wrong behavior, not style. In scope:
|
||||
- logic errors, off-by-one, wrong operator, inverted condition, wrong default
|
||||
- concurrency: data races, deadlocks, lock-order inversion, missed wakeups, goroutine leaks, TOCTOU
|
||||
- lifecycle: use-after-close, double-close, nil deref on error paths, leaked resources/listeners/timers
|
||||
- state machines that can reach an unintended state, or desync between two sources of truth
|
||||
- error paths that silently swallow, lose data, or leave partial writes
|
||||
- auth/authz checks reading stale state, or missing on one path while present on siblings
|
||||
|
||||
Out of scope, do not report: naming, formatting, missing tests, "consider adding", speculative hardening,
|
||||
performance that is not a hang, anything you cannot point at specific lines for.
|
||||
|
||||
Method:
|
||||
1. Read the actual files. Never report from a filename or a grep hit alone.
|
||||
2. For every candidate, grep for ALL callers before judging - a guard may already live upstream.
|
||||
3. Check whether an existing test already locks the behavior you think is wrong. If a test asserts it,
|
||||
it is intended behavior, not a bug. Test files are *_test.go and tests/unit/*.test.ts.
|
||||
4. Report EVERY finding you can prove - there is no cap. The quality bar stays: zero findings is a
|
||||
valid, respectable answer, and each finding needs file, line, and a concrete repro.
|
||||
|
||||
You may run read-only shell commands (grep, git log, go doc). Do not modify any file. Do not run the test suite.
|
||||
`
|
||||
|
||||
// ---------- lens catalog ----------
|
||||
// keys must match /^[a-z0-9-]+$/ - they are embedded in agent labels the harness parses.
|
||||
const SURFACE_LENSES = [
|
||||
{
|
||||
key: 'ws-hub',
|
||||
prompt:
|
||||
`Surface: the WebSocket hub and its client lifecycle. Files: Server/ws/*.go (skip *_test.go) - start with ` +
|
||||
`client.go, hub*.go, emit.go, event.go, event_persister.go, event_pruner.go, handlers*.go, command.go.\n\n` +
|
||||
`Hunt specifically for: send on closed channel; write to a client after unregister; hub map mutated without ` +
|
||||
`the right lock held; lock ordering between hub and client; a goroutine that outlives its client; ` +
|
||||
`read-pump/write-pump shutdown races; events emitted to a client mid-unregister; event ordering that can ` +
|
||||
`invert under concurrent publish; pruner racing the persister over the same rows.\n` +
|
||||
`Trace at least one full connect -> subscribe -> emit -> disconnect path end to end before reporting anything.`,
|
||||
},
|
||||
{
|
||||
key: 'voice-e2ee',
|
||||
prompt:
|
||||
`Surface: voice/video E2EE key lifecycle, spanning three languages. Files: Server/ws/handler_v2_voice*.go and ` +
|
||||
`any Server/ws/*voice*.go or *e2ee*.go; Client/tauri-client/src/lib/e2eeCrypto.ts, livekitE2EE.ts, ` +
|
||||
`livekitSession.ts, identity.ts; Client/tauri-client/src-tauri/src/tofu.rs, secret_store.rs, fallback_crypto.rs, dpapi.rs.\n\n` +
|
||||
`Hunt specifically for: a key-rotation window where a participant can decrypt after they should be excluded; ` +
|
||||
`TOFU pin re-check that reads state captured before a rotation (time-of-check/time-of-use); a participant ` +
|
||||
`joining mid-rotation getting the wrong epoch key; key material outliving the session; an error path that ` +
|
||||
`falls back to unencrypted or to a zeroed/default key; sender/receiver epoch disagreement after reconnect.\n` +
|
||||
`This area was hardened before - check git log for the relevant commits and do NOT re-report anything already fixed.`,
|
||||
},
|
||||
{
|
||||
key: 'api-authz',
|
||||
prompt:
|
||||
`Surface: REST API auth and authorization. Files: Server/api/*.go (skip *_test.go), Server/auth/*.go, ` +
|
||||
`Server/permissions/*.go.\n\n` +
|
||||
`Hunt specifically for: a permission checked against a snapshot that can go stale before it is used; ` +
|
||||
`a handler that checks channel access but not server/guild access (or vice versa); an ID taken from the ` +
|
||||
`request body when it should come from the session; sibling handlers where one path has a guard and a ` +
|
||||
`near-identical one does not; rate limiter keyed on something the caller controls; role/override resolution ` +
|
||||
`that returns allow on error instead of deny.\n` +
|
||||
`Compare handlers against each other - the strongest signal here is inconsistency between siblings.`,
|
||||
},
|
||||
{
|
||||
key: 'db-storage',
|
||||
prompt:
|
||||
`Surface: persistence. Files: Server/db/*.go (NOT db/dbgen/, that is generated), Server/db/queries/*.sql, ` +
|
||||
`Server/migrations/*.sql, Server/storage/*.go, Server/service/*.go.\n\n` +
|
||||
`Hunt specifically for: a multi-statement operation that is not in one transaction and can leave partial state; ` +
|
||||
`a tx that can be committed twice or leaked without rollback on an early return; sql.ErrNoRows treated as a ` +
|
||||
`real error or swallowed as success; a query whose SQL semantics disagree with what the caller assumes ` +
|
||||
`(LIMIT, ordering, NULL handling, JOIN dropping rows); a migration that is not idempotent or that breaks ` +
|
||||
`an older row shape; unbounded result sets read fully into memory.\n` +
|
||||
`Read the .sql alongside its Go caller - the bug is usually the gap between them.`,
|
||||
},
|
||||
{
|
||||
key: 'tauri-rust',
|
||||
prompt:
|
||||
`Surface: the Tauri Rust backend. Files: Client/tauri-client/src-tauri/src/*.rs.\n\n` +
|
||||
`Hunt specifically for: a panic reachable from a Tauri command (unwrap/expect on attacker- or ` +
|
||||
`environment-controlled input) - a panic here can take down the app; a lock held across .await; ` +
|
||||
`state in tauri::State mutated from two commands without coordination; the http_proxy / livekit_proxy / ` +
|
||||
`ws_proxy forwarding a header, URL, or origin it should filter; credentials/secret_store material logged, ` +
|
||||
`left in memory, or written unencrypted on a fallback path; ptt.rs global hook not released on shutdown.\n` +
|
||||
`For each panic you find, state exactly which input reaches it.`,
|
||||
},
|
||||
{
|
||||
key: 'client-state',
|
||||
prompt:
|
||||
`Surface: TypeScript client state and event handling. Files: Client/tauri-client/src/lib/*.ts and ` +
|
||||
`src/stores/*.ts - prioritize dispatcher.ts, reconcile.ts, read-state.ts, router.ts, roomEventHandlers.ts, ` +
|
||||
`navigation-guard.ts, rate-limiter.ts, channel-navigation.ts, and whatever the churn recon flagged.\n\n` +
|
||||
`Hunt specifically for: a listener/interval/observer registered without a matching teardown (check ` +
|
||||
`disposable.ts for the intended pattern and find who bypasses it); reconcile logic that drops or duplicates ` +
|
||||
`an entity when events arrive out of order; read-state that can mark unread messages read, or lose an unread ` +
|
||||
`count, across a reconnect; an async handler whose await lets stale state be written after a newer update ` +
|
||||
`(last-write-wins race); a route guard bypassable by a rapid navigation sequence.\n` +
|
||||
`Check tests/unit/ before reporting - much of this behavior is already test-locked.`,
|
||||
},
|
||||
]
|
||||
|
||||
const BUGCLASS_LENSES = [
|
||||
{
|
||||
key: 'concurrency',
|
||||
prompt:
|
||||
`Bug class: concurrency and interleaving - sweep the whole repo for THIS CLASS ONLY.\n` +
|
||||
`Go (Server/): data races on maps/slices/fields shared between goroutines; lock-order inversion; ` +
|
||||
`missed wakeups; TOCTOU between a check and its use; goroutines racing shutdown; send on closed channel.\n` +
|
||||
`Rust (src-tauri/src/): a lock held across .await; tauri::State mutated from two commands without ` +
|
||||
`coordination; Arc<Mutex<_>> cloned into tasks that outlive their owner.\n` +
|
||||
`TS (src/lib/, src/stores/): two async handlers interleaving on the same store (last-write-wins after ` +
|
||||
`an await); a stale closure writing state after a newer update already landed.\n` +
|
||||
`Use the recon concurrency-surface inventory to pick files. For every candidate, name the exact interleaving.`,
|
||||
},
|
||||
{
|
||||
key: 'lifecycle',
|
||||
prompt:
|
||||
`Bug class: lifecycle and teardown - sweep the whole repo for THIS CLASS ONLY.\n` +
|
||||
`Every acquire must have a matching release on EVERY exit path: goroutines outliving their owner; ` +
|
||||
`timers/intervals/listeners/workers registered without removal (client disposable.ts is the intended ` +
|
||||
`pattern - find who bypasses it); double-close and use-after-close; teardown-order mistakes; ` +
|
||||
`Rust Drop not running (mem::forget, leaked handles, the ptt.rs global hook); ` +
|
||||
`partial teardown when an error interrupts the happy path halfway.`,
|
||||
},
|
||||
{
|
||||
key: 'state-desync',
|
||||
prompt:
|
||||
`Bug class: two sources of truth drifting - sweep the whole repo for THIS CLASS ONLY.\n` +
|
||||
`Pairs to audit: hub client maps vs pubsub registrations; server voice state vs LiveKit vs client ` +
|
||||
`stores; client read-state vs server acked sequence numbers; DB rows vs in-memory caches; ` +
|
||||
`any two structures updated by different code paths. Find the path that updates one and not the ` +
|
||||
`other - reconnect, replacement, and error paths are where they diverge.`,
|
||||
},
|
||||
{
|
||||
key: 'error-paths',
|
||||
prompt:
|
||||
`Bug class: error-path data loss - sweep the whole repo for THIS CLASS ONLY.\n` +
|
||||
`Swallowed errors (err assigned and ignored, empty catch, unwrap_or(default) hiding failure); ` +
|
||||
`partial writes left behind on early return; fallbacks that silently degrade to wrong behavior; ` +
|
||||
`an error mapped to success upstream; cleanup skipped when the happy path is interrupted mid-way. ` +
|
||||
`Read every 'if err != nil', catch block, and .catch in the hot files from recon.`,
|
||||
},
|
||||
{
|
||||
key: 'ordering-boundary',
|
||||
prompt:
|
||||
`Bug class: ordering and boundaries - sweep the whole repo for THIS CLASS ONLY.\n` +
|
||||
`Off-by-one and fence-post errors; LIMIT/pagination silently truncating; sequence-number gaps, ` +
|
||||
`duplication, or inversion between assignment and delivery; sort-stability and tie assumptions; ` +
|
||||
`first/last/empty-collection special cases; inclusive-vs-exclusive range disagreements between a ` +
|
||||
`caller and its callee (read the SQL alongside its Go caller).`,
|
||||
},
|
||||
]
|
||||
|
||||
const FLOW_LENSES = [
|
||||
{
|
||||
key: 'flow-reconnect',
|
||||
prompt:
|
||||
`Flow: WebSocket drop -> reconnect -> resume. Trace it END TO END across all three languages before ` +
|
||||
`reporting anything. Server: the serve handshake/resume path, hub client replacement and state ` +
|
||||
`transfer (this transfer has needed four separate fixes: unsubscribe identity, VoiceTopic+E2EE key ` +
|
||||
`transfer, focused-channel transfer, closeSend ordering - hunt for what it STILL misses), topic ` +
|
||||
`re-subscription, cold/warm replay tiers. Client: the reconnect loop, seq ack tracking, store ` +
|
||||
`reconcile after resume. Report any state that exists on the old connection and does not provably ` +
|
||||
`reach the new one.`,
|
||||
},
|
||||
{
|
||||
key: 'flow-voice',
|
||||
prompt:
|
||||
`Flow: voice join -> E2EE key announce/offer -> key-holder election -> rotation -> participant ` +
|
||||
`leave -> LiveKit webhook -> cleanup. Trace it END TO END: Server/ws/*voice*, livekit_webhook.go, ` +
|
||||
`client livekitE2EE.ts and livekitSession.ts, Rust livekit_proxy.rs. Hunt for: a participant who can ` +
|
||||
`still decrypt after they should be excluded; holder-election stalls; epoch/key disagreement after ` +
|
||||
`reconnect; the three take-out-of-voice paths (webhook, sweep, voice_leave) diverging.`,
|
||||
},
|
||||
{
|
||||
key: 'flow-message',
|
||||
prompt:
|
||||
`Flow: message send -> permission gate -> persist -> sequence assign -> fan-out -> replay tiers -> ` +
|
||||
`client store -> read-state/unread counts. Trace it END TO END and hunt the gaps BETWEEN layers: ` +
|
||||
`persisted but never fanned out; delivered but sequence-skipped; acked via max(seq) while a lower ` +
|
||||
`seq was dropped; unread counts drifting from actual unread messages across reconnect or channel switch.`,
|
||||
},
|
||||
{
|
||||
key: 'flow-session',
|
||||
prompt:
|
||||
`Flow: login -> session/token issue -> per-connection auth -> revocation/sweep -> kick -> API-token ` +
|
||||
`paths. Trace it END TO END and hunt stale-authorization windows: state checked at connect but not ` +
|
||||
`re-checked at use; revocation that kicks the WS but leaves another surface authorized; the sweep ` +
|
||||
`racing an in-flight request; API tokens diverging from session-token semantics on any path.`,
|
||||
},
|
||||
]
|
||||
|
||||
function lensesForRound(round) {
|
||||
if (round === 1) return SURFACE_LENSES
|
||||
if (round === 2) return BUGCLASS_LENSES
|
||||
if (round === 3) return FLOW_LENSES
|
||||
return buildAdaptiveLenses()
|
||||
}
|
||||
function familyName(round) {
|
||||
return ['surfaces', 'bug-classes', 'flows'][round - 1] || 'adaptive'
|
||||
}
|
||||
function clusterOf(file) {
|
||||
const parts = String(file).split('/')
|
||||
return parts.slice(0, parts[0] === 'Client' ? 3 : 2).join('/')
|
||||
}
|
||||
function freshEyesLens() {
|
||||
const files = churnFiles.filter((f) => !seen.some((s) => s.file === f)).slice(0, 10)
|
||||
if (!files.length) return []
|
||||
return [
|
||||
{
|
||||
key: 'fresh-eyes',
|
||||
prompt:
|
||||
`These files churned heavily in the last 8 weeks, yet no hunt round has confirmed or refuted a ` +
|
||||
`single finding in them - either they are clean or every lens so far walked past them. Read each ` +
|
||||
`one IN FULL with fresh eyes and hunt for real bugs of any class:\n` +
|
||||
files.map((f) => ` - ${f}`).join('\n'),
|
||||
},
|
||||
]
|
||||
}
|
||||
function buildAdaptiveLenses() {
|
||||
const byCluster = {}
|
||||
for (const c of confirmedAll) {
|
||||
const cl = clusterOf(c.file)
|
||||
if (!byCluster[cl]) byCluster[cl] = []
|
||||
byCluster[cl].push(c)
|
||||
}
|
||||
const top = Object.entries(byCluster)
|
||||
.sort((a, b) => b[1].length - a[1].length)
|
||||
.slice(0, 3)
|
||||
const hotspots = top.map(([cluster, items]) => ({
|
||||
key: ('hotspot ' + cluster).toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||
prompt:
|
||||
`Bugs cluster. Confirmed findings so far in ${cluster}:\n` +
|
||||
items.map((i) => ` - ${i.file}:${i.line} ${i.title}`).join('\n') +
|
||||
`\nHunt ADJACENT to these: the same functions' siblings, every caller, the counterpart operations ` +
|
||||
`(subscribe/unsubscribe, open/close, register/transfer, acquire/release), and the paths a past fix ` +
|
||||
`here did NOT cover. Do not re-report the findings listed above - they are already known.`,
|
||||
}))
|
||||
return [...hotspots, ...freshEyesLens()]
|
||||
}
|
||||
|
||||
// ---------- dedupe + ledger helpers ----------
|
||||
function normTitle(t) {
|
||||
return String(t || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').split(/\s+/).filter((w) => w.length > 2)
|
||||
}
|
||||
// Dedupe is permanent: a candidate merged into an existing entry never comes
|
||||
// back, so an over-eager match silently loses a real bug rather than deferring
|
||||
// it. The title-word branch therefore only applies near the prior's location -
|
||||
// two distinct bugs in one file often share half their title words ("hub client
|
||||
// map race on register" vs "...on unregister"), and without a window the second
|
||||
// one is suppressed forever, sometimes by a merely REFUTED namesake.
|
||||
const TITLE_MATCH_WINDOW = 60
|
||||
function isDup(a, b) {
|
||||
if (a.file !== b.file) return false
|
||||
const delta = Math.abs((a.line || 0) - (b.line || 0))
|
||||
if (delta <= 10) return true
|
||||
if (delta > TITLE_MATCH_WINDOW) return false
|
||||
const aw = normTitle(a.title)
|
||||
if (!aw.length) return false
|
||||
const bw = new Set(normTitle(b.title))
|
||||
const hits = aw.filter((w) => bw.has(w)).length
|
||||
return hits * 2 >= aw.length
|
||||
}
|
||||
function dedupe(cands, priors) {
|
||||
const kept = []
|
||||
for (const c of cands) {
|
||||
if (priors.some((p) => isDup(c, p)) || kept.some((k) => isDup(c, k))) continue
|
||||
kept.push(c)
|
||||
}
|
||||
return kept
|
||||
}
|
||||
function seenBlock(seen) {
|
||||
if (!seen.length) return ''
|
||||
const lines = seen.map((s) => ` - ${s.file}:${s.line} [${s.status}] ${s.title}`)
|
||||
return `\n--- KNOWN FINDINGS (already investigated - do NOT re-report; refuted means examined and rejected) ---\n${lines.join('\n')}\n`
|
||||
}
|
||||
function convergenceTable(stats, converged, stoppedOnBudget) {
|
||||
const verdict = converged
|
||||
? `CONVERGED after ${stats.length} round(s).`
|
||||
: stoppedOnBudget
|
||||
? 'NOT converged - stopped on budget.'
|
||||
: 'NOT converged - hit the round backstop.'
|
||||
const rows = stats.map(
|
||||
(s) =>
|
||||
`| ${s.round} | ${s.family} | ${s.lenses} | ${s.candidates} | ${s.fresh} | ${s.confirmed} | ${s.refuted} | ${s.dryEligible ? 'yes' : 'NO'} | ${s.dryAfter} |`,
|
||||
)
|
||||
return [
|
||||
'## Convergence',
|
||||
'',
|
||||
verdict,
|
||||
'',
|
||||
'| round | family | lenses | candidates | fresh | confirmed | refuted | dry-eligible | dry after |',
|
||||
'|---|---|---|---|---|---|---|---|---|',
|
||||
...rows,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
// ---------- recon (verbatim from the current script, including both prompts) ----------
|
||||
phase('Recon')
|
||||
const recon = await parallel([
|
||||
() =>
|
||||
agent(
|
||||
`${RULES}\n\nRECON TASK (mechanical, do not hunt bugs yourself):\n` +
|
||||
`Run: git -C D:/Local-Lab/Repos/OwnCord log --since="8 weeks ago" --name-only --pretty=format: -- Server Client\n` +
|
||||
`Count how often each non-test source file changed. Return the 25 most-churned files with their counts, ` +
|
||||
`plus any file that changed in more than 6 distinct commits. High churn = where bugs concentrate.\n` +
|
||||
`Return plain text: one "path count" per line, most-churned first. No commentary.`,
|
||||
{ label: 'recon:churn', phase: 'Recon', model: 'haiku', effort: 'low' },
|
||||
),
|
||||
() =>
|
||||
agent(
|
||||
`${RULES}\n\nRECON TASK (mechanical, do not hunt bugs yourself):\n` +
|
||||
`Inventory the concurrency and lifecycle surface so the finders know where to look. Report:\n` +
|
||||
` (a) every Server/ non-test .go file containing "go func", "sync.", "chan ", "select {", or "context.WithCancel"\n` +
|
||||
` (b) every Client/tauri-client/src/**/*.ts (non-test) containing "addEventListener", "setInterval", "setTimeout", or "new AbortController"\n` +
|
||||
` (c) every Client/tauri-client/src-tauri/src/*.rs containing "unsafe", "Mutex", "RwLock", "spawn", or "unwrap()"\n` +
|
||||
`For each file give the path and a rough hit count. Return plain text grouped under (a)/(b)/(c). No commentary, no analysis.`,
|
||||
{ label: 'recon:surface', phase: 'Recon', model: 'haiku', effort: 'low' },
|
||||
),
|
||||
])
|
||||
const CONTEXT = `\n\n--- RECON: most-churned files (last 8 weeks) ---\n${recon[0] || 'unavailable'}\n\n--- RECON: concurrency & lifecycle surface ---\n${recon[1] || 'unavailable'}\n`
|
||||
const churnFiles = String(recon[0] || '')
|
||||
.split('\n')
|
||||
.map((l) => l.trim().split(/\s+/)[0])
|
||||
.filter((p) => p.includes('/'))
|
||||
log('Recon complete - starting converging rounds')
|
||||
|
||||
// ---------- round loop ----------
|
||||
const seen = []
|
||||
const confirmedAll = []
|
||||
const unverified = []
|
||||
const roundStats = []
|
||||
const cleanStreak = {}
|
||||
let dry = 0
|
||||
let round = 0
|
||||
let stoppedOnBudget = false
|
||||
|
||||
function finderPrompt(lens, rnd) {
|
||||
return (
|
||||
`${RULES}${CONTEXT}${seenBlock(seen)}\n\nThis is round ${rnd} of a converging hunt. Everything under ` +
|
||||
`KNOWN FINDINGS has already been investigated - spend zero effort re-deriving those; hunt for what is ` +
|
||||
`NOT on that list.\n\n${lens.prompt}`
|
||||
)
|
||||
}
|
||||
function verifyPrompt(lensKey, candidates) {
|
||||
return (
|
||||
`${RULES}\n\nYou are an ADVERSARIAL VERIFIER. Another model hunted the "${lensKey}" lens of this repo and ` +
|
||||
`produced the candidate findings below. Your job is to REFUTE them, not to agree with them.\n\n` +
|
||||
`For each candidate, independently: open the cited file, read the surrounding function in full, grep every ` +
|
||||
`caller, and look for an existing test that locks the current behavior. Then ask, in order:\n` +
|
||||
` 1. Does the cited code actually say what the finding claims? (Misread code is the most common failure.)\n` +
|
||||
` 2. Is the bad state actually reachable, or does an upstream guard/type/lock make it impossible?\n` +
|
||||
` 3. Is the described repro real - can you name the concrete inputs or the exact interleaving?\n` +
|
||||
` 4. Is this intended behavior that a test already asserts?\n\n` +
|
||||
`Set refuted=true if ANY of those kills it. DEFAULT TO refuted=true when you are uncertain - a false ` +
|
||||
`positive costs more than a miss here. Only set refuted=false when you can point at the specific lines ` +
|
||||
`that prove the bug and describe how it fires.\n` +
|
||||
`Re-rate severity yourself; do not inherit the hunter's rating. For each survivor, give the smallest ` +
|
||||
`correct fix - one guard in the shared function beats a guard in every caller.\n\n` +
|
||||
`Return one verdict per candidate, keeping title/file/line so they can be matched up.\n\n` +
|
||||
`--- CANDIDATES ---\n${JSON.stringify(candidates, null, 2)}`
|
||||
)
|
||||
}
|
||||
|
||||
while (dry < DRY_THRESHOLD && round < MAX_ROUNDS) {
|
||||
if (budget.total && budget.remaining() < ROUND_BUDGET_FLOOR) {
|
||||
stoppedOnBudget = true
|
||||
log(`Budget floor reached (${Math.round(budget.remaining() / 1000)}k left) - stopping before round ${round + 1}`)
|
||||
break
|
||||
}
|
||||
const family = lensesForRound(round + 1)
|
||||
if (!family || !family.length) break // nothing to hunt != everything demoted
|
||||
round++
|
||||
const lenses = family.filter((l) => (cleanStreak[l.key] || 0) < 2)
|
||||
if (!lenses.length) {
|
||||
dry++
|
||||
roundStats.push({ round, family: familyName(round), lenses: 0, candidates: 0, fresh: 0, confirmed: 0, refuted: 0, dryEligible: true, dryAfter: dry })
|
||||
log(`Round ${round}: every lens demoted - counts as a dry round (dry=${dry})`)
|
||||
continue
|
||||
}
|
||||
const rnd = round
|
||||
const seenAtStart = seen.slice()
|
||||
const lensResults = await pipeline(
|
||||
lenses,
|
||||
(lens) =>
|
||||
parallel([
|
||||
() => agent(finderPrompt(lens, rnd), { label: `r${rnd}:hunt:${lens.key}:opus`, phase: `Round ${rnd}`, model: 'opus', effort: 'high', schema: FINDINGS }),
|
||||
() => agent(finderPrompt(lens, rnd), { label: `r${rnd}:hunt:${lens.key}:sonnet`, phase: `Round ${rnd}`, model: 'sonnet', effort: 'high', schema: FINDINGS }),
|
||||
]).then((pair) => ({ lens, pair })),
|
||||
async (r) => {
|
||||
const { lens, pair } = r
|
||||
const finderFailed = pair.some((p) => p === null)
|
||||
const union = pair.filter(Boolean).flatMap((p) => p.findings || [])
|
||||
const fresh = dedupe(union, seenAtStart)
|
||||
if (!fresh.length) return { lens, finderFailed, unionCount: union.length, fresh: [], verdicts: [] }
|
||||
log(`r${rnd} ${lens.key}: ${fresh.length} fresh candidate(s) -> verification`)
|
||||
const vopts = { phase: `Round ${rnd}`, model: 'fable', effort: 'high', schema: VERDICTS }
|
||||
let v = await agent(verifyPrompt(lens.key, fresh), { ...vopts, label: `r${rnd}:verify:${lens.key}` })
|
||||
if (!v || (v.verdicts || []).length < fresh.length) {
|
||||
const retry = await agent(verifyPrompt(lens.key, fresh), { ...vopts, label: `r${rnd}:verify:${lens.key}:retry` })
|
||||
if (((retry && retry.verdicts) || []).length > ((v && v.verdicts) || []).length) v = retry
|
||||
}
|
||||
return { lens, finderFailed, unionCount: union.length, fresh, verdicts: (v && v.verdicts) || [] }
|
||||
},
|
||||
)
|
||||
|
||||
let eligible = !lensResults.some((r) => !r)
|
||||
let newConfirmed = 0
|
||||
let newRefuted = 0
|
||||
let candCount = 0
|
||||
let freshCount = 0
|
||||
for (const r of lensResults.filter(Boolean)) {
|
||||
candCount += r.unionCount
|
||||
freshCount += r.fresh.length
|
||||
if (r.finderFailed) eligible = false
|
||||
let lensConfirmed = 0
|
||||
const unmatched = r.fresh.slice()
|
||||
for (const v of r.verdicts) {
|
||||
const vRec = { file: v.file, line: v.line, title: v.title }
|
||||
const idx = unmatched.findIndex((f) => isDup(vRec, f) || isDup(f, vRec))
|
||||
if (idx === -1) {
|
||||
log(`r${round} ${r.lens.key}: verifier verdict "${v.title}" (${v.file}:${v.line}) matched no candidate - dropped`)
|
||||
continue
|
||||
}
|
||||
unmatched.splice(idx, 1)
|
||||
const rec = { file: v.file, line: v.line, title: v.title, status: v.refuted ? 'refuted' : 'confirmed' }
|
||||
if (seen.some((p) => isDup(rec, p))) continue // cross-lens same-round duplicate
|
||||
seen.push(rec)
|
||||
if (v.refuted) newRefuted++
|
||||
else {
|
||||
newConfirmed++
|
||||
lensConfirmed++
|
||||
confirmedAll.push({ ...v, lens: r.lens.key, round })
|
||||
}
|
||||
}
|
||||
if (unmatched.length) {
|
||||
eligible = false // partial verifier failure: some candidates got no verdict at all
|
||||
for (const f of unmatched) unverified.push({ ...f, lens: r.lens.key, round })
|
||||
}
|
||||
// a lens hunted at partial panel strength, or whose candidates never got a verdict, is not evidence of cleanliness
|
||||
if (!r.finderFailed && !unmatched.length) cleanStreak[r.lens.key] = lensConfirmed > 0 ? 0 : (cleanStreak[r.lens.key] || 0) + 1
|
||||
}
|
||||
|
||||
if (newConfirmed > 0) dry = 0
|
||||
else if (eligible) dry++
|
||||
// ineligible zero-confirm round: dry unchanged - "we didn't fully look" is not "it's clean"
|
||||
roundStats.push({ round, family: familyName(round), lenses: lenses.length, candidates: candCount, fresh: freshCount, confirmed: newConfirmed, refuted: newRefuted, dryEligible: eligible, dryAfter: dry })
|
||||
log(`Round ${round} (${familyName(round)}): ${newConfirmed} confirmed, ${newRefuted} refuted, dry=${dry}${eligible ? '' : ' (ineligible)'}`)
|
||||
}
|
||||
|
||||
const converged = dry >= DRY_THRESHOLD
|
||||
|
||||
// ---------- report ----------
|
||||
phase('Report')
|
||||
const RANK = { critical: 0, high: 1, medium: 2, low: 3 }
|
||||
const confirmedSorted = confirmedAll.slice().sort((a, b) => RANK[a.severity] - RANK[b.severity])
|
||||
const unverifiedFinal = unverified.filter((u) => !seen.some((p) => isDup(u, p)))
|
||||
const table = convergenceTable(roundStats, converged, stoppedOnBudget)
|
||||
|
||||
let report
|
||||
if (!confirmedSorted.length && !unverifiedFinal.length) {
|
||||
const outcome = converged ? 'Converged' : stoppedOnBudget ? 'Stopped on budget - NOT converged' : 'Hit the round backstop - NOT converged'
|
||||
report = `${outcome} after ${round} round(s) with zero confirmed findings.\n\n${table}`
|
||||
} else {
|
||||
report = await agent(
|
||||
`You are writing the final bug-hunt report for OwnCord (D:/Local-Lab/Repos/OwnCord).\n\n` +
|
||||
`The findings below already survived adversarial verification - do NOT re-litigate them, and do NOT add new ` +
|
||||
`ones. Your job is presentation and prioritization for a maintainer who will fix these today.\n\n` +
|
||||
`Spot-check the two highest-severity findings against the real files to make sure file paths and line numbers ` +
|
||||
`are accurate; correct them silently if they drifted.\n\n` +
|
||||
`Write markdown:\n` +
|
||||
` - Open with one paragraph: how many real bugs, in which subsystems, whether the hunt CONVERGED, and which ` +
|
||||
`finding to fix first and why.\n` +
|
||||
` - Then one section per finding, ordered by severity: a "### <severity> - <title>" heading, the ` +
|
||||
`\`file:line\` reference, what breaks and under exactly what conditions, and the smallest correct fix.\n` +
|
||||
(unverifiedFinal.length
|
||||
? ` - Then an "## Unverified - re-run" section listing these candidates whose verification failed twice: ` +
|
||||
`${JSON.stringify(unverifiedFinal)}\n`
|
||||
: '') +
|
||||
` - End with the convergence table below, VERBATIM.\n` +
|
||||
`Write in complete sentences. No emoji, no "consider" hedging.\n\n` +
|
||||
`--- CONFIRMED FINDINGS ---\n${JSON.stringify(confirmedSorted, null, 2)}\n\n` +
|
||||
`--- CONVERGENCE TABLE ---\n${table}`,
|
||||
{ label: 'report', phase: 'Report', model: 'fable', effort: 'high' },
|
||||
)
|
||||
}
|
||||
|
||||
return { converged, stoppedOnBudget, rounds: roundStats, confirmed: confirmedSorted, unverified: unverifiedFinal, report }
|
||||
@@ -17,6 +17,10 @@
|
||||
- [ ] Unit tests pass (`npm test` / `go test ./...`)
|
||||
- [ ] TypeScript check passes (`npx tsc --noEmit`)
|
||||
- [ ] Manual testing done (describe below)
|
||||
- [ ] Docs updated — anything under `docs/architecture/` (incl. `ux/`) whose
|
||||
"Source of truth" files this PR touches is updated in the same PR
|
||||
(their maintenance rule), and reference docs (`api.md`, `protocol.md`,
|
||||
`schema.md`, `server-configuration.md`) reflect any surface changes
|
||||
|
||||
## Screenshots
|
||||
|
||||
|
||||
+113
-63
@@ -37,7 +37,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache-dependency-path: Server/go.sum
|
||||
@@ -75,6 +75,19 @@ jobs:
|
||||
- name: Run tests with deadlock detection
|
||||
run: go test -tags deadlock -count=1 ./...
|
||||
|
||||
# Tag-gated tests (DC-06 / T-2026-07-25-16). The build-tag matrix above
|
||||
# only COMPILES the otel/wazero variants; the tests behind those tags
|
||||
# (plugin/sandbox_wazero_test.go, telemetry/telemetry_otel_test.go) ran
|
||||
# nowhere until this step. Scoped to the two packages that carry tagged
|
||||
# files — every other package is tag-invariant and already covered by the
|
||||
# race run above. One leg is enough; no -race (the runtime under the tag
|
||||
# is the concern, not new concurrency).
|
||||
- name: Run tag-gated tests (-tags wazero, -tags otel)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
go test -tags wazero -count=1 ./plugin/...
|
||||
go test -tags otel -count=1 ./telemetry/...
|
||||
|
||||
- name: Upload Go coverage
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
@@ -83,11 +96,18 @@ jobs:
|
||||
path: Server/coverage.out
|
||||
retention-days: 7
|
||||
|
||||
# verify: false — the action's default `config verify` pass fetches
|
||||
# golangci-lint.run's JSONSchema over HTTPS before linting anything, so a
|
||||
# timeout on that host fails a required job having run zero linters (it
|
||||
# took main red on d352696). `golangci-lint run` rejects a bad config on
|
||||
# its own; the schema pass only bought a prettier error message, priced
|
||||
# at a third-party site inside the gate.
|
||||
- name: Lint
|
||||
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
|
||||
with:
|
||||
version: v2.11.3
|
||||
working-directory: Server/
|
||||
verify: false
|
||||
|
||||
client-check:
|
||||
name: Client Static Checks
|
||||
@@ -107,25 +127,6 @@ jobs:
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Patch auto-generated Tauri TypeScript bindings
|
||||
working-directory: Client/tauri-client/
|
||||
# tauri-typegen generates an Event type that is intentionally unused in app code.
|
||||
# Rename it to _Event so @typescript-eslint/no-unused-vars does not fail.
|
||||
run: |
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const p = 'src/generated/events.ts';
|
||||
if (fs.existsSync(p)) {
|
||||
let c = fs.readFileSync(p, 'utf8');
|
||||
c = c.replace(/^type Event\b/gm, 'type _Event').replace(/^interface Event\b/gm, 'interface _Event');
|
||||
c = c.replace(/,\s*type Event\s*(?=\})/g, ' '); // unused named import from @tauri-apps/api/event
|
||||
fs.writeFileSync(p, c);
|
||||
console.log('Patched: renamed Event -> _Event in generated/events.ts');
|
||||
} else {
|
||||
console.log('src/generated/events.ts not found, skipping patch.');
|
||||
}
|
||||
"
|
||||
|
||||
# Scoped to shipped dependencies. The remaining high findings are all one
|
||||
# advisory, brace-expansion <=5.0.7, reaching us only through dev tooling
|
||||
# (eslint, @vitest/coverage-v8, stryker). Those are already on their
|
||||
@@ -144,6 +145,12 @@ jobs:
|
||||
- name: TypeScript check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: TypeScript check (Playwright specs)
|
||||
# The main tsconfig excludes tests/e2e from the app graph; this
|
||||
# project typechecks the 47 spec files + fixtures + the three
|
||||
# playwright configs so type rot cannot hide there.
|
||||
run: npx tsc -p tsconfig.e2e.json --noEmit
|
||||
|
||||
- name: ESLint (type-aware rules)
|
||||
run: npx eslint src/
|
||||
|
||||
@@ -151,7 +158,9 @@ jobs:
|
||||
run: npx prettier --check "src/**/*.ts" "tests/**/*.ts"
|
||||
|
||||
- name: Knip (unused code & deps)
|
||||
run: npx knip || true
|
||||
# Blocking since the 2026-08-04 remediation: the '|| true' era let a
|
||||
# real unused-export finding sit invisible in every green run.
|
||||
run: npx knip
|
||||
|
||||
# Unit tests live in their own job so a suite failure is visible as exactly one
|
||||
# failing check instead of masking the static gates above. The suite is GREEN
|
||||
@@ -218,7 +227,7 @@ jobs:
|
||||
components: clippy
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
|
||||
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
with:
|
||||
workspaces: Client/tauri-client/src-tauri
|
||||
|
||||
@@ -228,24 +237,23 @@ jobs:
|
||||
- name: Rust unit tests
|
||||
run: cargo test --lib
|
||||
|
||||
# Playwright e2e against the mocked-Tauri dev server. The suite is green
|
||||
# since the mock repair (start_http_proxy stub + voice-premise rewrite):
|
||||
# a full 255-test run passes locally in ~7.5 min at 1 worker. Runaway
|
||||
# protection lives in playwright.config.ts (maxFailures: 20 aborts a
|
||||
# systemic cascade early; globalTimeout: 20 min self-terminates with a
|
||||
# usable report) with timeout-minutes below as the outer backstop.
|
||||
# Playwright e2e against the mocked-Tauri dev server. Runaway protection
|
||||
# lives in playwright.config.ts (maxFailures: 20 aborts a systemic cascade
|
||||
# early; globalTimeout: 20 min self-terminates with a usable report) with
|
||||
# timeout-minutes below as the outer backstop.
|
||||
#
|
||||
# Still continue-on-error for now: a newly-revived 255-test browser suite
|
||||
# may harbor rare flakes (retries: 2 covers them, but confidence needs a
|
||||
# few green pushes first). Flip this job to blocking once it has been
|
||||
# stably green across several pushes.
|
||||
# BLOCKING since 2026-08-05 (DC-07): the post-repair soak recorded green
|
||||
# full-suite runs at 270, 276 and 291 tests across the 08-04/08-05 audit
|
||||
# branches, and the one hard CI failure in that window was a real spec bug
|
||||
# (updater install-settle race), which a non-blocking job would have let
|
||||
# rot. retries: 2 absorbs the known rare flake class (see E2E-ISSUES.md's
|
||||
# flake accounting).
|
||||
# See docs/audit-test-coverage-2026-07-25.md T-2026-07-25-21.
|
||||
# The native config (playwright.config.native.ts) is deliberately not wired
|
||||
# up — it needs a real server and a built desktop binary.
|
||||
client-e2e:
|
||||
name: Client E2E (Playwright, non-blocking)
|
||||
name: Client E2E (Playwright)
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
timeout-minutes: 25
|
||||
defaults:
|
||||
run:
|
||||
@@ -278,6 +286,53 @@ jobs:
|
||||
Client/tauri-client/test-results/
|
||||
retention-days: 7
|
||||
|
||||
# Admin-panel journey against a REAL server (no mocks): start-server.sh
|
||||
# builds the Go binary and boots it with a fresh temp data dir, and the
|
||||
# suite drives the embedded SPA through the first-run wizard, dashboard,
|
||||
# channel CRUD, audit log and re-login — the one DC-04 surface the mocked
|
||||
# suites cannot reach. Non-blocking while it earns its soak, same
|
||||
# graduation convention client-e2e followed.
|
||||
admin-e2e:
|
||||
name: Admin Panel E2E (real server, non-blocking)
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: Client/tauri-client/
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache-dependency-path: Server/go.sum
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browser
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run admin-panel journey
|
||||
run: npx playwright test --config=playwright.config.admin.ts
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: admin-e2e-report
|
||||
path: |
|
||||
Client/tauri-client/playwright-report/
|
||||
Client/tauri-client/test-results/
|
||||
retention-days: 7
|
||||
|
||||
# Blocking e2e subset: the parity-feature specs (tagged "@parity"), covering
|
||||
# the wire paths added in v1.2.0 (mentions/badges, per-channel mute, NSFW
|
||||
# gate, group DMs, role change, custom-emoji autocomplete, voice moderation).
|
||||
@@ -330,10 +385,10 @@ jobs:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Build image (no push)
|
||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: Server/
|
||||
push: false
|
||||
@@ -341,11 +396,30 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Full Tauri build only on PRs to main (expensive: ~15 min x2 multiplier)
|
||||
# Full Tauri build only on PRs to main (expensive: ~15 min x2 multiplier).
|
||||
#
|
||||
# Skipped for Dependabot: its PRs run under the separate `dependabot` secrets
|
||||
# scope, so TAURI_SIGNING_PRIVATE_KEY arrives empty and `npm run tauri build`
|
||||
# always aborts with "failed to decode secret key" while signing the updater
|
||||
# artifact — after a successful compile and bundle. That burned ~50 min of
|
||||
# runner time per dependency PR to produce a red check that never carried any
|
||||
# signal. Granting Dependabot the signing key would fix the symptom but hands
|
||||
# a release key to workflows triggered by third-party dependency updates.
|
||||
#
|
||||
# What still covers Dependabot PRs: the required `rust-tests` job compiles the
|
||||
# crate (cargo clippy --all-targets + cargo test --lib), so a dependency bump
|
||||
# that breaks the Rust build is still caught.
|
||||
# What this gives up on those PRs: bundling (NSIS/AppImage/deb), Windows and
|
||||
# ARM-specific compilation, and the `cargo audit` step below — that last one
|
||||
# overlaps with Dependabot's own cargo scanning, which is what opens these PRs
|
||||
# in the first place.
|
||||
tauri-build:
|
||||
name: Tauri Full Build (${{ matrix.os }})
|
||||
needs: client-check
|
||||
if: github.event_name == 'pull_request' && github.base_ref == 'main'
|
||||
if: >-
|
||||
github.event_name == 'pull_request'
|
||||
&& github.base_ref == 'main'
|
||||
&& github.actor != 'dependabot[bot]'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -388,37 +462,13 @@ jobs:
|
||||
components: clippy
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
|
||||
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
with:
|
||||
workspaces: Client/tauri-client/src-tauri
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install tauri-typegen
|
||||
run: cargo install tauri-typegen@0.5.0 --quiet
|
||||
|
||||
- name: Generate TypeScript IPC bindings
|
||||
working-directory: Client/tauri-client/
|
||||
run: cargo tauri-typegen generate
|
||||
|
||||
- name: Fix generated TypeScript bindings (tauri-typegen 0.5.0 workaround)
|
||||
working-directory: Client/tauri-client/
|
||||
# tauri-typegen 0.5.0 cannot map serde_json::Value to a TS type — patch post-generation.
|
||||
# Duplicate events are avoided at source by using one emit() call site per event name.
|
||||
run: |
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const tp = fs.readFileSync('src/generated/types.ts', 'utf8');
|
||||
if (!tp.includes('export type Value')) {
|
||||
fs.writeFileSync('src/generated/types.ts', tp.replace(
|
||||
'export interface CredentialData',
|
||||
'export type Value = unknown;\n\nexport interface CredentialData'
|
||||
));
|
||||
}
|
||||
console.log('Generated bindings patched.');
|
||||
"
|
||||
|
||||
- name: Clippy lint (Rust)
|
||||
working-directory: Client/tauri-client/src-tauri/
|
||||
run: cargo clippy -- -D warnings
|
||||
|
||||
@@ -26,13 +26,13 @@ jobs:
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
uses: anthropics/claude-code-action@9db594c7a0e82298c121c18b7f08aa1579ce7341 # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
|
||||
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
with:
|
||||
workspaces: Client/tauri-client/src-tauri
|
||||
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
|
||||
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
with:
|
||||
workspaces: Client/tauri-client/src-tauri
|
||||
|
||||
@@ -201,7 +201,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
|
||||
with:
|
||||
go-version: "1.26"
|
||||
|
||||
@@ -276,7 +276,7 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
|
||||
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
with:
|
||||
workspaces: Client/tauri-client/src-tauri
|
||||
|
||||
@@ -359,7 +359,7 @@ jobs:
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
@@ -379,7 +379,7 @@ jobs:
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: Server/
|
||||
push: true
|
||||
|
||||
+12
-5
@@ -2,11 +2,13 @@
|
||||
.env
|
||||
Server/.env
|
||||
|
||||
# Claude Code — local-only, never committed. A slashless pattern matches at any
|
||||
# depth, so these also cover Server/CLAUDE.md, Client/**/CLAUDE.md and nested
|
||||
# .claude/ dirs.
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
# Claude Code — local-only by default. The exceptions are committed on purpose:
|
||||
# a cloud session clones this repo and sees ONLY tracked files, so the CLAUDE.md
|
||||
# files, skills and workflows have to be here or it starts with no instructions.
|
||||
# Machine-local state (settings.local.json, locks) stays ignored.
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
!.claude/workflows/
|
||||
CLAUDE.local.md
|
||||
.mcp.json
|
||||
|
||||
@@ -25,6 +27,11 @@ docs/research/
|
||||
docs/superpowers/
|
||||
/skills/
|
||||
|
||||
# Mutation-testing output (npm run test:mutate). Local-only by design: a
|
||||
# surviving-mutant report maps exactly which behaviour nothing tests.
|
||||
Client/tauri-client/.stryker-tmp/
|
||||
Client/tauri-client/reports/
|
||||
|
||||
# Server runtime artifacts
|
||||
Server/chatserver.exe
|
||||
Server/chatserver.exe~
|
||||
|
||||
+200
-3
@@ -5,6 +5,203 @@ tooling (`npm run changelog`) auto-generates entries from commit messages
|
||||
on each release; this file is the curated counterpart that calls out
|
||||
behavioural changes operators must know about.
|
||||
|
||||
## v1.2.0-alpha.2
|
||||
|
||||
- **feat(client):** the login form has an **Auto connect** checkbox under
|
||||
Remember password. Ticking it makes that server connect automatically on
|
||||
launch — the same setting as the auto-login button on a server card, so
|
||||
the two stay in sync, and as before only one server can be auto-connect
|
||||
at a time.
|
||||
Ticking it also forces Remember password on and locks it: auto-connect
|
||||
replays the stored token, which is only written when the password is
|
||||
remembered, so the two cannot be set independently without producing a
|
||||
setting that silently does nothing.
|
||||
- **fix(client):** Remember password works again. The password was saved to
|
||||
the OS keyring but never returned to the client over IPC, so the login
|
||||
form could not prefill it — the box appeared to work and did nothing.
|
||||
- **fix:** three bug-hunt sweeps closed **233 verified defects** since
|
||||
`v1.2.0-alpha.1` — 26 in #1328, 107 in #1331, 100 in #1332 — each fixed
|
||||
test-first, with the failing assertion watched red against the unpatched
|
||||
code before the patch landed. The behavioural consequences worth knowing
|
||||
about are listed in the nine entries below.
|
||||
- **server:** WS hub reconnect and replay hardening (#1328, #1331).
|
||||
Cold-tier replay used to truncate silently instead of forcing a full
|
||||
ready, and a retention-pruned event log was accepted outright as a
|
||||
complete resume — the highest-impact fix in #1331, since any client whose
|
||||
reconnect gap crossed the 24h retention default was permanently desynced.
|
||||
Resume also silently dropped the focused channel's topic subscription,
|
||||
stopping message delivery until the user manually switched channels; it
|
||||
is now restored during the handshake. `visibilityChangeSeq` can now only
|
||||
move forward across its three writers — it previously could regress and
|
||||
skip a required resync.
|
||||
- **server:** voice/E2EE key-holder election and audience gating (#1328,
|
||||
#1331) — three key-holder desync bugs (no client demotion path, peer keys
|
||||
cleared on reconnect, missing re-election on the webhook and
|
||||
fresh-reconnect paths), plus re-election wired into the sweep and
|
||||
channel-cleanup paths. Voice events were READ-filtered while membership
|
||||
is CONNECT-only, so participants in that gap silently missed
|
||||
`voice_leave`, stalling key-holder election and forward-secrecy rotation.
|
||||
Deleting a channel now evicts its voice participants first — the cleanup
|
||||
function existed but had zero production callers, so the FK cascade used
|
||||
to strand them silently. Moderator mute/deafen now survives a
|
||||
voice-channel switch; joins to non-voice channels are rejected; archived
|
||||
channels are read-only and unjoinable.
|
||||
- **security(server):** roles/permissions (#1328, #1331) — `UpdateRole`
|
||||
allowed position collisions that `CreateRole` already rejected, so tied
|
||||
positions could read as equal rank in every hierarchy comparison; it now
|
||||
matches `CreateRole`'s validation. `can_send` is now recomputed per client
|
||||
on every role/override change, so a permission change takes effect for
|
||||
connected clients immediately rather than waiting on a reconnect.
|
||||
- **server:** attachments and admin data-safety (#1331) — migration **030**
|
||||
unlinks attachments on message delete instead of cascading, so a cascaded
|
||||
channel/DM delete no longer strands uploaded files on disk with no
|
||||
reclamation path. The 15-minute orphan-attachment sweep was deleting every
|
||||
avatar in the instance (avatars are, by design, attachments with no
|
||||
message link) on its first tick past the grace period, permanently 404ing
|
||||
every profile picture; a second bug in the same sweep collapsed the
|
||||
one-hour grace period to effectively zero, from a TEXT-comparison mismatch
|
||||
between an RFC3339 cutoff and SQLite's own timestamp format. A failed
|
||||
backup restore used to truncate the live database to zero bytes with no
|
||||
rollback, while the server kept answering requests against the now-closed
|
||||
DB and falsely claimed a restart was underway — it now restores the
|
||||
pre-restore safety copy on failure and requests the restart honestly.
|
||||
Also fixed: personal data is cleared on account deletion, banned users are
|
||||
excluded from owner lookup, the silent 1000-member roster cap is gone, and
|
||||
a sender's own read state now advances on send. Migration applies
|
||||
automatically on first start; no operator action needed.
|
||||
- **protocol:** a new READ-gated `active_channel_id` auth field (#1331)
|
||||
restores the focused-channel subscription during the reconnect handshake
|
||||
itself, closing the window before the post-`auth_ok` `channel_focus` round
|
||||
trip lands. `protocol.md` also corrects the presence table, which had
|
||||
incorrectly documented all presence events as sequenced. Older
|
||||
clients/servers are unaffected — it is a new, ignorable field.
|
||||
- **security(client):** identity/TOFU and transport (#1332) — an in-flight
|
||||
change to scope the identity keypair by host *and* user id would have
|
||||
re-minted a fresh key on every existing install, firing the TOFU "verify
|
||||
out-of-band" re-pin warning at the entire alpha population simultaneously,
|
||||
exactly the pattern that teaches users to click through the one warning
|
||||
meant to matter. The legacy host-only key is now adopted into the scoped
|
||||
name instead, saving before deleting so a partial failure cannot strand a
|
||||
user with neither key. Switching hosts carried the previous server's
|
||||
bearer token forward into the next login request; `api.setConfig` now
|
||||
drops it when the host changes without a replacement. A hand-copied,
|
||||
un-lowercased host normalizer in `main.ts` meant an uppercase hostname's
|
||||
cert-mismatch *reject* path skipped `disconnect()`/`clearAuth()`, leaving
|
||||
a user who refused a changed certificate still connected to that server —
|
||||
the single lowercased implementation in `ws.ts` is now shared everywhere.
|
||||
- **fix(client):** voice mic/camera reliability (#1331, #1332) — six
|
||||
separate paths could republish the microphone without checking the user's
|
||||
mute state (the audio-device fallback, selecting "Default" input,
|
||||
un-deafening, `retryMicPermission`, a stale PTT ownership latch, and
|
||||
auto-reconnect's `restoreLocalVoiceState`), each producing a hot mic while
|
||||
every remote UI still showed the user muted; all now route through
|
||||
`isMicPolicyGated()`. Camera and screenshare kept publishing to the SFU
|
||||
after the user turned them off during the OS device picker. Enhanced Noise
|
||||
Suppression silently disabled the input-volume slider and VAD gate because
|
||||
`livekit-client`'s own `replaceTrack` call landed after ours. A key-holder
|
||||
promotion arriving mid voice-setup was clobbered, ejecting the joiner
|
||||
after a timeout only it could have resolved.
|
||||
- **fix(client):** messaging and store reliability (#1328, #1331, #1332) —
|
||||
sequenced DMs could jump the FIFO ahead of `sendHigh`, permanently losing
|
||||
an event dropped before flush. A full-ready resync left every loaded
|
||||
channel with a permanent hole in its history, because that tier never
|
||||
replays `chat_message` frames; loaded windows are now invalidated and the
|
||||
active channel refetched. The WS error handler only bannered
|
||||
`RATE_LIMITED` and `FORBIDDEN`, so every other server error code — for
|
||||
example a rejected `chat_edit` — was dropped in silence while the
|
||||
optimistic "Message edited" toast still fired. A message whose
|
||||
`chat_send_ok` was lost to the same disconnect that forced a resync could
|
||||
render twice; the optimistic row's id-based dedup now shares the
|
||||
content-based match predicate `addMessage` already used. Replay detection
|
||||
compared the server's `created_at` against the client's own clock, so a
|
||||
self-hosted server without NTP made every live message after a reconnect
|
||||
look like a replay and silently killed its notification; both sides now
|
||||
use an estimated server-time skew.
|
||||
- **fix(client):** UI defects (#1331, #1332) — the quick-switcher could
|
||||
mount a second overlay, orphaning a body-mounted backdrop that blocked all
|
||||
input until reload. The status-picker stylesheet targeted a root element
|
||||
the component never toggles; a same-branch repair then left the status dot
|
||||
itself 0×0 and unclickable, now fixed together with a test pinning the
|
||||
stylesheet to the classes the component actually emits. The attachment
|
||||
remove button and the failed-send Retry/Discard buttons did nothing;
|
||||
drag-reorder's phantom-drag latch and permission gate are fixed; keyboard
|
||||
Tab could escape every modal because hidden (`display: none`) controls
|
||||
were still counted as focusable.
|
||||
- **fix(client):** the user profile popup is styled correctly again
|
||||
(`a308f81`).
|
||||
- **fix(client):** Vite no longer watches `src-tauri/`, so a running dev
|
||||
server does not rebuild the frontend when Rust sources or build artifacts
|
||||
change (`cdcfc03`).
|
||||
- **fix(release):** the stripped Linux AppImage is signed from the
|
||||
environment-provided key instead of a temporary key file (`9d75890`) —
|
||||
release-pipeline only, no operator action needed.
|
||||
- **docs:** full documentation audit against `5630aa1` — reference docs,
|
||||
architecture pages, and UX specs corrected; plans and prior audits given
|
||||
verified statuses; see `docs/audit-2026-08-04-docs-and-coverage.md`.
|
||||
- **security(server):** closed the three 2026-08-04 review findings — the
|
||||
channel role-override **DELETE** now enforces the same hierarchy guard as
|
||||
PUT (A-2026-08-01); the admin channel list/edit/delete surface no longer
|
||||
sees DM channels, answering 404 for their ids (A-2026-08-02); DM call
|
||||
rings respect blocks like every other DM interaction (A-2026-08-03).
|
||||
Behavioural note: deleting a channel override for a *nonexistent* role now
|
||||
returns 404 (was 204), matching PUT.
|
||||
- **server:** migration **029** drops the never-used `sounds` table (dead
|
||||
since the initial schema; A-2026-07-13). Applies automatically on first
|
||||
start; no operator action.
|
||||
- **protocol:** the plugin command family (`chat_command`, `command_reply`,
|
||||
`plugin_broadcast`) is now part of `protocol-schema.json` and the
|
||||
generated constants (27 client→server / 39 server→client). Wire strings
|
||||
are unchanged — no client or plugin impact.
|
||||
- **chore(client):** dead modules deleted (`ServerStrip`, `FileUpload`,
|
||||
`reconcile`, a stray worklet copy, orphan sounds API methods) and the
|
||||
unused tauri-typegen pipeline retired (`src/generated/**`, its CI steps,
|
||||
config block, and build-dependency).
|
||||
- **ci:** knip is now blocking; Playwright specs are typechecked
|
||||
(`typecheck:e2e`); three orphaned native e2e specs run again;
|
||||
`claude.yml` actions are SHA-pinned; the PR template asks for docs
|
||||
updates per the architecture maintenance rule.
|
||||
- **tests(client):** the TOFU certificate ceremony has e2e coverage
|
||||
(first-use + mismatch journeys), and `modalFactory` is fully covered.
|
||||
- **security(client):** the voice-E2EE identity pin lookup fails **closed**
|
||||
on keyring errors (DC-08): a transient store failure used to read as
|
||||
"never pinned", silently sending a pinned peer down the first-sight path
|
||||
and re-pinning whatever key the server delivered. An unreadable pin store
|
||||
now rejects the peer's announce, writes nothing, and shows a distinct
|
||||
amber "could not check" badge until the store recovers.
|
||||
- **feat(client):** accessibility pass over the modal/overlay stack
|
||||
(DC-13): every modal is a labelled `role="dialog"` with a focus trap and
|
||||
focus restore, Escape maps to each dialog's safe action, the settings
|
||||
sidebar is a keyboard-navigable tablist, the quick switcher and composer
|
||||
autocompletes are wired as combobox/listbox, the emoji/GIF pickers are
|
||||
keyboard-operable, and toasts/typing announce via polite live regions.
|
||||
- **feat(client):** UX polish (DC-12): deleting the active channel now
|
||||
says so in a toast; reactions toggle optimistically with rollback on
|
||||
failure; the role-change menu can no longer double-fire; a document-level
|
||||
listener leak in channel drag-reorder is fixed.
|
||||
- **feat(admin):** restoring a backup now writes a `backup_restore`
|
||||
audit-log row (DC-09). The row is written before the pre-restore safety
|
||||
copy, so it lives inside the `pre_restore_*.db` backup — the restored
|
||||
database itself cannot carry it (the restore replaces the file).
|
||||
- **ci:** the `-tags wazero` / `-tags otel` Go tests now actually run in CI
|
||||
(DC-06) — previously those variants were only compiled, leaving ~600
|
||||
lines of plugin/telemetry tests permanently dark.
|
||||
- **tests(client):** e2e journeys for voice-E2EE identity verification
|
||||
(badge states + mismatch modal, driven through the real crypto path) and
|
||||
the updater (banner → progress → auto-relaunch), plus an accessibility
|
||||
smoke; full web suite now 291 tests.
|
||||
|
||||
- **server/admin:** in-place self-update is refused in container
|
||||
deployments (503 `CONTAINER_DEPLOYMENT`; the shipped image sets
|
||||
`OWNCORD_CONTAINER=1`, bind-mount operators can set `0` to opt back in).
|
||||
Container upgrades are image pulls; `GET /admin/api/updates` now reports
|
||||
`can_apply` and the admin panel says so instead of offering the button.
|
||||
- **ci:** the full client e2e suite now blocks merges (DC-07); a new
|
||||
non-blocking `admin-e2e` job drives the admin panel against a real server
|
||||
(first-run wizard, channel CRUD, audit log, re-login).
|
||||
- **docs:** the dependency pinning/review policy is written down in
|
||||
`docs/contributing.md`, closing the last 2026-04 audit carryover that was
|
||||
still undecided.
|
||||
|
||||
## v1.2.0-alpha.1 — Discord feature parity
|
||||
|
||||
> **Project reset note:** OwnCord has re-entered alpha. The `v1.0.0` release is
|
||||
@@ -256,6 +453,6 @@ claimed behaviour — no product code changed and no assertion weakened.
|
||||
The project is under a feature freeze until the beta reset completes.
|
||||
Explicitly deferred (not abandoned unless noted): real OpenTelemetry SDK
|
||||
wiring, the Postgres backend (scaffolding removed pending real demand),
|
||||
the slash-command dispatcher (`docs/plans/slash-commands.md`), and the
|
||||
Solid.js migration (abandoned — the experiment is being removed in favor
|
||||
of the established vanilla component pattern).
|
||||
and the slash-command dispatcher (`docs/plans/slash-commands.md`). The
|
||||
Solid.js migration was abandoned and its experiment fully removed
|
||||
(2026-07-19) in favor of the established vanilla component pattern.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# OwnCord
|
||||
|
||||
Self-hosted chat platform (alpha). `Server/` is a Go 1.26 REST + WebSocket
|
||||
server over SQLite with LiveKit voice/video; `Client/tauri-client/` is a Tauri
|
||||
v2 desktop app (TypeScript frontend, thin Rust backend). Per-component detail
|
||||
lives in `Server/CLAUDE.md` and `Client/tauri-client/CLAUDE.md`; the protocol
|
||||
and schema are documented in `docs/protocol.md`, `docs/schema.md`, and
|
||||
`docs/architecture/README.md`.
|
||||
|
||||
## Generated code — never hand-edit
|
||||
|
||||
CI fails on drift, and the next generator run silently discards your edit.
|
||||
|
||||
| Generated | Source of truth | Workflow |
|
||||
| --- | --- | --- |
|
||||
| `Server/db/dbgen/` | `Server/db/queries/*.sql`, `Server/migrations/` | `db-change` skill |
|
||||
| `Server/ws/message_types.go` **and** `Client/tauri-client/src/lib/protocolTypes.ts` | `docs/protocol-schema.json` | `protocol-change` skill |
|
||||
| `Client/tauri-client/src/generated/` | `tauri-typegen` | CI patches known typegen bugs — see `.github/workflows/ci.yml` |
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Verify with the `ci-check` skill**, not with an ad-hoc `go build && go test`.
|
||||
CI compiles four Go build-tag variants and runs a deadlock-detection pass;
|
||||
the default build proves nothing about the tagged ones.
|
||||
- **The client unit suite is green and must stay green.** Never make a failing
|
||||
test pass by weakening its assertions.
|
||||
- Security issues go through GitHub Security Advisories, never public issues
|
||||
(`docs/security.md`). This repo is public — unfixed defects do not belong in
|
||||
commits, issues, or PR descriptions.
|
||||
- Branch from `main`, PR to `main`, squash merge, conventional commit subjects.
|
||||
@@ -0,0 +1 @@
|
||||
20
|
||||
@@ -0,0 +1,38 @@
|
||||
# OwnCord Client (Tauri v2)
|
||||
|
||||
TypeScript frontend (Vite, vanilla TS — no React/Vue) plus a deliberately thin
|
||||
Rust backend in `src-tauri/` for native APIs only. LiveKit handles voice/video.
|
||||
|
||||
## Layout
|
||||
|
||||
- `src/stores/` observable stores · `src/lib/` protocol, WS, voice, E2EE ·
|
||||
`src/pages/`, `src/components/` UI
|
||||
- `src/lib/protocolTypes.ts` and `src/generated/` are generated — see the root
|
||||
CLAUDE.md
|
||||
- `tests/unit`, `tests/integration` (vitest, jsdom) · `tests/e2e` (Playwright) ·
|
||||
`tests/browser` (vitest browser mode)
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **On Node 22+ you must run `NODE_OPTIONS=--no-experimental-webstorage npm test`.**
|
||||
Native Web Storage shadows jsdom's `localStorage` and fails ~478 tests that
|
||||
have nothing to do with your change. That is a local toolchain artifact, not
|
||||
a regression — do not "fix" those failures. CI pins Node 20.
|
||||
- `src/lib/dispatcher.ts` is the single WS-event entry point **into the
|
||||
stores**: server events reach domain stores only through a `ws.on(...)`
|
||||
subscription registered there. Other modules do register their own
|
||||
`ws.on(...)` handlers for page-local UI (`main.ts`, `MainPage.ts`,
|
||||
`ChannelController.ts` — ringing, overlays, slow-mode timers); that is fine
|
||||
as long as they only *read* store state. Writing a store from one of those
|
||||
handlers is the violation, and `local/no-store-write-in-ws-on` now fails the
|
||||
build on it.
|
||||
- Voice sessions are superseded, not cancelled. `LiveKitSession` re-entry
|
||||
points check whether a newer attempt owns the shared state before tearing
|
||||
anything down, so cleanup in an aborted path must be scoped to that attempt's
|
||||
own room — a global `leaveVoice()` there kills the live session.
|
||||
- Voice E2EE is key-holder based with TOFU identity pinning. Anything touching
|
||||
`livekitE2EE.ts` or `identity.ts` must preserve the epoch/keypair staleness
|
||||
guards and must never report an unverified peer as verified.
|
||||
- Do not run `npm run tauri build` locally; the desktop build is CI-only.
|
||||
- Formatting is prettier-enforced; match the surrounding code rather than
|
||||
reasoning about style.
|
||||
@@ -0,0 +1,408 @@
|
||||
// Custom ESLint rules that turn three of the invariants documented in prose in
|
||||
// CLAUDE.md into enforced, test-covered lint rules. Each rule is scoped (via
|
||||
// `files:` in eslint.config.js) to only the module(s) its invariant governs —
|
||||
// see the per-rule `meta.docs.description` for the invariant it encodes and
|
||||
// tests/unit/eslint-rules.test.ts for the real-code shapes it was proven
|
||||
// against (both the shapes that must stay clean and the historical bug shapes
|
||||
// it must catch).
|
||||
//
|
||||
// Plain JS, ESM, no build step — eslint.config.js imports this directly.
|
||||
|
||||
/** True when `node` is a `this.<methodName>(...)` call. */
|
||||
function isThisMethodCall(node, methodName) {
|
||||
return (
|
||||
node !== null &&
|
||||
node.type === "CallExpression" &&
|
||||
node.callee.type === "MemberExpression" &&
|
||||
node.callee.object.type === "ThisExpression" &&
|
||||
!node.callee.computed &&
|
||||
node.callee.property.type === "Identifier" &&
|
||||
node.callee.property.name === methodName
|
||||
);
|
||||
}
|
||||
|
||||
/** True when `node` is a `this.<propertyName>` member access. */
|
||||
function isThisMember(node, propertyName) {
|
||||
return (
|
||||
node !== null &&
|
||||
node.type === "MemberExpression" &&
|
||||
node.object.type === "ThisExpression" &&
|
||||
!node.computed &&
|
||||
node.property.type === "Identifier" &&
|
||||
node.property.name === propertyName
|
||||
);
|
||||
}
|
||||
|
||||
function isFunctionNode(node) {
|
||||
return (
|
||||
node.type === "FunctionDeclaration" ||
|
||||
node.type === "FunctionExpression" ||
|
||||
node.type === "ArrowFunctionExpression"
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Rule: no-leave-voice-when-superseded
|
||||
//
|
||||
// Invariant (CLAUDE.md): "Voice sessions are superseded, not cancelled.
|
||||
// LiveKitSession re-entry points check whether a newer attempt owns the
|
||||
// shared state before tearing anything down, so cleanup in an aborted path
|
||||
// must be scoped to that attempt's own room — a global leaveVoice() there
|
||||
// kills the live session."
|
||||
//
|
||||
// livekitSession.ts encodes "this attempt was superseded" with exactly two
|
||||
// guard predicates, always used the same way: `this.reconnectSuperseded(...)`
|
||||
// (true = superseded) and `!this.isStateConnected(...)` (negated = true when
|
||||
// superseded). Once either guard has confirmed supersession, the historical
|
||||
// bug (see the fix that introduced disconnectSupersededLocalRoom /
|
||||
// generation-guarded leaveVoice calls) was calling the global
|
||||
// `this.leaveVoice()` inside that same branch, tearing down whichever session
|
||||
// currently owns the shared state — which, once superseded, is a newer
|
||||
// attempt's live session, not this one.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** True when `test` (walking through &&/||) asserts "this attempt IS
|
||||
* superseded" via one of the two named guards used throughout the file. */
|
||||
function testSignalsSuperseded(test) {
|
||||
if (test === null) return false;
|
||||
if (test.type === "LogicalExpression") {
|
||||
return testSignalsSuperseded(test.left) || testSignalsSuperseded(test.right);
|
||||
}
|
||||
if (isThisMethodCall(test, "reconnectSuperseded")) return true;
|
||||
if (test.type === "UnaryExpression" && test.operator === "!") {
|
||||
return isThisMethodCall(test.argument, "isStateConnected");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const noLeaveVoiceWhenSuperseded = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow this.leaveVoice() inside a branch that already confirmed this connect/reconnect " +
|
||||
"attempt was superseded. Voice sessions are superseded, not cancelled — once reconnectSuperseded() " +
|
||||
"or !isStateConnected() is true, `_state` may already belong to a newer, live attempt, and " +
|
||||
"leaveVoice() there tears that live session down instead of the aborted one.",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
unsafeLeaveVoice:
|
||||
"this.leaveVoice() must not run once this attempt is known to be superseded — it acts on " +
|
||||
"whichever session currently owns `_state`, which may now be a newer, live attempt. Disconnect " +
|
||||
"only this attempt's own room instead (e.g. disconnectSupersededLocalRoom(localRoom) / " +
|
||||
"localRoom.disconnect()), or simply return without calling it.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
CallExpression(node) {
|
||||
if (!isThisMethodCall(node, "leaveVoice")) return;
|
||||
let child = node;
|
||||
let parent = node.parent;
|
||||
while (parent) {
|
||||
if (isFunctionNode(parent)) return; // left the enclosing method — stop
|
||||
if (
|
||||
parent.type === "IfStatement" &&
|
||||
child === parent.consequent &&
|
||||
testSignalsSuperseded(parent.test)
|
||||
) {
|
||||
context.report({ node, messageId: "unsafeLeaveVoice" });
|
||||
return;
|
||||
}
|
||||
child = parent;
|
||||
parent = parent.parent;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Rule: e2ee-epoch-needs-keypair-check
|
||||
//
|
||||
// Invariant (CLAUDE.md): "Anything touching livekitE2EE.ts ... must preserve
|
||||
// the epoch/keypair staleness guards."
|
||||
//
|
||||
// Every async E2EE operation that resumes after an await re-checks it is
|
||||
// still the current attempt before writing shared state. The historical bug
|
||||
// (see the fix for handleOfferInner / handleAnnounceInner) compared only
|
||||
// `this._e2eeEpoch !== epochBefore` — insufficient, because a non-key-holder
|
||||
// never bumps the epoch, so a torn-down-then-restarted session can resume
|
||||
// with the epoch unchanged in both the old and new session. The fix requires
|
||||
// ALSO comparing keypair identity (`this._ecdhKeyPair !== keypair`). This
|
||||
// rule requires both checks to appear together in the same guard.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** True when `test` (walking through &&/||) contains `this.<prop> !== X`
|
||||
* (in either operand order). */
|
||||
function containsStrictInequality(test, prop) {
|
||||
if (test === null) return false;
|
||||
if (test.type === "LogicalExpression") {
|
||||
return containsStrictInequality(test.left, prop) || containsStrictInequality(test.right, prop);
|
||||
}
|
||||
if (test.type === "BinaryExpression" && test.operator === "!==") {
|
||||
return isThisMember(test.left, prop) || isThisMember(test.right, prop);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const e2eeEpochNeedsKeypairCheck = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Require this._ecdhKeyPair identity checks alongside this._e2eeEpoch staleness checks. A " +
|
||||
"non-key-holder session never bumps the epoch, so an epoch-only comparison cannot detect a " +
|
||||
"torn-down-then-restarted session resuming after an await — only the keypair identity can.",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
missingKeypairCheck:
|
||||
"This staleness check compares this._e2eeEpoch but not this._ecdhKeyPair. A non-key-holder " +
|
||||
"session never advances the epoch, so this guard alone cannot detect a torn-down-then-restarted " +
|
||||
"session — add `|| this._ecdhKeyPair !== <the keypair captured before the await>` to the condition.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
IfStatement(node) {
|
||||
if (
|
||||
containsStrictInequality(node.test, "_e2eeEpoch") &&
|
||||
!containsStrictInequality(node.test, "_ecdhKeyPair")
|
||||
) {
|
||||
context.report({ node: node.test, messageId: "missingKeypairCheck" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Rule: e2ee-verified-status-literal
|
||||
//
|
||||
// Invariant (CLAUDE.md): "Anything touching livekitE2EE.ts ... must never
|
||||
// report an unverified peer as verified."
|
||||
//
|
||||
// verifyPeerAnnounce's every write of peer-verification state goes through
|
||||
// setPeerVerification/setPeerVerificationIfCurrent, and "verified" is reached
|
||||
// exactly once, only after a real signature check. This rule keeps that
|
||||
// structurally true: the `status` field at every call site must be a literal
|
||||
// the author typed by hand at that call site, never a variable/expression —
|
||||
// which would let a status be computed (and potentially manipulated) instead
|
||||
// of asserted at the one audited call site that earned it.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function getCalleeName(node) {
|
||||
if (node.callee.type === "Identifier") return node.callee.name;
|
||||
if (
|
||||
node.callee.type === "MemberExpression" &&
|
||||
!node.callee.computed &&
|
||||
node.callee.property.type === "Identifier"
|
||||
) {
|
||||
return node.callee.property.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const VERIFICATION_SETTERS = new Set(["setPeerVerification", "setPeerVerificationIfCurrent"]);
|
||||
|
||||
const e2eeVerifiedStatusLiteral = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Require the `status` field passed to setPeerVerification/setPeerVerificationIfCurrent to be a " +
|
||||
"string literal. A peer must never be reported verified via a computed/derived status — each " +
|
||||
"verification outcome is a distinct, hand-written call site that earned its status inline.",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
dynamicStatus:
|
||||
"The `status` passed here must be a string literal ('verified' | 'unverified' | 'mismatch' | " +
|
||||
"'unknown'), not a computed expression. Add a new literal call site for this outcome instead of " +
|
||||
"deriving the status dynamically — that is what keeps 'verified' provably tied to a real signature check.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
CallExpression(node) {
|
||||
const name = getCalleeName(node);
|
||||
if (name === null || !VERIFICATION_SETTERS.has(name)) return;
|
||||
const objArg = node.arguments[node.arguments.length - 1];
|
||||
if (objArg === undefined || objArg.type !== "ObjectExpression") return;
|
||||
const statusProp = objArg.properties.find(
|
||||
(p) =>
|
||||
p.type === "Property" &&
|
||||
!p.computed &&
|
||||
p.key.type === "Identifier" &&
|
||||
p.key.name === "status",
|
||||
);
|
||||
if (statusProp === undefined) return;
|
||||
const value = statusProp.value;
|
||||
if (value.type !== "Literal" || typeof value.value !== "string") {
|
||||
context.report({ node: statusProp, messageId: "dynamicStatus" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Rule: no-identity-scope-fallback
|
||||
//
|
||||
// Invariant (CLAUDE.md): "Anything touching livekitE2EE.ts or identity.ts
|
||||
// must preserve the epoch/keypair staleness guards." (Identity-scoping
|
||||
// analogue: a documented, previously-real bug — see identity.ts's
|
||||
// `identityKeyPairCache` comment — where a missing user id fell back to a
|
||||
// placeholder scope like `?? 0`, silently minting/adopting a keypair under
|
||||
// the wrong account and permanently desyncing the published key from the
|
||||
// announce-signing key for every peer.)
|
||||
//
|
||||
// getOrCreateIdentityKeyPair's userId argument must come from a value that
|
||||
// was already checked for `undefined` (the pattern both call sites use), not
|
||||
// a `??`/`||` fallback that would substitute a placeholder id.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const noIdentityScopeFallback = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow a ??/|| placeholder fallback as the userId argument to getOrCreateIdentityKeyPair. A " +
|
||||
"missing user id must abort (see the `userId === undefined` guards at both call sites), never " +
|
||||
"substitute a placeholder scope — that mints or adopts a keypair under the wrong account and " +
|
||||
"permanently desyncs the published key from the announce-signing key.",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
placeholderFallback:
|
||||
"Do not fall back with ??/|| when passing the user id to getOrCreateIdentityKeyPair — a missing " +
|
||||
"id must abort instead (check `=== undefined` and return, as both existing call sites do). A " +
|
||||
"placeholder id mints/adopts a keypair under the wrong account and desyncs it from the signing key.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
CallExpression(node) {
|
||||
if (
|
||||
node.callee.type !== "Identifier" ||
|
||||
node.callee.name !== "getOrCreateIdentityKeyPair"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const userIdArg = node.arguments[1];
|
||||
if (userIdArg === undefined) return;
|
||||
if (
|
||||
userIdArg.type === "LogicalExpression" &&
|
||||
(userIdArg.operator === "??" || userIdArg.operator === "||")
|
||||
) {
|
||||
context.report({ node: userIdArg, messageId: "placeholderFallback" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Rule: no-store-write-in-ws-on
|
||||
//
|
||||
// Invariant (CLAUDE.md): "src/lib/dispatcher.ts is the single WS-event entry
|
||||
// point: server events reach the stores only through a ws.on(...)
|
||||
// subscription registered there."
|
||||
//
|
||||
// Other modules DO register their own ws.on(...) handlers (page-local UI:
|
||||
// slow-mode timers, the connected overlay, incoming-call ringing) — that
|
||||
// itself is not the violation. What must never happen outside dispatcher.ts
|
||||
// is one of those handlers writing to a domain store directly, bypassing the
|
||||
// dispatcher. Store *reads* (`fooStore.getState()`) are unaffected; this only
|
||||
// flags calls to an imported store-mutator function (set/add/update/... from
|
||||
// a `*/stores/*` module) reached from inside a `ws.on(...)` callback.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const STORE_MUTATOR_PREFIX =
|
||||
/^(set|add|remove|update|increment|clear|toggle|open|close|join|leave|mark|confirm|bulk|rollback|reset|prepend|reattach|invalidate|load)[A-Z_]/;
|
||||
|
||||
function isStoreModuleSource(source) {
|
||||
// Matches both the "@stores/..." alias and relative "../stores/..." paths.
|
||||
return typeof source === "string" && /(?:^|\/)@?stores\//.test(source);
|
||||
}
|
||||
|
||||
function isWsOnCall(node) {
|
||||
return (
|
||||
node !== null &&
|
||||
node !== undefined &&
|
||||
node.type === "CallExpression" &&
|
||||
node.callee.type === "MemberExpression" &&
|
||||
!node.callee.computed &&
|
||||
node.callee.object.type === "Identifier" &&
|
||||
node.callee.object.name === "ws" &&
|
||||
node.callee.property.type === "Identifier" &&
|
||||
node.callee.property.name === "on" &&
|
||||
node.arguments.length >= 2
|
||||
);
|
||||
}
|
||||
|
||||
const noStoreWriteInWsOn = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow calling an imported store-mutator (set*/add*/update*/... from a stores/ module) from " +
|
||||
"inside a ws.on(...) callback outside dispatcher.ts. dispatcher.ts is the single place server " +
|
||||
"events are allowed to write into domain stores; a page-local ws.on(...) handler may read store " +
|
||||
"state and drive its own local UI, but must not mutate a domain store itself.",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
storeWriteOutsideDispatcher:
|
||||
"'{{name}}' is a store mutator called from a ws.on(...) handler outside dispatcher.ts. " +
|
||||
"dispatcher.ts is the single WS-event entry point that may write to stores — move this update " +
|
||||
"into a dispatcher.ts handler for this message type, or have this handler read the store instead " +
|
||||
"of writing it.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const storeMutatorImports = new Set();
|
||||
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
if (!isStoreModuleSource(node.source.value)) return;
|
||||
for (const spec of node.specifiers) {
|
||||
if (spec.type === "ImportSpecifier" && STORE_MUTATOR_PREFIX.test(spec.local.name)) {
|
||||
storeMutatorImports.add(spec.local.name);
|
||||
}
|
||||
}
|
||||
},
|
||||
CallExpression(node) {
|
||||
if (node.callee.type !== "Identifier" || !storeMutatorImports.has(node.callee.name)) return;
|
||||
let parent = node.parent;
|
||||
while (parent) {
|
||||
if (
|
||||
isFunctionNode(parent) &&
|
||||
isWsOnCall(parent.parent) &&
|
||||
parent.parent.arguments[1] === parent
|
||||
) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: "storeWriteOutsideDispatcher",
|
||||
data: { name: node.callee.name },
|
||||
});
|
||||
return;
|
||||
}
|
||||
parent = parent.parent;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
rules: {
|
||||
"no-leave-voice-when-superseded": noLeaveVoiceWhenSuperseded,
|
||||
"e2ee-epoch-needs-keypair-check": e2eeEpochNeedsKeypairCheck,
|
||||
"e2ee-verified-status-literal": e2eeVerifiedStatusLiteral,
|
||||
"no-identity-scope-fallback": noIdentityScopeFallback,
|
||||
"no-store-write-in-ws-on": noStoreWriteInWsOn,
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import eslint from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import localRules from "./eslint-rules.js";
|
||||
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
@@ -32,10 +33,7 @@ export default tseslint.config(
|
||||
// Empty functions are used for no-op callbacks
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
// Project uses void for fire-and-forget promises intentionally
|
||||
"@typescript-eslint/no-misused-promises": [
|
||||
"error",
|
||||
{ checksVoidReturn: false },
|
||||
],
|
||||
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: false }],
|
||||
// Allow require() in config files
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
// Unbound methods used in singleton export pattern (bind at export)
|
||||
@@ -67,14 +65,43 @@ export default tseslint.config(
|
||||
"consistent-return": "off",
|
||||
},
|
||||
},
|
||||
// --- Local rules: three CLAUDE.md invariants enforced as lint rules ---
|
||||
// See eslint-rules.js for each rule's rationale and the historical bug
|
||||
// shape it catches. Each is scoped to only the module(s) its invariant
|
||||
// governs.
|
||||
{
|
||||
ignores: [
|
||||
"dist/",
|
||||
"src-tauri/",
|
||||
"node_modules/",
|
||||
"public/",
|
||||
"*.js",
|
||||
"*.cjs",
|
||||
],
|
||||
files: ["src/lib/livekitSession.ts"],
|
||||
plugins: { local: localRules },
|
||||
rules: {
|
||||
"local/no-leave-voice-when-superseded": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/lib/livekitE2EE.ts"],
|
||||
plugins: { local: localRules },
|
||||
rules: {
|
||||
"local/e2ee-epoch-needs-keypair-check": "error",
|
||||
"local/e2ee-verified-status-literal": "error",
|
||||
"local/no-identity-scope-fallback": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/lib/identity.ts"],
|
||||
plugins: { local: localRules },
|
||||
rules: {
|
||||
"local/no-identity-scope-fallback": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
// dispatcher.ts IS the allowed entry point, so it is exempt from its own rule.
|
||||
files: ["src/**/*.ts"],
|
||||
ignores: ["src/lib/dispatcher.ts"],
|
||||
plugins: { local: localRules },
|
||||
rules: {
|
||||
"local/no-store-write-in-ws-on": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ["dist/", "src-tauri/", "node_modules/", "public/", "*.js", "*.cjs"],
|
||||
},
|
||||
);
|
||||
|
||||
Generated
+141
-123
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "owncord-client",
|
||||
"version": "1.2.0-alpha.1",
|
||||
"version": "1.2.0-alpha.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "owncord-client",
|
||||
"version": "1.2.0-alpha.1",
|
||||
"version": "1.2.0-alpha.2",
|
||||
"dependencies": {
|
||||
"@jitsi/rnnoise-wasm": "^0.2.1",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
@@ -28,12 +28,13 @@
|
||||
"@stryker-mutator/typescript-checker": "^9.6.1",
|
||||
"@stryker-mutator/vitest-runner": "^9.6.1",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/node": "^20.19.43",
|
||||
"@vitest/browser": "^3.2.4",
|
||||
"@vitest/coverage-v8": "^3",
|
||||
"eslint": "^10.8.0",
|
||||
"fast-check": "^4.9.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"knip": "^6.1.1",
|
||||
"knip": "^6.31.0",
|
||||
"oxlint": "^1.76.0",
|
||||
"prettier": "^3.9.6",
|
||||
"typescript": "^5.7",
|
||||
@@ -1952,9 +1953,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-android-arm-eabi": {
|
||||
"version": "0.140.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.140.0.tgz",
|
||||
"integrity": "sha512-ZfjDZ422mo7eo3b3VltqNsV9kmv1qt/sPEAMSl64iOSwhVfd0eIZ9LB79Mbs1xYXJnk7WSROwzBCKDIiVxPTvQ==",
|
||||
"version": "0.142.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.142.0.tgz",
|
||||
"integrity": "sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1969,9 +1970,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-android-arm64": {
|
||||
"version": "0.140.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.140.0.tgz",
|
||||
"integrity": "sha512-Ia8jSvikUX6Sf+Ht+KOCUF/k1HpR0VlmqIYymubmWDebOEGtsyliHDR6JxsZ4IX3/c/GbrB1uh09aVGQv/LQmQ==",
|
||||
"version": "0.142.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.142.0.tgz",
|
||||
"integrity": "sha512-WZkvGRLNQTz8lR9zP5nLjUdlroRCopBu3g9zF1p/laE6DzT1UbQo8Rdz5MWhaJUPYg/6gp+jo7HUgsyKaN1FtQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1986,9 +1987,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-darwin-arm64": {
|
||||
"version": "0.140.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.140.0.tgz",
|
||||
"integrity": "sha512-G6VK0nK61pH0d0mBjUqSZbVxGqqO5uzeginLDQj+gOO6ObfJjXRwgkD/ol0w1INcnFeAb6YGGO7qc3ueGHaycQ==",
|
||||
"version": "0.142.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.142.0.tgz",
|
||||
"integrity": "sha512-l4khS8LQOOVYsGRVARo1gSaCT/aBSceUVXgtovWc2+drnxVuDr082WA3OCHVdVzIz5JIrP/y9CWsSKxBDNmYGg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2003,9 +2004,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-darwin-x64": {
|
||||
"version": "0.140.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.140.0.tgz",
|
||||
"integrity": "sha512-HazBOuZzd2pO1C2uMmp8Gv7mhzMHqKSKDS1OZfcLEvpIcgA+48J92HEtNanVHDIzRD9PRPCV6aS6fkZIWOVl8Q==",
|
||||
"version": "0.142.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.142.0.tgz",
|
||||
"integrity": "sha512-QBsNF3nqlXmcH2B1YOPqQYmCJoy4HuIjUxGbBO/k5JAJUl68ghU2psRY2zPk+RyBaWqKP/qfL4oaFgEMCdwskA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2020,9 +2021,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-freebsd-x64": {
|
||||
"version": "0.140.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.140.0.tgz",
|
||||
"integrity": "sha512-9hSUU+HmTUyOe4JzMHxNGgLWNY7rrO+6ShicZwImNJacEAACDMIkuEQQkvXSL+WJN50jaNtLYJv8s4OcBdpyUQ==",
|
||||
"version": "0.142.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.142.0.tgz",
|
||||
"integrity": "sha512-b7Q7m4Cqc6XqNhri3R+QhU+GVy646Pn+bkdhrDdWym/Fdi0ZUa+d73H9dm5H91JtbtAQ/z1d8XKMW3oOV8a4tQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2037,9 +2038,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-arm-gnueabihf": {
|
||||
"version": "0.140.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.140.0.tgz",
|
||||
"integrity": "sha512-RAEuQsYtS0KcDFqN0ABTjyyNlokS91JeuDuoW9tEbG0JTbRNXnpQUdbYc/16JoA6Z/2ALbNrE3KmxtqDiuIjCQ==",
|
||||
"version": "0.142.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.142.0.tgz",
|
||||
"integrity": "sha512-3riVS5IhdH3uCZj1Y9ftDQlR0dvLsIlw/edrRqk8JhgNd5K0XSs+UBtgh50N13CAlW9/TXj6sVGXaKNBocd0Yg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2054,9 +2055,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-arm-musleabihf": {
|
||||
"version": "0.140.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.140.0.tgz",
|
||||
"integrity": "sha512-c4CkHvPvqfojouredJ0w3e6+jiBq0SbFyhH61kr/zPb/7XsaYTNKQ54vmlSsopfdQbNDX40ZeK9Abs2Qet6wcw==",
|
||||
"version": "0.142.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.142.0.tgz",
|
||||
"integrity": "sha512-NmXUOpgpTSkhl795TiXmWppTwmSJ92RC1qvD6e4XOF+slgmo3e6Ah+kEu+6AN8s7NAOEwqGmir58MgSQSWmBSA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2071,9 +2072,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-arm64-gnu": {
|
||||
"version": "0.140.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.140.0.tgz",
|
||||
"integrity": "sha512-yrjmLj8ixPB25yqvPGr28meGjb+keed7m1GqqY/0uqkhZIoT4t9zmfwUgFEtC33C7dtE+UQ7TU0IaVxf97SWJg==",
|
||||
"version": "0.142.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.142.0.tgz",
|
||||
"integrity": "sha512-gc0EXsKtXgerujmU2Bql3u1L1HsSQ2774R83idq/FoNMPVV/RY/1ErFsvnit7KoiP/sLvzQixeUo4Ut0ic0wmw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2088,9 +2089,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-arm64-musl": {
|
||||
"version": "0.140.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.140.0.tgz",
|
||||
"integrity": "sha512-ggGMQTN8Agwxp2WiLMpdY671dt0qTDJWiWlJeig3HnUwTnerRl0J2JdGVghWBeDcss2D9S2V2Js6dZHEiVabVA==",
|
||||
"version": "0.142.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.142.0.tgz",
|
||||
"integrity": "sha512-F2XvmWSE0uWpie+jHKKIFgdVOe9ypGhkEZxKx5DuW215K6cbAC274yYaPkcM7EqY4Df3Weyhpcz3lsURyH2LVg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2105,9 +2106,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-ppc64-gnu": {
|
||||
"version": "0.140.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.140.0.tgz",
|
||||
"integrity": "sha512-IgTs8xYAFgAUGNmR65tIqjlJ8vKgrfXzC515e9goSdfMyKQV4aJpd2pUUudU4u51G64H0/DSEJEXKOraxm9ZCA==",
|
||||
"version": "0.142.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.142.0.tgz",
|
||||
"integrity": "sha512-wLMbT21U/QxknQsk+VvNF0b9D2/aGWhcaQQQ+VYlE8FwD5+GoWZIPPXNzyHmkYyhm0KB3itL+TBavjMatqNnYA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -2122,9 +2123,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-riscv64-gnu": {
|
||||
"version": "0.140.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.140.0.tgz",
|
||||
"integrity": "sha512-A1x+PMWZmSGaFVOx2YeNTFau8uD+QO14/vLP4GrcuvUPs3+nBkUOjy9Lus86ftHsDojjYMbvBelmKc3F7Rv08g==",
|
||||
"version": "0.142.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.142.0.tgz",
|
||||
"integrity": "sha512-+G8F/4ckwT7FCJV4H2bt09xEzJbjNCfuL4Sp1AYNaFtFMVtgIGMuJlteT82U+K0UIZ/DzAR/LDlMFnEuajG7Kw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -2139,9 +2140,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-riscv64-musl": {
|
||||
"version": "0.140.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.140.0.tgz",
|
||||
"integrity": "sha512-zBqpfRo2myWPrPo5xUjeZqlnPXPXsX8BcWtWff66/eGRQdbPjhzPgXa/F+AtxT2afUViPxbuDlwscMKzQ5tg+g==",
|
||||
"version": "0.142.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.142.0.tgz",
|
||||
"integrity": "sha512-hTsHtTLxMAfCo+rpF5K3qZJKW2NpPN/CHd4mYB3y7XlSdspHkd2gehDIofP64AacA9nWQw2tY3O7wR6UY8IVOA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -2156,9 +2157,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-s390x-gnu": {
|
||||
"version": "0.140.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.140.0.tgz",
|
||||
"integrity": "sha512-2M1DPm/8w9I//YzFlFC9qXw+r2tJFh5CYwRlYTq2vUJQS7qoQftEDeCZ8EnN7KHtvSiXvYj8mZI5pR7DpXmcEw==",
|
||||
"version": "0.142.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.142.0.tgz",
|
||||
"integrity": "sha512-6y7qYY3TCUDYjqswImdTGl92y+KA/80twALegQPN27kfY+bG7Ib1+L3jbmrCZQx6wrVnai9IPsEZp07I0hx7JQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -2173,9 +2174,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-x64-gnu": {
|
||||
"version": "0.140.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.140.0.tgz",
|
||||
"integrity": "sha512-8aRDbZ/U/jO8N7go1MO72jtbpb4uswV8d7vOkMvt/BPgZiyEYvl1VIWK4ESxZZhnJ4tqwVldgX7dNiP/eB1Jdg==",
|
||||
"version": "0.142.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.142.0.tgz",
|
||||
"integrity": "sha512-i69kAWU+2LgoH5bR+zWiiu+UzAw7Oxkwv7COeJTeY19pn4e70nKQcr9Pm6cL2Z0Z54d+gl9qADlK/0yyuCPiBA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2190,9 +2191,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-x64-musl": {
|
||||
"version": "0.140.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.140.0.tgz",
|
||||
"integrity": "sha512-xRqpeI8U2sQQS1W5BMWRyMTxtagkuLG2dEWruet5lFsWHTvBth11/TpSaJatHdqVVwHN0q3uuoS9zRsGinq8hg==",
|
||||
"version": "0.142.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.142.0.tgz",
|
||||
"integrity": "sha512-4SQs678MmjYVrmhAgCWD4o0vpaFszXw9xLX5p2Z9MMFcltxiLkA88wQjh80YHjPrXtpyZ2CWI5m+1yNKM0m2Pw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2207,9 +2208,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-openharmony-arm64": {
|
||||
"version": "0.140.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.140.0.tgz",
|
||||
"integrity": "sha512-GbGRe26MqAKciFRvXeHNQJ6VAHYs9R4miP89sEAncysM3n+f4lnyLWgsa9kklJNpfnxdq2yRoNYHFqwBckVimw==",
|
||||
"version": "0.142.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.142.0.tgz",
|
||||
"integrity": "sha512-YHpx9N7Ln3a++Tc8rv+H7mrK1zyJQOAwCFg8LZ3lTs1T5afGWeZrLPhPT9HLnIwSjCyJqPWVMIrMxbjcmBr2oQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2224,9 +2225,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-wasm32-wasi": {
|
||||
"version": "0.140.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.140.0.tgz",
|
||||
"integrity": "sha512-vFiC1hqys+hkX1GnQkIoiTQJNiUm43Z0lO35ETKXTw0YtpW7+cN58YRRXFAQQ+TgpkIi3lrhcxdlnqz+Oi3ptQ==",
|
||||
"version": "0.142.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.142.0.tgz",
|
||||
"integrity": "sha512-3pLDyY3+oogW73RM5uehNgAiR/Xfb7fvO2Q1Z1gIqZ2+50XDVQmBVlRkHXZTU4gKnQHpwETNsYQVsJ3joVB2iA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -2243,9 +2244,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-win32-arm64-msvc": {
|
||||
"version": "0.140.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.140.0.tgz",
|
||||
"integrity": "sha512-fGSQldwEYKhM+H8uLt76Op8hh5+FYaR6lvvQ1Txw3Mhn86DyQXLcI0fi1EkFlTK7F+46OCk/j0AJMzZQm6g5Xg==",
|
||||
"version": "0.142.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.142.0.tgz",
|
||||
"integrity": "sha512-Had/VeVY28Oyb0K+Q4FV8KCzoBycIh93oDK6pCbya9lkzdq+ikMHMgBubsdqqlybjJmQRawCQRrnBRHyQwYvcQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2260,9 +2261,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-win32-ia32-msvc": {
|
||||
"version": "0.140.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.140.0.tgz",
|
||||
"integrity": "sha512-sDS2Bai+g3ZWYwfZqmosiSuFDBcVnZ3Ta6pszzsiJoLMqsJEWKcxXXbGa7b7yXr++W2lQNPb3ZRJ8czseqL7RA==",
|
||||
"version": "0.142.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.142.0.tgz",
|
||||
"integrity": "sha512-GGi3+YphVHavvgs6gum2UXoNCqzHAmPt/nXkn8ZQZstV2Q1qZD1Mn8fz/nWrDkefHQtrG/+1/XrbMxsBTo6Svw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -2277,9 +2278,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-win32-x64-msvc": {
|
||||
"version": "0.140.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.140.0.tgz",
|
||||
"integrity": "sha512-kHbE1zWyb5OQgJA6/5P4WjiuB01sYdQwtZnSSyE58FQEXDAMnyeeq4vj7KgN75i5SlBzOs8A5MrtlD3gOlDKqQ==",
|
||||
"version": "0.142.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.142.0.tgz",
|
||||
"integrity": "sha512-Ny/Wv4Us1LGC/ljwNTp+Hx3r/pH15EFfeDF0p+n898gt+TtRd6C9SccHcuUhDiNTb8s5tt7jdeAMDRQZ4Vq6hg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2294,9 +2295,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.140.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.140.0.tgz",
|
||||
"integrity": "sha512-h5LUOzGArYemnW1NMz/DuuQhBi96J6JL2Bk8zE4kvqxB5Sg3jxmCiH4uyOWHDkiKSt5vWlG4FIwCR/DbstcNRQ==",
|
||||
"version": "0.142.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz",
|
||||
"integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -2898,13 +2899,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
|
||||
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.62.0"
|
||||
"playwright": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -3858,6 +3859,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.43",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz",
|
||||
@@ -5091,9 +5102,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
|
||||
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
||||
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -5755,9 +5766,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/knip": {
|
||||
"version": "6.29.0",
|
||||
"resolved": "https://registry.npmjs.org/knip/-/knip-6.29.0.tgz",
|
||||
"integrity": "sha512-A3kXqSBky1tWBAqiU9srdtu0Larhzkuyor0aD/gg+ToiqyBncCCs2Q60sLsxmcKhV0OsKss9LV0hMPpwLv711Q==",
|
||||
"version": "6.31.0",
|
||||
"resolved": "https://registry.npmjs.org/knip/-/knip-6.31.0.tgz",
|
||||
"integrity": "sha512-NbeIEmUS2VUMjAkbiSNOKPJeV9wpCsr0660sUyKyMQbk4Iom0++nTLInVp4MJ+LfR4kORnw67bDi5tvO7YLnzA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -5775,13 +5786,13 @@
|
||||
"formatly": "^0.3.0",
|
||||
"get-tsconfig": "4.14.0",
|
||||
"jiti": "^2.7.0",
|
||||
"oxc-parser": "^0.140.0",
|
||||
"oxc-parser": "^0.142.0",
|
||||
"oxc-resolver": "11.24.2",
|
||||
"picomatch": "^4.0.5",
|
||||
"smol-toml": "^1.7.0",
|
||||
"smol-toml": "^1.7.1",
|
||||
"strip-json-comments": "5.0.3",
|
||||
"tinyglobby": "^0.2.17",
|
||||
"unbash": "^4.0.3",
|
||||
"unbash": "^4.0.4",
|
||||
"yaml": "^2.9.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -6153,13 +6164,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/oxc-parser": {
|
||||
"version": "0.140.0",
|
||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.140.0.tgz",
|
||||
"integrity": "sha512-h6QFWd6lBMfjESqgQ27GjzrSDb0qbznp7VDQqp2zvgsrWut4vcchyMIzOVXvGQ2GMZgKw9RWrFNWv9WqGL0p7Q==",
|
||||
"version": "0.142.0",
|
||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.142.0.tgz",
|
||||
"integrity": "sha512-kKR+jPiRJYJDexVoziIg/FVGvr1fT1FZSSJOk6tVoMKKSlsf1Cso+cgGCJkOEDWOP174vRntCPFKg+AS7InWvw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "^0.140.0"
|
||||
"@oxc-project/types": "^0.142.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
@@ -6168,26 +6179,26 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@oxc-parser/binding-android-arm-eabi": "0.140.0",
|
||||
"@oxc-parser/binding-android-arm64": "0.140.0",
|
||||
"@oxc-parser/binding-darwin-arm64": "0.140.0",
|
||||
"@oxc-parser/binding-darwin-x64": "0.140.0",
|
||||
"@oxc-parser/binding-freebsd-x64": "0.140.0",
|
||||
"@oxc-parser/binding-linux-arm-gnueabihf": "0.140.0",
|
||||
"@oxc-parser/binding-linux-arm-musleabihf": "0.140.0",
|
||||
"@oxc-parser/binding-linux-arm64-gnu": "0.140.0",
|
||||
"@oxc-parser/binding-linux-arm64-musl": "0.140.0",
|
||||
"@oxc-parser/binding-linux-ppc64-gnu": "0.140.0",
|
||||
"@oxc-parser/binding-linux-riscv64-gnu": "0.140.0",
|
||||
"@oxc-parser/binding-linux-riscv64-musl": "0.140.0",
|
||||
"@oxc-parser/binding-linux-s390x-gnu": "0.140.0",
|
||||
"@oxc-parser/binding-linux-x64-gnu": "0.140.0",
|
||||
"@oxc-parser/binding-linux-x64-musl": "0.140.0",
|
||||
"@oxc-parser/binding-openharmony-arm64": "0.140.0",
|
||||
"@oxc-parser/binding-wasm32-wasi": "0.140.0",
|
||||
"@oxc-parser/binding-win32-arm64-msvc": "0.140.0",
|
||||
"@oxc-parser/binding-win32-ia32-msvc": "0.140.0",
|
||||
"@oxc-parser/binding-win32-x64-msvc": "0.140.0"
|
||||
"@oxc-parser/binding-android-arm-eabi": "0.142.0",
|
||||
"@oxc-parser/binding-android-arm64": "0.142.0",
|
||||
"@oxc-parser/binding-darwin-arm64": "0.142.0",
|
||||
"@oxc-parser/binding-darwin-x64": "0.142.0",
|
||||
"@oxc-parser/binding-freebsd-x64": "0.142.0",
|
||||
"@oxc-parser/binding-linux-arm-gnueabihf": "0.142.0",
|
||||
"@oxc-parser/binding-linux-arm-musleabihf": "0.142.0",
|
||||
"@oxc-parser/binding-linux-arm64-gnu": "0.142.0",
|
||||
"@oxc-parser/binding-linux-arm64-musl": "0.142.0",
|
||||
"@oxc-parser/binding-linux-ppc64-gnu": "0.142.0",
|
||||
"@oxc-parser/binding-linux-riscv64-gnu": "0.142.0",
|
||||
"@oxc-parser/binding-linux-riscv64-musl": "0.142.0",
|
||||
"@oxc-parser/binding-linux-s390x-gnu": "0.142.0",
|
||||
"@oxc-parser/binding-linux-x64-gnu": "0.142.0",
|
||||
"@oxc-parser/binding-linux-x64-musl": "0.142.0",
|
||||
"@oxc-parser/binding-openharmony-arm64": "0.142.0",
|
||||
"@oxc-parser/binding-wasm32-wasi": "0.142.0",
|
||||
"@oxc-parser/binding-win32-arm64-msvc": "0.142.0",
|
||||
"@oxc-parser/binding-win32-ia32-msvc": "0.142.0",
|
||||
"@oxc-parser/binding-win32-x64-msvc": "0.142.0"
|
||||
}
|
||||
},
|
||||
"node_modules/oxc-resolver": {
|
||||
@@ -6403,13 +6414,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
|
||||
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.0"
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -6422,9 +6433,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
|
||||
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -6450,9 +6461,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.19",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
|
||||
"integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
|
||||
"version": "8.5.25",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
|
||||
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -6470,7 +6481,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.16",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -6877,9 +6888,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/smol-toml": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz",
|
||||
"integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==",
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.1.tgz",
|
||||
"integrity": "sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
@@ -7235,9 +7246,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/unbash": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/unbash/-/unbash-4.0.3.tgz",
|
||||
"integrity": "sha512-3cudTErfToSc4Ggv8XGXVNVli/xHKUtUZvaY5UVwhOcUPbQGz7PeaEnT/SAVgNziZtX67KEN9swMUYkLghxA1w==",
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/unbash/-/unbash-4.0.5.tgz",
|
||||
"integrity": "sha512-EE9xv9cr93DSppe086Rnbq0jwG7MBCEe22JZ4UQ8Bn9RyUwDSoUpBmzdb5+7PzocdqBEb/K73FGPaQUMmLxTQQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
@@ -7252,15 +7263,22 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicorn-magic": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "owncord-client",
|
||||
"private": true,
|
||||
"version": "1.2.0-alpha.1",
|
||||
"version": "1.2.0-alpha.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -14,12 +14,14 @@
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:prod": "npm run build && playwright test --config playwright.config.prod.ts",
|
||||
"test:e2e:native": "playwright test --config playwright.config.native.ts",
|
||||
"test:e2e:admin": "playwright test --config playwright.config.admin.ts",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:browser": "vitest run --config vitest.config.browser.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:build": "tsc -p tsconfig.build.json --noEmit",
|
||||
"typecheck:e2e": "tsc -p tsconfig.e2e.json --noEmit",
|
||||
"lint": "oxlint src/ && eslint src/",
|
||||
"lint:fix": "eslint src/ --fix",
|
||||
"lint:ox": "oxlint src/",
|
||||
@@ -37,12 +39,13 @@
|
||||
"@stryker-mutator/typescript-checker": "^9.6.1",
|
||||
"@stryker-mutator/vitest-runner": "^9.6.1",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/node": "^20.19.43",
|
||||
"@vitest/browser": "^3.2.4",
|
||||
"@vitest/coverage-v8": "^3",
|
||||
"eslint": "^10.8.0",
|
||||
"fast-check": "^4.9.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"knip": "^6.1.1",
|
||||
"knip": "^6.31.0",
|
||||
"oxlint": "^1.76.0",
|
||||
"prettier": "^3.9.6",
|
||||
"typescript": "^5.7",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Playwright config for the ADMIN PANEL e2e suite — the server-embedded SPA
|
||||
* (Server/admin/static/index.html), driven against a REAL server started by
|
||||
* tests/e2e/admin/start-server.sh (fresh temp data dir, TLS off, loopback).
|
||||
*
|
||||
* Unlike the mocked-Tauri web suite this exercises the true stack: chi
|
||||
* router, admin middleware/gates, SQLite, and the SPA itself. The journey is
|
||||
* stateful by design (first-run wizard creates the owner the later tests log
|
||||
* in as), so it runs serially in one worker against one server instance.
|
||||
*
|
||||
* Usage: npm run test:e2e:admin (requires the Go toolchain)
|
||||
*/
|
||||
const PORT = process.env.OWNCORD_ADMIN_E2E_PORT ?? "18446";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e/admin",
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 5_000 },
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: process.env.CI ? 2 : 1,
|
||||
reporter: process.env.CI
|
||||
? [
|
||||
["html", { open: "never" }],
|
||||
["junit", { outputFile: "test-results/admin-junit.xml" }],
|
||||
]
|
||||
: "html",
|
||||
|
||||
use: {
|
||||
baseURL: `http://127.0.0.1:${PORT}`,
|
||||
screenshot: "only-on-failure",
|
||||
trace: "on-first-retry",
|
||||
contextOptions: { reducedMotion: "reduce" },
|
||||
},
|
||||
|
||||
webServer: {
|
||||
command: "bash tests/e2e/admin/start-server.sh",
|
||||
url: `http://127.0.0.1:${PORT}/health`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
// First run compiles the Go server; CI cold caches need the headroom.
|
||||
timeout: 240_000,
|
||||
},
|
||||
});
|
||||
@@ -60,7 +60,10 @@ export default defineConfig({
|
||||
"app-layout.spec.ts",
|
||||
"channel-navigation.spec.ts",
|
||||
"chat-operations.spec.ts",
|
||||
"dm-system.spec.ts",
|
||||
"reconnection.spec.ts",
|
||||
"settings-overlay.spec.ts",
|
||||
"theme-persistence.spec.ts",
|
||||
"voice-controls.spec.ts",
|
||||
"overlays.spec.ts",
|
||||
],
|
||||
|
||||
@@ -9,7 +9,7 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
testIgnore: ["**/native/**"],
|
||||
testIgnore: ["**/native/**", "**/admin/**"],
|
||||
timeout: 30_000,
|
||||
expect: {
|
||||
timeout: 5_000,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
testIgnore: ["**/native/**"],
|
||||
testIgnore: ["**/native/**", "**/admin/**"],
|
||||
timeout: 30_000,
|
||||
expect: {
|
||||
timeout: 5_000,
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
// =============================================================================
|
||||
// RNNoise AudioWorklet Processor
|
||||
//
|
||||
// Runs on the audio rendering thread. Receives WASM module bytes from the
|
||||
// main thread, initializes RNNoise, and processes 480-sample frames at 48kHz.
|
||||
// =============================================================================
|
||||
|
||||
const FRAME_SIZE = 480;
|
||||
const WASM_MEMORY_INITIAL_PAGES = 256;
|
||||
const OUTPUT_RING_CAPACITY = 50;
|
||||
const RN_NOISE_INT16_SCALE = 32768;
|
||||
|
||||
declare abstract class AudioWorkletProcessor {
|
||||
readonly port: MessagePort;
|
||||
}
|
||||
|
||||
declare function registerProcessor(
|
||||
name: string,
|
||||
processorCtor: typeof RNNoiseProcessor,
|
||||
): void;
|
||||
|
||||
interface RNNoiseWasmExports extends WebAssembly.Exports {
|
||||
rnnoise_create(): number;
|
||||
rnnoise_destroy(state: number): void;
|
||||
rnnoise_process_frame(state: number, outputPtr: number, inputPtr: number): void;
|
||||
malloc(size: number): number;
|
||||
free(ptr: number): void;
|
||||
}
|
||||
|
||||
interface RNNoiseWasmInstance extends WebAssembly.Instance {
|
||||
exports: RNNoiseWasmExports;
|
||||
}
|
||||
|
||||
class RNNoiseProcessor extends AudioWorkletProcessor {
|
||||
private _instance: RNNoiseWasmInstance | null = null;
|
||||
private _state: number = 0;
|
||||
private _inputPtr: number = 0;
|
||||
private _outputPtr: number = 0;
|
||||
private _heapF32: Float32Array | null = null;
|
||||
private _ready: boolean = false;
|
||||
private _destroyed: boolean = false;
|
||||
|
||||
// Ring buffer to accumulate 480-sample frames
|
||||
private _inputRing: Float32Array;
|
||||
private _inputRingOffset: number = 0;
|
||||
|
||||
// Output ring buffer (contiguous for efficiency)
|
||||
private _outBuffer: Float32Array;
|
||||
private _outWritePos: number = 0;
|
||||
private _outReadPos: number = 0;
|
||||
private _outAvailable: number = 0;
|
||||
private _outSampleOffset: number = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this._inputRing = new Float32Array(FRAME_SIZE);
|
||||
this._outBuffer = new Float32Array(OUTPUT_RING_CAPACITY * FRAME_SIZE);
|
||||
|
||||
this.port.onmessage = (event: MessageEvent) => {
|
||||
if (event.data.type === "init") {
|
||||
this._initWasm(event.data.wasmBytes);
|
||||
} else if (event.data.type === "destroy") {
|
||||
this._cleanup();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports an error to the main thread and logs it.
|
||||
* @param message - Error message
|
||||
* @param error - Optional error object
|
||||
* @private
|
||||
*/
|
||||
private _reportError(message: string, error?: unknown): void {
|
||||
console.error(`RNNoise Processor: ${message}`, error);
|
||||
this.port.postMessage({ type: "error", message });
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the WASM module and RNNoise state.
|
||||
* @param wasmBytes - Raw WASM module bytes
|
||||
* @private
|
||||
*/
|
||||
private async _initWasm(wasmBytes: ArrayBuffer): Promise<void> {
|
||||
let allocated = false;
|
||||
try {
|
||||
// Basic validation: check for expected exports
|
||||
const module = await WebAssembly.compile(wasmBytes);
|
||||
const expectedExports = ['rnnoise_create', 'rnnoise_destroy', 'rnnoise_process_frame', 'malloc', 'free'];
|
||||
const availableExports = WebAssembly.Module.exports(module).map(exp => exp.name);
|
||||
|
||||
const hasRequiredExports = expectedExports.every(exp => availableExports.includes(exp));
|
||||
if (!hasRequiredExports) {
|
||||
throw new Error('WASM module missing required RNNoise exports');
|
||||
}
|
||||
|
||||
const memory = new WebAssembly.Memory({ initial: WASM_MEMORY_INITIAL_PAGES });
|
||||
const importObject = {
|
||||
env: {
|
||||
memory,
|
||||
emscripten_notify_memory_growth: () => {
|
||||
this._heapF32 = new Float32Array(memory.buffer);
|
||||
},
|
||||
},
|
||||
wasi_snapshot_preview1: {
|
||||
proc_exit: () => {},
|
||||
fd_close: () => 0,
|
||||
fd_write: () => 0,
|
||||
fd_seek: () => 0,
|
||||
},
|
||||
};
|
||||
|
||||
// Try instantiating with the raw WASM bytes
|
||||
const { instance } = await WebAssembly.instantiate(wasmBytes, importObject);
|
||||
this._instance = instance as RNNoiseWasmInstance;
|
||||
this._heapF32 = new Float32Array(memory.buffer);
|
||||
|
||||
// Call RNNoise C API
|
||||
const exports = instance.exports as unknown as RNNoiseWasmExports;
|
||||
this._state = exports.rnnoise_create();
|
||||
this._inputPtr = exports.malloc(FRAME_SIZE * 4);
|
||||
this._outputPtr = exports.malloc(FRAME_SIZE * 4);
|
||||
allocated = true;
|
||||
|
||||
this._ready = true;
|
||||
this.port.postMessage({ type: "ready" });
|
||||
} catch (err) {
|
||||
// Cleanup allocated memory on failure
|
||||
if (allocated && this._instance) {
|
||||
try {
|
||||
const exports = this._instance.exports;
|
||||
if (this._inputPtr) exports.free(this._inputPtr);
|
||||
if (this._outputPtr) exports.free(this._outputPtr);
|
||||
if (this._state) exports.rnnoise_destroy(this._state);
|
||||
} catch (cleanupErr) {
|
||||
// Log cleanup errors but don't override original error
|
||||
console.warn('Failed to cleanup WASM memory:', cleanupErr);
|
||||
}
|
||||
}
|
||||
this._reportError(`WASM initialization failed: ${err instanceof Error ? err.message : String(err)}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a complete 480-sample frame through RNNoise.
|
||||
* Copies input ring buffer to WASM memory, runs noise suppression,
|
||||
* and stores the result in the output ring buffer.
|
||||
* @private
|
||||
*/
|
||||
private _processFrame(): void {
|
||||
if (!this._instance || !this._heapF32) return;
|
||||
const exports = this._instance.exports;
|
||||
|
||||
const inOff = this._inputPtr / 4;
|
||||
const outOff = this._outputPtr / 4;
|
||||
|
||||
// CRITICAL: Bounds check before accessing heap
|
||||
if (inOff + FRAME_SIZE > this._heapF32.length ||
|
||||
outOff + FRAME_SIZE > this._heapF32.length) {
|
||||
console.error('WASM heap bounds exceeded');
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < FRAME_SIZE; i++) {
|
||||
this._heapF32[inOff + i] = (this._inputRing[i] ?? 0) * RN_NOISE_INT16_SCALE;
|
||||
}
|
||||
|
||||
exports.rnnoise_process_frame(this._state, this._outputPtr, this._inputPtr);
|
||||
|
||||
// Write to contiguous buffer
|
||||
const writeStart = this._outWritePos * FRAME_SIZE;
|
||||
for (let i = 0; i < FRAME_SIZE; i++) {
|
||||
this._outBuffer[writeStart + i] = (this._heapF32[outOff + i] ?? 0) / RN_NOISE_INT16_SCALE;
|
||||
}
|
||||
this._outWritePos = (this._outWritePos + 1) % OUTPUT_RING_CAPACITY;
|
||||
if (this._outAvailable < OUTPUT_RING_CAPACITY) {
|
||||
this._outAvailable++;
|
||||
} else {
|
||||
// Overwrite oldest
|
||||
this._outReadPos = (this._outReadPos + 1) % OUTPUT_RING_CAPACITY;
|
||||
this._outSampleOffset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up WASM resources and marks the processor as destroyed.
|
||||
* Safe to call multiple times.
|
||||
* @private
|
||||
*/
|
||||
private _cleanup(): void {
|
||||
if (this._instance && this._state) {
|
||||
try {
|
||||
const exports = this._instance.exports;
|
||||
exports.rnnoise_destroy(this._state);
|
||||
exports.free(this._inputPtr);
|
||||
exports.free(this._outputPtr);
|
||||
} catch (err) {
|
||||
console.warn('RNNoise cleanup failed:', err);
|
||||
// Continue cleanup even if individual steps fail
|
||||
}
|
||||
}
|
||||
this._ready = false;
|
||||
this._destroyed = true;
|
||||
this._state = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes input audio data into the ring buffer and triggers frame processing.
|
||||
* @param inData - Input audio samples
|
||||
* @private
|
||||
*/
|
||||
private _processInputRingBuffer(inData: Float32Array): void {
|
||||
let inIdx = 0;
|
||||
while (inIdx < inData.length) {
|
||||
const needed = FRAME_SIZE - this._inputRingOffset;
|
||||
const toCopy = Math.min(needed, inData.length - inIdx);
|
||||
this._inputRing.set(inData.subarray(inIdx, inIdx + toCopy), this._inputRingOffset);
|
||||
this._inputRingOffset += toCopy;
|
||||
inIdx += toCopy;
|
||||
|
||||
if (this._inputRingOffset >= FRAME_SIZE) {
|
||||
this._processFrame();
|
||||
this._inputRingOffset = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills output buffer from the processed frames ring buffer.
|
||||
* @param outData - Output audio buffer to fill
|
||||
* @private
|
||||
*/
|
||||
private _fillOutputFromRingBuffer(outData: Float32Array): void {
|
||||
let outIdx = 0;
|
||||
while (outIdx < outData.length && this._outAvailable > 0) {
|
||||
const readStart = this._outReadPos * FRAME_SIZE;
|
||||
const available = FRAME_SIZE - this._outSampleOffset;
|
||||
const toWrite = Math.min(available, outData.length - outIdx);
|
||||
outData.set(this._outBuffer.subarray(readStart + this._outSampleOffset, readStart + this._outSampleOffset + toWrite), outIdx);
|
||||
outIdx += toWrite;
|
||||
this._outSampleOffset += toWrite;
|
||||
if (this._outSampleOffset >= FRAME_SIZE) {
|
||||
this._outReadPos = (this._outReadPos + 1) % OUTPUT_RING_CAPACITY;
|
||||
this._outAvailable--;
|
||||
this._outSampleOffset = 0;
|
||||
}
|
||||
}
|
||||
// Fill remaining with silence
|
||||
if (outIdx < outData.length) {
|
||||
outData.fill(0, outIdx);
|
||||
}
|
||||
}
|
||||
|
||||
process(inputs: Float32Array[][], outputs: Float32Array[][]): boolean {
|
||||
if (this._destroyed) return false;
|
||||
|
||||
// Validate input/output structure
|
||||
if (!inputs || !inputs[0] || !inputs[0][0] ||
|
||||
!outputs || !outputs[0] || !outputs[0][0]) {
|
||||
return true; // Pass through silence or existing data
|
||||
}
|
||||
|
||||
const input = inputs[0];
|
||||
const output = outputs[0];
|
||||
const inData = input[0]!;
|
||||
const outData = output[0]!;
|
||||
|
||||
// Validate buffer lengths
|
||||
if (inData.length === 0 || outData.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!this._ready) {
|
||||
// Pass through until WASM is ready
|
||||
const copyLength = Math.min(inData.length, outData.length);
|
||||
outData.set(inData.subarray(0, copyLength));
|
||||
if (copyLength < outData.length) {
|
||||
outData.fill(0, copyLength);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
this._processInputRingBuffer(inData);
|
||||
this._fillOutputFromRingBuffer(outData);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor("rnnoise-processor", RNNoiseProcessor);
|
||||
@@ -8,4 +8,28 @@ ignore = [
|
||||
# Drop both entries when the notification chain moves to quick-xml >= 0.41.
|
||||
"RUSTSEC-2026-0194",
|
||||
"RUSTSEC-2026-0195",
|
||||
|
||||
# glib 0.18.5 is pinned by the whole Linux GTK stack: wry (even 0.56)
|
||||
# requires `webkit2gtk =2.0.2`, which requires `glib ^0.18.0`. The fix
|
||||
# landed in glib 0.20.0 and was never backported (0.18.5 is the last 0.18
|
||||
# release; 0.19.x is still in range), so no semver-compatible route exists.
|
||||
# The unsoundness is only reachable through `Variant::array_iter_str()` —
|
||||
# nothing in the dependency tree or in src-tauri/src/ calls it, and the
|
||||
# crate is Linux-only here (see the cfg(target_os = "linux") block in
|
||||
# Cargo.toml). Drop this entry when webkit2gtk moves to gtk-rs 0.20.
|
||||
"RUSTSEC-2024-0429",
|
||||
|
||||
# rand 0.7.3 arrives only as a BUILD dependency, three levels down:
|
||||
# tauri-utils -> kuchikiki 0.8.8-speedreader -> selectors 0.24.0, whose
|
||||
# build.rs uses phf_codegen -> phf_generator 0.8.0 (which requires
|
||||
# rand ^0.7). It runs at codegen time and never links into a shipped
|
||||
# binary. The advisory needs `ThreadRng` reseeding under a custom logger
|
||||
# with rand's `log` feature on; phf_generator instead uses a fixed-seed
|
||||
# `SmallRng::seed_from_u64(1234567890)` and never enables `log` — and no
|
||||
# other crate here depends on rand 0.7, so feature unification cannot
|
||||
# turn it on. Not upgradable: kuchikiki 0.8.9-speedreader would drop this
|
||||
# chain, but cargo will not match a pre-release across patch versions
|
||||
# (`^0.8.8-speedreader` rejects 0.8.9-speedreader) and tauri-utils 2.9.3
|
||||
# is the latest release. Drop this entry when tauri-utils bumps kuchikiki.
|
||||
"RUSTSEC-2026-0097",
|
||||
]
|
||||
|
||||
Generated
+6
-401
@@ -69,56 +69,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
@@ -423,16 +373,6 @@ dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
@@ -603,35 +543,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono-tz"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"chrono-tz-build",
|
||||
"phf 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono-tz-build"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
|
||||
dependencies = [
|
||||
"parse-zoneinfo",
|
||||
"phf 0.11.3",
|
||||
"phf_codegen 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
@@ -642,52 +558,6 @@ dependencies = [
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -707,18 +577,6 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "console"
|
||||
version = "0.16.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
|
||||
dependencies = [
|
||||
"encode_unicode",
|
||||
"libc",
|
||||
"unicode-width",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
@@ -860,25 +718,6 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
@@ -1087,12 +926,6 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deunicode"
|
||||
version = "1.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
|
||||
|
||||
[[package]]
|
||||
name = "device_query"
|
||||
version = "2.1.0"
|
||||
@@ -1310,12 +1143,6 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "encode_unicode"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
@@ -1874,30 +1701,6 @@ version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "globset"
|
||||
version = "0.4.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"bstr",
|
||||
"log",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "globwalk"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"ignore",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gobject-sys"
|
||||
version = "0.18.0"
|
||||
@@ -2110,15 +1913,6 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "humansize"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
|
||||
dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.8.1"
|
||||
@@ -2195,7 +1989,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.58.0",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2331,22 +2125,6 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ignore"
|
||||
version = "0.4.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"globset",
|
||||
"log",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"same-file",
|
||||
"walkdir",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
@@ -2370,19 +2148,6 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indicatif"
|
||||
version = "0.18.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c"
|
||||
dependencies = [
|
||||
"console",
|
||||
"portable-atomic",
|
||||
"unicode-width",
|
||||
"unit-prefix",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "infer"
|
||||
version = "0.19.0"
|
||||
@@ -2437,12 +2202,6 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -2626,12 +2385,6 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.14"
|
||||
@@ -3208,12 +2961,6 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.3"
|
||||
@@ -3274,7 +3021,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "owncord-client"
|
||||
version = "1.2.0-alpha.1"
|
||||
version = "1.2.0-alpha.2"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"device_query",
|
||||
@@ -3301,7 +3048,6 @@ dependencies = [
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"tauri-typegen",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-tungstenite",
|
||||
@@ -3367,15 +3113,6 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parse-zoneinfo"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24"
|
||||
dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
@@ -3388,49 +3125,6 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pest"
|
||||
version = "2.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"ucd-trie",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_derive"
|
||||
version = "2.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_generator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_generator"
|
||||
version = "2.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_meta",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_meta"
|
||||
version = "2.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.8.0"
|
||||
@@ -3692,12 +3386,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
@@ -4311,9 +3999,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.42"
|
||||
version = "0.23.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
|
||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
@@ -4582,12 +4270,6 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-rename-rule"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8a059d895f1a31dd928f40abbea4e7177e3d8ff3aa4152fdb7a396ae1ef63a3"
|
||||
|
||||
[[package]]
|
||||
name = "serde-untagged"
|
||||
version = "0.1.9"
|
||||
@@ -4820,16 +4502,6 @@ version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "slug"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724"
|
||||
dependencies = [
|
||||
"deunicode",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
@@ -5568,27 +5240,6 @@ dependencies = [
|
||||
"wry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-typegen"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e761d97bf6c90f13894383493485008d0c51f67ff7b12c0f95dc34b8a9e6e73"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"indicatif",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde-rename-rule",
|
||||
"serde_json",
|
||||
"syn 2.0.117",
|
||||
"tera",
|
||||
"thiserror 2.0.18",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-utils"
|
||||
version = "2.9.3"
|
||||
@@ -5685,28 +5336,6 @@ dependencies = [
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tera"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8004bca281f2d32df3bacd59bc67b312cb4c70cea46cbd79dbe8ac5ed206722"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"globwalk",
|
||||
"humansize",
|
||||
"lazy_static",
|
||||
"percent-encoding",
|
||||
"pest",
|
||||
"pest_derive",
|
||||
"rand 0.8.7",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"slug",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -6134,12 +5763,6 @@ version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "ucd-trie"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
@@ -6204,24 +5827,12 @@ version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "unit-prefix"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -6265,12 +5876,6 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.22.0"
|
||||
@@ -7561,9 +7166,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "owncord-client"
|
||||
version = "1.2.0-alpha.1"
|
||||
version = "1.2.0-alpha.2"
|
||||
edition = "2021"
|
||||
# Effective minimum: tauri 2.11 declares rust-version = "1.77.2", so the crate
|
||||
# cannot build below it. Declaring it here enables Cargo's MSRV-aware resolver
|
||||
@@ -21,7 +21,6 @@ crate-type = ["lib", "cdylib", "staticlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
tauri-typegen = "0.5"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"core:window:allow-outer-size",
|
||||
"core:window:allow-available-monitors",
|
||||
"core:window:allow-center",
|
||||
"core:window:allow-request-user-attention",
|
||||
"notification:default",
|
||||
"notification:allow-notify",
|
||||
"notification:allow-request-permission",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use serde::Serialize;
|
||||
use std::sync::Mutex;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::secret_store::{self, Backend};
|
||||
@@ -8,9 +9,9 @@ use crate::secret_store::{self, Backend};
|
||||
pub struct CredentialData {
|
||||
pub username: String,
|
||||
pub token: String,
|
||||
// Password is stored in the credential blob for re-authentication but
|
||||
// is never serialized back to the frontend over IPC to limit exposure.
|
||||
#[serde(skip)]
|
||||
// Password is stored in the credential blob for re-authentication and is
|
||||
// serialized back to the frontend over IPC so the login form can prefill
|
||||
// it when the user ticked "Remember password".
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
@@ -53,6 +54,42 @@ fn require_non_empty(value: &str, field: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-command serialization
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// B4-3 moved every command below to `#[tauri::command(async)]` so the
|
||||
// blocking keyring/DPAPI I/O runs off Tauri's IPC main thread instead of
|
||||
// freezing the UI on it. Before that, Tauri ran all (sync) commands one at a
|
||||
// time on that thread, so two overlapping invocations were always fully
|
||||
// serialized in arrival order. `async` dispatches each invocation onto the
|
||||
// async runtime's thread pool instead, so two overlapping calls can now
|
||||
// genuinely run concurrently and interleave their OS credential-store
|
||||
// operations.
|
||||
//
|
||||
// That is reachable, not hypothetical: `identity.ts`'s legacy-key migration
|
||||
// does a save-then-delete pair for two different accounts, and logging out
|
||||
// fires a fire-and-forget `delete_credential` for a host whose connect-page
|
||||
// auto-login can immediately issue `load_credential` for the very same host.
|
||||
// Nothing upstream awaits the delete before the read can start.
|
||||
//
|
||||
// This mutex restores the "only one credential-store operation in flight at
|
||||
// a time" property that made ordering safe pre-`async`, without giving back
|
||||
// the perf win: it guards the whole command body (not just the raw OS call),
|
||||
// so the fallback file's read-modify-write in `secret_store::set_with` is
|
||||
// still atomic with respect to a concurrent read or delete for the same or a
|
||||
// different account.
|
||||
static CREDENTIAL_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Run `f` with every other credential-store command excluded. Poisoning is
|
||||
/// recovered from (the guarded value is `()`, so there is nothing to
|
||||
/// distrust) rather than propagated, so a panic inside one command cannot
|
||||
/// permanently wedge every credential operation for the rest of the process.
|
||||
fn with_credential_lock<T>(f: impl FnOnce() -> T) -> T {
|
||||
let _guard = CREDENTIAL_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
f()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri commands
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -68,7 +105,7 @@ fn require_non_empty(value: &str, field: &str) -> Result<(), String> {
|
||||
/// On macOS it is stored in the system Keychain. The write is read back before
|
||||
/// this returns — see [`crate::secret_store`] for what happens when it does not
|
||||
/// come back.
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn save_credential(
|
||||
app: AppHandle,
|
||||
host: String,
|
||||
@@ -76,37 +113,41 @@ pub fn save_credential(
|
||||
token: String,
|
||||
password: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
require_non_empty(&host, "host")?;
|
||||
require_non_empty(&token, "token")?;
|
||||
require_non_empty(&username, "username")?;
|
||||
with_credential_lock(|| {
|
||||
require_non_empty(&host, "host")?;
|
||||
require_non_empty(&token, "token")?;
|
||||
require_non_empty(&username, "username")?;
|
||||
|
||||
let mut payload = serde_json::json!({
|
||||
"username": username,
|
||||
"token": token,
|
||||
});
|
||||
if let Some(ref pw) = password {
|
||||
payload["password"] = serde_json::Value::String(pw.clone());
|
||||
}
|
||||
let mut payload = serde_json::json!({
|
||||
"username": username,
|
||||
"token": token,
|
||||
});
|
||||
if let Some(ref pw) = password {
|
||||
payload["password"] = serde_json::Value::String(pw.clone());
|
||||
}
|
||||
|
||||
secret_store::set(&app, &login_account(&host), &payload.to_string())
|
||||
.map_err(|e| format!("save_credential failed: {e}"))?;
|
||||
Ok(())
|
||||
secret_store::set(&app, &login_account(&host), &payload.to_string())
|
||||
.map_err(|e| format!("save_credential failed: {e}"))?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Load a credential from the system credential store.
|
||||
///
|
||||
/// Returns `None` when no credential exists for the given host.
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn load_credential(app: AppHandle, host: String) -> Result<Option<CredentialData>, String> {
|
||||
require_non_empty(&host, "host")?;
|
||||
with_credential_lock(|| {
|
||||
require_non_empty(&host, "host")?;
|
||||
|
||||
let Some(json_str) = secret_store::get(&app, &login_account(&host))
|
||||
.map_err(|e| format!("load_credential failed: {e}"))?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(json_str) = secret_store::get(&app, &login_account(&host))
|
||||
.map_err(|e| format!("load_credential failed: {e}"))?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
parse_credential_blob(&json_str).map(Some)
|
||||
parse_credential_blob(&json_str).map(Some)
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the stored credential JSON blob.
|
||||
@@ -142,11 +183,13 @@ fn parse_credential_blob(json_str: &str) -> Result<CredentialData, String> {
|
||||
/// Delete a credential from the system credential store.
|
||||
///
|
||||
/// Deleting a non-existent credential is not treated as an error.
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn delete_credential(app: AppHandle, host: String) -> Result<(), String> {
|
||||
require_non_empty(&host, "host")?;
|
||||
secret_store::delete(&app, &login_account(&host))
|
||||
.map_err(|e| format!("delete_credential failed: {e}"))
|
||||
with_credential_lock(|| {
|
||||
require_non_empty(&host, "host")?;
|
||||
secret_store::delete(&app, &login_account(&host))
|
||||
.map_err(|e| format!("delete_credential failed: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -165,34 +208,40 @@ pub fn delete_credential(app: AppHandle, host: String) -> Result<(), String> {
|
||||
/// file (DPAPI on Windows, sealed per-install key elsewhere); if that is also
|
||||
/// unavailable this returns an error rather than reporting a success that would
|
||||
/// leave peers rejecting the user's voice announce after a restart.
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn save_identity_key(app: AppHandle, host: String, key: String) -> Result<(), String> {
|
||||
require_non_empty(&host, "host")?;
|
||||
require_non_empty(&key, "key")?;
|
||||
with_credential_lock(|| {
|
||||
require_non_empty(&host, "host")?;
|
||||
require_non_empty(&key, "key")?;
|
||||
|
||||
secret_store::set(&app, &identity_account(&host), &key)
|
||||
.map_err(|e| format!("save_identity_key failed: {e}"))?;
|
||||
Ok(())
|
||||
secret_store::set(&app, &identity_account(&host), &key)
|
||||
.map_err(|e| format!("save_identity_key failed: {e}"))?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Load the identity private key for `host`.
|
||||
///
|
||||
/// Returns `None` when no identity key exists for the given host.
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn load_identity_key(app: AppHandle, host: String) -> Result<Option<String>, String> {
|
||||
require_non_empty(&host, "host")?;
|
||||
secret_store::get(&app, &identity_account(&host))
|
||||
.map_err(|e| format!("load_identity_key failed: {e}"))
|
||||
with_credential_lock(|| {
|
||||
require_non_empty(&host, "host")?;
|
||||
secret_store::get(&app, &identity_account(&host))
|
||||
.map_err(|e| format!("load_identity_key failed: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete the identity private key for `host`.
|
||||
///
|
||||
/// Deleting a non-existent key is not treated as an error.
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn delete_identity_key(app: AppHandle, host: String) -> Result<(), String> {
|
||||
require_non_empty(&host, "host")?;
|
||||
secret_store::delete(&app, &identity_account(&host))
|
||||
.map_err(|e| format!("delete_identity_key failed: {e}"))
|
||||
with_credential_lock(|| {
|
||||
require_non_empty(&host, "host")?;
|
||||
secret_store::delete(&app, &identity_account(&host))
|
||||
.map_err(|e| format!("delete_identity_key failed: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -217,44 +266,46 @@ pub struct CredentialStoreProbe {
|
||||
/// announce: it distinguishes "the credential store is fine" from "writes are
|
||||
/// accepted and dropped" without touching any real credential. The probe
|
||||
/// account is removed again whatever the outcome.
|
||||
#[tauri::command]
|
||||
#[tauri::command(async)]
|
||||
pub fn probe_credential_store(app: AppHandle) -> CredentialStoreProbe {
|
||||
// Underscores are not legal in DNS hostnames, so this cannot collide with a
|
||||
// real `{host}` or `identity:{host}` account.
|
||||
const PROBE_ACCOUNT: &str = "__diagnostic_probe__";
|
||||
const PROBE_SECRET: &str = "owncord-credential-store-probe";
|
||||
with_credential_lock(|| {
|
||||
// Underscores are not legal in DNS hostnames, so this cannot collide
|
||||
// with a real `{host}` or `identity:{host}` account.
|
||||
const PROBE_ACCOUNT: &str = "__diagnostic_probe__";
|
||||
const PROBE_SECRET: &str = "owncord-credential-store-probe";
|
||||
|
||||
let result = secret_store::set(&app, PROBE_ACCOUNT, PROBE_SECRET).and_then(|backend| {
|
||||
match secret_store::get(&app, PROBE_ACCOUNT)? {
|
||||
Some(ref got) if got == PROBE_SECRET => Ok(backend),
|
||||
Some(_) => Err("read back a different value than was written".into()),
|
||||
None => Err("the store reported a successful write but returned no entry".into()),
|
||||
let result = secret_store::set(&app, PROBE_ACCOUNT, PROBE_SECRET).and_then(|backend| {
|
||||
match secret_store::get(&app, PROBE_ACCOUNT)? {
|
||||
Some(ref got) if got == PROBE_SECRET => Ok(backend),
|
||||
Some(_) => Err("read back a different value than was written".into()),
|
||||
None => Err("the store reported a successful write but returned no entry".into()),
|
||||
}
|
||||
});
|
||||
|
||||
// Always clean up, including when the probe failed part-way through.
|
||||
if let Err(e) = secret_store::delete(&app, PROBE_ACCOUNT) {
|
||||
log::warn!("failed to remove credential store probe entry: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
// Always clean up, including when the probe failed part-way through.
|
||||
if let Err(e) = secret_store::delete(&app, PROBE_ACCOUNT) {
|
||||
log::warn!("failed to remove credential store probe entry: {e}");
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(backend) => {
|
||||
log::info!("credential store probe succeeded (backend: {backend:?})");
|
||||
CredentialStoreProbe {
|
||||
ok: true,
|
||||
backend: Some(backend),
|
||||
error: None,
|
||||
match result {
|
||||
Ok(backend) => {
|
||||
log::info!("credential store probe succeeded (backend: {backend:?})");
|
||||
CredentialStoreProbe {
|
||||
ok: true,
|
||||
backend: Some(backend),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("credential store probe failed: {e}");
|
||||
CredentialStoreProbe {
|
||||
ok: false,
|
||||
backend: None,
|
||||
error: Some(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("credential store probe failed: {e}");
|
||||
CredentialStoreProbe {
|
||||
ok: false,
|
||||
backend: None,
|
||||
error: Some(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -347,14 +398,60 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_data_skips_password_in_json() {
|
||||
fn credential_data_serializes_password_for_prefill() {
|
||||
let data = CredentialData {
|
||||
username: "alice".into(),
|
||||
token: "tok".into(),
|
||||
password: Some("pw".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&data).unwrap();
|
||||
assert!(!json.contains("password"));
|
||||
assert!(!json.contains("pw"));
|
||||
assert!(json.contains("password"));
|
||||
assert!(json.contains("pw"));
|
||||
}
|
||||
|
||||
/// B4-3 follow-up: all 7 commands moved to `#[tauri::command(async)]`,
|
||||
/// which runs each invocation on the async runtime's thread pool instead
|
||||
/// of Tauri's single IPC main thread. Two overlapping invocations (e.g.
|
||||
/// `identity.ts`'s save-then-delete legacy-key migration, or a logout's
|
||||
/// `delete_credential` racing a connect-page auto-login's
|
||||
/// `load_credential` for the same host) can now genuinely run
|
||||
/// concurrently. `with_credential_lock` must serialize them: this proves
|
||||
/// no two holders of the lock ever run their critical section at the
|
||||
/// same time, regardless of which OS thread the runtime schedules them
|
||||
/// on.
|
||||
#[test]
|
||||
fn credential_lock_serializes_overlapping_commands() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
let concurrent = Arc::new(AtomicUsize::new(0));
|
||||
let max_concurrent = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let handles: Vec<_> = (0..8)
|
||||
.map(|_| {
|
||||
let concurrent = Arc::clone(&concurrent);
|
||||
let max_concurrent = Arc::clone(&max_concurrent);
|
||||
thread::spawn(move || {
|
||||
with_credential_lock(|| {
|
||||
let now = concurrent.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
max_concurrent.fetch_max(now, Ordering::SeqCst);
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
concurrent.fetch_sub(1, Ordering::SeqCst);
|
||||
});
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
for h in handles {
|
||||
h.join().unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
max_concurrent.load(Ordering::SeqCst),
|
||||
1,
|
||||
"two credential-store commands ran their critical section concurrently"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,12 +67,9 @@ pub fn load_or_create_key(dir: &Path) -> Result<[u8; KEY_LEN], String> {
|
||||
options.mode(0o600);
|
||||
}
|
||||
match options.open(&path) {
|
||||
Ok(mut file) => {
|
||||
file.write_all(&key)
|
||||
.and_then(|()| file.sync_all())
|
||||
.map_err(|e| format!("failed to write credential fallback key: {e}"))?;
|
||||
Ok(key)
|
||||
}
|
||||
Ok(mut file) => finish_new_key_file(&path, key, || {
|
||||
file.write_all(&key).and_then(|()| file.sync_all())
|
||||
}),
|
||||
// Lost the create race to another thread — use the winner's key.
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
let bytes = fs::read(&path)
|
||||
@@ -86,6 +83,28 @@ pub fn load_or_create_key(dir: &Path) -> Result<[u8; KEY_LEN], String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish writing a just-created (empty) key file: run `write_and_sync` — the
|
||||
/// real write_all + sync_all in production, injected here so the failure
|
||||
/// path is testable without forcing a genuine disk-full/IO error — and
|
||||
/// delete the file again if it fails.
|
||||
///
|
||||
/// `create_new` above already created `path` with zero bytes in it. Left
|
||||
/// behind, a write/sync failure leaves a short file that every future
|
||||
/// `load_or_create_key` call reads back and rejects forever (see this
|
||||
/// function's doc comment: "never rewritten once it exists") — silently
|
||||
/// poisoning the fallback store on the first ENOSPC/IO hiccup.
|
||||
fn finish_new_key_file(
|
||||
path: &Path,
|
||||
key: [u8; KEY_LEN],
|
||||
write_and_sync: impl FnOnce() -> std::io::Result<()>,
|
||||
) -> Result<[u8; KEY_LEN], String> {
|
||||
if let Err(e) = write_and_sync() {
|
||||
let _ = fs::remove_file(path);
|
||||
return Err(format!("failed to write credential fallback key: {e}"));
|
||||
}
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Seal `plaintext` under `key`, binding `aad` (the service + account name).
|
||||
///
|
||||
/// Output layout: `nonce (12 bytes) || ciphertext || tag`. The nonce is random
|
||||
@@ -217,6 +236,34 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removes_the_partial_file_when_the_write_fails() {
|
||||
// A crash / ENOSPC mid-write must not leave a short file behind:
|
||||
// load_or_create_key's doc comment says the key file is "never
|
||||
// rewritten once it exists", so a poisoned short file is permanent —
|
||||
// every future load fails the length check forever.
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"owncord-fallback-partial-write-test-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join(CREDENTIAL_FALLBACK_KEY_FILE);
|
||||
// `create_new` in load_or_create_key already created this empty file
|
||||
// before the write step (which is what's under test) runs.
|
||||
fs::write(&path, b"").unwrap();
|
||||
|
||||
let err = finish_new_key_file(&path, [7u8; KEY_LEN], || {
|
||||
Err(std::io::Error::other("disk full"))
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("failed to write"), "unexpected error: {err}");
|
||||
assert!(!path.exists(), "a failed write must not leave a partial key file behind");
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_corrupt_key_file() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
|
||||
@@ -33,7 +33,7 @@ use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use rustls::pki_types::ServerName;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::Mutex;
|
||||
@@ -57,6 +57,18 @@ impl HttpProxyState {
|
||||
inner: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the `remote_host` entry, but only if it still points at `port`.
|
||||
/// Used by `run_proxy_loop`'s accept-error exit path to deregister a dead
|
||||
/// tunnel without racing a newer tunnel that may have already replaced it
|
||||
/// (e.g. `stop_http_proxy` + a fresh `start_http_proxy` while this loop
|
||||
/// was mid-shutdown).
|
||||
async fn remove_if_port_matches(&self, remote_host: &str, port: u16) {
|
||||
let mut inner = self.inner.lock().await;
|
||||
if inner.get(remote_host).is_some_and(|entry| entry.port == port) {
|
||||
inner.remove(remote_host);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a remote host string before it is used in header rewriting and
|
||||
@@ -111,6 +123,7 @@ pub async fn start_http_proxy<R: Runtime>(
|
||||
app.clone(),
|
||||
listener,
|
||||
remote_host.clone(),
|
||||
port,
|
||||
shutdown_rx,
|
||||
));
|
||||
// Watch the loop so a panic is logged instead of vanishing silently (which
|
||||
@@ -161,6 +174,7 @@ async fn run_proxy_loop<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
listener: TcpListener,
|
||||
remote_host: String,
|
||||
port: u16,
|
||||
mut shutdown_rx: tokio::sync::oneshot::Receiver<()>,
|
||||
) {
|
||||
let mut consecutive_errors: u32 = 0;
|
||||
@@ -191,6 +205,21 @@ async fn run_proxy_loop<R: Runtime>(
|
||||
"[http_proxy] {} consecutive accept errors, stopping proxy loop",
|
||||
MAX_CONSECUTIVE_ACCEPT_ERRORS
|
||||
);
|
||||
// Deregister the dead tunnel BEFORE the break drops
|
||||
// `listener`, so a future start_http_proxy rebinds a
|
||||
// fresh port instead of handing back this closed one
|
||||
// forever. Doing it here rather than after the loop
|
||||
// returns matters: the listener still holds the port,
|
||||
// so no newer tunnel can have been handed the same
|
||||
// number and the port guard cannot misfire.
|
||||
if let Some(state) = app.try_state::<HttpProxyState>() {
|
||||
state.remove_if_port_matches(&remote_host, port).await;
|
||||
} else {
|
||||
warn!(
|
||||
"[http_proxy] state unmanaged; cannot deregister dead tunnel for {}",
|
||||
remote_host
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -408,6 +437,45 @@ async fn handle_connection<R: Runtime>(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Regression: the accept-error exit path in run_proxy_loop must be able to
|
||||
// deregister its own dead entry, but must NOT clobber a newer tunnel that
|
||||
// has since replaced it under the same remote_host key.
|
||||
#[tokio::test]
|
||||
async fn remove_if_port_matches_removes_only_matching_entry() {
|
||||
let state = HttpProxyState::new();
|
||||
{
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let mut inner = state.inner.lock().await;
|
||||
inner.insert(
|
||||
"example.com:8443".to_string(),
|
||||
ProxyEntry {
|
||||
port: 4242,
|
||||
shutdown_tx: tx,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// A stale loop reporting a port that no longer matches the live
|
||||
// entry must leave the current entry alone.
|
||||
state
|
||||
.remove_if_port_matches("example.com:8443", 9999)
|
||||
.await;
|
||||
assert_eq!(
|
||||
state.inner.lock().await.get("example.com:8443").map(|e| e.port),
|
||||
Some(4242),
|
||||
"mismatched port must not remove a newer tunnel's entry"
|
||||
);
|
||||
|
||||
// A loop reporting its own still-current port must remove it.
|
||||
state
|
||||
.remove_if_port_matches("example.com:8443", 4242)
|
||||
.await;
|
||||
assert!(
|
||||
state.inner.lock().await.get("example.com:8443").is_none(),
|
||||
"matching port must deregister the dead tunnel"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_crlf_and_null() {
|
||||
assert!(validate_remote_host("evil\r\nhost").is_err());
|
||||
|
||||
@@ -125,6 +125,7 @@ pub fn run() {
|
||||
ptt::ptt_stop,
|
||||
ptt::ptt_set_key,
|
||||
ptt::ptt_get_key,
|
||||
ptt::ptt_polling_supported,
|
||||
ptt::ptt_listen_for_key,
|
||||
livekit_proxy::start_livekit_proxy,
|
||||
livekit_proxy::stop_livekit_proxy,
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
// - Certificate validation uses the TOFU-pinned fingerprint from ws_proxy.
|
||||
// The WebSocket proxy must connect first to establish trust; the LiveKit
|
||||
// proxy then pins to that same certificate. If the cert changes between
|
||||
// WS and LiveKit connections, the LiveKit handshake will fail.
|
||||
// WS and LiveKit connections, the LiveKit handshake will fail (fail
|
||||
// closed) until the user accepts the new cert — each start call reloads
|
||||
// the stored pin and restarts the listener when it changed.
|
||||
// - Only one proxy instance runs at a time (per remote host). Connecting to
|
||||
// a different server replaces the proxy. Stale proxy ports are not reused.
|
||||
// - If the TcpListener errors (extremely unlikely on loopback), the cached
|
||||
@@ -29,7 +31,7 @@ use log::{debug, error, info, warn};
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use rustls::pki_types::ServerName;
|
||||
use tauri::Runtime;
|
||||
use tauri::{Manager, Runtime};
|
||||
use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::Mutex;
|
||||
@@ -45,6 +47,9 @@ struct ProxyInner {
|
||||
port: Option<u16>,
|
||||
/// The remote host:port we're proxying to.
|
||||
remote_host: String,
|
||||
/// The TOFU fingerprint the running listener pins. Baked into the proxy
|
||||
/// loop at spawn, so a re-pin in the cert store requires a restart.
|
||||
pinned_fingerprint: String,
|
||||
/// Shutdown signal sender.
|
||||
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
|
||||
}
|
||||
@@ -55,10 +60,26 @@ impl LiveKitProxyState {
|
||||
inner: Mutex::new(ProxyInner {
|
||||
port: None,
|
||||
remote_host: String::new(),
|
||||
pinned_fingerprint: String::new(),
|
||||
shutdown_tx: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the running-proxy state, but only if it still points at `port`.
|
||||
/// Mirrors HttpProxyState::remove_if_port_matches; used by run_proxy_loop's
|
||||
/// accept-error exit path so a dead listener doesn't keep being handed
|
||||
/// back by start_livekit_proxy's reuse branch, and doesn't race a newer
|
||||
/// proxy that may have already replaced it.
|
||||
async fn clear_if_port_matches(&self, port: u16) {
|
||||
let mut inner = self.inner.lock().await;
|
||||
if inner.port == Some(port) {
|
||||
inner.port = None;
|
||||
inner.remote_host.clear();
|
||||
inner.pinned_fingerprint.clear();
|
||||
inner.shutdown_tx = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -121,6 +142,21 @@ pub(crate) fn rewrite_proxy_headers(request: &str, remote_host: &str) -> String
|
||||
modified
|
||||
}
|
||||
|
||||
/// Decide whether an already-running proxy can serve a new start request:
|
||||
/// only when both the remote host AND the TOFU-pinned fingerprint are
|
||||
/// unchanged. The listener bakes its fingerprint in at spawn, so after the
|
||||
/// user accepts a rotated cert (which rewrites the store), reusing the old
|
||||
/// listener would fail every TLS handshake against the stale pin until
|
||||
/// logout — the caller must tear down and restart instead.
|
||||
pub(crate) fn can_reuse_proxy(
|
||||
running_host: &str,
|
||||
running_fingerprint: &str,
|
||||
requested_host: &str,
|
||||
stored_fingerprint: &str,
|
||||
) -> bool {
|
||||
running_host == requested_host && running_fingerprint == stored_fingerprint
|
||||
}
|
||||
|
||||
/// Extract the TLS server name from a `host[:port]` string.
|
||||
///
|
||||
/// IPv6 literals arrive bracketed (`[::1]:8443`); the brackets are stripped and
|
||||
@@ -162,24 +198,12 @@ pub async fn start_livekit_proxy<R: Runtime>(
|
||||
|
||||
info!("[livekit_proxy] start requested for {}", remote_host);
|
||||
|
||||
// Reuse existing proxy for same host.
|
||||
if let Some(port) = inner.port {
|
||||
if inner.remote_host == remote_host {
|
||||
debug!("[livekit_proxy] reusing existing proxy on port {} for {}", port, remote_host);
|
||||
return Ok(port);
|
||||
}
|
||||
// Different host — tear down old proxy.
|
||||
info!("[livekit_proxy] stopping old proxy for {} (switching to {})", inner.remote_host, remote_host);
|
||||
if let Some(tx) = inner.shutdown_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
inner.port = None;
|
||||
}
|
||||
|
||||
// Load the TOFU-pinned fingerprint from the cert store. The ws_proxy must
|
||||
// have connected first (establishing the TOFU trust), so the fingerprint
|
||||
// should already be stored. If not, reject — we refuse to connect without
|
||||
// a pinned cert.
|
||||
// Load the TOFU-pinned fingerprint from the cert store BEFORE the reuse
|
||||
// check — a running listener bakes its pin in at spawn, so a re-pin
|
||||
// (user accepted a rotated cert) must force a restart, not a reuse. The
|
||||
// ws_proxy must have connected first (establishing the TOFU trust), so
|
||||
// the fingerprint should already be stored. If not, reject — we refuse
|
||||
// to connect without a pinned cert.
|
||||
let store_key = tofu::cert_store_key(&remote_host);
|
||||
let fingerprint = tofu::load_stored_fingerprint(&app, &store_key)?
|
||||
.ok_or_else(|| format!(
|
||||
@@ -187,6 +211,23 @@ pub async fn start_livekit_proxy<R: Runtime>(
|
||||
Connect via WebSocket first to establish TOFU trust."
|
||||
))?;
|
||||
|
||||
// Reuse the existing proxy only when host AND pin are unchanged.
|
||||
if let Some(port) = inner.port {
|
||||
if can_reuse_proxy(&inner.remote_host, &inner.pinned_fingerprint, &remote_host, &fingerprint) {
|
||||
debug!("[livekit_proxy] reusing existing proxy on port {} for {}", port, remote_host);
|
||||
return Ok(port);
|
||||
}
|
||||
// Different host or re-pinned cert — tear down the old proxy.
|
||||
info!(
|
||||
"[livekit_proxy] stopping old proxy for {} (restarting for {})",
|
||||
inner.remote_host, remote_host
|
||||
);
|
||||
if let Some(tx) = inner.shutdown_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
inner.port = None;
|
||||
}
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|e| format!("livekit proxy bind failed: {e}"))?;
|
||||
@@ -198,7 +239,14 @@ pub async fn start_livekit_proxy<R: Runtime>(
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let host = remote_host.clone();
|
||||
let loop_handle = tokio::spawn(run_proxy_loop(listener, host, fingerprint, shutdown_rx));
|
||||
let loop_handle = tokio::spawn(run_proxy_loop(
|
||||
app.clone(),
|
||||
listener,
|
||||
host,
|
||||
port,
|
||||
fingerprint.clone(),
|
||||
shutdown_rx,
|
||||
));
|
||||
// Watch the loop so a panic is logged instead of vanishing silently.
|
||||
tokio::spawn(async move {
|
||||
match loop_handle.await {
|
||||
@@ -212,6 +260,7 @@ pub async fn start_livekit_proxy<R: Runtime>(
|
||||
|
||||
inner.port = Some(port);
|
||||
inner.remote_host = remote_host;
|
||||
inner.pinned_fingerprint = fingerprint;
|
||||
inner.shutdown_tx = Some(shutdown_tx);
|
||||
|
||||
Ok(port)
|
||||
@@ -228,6 +277,7 @@ pub async fn stop_livekit_proxy(
|
||||
}
|
||||
inner.port = None;
|
||||
inner.remote_host.clear();
|
||||
inner.pinned_fingerprint.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -238,9 +288,11 @@ pub async fn stop_livekit_proxy(
|
||||
/// Maximum consecutive accept errors before the proxy loop exits.
|
||||
const MAX_CONSECUTIVE_ACCEPT_ERRORS: u32 = 5;
|
||||
|
||||
async fn run_proxy_loop(
|
||||
async fn run_proxy_loop<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
listener: TcpListener,
|
||||
remote_host: String,
|
||||
port: u16,
|
||||
pinned_fingerprint: String,
|
||||
mut shutdown_rx: tokio::sync::oneshot::Receiver<()>,
|
||||
) {
|
||||
@@ -272,6 +324,20 @@ async fn run_proxy_loop(
|
||||
"[livekit_proxy] {} consecutive accept errors, stopping proxy loop",
|
||||
MAX_CONSECUTIVE_ACCEPT_ERRORS
|
||||
);
|
||||
// Deregister the dead proxy BEFORE the break drops
|
||||
// `listener`, so a future start_livekit_proxy
|
||||
// rebinds a fresh port instead of handing back
|
||||
// this closed one forever (the reuse branch keys
|
||||
// only on host+pin, not liveness). Mirrors
|
||||
// http_proxy.rs's identical fix.
|
||||
if let Some(state) = app.try_state::<LiveKitProxyState>() {
|
||||
state.clear_if_port_matches(port).await;
|
||||
} else {
|
||||
warn!(
|
||||
"[livekit_proxy] state unmanaged; cannot deregister dead proxy for {}",
|
||||
remote_host
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -282,6 +348,35 @@ async fn run_proxy_loop(
|
||||
}
|
||||
}
|
||||
|
||||
/// Bound on the outbound dial and TLS handshake, matching http_proxy.rs.
|
||||
const PROXY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Dial `remote_host` and complete the TLS handshake, bounding each step by
|
||||
/// `limit`.
|
||||
///
|
||||
/// Both steps must be bounded. A peer that accepts the TCP connection and then
|
||||
/// never answers the ClientHello blocks the handshake forever, and the calling
|
||||
/// task holds `local` without polling it — so the LiveKit SDK closing its side
|
||||
/// never cancels it. Those tasks and their sockets accumulate on every SDK
|
||||
/// retry and survive stop_livekit_proxy, whose shutdown oneshot only stops the
|
||||
/// accept loop; the per-connection tasks are detached.
|
||||
async fn connect_tls(
|
||||
connector: &tokio_rustls::TlsConnector,
|
||||
server_name: ServerName<'static>,
|
||||
remote_host: &str,
|
||||
limit: Duration,
|
||||
) -> Result<tokio_rustls::client::TlsStream<TcpStream>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
debug!("[livekit_proxy] connecting TCP to {}", remote_host);
|
||||
let tcp = timeout(limit, TcpStream::connect(remote_host))
|
||||
.await
|
||||
.map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from("TCP connect timed out"))??;
|
||||
debug!("[livekit_proxy] starting TLS handshake with {}", remote_host);
|
||||
let tls = timeout(limit, connector.connect(server_name, tcp))
|
||||
.await
|
||||
.map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from("TLS handshake timed out"))??;
|
||||
Ok(tls)
|
||||
}
|
||||
|
||||
/// Handle a single proxied connection:
|
||||
/// 1. Read the HTTP request headers from the local (plain) side
|
||||
/// 2. Rewrite Host/Origin so the remote server accepts the connection
|
||||
@@ -344,10 +439,7 @@ async fn handle_connection(
|
||||
|
||||
let server_name = parse_server_name(remote_host)?;
|
||||
|
||||
debug!("[livekit_proxy] connecting TCP to {}", remote_host);
|
||||
let tcp = TcpStream::connect(remote_host).await?;
|
||||
debug!("[livekit_proxy] starting TLS handshake with {}", remote_host);
|
||||
let mut tls = connector.connect(server_name, tcp).await?;
|
||||
let mut tls = connect_tls(&connector, server_name, remote_host, PROXY_CONNECT_TIMEOUT).await?;
|
||||
debug!("[livekit_proxy] TLS handshake complete, forwarding traffic");
|
||||
|
||||
// ── 4. Forward request + bidirectional copy ──────────────────────────
|
||||
@@ -431,6 +523,27 @@ mod tests {
|
||||
assert!(validate_remote_host("").is_ok());
|
||||
}
|
||||
|
||||
// ── can_reuse_proxy ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn reuses_proxy_only_when_host_and_pin_are_unchanged() {
|
||||
assert!(can_reuse_proxy("example.com:443", "aa:bb", "example.com:443", "aa:bb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restarts_proxy_when_host_changes() {
|
||||
assert!(!can_reuse_proxy("old.example:443", "aa:bb", "new.example:443", "aa:bb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restarts_proxy_when_pin_changes() {
|
||||
// The user accepted a rotated cert (accept_cert_fingerprint rewrote the
|
||||
// store). The running listener still pins the old fingerprint, so every
|
||||
// connection through it would fail the TLS handshake — reuse must be
|
||||
// refused so the caller tears down and restarts with the new pin.
|
||||
assert!(!can_reuse_proxy("example.com:443", "aa:bb", "example.com:443", "cc:dd"));
|
||||
}
|
||||
|
||||
// ── rewrite_proxy_headers ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -558,4 +671,86 @@ mod tests {
|
||||
fn rejects_an_invalid_dns_name() {
|
||||
assert!(parse_server_name("not a hostname").is_err());
|
||||
}
|
||||
|
||||
// A peer that accepts the TCP connection and then answers nothing must not
|
||||
// hang the connection task forever — see connect_tls.
|
||||
#[tokio::test]
|
||||
async fn tls_handshake_is_bounded_by_its_timeout() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
||||
let addr = listener.local_addr().expect("local_addr");
|
||||
tokio::spawn(async move {
|
||||
let _accepted = listener.accept().await.expect("accept");
|
||||
// Hold the connection open, answering nothing.
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
|
||||
let tls_config = rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(tofu::PinnedVerifier::new(
|
||||
"aa:bb:cc".to_string(),
|
||||
)))
|
||||
.with_no_client_auth();
|
||||
let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
|
||||
let server_name = ServerName::try_from("localhost").expect("server name");
|
||||
|
||||
// The outer bound exists only so a regression fails fast instead of
|
||||
// hanging the suite; the assertion is that the inner limit fired.
|
||||
let outcome = timeout(
|
||||
Duration::from_secs(5),
|
||||
connect_tls(
|
||||
&connector,
|
||||
server_name,
|
||||
&addr.to_string(),
|
||||
Duration::from_millis(100),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
outcome.is_ok(),
|
||||
"connect_tls hung: the TLS handshake is not bounded by its own timeout"
|
||||
);
|
||||
assert!(
|
||||
outcome.expect("bounded").is_err(),
|
||||
"a silent peer must produce an error, not a usable TLS stream"
|
||||
);
|
||||
}
|
||||
|
||||
// ── LiveKitProxyState::clear_if_port_matches ────────────────────────────
|
||||
//
|
||||
// B4_conn_ipc-7: run_proxy_loop's accept-error exit path drops the
|
||||
// listener without deregistering it, so ProxyInner.port stays set and
|
||||
// start_livekit_proxy's reuse branch (unchanged host+pin) hands the dead
|
||||
// port back forever. Mirrors http_proxy.rs's
|
||||
// remove_if_port_matches_removes_only_matching_entry test.
|
||||
|
||||
#[tokio::test]
|
||||
async fn clear_if_port_matches_clears_only_a_matching_entry() {
|
||||
let state = LiveKitProxyState::new();
|
||||
{
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let mut inner = state.inner.lock().await;
|
||||
inner.port = Some(4242);
|
||||
inner.remote_host = "example.com:8443".to_string();
|
||||
inner.pinned_fingerprint = "aa:bb".to_string();
|
||||
inner.shutdown_tx = Some(tx);
|
||||
}
|
||||
|
||||
// A stale loop reporting a port that no longer matches the live
|
||||
// listener must leave the current entry alone.
|
||||
state.clear_if_port_matches(9999).await;
|
||||
assert_eq!(
|
||||
state.inner.lock().await.port,
|
||||
Some(4242),
|
||||
"mismatched port must not clear a newer proxy's state"
|
||||
);
|
||||
|
||||
// A loop reporting its own still-current port must clear it so the
|
||||
// next start_livekit_proxy rebinds instead of reusing the dead listener.
|
||||
state.clear_if_port_matches(4242).await;
|
||||
let inner = state.inner.lock().await;
|
||||
assert_eq!(inner.port, None, "matching port must deregister the dead proxy");
|
||||
assert!(inner.remote_host.is_empty());
|
||||
assert!(inner.pinned_fingerprint.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,10 +298,55 @@ mod linux {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide whether the polling loop must emit a `ptt-state` event this tick.
|
||||
///
|
||||
/// Returns `Some(new_state)` on a press/release edge, `None` when nothing
|
||||
/// changed.
|
||||
///
|
||||
/// The `vk == 0` (unbound) case is folded into `pressed` here rather than
|
||||
/// guarding the whole tick: clearing the binding while the key is physically
|
||||
/// held must still produce the `true -> false` falling edge. With the guard
|
||||
/// outside, `was_pressed` freezes at `true`, no final `ptt-state=false` is
|
||||
/// ever emitted, and the microphone stays published.
|
||||
fn ptt_transition(vk: i32, key_down: bool, was_pressed: bool) -> Option<bool> {
|
||||
let pressed = vk != 0 && key_down;
|
||||
(pressed != was_pressed).then_some(pressed)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Whether this platform can actually observe global key state, i.e. whether
|
||||
/// the polling loop can ever emit a `ptt-state` event.
|
||||
///
|
||||
/// `ptt_start` spawns its thread unconditionally, so a live thread is NOT
|
||||
/// evidence that PTT works: on macOS `is_key_down` is a compile-time stub that
|
||||
/// always returns false, and on a pure-Wayland Linux session
|
||||
/// `DeviceState::checked_new()` returns None. The frontend gates its join-time
|
||||
/// PTT mute on this, because muting at join where no event can ever arrive
|
||||
/// would close the microphone for the whole session with no way to reopen it.
|
||||
#[tauri::command]
|
||||
pub fn ptt_polling_supported() -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use device_query::DeviceState;
|
||||
// Mirrors the availability check inside `is_key_down`: no reachable
|
||||
// X11/XWayland display means key state is never observable.
|
||||
DeviceState::checked_new().is_some()
|
||||
}
|
||||
|
||||
#[cfg(not(any(windows, target_os = "linux")))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the PTT polling loop. Emits `ptt-state` (bool) events.
|
||||
///
|
||||
/// Uses `PTT_THREAD`'s Mutex as the critical section to prevent duplicate
|
||||
@@ -329,12 +374,14 @@ pub fn ptt_start<R: Runtime>(app: AppHandle<R>) {
|
||||
|
||||
while !thread_shutdown.load(Ordering::SeqCst) {
|
||||
let vk = PTT_VKEY.load(Ordering::SeqCst);
|
||||
if vk != 0 {
|
||||
let pressed = is_key_down(vk);
|
||||
if pressed != was_pressed {
|
||||
was_pressed = pressed;
|
||||
let _ = app.emit("ptt-state", pressed);
|
||||
}
|
||||
// Evaluated on every tick, including vk == 0: clearing the PTT
|
||||
// key while it is physically held must still produce a falling
|
||||
// edge, otherwise was_pressed freezes at true and the mic never
|
||||
// gets its final `ptt-state=false`. `is_key_down` short-circuits
|
||||
// to false for vk == 0 on every platform, so this costs nothing.
|
||||
if let Some(pressed) = ptt_transition(vk, is_key_down(vk), was_pressed) {
|
||||
was_pressed = pressed;
|
||||
let _ = app.emit("ptt-state", pressed);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
@@ -523,6 +570,31 @@ mod tests {
|
||||
assert!(g.is_none(), "slot must stay empty when nothing was running");
|
||||
}
|
||||
|
||||
// The loop must emit only on edges, never on every tick — a repeat emit
|
||||
// would re-run the mute logic (and its user-mute guard) 50x/second.
|
||||
#[test]
|
||||
fn ptt_transition_reports_edges_only() {
|
||||
assert_eq!(ptt_transition(0x41, true, false), Some(true), "rising edge");
|
||||
assert_eq!(ptt_transition(0x41, true, true), None, "still held");
|
||||
assert_eq!(ptt_transition(0x41, false, true), Some(false), "falling edge");
|
||||
assert_eq!(ptt_transition(0x41, false, false), None, "still idle");
|
||||
}
|
||||
|
||||
// Regression for the "hot mic after Clear while the PTT key is held" bug:
|
||||
// clearing the binding (vk -> 0) with the key still physically down must
|
||||
// still yield the falling edge that emits the final ptt-state=false. The
|
||||
// old loop wrapped the whole comparison in `if vk != 0`, so this case
|
||||
// produced no transition at all and the mic stayed published.
|
||||
#[test]
|
||||
fn ptt_transition_emits_release_when_binding_cleared_while_key_held() {
|
||||
assert_eq!(ptt_transition(0, true, true), Some(false));
|
||||
// The release is reported once, then the unbound key stays quiet — an
|
||||
// unbound key must never read as pressed no matter what the raw
|
||||
// key-down probe says.
|
||||
assert_eq!(ptt_transition(0, true, false), None);
|
||||
assert_eq!(ptt_transition(0, false, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_capture_vk_accepts_safe_non_text_keys() {
|
||||
assert!(is_allowed_ptt_capture_vk(0x70)); // F1
|
||||
|
||||
@@ -92,6 +92,28 @@ const FALLBACK_BACKEND: Backend = Backend::EncryptedFile;
|
||||
/// secret — the caller's in-memory copy is all that is left, so the current
|
||||
/// session still works but nothing survives a restart.
|
||||
pub fn set(app: &AppHandle, account: &str, secret: &str) -> Result<Backend, String> {
|
||||
set_with(
|
||||
account,
|
||||
secret,
|
||||
keyring_set,
|
||||
keyring_get,
|
||||
keyring_delete,
|
||||
|acct, sec| set_fallback(app, acct, sec),
|
||||
|acct| clear_fallback(app, acct),
|
||||
)
|
||||
}
|
||||
|
||||
/// Core decision logic for [`set`], with the keyring and fallback operations
|
||||
/// injected so the branching is testable without a live OS credential store.
|
||||
fn set_with(
|
||||
account: &str,
|
||||
secret: &str,
|
||||
keyring_set: impl Fn(&str, &str) -> Result<(), String>,
|
||||
keyring_get: impl Fn(&str) -> Result<Option<String>, String>,
|
||||
keyring_delete: impl Fn(&str) -> Result<(), String>,
|
||||
fallback_set: impl FnOnce(&str, &str) -> Result<(), String>,
|
||||
fallback_clear: impl FnOnce(&str),
|
||||
) -> Result<Backend, String> {
|
||||
match keyring_set(account, secret) {
|
||||
Ok(()) => match keyring_get(account) {
|
||||
// The normal path: written and read back byte-for-byte.
|
||||
@@ -99,7 +121,7 @@ pub fn set(app: &AppHandle, account: &str, secret: &str) -> Result<Backend, Stri
|
||||
// A machine that was previously degraded and has since been
|
||||
// fixed must not keep a stale ciphertext shadowing the real
|
||||
// store on the next read.
|
||||
clear_fallback(app, account);
|
||||
fallback_clear(account);
|
||||
return Ok(Backend::Keyring);
|
||||
}
|
||||
Ok(Some(_)) => {
|
||||
@@ -127,10 +149,23 @@ pub fn set(app: &AppHandle, account: &str, secret: &str) -> Result<Backend, Stri
|
||||
but the read-back failed: {e} — falling back"
|
||||
),
|
||||
},
|
||||
Err(e) => log::error!("{SERVICE}: credential store write failed for '{account}': {e}"),
|
||||
Err(e) => {
|
||||
log::error!("{SERVICE}: credential store write failed for '{account}': {e}");
|
||||
// An older secret may already sit in the keyring from a prior
|
||||
// successful write. get() reads the keyring first, so leaving
|
||||
// that stale entry in place would shadow the fresh secret parked
|
||||
// in the fallback below — mirrors the read-back-mismatch arm
|
||||
// above, which purges for the same reason.
|
||||
if let Err(de) = keyring_delete(account) {
|
||||
log::warn!(
|
||||
"{SERVICE}: could not remove a stale keyring entry for '{account}' after a \
|
||||
failed write: {de}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set_fallback(app, account, secret)?;
|
||||
fallback_set(account, secret)?;
|
||||
log::warn!(
|
||||
"{SERVICE}: account '{account}' is stored in the encrypted fallback file, not the OS \
|
||||
credential store. See docs/credential-storage.md"
|
||||
@@ -143,12 +178,34 @@ pub fn set(app: &AppHandle, account: &str, secret: &str) -> Result<Backend, Stri
|
||||
/// The OS credential store wins over the fallback file, so a machine that
|
||||
/// recovers goes back to the real store without any migration step.
|
||||
pub fn get(app: &AppHandle, account: &str) -> Result<Option<String>, String> {
|
||||
get_with(account, keyring_get, |acct| get_fallback(app, acct))
|
||||
}
|
||||
|
||||
/// Core decision logic for [`get`], with the keyring and fallback lookups
|
||||
/// injected so the branching is testable without a live OS credential store.
|
||||
fn get_with(
|
||||
account: &str,
|
||||
keyring_get: impl Fn(&str) -> Result<Option<String>, String>,
|
||||
get_fallback: impl Fn(&str) -> Option<String>,
|
||||
) -> Result<Option<String>, String> {
|
||||
match keyring_get(account) {
|
||||
Ok(Some(secret)) => return Ok(Some(secret)),
|
||||
Ok(None) => {}
|
||||
Err(e) => log::warn!("{SERVICE}: credential store read failed for '{account}': {e}"),
|
||||
Ok(Some(secret)) => Ok(Some(secret)),
|
||||
Ok(None) => Ok(get_fallback(account)),
|
||||
Err(e) => {
|
||||
log::warn!("{SERVICE}: credential store read failed for '{account}': {e}");
|
||||
// A read error must not collapse to "nothing stored": on a
|
||||
// healthy machine set() clears the fallback on every successful
|
||||
// write, so an empty fallback here is indistinguishable from
|
||||
// "never stored". Prefer a fallback copy if one exists; only
|
||||
// report "nothing" when both stores genuinely have nothing, and
|
||||
// otherwise propagate the error so the caller can tell a broken
|
||||
// store apart from first login.
|
||||
match get_fallback(account) {
|
||||
Some(secret) => Ok(Some(secret)),
|
||||
None => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(get_fallback(app, account))
|
||||
}
|
||||
|
||||
/// Remove `account` from every store. Absent entries are not an error.
|
||||
@@ -413,6 +470,90 @@ mod tests {
|
||||
assert_eq!(fallback_aad("host.example"), fallback_aad("host.example"));
|
||||
}
|
||||
|
||||
// -- get_with: finding "a keyring read error must not read as 'not stored'" --
|
||||
|
||||
#[test]
|
||||
fn get_with_falls_back_when_the_keyring_errors_but_the_fallback_has_a_copy() {
|
||||
let result = get_with(
|
||||
"identity:chat.example",
|
||||
|_| Err("keychain locked".to_string()),
|
||||
|_| Some("fallback-secret".to_string()),
|
||||
);
|
||||
assert_eq!(result, Ok(Some("fallback-secret".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_with_propagates_the_keyring_error_when_the_fallback_is_also_empty() {
|
||||
// The bug: a keyring read failure must never be reported as "nothing
|
||||
// stored" (Ok(None)) when the fallback is empty too — that is
|
||||
// indistinguishable from first login, and the E2EE identity keypair
|
||||
// loader mints and publishes a brand-new identity key on exactly that
|
||||
// signal, invalidating every peer's TOFU pin.
|
||||
let result = get_with("identity:chat.example", |_| Err("keychain locked".to_string()), |_| None);
|
||||
assert_eq!(result, Err("keychain locked".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_with_prefers_the_live_keyring_value_over_the_fallback() {
|
||||
let result = get_with("acct", |_| Ok(Some("live".to_string())), |_| Some("stale".to_string()));
|
||||
assert_eq!(result, Ok(Some("live".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_with_uses_the_fallback_when_the_keyring_has_nothing_stored() {
|
||||
let result = get_with("acct", |_| Ok(None), |_| Some("fallback".to_string()));
|
||||
assert_eq!(result, Ok(Some("fallback".to_string())));
|
||||
}
|
||||
|
||||
// -- set_with: finding "a failed keyring write must not leave a stale entry" --
|
||||
|
||||
#[test]
|
||||
fn set_with_deletes_any_stale_keyring_entry_when_the_write_fails() {
|
||||
// The bug: a write failure with an older secret already sitting in
|
||||
// the keyring from a prior successful write must not leave that
|
||||
// stale entry in place — get() reads the keyring first, so it would
|
||||
// shadow the fresh secret parked in the fallback below forever.
|
||||
use std::cell::Cell;
|
||||
let delete_called = Cell::new(false);
|
||||
let result = set_with(
|
||||
"acct",
|
||||
"new-secret",
|
||||
|_, _| Err("write failed".to_string()),
|
||||
|_| panic!("keyring_get must not run after a failed write"),
|
||||
|_| {
|
||||
delete_called.set(true);
|
||||
Ok(())
|
||||
},
|
||||
|_, _| Ok(()),
|
||||
|_| {},
|
||||
);
|
||||
assert_eq!(result, Ok(FALLBACK_BACKEND));
|
||||
assert!(
|
||||
delete_called.get(),
|
||||
"a failed keyring write must delete any stale prior entry before falling back"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_with_returns_keyring_backend_when_the_write_round_trips() {
|
||||
use std::cell::Cell;
|
||||
let cleared = Cell::new(false);
|
||||
let result = set_with(
|
||||
"acct",
|
||||
"secret",
|
||||
|_, s| {
|
||||
assert_eq!(s, "secret");
|
||||
Ok(())
|
||||
},
|
||||
|_| Ok(Some("secret".to_string())),
|
||||
|_| panic!("must not delete a keyring entry that round-tripped"),
|
||||
|_, _| panic!("must not touch the fallback on a successful round trip"),
|
||||
|_| cleared.set(true),
|
||||
);
|
||||
assert_eq!(result, Ok(Backend::Keyring));
|
||||
assert!(cleared.get(), "a recovered machine must clear any stale fallback copy");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn dpapi_round_trips_and_rejects_foreign_entropy() {
|
||||
|
||||
@@ -283,8 +283,13 @@ impl rustls::client::danger::ServerCertVerifier for HostScopedVerifier {
|
||||
/// Cert-store key for a host. Strips a default `:443` so the ws proxy (which
|
||||
/// keys off `wss://host` with no explicit 443) and the http/livekit proxies
|
||||
/// (which see `host:443`) resolve the SAME pin. Non-default ports are kept.
|
||||
/// Case-folded (DNS names are case-insensitive): the host reaches this from
|
||||
/// several places (a profile-entered host verbatim, a `wss://` URL, a URL
|
||||
/// parsed on the TS side, which lowercases) — without folding case here, two
|
||||
/// callers with the same server in different case would pin/read different
|
||||
/// entries, opening a second, unpinned proxy tunnel.
|
||||
pub(crate) fn cert_store_key(host: &str) -> String {
|
||||
host.strip_suffix(":443").unwrap_or(host).to_string()
|
||||
host.strip_suffix(":443").unwrap_or(host).to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// Extract the host (with any non-default port) from a `wss://` URL.
|
||||
@@ -390,6 +395,20 @@ mod tests {
|
||||
assert_eq!(cert_store_key("example.com:8443"), "example.com:8443");
|
||||
}
|
||||
|
||||
// DNS names are case-insensitive, but a raw host string (a profile-entered
|
||||
// host, or one taken verbatim from a wss:// URL) is not normalized before
|
||||
// reaching here. Two call sites can derive the SAME host in different
|
||||
// case (e.g. login uses the host as typed, an attachment fetch resolves
|
||||
// it through URL parsing, which lowercases) — without folding case here,
|
||||
// they pin/read two different cert-store entries for the same server,
|
||||
// opening a second, unpinned proxy tunnel.
|
||||
#[test]
|
||||
fn cert_store_key_folds_case() {
|
||||
assert_eq!(cert_store_key("Example.COM"), "example.com");
|
||||
assert_eq!(cert_store_key("MyServer.LAN:8443"), "myserver.lan:8443");
|
||||
assert_eq!(cert_store_key("Example.COM:443"), "example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_host_variants() {
|
||||
assert_eq!(extract_host("wss://example.com/chat"), "example.com");
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use log::{debug, error, info, warn};
|
||||
use serde_json::Value;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Emitter, Runtime};
|
||||
@@ -32,14 +33,68 @@ use crate::tofu::{self, TofuOutcome};
|
||||
/// into its closure and clear the sender even after a worker task panic.
|
||||
pub struct WsState {
|
||||
tx: Arc<Mutex<Option<mpsc::Sender<String>>>>,
|
||||
/// Bumped once per `ws_connect` attempt. The handshake can pend for up to
|
||||
/// CONNECT_TIMEOUT, and callers (profile switch) start a second connect
|
||||
/// without awaiting the first, so an attempt must prove it is still the
|
||||
/// current generation before it may touch the shared sender slot.
|
||||
generation: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl WsState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tx: Arc::new(Mutex::new(None)),
|
||||
generation: Arc::new(AtomicU64::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Claim a generation for a new connection attempt, dropping any existing
|
||||
/// sender. Every later step of that attempt is conditional on this value
|
||||
/// still being current.
|
||||
async fn begin_connection(&self) -> u64 {
|
||||
let mut tx_lock = self.tx.lock().await;
|
||||
if tx_lock.is_some() {
|
||||
debug!("[ws_proxy] dropping existing connection");
|
||||
}
|
||||
*tx_lock = None;
|
||||
self.generation.fetch_add(1, Ordering::SeqCst) + 1
|
||||
}
|
||||
|
||||
/// Install `tx` as the live sender if `generation` is still current.
|
||||
/// Returns false when a newer `ws_connect` superseded this attempt.
|
||||
async fn install_sender(&self, generation: u64, tx: mpsc::Sender<String>) -> bool {
|
||||
// Checked under the slot lock so the decision and the write cannot be
|
||||
// split by a concurrent attempt.
|
||||
let mut tx_lock = self.tx.lock().await;
|
||||
if self.generation.load(Ordering::SeqCst) != generation {
|
||||
return false;
|
||||
}
|
||||
*tx_lock = Some(tx);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the live sender slot, but only if `my_generation` is still the
|
||||
/// current connection generation. Returns false when a newer `ws_connect`
|
||||
/// superseded this connection — that teardown must not clear the slot or
|
||||
/// announce a close. Ownership is proven by generation, NOT by holding a
|
||||
/// Sender clone: a clone kept alive in the monitor task would keep the
|
||||
/// outbound channel open, so the write task could never observe closure
|
||||
/// after `ws_disconnect` (circular wait — task, socket, and TLS session
|
||||
/// would all leak). `generation` only advances inside `begin_connection`
|
||||
/// while the slot lock is held, so checking it under the same lock makes
|
||||
/// the check-and-clear atomic with respect to new attempts.
|
||||
async fn clear_sender_if_current(
|
||||
slot: &Mutex<Option<mpsc::Sender<String>>>,
|
||||
generation: &AtomicU64,
|
||||
my_generation: u64,
|
||||
) -> bool {
|
||||
let mut tx_lock = slot.lock().await;
|
||||
if generation.load(Ordering::SeqCst) != my_generation {
|
||||
return false;
|
||||
}
|
||||
*tx_lock = None;
|
||||
true
|
||||
}
|
||||
|
||||
/// Single call site for ws-state events — keeps tauri-typegen from generating duplicates.
|
||||
@@ -69,14 +124,8 @@ pub async fn ws_connect<R: Runtime>(
|
||||
) -> Result<(), String> {
|
||||
info!("[ws_proxy] connecting to {}", url);
|
||||
|
||||
// Drop any existing connection
|
||||
{
|
||||
let mut tx_lock = state.tx.lock().await;
|
||||
if tx_lock.is_some() {
|
||||
debug!("[ws_proxy] dropping existing connection");
|
||||
}
|
||||
*tx_lock = None;
|
||||
}
|
||||
// Drop any existing connection and claim this attempt's generation.
|
||||
let my_generation = state.begin_connection().await;
|
||||
|
||||
// Only allow secure WebSocket connections
|
||||
if !url.starts_with("wss://") {
|
||||
@@ -169,23 +218,26 @@ pub async fn ws_connect<R: Runtime>(
|
||||
}
|
||||
// ── End TOFU check ───────────────────────────────────────────────────
|
||||
|
||||
let (mut sink, mut stream) = ws_stream.split();
|
||||
|
||||
// Channel for JS → server messages (bounded for backpressure). The slot
|
||||
// gets the ONLY Sender: teardown ownership is proven by generation, so no
|
||||
// clone may outlive the slot — one would keep rx.recv() pending forever.
|
||||
let (tx, mut rx) = mpsc::channel::<String>(256);
|
||||
if !state.install_sender(my_generation, tx).await {
|
||||
info!("[ws_proxy] handshake superseded by a newer connect; dropping stale socket");
|
||||
return Err("superseded by a newer connection".into());
|
||||
}
|
||||
|
||||
info!("[ws_proxy] connected to {}", host);
|
||||
emit_ws_state(&app, "open");
|
||||
|
||||
let (mut sink, mut stream) = ws_stream.split();
|
||||
|
||||
// Channel for JS → server messages (bounded for backpressure)
|
||||
let (tx, mut rx) = mpsc::channel::<String>(256);
|
||||
{
|
||||
let mut tx_lock = state.tx.lock().await;
|
||||
*tx_lock = Some(tx);
|
||||
}
|
||||
|
||||
let app_read = app.clone();
|
||||
let app_state = app.clone();
|
||||
// Clone the Arc so the monitoring closure can clear tx on any exit path,
|
||||
// Clone the Arcs so the monitoring closure can clear tx on any exit path,
|
||||
// including worker task panics, without needing tauri::State.
|
||||
let tx_arc = Arc::clone(&state.tx);
|
||||
let generation_arc = Arc::clone(&state.generation);
|
||||
|
||||
// Single outer task owns a JoinSet containing read and write workers.
|
||||
// join_next() blocks until the first worker finishes (normally or via panic),
|
||||
@@ -242,14 +294,16 @@ pub async fn ws_connect<R: Runtime>(
|
||||
}
|
||||
|
||||
// Clear the sender so ws_send returns "not connected". This runs on
|
||||
// every exit path — normal close, graceful disconnect, and panic.
|
||||
{
|
||||
let mut tx_lock = tx_arc.lock().await;
|
||||
*tx_lock = None;
|
||||
// every exit path — normal close, graceful disconnect, and panic — but
|
||||
// only when this connection still owns the slot. Clearing
|
||||
// unconditionally would kill a newer connection's sender and tell JS
|
||||
// that the live connection had closed.
|
||||
if clear_sender_if_current(&tx_arc, &generation_arc, my_generation).await {
|
||||
// Always emit closed, even after a panic.
|
||||
emit_ws_state(&app_state, "closed");
|
||||
} else {
|
||||
debug!("[ws_proxy] superseded connection torn down; leaving live sender in place");
|
||||
}
|
||||
|
||||
// Always emit closed, even after a panic.
|
||||
emit_ws_state(&app_state, "closed");
|
||||
});
|
||||
|
||||
Ok(())
|
||||
@@ -281,8 +335,13 @@ pub async fn ws_send(
|
||||
/// Disconnect the proxy WebSocket.
|
||||
#[tauri::command]
|
||||
pub async fn ws_disconnect(state: tauri::State<'_, WsState>) -> Result<(), String> {
|
||||
let mut tx_lock = state.tx.lock().await;
|
||||
*tx_lock = None; // dropping the sender closes the channel → write task ends
|
||||
// begin_connection() both clears the sender slot (dropping it closes the
|
||||
// channel so the write task ends) AND bumps the generation counter, so a
|
||||
// handshake still pending from before this disconnect fails install_sender
|
||||
// instead of installing itself afterward — reusing the same invalidation
|
||||
// path a superseding connect() already has. The returned generation is
|
||||
// unused: nothing will ever install under it.
|
||||
state.begin_connection().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -427,4 +486,138 @@ mod tests {
|
||||
let bad = format!("é{}", &VALID[..93]);
|
||||
assert!(!is_valid_cert_fingerprint(&bad));
|
||||
}
|
||||
|
||||
// ── Connection-generation ownership of the shared sender slot ───────────
|
||||
//
|
||||
// A handshake pends up to CONNECT_TIMEOUT, and a profile switch starts a
|
||||
// second ws_connect without awaiting or cancelling the first, so two
|
||||
// attempts can be in flight over one slot. Mirrors the ptt.rs
|
||||
// ATOMICRACE-001 guard.
|
||||
|
||||
#[tokio::test]
|
||||
async fn superseded_connect_does_not_take_the_sender_slot() {
|
||||
let state = WsState::new();
|
||||
|
||||
// Connection A starts its handshake, then a profile switch starts B
|
||||
// while A is still pending.
|
||||
let gen_a = state.begin_connection().await;
|
||||
let gen_b = state.begin_connection().await;
|
||||
assert_ne!(gen_a, gen_b);
|
||||
|
||||
let (tx_b, _rx_b) = mpsc::channel::<String>(4);
|
||||
assert!(
|
||||
state.install_sender(gen_b, tx_b.clone()).await,
|
||||
"the current generation must be able to install"
|
||||
);
|
||||
|
||||
// A's handshake finally completes. Installing now would route the next
|
||||
// auth send to the stale host and drop B's sender, ending B's write task.
|
||||
let (tx_a, _rx_a) = mpsc::channel::<String>(4);
|
||||
assert!(
|
||||
!state.install_sender(gen_a, tx_a).await,
|
||||
"a superseded attempt must not take the slot"
|
||||
);
|
||||
|
||||
let slot = state.tx.lock().await;
|
||||
assert!(
|
||||
slot.as_ref().is_some_and(|t| t.same_channel(&tx_b)),
|
||||
"the live connection's sender must still be installed"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Generation-owned teardown ───────────────────────────────────────────
|
||||
//
|
||||
// Teardown ownership must be provable WITHOUT holding a Sender clone: any
|
||||
// clone kept alive by the monitor task keeps the outbound channel open, so
|
||||
// after ws_disconnect drops the slot's sender the write task never sees
|
||||
// rx.recv() == None — writer, reader, and TLS socket all leak in a
|
||||
// circular wait (monitor waits on writer, writer waits on the monitor's
|
||||
// clone dropping).
|
||||
|
||||
#[tokio::test]
|
||||
async fn owning_teardown_clears_the_slot_by_generation() {
|
||||
let state = WsState::new();
|
||||
let my_generation = state.begin_connection().await;
|
||||
let (tx, _rx) = mpsc::channel::<String>(4);
|
||||
state.install_sender(my_generation, tx).await;
|
||||
|
||||
assert!(
|
||||
clear_sender_if_current(&state.tx, &state.generation, my_generation).await,
|
||||
"the owning connection must clear its slot without a Sender clone"
|
||||
);
|
||||
assert!(
|
||||
state.tx.lock().await.is_none(),
|
||||
"ws_send must report not-connected after a real close"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn superseded_teardown_by_generation_leaves_the_live_sender() {
|
||||
let state = WsState::new();
|
||||
let gen_a = state.begin_connection().await;
|
||||
let (tx_a, _rx_a) = mpsc::channel::<String>(4);
|
||||
state.install_sender(gen_a, tx_a).await;
|
||||
|
||||
let gen_b = state.begin_connection().await;
|
||||
let (tx_b, _rx_b) = mpsc::channel::<String>(4);
|
||||
assert!(state.install_sender(gen_b, tx_b.clone()).await);
|
||||
|
||||
// A's monitor task tears down after B is live. Clearing here would
|
||||
// kill B's sender and emit "closed" while JS believes B is connected.
|
||||
assert!(
|
||||
!clear_sender_if_current(&state.tx, &state.generation, gen_a).await,
|
||||
"a superseded teardown must not clear the slot or announce a close"
|
||||
);
|
||||
let slot = state.tx.lock().await;
|
||||
assert!(
|
||||
slot.as_ref().is_some_and(|t| t.same_channel(&tx_b)),
|
||||
"the live connection's sender must survive a superseded teardown"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disconnect_closes_the_outbound_channel() {
|
||||
// ws_disconnect's contract (the comment at its *tx_lock = None):
|
||||
// dropping the slot's sender closes the channel so the write task
|
||||
// ends. That holds only while install_sender receives the ONLY
|
||||
// Sender — no teardown-ownership clone may exist.
|
||||
let state = WsState::new();
|
||||
let generation = state.begin_connection().await;
|
||||
let (tx, mut rx) = mpsc::channel::<String>(4);
|
||||
state.install_sender(generation, tx).await;
|
||||
|
||||
*state.tx.lock().await = None; // ws_disconnect
|
||||
|
||||
let got = tokio::time::timeout(Duration::from_secs(1), rx.recv())
|
||||
.await
|
||||
.expect("write task would hang forever: channel still open after disconnect");
|
||||
assert_eq!(got, None, "rx.recv() must yield None so the write task exits");
|
||||
}
|
||||
|
||||
// B4_conn_ipc-9: ws_disconnect must invalidate an in-flight ws_connect
|
||||
// attempt, not just null the sender slot. A handshake can pend for up to
|
||||
// CONNECT_TIMEOUT (10s) past a disconnect (JS calls connect fire-and-
|
||||
// forget — logout during "connecting" is a real interleaving), and
|
||||
// install_sender checks generation alone, so a manual `*tx_lock = None`
|
||||
// leaves a "cancelled" connection free to install itself afterward and
|
||||
// spawn its worker tasks against a socket JS believes closed.
|
||||
#[tokio::test]
|
||||
async fn disconnect_invalidates_an_in_flight_connect_attempt() {
|
||||
let state = WsState::new();
|
||||
// A's handshake is in flight: generation claimed, sender not yet
|
||||
// installed (mirrors the pending window before install_sender runs).
|
||||
let gen_a = state.begin_connection().await;
|
||||
|
||||
// ws_disconnect fires while A is still mid-handshake — this is
|
||||
// ws_disconnect's real body (state.begin_connection().await).
|
||||
state.begin_connection().await;
|
||||
|
||||
// A's handshake finally completes and tries to install its sender.
|
||||
// It must be rejected: JS already believes the connection is closed.
|
||||
let (tx_a, _rx_a) = mpsc::channel::<String>(4);
|
||||
assert!(
|
||||
!state.install_sender(gen_a, tx_a).await,
|
||||
"a handshake pending during disconnect must not be able to install after it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"productName": "OwnCord",
|
||||
"version": "1.2.0-alpha.1",
|
||||
"version": "1.2.0-alpha.2",
|
||||
"identifier": "com.owncord.client",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -24,7 +24,7 @@
|
||||
],
|
||||
"withGlobalTauri": false,
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://ipc.localhost https: wss: http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:*; img-src 'self' https: data:; media-src 'self' blob:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-src https://www.youtube.com https://youtube.com; worker-src 'self' blob:"
|
||||
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://ipc.localhost https: wss: http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:*; img-src 'self' blob: https: data:; media-src 'self' blob:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-src https://www.youtube.com https://youtube.com; worker-src 'self' blob:"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
@@ -65,12 +65,6 @@
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"tauri-typegen": {
|
||||
"project_path": ".",
|
||||
"output_path": "../src/generated",
|
||||
"validation_library": "none",
|
||||
"verbose": false
|
||||
},
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEJCQkQ0NzM1MjkxRTlGQTIKUldTaW54NHBOVWU5dTRqYW0yalI5VTJBd0NXOUZwM2UrcDM4YkhCSmlMZWJKWWVXaGJWdHBaSHgK",
|
||||
"endpoints": [],
|
||||
|
||||
@@ -231,6 +231,10 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont
|
||||
);
|
||||
|
||||
const roleSub = createElement("div", { class: "context-menu__submenu" });
|
||||
// One guard across every option: `currentRole` only updates when the
|
||||
// member_update echoes, so without it a double-click (or a second option
|
||||
// clicked while the first PATCH is in flight) fires twice.
|
||||
let roleChangeRunning = false;
|
||||
for (const role of options.availableRoles) {
|
||||
const cls =
|
||||
role === options.currentRole
|
||||
@@ -240,9 +244,14 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont
|
||||
role,
|
||||
cls,
|
||||
() => {
|
||||
if (role !== options.currentRole) {
|
||||
void options.onChangeRole(role);
|
||||
}
|
||||
if (roleChangeRunning || role === options.currentRole) return;
|
||||
roleChangeRunning = true;
|
||||
roleOption.classList.add("context-menu__item--pending");
|
||||
const done = (): void => {
|
||||
roleChangeRunning = false;
|
||||
roleOption.classList.remove("context-menu__item--pending");
|
||||
};
|
||||
options.onChangeRole(role).then(done, done);
|
||||
},
|
||||
ac.signal,
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { createElement, setText, appendChildren } from "@lib/dom";
|
||||
import { createIcon } from "@lib/icons";
|
||||
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
|
||||
export interface CertMismatchModalOptions {
|
||||
@@ -21,17 +22,27 @@ export interface CertMismatchModalOptions {
|
||||
export function createCertMismatchModal(options: CertMismatchModalOptions): MountableComponent {
|
||||
const { host, storedFingerprint, newFingerprint, onAccept, onReject } = options;
|
||||
let overlay: HTMLDivElement | null = null;
|
||||
let restoreFocus: (() => void) | null = null;
|
||||
const ac = new AbortController();
|
||||
|
||||
function mount(container: Element): void {
|
||||
overlay = createElement("div", { class: "modal-overlay visible" });
|
||||
|
||||
const modal = createElement("div", { class: "modal" });
|
||||
// Ids are unique per factory, not per instance — these three trust prompts
|
||||
// never stack with each other in practice.
|
||||
applyDialogSemantics(modal, { labelledBy: "cert-mismatch-title" });
|
||||
trapFocus(modal, ac.signal);
|
||||
|
||||
// Header
|
||||
const header = createElement("div", { class: "modal-header" });
|
||||
const title = createElement("h3", {}, "Certificate Warning");
|
||||
const closeBtn = createElement("button", { class: "modal-close", type: "button" });
|
||||
const title = createElement("h3", { id: "cert-mismatch-title" }, "Certificate Warning");
|
||||
const closeBtn = createElement("button", {
|
||||
class: "modal-close",
|
||||
type: "button",
|
||||
// Icon-only control — the aria-label is its entire accessible name.
|
||||
"aria-label": "Close",
|
||||
});
|
||||
closeBtn.textContent = "";
|
||||
closeBtn.appendChild(createIcon("x", 14));
|
||||
closeBtn.addEventListener("click", onReject, { signal: ac.signal });
|
||||
@@ -94,7 +105,18 @@ export function createCertMismatchModal(options: CertMismatchModalOptions): Moun
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
// Escape maps to reject because that is the fail-closed safe default
|
||||
// (Disconnect) — dismissing a trust prompt must never grant trust.
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && overlay?.isConnected === true) onReject();
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
container.appendChild(overlay);
|
||||
restoreFocus = focusDialog(modal);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
@@ -103,6 +125,8 @@ export function createCertMismatchModal(options: CertMismatchModalOptions): Moun
|
||||
overlay.remove();
|
||||
overlay = null;
|
||||
}
|
||||
restoreFocus?.();
|
||||
restoreFocus = null;
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
@@ -124,15 +148,24 @@ export interface CertFirstUseModalOptions {
|
||||
export function createCertFirstUseModal(options: CertFirstUseModalOptions): MountableComponent {
|
||||
const { host, fingerprint, onAccept, onReject } = options;
|
||||
let overlay: HTMLDivElement | null = null;
|
||||
let restoreFocus: (() => void) | null = null;
|
||||
const ac = new AbortController();
|
||||
|
||||
function mount(container: Element): void {
|
||||
overlay = createElement("div", { class: "modal-overlay visible" });
|
||||
const modal = createElement("div", { class: "modal" });
|
||||
// Unique per factory, not per instance — the three trust prompts never
|
||||
// stack with each other in practice.
|
||||
applyDialogSemantics(modal, { labelledBy: "cert-first-use-title" });
|
||||
trapFocus(modal, ac.signal);
|
||||
|
||||
const header = createElement("div", { class: "modal-header" });
|
||||
const title = createElement("h3", {}, "New Server Certificate");
|
||||
const closeBtn = createElement("button", { class: "modal-close", type: "button" });
|
||||
const title = createElement("h3", { id: "cert-first-use-title" }, "New Server Certificate");
|
||||
const closeBtn = createElement("button", {
|
||||
class: "modal-close",
|
||||
type: "button",
|
||||
"aria-label": "Close",
|
||||
});
|
||||
closeBtn.textContent = "";
|
||||
closeBtn.appendChild(createIcon("x", 14));
|
||||
closeBtn.addEventListener("click", onReject, { signal: ac.signal });
|
||||
@@ -187,7 +220,18 @@ export function createCertFirstUseModal(options: CertFirstUseModalOptions): Moun
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
// Escape rejects (Cancel) — the fail-closed default: never trust a
|
||||
// certificate because the prompt was dismissed.
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && overlay?.isConnected === true) onReject();
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
container.appendChild(overlay);
|
||||
restoreFocus = focusDialog(modal);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
@@ -196,6 +240,8 @@ export function createCertFirstUseModal(options: CertFirstUseModalOptions): Moun
|
||||
overlay.remove();
|
||||
overlay = null;
|
||||
}
|
||||
restoreFocus?.();
|
||||
restoreFocus = null;
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
@@ -223,15 +269,24 @@ export function createIdentityMismatchModal(
|
||||
): MountableComponent {
|
||||
const { username, fingerprint, onAccept, onReject } = options;
|
||||
let overlay: HTMLDivElement | null = null;
|
||||
let restoreFocus: (() => void) | null = null;
|
||||
const ac = new AbortController();
|
||||
|
||||
function mount(container: Element): void {
|
||||
overlay = createElement("div", { class: "modal-overlay visible" });
|
||||
const modal = createElement("div", { class: "modal" });
|
||||
// Unique per factory, not per instance — the three trust prompts never
|
||||
// stack with each other in practice.
|
||||
applyDialogSemantics(modal, { labelledBy: "identity-mismatch-title" });
|
||||
trapFocus(modal, ac.signal);
|
||||
|
||||
const header = createElement("div", { class: "modal-header" });
|
||||
const title = createElement("h3", {}, "Identity Warning");
|
||||
const closeBtn = createElement("button", { class: "modal-close", type: "button" });
|
||||
const title = createElement("h3", { id: "identity-mismatch-title" }, "Identity Warning");
|
||||
const closeBtn = createElement("button", {
|
||||
class: "modal-close",
|
||||
type: "button",
|
||||
"aria-label": "Close",
|
||||
});
|
||||
closeBtn.textContent = "";
|
||||
closeBtn.appendChild(createIcon("x", 14));
|
||||
closeBtn.addEventListener("click", onReject, { signal: ac.signal });
|
||||
@@ -287,7 +342,18 @@ export function createIdentityMismatchModal(
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
// Escape rejects (Cancel) — the fail-closed default: dismissing the
|
||||
// prompt must never re-pin the new identity key.
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && overlay?.isConnected === true) onReject();
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
container.appendChild(overlay);
|
||||
restoreFocus = focusDialog(modal);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
@@ -296,6 +362,8 @@ export function createIdentityMismatchModal(
|
||||
overlay.remove();
|
||||
overlay = null;
|
||||
}
|
||||
restoreFocus?.();
|
||||
restoreFocus = null;
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
|
||||
@@ -22,7 +22,7 @@ import { attachStreamPreview, attachScrollCollapse } from "@lib/streamPreview";
|
||||
import { showUserVolumeMenu } from "./channel-sidebar/volume-menu";
|
||||
import type { VoiceModMenuOptions } from "./channel-sidebar/volume-menu";
|
||||
import { attachChannelContextMenu, CHANNEL_MUTE_CHANGED } from "./channel-sidebar/context-menu";
|
||||
import { attachDragHandlers, releaseGlobalDragListeners } from "./channel-sidebar/drag-reorder";
|
||||
import { attachDragHandlers } from "./channel-sidebar/drag-reorder";
|
||||
import { rePinPeerIdentity } from "@lib/livekitSession";
|
||||
import { createIdentityMismatchModal } from "./CertMismatchModal";
|
||||
import { createLogger } from "@lib/logger";
|
||||
@@ -34,10 +34,11 @@ import { importIdentityPublicKey, computeKeyFingerprint } from "@lib/e2eeCrypto"
|
||||
const log = createLogger("ChannelSidebar");
|
||||
|
||||
/** Icon, color, and tooltip for a peer's E2EE identity verification badge
|
||||
* (F3 TOFU). The three states mirror the voice store's PeerVerification:
|
||||
* (F3 TOFU). The states mirror the voice store's PeerVerification:
|
||||
* a green shield-check when the announce signature verified against the pinned
|
||||
* key, a muted shield when the peer published no key (legacy), and a red
|
||||
* shield-alert when the delivered key differs from the pinned one. */
|
||||
* key, a muted shield when the peer published no key (legacy), a red
|
||||
* shield-alert when the delivered key differs from the pinned one, and an
|
||||
* amber shield-question when the local pin store could not be read (DC-08). */
|
||||
function verifyPresentation(v: PeerVerification): {
|
||||
icon: IconName;
|
||||
color: string;
|
||||
@@ -60,6 +61,15 @@ function verifyPresentation(v: PeerVerification): {
|
||||
title: "Identity key changed — click to review and re-pin",
|
||||
};
|
||||
}
|
||||
if (v.status === "unknown") {
|
||||
return {
|
||||
icon: "shield-question",
|
||||
color: "var(--yellow, #f0b232)",
|
||||
title:
|
||||
"Could not check this participant's identity — key storage is unavailable, " +
|
||||
"so they are blocked for E2EE until it recovers",
|
||||
};
|
||||
}
|
||||
// "unverified" — the remaining status: peer published no identity key (legacy).
|
||||
return {
|
||||
icon: "shield",
|
||||
@@ -495,6 +505,11 @@ function renderVoiceChannelItem(
|
||||
// Don't trigger if the right-click menu is open
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
// Watching a stream needs a live LiveKit room -- join first, same
|
||||
// as the hover/focus preview's placeholder click below.
|
||||
if (voiceStore.getState().currentChannelId !== channel.id) {
|
||||
onVoiceJoin(channel.id);
|
||||
}
|
||||
const tileId = user.screenshare
|
||||
? user.userId + SCREENSHARE_TILE_ID_OFFSET
|
||||
: user.userId;
|
||||
@@ -907,8 +922,9 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
// ac.abort() also releases this sidebar's hold on the shared document-level
|
||||
// drag listeners (drag-reorder.ts tracks owners by signal).
|
||||
ac.abort();
|
||||
releaseGlobalDragListeners(channelList ?? undefined);
|
||||
for (const unsub of unsubscribers) {
|
||||
unsub();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* under every category — the server agrees (it validates the type alone).
|
||||
*/
|
||||
|
||||
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
|
||||
import { createElement, setText, appendChildren } from "@lib/dom";
|
||||
import { createIcon } from "@lib/icons";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
@@ -44,6 +45,7 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo
|
||||
const { category, onCreate, onClose } = options;
|
||||
const ac = new AbortController();
|
||||
let overlay: HTMLDivElement | null = null;
|
||||
let restoreFocus: (() => void) | null = null;
|
||||
|
||||
function mount(container: Element): void {
|
||||
overlay = createElement("div", {
|
||||
@@ -52,13 +54,17 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo
|
||||
});
|
||||
|
||||
const modal = createElement("div", { class: "modal" });
|
||||
applyDialogSemantics(modal, { labelledBy: "create-channel-title" });
|
||||
trapFocus(modal, ac.signal);
|
||||
|
||||
// Header
|
||||
const header = createElement("div", { class: "modal-header" });
|
||||
const title = createElement("h3", {}, "Create Channel");
|
||||
const title = createElement("h3", { id: "create-channel-title" }, "Create Channel");
|
||||
// Icon-only button: without a label a screen reader announces just "button".
|
||||
const closeBtn = createElement("button", {
|
||||
class: "modal-close",
|
||||
type: "button",
|
||||
"aria-label": "Close",
|
||||
});
|
||||
closeBtn.textContent = "";
|
||||
closeBtn.appendChild(createIcon("x", 14));
|
||||
@@ -188,8 +194,25 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
// Escape cancels — never creates. Document-level so it works wherever
|
||||
// focus sits; guarded on the overlay still being attached because the
|
||||
// listener lives until destroy() aborts it.
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && overlay?.isConnected === true) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
container.appendChild(overlay);
|
||||
|
||||
// Capture where focus came from before anything inside the dialog takes
|
||||
// it, so destroy() can hand it back to the opener.
|
||||
restoreFocus = focusDialog(modal);
|
||||
|
||||
// Focus the name input
|
||||
nameInput.focus();
|
||||
}
|
||||
@@ -200,6 +223,11 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo
|
||||
overlay.remove();
|
||||
overlay = null;
|
||||
}
|
||||
// Every close path (X, Cancel, backdrop, Escape) funnels through the
|
||||
// caller's onClose, which calls destroy() — the single place focus
|
||||
// returns to the opener.
|
||||
restoreFocus?.();
|
||||
restoreFocus = null;
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Shows channel name and requires explicit confirmation.
|
||||
*/
|
||||
|
||||
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
|
||||
import { createElement, setText, appendChildren } from "@lib/dom";
|
||||
import { createIcon } from "@lib/icons";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
@@ -18,6 +19,7 @@ export function createDeleteChannelModal(options: DeleteChannelModalOptions): Mo
|
||||
const { channelName, onConfirm, onClose } = options;
|
||||
const ac = new AbortController();
|
||||
let overlay: HTMLDivElement | null = null;
|
||||
let restoreFocus: (() => void) | null = null;
|
||||
|
||||
function mount(container: Element): void {
|
||||
overlay = createElement("div", {
|
||||
@@ -26,13 +28,17 @@ export function createDeleteChannelModal(options: DeleteChannelModalOptions): Mo
|
||||
});
|
||||
|
||||
const modal = createElement("div", { class: "modal" });
|
||||
applyDialogSemantics(modal, { labelledBy: "delete-channel-title" });
|
||||
trapFocus(modal, ac.signal);
|
||||
|
||||
// Header
|
||||
const header = createElement("div", { class: "modal-header" });
|
||||
const title = createElement("h3", {}, "Delete Channel");
|
||||
const title = createElement("h3", { id: "delete-channel-title" }, "Delete Channel");
|
||||
// Icon-only button: without a label a screen reader announces just "button".
|
||||
const closeBtn = createElement("button", {
|
||||
class: "modal-close",
|
||||
type: "button",
|
||||
"aria-label": "Close",
|
||||
});
|
||||
closeBtn.textContent = "";
|
||||
closeBtn.appendChild(createIcon("x", 14));
|
||||
@@ -109,7 +115,24 @@ export function createDeleteChannelModal(options: DeleteChannelModalOptions): Mo
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
// Escape cancels — it must never stand in for the destructive confirm.
|
||||
// Document-level so it works wherever focus sits; guarded on the overlay
|
||||
// still being attached because the listener lives until destroy() aborts it.
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && overlay?.isConnected === true) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
container.appendChild(overlay);
|
||||
|
||||
// Move focus in (lands on the header's close button, safely away from the
|
||||
// destructive confirm) and remember the opener for destroy() to restore.
|
||||
restoreFocus = focusDialog(modal);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
@@ -118,6 +141,11 @@ export function createDeleteChannelModal(options: DeleteChannelModalOptions): Mo
|
||||
overlay.remove();
|
||||
overlay = null;
|
||||
}
|
||||
// Every close path (X, Cancel, backdrop, Escape) funnels through the
|
||||
// caller's onClose, which calls destroy() — the single place focus
|
||||
// returns to the opener.
|
||||
restoreFocus?.();
|
||||
restoreFocus = null;
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
import { createElement, appendChildren, setText } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import type { UserStatus } from "@lib/types";
|
||||
import { isSafeUrl } from "./message-list/attachments";
|
||||
import { isRenderableAvatar } from "@lib/avatar";
|
||||
import { fetchImageAsDataUrl, resolveServerUrl } from "./message-list/attachments";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -31,6 +32,16 @@ export interface DmProfileData {
|
||||
export interface DmProfileSidebarOptions {
|
||||
readonly user: DmProfileData;
|
||||
readonly onClose: () => void;
|
||||
/**
|
||||
* The connected server's host, used to scope the note's localStorage key.
|
||||
* User ids are per-server, so without this a note about user 5 on one
|
||||
* server is shown for, and overwritten by, the unrelated user 5 on
|
||||
* another — real in the multi-profile client (see profiles.ts). Optional,
|
||||
* and falls back to the legacy unscoped key, so a caller that has not
|
||||
* been updated to pass it yet keeps today's single-profile behavior
|
||||
* exactly (including any note already saved under the old key).
|
||||
*/
|
||||
readonly host?: string;
|
||||
}
|
||||
|
||||
export type DmProfileSidebarComponent = MountableComponent & {
|
||||
@@ -67,17 +78,34 @@ const STATUS_LABELS: Readonly<Record<UserStatus, string>> = {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function loadNote(userId: number): string {
|
||||
/** The legacy unscoped key, from before per-server notes (or when the caller
|
||||
* has not yet been updated to pass a host). */
|
||||
function legacyNoteKey(userId: number): string {
|
||||
return NOTE_STORAGE_PREFIX + String(userId);
|
||||
}
|
||||
|
||||
function scopedNoteKey(userId: number, host: string): string {
|
||||
return `${NOTE_STORAGE_PREFIX}${host}:${userId}`;
|
||||
}
|
||||
|
||||
function loadNote(userId: number, host: string): string {
|
||||
try {
|
||||
return localStorage.getItem(NOTE_STORAGE_PREFIX + String(userId)) ?? "";
|
||||
if (host !== "") {
|
||||
const scoped = localStorage.getItem(scopedNoteKey(userId, host));
|
||||
if (scoped !== null) return scoped;
|
||||
}
|
||||
// Fall back to the legacy key so a note saved before per-server scoping
|
||||
// (or while the host was unknown) is not silently lost.
|
||||
return localStorage.getItem(legacyNoteKey(userId)) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function saveNote(userId: number, text: string): void {
|
||||
function saveNote(userId: number, host: string, text: string): void {
|
||||
try {
|
||||
localStorage.setItem(NOTE_STORAGE_PREFIX + String(userId), text);
|
||||
const key = host !== "" ? scopedNoteKey(userId, host) : legacyNoteKey(userId);
|
||||
localStorage.setItem(key, text);
|
||||
} catch {
|
||||
// localStorage may be unavailable or full -- silently ignore
|
||||
}
|
||||
@@ -92,7 +120,7 @@ export function createDmProfileSidebar(
|
||||
): DmProfileSidebarComponent {
|
||||
const ac = new AbortController();
|
||||
const { signal } = ac;
|
||||
const { user, onClose } = options;
|
||||
const { user, onClose, host = "" } = options;
|
||||
|
||||
let panel: HTMLDivElement | null = null;
|
||||
let open = false;
|
||||
@@ -119,22 +147,31 @@ export function createDmProfileSidebar(
|
||||
wrapper.style.position = "relative";
|
||||
wrapper.style.flexShrink = "0";
|
||||
|
||||
if (user.avatar !== null && user.avatar.length > 0 && isSafeUrl(user.avatar)) {
|
||||
wrapper.style.background = "transparent";
|
||||
const img = createElement("img", {
|
||||
src: user.avatar,
|
||||
alt: user.username,
|
||||
class: "dps-avatar-img",
|
||||
// The letter draws immediately; the picture (if any) is fetched through
|
||||
// the same cert-pinned, bearer-token path attachments use and swapped in
|
||||
// once the bytes arrive. `<img src>` cannot carry the auth header an
|
||||
// `/api/v1/files/{id}` avatar needs, so the URL is never assigned raw.
|
||||
wrapper.style.background = "var(--accent, #5865f2)";
|
||||
const initial = user.username.charAt(0).toUpperCase() || "?";
|
||||
const letter = createElement("span", {}, initial);
|
||||
wrapper.appendChild(letter);
|
||||
|
||||
if (isRenderableAvatar(user.avatar)) {
|
||||
const resolved = resolveServerUrl(user.avatar);
|
||||
void fetchImageAsDataUrl(resolved).then((dataUrl) => {
|
||||
if (dataUrl === null || !wrapper.isConnected) return;
|
||||
const img = createElement("img", {
|
||||
src: dataUrl,
|
||||
alt: user.username,
|
||||
class: "dps-avatar-img",
|
||||
});
|
||||
img.style.width = "80px";
|
||||
img.style.height = "80px";
|
||||
img.style.borderRadius = "50%";
|
||||
letter.remove();
|
||||
wrapper.style.background = "transparent";
|
||||
wrapper.insertBefore(img, wrapper.firstChild);
|
||||
});
|
||||
img.style.width = "80px";
|
||||
img.style.height = "80px";
|
||||
img.style.borderRadius = "50%";
|
||||
wrapper.appendChild(img);
|
||||
} else {
|
||||
wrapper.style.background = "var(--accent, #5865f2)";
|
||||
const initial = user.username.charAt(0).toUpperCase() || "?";
|
||||
const text = createElement("span", {}, initial);
|
||||
wrapper.appendChild(text);
|
||||
}
|
||||
|
||||
// Status dot overlay
|
||||
@@ -328,12 +365,12 @@ export function createDmProfileSidebar(
|
||||
noteInput.style.fontSize = "13px";
|
||||
noteInput.style.padding = "8px";
|
||||
noteInput.style.fontFamily = "inherit";
|
||||
noteInput.value = loadNote(user.id);
|
||||
noteInput.value = loadNote(user.id, host);
|
||||
|
||||
noteInput.addEventListener(
|
||||
"input",
|
||||
() => {
|
||||
saveNote(user.id, noteInput.value);
|
||||
saveNote(user.id, host, noteInput.value);
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
@@ -16,7 +16,8 @@ import { createElement, setText, appendChildren } from "@lib/dom";
|
||||
import { createIcon } from "@lib/icons";
|
||||
import { showContextMenu } from "@lib/context-menu";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import { isSafeUrl } from "./message-list/attachments";
|
||||
import { isRenderableAvatar } from "@lib/avatar";
|
||||
import { fetchImageAsDataUrl, resolveServerUrl } from "./message-list/attachments";
|
||||
|
||||
/** One member of a group DM, as far as the sidebar needs to draw them. */
|
||||
export interface DmParticipant {
|
||||
@@ -74,17 +75,26 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
offline: "var(--text-micro)",
|
||||
};
|
||||
|
||||
/** Fill one avatar circle: the picture if it is safe to load, else the letter. */
|
||||
/**
|
||||
* Fill one avatar circle: the letter immediately, the picture swapped in once
|
||||
* fetched. `<img src>` cannot carry the bearer token an authenticated
|
||||
* `/api/v1/files/{id}` avatar needs, so the URL is always fetched through the
|
||||
* same cert-pinned path attachments and custom emoji use rather than assigned
|
||||
* directly.
|
||||
*/
|
||||
function paintAvatar(el: HTMLElement, avatar: string | null, label: string): void {
|
||||
if (avatar !== null && isSafeUrl(avatar)) {
|
||||
const img = createElement("img", { src: avatar, alt: label });
|
||||
setText(el, label.charAt(0).toUpperCase());
|
||||
if (!isRenderableAvatar(avatar)) return;
|
||||
const resolved = resolveServerUrl(avatar);
|
||||
void fetchImageAsDataUrl(resolved).then((dataUrl) => {
|
||||
if (dataUrl === null || !el.isConnected) return;
|
||||
const img = createElement("img", { src: dataUrl, alt: label });
|
||||
img.style.width = "100%";
|
||||
img.style.height = "100%";
|
||||
img.style.borderRadius = "50%";
|
||||
el.textContent = "";
|
||||
el.appendChild(img);
|
||||
return;
|
||||
}
|
||||
setText(el, label.charAt(0).toUpperCase());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -318,6 +328,18 @@ export function createDmSidebar(options: DmSidebarOptions): MountableComponent {
|
||||
|
||||
const items = sorted.map((convo) => renderDmItem(convo, options, ac.signal));
|
||||
|
||||
searchInput.addEventListener(
|
||||
"input",
|
||||
() => {
|
||||
const q = searchInput.value.trim().toLowerCase();
|
||||
items.forEach((el, i) => {
|
||||
const match = q === "" || sorted[i]!.username.toLowerCase().includes(q);
|
||||
el.style.display = match ? "" : "none";
|
||||
});
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
appendChildren(root, header, sectionLabel, ...items);
|
||||
container.appendChild(root);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* shown as its own option rather than being silently rounded to a neighbour.
|
||||
*/
|
||||
|
||||
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
|
||||
import { createElement, setText, appendChildren } from "@lib/dom";
|
||||
import { createIcon } from "@lib/icons";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
@@ -159,6 +160,7 @@ export function createEditChannelModal(options: EditChannelModalOptions): Mounta
|
||||
const isVoice = channelType === "voice";
|
||||
const ac = new AbortController();
|
||||
let overlay: HTMLDivElement | null = null;
|
||||
let restoreFocus: (() => void) | null = null;
|
||||
|
||||
function mount(container: Element): void {
|
||||
overlay = createElement("div", {
|
||||
@@ -167,13 +169,17 @@ export function createEditChannelModal(options: EditChannelModalOptions): Mounta
|
||||
});
|
||||
|
||||
const modal = createElement("div", { class: "modal" });
|
||||
applyDialogSemantics(modal, { labelledBy: "edit-channel-title" });
|
||||
trapFocus(modal, ac.signal);
|
||||
|
||||
// Header
|
||||
const header = createElement("div", { class: "modal-header" });
|
||||
const title = createElement("h3", {}, "Edit Channel");
|
||||
const title = createElement("h3", { id: "edit-channel-title" }, "Edit Channel");
|
||||
// Icon-only button: without a label a screen reader announces just "button".
|
||||
const closeBtn = createElement("button", {
|
||||
class: "modal-close",
|
||||
type: "button",
|
||||
"aria-label": "Close",
|
||||
});
|
||||
closeBtn.textContent = "";
|
||||
closeBtn.appendChild(createIcon("x", 14));
|
||||
@@ -395,7 +401,25 @@ export function createEditChannelModal(options: EditChannelModalOptions): Mounta
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
// Escape cancels — never saves. Document-level so it works wherever focus
|
||||
// sits; guarded on the overlay still being attached because the listener
|
||||
// lives until destroy() aborts it.
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && overlay?.isConnected === true) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
container.appendChild(overlay);
|
||||
|
||||
// Capture where focus came from before anything inside the dialog takes
|
||||
// it, so destroy() can hand it back to the opener.
|
||||
restoreFocus = focusDialog(modal);
|
||||
|
||||
nameInput.focus();
|
||||
nameInput.select();
|
||||
}
|
||||
@@ -406,6 +430,11 @@ export function createEditChannelModal(options: EditChannelModalOptions): Mounta
|
||||
overlay.remove();
|
||||
overlay = null;
|
||||
}
|
||||
// Every close path (X, Cancel, backdrop, Escape) funnels through the
|
||||
// caller's onClose, which calls destroy() — the single place focus
|
||||
// returns to the opener.
|
||||
restoreFocus?.();
|
||||
restoreFocus = null;
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
|
||||
@@ -50,6 +50,11 @@ export interface EmojiAutocompleteOptions {
|
||||
/** Called with the text to insert (`:wave:` or a unicode character). */
|
||||
readonly onSelect: (insert: string) => void;
|
||||
readonly onClose: () => void;
|
||||
/**
|
||||
* Composer textarea the popup completes for; carries combobox semantics and
|
||||
* aria-activedescendant while the popup is open (see inline-autocomplete).
|
||||
*/
|
||||
readonly comboboxInput?: HTMLElement;
|
||||
}
|
||||
|
||||
/** Same shape as the shared inline-autocomplete widget. */
|
||||
@@ -153,5 +158,6 @@ export function createEmojiAutocomplete(
|
||||
// MIN_EMOJI_QUERY, so there is nothing to prime on create.
|
||||
onSelect: options.onSelect,
|
||||
onClose: options.onClose,
|
||||
comboboxInput: options.comboboxInput,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
|
||||
|
||||
import { createElement, setText, clearChildren } from "@lib/dom";
|
||||
import { enableRovingNavigation, setRovingTabindex } from "@lib/a11y";
|
||||
import { buildCustomEmojiNode } from "@components/message-list/custom-emoji";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -559,11 +560,16 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
|
||||
header.appendChild(searchInput);
|
||||
root.appendChild(header);
|
||||
|
||||
// Scrollable content area (holds category labels + grids)
|
||||
// Scrollable content area (holds category labels + grids). Announced as a
|
||||
// single flat listbox — the category grids are visual grouping only, and
|
||||
// roving tabindex (DC-13) treats every .ep-emoji cell as one list.
|
||||
const scrollArea = createElement("div", {
|
||||
style: "overflow-y: auto; max-height: 320px;",
|
||||
role: "listbox",
|
||||
"aria-label": "Emoji",
|
||||
});
|
||||
root.appendChild(scrollArea);
|
||||
enableRovingNavigation(scrollArea, ".ep-emoji", signal);
|
||||
|
||||
// Build categories with recent + custom
|
||||
function getAllCategories(): readonly EmojiCategory[] {
|
||||
@@ -596,6 +602,10 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
|
||||
const span = createElement("span", {
|
||||
class: "ep-emoji",
|
||||
title: emoji,
|
||||
role: "option",
|
||||
// Mirrors the title (the character or :shortcode: token) — e2e specs
|
||||
// select cells by title, so the accessible name must never diverge.
|
||||
"aria-label": emoji,
|
||||
});
|
||||
// A `:shortcode:` entry shows its image; everything else is the character
|
||||
// itself. An unresolvable shortcode falls back to the text, which is what
|
||||
@@ -652,6 +662,10 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
|
||||
);
|
||||
scrollArea.appendChild(empty);
|
||||
}
|
||||
|
||||
// Every render rebuilds the cell set, so the single Tab stop must be
|
||||
// re-established or filtering would leave zero tabbable cells.
|
||||
setRovingTabindex(scrollArea, ".ep-emoji");
|
||||
}
|
||||
|
||||
// Initial render
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
// Step 8.59 — File upload component with drag-and-drop, preview, and progress.
|
||||
// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
|
||||
|
||||
import { createElement, setText, appendChildren } from "@lib/dom";
|
||||
import { createIcon } from "@lib/icons";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
|
||||
/** Default allowed MIME types for file uploads. */
|
||||
const DEFAULT_ALLOWED_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"audio/mpeg",
|
||||
"audio/ogg",
|
||||
"audio/wav",
|
||||
"application/pdf",
|
||||
"text/plain",
|
||||
];
|
||||
|
||||
export interface FileUploadOptions {
|
||||
readonly onUpload: (file: File) => Promise<void>;
|
||||
readonly maxSizeMb?: number;
|
||||
readonly allowedMimeTypes?: readonly string[];
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_SIZE_MB = 10;
|
||||
|
||||
export type FileUploadComponent = MountableComponent & { openPicker(): void };
|
||||
|
||||
export function createFileUpload(options: FileUploadOptions): FileUploadComponent {
|
||||
const maxBytes = (options.maxSizeMb ?? DEFAULT_MAX_SIZE_MB) * 1024 * 1024;
|
||||
const ac = new AbortController();
|
||||
const signal = ac.signal;
|
||||
|
||||
let root: HTMLDivElement | null = null;
|
||||
let dropzone: HTMLDivElement;
|
||||
let fileInput: HTMLInputElement;
|
||||
let preview: HTMLDivElement;
|
||||
let thumb: HTMLImageElement;
|
||||
let nameSpan: HTMLSpanElement;
|
||||
let sizeSpan: HTMLSpanElement;
|
||||
let progressBar: HTMLDivElement;
|
||||
let cancelBtn: HTMLButtonElement;
|
||||
let errorDiv: HTMLDivElement;
|
||||
let uploadAbort: AbortController | null = null;
|
||||
|
||||
// oxlint-disable-next-line consistent-function-scoping -- co-located with its sole caller for readability
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function showError(message: string): void {
|
||||
setText(errorDiv, message);
|
||||
errorDiv.classList.remove("file-upload__error--hidden");
|
||||
preview.classList.add("file-upload__preview--hidden");
|
||||
}
|
||||
|
||||
function resetPreview(): void {
|
||||
preview.classList.add("file-upload__preview--hidden");
|
||||
thumb.src = "";
|
||||
thumb.style.display = "none";
|
||||
setText(nameSpan, "");
|
||||
setText(sizeSpan, "");
|
||||
progressBar.style.width = "0%";
|
||||
uploadAbort = null;
|
||||
errorDiv.classList.add("file-upload__error--hidden");
|
||||
}
|
||||
|
||||
function showPreview(file: File): void {
|
||||
resetPreview();
|
||||
setText(nameSpan, file.name);
|
||||
setText(sizeSpan, formatSize(file.size));
|
||||
if (file.type.startsWith("image/")) {
|
||||
const url = URL.createObjectURL(file);
|
||||
thumb.src = url;
|
||||
thumb.style.display = "block";
|
||||
thumb.addEventListener("load", () => URL.revokeObjectURL(url));
|
||||
}
|
||||
preview.classList.remove("file-upload__preview--hidden");
|
||||
}
|
||||
|
||||
async function handleFile(file: File): Promise<void> {
|
||||
errorDiv.classList.add("file-upload__error--hidden");
|
||||
const allowed = options.allowedMimeTypes ?? DEFAULT_ALLOWED_TYPES;
|
||||
if (file.type && !allowed.includes(file.type)) {
|
||||
showError(`File type "${file.type}" is not allowed.`);
|
||||
return;
|
||||
}
|
||||
if (file.size > maxBytes) {
|
||||
showError(
|
||||
`File too large (${formatSize(file.size)}). Max ${options.maxSizeMb ?? DEFAULT_MAX_SIZE_MB} MB.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
showPreview(file);
|
||||
uploadAbort = new AbortController();
|
||||
try {
|
||||
progressBar.style.width = "50%";
|
||||
await options.onUpload(file);
|
||||
progressBar.style.width = "100%";
|
||||
setTimeout(() => resetPreview(), 1500);
|
||||
} catch (err) {
|
||||
if (uploadAbort?.signal.aborted) return;
|
||||
showError(err instanceof Error ? err.message : "Upload failed");
|
||||
resetPreview();
|
||||
}
|
||||
}
|
||||
|
||||
function buildDom(): void {
|
||||
root = createElement("div", { class: "file-upload" });
|
||||
|
||||
dropzone = createElement("div", {
|
||||
class: "file-upload__dropzone file-upload__dropzone--hidden",
|
||||
});
|
||||
appendChildren(
|
||||
dropzone,
|
||||
createElement("span", { class: "file-upload__droptext" }, "Drop files here"),
|
||||
);
|
||||
|
||||
const allowed = options.allowedMimeTypes ?? DEFAULT_ALLOWED_TYPES;
|
||||
fileInput = createElement("input", {
|
||||
class: "file-upload__input",
|
||||
type: "file",
|
||||
accept: allowed.join(","),
|
||||
});
|
||||
fileInput.style.display = "none";
|
||||
|
||||
preview = createElement("div", { class: "file-upload__preview file-upload__preview--hidden" });
|
||||
thumb = createElement("img", { class: "file-upload__thumb" });
|
||||
thumb.style.display = "none";
|
||||
thumb.alt = "";
|
||||
nameSpan = createElement("span", { class: "file-upload__name" });
|
||||
sizeSpan = createElement("span", { class: "file-upload__size" });
|
||||
const progressContainer = createElement("div", { class: "file-upload__progress" });
|
||||
progressBar = createElement("div", { class: "file-upload__progress-bar" });
|
||||
progressBar.style.width = "0%";
|
||||
appendChildren(progressContainer, progressBar);
|
||||
cancelBtn = createElement("button", { class: "file-upload__cancel", type: "button" });
|
||||
cancelBtn.appendChild(createIcon("x", 14));
|
||||
appendChildren(preview, thumb, nameSpan, sizeSpan, progressContainer, cancelBtn);
|
||||
|
||||
errorDiv = createElement("div", { class: "file-upload__error file-upload__error--hidden" });
|
||||
appendChildren(root, dropzone, fileInput, preview, errorDiv);
|
||||
}
|
||||
|
||||
function attachListeners(): void {
|
||||
fileInput.addEventListener(
|
||||
"change",
|
||||
() => {
|
||||
const file = fileInput.files?.[0];
|
||||
if (file) {
|
||||
void handleFile(file);
|
||||
fileInput.value = "";
|
||||
}
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
cancelBtn.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
if (uploadAbort !== null) uploadAbort.abort();
|
||||
resetPreview();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
let dragCounter = 0;
|
||||
root!.addEventListener(
|
||||
"dragenter",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
dragCounter++;
|
||||
dropzone.classList.remove("file-upload__dropzone--hidden");
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
root!.addEventListener(
|
||||
"dragleave",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
dragCounter--;
|
||||
if (dragCounter <= 0) {
|
||||
dragCounter = 0;
|
||||
dropzone.classList.add("file-upload__dropzone--hidden");
|
||||
}
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
root!.addEventListener("dragover", (e) => e.preventDefault(), { signal });
|
||||
|
||||
root!.addEventListener(
|
||||
"drop",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
dragCounter = 0;
|
||||
dropzone.classList.add("file-upload__dropzone--hidden");
|
||||
const file = e.dataTransfer?.files[0];
|
||||
if (file) void handleFile(file);
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
||||
function mount(container: Element): void {
|
||||
buildDom();
|
||||
attachListeners();
|
||||
container.appendChild(root!);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
if (uploadAbort !== null) uploadAbort.abort();
|
||||
root?.remove();
|
||||
root = null;
|
||||
}
|
||||
|
||||
function openPicker(): void {
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
return { mount, destroy, openPicker };
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
// innerHTML with user content.
|
||||
|
||||
import { createElement, setText, clearChildren } from "@lib/dom";
|
||||
import { enableRovingNavigation, setRovingTabindex } from "@lib/a11y";
|
||||
import { ApiClientError } from "@lib/api";
|
||||
import { searchGifs, getTrendingGifs } from "@lib/gifProvider";
|
||||
import type { GifApi, GifResult } from "@lib/gifProvider";
|
||||
@@ -67,9 +68,15 @@ export function createGifPicker(options: GifPickerOptions): {
|
||||
|
||||
root.appendChild(header);
|
||||
|
||||
// Grid area (scrollable)
|
||||
const gridArea = createElement("div", { class: "gp-grid-area" });
|
||||
// Grid area (scrollable). Announced as a flat listbox of GIF options with
|
||||
// roving tabindex (DC-13); the inner .gp-grid is layout only.
|
||||
const gridArea = createElement("div", {
|
||||
class: "gp-grid-area",
|
||||
role: "listbox",
|
||||
"aria-label": "GIFs",
|
||||
});
|
||||
root.appendChild(gridArea);
|
||||
enableRovingNavigation(gridArea, ".gp-item", signal);
|
||||
|
||||
// Loading indicator
|
||||
const loadingEl = createElement("div", { class: "gp-loading" });
|
||||
@@ -92,7 +99,13 @@ export function createGifPicker(options: GifPickerOptions): {
|
||||
const grid = createElement("div", { class: "gp-grid" });
|
||||
|
||||
for (const gif of gifs) {
|
||||
const item = createElement("div", { class: "gp-item" });
|
||||
const item = createElement("div", {
|
||||
class: "gp-item",
|
||||
role: "option",
|
||||
// Same fallback as the img alt below — an untitled GIF still needs a
|
||||
// pronounceable accessible name.
|
||||
"aria-label": gif.title || "GIF",
|
||||
});
|
||||
const img = createElement("img", {
|
||||
class: "gp-img",
|
||||
src: gif.url,
|
||||
@@ -114,6 +127,9 @@ export function createGifPicker(options: GifPickerOptions): {
|
||||
}
|
||||
|
||||
gridArea.appendChild(grid);
|
||||
|
||||
// Each render replaces the cell set, so re-establish the single Tab stop.
|
||||
setRovingTabindex(gridArea, ".gp-item");
|
||||
}
|
||||
|
||||
function showLoading(): void {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Create, copy, and revoke invite codes.
|
||||
*/
|
||||
|
||||
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
|
||||
import { createElement, appendChildren, clearChildren } from "@lib/dom";
|
||||
import { createIcon } from "@lib/icons";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
@@ -56,6 +57,7 @@ export function createInviteManager(options: InviteManagerOptions): MountableCom
|
||||
let root: HTMLDivElement | null = null;
|
||||
let listEl: HTMLDivElement | null = null;
|
||||
let emptyEl: HTMLDivElement | null = null;
|
||||
let restoreFocus: (() => void) | null = null;
|
||||
let invites: readonly InviteItem[] = options.invites;
|
||||
|
||||
function renderList(): void {
|
||||
@@ -161,11 +163,14 @@ export function createInviteManager(options: InviteManagerOptions): MountableCom
|
||||
const modal = createElement("div", {
|
||||
class: "modal",
|
||||
});
|
||||
applyDialogSemantics(modal, { labelledBy: "invite-manager-title" });
|
||||
trapFocus(modal, ac.signal);
|
||||
|
||||
// Header
|
||||
const header = createElement("div", { class: "modal-header" });
|
||||
const title = createElement("h3", {}, "Server Invites");
|
||||
const closeBtn = createElement("button", { class: "modal-close" });
|
||||
const title = createElement("h3", { id: "invite-manager-title" }, "Server Invites");
|
||||
// Icon-only button: without a label a screen reader announces just "button".
|
||||
const closeBtn = createElement("button", { class: "modal-close", "aria-label": "Close" });
|
||||
closeBtn.appendChild(createIcon("x", 14));
|
||||
closeBtn.addEventListener("click", () => options.onClose(), { signal: ac.signal });
|
||||
appendChildren(header, title, closeBtn);
|
||||
@@ -236,6 +241,10 @@ export function createInviteManager(options: InviteManagerOptions): MountableCom
|
||||
renderList();
|
||||
|
||||
container.appendChild(root);
|
||||
|
||||
// Capture where focus came from before anything inside the dialog takes
|
||||
// it, so destroy() can hand it back to the opener.
|
||||
restoreFocus = focusDialog(modal);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
@@ -246,6 +255,10 @@ export function createInviteManager(options: InviteManagerOptions): MountableCom
|
||||
}
|
||||
listEl = null;
|
||||
emptyEl = null;
|
||||
// Every close path (X, backdrop, Escape) funnels through the caller's
|
||||
// onClose, which calls destroy() — the single place focus returns.
|
||||
restoreFocus?.();
|
||||
restoreFocus = null;
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
|
||||
@@ -272,7 +272,11 @@ function createMemberItem(
|
||||
|
||||
// Moderation actions are permission-gated per item (a role name told us
|
||||
// nothing about what its bits allow); block/unblock is open to everyone.
|
||||
const gates = moderationGates(opts.currentUserRole);
|
||||
// The role name is read live from authStore, not the opts snapshot
|
||||
// taken once at mount -- dispatcher.ts keeps authStore.user.role
|
||||
// current on every self MEMBER_UPDATE precisely so gates like this one
|
||||
// see a promotion/demotion without waiting for the sidebar to rebuild.
|
||||
const gates = moderationGates(authStore.getState().user?.role ?? opts.currentUserRole);
|
||||
const showAdminActions = gates.canKick || gates.canBan || gates.canManageRoles;
|
||||
|
||||
closeActiveMenu();
|
||||
|
||||
@@ -32,6 +32,11 @@ export interface MentionAutocompleteOptions {
|
||||
/** Called with the token to insert (without the leading "@"). */
|
||||
readonly onSelect: (token: string) => void;
|
||||
readonly onClose: () => void;
|
||||
/**
|
||||
* Composer textarea the popup completes for; carries combobox semantics and
|
||||
* aria-activedescendant while the popup is open (see inline-autocomplete).
|
||||
*/
|
||||
readonly comboboxInput?: HTMLElement;
|
||||
}
|
||||
|
||||
/** Same shape as the shared inline-autocomplete widget. */
|
||||
@@ -55,6 +60,10 @@ export function filterMentionSuggestions(query: string): MentionSuggestion[] {
|
||||
const substring: MentionSuggestion[] = [];
|
||||
|
||||
for (const member of membersStore.getState().members.values()) {
|
||||
// Skip usernames the mention grammar cannot express (a space, an "@",
|
||||
// etc. truncate the token on insert) -- picking one would insert a dead
|
||||
// token that resolves to no mention and notifies nobody.
|
||||
if (!/^[\p{L}\p{N}_.-]{1,64}$/u.test(member.username)) continue;
|
||||
const lower = member.username.toLowerCase();
|
||||
if (q !== "" && !lower.includes(q)) continue;
|
||||
const entry: MentionSuggestion = {
|
||||
@@ -121,5 +130,6 @@ export function createMentionAutocomplete(
|
||||
primeOnCreate: true,
|
||||
onSelect: options.onSelect,
|
||||
onClose: options.onClose,
|
||||
comboboxInput: options.comboboxInput,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,7 +90,16 @@ export function wrapWithMarker(
|
||||
const len = marker.length;
|
||||
|
||||
// Already wrapped — pressing the shortcut again takes the markers back off.
|
||||
if (selected.length > 2 * len && selected.startsWith(marker) && selected.endsWith(marker)) {
|
||||
// The interior must not itself contain the marker: otherwise a selection
|
||||
// that merely starts and ends with it (e.g. multiple already-wrapped spans,
|
||||
// or a longer marker like "**" matching the outer edge of "*x*") would be
|
||||
// mistaken for a single wrapped span and have its interior markers stripped.
|
||||
if (
|
||||
selected.length > 2 * len &&
|
||||
selected.startsWith(marker) &&
|
||||
selected.endsWith(marker) &&
|
||||
!selected.slice(len, selected.length - len).includes(marker)
|
||||
) {
|
||||
const inner = selected.slice(len, selected.length - len);
|
||||
return {
|
||||
value: value.slice(0, start) + inner + value.slice(end),
|
||||
@@ -128,6 +137,22 @@ const ALLOWED_TYPES = [
|
||||
"application/json",
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys that move the caret without an open autocomplete popup claiming them,
|
||||
* so the popup has to be resynced against the new caret on keyup. The popup's
|
||||
* own keys are deliberately absent: it consumes ArrowUp/ArrowDown/Enter/Tab
|
||||
* (so the caret does not move) and Escape closes it, and resyncing after any
|
||||
* of those would reset the highlighted row or reopen what Escape dismissed.
|
||||
*/
|
||||
const CARET_MOVE_KEYS: ReadonlySet<string> = new Set([
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"Home",
|
||||
"End",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
]);
|
||||
|
||||
/** Disable the GIF button and say why, instead of silently doing nothing. */
|
||||
function markGifUnavailable(gifBtn: HTMLButtonElement, reason: string): void {
|
||||
gifBtn.setAttribute("disabled", "true");
|
||||
@@ -198,7 +223,13 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
|
||||
/** Replace the token under the caret with "@token ". */
|
||||
function insertMention(token: string): void {
|
||||
if (textarea === null || mentionStart < 0) {
|
||||
// The popup can outlive the token it was opened over: a caret move the
|
||||
// composer never observed (Ctrl+A, a programmatic selection) leaves
|
||||
// mentionStart pointing at an offset the caret no longer follows, and
|
||||
// splicing there garbles the draft instead of completing it. Re-derive
|
||||
// the token and only commit while it still starts where the popup thinks.
|
||||
const active = activeMentionToken();
|
||||
if (textarea === null || mentionStart < 0 || active === null || active.start !== mentionStart) {
|
||||
closeMentionPopup();
|
||||
return;
|
||||
}
|
||||
@@ -242,7 +273,10 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
|
||||
/** Replace the `:token` under the caret with the chosen emoji, plus a space. */
|
||||
function insertEmoji(insert: string): void {
|
||||
if (textarea === null || emojiStart < 0) {
|
||||
// Same staleness guard as insertMention: never splice at an anchor the
|
||||
// caret has since moved away from.
|
||||
const active = activeEmojiToken();
|
||||
if (textarea === null || emojiStart < 0 || active === null || active.start !== emojiStart) {
|
||||
closeEmojiPopup();
|
||||
return;
|
||||
}
|
||||
@@ -270,6 +304,9 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
emojiPopup = createEmojiAutocomplete({
|
||||
onSelect: insertEmoji,
|
||||
onClose: closeEmojiPopup,
|
||||
// The popup manages combobox/aria-activedescendant state on the
|
||||
// textarea for as long as it is open.
|
||||
comboboxInput: textarea ?? undefined,
|
||||
});
|
||||
root?.appendChild(emojiPopup.element);
|
||||
}
|
||||
@@ -306,6 +343,9 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
mentionPopup = createMentionAutocomplete({
|
||||
onSelect: insertMention,
|
||||
onClose: closeMentionPopup,
|
||||
// The popup manages combobox/aria-activedescendant state on the
|
||||
// textarea for as long as it is open.
|
||||
comboboxInput: textarea ?? undefined,
|
||||
});
|
||||
root?.appendChild(mentionPopup.element);
|
||||
}
|
||||
@@ -378,10 +418,21 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
},
|
||||
message,
|
||||
);
|
||||
// app.css only shows the preview bar via .visible -- without this an
|
||||
// error with no attachments already queued renders into a display:none
|
||||
// container and is never seen.
|
||||
attachmentPreviewBar.classList.add("visible");
|
||||
attachmentPreviewBar.appendChild(errEl);
|
||||
const t = setTimeout(() => {
|
||||
activeTimers.delete(t);
|
||||
errEl.remove();
|
||||
if (
|
||||
attachmentPreviewBar !== null &&
|
||||
pendingAttachments.length === 0 &&
|
||||
attachmentPreviewBar.childElementCount === 0
|
||||
) {
|
||||
attachmentPreviewBar.classList.remove("visible");
|
||||
}
|
||||
}, 4000);
|
||||
activeTimers.add(t);
|
||||
}
|
||||
@@ -452,8 +503,8 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
/** Unique counter for preview items (before upload completes and we have a server ID). */
|
||||
let previewCounter = 0;
|
||||
|
||||
function removePreviewItem(tempId: string): void {
|
||||
const idx = pendingAttachments.findIndex((a) => a.id === tempId);
|
||||
function removePreviewItem(el: HTMLDivElement): void {
|
||||
const idx = pendingAttachments.findIndex((a) => a.previewEl === el);
|
||||
const att = idx !== -1 ? pendingAttachments[idx] : undefined;
|
||||
if (att !== undefined) {
|
||||
const img = att.previewEl.querySelector("img");
|
||||
@@ -482,6 +533,14 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
async function handlePasteFile(file: File): Promise<void> {
|
||||
if (options.onUploadFile === undefined || attachmentPreviewBar === null) return;
|
||||
|
||||
// Attachments queued during an edit are neither sent (the edit branch
|
||||
// never reads pendingAttachments) nor cleared -- they'd silently ride
|
||||
// along with the next ordinary message. Refuse at the single entry point.
|
||||
if (state.editing !== null) {
|
||||
showUploadError("Can't attach files while editing a message");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
showUploadError(`File too large: ${file.name} exceeds 100 MB limit`);
|
||||
@@ -540,7 +599,7 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
"click",
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
removePreviewItem(tempId);
|
||||
removePreviewItem(item);
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
@@ -566,7 +625,7 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
}
|
||||
} catch (err) {
|
||||
// Upload failed — remove preview and show error
|
||||
removePreviewItem(tempId);
|
||||
removePreviewItem(item);
|
||||
const errMsg = err instanceof Error ? err.message : "Upload failed";
|
||||
showUploadError(`Upload failed: ${errMsg}`);
|
||||
} finally {
|
||||
@@ -575,7 +634,9 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
}
|
||||
|
||||
function setReplyTo(messageId: number, username: string): void {
|
||||
if (state.editing !== null) hideEditBar();
|
||||
// cancelEdit also clears the textarea -- without it the stale edit text
|
||||
// survives into reply mode and Enter reposts it as a duplicate.
|
||||
if (state.editing !== null) cancelEdit();
|
||||
state = { replyTo: { messageId, username }, editing: null };
|
||||
showReplyBar(username);
|
||||
textarea?.focus();
|
||||
@@ -643,7 +704,7 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
const fileInput = createElement("input", {
|
||||
type: "file",
|
||||
style: "display: none;",
|
||||
accept: "image/*,video/*,audio/*,.pdf,.txt,.zip,.rar,.7z",
|
||||
accept: "image/*,video/*,audio/*,.pdf,.txt,.zip",
|
||||
});
|
||||
fileInput.addEventListener(
|
||||
"change",
|
||||
@@ -764,8 +825,17 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
{ signal },
|
||||
);
|
||||
|
||||
// Caret moves that aren't typing (click, blur) also decide the popup's fate.
|
||||
// Caret moves that aren't typing (click, arrow/Home/End keys, blur) also
|
||||
// decide the popup's fate — without this, completing a mention/emoji
|
||||
// after moving the caret away with the keyboard splices at a stale offset.
|
||||
textarea.addEventListener("click", syncAutocomplete, { signal });
|
||||
textarea.addEventListener(
|
||||
"keyup",
|
||||
(e: KeyboardEvent) => {
|
||||
if (CARET_MOVE_KEYS.has(e.key)) syncAutocomplete();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
textarea.addEventListener(
|
||||
"blur",
|
||||
() => {
|
||||
@@ -881,9 +951,20 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
markGifUnavailable(gifBtn, reason);
|
||||
},
|
||||
onSelect: (gifUrl: string) => {
|
||||
if (textarea !== null) {
|
||||
textarea.value = gifUrl;
|
||||
handleSend();
|
||||
// Send the GIF directly instead of routing it through the textarea
|
||||
// (handleSend's read of textarea.value): that overwrote — and
|
||||
// discarded — whatever draft the user had typed, and on slow
|
||||
// mode / mid-upload / debounced sends left the raw GIF URL sitting
|
||||
// in the composer instead of the draft. Guarded by the same
|
||||
// disabledReason/debounce checks as a normal send; an in-progress
|
||||
// edit and any typed draft are left untouched.
|
||||
if (disabledReason === null) {
|
||||
const now = Date.now();
|
||||
if (now - lastSendTime >= SEND_DEBOUNCE_MS) {
|
||||
lastSendTime = now;
|
||||
options.onSend(gifUrl, state.replyTo?.messageId ?? null, []);
|
||||
clearReply();
|
||||
}
|
||||
}
|
||||
closeGifPicker();
|
||||
},
|
||||
|
||||
@@ -36,7 +36,9 @@ export interface MessageListOptions {
|
||||
readonly channelName: string;
|
||||
readonly channelType?: string;
|
||||
readonly currentUserId: number;
|
||||
readonly onScrollTop: () => void;
|
||||
/** May return a promise (e.g. the underlying fetch); MessageList clears its
|
||||
* loadingOlder latch once it settles, success or failure. */
|
||||
readonly onScrollTop: () => void | Promise<void>;
|
||||
readonly onReplyClick: (messageId: number) => void;
|
||||
readonly onEditClick: (messageId: number) => void;
|
||||
readonly onDeleteClick: (messageId: number) => void;
|
||||
@@ -156,7 +158,11 @@ function buildVirtualItems(
|
||||
// A message directly under the NEW line starts a fresh block: rendering it
|
||||
// as a grouped continuation of a message from before the line hides both
|
||||
// its author and the fact that the line is there.
|
||||
const isGrouped = !isFirstUnread && prevMsg !== null && shouldGroup(prevMsg, msg);
|
||||
const isGrouped =
|
||||
!isFirstUnread &&
|
||||
prevMsg !== null &&
|
||||
isSameDay(prevMsg.timestamp, msg.timestamp) &&
|
||||
shouldGroup(prevMsg, msg);
|
||||
items.push({ kind: "message", message: msg, isGrouped });
|
||||
lastTimestamp = msg.timestamp;
|
||||
prevMsg = msg;
|
||||
@@ -266,6 +272,36 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
*/
|
||||
const unreadOnOpen = isWindowDetached(options.channelId) ? 0 : getUnreadOnOpen(options.channelId);
|
||||
|
||||
/**
|
||||
* Message id the NEW divider is anchored to, once one has been picked.
|
||||
* `firstUnreadIndex` returns a count-from-the-end offset, which drifts
|
||||
* whenever the loaded window grows (new messages arrive) between one full
|
||||
* rebuild and the next — the exact thing unreadOnOpen's doc comment above
|
||||
* promises won't happen. Latching onto the message id the first valid index
|
||||
* pointed at keeps the divider glued to that message for the rest of the
|
||||
* visit regardless of how the window grows around it.
|
||||
*/
|
||||
let newDividerAnchorId: number | null = null;
|
||||
|
||||
/**
|
||||
* Resolve the NEW divider's position for this rebuild. Prefers the latched
|
||||
* anchor id (stable across window growth); falls back to the count formula
|
||||
* only until an anchor exists, then latches it — skipping id 0 (an
|
||||
* unconfirmed optimistic row) since that id is not unique across pending
|
||||
* sends and would anchor to the wrong message once reconciled.
|
||||
*/
|
||||
function resolveNewDividerIndex(messages: readonly Message[]): number {
|
||||
if (newDividerAnchorId !== null) {
|
||||
return messages.findIndex((m) => m.id === newDividerAnchorId);
|
||||
}
|
||||
const idx = firstUnreadIndex(messages, unreadOnOpen);
|
||||
const anchor = idx !== -1 ? messages[idx] : undefined;
|
||||
if (anchor !== undefined && anchor.id !== 0) {
|
||||
newDividerAnchorId = anchor.id;
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Height estimation (Fenwick tree backed)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -512,12 +548,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
|
||||
function rebuildItems(): void {
|
||||
allMessages = getChannelMessages(options.channelId);
|
||||
virtualItems = buildVirtualItems(
|
||||
allMessages,
|
||||
null,
|
||||
null,
|
||||
firstUnreadIndex(allMessages, unreadOnOpen),
|
||||
);
|
||||
virtualItems = buildVirtualItems(allMessages, null, null, resolveNewDividerIndex(allMessages));
|
||||
|
||||
// Build Fenwick tree initialized with smart estimates / cached heights
|
||||
tree = new FenwickTree(virtualItems.length);
|
||||
@@ -683,14 +714,22 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let loadingOlder = false;
|
||||
let prevMessageCount = 0;
|
||||
// The oldest loaded message's id, not the count: a live tail append also
|
||||
// changes the count while a history fetch is still in flight, and
|
||||
// resetting the latch on that lets the next scroll refire loadOlderMessages
|
||||
// with the same unchanged cursor -- the same page then lands twice. Only a
|
||||
// prepend moves messages[0]. Seeded from the current state (not left at a
|
||||
// placeholder) so the first change observed after construction is compared
|
||||
// against reality, not an arbitrary initial value.
|
||||
let prevOldestId: number | null = getChannelMessages(options.channelId)[0]?.id ?? null;
|
||||
|
||||
const unsubLoadingReset = messagesStore.subscribeSelector(
|
||||
(s) => s.messagesByChannel,
|
||||
() => {
|
||||
const msgs = getChannelMessages(options.channelId);
|
||||
if (msgs.length !== prevMessageCount) {
|
||||
prevMessageCount = msgs.length;
|
||||
const oldestId = msgs.length > 0 ? msgs[0]!.id : null;
|
||||
if (oldestId !== prevOldestId) {
|
||||
prevOldestId = oldestId;
|
||||
loadingOlder = false;
|
||||
}
|
||||
},
|
||||
@@ -710,7 +749,14 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
hasMoreMessages(options.channelId)
|
||||
) {
|
||||
loadingOlder = true;
|
||||
options.onScrollTop();
|
||||
// A failed fetch never changes the message count, so the subscriber
|
||||
// below (which only reacts to a count change) would leave loadingOlder
|
||||
// latched forever. Clear it once the load settles either way — the
|
||||
// subscriber's reset still applies to the success path but is now just
|
||||
// belt-and-braces.
|
||||
void Promise.resolve(options.onScrollTop()).finally(() => {
|
||||
loadingOlder = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Update floating scroll-to-bottom button visibility
|
||||
|
||||
@@ -61,11 +61,17 @@ function renderPinnedItem(
|
||||
|
||||
// Hover actions
|
||||
const actions = createElement("div", { class: "pinned-msg__actions" });
|
||||
const jumpBtn = createElement("button", { title: "Jump to message" });
|
||||
// Icon-only buttons: title= only tooltips for mouse users, so mirror it as
|
||||
// an aria-label for screen readers.
|
||||
const jumpBtn = createElement("button", {
|
||||
title: "Jump to message",
|
||||
"aria-label": "Jump to message",
|
||||
});
|
||||
jumpBtn.appendChild(createIcon("external-link", 14));
|
||||
const unpinBtn = createElement("button", {
|
||||
class: "pinned-msg__unpin",
|
||||
title: "Unpin message",
|
||||
"aria-label": "Unpin message",
|
||||
});
|
||||
unpinBtn.appendChild(createIcon("x", 14));
|
||||
|
||||
@@ -94,7 +100,13 @@ export function createPinnedMessages(options: PinnedMessagesOptions): MountableC
|
||||
let root: HTMLDivElement | null = null;
|
||||
|
||||
function mount(container: Element): void {
|
||||
root = createElement("div", { class: "pinned-panel" });
|
||||
// A side panel, not a modal: complementary landmark (no aria-modal, no
|
||||
// focus trap), matching DmProfileSidebar.
|
||||
root = createElement("div", {
|
||||
class: "pinned-panel",
|
||||
role: "complementary",
|
||||
"aria-label": "Pinned messages",
|
||||
});
|
||||
|
||||
// Header
|
||||
const header = createElement("div", { class: "pinned-panel__header" });
|
||||
@@ -106,7 +118,10 @@ export function createPinnedMessages(options: PinnedMessagesOptions): MountableC
|
||||
const count = createElement("span", { class: "pinned-panel__count" });
|
||||
count.textContent = String(options.pinnedMessages.length);
|
||||
|
||||
const closeBtn = createElement("button", { class: "pinned-panel__close" });
|
||||
const closeBtn = createElement("button", {
|
||||
class: "pinned-panel__close",
|
||||
"aria-label": "Close pinned messages",
|
||||
});
|
||||
closeBtn.appendChild(createIcon("x", 16));
|
||||
closeBtn.addEventListener("click", () => options.onClose(), { signal: ac.signal });
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
|
||||
*/
|
||||
|
||||
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
|
||||
import { createElement, appendChildren, setText } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
|
||||
@@ -31,6 +32,7 @@ export interface QuickSwitchOverlayOptions {
|
||||
export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): MountableComponent {
|
||||
const ac = new AbortController();
|
||||
let root: HTMLDivElement | null = null;
|
||||
let restoreFocus: (() => void) | null = null;
|
||||
|
||||
function mount(container: Element): void {
|
||||
root = createElement("div", {
|
||||
@@ -48,6 +50,8 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
|
||||
);
|
||||
|
||||
const modal = createElement("div", { class: "quick-switch-modal" });
|
||||
applyDialogSemantics(modal, { label: "Switch server" });
|
||||
trapFocus(modal, ac.signal);
|
||||
|
||||
// Header
|
||||
const header = createElement("div", { class: "quick-switch-header" });
|
||||
@@ -64,11 +68,18 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
|
||||
|
||||
for (const profile of options.profiles) {
|
||||
const isCurrent = profile.host === options.currentHost;
|
||||
const item = createElement("div", {
|
||||
const attrs: Record<string, string> = {
|
||||
class: `quick-switch-item${isCurrent ? " current" : ""}`,
|
||||
"data-testid": "server-item",
|
||||
"data-host": profile.host,
|
||||
});
|
||||
};
|
||||
// Only actionable rows get button semantics — the connected row has no
|
||||
// click handler, and a "button" that does nothing lies to screen readers.
|
||||
if (!isCurrent) {
|
||||
attrs["role"] = "button";
|
||||
attrs["tabindex"] = "0";
|
||||
}
|
||||
const item = createElement("div", attrs);
|
||||
|
||||
const icon = createElement("div", { class: "quick-switch-icon" });
|
||||
setText(icon, profile.name.charAt(0).toUpperCase());
|
||||
@@ -94,6 +105,18 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
// Divs get no native key activation; Enter/Space mirrors the click
|
||||
// so the row honors the button role it advertises.
|
||||
item.addEventListener(
|
||||
"keydown",
|
||||
(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
options.onSwitch(profile.host, profile.name);
|
||||
}
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
}
|
||||
|
||||
list.appendChild(item);
|
||||
@@ -103,6 +126,8 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
|
||||
const addItem = createElement("div", {
|
||||
class: "quick-switch-item add-new",
|
||||
"data-testid": "add-server-btn",
|
||||
role: "button",
|
||||
tabindex: "0",
|
||||
});
|
||||
const addIcon = createElement("div", { class: "quick-switch-icon add" }, "+");
|
||||
const addInfo = createElement("div", { class: "quick-switch-info" });
|
||||
@@ -115,6 +140,16 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
|
||||
appendChildren(addInfo, addName, addHost);
|
||||
appendChildren(addItem, addIcon, addInfo);
|
||||
addItem.addEventListener("click", () => options.onAddServer(), { signal: ac.signal });
|
||||
addItem.addEventListener(
|
||||
"keydown",
|
||||
(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
options.onAddServer();
|
||||
}
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
list.appendChild(addItem);
|
||||
|
||||
// Footer
|
||||
@@ -124,6 +159,10 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
|
||||
root.appendChild(modal);
|
||||
container.appendChild(root);
|
||||
|
||||
// Move focus onto the first actionable row (or the modal itself) and
|
||||
// remember the opener — the UserBar switch button — for destroy().
|
||||
restoreFocus = focusDialog(modal);
|
||||
|
||||
// Escape key closes overlay
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
@@ -140,6 +179,10 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
|
||||
root.remove();
|
||||
root = null;
|
||||
}
|
||||
// Restore after removal so focus cannot land on a node inside the
|
||||
// just-detached overlay.
|
||||
restoreFocus?.();
|
||||
restoreFocus = null;
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Step 8.60 — Quick switcher modal (Ctrl+K) for fast channel navigation.
|
||||
// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
|
||||
|
||||
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
|
||||
import { createElement, setText, appendChildren, clearChildren } from "@lib/dom";
|
||||
import { createIcon } from "@lib/icons";
|
||||
import { channelsStore } from "@stores/channels.store";
|
||||
@@ -22,6 +23,7 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
|
||||
let activeIndex = 0;
|
||||
let filteredChannels: readonly Channel[] = [];
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let restoreFocus: (() => void) | null = null;
|
||||
|
||||
function getChannelIcon(ch: Channel): SVGSVGElement {
|
||||
return ch.type === "voice" ? createIcon("volume-2", 14) : createIcon("hash", 14);
|
||||
@@ -29,7 +31,11 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
|
||||
|
||||
function getFilteredChannels(query: string): readonly Channel[] {
|
||||
const state = channelsStore.getState();
|
||||
const all = Array.from(state.channels.values());
|
||||
// DM rows are synthesized into channelsStore once opened, but they have
|
||||
// their own sidebar path (full clearDmUnread/setSidebarMode handling) —
|
||||
// listing them here too would select via a bare setActiveChannel and
|
||||
// leave their unread/mention badge lit forever.
|
||||
const all = Array.from(state.channels.values()).filter((ch) => ch.type !== "dm");
|
||||
const sorted = [...all].toSorted((a, b) => a.position - b.position);
|
||||
|
||||
if (query.length === 0) return sorted;
|
||||
@@ -51,6 +57,12 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
|
||||
? "quick-switcher__item quick-switcher__item--active"
|
||||
: "quick-switcher__item",
|
||||
"data-channelid": String(ch.id),
|
||||
// Combobox option wiring: the id feeds aria-activedescendant so a
|
||||
// screen reader tracks the roving --active highlight without the
|
||||
// input ever losing DOM focus.
|
||||
id: `qs-option-${i}`,
|
||||
role: "option",
|
||||
"aria-selected": isActive ? "true" : "false",
|
||||
});
|
||||
|
||||
const icon = createElement("span", { class: "quick-switcher__icon" });
|
||||
@@ -79,6 +91,16 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
|
||||
|
||||
resultsDiv.appendChild(item);
|
||||
}
|
||||
|
||||
// Re-point aria-activedescendant on every render — arrow keys, filtering
|
||||
// and store refreshes all funnel through here, so it can never go stale.
|
||||
// An empty result set clears it; pointing at a missing id is worse than
|
||||
// pointing at nothing.
|
||||
if (filteredChannels.length > 0) {
|
||||
input.setAttribute("aria-activedescendant", `qs-option-${activeIndex}`);
|
||||
} else {
|
||||
input.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput(): void {
|
||||
@@ -154,21 +176,39 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
|
||||
|
||||
// Modal container
|
||||
const modal = createElement("div", { class: "quick-switcher" });
|
||||
applyDialogSemantics(modal, { label: "Quick switcher" });
|
||||
trapFocus(modal, signal);
|
||||
|
||||
// Search input
|
||||
// Search input — combobox over the results listbox: the input keeps DOM
|
||||
// focus while aria-activedescendant (set in renderResults) names the row
|
||||
// the arrow keys have highlighted. The list is always rendered, so
|
||||
// aria-expanded is statically true.
|
||||
input = createElement("input", {
|
||||
class: "quick-switcher__input",
|
||||
type: "text",
|
||||
placeholder: "Where do you want to go?",
|
||||
role: "combobox",
|
||||
"aria-expanded": "true",
|
||||
"aria-autocomplete": "list",
|
||||
"aria-controls": "quick-switcher-results",
|
||||
});
|
||||
|
||||
// Results list
|
||||
resultsDiv = createElement("div", { class: "quick-switcher__results" });
|
||||
resultsDiv = createElement("div", {
|
||||
class: "quick-switcher__results",
|
||||
id: "quick-switcher-results",
|
||||
role: "listbox",
|
||||
});
|
||||
|
||||
appendChildren(modal, input, resultsDiv);
|
||||
root.appendChild(modal);
|
||||
container.appendChild(root);
|
||||
|
||||
// Capture the opener before anything inside grabs focus — Ctrl+K comes
|
||||
// from the composer, and a keyboard user needs destroy() to land them
|
||||
// back there, not at the top of the document.
|
||||
restoreFocus = focusDialog(modal);
|
||||
|
||||
// Initial render
|
||||
filteredChannels = getFilteredChannels("");
|
||||
renderResults();
|
||||
@@ -194,6 +234,10 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
|
||||
}
|
||||
root?.remove();
|
||||
root = null;
|
||||
// Restore after the overlay is gone, so focus cannot land on a node the
|
||||
// removal is about to detach.
|
||||
restoreFocus?.();
|
||||
restoreFocus = null;
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* ServerStrip component — vertical strip on the far left showing server icons.
|
||||
* Single-server for now: Home button, separator, add server button.
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
|
||||
export function createServerStrip(): MountableComponent {
|
||||
const ac = new AbortController();
|
||||
let root: HTMLDivElement | null = null;
|
||||
|
||||
function mount(container: Element): void {
|
||||
root = createElement("div", { class: "server-strip", "data-testid": "server-strip" });
|
||||
|
||||
const homeIcon = createElement(
|
||||
"div",
|
||||
{ class: "server-icon active", style: "background: var(--accent)" },
|
||||
"O",
|
||||
);
|
||||
|
||||
const separator = createElement("div", { class: "server-separator" });
|
||||
|
||||
const addIcon = createElement("div", { class: "server-icon add" }, "+");
|
||||
|
||||
// Add server button click — placeholder for future multi-server support
|
||||
addIcon.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
// No-op for single-server mode
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
appendChildren(root, homeIcon, separator, addIcon);
|
||||
container.appendChild(root);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
if (root !== null) {
|
||||
root.remove();
|
||||
root = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
* Subscribes to uiStore for settingsOpen state.
|
||||
*/
|
||||
|
||||
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
|
||||
import { createElement, appendChildren, clearChildren } from "@lib/dom";
|
||||
import { createIcon } from "@lib/icons";
|
||||
import type { IconName } from "@lib/icons";
|
||||
@@ -73,6 +74,11 @@ const TAB_ICONS: Record<TabName, IconName> = {
|
||||
Logs: "scroll-text",
|
||||
};
|
||||
|
||||
/** Stable DOM id for a tab button (aria-labelledby target), e.g. "settings-tab-text-images". */
|
||||
function tabId(name: TabName): string {
|
||||
return `settings-tab-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -83,11 +89,14 @@ export function createSettingsOverlay(
|
||||
const ac = new AbortController();
|
||||
const authenticated = options.isAuthenticated !== false;
|
||||
let root: HTMLDivElement | null = null;
|
||||
let panel: HTMLDivElement | null = null;
|
||||
let contentArea: HTMLDivElement | null = null;
|
||||
let pageTitle: HTMLHeadingElement | null = null;
|
||||
let activeTab: TabName = authenticated ? "Account" : "Appearance";
|
||||
/** False once the active tab's content has been torn down by `hide()`. */
|
||||
let contentLive = false;
|
||||
/** Puts focus back on whatever opened the panel; null while closed. */
|
||||
let restoreFocus: (() => void) | null = null;
|
||||
const tabButtons = new Map<TabName, HTMLButtonElement>();
|
||||
let unsubUi: (() => void) | null = null;
|
||||
let unsubAuth: (() => void) | null = null;
|
||||
@@ -139,16 +148,25 @@ export function createSettingsOverlay(
|
||||
for (const [name, btn] of tabButtons) {
|
||||
btn.classList.toggle("active", name === tab);
|
||||
btn.setAttribute("aria-selected", name === tab ? "true" : "false");
|
||||
// Roving tabindex: only the active tab sits in the page Tab order.
|
||||
btn.setAttribute("tabindex", name === tab ? "0" : "-1");
|
||||
}
|
||||
contentArea?.setAttribute("aria-labelledby", tabId(tab));
|
||||
renderActiveTab();
|
||||
}
|
||||
|
||||
function show(): void {
|
||||
const wasOpen = root?.classList.contains("open") ?? false;
|
||||
root?.classList.add("open");
|
||||
// Closing tore down the live parts of the active tab (mic meter, camera
|
||||
// preview, log listener). Rebuild it so a reopened panel shows live state
|
||||
// instead of a frozen snapshot — and so every tab re-reads current prefs.
|
||||
if (!contentLive) renderActiveTab();
|
||||
// Move focus in only on the closed→open transition — a repeated show()
|
||||
// would otherwise capture an element inside the panel as the "opener".
|
||||
if (!wasOpen && panel !== null) {
|
||||
restoreFocus = focusDialog(panel);
|
||||
}
|
||||
}
|
||||
|
||||
function hide(): void {
|
||||
@@ -156,6 +174,9 @@ export function createSettingsOverlay(
|
||||
// Stop camera preview, mic meter, and the log listener when the overlay closes
|
||||
cleanupActiveTab();
|
||||
contentLive = false;
|
||||
// Hand focus back to whatever opened the panel.
|
||||
restoreFocus?.();
|
||||
restoreFocus = null;
|
||||
}
|
||||
|
||||
// ---- MountableComponent ---------------------------------------------------
|
||||
@@ -163,8 +184,42 @@ export function createSettingsOverlay(
|
||||
function mount(container: Element): void {
|
||||
root = createElement("div", { class: "settings-overlay", "data-testid": "settings-overlay" });
|
||||
|
||||
// Sidebar
|
||||
const sidebar = createElement("div", { class: "settings-sidebar" });
|
||||
// Sidebar. It doubles as the tablist: the profile block, category headings
|
||||
// ("User Settings" / "App Settings") and the Log Out button also live in
|
||||
// here, and a tablist should own only tabs — but moving them out would
|
||||
// change the structure the e2e selectors pin down, so we accept that the
|
||||
// non-tab children are presentational noise inside the tablist (DC-13).
|
||||
const sidebar = createElement("div", {
|
||||
class: "settings-sidebar",
|
||||
role: "tablist",
|
||||
"aria-orientation": "vertical",
|
||||
"aria-label": "Settings sections",
|
||||
});
|
||||
|
||||
// Arrow-key navigation between tabs, activate-on-focus (the simpler
|
||||
// conformant flavor of the WAI-ARIA tabs pattern). Vertical list, so only
|
||||
// Up/Down move; Home/End jump to the edges; both directions wrap.
|
||||
sidebar.addEventListener(
|
||||
"keydown",
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") {
|
||||
return;
|
||||
}
|
||||
const order = [...tabButtons.keys()];
|
||||
const current = order.findIndex((name) => tabButtons.get(name) === e.target);
|
||||
if (current === -1) return; // e.g. the Log Out button — not a tab
|
||||
e.preventDefault();
|
||||
let next: number;
|
||||
if (e.key === "ArrowDown") next = (current + 1) % order.length;
|
||||
else if (e.key === "ArrowUp") next = (current - 1 + order.length) % order.length;
|
||||
else if (e.key === "Home") next = 0;
|
||||
else next = order.length - 1;
|
||||
const name = order[next]!;
|
||||
setActiveTab(name);
|
||||
tabButtons.get(name)?.focus();
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
// User profile section at top of sidebar
|
||||
const user = authStore.getState().user;
|
||||
@@ -213,8 +268,10 @@ export function createSettingsOverlay(
|
||||
|
||||
const accountBtn = createElement("button", {
|
||||
class: `settings-nav-item${activeTab === "Account" ? " active" : ""}`,
|
||||
id: tabId("Account"),
|
||||
role: "tab",
|
||||
"aria-selected": activeTab === "Account" ? "true" : "false",
|
||||
tabindex: activeTab === "Account" ? "0" : "-1",
|
||||
});
|
||||
accountBtn.prepend(createIcon(TAB_ICONS["Account"], 18));
|
||||
accountBtn.appendChild(document.createTextNode("Account"));
|
||||
@@ -240,8 +297,10 @@ export function createSettingsOverlay(
|
||||
for (const name of appTabs) {
|
||||
const btn = createElement("button", {
|
||||
class: `settings-nav-item${name === activeTab ? " active" : ""}`,
|
||||
id: tabId(name),
|
||||
role: "tab",
|
||||
"aria-selected": name === activeTab ? "true" : "false",
|
||||
tabindex: name === activeTab ? "0" : "-1",
|
||||
});
|
||||
btn.prepend(createIcon(TAB_ICONS[name], 18));
|
||||
btn.appendChild(document.createTextNode(name));
|
||||
@@ -263,8 +322,12 @@ export function createSettingsOverlay(
|
||||
// Page title (h1) at top of content area — created here, inserted in renderActiveTab
|
||||
pageTitle = createElement("h1", {}, activeTab);
|
||||
|
||||
// Content
|
||||
contentArea = createElement("div", { class: "settings-content" });
|
||||
// Content — the single tabpanel, renamed per switch via aria-labelledby
|
||||
contentArea = createElement("div", {
|
||||
class: "settings-content",
|
||||
role: "tabpanel",
|
||||
"aria-labelledby": tabId(activeTab),
|
||||
});
|
||||
|
||||
// Close button wrapped with ESC label
|
||||
const closeWrap = createElement("div", { class: "settings-close-wrap" });
|
||||
@@ -292,7 +355,11 @@ export function createSettingsOverlay(
|
||||
);
|
||||
|
||||
// Inner panel (Discord-style centered card)
|
||||
const panel = createElement("div", { class: "settings-panel" });
|
||||
panel = createElement("div", { class: "settings-panel" });
|
||||
applyDialogSemantics(panel, { label: "Settings" });
|
||||
// Arming the trap while hidden is safe: Tab can't land inside a
|
||||
// display:none panel, so the handler only fires while the overlay is open.
|
||||
trapFocus(panel, ac.signal);
|
||||
appendChildren(panel, sidebar, contentArea, closeWrap);
|
||||
|
||||
// Click backdrop (outside panel) to close
|
||||
@@ -343,10 +410,14 @@ export function createSettingsOverlay(
|
||||
logsTab.cleanup();
|
||||
voiceTab.cleanup();
|
||||
tabButtons.clear();
|
||||
// Tearing down while open still hands focus back to the opener.
|
||||
restoreFocus?.();
|
||||
restoreFocus = null;
|
||||
if (root !== null) {
|
||||
root.remove();
|
||||
root = null;
|
||||
}
|
||||
panel = null;
|
||||
contentArea = null;
|
||||
pageTitle = null;
|
||||
}
|
||||
|
||||
@@ -100,7 +100,16 @@ export function createToastContainer(): ToastContainer {
|
||||
}
|
||||
|
||||
function mount(container: Element): void {
|
||||
root = createElement("div", { class: "toast-container", "data-testid": "toast-container" });
|
||||
// One polite live region for all toasts (DC-13): screen readers announce
|
||||
// each toast as it is appended without interrupting current speech.
|
||||
// aria-atomic="false" so only the newly added toast is read, not the stack.
|
||||
root = createElement("div", {
|
||||
class: "toast-container",
|
||||
"data-testid": "toast-container",
|
||||
role: "status",
|
||||
"aria-live": "polite",
|
||||
"aria-atomic": "false",
|
||||
});
|
||||
container.appendChild(root);
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,13 @@ export function createTypingIndicator(options: TypingIndicatorOptions): Mountabl
|
||||
}
|
||||
|
||||
function mount(container: Element): void {
|
||||
root = createElement("div", { class: "typing-bar" });
|
||||
// Polite live region (DC-13): "X is typing" changes are announced without
|
||||
// interrupting whatever the screen reader is currently speaking.
|
||||
root = createElement("div", {
|
||||
class: "typing-bar",
|
||||
role: "status",
|
||||
"aria-live": "polite",
|
||||
});
|
||||
|
||||
updateFromState();
|
||||
|
||||
|
||||
@@ -90,6 +90,10 @@ export function createUpdateNotifier(options: UpdateNotifierOptions): MountableC
|
||||
// App will relaunch — this code won't execute after relaunch()
|
||||
} catch (err) {
|
||||
log.error("Update install failed", { error: String(err) });
|
||||
// The component may have been destroyed while the download was in
|
||||
// flight (page swap / logout) -- the banner it wanted to repaint is
|
||||
// already gone, so there is nothing left to do.
|
||||
if (banner === null) return;
|
||||
while (banner.firstChild) banner.removeChild(banner.firstChild);
|
||||
const errorText = createElement(
|
||||
"span",
|
||||
|
||||
@@ -120,19 +120,15 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
|
||||
});
|
||||
avatarTextEl = createElement("span", {});
|
||||
avatarEl.appendChild(avatarTextEl);
|
||||
const statusDot = createElement("div", {
|
||||
class: "status-dot",
|
||||
style:
|
||||
"background: var(--green); width: 10px; height: 10px; border-radius: 50%; position: absolute; bottom: 0; right: 0;",
|
||||
});
|
||||
avatarEl.appendChild(statusDot);
|
||||
|
||||
const info = createElement("div", { class: "ub-info" });
|
||||
nameEl = createElement("span", { class: "ub-name", "data-testid": "user-bar-name" });
|
||||
statusEl = createElement("span", { class: "ub-status" });
|
||||
appendChildren(info, nameEl, statusEl);
|
||||
|
||||
// Status picker — anchored below username, opens upward
|
||||
// Status picker — the dot itself lives in the avatar's corner (same spot
|
||||
// the old plain status indicator occupied) so it doubles as the status
|
||||
// display and its click target; the dropdown still opens upward from there.
|
||||
const statusPickerWrap = createElement("div", {
|
||||
class: "ub-status-picker-wrap",
|
||||
"data-testid": "status-picker-wrap",
|
||||
@@ -203,7 +199,7 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
|
||||
() => updatePickerDisabled(),
|
||||
);
|
||||
|
||||
info.appendChild(statusPickerWrap);
|
||||
avatarEl.appendChild(statusPickerWrap);
|
||||
|
||||
const buttons = createElement("div", { class: "ub-controls" });
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
* in the chat or member list. Shows avatar, username, role badge, status dot,
|
||||
* about section, join date, and Message/Call action buttons.
|
||||
*
|
||||
* Position: anchored to click point, flips if <100px from viewport edge.
|
||||
* Animation: fade+scale 100ms.
|
||||
* Position: anchored to the click point, flipped to the other side and clamped
|
||||
* against the measured card height so it always lands fully on screen.
|
||||
* Animation: fade+scale, defined in CSS so reduced-motion can drop it.
|
||||
* Close: outside click or Escape.
|
||||
* A11y: role="dialog", aria-label, focus trap, return focus on close.
|
||||
*/
|
||||
@@ -57,8 +58,10 @@ export type UserProfilePopupComponent = MountableComponent & {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const POPUP_WIDTH = 300;
|
||||
const EDGE_THRESHOLD = 100;
|
||||
const ANIMATION_DURATION_MS = 100;
|
||||
/** Keeps the card clear of the window edges on both axes. */
|
||||
const VIEWPORT_MARGIN = 8;
|
||||
/** Breathing room between the click point and the card. */
|
||||
const ANCHOR_GAP = 8;
|
||||
|
||||
const STATUS_COLORS: Record<UserStatus, string> = {
|
||||
online: "#3ba55d",
|
||||
@@ -110,28 +113,37 @@ export function createUserProfilePopup(
|
||||
}
|
||||
}
|
||||
|
||||
function computePosition(anchorX: number, anchorY: number): { left: number; top: number } {
|
||||
/**
|
||||
* Place the card beside the anchor, flipping and clamping so it always lands
|
||||
* fully on screen — Discord opens its popout away from whichever edge the
|
||||
* clicked row is nearest.
|
||||
*
|
||||
* The height is measured rather than assumed. The previous version guessed
|
||||
* 300px and only clamped the top edge, so a member clicked low in the list
|
||||
* opened a card that ran off the bottom of the window.
|
||||
*/
|
||||
function position(el: HTMLElement, anchorX: number, anchorY: number): void {
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const height = el.offsetHeight;
|
||||
|
||||
let left = anchorX;
|
||||
// Prefer the right of the anchor and flip left when there is no room. The
|
||||
// member list sits against the right edge, so flipping is the usual case.
|
||||
let left = anchorX + ANCHOR_GAP;
|
||||
if (left + POPUP_WIDTH > vw - VIEWPORT_MARGIN) {
|
||||
left = anchorX - POPUP_WIDTH - ANCHOR_GAP;
|
||||
}
|
||||
left = Math.max(VIEWPORT_MARGIN, Math.min(left, vw - POPUP_WIDTH - VIEWPORT_MARGIN));
|
||||
|
||||
// Align the top with the click, then lift the card just enough to fit.
|
||||
let top = anchorY;
|
||||
|
||||
// Flip horizontally if too close to right edge
|
||||
if (vw - anchorX < EDGE_THRESHOLD) {
|
||||
left = anchorX - POPUP_WIDTH;
|
||||
if (top + height > vh - VIEWPORT_MARGIN) {
|
||||
top = vh - height - VIEWPORT_MARGIN;
|
||||
}
|
||||
top = Math.max(VIEWPORT_MARGIN, top);
|
||||
|
||||
// Flip vertically if too close to bottom edge
|
||||
if (vh - anchorY < EDGE_THRESHOLD) {
|
||||
top = anchorY - 300; // approximate popup height
|
||||
}
|
||||
|
||||
// Clamp to viewport
|
||||
left = Math.max(8, Math.min(left, vw - POPUP_WIDTH - 8));
|
||||
top = Math.max(8, top);
|
||||
|
||||
return { left, top };
|
||||
el.style.left = `${left}px`;
|
||||
el.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
function buildAvatar(user: UserProfileData): HTMLDivElement {
|
||||
@@ -182,17 +194,8 @@ export function createUserProfilePopup(
|
||||
"data-testid": "user-profile-popup",
|
||||
});
|
||||
|
||||
// Position the popup
|
||||
const pos = computePosition(options.anchorX, options.anchorY);
|
||||
popup.style.left = `${pos.left}px`;
|
||||
popup.style.top = `${pos.top}px`;
|
||||
popup.style.width = `${POPUP_WIDTH}px`;
|
||||
|
||||
// Animation: fade + scale
|
||||
popup.style.opacity = "0";
|
||||
popup.style.transform = "scale(0.95)";
|
||||
popup.style.transition = `opacity ${ANIMATION_DURATION_MS}ms ease, transform ${ANIMATION_DURATION_MS}ms ease`;
|
||||
|
||||
// --- Content ---
|
||||
|
||||
// Avatar
|
||||
@@ -298,10 +301,12 @@ export function createUserProfilePopup(
|
||||
actions.appendChild(callBtn);
|
||||
}
|
||||
|
||||
// Assemble popup
|
||||
// Assemble the card: a banner strip and a body, with the avatar straddling
|
||||
// the seam between them the way Discord's popout does.
|
||||
const banner = createElement("div", { class: "upp-banner" });
|
||||
const body = createElement("div", { class: "upp-body" });
|
||||
appendChildren(
|
||||
popup,
|
||||
avatar,
|
||||
body,
|
||||
nameEl,
|
||||
handleEl,
|
||||
customStatusEl,
|
||||
@@ -311,17 +316,24 @@ export function createUserProfilePopup(
|
||||
joinSection,
|
||||
);
|
||||
if (actions.childElementCount > 0) {
|
||||
appendChildren(popup, divider, actions);
|
||||
appendChildren(body, divider, actions);
|
||||
}
|
||||
// The avatar hangs off the body's top edge, so it is a child of the card
|
||||
// rather than the body — the body scrolls, and a scroll container clips.
|
||||
// Appending it last puts it over the banner without needing a z-index.
|
||||
appendChildren(popup, banner, body, avatar);
|
||||
|
||||
overlay.appendChild(popup);
|
||||
container.appendChild(overlay);
|
||||
|
||||
// Trigger animation
|
||||
// Measure, then place: the card has to be in the document before it has a
|
||||
// height to clamp against.
|
||||
position(popup, options.anchorX, options.anchorY);
|
||||
|
||||
// The fade+scale itself lives in CSS so `prefers-reduced-motion` can drop it.
|
||||
requestAnimationFrame(() => {
|
||||
if (popup !== null) {
|
||||
popup.style.opacity = "1";
|
||||
popup.style.transform = "scale(1)";
|
||||
popup.classList.add("open");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createIcon } from "@lib/icons";
|
||||
import {
|
||||
getScreenshareAudioMuted,
|
||||
getScreenshareAudioVolume,
|
||||
getUserVolume,
|
||||
muteScreenshareAudio,
|
||||
setScreenshareAudioVolume,
|
||||
setUserVolume,
|
||||
@@ -26,8 +27,11 @@ export interface TileConfig {
|
||||
export interface VideoGridComponent extends MountableComponent {
|
||||
addStream(userId: number, username: string, stream: MediaStream, config?: TileConfig): void;
|
||||
removeStream(userId: number): void;
|
||||
/** Remove every tile — used on a real voice leave so stale remote tiles
|
||||
* from the previous session don't survive into the next join. */
|
||||
clearStreams(): void;
|
||||
hasStreams(): boolean;
|
||||
setFocusedTile(tileId: number): void;
|
||||
setFocusedTile(tileId: number | null): void;
|
||||
getFocusedTileId(): number | null;
|
||||
}
|
||||
|
||||
@@ -224,7 +228,7 @@ export function createVideoGrid(): VideoGridComponent {
|
||||
}
|
||||
}
|
||||
|
||||
function setFocusedTile(tileId: number): void {
|
||||
function setFocusedTile(tileId: number | null): void {
|
||||
focusedTileId = tileId;
|
||||
rebuildFocusLayout();
|
||||
}
|
||||
@@ -306,13 +310,18 @@ export function createVideoGrid(): VideoGridComponent {
|
||||
|
||||
// Add audio control overlay for remote tiles
|
||||
if (config !== undefined && !config.isSelf) {
|
||||
// Screenshare audio state survives tile rebuilds — initialize from it.
|
||||
// Screenshare sliders are 0-100 (HTMLAudioElement.volume caps at 1.0);
|
||||
// mic sliders keep 0-200 (LiveKit setVolume supports boost up to 2.0).
|
||||
let muted = config.isScreenshare ? getScreenshareAudioMuted(config.audioUserId) : false;
|
||||
let currentVolume = config.isScreenshare
|
||||
// Mic and screenshare audio state both survive tile rebuilds —
|
||||
// initialize from the same persisted values the sidebar volume menu
|
||||
// reads, instead of hardcoding "unmuted at 100%" (B3-5). Screenshare
|
||||
// sliders are 0-100 (HTMLAudioElement.volume caps at 1.0); mic sliders
|
||||
// keep 0-200 (LiveKit setVolume supports boost up to 2.0).
|
||||
const savedVolume = config.isScreenshare
|
||||
? Math.round(getScreenshareAudioVolume(config.audioUserId) * 100)
|
||||
: 100;
|
||||
: getUserVolume(config.audioUserId);
|
||||
let currentVolume = savedVolume;
|
||||
let muted = config.isScreenshare
|
||||
? getScreenshareAudioMuted(config.audioUserId)
|
||||
: savedVolume === 0;
|
||||
|
||||
const overlay = createElement("div", { class: "video-tile-overlay" });
|
||||
|
||||
@@ -421,6 +430,15 @@ export function createVideoGrid(): VideoGridComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove every tile (trackCleanup + srcObject=null via removeStream).
|
||||
* Deleting the current key mid-iteration is well-defined for Map — no
|
||||
* entries are skipped — so this needs no snapshot copy of the keys. */
|
||||
function clearStreams(): void {
|
||||
for (const userId of cells.keys()) {
|
||||
removeStream(userId);
|
||||
}
|
||||
}
|
||||
|
||||
function hasStreams(): boolean {
|
||||
return cells.size > 0;
|
||||
}
|
||||
@@ -470,6 +488,7 @@ export function createVideoGrid(): VideoGridComponent {
|
||||
destroy,
|
||||
addStream,
|
||||
removeStream,
|
||||
clearStreams,
|
||||
hasStreams,
|
||||
setFocusedTile,
|
||||
getFocusedTileId: getFocusedTileIdFn,
|
||||
|
||||
@@ -182,7 +182,10 @@ export function attachChannelContextMenu(
|
||||
menu.remove();
|
||||
menuAc.abort();
|
||||
};
|
||||
signal.addEventListener("abort", () => menuAc.abort());
|
||||
// Tie this bridge listener's own lifetime to menuAc so it does not
|
||||
// outlive the menu it belongs to — closeMenu (which aborts menuAc)
|
||||
// already fires far more often than the sidebar's own teardown.
|
||||
signal.addEventListener("abort", closeMenu, { signal: menuAc.signal });
|
||||
// Defer so this click event doesn't immediately close it
|
||||
setTimeout(() => {
|
||||
if (menuAc.signal.aborted) return;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Channel drag-reorder — mouse-based drag-and-drop for channel reordering.
|
||||
* Uses mousedown/mousemove/mouseup (avoids WebView2 HTML5 DnD issues).
|
||||
* Admin/owner only.
|
||||
* Gated on MANAGE_CHANNELS, like every other channel-management affordance.
|
||||
*/
|
||||
|
||||
import { getCurrentUser } from "@stores/auth.store";
|
||||
import { updateChannelPosition } from "@stores/channels.store";
|
||||
import type { Channel } from "@stores/channels.store";
|
||||
import type { ChannelReorderData } from "../ChannelSidebar";
|
||||
import { canManageChannels } from "@lib/permissions";
|
||||
|
||||
// ── Drag state (mouse-based, avoids WebView2 HTML5 DnD issues) ──
|
||||
interface DragState {
|
||||
@@ -16,17 +16,49 @@ interface DragState {
|
||||
containerEl: HTMLElement;
|
||||
channels: readonly Channel[];
|
||||
onReorder: (reorders: readonly ChannelReorderData[]) => void;
|
||||
/** The signal of the sidebar that started this drag, so its teardown can
|
||||
* clear the in-flight visual state without touching another sidebar's. */
|
||||
owner: AbortSignal;
|
||||
}
|
||||
let activeDrag: DragState | null = null;
|
||||
|
||||
/** Global mousemove/mouseup handlers for drag reordering. Registered once.
|
||||
* Reference-counted so multiple sidebar instances share the same listeners
|
||||
* and only the last destroy tears them down. */
|
||||
/** Global mousemove/mouseup handlers for drag reordering, shared by every
|
||||
* sidebar instance. Ownership is tracked per AbortSignal — the sidebar's
|
||||
* lifetime controller — not per attached channel row: attachDragHandlers runs
|
||||
* once per row per render, and the per-row ref-count this replaced meant a
|
||||
* sidebar took N references its single destroy could never return, so the two
|
||||
* document listeners lived for the rest of the process (the KNOWN BUG
|
||||
* drag-reorder.test.ts pinned until this fix). An owner's release is its
|
||||
* signal's abort — the same AbortController teardown idiom as
|
||||
* {@link ../../lib/disposable} — so there is no separate release call to
|
||||
* forget or miscount. */
|
||||
const listenerOwners = new Set<AbortSignal>();
|
||||
let globalDragAc: AbortController | null = null;
|
||||
let globalDragRefCount = 0;
|
||||
|
||||
export function ensureGlobalDragListeners(): void {
|
||||
globalDragRefCount++;
|
||||
function releaseOwner(owner: AbortSignal): void {
|
||||
listenerOwners.delete(owner);
|
||||
// A sidebar destroyed mid-drag must not leave the row stuck in the dragging
|
||||
// state or the body stuck in reorder mode.
|
||||
if (activeDrag !== null && activeDrag.owner === owner) {
|
||||
activeDrag.sourceEl.classList.remove("dragging");
|
||||
document.body.classList.remove("channel-reordering");
|
||||
activeDrag.containerEl.querySelectorAll(".channel-drop-indicator").forEach((x) => {
|
||||
x.classList.remove("channel-drop-indicator");
|
||||
});
|
||||
activeDrag = null;
|
||||
}
|
||||
if (listenerOwners.size === 0 && globalDragAc !== null) {
|
||||
globalDragAc.abort();
|
||||
globalDragAc = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureGlobalDragListeners(owner: AbortSignal): void {
|
||||
if (owner.aborted || listenerOwners.has(owner)) {
|
||||
return;
|
||||
}
|
||||
listenerOwners.add(owner);
|
||||
owner.addEventListener("abort", () => releaseOwner(owner), { once: true });
|
||||
if (globalDragAc !== null) {
|
||||
return;
|
||||
}
|
||||
@@ -111,17 +143,23 @@ export function ensureGlobalDragListeners(): void {
|
||||
...withoutDrag.slice(insertIdx),
|
||||
];
|
||||
|
||||
// Build reorder data and update store immediately
|
||||
// Build reorder data and update store immediately. Reassign the
|
||||
// group's own existing position slots, not a 0..n-1 range: the
|
||||
// server's position space is global, so a category can sit at
|
||||
// non-contiguous positions (interleaved with other categories), and
|
||||
// renumbering from 0 would stomp another category's slots.
|
||||
const slots = drag.channels.map((c) => c.position).sort((a, b) => a - b);
|
||||
const reorders: ChannelReorderData[] = [];
|
||||
for (let i = 0; i < reorderedIds.length; i++) {
|
||||
const id = reorderedIds[i];
|
||||
if (id === undefined) {
|
||||
const newPosition = slots[i];
|
||||
if (id === undefined || newPosition === undefined) {
|
||||
continue;
|
||||
}
|
||||
const ch = drag.channels.find((c) => c.id === id);
|
||||
if (ch !== undefined && ch.position !== i) {
|
||||
reorders.push({ channelId: id, newPosition: i });
|
||||
updateChannelPosition(id, i);
|
||||
if (ch !== undefined && ch.position !== newPosition) {
|
||||
reorders.push({ channelId: id, newPosition });
|
||||
updateChannelPosition(id, newPosition);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +171,7 @@ export function ensureGlobalDragListeners(): void {
|
||||
);
|
||||
}
|
||||
|
||||
/** Make a channel element draggable via mousedown (admin/owner only). */
|
||||
/** Make a channel element draggable via mousedown (MANAGE_CHANNELS only). */
|
||||
export function attachDragHandlers(
|
||||
el: HTMLElement,
|
||||
channel: Channel,
|
||||
@@ -145,13 +183,15 @@ export function attachDragHandlers(
|
||||
if (onReorderChannel === undefined) {
|
||||
return;
|
||||
}
|
||||
const user = getCurrentUser();
|
||||
const role = user?.role?.toLowerCase() ?? "";
|
||||
if (role !== "owner" && role !== "admin") {
|
||||
// The one derivation for every channel-management affordance (create, edit,
|
||||
// delete, reorder) — a custom role holding the bit gets the same rows the
|
||||
// Edit/Delete menu already offers it, and a role merely *named* "admin"
|
||||
// without the bit does not get a drag the server will 403.
|
||||
if (!canManageChannels()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureGlobalDragListeners();
|
||||
ensureGlobalDragListeners(signal);
|
||||
|
||||
el.classList.add("channel-draggable");
|
||||
el.dataset.dragChannelId = String(channel.id);
|
||||
@@ -173,6 +213,15 @@ export function attachDragHandlers(
|
||||
el.addEventListener(
|
||||
"mousemove",
|
||||
(e) => {
|
||||
// Defuse a stale latch: pendingDrag is cleared only by a mouseup on
|
||||
// this same row (see the listener below), so releasing the button
|
||||
// anywhere else — off this row entirely, or via a fast flick — leaves
|
||||
// it armed. A later button-free hover would otherwise promote it into
|
||||
// a real drag on the next `if` below.
|
||||
if (e.buttons === 0) {
|
||||
pendingDrag = null;
|
||||
return;
|
||||
}
|
||||
if (pendingDrag === null || activeDrag !== null) {
|
||||
return;
|
||||
}
|
||||
@@ -189,6 +238,7 @@ export function attachDragHandlers(
|
||||
containerEl,
|
||||
channels,
|
||||
onReorder: onReorderChannel,
|
||||
owner: signal,
|
||||
};
|
||||
el.classList.add("dragging");
|
||||
document.body.classList.add("channel-reordering");
|
||||
@@ -204,18 +254,3 @@ export function attachDragHandlers(
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
||||
/** Decrement global drag listener ref-count; tear down when no more sidebars. */
|
||||
export function releaseGlobalDragListeners(containerEl?: HTMLElement): void {
|
||||
// Clear stale drag state if the destroyed sidebar owns the active drag
|
||||
if (containerEl !== undefined && activeDrag?.containerEl === containerEl) {
|
||||
activeDrag.sourceEl.classList.remove("dragging");
|
||||
document.body.classList.remove("channel-reordering");
|
||||
activeDrag = null;
|
||||
}
|
||||
globalDragRefCount = Math.max(0, globalDragRefCount - 1);
|
||||
if (globalDragRefCount === 0 && globalDragAc !== null) {
|
||||
globalDragAc.abort();
|
||||
globalDragAc = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,11 +128,19 @@ export function showUserVolumeMenu(
|
||||
);
|
||||
}, 0);
|
||||
|
||||
// Also clean up if the parent component is destroyed
|
||||
signal.addEventListener("abort", () => {
|
||||
menu.remove();
|
||||
dismissAc.abort();
|
||||
});
|
||||
// Also clean up if the parent component is destroyed. Tied to dismissAc's
|
||||
// own signal (mirrors context-menu.ts's menuAc pattern) so this bridge
|
||||
// listener is torn down with the menu itself — otherwise it never runs
|
||||
// (the parent signal is long-lived) and every right-click permanently
|
||||
// accumulates one closure retaining a detached .user-vol-menu subtree.
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
menu.remove();
|
||||
dismissAc.abort();
|
||||
},
|
||||
{ signal: dismissAc.signal },
|
||||
);
|
||||
}
|
||||
|
||||
/** Builds the moderation rows. close() runs after any action so the menu does
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
* navigation, Enter/Tab/Escape handling, AbortController cleanup — lives here
|
||||
* once instead of being duplicated in each popup.
|
||||
*
|
||||
* Accessibility-wise this is a WAI-ARIA combobox, not a menu: DOM focus stays
|
||||
* in the composer textarea the whole time (moving it into the list would stop
|
||||
* keystrokes from reaching the textarea, so the rows deliberately get no
|
||||
* roving tabindex) and the "focused" row is conveyed purely through
|
||||
* aria-activedescendant on the textarea, pointing at per-row ids stamped on
|
||||
* every render.
|
||||
*
|
||||
* Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
|
||||
*/
|
||||
|
||||
@@ -40,6 +47,15 @@ export interface InlineAutocompleteConfig<T> {
|
||||
readonly onSelect: (value: string) => void;
|
||||
/** Called when the user dismisses the popup (Escape). */
|
||||
readonly onClose: () => void;
|
||||
/**
|
||||
* The composer control this popup completes for (the textarea). While the
|
||||
* popup exists it carries combobox semantics — role="combobox",
|
||||
* aria-autocomplete="list", aria-expanded="true", aria-controls={list id} —
|
||||
* plus aria-activedescendant tracking the active row; destroy() removes
|
||||
* them all again. DOM focus never moves here: it must stay in the textarea
|
||||
* so typing keeps working, which is why the rows have no tabindex.
|
||||
*/
|
||||
readonly comboboxInput?: HTMLElement;
|
||||
}
|
||||
|
||||
export interface InlineAutocompleteComponent {
|
||||
@@ -54,6 +70,15 @@ export interface InlineAutocompleteComponent {
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
/** The combobox state a popup stamps on its input, removed again on destroy. */
|
||||
const COMBOBOX_ATTRS = [
|
||||
"role",
|
||||
"aria-autocomplete",
|
||||
"aria-expanded",
|
||||
"aria-controls",
|
||||
"aria-activedescendant",
|
||||
] as const;
|
||||
|
||||
export function createInlineAutocomplete<T>(
|
||||
cfg: InlineAutocompleteConfig<T>,
|
||||
): InlineAutocompleteComponent {
|
||||
@@ -63,14 +88,29 @@ export function createInlineAutocomplete<T>(
|
||||
let suggestions: T[] = [];
|
||||
let activeIndex = 0;
|
||||
|
||||
// The testid is already unique per widget, so it doubles as a stable DOM id
|
||||
// for aria-controls / aria-activedescendant to point at.
|
||||
const rootId = cfg.rootTestId;
|
||||
|
||||
const root = createElement("div", {
|
||||
class: cfg.rootClass,
|
||||
id: rootId,
|
||||
role: "listbox",
|
||||
"data-testid": cfg.rootTestId,
|
||||
});
|
||||
const list = createElement("div", { class: "ma-list" });
|
||||
root.appendChild(list);
|
||||
|
||||
const input = cfg.comboboxInput ?? null;
|
||||
if (input !== null) {
|
||||
input.setAttribute("role", "combobox");
|
||||
input.setAttribute("aria-autocomplete", "list");
|
||||
// The popup only exists while it is open (the composer destroys it to
|
||||
// close), so "expanded" holds for this component's whole lifetime.
|
||||
input.setAttribute("aria-expanded", "true");
|
||||
input.setAttribute("aria-controls", rootId);
|
||||
}
|
||||
|
||||
function choose(index: number): void {
|
||||
const picked = suggestions[index];
|
||||
if (picked === undefined) return;
|
||||
@@ -83,6 +123,7 @@ export function createInlineAutocomplete<T>(
|
||||
const s = suggestions[i]!;
|
||||
const row = createElement("div", {
|
||||
class: i === activeIndex ? "ma-item ma-item--active" : "ma-item",
|
||||
id: `${rootId}-option-${i}`,
|
||||
role: "option",
|
||||
"aria-selected": i === activeIndex ? "true" : "false",
|
||||
"data-testid": cfg.rowTestId(s),
|
||||
@@ -100,6 +141,15 @@ export function createInlineAutocomplete<T>(
|
||||
);
|
||||
list.appendChild(row);
|
||||
}
|
||||
// Rows are rebuilt with index-based ids, so the pointer must be re-aimed
|
||||
// on every render, not just when activeIndex moves.
|
||||
if (input !== null) {
|
||||
if (suggestions.length > 0) {
|
||||
input.setAttribute("aria-activedescendant", `${rootId}-option-${activeIndex}`);
|
||||
} else {
|
||||
input.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setQuery(query: string): boolean {
|
||||
@@ -138,6 +188,12 @@ export function createInlineAutocomplete<T>(
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
// Another popup may have claimed the input between this one's open and
|
||||
// close (the composer opens the mention popup before closing the emoji
|
||||
// one), so only strip the combobox state while it still points here.
|
||||
if (input !== null && input.getAttribute("aria-controls") === rootId) {
|
||||
for (const attr of COMBOBOX_ATTRS) input.removeAttribute(attr);
|
||||
}
|
||||
root.remove();
|
||||
}
|
||||
|
||||
|
||||
@@ -346,6 +346,13 @@ export function renderInlineImage(url: string): HTMLDivElement {
|
||||
// properly remove document-level listeners from the previous instance.
|
||||
let activeLightboxClose: (() => void) | null = null;
|
||||
|
||||
/** Close the active lightbox, if any. Called on page teardown (logout, page
|
||||
* swap) so an open overlay doesn't survive onto the next page with live
|
||||
* document listeners and a revoked blob URL. */
|
||||
export function closeActiveLightbox(): void {
|
||||
activeLightboxClose?.();
|
||||
}
|
||||
|
||||
/** Open a full-screen lightbox overlay with zoom and pan. */
|
||||
export function openImageLightbox(src: string, alt: string): void {
|
||||
// Close any existing lightbox (including its document listeners)
|
||||
|
||||
@@ -221,6 +221,33 @@ interface HoverState {
|
||||
|
||||
const hoverStates = new WeakMap<HTMLElement, HoverState>();
|
||||
|
||||
/**
|
||||
* Chips currently mid-hover (debounce timer running or tooltip showing),
|
||||
* keyed by the message list's AbortSignal. A single abort listener per signal
|
||||
* hides whatever is in the set instead of registering a bare, never-removed
|
||||
* `abort` listener per chip on every render — the latter permanently pinned
|
||||
* every past chip (and, via parentNode, its whole detached row) in memory for
|
||||
* the rest of the channel visit. start()/stop() add/remove the chip, so the
|
||||
* set only ever holds the handful of chips actually being hovered.
|
||||
*/
|
||||
const hoveringChips = new WeakMap<AbortSignal, Set<HTMLElement>>();
|
||||
|
||||
function chipSetFor(signal: AbortSignal): Set<HTMLElement> {
|
||||
const existing = hoveringChips.get(signal);
|
||||
if (existing !== undefined) return existing;
|
||||
const set = new Set<HTMLElement>();
|
||||
hoveringChips.set(signal, set);
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
for (const chip of set) hide(chip);
|
||||
set.clear();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
return set;
|
||||
}
|
||||
|
||||
function removeTooltip(chip: HTMLElement): void {
|
||||
chip.querySelector(".reaction-tooltip")?.remove();
|
||||
}
|
||||
@@ -261,21 +288,25 @@ export function attachReactionTooltip(
|
||||
});
|
||||
};
|
||||
|
||||
const chips = chipSetFor(signal);
|
||||
|
||||
const start = (): void => {
|
||||
hide(chip);
|
||||
const existing = hoverStates.get(chip);
|
||||
const generation = existing === undefined ? 0 : existing.generation;
|
||||
const timer = window.setTimeout(show, REACTION_TOOLTIP_DEBOUNCE_MS);
|
||||
hoverStates.set(chip, { timer, generation });
|
||||
chips.add(chip);
|
||||
};
|
||||
|
||||
const stop = (): void => hide(chip);
|
||||
const stop = (): void => {
|
||||
chips.delete(chip);
|
||||
hide(chip);
|
||||
};
|
||||
|
||||
chip.addEventListener("mouseenter", start, { signal });
|
||||
chip.addEventListener("mouseleave", stop, { signal });
|
||||
// Keyboard accessibility: focus mirrors hover.
|
||||
chip.addEventListener("focusin", start, { signal });
|
||||
chip.addEventListener("focusout", stop, { signal });
|
||||
|
||||
signal.addEventListener("abort", () => hide(chip));
|
||||
}
|
||||
|
||||
@@ -179,9 +179,16 @@ function buildVoiceAudioTabInner(
|
||||
const onUp = (): void => {
|
||||
meterThreshold.removeEventListener("pointermove", onMove);
|
||||
meterThreshold.removeEventListener("pointerup", onUp);
|
||||
meterThreshold.removeEventListener("pointercancel", onUp);
|
||||
};
|
||||
meterThreshold.addEventListener("pointermove", onMove, { signal });
|
||||
meterThreshold.addEventListener("pointerup", onUp, { signal });
|
||||
// A touch/pen drag that the OS claims as a pan (or any other
|
||||
// mid-drag pointer loss) fires pointercancel instead of pointerup.
|
||||
// Without this, onMove stays attached for the tab's lifetime and
|
||||
// every later hover over the handle silently rewrites and persists
|
||||
// voiceSensitivity with no button held (v097).
|
||||
meterThreshold.addEventListener("pointercancel", onUp, { signal });
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
@@ -411,10 +418,14 @@ function buildVoiceAudioTabInner(
|
||||
{ signal },
|
||||
);
|
||||
|
||||
// Race guard: prevent stale getUserMedia results from overwriting a newer request
|
||||
// Race guard: prevent stale getUserMedia results from overwriting a newer
|
||||
// request. cleanupMic() invalidates both counters, so a stream resolving
|
||||
// after teardown is stopped instead of re-arming state nobody cleans up.
|
||||
let cameraRequestId = 0;
|
||||
let micRequestId = 0;
|
||||
registerCameraInvalidation(() => {
|
||||
cameraRequestId += 1;
|
||||
micRequestId += 1;
|
||||
});
|
||||
|
||||
function stopCameraPreview(): void {
|
||||
@@ -482,6 +493,7 @@ function buildVoiceAudioTabInner(
|
||||
|
||||
// Start mic level monitoring for visual feedback
|
||||
void (async () => {
|
||||
const thisRequest = ++micRequestId;
|
||||
try {
|
||||
const savedDevice = loadPref<string>("audioInputDevice", "");
|
||||
const constraints: MediaStreamConstraints = {
|
||||
@@ -489,6 +501,13 @@ function buildVoiceAudioTabInner(
|
||||
video: false,
|
||||
};
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
// Race guard: teardown (cleanup or abort) may have run while we awaited
|
||||
// — opening the mic now would leave it hot with nobody left to stop it,
|
||||
// and registerMic would re-arm state cleanupMic() already cleared.
|
||||
if (signal.aborted || thisRequest !== micRequestId) {
|
||||
for (const track of stream.getTracks()) track.stop();
|
||||
return;
|
||||
}
|
||||
const audioCtx = new AudioContext();
|
||||
const analyser = audioCtx.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"commands_hash": "ca3b770e3d69abf7",
|
||||
"structs_hash": "2c0574a96a92e42f",
|
||||
"config_hash": "c72a07caa5bc6ed4",
|
||||
"combined_hash": "6a107ade235e2401"
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* Auto-generated TypeScript bindings for Tauri commands
|
||||
* Generated by tauri-typegen v0.5.0
|
||||
* Generated at: 2026-04-03T09:09:31.628896400+00:00
|
||||
* Generator: none
|
||||
*
|
||||
* Do not edit manually - regenerate using: cargo tauri-typegen generate
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as types from "./types";
|
||||
|
||||
export async function startLivekitProxy(params: types.StartLivekitProxyParams): Promise<number> {
|
||||
return invoke("start_livekit_proxy", params);
|
||||
}
|
||||
|
||||
export async function stopLivekitProxy(): Promise<void> {
|
||||
return invoke("stop_livekit_proxy");
|
||||
}
|
||||
|
||||
export async function checkClientUpdate(
|
||||
params: types.CheckClientUpdateParams,
|
||||
): Promise<types.UpdateCheckResult> {
|
||||
return invoke("check_client_update", params);
|
||||
}
|
||||
|
||||
export async function downloadAndInstallUpdate(
|
||||
params: types.DownloadAndInstallUpdateParams,
|
||||
): Promise<void> {
|
||||
return invoke("download_and_install_update", params);
|
||||
}
|
||||
|
||||
export async function pttStart(): Promise<void> {
|
||||
return invoke("ptt_start");
|
||||
}
|
||||
|
||||
export async function pttStop(): Promise<void> {
|
||||
return invoke("ptt_stop");
|
||||
}
|
||||
|
||||
export async function pttSetKey(params: types.PttSetKeyParams): Promise<void> {
|
||||
return invoke("ptt_set_key", params);
|
||||
}
|
||||
|
||||
export async function pttGetKey(): Promise<number> {
|
||||
return invoke("ptt_get_key");
|
||||
}
|
||||
|
||||
export async function pttListenForKey(): Promise<number> {
|
||||
return invoke("ptt_listen_for_key");
|
||||
}
|
||||
|
||||
export async function saveCredential(params: types.SaveCredentialParams): Promise<void> {
|
||||
return invoke("save_credential", params);
|
||||
}
|
||||
|
||||
export async function loadCredential(
|
||||
params: types.LoadCredentialParams,
|
||||
): Promise<types.CredentialData | null> {
|
||||
return invoke("load_credential", params);
|
||||
}
|
||||
|
||||
export async function deleteCredential(params: types.DeleteCredentialParams): Promise<void> {
|
||||
return invoke("delete_credential", params);
|
||||
}
|
||||
|
||||
export async function wsConnect(params: types.WsConnectParams): Promise<void> {
|
||||
return invoke("ws_connect", params);
|
||||
}
|
||||
|
||||
export async function wsSend(params: types.WsSendParams): Promise<void> {
|
||||
return invoke("ws_send", params);
|
||||
}
|
||||
|
||||
export async function wsDisconnect(): Promise<void> {
|
||||
return invoke("ws_disconnect");
|
||||
}
|
||||
|
||||
export async function acceptCertFingerprint(
|
||||
params: types.AcceptCertFingerprintParams,
|
||||
): Promise<void> {
|
||||
return invoke("accept_cert_fingerprint", params);
|
||||
}
|
||||
|
||||
export async function getSettings(): Promise<types.Value> {
|
||||
return invoke("get_settings");
|
||||
}
|
||||
|
||||
export async function saveSettings(params: types.SaveSettingsParams): Promise<void> {
|
||||
return invoke("save_settings", params);
|
||||
}
|
||||
|
||||
export async function storeCertFingerprint(
|
||||
params: types.StoreCertFingerprintParams,
|
||||
): Promise<void> {
|
||||
return invoke("store_cert_fingerprint", params);
|
||||
}
|
||||
|
||||
export async function getCertFingerprint(
|
||||
params: types.GetCertFingerprintParams,
|
||||
): Promise<string | null> {
|
||||
return invoke("get_cert_fingerprint", params);
|
||||
}
|
||||
|
||||
export async function openDevtools(): Promise<void> {
|
||||
return invoke("open_devtools");
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Auto-generated TypeScript bindings for Tauri commands
|
||||
* Generated by tauri-typegen v0.5.0
|
||||
* Generated at: 2026-04-03T09:09:31.629251800+00:00
|
||||
* Generator: none
|
||||
*
|
||||
* Do not edit manually - regenerate using: cargo tauri-typegen generate
|
||||
*/
|
||||
|
||||
/**
|
||||
* Event Listeners
|
||||
* Type-safe event listener helpers for Tauri events
|
||||
*/
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import * as types from "./types";
|
||||
|
||||
/**
|
||||
* Listen for 'status-change' events
|
||||
* @param handler - Callback function to handle the event
|
||||
* @returns Promise that resolves to an unlisten function
|
||||
*/
|
||||
export async function onStatusChange(handler: (payload: string) => void): Promise<UnlistenFn> {
|
||||
return listen<string>("status-change", (event) => {
|
||||
handler(event.payload);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for 'ws-state' events
|
||||
* @param handler - Callback function to handle the event
|
||||
* @returns Promise that resolves to an unlisten function
|
||||
*/
|
||||
export async function onWsState(handler: (payload: string) => void): Promise<UnlistenFn> {
|
||||
return listen<string>("ws-state", (event) => {
|
||||
handler(event.payload);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for 'cert-tofu' events
|
||||
* @param handler - Callback function to handle the event
|
||||
* @returns Promise that resolves to an unlisten function
|
||||
*/
|
||||
export async function onCertTofu(handler: (payload: types.Value) => void): Promise<UnlistenFn> {
|
||||
return listen<types.Value>("cert-tofu", (event) => {
|
||||
handler(event.payload);
|
||||
});
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* Auto-generated TypeScript bindings for Tauri commands
|
||||
* Generated by tauri-typegen v0.5.0
|
||||
* Generated at: 2026-04-03T09:09:31.629428700+00:00
|
||||
* Generator: none
|
||||
*
|
||||
* Do not edit manually - regenerate using: cargo tauri-typegen generate
|
||||
*/
|
||||
|
||||
export * from "./types";
|
||||
export * from "./commands";
|
||||
export * from "./events";
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* Auto-generated TypeScript bindings for Tauri commands
|
||||
* Generated by tauri-typegen v0.5.0
|
||||
* Generated at: 2026-04-03T09:09:31.628377200+00:00
|
||||
* Generator: none
|
||||
*
|
||||
* Do not edit manually - regenerate using: cargo tauri-typegen generate
|
||||
*/
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
available: boolean;
|
||||
version?: string | null;
|
||||
body?: string | null;
|
||||
}
|
||||
|
||||
export type Value = unknown;
|
||||
|
||||
export interface CredentialData {
|
||||
username: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface StartLivekitProxyParams {
|
||||
remoteHost: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CheckClientUpdateParams {
|
||||
serverUrl: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DownloadAndInstallUpdateParams {
|
||||
serverUrl: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PttSetKeyParams {
|
||||
vkCode: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SaveCredentialParams {
|
||||
host: string;
|
||||
username: string;
|
||||
token: string;
|
||||
password?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LoadCredentialParams {
|
||||
host: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DeleteCredentialParams {
|
||||
host: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface WsConnectParams {
|
||||
url: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface WsSendParams {
|
||||
message: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AcceptCertFingerprintParams {
|
||||
host: string;
|
||||
fingerprint: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SaveSettingsParams {
|
||||
key: string;
|
||||
value: Value;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface StoreCertFingerprintParams {
|
||||
host: string;
|
||||
fingerprint: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface GetCertFingerprintParams {
|
||||
host: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Shared dialog accessibility helpers (DC-13).
|
||||
*
|
||||
* Generalizes the pattern UserProfilePopup pioneered — dialog semantics, a
|
||||
* Tab-cycling focus trap, and focus save/restore — so every modal applies the
|
||||
* same behavior instead of re-implementing (or forgetting) it. All listeners
|
||||
* register against the caller's AbortSignal, matching the component teardown
|
||||
* idiom used across the codebase.
|
||||
*/
|
||||
|
||||
/** The elements a dialog's Tab cycle visits. */
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
/**
|
||||
* Elements the app hides via inline `style.display = "none"` (the codebase's
|
||||
* standard show/hide idiom — e.g. a group-name field revealed only once a
|
||||
* second member is picked) still match FOCUSABLE_SELECTOR: the selector is
|
||||
* structural, not a visibility check. A browser silently refuses to move
|
||||
* focus onto a display:none element, so treating one as the dialog's "first"
|
||||
* or "last" focusable leaves .focus() a no-op and the Tab trap comparing
|
||||
* against an edge focus never actually reached — Tab then falls through to
|
||||
* the browser's native order and can walk out of the dialog entirely.
|
||||
*/
|
||||
function isFocusable(el: HTMLElement): boolean {
|
||||
return el.style.display !== "none" && el.style.visibility !== "hidden";
|
||||
}
|
||||
|
||||
function queryFocusable(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
||||
isFocusable,
|
||||
);
|
||||
}
|
||||
|
||||
export interface DialogSemanticsOptions {
|
||||
/** Accessible name for the dialog (aria-label). */
|
||||
readonly label?: string;
|
||||
/** Id of the element naming the dialog (aria-labelledby); wins over label. */
|
||||
readonly labelledBy?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp WAI-ARIA dialog semantics on a modal container: role="dialog",
|
||||
* aria-modal="true", and tabindex="-1" so the container itself can take
|
||||
* initial focus when it holds no focusable control.
|
||||
*/
|
||||
export function applyDialogSemantics(el: HTMLElement, opts: DialogSemanticsOptions = {}): void {
|
||||
el.setAttribute("role", "dialog");
|
||||
el.setAttribute("aria-modal", "true");
|
||||
el.setAttribute("tabindex", "-1");
|
||||
if (opts.labelledBy !== undefined) {
|
||||
el.setAttribute("aria-labelledby", opts.labelledBy);
|
||||
} else if (opts.label !== undefined) {
|
||||
el.setAttribute("aria-label", opts.label);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trap Tab/Shift+Tab inside `container` for as long as `signal` lives:
|
||||
* tabbing past the last focusable wraps to the first and vice versa. The
|
||||
* focusable set is queried per keystroke, so contents may change freely.
|
||||
*/
|
||||
export function trapFocus(container: HTMLElement, signal: AbortSignal): void {
|
||||
container.addEventListener(
|
||||
"keydown",
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key !== "Tab") return;
|
||||
const focusable = queryFocusable(container);
|
||||
if (focusable.length === 0) {
|
||||
// Nothing tabbable inside — keep focus on the container itself.
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0]!;
|
||||
const last = focusable[focusable.length - 1]!;
|
||||
// Focus outside the set (e.g. on the container) also wraps to an edge.
|
||||
const active = document.activeElement;
|
||||
if (e.shiftKey && (active === first || active === container)) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && (active === last || active === container)) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make exactly one cell in `container` tabbable (the first) and the rest
|
||||
* focusable only programmatically. Call after every render that replaces the
|
||||
* cell set — search results swap the cells out from under the tabindex, and a
|
||||
* grid with zero (or many) Tab stops breaks the "Tab enters the grid once"
|
||||
* contract.
|
||||
*/
|
||||
export function setRovingTabindex(container: HTMLElement, cellSelector: string): void {
|
||||
const cells = container.querySelectorAll<HTMLElement>(cellSelector);
|
||||
cells.forEach((cell, i) => {
|
||||
cell.setAttribute("tabindex", i === 0 ? "0" : "-1");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Roving-tabindex keyboard support for a flat list of option cells:
|
||||
* ArrowLeft/ArrowRight step, Home/End jump to the edges, and Enter/Space
|
||||
* activate the focused cell through its own click handler so keyboard and
|
||||
* mouse take the identical code path. The grid is deliberately treated as a
|
||||
* flat list — row-aware Up/Down would need layout knowledge the DOM doesn't
|
||||
* expose reliably.
|
||||
*
|
||||
* The listener lives on the container (which survives re-renders) and the
|
||||
* cell set is queried per keystroke, so callers may rebuild cells freely as
|
||||
* long as they re-run setRovingTabindex afterwards.
|
||||
*/
|
||||
export function enableRovingNavigation(
|
||||
container: HTMLElement,
|
||||
cellSelector: string,
|
||||
signal: AbortSignal,
|
||||
): void {
|
||||
container.addEventListener(
|
||||
"keydown",
|
||||
(e: KeyboardEvent) => {
|
||||
// Only keystrokes originating on a cell rove; the search input above
|
||||
// the grid keeps its native caret behavior for arrows and Home/End.
|
||||
const origin =
|
||||
e.target instanceof HTMLElement ? e.target.closest<HTMLElement>(cellSelector) : null;
|
||||
if (origin === null) return;
|
||||
const cells = Array.from(container.querySelectorAll<HTMLElement>(cellSelector));
|
||||
const from = cells.indexOf(origin);
|
||||
if (from === -1) return;
|
||||
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
origin.click();
|
||||
return;
|
||||
}
|
||||
|
||||
let to: number;
|
||||
if (e.key === "ArrowRight") to = Math.min(from + 1, cells.length - 1);
|
||||
else if (e.key === "ArrowLeft") to = Math.max(from - 1, 0);
|
||||
else if (e.key === "Home") to = 0;
|
||||
else if (e.key === "End") to = cells.length - 1;
|
||||
else return;
|
||||
|
||||
e.preventDefault();
|
||||
// Move the single Tab stop along with focus so tabbing away and back
|
||||
// returns to the last visited cell, not the first.
|
||||
origin.setAttribute("tabindex", "-1");
|
||||
const target = cells[to]!;
|
||||
target.setAttribute("tabindex", "0");
|
||||
target.focus();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move initial focus into a just-opened dialog (its first focusable control,
|
||||
* else the container itself) and return a restorer that puts focus back on
|
||||
* whatever held it before — call the restorer on close. Capturing happens NOW,
|
||||
* so call this before anything inside the dialog grabs focus.
|
||||
*/
|
||||
export function focusDialog(container: HTMLElement): () => void {
|
||||
const previous = document.activeElement;
|
||||
const firstFocusable = queryFocusable(container)[0];
|
||||
(firstFocusable ?? container).focus();
|
||||
return () => {
|
||||
if (previous instanceof HTMLElement && previous.isConnected) {
|
||||
previous.focus();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -17,9 +17,7 @@ import type {
|
||||
ChannelType,
|
||||
ChannelResponse,
|
||||
EmojiResponse,
|
||||
SoundResponse,
|
||||
InviteResponse,
|
||||
SessionResponse,
|
||||
UploadResponse,
|
||||
VoiceCredentialsResponse,
|
||||
MemberResponse,
|
||||
@@ -51,6 +49,30 @@ export class ApiClientError extends Error {
|
||||
|
||||
export type OnUnauthorized = () => void;
|
||||
|
||||
/**
|
||||
* Single session object from GET /users/me/sessions, matching the server's
|
||||
* wire shape (Server/api/profile_handler.go's sessionResponse, wrapped in a
|
||||
* `{sessions: [...]}` envelope — docs/api.md). Defined here, next to its only
|
||||
* consumer, rather than in `./types`: the declaration that used to live there
|
||||
* had drifted from the actual contract (it declared `ip_address`/`expires_at`,
|
||||
* which the server never sends, and omitted `ip`/`is_current`, which it always
|
||||
* does), and nothing else needs this shape.
|
||||
*/
|
||||
export interface SessionInfo {
|
||||
readonly id: number;
|
||||
/** Never null: the server's fields are plain Go strings, so an unknown
|
||||
* device or address arrives as "" rather than being omitted. */
|
||||
readonly device: string;
|
||||
readonly ip: string;
|
||||
readonly created_at: string;
|
||||
readonly last_used: string;
|
||||
readonly is_current: boolean;
|
||||
}
|
||||
|
||||
interface SessionsListResponse {
|
||||
readonly sessions: SessionInfo[];
|
||||
}
|
||||
|
||||
const log = createLogger("api");
|
||||
|
||||
/** Create the REST API client. */
|
||||
@@ -189,7 +211,20 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
|
||||
log.error("setConfig rejected invalid host", { host: newConfig.host });
|
||||
throw new Error("Invalid host format");
|
||||
}
|
||||
config = { ...config, ...newConfig };
|
||||
// Switching to a different host without an accompanying new token must
|
||||
// not carry the previous host's bearer token forward — otherwise the
|
||||
// login/register request to the new host rides a still-live session
|
||||
// token for the old one. Callers that only rotate the token (post-auth)
|
||||
// never pass `host`, so this never touches a same-host token refresh.
|
||||
if (
|
||||
newConfig.host !== undefined &&
|
||||
newConfig.host !== config.host &&
|
||||
newConfig.token === undefined
|
||||
) {
|
||||
config = { ...config, ...newConfig, token: undefined };
|
||||
} else {
|
||||
config = { ...config, ...newConfig };
|
||||
}
|
||||
},
|
||||
|
||||
/** Get current config (for debugging). Token is redacted. */
|
||||
@@ -334,7 +369,7 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
|
||||
return request<void>(
|
||||
"PUT",
|
||||
"/users/me/password",
|
||||
{ current_password: currentPassword, new_password: newPassword },
|
||||
{ old_password: currentPassword, new_password: newPassword },
|
||||
signal,
|
||||
);
|
||||
},
|
||||
@@ -354,8 +389,10 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
|
||||
return request<void>("DELETE", "/users/me/totp", { password }, signal);
|
||||
},
|
||||
|
||||
getSessions(signal?: AbortSignal): Promise<SessionResponse[]> {
|
||||
return request<SessionResponse[]>("GET", "/users/me/sessions", undefined, signal);
|
||||
getSessions(signal?: AbortSignal): Promise<SessionInfo[]> {
|
||||
return request<SessionsListResponse>("GET", "/users/me/sessions", undefined, signal).then(
|
||||
(r) => r.sessions,
|
||||
);
|
||||
},
|
||||
|
||||
revokeSession(sessionId: number, signal?: AbortSignal): Promise<void> {
|
||||
@@ -589,16 +626,6 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
|
||||
return request<void>("DELETE", `/emoji/${emojiId}`, undefined, signal);
|
||||
},
|
||||
|
||||
// ── Sounds ────────────────────────────────────────────
|
||||
|
||||
getSounds(signal?: AbortSignal): Promise<SoundResponse[]> {
|
||||
return request<SoundResponse[]>("GET", "/sounds", undefined, signal);
|
||||
},
|
||||
|
||||
deleteSound(soundId: number, signal?: AbortSignal): Promise<void> {
|
||||
return request<void>("DELETE", `/sounds/${soundId}`, undefined, signal);
|
||||
},
|
||||
|
||||
// ── Direct Messages ─────────────────────────────────────
|
||||
|
||||
/** List user's open DM channels. */
|
||||
|
||||
@@ -17,9 +17,49 @@ import { voiceStore } from "@stores/voice.store";
|
||||
|
||||
const log = createLogger("audioElements");
|
||||
|
||||
/** Get saved per-user volume (0-200 range, default 100). Applied via LiveKit's GainNode-backed setVolume(). */
|
||||
/**
|
||||
* Server host the per-user volume prefs below belong to. Mirrors
|
||||
* channel-mutes.ts's currentHost — the client is multi-server (one webview
|
||||
* origin means one localStorage) and userId is only unique per server, so
|
||||
* without a host component a volume set for user 7 on one server would
|
||||
* silence user 7 on every other server too. `setAudioVolumeHost` is always
|
||||
* called with a real host before any volume is read (see MainPage.ts), so
|
||||
* the `null` startup default is not what protects a pre-scoping install's
|
||||
* saved volumes — `getSavedUserVolume` does that below by reading through to
|
||||
* the original unscoped key on a miss at the scoped one.
|
||||
*/
|
||||
let currentHost: string | null = null;
|
||||
|
||||
/** Point per-user volume reads/writes at a specific server. Call on connect
|
||||
* and on server switch — mirroring channel-mutes.ts's setChannelMutesHost. */
|
||||
export function setAudioVolumeHost(host: string | null): void {
|
||||
currentHost = host;
|
||||
}
|
||||
|
||||
function userVolumeKey(userId: number): string {
|
||||
return currentHost === null ? `userVolume_${userId}` : `userVolume_${userId}:${currentHost}`;
|
||||
}
|
||||
|
||||
// setUserVolume always clamps to 0-200, so -1 is safe as a "nothing saved" sentinel.
|
||||
const VOLUME_NOT_SET = -1;
|
||||
|
||||
/** Get saved per-user volume (0-200 range, default 100). Applied via LiveKit's
|
||||
* GainNode-backed setVolume(). On a miss at the host-scoped key, reads
|
||||
* through to the pre-scoping unscoped key once and persists the result
|
||||
* under the scoped key so the read-through isn't repeated. */
|
||||
function getSavedUserVolume(userId: number): number {
|
||||
return loadPref<number>(`userVolume_${userId}`, 100);
|
||||
const scopedKey = userVolumeKey(userId);
|
||||
if (currentHost === null) return loadPref<number>(scopedKey, 100);
|
||||
|
||||
const scoped = loadPref<number>(scopedKey, VOLUME_NOT_SET);
|
||||
if (scoped !== VOLUME_NOT_SET) return scoped;
|
||||
|
||||
const legacy = loadPref<number>(`userVolume_${userId}`, VOLUME_NOT_SET);
|
||||
if (legacy !== VOLUME_NOT_SET) {
|
||||
savePref(scopedKey, legacy);
|
||||
return legacy;
|
||||
}
|
||||
return loadPref<number>(scopedKey, 100);
|
||||
}
|
||||
|
||||
export class AudioElements {
|
||||
@@ -185,7 +225,7 @@ export class AudioElements {
|
||||
|
||||
setUserVolume(userId: number, volume: number): void {
|
||||
const clamped = Math.max(0, Math.min(200, volume));
|
||||
savePref(`userVolume_${userId}`, clamped);
|
||||
savePref(userVolumeKey(userId), clamped);
|
||||
if (this.room !== null) {
|
||||
for (const participant of this.room.remoteParticipants.values()) {
|
||||
if (parseUserId(participant.identity) === userId) {
|
||||
|
||||
@@ -21,6 +21,11 @@ export class AudioPipeline {
|
||||
|
||||
/** Monotonic counter incremented on teardown — used to discard stale async results. */
|
||||
private _pipelineGeneration = 0;
|
||||
/** Monotonic counter incremented on stopVadPolling — narrower than
|
||||
* _pipelineGeneration (which only bumps on a full pipeline teardown), so it
|
||||
* also invalidates an in-flight startVadPolling()'s addModule when VAD is
|
||||
* stopped without tearing down the pipeline (e.g. setVoiceSensitivity(100)). */
|
||||
private _vadGeneration = 0;
|
||||
|
||||
// Pipeline nodes
|
||||
private audioPipelineCtx: AudioContext | null = null;
|
||||
@@ -74,6 +79,10 @@ export class AudioPipeline {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- LocalTrack.setProcessor uses wide generic, but AudioProcessorOptions is guaranteed at runtime with webAudioMix
|
||||
await micPub.track.setProcessor(processor as any);
|
||||
log.info("RNNoise processor attached to mic track");
|
||||
// Rebuild so the gain/VAD chain sources from the processor's output and
|
||||
// its own sender.replaceTrack runs last, winning over setProcessor's
|
||||
// internal replaceTrack to the raw processed track (B3-1).
|
||||
this.setupAudioPipeline();
|
||||
}
|
||||
|
||||
/** Remove RNNoise processor from the local mic track. Safe to call if none attached. */
|
||||
@@ -84,6 +93,9 @@ export class AudioPipeline {
|
||||
if (micPub.track.getProcessor() === undefined) return;
|
||||
await micPub.track.stopProcessor();
|
||||
log.info("RNNoise processor removed from mic track");
|
||||
// Rebuild so the sender ends back on the gain/VAD chain over the raw mic,
|
||||
// not whatever track stopProcessor's own internals left wired (B3-1).
|
||||
this.setupAudioPipeline();
|
||||
}
|
||||
|
||||
// --- Pipeline setup/teardown ---
|
||||
@@ -96,7 +108,15 @@ export class AudioPipeline {
|
||||
if (micPub?.track === undefined) return;
|
||||
|
||||
try {
|
||||
const mediaTrack = micPub.track.mediaStreamTrack;
|
||||
// Source from the NS processor's output when one is attached, not the
|
||||
// raw mic track — livekit-client's LocalTrack.setProcessor() does its
|
||||
// own (internal, unawaited) sender.replaceTrack(processedTrack) once
|
||||
// the worklet loads, and that call lands AFTER this one (it awaits
|
||||
// addModule+fetch first). Sourcing from mediaStreamTrack unconditionally
|
||||
// meant that call always won, silently rewiring the sender straight to
|
||||
// the raw mic and bypassing this pipeline's gain/VAD entirely (B3-1).
|
||||
const mediaTrack =
|
||||
micPub.track.getProcessor()?.processedTrack ?? micPub.track.mediaStreamTrack;
|
||||
const ctx = new AudioContext({ sampleRate: 48000 });
|
||||
void ctx.resume(); // Ensure not suspended (WebView2 autoplay policy)
|
||||
|
||||
@@ -163,7 +183,11 @@ export class AudioPipeline {
|
||||
if (this.room !== null) {
|
||||
const micPub = this.room.localParticipant.getTrackPublication(Track.Source.Microphone);
|
||||
if (micPub?.track?.sender !== undefined) {
|
||||
const originalTrack = micPub.track.mediaStreamTrack;
|
||||
// Restore to the NS processor's output when one is still attached, not
|
||||
// the raw mic — otherwise tearing down just the gain/VAD wrapper (e.g.
|
||||
// muting) would also silently bypass an active noise suppressor (B3-1).
|
||||
const originalTrack =
|
||||
micPub.track.getProcessor()?.processedTrack ?? micPub.track.mediaStreamTrack;
|
||||
void micPub.track.sender
|
||||
.replaceTrack(originalTrack)
|
||||
.then(() => {
|
||||
@@ -270,15 +294,18 @@ export class AudioPipeline {
|
||||
|
||||
// Try AudioWorklet first
|
||||
const gen = this._pipelineGeneration;
|
||||
const vadGen = this._vadGeneration;
|
||||
this.audioPipelineCtx.audioWorklet
|
||||
.addModule("/vad-worklet.js")
|
||||
.then(() => {
|
||||
if (gen !== this._pipelineGeneration) return; // Torn down while loading
|
||||
if (vadGen !== this._vadGeneration) return; // stopVadPolling() while loading
|
||||
if (this.audioPipelineCtx === null) return;
|
||||
this.startVadWorklet(threshold);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (gen !== this._pipelineGeneration) return;
|
||||
if (vadGen !== this._vadGeneration) return;
|
||||
log.warn("AudioWorklet unavailable, falling back to setTimeout VAD", err);
|
||||
this.startVadFallback(threshold);
|
||||
});
|
||||
@@ -389,6 +416,7 @@ export class AudioPipeline {
|
||||
|
||||
/** Stop VAD (both worklet and fallback). Pipeline stays intact. */
|
||||
stopVadPolling(): void {
|
||||
this._vadGeneration++;
|
||||
// Stop setTimeout fallback
|
||||
if (this.vadTimer !== null) {
|
||||
clearTimeout(this.vadTimer);
|
||||
|
||||
@@ -86,8 +86,11 @@ export function startAutoIdle(options: AutoIdleOptions): AutoIdleController {
|
||||
let destroyed = false;
|
||||
/** True while the timer is the reason the status is idle. Kept in memory so
|
||||
* the hot path (one mousemove per pixel) is a boolean check rather than a
|
||||
* preference read. */
|
||||
let idleByTimer = false;
|
||||
* preference read. Seeded from the persisted status/origin so a session
|
||||
* that starts already auto-idle (app restart, MainPage remount) can still
|
||||
* be un-idled by activity — otherwise the latch starts false and apply(false)
|
||||
* is unreachable until the user manually reselects a status. */
|
||||
let idleByTimer = loadUserStatus() === "idle" && loadUserStatusOrigin() === "auto";
|
||||
|
||||
function apply(idle: boolean): void {
|
||||
const next = nextAutoStatus(loadUserStatus(), loadUserStatusOrigin(), idle);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* cert-reconnect — resume a WS connection after the user accepts a rotated
|
||||
* TLS certificate fingerprint (TOFU mismatch flow).
|
||||
*
|
||||
* Extracted out of main.ts, which has no unit-test seam of its own (it wires
|
||||
* the DOM, router and stores together at startup and is exercised at the
|
||||
* e2e level — see vitest.config.ts's coverage excludes) so this one piece of
|
||||
* retry logic can be tested directly.
|
||||
*/
|
||||
|
||||
export interface CertReconnectWs {
|
||||
connect(cfg: { readonly host: string; readonly token: string }): void;
|
||||
onStateChange(listener: (state: string) => void): () => void;
|
||||
}
|
||||
|
||||
export interface CertReconnectRouter {
|
||||
getCurrentPage(): string;
|
||||
navigate(page: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect after the user accepts a rotated certificate fingerprint.
|
||||
*
|
||||
* wirePostAuth's own onStateChange handler unsubscribes itself the moment it
|
||||
* sees "disconnected" (so a later transition can't fire it a second time) —
|
||||
* and the mismatch that triggered this retry is exactly the "disconnected"
|
||||
* transition that did so. A bare `ws.connect()` here would therefore
|
||||
* reconnect into a page with nothing left listening to leave the connect
|
||||
* screen. Re-register a one-shot navigator first, unless the app already
|
||||
* reached "main" (mismatch arrived after login, socket already live there).
|
||||
*/
|
||||
export function reconnectAfterCertAccept(
|
||||
ws: CertReconnectWs,
|
||||
router: CertReconnectRouter,
|
||||
host: string,
|
||||
token: string,
|
||||
): void {
|
||||
if (router.getCurrentPage() !== "main") {
|
||||
const unsub = ws.onStateChange((state) => {
|
||||
if (state === "connected") {
|
||||
unsub();
|
||||
router.navigate("main");
|
||||
}
|
||||
});
|
||||
}
|
||||
ws.connect({ host, token });
|
||||
}
|
||||
@@ -18,11 +18,28 @@
|
||||
* and `notificationSounds` live in localStorage next to it.
|
||||
*/
|
||||
|
||||
import { loadPref, savePref } from "./preferences";
|
||||
import { loadPref, savePref, STORAGE_PREFIX } from "./preferences";
|
||||
|
||||
/** localStorage key (under the shared settings prefix). */
|
||||
const MUTED_KEY = "mutedChannels";
|
||||
|
||||
/**
|
||||
* Server host the mutes below belong to. The app is multi-server (saved
|
||||
* profiles keyed by host, all sharing one Tauri webview origin and therefore
|
||||
* one localStorage), and channel ids are per-server SQLite autoincrement
|
||||
* integers — without a host component in the key, muting channel 7 on one
|
||||
* server silently mutes channel 7 on every other server too. `setChannelMutesHost`
|
||||
* is always called with a real host before any mute is read (see MainPage.ts),
|
||||
* so the `null` startup default is not what protects a pre-scoping install's
|
||||
* saved mutes — `readMuted` does that below by reading through to the
|
||||
* original unscoped key on a miss at the scoped one.
|
||||
*/
|
||||
let currentHost: string | null = null;
|
||||
|
||||
function mutedKey(): string {
|
||||
return currentHost === null ? MUTED_KEY : `${MUTED_KEY}:${currentHost}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached parse of the stored list. Notification gating runs on every incoming
|
||||
* message, and a JSON.parse per message for a list that changes on a menu
|
||||
@@ -32,9 +49,20 @@ const MUTED_KEY = "mutedChannels";
|
||||
*/
|
||||
let cache: ReadonlySet<number> | null = null;
|
||||
|
||||
function readMuted(): ReadonlySet<number> {
|
||||
if (cache !== null) return cache;
|
||||
const raw = loadPref<unknown[]>(MUTED_KEY, []);
|
||||
/**
|
||||
* Point mute reads/writes at a specific server's key and drop the cache so
|
||||
* the next read re-parses under the new key instead of returning the
|
||||
* previous server's set. Call on connect and on server switch — mirroring
|
||||
* how `read-state.ts`'s `setMarkReadSender` and `ui.store.ts`'s
|
||||
* `loadCollapsedCategories` are wired from MainPage per-connection.
|
||||
*/
|
||||
export function setChannelMutesHost(host: string | null): void {
|
||||
if (host === currentHost) return;
|
||||
currentHost = host;
|
||||
invalidateMuteCache();
|
||||
}
|
||||
|
||||
function parseMutedIds(raw: unknown): Set<number> {
|
||||
const ids = new Set<number>();
|
||||
if (Array.isArray(raw)) {
|
||||
for (const v of raw) {
|
||||
@@ -43,13 +71,43 @@ function readMuted(): ReadonlySet<number> {
|
||||
if (typeof v === "number" && Number.isInteger(v) && v > 0) ids.add(v);
|
||||
}
|
||||
}
|
||||
cache = ids;
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Whether a raw localStorage entry exists at all under `key` (prefixed) —
|
||||
* as opposed to `loadPref`'s fallback, which can't distinguish "absent" from
|
||||
* "present but happens to equal the fallback". An empty saved mute list is
|
||||
* real data (the user unmuted everything) and must not be treated as a miss. */
|
||||
function keyExists(key: string): boolean {
|
||||
return localStorage.getItem(STORAGE_PREFIX + key) !== null;
|
||||
}
|
||||
|
||||
function readMuted(): ReadonlySet<number> {
|
||||
if (cache !== null) return cache;
|
||||
|
||||
const scopedKey = mutedKey();
|
||||
if (currentHost === null || keyExists(scopedKey)) {
|
||||
cache = parseMutedIds(loadPref<unknown[]>(scopedKey, []));
|
||||
return cache;
|
||||
}
|
||||
|
||||
// Miss at the scoped key: read through to the pre-scoping legacy key once
|
||||
// and persist the result under the scoped key so the read-through isn't
|
||||
// repeated. A different host with its OWN explicit (even empty) mute list
|
||||
// is not touched by this — it never reaches this branch.
|
||||
if (keyExists(MUTED_KEY)) {
|
||||
const legacy = parseMutedIds(loadPref<unknown[]>(MUTED_KEY, []));
|
||||
writeMuted(legacy);
|
||||
return legacy;
|
||||
}
|
||||
|
||||
cache = new Set();
|
||||
return cache;
|
||||
}
|
||||
|
||||
function writeMuted(ids: ReadonlySet<number>): void {
|
||||
cache = ids;
|
||||
savePref(MUTED_KEY, [...ids]);
|
||||
savePref(mutedKey(), [...ids]);
|
||||
}
|
||||
|
||||
/** Drop the cached parse. Exported for tests and for logout. */
|
||||
@@ -60,7 +118,7 @@ export function invalidateMuteCache(): void {
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("owncord:pref-change", (e) => {
|
||||
const detail = (e as CustomEvent<{ key?: string }>).detail;
|
||||
if (detail?.key === MUTED_KEY) invalidateMuteCache();
|
||||
if (detail?.key === mutedKey()) invalidateMuteCache();
|
||||
});
|
||||
// Cross-tab: the native storage event fires only in the *other* tab.
|
||||
window.addEventListener("storage", () => invalidateMuteCache());
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { setActiveChannel, clearUnread, channelsStore } from "@stores/channels.store";
|
||||
import { clearDmUnread } from "@stores/dm.store";
|
||||
|
||||
/**
|
||||
* Activate `channelId`, clearing its unread and mention badges.
|
||||
@@ -17,6 +18,13 @@ export function navigateToChannel(channelId: number): void {
|
||||
if (!channelsStore.getState().channels.has(channelId)) return;
|
||||
setActiveChannel(channelId);
|
||||
clearUnread(channelId);
|
||||
// findChannelById does not filter out DM mirrors, so a jump (permalink,
|
||||
// search, pinned, reply) can land on a `type: "dm"` channel. Its unread
|
||||
// badge lives in dmStore, not channelsStore — clearUnread alone leaves the
|
||||
// DM sidebar row lit while the user is reading it. No-op for a non-DM id
|
||||
// (dmStore has no matching channel), mirroring markChannelRead's dual
|
||||
// clear (read-state.ts).
|
||||
clearDmUnread(channelId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
*/
|
||||
|
||||
import { createLogger } from "./logger";
|
||||
import { authStore } from "@stores/auth.store";
|
||||
|
||||
const log = createLogger("credentials");
|
||||
|
||||
export interface SavedCredential {
|
||||
readonly username: string;
|
||||
readonly token: string;
|
||||
// Note: password is no longer returned from the Rust backend over IPC
|
||||
// to limit credential exposure in the JS heap.
|
||||
readonly password?: string;
|
||||
}
|
||||
|
||||
/** Dynamically import Tauri invoke to avoid errors in test/browser. */
|
||||
@@ -50,6 +50,31 @@ export async function saveCredential(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `user_update` listener that refreshes a session's stored
|
||||
* credential when the local user's own profile changes (a username edit, or
|
||||
* the identity-key PATCH) — mirroring the initial saveCredential call's
|
||||
* remember-password opt-out (BUG-135) so a later profile edit can't silently
|
||||
* persist a bearer token the user declined to store. Passes the session's
|
||||
* password through on every call: save_credential replaces the whole stored
|
||||
* blob, so omitting it (defaulting to null) would wipe out the password
|
||||
* saved at login for a user who DID opt in.
|
||||
*/
|
||||
export function createUserUpdateCredentialSaver(
|
||||
host: string,
|
||||
rememberPassword: boolean,
|
||||
password: string | undefined,
|
||||
): (payload: { readonly user_id: number; readonly username: string }) => void {
|
||||
return (payload) => {
|
||||
if (!rememberPassword) return;
|
||||
const currentUserId = authStore.getState().user?.id ?? 0;
|
||||
if (payload.user_id !== currentUserId) return;
|
||||
const currentToken = authStore.getState().token;
|
||||
if (!currentToken) return;
|
||||
void saveCredential(host, payload.username, currentToken, password);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a credential from Windows Credential Manager.
|
||||
* Returns null if not found or Tauri unavailable.
|
||||
@@ -67,6 +92,7 @@ export async function loadCredential(host: string): Promise<SavedCredential | nu
|
||||
return {
|
||||
username: cred.username,
|
||||
token: cred.token,
|
||||
password: typeof cred.password === "string" ? cred.password : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Monitors navigator.mediaDevices.ondevicechange for hot-swap (unplug/plug).
|
||||
|
||||
import { Room } from "livekit-client";
|
||||
import { voiceStore } from "@stores/voice.store";
|
||||
import { loadPref, savePref } from "@components/settings/helpers";
|
||||
import { createLogger } from "@lib/logger";
|
||||
import type { AudioPipeline } from "@lib/audioPipeline";
|
||||
@@ -14,6 +15,25 @@ const log = createLogger("deviceManager");
|
||||
/** Debounce interval for device change events (ms). */
|
||||
const DEVICE_CHANGE_DEBOUNCE_MS = 500;
|
||||
|
||||
/** True when a mute/deafen/server-mute/push-to-talk gate means the mic must
|
||||
* stay off regardless of a caller's own request to (re-)enable it.
|
||||
* livekit-client's setMicrophoneEnabled(true) is a bare track.unmute() when
|
||||
* a muted-but-published track survives a toggle (only ScreenShare actually
|
||||
* unpublishes) — no LocalTrackPublished/TrackUnmuted event fires for
|
||||
* anything downstream to catch and correct, so every re-enable path has to
|
||||
* check this itself instead of relying on one. Exported so LiveKitSession's
|
||||
* own re-enable paths (setDeafened's unmute branch, retryMicPermission)
|
||||
* share the same gate instead of each re-deriving it. */
|
||||
export function isMicPolicyGated(): boolean {
|
||||
const s = voiceStore.getState();
|
||||
return (
|
||||
s.localMuted === true ||
|
||||
s.localDeafened === true ||
|
||||
s.localServerMuted === true ||
|
||||
s.pttGated === true
|
||||
);
|
||||
}
|
||||
|
||||
export class DeviceManager {
|
||||
private room: Room | null = null;
|
||||
private audioPipeline: AudioPipeline | null = null;
|
||||
@@ -43,6 +63,21 @@ export class DeviceManager {
|
||||
this.onToast = cb;
|
||||
}
|
||||
|
||||
/** Toggle the mic off/on to force a fresh capture after a device change,
|
||||
* skipping the re-enable when a mute/deafen/server-mute/PTT gate is
|
||||
* active. Shared by handleDeviceChange's device-removed fallback and
|
||||
* switchInputDevice('') — both drive the exact same false/true cycle, and
|
||||
* both were unconditionally republishing a gated mic before this guard. */
|
||||
private async cycleMicForDeviceSwitch(room: Room): Promise<void> {
|
||||
await room.localParticipant.setMicrophoneEnabled(false);
|
||||
if (this.room !== room) return;
|
||||
if (isMicPolicyGated()) {
|
||||
log.debug("Skipping mic re-enable after device switch — muted/deafened/gated");
|
||||
return;
|
||||
}
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
}
|
||||
|
||||
// --- Device change detection (hot-swap) ---
|
||||
|
||||
private startDeviceChangeListener(): void {
|
||||
@@ -70,11 +105,19 @@ export class DeviceManager {
|
||||
}
|
||||
|
||||
private async handleDeviceChange(): Promise<void> {
|
||||
if (this.room === null) return;
|
||||
// Snapshot the room this attempt started for. `this.room` is a mutable
|
||||
// field that a system-driven reconnect (or session teardown) can
|
||||
// reassign out from under an in-flight await below — re-reading it
|
||||
// after each await would apply the fallback to the wrong Room, or throw
|
||||
// a null-deref that surfaces as a misleading "No audio input device
|
||||
// available" error after the user already left voice (v096).
|
||||
const room = this.room;
|
||||
if (room === null) return;
|
||||
log.info("Device change detected");
|
||||
|
||||
try {
|
||||
const devices = await Room.getLocalDevices("audioinput");
|
||||
if (this.room !== room) return;
|
||||
const savedInput = loadPref<string>("audioInputDevice", "");
|
||||
|
||||
// Check if the saved input device was removed
|
||||
@@ -84,8 +127,8 @@ export class DeviceManager {
|
||||
savePref("audioInputDevice", "");
|
||||
// Switch to default device
|
||||
try {
|
||||
await this.room.localParticipant.setMicrophoneEnabled(false);
|
||||
await this.room.localParticipant.setMicrophoneEnabled(true);
|
||||
await this.cycleMicForDeviceSwitch(room);
|
||||
if (this.room !== room) return;
|
||||
try {
|
||||
this.audioPipeline?.setupAudioPipeline();
|
||||
} catch (pipelineErr) {
|
||||
@@ -94,6 +137,7 @@ export class DeviceManager {
|
||||
}
|
||||
this.onToast?.("Audio device disconnected — switched to default");
|
||||
} catch (err) {
|
||||
if (this.room !== room) return;
|
||||
log.error("Failed to fallback to default input device", err);
|
||||
this.onErrorCallback?.("No audio input device available");
|
||||
}
|
||||
@@ -101,6 +145,7 @@ export class DeviceManager {
|
||||
|
||||
// Check output device
|
||||
const outputDevices = await Room.getLocalDevices("audiooutput");
|
||||
if (this.room !== room) return;
|
||||
const savedOutput = loadPref<string>("audioOutputDevice", "");
|
||||
if (savedOutput !== "" && !outputDevices.some((d) => d.deviceId === savedOutput)) {
|
||||
log.warn("Saved audio output device removed — falling back to default", { savedOutput });
|
||||
@@ -113,17 +158,18 @@ export class DeviceManager {
|
||||
}
|
||||
|
||||
async switchInputDevice(deviceId: string): Promise<void> {
|
||||
if (this.room === null) {
|
||||
const room = this.room;
|
||||
if (room === null) {
|
||||
log.debug("Skipping input device switch — no active voice session");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (deviceId) {
|
||||
await this.room.switchActiveDevice("audioinput", deviceId);
|
||||
await room.switchActiveDevice("audioinput", deviceId);
|
||||
} else {
|
||||
await this.room.localParticipant.setMicrophoneEnabled(false);
|
||||
await this.room.localParticipant.setMicrophoneEnabled(true);
|
||||
await this.cycleMicForDeviceSwitch(room);
|
||||
}
|
||||
if (this.room !== room) return;
|
||||
// Rebuild audio pipeline (source track changed after device switch)
|
||||
try {
|
||||
this.audioPipeline?.setupAudioPipeline();
|
||||
@@ -140,13 +186,15 @@ export class DeviceManager {
|
||||
}
|
||||
log.info("Switched input device", { deviceId });
|
||||
} catch (err) {
|
||||
if (this.room !== room) return;
|
||||
log.error("Failed to switch input device", err);
|
||||
this.onErrorCallback?.("Failed to switch microphone");
|
||||
}
|
||||
}
|
||||
|
||||
async switchOutputDevice(deviceId: string): Promise<void> {
|
||||
if (this.room === null) {
|
||||
const room = this.room;
|
||||
if (room === null) {
|
||||
log.debug("Skipping output device switch — no active voice session");
|
||||
return;
|
||||
}
|
||||
@@ -155,9 +203,11 @@ export class DeviceManager {
|
||||
// so an unhandled rejection would leave the user staring at a selection
|
||||
// that never took effect.
|
||||
try {
|
||||
await this.room.switchActiveDevice("audiooutput", deviceId);
|
||||
await room.switchActiveDevice("audiooutput", deviceId);
|
||||
if (this.room !== room) return;
|
||||
log.info("Switched output device", { deviceId });
|
||||
} catch (err) {
|
||||
if (this.room !== room) return;
|
||||
log.error("Failed to switch output device", err);
|
||||
this.onErrorCallback?.("Failed to switch speaker");
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
// Each server message type maps to one or more store actions.
|
||||
|
||||
import type { WsClient } from "./ws";
|
||||
import { toConnectionStatus } from "./ws";
|
||||
import { authStore, setAuth, clearAuth } from "@stores/auth.store";
|
||||
import { toConnectionStatus, setActiveChannelProvider } from "./ws";
|
||||
import { authStore, setAuth, clearAuth, updateUser } from "@stores/auth.store";
|
||||
import { setTransientError, setConnectionStatus } from "@stores/ui.store";
|
||||
import {
|
||||
setChannels,
|
||||
@@ -23,9 +23,13 @@ import {
|
||||
deleteMessage,
|
||||
bulkDeleteMessages,
|
||||
updateReaction,
|
||||
rollbackReaction,
|
||||
confirmSend,
|
||||
markSendFailed,
|
||||
messagesStore,
|
||||
setMessages,
|
||||
invalidateLoadedMessageWindows,
|
||||
setChannelLoadError,
|
||||
} from "@stores/messages.store";
|
||||
import {
|
||||
setMembers,
|
||||
@@ -50,10 +54,12 @@ import {
|
||||
dmStore,
|
||||
setDmChannels,
|
||||
addDmChannel,
|
||||
removeDmChannel,
|
||||
closeDmLocally,
|
||||
updateDmLastMessage,
|
||||
updateDmLastMessagePreview,
|
||||
incrementDmMention,
|
||||
dmDisplayName,
|
||||
updateDmParticipant,
|
||||
} from "@stores/dm.store";
|
||||
import type { DmChannel } from "@stores/dm.store";
|
||||
import { setBlockedByMe, setUserBlockedByThem, clearBlockedByThem } from "@stores/blocks.store";
|
||||
@@ -64,9 +70,15 @@ import { invalidateReactionUsers } from "@components/message-list/reaction-toolt
|
||||
import { notifyIncomingMessage } from "./notifications";
|
||||
import { highlightsCurrentUser } from "./mentions";
|
||||
import { ensureIdentityKeyPublished } from "@lib/identity";
|
||||
import { markChannelRead } from "./read-state";
|
||||
import { createLogger } from "./logger";
|
||||
import { showToast } from "./toast";
|
||||
import { ServerMessageType as S } from "./protocolTypes";
|
||||
// SidebarDmHelpers is page-level, but addDmToChannelsStore is the only
|
||||
// place the DM->channelsStore mirror row is synthesized (selectDmConversation
|
||||
// on open); the dm_channel_close fallback below needs the same synthesis for
|
||||
// a DM it is activating that was never opened this session.
|
||||
import { addDmToChannelsStore } from "@pages/main-page/SidebarDmHelpers";
|
||||
|
||||
const log = createLogger("dispatcher");
|
||||
|
||||
@@ -126,20 +138,74 @@ export function wireConnectionStatus(ws: Pick<WsClient, "onStateChange">): () =>
|
||||
* Returns a cleanup function that removes all listeners.
|
||||
*
|
||||
* `api` is optional so tests can wire the dispatcher without a client; when
|
||||
* present it is used to refresh DM block state (GET /blocks) on ready.
|
||||
* present it is used to refresh DM block state (GET /blocks) on ready, and to
|
||||
* refetch the active channel's history after a full-ready resync.
|
||||
*/
|
||||
export function wireDispatcher(
|
||||
ws: WsClient,
|
||||
api?: Pick<ApiClient, "listBlocks"> &
|
||||
Partial<Pick<ApiClient, "updateProfile" | "getConfig" | "listEmoji">>,
|
||||
Partial<Pick<ApiClient, "updateProfile" | "getConfig" | "listEmoji" | "getMessages">>,
|
||||
): DispatcherCleanup {
|
||||
const unsubs: Array<() => void> = [];
|
||||
|
||||
// A second (or later) auth_ok/ready in this call's lifetime is always a
|
||||
// reconnect: wireDispatcher is called once per login (main.ts's
|
||||
// wirePostAuth), and every automatic reconnect fires its events through
|
||||
// these same long-lived listeners. Closure-scoped so a fresh login (a new
|
||||
// wireDispatcher call) always starts clean.
|
||||
let hasAuthenticatedBefore = false;
|
||||
let hasReceivedReadyBefore = false;
|
||||
// Set from the second-or-later auth_ok — the reconnect handshake time, in
|
||||
// THIS CLIENT's clock. A chat_message replay frame the transport delivers
|
||||
// after it is timestamped *before* it; a genuinely live message is
|
||||
// timestamped after. But payload.timestamp is the SERVER's created_at, in
|
||||
// the SERVER's clock — comparing it to this anchor directly mixes clock
|
||||
// domains, so the comparison below shifts the anchor into server time
|
||||
// using serverClockSkewMs first (see its declaration below).
|
||||
let lastReconnectHandshakeAt: number | null = null;
|
||||
// Running estimate of (this client's clock) minus (the server's clock),
|
||||
// sampled from the most recently accepted live chat_message (Date.now() at
|
||||
// receipt minus that frame's own server timestamp). A self-hosted server
|
||||
// routinely runs without NTP or with a skewed TZ/clock, and comparing its
|
||||
// timestamps against lastReconnectHandshakeAt without this correction means
|
||||
// a lagging server clock makes every genuinely live message look like a
|
||||
// replay for the whole drift window after every reconnect — and with
|
||||
// persistent skew that never recovers. Network latency between the
|
||||
// server's send and this receipt biases the estimate positive, which nudges
|
||||
// the boundary computed below slightly EARLY relative to the server's true
|
||||
// clock; that is the safe direction — a missed replay suppression is at
|
||||
// worst a duplicate notification, while a false replay classification
|
||||
// silently drops one.
|
||||
let serverClockSkewMs = 0;
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────
|
||||
|
||||
// Let the transport declare the open channel in the auth frame itself, so a
|
||||
// resuming server can restore the ChannelTopic subscription during the
|
||||
// handshake rather than only after the channel_focus round trip below —
|
||||
// closing the window in which channel broadcasts reach nobody on this
|
||||
// socket. The round trip stays as the fallback for older servers.
|
||||
setActiveChannelProvider(() => channelsStore.select((s) => s.activeChannelId));
|
||||
unsubs.push(() => setActiveChannelProvider(null));
|
||||
|
||||
unsubs.push(
|
||||
ws.on(S.AUTH_OK, (payload) => {
|
||||
if (hasAuthenticatedBefore) {
|
||||
lastReconnectHandshakeAt = Date.now();
|
||||
}
|
||||
hasAuthenticatedBefore = true;
|
||||
setAuth(authStore.getState().token ?? "", payload.user, payload.server_name, payload.motd);
|
||||
|
||||
// The resume path can land with no ChannelTopic subscription: the hub
|
||||
// only transfers a focused channel from an old connection entry, but
|
||||
// readPump's unregister deletes that entry as soon as the server
|
||||
// observes the socket close — which happens well before the client's
|
||||
// first reconnect attempt. Re-asserting focus here (idempotent on the
|
||||
// server) covers that gap on every connect, resume included.
|
||||
const activeChannelId = channelsStore.select((s) => s.activeChannelId);
|
||||
if (activeChannelId !== null) {
|
||||
ws.send({ type: "channel_focus", payload: { channel_id: activeChannelId } });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -196,19 +262,118 @@ export function wireDispatcher(
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-select the first text channel if none is active
|
||||
// Auto-select the first text channel if none is active; clear it when
|
||||
// the channel this session was viewing is gone from the fresh snapshot
|
||||
// (deleted, or a DM closed elsewhere while this client was offline) so
|
||||
// the activeChannelId subscriber actually fires and tears down the
|
||||
// stale message list/composer instead of leaving them mounted against
|
||||
// a channel the server no longer recognizes. Checked against the raw
|
||||
// payload (not the synthesized channelsStore row) so a still-open DM
|
||||
// that was never locally synthesized this session isn't wrongly
|
||||
// cleared.
|
||||
const currentActive = channelsStore.select((s) => s.activeChannelId);
|
||||
// Set only when the branch below clears a channel that was active
|
||||
// before this ready — distinct from "no channel was active", which
|
||||
// must NOT mark-read whatever the auto-select branch just picked.
|
||||
let activeChannelCleared = false;
|
||||
if (currentActive === null && payload.channels.length > 0) {
|
||||
const firstText = payload.channels.find((ch) => ch.type === "text");
|
||||
if (firstText !== undefined) {
|
||||
setActiveChannel(firstText.id);
|
||||
}
|
||||
} else if (currentActive !== null) {
|
||||
const stillPresent =
|
||||
payload.channels.some((ch) => ch.id === currentActive) ||
|
||||
(payload.dm_channels ?? []).some((dm) => dm.channel_id === currentActive);
|
||||
if (!stillPresent) {
|
||||
setActiveChannel(null);
|
||||
activeChannelCleared = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Populate DM channels if present in the ready payload
|
||||
// A second (or later) `ready` in this dispatcher's lifetime only ever
|
||||
// arrives from a full-ready resync (Server/ws/serve.go: a fresh connect
|
||||
// and a full resync are the only paths that send `ready` at all — a
|
||||
// successful seq-based replay reconnect does not), and that tier never
|
||||
// replays missed chat_message frames. Every channel this session had
|
||||
// already loaded would otherwise keep a permanent, silent hole in its
|
||||
// history — invalidate them and refetch the one actually on screen.
|
||||
if (hasReceivedReadyBefore) {
|
||||
const activeAfterReady = channelsStore.select((s) => s.activeChannelId);
|
||||
const getMessages = api?.getMessages;
|
||||
// Only invalidate when the refetch below can actually happen — api is
|
||||
// a Partial<...>, so getMessages may be absent, and there may be no
|
||||
// resolvable active channel to refetch. Dropping every loaded window
|
||||
// with nothing able to reload it would leave a mounted MessageList
|
||||
// showing only carried-through pending rows until the user navigates
|
||||
// away and back.
|
||||
if (activeAfterReady !== null && getMessages !== undefined) {
|
||||
invalidateLoadedMessageWindows();
|
||||
getMessages(activeAfterReady, { limit: 50 })
|
||||
.then((resp) => setMessages(activeAfterReady, resp.messages, resp.has_more))
|
||||
.catch((err) => {
|
||||
log.warn("Failed to reload message history after resync", { error: String(err) });
|
||||
// The invalidate above already dropped this channel's window,
|
||||
// so a silent catch would leave a mounted MessageList showing
|
||||
// its "no messages yet" welcome state — indistinguishable from
|
||||
// a genuinely empty channel. Route through the same
|
||||
// historyLoadState the normal load path uses so the region
|
||||
// shows the inline error + Retry instead (MessageController's
|
||||
// loadMessages, wired to the Retry button, re-fetches because
|
||||
// invalidate also cleared "loaded").
|
||||
setChannelLoadError(activeAfterReady);
|
||||
});
|
||||
}
|
||||
}
|
||||
hasReceivedReadyBefore = true;
|
||||
|
||||
// Populate DM channels from the ready payload. The server always sends
|
||||
// the field, so an empty array is an authoritative "no open DMs" (all
|
||||
// closed/left on another device) and must clear ghosts from dmStore —
|
||||
// skipping it would let a stale DM survive every reconnect.
|
||||
const dmPayloads = payload.dm_channels ?? [];
|
||||
if (dmPayloads.length > 0) {
|
||||
setDmChannels(dmPayloads.map(mapDmPayload));
|
||||
setDmChannels(dmPayloads.map(mapDmPayload));
|
||||
|
||||
// The channels-store mirror row for a DM (synthesized on open by
|
||||
// addDmToChannelsStore) is deliberately carried across setChannels'
|
||||
// rebuild above, because the ready payload never includes DM rows at
|
||||
// all — but that means a DM closed elsewhere while this client was
|
||||
// offline keeps a phantom row here (closeDmLocally fixes this exact
|
||||
// shape for the live dm_channel_close path; this is its ready-time
|
||||
// equivalent), and a DM read elsewhere keeps a stale unread/mention
|
||||
// count (incrementUnread/incrementMention bump the mirror in parallel
|
||||
// with dmStore once it exists, but only dmStore is restated above).
|
||||
// Reconcile every dm-typed row against the just-restated payload.
|
||||
channelsStore.setState((prev) => {
|
||||
const dmById = new Map(dmPayloads.map((d) => [d.channel_id, d]));
|
||||
const nextChannels = new Map(prev.channels);
|
||||
let changed = false;
|
||||
for (const [id, ch] of prev.channels) {
|
||||
if (ch.type !== "dm") continue;
|
||||
const dm = dmById.get(id);
|
||||
if (dm === undefined) {
|
||||
nextChannels.delete(id);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
const mentionCount = dm.mention_count ?? 0;
|
||||
if (ch.unreadCount !== dm.unread_count || ch.mentionCount !== mentionCount) {
|
||||
nextChannels.set(id, { ...ch, unreadCount: dm.unread_count, mentionCount });
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? { ...prev, channels: nextChannels } : prev;
|
||||
});
|
||||
|
||||
// The server's read_states go stale while a channel stays focused
|
||||
// (channel_focus is sent once per mount, mark_read only from the context
|
||||
// menu), so a full-ready resync restates non-zero unread/mention counts
|
||||
// for the very channel the user is reading. Mark it read: this advances
|
||||
// the server read state and clears the local badges, for server channels
|
||||
// and DMs alike. Skipped on first connect (nothing was active yet) and
|
||||
// when the block above just cleared a channel that's gone.
|
||||
if (currentActive !== null && !activeChannelCleared) {
|
||||
markChannelRead(currentActive);
|
||||
}
|
||||
|
||||
// Refresh DM block state (channels-members-dms.md §3.2). "Being blocked"
|
||||
@@ -268,7 +433,28 @@ export function wireDispatcher(
|
||||
unsubs.push(
|
||||
ws.on(S.DM_CHANNEL_CLOSE, (payload) => {
|
||||
log.info("DM channel closed", { channelId: payload.channel_id });
|
||||
removeDmChannel(payload.channel_id);
|
||||
// Delivered to a device that never ran the local close flow (closed
|
||||
// from another signed-in device) — unlike the sidebar's closeOrLeaveDm,
|
||||
// there is no "channel visited before this DM" to restore, so fall
|
||||
// back to another open DM, else the first text channel.
|
||||
closeDmLocally(payload.channel_id, () => {
|
||||
const remaining = dmStore.getState().channels;
|
||||
if (remaining.length > 0) {
|
||||
// Synthesize the channelsStore mirror row before activating: it is
|
||||
// only ever created by addDmToChannelsStore (on open, via
|
||||
// selectDmConversation), so a DM present in dmStore from `ready`
|
||||
// but never opened this session has none — without this,
|
||||
// activating it lands on an id ChannelController can't resolve and
|
||||
// blanks the chat area with no way to recover.
|
||||
addDmToChannelsStore(remaining[0]!);
|
||||
setActiveChannel(remaining[0]!.channelId);
|
||||
return;
|
||||
}
|
||||
const firstText = [...channelsStore.getState().channels.values()]
|
||||
.filter((ch) => ch.type === "text")
|
||||
.toSorted((a, b) => a.position - b.position)[0];
|
||||
setActiveChannel(firstText?.id ?? null);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -296,15 +482,15 @@ export function wireDispatcher(
|
||||
// DM channel IDs are not in channelsStore (they use dmStore), so
|
||||
// incrementUnread is a no-op for DMs, but the own-message guard is
|
||||
// applied here for defence-in-depth.
|
||||
const isMention = highlightsCurrentUser(payload.content, {
|
||||
mentions: payload.mentions,
|
||||
mentionsEveryone: payload.mentions_everyone,
|
||||
});
|
||||
|
||||
if (payload.channel_id !== activeId && !isOwnMessage && !ws.isReplaying()) {
|
||||
incrementUnread(payload.channel_id);
|
||||
// A mention is an unread too — the mention badge just outranks it.
|
||||
if (
|
||||
highlightsCurrentUser(payload.content, {
|
||||
mentions: payload.mentions,
|
||||
mentionsEveryone: payload.mentions_everyone,
|
||||
})
|
||||
) {
|
||||
if (isMention) {
|
||||
incrementMention(payload.channel_id);
|
||||
}
|
||||
}
|
||||
@@ -323,11 +509,32 @@ export function wireDispatcher(
|
||||
);
|
||||
} else {
|
||||
updateDmLastMessage(payload.channel_id, payload.id, payload.content, payload.timestamp);
|
||||
// The DM badge reads dmStore's mentionCount (mute-immune, rendered
|
||||
// by DmSidebar) — incrementMention above no-ops for DM ids, which
|
||||
// are absent from channelsStore. Same guards as the unread bump.
|
||||
if (isMention) {
|
||||
incrementDmMention(payload.channel_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fire desktop notification, taskbar flash, and sound
|
||||
notifyIncomingMessage(payload);
|
||||
// Fire desktop notification, taskbar flash, and sound — but not for a
|
||||
// reconnect's replayed burst. ws.isReplaying() cannot gate this the way
|
||||
// it gates the unread counter above: ws.ts clears it as soon as auth_ok
|
||||
// is processed, before the replay burst itself even arrives. A replay
|
||||
// frame's timestamp instead predates the reconnect handshake that
|
||||
// preceded it, unlike a genuinely new live message — compared in
|
||||
// server-clock terms (see serverClockSkewMs above) so a lagging or
|
||||
// skewed server clock cannot make a live message look like a replay.
|
||||
const isReplayFrame =
|
||||
lastReconnectHandshakeAt !== null &&
|
||||
Date.parse(payload.timestamp) < lastReconnectHandshakeAt - serverClockSkewMs;
|
||||
if (!isReplayFrame) {
|
||||
notifyIncomingMessage(payload);
|
||||
// Refresh the skew estimate from this accepted-as-live frame so it
|
||||
// stays current for the next reconnect.
|
||||
serverClockSkewMs = Date.now() - Date.parse(payload.timestamp);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -385,6 +592,10 @@ export function wireDispatcher(
|
||||
// store treats "field absent" as "leave the text alone", which is what
|
||||
// an older server's presence event means.
|
||||
updatePresence(payload.user_id, payload.status, payload.custom_status);
|
||||
// dmStore keeps its own frozen copy of a DM partner's status for the
|
||||
// sidebar row (see buildDmConversations) — membersStore alone does not
|
||||
// reach it.
|
||||
updateDmParticipant(payload.user_id, { status: payload.status });
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -414,6 +625,9 @@ export function wireDispatcher(
|
||||
.toSorted((a, b) => a.position - b.position);
|
||||
const firstTextId = sorted.length > 0 ? sorted[0]!.id : null;
|
||||
setActiveChannel(firstTextId);
|
||||
// The redirect alone reads as the app spontaneously changing channels;
|
||||
// say why (ux/channels-members-dms §1.2).
|
||||
showToast("This channel was deleted", "info");
|
||||
log.info("Active channel deleted, redirected", { deletedId: payload.id });
|
||||
}
|
||||
}),
|
||||
@@ -446,6 +660,16 @@ export function wireDispatcher(
|
||||
ws.on(S.MEMBER_UPDATE, (payload) => {
|
||||
log.info("Member role updated", { userId: payload.user_id, role: payload.role });
|
||||
updateMemberRole(payload.user_id, payload.role);
|
||||
|
||||
// Keep authStore in sync when the signed-in user's own role changed —
|
||||
// every permission gate (canManageChannels, canViewAuditLog, ...) reads
|
||||
// authStore.user.role, not membersStore, so without this a promotion or
|
||||
// demotion of the current user would leave every affordance stale until
|
||||
// the socket reconnects (mirrors the USER_UPDATE self-branch below).
|
||||
const me = authStore.getState().user;
|
||||
if (me && payload.user_id === me.id) {
|
||||
updateUser({ role: payload.role });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -479,6 +703,17 @@ export function wireDispatcher(
|
||||
displayName: payload.display_name,
|
||||
identityPublicKey: payload.identity_public_key,
|
||||
});
|
||||
// Same reasoning as PRESENCE above: dmStore's copy of a DM partner's
|
||||
// username/avatar/displayName is otherwise never refreshed. DmUser's
|
||||
// avatar/displayName are non-nullable ("" = unset), so null (cleared)
|
||||
// maps to "". display_name absent means "leave the nickname alone" —
|
||||
// an older or partial payload must not blank it, exactly as
|
||||
// updateMemberProfile above.
|
||||
updateDmParticipant(payload.user_id, {
|
||||
username: payload.username,
|
||||
avatar: payload.avatar ?? "",
|
||||
...(payload.display_name === undefined ? {} : { displayName: payload.display_name ?? "" }),
|
||||
});
|
||||
|
||||
// Update auth store if the current user changed their own profile.
|
||||
const currentUser = authStore.getState().user;
|
||||
@@ -555,13 +790,26 @@ export function wireDispatcher(
|
||||
unsubs.push(
|
||||
ws.on(S.VOICE_LEAVE, (payload) => {
|
||||
removeVoiceUser(payload);
|
||||
// Notify E2EE state machine so key holder can rotate the room key.
|
||||
void livekitSession().then(({ handleParticipantLeft }) =>
|
||||
handleParticipantLeft(payload.user_id),
|
||||
);
|
||||
// Clear local voice state if the current user was removed (kick/disconnect)
|
||||
const currentUserId = authStore.getState().user?.id ?? 0;
|
||||
if (payload.user_id === currentUserId) {
|
||||
const isSelf = payload.user_id === currentUserId;
|
||||
// A server-initiated eviction (revocation sweep, channel delete) has no
|
||||
// companion teardown message — this voice_leave IS the signal that
|
||||
// drives our own LiveKit/E2EE teardown, or mic publish and key material
|
||||
// stay live while the UI shows not-in-voice. Guard on channel match: a
|
||||
// late-arriving voice_leave for a channel we already left (and rejoined
|
||||
// elsewhere) must not kill a newer join. Read the store before
|
||||
// leaveVoiceChannel() below clears currentChannelId.
|
||||
const shouldTeardownSession =
|
||||
isSelf && voiceStore.getState().currentChannelId === payload.channel_id;
|
||||
// Notify E2EE state machine so key holder can rotate the room key, and
|
||||
// (when applicable) tear down the media session — both through one lazy
|
||||
// import so the two effects cannot land in different ticks.
|
||||
void livekitSession().then(({ handleParticipantLeft, leaveVoice }) => {
|
||||
void handleParticipantLeft(payload.user_id);
|
||||
if (shouldTeardownSession) void leaveVoice(false);
|
||||
});
|
||||
// Clear local voice state if the current user was removed (kick/disconnect)
|
||||
if (isSelf) {
|
||||
leaveVoiceChannel();
|
||||
}
|
||||
}),
|
||||
@@ -637,13 +885,38 @@ export function wireDispatcher(
|
||||
|
||||
// Local transport failures (proxy not open, outbound channel full/closed):
|
||||
// fail the matching optimistic row exactly like a server error reply would.
|
||||
// Fire-and-forget sends (typing, presence, voice) have no pendingSends entry
|
||||
// and stay logged-only.
|
||||
// An optimistic reaction toggle rolls back the same way. Fire-and-forget
|
||||
// sends (typing, presence, voice) have no pending entry and stay logged-only.
|
||||
// A connection that leaves "connected" can never deliver chat_send_ok for
|
||||
// frames already handed to the transport: fail every pending optimistic
|
||||
// send so its row offers retry instead of spinning forever (and the leaked
|
||||
// pendingSends entries are cleared).
|
||||
unsubs.push(
|
||||
ws.onStateChange((state) => {
|
||||
if (state !== "reconnecting" && state !== "disconnected") return;
|
||||
// Snapshot the ids: markSendFailed deletes from pendingSends, so
|
||||
// iterating the live Map's keys would mutate during iteration.
|
||||
for (const id of Array.from(messagesStore.getState().pendingSends.keys())) {
|
||||
markSendFailed(id, "OFFLINE");
|
||||
}
|
||||
// Same reasoning applies to optimistic reaction toggles: a reaction
|
||||
// frame already handed to a dying socket can never deliver its
|
||||
// chat_send_ok/error either, so roll back every pending toggle instead
|
||||
// of leaving a permanently wrong pill and a stale pendingReactions
|
||||
// entry that could later consume an unrelated self-echo.
|
||||
for (const id of Array.from(messagesStore.getState().pendingReactions?.keys() ?? [])) {
|
||||
rollbackReaction(id);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.onSendFailure((id, code) => {
|
||||
if (messagesStore.getState().pendingSends.has(id)) {
|
||||
markSendFailed(id, code);
|
||||
return;
|
||||
}
|
||||
rollbackReaction(id);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -675,11 +948,20 @@ export function wireDispatcher(
|
||||
chId === undefined
|
||||
? undefined
|
||||
: dmStore.getState().channels.find((c) => c.channelId === chId);
|
||||
if (dm !== undefined) setUserBlockedByThem(dm.recipient.id, true);
|
||||
// Block gating is a 1:1-only rule (server exempts group DMs from
|
||||
// block checks entirely — a group FORBIDDEN means something else,
|
||||
// e.g. stale membership). recipient is just participants[0] for a
|
||||
// group, so flagging it there would gate an unrelated 1:1 DM.
|
||||
if (dm !== undefined && !dm.isGroup) setUserBlockedByThem(dm.recipient.id, true);
|
||||
}
|
||||
markSendFailed(id, payload.code);
|
||||
return;
|
||||
}
|
||||
// A failed optimistic reaction toggle: the pill reverting is the
|
||||
// feedback the spec asks for (ux/messaging §5) — no toast on top.
|
||||
if (id !== undefined && rollbackReaction(id)) {
|
||||
return;
|
||||
}
|
||||
// Voice capacity refusals. The server owns the limits (voice_max_users /
|
||||
// voice_max_video) and refuses the join or the camera; the client never
|
||||
// pre-blocks the click, because its copy of the participant list can lag
|
||||
@@ -688,14 +970,54 @@ export function wireDispatcher(
|
||||
// with an explanation buried in the log.
|
||||
if (payload.code === "CHANNEL_FULL") {
|
||||
showToast(payload.message || "That voice channel is full", "error");
|
||||
// The sidebar/widget optimistically writes currentChannelId before
|
||||
// the server answers (VoiceCallbacks.onVoiceJoin). A first-time join
|
||||
// refusal earns no voice_leave (there was no previous channel to
|
||||
// leave), so nothing else clears that optimistic state — the sidebar
|
||||
// is left keyed on a channel with no LiveKit session. A channel
|
||||
// *switch* refusal doesn't need this: the server always leaves the
|
||||
// old channel first, whose self voice_leave already reset
|
||||
// voiceStatus to idle before this error arrives, so the guard is a
|
||||
// no-op there.
|
||||
if (voiceStore.getState().voiceStatus === "joining") {
|
||||
leaveVoiceChannel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (payload.code === "VIDEO_LIMIT") {
|
||||
showToast(payload.message || "That voice channel has reached its video limit", "error");
|
||||
// max_video has no SFU-level enforcement — the server only refuses the
|
||||
// DB write. Without this rollback the already-published camera track
|
||||
// keeps streaming to everyone while voice_state says camera=false.
|
||||
void livekitSession().then(({ disableCamera }) => disableCamera());
|
||||
return;
|
||||
}
|
||||
if (payload.code === "RATE_LIMITED" || payload.code === "FORBIDDEN") {
|
||||
setTransientError(payload.message || "Server error");
|
||||
// Every remaining code has no dedicated handler above (not a pending
|
||||
// send/reaction rollback, not a capacity refusal) — this is the one
|
||||
// place every remaining server error lands (a rejected fire-and-forget
|
||||
// chat_edit, for one), so it must not be silently dropped just because
|
||||
// it isn't RATE_LIMITED/FORBIDDEN. Set synchronously, independent of
|
||||
// the video-rollback lookup below: both paths produce this exact same
|
||||
// message, so there is nothing left to gate on that lookup resolving.
|
||||
setTransientError(payload.message || "Server error");
|
||||
|
||||
// A server refusal of a voice_camera/voice_screenshare enable other
|
||||
// than VIDEO_LIMIT (FORBIDDEN, RATE_LIMITED, INTERNAL, ...): roll back
|
||||
// the already-published track, or it keeps streaming to every peer
|
||||
// while the store says it's off. Correlated by envelope id — exactly
|
||||
// like pendingSends/pendingReactions above — so an unrelated
|
||||
// FORBIDDEN/RATE_LIMITED on some other action never touches video
|
||||
// state. screenShare.ts pulls in livekit-client at module scope, so —
|
||||
// like livekitSession() above — it's loaded lazily here too, at its
|
||||
// one call site in this file.
|
||||
if (id !== undefined) {
|
||||
void import("@lib/screenShare").then(({ rollbackPendingVideo }) => {
|
||||
const kind = rollbackPendingVideo(id);
|
||||
if (kind === undefined) return;
|
||||
void livekitSession().then(({ disableCamera, disableScreenshare }) =>
|
||||
kind === "camera" ? disableCamera() : disableScreenshare(),
|
||||
);
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -15,26 +15,28 @@ import { createLogger } from "./logger";
|
||||
|
||||
const log = createLogger("http-proxy");
|
||||
|
||||
/** host → resolved loopback origin (e.g. "http://127.0.0.1:49812"). */
|
||||
const origins = new Map<string, string>();
|
||||
/** host → in-flight start so concurrent callers don't race the tunnel. */
|
||||
const pending = new Map<string, Promise<string>>();
|
||||
|
||||
/**
|
||||
* Ensure a tunnel exists for `host` and return its loopback origin
|
||||
* (no trailing slash). Idempotent and concurrency-safe per host.
|
||||
* (no trailing slash). Concurrency-safe per host.
|
||||
*
|
||||
* Always invokes start_http_proxy — never caches the resolved origin here.
|
||||
* Only the Rust side knows whether its listener is still alive: after 5
|
||||
* consecutive accept errors run_proxy_loop deregisters itself so the next
|
||||
* start_http_proxy rebinds a fresh port (http_proxy.rs). A JS-side cache
|
||||
* would keep pointing every REST call at that dead tunnel until app restart.
|
||||
* The Rust reuse branch dedups an unchanged host cheaply, so the repeat
|
||||
* invoke is inexpensive — mirroring livekitSession.ts's ensureLiveKitProxy.
|
||||
*/
|
||||
export async function ensureHttpProxy(host: string): Promise<string> {
|
||||
const cached = origins.get(host);
|
||||
if (cached) return cached;
|
||||
|
||||
const inFlight = pending.get(host);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
const start = (async () => {
|
||||
const port = await invoke<number>("start_http_proxy", { remoteHost: host });
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
origins.set(host, origin);
|
||||
log.debug("tunnel ready", { host, origin });
|
||||
return origin;
|
||||
})();
|
||||
@@ -47,9 +49,8 @@ export async function ensureHttpProxy(host: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop the tunnel for `host` and drop its cached origin (best-effort). */
|
||||
/** Stop the tunnel for `host` (best-effort). */
|
||||
export async function stopHttpProxy(host: string): Promise<void> {
|
||||
origins.delete(host);
|
||||
pending.delete(host);
|
||||
try {
|
||||
await invoke("stop_http_proxy", { remoteHost: host });
|
||||
|
||||
@@ -65,6 +65,7 @@ export type IconName =
|
||||
| "shield"
|
||||
| "shield-check"
|
||||
| "shield-alert"
|
||||
| "shield-question"
|
||||
| "zap";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -216,6 +217,7 @@ const ICON_PATHS: Record<IconName, string> = {
|
||||
shield: `<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>`,
|
||||
"shield-check": `<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>`,
|
||||
"shield-alert": `<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="M12 8v4"/><path d="M12 16h.01"/>`,
|
||||
"shield-question": `<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="M9.1 9a3 3 0 0 1 5.82 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>`,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
* layer (F3). Mirrors credentials.ts: dynamically imports Tauri `invoke` and
|
||||
* no-ops in non-Tauri environments (tests, browser).
|
||||
*
|
||||
* Two backing stores, both keyed by connection host:
|
||||
* - OS keyring (save/load/delete_identity_key, account `identity:{host}`):
|
||||
* the client's own long-term identity PRIVATE key (base64 JWK blob).
|
||||
* Two backing stores:
|
||||
* - OS keyring (save/load/delete_identity_key, account `identity:{host}:{uid}`):
|
||||
* the client's own long-term identity PRIVATE key (base64 JWK blob),
|
||||
* scoped by host AND user id (see `identityKeyPairCache` below — two
|
||||
* accounts must never share one identity keypair).
|
||||
* - identity_pins.json (store/get_identity_pin, key `{host}:{userId}`):
|
||||
* peers' pinned identity PUBLIC keys (base64), for TOFU verification.
|
||||
*/
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
generateIdentityKeyPair,
|
||||
importIdentityKeyPair,
|
||||
} from "./e2eeCrypto";
|
||||
import { authStore } from "@stores/auth.store";
|
||||
|
||||
const log = createLogger("identity");
|
||||
|
||||
@@ -50,7 +53,19 @@ export async function saveIdentityKey(host: string, key: string): Promise<boolea
|
||||
}
|
||||
}
|
||||
|
||||
/** Load the identity private-key blob for a host, or null if absent/unavailable. */
|
||||
/**
|
||||
* Load the identity private-key blob for a host, or null when nothing is
|
||||
* stored (a clean `load_identity_key` resolution with no value).
|
||||
*
|
||||
* A command REJECTION is rethrown, not swallowed to null: `secret_store::get`
|
||||
* on the Rust side reports `Ok(None)` only when both the keyring and the
|
||||
* fallback file genuinely hold nothing, and propagates a keyring read error
|
||||
* as `Err` instead. A rejection here is therefore a real, unreadable store —
|
||||
* not "nothing stored". Callers (see `loadOrGenerateIdentityKeyPair`) rely on
|
||||
* that distinction to abort instead of minting and publishing a fresh
|
||||
* identity keypair over an existing one, which would invalidate every peer's
|
||||
* TOFU pin.
|
||||
*/
|
||||
export async function loadIdentityKey(host: string): Promise<string | null> {
|
||||
const invoke = await getInvoke();
|
||||
if (!invoke) {
|
||||
@@ -60,8 +75,12 @@ export async function loadIdentityKey(host: string): Promise<string | null> {
|
||||
const result = await invoke("load_identity_key", { host });
|
||||
return typeof result === "string" ? result : null;
|
||||
} catch (err) {
|
||||
log.error("Failed to load identity key", { host, error: String(err) });
|
||||
return null;
|
||||
log.error(
|
||||
"Failed to load identity key — propagating so the caller does not treat an unreadable " +
|
||||
'store as "no key stored"',
|
||||
{ host, error: String(err) },
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,45 +101,92 @@ export async function deleteIdentityKey(host: string): Promise<boolean> {
|
||||
|
||||
// ── Peer identity pins (identity_pins.json, TOFU) ──────────────────────────
|
||||
|
||||
/**
|
||||
* Result of a peer identity-pin write. Mirrors IdentityPinLookup's tri-state
|
||||
* split: "no-store" (non-Tauri environment, no pin store by design) and
|
||||
* "failed" (a real write error, e.g. disk full / unwritable pins file) are
|
||||
* both falsy under a plain boolean, but callers that display a "verified"
|
||||
* state on the strength of a pin write must be able to tell them apart —
|
||||
* collapsing them let a write failure be silently treated the same as the
|
||||
* no-store case and still show "verified" with no pin ever persisted.
|
||||
*/
|
||||
export type StoreIdentityPinResult = "stored" | "no-store" | "failed";
|
||||
|
||||
/** Pin a peer's identity public key (base64) under `{host}:{userId}`. */
|
||||
export async function storeIdentityPin(
|
||||
host: string,
|
||||
userId: string,
|
||||
pin: string,
|
||||
): Promise<boolean> {
|
||||
): Promise<StoreIdentityPinResult> {
|
||||
const invoke = await getInvoke();
|
||||
if (!invoke) {
|
||||
log.warn("Tauri not available — identity pin not stored");
|
||||
return false;
|
||||
return "no-store";
|
||||
}
|
||||
try {
|
||||
await invoke("store_identity_pin", { host, userId, pin });
|
||||
return true;
|
||||
return "stored";
|
||||
} catch (err) {
|
||||
log.error("Failed to store identity pin", { host, userId, error: String(err) });
|
||||
return false;
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
|
||||
/** Load a peer's pinned identity public key, or null if never pinned. */
|
||||
export async function getIdentityPin(host: string, userId: string): Promise<string | null> {
|
||||
/**
|
||||
* Result of a peer identity-pin lookup. "unpinned" is a trust statement —
|
||||
* the store was read and holds nothing for this peer (TOFU first sight) —
|
||||
* while "unavailable" means the store could not be read at all, so NO trust
|
||||
* statement can be made. Mirrors the Rust TLS-TOFU split (tofu.rs), where
|
||||
* `load_stored_fingerprint` returns `Err` distinctly from `Ok(None)`.
|
||||
*/
|
||||
export type IdentityPinLookup =
|
||||
| { readonly status: "pinned"; readonly pin: string }
|
||||
| { readonly status: "unpinned" }
|
||||
| { readonly status: "unavailable" };
|
||||
|
||||
/**
|
||||
* Look up a peer's pinned identity public key.
|
||||
*
|
||||
* A store read error is returned as "unavailable", NOT "unpinned" (DC-08,
|
||||
* F3 follow-up 3): collapsing the two let a transient keyring error send a
|
||||
* pinned peer down the first-sight path — silently verifying against, and
|
||||
* then re-pinning, whatever key the server delivered. Callers must fail
|
||||
* closed on "unavailable". In non-Tauri environments (tests, browser) there
|
||||
* is no pin store by design, so the result is "unpinned" — consistent with
|
||||
* every other wrapper in this module no-oping there.
|
||||
*/
|
||||
export async function getIdentityPin(host: string, userId: string): Promise<IdentityPinLookup> {
|
||||
const invoke = await getInvoke();
|
||||
if (!invoke) {
|
||||
return null;
|
||||
return { status: "unpinned" };
|
||||
}
|
||||
try {
|
||||
const result = await invoke("get_identity_pin", { host, userId });
|
||||
return typeof result === "string" ? result : null;
|
||||
return typeof result === "string" ? { status: "pinned", pin: result } : { status: "unpinned" };
|
||||
} catch (err) {
|
||||
log.error("Failed to load identity pin", { host, userId, error: String(err) });
|
||||
return null;
|
||||
log.error("Failed to load identity pin — treating as unavailable, not unpinned", {
|
||||
host,
|
||||
userId,
|
||||
error: String(err),
|
||||
});
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
}
|
||||
|
||||
// ── High-level lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One identity keypair per host, shared by every caller in this process.
|
||||
* One identity keypair per host+user, shared by every caller in this process.
|
||||
*
|
||||
* Scoped by BOTH host and user id (B3-3), not host alone: two different
|
||||
* accounts signed into the same host — including two people sharing one OS
|
||||
* profile/keyring, or one client used to log into several accounts on the
|
||||
* same server without a restart — must never share a voice-E2EE identity
|
||||
* keypair. Sharing one would make their announces verify against each
|
||||
* other's TOFU pin, silently defeating the identity model's distinctness
|
||||
* guarantee. (Pre-existing installs mint a fresh per-account keypair the
|
||||
* first time they run this scoping — a one-time re-verify for their peers,
|
||||
* traded for closing the cross-account sharing hole.)
|
||||
*
|
||||
* The keypair has two independent consumers: the ready hook publishes its
|
||||
* public half (`ensureIdentityKeyPublished`) and the voice session signs
|
||||
@@ -138,57 +204,140 @@ export async function getIdentityPin(host: string, userId: string): Promise<stri
|
||||
*/
|
||||
const identityKeyPairCache = new Map<string, Promise<CryptoKeyPair>>();
|
||||
|
||||
/** Composite keyring/memo key scoping the identity keypair by host AND user
|
||||
* id. The keyring commands only take a single opaque `host` string, so the
|
||||
* scope is folded into that one field rather than requiring a Rust-side
|
||||
* change. */
|
||||
function identityScopeKey(host: string, userId: number): string {
|
||||
return `${host}:${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load this host's identity keypair from the keyring, generating and saving a
|
||||
* fresh one on first login (or when the stored blob is corrupt). In non-Tauri
|
||||
* environments the keypair is in-memory only (not persisted).
|
||||
* Load this host+user's identity keypair from the keyring, generating and
|
||||
* saving a fresh one on first login (or when the stored blob is corrupt). In
|
||||
* non-Tauri environments the keypair is in-memory only (not persisted).
|
||||
*
|
||||
* Stable for the lifetime of the process: repeat callers get the same keypair
|
||||
* even when the keyring is unavailable (see `identityKeyPairCache`).
|
||||
*/
|
||||
export function getOrCreateIdentityKeyPair(host: string): Promise<CryptoKeyPair> {
|
||||
let pending = identityKeyPairCache.get(host);
|
||||
export function getOrCreateIdentityKeyPair(host: string, userId: number): Promise<CryptoKeyPair> {
|
||||
const scope = identityScopeKey(host, userId);
|
||||
let pending = identityKeyPairCache.get(scope);
|
||||
if (pending === undefined) {
|
||||
// A rejected load must not be cached, or the host is poisoned for the
|
||||
// A rejected load must not be cached, or the scope is poisoned for the
|
||||
// rest of the session; drop it so the next caller can retry.
|
||||
pending = loadOrGenerateIdentityKeyPair(host).catch((err: unknown) => {
|
||||
identityKeyPairCache.delete(host);
|
||||
pending = loadOrGenerateIdentityKeyPair(host, userId).catch((err: unknown) => {
|
||||
identityKeyPairCache.delete(scope);
|
||||
throw err;
|
||||
});
|
||||
identityKeyPairCache.set(host, pending);
|
||||
identityKeyPairCache.set(scope, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Test-only: drop the per-host keypair memo so each case starts clean. */
|
||||
/** Test-only: drop the per-host+user keypair memo so each case starts clean. */
|
||||
export function resetIdentityKeyPairCache(): void {
|
||||
identityKeyPairCache.clear();
|
||||
}
|
||||
|
||||
async function loadOrGenerateIdentityKeyPair(host: string): Promise<CryptoKeyPair> {
|
||||
const stored = await loadIdentityKey(host);
|
||||
/**
|
||||
* One-time migration for pre-B3-3 installs (see `identityKeyPairCache` above):
|
||||
* before that fix, the identity keypair lived under the host-only keyring
|
||||
* account (`identity:{host}`, passed here as plain `host`) instead of the
|
||||
* scoped `identity:{host}:{uid}`. Without this, every existing install finds
|
||||
* nothing at the new scoped account and mints a fresh identity keypair, and
|
||||
* every peer who already pinned the old key sees a TOFU mismatch — a MITM
|
||||
* warning firing for the whole alpha population at once, training users to
|
||||
* click through the one warning meant to matter.
|
||||
*
|
||||
* Only called when the scoped account is empty, so a genuine first login (or
|
||||
* a second account on a host whose legacy key the first already adopted)
|
||||
* still gets its own fresh keypair — that distinctness is the point of B3-3.
|
||||
* On a host that really did have two accounts sharing one key, whichever logs
|
||||
* in first adopts it and the other mints fresh: the legacy account records no
|
||||
* user id, so there is nothing to match on. That leaves the old shared-key
|
||||
* behaviour in place for exactly one account instead of two, and it resolves
|
||||
* itself once both have signed in once.
|
||||
* The scoped save happens before the legacy delete, so a failed save can't
|
||||
* leave the user with neither key; the legacy account just stays put for the
|
||||
* next launch to retry.
|
||||
*
|
||||
* Delete this once the alpha population has rolled onto the scoped account.
|
||||
*/
|
||||
async function migrateLegacyIdentityKey(
|
||||
host: string,
|
||||
scope: string,
|
||||
): Promise<CryptoKeyPair | null> {
|
||||
const legacyBlob = await loadIdentityKey(host);
|
||||
if (!legacyBlob) {
|
||||
return null;
|
||||
}
|
||||
let keyPair: CryptoKeyPair;
|
||||
try {
|
||||
keyPair = await importIdentityKeyPair(legacyBlob);
|
||||
} catch (err) {
|
||||
log.error("Legacy identity key is corrupt — generating fresh instead of migrating", {
|
||||
host,
|
||||
error: String(err),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (await saveIdentityKey(scope, legacyBlob)) {
|
||||
await deleteIdentityKey(host);
|
||||
} else {
|
||||
log.error(
|
||||
"Failed to migrate legacy identity key to the scoped account — leaving the legacy " +
|
||||
"key in place so the next launch can retry",
|
||||
{ host },
|
||||
);
|
||||
}
|
||||
return keyPair;
|
||||
}
|
||||
|
||||
async function loadOrGenerateIdentityKeyPair(host: string, userId: number): Promise<CryptoKeyPair> {
|
||||
const scope = identityScopeKey(host, userId);
|
||||
const stored = await loadIdentityKey(scope);
|
||||
if (stored) {
|
||||
try {
|
||||
return await importIdentityKeyPair(stored);
|
||||
} catch (err) {
|
||||
log.error("Stored identity key is corrupt — regenerating", { host, error: String(err) });
|
||||
log.error("Stored identity key is corrupt — regenerating", {
|
||||
host,
|
||||
userId,
|
||||
error: String(err),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const migrated = await migrateLegacyIdentityKey(host, scope);
|
||||
if (migrated) {
|
||||
return migrated;
|
||||
}
|
||||
}
|
||||
const keyPair = await generateIdentityKeyPair();
|
||||
const blob = await exportIdentityKeyPair(keyPair.privateKey);
|
||||
if (await saveIdentityKey(host, blob)) {
|
||||
if (await saveIdentityKey(scope, blob)) {
|
||||
// Outer half of a two-layer check. `save_identity_key` already reads its own
|
||||
// write back and falls through to the DPAPI file if the OS credential store
|
||||
// does not return it (see src-tauri/src/secret_store.rs and
|
||||
// docs/credential-storage.md), so reaching the branch below now means the
|
||||
// secret survived neither store. Kept because this is the failure a
|
||||
// resolved promise cannot express, and its only other symptom is peers
|
||||
// flagging the user as a MITM after a restart.
|
||||
if ((await loadIdentityKey(host)) !== blob) {
|
||||
// flagging the user as a MITM after a restart. A read error here (as
|
||||
// opposed to loadIdentityKey's first call above, which decides whether to
|
||||
// regenerate) is treated the same as a mismatch, not rethrown — we
|
||||
// already have a freshly generated keypair for this session, so there is
|
||||
// nothing left to abort.
|
||||
let persisted: boolean;
|
||||
try {
|
||||
persisted = (await loadIdentityKey(scope)) === blob;
|
||||
} catch {
|
||||
persisted = false;
|
||||
}
|
||||
if (!persisted) {
|
||||
log.error(
|
||||
"Identity key did not persist — the credential store accepted the write but did not return it. " +
|
||||
"This session works, but peers will see a new identity (and prompt to re-verify) every restart.",
|
||||
{ host },
|
||||
{ host, userId },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -218,11 +367,27 @@ export async function publishIdentityKey(
|
||||
|
||||
/**
|
||||
* Login/ready hook: ensure the server holds this client's identity public key.
|
||||
* Loads (or generates) the host keypair and publishes it via the REST profile
|
||||
* update when the server's stored copy is absent or stale — idempotent, so it
|
||||
* runs at most once per key. The server's PATCH /users/me requires a username,
|
||||
* so `username` is sent alongside the key. Fire-and-forget: errors are logged
|
||||
* and swallowed (returns false) so the connect/voice flow is never blocked.
|
||||
* Loads (or generates) the host+user keypair and publishes it via the REST
|
||||
* profile update when the server's stored copy is absent or stale —
|
||||
* idempotent, so it runs at most once per key. The server's PATCH /users/me
|
||||
* requires a username, so `username` is sent alongside the key. Fire-and-forget:
|
||||
* errors are logged and swallowed (returns false) so the connect/voice flow is
|
||||
* never blocked.
|
||||
*
|
||||
* The user id is read from `authStore` rather than taken as a parameter: this
|
||||
* is called from the ready hook, by which point auth state is populated, and
|
||||
* keeping the signature unchanged avoids threading the id through every call
|
||||
* site just to scope the keyring lookup (B3-3).
|
||||
*
|
||||
* If auth state is NOT yet populated (no user id), this returns false
|
||||
* without touching the keyring at all — it must never substitute a
|
||||
* placeholder scope like `?? 0`. `getOrCreateIdentityKeyPair` is host+user
|
||||
* scoped, and minting (or migrating) a keypair under a bogus `host:0`
|
||||
* would adopt-and-DELETE the real legacy key into that wrong account (see
|
||||
* `identityKeyPairCache` / `migrateLegacyIdentityKey` above); the next,
|
||||
* correctly-authenticated call then mints a second, different keypair under
|
||||
* `host:<realId>`, so the published key and the announce signing key
|
||||
* permanently disagree — a false MITM warning for every peer.
|
||||
*/
|
||||
export async function ensureIdentityKeyPublished(
|
||||
host: string,
|
||||
@@ -231,7 +396,12 @@ export async function ensureIdentityKeyPublished(
|
||||
updateProfile: (data: { username: string; identity_public_key: string }) => Promise<unknown>,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const keyPair = await getOrCreateIdentityKeyPair(host);
|
||||
const userId = authStore.getState().user?.id;
|
||||
if (userId === undefined) {
|
||||
log.warn("No authenticated user id yet — not publishing identity key", { host });
|
||||
return false;
|
||||
}
|
||||
const keyPair = await getOrCreateIdentityKeyPair(host, userId);
|
||||
return await publishIdentityKey(
|
||||
(data) => updateProfile({ username, ...data }),
|
||||
serverCopy,
|
||||
|
||||
@@ -58,6 +58,11 @@ export class E2EEManager {
|
||||
private _identityKeyPair: CryptoKeyPair | null = null;
|
||||
/** True if this client is the key holder (longest-present participant). */
|
||||
private _isKeyHolder = false;
|
||||
/** Channel this exchange runs in, set at setupKeyExchange entry. The session
|
||||
* facade publishes its channel id only once "connected", which is after the
|
||||
* whole key-exchange wait — key-holder re-elections arriving in that window
|
||||
* must not be dropped for lack of a channel id. */
|
||||
private _channelId: number | null = null;
|
||||
/** Resolver/rejector for non-key-holders waiting to receive the room key via offer. */
|
||||
private _roomKeyResolver: (() => void) | null = null;
|
||||
private _roomKeyRejector: ((err: Error) => void) | null = null;
|
||||
@@ -80,6 +85,13 @@ export class E2EEManager {
|
||||
private _keyRotationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Interval between periodic key rotations (5 minutes). */
|
||||
private static readonly KEY_ROTATION_INTERVAL_MS = 5 * 60 * 1000;
|
||||
/** Bumped every time clearState() tears down a session. An in-flight
|
||||
* setupKeyExchange/reannounceForReconnect captures this before its first
|
||||
* await and re-checks it before publishing to this._ecdhKeyPair — a plain
|
||||
* `this._ecdhKeyPair === null` check can't see a teardown-then-restart
|
||||
* that happens entirely during those awaits, since nothing is null by the
|
||||
* time the abandoned attempt resumes. */
|
||||
private _sessionGeneration = 0;
|
||||
|
||||
constructor(private deps: E2EEDeps) {}
|
||||
|
||||
@@ -123,18 +135,86 @@ export class E2EEManager {
|
||||
* surfaces the "e2ee_timeout" error and leaves voice.
|
||||
*/
|
||||
async setupKeyExchange(isKeyHolder: boolean, channelId: number): Promise<boolean> {
|
||||
// Generate a fresh ECDH keypair for this session.
|
||||
this._ecdhKeyPair = await generateECDHKeyPair();
|
||||
// Captured before any await so a clearState() that lands anywhere below
|
||||
// (before we publish this._ecdhKeyPair) can be detected even though
|
||||
// nothing about our local state is null yet — see the field comment.
|
||||
const myGeneration = this._sessionGeneration;
|
||||
this._channelId = channelId;
|
||||
// Generate a fresh ECDH keypair for this session, but keep it local and
|
||||
// do NOT publish it to this._ecdhKeyPair until right before the drain
|
||||
// below (after _isKeyHolder/_roomKey are ready). Until then,
|
||||
// handleAnnounce's `!this._ecdhKeyPair` guard queues any announce that
|
||||
// arrives concurrently instead of running it through the live path —
|
||||
// where it would be stored in _peerPublicKeys but sent no offer (isKeyHolder
|
||||
// /roomKey not set up yet) and then never seen by the drain either (it was
|
||||
// never queued), stranding that peer until the next 5-minute rotation.
|
||||
const ecdhKeyPair = await generateECDHKeyPair();
|
||||
// Superseded already? Everything below this point mutates state a newer
|
||||
// session owns, so bail before the first write — clearing the live
|
||||
// session's peer keys/verifications would drop every subsequent rotation
|
||||
// for those peers (handleOffer's unknown-peer guard).
|
||||
if (this._sessionGeneration !== myGeneration) {
|
||||
log.warn("E2EE: setup superseded during keypair generation — aborting", { channelId });
|
||||
return false;
|
||||
}
|
||||
this._peerPublicKeys.clear();
|
||||
clearPeerVerifications();
|
||||
const myPubKeyBase64 = await exportPublicKey(this._ecdhKeyPair.publicKey);
|
||||
const myPubKeyBase64 = await exportPublicKey(ecdhKeyPair.publicKey);
|
||||
// Build the signed announce up front — this loads the identity key from
|
||||
// the keyring once, so the added identity round-trip does NOT stack on
|
||||
// the non-key-holder's 10s key-exchange stall below (F3).
|
||||
const announcePayload = await this.buildAnnouncePayload(myPubKeyBase64);
|
||||
|
||||
// Use server-authoritative is_key_holder from voice_token payload.
|
||||
this._isKeyHolder = isKeyHolder;
|
||||
// Same check again after the keyring round trip — the widest window of
|
||||
// the three, and the next statements install OUR role and room key over
|
||||
// whatever session is live now: a superseded non-holder attempt would
|
||||
// clear the live holder's _isKeyHolder (silently stopping its rotations
|
||||
// and its offers to new peers), and a superseded holder attempt would
|
||||
// push a room key nobody else has onto the shared key provider.
|
||||
if (this._sessionGeneration !== myGeneration) {
|
||||
log.warn("E2EE: setup superseded before key-holder setup — aborting", { channelId });
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use server-authoritative is_key_holder from voice_token payload — OR'd
|
||||
// with whatever this._isKeyHolder already is. The server value was
|
||||
// captured when we started joining and cannot see a handleParticipantLeft
|
||||
// promotion that landed during the awaits above: the generation check
|
||||
// just above proves no clearState() ran since myGeneration was captured,
|
||||
// so the only other writer of this field for THIS generation is that
|
||||
// promotion — unconditionally overwriting it with the stale server value
|
||||
// strands the newly-elected holder waiting for an offer nobody (least of
|
||||
// all itself) will ever send, timing out and ejecting it from voice.
|
||||
this._isKeyHolder = isKeyHolder || this._isKeyHolder;
|
||||
|
||||
if (this._isKeyHolder) {
|
||||
// Generate the room key BEFORE draining queued announces, so the
|
||||
// drain's handleAnnounce calls hit the wrap-and-offer branch and every
|
||||
// drained peer receives the fresh key immediately. Mid-call peers never
|
||||
// re-announce (handleAnnounce replies with an offer, not a
|
||||
// counter-announce), so the only later delivery would be the 5-minute
|
||||
// rotation timer — stranding them on a dead key whenever a new key
|
||||
// holder joins an ongoing call.
|
||||
this._e2eeEpoch++;
|
||||
this._roomKey = generateRoomKey();
|
||||
await this.keyProvider.setKey(roomKeyToBase64(this._roomKey));
|
||||
log.info("E2EE: key holder — generated room key", { channelId });
|
||||
this.startKeyRotationTimer();
|
||||
}
|
||||
|
||||
// And once more after keyProvider.setKey's await: a torn-down attempt
|
||||
// that resurrects this._ecdhKeyPair here would defeat the queue guard in
|
||||
// handleAnnounceInner and go on to announce a dead ephemeral key over a
|
||||
// live call (finding v043).
|
||||
if (this._sessionGeneration !== myGeneration) {
|
||||
log.warn("E2EE: setup superseded before keypair publish — aborting", { channelId });
|
||||
return false;
|
||||
}
|
||||
|
||||
// Publish the keypair now — right before the drain, so every announce
|
||||
// that arrived during the awaits above was queued (not silently
|
||||
// processed with no offer sent) and gets its offer sent below.
|
||||
this._ecdhKeyPair = ecdhKeyPair;
|
||||
|
||||
// Drain any announces that arrived before our keypair was ready. These
|
||||
// are existing participants whose keys the server relayed during
|
||||
@@ -148,12 +228,6 @@ export class E2EEManager {
|
||||
}
|
||||
|
||||
if (this._isKeyHolder) {
|
||||
// We're the first participant — generate the room key.
|
||||
this._e2eeEpoch++;
|
||||
this._roomKey = generateRoomKey();
|
||||
await this.keyProvider.setKey(roomKeyToBase64(this._roomKey));
|
||||
log.info("E2EE: key holder — generated room key", { channelId });
|
||||
this.startKeyRotationTimer();
|
||||
// Announce our (signed) key so existing participants can see us.
|
||||
this.deps.getWs()?.send({ type: "voice_e2ee_announce", payload: announcePayload });
|
||||
} else {
|
||||
@@ -179,12 +253,36 @@ export class E2EEManager {
|
||||
try {
|
||||
await Promise.race([roomKeyPromise, makeTimeout(10_000)]);
|
||||
} catch {
|
||||
// First attempt timed out — re-announce and retry once.
|
||||
// First attempt failed — re-announce and retry once. This also
|
||||
// catches a decrypt failure in handleOfferInner (which rejects
|
||||
// roomKeyPromise directly), not just a genuine timeout.
|
||||
if (timeoutId !== null) clearTimeout(timeoutId);
|
||||
// clearState() (e.g. the user left voice) also rejects roomKeyPromise
|
||||
// and, unlike a decrypt failure, nulls _ecdhKeyPair — there is nobody
|
||||
// left to retry with. Stop here instead of re-announcing into a torn-
|
||||
// down session and reinstalling a resolver nothing will ever call.
|
||||
// Compare by identity, not just null: a torn-down-then-restarted
|
||||
// session can leave this._ecdhKeyPair non-null but owned by a
|
||||
// completely different (superseded) attempt — retrying would
|
||||
// re-announce our dead ephemeral key over that live session and
|
||||
// steal its single _roomKeyResolver slot (finding v043).
|
||||
if (this._ecdhKeyPair !== ecdhKeyPair) {
|
||||
log.warn("E2EE: key exchange aborted (session cleared or superseded)", { channelId });
|
||||
return false;
|
||||
}
|
||||
log.warn("E2EE: first key exchange attempt timed out, re-announcing", { channelId });
|
||||
this.deps.getWs()?.send({ type: "voice_e2ee_announce", payload: announcePayload });
|
||||
// roomKeyPromise may already be SETTLED (rejected) at this point — a
|
||||
// decrypt failure rejects it permanently, so racing the SAME promise
|
||||
// again would resolve rejected on the very next microtask instead of
|
||||
// giving the retry its intended 5s window. Create a fresh promise and
|
||||
// reinstall the resolver/rejector before racing again.
|
||||
const retryPromise = new Promise<void>((resolve, reject) => {
|
||||
this._roomKeyResolver = resolve;
|
||||
this._roomKeyRejector = reject;
|
||||
});
|
||||
try {
|
||||
await Promise.race([roomKeyPromise, makeTimeout(5_000)]);
|
||||
await Promise.race([retryPromise, makeTimeout(5_000)]);
|
||||
} catch {
|
||||
log.error("E2EE: key exchange timed out after retry — disconnecting", { channelId });
|
||||
this._roomKeyResolver = null;
|
||||
@@ -209,14 +307,37 @@ export class E2EEManager {
|
||||
* fresh offer if the key was rotated during our absence.
|
||||
*/
|
||||
async reannounceForReconnect(): Promise<void> {
|
||||
this._ecdhKeyPair = await generateECDHKeyPair();
|
||||
this._peerPublicKeys.clear();
|
||||
clearPeerVerifications();
|
||||
// Captured before any await so a clearState() (e.g. the user hits
|
||||
// Disconnect during auto-reconnect) that lands during this method's
|
||||
// awaits can be detected instead of silently resurrecting
|
||||
// this._ecdhKeyPair / re-announcing for a channel we already left
|
||||
// (finding v093).
|
||||
const myGeneration = this._sessionGeneration;
|
||||
const pair = await generateECDHKeyPair();
|
||||
if (this._sessionGeneration !== myGeneration) {
|
||||
log.warn("E2EE: reconnect re-announce superseded before keypair publish — aborting");
|
||||
return;
|
||||
}
|
||||
this._ecdhKeyPair = pair;
|
||||
// Peers' ECDH public keys and their TOFU verifications survive: they are
|
||||
// unaffected by regenerating OUR pair, and ECDH still works (our new
|
||||
// private key against their existing public key). Clearing them here would
|
||||
// be permanent — handleAnnounce replies with an offer rather than a
|
||||
// counter-announce, and the server relays stored peer keys only on
|
||||
// voice_join — so handleOffer's unknown-peer guard would drop every
|
||||
// subsequent rotation, stranding us on the pre-reconnect key.
|
||||
if (this._roomKey) {
|
||||
await this.keyProvider.setKey(roomKeyToBase64(this._roomKey));
|
||||
}
|
||||
const reconnectPubKey = await exportPublicKey(this._ecdhKeyPair.publicKey);
|
||||
const reconnectPubKey = await exportPublicKey(pair.publicKey);
|
||||
const reconnectAnnounce = await this.buildAnnouncePayload(reconnectPubKey);
|
||||
// Re-check ownership right before the send too: buildAnnouncePayload can
|
||||
// itself await a keyring round trip, another window for clearState() (or
|
||||
// a fresh setupKeyExchange) to have superseded this attempt.
|
||||
if (this._ecdhKeyPair !== pair) {
|
||||
log.warn("E2EE: reconnect re-announce superseded before send — discarding stray announce");
|
||||
return;
|
||||
}
|
||||
this.deps.getWs()?.send({ type: "voice_e2ee_announce", payload: reconnectAnnounce });
|
||||
}
|
||||
|
||||
@@ -230,13 +351,28 @@ export class E2EEManager {
|
||||
|
||||
/** Load (once per session) this client's long-term identity keypair from the
|
||||
* OS keyring so we can sign ephemeral announces. Returns null when there is
|
||||
* no server host (identity is host-scoped) — the announce then goes out
|
||||
* unsigned and peers treat us as a legacy/unverified client. */
|
||||
* no server host (identity is host-scoped) OR no authenticated user id yet
|
||||
* (identity is host+user scoped, B3-3) — the announce then goes out
|
||||
* unsigned and peers treat us as a legacy/unverified client. A missing user
|
||||
* id must never fall back to a placeholder scope like `?? 0`:
|
||||
* `getOrCreateIdentityKeyPair` would mint (or migrate-and-DELETE the real
|
||||
* legacy key into) a bogus `host:0` keyring account, and a later
|
||||
* authenticated call would then mint a second, different keypair under
|
||||
* `host:<realId>` — so the published key and the announce signing key
|
||||
* permanently disagree and every peer's verifyPeerAnnounce reports a false
|
||||
* MITM "mismatch" (see identity.ts's `identityKeyPairCache` doc). */
|
||||
private async ensureIdentityKeyPair(): Promise<CryptoKeyPair | null> {
|
||||
if (this._identityKeyPair) return this._identityKeyPair;
|
||||
const host = this.deps.getServerHost();
|
||||
if (host === null) return null;
|
||||
this._identityKeyPair = await getOrCreateIdentityKeyPair(host);
|
||||
const myUserId = authStore.getState().user?.id;
|
||||
if (myUserId === undefined) {
|
||||
log.warn(
|
||||
"E2EE: no authenticated user id yet — announcing unsigned instead of scoping under a placeholder id",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
this._identityKeyPair = await getOrCreateIdentityKeyPair(host, myUserId);
|
||||
return this._identityKeyPair;
|
||||
}
|
||||
|
||||
@@ -284,7 +420,8 @@ export class E2EEManager {
|
||||
private async verifyPeerAnnounce(
|
||||
userId: number,
|
||||
publicKeyBase64: string,
|
||||
signatureBase64?: string,
|
||||
signatureBase64: string | undefined,
|
||||
myGeneration: number,
|
||||
): Promise<boolean> {
|
||||
const publishedIdentity =
|
||||
membersStore.getState().members.get(userId)?.identityPublicKey ?? null;
|
||||
@@ -293,12 +430,37 @@ export class E2EEManager {
|
||||
// Resolve the persisted pin FIRST — before any legacy shortcut. A server
|
||||
// must not be able to strip a pinned peer's published key (or swap it) to
|
||||
// force it back onto the legacy accept path (finding #2: TOFU pin bypass).
|
||||
const pin = host ? await getIdentityPin(host, String(userId)) : null;
|
||||
const lookup = host
|
||||
? await getIdentityPin(host, String(userId))
|
||||
: ({ status: "unpinned" } as const);
|
||||
|
||||
// Fail closed when the pin store could not be read (DC-08): with the pin
|
||||
// unknown, this peer might be pinned to a different key — proceeding down
|
||||
// the first-sight path would verify against, and then RE-PIN, whatever key
|
||||
// the server delivered. Reject the announce and surface the distinct
|
||||
// "unknown" state; the peer stays blocked for E2EE until the store recovers.
|
||||
if (lookup.status === "unavailable") {
|
||||
this.setPeerVerificationIfCurrent(myGeneration, {
|
||||
userId,
|
||||
status: "unknown",
|
||||
safetyNumber: null,
|
||||
});
|
||||
log.error("E2EE: identity pin store unreadable — rejecting announce (fail closed)", {
|
||||
userId,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const pin = lookup.status === "pinned" ? lookup.pin : null;
|
||||
|
||||
// Pinned peer whose delivered key is absent or differs from the pin —
|
||||
// possible server MITM. Block until the user re-pins.
|
||||
if (pin !== null && publishedIdentity !== pin) {
|
||||
setPeerVerification({ userId, status: "mismatch", safetyNumber: null });
|
||||
this.setPeerVerificationIfCurrent(myGeneration, {
|
||||
userId,
|
||||
status: "mismatch",
|
||||
safetyNumber: null,
|
||||
});
|
||||
log.error("E2EE: pinned peer identity key missing/changed — blocking (identity-tofu)", {
|
||||
userId,
|
||||
});
|
||||
@@ -309,7 +471,11 @@ export class E2EEManager {
|
||||
// but mark unverified (pin-pending). This is the only case the compatibility
|
||||
// posture keeps open.
|
||||
if (!publishedIdentity) {
|
||||
setPeerVerification({ userId, status: "unverified", safetyNumber: null });
|
||||
this.setPeerVerificationIfCurrent(myGeneration, {
|
||||
userId,
|
||||
status: "unverified",
|
||||
safetyNumber: null,
|
||||
});
|
||||
log.warn("E2EE: peer has no identity key — accepting as unverified (legacy)", { userId });
|
||||
return true;
|
||||
}
|
||||
@@ -324,21 +490,61 @@ export class E2EEManager {
|
||||
: false;
|
||||
if (!ok) {
|
||||
// Fail closed: peer has an identity key but no valid signature (MITM).
|
||||
setPeerVerification({ userId, status: "mismatch", safetyNumber: null });
|
||||
this.setPeerVerificationIfCurrent(myGeneration, {
|
||||
userId,
|
||||
status: "mismatch",
|
||||
safetyNumber: null,
|
||||
});
|
||||
log.error("E2EE: peer announce signature invalid — rejecting (MITM?)", { userId });
|
||||
return false;
|
||||
}
|
||||
|
||||
// First sight with a valid signature — pin the identity key now.
|
||||
// First sight with a valid signature — pin the identity key now. A
|
||||
// failed write (disk full, unwritable pins file) must not display
|
||||
// "verified" with no pin ever persisted: the pin is what arms mismatch
|
||||
// detection on a LATER announce, so a peer we call verified but never
|
||||
// pinned can never have that check fire — the exact MITM window the pin
|
||||
// exists to close. "no-store" (non-Tauri: no pin store by design) is not
|
||||
// a failure and keeps the normal verified outcome below.
|
||||
let pinWriteFailed = false;
|
||||
if (pin === null && host) {
|
||||
await storeIdentityPin(host, String(userId), publishedIdentity);
|
||||
log.info("E2EE: pinned peer identity key on first sight", { userId });
|
||||
const pinResult = await storeIdentityPin(host, String(userId), publishedIdentity);
|
||||
if (pinResult === "failed") {
|
||||
pinWriteFailed = true;
|
||||
log.error("E2EE: failed to persist identity pin — marking unverified, not verified", {
|
||||
userId,
|
||||
});
|
||||
} else {
|
||||
log.info("E2EE: pinned peer identity key on first sight", { userId });
|
||||
}
|
||||
}
|
||||
if (pinWriteFailed) {
|
||||
this.setPeerVerificationIfCurrent(myGeneration, {
|
||||
userId,
|
||||
status: "unverified",
|
||||
safetyNumber: null,
|
||||
});
|
||||
return true; // still accept the announce — the write failure alone shouldn't block the call
|
||||
}
|
||||
const safetyNumber = await computeKeyFingerprint(identityKey);
|
||||
setPeerVerification({ userId, status: "verified", safetyNumber });
|
||||
this.setPeerVerificationIfCurrent(myGeneration, { userId, status: "verified", safetyNumber });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** setPeerVerification, but a no-op if a clearState() teardown happened
|
||||
* since myGeneration was captured. verifyPeerAnnounce awaits a Tauri IPC
|
||||
* (identity pin lookup) internally and writes verification state on every
|
||||
* branch, so a Disconnect mid-await must not let the resumed continuation
|
||||
* resurrect voice-store state for a session that no longer exists
|
||||
* (finding B3-7). */
|
||||
private setPeerVerificationIfCurrent(
|
||||
myGeneration: number,
|
||||
verification: Parameters<typeof setPeerVerification>[0],
|
||||
): void {
|
||||
if (this._sessionGeneration !== myGeneration) return;
|
||||
setPeerVerification(verification);
|
||||
}
|
||||
|
||||
/**
|
||||
* F3 TOFU re-pin recovery (finding #4). Pin the EXACT identity key
|
||||
* `verifiedKey` — the bytes whose fingerprint the caller displayed and the
|
||||
@@ -361,7 +567,17 @@ export class E2EEManager {
|
||||
log.warn("E2EE: cannot re-pin peer without a host and the verified identity key", { userId });
|
||||
return false;
|
||||
}
|
||||
await storeIdentityPin(host, String(userId), verifiedKey);
|
||||
const result = await storeIdentityPin(host, String(userId), verifiedKey);
|
||||
if (result === "failed") {
|
||||
// The old pin is still on disk — do NOT clear the mismatch block. If we
|
||||
// did, the UI would report the peer trusted while nothing was actually
|
||||
// re-pinned, and the peer's very next announce would re-fail
|
||||
// verification against the stale pin with no error ever surfaced.
|
||||
log.error("E2EE: failed to persist re-pinned identity key — mismatch block kept", {
|
||||
userId,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
clearPeerVerification(userId);
|
||||
log.info("E2EE: re-pinned peer identity key (TOFU recovery)", { userId });
|
||||
return true;
|
||||
@@ -369,15 +585,35 @@ export class E2EEManager {
|
||||
|
||||
// ── Client-side E2EE handlers (ECDH key exchange) ───────────────────────
|
||||
|
||||
/** Serializes announce handling. Nothing chains concurrent invocations —
|
||||
* dispatcher fires them unawaited and the queued-announce drain in
|
||||
* setupKeyExchange is a separate, later pass — so two in-flight announces
|
||||
* for the same peer could complete out of WS-delivery order, letting a
|
||||
* stale announce's map write land after a fresher one and strand the peer
|
||||
* on a dead key until the next rotation (finding v015). Mirrors
|
||||
* _offerChain, whose identical ordering guarantee this file already
|
||||
* relies on and tests; handleAnnounceInner swallows its own errors (see
|
||||
* its try/catch below), so the chain cannot wedge on a failed announce. */
|
||||
private _announceChain: Promise<void> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Handle a voice_e2ee_announce from the server — another participant has
|
||||
* announced their ECDH public key. Before trusting it we verify the peer's
|
||||
* announced their ECDH public key. Applied strictly in WS delivery order
|
||||
* (see _announceChain). Before trusting it we verify the peer's
|
||||
* identity-key signature (F3 TOFU): resolve the peer's identity key (pinning
|
||||
* it on first sight), reject on mismatch/invalid signature, and only then
|
||||
* store the ECDH key + (if key holder) wrap the room key for them. Peers with
|
||||
* no published identity key (legacy) are accepted but marked unverified.
|
||||
*/
|
||||
async handleAnnounce(
|
||||
handleAnnounce(userId: number, publicKeyBase64: string, signatureBase64?: string): Promise<void> {
|
||||
const run = this._announceChain.then(() =>
|
||||
this.handleAnnounceInner(userId, publicKeyBase64, signatureBase64),
|
||||
);
|
||||
this._announceChain = run;
|
||||
return run;
|
||||
}
|
||||
|
||||
private async handleAnnounceInner(
|
||||
userId: number,
|
||||
publicKeyBase64: string,
|
||||
signatureBase64?: string,
|
||||
@@ -388,15 +624,27 @@ export class E2EEManager {
|
||||
log.info("E2EE: queued announce (keypair not ready)", { userId });
|
||||
return;
|
||||
}
|
||||
// Captured before verifyPeerAnnounce's awaits (a Tauri IPC pin lookup) so
|
||||
// a clearState() that lands during them — e.g. Disconnect mid-verify —
|
||||
// can be detected before this continuation writes into a session a newer
|
||||
// (or no) attempt now owns (finding B3-7).
|
||||
const myGeneration = this._sessionGeneration;
|
||||
try {
|
||||
// ── F3 TOFU verification gate ──────────────────────────────────────
|
||||
// Resolve the peer's identity key and verify the announce signature
|
||||
// BEFORE storing the ECDH key or wrapping the room key. A malicious
|
||||
// server that swaps user_id↔ephemeral-key or forges keys fails here.
|
||||
if (!(await this.verifyPeerAnnounce(userId, publicKeyBase64, signatureBase64))) {
|
||||
if (
|
||||
!(await this.verifyPeerAnnounce(userId, publicKeyBase64, signatureBase64, myGeneration))
|
||||
) {
|
||||
return; // rejected/blocked — do not store or wrap
|
||||
}
|
||||
|
||||
if (this._sessionGeneration !== myGeneration) {
|
||||
log.info("E2EE: discarding stale announce (session torn down during verify)", { userId });
|
||||
return;
|
||||
}
|
||||
|
||||
// Deduplicate: if the key is identical, skip the import but still
|
||||
// re-send the room key offer (the peer may be re-requesting after a
|
||||
// missed offer or reconnect).
|
||||
@@ -427,7 +675,26 @@ export class E2EEManager {
|
||||
const keypair = this._ecdhKeyPair;
|
||||
const currentRoomKey = this._roomKey;
|
||||
if (this._isKeyHolder && currentRoomKey && keypair) {
|
||||
// Capture epoch before the wrap await — a rotation racing this
|
||||
// announce already added the peer to _peerPublicKeys before we got
|
||||
// here, so it offers them the fresh key on its own; if that
|
||||
// happened, ship this pre-rotation wrap and the receiver's
|
||||
// strictly-ordered _offerChain ends up on the dead key.
|
||||
const epochBefore = this._e2eeEpoch;
|
||||
const { encryptedKey, iv } = await wrapRoomKey(keypair.privateKey, peerKey, currentRoomKey);
|
||||
// Discard if either the epoch advanced (a rotation landed during the
|
||||
// wrap) OR the keypair no longer matches (a concurrent
|
||||
// reannounceForReconnect() swapped it without bumping the epoch) —
|
||||
// mirrors handleOfferInner's dual guard. An offer wrapped under an
|
||||
// abandoned keypair is undecryptable by the peer (finding v101).
|
||||
if (this._e2eeEpoch !== epochBefore || this._ecdhKeyPair !== keypair) {
|
||||
log.info("E2EE: discarding stale announce-offer (epoch or keypair changed during wrap)", {
|
||||
userId,
|
||||
epochBefore,
|
||||
epochNow: this._e2eeEpoch,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.deps.getWs()?.send({
|
||||
type: "voice_e2ee_offer",
|
||||
payload: { target_user_id: userId, encrypted_key: encryptedKey, iv },
|
||||
@@ -439,11 +706,29 @@ export class E2EEManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Serializes offer application. The offer payload carries no epoch or
|
||||
* sequence and WebCrypto gives no cross-operation ordering guarantee, so
|
||||
* two in-flight offers could complete out of order — applying the older
|
||||
* key last and stranding this receiver on a dead key until the next
|
||||
* rotation. Chaining applies offers strictly in WS delivery order. */
|
||||
private _offerChain: Promise<void> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Handle a voice_e2ee_offer from the server — the key holder has sent us
|
||||
* the encrypted room key. Unwrap it and apply to the E2EE key provider.
|
||||
* Offers are applied one at a time, in delivery order.
|
||||
*/
|
||||
async handleOffer(
|
||||
handleOffer(fromUserId: number, encryptedKeyBase64: string, ivBase64: string): Promise<void> {
|
||||
// handleOfferInner never rejects (it catches internally), so the chain
|
||||
// cannot wedge on a failed offer.
|
||||
const run = this._offerChain.then(() =>
|
||||
this.handleOfferInner(fromUserId, encryptedKeyBase64, ivBase64),
|
||||
);
|
||||
this._offerChain = run;
|
||||
return run;
|
||||
}
|
||||
|
||||
private async handleOfferInner(
|
||||
fromUserId: number,
|
||||
encryptedKeyBase64: string,
|
||||
ivBase64: string,
|
||||
@@ -471,8 +756,12 @@ export class E2EEManager {
|
||||
ivBase64,
|
||||
);
|
||||
|
||||
if (this._e2eeEpoch !== epochBefore) {
|
||||
log.info("E2EE: discarding stale offer (epoch changed during unwrap)", {
|
||||
// Discard if either the epoch advanced (a rotation landed during
|
||||
// unwrap) OR the keypair no longer matches (clearState() ran and a new
|
||||
// session generated a fresh one — possible when the epoch is 0 in both
|
||||
// the old and new session, since a non-key-holder never bumps it).
|
||||
if (this._e2eeEpoch !== epochBefore || this._ecdhKeyPair !== keypair) {
|
||||
log.info("E2EE: discarding stale offer (epoch or session keypair changed during unwrap)", {
|
||||
fromUserId,
|
||||
epochBefore,
|
||||
epochNow: this._e2eeEpoch,
|
||||
@@ -484,6 +773,21 @@ export class E2EEManager {
|
||||
await this.keyProvider.setKey(roomKeyToBase64(this._roomKey));
|
||||
log.info("E2EE: room key received and applied", { fromUserId });
|
||||
|
||||
// Accepting an offer proves the sender is the server-authoritative key
|
||||
// holder (the server gates outgoing offers on IsVoiceKeyHolder), so if we
|
||||
// still think we hold the key, we have been re-elected away — a lower
|
||||
// userID joined. Stand down: our rotations would be rejected with
|
||||
// NOT_KEY_HOLDER, but only after we applied the new key locally, leaving
|
||||
// us deaf and mute until the real holder rotates again.
|
||||
// handleParticipantLeft can still re-promote us later.
|
||||
if (this._isKeyHolder) {
|
||||
this._isKeyHolder = false;
|
||||
this.clearKeyRotationTimer();
|
||||
log.info("E2EE: stood down as key holder — accepted an offer from the elected holder", {
|
||||
fromUserId,
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve the pending connect promise if we were waiting for the key.
|
||||
if (this._roomKeyResolver) {
|
||||
this._roomKeyResolver();
|
||||
@@ -501,6 +805,41 @@ export class E2EEManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the room key for each peer and send an offer, one at a time. Bails
|
||||
* out (without sending further offers) as soon as a concurrent keypair
|
||||
* swap (reannounceForReconnect) or room-key change invalidates the wrap —
|
||||
* an offer wrapped under an abandoned keypair/key is undecryptable by the
|
||||
* peer and would otherwise silently strand them on the stale key until the
|
||||
* next rotation (finding v045). Shared by the become-holder distribution,
|
||||
* its late-arrival (H3) pass, and the periodic rotation loop.
|
||||
*/
|
||||
private async distributeRoomKey(
|
||||
keypair: CryptoKeyPair,
|
||||
roomKey: Uint8Array,
|
||||
peers: Iterable<[number, CryptoKey]>,
|
||||
): Promise<void> {
|
||||
for (const [peerId, peerKey] of peers) {
|
||||
if (this._ecdhKeyPair !== keypair || this._roomKey !== roomKey) {
|
||||
log.warn("E2EE: aborting key distribution — keypair/room key changed mid-loop", {
|
||||
peerId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { encryptedKey, iv } = await wrapRoomKey(keypair.privateKey, peerKey, roomKey);
|
||||
if (this._ecdhKeyPair !== keypair || this._roomKey !== roomKey) {
|
||||
log.info("E2EE: discarding stale room-key offer (keypair/room key changed during wrap)", {
|
||||
peerId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.deps.getWs()?.send({
|
||||
type: "voice_e2ee_offer",
|
||||
payload: { target_user_id: peerId, encrypted_key: encryptedKey, iv },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a participant leaving the voice channel. If we become the new key
|
||||
* holder, rotate the room key and distribute to remaining peers. If we are
|
||||
@@ -517,7 +856,7 @@ export class E2EEManager {
|
||||
this._peerPublicKeys.delete(userId);
|
||||
clearPeerVerification(userId);
|
||||
|
||||
const channelId = this.deps.getCurrentChannelId();
|
||||
const channelId = this._channelId ?? this.deps.getCurrentChannelId();
|
||||
if (!channelId) return;
|
||||
|
||||
const state = voiceStore.getState();
|
||||
@@ -534,9 +873,20 @@ export class E2EEManager {
|
||||
const myUserId = authStore.getState().user?.id ?? 0;
|
||||
|
||||
if (myUserId !== 0 && lowestUserId === myUserId && !wasKeyHolder) {
|
||||
// Prevent concurrent rotations (e.g. two participants leave in rapid succession).
|
||||
// A rotation is already in flight (e.g. we stood down mid-rotation
|
||||
// after accepting another holder's offer, and are now re-elected
|
||||
// because THEY left). Don't drop the re-election — that would strand
|
||||
// the room with no key holder until the next voice_leave self-heals
|
||||
// it. Mirror the sibling branch below: defer, don't drop. The
|
||||
// in-flight rotation's finally -> drainPendingRotationOrArmTimer will
|
||||
// run rotateKeyPeriodically as holder once it completes.
|
||||
if (this._rotatingKey) {
|
||||
log.warn("E2EE: key rotation already in progress, skipping", { userId, channelId });
|
||||
this._isKeyHolder = true;
|
||||
this._rotationPending = true;
|
||||
log.warn("E2EE: key rotation already in progress — deferring re-election as holder", {
|
||||
userId,
|
||||
channelId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this._rotatingKey = true;
|
||||
@@ -550,43 +900,39 @@ export class E2EEManager {
|
||||
await this.keyProvider.setKey(roomKeyToBase64(this._roomKey));
|
||||
log.info("E2EE: rotated room key", { channelId, epoch: this._e2eeEpoch });
|
||||
|
||||
// Snapshot peers before async loop — new peers that arrive during
|
||||
// wrapping are handled by the post-rotation check below.
|
||||
// A client elected while still waiting inside setupKeyExchange has a
|
||||
// pending resolver — the offer it is waiting for will never arrive
|
||||
// (we are the holder now), so unblock it with the key just generated.
|
||||
if (this._roomKeyResolver) {
|
||||
this._roomKeyResolver();
|
||||
this._roomKeyResolver = null;
|
||||
this._roomKeyRejector = null;
|
||||
}
|
||||
|
||||
// Snapshot peers (and the keypair/room key) before the async loop —
|
||||
// new peers that arrive during wrapping are handled by the
|
||||
// post-rotation check below.
|
||||
const keypair = this._ecdhKeyPair;
|
||||
const roomKey = this._roomKey;
|
||||
const peersSnapshot = new Map(this._peerPublicKeys);
|
||||
|
||||
if (keypair) {
|
||||
for (const [peerId, peerKey] of peersSnapshot) {
|
||||
const { encryptedKey, iv } = await wrapRoomKey(
|
||||
keypair.privateKey,
|
||||
peerKey,
|
||||
this._roomKey,
|
||||
);
|
||||
this.deps.getWs()?.send({
|
||||
type: "voice_e2ee_offer",
|
||||
payload: { target_user_id: peerId, encrypted_key: encryptedKey, iv },
|
||||
});
|
||||
}
|
||||
if (keypair && roomKey) {
|
||||
await this.distributeRoomKey(keypair, roomKey, peersSnapshot);
|
||||
log.info("E2EE: distributed rotated key to peers", {
|
||||
peerCount: peersSnapshot.size,
|
||||
});
|
||||
|
||||
// H3: Check for peers that arrived during the rotation loop and
|
||||
// send them the new key too.
|
||||
if (keypair === this._ecdhKeyPair && this._roomKey) {
|
||||
for (const [peerId, peerKey] of this._peerPublicKeys) {
|
||||
if (!peersSnapshot.has(peerId)) {
|
||||
const { encryptedKey, iv } = await wrapRoomKey(
|
||||
keypair.privateKey,
|
||||
peerKey,
|
||||
this._roomKey,
|
||||
);
|
||||
this.deps.getWs()?.send({
|
||||
type: "voice_e2ee_offer",
|
||||
payload: { target_user_id: peerId, encrypted_key: encryptedKey, iv },
|
||||
});
|
||||
log.info("E2EE: sent rotated key to late-arriving peer", { peerId });
|
||||
}
|
||||
if (keypair === this._ecdhKeyPair && this._roomKey === roomKey) {
|
||||
const lateArrivals = [...this._peerPublicKeys].filter(
|
||||
([peerId]) => !peersSnapshot.has(peerId),
|
||||
);
|
||||
if (lateArrivals.length > 0) {
|
||||
await this.distributeRoomKey(keypair, roomKey, lateArrivals);
|
||||
log.info("E2EE: sent rotated key to late-arriving peers", {
|
||||
peerCount: lateArrivals.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -642,7 +988,7 @@ export class E2EEManager {
|
||||
/** Rotate the room key on a timer tick (forward secrecy improvement). */
|
||||
async rotateKeyPeriodically(): Promise<void> {
|
||||
if (!this._isKeyHolder || this._rotatingKey) return;
|
||||
const channelId = this.deps.getCurrentChannelId();
|
||||
const channelId = this._channelId ?? this.deps.getCurrentChannelId();
|
||||
if (!channelId) return;
|
||||
|
||||
this._rotatingKey = true;
|
||||
@@ -653,21 +999,14 @@ export class E2EEManager {
|
||||
log.info("E2EE: periodic key rotation", { channelId, epoch: this._e2eeEpoch });
|
||||
|
||||
const keypair = this._ecdhKeyPair;
|
||||
if (keypair && this._roomKey) {
|
||||
for (const [peerId, peerKey] of this._peerPublicKeys) {
|
||||
const { encryptedKey, iv } = await wrapRoomKey(
|
||||
keypair.privateKey,
|
||||
peerKey,
|
||||
this._roomKey,
|
||||
);
|
||||
this.deps.getWs()?.send({
|
||||
type: "voice_e2ee_offer",
|
||||
payload: { target_user_id: peerId, encrypted_key: encryptedKey, iv },
|
||||
});
|
||||
}
|
||||
log.info("E2EE: distributed periodically rotated key", {
|
||||
peerCount: this._peerPublicKeys.size,
|
||||
});
|
||||
const roomKey = this._roomKey;
|
||||
if (keypair && roomKey) {
|
||||
const peerCount = this._peerPublicKeys.size;
|
||||
// Pass the live map (not a snapshot): peers that arrive mid-loop are
|
||||
// still visited, matching the original behavior — only the
|
||||
// keypair/room-key ownership check is new here.
|
||||
await this.distributeRoomKey(keypair, roomKey, this._peerPublicKeys);
|
||||
log.info("E2EE: distributed periodically rotated key", { peerCount });
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("E2EE: periodic key rotation failed", err);
|
||||
@@ -696,6 +1035,10 @@ export class E2EEManager {
|
||||
* keypair is intentionally NOT cleared here — it persists across calls to
|
||||
* the same host (cleared only on host change / cleanupAll). */
|
||||
clearState(): void {
|
||||
this._sessionGeneration++;
|
||||
this._channelId = null;
|
||||
this._offerChain = Promise.resolve();
|
||||
this._announceChain = Promise.resolve();
|
||||
this._ecdhKeyPair = null;
|
||||
this._roomKey = null;
|
||||
this._peerPublicKeys.clear();
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
setLocalDeafened,
|
||||
setLocalCamera,
|
||||
setLocalScreenshare,
|
||||
setPttGated,
|
||||
isPttPollingLive,
|
||||
leaveVoiceChannel,
|
||||
setListenOnly,
|
||||
setVoiceStatus,
|
||||
@@ -17,7 +19,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { AudioPipeline } from "@lib/audioPipeline";
|
||||
import { AudioElements } from "@lib/audioElements";
|
||||
import { E2EEManager } from "@lib/livekitE2EE";
|
||||
import { DeviceManager } from "@lib/deviceManager";
|
||||
import { DeviceManager, isMicPolicyGated } from "@lib/deviceManager";
|
||||
import {
|
||||
type VideoTrackDeps,
|
||||
type CameraTrackState,
|
||||
@@ -50,6 +52,13 @@ export type { StreamQuality } from "@lib/screenShare";
|
||||
|
||||
const log = createLogger("livekitSession");
|
||||
|
||||
// --- Push-to-talk liveness (cross-module signal, no instance state) ---
|
||||
|
||||
/** Re-exported from the voice store, which owns the flag so `ptt.ts` can write
|
||||
* it at startup without importing this module (and the ~1.3 MB livekit-client
|
||||
* SDK behind it). See `voice.store.ts` for the platform-capability contract. */
|
||||
export { setPttPollingLive } from "@stores/voice.store";
|
||||
|
||||
// --- Pure helpers (no instance state) ---
|
||||
|
||||
/** Parse userId from LiveKit participant identity "user-{id}" or "user-{id}:{token}". Returns 0 if unparseable. */
|
||||
@@ -110,6 +119,16 @@ export class LiveKitSession {
|
||||
/** Single source of truth for all connection-lifecycle state. */
|
||||
private _state: SessionState = { type: "idle" };
|
||||
|
||||
/** BUG-142 fix: the ONLY source of join generations. Must never be
|
||||
* re-derived from `_state` — a transition through "idle" (e.g. leaveVoice()
|
||||
* during an in-flight connect) would reset a derived counter back to the
|
||||
* same value a still-running stale attempt is holding, letting the two
|
||||
* attempts collide on one generation and defeating every supersession
|
||||
* checkpoint. Monotonic increments here guarantee every connectAndSetup()
|
||||
* call gets a value no other attempt has ever held, regardless of how
|
||||
* many times the session has bounced through "idle" in between. */
|
||||
private _joinGenerationCounter = 0;
|
||||
|
||||
// --- Non-connection fields (configuration / callbacks / infrastructure) ---
|
||||
private ws: WsClient | null = null;
|
||||
private onErrorCallback: ((message: string) => void) | null = null;
|
||||
@@ -280,6 +299,7 @@ export class LiveKitSession {
|
||||
getOnRemoteVideoRemovedCallback: () => this.onRemoteVideoRemovedCallback,
|
||||
getOnErrorCallback: () => this.onErrorCallback,
|
||||
isConnecting: () => this._connecting,
|
||||
isReconnecting: () => this._state.type === "reconnecting",
|
||||
getLatestToken: () => this._latestToken,
|
||||
getLastUrl: () => this._lastUrl,
|
||||
getLastDirectUrl: () => this._lastDirectUrl,
|
||||
@@ -309,6 +329,21 @@ export class LiveKitSession {
|
||||
teardownForReconnect: () => {
|
||||
this._audioPipeline.teardownAudioPipeline();
|
||||
this.clearTokenRefreshTimer();
|
||||
// The WS session is independent of the LiveKit drop, so tell the
|
||||
// server the camera/screenshare are off before the local tracks are
|
||||
// stopped below — otherwise a successful reconnect leaves the
|
||||
// server's voice_states row at camera=1/screenshare=1 forever (no
|
||||
// webhook clears a reconnected, non-rogue participant), occupying a
|
||||
// max_video slot the user can never free.
|
||||
const { localCamera, localScreenshare } = voiceStore.getState();
|
||||
if (this.ws !== null) {
|
||||
if (localCamera) {
|
||||
this.ws.send({ type: "voice_camera", payload: { enabled: false } });
|
||||
}
|
||||
if (localScreenshare) {
|
||||
this.ws.send({ type: "voice_screenshare", payload: { enabled: false } });
|
||||
}
|
||||
}
|
||||
// BUG-098: Stop leaked camera/screen tracks before room is nulled.
|
||||
stopManualCameraTrack(this._cameraState, this._room);
|
||||
stopManualScreenTracks(this._screenState, this._room);
|
||||
@@ -333,7 +368,18 @@ export class LiveKitSession {
|
||||
|
||||
// --- Room factory ---
|
||||
|
||||
/** The current room's E2EE worker. livekit never terminates it, so the
|
||||
* session must — a leaked worker keeps receiving every future room key
|
||||
* through the process-lifetime key provider's setKey fan-out. */
|
||||
private _e2eeWorker: Worker | null = null;
|
||||
|
||||
private createRoom(): Room {
|
||||
// livekit's per-room E2EEManager registers a SetKey listener on the
|
||||
// shared key provider and never removes it; only those managers
|
||||
// subscribe, so clear them all before the new Room re-registers.
|
||||
this._e2ee.keyProvider.removeAllListeners();
|
||||
this._e2eeWorker?.terminate();
|
||||
this._e2eeWorker = new Worker(new URL("livekit-client/e2ee-worker", import.meta.url));
|
||||
const quality = getStreamQuality();
|
||||
const isSource = quality === "source";
|
||||
const newRoom = new Room({
|
||||
@@ -363,7 +409,7 @@ export class LiveKitSession {
|
||||
// per-channel symmetric key. The SFU only sees encrypted frames.
|
||||
e2ee: {
|
||||
keyProvider: this._e2ee.keyProvider,
|
||||
worker: new Worker(new URL("livekit-client/e2ee-worker", import.meta.url)),
|
||||
worker: this._e2eeWorker,
|
||||
},
|
||||
});
|
||||
newRoom.on(RoomEvent.TrackSubscribed, this._eventHandlers.handleTrackSubscribed);
|
||||
@@ -393,6 +439,24 @@ export class LiveKitSession {
|
||||
this._deviceManager.setOnToast(this.onErrorCallback);
|
||||
}
|
||||
|
||||
/** True when an in-flight reconnect attempt for `channelId` has been
|
||||
* superseded and must stop touching shared state: the signal was
|
||||
* aborted, OR a newer connectAndSetup() already claimed `_state` (whether
|
||||
* by moving to "idle"/"connected" for a DIFFERENT channel, or — the
|
||||
* airtight case — by reaching "connected" for the SAME channel, since
|
||||
* connectAndSetup()'s entry-point leaveVoice(false) never runs while
|
||||
* `_room` reads null during "reconnecting" and so never aborts our
|
||||
* signal). State is always "reconnecting" during this loop's own
|
||||
* legitimate run (it only transitions to "connected" at the end of a
|
||||
* successful attempt), so the type check can never false-positive on a
|
||||
* still-current attempt. Checked at every checkpoint in the loop, in the
|
||||
* loop's own state-restore branch, and in the post-loop give-up path. */
|
||||
private reconnectSuperseded(signal: AbortSignal, channelId: number): boolean {
|
||||
return (
|
||||
signal.aborted || this._state.type !== "reconnecting" || this._currentChannelId !== channelId
|
||||
);
|
||||
}
|
||||
|
||||
/** Attempt to auto-reconnect after unexpected disconnect using stored token.
|
||||
* The signal is aborted by leaveVoice() to cancel the loop when the user
|
||||
* voluntarily leaves voice during the reconnect delay. */
|
||||
@@ -411,12 +475,16 @@ export class LiveKitSession {
|
||||
// oxlint-disable-next-line no-await-in-loop -- intentional sequential polling with backoff delay
|
||||
await new Promise((r) => setTimeout(r, LiveKitSession.RECONNECT_DELAY_MS));
|
||||
// If user manually left or joined a different channel during the delay, abort.
|
||||
if (signal.aborted || this._currentChannelId !== channelId) {
|
||||
if (this.reconnectSuperseded(signal, channelId)) {
|
||||
log.info("Auto-reconnect aborted — user left or channel changed");
|
||||
return;
|
||||
}
|
||||
// Aliased outside the try so the catch can tear down the attempt's own
|
||||
// room: this._room is null while state is "reconnecting".
|
||||
let attemptRoom: Room | null = null;
|
||||
try {
|
||||
const newRoom = this.createRoom();
|
||||
attemptRoom = newRoom;
|
||||
const cleanupAbortedReconnect = async (): Promise<void> => {
|
||||
newRoom.removeAllListeners();
|
||||
try {
|
||||
@@ -424,10 +492,14 @@ export class LiveKitSession {
|
||||
} catch (disconnectErr) {
|
||||
log.warn("Failed to disconnect room after reconnect abort", disconnectErr);
|
||||
}
|
||||
this._audioPipeline.setRoom(null);
|
||||
this._audioElements.setRoom(null);
|
||||
this._deviceManager.setRoom(null);
|
||||
this._deviceManager.setAudioPipeline(null);
|
||||
// Re-sync from the CURRENT shared state instead of unconditionally
|
||||
// nulling: by the time this runs, a newer attempt may already own
|
||||
// `_state` (and its room), and this attempt's own room is never the
|
||||
// one referenced there (we are aborting before reaching "connected").
|
||||
// syncModuleRooms() derives from `_room`, so it correctly nulls the
|
||||
// modules when nothing newer has connected yet, and correctly leaves
|
||||
// a newer session's wiring alone when one has.
|
||||
this.syncModuleRooms();
|
||||
};
|
||||
// Set state to reconnecting with the fresh room-less attempt info;
|
||||
// the actual room appears in "connected" state after connect succeeds.
|
||||
@@ -439,7 +511,7 @@ export class LiveKitSession {
|
||||
this._deviceManager.setRoom(newRoom);
|
||||
this._deviceManager.setAudioPipeline(this._audioPipeline);
|
||||
|
||||
if (signal.aborted || this._currentChannelId !== channelId) {
|
||||
if (this.reconnectSuperseded(signal, channelId)) {
|
||||
log.info("Auto-reconnect aborted after room creation");
|
||||
await cleanupAbortedReconnect();
|
||||
return;
|
||||
@@ -448,7 +520,7 @@ export class LiveKitSession {
|
||||
// oxlint-disable-next-line no-await-in-loop -- sequential reconnect: resolve URL then connect
|
||||
const resolvedUrl = await this.resolveLiveKitUrl(url, directUrl);
|
||||
|
||||
if (signal.aborted || this._currentChannelId !== channelId) {
|
||||
if (this.reconnectSuperseded(signal, channelId)) {
|
||||
log.info("Auto-reconnect aborted before room connect");
|
||||
await cleanupAbortedReconnect();
|
||||
return;
|
||||
@@ -465,7 +537,7 @@ export class LiveKitSession {
|
||||
// oxlint-disable-next-line no-await-in-loop -- sequential reconnect: must connect before restoring state
|
||||
await newRoom.connect(resolvedUrl, token);
|
||||
|
||||
if (signal.aborted || this._currentChannelId !== channelId) {
|
||||
if (this.reconnectSuperseded(signal, channelId)) {
|
||||
log.info("Auto-reconnect aborted after room connect");
|
||||
await cleanupAbortedReconnect();
|
||||
return;
|
||||
@@ -518,10 +590,13 @@ export class LiveKitSession {
|
||||
return;
|
||||
} catch (err) {
|
||||
log.warn("Auto-reconnect failed", { attempt, url, error: err });
|
||||
const failedRoom = this._room;
|
||||
if (failedRoom !== null) {
|
||||
failedRoom.removeAllListeners();
|
||||
failedRoom
|
||||
// Tear down this attempt's room (this._room is null in "reconnecting"
|
||||
// state) — a leaked room keeps its listeners, and its synchronous
|
||||
// Disconnected event would spawn a second, uncancellable reconnect
|
||||
// loop. null only if createRoom() itself threw.
|
||||
if (attemptRoom !== null) {
|
||||
attemptRoom.removeAllListeners();
|
||||
attemptRoom
|
||||
.disconnect()
|
||||
.catch((disconnectErr) =>
|
||||
log.warn("Failed to disconnect room after reconnect failure", disconnectErr),
|
||||
@@ -538,13 +613,27 @@ export class LiveKitSession {
|
||||
ac: this._state.ac,
|
||||
});
|
||||
}
|
||||
this._audioPipeline.setRoom(null);
|
||||
this._audioElements.setRoom(null);
|
||||
this._deviceManager.setRoom(null);
|
||||
this._deviceManager.setAudioPipeline(null);
|
||||
// See the matching comment in cleanupAbortedReconnect above: sync from
|
||||
// the current shared state rather than unconditionally nulling, so a
|
||||
// stale failed attempt cannot clobber a newer session's module wiring.
|
||||
this.syncModuleRooms();
|
||||
}
|
||||
}
|
||||
// All attempts exhausted — give up and clean up.
|
||||
// All attempts exhausted — give up and clean up. But first check this
|
||||
// loop is still current: the user may have left voice or joined a
|
||||
// different channel during the last attempt's delay/connect, in which
|
||||
// case `leaveVoice(true)` below would tear down the LIVE session that
|
||||
// replaced this one (CLAUDE.md: voice sessions are superseded, not
|
||||
// cancelled — cleanup here must be scoped to this attempt, not global).
|
||||
// The state-type check is what catches a re-join of the SAME channel:
|
||||
// connectAndSetup() overwrites `_state` without aborting our signal (the
|
||||
// `_room` getter is null while "reconnecting", so its entry-point
|
||||
// leaveVoice(false) never runs), leaving both `signal.aborted` false and
|
||||
// `_currentChannelId` equal to ours once that join reaches "connected".
|
||||
if (this.reconnectSuperseded(signal, channelId)) {
|
||||
log.info("Auto-reconnect give-up skipped — superseded");
|
||||
return;
|
||||
}
|
||||
// Send voice_leave over WS so the server removes our voice state;
|
||||
// without this the server and other clients see us as a ghost participant.
|
||||
log.error("Auto-reconnect exhausted all attempts, giving up");
|
||||
@@ -589,9 +678,14 @@ export class LiveKitSession {
|
||||
return proxyPath;
|
||||
}
|
||||
|
||||
/** Start (or reuse) the Rust-side local TCP-to-TLS proxy for LiveKit. */
|
||||
/** Start (or reuse) the Rust-side local TCP-to-TLS proxy for LiveKit.
|
||||
*
|
||||
* Always invokes start_livekit_proxy — never cache the port here. Only the
|
||||
* Rust side can compare the running proxy's TOFU pin against certs.json,
|
||||
* so after the user accepts a rotated cert a JS port cache would keep
|
||||
* every voice rejoin tunneling into the stale pin until logout. The Rust
|
||||
* reuse branch dedups unchanged host+pin, so the repeat call is cheap. */
|
||||
private async ensureLiveKitProxy(): Promise<number> {
|
||||
if (this.liveKitProxyPort !== null) return this.liveKitProxyPort;
|
||||
if (this.serverHost === null) throw new Error("no server host for LiveKit proxy");
|
||||
// Ensure host:port format — default to 443 (standard HTTPS) when the
|
||||
// server is behind a reverse proxy. Without an explicit port, the Rust
|
||||
@@ -706,7 +800,33 @@ export class LiveKitSession {
|
||||
if (room === null) return;
|
||||
|
||||
const state = voiceStore.getState();
|
||||
const muted = state.localMuted || state.localDeafened;
|
||||
// A bound PTT key means transmission is gated by press/release, but the
|
||||
// Rust poller only emits ptt-state on a state TRANSITION — an idle key
|
||||
// produces no event at all, so without this the freshly published mic
|
||||
// would stay hot and transmitting until the user's first press+release.
|
||||
// Only arm this when the poller is confirmed live (setPttPollingLive) —
|
||||
// gating on the stored key alone would close the mic permanently on
|
||||
// platforms where PTT can never actually report state (macOS's
|
||||
// is_key_down stub, pure-Wayland Linux with no XWayland).
|
||||
// Record the gate in pttGated, NEVER in localMuted: localMuted means "the
|
||||
// user muted themselves", and ptt.ts refuses to open the mic on a PTT
|
||||
// press while it is set — writing it here would close the mic for the
|
||||
// whole session instead of only until the first press.
|
||||
// On reconnect, don't recompute pttArmed from scratch — that always
|
||||
// yields false (mode !== "join") and ignores whatever pttGated the store
|
||||
// is still carrying from before the disconnect. If the user joined with
|
||||
// PTT armed and never pressed the key before the connection dropped, the
|
||||
// gate is still supposed to be closed; reading it back here (instead of
|
||||
// silently reopening the mic) is what keeps that promise across a
|
||||
// reconnect.
|
||||
const pttArmed =
|
||||
mode === "join"
|
||||
? isPttPollingLive() && loadPref<number>("pttVk", 0) !== 0
|
||||
: state.pttGated === true;
|
||||
if (mode === "join") {
|
||||
setPttGated(pttArmed);
|
||||
}
|
||||
const muted = pttArmed || state.localMuted || state.localDeafened;
|
||||
const deafened = state.localDeafened;
|
||||
const shouldEnableMicrophone = !muted;
|
||||
|
||||
@@ -784,6 +904,20 @@ export class LiveKitSession {
|
||||
this.onRemoteVideoRemovedCallback = null;
|
||||
}
|
||||
|
||||
/** Post-connect-checkpoint cleanup for a superseded connectAndSetup attempt
|
||||
* (checkpoints 3-5, after this attempt already installed its room into the
|
||||
* shared "connected" state). By the time one of these fires, a NEWER
|
||||
* attempt may have already claimed `_state` (and torn down THIS attempt's
|
||||
* room via its own entry-point leaveVoice(false)) — so this must disconnect
|
||||
* only the passed-in localRoom, mirroring checkpoint 2, and must never call
|
||||
* the global leaveVoice()/touch `_state`, or it tears down whichever
|
||||
* session currently occupies `_state`, which now belongs to the newer
|
||||
* attempt. */
|
||||
private disconnectSupersededLocalRoom(localRoom: Room): void {
|
||||
localRoom.removeAllListeners();
|
||||
localRoom.disconnect().catch((err) => log.debug("Failed to disconnect superseded room", err));
|
||||
}
|
||||
|
||||
/** Shared connect-with-retry + post-connect setup used by both the primary
|
||||
* handleVoiceToken path and the pending-join drain loop.
|
||||
* Returns true if the room ended up connected and set up,
|
||||
@@ -797,12 +931,13 @@ export class LiveKitSession {
|
||||
isKeyHolder?: boolean,
|
||||
): Promise<boolean | "superseded"> {
|
||||
if (this._room !== null) this.leaveVoice(false);
|
||||
// Increment the generation counter and embed it into the "connecting" state.
|
||||
// Any newer call to connectAndSetup() will produce a larger generation,
|
||||
// making myGeneration !== currentGeneration at each checkpoint.
|
||||
const prevState = this._state;
|
||||
const prevGeneration = prevState.type === "connecting" ? prevState.joinGeneration : 0;
|
||||
const myGeneration = prevGeneration + 1;
|
||||
// Draw the next generation from the monotonic instance counter (never
|
||||
// re-derived from `_state`) and embed it into the "connecting" state.
|
||||
// Any newer call to connectAndSetup() will produce a strictly larger
|
||||
// generation, making myGeneration !== currentGeneration at each
|
||||
// checkpoint even if this attempt's own state transitioned through
|
||||
// "idle" in the meantime.
|
||||
const myGeneration = ++this._joinGenerationCounter;
|
||||
this.setState({ type: "connecting", pendingJoin: null, joinGeneration: myGeneration });
|
||||
// "joining" = connecting to the room; the E2EE "securing" phase is set below.
|
||||
setVoiceStatus("joining");
|
||||
@@ -841,8 +976,30 @@ export class LiveKitSession {
|
||||
setVoiceStatus("securing");
|
||||
const keyExchangeOk = await this._e2ee.setupKeyExchange(isKeyHolder ?? false, channelId);
|
||||
if (!keyExchangeOk) {
|
||||
// setupKeyExchange() also returns false when clearState() aborted the
|
||||
// wait (e.g. a supersession that ran leaveVoice() while we were
|
||||
// blocked here) — indistinguishable from a genuine timeout by return
|
||||
// value alone. Check ownership before treating it as a real failure:
|
||||
// a superseded attempt must not fire a spurious toast, send
|
||||
// voice_leave (it carries no channel id and would act on whichever
|
||||
// channel the NEWER attempt just joined), or clear the store's
|
||||
// currentChannelId that the newer join just set.
|
||||
if (this._state.type !== "connecting" || this._state.joinGeneration !== myGeneration) {
|
||||
log.info("connectAndSetup: superseded during key exchange — aborting", {
|
||||
channelId,
|
||||
myGeneration,
|
||||
});
|
||||
return "superseded";
|
||||
}
|
||||
this.onErrorCallback?.("e2ee_timeout");
|
||||
this.leaveVoice(false);
|
||||
// The exchange timed out BEFORE room.connect(): no SFU participant
|
||||
// exists, so no LiveKit webhook will ever clean up, and the server
|
||||
// registered the join when it sent voice_token. Send voice_leave and
|
||||
// leave the store's voice channel (like the reconnect-exhausted give-up
|
||||
// path) or the stale row ghosts forever and can wedge the channel's
|
||||
// key-holder election.
|
||||
this.leaveVoice(true);
|
||||
leaveVoiceChannel();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -954,7 +1111,7 @@ export class LiveKitSession {
|
||||
log.info("connectAndSetup: superseded after restoreLocalVoiceState — aborting", {
|
||||
channelId,
|
||||
});
|
||||
this.leaveVoice(false);
|
||||
this.disconnectSupersededLocalRoom(localRoom);
|
||||
return "superseded";
|
||||
}
|
||||
|
||||
@@ -972,7 +1129,7 @@ export class LiveKitSession {
|
||||
log.info("connectAndSetup: superseded after audioinput switch — aborting", {
|
||||
channelId,
|
||||
});
|
||||
this.leaveVoice(false);
|
||||
this.disconnectSupersededLocalRoom(localRoom);
|
||||
return "superseded";
|
||||
}
|
||||
|
||||
@@ -990,7 +1147,7 @@ export class LiveKitSession {
|
||||
log.info("connectAndSetup: superseded after audiooutput switch — aborting", {
|
||||
channelId,
|
||||
});
|
||||
this.leaveVoice(false);
|
||||
this.disconnectSupersededLocalRoom(localRoom);
|
||||
return "superseded";
|
||||
}
|
||||
|
||||
@@ -1004,6 +1161,12 @@ export class LiveKitSession {
|
||||
} catch (err) {
|
||||
log.error("Failed to connect to LiveKit", { url: resolvedUrl, error: err });
|
||||
if (localRoom !== null) {
|
||||
// Drop this attempt's listeners BEFORE disconnecting: handleDisconnected
|
||||
// acts on the shared session state, so a failed attempt's Disconnected
|
||||
// event would otherwise tear down (or spawn a reconnect loop for)
|
||||
// whichever session owns `_state` by then — which, when this attempt
|
||||
// has been superseded, is a live one that belongs to a newer join.
|
||||
localRoom.removeAllListeners();
|
||||
try {
|
||||
void localRoom.disconnect();
|
||||
} catch {
|
||||
@@ -1011,7 +1174,28 @@ export class LiveKitSession {
|
||||
}
|
||||
this.onErrorCallback?.("Failed to join voice — connection error");
|
||||
}
|
||||
this.leaveVoice(false);
|
||||
// Only touch the shared session state if this attempt is still current.
|
||||
// A superseded attempt must not clear a newer join's server-side voice
|
||||
// membership — leaveVoice's voice_leave frame carries no channel id and
|
||||
// acts on whichever channel the user currently occupies, so sending it
|
||||
// here for a stale attempt would delete the NEW join's voice_states row
|
||||
// — nor reset a live session back to idle (CLAUDE.md: voice sessions are
|
||||
// superseded, not cancelled).
|
||||
if (
|
||||
this._state.type === "connecting" &&
|
||||
this._state.joinGeneration === myGeneration &&
|
||||
this._state.pendingJoin === null
|
||||
) {
|
||||
// The connect attempt failed entirely: no SFU participant was ever
|
||||
// created, so no LiveKit webhook will ever clean up, and the server
|
||||
// already registered the join when it sent voice_token. Send
|
||||
// voice_leave and leave the store's voice channel (mirroring the
|
||||
// e2ee-timeout and reconnect-exhausted give-up paths) or the stale
|
||||
// voice_states row ghosts forever and can wedge the channel's
|
||||
// key-holder election.
|
||||
this.leaveVoice(true);
|
||||
leaveVoiceChannel();
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
// Only clear "connecting" back to "idle" if we are still in the connecting
|
||||
@@ -1138,10 +1322,16 @@ export class LiveKitSession {
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
setListenOnly(false);
|
||||
// BUG-103: Honor deafened state — keep mic muted if user is deafened.
|
||||
const { localDeafened } = voiceStore.getState();
|
||||
if (localDeafened) {
|
||||
// Also honor a moderator's server-mute, a genuine self-mute, and an
|
||||
// unpressed push-to-talk key the same way: a listen-only join publishes
|
||||
// no audio track, so none of these have anything to act on and persist
|
||||
// silently — republishing here must not hand the whole channel a
|
||||
// fresh, unmuted track. Shares applyMicMuteState's own gate rather
|
||||
// than re-deriving a narrower one (the setMuted() guard does not cover
|
||||
// this direct setMicrophoneEnabled call).
|
||||
if (isMicPolicyGated()) {
|
||||
await this.applyMicMuteState(true);
|
||||
log.info("Microphone acquired but muted (user is deafened)");
|
||||
log.info("Microphone acquired but muted (mute/deafen/server-mute/PTT gate active)");
|
||||
} else {
|
||||
setLocalMuted(false);
|
||||
log.info("Microphone permission granted — exited listen-only mode");
|
||||
@@ -1182,8 +1372,11 @@ export class LiveKitSession {
|
||||
room.removeAllListeners();
|
||||
room.disconnect().catch((err) => log.warn("room.disconnect() error (non-fatal)", err));
|
||||
}
|
||||
// Clear client-side E2EE state (ECDH keypair, room key, peer keys).
|
||||
// Clear client-side E2EE state (ECDH keypair, room key, peer keys), and
|
||||
// kill the E2EE worker so the last room key does not stay resident in it.
|
||||
this._e2ee.clearState();
|
||||
this._e2eeWorker?.terminate();
|
||||
this._e2eeWorker = null;
|
||||
// Transition to idle — atomically clears room, channelId, tokens, reconnectAc,
|
||||
// pendingJoin, and the joinGeneration (idle has none). Any in-flight
|
||||
// connectAndSetup() will detect the state type change at its next checkpoint.
|
||||
@@ -1211,11 +1404,31 @@ export class LiveKitSession {
|
||||
}
|
||||
|
||||
setMuted(muted: boolean): void {
|
||||
// A moderator-imposed mute is not ours to lift. The server only mutes the
|
||||
// track SIDs that exist at mute time and the LiveKit grant still carries
|
||||
// the microphone publish source, so unmuting here would publish a fresh
|
||||
// track the SFU happily forwards — server-side muting relies on the client
|
||||
// refusing its own unmute. The guard lives here rather than in the callers
|
||||
// because push-to-talk calls straight into this method (ptt.ts), bypassing
|
||||
// the voice widget's own check. Muting is always permitted.
|
||||
if (!muted && voiceStore.getState().localServerMuted === true) {
|
||||
log.debug("Ignoring unmute: server-muted by a moderator");
|
||||
return;
|
||||
}
|
||||
setLocalMuted(muted);
|
||||
this.applyMicMuteState(muted).catch((e) => log.warn("applyMicMuteState failed", e));
|
||||
}
|
||||
|
||||
setDeafened(deafened: boolean): void {
|
||||
// Mirror setMuted's guard: a moderator-imposed deafen is not ours to
|
||||
// lift locally. Without this, undeafening while server-deafened
|
||||
// resubscribes remote audio and unmutes the mic client-side even though
|
||||
// the server still considers the user deafened — see setMuted() above
|
||||
// for why the refusal must live in this shared entry point.
|
||||
if (!deafened && voiceStore.getState().localServerDeafened === true) {
|
||||
log.debug("Ignoring undeafen: server-deafened by a moderator");
|
||||
return;
|
||||
}
|
||||
setLocalDeafened(deafened);
|
||||
this._audioElements.applyRemoteAudioSubscriptionState(deafened);
|
||||
const shouldMute = deafened || voiceStore.getState().localMuted;
|
||||
@@ -1236,6 +1449,14 @@ export class LiveKitSession {
|
||||
await room.localParticipant.setMicrophoneEnabled(false);
|
||||
log.debug("Mic fully unpublished (muted)");
|
||||
} else {
|
||||
// A push-to-talk gate (or, defensively, a moderator's server-mute) is
|
||||
// not this call's to lift — setMuted/setDeafened only guard their own
|
||||
// flag before calling here, so this is the one place every re-enable
|
||||
// path (present and future) shares the full policy check.
|
||||
if (isMicPolicyGated()) {
|
||||
log.debug("Skipping mic re-publish — still gated (mute/deafen/server-mute/PTT)");
|
||||
return;
|
||||
}
|
||||
// Re-enable mic — this re-publishes the track to the SFU
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
// Rebuild the audio pipeline on the fresh track
|
||||
|
||||
@@ -107,6 +107,12 @@ async function rotateOldFiles(): Promise<void> {
|
||||
/** Handle a log entry by serializing it and buffering for disk write. */
|
||||
function onLogEntry(entry: LogEntry): void {
|
||||
if (!initialized) return;
|
||||
// Break the self-sustaining loop: a persistently failing flush logs
|
||||
// through this module's own logger (flush failed / rotation failed),
|
||||
// which would otherwise re-enter here and re-arm scheduleFlush every 2s
|
||||
// forever. The entry still reaches console/the in-memory ring buffer —
|
||||
// it just never gets queued for its own persistence.
|
||||
if (entry.component === "logPersistence") return;
|
||||
buffer.push(JSON.stringify(entry));
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
@@ -3,12 +3,17 @@
|
||||
* Creates a modal with backdrop, optional click-outside and Escape key
|
||||
* dismissal, and clean lifecycle management via AbortController.
|
||||
*
|
||||
* Every factory modal carries the dialog accessibility contract (DC-13):
|
||||
* role="dialog" + aria-modal on the container, focus moved into the dialog on
|
||||
* open and restored on close, and a Tab-cycling focus trap (lib/a11y.ts).
|
||||
*
|
||||
* CSS classes match the existing project convention:
|
||||
* - div.modal-overlay.visible (backdrop)
|
||||
* - div.modal (content container)
|
||||
*/
|
||||
|
||||
import { createElement } from "./dom";
|
||||
import { applyDialogSemantics, focusDialog, trapFocus } from "./a11y";
|
||||
|
||||
export interface ModalOptions {
|
||||
/** The content element to place inside the modal container. */
|
||||
@@ -23,6 +28,8 @@ export interface ModalOptions {
|
||||
readonly className?: string;
|
||||
/** Additional attributes on the overlay element (e.g. data-testid). */
|
||||
readonly overlayAttrs?: Readonly<Record<string, string>>;
|
||||
/** Accessible name for the dialog (aria-label on the .modal container). */
|
||||
readonly ariaLabel?: string;
|
||||
/** AbortSignal for automatic cleanup when the parent component is destroyed. */
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
@@ -53,6 +60,7 @@ export function createModal(
|
||||
closeOnEscape = true,
|
||||
className,
|
||||
overlayAttrs,
|
||||
ariaLabel,
|
||||
signal,
|
||||
} = options;
|
||||
|
||||
@@ -70,16 +78,20 @@ export function createModal(
|
||||
// Build modal container
|
||||
const modalClass = className !== undefined ? `modal ${className}` : "modal";
|
||||
const modal = createElement("div", { class: modalClass });
|
||||
applyDialogSemantics(modal, ariaLabel !== undefined ? { label: ariaLabel } : {});
|
||||
trapFocus(modal, ac.signal);
|
||||
modal.appendChild(content);
|
||||
overlay.appendChild(modal);
|
||||
|
||||
let closed = false;
|
||||
let restoreFocus: (() => void) | null = null;
|
||||
|
||||
function handleClose(): void {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
overlay.remove();
|
||||
ac.abort();
|
||||
restoreFocus?.();
|
||||
if (onClose !== undefined) {
|
||||
onClose();
|
||||
}
|
||||
@@ -119,6 +131,7 @@ export function createModal(
|
||||
if (!closed) {
|
||||
closed = true;
|
||||
overlay.remove();
|
||||
restoreFocus?.();
|
||||
onClose?.();
|
||||
if (!ac.signal.aborted) {
|
||||
ac.abort();
|
||||
@@ -131,6 +144,11 @@ export function createModal(
|
||||
|
||||
container.appendChild(overlay);
|
||||
|
||||
// After append: move focus into the dialog and remember where it came from.
|
||||
// Callers that focus a specific control afterwards (e.g. the prompt input)
|
||||
// simply override the initial target; the restore still works.
|
||||
restoreFocus = focusDialog(modal);
|
||||
|
||||
return {
|
||||
overlay,
|
||||
modal,
|
||||
@@ -211,7 +229,7 @@ export function createPromptModal(
|
||||
content.appendChild(row);
|
||||
|
||||
const instance = createModal(
|
||||
{ content, onClose: options.onClose, className: "modal-prompt" },
|
||||
{ content, onClose: options.onClose, className: "modal-prompt", ariaLabel: options.title },
|
||||
container,
|
||||
);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { notificationAllowed } from "./channel-mutes";
|
||||
import { loadUserStatus } from "./userStatus";
|
||||
import { authStore } from "@stores/auth.store";
|
||||
import { channelsStore } from "@stores/channels.store";
|
||||
import { dmStore, dmDisplayName } from "@stores/dm.store";
|
||||
import type { ChatMessagePayload } from "./types";
|
||||
import { mentionsCurrentUser } from "./mentions";
|
||||
import { createLogger } from "./logger";
|
||||
@@ -19,11 +20,21 @@ function isWindowFocused(): boolean {
|
||||
return document.hasFocus();
|
||||
}
|
||||
|
||||
/** Get the channel name for a given channel ID. */
|
||||
function getChannelName(channelId: number): string {
|
||||
const channels = channelsStore.getState().channels;
|
||||
const channel = channels.get(channelId);
|
||||
return channel?.name ?? `Channel ${channelId}`;
|
||||
/**
|
||||
* The name to show for a given channel/DM id, and whether it is a DM (a DM
|
||||
* gets no "#" prefix -- it is not a channel).
|
||||
*
|
||||
* DM ids are absent from channelsStore until the conversation is opened
|
||||
* (dispatcher.ts), so they must be checked first or the fallback below always
|
||||
* wins and a DM notification reads "Channel <id>". dmDisplayName is the one
|
||||
* place every DM-labelling surface (sidebar, header, quick switcher, and
|
||||
* this) agrees on what a conversation is called.
|
||||
*/
|
||||
function resolveNotificationChannel(channelId: number): { name: string; isDm: boolean } {
|
||||
const dm = dmStore.getState().channels.find((c) => c.channelId === channelId);
|
||||
if (dm !== undefined) return { name: dmDisplayName(dm), isDm: true };
|
||||
const channel = channelsStore.getState().channels.get(channelId);
|
||||
return { name: channel?.name ?? `Channel ${channelId}`, isDm: false };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,7 +84,8 @@ export function notifyIncomingMessage(payload: ChatMessagePayload): void {
|
||||
// flash stays: it's a passive hint, not a notification.
|
||||
const dnd = loadUserStatus() === "dnd";
|
||||
|
||||
const channelName = getChannelName(payload.channel_id);
|
||||
const { name: channelName, isDm } = resolveNotificationChannel(payload.channel_id);
|
||||
const channelLabel = isDm ? channelName : `#${channelName}`;
|
||||
|
||||
// oxlint-disable-next-line consistent-function-scoping -- co-located with its sole caller for readability
|
||||
function sanitizeNotif(s: string, maxLen: number): string {
|
||||
@@ -84,8 +96,8 @@ export function notifyIncomingMessage(payload: ChatMessagePayload): void {
|
||||
|
||||
const title = sanitizeNotif(
|
||||
mentioned
|
||||
? `${payload.user.username} mentioned you in #${channelName}`
|
||||
: `${payload.user.username} in #${channelName}`,
|
||||
? `${payload.user.username} mentioned you in ${channelLabel}`
|
||||
: `${payload.user.username} in ${channelLabel}`,
|
||||
80,
|
||||
);
|
||||
const body = sanitizeNotif(payload.content, 100);
|
||||
|
||||
@@ -17,8 +17,26 @@
|
||||
|
||||
const STORAGE_PREFIX = "owncord:nsfw-ack:";
|
||||
|
||||
/**
|
||||
* Server host the acknowledgements below belong to. The app is multi-server,
|
||||
* a server/account switch is in-document SPA navigation (no reload, so
|
||||
* sessionStorage survives it), and channel ids are per-server SQLite
|
||||
* autoincrement integers — without a host component in the key, an ack for
|
||||
* channel N on server A silently suppresses the gate for an unrelated
|
||||
* channel N on server B. `null` (before any host is known, or in a context
|
||||
* that never sets one) falls back to the original unscoped key.
|
||||
*/
|
||||
let currentHost: string | null = null;
|
||||
|
||||
/** Point acknowledgements at a specific server. Call on connect and on
|
||||
* server switch, mirroring `channel-mutes.ts`'s `setChannelMutesHost`. */
|
||||
export function setNsfwGateHost(host: string | null): void {
|
||||
currentHost = host;
|
||||
}
|
||||
|
||||
function storageKey(channelId: number): string {
|
||||
return `${STORAGE_PREFIX}${channelId}`;
|
||||
const suffix = currentHost === null ? `${channelId}` : `${channelId}:${currentHost}`;
|
||||
return `${STORAGE_PREFIX}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -49,6 +49,8 @@ export const ServerMessageType = {
|
||||
CALL_DECLINED: "call_declined",
|
||||
VOICE_E2EE_ANNOUNCE: "voice_e2ee_announce", // broadcast (same string as client msg)
|
||||
VOICE_E2EE_OFFER: "voice_e2ee_offer", // relay (same string as client msg)
|
||||
COMMAND_REPLY: "command_reply", // ephemeral plugin reply, sent only to the invoking client
|
||||
PLUGIN_BROADCAST: "plugin_broadcast", // plugin channel broadcast, gated by the sender's SEND_MESSAGES
|
||||
} as const;
|
||||
|
||||
export type ServerMessageTypeValue = (typeof ServerMessageType)[keyof typeof ServerMessageType];
|
||||
@@ -84,6 +86,7 @@ export const ClientMessageType = {
|
||||
VOICE_E2EE_OFFER: "voice_e2ee_offer",
|
||||
CALL_RING: "call_ring",
|
||||
CALL_DECLINE: "call_decline",
|
||||
CHAT_COMMAND: "chat_command", // plugin slash-command dispatch (Phase C)
|
||||
} as const;
|
||||
|
||||
export type ClientMessageTypeValue = (typeof ClientMessageType)[keyof typeof ClientMessageType];
|
||||
|
||||
@@ -5,13 +5,60 @@
|
||||
*/
|
||||
|
||||
import { loadPref, savePref } from "@components/settings/helpers";
|
||||
import { voiceStore } from "@stores/voice.store";
|
||||
import { voiceStore, setPttGated, setPttPollingLive } from "@stores/voice.store";
|
||||
import { createLogger } from "./logger";
|
||||
|
||||
const log = createLogger("ptt");
|
||||
|
||||
let listening = false;
|
||||
let pttUnsubscribe: (() => void) | null = null;
|
||||
/** Unsubscribes from voiceStore so a non-PTT unmute (widget button,
|
||||
* retryMicPermission, ...) can clear a stale `pttOwnsMute` latch. See its
|
||||
* registration in initPtt for why. */
|
||||
let pttStoreUnsubscribe: (() => void) | null = null;
|
||||
/** Unsubscribes the 'ptt-error' listener registered in initPtt. */
|
||||
let pttErrorUnsubscribe: (() => void) | null = null;
|
||||
|
||||
/** True when the mute currently in effect is the one a PTT release applied,
|
||||
* rather than one the user asked for. livekitSession.setMuted() writes
|
||||
* localMuted for every caller, so that flag alone cannot tell "the user
|
||||
* muted themselves" (which a press must never lift — v006) from "the last
|
||||
* release muted the mic" (which it must). Reset on init/stop so a mute that
|
||||
* outlived the previous PTT binding is treated as the user's.
|
||||
*
|
||||
* This alone is not enough: the only writes are PTT's own (press/release),
|
||||
* so a non-PTT unmute (the widget's mic button, retryMicPermission) never
|
||||
* clears it. If the user then re-mutes, the stale `true` survives and the
|
||||
* next PTT press wrongly treats their genuine self-mute as PTT's own to
|
||||
* lift. The voiceStore subscription registered in initPtt closes that gap
|
||||
* by clearing the latch on any observed unmute, not just PTT's. */
|
||||
let pttOwnsMute = false;
|
||||
|
||||
/** Clear the PTT gate and, if the mute in effect is the one PTT's own last
|
||||
* release applied (not one the user asked for) and nothing else
|
||||
* independently wants the mic closed, re-open it. Used whenever the poller
|
||||
* can no longer produce a future press/release edge to lift that mute —
|
||||
* clearing the key binding (stopPtt) or the polling thread dying
|
||||
* (ptt-error) — so a PTT-applied mute is never stranded gated with no
|
||||
* recovery path.
|
||||
*
|
||||
* `mutedByPtt` must be the caller's `pttOwnsMute` latch read BEFORE it
|
||||
* resets the latch to false: both call sites zero it ahead of calling this
|
||||
* (a mute must not outlive its PTT binding), so by the time this body runs
|
||||
* the module-level flag itself is already false and can't be consulted
|
||||
* here — the pre-reset value has to be threaded through instead. */
|
||||
function ungateMic(mutedByPtt: boolean): void {
|
||||
if (voiceStore.getState().pttGated !== true) return;
|
||||
setPttGated(false);
|
||||
const { localMuted, localDeafened } = voiceStore.getState();
|
||||
if (localDeafened) return;
|
||||
// A mute the user asked for is never PTT's to lift (v006) — only lift it
|
||||
// when it's the one PTT's own release applied.
|
||||
if (localMuted && !mutedByPtt) return;
|
||||
void import("./livekitSession")
|
||||
.then(({ setMuted }) => setMuted(false))
|
||||
.catch((e) => log.warn("Failed to re-open mic after clearing PTT gate", e));
|
||||
}
|
||||
|
||||
// Well-known virtual key code names for display
|
||||
const VK_NAMES: ReadonlyMap<number, string> = new Map([
|
||||
@@ -89,9 +136,45 @@ export async function initPtt(): Promise<void> {
|
||||
await invoke("ptt_set_key", { vkCode: vk });
|
||||
await invoke("ptt_start");
|
||||
|
||||
// Clean up previous listener if any
|
||||
// ptt_start spawns its thread unconditionally, so a running thread is NOT
|
||||
// evidence that PTT works — on macOS is_key_down is a stub and on
|
||||
// pure-Wayland Linux there is no reachable display. Ask the backend what
|
||||
// it can actually observe, so livekitSession only applies its join-time
|
||||
// PTT mute where a press can genuinely lift it again.
|
||||
const supported = await invoke<boolean>("ptt_polling_supported");
|
||||
setPttPollingLive(supported);
|
||||
if (!supported) {
|
||||
log.warn("PTT key polling unsupported on this platform — mic will not be gated at join");
|
||||
}
|
||||
|
||||
// Clean up previous listeners if any
|
||||
pttUnsubscribe?.();
|
||||
pttUnsubscribe = null;
|
||||
pttStoreUnsubscribe?.();
|
||||
pttStoreUnsubscribe = null;
|
||||
pttErrorUnsubscribe?.();
|
||||
pttErrorUnsubscribe = null;
|
||||
// A mute left over from a previous binding is no longer PTT's to lift.
|
||||
pttOwnsMute = false;
|
||||
|
||||
// See pttOwnsMute's doc comment: a non-PTT unmute must clear the latch
|
||||
// too, or a later genuine self-mute is mistaken for one PTT itself
|
||||
// applied and a subsequent press republishes the mic over it.
|
||||
pttStoreUnsubscribe = voiceStore.subscribe((s) => {
|
||||
if (!s.localMuted) pttOwnsMute = false;
|
||||
});
|
||||
|
||||
// Surface a backend polling-thread panic: no further ptt-state events can
|
||||
// ever arrive afterward, so a mute the last release applied would
|
||||
// otherwise be stranded with no way to lift it.
|
||||
pttErrorUnsubscribe = await listen<string>("ptt-error", (event) => {
|
||||
log.warn("PTT polling thread stopped unexpectedly", { error: event.payload });
|
||||
setPttPollingLive(false);
|
||||
// Capture before resetting — see ungateMic's doc comment.
|
||||
const mutedByPtt = pttOwnsMute;
|
||||
pttOwnsMute = false;
|
||||
ungateMic(mutedByPtt);
|
||||
});
|
||||
|
||||
// Listen for press/release events
|
||||
const unsub = await listen<boolean>("ptt-state", (event) => {
|
||||
@@ -99,14 +182,39 @@ export async function initPtt(): Promise<void> {
|
||||
const channelId = voiceStore.getState().currentChannelId;
|
||||
if (channelId === null) return;
|
||||
|
||||
const pressed = event.payload;
|
||||
// Track the PTT gate in the store regardless of whether we end up
|
||||
// calling setMuted below — this is the source of truth other code
|
||||
// (e.g. the widget) can read without depending on localMuted.
|
||||
setPttGated(!pressed);
|
||||
|
||||
// livekitSession (and the ~1.3 MB livekit-client SDK behind it) is
|
||||
// loaded lazily so it stays out of the startup path. In a voice channel
|
||||
// the module is necessarily already loaded, so this import resolves
|
||||
// from the module cache in a microtask.
|
||||
void import("./livekitSession")
|
||||
.then(({ setMuted }) => {
|
||||
setMuted(!event.payload);
|
||||
log.debug(event.payload ? "PTT pressed — unmuted" : "PTT released — muted");
|
||||
const { localMuted, localDeafened } = voiceStore.getState();
|
||||
if (pressed) {
|
||||
// Never let PTT lift a mute the user asked for — that would
|
||||
// republish the mic to every peer while voice_states.muted (and
|
||||
// every remote UI) still shows the user muted (v006). The mute a
|
||||
// previous release applied is PTT's own, so lifting that is fine.
|
||||
if (localDeafened || (localMuted && !pttOwnsMute)) {
|
||||
log.debug("PTT pressed — staying muted (user is self-muted or deafened)");
|
||||
return;
|
||||
}
|
||||
setMuted(false);
|
||||
pttOwnsMute = false;
|
||||
log.debug("PTT pressed — unmuted");
|
||||
return;
|
||||
}
|
||||
// Muting is always safe. setMuted() writes localMuted, so record
|
||||
// whether this release is what muted the mic — only then may the
|
||||
// next press lift it.
|
||||
setMuted(true);
|
||||
pttOwnsMute = !localMuted;
|
||||
log.debug("PTT released — muted");
|
||||
})
|
||||
.catch((e) => log.warn("Failed to apply PTT mute", e));
|
||||
});
|
||||
@@ -115,7 +223,10 @@ export async function initPtt(): Promise<void> {
|
||||
listening = true;
|
||||
log.info("PTT started", { vk, name: vkName(vk) });
|
||||
} catch (err) {
|
||||
// Not in Tauri environment (dev mode)
|
||||
// Not in Tauri environment (dev mode), or the backend rejected a command.
|
||||
// Either way no ptt-state event can arrive, so the poller is not live —
|
||||
// leaving a stale `true` here would let a later join mute the mic for good.
|
||||
setPttPollingLive(false);
|
||||
log.debug("PTT not available", { error: err });
|
||||
}
|
||||
}
|
||||
@@ -126,6 +237,20 @@ export async function stopPtt(): Promise<void> {
|
||||
try {
|
||||
pttUnsubscribe?.();
|
||||
pttUnsubscribe = null;
|
||||
pttStoreUnsubscribe?.();
|
||||
pttStoreUnsubscribe = null;
|
||||
pttErrorUnsubscribe?.();
|
||||
pttErrorUnsubscribe = null;
|
||||
// Capture before resetting — see ungateMic's doc comment.
|
||||
const mutedByPtt = pttOwnsMute;
|
||||
pttOwnsMute = false;
|
||||
// No further ptt-state events once the loop is torn down; clear the flag
|
||||
// before the await so a concurrent join cannot observe a stale `true`.
|
||||
setPttPollingLive(false);
|
||||
// With the key idle there is no press/release edge left to lift a mute
|
||||
// PTT's last release applied — rearm now, or it stays stranded for the
|
||||
// rest of the voice session (see ungateMic's doc comment).
|
||||
ungateMic(mutedByPtt);
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
await invoke("ptt_stop");
|
||||
listening = false;
|
||||
|
||||
@@ -24,6 +24,11 @@ let sender: MarkReadSender | null = null;
|
||||
*/
|
||||
export function setMarkReadSender(next: MarkReadSender | null): void {
|
||||
sender = next;
|
||||
// A re-registration means a new connection (MainPage mounts once per
|
||||
// session), so anything `markAllRead` still had queued belongs to the
|
||||
// previous server. Channel ids are per-server, so letting those fire would
|
||||
// mark the *new* server's same-numbered channel read.
|
||||
cancelPendingMarkAll();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,12 +71,52 @@ export function unreadChannelIds(): readonly number[] {
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's `mark_read` handler shares a 5-per-second-per-user budget with
|
||||
* `channel_focus` (Server/ws/handlers_presence.go) and silently drops frames
|
||||
* over that budget — no error reaches the client. A burst of `mark_read`
|
||||
* sends larger than the budget would still clear every local badge (see
|
||||
* `markChannelRead`), so the excess channels' badges would resurrect on the
|
||||
* next `ready` once the server re-asserts its own unread counts. Pacing the
|
||||
* burst to below the budget, with headroom for a `channel_focus` that may
|
||||
* have already spent a slot, keeps every send inside a window the server
|
||||
* actually honours.
|
||||
*/
|
||||
const MARK_ALL_READ_BURST_SIZE = 4;
|
||||
const MARK_ALL_READ_BURST_INTERVAL_MS = 1100;
|
||||
|
||||
/** Timers for the not-yet-sent tail of the current `markAllRead` burst. Held so
|
||||
* a second mark-all, or a new connection, can drop the stale ones instead of
|
||||
* letting them land against a channel list that has since been replaced. */
|
||||
let pendingMarkAll: Array<ReturnType<typeof setTimeout>> = [];
|
||||
|
||||
function cancelPendingMarkAll(): void {
|
||||
for (const t of pendingMarkAll) clearTimeout(t);
|
||||
pendingMarkAll = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark every unread channel and DM read. Returns how many were marked, so the
|
||||
* caller can stay silent when there was nothing to do.
|
||||
*
|
||||
* Sent in bursts of `MARK_ALL_READ_BURST_SIZE` spaced `MARK_ALL_READ_BURST_INTERVAL_MS`
|
||||
* apart — see the budget note above. Each channel's local badge is cleared at
|
||||
* the moment its own frame actually goes out, not up front, so a channel
|
||||
* whose send hasn't fired yet still shows unread rather than lying about it.
|
||||
*/
|
||||
export function markAllRead(): number {
|
||||
// A second click supersedes the first: its own `unreadChannelIds()` already
|
||||
// covers everything the earlier burst had not sent yet, so keeping the old
|
||||
// timers would only duplicate sends and spend budget twice.
|
||||
cancelPendingMarkAll();
|
||||
const ids = unreadChannelIds();
|
||||
for (const id of ids) markChannelRead(id);
|
||||
for (const [i, id] of ids.entries()) {
|
||||
const delay = Math.floor(i / MARK_ALL_READ_BURST_SIZE) * MARK_ALL_READ_BURST_INTERVAL_MS;
|
||||
if (delay === 0) {
|
||||
markChannelRead(id);
|
||||
} else {
|
||||
pendingMarkAll.push(setTimeout(() => markChannelRead(id), delay));
|
||||
}
|
||||
}
|
||||
return ids.length;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user