Compare commits

...
Author SHA1 Message Date
molluskandClaude Opus 4.8 82e1740d3c QUARANTINE: Codex off-task presence rate-limiter (NOT A24, unreviewed)
Codex was assigned A24 (per-peer volume) but instead built a friends-listener
presence rate-limiter touching the fenced security surface (presence.rs, the
friends listener in core, contacts-plan.md). Preserved here for later review as
a possible W7 hardening item; NOT merged to main, NOT the assigned task.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 02:39:34 -04:00
molluskandClaude Opus 4.8 4dc1bcd546 screenshare: surface pixelpass stderr on host startup failure
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
When a pixelpass host dies before emitting its ticket (e.g. a fresh Ubuntu
box missing the GStreamer capture stack), we discarded its stderr — which is
exactly where pixelpass prints the failing element plus an actionable
"Install hint: sudo apt install ..." line. The user only saw a dead-end
"pixelpass host exited before emitting a ticket".

Capture stderr instead of nulling it, and on the early-exit path fold a short
tail into our error via the pure `pixelpass_failure_detail` (drops the
decorative banner, keeps the error + hint, caps at the last 12 lines). On the
success path stderr is drained in the background so its pipe can't stall the
host. +3 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 23:10:16 -04:00
molluskandClaude Opus 4.8 067997f9ba screenshare: action A23 P2/P3 follow-up audit (capability probe, event guard, pactl kill)
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Triage of Codex's a23-p2-audit-2026-06-26 (the strict-audio fix itself verified
sound — no path reloads the desktop loopback). Four findings actioned:

- P2 (F1, silent-from-start): pair pixelpass's new initial `app_audio:lost`
  with a reworded warning ("Shared app isn't sending audio — viewers hear
  silence until it plays") so an app share that never produced audio is flagged,
  not just one that stopped.
- P2 (version skew): probe `pixelpass --help` for `--strict-audio` before
  offering per-app capture (pure `help_mentions_strict_audio` + timeout-bounded,
  kill-on-drop `supports_strict_audio`). An older pixelpass would have clap
  reject the flag and hard-fail the share; now the picker degrades to
  whole-desktop only with an upgrade nudge. Never a silent best-effort `--app`
  fallback (that reopens A23). AudioAppsListed now carries `app_audio_supported`.
- P3 (pactl child leak): list_audio_apps' enumeration child is now
  kill_on_drop, so a wedged pactl is reaped on the 2s timeout.
- P3 (unscoped events): apply ShareAudioActive only while actually sharing a
  specific app (`share_audio_app_active`), so a late event from a just-killed
  host can't flip the warning after stop or on a whole-desktop share.

+4 tests (probe parse, post-stop/whole-desktop guard, old-pixelpass picker
degrade), updated reset + app_audio tracking tests. 440 lib tests, clippy
--all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:09:58 -04:00
molluskandClaude Opus 4.8 660eb27a84 packaging: add cargo-deb metadata for Debian/Ubuntu .deb builds
Mirror packaging/PKGBUILD as a [package.metadata.deb] block so a Debian/
Ubuntu .deb can be produced with `cargo deb` from inside a matching
distrobox (glibc is forward-compatible only, so the build base sets the
minimum target). Ships only the `peerspeak` binary (not test_net/specview),
the desktop entry, and the full hicolor icon set; runtime shared-lib deps
are resolved by dpkg-shlibdeps. Recommends pixelpass + mpv. Also add the
missing [package].description (cargo-deb requires a synopsis) and roll the
stale PKGBUILD pkgver fallback to the current 0.4.0 tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:30:17 -04:00
molluskandClaude Opus 4.8 913b0b6b20 feat(screenshare): pass --strict-audio + surface app_audio drop warning (A23 P2)
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Consumes the new pixelpass --strict-audio mode + app_audio events (pixelpass
85fdebe) to close Codex's A23 P2: the per-app pick alone was best-effort —
pixelpass would fall back to whole-desktop audio before the app routed and again
if it stopped, both reintroducing the call echo.

- host_args appends --strict-audio alongside --app=<name>, so pixelpass never
  mirrors the desktop for our share: viewers hear only the chosen app or silence.
- Parse the app_audio JSON event (routed/lost) into PixelpassEvent; spawn_host
  takes an optional notices channel and the stdout drain forwards events on it.
- Core spawns a forwarder (only when an app is selected) mapping routed/lost to
  UiEvent::ShareAudioActive(bool); the channel/task self-terminate on host EOF.
- App tracks share_audio_dropped and shows a transient warning under the "Sharing
  your screen" badge when the chosen app's audio stops ("viewers hear silence
  until it plays again"). Reset on start/stop/room-leave.

In-process mpsc only — no wire/GOSSIP_PROTO change. 435 lib tests (+2: app_audio
parse + the dropped-flag state machine), clippy --all-targets + release clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:54:06 -04:00
molluskandClaude Opus 4.8 36fb8bfa9a fix(screenshare): address Codex A23 audit P3s — picker state machine, enum parity, pactl timeout
Triage of Codex's read-only A23 audit (a23-audit-2026-06-26.md). The P2 (pixelpass
--app best-effort fallback) is cross-repo and deferred to a design decision; these
are the three actionable peerspeak-side P3s:

- P3-1: guard the share-startup window. New `share_starting` flag blocks reopening
  the picker (and re-firing StartScreenShare) between ConfirmShareScreen and the
  core's ScreenShareStarted; cleared on Started/Stopped/Error so a failed spawn
  (surfaced as Error, not Stopped) can't wedge it. +2 state-machine tests.
- P3-2: parse_audio_apps now runs each name through sanitize_app_name, so the
  picker never offers a name that host_args would later silently drop (which would
  revert the share to whole-desktop audio = the A23 echo, with no signal). +1 test.
- P3-3: list_audio_apps wraps pactl in a 2s timeout so a wedged enumeration can't
  stall the core command loop (mute/deafen/leave/stop) while the picker opens.

433 lib tests (+3), clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:37:49 -04:00
molluskandClaude Opus 4.8 2e9164745f fix(screenshare): per-app audio capture to kill call-loopback echo (A23)
Screen sharing spawned pixelpass with a bare `--host`, so pixelpass
captured the whole default-sink monitor — which contains peerspeak's own
call playout. A viewer therefore heard their OWN voice echoed back out of
the sharer's machine (confirmed live, backlog A23).

pixelpass already supports `--app <NAME>` (capture only one app's audio,
per-app PipeWire routing); peerspeak just never passed it. This wires that
flag through, peerspeak-side only — no pixelpass change.

- screenshare: pure `host_args(audio_app)` builds the host argv, appending
  `--app=<name>` (single-token form so a hyphen-leading name can't be
  reparsed as a flag) when an app is chosen; `sanitize_app_name` guards the
  locally-chosen value; `list_audio_apps`/`parse_audio_apps` enumerate
  currently-playing apps via `pactl -f json list sink-inputs` (mirroring how
  pixelpass builds its own picker, so the names match what `--app` matches).
- core: `StartScreenShare { audio_app }` + `ListAudioApps`/`AudioAppsListed`.
- GUI: Share Screen now opens a small audio picker (radio-style modal) listing
  the playing apps + "All system audio" (warned, = legacy whole-desktop);
  picking one starts the share with `--app=<name>`. Reset on room leave.

+6 unit tests (host_args with/without/blank app, sanitize_app_name,
parse_audio_apps dedup + garbage). 430 lib tests, clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:18:49 -04:00
molluskandClaude Opus 4.8 3b640726d7 fix(security): address Codex F-02/F-12 audit — save-filename alias + doc nits
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Codex audit of 381e00b (f02-f12-audit-2026-06-26) found no P1/P2 regression
and verified the F-12 key round-trip invariant sound (iroh EndpointId
Display/FromStr are exact inverses). Acting on the one real P3 + nits:

