Compare commits

..
Author SHA1 Message Date
molluskandClaude Fable 5 e043810eb0 test: presence gate — a host fault clears the ticket for peers, promptly
The 🟡 S2 gap: presence-side ticket removal on fault was asserted by no
gate, and the "same code path as StopScreenShare" argument turned out
false — the fault handler duplicates the presence statements, so a
mutant deleting them passed all 644 tests. Nothing on the sharer's own
UiEvent channel can witness presence; it is only observable from
another node.

The gate runs a real second core as an observer in a SEPARATE PROCESS
(this test binary re-invoked as `presence_probe_helper`, its own
XDG_CONFIG_HOME): two in-process cores would load the same identity.key
and collapse into one node id, and swapping the env var between spawns
races other threads' getenv. The observer asserts the sharer's
PeerState.sharing goes Some → None on fault.

The fake host is a wedge — valid-shaped ticket (the OBSERVER's gossip
ingest sanitizes peer tickets; a garbage one is nulled to None and the
gate goes vacuous), ~1 s of life, then closes stdout while trapping
SIGINT — so the reap burns the full stop grace and TIME discriminates
the ordering, like the SIGINT gate: presence-first clears in ~1 s, the
old reap-then-presence ordering in ~3 s, asserted < the 2 s grace.
Mutation-verified both ways: presence removal deleted ⇒ observer times
out; old ordering restored ⇒ 3002 ms measured, assert fires.

Standalone (a plain --ignored sweep) the helper no-ops; the probe is
kill_on_drop so a parent panic can't orphan it (Gemini P3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:52:10 -04:00
molluskandClaude Fable 5 5b80a1a010 core: pull the ticket off presence before the reap wait on host fault
Gemini's merge-round review (P2, CERTAIN): the fault handler ran
`stop_host().await` first, and a host that merely closed stdout but
lives on — trapped SIGINT, wedged — makes that call burn the full 2 s
stop grace before the SIGKILL fallback. For that whole window the dead
share stayed advertised: peers could still click Watch on it, and the
sharer's own UI kept saying "sharing".

The handler now retires the share where the fault is decided, not where
the corpse is confirmed: presence ticket removal and ScreenShareStopped
are emitted before the reap wait, and only the explanatory error (which
carries the unconfirmed-reap caveat) waits for `stop_host`. The
Stopped-before-Error contract is unchanged and still gated.

StopScreenShare's identical reap-then-presence ordering predates S2 and
is deliberately left alone (user-initiated stop, lower stakes); recorded
as a follow-up note instead of churning reviewed main-line code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:51:59 -04:00
molluskandClaude Fable 5 b9803f93fb core: retire the share at Join's session teardown, not after ticket parse
Gemini's review of the S2 branch found the one hole the harness had not
covered (P2, verified reachable): Join tears the old session down —
deliberately killing the share host — BEFORE validating the ticket, and
an invalid ticket exits the arm early, skipping the late
`current_sharing = None`. The killed host's stdout EOF then passed the
staleness gate and the user got a spurious "Screen share ended
unexpectedly" on top of "invalid room ticket". Pre-S2 the stale value
was toothless on this path; the fault handler gave it teeth.

The share now dies where the session does: cleared unconditionally right
after the teardown block, ahead of every early exit. The live gate grew
a third half — share, Join with a garbage ticket, then require silence
after the ticket error — and the mutant restoring the old placement is
killed by exactly that assertion (spurious re-emitted ScreenShareStopped).

Also Gemini's P3: the test's temp dir is now dropped by a guard, so an
assertion panic no longer leaks the fake-pixelpass scripts in /tmp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 04:05:17 -04:00
molluskandClaude Fable 5 3df2378831 test: the owed Stop Share SIGINT gate, against the real pixelpass
0c half (ii) had never been field-run: SIGINT sent, child exits within
the bound, no fallback kill on the normal path. Now it's a repeatable
live gate instead of a one-off manual check: a real whole-desktop host
(idle — no viewer, so no capture) is stopped and must reach
ScreenShareStopped inside STOP_GRACE. The SIGKILL fallback is
indistinguishable from success in the event stream, so time is the
discriminator: the fallback first waits out the full 2 s grace, while a
host honouring SIGINT exits in milliseconds.

Also holds the SIGINTed host's late stdout EOF to the same staleness
contract as the fake-host gate. Verified green on this desktop; no
stray pixelpass processes after the run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 01:10:11 -04:00
molluskandClaude Fable 5 81c230a09c test: live S2 exit gate — dead host torn down, clean stop stays clean
Drives the real core loop through CoreController with the pixelpass
override pointed at fake shell scripts (a host that emits its ticket and
dies; one that lives until signalled). The command loop has no unit
seam, so this is the only harness reaching the fault handler.

Half 1 pins the whole death path: ScreenShareStopped arrives BEFORE the
"ended unexpectedly" error. Half 2 stops a share deliberately and then
requires silence while the retired host's late stdout EOF lands as a
stale fault. The `exec sleep` in the living host is load-bearing: it
makes SIGINT close stdout so the stale fault actually arrives, keeping
the staleness assertion non-vacuous.

Both core-side mutants verified killed: swallowing the forwarder's fault
times out half 1; disabling the staleness gate panics half 2 with the
spurious re-emitted ScreenShareStopped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 01:08:13 -04:00
molluskandClaude Fable 5 be3740f5f9 screenshare/core: a host that dies is no longer advertised as sharing (S2)
The defect: pixelpass's stdout EOF was silently discarded, the notice
channel existed only for app-audio shares, and nothing cleared the host
from the teardown slot or the ticket from presence — so a crashed host
stayed advertised in the room and the UI kept saying "sharing".

Every share now gets a notice channel. The drain task synthesizes a
terminal HostNotice::Eof when the stream ends (EOF or read error — a
crash can abort across `extern "C"` before any JSON line is written, so
the stream ending is the only reliable death signal). The core's
forwarder turns that into a ScreenShareHostFault scoped to the spawn's
generation; a stale fault (already stopped, or a newer share running) is
dropped. The handler reaps the child through the existing confirmed-reap
path, pulls the ticket off presence, and emits ScreenShareStopped BEFORE
the error, so the UI never shows "sharing" next to the explanation.

The ticket and its generation live in one ActiveShare value on purpose:
they must appear and vanish together, or the staleness gate drifts.

Both drain gates are mutation-verified: swallowing the Eof fails both
tests; skipping it only on the read-error path fails exactly the
error-path test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 01:02:41 -04:00
molluskandClaude Opus 5 0d836d14c2 docs: round 18 — repair moves to libpulse; two reviews, three reusable lessons
Rounds 17c and 4 of the repair review, recorded together because they resolve to
one decision: `pactl`'s text output cannot carry the guarantees repair claims, so
observation and unloading now go through libpulse introspection over a single
verified-local connection. The dependency was taken with the user's sign-off after
vetting (details beside the dep in pixelpass Cargo.toml).

Three lessons that generalise beyond this phase:

- A *prescription* can fail reachability just as a finding can. "Use `pactl -f json
  list modules`" is sound reasoning against an API that does not exist — those
  records carry no module index, and `unload-module` accepts only an index.
- Auditing my own fixes paid a third time: two of the four fixes applied in round
  17a were themselves defective, including a correlation scheme that is unsound
  whenever module names repeat.
- The live field test caught a bug unit tests structurally cannot reach, and it was
  phase 0b's bug one layer down: fields drop in declaration order, the Pulse
  context's teardown frees IO events owned by the mainloop, and declaring the
  mainloop first turned a fully successful repair into SIGABRT and exit 134.

Also recorded: the newline defect needed no adversary and was confirmed on the live
server, and the remaining namespace hole is left open with its trade stated — an
owner token would close it but would make orphans from older builds uncleanable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:24:46 -04:00
molluskandClaude Opus 5 76c1a13e11 docs: round 17 — the repair review, the 0c actor review, and 0c's slicing
Two reviews in one round. The repair planner returned changes-requested (no P1s,
four reachable P2s, all applied in pixelpass `9145b2a`); the 0c actor design
returned four blocking issues, all accepted, plus a concession on epoch.

Recorded because three of them generalise:

- A prescribed fix was not implementable as written. "Use `pactl -f json list
  modules`" is sound reasoning against an API that does not exist: on pactl 17
  those records carry no module index, and `unload-module` takes only an index.
  The reachability rule now applies to prescriptions, not just findings.
- The actor's bounded join would have disarmed `Drop` by moving the thread handle
  into `spawn_blocking` — the same defect shape as round 15's, a defence disarmed
  exactly when needed.
- Epoch was over-specified and my vacuity instinct was right: serial equality is
  the entire identity guarantee, so epoch is diagnostic and explicitly not a gate.

Measured on the live graph rather than argued: recorded module arguments are
byte-exact with `@DEFAULT_SINK@` unresolved (both load-bearing for exact-form
matching), and two sinks may share one `node.name` with capture attaching to the
OLDER one in 3 of 3 trials — so a surviving wedged owner silently steals the next
session's capture instead of merely risking a collision.

0c step 2 is sliced into S2–S5 so each lands reviewed. Nothing reopens D6: the
connection-owned-sink design is unchanged, and a material part of the growth is
pre-existing debt 0c forced into the light.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:33:57 -04:00
mollusk aa0515af1c Merge phase 0b + the peerspeak half of 0c: teardown ordering, reaping, graceful stop
Two invariants land here, both of which phase 6 depends on:

1. The echo-cancel module cannot unload while a pixelpass host is alive. The
   ordering-critical fields moved into `ScreenshareTeardown` with `echo_cancel`
   declared LAST, and killing is no longer taken for reaping — `kill_on_drop`
   only signals, so `ReapOnDrop` blocks on a bounded poll until the child is
   actually gone. The defect was real, not theoretical: `echo_cancel` sat ahead
   of `screenshare_host` in declaration order, so any unwind unloaded the AEC
   first, and unwind is reachable (no `panic=abort`, many `unwrap()`s).

2. Stop Share asks before it insists — SIGINT, a bounded grace, then SIGKILL —
   so pixelpass runs its own cleanup instead of leaking a null-sink module every
   time. SIGINT specifically: pixelpass installs only a `ctrl_c()` handler.

Reviewed by Codex across two rounds: changes-requested (two blocking findings,
both real, both the same shape — a defence disarmed exactly when it was needed)
then approve-with-follow-ups (five P3s, all applied). The mutation matrix was
revised from five to four after one pinned mutation was proved unreachable by
construction, and teardown was hoisted to one unconditional post-loop site so
every loop exit is covered structurally.

Owed and recorded: the live Stop Share SIGINT gate has never been field-run, and
the hoisted call site's live proof belongs to the phase-9 lifecycle row.

638 lib tests, clippy clean, fmt clean.
2026-07-26 19:43:17 -04:00
molluskandClaude Opus 5 9f06741b99 core/teardown: an unconfirmed stop is not a clean stop
Codex's re-review of the branch returned "approve with follow-ups" — no
blocking findings, five P3s. All five are applied here rather than carried as
debt, since each is a few lines.

The one with user-visible consequences: `stop_host` returned a bare "was
sharing" bool, so the single case where the availability-first policy gives up
(SIGKILL queued, reap never confirmed) still sent `ScreenShareStopped` with
nothing else. The UI would say sharing had ended while pixelpass might still be
alive and fanning out — a claim the user cannot see through. `shutdown` now
returns `StopOutcome`, `stop_host` returns `Option<StopOutcome>`, and an
unconfirmed *user-initiated* stop raises a UI error naming the stray process.
Session and viewer teardown discard the outcome deliberately: nobody is waiting
on an answer there, and the residual risk is already logged.

Also: the three failure diagnoses in `shutdown` (the signal never left, the
child ignored it, the wait itself broke) were collapsed into one log line and
are now distinct — they mean different things to whoever reads the log.

The second cancellation gate is the one worth keeping. The review pointed out
that all cancellation coverage sat in the *graceful* wait, so a mutant that
disarmed the wrapper between the two waits would survive. It was right, with a
wrinkle: the naive mutant does not compile, because the child is borrowed from
`self` for the whole function — the borrow checker is doing real work here. The
restructured form (`self.child.take()` once cooperation has failed) does
compile, and the pre-existing mid-wait test passes it.
`cancelling_shutdown_after_the_kill_leaves_the_fallback_armed` kills it.

Mutation-verified, both new gates: reporting an unconfirmed stop as `Reaped`
fails exactly `a_failed_wait_is_not_treated_as_a_confirmed_reap`; disarming
between the waits fails the new cancellation test (and the failed-wait test,
which also asserts armedness) while leaving the old mid-wait test green — which
is the proof the new test is not redundant. The logging split is diagnostics
only and has no gate; said plainly rather than dressed up as covered.

Docs: the "four mutations" line is now an explicit table naming each target and
its test, with 0c's pair counted under 0c; and the aggregate teardown latency is
recorded as a deferred item with a trigger (a fourth routine child, or a
measured teardown over 5 s) instead of an unwritten known cost.

638 lib tests, clippy clean, fmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:43:04 -04:00
molluskandClaude Opus 5 3aa768af52 docs: the 0b DAG row says four mutations, matching §10 round 14
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:11:19 -04:00
molluskandClaude Opus 5 1cfa932fbe docs: record the 0c mechanism probe, and revise 0b's mutation matrix
The 0c mechanism probe was run on this host before any structural work, because
one unverified assumption could have invalidated the whole approach: whether a
hand-created `adapter` node is visible to pipewire-pulse under the name the
capture path depends on. It is. Five gates green, including the two that
mattered — `<node.name>.monitor` is exposed as a Pulse source, and SIGKILL of
the owning connection removes both Pulse-visible names with zero graph residue.
No null-sink module is involved at any point. Every O1 stop condition for 0c is
retired, and the default sink never moved, so the probe is safe on a live
desktop.

The probe also settled the native-sink scope question: it applies to every mode
that owns a capture sink, not only `DesktopExcluding`. That makes the `--repair`
rework load-bearing rather than defensive — repair derives dead PIDs only from
`module-null-sink` entries, so once the sink is native its loopbacks become
undiscoverable orphans.

§10 gains rounds 14 and 15: the 0b matrix drops to four mutations because the
best-effort wake arm is unreachable by construction (the loop owns a sender, and
the biased select would win anyway), and teardown is hoisted to one unconditional
post-loop site instead of being duplicated across one live arm and one dead one.
Round 15 records the two blocking implementation-review findings and the vacuous
gate of my own that the review's test-double critique exposed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:11:06 -04:00
molluskandClaude Opus 5 d8b8fd79cf core/teardown: stay armed across the wait, and never call a failed wait a reap
Codex's review of the two commits below returned "changes requested" with two
blocking findings. Both were real.

1. `ReapOnDrop` disarmed itself across the async wait. `shutdown` moved the
   child out of the wrapper with `take()` before the first `.await`, so if that
   future was cancelled or unwound mid-wait, the raw child dropped with nothing
   but `kill_on_drop` (signals, does not reap) while `Drop` found `None` and did
   nothing — the AEC could then unload over a live child. That is precisely the
   hole the type exists to close, left open for the duration of every wait. The
   child now stays owned by `self` across every await and is released only on a
   *confirmed* reap.

2. A failed wait was silently converted into success, and the hard-kill path was
   unbounded. `wait_reaped` discarded `io::Result`, so a wait error made the
   timeout return `Ok` and shutdown returned as though the reap were confirmed;
   meanwhile a process stuck in uninterruptible sleep after SIGKILL could wedge
   the core command loop forever. The trait now preserves the result, both waits
   are bounded, and the conflict case has an explicit written policy: we choose
   availability, leave the child owned so the bounded Drop retry stays armed,
   and log the residual risk rather than hiding it.

Codex also showed the test double was flattering the implementation in four
ways. All four are closed: the fake can now be cancelled mid-wait, can fail its
wait, and can take several polls to die, and the grace is pinned independently.

That last one caught a flaw in my own gate. The elapsed-time assertion compares
against `STOP_GRACE` itself, so setting the constant to zero leaves it vacuously
true — both sides move together. `the_grace_is_a_real_interval` pins the
constant to a band instead, and now kills that mutation directly.

Mutation-verified again, five mutants, each killed by its own gate: disarming
the wrapper (cancellation test), treating a wait error as success (failed-wait
test, exactly one), a zero grace (the new band test), a single poll instead of
the drop loop (delayed-reap test, exactly one), reversed field order (the two
ordering tests).

Also applies the matrix adjudication, which Codex and I reached independently:
teardown moves out of the reliable close arm to ONE unconditional site after the
loop, so every `break` is covered structurally — including any added later —
instead of duplicating teardown across one live arm and one provably dead one.

637 lib tests, clippy clean, fmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:09:58 -04:00
molluskandClaude Opus 5 92a64465a4 core/teardown: ask pixelpass to stop before killing it
The peerspeak half of phase 0c (design v3.4 §7.4 item 1). Stop Share and
session teardown both went straight to `Child::kill()`, i.e. SIGKILL, which
skips pixelpass's own cleanup and leaks one null-sink module every time.

`ReapOnDrop::shutdown` now asks first: SIGINT, a bounded 2 s wait, then SIGKILL
only if the child ignored the request. SIGINT specifically, not SIGTERM —
pixelpass installs only a `ctrl_c()` handler, so SIGTERM would take the default
disposition and be indistinguishable from SIGKILL.

Signalling by pid is safe against pid reuse here: we have not reaped the child,
so it is a zombie whose pid the kernel reserves until we wait it, and the pid
cannot name a stranger. (Same reasoning that dismissed pixelpass bug #6.)

The grace is 2 s because it is awaited inline in the core command loop, so it
is also how long a wedged child can delay other commands. A healthy pixelpass
never spends it.

The drop/unwind path deliberately stays a hard kill: `Drop` cannot await, and
there the ordering invariant (§7.1) outranks tidiness. Once the pixelpass half
of 0c lands, the capture sink is connection-owned and that path stops leaking
by construction.

`libc` becomes a direct unix-only dependency, pinned to 0.2.186 — the version
already in the tree via alsa/cpal/tokio — so Cargo.lock gains one line and no
new code enters the build.

Mutation-verified, five mutations, each killing its own gate: no wait (6 fail),
reversed field order (2, reap test green), no reap loop (2, ordering test
green), no SIGINT (6), no SIGKILL fallback (exactly 1 — the wedged-child test).
633 lib tests, clippy clean, fmt clean.

Not yet field-tested: the live Stop Share gate (SIGINT sent, child exits within
the bound, no fallback kill on the normal path) still owes a real run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:26:05 -04:00
molluskandClaude Opus 5 6ba763774d core/teardown: reap the screen-share children before the AEC unloads
Phase 0b, fixes 1-3 of design v3.4 §7.2 (decision D4). The invariant is that
the echo-cancel module must not unload while a pixelpass host is alive and
fanning out; two paths have to honour it and only one is code we get to run.

The explicit path: `ActiveSession::shutdown` now awaits
`ScreenshareTeardown::shutdown_children`, and the reliable command channel's
close arm tears the session down explicitly instead of letting it drop on the
way out of `run_core_loop`.

The drop/unwind path: the ordering-critical fields move out of `ActiveSession`
into `core::teardown::ScreenshareTeardown`, where `echo_cancel` is the LAST
declared field and therefore the last dropped. Previously it was declared
first (`:682`, ahead of `screenshare_host` at `:685`), so an unwind unloaded
the AEC while the host was still live — and unwind is reachable, the core is
full of `unwrap()` and has no `panic=abort` profile.

Killing is not enough. `kill_on_drop(true)` only signals: it hands the child to
the runtime's orphan queue and returns, which an unwinding runtime may never
drain. `ReapOnDrop` blocks on a bounded 250 ms budget until the child is really
gone, because a bounded stall beats unloading the AEC out from under a live
pixelpass.

Everything is generic over a narrow `ChildProcess` trait and over the guard
type, so ordering is unit-testable without spawning processes or loading
PipeWire modules — the seam idiom already used by `replace_viewer_index`.

Mutation-verified, and the plan's demand that mutations 4 and 5 prove
*different* defenses holds: reversing the field order fails only the
AEC-ordering tests and leaves the reap test green; removing the reap loop fails
only the reap tests and leaves the ordering test green. Removing the explicit
wait fails the explicit-path tests. 631 lib tests, clippy clean, fmt clean.

⚠️ Mutations 1 and 2 of the pinned matrix do not both exist: the best-effort
wake arm is unreachable by construction, twice over. Documented at the site;
adjudication owed in the impl plan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:19:07 -04:00
molluskandClaude Opus 5 692ad677d2 docs: record F11-1 closed — boundedness needs a resolved Client
Design v3.7 §6.1.1 gains the round-13 box (the rule, the ordering that is
load-bearing in both directions, and why bridging deliberately still uses the
full union); the phase-5 results file records the close with the measurement
the deferral was waiting for; the impl plan's phase-6 gate note drops F11-1.

pixelpass c78eb2d is the implementation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 04:46:25 -04:00
mollusk bf908adbf0 docs: phase 5 matrix PASSED (13/13) — design round 10, results run 2
The §5.1 dry-run audit gate passes. All 13 rows completed with a non-empty
eligible half in every one, and O5 is re-measured on the fixed graph: worst
recompute 67 µs across every run, 10 µs mean under deliberate churn, busy
fraction 0.0006, readiness 1-2 ms with 18 binds against a 2000 ms budget.

Round 10's finding, and it is the third of exactly the same shape: the
pipewire-pulse PID derivation required a SINGLE repeated pipewire.sec.pid.
WirePlumber repeats one too (two Clients, both sec_pid 1747), so the derivation
returned None permanently on a stock desktop, key 4's suppression never fired,
and every Pulse-emulated node fused into one owner. Row 1's CLEAN control
forwarder and Firefox were both excluded. Fixed in pixelpass 91c4ded by deleting
the heuristic: probe every distinct sec_pid and let /proc/<pid>/comm decide.

Three measured rounds now, all at the observation boundary, none in the
architecture -- and all three were fail-closed and silent, caught only because
§5.1 requires asserting what must remain ELIGIBLE. An exclusion-only checklist
would have passed every one of these builds.

Rows 4-6 are closed through peerspeak's REAL tagging sites (call, mpv, notify,
plus clip) with a hand-launched mpv staying eligible, so the cross-repo contract
is proven end to end on live nodes. Row 10 covers the full sticky lifecycle
including retirement; row 11 is provably non-vacuous (the recycled
node.link-group came back byte-identical and did not inherit taint).

Recorded and NOT claimed as passes: substitutions in rows 8, 9 and 13, and two
reporting-only findings (the audit's sticky flag is uninformative; a bridge's
named key is lost when a leg reappears under a new serial).

Phase 6 remains blocked by F11-1, phases 0b/0c/0d and the Stereo Mix design
call -- this file removes one gate, not all of them.
2026-07-26 02:59:43 -04:00
mollusk b68fca689e Merge phase 1: ownership tagging (SPA-JSON carriers via libspa)
peerspeak-side half of phase 1 of the screenshare audio-exclusion work: every
node peerspeak owns carries two registry-visible ownership carriers, so the
taint engine has a primary root that survives the registry's filtered global
event (design v3.5 section 6.7).

Reviewed by Codex over rounds 10-12; all findings verified and dispositioned.
Round 12's F12-1 (rfind('}') spliced carriers inside a trailing comment, a
fail-open) and F12-2 (depth ceiling taken from the consumer,
pw_properties_update_string, not from the spa-json-dump grammar) are fixed and
live-verified through the real ALSA plugin.

623 lib tests green, fmt clean, clippy clean.
2026-07-26 02:14:25 -04:00
molluskandClaude Opus 5 c82ef07464 audio/ownership: take the depth ceiling from the consumer, not the grammar
Round 12 review, finding 2 — filed as P2, and the interesting part is
that its author retracted it to P3 once we had measurements, while the
remedy it originally proposed would have been a fail-open.

The finding was that our validator rejects nesting `spa-json-dump -s`
accepts, and the suggested fix was a recursive sub-iterator walk to
match the dump tool. Both halves rest on the dump tool being the
reference. It is not. Nothing reads `PIPEWIRE_PROPS` or `PIPEWIRE_ALSA`
with `spa-json-dump`; `pw_properties_update_string` does, in the client
process.

Measured live on this host, against the real ALSA plugin:

    depth 513  dump accept   plugin accept   ours accept
    depth 514  dump accept   plugin accept   ours REJECT
    depth 515  dump accept   plugin REJECT   ours reject
    depth 1000 dump accept   plugin REJECT   ours reject

At 515 the plugin discards the whole object: the node came back as
`alsa_playback.aplay` with no properties at all. So matching the dump
tool would have made us splice carriers into values the consumer throws
away wholesale — losing both, which is the echo this feature exists to
prevent. Over-rejecting costs a routing preference; over-accepting costs
a carrier. Those are not the same price.

What was genuinely wrong is narrower: we sat exactly one level below the
consumer. `pw_properties_update_string` calls `spa_json_container_len`
on a container value, which enters one more sub-iterator before its flat
walk, and that single level is the entire discrepancy. Doing the same
puts the boundaries on the same number.

Codex reached the same three numbers independently by calling
`pw_properties_update_string_checked(NULL, ...)` directly, having
disassembled both call sites; I measured through the live plugin. Two
methods, one table.

The dump differential stays, but it is now labelled a *grammar* oracle
with a warning not to add deep values — it would fail by design. The
acceptance oracle is the new boundary test.

Mutation-verified: removing the container step fails the 514 assertion.
622 -> 623 lib tests, fmt clean, clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 01:59:36 -04:00
molluskandClaude Opus 5 9eab6c118d audio/ownership: let libspa say where the object closes
Round 12 review, finding 1 — a measured fail-open, and the third
distinct door into the same failure.

A trailing comment is valid SPA-JSON and *ends the document*
(`case __COMMENT: return 0` in spa/utils/json-core.h), so an object may
close before the last `}` in the string. `merge_pipewire_props` located
the closing brace with `rfind('}')`, which is a byte scan and not a
parse, so for

    { "target.object" = "my-sink" } # trailing }

it selected the comment's brace and spliced both ownership carriers
*into the comment*. The re-validation did not catch it, because the
result parses perfectly well — as `{ target.object = "my-sink" }`, with
neither carrier present. Confirmed against `spa-json-dump -s`.

That is an untagged node, so no taint root, so echo — exactly what
rounds 10 and 11 each closed by a different route. Latent rather than
live: pixelpass's evaluate() is still audit-only, so today it corrupts
an audit classification and becomes a leak when phase 6 consumes
eligibility.

The whole thesis of round 11 was "do not re-implement someone else's
grammar". The scanner went, but this brace hunt stayed behind in the
caller, which is the same defect wearing different clothes.

So spa_object now reports the object's own closer, taken from libspa:
closing a container at depth 0 writes the brace's position back to the
parent iterator, and spa_json_enter made `outer` that parent. Read
before the trailing check, which advances past it.

Also:
- whatever followed the object is preserved, so a user's trailing
  comment survives instead of being silently deleted;
- the output check now asks whether the object closes where we put our
  brace, not merely whether the string parses. A parse-only check is
  what this finding defeated.

Mutation-verified: restoring `rfind` fails the new test, and dropping
the tail fails it on the deleted comment. Honest note in the code —
mutation cannot distinguish the closer comparison or the is-object
test; both are labelled belt-and-braces rather than presented as
tested.

621 -> 622 lib tests, fmt clean, clippy clean, and the ignored
spa-json-dump differential still agrees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 01:51:14 -04:00
molluskandClaude Opus 5 21ba633825 audio/ownership: validate inherited SPA-JSON with libspa, not a scanner
Round 11 review, findings 2, 3 and 4.

The round-10 fix replaced a brace check with a hand-written scanner. That was
the wrong shape: a second implementation of someone else's grammar drifts in
both directions at once, and measured against `spa-json-dump -s` on this host
it did.

It ACCEPTED `{ "foo" = { garbage } }` (only brackets were balanced, contents
never validated), `{ "a" = "\é" }`, `{ "a" = é }` and `{ "a" = foo\bar }`.
Merging into those put an invalid pair before our carriers, so the daemon
stops at it and drops both -- recreating the exact fail-open the round-10 fix
existed to close. Its own test even pinned `"\é"` as a valid token.

It REJECTED `{ target.object, "my-sink" }`, `{ key == "value" }` and
CR-terminated comments, all valid -- so a user with one of those in their
environment silently lost their routing policy to an overwrite. That half
affects a running Linux user.

Now libspa's own parser validates, and the merge splices into the validated
text instead of re-emitting parsed pairs. Splicing preserves the user's bytes
exactly, which also answers the review's point that re-quoting a bare key can
invent a different one (`foo\bar` -> a string with a \b escape). Three
measured properties make the splice safe -- the last `}` is the object's, a
validated object's brace is never mid-comment, and commas are pure separators
-- and the result is validated again before it is returned.

Mutation testing then deleted the rest: every pairing and recursion check I
had written turned out to be redundant, because spa_json_next already errors
on `{ garbage }` and on nested garbage, and skips containers rather than
descending. ~60 lines of my own grammar logic removed. What remains is gated
by a new differential test against `spa-json-dump -s` over a 27-value corpus
-- the check whose absence caused this round. It found a real disagreement on
its first run (a bare document, which we reject by design, not by accident).

One mutation HUNG rather than failed: dropping the `length < 0` check makes
libspa report the same error without advancing, spinning forever. Kept, now
labelled load-bearing for termination, with a token-count bound beside it.

Finding 4: the ordering test took the first textual match of `fn main`, so a
raw-string decoy above the real function satisfied it while the real one
spawned a thread first. Now requires each of the three anchors to be unique.
Mutation-verified with the review's own decoy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 01:08:17 -04:00
molluskandClaude Opus 5 45b1b97dd8 audio/ownership: pin the no-lost-carrier invariant, and harden the byte scan
Verification round on the round-10 review fixes.

Adds the property the whole of finding 3 is about, stated directly: over
20,000 deterministic inputs built from the exact characters that break
SPA-JSON (braces, brackets, quotes, separators, comment marks, escapes,
newlines, multi-byte characters), the merge always emits both carriers in an
object it can read back. Either outcome — parse and rebuild, or overwrite —
has to end that way, and now nothing can quietly change which.

Also replaces two byte-index steps with character-boundary steps. Both were
correct on the ASCII input they actually see, but `index + 1` after a
reverse find would have split a multi-byte character and panicked the slice.
scan_token gains multi-byte cases for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:00:23 -04:00
molluskandClaude Opus 5 ae2e9de523 tests/fixtures: the ownership contract says exact-match, not truthy
Round 10 review, finding 6. The cross-repo contract still documented carrier
1 as "any value other than false/0 is truthy" after R10-4 made pixelpass
match it exactly. A future producer following the fixture could emit "true"
and silently lose the carrier.

Committed byte-identical with pixelpass's copy in the same session, as the
file's own rules require.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:57:16 -04:00
molluskandClaude Opus 5 985c63806b audio/ownership: state the playlist policy, and gate main's ordering properly
Round 10 review, findings 2 and 5.

Finding 2 — R10-2's rationale for tagging local playlist audio was factually
wrong. It claimed a local track is "already being broadcast to peers on the
same keypress", but shared listening is opt-in: music_broadcast defaults to
false, play_music_index starts local playback unconditionally, and
broadcast_track returns immediately when can_broadcast_music is false. So a
default-config playlist is not already broadcast.

The tag stays, now as an explicit policy with the real reason: the carriers
reach rodio through PIPEWIRE_ALSA, which is process-wide, and clip_player
and music_player are two ClipPlayer instances in one process — no value of
that variable can tag one and not the other. Exempting the playlist means
giving it a separately taggable stream, which is a large change for a case
with a one-step workaround (play it in any other app). Tagging is not
optional for received clips and peer music, which are the far end's own
audio.

Finding 5 — the ordering test proved only "before run_gui", which a
thread::spawn inserted above the tag still satisfies while making the
set_var a data race. It now requires the tag to be the first executable
statement in main: attributes, `unsafe` and block punctuation are stripped,
and any residue fails. Mutation-verified against a spawn, an unrelated
statement, and the call deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:51:02 -04:00
molluskandClaude Opus 5 6fc55a286d audio/ownership: parse inherited SPA-JSON instead of trusting its braces
Round 10 review, finding 3. The merge's shape check was the outer braces
only, so an inherited `PIPEWIRE_ALSA='{ garbage }'` was spliced into rather
than overwritten, producing an object the daemon does not accept.

Measured live 2026-07-25, and the failure is worse than a rejection: with
PIPEWIRE_ALSA set to the old merge's output, a real aplay node came up as
node.name=alsa_playback.aplay, no peerspeak.owned, and a junk property
`garbage = "peerspeak.owned"` — the lenient parser ate our key as their
value and stopped. Both ownership carriers lost on a live
Stream/Output/Audio node, which is an echo.

So: parse the inherited object and REBUILD it with our pairs last, rather
than splicing before the closing brace. Rebuilding is what makes the result
independent of the input's formatting — a value ending in a `#` comment
would otherwise swallow everything appended after it.

The three values the new merge emits were verified against the live daemon
(user props preserved, both carriers present) and are pinned byte-for-byte.
scan_token is gated on its own postcondition: at the object level an
unterminated string is also caught by "the object never closed", so the two
implementations only disagree at the seam.

Also parameterizes the malformed-value warning, which always named
PIPEWIRE_PROPS even when PIPEWIRE_ALSA was the malformed one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:47:32 -04:00
molluskandClaude Opus 5 d63db68318 audio/ownership: apply the merge rule to the ALSA carrier too
Verification round on round 10's own fixes, not on the next layer.

R10-5 preserved a user's PULSE_PROP and PIPEWIRE_PROPS but
tag_this_process_alsa_audio still clobbered their PIPEWIRE_ALSA, which is
the same kind of routing policy and deserves the same treatment. Both it
and tag_child now merge.

MEASURED, rather than assumed, because "our pairs go last so they win"
was load-bearing for the whole merge design and was never checked:
  PIPEWIRE_PROPS='{ "node.name"="theirs_first", "media.role"="music",
                    "node.name"="ours_last" }' on pw-play
    -> node.name=ours_last, media.role preserved.
  The PULSE_PROP equivalent on paplay -> the same.
So last-wins holds on both grammars: a user who already sets node.name
cannot silently untag us, and their other keys survive.

That also makes tag_child's ALSA carrier merge from the inherited value
safely: in production main has already put this process's `clip` tag
there, and the child's own role now overrides it by coming last. The
existing row could not see this — the test binary never runs main, so it
only ever exercised the merge-into-nothing case. Added a row that drives
the real shape directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:07:33 -04:00
molluskandClaude Opus 5 e7923a1b5c audio/ownership: merge inherited player env vars instead of clobbering
PULSE_PROP and PIPEWIRE_PROPS can legitimately carry a user's own routing
policy — media.role, a target sink — and replacing them changes where the
user's audio goes as a side effect of a tagging mechanism that is
supposed to be behaviourally invisible.

PULSE_PROP is space-separated key=value, so merging is appending;
PIPEWIRE_PROPS is a SPA-JSON object, so it is an insert before the
closing brace. Our pairs go last in both, so they win a duplicate key —
without that, a user with node.name already set would silently untag us.
A value that does not match the expected shape is logged and overwritten:
a half-merged string that fails to parse would drop the tag silently,
which is worse than losing a routing preference. No full SPA-JSON parser,
which would be over-engineering for a case with no live consumer
(measured: neither variable is set anywhere in this user's env or config).

Also sets PIPEWIRE_ALSA on the child, with the child's own role. A player
configured for ALSA output is reached by neither of the other two
variables, so this closes a real gap rather than only a cosmetic one —
and without it such a child would inherit this process's `clip` tag from
tag_this_process_alsa_audio and report the wrong role in the audit.

Corrects a stale doc comment on OWNED_PROP_VALUE that still claimed
pixelpass accepts any truthy value; R10-4 made the match exact. Codex's
F5 was reasoned partly from a stale comment of mine, so these are worth
fixing on sight.

Codex phase-1 review F4. Round 10, R10-5.
8 new rows; 5 mutations verified (clobber PULSE_PROP, our pairs first,
naive object concat, doubled trailing comma, drop the ALSA carrier).
All 4 live ownership gates re-run green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:03:32 -04:00
molluskandClaude Opus 5 b5569fe2c6 audio: tag the fourth playback path, rodio's ClipPlayer
ClipPlayer opens a rodio default sink, which on Linux reaches the graph
through PipeWire's ALSA plugin. It was untagged through all of phase 1,
and it is a real echo path: B broadcasts music, A tunes in, A shares
their desktop, B hears their own track played back at them. Confirmed
live as `alsa_playback.peerspeak-...` with no ownership properties.

rodio exposes no way to set PipeWire node properties, so the carrier is
PIPEWIRE_ALSA, set once at the top of main while still single-threaded.

Measured, with PIPEWIRE_PROPS and PULSE_PROP unset, to establish that
setting it process-wide is safe:
  - aplay (ALSA plugin)   -> both carriers land. Confirms the mechanism.
  - pw-play (native)      -> untouched. Our own call-playback and capture
                             streams are native, so they keep their own
                             explicit tagging and are unaffected.
  - arecord (ALSA capture)-> IS tagged, on a Stream/Input/Audio. Not
                             surgical in the role dimension; harmless only
                             because R10-1 honours the carriers on
                             producers alone. This is why R10-1 lands first.

Local playlist tracks are tagged too, not just inbound peer audio. A
local track is already broadcast to peers over the call on the same
keypress, so sharing it again through the screen share would send the far
end two copies at differing latency. That is a defect, not a feature.

Codex phase-1 review F1. Round 10, R10-2.

New live exit-gate row drives the real ClipPlayer; mutation-verified
(drop the tag -> no node within 5s). The wiring guard is mutation-
verified too, and its first version was WRONG: it searched raw source and
passed against a main with the call deleted, because the comment above it
named the function. It strips comments now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:52:22 -04:00
molluskandClaude Opus 5 503f78153b audio/ownership: refuse an ambiguous contract fixture
Producer half of the same fix (Codex phase-1 review, finding 3, P2).
This side collected fixture lines into a map, so a duplicated key
silently took the last value while pixelpass took the first — both
repos green on different contracts.

Mutation-verified in both repos with a duplicated `prop_value`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:25:27 -04:00
molluskandClaude Opus 5 d40385f85c notify: correct a measured claim about the aplay fallback
The comment said aplay ignores PULSE_PROP/PIPEWIRE_PROPS. Measured:
it reaches the graph through PipeWire's ALSA plugin and carries both
carriers exactly like pw-play and paplay. Comment only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:07:08 -04:00
molluskandClaude Opus 5 bcf1343a55 phase 1: tag every audio node peerspeak owns, on both carriers
Zero behaviour change. This is what makes the screenshare exclusion
engine able to see us at all (plan §5.1, impl plan §3): pixelpass must
refuse to fan out our own playback, and until now it had no way to
recognise it.

Two carriers, matched by pixelpass as a union — `peerspeak.owned=1`
and a `node.name` prefix `peerspeak_owned_<role>_<pid>`. Round 8 added
the second after the phase-5 audit found a node property is invisible
to the PipeWire registry `global` event and recoverable only by
binding the node; the prefix is announced directly. A union is also
the fail-closed direction: a missed tag leaks call audio into a share,
a spurious one only over-excludes.

Three tagging sites, all three verified live on this host:
  - native call playback  → props on the stream dict
  - screenshare mpv/VLC   → PULSE_PROP + PIPEWIRE_PROPS on the child
  - notification chimes   → same, on pw-play/paplay

The literals are a cross-repo wire contract, so they appear once here
as named constants and are pinned in a fixture committed byte-identical
in both repos (tests/fixtures/ownership-tag-contract.txt). The contract
test is black-box: it builds a real child `Command` and reads back the
environment it would carry, rather than testing our own formatter.

Three live `#[ignore]`d exit-gate tests drive the real call sites and
poll `pw-dump` for the resulting node — the plan requires the tag be
shown landing on a live node, not just in the env. All three
mutation-verified (drop either carrier, or the role, and the matching
gate fails).

Measured while verifying: mpv, VLC, pw-play and paplay all honour
`node.name` from those env vars. The native stream set neither
`application.name` nor a description, so a mixer fell back to
`node.name` — which the tag turns into an internal identifier. Added
an explicit `node.description = "PeerSpeak"` there, which keeps the
plan's rule (the prefix must not reach `node.description`) while
preserving its intent: mixers stay readable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:19:25 -04:00
mollusk 6773a3882b docs: round 9 — uncertainty is not history (design v3.6)
Phase 3r landed §6.7 and the phase-5 audit was re-run against it immediately.
It found a second measured defect within minutes: a real hardware sink
carrying `unresolved-ancestry` permanently, from one link observed while its
output node was still unbound during enumeration. Round 8 made that
systematic rather than rare, because every node is now withheld until its
bind resolves.

New §6.8: sticky taint is a claim about history, and uncertainty is not
history. Retiring by reason code would not be enough — an unresolved node
propagates `TaintedUpstream`, which is indistinguishable from real
contamination once recorded — so the split is by provenance: the engine runs
its fixpoint twice, and only the evidence-only pass may feed sticky state.
Decisions are unchanged and still fail closed.

Also recorded in §6.8, both from Codex's round-9 review and both pre-existing:
hardware playback-to-capture paths ("Stereo Mix") defeat the `session_device`
classifier in a way the driver denylist cannot detect — a real echo path
needing a design call — and the 2 s readiness budget has no calibration
argument beyond one measurement on one idle desktop.

Impl plan: phase 3r marked built and merged with its gate results, including
the extra Device-side live gate and why row 1 alone could not cover it.
2026-07-25 18:51:21 -04:00
molluskandClaude Opus 5 1cd19b355f docs: design round 8 — the observation boundary (v3.5)
The phase-5 dry-run gate failed on its first live run: the engine built to
v3.4 could not see its own primary taint root (echo, AEC off) while excluding
every stream on the machine (silence). One cause — the PipeWire registry
`global` event carries only a filtered subset of an object's properties, and
eight the design depends on are never announced.

Design doc (v3.4 → v3.5):
- NEW §6.7 — the observation boundary. The global is an index, not a source of
  truth: bind every Node and Device, `info` props are the sole source, live
  prop tracking, one readiness obligation per unbound node, fail closed.
  Four user design calls recorded.
- §5.1 — a second, registry-visible tag carrier (`node.name` prefix) alongside
  `peerspeak.owned`, so the primary root does not rest on one mechanism.
- §6.4 — node/device props are not an optimisation to skip, they are
  unavailable from the global; the round-6 Link lesson was right and applied
  to exactly one object type.
- §6.1.0, §6.1.4 — the two corrections the impl plan owed v3.5: a
  time-dependent "hazard is LIVE" claim, and an unreachable nominated test
  case (twice over).
- §9.1 measured facts, §12 rig discipline (pw-dump binds; the registry does
  not), §14 readiness.

Impl plan:
- NEW phase 3r with a four-part exit gate, the first the direct inverse of the
  finding. Ports deliberately not bound in v1, with a revisit trigger.
- Phase 1 pins the second carrier literal as a cross-repo contract.
- Phase 5 marked GATE FAILED; matrix and O5 re-run after 3r and 1.
- Risk register: the over-exclusion row fired and worked; new row for the
  observation boundary class.

Architecture is unchanged and vindicated: fed correct properties, the engine
decided correctly in every fixture. The §5.1 exact-partition requirement is
what caught this — every exclusion was defensible and the eligible half was
empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 17:04:51 -04:00
molluskandClaude Opus 5 297f4397a7 docs: phase 5 dry-run audit results — GATE FAILED, two findings
Impl plan §5's required results file. Phase 6 does not start.

F1 (fatal): the PipeWire registry `global` event delivers only a filtered
subset of node properties, and eight of the properties the phase-3 adapter
reads are not among them — peerspeak.owned, pulse.module.id, node.link-group,
application.process.id, node.passthrough, device.api, factory.name,
alsa.driver_name (plus port.exclusive on Ports). They are silently absent, so
the primary taint root never fires, the AEC identity can never validate, and
session_device is universally false. Measured on PipeWire 1.6.8 /
WirePlumber 0.5.15, with the full announced key set for all five object types
recorded. Links and Clients are unaffected; pulse-PID derivation works.

F2: with F1 in force no node has a strong owner key, so any tainted capture
stream is an unbounded tainted reader and phase 2's fail-closed backstop
excludes every Stream/Output/Audio on the machine. Fail-closed, so silence
rather than echo — but entirely non-functional, and non-functional in a way an
exclusion-only checklist would have scored as passing. The eligible half of
the §5.1 partition is what caught it, exactly as the plan argued it would.

The fix direction is measured and recorded: binding each Node and reading its
info props recovers every missing property, which is the pattern phase 3
already built for Links. factory.id is not a shortcut — it resolves to
"adapter", not api.alsa.pcm.sink.

O5 is closed with ~4 orders of magnitude of headroom: 308 graph events in
6.5s under churn, every recompute under 50us (max 15us), busy fraction 0.0004.
Caveat recorded — measured on the degraded graph, and the F1 fix adds
per-node bind I/O this run did not measure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 15:43:23 -04:00
molluskandClaude Opus 4.8 283d938b79 docs: sequenced implementation plan for screenshare audio exclusion
Turns the converged v3.4 design into ordered phases with falsifiable exit
gates. Three adversarial review rounds with Codex (gpt-5.6-sol, xhigh);
findings adjudicated rather than accepted wholesale, with reachability
verified against source on both sides.

Structural decisions:
- Phase 0d closes BOTH unsafe paths into the capture (source string and
  capture-sink inputs) before any machinery that could take them exists.
- Phase 5 dry-run audit mode is a hard gate: the taint engine runs against
  the live graph, creating no links, asserting exact eligible/excluded
  partitions with reason codes.
- Link manager is deliberately last among the pixelpass components.

Two measured corrections owed back to v3.4 (plan §11): §6.1.0's "hazard is
LIVE right now" has already flipped and must not be gated on, and §6.1.4
nominates an unreachable test case (as did my first replacement for it).

Design approval only. No code, nothing approved for merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:34:27 -04:00
molluskandClaude Opus 4.8 fd72e6f018 docs: close the AEC default-sink question raised by §6.1.0
Checked ~/.config/peerspeak/config.json: output_device and input_device are
both pinned to the Arctis, so echo_cancel::enable always passes sink_master
explicitly and the AEC binds to real hardware regardless of Sunshine owning
the default sink. Not live for this user.

Kept as a low-priority general defect: on "system default", the master args
are omitted (echo_cancel.rs:89-94) and module-echo-cancel binds to whatever
the default is, which on a box like this one is a null sink. Hardening would
be to resolve and validate the default before load. Own task, not this
feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 05:22:32 -04:00
molluskandClaude Opus 4.8 8768cd242c docs: v3.4 audio-exclusion — CONVERGED, ready for implementation planning
Round 7. Codex ratifies: v3.3 is ready to become the implementation plan.
Seven rounds, every blocker fixed or refuted with evidence. Design approval
only — nothing approved for merge, no code written.

Two subtle catches from the ratification round, both applied:

- The owner-key union had a wording trap that would have preserved the exact
  bug it was written to fix. "Resolves" must mean "yields a MATCH between the
  two legs", not "first property present on the node" — client.id IS present
  on both gst-launch legs but differs, so a first-present implementation stops
  at key 3, sees a mismatch, concludes "different owners" and leaks. Now
  specified as try-in-order-until-equal, with a dedicated test.
- Sticky taint must be lifetime-aware, not keyed on raw ids. client.id, node
  ids, module indices, link-groups and PIDs all recycle on this stack, so a
  bare key would hand an unrelated future app permanent inherited taint.
  Stored against live owner components, cleared only when all members vanish.

Also added: how pixelpass learns the pipewire-pulse PID itself (consistent
pipewire.sec.pid across Pulse clients, validated against /proc/<pid>/comm),
with the failure modes in both directions — safe only because unresolved
ancestry is fail-closed, which is the invariant the section rests on.

NEW LIVE FINDING (§6.1.0), the strongest reachability evidence yet and one
Codex's sandbox could not have seen: the user's CURRENT DEFAULT SINK is
sink-sunshine-stereo, a support.null-audio-sink. Every hardware sink is
SUSPENDED; the only RUNNING sink is Sunshine's virtual one, with Firefox
playing into it and sunshine reading its monitor. The hazardous forwarder
topology is live in the default audio path full time, with no EasyEffects
involved. It also means the rejected hardware-sink-only shortcut would have
captured NOTHING on this machine. Flagged separately, explicitly UNVERIFIED:
what module-echo-cancel binds to when the default sink is an app-owned null
sink.

§12 expanded with a graph-engine test surface (node-local tests cannot catch
C2/C3-class defects). §14 rewritten: convergence table, agreed v1 scope, and
what is deliberately out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 04:14:34 -04:00
molluskandClaude Opus 4.8 da72541e18 docs: v3.3 audio-exclusion — owner-key union + sticky taint
Round 6. Codex disagreed with two of my three round-5 claims and was right
about both; I had independently refuted one of them with a sharper test.

C2 REFUTED (by my own measurement): client.id is NOT an owner bridge. One
gst-launch process doing capture+playback produced TWO client objects (209
input, 210 output), no link-group, same application.process.id 20172. So
client.id bridges a *connection*, not an owner, and GStreamer — the same
framework pixelpass uses — splits them by default. Replaced with a
conservative union, strongest first: node.link-group, owned pulse.module.id,
client.id, node application.process.id, else fail closed.

With a trap Codex did not flag: application.process.id is pipewire-pulse's
PID for module-created streams, so bridging on it would fuse every Pulse
module's legs into one owner and mass-exclude tunnel/RTP/loopback audio the
user may legitimately want shared. Never bridge on that key when it equals
the pipewire-pulse PID; keys 1-2 already cover those precisely. PID thus
returns to the design in the CORRELATION role while remaining unusable in
the IDENTITY role — and in that role a wrong answer fails closed.

C3 CONCEDED: taint must be STICKY. Current-topology taint forgets buffered
audio — an app that reads a tainted monitor, buffers, then closes its input
leg would be relinked while still emitting peerspeak audio from the buffer,
and no graph event marks the drain. Taint now persists per owner until its
nodes disappear. Added §6.1.4 quantifying the arrival-side window (~10.6-21.3
ms quantum plus scheduling) and noting it is zero when taint roots already
exist, which is the common case.

C1 SUSTAINED with Codex's caveat: node-granular traversal is free for the
monitor boundary, but over-taints Audio/Duplex nodes. Fail-closed, accepted
for v1, documented as a known contradiction of the "Firefox with a mic stays
shareable" promise on duplex devices.

S1: endpoint props demoted to an optimization; bind-LinkInfo fallback is the
correctness path. S2: readiness epoch + revalidate before each link creation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 04:01:37 -04:00
molluskandClaude Opus 4.8 8610ab2eb6 docs: v3.2 audio-exclusion — signal-graph taint, measured
Round 5. Codex and I converged independently on the same conclusion — a
Link-only ancestry walk does not catch the leak — it from the crate/header/
WirePlumber sources, me from the live graph. Its sandbox could not reach the
daemon (pw-dump: Operation not permitted), so the measurements are mine.

Reproduced the EasyEffects topology with module-null-sink + module-loopback
(same shape, no EasyEffects needed). Result: there is NO Link object between
a forwarder's input leg and its output leg. Walking upstream from the leaking
node over Links alone finds no inbound links at all — a dead end that reads
as "clean". The legs are related only by shared node.link-group / client.id /
pulse.module.id.

So the signal graph needs three edge types:
1. Link edges — measured: registry Links carry all four endpoint props.
2. Sink-monitor — measured FREE at node granularity: the monitor connection
   IS a real Link whose output node is the sink itself. Codex held that this
   must be modelled explicitly; that is true only for a port-granular walk.
   Taint walks at node granularity, links are created per port.
3. Owner bridge — node.link-group when present, else client.id (measured
   shared across the forwarder's legs, distinct per app). Only modules set
   link-group, so client.id is what covers ordinary apps.

New §6.1.1: bridge taint must be CONDITIONAL on the input leg being tainted.
"Client has both legs ⇒ exclude" would exclude every app using a microphone.
Firefox in a Meet call stays shareable; Firefox sharing desktop audio does not.

Also: §6.5 rejects the cheap "hardware-sink-only" predicate with a measurement
— the forwarder's output leg links directly to alsa_output, so the shortcut
passes the leak and excludes the innocent app, backwards on both halves.
§6.3 barrier corrected: core sync/done is a previous-work roundtrip, not graph
quiescence. §6.4 adds crate version, endpoint fast path + bind fallback, and
full-recompute cost. §5.2 correction 5 rewritten: application.process.id lives
on the Node and is the app's own PID; pipewire.sec.pid lives on the Client and
is pipewire-pulse's for every Pulse client. That resolves four rounds of
contradictory PID claims.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 03:49:09 -04:00
molluskandClaude Opus 4.8 100117085d docs: v3.1 audio-exclusion — apply Codex round-4 findings
Round 4 (review-2026-07-21-design-v3-round4.md) returned 5 findings, 3 of
them blocking. All claims re-verified against source before acceptance.

Biggest correction: eligibility is a GRAPH property, not a node property.
Exclusion does not propagate downstream — a filter-chain/loopback/combine-sink
re-emits the mix as a fresh untagged Stream/Output/Audio that passes both the
peerspeak.owned and pulse.module.id checks, re-injecting the whole call into
the share. Reachability confirmed: easyeffects IS installed on this machine
(it merely wasn't running during the fan-out spike, which is why the spike
missed it). §6 rewritten around transitive upstream reachability, tracking
Node/Port/Link globals, with a registry sync barrier and revalidation
immediately before each link creation.

Also applied:
- §5.3 is now a bounded validation state machine, not a one-shot check.
  wait_for_nodes only waits for the virtual source/sink, never the playback
  hazard leg, and pixelpass capture spawns lazily on first viewer, so the
  one-shot check raced in both directions. Revocation redefined as loss of
  the module identity, not transient absence of one leg.
- §7.2: reordering ActiveSession fields is NOT sufficient — kill_on_drop
  sends SIGKILL without waiting, so AEC can still unload while pixelpass
  lives. Fix is explicit shutdown().await at both channel-close breaks,
  field order as defence in depth, plus a fake-resource ordering test.
- §5.1 relabelled implementation sites; none of them tag anything today.
- Stop Share citation corrected to :699/:3480.
- D1-D7 resolved; readiness section added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 03:31:12 -04:00
molluskandClaude Opus 4.8 cab6bafce5 docs: v3 audio-exclusion design — rewrite around Option C + AEC gate
v1/v2 described a move-based design that Option C superseded on 2026-07-20,
and the AEC playback-leg identity gate has since passed. Roughly two thirds
of v2 documented problems Option C does not have, so this is a rewrite rather
than a patch (v1/v2 remain at 88ad5a0 / 10203e1).

Folds in: the four AEC gate results, the five corrections that constrain them
(observed correlation not a contract; exact-equality only; index/link-group
reuse and node-id recycling; group prefix = hazard detection not ownership;
application.process.id == pipewire-pulse for module-created streams), the
verified implicit-drop ordering defect in ActiveSession, fail-closed
validation/revocation, the IPC shape, and the split-out prerequisites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 03:16:30 -04:00
molluskandClaude Opus 4.8 10203e1edb docs: adopt fan-out (Option C) after feasibility spike
Ran the direct-link spike on the live graph (PipeWire 1.6.8,
WirePlumber 0.5.15). Fan-out carries full-level audio for paplay, mpv
and VLC while the application keeps its existing speaker link;
WirePlumber does not reap foreign links across default-sink switch,
suspend/resume or 100s steady state; and non-lingering links are
destroyed automatically when their owning connection is SIGKILLed.

The decisive result is that destroying the capture sink mid-share left
the application playing to its speakers undisturbed, so capture-side
failure degrades to "not captured" rather than breaking the user's
audio. That is the property the move-based design had to work hard to
approximate.

Records what the spike does not prove: fidelity beyond signal presence,
daemon restart, quantum perturbation, and exclusive/passthrough streams.
The capture null sink is still pactl-owned, so Stop Share continues to
leak a module every time and the graceful-stop work is still owed.

Eligibility becomes a broad guarded selector rather than a narrow
allowlist, since copying no longer risks disturbing the source.

Option A and its attendant cleanup, restore and output-switch machinery
are retained for the record but are no longer the plan. A v3 rewrite is
owed once the AEC playback-leg identity is settled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 05:04:25 -04:00
molluskandClaude Opus 4.8 88ad5a0807 docs: screenshare audio exclusion design v2
Rewrite after Codex's adversarial review of v1 found four release
blockers, all independently verified against source.

v1's premise was wrong: whole-desktop capture bypasses Routing::start
entirely (pipeline.rs:121), so this needs a new capture mode rather than
an inverted predicate.

v2 replaces PID-based identity with ownership by inherited tag, and makes
the router an allowlist so unrecognized infrastructure is left alone
rather than optimistically moved. Graceful stop becomes a prerequisite:
Stop Share is currently SIGKILL, so cleanup never runs on the normal path.

Records live measurements taken 2026-07-20: PULSE_PROP tagging reaches
the graph for paplay, mpv and VLC, and application.process.id is the
client's own PID, not pipewire-pulse's — correcting a claim both the
review and v1 relied on.

Adds Option C (fan out a second owned link instead of moving streams),
which deletes most of the cleanup, latency and multi-host problems the
move-based design has to solve. Not yet implemented; gated on a
feasibility spike.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 02:54:53 -04:00
molluskandClaude Opus 4.8 0588d92537 release: 0.6.6
CI / check (push) Failing after 5m35s
The live-edge catch-up (8c4f4a0, b4a4c00) landed after the v0.6.5 tag, so
the 0.6.5 artifacts do not contain it — the same gap that left the fix out
of v0.6.4. Cut 0.6.6 so the published build actually carries it.

Local-only changes (no wire change; PROTO planes unchanged), so this is a
PATCH bump per VERSIONING.md.

601 lib tests green, clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 15:57:05 -04:00
molluskandClaude Opus 4.8 b4a4c00711 fix(screenshare): make live-edge catch-up actually recover
CI / check (push) Failing after 2m37s
The first cut used a fixed 1.05x drain, which measurement showed was too
gentle to matter: clearing a 6 s backlog would take two minutes, which a
viewer experiences as still broken.

Two changes, both measured on the netem satellite rig (loopback
impairment, gst -> ffmpeg HTTP relay -> mpv, matching the http:// URL
production actually serves):

1. Proportional drain. Speed now scales with buffer depth,
   1 + 0.05*(cache - 0.5), clamped to 1.15x, keeping the hysteresis band
   so it cannot oscillate. Deep backlogs recover in tens of seconds;
   small excursions still get an inaudible nudge.

2. Bound the byte cache in Low latency. The demuxer cache is a *byte*
   budget, so at a given bitrate it sets the worst-case backlog: 2 MiB
   held ~6 s of a 2.5 Mbps share. Capping Low latency at 1 MiB halved the
   standing buffer, 6.0 s -> 2.8 s, on its own. Smooth keeps the user's
   value, since a deep buffer is that posture's whole point.

Measured effect with both: playback consumes 11.6% faster than realtime
while behind (ratio 1.1157 vs 0.9988 with catch-up off), i.e. ~9 s of
backlog cleared in 80 s where before it recovered nothing at all and the
viewer stayed behind for the rest of the call.

Rig caveat: its upstream queues hold an unbounded backlog, so the cache
never drops back through the low mark and the return-to-1x transition is
only covered by unit tests, not the rig.

601 lib tests green, clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 15:47:32 -04:00
molluskandClaude Opus 4.8 8c4f4a0b8b feat(screenshare): drain a lagging viewer back to the live edge
CI / check (push) Failing after 2m12s
On a lossy link the reliable PixelPass transport turns every loss burst
into buffered latency that nothing trims back, so the viewer settles
seconds behind the host and stays there. Measured on a tc netem satellite
simulation: a viewer parks at a ~6 s standing buffer indefinitely.

--untimed (0.6.5) does NOT fix this and measured marginally worse (+1.38 s
vs +1.24 s): it only unpaces presentation, while audio still drains at 1x
the DAC rate, so an accumulated backlog never shrinks. Drop it.

Instead give mpv a JSON IPC socket in the Low latency posture and drive
playback slightly fast while the buffer is deep, returning to 1x once it
drains. Pitch correction keeps it inaudible and A/V sync is preserved,
because audio and video speed up together.

The control law and IPC message handling are pure functions with unit
tests; the only I/O is livesync::drive, which ends by itself when the
player exits. Smooth is deliberately excluded — its ~2 s readahead is the
point of that posture, and catch-up would fight it every poll.

Known limitation: 1.05x needs ~120 s to clear a 6 s backlog, so recovery
is slower than ideal. Tuning (a proportional law, or a seek-to-live for
large backlogs) is the follow-up.

598 lib tests green (+11), clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 15:36:06 -04:00
molluskandClaude Opus 4.8 76c4f68bb3 release: 0.6.5
CI / check (push) Successful in 2m54s
Local-only changes since 0.6.4 (no wire change; PROTO planes unchanged),
so this is a PATCH bump per VERSIONING.md.

Ships the low-latency screen-share live-edge fix (4bfc184), which landed
three hours after the v0.6.4 tag and was therefore never released.

Also adds the missing CHANGELOG entry for the participant "Advanced audio"
foldout (26d6600), which shipped without one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 14:45:16 -04:00
mollusk c427231858 feat(notifications): add chat and contact sounds
CI / check (push) Successful in 2m39s
2026-07-19 02:02:03 -04:00
mollusk 4bfc18463b fix(screenshare): keep low-latency playback live 2026-07-18 22:22:24 -04:00
mollusk 26d66007de ui: fold participant audio controls 2026-07-18 20:14:09 -04:00
molluskandClaude Fable 5 3d7b01c8a2 release: 0.6.4
CI / check (push) Failing after 3m17s
Wire-compatible refinement release (GOSSIP_PROTO stays 5). Highlights:
honest chat send status + sender-side pacing (chat-hardening Phase 5),
completing the chat-hardening plan; playlist drawer de-clutter + auto-resize;
plus the FEC-gap and network-restart fixes already on the branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:16:39 -04:00
molluskandClaude Fable 5 5f4eba1815 chat: honest local send status + sender-side pacing (Phase 5)
CI / check (push) Successful in 4m17s
Closes the final phase of docs/chat-hardening-plan.md. Two problems: a
locally echoed message always looked sent even when the core had no active
session or the gossip broadcast failed; and a fast burst could broadcast
'successfully' yet be silently dropped by every receiver's per-author rate
bucket (8 burst, then 1/s) with no sender feedback.

Send status: CoreCommand::SendChat/SendChatFile carry a local-only id (never
on the wire); the core replies with UiEvent::ChatSendResult after the gossip
broadcast succeeds or fails, and a no-active-session is now an explicit
failure rather than a silent no-op. gossip send_chat, which previously
returned Ok on a missing sender/topic or an encode failure, now returns Err.
ChatEntry gains local_send: Option<LocalSend>; failed sends render a red
'Not sent — {reason}  [Retry]' line, Broadcast/Pending render nothing
(there are no delivery receipts, so silence is the honest success state).

Sender-side pacing (new src/app/sendqueue.rs): sends past the burst queue
locally as 'queued…' and trickle out at the receivers' sustained rate, so
nothing is lost and typing is never blocked (user chose queue-and-trickle
over input throttling). The pacer reuses the gossip gate's own TokenBucket +
per-author constants (now pub(crate)) so the two sides of the policy can't
drift. A 250ms drain subscription runs only while the queue is non-empty.
Retry re-dispatches the retained payload; re-serving the same attachment id
replaces the ServeStore entry rather than double-counting bytes. The pacer
and monotonic send-id counter survive a room reset (receivers' buckets
persist; ids never alias a late result); queue and retry payloads are cleared.

582 lib tests (+11: 4 pacer/queue seam, 7 app-level transition/retry/reset);
all-targets green, clippy -D warnings clean, fmt clean, smoke launch OK. No
wire change (GOSSIP_PROTO stays 5). Tests-green-only — the two owed
two-machine field-test items are logged in the plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:03:25 -04:00
molluskandClaude Fable 5 77d2bf2992 style(music): de-duplicate drawer transport controls, let playlist fill drawer height
CI / check (push) Successful in 2m43s
The playlist drawer duplicated the player bar's |prev/play/next| transport
row even though the drawer can only be open while the bar is visible
(drawer_open gates on show_player_bar), so the drawer copy is removed;
seek, music volume, Browse, and the tune-in checkbox remain drawer-only.

The track list (and the Public tab's broadcast list) was a 160px-fixed
scrollable nested inside a second full-height scrollable, showing only a
few entries. The outer scrollable is gone and both lists now fill the
drawer's remaining height, resizing with the window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:25:34 -04:00
molluskandClaude Fable 5 c8d0053431 docs(plan): add Phase 4 items to the two-machine field-test checklist
CI / check (push) Successful in 3m31s
Also re-triggers CI: run 165 on 1d038be died to rust-lld crashes from disk
exhaustion on the runner host (12G free vs ~12G cold-build transient), not a
code failure; 18G of local build artifacts have been swept (30G free now).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 05:04:25 -04:00
molluskandClaude Fable 5 1d038be9a0 chat: parsed-URL link policy, cached link ranges, history byte budget (Phase 4)
CI / check (push) Failing after 3m10s
Phase 4 of docs/chat-hardening-plan.md — URL and rendering resilience.
Closes the chat-body half of S14 (bidi override strip).

- sanitize: new is_safe_web_url shared link policy (url crate, promoted to a
  direct dependency): http/https scheme + non-empty host + no userinfo;
  candidates failing it stay plain text (their whole whitespace run, interior
  not re-scanned). Scheme detection is now ASCII-case-insensitive.
- sanitize: linkify() -> link_ranges()/segments(): validated byte ranges
  computed once, exact-roundtrip slicing, at most CHAT_MSG_MAX_LINKS (8)
  clickable links per message; the rest stays selectable plain text.
- sanitize_chat: strips bidi overrides/isolates (U+202A-202E, U+2066-2069)
  from message bodies while keeping ZWJ/ZWNJ/LRM/RLM (S14 chat-body half).
- app: ChatEntry caches its link ranges (filled in push_chat), so redraws
  slice instead of rescanning/re-validating; only link spans allocate.
- app: chat history now also bounded by 512 KiB total sanitized text
  (CHAT_HISTORY_MAX_TEXT_BYTES) alongside the 300-entry cap; the attachment
  byte cache is deliberately untouched by history eviction (own budgets).
- app: AppMessage::OpenUrl re-checks the same parsed policy (defence in
  depth) instead of prefix checks - non-web schemes can never reach the
  opener even if the handler is invoked directly.

571 lib tests green (+3 net); clippy -D warnings + fmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 04:57:15 -04:00
molluskandClaude Fable 5 554b613466 chat: attachment cache, download, and transfer hardening (Phase 3)
CI / check (push) Successful in 2m33s
Phase 3 of docs/chat-hardening-plan.md — attachments can no longer turn
into unbounded memory, bandwidth, decoder, or task pressure (S15 closed;
S14's filename half closed).

Cache and image cost (3A): AttachmentCache now carries encoded- and
decoded-byte budgets (96 MiB / 64 MiB) on top of the count cap, with
per-entry weights, replacement accounting, and oldest-first eviction; an
individually over-budget fetch services any pending Save/Play from the
bytes in hand and is exposed as Evicted instead of retained.
validate_image_bytes prechecks header dimensions (per-side AND a new
14 MP total-pixel limit) before any decode; the renderer only ever
receives a ≤1600 px downscaled RGBA preview whose w*h*4 cost counts
against the decoded budget — originals stay encoded-only for Save.
sanitize_filename strips the bidi/zero-width spoofing set (RTL-override
extension spoof).

Download policy and state (3B): images auto-fetch only when roster-
authored AND declared ≤4 MiB, gated by a new deterministic
AutoFetchBudget (per-author and session request+byte token buckets,
check-then-take, bounded author map) alongside the existing dedup and
four-permit bound. Attachment state is now explicit — absence/Loading/
Ready/Failed/Evicted — driven by a new AttachmentFetchStarted event, so
skipped or evicted images render a "Load image" button instead of an
indefinite "loading…", and repeated clicks can never spawn duplicate
fetch tasks.

Exact transfers and serve store (3C): fetch_blob requires the received
length to equal the declared size (short = local error, overlong =
bounded-read reject, empty keeps meaning "sender no longer has it");
the file picker's unbounded read is replaced by a metadata-prechecked
cap+1 bounded reader; one Arc<Vec<u8>> now backs the UI cache, command
queue, and serve store; served_files is a count- and byte-budgeted FIFO
ServeStore (16 entries / 128 MiB).

37 new tests (568 lib total) including a real two-endpoint loopback
exercising exact/short/overlong/unknown-id transfers. Plan checkboxes
ticked and constant deviations decision-logged. Tests-green-only: the
plan's two-machine field-test section remains open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 02:09:30 -04:00
molluskandClaude Fable 5 8898652349 chat: roster-bound authorship, replay dedup, and rate limits (Phase 2)
CI / check (push) Successful in 2m59s
Chat-hardening plan Phase 2 — only current authenticated room members can
create chat UI work, impersonation via the wire name is structurally closed,
and no member can monopolize the event channel:

- core: new ChatRoster (bounded id -> sanitized-name map, shared) replaces the
  event task's bare HashSet; upserted on PeerJoined/PeerUpdated, removed on
  graceful PeerLeft AND terminal grace-expiry eviction (both timer paths).
  Non-roster chat is dropped before attachment handling; the rendered author
  label is the roster-bound name — the sender-claimed wire name is never read.
- gossip: ChatIngressGate after verify_gossip, before any sanitize work or
  event send: early known-author gate (live + mid-reconnect peers), exact-
  replay suppression keyed on the deterministic Ed25519 signature (1024-entry
  cap + freshness-window TTL, zero new deps vs the plan's BLAKE3 option), then
  per-author (8 burst, 1/s) and room-wide (32 burst, 8/s) token buckets.
  Replays are detected before tokens are consumed; a room-bucket reject
  refunds the author token; rejection logging is squelched per author.
- The inner Chat.ts is now ignored entirely; RoomEvent carries the signed
  envelope timestamp.

550 lib tests (+18), reconnect_eviction +1 (grace keeps chat authority,
terminal eviction revokes it), clippy --all-targets -D warnings clean.
Tests-green-only: the plan's two-machine field-test section remains open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:37:13 -04:00
molluskandClaude Fable 5 5927148ee4 chat: enforce shared text policy at UI, sign point, and gossip ingress
CI / check (push) Successful in 3m4s
Chat-hardening plan Phase 1. The chat body policy (2,000-char + 8 KiB
ceilings, single-pass control/whitespace normalization) moves from the UI
layer into src/sanitize.rs and is now enforced at every trust boundary:
cap_chat_input bounds the live input (oversized paste), the gossip sign
point re-sanitizes so non-UI callers can't bypass policy, and gossip
ingress rejects oversized raw text before sanitizing (admit_chat_text)
and drops messages with neither visible text nor an attachment. The
incoming chat author label now uses the strict name sanitizer until
Phase 2 roster-binds it. +8 tests (532 lib green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:36:21 -04:00
molluskandClaude Fable 5 93f4954653 docs: changelog for the jitter FEC and net-rebuild resilience fixes
CI / check (push) Successful in 2m26s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 00:42:08 -04:00
molluskandClaude Fable 5 e6eb490939 audio: only FEC-recover a gap from its immediate successor packet
CI / check (push) Failing after 10m54s
The jitter buffer's gap path fed the LOWEST buffered packet to
decode_fec regardless of position. Opus in-band FEC in packet N carries
a copy of frame N-1 and nothing else, so that reconstruction is only
correct when the smallest survivor is exactly next+1 (single loss).
On burst loss it spliced a later frame's audio into the wrong slot —
worse than concealment. Gate FEC on adjacency (new fec_covers_gap(),
wraparound-aware); everything else falls back to plain PLC.

Two new tests: the gate itself, and a burst-loss test proven to bite —
it compares bit-exact against a twin decoder and fails against the old
unconditional-FEC behavior (checked by mutation).

Fixes finding 3 of the 2026-07-16 full-codebase review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 00:31:47 -04:00
molluskandClaude Fable 5 e724167b03 docs: add chat-hardening plan as scope contract
GPT-5.6's 5-phase plan for the chat identity/replay/rate-limit cluster
(2026-07-16 review findings 5-8): roster-bound display names, replay
dedup, quiet rate limiting, bidi-aware sanitization, attachment size
checks. Self-describes as temporary — delete when the work completes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 00:27:47 -04:00
molluskandClaude Fable 5 7b9cb57003 core: survive a failed net-stack rebuild instead of silently dying
CI / check (push) Failing after 13m37s
The four live-rebuild sites (deferred rebuild on Join/Leave, idle
SetNetworkMode, idle RegenerateIdentity) all did
`net.shutdown().await` then `build_net_stack(...).await?` — a build
failure propagated out of run_core_loop, which its supervisor only
logs. Every subsequent command went nowhere: window alive, app dead,
user told nothing. (The initial startup build already reported.)

New replace_net_stack() helper: tear down the old stack, build for the
requested posture, and on failure fall back to the posture the old
stack was actually running (tracked in the new `net_mode` local; when
the postures are equal the fallback is a plain retry — e.g. identity
regeneration, where reverting the already-persisted key would be
wrong). If the fallback lands, the UI is told the change didn't stick
and `network_mode` reverts so state stays honest and the change stays
re-attemptable. If both builds fail the UI gets a fatal 'Networking
lost … restart' error before the loop exits — informed, not a zombie.

Retry policy isolated in rebuild_with_fallback(), generic over the
builder: 4 new unit tests cover first-try success, fall-back, plain
retry, and double failure without binding sockets.

Fixes finding 2 of the 2026-07-16 full-codebase review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:58:58 -04:00
molluskandClaude Fable 5 af7a42a049 ci: remove zombie workflows from the per-push pipeline
cargo-deny.yml (runs-on: ubuntu-latest) and windows-build.yml (runs-on:
windows-latest) target runner labels no registered runner advertises, so
every push queued two runs Gitea auto-cancelled ~24h later — the Actions
page has shown 2 cancelled runs per push since the runner went live.

- cargo-deny.yml: deleted; redundant with ci.yml's deny step, which now
  runs `cargo deny --locked check` to preserve the locked-tree stance.
- windows-build.yml: kept but workflow_dispatch-only until a Windows
  runner exists; restore instructions in the header comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:46:56 -04:00
molluskandClaude Fable 5 e78e7bc2a5 supply-chain: ignore quick-xml build-time DoS advisories + ttf-parser unmaintained
RUSTSEC-2026-0194/0195 (quick-xml 0.39.4, published 2026-06-29) broke the
deny/audit CI gates on every push since June 29. quick-xml is reached only
via the wayland-scanner proc-macro parsing vendored protocol XML at compile
time — attacker input never touches it and it is absent from the shipped
binary. The fixed 0.41.0 is semver-incompatible with wayland-scanner's
`^0.39` req (no upstream bump yet); documented ignores until one exists.

RUSTSEC-2026-0192 (ttf-parser unmaintained, via iced/cosmic-text) joins the
existing unmaintained ignores (paste, audiopus_sys) — same class, same
lockfile-pinning protection.

New .cargo/audit.toml keeps cargo-audit in sync with deny.toml.

Known leftover warning (allowed, non-failing): spin 0.10.0 is yanked but
futures-buffered (via iroh) requires ^0.10 and no unyanked 0.10.x exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:46:13 -04:00
molluskandClaude Fable 5 52ab374b74 style: cargo fmt under rustfmt 1.9.0 (toolchain update 2026-07-08)
Six diffs across four files: the 2026-07-08 stable toolchain update
(rustc 1.96.1 / rustfmt 1.9.0) re-flags code that was fmt-clean when
committed under the previous rustfmt. No semantic change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:45:18 -04:00
mollusk 8825707c17 chore: patch crossbeam-epoch RustSec advisory
CI / check (push) Failing after 7s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-07-15 06:31:09 -04:00
molluskandClaude Fable 5 76c62e5ac3 docs: mark connection badge field-verified (2-machine call 2026-07-08)
CI / check (push) Failing after 5s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 17:07:54 -04:00
molluskandClaude Fable 5 d2432740c1 network: per-peer connection badge (direct/relay, RTT, loss, bitrate)
Answer "am I actually P2P right now?" per peer. A 1 Hz session task
snapshots the selected QUIC path of every live audio connection
(IrohTransport::connection_stats), core::connstats::derive turns
consecutive snapshots into RTT/loss/bitrate (path switches and counter
resets invalidate the rate window), and the peer card shows a
Direct/Relay badge with a hover tooltip for address, loss, and up/down
bitrate. No new dependencies, no wire change.

Loopback-integration-tested against real iroh endpoints; not yet
field-verified on a 2-machine call (FEATURES.md row marked 🧪).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 15:15:22 -04:00
molluskandClaude Opus 4.8 99a4a336ad Release 0.6.3 — in-app screen-sharing controls + hwdec fixes
CI / check (push) Failing after 4s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Bump to 0.6.3 and document the screen-share work merged on this branch:
the advanced Settings section + per-call quality picker (96e41de), the
hardware-decode-defaults-off frame-1 freeze fix (96e41de), the per-call
quality override fix (e378b2e), and the VLC-honors-viewer-settings fix
(faad8ce). All local-only — no wire-protocol change, old configs load
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 17:20:00 -04:00
molluskandClaude Opus 4.8 df45c0bfeb screenshare: log the pixelpass-host and player argv on spawn
The screen-share code only logged pixelpass's high-level JSON events, never
the argv it spawned children with, so a field log couldn't confirm which
encode/viewer settings actually reached the helpers — e.g. the per-call
quality's --bitrate (host) or the hardware-decode --avcodec-hw/--hwdec flag
(player). Log both verbatim at spawn: host args carry no secret, and the
player line omits the local stream URL. Logged per attempt so a player
fallback is visible too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:47:06 -04:00
molluskandClaude Opus 4.8 faad8ce26a screenshare: honor viewer settings for the VLC player too
The viewer playback settings (hardware decode + buffering) only shaped
mpv's argv; vlc_args() was fixed, so a VLC viewer silently ignored them.
The load-bearing case is hardware decode: mpv defaults to software decode
(the A-bug fix), but VLC hardware-decodes by default, so a VLC viewer with
the default hardware_decode=false still got GPU decode and could hit the
frame-1 freeze the default exists to avoid — the toggle did nothing.

vlc_args() now takes the settings and maps the knobs that translate
cleanly to VLC: hardware decode (--avcodec-hw=none/any) and buffering
posture (network/live caching ms). The genuinely mpv-specific knobs
(cache_mb byte-cache, extra_mpv_args) stay mpv-only; the Settings UI
hints are reworded to say which knobs are mpv-only vs universal. +2 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:22:31 -04:00
molluskandClaude Opus 4.8 e378b2e33b screenshare: fix inline per-call quality override being discarded
The Share control's inline quality dropdown sets a session-only
`share_quality_selection`, but ToggleScreenShare (which opens the audio
picker on the only real path to a share) unconditionally reset it back to
the saved config default before ConfirmShareScreen read it. The picker has
no quality control of its own, so the user's per-call pick was silently
dropped 100% of the time and every share used the persisted default.

Drop the reset; add a regression test asserting the override survives
picker-open and reaches the confirm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:09:22 -04:00
molluskandClaude Opus 4.8 96e41de1b1 screenshare: advanced in-app streaming controls + hwdec toggle
Add a local-only "Screen sharing" section to Settings plus a per-call quality
picker on the Share control: in-app control over how a share is encoded
(quality/bitrate/framerate/max-height/max-viewers/software-x264, + extra
pixelpass args) and how it's played back (mpv/vlc, hardware decode, buffering,
cache, + extra mpv args). Settings live in AppConfig.screen_share (all
serde-defaulted, so old configs load unchanged) and become pixelpass host CLI
flags / mpv args at share/view launch.

Hardware decode defaults OFF, which also fixes the frozen-frame-with-audio bug:
forcing --hwdec=auto stalled some viewers' HW decoder on frame 1 while audio
kept playing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 21:15:11 -04:00
molluskandClaude Opus 4.8 5c888f8357 context_input: middle-click pastes the X11 PRIMARY selection
Joe (X11) reported that middle-click paste did nothing in the ticket and
node-ID fields. iced's base text_input only binds Ctrl+V to the Standard
(CLIPBOARD) selection and never reads PRIMARY or binds mouse button 2, so
the "select text, middle-click to paste" workflow was dead.

Add a Button::Middle branch to ContextInput::update that reads
clipboard::Kind::Primary, sanitizes it, and pastes at the cursor (reusing
the already-tested pure paste()). Factor the control-char stripping into a
shared, unit-tested sanitize_clip() helper also used by the menu Paste, so
a trailing newline on the PRIMARY selection is dropped. Respects `locked`
so read-only display fields still reject paste.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 01:25:01 -04:00
molluskandClaude Opus 4.8 074f004227 core: one screen-share player per share — re-watch replaces, not stacks
Every click of Watch (`CoreCommand::ViewShare`) spawned a fresh pixelpass
viewer + mpv and pushed it onto an untracked Vec. A field test hit the
consequence: the first click gave a frozen player (the host's capture was
stalling), so the viewer clicked again to retry — and got a SECOND mpv,
doubling the shared audio.

Track viewers paired with their share ticket. On ViewShare, reap players
whose window already closed (try_wait), then if a live player for the same
ticket exists, kill it before spawning the replacement. Re-watching a
share now swaps its player instead of stacking a second one. Pure
`replace_viewer_index` seam + test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:48:52 -04:00
molluskandClaude Opus 4.8 2d22036930 screenshare: drop mpv --untimed so shared video stays A/V-synced
The viewer launched mpv with `--untimed`, which displays each video
frame the instant it decodes and ignores audio timestamps. Sharing a
desktop (no audio) that just minimizes latency, but sharing a *video*
made its audio drift progressively out of sync — confirmed in a field
test watching a video together. Remove the flag so mpv paces video to
the audio clock; the remaining low-latency flags keep lag negligible for
desktop pointing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:29:59 -04:00
molluskandClaude Opus 4.8 8014edf91c Release 0.6.2 + AppImage packaging
CI / check (push) Failing after 3m41s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Bump to 0.6.2 (Cargo + Inno .iss), promote CHANGELOG [Unreleased] -> [0.6.2].
0.6.2 rolls up the licensing (MIT + THIRD_PARTY_LICENSES) and the friends-list
liveness fixes (active offline marking, 15s refresh, manual Rescan) on the
0.6.x wire format (gossip v5, compatible with 0.6.0/0.6.1).

Adds packaging/appimage: a thin AppImage recipe (linuxdeploy) that bundles the
pixelpass screen-share helper in usr/bin so peerspeak's $PATH lookup finds it
with no code change. Assets are include_bytes!-embedded; the graphics stack and
pixelpass's gstreamer/mpv tools are left to the host. Built on Ubuntu 24.04
(glibc 2.39) for reach across Debian 13+/Fedora 40+/rolling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 16:07:41 -04:00
molluskandClaude Opus 4.8 89d5218d25 Housekeeping: Windows doc refresh + scan.rs test-import cleanup
Codex-authored refresh of docs/WINDOWS.md and packaging/windows/{README,INSTALL}.md
from the 2026-07-01 Windows session; scan.rs qualifies super::running_executables()
to drop an unused glob import. PKGBUILD pkgver reflects the last Arch build
(auto-regenerated by makepkg's pkgver()).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:59:26 -04:00
mollusk f3c7aa7050 Merge A6 playback handoff fix 2026-07-01 13:50:44 -04:00
mollusk 39b5dafd57 fix(audio): bound playback handoff queue 2026-07-01 13:39:10 -04:00
mollusk a78860db15 Merge W12 FEC/DTX follow-up (Codex, senior-reviewed)
CI / check (push) Failing after 23s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-06-30 16:56:19 -04:00
5f52aa1506 W12 follow-up: consume in-band FEC, drop redundant DTX
Fixes the two P2 efficacy findings from the Codex audit of the W12
profiles feature.

FEC was enabled on the encoder but never used: the jitter buffer's
loss path did pure PLC, so the redundancy was wasted bitrate. Now the
gap path reconstructs the lost frame from the next buffered packet via
Opus in-band FEC (new `AudioDecoder::decode_fec`, libopus decode with
fec=true into a one-frame buffer), keeping that packet for its own
normal decode and falling back to PLC if FEC decode fails. This is the
documented libopus FEC pattern; receiver-side only, no wire change.

DTX was enabled on BadNetwork but provided no benefit — the capture
noise gate already suppresses silence transmission, and the broadcast
DTX silence packets only created seq gaps that grew the jitter cushion.
All profiles now set dtx=false (plumbing kept for a future revisit).

Adds a jitter-buffer test proving FEC reconstruction beats pure PLC
(RMS error < 0.75x) and that the FEC source packet stays buffered.
500 lib tests, clippy + fmt clean, release build clean.

Co-Authored-By: Codex (gpt-5.5) <noreply@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:56:19 -04:00
molluskandClaude Opus 4.8 d92d0f6f6b Add W12 Opus/network quality profiles
CI / check (push) Failing after 25s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Add a small, named codec-policy picker (Low latency / Balanced / Bad
network) instead of exposing raw Opus knobs. The profile->params mapping
is a pure function (`codec::opus_impl::opus_params`) for unit testing;
profiles tune bitrate, in-band FEC, expected packet-loss, and DTX.

- config: `AudioProfile` enum (serde + Display + ALL + u8 round-trip),
  persisted `audio_profile` field (default Balanced).
- codec: `OpusParams` + pure `opus_params()` + `OpusEncoder::apply_params`
  / `apply_profile`.
- core: new `SetAudioProfile` command (Reliable, no coalesce); a shared
  `AtomicU8` lets the capture thread re-tune the live encoder on a
  mid-call switch and read it at each new call's encoder creation.
- app: Settings "Connection quality" picker in the Audio tab, startup
  config-sync send, and a one-line hint per profile.

No wire-format change (GOSSIP/audio planes untouched). 499 lib tests
green (config + codec mapping/apply tests added), clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:13:26 -04:00
mollusk 551767f9f5 Show build version on launch screen
CI / check (push) Failing after 37s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-06-29 16:54:26 -04:00
mollusk fa90cd3ce9 Update Arch package version 2026-06-29 16:53:03 -04:00
molluskandClaude Opus 4.8 660261a9a5 deps: bump memmap2 0.9.10 -> 0.9.11 (clears RUSTSEC-2026-0186)
CI / check (push) Successful in 2m6s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
cargo-audit flagged memmap2 0.9.10 as unsound (RUSTSEC-2026-0186, unchecked
pointer offset); 0.9.11 is the patched release. Warning-level only (audit/deny
don't fail on it), but cheap to clear. Audit now down to the two deliberately
-accepted unmaintained warnings (audiopus_sys, paste; ignored in deny.toml).
Lockfile-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 16:06:19 -04:00
molluskandClaude Opus 4.8 3a74fd0230 ci: drop concurrency block (Gitea 1.26 dropped runs with it set)
CI / check (push) Failing after 12m11s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
A push that only changed Cargo.lock failed to create any Actions run while the
concurrency group was present; removing it restores reliable push triggering.
Single-dev CI doesn't need run-cancellation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 16:02:15 -04:00
molluskandClaude Opus 4.8 2dbb1ea316 deps: bump anyhow 1.0.102 -> 1.0.103 (fixes RUSTSEC-2026-0190)
CI / check (push) Failing after 12m46s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
CI's cargo-deny flagged RUSTSEC-2026-0190: unsoundness in anyhow's
Error::downcast_mut() (UB via borrow-rule violation after Error::context),
reached transitively (n0-error / iroh + the image/rav1e chain). 1.0.103 is the
patched release; lockfile-only, no API change. cargo deny check now fully clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:56:35 -04:00
molluskandClaude Opus 4.8 c902db2e90 style: rustfmt the 0.6.2 additions (A17b + version-in-UI)
CI / check (push) Failing after 2m34s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
CI's fmt --check caught that Codex's hand-written additions in these two files
weren't rustfmt-formatted (the senior gate ran clippy + tests but not
fmt --check). Pure line-wrapping, no logic change. Keeps the crate fmt-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:52:31 -04:00
molluskandClaude Opus 4.8 83e5881768 ci: cancel superseded in-progress runs (concurrency group)
CI / check (push) Failing after 7s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:51:31 -04:00
molluskandClaude Opus 4.8 8424b44dec ci: add Gitea Actions workflow (self-hosted host-mode runner)
CI / check (push) Failing after 13s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
CI runs on a self-hosted host-mode gitea-runner on the desktop (label `arch`),
so the cheap gitbutter VPS only queues jobs while all compile/test compute runs
locally. Pipeline on push-to-main / PR / manual dispatch: cargo fmt --check,
clippy --all-targets -D warnings, cargo test --all-targets + doc tests, cargo
deny check, cargo audit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:43:35 -04:00
molluskandClaude Opus 4.8 33e49a8ca7 Merge 0.6.2 refinements (Track B): A17b multitrack offload + build-version-in-UI
Track B code body for the 0.6.2 patch release. No wire change (GOSSIP_PROTO stays
5, interoperable with 0.6.0/0.6.1). Two code commits + two investigation closeouts
(A6 root-caused -> deferred to W5; A3 palette audit -> accepted as-is).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:28:48 -04:00
molluskandClaude Opus 4.8 393c1c7f09 feat(ui): surface the build version in Settings + at startup
A field build is now self-identifying. `run_gui` logs `PeerSpeak v<version>
starting` (from env!("CARGO_PKG_VERSION")) on launch, and the Settings panel
shows a muted `PeerSpeak v<version>` footer — pinned to the bottom of the
220px category sidebar (wide layout) and appended under the body in the narrow
(<820px) layout. Compile-time string, no new test, no deps, local-only.

Renders the current crate version, so it tracks the Cargo.toml bump at each
release cut (shows v0.6.1 until 0.6.2 is stamped in Track A).

Codex-implemented (gpt-5.5 xhigh), senior-reviewed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:58:33 -04:00
molluskandClaude Opus 4.8 1bf14ba08f perf(recording): move multitrack stem disk I/O off the mixer path (A17b)
The multitrack recorder wrote every per-stem WAV frame (and the potentially
large late-joiner back-pad) inline on the caller thread while holding the
recorder mutex, so a slow/contended disk stalled the playout mixer (local
underruns) and the events loop. This is the multitrack counterpart to A17
(e0325d4), which moved the single-file recorder's writes off the mixer path.

Design: the front (MultitrackRecorder) now keeps only cheap in-memory state
(known-peer set, mic FIFO, a pending-cycle builder) and on each end_cycle
assembles ONE whole-cycle batch (new peers + mic frame + optional mix frame +
the map of peer frames written this cycle) and try_sends it over a bounded
sync_channel(256) to a dedicated writer thread. The writer thread owns every
WavWriter, is authoritative for its own cycle count, back-pads a brand-new
peer by cycles_written*frame_samples, fills absent peer/mix frames with
silence, latches the first write/create error then drains, and finalizes all
headers on channel close.

The unit of hand-off is a whole cycle, not a track: the writer appends exactly
frame_samples to every existing track per applied batch, and a full queue
DROPS the entire batch (counted + logged at 1 and every 256). So a dropped
cycle omits the same 20ms from every stem at once and all tracks stay
equal-length and sample-aligned by construction even under disk back-pressure.
On drop the batch's new-peer announcements are rolled back out of the known set
so they re-announce (and correctly re-back-pad) on the next applied cycle.

Public method signatures are unchanged -> zero core/mod.rs edits. The
WAV/file format is unchanged (no wire/on-disk change), no new deps
(std::sync::mpsc + std::thread, as A17). Writer logic is factored behind a
generic SampleWriter seam so the apply-batch alignment invariant is unit-tested
without spawning the thread; new tests cover the back-pad-on-apply invariant,
the dropped-cycle equal-length property, and async create-error surfacing at
finalize. The three existing end-to-end tests pass unchanged (now exercising
the threaded path). 496 lib tests, clippy --all-targets clean, release builds.

Codex-implemented (gpt-5.5 xhigh), senior-reviewed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:53:52 -04:00
molluskandClaude Opus 4.8 6f14d2668d docs(protocol): correct GOSSIP_PROTO version mapping (v5 = 0.6.0, not 0.7.0)
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
The const comment claimed "v5 (0.7.0)" while GOSSIP_PROTO has been 5 since the
v0.6.0 tag (introduced by bca2ccd, "release 0.6.0"). Git confirms the value went
straight 3 -> 5 in that one release and a GOSSIP_PROTO == 4 build never existed.
Merge the two mislabeled v4/v5 bullets into one accurate v4-v5 (0.6.0) entry and
note the 3->5 jump + that this breaking gossip change correctly rode the
0.5.1 -> 0.6.0 MINOR bump per VERSIONING.md (0.6.1 is a wire-compatible PATCH,
still proto 5). Comment-only; no wire/behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 02:24:53 -04:00
molluskandClaude Opus 4.8 49c3ce8c0a release: 0.6.1 refinements
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Wire-compatible refinement release (no *_PROTO change; interoperates with 0.6.0):
- A19  config: atomic save + corruption-preserving load
- S5   notify: chime temp-WAV symlink-clobber hardening
- A15b core: continuous-control command coalescing (last-value-wins)
- A2   window: clamp restored X11 position + sanity guard
- A17  recording: single-file WAV disk I/O moved off the mixer path
- A20  cargo fmt across the crate

All Codex-implemented (gpt-5.5 xhigh), senior-reviewed, tests-green (493 lib),
clippy --all-targets clean. Deferred: CI workflow, ARCHITECTURE/FEATURES doc
refresh, A17b (multitrack writer-thread offload).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 02:16:10 -04:00
molluskandClaude Opus 4.8 aec21a48d5 chore(release): bump version to 0.6.1
0.6.1 refinements release: A19 atomic config, S5 temp-WAV hardening, A15b slider
coalescing, A2 window-position clamp, A17 single-file recording I/O off the mixer
path, and a crate-wide cargo fmt. All wire-compatible (no *_PROTO change) with
0.6.0 peers -- no resync required.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 02:15:57 -04:00
molluskandClaude Opus 4.8 d0a16cb8b9 style: apply cargo fmt across the crate (A20)
The repo never enforced rustfmt, so formatting had drifted broadly. This is a
single mechanical `cargo fmt` pass over the whole crate (no behavioral change;
lib suite green, 493 passed). Going forward fmt should be enforced (planned CI
fmt --check step). Part of the 0.6.1 hygiene pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 02:11:44 -04:00
molluskandClaude Opus 4.8 e0325d4590 perf(recording): move single-file WAV disk I/O off the mixer path (A17)
Recorder::write_frame ran on the playout mixer path and did a blocking write_all
to disk per 20ms frame; slow/contended storage could stall the mixer and cause
local playback underruns. Now the mixer thread only does the cheap mic-sum
(extracted as the pure mix_with_mic helper) and try_sends the frame to a
dedicated writer thread over a bounded sync_channel(256). The writer thread owns
the WavWriter, writes queued frames, records the first write error then drains
without writing, and patches the WAV size fields on channel close. A full queue
DROPS the recording frame (counted + logged at 1 and every 256) rather than
blocking call audio; a disconnected writer surfaces BrokenPipe. finalize() closes
the channel, joins the thread, and returns the first write error or the finalize
result (thread panic handled).

Scope: single-file Recorder only; WavWriter unchanged so the multitrack recorder
is untouched (its writer-thread offload is deferred as A17b). Public method
signatures preserved -> no core/mod.rs changes. New end-to-end threaded WAV
readback test + mix_with_mic helper tests; existing FIFO/mic-sum intent kept.
No new deps, no wire change. Codex-implemented (gpt-5.5 xhigh), senior-reviewed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 02:10:29 -04:00
molluskandClaude Opus 4.8 e8a894be49 fix(window): clamp restored X11 window position, sanity-guard absurd coords (A2)
initial_window_position fed saved window_x/window_y straight into
Position::Specific with no bounds check, so a saved position on a since-
disconnected monitor (or after a resolution shrink) could open the window fully
off-screen on a bare X11 WM that doesn't clamp. New pure clamp_window_position
seam: given display bounds it pulls a partly-offscreen window back inside,
centers one parked on a vanished monitor, and crucially PRESERVES legitimate
multi-monitor negative-origin coordinates (a naive clamp-to-0 would break that).

iced 0.14 has no dependency-free way to learn the virtual-desktop bounds before
the window exists, so screen_bounds() returns None for now and the clamp applies
a sanity envelope (reject |coord| > 32000 -> Centered) while preserving today's
restore behavior; the full clamp is unit-tested and ready for when bounds can be
supplied. Five clamp tests (inside, edge-clamp, disconnected, negative-origin,
None-sanity) + existing tests updated. No new deps, no wire change.
Codex-implemented (gpt-5.5 xhigh), senior-reviewed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 01:58:26 -04:00
molluskandClaude Opus 4.8 8c33b5c70f perf(core): coalesce continuous-control commands, last-value-wins (A15b)
A fast slider drag could burst past the bounded(100) best-effort command queue
and try_send would drop commands -- possibly the FINAL value of the drag, leaving
a gain/pan/volume stuck mid-drag until the next interaction. Replace the
best-effort queue with a coalescing latest-value map keyed by control
(CoalesceKey) plus a bounded(1) wake channel: send() overwrites the latest value
per control (never drops, never blocks) and wakes the loop, which pops one
coalesced command at a time and self-re-arms while entries remain. The existing
single-command match handler is reused unchanged.

command_sender() now returns a typed CoreCommandSender that routes by
delivery_class, so the window-close Shutdown (Reliable) goes through the
unbounded reliable channel (drained biased-first) instead of the best-effort
path -- a small correctness improvement. Mute/PTT remain Reliable, untouched.

Pure seams coalesce_key/coalesce_insert/coalesce_pop with unit tests
(overwrite-same-key, distinct-peers, global control, empty pop, drain-each-once)
and a coalesce_key<->BestEffort invariant assertion. No new deps, no wire change.
Codex-implemented (gpt-5.5 xhigh), senior-reviewed; tests-green (487 lib).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 01:42:41 -04:00
molluskandClaude Opus 4.8 10bd15aeaa fix(notify): harden chime temp-WAV writes against symlink-clobber (S5)
cached_path materialized each embedded chime to a fixed, predictable path
(/tmp/peerspeak-<name>.wav) via fs::write, which follows symlinks -> a local
attacker on a shared host could pre-plant a symlink and redirect the write. New
write_private_wav seam writes to a randomized peerspeak-<stem>-<pid>-<counter>-
<nanos>.wav name with OpenOptions::create_new (O_EXCL, refuses to write through
an existing path) and 0600 mode at creation on Unix. Per-process cache and the
None-on-error fallback (chime simply doesn't play) are unchanged.

Unit tests: exact bytes, 0600 mode, unique paths, create_new-refuses-existing.
No new deps, no wire/schema change. Codex-implemented (gpt-5.5 xhigh), reviewed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 01:28:41 -04:00
molluskandClaude Opus 4.8 8ad0bea19d fix(config): atomic save + corruption-preserving load (A19)
AppConfig::save now writes to a same-dir temp file and atomically renames
over the target (mirrors identity.rs/friends.rs), and surfaces errors via
log_msg instead of swallowing them. AppConfig::load distinguishes a missing
config (silent default, first run) from a present-but-corrupt one: the damaged
file is moved aside to config.json.corrupt.<unix_secs> before falling back to
defaults, so a later save can no longer clobber the user's real prefs.

Path-injectable seams save_to/load_from + LoadOutcome with unit tests
(round-trip, missing, corrupt-preserves-bytes, no leftover temp). No new deps,
no schema/wire change. Codex-implemented (gpt-5.5 xhigh), senior-reviewed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 01:15:08 -04:00
molluskandClaude Opus 4.8 abb53af559 docs(deb): document the Debian .deb build environment
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
The .deb recipe already lives in Cargo.toml's [package.metadata.deb], but the
build *environment* (bookworm distrobox, glibc floor, the mandatory separate
CARGO_TARGET_DIR) was only captured in handoff notes. Add a packaging/debian
README so the deb path is as self-documenting as the Arch + AppImage paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 00:03:48 -04:00
molluskandClaude Opus 4.8 c0c1969332 style(music): theme-colored skip glyphs in player bar + drawer
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
The skip-back/forward buttons rendered orange in every theme because the
emoji glyphs ⏮/⏭ (U+23EE/U+23ED) are drawn by the system color-emoji font,
which ignores the button's text color. Replace them with |◀ / ▶| built from
the text-presentation triangles ◀/▶ (U+25C0/U+25B6) — the same family the
play button already uses — so they honor .color() and follow the active
theme like the play button does. Applies to both the now-playing player bar
and the full music drawer panel. Pure visual change; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:33:32 -04:00
d155091eed style(music): center the transport controls on the player bar
The transport buttons were pushed to the far right because the
now-playing label had width(Fill). Regroup the bar into three sections
— left(Fill) identity+label, centered transport, right(Fill) position +
expand — so the controls sit in the middle. Pure widget regrouping; no
message, config, or behavior change.

Implemented by Codex (gpt-5.5), reviewed + gates re-run by Claude.

Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:07:59 -04:00
97 changed files with 19289 additions and 2595 deletions
+11
View File
@@ -0,0 +1,11 @@
# cargo-audit configuration. Keep the ignore list in sync with deny.toml,
# which carries the full justification for each entry.
[advisories]
ignore = [
# quick-xml DoS advisories: build-time only, reached solely via the
# wayland-scanner proc-macro parsing trusted vendored protocol XML.
# Fix (0.41.0) is semver-incompatible with wayland-scanner's `^0.39`;
# drop once wayland-scanner bumps. See deny.toml.
"RUSTSEC-2026-0194",
"RUSTSEC-2026-0195",
]
-34
View File
@@ -1,34 +0,0 @@
name: cargo-deny
# Enforce the supply-chain policy in deny.toml (advisories / bans / licenses /
# sources) on every push to main and every PR. Runs on a *locked* tree so the
# pinned, vetted versions in Cargo.lock are exactly what get audited — see the
# deny.toml header and VERSIONING.md. A new poisoned release of a dependency
# cannot reach CI until Cargo.lock is deliberately updated.
on:
push:
branches: [main]
pull_request:
jobs:
cargo-deny:
runs-on: ubuntu-latest
# rust:1 provides the cargo toolchain that cargo-deny shells out to for
# `cargo metadata`. Adjust the runner label if your act_runner uses a
# different one.
container: rust:1
steps:
- uses: actions/checkout@v4
- name: Install cargo-deny (pinned prebuilt)
run: |
set -euo pipefail
version=0.19.9
curl -sSfL \
"https://github.com/EmbarkStudios/cargo-deny/releases/download/${version}/cargo-deny-${version}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz -C /usr/local/bin --strip-components=1 --wildcards '*/cargo-deny'
cargo-deny --version
- name: cargo deny check
run: cargo deny --locked check
+44
View File
@@ -0,0 +1,44 @@
name: CI
# Runs on the self-hosted host-mode runner on the desktop (label `arch`). The
# gitbutter VPS only queues the job; all compile/test compute happens locally.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
check:
runs-on: arch
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Toolchain versions
run: |
rustc --version
cargo --version
cargo clippy --version
cargo deny --version
cargo audit --version
- name: Format check
run: cargo fmt --all -- --check
- name: Clippy (all targets, warnings as errors)
run: cargo clippy --all-targets -- -D warnings
- name: Tests
run: cargo test --all-targets
- name: Doc tests
run: cargo test --doc
- name: cargo-deny (advisories, bans, licenses, sources)
# --locked so the pinned, vetted versions in Cargo.lock are exactly
# what get audited (the lockfile-as-review-checkpoint model).
run: cargo deny --locked check
- name: cargo-audit
run: cargo audit
+15 -11
View File
@@ -7,11 +7,20 @@ name: windows-build
# alias) so a Unix-only assumption can't sneak back in and break Windows. # alias) so a Unix-only assumption can't sneak back in and break Windows.
# #
# RUNNER REQUIREMENT: this needs a Windows act_runner registered with the # RUNNER REQUIREMENT: this needs a Windows act_runner registered with the
# `windows-latest` label (the Linux `cargo-deny` job's container approach does # `windows-latest` label (a Linux-container approach does NOT apply here —
# NOT apply here — Windows jobs run on the host, not a Linux container). If your # Windows jobs run on the host, not a Linux container). If your runner
# runner advertises a different label, change `runs-on` below. Until a Windows # advertises a different label, change `runs-on` below.
# runner exists this workflow is simply skipped/queued, not a failure of the #
# Linux CI. # MANUAL-ONLY until that runner exists: with push/PR triggers enabled, every
# push queued a run no runner could claim and Gitea auto-cancelled it ~24h
# later, littering the Actions page with cancelled runs. Restore the push/PR
# triggers when a Windows runner is registered:
#
# on:
# push:
# branches: [main, "windows-port-**"]
# pull_request:
# workflow_dispatch:
# #
# BUILD-HOST REQUIREMENTS (validated by the opus spike, see # BUILD-HOST REQUIREMENTS (validated by the opus spike, see
# peerspeak-windows-opus-spike.md): # peerspeak-windows-opus-spike.md):
@@ -23,12 +32,7 @@ name: windows-build
# must provide both. # must provide both.
on: on:
push: # Manual runs from the Gitea Actions UI only — see the header comment.
# `main` plus the in-progress port branches, so the Windows path is exercised
# before merge rather than only after.
branches: [main, "windows-port-**"]
pull_request:
# Allow manual runs from the Gitea Actions UI.
workflow_dispatch: workflow_dispatch:
permissions: permissions:
+104
View File
@@ -4,6 +4,108 @@ All notable changes to PeerSpeak are documented here.
## [Unreleased] ## [Unreleased]
## [0.6.6] — 2026-07-19
### Fixed
- **A screen share that falls behind now catches back up.** On a lossy
connection (satellite links are the worst case) the share could settle several
seconds behind the host and simply stay there for the rest of the call. The
viewer now notices a deep buffer and plays imperceptibly fast until it is back
at the live edge — the audio stays in tune and in sync while it does. This
replaces the previous attempt at the problem, which measurement showed did not
help. Applies to the Low latency setting; Smooth intentionally keeps its
larger buffer.
### Changed
- **Low latency now keeps a tighter viewer buffer.** The screen-share cache
setting is a size in megabytes, which at a given bitrate quietly decides how
many *seconds* behind a viewer can drift — a 2 MB buffer turned out to hold
about six seconds of a typical share. Low latency now caps that buffer at 1 MB
regardless of the setting, which halved how far behind a share fell on a bad
connection before anything else kicked in. Smooth still honors the value you
choose, since a deep buffer is the point of that mode.
## [0.6.5] — 2026-07-19
### Added
- **Chat message sounds.** Successful outgoing messages and admitted incoming
messages now have distinct notification chimes, each with its own enable
toggle and optional custom WAV path in Notifications settings.
- **Contact presence sounds.** The home-screen contacts list now announces a
contact becoming online or offline. Initial online contacts are announced;
initial offline results stay silent. Both events have independent toggles and
optional custom WAV paths.
- **Notification sound browser.** Every notification event now has a native
Browse button for choosing a custom WAV instead of typing its path manually.
### Changed
- **Tidier per-participant audio controls.** The equalizer bands and noise gate
for each participant now live behind an **"Advanced audio"** foldout instead
of being expanded all the time, so a call with several people no longer fills
the panel with sliders. The controls themselves are unchanged.
### Fixed
- **Low-latency screen sharing stays near the live edge again.** mpv's
timestamp pacing could let stale frames accumulate across the reliable
PixelPass transport until a share was 710 seconds behind. Low-latency mode
now presents decoded frames immediately; Smooth mode retains timestamp pacing
when keeping shared-video audio and video synchronized matters more.
## [0.6.4] — 2026-07-18
### Added
- **Chat now tells you when a message didn't send.** A message that couldn't go
out — because you weren't in a room, or the broadcast failed — is marked
**"⚠ Not sent"** with a **Retry** button, instead of sitting in the transcript
looking delivered. A successful send shows nothing (PeerSpeak has no
delivery/read receipts, so anything else would be a false promise).
- **Fast typing no longer loses messages.** When you fire off a quick burst,
messages past the first few are held as **"queued…"** and sent a moment apart,
matching the rate other people's clients accept. Previously a fast burst could
look sent on your end while some messages silently never reached the room.
### Changed
- **Tidier music playlist drawer.** The slide-out playlist no longer repeats the
play/skip controls already on the player bar, and the track list now grows to
fill the drawer instead of being boxed into a short scroll area, so you can see
more of your playlist at once.
- **Safer chat under the hood.** A round of chat hardening tightened how incoming
messages, display names, links, and file/image attachments are validated and
bounded, so a malformed or hostile message from a peer can't spoof a name,
replay, flood, or run the app out of memory. No change to how normal chat looks
or works.
### Fixed
- **Burst packet loss no longer splices the wrong audio into the gap.** Loss
concealment used Opus in-band FEC even when the next packet to arrive wasn't
the one immediately after the gap, so losing several packets in a row could
briefly play a later frame's audio in the wrong position. FEC now only
reconstructs a gap from its immediate successor packet; larger gaps are
concealed normally.
- **A failed network restart no longer silently kills the app.** Changing the
network mode (or regenerating your identity) rebuilds the connection stack;
if that rebuild failed — rare, but possible when the local socket can't
bind — PeerSpeak kept its window open but silently stopped responding to
every command. It now falls back to your previous network settings and says
so, and only gives up (with a clear error telling you to restart) if even
the fallback fails.
[0.6.4]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.6.4
## [0.6.3] — 2026-07-06
### Added
- **In-app screen-sharing controls.** A new **Screen sharing** section in Settings, plus a per-call **quality picker** on the Share control, put the whole share pipeline under your control without editing config files. Encode side: quality preset, bitrate, framerate, maximum resolution, maximum viewers, a force-software-encode switch, and an escape hatch for extra pixelpass arguments. Playback side: choose **mpv or VLC**, toggle **hardware decoding**, pick a buffering posture (low-latency vs. smooth), set the demuxer cache, and pass extra mpv arguments. Everything is stored locally in your config and defaults are unchanged, so existing setups keep working as-is.
### Fixed
- **Shared video no longer freezes on the first frame while audio keeps playing.** Hardware decoding now defaults **off**; forcing `--hwdec=auto` stalled some viewers' hardware decoder on frame 1. You can re-enable hardware decoding from the new Screen sharing settings if your machine handles it well.
- **The per-call quality picker is now honored.** The inline quality dropdown next to the Share button was being reset to the saved default before a share started, so every share silently used the default quality regardless of what you picked.
- **VLC now respects your playback settings.** VLC hardware-decodes by default, so a VLC viewer previously ignored the hardware-decode toggle (and could hit the same frame-1 freeze) and the buffering posture. VLC viewers now map both settings onto VLC's own options.
[0.6.3]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.6.3
## [0.6.2] — 2026-07-03
### Fixed ### Fixed
- **Friends list now reflects status changes without a restart.** A presence probe that fails now actively marks the friend **offline**, so a friend who goes offline, leaves a room, or turns invisible no longer lingers showing a stale "online" / "in a room" status until PeerSpeak is relaunched. Previously only successful probes updated the list, so it could ratchet a friend's status up but never down. The auto-refresh interval was also shortened from 60s to **15s** so the list tracks changes more closely. - **Friends list now reflects status changes without a restart.** A presence probe that fails now actively marks the friend **offline**, so a friend who goes offline, leaves a room, or turns invisible no longer lingers showing a stale "online" / "in a room" status until PeerSpeak is relaunched. Previously only successful probes updated the list, so it could ratchet a friend's status up but never down. The auto-refresh interval was also shortened from 60s to **15s** so the list tracks changes more closely.
@@ -13,6 +115,8 @@ All notable changes to PeerSpeak are documented here.
### Licensing ### Licensing
- **PeerSpeak is now released under the MIT License** (previously an unlicensed private build). Added a `LICENSE` file and a `THIRD_PARTY_LICENSES` file enumerating the full dependency manifest plus the canonical text of every referenced license, with notices for the statically bundled Opus codec and the embedded fonts (Iced-Icons, Cantarell/OFL-1.1). Both files ship in the Arch and Debian packages. - **PeerSpeak is now released under the MIT License** (previously an unlicensed private build). Added a `LICENSE` file and a `THIRD_PARTY_LICENSES` file enumerating the full dependency manifest plus the canonical text of every referenced license, with notices for the statically bundled Opus codec and the embedded fonts (Iced-Icons, Cantarell/OFL-1.1). Both files ship in the Arch and Debian packages.
[0.6.2]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.6.2
## [0.6.0] — 2026-06-28 ## [0.6.0] — 2026-06-28
### Added ### Added
Generated
+9 -7
View File
@@ -200,9 +200,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.102" version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]] [[package]]
name = "arbitrary" name = "arbitrary"
@@ -1207,9 +1207,9 @@ dependencies = [
[[package]] [[package]]
name = "crossbeam-epoch" name = "crossbeam-epoch"
version = "0.9.18" version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
@@ -3682,9 +3682,9 @@ checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]] [[package]]
name = "memmap2" name = "memmap2"
version = "0.9.10" version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
dependencies = [ dependencies = [
"libc", "libc",
] ]
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]] [[package]]
name = "peerspeak" name = "peerspeak"
version = "0.6.0" version = "0.6.6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -4883,6 +4883,7 @@ dependencies = [
"image", "image",
"iroh", "iroh",
"iroh-gossip", "iroh-gossip",
"libc",
"opus", "opus",
"pipewire", "pipewire",
"rand 0.10.1", "rand 0.10.1",
@@ -4894,6 +4895,7 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"url",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
+12 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "peerspeak" name = "peerspeak"
version = "0.6.0" version = "0.6.6"
edition = "2024" edition = "2024"
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)" description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
license = "MIT" license = "MIT"
@@ -75,6 +75,10 @@ serde_json = "1.0.150"
thiserror = "2.0.18" thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["full"] } tokio = { version = "1.52.3", features = ["full"] }
tokio-stream = "0.1.18" tokio-stream = "0.1.18"
# Chat link policy: parse + validate clickable URL candidates (scheme/host/
# userinfo checks in `sanitize::is_safe_web_url`). Already in the tree
# transitively via iroh — this only promotes it to a direct dependency.
url = "2.5"
# --- Platform-specific dependencies ----------------------------------------- # --- Platform-specific dependencies -----------------------------------------
# Audio and the native file-picker backends differ per OS. Everything else in the # Audio and the native file-picker backends differ per OS. Everything else in the
@@ -105,3 +109,10 @@ windows-sys = { version = "0.61", features = [
"Win32_System_Diagnostics_ToolHelp", "Win32_System_Diagnostics_ToolHelp",
"Win32_System_Threading", "Win32_System_Threading",
] } ] }
# Unix-only. Used for exactly one thing: sending SIGINT to our own
# pixelpass child so it can run its cleanup before we resort to SIGKILL
# (src/core/teardown.rs). Already in the tree via alsa/cpal/tokio, so
# declaring it directly adds no new code to the build.
[target.'cfg(unix)'.dependencies]
libc = "0.2.186"
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8
View File
@@ -76,6 +76,14 @@ CHIMES = {
"mic-toggle.wav": [(E5, 0.08)], "mic-toggle.wav": [(E5, 0.08)],
# Reconnect gave up: disappointing low two-note fall. # Reconnect gave up: disappointing low two-note fall.
"reconnect-failed.wav": [(C5, 0.15), (349.23, 0.30)], "reconnect-failed.wav": [(C5, 0.15), (349.23, 0.30)],
# Our chat message entered the room: a tiny bright acknowledgement.
"chat-sent.wav": [(1046.50, 0.06)],
# A peer message arrived: a soft two-note lift, distinct but unobtrusive.
"chat-received.wav": [(E5, 0.07), (G5, 0.11)],
# A saved contact came online: a light, higher two-note arrival.
"contact-online.wav": [(E5, 0.09), (880.00, 0.18)],
# A saved contact went offline: the same tonal family falling away.
"contact-offline.wav": [(E5, 0.09), (440.00, 0.18)],
} }
+22
View File
@@ -0,0 +1,22 @@
use std::process::Command;
fn main() {
println!("cargo:rerun-if-changed=.git/HEAD");
if let Ok(head) = std::fs::read_to_string(".git/HEAD")
&& let Some(reference) = head.strip_prefix("ref: ")
{
println!("cargo:rerun-if-changed=.git/{}", reference.trim());
}
let short = Command::new("git")
.args(["rev-parse", "--short=8", "HEAD"])
.output()
.ok()
.filter(|output| output.status.success())
.and_then(|output| String::from_utf8(output.stdout).ok())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "unknown".to_string());
println!("cargo:rustc-env=PEERSPEAK_GIT_SHORT={short}");
}
+13
View File
@@ -24,6 +24,19 @@ ignore = [
# audiopus_sys: unmaintained FFI bindings to the stable libopus C library, # audiopus_sys: unmaintained FFI bindings to the stable libopus C library,
# pulled in via our direct `opus 0.3.1` dep. No drop-in replacement. # pulled in via our direct `opus 0.3.1` dep. No drop-in replacement.
"RUSTSEC-2026-0150", "RUSTSEC-2026-0150",
# ttf-parser: unmaintained, transitive via iced/cosmic-text (font parsing
# for the GUI). Inputs are system + embedded fonts, not network data. No
# upstream migration yet; revisit when iced moves off it.
"RUSTSEC-2026-0192",
# quick-xml 0.39.4 DoS advisories (quadratic dup-attr check; unbounded
# namespace allocation). Build-time only: quick-xml is reached solely via
# the wayland-scanner PROC-MACRO, which parses the wayland protocol XML
# files vendored inside the wayland-* crates at compile time. Attacker
# input never reaches it and it is not in the shipped binary. The fix
# (0.41.0) is semver-incompatible with wayland-scanner 0.31.x's `^0.39`
# requirement; drop both ignores once wayland-scanner releases a bump.
"RUSTSEC-2026-0194",
"RUSTSEC-2026-0195",
] ]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+1
View File
@@ -102,6 +102,7 @@ covers internals). When you ship a feature, add it here.
| iroh QUIC transport | ✅ | | | iroh QUIC transport | ✅ | |
| Network mode picker | ✅ | `RelayNoDiscovery` (default), `N0Full`, `DirectOnly`. Takes effect next join. | | Network mode picker | ✅ | `RelayNoDiscovery` (default), `N0Full`, `DirectOnly`. Takes effect next join. |
| Retained-address reconnect | ✅ | Dials last-known full addr before falling back to bare id. | | Retained-address reconnect | ✅ | Dials last-known full addr before falling back to bare id. |
| Per-peer connection badge (direct/relay + RTT, hover for addr/loss/bitrate) | ✅ | Peer-card badge fed by a 1 Hz poll of the live audio link's selected QUIC path (`connection_stats``core::connstats::derive`). Field-verified on a real 2-machine call 2026-07-08. |
| Reconnect + eviction model | ✅ | Incl. two-outage reconnect-eviction fix + regression test. | | Reconnect + eviction model | ✅ | Incl. two-outage reconnect-eviction fix + regression test. |
| Self-hosted relay | ❌ | Decided against — rely on n0 relays, `RelayNoDiscovery` default. | | Self-hosted relay | ❌ | Decided against — rely on n0 relays, `RelayNoDiscovery` default. |
+65 -39
View File
@@ -1,19 +1,28 @@
# PeerSpeak on Windows # PeerSpeak on Windows
Current status: the Windows port cross-compiles to `x86_64-pc-windows-gnu` and the `.exe` Current status: PeerSpeak cross-compiles to `x86_64-pc-windows-gnu` from Linux and
launches under Wine. A real Windows/WASAPI host is still needed for the final audio-device has passed an older native Windows 11 VM smoke test for launch, GUI render, call
checks listed below. join, and audio flow. The build environment is **not** the Windows VM; current
Windows binaries are built from Linux, normally inside the `peerspeak-win`
distrobox or with the same GNU target environment.
The Windows runtime still trails Linux in a few important areas. See the Claude
handoff file `windows-parity-audit.md` for the full audit and task breakdown.
## What works today ## What works today
| Area | Status | | Area | Status |
|---|---| |---|---|
| GUI | Iced/wgpu builds and renders under Wine. | | GUI | Iced/wgpu builds for Windows and rendered in the Windows 11 VM. |
| Networking | Iroh QUIC transport and gossip compile on Windows. | | Networking | Iroh QUIC transport and gossip compile on Windows; VM call reached two peers. |
| Audio backend | `cpal` drives WASAPI capture/playback behind `AudioBackend`. | | Audio backend | `cpal` drives WASAPI capture/playback behind `AudioBackend`. |
| Device selection | cpal enumerates input/output devices; see caveat below about stable IDs. |
| Resampling/remap | WASAPI devices can run non-48 kHz formats; PeerSpeak converts at the backend boundary. |
| Codec | Opus remains 48 kHz mono, 20 ms frames. | | Codec | Opus remains 48 kHz mono, 20 ms frames. |
| Identity | `ring` identity generation/load is platform-neutral. | | Identity/config | Stored through `dirs` under the Windows profile. |
| Chimes | Windows uses PowerShell `System.Media.SoundPlayer` for WAV playback. | | Chimes | Windows uses PowerShell `System.Media.SoundPlayer` for WAV playback. |
| Game detection | Steam registry `RunningAppID` plus Toolhelp process-scan fallback compile on Windows. |
| File dialogs | `rfd` uses the native Win32 dialog backend. |
Windows paths are resolved through `dirs`: Windows paths are resolved through `dirs`:
@@ -23,55 +32,72 @@ Windows paths are resolved through `dirs`:
## Building ## Building
### Native Windows ### Cross-compile from Linux
Install MSVC Build Tools and CMake, then build normally: Preferred local path:
```powershell ```sh
cargo build --release distrobox enter peerspeak-win -- bash -lc '
cd ~/git/butter/peerspeak &&
RUSTC_BOOTSTRAP=1 ./win-cross-build.sh -Z build-std=std,panic_abort
'
``` ```
If CMake is 4.x or newer, the vendored `opus`/`libopus` build may need: Equivalent direct command when the host has the GNU target, MinGW, `rust-src`, and
CMake available:
```sh
CMAKE_POLICY_VERSION_MINIMUM=3.5 RUSTC_BOOTSTRAP=1 \
cargo build --release --target x86_64-pc-windows-gnu --bin peerspeak \
-Z build-std=std,panic_abort
```
`CMAKE_POLICY_VERSION_MINIMUM=3.5` is required with host CMake 4.x because the
vendored Opus build used by `audiopus_sys` still declares an old minimum CMake
version. Without that env var, the Windows build/check fails during Opus configure.
### Native Windows
A native MSVC build is not the active development path. If used, install MSVC Build
Tools and CMake, then build normally:
```powershell ```powershell
$env:CMAKE_POLICY_VERSION_MINIMUM = "3.5" $env:CMAKE_POLICY_VERSION_MINIMUM = "3.5"
cargo build --release cargo build --release
``` ```
### Cross-compile from Linux
The current dev path cross-compiles from an Arch environment to the GNU Windows target:
```sh
rustup target add x86_64-pc-windows-gnu
sudo pacman -S mingw-w64-gcc cmake
CMAKE_POLICY_VERSION_MINIMUM=3.5 cargo build --release --target x86_64-pc-windows-gnu --bin peerspeak
```
Wine is useful for launch/render smoke tests, but it is not a substitute for a real
Windows audio-device pass. The deeper migration plan (phases, decisions, the opus build
spike) lives in the maintainer's handoff docs, outside the repo.
## First run and networking ## First run and networking
Expect a Windows Firewall prompt the first time the app opens network sockets. Allow it: Expect a Windows Firewall prompt the first time the app opens network sockets, or
PeerSpeak uses UDP for QUIC, plus relay traffic when direct NAT traversal is not available. use the Inno installer option that pre-adds a firewall allow rule. PeerSpeak uses
UDP for QUIC plus relay traffic when direct NAT traversal is unavailable.
The default network mode keeps the n0 relay available for NAT traversal without publishing The default network mode keeps the n0 relay available for NAT traversal without
presence to n0 DNS. Direct peer-to-peer paths may work when both networks allow them; relayed publishing presence to n0 DNS. Relayed connections are expected and valid.
connections are expected and valid.
## Known gaps ## Known gaps
| Item | Status | | Item | Status |
|---|---| |---|---|
| Echo cancellation | Linux-only PipeWire feature. The Windows UI shows it disabled as unavailable. | | Echo cancellation | Linux-only today. The Windows UI shows it disabled as unavailable. |
| Screen share | Requires a Windows `pixelpass.exe` on `PATH` or a configured override. | | Screen share | Blocked by PixelPass, which is currently Linux-only in practice. PeerSpeak can spawn `pixelpass.exe`, but there is no Windows PixelPass host/viewer parity yet. |
| Chimes | Now routed through Windows `SoundPlayer`; needs a real Windows host to audibly verify. | | Device persistence | Uses cpal friendly names as keys. These can duplicate or change across Windows driver/profile changes; stable WASAPI endpoint IDs are still needed. |
| Resampling/device format | Cross-compiled. cpal/WASAPI now chooses native 48 kHz when available and otherwise resamples/remaps at the device boundary; needs real Windows hardware audio verification. | | Release hygiene | Keep `.iss` and installer output in sync with `Cargo.toml`; rebuild Windows artifacts during each release. |
| Device persistence | Open. WASAPI friendly names may duplicate or change across driver/profile changes. | | Runtime coverage | The Windows VM smoke test proved an older tester build. Current `main` needs a fresh VM smoke matrix before calling parity current. |
| Playback pacing | Cross-compiled. The fixed playback target under WASAPI shared mode still needs real-hardware verification with `audio_probe`. |
Before calling Windows support done, verify a real Windows machine can create/join a room, ## Current smoke checklist
capture mic audio, hear remote audio, select devices, restart with selections preserved, and
play notification chimes. Before calling a Windows build current, verify on the Windows VM or real Windows
hardware:
- Launch current `peerspeak.exe`; GUI renders and settings open.
- Run `audio_probe.exe 440 30`; listen for glitches and inspect `playout-health`.
- Create/join a Linux <-> Windows room; confirm mic and playback both directions.
- Select input/output devices, restart, and confirm selections persist or fall back clearly.
- Play chimes and custom chime paths.
- Send chat, image/file attachments, and save an attachment through the native dialog.
- Import/play/share/listen to music from Windows file paths.
- Record mixed/stems/both and inspect the WAV output path.
- Exercise friends/presence/recents and the clock-skew banner.
- Test Steam and non-Steam game detection on a real Windows Steam install.
- Install/upgrade/uninstall through the Inno installer, including firewall rule cleanup.
+584
View File
@@ -0,0 +1,584 @@
# Chat hardening — ephemeral implementation plan
**Status (2026-07-18):** Phases 15 COMPLETE (all plan phases done). Phase 1 =
shared text policy in `src/sanitize.rs`, ceilings enforced at UI input, sign
point, and gossip ingress. Phase 2 = roster-bound authorship
(`src/core/chatroster.rs`), replay dedup + rate limits (`ChatIngressGate` in
`src/network/gossip.rs`). Phase 3 = attachment cache/serve-store budgets,
downscaled previews, auto-fetch byte/request budgets (`src/core/fetchbudget.rs`),
exact transfers, bounded local reads. Phase 4 = parsed-URL link policy
(`is_safe_web_url`/`link_ranges` in `src/sanitize.rs`, `url` crate), 8-link cap,
cached link ranges in `ChatEntry`, 512 KiB history text budget, chat-body
bidi-override strip (closes S14). Phase 5 = honest local send status
(`CoreCommand::SendChat`/`SendChatFile` carry a local id, `UiEvent::ChatSendResult`,
`SendStatus` on own echoes) PLUS sender-side pacing (`src/app/sendqueue.rs`
mirrors the receivers' per-author budget so fast bursts trickle instead of being
silently dropped downstream). All gates green each phase. This is a temporary
scope contract for hardening the existing room chat; with every phase complete
and the two-machine field test done, delete this file (see the completion note
at the end). The two-machine field-test section below is still owed before that
deletion. Do not add link previews as part of this effort.
## Goal
Strengthen the current encrypted, signed, session-only room chat without changing
its product model: plain selectable text, clickable web links, and peer-to-peer
attachments over the existing gossip and files planes. The work should make chat
resistant to identity spoofing, replay, spam, oversized input, expensive rendering,
and attachment-driven memory/bandwidth pressure while preserving normal Unicode
conversation and the existing full-mesh architecture.
## Existing foundation to preserve
- Gossip payloads are signed by the claimed `EndpointId`, bound to the raw room
topic and protocol domain, and checked before dispatch.
- The signed envelope timestamp is admitted only within the two-minute gossip
freshness window.
- Inbound gossip frames are capped at 128 KiB before JSON deserialization. This
larger plane-wide cap must remain because `Announce` may contain a custom avatar.
- Chat history is session-only and capped at 300 entries.
- Only `http://` and `https://` links are opened, as a single process argument
without a shell.
- Attachment descriptors are signed with the chat payload; attachment bytes use
the encrypted files plane, have a 25 MiB per-file cap, and are keyed by both
author and attachment id.
- Image bytes are decoded defensively and automatic image fetches already have a
four-task concurrency limit.
## Working design decisions
These are the implementation defaults unless code inspection or tests reveal a
concrete reason to adjust them. Record any adjustment in the decision log.
1. **No wire change.** Keep `GossipMessage::Chat` unchanged and do not bump
`GOSSIP_PROTO`. The redundant wire `name` and inner `Chat.ts` remain serialized
for compatibility but are not trusted. Remove them only during a future planned
gossip-version bump.
2. **Roster identity is authoritative.** A chat line is admitted only for an
authenticated identity already known to the current room (including the
reconnect grace state). Its displayed name comes from the sanitized roster
state, never from `GossipMessage::Chat.name`.
3. **Body Unicode remains expressive.** Do not apply the short-label sanitizer to
the message body; it strips format characters used by some languages and emoji.
Continue neutralizing controls and whitespace, while treating author labels,
filenames, and URLs more strictly because those are spoof-sensitive surfaces.
4. **Bounds apply at every trust boundary.** UI input is bounded while editing,
outgoing text is normalized before signing, and incoming text is byte-checked
and normalized before it leaves the gossip layer. UI-only truncation is not an
adequate ingress defense.
5. **Automatic network work is stricter than manual work.** Keep the 25 MiB manual
attachment ceiling, but auto-fetch only small images. Larger images remain
available behind an explicit Load/Download action.
6. **Caches are bounded by cost, not only entry count.** Count encoded bytes and
estimated decoded image bytes. A count cap remains as a secondary bound.
7. **Rate limiting degrades quietly.** Drop excess/replayed peer messages with a
rate-limited log entry. Do not let a spammer produce a second UI-notification
flood.
## Proposed policy constants
Keep these together near the code that enforces them and cover them with boundary
tests. Values are starting points, not a compatibility contract.
| Policy | Initial value | Reason |
| --- | ---: | --- |
| Chat body characters | 2,000 | Preserves current UI behavior |
| Chat body UTF-8 bytes | 8 KiB | Covers 2,000 four-byte scalars with small headroom |
| Live input characters/bytes | Same as body | Prevent oversized paste/edit state |
| Clickable links per message | 8 | Bounds spans and opener targets |
| Retained chat text | 512 KiB plus 300 entries | Bounds redraw and selection work |
| Per-author chat limiter | Burst 8, refill 1/second | Allows normal bursts, stops sustained spam |
| Room-wide chat limiter | Burst 32, refill 8/second | Protects shared event/UI queues |
| Exact-chat replay cache | 1,024 digests, 2-minute TTL | Covers freshness window with a hard bound |
| Auto-fetch image encoded size | 4 MiB | Limits unsolicited bandwidth and allocations |
| Attachment cache encoded budget | 128 MiB | Allows several ordinary files without GiB growth |
| Attachment cache decoded-preview budget | 64 MiB | Bounds renderer-side image pressure |
| Served attachment budget | 256 MiB plus a count cap | Bounds sender memory for a long session |
| Inline preview longest side | 1,600 px | Chat renders near 260 px; full 4K decode is wasteful |
| Decoded source image pixels | 16 megapixels maximum | Adds a total-pixel bound to per-side bounds |
## Phase 1 — Shared text policy and live-input bounds
**Target:** downstream layers never receive or retain an unexpectedly large or
unsafe chat string.
- [x] Move chat constants and `sanitize_chat` from `src/app/mod.rs` into
`src/sanitize.rs` (or a narrowly scoped shared chat-policy module if that keeps
the API clearer).
- [x] Implement a single-pass sanitizer that:
- maps control characters to spaces;
- collapses whitespace and trims ends;
- enforces both the character and UTF-8 byte ceilings without splitting a scalar;
- returns empty for content with no visible text.
- [x] Add `cap_chat_input` for live editing. It must preserve the user's current
whitespace while enforcing character and byte ceilings; normalization remains a
submit/ingress operation so typing does not visibly jump.
- [x] Apply `cap_chat_input` in `AppMessage::ChatInputChanged`, covering keyboard,
clipboard, primary-selection, and context-menu paste paths through the controlled
input widget.
- [x] Sanitize outgoing text immediately before local echo and `CoreCommand` send.
- [x] Sanitize again before `GossipMessage::Chat` is signed, so a future non-UI
caller cannot bypass policy.
- [x] At gossip ingress, reject raw chat text over the byte ceiling before doing
downstream sanitization; sanitize accepted text before creating `RoomEvent`.
- [x] Keep attachment-only messages when the sanitized caption is empty; drop a
chat with neither visible text nor a valid attachment.
- [x] Stop sanitizing an incoming chat `name` with the body sanitizer. Phase 2
replaces it with the roster-bound name.
### Phase 1 tests
- [x] ASCII, multibyte Unicode, emoji, whitespace, NUL/CR/LF/TAB/ESC, empty input.
- [x] Exact character and byte boundaries, including a four-byte scalar at the
cutoff.
- [x] Oversized paste never makes `state.chat_input` exceed either ceiling.
- [x] Outgoing, incoming, and direct core/network paths converge on the same
normalized result.
- [x] Empty captions are retained only when a valid attachment remains.
## Phase 2 — Admission, identity binding, replay, and spam control
**Target:** only current authenticated room members can create chat UI work, and a
member cannot impersonate another participant or monopolize the control/UI queues.
- [x] Change the core event task's chat roster from a bare `HashSet<EndpointId>` to
a bounded map containing each member's latest sanitized display name (or retain a
parallel name map if less invasive).
- [x] Insert/update the map on `PeerJoined`/`PeerUpdated`, retain it during transient
reconnect grace, and remove it on graceful or terminal eviction.
- [x] Before attachment handling or UI forwarding, reject `RoomEvent::ChatMessage`
whose author is not present in that authoritative roster.
- [x] Replace the embedded wire name with the roster map's name before constructing
`UiEvent::ChatMessage`. The UI may keep storing a name snapshot so old chat lines
remain labeled after a peer leaves.
- [x] Add a lightweight early known-author gate in the gossip loop using its live
and disconnected-peer sets. Keep the core roster gate as defense in depth and as
the final authority.
- [x] Validate that the inner `Chat.ts` equals the signed envelope timestamp, or
ignore it entirely. Do not use the inner timestamp for replay or ordering.
- [x] Add exact-chat replay suppression after signature verification and before
event-channel send:
- hash the canonical signed bytes, not raw JSON formatting;
- use BLAKE3 (make it a direct dependency if needed; it is already in the iroh
dependency graph) or an equally collision-resistant existing primitive;
- store a `HashSet` plus FIFO/TTL order for bounded lookup and eviction;
- prune by both the gossip freshness window and the hard entry cap.
- [x] Add a bounded token bucket per admitted author and a room-wide bucket before
awaiting `event_tx.send`. Limiter state must be removed with roster eviction and
remain bounded by the roster cap.
- [x] Ensure duplicate messages are dropped before consuming rate-limit tokens, so
a replay cannot starve a legitimate new message from that author.
- [x] Rate-limit rejection logging per author/reason.
- [ ] Consider applying the same local submit policy to accidental rapid Enter or
button activation, without routing chat through the coalescing command path.
### Phase 2 tests
- [x] Valid roster author is admitted; never-announced, post-leave, forged, and
stale authors are rejected.
- [x] A peer sending `name = "Victim"` renders under its own roster name.
- [x] A name update affects future messages without rewriting history.
- [x] Reconnect grace continues accepting the known author; terminal eviction does
not.
- [x] The same signed chat is displayed once; distinct chats created in the same
millisecond are both admitted.
- [x] Replay-cache TTL/cap pruning cannot grow without bound.
- [x] Per-author burst/refill and room-wide burst/refill boundaries.
- [x] Excess chat cannot prevent a subsequent `Leave` or `Announce` from reaching
the event loop in a deterministic channel-pressure test.
## Phase 3 — Attachment transfer and memory hardening
**Target:** neither peers nor long local sessions can turn chat attachments into
unbounded memory, bandwidth, decoder, or task pressure.
### 3A. Cache and image cost
- [x] Extend `AttachmentCache` with encoded-byte and decoded-preview-byte counters.
Preserve the count cap, but evict oldest entries until all three budgets fit.
- [x] Give every entry an explicit weight. Replacement must subtract the old
weight before checking/inserting the new one.
- [x] Decide behavior for a single entry larger than the cache budget: service an
immediate pending Save/Play request without retaining it, then expose it as
evicted/unavailable rather than exceeding the budget.
- [x] Add a total-pixel limit to `validate_image_bytes` in addition to the existing
width/height limit.
- [x] Build a downscaled inline preview handle with a maximum 1,600 px side. Keep
original bytes only for Save; do not hand a full-resolution 4K image to the
renderer merely to display it at chat width.
- [x] Count estimated RGBA preview cost (`width * height * 4`) against the decoded
budget even if iced internally copies or uploads it.
- [x] Strip the same bidi/zero-width spoofing characters used for display labels
from attachment filenames, while preserving ordinary Unicode filenames.
### 3B. Automatic download policy and state
- [x] Auto-fetch only roster-authored images whose declared size is at or below
`MAX_AUTO_IMAGE_BYTES`; keep the existing `(author,id)` dedup and four-permit
concurrency bound.
- [x] Add per-author and session byte/request budgets for automatic fetches so a
peer cannot drain bandwidth sequentially after each permit is released.
- [x] Represent `NotFetched`, `Loading`, `Ready`, `Failed`, and `Evicted` distinctly
enough for the UI to avoid an indefinite “loading…” label when auto-fetch was
skipped or the cache evicted an item.
- [x] Render a Load image button for large/skipped images. A manual click may use
the 25 MiB file cap but still observes cache/decoder budgets.
- [x] Ensure a repeated click cannot create duplicate unguarded fetch tasks.
- [x] Keep non-image attachments manual-only.
### 3C. Exact transfers, local reads, and served files
- [x] In `IrohTransport::fetch_blob`, require `bytes.len() as u64 == declared_size`.
Reject empty, short, and overlong transfers with a concise local error.
- [x] Replace the file picker's unbounded `FileHandle::read()` with a helper that
reads at most `MAX_ATTACHMENT_BYTES + 1`. Check metadata first where available,
but retain the bounded read because metadata can race or be unavailable through
a portal.
- [x] Avoid duplicating a full attachment across UI, command queue, and serve store.
Prefer `Arc<Vec<u8>>`/`Arc<[u8]>` through `AttachmentState`, `CoreCommand`, and
`serve_attachment`, subject to iced handle API constraints.
- [x] Replace the unbounded session `served_files` map with a count- and byte-
budgeted FIFO store. Evicted ids should produce the existing “sender no longer
has the file” response rather than stale or aliased data.
- [x] Keep attachment ids keyed by author on receipt and preserve all existing
request-length, timeout, filename, and decoder checks.
### Phase 3 tests
- [x] Byte-budget eviction, count eviction, replacement accounting, clear/reset,
and an individually overweight entry.
- [x] Decoded-preview budget and downscale dimensions for wide, tall, square, and
boundary images.
- [x] Image with valid per-side dimensions but excessive total pixels is rejected.
- [x] A declared 4 MiB image auto-fetches; the first byte over the limit requires a
click.
- [x] Per-author/session auto-fetch budgets recover according to their policy and
never exceed task concurrency.
- [x] Short, exact, and overlong file responses.
- [x] Local file reader stops at cap + 1 instead of allocating the full source.
- [x] Served-file FIFO/byte eviction and replacement accounting.
- [x] Same attachment id from two authors remains isolated throughout fetch, cache,
save, and display.
## Phase 4 — URL and rendering resilience
**Target:** keep clickable links without making malformed/deceptive input or many
small spans an unnecessary UI/launcher surface.
- [x] Make `url` a direct dependency (already present transitively) and validate
link candidates with `url::Url`.
- [x] A clickable URL must have an `http` or `https` scheme and a valid host.
- [x] Treat URLs containing username/password syntax as plain text, or require an
explicit confirmation that shows the parsed destination host. Prefer plain text
for the first implementation.
- [x] Preserve the existing defense-in-depth validation in `AppMessage::OpenUrl`;
replace prefix checks with the shared parsed-URL policy.
- [x] Cap clickable candidates at eight per message. Remaining content stays
selectable plain text and must still round-trip exactly.
- [x] Refactor linkification to return borrowed ranges/offsets or cache link ranges
in `ChatEntry`, avoiding allocation and rescanning on every redraw.
- [x] Bound retained history by total sanitized text bytes as well as 300 entries.
Eviction must keep attachment bookkeeping coherent and should not invalidate an
open Save/Play operation.
- [x] Do not add metadata fetching, remote images, Markdown, or link previews.
- [x] (Folded in from S14, per the security handoff) Strip bidi
overrides/isolates from the chat BODY in `sanitize_chat`, keeping the other
expressive format characters (ZWJ/ZWNJ/LRM/RLM).
### Phase 4 tests
- [x] Valid HTTP/HTTPS, malformed host, empty host, mixed case, Unicode path/query,
punctuation, credentials/userinfo, and non-web schemes.
- [x] Eight-link boundary and many-link adversarial input.
- [x] Segment/range reconstruction exactly reproduces the sanitized message.
- [x] Entry-count and total-text-budget history eviction.
- [x] Opener policy cannot launch a non-web scheme even if called directly.
## Phase 5 — Honest local send status
**Target:** never present a locally echoed message as successfully broadcast when
the core rejected it or gossip broadcast failed.
- [x] Add a local-only message id and `Pending`/`Broadcast`/`Failed` state to local
chat entries. Do not put this id or state on the wire. (`ChatEntry.local_send:
Option<LocalSend>`; `SendStatus` also has `Queued` for the paced-but-not-yet-sent
state — see the pacing decision-log entry.)
- [x] Carry the local id through `CoreCommand::SendChat`/`SendChatFile` and return a
`UiEvent` result after the local gossip broadcast call succeeds or fails.
(`SendChat`/`SendChatFile` gained `local_id`; new `UiEvent::ChatSendResult { local_id,
error }`.)
- [x] If the core is not in an active session, return failure instead of silently
doing nothing. (`send_chat` now `Err`s on missing sender/topic and on encode
failure; the core arm maps no-session to a `ChatSendResult` error.)
- [x] Show failure compactly with a retry action. A successful local broadcast must
not be labeled “delivered” or “read”; PeerSpeak has no peer acknowledgements.
(Failed → red "⚠ Not sent — {reason} [Retry]" line; Broadcast/Pending render
nothing — silence is the honest success state.)
- [x] Retry creates one new signed broadcast while retaining replay correctness and
attachment serving state. (`RetryChatSend(id)` re-dispatches the retained
`PendingSend`; re-serving the same attachment id REPLACES the `ServeStore`
entry, never double-counts — see `serve_store_replacement_accounting_and_remove_clear`.)
### Phase 5 tests
- [x] Local echo starts pending, becomes broadcast on success, and becomes failed
on no-session/channel/gossip error. (`send_status_pending_then_broadcast_on_success`,
`send_status_failed_keeps_payload_for_retry`.)
- [x] Results update only the matching local entry, including after history
eviction or room reset. (`send_result_updates_only_the_matching_entry`,
`send_result_after_eviction_drops_orphan_payload`, `send_result_after_room_reset_is_a_noop`.)
- [x] Retry does not duplicate served bytes or mutate an unrelated entry.
(`retry_redispatches_only_the_targeted_send`; served-byte dedup =
`serve_store_replacement_accounting_and_remove_clear` in `files.rs`.)
## Compatibility and versioning
- The planned implementation changes validation, local data structures, and
internal `CoreCommand`/`UiEvent` shapes only. Keep the serialized
`GossipMessage::Chat` and file request/response formats unchanged.
- Therefore do **not** bump `GOSSIP_PROTO`, `FILES_PROTO`, or the pre-1.0 MINOR
solely for this plan. The eventual release is a compatible PATCH unless scope
expands into a wire change.
- If implementation requires removing/adding serialized fields, changing
attachment request framing, or introducing acknowledgements on the wire, stop
and revise this section before coding that part. Follow `VERSIONING.md` and use
the appropriate protocol plus release MINOR bump.
## Verification gates
Run after each phase, with focused tests first and the full gates before handoff:
```text
cargo fmt --check
cargo test --lib
cargo test --all-targets
cargo clippy --all-targets -- -D warnings
```
Also retain the existing ignored/loopback coverage where the environment supports
it; do not make ordinary unit tests depend on external network access.
### Two-machine field test
- [ ] Ordinary ASCII/Unicode conversation, rapid short burst, long boundary text,
and oversized paste.
- [ ] Rename during a room: new lines use the new roster name; old lines retain
their snapshot.
- [ ] Disconnect/reconnect grace and post-leave chat admission behavior.
- [ ] Multiple normal images, one image above the auto threshold, a malformed
“image”, and a maximum-size manual file.
- [ ] Download/save after cache eviction; clear failure state and no runaway
memory across repeated attachments.
- [ ] Observe process RSS and UI responsiveness during a bounded spam/attachment
stress run; verify leave/reconnect controls remain responsive.
- [ ] Linux and Windows URL opening for valid links; malformed/userinfo links remain
selectable but do not launch.
- [ ] A message with more than eight URLs renders eight clickable links and the
rest as selectable plain text, with nothing dropped.
- [ ] A message attempting bidi-override display spoofing renders in send order
(the override characters are stripped, emoji/joining-script text intact).
- [ ] Send a fast burst (>8 messages in a second): all arrive at the peer in
order, none silently lost; the sender sees "queued…" on the overflow that
then clears as each goes out.
- [ ] Send with no active session (or a failing broadcast): the message shows
"⚠ Not sent" with a Retry, and Retry resends it once when connectivity is back.
## Completion criteria
The plan is complete when:
1. Only active/grace-rostered authenticated authors reach chat UI state.
2. Chat identity is roster-bound and cannot be overridden by the embedded wire
name.
3. Exact replay and sustained spam are bounded before shared event queues.
4. Live input, inbound/outbound body size, history text, attachment caches,
automatic transfers, served files, and decoded previews all have tested hard
bounds.
5. File transfer length and image decoding/display costs are validated.
6. Clickable links pass a shared parsed-URL policy and rendering work is bounded.
7. Local broadcast failure is visible without claiming peer delivery.
8. Unit/all-target/clippy gates and the two-machine field test pass.
9. Relevant durable docs (`README.md`, `docs/FEATURES.md`, `CHANGELOG.md`, security
notes, and comments) describe the final behavior.
10. This ephemeral plan is deleted after its useful status/history is transferred
to durable documentation.
## Out of scope
- Link previews, metadata fetches, or remote thumbnail requests.
- Persistent/offline chat history or server-side message storage.
- Markdown, rich embeds, reactions, editing, deletion, threads, or search.
- Read receipts or peer delivery acknowledgements.
- Moderation UI, kicking, blocking, or trust-list redesign.
- Antivirus/malware scanning of user-requested downloaded files.
- A new application-layer group-encryption protocol or a broader cryptographic
redesign. If PeerSpeak makes a formal end-to-end-encryption product claim, audit
and document the exact iroh/gossip/relay threat model as a separate project.
## Decision log
- **2026-07-15:** Chose hardening over automatic link previews because receiving a
message should not trigger third-party web requests or weaken PeerSpeak's
privacy-oriented design.
- **2026-07-15:** Initial scope keeps all wire formats stable; hardening is local
admission, validation, resource accounting, and honest UI state.
- **2026-07-17 (Phase 1):** The 8 KiB byte ceiling deliberately cannot bind on
*sanitized* output (2,000 scalars × 4 bytes = 8,000 ≤ 8,192), so inside
`sanitize_chat`/`cap_chat_input` it is defense in depth; its operative role is
the raw-ingress reject in `admit_chat_text`.
- **2026-07-17 (Phase 1):** Interim until Phase 2's roster binding: the incoming
chat `name` now goes through the strict `sanitize_name` label sanitizer at the
UI edge (was the body sanitizer), so author labels already get bidi/zero-width
stripping and the 48-char label cap.
- **2026-07-17 (Phase 1):** `send_chat` at the gossip sign point silently no-ops
(Ok) on an empty-after-sanitize body with no attachment rather than erroring;
the UI already prevents this case, and Phase 5's send-status work is where
send-path feedback gets designed.
- **2026-07-17 (Phase 2):** Replay dedup is keyed on the payload's own Ed25519
**signature bytes** instead of a BLAKE3 digest (the plan allowed "an equally
collision-resistant existing primitive"): ed25519 signing is deterministic
(RFC 8032), so the 64-byte signature is already a collision-resistant
fingerprint of the exact signed bytes — same dedup power, zero new direct
dependencies. Cache entries are stamped with the signed envelope `ts` and
pruned once it exits the freshness window, because `verify_gossip` already
rejects such a frame before the cache is consulted.
- **2026-07-17 (Phase 2):** A room-bucket reject refunds the just-consumed
author token, so a room-wide squeeze caused by other members does not also
drain an innocent author's personal budget.
- **2026-07-17 (Phase 2):** Rate-limited frames are NOT entered into the replay
cache: only fully admitted chats are. A legitimate message the room was too
busy for, redelivered later by the swarm, is then displayed once instead of
being misread as a replay of something never shown.
- **2026-07-17 (Phase 2):** The "wire name never renders" guarantee is
structural: the core event task binds the wire field as `name: _` and builds
`UiEvent::ChatMessage` exclusively from `ChatRoster::name_of`, so there is no
code path from wire name to UI. The roster map behavior is unit-tested; the
end-to-end impersonation scenario stays on the (still-open) two-machine
field-test list.
- **2026-07-17 (Phase 2):** The channel-pressure requirement is met at the seam
level: chat admission is bounded (32-burst / 8-per-s room-wide) BEFORE any
`event_tx.send`, and `Announce`/`Leave` admission is independent of the chat
gate — verified by unit tests. A full gossip-loop pressure harness was not
built; the seam bound is what protects the channel.
- **2026-07-17 (Phase 2):** An empty-after-sanitize roster name falls back to
the short node id, so a member who announces an all-control-character name
still gets a stable, non-blank chat label.
- **2026-07-17 (Phase 2):** The "Consider applying the same local submit policy
to accidental rapid Enter" item is DEFERRED: the receiving side is the
security boundary (every peer independently enforces the buckets), and a
local silent drop would be a UX regression better designed alongside Phase
5's honest send status.
- **2026-07-18 (Phase 3):** Constants that deviate from the proposed table, all
bound-tested: total decoded pixels **14 MP** (not 16 MP) so the bound clears
12 MP phone photos (4032×3024) yet actually binds inside the 4096²≈16.8 MP
per-side envelope; cache encoded budget **96 MiB** (not 128) — still several
full-size files, tighter worst case; serve store **128 MiB + 16 entries**
(not 256 MiB) — a sender's own session should not pin a quarter GiB.
- **2026-07-18 (Phase 3):** `validate_image_bytes`/`decode_preview` precheck
dimensions from the container HEADER (`into_dimensions`) before any pixel
decode, so an over-limit decode bomb is rejected without paying its decode
cost; the decode-time `image::Limits` remain as defense in depth, and the
decoded dimensions must equal the prechecked header dimensions.
- **2026-07-18 (Phase 3):** Budget-pressure evictions leave NO cache entry
(absence = NotFetched → the same Load/Download affordance), while the
explicit `Evicted` state marks only an *individually over-budget* fetch whose
bytes were used once (pending Save/Play serviced from hand) and dropped. Both
render load-on-demand; only the bookkeeping differs.
- **2026-07-18 (Phase 3):** The core still runs `validate_image_bytes` before
emitting `AttachmentReady`, and the UI decodes once more to build the ≤1600px
preview. Two bounded decodes per image were accepted over shipping decoded
RGBA across the channel (which would defeat the encoded-only Arc sharing).
- **2026-07-18 (Phase 3):** The image lightbox now enlarges the ≤1600px preview
handle, not the original bitmap — originals are retained encoded-only for
Save. At the lightbox's window-sized draw area the visual difference is nil
for the chat use case; full fidelity remains one Save away.
- **2026-07-18 (Phase 3):** `AutoFetchBudget` checks all four buckets
(author/session × requests/bytes) and only then consumes atomically, so a
rejection burns nothing (no refund path like Phase 2's room bucket needed).
Tokens ARE consumed if the four-permit semaphore then rejects the spawn —
that only happens mid-flood, when charging the author is the intent.
- **2026-07-18 (Phase 3):** The auto-fetch budget's author map prunes
least-recently-active past 64 entries instead of wiring roster eviction into
the event task: authors are roster-gated upstream (≤32 live members), so
strangers cannot churn the map, and a pruned author returning with full
buckets is within policy.
- **2026-07-18 (Phase 3):** Music-track serving shares the bounded serve store
with chat attachments. A user who sends enough large attachments during a
broadcast can evict their own current track; listeners then get the standard
"sender no longer has the file" failure. Accepted: budget honesty over a
second store, and the store comfortably fits current+next track plus a
normal chat working set.
- **2026-07-18 (Phase 3):** The clip player's command channel still takes one
owned byte copy at the moment of a Play click (small, human-initiated). The
Arc de-duplication targeted the send path (UI cache / command queue / serve
store), which now shares a single allocation.
- **2026-07-18 (Phase 3):** Overlong transfers are rejected by the transport
read itself (`read_to_end(size)` errors past the bound) rather than an
explicit length compare; short transfers get the explicit
`len == declared_size` check. Music fetches ride `fetch_blob`, so they
inherit exactness for free.
- **2026-07-18 (Phase 4):** The S14 chat-body half (bidi strip) landed here per
the security handoff: `sanitize_chat` strips ONLY bidi overrides/isolates
(U+202A202E, U+20662069) — the characters that can visually reorder a
rendered line — while ZWJ/ZWNJ (emoji sequences, joining scripts) and the
LRM/RLM direction *marks* (which cannot reorder) are kept. Labels/filenames
keep the stricter full-format-strip.
- **2026-07-18 (Phase 4):** A link's href is the exact displayed slice of the
message — validation is parse-only, no normalization on open — so what the
user sees IS the argv the opener receives. Consequence: WHATWG slash
collapsing means `http:///path` parses to host `path` (as in browsers) and is
accepted; the empty-host rejects are `http://` and friends that fail parsing.
- **2026-07-18 (Phase 4):** URLs with userinfo syntax went the plan-preferred
plain-text route (no confirmation dialog). A candidate that fails the policy
leaves its WHOLE whitespace-delimited run as plain text without re-scanning
the interior — `http://a@http://b.com` yields zero links, by design.
- **2026-07-18 (Phase 4):** Scheme detection became ASCII-case-insensitive
(`Http://…` from sentence auto-capitalization now linkifies); the policy
check is unaffected since `url` normalizes scheme/host case during parsing.
- **2026-07-18 (Phase 4):** Cached ranges in `ChatEntry.links`, filled inside
`push_chat` (the single history choke point), were chosen over
borrowed-return-per-redraw: redraws now slice cached char-boundary ranges,
and only link spans allocate (their href String).
- **2026-07-18 (Phase 4):** History byte-budget eviction (512 KiB, alongside
the 300-entry cap) deliberately does NOT touch the attachment byte cache:
that cache is bounded by its own Phase 3 budgets, and leaving it alone means
an open Save/Play on an evicted line keeps its bytes-in-hand (the save
dialog falls back to the generic "download" name). The just-pushed entry is
never evicted; a single message's 8 KiB ceiling cannot exceed the budget.
- **2026-07-18 (Phase 5):** Sender-side PACING was added to Phase 5's scope
(originally receiver-status only). The Phase 2 decision log deferred the
"apply the same local submit policy to accidental rapid Enter" item to pair
with Phase 5, and honest status alone would still let a fast burst broadcast
successfully yet be silently dropped by every receiver's per-author bucket
(8 burst, then 1/s) with no sender feedback. The user chose "queue and
trickle" over "throttle input": sends past the burst queue locally as
`SendStatus::Queued` ("queued…") and release at the receivers' sustained
rate, so nothing is lost and typing is never blocked.
- **2026-07-18 (Phase 5):** The pacer (`src/app/sendqueue.rs`) reuses the
gossip gate's OWN `TokenBucket` + `CHAT_AUTHOR_BURST`/`CHAT_AUTHOR_REFILL_PER_MS`
(made `pub(crate)`), so the two sides of the rate policy are one definition
and cannot drift. It mirrors only the PER-AUTHOR budget, not the room-wide
one — we cannot know other members' send rates, and the per-author bucket is
the one guaranteed to apply to us at every receiver.
- **2026-07-18 (Phase 5):** Send status renders as a line UNDER the message
(user pick over an inline suffix glyph); `Broadcast` and the transient
`Pending` show nothing because PeerSpeak has no delivery/read receipts, so an
unadorned message IS the honest "handed to the swarm" state. Only `Queued`
and `Failed` (with Retry) are surfaced.
- **2026-07-18 (Phase 5):** The pacer and the monotonic send-id counter
deliberately SURVIVE a room reset while the queue and retry payloads are
cleared: receivers' per-author buckets persist across our rejoin (so the
pacer should not refill to full), and never-reused ids keep a late
`ChatSendResult` from a pre-reset send from aliasing a new entry — verified by
`send_result_after_room_reset_is_a_noop`.
- **2026-07-18 (Phase 5):** The pacer clock is `Instant`-based
(`AppState.send_clock`), not wall-clock, so a system time jump can neither
rewind nor fast-forward the send budget.
## Completion
All five phases are implemented and every gate is green. Per the scope-contract
note at the top, this file should be DELETED once the owed two-machine field
test (the checklist below) has been run — that deletion is a separate,
user-gated step, not part of the Phase 5 commit. Until then the plan stays as
the record of what shipped and what remains to verify on real hardware.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,472 @@
# Phase 5 — dry-run audit gate: results
**Status: 🟢 GATE PASSED (run 2, 2026-07-26). All 13 §5.1 rows completed; the
eligible half of every row is non-empty. O5 re-measured on the fixed graph and
stays closed.** One new defect was found and fixed during the run (F13-1); three
findings are recorded as non-blocking, and three rows carry recorded
substitutions. Phase 6 is unblocked **by this file**, and F11-1 — the other gate —
was closed with this data on 2026-07-26 (see "What still blocks phase 6").
- **Run date:** 2026-07-26 (run 1: 2026-07-25, gate FAILED — see history below)
- **Host:** `cazen` — PipeWire 1.6.8, WirePlumber 0.5.15, CachyOS
- **Audit build:** pixelpass `main` @ `91c4ded`, release profile
- **peerspeak build:** `main` @ `b68fca6` (phase 1 merged)
- **Ambient load:** Firefox playing audio throughout (a live, uncontrived
candidate); Sunshine running (pid 3838); Arctis 1 Wireless as active sink
- **Graph size:** 14 Nodes, 4 Devices, 57 Ports, 4 Links, 24 Clients
---
## What changed since run 1
Run 1 failed on two defects, both fixed before this run:
- **F1** (fatal): the registry `global` event delivers only a filtered subset of
node properties, so eight properties the engine depends on were permanently
absent. Fixed by design round 8 / **phase 3r** — bind every Node and Device
and read properties from `info`.
- **F2**: a machine-wide over-exclusion cascade downstream of F1.
Both are gone: the baseline run (no fixture at all) reports **1 candidate,
eligible, empty taint set**.
### 🔴 F13-1 — FOUND AND FIXED DURING THIS RUN
**Row 1 failed on its first attempt, and the cause was a third defect of exactly
the F2 class from a new source: pipewire-pulse's PID was unresolvable on this
host, permanently.**
`pulse_pid::candidate` returned the single `pipewire.sec.pid` shared by two or
more Clients, on the stated reasoning that "native PipeWire clients carry their
own distinct PID; only the Pulse shim repeats one value". Measured: **WirePlumber
repeats one too.** It holds two Clients — `WirePlumber` and
`WirePlumber [export]` — both `sec_pid` 1747. Two values repeated (1747 and
pipewire-pulse's 2528), the rule called that ambiguous, and returned `None`.
With the daemon PID unknown, `owner::keys_of`'s documented fail-closed asymmetry
takes over: key 4's suppression never fires, every Pulse-emulated node fuses into
one owner, and the cascade follows. Row 1's observed failure:
```
ELIGIBLE (1): r1_plain_app
EXCLUDED: Firefox tainted-owner-bridge key=application.process.id
r1_c_play tainted-owner-bridge <- the CLEAN control half
TAINT: ... + both sound cards, all three sunshine sinks, sunshine itself
```
The rule was wrong in **both** directions, so the prefilter was removed rather
than patched:
- **False ambiguity** — any second process holding two Clients defeats it.
WirePlumber always does, so this was permanent, not a corner case.
- **False absence** — a session where pipewire-pulse holds exactly one Client
(one Pulse app running) repeats nothing, so the candidate is missed and the
same cascade follows.
`comm` was always the authoritative check; repetition was a heuristic standing in
front of it, and it was a guess about other processes' Client counts. Fixed in
pixelpass `91c4ded`: `candidates()` lists every distinct `sec_pid`, `resolve()`
picks the unique one whose `/proc/<pid>/comm` is exactly `pipewire-pulse`, and
several matches still fail closed (a single `Option<u32>` cannot suppress two
daemons — recorded, not approximated). The adapter probes only PIDs *entering*
the candidate set, and `retain_probed_comms` bounds the map to live PIDs so a PID
that leaves and returns is re-probed instead of answered from a stale `comm`.
**This is the §5.1 exact-partition requirement earning its keep for the second
time.** The verdict was fail-closed and silent; only the asserted *eligible* half
exposed it. An exclusion-only checklist would have passed this build too.
---
## §5.1 — the matrix
Every row ran with `PIXELPASS_AUDIO_AUDIT_AEC=off` except row 12. Every row ran
in its **own** audit process, so nothing carries over (sticky taint is
per-process state).
⚠️ **Methodology change from run 1, and it is load-bearing.** Run 1 built each
fixture *before* starting the audit. On this host the entire graph then arrives
as one enumeration burst (~122 events in 12 ms), so every node is first tainted
while `graph_ready` is still false, that partial-graph taint is recorded into
sticky state, and on the single ready record the sticky pass raises
`TaintedOwnerBridge { key: None }` before the evidence pass can name a key —
`raise` will not replace a same-rank reason. Verdicts were still correct but rows
could not assert their key. This run starts the audit first, waits for readiness,
then builds the fixture, so taint is derived from real topology *changes* against
a ready graph — which is also the dynamic path §6.3 cares about. Keys are read at
**derivation** (first non-sticky appearance), not from the final record.
| # | scenario | status |
| --- | --- | --- |
| 1 | null-sink + loopback forwarder, owner bridge | ✅ **pass** (after F13-1 fixed) |
| 1b | Sunshine's topology (opportunistic, non-gating) | 🟡 observed, nothing to exclude — see below |
| 2 | gst split clients, tainted input | ✅ **pass**, key 4 named at derivation |
| 3 | two Pulse modules, one tainted | ✅ **pass** |
| 4 | peerspeak native call playback | ✅ **pass** — real tagging site |
| 5 | peerspeak-spawned mpv | ✅ **pass** — real tagging site, hand-launched mpv eligible |
| 6 | peerspeak notification sound | ✅ **pass** — real tagging site |
| 7 | second host's capture sink + forwarder | ✅ **pass**, eligible half non-empty |
| 8 | EasyEffects | 🟡 **pass with substitution** — echo-cancel stood in |
| 9 | Firefox three cases | ✅ **pass** (cases 23 via gst; see substitution) |
| 10 | sticky taint across teardown | ✅ **pass**, all four phases incl. retirement |
| 11 | recycled serial / index / link-group | ✅ **pass**, and provably non-vacuous |
| 12 | AEC loaded → unloaded → Revoked | ✅ **pass** |
| 13 | `Audio/Duplex` device | 🟡 **pass with synthetic node** — over-taint confirmed |
### Row 1 — owner bridge, key named
```
ELIGIBLE (3): Firefox · r1_c_play · r1_plain_app
EXCLUDED (2): peerspeak_owned_call_4242 peerspeak-owned
r1_t_play tainted-owner-bridge key=node.link-group
TAINT (5): the tagged producer, r1_t_src, r1_t_cap, r1_t_play, r1_t_dest
```
The clean half is an **identically shaped** forwarder — same module type, same
monitor-read, same re-emit — differing only in whether anything tainted feeds it.
`r1_c_play` eligible is the assertion an exclude-everything build cannot satisfy.
The key is `node.link-group`, a strong key, not a link walk.
### Row 2 — GStreamer split clients, key 4
Measured props confirm the shape is the real refutation: `r2_gst_tainted_src`
(client 188) and `r2_gst_tainted_sink` (client 191) are **different Clients** of
**one process**, pid 235628, with no `link-group` and no `pulse.module.id`. So
`application.process.id` is the only key that can relate them.
Derivation record (seq 209): `r2_gst_tainted_sink``tainted-owner-bridge`,
**`owner_key=application.process.id`**. `r2_gst_clean_sink`, reading an untainted
monitor in a second process, is eligible.
### Rows 46 — peerspeak's own paths, through the real call sites
Driven by peerspeak's phase-1 live gate tests (`--ignored`), i.e. the real
tagging sites, not a hand-rolled env: "emission alone proves only that peerspeak
talks, not that pixelpass listens" (impl plan §3).
| node | verdict |
| --- | --- |
| `peerspeak_owned_call_238172` | EXCLUDED `peerspeak-owned` |
| `peerspeak_owned_mpv_238196` | EXCLUDED `peerspeak-owned` |
| `peerspeak_owned_notify_238231` | EXCLUDED `peerspeak-owned` |
| `peerspeak_owned_clip_238249` | EXCLUDED `peerspeak-owned` (bonus — chat clips) |
| `mpv` (launched by hand, untagged) | **ELIGIBLE** |
This is the cross-repo contract closed end to end on live nodes.
### Row 9 — the over-exclusion promise
```
ELIGIBLE: Firefox (music only) · r9_mic_out (captures an untainted real device)
EXCLUDED: r9_mon_out tainted-owner-bridge key=application.process.id
```
`r9_mic_out` is the row that defends §6.1.1: an app that captures a real
`session_device` source and also plays audio stays shareable. The device source
itself never entered the taint set.
### Row 10 — the full sticky lifecycle
| phase | topology | verdict |
| --- | --- | --- |
| A | tainted producer + forwarder | `r10_play_out` EXCLUDED, key `node.link-group` |
| B | **tagged producer killed**, forwarder lives | **still EXCLUDED** (sticky) — current topology alone no longer justifies it |
| C | forwarder owner replaced, tainted sink kept | fresh forwarder EXCLUDED — correct: a sink that received call audio is still a hazard while it lives |
| D | **every** tainted object torn down, then restart | taint set **empty** at 16.3 s; `r10_new_out` **ELIGIBLE** at 20.3 s |
Phase B proves stickiness works; phase D proves it is not permanent. Phase C is
worth keeping in mind when reading any future report: partial teardown legitimately
does *not* retire taint, and that is easy to mistake for over-exclusion.
### Row 11 — recycled identifiers, provably non-vacuous
| generation | `node.link-group` | global id (`r11_src`) | `object.serial` (`r11_play`) | pulse module |
| --- | --- | --- | --- | --- |
| 1 (tainted) | `loopback-2528-14` | 168 | 4702 | 536870919 |
| 2 (after teardown) | **`loopback-2528-14`** | **168** | 4746 | 536870920 |
The `node.link-group` came back **byte-identical** — and it is the very key that
carried the taint in generation 1 — and the global id was reused. Generation 2's
`r11_play` is **ELIGIBLE** with an empty taint set. `object.serial` correctly did
not recycle, which is why the model keys everything by it.
### Row 12 — AEC lifecycle
| stage | `aec_state` | `fan_out_permitted` | candidates |
| --- | --- | --- | --- |
| module live, configured | `validated` | `true` | Firefox + `r12_plain_app` ELIGIBLE; `echo-cancel-playback` EXCLUDED `aec-identity` |
| module unloaded | `revoked` | `false` (`gate_reason=aec-revoked`) | every candidate EXCLUDED `aec-revoked` |
All **four** link-group siblings (`sink`, `source`, `capture`, `playback`) carry
`aec-identity`; only `echo-cancel-playback` is a candidate, so it is the only one
in the excluded partition. Ordinary apps staying eligible *while validated* is
what makes "the gate is open" observable rather than inferred.
### Row 13 — `Audio/Duplex` over-taint (known accepted)
No real duplex device exists on this host, so one was synthesised by overriding
`media.class=Audio/Duplex` on a null sink. Its playback side was tainted and its
capture-side consumer was dragged down with it (`r13_dup_play` EXCLUDED), with
the eligible half intact. **Fixture limit, stated plainly:** on a null sink the
capture side *is* the monitor, so this cannot separate the duplex smear from the
ordinary sink→monitor edge. The accepted over-taint is confirmed as *behaviour*;
a real duplex device is still the only way to isolate the mechanism.
### Row 1b — Sunshine (opportunistic, non-gating)
Sunshine ran throughout. Its three null sinks stayed SUSPENDED and it read the
**hardware** monitor instead, exactly as §5.3 warned. It appears consistently and
correctly as `sunshine` / `tainted-upstream` whenever the monitor it reads is
tainted (rows 8, 12, o5). It has **no re-emitting output leg** — it sends over
the network — so it is never a candidate and there is nothing to exclude. Recorded
as observed; the "if a re-emitting leg exists" clause did not apply. A real
third-party forwarder sample remains owed.
---
## §5.2 — O5 re-measured
The run-1 numbers do not carry over: they were measured on the graph F1 degraded,
and phase 3r adds a bind plus an `info` round-trip **per node**, which is new I/O
that run never exercised.
Per-run, across all 13 rows (`recompute` in µs):
| run | events | ev/s | max | mean | emit max | busy fraction | ready@ms |
| --- | --- | --- | --- | --- | --- | --- | --- |
| baseline | 123 | 21.4 | 20 | 3 | 6 | 0.0001 | 1 |
| o5 (churn) | 407 | 44.0 | 32 | 10 | 9 | 0.0006 | 1 |
| row01 | 219 | 41.7 | 53 | 10 | 9 | 0.0006 | 1 |
| row02 | 241 | 45.9 | **67** | 11 | 10 | 0.0006 | 1 |
| row03 | 206 | 48.5 | 54 | 8 | 8 | 0.0005 | 1 |
| row0456 | 185 | 20.0 | 38 | 7 | 10 | 0.0002 | 2 |
| row07 | 184 | 43.3 | 40 | 7 | 9 | 0.0004 | 1 |
| row08 | 172 | 32.6 | 44 | 6 | 7 | 0.0003 | 2 |
| row09 | 224 | 30.9 | 52 | 9 | 9 | 0.0004 | 1 |
| row10 | 332 | 14.3 | 41 | 11 | 15 | 0.0002 | 1 |
| row11 | 298 | 24.1 | 41 | 9 | 11 | 0.0003 | 1 |
| row12 | 188 | 25.9 | 39 | 7 | 8 | 0.0003 | 1 |
| row13 | 193 | 36.8 | 43 | 8 | 7 | 0.0004 | 1 |
The dedicated churn run (five load/unload cycles of null-sink + loopback, the
same shape as run 1's measurement):
```json
{"kind":"metrics","graph_events":407,"tick_events":37,"emitted_records":407,
"span_us":9249639,"graph_events_per_sec":44.0,
"recompute_max_us":32,"recompute_mean_us":10,
"recompute_p50":"<50us","recompute_p90":"<50us","recompute_p99":"<50us",
"recompute_distribution":[["<50us",444]],
"emit_max_us":9,"emit_mean_us":1,
"busy_us":5240,"busy_fraction":0.0006,
"queued_events":292,"queue_threshold_us":100}
```
**O5 stays closed on the real graph.** Worst recompute across every run is
**67 µs**; every single recompute in the churn run finished under 50 µs, against
a 44 Hz event rate under churn heavier than a desktop produces at rest. The
observer thread spent **0.06 %** of wall time working. Node binding roughly
doubled the per-event cost (run 1: 15 µs max / 4 µs mean; now 32 µs / 10 µs on
the same churn shape) and that is the honest cost of the F1 fix — it buys three
orders of magnitude of remaining headroom, not one.
**Readiness with node binds: 12 ms**, with ~122 enumeration events and 18 binds
(14 Nodes + 4 Devices), against the 2000 ms budget. `queued_events` is high
(292) for the same benign reason as run 1: PipeWire delivers enumeration and
teardown in bursts, and a 32 µs recompute drains a burst faster than it forms.
`busy_fraction` is the number to trust.
⚠️ **The readiness budget still has no calibration argument.** 12 ms against
2000 ms is three orders of magnitude of slack on *this* host with 18 binds; it is
not an argument about a host with a large USB interface, many virtual devices, or
a cold cache. Carried forward as open, unchanged.
---
## Findings recorded, not blocking
### R2-1 — the audit's `sticky` flag is nearly always true, so it says little
As emitted, `sticky` means "this node is in the remembered set", which
`seed_sticky` populates for any node whose current reason the sticky pass agrees
with — i.e. essentially every currently-tainted node. It does **not** mean
"excluded *only* because remembered", which is what its doc comment implies and
what a reader diagnosing "why is this still excluded?" wants.
The information exists: round 9 already computes a second, **evidence-only** pass
(that is the whole provenance mechanism). Emitting "excluded by memory alone"
would make row 10 phase B assertable from a single record instead of from a
sequence. Not fixed here — it is a reporting change to a merged phase in the
middle of a gate run. Row 10 was asserted behaviourally instead, which is
stronger anyway.
### R2-2 — a bridge key is lost when a leg reappears under a new serial
Row 2 named `application.process.id` at derivation (seq 209), then gst re-created
that node; the sticky owner re-seeded the new serial through `reason_for`, whose
documented fallback is `TaintedOwnerBridge { key: None }`, and `raise` will not
replace a same-rank reason with a better-informed one. The verdict is unaffected;
only the diagnosis degrades. The fallback is honest when the owner has no live
tainted receiver, and stale when it does — which is the case worth improving.
### R2-3 — `owner_key` had to be added to the record to run row 1 at all
Row 1 asserts "reason = owner bridge, **naming the key**", and the record could
not express it: `Reason::code` collapses `TaintedOwnerBridge { key }` to one
string. `OwnerKey::code` already documented itself as ending up in the phase 5
audit output; it was simply never wired to it. Added in pixelpass `d462754`
(read-only, diagnostic-only, mutation-verified test). Worth noting as a gate-spec
lesson: the row could not have been asserted from any previous build's output.
---
## Substitutions, stated so they are not mistaken for passes
| row | asked for | used instead | why |
| --- | --- | --- | --- |
| 8 | EasyEffects | `module-echo-cancel` with `AEC=off` | EasyEffects makes itself the default sink on start and the user had live audio playing. `module-filter-chain` cannot stand in either — it is a PipeWire module, so `pactl load-module` answers "No such entity" (measured). The stand-in produces the same shape (four nodes, one `node.link-group`) and exercises `foreign-echo-cancel` (decision D3), a reason code no other row reaches. |
| 9 | Firefox's mic + monitor capture | `gst-launch` pipelines | Firefox's mic and monitor-capture paths need interactive GUI permission grants. Firefox is present live as case 1 in every row. Case 2 captures the motherboard's **analog input**, not the headset mic the user is wearing — identical to the engine (both `session_device` sources), and nothing of the user is recorded. |
| 13 | a real `Audio/Duplex` device | synthetic `media.class` override | None on this host. See row 13 above for what the fixture cannot show. |
---
## What still blocks phase 6
This file passing removes **one** of the two gates. F11-1, the other, is now
closed. Still outstanding:
1. **Hardware playback-to-capture paths ("Stereo Mix")** defeat `session_device`
and are a real echo path — needs ALSA control inspection; user design call owed.
2. **Phases 0b / 0c / 0d** are untouched and all precede phase 6.
3. **The readiness budget calibration argument** (above).
4. **Owed samples:** a real third-party forwarder (row 1b), EasyEffects (row 8),
a real `Audio/Duplex` device (row 13).
### ✅ F11-1 — closed 2026-07-26, with this matrix's data
The rule now implemented (pixelpass `c78eb2d`, §6.1.2's round-13 box): **key 4 bounds an
owner only when the node's Client resolves** — an unambiguous Client yielding
`Some(pipewire.sec.pid)`, read *before* pipewire-pulse suppression — so a node can no
longer bound itself, and escape `propagate_unresolved_owner`'s sweep, with an
`application.process.id` it invented. Bridging still uses the full union.
Codex's round-12 sharpening was the decisive part: "resolved" must mean a `sec_pid`, not
"a unique Client object exists", and the **unique-but-pid-less** row is the only one that
tells the two apart. All five Client cases are unit tests (absent · ambiguous ·
unique-but-pid-less · resolved-native · resolved-to-pipewire-pulse), plus the recorded
three-step leak path end to end. Mutation-verified: dropping the provenance test fails
four of the six rows and leaves the two no-over-exclusion rows green.
**The cost question the deferral was waiting on, measured on this host:** the before- and
after-binaries audited the *same* live graph simultaneously (both are read-only observers)
— tagged producer into the default sink, `parec` on its monitor as a live tainted reader
so the sweep was genuinely armed, Firefox + `aplay` + `pacat` as bystanders. **181 records
each, the same 14 distinct decision states, none exclusive to either side, no
`unresolved-owner` on either, eligible half non-empty throughout.** O5 unmoved (identical
p50 15 µs and busy fraction 0.0012). Every real app here is native or Pulse-emulated and
**both resolve**; sweeping all 18 live nodes, the only unresolved-Client ones were
`Dummy-Driver` and `Freewheel-Driver`, which carry no pid key to lose.
---
## Reproducing this run
Scripts live in the session scratchpad (not committed — they hard-code paths):
one per row, plus `lib.sh`, `summarize.py` and `keys.py`. The shape of every row:
```sh
audit_start out.jsonl off # start FIRST, wait for graph_ready
... build fixture ... # taint arrives as topology CHANGES
audit_stop # SIGTERM: flushes the O5 summary
python3 summarize.py out.jsonl # final partition + derivations + metrics
```
```
env PIXELPASS_AUDIO_AUDIT_FILE=/path/out.jsonl PIXELPASS_AUDIO_AUDIT_AEC=off \
./target/release/pixelpass --audit-audio
```
Rig notes that cost time:
- A tagged producer: `env PIPEWIRE_ALSA='{ "peerspeak.owned": "1", "node.name":
"peerspeak_owned_call_4242", "target.object": "<sink>" }' aplay -c 2 -r 48000
-f S16_LE -t raw -d 30 /dev/zero`. Both carriers land, and `target.object`
routes it.
- ⚠️ `pactl load-module module-echo-cancel --help` **loads the module** with
`--help` as its argument instead of printing help. It was loaded accidentally
during this session and unloaded again; check `pactl list short modules` after
any such probe.
- ⚠️ `pkill -f <pattern>` matches the harness's own shell command line and kills
the script. Use `pkill -x` or an exact pid.
- ⚠️ Under `set -e`, `kill` on an already-exited pid aborts the row before its
modules are unloaded; and `timeout` exiting 124 is *success* for the audit.
---
## History — run 1 (2026-07-25): GATE FAILED
Kept because the reasoning is still the record of why the observation boundary
was redesigned.
### F1 🔴 FATAL — the registry `global` event delivers only a filtered subset of node properties
The phase-3 adapter read eight node properties the registry never announces.
Parsed off `obj.props` in the registry `global` callback, they were silently
absent, so every one was permanently `None`/`false`.
The complete set the registry announces for a `Node` on this host:
```
application.name client.api client.id device.id factory.id media.class
node.description node.name node.nick object.path object.serial
priority.driver priority.session
```
| property | announced? | what died without it |
| --- | --- | --- |
| `object.serial`, `node.name`, `media.class`, `client.id`, `device.id` | ✅ | — |
| **`peerspeak.owned`** | ❌ | **the primary taint root (all of phase 1)** |
| **`pulse.module.id`** | ❌ | **AEC identity exclusion + phase 4 validation** |
| **`node.link-group`** | ❌ | the link-group owner key |
| **`application.process.id`** | ❌ | the process owner key |
| **`node.passthrough`** | ❌ | the passthrough local exclusion |
| **`device.api`**, **`factory.name`**, **`alsa.driver_name`** | ❌ | `session_device` classification |
Ports lost `port.exclusive`; Links and Clients were fine — notably
`pipewire.sec.pid` **is** announced, so pulse-PID derivation was reachable.
Demonstrated end to end: a null sink carrying `peerspeak.owned=true` whose
monitor a `module-loopback` re-emitted was reported **eligible** with an **empty
taint set**. In phase 6 that is an echo.
The fix became design round 8 (v3.5 §6.7) and phase 3r: bind each Node and read
props off its `info`, exactly how `pw-dump` obtains them. `factory.id` is not a
shortcut (`factory.id=19` resolves to `factory.name = "adapter"`), and
`device.api` is on the *Device* global.
### F2 🟠 Machine-wide over-exclusion cascade, downstream of F1
With F1 in force, `pixelpass_capture_*` (matched on `node.name`, which *is*
announced) was the only surviving taint root. Row 7 then excluded every
`Stream/Output/Audio` on the machine: with no strong owner keys, every tainted
capture stream was an **unbounded tainted reader**, tripping phase 2's
fail-closed backstop, while WirePlumber's shared `client.id = 42` fused the
device layer into one owner.
Net live behaviour: exclude everything, always, as soon as pixelpass's own
capture sink existed. Fail-closed, so silence rather than echo — but entirely
non-functional, and non-functional in a way that would have looked like "working
safely" to any test that asserted only exclusions.
### What run 1's machinery got right
None of this needed revisiting:
- Running the recompute **inline on the observer thread**, once per applied
registry event, upheld phase 4's no-coalescing contract and put the cost where
O5 could measure it.
- The **complete-partition record** is what caught F2 — and, in run 2, F13-1.
- **Reason codes survived the trip** and were immediately diagnostic.
- The **`peerspeak.owned` / `pulse.module.id` fixtures were right**: the engine
does the correct thing when handed correct properties. Both failures were at
the observation boundary, which is where phase 5 was designed to look.
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com> # Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
pkgname=peerspeak-git pkgname=peerspeak-git
_pkgname=peerspeak _pkgname=peerspeak
pkgver=0.5.0.r0.g0000000 pkgver=0.6.2.r319.g8014edf
pkgrel=1 pkgrel=1
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)" pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
arch=('x86_64') arch=('x86_64')
+4
View File
@@ -0,0 +1,4 @@
.tools/
AppDir/
*.AppImage
squashfs-root/
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# AppRun for the PeerSpeak AppImage.
#
# PeerSpeak bundles the pixelpass screen-share helper in usr/bin. We prepend our
# own usr/bin to PATH so peerspeak's $PATH lookup for `pixelpass` finds the
# bundled copy, while the host's tools (gst-launch-1.0, pactl, mpv — which
# pixelpass in turn shells out to) remain reachable via the appended host PATH.
# That no-sandbox spawning is exactly why this app suits AppImage over Flatpak.
HERE="$(dirname "$(readlink -f "$0")")"
export PATH="$HERE/usr/bin:$PATH"
exec "$HERE/usr/bin/peerspeak" "$@"
+76
View File
@@ -0,0 +1,76 @@
# PeerSpeak AppImage
A "thin" AppImage: the `peerspeak` binary, the bundled `pixelpass` screen-share
helper, a launcher (`AppRun`), and the desktop entry + icon. Run
`./build-appimage.sh` to produce `peerspeak-<version>-x86_64.AppImage`.
## Why thin, and why pixelpass is bundled
PeerSpeak owns voice; **pixelpass** owns pixels. They are never Cargo
dependencies of each other — peerspeak shells out to the `pixelpass` binary over
its CLI. The AppImage co-locates `pixelpass` in `usr/bin`, and `AppRun` prepends
`usr/bin` to `PATH`, so peerspeak's normal `$PATH` lookup finds it with no code
change. Joe gets one file, and screen-share works out of the box.
Almost nothing is bundled: peerspeak's own assets (notification WAVs, avatar
presets, window icon, fonts) are `include_bytes!`-embedded, and the graphics
stack (`libGL`, `libvulkan`, `libwayland-*`, `libxkbcommon`, X11) is dlopen'd at
runtime and on the AppImage excludelist because it must match the host driver.
So the image carries just the two binaries plus a handful of small libs.
## Host requirements
The AppImage runs on any reasonably current glibc-based distro that has:
- **A Vulkan-capable GPU + driver** (peerspeak's iced/wgpu renderer). Mesa/RADV
on AMD/Intel or the NVIDIA driver all work.
- **PipeWire** (with the PulseAudio shim, for `pactl`).
- For **screen-share only** — pixelpass shells out to these on the host `PATH`;
it prints the exact package names for your distro if any are missing:
- **GStreamer + plugins** (`gst-launch-1.0`/`gst-inspect-1.0`, base,
good/bad/ugly, libav, and the PipeWire plugin),
- **mpv** (or vlc) for the viewer side,
- on X11, `xwininfo` for single-window capture.
On Arch/Artix that is one pacman line, e.g.:
```sh
sudo pacman -S gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad \
gst-plugins-ugly gst-libav gst-plugin-pipewire mpv xorg-xwininfo libpulse
```
(Add `gstreamer-vaapi` for hardware H.264 encode on AMD/Intel; the software
x264 path always works. On XLibre / X11 the capture path uses `ximagesrc` and
needs no XDG portal — no systemd required.)
## Building for broad compatibility (glibc baseline)
An AppImage requires a host glibc **at least as new** as the build host's. Built
on a rolling distro (glibc 2.43) it only runs on equally-new systems. Build
inside **Ubuntu 24.04** (glibc 2.39, PipeWire 1.0.5) for wide reach — pixelpass's
`pipewire` crate binds the system PipeWire headers and needs PipeWire >= 1.0, so
the older Debian 12 `peerspeak-bookworm` box (PW 0.3.65) cannot build it. 2.39
covers Debian 13+, Fedora 40+, and current rolling distros.
```sh
# One-time: an Ubuntu 24.04 distrobox that reuses the host rustup toolchain.
distrobox create --yes --image ubuntu:24.04 --name peerspeak-appimage
distrobox enter peerspeak-appimage -- sudo apt-get update
distrobox enter peerspeak-appimage -- sudo apt-get install -y \
build-essential cmake clang libclang-dev pkg-config \
libpipewire-0.3-dev libspa-0.2-dev libasound2-dev libxcb1-dev \
curl ca-certificates file patchelf git
# Build (the host's ~/.rustup toolchain is glibc-2.17-baseline, so it runs in the
# box; isolated CARGO_TARGET_DIRs keep it off the host target/):
distrobox enter peerspeak-appimage -- env \
PATH="$HOME/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin:$PATH" \
./packaging/appimage/build-appimage.sh
```
## Caveats
- **Hardware encode (VAAPI)** uses the host GPU driver and can't be bundled; the
software x264 path always works.
- The bundled `pixelpass` is built headless (no `gui` feature) — it is only ever
driven by peerspeak, never launched standalone from this image.
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Build a "thin" PeerSpeak AppImage that also bundles the pixelpass screen-share
# helper.
#
# PeerSpeak is an iced/wgpu GUI app; pixelpass is the separate screen-share
# orchestrator peerspeak shells out to (never a Cargo dependency). Both link
# almost nothing — the graphics stack (libGL, libvulkan, wayland, xkbcommon,
# X11) is dlopen'd at runtime and is on the AppImage excludelist because it must
# match the host driver, and pixelpass's capture/encode tools (gst-launch-1.0,
# pactl, mpv) are expected on the host PATH. So the AppImage carries just the two
# binaries plus their handful of non-excludelisted libs. The custom AppRun
# prepends usr/bin to PATH so peerspeak's own $PATH lookup finds the bundled
# pixelpass, while the host's tools stay reachable.
#
# All runtime assets (notification WAVs, avatar presets, window icon, fonts) are
# include_bytes!-embedded in the peerspeak binary, so nothing else is bundled.
#
# Usage: packaging/appimage/build-appimage.sh
# Output: packaging/appimage/peerspeak-<version>-x86_64.AppImage
#
# Build inside an Ubuntu 24.04 distrobox (glibc 2.39, PipeWire 1.0.5) for broad
# reach — pixelpass's `pipewire` crate needs PipeWire >= 1.0 headers, so the
# older peerspeak-bookworm box (PW 0.3.65) cannot build it. The 2.39 baseline
# covers Debian 13+, Fedora 40+, and all current rolling distros. See README.md.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "$here/../.." && pwd)"
tools="$here/.tools"
appdir="$here/AppDir"
mkdir -p "$tools"
# linuxdeploy is itself an AppImage; run it without FUSE so this works in a
# container / on CI without libfuse2.
export APPIMAGE_EXTRACT_AND_RUN=1
VERSION="$(grep -m1 '^version' "$repo/Cargo.toml" | sed -E 's/.*"(.*)".*/\1/')"
export VERSION
# Isolated target dirs so an old-glibc box build never clobbers the host target/.
cache="${PEERSPEAK_APPIMAGE_CACHE:-$HOME/.cache/peerspeak-appimage}"
ps_target="$cache/peerspeak-target"
pp_target="$cache/pixelpass-target"
# The pixelpass screen-share helper we bundle. Sibling checkout by default.
pixelpass_repo="${PIXELPASS_REPO:-$repo/../pixelpass}"
if [ ! -d "$pixelpass_repo" ]; then
echo "!! pixelpass repo not found at $pixelpass_repo (set PIXELPASS_REPO)" >&2
exit 1
fi
echo ">> building peerspeak (release)"
( cd "$repo" && CARGO_TARGET_DIR="$ps_target" cargo build --release )
ps_bin="$ps_target/release/peerspeak"
# Headless pixelpass: peerspeak drives it via `--host`/viewer + `--output json`,
# never its GUI, so the default (no `gui` feature) keeps the GL toolkit out.
echo ">> building pixelpass (release, headless) from $pixelpass_repo"
( cd "$pixelpass_repo" && CARGO_TARGET_DIR="$pp_target" cargo build --release )
pp_bin="$pp_target/release/pixelpass"
echo ">> fetching linuxdeploy"
ld="$tools/linuxdeploy-x86_64.AppImage"
if [ ! -x "$ld" ]; then
curl -fL --retry 3 -o "$ld" \
"https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
chmod +x "$ld"
fi
echo ">> assembling AppDir"
rm -rf "$appdir"
mkdir -p "$appdir/usr/bin"
install -m755 "$ps_bin" "$appdir/usr/bin/peerspeak"
install -m755 "$pp_bin" "$appdir/usr/bin/pixelpass"
echo ">> running linuxdeploy (bundles libs, builds the AppImage)"
# -e (repeated): analyse both binaries for libraries to bundle; excludelisted
# graphics/glibc libs are skipped. -d/-i: desktop entry + icon.
# --custom-apprun: our launcher that puts the bundled pixelpass on PATH.
( cd "$here" && OUTPUT="peerspeak-${VERSION}-x86_64.AppImage" "$ld" \
--appdir "$appdir" \
-e "$appdir/usr/bin/peerspeak" \
-e "$appdir/usr/bin/pixelpass" \
-d "$repo/packaging/peerspeak.desktop" \
-i "$repo/assets/icons/peerspeak-256.png" \
--icon-filename peerspeak \
--custom-apprun "$here/AppRun" \
--output appimage )
echo ">> done: $here/peerspeak-${VERSION}-x86_64.AppImage"
+87
View File
@@ -0,0 +1,87 @@
# Debian / Ubuntu `.deb` build
This documents how the `peerspeak_*.deb` is produced, so the deb path is as
self-documenting as the Arch (`packaging/PKGBUILD`) and AppImage paths.
The deb **recipe itself** lives in-repo as the `[package.metadata.deb]` block in
the top-level `Cargo.toml` (cargo-deb's equivalent of a PKGBUILD). This file
documents only the **build environment**, which is otherwise undiscoverable from
a fresh clone.
## TL;DR
```sh
# one-time: create + provision the build box (see "Build environment" below)
distrobox enter peerspeak-bookworm -- bash -lc '
source ~/.cargo/env
cd ~/git/butter/peerspeak
export CARGO_TARGET_DIR=~/.cache/cargo-deb-targets/peerspeak # MANDATORY, see below
cargo deb
'
# output: $CARGO_TARGET_DIR/debian/peerspeak_<version>-1_amd64.deb
```
## Build environment
- **Base: a Debian 12 (bookworm) distrobox named `peerspeak-bookworm`.**
Created with `distrobox create --name peerspeak-bookworm --image debian:12`.
Bookworm ships **glibc 2.36**, which sets the widest practical compatibility
floor (see "glibc floor" below).
- **NEVER build the `.deb` on the Arch host.** Two independent reasons:
1. The Arch host's glibc is far newer, so the resulting `.deb` would demand a
glibc no normal Debian/Ubuntu user has, and ships an empty `Depends`.
2. distrobox shares `$HOME` (and therefore the repo's `target/`) with the host,
so a host build links Arch-compiled C objects into the "Debian" binary.
### One-time provisioning inside the box
```sh
distrobox enter peerspeak-bookworm
sudo apt update
sudo apt install -y build-essential pkg-config clang libclang-dev \
libpipewire-0.3-dev libopus-dev libasound2-dev libxcb1-dev
# clang/libclang -> pipewire-sys bindgen ; libxcb1-dev -> link
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source ~/.cargo/env
cargo install cargo-deb
```
### The mandatory separate `CARGO_TARGET_DIR`
Because distrobox shares `$HOME`, the repo's default `target/` is the **same
directory** the Arch host builds into. If you run `cargo deb` without overriding
the target dir, cargo will happily reuse Arch-built `.o`/rlib artifacts and link
them into the Debian binary, producing a `.deb` that crashes or demands the
host's glibc.
Always point the build at a box-local cache:
```sh
export CARGO_TARGET_DIR=~/.cache/cargo-deb-targets/peerspeak
```
(Run `cargo clean` first if you ever suspect a polluted target dir.)
## glibc floor
The `.deb` is built against the build box's glibc, which becomes the install
floor (`libc6 (>= 2.36)` lands in `Depends` via `$auto`):
| Build box | glibc | Runs on |
|----------------------|-------|--------------------------------------|
| `debian:12` (current)| 2.36 | Debian 12+, Ubuntu 24.04+ (glibc ≥ 2.36) |
| `ubuntu:26.04` (old) | 2.43 | Ubuntu 26.04+ only — too narrow, abandoned |
If a friend is on something even older than Debian 12, drop the floor further by
recreating the box from an older base image and rebuilding.
## Runtime `Depends` / `Recommends`
- `Depends = "$auto"` — cargo-deb runs `dpkg-shlibdeps`, which discovers the
linked shared libraries (PipeWire, Opus, ALSA, xcb, glibc, …) automatically.
- `Recommends = "pixelpass, mpv"``pixelpass` provides in-room screen sharing
and `mpv` is the screen-share viewer (these are companion programs invoked as
subprocesses, not linked libraries, so they are Recommends not Depends).
See `pixelpass`'s own `packaging/debian/README.md` for why **its** `Depends`
lists the whole GStreamer stack explicitly.
+1 -1
View File
@@ -8,7 +8,7 @@ it once, then you and I connect directly to each other.
## 1. Install it ## 1. Install it
1. Double-click **`peerspeak-0.4.0-setup.exe`** (the file I sent you). 1. Double-click **`peerspeak-<version>-setup.exe`** (the file I sent you).
2. **Windows will probably show a blue "Windows protected your PC" warning.** 2. **Windows will probably show a blue "Windows protected your PC" warning.**
This is normal — it shows up for any app that isn't from a big company with a This is normal — it shows up for any app that isn't from a big company with a
+3 -2
View File
@@ -11,8 +11,9 @@ runtime, so there are no extra DLLs to bundle. The installer payload is just the
## Version compatibility ## Version compatibility
The installer version tracks the crate version in `Cargo.toml` (currently The installer version tracks the release version in `Cargo.toml` — keep
**0.4.0**) — keep `MyAppVersion` in `peerspeak.iss` in sync when it changes. `MyAppVersion` in `peerspeak.iss` in sync when cutting a release. Do not reuse an
old installer filename after a crate-version bump.
Per `VERSIONING.md`, a **MINOR** bump in `0.x` is a **breaking wire change**: Per `VERSIONING.md`, a **MINOR** bump in `0.x` is a **breaking wire change**:
peers on different MINOR versions can't connect (they fail fast at the peers on different MINOR versions can't connect (they fail fast at the
+1 -1
View File
@@ -12,7 +12,7 @@
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed). ; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
#define MyAppName "PeerSpeak" #define MyAppName "PeerSpeak"
#define MyAppVersion "0.6.0" #define MyAppVersion "0.6.6"
#define MyAppPublisher "mollusk" #define MyAppPublisher "mollusk"
#define MyAppExeName "peerspeak.exe" #define MyAppExeName "peerspeak.exe"
+3703 -1093
View File
File diff suppressed because it is too large Load Diff
+129
View File
@@ -0,0 +1,129 @@
//! Sender-side chat send status and pacing (chat-hardening Phase 5).
//!
//! Every RECEIVER admits our chat through a per-author token bucket
//! ([`CHAT_AUTHOR_BURST`] then 1/s) and silently drops what exceeds it, with no
//! acknowledgement wire. The only way the sender can be honest about fast
//! bursts is to never exceed that budget in the first place: sends past the
//! burst are queued locally (shown as "queued…") and trickled out at the
//! receivers' sustained rate. The pacer deliberately reuses the receiver
//! gate's own [`TokenBucket`] and constants so the two sides of the policy
//! cannot drift apart.
//!
//! Everything here is pure — `now_ms` is passed in, never read from a clock —
//! so every boundary is unit-testable.
use std::collections::VecDeque;
use crate::network::gossip::{CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, TokenBucket};
/// Send lifecycle of one locally authored chat message. Success is
/// [`SendStatus::Broadcast`] — "our signed frame was handed to the gossip
/// swarm" — deliberately NOT "delivered": PeerSpeak has no peer
/// acknowledgements, so the honest success presentation is no label at all.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SendStatus {
/// Waiting in the local outbound queue for a pacer token.
Queued,
/// Handed to the core; the broadcast result has not come back yet.
Pending,
/// The signed broadcast reached the gossip swarm.
Broadcast,
/// The send failed; carries a short reason. The entry offers a Retry.
Failed(String),
}
/// Local-only send bookkeeping attached to our own chat entries. The id never
/// goes on the wire; it ties a `ChatSendResult` back to the matching echo.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalSend {
pub id: u64,
pub status: SendStatus,
}
/// Sender-side pacer mirroring the receiver's per-author admission budget.
#[derive(Debug, Clone, Copy)]
pub struct SendPacer {
bucket: TokenBucket,
}
impl SendPacer {
pub fn new(now_ms: u64) -> Self {
Self {
bucket: TokenBucket::full(CHAT_AUTHOR_BURST, now_ms),
}
}
/// Take one send token if the mirrored per-author budget allows it now.
pub fn try_send(&mut self, now_ms: u64) -> bool {
self.bucket
.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, now_ms)
}
}
/// Pop the queued ids that may be dispatched now: strict front-of-queue order,
/// one pacer token each, stopping at the first refusal so a message can never
/// overtake an earlier one.
pub fn release_ready(queue: &mut VecDeque<u64>, pacer: &mut SendPacer, now_ms: u64) -> Vec<u64> {
let mut ready = Vec::new();
while !queue.is_empty() && pacer.try_send(now_ms) {
// The unwrap is safe: the loop condition just checked non-empty.
ready.push(queue.pop_front().unwrap());
}
ready
}
#[cfg(test)]
mod tests {
use super::*;
const T0: u64 = 1_000_000;
#[test]
fn pacer_allows_the_full_burst_then_refuses() {
let mut pacer = SendPacer::new(T0);
for _ in 0..CHAT_AUTHOR_BURST as usize {
assert!(pacer.try_send(T0));
}
assert!(!pacer.try_send(T0));
}
#[test]
fn pacer_refills_at_one_per_second() {
let mut pacer = SendPacer::new(T0);
for _ in 0..CHAT_AUTHOR_BURST as usize {
assert!(pacer.try_send(T0));
}
// 999ms is just under one token; 1000ms grants exactly one.
assert!(!pacer.try_send(T0 + 999));
assert!(pacer.try_send(T0 + 1000));
assert!(!pacer.try_send(T0 + 1000));
}
#[test]
fn release_ready_preserves_order_and_stops_at_refusal() {
let mut pacer = SendPacer::new(T0);
// Drain the burst so only refill tokens remain.
for _ in 0..CHAT_AUTHOR_BURST as usize {
assert!(pacer.try_send(T0));
}
let mut queue: VecDeque<u64> = [10, 11, 12].into_iter().collect();
// 2 seconds of refill = 2 tokens: exactly the first two, in order.
let ready = release_ready(&mut queue, &mut pacer, T0 + 2000);
assert_eq!(ready, vec![10, 11]);
assert_eq!(queue, VecDeque::from([12]));
// No tokens left at the same instant.
assert!(release_ready(&mut queue, &mut pacer, T0 + 2000).is_empty());
assert_eq!(queue, VecDeque::from([12]));
}
#[test]
fn release_ready_empty_queue_consumes_no_tokens() {
let mut pacer = SendPacer::new(T0);
let mut queue = VecDeque::new();
assert!(release_ready(&mut queue, &mut pacer, T0).is_empty());
// The full burst must still be available.
for _ in 0..CHAT_AUTHOR_BURST as usize {
assert!(pacer.try_send(T0));
}
}
}
+50
View File
@@ -328,4 +328,54 @@ mod tests {
assert_eq!(seek_target(-1.0, total), Duration::ZERO); assert_eq!(seek_target(-1.0, total), Duration::ZERO);
assert_eq!(seek_target(2.0, total), total); assert_eq!(seek_target(2.0, total), total);
} }
/// **The fourth playback path's exit gate (round 10, R10-2).** Drives a
/// real [`ClipPlayer`] — the same object the app uses for chat clips, peer
/// music and the local playlist — and asserts the node it puts on the
/// graph carries both ownership carriers.
///
/// This path was untagged through all of phase 1, which is a real echo:
/// B broadcasts music, A tunes in, A shares their desktop, B hears their
/// own track. It was missed because phase 1 worked from the impl plan's
/// list of three playback sites and that list was incomplete — so this
/// gate drives the *player*, not the tagging helper.
///
/// ⚠️ **Run alone**: it sets a process-wide environment variable, which is
/// only sound single-threaded. In production `main` does this before
/// anything is spawned; a test binary has no such guarantee, hence
/// `--test-threads=1`.
///
/// `cargo test --lib -- --ignored --test-threads=1 clip_player_node`
#[test]
#[ignore = "live: requires a running PipeWire daemon and pw-dump; run with --test-threads=1"]
fn clip_player_node_carries_both_ownership_carriers() {
use crate::audio::ownership::{self, live_test};
// SAFETY: `--test-threads=1` is documented above and in the ignore
// reason; this is the same call `main` makes, exercised for real
// rather than reimplemented, so the gate cannot pass against a
// formatter that production never uses.
unsafe { ownership::tag_this_process_alsa_audio() };
let (player, _status) = ClipPlayer::new(1.0);
// Six seconds of silence: long enough for the poll, inaudible.
player.play([0u8; 32], live_test::silent_wav(6));
let prefix = live_test::expected_prefix(ownership::CLIP_ROLE);
let found = live_test::poll_for_owned_node(&prefix, Duration::from_secs(5));
player.stop();
let (name, owned) = found.unwrap_or_else(|| {
panic!("no live clip-player node named {prefix:?} appeared within 5s")
});
assert!(
name.starts_with(ownership::OWNED_NODE_NAME_PREFIX),
"{name}"
);
assert_eq!(
owned.as_deref(),
Some(ownership::OWNED_PROP_VALUE),
"carrier 1 must be on the live node, not just carrier 2"
);
}
} }
+53 -12
View File
@@ -578,7 +578,9 @@ fn choose_config(device: &Device, output: bool) -> Result<cpal::SupportedStreamC
let pick = |channels: Option<u16>| { let pick = |channels: Option<u16>| {
ranges ranges
.iter() .iter()
.find(|r| usable_range(r) && supports_48k(r) && channels.is_none_or(|c| r.channels() == c)) .find(|r| {
usable_range(r) && supports_48k(r) && channels.is_none_or(|c| r.channels() == c)
})
.cloned() .cloned()
}; };
@@ -666,15 +668,30 @@ fn run_capture(
let device_rate = config.sample_rate.0; let device_rate = config.sample_rate.0;
let stream = match sample_format { let stream = match sample_format {
SampleFormat::F32 => build_input::<f32, _>( SampleFormat::F32 => build_input::<f32, _>(
&device, &config, producer, channels, overrun.clone(), callbacks.clone(), &device,
&config,
producer,
channels,
overrun.clone(),
callbacks.clone(),
err_code.clone(), err_code.clone(),
), ),
SampleFormat::I16 => build_input::<i16, _>( SampleFormat::I16 => build_input::<i16, _>(
&device, &config, producer, channels, overrun.clone(), callbacks.clone(), &device,
&config,
producer,
channels,
overrun.clone(),
callbacks.clone(),
err_code.clone(), err_code.clone(),
), ),
SampleFormat::U16 => build_input::<u16, _>( SampleFormat::U16 => build_input::<u16, _>(
&device, &config, producer, channels, overrun.clone(), callbacks.clone(), &device,
&config,
producer,
channels,
overrun.clone(),
callbacks.clone(),
err_code.clone(), err_code.clone(),
), ),
other => Err(AudioError::Stream(format!( other => Err(AudioError::Stream(format!(
@@ -764,7 +781,10 @@ fn run_capture(
// Surface a stream error the RT callback flagged (it can't log itself). // Surface a stream error the RT callback flagged (it can't log itself).
let ec = err_code.load(Ordering::Relaxed); let ec = err_code.load(Ordering::Relaxed);
if ec != STREAM_ERR_NONE && ec != last_err { if ec != STREAM_ERR_NONE && ec != last_err {
crate::log_msg(&format!("cpal capture stream error: {}", stream_err_text(ec))); crate::log_msg(&format!(
"cpal capture stream error: {}",
stream_err_text(ec)
));
last_err = ec; last_err = ec;
} }
if !drained { if !drained {
@@ -914,16 +934,34 @@ fn run_playback(
let device_rate = config.sample_rate.0; let device_rate = config.sample_rate.0;
let stream = match sample_format { let stream = match sample_format {
SampleFormat::F32 => build_output::<f32, _>( SampleFormat::F32 => build_output::<f32, _>(
&device, &config, consumer, ring_fill.clone(), underrun.clone(), &device,
max_cb.clone(), callbacks.clone(), err_code.clone(), &config,
consumer,
ring_fill.clone(),
underrun.clone(),
max_cb.clone(),
callbacks.clone(),
err_code.clone(),
), ),
SampleFormat::I16 => build_output::<i16, _>( SampleFormat::I16 => build_output::<i16, _>(
&device, &config, consumer, ring_fill.clone(), underrun.clone(), &device,
max_cb.clone(), callbacks.clone(), err_code.clone(), &config,
consumer,
ring_fill.clone(),
underrun.clone(),
max_cb.clone(),
callbacks.clone(),
err_code.clone(),
), ),
SampleFormat::U16 => build_output::<u16, _>( SampleFormat::U16 => build_output::<u16, _>(
&device, &config, consumer, ring_fill.clone(), underrun.clone(), &device,
max_cb.clone(), callbacks.clone(), err_code.clone(), &config,
consumer,
ring_fill.clone(),
underrun.clone(),
max_cb.clone(),
callbacks.clone(),
err_code.clone(),
), ),
other => Err(AudioError::Stream(format!( other => Err(AudioError::Stream(format!(
"unsupported playback sample format: {other:?}" "unsupported playback sample format: {other:?}"
@@ -1199,7 +1237,10 @@ fn spawn_health_logger(
// Surface a stream error the RT callback flagged (it can't log itself). // Surface a stream error the RT callback flagged (it can't log itself).
let ec = err_code.load(Ordering::Relaxed); let ec = err_code.load(Ordering::Relaxed);
if ec != STREAM_ERR_NONE && ec != last_err { if ec != STREAM_ERR_NONE && ec != last_err {
crate::log_msg(&format!("cpal playback stream error: {}", stream_err_text(ec))); crate::log_msg(&format!(
"cpal playback stream error: {}",
stream_err_text(ec)
));
last_err = ec; last_err = ec;
} }
// Report the device's per-cycle demand (in internal 48 kHz-stereo // Report the device's per-cycle demand (in internal 48 kHz-stereo
+49 -11
View File
@@ -54,7 +54,10 @@ impl Drop for EchoCancelGuard {
.arg("unload-module") .arg("unload-module")
.arg(&self.module_index) .arg(&self.module_index)
.output(); .output();
crate::log_msg(&format!("Echo cancel: unloaded module {}", self.module_index)); crate::log_msg(&format!(
"Echo cancel: unloaded module {}",
self.module_index
));
} }
} }
@@ -65,7 +68,10 @@ impl Drop for EchoCancelGuard {
/// `None` (or an empty string) to bind to the system defaults. Returns `Err` with /// `None` (or an empty string) to bind to the system defaults. Returns `Err` with
/// a human-readable reason if `pactl` is missing, the load fails, or the nodes /// a human-readable reason if `pactl` is missing, the load fails, or the nodes
/// don't appear — the caller should fall back to the direct devices. /// don't appear — the caller should fall back to the direct devices.
pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result<EchoCancelGuard, String> { pub fn enable(
real_source: Option<&str>,
real_sink: Option<&str>,
) -> Result<EchoCancelGuard, String> {
// Best-effort: clear any stale instance left by a crashed prior run so we // Best-effort: clear any stale instance left by a crashed prior run so we
// don't stack duplicate modules / fight over the virtual node names. // don't stack duplicate modules / fight over the virtual node names.
unload_stale(); unload_stale();
@@ -101,7 +107,11 @@ pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result<Echo
if module_index.parse::<u64>().is_err() { if module_index.parse::<u64>().is_err() {
return Err(format!("unexpected pactl output: {module_index:?}")); return Err(format!("unexpected pactl output: {module_index:?}"));
} }
let guard = EchoCancelGuard { module_index, source_name, sink_name }; let guard = EchoCancelGuard {
module_index,
source_name,
sink_name,
};
// The virtual nodes appear shortly after the module loads; wait for both so // The virtual nodes appear shortly after the module loads; wait for both so
// the subsequent capture/playback streams can actually target them. If they // the subsequent capture/playback streams can actually target them. If they
@@ -134,7 +144,12 @@ fn wait_for_nodes(source_name: &str, sink_name: &str) -> bool {
/// Whether `pactl list <kind> short` lists a node named `name`. /// Whether `pactl list <kind> short` lists a node named `name`.
/// `kind` is "sources" or "sinks". /// `kind` is "sources" or "sinks".
fn node_present(kind: &str, name: &str) -> bool { fn node_present(kind: &str, name: &str) -> bool {
let Ok(out) = Command::new("pactl").arg("list").arg(kind).arg("short").output() else { let Ok(out) = Command::new("pactl")
.arg("list")
.arg(kind)
.arg("short")
.output()
else {
return false; return false;
}; };
String::from_utf8_lossy(&out.stdout) String::from_utf8_lossy(&out.stdout)
@@ -167,7 +182,12 @@ fn process_is_alive(_pid: u32) -> bool {
/// Unloads leftover PeerSpeak `module-echo-cancel` instances only when their /// Unloads leftover PeerSpeak `module-echo-cancel` instances only when their
/// owning process is gone. Best-effort and conservative on non-Linux platforms. /// owning process is gone. Best-effort and conservative on non-Linux platforms.
fn unload_stale() { fn unload_stale() {
let Ok(out) = Command::new("pactl").arg("list").arg("modules").arg("short").output() else { let Ok(out) = Command::new("pactl")
.arg("list")
.arg("modules")
.arg("short")
.output()
else {
return; return;
}; };
for line in String::from_utf8_lossy(&out.stdout).lines() { for line in String::from_utf8_lossy(&out.stdout).lines() {
@@ -179,7 +199,10 @@ fn unload_stale() {
&& ec_module_is_stale(args, process_is_alive) && ec_module_is_stale(args, process_is_alive)
&& index.parse::<u64>().is_ok() && index.parse::<u64>().is_ok()
{ {
let _ = Command::new("pactl").arg("unload-module").arg(index).output(); let _ = Command::new("pactl")
.arg("unload-module")
.arg(index)
.output();
crate::log_msg(&format!("Echo cancel: cleaned up stale module {index}")); crate::log_msg(&format!("Echo cancel: cleaned up stale module {index}"));
} }
} }
@@ -198,13 +221,25 @@ mod tests {
let guard = enable(None, None).expect("module-echo-cancel should load"); let guard = enable(None, None).expect("module-echo-cancel should load");
let source_name = guard.source_name().to_string(); let source_name = guard.source_name().to_string();
let sink_name = guard.sink_name().to_string(); let sink_name = guard.sink_name().to_string();
assert!(node_present("sources", &source_name), "cleaned source must exist"); assert!(
assert!(node_present("sinks", &sink_name), "reference sink must exist"); node_present("sources", &source_name),
"cleaned source must exist"
);
assert!(
node_present("sinks", &sink_name),
"reference sink must exist"
);
drop(guard); drop(guard);
// Give pactl a moment to tear the nodes down. // Give pactl a moment to tear the nodes down.
std::thread::sleep(Duration::from_millis(300)); std::thread::sleep(Duration::from_millis(300));
assert!(!node_present("sources", &source_name), "source must be gone after unload"); assert!(
assert!(!node_present("sinks", &sink_name), "sink must be gone after unload"); !node_present("sources", &source_name),
"source must be gone after unload"
);
assert!(
!node_present("sinks", &sink_name),
"sink must be gone after unload"
);
} }
#[test] #[test]
@@ -220,7 +255,10 @@ mod tests {
pid_from_ec_args("source_name=peerspeak_echocancel_source.not-a-pid"), pid_from_ec_args("source_name=peerspeak_echocancel_source.not-a-pid"),
None None
); );
assert_eq!(pid_from_ec_args("source_name=someone_elses_source.4242"), None); assert_eq!(
pid_from_ec_args("source_name=someone_elses_source.4242"),
None
);
} }
#[test] #[test]
+17 -9
View File
@@ -251,7 +251,10 @@ mod tests {
let before = rms(&low); let before = rms(&low);
eq.process_frame(&mut low); eq.process_frame(&mut low);
let after = rms(&low); let after = rms(&low);
assert!(after > before * 1.6, "low shelf should boost low RMS: {before} -> {after}"); assert!(
after > before * 1.6,
"low shelf should boost low RMS: {before} -> {after}"
);
} }
#[test] #[test]
@@ -264,7 +267,10 @@ mod tests {
let before = rms(&high); let before = rms(&high);
eq.process_frame(&mut high); eq.process_frame(&mut high);
let after = rms(&high); let after = rms(&high);
assert!(after > before * 1.6, "high shelf should boost high RMS: {before} -> {after}"); assert!(
after > before * 1.6,
"high shelf should boost high RMS: {before} -> {after}"
);
} }
#[test] #[test]
@@ -275,7 +281,10 @@ mod tests {
Biquad::peaking(DEFAULT_SAMPLE_RATE, MID_PEAK_HZ, gain, MID_Q), Biquad::peaking(DEFAULT_SAMPLE_RATE, MID_PEAK_HZ, gain, MID_Q),
Biquad::high_shelf(DEFAULT_SAMPLE_RATE, HIGH_SHELF_HZ, gain, SHELF_Q), Biquad::high_shelf(DEFAULT_SAMPLE_RATE, HIGH_SHELF_HZ, gain, SHELF_Q),
] { ] {
assert!(b.coeffs.all_finite(), "coefficients must be finite at {gain} dB"); assert!(
b.coeffs.all_finite(),
"coefficients must be finite at {gain} dB"
);
} }
} }
} }
@@ -289,12 +298,11 @@ mod tests {
}); });
let mut frame = sine(1_000.0, 48_000, 30_000.0); let mut frame = sine(1_000.0, 48_000, 30_000.0);
eq.process_frame(&mut frame); eq.process_frame(&mut frame);
let peak = frame let peak = frame.iter().map(|&s| i32::from(s).abs()).max().unwrap_or(0);
.iter() assert!(
.map(|&s| i32::from(s).abs()) peak > 1_000,
.max() "processed signal should retain audible energy"
.unwrap_or(0); );
assert!(peak > 1_000, "processed signal should retain audible energy");
assert!( assert!(
frame.iter().any(|&s| s > 0) && frame.iter().any(|&s| s < 0), frame.iter().any(|&s| s > 0) && frame.iter().any(|&s| s < 0),
"a boosted sine should retain both polarities" "a boosted sine should retain both polarities"
+51 -12
View File
@@ -169,7 +169,10 @@ mod tests {
assert!(g.process(&mut f, 0.05), "loud frame must transmit"); assert!(g.process(&mut f, 0.05), "loud frame must transmit");
last = peak(&f); last = peak(&f);
} }
assert!(last >= 9900, "gain should reach ~1.0 on sustained loud input, got peak {last}"); assert!(
last >= 9900,
"gain should reach ~1.0 on sustained loud input, got peak {last}"
);
} }
#[test] #[test]
@@ -179,8 +182,15 @@ mod tests {
g.process(&mut f, 0.05); g.process(&mut f, 0.05);
// 5ms attack @48k = 240 samples; across a 960-sample frame the gain ramps // 5ms attack @48k = 240 samples; across a 960-sample frame the gain ramps
// 0->1, so the early samples are well below full scale (no instant click). // 0->1, so the early samples are well below full scale (no instant click).
assert!(f[0].abs() < 5000, "attack should start near zero, got {}", f[0]); assert!(
assert!(f[FRAME - 1].abs() > 9000, "attack should complete within the frame"); f[0].abs() < 5000,
"attack should start near zero, got {}",
f[0]
);
assert!(
f[FRAME - 1].abs() > 9000,
"attack should complete within the frame"
);
} }
#[test] #[test]
@@ -193,8 +203,14 @@ mod tests {
} }
// First quiet frame right after speech: hold keeps it open (not chopped). // First quiet frame right after speech: hold keeps it open (not chopped).
let mut q = frame(50); // rms ~0.0015, below close (0.03) let mut q = frame(50); // rms ~0.0015, below close (0.03)
assert!(g.process(&mut q, 0.05), "first quiet frame must stay open (hangover)"); assert!(
assert!(peak(&q) > 0, "held-open frame must not be silenced immediately"); g.process(&mut q, 0.05),
"first quiet frame must stay open (hangover)"
);
assert!(
peak(&q) > 0,
"held-open frame must not be silenced immediately"
);
// Hold is 200ms = 10 frames; keep feeding quiet until it fully closes. // Hold is 200ms = 10 frames; keep feeding quiet until it fully closes.
let mut closed = false; let mut closed = false;
@@ -205,7 +221,10 @@ mod tests {
break; break;
} }
} }
assert!(closed, "gate must eventually close and stop transmitting after sustained silence"); assert!(
closed,
"gate must eventually close and stop transmitting after sustained silence"
);
} }
#[test] #[test]
@@ -216,8 +235,14 @@ mod tests {
g.process(&mut f, 0.05); // open=0.05, close=0.03 g.process(&mut f, 0.05); // open=0.05, close=0.03
// A frame between close and open thresholds: rms ~0.04 (amp ~1310). // A frame between close and open thresholds: rms ~0.04 (amp ~1310).
let mut mid = frame(1310); let mut mid = frame(1310);
assert!(g.process(&mut mid, 0.05), "between-threshold frame must keep an open gate open"); assert!(
assert!(g.open, "hysteresis: gate stays open above the close threshold"); g.process(&mut mid, 0.05),
"between-threshold frame must keep an open gate open"
);
assert!(
g.open,
"hysteresis: gate stays open above the close threshold"
);
} }
#[test] #[test]
@@ -225,7 +250,10 @@ mod tests {
let mut g = NoiseGate::new(SR); let mut g = NoiseGate::new(SR);
// Never opened; feed silence — should report don't-transmit promptly. // Never opened; feed silence — should report don't-transmit promptly.
let mut f = frame(0); let mut f = frame(0);
assert!(!g.process(&mut f, 0.05), "an unopened gate on silence must not transmit"); assert!(
!g.process(&mut f, 0.05),
"an unopened gate on silence must not transmit"
);
} }
#[test] #[test]
@@ -266,7 +294,11 @@ mod tests {
let mut f2 = frame(10000); let mut f2 = frame(10000);
assert!(g.process(&mut f2, 0.05)); // enabled assert!(g.process(&mut f2, 0.05)); // enabled
assert!(f2[0].abs() > 9000, "expected first sample of enabled frame to have no fade-in, got {}", f2[0]); assert!(
f2[0].abs() > 9000,
"expected first sample of enabled frame to have no fade-in, got {}",
f2[0]
);
} }
#[test] #[test]
@@ -302,7 +334,10 @@ mod tests {
let mut f = frame(1310); let mut f = frame(1310);
assert!(g.process(&mut f, 0.05)); assert!(g.process(&mut f, 0.05));
} }
assert!(g.open, "gate must stay open (hold refreshed by mid-level input)"); assert!(
g.open,
"gate must stay open (hold refreshed by mid-level input)"
);
} }
#[test] #[test]
@@ -333,6 +368,10 @@ mod tests {
last_peak = peak(&f); last_peak = peak(&f);
} }
assert!(g.open); assert!(g.open);
assert!(last_peak >= 9900, "peak of the 3rd reopened frame must be >= 9900, got {}", last_peak); assert!(
last_peak >= 9900,
"peak of the 3rd reopened frame must be >= 9900, got {}",
last_peak
);
} }
} }
+69 -15
View File
@@ -123,7 +123,10 @@ mod tests {
let out = lim.process(&loud, 1.0); let out = lim.process(&loud, 1.0);
let ceiling = lim.ceiling().ceil() as i16; let ceiling = lim.ceiling().ceil() as i16;
for &s in &out { for &s in &out {
assert!(s > 0, "positive loud input stays positive (no wrap), got {s}"); assert!(
s > 0,
"positive loud input stays positive (no wrap), got {s}"
);
assert!(s <= ceiling, "sample {s} exceeded ceiling {ceiling}"); assert!(s <= ceiling, "sample {s} exceeded ceiling {ceiling}");
} }
} }
@@ -175,7 +178,10 @@ mod tests {
let out_pos = lim.process(&pos_loud, 1.0); let out_pos = lim.process(&pos_loud, 1.0);
for &s in &out_pos { for &s in &out_pos {
assert!(s > 0, "positive input stays positive, got {s}"); assert!(s > 0, "positive input stays positive, got {s}");
assert!(s <= ceiling_ceil, "positive sample {s} exceeded ceiling {ceiling_ceil}"); assert!(
s <= ceiling_ceil,
"positive sample {s} exceeded ceiling {ceiling_ceil}"
);
} }
// Sustained negative loud sum // Sustained negative loud sum
@@ -185,7 +191,10 @@ mod tests {
let neg_ceiling = -ceiling_ceil; let neg_ceiling = -ceiling_ceil;
for &s in &out_neg { for &s in &out_neg {
assert!(s < 0, "negative input stays negative, got {s}"); assert!(s < 0, "negative input stays negative, got {s}");
assert!(s >= neg_ceiling, "negative sample {s} exceeded negative ceiling {neg_ceiling}"); assert!(
s >= neg_ceiling,
"negative sample {s} exceeded negative ceiling {neg_ceiling}"
);
} }
} }
@@ -200,8 +209,14 @@ mod tests {
let out = lim.process(&input, 8.0); let out = lim.process(&input, 8.0);
for &s in &out { for &s in &out {
assert!(s > 0, "positive stays positive"); assert!(s > 0, "positive stays positive");
assert!(s <= ceiling_ceil, "sample {s} must be limited to ceiling {ceiling_ceil}"); assert!(
assert!((s - ceiling_ceil).abs() <= 2, "sample {s} should ride the ceiling {ceiling_ceil}"); s <= ceiling_ceil,
"sample {s} must be limited to ceiling {ceiling_ceil}"
);
assert!(
(s - ceiling_ceil).abs() <= 2,
"sample {s} should ride the ceiling {ceiling_ceil}"
);
} }
} }
@@ -213,7 +228,10 @@ mod tests {
let out = lim.process(&input, 0.5); let out = lim.process(&input, 0.5);
for (i, &s) in out.iter().enumerate() { for (i, &s) in out.iter().enumerate() {
let expected = (input[i] as f32 * 0.5).round() as i16; let expected = (input[i] as f32 * 0.5).round() as i16;
assert!((s - expected).abs() <= 1, "sample {s} should be close to expected {expected}"); assert!(
(s - expected).abs() <= 1,
"sample {s} should be close to expected {expected}"
);
} }
// Subsequently feed a new sample at unity gain. It must be transparent, // Subsequently feed a new sample at unity gain. It must be transparent,
@@ -230,7 +248,12 @@ mod tests {
let loud = vec![200_000i32; 10]; let loud = vec![200_000i32; 10];
let out = lim.process(&loud, 1.0); let out = lim.process(&loud, 1.0);
assert!(out[0] <= ceiling_ceil, "first sample {} must not overshoot ceiling {}", out[0], ceiling_ceil); assert!(
out[0] <= ceiling_ceil,
"first sample {} must not overshoot ceiling {}",
out[0],
ceiling_ceil
);
} }
/// 5. Release direction & monotonicity. /// 5. Release direction & monotonicity.
@@ -247,13 +270,23 @@ mod tests {
// Output should be monotonic (non-decreasing) // Output should be monotonic (non-decreasing)
for i in 1..out.len() { for i in 1..out.len() {
assert!(out[i] >= out[i - 1], "output must be monotonic; index {} was {}, index {} was {}", i - 1, out[i - 1], i, out[i]); assert!(
out[i] >= out[i - 1],
"output must be monotonic; index {} was {}, index {} was {}",
i - 1,
out[i - 1],
i,
out[i]
);
} }
// The end sample should be closer to the original input than the start sample // The end sample should be closer to the original input than the start sample
let start_diff = (mid_val as i16 - out[0]).abs(); let start_diff = (mid_val as i16 - out[0]).abs();
let end_diff = (mid_val as i16 - *out.last().unwrap()).abs(); let end_diff = (mid_val as i16 - *out.last().unwrap()).abs();
assert!(end_diff < start_diff, "end diff {end_diff} should be smaller than start diff {start_diff}"); assert!(
end_diff < start_diff,
"end diff {end_diff} should be smaller than start diff {start_diff}"
);
} }
/// 6. Release is gradual, not instantaneous. /// 6. Release is gradual, not instantaneous.
@@ -265,7 +298,11 @@ mod tests {
// Immediately follow with a sub-ceiling sample // Immediately follow with a sub-ceiling sample
let out = lim.process(&[10_000i32], 1.0); let out = lim.process(&[10_000i32], 1.0);
assert!(out[0] < 10_000, "first quiet sample should still be attenuated (got {})", out[0]); assert!(
out[0] < 10_000,
"first quiet sample should still be attenuated (got {})",
out[0]
);
} }
/// 7. State carries across process calls. /// 7. State carries across process calls.
@@ -287,7 +324,10 @@ mod tests {
let mut out_split = out_split1; let mut out_split = out_split1;
out_split.extend(&out_split2); out_split.extend(&out_split2);
assert_eq!(out_single, out_split, "splitting process calls must produce identical output to a single call"); assert_eq!(
out_single, out_split,
"splitting process calls must produce identical output to a single call"
);
// Test 2: Pre-loaded limiter vs fresh limiter on the same input // Test 2: Pre-loaded limiter vs fresh limiter on the same input
let mut lim_preloaded = SoftLimiter::new(SR); let mut lim_preloaded = SoftLimiter::new(SR);
@@ -299,8 +339,16 @@ mod tests {
let out_preloaded = lim_preloaded.process(&test_input, 1.0); let out_preloaded = lim_preloaded.process(&test_input, 1.0);
let out_fresh = lim_fresh.process(&test_input, 1.0); let out_fresh = lim_fresh.process(&test_input, 1.0);
assert_ne!(out_preloaded, out_fresh, "pre-loaded and fresh limiter outputs should differ"); assert_ne!(
assert!(out_preloaded[0] < out_fresh[0], "pre-loaded limiter first sample {} should be smaller than fresh limiter first sample {}", out_preloaded[0], out_fresh[0]); out_preloaded, out_fresh,
"pre-loaded and fresh limiter outputs should differ"
);
assert!(
out_preloaded[0] < out_fresh[0],
"pre-loaded limiter first sample {} should be smaller than fresh limiter first sample {}",
out_preloaded[0],
out_fresh[0]
);
} }
/// 8. Empty input. /// 8. Empty input.
@@ -320,7 +368,10 @@ mod tests {
// Gain 0.0 // Gain 0.0
let out_zero = lim.process(&input, 0.0); let out_zero = lim.process(&input, 0.0);
assert_eq!(out_zero.len(), input.len()); assert_eq!(out_zero.len(), input.len());
assert!(out_zero.iter().all(|&s| s == 0), "0.0 gain should result in all zeros"); assert!(
out_zero.iter().all(|&s| s == 0),
"0.0 gain should result in all zeros"
);
// Gain 1.0 // Gain 1.0
let out_unity = lim.process(&input, 1.0); let out_unity = lim.process(&input, 1.0);
@@ -354,6 +405,9 @@ mod tests {
let out = lim.process(&input, 1.0); let out = lim.process(&input, 1.0);
let expected: Vec<i16> = input.iter().map(|&s| s as i16).collect(); let expected: Vec<i16> = input.iter().map(|&s| s as i16).collect();
assert_eq!(out, expected, "below ceiling input must be bit-exact at unity gain"); assert_eq!(
out, expected,
"below ceiling input must be bit-exact at unity gain"
);
} }
} }
+15 -7
View File
@@ -1,6 +1,6 @@
use std::sync::mpsc::{Receiver, Sender};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::AtomicUsize; use std::sync::atomic::AtomicUsize;
use std::sync::mpsc::{Receiver, Sender};
use thiserror::Error; use thiserror::Error;
/// Playback output channel count. Capture/encode/network remain mono; only the /// Playback output channel count. Capture/encode/network remain mono; only the
@@ -35,7 +35,11 @@ pub enum AudioError {
pub trait AudioBackend: Send + Sync { pub trait AudioBackend: Send + Sync {
/// Starts capturing raw PCM audio from the input device (microphone), /// Starts capturing raw PCM audio from the input device (microphone),
/// sending chunks of samples (e.g. `Vec<i16>`) to the provided Sender. /// sending chunks of samples (e.g. `Vec<i16>`) to the provided Sender.
fn start_capture(&self, tx: Sender<Vec<i16>>, target_node: Option<String>) -> Result<(), AudioError>; fn start_capture(
&self,
tx: Sender<Vec<i16>>,
target_node: Option<String>,
) -> Result<(), AudioError>;
/// Starts playing back raw PCM audio to the output device (speaker), /// Starts playing back raw PCM audio to the output device (speaker),
/// reading mixed/incoming chunks of samples from the provided Receiver. /// reading mixed/incoming chunks of samples from the provided Receiver.
@@ -61,20 +65,24 @@ pub mod eq;
pub mod gate; pub mod gate;
pub mod limiter; pub mod limiter;
pub mod multitrack; pub mod multitrack;
// The cross-repo ownership tag (plan §5.1). Platform-neutral on purpose: the
// carriers only matter on PipeWire, but the literals are a wire contract and
// their test must run on every platform so a rename can't pass CI elsewhere.
pub mod ownership;
pub mod pan; pub mod pan;
// Linear resamplers used by the Windows/cpal backend (W4). Platform-neutral and // Linear resamplers used by the Windows/cpal backend (W4). Platform-neutral and
// pure, so it builds (and its tests run) everywhere even though only the cpal // pure, so it builds (and its tests run) everywhere even though only the cpal
// backend wires it in. // backend wires it in.
pub mod resample; #[cfg(windows)]
pub mod cpal_impl;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub mod echo_cancel; pub mod echo_cancel;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub mod pipewire_impl; pub mod pipewire_impl;
#[cfg(windows)]
pub mod cpal_impl;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub mod pw_cli; pub mod pw_cli;
pub mod recorder; pub mod recorder;
pub mod resample;
/// A selectable audio device for the input/output pickers. `name` is the stable /// A selectable audio device for the input/output pickers. `name` is the stable
/// identifier the backend uses to request the device (`target_node`); /// identifier the backend uses to request the device (`target_node`);
@@ -96,10 +104,10 @@ impl std::fmt::Display for AudioDevice {
// Enumerate audio input/output devices for the pickers (sorted by description), // Enumerate audio input/output devices for the pickers (sorted by description),
// returning the same `AudioDevice` shape regardless of platform: PipeWire // returning the same `AudioDevice` shape regardless of platform: PipeWire
// (`pw-cli`) on Linux, cpal/WASAPI on Windows. // (`pw-cli`) on Linux, cpal/WASAPI on Windows.
#[cfg(target_os = "linux")]
pub use pw_cli::enumerate_audio_devices;
#[cfg(windows)] #[cfg(windows)]
pub use cpal_impl::enumerate_audio_devices; pub use cpal_impl::enumerate_audio_devices;
#[cfg(target_os = "linux")]
pub use pw_cli::enumerate_audio_devices;
/// The audio backend implementation for the current platform. /// The audio backend implementation for the current platform.
/// ///
+461 -91
View File
@@ -12,9 +12,11 @@
//! This module is pure plumbing over [`WavWriter`]: no audio decode, no //! This module is pure plumbing over [`WavWriter`]: no audio decode, no
//! networking, no realtime work. The mixer (a non-RT task) drives it. //! networking, no realtime work. The mixer (a non-RT task) drives it.
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, HashSet, VecDeque};
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, SyncSender, TrySendError};
use std::thread::{self, JoinHandle};
use iroh::EndpointId; use iroh::EndpointId;
@@ -24,6 +26,8 @@ use crate::core::jitter::FRAME_SAMPLES;
/// Cap on the silence chunk written at once when pre-padding a late joiner, so a /// Cap on the silence chunk written at once when pre-padding a late joiner, so a
/// long-running call can't trigger a single multi-hundred-MB allocation. /// long-running call can't trigger a single multi-hundred-MB allocation.
const SILENCE_CHUNK: usize = FRAME_SAMPLES * 256; const SILENCE_CHUNK: usize = FRAME_SAMPLES * 256;
const WRITER_QUEUE_CYCLES: usize = 256;
const DROP_LOG_INTERVAL_CYCLES: u64 = 256;
/// Cap on buffered mic samples (~200ms @ 48kHz). Bounds how far the mic track /// Cap on buffered mic samples (~200ms @ 48kHz). Bounds how far the mic track
/// can drift if the capture clock runs ahead of the mixer cycle; past it the /// can drift if the capture clock runs ahead of the mixer cycle; past it the
@@ -56,41 +60,6 @@ pub fn create_session_dir(base: &Path, now_unix_secs: u64) -> io::Result<PathBuf
)) ))
} }
/// One output track: its WAV writer plus whether it has been written *this*
/// cycle (so `end_cycle` knows which tracks to pad with silence).
struct Track {
writer: WavWriter,
written_this_cycle: bool,
}
impl Track {
fn create(path: &Path) -> io::Result<Self> {
Ok(Self {
writer: WavWriter::new(path)?,
written_this_cycle: false,
})
}
/// Append `frame` fitted to exactly `frame_samples` (zero-padded if short),
/// and mark the track as written for this cycle.
fn write_frame(&mut self, frame: &[i16], frame_samples: usize) -> io::Result<()> {
self.writer.write_samples(&fit(frame, frame_samples))?;
self.written_this_cycle = true;
Ok(())
}
/// Append `samples` of silence (no cycle-marking — used for padding).
fn write_silence(&mut self, samples: usize) -> io::Result<()> {
let mut remaining = samples;
while remaining > 0 {
let n = remaining.min(SILENCE_CHUNK);
self.writer.write_samples(&vec![0i16; n])?;
remaining -= n;
}
Ok(())
}
}
/// Return `frame` resized to exactly `n` samples: truncated if longer (shouldn't /// Return `frame` resized to exactly `n` samples: truncated if longer (shouldn't
/// happen — Opus frames are uniform), zero-padded if shorter. /// happen — Opus frames are uniform), zero-padded if shorter.
fn fit(frame: &[i16], n: usize) -> Vec<i16> { fn fit(frame: &[i16], n: usize) -> Vec<i16> {
@@ -108,7 +77,13 @@ pub fn track_filename(name: &str, id: &EndpointId) -> String {
let clean = crate::sanitize::sanitize_name(name); let clean = crate::sanitize::sanitize_name(name);
let mut slug: String = clean let mut slug: String = clean
.chars() .chars()
.map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }) .map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect(); .collect();
// Collapse runs of '-' and trim them off the ends. // Collapse runs of '-' and trim them off the ends.
while slug.contains("--") { while slug.contains("--") {
@@ -120,41 +95,210 @@ pub fn track_filename(name: &str, id: &EndpointId) -> String {
format!("{slug}-{short}.wav") format!("{slug}-{short}.wav")
} }
/// A live multitrack recording: per-peer stems + your mic, plus an optional #[derive(Default)]
/// mixed track, all under one session directory and clocked together. struct PendingCycle {
pub struct MultitrackRecorder { new_peers: Vec<NewPeer>,
dir: PathBuf, peer_frames: HashMap<EndpointId, Vec<i16>>,
frame_samples: usize, mix_frame: Option<Vec<i16>>,
/// Cycles recorded so far = the shared length (in frames) of every track.
cycles: u64,
peers: HashMap<EndpointId, Track>,
/// Your mic track. Fed asynchronously from the capture thread via
/// [`push_mic`](MultitrackRecorder::push_mic) into `mic_fifo`, then drained
/// one frame per `end_cycle` so it aligns with the cycle clock.
mic: WavWriter,
mic_fifo: VecDeque<i16>,
/// Present in "Both" mode (stems + mixed), absent in "stems only".
mix: Option<Track>,
} }
impl MultitrackRecorder { struct NewPeer {
/// Create a recording in `dir` (which must already exist). `with_mix` adds id: EndpointId,
/// the convenience mixed track (`mix.wav`). Your mic is always `me.wav`. filename: String,
pub fn create(dir: &Path, frame_samples: usize, with_mix: bool) -> io::Result<Self> { }
struct CycleBatch {
new_peers: Vec<NewPeer>,
mic_frame: Vec<i16>,
mix_frame: Option<Vec<i16>>,
peer_frames: HashMap<EndpointId, Vec<i16>>,
}
trait SampleWriter {
fn write_samples(&mut self, samples: &[i16]) -> io::Result<()>;
fn finalize(self) -> io::Result<()>;
}
impl SampleWriter for WavWriter {
fn write_samples(&mut self, samples: &[i16]) -> io::Result<()> {
WavWriter::write_samples(self, samples)
}
fn finalize(self) -> io::Result<()> {
WavWriter::finalize(self)
}
}
struct WriterState<W> {
dir: PathBuf,
frame_samples: usize,
peers: HashMap<EndpointId, W>,
mic: W,
mix: Option<W>,
cycles_written: u64,
}
impl WriterState<WavWriter> {
fn create(dir: &Path, frame_samples: usize, with_mix: bool) -> io::Result<Self> {
let mic = WavWriter::new(&dir.join("me.wav"))?; let mic = WavWriter::new(&dir.join("me.wav"))?;
let mix = if with_mix { let mix = if with_mix {
Some(Track::create(&dir.join("mix.wav"))?) Some(WavWriter::new(&dir.join("mix.wav"))?)
} else { } else {
None None
}; };
Ok(Self { Ok(Self {
dir: dir.to_path_buf(), dir: dir.to_path_buf(),
frame_samples, frame_samples,
cycles: 0,
peers: HashMap::new(), peers: HashMap::new(),
mic, mic,
mic_fifo: VecDeque::new(),
mix, mix,
cycles_written: 0,
})
}
}
impl<W: SampleWriter> WriterState<W> {
fn apply_batch<F>(&mut self, batch: &CycleBatch, mut create_peer: F) -> io::Result<()>
where
F: FnMut(&Path) -> io::Result<W>,
{
for peer in &batch.new_peers {
if !self.peers.contains_key(&peer.id) {
let writer = create_peer(&self.dir.join(&peer.filename))?;
self.peers.insert(peer.id, writer);
let pad = self.back_pad_samples()?;
let writer = self.peers.get_mut(&peer.id).unwrap();
Self::write_silence(writer, pad)?;
}
}
self.mic.write_samples(&batch.mic_frame)?;
if let Some(mix) = self.mix.as_mut() {
if let Some(frame) = batch.mix_frame.as_deref() {
mix.write_samples(frame)?;
} else {
Self::write_silence(mix, self.frame_samples)?;
}
}
let silence = vec![0i16; self.frame_samples];
for (id, writer) in &mut self.peers {
let frame = batch
.peer_frames
.get(id)
.map(Vec::as_slice)
.unwrap_or(&silence);
writer.write_samples(frame)?;
}
self.cycles_written += 1;
Ok(())
}
fn back_pad_samples(&self) -> io::Result<usize> {
let cycles = usize::try_from(self.cycles_written)
.map_err(|_| io::Error::other("multitrack recording too long"))?;
cycles
.checked_mul(self.frame_samples)
.ok_or_else(|| io::Error::other("multitrack recording too long"))
}
fn write_silence(writer: &mut W, samples: usize) -> io::Result<()> {
let mut remaining = samples;
let silence = vec![0i16; remaining.min(SILENCE_CHUNK)];
while remaining > 0 {
let n = remaining.min(silence.len());
writer.write_samples(&silence[..n])?;
remaining -= n;
}
Ok(())
}
fn finalize(self) -> io::Result<()> {
let mut first_finalize_error = None;
record_first_error(&mut first_finalize_error, self.mic.finalize());
if let Some(mix) = self.mix {
record_first_error(&mut first_finalize_error, mix.finalize());
}
for writer in self.peers.into_values() {
record_first_error(&mut first_finalize_error, writer.finalize());
}
if let Some(e) = first_finalize_error {
Err(e)
} else {
Ok(())
}
}
}
fn record_first_error(slot: &mut Option<io::Error>, result: io::Result<()>) {
if slot.is_none()
&& let Err(e) = result
{
*slot = Some(e);
}
}
/// Applies whole-cycle batches on the writer thread. Each applied batch appends
/// exactly `frame_samples` to every existing track, and a dropped batch never
/// reaches this loop for any track, so stem lengths stay equal even when the
/// bounded queue applies back-pressure.
fn writer_thread_main(
mut state: WriterState<WavWriter>,
batch_rx: mpsc::Receiver<CycleBatch>,
) -> io::Result<()> {
let mut first_write_error = None;
for batch in batch_rx {
if first_write_error.is_none()
&& let Err(e) = state.apply_batch(&batch, WavWriter::new)
{
first_write_error = Some(e);
}
}
let finalize_result = state.finalize();
if let Some(e) = first_write_error {
Err(e)
} else {
finalize_result
}
}
/// A live multitrack recording: per-peer stems + your mic, plus an optional
/// mixed track, all under one session directory and clocked together.
pub struct MultitrackRecorder {
dir: PathBuf,
frame_samples: usize,
known_peers: HashSet<EndpointId>,
/// Your mic track. Fed asynchronously from the capture thread via
/// [`push_mic`](MultitrackRecorder::push_mic) into `mic_fifo`, then drained
/// one frame per `end_cycle` so it aligns with the cycle clock.
mic_fifo: VecDeque<i16>,
/// Present in "Both" mode (stems + mixed), absent in "stems only".
with_mix: bool,
batch_tx: SyncSender<CycleBatch>,
writer_thread: JoinHandle<io::Result<()>>,
dropped_cycles: u64,
pending: PendingCycle,
}
impl MultitrackRecorder {
/// Create a recording in `dir` (which must already exist). `with_mix` adds
/// the convenience mixed track (`mix.wav`). Your mic is always `me.wav`.
pub fn create(dir: &Path, frame_samples: usize, with_mix: bool) -> io::Result<Self> {
let writer_state = WriterState::create(dir, frame_samples, with_mix)?;
let (batch_tx, batch_rx) = mpsc::sync_channel(WRITER_QUEUE_CYCLES);
let writer_thread = thread::spawn(move || writer_thread_main(writer_state, batch_rx));
Ok(Self {
dir: dir.to_path_buf(),
frame_samples,
known_peers: HashSet::new(),
mic_fifo: VecDeque::new(),
with_mix,
batch_tx,
writer_thread,
dropped_cycles: 0,
pending: PendingCycle::default(),
}) })
} }
@@ -167,12 +311,14 @@ impl MultitrackRecorder {
/// so it aligns with the others. Idempotent: a peer already tracked is left /// so it aligns with the others. Idempotent: a peer already tracked is left
/// as-is (re-announce / name change doesn't restart their file). /// as-is (re-announce / name change doesn't restart their file).
pub fn add_peer(&mut self, id: EndpointId, name: &str) -> io::Result<()> { pub fn add_peer(&mut self, id: EndpointId, name: &str) -> io::Result<()> {
if self.peers.contains_key(&id) { if self.known_peers.contains(&id) {
return Ok(()); return Ok(());
} }
let mut track = Track::create(&self.dir.join(track_filename(name, &id)))?; self.known_peers.insert(id);
track.write_silence(self.cycles as usize * self.frame_samples)?; self.pending.new_peers.push(NewPeer {
self.peers.insert(id, track); id,
filename: track_filename(name, &id),
});
Ok(()) Ok(())
} }
@@ -180,11 +326,13 @@ impl MultitrackRecorder {
/// registered yet (write raced ahead of the join event), auto-register it /// registered yet (write raced ahead of the join event), auto-register it
/// with an id-only name so no audio is dropped. /// with an id-only name so no audio is dropped.
pub fn write_peer(&mut self, id: EndpointId, frame: &[i16]) -> io::Result<()> { pub fn write_peer(&mut self, id: EndpointId, frame: &[i16]) -> io::Result<()> {
if !self.peers.contains_key(&id) { if !self.known_peers.contains(&id) {
self.add_peer(id, "")?; self.add_peer(id, "")?;
} }
let fs = self.frame_samples; self.pending
self.peers.get_mut(&id).unwrap().write_frame(frame, fs) .peer_frames
.insert(id, fit(frame, self.frame_samples));
Ok(())
} }
/// Buffer a frame of your transmitted mic audio (called from the capture /// Buffer a frame of your transmitted mic audio (called from the capture
@@ -209,9 +357,8 @@ impl MultitrackRecorder {
/// Record the finished mixed-bus frame for the current cycle (no-op in /// Record the finished mixed-bus frame for the current cycle (no-op in
/// stems-only mode). /// stems-only mode).
pub fn write_mix(&mut self, frame: &[i16]) -> io::Result<()> { pub fn write_mix(&mut self, frame: &[i16]) -> io::Result<()> {
let fs = self.frame_samples; if self.with_mix {
if let Some(mix) = self.mix.as_mut() { self.pending.mix_frame = Some(fit(frame, self.frame_samples));
mix.write_frame(frame, fs)?;
} }
Ok(()) Ok(())
} }
@@ -224,28 +371,63 @@ impl MultitrackRecorder {
// Mic: always one frame per cycle, drained from the FIFO (silence on // Mic: always one frame per cycle, drained from the FIFO (silence on
// underrun), so it tracks the cycle clock like the peer stems. // underrun), so it tracks the cycle clock like the peer stems.
let mic_frame = self.drain_mic(fs); let mic_frame = self.drain_mic(fs);
self.mic.write_samples(&mic_frame)?; let mut pending = std::mem::take(&mut self.pending);
// Peers + the optional mix track: pad any not written this cycle. pending.new_peers.sort_by(|a, b| {
for track in self.peers.values_mut().chain(self.mix.as_mut()) { a.filename
if !track.written_this_cycle { .cmp(&b.filename)
track.write_silence(fs)?; .then_with(|| a.id.to_string().cmp(&b.id.to_string()))
});
let batch = CycleBatch {
new_peers: pending.new_peers,
mic_frame,
mix_frame: if self.with_mix {
pending.mix_frame
} else {
None
},
peer_frames: pending.peer_frames,
};
match self.batch_tx.try_send(batch) {
Ok(()) => Ok(()),
Err(TrySendError::Full(batch)) => {
for peer in &batch.new_peers {
self.known_peers.remove(&peer.id);
}
self.dropped_cycles = self.dropped_cycles.saturating_add(1);
if self.dropped_cycles == 1
|| self.dropped_cycles.is_multiple_of(DROP_LOG_INTERVAL_CYCLES)
{
crate::log_msg(&format!(
"multitrack recording: writer queue full; dropped {} cycle(s)",
self.dropped_cycles
));
}
Ok(())
} }
track.written_this_cycle = false; Err(TrySendError::Disconnected(_)) => Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"multitrack writer thread stopped",
)),
} }
self.cycles += 1;
Ok(())
} }
/// Finalize every track's WAV header. Consumes the recorder. /// Finalize every track's WAV header. Consumes the recorder.
pub fn finalize(self) -> io::Result<()> { pub fn finalize(self) -> io::Result<()> {
self.mic.finalize()?; let Self {
if let Some(mix) = self.mix { dir: _,
mix.writer.finalize()?; frame_samples: _,
} known_peers: _,
for (_, track) in self.peers { mic_fifo: _,
track.writer.finalize()?; with_mix: _,
} batch_tx,
Ok(()) writer_thread,
dropped_cycles: _,
pending: _,
} = self;
drop(batch_tx);
writer_thread
.join()
.unwrap_or_else(|_| Err(io::Error::other("multitrack writer thread panicked")))
} }
} }
@@ -271,6 +453,51 @@ mod tests {
d d
} }
#[derive(Default)]
struct TestWriter {
samples: Vec<i16>,
}
impl SampleWriter for TestWriter {
fn write_samples(&mut self, samples: &[i16]) -> io::Result<()> {
self.samples.extend_from_slice(samples);
Ok(())
}
fn finalize(self) -> io::Result<()> {
Ok(())
}
}
fn test_writer_state(frame_samples: usize, with_mix: bool) -> WriterState<TestWriter> {
WriterState {
dir: PathBuf::new(),
frame_samples,
peers: HashMap::new(),
mic: TestWriter::default(),
mix: if with_mix {
Some(TestWriter::default())
} else {
None
},
cycles_written: 0,
}
}
fn test_batch(
new_peers: Vec<NewPeer>,
mic_frame: Vec<i16>,
mix_frame: Option<Vec<i16>>,
peer_frames: Vec<(EndpointId, Vec<i16>)>,
) -> CycleBatch {
CycleBatch {
new_peers,
mic_frame,
mix_frame,
peer_frames: peer_frames.into_iter().collect(),
}
}
#[test] #[test]
fn fit_pads_and_truncates() { fn fit_pads_and_truncates() {
assert_eq!(fit(&[1, 2], 4), vec![1, 2, 0, 0]); assert_eq!(fit(&[1, 2], 4), vec![1, 2, 0, 0]);
@@ -284,7 +511,10 @@ mod tests {
let short: String = id.to_string().chars().take(8).collect(); let short: String = id.to_string().chars().take(8).collect();
assert_eq!(track_filename("Alice", &id), format!("alice-{short}.wav")); assert_eq!(track_filename("Alice", &id), format!("alice-{short}.wav"));
// Spaces / punctuation collapse to single dashes, trimmed. // Spaces / punctuation collapse to single dashes, trimmed.
assert_eq!(track_filename(" Bob the Builder! ", &id), format!("bob-the-builder-{short}.wav")); assert_eq!(
track_filename(" Bob the Builder! ", &id),
format!("bob-the-builder-{short}.wav")
);
// A name that sanitizes/slugs to nothing falls back to "peer". // A name that sanitizes/slugs to nothing falls back to "peer".
assert_eq!(track_filename("!!!", &id), format!("peer-{short}.wav")); assert_eq!(track_filename("!!!", &id), format!("peer-{short}.wav"));
} }
@@ -302,6 +532,110 @@ mod tests {
let _ = std::fs::remove_dir_all(&base); let _ = std::fs::remove_dir_all(&base);
} }
#[test]
fn apply_batch_advances_existing_tracks_and_back_pads_late_peer() {
let frame = 3;
let early = an_id();
let late = an_id();
let mut state = test_writer_state(frame, true);
state.cycles_written = 2;
state.mic.samples = vec![8; 2 * frame];
state.mix.as_mut().unwrap().samples = vec![6; 2 * frame];
state.peers.insert(
early,
TestWriter {
samples: vec![1; 2 * frame],
},
);
let batch = test_batch(
vec![NewPeer {
id: late,
filename: "late.wav".to_string(),
}],
vec![9; frame],
None,
vec![(early, vec![2; frame]), (late, vec![7; frame])],
);
state
.apply_batch(&batch, |_| Ok(TestWriter::default()))
.unwrap();
assert_eq!(state.cycles_written, 3);
assert_eq!(state.mic.samples.len(), 3 * frame);
assert_eq!(state.mix.as_ref().unwrap().samples.len(), 3 * frame);
assert_eq!(
&state.mix.as_ref().unwrap().samples[2 * frame..],
&[0, 0, 0]
);
assert_eq!(state.peers.get(&early).unwrap().samples.len(), 3 * frame);
assert_eq!(
&state.peers.get(&early).unwrap().samples[2 * frame..],
&[2, 2, 2]
);
assert_eq!(
state.peers.get(&late).unwrap().samples,
vec![0, 0, 0, 0, 0, 0, 7, 7, 7],
"late peer is back-padded by completed cycles before this batch"
);
}
#[test]
fn skipped_batches_keep_all_tracks_equal_length() {
let frame = 2;
let p1 = an_id();
let p2 = an_id();
let mut state = test_writer_state(frame, true);
let first = test_batch(
vec![
NewPeer {
id: p1,
filename: "p1.wav".to_string(),
},
NewPeer {
id: p2,
filename: "p2.wav".to_string(),
},
],
vec![1; frame],
Some(vec![5; frame]),
vec![(p1, vec![10; frame]), (p2, vec![20; frame])],
);
state
.apply_batch(&first, |_| Ok(TestWriter::default()))
.unwrap();
let _dropped_cycle = test_batch(
Vec::new(),
vec![2; frame],
Some(vec![6; frame]),
vec![(p1, vec![11; frame])],
);
let after_drop = test_batch(
Vec::new(),
vec![3; frame],
None,
vec![(p1, vec![12; frame])],
);
state
.apply_batch(&after_drop, |_| Ok(TestWriter::default()))
.unwrap();
let expected = 2 * frame;
assert_eq!(state.cycles_written, 2);
assert_eq!(state.mic.samples.len(), expected);
assert_eq!(state.mix.as_ref().unwrap().samples.len(), expected);
assert_eq!(state.peers.get(&p1).unwrap().samples.len(), expected);
assert_eq!(state.peers.get(&p2).unwrap().samples.len(), expected);
assert_eq!(
&state.peers.get(&p2).unwrap().samples[frame..],
&[0, 0],
"peer absent from an applied batch gets silence for that cycle"
);
}
#[test] #[test]
fn all_tracks_equal_length_after_n_cycles() { fn all_tracks_equal_length_after_n_cycles() {
let dir = tmpdir("equal"); let dir = tmpdir("equal");
@@ -327,10 +661,18 @@ mod tests {
rec.finalize().unwrap(); rec.finalize().unwrap();
let expected = 3 * frame; let expected = 3 * frame;
assert_eq!(wav_samples(&dir.join("me.wav")), expected, "mic padded to full length"); assert_eq!(
wav_samples(&dir.join("me.wav")),
expected,
"mic padded to full length"
);
assert_eq!(wav_samples(&dir.join("mix.wav")), expected); assert_eq!(wav_samples(&dir.join("mix.wav")), expected);
assert_eq!(wav_samples(&dir.join(track_filename("p1", &p1))), expected); assert_eq!(wav_samples(&dir.join(track_filename("p1", &p1))), expected);
assert_eq!(wav_samples(&dir.join(track_filename("p2", &p2))), expected, "silent peer still full length"); assert_eq!(
wav_samples(&dir.join(track_filename("p2", &p2))),
expected,
"silent peer still full length"
);
} }
#[test] #[test]
@@ -357,8 +699,14 @@ mod tests {
rec.finalize().unwrap(); rec.finalize().unwrap();
// Both tracks are the full 5 cycles long (late one was back-padded). // Both tracks are the full 5 cycles long (late one was back-padded).
assert_eq!(wav_samples(&dir.join(track_filename("early", &early))), 5 * frame); assert_eq!(
assert_eq!(wav_samples(&dir.join(track_filename("late", &late))), 5 * frame); wav_samples(&dir.join(track_filename("early", &early))),
5 * frame
);
assert_eq!(
wav_samples(&dir.join(track_filename("late", &late))),
5 * frame
);
// The late track's first 2 cycles are silence, then the real audio. // The late track's first 2 cycles are silence, then the real audio.
let bytes = std::fs::read(dir.join(track_filename("late", &late))).unwrap(); let bytes = std::fs::read(dir.join(track_filename("late", &late))).unwrap();
@@ -378,6 +726,28 @@ mod tests {
rec.end_cycle().unwrap(); rec.end_cycle().unwrap();
rec.finalize().unwrap(); rec.finalize().unwrap();
assert!(dir.join("me.wav").exists()); assert!(dir.join("me.wav").exists());
assert!(!dir.join("mix.wav").exists(), "no mix track in stems-only mode"); assert!(
!dir.join("mix.wav").exists(),
"no mix track in stems-only mode"
);
}
#[cfg(unix)]
#[test]
fn async_peer_create_error_surfaces_at_finalize() {
use std::os::unix::fs::PermissionsExt;
let dir = tmpdir("asyncerr");
let mut rec = MultitrackRecorder::create(&dir, 4, false).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
rec.add_peer(an_id(), "blocked").unwrap();
rec.end_cycle().unwrap();
let result = rec.finalize();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
let err = result.unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
let _ = std::fs::remove_dir_all(&dir);
} }
} }
File diff suppressed because it is too large Load Diff
+20 -5
View File
@@ -23,7 +23,10 @@ pub fn pan_gains(pan: f32) -> (f32, f32) {
/// still following the same equal-power curve as a peer is moved away from center. /// still following the same equal-power curve as a peer is moved away from center.
pub fn playback_pan_gains(pan: f32) -> (f32, f32) { pub fn playback_pan_gains(pan: f32) -> (f32, f32) {
let (left, right) = pan_gains(pan); let (left, right) = pan_gains(pan);
(left * std::f32::consts::SQRT_2, right * std::f32::consts::SQRT_2) (
left * std::f32::consts::SQRT_2,
right * std::f32::consts::SQRT_2,
)
} }
#[cfg(test)] #[cfg(test)]
@@ -36,8 +39,14 @@ mod tests {
fn hard_left_and_right_are_endpoints() { fn hard_left_and_right_are_endpoints() {
assert_eq!(pan_gains(-1.0), (1.0, 0.0)); assert_eq!(pan_gains(-1.0), (1.0, 0.0));
let (l, r) = pan_gains(1.0); let (l, r) = pan_gains(1.0);
assert!(l.abs() < EPS, "left at hard-right should be zero-ish, got {l}"); assert!(
assert!((r - 1.0).abs() < EPS, "right at hard-right should be one, got {r}"); l.abs() < EPS,
"left at hard-right should be zero-ish, got {l}"
);
assert!(
(r - 1.0).abs() < EPS,
"right at hard-right should be one, got {r}"
);
} }
#[test] #[test]
@@ -55,8 +64,14 @@ mod tests {
let mut prev_r = f32::NEG_INFINITY; let mut prev_r = f32::NEG_INFINITY;
for pan in pans { for pan in pans {
let (l, r) = pan_gains(pan); let (l, r) = pan_gains(pan);
assert!(l <= prev_l + EPS, "left gain must not rise as pan moves right"); assert!(
assert!(r >= prev_r - EPS, "right gain must not fall as pan moves right"); l <= prev_l + EPS,
"left gain must not rise as pan moves right"
);
assert!(
r >= prev_r - EPS,
"right gain must not fall as pan moves right"
);
prev_l = l; prev_l = l;
prev_r = r; prev_r = r;
} }
+141 -44
View File
@@ -1,13 +1,17 @@
use crate::audio::ownership;
use crate::audio::{AudioBackend, AudioError}; use crate::audio::{AudioBackend, AudioError};
use std::sync::mpsc::{Sender, Receiver, RecvTimeoutError};
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use pipewire as pw; use pipewire as pw;
use pw::{properties::properties, spa}; use pw::{properties::properties, spa};
use ringbuf::{
HeapRb,
traits::{Consumer, Producer, Split},
};
use spa::pod::Pod; use spa::pod::Pod;
use ringbuf::{HeapRb, traits::{Consumer, Producer, Split}}; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::Duration;
pub struct PipeWireBackend { pub struct PipeWireBackend {
capture_state: Mutex<Option<CaptureState>>, capture_state: Mutex<Option<CaptureState>>,
@@ -41,7 +45,11 @@ impl PipeWireBackend {
} }
impl AudioBackend for PipeWireBackend { impl AudioBackend for PipeWireBackend {
fn start_capture(&self, tx: Sender<Vec<i16>>, target_node: Option<String>) -> Result<(), AudioError> { fn start_capture(
&self,
tx: Sender<Vec<i16>>,
target_node: Option<String>,
) -> Result<(), AudioError> {
let mut capture_guard = self.capture_state.lock().unwrap(); let mut capture_guard = self.capture_state.lock().unwrap();
if capture_guard.is_some() { if capture_guard.is_some() {
return Err(AudioError::Stream("Capture already started".to_string())); return Err(AudioError::Stream("Capture already started".to_string()));
@@ -108,12 +116,17 @@ impl AudioBackend for PipeWireBackend {
} }
} }
fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_node: Option<String>) -> Result<(), AudioError> { fn run_capture(
let mainloop = pw::main_loop::MainLoopRc::new(None) cmd_rx: pw::channel::Receiver<()>,
.map_err(|e| AudioError::Init(e.to_string()))?; tx: Sender<Vec<i16>>,
target_node: Option<String>,
) -> Result<(), AudioError> {
let mainloop =
pw::main_loop::MainLoopRc::new(None).map_err(|e| AudioError::Init(e.to_string()))?;
let context = pw::context::ContextRc::new(&mainloop, None) let context = pw::context::ContextRc::new(&mainloop, None)
.map_err(|e| AudioError::Init(e.to_string()))?; .map_err(|e| AudioError::Init(e.to_string()))?;
let core = context.connect_rc(None) let core = context
.connect_rc(None)
.map_err(|e| AudioError::Init(e.to_string()))?; .map_err(|e| AudioError::Init(e.to_string()))?;
// Ring buffer setup: 9600 samples (200ms capacity for mono 48kHz) // Ring buffer setup: 9600 samples (200ms capacity for mono 48kHz)
@@ -181,15 +194,16 @@ fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_n
let mut params = [Pod::from_bytes(&values).unwrap()]; let mut params = [Pod::from_bytes(&values).unwrap()];
stream.connect( stream
spa::utils::Direction::Input, .connect(
None, spa::utils::Direction::Input,
pw::stream::StreamFlags::AUTOCONNECT None,
| pw::stream::StreamFlags::MAP_BUFFERS pw::stream::StreamFlags::AUTOCONNECT
| pw::stream::StreamFlags::RT_PROCESS, | pw::stream::StreamFlags::MAP_BUFFERS
&mut params, | pw::stream::StreamFlags::RT_PROCESS,
) &mut params,
.map_err(|e| AudioError::Stream(e.to_string()))?; )
.map_err(|e| AudioError::Stream(e.to_string()))?;
// Spawn the worker thread to pop from consumer and send Vec<i16> frames // Spawn the worker thread to pop from consumer and send Vec<i16> frames
let running = Arc::new(AtomicBool::new(true)); let running = Arc::new(AtomicBool::new(true));
@@ -257,11 +271,7 @@ const WORKER_POLL: Duration = Duration::from_millis(100);
/// every `WORKER_POLL` even when no frames arrive — this is what lets `stop()` /// every `WORKER_POLL` even when no frames arrive — this is what lets `stop()`
/// join the worker promptly instead of hanging on a parked blocking `recv()` /// join the worker promptly instead of hanging on a parked blocking `recv()`
/// (bug A7). Pure w.r.t. its inputs (no PipeWire), so it's unit-testable. /// (bug A7). Pure w.r.t. its inputs (no PipeWire), so it's unit-testable.
fn drain_loop( fn drain_loop(rx: &Receiver<Vec<i16>>, running: &AtomicBool, mut on_frame: impl FnMut(Vec<i16>)) {
rx: &Receiver<Vec<i16>>,
running: &AtomicBool,
mut on_frame: impl FnMut(Vec<i16>),
) {
while running.load(Ordering::Relaxed) { while running.load(Ordering::Relaxed) {
match rx.recv_timeout(WORKER_POLL) { match rx.recv_timeout(WORKER_POLL) {
Ok(frame) => on_frame(frame), Ok(frame) => on_frame(frame),
@@ -293,7 +303,11 @@ fn publish_frame<P: Producer<Item = i16>>(
fn frames_to_produce(requested: usize, mapped_frames: usize) -> usize { fn frames_to_produce(requested: usize, mapped_frames: usize) -> usize {
/// Safe per-cycle fallback when the graph doesn't report a quantum. /// Safe per-cycle fallback when the graph doesn't report a quantum.
const FALLBACK_FRAMES: usize = 1024; const FALLBACK_FRAMES: usize = 1024;
let want = if requested > 0 { requested } else { FALLBACK_FRAMES }; let want = if requested > 0 {
requested
} else {
FALLBACK_FRAMES
};
want.min(mapped_frames) want.min(mapped_frames)
} }
@@ -303,11 +317,12 @@ fn run_playback(
target_node: Option<String>, target_node: Option<String>,
fill_gauge: Arc<AtomicUsize>, fill_gauge: Arc<AtomicUsize>,
) -> Result<(), AudioError> { ) -> Result<(), AudioError> {
let mainloop = pw::main_loop::MainLoopRc::new(None) let mainloop =
.map_err(|e| AudioError::Init(e.to_string()))?; pw::main_loop::MainLoopRc::new(None).map_err(|e| AudioError::Init(e.to_string()))?;
let context = pw::context::ContextRc::new(&mainloop, None) let context = pw::context::ContextRc::new(&mainloop, None)
.map_err(|e| AudioError::Init(e.to_string()))?; .map_err(|e| AudioError::Init(e.to_string()))?;
let core = context.connect_rc(None) let core = context
.connect_rc(None)
.map_err(|e| AudioError::Init(e.to_string()))?; .map_err(|e| AudioError::Init(e.to_string()))?;
// Ring buffer setup: 19200 interleaved samples (200ms capacity for stereo // Ring buffer setup: 19200 interleaved samples (200ms capacity for stereo
@@ -357,6 +372,11 @@ fn run_playback(
mainloop_clone.quit(); mainloop_clone.quit();
}); });
// Ownership tag, both carriers (`crate::audio::ownership`, plan §5.1).
// This is the node that carries the far end's voice, so it is the single
// most important thing for pixelpass to refuse to fan out: sharing it
// would send the call back to the person already speaking on it.
let owned_node_name = ownership::owned_node_name(ownership::NATIVE_PLAYBACK_ROLE);
let mut props = properties! { let mut props = properties! {
*pw::keys::MEDIA_TYPE => "Audio", *pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Playback", *pw::keys::MEDIA_CATEGORY => "Playback",
@@ -365,6 +385,19 @@ fn run_playback(
// buffer — the real fix is the explicit Buffers param below — but it // buffer — the real fix is the explicit Buffers param below — but it
// expresses the intended quantum for any node that honours it. // expresses the intended quantum for any node that honours it.
*pw::keys::NODE_LATENCY => "1024/48000", *pw::keys::NODE_LATENCY => "1024/48000",
ownership::OWNED_PROP_KEY => ownership::OWNED_PROP_VALUE,
// Set explicitly rather than relying on the stream name passed to
// `StreamBox::new` below: props win over that name, and this one has
// to be exact.
*pw::keys::NODE_NAME => owned_node_name.as_str(),
// Measured: this stream sets neither `application.name` nor a
// description, so a mixer falls back to `node.name` — which the line
// above just turned into an internal identifier. The plan's rule is
// that the ownership prefix must not reach `node.description`; a
// human label there is what keeps that rule's *intent* (mixers stay
// readable) true for our own stream, exactly as mpv's own
// description does for the spawned players.
*pw::keys::NODE_DESCRIPTION => "PeerSpeak",
}; };
if let Some(target) = target_node { if let Some(target) = target_node {
props.insert("node.target", target); props.insert("node.target", target);
@@ -428,7 +461,9 @@ fn run_playback(
} }
if starved > 0 { if starved > 0 {
// One wait-free atomic add per quantum — RT-safe. // One wait-free atomic add per quantum — RT-safe.
user_data.underrun_samples.fetch_add(starved, Ordering::Relaxed); user_data
.underrun_samples
.fetch_add(starved, Ordering::Relaxed);
} }
// Decrement the exact occupancy counter by the samples we // Decrement the exact occupancy counter by the samples we
// actually pulled (excluding underruns, which removed // actually pulled (excluding underruns, which removed
@@ -493,7 +528,11 @@ fn run_playback(
pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Int( pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Int(
pw::spa::utils::Choice( pw::spa::utils::Choice(
pw::spa::utils::ChoiceFlags::empty(), pw::spa::utils::ChoiceFlags::empty(),
pw::spa::utils::ChoiceEnum::Range { default: 8, min: 2, max: 64 }, pw::spa::utils::ChoiceEnum::Range {
default: 8,
min: 2,
max: 64,
},
), ),
)), )),
), ),
@@ -524,15 +563,16 @@ fn run_playback(
Pod::from_bytes(&buffers_values).unwrap(), Pod::from_bytes(&buffers_values).unwrap(),
]; ];
stream.connect( stream
spa::utils::Direction::Output, .connect(
None, spa::utils::Direction::Output,
pw::stream::StreamFlags::AUTOCONNECT None,
| pw::stream::StreamFlags::MAP_BUFFERS pw::stream::StreamFlags::AUTOCONNECT
| pw::stream::StreamFlags::RT_PROCESS, | pw::stream::StreamFlags::MAP_BUFFERS
&mut params, | pw::stream::StreamFlags::RT_PROCESS,
) &mut params,
.map_err(|e| AudioError::Stream(e.to_string()))?; )
.map_err(|e| AudioError::Stream(e.to_string()))?;
// Spawn a worker thread to read from rx and push to producer // Spawn a worker thread to read from rx and push to producer
let running = Arc::new(AtomicBool::new(true)); let running = Arc::new(AtomicBool::new(true));
@@ -607,12 +647,71 @@ fn run_playback(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{drain_loop, for_each_capture_sample, frames_to_produce, publish_frame}; use super::{drain_loop, for_each_capture_sample, frames_to_produce, publish_frame};
use ringbuf::{HeapRb, traits::{Consumer, Producer, Split}}; use ringbuf::{
HeapRb,
traits::{Consumer, Producer, Split},
};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use std::{sync::mpsc, thread}; use std::{sync::mpsc, thread};
/// Phase-1 exit gate, native-playback half (impl plan §3): the stream
/// that carries the far end's voice appears on the graph with **both**
/// ownership carriers, and still with the `Communication` media role.
///
/// The third and most important of the three tagged paths — this is the
/// node whose audio, if fanned out, would send the call back to whoever
/// is speaking on it.
///
/// Feeds silence, so the gate is inaudible. Live: needs PipeWire and
/// `pw-dump`. `cargo test --lib -- --ignored native_playback`
#[test]
#[ignore = "live: requires a running PipeWire daemon and pw-dump"]
fn native_playback_node_carries_both_ownership_carriers() {
use crate::audio::ownership::{self, live_test};
use crate::audio::{AudioBackend, PLAYBACK_TARGET_SAMPLES};
let backend = super::PipeWireBackend::new();
let (tx, rx) = mpsc::channel::<Vec<i16>>();
let ring_fill = Arc::new(AtomicUsize::new(0));
backend
.start_playback(rx, None, ring_fill.clone())
.expect("playback starts");
// Keep the ring fed so the node stays live for the whole poll; the
// stream is created on connect, but a starved one is not a fair test
// of what a real call looks like on the graph.
let feeder = thread::spawn(move || {
let silence = vec![0i16; 960 * 2];
for _ in 0..300 {
if ring_fill.load(Ordering::Relaxed) < PLAYBACK_TARGET_SAMPLES
&& tx.send(silence.clone()).is_err()
{
return;
}
thread::sleep(Duration::from_millis(20));
}
});
let prefix = live_test::expected_prefix(ownership::NATIVE_PLAYBACK_ROLE);
let found = live_test::poll_for_owned_node(&prefix, Duration::from_secs(5));
let _ = backend.stop();
let _ = feeder.join();
let (name, owned) =
found.unwrap_or_else(|| panic!("no live node named {prefix:?} appeared within 5s"));
assert!(
name.starts_with(ownership::OWNED_NODE_NAME_PREFIX),
"{name}"
);
assert_eq!(
owned.as_deref(),
Some(ownership::OWNED_PROP_VALUE),
"carrier 1 must be on the live node, not just carrier 2"
);
}
#[test] #[test]
fn requested_in_range_is_honored() { fn requested_in_range_is_honored() {
// The graph's requested quantum is produced verbatim when it fits. // The graph's requested quantum is produced verbatim when it fits.
@@ -647,9 +746,7 @@ mod tests {
#[test] #[test]
fn capture_size_larger_than_mapping_is_clamped() { fn capture_size_larger_than_mapping_is_clamped() {
let mut samples = Vec::new(); let mut samples = Vec::new();
for_each_capture_sample(&[1, 0, 2, 0, 3], usize::MAX, |sample| { for_each_capture_sample(&[1, 0, 2, 0, 3], usize::MAX, |sample| samples.push(sample));
samples.push(sample)
});
assert_eq!(samples, vec![1, 2]); assert_eq!(samples, vec![1, 2]);
} }
+31 -6
View File
@@ -16,11 +16,20 @@ pub fn enumerate_audio_devices() -> Vec<AudioDevice> {
/// Emits the in-progress node as an `AudioDevice` if it's a complete Audio/* /// Emits the in-progress node as an `AudioDevice` if it's a complete Audio/*
/// node, then resets the accumulators for the next block. Non-audio or /// node, then resets the accumulators for the next block. Non-audio or
/// incomplete blocks are dropped (but still reset). /// incomplete blocks are dropped (but still reset).
fn push_device(name: &mut String, desc: &mut String, class: &mut String, out: &mut Vec<AudioDevice>) { fn push_device(
name: &mut String,
desc: &mut String,
class: &mut String,
out: &mut Vec<AudioDevice>,
) {
if !name.is_empty() && class.starts_with("Audio/") { if !name.is_empty() && class.starts_with("Audio/") {
out.push(AudioDevice { out.push(AudioDevice {
name: name.clone(), name: name.clone(),
description: if desc.is_empty() { name.clone() } else { desc.clone() }, description: if desc.is_empty() {
name.clone()
} else {
desc.clone()
},
is_input: class == "Audio/Source", is_input: class == "Audio/Source",
}); });
} }
@@ -44,7 +53,12 @@ fn parse_pw_nodes(text: &str) -> Vec<AudioDevice> {
for line in text.lines() { for line in text.lines() {
let line = line.trim(); let line = line.trim();
if line.starts_with("id ") { if line.starts_with("id ") {
push_device(&mut current_name, &mut current_desc, &mut current_class, &mut devices); push_device(
&mut current_name,
&mut current_desc,
&mut current_class,
&mut devices,
);
} else if let Some(val) = line.strip_prefix("node.name = \"") { } else if let Some(val) = line.strip_prefix("node.name = \"") {
current_name = val.trim_end_matches('"').to_string(); current_name = val.trim_end_matches('"').to_string();
} else if let Some(val) = line.strip_prefix("node.description = \"") { } else if let Some(val) = line.strip_prefix("node.description = \"") {
@@ -53,7 +67,12 @@ fn parse_pw_nodes(text: &str) -> Vec<AudioDevice> {
current_class = val.trim_end_matches('"').to_string(); current_class = val.trim_end_matches('"').to_string();
} }
} }
push_device(&mut current_name, &mut current_desc, &mut current_class, &mut devices); push_device(
&mut current_name,
&mut current_desc,
&mut current_class,
&mut devices,
);
devices.sort_by(|a, b| a.description.cmp(&b.description)); devices.sort_by(|a, b| a.description.cmp(&b.description));
devices devices
@@ -108,8 +127,14 @@ mod tests {
fn source_is_input_sink_is_output() { fn source_is_input_sink_is_output() {
let devices = parse_pw_nodes(SAMPLE_NODES); let devices = parse_pw_nodes(SAMPLE_NODES);
// Find devices by name or description to verify is_input // Find devices by name or description to verify is_input
let mic = devices.iter().find(|d| d.name == "alsa_input.builtin").unwrap(); let mic = devices
let speakers = devices.iter().find(|d| d.name == "alsa_output.builtin").unwrap(); .iter()
.find(|d| d.name == "alsa_input.builtin")
.unwrap();
let speakers = devices
.iter()
.find(|d| d.name == "alsa_output.builtin")
.unwrap();
let bare = devices.iter().find(|d| d.name == "bare.sink").unwrap(); let bare = devices.iter().find(|d| d.name == "bare.sink").unwrap();
assert!(mic.is_input); assert!(mic.is_input);
+151 -43
View File
@@ -2,21 +2,26 @@
//! //!
//! Records the **full call as you experienced it**: the mixed incoming audio //! Records the **full call as you experienced it**: the mixed incoming audio
//! (everyone you hear) summed with your own transmitted mic, into a single mono //! (everyone you hear) summed with your own transmitted mic, into a single mono
//! WAV. Writing is driven by the playout mixer (one [`Recorder::write_frame`] //! WAV. Mixing/enqueue is driven by the playout mixer (one
//! per produced 20ms frame, paced by the hardware clock); your mic arrives //! [`Recorder::write_frame`] per produced 20ms frame, paced by the hardware
//! separately from the capture thread via [`Recorder::push_mic`] and is buffered //! clock), while disk writes happen on a dedicated writer thread; your mic
//! in a small FIFO so the two independently-clocked streams stay roughly aligned. //! arrives separately from the capture thread via [`Recorder::push_mic`] and is
//! buffered in a small FIFO so the two independently-clocked streams stay
//! roughly aligned.
//! Minor clock drift just slowly grows/shrinks that FIFO (capped, so the lag //! Minor clock drift just slowly grows/shrinks that FIFO (capped, so the lag
//! between your voice and the recording is bounded) — harmless for a voice //! between your voice and the recording is bounded) — harmless for a voice
//! recording, no realtime crackle concern. //! recording, no realtime crackle concern.
//! //!
//! No external crates: the WAV writer emits the 44-byte canonical header itself //! No external crates: the WAV writer emits the 44-byte canonical header itself
//! and patches the two size fields on [`Recorder::finalize`]. //! and patches the two size fields on the writer thread during
//! [`Recorder::finalize`].
use std::collections::VecDeque; use std::collections::VecDeque;
use std::fs::{File, OpenOptions}; use std::fs::{File, OpenOptions};
use std::io::{self, Seek, SeekFrom, Write}; use std::io::{self, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, SyncSender, TrySendError};
use std::thread::{self, JoinHandle};
/// Capture sample rate (mono, 48kHz, matching the rest of the audio path). /// Capture sample rate (mono, 48kHz, matching the rest of the audio path).
const SAMPLE_RATE: u32 = 48_000; const SAMPLE_RATE: u32 = 48_000;
@@ -25,6 +30,8 @@ const CHANNELS: u16 = 1;
const RIFF_DATA_OVERHEAD: u64 = 36; const RIFF_DATA_OVERHEAD: u64 = 36;
const MAX_RIFF_DATA_BYTES: u64 = u32::MAX as u64 - RIFF_DATA_OVERHEAD; const MAX_RIFF_DATA_BYTES: u64 = u32::MAX as u64 - RIFF_DATA_OVERHEAD;
const MAX_NAME_ATTEMPTS: usize = 1_000; const MAX_NAME_ATTEMPTS: usize = 1_000;
const WRITER_QUEUE_FRAMES: usize = 256;
const DROP_LOG_INTERVAL_FRAMES: u64 = 256;
/// Cap on buffered mic samples (~200ms). Bounds how far recording lag can drift /// Cap on buffered mic samples (~200ms). Bounds how far recording lag can drift
/// if the capture clock runs persistently faster than playout — past this we drop /// if the capture clock runs persistently faster than playout — past this we drop
@@ -118,13 +125,15 @@ impl WavWriter {
} }
} }
/// A live call recorder: a [`WavWriter`] plus a small mic FIFO that aligns your /// A live call recorder: a writer-thread queue plus a small mic FIFO that aligns
/// transmitted mic with the playout mixer's incoming-mix frames. /// your transmitted mic with the playout mixer's incoming-mix frames.
pub struct Recorder { pub struct Recorder {
writer: WavWriter, frame_tx: SyncSender<Vec<i16>>,
writer_thread: JoinHandle<io::Result<()>>,
/// Your transmitted mic samples, awaiting alignment with the next mix frame. /// Your transmitted mic samples, awaiting alignment with the next mix frame.
mic_fifo: VecDeque<i16>, mic_fifo: VecDeque<i16>,
path: PathBuf, path: PathBuf,
dropped_frames: u64,
} }
impl Recorder { impl Recorder {
@@ -142,10 +151,15 @@ impl Recorder {
let path = dir.join(name); let path = dir.join(name);
match OpenOptions::new().write(true).create_new(true).open(&path) { match OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(file) => { Ok(file) => {
let writer = WavWriter::from_file(file)?;
let (frame_tx, frame_rx) = mpsc::sync_channel(WRITER_QUEUE_FRAMES);
let writer_thread = thread::spawn(move || writer_thread_main(writer, frame_rx));
return Ok(Self { return Ok(Self {
writer: WavWriter::from_file(file)?, frame_tx,
writer_thread,
mic_fifo: VecDeque::new(), mic_fifo: VecDeque::new(),
path, path,
dropped_frames: 0,
}); });
} }
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
@@ -179,21 +193,73 @@ impl Recorder {
/// treated as silence (you weren't transmitting), so quiet stretches record /// treated as silence (you weren't transmitting), so quiet stretches record
/// the incoming mix alone. /// the incoming mix alone.
pub fn write_frame(&mut self, mixed: &[i16]) -> io::Result<()> { pub fn write_frame(&mut self, mixed: &[i16]) -> io::Result<()> {
let mut out = Vec::with_capacity(mixed.len()); let out = mix_with_mic(mixed, &mut self.mic_fifo);
for &m in mixed { match self.frame_tx.try_send(out) {
let mic = self.mic_fifo.pop_front().unwrap_or(0); Ok(()) => Ok(()),
let sum = (m as i32 + mic as i32).clamp(i16::MIN as i32, i16::MAX as i32); Err(TrySendError::Full(_)) => {
out.push(sum as i16); self.dropped_frames = self.dropped_frames.saturating_add(1);
if self.dropped_frames == 1
|| self.dropped_frames.is_multiple_of(DROP_LOG_INTERVAL_FRAMES)
{
crate::log_msg(&format!(
"recording: writer queue full; dropped {} frame(s)",
self.dropped_frames
));
}
Ok(())
}
Err(TrySendError::Disconnected(_)) => Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"recording writer thread stopped",
)),
} }
self.writer.write_samples(&out)
} }
/// Finish the file, patching its size fields. Consumes the recorder. /// Finish the file, patching its size fields. Consumes the recorder.
pub fn finalize(self) -> io::Result<()> { pub fn finalize(self) -> io::Result<()> {
self.writer.finalize() let Self {
frame_tx,
writer_thread,
mic_fifo: _,
path: _,
dropped_frames: _,
} = self;
drop(frame_tx);
writer_thread
.join()
.unwrap_or_else(|_| Err(io::Error::other("recording writer thread panicked")))
} }
} }
fn writer_thread_main(mut writer: WavWriter, frame_rx: mpsc::Receiver<Vec<i16>>) -> io::Result<()> {
let mut first_write_error = None;
for frame in frame_rx {
if first_write_error.is_none()
&& let Err(e) = writer.write_samples(&frame)
{
first_write_error = Some(e);
}
}
let finalize_result = writer.finalize();
if let Some(e) = first_write_error {
Err(e)
} else {
finalize_result
}
}
fn mix_with_mic(mixed: &[i16], mic_fifo: &mut VecDeque<i16>) -> Vec<i16> {
let mut out = Vec::with_capacity(mixed.len());
for &m in mixed {
let mic = mic_fifo.pop_front().unwrap_or(0);
let sum = (m as i32 + mic as i32).clamp(i16::MIN as i32, i16::MAX as i32);
out.push(sum as i16);
}
out
}
/// Civil date (year, month, day) from a count of days since the Unix epoch. /// Civil date (year, month, day) from a count of days since the Unix epoch.
/// Howard Hinnant's `civil_from_days`; valid across the whole practical range. /// Howard Hinnant's `civil_from_days`; valid across the whole practical range.
fn civil_from_days(z: i64) -> (i64, u32, u32) { fn civil_from_days(z: i64) -> (i64, u32, u32) {
@@ -222,6 +288,23 @@ pub fn timestamp_filename(unix_secs: u64) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0);
fn unique_temp_dir(prefix: &str) -> PathBuf {
let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("{prefix}-{}-{id}", std::process::id()))
}
fn read_wav_samples(path: &Path) -> (Vec<u8>, Vec<i16>) {
let bytes = std::fs::read(path).unwrap();
let samples = bytes[44..]
.chunks_exact(2)
.map(|sample| i16::from_le_bytes([sample[0], sample[1]]))
.collect();
(bytes, samples)
}
#[test] #[test]
fn timestamp_filename_is_utc_and_padded() { fn timestamp_filename_is_utc_and_padded() {
@@ -236,10 +319,7 @@ mod tests {
#[test] #[test]
fn same_second_recordings_get_unique_files_without_truncation() { fn same_second_recordings_get_unique_files_without_truncation() {
let dir = std::env::temp_dir().join(format!( let dir = unique_temp_dir("peerspeak-collision");
"peerspeak-collision-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap(); std::fs::create_dir_all(&dir).unwrap();
@@ -258,6 +338,40 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
#[test]
fn recorder_thread_writes_mixed_samples_and_header_on_finalize() {
let dir = unique_temp_dir("peerspeak-recorder-thread");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut recorder = Recorder::create(&dir, 1_700_000_123).unwrap();
let path = recorder.path().to_path_buf();
recorder.push_mic(&[1000, i16::MAX, -1000, i16::MIN, 2222]);
recorder.write_frame(&[10, 20, -32700]).unwrap();
recorder.push_mic(&[300, -300]);
recorder
.write_frame(&[0, 1000, i16::MAX, i16::MIN])
.unwrap();
recorder.finalize().unwrap();
let expected = vec![1010, i16::MAX, i16::MIN, i16::MIN, 3222, i16::MAX, i16::MIN];
let expected_data_bytes = u32::try_from(expected.len() * 2).unwrap();
let (bytes, samples) = read_wav_samples(&path);
assert_eq!(&bytes[0..4], b"RIFF");
assert_eq!(&bytes[8..12], b"WAVE");
assert_eq!(&bytes[36..40], b"data");
let riff = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
let data = u32::from_le_bytes([bytes[40], bytes[41], bytes[42], bytes[43]]);
assert_eq!(data, expected_data_bytes);
assert_eq!(riff, RIFF_DATA_OVERHEAD as u32 + expected_data_bytes);
assert_eq!(bytes.len(), 44 + expected.len() * 2);
assert_eq!(samples, expected);
let _ = std::fs::remove_dir_all(&dir);
}
#[test] #[test]
fn wav_header_round_trips_sizes() { fn wav_header_round_trips_sizes() {
let dir = std::env::temp_dir(); let dir = std::env::temp_dir();
@@ -300,38 +414,32 @@ mod tests {
#[test] #[test]
fn mic_is_summed_with_mix_when_present() { fn mic_is_summed_with_mix_when_present() {
let dir = std::env::temp_dir(); let mut mic_fifo = VecDeque::from([1000, 2000, 3000]);
let mut r = Recorder {
writer: WavWriter::new(&dir.join(format!("ps-sum-{}.wav", std::process::id()))) let first = mix_with_mic(&[10, 20], &mut mic_fifo);
.unwrap(), assert_eq!(first, vec![1010, 2020]);
mic_fifo: VecDeque::new(), assert_eq!(mic_fifo.len(), 1, "two samples consumed, one mic left");
path: PathBuf::new(),
}; let second = mix_with_mic(&[0, 0], &mut mic_fifo);
r.push_mic(&[1000, 2000, 3000]); assert_eq!(second, vec![3000, 0]);
// write_frame pops mic per-sample and sums; we can't read the file mid-stream,
// so assert the FIFO drains exactly by frame length.
r.write_frame(&[10, 20]).unwrap();
assert_eq!(r.mic_fifo.len(), 1, "two samples consumed, one mic left");
r.write_frame(&[0, 0]).unwrap();
assert_eq!( assert_eq!(
r.mic_fifo.len(), mic_fifo.len(),
0, 0,
"remaining mic sample consumed; rest is silence" "remaining mic sample consumed; rest is silence"
); );
let _ = r.finalize();
} }
#[test] #[test]
fn mic_fifo_is_capped() { fn mic_fifo_is_capped() {
let dir = std::env::temp_dir(); let dir = unique_temp_dir("peerspeak-cap");
let mut r = Recorder { let _ = std::fs::remove_dir_all(&dir);
writer: WavWriter::new(&dir.join(format!("ps-cap-{}.wav", std::process::id()))) std::fs::create_dir_all(&dir).unwrap();
.unwrap(),
mic_fifo: VecDeque::new(), let mut r = Recorder::create(&dir, 1_700_000_001).unwrap();
path: PathBuf::new(),
};
r.push_mic(&vec![5i16; MAX_MIC_FIFO * 2]); r.push_mic(&vec![5i16; MAX_MIC_FIFO * 2]);
assert_eq!(r.mic_fifo.len(), MAX_MIC_FIFO, "FIFO is bounded to the cap"); assert_eq!(r.mic_fifo.len(), MAX_MIC_FIFO, "FIFO is bounded to the cap");
let _ = r.finalize(); r.finalize().unwrap();
let _ = std::fs::remove_dir_all(&dir);
} }
} }
+8 -2
View File
@@ -145,7 +145,10 @@ impl StereoPullResampler {
self.frac -= 1.0; self.frac -= 1.0;
} }
let f = self.frac as f32; let f = self.frac as f32;
let out = (lerp(self.prev.0, self.cur.0, f), lerp(self.prev.1, self.cur.1, f)); let out = (
lerp(self.prev.0, self.cur.0, f),
lerp(self.prev.1, self.cur.1, f),
);
self.frac += self.step; self.frac += self.step;
Some(out) Some(out)
} }
@@ -273,7 +276,10 @@ mod tests {
} }
} }
// At step 2.0 we consume ~2 input frames per output frame. // At step 2.0 we consume ~2 input frames per output frame.
assert!(idx > emitted, "consumed {idx} input, emitted {emitted} output"); assert!(
idx > emitted,
"consumed {idx} input, emitted {emitted} output"
);
} }
/// A zero rate must not produce a zero `step` (which would spin `push`'s inner /// A zero rate must not produce a zero `step` (which would spin `push`'s inner
+12 -3
View File
@@ -206,7 +206,10 @@ pub struct ByteLru<V> {
impl<V: Clone> ByteLru<V> { impl<V: Clone> ByteLru<V> {
/// Create an LRU holding at most `cap` entries (`cap` is clamped to >= 1). /// Create an LRU holding at most `cap` entries (`cap` is clamped to >= 1).
pub fn new(cap: usize) -> Self { pub fn new(cap: usize) -> Self {
Self { cap: cap.max(1), entries: Vec::new() } Self {
cap: cap.max(1),
entries: Vec::new(),
}
} }
/// Return the cached value for these exact `bytes`, building and inserting it /// Return the cached value for these exact `bytes`, building and inserting it
@@ -349,7 +352,10 @@ mod tests {
fn preset_png_in_range_and_out_of_range() { fn preset_png_in_range_and_out_of_range() {
// Every declared preset index resolves to embedded bytes. // Every declared preset index resolves to embedded bytes.
for i in 0..PRESET_COUNT { for i in 0..PRESET_COUNT {
assert!(Avatar::Preset(i).preset_png().is_some(), "preset {i} missing"); assert!(
Avatar::Preset(i).preset_png().is_some(),
"preset {i} missing"
);
} }
// Out-of-range index gracefully yields None (→ monogram fallback). // Out-of-range index gracefully yields None (→ monogram fallback).
assert!(Avatar::Preset(PRESET_COUNT).preset_png().is_none()); assert!(Avatar::Preset(PRESET_COUNT).preset_png().is_none());
@@ -406,7 +412,10 @@ mod tests {
#[test] #[test]
fn sanitize_incoming_rejects_junk_and_oversize() { fn sanitize_incoming_rejects_junk_and_oversize() {
// Not valid base64 / not a PNG → downgraded to monogram. // Not valid base64 / not a PNG → downgraded to monogram.
assert_eq!(Avatar::Custom("not base64!!!".into()).sanitize_incoming(), Avatar::Monogram); assert_eq!(
Avatar::Custom("not base64!!!".into()).sanitize_incoming(),
Avatar::Monogram
);
// Over the byte cap → downgraded without even decoding. // Over the byte cap → downgraded without even decoding.
let huge = Avatar::Custom("A".repeat(CUSTOM_MAX_B64 + 1)); let huge = Avatar::Custom("A".repeat(CUSTOM_MAX_B64 + 1));
assert_eq!(huge.sanitize_incoming(), Avatar::Monogram); assert_eq!(huge.sanitize_incoming(), Avatar::Monogram);
+4 -1
View File
@@ -66,7 +66,10 @@ pub fn game_background_filename(game_id: &str) -> String {
/// recedes the image so body text and panel chrome stay readable, and it re-tints /// recedes the image so body text and panel chrome stay readable, and it re-tints
/// per theme since `base` comes from the active palette. /// per theme since `base` comes from the active palette.
pub fn scrim_color(base: Color, dim: f32) -> Color { pub fn scrim_color(base: Color, dim: f32) -> Color {
Color { a: dim.clamp(0.0, 1.0), ..base } Color {
a: dim.clamp(0.0, 1.0),
..base
}
} }
#[cfg(test)] #[cfg(test)]
+58 -12
View File
@@ -105,7 +105,11 @@ fn cmd_gen(args: &[String]) -> Result<(), String> {
"pink" => generators::pink_noise(amp, len, seed), "pink" => generators::pink_noise(amp, len, seed),
"impulse" => generators::impulse(amp, len), "impulse" => generators::impulse(amp, len),
"silence" => generators::silence(len), "silence" => generators::silence(len),
other => return Err(format!("unknown kind {other:?} (sine sweep white pink impulse silence)")), other => {
return Err(format!(
"unknown kind {other:?} (sine sweep white pink impulse silence)"
));
}
}; };
wav::write(Path::new(out), &samples, SAMPLE_RATE)?; wav::write(Path::new(out), &samples, SAMPLE_RATE)?;
@@ -125,8 +129,12 @@ fn cmd_gen(args: &[String]) -> Result<(), String> {
/// in which frequency range any residual lives. /// in which frequency range any residual lives.
fn cmd_erle(args: &[String]) -> Result<(), String> { fn cmd_erle(args: &[String]) -> Result<(), String> {
let (positional, flags) = parse_args(args); let (positional, flags) = parse_args(args);
let before = positional.first().ok_or("erle needs <before.wav> <after.wav>")?; let before = positional
let after = positional.get(1).ok_or("erle needs <before.wav> <after.wav>")?; .first()
.ok_or("erle needs <before.wav> <after.wav>")?;
let after = positional
.get(1)
.ok_or("erle needs <before.wav> <after.wav>")?;
let b = wav::read(Path::new(before))?; let b = wav::read(Path::new(before))?;
let a = wav::read(Path::new(after))?; let a = wav::read(Path::new(after))?;
@@ -239,17 +247,34 @@ fn cmd_aec(args: &[String]) -> Result<(), String> {
1000.0 * tail as f32 / sr as f32, 1000.0 * tail as f32 / sr as f32,
metrics::dbfs(atten), metrics::dbfs(atten),
); );
println!(" filter: {taps} taps, mu {mu}{}", if has_near { " (with near-end / double-talk)" } else { "" }); println!(
" filter: {taps} taps, mu {mu}{}",
if has_near {
" (with near-end / double-talk)"
} else {
""
}
);
if has_near { if has_near {
let dtd = if flags.present("no-dtd") { "off" } else { "on" }; let dtd = if flags.present("no-dtd") { "off" } else { "on" };
println!( println!(
" double-talk: detector {dtd}, threshold {dtd_threshold}, flagged {:.0}% of samples{}", " double-talk: detector {dtd}, threshold {dtd_threshold}, flagged {:.0}% of samples{}",
100.0 * canceller.double_talk_rate(), 100.0 * canceller.double_talk_rate(),
if onset > 0 { format!(", near-end onset {:.1}s", onset as f32 / sr as f32) } else { String::new() }, if onset > 0 {
format!(", near-end onset {:.1}s", onset as f32 / sr as f32)
} else {
String::new()
},
); );
} }
println!(" mic before: {:.1} dBFS rms", metrics::dbfs(metrics::rms(&mic))); println!(
println!(" residual echo after: {:.1} dBFS rms", metrics::dbfs(metrics::rms(&residual))); " mic before: {:.1} dBFS rms",
metrics::dbfs(metrics::rms(&mic))
);
println!(
" residual echo after: {:.1} dBFS rms",
metrics::dbfs(metrics::rms(&residual))
);
println!(" ERLE broadband: {broadband:+.1} dB"); println!(" ERLE broadband: {broadband:+.1} dB");
println!(" ERLE early/late: {early:+.1} -> {late:+.1} dB (rise = filter converging)"); println!(" ERLE early/late: {early:+.1} -> {late:+.1} dB (rise = filter converging)");
@@ -278,9 +303,21 @@ fn cmd_aec(args: &[String]) -> Result<(), String> {
} }
if flags.present("show") { if flags.present("show") {
println!("\n--- mic (echo present) ---"); println!("\n--- mic (echo present) ---");
print!("{}", render::render(&stft::analyze(&mic, sr, 2048, 512), &render::RenderOpts::default())); print!(
"{}",
render::render(
&stft::analyze(&mic, sr, 2048, 512),
&render::RenderOpts::default()
)
);
println!("\n--- cleaned (post-AEC) ---"); println!("\n--- cleaned (post-AEC) ---");
print!("{}", render::render(&stft::analyze(&cleaned, sr, 2048, 512), &render::RenderOpts::default())); print!(
"{}",
render::render(
&stft::analyze(&cleaned, sr, 2048, 512),
&render::RenderOpts::default()
)
);
} }
Ok(()) Ok(())
} }
@@ -339,13 +376,22 @@ impl Flags {
self.bools.iter().any(|b| b == key) || self.map.contains_key(key) self.bools.iter().any(|b| b == key) || self.map.contains_key(key)
} }
fn f32_or(&self, key: &str, default: f32) -> f32 { fn f32_or(&self, key: &str, default: f32) -> f32 {
self.map.get(key).and_then(|v| v.parse().ok()).unwrap_or(default) self.map
.get(key)
.and_then(|v| v.parse().ok())
.unwrap_or(default)
} }
fn usize_or(&self, key: &str, default: usize) -> usize { fn usize_or(&self, key: &str, default: usize) -> usize {
self.map.get(key).and_then(|v| v.parse().ok()).unwrap_or(default) self.map
.get(key)
.and_then(|v| v.parse().ok())
.unwrap_or(default)
} }
fn u64_or(&self, key: &str, default: u64) -> u64 { fn u64_or(&self, key: &str, default: u64) -> u64 {
self.map.get(key).and_then(|v| v.parse().ok()).unwrap_or(default) self.map
.get(key)
.and_then(|v| v.parse().ok())
.unwrap_or(default)
} }
} }
+14 -7
View File
@@ -1,9 +1,6 @@
use peerspeak::network::{
gossip::IrohGossipState,
RoomState, PeerState,
};
use iroh::{Endpoint, endpoint::presets}; use iroh::{Endpoint, endpoint::presets};
use iroh_gossip::net::Gossip; use iroh_gossip::net::Gossip;
use peerspeak::network::{PeerState, RoomState, gossip::IrohGossipState};
use tokio::time::{self, Duration}; use tokio::time::{self, Duration};
#[tokio::main] #[tokio::main]
@@ -18,7 +15,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.address_lookup(lookup_a.clone()) .address_lookup(lookup_a.clone())
.bind() .bind()
.await?; .await?;
endpoint_a.online().await; endpoint_a.online().await;
println!("Node A online. ID: {}", endpoint_a.id()); println!("Node A online. ID: {}", endpoint_a.id());
@@ -27,7 +24,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip_a.clone()) .accept(iroh_gossip::net::GOSSIP_ALPN, gossip_a.clone())
.spawn(); .spawn();
let room_a = IrohGossipState::new(endpoint_a.clone(), gossip_a.clone(), lookup_a.clone(), secret_a); let room_a = IrohGossipState::new(
endpoint_a.clone(),
gossip_a.clone(),
lookup_a.clone(),
secret_a,
);
// 2. Node B (Client) Setup // 2. Node B (Client) Setup
let lookup_b = iroh::address_lookup::memory::MemoryLookup::new(); let lookup_b = iroh::address_lookup::memory::MemoryLookup::new();
@@ -46,7 +48,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip_b.clone()) .accept(iroh_gossip::net::GOSSIP_ALPN, gossip_b.clone())
.spawn(); .spawn();
let room_b = IrohGossipState::new(endpoint_b.clone(), gossip_b.clone(), lookup_b.clone(), secret_b); let room_b = IrohGossipState::new(
endpoint_b.clone(),
gossip_b.clone(),
lookup_b.clone(),
secret_b,
);
// 3. Create room on Node A // 3. Create room on Node A
let topic_id = rand::random(); let topic_id = rand::random();
+3
View File
@@ -20,6 +20,9 @@ pub trait AudioDecoder: Send {
/// If `compressed` is `None` (or `Some(&[])`), it indicates packet loss, /// If `compressed` is `None` (or `Some(&[])`), it indicates packet loss,
/// enabling the decoder to perform packet loss concealment (PLC). /// enabling the decoder to perform packet loss concealment (PLC).
fn decode(&mut self, compressed: Option<&[u8]>) -> Result<Vec<i16>, CodecError>; fn decode(&mut self, compressed: Option<&[u8]>) -> Result<Vec<i16>, CodecError>;
/// Reconstructs the previous lost frame from the next packet's in-band FEC.
fn decode_fec(&mut self, next_payload: &[u8]) -> Result<Vec<i16>, CodecError>;
} }
pub mod opus_impl; pub mod opus_impl;
+182 -17
View File
@@ -1,5 +1,49 @@
use crate::codec::{AudioEncoder, AudioDecoder, CodecError}; use crate::codec::{AudioDecoder, AudioEncoder, CodecError};
use opus::{Encoder, Decoder, Application, Channels}; use crate::config::AudioProfile;
use opus::{Application, Bitrate, Channels, Decoder, Encoder};
/// Concrete libopus encoder settings derived from an [`AudioProfile`]. Plain
/// data, so the profile→params mapping ([`opus_params`]) stays a pure,
/// unit-testable function (W12).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OpusParams {
/// Target bitrate in bits/sec.
pub bitrate: i32,
/// Enable in-band forward error correction (loss redundancy in the bitstream).
pub inband_fec: bool,
/// Expected packet-loss percentage (0..=100); tunes how much FEC libopus adds.
pub packet_loss_perc: i32,
/// Discontinuous transmission: stop sending during silence to save bandwidth.
pub dtx: bool,
}
/// Map a named profile to concrete Opus parameters. Pure — the W12 testable seam.
///
/// `BadNetwork` deliberately runs a *lower* bitrate than `Balanced`: in-band FEC
/// redundancy is carried inside the same bitstream, so trimming the base bitrate
/// leaves headroom for the redundancy on a congested link.
pub fn opus_params(profile: AudioProfile) -> OpusParams {
match profile {
AudioProfile::LowLatency => OpusParams {
bitrate: 24_000,
inband_fec: false,
packet_loss_perc: 0,
dtx: false,
},
AudioProfile::Balanced => OpusParams {
bitrate: 32_000,
inband_fec: true,
packet_loss_perc: 10,
dtx: false,
},
AudioProfile::BadNetwork => OpusParams {
bitrate: 20_000,
inband_fec: true,
packet_loss_perc: 25,
dtx: false,
},
}
}
pub struct OpusEncoder { pub struct OpusEncoder {
encoder: Encoder, encoder: Encoder,
@@ -8,11 +52,38 @@ pub struct OpusEncoder {
impl OpusEncoder { impl OpusEncoder {
/// Creates a new Opus encoder. /// Creates a new Opus encoder.
/// Standard voice parameters: sample_rate = 48000, channels = Channels::Mono, application = Application::Voip /// Standard voice parameters: sample_rate = 48000, channels = Channels::Mono, application = Application::Voip
pub fn new(sample_rate: u32, channels: Channels, application: Application) -> Result<Self, CodecError> { pub fn new(
sample_rate: u32,
channels: Channels,
application: Application,
) -> Result<Self, CodecError> {
let encoder = Encoder::new(sample_rate, channels, application) let encoder = Encoder::new(sample_rate, channels, application)
.map_err(|e| CodecError::Init(format!("Failed to create Opus encoder: {}", e)))?; .map_err(|e| CodecError::Init(format!("Failed to create Opus encoder: {}", e)))?;
Ok(Self { encoder }) Ok(Self { encoder })
} }
/// Apply concrete codec parameters to the live encoder. Safe to call between
/// frames, so the user can switch profile mid-call.
pub fn apply_params(&mut self, params: &OpusParams) -> Result<(), CodecError> {
self.encoder
.set_bitrate(Bitrate::Bits(params.bitrate))
.map_err(|e| CodecError::Init(format!("set_bitrate: {}", e)))?;
self.encoder
.set_inband_fec(params.inband_fec)
.map_err(|e| CodecError::Init(format!("set_inband_fec: {}", e)))?;
self.encoder
.set_packet_loss_perc(params.packet_loss_perc)
.map_err(|e| CodecError::Init(format!("set_packet_loss_perc: {}", e)))?;
self.encoder
.set_dtx(params.dtx)
.map_err(|e| CodecError::Init(format!("set_dtx: {}", e)))?;
Ok(())
}
/// Apply a named [`AudioProfile`] (shorthand for `apply_params(&opus_params(p))`).
pub fn apply_profile(&mut self, profile: AudioProfile) -> Result<(), CodecError> {
self.apply_params(&opus_params(profile))
}
} }
impl AudioEncoder for OpusEncoder { impl AudioEncoder for OpusEncoder {
@@ -20,9 +91,11 @@ impl AudioEncoder for OpusEncoder {
// We allocate a buffer for the compressed output. // We allocate a buffer for the compressed output.
// A maximum packet size of 4000 bytes is more than enough for a single voice frame. // A maximum packet size of 4000 bytes is more than enough for a single voice frame.
let mut compressed = vec![0u8; 4000]; let mut compressed = vec![0u8; 4000];
let len = self.encoder.encode(pcm, &mut compressed) let len = self
.encoder
.encode(pcm, &mut compressed)
.map_err(|e| CodecError::Encode(format!("Opus encoding failed: {}", e)))?; .map_err(|e| CodecError::Encode(format!("Opus encoding failed: {}", e)))?;
compressed.truncate(len); compressed.truncate(len);
Ok(compressed) Ok(compressed)
} }
@@ -42,10 +115,18 @@ impl OpusDecoder {
/// Creates a new Opus decoder. /// Creates a new Opus decoder.
/// Standard voice parameters: sample_rate = 48000, channels = Channels::Mono. /// Standard voice parameters: sample_rate = 48000, channels = Channels::Mono.
/// `frame_samples` is the per-channel length of one transmitted frame (e.g. 960). /// `frame_samples` is the per-channel length of one transmitted frame (e.g. 960).
pub fn new(sample_rate: u32, channels: Channels, frame_samples: usize) -> Result<Self, CodecError> { pub fn new(
sample_rate: u32,
channels: Channels,
frame_samples: usize,
) -> Result<Self, CodecError> {
let decoder = Decoder::new(sample_rate, channels) let decoder = Decoder::new(sample_rate, channels)
.map_err(|e| CodecError::Init(format!("Failed to create Opus decoder: {}", e)))?; .map_err(|e| CodecError::Init(format!("Failed to create Opus decoder: {}", e)))?;
Ok(Self { decoder, channels, frame_samples }) Ok(Self {
decoder,
channels,
frame_samples,
})
} }
fn channels_count(&self) -> usize { fn channels_count(&self) -> usize {
@@ -73,18 +154,72 @@ impl AudioDecoder for OpusDecoder {
} }
}; };
let decoded_per_channel = self.decoder.decode(input, &mut pcm, false) let decoded_per_channel = self
.decoder
.decode(input, &mut pcm, false)
.map_err(|e| CodecError::Decode(format!("Opus decoding failed: {}", e)))?; .map_err(|e| CodecError::Decode(format!("Opus decoding failed: {}", e)))?;
pcm.truncate(decoded_per_channel * channels_count); pcm.truncate(decoded_per_channel * channels_count);
Ok(pcm) Ok(pcm)
} }
fn decode_fec(&mut self, next_payload: &[u8]) -> Result<Vec<i16>, CodecError> {
let channels_count = self.channels_count();
let mut pcm = vec![0i16; self.frame_samples * channels_count];
let decoded_per_channel = self
.decoder
.decode(next_payload, &mut pcm, true)
.map_err(|e| CodecError::Decode(format!("Opus FEC decoding failed: {}", e)))?;
pcm.truncate(decoded_per_channel * channels_count);
Ok(pcm)
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn test_opus_params_mapping() {
let low = opus_params(AudioProfile::LowLatency);
let bal = opus_params(AudioProfile::Balanced);
let bad = opus_params(AudioProfile::BadNetwork);
// LowLatency has no loss redundancy; the other two do.
assert!(!low.inband_fec);
assert_eq!(low.packet_loss_perc, 0);
assert!(bal.inband_fec);
assert!(bad.inband_fec);
// Capture-side gating suppresses silence; no profile adds Opus DTX.
assert!(!low.dtx && !bal.dtx && !bad.dtx);
assert!(bad.packet_loss_perc > bal.packet_loss_perc);
// BadNetwork trims base bitrate to make room for FEC redundancy.
assert!(bad.bitrate < bal.bitrate);
// All bitrates are sane positive voice rates.
for p in [low, bal, bad] {
assert!(p.bitrate > 0 && p.bitrate <= 64_000);
assert!((0..=100).contains(&p.packet_loss_perc));
}
}
#[test]
fn test_apply_profile_sets_bitrate() {
let mut encoder = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
// Every profile applies cleanly to a real encoder...
for profile in AudioProfile::ALL {
encoder.apply_profile(profile).unwrap();
}
// ...and the last-applied bitrate is reflected by the encoder.
encoder.apply_profile(AudioProfile::Balanced).unwrap();
let want = opus_params(AudioProfile::Balanced).bitrate;
assert_eq!(encoder.encoder.get_bitrate().unwrap(), Bitrate::Bits(want));
}
#[test] #[test]
fn test_round_trip() { fn test_round_trip() {
let mut encoder = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap(); let mut encoder = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
@@ -100,7 +235,10 @@ mod tests {
// encode it // encode it
let compressed = encoder.encode(&pcm).unwrap(); let compressed = encoder.encode(&pcm).unwrap();
assert!(!compressed.is_empty(), "Compressed buffer should not be empty"); assert!(
!compressed.is_empty(),
"Compressed buffer should not be empty"
);
assert!( assert!(
compressed.len() < pcm.len() * std::mem::size_of::<i16>(), compressed.len() < pcm.len() * std::mem::size_of::<i16>(),
"Compressed size ({}) should be smaller than raw PCM size ({})", "Compressed size ({}) should be smaller than raw PCM size ({})",
@@ -110,13 +248,21 @@ mod tests {
// decode it // decode it
let decoded = decoder.decode(Some(&compressed)).unwrap(); let decoded = decoder.decode(Some(&compressed)).unwrap();
assert_eq!(decoded.len(), 960, "Decoded sample count should be exactly 960"); assert_eq!(
decoded.len(),
960,
"Decoded sample count should be exactly 960"
);
// 2. Round-trip carries signal energy (not silence) // 2. Round-trip carries signal energy (not silence)
let sum_sq: f64 = decoded.iter().map(|&x| (x as f64).powi(2)).sum(); let sum_sq: f64 = decoded.iter().map(|&x| (x as f64).powi(2)).sum();
let rms = (sum_sq / decoded.len() as f64).sqrt(); let rms = (sum_sq / decoded.len() as f64).sqrt();
// Since input had amplitude ~10000, let's verify RMS is significantly above 0 (e.g. > 100.0) // Since input had amplitude ~10000, let's verify RMS is significantly above 0 (e.g. > 100.0)
assert!(rms > 100.0, "Decoded signal should carry energy (RMS was {})", rms); assert!(
rms > 100.0,
"Decoded signal should carry energy (RMS was {})",
rms
);
} }
#[test] #[test]
@@ -125,11 +271,19 @@ mod tests {
// decode(None) returns exactly frame_samples (960) samples // decode(None) returns exactly frame_samples (960) samples
let plc_none = decoder.decode(None).unwrap(); let plc_none = decoder.decode(None).unwrap();
assert_eq!(plc_none.len(), 960, "decode(None) should yield exactly 960 samples"); assert_eq!(
plc_none.len(),
960,
"decode(None) should yield exactly 960 samples"
);
// decode(Some(&[])) (empty slice) does the same // decode(Some(&[])) (empty slice) does the same
let plc_empty = decoder.decode(Some(&[])).unwrap(); let plc_empty = decoder.decode(Some(&[])).unwrap();
assert_eq!(plc_empty.len(), 960, "decode(Some(&[])) should yield exactly 960 samples"); assert_eq!(
plc_empty.len(),
960,
"decode(Some(&[])) should yield exactly 960 samples"
);
} }
#[test] #[test]
@@ -140,7 +294,11 @@ mod tests {
let pcm = vec![0i16; 960]; let pcm = vec![0i16; 960];
let compressed = encoder.encode(&pcm).unwrap(); let compressed = encoder.encode(&pcm).unwrap();
let decoded = decoder.decode(Some(&compressed)).unwrap(); let decoded = decoder.decode(Some(&compressed)).unwrap();
assert_eq!(decoded.len(), 960, "Decoded sample count should match packet duration"); assert_eq!(
decoded.len(),
960,
"Decoded sample count should match packet duration"
);
} }
#[test] #[test]
@@ -149,11 +307,18 @@ mod tests {
// decode(None) returns exactly frame_samples * 2 (1920) samples // decode(None) returns exactly frame_samples * 2 (1920) samples
let plc_none = decoder.decode(None).unwrap(); let plc_none = decoder.decode(None).unwrap();
assert_eq!(plc_none.len(), 960 * 2, "Stereo decode(None) should yield exactly 1920 samples"); assert_eq!(
plc_none.len(),
960 * 2,
"Stereo decode(None) should yield exactly 1920 samples"
);
// decode(Some(&[])) (empty slice) does the same // decode(Some(&[])) (empty slice) does the same
let plc_empty = decoder.decode(Some(&[])).unwrap(); let plc_empty = decoder.decode(Some(&[])).unwrap();
assert_eq!(plc_empty.len(), 960 * 2, "Stereo decode(Some(&[])) should yield exactly 1920 samples"); assert_eq!(
plc_empty.len(),
960 * 2,
"Stereo decode(Some(&[])) should yield exactly 1920 samples"
);
} }
} }
+523 -31
View File
@@ -1,9 +1,12 @@
use crate::notify::Sound; use crate::notify::Sound;
use crate::theme::AppTheme; use crate::theme::AppTheme;
use anyhow::Context;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
use std::fs; use std::fs;
use std::path::PathBuf; use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
/// Relay/discovery posture, trading connectivity against how much the n0 /// Relay/discovery posture, trading connectivity against how much the n0
/// infrastructure learns about you. See the network module for details. /// infrastructure learns about you. See the network module for details.
@@ -24,8 +27,11 @@ pub enum NetworkMode {
impl NetworkMode { impl NetworkMode {
/// All variants, for presentation in a picker. /// All variants, for presentation in a picker.
pub const ALL: [NetworkMode; 3] = pub const ALL: [NetworkMode; 3] = [
[NetworkMode::RelayNoDiscovery, NetworkMode::N0Full, NetworkMode::DirectOnly]; NetworkMode::RelayNoDiscovery,
NetworkMode::N0Full,
NetworkMode::DirectOnly,
];
} }
/// Arrangement of the in-call room screen, chosen via the layout picker. /// Arrangement of the in-call room screen, chosen via the layout picker.
@@ -42,8 +48,11 @@ pub enum RoomLayout {
impl RoomLayout { impl RoomLayout {
/// All variants, in picker display order. /// All variants, in picker display order.
pub const ALL: [RoomLayout; 3] = pub const ALL: [RoomLayout; 3] = [
[RoomLayout::ThreeColumn, RoomLayout::BottomDock, RoomLayout::Drawer]; RoomLayout::ThreeColumn,
RoomLayout::BottomDock,
RoomLayout::Drawer,
];
} }
/// What a call recording captures. `Mixed` is the original single-file behaviour; /// What a call recording captures. `Mixed` is the original single-file behaviour;
@@ -62,8 +71,11 @@ pub enum RecordingMode {
impl RecordingMode { impl RecordingMode {
/// All variants, in picker display order. /// All variants, in picker display order.
pub const ALL: [RecordingMode; 3] = pub const ALL: [RecordingMode; 3] = [
[RecordingMode::Mixed, RecordingMode::Multitrack, RecordingMode::Both]; RecordingMode::Mixed,
RecordingMode::Multitrack,
RecordingMode::Both,
];
/// True when this mode writes per-peer stem tracks (Multitrack or Both). /// True when this mode writes per-peer stem tracks (Multitrack or Both).
pub fn is_multitrack(self) -> bool { pub fn is_multitrack(self) -> bool {
@@ -81,6 +93,60 @@ impl std::fmt::Display for RecordingMode {
} }
} }
/// Named Opus encoder / network-resilience policy (W12). The user picks a
/// profile instead of raw codec knobs; the concrete libopus parameters live in
/// `codec::opus_impl::opus_params`. Applies live to the running encoder.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum AudioProfile {
/// Lowest mouth-to-ear delay: modest bitrate, no FEC redundancy. Best on a
/// clean LAN / low-loss link where added latency matters more than loss.
LowLatency,
/// Sensible default: voice bitrate with in-band FEC for light packet loss.
#[default]
Balanced,
/// Maximum resilience on a lossy/congested link: in-band FEC tuned for heavy
/// loss, at a lower bitrate to leave headroom for the redundancy.
BadNetwork,
}
impl AudioProfile {
/// All variants, in picker display order.
pub const ALL: [AudioProfile; 3] = [
AudioProfile::LowLatency,
AudioProfile::Balanced,
AudioProfile::BadNetwork,
];
/// Compact discriminant for handing the profile to the capture thread via an
/// atomic. Pairs with [`AudioProfile::from_u8`].
pub fn as_u8(self) -> u8 {
match self {
AudioProfile::LowLatency => 0,
AudioProfile::Balanced => 1,
AudioProfile::BadNetwork => 2,
}
}
/// Inverse of [`AudioProfile::as_u8`]; unknown values fall back to the default.
pub fn from_u8(v: u8) -> AudioProfile {
match v {
0 => AudioProfile::LowLatency,
2 => AudioProfile::BadNetwork,
_ => AudioProfile::Balanced,
}
}
}
impl std::fmt::Display for AudioProfile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
AudioProfile::LowLatency => "Low latency",
AudioProfile::Balanced => "Balanced",
AudioProfile::BadNetwork => "Bad network",
})
}
}
impl std::fmt::Display for RoomLayout { impl std::fmt::Display for RoomLayout {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self { f.write_str(match self {
@@ -102,6 +168,139 @@ impl std::fmt::Display for NetworkMode {
} }
} }
/// Pixelpass host quality preset for screen shares. `Auto` leaves pixelpass free
/// to choose from its bandwidth pre-flight; fixed presets are passed as CLI flags.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ShareQuality {
#[default]
Auto,
Low,
Medium,
High,
Source,
}
impl ShareQuality {
pub const ALL: [ShareQuality; 5] = [
ShareQuality::Auto,
ShareQuality::Low,
ShareQuality::Medium,
ShareQuality::High,
ShareQuality::Source,
];
}
impl std::fmt::Display for ShareQuality {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
ShareQuality::Auto => "Auto",
ShareQuality::Low => "Low",
ShareQuality::Medium => "Medium",
ShareQuality::High => "High",
ShareQuality::Source => "Source",
})
}
}
/// Preferred local player for watching a peer's screen share.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum SharePlayer {
#[default]
Mpv,
Vlc,
}
impl SharePlayer {
pub const ALL: [SharePlayer; 2] = [SharePlayer::Mpv, SharePlayer::Vlc];
}
impl std::fmt::Display for SharePlayer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
SharePlayer::Mpv => "mpv",
SharePlayer::Vlc => "VLC",
})
}
}
/// Local player buffering posture for screen-share playback.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ShareBuffering {
#[default]
LowLatency,
Smooth,
}
impl ShareBuffering {
pub const ALL: [ShareBuffering; 2] = [ShareBuffering::LowLatency, ShareBuffering::Smooth];
}
impl std::fmt::Display for ShareBuffering {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
ShareBuffering::LowLatency => "Low latency",
ShareBuffering::Smooth => "Smooth",
})
}
}
fn default_screen_share_cache_mb() -> u32 {
2
}
/// Local-only screen-share preferences. Host fields become pixelpass host CLI
/// flags; viewer fields shape local mpv/VLC launch. None/empty/default values
/// deliberately let pixelpass/player defaults stand.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScreenShareSettings {
#[serde(default)]
pub quality: ShareQuality,
#[serde(default)]
pub bitrate_mbps: Option<u32>,
#[serde(default)]
pub framerate: Option<u32>,
#[serde(default)]
pub max_height: Option<u32>,
#[serde(default)]
pub max_viewers: Option<u32>,
#[serde(default)]
pub force_software_encode: bool,
#[serde(default)]
pub extra_host_args: String,
#[serde(default)]
pub player: SharePlayer,
#[serde(default)]
pub hardware_decode: bool,
#[serde(default)]
pub buffering: ShareBuffering,
#[serde(default = "default_screen_share_cache_mb")]
pub cache_mb: u32,
#[serde(default)]
pub extra_mpv_args: String,
}
impl Default for ScreenShareSettings {
fn default() -> Self {
Self {
quality: ShareQuality::default(),
bitrate_mbps: None,
framerate: None,
max_height: None,
max_viewers: None,
force_software_encode: false,
extra_host_args: String::new(),
player: SharePlayer::default(),
hardware_decode: false,
buffering: ShareBuffering::default(),
cache_mb: default_screen_share_cache_mb(),
extra_mpv_args: String::new(),
}
}
}
fn default_true() -> bool { fn default_true() -> bool {
true true
} }
@@ -187,6 +386,10 @@ pub struct AppConfig {
pub clip_volume_universal: bool, pub clip_volume_universal: bool,
#[serde(default)] #[serde(default)]
pub network_mode: NetworkMode, pub network_mode: NetworkMode,
/// Opus encoder / network-resilience profile (W12). Applies live to the
/// running encoder; default `Balanced`.
#[serde(default)]
pub audio_profile: AudioProfile,
/// Presence posture for the friends idle listener (W7): invisible / normal / /// Presence posture for the friends idle listener (W7): invisible / normal /
/// discoverable. Default `Normal` = answer friends only, no DNS beacon. /// discoverable. Default `Normal` = answer friends only, no DNS beacon.
#[serde(default)] #[serde(default)]
@@ -275,6 +478,14 @@ pub struct AppConfig {
pub custom_sound_mic_toggle: Option<String>, pub custom_sound_mic_toggle: Option<String>,
#[serde(default)] #[serde(default)]
pub custom_sound_reconnect_failed: Option<String>, pub custom_sound_reconnect_failed: Option<String>,
#[serde(default)]
pub custom_sound_chat_sent: Option<String>,
#[serde(default)]
pub custom_sound_chat_received: Option<String>,
#[serde(default)]
pub custom_sound_contact_online: Option<String>,
#[serde(default)]
pub custom_sound_contact_offline: Option<String>,
/// Per-sound enable flags (W6). The master `notifications_enabled` toggle /// Per-sound enable flags (W6). The master `notifications_enabled` toggle
/// gates ALL chimes; these let the user silence individual events while the /// gates ALL chimes; these let the user silence individual events while the
/// master stays on. A chime plays only if the master AND its flag are true. /// master stays on. A chime plays only if the master AND its flag are true.
@@ -295,10 +506,21 @@ pub struct AppConfig {
pub sound_mic_toggle_enabled: bool, pub sound_mic_toggle_enabled: bool,
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub sound_reconnect_failed_enabled: bool, pub sound_reconnect_failed_enabled: bool,
#[serde(default = "default_true")]
pub sound_chat_sent_enabled: bool,
#[serde(default = "default_true")]
pub sound_chat_received_enabled: bool,
#[serde(default = "default_true")]
pub sound_contact_online_enabled: bool,
#[serde(default = "default_true")]
pub sound_contact_offline_enabled: bool,
/// Optional override for the `pixelpass` binary location (screen share). /// Optional override for the `pixelpass` binary location (screen share).
/// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet. /// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet.
#[serde(default)] #[serde(default)]
pub pixelpass_path: Option<String>, pub pixelpass_path: Option<String>,
/// Local-only host/player controls for screen sharing.
#[serde(default)]
pub screen_share: ScreenShareSettings,
/// Recently-joined rooms (W7), most-recent-first. Purely local UI state for a /// Recently-joined rooms (W7), most-recent-first. Purely local UI state for a
/// one-click rejoin; never sent over the wire. De-duped by room topic and /// one-click rejoin; never sent over the wire. De-duped by room topic and
/// capped (see `recents`). Defaulted empty so older configs upgrade cleanly. /// capped (see `recents`). Defaulted empty so older configs upgrade cleanly.
@@ -345,6 +567,13 @@ pub struct AppConfig {
pub window_y: Option<i32>, pub window_y: Option<i32>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoadOutcome {
Missing,
Loaded,
Recovered,
}
impl Default for AppConfig { impl Default for AppConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -361,6 +590,7 @@ impl Default for AppConfig {
show_player_bar: true, show_player_bar: true,
clip_volume_universal: true, clip_volume_universal: true,
network_mode: NetworkMode::default(), network_mode: NetworkMode::default(),
audio_profile: AudioProfile::default(),
presence_mode: crate::presence::PresenceMode::default(), presence_mode: crate::presence::PresenceMode::default(),
echo_cancellation_enabled: false, echo_cancellation_enabled: false,
notifications_enabled: true, notifications_enabled: true,
@@ -387,6 +617,10 @@ impl Default for AppConfig {
custom_sound_self_leave: None, custom_sound_self_leave: None,
custom_sound_mic_toggle: None, custom_sound_mic_toggle: None,
custom_sound_reconnect_failed: None, custom_sound_reconnect_failed: None,
custom_sound_chat_sent: None,
custom_sound_chat_received: None,
custom_sound_contact_online: None,
custom_sound_contact_offline: None,
sound_self_join_enabled: true, sound_self_join_enabled: true,
sound_peer_join_enabled: true, sound_peer_join_enabled: true,
sound_peer_leave_enabled: true, sound_peer_leave_enabled: true,
@@ -395,7 +629,12 @@ impl Default for AppConfig {
sound_self_leave_enabled: true, sound_self_leave_enabled: true,
sound_mic_toggle_enabled: true, sound_mic_toggle_enabled: true,
sound_reconnect_failed_enabled: true, sound_reconnect_failed_enabled: true,
sound_chat_sent_enabled: true,
sound_chat_received_enabled: true,
sound_contact_online_enabled: true,
sound_contact_offline_enabled: true,
pixelpass_path: None, pixelpass_path: None,
screen_share: ScreenShareSettings::default(),
recents: Vec::new(), recents: Vec::new(),
peer_eq: HashMap::new(), peer_eq: HashMap::new(),
peer_pan: HashMap::new(), peer_pan: HashMap::new(),
@@ -424,6 +663,10 @@ impl AppConfig {
Sound::SelfLeave => self.sound_self_leave_enabled, Sound::SelfLeave => self.sound_self_leave_enabled,
Sound::MicToggle => self.sound_mic_toggle_enabled, Sound::MicToggle => self.sound_mic_toggle_enabled,
Sound::ReconnectFailed => self.sound_reconnect_failed_enabled, Sound::ReconnectFailed => self.sound_reconnect_failed_enabled,
Sound::ChatSent => self.sound_chat_sent_enabled,
Sound::ChatReceived => self.sound_chat_received_enabled,
Sound::ContactOnline => self.sound_contact_online_enabled,
Sound::ContactOffline => self.sound_contact_offline_enabled,
} }
} }
@@ -438,6 +681,10 @@ impl AppConfig {
Sound::SelfLeave => self.sound_self_leave_enabled = enabled, Sound::SelfLeave => self.sound_self_leave_enabled = enabled,
Sound::MicToggle => self.sound_mic_toggle_enabled = enabled, Sound::MicToggle => self.sound_mic_toggle_enabled = enabled,
Sound::ReconnectFailed => self.sound_reconnect_failed_enabled = enabled, Sound::ReconnectFailed => self.sound_reconnect_failed_enabled = enabled,
Sound::ChatSent => self.sound_chat_sent_enabled = enabled,
Sound::ChatReceived => self.sound_chat_received_enabled = enabled,
Sound::ContactOnline => self.sound_contact_online_enabled = enabled,
Sound::ContactOffline => self.sound_contact_offline_enabled = enabled,
} }
} }
@@ -475,24 +722,115 @@ impl AppConfig {
} }
pub fn load() -> Self { pub fn load() -> Self {
if let Some(path) = Self::config_path() let Some(path) = Self::config_path() else {
&& let Ok(contents) = fs::read_to_string(&path) return Self::default();
&& let Ok(config) = serde_json::from_str(&contents) { };
return config; let (config, _) = Self::load_from(&path);
} config
Self::default()
} }
pub fn save(&self) { pub fn save(&self) {
if let Some(path) = Self::config_path() { if let Some(path) = Self::config_path() {
if let Some(dir) = path.parent() { if let Err(e) = self.save_to(&path) {
let _ = fs::create_dir_all(dir); crate::log_msg(&format!("config: save failed: {e:#}"));
} }
if let Ok(json) = serde_json::to_string_pretty(self) { } else {
let _ = fs::write(path, json); crate::log_msg("config: save failed: could not determine a config directory");
}
}
pub fn load_from(path: &Path) -> (Self, LoadOutcome) {
match fs::read_to_string(path) {
Ok(contents) => match serde_json::from_str(&contents) {
Ok(config) => (config, LoadOutcome::Loaded),
Err(e) => {
let backup = recover_corrupt_config(path, &format!("failed to parse: {e}"));
(Self::default(), backup)
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
(Self::default(), LoadOutcome::Missing)
}
Err(e) => {
let backup = recover_corrupt_config(path, &format!("failed to read: {e}"));
(Self::default(), backup)
} }
} }
} }
pub fn save_to(&self, path: &Path) -> anyhow::Result<()> {
let parent = path
.parent()
.context("config path has no parent directory")?;
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
let json = serde_json::to_string_pretty(self).context("failed to encode config")?;
let tmp = config_tmp_path(path)?;
let result = (|| -> anyhow::Result<()> {
{
let mut f = fs::File::create(&tmp)
.with_context(|| format!("failed to create {}", tmp.display()))?;
f.write_all(json.as_bytes())
.with_context(|| format!("failed to write {}", tmp.display()))?;
f.sync_all().ok();
}
fs::rename(&tmp, path).with_context(|| {
format!("failed to rename {} -> {}", tmp.display(), path.display())
})?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(&tmp);
}
result
}
}
fn config_tmp_path(path: &Path) -> anyhow::Result<PathBuf> {
let parent = path
.parent()
.context("config path has no parent directory")?;
let mut name = path
.file_name()
.context("config path has no file name")?
.to_os_string();
name.push(format!(".tmp.{}", std::process::id()));
Ok(parent.join(name))
}
fn corrupt_backup_path(path: &Path) -> PathBuf {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let mut name = path
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_else(|| "config.json".into());
name.push(format!(".corrupt.{secs}"));
parent.join(name)
}
fn recover_corrupt_config(path: &Path, reason: &str) -> LoadOutcome {
let backup = corrupt_backup_path(path);
match fs::rename(path, &backup) {
Ok(()) => {
crate::log_msg(&format!(
"config: {reason}; moved damaged config to {}",
backup.display()
));
}
Err(e) => {
crate::log_msg(&format!(
"config: {reason}; failed to move damaged config to {}: {e}",
backup.display()
));
}
}
LoadOutcome::Recovered
} }
#[cfg(test)] #[cfg(test)]
@@ -507,14 +845,109 @@ mod tests {
assert_eq!(original, deserialized); assert_eq!(original, deserialized);
} }
fn temp_config_path(tag: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"peerspeak-configtest-{}-{}",
std::process::id(),
tag
));
p.push("config.json");
p
}
#[test]
fn save_to_then_load_from_round_trips() {
let path = temp_config_path("roundtrip");
let _ = fs::remove_dir_all(path.parent().unwrap());
let cfg = AppConfig {
username: "Ada".into(),
input_device: "mic".into(),
output_device: "speaker".into(),
noise_gate_threshold: 0.42,
..AppConfig::default()
};
cfg.save_to(&path).unwrap();
let (loaded, outcome) = AppConfig::load_from(&path);
assert_eq!(outcome, LoadOutcome::Loaded);
assert_eq!(loaded, cfg);
let _ = fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn load_from_missing_returns_default_without_corrupt_backup() {
let path = temp_config_path("missing");
let _ = fs::remove_dir_all(path.parent().unwrap());
let (loaded, outcome) = AppConfig::load_from(&path);
assert_eq!(outcome, LoadOutcome::Missing);
assert_eq!(loaded, AppConfig::default());
assert!(!path.parent().unwrap().exists());
}
#[test]
fn load_from_corrupt_file_preserves_original_bytes() {
let path = temp_config_path("corrupt");
let _ = fs::remove_dir_all(path.parent().unwrap());
fs::create_dir_all(path.parent().unwrap()).unwrap();
let corrupt = b"{ this is not json";
fs::write(&path, corrupt).unwrap();
let (loaded, outcome) = AppConfig::load_from(&path);
assert_eq!(outcome, LoadOutcome::Recovered);
assert_eq!(loaded, AppConfig::default());
assert_ne!(fs::read(&path).ok().as_deref(), Some(corrupt.as_slice()));
let backups: Vec<_> = fs::read_dir(path.parent().unwrap())
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|entry| {
entry
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("config.json.corrupt."))
})
.collect();
assert_eq!(backups.len(), 1, "expected one corrupt backup");
assert_eq!(fs::read(&backups[0]).unwrap(), corrupt);
let _ = fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn save_to_leaves_no_tmp_file_after_success() {
let path = temp_config_path("atomic");
let _ = fs::remove_dir_all(path.parent().unwrap());
AppConfig::default().save_to(&path).unwrap();
let tmp_files: Vec<_> = fs::read_dir(path.parent().unwrap())
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|entry| {
entry
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.contains(".tmp."))
})
.collect();
assert!(tmp_files.is_empty(), "leftover temp files: {tmp_files:?}");
let _ = fs::remove_dir_all(path.parent().unwrap());
}
#[test] #[test]
fn test_backward_compat_default_fill() { fn test_backward_compat_default_fill() {
let minimal_json = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#; let minimal_json = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
let deserialized: AppConfig = serde_json::from_str(minimal_json).unwrap(); let deserialized: AppConfig = serde_json::from_str(minimal_json).unwrap();
assert_eq!(deserialized.network_mode, NetworkMode::RelayNoDiscovery); assert_eq!(deserialized.network_mode, NetworkMode::RelayNoDiscovery);
// Configs predating the presence posture load as friends-only (no beacon). // Configs predating the presence posture load as friends-only (no beacon).
assert_eq!(deserialized.presence_mode, crate::presence::PresenceMode::Normal); assert_eq!(
deserialized.presence_mode,
crate::presence::PresenceMode::Normal
);
assert!(!deserialized.echo_cancellation_enabled); assert!(!deserialized.echo_cancellation_enabled);
assert!(deserialized.notifications_enabled); assert!(deserialized.notifications_enabled);
// Configs predating the volume sliders must load at unity gain. // Configs predating the volume sliders must load at unity gain.
@@ -539,10 +972,25 @@ mod tests {
assert!(deserialized.custom_sound_self_leave.is_none()); assert!(deserialized.custom_sound_self_leave.is_none());
assert!(deserialized.custom_sound_mic_toggle.is_none()); assert!(deserialized.custom_sound_mic_toggle.is_none());
assert!(deserialized.custom_sound_reconnect_failed.is_none()); assert!(deserialized.custom_sound_reconnect_failed.is_none());
assert!(deserialized.custom_sound_chat_sent.is_none());
assert!(deserialized.custom_sound_chat_received.is_none());
assert!(deserialized.custom_sound_contact_online.is_none());
assert!(deserialized.custom_sound_contact_offline.is_none());
assert_eq!(deserialized.screen_share, ScreenShareSettings::default());
assert_eq!(deserialized.screen_share.quality, ShareQuality::Auto);
assert_eq!(deserialized.screen_share.player, SharePlayer::Mpv);
assert_eq!(
deserialized.screen_share.buffering,
ShareBuffering::LowLatency
);
assert_eq!(deserialized.screen_share.cache_mb, 2);
// Configs predating the per-sound flags (W6) enable every chime, so an // Configs predating the per-sound flags (W6) enable every chime, so an
// upgrade is silent-change-free. // upgrade is silent-change-free.
for sound in Sound::ALL { for sound in Sound::ALL {
assert!(deserialized.sound_enabled(sound), "{sound:?} should default on"); assert!(
deserialized.sound_enabled(sound),
"{sound:?} should default on"
);
} }
// The accessor and mutator agree round-trip. // The accessor and mutator agree round-trip.
let mut cfg = AppConfig::default(); let mut cfg = AppConfig::default();
@@ -589,7 +1037,10 @@ mod tests {
}"#; }"#;
let cfg: AppConfig = serde_json::from_str(legacy_json).unwrap(); let cfg: AppConfig = serde_json::from_str(legacy_json).unwrap();
// The pre-existing single background survives untouched (still Option<String>). // The pre-existing single background survives untouched (still Option<String>).
assert_eq!(cfg.background.as_deref(), Some("/home/eric/.config/peerspeak/background.png")); assert_eq!(
cfg.background.as_deref(),
Some("/home/eric/.config/peerspeak/background.png")
);
assert!((cfg.background_dim - 0.4).abs() < f32::EPSILON); assert!((cfg.background_dim - 0.4).abs() < f32::EPSILON);
// The new game-detection fields default to off/empty → silent, opt-in upgrade. // The new game-detection fields default to off/empty → silent, opt-in upgrade.
assert!(!cfg.game_presence_enabled); assert!(!cfg.game_presence_enabled);
@@ -601,9 +1052,12 @@ mod tests {
fn test_game_maps_serialize_deterministically() { fn test_game_maps_serialize_deterministically() {
// BTreeMap ordering makes the serialized config stable across runs. // BTreeMap ordering makes the serialized config stable across runs.
let mut cfg = AppConfig::default(); let mut cfg = AppConfig::default();
cfg.game_backgrounds.insert("steam:730".into(), "/a.png".into()); cfg.game_backgrounds
cfg.game_backgrounds.insert("exe:hl2_linux".into(), "/b.png".into()); .insert("steam:730".into(), "/a.png".into());
cfg.game_process_map.insert("hl2_linux".into(), "Half-Life 2".into()); cfg.game_backgrounds
.insert("exe:hl2_linux".into(), "/b.png".into());
cfg.game_process_map
.insert("hl2_linux".into(), "Half-Life 2".into());
let json = serde_json::to_string(&cfg).unwrap(); let json = serde_json::to_string(&cfg).unwrap();
// Keys appear in sorted order (exe: before steam:). // Keys appear in sorted order (exe: before steam:).
let bg = json.find("game_backgrounds").unwrap(); let bg = json.find("game_backgrounds").unwrap();
@@ -661,8 +1115,7 @@ mod tests {
recording_mode: RecordingMode::Both, recording_mode: RecordingMode::Both,
..AppConfig::default() ..AppConfig::default()
}; };
let back: AppConfig = let back: AppConfig = serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
assert_eq!(back.recording_mode, RecordingMode::Both); assert_eq!(back.recording_mode, RecordingMode::Both);
// is_multitrack() classifies correctly. // is_multitrack() classifies correctly.
assert!(!RecordingMode::Mixed.is_multitrack()); assert!(!RecordingMode::Mixed.is_multitrack());
@@ -748,7 +1201,10 @@ mod tests {
assert_eq!(round_tripped.input_volume, 1.5); assert_eq!(round_tripped.input_volume, 1.5);
assert_eq!(round_tripped.output_volume, 0.25); assert_eq!(round_tripped.output_volume, 0.25);
assert_eq!(round_tripped.clip_volume, 0.7); assert_eq!(round_tripped.clip_volume, 0.7);
assert_eq!(round_tripped.music_playlist, vec!["/tmp/song.ogg".to_string()]); assert_eq!(
round_tripped.music_playlist,
vec!["/tmp/song.ogg".to_string()]
);
assert_eq!(round_tripped.music_volume, 0.6); assert_eq!(round_tripped.music_volume, 0.6);
assert!(round_tripped.music_broadcast); assert!(round_tripped.music_broadcast);
assert!(!round_tripped.show_player_bar); assert!(!round_tripped.show_player_bar);
@@ -757,7 +1213,8 @@ mod tests {
#[test] #[test]
fn test_notifications_enabled_specifically() { fn test_notifications_enabled_specifically() {
let missing_notifications = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#; let missing_notifications =
r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
let config_missing: AppConfig = serde_json::from_str(missing_notifications).unwrap(); let config_missing: AppConfig = serde_json::from_str(missing_notifications).unwrap();
assert!(config_missing.notifications_enabled); assert!(config_missing.notifications_enabled);
@@ -802,6 +1259,38 @@ mod tests {
assert_ne!(display_0, display_2); assert_ne!(display_0, display_2);
} }
#[test]
fn test_audio_profile() {
// Default is Balanced.
assert_eq!(AudioProfile::default(), AudioProfile::Balanced);
// ALL holds the three variants.
assert_eq!(AudioProfile::ALL.len(), 3);
assert!(AudioProfile::ALL.contains(&AudioProfile::LowLatency));
assert!(AudioProfile::ALL.contains(&AudioProfile::Balanced));
assert!(AudioProfile::ALL.contains(&AudioProfile::BadNetwork));
// as_u8 / from_u8 round-trip every variant, and unknown bytes fall back
// to the default rather than panicking.
for p in AudioProfile::ALL {
assert_eq!(AudioProfile::from_u8(p.as_u8()), p);
}
assert_eq!(AudioProfile::from_u8(99), AudioProfile::Balanced);
// serde round-trips, and Display strings are non-empty + distinct.
let mut labels = Vec::new();
for p in AudioProfile::ALL {
let s = serde_json::to_string(&p).unwrap();
assert_eq!(serde_json::from_str::<AudioProfile>(&s).unwrap(), p);
let label = p.to_string();
assert!(!label.is_empty());
labels.push(label);
}
labels.sort();
labels.dedup();
assert_eq!(labels.len(), 3);
}
#[test] #[test]
fn test_unknown_field_tolerance() { fn test_unknown_field_tolerance() {
// Unknown/extra field tolerance: a config JSON containing an extra unrecognized key should still deserialize. // Unknown/extra field tolerance: a config JSON containing an extra unrecognized key should still deserialize.
@@ -813,11 +1302,14 @@ mod tests {
"unrecognized_field_xyz_123": "some_value" "unrecognized_field_xyz_123": "some_value"
}"#; }"#;
let deserialized_res: Result<AppConfig, _> = serde_json::from_str(json_with_extra); let deserialized_res: Result<AppConfig, _> = serde_json::from_str(json_with_extra);
// Assert that deserialization succeeds even with unrecognized/unknown fields. // Assert that deserialization succeeds even with unrecognized/unknown fields.
// This confirms that serde does not reject unknown fields (i.e. default behavior). // This confirms that serde does not reject unknown fields (i.e. default behavior).
assert!(deserialized_res.is_ok(), "Config deserialization failed when an unknown field was present"); assert!(
deserialized_res.is_ok(),
"Config deserialization failed when an unknown field was present"
);
let config = deserialized_res.unwrap(); let config = deserialized_res.unwrap();
assert_eq!(config.input_device, ""); assert_eq!(config.input_device, "");
assert_eq!(config.output_device, ""); assert_eq!(config.output_device, "");
+132
View File
@@ -0,0 +1,132 @@
//! Roster-bound chat identity (chat-hardening plan, Phase 2).
//!
//! The wire `GossipMessage::Chat` carries a sender-CLAIMED display name, which
//! any insider could set to another member's name. This map is the antidote:
//! the core event task records each authenticated member's latest sanitized
//! presence name here (from `PeerJoined`/`PeerUpdated`, the events that only
//! fire for a verified signed `Announce`), and chat renders under THAT name —
//! the embedded wire name is never displayed.
//!
//! Shared (`Arc<Mutex<…>>`) because eviction happens in two places: the event
//! task itself (graceful `PeerLeft`) and the detached reconnect-grace timer
//! (terminal eviction). A peer mid-reconnect-grace keeps its entry, so its
//! chat stays admitted until the grace actually expires.
use iroh::EndpointId;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
/// Bound on tracked names. Mirrors the gossip roster cap (`MAX_ACTIVE_PEERS`):
/// insertions only follow cap-gated roster admissions, so this is pure defense
/// in depth against that invariant breaking.
const CHAT_ROSTER_CAP: usize = 32;
/// The authoritative id → display-name map for the current room. Cheap to
/// clone; all clones share one map.
#[derive(Debug, Clone, Default)]
pub struct ChatRoster {
names: Arc<Mutex<HashMap<EndpointId, String>>>,
}
impl ChatRoster {
/// Record (or refresh) a member's display name. The name is re-sanitized
/// here (idempotent — gossip ingress already did) and an empty result falls
/// back to the short node id so a chat line is never label-less. A NEW id
/// is refused past the cap; updates to a present id always land.
pub fn upsert(&self, id: EndpointId, name: &str) {
let clean = crate::sanitize::sanitize_name(name);
let label = if clean.is_empty() {
crate::short_id(&id.to_string())
} else {
clean
};
let mut names = self.names.lock().unwrap();
if names.contains_key(&id) || names.len() < CHAT_ROSTER_CAP {
names.insert(id, label);
}
}
/// Drop a member on graceful leave or terminal (grace-expired) eviction.
pub fn remove(&self, id: &EndpointId) {
self.names.lock().unwrap().remove(id);
}
/// The roster-bound name for an id, or `None` if the author is not a
/// current member — the caller must then drop the chat entirely.
pub fn name_of(&self, id: &EndpointId) -> Option<String> {
self.names.lock().unwrap().get(id).cloned()
}
}
#[cfg(test)]
mod tests {
use super::*;
use iroh::SecretKey;
fn fresh_id() -> EndpointId {
SecretKey::generate().public()
}
#[test]
fn upsert_then_lookup_returns_sanitized_name() {
let roster = ChatRoster::default();
let a = fresh_id();
roster.upsert(a, "Alice");
assert_eq!(roster.name_of(&a), Some("Alice".to_string()));
// Bidi override / zero-width spoofing characters are stripped.
roster.upsert(a, "Al\u{202E}ice\u{200B}");
assert_eq!(roster.name_of(&a), Some("Alice".to_string()));
}
#[test]
fn name_update_affects_future_lookups() {
let roster = ChatRoster::default();
let a = fresh_id();
roster.upsert(a, "Alice");
roster.upsert(a, "Alice2");
assert_eq!(roster.name_of(&a), Some("Alice2".to_string()));
}
#[test]
fn unknown_author_has_no_name() {
let roster = ChatRoster::default();
roster.upsert(fresh_id(), "Alice");
assert_eq!(roster.name_of(&fresh_id()), None);
}
#[test]
fn removed_author_is_no_longer_a_member() {
let roster = ChatRoster::default();
let a = fresh_id();
roster.upsert(a, "Alice");
roster.remove(&a);
assert_eq!(roster.name_of(&a), None);
}
#[test]
fn empty_sanitized_name_falls_back_to_short_id() {
let roster = ChatRoster::default();
let a = fresh_id();
roster.upsert(a, "\u{0}\r\n\t ");
let label = roster.name_of(&a).unwrap();
assert!(!label.is_empty());
assert_eq!(label, crate::short_id(&a.to_string()));
}
#[test]
fn new_ids_are_refused_past_the_cap_but_updates_land() {
let roster = ChatRoster::default();
let first = fresh_id();
roster.upsert(first, "member");
for _ in 1..CHAT_ROSTER_CAP {
roster.upsert(fresh_id(), "member");
}
// A brand-new 33rd id is refused...
let overflow = fresh_id();
roster.upsert(overflow, "overflow");
assert_eq!(roster.name_of(&overflow), None);
// ...but an update to a present id still lands at the cap.
roster.upsert(first, "renamed");
assert_eq!(roster.name_of(&first), Some("renamed".to_string()));
}
}
+187
View File
@@ -0,0 +1,187 @@
//! Per-peer connection-transparency derivation.
//!
//! The transport hands us cumulative counters for each peer's selected QUIC
//! path ([`PathSnapshot`]); this module turns two consecutive snapshots into
//! the human-facing [`PeerConnInfo`] the UI renders (badge + tooltip): path
//! type, RTT, and loss/bitrate over the poll window. Pure functions only —
//! the polling task in `core::mod` owns the clock and the previous-snapshot
//! map.
use crate::network::PathSnapshot;
use std::time::Duration;
/// How often the core polls the transport for path snapshots.
pub const POLL_INTERVAL: Duration = Duration::from_secs(1);
/// Derived, display-ready connection info for one peer, sent to the UI via
/// `UiEvent::ConnectionStats`. Window-relative fields are `None` when they
/// can't be derived yet (first poll, path switch, or an idle window).
#[derive(Debug, Clone, PartialEq)]
pub struct PeerConnInfo {
/// True = relayed path, false = direct IP path.
pub relay: bool,
/// `ip:port` for a direct path, the relay URL for a relayed one.
pub remote_addr: String,
/// Path round-trip time, rounded to whole milliseconds.
pub rtt_ms: u32,
/// Percentage of packets sent in the window that were detected lost.
pub loss_pct: Option<f32>,
/// Outbound bitrate over the window, kilobits per second.
pub up_kbps: Option<f32>,
/// Inbound bitrate over the window, kilobits per second.
pub down_kbps: Option<f32>,
}
/// Derive display info from the current snapshot and (when comparable) the
/// previous one. `prev` is comparable only if it's the same path — a relay→
/// direct migration or a reconnect resets the counters, so those windows
/// yield `None` rates rather than garbage (negative deltas show up as
/// `cur < prev` and are treated the same way).
pub fn derive(prev: Option<&PathSnapshot>, cur: &PathSnapshot, elapsed: Duration) -> PeerConnInfo {
let rates = prev
.filter(|p| comparable(p, cur))
.and_then(|p| window_rates(p, cur, elapsed));
PeerConnInfo {
relay: cur.is_relay,
remote_addr: cur.remote_addr.clone(),
rtt_ms: cur.rtt.as_millis().min(u128::from(u32::MAX)) as u32,
loss_pct: rates.and_then(|r| r.loss_pct),
up_kbps: rates.map(|r| r.up_kbps),
down_kbps: rates.map(|r| r.down_kbps),
}
}
/// True when `cur`'s counters continue `prev`'s: same path (address) and
/// monotonically non-decreasing counters (a reconnect on the same address
/// restarts them from zero).
fn comparable(prev: &PathSnapshot, cur: &PathSnapshot) -> bool {
prev.remote_addr == cur.remote_addr
&& cur.tx_bytes >= prev.tx_bytes
&& cur.rx_bytes >= prev.rx_bytes
&& cur.tx_datagrams >= prev.tx_datagrams
&& cur.lost_packets >= prev.lost_packets
}
#[derive(Debug, Clone, Copy)]
struct WindowRates {
loss_pct: Option<f32>,
up_kbps: f32,
down_kbps: f32,
}
fn window_rates(prev: &PathSnapshot, cur: &PathSnapshot, elapsed: Duration) -> Option<WindowRates> {
let secs = elapsed.as_secs_f64();
if secs <= 0.0 {
return None;
}
let sent = cur.tx_datagrams - prev.tx_datagrams;
let lost = cur.lost_packets - prev.lost_packets;
// Loss detection lags sending (it needs ACK timeouts), so a window can see
// more losses than sends; clamp to 100% rather than exceeding it. An idle
// window (nothing sent or lost) has no loss story to tell.
let loss_pct = if sent == 0 && lost == 0 {
None
} else {
Some(((lost as f64 / (sent.max(lost)) as f64) * 100.0) as f32)
};
let kbps = |bytes: u64| ((bytes as f64 * 8.0 / 1000.0) / secs) as f32;
Some(WindowRates {
loss_pct,
up_kbps: kbps(cur.tx_bytes - prev.tx_bytes),
down_kbps: kbps(cur.rx_bytes - prev.rx_bytes),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn snap(addr: &str, tx_b: u64, rx_b: u64, tx_d: u64, lost: u64) -> PathSnapshot {
PathSnapshot {
is_relay: false,
remote_addr: addr.to_string(),
rtt: Duration::from_millis(12),
tx_bytes: tx_b,
rx_bytes: rx_b,
tx_datagrams: tx_d,
lost_packets: lost,
}
}
#[test]
fn first_poll_has_type_and_rtt_but_no_rates() {
let cur = snap("1.2.3.4:5", 1000, 2000, 50, 0);
let info = derive(None, &cur, POLL_INTERVAL);
assert_eq!(info.rtt_ms, 12);
assert!(!info.relay);
assert_eq!(info.remote_addr, "1.2.3.4:5");
assert_eq!(info.loss_pct, None);
assert_eq!(info.up_kbps, None);
assert_eq!(info.down_kbps, None);
}
#[test]
fn steady_window_yields_rates_and_loss() {
let prev = snap("1.2.3.4:5", 0, 0, 0, 0);
// 1s window: 4000 bytes up (32 kbps), 2000 down (16 kbps), 2 of 100 lost.
let cur = snap("1.2.3.4:5", 4000, 2000, 100, 2);
let info = derive(Some(&prev), &cur, Duration::from_secs(1));
assert_eq!(info.up_kbps, Some(32.0));
assert_eq!(info.down_kbps, Some(16.0));
assert_eq!(info.loss_pct, Some(2.0));
}
#[test]
fn idle_window_has_no_loss_story() {
let prev = snap("1.2.3.4:5", 4000, 2000, 100, 2);
let cur = prev.clone();
let info = derive(Some(&prev), &cur, Duration::from_secs(1));
assert_eq!(info.loss_pct, None);
assert_eq!(info.up_kbps, Some(0.0));
}
#[test]
fn loss_detected_in_an_idle_window_clamps_to_full() {
// Losses can be *detected* after sending stops (ACK timeouts fire late).
let prev = snap("1.2.3.4:5", 4000, 2000, 100, 0);
let cur = snap("1.2.3.4:5", 4000, 2000, 100, 3);
let info = derive(Some(&prev), &cur, Duration::from_secs(1));
assert_eq!(info.loss_pct, Some(100.0));
}
#[test]
fn path_switch_resets_the_window() {
let prev = snap("relay.example:443", 9000, 9000, 900, 5);
let cur = snap("1.2.3.4:5", 100, 100, 10, 0);
let info = derive(Some(&prev), &cur, Duration::from_secs(1));
assert_eq!(info.up_kbps, None);
assert_eq!(info.loss_pct, None);
}
#[test]
fn counter_reset_on_same_address_resets_the_window() {
// Same address but the connection was rebuilt → counters restarted.
let prev = snap("1.2.3.4:5", 9000, 9000, 900, 5);
let cur = snap("1.2.3.4:5", 100, 100, 10, 0);
let info = derive(Some(&prev), &cur, Duration::from_secs(1));
assert_eq!(info.up_kbps, None);
assert_eq!(info.loss_pct, None);
}
#[test]
fn zero_elapsed_yields_no_rates() {
let prev = snap("1.2.3.4:5", 0, 0, 0, 0);
let cur = snap("1.2.3.4:5", 4000, 2000, 100, 2);
let info = derive(Some(&prev), &cur, Duration::ZERO);
assert_eq!(info.up_kbps, None);
assert_eq!(info.loss_pct, None);
}
#[test]
fn oversized_rtt_saturates_instead_of_wrapping() {
let mut cur = snap("1.2.3.4:5", 0, 0, 0, 0);
cur.rtt = Duration::from_secs(u64::MAX);
let info = derive(None, &cur, POLL_INTERVAL);
assert_eq!(info.rtt_ms, u32::MAX);
}
}
+252
View File
@@ -0,0 +1,252 @@
//! Byte/request budgets for AUTOMATIC chat-attachment fetches (Phase 3B).
//!
//! The four-permit semaphore bounds how many auto-fetch tasks run at once, but
//! not how much a peer can make us download over time: with permits released
//! after each transfer, an insider could stream distinct ≤4 MiB images
//! sequentially forever. This budget adds per-author and session (room-wide)
//! token buckets over both request COUNT and declared BYTES. Like the Phase 2
//! chat gate, time is passed in — never read from a clock — so every refill
//! boundary is unit-testable.
//!
//! Only the automatic path consults this; a user's explicit click (Save /
//! Download / Load image) is human-rate-limited and always allowed through to
//! the fetch (still subject to the transfer cap and cache/decoder budgets).
use iroh::EndpointId;
use std::collections::HashMap;
/// Per-author request burst: how many auto-fetches one author can trigger
/// back-to-back before refill pacing binds.
pub const AUTHOR_REQ_BURST: f64 = 8.0;
/// Per-author request refill: one recovered every 10 s.
pub const AUTHOR_REQ_REFILL_PER_MS: f64 = 1.0 / 10_000.0;
/// Per-author byte burst (declared sizes): a couple of full-size auto images
/// plus a normal working set.
pub const AUTHOR_BYTES_BURST: f64 = (16 * 1024 * 1024) as f64;
/// Per-author byte refill: 64 KiB/s (~one 4 MiB auto image per minute).
pub const AUTHOR_BYTES_REFILL_PER_MS: f64 = (64 * 1024) as f64 / 1000.0;
/// Session-wide request burst across all authors.
pub const SESSION_REQ_BURST: f64 = 16.0;
/// Session-wide request refill: one recovered every 5 s.
pub const SESSION_REQ_REFILL_PER_MS: f64 = 1.0 / 5_000.0;
/// Session-wide byte burst across all authors.
pub const SESSION_BYTES_BURST: f64 = (48 * 1024 * 1024) as f64;
/// Session-wide byte refill: 128 KiB/s.
pub const SESSION_BYTES_REFILL_PER_MS: f64 = (128 * 1024) as f64 / 1000.0;
/// Bound on the per-author bucket map. Authors are roster members (≤32 live),
/// so this tracks the roster plus recently departed; the least-recently-active
/// entry is pruned past the cap.
pub const AUTHOR_MAP_CAP: usize = 64;
/// A deterministic token bucket that can take a WEIGHTED cost (bytes), unlike
/// the unit-cost bucket in the gossip chat gate.
#[derive(Debug, Clone, Copy)]
struct WeightedBucket {
tokens: f64,
last_ms: u64,
}
impl WeightedBucket {
fn full(burst: f64, now_ms: u64) -> Self {
Self {
tokens: burst,
last_ms: now_ms,
}
}
/// Refill for elapsed time (capped at `burst`) without consuming.
fn refill(&mut self, burst: f64, refill_per_ms: f64, now_ms: u64) {
let elapsed = now_ms.saturating_sub(self.last_ms) as f64;
self.tokens = (self.tokens + elapsed * refill_per_ms).min(burst);
self.last_ms = now_ms;
}
fn has(&self, cost: f64) -> bool {
self.tokens >= cost
}
fn take(&mut self, cost: f64) {
self.tokens -= cost;
}
}
/// One author's pair of buckets plus last activity (for idle pruning).
#[derive(Debug)]
struct AuthorBudget {
reqs: WeightedBucket,
bytes: WeightedBucket,
last_seen_ms: u64,
}
/// Admission budget for automatic attachment fetches. All four buckets are
/// checked BEFORE any is consumed, so a rejection never burns tokens (no
/// refund bookkeeping — the check-then-take is atomic within `admit`).
#[derive(Debug)]
pub struct AutoFetchBudget {
session_reqs: WeightedBucket,
session_bytes: WeightedBucket,
authors: HashMap<EndpointId, AuthorBudget>,
}
impl AutoFetchBudget {
pub fn new(now_ms: u64) -> Self {
Self {
session_reqs: WeightedBucket::full(SESSION_REQ_BURST, now_ms),
session_bytes: WeightedBucket::full(SESSION_BYTES_BURST, now_ms),
authors: HashMap::new(),
}
}
/// Whether an auto-fetch of `size` declared bytes for `author` may start
/// now. Consumes one request token and `size` byte tokens from BOTH the
/// author's and the session's buckets — or nothing at all on rejection.
pub fn admit(&mut self, author: EndpointId, size: u64, now_ms: u64) -> bool {
self.prune(author, now_ms);
let entry = self.authors.entry(author).or_insert_with(|| AuthorBudget {
reqs: WeightedBucket::full(AUTHOR_REQ_BURST, now_ms),
bytes: WeightedBucket::full(AUTHOR_BYTES_BURST, now_ms),
last_seen_ms: now_ms,
});
entry.last_seen_ms = now_ms;
entry
.reqs
.refill(AUTHOR_REQ_BURST, AUTHOR_REQ_REFILL_PER_MS, now_ms);
entry
.bytes
.refill(AUTHOR_BYTES_BURST, AUTHOR_BYTES_REFILL_PER_MS, now_ms);
self.session_reqs
.refill(SESSION_REQ_BURST, SESSION_REQ_REFILL_PER_MS, now_ms);
self.session_bytes
.refill(SESSION_BYTES_BURST, SESSION_BYTES_REFILL_PER_MS, now_ms);
let cost = size as f64;
let ok = entry.reqs.has(1.0)
&& entry.bytes.has(cost)
&& self.session_reqs.has(1.0)
&& self.session_bytes.has(cost);
if ok {
let entry = self.authors.get_mut(&author).expect("just inserted");
entry.reqs.take(1.0);
entry.bytes.take(cost);
self.session_reqs.take(1.0);
self.session_bytes.take(cost);
}
ok
}
/// Keep the author map bounded: past the cap, drop the least-recently
/// active entry that isn't the author being admitted. A pruned author
/// returns with full buckets, but authors are roster-gated upstream, so
/// the map can't be churned by strangers.
fn prune(&mut self, keep: EndpointId, _now_ms: u64) {
while self.authors.len() >= AUTHOR_MAP_CAP {
let Some(victim) = self
.authors
.iter()
.filter(|(id, _)| **id != keep)
.min_by_key(|(_, b)| b.last_seen_ms)
.map(|(id, _)| *id)
else {
break;
};
self.authors.remove(&victim);
}
}
#[cfg(test)]
fn author_count(&self) -> usize {
self.authors.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use iroh::SecretKey;
const T0: u64 = 1_000_000;
const MIB: u64 = 1024 * 1024;
fn author() -> EndpointId {
SecretKey::generate().public()
}
#[test]
fn author_request_burst_then_refill_recovers() {
let mut b = AutoFetchBudget::new(T0);
let a = author();
// Tiny sizes so only the REQUEST buckets can bind.
for _ in 0..AUTHOR_REQ_BURST as usize {
assert!(b.admit(a, 1, T0));
}
assert!(!b.admit(a, 1, T0), "author request burst exhausted");
// One request refills after 10 s.
assert!(b.admit(a, 1, T0 + 10_000));
assert!(!b.admit(a, 1, T0 + 10_000));
}
#[test]
fn author_byte_budget_binds_and_recovers() {
let mut b = AutoFetchBudget::new(T0);
let a = author();
// 4 × 4 MiB = the full 16 MiB author byte burst (well under the
// 8-request burst, so bytes are the binding constraint).
for _ in 0..4 {
assert!(b.admit(a, 4 * MIB, T0));
}
assert!(!b.admit(a, 4 * MIB, T0), "author byte burst exhausted");
// 64 KiB/s → a 4 MiB image is affordable again after 64 s (which also
// refills 6 request tokens, so bytes stay the binding constraint).
assert!(!b.admit(a, 4 * MIB, T0 + 32_000));
assert!(b.admit(a, 4 * MIB, T0 + 64_000));
}
#[test]
fn session_budget_binds_across_authors_without_burning_author_tokens() {
let mut b = AutoFetchBudget::new(T0);
// Three authors × 16 MiB exhausts the 48 MiB session byte burst even
// though each author is within their own budget.
for _ in 0..3 {
let a = author();
for _ in 0..4 {
assert!(b.admit(a, 4 * MIB, T0));
}
}
let fresh = author();
assert!(!b.admit(fresh, 4 * MIB, T0), "session bytes exhausted");
// The rejection consumed NOTHING: once the session refills enough for
// one image (4 MiB / 128 KiB/s = 32 s), the fresh author's own full
// burst is intact and admits immediately.
assert!(b.admit(fresh, 4 * MIB, T0 + 32_000));
}
#[test]
fn session_request_bucket_binds_across_authors() {
let mut b = AutoFetchBudget::new(T0);
// 16 tiny requests from distinct authors exhaust the session request
// burst while every author bucket stays nearly full.
for _ in 0..SESSION_REQ_BURST as usize {
assert!(b.admit(author(), 1, T0));
}
assert!(!b.admit(author(), 1, T0), "session requests exhausted");
assert!(b.admit(author(), 1, T0 + 5_000), "one recovers after 5 s");
}
#[test]
fn author_map_stays_bounded_pruning_least_recent() {
let mut b = AutoFetchBudget::new(T0);
// Session request refill would bind over a naive loop; space the
// admissions out so only the map bound is under test.
let mut t = T0;
let first = author();
assert!(b.admit(first, 1, t));
for _ in 0..(AUTHOR_MAP_CAP + 10) {
t += 10_000;
assert!(b.admit(author(), 1, t));
assert!(b.author_count() <= AUTHOR_MAP_CAP);
}
assert!(b.author_count() <= AUTHOR_MAP_CAP);
}
}
+180 -5
View File
@@ -202,11 +202,20 @@ impl JitterBuffer {
None None
} else { } else {
// Gap with later packets already buffered: a packet was lost // Gap with later packets already buffered: a packet was lost
// or reordered out of window. Conceal this frame via Opus PLC // or reordered out of window. Try Opus in-band FEC from the
// and grow the cushion — the jitter beat our current delay. // packet right after the gap; if that packet isn't buffered
// (burst loss) or FEC fails, fall back to plain PLC.
self.next_seq = Some(next.wrapping_add(1)); self.next_seq = Some(next.wrapping_add(1));
self.note_disruption(); self.note_disruption();
self.decoder.decode(None).ok() let (&smallest, next_payload) = self.packets.iter().next().expect("non-empty");
if fec_covers_gap(next, smallest) {
self.decoder
.decode_fec(next_payload)
.or_else(|_| self.decoder.decode(None))
.ok()
} else {
self.decoder.decode(None).ok()
}
} }
} }
} }
@@ -218,11 +227,20 @@ impl JitterBuffer {
} }
} }
/// Opus in-band FEC in packet N carries a low-fidelity copy of frame N-1 and
/// nothing else — a lost frame `next` is FEC-recoverable solely from packet
/// `next+1`. Any later successor's FEC data is a different frame's audio, and
/// splicing it into this gap plays sound from the wrong position; the caller
/// must conceal with plain PLC instead.
fn fec_covers_gap(next: u32, smallest_buffered: u32) -> bool {
smallest_buffered == next.wrapping_add(1)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::codec::AudioEncoder; use crate::codec::opus_impl::{OpusDecoder, OpusEncoder, OpusParams};
use crate::codec::opus_impl::OpusEncoder; use crate::codec::{AudioDecoder, AudioEncoder};
use opus::{Application, Channels}; use opus::{Application, Channels};
/// A real, decodable Opus packet for one 20ms mono frame at amplitude `amp`. /// A real, decodable Opus packet for one 20ms mono frame at amplitude `amp`.
@@ -233,6 +251,32 @@ mod tests {
enc.encode(&pcm).unwrap() enc.encode(&pcm).unwrap()
} }
fn tone_frame(enc: &mut OpusEncoder, amp: i16, frame_index: usize) -> Vec<u8> {
let pcm: Vec<i16> = (0..FRAME_SAMPLES)
.map(|i| {
let sample_index = frame_index * FRAME_SAMPLES + i;
let t = sample_index as f32 / 48_000.0;
let fundamental = (t * 220.0 * 2.0 * std::f32::consts::PI).sin();
let harmonic = (t * 440.0 * 2.0 * std::f32::consts::PI).sin();
((fundamental * 0.7 + harmonic * 0.3) * amp as f32) as i16
})
.collect();
enc.encode(&pcm).unwrap()
}
fn rms_error(a: &[i16], b: &[i16]) -> f64 {
assert_eq!(a.len(), b.len());
let sum_sq: f64 = a
.iter()
.zip(b)
.map(|(&left, &right)| {
let diff = left as f64 - right as f64;
diff * diff
})
.sum();
(sum_sq / a.len() as f64).sqrt()
}
#[test] #[test]
fn buffers_then_plays_in_order() { fn buffers_then_plays_in_order() {
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap(); let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
@@ -289,6 +333,137 @@ mod tests {
assert!(jb.pop_frame().is_none()); assert!(jb.pop_frame().is_none());
} }
#[test]
fn uses_in_band_fec_from_next_packet_for_gap() {
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
enc.apply_params(&OpusParams {
bitrate: 20_000,
inband_fec: true,
packet_loss_perc: 60,
dtx: false,
})
.unwrap();
let dropped_seq = 5usize;
let amps = [1800, 1800, 1800, 1800, 1800, 12_000, 12_000, 12_000];
let packets: Vec<Vec<u8>> = amps
.into_iter()
.enumerate()
.map(|(seq, amp)| tone_frame(&mut enc, amp, seq))
.collect();
let mut expected_decoder = OpusDecoder::new(48000, Channels::Mono, FRAME_SAMPLES).unwrap();
for packet in packets.iter().take(dropped_seq) {
expected_decoder.decode(Some(packet)).unwrap();
}
let expected_lost = expected_decoder
.decode(Some(&packets[dropped_seq]))
.unwrap();
let mut plc_decoder = OpusDecoder::new(48000, Channels::Mono, FRAME_SAMPLES).unwrap();
for packet in packets.iter().take(dropped_seq) {
plc_decoder.decode(Some(packet)).unwrap();
}
let pure_plc = plc_decoder.decode(None).unwrap();
let mut jb = JitterBuffer::new().unwrap();
for (seq, packet) in packets.iter().enumerate() {
if seq != dropped_seq {
jb.insert(seq as u32, packet.clone());
}
}
for _ in 0..dropped_seq {
assert_eq!(jb.pop_frame().map(|frame| frame.len()), Some(FRAME_SAMPLES));
}
let recovered = jb.pop_frame().expect("gap should be reconstructed");
assert_eq!(recovered.len(), FRAME_SAMPLES);
assert!(
jb.packets.contains_key(&(dropped_seq as u32 + 1)),
"FEC source packet must remain buffered for normal decode"
);
assert_eq!(jb.pop_frame().map(|frame| frame.len()), Some(FRAME_SAMPLES));
let fec_error = rms_error(&recovered, &expected_lost);
let plc_error = rms_error(&pure_plc, &expected_lost);
assert!(
fec_error < plc_error * 0.75,
"FEC reconstruction should be materially closer than PLC (fec_error={fec_error}, plc_error={plc_error})"
);
}
#[test]
fn fec_covers_gap_only_for_the_immediate_successor() {
// Packet next+1 is the only one whose in-band FEC describes frame `next`.
assert!(fec_covers_gap(4, 5));
// A burst gap: the smallest survivor's FEC is some other frame's audio.
assert!(!fec_covers_gap(3, 5));
assert!(!fec_covers_gap(3, 3_000));
// Sequence wraparound still counts as adjacent.
assert!(fec_covers_gap(u32::MAX, 0));
}
#[test]
fn burst_gap_falls_back_to_plc_not_wrong_position_fec() {
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
enc.apply_params(&OpusParams {
bitrate: 20_000,
inband_fec: true,
packet_loss_perc: 60,
dtx: false,
})
.unwrap();
// Frames 0..=6; 3 and 4 are lost as a burst, so when playout reaches
// seq 3 the smallest buffered packet is 5 — whose FEC data is frame 4,
// NOT frame 3. The buffer must conceal 3 with plain PLC rather than
// splice frame 4's audio into the wrong position.
let packets: Vec<Vec<u8>> = (0..7).map(|seq| tone_frame(&mut enc, 8_000, seq)).collect();
// Twin decoder replaying the exact call sequence the jitter buffer
// should make for seq 3: decode 0,1,2 then a plain PLC conceal.
let mut twin = OpusDecoder::new(48000, Channels::Mono, FRAME_SAMPLES).unwrap();
for packet in packets.iter().take(3) {
twin.decode(Some(packet)).unwrap();
}
let expected_plc = twin.decode(None).unwrap();
let mut jb = JitterBuffer::new().unwrap();
for (seq, packet) in packets.iter().enumerate() {
if seq != 3 && seq != 4 {
jb.insert(seq as u32, packet.clone());
}
}
for _ in 0..3 {
assert_eq!(jb.pop_frame().map(|frame| frame.len()), Some(FRAME_SAMPLES));
}
// Seq 3: burst gap — bit-exact PLC (same decoder state, same inputs),
// which decode_fec(packet 5) could never produce.
let concealed = jb.pop_frame().expect("gap should be concealed");
assert_eq!(concealed, expected_plc, "burst gap must use plain PLC");
// Seq 4: packet 5 IS the immediate successor, so its FEC data is
// frame 4's audio — the correctly-positioned recovery still applies.
let recovered = jb
.pop_frame()
.expect("adjacent gap should be reconstructed");
let mut fec_twin = OpusDecoder::new(48000, Channels::Mono, FRAME_SAMPLES).unwrap();
for packet in packets.iter().take(3) {
fec_twin.decode(Some(packet)).unwrap();
}
fec_twin.decode(None).unwrap();
let expected_fec = fec_twin.decode_fec(&packets[5]).unwrap();
assert_eq!(recovered, expected_fec, "adjacent gap should still use FEC");
// Then 5 and 6 play normally.
assert_eq!(jb.pop_frame().map(|frame| frame.len()), Some(FRAME_SAMPLES));
assert_eq!(jb.pop_frame().map(|frame| frame.len()), Some(FRAME_SAMPLES));
assert!(jb.pop_frame().is_none());
}
#[test] #[test]
fn drops_packets_already_played() { fn drops_packets_already_played() {
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap(); let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
+350 -53
View File
@@ -1,4 +1,4 @@
use crate::config::{NetworkMode, RecordingMode}; use crate::config::{AudioProfile, NetworkMode, RecordingMode, ScreenShareSettings, ShareQuality};
use crate::friends::Friend; use crate::friends::Friend;
use crate::network::PeerState; use crate::network::PeerState;
use crate::presence::{FriendPresence, PresenceMode}; use crate::presence::{FriendPresence, PresenceMode};
@@ -9,7 +9,15 @@ pub enum CoreCommand {
/// Join a room. `ticket` is "create" (or empty) to mint a fresh room, else a /// Join a room. `ticket` is "create" (or empty) to mint a fresh room, else a
/// share ticket to join. `room_name` is the creator's chosen cosmetic label /// share ticket to join. `room_name` is the creator's chosen cosmetic label
/// for a NEW room; it's ignored when joining (the label rides in the ticket). /// for a NEW room; it's ignored when joining (the label rides in the ticket).
Join { name: String, ticket: String, room_name: String, input_device: Option<String>, output_device: Option<String>, echo_cancellation: bool, avatar: crate::avatar::Avatar }, Join {
name: String,
ticket: String,
room_name: String,
input_device: Option<String>,
output_device: Option<String>,
echo_cancellation: bool,
avatar: crate::avatar::Avatar,
},
Leave, Leave,
/// Orderly app shutdown: finalize recordings, leave any active room, stop local /// Orderly app shutdown: finalize recordings, leave any active room, stop local
/// audio/screen-share work, close the persistent network stack, then ack with /// audio/screen-share work, close the persistent network stack, then ack with
@@ -41,37 +49,74 @@ pub enum CoreCommand {
/// Start/stop a standalone capture-only stream that reports the raw mic /// Start/stop a standalone capture-only stream that reports the raw mic
/// level via [`UiEvent::MicLevel`], for gate calibration outside a call. /// level via [`UiEvent::MicLevel`], for gate calibration outside a call.
/// Ignored while a room session is active (the in-call meter covers that). /// Ignored while a room session is active (the in-call meter covers that).
SetMicMonitor { enabled: bool, input_device: Option<String> }, SetMicMonitor {
enabled: bool,
input_device: Option<String>,
},
/// Set the relay/discovery posture. Takes effect on the next room join, /// Set the relay/discovery posture. Takes effect on the next room join,
/// since the endpoint is (re)built then. /// since the endpoint is (re)built then.
SetNetworkMode(NetworkMode), SetNetworkMode(NetworkMode),
/// Set the Opus encoder / network-resilience profile (W12). Applies live to
/// the running capture encoder, and to the next call's encoder. Sent at
/// startup from config and whenever the user changes it.
SetAudioProfile(AudioProfile),
/// Start/stop recording the call to a local WAV (your mic + the incoming /// Start/stop recording the call to a local WAV (your mic + the incoming
/// mix). No-op start if already recording / not in a call. /// mix). No-op start if already recording / not in a call.
SetRecording(bool), SetRecording(bool),
/// Set what a recording captures (mixed / per-peer stems / both). Takes /// Set what a recording captures (mixed / per-peer stems / both). Takes
/// effect on the next recording start. Sent at startup from config. /// effect on the next recording start. Sent at startup from config.
SetRecordingMode(RecordingMode), SetRecordingMode(RecordingMode),
/// Broadcast a room text-chat message. No-op when not in a call. /// Broadcast a room text-chat message. `local_id` is the app's local-only
SendChat(String), /// handle for this send — it never goes on the wire; the core echoes it back
/// in [`UiEvent::ChatSendResult`] so the UI can mark the matching local echo
/// honestly (chat-hardening Phase 5). Not being in a call is a FAILURE
/// result, not a silent no-op.
SendChat {
local_id: u64,
text: String,
},
/// Send a chat message carrying a file attachment. The app has already read + /// Send a chat message carrying a file attachment. The app has already read +
/// capped the file and built the descriptor; core makes the bytes available /// capped the file and built the descriptor; core makes the bytes available
/// on the file plane and broadcasts the descriptor. /// on the file plane and broadcasts the descriptor. `local_id` as in
SendChatFile { text: String, attachment: crate::files::ChatAttachment, data: Vec<u8> }, /// [`CoreCommand::SendChat`].
SendChatFile {
local_id: u64,
text: String,
attachment: crate::files::ChatAttachment,
/// Shared, not owned: the same allocation is retained by the UI cache
/// and handed to the serve store, so a 25 MiB attachment is held once,
/// not copied across UI / command queue / serve store (Phase 3C).
data: std::sync::Arc<Vec<u8>>,
},
/// Fetch a received attachment's bytes from its sender over the file plane /// Fetch a received attachment's bytes from its sender over the file plane
/// (used for on-demand file/chip downloads; images are auto-fetched on /// (used for on-demand file/chip downloads; images are auto-fetched on
/// receipt). Replies with `AttachmentReady`/`AttachmentFailed`. /// receipt). Replies with `AttachmentReady`/`AttachmentFailed`.
FetchAttachment { from: EndpointId, attachment: crate::files::ChatAttachment }, FetchAttachment {
from: EndpointId,
attachment: crate::files::ChatAttachment,
},
/// Register `data` as fetchable under `id` for room members (the current /// Register `data` as fetchable under `id` for room members (the current
/// broadcast track). Called once per track when broadcasting. /// broadcast track). Called once per track when broadcasting.
ServeMusicTrack { id: crate::files::AttachmentId, data: std::sync::Arc<Vec<u8>> }, ServeMusicTrack {
id: crate::files::AttachmentId,
data: std::sync::Arc<Vec<u8>>,
},
/// Drop a music blob that is no longer current-or-next. /// Drop a music blob that is no longer current-or-next.
ForgetMusicTrack(crate::files::AttachmentId), ForgetMusicTrack(crate::files::AttachmentId),
/// Set (or clear) our broadcast music timeline and re-announce presence. /// Set (or clear) our broadcast music timeline and re-announce presence.
SetMusicPresence(Option<crate::network::MusicPresence>), SetMusicPresence(Option<crate::network::MusicPresence>),
/// Fetch a source peer's current track bytes after tuning into them. /// Fetch a source peer's current track bytes after tuning into them.
FetchMusic { from: EndpointId, id: crate::files::AttachmentId, size: u64 }, FetchMusic {
from: EndpointId,
id: crate::files::AttachmentId,
size: u64,
},
/// Fetch a source peer's advertised next track bytes before it becomes current. /// Fetch a source peer's advertised next track bytes before it becomes current.
PrefetchMusic { from: EndpointId, id: crate::files::AttachmentId, size: u64 }, PrefetchMusic {
from: EndpointId,
id: crate::files::AttachmentId,
size: u64,
},
/// Set the pixelpass binary location (config override, empty = use `$PATH`). /// Set the pixelpass binary location (config override, empty = use `$PATH`).
/// Sent at startup so screen-share can resolve the binary. /// Sent at startup so screen-share can resolve the binary.
SetPixelpassPath(Option<String>), SetPixelpassPath(Option<String>),
@@ -84,13 +129,30 @@ pub enum CoreCommand {
/// `audio_app` selects which app's audio to capture: `Some(name)` captures /// `audio_app` selects which app's audio to capture: `Some(name)` captures
/// only that app (avoiding the call-loopback echo, A23); `None` shares the /// only that app (avoiding the call-loopback echo, A23); `None` shares the
/// whole desktop audio (the legacy behavior). /// whole desktop audio (the legacy behavior).
StartScreenShare { audio_app: Option<String> }, StartScreenShare {
audio_app: Option<String>,
settings: ScreenShareSettings,
quality: ShareQuality,
},
/// Stop sharing our screen: kill the pixelpass host and clear the presence /// Stop sharing our screen: kill the pixelpass host and clear the presence
/// ticket. No-op when not sharing. /// ticket. No-op when not sharing.
StopScreenShare, StopScreenShare,
/// **Core-internal.** The running pixelpass host's stdout ended — the
/// process died (or its event stream broke), so the share identified by
/// `generation` is over: reap the child, pull the ticket off presence, and
/// tell the user. Synthesized by the core's own notice-forwarder task; the
/// UI never sends it. `generation` scopes the fault to one specific host
/// spawn, so a stale fault (the user already stopped, or started a new
/// share) is ignored rather than tearing down the wrong share.
ScreenShareHostFault {
generation: u64,
},
/// Watch a peer's screen share: spawn a pixelpass viewer for `ticket` and /// Watch a peer's screen share: spawn a pixelpass viewer for `ticket` and
/// open it in a local player. /// open it in a local player.
ViewShare(String), ViewShare {
ticket: String,
settings: ScreenShareSettings,
},
/// Mint a fresh persistent identity (W7), discarding the old one. Takes effect /// Mint a fresh persistent identity (W7), discarding the old one. Takes effect
/// on the next room join (the endpoint is rebuilt then). The core replies with /// on the next room join (the endpoint is rebuilt then). The core replies with
/// an updated [`UiEvent::IdentityStatus`]. /// an updated [`UiEvent::IdentityStatus`].
@@ -98,7 +160,11 @@ pub enum CoreCommand {
/// Add a friend (W7). Core owns the friends store: it mutates + persists it and /// Add a friend (W7). Core owns the friends store: it mutates + persists it and
/// replies with [`UiEvent::FriendsUpdated`]. `addr` seeds `last_addr` if known /// replies with [`UiEvent::FriendsUpdated`]. `addr` seeds `last_addr` if known
/// (e.g. added from a room). Idempotent — re-adding an existing id is a no-op. /// (e.g. added from a room). Idempotent — re-adding an existing id is a no-op.
AddFriend { id: EndpointId, name: String, addr: Option<EndpointAddr> }, AddFriend {
id: EndpointId,
name: String,
addr: Option<EndpointAddr>,
},
/// Remove a friend by id (W7). /// Remove a friend by id (W7).
RemoveFriend(EndpointId), RemoveFriend(EndpointId),
/// Locally rename a friend (W7). /// Locally rename a friend (W7).
@@ -130,6 +196,17 @@ pub enum DeliveryClass {
BestEffort, BestEffort,
} }
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum CoalesceKey {
InputVolume,
OutputVolume,
NoiseGate,
PeerVolume(EndpointId),
PeerPan(EndpointId),
PeerGate(EndpointId),
PeerEq(EndpointId),
}
/// Route a command by how bad it is to drop it. Discrete, human-paced user /// Route a command by how bad it is to drop it. Discrete, human-paced user
/// actions are Reliable (must land). The only high-frequency commands are the /// actions are Reliable (must land). The only high-frequency commands are the
/// continuous audio sliders, where dropping intermediate values is harmless; /// continuous audio sliders, where dropping intermediate values is harmless;
@@ -166,10 +243,15 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
input_device: _, input_device: _,
} }
| CoreCommand::SetNetworkMode(_) | CoreCommand::SetNetworkMode(_)
| CoreCommand::SetAudioProfile(_)
| CoreCommand::SetRecording(_) | CoreCommand::SetRecording(_)
| CoreCommand::SetRecordingMode(_) | CoreCommand::SetRecordingMode(_)
| CoreCommand::SendChat(_) | CoreCommand::SendChat {
local_id: _,
text: _,
}
| CoreCommand::SendChatFile { | CoreCommand::SendChatFile {
local_id: _,
text: _, text: _,
attachment: _, attachment: _,
data: _, data: _,
@@ -178,10 +260,7 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
from: _, from: _,
attachment: _, attachment: _,
} }
| CoreCommand::ServeMusicTrack { | CoreCommand::ServeMusicTrack { id: _, data: _ }
id: _,
data: _,
}
| CoreCommand::ForgetMusicTrack(_) | CoreCommand::ForgetMusicTrack(_)
| CoreCommand::SetMusicPresence(_) | CoreCommand::SetMusicPresence(_)
| CoreCommand::FetchMusic { | CoreCommand::FetchMusic {
@@ -196,9 +275,17 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
} }
| CoreCommand::SetPixelpassPath(_) | CoreCommand::SetPixelpassPath(_)
| CoreCommand::ListAudioApps | CoreCommand::ListAudioApps
| CoreCommand::StartScreenShare { audio_app: _ } | CoreCommand::StartScreenShare {
audio_app: _,
settings: _,
quality: _,
}
| CoreCommand::StopScreenShare | CoreCommand::StopScreenShare
| CoreCommand::ViewShare(_) | CoreCommand::ScreenShareHostFault { generation: _ }
| CoreCommand::ViewShare {
ticket: _,
settings: _,
}
| CoreCommand::RegenerateIdentity | CoreCommand::RegenerateIdentity
| CoreCommand::AddFriend { | CoreCommand::AddFriend {
id: _, id: _,
@@ -215,57 +302,223 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
} }
} }
/// Coalescing bucket for high-frequency continuous controls. A key exists
/// exactly for [`DeliveryClass::BestEffort`] commands.
pub fn coalesce_key(cmd: &CoreCommand) -> Option<CoalesceKey> {
match cmd {
CoreCommand::SetPeerVolume(peer_id, _) => Some(CoalesceKey::PeerVolume(*peer_id)),
CoreCommand::SetPeerPan(peer_id, _) => Some(CoalesceKey::PeerPan(*peer_id)),
CoreCommand::SetPeerGate(peer_id, _) => Some(CoalesceKey::PeerGate(*peer_id)),
CoreCommand::SetPeerEq(peer_id, _) => Some(CoalesceKey::PeerEq(*peer_id)),
CoreCommand::SetInputVolume(_) => Some(CoalesceKey::InputVolume),
CoreCommand::SetOutputVolume(_) => Some(CoalesceKey::OutputVolume),
CoreCommand::SetNoiseGateThreshold(_) => Some(CoalesceKey::NoiseGate),
CoreCommand::Join {
name: _,
ticket: _,
room_name: _,
input_device: _,
output_device: _,
echo_cancellation: _,
avatar: _,
}
| CoreCommand::Leave
| CoreCommand::Shutdown
| CoreCommand::ToggleMute
| CoreCommand::SetAvatar(_)
| CoreCommand::ToggleDeafen
| CoreCommand::SetPttMode(_)
| CoreCommand::SetPttActive(_)
| CoreCommand::SetPeerMuted(_, _)
| CoreCommand::SetMicMonitor {
enabled: _,
input_device: _,
}
| CoreCommand::SetNetworkMode(_)
| CoreCommand::SetAudioProfile(_)
| CoreCommand::SetRecording(_)
| CoreCommand::SetRecordingMode(_)
| CoreCommand::SendChat {
local_id: _,
text: _,
}
| CoreCommand::SendChatFile {
local_id: _,
text: _,
attachment: _,
data: _,
}
| CoreCommand::FetchAttachment {
from: _,
attachment: _,
}
| CoreCommand::ServeMusicTrack { id: _, data: _ }
| CoreCommand::ForgetMusicTrack(_)
| CoreCommand::SetMusicPresence(_)
| CoreCommand::FetchMusic {
from: _,
id: _,
size: _,
}
| CoreCommand::PrefetchMusic {
from: _,
id: _,
size: _,
}
| CoreCommand::SetPixelpassPath(_)
| CoreCommand::ListAudioApps
| CoreCommand::StartScreenShare {
audio_app: _,
settings: _,
quality: _,
}
| CoreCommand::StopScreenShare
| CoreCommand::ScreenShareHostFault { generation: _ }
| CoreCommand::ViewShare {
ticket: _,
settings: _,
}
| CoreCommand::RegenerateIdentity
| CoreCommand::AddFriend {
id: _,
name: _,
addr: _,
}
| CoreCommand::RemoveFriend(_)
| CoreCommand::RenameFriend(_, _)
| CoreCommand::RefreshFriends
| CoreCommand::SetPresenceMode(_)
| CoreCommand::SetGamePresenceEnabled(_)
| CoreCommand::SetGameOverride(_)
| CoreCommand::SetGameProcessMap(_) => None,
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum UiEvent { pub enum UiEvent {
RoomJoined { ticket: String, self_id: String }, RoomJoined {
ticket: String,
self_id: String,
},
RoomLeft, RoomLeft,
/// Clear room-scoped UI state after a failed in-call room switch, without a /// Clear room-scoped UI state after a failed in-call room switch, without a
/// leave chime. The persistent identity remains unchanged. /// leave chime. The persistent identity remains unchanged.
RoomReset, RoomReset,
PeerJoined { id: EndpointId, state: PeerState }, PeerJoined {
PeerLeft { id: EndpointId }, id: EndpointId,
state: PeerState,
},
PeerLeft {
id: EndpointId,
},
/// The fixed reconnect grace expired and bounded background gossip recovery /// The fixed reconnect grace expired and bounded background gossip recovery
/// has started. This is non-terminal and must not play the failure chime. /// has started. This is non-terminal and must not play the failure chime.
PeerRecoveryStarted { id: EndpointId }, PeerRecoveryStarted {
PeerConnectionFailed { id: EndpointId }, id: EndpointId,
PeerUpdated { id: EndpointId, state: PeerState }, },
PeerConnectionFailed {
id: EndpointId,
},
PeerUpdated {
id: EndpointId,
state: PeerState,
},
/// Audio link to a peer is being (re)established — show a connecting state. /// Audio link to a peer is being (re)established — show a connecting state.
PeerConnecting { id: EndpointId }, PeerConnecting {
id: EndpointId,
},
/// Audio link to a peer is up and carrying audio. /// Audio link to a peer is up and carrying audio.
PeerConnected { id: EndpointId }, PeerConnected {
id: EndpointId,
},
AudioLevels(Vec<(EndpointId, f32)>), AudioLevels(Vec<(EndpointId, f32)>),
/// Periodic per-peer connection transparency snapshot (~1/sec): path type
/// (direct/relay), RTT, and window loss/bitrate for every peer with a live
/// audio link. A FULL replacement each time — a peer absent from the list
/// has no live link right now, so its badge should disappear.
ConnectionStats(Vec<(EndpointId, crate::core::connstats::PeerConnInfo)>),
/// Raw (pre-gate, pre-mute) normalized RMS of the local mic, `0.0..=1.0`, /// Raw (pre-gate, pre-mute) normalized RMS of the local mic, `0.0..=1.0`,
/// for the settings level meter. Throttled to ~10/sec. /// for the settings level meter. Throttled to ~10/sec.
MicLevel(f32), MicLevel(f32),
/// Call recording started; carries the absolute WAV path being written. /// Call recording started; carries the absolute WAV path being written.
RecordingStarted { path: String }, RecordingStarted {
path: String,
},
/// Call recording stopped; carries the finished WAV path. /// Call recording stopped; carries the finished WAV path.
RecordingStopped { path: String }, RecordingStopped {
path: String,
},
/// The outcome of one locally initiated chat send (chat-hardening Phase 5).
/// `error = None` means our signed broadcast was handed to the gossip swarm
/// — deliberately NOT a delivery/read receipt; PeerSpeak has no peer
/// acknowledgements. `local_id` is the app's own handle from the
/// `SendChat`/`SendChatFile` command and never appears on the wire.
ChatSendResult {
local_id: u64,
error: Option<String>,
},
/// A room text-chat message arrived from a peer (never our own — local /// A room text-chat message arrived from a peer (never our own — local
/// messages are echoed by the UI on send). `from` is the sender's node id /// messages are echoed by the UI on send). `from` is the sender's node id
/// string, used to key their avatar (W4). /// string, used to key their avatar (W4).
ChatMessage { from: String, name: String, text: String, attachment: Option<crate::files::ChatAttachment> }, ChatMessage {
from: String,
name: String,
text: String,
attachment: Option<crate::files::ChatAttachment>,
},
/// An attachment's bytes are now available (auto-fetched for images, or /// An attachment's bytes are now available (auto-fetched for images, or
/// fetched on demand for files). Keyed by `(from, id)`: the id is /// fetched on demand for files). Keyed by `(from, id)`: the id is
/// attacker-chosen, so a malicious peer can reuse a victim's id — the author /// attacker-chosen, so a malicious peer can reuse a victim's id — the author
/// disambiguates whose bytes these are and stops content aliasing (Tier C /// disambiguates whose bytes these are and stops content aliasing (Tier C
/// F-12). /// F-12).
AttachmentReady { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> }, AttachmentReady {
from: EndpointId,
id: crate::files::AttachmentId,
data: std::sync::Arc<Vec<u8>>,
},
/// An attachment fetch task was spawned (auto or on demand). Lets the UI
/// show a real "loading" state instead of inferring it from cache absence —
/// absence now means NOT fetched (e.g. auto-fetch was skipped), which
/// renders a Load button rather than an indefinite "loading…" (Phase 3B).
AttachmentFetchStarted {
from: EndpointId,
id: crate::files::AttachmentId,
},
/// An attachment fetch failed (sender gone, too large, decode error, etc.). /// An attachment fetch failed (sender gone, too large, decode error, etc.).
AttachmentFailed { from: EndpointId, id: crate::files::AttachmentId, error: String }, AttachmentFailed {
from: EndpointId,
id: crate::files::AttachmentId,
error: String,
},
/// A tuned-in source's track bytes arrived; play them in the music sink. /// A tuned-in source's track bytes arrived; play them in the music sink.
MusicReady { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> }, MusicReady {
from: EndpointId,
id: crate::files::AttachmentId,
data: Vec<u8>,
},
/// A tuned-in source's next-track bytes arrived; cache them for a gapless swap. /// A tuned-in source's next-track bytes arrived; cache them for a gapless swap.
MusicPrefetched { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> }, MusicPrefetched {
from: EndpointId,
id: crate::files::AttachmentId,
data: Vec<u8>,
},
/// A music-track fetch failed (source gone, too large, etc.). /// A music-track fetch failed (source gone, too large, etc.).
MusicFetchFailed { from: EndpointId, id: crate::files::AttachmentId, error: String }, MusicFetchFailed {
from: EndpointId,
id: crate::files::AttachmentId,
error: String,
},
/// The apps currently producing audio, for the screen-share audio picker /// The apps currently producing audio, for the screen-share audio picker
/// (A23). Sorted, deduplicated `application.name`s; empty when nothing is /// (A23). Sorted, deduplicated `application.name`s; empty when nothing is
/// playing or enumeration isn't available. `app_audio_supported` reports /// playing or enumeration isn't available. `app_audio_supported` reports
/// whether the resolved pixelpass understands `--strict-audio`: when `false` /// whether the resolved pixelpass understands `--strict-audio`: when `false`
/// (an older pixelpass) the picker must offer whole-desktop audio only, since /// (an older pixelpass) the picker must offer whole-desktop audio only, since
/// a per-app share would pass a flag that older binary rejects (audit P2). /// a per-app share would pass a flag that older binary rejects (audit P2).
AudioAppsListed { apps: Vec<String>, app_audio_supported: bool }, AudioAppsListed {
apps: Vec<String>,
app_audio_supported: bool,
},
/// Our own screen share started; the UI flips the Share button to "Stop". /// Our own screen share started; the UI flips the Share button to "Stop".
ScreenShareStarted, ScreenShareStarted,
/// Our own screen share stopped (or failed to start). /// Our own screen share stopped (or failed to start).
@@ -278,24 +531,37 @@ pub enum UiEvent {
/// A validly signed peer cannot be admitted because its gossip timestamp is /// A validly signed peer cannot be admitted because its gossip timestamp is
/// outside the replay freshness window. `peer_ahead` describes the peer's /// outside the replay freshness window. `peer_ahead` describes the peer's
/// sender-stamped timestamp relative to this machine's clock. /// sender-stamped timestamp relative to this machine's clock.
ClockSkewWarning { skew_secs: u64, peer_ahead: bool }, ClockSkewWarning {
skew_secs: u64,
peer_ahead: bool,
},
/// Our node identity (W7): the current node id string, and whether it is /// Our node identity (W7): the current node id string, and whether it is
/// PERSISTED to disk. Sent once at startup and again after a regenerate. /// PERSISTED to disk. Sent once at startup and again after a regenerate.
/// `persisted = false` means the key file couldn't be read/written and we're /// `persisted = false` means the key file couldn't be read/written and we're
/// running on an ephemeral fallback — a degraded state the UI must surface, /// running on an ephemeral fallback — a degraded state the UI must surface,
/// since the id (and thus friend recognition) won't survive the next launch. /// since the id (and thus friend recognition) won't survive the next launch.
/// `error` carries the reason when degraded, for the UI explainer. /// `error` carries the reason when degraded, for the UI explainer.
IdentityStatus { node_id: String, persisted: bool, error: Option<String> }, IdentityStatus {
node_id: String,
persisted: bool,
error: Option<String>,
},
/// The friends list (W7), now owned by core. Sent at startup (after load) and /// The friends list (W7), now owned by core. Sent at startup (after load) and
/// after every add/remove/rename so the GUI renders from this snapshot instead /// after every add/remove/rename so the GUI renders from this snapshot instead
/// of owning the store. `read_only` is true when `friends.json` failed to load /// of owning the store. `read_only` is true when `friends.json` failed to load
/// (malformed) — the GUI shows a degraded warning and disables edits so we never /// (malformed) — the GUI shows a degraded warning and disables edits so we never
/// overwrite the damaged file (backlog A16). /// overwrite the damaged file (backlog A16).
FriendsUpdated { friends: Vec<Friend>, read_only: bool }, FriendsUpdated {
friends: Vec<Friend>,
read_only: bool,
},
/// A friend's live presence from a successful ping reply (W7): online, or in a /// A friend's live presence from a successful ping reply (W7): online, or in a
/// joinable gathering (with a one-click ticket). Emitted by the outbound ping /// joinable gathering (with a one-click ticket). Emitted by the outbound ping
/// scheduler; absence of a recent event = treat as offline. /// scheduler; absence of a recent event = treat as offline.
FriendPresence { id: EndpointId, presence: FriendPresence }, FriendPresence {
id: EndpointId,
presence: FriendPresence,
},
/// A manual "Rescan" pass finished (every friend has been probed and its /// A manual "Rescan" pass finished (every friend has been probed and its
/// per-friend `FriendPresence` already emitted). Lets the GUI clear the /// per-friend `FriendPresence` already emitted). Lets the GUI clear the
/// transient "Rescanning…" status. Sent only for the on-demand button, not the /// transient "Rescanning…" status. Sent only for the on-demand button, not the
@@ -305,7 +571,9 @@ pub enum UiEvent {
/// time-box elapsed and the core auto-reverted to `Normal`; on discovery apply /// time-box elapsed and the core auto-reverted to `Normal`; on discovery apply
/// failure, this carries the previous truthful mode. The GUI must mirror + /// failure, this carries the previous truthful mode. The GUI must mirror +
/// persist this so its presence picker matches the endpoint's discovery state. /// persist this so its presence picker matches the endpoint's discovery state.
PresenceModeReverted { mode: PresenceMode }, PresenceModeReverted {
mode: PresenceMode,
},
/// The locally-detected running game changed (game detection). Carries the /// The locally-detected running game changed (game detection). Carries the
/// debounced `DetectedGame` (id + display name + source) or `None` when nothing /// debounced `DetectedGame` (id + display name + source) or `None` when nothing
/// is detected. The GUI uses the stable `id` to switch the per-game background /// is detected. The GUI uses the stable `id` to switch the per-game background
@@ -319,7 +587,7 @@ pub enum UiEvent {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{delivery_class, CoreCommand, DeliveryClass}; use super::{CoalesceKey, CoreCommand, DeliveryClass, coalesce_key, delivery_class};
use crate::audio::eq::EqSettings; use crate::audio::eq::EqSettings;
use crate::presence::PresenceMode; use crate::presence::PresenceMode;
use iroh::{EndpointId, SecretKey}; use iroh::{EndpointId, SecretKey};
@@ -332,17 +600,37 @@ mod tests {
fn continuous_audio_controls_are_best_effort() { fn continuous_audio_controls_are_best_effort() {
let peer = endpoint_id(); let peer = endpoint_id();
let commands = [ let commands = [
CoreCommand::SetPeerVolume(peer, 0.7), (
CoreCommand::SetPeerPan(peer, -0.2), CoreCommand::SetPeerVolume(peer, 0.7),
CoreCommand::SetPeerGate(peer, 0.1), CoalesceKey::PeerVolume(peer),
CoreCommand::SetPeerEq(peer, EqSettings::default()), ),
CoreCommand::SetInputVolume(0.8), (
CoreCommand::SetOutputVolume(0.9), CoreCommand::SetPeerPan(peer, -0.2),
CoreCommand::SetNoiseGateThreshold(0.02), CoalesceKey::PeerPan(peer),
),
(
CoreCommand::SetPeerGate(peer, 0.1),
CoalesceKey::PeerGate(peer),
),
(
CoreCommand::SetPeerEq(peer, EqSettings::default()),
CoalesceKey::PeerEq(peer),
),
(CoreCommand::SetInputVolume(0.8), CoalesceKey::InputVolume),
(CoreCommand::SetOutputVolume(0.9), CoalesceKey::OutputVolume),
(
CoreCommand::SetNoiseGateThreshold(0.02),
CoalesceKey::NoiseGate,
),
]; ];
for cmd in commands { for (cmd, key) in commands {
assert_eq!(delivery_class(&cmd), DeliveryClass::BestEffort); assert_eq!(delivery_class(&cmd), DeliveryClass::BestEffort);
assert_eq!(coalesce_key(&cmd), Some(key));
assert_eq!(
coalesce_key(&cmd).is_some(),
delivery_class(&cmd) == DeliveryClass::BestEffort
);
} }
} }
@@ -365,11 +653,20 @@ mod tests {
}, },
CoreCommand::SetPeerMuted(peer, true), CoreCommand::SetPeerMuted(peer, true),
CoreCommand::SetPresenceMode(PresenceMode::Normal), CoreCommand::SetPresenceMode(PresenceMode::Normal),
CoreCommand::SendChat("hello".to_string()), CoreCommand::SetAudioProfile(crate::config::AudioProfile::BadNetwork),
CoreCommand::SendChat {
local_id: 1,
text: "hello".to_string(),
},
]; ];
for cmd in commands { for cmd in commands {
assert_eq!(delivery_class(&cmd), DeliveryClass::Reliable); assert_eq!(delivery_class(&cmd), DeliveryClass::Reliable);
assert_eq!(coalesce_key(&cmd), None);
assert_eq!(
coalesce_key(&cmd).is_some(),
delivery_class(&cmd) == DeliveryClass::BestEffort
);
} }
} }
} }
+1322 -264
View File
File diff suppressed because it is too large Load Diff
+889
View File
@@ -0,0 +1,889 @@
//! Destruction-order guarantees for the screen-share children and the
//! echo-cancel module (phase 0b of the screenshare audio-exclusion plan;
//! design v3.4 §7.1–§7.2, decision D4).
//!
//! # The invariant
//!
//! > **The echo-cancel module must not unload while a pixelpass host is alive
//! > and fanning out.**
//!
//! If it does, the AEC's virtual nodes vanish from under a live pixelpass that
//! still holds link proxies and a stale module index. Phase 6 makes this sharp
//! — it is the first phase whose objects live only as long as pixelpass does —
//! so the ordering guarantee has to exist *before* it.
//!
//! Two paths have to honour it, and only one of them is code we get to run:
//!
//! 1. **The explicit path** — [`ScreenshareTeardown::shutdown_children`], awaited
//! by `ActiveSession::shutdown` before the guard is dropped.
//! 2. **The drop/unwind path** — nobody calls anything. The core has numerous
//! `unwrap()` sites and no `panic=abort` profile, so unwind is reachable, and
//! on that path the only thing standing between us and a violated invariant
//! is *field declaration order* plus [`ReapOnDrop`].
//!
//! Hence the two structural rules enforced here:
//!
//! - `echo_cancel` is the **last declared field** of [`ScreenshareTeardown`].
//! Rust drops fields in declaration order, so last-declared is last-dropped.
//! This is not a style choice; reversing it reintroduces the bug.
//! - Killing is not enough — a child must be **reaped**. `kill_on_drop(true)`
//! only *signals*; it hands the child to the runtime's orphan queue and
//! returns, which on an unwinding runtime may never be drained. [`ReapOnDrop`]
//! therefore blocks, briefly and boundedly, until the child is actually gone.
//!
//! Everything here is generic over [`ChildProcess`] and over the guard type so
//! the ordering is unit-testable without spawning processes or loading PipeWire
//! modules — the same seam idiom as `replace_viewer_index` and
//! `rebuild_with_fallback` in the parent module.
use std::future::Future;
use std::time::{Duration, Instant};
/// How long [`ReapOnDrop::drop`] will block waiting for a killed child to be
/// reaped before giving up and logging. This runs on the unwind path, so it is
/// a deliberate trade: a bounded stall is preferable to unloading the AEC out
/// from under a live pixelpass, and unbounded blocking in a `Drop` is not.
const REAP_BUDGET: Duration = Duration::from_millis(250);
/// Poll interval while waiting out [`REAP_BUDGET`].
const REAP_POLL: Duration = Duration::from_millis(5);
/// How long a child gets to honour the graceful stop before it is killed.
///
/// A healthy pixelpass exits in well under this, so the normal path never
/// spends it; only a wedged child does. It is awaited inline in the core
/// command loop, so it is also how long a wedged child can delay other
/// commands — hence seconds, not tens of seconds.
const STOP_GRACE: Duration = Duration::from_secs(2);
/// The child-process operations the teardown ordering actually depends on.
///
/// Deliberately narrow, and deliberately not `ExitStatus`-shaped: the ordering
/// rules care only about *whether* a child has been signalled and *whether* it
/// has been reaped, so the test double is a few lines instead of a fabricated
/// exit status.
pub(super) trait ChildProcess {
/// Ask the child to exit **gracefully**, so it can run its own cleanup.
/// Does **not** wait, and is not guaranteed to be honoured.
fn request_stop(&mut self) -> std::io::Result<()>;
/// Signal the child to die. Does **not** wait.
fn start_kill(&mut self) -> std::io::Result<()>;
/// Poll once. `true` once the child has exited **and been reaped**.
fn try_reap(&mut self) -> bool;
/// Wait until the child has exited and been reaped.
///
/// The `io::Result` is load-bearing and must not be discarded by callers:
/// a failed wait is *not* a confirmed reap, and treating it as one is how
/// the AEC ends up unloading over a live child.
fn wait_reaped(&mut self) -> impl Future<Output = std::io::Result<()>> + Send;
}
impl ChildProcess for tokio::process::Child {
/// **SIGINT, not SIGTERM.** pixelpass installs only a `tokio::signal::ctrl_c()`
/// handler (`pixelpass/src/common/signal.rs`), so SIGTERM would be the default
/// disposition — instant death, no cleanup — which is indistinguishable from
/// SIGKILL for our purposes.
///
/// Signalling by pid is safe against pid reuse here because we have not
/// reaped this child: an exited-but-unreaped child is a zombie whose pid the
/// kernel reserves until we `wait` it, so the pid cannot name a stranger.
#[cfg(unix)]
fn request_stop(&mut self) -> std::io::Result<()> {
let Some(pid) = self.id() else {
// Already reaped — nothing to signal.
return Ok(());
};
// SAFETY: `kill` is async-signal-safe and takes no pointers; the pid is
// this process's own unreaped child (see above).
if unsafe { libc::kill(pid as libc::pid_t, libc::SIGINT) } == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
/// Windows has no SIGINT to send to another process without attaching to its
/// console, so the graceful request degrades to the hard kill and the
/// bounded wait below simply returns early.
#[cfg(not(unix))]
fn request_stop(&mut self) -> std::io::Result<()> {
tokio::process::Child::start_kill(self)
}
fn start_kill(&mut self) -> std::io::Result<()> {
tokio::process::Child::start_kill(self)
}
fn try_reap(&mut self) -> bool {
matches!(self.try_wait(), Ok(Some(_)))
}
async fn wait_reaped(&mut self) -> std::io::Result<()> {
self.wait().await.map(|_| ())
}
}
/// Did the explicit stop path actually confirm the child was reaped?
///
/// The distinction is not cosmetic: on [`Unconfirmed`](Self::Unconfirmed) we
/// deliberately stopped waiting (see [`ReapOnDrop::shutdown`]), so pixelpass may
/// still be alive and fanning out. A user-initiated Stop Share must not report
/// that as a clean stop.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[must_use = "an unconfirmed stop means the child may still be sharing"]
pub(super) enum StopOutcome {
/// The child is gone and has been reaped.
Reaped,
/// We could not confirm the reap within the bound and gave up waiting.
Unconfirmed,
}
/// A child that is killed **and reaped** when it is dropped.
///
/// The explicit path calls [`shutdown`](Self::shutdown), which releases the
/// child only once its reap is *confirmed*, so the `Drop` below is a no-op
/// afterwards but stays armed through every await until then. `Drop` is the
/// last-ditch protection for the panic/unwind/cancellation paths.
pub(super) struct ReapOnDrop<C: ChildProcess> {
/// `None` once the child has been reaped through the explicit path.
child: Option<C>,
/// Names the child in the reap-timeout log line.
label: &'static str,
}
impl<C: ChildProcess> ReapOnDrop<C> {
pub(super) fn new(child: C, label: &'static str) -> Self {
Self {
child: Some(child),
label,
}
}
/// Poll once, without killing. `true` if the child has exited on its own —
/// used to sweep player windows the user has already closed.
pub(super) fn has_exited(&mut self) -> bool {
match &mut self.child {
Some(child) => {
if child.try_reap() {
self.child = None;
true
} else {
false
}
}
// Already reaped through the explicit path.
None => true,
}
}
/// Stop the child gracefully if it will go, and by force if it will not.
/// Waits for it to be reaped either way. Idempotent.
///
/// Ask, then insist (design v3.4 §7.4): a pixelpass host that gets SIGINT
/// unloads its capture sink on the way out, whereas SIGKILL skips that and
/// leaks a null-sink module on every Stop Share.
///
/// The wait is the point: returning after signalling would let the caller
/// proceed to unload the AEC while the child is still running.
///
/// ⚠️ The child stays owned by `self` across every `.await`, and is released
/// **only after a confirmed reap**. Taking it out first would disarm the
/// `Drop` fallback for exactly as long as the wait lasts: cancel or unwind
/// this future at that moment and the raw child would drop with nothing but
/// `kill_on_drop` (which signals without reaping) while `Drop` below found
/// `None` and did nothing — the precise hole this type exists to close.
pub(super) async fn shutdown(&mut self) -> StopOutcome {
let Some(child) = self.child.as_mut() else {
return StopOutcome::Reaped;
};
// Three different things can go wrong here and they want three
// different operator diagnoses: the signal never left (a runtime or
// permission fault), the child ignored it (a wedged pixelpass), or the
// wait itself broke (we no longer know anything about the child).
// Collapsing them into one line was P3-1 of the round-16 review.
if let Err(e) = child.request_stop() {
crate::log_msg(&format!(
"teardown: could not ask {} to stop: {e}",
self.label
));
}
match tokio::time::timeout(STOP_GRACE, child.wait_reaped()).await {
Ok(Ok(())) => {
self.child = None;
return StopOutcome::Reaped;
}
Ok(Err(e)) => crate::log_msg(&format!(
"teardown: waiting for {} failed ({e}); killing it",
self.label
)),
Err(_) => crate::log_msg(&format!(
"teardown: {} ignored the graceful stop within {STOP_GRACE:?}; killing it",
self.label
)),
}
if let Err(e) = child.start_kill() {
crate::log_msg(&format!(
"teardown: {} could not be killed: {e}",
self.label
));
}
// The second wait is bounded too. An unbounded one lets a process stuck
// in uninterruptible sleep wedge the core command loop forever, and a
// permanently frozen app is a worse failure than the risk below.
if let Ok(Ok(())) = tokio::time::timeout(STOP_GRACE, child.wait_reaped()).await {
self.child = None;
return StopOutcome::Reaped;
}
// Explicit policy for the one case where the two guarantees conflict:
// we could not confirm the reap and will NOT block indefinitely, so we
// give up availability-first and leave the child owned — `Drop`'s
// bounded retry stays armed, and the AEC may unload over a child that
// is still somehow alive. That residual risk is logged, not silent —
// and, for a user-initiated stop, reported to the caller rather than
// dressed up as success.
crate::log_msg(&format!(
"teardown: {} could not be confirmed dead; the echo-cancel module \
may unload while it lives",
self.label
));
StopOutcome::Unconfirmed
}
/// Is the `Drop` fallback still armed? Test-only: the arming rule is the
/// whole point of holding the child across the waits.
#[cfg(test)]
fn is_armed(&self) -> bool {
self.child.is_some()
}
}
impl<C: ChildProcess> Drop for ReapOnDrop<C> {
fn drop(&mut self) {
let Some(child) = self.child.as_mut() else {
return;
};
let _ = child.start_kill();
// `Drop` cannot await, so poll on a bounded budget. See `REAP_BUDGET`.
let deadline = Instant::now() + REAP_BUDGET;
loop {
if child.try_reap() {
return;
}
if Instant::now() >= deadline {
crate::log_msg(&format!(
"teardown: {} did not exit within the reap budget; \
continuing (the echo-cancel module may unload while it lives)",
self.label
));
return;
}
std::thread::sleep(REAP_POLL);
}
}
}
/// Everything in an `ActiveSession` whose **destruction order** is load-bearing.
///
/// ⚠️ Field order below **is** the invariant. `echo_cancel` is declared last so
/// it is dropped last, after every screen-share child has been killed and
/// reaped. Do not reorder these fields.
pub(super) struct ScreenshareTeardown<C: ChildProcess, G> {
/// Our pixelpass screen-share host child while sharing.
host: Option<ReapOnDrop<C>>,
/// pixelpass viewer children we spawned to watch peers' shares, each paired
/// with the share ticket it is viewing so a re-watch of the same share can
/// replace (not stack) its player.
viewers: Vec<(String, ReapOnDrop<C>)>,
/// Loaded PipeWire echo-cancel module (if enabled); unloads on drop.
///
/// ⚠️ **LAST FIELD ON PURPOSE** — see the module docs and the struct note.
///
/// Never read, and that is the design: the guard is held only so that its
/// `Drop` runs, and only so that it runs *here*, last. `dead_code` is right
/// that nothing reads it and wrong that it does nothing.
#[allow(dead_code)]
echo_cancel: Option<G>,
}
impl<C: ChildProcess, G> ScreenshareTeardown<C, G> {
pub(super) fn new(echo_cancel: Option<G>) -> Self {
Self {
host: None,
viewers: Vec::new(),
echo_cancel,
}
}
pub(super) fn is_sharing(&self) -> bool {
self.host.is_some()
}
pub(super) fn set_host(&mut self, child: C) {
self.host = Some(ReapOnDrop::new(child, "screen-share host"));
}
/// Stop sharing: kill the host and wait for it to be reaped. `None` if we
/// were not sharing; otherwise whether the reap was actually confirmed —
/// the caller owns telling the user, since an unconfirmed stop may leave
/// pixelpass fanning out after the UI says sharing ended.
pub(super) async fn stop_host(&mut self) -> Option<StopOutcome> {
let mut host = self.host.take()?;
Some(host.shutdown().await)
}
/// Drop viewers whose player window has already closed, so the list only
/// tracks live players.
pub(super) fn sweep_exited_viewers(&mut self) {
self.viewers.retain_mut(|(_, child)| !child.has_exited());
}
/// Kill and reap the viewer already showing `ticket`, if any, so a re-watch
/// replaces its player instead of stacking a second one.
pub(super) async fn replace_viewer(&mut self, ticket: &str) -> bool {
let Some(pos) = super::replace_viewer_index(&self.viewers, ticket) else {
return false;
};
let (_, mut old) = self.viewers.remove(pos);
// A viewer is our own player window, not the thing peers are watching:
// an unconfirmed reap is already logged, and there is no user decision
// riding on it the way there is for Stop Share.
let _ = old.shutdown().await;
true
}
pub(super) fn push_viewer(&mut self, ticket: String, child: C) {
self.viewers
.push((ticket, ReapOnDrop::new(child, "screen-share viewer")));
}
/// Kill and reap **every** screen-share child, host first so viewers see the
/// stream end promptly.
///
/// The caller must await this before the echo-cancel guard is dropped. On
/// the drop/unwind path nothing calls it and field order carries the
/// invariant instead.
pub(super) async fn shutdown_children(&mut self) {
// Outcomes are discarded on purpose: this runs on the session/teardown
// path, where the policy is already availability-first and the residual
// risk is logged by `shutdown` itself. There is no user still waiting
// on an answer here, unlike `stop_host`.
if let Some(host) = &mut self.host {
let _ = host.shutdown().await;
}
self.host = None;
for (_, viewer) in self.viewers.iter_mut() {
let _ = viewer.shutdown().await;
}
self.viewers.clear();
}
}
#[cfg(test)]
mod tests {
use super::{ChildProcess, ReapOnDrop, STOP_GRACE, ScreenshareTeardown, StopOutcome};
use std::future::Future;
use std::sync::{Arc, Mutex};
use std::time::Duration;
type Log = Arc<Mutex<Vec<String>>>;
fn log() -> Log {
Arc::new(Mutex::new(Vec::new()))
}
fn entries(log: &Log) -> Vec<String> {
log.lock().unwrap().clone()
}
fn position(log: &Log, entry: &str) -> Option<usize> {
entries(log).iter().position(|e| e == entry)
}
/// Records the events the ordering rules turn on. Death is gated on an
/// actual signal, so the double cannot report a reap that nothing caused.
struct FakeChild {
log: Log,
label: &'static str,
interrupted: bool,
killed: bool,
reaped: bool,
/// A well-behaved child exits on SIGINT. A wedged one ignores it and
/// dies only to SIGKILL.
honours_interrupt: bool,
/// When true the child is already dead before anyone signals it — the
/// closed-player-window case that `sweep_exited_viewers` looks for.
exited_on_its_own: bool,
/// Death is not instantaneous: `try_reap` reports the child alive this
/// many more times before it goes.
polls_before_death: u32,
/// `wait` reports an error instead of a reap.
wait_fails: bool,
}
impl FakeChild {
/// A well-behaved child: exits when asked.
fn new(log: &Log, label: &'static str) -> Self {
Self {
log: log.clone(),
label,
interrupted: false,
killed: false,
reaped: false,
honours_interrupt: true,
exited_on_its_own: false,
polls_before_death: 0,
wait_fails: false,
}
}
/// A child that ignores the graceful stop entirely.
fn wedged(log: &Log, label: &'static str) -> Self {
Self {
honours_interrupt: false,
..Self::new(log, label)
}
}
/// A child that does not die the instant it is signalled: `try_reap`
/// reports it alive for `polls` calls first. Without this the `Drop`
/// polling loop could be replaced by a single `try_reap` and no test
/// would notice.
fn reaps_after_polls(log: &Log, label: &'static str, polls: u32) -> Self {
Self {
polls_before_death: polls,
..Self::new(log, label)
}
}
/// A child that ignores SIGINT *and* does not die the instant it is
/// killed — the only shape that lets a test reach the post-SIGKILL
/// wait and still be reaped by the `Drop` poll loop afterwards.
fn wedged_then_dies_after_polls(log: &Log, label: &'static str, polls: u32) -> Self {
Self {
honours_interrupt: false,
polls_before_death: polls,
..Self::new(log, label)
}
}
/// A child whose `wait` fails. A failed wait is not a confirmed reap,
/// so it must not be reported as one.
fn wait_fails(log: &Log, label: &'static str) -> Self {
Self {
wait_fails: true,
..Self::new(log, label)
}
}
fn already_exited(log: &Log, label: &'static str) -> Self {
Self {
exited_on_its_own: true,
..Self::new(log, label)
}
}
/// Has anything actually made this child exit yet? A signalled child
/// still has to burn through `polls_before_death` first.
fn is_dead(&self) -> bool {
let signalled = self.killed
|| self.exited_on_its_own
|| (self.interrupted && self.honours_interrupt);
signalled && self.polls_before_death == 0
}
/// One observation of a dying-but-not-yet-dead child.
fn tick(&mut self) {
self.polls_before_death = self.polls_before_death.saturating_sub(1);
}
fn record(&self, event: &str) {
self.log
.lock()
.unwrap()
.push(format!("{}:{event}", self.label));
}
fn mark_reaped(&mut self) {
if !self.reaped {
self.reaped = true;
self.record("reap");
}
}
}
impl ChildProcess for FakeChild {
fn request_stop(&mut self) -> std::io::Result<()> {
if !self.interrupted {
self.interrupted = true;
self.record("sigint");
}
Ok(())
}
fn start_kill(&mut self) -> std::io::Result<()> {
if !self.killed {
self.killed = true;
self.record("kill");
}
Ok(())
}
fn try_reap(&mut self) -> bool {
if self.is_dead() {
self.mark_reaped();
return true;
}
self.tick();
false
}
/// Pending until something actually kills the child, so a wedged child
/// really does make the caller wait out `STOP_GRACE`. No waker is
/// registered: under `start_paused` the runtime auto-advances its clock
/// when every task is idle, which is exactly what fires the timeout.
fn wait_reaped(&mut self) -> impl Future<Output = std::io::Result<()>> + Send {
std::future::poll_fn(move |_cx| {
if self.wait_fails {
return std::task::Poll::Ready(Err(std::io::Error::other("wait failed")));
}
if self.is_dead() {
self.mark_reaped();
std::task::Poll::Ready(Ok(()))
} else {
std::task::Poll::Pending
}
})
}
}
/// Stands in for `EchoCancelGuard`, whose real `Drop` runs `pactl unload`.
struct FakeAec(Log);
impl Drop for FakeAec {
fn drop(&mut self) {
self.0.lock().unwrap().push("aec:unload".to_string());
}
}
fn teardown(log: &Log) -> ScreenshareTeardown<FakeChild, FakeAec> {
ScreenshareTeardown::new(Some(FakeAec(log.clone())))
}
// --- The drop/unwind path: field order + ReapOnDrop carry the invariant ---
/// Mutation gate #5 (remove the reap loop from `ReapOnDrop::drop`).
///
/// Asserts only that dropping a guard reaps, and reaps *after* killing —
/// deliberately says nothing about the AEC, so reversing the struct's field
/// order leaves this test green and only the ordering test below fails.
#[test]
fn dropping_a_guard_kills_and_then_reaps_the_child() {
let log = log();
drop(ReapOnDrop::new(FakeChild::new(&log, "host"), "host"));
assert_eq!(entries(&log), vec!["host:kill", "host:reap"]);
}
/// Mutation gate #4 (reverse the field order of `ScreenshareTeardown`).
///
/// Asserts only kill-before-unload, so removing the reap loop leaves this
/// test green and only the reap test above fails.
#[test]
fn the_aec_unloads_after_the_children_on_the_drop_path() {
let log = log();
let mut t = teardown(&log);
t.set_host(FakeChild::new(&log, "host"));
t.push_viewer("ticket-A".to_string(), FakeChild::new(&log, "viewer"));
drop(t);
let unload = position(&log, "aec:unload").expect("the AEC guard must be dropped");
let host_kill = position(&log, "host:kill").expect("the host must be killed");
let viewer_kill = position(&log, "viewer:kill").expect("the viewer must be killed");
assert!(
host_kill < unload,
"the AEC unloaded while the host was alive: {:?}",
entries(&log)
);
assert!(
viewer_kill < unload,
"the AEC unloaded while a viewer was alive: {:?}",
entries(&log)
);
}
/// The whole invariant in one sequence, as documentation.
#[test]
fn the_drop_path_reaps_every_child_before_unloading_the_aec() {
let log = log();
let mut t = teardown(&log);
t.set_host(FakeChild::new(&log, "host"));
drop(t);
assert_eq!(entries(&log), vec!["host:kill", "host:reap", "aec:unload"]);
}
// --- The explicit path: ask, then insist ---
/// A healthy child must be *asked*, never killed. If Stop Share went
/// straight to SIGKILL, pixelpass would skip its own cleanup and leak a
/// null-sink module every time (design v3.4 §7.4).
#[tokio::test]
async fn a_healthy_child_is_asked_to_stop_and_never_killed() {
let log = log();
let mut t = teardown(&log);
t.set_host(FakeChild::new(&log, "host"));
assert_eq!(t.stop_host().await, Some(StopOutcome::Reaped));
assert_eq!(entries(&log), vec!["host:sigint", "host:reap"]);
assert!(
!entries(&log).contains(&"host:kill".to_string()),
"a child that honoured the graceful stop must not be killed: {:?}",
entries(&log)
);
}
/// ...but a child that ignores the request must not be able to hold the
/// session open forever: the grace is bounded and SIGKILL follows.
#[tokio::test(start_paused = true)]
async fn a_wedged_child_is_killed_once_the_grace_expires() {
let log = log();
let mut t = teardown(&log);
t.set_host(FakeChild::wedged(&log, "host"));
// The outer bound turns "the fallback was removed" into a failure
// rather than a hung test. Under `start_paused` no real time passes.
let start = tokio::time::Instant::now();
tokio::time::timeout(Duration::from_secs(60), t.stop_host())
.await
.expect("a wedged child must not block teardown indefinitely");
assert_eq!(entries(&log), vec!["host:sigint", "host:kill", "host:reap"]);
assert!(
start.elapsed() >= STOP_GRACE,
"the child must actually be given the grace period, waited {:?}",
start.elapsed()
);
}
/// The assertion above compares elapsed time against `STOP_GRACE` itself,
/// so it stays vacuously true if the constant is set to zero — both sides
/// move together. Pin the constant independently: the whole point of the
/// graceful stop is that pixelpass gets a real interval in which to unload
/// its capture sink, and zero is not one.
#[test]
fn the_grace_is_a_real_interval() {
assert!(
STOP_GRACE >= Duration::from_millis(500),
"too short to let pixelpass tear its pipeline down: {STOP_GRACE:?}"
);
// ...and short enough that a wedged child cannot visibly stall the core
// command loop, which awaits this inline.
assert!(
STOP_GRACE <= Duration::from_secs(5),
"long enough to freeze the UI's command handling: {STOP_GRACE:?}"
);
}
/// The hole the whole type exists to close, and the one place the old
/// implementation left open: if `shutdown` is cancelled while waiting, the
/// child must still be owned, so dropping the guard still kills and reaps.
#[tokio::test(start_paused = true)]
async fn cancelling_shutdown_mid_wait_leaves_the_fallback_armed() {
let log = log();
let mut guard = ReapOnDrop::new(FakeChild::wedged(&log, "host"), "host");
// Cancel well inside the grace, while it is still waiting.
assert!(
tokio::time::timeout(STOP_GRACE / 4, guard.shutdown())
.await
.is_err(),
"the wedged child should still have been waiting when we cancelled"
);
assert!(
guard.is_armed(),
"a cancelled shutdown must not disarm the drop fallback"
);
drop(guard);
assert_eq!(entries(&log), vec!["host:sigint", "host:kill", "host:reap"]);
}
/// The test above only ever cancels during the *graceful* wait, so a
/// mutation that disarmed the wrapper between the two waits would survive
/// it (round-16 review, P3-3). This one cancels during the post-SIGKILL
/// wait — the window where we have already given up on cooperation and the
/// `Drop` fallback is the only thing left.
#[tokio::test(start_paused = true)]
async fn cancelling_shutdown_after_the_kill_leaves_the_fallback_armed() {
let log = log();
// Ignores SIGINT, so the grace expires and we reach the kill; then
// survives three polls, so the second wait is still pending when we
// cancel, and the drop loop still gets to reap it.
let mut guard = ReapOnDrop::new(
FakeChild::wedged_then_dies_after_polls(&log, "host", 3),
"host",
);
assert!(
tokio::time::timeout(STOP_GRACE + STOP_GRACE / 4, guard.shutdown())
.await
.is_err(),
"we should have been cancelled inside the post-kill wait"
);
assert_eq!(
entries(&log),
vec!["host:sigint", "host:kill"],
"the graceful stop must have expired and escalated before we cancelled"
);
assert!(
guard.is_armed(),
"cancelling after the kill must not disarm the drop fallback either"
);
drop(guard);
// The fake's `start_kill` is idempotent, so `Drop` re-signalling an
// already-killed child adds no entry; the *reap* is what proves the
// fallback ran to completion after we abandoned the wait.
assert_eq!(
entries(&log),
vec!["host:sigint", "host:kill", "host:reap"],
"Drop must poll until the child is actually gone"
);
}
/// A failed wait is not a reap. Reporting it as one is how the AEC ends up
/// unloading over a child that is still alive.
#[tokio::test(start_paused = true)]
async fn a_failed_wait_is_not_treated_as_a_confirmed_reap() {
let log = log();
let mut guard = ReapOnDrop::new(FakeChild::wait_fails(&log, "host"), "host");
assert_eq!(
guard.shutdown().await,
StopOutcome::Unconfirmed,
"a stop we could not confirm must not be reported as a clean one"
);
assert!(
!entries(&log).contains(&"host:reap".to_string()),
"nothing confirmed the reap: {:?}",
entries(&log)
);
assert!(
entries(&log).contains(&"host:kill".to_string()),
"a child that would not stop must still be escalated: {:?}",
entries(&log)
);
assert!(
guard.is_armed(),
"an unconfirmed reap must leave the drop fallback armed"
);
}
/// Death is not instantaneous, so the drop path has to keep polling. A
/// single `try_reap` in place of the loop must not pass.
#[test]
fn the_drop_path_polls_until_the_child_is_actually_gone() {
let log = log();
drop(ReapOnDrop::new(
FakeChild::reaps_after_polls(&log, "host", 3),
"host",
));
assert_eq!(entries(&log), vec!["host:kill", "host:reap"]);
}
/// Mutation gate #3 (remove the wait after the host kill).
#[tokio::test]
async fn explicit_shutdown_reaps_the_host_before_the_aec_can_unload() {
let log = log();
let mut t = teardown(&log);
t.set_host(FakeChild::new(&log, "host"));
t.push_viewer("ticket-A".to_string(), FakeChild::new(&log, "viewer"));
t.shutdown_children().await;
// Reaped by the explicit path — before the guard is anywhere near dropped.
assert_eq!(
entries(&log),
vec!["host:sigint", "host:reap", "viewer:sigint", "viewer:reap"],
"children must be stopped and reaped by the explicit path"
);
drop(t);
let unload = position(&log, "aec:unload").expect("the AEC guard must be dropped");
let host_reap = position(&log, "host:reap").expect("the host must be reaped");
assert!(host_reap < unload);
}
#[tokio::test]
async fn explicit_shutdown_is_idempotent_with_the_drop_path() {
let log = log();
let mut t = teardown(&log);
t.set_host(FakeChild::new(&log, "host"));
t.shutdown_children().await;
drop(t);
// Exactly one stop and one reap: the drop path must not re-signal a
// child the explicit path already took.
assert_eq!(
entries(&log),
vec!["host:sigint", "host:reap", "aec:unload"]
);
}
// --- Host/viewer bookkeeping ---
#[tokio::test]
async fn stop_host_reports_whether_it_was_sharing() {
let log = log();
let mut t = teardown(&log);
assert!(!t.is_sharing());
assert_eq!(t.stop_host().await, None, "not sharing: nothing to stop");
t.set_host(FakeChild::new(&log, "host"));
assert!(t.is_sharing());
assert_eq!(t.stop_host().await, Some(StopOutcome::Reaped));
assert!(!t.is_sharing());
assert_eq!(entries(&log), vec!["host:sigint", "host:reap"]);
}
#[test]
fn sweeping_drops_only_the_players_that_already_closed() {
let log = log();
let mut t = teardown(&log);
t.push_viewer(
"closed".to_string(),
FakeChild::already_exited(&log, "closed"),
);
t.push_viewer("live".to_string(), FakeChild::new(&log, "live"));
t.sweep_exited_viewers();
// The live player survives the sweep; only the closed one is dropped,
// and dropping it must not kill anything (it was already gone).
assert_eq!(t.viewers.len(), 1);
assert_eq!(t.viewers[0].0, "live");
assert_eq!(entries(&log), vec!["closed:reap"]);
}
#[tokio::test]
async fn re_watching_a_share_replaces_that_player_only() {
let log = log();
let mut t = teardown(&log);
t.push_viewer("ticket-A".to_string(), FakeChild::new(&log, "a"));
t.push_viewer("ticket-B".to_string(), FakeChild::new(&log, "b"));
assert!(t.replace_viewer("ticket-A").await);
assert_eq!(entries(&log), vec!["a:sigint", "a:reap"]);
assert_eq!(t.viewers.len(), 1);
assert_eq!(t.viewers[0].0, "ticket-B");
// A share we are not watching has nothing to replace.
assert!(!t.replace_viewer("ticket-C").await);
}
}
+29 -7
View File
@@ -283,8 +283,14 @@ mod tests {
let q = echo.len() / 4; let q = echo.len() / 4;
let early = erle(&echo[..q], &cleaned[..q]); let early = erle(&echo[..q], &cleaned[..q]);
let late = erle(&echo[3 * q..], &cleaned[3 * q..]); let late = erle(&echo[3 * q..], &cleaned[3 * q..]);
assert!(late > early + 10.0, "should improve markedly: early {early:.1} late {late:.1}"); assert!(
assert!(late > 20.0, "converged ERLE should exceed 20 dB, got {late:.1}"); late > early + 10.0,
"should improve markedly: early {early:.1} late {late:.1}"
);
assert!(
late > 20.0,
"converged ERLE should exceed 20 dB, got {late:.1}"
);
} }
#[test] #[test]
@@ -307,7 +313,10 @@ mod tests {
let mut aec = Nlms::new(128, 0.5, 1e-6); let mut aec = Nlms::new(128, 0.5, 1e-6);
let out = aec.process(&silent_ref, &near); let out = aec.process(&silent_ref, &near);
for (a, b) in near.iter().zip(&out) { for (a, b) in near.iter().zip(&out) {
assert!((a - b).abs() < 1e-6, "near-end should pass through: {a} vs {b}"); assert!(
(a - b).abs() < 1e-6,
"near-end should pass through: {a} vs {b}"
);
} }
} }
@@ -341,14 +350,24 @@ mod tests {
let mut late_hits = 0; let mut late_hits = 0;
for i in 0..far.len() { for i in 0..far.len() {
if dtd.update(far[i], mic[i]) { if dtd.update(far[i], mic[i]) {
if i < onset { early_hits += 1 } else { late_hits += 1 } if i < onset {
early_hits += 1
} else {
late_hits += 1
}
} }
} }
// Echo-only stretch should rarely trip; near-end stretch should trip a lot. // Echo-only stretch should rarely trip; near-end stretch should trip a lot.
let early_rate = early_hits as f32 / onset as f32; let early_rate = early_hits as f32 / onset as f32;
let late_rate = late_hits as f32 / (far.len() - onset) as f32; let late_rate = late_hits as f32 / (far.len() - onset) as f32;
assert!(early_rate < 0.10, "false-positive rate {early_rate:.2} too high"); assert!(
assert!(late_rate > 0.50, "missed double-talk, rate only {late_rate:.2}"); early_rate < 0.10,
"false-positive rate {early_rate:.2} too high"
);
assert!(
late_rate > 0.50,
"missed double-talk, rate only {late_rate:.2}"
);
} }
#[test] #[test]
@@ -380,6 +399,9 @@ mod tests {
erle_dtd > erle_no + 15.0, erle_dtd > erle_no + 15.0,
"DTD should hold the echo path: with {erle_dtd:.1} dB vs without {erle_no:.1} dB" "DTD should hold the echo path: with {erle_dtd:.1} dB vs without {erle_no:.1} dB"
); );
assert!(erle_dtd > 15.0, "held filter should still cancel echo: {erle_dtd:.1} dB"); assert!(
erle_dtd > 15.0,
"held filter should still cancel echo: {erle_dtd:.1} dB"
);
} }
} }
+4 -1
View File
@@ -115,7 +115,10 @@ mod tests {
let path = EchoPath::synthetic(480, 480, 0.5, 99); let path = EchoPath::synthetic(480, 480, 0.5, 99);
let echo = path.apply(&far); let echo = path.apply(&far);
let ratio = rms(&echo) / rms(&far); let ratio = rms(&echo) / rms(&far);
assert!((0.3..0.7).contains(&ratio), "echo/far rms ratio {ratio} off target"); assert!(
(0.3..0.7).contains(&ratio),
"echo/far rms ratio {ratio} off target"
);
} }
#[test] #[test]
+4 -1
View File
@@ -141,7 +141,10 @@ mod tests {
buf[0] = Complex::new(1.0, 0.0); buf[0] = Complex::new(1.0, 0.0);
fft(&mut buf); fft(&mut buf);
for c in &buf { for c in &buf {
assert!(approx(c.magnitude(), 1.0, 1e-9), "expected flat 1.0, got {c:?}"); assert!(
approx(c.magnitude(), 1.0, 1e-9),
"expected flat 1.0, got {c:?}"
);
} }
} }
+25 -5
View File
@@ -62,11 +62,31 @@ pub struct Band {
/// Voice-relevant bands for spotting *where* residual echo or noise lives. /// Voice-relevant bands for spotting *where* residual echo or noise lives.
pub const VOICE_BANDS: &[Band] = &[ pub const VOICE_BANDS: &[Band] = &[
Band { label: "low (80-300)", low_hz: 80.0, high_hz: 300.0 }, Band {
Band { label: "low-mid (300-1k)", low_hz: 300.0, high_hz: 1000.0 }, label: "low (80-300)",
Band { label: "mid (1k-3k)", low_hz: 1000.0, high_hz: 3000.0 }, low_hz: 80.0,
Band { label: "high-mid (3k-6k)", low_hz: 3000.0, high_hz: 6000.0 }, high_hz: 300.0,
Band { label: "high (6k-12k)", low_hz: 6000.0, high_hz: 12000.0 }, },
Band {
label: "low-mid (300-1k)",
low_hz: 300.0,
high_hz: 1000.0,
},
Band {
label: "mid (1k-3k)",
low_hz: 1000.0,
high_hz: 3000.0,
},
Band {
label: "high-mid (3k-6k)",
low_hz: 3000.0,
high_hz: 6000.0,
},
Band {
label: "high (6k-12k)",
low_hz: 6000.0,
high_hz: 12000.0,
},
]; ];
/// Sums the linear magnitude energy within `[low_hz, high_hz)` across a single /// Sums the linear magnitude energy within `[low_hz, high_hz)` across a single
+7 -2
View File
@@ -202,7 +202,8 @@ fn legend(opts: &RenderOpts) -> String {
if opts.ascii { if opts.ascii {
for i in 0..steps { for i in 0..steps {
let v = i as f32 / (steps - 1) as f32; let v = i as f32 / (steps - 1) as f32;
let idx = ((v * (ASCII_RAMP.len() - 1) as f32).round() as usize).min(ASCII_RAMP.len() - 1); let idx =
((v * (ASCII_RAMP.len() - 1) as f32).round() as usize).min(ASCII_RAMP.len() - 1);
s.push(ASCII_RAMP[idx] as char); s.push(ASCII_RAMP[idx] as char);
} }
} else { } else {
@@ -243,7 +244,11 @@ mod tests {
fn render_produces_grid_of_expected_height() { fn render_produces_grid_of_expected_height() {
let sig = generators::sine(2000.0, 0.8, 48_000, 48_000); let sig = generators::sine(2000.0, 0.8, 48_000, 48_000);
let spec = stft::analyze(&sig, 48_000, 1024, 512); let spec = stft::analyze(&sig, 48_000, 1024, 512);
let opts = RenderOpts { width: 40, height: 10, ..Default::default() }; let opts = RenderOpts {
width: 40,
height: 10,
..Default::default()
};
let out = render(&spec, &opts); let out = render(&spec, &opts);
// Header + 10 body rows + time axis (2) + legend = non-trivial. // Header + 10 body rows + time axis (2) + legend = non-trivial.
let lines = out.lines().count(); let lines = out.lines().count();
+4 -1
View File
@@ -94,7 +94,10 @@ mod tests {
.unwrap() .unwrap()
.0; .0;
let peak_hz = s.bin_hz(peak_bin); let peak_hz = s.bin_hz(peak_bin);
assert!((peak_hz - freq as f32).abs() < 100.0, "peak at {peak_hz} Hz, want {freq}"); assert!(
(peak_hz - freq as f32).abs() < 100.0,
"peak at {peak_hz} Hz, want {freq}"
);
} }
#[test] #[test]
+13 -3
View File
@@ -34,7 +34,12 @@ pub fn read(path: &Path) -> Result<WavData, String> {
let mut pos = 12usize; let mut pos = 12usize;
while pos + 8 <= bytes.len() { while pos + 8 <= bytes.len() {
let id = &bytes[pos..pos + 4]; let id = &bytes[pos..pos + 4];
let size = u32::from_le_bytes([bytes[pos + 4], bytes[pos + 5], bytes[pos + 6], bytes[pos + 7]]) as usize; let size = u32::from_le_bytes([
bytes[pos + 4],
bytes[pos + 5],
bytes[pos + 6],
bytes[pos + 7],
]) as usize;
let body_start = pos + 8; let body_start = pos + 8;
let body_end = (body_start + size).min(bytes.len()); let body_end = (body_start + size).min(bytes.len());
match id { match id {
@@ -45,7 +50,9 @@ pub fn read(path: &Path) -> Result<WavData, String> {
sample_rate = u32::from_le_bytes([fmt[4], fmt[5], fmt[6], fmt[7]]); sample_rate = u32::from_le_bytes([fmt[4], fmt[5], fmt[6], fmt[7]]);
bits = u16::from_le_bytes([fmt[14], fmt[15]]); bits = u16::from_le_bytes([fmt[14], fmt[15]]);
if audio_format != 1 { if audio_format != 1 {
return Err(format!("unsupported WAV format tag {audio_format} (need PCM=1)")); return Err(format!(
"unsupported WAV format tag {audio_format} (need PCM=1)"
));
} }
} }
b"data" => { b"data" => {
@@ -75,7 +82,10 @@ pub fn read(path: &Path) -> Result<WavData, String> {
samples.push(avg / 32768.0); samples.push(avg / 32768.0);
} }
Ok(WavData { samples, sample_rate }) Ok(WavData {
samples,
sample_rate,
})
} }
/// Writes mono `f32` samples (clamped to `[-1, 1]`) as a 16-bit PCM WAV. Used by /// Writes mono `f32` samples (clamped to `[-1, 1]`) as a 16-bit PCM WAV. Used by
+350 -13
View File
@@ -22,6 +22,22 @@ pub const MAX_ATTACHMENT_BYTES: u64 = 25 * 1024 * 1024;
/// the byte cap. Applied via `image::Limits` when validating/decoding. /// the byte cap. Applied via `image::Limits` when validating/decoding.
pub const MAX_IMAGE_PX: u32 = 4096; pub const MAX_IMAGE_PX: u32 = 4096;
/// Max total decoded pixels, applied on top of the per-side [`MAX_IMAGE_PX`]
/// limit. The per-side cap alone still admits a 4096×4096 ≈ 16.8 MP bitmap
/// (~64 MiB transient RGBA); this bounds the worst-case decode allocation while
/// still clearing common 12 MP phone photos (4032×3024 ≈ 12.2 MP).
pub const MAX_IMAGE_TOTAL_PIXELS: u64 = 14_000_000;
/// Max pixels per side of the downscaled inline preview handed to the renderer.
/// Original bytes are kept only for Save; the chat column never needs more than
/// this (it displays at ~260 px, and the lightbox at window size).
pub const IMAGE_PREVIEW_MAX_SIDE: u32 = 1600;
/// Largest declared size an image attachment may auto-fetch at. Anything larger
/// (or any skipped/evicted image) renders a "Load image" button instead; a
/// manual click may use the full [`MAX_ATTACHMENT_BYTES`] cap.
pub const MAX_AUTO_IMAGE_BYTES: u64 = 4 * 1024 * 1024;
/// Longest filename we keep and display. Keeps the gossip descriptor compact and /// Longest filename we keep and display. Keeps the gossip descriptor compact and
/// the UI tidy; the real bytes are unaffected. /// the UI tidy; the real bytes are unaffected.
pub const MAX_FILENAME_LEN: usize = 96; pub const MAX_FILENAME_LEN: usize = 96;
@@ -75,10 +91,13 @@ pub fn sanitize_filename(raw: &str) -> String {
.unwrap_or("") .unwrap_or("")
.trim(); .trim();
// Drop control chars; turn other whitespace into single spaces later. // Drop control chars and the same bidi/zero-width spoofing format chars
// stripped from display names (a U+202E override can visually reverse an
// extension, e.g. "photo\u{202E}gnp.exe" renders as "photoexe.png").
// Ordinary non-ASCII filenames pass through untouched.
let cleaned: String = base let cleaned: String = base
.chars() .chars()
.filter(|c| !c.is_control()) .filter(|c| !c.is_control() && !crate::sanitize::is_spoofing_format_char(*c))
.collect(); .collect();
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" "); let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
let collapsed = collapsed.trim_matches('.').trim(); let collapsed = collapsed.trim_matches('.').trim();
@@ -145,7 +164,10 @@ pub fn looks_like_audio_name(name: &str) -> bool {
let Some((_, extension)) = name.rsplit_once('.') else { let Some((_, extension)) = name.rsplit_once('.') else {
return false; return false;
}; };
matches!(extension.to_ascii_lowercase().as_str(), "wav" | "mp3" | "ogg" | "oga" | "flac") matches!(
extension.to_ascii_lowercase().as_str(),
"wav" | "mp3" | "ogg" | "oga" | "flac"
)
} }
/// The attachment kind for some file bytes: [`AttachmentKind::Image`] if it /// The attachment kind for some file bytes: [`AttachmentKind::Image`] if it
@@ -164,20 +186,185 @@ pub fn classify(bytes: &[u8]) -> AttachmentKind {
/// regardless of [`MAX_ATTACHMENT_BYTES`]. Only PNG/JPEG are buildable in our /// regardless of [`MAX_ATTACHMENT_BYTES`]. Only PNG/JPEG are buildable in our
/// `image` feature set; anything else returns `None` and the caller shows a chip. /// `image` feature set; anything else returns `None` and the caller shows a chip.
pub fn validate_image_bytes(bytes: &[u8]) -> Option<(u32, u32)> { pub fn validate_image_bytes(bytes: &[u8]) -> Option<(u32, u32)> {
let mut limits = image::Limits::default(); let img = decode_image_bounded(bytes)?;
limits.max_image_width = Some(MAX_IMAGE_PX); Some((img.width(), img.height()))
limits.max_image_height = Some(MAX_IMAGE_PX); }
/// Shared bounded decode: header-check the dimensions (per-side AND total-pixel
/// limits) BEFORE decoding, then decode under `image::Limits` as defense in
/// depth. The precheck reads only the container header, so an over-limit bomb is
/// rejected without paying its decode cost.
fn decode_image_bounded(bytes: &[u8]) -> Option<image::DynamicImage> {
let reader = image::ImageReader::new(std::io::Cursor::new(bytes)) let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format() .with_guessed_format()
.ok()?; .ok()?;
let mut reader = reader; let (w, h) = reader.into_dimensions().ok()?;
reader.limits(limits);
let img = reader.decode().ok()?;
let (w, h) = (img.width(), img.height());
if w == 0 || h == 0 || w > MAX_IMAGE_PX || h > MAX_IMAGE_PX { if w == 0 || h == 0 || w > MAX_IMAGE_PX || h > MAX_IMAGE_PX {
return None; return None;
} }
Some((w, h)) if u64::from(w) * u64::from(h) > MAX_IMAGE_TOTAL_PIXELS {
return None;
}
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_IMAGE_PX);
limits.max_image_height = Some(MAX_IMAGE_PX);
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()?;
reader.limits(limits);
let img = reader.decode().ok()?;
// Decoded size must match the header the precheck approved.
if img.width() != w || img.height() != h {
return None;
}
Some(img)
}
/// A decoded, display-ready inline preview: RGBA pixels downscaled so neither
/// side exceeds [`IMAGE_PREVIEW_MAX_SIDE`]. `rgba.len() == width * height * 4`,
/// which is also the preview's decoded-budget weight in the attachment cache.
pub struct ImagePreview {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
/// Decode image bytes under the same limits as [`validate_image_bytes`] and
/// build the downscaled inline preview. The full-resolution bitmap exists only
/// transiently here; the renderer is never handed more than
/// [`IMAGE_PREVIEW_MAX_SIDE`]² pixels. Returns `None` for anything that fails
/// validation (caller falls back to a chip / failure row).
pub fn decode_preview(bytes: &[u8]) -> Option<ImagePreview> {
let img = decode_image_bounded(bytes)?;
let img = if img.width() > IMAGE_PREVIEW_MAX_SIDE || img.height() > IMAGE_PREVIEW_MAX_SIDE {
// `thumbnail` preserves aspect ratio within the bounding box.
img.thumbnail(IMAGE_PREVIEW_MAX_SIDE, IMAGE_PREVIEW_MAX_SIDE)
} else {
img
};
let rgba = img.into_rgba8();
let (width, height) = (rgba.width(), rgba.height());
Some(ImagePreview {
width,
height,
rgba: rgba.into_raw(),
})
}
/// Estimated decoded RGBA cost of a preview, the weight counted against the
/// attachment cache's decoded-byte budget (`width * height * 4`).
pub fn preview_rgba_cost(width: u32, height: u32) -> usize {
(width as usize)
.saturating_mul(height as usize)
.saturating_mul(4)
}
/// Read at most [`MAX_ATTACHMENT_BYTES`] bytes from `r`. Returns `Ok(None)` if
/// the source holds even one byte more (detected by reading cap + 1), so a huge
/// or unbounded source is never fully buffered. Pure over `Read` for tests; the
/// picker wraps it via [`read_file_capped`].
pub fn read_capped<R: std::io::Read>(r: R) -> std::io::Result<Option<Vec<u8>>> {
use std::io::Read as _;
let mut buf = Vec::new();
let mut limited = r.take(MAX_ATTACHMENT_BYTES + 1);
limited.read_to_end(&mut buf)?;
if buf.len() as u64 > MAX_ATTACHMENT_BYTES {
return Ok(None);
}
Ok(Some(buf))
}
/// Read a picked file, bounded by [`MAX_ATTACHMENT_BYTES`]. Checks metadata
/// first to reject an obviously-oversized file without opening it, but keeps the
/// bounded read regardless — metadata can race (the file can grow after the
/// check) or be unavailable through a portal. `Ok(None)` = over the cap.
pub fn read_file_capped(path: &std::path::Path) -> std::io::Result<Option<Vec<u8>>> {
if let Ok(meta) = std::fs::metadata(path)
&& meta.len() > MAX_ATTACHMENT_BYTES
{
return Ok(None);
}
read_capped(std::fs::File::open(path)?)
}
/// Cap on how many blobs the session serve store retains at once (sent chat
/// attachments plus the current/next broadcast music tracks).
pub const SERVED_FILES_MAX_ENTRIES: usize = 16;
/// Byte budget for the serve store. Without it, a sender's own session could
/// grow unbounded at up to [`MAX_ATTACHMENT_BYTES`] per send (Phase 3C).
pub const SERVED_FILES_MAX_BYTES: usize = 128 * 1024 * 1024;
/// Count- and byte-budgeted FIFO store of blobs we serve to room members over
/// the file plane. Evicting an id makes a later request for it read as an empty
/// body — the existing "sender no longer has the file" response — never stale
/// or aliased bytes. Pure (no locks/IO) so budgets are unit-testable; the
/// transport wraps it in its own mutex.
#[derive(Debug, Default)]
pub struct ServeStore {
entries: std::collections::HashMap<AttachmentId, std::sync::Arc<Vec<u8>>>,
/// Present ids in insertion order; the front is the eviction candidate.
order: std::collections::VecDeque<AttachmentId>,
total_bytes: usize,
}
impl ServeStore {
/// Insert or replace a blob, evicting oldest entries until the count and
/// byte budgets fit. Replacement keeps the id's age and subtracts the old
/// bytes before the new ones are counted. Returns `false` for a blob that
/// alone exceeds the byte budget (not stored; an existing entry under the
/// id is dropped rather than left stale).
pub fn insert(&mut self, id: AttachmentId, bytes: std::sync::Arc<Vec<u8>>) -> bool {
if let Some(old) = self.entries.get(&id) {
self.total_bytes -= old.len();
}
if bytes.len() > SERVED_FILES_MAX_BYTES {
if self.entries.remove(&id).is_some() {
self.order.retain(|k| k != &id);
}
return false;
}
let replacing = self.entries.contains_key(&id);
loop {
let count_full = !replacing && self.entries.len() >= SERVED_FILES_MAX_ENTRIES;
let bytes_full = self.total_bytes + bytes.len() > SERVED_FILES_MAX_BYTES;
if !count_full && !bytes_full {
break;
}
let Some(victim) = self.order.iter().find(|k| **k != id).copied() else {
break;
};
self.remove(&victim);
}
if !replacing {
self.order.push_back(id);
}
self.total_bytes += bytes.len();
self.entries.insert(id, bytes);
true
}
pub fn get(&self, id: &AttachmentId) -> Option<std::sync::Arc<Vec<u8>>> {
self.entries.get(id).cloned()
}
pub fn remove(&mut self, id: &AttachmentId) {
if let Some(old) = self.entries.remove(id) {
self.total_bytes -= old.len();
self.order.retain(|k| k != id);
}
}
pub fn clear(&mut self) {
self.entries.clear();
self.order.clear();
self.total_bytes = 0;
}
#[cfg(test)]
fn len(&self) -> usize {
self.entries.len()
}
} }
/// Parse a file-plane request: it must be exactly one [`AttachmentId`] (32 /// Parse a file-plane request: it must be exactly one [`AttachmentId`] (32
@@ -240,7 +427,11 @@ mod tests {
let long_stem = "x".repeat(200); let long_stem = "x".repeat(200);
let name = format!("{long_stem}.png"); let name = format!("{long_stem}.png");
let out = sanitize_filename(&name); let out = sanitize_filename(&name);
assert!(out.chars().count() <= MAX_FILENAME_LEN, "len was {}", out.chars().count()); assert!(
out.chars().count() <= MAX_FILENAME_LEN,
"len was {}",
out.chars().count()
);
assert!(out.ends_with(".png"), "extension preserved: {out}"); assert!(out.ends_with(".png"), "extension preserved: {out}");
} }
@@ -254,7 +445,9 @@ mod tests {
#[test] #[test]
fn image_sniffing_recognizes_containers() { fn image_sniffing_recognizes_containers() {
assert!(is_probably_image(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0])); assert!(is_probably_image(&[
0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0
]));
assert!(is_probably_image(&[0xFF, 0xD8, 0xFF, 0xE0])); assert!(is_probably_image(&[0xFF, 0xD8, 0xFF, 0xE0]));
assert!(is_probably_image(b"GIF89a....")); assert!(is_probably_image(b"GIF89a...."));
let mut webp = b"RIFF".to_vec(); let mut webp = b"RIFF".to_vec();
@@ -346,6 +539,87 @@ mod tests {
assert_eq!(validate_image_bytes(&buf.into_inner()), Some((4, 3))); assert_eq!(validate_image_bytes(&buf.into_inner()), Some((4, 3)));
} }
#[test]
fn sanitize_strips_bidi_and_zero_width_spoofing_chars() {
// U+202E would visually reverse the tail, disguising the extension.
assert_eq!(sanitize_filename("photo\u{202E}gnp.exe"), "photognp.exe");
assert_eq!(sanitize_filename("a\u{200B}b\u{FEFF}.txt"), "ab.txt");
// Ordinary Unicode filenames pass through.
assert_eq!(sanitize_filename("família_fotos.png"), "família_fotos.png");
assert_eq!(sanitize_filename("日本語.pdf"), "日本語.pdf");
}
/// Encode a solid PNG of the given dimensions for limit tests.
fn png_bytes(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbImage::from_pixel(w, h, image::Rgb([10, 20, 30]));
let mut buf = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(img)
.write_to(&mut buf, image::ImageFormat::Png)
.unwrap();
buf.into_inner()
}
#[test]
fn validate_image_rejects_excessive_total_pixels() {
// Both sides within MAX_IMAGE_PX, but 4096 * 4096 > MAX_IMAGE_TOTAL_PIXELS.
assert!(u64::from(MAX_IMAGE_PX) * u64::from(MAX_IMAGE_PX) > MAX_IMAGE_TOTAL_PIXELS);
assert_eq!(validate_image_bytes(&png_bytes(4096, 4096)), None);
// A 12 MP phone-photo shape passes both limits.
assert_eq!(
validate_image_bytes(&png_bytes(4032, 3024)),
Some((4032, 3024))
);
}
#[test]
fn preview_downscales_to_max_side_preserving_aspect() {
// Wide: 3200x400 → 1600x200.
let p = decode_preview(&png_bytes(3200, 400)).unwrap();
assert_eq!((p.width, p.height), (1600, 200));
assert_eq!(p.rgba.len(), preview_rgba_cost(1600, 200));
// Tall: 400x3200 → 200x1600.
let p = decode_preview(&png_bytes(400, 3200)).unwrap();
assert_eq!((p.width, p.height), (200, 1600));
// Square over the side cap: 2000x2000 → 1600x1600.
let p = decode_preview(&png_bytes(2000, 2000)).unwrap();
assert_eq!((p.width, p.height), (1600, 1600));
// At/under the cap is untouched.
let p = decode_preview(&png_bytes(1600, 900)).unwrap();
assert_eq!((p.width, p.height), (1600, 900));
let p = decode_preview(&png_bytes(4, 3)).unwrap();
assert_eq!((p.width, p.height), (4, 3));
assert_eq!(p.rgba.len(), preview_rgba_cost(4, 3));
}
#[test]
fn preview_rejects_what_validation_rejects() {
assert!(decode_preview(b"not an image").is_none());
assert!(decode_preview(&png_bytes(4096, 4096)).is_none());
}
#[test]
fn read_capped_stops_at_cap_plus_one() {
// Under the cap: full read.
let small = vec![7u8; 1024];
assert_eq!(
read_capped(std::io::Cursor::new(&small))
.unwrap()
.as_deref(),
Some(&small[..])
);
// Exactly at the cap: accepted. `repeat` is endless, `take` proves the
// reader is bounded rather than draining the source.
let at_cap = std::io::Read::take(std::io::repeat(1), MAX_ATTACHMENT_BYTES);
let got = read_capped(at_cap).unwrap().unwrap();
assert_eq!(got.len() as u64, MAX_ATTACHMENT_BYTES);
// One byte over: rejected, and only cap + 1 bytes were ever buffered
// (an unbounded source returns instead of allocating forever).
let over = std::io::Read::take(std::io::repeat(1), MAX_ATTACHMENT_BYTES + 1);
assert_eq!(read_capped(over).unwrap(), None);
let endless = std::io::repeat(1);
assert_eq!(read_capped(endless).unwrap(), None);
}
#[test] #[test]
fn human_size_units() { fn human_size_units() {
assert_eq!(human_size(40), "40 B"); assert_eq!(human_size(40), "40 B");
@@ -353,6 +627,69 @@ mod tests {
assert_eq!(human_size(3 * 1024 * 1024 + 300 * 1024), "3.3 MB"); assert_eq!(human_size(3 * 1024 * 1024 + 300 * 1024), "3.3 MB");
} }
#[test]
fn serve_store_count_and_byte_eviction_fifo() {
use std::sync::Arc;
let mut s = ServeStore::default();
let blob = |n: u8, len: usize| ([n; 32], Arc::new(vec![n; len]));
// Count cap: entry 0 is evicted when the 17th arrives.
for n in 0..=SERVED_FILES_MAX_ENTRIES as u8 {
let (id, b) = blob(n, 8);
assert!(s.insert(id, b));
}
assert_eq!(s.len(), SERVED_FILES_MAX_ENTRIES);
assert!(s.get(&[0u8; 32]).is_none(), "oldest evicted by count");
assert!(s.get(&[1u8; 32]).is_some());
// Byte budget: two ~half-budget blobs evict everything older.
let half = SERVED_FILES_MAX_BYTES / 2;
let (a, ab) = blob(100, half);
let (b, bb) = blob(101, half);
assert!(s.insert(a, ab));
assert!(s.insert(b, bb));
assert!(s.get(&a).is_some());
assert!(s.get(&b).is_some());
assert!(s.get(&[1u8; 32]).is_none(), "evicted for byte budget");
// A third half-budget blob evicts `a` (oldest), keeps `b`.
let (c, cb) = blob(102, half);
assert!(s.insert(c, cb));
assert!(s.get(&a).is_none());
assert!(s.get(&b).is_some());
assert!(s.get(&c).is_some());
}
#[test]
fn serve_store_replacement_accounting_and_remove_clear() {
use std::sync::Arc;
let mut s = ServeStore::default();
let id = [9u8; 32];
assert!(s.insert(id, Arc::new(vec![1; SERVED_FILES_MAX_BYTES - 10])));
// Replacing the near-budget blob must subtract its old bytes first —
// otherwise this same-id replacement would evict itself.
assert!(s.insert(id, Arc::new(vec![2; SERVED_FILES_MAX_BYTES - 5])));
assert_eq!(s.get(&id).unwrap()[0], 2);
assert_eq!(s.len(), 1);
s.remove(&id);
assert!(s.get(&id).is_none());
// Removed bytes were released: the budget admits a full-size blob again.
assert!(s.insert(id, Arc::new(vec![3; SERVED_FILES_MAX_BYTES])));
s.clear();
assert_eq!(s.len(), 0);
assert!(s.insert(id, Arc::new(vec![4; SERVED_FILES_MAX_BYTES])));
}
#[test]
fn serve_store_rejects_individually_overweight_blob() {
use std::sync::Arc;
let mut s = ServeStore::default();
let id = [7u8; 32];
assert!(s.insert(id, Arc::new(vec![1; 8])));
assert!(!s.insert(id, Arc::new(vec![2; SERVED_FILES_MAX_BYTES + 1])));
// The stale small blob is gone too — a fetch reads "no longer has it",
// never old bytes under a replaced id.
assert!(s.get(&id).is_none());
assert_eq!(s.len(), 0);
}
#[test] #[test]
fn attachment_descriptor_round_trips_json() { fn attachment_descriptor_round_trips_json() {
let a = ChatAttachment { let a = ChatAttachment {
+22 -8
View File
@@ -66,7 +66,11 @@ impl FriendStore {
if self.contains(&id) { if self.contains(&id) {
return false; return false;
} }
self.friends.push(Friend { id, name, last_addr: addr }); self.friends.push(Friend {
id,
name,
last_addr: addr,
});
true true
} }
@@ -118,21 +122,24 @@ pub fn friends_path() -> Option<PathBuf> {
/// *parse* error bubbles up so a hand-edit being debugged isn't silently /// *parse* error bubbles up so a hand-edit being debugged isn't silently
/// overwritten with an empty list. /// overwritten with an empty list.
pub fn load() -> Result<FriendStore> { pub fn load() -> Result<FriendStore> {
let path = friends_path().context("could not determine a config directory for the friends list")?; let path =
friends_path().context("could not determine a config directory for the friends list")?;
load_at(&path) load_at(&path)
} }
/// Save the store. Atomic via tempfile-in-same-dir + rename. /// Save the store. Atomic via tempfile-in-same-dir + rename.
pub fn save(store: &FriendStore) -> Result<()> { pub fn save(store: &FriendStore) -> Result<()> {
let path = friends_path().context("could not determine a config directory for the friends list")?; let path =
friends_path().context("could not determine a config directory for the friends list")?;
save_at(&path, store) save_at(&path, store)
} }
/// Path-injectable core of [`load`], so the round-trip is testable in a temp dir. /// Path-injectable core of [`load`], so the round-trip is testable in a temp dir.
fn load_at(path: &Path) -> Result<FriendStore> { fn load_at(path: &Path) -> Result<FriendStore> {
match fs::read_to_string(path) { match fs::read_to_string(path) {
Ok(s) => serde_json::from_str(&s) Ok(s) => {
.with_context(|| format!("failed to parse {}", path.display())), serde_json::from_str(&s).with_context(|| format!("failed to parse {}", path.display()))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FriendStore::default()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FriendStore::default()),
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())), Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
} }
@@ -141,11 +148,14 @@ fn load_at(path: &Path) -> Result<FriendStore> {
/// Path-injectable core of [`save`]. Atomic write: tempfile-in-same-dir, then /// Path-injectable core of [`save`]. Atomic write: tempfile-in-same-dir, then
/// rename, so a crash mid-write can't leave a truncated list. /// rename, so a crash mid-write can't leave a truncated list.
fn save_at(path: &Path, store: &FriendStore) -> Result<()> { fn save_at(path: &Path, store: &FriendStore) -> Result<()> {
let parent = path.parent().context("friends path has no parent directory")?; let parent = path
.parent()
.context("friends path has no parent directory")?;
fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?;
let json = serde_json::to_string_pretty(store).context("failed to encode the friends list")?; let json = serde_json::to_string_pretty(store).context("failed to encode the friends list")?;
let tmp = parent.join(format!(".friends.json.tmp.{}", std::process::id())); let tmp = parent.join(format!(".friends.json.tmp.{}", std::process::id()));
fs::write(&tmp, json.as_bytes()).with_context(|| format!("failed to write {}", tmp.display()))?; fs::write(&tmp, json.as_bytes())
.with_context(|| format!("failed to write {}", tmp.display()))?;
fs::rename(&tmp, path) fs::rename(&tmp, path)
.with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?; .with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?;
Ok(()) Ok(())
@@ -219,7 +229,11 @@ mod tests {
/// A unique temp path; `save_at` creates the nested dir (exercises create_dir_all). /// A unique temp path; `save_at` creates the nested dir (exercises create_dir_all).
fn temp_path(tag: &str) -> PathBuf { fn temp_path(tag: &str) -> PathBuf {
let mut p = std::env::temp_dir(); let mut p = std::env::temp_dir();
p.push(format!("peerspeak-friendstest-{}-{}", std::process::id(), tag)); p.push(format!(
"peerspeak-friendstest-{}-{}",
std::process::id(),
tag
));
p.push("friends.json"); p.push("friends.json");
p p
} }
+45 -12
View File
@@ -9,11 +9,9 @@
//! is factored into the pure [`poll_once`] so the wiring of resolve + match + //! is factored into the pure [`poll_once`] so the wiring of resolve + match +
//! debounce is unit-tested without any I/O. //! debounce is unit-tested without any I/O.
use super::{
builtin_denylist, match_processes, resolve, Debouncer, DetectedGame, ManualOverride,
};
use super::scan; use super::scan;
use super::steam::SteamProbe; use super::steam::SteamProbe;
use super::{Debouncer, DetectedGame, ManualOverride, builtin_denylist, match_processes, resolve};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::io; use std::io;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
@@ -145,9 +143,14 @@ fn worker_loop(
let steam_game = steam.detect(); let steam_game = steam.detect();
let processes = scan::running_executables(); let processes = scan::running_executables();
if let Some(new_current) = if let Some(new_current) = poll_once(
poll_once(&mut debouncer, &override_, steam_game, &processes, &process_map, &denylist) &mut debouncer,
{ &override_,
steam_game,
&processes,
&process_map,
&denylist,
) {
// A closed receiver means core shut down; stop quietly. // A closed receiver means core shut down; stop quietly.
if tx.send(new_current).is_err() { if tx.send(new_current).is_err() {
return; return;
@@ -169,11 +172,18 @@ mod tests {
use super::*; use super::*;
fn game(id: &str, name: &str, source: GameSource) -> DetectedGame { fn game(id: &str, name: &str, source: GameSource) -> DetectedGame {
DetectedGame { id: id.into(), name: Some(name.into()), source } DetectedGame {
id: id.into(),
name: Some(name.into()),
source,
}
} }
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> { fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
} }
#[test] #[test]
@@ -185,17 +195,38 @@ mod tests {
// First poll: detected but not yet published (needs two hits). // First poll: detected but not yet published (needs two hits).
assert_eq!( assert_eq!(
poll_once(&mut d, &ManualOverride::Auto, Some(steam.clone()), &[], &empty, &deny), poll_once(
&mut d,
&ManualOverride::Auto,
Some(steam.clone()),
&[],
&empty,
&deny
),
None None
); );
// Second poll: published. // Second poll: published.
assert_eq!( assert_eq!(
poll_once(&mut d, &ManualOverride::Auto, Some(steam.clone()), &[], &empty, &deny), poll_once(
&mut d,
&ManualOverride::Auto,
Some(steam.clone()),
&[],
&empty,
&deny
),
Some(Some(steam)) Some(Some(steam))
); );
// Third identical poll: no change event. // Third identical poll: no change event.
assert_eq!( assert_eq!(
poll_once(&mut d, &ManualOverride::Auto, Some(game("steam:730", "CS2", GameSource::Steam)), &[], &empty, &deny), poll_once(
&mut d,
&ManualOverride::Auto,
Some(game("steam:730", "CS2", GameSource::Steam)),
&[],
&empty,
&deny
),
None None
); );
} }
@@ -209,7 +240,9 @@ mod tests {
poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny); poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny);
let change = poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny); let change = poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny);
let published = change.expect("should publish on second hit").expect("a game"); let published = change
.expect("should publish on second hit")
.expect("a game");
assert_eq!(published.id, "exe:hl2_linux"); assert_eq!(published.id, "exe:hl2_linux");
assert_eq!(published.name.as_deref(), Some("Half-Life 2")); assert_eq!(published.name.as_deref(), Some("Half-Life 2"));
} }
+48 -17
View File
@@ -92,11 +92,20 @@ pub fn resolve(
processes: &[DetectedGame], processes: &[DetectedGame],
) -> Resolution { ) -> Resolution {
match override_ { match override_ {
ManualOverride::ForceNone => Resolution { game: None, immediate: true }, ManualOverride::ForceNone => Resolution {
ManualOverride::Force(g) => Resolution { game: Some(g.clone()), immediate: true }, game: None,
immediate: true,
},
ManualOverride::Force(g) => Resolution {
game: Some(g.clone()),
immediate: true,
},
ManualOverride::Auto => { ManualOverride::Auto => {
let game = steam.or_else(|| processes.first().cloned()); let game = steam.or_else(|| processes.first().cloned());
Resolution { game, immediate: false } Resolution {
game,
immediate: false,
}
} }
} }
} }
@@ -192,7 +201,11 @@ impl Debouncer {
/// lowercase it. Keeps any extension (`minecraft.exe` stays distinct from a /// lowercase it. Keeps any extension (`minecraft.exe` stays distinct from a
/// hypothetical `minecraft`), trims surrounding whitespace. /// hypothetical `minecraft`), trims surrounding whitespace.
pub fn normalize_exe(raw: &str) -> String { pub fn normalize_exe(raw: &str) -> String {
raw.rsplit(['/', '\\']).next().unwrap_or(raw).trim().to_lowercase() raw.rsplit(['/', '\\'])
.next()
.unwrap_or(raw)
.trim()
.to_lowercase()
} }
/// Launcher/helper executables that must NEVER be reported as a game even if a /// Launcher/helper executables that must NEVER be reported as a game even if a
@@ -245,8 +258,10 @@ pub fn match_processes(
denylist: &BTreeSet<&str>, denylist: &BTreeSet<&str>,
) -> Vec<DetectedGame> { ) -> Vec<DetectedGame> {
// Normalize the user map once so lookups are basename/case-insensitive. // Normalize the user map once so lookups are basename/case-insensitive.
let normalized_map: BTreeMap<String, &String> = let normalized_map: BTreeMap<String, &String> = user_map
user_map.iter().map(|(k, v)| (normalize_exe(k), v)).collect(); .iter()
.map(|(k, v)| (normalize_exe(k), v))
.collect();
let mut seen: BTreeSet<String> = BTreeSet::new(); let mut seen: BTreeSet<String> = BTreeSet::new();
let mut out: Vec<DetectedGame> = Vec::new(); let mut out: Vec<DetectedGame> = Vec::new();
@@ -288,8 +303,14 @@ mod tests {
#[test] #[test]
fn stable_ids_are_namespaced() { fn stable_ids_are_namespaced() {
assert_eq!(DetectedGame::steam_id(730), "steam:730"); assert_eq!(DetectedGame::steam_id(730), "steam:730");
assert_eq!(DetectedGame::exe_id("/usr/games/hl2_linux"), "exe:hl2_linux"); assert_eq!(
assert_eq!(DetectedGame::exe_id("C:\\Games\\Minecraft.exe"), "exe:minecraft.exe"); DetectedGame::exe_id("/usr/games/hl2_linux"),
"exe:hl2_linux"
);
assert_eq!(
DetectedGame::exe_id("C:\\Games\\Minecraft.exe"),
"exe:minecraft.exe"
);
} }
#[test] #[test]
@@ -318,8 +339,16 @@ mod tests {
#[test] #[test]
fn resolve_falls_back_to_first_process_then_none() { fn resolve_falls_back_to_first_process_then_none() {
let procs = vec![ let procs = vec![
DetectedGame { id: "exe:a".into(), name: Some("A".into()), source: GameSource::Process }, DetectedGame {
DetectedGame { id: "exe:b".into(), name: Some("B".into()), source: GameSource::Process }, id: "exe:a".into(),
name: Some("A".into()),
source: GameSource::Process,
},
DetectedGame {
id: "exe:b".into(),
name: Some("B".into()),
source: GameSource::Process,
},
]; ];
let r = resolve(&ManualOverride::Auto, None, &procs); let r = resolve(&ManualOverride::Auto, None, &procs);
assert_eq!(r.game.as_ref().unwrap().id, "exe:a"); assert_eq!(r.game.as_ref().unwrap().id, "exe:a");
@@ -426,7 +455,10 @@ mod tests {
// --- process matching -------------------------------------------------- // --- process matching --------------------------------------------------
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> { fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
} }
#[test] #[test]
@@ -461,14 +493,13 @@ mod tests {
let user = map(&[("zed", "Zed"), ("alpha", "Alpha")]); let user = map(&[("zed", "Zed"), ("alpha", "Alpha")]);
let deny = builtin_denylist(); let deny = builtin_denylist();
// Same game twice (two processes) + reverse discovery order. // Same game twice (two processes) + reverse discovery order.
let running = vec![ let running = vec!["/b/zed".into(), "/a/alpha".into(), "/c/alpha".into()];
"/b/zed".into(),
"/a/alpha".into(),
"/c/alpha".into(),
];
let got = match_processes(&running, &user, &deny); let got = match_processes(&running, &user, &deny);
// Deduped to two, sorted by id (alpha before zed) regardless of scan order. // Deduped to two, sorted by id (alpha before zed) regardless of scan order.
assert_eq!(got.iter().map(|g| g.id.as_str()).collect::<Vec<_>>(), vec!["exe:alpha", "exe:zed"]); assert_eq!(
got.iter().map(|g| g.id.as_str()).collect::<Vec<_>>(),
vec!["exe:alpha", "exe:zed"]
);
} }
#[test] #[test]
+13 -7
View File
@@ -62,7 +62,7 @@ fn linux_proc_executables() -> Vec<String> {
fn windows_toolhelp_executables() -> Vec<String> { fn windows_toolhelp_executables() -> Vec<String> {
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{ use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
TH32CS_SNAPPROCESS, TH32CS_SNAPPROCESS,
}; };
@@ -78,7 +78,11 @@ fn windows_toolhelp_executables() -> Vec<String> {
let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) }; let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) };
while ok != 0 { while ok != 0 {
// szExeFile is a NUL-terminated UTF-16 array (the basename, e.g. game.exe). // szExeFile is a NUL-terminated UTF-16 array (the basename, e.g. game.exe).
let end = entry.szExeFile.iter().position(|&c| c == 0).unwrap_or(entry.szExeFile.len()); let end = entry
.szExeFile
.iter()
.position(|&c| c == 0)
.unwrap_or(entry.szExeFile.len());
let name = String::from_utf16_lossy(&entry.szExeFile[..end]); let name = String::from_utf16_lossy(&entry.szExeFile[..end]);
if !name.is_empty() { if !name.is_empty() {
out.push(name); out.push(name);
@@ -93,15 +97,16 @@ fn windows_toolhelp_executables() -> Vec<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[test] #[test]
fn enumerates_at_least_this_process() { fn enumerates_at_least_this_process() {
// The test runner itself is a process, so /proc enumeration must be // The test runner itself is a process, so /proc enumeration must be
// non-empty and include something that normalizes to our own exe basename. // non-empty and include something that normalizes to our own exe basename.
let exes = running_executables(); let exes = super::running_executables();
assert!(!exes.is_empty(), "expected to see running processes via /proc"); assert!(
!exes.is_empty(),
"expected to see running processes via /proc"
);
// Our own /proc/self/exe basename should appear among them. // Our own /proc/self/exe basename should appear among them.
let me = std::fs::read_link("/proc/self/exe") let me = std::fs::read_link("/proc/self/exe")
.ok() .ok()
@@ -109,7 +114,8 @@ mod tests {
if let Some(me) = me { if let Some(me) = me {
let me_norm = super::super::normalize_exe(&me); let me_norm = super::super::normalize_exe(&me);
assert!( assert!(
exes.iter().any(|e| super::super::normalize_exe(e) == me_norm), exes.iter()
.any(|e| super::super::normalize_exe(e) == me_norm),
"running list should include our own executable {me_norm:?}" "running list should include our own executable {me_norm:?}"
); );
} }
+57 -20
View File
@@ -25,8 +25,7 @@ const MAX_STEAM_PATH_BYTES: u32 = 4 * 1024;
#[cfg(any(windows, test))] #[cfg(any(windows, test))]
fn validate_reg_len(len: u32) -> Option<usize> { fn validate_reg_len(len: u32) -> Option<usize> {
(len != 0 && len.is_multiple_of(2) && len <= MAX_STEAM_PATH_BYTES) (len != 0 && len.is_multiple_of(2) && len <= MAX_STEAM_PATH_BYTES).then_some(len as usize / 2)
.then_some(len as usize / 2)
} }
#[cfg(any(windows, test))] #[cfg(any(windows, test))]
@@ -48,7 +47,14 @@ fn decode_reg_sz(mut buf: Vec<u16>, returned_bytes: u32) -> Option<String> {
pub fn parse_running_app_id(registry_vdf: &str) -> Option<u32> { pub fn parse_running_app_id(registry_vdf: &str) -> Option<u32> {
let root = vdf::parse(registry_vdf).ok()?; let root = vdf::parse(registry_vdf).ok()?;
let raw = root let raw = root
.get_path(&["Registry", "HKCU", "Software", "Valve", "Steam", "RunningAppID"]) .get_path(&[
"Registry",
"HKCU",
"Software",
"Valve",
"Steam",
"RunningAppID",
])
.and_then(Value::as_str)?; .and_then(Value::as_str)?;
let id: u32 = raw.trim().parse().ok()?; let id: u32 = raw.trim().parse().ok()?;
(id != 0).then_some(id) (id != 0).then_some(id)
@@ -196,7 +202,13 @@ impl SteamProbe {
return cached.name.clone(); return cached.name.clone();
} }
let name = read_capped(&manifest).and_then(|c| parse_app_name(&c)); let name = read_capped(&manifest).and_then(|c| parse_app_name(&c));
self.manifests.insert(app_id, CachedManifest { mtime, name: name.clone() }); self.manifests.insert(
app_id,
CachedManifest {
mtime,
name: name.clone(),
},
);
name name
} }
@@ -240,7 +252,11 @@ impl SteamProbe {
paths.push(root.clone()); paths.push(root.clone());
} }
} }
self.libraries = CachedLibraries { source, mtime, paths: paths.clone() }; self.libraries = CachedLibraries {
source,
mtime,
paths: paths.clone(),
};
paths paths
} }
} }
@@ -360,8 +376,8 @@ mod win {
use std::path::PathBuf; use std::path::PathBuf;
use windows_sys::Win32::Foundation::ERROR_SUCCESS; use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::System::Registry::{ use windows_sys::Win32::System::Registry::{
RegCloseKey, RegOpenKeyExW, RegQueryValueExW, HKEY, HKEY_CURRENT_USER, KEY_READ, HKEY, HKEY_CURRENT_USER, KEY_READ, REG_DWORD, REG_SZ, RegCloseKey, RegOpenKeyExW,
REG_DWORD, REG_SZ, RegQueryValueExW,
}; };
/// UTF-16, NUL-terminated, for a Win32 wide-string argument. /// UTF-16, NUL-terminated, for a Win32 wide-string argument.
@@ -374,9 +390,8 @@ mod win {
let subkey = wide("Software\\Valve\\Steam"); let subkey = wide("Software\\Valve\\Steam");
let mut hkey: HKEY = std::ptr::null_mut(); let mut hkey: HKEY = std::ptr::null_mut();
// SAFETY: valid HKEY constant, NUL-terminated subkey, out-param for the handle. // SAFETY: valid HKEY constant, NUL-terminated subkey, out-param for the handle.
let rc = unsafe { let rc =
RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut hkey) unsafe { RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut hkey) };
};
(rc == ERROR_SUCCESS).then_some(hkey) (rc == ERROR_SUCCESS).then_some(hkey)
} }
@@ -463,13 +478,20 @@ mod tests {
#[test] #[test]
fn registry_string_lengths_are_bounded_and_trimmed() { fn registry_string_lengths_are_bounded_and_trimmed() {
assert_eq!(validate_reg_len(5), None, "odd byte lengths are invalid UTF-16"); assert_eq!(
validate_reg_len(5),
None,
"odd byte lengths are invalid UTF-16"
);
assert_eq!(validate_reg_len(MAX_STEAM_PATH_BYTES + 2), None); assert_eq!(validate_reg_len(MAX_STEAM_PATH_BYTES + 2), None);
assert_eq!(validate_reg_len(8), Some(4)); assert_eq!(validate_reg_len(8), Some(4));
let raw = "C:\\Steam\0ignored".encode_utf16().collect::<Vec<_>>(); let raw = "C:\\Steam\0ignored".encode_utf16().collect::<Vec<_>>();
let returned_bytes = ("C:\\Steam\0".encode_utf16().count() * 2) as u32; let returned_bytes = ("C:\\Steam\0".encode_utf16().count() * 2) as u32;
assert_eq!(decode_reg_sz(raw, returned_bytes).as_deref(), Some("C:\\Steam")); assert_eq!(
decode_reg_sz(raw, returned_bytes).as_deref(),
Some("C:\\Steam")
);
} }
#[test] #[test]
@@ -495,10 +517,13 @@ mod tests {
"contentstatsid" "12345" "contentstatsid" "12345"
}"#; }"#;
let got = parse_library_paths(current); let got = parse_library_paths(current);
assert_eq!(got, vec![ assert_eq!(
PathBuf::from("/home/eric/.local/share/Steam"), got,
PathBuf::from("/mnt/games/SteamLibrary"), vec![
]); PathBuf::from("/home/eric/.local/share/Steam"),
PathBuf::from("/mnt/games/SteamLibrary"),
]
);
// Legacy shape: numeric keys map straight to path strings. // Legacy shape: numeric keys map straight to path strings.
let legacy = r#""LibraryFolders" { let legacy = r#""LibraryFolders" {
@@ -522,13 +547,25 @@ mod tests {
let environ = b"PATH=/usr/bin\0SteamAppId=440\0HOME=/home/x\0SteamGameId=440\0"; let environ = b"PATH=/usr/bin\0SteamAppId=440\0HOME=/home/x\0SteamGameId=440\0";
assert_eq!(parse_steam_app_id_from_environ(environ), Some(440)); assert_eq!(parse_steam_app_id_from_environ(environ), Some(440));
// Nonzero requirement: SteamAppId=0 (the launcher itself) is ignored. // Nonzero requirement: SteamAppId=0 (the launcher itself) is ignored.
assert_eq!(parse_steam_app_id_from_environ(b"SteamAppId=0\0FOO=bar\0"), None); assert_eq!(
parse_steam_app_id_from_environ(b"SteamAppId=0\0FOO=bar\0"),
None
);
// Absent → None (a non-Steam process). // Absent → None (a non-Steam process).
assert_eq!(parse_steam_app_id_from_environ(b"PATH=/usr/bin\0HOME=/home/x\0"), None); assert_eq!(
parse_steam_app_id_from_environ(b"PATH=/usr/bin\0HOME=/home/x\0"),
None
);
// Not fooled by a different var that merely contains the substring. // Not fooled by a different var that merely contains the substring.
assert_eq!(parse_steam_app_id_from_environ(b"MY_SteamAppId=999\0"), None); assert_eq!(
parse_steam_app_id_from_environ(b"MY_SteamAppId=999\0"),
None
);
// Garbage value → None, no panic. // Garbage value → None, no panic.
assert_eq!(parse_steam_app_id_from_environ(b"SteamAppId=notanumber\0"), None); assert_eq!(
parse_steam_app_id_from_environ(b"SteamAppId=notanumber\0"),
None
);
} }
#[test] #[test]
+33 -7
View File
@@ -234,10 +234,20 @@ mod tests {
} }
"#; "#;
let root = parse(acf).unwrap(); let root = parse(acf).unwrap();
assert_eq!(root.get_path(&["AppState", "name"]).and_then(Value::as_str), Some("Counter-Strike 2")); assert_eq!(
assert_eq!(root.get_path(&["AppState", "appid"]).and_then(Value::as_str), Some("730")); root.get_path(&["AppState", "name"]).and_then(Value::as_str),
Some("Counter-Strike 2")
);
assert_eq!(
root.get_path(&["AppState", "appid"])
.and_then(Value::as_str),
Some("730")
);
// Case-insensitive key lookup. // Case-insensitive key lookup.
assert_eq!(root.get_path(&["appstate", "NAME"]).and_then(Value::as_str), Some("Counter-Strike 2")); assert_eq!(
root.get_path(&["appstate", "NAME"]).and_then(Value::as_str),
Some("Counter-Strike 2")
);
} }
#[test] #[test]
@@ -262,8 +272,14 @@ mod tests {
"#; "#;
let root = parse(vdf).unwrap(); let root = parse(vdf).unwrap();
let lf = root.get("libraryfolders").unwrap(); let lf = root.get("libraryfolders").unwrap();
assert_eq!(lf.get_path(&["0", "path"]).and_then(Value::as_str), Some(r"C:\Program Files (x86)\Steam")); assert_eq!(
assert_eq!(lf.get_path(&["1", "path"]).and_then(Value::as_str), Some("/home/eric/.local/share/Steam")); lf.get_path(&["0", "path"]).and_then(Value::as_str),
Some(r"C:\Program Files (x86)\Steam")
);
assert_eq!(
lf.get_path(&["1", "path"]).and_then(Value::as_str),
Some("/home/eric/.local/share/Steam")
);
// The library folder ids are iterable for discovery. // The library folder ids are iterable for discovery.
let ids: Vec<&str> = lf.entries().iter().map(|(k, _)| k.as_str()).collect(); let ids: Vec<&str> = lf.entries().iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(ids, vec!["0", "1"]); assert_eq!(ids, vec!["0", "1"]);
@@ -292,7 +308,14 @@ mod tests {
"#; "#;
let root = parse(reg).unwrap(); let root = parse(reg).unwrap();
let appid = root let appid = root
.get_path(&["Registry", "HKCU", "Software", "Valve", "Steam", "RunningAppID"]) .get_path(&[
"Registry",
"HKCU",
"Software",
"Valve",
"Steam",
"RunningAppID",
])
.and_then(Value::as_str); .and_then(Value::as_str);
assert_eq!(appid, Some("570")); assert_eq!(appid, Some("570"));
} }
@@ -301,7 +324,10 @@ mod tests {
fn handles_comments_and_barewords() { fn handles_comments_and_barewords() {
let vdf = "// a comment\n\"root\"\n{\n\tbarekey barevalue // trailing\n}\n"; let vdf = "// a comment\n\"root\"\n{\n\tbarekey barevalue // trailing\n}\n";
let root = parse(vdf).unwrap(); let root = parse(vdf).unwrap();
assert_eq!(root.get_path(&["root", "barekey"]).and_then(Value::as_str), Some("barevalue")); assert_eq!(
root.get_path(&["root", "barekey"]).and_then(Value::as_str),
Some("barevalue")
);
} }
#[test] #[test]
+7 -4
View File
@@ -89,9 +89,9 @@ impl HotkeyAction {
pub fn tier(self) -> HotkeyTier { pub fn tier(self) -> HotkeyTier {
match self { match self {
HotkeyAction::ToggleMute HotkeyAction::ToggleMute | HotkeyAction::ToggleDeafen | HotkeyAction::OpenSettings => {
| HotkeyAction::ToggleDeafen HotkeyTier::AppWide
| HotkeyAction::OpenSettings => HotkeyTier::AppWide, }
HotkeyAction::PushToTalk | HotkeyAction::LeaveRoom => HotkeyTier::RoomOnly, HotkeyAction::PushToTalk | HotkeyAction::LeaveRoom => HotkeyTier::RoomOnly,
} }
} }
@@ -278,7 +278,10 @@ mod tests {
#[test] #[test]
fn parse_single_character_case_folds() { fn parse_single_character_case_folds() {
assert_eq!(parse_binding("M"), Some(KeyBinding::Character("m".to_string()))); assert_eq!(
parse_binding("M"),
Some(KeyBinding::Character("m".to_string()))
);
assert_eq!(format_binding(parse_binding("m").as_ref()), "M"); assert_eq!(format_binding(parse_binding("m").as_ref()), "M");
} }
} }
+11 -6
View File
@@ -41,7 +41,8 @@ pub fn identity_path() -> Option<PathBuf> {
/// A *missing* file (first ever run, or right after a reset) is the normal /// A *missing* file (first ever run, or right after a reset) is the normal
/// create path. /// create path.
pub fn load_or_create() -> Result<SecretKey> { pub fn load_or_create() -> Result<SecretKey> {
let path = identity_path().context("could not determine a config directory for the identity key")?; let path =
identity_path().context("could not determine a config directory for the identity key")?;
load_or_create_at(&path) load_or_create_at(&path)
} }
@@ -49,7 +50,8 @@ pub fn load_or_create() -> Result<SecretKey> {
/// deliberate "Regenerate identity" / unlink action — the old id is discarded and /// deliberate "Regenerate identity" / unlink action — the old id is discarded and
/// unrecoverable, so callers should confirm with the user first. /// unrecoverable, so callers should confirm with the user first.
pub fn regenerate() -> Result<SecretKey> { pub fn regenerate() -> Result<SecretKey> {
let path = identity_path().context("could not determine a config directory for the identity key")?; let path =
identity_path().context("could not determine a config directory for the identity key")?;
let key = SecretKey::generate(); let key = SecretKey::generate();
save_at(&path, &key)?; save_at(&path, &key)?;
Ok(key) Ok(key)
@@ -57,7 +59,8 @@ pub fn regenerate() -> Result<SecretKey> {
/// Atomic, `0600` write at the default identity path. See [`save_at`]. /// Atomic, `0600` write at the default identity path. See [`save_at`].
pub fn save(key: &SecretKey) -> Result<()> { pub fn save(key: &SecretKey) -> Result<()> {
let path = identity_path().context("could not determine a config directory for the identity key")?; let path =
identity_path().context("could not determine a config directory for the identity key")?;
save_at(&path, key) save_at(&path, key)
} }
@@ -80,13 +83,15 @@ fn load_or_create_at(path: &std::path::Path) -> Result<SecretKey> {
/// perms are applied before the rename so the secret is never briefly /// perms are applied before the rename so the secret is never briefly
/// world-readable. /// world-readable.
fn save_at(path: &std::path::Path, key: &SecretKey) -> Result<()> { fn save_at(path: &std::path::Path, key: &SecretKey) -> Result<()> {
let parent = path.parent().context("identity path has no parent directory")?; let parent = path
.parent()
.context("identity path has no parent directory")?;
fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?;
let tmp = parent.join(format!(".identity.key.tmp.{}", std::process::id())); let tmp = parent.join(format!(".identity.key.tmp.{}", std::process::id()));
{ {
let mut f = let mut f = fs::File::create(&tmp)
fs::File::create(&tmp).with_context(|| format!("failed to create {}", tmp.display()))?; .with_context(|| format!("failed to create {}", tmp.display()))?;
#[cfg(unix)] #[cfg(unix)]
{ {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
+28 -21
View File
@@ -1,27 +1,27 @@
pub mod audio;
pub mod codec;
pub mod dsp;
pub mod network;
pub mod protocol;
pub mod core;
pub mod app; pub mod app;
pub mod config; pub mod audio;
pub mod identity;
pub mod friends;
pub mod presence;
pub mod presence_net;
pub mod theme;
pub mod notify;
pub mod screenshare;
pub mod sanitize;
pub mod avatar; pub mod avatar;
pub mod background; pub mod background;
pub mod recents; pub mod codec;
pub mod config;
pub mod core;
pub mod discovery; pub mod discovery;
pub mod hotkeys; pub mod dsp;
pub mod files; pub mod files;
pub mod playlist; pub mod friends;
pub mod game; pub mod game;
pub mod hotkeys;
pub mod identity;
pub mod network;
pub mod notify;
pub mod playlist;
pub mod presence;
pub mod presence_net;
pub mod protocol;
pub mod recents;
pub mod sanitize;
pub mod screenshare;
pub mod theme;
pub mod widget; pub mod widget;
use std::fs::File; use std::fs::File;
@@ -75,7 +75,8 @@ pub fn redact_for_log(value: &str) -> String {
} }
pub fn short_bytes_hex(bytes: &[u8]) -> String { pub fn short_bytes_hex(bytes: &[u8]) -> String {
bytes.iter() bytes
.iter()
.take(6) .take(6)
.map(|b| format!("{b:02x}")) .map(|b| format!("{b:02x}"))
.collect::<Vec<_>>() .collect::<Vec<_>>()
@@ -83,7 +84,10 @@ pub fn short_bytes_hex(bytes: &[u8]) -> String {
} }
fn rotated_log_path(path: &Path) -> PathBuf { fn rotated_log_path(path: &Path) -> PathBuf {
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("peerspeak.log"); let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("peerspeak.log");
path.with_file_name(format!("{file_name}.1")) path.with_file_name(format!("{file_name}.1"))
} }
@@ -100,7 +104,10 @@ fn prepare_log_file_with_limit(path: &Path, max_bytes: u64) -> std::io::Result<F
let rotated = rotated_log_path(path); let rotated = rotated_log_path(path);
let _ = std::fs::remove_file(&rotated); let _ = std::fs::remove_file(&rotated);
if std::fs::rename(path, &rotated).is_err() { if std::fs::rename(path, &rotated).is_err() {
let _ = std::fs::OpenOptions::new().write(true).truncate(true).open(path); let _ = std::fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(path);
} }
} }
+16
View File
@@ -3,6 +3,22 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() { fn main() {
// Tag the audio we play through ALSA (rodio's `ClipPlayer`: chat clips,
// peer music, local playlist tracks) so the screen-share exclusion engine
// can recognise it as ours and refuse to fan it back to the far end.
//
// First statement in the program, and that is load-bearing: this sets an
// environment variable, which is only sound while the process is still
// single-threaded, and PipeWire's ALSA plugin reads it when a stream is
// opened. See `audio::ownership::tag_this_process_alsa_audio`.
//
// SAFETY: nothing has been spawned yet, so no thread can be reading the
// environment concurrently.
#[cfg(target_os = "linux")]
unsafe {
peerspeak::audio::ownership::tag_this_process_alsa_audio()
};
if let Err(e) = peerspeak::app::run_gui() { if let Err(e) = peerspeak::app::run_gui() {
eprintln!("Error running GUI: {:?}", e); eprintln!("Error running GUI: {:?}", e);
} }
+829 -106
View File
File diff suppressed because it is too large Load Diff
+140 -30
View File
@@ -1,16 +1,16 @@
use crate::network::{NetworkTransport, NetError, ConnEvent}; use crate::network::{ConnEvent, NetError, NetworkTransport};
use iroh::{Endpoint, EndpointId}; use async_trait::async_trait;
use iroh::endpoint::{Connection, ConnectionError, VarInt};
use bytes::Bytes; use bytes::Bytes;
use iroh::endpoint::{Connection, ConnectionError, VarInt};
use iroh::{Endpoint, EndpointId};
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::sync::mpsc::Receiver; use tokio::sync::mpsc::Receiver;
use std::sync::{Arc, Mutex as StdMutex};
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use async_trait::async_trait;
use crate::protocol::{AUDIO_ALPN, FILES_ALPN};
use crate::files::{AttachmentId, ChatAttachment}; use crate::files::{AttachmentId, ChatAttachment};
use crate::protocol::{AUDIO_ALPN, FILES_ALPN};
/// Per-peer datagram send queue depth. Audio is real-time, so a backlog is /// Per-peer datagram send queue depth. Audio is real-time, so a backlog is
/// useless latency — keep it shallow and drop the oldest frame when full. /// useless latency — keep it shallow and drop the oldest frame when full.
@@ -69,7 +69,9 @@ struct Shared {
/// the random attachment id. Populated when we send a chat file; read by the /// the random attachment id. Populated when we send a chat file; read by the
/// file protocol handler to answer a member's fetch. Cleared on leave. Each /// file protocol handler to answer a member's fetch. Cleared on leave. Each
/// blob is already byte-capped at send time. /// blob is already byte-capped at send time.
served_files: StdMutex<HashMap<crate::files::AttachmentId, Arc<Vec<u8>>>>, /// Blobs we serve to room members, bounded by count and byte budgets
/// (Phase 3C) — an evicted id reads as "sender no longer has the file".
served_files: StdMutex<crate::files::ServeStore>,
incoming_tx: mpsc::Sender<(EndpointId, Bytes)>, incoming_tx: mpsc::Sender<(EndpointId, Bytes)>,
/// Best-effort link-state notifications for the UI (connecting / connected). /// Best-effort link-state notifications for the UI (connecting / connected).
conn_events_tx: mpsc::Sender<ConnEvent>, conn_events_tx: mpsc::Sender<ConnEvent>,
@@ -111,7 +113,13 @@ impl Shared {
let shared = self.clone(); let shared = self.clone();
let supervisor = tokio::spawn(supervise(shared, peer_id, inbound_rx)); let supervisor = tokio::spawn(supervise(shared, peer_id, inbound_rx));
let inbound_tx_ret = inbound_tx.clone(); let inbound_tx_ret = inbound_tx.clone();
peers.insert(peer_id, PeerHandle { supervisor, inbound_tx }); peers.insert(
peer_id,
PeerHandle {
supervisor,
inbound_tx,
},
);
crate::log_msg(&format!("Transport: supervising peer {:?}", peer_id)); crate::log_msg(&format!("Transport: supervising peer {:?}", peer_id));
inbound_tx_ret inbound_tx_ret
} }
@@ -123,7 +131,10 @@ impl Shared {
self.addrs.lock().unwrap().remove(&peer_id); self.addrs.lock().unwrap().remove(&peer_id);
if let Some(handle) = self.peers.lock().await.remove(&peer_id) { if let Some(handle) = self.peers.lock().await.remove(&peer_id) {
handle.supervisor.abort(); handle.supervisor.abort();
crate::log_msg(&format!("Transport: stopped supervising peer {:?}", peer_id)); crate::log_msg(&format!(
"Transport: stopped supervising peer {:?}",
peer_id
));
} }
} }
@@ -208,12 +219,15 @@ async fn supervise(
let mut backoff = INITIAL_BACKOFF; let mut backoff = INITIAL_BACKOFF;
// Show "connecting" until the first link is actually up. // Show "connecting" until the first link is actually up.
let _ = shared.conn_events_tx.try_send(ConnEvent::Connecting(peer_id)); let _ = shared
.conn_events_tx
.try_send(ConnEvent::Connecting(peer_id));
let mut conn = match obtain_conn(&shared, peer_id, is_dialer, &mut inbound_rx, &mut backoff).await { let mut conn =
Some(conn) => conn, match obtain_conn(&shared, peer_id, is_dialer, &mut inbound_rx, &mut backoff).await {
None => return, // retired before we ever connected Some(conn) => conn,
}; None => return, // retired before we ever connected
};
loop { loop {
// A healthy link resets the dialer's backoff for the next outage. // A healthy link resets the dialer's backoff for the next outage.
@@ -223,8 +237,14 @@ async fn supervise(
shared.senders.lock().unwrap().insert(peer_id, send_tx); shared.senders.lock().unwrap().insert(peer_id, send_tx);
// Publish the live connection so an intentional leave can close it with // Publish the live connection so an intentional leave can close it with
// the goodbye code. // the goodbye code.
shared.live_conns.lock().unwrap().insert(peer_id, conn.clone()); shared
let _ = shared.conn_events_tx.try_send(ConnEvent::Connected(peer_id)); .live_conns
.lock()
.unwrap()
.insert(peer_id, conn.clone());
let _ = shared
.conn_events_tx
.try_send(ConnEvent::Connected(peer_id));
crate::log_msg(&format!("Transport: peer {:?} link up", peer_id)); crate::log_msg(&format!("Transport: peer {:?} link up", peer_id));
// Run until the link dies, a replacement arrives, or we're retired. The // Run until the link dies, a replacement arrives, or we're retired. The
@@ -272,21 +292,36 @@ async fn supervise(
match wake { match wake {
Wake::Shutdown => return, Wake::Shutdown => return,
Wake::Replacement(new_conn) => { Wake::Replacement(new_conn) => {
crate::log_msg(&format!("Transport: peer {:?} replaced with new inbound link", peer_id)); crate::log_msg(&format!(
let _ = shared.conn_events_tx.try_send(ConnEvent::Connecting(peer_id)); "Transport: peer {:?} replaced with new inbound link",
peer_id
));
let _ = shared
.conn_events_tx
.try_send(ConnEvent::Connecting(peer_id));
conn = new_conn; conn = new_conn;
} }
Wake::Closed(reason) => { Wake::Closed(reason) => {
// A graceful application close means the peer left on purpose — // A graceful application close means the peer left on purpose —
// don't reconnect; tell the core to evict it now. // don't reconnect; tell the core to evict it now.
if is_graceful_leave(&reason) { if is_graceful_leave(&reason) {
crate::log_msg(&format!("Transport: peer {:?} left gracefully ({:?})", peer_id, reason)); crate::log_msg(&format!(
"Transport: peer {:?} left gracefully ({:?})",
peer_id, reason
));
let _ = shared.conn_events_tx.try_send(ConnEvent::Left(peer_id)); let _ = shared.conn_events_tx.try_send(ConnEvent::Left(peer_id));
return; return;
} }
crate::log_msg(&format!("Transport: peer {:?} link dropped; reconnecting", peer_id)); crate::log_msg(&format!(
let _ = shared.conn_events_tx.try_send(ConnEvent::Connecting(peer_id)); "Transport: peer {:?} link dropped; reconnecting",
conn = match obtain_conn(&shared, peer_id, is_dialer, &mut inbound_rx, &mut backoff).await { peer_id
));
let _ = shared
.conn_events_tx
.try_send(ConnEvent::Connecting(peer_id));
conn = match obtain_conn(&shared, peer_id, is_dialer, &mut inbound_rx, &mut backoff)
.await
{
Some(conn) => conn, Some(conn) => conn,
None => return, // retired while reconnecting None => return, // retired while reconnecting
}; };
@@ -405,7 +440,10 @@ impl iroh::protocol::ProtocolHandler for AudioRouter {
// only happens if links are churning, and the supervisor gets the next one. // only happens if links are churning, and the supervisor gets the next one.
let inbound_tx = shared.ensure_supervisor(peer_id).await; let inbound_tx = shared.ensure_supervisor(peer_id).await;
if inbound_tx.try_send(connection).is_err() { if inbound_tx.try_send(connection).is_err() {
crate::log_msg(&format!("Transport: dropped inbound link from {:?} (queue full)", peer_id)); crate::log_msg(&format!(
"Transport: dropped inbound link from {:?} (queue full)",
peer_id
));
} }
Ok(()) Ok(())
} }
@@ -482,7 +520,7 @@ impl iroh::protocol::ProtocolHandler for FileRouter {
let Some(id) = crate::files::parse_request(&req) else { let Some(id) = crate::files::parse_request(&req) else {
return Ok(()); return Ok(());
}; };
let blob = shared.served_files.lock().unwrap().get(&id).cloned(); let blob = shared.served_files.lock().unwrap().get(&id);
if let Some(blob) = blob { if let Some(blob) = blob {
let _ = send.write_all(&blob).await; let _ = send.write_all(&blob).await;
} }
@@ -527,7 +565,7 @@ impl IrohTransport {
peers: tokio::sync::Mutex::new(HashMap::new()), peers: tokio::sync::Mutex::new(HashMap::new()),
live_conns: StdMutex::new(HashMap::new()), live_conns: StdMutex::new(HashMap::new()),
admitted_audio: StdMutex::new(HashSet::new()), admitted_audio: StdMutex::new(HashSet::new()),
served_files: StdMutex::new(HashMap::new()), served_files: StdMutex::new(crate::files::ServeStore::default()),
incoming_tx, incoming_tx,
conn_events_tx, conn_events_tx,
}); });
@@ -545,7 +583,14 @@ impl IrohTransport {
/// all supervisors so none linger redialing the about-to-close endpoint. /// all supervisors so none linger redialing the about-to-close endpoint.
/// Call this before shutting the router down. /// Call this before shutting the router down.
pub async fn leave(&self) { pub async fn leave(&self) {
let conns: Vec<Connection> = self.shared.live_conns.lock().unwrap().drain().map(|(_, c)| c).collect(); let conns: Vec<Connection> = self
.shared
.live_conns
.lock()
.unwrap()
.drain()
.map(|(_, c)| c)
.collect();
for conn in &conns { for conn in &conns {
conn.close(VarInt::from_u32(GOODBYE_CODE), b"leave"); conn.close(VarInt::from_u32(GOODBYE_CODE), b"leave");
} }
@@ -591,7 +636,9 @@ impl IrohTransport {
/// session (served by the [`FileRouter`] handler). Called by core when we /// session (served by the [`FileRouter`] handler). Called by core when we
/// send a chat file. The blob is cleared on leave. /// send a chat file. The blob is cleared on leave.
pub fn serve_attachment(&self, id: AttachmentId, bytes: Arc<Vec<u8>>) { pub fn serve_attachment(&self, id: AttachmentId, bytes: Arc<Vec<u8>>) {
self.shared.served_files.lock().unwrap().insert(id, bytes); if !self.shared.served_files.lock().unwrap().insert(id, bytes) {
crate::log_msg("Transport: refused to serve an over-budget blob");
}
} }
/// Drop a previously-served blob (e.g. a music track no longer current-or-next). /// Drop a previously-served blob (e.g. a music track no longer current-or-next).
@@ -633,17 +680,80 @@ impl IrohTransport {
send.finish() send.finish()
.map_err(|e| NetError::Other(format!("file fetch: request finish failed: {e}")))?; .map_err(|e| NetError::Other(format!("file fetch: request finish failed: {e}")))?;
// `read_to_end(size)` errors if the stream exceeds `size`, rejecting an
// overlong transfer; the exact-length check below rejects a short one.
let read = recv.read_to_end(size as usize); let read = recv.read_to_end(size as usize);
let bytes = tokio::time::timeout(FILE_FETCH_TIMEOUT, read) let bytes = tokio::time::timeout(FILE_FETCH_TIMEOUT, read)
.await .await
.map_err(|_| NetError::Other("file fetch: read timed out".to_string()))? .map_err(|_| NetError::Other("file fetch: read timed out".to_string()))?
.map_err(|e| NetError::Other(format!("file fetch: read failed: {e}")))?; .map_err(|e| NetError::Other(format!("file fetch: read failed: {e}")))?;
if bytes.is_empty() { if bytes.is_empty() {
return Err(NetError::Other("file fetch: sender no longer has the file".to_string())); return Err(NetError::Other(
"file fetch: sender no longer has the file".to_string(),
));
}
// Exact transfer required (Phase 3C): a truncated body must not be
// cached/saved/decoded as if it were the declared attachment.
if bytes.len() as u64 != size {
return Err(NetError::Other(format!(
"file fetch: incomplete transfer ({} of {size} bytes)",
bytes.len()
)));
} }
Ok(bytes) Ok(bytes)
} }
/// Snapshot the selected QUIC path of every live audio connection, for the
/// UI's per-peer connection badge (direct/relay, RTT, loss, bitrate).
/// Cheap and lock-light: the `live_conns` guard is released before touching
/// any connection, and `Connection::paths()` reads shared state without I/O.
pub fn connection_stats(&self) -> Vec<(EndpointId, crate::network::PathSnapshot)> {
// Clone the connections out so the map lock isn't held while we inspect
// paths (a supervisor inserts/removes entries as links come and go).
let conns: Vec<(EndpointId, Connection)> = self
.shared
.live_conns
.lock()
.unwrap()
.iter()
.map(|(id, conn)| (*id, conn.clone()))
.collect();
conns
.into_iter()
.filter_map(|(id, conn)| {
let paths = conn.paths();
// The selected path is the one carrying application data. In the
// brief window where none is flagged (e.g. mid-migration), fall
// back to the first open path rather than dropping the badge.
let path = paths
.iter()
.find(|p| p.is_selected())
.or_else(|| paths.iter().next())?;
let stats = path.stats();
// Per-variant display: `TransportAddr`'s own `Display` prefixes
// a scheme ("ip:1.2.3.4:5") that's noise next to the badge's
// Direct/Relay label.
let remote_addr = match path.remote_addr() {
iroh::TransportAddr::Ip(sock) => sock.to_string(),
iroh::TransportAddr::Relay(url) => url.to_string(),
other => other.to_string(),
};
Some((
id,
crate::network::PathSnapshot {
is_relay: path.remote_addr().is_relay(),
remote_addr,
rtt: stats.rtt,
tx_bytes: stats.udp_tx.bytes,
rx_bytes: stats.udp_rx.bytes,
tx_datagrams: stats.udp_tx.datagrams,
lost_packets: stats.lost_packets,
},
))
})
.collect()
}
/// Fetch a chat attachment's bytes from its sender over the file plane. /// Fetch a chat attachment's bytes from its sender over the file plane.
pub async fn fetch_attachment( pub async fn fetch_attachment(
&self, &self,
+83 -32
View File
@@ -1,10 +1,10 @@
use iroh::{EndpointId, EndpointAddr}; use async_trait::async_trait;
use bytes::Bytes; use bytes::Bytes;
use iroh::{EndpointAddr, EndpointId};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use thiserror::Error; use thiserror::Error;
use tokio::sync::mpsc::Receiver; use tokio::sync::mpsc::Receiver;
use async_trait::async_trait;
use serde::{Serialize, Deserialize};
use std::str::FromStr;
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum NetError { pub enum NetError {
@@ -148,10 +148,14 @@ pub enum RoomEvent {
/// A validly signed gossip payload was rejected only because its timestamp is /// A validly signed gossip payload was rejected only because its timestamp is
/// outside the replay-protection window. The peer is not in the roster yet, /// outside the replay-protection window. The peer is not in the roster yet,
/// so this surfaces as a room-level warning instead of a peer-card state. /// so this surfaces as a room-level warning instead of a peer-card state.
ClockSkewSuspected { author: EndpointId, skew_ms: i64 }, ClockSkewSuspected {
/// A peer sent a room text-chat message. Carries the sender's id, their author: EndpointId,
/// display name (embedded so it shows even without a presence entry), the skew_ms: i64,
/// text, and a sender-stamped millisecond timestamp. },
/// A peer sent a room text-chat message. Carries the sender's id, the
/// sender-CLAIMED display name (untrusted; the core replaces it with the
/// roster-bound name before the UI sees it — chat-hardening Phase 2), the
/// text, and the signed envelope timestamp (display only, never ordering).
ChatMessage { ChatMessage {
from: EndpointId, from: EndpointId,
name: String, name: String,
@@ -181,6 +185,32 @@ pub enum ConnEvent {
Left(EndpointId), Left(EndpointId),
} }
/// Owned snapshot of a peer's *selected* QUIC path (the one currently carrying
/// application data), taken from the live audio connection for the UI's
/// connection-transparency badge. Counters are cumulative for the path's
/// lifetime; rate/loss derivation over a poll window happens in
/// `core::connstats` (which also detects path switches via `remote_addr`).
#[derive(Debug, Clone, PartialEq)]
pub struct PathSnapshot {
/// True when the path runs through a relay server, false for a direct
/// (holepunched or local) IP path.
pub is_relay: bool,
/// The path's remote transport address: `ip:port` for a direct path, the
/// relay URL for a relayed one.
pub remote_addr: String,
/// Current QUIC round-trip-time estimate for the path.
pub rtt: std::time::Duration,
/// Cumulative bytes sent in UDP datagrams on the path.
pub tx_bytes: u64,
/// Cumulative bytes received in UDP datagrams on the path.
pub rx_bytes: u64,
/// Cumulative UDP datagrams sent on the path (the loss denominator: for our
/// small voice frames these map ~1:1 to QUIC packets).
pub tx_datagrams: u64,
/// Cumulative packets detected lost on the path.
pub lost_packets: u64,
}
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PeerSpeakTicket { pub struct PeerSpeakTicket {
pub host_addr: iroh::EndpointAddr, pub host_addr: iroh::EndpointAddr,
@@ -203,10 +233,12 @@ impl PeerSpeakTicket {
/// is idempotent. /// is idempotent.
pub fn restamp(ticket_str: &str, my_addr: iroh::EndpointAddr) -> String { pub fn restamp(ticket_str: &str, my_addr: iroh::EndpointAddr) -> String {
match ticket_str.parse::<PeerSpeakTicket>() { match ticket_str.parse::<PeerSpeakTicket>() {
Ok(t) => { Ok(t) => PeerSpeakTicket {
PeerSpeakTicket { host_addr: my_addr, topic_id: t.topic_id, name: t.name } host_addr: my_addr,
.to_string() topic_id: t.topic_id,
name: t.name,
} }
.to_string(),
Err(_) => ticket_str.to_string(), Err(_) => ticket_str.to_string(),
} }
} }
@@ -215,7 +247,10 @@ impl PeerSpeakTicket {
/// can't be parsed or carries no label. Pure; used to label the gathering both /// can't be parsed or carries no label. Pure; used to label the gathering both
/// in the room UI and in the presence we report to friends. /// in the room UI and in the presence we report to friends.
pub fn label_of(ticket_str: &str) -> String { pub fn label_of(ticket_str: &str) -> String {
ticket_str.parse::<PeerSpeakTicket>().map(|t| t.name).unwrap_or_default() ticket_str
.parse::<PeerSpeakTicket>()
.map(|t| t.name)
.unwrap_or_default()
} }
/// The room's `topic_id` embedded in a ticket string, or `None` if the ticket /// The room's `topic_id` embedded in a ticket string, or `None` if the ticket
@@ -223,7 +258,10 @@ impl PeerSpeakTicket {
/// the recents list (the host address and label change between members/sessions, /// the recents list (the host address and label change between members/sessions,
/// but the topic uniquely identifies the gathering). /// but the topic uniquely identifies the gathering).
pub fn topic_of(ticket_str: &str) -> Option<[u8; 32]> { pub fn topic_of(ticket_str: &str) -> Option<[u8; 32]> {
ticket_str.parse::<PeerSpeakTicket>().ok().map(|t| t.topic_id) ticket_str
.parse::<PeerSpeakTicket>()
.ok()
.map(|t| t.topic_id)
} }
} }
@@ -246,8 +284,8 @@ impl FromStr for PeerSpeakTicket {
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
let decoded = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, s) let decoded = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, s)
.map_err(|e| NetError::InvalidTicket(e.to_string()))?; .map_err(|e| NetError::InvalidTicket(e.to_string()))?;
let ticket: PeerSpeakTicket = serde_json::from_slice(&decoded) let ticket: PeerSpeakTicket =
.map_err(|e| NetError::InvalidTicket(e.to_string()))?; serde_json::from_slice(&decoded).map_err(|e| NetError::InvalidTicket(e.to_string()))?;
Ok(ticket) Ok(ticket)
} }
} }
@@ -327,13 +365,13 @@ pub trait RoomState: Send + Sync {
async fn subscribe_events(&self) -> Result<Receiver<RoomEvent>, NetError>; async fn subscribe_events(&self) -> Result<Receiver<RoomEvent>, NetError>;
} }
pub mod iroh_impl;
pub mod gossip; pub mod gossip;
pub mod iroh_impl;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use iroh::{SecretKey, EndpointAddr}; use iroh::{EndpointAddr, SecretKey};
fn sample_peer_state() -> PeerState { fn sample_peer_state() -> PeerState {
let secret = SecretKey::generate(); let secret = SecretKey::generate();
@@ -371,9 +409,12 @@ mod tests {
let host = SecretKey::generate().public(); let host = SecretKey::generate().public();
let topic_id = [3u8; 32]; let topic_id = [3u8; 32];
// A labelled ticket: restamp keeps the label, label_of reads it. // A labelled ticket: restamp keeps the label, label_of reads it.
let labelled = let labelled = PeerSpeakTicket {
PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id, name: "HangOut".into() } host_addr: EndpointAddr::from(host),
.to_string(); topic_id,
name: "HangOut".into(),
}
.to_string();
assert_eq!(PeerSpeakTicket::label_of(&labelled), "HangOut"); assert_eq!(PeerSpeakTicket::label_of(&labelled), "HangOut");
let member = SecretKey::generate().public(); let member = SecretKey::generate().public();
let restamped = PeerSpeakTicket::restamp(&labelled, EndpointAddr::from(member)); let restamped = PeerSpeakTicket::restamp(&labelled, EndpointAddr::from(member));
@@ -410,10 +451,8 @@ mod tests {
// valid URL-safe-base64 that decodes to non-JSON bytes // valid URL-safe-base64 that decodes to non-JSON bytes
let bad_json = b"hello world"; let bad_json = b"hello world";
let encoded = base64::Engine::encode( let encoded =
&base64::engine::general_purpose::URL_SAFE_NO_PAD, base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, bad_json);
bad_json,
);
let res3 = encoded.parse::<PeerSpeakTicket>(); let res3 = encoded.parse::<PeerSpeakTicket>();
assert!(matches!(res3, Err(NetError::InvalidTicket(_)))); assert!(matches!(res3, Err(NetError::InvalidTicket(_))));
} }
@@ -424,9 +463,12 @@ mod tests {
let host = SecretKey::generate().public(); let host = SecretKey::generate().public();
let member = SecretKey::generate().public(); let member = SecretKey::generate().public();
let topic_id = [42u8; 32]; let topic_id = [42u8; 32];
let original = let original = PeerSpeakTicket {
PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id, name: "HangOut".into() } host_addr: EndpointAddr::from(host),
.to_string(); topic_id,
name: "HangOut".into(),
}
.to_string();
let restamped_str = PeerSpeakTicket::restamp(&original, EndpointAddr::from(member)); let restamped_str = PeerSpeakTicket::restamp(&original, EndpointAddr::from(member));
let restamped = restamped_str.parse::<PeerSpeakTicket>().unwrap(); let restamped = restamped_str.parse::<PeerSpeakTicket>().unwrap();
@@ -441,18 +483,27 @@ mod tests {
fn test_restamp_is_idempotent_for_same_addr() { fn test_restamp_is_idempotent_for_same_addr() {
let me = SecretKey::generate().public(); let me = SecretKey::generate().public();
let topic_id = [7u8; 32]; let topic_id = [7u8; 32];
let mine = let mine = PeerSpeakTicket {
PeerSpeakTicket { host_addr: EndpointAddr::from(me), topic_id, name: String::new() } host_addr: EndpointAddr::from(me),
.to_string(); topic_id,
name: String::new(),
}
.to_string();
// Re-stamping my own ticket with my own addr changes nothing. // Re-stamping my own ticket with my own addr changes nothing.
assert_eq!(PeerSpeakTicket::restamp(&mine, EndpointAddr::from(me)), mine); assert_eq!(
PeerSpeakTicket::restamp(&mine, EndpointAddr::from(me)),
mine
);
} }
#[test] #[test]
fn test_restamp_passes_through_unparseable() { fn test_restamp_passes_through_unparseable() {
let me = SecretKey::generate().public(); let me = SecretKey::generate().public();
// A malformed ticket is returned unchanged (the join will fail anyway). // A malformed ticket is returned unchanged (the join will fail anyway).
assert_eq!(PeerSpeakTicket::restamp("not-a-ticket", EndpointAddr::from(me)), "not-a-ticket"); assert_eq!(
PeerSpeakTicket::restamp("not-a-ticket", EndpointAddr::from(me)),
"not-a-ticket"
);
} }
#[test] #[test]
+201 -18
View File
@@ -10,13 +10,19 @@
//! leaves a zombie. Any failure (no player, no audio) is silent by design — a //! leaves a zombie. Any failure (no player, no audio) is silent by design — a
//! missing chime should never disrupt a call. //! missing chime should never disrupt a call.
#[cfg(not(windows))]
use crate::audio::ownership;
use std::collections::HashMap; use std::collections::HashMap;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
static ENABLED: AtomicBool = AtomicBool::new(true); static ENABLED: AtomicBool = AtomicBool::new(true);
static TEMP_WAV_COUNTER: AtomicU64 = AtomicU64::new(0);
/// Per-sound enable flags (W6), indexed by `Sound::index`. The master `ENABLED` /// Per-sound enable flags (W6), indexed by `Sound::index`. The master `ENABLED`
/// toggle gates everything; these silence individual events while the master /// toggle gates everything; these silence individual events while the master
@@ -57,7 +63,6 @@ pub fn should_play(master_enabled: bool, sound_enabled: bool) -> bool {
master_enabled && sound_enabled master_enabled && sound_enabled
} }
/// A notification event with a distinct chime. /// A notification event with a distinct chime.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Sound { pub enum Sound {
@@ -77,6 +82,14 @@ pub enum Sound {
MicToggle, MicToggle,
/// Reconnect failed / peer evicted. /// Reconnect failed / peer evicted.
ReconnectFailed, ReconnectFailed,
/// One of our chat messages was broadcast to the room.
ChatSent,
/// A chat message from another participant was admitted.
ChatReceived,
/// A saved contact was detected online on the home screen.
ContactOnline,
/// A saved contact previously seen online went offline on the home screen.
ContactOffline,
} }
impl Sound { impl Sound {
@@ -90,10 +103,14 @@ impl Sound {
Sound::SelfLeave, Sound::SelfLeave,
Sound::MicToggle, Sound::MicToggle,
Sound::ReconnectFailed, Sound::ReconnectFailed,
Sound::ChatSent,
Sound::ChatReceived,
Sound::ContactOnline,
Sound::ContactOffline,
]; ];
/// Number of distinct notification events. /// Number of distinct notification events.
pub const COUNT: usize = 8; pub const COUNT: usize = 12;
/// Stable 0-based index into the per-sound flag array. Must match `ALL`. /// Stable 0-based index into the per-sound flag array. Must match `ALL`.
fn index(self) -> usize { fn index(self) -> usize {
@@ -106,6 +123,10 @@ impl Sound {
Sound::SelfLeave => 5, Sound::SelfLeave => 5,
Sound::MicToggle => 6, Sound::MicToggle => 6,
Sound::ReconnectFailed => 7, Sound::ReconnectFailed => 7,
Sound::ChatSent => 8,
Sound::ChatReceived => 9,
Sound::ContactOnline => 10,
Sound::ContactOffline => 11,
} }
} }
@@ -120,6 +141,10 @@ impl Sound {
Sound::SelfLeave => include_bytes!("../assets/sounds/self-leave.wav"), Sound::SelfLeave => include_bytes!("../assets/sounds/self-leave.wav"),
Sound::MicToggle => include_bytes!("../assets/sounds/mic-toggle.wav"), Sound::MicToggle => include_bytes!("../assets/sounds/mic-toggle.wav"),
Sound::ReconnectFailed => include_bytes!("../assets/sounds/reconnect-failed.wav"), Sound::ReconnectFailed => include_bytes!("../assets/sounds/reconnect-failed.wav"),
Sound::ChatSent => include_bytes!("../assets/sounds/chat-sent.wav"),
Sound::ChatReceived => include_bytes!("../assets/sounds/chat-received.wav"),
Sound::ContactOnline => include_bytes!("../assets/sounds/contact-online.wav"),
Sound::ContactOffline => include_bytes!("../assets/sounds/contact-offline.wav"),
} }
} }
@@ -134,6 +159,10 @@ impl Sound {
Sound::SelfLeave => "self-leave", Sound::SelfLeave => "self-leave",
Sound::MicToggle => "mic-toggle", Sound::MicToggle => "mic-toggle",
Sound::ReconnectFailed => "reconnect-failed", Sound::ReconnectFailed => "reconnect-failed",
Sound::ChatSent => "chat-sent",
Sound::ChatReceived => "chat-received",
Sound::ContactOnline => "contact-online",
Sound::ContactOffline => "contact-offline",
} }
} }
} }
@@ -195,14 +224,38 @@ fn cached_path(sound: Sound) -> Option<PathBuf> {
if let Some(path) = guard.get(sound.name()) { if let Some(path) = guard.get(sound.name()) {
return Some(path.clone()); return Some(path.clone());
} }
let path = std::env::temp_dir().join(format!("peerspeak-{}.wav", sound.name())); let path = match write_private_wav(&std::env::temp_dir(), sound.name(), sound.bytes()) {
if std::fs::write(&path, sound.bytes()).is_err() { Ok(path) => path,
return None; Err(_) => return None,
} };
guard.insert(sound.name(), path.clone()); guard.insert(sound.name(), path.clone());
Some(path) Some(path)
} }
fn write_private_wav(dir: &Path, stem: &str, bytes: &[u8]) -> std::io::Result<PathBuf> {
let counter = TEMP_WAV_COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let path = dir.join(format!(
"peerspeak-{stem}-{}-{counter}-{nanos}.wav",
std::process::id()
));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(&path)?;
file.write_all(bytes)?;
Ok(path)
}
#[cfg(any(windows, test))] #[cfg(any(windows, test))]
fn escape_powershell_single_quoted(s: &str) -> String { fn escape_powershell_single_quoted(s: &str) -> String {
s.replace('\'', "''") s.replace('\'', "''")
@@ -213,12 +266,20 @@ fn escape_powershell_single_quoted(s: &str) -> String {
#[cfg(not(windows))] #[cfg(not(windows))]
fn spawn_player(path: &Path) { fn spawn_player(path: &Path) {
for player in ["pw-play", "paplay", "aplay"] { for player in ["pw-play", "paplay", "aplay"] {
let started = Command::new(player) let mut command = Command::new(player);
command
.arg(path) .arg(path)
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()) .stderr(Stdio::null());
.status(); // Ownership tag (plan §5.1). A chime is short, but it is still our
// audio on the default sink, and an untagged one is an unowned root
// the exclusion engine would have to reason about from scratch.
// Measured on this host: all three fallbacks tag correctly, `aplay`
// included — it reaches the graph through PipeWire's ALSA plugin,
// which honours `PIPEWIRE_PROPS` like any other client.
ownership::tag_child(&mut command, ownership::NOTIFICATION_ROLE);
let started = command.status();
// `status()` errors only if the player binary isn't present; on a real // `status()` errors only if the player binary isn't present; on a real
// playback error it still returns (non-zero), so a started player ends // playback error it still returns (non-zero), so a started player ends
// the loop either way — we don't want to double-play through fallbacks. // the loop either way — we don't want to double-play through fallbacks.
@@ -249,6 +310,62 @@ fn spawn_player(path: &Path) {
mod tests { mod tests {
use super::*; use super::*;
static TEST_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_wav_dir(tag: &str) -> PathBuf {
let counter = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"peerspeak-notifytest-{}-{tag}-{counter}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// Phase-1 exit gate, notification half (impl plan §3): a chime peerspeak
/// actually plays produces a live PipeWire node carrying **both**
/// ownership carriers.
///
/// ⚠️ Deliberately drives `play()`, not `tag_child()`. The unit test in
/// `audio::ownership` proves the environment is built correctly; only a
/// live run proves this module *uses* it and that the audio stack honours
/// it end to end. The chime is silent (a zero-filled WAV), so running it
/// never makes noise.
///
/// Live: needs a running PipeWire daemon, `pw-play`/`paplay` and
/// `pw-dump`. `cargo test --lib -- --ignored notification_chime`
#[test]
#[ignore = "live: requires a running PipeWire daemon and pw-dump"]
#[cfg(not(windows))]
fn notification_chime_node_carries_both_ownership_carriers() {
use crate::audio::ownership::{self, live_test};
let dir = temp_wav_dir("ownership");
let path = dir.join("silence.wav");
std::fs::write(&path, live_test::silent_wav(6)).unwrap();
set_enabled(true);
set_sound_enabled(Sound::PeerJoin, true);
play(Sound::PeerJoin, Some(path.to_str().unwrap()));
let prefix = live_test::expected_prefix(ownership::NOTIFICATION_ROLE);
let found = live_test::poll_for_owned_node(&prefix, std::time::Duration::from_secs(5));
std::fs::remove_dir_all(&dir).ok();
let (name, owned) =
found.unwrap_or_else(|| panic!("no live node named {prefix:?} appeared within 5s"));
assert!(
name.starts_with(ownership::OWNED_NODE_NAME_PREFIX),
"{name}"
);
assert_eq!(
owned.as_deref(),
Some(ownership::OWNED_PROP_VALUE),
"carrier 1 must be on the live node too, not just carrier 2"
);
}
#[test] #[test]
fn test_should_play_truth_table() { fn test_should_play_truth_table() {
// Plays only when BOTH the master and the per-sound flag are on. // Plays only when BOTH the master and the per-sound flag are on.
@@ -264,10 +381,7 @@ mod tests {
escape_powershell_single_quoted(r"C:\Users\O'Brien\chime.wav"), escape_powershell_single_quoted(r"C:\Users\O'Brien\chime.wav"),
r"C:\Users\O''Brien\chime.wav" r"C:\Users\O''Brien\chime.wav"
); );
assert_eq!( assert_eq!(escape_powershell_single_quoted("a'b'c"), "a''b''c");
escape_powershell_single_quoted("a'b'c"),
"a''b''c"
);
} }
#[test] #[test]
@@ -297,7 +411,10 @@ mod tests {
// bare `~` -> home dir // bare `~` -> home dir
assert_eq!(expand_tilde("~"), home); assert_eq!(expand_tilde("~"), home);
// `~/sub/dir/file.wav` -> home joined with `sub/dir/file.wav` // `~/sub/dir/file.wav` -> home joined with `sub/dir/file.wav`
assert_eq!(expand_tilde("~/sub/dir/file.wav"), home.join("sub/dir/file.wav")); assert_eq!(
expand_tilde("~/sub/dir/file.wav"),
home.join("sub/dir/file.wav")
);
} }
// absolute path (`/etc/foo.wav`) -> unchanged // absolute path (`/etc/foo.wav`) -> unchanged
assert_eq!(expand_tilde("/etc/foo.wav"), PathBuf::from("/etc/foo.wav")); assert_eq!(expand_tilde("/etc/foo.wav"), PathBuf::from("/etc/foo.wav"));
@@ -310,10 +427,19 @@ mod tests {
// leading/trailing whitespace is trimmed // leading/trailing whitespace is trimmed
if let Some(home) = dirs::home_dir() { if let Some(home) = dirs::home_dir() {
assert_eq!(expand_tilde(" ~ "), home); assert_eq!(expand_tilde(" ~ "), home);
assert_eq!(expand_tilde(" ~/sub/dir/file.wav "), home.join("sub/dir/file.wav")); assert_eq!(
expand_tilde(" ~/sub/dir/file.wav "),
home.join("sub/dir/file.wav")
);
} }
assert_eq!(expand_tilde(" /etc/foo.wav "), PathBuf::from("/etc/foo.wav")); assert_eq!(
assert_eq!(expand_tilde(" foo/bar.wav "), PathBuf::from("foo/bar.wav")); expand_tilde(" /etc/foo.wav "),
PathBuf::from("/etc/foo.wav")
);
assert_eq!(
expand_tilde(" foo/bar.wav "),
PathBuf::from("foo/bar.wav")
);
} }
#[test] #[test]
@@ -332,4 +458,61 @@ mod tests {
// a `~`-prefixed path that resolves to a non-existent file -> Some(false) // a `~`-prefixed path that resolves to a non-existent file -> Some(false)
assert_eq!(validate_custom_path("~/non/existent/file.wav"), Some(false)); assert_eq!(validate_custom_path("~/non/existent/file.wav"), Some(false));
} }
#[test]
fn write_private_wav_writes_exact_bytes() {
let dir = temp_wav_dir("writes");
let bytes = b"RIFFpeerspeak-test";
let path = write_private_wav(&dir, "unit", bytes).unwrap();
assert!(path.exists());
assert_eq!(std::fs::read(&path).unwrap(), bytes);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn write_private_wav_creates_0600_file() {
use std::os::unix::fs::PermissionsExt;
let dir = temp_wav_dir("mode");
let path = write_private_wav(&dir, "unit", b"mode").unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn write_private_wav_uses_unique_paths() {
let dir = temp_wav_dir("unique");
let first = write_private_wav(&dir, "same-stem", b"first").unwrap();
let second = write_private_wav(&dir, "same-stem", b"second").unwrap();
assert_ne!(first, second);
assert!(first.exists());
assert!(second.exists());
assert_eq!(std::fs::read(&first).unwrap(), b"first");
assert_eq!(std::fs::read(&second).unwrap(), b"second");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn create_new_refuses_existing_path() {
let dir = temp_wav_dir("create-new");
let path = dir.join("preexisting.wav");
std::fs::write(&path, b"original").unwrap();
let err = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
assert_eq!(std::fs::read(&path).unwrap(), b"original");
let _ = std::fs::remove_dir_all(&dir);
}
} }
+5 -4
View File
@@ -49,9 +49,7 @@ pub fn parse_playlist(contents: &str, base_dir: &Path, kind: PlaylistKind) -> Ve
fn playlist_entry_path(entry: &str, base_dir: &Path) -> Option<PathBuf> { fn playlist_entry_path(entry: &str, base_dir: &Path) -> Option<PathBuf> {
let lower = entry.to_ascii_lowercase(); let lower = entry.to_ascii_lowercase();
if lower.starts_with("http://") if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("ftp://")
|| lower.starts_with("https://")
|| lower.starts_with("ftp://")
{ {
return None; return None;
} }
@@ -107,7 +105,10 @@ File3=/var/audio/two.MP3
#[test] #[test]
fn playlist_kind_is_case_insensitive() { fn playlist_kind_is_case_insensitive() {
assert_eq!(playlist_kind(Path::new("mix.M3U")), Some(PlaylistKind::M3u)); assert_eq!(playlist_kind(Path::new("mix.M3U")), Some(PlaylistKind::M3u));
assert_eq!(playlist_kind(Path::new("mix.m3u8")), Some(PlaylistKind::M3u)); assert_eq!(
playlist_kind(Path::new("mix.m3u8")),
Some(PlaylistKind::M3u)
);
assert_eq!(playlist_kind(Path::new("mix.PLS")), Some(PlaylistKind::Pls)); assert_eq!(playlist_kind(Path::new("mix.PLS")), Some(PlaylistKind::Pls));
assert_eq!(playlist_kind(Path::new("mix.txt")), None); assert_eq!(playlist_kind(Path::new("mix.txt")), None);
} }
+67 -19
View File
@@ -36,8 +36,11 @@ pub enum PresenceMode {
impl PresenceMode { impl PresenceMode {
/// All postures, default first — the option list for the Settings/home picker. /// All postures, default first — the option list for the Settings/home picker.
pub const ALL: [PresenceMode; 3] = pub const ALL: [PresenceMode; 3] = [
[PresenceMode::Normal, PresenceMode::Invisible, PresenceMode::Discoverable]; PresenceMode::Normal,
PresenceMode::Invisible,
PresenceMode::Discoverable,
];
/// Whether this posture publishes to discovery (the only mode that does). /// Whether this posture publishes to discovery (the only mode that does).
pub fn publishes_to_discovery(self) -> bool { pub fn publishes_to_discovery(self) -> bool {
@@ -193,7 +196,11 @@ mod tests {
assert!(!should_answer(&friend, &friends, PresenceMode::Invisible)); assert!(!should_answer(&friend, &friends, PresenceMode::Invisible));
// Stranger is NEVER answered, in any mode. // Stranger is NEVER answered, in any mode.
assert!(!should_answer(&stranger, &friends, PresenceMode::Normal)); assert!(!should_answer(&stranger, &friends, PresenceMode::Normal));
assert!(!should_answer(&stranger, &friends, PresenceMode::Discoverable)); assert!(!should_answer(
&stranger,
&friends,
PresenceMode::Discoverable
));
assert!(!should_answer(&stranger, &friends, PresenceMode::Invisible)); assert!(!should_answer(&stranger, &friends, PresenceMode::Invisible));
} }
@@ -214,7 +221,10 @@ mod tests {
ControlMsg::Ping, ControlMsg::Ping,
ControlMsg::Pong { room: None }, ControlMsg::Pong { room: None },
ControlMsg::Pong { ControlMsg::Pong {
room: Some(RoomPresence { name: "HangOut".into(), ticket: "abc".into() }), room: Some(RoomPresence {
name: "HangOut".into(),
ticket: "abc".into(),
}),
}, },
]; ];
for msg in cases { for msg in cases {
@@ -245,19 +255,37 @@ mod tests {
); );
// Valid ticket -> InRoom with a sanitized name. // Valid ticket -> InRoom with a sanitized name.
let t = valid_ticket(friend); let t = valid_ticket(friend);
let got = interpret_pong(&ControlMsg::Pong { let got = interpret_pong(
room: Some(RoomPresence { name: "HangOut".into(), ticket: t.clone() }), &ControlMsg::Pong {
}, friend); room: Some(RoomPresence {
assert_eq!(got, Some(FriendPresence::InRoom { name: "HangOut".into(), ticket: t })); name: "HangOut".into(),
ticket: t.clone(),
}),
},
friend,
);
assert_eq!(
got,
Some(FriendPresence::InRoom {
name: "HangOut".into(),
ticket: t
})
);
} }
#[test] #[test]
fn interpret_pong_downgrades_a_garbage_ticket_to_online() { fn interpret_pong_downgrades_a_garbage_ticket_to_online() {
// A friend reporting a room with an unparseable ticket is treated as just // A friend reporting a room with an unparseable ticket is treated as just
// Online — no dead/hostile Join button is surfaced. // Online — no dead/hostile Join button is surfaced.
let got = interpret_pong(&ControlMsg::Pong { let got = interpret_pong(
room: Some(RoomPresence { name: "Trap".into(), ticket: "not-a-ticket".into() }), &ControlMsg::Pong {
}, id()); room: Some(RoomPresence {
name: "Trap".into(),
ticket: "not-a-ticket".into(),
}),
},
id(),
);
assert_eq!(got, Some(FriendPresence::Online)); assert_eq!(got, Some(FriendPresence::Online));
} }
@@ -266,9 +294,15 @@ mod tests {
let friend = id(); let friend = id();
let attacker = id(); let attacker = id();
let t = valid_ticket(attacker); let t = valid_ticket(attacker);
let got = interpret_pong(&ControlMsg::Pong { let got = interpret_pong(
room: Some(RoomPresence { name: "Redirect".into(), ticket: t }), &ControlMsg::Pong {
}, friend); room: Some(RoomPresence {
name: "Redirect".into(),
ticket: t,
}),
},
friend,
);
assert_eq!(got, Some(FriendPresence::Online)); assert_eq!(got, Some(FriendPresence::Online));
} }
@@ -287,10 +321,18 @@ mod tests {
let t = valid_ticket(friend); let t = valid_ticket(friend);
assert_eq!( assert_eq!(
presence_from_probe(Some(( presence_from_probe(Some((
&ControlMsg::Pong { room: Some(RoomPresence { name: "Den".into(), ticket: t.clone() }) }, &ControlMsg::Pong {
room: Some(RoomPresence {
name: "Den".into(),
ticket: t.clone()
})
},
friend, friend,
))), ))),
FriendPresence::InRoom { name: "Den".into(), ticket: t } FriendPresence::InRoom {
name: "Den".into(),
ticket: t
}
); );
// A non-reply (a stray Ping) is not a presence -> Offline, never a false Online. // A non-reply (a stray Ping) is not a presence -> Offline, never a false Online.
assert_eq!( assert_eq!(
@@ -304,9 +346,15 @@ mod tests {
// Control/bidi characters in a peer-supplied name are stripped. // Control/bidi characters in a peer-supplied name are stripped.
let friend = id(); let friend = id();
let t = valid_ticket(friend); let t = valid_ticket(friend);
let got = interpret_pong(&ControlMsg::Pong { let got = interpret_pong(
room: Some(RoomPresence { name: "Hang\u{202e}Out\u{0007}".into(), ticket: t.clone() }), &ControlMsg::Pong {
}, friend); room: Some(RoomPresence {
name: "Hang\u{202e}Out\u{0007}".into(),
ticket: t.clone(),
}),
},
friend,
);
match got { match got {
Some(FriendPresence::InRoom { name, .. }) => { Some(FriendPresence::InRoom { name, .. }) => {
assert!(!name.contains('\u{202e}'), "bidi override must be stripped"); assert!(!name.contains('\u{202e}'), "bidi override must be stripped");
+21 -8
View File
@@ -54,7 +54,10 @@ fn decode(bytes: &[u8]) -> Result<ControlMsg> {
/// malformed) — the caller treats that as "appears offline". `peer` is usually a /// malformed) — the caller treats that as "appears offline". `peer` is usually a
/// bare [`EndpointId`] (friends store the stable id); a full [`EndpointAddr`] is /// bare [`EndpointId`] (friends store the stable id); a full [`EndpointAddr`] is
/// also accepted (and used by hermetic tests). /// also accepted (and used by hermetic tests).
pub async fn probe(endpoint: &Endpoint, peer: impl Into<EndpointAddr>) -> Result<(EndpointId, ControlMsg)> { pub async fn probe(
endpoint: &Endpoint,
peer: impl Into<EndpointAddr>,
) -> Result<(EndpointId, ControlMsg)> {
let conn = tokio::time::timeout(IO_TIMEOUT, endpoint.connect(peer, FRIENDS_ALPN)) let conn = tokio::time::timeout(IO_TIMEOUT, endpoint.connect(peer, FRIENDS_ALPN))
.await .await
.context("timed out connecting to peer")? .context("timed out connecting to peer")?
@@ -62,7 +65,10 @@ pub async fn probe(endpoint: &Endpoint, peer: impl Into<EndpointAddr>) -> Result
let from = conn.remote_id(); let from = conn.remote_id();
let io = async { let io = async {
let (mut send, mut recv) = conn.open_bi().await.context("failed to open control stream")?; let (mut send, mut recv) = conn
.open_bi()
.await
.context("failed to open control stream")?;
send.write_all(&encode(&ControlMsg::Ping)?) send.write_all(&encode(&ControlMsg::Ping)?)
.await .await
.context("failed to write ping")?; .context("failed to write ping")?;
@@ -118,7 +124,10 @@ async fn exchange(conn: &iroh::endpoint::Connection, handler: &Handler) -> Resul
let io = async { let io = async {
let (mut send, mut recv) = conn.accept_bi().await.context("failed to accept stream")?; let (mut send, mut recv) = conn.accept_bi().await.context("failed to accept stream")?;
let bytes = recv.read_to_end(MAX_MSG).await.context("failed to read ping")?; let bytes = recv
.read_to_end(MAX_MSG)
.await
.context("failed to read ping")?;
match decode(&bytes)? { match decode(&bytes)? {
ControlMsg::Ping => {} ControlMsg::Ping => {}
other => bail!("expected a ping, got {other:?}"), other => bail!("expected a ping, got {other:?}"),
@@ -211,7 +220,10 @@ mod tests {
let handler: Handler = Arc::new(move |from| { let handler: Handler = Arc::new(move |from| {
if from == allowed { if from == allowed {
Some(ControlMsg::Pong { Some(ControlMsg::Pong {
room: Some(RoomPresence { name: "HangOut".into(), ticket: "t".into() }), room: Some(RoomPresence {
name: "HangOut".into(),
ticket: "t".into(),
}),
}) })
} else { } else {
None // stranger -> no reply None // stranger -> no reply
@@ -221,10 +233,11 @@ mod tests {
let serve_task = tokio::spawn(async move { serve(server_ep, handler).await }); let serve_task = tokio::spawn(async move { serve(server_ep, handler).await });
// The allowed prober gets a Pong with the room. // The allowed prober gets a Pong with the room.
let (from, pong) = tokio::time::timeout(Duration::from_secs(15), probe(&prober, server_addr.clone())) let (from, pong) =
.await tokio::time::timeout(Duration::from_secs(15), probe(&prober, server_addr.clone()))
.expect("probe timed out") .await
.expect("probe failed"); .expect("probe timed out")
.expect("probe failed");
assert_eq!(from, server_addr.id); assert_eq!(from, server_addr.id);
match pong { match pong {
ControlMsg::Pong { room: Some(r) } => assert_eq!(r.name, "HangOut"), ControlMsg::Pong { room: Some(r) } => assert_eq!(r.name, "HangOut"),
+31 -11
View File
@@ -33,12 +33,16 @@ pub const FRIENDS_PROTO: u32 = 1;
/// change is isolated into its own topic + signature domain so v2 and v3 peers /// change is isolated into its own topic + signature domain so v2 and v3 peers
/// never share a swarm. Resync everyone, exactly like the W4 avatar bump. /// never share a swarm. Resync everyone, exactly like the W4 avatar bump.
/// ///
/// v4 (0.6.0): `PeerState` gained an optional `music` presence field carrying a /// v4v5 (0.6.0): the W22 shared-listening / music presence work. `PeerState`
/// current shared-listening track descriptor and playback timeline. Bytes still /// gained an optional `music` presence field (a current shared-listening track
/// ride the files plane by id; gossip carries only the descriptor/timeline. /// descriptor + playback timeline; the audio bytes still ride the files plane by
/// /// id, gossip carries only the descriptor/timeline), and `MusicPresence` then
/// v5 (0.7.0): `MusicPresence` gained optional prefetch hints for the next /// gained optional prefetch hints for the next track so tuned-in listeners can
/// track so tuned-in listeners can fetch it before the DJ advances. /// fetch it before the DJ advances. Both shipped together in the **0.6.0** release
/// (commit `bca2ccd`), where the const advanced straight `3 → 5`: there was never
/// a `GOSSIP_PROTO == 4` build — 4 is a skipped step. (Per `VERSIONING.md` this
/// breaking gossip change rode the `0.5.1 → 0.6.0` MINOR bump, so the discipline
/// was honoured; 0.6.1 is a wire-compatible PATCH on top, still proto 5.)
pub const GOSSIP_PROTO: u32 = 5; pub const GOSSIP_PROTO: u32 = 5;
/// File-transfer plane version (chat attachment request/stream shape). Bump on /// File-transfer plane version (chat attachment request/stream shape). Bump on
/// any change. Mirrored in [`FILES_ALPN`]. /// any change. Mirrored in [`FILES_ALPN`].
@@ -83,10 +87,22 @@ mod tests {
/// so a version bump can't silently forget to update the wire string. /// so a version bump can't silently forget to update the wire string.
#[test] #[test]
fn alpns_match_their_proto_versions() { fn alpns_match_their_proto_versions() {
assert_eq!(AUDIO_ALPN, format!("peerspeak/audio/{AUDIO_PROTO}").as_bytes()); assert_eq!(
assert_eq!(FRIENDS_ALPN, format!("peerspeak/friends/{FRIENDS_PROTO}").as_bytes()); AUDIO_ALPN,
assert_eq!(FILES_ALPN, format!("peerspeak/files/{FILES_PROTO}").as_bytes()); format!("peerspeak/audio/{AUDIO_PROTO}").as_bytes()
assert_eq!(GOSSIP_SIG_DOMAIN, format!("peerspeak-gossip-v{GOSSIP_PROTO}")); );
assert_eq!(
FRIENDS_ALPN,
format!("peerspeak/friends/{FRIENDS_PROTO}").as_bytes()
);
assert_eq!(
FILES_ALPN,
format!("peerspeak/files/{FILES_PROTO}").as_bytes()
);
assert_eq!(
GOSSIP_SIG_DOMAIN,
format!("peerspeak-gossip-v{GOSSIP_PROTO}")
);
} }
#[test] #[test]
@@ -95,7 +111,11 @@ mod tests {
let mut b = a; let mut b = a;
b[5] = 10; b[5] = 10;
assert_eq!(versioned_topic(a), versioned_topic(a), "deterministic"); assert_eq!(versioned_topic(a), versioned_topic(a), "deterministic");
assert_ne!(versioned_topic(a), versioned_topic(b), "distinct rooms stay distinct"); assert_ne!(
versioned_topic(a),
versioned_topic(b),
"distinct rooms stay distinct"
);
} }
#[test] #[test]
+14 -3
View File
@@ -51,7 +51,14 @@ fn same_room(a: &str, b: &str) -> bool {
/// supplies `now` (unix seconds) and persists the list afterwards. /// supplies `now` (unix seconds) and persists the list afterwards.
pub fn push_recent(list: &mut Vec<Recent>, name: String, ticket: String, now: u64) { pub fn push_recent(list: &mut Vec<Recent>, name: String, ticket: String, now: u64) {
list.retain(|r| !same_room(&r.ticket, &ticket)); list.retain(|r| !same_room(&r.ticket, &ticket));
list.insert(0, Recent { name, ticket, joined_at: now }); list.insert(
0,
Recent {
name,
ticket,
joined_at: now,
},
);
list.truncate(RECENTS_MAX); list.truncate(RECENTS_MAX);
} }
@@ -86,8 +93,12 @@ mod tests {
/// Build a real, parseable ticket for a fresh room with the given label. /// Build a real, parseable ticket for a fresh room with the given label.
fn ticket(name: &str, topic: [u8; 32]) -> String { fn ticket(name: &str, topic: [u8; 32]) -> String {
let host = SecretKey::generate().public(); let host = SecretKey::generate().public();
PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id: topic, name: name.into() } PeerSpeakTicket {
.to_string() host_addr: EndpointAddr::from(host),
topic_id: topic,
name: name.into(),
}
.to_string()
} }
#[test] #[test]
+435 -115
View File
@@ -3,28 +3,40 @@
//! Peer display names ride the gossip presence plane (`PeerState.name`), which is //! Peer display names ride the gossip presence plane (`PeerState.name`), which is
//! untrusted and spoofable, yet they're rendered directly in the roster. This //! untrusted and spoofable, yet they're rendered directly in the roster. This
//! module cleans a name at the gossip ingest point so every downstream consumer //! module cleans a name at the gossip ingest point so every downstream consumer
//! gets a safe value (security finding S4). Chat text has its own sanitizer in //! gets a safe value (security finding S4). Chat text policy ([`sanitize_chat`],
//! the UI layer (`app::sanitize_chat`). //! [`cap_chat_input`], [`admit_chat_text`]) also lives here so the UI, the gossip
//! sign point, and the gossip ingress all enforce the same ceilings.
/// Max characters kept for a peer's display name after sanitizing. Names are /// Max characters kept for a peer's display name after sanitizing. Names are
/// short labels, so a tight cap both prevents UI/layout/memory abuse and keeps /// short labels, so a tight cap both prevents UI/layout/memory abuse and keeps
/// the roster readable. /// the roster readable.
pub const NAME_MAX_CHARS: usize = 48; pub const NAME_MAX_CHARS: usize = 48;
/// Bidirectional override/isolate format characters (`General_Category=Cf`, NOT
/// caught by [`char::is_control`]) that can visually reorder surrounding text.
/// Stripped even from expressive chat bodies (security finding S14): unlike the
/// benign zero-width joiners/marks, these let a sender make rendered text read
/// differently from what was actually sent.
pub(crate) fn is_bidi_override_char(c: char) -> bool {
matches!(c,
'\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO (bidi overrides)
| '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI (bidi isolates)
)
}
/// Unicode *format* characters (`General_Category=Cf`) that can spoof or garble a /// Unicode *format* characters (`General_Category=Cf`) that can spoof or garble a
/// rendered name even though they are NOT caught by [`char::is_control`]: /// rendered name even though they are NOT caught by [`char::is_control`]:
/// bidirectional overrides/isolates (text-direction spoofing) and /// bidirectional overrides/isolates (text-direction spoofing) and
/// zero-width / BOM characters (invisible, can hide or fake content). Listed /// zero-width / BOM characters (invisible, can hide or fake content). Listed
/// explicitly so the sanitizer stays dependency-free (std exposes no category /// explicitly so the sanitizer stays dependency-free (std exposes no category
/// query). Stripped outright rather than replaced. /// query). Stripped outright rather than replaced.
fn is_spoofing_format_char(c: char) -> bool { pub(crate) fn is_spoofing_format_char(c: char) -> bool {
matches!(c, is_bidi_override_char(c)
'\u{200B}'..='\u{200F}' // zero-width space, ZWNJ, ZWJ, LRM, RLM || matches!(c,
| '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO (bidi overrides) '\u{200B}'..='\u{200F}' // zero-width space, ZWNJ, ZWJ, LRM, RLM
| '\u{2060}'..='\u{2064}' // word joiner .. invisible plus | '\u{2060}'..='\u{2064}' // word joiner .. invisible plus
| '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI (bidi isolates) | '\u{FEFF}' // BOM / zero-width no-break space
| '\u{FEFF}' // BOM / zero-width no-break space )
)
} }
/// Max characters kept for a broadcast game-presence label after sanitizing /// Max characters kept for a broadcast game-presence label after sanitizing
@@ -73,57 +85,186 @@ pub fn sanitize_game_label(input: &str) -> String {
out out
} }
/// A piece of a chat message after URL detection: literal text or a link. /// Max characters kept for a single chat message after sanitizing.
#[derive(Debug, PartialEq, Eq, Clone)] pub const CHAT_MSG_MAX_CHARS: usize = 2000;
pub enum Segment {
/// Plain text to render as-is. /// Max UTF-8 bytes kept for a single chat message, enforced alongside
Text(String), /// [`CHAT_MSG_MAX_CHARS`] (2,000 four-byte scalars would otherwise reach 8,000
/// A detected URL to render as a clickable link (also its href). /// bytes). This is also the ingress bound: signed peers never produce more, so
Link(String), /// raw incoming text above it is rejected outright (see [`admit_chat_text`]).
pub const CHAT_MSG_MAX_BYTES: usize = 8 * 1024;
/// Sanitize a chat message body, applied to BOTH our outgoing text (before local
/// echo, and again at the gossip sign point) and incoming peer text (untrusted —
/// a buggy/malicious sender could include control characters or an enormous
/// payload). Single pass: bidi overrides/isolates are stripped outright (S14 —
/// they can visually reorder the rendered line), control characters become
/// spaces, any whitespace run collapses to a single space, the ends are trimmed,
/// and both the character and UTF-8 byte ceilings are enforced without ever
/// splitting a scalar. Message bodies deliberately keep the OTHER format
/// characters (ZWJ/ZWNJ/LRM/RLM etc.) that the short-label sanitizers strip —
/// chat is expressive text, not a label, and those are needed for emoji
/// sequences and joining scripts. Returns `""` for input with no visible text
/// (callers drop empty messages). Idempotent, so layered application converges
/// on the same result.
pub fn sanitize_chat(input: &str) -> String {
let mut out = String::new();
let mut chars = 0usize;
let mut pending_space = false;
for c in input.chars() {
if is_bidi_override_char(c) {
continue;
}
let c = if c.is_control() { ' ' } else { c };
if c.is_whitespace() {
// Trim: only mark a separator once visible text exists; a trailing
// run is never emitted because the space lands with the NEXT char.
pending_space = !out.is_empty();
continue;
}
let sep = usize::from(pending_space);
if chars + sep + 1 > CHAT_MSG_MAX_CHARS
|| out.len() + sep + c.len_utf8() > CHAT_MSG_MAX_BYTES
{
break;
}
if pending_space {
out.push(' ');
chars += 1;
pending_space = false;
}
out.push(c);
chars += 1;
}
out
} }
/// Cap the LIVE chat-input text (typing, clipboard/primary-selection paste,
/// context-menu paste) at the chat ceilings. Unlike [`sanitize_chat`] this
/// preserves the user's whitespace exactly — normalization stays a submit-time
/// operation so the visible text never jumps while editing — and only truncates,
/// always on a scalar boundary. Returns the input unchanged when within bounds.
pub fn cap_chat_input(input: String) -> String {
if input.len() <= CHAT_MSG_MAX_BYTES && input.chars().count() <= CHAT_MSG_MAX_CHARS {
return input;
}
let mut out = String::new();
for (chars, c) in input.chars().enumerate() {
if chars >= CHAT_MSG_MAX_CHARS || out.len() + c.len_utf8() > CHAT_MSG_MAX_BYTES {
break;
}
out.push(c);
}
out
}
/// Gossip-ingress admission for an untrusted incoming chat body. `None` drops
/// the message: raw text over the byte ceiling is rejected BEFORE any
/// sanitization work (a compliant sender sanitizes before signing, so oversized
/// text is a protocol violation, not something to repair), and a message with
/// neither visible text nor an attachment carries nothing to show. Otherwise
/// yields the sanitized (possibly empty, attachment-only) body to forward.
pub fn admit_chat_text(raw: &str, has_attachment: bool) -> Option<String> {
if raw.len() > CHAT_MSG_MAX_BYTES {
return None;
}
let text = sanitize_chat(raw);
(!text.is_empty() || has_attachment).then_some(text)
}
/// Max clickable links rendered per chat message. Later URL candidates stay
/// selectable plain text — bounds both the span count a message can force the
/// renderer to build and the opener targets one line can carry.
pub const CHAT_MSG_MAX_LINKS: usize = 8;
/// Trailing characters commonly adjacent to a URL in prose that should NOT be /// Trailing characters commonly adjacent to a URL in prose that should NOT be
/// part of the link (so "see http://x.com." or "(http://x.com)" linkify cleanly). /// part of the link (so "see http://x.com." or "(http://x.com)" linkify cleanly).
fn is_url_trailing_punct(c: char) -> bool { fn is_url_trailing_punct(c: char) -> bool {
matches!(c, '.' | ',' | '!' | '?' | ';' | ':' | ')' | ']' | '}' | '>' | '"' | '\'') matches!(
c,
'.' | ',' | '!' | '?' | ';' | ':' | ')' | ']' | '}' | '>' | '"' | '\''
)
} }
/// Find the byte index of the earliest `http://` or `https://` scheme in `s`, /// Find the byte index of the earliest `http://` or `https://` scheme in `s`
/// (ASCII-case-insensitive, so a sentence-capitalized "Http://…" still counts),
/// scanning only on char boundaries so slicing is always safe. /// scanning only on char boundaries so slicing is always safe.
fn find_scheme(s: &str) -> Option<usize> { fn find_scheme(s: &str) -> Option<usize> {
s.char_indices().find_map(|(i, _)| { s.char_indices().find_map(|(i, _)| {
let tail = &s[i..]; let tail = &s[i..];
(tail.starts_with("http://") || tail.starts_with("https://")).then_some(i) let matches_prefix = |p: &str| {
tail.get(..p.len())
.is_some_and(|t| t.eq_ignore_ascii_case(p))
};
(matches_prefix("http://") || matches_prefix("https://")).then_some(i)
}) })
} }
/// Split an (already chat-sanitized) message into plain-text and URL [`Segment`]s /// The clickable-link policy, shared by link detection ([`link_ranges`]) and the
/// for rendering. **Conservative on purpose:** only `http://` / `https://` runs /// opener's defence-in-depth re-check (`AppMessage::OpenUrl`): the candidate must
/// are treated as links, each ending at the first whitespace, with trailing prose /// parse as a URL with an `http`/`https` scheme, a non-empty host, and NO
/// punctuation peeled back into the following text. Concatenating every segment's /// username/password syntax (`http://user@host` reads as a credential but is a
/// inner string reproduces the input exactly (no characters added or dropped), so /// classic destination-spoof — such text stays plain, never clickable).
/// it's purely a presentational split. Linkify AFTER sanitizing so control/format pub fn is_safe_web_url(s: &str) -> bool {
/// chars are already gone (the URL can't smuggle them). Pure → unit-testable. let Ok(u) = url::Url::parse(s) else {
pub fn linkify(input: &str) -> Vec<Segment> { return false;
};
matches!(u.scheme(), "http" | "https")
&& u.host_str().is_some_and(|h| !h.is_empty())
&& u.username().is_empty()
&& u.password().is_none()
}
/// Detect clickable links in an (already chat-sanitized) message, returning the
/// byte range of each — computed ONCE when a message enters history and cached
/// on its entry, so redraws slice instead of rescanning. **Conservative on
/// purpose:** only `http://` / `https://` runs count, each ending at the first
/// whitespace with trailing prose punctuation peeled off, and only candidates
/// passing [`is_safe_web_url`] become links — a failing candidate's whole
/// whitespace-delimited run stays plain text (its interior is not re-scanned).
/// At most [`CHAT_MSG_MAX_LINKS`] ranges; ranges are ascending, non-overlapping,
/// and always on char boundaries. The href is exactly the displayed slice, so
/// what the user sees IS what the opener receives.
pub fn link_ranges(text: &str) -> Vec<std::ops::Range<usize>> {
let mut out = Vec::new(); let mut out = Vec::new();
let mut rest = input; let mut base = 0usize;
while !rest.is_empty() { while out.len() < CHAT_MSG_MAX_LINKS {
let Some(start) = find_scheme(rest) else { let Some(start) = find_scheme(&text[base..]) else {
out.push(Segment::Text(rest.to_string()));
break; break;
}; };
if start > 0 { let run_start = base + start;
out.push(Segment::Text(rest[..start].to_string())); let run = &text[run_start..];
let run_end = run.find(char::is_whitespace).unwrap_or(run.len());
// Peel trailing punctuation back out of the candidate; a run is at least
// the 7-byte scheme long, so `base` always advances.
let candidate = run[..run_end].trim_end_matches(is_url_trailing_punct);
if is_safe_web_url(candidate) {
out.push(run_start..run_start + candidate.len());
base = run_start + candidate.len();
} else {
base = run_start + run_end;
} }
let after = &rest[start..]; }
let end = after.find(char::is_whitespace).unwrap_or(after.len()); out
let candidate = &after[..end]; }
// Peel trailing punctuation back out of the link.
let url = candidate.trim_end_matches(is_url_trailing_punct); /// Split `text` into `(slice, is_link)` pieces from cached [`link_ranges`]
out.push(Segment::Link(url.to_string())); /// output. Concatenating the slices reproduces `text` exactly (purely a
// Continue past just the URL; any peeled punctuation + the rest (incl. the /// presentational split — no characters added or dropped). Borrows, so a redraw
// whitespace) is reconsidered as ordinary text on the next iteration. /// allocates nothing for plain text. `ranges` must come from [`link_ranges`] on
rest = &after[url.len()..]; /// this same `text` (ascending, non-overlapping, char-boundary ranges).
pub fn segments<'a>(text: &'a str, ranges: &[std::ops::Range<usize>]) -> Vec<(&'a str, bool)> {
let mut out = Vec::new();
let mut pos = 0usize;
for r in ranges {
if r.start > pos {
out.push((&text[pos..r.start], false));
}
out.push((&text[r.clone()], true));
pos = r.end;
}
if pos < text.len() {
out.push((&text[pos..], false));
} }
out out
} }
@@ -144,7 +285,10 @@ mod tests {
fn strips_control_chars_and_collapses_whitespace() { fn strips_control_chars_and_collapses_whitespace() {
// NUL, CR/LF, TAB, and ANSI ESC are control chars → become spaces, then // NUL, CR/LF, TAB, and ANSI ESC are control chars → become spaces, then
// collapse; ends trim. // collapse; ends trim.
assert_eq!(sanitize_name(" a\u{0}b\r\nc\td\u{1b}[31m "), "a b c d [31m"); assert_eq!(
sanitize_name(" a\u{0}b\r\nc\td\u{1b}[31m "),
"a b c d [31m"
);
// A name that is only control/whitespace cleans to empty. // A name that is only control/whitespace cleans to empty.
assert_eq!(sanitize_name("\u{0}\r\n\t "), ""); assert_eq!(sanitize_name("\u{0}\r\n\t "), "");
} }
@@ -183,7 +327,10 @@ mod tests {
let mid = "g".repeat(56); let mid = "g".repeat(56);
assert_eq!(sanitize_game_label(&mid).chars().count(), 56); assert_eq!(sanitize_game_label(&mid).chars().count(), 56);
let long = "g".repeat(GAME_LABEL_MAX_CHARS + 100); let long = "g".repeat(GAME_LABEL_MAX_CHARS + 100);
assert_eq!(sanitize_game_label(&long).chars().count(), GAME_LABEL_MAX_CHARS); assert_eq!(
sanitize_game_label(&long).chars().count(),
GAME_LABEL_MAX_CHARS
);
} }
#[test] #[test]
@@ -206,85 +353,199 @@ mod tests {
assert_eq!(sanitize_game_label("\u{0}\r\n\t "), ""); assert_eq!(sanitize_game_label("\u{0}\r\n\t "), "");
} }
// --- linkify ----------------------------------------------------------- // --- chat body policy ---------------------------------------------------
/// Concatenating every segment's inner text must reproduce the input exactly. #[test]
fn reassemble(segs: &[Segment]) -> String { fn chat_keeps_ordinary_text_and_unicode() {
segs.iter() assert_eq!(sanitize_chat("hello world"), "hello world");
.map(|s| match s { assert_eq!(sanitize_chat("héllo 🎙 世界"), "héllo 🎙 世界");
Segment::Text(t) | Segment::Link(t) => t.as_str(), // Bodies keep format characters that label sanitizers strip: a ZWJ emoji
}) // family sequence survives intact.
let family = "👨\u{200D}👩\u{200D}👧";
assert_eq!(sanitize_chat(family), family);
}
#[test]
fn chat_strips_control_chars_and_collapses_whitespace() {
assert_eq!(sanitize_chat(" hi there "), "hi there");
assert_eq!(sanitize_chat("a\u{0}b\r\nc\td\u{1b}[31m"), "a b c d [31m");
assert_eq!(sanitize_chat("\u{0}\r\n\t "), "");
assert_eq!(sanitize_chat(""), "");
}
#[test]
fn chat_caps_chars_at_exact_boundary_without_trailing_space() {
let long = "x".repeat(CHAT_MSG_MAX_CHARS + 500);
assert_eq!(sanitize_chat(&long).chars().count(), CHAT_MSG_MAX_CHARS);
assert_eq!(
sanitize_chat(&"x".repeat(CHAT_MSG_MAX_CHARS))
.chars()
.count(),
CHAT_MSG_MAX_CHARS
);
// Truncation never leaves a dangling separator: with "word " units the
// cut lands mid-run, and the output still ends on visible text.
let words = "word ".repeat(1000);
let out = sanitize_chat(&words);
assert!(out.chars().count() <= CHAT_MSG_MAX_CHARS);
assert!(!out.ends_with(' '));
}
#[test]
fn chat_ceilings_never_split_a_scalar() {
// Four-byte scalars: the char cap bites first (2,000 × 4 = 8,000 bytes,
// inside the byte ceiling by design) and the last emoji is kept whole.
let emoji = "🎮".repeat(CHAT_MSG_MAX_CHARS + 100);
let out = sanitize_chat(&emoji);
assert_eq!(out.chars().count(), CHAT_MSG_MAX_CHARS);
assert!(out.len() <= CHAT_MSG_MAX_BYTES);
assert!(out.chars().all(|c| c == '🎮'));
// Three-byte scalars at the char boundary.
let cjk = "".repeat(CHAT_MSG_MAX_CHARS + 1);
let out = sanitize_chat(&cjk);
assert_eq!(out.chars().count(), CHAT_MSG_MAX_CHARS);
assert!(out.is_char_boundary(out.len()));
}
#[test]
fn chat_sanitize_is_idempotent() {
for input in [
"plain text",
" spaced \t out\r\n text ",
"unicode 🎙 世界 👨\u{200D}👩\u{200D}👧",
&"word ".repeat(1000),
&"🎮".repeat(CHAT_MSG_MAX_CHARS + 100),
] {
let once = sanitize_chat(input);
assert_eq!(sanitize_chat(&once), once, "not idempotent for {input:?}");
}
}
#[test]
fn cap_chat_input_preserves_whitespace_within_bounds() {
// In-bounds input comes back byte-identical — no normalization while
// the user is still editing.
let draft = " hello world \t ".to_string();
assert_eq!(cap_chat_input(draft.clone()), draft);
}
#[test]
fn cap_chat_input_truncates_oversized_paste_on_scalar_boundary() {
let paste = "x".repeat(CHAT_MSG_MAX_CHARS + 5000);
let out = cap_chat_input(paste);
assert_eq!(out.chars().count(), CHAT_MSG_MAX_CHARS);
let emoji_paste = "🎮".repeat(CHAT_MSG_MAX_CHARS + 100);
let out = cap_chat_input(emoji_paste);
assert_eq!(out.chars().count(), CHAT_MSG_MAX_CHARS);
assert!(out.len() <= CHAT_MSG_MAX_BYTES);
assert!(out.chars().all(|c| c == '🎮'));
}
#[test]
fn admit_rejects_oversized_raw_bytes_before_sanitizing() {
// One byte over the ceiling → rejected outright, attachment or not.
let over = "x".repeat(CHAT_MSG_MAX_BYTES + 1);
assert_eq!(admit_chat_text(&over, false), None);
assert_eq!(admit_chat_text(&over, true), None);
// Exactly at the ceiling → admitted (then sanitized/capped).
let at = "x".repeat(CHAT_MSG_MAX_BYTES);
let admitted = admit_chat_text(&at, false).expect("at-ceiling text admitted");
assert_eq!(admitted.chars().count(), CHAT_MSG_MAX_CHARS);
// Multibyte raw over the ceiling → rejected.
let cjk_over = "".repeat(CHAT_MSG_MAX_BYTES / 3 + 1);
assert!(cjk_over.len() > CHAT_MSG_MAX_BYTES);
assert_eq!(admit_chat_text(&cjk_over, false), None);
}
#[test]
fn admit_keeps_attachment_only_messages_and_drops_truly_empty_ones() {
// No visible text + no attachment → nothing to show, dropped.
assert_eq!(admit_chat_text("", false), None);
assert_eq!(admit_chat_text("\u{0}\r\n\t ", false), None);
// Same bodies WITH an attachment → kept as an empty caption.
assert_eq!(admit_chat_text("", true), Some(String::new()));
assert_eq!(admit_chat_text("\u{0}\r\n\t ", true), Some(String::new()));
// Normal text converges on the same result as direct sanitization.
assert_eq!(
admit_chat_text(" hi there ", false),
Some(sanitize_chat(" hi there "))
);
}
#[test]
fn chat_strips_bidi_overrides_but_keeps_benign_format_chars() {
// Overrides and isolates are removed outright (S14) …
assert_eq!(sanitize_chat("pay \u{202E}gpj.exe now"), "pay gpj.exe now");
assert_eq!(sanitize_chat("a\u{2066}b\u{2069}c"), "abc");
assert_eq!(
sanitize_chat("\u{202A}\u{202B}\u{202C}\u{202D}\u{202E}"),
""
);
// … while the expressive format characters chat promises to keep — ZWJ
// (emoji sequences), ZWNJ (joining scripts), LRM/RLM (bidi *marks*, which
// cannot reorder text) — survive.
for kept in ['\u{200D}', '\u{200C}', '\u{200E}', '\u{200F}'] {
let msg = format!("a{kept}b");
assert_eq!(sanitize_chat(&msg), msg, "stripped benign {kept:?}");
}
}
// --- link policy ---------------------------------------------------------
/// Concatenating every segment's slice must reproduce the input exactly.
fn reassemble(text: &str) -> String {
segments(text, &link_ranges(text))
.iter()
.map(|(s, _)| *s)
.collect()
}
/// The link slices of a message, in order.
fn links(text: &str) -> Vec<&str> {
segments(text, &link_ranges(text))
.into_iter()
.filter_map(|(s, is_link)| is_link.then_some(s))
.collect() .collect()
} }
#[test] #[test]
fn linkify_plain_text_has_no_links() { fn url_policy_accepts_only_wellformed_web_urls() {
let segs = linkify("just a normal message, nothing here"); for ok in [
assert_eq!(segs, vec![Segment::Text("just a normal message, nothing here".into())]); "http://example.com",
"https://a.test/path?q=1&w=2",
"HTTP://EXAMPLE.COM", // mixed case scheme+host
"https://x.com:8443/p", // explicit port
"https://d.com/路径?q=世界#frag", // unicode path/query/fragment
// WHATWG parsing (what browsers do) collapses the extra slash into
// host "path" — a valid, if odd, destination; not an empty host.
"http:///path",
] {
assert!(is_safe_web_url(ok), "rejected {ok:?}");
}
for bad in [
"",
"example.com", // no scheme
"http://", // empty host
"ftp://x.com", // non-web scheme
"file:///etc/passwd", // no host, wrong scheme
"javascript:alert(1)", // opener must never see this
"http://user@good.com", // userinfo → destination spoof risk
"http://user:pw@good.com", // credentials
"http://exa mple.com", // malformed host
] {
assert!(!is_safe_web_url(bad), "accepted {bad:?}");
}
} }
#[test] #[test]
fn linkify_detects_http_and_https() { fn link_ranges_detects_http_and_https_with_exact_roundtrip() {
assert_eq!(links("see http://example.com now"), ["http://example.com"]);
assert_eq!( assert_eq!(
linkify("see http://example.com now"), links("a http://one.com b https://two.com c"),
vec![ ["http://one.com", "https://two.com"]
Segment::Text("see ".into()),
Segment::Link("http://example.com".into()),
Segment::Text(" now".into()),
]
); );
assert_eq!( // Sentence-capitalized scheme still detected; href = the displayed slice.
linkify("https://a.test/path?q=1"), assert_eq!(links("go to Http://example.com"), ["Http://example.com"]);
vec![Segment::Link("https://a.test/path?q=1".into())]
);
}
#[test]
fn linkify_peels_trailing_punctuation() {
// Sentence-final period is not part of the link.
assert_eq!(
linkify("go to https://x.com."),
vec![
Segment::Text("go to ".into()),
Segment::Link("https://x.com".into()),
Segment::Text(".".into()),
]
);
// Parenthesized URL.
assert_eq!(
linkify("(https://x.com)"),
vec![
Segment::Text("(".into()),
Segment::Link("https://x.com".into()),
Segment::Text(")".into()),
]
);
}
#[test]
fn linkify_handles_multiple_urls() {
let segs = linkify("a http://one.com b https://two.com c");
assert_eq!(
segs,
vec![
Segment::Text("a ".into()),
Segment::Link("http://one.com".into()),
Segment::Text(" b ".into()),
Segment::Link("https://two.com".into()),
Segment::Text(" c".into()),
]
);
}
#[test]
fn linkify_only_matches_http_schemes() {
// Non-web schemes and bare domains are NOT linkified (conservative).
let segs = linkify("email me@x.com or ftp://x.com or visit x.com");
assert_eq!(segs, vec![Segment::Text("email me@x.com or ftp://x.com or visit x.com".into())]);
}
#[test]
fn linkify_preserves_input_exactly() {
for msg in [ for msg in [
"", "",
"no urls at all", "no urls at all",
@@ -292,8 +553,67 @@ mod tests {
"pre http://a.com/x?y=z&w=1 mid https://b.org/p, end!", "pre http://a.com/x?y=z&w=1 mid https://b.org/p, end!",
"weird))) http://c.com]]] tail", "weird))) http://c.com]]] tail",
"unicode 世界 http://d.com/路径 more 世界", "unicode 世界 http://d.com/路径 more 世界",
"bad http:// and http://user@x.com around https://ok.org here",
] { ] {
assert_eq!(reassemble(&linkify(msg)), msg, "roundtrip failed for {msg:?}"); assert_eq!(reassemble(msg), msg, "roundtrip failed for {msg:?}");
} }
} }
#[test]
fn link_ranges_peels_trailing_punctuation() {
assert_eq!(links("go to https://x.com."), ["https://x.com"]);
assert_eq!(links("(https://x.com)"), ["https://x.com"]);
}
#[test]
fn link_ranges_leaves_invalid_candidates_as_plain_text() {
// Non-web schemes and bare domains never linkify (conservative).
assert_eq!(
links("email me@x.com or ftp://x.com or visit x.com"),
[] as [&str; 0]
);
// A malformed/deceptive candidate stays text WITHOUT eating a later
// valid link.
assert_eq!(links("http:// then https://ok.org"), ["https://ok.org"]);
assert_eq!(
links("http://user:pw@evil.com vs https://good.com"),
["https://good.com"]
);
// An invalid run's interior is not re-scanned for nested schemes.
assert_eq!(links("http://a@http://b.com"), [] as [&str; 0]);
}
#[test]
fn link_ranges_caps_clickable_links_per_message() {
let many = (0..CHAT_MSG_MAX_LINKS + 4)
.map(|i| format!("https://site{i}.test"))
.collect::<Vec<_>>()
.join(" ");
let ranges = link_ranges(&many);
assert_eq!(ranges.len(), CHAT_MSG_MAX_LINKS);
// The 9th+ URLs remain, but as plain selectable text.
assert_eq!(reassemble(&many), many);
let l = links(&many);
assert_eq!(l.last(), Some(&"https://site7.test"));
// Exactly at the cap: all clickable.
let at_cap = (0..CHAT_MSG_MAX_LINKS)
.map(|i| format!("https://site{i}.test"))
.collect::<Vec<_>>()
.join(" ");
assert_eq!(link_ranges(&at_cap).len(), CHAT_MSG_MAX_LINKS);
}
#[test]
fn link_ranges_survives_adversarial_many_link_input() {
// A ceiling-length message packed with minimal URLs: bounded output,
// exact reconstruction, and every range on char boundaries.
let flood = "http://a.io ".repeat(CHAT_MSG_MAX_BYTES / 12 + 1);
let msg = sanitize_chat(&flood);
let ranges = link_ranges(&msg);
assert_eq!(ranges.len(), CHAT_MSG_MAX_LINKS);
for r in &ranges {
assert!(msg.is_char_boundary(r.start) && msg.is_char_boundary(r.end));
}
assert_eq!(reassemble(&msg), msg);
}
} }
+314
View File
@@ -0,0 +1,314 @@
//! Live-edge catch-up for the screen-share viewer.
//!
//! PixelPass carries the share as MPEG-TS over a reliable, ordered transport. On
//! a lossy link (satellite handovers are the pathological case) every loss burst
//! becomes retransmission plus head-of-line blocking, and the viewer absorbs the
//! stall as buffered latency. Nothing in the chain ever trims that buffer back,
//! so the picture ends up seconds behind the host and stays there.
//!
//! Measured on a `tc netem` rig that simulates a satellite link (40 ms +/- 20 ms
//! jitter, 0.5% loss, a 250 ms/30%-loss handover burst every 15 s): a viewer with
//! ordinary timestamp pacing settles ~1.24 s behind. mpv's `--untimed` does NOT
//! help (~1.38 s, marginally worse) because it only removes pacing at
//! *presentation* while audio still drains at 1x the DAC rate, so an accumulated
//! buffer never shrinks. Returning to the live edge requires consuming the
//! backlog faster than it arrives.
//!
//! So we nudge playback slightly faster than realtime while the buffer is deep,
//! and drop back to 1x once it has drained. mpv's default pitch correction
//! (`scaletempo2`) keeps a 5% speedup inaudible, and because audio and video are
//! sped up together A/V sync is preserved — unlike `--untimed`.
//!
//! The control law and the JSON-IPC message handling are pure functions with
//! tests; the only I/O is [`drive`], which talks to mpv's `--input-ipc-server`
//! socket.
use std::path::{Path, PathBuf};
use std::time::Duration;
/// Buffer depth (seconds) above which we start draining.
pub const CACHE_HIGH_S: f64 = 1.0;
/// Buffer depth (seconds) below which we return to realtime.
pub const CACHE_LOW_S: f64 = 0.4;
/// The buffer depth we aim to sit at; the drain rate is proportional to how far
/// above this the buffer actually is.
pub const CACHE_TARGET_S: f64 = 0.5;
/// Extra playback rate per second of excess buffer.
pub const CATCHUP_GAIN: f64 = 0.05;
/// Hard ceiling on the drain rate. Beyond this the speedup stops being
/// unnoticeable, and a share that far behind is better served by the operator
/// restarting it than by a chipmunk impression.
pub const MAX_CATCHUP_SPEED: f64 = 1.15;
/// Normal realtime playback.
pub const NORMAL_SPEED: f64 = 1.0;
/// How often we sample the buffer depth.
pub const POLL_INTERVAL: Duration = Duration::from_millis(500);
/// Smallest rate change worth sending to the player.
pub const SPEED_EPSILON: f64 = 0.005;
/// The property we watch on the viewer.
const CACHE_PROPERTY: &str = "demuxer-cache-duration";
/// Decide the playback rate for the next interval.
///
/// Proportional, because a fixed small speedup cannot recover a large backlog in
/// any reasonable time: draining 6 s at 1.05x takes two minutes, which a viewer
/// experiences as "still broken". The drain rate instead scales with how deep
/// the buffer is, so a bad handover is cleared in tens of seconds while a small
/// excursion still gets only a gentle, inaudible nudge.
///
/// Deliberately hysteretic: between [`CACHE_LOW_S`] and [`CACHE_HIGH_S`] the
/// current rate is held, so a buffer hovering near a single threshold cannot
/// oscillate the speed (and with it the audio pitch) every poll. Pure.
///
/// A non-finite reading (mpv reports `null` before playback starts, and the
/// caller maps that to NaN) holds the current rate rather than guessing.
pub fn catchup_speed(cache_s: f64, current: f64) -> f64 {
if !cache_s.is_finite() {
return current;
}
if cache_s < CACHE_LOW_S {
return NORMAL_SPEED;
}
if cache_s <= CACHE_HIGH_S {
return current;
}
let excess = cache_s - CACHE_TARGET_S;
(NORMAL_SPEED + CATCHUP_GAIN * excess).clamp(NORMAL_SPEED, MAX_CATCHUP_SPEED)
}
/// Where mpv should create its IPC socket. Kept separate from the runtime
/// lookup so tests can pin a directory. Pure.
pub fn socket_path(dir: &Path, token: u64) -> PathBuf {
dir.join(format!("peerspeak-mpv-{token}.sock"))
}
/// The directory for the IPC socket: the XDG runtime dir when the session
/// provides one (tmpfs, user-private, cleaned at logout), else the temp dir.
pub fn socket_dir() -> PathBuf {
std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
}
/// A `get_property` request for the buffer depth. Pure.
pub fn get_cache_request(request_id: u64) -> String {
format!(r#"{{"command":["get_property","{CACHE_PROPERTY}"],"request_id":{request_id}}}"#)
}
/// A `set_property` request for the playback rate. Pure.
pub fn set_speed_request(request_id: u64, speed: f64) -> String {
format!(r#"{{"command":["set_property","speed",{speed}],"request_id":{request_id}}}"#)
}
/// Extract the buffer depth from one line of mpv's IPC output.
///
/// mpv interleaves unsolicited event lines with command replies, so a line is
/// only ours when it carries the matching `request_id`. Returns:
/// - `Some(Some(secs))` — our reply, with a usable number,
/// - `Some(None)` — our reply, but no number (mpv sends `"data":null` before
/// playback starts, and reports `error` while the demuxer has no cache yet),
/// - `None` — not our reply (an event, or another command's response).
///
/// Pure.
pub fn parse_cache_response(line: &str, request_id: u64) -> Option<Option<f64>> {
let value: serde_json::Value = serde_json::from_str(line.trim()).ok()?;
let id = value.get("request_id")?.as_u64()?;
if id != request_id {
return None;
}
if value.get("error").and_then(|e| e.as_str()) != Some("success") {
return Some(None);
}
Some(value.get("data").and_then(|d| d.as_f64()))
}
/// Drive one mpv viewer's playback rate over its JSON IPC socket.
///
/// Runs until mpv exits (the socket dies), so it is spawned detached alongside
/// the player and needs no shutdown signal. Every failure path just ends the
/// task: catch-up is an optimization, and a viewer that never gets it still
/// plays, exactly as before this existed.
#[cfg(unix)]
pub async fn drive(socket: PathBuf) {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
// mpv creates the socket a moment after exec, so the first connects race it.
let mut stream = None;
for _ in 0..40 {
match UnixStream::connect(&socket).await {
Ok(s) => {
stream = Some(s);
break;
}
Err(_) => tokio::time::sleep(Duration::from_millis(250)).await,
}
}
let Some(stream) = stream else {
crate::log_msg("livesync: mpv IPC socket never appeared; catch-up disabled");
return;
};
let (read_half, mut write_half) = stream.into_split();
let mut lines = BufReader::new(read_half).lines();
let mut request_id: u64 = 0;
let mut speed = NORMAL_SPEED;
loop {
tokio::time::sleep(POLL_INTERVAL).await;
request_id += 1;
let query = format!("{}\n", get_cache_request(request_id));
if write_half.write_all(query.as_bytes()).await.is_err() {
break;
}
// Skip event lines until our reply arrives.
let cache = loop {
match lines.next_line().await {
Ok(Some(line)) => {
if let Some(value) = parse_cache_response(&line, request_id) {
break value;
}
}
// Socket closed or unreadable: mpv is gone.
_ => return,
}
};
let cache = cache.unwrap_or(f64::NAN);
let next = catchup_speed(cache, speed);
// A proportional law would otherwise re-send on every wobble of the
// reading; only a change worth hearing is worth a round trip.
if (next - speed).abs() > SPEED_EPSILON {
speed = next;
request_id += 1;
let set = format!("{}\n", set_speed_request(request_id, speed));
if write_half.write_all(set.as_bytes()).await.is_err() {
break;
}
crate::log_msg(&format!(
"livesync: cache {cache:.2}s -> playback speed {speed}x"
));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deep_buffer_speeds_up_and_drained_buffer_returns_to_realtime() {
assert!(catchup_speed(1.5, NORMAL_SPEED) > NORMAL_SPEED);
assert_eq!(catchup_speed(0.1, MAX_CATCHUP_SPEED), NORMAL_SPEED);
}
#[test]
fn drain_rate_scales_with_how_far_behind_we_are() {
// The point of the proportional law: a small excursion gets a gentle
// nudge, a deep backlog gets real recovery.
let small = catchup_speed(1.5, NORMAL_SPEED);
let large = catchup_speed(4.0, NORMAL_SPEED);
assert!(
large > small,
"deeper buffer must drain faster: {small} vs {large}"
);
assert!(
(small - 1.05).abs() < 1e-9,
"1.5s buffer -> 1.05x, got {small}"
);
}
#[test]
fn drain_rate_is_capped_so_it_never_sounds_absurd() {
// The ~6 s standing buffer measured on the netem rig, and far worse.
assert_eq!(catchup_speed(6.0, NORMAL_SPEED), MAX_CATCHUP_SPEED);
assert_eq!(catchup_speed(600.0, NORMAL_SPEED), MAX_CATCHUP_SPEED);
}
#[test]
fn hysteresis_band_holds_the_current_speed() {
// Between the marks nothing changes, whichever side we came from —
// this is what stops the rate (and audio pitch) oscillating.
for cache in [CACHE_LOW_S, 0.7, CACHE_HIGH_S] {
assert_eq!(catchup_speed(cache, NORMAL_SPEED), NORMAL_SPEED);
assert_eq!(catchup_speed(cache, MAX_CATCHUP_SPEED), MAX_CATCHUP_SPEED);
}
}
#[test]
fn unknown_cache_holds_the_current_speed() {
assert_eq!(
catchup_speed(f64::NAN, MAX_CATCHUP_SPEED),
MAX_CATCHUP_SPEED
);
assert_eq!(catchup_speed(f64::INFINITY, NORMAL_SPEED), NORMAL_SPEED);
}
#[test]
fn a_full_handover_cycle_drains_then_settles() {
// Buffer grows through a loss burst, then drains as we play faster.
let mut speed = NORMAL_SPEED;
for cache in [0.2, 0.5, 1.2, 3.4, 1.4, 0.9, 0.6, 0.3, 0.2] {
speed = catchup_speed(cache, speed);
}
assert_eq!(
speed, NORMAL_SPEED,
"should be back at realtime once drained"
);
}
#[test]
fn requests_are_valid_json_with_their_ids() {
let get: serde_json::Value = serde_json::from_str(&get_cache_request(7)).unwrap();
assert_eq!(get["request_id"], 7);
assert_eq!(get["command"][0], "get_property");
assert_eq!(get["command"][1], CACHE_PROPERTY);
let set: serde_json::Value = serde_json::from_str(&set_speed_request(8, 1.05)).unwrap();
assert_eq!(set["request_id"], 8);
assert_eq!(set["command"][0], "set_property");
assert_eq!(set["command"][1], "speed");
assert_eq!(set["command"][2], 1.05);
}
#[test]
fn parses_our_reply_only() {
assert_eq!(
parse_cache_response(r#"{"error":"success","data":1.25,"request_id":3}"#, 3),
Some(Some(1.25))
);
// Another command's reply, and an unsolicited event, are not ours.
assert_eq!(
parse_cache_response(r#"{"error":"success","data":1.25,"request_id":4}"#, 3),
None
);
assert_eq!(
parse_cache_response(r#"{"event":"playback-restart"}"#, 3),
None
);
assert_eq!(parse_cache_response("not json", 3), None);
}
#[test]
fn reply_without_a_usable_number_is_ours_but_empty() {
// mpv before playback starts, and while the demuxer has no cache.
assert_eq!(
parse_cache_response(r#"{"error":"success","data":null,"request_id":1}"#, 1),
Some(None)
);
assert_eq!(
parse_cache_response(r#"{"error":"property unavailable","request_id":1}"#, 1),
Some(None)
);
}
#[test]
fn socket_path_is_scoped_to_its_token() {
let a = socket_path(Path::new("/run/user/1000"), 42);
assert_eq!(a, Path::new("/run/user/1000/peerspeak-mpv-42.sock"));
assert_ne!(a, socket_path(Path::new("/run/user/1000"), 43));
}
}
+594 -58
View File
@@ -21,6 +21,12 @@ use std::time::Duration;
use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command}; use tokio::process::{Child, Command};
use crate::audio::ownership;
pub mod livesync;
use crate::config::{ScreenShareSettings, ShareBuffering, SharePlayer, ShareQuality};
/// The binary we shell out to. Looked up on `$PATH` unless a config override /// The binary we shell out to. Looked up on `$PATH` unless a config override
/// points elsewhere. /// points elsewhere.
const PIXELPASS_BIN: &str = "pixelpass"; const PIXELPASS_BIN: &str = "pixelpass";
@@ -43,6 +49,12 @@ const MAX_TICKET_LEN: usize = 512;
/// are short ("Firefox", "mpv"); this only guards against a pathological value. /// are short ("Firefox", "mpv"); this only guards against a pathological value.
const MAX_APP_NAME_LEN: usize = 256; const MAX_APP_NAME_LEN: usize = 256;
/// Ceiling on the viewer's demuxer byte cache in the Low latency posture. The
/// cache is a *byte* budget, so at a given bitrate it sets the worst-case
/// backlog in seconds; keeping it tight is what stops a lossy link parking the
/// viewer seconds behind before [`livesync`] even gets a chance to drain it.
const LOW_LATENCY_CACHE_CAP_MB: u32 = 1;
/// How long to wait for the host to emit its ticket / the viewer to connect /// How long to wait for the host to emit its ticket / the viewer to connect
/// before giving up and killing the child. Startup is normally sub-second; this /// before giving up and killing the child. Startup is normally sub-second; this
/// is only a safety net so a hung pixelpass can't wedge the caller forever. /// is only a safety net so a hung pixelpass can't wedge the caller forever.
@@ -78,6 +90,21 @@ pub enum PixelpassEvent {
Other, Other,
} }
/// What the host's stdout drain forwards to the core over the notice channel.
///
/// `Eof` is **synthesized here**, not parsed: pixelpass has no "I died" event,
/// and a crash can abort across `extern "C"` before any JSON line is written,
/// so the stream ending is the only reliable death signal. A read *error*
/// counts too — either way the event stream is gone and the host must be
/// treated as over.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HostNotice {
/// A parsed pixelpass event line.
Event(PixelpassEvent),
/// The host's stdout ended (EOF or read error). Terminal: nothing follows.
Eof,
}
/// Parse a single stdout line from pixelpass `--output json`. Pure: no I/O. /// Parse a single stdout line from pixelpass `--output json`. Pure: no I/O.
pub fn parse_pixelpass_event(line: &str) -> Option<PixelpassEvent> { pub fn parse_pixelpass_event(line: &str) -> Option<PixelpassEvent> {
let line = line.trim(); let line = line.trim();
@@ -139,7 +166,11 @@ fn json_u32(v: &serde_json::Value, key: &str) -> u32 {
/// otherwise rejects hyphen-leading option values). The name is locally chosen /// otherwise rejects hyphen-leading option values). The name is locally chosen
/// (our own enumeration / the user's pick), not peer-supplied, but is still /// (our own enumeration / the user's pick), not peer-supplied, but is still
/// sanitized via [`sanitize_app_name`] before reaching here. Pure: no I/O. /// sanitized via [`sanitize_app_name`] before reaching here. Pure: no I/O.
pub fn host_args(audio_app: Option<&str>) -> Vec<String> { pub fn host_args(
audio_app: Option<&str>,
settings: &ScreenShareSettings,
quality: ShareQuality,
) -> Vec<String> {
let mut args = vec![ let mut args = vec![
"--host".to_string(), "--host".to_string(),
"--output".to_string(), "--output".to_string(),
@@ -149,18 +180,52 @@ pub fn host_args(audio_app: Option<&str>) -> Vec<String> {
args.push(format!("--app={name}")); args.push(format!("--app={name}"));
args.push("--strict-audio".to_string()); args.push("--strict-audio".to_string());
} }
if quality != ShareQuality::Auto {
args.push(format!("--quality={}", pixelpass_quality(quality)));
}
if let Some(height) = settings.max_height {
args.push(format!("--max-height={height}"));
}
if let Some(mbps) = settings.bitrate_mbps {
args.push(format!("--bitrate={}", mbps.saturating_mul(1000)));
}
if let Some(fps) = settings.framerate {
args.push(format!("--framerate={fps}"));
}
if settings.force_software_encode {
args.push("--no-hwencode".to_string());
}
if let Some(max) = settings.max_viewers {
args.push(format!("--max-viewers={max}"));
}
args.extend(split_extra_args(&settings.extra_host_args));
args args
} }
fn pixelpass_quality(quality: ShareQuality) -> &'static str {
match quality {
ShareQuality::Auto => "auto",
ShareQuality::Low => "low",
ShareQuality::Medium => "medium",
ShareQuality::High => "high",
ShareQuality::Source => "source",
}
}
/// Split user-supplied advanced argv text into separate tokens. Peerspeak does
/// not depend on a shell lexer, so quoted values are not interpreted here.
fn split_extra_args(raw: &str) -> impl Iterator<Item = String> + '_ {
raw.split_whitespace().map(str::to_string)
}
/// Validate a locally-chosen audio app name before it becomes a `--app` value: /// Validate a locally-chosen audio app name before it becomes a `--app` value:
/// trim, reject empty / overlong, and reject names carrying control characters /// trim, reject empty / overlong, and reject names carrying control characters
/// (newlines etc.) that have no place in a real `application.name`. `None` means /// (newlines etc.) that have no place in a real `application.name`. `None` means
/// "no valid app selected" — the caller then shares the whole desktop audio. /// "no valid app selected" — the caller then shares the whole desktop audio.
pub fn sanitize_app_name(name: &str) -> Option<String> { pub fn sanitize_app_name(name: &str) -> Option<String> {
let name = name.trim(); let name = name.trim();
let ok = !name.is_empty() let ok =
&& name.len() <= MAX_APP_NAME_LEN !name.is_empty() && name.len() <= MAX_APP_NAME_LEN && !name.chars().any(|c| c.is_control());
&& !name.chars().any(|c| c.is_control());
ok.then(|| name.to_string()) ok.then(|| name.to_string())
} }
@@ -320,16 +385,30 @@ pub fn is_available(config_override: Option<&str>) -> bool {
/// `audio_app` is `Some`, pixelpass captures only that app's audio instead of the /// `audio_app` is `Some`, pixelpass captures only that app's audio instead of the
/// whole desktop sink, which avoids the call-loopback echo (A23). The child keeps /// whole desktop sink, which avoids the call-loopback echo (A23). The child keeps
/// running (streaming to viewers) until killed or dropped; remaining stdout is /// running (streaming to viewers) until killed or dropped; remaining stdout is
/// drained in a background task so a full pipe can't stall the host. We do /// drained in a background task so a full pipe can't stall the host. The drain
/// **not** pass `--max-viewers`: pixelpass bandwidth-measures its own safe cap, /// forwards every parsed event over `notices` and — the part no share may opt
/// protecting the sharer's uplink, and refuses extras with `viewer_refused`. /// out of — a terminal [`HostNotice::Eof`] when the stream ends, which is the
/// caller's only reliable signal that the host died. We do
/// not pass encode/viewer overrides unless the local settings explicitly ask for
/// them, so pixelpass keeps its own defaults in the common case.
pub async fn spawn_host( pub async fn spawn_host(
bin: &Path, bin: &Path,
audio_app: Option<&str>, audio_app: Option<&str>,
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>, settings: &ScreenShareSettings,
quality: ShareQuality,
notices: tokio::sync::mpsc::UnboundedSender<HostNotice>,
) -> std::io::Result<(Child, String)> { ) -> std::io::Result<(Child, String)> {
let args = host_args(audio_app, settings, quality);
// Log the exact argv we hand pixelpass so a field log can confirm which
// encode/quality flags (e.g. --bitrate) actually reached the host — these
// are local flags with no ticket/secret, so logging them verbatim is safe.
crate::log_msg(&format!(
"pixelpass host spawn: {} {}",
bin.display(),
args.join(" ")
));
let mut child = Command::new(bin) let mut child = Command::new(bin)
.args(host_args(audio_app)) .args(&args)
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
// Capture stderr (not null): pixelpass prints its startup precondition // Capture stderr (not null): pixelpass prints its startup precondition
@@ -372,7 +451,7 @@ pub async fn spawn_host(
if let Some(stderr) = stderr { if let Some(stderr) = stderr {
drain_stderr_in_background(stderr); drain_stderr_in_background(stderr);
} }
drain_in_background(lines, "host", notices); drain_in_background(lines, "host", Some(notices));
Ok((child, ticket)) Ok((child, ticket))
} }
@@ -429,10 +508,14 @@ pub fn pixelpass_failure_detail(stderr: &str) -> String {
} }
/// Spawn a pixelpass viewer for `ticket`, wait for it to connect, and open the /// Spawn a pixelpass viewer for `ticket`, wait for it to connect, and open the
/// stream in a local player (mpv, falling back to vlc). Returns the live viewer /// stream in a local player (mpv/VLC in the configured order, then fallback).
/// child so the caller can kill it on room-leave; it also self-exits when the /// Returns the live viewer child so the caller can kill it on room-leave; it also
/// player window closes (its tunnel ends). /// self-exits when the player window closes (its tunnel ends).
pub async fn spawn_viewer(bin: &Path, ticket: &str) -> std::io::Result<Child> { pub async fn spawn_viewer(
bin: &Path,
ticket: &str,
settings: &ScreenShareSettings,
) -> std::io::Result<Child> {
let mut child = Command::new(bin) let mut child = Command::new(bin)
.args(viewer_args(ticket)) .args(viewer_args(ticket))
.stdin(Stdio::null()) .stdin(Stdio::null())
@@ -466,7 +549,7 @@ pub async fn spawn_viewer(bin: &Path, ticket: &str) -> std::io::Result<Child> {
} }
}; };
if let Err(e) = launch_player(&url) { if let Err(e) = launch_player(&url, settings) {
let _ = child.kill().await; let _ = child.kill().await;
return Err(e); return Err(e);
} }
@@ -507,13 +590,15 @@ where
/// Keep reading the child's stdout to EOF in the background so a full pipe can't /// Keep reading the child's stdout to EOF in the background so a full pipe can't
/// stall it; log notable events for diagnostics. When `notices` is `Some`, each /// stall it; log notable events for diagnostics. When `notices` is `Some`, each
/// parsed event is also forwarded to the caller (the core, which translates the /// parsed event is also forwarded to the caller (the core), and when the stream
/// `app_audio` ones into a UI warning); a send failure (receiver dropped) just /// ends — EOF or read error, i.e. the child exited or its event stream broke —
/// stops forwarding, draining continues. The task ends on EOF (child exited). /// a final [`HostNotice::Eof`] is sent so the caller learns the child is gone
/// (a host that dies must not stay advertised as sharing). A send failure
/// (receiver dropped) just stops forwarding, draining continues.
fn drain_in_background<R>( fn drain_in_background<R>(
mut lines: tokio::io::Lines<BufReader<R>>, mut lines: tokio::io::Lines<BufReader<R>>,
role: &'static str, role: &'static str,
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>, notices: Option<tokio::sync::mpsc::UnboundedSender<HostNotice>>,
) where ) where
R: tokio::io::AsyncRead + Unpin + Send + 'static, R: tokio::io::AsyncRead + Unpin + Send + 'static,
{ {
@@ -522,10 +607,14 @@ fn drain_in_background<R>(
if let Some(ev) = parse_pixelpass_event(&line) { if let Some(ev) = parse_pixelpass_event(&line) {
crate::log_msg(&format!("pixelpass {role}: {}", event_for_log(&ev))); crate::log_msg(&format!("pixelpass {role}: {}", event_for_log(&ev)));
if let Some(tx) = &notices { if let Some(tx) = &notices {
let _ = tx.send(ev); let _ = tx.send(HostNotice::Event(ev));
} }
} }
} }
if let Some(tx) = &notices {
crate::log_msg(&format!("pixelpass {role}: stdout ended"));
let _ = tx.send(HostNotice::Eof);
}
}); });
} }
@@ -548,29 +637,68 @@ fn event_for_log(ev: &PixelpassEvent) -> String {
} }
} }
/// Open the viewer stream URL in a media player. Mirrors pixelpass's own /// Open the viewer stream URL in a media player, then fall back to vlc. The
/// low-latency mpv invocation; falls back to vlc. The player is reaped in a /// player is reaped in a background task so it doesn't linger as a zombie when
/// background task so it doesn't linger as a zombie when its window closes. /// its window closes.
fn launch_player(url: &str) -> std::io::Result<()> { ///
const MPV_ARGS: &[&str] = &[ /// The buffering posture chooses the latency/A/V-sync tradeoff. Low latency
"--profile=low-latency", /// keeps the viewer at the live edge: mpv gets an IPC socket and [`livesync`]
"--untimed", /// drains a lagging buffer by playing slightly fast (pitch-corrected, so A/V
"--hwdec=auto", /// sync is preserved). Smooth leaves a deeper buffer alone, trading live
"--audio-buffer=0.2", /// latency for immunity to jitter. Hardware decoding remains opt-in: forcing
"--demuxer-max-bytes=2M", /// `--hwdec=auto` froze some viewers on frame 1 while audio kept playing.
"--demuxer-readahead-secs=0.5", fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<()> {
]; // One socket per viewer launch, so overlapping shares can't collide on it.
const VLC_ARGS: &[&str] = &["--network-caching=200", "--live-caching=200"]; // Unix only: mpv's IPC is a named pipe on Windows, which `livesync` does not
// speak, and an unusable socket path on the argv would help nobody.
#[cfg(unix)]
let ipc_socket = Some(livesync::socket_path(
&livesync::socket_dir(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0),
));
#[cfg(not(unix))]
let ipc_socket: Option<PathBuf> = None;
let child = match spawn_player("mpv", MPV_ARGS, url) { let mpv_args = mpv_args(settings, ipc_socket.as_deref());
Ok(c) => c, let vlc_args = vlc_args(settings);
Err(_) => spawn_player("vlc", VLC_ARGS, url).map_err(|_| { let first = match settings.player {
std::io::Error::new( SharePlayer::Mpv => ("mpv", &mpv_args),
std::io::ErrorKind::NotFound, SharePlayer::Vlc => ("vlc", &vlc_args),
"no media player found — install mpv or vlc to watch screen shares",
)
})?,
}; };
let second = match settings.player {
SharePlayer::Mpv => ("vlc", &vlc_args),
SharePlayer::Vlc => ("mpv", &mpv_args),
};
let (launched, child) = match spawn_player(first.0, first.1, url) {
Ok(c) => (first.0, c),
Err(_) => (
second.0,
spawn_player(second.0, second.1, url).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"no media player found — install mpv or vlc to watch screen shares",
)
})?,
),
};
// Only when the socket actually reached the argv: mpv (VLC has no
// equivalent IPC) in the Low latency posture. The driver ends by itself when
// the player exits, so it needs no shutdown path.
#[cfg(unix)]
if launched == "mpv"
&& settings.buffering == ShareBuffering::LowLatency
&& let Some(socket) = ipc_socket
{
tokio::spawn(livesync::drive(socket));
}
#[cfg(not(unix))]
let _ = launched;
tokio::spawn(async move { tokio::spawn(async move {
let mut child = child; let mut child = child;
let _ = child.wait().await; let _ = child.wait().await;
@@ -578,28 +706,164 @@ fn launch_player(url: &str) -> std::io::Result<()> {
Ok(()) Ok(())
} }
fn spawn_player(bin: &str, args: &[&str], url: &str) -> std::io::Result<Child> { /// Build the argv for an mpv viewer.
Command::new(bin) ///
/// `ipc_socket` is where mpv should expose its JSON IPC socket so [`livesync`]
/// can drain a lagging buffer. It is wired up for Low latency only: Smooth
/// deliberately holds a ~2 s readahead, which the catch-up thresholds would
/// fight on every poll.
pub fn mpv_args(settings: &ScreenShareSettings, ipc_socket: Option<&Path>) -> Vec<String> {
let mut args = Vec::new();
match settings.buffering {
ShareBuffering::LowLatency => {
args.push("--profile=low-latency".to_string());
// Pixelpass carries MPEG-TS through reliable ordered QUIC/TCP, so a
// lossy link turns every retransmission into buffered latency that
// nothing trims back. `--untimed` does NOT fix that (measured
// marginally worse: it only unpaces *presentation*, while audio
// still drains at 1x, so the backlog never shrinks) — the viewer
// instead drains it by playing slightly fast, see `livesync`.
args.push("--audio-buffer=0.2".to_string());
args.push("--demuxer-readahead-secs=0.5".to_string());
}
ShareBuffering::Smooth => {
args.push("--cache=yes".to_string());
args.push("--demuxer-readahead-secs=2".to_string());
}
}
// The byte cap is what bounds how far behind a viewer can silently fall:
// a demuxer allowed 2 MiB will happily sit on ~6 s of a 2.5 Mbps share (as
// measured on the netem rig) and call it a buffer. Low latency therefore
// gets a tighter ceiling than the user's Smooth-oriented setting, so the
// catch-up has less to claw back after a bad patch of link.
let cache_mb = match settings.buffering {
ShareBuffering::LowLatency => settings.cache_mb.min(LOW_LATENCY_CACHE_CAP_MB),
ShareBuffering::Smooth => settings.cache_mb,
};
args.push(format!("--demuxer-max-bytes={cache_mb}M"));
if settings.hardware_decode {
args.push("--hwdec=auto".to_string());
}
if let Some(socket) = ipc_socket
&& settings.buffering == ShareBuffering::LowLatency
{
args.push(format!("--input-ipc-server={}", socket.display()));
}
// Extra args stay last so a user override wins over everything above.
args.extend(split_extra_args(&settings.extra_mpv_args));
args
}
/// Build the argv for a VLC viewer. VLC honors the subset of viewer settings
/// that map cleanly onto its option set: the buffering posture (network/live
/// caching, in ms) and hardware decoding. The rest of the viewer knobs are
/// mpv-specific — `cache_mb` is an mpv demuxer *byte* cache (VLC's caching is
/// time-based, already covered by `buffering`) and `extra_mpv_args` is literally
/// mpv flags — so they are deliberately not mapped here; the Settings UI labels
/// them as mpv-only. Pure: no I/O.
///
/// The hardware-decode mapping is the load-bearing one: VLC hardware-decodes by
/// default, so without an explicit `--avcodec-hw=none` a VLC viewer would ignore
/// the (default-off) hardware-decode toggle and could hit the frame-1 freeze
/// that default exists to avoid — the same A-bug that made us drop mpv's forced
/// `--hwdec=auto`.
fn vlc_args(settings: &ScreenShareSettings) -> Vec<String> {
let caching_ms = match settings.buffering {
ShareBuffering::LowLatency => 200,
ShareBuffering::Smooth => 1500,
};
let hw = if settings.hardware_decode {
"--avcodec-hw=any"
} else {
"--avcodec-hw=none"
};
vec![
format!("--network-caching={caching_ms}"),
format!("--live-caching={caching_ms}"),
hw.to_string(),
]
}
fn spawn_player(bin: &str, args: &[String], url: &str) -> std::io::Result<Child> {
// Log the player + its flags (mpv/vlc, incl. hardware-decode: --hwdec /
// --avcodec-hw) so a field log can confirm the viewer settings reached the
// player. The `url` is omitted deliberately — it is the local stream address
// and is not needed to verify the flags. Logged on each attempt, so a
// fallback from the preferred player to the other one is visible too.
crate::log_msg(&format!("player spawn: {bin} {}", args.join(" ")));
let mut command = Command::new(bin);
command
.args(args) .args(args)
.arg(url) .arg(url)
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()) .stderr(Stdio::null())
.kill_on_drop(false) .kill_on_drop(false);
.spawn() // Ownership tag (plan §5.1): this player is playing the *incoming*
// screenshare's audio, so it is exactly what must not be fanned back out
// if this machine also starts sharing. The role is the player binary, so
// a `pw-dump` during a field test names which one produced the node.
ownership::tag_child(command.as_std_mut(), bin);
command.spawn()
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
/// Phase-1 exit gate, player half (impl plan §3): the mpv peerspeak
/// actually spawns produces a live node carrying **both** ownership
/// carriers, tagged with the player's own name as the role.
///
/// ⚠️ Drives the real [`spawn_player`], for the same reason the notify
/// gate does: the plan requires the tag to be shown "landing on a live
/// mpv node, not just in the env". Plays a silent WAV, so it is quiet.
///
/// Live: needs PipeWire, `mpv` and `pw-dump`.
/// `cargo test --lib -- --ignored spawned_player`
#[tokio::test]
#[ignore = "live: requires a running PipeWire daemon, mpv and pw-dump"]
async fn spawned_player_node_carries_both_ownership_carriers() {
use crate::audio::ownership::live_test;
let dir = std::env::temp_dir().join(format!("peerspeak-playertest-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("silence.wav");
std::fs::write(&path, live_test::silent_wav(6)).unwrap();
let mut child = spawn_player(
"mpv",
&["--no-video".to_string(), "--really-quiet".to_string()],
path.to_str().unwrap(),
)
.expect("mpv spawns");
// The role is the player binary, so this also pins that the call site
// passes `bin` and not a fixed literal.
let prefix = live_test::expected_prefix("mpv");
let found = live_test::poll_for_owned_node(&prefix, std::time::Duration::from_secs(5));
let _ = child.kill().await;
std::fs::remove_dir_all(&dir).ok();
let (name, owned) =
found.unwrap_or_else(|| panic!("no live node named {prefix:?} appeared within 5s"));
assert!(
name.starts_with(ownership::OWNED_NODE_NAME_PREFIX),
"{name}"
);
assert_eq!(owned.as_deref(), Some(ownership::OWNED_PROP_VALUE));
}
#[test] #[test]
fn viewer_args_guard_neutralizes_flag_like_ticket() { fn viewer_args_guard_neutralizes_flag_like_ticket() {
// A malicious "ticket" that looks like a flag must end up positional, // A malicious "ticket" that looks like a flag must end up positional,
// i.e. after the `--` end-of-options guard, never parsed as a flag. // i.e. after the `--` end-of-options guard, never parsed as a flag.
let args = viewer_args("--malicious-flag"); let args = viewer_args("--malicious-flag");
assert_eq!(args.last().unwrap(), "--malicious-flag", "ticket is last"); assert_eq!(args.last().unwrap(), "--malicious-flag", "ticket is last");
let guard = args.iter().position(|a| a == "--").expect("`--` guard present"); let guard = args
.iter()
.position(|a| a == "--")
.expect("`--` guard present");
let ticket = args.len() - 1; let ticket = args.len() - 1;
assert!(guard < ticket, "ticket must follow the `--` guard"); assert!(guard < ticket, "ticket must follow the `--` guard");
// The real flags are parsed before the guard. // The real flags are parsed before the guard.
@@ -619,7 +883,11 @@ mod tests {
fn host_args_without_app_shares_whole_desktop() { fn host_args_without_app_shares_whole_desktop() {
// No app selected → no --app flag → pixelpass keeps its default // No app selected → no --app flag → pixelpass keeps its default
// (whole-desktop) audio capture. // (whole-desktop) audio capture.
assert_eq!(host_args(None), vec!["--host", "--output", "json"]); let settings = ScreenShareSettings::default();
assert_eq!(
host_args(None, &settings, ShareQuality::Auto),
vec!["--host", "--output", "json"]
);
} }
#[test] #[test]
@@ -627,13 +895,20 @@ mod tests {
// The chosen app rides in the `--app=<name>` single-token form so a // The chosen app rides in the `--app=<name>` single-token form so a
// name beginning with `-` can never be reparsed as a flag (A23), plus // name beginning with `-` can never be reparsed as a flag (A23), plus
// `--strict-audio` so pixelpass never falls back to whole-desktop audio. // `--strict-audio` so pixelpass never falls back to whole-desktop audio.
let settings = ScreenShareSettings::default();
assert_eq!( assert_eq!(
host_args(Some("Firefox")), host_args(Some("Firefox"), &settings, ShareQuality::Auto),
vec!["--host", "--output", "json", "--app=Firefox", "--strict-audio"] vec![
"--host",
"--output",
"json",
"--app=Firefox",
"--strict-audio"
]
); );
// The hyphen-leading name is still bound to --app as a single token; // The hyphen-leading name is still bound to --app as a single token;
// --strict-audio is the trailing flag. // --strict-audio is the trailing flag.
let args = host_args(Some("-rm -rf")); let args = host_args(Some("-rm -rf"), &settings, ShareQuality::Auto);
assert_eq!(args[3], "--app=-rm -rf"); assert_eq!(args[3], "--app=-rm -rf");
assert_eq!(args[4], "--strict-audio"); assert_eq!(args[4], "--strict-audio");
} }
@@ -642,13 +917,200 @@ mod tests {
fn host_args_blank_or_control_app_is_dropped() { fn host_args_blank_or_control_app_is_dropped() {
// An empty / whitespace / control-laden selection is sanitized away, // An empty / whitespace / control-laden selection is sanitized away,
// falling back to whole-desktop capture rather than a broken flag. // falling back to whole-desktop capture rather than a broken flag.
assert_eq!(host_args(Some(" ")), vec!["--host", "--output", "json"]); let settings = ScreenShareSettings::default();
assert_eq!(host_args(Some("bad\nname")), vec!["--host", "--output", "json"]); assert_eq!(
host_args(Some(" "), &settings, ShareQuality::Auto),
vec!["--host", "--output", "json"]
);
assert_eq!(
host_args(Some("bad\nname"), &settings, ShareQuality::Auto),
vec!["--host", "--output", "json"]
);
}
#[test]
fn host_args_apply_screen_share_settings_and_extra_args_last() {
let settings = ScreenShareSettings {
bitrate_mbps: Some(5),
framerate: Some(60),
max_height: Some(1080),
max_viewers: Some(4),
force_software_encode: true,
extra_host_args: "--relay https://relay.example --verbose".to_string(),
..ScreenShareSettings::default()
};
assert_eq!(
host_args(Some("Firefox"), &settings, ShareQuality::High),
vec![
"--host",
"--output",
"json",
"--app=Firefox",
"--strict-audio",
"--quality=high",
"--max-height=1080",
"--bitrate=5000",
"--framerate=60",
"--no-hwencode",
"--max-viewers=4",
"--relay",
"https://relay.example",
"--verbose",
]
);
}
#[test]
fn mpv_args_default_matches_low_latency_software_decode() {
assert_eq!(
mpv_args(&ScreenShareSettings::default(), None),
vec![
"--profile=low-latency",
"--audio-buffer=0.2",
"--demuxer-readahead-secs=0.5",
"--demuxer-max-bytes=1M",
]
);
}
#[test]
fn low_latency_gets_the_ipc_socket_for_live_edge_catch_up() {
let args = mpv_args(
&ScreenShareSettings::default(),
Some(Path::new("/run/user/1000/peerspeak-mpv-1.sock")),
);
assert!(
args.contains(&"--input-ipc-server=/run/user/1000/peerspeak-mpv-1.sock".to_string()),
"low latency drains a lagging buffer over mpv IPC: {args:?}"
);
// The flag that used to hold this posture at the live edge measured no
// better than pacing, and cost A/V sync — it must not come back.
assert!(!args.contains(&"--untimed".to_string()));
}
#[test]
fn smooth_keeps_its_deep_buffer_and_gets_no_ipc_socket() {
let settings = ScreenShareSettings {
buffering: ShareBuffering::Smooth,
..ScreenShareSettings::default()
};
let args = mpv_args(
&settings,
Some(Path::new("/run/user/1000/peerspeak-mpv-1.sock")),
);
assert!(
!args.iter().any(|a| a.starts_with("--input-ipc-server")),
"catch-up would fight Smooth's deliberate ~2s readahead: {args:?}"
);
}
#[test]
fn low_latency_caps_the_byte_cache_but_smooth_keeps_the_user_value() {
// The cache is a byte budget, so at a given bitrate it sets the
// worst-case backlog: 2 MiB held ~6 s of a 2.5 Mbps share on the rig.
let generous = ScreenShareSettings {
cache_mb: 32,
..ScreenShareSettings::default()
};
assert!(
mpv_args(&generous, None)
.contains(&format!("--demuxer-max-bytes={LOW_LATENCY_CACHE_CAP_MB}M")),
"low latency must bound how far behind the viewer can silently fall"
);
let smooth = ScreenShareSettings {
cache_mb: 32,
buffering: ShareBuffering::Smooth,
..ScreenShareSettings::default()
};
assert!(
mpv_args(&smooth, None).contains(&"--demuxer-max-bytes=32M".to_string()),
"smooth is the posture where the user asked for a deep buffer"
);
}
#[test]
fn user_extra_args_still_come_last() {
let settings = ScreenShareSettings {
extra_mpv_args: "--no-osc".to_string(),
..ScreenShareSettings::default()
};
let args = mpv_args(&settings, Some(Path::new("/tmp/s.sock")));
assert_eq!(
args.last().map(String::as_str),
Some("--no-osc"),
"a user override has to win over everything we add: {args:?}"
);
}
#[test]
fn mpv_args_smooth_hwdecode_and_extra_args_last() {
let settings = ScreenShareSettings {
hardware_decode: true,
buffering: ShareBuffering::Smooth,
cache_mb: 16,
extra_mpv_args: "--no-osc --vd-lavc-threads=2".to_string(),
..ScreenShareSettings::default()
};
assert_eq!(
mpv_args(&settings, None),
vec![
"--cache=yes",
"--demuxer-readahead-secs=2",
"--demuxer-max-bytes=16M",
"--hwdec=auto",
"--no-osc",
"--vd-lavc-threads=2",
]
);
}
#[test]
fn vlc_args_default_disables_hardware_decode() {
// The A-bug fix default (hardware_decode = false) must reach VLC too:
// VLC hardware-decodes by default, so without an explicit
// `--avcodec-hw=none` a VLC viewer would ignore the toggle and could hit
// the frame-1 freeze. Low-latency buffering keeps the 200 ms caches.
assert_eq!(
vlc_args(&ScreenShareSettings::default()),
vec![
"--network-caching=200",
"--live-caching=200",
"--avcodec-hw=none",
]
);
}
#[test]
fn vlc_args_smooth_buffering_and_hwdecode() {
// Enabling hardware decode flips VLC to `--avcodec-hw=any`; Smooth
// buffering raises the network/live caches. cache_mb / extra_mpv_args are
// mpv-only and must NOT leak into the VLC argv.
let settings = ScreenShareSettings {
hardware_decode: true,
buffering: ShareBuffering::Smooth,
cache_mb: 16,
extra_mpv_args: "--no-osc".to_string(),
..ScreenShareSettings::default()
};
assert_eq!(
vlc_args(&settings),
vec![
"--network-caching=1500",
"--live-caching=1500",
"--avcodec-hw=any",
]
);
} }
#[test] #[test]
fn sanitize_app_name_trims_and_rejects_garbage() { fn sanitize_app_name_trims_and_rejects_garbage() {
assert_eq!(sanitize_app_name(" Firefox \n"), Some("Firefox".to_string())); assert_eq!(
sanitize_app_name(" Firefox \n"),
Some("Firefox".to_string())
);
assert_eq!(sanitize_app_name(""), None); assert_eq!(sanitize_app_name(""), None);
assert_eq!(sanitize_app_name(" "), None); assert_eq!(sanitize_app_name(" "), None);
assert_eq!(sanitize_app_name("a\tb"), None); assert_eq!(sanitize_app_name("a\tb"), None);
@@ -667,7 +1129,11 @@ mod tests {
]"#; ]"#;
assert_eq!( assert_eq!(
parse_audio_apps(stdout), parse_audio_apps(stdout),
vec!["Firefox".to_string(), "Spotify".to_string(), "mpv".to_string()] vec![
"Firefox".to_string(),
"Spotify".to_string(),
"mpv".to_string()
]
); );
} }
@@ -754,13 +1220,19 @@ Install hint: sudo apt install gstreamer1.0-plugins-bad
#[test] #[test]
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() { fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm"; let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
assert_eq!(sanitize_ticket(format!(" {ticket}\n")), Some(ticket.to_string())); assert_eq!(
sanitize_ticket(format!(" {ticket}\n")),
Some(ticket.to_string())
);
} }
#[test] #[test]
fn sanitize_ticket_rejects_oversized_or_garbage_ticket() { fn sanitize_ticket_rejects_oversized_or_garbage_ticket() {
assert_eq!(sanitize_ticket("not-a-ticket".into()), None); assert_eq!(sanitize_ticket("not-a-ticket".into()), None);
assert_eq!(sanitize_ticket(format!("endpoint{}", "a".repeat(MAX_TICKET_LEN))), None); assert_eq!(
sanitize_ticket(format!("endpoint{}", "a".repeat(MAX_TICKET_LEN))),
None
);
assert_eq!(sanitize_ticket("endpointabc-def".into()), None); assert_eq!(sanitize_ticket("endpointabc-def".into()), None);
} }
@@ -784,7 +1256,9 @@ Install hint: sudo apt install gstreamer1.0-plugins-bad
fn parses_connected_url() { fn parses_connected_url() {
assert_eq!( assert_eq!(
parse_pixelpass_event(r#"{"event":"connected","url":"http://127.0.0.1:5500"}"#), parse_pixelpass_event(r#"{"event":"connected","url":"http://127.0.0.1:5500"}"#),
Some(PixelpassEvent::Connected("http://127.0.0.1:5500".to_string())) Some(PixelpassEvent::Connected(
"http://127.0.0.1:5500".to_string()
))
); );
} }
@@ -919,8 +1393,70 @@ Install hint: sudo apt install gstreamer1.0-plugins-bad
let dir = Path::new("bin"); let dir = Path::new("bin");
let candidates: Vec<PathBuf> = pixelpass_path_candidates(dir).into_iter().collect(); let candidates: Vec<PathBuf> = pixelpass_path_candidates(dir).into_iter().collect();
#[cfg(windows)] #[cfg(windows)]
assert_eq!(candidates, vec![dir.join("pixelpass"), dir.join("pixelpass.exe")]); assert_eq!(
candidates,
vec![dir.join("pixelpass"), dir.join("pixelpass.exe")]
);
#[cfg(not(windows))] #[cfg(not(windows))]
assert_eq!(candidates, vec![dir.join("pixelpass")]); assert_eq!(candidates, vec![dir.join("pixelpass")]);
} }
/// The host-fault contract, clean-exit half: events are forwarded in order
/// and the stream ending yields exactly one terminal [`HostNotice::Eof`],
/// after which the drain task drops its sender (the closed channel is what
/// ends the core's forwarder). A host that dies silently — EOF swallowed —
/// is the S2 defect: the dead share stays advertised in presence.
#[tokio::test]
async fn drain_forwards_events_then_synthesizes_eof_when_stdout_ends() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let (read_half, mut write_half) = tokio::io::duplex(1024);
drain_in_background(BufReader::new(read_half).lines(), "test", Some(tx));
use tokio::io::AsyncWriteExt;
write_half
.write_all(b"{\"event\":\"app_audio\",\"state\":\"routed\"}\nnot json\n")
.await
.unwrap();
drop(write_half); // child exited: stdout EOF
assert_eq!(
rx.recv().await,
Some(HostNotice::Event(PixelpassEvent::AppAudioRouted))
);
// The non-JSON line is dropped, not forwarded.
assert_eq!(rx.recv().await, Some(HostNotice::Eof));
assert_eq!(rx.recv().await, None, "task ended and dropped the sender");
}
/// The host-fault contract, broken-stream half: a read *error* (not a tidy
/// EOF) must synthesize the same terminal `Eof` — the event stream is gone
/// either way, and only the drain task can tell the core so.
#[tokio::test]
async fn drain_synthesizes_eof_on_a_read_error_too() {
struct BrokenPipe;
impl tokio::io::AsyncRead for BrokenPipe {
fn poll_read(
self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
_buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::task::Poll::Ready(Err(std::io::Error::other("stream broke")))
}
}
use tokio::io::AsyncReadExt;
// One good event line, then the stream breaks mid-read.
let reader =
std::io::Cursor::new(b"{\"event\":\"capture\",\"state\":\"started\"}\n".to_vec())
.chain(BrokenPipe);
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
drain_in_background(BufReader::new(reader).lines(), "test", Some(tx));
assert_eq!(
rx.recv().await,
Some(HostNotice::Event(PixelpassEvent::CaptureStarted))
);
assert_eq!(rx.recv().await, Some(HostNotice::Eof));
assert_eq!(rx.recv().await, None, "task ended and dropped the sender");
}
} }
+6 -2
View File
@@ -211,7 +211,7 @@ impl AppTheme {
overlay: hex(0x6272a4), overlay: hex(0x6272a4),
text: hex(0xf8f8f2), text: hex(0xf8f8f2),
subtext: hex(0xbdc0d4), subtext: hex(0xbdc0d4),
blue: hex(0xbd93f9), // Dracula's signature purple as the primary accent blue: hex(0xbd93f9), // Dracula's signature purple as the primary accent
lavender: hex(0x8be9fd), // cyan lavender: hex(0x8be9fd), // cyan
red: hex(0xff5555), red: hex(0xff5555),
maroon: hex(0xff79c6), // pink maroon: hex(0xff79c6), // pink
@@ -400,7 +400,11 @@ mod tests {
for theme in AppTheme::ALL { for theme in AppTheme::ALL {
let p = theme.palette(); let p = theme.palette();
let sub = contrast_ratio(p.subtext, p.base); let sub = contrast_ratio(p.subtext, p.base);
assert!(sub >= 3.0, "{}: subtext contrast {sub:.2} < 3.0", theme.label()); assert!(
sub >= 3.0,
"{}: subtext contrast {sub:.2} < 3.0",
theme.label()
);
let accent = contrast_ratio(p.blue, p.base); let accent = contrast_ratio(p.blue, p.base);
assert!( assert!(
accent >= 3.0, accent >= 3.0,
+134 -81
View File
@@ -9,8 +9,8 @@ use iced::advanced::widget::{self, Widget};
use iced::advanced::{Layout, Shell}; use iced::advanced::{Layout, Shell};
use iced::widget::text_input; use iced::widget::text_input;
use iced::{ use iced::{
alignment, Background, Border, Color, Element, Event, Length, Padding, Background, Border, Color, Element, Event, Length, Padding, Pixels, Point, Rectangle, Shadow,
Pixels, Point, Rectangle, Shadow, Size, Vector, Size, Vector, alignment,
}; };
use std::rc::Rc; use std::rc::Rc;
@@ -27,11 +27,7 @@ pub fn copy_selection(value: &str, start: usize, end: usize) -> Option<String> {
(start != end).then(|| value.select(start, end).to_string()) (start != end).then(|| value.select(start, end).to_string())
} }
pub fn cut_selection( pub fn cut_selection(value: &str, start: usize, end: usize) -> (Edit, Option<String>) {
value: &str,
start: usize,
end: usize,
) -> (Edit, Option<String>) {
let mut value = text_input::Value::new(value); let mut value = text_input::Value::new(value);
let (start, end) = normalized_range(&value, start, end); let (start, end) = normalized_range(&value, start, end);
@@ -74,24 +70,26 @@ pub fn paste(value: &str, start: usize, end: usize, clip: &str) -> Edit {
} }
} }
/// Strip control characters (e.g. a trailing newline on an X11 PRIMARY
/// selection) from clipboard text before it is pasted. Shared by the
/// right-click menu Paste and the middle-click PRIMARY paste.
pub fn sanitize_clip(raw: &str) -> String {
raw.chars().filter(|c| !c.is_control()).collect()
}
pub fn select_all_range(value: &str) -> (usize, usize) { pub fn select_all_range(value: &str) -> (usize, usize) {
let value = text_input::Value::new(value); let value = text_input::Value::new(value);
(0, value.len()) (0, value.len())
} }
fn normalized_range( fn normalized_range(value: &text_input::Value, start: usize, end: usize) -> (usize, usize) {
value: &text_input::Value,
start: usize,
end: usize,
) -> (usize, usize) {
let len = value.len(); let len = value.len();
(start.min(end).min(len), start.max(end).min(len)) (start.min(end).min(len), start.max(end).min(len))
} }
type InputStyleFn<'a, Theme> = type InputStyleFn<'a, Theme> = Rc<dyn Fn(&Theme, text_input::Status) -> text_input::Style + 'a>;
Rc<dyn Fn(&Theme, text_input::Status) -> text_input::Style + 'a>;
pub fn context_input<'a, Message, Theme, Renderer>( pub fn context_input<'a, Message, Theme, Renderer>(
placeholder: &str, placeholder: &str,
@@ -119,12 +117,8 @@ where
.locked(true) .locked(true)
} }
pub struct ContextInput< pub struct ContextInput<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer>
'a, where
Message,
Theme = iced::Theme,
Renderer = iced::Renderer,
> where
Theme: text_input::Catalog, Theme: text_input::Catalog,
Renderer: text::Renderer, Renderer: text::Renderer,
{ {
@@ -137,8 +131,7 @@ pub struct ContextInput<
style: Option<InputStyleFn<'a, Theme>>, style: Option<InputStyleFn<'a, Theme>>,
} }
impl<'a, Message, Theme, Renderer> impl<'a, Message, Theme, Renderer> ContextInput<'a, Message, Theme, Renderer>
ContextInput<'a, Message, Theme, Renderer>
where where
Message: Clone + 'a, Message: Clone + 'a,
Theme: text_input::Catalog + 'a, Theme: text_input::Catalog + 'a,
@@ -172,16 +165,13 @@ where
self self
} }
pub fn on_input( pub fn on_input(mut self, on_input: impl Fn(String) -> Message + 'a) -> Self {
mut self, let on_input: Rc<dyn Fn(String) -> Message + 'a> = Rc::new(on_input);
on_input: impl Fn(String) -> Message + 'a,
) -> Self {
let on_input: Rc<dyn Fn(String) -> Message + 'a> =
Rc::new(on_input);
let input_callback = Rc::clone(&on_input); let input_callback = Rc::clone(&on_input);
self.input = self.input = self
self.input.on_input(move |value| input_callback.as_ref()(value)); .input
.on_input(move |value| input_callback.as_ref()(value));
self.on_input = Some(on_input); self.on_input = Some(on_input);
self self
} }
@@ -196,16 +186,13 @@ where
self self
} }
pub fn on_paste( pub fn on_paste(mut self, on_paste: impl Fn(String) -> Message + 'a) -> Self {
mut self, let on_paste: Rc<dyn Fn(String) -> Message + 'a> = Rc::new(on_paste);
on_paste: impl Fn(String) -> Message + 'a,
) -> Self {
let on_paste: Rc<dyn Fn(String) -> Message + 'a> =
Rc::new(on_paste);
let paste_callback = Rc::clone(&on_paste); let paste_callback = Rc::clone(&on_paste);
self.input = self.input = self
self.input.on_paste(move |value| paste_callback.as_ref()(value)); .input
.on_paste(move |value| paste_callback.as_ref()(value));
self.on_paste = Some(on_paste); self.on_paste = Some(on_paste);
self self
} }
@@ -235,18 +222,12 @@ where
self self
} }
pub fn line_height( pub fn line_height(mut self, line_height: impl Into<text::LineHeight>) -> Self {
mut self,
line_height: impl Into<text::LineHeight>,
) -> Self {
self.input = self.input.line_height(line_height); self.input = self.input.line_height(line_height);
self self
} }
pub fn align_x( pub fn align_x(mut self, alignment: impl Into<alignment::Horizontal>) -> Self {
mut self,
alignment: impl Into<alignment::Horizontal>,
) -> Self {
self.input = self.input.align_x(alignment); self.input = self.input.align_x(alignment);
self self
} }
@@ -379,11 +360,51 @@ where
} }
}; };
tree.state.downcast_mut::<ContextInputState>().menu = tree.state.downcast_mut::<ContextInputState>().menu = cursor
cursor.position().map(|anchor| MenuState { .position()
anchor, .map(|anchor| MenuState { anchor, selection });
selection,
}); shell.capture_event();
shell.request_redraw();
return;
}
// Middle-click pastes the X11 PRIMARY selection at the cursor. iced's
// base text_input only wires Ctrl+V to the Standard (CLIPBOARD)
// selection, so without this the common "select text, middle-click to
// paste" workflow does nothing on X11.
let middle_click_on_input = matches!(
event,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle))
) && cursor.is_over(layout.bounds());
if middle_click_on_input && !self.locked {
let clip = sanitize_clip(&clipboard.read(clipboard::Kind::Primary).unwrap_or_default());
if !clip.is_empty() {
let value = text_input::Value::new(&self.value);
let input_state = tree.children[0]
.state
.downcast_mut::<text_input::State<Renderer::Paragraph>>();
let (start, end) = match input_state.cursor().state(&value) {
text_input::cursor::State::Index(index) => {
let index = index.min(value.len());
(index, index)
}
text_input::cursor::State::Selection { start, end } => {
normalized_range(&value, start, end)
}
};
let edit = paste(&self.value, start, end, &clip);
input_state.move_cursor_to(edit.cursor);
if let Some(on_paste) = &self.on_paste {
shell.publish(on_paste.as_ref()(edit.value));
} else if let Some(on_input) = &self.on_input {
shell.publish(on_input.as_ref()(edit.value));
}
}
shell.capture_event(); shell.capture_event();
shell.request_redraw(); shell.request_redraw();
@@ -477,8 +498,7 @@ where
} }
} }
impl<'a, Message, Theme, Renderer> impl<'a, Message, Theme, Renderer> From<ContextInput<'a, Message, Theme, Renderer>>
From<ContextInput<'a, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer> for Element<'a, Message, Theme, Renderer>
where where
Message: Clone + 'a, Message: Clone + 'a,
@@ -516,12 +536,7 @@ enum MenuAction {
} }
impl MenuAction { impl MenuAction {
const ALL: [Self; 4] = [ const ALL: [Self; 4] = [Self::Cut, Self::Copy, Self::Paste, Self::SelectAll];
Self::Cut,
Self::Copy,
Self::Paste,
Self::SelectAll,
];
fn label(self) -> &'static str { fn label(self) -> &'static str {
match self { match self {
@@ -564,8 +579,7 @@ where
cursor: mouse::Cursor, cursor: mouse::Cursor,
) { ) {
let active_style = input_style(theme, self.style.as_ref(), text_input::Status::Active); let active_style = input_style(theme, self.style.as_ref(), text_input::Status::Active);
let hovered_style = let hovered_style = input_style(theme, self.style.as_ref(), text_input::Status::Hovered);
input_style(theme, self.style.as_ref(), text_input::Status::Hovered);
let bounds = layout.bounds(); let bounds = layout.bounds();
let viewport = Rectangle::INFINITE; let viewport = Rectangle::INFINITE;
@@ -640,9 +654,7 @@ where
) { ) {
match event { match event {
Event::Keyboard(iced::keyboard::Event::KeyPressed { Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named( key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
iced::keyboard::key::Named::Escape,
),
.. ..
}) => { }) => {
self.close(shell); self.close(shell);
@@ -718,11 +730,7 @@ where
) )
} }
fn hit_action( fn hit_action(&self, bounds: Rectangle, position: Point) -> Option<MenuAction> {
&self,
bounds: Rectangle,
position: Point,
) -> Option<MenuAction> {
if !bounds.contains(position) { if !bounds.contains(position) {
return None; return None;
} }
@@ -758,12 +766,11 @@ where
} }
} }
MenuAction::Paste => { MenuAction::Paste => {
let clip = clipboard let clip = sanitize_clip(
.read(clipboard::Kind::Standard) &clipboard
.unwrap_or_default() .read(clipboard::Kind::Standard)
.chars() .unwrap_or_default(),
.filter(|c| !c.is_control()) );
.collect::<String>();
let edit = paste(self.value, start, end, &clip); let edit = paste(self.value, start, end, &clip);
self.publish_paste(edit, shell); self.publish_paste(edit, shell);
@@ -883,6 +890,16 @@ mod tests {
assert_eq!(clip, None); assert_eq!(clip, None);
} }
#[test]
fn sanitize_clip_strips_control_chars_keeps_text() {
// An X11 PRIMARY selection commonly carries a trailing newline.
assert_eq!(sanitize_clip("pixelpassF1:abc\n"), "pixelpassF1:abc");
assert_eq!(sanitize_clip("a\tb\r\nc"), "abc");
// Non-control unicode is preserved.
assert_eq!(sanitize_clip("héllo🦀"), "héllo🦀");
assert_eq!(sanitize_clip(""), "");
}
#[test] #[test]
fn paste_replaces_selection_or_inserts_at_cursor() { fn paste_replaces_selection_or_inserts_at_cursor() {
assert_eq!( assert_eq!(
@@ -932,12 +949,48 @@ mod tests {
#[test] #[test]
fn locked_menu_allows_copy_and_select_all_only() { fn locked_menu_allows_copy_and_select_all_only() {
assert!(!menu_action_enabled(MenuAction::Cut, true, true, false, true)); assert!(!menu_action_enabled(
assert!(menu_action_enabled(MenuAction::Copy, true, true, false, true)); MenuAction::Cut,
assert!(!menu_action_enabled(MenuAction::Paste, true, true, false, true)); true,
assert!(menu_action_enabled(MenuAction::SelectAll, true, true, false, true)); true,
false,
true
));
assert!(menu_action_enabled(
MenuAction::Copy,
true,
true,
false,
true
));
assert!(!menu_action_enabled(
MenuAction::Paste,
true,
true,
false,
true
));
assert!(menu_action_enabled(
MenuAction::SelectAll,
true,
true,
false,
true
));
assert!(!menu_action_enabled(MenuAction::Copy, false, true, false, true)); assert!(!menu_action_enabled(
assert!(!menu_action_enabled(MenuAction::SelectAll, false, false, false, true)); MenuAction::Copy,
false,
true,
false,
true
));
assert!(!menu_action_enabled(
MenuAction::SelectAll,
false,
false,
false,
true
));
} }
} }
+47 -117
View File
@@ -3,16 +3,15 @@ use iced::advanced::layout;
use iced::advanced::mouse; use iced::advanced::mouse;
use iced::advanced::renderer; use iced::advanced::renderer;
use iced::advanced::text::{self as advanced_text, Paragraph, Span}; use iced::advanced::text::{self as advanced_text, Paragraph, Span};
use iced::advanced::widget::tree::{self, Tree};
use iced::advanced::widget::Widget; use iced::advanced::widget::Widget;
use iced::advanced::widget::tree::{self, Tree};
use iced::advanced::{Layout, Shell}; use iced::advanced::{Layout, Shell};
use iced::widget::text::{ use iced::widget::text::{
self as widget_text, Alignment, Catalog, LineHeight, Shaping, Style, StyleFn, self as widget_text, Alignment, Catalog, LineHeight, Shaping, Style, StyleFn, Wrapping,
Wrapping,
}; };
use iced::{ use iced::{
alignment, Background, Border, Color, Element, Event, Length, Pixels, Point, Background, Border, Color, Element, Event, Length, Pixels, Point, Rectangle, Size, Vector,
Rectangle, Size, Vector, keyboard, alignment, keyboard,
}; };
const DRAG_THRESHOLD: f32 = 3.0; const DRAG_THRESHOLD: f32 = 3.0;
@@ -22,17 +21,13 @@ const HIT_SEARCH_STEPS: usize = 24;
// `Hit::CharOffset(cursor.index)`, and cosmic-text's `cursor.index` is a byte // `Hit::CharOffset(cursor.index)`, and cosmic-text's `cursor.index` is a byte
// offset WITHIN its buffer line — it discards the line number. That equals the // offset WITHIN its buffer line — it discards the line number. That equals the
// global byte offset only when the text is a single logical line. Chat bodies // global byte offset only when the text is a single logical line. Chat bodies
// satisfy this because `app::sanitize_chat` turns every control char (incl. `\n` // satisfy this because `sanitize::sanitize_chat` turns every control char (incl. `\n`
// and `\r`) into a space and collapses whitespace, so a stored message can never // and `\r`) into a space and collapses whitespace, so a stored message can never
// contain a newline. If that sanitizer ever starts preserving newlines, this // contain a newline. If that sanitizer ever starts preserving newlines, this
// widget's per-line offsets would stop being global and selection/copy across // widget's per-line offsets would stop being global and selection/copy across
// lines would break — revisit then. // lines would break — revisit then.
pub fn selected_substring( pub fn selected_substring(text: &str, anchor: usize, cursor: usize) -> Option<String> {
text: &str,
anchor: usize,
cursor: usize,
) -> Option<String> {
let (start, end) = normalized_byte_range(text, anchor, cursor); let (start, end) = normalized_byte_range(text, anchor, cursor);
(start != end).then(|| text[start..end].to_owned()) (start != end).then(|| text[start..end].to_owned())
@@ -42,11 +37,7 @@ pub fn select_all(text: &str) -> (usize, usize) {
(0, text.len()) (0, text.len())
} }
fn normalized_byte_range( fn normalized_byte_range(text: &str, anchor: usize, cursor: usize) -> (usize, usize) {
text: &str,
anchor: usize,
cursor: usize,
) -> (usize, usize) {
let start = clamp_to_char_boundary(text, anchor.min(cursor)); let start = clamp_to_char_boundary(text, anchor.min(cursor));
let end = clamp_to_char_boundary(text, anchor.max(cursor)); let end = clamp_to_char_boundary(text, anchor.max(cursor));
@@ -75,13 +66,8 @@ where
SelectableRichText::with_spans(spans) SelectableRichText::with_spans(spans)
} }
pub struct SelectableRichText< pub struct SelectableRichText<'a, Link, Message, Theme = iced::Theme, Renderer = iced::Renderer>
'a, where
Link,
Message,
Theme = iced::Theme,
Renderer = iced::Renderer,
> where
Link: Clone + 'static, Link: Clone + 'static,
Theme: Catalog, Theme: Catalog,
Renderer: advanced_text::Renderer, Renderer: advanced_text::Renderer,
@@ -101,8 +87,7 @@ pub struct SelectableRichText<
selection_color: Color, selection_color: Color,
} }
impl<'a, Link, Message, Theme, Renderer> impl<'a, Link, Message, Theme, Renderer> SelectableRichText<'a, Link, Message, Theme, Renderer>
SelectableRichText<'a, Link, Message, Theme, Renderer>
where where
Link: Clone + 'static, Link: Clone + 'static,
Theme: Catalog, Theme: Catalog,
@@ -127,9 +112,7 @@ where
} }
} }
pub fn with_spans( pub fn with_spans(spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a) -> Self {
spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a,
) -> Self {
Self { Self {
spans: Box::new(spans), spans: Box::new(spans),
..Self::new() ..Self::new()
@@ -166,10 +149,7 @@ where
self self
} }
pub fn align_y( pub fn align_y(mut self, alignment: impl Into<alignment::Vertical>) -> Self {
mut self,
alignment: impl Into<alignment::Vertical>,
) -> Self {
self.align_y = alignment.into(); self.align_y = alignment.into();
self self
} }
@@ -179,10 +159,7 @@ where
self self
} }
pub fn on_link_click( pub fn on_link_click(mut self, on_link_click: impl Fn(Link) -> Message + 'a) -> Self {
mut self,
on_link_click: impl Fn(Link) -> Message + 'a,
) -> Self {
self.on_link_click = Some(Box::new(on_link_click)); self.on_link_click = Some(Box::new(on_link_click));
self self
} }
@@ -356,26 +333,16 @@ where
} }
for (index, span) in spans.iter().enumerate() { for (index, span) in spans.iter().enumerate() {
let is_hovered_link = self.on_link_click.is_some() let is_hovered_link = self.on_link_click.is_some() && Some(index) == self.hovered_link;
&& Some(index) == self.hovered_link;
if span.highlight.is_some() if span.highlight.is_some() || span.underline || span.strikethrough || is_hovered_link {
|| span.underline
|| span.strikethrough
|| is_hovered_link
{
let regions = state.paragraph.span_bounds(index); let regions = state.paragraph.span_bounds(index);
if let Some(highlight) = span.highlight { if let Some(highlight) = span.highlight {
for bounds in &regions { for bounds in &regions {
let bounds = Rectangle::new( let bounds = Rectangle::new(
bounds.position() bounds.position() - Vector::new(span.padding.left, span.padding.top),
- Vector::new( bounds.size() + Size::new(span.padding.x(), span.padding.y()),
span.padding.left,
span.padding.top,
),
bounds.size()
+ Size::new(span.padding.x(), span.padding.y()),
); );
renderer.fill_quad( renderer.fill_quad(
@@ -390,26 +357,17 @@ where
} }
if span.underline || span.strikethrough || is_hovered_link { if span.underline || span.strikethrough || is_hovered_link {
let size = span let size = span.size.or(self.size).unwrap_or(renderer.default_size());
.size
.or(self.size)
.unwrap_or(renderer.default_size());
let line_height = span let line_height = span
.line_height .line_height
.unwrap_or(self.line_height) .unwrap_or(self.line_height)
.to_absolute(size); .to_absolute(size);
let color = span let color = span.color.or(style.color).unwrap_or(defaults.text_color);
.color
.or(style.color)
.unwrap_or(defaults.text_color);
let baseline = translation let baseline =
+ Vector::new( translation + Vector::new(0.0, size.0 + (line_height.0 - size.0) / 2.0);
0.0,
size.0 + (line_height.0 - size.0) / 2.0,
);
if span.underline || is_hovered_link { if span.underline || is_hovered_link {
for bounds in &regions { for bounds in &regions {
@@ -497,13 +455,10 @@ where
state.dragging = true; state.dragging = true;
state.press_position = Some(position); state.press_position = Some(position);
state.span_pressed = self.hovered_link; state.span_pressed = self.hovered_link;
state.selection = state state.selection = state.paragraph.hit_test(position).map(|hit| {
.paragraph let offset = hit.cursor().min(flat_text.len());
.hit_test(position) (offset, offset)
.map(|hit| { });
let offset = hit.cursor().min(flat_text.len());
(offset, offset)
});
shell.capture_event(); shell.capture_event();
shell.request_redraw(); shell.request_redraw();
} else if state.active || state.selection.is_some() { } else if state.active || state.selection.is_some() {
@@ -521,8 +476,7 @@ where
&& let Some(hit) = state.paragraph.hit_test(position) && let Some(hit) = state.paragraph.hit_test(position)
&& let Some((anchor, _)) = state.selection && let Some((anchor, _)) = state.selection
{ {
state.selection = state.selection = Some((anchor, hit.cursor().min(flat_text.len())));
Some((anchor, hit.cursor().min(flat_text.len())));
shell.request_redraw(); shell.request_redraw();
} }
} }
@@ -540,16 +494,14 @@ where
&& let Some(hit) = state.paragraph.hit_test(position) && let Some(hit) = state.paragraph.hit_test(position)
&& let Some((anchor, _)) = state.selection && let Some((anchor, _)) = state.selection
{ {
state.selection = state.selection = Some((anchor, hit.cursor().min(flat_text.len())));
Some((anchor, hit.cursor().min(flat_text.len())));
} }
if !dragged { if !dragged {
if let (Some(on_link_clicked), Some(span)) = if let (Some(on_link_clicked), Some(span)) =
(&self.on_link_click, state.span_pressed) (&self.on_link_click, state.span_pressed)
&& Some(span) == self.hovered_link && Some(span) == self.hovered_link
&& let Some(link) = && let Some(link) = spans.get(span).and_then(|span| span.link.clone())
spans.get(span).and_then(|span| span.link.clone())
{ {
shell.publish(on_link_clicked(link)); shell.publish(on_link_clicked(link));
} }
@@ -570,25 +522,22 @@ where
physical_key, physical_key,
modifiers, modifiers,
.. ..
}) if state.active && modifiers.command() => { }) if state.active && modifiers.command() => match key.to_latin(*physical_key) {
match key.to_latin(*physical_key) { Some('c') | Some('C') => {
Some('c') | Some('C') => { if let Some((anchor, cursor)) = state.selection
if let Some((anchor, cursor)) = state.selection && let Some(selected) = selected_substring(&flat_text, anchor, cursor)
&& let Some(selected) = {
selected_substring(&flat_text, anchor, cursor) clipboard.write(clipboard::Kind::Standard, selected);
{
clipboard.write(clipboard::Kind::Standard, selected);
shell.capture_event();
}
}
Some('a') | Some('A') => {
state.selection = Some(select_all(&flat_text));
shell.capture_event(); shell.capture_event();
shell.request_redraw();
} }
_ => {}
} }
} Some('a') | Some('A') => {
state.selection = Some(select_all(&flat_text));
shell.capture_event();
shell.request_redraw();
}
_ => {}
},
_ => {} _ => {}
} }
} }
@@ -657,14 +606,8 @@ where
}; };
if state.spans != config.spans { if state.spans != config.spans {
state.paragraph = state.paragraph = Renderer::Paragraph::with_spans(text_with_spans());
Renderer::Paragraph::with_spans(text_with_spans()); state.spans = config.spans.iter().cloned().map(Span::to_static).collect();
state.spans = config
.spans
.iter()
.cloned()
.map(Span::to_static)
.collect();
} else { } else {
match state.paragraph.compare(advanced_text::Text { match state.paragraph.compare(advanced_text::Text {
content: (), content: (),
@@ -682,8 +625,7 @@ where
state.paragraph.resize(bounds); state.paragraph.resize(bounds);
} }
advanced_text::Difference::Shape => { advanced_text::Difference::Shape => {
state.paragraph = state.paragraph = Renderer::Paragraph::with_spans(text_with_spans());
Renderer::Paragraph::with_spans(text_with_spans());
} }
} }
} }
@@ -761,13 +703,7 @@ fn selection_rect_for_line<P: Paragraph>(
}) })
} }
fn x_for_offset<P: Paragraph>( fn x_for_offset<P: Paragraph>(paragraph: &P, y: f32, offset: usize, left: f32, right: f32) -> f32 {
paragraph: &P,
y: f32,
offset: usize,
left: f32,
right: f32,
) -> f32 {
let mut low = left; let mut low = left;
let mut high = right.max(left); let mut high = right.max(left);
@@ -787,10 +723,7 @@ fn x_for_offset<P: Paragraph>(
high high
} }
fn visual_lines<P: Paragraph>( fn visual_lines<P: Paragraph>(paragraph: &P, span_count: usize) -> Vec<Rectangle> {
paragraph: &P,
span_count: usize,
) -> Vec<Rectangle> {
let mut lines: Vec<Rectangle> = Vec::new(); let mut lines: Vec<Rectangle> = Vec::new();
for span in 0..span_count { for span in 0..span_count {
@@ -820,10 +753,7 @@ fn union(a: Rectangle, b: Rectangle) -> Rectangle {
let right = (a.x + a.width).max(b.x + b.width); let right = (a.x + a.width).max(b.x + b.width);
let bottom = (a.y + a.height).max(b.y + b.height); let bottom = (a.y + a.height).max(b.y + b.height);
Rectangle::new( Rectangle::new(Point::new(left, top), Size::new(right - left, bottom - top))
Point::new(left, top),
Size::new(right - left, bottom - top),
)
} }
fn clamped_position(cursor: mouse::Cursor, bounds: Rectangle) -> Option<Point> { fn clamped_position(cursor: mouse::Cursor, bounds: Rectangle) -> Option<Point> {
+10 -3
View File
@@ -21,7 +21,7 @@ use iroh::endpoint::presets;
use iroh::protocol::Router; use iroh::protocol::Router;
use iroh::{Endpoint, RelayMode}; use iroh::{Endpoint, RelayMode};
use peerspeak::files::{ChatAttachment, AttachmentKind}; use peerspeak::files::{AttachmentKind, ChatAttachment};
use peerspeak::network::NetworkTransport; use peerspeak::network::NetworkTransport;
use peerspeak::network::iroh_impl::{FileRouter, IrohTransport}; use peerspeak::network::iroh_impl::{FileRouter, IrohTransport};
use peerspeak::protocol::FILES_ALPN; use peerspeak::protocol::FILES_ALPN;
@@ -52,7 +52,12 @@ async fn spawn_node() -> Node {
.accept(FILES_ALPN, file_router) .accept(FILES_ALPN, file_router)
.spawn(); .spawn();
Node { endpoint, transport, _router: router, lookup } Node {
endpoint,
transport,
_router: router,
lookup,
}
} }
/// A pseudo-random-ish multi-megabyte payload spanning many QUIC packets, so a /// A pseudo-random-ish multi-megabyte payload spanning many QUIC packets, so a
@@ -86,7 +91,9 @@ async fn loopback_attachment_round_trips_intact() {
let blob = big_blob(); let blob = big_blob();
let id = [42u8; 32]; let id = [42u8; 32];
server.transport.serve_attachment(id, Arc::new(blob.clone())); server
.transport
.serve_attachment(id, Arc::new(blob.clone()));
let att = ChatAttachment { let att = ChatAttachment {
name: "exterior-landscape.jpg".to_string(), name: "exterior-landscape.jpg".to_string(),
+42
View File
@@ -0,0 +1,42 @@
# Screenshare audio exclusion — ownership tagging wire contract.
#
# peerspeak PRODUCES these carriers on every audio node it owns; pixelpass
# CONSUMES them as the primary taint root of the exclusion engine. Neither
# repo depends on the other, so this file is the contract: it is committed
# byte-identical in both, and each repo has a test that asserts its own named
# constants (and, on the producer side, the environment a real child Command
# would carry) match these values exactly.
#
# peerspeak/tests/fixtures/ownership-tag-contract.txt
# pixelpass/tests/fixtures/ownership-tag-contract.txt
#
# Pinned by peerspeak docs/screenshare-audio-exclusion-impl-plan.md §3 and
# docs/screenshare-audio-exclusion-plan.md §5.1 (v3.5). Changing a value here
# is a cross-repo breaking change: both repos must land in the same session,
# and the phase 5 matrix must be re-run.
#
# Two carriers, matched as a UNION — a node is peerspeak-owned if EITHER
# matches. Round 8 added the second because a property is invisible to the
# PipeWire registry `global` event and readable only via a node bind, so the
# primary taint root must not rest on one observation mechanism alone.
# Carrier 1 — a node property, matched EXACTLY: `prop_value` below is the
# ONLY spelling the consumer reads as owned. A producer emitting "true", "yes"
# or "" is NOT owned on this carrier, and only carrier 2 would still catch it.
#
# ⚠️ This wording is load-bearing and it CHANGED in round 10. The consumer
# used to accept any value other than "false"/"0", on the theory that leniency
# over-excludes and is therefore safe. It is not: leniency buys false-positive
# exclusion, and it let any process suppress a rival application's audio from
# the share with a property it did not even have to spell right. Fail-closed
# on this feature is about ANCESTRY — an unresolvable graph is not eligible —
# not about parsing.
prop_key=peerspeak.owned
prop_value=1
# Carrier 2 — a `node.name` prefix, announced by the registry without a bind.
# `node.description` is deliberately NOT touched, so mixers still show "mpv".
# Only the prefix is matched; the rest of the name is for diagnostics.
node_name_prefix=peerspeak_owned_
node_name_format=peerspeak_owned_<role>_<pid>
node_name_example=peerspeak_owned_mpv_31284
+38 -2
View File
@@ -62,7 +62,7 @@ async fn evicted_within(
Ok(Some(UiEvent::PeerConnectionFailed { id })) if id == peer => return true, Ok(Some(UiEvent::PeerConnectionFailed { id })) if id == peer => return true,
Ok(Some(_)) => continue, // ignore PeerConnecting / PeerConnected / PeerLeft Ok(Some(_)) => continue, // ignore PeerConnecting / PeerConnected / PeerLeft
Ok(None) => return false, // channel closed Ok(None) => return false, // channel closed
Err(_) => return false, // timed out — no eviction Err(_) => return false, // timed out — no eviction
} }
} }
} }
@@ -80,7 +80,7 @@ async fn left_within(
Ok(Some(UiEvent::PeerLeft { id })) if id == peer => return true, Ok(Some(UiEvent::PeerLeft { id })) if id == peer => return true,
Ok(Some(_)) => continue, // ignore PeerConnecting / PeerConnected Ok(Some(_)) => continue, // ignore PeerConnecting / PeerConnected
Ok(None) => return false, // channel closed Ok(None) => return false, // channel closed
Err(_) => return false, // timed out — no leave Err(_) => return false, // timed out — no leave
} }
} }
} }
@@ -201,3 +201,39 @@ async fn rejoin_after_grace_eviction_dials_cleanly() {
"an initial dial after a grace eviction must not be treated as a reconnect" "an initial dial after a grace eviction must not be treated as a reconnect"
); );
} }
/// Chat-hardening Phase 2: a peer mid-reconnect-grace keeps its roster-bound
/// chat name (its chat stays admitted), but a TERMINAL grace-expiry eviction
/// revokes it — after that, only a fresh authenticated Announce (PeerJoined)
/// restores chat authority.
#[tokio::test]
async fn grace_eviction_revokes_chat_roster_entry() {
use peerspeak::core::chatroster::ChatRoster;
let (ui_tx, mut ui_rx) = mpsc::channel(100);
let roster = ChatRoster::default();
let h = make_handler(ui_tx, make_transport().await, GRACE).with_chat_roster(roster.clone());
let peer = fake_peer();
roster.upsert(peer, "Victim");
// Link up, then drop: DURING the grace window the peer is still a member —
// its chat must keep rendering under its roster name.
h.handle(ConnEvent::Connected(peer)).await;
h.handle(ConnEvent::Connecting(peer)).await;
assert_eq!(
roster.name_of(&peer),
Some("Victim".to_string()),
"reconnect grace must NOT revoke chat authority"
);
// Once the grace expires and the eviction fires, chat authority goes too.
assert!(
evicted_within(&mut ui_rx, peer, GRACE * 4).await,
"the outage should evict once the grace window elapses"
);
assert_eq!(
roster.name_of(&peer),
None,
"terminal eviction must revoke the roster-bound chat name"
);
}
+589
View File
@@ -0,0 +1,589 @@
//! S2 exit gate: a pixelpass host that dies mid-share must be torn down —
//! reaped, pulled off presence, `ScreenShareStopped` emitted **before** the
//! explanatory error — and a host stopped *deliberately* must NOT produce that
//! error when its stdout EOF arrives late (the staleness gate).
//!
//! Drives the real core loop end to end through `CoreController`, with the
//! pixelpass override pointed at fake shell scripts: one that emits a ticket
//! and dies, one that emits a ticket and lives until signalled. This is the
//! only harness that reaches the core's fault handler — the command loop has
//! no unit seam — so these two halves are what kill the "forwarder drops the
//! Eof" and "handler ignores the generation" mutants.
//!
//! Live: joins a real (solo) room, so it needs a working audio backend and
//! network access for the endpoint bind.
//! `cargo test --test screenshare_host_fault -- --ignored`
#![cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::time::Duration;
use peerspeak::core::CoreController;
use peerspeak::core::messages::{CoreCommand, UiEvent};
const EVENT_TIMEOUT: Duration = Duration::from_secs(20);
/// How long to listen for events that must NOT arrive. Comfortably past the
/// fake host's exit plus the drain/forwarder hop, so a stale fault that WOULD
/// be mishandled has arrived by the end of it.
const QUIET_WINDOW: Duration = Duration::from_secs(3);
/// Removes the fake-pixelpass dir even when an assertion panics mid-test
/// (a plain trailing `remove_dir_all` never runs on an unwind).
struct TempDir(PathBuf);
impl Drop for TempDir {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).ok();
}
}
fn write_fake_pixelpass(dir: &std::path::Path, name: &str, body: &str) -> PathBuf {
let path = dir.join(name);
std::fs::write(&path, body).expect("write fake pixelpass");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
.expect("chmod fake pixelpass");
path
}
/// Skip events until `pick` matches, panicking after [`EVENT_TIMEOUT`].
/// Unrelated events (identity, presence, chat plumbing) flow on this channel
/// too, so gates scan rather than assert exact sequences.
async fn wait_for<T>(
rx: &mut tokio::sync::mpsc::Receiver<UiEvent>,
what: &str,
mut pick: impl FnMut(&UiEvent) -> Option<T>,
) -> T {
let deadline = tokio::time::Instant::now() + EVENT_TIMEOUT;
loop {
let ev = tokio::time::timeout_at(deadline, rx.recv())
.await
.unwrap_or_else(|_| panic!("timed out waiting for {what}"))
.unwrap_or_else(|| panic!("ui channel closed waiting for {what}"));
if let Some(v) = pick(&ev) {
return v;
}
}
}
#[tokio::test]
#[ignore = "live: joins a real solo room (audio backend + network bind)"]
async fn a_dead_host_is_torn_down_and_a_clean_stop_stays_clean() {
let dir_guard =
TempDir(std::env::temp_dir().join(format!("peerspeak-hostfault-{}", std::process::id())));
let dir = dir_guard.0.clone();
std::fs::create_dir_all(&dir).unwrap();
// Half 1's host: emits its ticket, then dies on its own — the S2 defect
// scenario. Plain `sleep` (no exec) so the shell itself exits and closes
// stdout with no orphan holding the pipe.
let dying_host = write_fake_pixelpass(
&dir,
"pixelpass-dies",
"#!/bin/sh\necho '{\"event\":\"ticket\",\"value\":\"fake-ticket-dies\"}'\nsleep 1\n",
);
// Half 2's host: lives until signalled. `exec` so the SIGINT from Stop
// Share hits the sleep itself — the process dies AND its stdout closes,
// which is exactly what makes the late Eof arrive and exercise the
// staleness gate rather than vacuously never sending a fault.
let living_host = write_fake_pixelpass(
&dir,
"pixelpass-lives",
"#!/bin/sh\necho '{\"event\":\"ticket\",\"value\":\"fake-ticket-lives\"}'\nexec sleep 600\n",
);
let (ui_tx, mut ui_rx) = tokio::sync::mpsc::channel(256);
let controller = CoreController::new(ui_tx);
assert!(controller.send(CoreCommand::SetPixelpassPath(Some(
dying_host.to_string_lossy().into_owned()
))));
assert!(controller.send(CoreCommand::Join {
name: "host-fault-gate".into(),
ticket: "create".into(),
room_name: "s2".into(),
input_device: None,
output_device: None,
echo_cancellation: false,
avatar: Default::default(),
}));
wait_for(&mut ui_rx, "RoomJoined", |ev| match ev {
UiEvent::RoomJoined { .. } => Some(()),
UiEvent::Error(e) => panic!("join failed: {e}"),
_ => None,
})
.await;
// ── Half 1: the host dies mid-share ─────────────────────────────────────
assert!(controller.send(CoreCommand::StartScreenShare {
audio_app: None,
settings: Default::default(),
quality: Default::default(),
}));
wait_for(
&mut ui_rx,
"ScreenShareStarted (dying host)",
|ev| match ev {
UiEvent::ScreenShareStarted => Some(()),
UiEvent::Error(e) => panic!("share start failed: {e}"),
_ => None,
},
)
.await;
// The fake host exits ~1s in. The contract: ScreenShareStopped FIRST (it
// clears the UI's sharing state), the explanatory error only after.
wait_for(&mut ui_rx, "ScreenShareStopped after host death", |ev| {
match ev {
UiEvent::ScreenShareStopped => Some(()),
// An error arriving first is the exact ordering defect S2 fixes:
// the UI would show "sharing" next to the explanation.
UiEvent::Error(e) => panic!("error arrived before ScreenShareStopped: {e}"),
_ => None,
}
})
.await;
let err = wait_for(&mut ui_rx, "the host-death error", |ev| match ev {
UiEvent::Error(e) => Some(e.clone()),
_ => None,
})
.await;
assert!(
err.contains("unexpectedly"),
"the error should say the share ended unexpectedly, got: {err}"
);
// ── Half 2: a deliberate stop must stay clean ───────────────────────────
assert!(controller.send(CoreCommand::SetPixelpassPath(Some(
living_host.to_string_lossy().into_owned()
))));
assert!(controller.send(CoreCommand::StartScreenShare {
audio_app: None,
settings: Default::default(),
quality: Default::default(),
}));
wait_for(
&mut ui_rx,
"ScreenShareStarted (living host)",
|ev| match ev {
UiEvent::ScreenShareStarted => Some(()),
UiEvent::Error(e) => panic!("second share start failed: {e}"),
_ => None,
},
)
.await;
assert!(controller.send(CoreCommand::StopScreenShare));
wait_for(
&mut ui_rx,
"ScreenShareStopped after Stop Share",
|ev| match ev {
UiEvent::ScreenShareStopped => Some(()),
UiEvent::Error(e) => panic!("clean stop produced an error: {e}"),
_ => None,
},
)
.await;
// The stopped host's stdout EOF is arriving about now as a *stale* fault
// (its generation was retired when Stop Share cleared the share). Without
// the staleness gate the handler would emit a second ScreenShareStopped
// and a spurious "ended unexpectedly" error — listen long enough for that
// mishandling to have shown up, and require silence.
let deadline = tokio::time::Instant::now() + QUIET_WINDOW;
while let Ok(Some(ev)) = tokio::time::timeout_at(deadline, ui_rx.recv()).await {
match ev {
UiEvent::ScreenShareStopped => {
panic!("stale host fault re-emitted ScreenShareStopped after a clean stop")
}
UiEvent::Error(e) if e.contains("unexpectedly") => {
panic!("stale host fault surfaced as an error after a clean stop: {e}")
}
_ => {}
}
}
// ── Half 3: a failed room switch while sharing must not cry "crash" ─────
// Join tears the old session down (killing the host, deliberately) BEFORE
// it validates the ticket, so an invalid ticket exits the Join arm early.
// The share must be retired at the teardown itself — left advertised, the
// killed host's EOF passes the staleness gate and a spurious "ended
// unexpectedly" lands on top of the ticket error (Gemini review, P2-1).
assert!(controller.send(CoreCommand::StartScreenShare {
audio_app: None,
settings: Default::default(),
quality: Default::default(),
}));
wait_for(
&mut ui_rx,
"ScreenShareStarted (before failed switch)",
|ev| match ev {
UiEvent::ScreenShareStarted => Some(()),
UiEvent::Error(e) => panic!("third share start failed: {e}"),
_ => None,
},
)
.await;
assert!(controller.send(CoreCommand::Join {
name: "host-fault-gate".into(),
ticket: "definitely-not-a-ticket".into(),
room_name: "s2".into(),
input_device: None,
output_device: None,
echo_cancellation: false,
avatar: Default::default(),
}));
wait_for(&mut ui_rx, "the invalid-ticket error", |ev| match ev {
UiEvent::Error(e) if e.contains("invalid room ticket") => Some(()),
UiEvent::Error(e) => panic!("unexpected error before the ticket error: {e}"),
_ => None,
})
.await;
// The deliberately-killed host's EOF is arriving about now; it must be
// dropped as stale, not reported as a crash.
let deadline = tokio::time::Instant::now() + QUIET_WINDOW;
while let Ok(Some(ev)) = tokio::time::timeout_at(deadline, ui_rx.recv()).await {
match ev {
UiEvent::ScreenShareStopped => {
panic!("failed room switch re-emitted ScreenShareStopped for the torn-down share")
}
UiEvent::Error(e) if e.contains("unexpectedly") => {
panic!("deliberate teardown during a failed room switch reported as a crash: {e}")
}
_ => {}
}
}
}
/// S2 presence gate: a host fault must pull the share ticket off PRESENCE —
/// what remote peers actually see — and must do it BEFORE the reap wait, not
/// after. Nothing on the sharer's own `UiEvent` channel can witness either
/// half (presence is only observable from another node), so this test runs a
/// real second core as an OBSERVER and asserts the sharer's `PeerState.sharing`
/// goes `Some` → `None` on fault.
///
/// The observer runs in a SEPARATE PROCESS (`presence_probe_helper`, this same
/// test binary re-invoked): two in-process cores would load the same
/// `identity.key` and collapse into one node id, and swapping `XDG_CONFIG_HOME`
/// between spawns in-process races other threads' getenv.
///
/// The fake host is a WEDGE — it closes stdout (the fault) but ignores SIGINT
/// and lives until the SIGKILL fallback — so `stop_host` burns the full 2 s
/// grace and TIME becomes the discriminator, exactly like the SIGINT gate:
/// with presence-removal-first the observer sees the ticket clear ~1 s after
/// it appeared (the wedge's pre-fault lifetime); with the old
/// reap-then-presence ordering, only after ~3 s. The bound also makes the
/// "presence removal deleted" mutant fail by timeout instead of passing
/// vacuously.
///
/// Live: two real solo-room cores (audio backend + network bind each).
#[tokio::test]
#[ignore = "live: two real cores in one room (audio backend + network bind), observer subprocess"]
async fn a_host_fault_pulls_the_ticket_off_presence_within_the_grace() {
/// Mirrors `core::teardown::STOP_GRACE` (private): the wait the wedge
/// forces before the SIGKILL fallback reaps it.
const STOP_GRACE_MS: u128 = 2000;
let dir_guard = TempDir(
std::env::temp_dir().join(format!("peerspeak-presence-gate-{}", std::process::id())),
);
let dir = dir_guard.0.clone();
std::fs::create_dir_all(&dir).unwrap();
// Emits its ticket, shares for ~1 s, then closes stdout (the fault) while
// staying alive and ignoring SIGINT, so the reap must wait out the grace.
// The trailing sleep is NOT exec'd on purpose: it forks after stdout is
// closed, so it holds no pipe (the vacuous-staleness trap doesn't apply),
// and it merely idles out after the SIGKILL reaps the shell.
//
// The fake ticket must pass `screenshare::sanitize_ticket` (`endpoint` +
// alphanumerics): the OBSERVER's gossip ingest sanitizes peer-advertised
// tickets, and a garbage one is nulled to `sharing: None` there — the
// probe would never see the share appear and the gate would go vacuous.
let wedged_host = write_fake_pixelpass(
&dir,
"pixelpass-wedges",
"#!/bin/sh\ntrap '' INT\n\
echo '{\"event\":\"ticket\",\"value\":\"endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm\"}'\n\
sleep 1\nexec 1>&-\nsleep 30\n",
);
let (ui_tx, mut ui_rx) = tokio::sync::mpsc::channel(256);
let controller = CoreController::new(ui_tx);
assert!(controller.send(CoreCommand::SetPixelpassPath(Some(
wedged_host.to_string_lossy().into_owned()
))));
assert!(controller.send(CoreCommand::Join {
name: "presence-gate".into(),
ticket: "create".into(),
room_name: "s2-presence".into(),
input_device: None,
output_device: None,
echo_cancellation: false,
avatar: Default::default(),
}));
let room_ticket = wait_for(&mut ui_rx, "RoomJoined", |ev| match ev {
UiEvent::RoomJoined { ticket, .. } => Some(ticket.clone()),
UiEvent::Error(e) => panic!("join failed: {e}"),
_ => None,
})
.await;
// The observer, in its own process with its own config dir (fresh
// identity). It prints `PROBE …` lines this test parses.
let probe_config = dir.join("probe-config");
std::fs::create_dir_all(&probe_config).unwrap();
let probe = tokio::process::Command::new(std::env::current_exe().unwrap())
.kill_on_drop(true)
.args([
"presence_probe_helper",
"--exact",
"--ignored",
"--nocapture",
])
.env("PEERSPEAK_PROBE_TICKET", &room_ticket)
.env("XDG_CONFIG_HOME", &probe_config)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("spawn the presence probe");
// Only share once the probe is in the room, so it witnesses the ticket
// APPEARING before the fault clears it (otherwise `Some` → `None` could
// both predate its join and the gate would go vacuous).
wait_for(&mut ui_rx, "the probe's PeerJoined", |ev| match ev {
UiEvent::PeerJoined { .. } => Some(()),
UiEvent::Error(e) => panic!("waiting for the probe: {e}"),
_ => None,
})
.await;
assert!(controller.send(CoreCommand::StartScreenShare {
audio_app: None,
settings: Default::default(),
quality: Default::default(),
}));
wait_for(
&mut ui_rx,
"ScreenShareStarted (wedged host)",
|ev| match ev {
UiEvent::ScreenShareStarted => Some(()),
UiEvent::Error(e) => panic!("share start failed: {e}"),
_ => None,
},
)
.await;
// Sharer-side contract, unchanged by the reorder: Stopped first, the
// explanatory error only after.
wait_for(
&mut ui_rx,
"ScreenShareStopped after the wedge faults",
|ev| match ev {
UiEvent::ScreenShareStopped => Some(()),
UiEvent::Error(e) => panic!("error arrived before ScreenShareStopped: {e}"),
_ => None,
},
)
.await;
let err = wait_for(&mut ui_rx, "the host-death error", |ev| match ev {
UiEvent::Error(e) => Some(e.clone()),
_ => None,
})
.await;
assert!(
err.contains("unexpectedly"),
"the error should say the share ended unexpectedly, got: {err}"
);
let out = tokio::time::timeout(Duration::from_secs(60), probe.wait_with_output())
.await
.expect("probe process outlived its budget")
.expect("probe process wait");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"probe failed ({}).\nstdout:\n{stdout}\nstderr:\n{stderr}",
out.status
);
let cleared_ms: u128 = stdout
.lines()
.find_map(|l| l.strip_prefix("PROBE sharing-cleared "))
.unwrap_or_else(|| {
panic!("probe never saw the ticket clear from presence.\nstdout:\n{stdout}")
})
.trim()
.parse()
.expect("probe delta should be integer millis");
// Presence-removal-first: ~1000 ms (the wedge's pre-fault lifetime).
// Reap-then-presence: ~3000 ms (lifetime + the full stop grace). The
// grace itself splits them with ~1 s of jitter headroom on each side.
assert!(
cleared_ms < STOP_GRACE_MS,
"presence kept advertising the dead share for {cleared_ms} ms after it appeared — \
at or past the wedge lifetime + stop grace, i.e. the ticket was only removed \
AFTER the reap wait instead of before it"
);
assert!(controller.send(CoreCommand::Leave));
}
/// Observer half of `a_host_fault_pulls_the_ticket_off_presence_within_the_grace`,
/// run BY that test as a subprocess. Standalone (no `PEERSPEAK_PROBE_TICKET` in
/// the env — e.g. a plain `--ignored` sweep) it is a no-op pass.
#[tokio::test]
#[ignore = "helper: spawned by the presence gate as a subprocess; standalone it no-ops"]
async fn presence_probe_helper() {
let Ok(room_ticket) = std::env::var("PEERSPEAK_PROBE_TICKET") else {
return;
};
let (ui_tx, mut ui_rx) = tokio::sync::mpsc::channel(256);
let controller = CoreController::new(ui_tx);
assert!(controller.send(CoreCommand::Join {
name: "presence-probe".into(),
ticket: room_ticket,
room_name: String::new(),
input_device: None,
output_device: None,
echo_cancellation: false,
avatar: Default::default(),
}));
wait_for(&mut ui_rx, "RoomJoined (probe)", |ev| match ev {
UiEvent::RoomJoined { .. } => Some(()),
UiEvent::Error(e) => panic!("probe join failed: {e}"),
_ => None,
})
.await;
// Watch the sharer's presence: record when its `sharing` ticket appears,
// report the delta when it clears. Timings on both ends are local-loopback
// arrival times, so the parent's bound compares like with like.
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
let mut seen_at: Option<std::time::Instant> = None;
loop {
let ev = tokio::time::timeout_at(deadline, ui_rx.recv())
.await
.expect("probe timed out watching for the sharing transition")
.expect("probe ui channel closed");
let sharing = match &ev {
UiEvent::PeerJoined { state, .. } | UiEvent::PeerUpdated { state, .. } => {
state.sharing.is_some()
}
_ => continue,
};
match (&seen_at, sharing) {
(None, true) => {
seen_at = Some(std::time::Instant::now());
println!("PROBE sharing-seen");
}
(Some(t0), false) => {
println!("PROBE sharing-cleared {}", t0.elapsed().as_millis());
break;
}
_ => {}
}
}
assert!(controller.send(CoreCommand::Leave));
}
/// The long-owed Stop Share SIGINT gate (0c half (ii)), against the REAL
/// pixelpass binary: a Stop Share must end the host through the graceful
/// SIGINT path — child exits within [`STOP_GRACE`], no SIGKILL fallback, no
/// "couldn't confirm" warning — because SIGKILL would skip pixelpass's own
/// teardown (it unloads its capture sink on the way out in sink-owning modes).
///
/// The fallback is indistinguishable from success in the event stream (both
/// end in a confirmed reap), so the discriminator is TIME: the fallback path
/// first waits out the full 2 s grace, while a host honouring SIGINT exits in
/// milliseconds. The bound asserts the stop completed inside the grace.
///
/// Live: needs `pixelpass` on `$PATH` plus a real solo room (audio + network).
#[tokio::test]
#[ignore = "live: real pixelpass host + a real solo room (audio backend, network bind)"]
async fn stop_share_ends_the_real_host_via_sigint_within_the_grace() {
/// Mirrors `core::teardown::STOP_GRACE` (private): the graceful wait
/// before the SIGKILL fallback.
const STOP_GRACE: Duration = Duration::from_secs(2);
let (ui_tx, mut ui_rx) = tokio::sync::mpsc::channel(256);
let controller = CoreController::new(ui_tx);
// No override: resolve the real binary from $PATH.
assert!(controller.send(CoreCommand::SetPixelpassPath(None)));
assert!(controller.send(CoreCommand::Join {
name: "sigint-gate".into(),
ticket: "create".into(),
room_name: "s2".into(),
input_device: None,
output_device: None,
echo_cancellation: false,
avatar: Default::default(),
}));
wait_for(&mut ui_rx, "RoomJoined", |ev| match ev {
UiEvent::RoomJoined { .. } => Some(()),
UiEvent::Error(e) => panic!("join failed: {e}"),
_ => None,
})
.await;
// Whole-desktop share: no viewers ever connect, so the real host sits idle
// after its ticket (capture starts on first viewer) — exactly the state a
// Stop Share most often hits.
assert!(controller.send(CoreCommand::StartScreenShare {
audio_app: None,
settings: Default::default(),
quality: Default::default(),
}));
wait_for(
&mut ui_rx,
"ScreenShareStarted (real pixelpass)",
|ev| match ev {
UiEvent::ScreenShareStarted => Some(()),
UiEvent::Error(e) => panic!("real pixelpass host failed to start: {e}"),
_ => None,
},
)
.await;
let stop_started = std::time::Instant::now();
assert!(controller.send(CoreCommand::StopScreenShare));
wait_for(
&mut ui_rx,
"ScreenShareStopped (real pixelpass)",
|ev| match ev {
UiEvent::ScreenShareStopped => Some(()),
// An Unconfirmed reap surfaces exactly this way; it means the
// SIGINT AND the SIGKILL both failed to end the host.
UiEvent::Error(e) => panic!("stop of the real host was not clean: {e}"),
_ => None,
},
)
.await;
let elapsed = stop_started.elapsed();
assert!(
elapsed < STOP_GRACE,
"stop took {elapsed:?} — at or past the {STOP_GRACE:?} grace, i.e. the \
SIGKILL fallback fired instead of pixelpass honouring SIGINT"
);
// And the late stdout EOF from the SIGINTed host must stay silent (same
// staleness contract the fake-host half pins).
let deadline = tokio::time::Instant::now() + QUIET_WINDOW;
while let Ok(Some(ev)) = tokio::time::timeout_at(deadline, ui_rx.recv()).await {
match ev {
UiEvent::ScreenShareStopped => {
panic!("stale fault from the SIGINTed real host re-emitted ScreenShareStopped")
}
UiEvent::Error(e) if e.contains("unexpectedly") => {
panic!("stale fault from the SIGINTed real host surfaced as an error: {e}")
}
_ => {}
}
}
assert!(controller.send(CoreCommand::Leave));
}
+211 -11
View File
@@ -23,8 +23,8 @@ use iroh::protocol::{AcceptError, ProtocolHandler};
use peerspeak::codec::AudioEncoder; use peerspeak::codec::AudioEncoder;
use peerspeak::codec::opus_impl::OpusEncoder; use peerspeak::codec::opus_impl::OpusEncoder;
use peerspeak::core::jitter::{FRAME_SAMPLES, JitterBuffer}; use peerspeak::core::jitter::{FRAME_SAMPLES, JitterBuffer};
use peerspeak::network::{ConnEvent, NetworkTransport};
use peerspeak::network::iroh_impl::{AudioRouter, IrohTransport}; use peerspeak::network::iroh_impl::{AudioRouter, IrohTransport};
use peerspeak::network::{ConnEvent, NetworkTransport};
use peerspeak::protocol::AUDIO_ALPN; use peerspeak::protocol::AUDIO_ALPN;
struct Node { struct Node {
@@ -91,7 +91,12 @@ async fn spawn_capture_peer(secret: iroh::SecretKey) -> CapturePeer {
.accept(AUDIO_ALPN, CaptureProtocol { conns_tx }) .accept(AUDIO_ALPN, CaptureProtocol { conns_tx })
.spawn(); .spawn();
CapturePeer { endpoint, _router: router, lookup, conns_rx } CapturePeer {
endpoint,
_router: router,
lookup,
conns_rx,
}
} }
/// Spawn a node with a specific secret key. Reusing a key gives the respawned /// Spawn a node with a specific secret key. Reusing a key gives the respawned
@@ -208,7 +213,11 @@ async fn loopback_sequenced_audio_reaches_peer_and_decodes() {
received += 1; received += 1;
if let Some(frame) = jitter.pop_frame() { if let Some(frame) = jitter.pop_frame() {
assert_eq!(frame.len(), FRAME_SAMPLES, "decoded frame is one 20ms frame"); assert_eq!(
frame.len(),
FRAME_SAMPLES,
"decoded frame is one 20ms frame"
);
decoded_frames += 1; decoded_frames += 1;
} }
if received >= N { if received >= N {
@@ -235,6 +244,73 @@ async fn loopback_sequenced_audio_reaches_peer_and_decodes() {
); );
} }
/// Connection transparency: over a real loopback link, `connection_stats()`
/// must report the peer's selected path as direct (relay disabled here), with
/// an IP remote address and counters that advance while audio flows — and the
/// `connstats::derive` seam must turn two such snapshots into badge info with
/// live rates.
#[tokio::test]
async fn connection_stats_report_a_direct_path_with_live_counters() {
let a = spawn_node().await;
let b = spawn_node().await;
a.lookup.add_endpoint_info(b.endpoint.addr());
b.lookup.add_endpoint_info(a.endpoint.addr());
let a_id = a.endpoint.id();
let b_id = b.endpoint.id();
a.transport.admit_audio_sender(b_id);
b.transport.admit_audio_sender(a_id);
// Keep B's receive path subscribed like production (drained implicitly).
let _b_rx = b.transport.receive_datagrams().await.expect("subscribe B");
a.transport.connect_peer(b.endpoint.addr()).await;
b.transport.connect_peer(a.endpoint.addr()).await;
tokio::time::sleep(Duration::from_millis(500)).await;
let snap = |stats: Vec<(iroh::EndpointId, peerspeak::network::PathSnapshot)>| {
stats
.into_iter()
.find(|(id, _)| *id == b_id)
.map(|(_, s)| s)
.expect("peer B should appear in A's connection stats")
};
let s1 = snap(a.transport.connection_stats());
assert!(!s1.is_relay, "loopback with relay disabled must be direct");
assert!(
s1.remote_addr.parse::<std::net::SocketAddr>().is_ok(),
"direct path address should be ip:port, got {}",
s1.remote_addr
);
// Stream real audio so the path counters move.
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
for seq in 0..25u32 {
a.transport.broadcast(packet(&mut enc, seq));
tokio::time::sleep(Duration::from_millis(5)).await;
}
let s2 = snap(a.transport.connection_stats());
assert!(s2.tx_bytes > s1.tx_bytes, "sent bytes should advance");
assert!(
s2.tx_datagrams > s1.tx_datagrams,
"sent datagrams should advance"
);
// The derivation seam turns the two snapshots into live badge info.
let info = peerspeak::core::connstats::derive(Some(&s1), &s2, Duration::from_millis(200));
assert!(!info.relay);
assert_eq!(info.remote_addr, s2.remote_addr);
assert!(info.rtt_ms < 1000, "localhost RTT should be sane");
assert!(
info.up_kbps
.expect("same path + positive window has a rate")
> 0.0,
"audio was flowing, so the upstream rate must be non-zero"
);
}
/// Read datagrams off a raw connection until `target` arrive or the deadline /// Read datagrams off a raw connection until `target` arrive or the deadline
/// passes, asserting each carries the 4-byte sequence header. /// passes, asserting each carries the 4-byte sequence header.
async fn count_audio(conn: &Connection, target: u32, deadline: tokio::time::Instant) -> u32 { async fn count_audio(conn: &Connection, target: u32, deadline: tokio::time::Instant) -> u32 {
@@ -290,7 +366,11 @@ async fn dialer_reconnects_after_link_drops() {
.expect("timed out awaiting initial connection") .expect("timed out awaiting initial connection")
.expect("connection channel closed"); .expect("connection channel closed");
assert!( assert!(
await_reconnect(&mut conn_events, tokio::time::Instant::now() + Duration::from_secs(10)).await, await_reconnect(
&mut conn_events,
tokio::time::Instant::now() + Duration::from_secs(10)
)
.await,
"initial link should report Connecting then Connected" "initial link should report Connecting then Connected"
); );
@@ -306,7 +386,11 @@ async fn dialer_reconnects_after_link_drops() {
.expect("timed out awaiting reconnect") .expect("timed out awaiting reconnect")
.expect("connection channel closed"); .expect("connection channel closed");
assert!( assert!(
await_reconnect(&mut conn_events, tokio::time::Instant::now() + Duration::from_secs(15)).await, await_reconnect(
&mut conn_events,
tokio::time::Instant::now() + Duration::from_secs(15)
)
.await,
"dropped link should report Connecting (down) then Connected (recovered)" "dropped link should report Connecting (down) then Connected (recovered)"
); );
@@ -319,7 +403,12 @@ async fn dialer_reconnects_after_link_drops() {
tokio::time::sleep(Duration::from_millis(5)).await; tokio::time::sleep(Duration::from_millis(5)).await;
} }
let received = count_audio(&conn2, 25, tokio::time::Instant::now() + Duration::from_secs(3)).await; let received = count_audio(
&conn2,
25,
tokio::time::Instant::now() + Duration::from_secs(3),
)
.await;
assert!( assert!(
received >= 20, received >= 20,
"audio should resume after reconnect; got {received} frames" "audio should resume after reconnect; got {received} frames"
@@ -359,7 +448,11 @@ async fn dialer_reports_left_on_graceful_close() {
.expect("timed out awaiting initial connection") .expect("timed out awaiting initial connection")
.expect("connection channel closed"); .expect("connection channel closed");
assert!( assert!(
await_reconnect(&mut conn_events, tokio::time::Instant::now() + Duration::from_secs(10)).await, await_reconnect(
&mut conn_events,
tokio::time::Instant::now() + Duration::from_secs(10)
)
.await,
"initial link should report Connecting then Connected" "initial link should report Connecting then Connected"
); );
@@ -384,7 +477,10 @@ async fn dialer_reports_left_on_graceful_close() {
// And no re-dial reaches the peer within a short window. // And no re-dial reaches the peer within a short window.
let redial = tokio::time::timeout(Duration::from_secs(2), peer.conns_rx.recv()).await; let redial = tokio::time::timeout(Duration::from_secs(2), peer.conns_rx.recv()).await;
assert!(redial.is_err(), "supervisor must not re-dial after a graceful leave"); assert!(
redial.is_err(),
"supervisor must not re-dial after a graceful leave"
);
} }
#[tokio::test] #[tokio::test]
@@ -429,7 +525,11 @@ async fn dialer_connects_and_reconnects_without_an_address_lookup() {
.expect("timed out awaiting initial connection (retained address path)") .expect("timed out awaiting initial connection (retained address path)")
.expect("connection channel closed"); .expect("connection channel closed");
assert!( assert!(
await_reconnect(&mut conn_events, tokio::time::Instant::now() + Duration::from_secs(10)).await, await_reconnect(
&mut conn_events,
tokio::time::Instant::now() + Duration::from_secs(10)
)
.await,
"initial link should report Connecting then Connected" "initial link should report Connecting then Connected"
); );
@@ -443,7 +543,11 @@ async fn dialer_connects_and_reconnects_without_an_address_lookup() {
.expect("timed out awaiting reconnect (retained address path)") .expect("timed out awaiting reconnect (retained address path)")
.expect("connection channel closed"); .expect("connection channel closed");
assert!( assert!(
await_reconnect(&mut conn_events, tokio::time::Instant::now() + Duration::from_secs(15)).await, await_reconnect(
&mut conn_events,
tokio::time::Instant::now() + Duration::from_secs(15)
)
.await,
"reconnect should report Connecting then Connected with no lookup at all" "reconnect should report Connecting then Connected with no lookup at all"
); );
@@ -455,9 +559,105 @@ async fn dialer_connects_and_reconnects_without_an_address_lookup() {
tokio::time::sleep(Duration::from_millis(5)).await; tokio::time::sleep(Duration::from_millis(5)).await;
} }
let received = count_audio(&conn2, 25, tokio::time::Instant::now() + Duration::from_secs(3)).await; let received = count_audio(
&conn2,
25,
tokio::time::Instant::now() + Duration::from_secs(3),
)
.await;
assert!( assert!(
received >= 20, received >= 20,
"audio should resume after reconnecting via the retained address; got {received} frames" "audio should resume after reconnecting via the retained address; got {received} frames"
); );
} }
/// A node that also serves the file plane (`FILES_ALPN`), mirroring how core
/// registers the `FileRouter` for a session.
async fn spawn_file_server() -> Node {
let lookup = MemoryLookup::new();
let endpoint = Endpoint::builder(presets::Minimal)
.secret_key(iroh::SecretKey::generate())
.relay_mode(RelayMode::Disabled)
.address_lookup(lookup.clone())
.bind()
.await
.expect("bind endpoint");
let transport = Arc::new(IrohTransport::new(endpoint.clone()));
let audio_router = AudioRouter::new();
audio_router.bind(&transport);
let file_router = peerspeak::network::iroh_impl::FileRouter::new();
file_router.bind(&transport);
let router = Router::builder(endpoint.clone())
.accept(AUDIO_ALPN, audio_router)
.accept(peerspeak::protocol::FILES_ALPN, file_router)
.spawn();
Node {
endpoint,
transport,
_router: router,
lookup,
}
}
/// Phase 3C: a file fetch must deliver EXACTLY the declared size — short,
/// overlong, and unknown-id transfers are all rejected with local errors, and
/// an exact transfer round-trips byte-identically.
#[tokio::test]
async fn file_plane_requires_exact_declared_size() {
let fetcher = spawn_node().await;
let server = spawn_file_server().await;
fetcher.lookup.add_endpoint_info(server.endpoint.addr());
server.lookup.add_endpoint_info(fetcher.endpoint.addr());
let server_id = server.endpoint.id();
// Member gating: the server only serves current room members.
server.transport.admit_audio_sender(fetcher.endpoint.id());
let blob = vec![42u8; 1000];
let id = [7u8; 32];
server
.transport
.serve_attachment(id, Arc::new(blob.clone()));
// Exact declared size: byte-identical round trip.
let got = fetcher
.transport
.fetch_blob(server_id, id, 1000)
.await
.expect("exact-size fetch succeeds");
assert_eq!(got, blob);
// Declared larger than served (short transfer): rejected, not cached as-is.
let err = fetcher
.transport
.fetch_blob(server_id, id, 2000)
.await
.expect_err("short transfer must fail");
assert!(
err.to_string().contains("incomplete transfer"),
"unexpected error: {err}"
);
// Declared smaller than served (overlong transfer): the bounded read
// rejects the stream rather than truncating it into a "valid" result.
let err = fetcher
.transport
.fetch_blob(server_id, id, 500)
.await
.expect_err("overlong transfer must fail");
assert!(
err.to_string().contains("read failed"),
"unexpected error: {err}"
);
// Unknown id: the empty body reads as the sender no longer having it.
let err = fetcher
.transport
.fetch_blob(server_id, [9u8; 32], 1000)
.await
.expect_err("unknown id must fail");
assert!(
err.to_string().contains("no longer has the file"),
"unexpected error: {err}"
);
}