- P3: save_attachment_task picked the dialog's DEFAULT FILENAME by bare
  attachment id, so a peer reusing a victim's id could mislabel the save
  with another sender's name/extension (bytes were already author-keyed and
  correct — this was a metadata residual, not content aliasing). Extracted a
  pure `attachment_default_name` that matches the full (author, id) key, like
  find_attachment_source. +1 unit test (closes the audit's coverage gap).
- Doc nits: refreshed the stale `attachment_data` reference on ChatEntry,
  the "keyed by attachment id" note on spawn_attachment_fetch, and a
  duplicated doc block above find_attachment_source.

DEFERRED (user decision pending): the P3 judgement call — pending_plays /
invalid_audio / clip playing_id stay bare-id keyed, so duplicate-id audio
rows share play/seek/invalid state (cosmetic; bytes played are still
author-keyed and correct). Fully closing it means threading AttachmentKey
through the clip player.

424 lib tests, clippy --all-targets clean, release build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:00:09 -04:00
molluskandClaude Opus 4.8 381e00bc0e fix(security): bound + author-key chat attachment cache (Tier C F-02/F-12)
The chat-attachment result cache (`attachment_data` + `image_handle_cache`)
was keyed by attachment id alone and only cleared on room-leave, so an
authenticated insider could (F-02) stream distinct attachments to grow it
without bound, and (F-12) reuse a victim's attachment id to alias displayed/
saved bytes — the id is attacker-chosen, so a signature only proves keypair
ownership, not a distinct human.

F-12: thread the author (`from: EndpointId`) back through the
`AttachmentReady`/`AttachmentFailed` core→UI events (the fetch task already
holds it) and key all attachment result state on `(author, id)`:
- new `AttachmentKey = (EndpointId, AttachmentId)`;
- `attachment_data` + `image_handle_cache` fold into one `AttachmentCache`;
- `pending_saves` and the `SaveAttachment`/`PlayAudio` messages re-keyed, so
  the save/fetch dispatch can't be redirected to the wrong sender's line;
- `find_attachment_source` now matches author AND id;
- the render path resolves each line's key from `ChatEntry.from`.

F-02: `AttachmentCache` is bounded (`ATTACHMENT_CACHE_CAP = 64`) with
insertion-order eviction. True LRU is impossible because iced's `view`
borrows `&self` and so can't reorder on a render read; the generous cap means
a normal session never evicts and the newest (on-screen) entries are always
retained — only an abusive stream hits the bound.

Deliberately id-keyed (cosmetic only, documented): the clip player's
`playing_id`, `pending_plays`, `invalid_audio` — they're coupled to the
id-keyed clip player, and the bytes actually played come from the
author-keyed cache, so content is always correct.

No gossip/wire/protocol change (UiEvent is in-process), no new deps. +6
unit tests (cache eviction, replace-keeps-position, same-id/distinct-author
non-aliasing, is_ready/handle/clear, cap-zero clamp). 423 lib tests,
clippy --all-targets clean, release build green. TESTS-GREEN-ONLY.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:58:26 -04:00
molluskandClaude Opus 4.8 1a3c481f4c fix(security): cap recovery-identity state (Tier C F-01 follow-up)
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Closes the remaining insider resource-exhaustion vector the Codex Tier C
audit flagged: the active-roster cap bounds the live peer map, but a member
could join (<=32), drop the link without a signed Leave, let the grace timer
expire, and repeat with a fresh identity. Each abandoned identity grew two
unbounded structures and kept doing periodic work forever:

  - known_peers[topic] (the retained rejoin/recovery dial table) was only
    pruned on a signed PeerLeft, so grace-evicted ghosts accumulated.
  - the recovery coordinator's active set + entries map had no identity cap
    and no terminal retry budget — backoff saturated at 60s and re-dialed a
    never-returning peer indefinitely.

Two non-breaking, dependency-free bounds (no wire/protocol change):

  - MAX_RETAINED_PEERS=64 per topic via pure admit_retained() — refreshing a
    tracked peer always succeeds, a brand-new identity is rejected when full.
    Set above MAX_ACTIVE_PEERS=32 so legitimate rooms never hit it.
  - RECOVERY_TERMINAL_ATTEMPTS=12 (~7 min) via pure recovery_is_terminal():
    the coordinator gives up, frees the active slot, and signals a new
    terminal channel; a small drain task forgets the retained address (so the
    table self-drains), scrubs seen-connected state, and emits
    PeerConnectionFailed.

Giving up never blocks a legitimate reconnect: a peer returning after a long
outage still rejoins on its own via a gossip announce — terminal eviction only
stops us from dialing a peer that is not coming back, which was a latent leak
even absent an attacker.

+2 pure-seam unit tests (admit_retained, recovery_is_terminal); 418 lib tests
green, clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:20:45 -04:00
molluskandClaude Opus 4.8 f927567105 fix(security): address Codex Tier C audit — F-01 regression + cheap closes
Follow-up to 5c11947 after Codex's adversarial audit
(tier-c-audit-2026-06-23.md). Fixes a regression the roster cap introduced
and closes F-01's two cheap unbounded-growth vectors. No wire/protocol
change, no new deps.

- Regression (cap × reconnect): a peer reconnecting from a transient drop
  sits in `disconnected_peers`, not the live roster, so the new cap could
  reject it as "new" at a full 32-peer roster — and the eager
  `disconnected_peers.remove()` (before the cap check) then orphaned its
  recovery state so a later signed Leave skipped cleanup. Now reconnecting
  (and existing) peers are exempt from the cap via the pure
  `announce_subject_to_cap`, and the disconnect marker is cleared only after
  admission. PeerJoined semantics for reconnects are preserved.

- F-01 replay map: `state_mutations_seen` was uncapped, so signed Leaves
  from unlimited generated keys grew it for the room's lifetime. Prune
  entries older than the freshness window once past a soft cap
  (`prune_stale_mutations`) — stale entries can't gate an in-window message
  (verify_gossip rejects the replay first), so replay protection is intact;
  the map is now bounded to ~authors-seen-per-window.

- F-01 address lookup: a signed Leave now calls `remove_endpoint_info`, so
  cycling identities through Announce→Leave can't grow the iroh lookup
  without bound. Re-announce re-populates it.

- F-03 test: added a forced same-hash/different-bytes ByteLru test (via a
  hash-injectable inner seam) so collision-safety is regression-tested, not
  just code-reviewed.

Deferred follow-ups from the audit (logged): recovery/known_peers identity
cap (needs a design pass, touches reconnect-resilience), F-02 result-cache
LRU, and the (author,id)-vs-id attachment aliasing integrity bug.

416 lib tests (+3), clippy --all-targets clean, release build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:51:41 -04:00
molluskandClaude Opus 4.8 5c11947bd7 fix(security): Tier C F-01/F-02/F-03 insider resource-exhaustion caps
A room ticket holder is an authenticated insider; signatures only prove
keypair ownership, not a distinct human. Previously such a member could
exhaust a victim's memory/tasks/dials without bound. Add caps + dedup at
the gossip/core/UI boundaries (no wire/protocol change, no new deps):

F-01 (gossip): cap the roster at MAX_ACTIVE_PEERS (32) — new authors are
rejected when full, existing peers' updates always pass; sanitize each
announced EndpointAddr (<=8 addrs, relay-URL <=256 bytes, drop Custom);
replace (set_endpoint_info) instead of unioning attacker address history.

F-02 (core): gate chat image auto-fetch — only roster authors qualify,
(author, attachment_id) is deduped, and a 4-permit pool bounds concurrent
detached fetch tasks (RAII AutoFetchGuard releases permit + dedup marker).
Chat text is still shown (already sanitized); the user-initiated "Save"
fetch is unchanged. Non-roster sock-puppet chat can no longer spawn tasks.

F-03 (app): replace the unbounded AVATAR_HANDLE_CACHE map with a bounded,
byte-equality-keyed LRU (avatar::ByteLru, cap 64) — fixes both unbounded
growth from an endless stream of distinct valid avatars and the prior
64-bit-hash-collision-shows-wrong-avatar bug.

Pure seams (sanitize_endpoint_addr, admit_into_roster, should_auto_fetch,
ByteLru) + 6 adversarial/unit tests. 413 lib tests, clippy --all-targets
clean, release build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:10:08 -04:00
molluskandClaude Opus 4.8 7349744d16 chore(packaging): bump Windows installer to 0.4.0
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
The 0.4.0 Windows binary was cross-built (x86_64-pc-windows-gnu,
build-std static) and verified in the libvirt Windows 11 VM: the raw
exe launches/renders the full v0.4.0 UI and runs stably, and the
compiled installer was test-installed end-to-end (Program Files exe
sha256 1a211eb6…, Start-menu shortcut, firewall rule, launch from
the installed location) before publishing to the v0.4.0 release.

- peerspeak.iss: MyAppVersion 0.3.0 -> 0.4.0 (installer output is
  peerspeak-0.4.0-setup.exe)
- INSTALL.md / README.md: update the 0.3.0 filename/version references

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:45:49 -04:00
molluskandClaude Opus 4.8 a6a88d15c0 fix(app): don't wipe early-arriving peers on RoomJoined
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Field-test regression from the F-06 fix (a17b930): joining a room via the
friends-list / Recents Join button sometimes landed in an empty roster even
though the peer was fully connected at the gossip layer.

Cause: `PeerJoined` (gossip event task) and `RoomJoined` (core command loop)
ride the same UI channel from different senders. The core emits `RoomJoined`
only after audio + echo-cancel setup, so `PeerJoined` for the new room
routinely arrives first. F-06 had added `reset_room_state()` to the
`RoomJoined` handler, which then cleared the peer that had already announced.
Echo cancellation widened the window and made it reliable; the roster
"self-healed" only on the peer's next periodic re-announce (`PeerUpdated`).

Fix: reset room-scoped UI state at join *initiation* (JoinPressed,
CreatePressed, JoinFriendRoom, JoinRecent) instead of on `RoomJoined`. From
Home that's a no-op (already cleared on leave), so nothing leaks, and an
early `PeerJoined` for the new room now survives. The in-call switch path
F-06 targeted is unreachable from the current UI (friends list + Recents
render only on the Home screen), so this fully covers the reachable case.

Field-verified on a 2-machine desktop<->dopedart call. 407 lib tests pass,
clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:27:16 -04:00
mollusk a17b930524 Merge codex-tier-b-fixes: Tier B bug-sweep fixes (F-05, F-06, F-10, F-11)
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-06-22 15:38:54 -04:00
6100abef33 fix(core,audio,app): Tier B bug-sweep fixes (F-05, F-06, F-10, F-11)
Four confirmed P2 findings from the 2026-06-22 adversarial bug sweep.
None change the wire format / PeerState / GOSSIP_PROTO — all local.

- F-05: re-key the A8 rejoin archive (known_peers) and RecoveryContext
  by topic_id ([u8; 32]) instead of the raw ticket string. A W7-
  restamped member ticket shares the room's topic but not its string,
  so a rejoin-from-Recents previously missed the retained bootstrap
  bucket and dropped to an empty bootstrap — the exact dead-end A8
  fixed. Topic is derived once via PeerSpeakTicket::topic_of in Join;
  a malformed ticket now fails early and clean.
- F-06: an in-call Join no longer leaks the old room's peers/chat into
  the new room, nor strands stale presence on a failed switch. Core
  captures was_in_room, clears current_room at teardown, and emits a
  new local UiEvent::RoomReset on every post-teardown failure path so
  a failed switch lands idle on Home. The UI's room-scoped clearing is
  factored into AppState::reset_room_state(), called by RoomLeft,
  RoomReset, and at the top of RoomJoined — so a successful switch
  clears+repopulates seamlessly on the Room screen (no Home bounce, no
  leave chime).
- F-10: echo-cancel virtual nodes now get per-PID-unique names
  (peerspeak_echocancel_{source,sink}.<pid>); the guard carries them
  and core targets them instead of the fixed constants. unload_stale
  only unloads our modules whose owner PID is dead (/proc check, cfg-
  gated; conservative elsewhere), so enabling AEC in one instance can
  no longer tear down another live instance's call. Pure
  pid_from_ec_args / ec_module_is_stale seams.
- F-11: a recording write failure now stops recording atomically
  (best-effort finalize via stop_recording + one UI Error) instead of
  looping the error at ~50 Hz with silent data loss. Both mixer
  branches release the recorder mutex before calling stop_recording to
  avoid a self-deadlock on the non-reentrant std::Mutex.

407 lib tests pass (+4), clippy --all-targets clean, release build
green. Tests-green only; the rejoin (F-05), in-call switch (F-06),
two-instance AEC (F-10), and disk-full (F-11) paths need a real run.
Implemented by Codex, reviewed + gates re-run by senior.

Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:38:48 -04:00
mollusk 49bd2ba687 Merge codex-tier-a-fixes: Tier A bug-sweep fixes 2026-06-22 03:34:29 -04:00
6b0b23ef69 fix(audio,game): Tier A bug-sweep fixes (S-01, F-04, F-08, F-09, S-02)
Five confirmed findings from the 2026-06-22 adversarial bug sweep:

- S-01: clamp PipeWire capture chunk size to the mapped slice before
  indexing, so a bad reported size can't panic (= process abort) from
  the RT capture callback. Extracted testable for_each_capture_sample.
- F-04: reserve ring occupancy before publishing a frame on the PipeWire
  playback path (mirrors the cpal fix), preventing the RT consumer from
  popping an uncounted sample and wrapping fill_gauge to usize::MAX,
  which permanently wedged mixer pacing. Extracted publish_frame.
- F-09: GameDetector::spawn now returns io::Result and retains its
  JoinHandle (joined on Drop); core fuses a closed watch receiver to
  None via next_game_change so a dead detector can't busy-loop select!.
- F-08: collision-free recording paths — Recorder::create and the
  multitrack session dir use create_new/create_dir with bounded suffix
  retry, so two recordings in the same second no longer truncate the
  first.
- S-02: bound the Windows SteamPath registry read (<=4 KiB, even length,
  re-checked type/returned length) before allocating/decoding.

403 lib tests pass (+6), clippy --all-targets clean. Implemented by
Codex, reviewed + gates re-run by senior.

Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 03:34:29 -04:00
mollusk f422150c84 Merge codex-log-game-field: log game field in presence
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-06-22 02:31:49 -04:00
molluskandClaude Opus 4.8 86d333d4dc chore(gossip): log the game field in peer_state_for_log
Add the new PeerState.game field to the presence log formatter so
gossip Announces show whether a peer is broadcasting a game. The line
previously printed name/muted/addr_id/addrs/sharing only, making the
game-presence broadcast invisible in logs (verified solely via UI
during the 2026-06-22 2-machine field test). Log-only: no wire,
protocol, or GOSSIP_PROTO change. Adds the first unit test for the
formatter (Some and None cases).

Implemented by Codex (gpt-5.5) on branch codex-log-game-field; reviewed
and gates re-run by the senior (397 lib tests, clippy --all-targets,
release build all green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 02:31:45 -04:00
mollusk fad65a4fcf fix(game): detect live Steam appid via /proc SteamAppId, not stale registry.vdf
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Field test found Steam games were never detected on Linux. Root cause:
Steam rewrites ~/.steam/registry.vdf only on SHUTDOWN, so its RunningAppID
is stale (often absent) while a game is actually running — polling it can
never see the live game.

Fix: on Linux, read the live appid from the running game's environment
(SteamAppId in /proc/<pid>/environ, the var Steam exports to every game
process — the same signal MangoHud uses; readable for our own processes).
registry.vdf stays as a best-effort fallback. Windows still reads the real
registry's RunningAppID, which IS updated live there. Other Unix keeps the
registry.vdf fallback.

Pure parse_steam_app_id_from_environ() is unit-tested (nonzero filter,
absent, substring-not-fooled, garbage). Also fixes a latent bug in the
first draft where a single non-UTF8 SteamAppId value would abort the whole
scan via ? instead of skipping.

396 lib tests, clippy --all-targets clean.
2026-06-21 16:11:17 -04:00
molluskandClaude Opus 4.8 3878e716dd feat(game): Step 7 UI — opt-in toggle, roster Playing line, Settings
Final step of game detection. Functional, plain styling (to art-direct).

- Settings 'Games' category: opt-in 'show my game' toggle
  (SetGamePresenceEnabled, persisted), manual override picker (Auto /
  None / Pin current), per-game background picker+remove (reuses
  process_background + hashed game_background_path), and a non-Steam
  process->name mapping editor (add/remove, pushes SetGameProcessMap).
- Roster: each peer card shows 'Playing <game>' under their name when
  they broadcast one; our own self card shows it too, marked
  '(not shared)' when broadcasting is off.
- Startup: seeds SetGamePresenceEnabled + SetGameProcessMap from config.
- Updated the settings-category navigation test for the new category.

395 lib tests green, clippy --all-targets clean, binary builds, and an
8s smoke launch starts the core + detector thread with no panic (detector
logs nothing by design — privacy).

Feature complete on Linux end-to-end (pending a coordinated GOSSIP_PROTO
3 redeploy to field-test presence with peers). Windows FFI still needs
its cross-build pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:43:24 -04:00
molluskandClaude Opus 4.8 961705ffa9 feat(game): broadcast game presence (GOSSIP_PROTO 3) + per-game background
Steps 5-6 of game detection. BREAKING wire change — bump everyone.

Wire (step 5):
- PeerState.game: Option<String> (display label only — never appid/source).
- SelfPresence.game + to_state carry it (single self-state builder).
- GOSSIP_PROTO 2->3, GOSSIP_SIG_DOMAIN v3, version comment bumped together;
  Cargo MINOR 0.3.0 -> 0.4.0 per VERSIONING.md. v2/v3 isolate into
  different topics + signature domains, so a coordinated redeploy is
  required (same as the W4 avatar bump).
- Gossip ingest sanitizes incoming game via sanitize_game_label (bidi/
  control strip, 64-char/256-byte cap); empty -> None.
- Bonus security fix (Codex find): reject inbound gossip frames over a
  128KB cap BEFORE serde_json::from_slice — a legit Announce with a full
  48KB avatar is ~49KB, so this bounds allocation abuse with headroom.

Core wiring:
- Spawns the detector at startup; consumes its watch channel in the main
  select. Detection runs continuously (for the local background); the
  broadcast is gated by game_presence_enabled (opt-in, default OFF).
  New commands: SetGamePresenceEnabled (immediate publish/clear, D8),
  SetGameOverride, SetGameProcessMap. New event: GameChanged.
- game_presence_label sanitizes the outgoing label too.

Background switch (step 6):
- GUI handles GameChanged: stores current_game, swaps background to the
  per-game override (config.game_backgrounds[id]) or falls back to the
  W16 default; reuses the existing cached-handle path (no redraw flicker).

397 lib tests (all green), clippy --all-targets clean, full binary builds.
Remaining: step 7 UI (opt-in toggle, roster 'Playing' text, manual
override control, Settings game-backgrounds + process-map editors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:31:09 -04:00
molluskandClaude Opus 4.8 7d44808a5e feat(game): cancellable detector service
Step 4 of game detection. One std-thread worker owns the SteamProbe cache
+ Debouncer across ticks, polls the OS adapters every 3s off the async
runtime, and publishes the stable detected game on a tokio watch channel
only when it changes. Manual override + process map are live-updatable via
shared handles; a cancellable sleep honors stop promptly; drop stops it.

The per-tick decision (match + resolve + debounce) is the pure poll_once,
unit-tested with synthetic Steam/process inputs (debounce, process-only
match, immediate manual override). +4 tests (397 lib).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:21:52 -04:00
molluskandClaude Opus 4.8 e31d3db986 feat(game): Steam + process-scan OS adapters
Step 2-3 of game detection (game-presence-plan.md). The OS edges feeding
the pure seams from the previous commit.

- src/game/steam.rs: SteamProbe — reads the live RunningAppID and resolves
  it to a name via appmanifest_<id>.acf (no binary appinfo.vdf). Pure parse
  fns (parse_running_app_id / parse_library_paths / parse_app_name) over
  file contents are unit-tested incl. current+legacy libraryfolders shapes,
  escaped Windows paths, empty/missing names, and garbage. Roots discovered
  across native/Flatpak/Snap (Linux) and the registry (Windows); libraries
  and resolved names cached + mtime-invalidated so the 3s poll doesn't
  rescan. File reads byte-capped.
- src/game/scan.rs: native running-process enumeration — /proc (exe symlink,
  comm fallback) on Linux, Toolhelp on Windows — feeding the pure
  match_processes. No sysinfo dep (D7).
- Cargo.toml: windows-sys as a direct Windows-only dep for the registry +
  Toolhelp FFI. No NEW crate — it was already in the lockfile transitively
  via cpal/rfd, so the audit surface is unchanged.

391 lib tests (+5). Linux: build + clippy --all-targets clean. Windows FFI
signatures verified against windows-sys 0.61 source (one *const vs *mut
lpReserved fixed) but NOT yet cross-compiled — defer to the post-UI Windows
build cycle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:19:53 -04:00
molluskandClaude Opus 4.8 87a2209a85 feat(game): pure detection seams — matcher, debouncer, VDF parser, config
Step 1 of the game-detection feature (game-presence-plan.md): all the
pure, I/O-free logic, tested first.

- src/game/mod.rs: DetectedGame + stable namespaced ids (steam:730 /
  exe:hl2_linux, never the mutable name); ManualOverride; the priority
  resolve() matcher (override -> Steam -> mapped process -> none); the
  Debouncer (2-on/3-off, immediate bypass for manual override) that
  stops a flapping detector re-announcing the ~48KB-avatar PeerState;
  match_processes() over explicit user mappings with a launcher denylist
  (never guesses a game from an arbitrary process).
- src/game/vdf.rs: a real recursive-descent KeyValues/VDF parser (not a
  name-regex) for appmanifest/.acf, libraryfolders.vdf, registry.vdf —
  depth-capped, escape-aware, never panics on malformed/truncated input.
- src/sanitize.rs: sanitize_game_label (64-char/256-byte cap, wider than
  the 48-char name cap) sharing the bidi/zero-width cleaning.
- src/config.rs: additive game_presence_enabled (opt-in, default OFF),
  game_backgrounds + game_process_map (BTreeMap, deterministic);
  background_path generalized to hashed per-game files; explicit
  legacy-config migration test (load() wipes on any deserialize error).
- src/background.rs: game_background_filename (FNV-1a hashed, fs-safe).

No wire/protocol change yet; no OS reads yet. 386 lib tests (+28).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:12:58 -04:00
molluskandClaude Opus 4.8 9e8c8b4ace refactor(core): single SelfPresence self-state builder
Core reconstructed PeerState in five command branches (join, mute
toggle, avatar change, screen-share start/stop), each repeating the full
field list. Factor a SelfPresence struct holding the sticky identity
fields (name + avatar) with a to_state(is_muted, addr, sharing) builder
that folds in the volatile per-announce fields, so the PeerState literal
lives in one place. This is the precondition for adding a broadcast
game-presence field without editing every call site.

No behavior change. +1 unit test (359 lib total path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:05:32 -04:00
32 changed files with 5224 additions and 351 deletions
Generated
+2 -1
View File
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "peerspeak"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"anyhow",
"async-trait",
@@ -4894,6 +4894,7 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"windows-sys 0.61.2",
]
[[package]]
+39 -1
View File
@@ -1,11 +1,40 @@
[package]
name = "peerspeak"
version = "0.3.0"
version = "0.4.0"
edition = "2024"
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
# Application crate, not a crates.io library — refuse `cargo publish` and let
# cargo-deny's [licenses.private] skip the missing-license check.
publish = false
# Debian/Ubuntu packaging (cargo-deb). Mirrors packaging/PKGBUILD: only the main
# `peerspeak` binary ships (not test_net/specview), plus the desktop entry and the
# hicolor icon set. Runtime shared-lib deps (libpipewire, libopus, libc, …) are
# resolved by dpkg-shlibdeps via `depends = "$auto"`. Build inside a Debian/Ubuntu
# distrobox so the binary links that distro's glibc, then `cargo deb --no-build`.
[package.metadata.deb]
maintainer = "mollusk <jitty+lc1iz0dc@protonmail.com>"
copyright = "2026, mollusk. Private build — not for redistribution."
section = "net"
priority = "optional"
depends = "$auto"
# pixelpass = in-room screen sharing; mpv = the screen-share viewer (vlc fallback).
recommends = "pixelpass, mpv"
extended-description = "Decentralized peer-to-peer voice chat over iroh (QUIC) with PipeWire audio, the Opus codec, and an iced GUI. Full-mesh, no central server."
assets = [
["target/release/peerspeak", "usr/bin/", "755"],
["packaging/peerspeak.desktop", "usr/share/applications/", "644"],
["assets/icons/peerspeak.svg", "usr/share/icons/hicolor/scalable/apps/peerspeak.svg", "644"],
["assets/icons/peerspeak-16.png", "usr/share/icons/hicolor/16x16/apps/peerspeak.png", "644"],
["assets/icons/peerspeak-24.png", "usr/share/icons/hicolor/24x24/apps/peerspeak.png", "644"],
["assets/icons/peerspeak-32.png", "usr/share/icons/hicolor/32x32/apps/peerspeak.png", "644"],
["assets/icons/peerspeak-48.png", "usr/share/icons/hicolor/48x48/apps/peerspeak.png", "644"],
["assets/icons/peerspeak-64.png", "usr/share/icons/hicolor/64x64/apps/peerspeak.png", "644"],
["assets/icons/peerspeak-128.png", "usr/share/icons/hicolor/128x128/apps/peerspeak.png", "644"],
["assets/icons/peerspeak-256.png", "usr/share/icons/hicolor/256x256/apps/peerspeak.png", "644"],
["assets/icons/peerspeak-512.png", "usr/share/icons/hicolor/512x512/apps/peerspeak.png", "644"],
]
[lib]
name = "peerspeak"
path = "src/lib.rs"
@@ -65,3 +94,12 @@ rfd = { version = "0.17", default-features = false }
# Windows audio backend: cpal drives WASAPI for capture/playback behind the
# AudioBackend trait (src/audio/cpal_impl.rs). The Linux counterpart is pipewire.
cpal = "0.15"
# Win32 FFI for game detection (no new crate: windows-sys is already pulled in
# transitively by cpal/rfd). Registry reads the Steam RunningAppID + install path;
# Toolhelp enumerates running processes for the non-Steam process-scan fallback.
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_System_Registry",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_Threading",
] }
+14
View File
@@ -225,6 +225,20 @@ state change; rate-limit pings), tickets from friends (validate defensively, no
auto-join), the discovery publish (only when toggled, ideally auto-expiring).
`cargo audit` (JSON store → no new deps expected). Field test on dopedart.
**Local hardening DONE 2026-06-27:** inbound friend-presence replies are now
rate-limited per authenticated friend id (`PresenceRateLimiter`: burst 4, refill
1/15s) and wired into the live friends listener before it builds a `Pong`; denied
probes get the same silent no-data close as unauthorized probes. Existing
defensive reply handling still validates room tickets against the authenticated
friend id and never auto-joins. Verified with `cargo test presence`,
`cargo test --lib`, `cargo clippy --all-targets -- -D warnings`, and
`cargo audit --no-fetch --stale` (local DB; reports only the two already-allowed
unmaintained advisories in `deny.toml`). A fresh advisory fetch was blocked in
this sandbox by network restrictions.
**Remaining:** live 2-machine field test on dopedart, a fresh online
`cargo audit`, and any follow-up findings from that test.
## The connect flow (the user's scenario, end to end)
1. Friend X, at a coffee shop, opens peerspeak and starts a gathering labeled
"HangOut."
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
pkgname=peerspeak-git
_pkgname=peerspeak
pkgver=0.3.0.r229.g7fb1c96
pkgver=0.4.0.r254.g913b0b6
pkgrel=1
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
arch=('x86_64')
+1 -1
View File
@@ -8,7 +8,7 @@ it once, then you and I connect directly to each other.
## 1. Install it
1. Double-click **`peerspeak-0.3.0-setup.exe`** (the file I sent you).
1. Double-click **`peerspeak-0.4.0-setup.exe`** (the file I sent you).
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
+1 -1
View File
@@ -12,7 +12,7 @@ runtime, so there are no extra DLLs to bundle. The installer payload is just the
## Version compatibility
The installer version tracks the crate version in `Cargo.toml` (currently
**0.3.0**) — keep `MyAppVersion` in `peerspeak.iss` in sync when it changes.
**0.4.0**) — keep `MyAppVersion` in `peerspeak.iss` in sync when it changes.
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
+1 -1
View File
@@ -12,7 +12,7 @@
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
#define MyAppName "PeerSpeak"
#define MyAppVersion "0.3.0"
#define MyAppVersion "0.4.0"
#define MyAppPublisher "mollusk"
#define MyAppExeName "peerspeak.exe"
+1344 -150
View File
File diff suppressed because it is too large Load Diff
+84 -13
View File
@@ -34,6 +34,18 @@ const NODE_READY_TIMEOUT: Duration = Duration::from_secs(3);
/// nodes never leak past the call that created them.
pub struct EchoCancelGuard {
module_index: String,
source_name: String,
sink_name: String,
}
impl EchoCancelGuard {
pub fn source_name(&self) -> &str {
&self.source_name
}
pub fn sink_name(&self) -> &str {
&self.sink_name
}
}
impl Drop for EchoCancelGuard {
@@ -58,12 +70,16 @@ pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result<Echo
// don't stack duplicate modules / fight over the virtual node names.
unload_stale();
let owner_pid = std::process::id();
let source_name = format!("{EC_SOURCE}.{owner_pid}");
let sink_name = format!("{EC_SINK}.{owner_pid}");
let mut cmd = Command::new("pactl");
cmd.arg("load-module")
.arg("module-echo-cancel")
.arg("aec_method=webrtc")
.arg(format!("source_name={EC_SOURCE}"))
.arg(format!("sink_name={EC_SINK}"));
.arg(format!("source_name={source_name}"))
.arg(format!("sink_name={sink_name}"));
if let Some(src) = real_source.filter(|s| !s.is_empty()) {
cmd.arg(format!("source_master={src}"));
}
@@ -85,12 +101,12 @@ pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result<Echo
if module_index.parse::<u64>().is_err() {
return Err(format!("unexpected pactl output: {module_index:?}"));
}
let guard = EchoCancelGuard { module_index };
let guard = EchoCancelGuard { module_index, source_name, sink_name };
// The virtual nodes appear shortly after the module loads; wait for both so
// the subsequent capture/playback streams can actually target them. If they
// never show, drop the guard (unloads) and report failure.
if !wait_for_nodes() {
if !wait_for_nodes(guard.source_name(), guard.sink_name()) {
return Err("echo-cancel virtual nodes did not appear in time".to_string());
}
@@ -102,10 +118,10 @@ pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result<Echo
}
/// Polls until both virtual nodes exist or the timeout elapses.
fn wait_for_nodes() -> bool {
fn wait_for_nodes(source_name: &str, sink_name: &str) -> bool {
let deadline = Instant::now() + NODE_READY_TIMEOUT;
loop {
if node_present("sources", EC_SOURCE) && node_present("sinks", EC_SINK) {
if node_present("sources", source_name) && node_present("sinks", sink_name) {
return true;
}
if Instant::now() >= deadline {
@@ -126,8 +142,30 @@ fn node_present(kind: &str, name: &str) -> bool {
.any(|line| line.split('\t').nth(1) == Some(name))
}
/// Unloads any leftover `module-echo-cancel` instance we previously created
/// (identified by our virtual node names in its argument string). Best-effort.
fn pid_from_ec_args(args: &str) -> Option<u32> {
let source_prefix = format!("source_name={EC_SOURCE}.");
args.split_whitespace()
.find_map(|arg| arg.strip_prefix(&source_prefix))?
.parse()
.ok()
}
fn ec_module_is_stale(args: &str, is_alive: impl Fn(u32) -> bool) -> bool {
pid_from_ec_args(args).is_some_and(|pid| !is_alive(pid))
}
#[cfg(target_os = "linux")]
fn process_is_alive(pid: u32) -> bool {
std::path::Path::new("/proc").join(pid.to_string()).exists()
}
#[cfg(not(target_os = "linux"))]
fn process_is_alive(_pid: u32) -> bool {
true
}
/// Unloads leftover PeerSpeak `module-echo-cancel` instances only when their
/// owning process is gone. Best-effort and conservative on non-Linux platforms.
fn unload_stale() {
let Ok(out) = Command::new("pactl").arg("list").arg("modules").arg("short").output() else {
return;
@@ -137,7 +175,10 @@ fn unload_stale() {
let index = cols.next().unwrap_or("");
let name = cols.next().unwrap_or("");
let args = cols.next().unwrap_or("");
if name == "module-echo-cancel" && args.contains(EC_SOURCE) && index.parse::<u64>().is_ok() {
if name == "module-echo-cancel"
&& ec_module_is_stale(args, process_is_alive)
&& index.parse::<u64>().is_ok()
{
let _ = Command::new("pactl").arg("unload-module").arg(index).output();
crate::log_msg(&format!("Echo cancel: cleaned up stale module {index}"));
}
@@ -155,12 +196,42 @@ mod tests {
#[ignore]
fn enable_creates_and_unloads_nodes() {
let guard = enable(None, None).expect("module-echo-cancel should load");
assert!(node_present("sources", EC_SOURCE), "cleaned source must exist");
assert!(node_present("sinks", EC_SINK), "reference sink must exist");
let source_name = guard.source_name().to_string();
let sink_name = guard.sink_name().to_string();
assert!(node_present("sources", &source_name), "cleaned source must exist");
assert!(node_present("sinks", &sink_name), "reference sink must exist");
drop(guard);
// Give pactl a moment to tear the nodes down.
std::thread::sleep(Duration::from_millis(300));
assert!(!node_present("sources", EC_SOURCE), "source must be gone after unload");
assert!(!node_present("sinks", EC_SINK), "sink must be gone after unload");
assert!(!node_present("sources", &source_name), "source must be gone after unload");
assert!(!node_present("sinks", &sink_name), "sink must be gone after unload");
}
#[test]
fn parses_owner_pid_only_from_our_source_name() {
assert_eq!(
pid_from_ec_args(
"aec_method=webrtc source_name=peerspeak_echocancel_source.4242 sink_name=peerspeak_echocancel_sink.4242"
),
Some(4242)
);
assert_eq!(pid_from_ec_args("aec_method=webrtc"), None);
assert_eq!(
pid_from_ec_args("source_name=peerspeak_echocancel_source.not-a-pid"),
None
);
assert_eq!(pid_from_ec_args("source_name=someone_elses_source.4242"), None);
}
#[test]
fn stale_decision_keeps_live_and_foreign_modules() {
let ours = "source_name=peerspeak_echocancel_source.4242";
assert!(!ec_module_is_stale(ours, |pid| pid == 4242));
assert!(ec_module_is_stale(ours, |_| false));
assert!(!ec_module_is_stale("source_name=foreign.4242", |_| false));
assert!(!ec_module_is_stale(
"source_name=peerspeak_echocancel_source.malformed",
|_| false
));
}
}
+39
View File
@@ -29,6 +29,32 @@ const SILENCE_CHUNK: usize = FRAME_SAMPLES * 256;
/// can drift if the capture clock runs ahead of the mixer cycle; past it the
/// oldest mic audio is dropped. Mirrors `recorder::MAX_MIC_FIFO`.
const MAX_MIC_FIFO: usize = 48_000 / 5;
const MAX_SESSION_DIR_ATTEMPTS: usize = 1_000;
/// Create a collision-free session directory for a timestamp. The base
/// timestamp is tried first, followed by `-2`, `-3`, and so on; an existing
/// recording is never reopened or overwritten.
pub fn create_session_dir(base: &Path, now_unix_secs: u64) -> io::Result<PathBuf> {
let filename = crate::audio::recorder::timestamp_filename(now_unix_secs);
let stem = filename.trim_end_matches(".wav");
for attempt in 1..=MAX_SESSION_DIR_ATTEMPTS {
let name = if attempt == 1 {
stem.to_string()
} else {
format!("{stem}-{attempt}")
};
let path = base.join(name);
match std::fs::create_dir(&path) {
Ok(()) => return Ok(path),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"multitrack directory suffixes exhausted",
))
}
/// One output track: its WAV writer plus whether it has been written *this*
/// cycle (so `end_cycle` knows which tracks to pad with silence).
@@ -263,6 +289,19 @@ mod tests {
assert_eq!(track_filename("!!!", &id), format!("peer-{short}.wav"));
}
#[test]
fn same_second_sessions_get_unique_directories_without_reuse() {
let base = tmpdir("collision");
let first = create_session_dir(&base, 1_700_000_000).unwrap();
std::fs::write(first.join("sentinel"), b"keep me").unwrap();
let second = create_session_dir(&base, 1_700_000_000).unwrap();
assert_ne!(second, first);
assert_eq!(std::fs::read(first.join("sentinel")).unwrap(), b"keep me");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn all_tracks_equal_length_after_n_cycles() {
let dir = tmpdir("equal");
+73 -10
View File
@@ -151,11 +151,9 @@ fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_n
let data = &mut datas[0];
let size = data.chunk().size() as usize;
if let Some(slice) = data.data() {
// Each sample is 2 bytes (S16LE)
for chunk in slice[..size].chunks_exact(2) {
let sample = i16::from_le_bytes([chunk[0], chunk[1]]);
for_each_capture_sample(slice, size, |sample| {
let _ = user_data.producer.try_push(sample);
}
});
}
}
}
@@ -224,6 +222,16 @@ fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_n
Ok(())
}
/// Visit the complete S16LE samples in the portion PipeWire reports as filled.
/// Clamp the reported byte count to the mapped slice before indexing: a bad
/// chunk size must not panic from the realtime capture callback.
fn for_each_capture_sample(slice: &[u8], size: usize, mut visit: impl FnMut(i16)) {
let size = size.min(slice.len());
for chunk in slice[..size].chunks_exact(2) {
visit(i16::from_le_bytes([chunk[0], chunk[1]]));
}
}
/// Frames the playback RT callback should produce this cycle.
///
/// `requested` is the graph's per-cycle quantum from `Buffer::requested()` (0 if
@@ -263,6 +271,25 @@ fn drain_loop(
}
}
/// Reserve exact occupancy before making a frame visible to the consumer.
/// `after_reserve` is empty in production and lets the regression test force a
/// consumer interleaving at the critical ordering boundary.
fn publish_frame<P: Producer<Item = i16>>(
fill: &AtomicUsize,
dropped: &AtomicU64,
producer: &mut P,
frame: &[i16],
after_reserve: impl FnOnce(),
) {
fill.fetch_add(frame.len(), Ordering::Relaxed);
after_reserve();
let pushed = producer.push_slice(frame);
if pushed != frame.len() {
fill.fetch_sub(frame.len() - pushed, Ordering::Relaxed);
dropped.fetch_add(1, Ordering::Relaxed);
}
}
fn frames_to_produce(requested: usize, mapped_frames: usize) -> usize {
/// Safe per-cycle fallback when the graph doesn't report a quantum.
const FALLBACK_FRAMES: usize = 1024;
@@ -522,10 +549,12 @@ fn run_playback(
worker_dropped.fetch_add(1, Ordering::Relaxed);
return;
}
for &sample in &frame {
let _ = producer.try_push(sample);
}
worker_fill.fetch_add(frame.len(), Ordering::Relaxed);
// Reserve occupancy BEFORE publishing samples. Otherwise the RT
// consumer can pop a newly-visible sample before it is counted and
// wrap the exact fill gauge to usize::MAX, wedging mixer pacing.
// `push_slice` also publishes the frame as one operation rather than
// exposing a half-written stereo pair.
publish_frame(&worker_fill, &worker_dropped, &mut producer, &frame, || {});
});
});
@@ -577,8 +606,9 @@ fn run_playback(
#[cfg(test)]
mod tests {
use super::{drain_loop, frames_to_produce};
use std::sync::atomic::{AtomicBool, Ordering};
use super::{drain_loop, for_each_capture_sample, frames_to_produce, publish_frame};
use ringbuf::{HeapRb, traits::{Consumer, Producer, Split}};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use std::{sync::mpsc, thread};
@@ -614,6 +644,39 @@ mod tests {
assert_eq!(frames_to_produce(1024, 0), 0);
}
#[test]
fn capture_size_larger_than_mapping_is_clamped() {
let mut samples = Vec::new();
for_each_capture_sample(&[1, 0, 2, 0, 3], usize::MAX, |sample| {
samples.push(sample)
});
assert_eq!(samples, vec![1, 2]);
}
#[test]
fn occupancy_is_reserved_before_frame_is_published() {
let rb = HeapRb::<i16>::new(8);
let (mut producer, mut consumer) = rb.split();
assert!(producer.try_push(7).is_ok());
let fill = AtomicUsize::new(1);
let dropped = AtomicU64::new(0);
publish_frame(&fill, &dropped, &mut producer, &[10, 11], || {
// Force the consumer to drain the old sample after the new frame's
// occupancy is reserved but before that frame is published.
assert_eq!(consumer.try_pop(), Some(7));
assert_eq!(fill.fetch_sub(1, Ordering::Relaxed), 3);
});
assert_eq!(fill.load(Ordering::Relaxed), 2);
assert_eq!(consumer.try_pop(), Some(10));
assert_eq!(fill.fetch_sub(1, Ordering::Relaxed), 2);
assert_eq!(consumer.try_pop(), Some(11));
assert_eq!(fill.fetch_sub(1, Ordering::Relaxed), 1);
assert_eq!(fill.load(Ordering::Relaxed), 0);
assert_eq!(dropped.load(Ordering::Relaxed), 0);
}
// --- drain_loop (A7: worker must not hang shutdown) ---
#[test]
+57 -9
View File
@@ -14,7 +14,7 @@
//! and patches the two size fields on [`Recorder::finalize`].
use std::collections::VecDeque;
use std::fs::File;
use std::fs::{File, OpenOptions};
use std::io::{self, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
@@ -24,6 +24,7 @@ const BITS_PER_SAMPLE: u16 = 16;
const CHANNELS: u16 = 1;
const RIFF_DATA_OVERHEAD: u64 = 36;
const MAX_RIFF_DATA_BYTES: u64 = u32::MAX as u64 - RIFF_DATA_OVERHEAD;
const MAX_NAME_ATTEMPTS: usize = 1_000;
/// 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
@@ -42,7 +43,12 @@ pub struct WavWriter {
impl WavWriter {
/// Create the file and write the 44-byte header with zeroed size fields.
pub fn new(path: &Path) -> io::Result<Self> {
let mut file = File::create(path)?;
Self::from_file(File::create(path)?)
}
/// Start a WAV in an already-opened file. This lets callers choose atomic
/// create-new semantics instead of the truncating behavior of `File::create`.
fn from_file(mut file: File) -> io::Result<Self> {
file.write_all(&Self::header(0))?;
Ok(Self {
file,
@@ -125,13 +131,31 @@ impl Recorder {
/// Create a recording at `dir/<timestamped>.wav`. The directory is assumed to
/// exist (the caller creates it).
pub fn create(dir: &Path, now_unix_secs: u64) -> io::Result<Self> {
let path = dir.join(timestamp_filename(now_unix_secs));
let writer = WavWriter::new(&path)?;
Ok(Self {
writer,
mic_fifo: VecDeque::new(),
path,
})
let filename = timestamp_filename(now_unix_secs);
let stem = filename.trim_end_matches(".wav");
for attempt in 1..=MAX_NAME_ATTEMPTS {
let name = if attempt == 1 {
filename.clone()
} else {
format!("{stem}-{attempt}.wav")
};
let path = dir.join(name);
match OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(file) => {
return Ok(Self {
writer: WavWriter::from_file(file)?,
mic_fifo: VecDeque::new(),
path,
});
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"recording filename suffixes exhausted",
))
}
/// The path being written.
@@ -210,6 +234,30 @@ mod tests {
assert_eq!(timestamp_filename(0), "peerspeak-1970-01-01_000000.wav");
}
#[test]
fn same_second_recordings_get_unique_files_without_truncation() {
let dir = std::env::temp_dir().join(format!(
"peerspeak-collision-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut first = Recorder::create(&dir, 1_700_000_000).unwrap();
first.write_frame(&[123, 456]).unwrap();
let first_path = first.path().to_path_buf();
first.finalize().unwrap();
let original = std::fs::read(&first_path).unwrap();
let second = Recorder::create(&dir, 1_700_000_000).unwrap();
let second_path = second.path().to_path_buf();
assert_ne!(second_path, first_path);
assert_eq!(std::fs::read(&first_path).unwrap(), original);
second.finalize().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn wav_header_round_trips_sizes() {
let dir = std::env::temp_dir();
+119
View File
@@ -185,10 +185,129 @@ pub fn initials(name: &str) -> String {
}
}
/// A small content-addressed LRU cache mapping image bytes to a built value
/// (e.g. an `iced` image handle), so the SAME value is reused across redraws
/// instead of rebuilt every frame. Two Tier C F-03 properties beyond a plain
/// hash map:
///
/// 1. **Bounded** — at most `cap` entries, evicting the least-recently-used on
/// overflow, so a peer can't grow the cache without limit by publishing an
/// endless stream of distinct valid avatars.
/// 2. **Collision-safe** — a hit requires full byte equality, not just a matching
/// 64-bit hash, so a hash collision can never return a different image's value.
///
/// Linear scan; intended for small `cap` (tens of entries).
pub struct ByteLru<V> {
cap: usize,
/// `(content hash, content bytes, value)`; back = most recently used.
entries: Vec<(u64, Vec<u8>, V)>,
}
impl<V: Clone> ByteLru<V> {
/// Create an LRU holding at most `cap` entries (`cap` is clamped to >= 1).
pub fn new(cap: usize) -> Self {
Self { cap: cap.max(1), entries: Vec::new() }
}
/// Return the cached value for these exact `bytes`, building and inserting it
/// on a miss (evicting the least-recently-used entry once over `cap`). A hit
/// verifies full byte equality, so a 64-bit hash collision never returns the
/// wrong value. A hit also refreshes the entry's recency.
pub fn get_or_insert(&mut self, bytes: &[u8], build: impl FnOnce() -> V) -> V {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut hasher);
self.get_or_insert_hashed(hasher.finish(), bytes, build)
}
/// Inner seam with the content `hash` supplied explicitly. Production callers
/// use [`get_or_insert`]; tests use this to force a hash collision (different
/// bytes, same hash) and exercise the byte-equality guard.
fn get_or_insert_hashed(&mut self, hash: u64, bytes: &[u8], build: impl FnOnce() -> V) -> V {
if let Some(idx) = self
.entries
.iter()
.position(|(h, b, _)| *h == hash && b.as_slice() == bytes)
{
// LRU touch: move the hit entry to the back (most recent).
let entry = self.entries.remove(idx);
let val = entry.2.clone();
self.entries.push(entry);
return val;
}
let val = build();
if self.entries.len() >= self.cap {
self.entries.remove(0); // evict least-recently-used
}
self.entries.push((hash, bytes.to_vec(), val.clone()));
val
}
#[cfg(test)]
fn len(&self) -> usize {
self.entries.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn byte_lru_reuses_value_for_identical_bytes() {
let mut lru: ByteLru<u32> = ByteLru::new(4);
let mut next = 0u32;
let mut build = |lru: &mut ByteLru<u32>, b: &[u8]| {
lru.get_or_insert(b, || {
next += 1;
next
})
};
// Same bytes → same value, built only once.
assert_eq!(build(&mut lru, b"alice"), 1);
assert_eq!(build(&mut lru, b"alice"), 1);
// Different bytes → a freshly built value.
assert_eq!(build(&mut lru, b"bob"), 2);
assert_eq!(lru.len(), 2);
}
#[test]
fn byte_lru_evicts_least_recently_used() {
let mut lru: ByteLru<u32> = ByteLru::new(2);
let mut n = 0u32;
let mut ins = |lru: &mut ByteLru<u32>, b: &[u8]| {
lru.get_or_insert(b, || {
n += 1;
n
})
};
ins(&mut lru, b"a"); // -> 1
ins(&mut lru, b"b"); // -> 2, cache = [a, b]
ins(&mut lru, b"a"); // touch a, cache = [b, a]
ins(&mut lru, b"c"); // evicts LRU (b), cache = [a, c]
assert_eq!(lru.len(), 2);
// `a` survived (recently touched) → still value 1, not rebuilt.
assert_eq!(ins(&mut lru, b"a"), 1);
// `b` was evicted → rebuilt with a new value.
assert_eq!(ins(&mut lru, b"b"), 4);
}
#[test]
fn byte_lru_byte_equality_survives_a_hash_collision() {
// Force the SAME 64-bit hash for two DIFFERENT byte strings (the case a
// bare-hash cache would alias — Tier C F-03 collision bug).
let mut lru: ByteLru<u32> = ByteLru::new(4);
assert_eq!(lru.get_or_insert_hashed(42, b"alice", || 1), 1);
// `bob` collides on the hash but differs in bytes → a MISS, built fresh,
// NOT aliased to alice's value.
assert_eq!(lru.get_or_insert_hashed(42, b"bob", || 2), 2);
// Both coexist; each re-lookup returns its own value (build closure unused).
assert_eq!(lru.get_or_insert_hashed(42, b"alice", || 99), 1);
assert_eq!(lru.get_or_insert_hashed(42, b"bob", || 99), 2);
assert_eq!(lru.len(), 2);
}
#[test]
fn initials_takes_first_two_words() {
assert_eq!(initials("Alice"), "A");
+27
View File
@@ -45,6 +45,22 @@ pub fn process_background(raw: &[u8]) -> Result<Vec<u8>, String> {
Ok(png.into_inner())
}
/// A filesystem-safe, app-owned filename for the processed PNG of a per-game
/// background (W18), derived from the game's stable id by hashing rather than
/// embedding the raw id: keeps the name short and safe (ids contain `:` and
/// arbitrary executable basenames) and avoids leaking the id into the filesystem.
/// Deterministic and dependency-free (FNV-1a 64-bit), so the same game id always
/// maps to the same file.
pub fn game_background_filename(game_id: &str) -> String {
// FNV-1a, 64-bit.
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for b in game_id.as_bytes() {
hash ^= *b as u64;
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("game-bg-{hash:016x}.png")
}
/// The legibility scrim drawn between the background image and the UI: the active
/// theme's base colour at `dim` alpha (clamped to `0.0..=1.0`). A higher `dim`
/// recedes the image so body text and panel chrome stay readable, and it re-tints
@@ -90,6 +106,17 @@ mod tests {
assert!(process_background(b"definitely not an image").is_err());
}
#[test]
fn game_background_filename_is_stable_safe_and_distinct() {
let a = game_background_filename("steam:730");
// Stable for the same id.
assert_eq!(a, game_background_filename("steam:730"));
// Distinct ids → distinct files (no `:` or path chars leak through).
assert_ne!(a, game_background_filename("exe:hl2_linux"));
assert!(a.starts_with("game-bg-") && a.ends_with(".png"));
assert!(!a.contains(':') && !a.contains('/') && !a.contains('\\'));
}
#[test]
fn scrim_color_sets_alpha_and_keeps_rgb() {
let base = Color::from_rgb(0.1, 0.2, 0.3);
+2
View File
@@ -64,6 +64,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
addr: endpoint_a.addr(),
sharing: None,
avatar: Default::default(),
game: None,
};
room_a.join(&ticket_str, state_a, vec![]).await?;
println!("Node A joined topic.");
@@ -83,6 +84,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
addr: endpoint_b.addr(),
sharing: None,
avatar: Default::default(),
game: None,
};
room_b.join(&ticket_str, state_b, vec![]).await?;
println!("Node B joined topic.");
+88 -6
View File
@@ -1,7 +1,7 @@
use crate::notify::Sound;
use crate::theme::AppTheme;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::PathBuf;
@@ -199,6 +199,26 @@ pub struct AppConfig {
/// `crate::background::scrim_color`.
#[serde(default = "default_background_dim")]
pub background_dim: f32,
/// Broadcast the detected game as presence next to our avatar (game-detection
/// feature). **Opt-in, default OFF.** Enabling immediately publishes the
/// current game; disabling immediately publishes `game: None`. Toggling this
/// is the only thing that puts our game on the wire — detection itself (for the
/// local background) runs regardless.
#[serde(default)]
pub game_presence_enabled: bool,
/// Per-game UI background overrides (W18), keyed by stable game id
/// (`steam:730`, `exe:hl2_linux`) → path to the processed PNG we wrote in the
/// config dir (see `game_background_path`). The running game's entry wins; with
/// no entry we fall back to the single custom `background`. Local-only; never
/// sent to peers. `BTreeMap` for deterministic serialization.
#[serde(default)]
pub game_backgrounds: BTreeMap<String, String>,
/// User process→display-name mappings for non-Steam game detection, keyed by
/// normalized executable basename (`hl2_linux`) → the name to show/broadcast
/// (`Half-Life 2`). Only exact mappings here are ever matched (we never guess a
/// game from an arbitrary process). Local-only.
#[serde(default)]
pub game_process_map: BTreeMap<String, String>,
/// What a call recording captures (mixed / per-peer stems / both).
#[serde(default)]
pub recording_mode: RecordingMode,
@@ -305,6 +325,9 @@ impl Default for AppConfig {
avatar: crate::avatar::Avatar::default(),
background: None,
background_dim: default_background_dim(),
game_presence_enabled: false,
game_backgrounds: BTreeMap::new(),
game_process_map: BTreeMap::new(),
recording_mode: RecordingMode::default(),
custom_sound_self_join: None,
custom_sound_peer_join: None,
@@ -375,17 +398,31 @@ impl AppConfig {
})
}
/// Path the processed custom-background PNG (W16) is written to, alongside
/// `config.json` in the app config dir. We store our own downscaled copy here
/// (rather than base64 in the config) so the JSON stays small.
pub fn background_path() -> Option<PathBuf> {
/// Path to a processed-background PNG of the given filename, alongside
/// `config.json` in the app config dir. We store our own downscaled copies here
/// (rather than base64 in the config) so the JSON stays small. Used for both
/// the single custom background and the per-game backgrounds.
fn background_dir_path(filename: &str) -> Option<PathBuf> {
dirs::config_dir().map(|mut p| {
p.push("peerspeak");
p.push("background.png");
p.push(filename);
p
})
}
/// Path the single custom-background PNG (W16) is written to.
pub fn background_path() -> Option<PathBuf> {
Self::background_dir_path("background.png")
}
/// Path the processed per-game background PNG (W18) for `game_id` is written
/// to. The filename is an app-owned hash of the id (see
/// `crate::background::game_background_filename`), so raw game ids never appear
/// on disk and the name is always filesystem-safe.
pub fn game_background_path(game_id: &str) -> Option<PathBuf> {
Self::background_dir_path(&crate::background::game_background_filename(game_id))
}
pub fn load() -> Self {
if let Some(path) = Self::config_path()
&& let Ok(contents) = fs::read_to_string(&path)
@@ -480,6 +517,51 @@ mod tests {
);
}
#[test]
fn test_backward_compat_game_detection_fields() {
// A config that predates the game-detection feature (W18) — and crucially
// still carries the W16 single `background` as a plain string — must
// deserialize without error. `AppConfig::load()` silently replaces ANY
// deserialize failure with full defaults, so a broken migration here would
// wipe everyone's settings; this guards that the additive fields kept the
// old shape loadable and that `background` was NOT retyped.
let legacy_json = r#"{
"input_device": "",
"output_device": "",
"noise_gate_threshold": 0.01,
"username": "Eric",
"background": "/home/eric/.config/peerspeak/background.png",
"background_dim": 0.4
}"#;
let cfg: AppConfig = serde_json::from_str(legacy_json).unwrap();
// The pre-existing single background survives untouched (still Option<String>).
assert_eq!(cfg.background.as_deref(), Some("/home/eric/.config/peerspeak/background.png"));
assert!((cfg.background_dim - 0.4).abs() < f32::EPSILON);
// The new game-detection fields default to off/empty → silent, opt-in upgrade.
assert!(!cfg.game_presence_enabled);
assert!(cfg.game_backgrounds.is_empty());
assert!(cfg.game_process_map.is_empty());
}
#[test]
fn test_game_maps_serialize_deterministically() {
// BTreeMap ordering makes the serialized config stable across runs.
let mut cfg = AppConfig::default();
cfg.game_backgrounds.insert("steam:730".into(), "/a.png".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();
// Keys appear in sorted order (exe: before steam:).
let bg = json.find("game_backgrounds").unwrap();
let exe_at = json[bg..].find("exe:hl2_linux").unwrap();
let steam_at = json[bg..].find("steam:730").unwrap();
assert!(exe_at < steam_at, "BTreeMap keys must serialize sorted");
// Full round-trip preserves the maps.
let back: AppConfig = serde_json::from_str(&json).unwrap();
assert_eq!(back.game_backgrounds, cfg.game_backgrounds);
assert_eq!(back.game_process_map, cfg.game_process_map);
}
#[test]
fn test_window_size_fields() {
// Default impl is the standard launch size.
+46 -5
View File
@@ -64,9 +64,16 @@ pub enum CoreCommand {
/// Set the pixelpass binary location (config override, empty = use `$PATH`).
/// Sent at startup so screen-share can resolve the binary.
SetPixelpassPath(Option<String>),
/// Enumerate apps currently producing audio (for the screen-share audio
/// picker, A23). Replies with [`UiEvent::AudioAppsListed`]. Cheap shell-out;
/// safe to call each time the picker opens.
ListAudioApps,
/// Start sharing our screen: spawn a pixelpass host and announce its ticket
/// on our presence so the room can watch. No-op when not in a call.
StartScreenShare,
/// `audio_app` selects which app's audio to capture: `Some(name)` captures
/// only that app (avoiding the call-loopback echo, A23); `None` shares the
/// whole desktop audio (the legacy behavior).
StartScreenShare { audio_app: Option<String> },
/// Stop sharing our screen: kill the pixelpass host and clear the presence
/// ticket. No-op when not sharing.
StopScreenShare,
@@ -89,12 +96,26 @@ pub enum CoreCommand {
/// invisible) and the outbound ping scheduler (invisible = fully dark). Sent at
/// startup from config and whenever the user changes it.
SetPresenceMode(PresenceMode),
/// Toggle broadcasting the detected game as presence (game detection). Opt-in,
/// default OFF. Enabling immediately publishes the current game; disabling
/// immediately publishes `game: None`. Detection for the local background runs
/// regardless. Sent at startup from config and on user toggle.
SetGamePresenceEnabled(bool),
/// Set the manual game-detection override (`Auto` / `None` / a forced game).
/// Forwarded to the detector and applied immediately (bypasses debounce).
SetGameOverride(crate::game::ManualOverride),
/// Replace the user process→display-name mappings used by the non-Steam
/// detection fallback. Sent at startup from config and after Settings edits.
SetGameProcessMap(std::collections::BTreeMap<String, String>),
}
#[derive(Debug, Clone)]
pub enum UiEvent {
RoomJoined { ticket: String, self_id: String },
RoomLeft,
/// Clear room-scoped UI state after a failed in-call room switch, without a
/// leave chime. The persistent identity remains unchanged.
RoomReset,
PeerJoined { id: EndpointId, state: PeerState },
PeerLeft { id: EndpointId },
/// The fixed reconnect grace expired and bounded background gossip recovery
@@ -119,15 +140,29 @@ pub enum UiEvent {
/// string, used to key their avatar (W4).
ChatMessage { from: String, name: String, text: String, attachment: Option<crate::files::ChatAttachment> },
/// An attachment's bytes are now available (auto-fetched for images, or
/// fetched on demand for files). Keyed by attachment id so the UI can match
/// it to the chat entry.
AttachmentReady { id: crate::files::AttachmentId, data: Vec<u8> },
/// 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
/// disambiguates whose bytes these are and stops content aliasing (Tier C
/// F-12).
AttachmentReady { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> },
/// An attachment fetch failed (sender gone, too large, decode error, etc.).
AttachmentFailed { id: crate::files::AttachmentId, error: String },
AttachmentFailed { from: EndpointId, id: crate::files::AttachmentId, error: String },
/// The apps currently producing audio, for the screen-share audio picker
/// (A23). Sorted, deduplicated `application.name`s; empty when nothing is
/// playing or enumeration isn't available. `app_audio_supported` reports
/// whether the resolved pixelpass understands `--strict-audio`: when `false`
/// (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).
AudioAppsListed { apps: Vec<String>, app_audio_supported: bool },
/// Our own screen share started; the UI flips the Share button to "Stop".
ScreenShareStarted,
/// Our own screen share stopped (or failed to start).
ScreenShareStopped,
/// Per-app screen-share audio routing state (A23). `true` = the app we chose
/// is now reaching viewers; `false` = its audio stopped, so under our strict
/// run viewers currently hear silence. The UI shows a transient warning while
/// `false`. Only meaningful while sharing a specific app (not whole-desktop).
ShareAudioActive(bool),
/// 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 = false` means the key file couldn't be read/written and we're
@@ -150,6 +185,12 @@ pub enum UiEvent {
/// failure, this carries the previous truthful mode. The GUI must mirror +
/// persist this so its presence picker matches the endpoint's discovery state.
PresenceModeReverted { mode: PresenceMode },
/// The locally-detected running game changed (game detection). Carries the
/// debounced `DetectedGame` (id + display name + source) or `None` when nothing
/// is detected. The GUI uses the stable `id` to switch the per-game background
/// (W18) and may show a local "Playing …" indicator. Emitted regardless of
/// whether game presence is being broadcast — the broadcast is core's own job.
GameChanged(Option<crate::game::DetectedGame>),
/// Core finished orderly app shutdown and the GUI can exit.
ShutdownComplete,
Error(String),
+568 -109
View File
@@ -7,7 +7,7 @@ use crate::audio::eq::{Eq, EqSettings};
use crate::codec::{AudioEncoder, opus_impl::OpusEncoder};
use crate::core::jitter::{JitterBuffer, FRAME_SAMPLES};
use crate::network::{
NetworkTransport, RoomState, PeerState, RoomEvent, ConnEvent, PeerSpeakTicket,
NetworkTransport, RoomState, SelfPresence, RoomEvent, ConnEvent, PeerSpeakTicket,
iroh_impl::{IrohTransport, AudioRouter, FileRouter},
gossip::IrohGossipState,
};
@@ -80,6 +80,33 @@ fn audio_datagram_len_ok(len: usize) -> bool {
(4..=4 + MAX_OPUS_PAYLOAD).contains(&len)
}
/// The presence label to broadcast for a detected game: its display name,
/// sanitized + length-capped, or `None` when there's no game or no broadcastable
/// name (a Steam appid without a manifest name, or a label that sanitizes empty).
/// Sanitizing here as well as at the gossip ingest boundary keeps the outgoing
/// value clean even though every peer re-sanitizes on receipt.
fn game_presence_label(game: Option<&crate::game::DetectedGame>) -> Option<String> {
game.and_then(|g| g.name.as_deref())
.map(crate::sanitize::sanitize_game_label)
.filter(|s| !s.is_empty())
}
/// Wait for the next game update. A closed sender permanently disables the
/// source by clearing the receiver; subsequent calls remain pending instead of
/// leaving an always-ready closed branch in the core `select!` loop.
async fn next_game_change(
game_rx: &mut Option<tokio::sync::watch::Receiver<Option<crate::game::DetectedGame>>>,
) -> Option<Option<crate::game::DetectedGame>> {
let Some(rx) = game_rx.as_mut() else {
return std::future::pending().await;
};
if rx.changed().await.is_err() {
*game_rx = None;
return None;
}
Some(rx.borrow_and_update().clone())
}
fn arm_discovery_retry(
discovery_deadline: &mut Option<tokio::time::Instant>,
now: tokio::time::Instant,
@@ -105,14 +132,33 @@ type GraceTimers = Arc<std::sync::Mutex<HashMap<EndpointId, tokio::task::JoinHan
type SeenConnected = Arc<std::sync::Mutex<HashSet<EndpointId>>>;
type KnownPeers =
Arc<std::sync::Mutex<HashMap<String, HashMap<EndpointId, EndpointAddr>>>>;
Arc<std::sync::Mutex<HashMap<[u8; 32], HashMap<EndpointId, EndpointAddr>>>>;
/// Per-topic cap on the retained rejoin-bootstrap / recovery target table
/// (Tier C recovery-identity cap). Set comfortably above the live-roster cap
/// (`gossip::MAX_ACTIVE_PEERS`, 32) so a legitimate room — even one where every
/// member drops at once during a relay outage — never hits it, while an insider
/// who grace-cycles distinct identities (join, drop without a signed Leave,
/// repeat) cannot grow the table without bound. Combined with the recovery
/// terminal budget (which forgets a retained address when it gives up), abandoned
/// identities drain on their own, so this cap is a deterministic ceiling rather
/// than a pinnable slot pool.
const MAX_RETAINED_PEERS: usize = 64;
/// Whether a peer may be inserted into a retained-target table at `len` entries.
/// An update to an id already present is always allowed (it only refreshes an
/// address); a brand-new id is admitted only while below the cap. Mirrors the
/// gossip roster's `admit_into_roster` reject-when-full admission.
fn admit_retained(len: usize, is_new_id: bool, cap: usize) -> bool {
!is_new_id || len < cap
}
#[derive(Clone)]
struct RecoveryContext {
coordinator: RecoveryCoordinator,
room_state: Arc<IrohGossipState>,
known_peers: KnownPeers,
ticket: String,
topic_id: [u8; 32],
}
impl RecoveryContext {
@@ -120,7 +166,7 @@ impl RecoveryContext {
self.known_peers
.lock()
.unwrap()
.get(&self.ticket)
.get(&self.topic_id)
.and_then(|peers| peers.get(peer_id))
.cloned()
}
@@ -130,7 +176,7 @@ impl RecoveryContext {
}
fn forget(&self, peer_id: EndpointId) {
if let Some(peers) = self.known_peers.lock().unwrap().get_mut(&self.ticket) {
if let Some(peers) = self.known_peers.lock().unwrap().get_mut(&self.topic_id) {
peers.remove(&peer_id);
}
self.coordinator.cancel(peer_id);
@@ -495,6 +541,7 @@ struct ActiveSession {
event_task: tokio::task::JoinHandle<()>,
conn_event_task: tokio::task::JoinHandle<()>,
recovery_task: tokio::task::JoinHandle<()>,
recovery_terminal_task: tokio::task::JoinHandle<()>,
grace_timers: GraceTimers,
transport: Arc<IrohTransport>,
/// Loaded PipeWire echo-cancel module (if enabled); unloads on drop.
@@ -530,6 +577,7 @@ impl ActiveSession {
handle.abort();
}
self.recovery_task.abort();
self.recovery_terminal_task.abort();
crate::log_msg("Aborted tasks");
let audio_backend_clone = audio_backend.clone();
@@ -717,25 +765,70 @@ async fn build_net_stack(
})
}
/// Maximum number of *automatic* chat-attachment fetches in flight at once.
///
/// Auto-fetch (inline image preview) is triggered by an untrusted peer's chat
/// message, and each fetch is a detached task that can spend up to ~60s dialing
/// and reading. Without a bound, a room insider could spam attachment-carrying
/// chat to accumulate arbitrary pending tasks/dials (Tier C F-02). When the bound
/// is reached we simply skip the auto-fetch; the descriptor still renders and the
/// user can fetch it on demand (which is not rate-limited here).
const MAX_INFLIGHT_ATTACHMENT_FETCHES: usize = 4;
/// In-flight `(author, attachment_id)` markers for bounded, deduplicated auto-
/// fetches (Tier C F-02). Bounded by [`MAX_INFLIGHT_ATTACHMENT_FETCHES`].
type InflightAttachments =
Arc<std::sync::Mutex<HashSet<(EndpointId, crate::files::AttachmentId)>>>;
/// RAII bookkeeping for one bounded auto-fetch: holds the concurrency permit for
/// the task's lifetime and clears the in-flight `(author, id)` marker when the
/// fetch finishes (success OR failure), so the same image can be retried later.
struct AutoFetchGuard {
_permit: tokio::sync::OwnedSemaphorePermit,
inflight: InflightAttachments,
key: (EndpointId, crate::files::AttachmentId),
}
impl Drop for AutoFetchGuard {
fn drop(&mut self) {
self.inflight.lock().unwrap().remove(&self.key);
}
}
/// Whether to AUTO-fetch a chat image attachment. Only authenticated roster
/// authors qualify (closing the non-roster injection vector), and a `(author,
/// id)` already being fetched is skipped (dedup). The concurrency bound itself is
/// enforced separately by the permit. Pure → unit-testable (Tier C F-02).
fn should_auto_fetch(is_image: bool, author_in_roster: bool, already_inflight: bool) -> bool {
is_image && author_in_roster && !already_inflight
}
/// Fetch a chat attachment's bytes from `from` over the file plane in a detached
/// task, then report the result to the UI via [`UiEvent::AttachmentReady`] /
/// [`UiEvent::AttachmentFailed`] keyed by the attachment id. For images
/// [`UiEvent::AttachmentFailed`], tagged with `from` so the UI keys the bytes by
/// `(author, id)` and can't alias a same-id attachment from another sender. For images
/// (`is_image`) the bytes are defensively re-validated (decodable + within pixel
/// limits) before being handed to the renderer; an "Image" that doesn't decode is
/// reported as a failure rather than rendered.
/// reported as a failure rather than rendered. `guard` is `Some` for bounded
/// auto-fetches and `None` for user-initiated fetches; it is dropped when the
/// task ends, releasing the concurrency permit and the dedup marker.
fn spawn_attachment_fetch(
transport: Arc<IrohTransport>,
ui_tx: mpsc::Sender<UiEvent>,
from: EndpointId,
att: crate::files::ChatAttachment,
is_image: bool,
guard: Option<AutoFetchGuard>,
) {
tokio::spawn(async move {
// Held for the whole fetch; dropped here on completion (Tier C F-02).
let _guard = guard;
match transport.fetch_attachment(from, &att).await {
Ok(data) => {
if is_image && crate::files::validate_image_bytes(&data).is_none() {
let _ = ui_tx
.send(UiEvent::AttachmentFailed {
from,
id: att.id,
error: "received image failed to decode".to_string(),
})
@@ -743,12 +836,12 @@ fn spawn_attachment_fetch(
return;
}
let _ = ui_tx
.send(UiEvent::AttachmentReady { id: att.id, data })
.send(UiEvent::AttachmentReady { from, id: att.id, data })
.await;
}
Err(e) => {
let _ = ui_tx
.send(UiEvent::AttachmentFailed { id: att.id, error: e.to_string() })
.send(UiEvent::AttachmentFailed { from, id: att.id, error: e.to_string() })
.await;
}
}
@@ -917,10 +1010,38 @@ async fn run_core_loop(
let peer_gate = Arc::new(Mutex::new(HashMap::<EndpointId, f32>::new()));
// Peers locally muted by us: decoded for level metering but not mixed.
let locally_muted = Arc::new(Mutex::new(HashSet::<EndpointId>::new()));
let mut current_name = "Anonymous".to_string();
// Our chosen avatar (W4), set on Join and changeable via SetAvatar; included
// in every self-state we announce over presence.
let mut current_avatar = crate::avatar::Avatar::default();
// Sticky identity fields of our own presence (display name + W4 avatar), set on
// Join and changed via SetName/SetAvatar. Combined with the volatile per-announce
// fields (mute/addr/share ticket) by `SelfPresence::to_state` — the single place
// our `PeerState` is built. Defaults match the prior `current_name`/`current_avatar`.
let mut presence = SelfPresence {
name: "Anonymous".to_string(),
avatar: crate::avatar::Avatar::default(),
game: None,
};
// Game detection (W17/W18): a background worker polls Steam state + the process
// list and publishes the debounced running game on a watch channel. Detection
// runs continuously (the GUI uses it for the local per-game background); whether
// the game is *broadcast* as presence is gated by `game_presence_enabled` (opt-in,
// seeded false; the GUI sends `SetGamePresenceEnabled` from config at startup).
// The override + process map start at their defaults and are set via commands.
let (game_detector, mut game_rx) = match crate::game::detector::GameDetector::spawn(
crate::game::ManualOverride::Auto,
std::collections::BTreeMap::new(),
) {
Ok(detector) => {
let rx = detector.subscribe();
(Some(detector), Some(rx))
}
Err(e) => {
crate::log_msg(&format!("game detector unavailable: {e}"));
(None, None)
}
};
let mut game_presence_enabled = false;
// The latest debounced detection, kept regardless of the broadcast toggle so a
// later opt-in can immediately publish whatever is currently running.
let mut current_game: Option<crate::game::DetectedGame> = None;
let mut network_mode = NetworkMode::default();
// Pixelpass binary override (config), and the ticket of our own active screen
// share (rides our presence so the room — incl. late joiners — can watch).
@@ -953,22 +1074,34 @@ async fn run_core_loop(
// Join, cleared on Leave.
let current_room: Arc<std::sync::Mutex<Option<crate::presence::RoomPresence>>> =
Arc::new(std::sync::Mutex::new(None));
let presence_rate_limiter =
Arc::new(std::sync::Mutex::new(crate::presence::PresenceRateLimiter::default()));
// Reply policy for the idle friends listener (B2): answer friends only, never
// while invisible (`should_answer`), and report our current gathering so a friend
// can one-click join. Reads the shared snapshots, so it stays correct as they
// change and survives a network-stack rebuild. Pure-sync (no awaits, no lock held
// across one). Built once and handed to every `build_net_stack`.
// can one-click join. Rate-limits allowed friends before building a reply, so a
// spammy saved peer gets the same silent close as an unauthorized peer. Reads the
// shared snapshots, so it stays correct as they change and survives a network-stack
// rebuild. Pure-sync (no awaits, no lock held across one). Built once and handed
// to every `build_net_stack`.
let friends_handler: crate::presence_net::Handler = {
let friends = friends.clone();
let presence_mode = presence_mode.clone();
let current_room = current_room.clone();
let presence_rate_limiter = presence_rate_limiter.clone();
Arc::new(move |from| {
let mode = *presence_mode.lock().unwrap();
let allowed = crate::presence::should_answer(&from, &friends.lock().unwrap(), mode);
if !allowed {
return None;
}
if !presence_rate_limiter
.lock()
.unwrap()
.allow(from, std::time::Instant::now())
{
return None;
}
let room = current_room.lock().unwrap().clone();
Some(crate::presence::ControlMsg::Pong { room })
})
@@ -1027,6 +1160,31 @@ async fn run_core_loop(
Some(cmd) => cmd,
None => break,
},
game_change = next_game_change(&mut game_rx) => {
// The detector worker published a new debounced game (or `None`).
let Some(detected) = game_change else {
// Worker gone unexpectedly. The helper fused this source, so
// this logs once and the closed channel cannot spin select!.
crate::log_msg("game detector stopped; disabling game detection");
continue;
};
current_game = detected.clone();
// Always tell the GUI for the local per-game background + indicator.
let _ = ui_tx.send(UiEvent::GameChanged(detected.clone())).await;
// Broadcast as presence only when opted in; re-announce if in a room.
if game_presence_enabled {
presence.game = game_presence_label(detected.as_ref());
if let Some(session) = &active_session {
let self_state = presence.to_state(
is_muted.load(Ordering::Relaxed),
net.endpoint.addr(),
current_sharing.clone(),
);
let _ = session.room_state.update_self_state(self_state).await;
}
}
continue;
}
_ = ping_interval.tick() => {
// Fully dark while Invisible (the user's choice): don't even probe,
// so nothing we do touches a friend's machine. Otherwise refresh in a
@@ -1125,8 +1283,9 @@ async fn run_core_loop(
}
CoreCommand::Join { name, ticket, room_name, input_device, output_device, echo_cancellation, avatar } => {
current_name = name.clone();
current_avatar = avatar;
presence.name = name.clone();
presence.avatar = avatar;
let was_in_room = active_session.is_some();
// Finalize any recording before tearing down the old session — its
// capture/mixer feeders are about to stop.
@@ -1139,6 +1298,7 @@ async fn run_core_loop(
session.shutdown(audio_backend.clone()).await;
net.audio_router.clear();
net.file_router.clear();
*current_room.lock().unwrap() = None;
}
// If a network-mode / identity change was deferred while a call was
@@ -1188,6 +1348,17 @@ async fn run_core_loop(
));
ticket_str
};
let topic_id = match PeerSpeakTicket::topic_of(&ticket_str) {
Some(topic_id) => topic_id,
None => {
crate::log_msg("Error invalid room ticket");
if was_in_room {
let _ = ui_tx.send(UiEvent::RoomReset).await;
}
let _ = ui_tx.send(UiEvent::Error("invalid room ticket".to_string())).await;
continue;
}
};
// Per-session transport over the persistent endpoint, bound to the
// persistent audio router so this call's inbound audio links route
@@ -1206,22 +1377,20 @@ async fn run_core_loop(
// Fresh join starts not sharing; clear any stale share ticket.
current_sharing = None;
let self_state = PeerState {
name: current_name.clone(),
is_muted: is_muted.load(Ordering::Relaxed),
addr: endpoint.addr(),
sharing: None,
avatar: current_avatar.clone(),
};
let self_state = presence.to_state(
is_muted.load(Ordering::Relaxed),
endpoint.addr(),
None,
);
// Snapshot THIS room's retained peers (by ticket) as extra bootstrap
// Snapshot THIS room's retained peers (by topic) as extra bootstrap
// targets so a rejoin can dial them (A8) — including after a detour
// through another room, since the per-ticket archive isn't cleared.
// through another room, since the per-topic archive isn't cleared.
// Resolution rides the persistent address book.
let extra_bootstrap: Vec<EndpointAddr> = known_peers
.lock()
.unwrap()
.get(&ticket_str)
.get(&topic_id)
.map(|peers| peers.values().cloned().collect())
.unwrap_or_default();
@@ -1238,6 +1407,9 @@ async fn run_core_loop(
));
if let Err(e) = room_state.join(&ticket_str, self_state.clone(), extra_bootstrap).await {
crate::log_msg(&format!("Error room_state.join failed: {:?}", e));
if was_in_room {
let _ = ui_tx.send(UiEvent::RoomReset).await;
}
let _ = ui_tx.send(UiEvent::Error(format!("Failed to join room: {}", e))).await;
net.audio_router.clear();
net.file_router.clear();
@@ -1264,12 +1436,11 @@ async fn run_core_loop(
output_device.as_deref(),
) {
Ok(guard) => {
let source_name = guard.source_name().to_string();
let sink_name = guard.sink_name().to_string();
echo_cancel_guard = Some(guard);
crate::log_msg("Echo cancellation enabled");
(
Some(crate::audio::echo_cancel::EC_SOURCE.to_string()),
Some(crate::audio::echo_cancel::EC_SINK.to_string()),
)
(Some(source_name), Some(sink_name))
}
Err(e) => {
crate::log_msg(&format!(
@@ -1290,6 +1461,9 @@ async fn run_core_loop(
let (capture_target, playback_target) = (input_device.clone(), output_device.clone());
if let Err(e) = audio_backend.start_capture(capture_tx, capture_target) {
if was_in_room {
let _ = ui_tx.send(UiEvent::RoomReset).await;
}
let _ = ui_tx.send(UiEvent::Error(format!("Failed to start capture: {}", e))).await;
let _ = room_state.leave().await;
net.audio_router.clear();
@@ -1302,6 +1476,9 @@ async fn run_core_loop(
// production to the hardware clock instead of a fixed timer.
let ring_fill = Arc::new(AtomicUsize::new(0));
if let Err(e) = audio_backend.start_playback(playback_rx, playback_target, ring_fill.clone()) {
if was_in_room {
let _ = ui_tx.send(UiEvent::RoomReset).await;
}
let _ = ui_tx.send(UiEvent::Error(format!("Failed to start playback: {}", e))).await;
let _ = audio_backend.stop();
let _ = room_state.leave().await;
@@ -1603,23 +1780,57 @@ async fn run_core_loop(
// track in Both mode) one aligned frame per cycle; Mixed mode
// writes the single blended file as before.
if mt_active {
if let Some(mt) = multitrack_mixer.lock().unwrap().as_mut() {
let res = (|| -> std::io::Result<()> {
let write_err = multitrack_mixer
.lock()
.unwrap()
.as_mut()
.map(|mt| -> std::io::Result<()> {
for (id, f) in &stems {
mt.write_peer(*id, f)?;
}
mt.write_mix(&record_mix)?;
mt.end_cycle()
})();
if let Err(e) = res {
crate::log_msg(&format!("Multitrack write failed: {e}"));
}
})
.transpose()
.err();
if let Some(e) = write_err {
crate::log_msg(&format!("Multitrack write failed: {e}"));
stop_recording(
&recorder_mixer,
&is_recording_mixer,
&multitrack_mixer,
&is_multitrack_mixer,
&ui_tx_mixer,
).await;
let _ = ui_tx_mixer
.send(UiEvent::Error(format!(
"Recording stopped — write failed: {e}"
)))
.await;
}
} else if is_recording_mixer.load(Ordering::Relaxed) {
let write_err = recorder_mixer
.lock()
.unwrap()
.as_mut()
.map(|rec| rec.write_frame(&record_mix))
.transpose()
.err();
if let Some(e) = write_err {
crate::log_msg(&format!("Recording write failed: {e}"));
stop_recording(
&recorder_mixer,
&is_recording_mixer,
&multitrack_mixer,
&is_multitrack_mixer,
&ui_tx_mixer,
).await;
let _ = ui_tx_mixer
.send(UiEvent::Error(format!(
"Recording stopped — write failed: {e}"
)))
.await;
}
} else if is_recording_mixer.load(Ordering::Relaxed)
&& let Some(rec) = recorder_mixer.lock().unwrap().as_mut()
&& let Err(e) = rec.write_frame(&record_mix)
{
crate::log_msg(&format!("Recording write failed: {e}"));
}
let frame_to_send = if is_deafened_clone.load(Ordering::Relaxed) {
@@ -1646,6 +1857,9 @@ async fn run_core_loop(
let mut room_events = match room_state.subscribe_events().await {
Ok(rx) => rx,
Err(e) => {
if was_in_room {
let _ = ui_tx.send(UiEvent::RoomReset).await;
}
let _ = ui_tx.send(UiEvent::Error(format!("Failed to subscribe events: {}", e))).await;
continue;
}
@@ -1660,29 +1874,63 @@ async fn run_core_loop(
let multitrack_events = multitrack.clone();
let is_multitrack_events = is_multitrack.clone();
let known_peers_events = known_peers.clone();
// The ticket of the room this event loop serves, so peer add/remove
// updates the right per-ticket bucket in `known_peers` (A8 archive).
let ticket_events = ticket_str.clone();
let (recovery_coordinator, recovery_task) =
// The topic of the room this event loop serves, so peer add/remove
// updates the right per-topic bucket in `known_peers` (A8 archive).
let room_topic = topic_id;
let (recovery_coordinator, recovery_task, recovery_terminal_rx) =
RecoveryCoordinator::spawn(room_state.clone());
let recovery_context = RecoveryContext {
coordinator: recovery_coordinator,
room_state: room_state.clone(),
known_peers: known_peers.clone(),
ticket: ticket_str.clone(),
topic_id,
};
let recovery_events = recovery_context.clone();
// Drain the recovery coordinator's terminal-eviction signals (Tier C
// recovery-identity cap). When background recovery exhausts its budget
// for a peer, forget its retained dial target so the per-topic retain
// table drains, scrub residual seen-connected state, and surface the
// failure. A peer that later returns can still rejoin via a gossip
// announce, so giving up never blocks a legitimate reconnect.
let recovery_terminal_ctx = recovery_context.clone();
let seen_connected_terminal = seen_connected.clone();
let ui_tx_terminal = ui_tx.clone();
let recovery_terminal_task = tokio::spawn(async move {
let mut terminal_rx = recovery_terminal_rx;
while let Some(peer_id) = terminal_rx.recv().await {
crate::log_msg(&format!(
"Background recovery gave up on peer {peer_id:?}; forgetting retained target"
));
recovery_terminal_ctx.forget(peer_id);
seen_connected_terminal.lock().unwrap().remove(&peer_id);
let _ = ui_tx_terminal
.send(UiEvent::PeerConnectionFailed { id: peer_id })
.await;
}
});
// Friends store + ui sender, so a connected peer who is a friend has
// their saved address auto-healed (W7) — populates `last_addr` so the
// presence scheduler can reach them later.
let friends_events = friends.clone();
let friends_read_only_events = friends_read_only;
// Bounded, deduplicated auto-fetch of chat image attachments (Tier C
// F-02): the permit pool caps concurrent fetch tasks; the in-flight
// set dedups identical (author, id) pairs.
let attachment_limiter =
Arc::new(tokio::sync::Semaphore::new(MAX_INFLIGHT_ATTACHMENT_FETCHES));
let inflight_attachments: InflightAttachments =
Arc::new(std::sync::Mutex::new(HashSet::new()));
let event_task = tokio::spawn(async move {
// The authenticated roster for this room, maintained from the
// same sequential event stream. Only its members may trigger an
// automatic attachment fetch (Tier C F-02).
let mut roster: HashSet<EndpointId> = HashSet::new();
while let Some(event) = room_events.recv().await {
match event {
RoomEvent::PeerJoined(peer_id, state) => {
// A (re)join means the peer is back — cancel any
// pending reconnect grace timer before re-adding it.
roster.insert(peer_id);
cancel_grace_timer(&grace_timers_events, &peer_id);
recovery_events.cancel(peer_id);
transport_events.admit_audio_sender(peer_id);
@@ -1706,14 +1954,23 @@ async fn run_core_loop(
)
.await;
}
// Retain this peer under this room's ticket as a
// future rejoin bootstrap target (A8).
known_peers_events
.lock()
.unwrap()
.entry(ticket_events.clone())
.or_default()
.insert(peer_id, state.addr.clone());
// Retain this peer under this room's topic as a
// future rejoin bootstrap target (A8), bounded by the
// per-topic retain cap (Tier C recovery-identity cap):
// refreshing a peer we already track is always allowed,
// a brand-new identity only while below the cap.
{
let mut kp = known_peers_events.lock().unwrap();
let bucket = kp.entry(room_topic).or_default();
let is_new_id = !bucket.contains_key(&peer_id);
if admit_retained(bucket.len(), is_new_id, MAX_RETAINED_PEERS) {
bucket.insert(peer_id, state.addr.clone());
} else {
crate::log_msg(&format!(
"Retain table full ({MAX_RETAINED_PEERS}); not retaining new peer {peer_id:?} for rejoin"
));
}
}
// If a multitrack recording is live, give this peer
// its own stem track (silence-padded back to t=0).
if is_multitrack_events.load(Ordering::Relaxed)
@@ -1726,6 +1983,7 @@ async fn run_core_loop(
}
RoomEvent::PeerLeft(peer_id) => {
// Graceful leave — evict immediately.
roster.remove(&peer_id);
cancel_grace_timer(&grace_timers_events, &peer_id);
seen_connected_events.lock().unwrap().remove(&peer_id);
// A signed Leave cancels background recovery and
@@ -1763,13 +2021,21 @@ async fn run_core_loop(
.await;
}
// Refresh this room's retained rejoin target with the
// fresh addr (A8).
known_peers_events
.lock()
.unwrap()
.entry(ticket_events.clone())
.or_default()
.insert(peer_id, state.addr.clone());
// fresh addr (A8), under the per-topic retain cap. A
// re-announce from a peer we already track always
// refreshes; a new identity is bounded by the cap.
{
let mut kp = known_peers_events.lock().unwrap();
let bucket = kp.entry(room_topic).or_default();
let is_new_id = !bucket.contains_key(&peer_id);
if admit_retained(bucket.len(), is_new_id, MAX_RETAINED_PEERS) {
bucket.insert(peer_id, state.addr.clone());
} else {
crate::log_msg(&format!(
"Retain table full ({MAX_RETAINED_PEERS}); not retaining new peer {peer_id:?} for rejoin"
));
}
}
let _ = ui_tx_events.send(UiEvent::PeerUpdated { id: peer_id, state }).await;
}
RoomEvent::ChatMessage { from, name, text, ts: _, attachment } => {
@@ -1777,16 +2043,47 @@ async fn run_core_loop(
// without a click; non-image files wait for an explicit
// FetchAttachment (the "Save" chip). The descriptor was
// already filename-sanitized + size-capped on ingest.
if let Some(att) = attachment.clone()
&& att.kind == crate::files::AttachmentKind::Image
{
spawn_attachment_fetch(
transport_events.clone(),
ui_tx_events.clone(),
from,
att,
true,
);
//
// The auto path is an untrusted-peer-triggered detached
// task, so it is gated (Tier C F-02): only roster authors
// qualify, identical (author,id) pairs are deduped, and a
// permit pool caps concurrent fetch tasks. The chat TEXT
// is always forwarded (it's sanitized at the UI edge);
// only the fetch is bounded.
if let Some(att) = attachment.clone() {
let is_image = att.kind == crate::files::AttachmentKind::Image;
let key = (from, att.id);
let already_inflight =
inflight_attachments.lock().unwrap().contains(&key);
if should_auto_fetch(is_image, roster.contains(&from), already_inflight) {
// Reserve the dedup slot, then a permit. If the
// pool is exhausted, drop the auto-fetch (and the
// dedup marker) — the descriptor still shows and
// the user can fetch on demand.
inflight_attachments.lock().unwrap().insert(key);
match attachment_limiter.clone().try_acquire_owned() {
Ok(permit) => {
spawn_attachment_fetch(
transport_events.clone(),
ui_tx_events.clone(),
from,
att,
true,
Some(AutoFetchGuard {
_permit: permit,
inflight: inflight_attachments.clone(),
key,
}),
);
}
Err(_) => {
inflight_attachments.lock().unwrap().remove(&key);
crate::log_msg(
"Chat attachment auto-fetch limit reached; skipping (fetch on demand)",
);
}
}
}
}
let _ = ui_tx_events.send(UiEvent::ChatMessage {
from: from.to_string(),
@@ -1827,6 +2124,9 @@ async fn run_core_loop(
let mut conn_events = match transport.subscribe_conn_events().await {
Ok(rx) => rx,
Err(e) => {
if was_in_room {
let _ = ui_tx.send(UiEvent::RoomReset).await;
}
let _ = ui_tx.send(UiEvent::Error(format!("Failed to subscribe conn events: {}", e))).await;
continue;
}
@@ -1853,6 +2153,7 @@ async fn run_core_loop(
event_task,
conn_event_task,
recovery_task,
recovery_terminal_task,
grace_timers,
transport: transport.clone(),
#[cfg(target_os = "linux")]
@@ -1917,29 +2218,25 @@ async fn run_core_loop(
is_muted.store(new_state, Ordering::Relaxed);
if let Some(session) = &active_session {
let self_state = PeerState {
name: current_name.clone(),
is_muted: new_state,
addr: net.endpoint.addr(),
sharing: current_sharing.clone(),
avatar: current_avatar.clone(),
};
let self_state = presence.to_state(
new_state,
net.endpoint.addr(),
current_sharing.clone(),
);
let _ = session.room_state.update_self_state(self_state).await;
}
}
CoreCommand::SetAvatar(avatar) => {
current_avatar = avatar;
presence.avatar = avatar;
// Re-announce presence so the room (incl. late joiners, via the
// retained presence) picks up the new avatar (W4).
if let Some(session) = &active_session {
let self_state = PeerState {
name: current_name.clone(),
is_muted: is_muted.load(Ordering::Relaxed),
addr: net.endpoint.addr(),
sharing: current_sharing.clone(),
avatar: current_avatar.clone(),
};
let self_state = presence.to_state(
is_muted.load(Ordering::Relaxed),
net.endpoint.addr(),
current_sharing.clone(),
);
let _ = session.room_state.update_self_state(self_state).await;
}
}
@@ -2184,6 +2481,39 @@ async fn run_core_loop(
}
}
CoreCommand::SetGamePresenceEnabled(enabled) => {
game_presence_enabled = enabled;
// Recompute our broadcast label: the current game when enabling,
// cleared when disabling. Publish immediately (D8) so peers see the
// game appear/disappear at once, not on the next detector tick.
presence.game = if enabled {
game_presence_label(current_game.as_ref())
} else {
None
};
if let Some(session) = &active_session {
let self_state = presence.to_state(
is_muted.load(Ordering::Relaxed),
net.endpoint.addr(),
current_sharing.clone(),
);
let _ = session.room_state.update_self_state(self_state).await;
}
}
CoreCommand::SetGameOverride(override_) => {
// Applied on the detector's next poll, immediately (bypasses debounce).
if let Some(detector) = &game_detector {
detector.set_override(override_);
}
}
CoreCommand::SetGameProcessMap(map) => {
if let Some(detector) = &game_detector {
detector.set_process_map(map);
}
}
CoreCommand::SetRecordingMode(mode) => {
recording_mode = mode;
}
@@ -2205,11 +2535,13 @@ async fn run_core_loop(
.unwrap_or(0);
let result: Result<String, String> = if recording_mode.is_multitrack() {
// Multitrack/Both: a per-session directory of stems.
let stamp = crate::audio::recorder::timestamp_filename(now);
let session_dir = base.join(stamp.trim_end_matches(".wav"));
std::fs::create_dir_all(&session_dir)
std::fs::create_dir_all(&base)
.map_err(|e| e.to_string())
.and_then(|_| {
crate::audio::multitrack::create_session_dir(&base, now)
.map_err(|e| e.to_string())
})
.and_then(|session_dir| {
MultitrackRecorder::create(
&session_dir,
FRAME_SAMPLES,
@@ -2297,12 +2629,15 @@ async fn run_core_loop(
CoreCommand::FetchAttachment { from, attachment } => {
if let Some(session) = &active_session {
let is_image = attachment.kind == crate::files::AttachmentKind::Image;
// User-initiated (the "Save" chip): not bounded here — a human
// click rate-limits it. The auto path (F-02) passes a guard.
spawn_attachment_fetch(
session.transport.clone(),
ui_tx.clone(),
from,
attachment,
is_image,
None,
);
}
}
@@ -2311,7 +2646,29 @@ async fn run_core_loop(
pixelpass_override = path.filter(|p| !p.trim().is_empty());
}
CoreCommand::StartScreenShare => {
CoreCommand::ListAudioApps => {
// Probe whether this pixelpass supports `--strict-audio` before
// offering per-app capture: an older binary would reject the flag
// and hard-fail the share (audit P2). When unsupported (or
// pixelpass is missing), skip enumeration and let the picker show
// whole-desktop audio only — never a best-effort `--app` that
// would reopen the A23 echo.
let app_audio_supported =
match crate::screenshare::pixelpass_path(pixelpass_override.as_deref()) {
Some(bin) => crate::screenshare::supports_strict_audio(&bin).await,
None => false,
};
let apps = if app_audio_supported {
crate::screenshare::list_audio_apps().await
} else {
Vec::new()
};
let _ = ui_tx
.send(UiEvent::AudioAppsListed { apps, app_audio_supported })
.await;
}
CoreCommand::StartScreenShare { audio_app } => {
let Some(session) = &mut active_session else {
let _ = ui_tx
.send(UiEvent::Error("Join a call before sharing your screen".into()))
@@ -2332,18 +2689,42 @@ async fn run_core_loop(
continue;
}
};
match crate::screenshare::spawn_host(&bin).await {
// Forward pixelpass `app_audio` events (only emitted when an app
// is selected) to the UI so it can warn when the chosen app's
// audio drops. The channel closes when the host dies (drain hits
// EOF), ending the forwarder task on its own.
let notices = audio_app.as_deref().map(|_| {
let (tx, mut rx) =
tokio::sync::mpsc::unbounded_channel::<crate::screenshare::PixelpassEvent>();
let ui_tx_notices = ui_tx.clone();
tokio::spawn(async move {
while let Some(ev) = rx.recv().await {
let active = match ev {
crate::screenshare::PixelpassEvent::AppAudioRouted => true,
crate::screenshare::PixelpassEvent::AppAudioLost => false,
_ => continue,
};
if ui_tx_notices
.send(UiEvent::ShareAudioActive(active))
.await
.is_err()
{
break;
}
}
});
tx
});
match crate::screenshare::spawn_host(&bin, audio_app.as_deref(), notices).await {
Ok((child, ticket)) => {
crate::log_msg("Screen share host started");
session.screenshare_host = Some(child);
current_sharing = Some(ticket.clone());
let self_state = PeerState {
name: current_name.clone(),
is_muted: is_muted.load(Ordering::Relaxed),
addr: net.endpoint.addr(),
sharing: Some(ticket),
avatar: current_avatar.clone(),
};
let self_state = presence.to_state(
is_muted.load(Ordering::Relaxed),
net.endpoint.addr(),
Some(ticket),
);
let _ = session.room_state.update_self_state(self_state).await;
let _ = ui_tx.send(UiEvent::ScreenShareStarted).await;
}
@@ -2362,13 +2743,11 @@ async fn run_core_loop(
let _ = child.kill().await;
crate::log_msg("Screen share host stopped");
}
let self_state = PeerState {
name: current_name.clone(),
is_muted: is_muted.load(Ordering::Relaxed),
addr: net.endpoint.addr(),
sharing: None,
avatar: current_avatar.clone(),
};
let self_state = presence.to_state(
is_muted.load(Ordering::Relaxed),
net.endpoint.addr(),
None,
);
let _ = session.room_state.update_self_state(self_state).await;
}
let _ = ui_tx.send(UiEvent::ScreenShareStopped).await;
@@ -2409,15 +2788,95 @@ async fn run_core_loop(
#[cfg(test)]
mod tests {
use super::{
apply_volume, audio_datagram_len_ok, frame_level, mix_frames, mix_stereo_frames,
stereo_to_mono, MicLevelMeter, MAX_OPUS_PAYLOAD, MIC_LEVEL_REPORT_SAMPLES,
admit_retained, apply_volume, audio_datagram_len_ok, frame_level, mix_frames,
mix_stereo_frames, next_game_change, should_auto_fetch, stereo_to_mono, KnownPeers,
MicLevelMeter, PeerSpeakTicket, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS,
MIC_LEVEL_REPORT_SAMPLES,
};
#[test]
fn admit_retained_rejects_only_new_ids_at_the_cap() {
// Below the cap, a brand-new identity is retained.
assert!(admit_retained(0, true, MAX_RETAINED_PEERS));
assert!(admit_retained(MAX_RETAINED_PEERS - 1, true, MAX_RETAINED_PEERS));
// At the cap, a brand-new identity is refused — this is the bound that stops
// an insider grace-cycling distinct identities from growing the retain table.
assert!(!admit_retained(MAX_RETAINED_PEERS, true, MAX_RETAINED_PEERS));
// A peer already tracked always refreshes, even at (or past) the cap: it only
// updates an existing address and never adds a slot.
assert!(admit_retained(MAX_RETAINED_PEERS, false, MAX_RETAINED_PEERS));
assert!(admit_retained(MAX_RETAINED_PEERS + 5, false, MAX_RETAINED_PEERS));
}
#[test]
fn auto_fetch_only_for_roster_images_not_already_inflight() {
// The happy path: a roster author's brand-new image attachment.
assert!(should_auto_fetch(true, true, false));
// A non-image (generic file) never auto-fetches — it waits for "Save".
assert!(!should_auto_fetch(false, true, false));
// A non-roster author (e.g. a sock puppet that never announced) is rejected,
// closing the F-02 unbounded-task vector.
assert!(!should_auto_fetch(true, false, false));
// An identical (author,id) already being fetched is deduped.
assert!(!should_auto_fetch(true, true, true));
}
#[test]
fn retained_peers_are_shared_by_topic_across_restamped_tickets() {
let topic_id = [23u8; 32];
let original_host = iroh::SecretKey::generate().public();
let member_host = iroh::SecretKey::generate().public();
let retained_peer = iroh::SecretKey::generate().public();
let original = PeerSpeakTicket {
host_addr: iroh::EndpointAddr::from(original_host),
topic_id,
name: "Room".to_string(),
}.to_string();
let restamped = PeerSpeakTicket {
host_addr: iroh::EndpointAddr::from(member_host),
topic_id,
name: "Room".to_string(),
}.to_string();
assert_ne!(original, restamped);
let original_topic = PeerSpeakTicket::topic_of(&original).unwrap();
let restamped_topic = PeerSpeakTicket::topic_of(&restamped).unwrap();
assert_eq!(original_topic, restamped_topic);
let retained_addr = iroh::EndpointAddr::from(retained_peer);
let known_peers: KnownPeers =
std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
known_peers
.lock()
.unwrap()
.entry(original_topic)
.or_default()
.insert(retained_peer, retained_addr.clone());
let found = known_peers
.lock()
.unwrap()
.get(&restamped_topic)
.and_then(|peers| peers.get(&retained_peer))
.cloned();
assert_eq!(found, Some(retained_addr));
}
/// A frame of constant amplitude with the given sample count.
fn frame(amp: i16, len: usize) -> Vec<i16> {
vec![amp; len]
}
#[tokio::test]
async fn closed_game_watch_is_fused_after_one_ready_event() {
let (tx, rx) = tokio::sync::watch::channel(None);
let mut rx = Some(rx);
drop(tx);
assert_eq!(next_game_change(&mut rx).await, None);
assert!(rx.is_none(), "closed receiver must disable its select source");
}
#[test]
fn mic_meter_reports_only_after_enough_samples() {
let mut m = MicLevelMeter::new();
+68 -8
View File
@@ -22,6 +22,27 @@ fn recovery_delay(attempt: usize) -> Duration {
RECOVERY_DELAYS[attempt.min(RECOVERY_DELAYS.len() - 1)]
}
/// Terminal retry budget for background recovery. After this many failed attempts
/// the coordinator gives up: it drops the entry, frees the active slot, and signals
/// the event task to forget the retained address (Tier C recovery-identity cap).
///
/// With the [`RECOVERY_DELAYS`] backoff this is roughly seven minutes of dialing
/// (1+2+4+8+15+30+60s, then 60s steps), far beyond any normal transient outage. A
/// genuine peer returning after a longer outage still rejoins on its own via a
/// gossip announce, so giving up only stops us from dialing a peer that is not
/// coming back — it does not break legitimate reconnect-after-outage.
const RECOVERY_TERMINAL_ATTEMPTS: usize = 12;
/// Capacity of the terminal-eviction notification channel. Bounded; on the rare
/// event of saturation the entry is still removed (the dial work stops) and only
/// the retained-address forget is skipped, which the per-topic retain cap bounds.
const RECOVERY_TERMINAL_CAPACITY: usize = 64;
/// Whether `attempt` completed recoveries have exhausted the terminal budget.
fn recovery_is_terminal(attempt: usize, max_attempts: usize) -> bool {
attempt >= max_attempts
}
enum RecoveryCommand {
Start {
peer_id: EndpointId,
@@ -60,19 +81,24 @@ pub(super) struct RecoveryCoordinator {
}
impl RecoveryCoordinator {
pub(super) fn spawn(room_state: Arc<IrohGossipState>) -> (Self, JoinHandle<()>) {
pub(super) fn spawn(
room_state: Arc<IrohGossipState>,
) -> (Self, JoinHandle<()>, mpsc::Receiver<EndpointId>) {
Self::spawn_inner(room_state)
}
fn spawn_inner(room_state: Arc<dyn RecoveryRoom>) -> (Self, JoinHandle<()>) {
fn spawn_inner(
room_state: Arc<dyn RecoveryRoom>,
) -> (Self, JoinHandle<()>, mpsc::Receiver<EndpointId>) {
let (tx, rx) = mpsc::channel(RECOVERY_COMMAND_CAPACITY);
let (terminal_tx, terminal_rx) = mpsc::channel(RECOVERY_TERMINAL_CAPACITY);
let active = Arc::new(Mutex::new(HashSet::new()));
let handle = Self {
tx,
active: active.clone(),
};
let task = tokio::spawn(run_coordinator(room_state, active, rx));
(handle, task)
let task = tokio::spawn(run_coordinator(room_state, active, rx, terminal_tx));
(handle, task, terminal_rx)
}
/// Reserve one recovery slot before grace-expiry teardown begins. Returns
@@ -116,6 +142,7 @@ async fn run_coordinator(
room_state: Arc<dyn RecoveryRoom>,
active: Arc<Mutex<HashSet<EndpointId>>>,
mut rx: mpsc::Receiver<RecoveryCommand>,
terminal_tx: mpsc::Sender<EndpointId>,
) {
let mut entries: HashMap<EndpointId, RecoveryEntry> = HashMap::new();
@@ -155,9 +182,24 @@ async fn run_coordinator(
entries.remove(&peer_id);
continue;
}
if let Some(entry) = entries.get_mut(&peer_id) {
// Advance the backoff, then check the terminal budget.
// `attempt` counts completed attempts, so the delay
// uses the current value before it is incremented.
let terminal = if let Some(entry) = entries.get_mut(&peer_id) {
entry.next_attempt = scheduled_at + recovery_delay(entry.attempt);
entry.attempt = entry.attempt.saturating_add(1);
recovery_is_terminal(entry.attempt, RECOVERY_TERMINAL_ATTEMPTS)
} else {
false
};
if terminal {
// Give up on a peer that has not returned within the
// budget: drop its entry, free the active slot, and
// signal the event task to forget its retained
// address so the per-topic retain table drains.
entries.remove(&peer_id);
active.lock().unwrap().remove(&peer_id);
let _ = terminal_tx.try_send(peer_id);
}
}
}
@@ -210,6 +252,23 @@ mod tests {
assert_eq!(actual, vec![1, 2, 4, 8, 15, 30, 60, 60, 60, 60]);
}
#[test]
fn recovery_budget_is_terminal_only_at_or_past_the_cap() {
assert!(!recovery_is_terminal(0, RECOVERY_TERMINAL_ATTEMPTS));
assert!(!recovery_is_terminal(
RECOVERY_TERMINAL_ATTEMPTS - 1,
RECOVERY_TERMINAL_ATTEMPTS
));
assert!(recovery_is_terminal(
RECOVERY_TERMINAL_ATTEMPTS,
RECOVERY_TERMINAL_ATTEMPTS
));
assert!(recovery_is_terminal(
RECOVERY_TERMINAL_ATTEMPTS + 5,
RECOVERY_TERMINAL_ATTEMPTS
));
}
#[test]
fn recovery_slots_are_deduplicated_and_cancel_immediately() {
let (tx, mut rx) = mpsc::channel(4);
@@ -244,9 +303,10 @@ mod tests {
#[tokio::test]
async fn coordinator_attempts_rebootstrap_immediately() {
let (attempts_tx, mut attempts_rx) = mpsc::unbounded_channel();
let (coordinator, task) = RecoveryCoordinator::spawn_inner(Arc::new(RecordingRoom {
attempts: attempts_tx,
}));
let (coordinator, task, _terminal_rx) =
RecoveryCoordinator::spawn_inner(Arc::new(RecordingRoom {
attempts: attempts_tx,
}));
let peer_id = SecretKey::generate().public();
let addr = EndpointAddr::from(peer_id);
+247
View File
@@ -0,0 +1,247 @@
//! The detector service (§5): one cancellable background worker that polls the OS
//! adapters, runs the pure matcher + debouncer, and publishes the stable detected
//! game on a watch channel — only when it changes, so a flapping detector can't
//! spam `PeerState` re-announces.
//!
//! All the OS reads (Steam files / registry, the process scan) are blocking, so
//! the worker is a dedicated `std::thread`, not a tokio task; it owns the
//! [`SteamProbe`] cache and the [`Debouncer`] across ticks. The per-tick decision
//! is factored into the pure [`poll_once`] so the wiring of resolve + match +
//! debounce is unit-tested without any I/O.
use super::{
builtin_denylist, match_processes, resolve, Debouncer, DetectedGame, ManualOverride,
};
use super::scan;
use super::steam::SteamProbe;
use std::collections::BTreeMap;
use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::Duration;
use tokio::sync::watch;
/// How often the detector samples Steam state + the process list.
pub const POLL_INTERVAL: Duration = Duration::from_secs(3);
/// Granularity of the cancellable sleep between polls, so a stop request is
/// honored promptly instead of after a full [`POLL_INTERVAL`].
const SLEEP_TICK: Duration = Duration::from_millis(200);
/// Apply one poll's worth of inputs to the debouncer, returning the new published
/// value **iff it changed** (the signal to re-announce presence / switch the
/// background). Pure: the caller supplies the already-fetched Steam detection and
/// process list, so resolve + match + debounce are testable with zero I/O.
pub fn poll_once(
debouncer: &mut Debouncer,
override_: &ManualOverride,
steam: Option<DetectedGame>,
processes: &[String],
process_map: &BTreeMap<String, String>,
denylist: &std::collections::BTreeSet<&str>,
) -> Option<Option<DetectedGame>> {
let matched = match_processes(processes, process_map, denylist);
let res = resolve(override_, steam, &matched);
if debouncer.observe(res.game, res.immediate) {
Some(debouncer.current().cloned())
} else {
None
}
}
/// Shared, live-updatable inputs to the detector, written by core (manual override
/// changes, config edits to the process map) and read each poll by the worker.
#[derive(Default)]
pub struct DetectorInputs {
pub override_: Mutex<ManualOverride>,
pub process_map: Mutex<BTreeMap<String, String>>,
}
/// A running detector service. Holds the watch receiver for detected-game changes
/// and the shared inputs; dropping it (or calling [`stop`](Self::stop)) ends the
/// worker thread.
pub struct GameDetector {
inputs: Arc<DetectorInputs>,
rx: watch::Receiver<Option<DetectedGame>>,
stop: Arc<AtomicBool>,
worker: Option<JoinHandle<()>>,
}
impl GameDetector {
/// Spawn the detector worker. `process_map` seeds the non-Steam mappings;
/// `override_` seeds the manual override (usually `Auto`). The worker runs
/// until [`stop`](Self::stop) or the returned `GameDetector` is dropped.
pub fn spawn(
override_: ManualOverride,
process_map: BTreeMap<String, String>,
) -> io::Result<Self> {
let inputs = Arc::new(DetectorInputs {
override_: Mutex::new(override_),
process_map: Mutex::new(process_map),
});
let (tx, rx) = watch::channel(None);
let stop = Arc::new(AtomicBool::new(false));
let worker_inputs = inputs.clone();
let worker_stop = stop.clone();
let worker = std::thread::Builder::new()
.name("game-detector".to_string())
.spawn(move || worker_loop(worker_inputs, tx, worker_stop))?;
Ok(Self {
inputs,
rx,
stop,
worker: Some(worker),
})
}
/// A clone of the watch receiver for detected-game changes. The current value
/// is `None` until the first non-empty detection is debounced in.
pub fn subscribe(&self) -> watch::Receiver<Option<DetectedGame>> {
self.rx.clone()
}
/// Replace the manual override (applied on the next poll, immediately,
/// bypassing debounce).
pub fn set_override(&self, override_: ManualOverride) {
*self.inputs.override_.lock().unwrap() = override_;
}
/// Replace the user process→name mappings (e.g. after a Settings edit).
pub fn set_process_map(&self, map: BTreeMap<String, String>) {
*self.inputs.process_map.lock().unwrap() = map;
}
/// Signal the worker to exit. Idempotent; also happens on drop.
pub fn stop(&self) {
self.stop.store(true, Ordering::Relaxed);
}
}
impl Drop for GameDetector {
fn drop(&mut self) {
self.stop();
if let Some(worker) = self.worker.take() {
let _ = worker.join();
}
}
}
/// The blocking worker loop: probe, decide, publish on change, sleep (cancellably).
fn worker_loop(
inputs: Arc<DetectorInputs>,
tx: watch::Sender<Option<DetectedGame>>,
stop: Arc<AtomicBool>,
) {
let denylist = builtin_denylist();
let mut steam = SteamProbe::new();
let mut debouncer = Debouncer::default();
while !stop.load(Ordering::Relaxed) {
let override_ = inputs.override_.lock().unwrap().clone();
let process_map = inputs.process_map.lock().unwrap().clone();
let steam_game = steam.detect();
let processes = scan::running_executables();
if let Some(new_current) =
poll_once(&mut debouncer, &override_, steam_game, &processes, &process_map, &denylist)
{
// A closed receiver means core shut down; stop quietly.
if tx.send(new_current).is_err() {
return;
}
}
// Cancellable sleep: wake promptly on a stop request.
let mut slept = Duration::ZERO;
while slept < POLL_INTERVAL && !stop.load(Ordering::Relaxed) {
std::thread::sleep(SLEEP_TICK);
slept += SLEEP_TICK;
}
}
}
#[cfg(test)]
mod tests {
use super::super::GameSource;
use super::*;
fn game(id: &str, name: &str, source: GameSource) -> DetectedGame {
DetectedGame { id: id.into(), name: Some(name.into()), source }
}
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
#[test]
fn poll_once_debounces_steam_detection() {
let deny = builtin_denylist();
let mut d = Debouncer::default();
let steam = game("steam:730", "CS2", GameSource::Steam);
let empty = BTreeMap::new();
// First poll: detected but not yet published (needs two hits).
assert_eq!(
poll_once(&mut d, &ManualOverride::Auto, Some(steam.clone()), &[], &empty, &deny),
None
);
// Second poll: published.
assert_eq!(
poll_once(&mut d, &ManualOverride::Auto, Some(steam.clone()), &[], &empty, &deny),
Some(Some(steam))
);
// Third identical poll: no change event.
assert_eq!(
poll_once(&mut d, &ManualOverride::Auto, Some(game("steam:730", "CS2", GameSource::Steam)), &[], &empty, &deny),
None
);
}
#[test]
fn poll_once_matches_process_when_no_steam() {
let deny = builtin_denylist();
let mut d = Debouncer::default();
let procs = vec!["/games/hl2_linux".to_string()];
let user = map(&[("hl2_linux", "Half-Life 2")]);
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");
assert_eq!(published.id, "exe:hl2_linux");
assert_eq!(published.name.as_deref(), Some("Half-Life 2"));
}
#[test]
fn poll_once_manual_override_is_immediate() {
let deny = builtin_denylist();
let mut d = Debouncer::default();
let forced = game("steam:220", "HL2", GameSource::Steam);
// Even with a live Steam detection of something else, the override wins now.
let other = game("steam:730", "CS2", GameSource::Steam);
let change = poll_once(
&mut d,
&ManualOverride::Force(forced.clone()),
Some(other),
&[],
&BTreeMap::new(),
&deny,
);
assert_eq!(change, Some(Some(forced)));
}
#[test]
fn spawn_and_stop_is_clean() {
// Smoke test the lifecycle: spawning and stopping must not panic, and the
// initial published value is None.
let det = GameDetector::spawn(ManualOverride::Auto, BTreeMap::new()).unwrap();
assert_eq!(*det.subscribe().borrow(), None);
det.set_override(ManualOverride::ForceNone);
det.set_process_map(map(&[("x", "X")]));
det.stop();
// Dropping also stops; no hang/panic.
drop(det);
}
}
+483
View File
@@ -0,0 +1,483 @@
//! Game detection, game-presence, and game-reactive backgrounds.
//!
//! A single local "what game is running" detector feeds two consumers:
//! 1. **Local** — a per-game UI background that auto-switches (extends W16).
//! 2. **Broadcast** — a `Playing <name>` status next to our avatar in every peer's
//! roster, riding the gossip presence plane like nickname + avatar.
//!
//! This module is structured testable-seams-first: the *pure* logic lives here
//! (the stable-id scheme, the priority [`resolve`] matcher, the [`Debouncer`], and
//! the process-name [`match_processes`] mapping), unit-tested with zero I/O. The OS
//! edges — Steam state/file reads ([`steam`]) and the running-process scan
//! ([`scan`]) — feed already-parsed values into these pure functions, and the
//! cancellable poll service ([`detector`]) wires them together.
pub mod detector;
pub mod scan;
pub mod steam;
pub mod vdf;
use std::collections::{BTreeMap, BTreeSet};
/// Where a detected game came from. Encodes the trust/priority tier directly:
/// a manual override beats live Steam state, which beats a matched process. Used
/// only for prioritization and as a presentation hint — never trusted as identity.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GameSource {
/// The user forced a specific game (or "none") via the manual override.
Manual,
/// Steam's live `RunningAppID` resolved against an `appmanifest`.
Steam,
/// A running process matched against the user's process→name mappings.
Process,
}
/// A game the local detector currently believes is running.
///
/// `id` is the stable, namespaced identity used as the config key for backgrounds
/// (`steam:730`, `exe:hl2_linux`) — **never** the mutable display name. `name` is
/// the human label shown locally and broadcast as presence; it is `None` only for
/// the Steam appid-without-manifest case, where the background can still switch by
/// `id` but nothing is broadcast (per the "don't invent `Steam App 123`" rule).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectedGame {
/// Stable namespaced identity. Config-key safe; survives renames.
pub id: String,
/// Trustworthy human name; `None` = id-only (Steam manifest unavailable).
pub name: Option<String>,
/// Provenance / priority tier.
pub source: GameSource,
}
impl DetectedGame {
/// The Steam namespaced id for an appid: `steam:<appid>`.
pub fn steam_id(app_id: u32) -> String {
format!("steam:{app_id}")
}
/// The process namespaced id for an executable identity: `exe:<normalized>`.
pub fn exe_id(exe: &str) -> String {
format!("exe:{}", normalize_exe(exe))
}
}
/// The user's manual override sitting above both detectors (D2). Small by design.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ManualOverride {
/// Trust the auto-detector (default).
#[default]
Auto,
/// Force "not playing anything" regardless of what is detected.
ForceNone,
/// Force a specific game (the user picked it from the known-games list).
Force(DetectedGame),
}
/// The outcome of [`resolve`]: the chosen game (if any) plus whether the choice is
/// a manual override and so should **bypass the [`Debouncer`]** (apply immediately).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolution {
pub game: Option<DetectedGame>,
/// `true` when a manual override (`ForceNone`/`Force`) decided the value.
pub immediate: bool,
}
/// Apply the detector priority (D2 / §5): **manual override → Steam → mapped
/// process → none**. Pure; the adapters resolve `steam`/`processes` into
/// `DetectedGame`s and this only picks the winner. `processes` is in the adapter's
/// deterministic priority order (see [`match_processes`]); its first entry wins.
pub fn resolve(
override_: &ManualOverride,
steam: Option<DetectedGame>,
processes: &[DetectedGame],
) -> Resolution {
match override_ {
ManualOverride::ForceNone => Resolution { game: None, immediate: true },
ManualOverride::Force(g) => Resolution { game: Some(g.clone()), immediate: true },
ManualOverride::Auto => {
let game = steam.or_else(|| processes.first().cloned());
Resolution { game, immediate: false }
}
}
}
/// Samples required before a *new* game is accepted/switched to.
pub const ACCEPT_HITS: u32 = 2;
/// Consecutive "no game" samples before a currently-shown game is cleared. At the
/// ~3 s poll cadence this is ~9 s, absorbing a brief Steam stale/crash blip.
pub const CLEAR_MISSES: u32 = 3;
/// Debounces a stream of raw per-poll detections into a stable published value, so
/// a flapping detector can't repeatedly re-announce the entire `PeerState` (which
/// can carry the ~48 KB avatar). Pure state machine — the service feeds it samples
/// and re-announces only when [`observe`](Debouncer::observe) reports a change.
///
/// A switch to a different game needs [`ACCEPT_HITS`] matching samples; clearing a
/// game needs [`CLEAR_MISSES`] consecutive misses. A manual override
/// (`immediate = true`) applies at once, bypassing both counters.
#[derive(Debug, Clone, Default)]
pub struct Debouncer {
current: Option<DetectedGame>,
pending: Option<DetectedGame>,
pending_hits: u32,
misses: u32,
}
impl Debouncer {
/// The currently published, debounced value.
pub fn current(&self) -> Option<&DetectedGame> {
self.current.as_ref()
}
/// Feed one poll result. `immediate` (a manual override is active) bypasses the
/// debounce. Returns `true` iff the published [`current`](Self::current) value
/// changed — the signal for the service to re-announce presence / switch the
/// background.
pub fn observe(&mut self, sample: Option<DetectedGame>, immediate: bool) -> bool {
if immediate {
let changed = self.current != sample;
self.current = sample;
self.pending = None;
self.pending_hits = 0;
self.misses = 0;
return changed;
}
match sample {
Some(game) => {
self.misses = 0;
if self.current.as_ref() == Some(&game) {
// Already publishing this game; drop any half-counted switch.
self.pending = None;
self.pending_hits = 0;
false
} else {
if self.pending.as_ref() == Some(&game) {
self.pending_hits += 1;
} else {
self.pending = Some(game);
self.pending_hits = 1;
}
if self.pending_hits >= ACCEPT_HITS {
self.current = self.pending.take();
self.pending_hits = 0;
true
} else {
false
}
}
}
None => {
// A miss never counts toward a *switch*; drop any pending candidate.
self.pending = None;
self.pending_hits = 0;
if self.current.is_some() {
self.misses += 1;
if self.misses >= CLEAR_MISSES {
self.current = None;
self.misses = 0;
true
} else {
false
}
} else {
false
}
}
}
}
}
/// Normalize a raw executable name/path to a stable identity for matching and ids:
/// take the final path component (handling both `/` and `\\` separators) and
/// lowercase it. Keeps any extension (`minecraft.exe` stays distinct from a
/// hypothetical `minecraft`), trims surrounding whitespace.
pub fn normalize_exe(raw: &str) -> String {
raw.rsplit(['/', '\\']).next().unwrap_or(raw).trim().to_lowercase()
}
/// Launcher/helper executables that must NEVER be reported as a game even if a
/// mapping names them — defense against a mis-entered mapping turning the launcher
/// itself into "the game". Normalized (lowercase basename) for comparison.
const BUILTIN_DENYLIST: &[&str] = &[
"steam",
"steam.exe",
"steamwebhelper",
"steamwebhelper.exe",
"steamerrorreporter",
"gameoverlayui",
"reaper",
"lutris",
"heroic",
"heroic.exe",
"legendary",
"gogdl",
"wine",
"wine64",
"wineserver",
"wine-preloader",
"proton",
"pressure-vessel-wrap",
"explorer.exe",
"services.exe",
"svchost.exe",
];
/// The built-in launcher/helper denylist as a set, for membership checks.
pub fn builtin_denylist() -> BTreeSet<&'static str> {
BUILTIN_DENYLIST.iter().copied().collect()
}
/// Match the currently-running executables against the user's explicit
/// process→display-name mappings, returning detected games in **deterministic
/// priority order** (sorted by stable id) with duplicates removed.
///
/// Conservative by construction (§3): only exact normalized-basename matches to a
/// user mapping count — we never guess that an arbitrary long-running process is a
/// game. Any executable on `denylist` is rejected even if mapped, so a launcher or
/// helper can't be promoted to "the game".
///
/// `user_map` keys are matched against the normalized basename of each running
/// entry; the key itself is normalized too, so the caller may store either
/// `Half-Life 2` style display values keyed by `hl2_linux` or `HL2_Linux`.
pub fn match_processes(
running: &[String],
user_map: &BTreeMap<String, String>,
denylist: &BTreeSet<&str>,
) -> Vec<DetectedGame> {
// Normalize the user map once so lookups are basename/case-insensitive.
let normalized_map: BTreeMap<String, &String> =
user_map.iter().map(|(k, v)| (normalize_exe(k), v)).collect();
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut out: Vec<DetectedGame> = Vec::new();
for raw in running {
let norm = normalize_exe(raw);
if norm.is_empty() || denylist.contains(norm.as_str()) {
continue;
}
if let Some(name) = normalized_map.get(&norm) {
let id = format!("exe:{norm}");
if seen.insert(id.clone()) {
out.push(DetectedGame {
id,
name: Some((*name).clone()),
source: GameSource::Process,
});
}
}
}
// Deterministic priority: stable order independent of process-scan order.
out.sort_by(|a, b| a.id.cmp(&b.id));
out
}
#[cfg(test)]
mod tests {
use super::*;
fn steam_game(app_id: u32, name: &str) -> DetectedGame {
DetectedGame {
id: DetectedGame::steam_id(app_id),
name: Some(name.to_string()),
source: GameSource::Steam,
}
}
// --- ids / normalization ----------------------------------------------
#[test]
fn stable_ids_are_namespaced() {
assert_eq!(DetectedGame::steam_id(730), "steam:730");
assert_eq!(DetectedGame::exe_id("/usr/games/hl2_linux"), "exe:hl2_linux");
assert_eq!(DetectedGame::exe_id("C:\\Games\\Minecraft.exe"), "exe:minecraft.exe");
}
#[test]
fn normalize_handles_both_separators_and_case() {
assert_eq!(normalize_exe("/opt/Foo/Bar.x86_64"), "bar.x86_64");
assert_eq!(normalize_exe("D:\\a\\b\\GAME.EXE"), "game.exe");
assert_eq!(normalize_exe(" spaced.bin "), "spaced.bin");
assert_eq!(normalize_exe("bare"), "bare");
}
// --- resolve priority --------------------------------------------------
#[test]
fn resolve_prefers_steam_over_process_in_auto() {
let steam = steam_game(730, "CS2");
let procs = vec![DetectedGame {
id: "exe:foo".into(),
name: Some("Foo".into()),
source: GameSource::Process,
}];
let r = resolve(&ManualOverride::Auto, Some(steam.clone()), &procs);
assert_eq!(r.game, Some(steam));
assert!(!r.immediate);
}
#[test]
fn resolve_falls_back_to_first_process_then_none() {
let procs = vec![
DetectedGame { 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);
assert_eq!(r.game.as_ref().unwrap().id, "exe:a");
let none = resolve(&ManualOverride::Auto, None, &[]);
assert_eq!(none.game, None);
assert!(!none.immediate);
}
#[test]
fn resolve_manual_override_wins_and_is_immediate() {
let steam = steam_game(730, "CS2");
// ForceNone overrides a live Steam detection, immediately.
let r = resolve(&ManualOverride::ForceNone, Some(steam.clone()), &[]);
assert_eq!(r.game, None);
assert!(r.immediate);
// Force(x) overrides too.
let forced = steam_game(220, "HL2");
let r = resolve(&ManualOverride::Force(forced.clone()), Some(steam), &[]);
assert_eq!(r.game, Some(forced));
assert!(r.immediate);
}
// --- debounce ----------------------------------------------------------
#[test]
fn debounce_requires_two_hits_to_switch() {
let mut d = Debouncer::default();
let g = steam_game(730, "CS2");
// First sighting: not yet published.
assert!(!d.observe(Some(g.clone()), false));
assert_eq!(d.current(), None);
// Second consecutive sighting: now published.
assert!(d.observe(Some(g.clone()), false));
assert_eq!(d.current(), Some(&g));
// Steady state: same game, no further change events.
assert!(!d.observe(Some(g.clone()), false));
}
#[test]
fn debounce_requires_three_misses_to_clear() {
let mut d = Debouncer::default();
let g = steam_game(730, "CS2");
d.observe(Some(g.clone()), false);
d.observe(Some(g.clone()), false);
assert_eq!(d.current(), Some(&g));
// Two misses: still shown (absorbs a transient blip).
assert!(!d.observe(None, false));
assert!(!d.observe(None, false));
assert_eq!(d.current(), Some(&g));
// Third miss: cleared.
assert!(d.observe(None, false));
assert_eq!(d.current(), None);
}
#[test]
fn debounce_blip_during_clear_resets_miss_count() {
let mut d = Debouncer::default();
let g = steam_game(730, "CS2");
d.observe(Some(g.clone()), false);
d.observe(Some(g.clone()), false);
// Miss, miss, then the game reappears: miss count resets, stays published.
d.observe(None, false);
d.observe(None, false);
assert!(!d.observe(Some(g.clone()), false));
assert_eq!(d.current(), Some(&g));
// It now takes a fresh run of three misses to clear.
d.observe(None, false);
d.observe(None, false);
assert!(d.observe(None, false));
assert_eq!(d.current(), None);
}
#[test]
fn debounce_immediate_bypasses_counters() {
let mut d = Debouncer::default();
let g = steam_game(730, "CS2");
// A manual override publishes on the first sample.
assert!(d.observe(Some(g.clone()), true));
assert_eq!(d.current(), Some(&g));
// ForceNone clears immediately.
assert!(d.observe(None, true));
assert_eq!(d.current(), None);
// Re-issuing the same immediate value is not a change.
d.observe(Some(g.clone()), true);
assert!(!d.observe(Some(g.clone()), true));
}
#[test]
fn debounce_switching_games_needs_two_hits_of_the_new_one() {
let mut d = Debouncer::default();
let a = steam_game(1, "A");
let b = steam_game(2, "B");
d.observe(Some(a.clone()), false);
d.observe(Some(a.clone()), false);
assert_eq!(d.current(), Some(&a));
// One sample of B does not switch.
assert!(!d.observe(Some(b.clone()), false));
assert_eq!(d.current(), Some(&a));
// Second consecutive B switches.
assert!(d.observe(Some(b.clone()), false));
assert_eq!(d.current(), Some(&b));
}
// --- process matching --------------------------------------------------
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
#[test]
fn match_processes_matches_only_explicit_mappings() {
let user = map(&[("hl2_linux", "Half-Life 2")]);
let deny = builtin_denylist();
let running = vec![
"/usr/bin/firefox".to_string(),
"/games/Half-Life 2/hl2_linux".to_string(),
"/usr/bin/htop".to_string(),
];
let got = match_processes(&running, &user, &deny);
assert_eq!(got.len(), 1);
assert_eq!(got[0].id, "exe:hl2_linux");
assert_eq!(got[0].name.as_deref(), Some("Half-Life 2"));
assert_eq!(got[0].source, GameSource::Process);
}
#[test]
fn match_processes_rejects_denylisted_even_if_mapped() {
// A mis-entered mapping naming the Steam client must not win.
let user = map(&[("steam", "Steam (oops)"), ("mygame", "My Game")]);
let deny = builtin_denylist();
let running = vec!["/usr/bin/steam".into(), "/opt/mygame".into()];
let got = match_processes(&running, &user, &deny);
assert_eq!(got.len(), 1);
assert_eq!(got[0].id, "exe:mygame");
}
#[test]
fn match_processes_is_deterministic_and_deduped() {
let user = map(&[("zed", "Zed"), ("alpha", "Alpha")]);
let deny = builtin_denylist();
// Same game twice (two processes) + reverse discovery order.
let running = vec![
"/b/zed".into(),
"/a/alpha".into(),
"/c/alpha".into(),
];
let got = match_processes(&running, &user, &deny);
// 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"]);
}
#[test]
fn match_processes_ignores_unmapped_and_case_folds() {
let user = map(&[("Game.x86_64", "The Game")]);
let deny = builtin_denylist();
let running = vec!["/x/GAME.X86_64".into(), "/y/random".into()];
let got = match_processes(&running, &user, &deny);
assert_eq!(got.len(), 1);
assert_eq!(got[0].name.as_deref(), Some("The Game"));
}
}
+117
View File
@@ -0,0 +1,117 @@
//! Running-process enumeration for the non-Steam detection fallback (D6/D7):
//! native adapters only — `/proc` on Linux, Toolhelp on Windows — so there is no
//! `sysinfo` dependency and the audit surface stays small.
//!
//! This module is *just the OS edge*: it returns the list of running executable
//! paths/names. The trustworthy part — turning that list into a game via the
//! user's explicit mappings and the launcher denylist — is the pure
//! [`match_processes`](super::match_processes), unit-tested in the parent module.
/// Enumerate the executables of currently-running processes as paths/basenames.
/// Best-effort: processes we can't introspect (other users') are skipped rather
/// than erroring. The result is fed to [`match_processes`](super::match_processes),
/// which normalizes each entry to a basename before matching.
pub fn running_executables() -> Vec<String> {
#[cfg(target_os = "linux")]
{
linux_proc_executables()
}
#[cfg(windows)]
{
windows_toolhelp_executables()
}
#[cfg(not(any(target_os = "linux", windows)))]
{
Vec::new()
}
}
#[cfg(target_os = "linux")]
fn linux_proc_executables() -> Vec<String> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir("/proc") else {
return out;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
// Only numeric entries are processes.
if !name.bytes().all(|b| b.is_ascii_digit()) {
continue;
}
let proc_dir = entry.path();
// Prefer the real exe path (full, untruncated); fall back to `comm`, which
// is readable for all processes but truncated to 15 bytes.
if let Ok(exe) = std::fs::read_link(proc_dir.join("exe"))
&& let Some(s) = exe.to_str()
{
out.push(s.to_string());
continue;
}
if let Ok(comm) = std::fs::read_to_string(proc_dir.join("comm")) {
let trimmed = comm.trim();
if !trimmed.is_empty() {
out.push(trimmed.to_string());
}
}
}
out
}
#[cfg(windows)]
fn windows_toolhelp_executables() -> Vec<String> {
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
TH32CS_SNAPPROCESS,
};
let mut out = Vec::new();
// SAFETY: standard Toolhelp snapshot of all processes; handle checked below.
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
if snapshot == INVALID_HANDLE_VALUE {
return out;
}
let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
entry.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
// SAFETY: entry is zeroed with dwSize set, as Process32FirstW requires.
let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) };
while ok != 0 {
// 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 name = String::from_utf16_lossy(&entry.szExeFile[..end]);
if !name.is_empty() {
out.push(name);
}
// SAFETY: same valid snapshot + entry struct.
ok = unsafe { Process32NextW(snapshot, &mut entry) };
}
// SAFETY: snapshot handle came from CreateToolhelp32Snapshot above.
unsafe { CloseHandle(snapshot) };
out
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
#[test]
fn enumerates_at_least_this_process() {
// The test runner itself is a process, so /proc enumeration must be
// non-empty and include something that normalizes to our own exe basename.
let exes = running_executables();
assert!(!exes.is_empty(), "expected to see running processes via /proc");
// Our own /proc/self/exe basename should appear among them.
let me = std::fs::read_link("/proc/self/exe")
.ok()
.and_then(|p| p.file_name().map(|f| f.to_string_lossy().into_owned()));
if let Some(me) = me {
let me_norm = super::super::normalize_exe(&me);
assert!(
exes.iter().any(|e| super::super::normalize_exe(e) == me_norm),
"running list should include our own executable {me_norm:?}"
);
}
}
}
+544
View File
@@ -0,0 +1,544 @@
//! Steam detection adapter: the primary signal (D1). Reads Steam's live
//! `RunningAppID` and resolves it to a display name via the plain-text
//! `appmanifest_<appid>.acf`, with no dependency on the binary `appinfo.vdf`.
//!
//! The *parsing* is pure and unit-tested ([`parse_running_app_id`],
//! [`parse_library_paths`], [`parse_app_name`], all over file contents). The fs /
//! Windows-registry reads are the thin edge, and [`SteamProbe`] caches roots,
//! library list, and resolved names — invalidating by mtime — so the 3 s detector
//! poll does not rescan every library each tick (Codex hardening).
use super::vdf::{self, Value};
use super::{DetectedGame, GameSource};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
/// Max bytes read from any single Steam state file. These are small text files
/// (a manifest is a few KB); the cap stops a corrupt/hostile giant file from being
/// slurped into memory before the parser's own depth guard kicks in.
const MAX_STEAM_FILE_BYTES: u64 = 4 * 1024 * 1024;
/// SteamPath is a local filesystem path. Four KiB is deliberately generous and
/// prevents a corrupt registry length from driving an enormous allocation.
#[cfg(any(windows, test))]
const MAX_STEAM_PATH_BYTES: u32 = 4 * 1024;
#[cfg(any(windows, test))]
fn validate_reg_len(len: u32) -> Option<usize> {
(len != 0 && len.is_multiple_of(2) && len <= MAX_STEAM_PATH_BYTES)
.then_some(len as usize / 2)
}
#[cfg(any(windows, test))]
fn decode_reg_sz(mut buf: Vec<u16>, returned_bytes: u32) -> Option<String> {
let units = validate_reg_len(returned_bytes)?;
if units > buf.len() {
return None;
}
buf.truncate(units);
while buf.last() == Some(&0) {
buf.pop();
}
Some(String::from_utf16_lossy(&buf))
}
/// Parse the live `RunningAppID` out of a Steam `registry.vdf` (the Linux/macOS
/// client's emulated-registry text file). Returns the appid only when present and
/// nonzero — `0`/absent is the "no game" state. Pure.
pub fn parse_running_app_id(registry_vdf: &str) -> Option<u32> {
let root = vdf::parse(registry_vdf).ok()?;
let raw = root
.get_path(&["Registry", "HKCU", "Software", "Valve", "Steam", "RunningAppID"])
.and_then(Value::as_str)?;
let id: u32 = raw.trim().parse().ok()?;
(id != 0).then_some(id)
}
/// Parse the library folder paths out of a `libraryfolders.vdf`, handling **both**
/// the current shape (`"0" { "path" "..." }`) and the legacy shape
/// (`"1" "/path"`, the path as a direct string value). Non-numeric keys
/// (`contentstatsid`, …) are skipped. Pure; paths are returned as-is (escapes
/// already decoded by the VDF parser), including ones on offline drives — the
/// caller checks existence.
pub fn parse_library_paths(libraryfolders_vdf: &str) -> Vec<PathBuf> {
let Ok(root) = vdf::parse(libraryfolders_vdf) else {
return Vec::new();
};
// The root may or may not wrap entries in a "libraryfolders" object.
let container = root.get("libraryfolders").unwrap_or(&root);
let mut out = Vec::new();
for (key, val) in container.entries() {
// Only numeric-keyed entries are library folders.
if key.parse::<u32>().is_err() {
continue;
}
let path = match val {
Value::Str(s) => Some(s.as_str()),
Value::Obj(_) => val.get("path").and_then(Value::as_str),
};
if let Some(p) = path
&& !p.is_empty()
{
out.push(PathBuf::from(p));
}
}
out
}
/// Parse the human `name` out of an `appmanifest_<appid>.acf`. Pure.
pub fn parse_app_name(appmanifest_acf: &str) -> Option<String> {
let root = vdf::parse(appmanifest_acf).ok()?;
root.get_path(&["AppState", "name"])
.and_then(Value::as_str)
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
}
/// Read at most [`MAX_STEAM_FILE_BYTES`] of a file as UTF-8 (lossy), or `None` if
/// it is missing/unreadable. The thin fs edge under the pure parsers above.
fn read_capped(path: &Path) -> Option<String> {
use std::io::Read;
let file = std::fs::File::open(path).ok()?;
let mut buf = Vec::new();
file.take(MAX_STEAM_FILE_BYTES).read_to_end(&mut buf).ok()?;
Some(String::from_utf8_lossy(&buf).into_owned())
}
fn mtime_of(path: &Path) -> Option<SystemTime> {
std::fs::metadata(path).ok()?.modified().ok()
}
/// A library list cached against its source file's mtime.
#[derive(Default)]
struct CachedLibraries {
source: Option<PathBuf>,
mtime: Option<SystemTime>,
paths: Vec<PathBuf>,
}
/// A per-appid resolved name cached against the manifest's mtime. `name` is `None`
/// when the manifest exists but carries no usable name, or wasn't found.
struct CachedManifest {
mtime: Option<SystemTime>,
name: Option<String>,
}
/// Stateful Steam probe with mtime-invalidated caches. Construct once and call
/// [`detect`](Self::detect) each poll; all reads are blocking, so the detector
/// service runs it off the async worker.
pub struct SteamProbe {
roots: Vec<PathBuf>,
libraries: CachedLibraries,
manifests: HashMap<u32, CachedManifest>,
}
impl Default for SteamProbe {
fn default() -> Self {
Self::new()
}
}
impl SteamProbe {
pub fn new() -> Self {
Self {
roots: discover_roots(),
libraries: CachedLibraries::default(),
manifests: HashMap::new(),
}
}
/// One detection pass: read the live `RunningAppID`, and if a game is running,
/// resolve its name from the appmanifest (cached). Returns a `DetectedGame`
/// with `name: None` when the appid is known but no manifest name is available
/// — the background can still switch by id, but presence must not invent a name.
pub fn detect(&mut self) -> Option<DetectedGame> {
let app_id = self.running_app_id()?;
let name = self.app_name(app_id);
Some(DetectedGame {
id: DetectedGame::steam_id(app_id),
name,
source: GameSource::Steam,
})
}
/// The live RunningAppID (nonzero), or `None`.
///
/// Platform notes: on **Windows** the real registry's `RunningAppID` is updated
/// live, so we read it. On **Linux** the client's `registry.vdf` is only
/// rewritten on Steam *shutdown* — it's stale while a game runs — so the live
/// signal is the running game process's `SteamAppId` environment variable
/// (`/proc/<pid>/environ`, readable for our own processes; the same approach
/// MangoHud uses); `registry.vdf` stays as a best-effort fallback. Other Unix
/// (macOS) only has the `registry.vdf` fallback for now.
fn running_app_id(&self) -> Option<u32> {
#[cfg(windows)]
{
win::running_app_id()
}
#[cfg(target_os = "linux")]
{
running_app_id_from_environ().or_else(registry_running_app_id)
}
#[cfg(not(any(windows, target_os = "linux")))]
{
registry_running_app_id()
}
}
/// Resolve (and cache) the display name for an appid by locating its
/// `appmanifest_<appid>.acf` across the known libraries.
fn app_name(&mut self, app_id: u32) -> Option<String> {
let manifest = self.find_manifest(app_id)?;
let mtime = mtime_of(&manifest);
if let Some(cached) = self.manifests.get(&app_id)
&& cached.mtime == mtime
{
return cached.name.clone();
}
let name = read_capped(&manifest).and_then(|c| parse_app_name(&c));
self.manifests.insert(app_id, CachedManifest { mtime, name: name.clone() });
name
}
/// The path to an appid's manifest, if it exists in any library.
fn find_manifest(&mut self, app_id: u32) -> Option<PathBuf> {
let filename = format!("appmanifest_{app_id}.acf");
for lib in self.library_paths() {
let candidate = lib.join("steamapps").join(&filename);
if candidate.exists() {
return Some(candidate);
}
}
None
}
/// All Steam library folder paths, cached and refreshed only when the source
/// `libraryfolders.vdf` changes (mtime). Discovered from the known roots.
fn library_paths(&mut self) -> Vec<PathBuf> {
// Locate the libraryfolders.vdf to watch (first existing across roots).
let source = self
.roots
.iter()
.map(|r| r.join("steamapps").join("libraryfolders.vdf"))
.find(|p| p.exists());
let mtime = source.as_deref().and_then(mtime_of);
if self.libraries.source == source && self.libraries.mtime == mtime && source.is_some() {
return self.libraries.paths.clone();
}
let mut paths = Vec::new();
if let Some(ref src) = source
&& let Some(contents) = read_capped(src)
{
paths = parse_library_paths(&contents);
}
// Always include the roots themselves: the install dir is an implicit
// library even if libraryfolders.vdf is missing or lists only extras.
for root in &self.roots {
if !paths.contains(root) {
paths.push(root.clone());
}
}
self.libraries = CachedLibraries { source, mtime, paths: paths.clone() };
paths
}
}
/// Candidate Steam install roots that actually exist on this machine (each is a
/// directory containing a `steamapps` folder). Covers native, Flatpak, and Snap
/// layouts on Linux; on Windows the install path comes from the registry.
fn discover_roots() -> Vec<PathBuf> {
let mut roots = Vec::new();
#[cfg(windows)]
{
if let Some(p) = win::install_path() {
roots.push(p);
}
}
#[cfg(not(windows))]
{
if let Some(home) = dirs::home_dir() {
for rel in [
".steam/steam",
".steam/root",
".local/share/Steam",
".var/app/com.valvesoftware.Steam/.local/share/Steam",
"snap/steam/common/.local/share/Steam",
] {
roots.push(home.join(rel));
}
}
}
// Keep only roots that exist and look like a Steam install.
roots.retain(|p| p.join("steamapps").is_dir());
roots.sort();
roots.dedup();
roots
}
/// Candidate `registry.vdf` locations (Linux/macOS emulated registry).
#[cfg(not(windows))]
fn registry_vdf_candidates() -> Vec<PathBuf> {
let mut out = Vec::new();
if let Some(home) = dirs::home_dir() {
out.push(home.join(".steam/registry.vdf"));
out.push(home.join(".steam/steam/registry.vdf"));
out.push(home.join(".var/app/com.valvesoftware.Steam/.steam/registry.vdf"));
out.push(home.join("snap/steam/common/.steam/registry.vdf"));
}
out
}
/// Best-effort `RunningAppID` from the on-disk `registry.vdf`. ⚠️ Stale while a
/// game runs (Steam rewrites the file only on shutdown), so this is a *fallback*
/// behind the live `/proc` `SteamAppId` scan on Linux — not the primary signal.
#[cfg(not(windows))]
fn registry_running_app_id() -> Option<u32> {
for path in registry_vdf_candidates() {
if let Some(contents) = read_capped(&path)
&& let Some(id) = parse_running_app_id(&contents)
{
return Some(id);
}
}
None
}
/// Parse a Steam appid out of a process's raw `environ` blob (NUL-separated
/// `KEY=VALUE` pairs), reading the `SteamAppId` variable Steam exports to every
/// game process. Returns the appid only when present and nonzero. Pure +
/// unit-tested; the `/proc` iteration is the thin edge in
/// [`running_app_id_from_environ`].
#[cfg(target_os = "linux")]
pub fn parse_steam_app_id_from_environ(environ: &[u8]) -> Option<u32> {
for kv in environ.split(|&b| b == 0) {
if let Some(val) = kv.strip_prefix(b"SteamAppId=")
&& let Ok(s) = std::str::from_utf8(val)
&& let Ok(id) = s.trim().parse::<u32>()
&& id != 0
{
return Some(id);
}
}
None
}
/// The live Steam appid of a running game, found by scanning `/proc/<pid>/environ`
/// for the `SteamAppId` Steam exports to the game's process tree. `environ` is
/// readable only for our own processes — exactly the ones a Steam game we launched
/// runs as — and we skip the rest. The live signal that replaces the stale
/// on-disk `registry.vdf` on Linux.
#[cfg(target_os = "linux")]
fn running_app_id_from_environ() -> Option<u32> {
let entries = std::fs::read_dir("/proc").ok()?;
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !name.bytes().all(|b| b.is_ascii_digit()) {
continue;
}
// Cap the read: an environ is small; this bounds a pathological case.
if let Some(environ) = read_capped(&entry.path().join("environ"))
&& let Some(id) = parse_steam_app_id_from_environ(environ.as_bytes())
{
return Some(id);
}
}
None
}
#[cfg(windows)]
mod win {
//! Windows registry reads via direct Win32 FFI (windows-sys), no `winreg`
//! crate. Steam stores both the live `RunningAppID` and its install path under
//! `HKCU\Software\Valve\Steam`.
use super::{decode_reg_sz, validate_reg_len};
use std::path::PathBuf;
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::System::Registry::{
RegCloseKey, RegOpenKeyExW, RegQueryValueExW, HKEY, HKEY_CURRENT_USER, KEY_READ,
REG_DWORD, REG_SZ,
};
/// UTF-16, NUL-terminated, for a Win32 wide-string argument.
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
/// Open `HKCU\Software\Valve\Steam` for reading; `None` if absent.
fn open_steam_key() -> Option<HKEY> {
let subkey = wide("Software\\Valve\\Steam");
let mut hkey: HKEY = std::ptr::null_mut();
// SAFETY: valid HKEY constant, NUL-terminated subkey, out-param for the handle.
let rc = unsafe {
RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut hkey)
};
(rc == ERROR_SUCCESS).then_some(hkey)
}
/// The live `RunningAppID` REG_DWORD, nonzero, or `None`.
pub fn running_app_id() -> Option<u32> {
let hkey = open_steam_key()?;
let name = wide("RunningAppID");
let mut kind: u32 = 0;
let mut data: u32 = 0;
let mut len = std::mem::size_of::<u32>() as u32;
// SAFETY: out-params sized for a DWORD; data buffer is a u32 we own.
let rc = unsafe {
RegQueryValueExW(
hkey,
name.as_ptr(),
std::ptr::null(),
&mut kind,
&mut data as *mut u32 as *mut u8,
&mut len,
)
};
// SAFETY: handle came from RegOpenKeyExW above.
unsafe { RegCloseKey(hkey) };
if rc == ERROR_SUCCESS && kind == REG_DWORD && data != 0 {
Some(data)
} else {
None
}
}
/// The Steam install directory from `HKCU\...\Steam\SteamPath`, if it exists.
pub fn install_path() -> Option<PathBuf> {
let hkey = open_steam_key()?;
let name = wide("SteamPath");
let mut kind: u32 = 0;
let mut len: u32 = 0;
// First query the size.
// SAFETY: null data ptr with a zeroed len asks for the required size.
let rc = unsafe {
RegQueryValueExW(
hkey,
name.as_ptr(),
std::ptr::null(),
&mut kind,
std::ptr::null_mut(),
&mut len,
)
};
if rc != ERROR_SUCCESS || kind != REG_SZ {
// SAFETY: valid handle.
unsafe { RegCloseKey(hkey) };
return None;
}
let Some(units) = validate_reg_len(len) else {
// SAFETY: valid handle.
unsafe { RegCloseKey(hkey) };
return None;
};
let mut buf = vec![0u16; units];
let mut len2 = len;
// SAFETY: buffer sized to the queried byte length.
let rc = unsafe {
RegQueryValueExW(
hkey,
name.as_ptr(),
std::ptr::null(),
&mut kind,
buf.as_mut_ptr() as *mut u8,
&mut len2,
)
};
// SAFETY: valid handle.
unsafe { RegCloseKey(hkey) };
if rc != ERROR_SUCCESS || kind != REG_SZ || len2 > len {
return None;
}
Some(PathBuf::from(decode_reg_sz(buf, len2)?))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
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(MAX_STEAM_PATH_BYTES + 2), None);
assert_eq!(validate_reg_len(8), Some(4));
let raw = "C:\\Steam\0ignored".encode_utf16().collect::<Vec<_>>();
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"));
}
#[test]
fn running_app_id_reads_nonzero_and_rejects_zero() {
let running = r#""Registry" { "HKCU" { "Software" { "Valve" { "Steam" {
"RunningAppID" "440"
} } } } }"#;
assert_eq!(parse_running_app_id(running), Some(440));
let idle = r#""Registry" { "HKCU" { "Software" { "Valve" { "Steam" {
"RunningAppID" "0"
} } } } }"#;
assert_eq!(parse_running_app_id(idle), None);
// Missing key / garbage → None, no panic.
assert_eq!(parse_running_app_id(r#""Registry" { }"#), None);
assert_eq!(parse_running_app_id("not vdf at all {{{"), None);
}
#[test]
fn library_paths_handles_current_and_legacy_shapes() {
let current = r#""libraryfolders" {
"0" { "path" "/home/eric/.local/share/Steam" "label" "" }
"1" { "path" "/mnt/games/SteamLibrary" }
"contentstatsid" "12345"
}"#;
let got = parse_library_paths(current);
assert_eq!(got, vec![
PathBuf::from("/home/eric/.local/share/Steam"),
PathBuf::from("/mnt/games/SteamLibrary"),
]);
// Legacy shape: numeric keys map straight to path strings.
let legacy = r#""LibraryFolders" {
"TimeNextStatsReport" "9999"
"ContentStatsID" "42"
"1" "/mnt/old/SteamLibrary"
}"#;
let got = parse_library_paths(legacy);
assert_eq!(got, vec![PathBuf::from("/mnt/old/SteamLibrary")]);
}
#[test]
fn library_paths_empty_on_garbage() {
assert!(parse_library_paths("totally broken {{{").is_empty());
}
#[cfg(target_os = "linux")]
#[test]
fn steam_app_id_parsed_from_environ_blob() {
// A realistic NUL-separated environ with SteamAppId among other vars.
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));
// Nonzero requirement: SteamAppId=0 (the launcher itself) is ignored.
assert_eq!(parse_steam_app_id_from_environ(b"SteamAppId=0\0FOO=bar\0"), None);
// Absent → None (a non-Steam process).
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.
assert_eq!(parse_steam_app_id_from_environ(b"MY_SteamAppId=999\0"), None);
// Garbage value → None, no panic.
assert_eq!(parse_steam_app_id_from_environ(b"SteamAppId=notanumber\0"), None);
}
#[test]
fn app_name_extracts_and_filters_empty() {
let acf = r#""AppState" { "appid" "440" "name" "Team Fortress 2" }"#;
assert_eq!(parse_app_name(acf), Some("Team Fortress 2".to_string()));
// Empty name → None (don't broadcast a blank).
let blank = r#""AppState" { "appid" "440" "name" "" }"#;
assert_eq!(parse_app_name(blank), None);
// Missing name → None.
assert_eq!(parse_app_name(r#""AppState" { "appid" "440" }"#), None);
}
}
+340
View File
@@ -0,0 +1,340 @@
//! A small, defensive parser for Valve's KeyValues / VDF text format, used by
//! `appmanifest_<appid>.acf`, `libraryfolders.vdf`, and `~/.steam/registry.vdf`.
//!
//! Pure (operates on already-read file *contents*) and unit-tested, per the
//! testable-seams-first workflow — the file I/O and size caps live in the Steam
//! adapter. Deliberately a real recursive-descent KeyValues parser rather than a
//! `"name"`-line regex: escapes, nesting, and truncation will eventually break a
//! regex (Codex's "use a real VDF parser" hardening). Hardened against hostile
//! input with a recursion-depth cap, so a deeply nested file errors instead of
//! overflowing the stack, and never panics on malformed/truncated input.
/// Max object nesting depth accepted before bailing out. Real Steam files nest a
/// handful of levels (`registry.vdf` is the deepest at ~6); this is generous while
/// still bounding a malicious file.
const MAX_DEPTH: usize = 32;
/// A parsed KeyValues value: either a leaf string or a nested object. Child order
/// is preserved and duplicate keys are kept (KeyValues permits them); lookups
/// return the first match.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Value {
Str(String),
Obj(Vec<(String, Value)>),
}
impl Value {
/// The leaf string at this node, if it is a string (not an object).
pub fn as_str(&self) -> Option<&str> {
match self {
Value::Str(s) => Some(s),
Value::Obj(_) => None,
}
}
/// The first child value under `key`, if this is an object containing it.
/// Case-insensitive on the key (KeyValues keys are conventionally
/// case-insensitive, and Steam is inconsistent, e.g. `AppState`/`appid`).
pub fn get(&self, key: &str) -> Option<&Value> {
match self {
Value::Obj(pairs) => pairs
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(key))
.map(|(_, v)| v),
Value::Str(_) => None,
}
}
/// Follow a chain of object keys, returning the value at the end of the path.
/// `root.get_path(&["AppState", "name"])`.
pub fn get_path<'a>(&'a self, path: &[&str]) -> Option<&'a Value> {
let mut cur = self;
for key in path {
cur = cur.get(key)?;
}
Some(cur)
}
/// Iterate the (key, value) child pairs if this is an object.
pub fn entries(&self) -> &[(String, Value)] {
match self {
Value::Obj(pairs) => pairs,
Value::Str(_) => &[],
}
}
}
/// Parse KeyValues/VDF text into a top-level object (the sequence of root
/// key→value pairs). Returns `Err` on unbalanced braces, a key with no value, or
/// nesting past [`MAX_DEPTH`]. Never panics.
pub fn parse(input: &str) -> Result<Value, String> {
let mut lexer = Lexer { rest: input };
let obj = parse_object(&mut lexer, 0, true)?;
Ok(Value::Obj(obj))
}
/// Parse a run of `key value` pairs. `top_level` parses until EOF; otherwise it
/// parses until a closing `}` (which it consumes).
fn parse_object(
lexer: &mut Lexer,
depth: usize,
top_level: bool,
) -> Result<Vec<(String, Value)>, String> {
if depth > MAX_DEPTH {
return Err("VDF nesting too deep".to_string());
}
let mut pairs = Vec::new();
loop {
match lexer.next_token()? {
None => {
if top_level {
return Ok(pairs);
}
return Err("unexpected end of input inside object".to_string());
}
Some(Token::Close) => {
if top_level {
return Err("unexpected '}' at top level".to_string());
}
return Ok(pairs);
}
Some(Token::Open) => {
return Err("expected key, found '{'".to_string());
}
Some(Token::Str(key)) => {
// A key must be followed by a value: a string or a nested object.
match lexer.next_token()? {
Some(Token::Str(val)) => pairs.push((key, Value::Str(val))),
Some(Token::Open) => {
let child = parse_object(lexer, depth + 1, false)?;
pairs.push((key, Value::Obj(child)));
}
Some(Token::Close) => {
return Err(format!("key '{key}' has no value (found '}}')"));
}
None => return Err(format!("key '{key}' has no value (end of input)")),
}
}
}
}
}
enum Token {
Open,
Close,
Str(String),
}
struct Lexer<'a> {
rest: &'a str,
}
impl Lexer<'_> {
/// Produce the next token, skipping whitespace and `//` line comments.
fn next_token(&mut self) -> Result<Option<Token>, String> {
loop {
self.rest = self.rest.trim_start();
if self.rest.is_empty() {
return Ok(None);
}
// Line comments: `//` to end of line.
if let Some(after) = self.rest.strip_prefix("//") {
match after.find('\n') {
Some(nl) => self.rest = &after[nl + 1..],
None => {
self.rest = "";
return Ok(None);
}
}
continue;
}
let mut chars = self.rest.char_indices();
let (_, first) = chars.next().expect("non-empty checked above");
return match first {
'{' => {
self.advance_bytes(first.len_utf8());
Ok(Some(Token::Open))
}
'}' => {
self.advance_bytes(first.len_utf8());
Ok(Some(Token::Close))
}
'"' => self.lex_quoted(),
_ => Ok(Some(self.lex_bareword())),
};
}
}
fn advance_bytes(&mut self, n: usize) {
self.rest = &self.rest[n..];
}
/// Lex a `"..."` string, decoding `\\ \" \n \t` escapes. Errors if unterminated.
fn lex_quoted(&mut self) -> Result<Option<Token>, String> {
// Skip the opening quote.
self.advance_bytes(1);
let mut out = String::new();
let mut chars = self.rest.char_indices();
while let Some((i, c)) = chars.next() {
match c {
'"' => {
// Consume through the closing quote.
self.rest = &self.rest[i + 1..];
return Ok(Some(Token::Str(out)));
}
'\\' => {
// Decode the escape.
match chars.next() {
Some((_, esc)) => out.push(match esc {
'n' => '\n',
't' => '\t',
'r' => '\r',
// `\\`, `\"`, and anything else: take the literal char.
other => other,
}),
None => return Err("unterminated escape in quoted string".to_string()),
}
}
other => out.push(other),
}
}
Err("unterminated quoted string".to_string())
}
/// Lex an unquoted token: run of non-whitespace, non-brace, non-quote chars.
fn lex_bareword(&mut self) -> Token {
let end = self
.rest
.find(|c: char| c.is_whitespace() || matches!(c, '{' | '}' | '"'))
.unwrap_or(self.rest.len());
let word = self.rest[..end].to_string();
self.rest = &self.rest[end..];
Token::Str(word)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_appmanifest_name() {
// A trimmed-down real appmanifest_<id>.acf.
let acf = r#"
"AppState"
{
"appid" "730"
"name" "Counter-Strike 2"
"StateFlags" "4"
"installdir" "Counter-Strike Global Offensive"
"UserConfig"
{
"language" "english"
}
}
"#;
let root = parse(acf).unwrap();
assert_eq!(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.
assert_eq!(root.get_path(&["appstate", "NAME"]).and_then(Value::as_str), Some("Counter-Strike 2"));
}
#[test]
fn parses_libraryfolders_paths_with_escaped_backslashes() {
// Windows paths arrive with doubled backslashes (escaped).
let vdf = r#"
"libraryfolders"
{
"0"
{
"path" "C:\\Program Files (x86)\\Steam"
"apps"
{
"730" "35000000000"
}
}
"1"
{
"path" "/home/eric/.local/share/Steam"
}
}
"#;
let root = parse(vdf).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!(lf.get_path(&["1", "path"]).and_then(Value::as_str), Some("/home/eric/.local/share/Steam"));
// The library folder ids are iterable for discovery.
let ids: Vec<&str> = lf.entries().iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(ids, vec!["0", "1"]);
}
#[test]
fn parses_registry_running_appid_deep_path() {
let reg = r#"
"Registry"
{
"HKCU"
{
"Software"
{
"Valve"
{
"Steam"
{
"RunningAppID" "570"
"language" "english"
}
}
}
}
}
"#;
let root = parse(reg).unwrap();
let appid = root
.get_path(&["Registry", "HKCU", "Software", "Valve", "Steam", "RunningAppID"])
.and_then(Value::as_str);
assert_eq!(appid, Some("570"));
}
#[test]
fn handles_comments_and_barewords() {
let vdf = "// a comment\n\"root\"\n{\n\tbarekey barevalue // trailing\n}\n";
let root = parse(vdf).unwrap();
assert_eq!(root.get_path(&["root", "barekey"]).and_then(Value::as_str), Some("barevalue"));
}
#[test]
fn rejects_malformed_without_panicking() {
// Unbalanced braces.
assert!(parse("\"a\" {").is_err());
// Stray closing brace.
assert!(parse("}").is_err());
// Key with no value at EOF.
assert!(parse("\"lonely\"").is_err());
// Unterminated quoted string.
assert!(parse("\"key\" \"unterminated").is_err());
}
#[test]
fn rejects_pathologically_deep_nesting() {
// Build MAX_DEPTH+5 nested objects; must error, not overflow the stack.
let mut s = String::new();
for i in 0..(MAX_DEPTH + 5) {
s.push_str(&format!("\"k{i}\" {{"));
}
for _ in 0..(MAX_DEPTH + 5) {
s.push('}');
}
assert!(parse(&s).is_err());
}
#[test]
fn missing_keys_return_none_not_error() {
let root = parse("\"AppState\" { \"appid\" \"1\" }").unwrap();
assert_eq!(root.get_path(&["AppState", "name"]), None);
assert_eq!(root.get_path(&["Nope"]), None);
// Treating a string as an object yields None rather than panicking.
assert_eq!(root.get_path(&["AppState", "appid", "deeper"]), None);
}
}
+1
View File
@@ -20,6 +20,7 @@ pub mod recents;
pub mod discovery;
pub mod hotkeys;
pub mod files;
pub mod game;
use std::fs::File;
use std::path::{Path, PathBuf};
+268 -11
View File
@@ -1,11 +1,11 @@
use crate::network::{RoomState, NetError, PeerState, RoomEvent, PeerSpeakTicket};
use iroh::{Endpoint, EndpointAddr, EndpointId, SecretKey, Signature};
use iroh::{Endpoint, EndpointAddr, EndpointId, SecretKey, Signature, TransportAddr};
use iroh_gossip::net::Gossip;
use iroh_gossip::proto::TopicId;
use tokio::sync::mpsc;
use tokio::sync::mpsc::Receiver;
use std::sync::{Arc, Mutex};
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeSet, HashMap, HashSet};
use async_trait::async_trait;
use tokio_stream::StreamExt;
use serde::{Serialize, Deserialize};
@@ -22,6 +22,15 @@ use crate::protocol::GOSSIP_SIG_DOMAIN;
/// reasonable cross-peer clock skew without leaving a wide replay window.
const GOSSIP_FRESHNESS_MS: u64 = 120_000;
/// Hard cap on an inbound gossip frame before it is deserialized. The largest
/// legitimate payload is an `Announce` carrying a full custom avatar (≤48 KB
/// base64, [`crate::avatar::CUSTOM_MAX_B64`]) plus the small presence/signature
/// fields — about 49 KB on the wire. This cap sits comfortably above that while
/// bounding the work/allocation a hostile peer can force: `serde_json::from_slice`
/// allocates while parsing, so post-deserialize string caps do NOT prevent abuse —
/// the size must be checked *before* parsing (security hardening, Codex find).
const MAX_GOSSIP_FRAME_BYTES: usize = 128 * 1024;
/// A gossip message plus the authentication envelope that proves who sent it.
/// `author` is the claimed sender (an `EndpointId`, which *is* an ed25519 public
/// key); `sig` is that key's signature over [`signable_bytes`], so a forged
@@ -122,14 +131,98 @@ fn admit_state_mutation(
true
}
/// Size at which we prune stale entries from the replay-tracking map (Tier C
/// F-01 audit). `admit_state_mutation` records `(author, kind)` for every signed
/// mutation, so an insider sending validly signed `Leave`s from unlimited
/// generated keys would otherwise grow it for the room's lifetime. A mutation
/// older than the freshness window can never be the deciding `last_ts` for an
/// in-window message — `verify_gossip`'s timestamp check rejects such a replay
/// first — so dropping those entries cannot weaken replay protection; it bounds
/// the map to roughly the authors seen within one freshness window.
const STATE_MUTATIONS_SOFT_CAP: usize = 256;
/// Drop replay-tracking entries whose timestamp is older than `window_ms` before
/// `now_ms` (see [`STATE_MUTATIONS_SOFT_CAP`]). Pure → unit-testable.
fn prune_stale_mutations(
seen: &mut HashMap<(EndpointId, StateMutationKind), u64>,
now_ms: u64,
window_ms: u64,
) {
let floor = now_ms.saturating_sub(window_ms);
seen.retain(|_, last_ts| *last_ts >= floor);
}
/// Maximum number of distinct peers we hold in a room roster at once.
///
/// Everyone with the room ticket is an authenticated *insider*: a signature only
/// proves ownership of the generated keypair it was made with, not that the
/// author is a distinct human. A malicious member can therefore mint many valid
/// signed identities. Voice is full-mesh (each peer dials every other), so a real
/// room is realistically well under this bound; the cap exists purely so a flood
/// of sock-puppet `Announce`s can't grow our peer map / audio supervisors / dials
/// without limit (Tier C F-01).
const MAX_ACTIVE_PEERS: usize = 32;
/// Maximum transport addresses we retain from a single peer announce. iroh
/// normally advertises a handful (a few LAN/WAN IP candidates plus one home
/// relay); the cap stops an insider stuffing a large unique address set into each
/// announce to inflate the address lookup and the dialer's candidate list.
const MAX_PEER_ADDRS: usize = 8;
/// Maximum byte length of a relay URL we accept inside a peer address. A relay
/// URL is normal-length; anything longer is dropped rather than retained.
const MAX_RELAY_URL_LEN: usize = 256;
/// Bound an untrusted peer's advertised address set before we retain it / hand it
/// to the address lookup and dialer (Tier C F-01). Drops transport kinds we never
/// use (`Custom`) and over-long relay URLs, then truncates to at most
/// [`MAX_PEER_ADDRS`] addresses. `BTreeSet` iteration is deterministic, so the
/// kept subset is stable. Pure → unit-testable.
fn sanitize_endpoint_addr(addr: &EndpointAddr) -> EndpointAddr {
let addrs: BTreeSet<TransportAddr> = addr
.addrs
.iter()
.filter(|a| match a {
TransportAddr::Relay(url) => url.as_str().len() <= MAX_RELAY_URL_LEN,
TransportAddr::Ip(_) => true,
// `TransportAddr` is #[non_exhaustive]; we only speak IP + relay, so
// anything else (Custom / future kinds) is dropped, not retained.
_ => false,
})
.take(MAX_PEER_ADDRS)
.cloned()
.collect();
EndpointAddr { id: addr.id, addrs }
}
/// Whether an `Announce` may enter the roster. Only a brand-new author
/// (`subject_to_cap`) is gated by [`MAX_ACTIVE_PEERS`]; updates to an
/// already-present peer AND re-announces from a peer mid-reconnect (which
/// already held a slot) always pass — exempting reconnects keeps a full room
/// from rejecting a legitimately reconnecting member and orphaning its recovery
/// state (Tier C F-01 audit). Pure → unit-testable.
fn admit_into_roster(roster_len: usize, subject_to_cap: bool, max_peers: usize) -> bool {
!subject_to_cap || roster_len < max_peers
}
/// Whether a received `Announce`'s author is gated by the roster cap. A peer
/// already in the roster (`is_new == false`, an ordinary update) or one
/// mid-reconnect (`is_reconnecting`, it already held a slot) is exempt; only a
/// brand-new author counts against [`MAX_ACTIVE_PEERS`] (Tier C F-01 audit).
/// Pure → unit-testable.
fn announce_subject_to_cap(is_new: bool, is_reconnecting: bool) -> bool {
is_new && !is_reconnecting
}
fn peer_state_for_log(state: &PeerState) -> String {
format!(
"name={:?}, muted={}, addr_id={}, addrs={}, sharing={}",
"name={:?}, muted={}, addr_id={}, addrs={}, sharing={}, game={:?}",
state.name,
state.is_muted,
crate::short_id(&state.addr.id.to_string()),
state.addr.addrs.len(),
state.sharing.is_some()
state.sharing.is_some(),
state.game
)
}
@@ -343,6 +436,17 @@ impl RoomState for IrohGossipState {
match res {
Ok(iroh_gossip::api::Event::Received(msg)) => {
crate::log_msg(&format!("Gossip received Event::Received from delivery={:?}", msg.delivered_from));
// Reject oversized frames BEFORE deserializing: parsing
// allocates, so a size check has to precede `from_slice` to
// bound the memory a hostile peer can make us hold.
if msg.content.len() > MAX_GOSSIP_FRAME_BYTES {
crate::log_msg(&format!(
"Gossip dropped oversized frame: {} bytes > {} cap",
msg.content.len(),
MAX_GOSSIP_FRAME_BYTES
));
continue;
}
match serde_json::from_slice::<GossipPayload>(&msg.content) {
Ok(payload) => {
// Authenticate before trusting `author` for ANY
@@ -369,6 +473,18 @@ impl RoomState for IrohGossipState {
continue;
}
// Keep the replay-tracking map bounded: prune entries
// older than the freshness window once it grows past the
// soft cap (Tier C F-01 audit). Stale entries can't gate
// an in-window message, so this never weakens replay
// protection.
if state_mutations_seen.len() > STATE_MUTATIONS_SOFT_CAP {
prune_stale_mutations(
&mut state_mutations_seen,
now_millis(),
GOSSIP_FRESHNESS_MS,
);
}
if !admit_state_mutation(
&mut state_mutations_seen,
payload.author,
@@ -406,16 +522,58 @@ impl RoomState for IrohGossipState {
// peer-supplied: cap/validate once at ingest
// so invalid offers never render a Watch button.
state.sharing = state.sharing.and_then(crate::screenshare::sanitize_ticket);
disconnected_peers.lock().unwrap().remove(&payload.author);
let (is_new, state_changed) = {
// The game-presence label is untrusted
// peer text like the name: sanitize +
// length-cap at ingest (strip bidi/control,
// 64-char/256-byte cap). An empty result
// means "no game" rather than a blank label.
state.game = state.game.and_then(|g| {
let cleaned = crate::sanitize::sanitize_game_label(&g);
(!cleaned.is_empty()).then_some(cleaned)
});
// Bound an insider's advertised address set
// before we retain it / hand it to the dialer
// (Tier C F-01).
state.addr = sanitize_endpoint_addr(&state.addr);
// A peer reconnecting from a transient drop sits
// in `disconnected_peers` (not the live roster);
// it already held a slot, so it must be re-admitted
// regardless of the cap, and its disconnect marker
// cleared ONLY once re-admitted — clearing it before
// a possible reject would orphan its recovery state
// (Tier C F-01 audit).
let is_reconnecting =
disconnected_peers.lock().unwrap().contains(&payload.author);
let admitted = {
let mut peer_map = peers.lock().unwrap();
let is_new = !peer_map.contains_key(&payload.author);
let state_changed = peer_map.get(&payload.author) != Some(&state);
if is_new || state_changed {
peer_map.insert(payload.author, state.clone());
// Cap the roster so a flood of signed
// sock-puppet identities can't grow our
// memory/tasks/dials without bound (Tier C
// F-01). Existing-peer updates and reconnects
// are exempt; only brand-new authors are gated.
let subject_to_cap = announce_subject_to_cap(is_new, is_reconnecting);
if !admit_into_roster(peer_map.len(), subject_to_cap, MAX_ACTIVE_PEERS) {
None
} else {
let state_changed = peer_map.get(&payload.author) != Some(&state);
if is_new || state_changed {
peer_map.insert(payload.author, state.clone());
}
Some((is_new, state_changed))
}
(is_new, state_changed)
};
let Some((is_new, state_changed)) = admitted else {
crate::log_msg(&format!(
"Gossip roster full ({MAX_ACTIVE_PEERS}); rejecting new peer {}",
crate::short_id(&payload.author.to_string())
));
continue;
};
// Admitted — now it is safe to clear any reconnect
// marker (a rejected announce above leaves it intact
// so a later signed Leave still cleans up).
disconnected_peers.lock().unwrap().remove(&payload.author);
if is_new {
crate::log_msg(&format!(
@@ -423,7 +581,12 @@ impl RoomState for IrohGossipState {
crate::short_id(&payload.author.to_string()),
peer_state_for_log(&state)
));
address_lookup.add_endpoint_info(state.addr.clone());
// Replace (not union) the lookup's record for
// this id with the authenticated, sanitized
// address set, so leave/re-announce cycles
// can't accumulate attacker-supplied history
// (Tier C F-01).
let _ = address_lookup.set_endpoint_info(state.addr.clone());
let _ = event_tx.send(RoomEvent::PeerJoined(payload.author, state)).await;
} else if state_changed {
crate::log_msg(&format!(
@@ -436,6 +599,11 @@ impl RoomState for IrohGossipState {
}
GossipMessage::Leave => {
crate::log_msg(&format!("Gossip peer leave request from author={:?}", payload.author));
// Drop this id's address-lookup entry so cycling
// distinct identities through Announce→Leave can't
// grow the lookup for the room's lifetime (Tier C
// F-01 audit). Re-announce re-populates it.
let _ = address_lookup.remove_endpoint_info(payload.author);
let removed = peers.lock().unwrap().remove(&payload.author).is_some();
let was_disconnected = disconnected_peers
.lock()
@@ -676,6 +844,7 @@ mod tests {
addr,
sharing: None,
avatar: crate::avatar::Avatar::default(),
game: None,
}
}
@@ -687,6 +856,16 @@ mod tests {
EndpointAddr::from(id)
}
#[test]
fn peer_state_log_includes_game() {
let mut state = sample_peer_state_for(fresh_id());
state.game = Some("Half-Life 2".to_string());
assert!(peer_state_for_log(&state).contains("game=Some(\"Half-Life 2\")"));
state.game = None;
assert!(peer_state_for_log(&state).contains("game=None"));
}
#[test]
fn bootstrap_client_dials_host() {
// A non-host (client) with no retained peers dials just the ticket host.
@@ -727,6 +906,84 @@ mod tests {
assert!(!bootstrap.contains(&me));
}
#[test]
fn admit_into_roster_caps_new_authors_but_not_updates() {
// New authors are admitted while there's room...
assert!(admit_into_roster(0, true, 3));
assert!(admit_into_roster(2, true, 3));
// ...rejected once the roster is full...
assert!(!admit_into_roster(3, true, 3));
assert!(!admit_into_roster(10, true, 3));
// ...but an existing peer's update always passes, even at/over the cap.
assert!(admit_into_roster(3, false, 3));
assert!(admit_into_roster(99, false, 3));
}
#[test]
fn reconnecting_and_existing_peers_are_exempt_from_the_cap() {
// A brand-new author counts against the cap...
assert!(announce_subject_to_cap(/* is_new */ true, /* is_reconnecting */ false));
// ...but an ordinary update from an in-roster peer does not...
assert!(!announce_subject_to_cap(false, false));
// ...and neither does a re-announce from a peer mid-reconnect, even
// though it was removed from the live roster (the F-01-audit fix: a full
// room must not reject a legitimately reconnecting member).
assert!(!announce_subject_to_cap(true, true));
// Combined with admit_into_roster: a reconnecting author passes at a full
// roster, a brand-new one does not.
assert!(admit_into_roster(3, announce_subject_to_cap(true, true), 3));
assert!(!admit_into_roster(3, announce_subject_to_cap(true, false), 3));
}
#[test]
fn prune_stale_mutations_drops_only_out_of_window_entries() {
let a = fresh_id();
let b = fresh_id();
let mut seen = HashMap::new();
seen.insert((a, StateMutationKind::Announce), 10_000u64);
seen.insert((b, StateMutationKind::Leave), 250_000u64);
// now = 300_000, window = 120_000 → floor 180_000. The 10_000 entry is
// stale (and could never gate an in-window message), the 250_000 is live.
prune_stale_mutations(&mut seen, 300_000, GOSSIP_FRESHNESS_MS);
assert_eq!(seen.len(), 1);
assert!(seen.contains_key(&(b, StateMutationKind::Leave)));
assert!(!seen.contains_key(&(a, StateMutationKind::Announce)));
}
#[test]
fn sanitize_endpoint_addr_caps_address_count() {
use std::net::SocketAddr;
let id = fresh_id();
// An insider stuffs far more addresses than MAX_PEER_ADDRS into one announce.
let many: Vec<TransportAddr> = (0..(MAX_PEER_ADDRS as u16 + 50))
.map(|i| TransportAddr::Ip(SocketAddr::from(([127, 0, 0, 1], 1000 + i))))
.collect();
let addr = EndpointAddr::from_parts(id, many);
let out = sanitize_endpoint_addr(&addr);
assert_eq!(out.id, id);
assert_eq!(out.addrs.len(), MAX_PEER_ADDRS);
}
#[test]
fn sanitize_endpoint_addr_drops_overlong_relay_url() {
use std::str::FromStr;
let id = fresh_id();
let short = iroh::RelayUrl::from_str("https://relay.example/").unwrap();
let long = iroh::RelayUrl::from_str(&format!(
"https://relay.example/{}",
"a".repeat(MAX_RELAY_URL_LEN)
))
.unwrap();
assert!(long.as_str().len() > MAX_RELAY_URL_LEN);
let addr = EndpointAddr::from_parts(
id,
[TransportAddr::Relay(short.clone()), TransportAddr::Relay(long)],
);
let out = sanitize_endpoint_addr(&addr);
let relays: Vec<_> = out.relay_urls().cloned().collect();
assert_eq!(relays, vec![short], "over-long relay URL must be dropped");
}
#[test]
fn test_gossip_message_leave_round_trip() {
let original = GossipMessage::Leave;
+76
View File
@@ -39,6 +39,58 @@ pub struct PeerState {
/// peers/configs that predate the field still deserialize (→ monogram).
#[serde(default)]
pub avatar: crate::avatar::Avatar,
/// The game this peer is currently playing, as a display string only (shown as
/// `Playing <name>` next to their avatar). Opt-in and **untrusted** like
/// `name`: sanitized + length-capped at the gossip ingest boundary. `None` when
/// the peer isn't sharing a game (feature off / nothing detected). Only the
/// display string rides the wire — never the appid or detection source, to
/// avoid fingerprinting and coupling the protocol to detector internals.
/// Defaulted so peers/configs predating the field still deserialize.
#[serde(default)]
pub game: Option<String>,
}
/// The locally-owned, "sticky" pieces of our own presence: the identity fields
/// that change only on explicit user action and persist for the whole core
/// session. The remaining `PeerState` fields are *volatile* — mute state, current
/// `addr`, and the active screen-share ticket are read fresh at each announce — so
/// they are passed into [`SelfPresence::to_state`] rather than stored here.
///
/// This is the single source of truth for building our own `PeerState`: core
/// reconstructs self-state in several command branches (join, mute toggle, avatar
/// change, screen-share start/stop), and centralizing the `PeerState` literal here
/// means a new presence field is added in exactly one place instead of at every
/// call site.
#[derive(Debug, Clone, Default)]
pub struct SelfPresence {
pub name: String,
pub avatar: crate::avatar::Avatar,
/// The display label of the game we're currently broadcasting, or `None` when
/// game presence is off / nothing is detected. Already sanitized + capped
/// (see `crate::sanitize::sanitize_game_label`) before being stored here, so
/// the outgoing announce carries a safe value.
pub game: Option<String>,
}
impl SelfPresence {
/// Combine the sticky identity fields with the volatile per-announce fields
/// (`is_muted`, current `addr`, active-share `sharing` ticket) into a full
/// `PeerState` ready to announce over the gossip presence plane.
pub fn to_state(
&self,
is_muted: bool,
addr: iroh::EndpointAddr,
sharing: Option<String>,
) -> PeerState {
PeerState {
name: self.name.clone(),
is_muted,
addr,
sharing,
avatar: self.avatar.clone(),
game: self.game.clone(),
}
}
}
#[derive(Debug, Clone)]
@@ -250,6 +302,7 @@ mod tests {
addr,
sharing: None,
avatar: crate::avatar::Avatar::default(),
game: None,
}
}
@@ -358,6 +411,29 @@ mod tests {
assert_eq!(PeerSpeakTicket::restamp("not-a-ticket", EndpointAddr::from(me)), "not-a-ticket");
}
#[test]
fn self_presence_builds_peer_state_with_volatile_fields() {
let addr = EndpointAddr::from(SecretKey::generate().public());
let presence = SelfPresence {
name: "Alice".to_string(),
avatar: crate::avatar::Avatar::default(),
game: Some("Half-Life 2".to_string()),
};
// Volatile fields come from the call; sticky fields from the struct.
let muted = presence.to_state(true, addr.clone(), Some("ticket".to_string()));
assert_eq!(muted.name, "Alice");
assert!(muted.is_muted);
assert_eq!(muted.addr.id, addr.id);
assert_eq!(muted.sharing.as_deref(), Some("ticket"));
assert_eq!(muted.avatar, crate::avatar::Avatar::default());
assert_eq!(muted.game.as_deref(), Some("Half-Life 2"));
// The same sticky presence yields different volatile fields per announce.
let unmuted = presence.to_state(false, addr.clone(), None);
assert!(!unmuted.is_muted);
assert_eq!(unmuted.sharing, None);
assert_eq!(unmuted.name, muted.name);
}
#[test]
fn test_peer_state_serde_round_trip() {
let original = sample_peer_state();
+82
View File
@@ -15,6 +15,16 @@
use crate::friends::FriendStore;
use iroh::EndpointId;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::{Duration, Instant};
/// Maximum immediate presence replies to one friend before throttling. Normal
/// presence polling is once per minute, so this only catches repeated/manual or
/// abusive probes while still allowing a short burst after app startup.
pub const PRESENCE_RATE_LIMIT_BURST: u32 = 4;
/// Refill one presence-reply token per friend at this cadence.
pub const PRESENCE_RATE_LIMIT_REFILL: Duration = Duration::from_secs(15);
/// The user's presence posture — how reachable they are to friends while idle.
/// Persisted in `AppConfig`; the default keeps you privately reachable to friends
@@ -100,6 +110,48 @@ pub fn should_answer(from: &EndpointId, friends: &FriendStore, mode: PresenceMod
mode.answers_pings() && friends.contains(from)
}
#[derive(Debug, Clone)]
struct RateBucket {
tokens: u32,
last_refill: Instant,
}
/// Per-friend limiter for inbound presence pings. It is intentionally keyed by
/// the authenticated connection id, not payload data. Callers should only invoke
/// it after [`should_answer`] passes, so strangers do not consume memory here.
#[derive(Debug, Default, Clone)]
pub struct PresenceRateLimiter {
buckets: HashMap<EndpointId, RateBucket>,
}
impl PresenceRateLimiter {
/// Return whether `from` may receive a presence reply at `now`.
///
/// This is a token bucket: each friend starts with a small burst and regains
/// one token every [`PRESENCE_RATE_LIMIT_REFILL`]. A denied probe should be
/// answered with no data, matching the listener's "reveal nothing" policy.
pub fn allow(&mut self, from: EndpointId, now: Instant) -> bool {
let bucket = self.buckets.entry(from).or_insert(RateBucket {
tokens: PRESENCE_RATE_LIMIT_BURST,
last_refill: now,
});
let elapsed = now.saturating_duration_since(bucket.last_refill);
let refill = elapsed.as_secs() / PRESENCE_RATE_LIMIT_REFILL.as_secs();
if refill > 0 {
let refill = refill.min(u32::MAX as u64) as u32;
bucket.tokens = PRESENCE_RATE_LIMIT_BURST.min(bucket.tokens.saturating_add(refill));
bucket.last_refill = now;
}
if bucket.tokens == 0 {
return false;
}
bucket.tokens -= 1;
true
}
}
/// What we learned about a friend from a successful ping reply.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FriendPresence {
@@ -177,6 +229,36 @@ mod tests {
assert!(!should_answer(&stranger, &friends, PresenceMode::Invisible));
}
#[test]
fn presence_rate_limiter_allows_a_small_burst_then_refills() {
let mut limiter = PresenceRateLimiter::default();
let friend = id();
let now = Instant::now();
for _ in 0..PRESENCE_RATE_LIMIT_BURST {
assert!(limiter.allow(friend, now));
}
assert!(!limiter.allow(friend, now));
assert!(!limiter.allow(friend, now + PRESENCE_RATE_LIMIT_REFILL - Duration::from_millis(1)));
assert!(limiter.allow(friend, now + PRESENCE_RATE_LIMIT_REFILL));
assert!(!limiter.allow(friend, now + PRESENCE_RATE_LIMIT_REFILL));
}
#[test]
fn presence_rate_limiter_is_per_peer() {
let mut limiter = PresenceRateLimiter::default();
let a = id();
let b = id();
let now = Instant::now();
for _ in 0..PRESENCE_RATE_LIMIT_BURST {
assert!(limiter.allow(a, now));
}
assert!(!limiter.allow(a, now));
assert!(limiter.allow(b, now));
}
#[test]
fn presence_mode_flags() {
assert!(PresenceMode::Discoverable.publishes_to_discovery());
+8 -2
View File
@@ -26,7 +26,13 @@ pub const FRIENDS_PROTO: u32 = 1;
/// v2 (0.3.0): `GossipMessage::Chat` gained an optional file attachment
/// (`ChatAttachment`), so a pre-v2 peer can't interpret/serve chat files — bumped
/// to fail fast rather than half-work.
pub const GOSSIP_PROTO: u32 = 2;
///
/// v3 (0.4.0): `PeerState` gained an optional `game` presence field (the
/// `Playing <name>` status). The field is `#[serde(default)]`, so the bump isn't
/// strictly required for decoding — but per the versioning discipline a wire-shape
/// 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.
pub const GOSSIP_PROTO: u32 = 3;
/// File-transfer plane version (chat attachment request/stream shape). Bump on
/// any change. Mirrored in [`FILES_ALPN`].
pub const FILES_PROTO: u32 = 1;
@@ -41,7 +47,7 @@ pub const FILES_ALPN: &[u8] = b"peerspeak/files/1";
/// ed25519 gossip signature domain: `peerspeak-gossip-v<GOSSIP_PROTO>`. Carries
/// the gossip protocol version into every signed payload — a version mismatch
/// fails verification (cryptographic separation between gossip versions).
pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v2";
pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v3";
/// Version-namespace a room topic so peers on different gossip protocol versions
/// derive **different subscription topics from the same ticket** and therefore
+78 -7
View File
@@ -27,19 +27,50 @@ fn is_spoofing_format_char(c: char) -> bool {
)
}
/// Max characters kept for a broadcast game-presence label after sanitizing
/// (game titles run longer than nicknames, so a wider cap than [`NAME_MAX_CHARS`]),
/// bounded additionally by [`GAME_LABEL_MAX_BYTES`] so a multibyte-heavy string
/// can't blow the presence frame.
pub const GAME_LABEL_MAX_CHARS: usize = 64;
/// Max UTF-8 bytes kept for a broadcast game-presence label, applied on top of
/// [`GAME_LABEL_MAX_CHARS`]. Caps the on-wire size regardless of scalar width.
pub const GAME_LABEL_MAX_BYTES: usize = 256;
/// Shared cleaning for untrusted short labels: strip bidi / zero-width spoofing
/// format characters, turn control characters into spaces, collapse any whitespace
/// run to a single space, and trim the ends. Length capping is the caller's job.
fn clean_label(input: &str) -> String {
let cleaned: String = input
.chars()
.filter(|c| !is_spoofing_format_char(*c))
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
cleaned.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// Sanitize an untrusted peer display name for safe rendering. Strips bidi /
/// zero-width format characters, turns control characters into spaces, collapses
/// any whitespace run to a single space, trims the ends, and caps the length at
/// [`NAME_MAX_CHARS`]. Returns `""` if nothing usable remains (callers may
/// substitute a placeholder such as a short id).
pub fn sanitize_name(input: &str) -> String {
let cleaned: String = input
.chars()
.filter(|c| !is_spoofing_format_char(*c))
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
collapsed.chars().take(NAME_MAX_CHARS).collect()
clean_label(input).chars().take(NAME_MAX_CHARS).collect()
}
/// Sanitize an untrusted game-presence label (the `Playing <name>` status that
/// rides the gossip presence plane). Same spoof/control cleaning as
/// [`sanitize_name`], but capped at [`GAME_LABEL_MAX_CHARS`] scalars AND
/// [`GAME_LABEL_MAX_BYTES`] bytes. Apply on BOTH the outgoing label we detect and
/// any incoming peer label. Returns `""` if nothing usable remains (no broadcast).
pub fn sanitize_game_label(input: &str) -> String {
let mut out = String::new();
for c in clean_label(input).chars().take(GAME_LABEL_MAX_CHARS) {
if out.len() + c.len_utf8() > GAME_LABEL_MAX_BYTES {
break;
}
out.push(c);
}
out
}
/// A piece of a chat message after URL detection: literal text or a link.
@@ -135,6 +166,46 @@ mod tests {
assert_eq!(sanitize_name(&long).chars().count(), NAME_MAX_CHARS);
}
// --- sanitize_game_label ----------------------------------------------
#[test]
fn game_label_keeps_ordinary_titles_and_strips_spoofing() {
assert_eq!(sanitize_game_label("Half-Life 2"), "Half-Life 2");
// Same spoof/control cleaning as names.
assert_eq!(sanitize_game_label("Doom\u{202E}txt"), "Doomtxt");
assert_eq!(sanitize_game_label("a\u{0}b\r\nc"), "a b c");
}
#[test]
fn game_label_caps_chars_wider_than_names() {
// A game label keeps more than a name's 48 (up to 64), so a title between
// the two caps survives in full.
let mid = "g".repeat(56);
assert_eq!(sanitize_game_label(&mid).chars().count(), 56);
let long = "g".repeat(GAME_LABEL_MAX_CHARS + 100);
assert_eq!(sanitize_game_label(&long).chars().count(), GAME_LABEL_MAX_CHARS);
}
#[test]
fn game_label_caps_bytes_for_multibyte_titles() {
// Each '世' is 3 bytes; 64 of them = 192 bytes (under 256) → all kept.
let cjk = "".repeat(GAME_LABEL_MAX_CHARS);
let out = sanitize_game_label(&cjk);
assert_eq!(out.chars().count(), GAME_LABEL_MAX_CHARS);
assert!(out.len() <= GAME_LABEL_MAX_BYTES);
// Emoji are 4 bytes; the byte cap bites before the char cap (256/4 = 64,
// but the leading clean keeps them as a run) — never exceeds the byte cap.
let emoji = "🎮".repeat(GAME_LABEL_MAX_CHARS);
let out = sanitize_game_label(&emoji);
assert!(out.len() <= GAME_LABEL_MAX_BYTES);
assert!(out.chars().all(|c| c == '🎮'));
}
#[test]
fn game_label_empty_when_nothing_usable() {
assert_eq!(sanitize_game_label("\u{0}\r\n\t "), "");
}
// --- linkify -----------------------------------------------------------
/// Concatenating every segment's inner text must reproduce the input exactly.
+405 -15
View File
@@ -39,6 +39,10 @@ fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 1] {
/// growth, but reject unbounded gossip payloads before the UI offers "Watch".
const MAX_TICKET_LEN: usize = 512;
/// Upper bound on a PipeWire `application.name` we'll pass to `--app`. Real names
/// are short ("Firefox", "mpv"); this only guards against a pathological value.
const MAX_APP_NAME_LEN: usize = 256;
/// 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
/// is only a safety net so a hung pixelpass can't wedge the caller forever.
@@ -64,6 +68,12 @@ pub enum PixelpassEvent {
CaptureStarted,
/// Host: capture pipeline torn down (on last viewer).
CaptureStopped,
/// Host (per-app audio): the chosen app's audio is now reaching viewers.
AppAudioRouted,
/// Host (per-app audio): the chosen app's last audio stream went away. Under
/// our `--strict-audio` run this means viewers now hear silence (not the call
/// echo) until the app produces audio again — we surface it as a warning.
AppAudioLost,
/// A recognized event we don't act on (e.g. `host_info`).
Other,
}
@@ -98,6 +108,11 @@ pub fn parse_pixelpass_event(line: &str) -> Option<PixelpassEvent> {
Some("stopped") => PixelpassEvent::CaptureStopped,
_ => PixelpassEvent::Other,
},
"app_audio" => match v.get("state").and_then(|s| s.as_str()) {
Some("routed") => PixelpassEvent::AppAudioRouted,
Some("lost") => PixelpassEvent::AppAudioLost,
_ => PixelpassEvent::Other,
},
_ => PixelpassEvent::Other,
};
Some(ev)
@@ -107,6 +122,144 @@ fn json_u32(v: &serde_json::Value, key: &str) -> u32 {
v.get(key).and_then(|x| x.as_u64()).unwrap_or(0) as u32
}
/// Build the argv for a pixelpass *host*. Always `--host --output json`; when
/// `audio_app` is `Some`, append `--app=<name> --strict-audio` so pixelpass
/// captures only that app's audio instead of the whole desktop sink monitor
/// (which contains our own call playout → the viewer would hear themselves
/// echoed back, backlog A23).
///
/// `--strict-audio` is what makes the fix a guarantee rather than best-effort:
/// without it, pixelpass falls back to the whole-desktop loopback before the
/// app's first stream routes and again if the app's audio later stops — both of
/// which reintroduce the echo. With it, the viewer hears only the chosen app (or
/// silence), and pixelpass emits `app_audio` events we surface as a warning.
///
/// The name is passed in the single-token `--app=<name>` form so a value that
/// happens to begin with `-` can never be reparsed as a pixelpass flag (clap
/// otherwise rejects hyphen-leading option values). The name is locally chosen
/// (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.
pub fn host_args(audio_app: Option<&str>) -> Vec<String> {
let mut args = vec![
"--host".to_string(),
"--output".to_string(),
"json".to_string(),
];
if let Some(name) = audio_app.and_then(sanitize_app_name) {
args.push(format!("--app={name}"));
args.push("--strict-audio".to_string());
}
args
}
/// Validate a locally-chosen audio app name before it becomes a `--app` value:
/// trim, reject empty / overlong, and reject names carrying control characters
/// (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.
pub fn sanitize_app_name(name: &str) -> Option<String> {
let name = name.trim();
let ok = !name.is_empty()
&& name.len() <= MAX_APP_NAME_LEN
&& !name.chars().any(|c| c.is_control());
ok.then(|| name.to_string())
}
/// Hard cap on how long enumeration waits for `pactl`. It runs inline on the core
/// command loop (the picker awaits it before opening), so a wedged/slow `pactl`
/// must not stall mute/deafen/leave/stop. On timeout we treat it like any other
/// failure: empty list → "All system audio" only.
const LIST_APPS_TIMEOUT: Duration = Duration::from_secs(2);
/// Enumerate the apps currently sending audio to a sink, deduplicated by
/// `application.name`. Mirrors how pixelpass itself builds its interactive
/// picker (`pactl -f json list sink-inputs`), so the names we return are exactly
/// the ones `--app` matches against. Returns an empty list on any error (pactl
/// missing, non-PipeWire host, nothing playing, or [`LIST_APPS_TIMEOUT`] elapsed)
/// — a normal, handled state that leaves the picker showing only "All system
/// audio".
pub async fn list_audio_apps() -> Vec<String> {
let run = Command::new("pactl")
.args(["-f", "json", "list", "sink-inputs"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
// On [`LIST_APPS_TIMEOUT`] the `output()` future is dropped, which drops
// the child — `kill_on_drop(true)` then SIGKILLs and reaps it so a wedged
// `pactl` can't linger/accumulate across picker opens (audit P3).
.kill_on_drop(true)
.output();
match tokio::time::timeout(LIST_APPS_TIMEOUT, run).await {
Ok(Ok(o)) if o.status.success() => parse_audio_apps(&o.stdout),
_ => Vec::new(),
}
}
/// Hard cap on the capability probe (`pixelpass --help`). Conservative: a slow or
/// hung pixelpass degrades to "strict audio unsupported" → whole-desktop-only
/// picker (safe), never a stalled core loop.
const HELP_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
/// Whether the resolved pixelpass understands `--strict-audio` (added in pixelpass
/// `85fdebe`). peerspeak only offers per-app audio capture when it does: a per-app
/// share always appends `--strict-audio`, and an **older** pixelpass would have
/// clap reject the unknown flag → the host spawn hard-fails and the share is
/// broken (audit P2, version skew). When unsupported the picker degrades to
/// whole-desktop audio only — we never silently drop to best-effort `--app`, which
/// would reintroduce the call echo (A23).
///
/// Any probe failure/timeout returns `false` (degrade to the safe path). The
/// `--help` child is `kill_on_drop` so a hung pixelpass can't linger.
pub async fn supports_strict_audio(bin: &Path) -> bool {
let run = Command::new(bin)
.arg("--help")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true)
.output();
match tokio::time::timeout(HELP_PROBE_TIMEOUT, run).await {
Ok(Ok(o)) => help_mentions_strict_audio(&o.stdout),
_ => false,
}
}
/// Pure check: does `pixelpass --help` advertise `--strict-audio`? Matches the
/// flag token rather than a whole line, since clap may wrap/realign help text.
pub fn help_mentions_strict_audio(help_stdout: &[u8]) -> bool {
String::from_utf8_lossy(help_stdout).contains("--strict-audio")
}
/// Parse `pactl -f json list sink-inputs` stdout into a sorted, deduplicated list
/// of `application.name`s. Pure: no I/O. Unparseable input yields an empty list.
/// Each name is passed through [`sanitize_app_name`] so the picker only ever
/// offers names that will actually survive [`host_args`]; otherwise a name that
/// parses here but fails sanitization later would be selectable yet silently
/// drop the `--app` flag and revert the share to whole-desktop audio (A23 echo).
pub fn parse_audio_apps(stdout: &[u8]) -> Vec<String> {
let Ok(entries) = serde_json::from_slice::<Vec<SinkInput>>(stdout) else {
return Vec::new();
};
let mut names: Vec<String> = entries
.into_iter()
.filter_map(|e| e.properties.application_name)
.filter_map(|n| sanitize_app_name(&n))
.collect();
names.sort_unstable();
names.dedup();
names
}
#[derive(serde::Deserialize)]
struct SinkInput {
properties: SinkInputProperties,
}
#[derive(serde::Deserialize)]
struct SinkInputProperties {
#[serde(rename = "application.name")]
application_name: Option<String>,
}
/// Build the argv for a pixelpass *viewer*. The `ticket` is peer-supplied (it
/// rides gossip presence, which is untrusted and spoofable), so flags come first
/// and the ticket is passed as a positional **after a `--` end-of-options
@@ -162,20 +315,30 @@ pub fn is_available(config_override: Option<&str>) -> bool {
pixelpass_path(config_override).is_some()
}
/// Spawn a pixelpass host (`pixelpass --host --output json`), wait for its
/// startup ticket, and return the live child plus the ticket. The child keeps
/// Spawn a pixelpass host (`pixelpass --host --output json [--app=<name>]`), wait
/// for its startup ticket, and return the live child plus the ticket. When
/// `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
/// 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
/// **not** pass `--max-viewers`: pixelpass bandwidth-measures its own safe cap,
/// protecting the sharer's uplink, and refuses extras with `viewer_refused`.
pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
pub async fn spawn_host(
bin: &Path,
audio_app: Option<&str>,
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
) -> std::io::Result<(Child, String)> {
let mut child = Command::new(bin)
.arg("--host")
.arg("--output")
.arg("json")
.args(host_args(audio_app))
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
// Capture stderr (not null): pixelpass prints its startup precondition
// failures there — a missing GStreamer plugin / `pactl`, each with an
// actionable "Install hint: sudo apt install ..." line. If the host dies
// before its ticket we fold that tail into our error so the user sees
// *what to install* instead of a dead-end "exited before a ticket". On
// the success path we drain it in the background so the pipe can't fill.
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()?;
@@ -183,6 +346,7 @@ pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
.stdout
.take()
.ok_or_else(|| std::io::Error::other("pixelpass host stdout missing"))?;
let stderr = child.stderr.take();
let mut lines = BufReader::new(stdout).lines();
let ticket = match read_until(&mut lines, |e| match e {
@@ -194,9 +358,10 @@ pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
Ok(Some(t)) => t,
Ok(None) => {
let _ = child.kill().await;
return Err(std::io::Error::other(
"pixelpass host exited before emitting a ticket",
));
let detail = read_stderr_tail(stderr).await;
return Err(std::io::Error::other(format!(
"pixelpass host exited before emitting a ticket{detail}"
)));
}
Err(e) => {
let _ = child.kill().await;
@@ -204,10 +369,65 @@ pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
}
};
drain_in_background(lines, "host");
if let Some(stderr) = stderr {
drain_stderr_in_background(stderr);
}
drain_in_background(lines, "host", notices);
Ok((child, ticket))
}
/// Read a killed pixelpass child's stderr to EOF and reduce it to a short,
/// user-facing diagnostic tail via [`pixelpass_failure_detail`]. Bounded: the
/// caller kills the child first, so the pipe EOFs promptly. Returns an empty
/// string when stderr was already taken or carried nothing useful.
async fn read_stderr_tail(stderr: Option<tokio::process::ChildStderr>) -> String {
use tokio::io::AsyncReadExt;
let Some(mut stderr) = stderr else {
return String::new();
};
let mut buf = Vec::new();
let _ = stderr.read_to_end(&mut buf).await;
pixelpass_failure_detail(&String::from_utf8_lossy(&buf))
}
/// Discard a running pixelpass child's stderr in the background so its pipe
/// can't fill and stall the host (mirrors [`drain_in_background`] for stdout).
fn drain_stderr_in_background(mut stderr: tokio::process::ChildStderr) {
use tokio::io::AsyncReadExt;
tokio::spawn(async move {
let mut buf = [0u8; 4096];
while let Ok(n) = stderr.read(&mut buf).await {
if n == 0 {
break;
}
}
});
}
/// Extract a human-useful tail from a failed pixelpass child's stderr to append
/// to our error. pixelpass writes actionable startup errors there (a missing
/// GStreamer element / `pactl` plus an `Install hint: sudo apt install ...`
/// line), which is exactly what a freshly-installed host needs to see. The
/// decorative host banner (box-drawing) is dropped — it only prints on the
/// success path, but we filter it defensively. Pure: no I/O. Returns an empty
/// string when there's nothing worth surfacing (so callers can append blindly).
pub fn pixelpass_failure_detail(stderr: &str) -> String {
let useful: Vec<&str> = stderr
.lines()
.map(str::trim_end)
.filter(|l| !l.trim().is_empty())
.filter(|l| !l.trim_start().starts_with(['│', '┌', '└', '├']))
.collect();
if useful.is_empty() {
return String::new();
}
// The anyhow error and its install hint are the *last* lines printed, so
// keep the tail rather than the head.
const MAX_LINES: usize = 12;
let start = useful.len().saturating_sub(MAX_LINES);
format!("\n\npixelpass reported:\n{}", useful[start..].join("\n"))
}
/// 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
/// child so the caller can kill it on room-leave; it also self-exits when the
@@ -251,7 +471,7 @@ pub async fn spawn_viewer(bin: &Path, ticket: &str) -> std::io::Result<Child> {
return Err(e);
}
drain_in_background(lines, "viewer");
drain_in_background(lines, "viewer", None);
Ok(child)
}
@@ -286,15 +506,24 @@ where
}
/// Keep reading the child's stdout to EOF in the background so a full pipe can't
/// stall it; log notable events for diagnostics.
fn drain_in_background<R>(mut lines: tokio::io::Lines<BufReader<R>>, role: &'static str)
where
/// 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
/// `app_audio` ones into a UI warning); a send failure (receiver dropped) just
/// stops forwarding, draining continues. The task ends on EOF (child exited).
fn drain_in_background<R>(
mut lines: tokio::io::Lines<BufReader<R>>,
role: &'static str,
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
) where
R: tokio::io::AsyncRead + Unpin + Send + 'static,
{
tokio::spawn(async move {
while let Ok(Some(line)) = lines.next_line().await {
if let Some(ev) = parse_pixelpass_event(&line) {
crate::log_msg(&format!("pixelpass {role}: {}", event_for_log(&ev)));
if let Some(tx) = &notices {
let _ = tx.send(ev);
}
}
}
});
@@ -313,6 +542,8 @@ fn event_for_log(ev: &PixelpassEvent) -> String {
PixelpassEvent::Refused(reason) => format!("viewer_refused reason={reason:?}"),
PixelpassEvent::CaptureStarted => "capture_started".to_string(),
PixelpassEvent::CaptureStopped => "capture_stopped".to_string(),
PixelpassEvent::AppAudioRouted => "app_audio_routed".to_string(),
PixelpassEvent::AppAudioLost => "app_audio_lost".to_string(),
PixelpassEvent::Other => "other".to_string(),
}
}
@@ -384,6 +615,142 @@ mod tests {
);
}
#[test]
fn host_args_without_app_shares_whole_desktop() {
// No app selected → no --app flag → pixelpass keeps its default
// (whole-desktop) audio capture.
assert_eq!(host_args(None), vec!["--host", "--output", "json"]);
}
#[test]
fn host_args_with_app_appends_single_token_flag() {
// 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
// `--strict-audio` so pixelpass never falls back to whole-desktop audio.
assert_eq!(
host_args(Some("Firefox")),
vec!["--host", "--output", "json", "--app=Firefox", "--strict-audio"]
);
// The hyphen-leading name is still bound to --app as a single token;
// --strict-audio is the trailing flag.
let args = host_args(Some("-rm -rf"));
assert_eq!(args[3], "--app=-rm -rf");
assert_eq!(args[4], "--strict-audio");
}
#[test]
fn host_args_blank_or_control_app_is_dropped() {
// An empty / whitespace / control-laden selection is sanitized away,
// falling back to whole-desktop capture rather than a broken flag.
assert_eq!(host_args(Some(" ")), vec!["--host", "--output", "json"]);
assert_eq!(host_args(Some("bad\nname")), vec!["--host", "--output", "json"]);
}
#[test]
fn sanitize_app_name_trims_and_rejects_garbage() {
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("a\tb"), None);
assert_eq!(sanitize_app_name(&"x".repeat(MAX_APP_NAME_LEN + 1)), None);
}
#[test]
fn parse_audio_apps_dedups_and_sorts_by_application_name() {
let stdout = br#"[
{"index":1,"properties":{"application.name":"Firefox"}},
{"index":2,"properties":{"application.name":"mpv"}},
{"index":3,"properties":{"application.name":"Firefox"}},
{"index":4,"properties":{"application.name":" Spotify "}},
{"index":5,"properties":{"application.name":""}},
{"index":6,"properties":{"other":"no name here"}}
]"#;
assert_eq!(
parse_audio_apps(stdout),
vec!["Firefox".to_string(), "Spotify".to_string(), "mpv".to_string()]
);
}
#[test]
fn parse_audio_apps_empty_or_garbage_is_empty() {
assert_eq!(parse_audio_apps(b""), Vec::<String>::new());
assert_eq!(parse_audio_apps(b"not json"), Vec::<String>::new());
assert_eq!(parse_audio_apps(b"[]"), Vec::<String>::new());
}
#[test]
fn parse_audio_apps_drops_names_host_args_would_reject() {
// Names that parse from pactl but fail `sanitize_app_name` (control chars,
// overlong) must NOT be offered in the picker — otherwise the user could
// pick one, `host_args` would silently drop `--app`, and the share would
// revert to whole-desktop audio (A23 echo) with no signal. The valid name
// survives; the control-char and overlong ones are filtered out.
let overlong = "x".repeat(MAX_APP_NAME_LEN + 1);
let stdout = format!(
r#"[
{{"index":1,"properties":{{"application.name":"mpv"}}}},
{{"index":2,"properties":{{"application.name":"bad\nname"}}}},
{{"index":3,"properties":{{"application.name":"{overlong}"}}}}
]"#
);
assert_eq!(parse_audio_apps(stdout.as_bytes()), vec!["mpv".to_string()]);
}
#[test]
fn failure_detail_surfaces_install_hint_and_drops_banner() {
// The real shape of a fresh-host failure: anyhow error + install hint on
// stderr. We must keep those (so the user knows what to apt install) and
// drop the decorative banner box-drawing lines.
let stderr = "\
PixelPass · host
display server : Wayland
Error: GStreamer element `vah264enc` not available.
Install hint: sudo apt install gstreamer1.0-plugins-bad
";
let detail = pixelpass_failure_detail(stderr);
assert!(detail.starts_with("\n\npixelpass reported:\n"));
assert!(detail.contains("vah264enc` not available"));
assert!(detail.contains("sudo apt install gstreamer1.0-plugins-bad"));
assert!(!detail.contains('│'), "banner box-drawing must be dropped");
assert!(!detail.contains('┌'));
}
#[test]
fn failure_detail_empty_when_nothing_useful() {
// Blank / banner-only stderr yields an empty string so the caller can
// append it to the base message unconditionally without trailing noise.
assert_eq!(pixelpass_failure_detail(""), "");
assert_eq!(pixelpass_failure_detail(" \n \n"), "");
assert_eq!(
pixelpass_failure_detail("│ display server : Wayland\n│ capture : x\n"),
""
);
}
#[test]
fn failure_detail_keeps_only_the_tail() {
// A long stderr is truncated to its last lines (where the real error
// and hint live), not its head.
let body: String = (0..30).map(|i| format!("line {i}\n")).collect();
let detail = pixelpass_failure_detail(&body);
assert!(detail.contains("line 29"));
assert!(!detail.contains("line 0\n"));
}
#[test]
fn help_probe_detects_strict_audio_flag() {
// A new pixelpass advertises the flag; an old one doesn't. The probe must
// match the token even when clap wraps the option onto its own line.
let new_help = b"Options:\n --app <APP>\n --strict-audio\n With --app, never fall back...";
assert!(help_mentions_strict_audio(new_help));
let old_help = b"Options:\n --app <APP>\n --output <OUTPUT>\n -h, --help";
assert!(!help_mentions_strict_audio(old_help));
// Garbage / empty output degrades to "unsupported" (safe path).
assert!(!help_mentions_strict_audio(b""));
assert!(!help_mentions_strict_audio(&[0xff, 0xfe, 0x00]));
}
#[test]
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
@@ -470,6 +837,29 @@ mod tests {
);
}
#[test]
fn parses_app_audio_states() {
// The wire contract from pixelpass's --strict-audio run (A23): routed =
// the chosen app's audio is live; lost = it stopped (viewers now silent).
assert_eq!(
parse_pixelpass_event(r#"{"event":"app_audio","state":"routed"}"#),
Some(PixelpassEvent::AppAudioRouted)
);
assert_eq!(
parse_pixelpass_event(r#"{"event":"app_audio","state":"lost"}"#),
Some(PixelpassEvent::AppAudioLost)
);
// Unknown / missing state is recognized-but-unused, not a parse failure.
assert_eq!(
parse_pixelpass_event(r#"{"event":"app_audio","state":"weird"}"#),
Some(PixelpassEvent::Other)
);
assert_eq!(
parse_pixelpass_event(r#"{"event":"app_audio"}"#),
Some(PixelpassEvent::Other)
);
}
#[test]
fn recognized_but_unused_event_is_other() {
assert_eq!(
+1
View File
@@ -63,6 +63,7 @@ fn state(name: &str, addr: EndpointAddr) -> PeerState {
addr,
sharing: None,
avatar: Default::default(),
game: None,
}
}