Compare commits
23
Commits
63c246d976
..
v0.6.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f100981ad0 | ||
|
|
c2d82acf82 | ||
|
|
0823f6173f | ||
|
|
ff2533c95d | ||
|
|
140e4e7f73 | ||
|
|
db8459fd03 | ||
|
|
260815154f | ||
|
|
2c2b861516 | ||
|
|
ba96e59db0 | ||
|
|
d72271bd2e | ||
|
|
bf6d0e47b5 | ||
|
|
46dc5902d6 | ||
|
|
a1df62ce21 | ||
|
|
30a460420a | ||
|
|
61960eb76f | ||
|
|
3d1f114fd8 | ||
|
|
9e52acf9d3 | ||
|
|
9ba42c4cda | ||
|
|
f46b2cacc7 | ||
|
|
d023621eee | ||
|
|
59da73c013 | ||
|
|
0e395e5c0c | ||
|
|
774922c6a9 |
@@ -11,3 +11,9 @@
|
||||
# the .iss script and .ico are the tracked sources.
|
||||
/packaging/windows/peerspeak.exe
|
||||
/packaging/windows/output/
|
||||
|
||||
# Nix: the symlink `nix build` drops, and direnv's local cache. flake.nix and
|
||||
# flake.lock ARE tracked — the lock is what pins the toolchain.
|
||||
/result
|
||||
/result-*
|
||||
/.direnv/
|
||||
|
||||
@@ -4,6 +4,39 @@ All notable changes to PeerSpeak are documented here.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.7] — 2026-08-22
|
||||
|
||||
### Changed
|
||||
- **All system audio is now the safe screen-share choice.** On supported
|
||||
PixelPass builds, the single **All system audio** row shares ordinary desktop
|
||||
sound while excluding PeerSpeak's own call playback. There is no longer a
|
||||
second, technical-sounding desktop-audio choice to understand before sharing.
|
||||
|
||||
### Fixed
|
||||
- **Sharing desktop audio no longer sends the call back to its listeners.**
|
||||
PeerSpeak marks every playback path it owns, passes the active echo-canceller
|
||||
identity to PixelPass, and uses a fail-closed PipeWire fan-out so voice,
|
||||
watched shares, and notification sounds cannot enter the screen-share mix.
|
||||
- **Screen-share audio failures are visible without becoming unsafe.** Missing,
|
||||
unsupported, or newly unsafe streams stay out of the capture and produce a
|
||||
useful warning; warnings clear again when the condition or share ends.
|
||||
- **Stopped and failed shares clean up promptly.** Remote viewers and their
|
||||
players are reaped when a stream ends, and leaving a call tears down the
|
||||
PixelPass host, capture graph, and viewer processes instead of leaving a
|
||||
stale sharing state or warning behind.
|
||||
|
||||
### Packaging
|
||||
- **The Linux AppImage bundles the matching PixelPass helper.** The one-file
|
||||
build therefore carries the desktop-audio exclusion capability it advertises
|
||||
rather than depending on an unrelated system PixelPass version.
|
||||
|
||||
### Compatibility
|
||||
- **Wire-compatible with 0.6.6.** This release changes local screen-share audio
|
||||
routing and lifecycle behavior; no PeerSpeak audio, friends, files, or gossip
|
||||
protocol version changed.
|
||||
|
||||
[0.6.7]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.6.7
|
||||
|
||||
## [0.6.6] — 2026-07-19
|
||||
|
||||
### Fixed
|
||||
@@ -25,6 +58,8 @@ All notable changes to PeerSpeak are documented here.
|
||||
connection before anything else kicked in. Smooth still honors the value you
|
||||
choose, since a deep buffer is the point of that mode.
|
||||
|
||||
[0.6.6]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.6.6
|
||||
|
||||
## [0.6.5] — 2026-07-19
|
||||
|
||||
### Added
|
||||
@@ -51,6 +86,8 @@ All notable changes to PeerSpeak are documented here.
|
||||
now presents decoded frames immediately; Smooth mode retains timestamp pacing
|
||||
when keeping shared-video audio and video synchronized matters more.
|
||||
|
||||
[0.6.5]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.6.5
|
||||
|
||||
## [0.6.4] — 2026-07-18
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+3
-3
@@ -2262,9 +2262,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.14"
|
||||
version = "0.4.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
|
||||
checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "peerspeak"
|
||||
version = "0.6.6"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "peerspeak"
|
||||
version = "0.6.6"
|
||||
version = "0.6.7"
|
||||
edition = "2024"
|
||||
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||
license = "MIT"
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
# PeerSpeak 0.6.7 release prep
|
||||
|
||||
Prepared: 2026-08-22
|
||||
|
||||
Planned work day: 2026-08-23 (confirm the actual date before updating the changelog)
|
||||
|
||||
Release target: GitButter release page with a verified Linux x86_64 AppImage
|
||||
|
||||
This is a checklist, not release authorization. Pushing `main`, creating or
|
||||
pushing a tag, creating the GitButter release, and uploading assets remain
|
||||
explicit approval gates.
|
||||
|
||||
## Starting checkpoint
|
||||
|
||||
Recheck every value live tomorrow; these are the known-good handoff values from
|
||||
2026-08-22.
|
||||
|
||||
- PeerSpeak: `main` at `0823f617`, 17 commits ahead of `origin/main`.
|
||||
- PixelPass: `main` at `ce909afc`, 14 commits ahead of `origin/main`.
|
||||
- Latest published PeerSpeak tag: `v0.6.6`.
|
||||
- Current Cargo/Windows version: `0.6.6`.
|
||||
- Current field-test build:
|
||||
`packaging/appimage/peerspeak-0.6.6-unofficial-20260822-ps0823f617-ppce909afc-fieldtest-x86_64.AppImage`
|
||||
- Current field-test SHA-256:
|
||||
`f094a665dbd719929d6def32c8fd3d4943e4b88b6290e371e57b127c4e91ec45`.
|
||||
- The same build was staged for Lindsay at
|
||||
`/home/lindsay/Downloads/peerspeak-0.6.6-unofficial-20260822-ps0823f617-ppce909afc-fieldtest-x86_64.AppImage`.
|
||||
- Preserve the existing user-owned `docs/FEATURES.md` modification and
|
||||
untracked `.codex/` directory. Do not include either in release-prep commits
|
||||
unless the user explicitly puts them in scope.
|
||||
|
||||
The current one-row audio-picker build passed local AppImage artifact checks,
|
||||
but it still needs the final two-machine field pass. Earlier two-machine passes
|
||||
proved that desktop audio was heard, call voices were not echoed, and no warning
|
||||
was visible. They also exposed the now-fixed stale viewer/warning behavior. Do
|
||||
not substitute those earlier passes for testing the current build.
|
||||
|
||||
## Version decision
|
||||
|
||||
The expected release is **0.6.7**.
|
||||
|
||||
`VERSIONING.md` defines pre-1.0 MINOR bumps as breaking wire-protocol changes,
|
||||
not as a measure of feature size. The diff from `v0.6.6` through `0823f617` is a
|
||||
large screen-share implementation and local UI/lifecycle change, but it does
|
||||
not change `src/protocol.rs` or bump an audio, friends, files, or gossip protocol
|
||||
version. Under the repository's policy, that makes this a PATCH release.
|
||||
|
||||
Use `0.7.0` only if tomorrow's final source review finds or adds an actual
|
||||
incompatible wire change. If that happens, identify and bump the affected
|
||||
Layer-2 protocol constant/domain in the same change and plan a coordinated
|
||||
upgrade for every peer.
|
||||
|
||||
## Critical build-label rule
|
||||
|
||||
The first/home screen visibly identifies the binary as:
|
||||
|
||||
```text
|
||||
PeerSpeak v<package version> (<8-character commit hash>)
|
||||
```
|
||||
|
||||
The same label also appears in Settings. The two pieces come from different
|
||||
places:
|
||||
|
||||
- `Cargo.toml` supplies the version through `CARGO_PKG_VERSION` and must be
|
||||
bumped from `0.6.6` to `0.6.7`.
|
||||
- `build.rs` runs `git rev-parse --short=8 HEAD` and embeds the result as
|
||||
`PEERSPEAK_GIT_SHORT`. **Do not edit or bump the hash by hand.**
|
||||
|
||||
Consequences for the release order:
|
||||
|
||||
1. Make and commit all source, version, changelog, and field-evidence changes.
|
||||
2. Build the final AppImage from that clean final commit.
|
||||
3. On first launch, verify the title screen says exactly
|
||||
`PeerSpeak v0.6.7 (<final HEAD short hash>)`.
|
||||
4. Verify the displayed hash equals `git rev-parse --short=8 HEAD`.
|
||||
5. Tag that exact commit as `v0.6.7`.
|
||||
|
||||
If any commit is added after an AppImage is built—even a field-evidence or
|
||||
release-note commit—the embedded hash is now old. Rebuild and revalidate the
|
||||
AppImage. Never publish an artifact whose visible hash differs from the tag
|
||||
target.
|
||||
|
||||
## Tomorrow's runbook
|
||||
|
||||
### 1. Re-establish live state
|
||||
|
||||
From `/home/mollusk/git/butter/peerspeak`:
|
||||
|
||||
```sh
|
||||
git status --short --branch
|
||||
git log -1 --oneline --decorate
|
||||
git -C ../pixelpass status --short --branch
|
||||
git -C ../pixelpass log -1 --oneline --decorate
|
||||
git fetch --prune --tags origin
|
||||
git ls-remote --heads --tags origin
|
||||
df -h / /mnt/superjar
|
||||
pgrep -af 'peerspeak|pixelpass|mpv|gst-launch' || true
|
||||
```
|
||||
|
||||
Confirm that:
|
||||
|
||||
- the checkpoint commits above are still the intended source;
|
||||
- neither repository has unexpected changes;
|
||||
- the existing `docs/FEATURES.md` and `.codex/` state is preserved;
|
||||
- no stale call/share processes are running;
|
||||
- `v0.6.7` does not already exist locally, remotely, or on GitButter;
|
||||
- there is enough space for one-job release builds and extracted AppImages.
|
||||
|
||||
### 2. Complete the current-build two-machine field test
|
||||
|
||||
Use the stamped `0823f617` AppImage on this machine and Lindsay's staged copy.
|
||||
Start with PeerSpeak and any old screen-share helper/player processes closed.
|
||||
|
||||
Run both directions, with Lindsay keeping software encode enabled where needed:
|
||||
|
||||
1. Mollusk hosts; Lindsay views.
|
||||
2. Lindsay hosts; Mollusk views.
|
||||
3. In each direction select the sole visible **All system audio** row.
|
||||
4. Play desktop audio after the share begins, including starting a new audio
|
||||
stream/application during the share.
|
||||
5. Talk from both machines while the desktop audio plays.
|
||||
6. Stop sharing while the voice call remains active.
|
||||
7. Start one more share, then have both peers leave the call.
|
||||
|
||||
Record all of these results explicitly:
|
||||
|
||||
- the picker has one desktop-audio row, not duplicate legacy/safe choices;
|
||||
- desktop audio is heard by the viewer;
|
||||
- neither person's call voice loops back through the shared audio;
|
||||
- no transient or persistent missing-output-port warning appears;
|
||||
- stopping a share clears the viewer and warning promptly;
|
||||
- the viewer PixelPass child is reaped within roughly 500 ms after the remote
|
||||
stream ends;
|
||||
- leaving the call clears the share and leaves no PeerSpeak-owned PixelPass,
|
||||
player, GStreamer, capture, or echo-cancellation residue.
|
||||
|
||||
If any row fails, stop release preparation. Save the exact visible text and
|
||||
relevant logs, fix the defect, run the focused regression gates, commit the
|
||||
fix, rebuild, and repeat the matrix.
|
||||
|
||||
### 3. Prepare the 0.6.7 source
|
||||
|
||||
After the current build passes:
|
||||
|
||||
- Change `[package].version` in `Cargo.toml` to `0.6.7`.
|
||||
- Refresh the root PeerSpeak package entry in `Cargo.lock`; inspect the diff and
|
||||
make sure it changes only as intended.
|
||||
- Change `MyAppVersion` in `packaging/windows/peerspeak.iss` to `0.6.7`, even
|
||||
though tomorrow's planned public asset is AppImage-only. This prevents the
|
||||
next Windows installer from silently retaining `0.6.6`.
|
||||
- Move the relevant `[Unreleased]` material into a dated `0.6.7` section in
|
||||
`CHANGELOG.md` and add the `v0.6.7` comparison/release link.
|
||||
- State that the release is wire-compatible with `0.6.6`; do not claim a
|
||||
protocol bump.
|
||||
- Summarize user-visible behavior, especially:
|
||||
- **All system audio** now shares desktop sound without feeding PeerSpeak's
|
||||
own call audio back to listeners;
|
||||
- the safe path is the single normal desktop-audio choice;
|
||||
- unsafe or incomplete audio-routing states fail closed and surface a useful
|
||||
warning;
|
||||
- viewer/share cleanup is prompt when a stream stops or a peer leaves;
|
||||
- the Linux AppImage bundles the matching PixelPass helper.
|
||||
- Record the completed two-machine evidence in
|
||||
`docs/screenshare-audio-exclusion-impl-plan.md`.
|
||||
|
||||
Commit the scoped version/changelog/evidence work. Keep unrelated user changes
|
||||
out of the commit.
|
||||
|
||||
### 4. Run source gates on the final commit
|
||||
|
||||
Use the pinned Rust toolchain and one build job if disk or linker pressure is
|
||||
tight:
|
||||
|
||||
```sh
|
||||
cargo fmt --all -- --check
|
||||
CARGO_BUILD_JOBS=1 cargo clippy --locked --all-targets -- -D warnings
|
||||
CARGO_BUILD_JOBS=1 cargo test --locked --all-targets
|
||||
CARGO_BUILD_JOBS=1 cargo test --locked --doc
|
||||
cargo deny --locked check
|
||||
cargo audit
|
||||
git diff --check
|
||||
git status --short --branch
|
||||
```
|
||||
|
||||
The commands mirror the repository CI, with `--locked` added to compilation
|
||||
and test gates after the intentional lockfile refresh. A gate that cannot run
|
||||
must be recorded as unverified; do not silently treat it as passed.
|
||||
|
||||
Also verify the bundled PixelPass source is still exactly the intended clean
|
||||
commit and run its release diagnostics before packaging:
|
||||
|
||||
```sh
|
||||
git -C ../pixelpass status --short --branch
|
||||
git -C ../pixelpass rev-parse --short=8 HEAD
|
||||
cargo run --manifest-path ../pixelpass/Cargo.toml --locked -- --doctor
|
||||
```
|
||||
|
||||
### 5. Build the final AppImage
|
||||
|
||||
Build from the clean, committed PeerSpeak release source and clean PixelPass
|
||||
source in the Ubuntu 24.04 distrobox described in `packaging/appimage/README.md`.
|
||||
Keep build caches on `/mnt/superjar` and use one build job if space remains
|
||||
tight. A representative invocation is:
|
||||
|
||||
```sh
|
||||
distrobox enter peerspeak-appimage -- env \
|
||||
PATH="$HOME/.rustup/toolchains/1.97.1-x86_64-unknown-linux-gnu/bin:$PATH" \
|
||||
SYSTEM_DEPS_LIBSPA_INCLUDE=/run/host/usr/include/spa-0.2 \
|
||||
SYSTEM_DEPS_LIBPIPEWIRE_INCLUDE=/run/host/usr/include/pipewire-0.3:/run/host/usr/include/spa-0.2 \
|
||||
CARGO_BUILD_JOBS=1 \
|
||||
PEERSPEAK_APPIMAGE_CACHE=/mnt/superjar/peerspeak-appimage-cache \
|
||||
./packaging/appimage/build-appimage.sh
|
||||
```
|
||||
|
||||
The official output should be
|
||||
`packaging/appimage/peerspeak-0.6.7-x86_64.AppImage`. Do not overwrite the
|
||||
stamped 0.6.6 field-test artifact until the release is complete and verified.
|
||||
|
||||
### 6. Validate the exact final artifact
|
||||
|
||||
At minimum:
|
||||
|
||||
- record `sha256sum` and create a matching `.sha256` sidecar;
|
||||
- run `--appimage-extract` into an isolated scratch directory;
|
||||
- inspect `ldd` for both bundled `usr/bin/peerspeak` and `usr/bin/pixelpass` and
|
||||
require no `not found` entries;
|
||||
- inspect RUNPATH/RPATH and confirm the bundle has not captured the host's
|
||||
graphics, PulseAudio, or GStreamer stack contrary to the thin-AppImage policy;
|
||||
- run the bundled `pixelpass --capabilities` and confirm desktop-audio exclusion
|
||||
support is advertised;
|
||||
- run the bundled `pixelpass --doctor` with a fresh temporary GStreamer registry;
|
||||
- launch the AppImage with an isolated config/data directory for a GUI smoke;
|
||||
- verify the first/home screen reads
|
||||
`PeerSpeak v0.6.7 (<final PeerSpeak HEAD short hash>)`;
|
||||
- verify the same version/hash appears in Settings;
|
||||
- confirm the bundled PixelPass binary corresponds to `ce909afc` (or the exact
|
||||
newer commit deliberately selected tomorrow).
|
||||
|
||||
Because the final version/evidence commit changes the embedded hash relative to
|
||||
the current field-test artifact, do one short two-machine confirmation using
|
||||
the final AppImage: connect, share **All system audio**, confirm desktop sound
|
||||
and no voice echo/warning, stop the share, and leave cleanly.
|
||||
|
||||
### 7. Publication gate
|
||||
|
||||
Before any external write, present the final facts to the user:
|
||||
|
||||
- final PeerSpeak full and short commit;
|
||||
- bundled PixelPass full and short commit;
|
||||
- `v0.6.7` proposed tag target;
|
||||
- AppImage filename, size, and SHA-256;
|
||||
- title-screen version/hash observed;
|
||||
- source, artifact, and two-machine gate results;
|
||||
- exact release notes/assets to publish.
|
||||
|
||||
Then wait for explicit approval to publish.
|
||||
|
||||
After approval only:
|
||||
|
||||
1. Push `main` without force.
|
||||
2. Confirm remote `main` resolves to the tested final commit.
|
||||
3. Create an annotated `v0.6.7` tag on that exact commit and push it.
|
||||
4. Create the `v0.6.7` GitButter release page from the finalized changelog text.
|
||||
5. Upload `peerspeak-0.6.7-x86_64.AppImage` and its `.sha256` sidecar.
|
||||
6. Do not paste or store a GitButter token in the repository, documentation,
|
||||
shell history, or release artifacts.
|
||||
|
||||
The planned release scope is the Linux x86_64 AppImage only. Do not imply that
|
||||
a Windows installer, Debian package, or separate PixelPass release exists
|
||||
unless those artifacts are deliberately added and independently verified.
|
||||
|
||||
### 8. Verify the public release
|
||||
|
||||
Publication is not complete until it is independently read back:
|
||||
|
||||
- confirm GitButter shows the correct release title, tag, notes, and both assets;
|
||||
- confirm the remote tag and remote `main` point to the expected commit;
|
||||
- download the public AppImage to a fresh temporary path;
|
||||
- verify its SHA-256 against the published sidecar and the local final hash;
|
||||
- extract or run the downloaded copy and repeat the version/hash and bundled
|
||||
PixelPass diagnostic checks;
|
||||
- save the final release URL and verification result in the implementation plan
|
||||
or handoff.
|
||||
|
||||
## Failure and recovery rules
|
||||
|
||||
- A failed gate means no tag and no release—not a waiver.
|
||||
- Never force-push or move a published tag as an automatic recovery step.
|
||||
- If the tag is pushed but release creation/upload fails, stop and report the
|
||||
exact remote state before changing anything.
|
||||
- Keep failed or candidate artifacts clearly stamped so they cannot be mistaken
|
||||
for the final asset.
|
||||
- Remove only exact, rebuildable scratch/extraction directories when reclaiming
|
||||
disk space; preserve source, user changes, final artifacts, hashes, and field
|
||||
evidence.
|
||||
@@ -1,8 +1,36 @@
|
||||
# Implementation plan: whole-desktop screen-share audio without self-echo
|
||||
|
||||
**Status:** 🟢 **v4 — three review rounds applied. Approved to start Phase 0a.**
|
||||
**Date:** 2026-07-21
|
||||
**Design of record:** [`screenshare-audio-exclusion-plan.md`](screenshare-audio-exclusion-plan.md) v3.4 (`8768cd2`), converged round 7.
|
||||
**Status:** 🟢 **v4 — three review rounds applied.** *Progress as of 2026-08-22:* phases 0a, 0b,
|
||||
0c step 1, 1, 2, 3, 3r, 4 and 5 are merged, and the **phase-5 major gate PASSED on 2026-07-26**
|
||||
(§1). The two pre-Phase-6 decisions are now **closed** in design v3.8 §6.9: the conservative
|
||||
same-device hardware bridge is built and validated, and the 2 s readiness budget passed
|
||||
baseline, inflated-graph and live-churn calibration. The completed **0c step 2** was
|
||||
sliced S1–S5: S1 and S2 are
|
||||
merged; **S3a is merged locally** in pixelpass (`15d1374`); and **S3b is built, validated, and
|
||||
committed locally** (`5d3da8b`). **S4, S5, 0d, round 11's bridge changes, and Phase 6's first
|
||||
pure channel-planning prerequisite are built, validated, and committed locally in pixelpass
|
||||
(`781defc`).** The first bounded Phase 6 mutation slice is now built, validated and committed
|
||||
locally in PixelPass (`98cde2c`): the hidden production path creates, retains, revokes and
|
||||
crash-cleans exact-channel non-lingering fan-out links. The four versioned Phase 6 status events
|
||||
are built, validated and committed locally in PixelPass (`5a65f50`). Capture-sink replacement
|
||||
and successful exact-channel relinking are built, validated and committed locally in PixelPass
|
||||
(`d09ee9b`). **The Row 1 mutation-edge identity gate and the Row 8d/8e fail-closed
|
||||
construction gates are built, validated and committed locally in PixelPass (`6be07ef`).**
|
||||
**The independent Row 6a/6b/6c refusal gates are built, validated and committed locally in
|
||||
PixelPass (`956534f`).** **The revised Row 9 positive/negative late-arrival partition is built,
|
||||
validated and committed locally in PixelPass (`7b11827`).** The deterministic Phase 6
|
||||
link-manager matrix is complete. **The production-path three-arm AEC leak qualification is
|
||||
built, validated and committed locally in PixelPass (`e027bc6`), completing Phase 6.**
|
||||
**Phase 7's public selector, AEC wiring and versioned capability response are built, validated
|
||||
and committed locally in PixelPass (`792f2bd`).** **Phase 8's PeerSpeak capability negotiation,
|
||||
picker/argv integration and causal status surface are built, validated and committed locally
|
||||
(`2c2b861`).** **Phase 9's PN correlation/xrun rig is implemented and live-qualified locally in
|
||||
PixelPass (`98bb78f`, hardened by `360d711`). The two-direction release-prep field slice passed
|
||||
on cazen and dopedart with the exact AppImage described in plan §7; the broader variance rows
|
||||
listed there are not silently claimed by that slice. Nothing has been released, and Phase 9
|
||||
remains the current front and ship gate until those rows are dispositioned.**
|
||||
**Date:** 2026-07-21 (v4); status line refreshed 2026-08-22
|
||||
**Design of record:** [`screenshare-audio-exclusion-plan.md`](screenshare-audio-exclusion-plan.md) v3.8, round 11.
|
||||
**Scope:** *ordering, gates and acceptance criteria only.*
|
||||
|
||||
**Reference convention.** `v3.4 §N` = the design doc. `plan §N` = this document. The two
|
||||
@@ -100,13 +128,21 @@ if it differs, failing closed.
|
||||
1 (r8 carriers) ──────────────────────────────────────► 5 (re-run)
|
||||
```
|
||||
|
||||
⚠️ **Status 2026-07-25 (evening): 3r is BUILT AND MERGED; the re-run has not happened yet.**
|
||||
The phase-5 gate failed on its first live run and put 3r into the DAG; 3r's own four-part
|
||||
gate now passes, including the live prop-recovery row on this host. Phase 5's machinery is
|
||||
built and correct — it is the audit that found the defect, twice — so "5 (re-run)" is a
|
||||
*re-run of the matrix*, not a rebuild. **Phase 6 still does not start** until a passing
|
||||
results file exists. **Phase 1 is a hard prerequisite of the re-run for both carriers**
|
||||
(plan §3).
|
||||
✅ **Status 2026-07-26: the phase-5 gate PASSED on run 2 — all 13 §5.1 rows completed.**
|
||||
Record: [`screenshare-audio-exclusion-phase5-results.md`](screenshare-audio-exclusion-phase5-results.md)
|
||||
(audit build pixelpass `main` @ `91c4ded`, release profile). Phase 1 was the hard prerequisite
|
||||
of the re-run for both carriers (plan §3) and was satisfied — rows 4 and 5 passed at the real
|
||||
tagging sites. **Phase 6 is no longer blocked by this gate.** What still blocks it is the rest
|
||||
of the DAG: `0b → 6` is satisfied and merged; **0c and 0d are built, validated, and committed
|
||||
locally with S4/S5 and round 11 in pixelpass `781defc`**. **Round 11 now closes the two former
|
||||
design §6.8 blockers** with the v3.8 §6.9 hardware bridge and readiness calibration; Phase 6
|
||||
is unblocked and its first pure planning prerequisite has landed.
|
||||
|
||||
⚠️ **Superseded, kept for the trail — status 2026-07-25 (evening): "3r is BUILT AND MERGED; the
|
||||
re-run has not happened yet."** The phase-5 gate failed on its first live run and put 3r into
|
||||
the DAG; 3r's own four-part gate then passed, including the live prop-recovery row on this host.
|
||||
Phase 5's machinery was built and correct throughout — it is the audit that found the defect,
|
||||
twice — so "5 (re-run)" was a *re-run of the matrix*, not a rebuild. Run 2 is that re-run.
|
||||
|
||||
⚠️ **A smoke run of the audit against the fixed observer immediately found a second defect
|
||||
(design v3.6 §6.8): a fail-closed `unresolved-ancestry` mark was being promoted to permanent
|
||||
@@ -300,6 +336,18 @@ through four phases of active work around them. Guardrails go up before the scaf
|
||||
capture sink;
|
||||
- legacy behaviour byte-identical.
|
||||
|
||||
> ✅ **BUILT, VALIDATED, AND COMMITTED locally in pixelpass `781defc`, 2026-08-21.** The real hidden
|
||||
> `--internal-desktop-excluding` host input resolves to a typed `CapturePlan`; only
|
||||
> `LegacyDesktop` can construct `DefaultMonitor`, while `DesktopExcluding` owns a bare
|
||||
> connection-owned sink whose type has no legacy loopback API. The complete 16-row mode matrix
|
||||
> passes, both conflict inputs reject before graph mutation, and the exact legacy GStreamer
|
||||
> audio tail is unchanged. The serialized live graph assertion passed with neither an incoming
|
||||
> PipeWire link nor a Pulse module feeding the new sink; all four prior ownership/cleanup live
|
||||
> regressions also passed and left no PixelPass audio residue. Broad result: pixelpass **310
|
||||
> passed, 9 ignored**, fresh `--doctor` all green; peerspeak screen-share units **41 passed, 1
|
||||
> ignored**, plus the real Stop Share/SIGINT compatibility gate passed. This remains an internal
|
||||
> mode with no fan-out and no public selector; Phase 6 is still the first fan-out mutation.
|
||||
|
||||
A constructible-but-not-yet-public variant is acceptable for the interval between 0d and Phase
|
||||
6 provided it is unit-tested and reachable by the hidden trigger.
|
||||
|
||||
@@ -428,7 +476,8 @@ and 4 are unaffected, and the phase-5 audit machinery is already correct.
|
||||
> mutation-verified (a `device_props` ambiguity test that checked for one live *Device*
|
||||
> rather than one live *global*, and `device.api` corroborating by presence). Two findings
|
||||
> left open as design items, both pre-existing — hardware playback-to-capture paths and the
|
||||
> readiness-budget calibration, both recorded in design §6.8.
|
||||
> readiness-budget calibration. **Both are now closed by design v3.8 §6.9 / the pre-Phase-6
|
||||
> round-11 gate below.**
|
||||
>
|
||||
> **Added beyond the spec: a second live gate for the Device-side path.** Row 1's
|
||||
> `session_device` assertion is satisfied by a union, and WirePlumber 0.5.15 copies
|
||||
@@ -526,7 +575,9 @@ observable in Phase 5 before they gate anything real.
|
||||
> ⚠️ **Phase 6 is NOT unblocked by this file alone.** F11-1 was the other gate and is now
|
||||
> **closed** (2026-07-26, pixelpass `c78eb2d`: key 4 bounds an owner only when the node's
|
||||
> Client resolves; measured cost on the live graph, zero — see the results file). Phases
|
||||
> 0b/0c/0d and the "Stereo Mix" design call still precede phase 6.
|
||||
> 0b/0c/0d are satisfied and committed locally with S4/S5 in pixelpass `781defc`.
|
||||
> **Round 11 (2026-08-21) closed the remaining hardware playback-to-capture and readiness
|
||||
> gates; Phase 6 is now unblocked for development.**
|
||||
>
|
||||
> Two things to keep when re-running: **every partition row must run with `AEC=off`** (a
|
||||
> configured-but-unvalidated AEC shuts the fan-out gate and empties the eligible half of every
|
||||
@@ -567,7 +618,7 @@ exclude-everything implementation fails the eligible half of every row.
|
||||
| 6 | peerspeak **notification** sound | that node, reason = tag | — |
|
||||
| 7 | a **second** pixelpass host's capture sink, **plus a controlled forwarder reading that sink's monitor** | the forwarder's **named output serial** (cycle prevention, v3.4 §6.2) | — |
|
||||
| 8 | EasyEffects running | combined output leg | EasyEffects stopped ⇒ ordinary streams |
|
||||
| 9 | Firefox: music only / mic on untainted source / capturing a tainted monitor | the third only (v3.4 §6.1.1) | the first two |
|
||||
| 9 | Firefox: music only / mic on a **different Device** / mic on the **same Device receiving tainted playback** / capturing a tainted monitor | the third and fourth (`tainted-owner-bridge`) | the first two |
|
||||
| 10 | sticky taint: tainted input leg removed, output leg lives | still excluded | after full owner teardown + restart |
|
||||
| 11 | recycled serial/index/link-group after teardown | — | must **not** inherit taint |
|
||||
| 12 | AEC loaded, then unloaded | four nodes; then `Revoked` | — |
|
||||
@@ -597,8 +648,32 @@ assumptions, whereas Sunshine is an uncontrived third-party forwarder nobody des
|
||||
test. It stays as row 1b, **opportunistic and non-gating**, because it cannot be relied on to
|
||||
be present.
|
||||
|
||||
**Any surprise here goes back to the design doc as round 8. Phase 6 does not start until this
|
||||
results file exists.**
|
||||
**Any surprise here goes back to the design doc as a new measured round. Phase 6 did not start
|
||||
until this results file existed; round 11's targeted addendum now pins the same-device rule.**
|
||||
|
||||
### 5.4 Pre-Phase-6 round-11 closure — ✅ PASSED 2026-08-21
|
||||
|
||||
Design v3.8 §6.9 and the addendum in
|
||||
[`screenshare-audio-exclusion-phase5-results.md`](screenshare-audio-exclusion-phase5-results.md)
|
||||
are the durable evidence. PixelPass retains snapshot-local `device.id` on positively classified
|
||||
session-device nodes and adds a conservative `Sink → Source` taint edge only within that Device.
|
||||
No ALSA control-name guess is part of the runtime policy.
|
||||
|
||||
Exit gates:
|
||||
|
||||
- 83 focused taint tests pass, including same-device exclusion, different-device eligibility,
|
||||
and the accepted same-device-microphone over-exclusion.
|
||||
- 65 pure observer tests and all three serialized live PipeWire observer tests pass; a live
|
||||
passive device retains the `device.id` consumed by the engine.
|
||||
- Full non-GUI suite: 313 passed, 0 failed, 9 ignored. The three live observer tests were then
|
||||
run explicitly and passed.
|
||||
- Targeted live audit exact partition: tagged ALC897 playback excluded the same-ALC897
|
||||
capture/re-emitter as `tainted-owner-bridge`; the Arctis-source control remained eligible.
|
||||
- Readiness: baseline 30 starts p95/max 10/11 ms; 48-module graph 30 starts 113/114 ms;
|
||||
20 starts during 250 create/remove cycles 6/8 ms, zero timeouts. Keep the 2 s budget.
|
||||
|
||||
All temporary modules were unloaded by their exact module ids, configured audio defaults were
|
||||
unchanged, and root filesystem free space was 17 GiB after tests and Clippy.
|
||||
|
||||
---
|
||||
|
||||
@@ -611,6 +686,141 @@ retained for the life of the share, per-port link sets, "captured" only when **e
|
||||
link is `ACTIVE`, same-epoch revalidation immediately before each creation, proxy drop on
|
||||
ancestry becoming unsafe.
|
||||
|
||||
> **Status 2026-08-21 — first Phase 6 mutation slice built, validated and committed locally.** The
|
||||
> hidden `DesktopExcluding` production path now launches a same-observer-callback link manager.
|
||||
> It revalidates every recyclable id against its `object.serial` immediately before mutation,
|
||||
> creates exact FL/FR (or MONO fan-out) links through `link-factory`, retains the proxies, marks a
|
||||
> stream captured only when every required link is `ACTIVE`, and drops owned links before the
|
||||
> capture sink on normal teardown. The links explicitly set `object.linger=false`.
|
||||
>
|
||||
> Twelve deterministic controller tests cover channel planning, recycled ids, idempotence,
|
||||
> partial activation, late arrivals, newly unsafe ancestry, AEC gating/revocation and mutation
|
||||
> failure. The full non-GUI suite passes with 326 passed, 0 failed and 10 ignored, and strict
|
||||
> all-target Clippy is clean. The real hidden host path was also tested with a late stereo
|
||||
> stream: exactly two native links became `ACTIVE`, no Pulse `module-loopback` fed the capture
|
||||
> sink, and both links were revoked when the stream ended. A separate subprocess gate sent
|
||||
> PixelPass `SIGKILL`; the connection-owned capture sink and both link object serials disappeared
|
||||
> without Rust destructors. The three serialized live observer tests and `pixelpass --doctor`
|
||||
> pass.
|
||||
>
|
||||
> **Status-event slice, committed locally as `5a65f50`.** PixelPass now emits
|
||||
> exact version-1 `stream_unsupported`, `aec_failed`, `aec_revoked` and `foreign_aec_warning`
|
||||
> records. The PipeWire callback only enqueues owned records; a Tokio-side forwarder performs
|
||||
> JSON serialization and stdout I/O. Each cause has a direct emission test plus an exact JSON
|
||||
> golden. Repeated graph ticks do not repeat a sticky failure, every leg of one foreign AEC
|
||||
> collapses to one warning, an unindexed native AEC still counts as foreign, and `aec_revoked`
|
||||
> is queued only after owned links are dropped. Full PixelPass: 330 passed, 0 failed, 10 ignored;
|
||||
> strict all-target Clippy, both serialized Phase 6 live gates and `pixelpass --doctor` pass.
|
||||
>
|
||||
> **Capture-sink replacement slice, committed locally as `d09ee9b`.** The
|
||||
> connection owner now recreates an unexpectedly removed non-lingering sink under the stable
|
||||
> Pulse name but with a fresh exact serial. A narrow identity channel hands that serial to the
|
||||
> fan-out observer, whose own main-loop command immediately reconciles its coherent snapshot:
|
||||
> stale proxies are dropped before new links are created, and the stream returns to `Captured`
|
||||
> only after every replacement channel is `ACTIVE`. The observer treats only PipeWire's
|
||||
> asynchronous `-ENOENT` for a resource lost during ordinary graph churn as recoverable; every
|
||||
> other Core error remains fatal.
|
||||
>
|
||||
> The pure recovery gate proves both stale links are dropped and two replacement links activate.
|
||||
> A live owner-only gate destroys the sink and observes a fresh serial. The real hidden host gate
|
||||
> destroys an actively linked sink, observes a new sink serial and two fresh `ACTIVE` links,
|
||||
> proves both old link serials are absent, and leaves no residue. All three serialized Phase 6
|
||||
> live gates pass together. Full PixelPass: 332 passed, 0 failed, 12 ignored; strict all-target
|
||||
> Clippy, formatting, `git diff --check`, and `pixelpass --doctor` pass.
|
||||
>
|
||||
> This does **not** complete Phase 6. The remaining failure-matrix rows are still open. The public
|
||||
> mode selector remains Phase 7 and PeerSpeak parsing/UI integration remains Phase 8. The last
|
||||
> committed PixelPass checkpoint is `6be07ef`.
|
||||
>
|
||||
> **Row 1 plus Row 8d/8e slice — built, validated and committed locally as `6be07ef` on
|
||||
> 2026-08-21.** The production
|
||||
> link mutator now applies serial revalidation to retained proxies as well as new requests; a
|
||||
> node/global-id recycle between planning and mutation yields zero `create_link` calls and drops
|
||||
> any stale retained intent. `CapturePlan` has one injectable production construction seam, and
|
||||
> separate optimized-release gates prove both capture-sink construction failure and the
|
||||
> observer's sticky readiness timeout escape `DesktopExcluding` as errors without ever resolving
|
||||
> the default monitor or constructing legacy `Routing`.
|
||||
>
|
||||
> The full suite passes with 335 passed, 0 failed and 12 ignored. All three new gates pass in an
|
||||
> optimized release build; strict all-target Clippy, formatting and `git diff --check` pass. All
|
||||
> three serialized Phase 6 live audio-plan gates still pass, `pixelpass --doctor` passes, and the
|
||||
> final Pulse/PipeWire/process residue scan is empty. Nix remains unavailable, so validation used
|
||||
> system Rust 1.97.1. The disposable 1.0 GiB release cache was removed afterward, leaving 12 GiB
|
||||
> free. Next is the independent Row 6a/6b/6c refusal/reason slice; Phase 6 is not complete.
|
||||
|
||||
> **Row 6a/6b/6c slice — built, validated and committed locally as PixelPass `956534f` on
|
||||
> 2026-08-21.**
|
||||
> The bound Node observer now subscribes to the configured `SPA_PARAM_Format` only for
|
||||
> `Stream/Output/Audio` nodes that advertise it as readable, and classifies the native libspa
|
||||
> format as raw, encoded or IEC958. Missing/unparseable format evidence is a per-stream
|
||||
> fail-closed `format-unknown`, not permission to link. `node.passthrough` stays a separate
|
||||
> predicate, so it cannot be masked by the format classifier.
|
||||
>
|
||||
> Three independent controller fixtures prove Row 6a `port.exclusive`, Row 6b encoded and Row
|
||||
> 6c IEC958 each make **zero** link-creation attempts and emit exactly their own
|
||||
> `stream_unsupported` reason (`port-exclusive`, `encoded`, `iec958-passthrough`) once. A fourth
|
||||
> gate independently exercises explicit `node.passthrough`; another proves the ordinary
|
||||
> Node-info-before-Format ordering stays fail-closed without emitting a transient false warning,
|
||||
> then captures once raw PCM arrives. Row 6a remains the accepted injected-graph gate: v1 still
|
||||
> does not bind Ports, so a live exclusive port reaches the already-gated clean link-failure path.
|
||||
>
|
||||
> The first read-only live audit exposed and prevented two observer defects before completion:
|
||||
> generic Pod deserialization rejected the real Format object, and enumerating Format on every
|
||||
> driver Node produced expected ENOENT/EIO core errors. The corrected path uses libspa's native
|
||||
> format parser and the Node's advertised readable-param list. A second live audit observed
|
||||
> Strawberry, FFXIV and Chromium settle from `format-unknown` to eligible raw PCM within the
|
||||
> initial callback burst, with no parse or core errors. Full PixelPass validation is 342 passed,
|
||||
> 0 failed and 12 ignored; all three Row 6 gates pass in an optimized one-job release build;
|
||||
> strict all-target Clippy, formatting, `git diff --check` and `pixelpass --doctor` pass. The
|
||||
> 966 MiB disposable release cache was removed and disk space returned. Next is the revised Row
|
||||
> 9 positive/negative partition; Phase 6 is not complete.
|
||||
|
||||
> **Revised Row 9 slice — built, validated and committed locally as PixelPass `7b11827` on
|
||||
> 2026-08-21.**
|
||||
> The former single music-only late-arrival fixture is now four independent controller gates
|
||||
> carrying Phase 5's exact Firefox partition across the Phase 6 mutation boundary. Music-only
|
||||
> and a Firefox output whose microphone is on a different Device each create exactly two stereo
|
||||
> links and reach `Captured`. A Firefox output whose input reads the same Device receiving
|
||||
> tainted playback, and one reading that sink's tainted monitor, each retain
|
||||
> `tainted-owner-bridge`, make zero link-creation calls and hold no proxies. Every case is
|
||||
> re-driven unchanged to prove idempotence.
|
||||
>
|
||||
> Full PixelPass validation is 345 passed, 0 failed and 12 ignored; all four focused Row 9 gates,
|
||||
> strict all-target Clippy, formatting and `git diff --check` pass. The three serialized live
|
||||
> Phase 6 mutation gates — late eligible fan-out/cleanup, sink replacement/relink and SIGKILL
|
||||
> cleanup — pass, `pixelpass --doctor` passes, and the final Pulse/PipeWire/process residue scan
|
||||
> is empty. Nix is unavailable, so this slice used system Rust 1.96.1. The deterministic
|
||||
> link-manager matrix is now complete. Phase 6 remains open on its production-path three-arm AEC
|
||||
> leak measurement re-run, whose naive positive control must exist only behind a test seam.
|
||||
|
||||
> **Production-path three-arm AEC leak qualification — built, validated and committed locally as
|
||||
> PixelPass `e027bc6` on 2026-08-21. Phase 6 is complete.** A `#[cfg(test)]` policy can re-admit
|
||||
> only the exact configured `aec-identity` candidate; the unsafe policy, constructor and field do
|
||||
> not exist in a production build. A focused controller gate proves safe mode retains only the
|
||||
> ordinary stereo pair while the deliberately naive mode adds exactly the AEC playback pair.
|
||||
>
|
||||
> The ignored serialized live gate drives the hidden `DesktopExcluding` selector through the real
|
||||
> connection-owned sink, registry observer, taint controller, native link manager and
|
||||
> `<sink>.monitor` Pulse source. It loads one exact-ID WebRTC echo-cancel module per arm, filters
|
||||
> `media.class=Stream/Output/Audio` before matching `pulse.module.id`, retains and reports every
|
||||
> child stderr stream, verifies the exact graph links, and records 48 kHz stereo s16le with
|
||||
> `parec`. The guarded and naive arms inject a PeerSpeak-owned 1500 Hz stream into the real AEC
|
||||
> sink; all arms retain an ordinary 440 Hz desktop stream.
|
||||
>
|
||||
> Two complete runs passed the thresholds declared in the test. Desktop 440 Hz stayed between
|
||||
> -32.84 and -34.05 dBFS. The guarded arm's 1500 Hz result (-75.76 to -78.03 dBFS) never rose more
|
||||
> than 3 dB above its run's control floor, while the naive positive control measured -33.80 to
|
||||
> -33.92 dBFS, comparable to its desktop tone and at least 18 dB above both control and guarded.
|
||||
> The supported conclusion is deliberately narrow: **no incremental 1500 Hz energy was detectable
|
||||
> above the control floor at this analysis resolution**; this is not a claim that remote audio is
|
||||
> absent. Phase 9 still owns the PN/MLS intelligibility rig and field variance.
|
||||
>
|
||||
> Final PixelPass validation: 346 passed, 0 failed and 13 ignored; strict all-target Clippy,
|
||||
> formatting, `git diff --check`, `pixelpass --doctor`, and all four serialized Phase 6 live audio
|
||||
> gates pass. The final Pulse/PipeWire/process/temp-file residue scan is empty. Nix is unavailable,
|
||||
> so validation used system Rust 1.96.1. The next implementation front is Phase 7's public mode
|
||||
> selector and versioned capability advertisement.
|
||||
|
||||
Failure ⇒ report the stream unsupported. **Never** fall back to the default monitor — and after
|
||||
0d that fallback is unconstructible in this mode, by either path.
|
||||
|
||||
@@ -686,8 +896,9 @@ shippable runtime override.
|
||||
### Phase 7 — public mode selector + capability advertisement (pixelpass ships first)
|
||||
|
||||
⚠️ **Round-3 P1: nothing in v3 ever promoted the hidden trigger to a public flag.** 0d added an
|
||||
internal mode input; Phase 7 advertised capability and naming; Phase 8 added `--aec`, the picker
|
||||
and status. No phase required the actual **mode selector** to exist publicly or to be passed.
|
||||
internal mode input; Phase 7 advertised capability and naming; Phase 8 added PeerSpeak's
|
||||
`--aec` emission, the picker and status. No phase required the actual **mode selector** to exist
|
||||
publicly or to be passed.
|
||||
The result would be a capability-gated picker entry that, when chosen, still spawns legacy
|
||||
whole-desktop capture — the feature appearing to ship while doing nothing. Reachable: peerspeak's
|
||||
host argv has no mode parameter (`screenshare/mod.rs:152`) and pixelpass's `HostOpts` has no mode
|
||||
@@ -706,6 +917,39 @@ byte-identical, absent `--aec` still accepted.
|
||||
v3.4 §11 public naming is a **blocking user input at the start of this phase**. Internal typed
|
||||
variant names (0d) do not block on it.
|
||||
|
||||
> **Phase 7 — built, validated and committed locally as PixelPass `792f2bd` on 2026-08-21.** The
|
||||
> user selected `--audio-mode=desktop-shared|desktop-excluding`. The shared value resolves to the
|
||||
> byte-identical legacy plan; the excluding value reaches the existing typed
|
||||
> `DesktopExcluding` plan and requires an explicit `--aec=off|pulse-module:<idx>`. The exact
|
||||
> Phase-4 parser is now the public CLI parser, malformed values fail before host startup, and the
|
||||
> selected AEC config is carried through `CapturePlan` into the production graph controller
|
||||
> instead of being hardcoded to `Off`. Both protocol flags require `--host`; the hidden 0d
|
||||
> trigger remains test-only and defaults to `Off` for its existing fixtures.
|
||||
>
|
||||
> `pixelpass --capabilities` now emits exactly one versioned JSON line:
|
||||
> `{"schema_version":1,"capabilities":{"strict_app_audio":true,"desktop_audio_exclusion":true}}`.
|
||||
> The two booleans are independent by construction. The old-PeerSpeak/new-PixelPass golden feeds
|
||||
> both existing whole-desktop and strict per-app argv into the new parser unchanged and proves
|
||||
> absent `--audio-mode`/`--aec` still resolves to legacy behavior. The existing byte-exact legacy
|
||||
> GStreamer audio-tail gate remains green; `--help` probing is retained only for old integrations.
|
||||
>
|
||||
> Phase-7 validation also made the Phase-6 signal gate consume the selector-owned AEC config. Its
|
||||
> first rerun caught the stale test-local input immediately. Two later reruns exposed a separate
|
||||
> measurement issue: a coherent projection of sub-LSB stochastic noise can land in an unusually
|
||||
> deep single-bin null, so comparing only control-bin to guarded-bin overstated the rig's
|
||||
> resolution. Retained raw spectra showed no coherent guarded 1500 Hz peak and comparable nearby
|
||||
> noise. The committed gate now defines the control resolution as the larger of the exact control
|
||||
> bin and the 90th percentile of neighboring ±5–25 Hz projections outside the Hann main lobe;
|
||||
> the 3 dB guarded tolerance and 18 dB naive positive-control margin remain unchanged. This is a
|
||||
> resolution estimate for the steady-state gross-leak gate, not the Phase-9 PN/MLS upgrade.
|
||||
>
|
||||
> Final PixelPass validation: 354 passed, 0 failed and 13 ignored; strict all-target Clippy,
|
||||
> formatting, `git diff --check`, the exact capability/help probes, `pixelpass --doctor`, and all
|
||||
> four serialized live audio gates pass. The final Pulse/PipeWire/process/temp-file residue scan
|
||||
> is empty. Nix is unavailable, so validation used system Rust 1.96.1. Phase 8 is next: bind the
|
||||
> capability to the resolved PixelPass path, add the capability-gated picker/argv, and carry
|
||||
> exclusion status causally to the UI.
|
||||
|
||||
### Phase 8 — peerspeak integration
|
||||
- `EchoCancelGuard::module_index()` accessor (currently private; only `source_name()` /
|
||||
`sink_name()` exist).
|
||||
@@ -729,14 +973,73 @@ variant names (0d) do not block on it.
|
||||
- Capability-gated picker entry; wording per v3.4 §11.
|
||||
- Regression: existing `--app` / `--strict-audio` argv byte-identical to today.
|
||||
|
||||
> **Phase 8 — built, validated and committed locally as PeerSpeak `2c2b861` on 2026-08-21.**
|
||||
> The picker/core boundary now uses one typed selection for legacy desktop, desktop-excluding,
|
||||
> and strict per-app audio. `System audio except PeerSpeak` appears only when the exact resolved
|
||||
> PixelPass advertises `desktop_audio_exclusion`; the existing `All system audio` choice remains
|
||||
> alongside it with its echo warning. The machine-readable schema is primary, `--help` is only
|
||||
> the old-PixelPass strict-app fallback, and the two capability bits stay independent.
|
||||
>
|
||||
> Capability results carry the resolved executable path. A capability-gated share reuses the
|
||||
> picker probe only for that same path; a changed override or `$PATH` resolution is re-probed at
|
||||
> start and fails closed when the selected feature is absent. The new mode emits exactly
|
||||
> `--audio-mode=desktop-excluding` plus `--aec=off|pulse-module:<idx>`. The session-owned
|
||||
> `EchoCancelGuard` exposes its verified numeric module index without moving the guard out of the
|
||||
> load-bearing teardown object. Legacy desktop and per-app argv still delegate to the old builder
|
||||
> and are byte-identical.
|
||||
>
|
||||
> All four version-1 exclusion events parse into one shared status type that crosses the actual
|
||||
> host-notice channel into `UiEvent`; there is no duplicate matching enum. The UI retains an
|
||||
> explanatory warning even when a status races the share-start acknowledgement, displays it with
|
||||
> the live sharing badge, clears it on stop, and ignores late events for another mode. Exact
|
||||
> parser and causal tests cover `stream_unsupported`, `aec_failed`, `aec_revoked`, and
|
||||
> `foreign_aec_warning`.
|
||||
>
|
||||
> Final serialized validation (used after the parallel all-target link exhausted disk) passes:
|
||||
> 649 unit tests with 7 live-only ignored, 20 integration tests with 4 live-only screen-share
|
||||
> gates ignored, strict all-target Clippy, formatting, and `git diff --check`. The host-fault
|
||||
> integration target compiles with the typed command. PixelPass's completed 9.1 GiB target tree
|
||||
> was cleaned to recover disk; no source or Git state was removed. Phase 9 remains the explicit
|
||||
> rig-upgrade and two-machine field-test ship gate.
|
||||
|
||||
---
|
||||
|
||||
## 7. Phase 9 — rig upgrade and field tests 🚦 SHIP GATE
|
||||
|
||||
v3.4 §9.2's rig upgrade is **owed before any exclusion claim is published**: two orthogonal
|
||||
PN/MLS probes, windowed per-channel normalised cross-correlation reporting max per-window
|
||||
correlation, plus xrun telemetry. Until it exists the only defensible claim is the gross-leak
|
||||
distinction, in v3.4 §9.2's exact wording.
|
||||
correlation, plus xrun telemetry. Until it is live-qualified the only defensible claim is the
|
||||
gross-leak distinction, in v3.4 §9.2's exact wording.
|
||||
|
||||
> **Rig implementation checkpoint — PixelPass `98bb78f`, 2026-08-21.** Two pinned independent
|
||||
> PN probes now drive the same production-path control/guarded/naive topology used by the Phase 6
|
||||
> qualification. The rig downsamples each captured stereo channel to chips, reports normalized
|
||||
> maximum cyclic correlation for every non-overlapping 1,024-chip window, verifies the eligible
|
||||
> probe in every window/channel, bounds the absent/excluded probe, and proves detectability with
|
||||
> the naive positive control. It also snapshots `pw-top` ERR counters for the involved nodes,
|
||||
> checks original playback and exact capture routes after recording, and retains the existing
|
||||
> teardown/residue and audio-health gates.
|
||||
>
|
||||
> Thresholds are fixed in the ignored live test before any arm runs: present correlation ≥ 0.25,
|
||||
> excluded correlation ≤ 0.20, and zero new xruns.
|
||||
>
|
||||
> **Controlled live qualification PASSED on 2026-08-22; harness hardening committed as PixelPass
|
||||
> `360d711`.** The first two safe attempts exposed assumptions in the new rig before they could
|
||||
> hide field variance: PipeWire returned five matching IDs for four requested node names, proving
|
||||
> again that `node.name` is not unique, and cold `parec` startup yielded too little audio for three
|
||||
> windows. The resolver now requires every requested name while monitoring all matching IDs, with
|
||||
> a duplicate/missing-name regression test. Phase 9 alone records for six seconds from twelve-second
|
||||
> probes; the proven Phase 6 recorder remains at three seconds and its behavior is unchanged.
|
||||
>
|
||||
> The final serialized run produced seven complete windows per channel in every arm. The absent
|
||||
> remote-probe maximum was 0.1356 in control and 0.1340 when guarded, both below 0.20. The naive
|
||||
> positive-control remote minimum was 0.5741, and the eligible desktop minimum across all arms was
|
||||
> 0.6429, both above 0.25. Every arm added zero xruns. Original playback and exact capture routes
|
||||
> remained intact, and the final module/sink/link/process/temp-file residue scan was empty. Full
|
||||
> validation passes 356 non-live tests with 14 live-only ignored, strict all-target Clippy,
|
||||
> formatting, diff checks, and `pixelpass --doctor`. Nix was unavailable, so the run used system
|
||||
> Rust 1.96.1 with one build job. **The rig upgrade is qualified; the two-machine/real-path matrix
|
||||
> below remains open and keeps the Phase 9 ship gate closed.**
|
||||
|
||||
**Every row gets a declared pass/fail threshold before the run, not after.** Baseline for all
|
||||
rows: excluded probe ≤ the declared rig criterion; **eligible control audio present**; original
|
||||
@@ -772,6 +1075,40 @@ The reachable newly-created mid-share taint roots are: **a notification sound pl
|
||||
mpv/VLC (`screenshare/mod.rs:768-775`). Those are the transition-window field tests. Owned-AEC
|
||||
mid-share load stays a **synthetic** test until a second `enable` site or hot reload arms it.
|
||||
|
||||
### 7.1 Two-machine release-prep slice — PASSED 2026-08-22
|
||||
|
||||
The exact artifact was the thin AppImage built from PeerSpeak `0823f617` and bundled PixelPass
|
||||
`ce909afc`, SHA-256
|
||||
`f094a665dbd719929d6def32c8fd3d4943e4b88b6290e371e57b127c4e91ec45`. Application Manager
|
||||
installed the cazen copy as `/home/mollusk/Applications/PeerSpeak`; the byte-identical artifact
|
||||
was copied to dopedart and its checksum was verified before launch.
|
||||
|
||||
Both directions passed the declared release-prep checks:
|
||||
|
||||
- **cazen host → dopedart viewer:** the PeerSpeak host launched PixelPass with
|
||||
`--audio-mode=desktop-excluding --aec=pulse-module:536870916`; GStreamer used hardware
|
||||
`vah264enc`. Strawberry desktop music was heard clearly on dopedart while speech from the call
|
||||
was not heard through the share. No exclusion warning appeared on either machine; a supplied
|
||||
screenshot of the live participant/share surface also showed no warning.
|
||||
- **dopedart host → cazen viewer:** the host used
|
||||
`--audio-mode=desktop-excluding --aec=off --no-hwencode`; GStreamer used software `x264enc`.
|
||||
Desktop audio was heard, neither call voice echoed, no warning appeared, and the explicit
|
||||
all-checks confirmation included the single **All system audio** picker choice.
|
||||
- **Stop Share while the call remained live:** cazen's host PixelPass, GStreamer process and
|
||||
`pixelpass_capture_*` sink disappeared; dopedart's viewer PixelPass and mpv disappeared. Both
|
||||
PeerSpeak processes and cazen's call AEC remained, proving share teardown did not tear down the
|
||||
call.
|
||||
- **Host leaves while sharing:** dopedart's PixelPass, software GStreamer process and capture
|
||||
sink disappeared; cazen's viewer PixelPass and mpv disappeared; both PeerSpeak applications
|
||||
remained open in their expected call/home states.
|
||||
- **Final room leave:** process, Pulse module/sink/source, and `pw-dump` scans on both machines
|
||||
found no PixelPass capture object, PeerSpeak AEC/call node, viewer, player, or GStreamer residue.
|
||||
|
||||
This closes the two-direction acceptance matrix in `docs/release-0.6.7-prep.md`. It does **not**
|
||||
retroactively claim unrun rows from the full matrix above, including simultaneous host+viewer,
|
||||
EasyEffects, device switching, daemon restart, and real-sink format/CPU variance. Those rows must
|
||||
either be run, satisfied by named existing evidence, or explicitly re-scoped before publication.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions — final status
|
||||
@@ -1027,8 +1364,23 @@ light (the `abort()` orphan race, the unbounded join, peerspeak advertising a de
|
||||
| S1 | repair planner (`919d5bd` + `9145b2a`) | done; awaiting re-review, then merge |
|
||||
| S2 | peerspeak host-fault path: always-on notice channel, EOF synthesis, session-scoped fault, clear `is_sharing` + presence ticket, `ScreenShareStopped` then error | fixes a defect **today** — a dead share stays advertised — and is independent of the actor |
|
||||
| S3 | pixelpass ledger transactions + ambiguous-load reconciliation + child containment + pre-armed watchdog + poison state machine + supervisor health arm | fixes the `abort()` orphan race **today**; no libpipewire work |
|
||||
| S4 | the `AudioGraphOwner` actor itself, the readiness handshake, and both measured budgets | the only slice that needs new PipeWire mechanism |
|
||||
| S5 | the two live exit gates: two-host ownership, and the never-yet-run Stop Share SIGINT gate | needs S4 on the graph |
|
||||
| S4 | **built, validated, and committed locally in pixelpass `781defc` (2026-08-20):** the `AudioGraphOwner` actor itself, the readiness handshake, and both measured budgets | the only slice that needs new PipeWire mechanism |
|
||||
| S5 | **built, validated, and committed locally in pixelpass `781defc` (2026-08-21):** the two live exit gates: two-host ownership/repair and Stop Share SIGINT | needs S4 on the graph |
|
||||
|
||||
**S5 live evidence (2026-08-21).** Pixelpass's ignored
|
||||
`live_two_host_sigkill_and_repair_preserve_the_survivor` gate starts two independent routing
|
||||
owners, observes a distinct native sink and ownership-tagged loopback for each, SIGKILLs the
|
||||
first, and proves only its sink disappears. `--repair` then removes exactly the dead host's
|
||||
loopback while explicitly leaving the second live host alone. The survivor exits through a real
|
||||
SIGINT with its active graph teardown measured at 40 ms, inside peerspeak's 2 s grace, and leaves
|
||||
no sink or module residue. Peerspeak's separate
|
||||
`stop_share_ends_the_real_host_via_sigint_within_the_grace` end-to-end gate also passed against
|
||||
the freshly built pixelpass binary, proving Stop Share drives that signal path before fallback.
|
||||
The pre-gate cold review also found and fixed an S4 unwind regression: the actor's emergency
|
||||
`Stop` path had quit without restoring still-owned `target.object` values. `Stop` now performs
|
||||
the same ownership-checked restoration and waits for a PipeWire core round-trip before closing
|
||||
the connection, so constructor cancellation or unwind cannot knowingly strand an app on the
|
||||
disappearing sink.
|
||||
|
||||
**Round 18 (2026-07-26 night) — two more repair review rounds. `--repair` now reads and unloads
|
||||
through libpulse, and one of the review's own prescriptions had to be replaced after measuring.**
|
||||
|
||||
@@ -17,6 +17,62 @@ was closed with this data on 2026-07-26 (see "What still blocks phase 6").
|
||||
|
||||
---
|
||||
|
||||
## Addendum — pre-Phase-6 hardware/readiness gate (2026-08-21)
|
||||
|
||||
This addendum does not rewrite the historical 2026-07-26 matrix. Design v3.8 §6.9 adds one
|
||||
conservative edge the old engine did not have: tainted playback into a positively classified
|
||||
hardware sink taints passive capture nodes carrying the same snapshot-local `device.id`. It
|
||||
also closes the readiness-budget calibration that round 9 left open.
|
||||
|
||||
### Hardware path — targeted live exact partition
|
||||
|
||||
The audit started first and reached readiness. Controlled modules then created three named
|
||||
candidates:
|
||||
|
||||
| candidate | expected | observed |
|
||||
| --- | --- | --- |
|
||||
| tagged playback into ALC897 | excluded root | `peerspeak-owned` |
|
||||
| capture/re-emitter reading the ALC897 source | excluded through hidden same-device hop | `tainted-owner-bridge` |
|
||||
| identical capture/re-emitter reading the Arctis source | eligible; different Device | eligible, no reason |
|
||||
|
||||
The settled record was `graph_ready=true`, epoch `complete`. This revises row 9 for every
|
||||
future full matrix: music-only and a microphone on a **different Device** remain eligible;
|
||||
a microphone on the **same Device receiving tainted playback** and a tainted-monitor capture
|
||||
are excluded. This is deliberate fail-closed over-exclusion because a private hardware or
|
||||
firmware loopback is not observable as a PipeWire Link.
|
||||
|
||||
The host's ALC897 had no `Stereo Mix` capture-source item: `Input Source` offered Rear Mic,
|
||||
Front Mic and Line. Its separate `Loopback Mixing` control was disabled. Runtime safety does
|
||||
not depend on either spelling; USB/vendor loopbacks need the same rule.
|
||||
|
||||
### Readiness calibration — retain the 2 s sticky deadline
|
||||
|
||||
Each measurement used a fresh observer process and its emitted monotonic `at_ms` readiness
|
||||
timestamp:
|
||||
|
||||
| arm | runs | min | p50 | p95 | max | timed out |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| ambient live graph | 30 | 4 ms | 5 ms | 10 ms | 11 ms | 0 |
|
||||
| 24 temporary null sinks + 24 loopbacks | 30 | 109 ms | 111 ms | 113 ms | 114 ms | 0 |
|
||||
| 250 null-sink create/remove cycles concurrent with starts | 20 | 4 ms | 5 ms | 6 ms | 8 ms | 0 |
|
||||
|
||||
The inflated maximum is 17.5x below the 2 s deadline. All temporary modules were unloaded by
|
||||
the exact ids returned from `pactl`; no test modules remained, and the configured default
|
||||
sink/source were unchanged.
|
||||
|
||||
### Regression gates
|
||||
|
||||
- focused taint engine: 83 passed;
|
||||
- observer: 65 pure passed, then all 3 serialized live PipeWire tests passed;
|
||||
- complete non-GUI PixelPass suite: 313 passed, 0 failed, 9 ignored;
|
||||
- disk before the first build: 20 GiB free; after tests and Clippy: 17 GiB free.
|
||||
|
||||
**Verdict:** both former pre-Phase-6 design blockers are closed. S4/S5/0d, round 11, and the
|
||||
first pure Phase 6 channel-planning prerequisite are committed locally in PixelPass `781defc`;
|
||||
the first fan-out mutation remains the current work.
|
||||
|
||||
---
|
||||
|
||||
## What changed since run 1
|
||||
|
||||
Run 1 failed on two defects, both fixed before this run:
|
||||
@@ -276,10 +332,11 @@ orders of magnitude of remaining headroom, not one.
|
||||
teardown in bursts, and a 32 µs recompute drains a burst faster than it forms.
|
||||
`busy_fraction` is the number to trust.
|
||||
|
||||
⚠️ **The readiness budget still has no calibration argument.** 1–2 ms against
|
||||
2000 ms is three orders of magnitude of slack on *this* host with 18 binds; it is
|
||||
not an argument about a host with a large USB interface, many virtual devices, or
|
||||
a cold cache. Carried forward as open, unchanged.
|
||||
⚠️ **Historical run-2 finding, closed by the 2026-08-21 addendum above:** 1–2 ms
|
||||
against 2000 ms was three orders of magnitude of slack on *this* host with 18
|
||||
binds, but not an argument about enumeration volume or instability. The addendum
|
||||
adds a 48-module graph and concurrent create/remove churn and retains the 2 s
|
||||
budget from that evidence.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
# Design v3: whole-desktop screen-share audio without self-echo
|
||||
# Design v3.8: whole-desktop screen-share audio without self-echo
|
||||
|
||||
**Status:** 🟢 **v3.7 — round 10: the §5.1 matrix PASSED in full and the architecture is
|
||||
unchanged for the third consecutive measured round.** Round 8 revised the **observation
|
||||
boundary** (§6.7), round 9 revised what stickiness may remember (§6.8), and round 10 deletes
|
||||
the pipewire-pulse PID **derivation heuristic** (§6.1.2) after measuring that WirePlumber
|
||||
repeats a `sec_pid` too — which had switched key 4's suppression off permanently. All three
|
||||
were found by running code, not by reading it, and all three were at the *observation*
|
||||
boundary rather than in the design.
|
||||
**Date:** 2026-07-26 (v1: 07-19 · v2: 07-20 · Option C 07-20 · v3.1 r4 · v3.2 r5 · v3.3 r6 ·
|
||||
v3.4 r7 · v3.5 r8 · v3.6 r9 · v3.7 r10)
|
||||
**Status:** 🟢 **v3.8 — round 11: both pre-Phase-6 design gates are closed.** The graph now
|
||||
models an unobservable playback-to-capture route as a conservative same-`device.id` hardware
|
||||
edge (§6.9), and the 2 s readiness budget is retained after repeated baseline, inflated-graph,
|
||||
and live-churn calibration. The targeted live dry-run partition passed. The first pure Phase 6
|
||||
channel-planning prerequisite is committed locally in PixelPass `781defc`; the first bounded
|
||||
live fan-out mutation slice is built, validated and committed locally in PixelPass `98cde2c`.
|
||||
The four versioned causal status events are built, validated and committed locally in PixelPass
|
||||
`5a65f50`. Capture-sink replacement and successful all-channel relinking are built, validated
|
||||
and committed locally in PixelPass `d09ee9b`. The Row 1 mutation-edge identity gate and Row
|
||||
8d/8e fail-closed construction gates are built, validated and committed locally in PixelPass
|
||||
`6be07ef`. The independent Row 6 refusal gates (`956534f`) and revised Row 9 partition
|
||||
(`7b11827`) complete the deterministic link-manager matrix. The production-path three-arm AEC
|
||||
leak qualification is built, validated and committed locally in PixelPass `e027bc6`; **Phase 6
|
||||
is complete.** The public selector, AEC wiring and versioned capability response are built,
|
||||
validated and committed locally in PixelPass `792f2bd`; **Phase 7 is complete.** PeerSpeak
|
||||
`2c2b861` completes the capability-bound picker/argv and causal UI status integration;
|
||||
**Phase 8 is complete and Phase 9 is now the ship gate.**
|
||||
**Date:** 2026-08-21 (v1: 07-19 · v2: 07-20 · Option C 07-20 · v3.1 r4 · v3.2 r5 · v3.3 r6 ·
|
||||
v3.4 r7 · v3.5 r8 · v3.6 r9 · v3.7 r10 · v3.8 r11 2026-08-21)
|
||||
**Origin:** Joe's suggestion — "whitelist all audio except audio coming from peerspeak."
|
||||
**Scope:** a new capture mode in pixelpass (`src/host/pipeline.rs`, `src/host/audio.rs`),
|
||||
playback tagging + AEC-identity export + teardown-ordering invariants in peerspeak.
|
||||
@@ -29,6 +39,7 @@ v1/v2 remain in git history at `88ad5a0` and `10203e1`.
|
||||
| fan-out spike | `~/Documents/handoff-docs/Claude/peerspeak/fanout-spike-results-2026-07-20.md` + Codex rounds 3/4 | **Option C adopted**, ratified |
|
||||
| AEC identity gate | `~/Documents/handoff-docs/Claude/peerspeak/aec-playback-leg-identity-2026-07-20.md` | **🟢 gate passed**, both models agree after 2 adversarial rounds |
|
||||
| **phase 5 dry-run gate (r8)** | `docs/screenshare-audio-exclusion-phase5-results.md` | **🚦 GATE FAILED** — the observation boundary is wrong (§6.7); architecture unaffected |
|
||||
| **pre-Phase-6 closure (r11)** | `docs/screenshare-audio-exclusion-phase5-results.md` addendum | **PASSED** — same-device bridge, different-device negative control, and readiness calibration (§6.9) |
|
||||
|
||||
---
|
||||
|
||||
@@ -907,21 +918,74 @@ through a teardown, and it is unaffected by this change.
|
||||
Cost: two fixpoints per graph event. Measured 80 µs worst case against a 47 Hz event rate,
|
||||
so the O5 headroom absorbs it without argument.
|
||||
|
||||
⚠️ **Owed, from the round-9 review (Codex, P1 "worth checking"): hardware
|
||||
playback-to-capture paths.** A card offering "Stereo Mix" / "Digital Loopback" presents an
|
||||
⚠️ **Round-9 open item — CLOSED in round 11 (§6.9): hardware playback-to-capture
|
||||
paths.** A card offering "Stereo Mix" / "Digital Loopback" presents an
|
||||
ordinary driver name (`snd_hda_intel`), so both its sink and its source classify
|
||||
`session_device` — and audio written to the sink reappears on the source through a hop the
|
||||
Link graph cannot see. This is the `snd_aloop` hazard (§6.1.1, phase-3 review finding 2) in
|
||||
a form the driver denylist cannot detect. It is **not new in round 9** and not introduced by
|
||||
either recent round; distinguishing it needs ALSA control inspection, a new I/O surface and
|
||||
therefore a design decision. Until then a card with that path enabled can carry the call
|
||||
from sink to source untainted, and a capture app reading it can re-emit: **echo**.
|
||||
either recent round. Round 11 closes it without relying on a driver denylist or control name.
|
||||
|
||||
⚠️ **Also owed: a calibration argument for the readiness budget.** The observer times out
|
||||
⚠️ **Round-9 open item — CLOSED in round 11 (§6.9): calibration of the readiness
|
||||
budget.** The observer times out
|
||||
after 2 s and `TimedOut` is sticky by design, so a process that never sees one
|
||||
obligation-free instant during initial enumeration is silent for its lifetime. Measured on
|
||||
this host: readiness at ~3 ms with 19 binds. The margin is three orders of magnitude, which
|
||||
is an argument, but it is one measurement on one idle desktop.
|
||||
obligation-free instant during initial enumeration is silent for its lifetime. The original
|
||||
~3 ms observation on one idle desktop was not enough; round 11 adds repeated starts, an
|
||||
inflated graph, and concurrent graph churn.
|
||||
|
||||
### 6.9 🟢 Same-device hardware bridge + readiness calibration (round 11, MEASURED)
|
||||
|
||||
**The hardware rule.** For every positively classified passive hardware terminal, retain the
|
||||
Node's snapshot-local `device.id`. The taint walk adds a directed synthetic edge from an
|
||||
`Audio/Sink` (or output side of `Audio/Duplex`) to every `Audio/Source` (or input side of
|
||||
`Audio/Duplex`) carrying the **same** `device.id`:
|
||||
|
||||
```
|
||||
tainted stream -> hardware sink ~[private mixer / firmware]~> same-device source -> reader
|
||||
```
|
||||
|
||||
Both predicates are load-bearing. `session_device=true` limits the rule to the observer's
|
||||
positive passive-hardware allowlist; `device.id` limits it to one physical Device instead of
|
||||
fusing every card exported by WirePlumber. The id never enters sticky identity and never
|
||||
survives its snapshot.
|
||||
|
||||
**Why this is unconditional rather than an ALSA-control probe.** Measured 2026-08-21: the
|
||||
ALC897 exposes `Loopback Mixing` (disabled) and two `Input Source` controls containing Rear
|
||||
Mic, Front Mic and Line, but no `Stereo Mix`. Linux HDA treats analog loopback monitoring and
|
||||
the optional `Stereo Mix` capture source as distinct mechanisms (kernel
|
||||
[`hda_generic.c`](https://code.googlesource.com/linux/torvalds/linux/+/master/sound/pci/hda/hda_generic.c)
|
||||
and [HDA control documentation](https://cdn.kernel.org/doc/html/latest/sound/hd-audio/controls.html)).
|
||||
More importantly, ALSA/HDA
|
||||
control spelling cannot prove the absence of USB, vendor-DSP or firmware loopback paths. A
|
||||
control-name allowlist would therefore be precise on this card and unsound as a portable
|
||||
absence proof. The graph rule closes every such hidden same-device hop without a new runtime
|
||||
ALSA dependency.
|
||||
|
||||
**Accepted cost.** An app capturing a microphone from the same Device that is receiving
|
||||
tainted playback is excluded even when that particular microphone path is clean. A source on
|
||||
a different Device remains eligible. This is deliberate fail-closed over-exclusion, pinned by
|
||||
pure exact-partition tests and a live dry-run negative control.
|
||||
|
||||
**Live partition, 2026-08-21.** Tagged playback was routed to the ALC897 sink. A controlled
|
||||
reader/re-emitter on the ALC897 source was excluded `tainted-owner-bridge`; the identical
|
||||
reader/re-emitter on the Arctis source remained eligible. The audit was graph-ready, all
|
||||
temporary modules were unloaded by exact module id, and the configured default sink/source
|
||||
were unchanged.
|
||||
|
||||
**Readiness budget — keep 2 s for v1.** Fresh observer startup measurements on the same live
|
||||
desktop, using the observer's own monotonic `at_ms` clock:
|
||||
|
||||
| arm | runs | p50 | p95 | max | timeout |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| ambient graph | 30 | 5 ms | 10 ms | 11 ms | 0 |
|
||||
| inflated graph: 24 null sinks + 24 loopbacks | 30 | 111 ms | 113 ms | 114 ms | 0 |
|
||||
| 250 create/remove cycles concurrent with fresh starts | 20 | 5 ms | 6 ms | 8 ms | 0 |
|
||||
|
||||
The deliberately inflated maximum leaves 17.5x headroom to the sticky 2 s deadline. This is
|
||||
not a universal latency promise; it is a calibration argument that exercises enumeration
|
||||
volume and graph instability, rather than extrapolating from one idle start. Revisit the
|
||||
budget if a supported target measures startup p95 above 500 ms or produces a real timeout;
|
||||
do not weaken `TimedOut`'s fail-closed/sticky semantics to hide one.
|
||||
|
||||
## 7. Lifecycle and teardown invariants
|
||||
|
||||
@@ -1227,6 +1291,12 @@ requirement to assert what must remain **eligible**: §6.1.2's pulse-PID derivat
|
||||
Three rows passed with recorded substitutions (8 EasyEffects, 9 Firefox's own mic/monitor
|
||||
paths, 13 a real `Audio/Duplex` device) and the third-party samples stay owed.
|
||||
|
||||
**Round-11 supersession (2026-08-21):** the row above is the round-10 snapshot, not current
|
||||
status. F11-1 and phases 0b/0c/0d are satisfied, and §6.9 closes the hardware-path decision
|
||||
plus readiness calibration. S4/S5/0d, round 11, and Phase 6's first pure channel planner are
|
||||
committed locally in PixelPass `781defc`; the first live fan-out mutation is committed locally
|
||||
in `98cde2c`.
|
||||
|
||||
**Round 9 (2026-07-25).** Phase 3r shipped §6.7 and the audit was re-run
|
||||
immediately; it found a *second* measured defect within minutes — a permanent sticky taint
|
||||
on a hardware sink (§6.8). Both rounds share a shape worth naming: **the architecture was
|
||||
@@ -1286,8 +1356,8 @@ How the blockers closed:
|
||||
| **9** | **a fail-closed unresolved mark became permanent sticky taint (measured, phase 5 re-run)** | **fixed** — §6.8 evidence-only sticky pass |
|
||||
| **9** | `device_props` tested for one live *Device* rather than one live *global* on the id (Codex, certain) | **fixed** in phase 3r — stale `session_device` on a contested id is an echo path |
|
||||
| **9** | `device.api` corroborated by presence, so `v4l2` under an ALSA factory passed (Codex) | **fixed** in phase 3r — the API must equal the allowlist's own |
|
||||
| **9** | hardware playback-to-capture ("Stereo Mix") defeats the `session_device` classifier (Codex, P1 worth checking) | **OPEN — design decision owed**, §6.8; pre-existing, needs ALSA control inspection |
|
||||
| **9** | the 2 s readiness budget has no calibration argument (Codex) | **OPEN — measurement owed**, §6.8; 1–2 ms observed on this host with 18 binds (phase-5 run 2) |
|
||||
| **9 → 11** | hardware playback-to-capture ("Stereo Mix") defeats the `session_device` classifier (Codex, P1 worth checking) | **CLOSED — §6.9.** Conservative `Sink → Source` edge for passive terminals sharing `device.id`; pure and targeted live exact partitions pass. No runtime control-name guess. |
|
||||
| **9 → 11** | the 2 s readiness budget has no calibration argument (Codex) | **CLOSED — §6.9.** 30 baseline starts, 30 starts with 48 temporary modules, and 20 starts during 250 create/remove cycles; inflated max 114 ms, zero timeouts. Keep 2 s. |
|
||||
| **10** | **the pulse-PID derivation required a *single* repeated `sec_pid`; WirePlumber repeats one too, so it returned `None` permanently and key 4's suppression never fired (measured, phase-5 run 2)** | **fixed** — §6.1.2 round-10 box: probe every distinct `sec_pid`, let `/proc/<pid>/comm` decide |
|
||||
| **10** | the audit's `sticky` flag means "is in the remembered set", so it is true for nearly every tainted node and does not answer "excluded only because remembered" | **OPEN — reporting only**; the evidence-only pass §6.8 already computes what is needed |
|
||||
| **10** | a bridge's named key is lost when a leg reappears under a new serial (sticky `reason_for` falls back to keyless, and `raise` will not replace a same-rank reason) | **OPEN — reporting only**; verdict unaffected |
|
||||
@@ -1297,7 +1367,8 @@ How the blockers closed:
|
||||
Option C fan-out · explicit `--aec=off|pulse-module:<idx>` · peerspeak playback and child
|
||||
tagging · exact AEC module validation · graph taint with the owner-key union and the
|
||||
pipewire-pulse PID exception · sticky taint · readiness epoch · fail-closed unresolved
|
||||
ancestry · owned non-lingering links · §10 items 1, 4 and 5 landed first.
|
||||
ancestry · conservative same-device hardware bridge · owned non-lingering links · §10 items
|
||||
1, 4 and 5 landed first.
|
||||
|
||||
### Deliberately OUT of v1
|
||||
|
||||
@@ -1311,21 +1382,103 @@ binding, so `port.exclusive` is never observed and the §6.2 row it guards relie
|
||||
create failing cleanly · **(r8)** no serial-continuity signal for the AEC validator's
|
||||
no-coalescing contract.
|
||||
|
||||
### Next step (round 9)
|
||||
### Current front after round 11
|
||||
|
||||
Phases 0a, 2, 3, 3r, 4 and 5 are built; §6.7 and §6.8 are implemented and merged. What
|
||||
remains before phase 6 unblocks:
|
||||
Phases 0a–5 and 3r are built; the full phase-5 matrix passed; 0b is merged; and 0c/0d plus
|
||||
the S4/S5 ownership work are built, validated, and committed locally. Round 11 closes the last
|
||||
two pre-Phase-6 design decisions with pure, observer, live PipeWire and targeted dry-run
|
||||
evidence. Phase 6's channel-aware pure planner is also committed in PixelPass `781defc`. On top
|
||||
of that checkpoint, local commit `98cde2c` creates and retains serial-guarded
|
||||
non-lingering links through the hidden production path, requires all planned links to be
|
||||
`ACTIVE`, revokes them when eligibility changes, and passed both normal-teardown and `SIGKILL`
|
||||
cleanup gates. Local commit `5a65f50` adds exact version-1 status records for stream link failure,
|
||||
AEC validation failure, AEC revocation and foreign AEC detection; PipeWire callbacks enqueue them
|
||||
for Tokio-side JSON emission. Local commit `d09ee9b` recreates an unexpectedly removed
|
||||
connection-owned sink with a fresh serial, hands that exact identity to the fan-out observer,
|
||||
drops stale link proxies, and returns every replacement channel to `ACTIVE`. The pure recovery
|
||||
gate, an owner-only live sink-destruction gate, and the full hidden
|
||||
host-path sink-destruction/relink gate pass; the full suite reports 332 passed and 12 ignored, all
|
||||
three serialized Phase 6 live gates pass together, and strict Clippy plus `pixelpass --doctor`
|
||||
remain clean.
|
||||
|
||||
1. ~~**Revise phase 3** to §6.7~~ — **done**, phase 3r merged, four-part gate passed
|
||||
including the live prop-recovery row and an added live gate for the Device-side path.
|
||||
2. **Revise phase 1** to emit both carriers (§5.1), literals pinned in plan §3. Unblocked
|
||||
and next.
|
||||
3. **Re-run the whole phase-5 §5.1 matrix** — no row was completable under the round-8
|
||||
defect, so nothing carries over — and **re-measure O5** with bind I/O *and* the round-9
|
||||
second fixpoint in it. Phase 6 stays blocked until that results file passes.
|
||||
4. Decide the two items §6.8 leaves open: hardware playback-to-capture paths (a real echo
|
||||
path, needs a design call) and the readiness-budget calibration.
|
||||
PixelPass commit `6be07ef` closes Row 1 and Row 8d/8e. Mutation-edge serial
|
||||
revalidation now covers retained proxies as well as new links, and a recycled identity produces
|
||||
zero unsafe creates. Two optimized-release fault-injection gates prove capture-sink construction
|
||||
failure and readiness timeout both fail `DesktopExcluding` closed without resolving the legacy
|
||||
default monitor or constructing legacy routing. Full validation reports 335 passed and 12
|
||||
ignored; strict Clippy, all three serialized live Phase 6 gates, diagnostics and residue checks
|
||||
pass.
|
||||
|
||||
Still owed beyond that, unchanged: the §9.2 rig upgrade before any exclusion claim is
|
||||
published, and **field-test §12** — nothing in this design has been tested over the real
|
||||
GStreamer/AAC/network path or on two machines.
|
||||
PixelPass commit `956534f` closes the three independent Row 6a/6b/6c refusal/reason fixtures.
|
||||
`Stream/Output/Audio` Nodes advertising a readable configured Format
|
||||
param are subscribed and parsed through libspa into raw, encoded or IEC958; unknown format is
|
||||
per-stream fail-closed, while the existing `node.passthrough` property remains an independent
|
||||
predicate. Separate controller gates prove `port.exclusive`, encoded and IEC958 each make zero
|
||||
link-create calls and emit only `port-exclusive`, `encoded` and `iec958-passthrough`
|
||||
respectively. An additional gate proves an explicit passthrough property cannot be masked by a
|
||||
raw Format, and the initial Node-info-before-Format ordering does not emit a false warning.
|
||||
|
||||
A read-only live audit initially caught generic Pod parsing failures and harmful Format queries
|
||||
against Nodes that did not advertise the param. After switching to libspa's native parser and
|
||||
gating queries on readable Format support, Strawberry, FFXIV and Chromium all settled to raw
|
||||
eligible streams with no parse/core errors. Full validation is 342 passed and 12 ignored; the
|
||||
three Row 6 cases also pass optimized release, strict Clippy and `pixelpass --doctor` are clean.
|
||||
The `port.exclusive` case remains an injected-graph gate under v1's accepted no-Port-binding
|
||||
limitation; a live exclusive port still degrades through the separately tested link-failure path.
|
||||
|
||||
PixelPass commit `7b11827` carries the revised Row 9 partition through the real fan-out
|
||||
controller. Four independent late-arrival gates prove music-only and a different-device
|
||||
microphone each create both stereo links and reach `Captured`, while same-device capture and a
|
||||
tainted-monitor capture retain `tainted-owner-bridge` and make zero link calls. Full validation
|
||||
is 345 passed and 12 ignored; strict Clippy, all three serialized live Phase 6 mutation gates,
|
||||
diagnostics and residue checks pass. This completes the deterministic link-manager matrix, not
|
||||
Phase 6's separate live signal qualification.
|
||||
|
||||
PixelPass commit `e027bc6` completes Phase 6's separate live signal qualification. Its
|
||||
deliberately unsafe predicate is wholly `#[cfg(test)]` and re-admits only the exact configured
|
||||
`aec-identity` candidate. The live test drives the hidden selector through the real sink,
|
||||
observer, taint controller, native link manager and Pulse monitor recording path; filters
|
||||
`media.class` before the exact module ID; preserves child stderr; and verifies the intended
|
||||
links in PipeWire before recording with `parec`.
|
||||
|
||||
Two complete runs kept the guarded 1500 Hz result at or below the run's control floor within the
|
||||
declared 3 dB tolerance, while the naive arm captured 1500 Hz at desktop level and at least 18 dB
|
||||
above control and guarded. The supported result is **no incremental 1500 Hz energy detectable
|
||||
above the control floor at this analysis resolution**, not proof of absence. Full validation is
|
||||
346 passed and 13 ignored; strict Clippy, all four serialized Phase 6 live audio gates,
|
||||
`pixelpass --doctor` and residue checks pass.
|
||||
|
||||
PixelPass commit `792f2bd` completes Phase 7. The selected public contract is
|
||||
`--audio-mode=desktop-shared|desktop-excluding`; excluding requires the explicit
|
||||
`--aec=off|pulse-module:<idx>` state and carries it into the production fan-out controller.
|
||||
`pixelpass --capabilities` emits schema version 1 with independent `strict_app_audio` and
|
||||
`desktop_audio_exclusion` booleans. Exact old-PeerSpeak argv remains legacy and byte-compatible.
|
||||
|
||||
Phase-7 validation also corrected the Phase-6 estimator without weakening its declared margins.
|
||||
Several extra runs showed that one sub-LSB coherent control bin can fall into a stochastic null.
|
||||
The gate now resolves its control floor from that bin plus the 90th percentile of neighboring
|
||||
frequencies outside the Hann main lobe; the 3 dB guarded tolerance and 18 dB naive margin are
|
||||
unchanged. Final validation is 354 passed and 13 ignored; strict Clippy, all four serialized
|
||||
live gates, exact capability/help probes, `pixelpass --doctor` and residue checks pass.
|
||||
|
||||
PeerSpeak commit `2c2b861` completes Phase 8. A typed picker/core selection keeps legacy desktop,
|
||||
desktop-excluding and per-app capture distinct. The versioned capability result is bound to its
|
||||
resolved PixelPass path and re-probed on path changes before capability-gated argv is built. The
|
||||
new picker row emits both `--audio-mode=desktop-excluding` and explicit
|
||||
`--aec=off|pulse-module:<idx>`; old PixelPass keeps the row absent and receives no new flags.
|
||||
Legacy desktop and per-app argv remain byte-identical.
|
||||
|
||||
The four Phase-6 status values now cross the real host-notice channel into the UI as the same
|
||||
parsed type and drive a persistent, mode-scoped explanation beside the live sharing badge. Final
|
||||
serialized validation passes 649 unit tests (7 live-only ignored), 20 integration tests (4
|
||||
live-only screen-share gates ignored), strict all-target Clippy, formatting and diff checks.
|
||||
|
||||
**Phase 9 is current.** PixelPass `98bb78f` implements the two-probe, windowed per-channel
|
||||
normalized-correlation rig with involved-node xrun telemetry, fixed pre-run thresholds, route
|
||||
integrity checks, and the control/guarded/naive topology; live-discovered harness hardening is
|
||||
committed as `360d711`. The controlled live run passed on 2026-08-22: excluded-probe maxima were
|
||||
0.1356 control / 0.1340 guarded against a 0.20 limit; the naive positive-control minimum was
|
||||
0.5741 and eligible desktop minimum was 0.6429 against a 0.25 floor; every arm added zero xruns.
|
||||
The full non-live suite, strict Clippy, `pixelpass --doctor`, route checks, and final residue scan
|
||||
also pass. **Field-test §12 remains owed** — nothing in this design has yet been tested over the
|
||||
real GStreamer/AAC/network path or on two machines. No exclusion claim may be published yet.
|
||||
|
||||
Generated
+48
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1785989512,
|
||||
"narHash": "sha256-HFQhkQcl5D1hUNoen3SGHCSFCt2Bg6uP+HgbrnA3InQ=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "445d861c6d31b4af0c79d8d4be2331f762a361d7",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-26.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1786076960,
|
||||
"narHash": "sha256-jfR6OhwurCKn1tREyfOcK/Omxf1Q/DzDDFbnEr1mBLs=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "57a23bfaf4f7017267294b161175db1e32eb1c85",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
{
|
||||
description = "PeerSpeak — decentralized P2P voice chat (Rust/iroh/PipeWire/Opus/iced)";
|
||||
|
||||
inputs = {
|
||||
# Pinned to the same channel the hosts run (nixos-config tracks
|
||||
# nixos-26.05), so the libraries this shell links and dlopens are built
|
||||
# against the same release as the PipeWire daemon and Vulkan ICD actually
|
||||
# running on the machine. Floating to unstable here would reintroduce
|
||||
# precisely the client/server version skew the pin exists to prevent.
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-26.05";
|
||||
|
||||
# The Rust toolchain is pinned SEPARATELY from the system libraries, and
|
||||
# deliberately so. nixpkgs 26.05 ships rustc 1.95.0, but this crate was
|
||||
# developed and verified against 1.97.1 — close enough to build and pass
|
||||
# every test, but not close enough for clippy, which flags a
|
||||
# `collapsible_match` on 1.95 that 1.97 does not. Taking the compiler from
|
||||
# here decouples "which Rust the project targets" from "which release the
|
||||
# audio stack came from", so a nixpkgs bump can never silently move the
|
||||
# compiler under the lint gate again.
|
||||
#
|
||||
# This is the reproducible alternative to rustup: same exact-version
|
||||
# control, but the choice is recorded in flake.lock, so darp5 or a fresh
|
||||
# clone resolves the identical toolchain instead of whatever rustup happens
|
||||
# to fetch that day.
|
||||
rust-overlay = {
|
||||
url = "github:oxalica/rust-overlay";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs =
|
||||
{ nixpkgs, rust-overlay, ... }:
|
||||
let
|
||||
system = "x86_64-linux";
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ rust-overlay.overlays.default ];
|
||||
};
|
||||
|
||||
# Matches what CachyOS shipped (rust 1:1.97.1-1), which is the toolchain
|
||||
# every green result in the handoff was produced with.
|
||||
#
|
||||
# `default` is the rustup "default" profile — rustc, cargo, rust-std,
|
||||
# rustfmt and clippy — so those are NOT listed separately below.
|
||||
#
|
||||
# rust-src and the windows-gnu target exist for win-cross-build.sh, which
|
||||
# needs `-Z build-std=std,panic_abort` for the self-contained .exe. That
|
||||
# script still expects to run in the peerspeak-win distrobox for the
|
||||
# mingw toolchain; carrying the target here just means the Rust half is
|
||||
# already in place if it is ever driven from the host.
|
||||
rustToolchain = pkgs.rust-bin.stable."1.97.1".default.override {
|
||||
extensions = [ "rust-src" ];
|
||||
targets = [ "x86_64-pc-windows-gnu" ];
|
||||
};
|
||||
|
||||
# Libraries that iced/winit/wgpu open with dlopen at RUNTIME rather than
|
||||
# linking at build time. Because nothing links them, they never land in
|
||||
# the binary's rpath — under `cargo run` the loader finds them only
|
||||
# through LD_LIBRARY_PATH. Leaving them out builds fine and then panics
|
||||
# at window creation, which is a genuinely confusing failure, so they are
|
||||
# listed explicitly instead of discovered the hard way.
|
||||
runtimeLibs = with pkgs; [
|
||||
vulkan-loader # wgpu's Vulkan backend (iced's renderer)
|
||||
libxkbcommon # winit keyboard handling
|
||||
wayland # wayland-sys, dlopen'd on a Wayland session
|
||||
libx11 # x11-dl, dlopen'd on the X11 fallback path
|
||||
libxcursor
|
||||
libxrandr
|
||||
libxi
|
||||
];
|
||||
|
||||
# Screen sharing spawns pixelpass as a CHILD PROCESS, and pixelpass in
|
||||
# turn drives GStreamer as a subprocess. That makes these tools a
|
||||
# dependency of peerspeak's own test suite, not just of pixelpass:
|
||||
# `tests/screenshare_host_fault.rs` starts a real pixelpass host, which
|
||||
# aborts at its preflight if gst-launch-1.0 is missing.
|
||||
#
|
||||
# Deliberately duplicated from pixelpass's flake rather than importing it
|
||||
# as an input. The two projects are mutually optional by design — neither
|
||||
# is a dependency of the other, and the coupling is a runtime subprocess
|
||||
# contract. Making one flake consume the other would quietly reintroduce
|
||||
# exactly the build-level dependency that rule exists to prevent.
|
||||
screenshareTools = with pkgs; [
|
||||
gst_all_1.gstreamer
|
||||
gst_all_1.gst-plugins-base
|
||||
gst_all_1.gst-plugins-good
|
||||
gst_all_1.gst-plugins-bad
|
||||
gst_all_1.gst-plugins-ugly
|
||||
gst_all_1.gst-libav
|
||||
pipewire # pipewiresrc (Wayland capture; ships in this pkg)
|
||||
];
|
||||
in
|
||||
{
|
||||
devShells.${system}.default = pkgs.mkShell {
|
||||
nativeBuildInputs = [
|
||||
rustToolchain
|
||||
]
|
||||
++ (with pkgs; [
|
||||
# The supply-chain gates .gitea/workflows/ci.yml runs, so the same
|
||||
# checks are reproducible locally before a push. These were `cargo
|
||||
# install`ed on the CachyOS side, which does not carry over — those
|
||||
# binaries link that distro's glibc and will not run here.
|
||||
# cargo-deny reads deny.toml; cargo-audit reads .cargo/audit.toml.
|
||||
cargo-audit
|
||||
cargo-deny
|
||||
# Debian packaging (`cargo deb --no-build`). Note the .deb itself
|
||||
# should still be built inside a Debian/Ubuntu distrobox so the
|
||||
# binary links that distro's glibc — see the packaging notes in
|
||||
# Cargo.toml.
|
||||
cargo-deb
|
||||
|
||||
pkg-config
|
||||
|
||||
# pipewire-sys and libspa-sys generate their bindings with bindgen,
|
||||
# which needs a real libclang present at build time.
|
||||
clang
|
||||
|
||||
# audiopus_sys prefers the system libopus via pkg-config but falls
|
||||
# back to a vendored CMake build; cmake keeps that fallback working
|
||||
# rather than failing obscurely inside a build script.
|
||||
cmake
|
||||
|
||||
# build.rs shells out to `git rev-parse --short=8 HEAD` to stamp
|
||||
# PEERSPEAK_GIT_SHORT into the binary (surfaced in Settings).
|
||||
git
|
||||
])
|
||||
++ screenshareTools
|
||||
++ [
|
||||
pkgs.pulseaudio # `pactl`, used by pixelpass's audio routing
|
||||
pkgs.mpv # the screen-share viewer
|
||||
];
|
||||
|
||||
buildInputs =
|
||||
with pkgs;
|
||||
[
|
||||
alsa-lib # alsa-sys, pulled in by rodio/cpal
|
||||
libopus # audiopus_sys, linked dynamically
|
||||
pipewire # pipewire-sys + libspa-sys: the Linux audio backend
|
||||
]
|
||||
++ runtimeLibs;
|
||||
|
||||
# bindgen finds libclang through this variable specifically — having
|
||||
# clang on PATH is not sufficient.
|
||||
LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib";
|
||||
|
||||
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs;
|
||||
|
||||
# NixOS keeps every GStreamer plugin in its own store path, so the
|
||||
# gst-launch-1.0 that pixelpass spawns discovers them ONLY through this
|
||||
# search path. Same reasoning as hosts/darp5 and hosts/cazen in
|
||||
# nixos-config.
|
||||
GST_PLUGIN_SYSTEM_PATH_1_0 =
|
||||
pkgs.lib.makeSearchPathOutput "lib" "lib/gstreamer-1.0" screenshareTools;
|
||||
|
||||
# Only greet an interactive shell. shellHook also runs under
|
||||
# `nix develop --command …`, where printing this would interleave the
|
||||
# banner with the command's own output.
|
||||
shellHook = ''
|
||||
if [ -t 1 ]; then
|
||||
echo "peerspeak — rustc $(rustc --version | cut -d' ' -f2) / cargo $(cargo --version | cut -d' ' -f2)"
|
||||
echo " cargo build --release build"
|
||||
echo " cargo test lib tests"
|
||||
echo " cargo clippy --all-targets -- -D warnings lint"
|
||||
echo
|
||||
echo "Screen sharing spawns pixelpass as a child process — it must be"
|
||||
echo "on PATH. Build it from ../pixelpass and add its target/release."
|
||||
fi
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -24,7 +24,10 @@ The AppImage runs on any reasonably current glibc-based distro that has:
|
||||
|
||||
- **A Vulkan-capable GPU + driver** (peerspeak's iced/wgpu renderer). Mesa/RADV
|
||||
on AMD/Intel or the NVIDIA driver all work.
|
||||
- **PipeWire** (with the PulseAudio shim, for `pactl`).
|
||||
- **PipeWire** with the PulseAudio shim, `pactl`, and the host `libpulse.so.0`
|
||||
client library. The Pulse client stack is deliberately not bundled because
|
||||
PixelPass launches host GStreamer tools that must keep using the host's
|
||||
matching multimedia libraries.
|
||||
- For **screen-share only** — pixelpass shells out to these on the host `PATH`;
|
||||
it prints the exact package names for your distro if any are missing:
|
||||
- **GStreamer + plugins** (`gst-launch-1.0`/`gst-inspect-1.0`, base,
|
||||
@@ -58,13 +61,23 @@ distrobox create --yes --image ubuntu:24.04 --name peerspeak-appimage
|
||||
distrobox enter peerspeak-appimage -- sudo apt-get update
|
||||
distrobox enter peerspeak-appimage -- sudo apt-get install -y \
|
||||
build-essential cmake clang libclang-dev pkg-config \
|
||||
libpipewire-0.3-dev libspa-0.2-dev libasound2-dev libxcb1-dev \
|
||||
libpipewire-0.3-dev libspa-0.2-dev libpulse-dev libasound2-dev libxcb1-dev \
|
||||
curl ca-certificates file patchelf git
|
||||
rustup toolchain install 1.97.1 --profile default
|
||||
|
||||
# Build (the host's ~/.rustup toolchain is glibc-2.17-baseline, so it runs in the
|
||||
# box; isolated CARGO_TARGET_DIRs keep it off the host target/):
|
||||
# PeerSpeak's ownership validator needs the modern SPA-JSON parser headers it
|
||||
# is tested against. Distrobox exposes the host's headers under /run/host;
|
||||
# pkg-config still links Ubuntu's older ABI-compatible libraries, preserving
|
||||
# the glibc baseline. This checkout's verified host-header version is 1.6.8:
|
||||
test -f /run/host/usr/include/spa-0.2/spa/utils/json-core.h
|
||||
|
||||
# Build with the repository's pinned Rust version (the host's ~/.rustup
|
||||
# toolchain is glibc-2.17-baseline, so it runs in the box; isolated
|
||||
# CARGO_TARGET_DIRs keep it off the host target/):
|
||||
distrobox enter peerspeak-appimage -- env \
|
||||
PATH="$HOME/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin:$PATH" \
|
||||
PATH="$HOME/.rustup/toolchains/1.97.1-x86_64-unknown-linux-gnu/bin:$PATH" \
|
||||
SYSTEM_DEPS_LIBSPA_INCLUDE="/run/host/usr/include/spa-0.2" \
|
||||
SYSTEM_DEPS_LIBPIPEWIRE_INCLUDE="/run/host/usr/include/pipewire-0.3:/run/host/usr/include/spa-0.2" \
|
||||
./packaging/appimage/build-appimage.sh
|
||||
```
|
||||
|
||||
|
||||
@@ -8,9 +8,12 @@
|
||||
# X11) is dlopen'd at runtime and is on the AppImage excludelist because it must
|
||||
# match the host driver, and pixelpass's capture/encode tools (gst-launch-1.0,
|
||||
# pactl, mpv) are expected on the host PATH. So the AppImage carries just the two
|
||||
# binaries plus their handful of non-excludelisted libs. The custom AppRun
|
||||
# prepends usr/bin to PATH so peerspeak's own $PATH lookup finds the bundled
|
||||
# pixelpass, while the host's tools stay reachable.
|
||||
# binaries plus their handful of non-excludelisted libs. PulseAudio's client
|
||||
# stack is also excluded: pixelpass shells out to host GStreamer, and letting
|
||||
# those subprocesses inherit Ubuntu's bundled libsndfile/libmpg123 stack can
|
||||
# override incompatible host multimedia libraries. The custom AppRun prepends
|
||||
# usr/bin to PATH so peerspeak's own $PATH lookup finds the bundled pixelpass,
|
||||
# while the host's tools and matching audio stack stay reachable.
|
||||
#
|
||||
# All runtime assets (notification WAVs, avatar presets, window icon, fonts) are
|
||||
# include_bytes!-embedded in the peerspeak binary, so nothing else is bundled.
|
||||
@@ -80,6 +83,7 @@ echo ">> running linuxdeploy (bundles libs, builds the AppImage)"
|
||||
--appdir "$appdir" \
|
||||
-e "$appdir/usr/bin/peerspeak" \
|
||||
-e "$appdir/usr/bin/pixelpass" \
|
||||
--exclude-library 'lib*.so*' \
|
||||
-d "$repo/packaging/peerspeak.desktop" \
|
||||
-i "$repo/assets/icons/peerspeak-256.png" \
|
||||
--icon-filename peerspeak \
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
|
||||
|
||||
#define MyAppName "PeerSpeak"
|
||||
#define MyAppVersion "0.6.6"
|
||||
#define MyAppVersion "0.6.7"
|
||||
#define MyAppPublisher "mollusk"
|
||||
#define MyAppExeName "peerspeak.exe"
|
||||
|
||||
|
||||
+367
-66
@@ -16,6 +16,7 @@ use crate::hotkeys::{HotkeyAction, HotkeyContext, KeyBinding, format_binding};
|
||||
use crate::network::PeerState;
|
||||
use crate::notify::{self, Sound};
|
||||
use crate::presence::PresenceMode;
|
||||
use crate::screenshare::{AudioExclusionStatus, ShareAudioSelection};
|
||||
use crate::theme::{AppTheme, Palette};
|
||||
use crate::widget::context_input::{context_input, locked_value};
|
||||
use crate::widget::selectable_text::selectable_rich_text;
|
||||
@@ -901,9 +902,8 @@ pub enum AppMessage {
|
||||
ToggleScreenShare,
|
||||
/// Close the screen-share audio picker without sharing.
|
||||
CloseSharePicker,
|
||||
/// Select which app's audio to share in the picker: `Some(name)` for one app,
|
||||
/// `None` for the whole desktop ("All system audio").
|
||||
SelectShareAudioApp(Option<String>),
|
||||
/// Select legacy desktop, desktop-excluding, or strict per-app audio.
|
||||
SelectShareAudio(ShareAudioSelection),
|
||||
/// Session-only quality preset for the next share start.
|
||||
SelectShareQualityOverride(ShareQuality),
|
||||
/// Confirm the picker: start the share with the currently selected audio app.
|
||||
@@ -1150,9 +1150,10 @@ pub struct AppState {
|
||||
/// Apps currently producing audio, shown in the share picker. Populated from
|
||||
/// `UiEvent::AudioAppsListed` after the picker requests an enumeration.
|
||||
share_audio_apps: Vec<String>,
|
||||
/// The picker's current selection: `Some(name)` = capture that app's audio,
|
||||
/// `None` = "All system audio" (whole desktop; may echo the call).
|
||||
share_audio_selection: Option<String>,
|
||||
/// The picker's current typed selection. Desktop-excluding is the normal
|
||||
/// whole-desktop choice; desktop-shared remains an internal compatibility
|
||||
/// fallback for an older PixelPass that does not advertise exclusion.
|
||||
share_audio_selection: ShareAudioSelection,
|
||||
/// Session-only quality override for the next screen-share start.
|
||||
share_quality_selection: ShareQuality,
|
||||
/// A share start is in flight: `ConfirmShareScreen` was sent but the core
|
||||
@@ -1171,11 +1172,20 @@ pub struct AppState {
|
||||
/// just-killed host can't flip the warning on a new whole-desktop share or
|
||||
/// after stop (audit P3, unscoped events).
|
||||
share_audio_app_active: bool,
|
||||
/// Whether the current share is the desktop-excluding mode. This gates its
|
||||
/// status events so late notices cannot affect another share mode.
|
||||
share_desktop_excluding_active: bool,
|
||||
/// The latest still-active warning from PixelPass while desktop exclusion
|
||||
/// is active. Keeping the typed status preserves a stream serial so an
|
||||
/// additive `stream_status_cleared` event can clear only its own warning.
|
||||
share_audio_exclusion_warning: Option<AudioExclusionStatus>,
|
||||
/// Whether the resolved pixelpass supports `--strict-audio` (per-app audio).
|
||||
/// `false` ⇒ the picker offers whole-desktop only, because a per-app share
|
||||
/// would pass a flag an older pixelpass rejects (audit P2). Optimistic `true`
|
||||
/// until the core's `AudioAppsListed` reports otherwise.
|
||||
share_app_audio_supported: bool,
|
||||
/// Whether the resolved PixelPass advertises desktop audio exclusion.
|
||||
share_desktop_audio_exclusion_supported: bool,
|
||||
/// Room-level warning for a validly signed peer whose gossip timestamp falls
|
||||
/// outside the replay freshness window. The peer is not yet in the roster, so
|
||||
/// this is not attached to a participant card.
|
||||
@@ -1272,12 +1282,15 @@ impl AppState {
|
||||
self.self_sharing = false;
|
||||
self.share_picker_open = false;
|
||||
self.share_audio_apps.clear();
|
||||
self.share_audio_selection = None;
|
||||
self.share_audio_selection = ShareAudioSelection::DesktopShared;
|
||||
self.share_quality_selection = self.config.screen_share.quality;
|
||||
self.share_starting = false;
|
||||
self.share_audio_dropped = false;
|
||||
self.share_audio_app_active = false;
|
||||
self.share_desktop_excluding_active = false;
|
||||
self.share_audio_exclusion_warning = None;
|
||||
self.share_app_audio_supported = true;
|
||||
self.share_desktop_audio_exclusion_supported = false;
|
||||
self.clock_skew_warning = None;
|
||||
}
|
||||
|
||||
@@ -1580,12 +1593,15 @@ impl Default for AppState {
|
||||
pixelpass_help_open: false,
|
||||
share_picker_open: false,
|
||||
share_audio_apps: Vec::new(),
|
||||
share_audio_selection: None,
|
||||
share_audio_selection: ShareAudioSelection::DesktopShared,
|
||||
share_quality_selection,
|
||||
share_starting: false,
|
||||
share_audio_dropped: false,
|
||||
share_audio_app_active: false,
|
||||
share_desktop_excluding_active: false,
|
||||
share_audio_exclusion_warning: None,
|
||||
share_app_audio_supported: true,
|
||||
share_desktop_audio_exclusion_supported: false,
|
||||
clock_skew_warning: None,
|
||||
drawer_chat_open: false,
|
||||
playlist_drawer_open: false,
|
||||
@@ -2172,12 +2188,14 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
// Open the audio picker instead of sharing immediately, so the
|
||||
// user chooses which app's audio to capture rather than the whole
|
||||
// desktop (which echoes the call back to viewers, A23). Default
|
||||
// selection is "All system audio" (None). Kick off a fresh
|
||||
// to the fail-closed desktop-exclusion contract while the fresh
|
||||
// capability probe runs; an old PixelPass response replaces it
|
||||
// with the visibly warned legacy fallback. Kick off a fresh
|
||||
// enumeration so the list reflects what's playing right now.
|
||||
// Suppressed while a start is already in flight (`share_starting`)
|
||||
// so the picker can't be reopened during the startup window.
|
||||
state.share_picker_open = true;
|
||||
state.share_audio_selection = None;
|
||||
state.share_audio_selection = ShareAudioSelection::DesktopExcluding;
|
||||
// NB: do NOT reset `share_quality_selection` here. It is the
|
||||
// per-call override set by the inline quality dropdown next to
|
||||
// the Share button, and the picker has no quality control of its
|
||||
@@ -2190,8 +2208,8 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
AppMessage::CloseSharePicker => {
|
||||
state.share_picker_open = false;
|
||||
}
|
||||
AppMessage::SelectShareAudioApp(app) => {
|
||||
state.share_audio_selection = app;
|
||||
AppMessage::SelectShareAudio(audio) => {
|
||||
state.share_audio_selection = audio;
|
||||
}
|
||||
AppMessage::SelectShareQualityOverride(quality) => {
|
||||
state.share_quality_selection = quality;
|
||||
@@ -2203,11 +2221,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
if state.share_picker_open && !state.share_starting {
|
||||
state.share_picker_open = false;
|
||||
state.share_starting = true;
|
||||
let audio_app = state.share_audio_selection.clone();
|
||||
state.share_audio_exclusion_warning = None;
|
||||
let audio = state.share_audio_selection.clone();
|
||||
let settings = state.config.screen_share.clone();
|
||||
let quality = state.share_quality_selection;
|
||||
let _ = state.controller.send(CoreCommand::StartScreenShare {
|
||||
audio_app,
|
||||
audio,
|
||||
settings,
|
||||
quality,
|
||||
});
|
||||
@@ -2513,25 +2532,52 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
UiEvent::AudioAppsListed {
|
||||
apps,
|
||||
app_audio_supported,
|
||||
desktop_audio_exclusion_supported,
|
||||
} => {
|
||||
// Only meaningful while the picker is open; if the user
|
||||
// already cancelled, drop it.
|
||||
if state.share_picker_open {
|
||||
let desktop_fallback = if desktop_audio_exclusion_supported {
|
||||
ShareAudioSelection::DesktopExcluding
|
||||
} else {
|
||||
ShareAudioSelection::DesktopShared
|
||||
};
|
||||
state.share_app_audio_supported = app_audio_supported;
|
||||
state.share_desktop_audio_exclusion_supported =
|
||||
desktop_audio_exclusion_supported;
|
||||
if app_audio_supported {
|
||||
// Keep the current selection if it still exists in the
|
||||
// refreshed list, else fall back to "All system audio".
|
||||
if let Some(sel) = &state.share_audio_selection
|
||||
if let ShareAudioSelection::Application(sel) =
|
||||
&state.share_audio_selection
|
||||
&& !apps.iter().any(|a| a == sel)
|
||||
{
|
||||
state.share_audio_selection = None;
|
||||
state.share_audio_selection = desktop_fallback.clone();
|
||||
}
|
||||
state.share_audio_apps = apps;
|
||||
} else {
|
||||
// Older pixelpass: per-app capture would hard-fail
|
||||
// (--strict-audio unknown). Force whole-desktop only.
|
||||
state.share_audio_apps.clear();
|
||||
state.share_audio_selection = None;
|
||||
if matches!(
|
||||
state.share_audio_selection,
|
||||
ShareAudioSelection::Application(_)
|
||||
) {
|
||||
state.share_audio_selection = desktop_fallback.clone();
|
||||
}
|
||||
}
|
||||
match state.share_audio_selection {
|
||||
ShareAudioSelection::DesktopShared
|
||||
if desktop_audio_exclusion_supported =>
|
||||
{
|
||||
state.share_audio_selection = ShareAudioSelection::DesktopExcluding;
|
||||
}
|
||||
ShareAudioSelection::DesktopExcluding
|
||||
if !desktop_audio_exclusion_supported =>
|
||||
{
|
||||
state.share_audio_selection = ShareAudioSelection::DesktopShared;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2541,7 +2587,14 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
state.share_audio_dropped = false;
|
||||
// Remember whether this share captures a specific app, so we
|
||||
// only apply `app_audio` warnings to app shares (P3).
|
||||
state.share_audio_app_active = state.share_audio_selection.is_some();
|
||||
state.share_audio_app_active = matches!(
|
||||
state.share_audio_selection,
|
||||
ShareAudioSelection::Application(_)
|
||||
);
|
||||
state.share_desktop_excluding_active = matches!(
|
||||
state.share_audio_selection,
|
||||
ShareAudioSelection::DesktopExcluding
|
||||
);
|
||||
// Defensive: ensure no picker lingers across a successful start.
|
||||
state.share_picker_open = false;
|
||||
state.status_message = "Sharing your screen".to_string();
|
||||
@@ -2551,6 +2604,8 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
state.share_starting = false;
|
||||
state.share_audio_dropped = false;
|
||||
state.share_audio_app_active = false;
|
||||
state.share_desktop_excluding_active = false;
|
||||
state.share_audio_exclusion_warning = None;
|
||||
state.status_message = "Screen share stopped".to_string();
|
||||
}
|
||||
UiEvent::ShareAudioActive(active) => {
|
||||
@@ -2562,6 +2617,40 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
state.share_audio_dropped = !active;
|
||||
}
|
||||
}
|
||||
UiEvent::ShareAudioExclusionStatus(status) => {
|
||||
// A status can race the start acknowledgement because the
|
||||
// host drain and core loop use cloned UI senders. Accept it
|
||||
// during an in-flight excluding start as well as the active
|
||||
// share, but ignore late notices for other modes.
|
||||
let excluding_starting = state.share_starting
|
||||
&& matches!(
|
||||
state.share_audio_selection,
|
||||
ShareAudioSelection::DesktopExcluding
|
||||
);
|
||||
if state.share_desktop_excluding_active || excluding_starting {
|
||||
match status {
|
||||
AudioExclusionStatus::StreamStatusCleared { stream_serial } => {
|
||||
let clears_visible = matches!(
|
||||
state.share_audio_exclusion_warning.as_ref(),
|
||||
Some(AudioExclusionStatus::StreamUnsupported {
|
||||
stream_serial: visible_serial,
|
||||
..
|
||||
}) if *visible_serial == stream_serial
|
||||
);
|
||||
if clears_visible {
|
||||
state.share_audio_exclusion_warning = None;
|
||||
state.status_message = "Sharing your screen".to_string();
|
||||
}
|
||||
}
|
||||
warning => {
|
||||
if let Some(message) = audio_exclusion_status_message(&warning) {
|
||||
state.status_message = message;
|
||||
state.share_audio_exclusion_warning = Some(warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
UiEvent::ClockSkewWarning {
|
||||
skew_secs,
|
||||
peer_ahead,
|
||||
@@ -5568,7 +5657,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.size(11)
|
||||
.color(color_subtext),
|
||||
slider(0.0..=1.0, state.config.background_dim, AppMessage::SetBackgroundDim)
|
||||
.step(0.05),
|
||||
.step(0.05_f32),
|
||||
text("Set a picture from your computer as the app background. Auto-resized; a dimming overlay keeps text readable. Applies live.")
|
||||
.size(11)
|
||||
.color(color_subtext),
|
||||
@@ -5996,7 +6085,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
).width(iced::Length::Fill),
|
||||
text(format!("Input Volume (mic): {:.0}%", state.config.input_volume * 100.0)).size(11).color(color_subtext),
|
||||
slider(0.0..=2.0, state.config.input_volume, AppMessage::InputVolumeChanged)
|
||||
.step(0.05)
|
||||
.step(0.05_f32)
|
||||
.on_release(AppMessage::PersistConfig),
|
||||
].spacing(8).width(iced::Length::Fill),
|
||||
column![
|
||||
@@ -6008,7 +6097,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
).width(iced::Length::Fill),
|
||||
text(format!("Output Volume: {:.0}%", state.config.output_volume * 100.0)).size(11).color(color_subtext),
|
||||
slider(0.0..=2.0, state.config.output_volume, AppMessage::OutputVolumeChanged)
|
||||
.step(0.05)
|
||||
.step(0.05_f32)
|
||||
.on_release(AppMessage::PersistConfig),
|
||||
].spacing(8).width(iced::Length::Fill),
|
||||
].spacing(20).align_y(iced::alignment::Vertical::Top).width(iced::Length::Fill),
|
||||
@@ -6618,7 +6707,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
column![
|
||||
row![
|
||||
avatar_view(&state.config.avatar, &state.name, &state.self_id, 34.0),
|
||||
text(format!("{} (You)", &state.name)).size(16).color(color_text),
|
||||
text(format!("{} (You)", state.name)).size(16).color(color_text),
|
||||
horizontal_space(),
|
||||
if state.is_muted {
|
||||
text("[Muted]").size(14).color(color_red)
|
||||
@@ -6656,20 +6745,24 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::alignment::Vertical::Center);
|
||||
let mut details = column![badge].spacing(3);
|
||||
if state.share_audio_dropped {
|
||||
column![
|
||||
badge,
|
||||
details = details.push(
|
||||
text(
|
||||
"⚠ Shared app isn't sending audio — viewers hear silence until it plays"
|
||||
)
|
||||
.size(11)
|
||||
.color(color_yellow),
|
||||
]
|
||||
.spacing(3)
|
||||
.into()
|
||||
} else {
|
||||
badge.into()
|
||||
);
|
||||
}
|
||||
if let Some(warning) = state
|
||||
.share_audio_exclusion_warning
|
||||
.as_ref()
|
||||
.and_then(audio_exclusion_status_message)
|
||||
{
|
||||
details = details.push(text(warning).size(11).color(color_yellow));
|
||||
}
|
||||
details.into()
|
||||
} else {
|
||||
iced::widget::Space::new().width(0.0).height(0.0).into()
|
||||
};
|
||||
@@ -6933,7 +7026,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
slider(0.0..=2.0, current_vol, move |v| {
|
||||
AppMessage::PeerVolumeChanged(peer_id_clone, v)
|
||||
})
|
||||
.step(0.01)
|
||||
.step(0.01_f32)
|
||||
.on_release(AppMessage::PersistConfig)
|
||||
]
|
||||
.spacing(8)
|
||||
@@ -6946,7 +7039,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
slider(-1.0..=1.0, current_pan, move |v| {
|
||||
AppMessage::PeerPanChanged(peer_id_clone, v)
|
||||
})
|
||||
.step(0.05)
|
||||
.step(0.05_f32)
|
||||
.on_release(AppMessage::PersistConfig),
|
||||
]
|
||||
.spacing(8)
|
||||
@@ -6961,7 +7054,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
slider(0.0..=METER_MAX, current_gate, move |v| {
|
||||
AppMessage::PeerGateChanged(peer_id_clone, v)
|
||||
})
|
||||
.step(0.001)
|
||||
.step(0.001_f32)
|
||||
.on_release(AppMessage::PersistConfig),
|
||||
]
|
||||
.spacing(8)
|
||||
@@ -6980,7 +7073,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
slider(EQ_GAIN_DB_MIN..=EQ_GAIN_DB_MAX, value, move |v| {
|
||||
AppMessage::PeerEqChanged(peer_id_clone, band, v)
|
||||
})
|
||||
.step(0.5)
|
||||
.step(0.5_f32)
|
||||
.on_release(AppMessage::PersistConfig),
|
||||
]
|
||||
.spacing(8)
|
||||
@@ -7215,7 +7308,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
clip_progress(music_status.position, music_status.total),
|
||||
AppMessage::MusicSeek,
|
||||
)
|
||||
.step(0.001),
|
||||
.step(0.001_f32),
|
||||
text(format!("{elapsed} / {duration}"))
|
||||
.size(11)
|
||||
.color(color_subtext),
|
||||
@@ -7225,7 +7318,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
state.config.music_volume,
|
||||
AppMessage::MusicSetVolume
|
||||
)
|
||||
.step(0.01),
|
||||
.step(0.01_f32),
|
||||
button(
|
||||
text("Browse")
|
||||
.size(12)
|
||||
@@ -7319,7 +7412,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
effective_music_volume(state),
|
||||
AppMessage::MusicSetSourceVolume
|
||||
)
|
||||
.step(0.01),
|
||||
.step(0.01_f32),
|
||||
]
|
||||
.spacing(8)
|
||||
.into()
|
||||
@@ -7815,7 +7908,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
},
|
||||
move |fraction| AppMessage::SeekAudio(att.id, fraction),
|
||||
)
|
||||
.step(0.001)
|
||||
.step(0.001_f32)
|
||||
.width(iced::Length::Fixed(180.0)),
|
||||
text(format!("{elapsed} / {duration}"))
|
||||
.size(11)
|
||||
@@ -7827,7 +7920,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
slider(0.0..=2.0, effective_clip_volume(state, att.id), move |v| {
|
||||
AppMessage::SetClipVolumeFor(att.id, v)
|
||||
},)
|
||||
.step(0.01)
|
||||
.step(0.01_f32)
|
||||
.width(iced::Length::Fixed(80.0)),
|
||||
]
|
||||
.spacing(8)
|
||||
@@ -7945,7 +8038,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
state.config.clip_volume,
|
||||
AppMessage::SetClipVolume
|
||||
)
|
||||
.step(0.01)
|
||||
.step(0.01_f32)
|
||||
.width(iced::Length::Fixed(110.0)),
|
||||
]
|
||||
.spacing(10)
|
||||
@@ -8757,11 +8850,28 @@ fn with_pixelpass_help<'a>(
|
||||
.into()
|
||||
}
|
||||
|
||||
fn audio_exclusion_status_message(status: &AudioExclusionStatus) -> Option<String> {
|
||||
match status {
|
||||
AudioExclusionStatus::StreamUnsupported { reason, .. } => Some(format!(
|
||||
"Some system audio could not be shared safely ({reason}); it was left out."
|
||||
)),
|
||||
AudioExclusionStatus::StreamStatusCleared { .. } => None,
|
||||
AudioExclusionStatus::AecFailed { .. } =>
|
||||
Some("Audio exclusion could not verify PeerSpeak's echo canceller; viewers hear silence."
|
||||
.to_string()),
|
||||
AudioExclusionStatus::AecRevoked { .. } =>
|
||||
Some("Audio exclusion stopped because PeerSpeak's echo canceller disappeared; viewers hear silence."
|
||||
.to_string()),
|
||||
AudioExclusionStatus::ForeignAecWarning { .. } =>
|
||||
Some("Another echo-cancel stream was kept out of the screen share.".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Overlay the screen-share **audio picker** when open (A23). Lets the user
|
||||
/// capture a single app's audio instead of the whole desktop sink — the default
|
||||
/// whole-desktop capture contains our own call playout, so a viewer would
|
||||
/// otherwise hear themselves echoed back. "All system audio" keeps the legacy
|
||||
/// behavior (with a warning); picking an app passes `--app=<name>` to pixelpass.
|
||||
/// capture all safe system audio or one specific app. With a capable PixelPass,
|
||||
/// "All system audio" uses desktop exclusion so PeerSpeak playback is never
|
||||
/// included. An older PixelPass gets the same row backed by legacy capture and
|
||||
/// an explicit echo warning. Picking an app passes `--app=<name>` to pixelpass.
|
||||
fn with_share_picker<'a>(
|
||||
base: Element<'a, AppMessage>,
|
||||
state: &'a AppState,
|
||||
@@ -8828,22 +8938,47 @@ fn with_share_picker<'a>(
|
||||
})
|
||||
};
|
||||
|
||||
// "All system audio" first (the whole-desktop default — carries the echo
|
||||
// warning), then each currently-playing app.
|
||||
let mut options = column![opt_row(
|
||||
state.share_audio_selection.is_none(),
|
||||
"All system audio".to_string(),
|
||||
Some("⚠ may echo the call back to viewers"),
|
||||
AppMessage::SelectShareAudioApp(None),
|
||||
)]
|
||||
// Present one whole-desktop row. A current PixelPass backs it with safe
|
||||
// exclusion; only an older binary sees the legacy implementation/warning.
|
||||
// Treat the optimistic pre-probe DesktopExcluding selection as the safe row
|
||||
// too; the core re-probes the exact binary before constructing its argv.
|
||||
let safe_desktop = state.share_desktop_audio_exclusion_supported
|
||||
|| matches!(
|
||||
state.share_audio_selection,
|
||||
ShareAudioSelection::DesktopExcluding
|
||||
);
|
||||
let mut options = if safe_desktop {
|
||||
column![opt_row(
|
||||
matches!(
|
||||
state.share_audio_selection,
|
||||
ShareAudioSelection::DesktopExcluding
|
||||
),
|
||||
"All system audio".to_string(),
|
||||
Some("Excludes call and watched-share playback"),
|
||||
AppMessage::SelectShareAudio(ShareAudioSelection::DesktopExcluding),
|
||||
)]
|
||||
} else {
|
||||
column![opt_row(
|
||||
matches!(
|
||||
state.share_audio_selection,
|
||||
ShareAudioSelection::DesktopShared
|
||||
),
|
||||
"All system audio".to_string(),
|
||||
Some("⚠ may echo the call back to viewers"),
|
||||
AppMessage::SelectShareAudio(ShareAudioSelection::DesktopShared),
|
||||
)]
|
||||
}
|
||||
.spacing(4);
|
||||
for app in &state.share_audio_apps {
|
||||
let selected = state.share_audio_selection.as_deref() == Some(app.as_str());
|
||||
let selected = matches!(
|
||||
&state.share_audio_selection,
|
||||
ShareAudioSelection::Application(selected) if selected == app
|
||||
);
|
||||
options = options.push(opt_row(
|
||||
selected,
|
||||
app.clone(),
|
||||
None,
|
||||
AppMessage::SelectShareAudioApp(Some(app.clone())),
|
||||
AppMessage::SelectShareAudio(ShareAudioSelection::Application(app.clone())),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -9668,13 +9803,14 @@ mod tests {
|
||||
use super::PendingSend;
|
||||
use super::sendqueue::{self, LocalSend, SendStatus};
|
||||
use super::{
|
||||
AppConfig, AppMessage, AppState, AttachmentCache, AttachmentState,
|
||||
AppConfig, AppMessage, AppState, AttachmentCache, AttachmentState, AudioExclusionStatus,
|
||||
CLOCK_SKEW_WARNING_VISIBLE_SECS, ChatEntry, ClockSkewBanner, GateMeter, METER_MAX, Screen,
|
||||
ScreenBounds, UiEvent, attachment_default_name, clamp_window_position,
|
||||
clear_expired_clock_skew_warning, format_clock_skew_duration, format_duration,
|
||||
format_relative_ago, friend_presence_notification, initial_window_position,
|
||||
now_playing_label, reconnect_attempt_chime, reconnected_chime, selected_wav_path,
|
||||
set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning, update,
|
||||
ScreenBounds, ShareAudioSelection, UiEvent, attachment_default_name,
|
||||
audio_exclusion_status_message, clamp_window_position, clear_expired_clock_skew_warning,
|
||||
format_clock_skew_duration, format_duration, format_relative_ago,
|
||||
friend_presence_notification, initial_window_position, now_playing_label,
|
||||
reconnect_attempt_chime, reconnected_chime, selected_wav_path, set_peer_gate_config,
|
||||
set_peer_volume_config, show_clock_skew_warning, update,
|
||||
};
|
||||
use iroh::SecretKey;
|
||||
use std::collections::VecDeque;
|
||||
@@ -9970,11 +10106,15 @@ mod tests {
|
||||
state.self_sharing = true;
|
||||
state.share_picker_open = true;
|
||||
state.share_audio_apps = vec!["Firefox".to_string()];
|
||||
state.share_audio_selection = Some("Firefox".to_string());
|
||||
state.share_audio_selection = ShareAudioSelection::Application("Firefox".to_string());
|
||||
state.share_starting = true;
|
||||
state.share_audio_dropped = true;
|
||||
state.share_audio_app_active = true;
|
||||
state.share_desktop_excluding_active = true;
|
||||
state.share_audio_exclusion_warning =
|
||||
Some(AudioExclusionStatus::AecFailed { module_index: 77 });
|
||||
state.share_app_audio_supported = false;
|
||||
state.share_desktop_audio_exclusion_supported = true;
|
||||
state.clock_skew_warning = Some(ClockSkewBanner {
|
||||
skew_secs: 180,
|
||||
peer_ahead: true,
|
||||
@@ -10011,7 +10151,10 @@ mod tests {
|
||||
assert!(!state.self_sharing);
|
||||
assert!(!state.share_picker_open);
|
||||
assert!(state.share_audio_apps.is_empty());
|
||||
assert!(state.share_audio_selection.is_none());
|
||||
assert_eq!(
|
||||
state.share_audio_selection,
|
||||
ShareAudioSelection::DesktopShared
|
||||
);
|
||||
assert_eq!(
|
||||
state.share_quality_selection,
|
||||
state.config.screen_share.quality
|
||||
@@ -10019,10 +10162,13 @@ mod tests {
|
||||
assert!(!state.share_starting);
|
||||
assert!(!state.share_audio_dropped);
|
||||
assert!(!state.share_audio_app_active);
|
||||
assert!(!state.share_desktop_excluding_active);
|
||||
assert!(state.share_audio_exclusion_warning.is_none());
|
||||
assert!(
|
||||
state.share_app_audio_supported,
|
||||
"reset is optimistic by default"
|
||||
);
|
||||
assert!(!state.share_desktop_audio_exclusion_supported);
|
||||
assert!(state.clock_skew_warning.is_none());
|
||||
assert!(state.music_broadcast_id.is_none());
|
||||
assert!(state.music_broadcast_next.is_none());
|
||||
@@ -10135,7 +10281,7 @@ mod tests {
|
||||
// Picker open, user confirms a selection.
|
||||
let mut state = AppState {
|
||||
share_picker_open: true,
|
||||
share_audio_selection: Some("mpv".to_string()),
|
||||
share_audio_selection: ShareAudioSelection::Application("mpv".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let _ = update(&mut state, AppMessage::ConfirmShareScreen);
|
||||
@@ -10204,6 +10350,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_picker_defaults_whole_desktop_to_safe_exclusion() {
|
||||
let mut state = AppState::default();
|
||||
|
||||
let _ = update(&mut state, AppMessage::ToggleScreenShare);
|
||||
|
||||
assert!(state.share_picker_open);
|
||||
assert_eq!(
|
||||
state.share_audio_selection,
|
||||
ShareAudioSelection::DesktopExcluding,
|
||||
"the picker must not expose legacy echoing capture as its normal desktop default"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_start_failure_clears_in_flight_flag() {
|
||||
// A failed spawn surfaces as UiEvent::Error (not ScreenShareStopped); the
|
||||
@@ -10233,7 +10393,7 @@ mod tests {
|
||||
// flag; start and stop both reset it so it can't linger across sessions.
|
||||
// A specific app was chosen in the picker, so the share is app-specific.
|
||||
let mut state = AppState {
|
||||
share_audio_selection: Some("mpv".to_string()),
|
||||
share_audio_selection: ShareAudioSelection::Application("mpv".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -10300,7 +10460,7 @@ mod tests {
|
||||
|
||||
// (b) After stop: a straggling event can't resurrect the warning.
|
||||
let mut state = AppState {
|
||||
share_audio_selection: Some("mpv".to_string()),
|
||||
share_audio_selection: ShareAudioSelection::Application("mpv".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let _ = update(
|
||||
@@ -10318,6 +10478,100 @@ mod tests {
|
||||
assert!(!state.share_audio_dropped, "post-stop event is ignored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_exclusion_status_reaches_visible_state_and_clears_on_stop() {
|
||||
let mut state = AppState {
|
||||
share_starting: true,
|
||||
share_audio_selection: ShareAudioSelection::DesktopExcluding,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// The status may beat ScreenShareStarted because the host drain uses a
|
||||
// cloned UI sender. It must still be retained and shown.
|
||||
let _ = update(
|
||||
&mut state,
|
||||
AppMessage::UiEventReceived(UiEvent::ShareAudioExclusionStatus(
|
||||
AudioExclusionStatus::AecFailed { module_index: 77 },
|
||||
)),
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.share_audio_exclusion_warning
|
||||
.as_ref()
|
||||
.and_then(audio_exclusion_status_message)
|
||||
.is_some_and(|message| message.contains("viewers hear silence"))
|
||||
);
|
||||
|
||||
let _ = update(
|
||||
&mut state,
|
||||
AppMessage::UiEventReceived(UiEvent::ScreenShareStarted),
|
||||
);
|
||||
assert!(state.share_desktop_excluding_active);
|
||||
assert!(state.share_audio_exclusion_warning.is_some());
|
||||
|
||||
let _ = update(
|
||||
&mut state,
|
||||
AppMessage::UiEventReceived(UiEvent::ScreenShareStopped),
|
||||
);
|
||||
assert!(!state.share_desktop_excluding_active);
|
||||
assert!(state.share_audio_exclusion_warning.is_none());
|
||||
|
||||
// A stale notice after stop cannot resurrect the warning.
|
||||
let _ = update(
|
||||
&mut state,
|
||||
AppMessage::UiEventReceived(UiEvent::ShareAudioExclusionStatus(
|
||||
AudioExclusionStatus::AecRevoked { module_index: 77 },
|
||||
)),
|
||||
);
|
||||
assert!(state.share_audio_exclusion_warning.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_exclusion_clears_only_the_matching_stream_warning() {
|
||||
let mut state = AppState {
|
||||
self_sharing: true,
|
||||
share_desktop_excluding_active: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let _ = update(
|
||||
&mut state,
|
||||
AppMessage::UiEventReceived(UiEvent::ShareAudioExclusionStatus(
|
||||
AudioExclusionStatus::StreamUnsupported {
|
||||
stream_serial: 41,
|
||||
reason: "unidentified-channel".to_string(),
|
||||
},
|
||||
)),
|
||||
);
|
||||
assert!(matches!(
|
||||
state.share_audio_exclusion_warning.as_ref(),
|
||||
Some(AudioExclusionStatus::StreamUnsupported {
|
||||
stream_serial: 41,
|
||||
..
|
||||
})
|
||||
));
|
||||
|
||||
let _ = update(
|
||||
&mut state,
|
||||
AppMessage::UiEventReceived(UiEvent::ShareAudioExclusionStatus(
|
||||
AudioExclusionStatus::StreamStatusCleared { stream_serial: 99 },
|
||||
)),
|
||||
);
|
||||
assert!(
|
||||
state.share_audio_exclusion_warning.is_some(),
|
||||
"another stream's recovery must not clear the visible warning"
|
||||
);
|
||||
|
||||
let _ = update(
|
||||
&mut state,
|
||||
AppMessage::UiEventReceived(UiEvent::ShareAudioExclusionStatus(
|
||||
AudioExclusionStatus::StreamStatusCleared { stream_serial: 41 },
|
||||
)),
|
||||
);
|
||||
assert!(state.share_audio_exclusion_warning.is_none());
|
||||
assert_eq!(state.status_message, "Sharing your screen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_pixelpass_picker_offers_whole_desktop_only() {
|
||||
// P2 (version skew): when the resolved pixelpass lacks --strict-audio, the
|
||||
@@ -10325,7 +10579,7 @@ mod tests {
|
||||
// so a per-app share (which would pass the unknown flag) can't be started.
|
||||
let mut state = AppState {
|
||||
share_picker_open: true,
|
||||
share_audio_selection: Some("Firefox".to_string()),
|
||||
share_audio_selection: ShareAudioSelection::Application("Firefox".to_string()),
|
||||
share_audio_apps: vec!["Firefox".to_string(), "mpv".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
@@ -10334,12 +10588,14 @@ mod tests {
|
||||
AppMessage::UiEventReceived(UiEvent::AudioAppsListed {
|
||||
apps: vec!["Firefox".to_string(), "mpv".to_string()],
|
||||
app_audio_supported: false,
|
||||
desktop_audio_exclusion_supported: false,
|
||||
}),
|
||||
);
|
||||
assert!(!state.share_app_audio_supported);
|
||||
assert!(!state.share_desktop_audio_exclusion_supported);
|
||||
assert!(state.share_audio_apps.is_empty(), "no per-app rows offered");
|
||||
assert!(
|
||||
state.share_audio_selection.is_none(),
|
||||
state.share_audio_selection == ShareAudioSelection::DesktopShared,
|
||||
"forced to whole-desktop"
|
||||
);
|
||||
|
||||
@@ -10349,10 +10605,55 @@ mod tests {
|
||||
AppMessage::UiEventReceived(UiEvent::AudioAppsListed {
|
||||
apps: vec!["Firefox".to_string(), "mpv".to_string()],
|
||||
app_audio_supported: true,
|
||||
desktop_audio_exclusion_supported: true,
|
||||
}),
|
||||
);
|
||||
assert!(state.share_app_audio_supported);
|
||||
assert!(state.share_desktop_audio_exclusion_supported);
|
||||
assert_eq!(state.share_audio_apps.len(), 2);
|
||||
assert_eq!(
|
||||
state.share_audio_selection,
|
||||
ShareAudioSelection::DesktopExcluding,
|
||||
"a capable PixelPass must make the single desktop row use exclusion"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_treats_per_app_and_desktop_exclusion_as_independent_capabilities() {
|
||||
let mut state = AppState {
|
||||
share_picker_open: true,
|
||||
share_audio_selection: ShareAudioSelection::DesktopExcluding,
|
||||
..Default::default()
|
||||
};
|
||||
let _ = update(
|
||||
&mut state,
|
||||
AppMessage::UiEventReceived(UiEvent::AudioAppsListed {
|
||||
apps: vec!["Firefox".to_string()],
|
||||
app_audio_supported: false,
|
||||
desktop_audio_exclusion_supported: true,
|
||||
}),
|
||||
);
|
||||
assert!(!state.share_app_audio_supported);
|
||||
assert!(state.share_desktop_audio_exclusion_supported);
|
||||
assert_eq!(
|
||||
state.share_audio_selection,
|
||||
ShareAudioSelection::DesktopExcluding,
|
||||
"lack of per-app support must not hide the independent exclusion mode"
|
||||
);
|
||||
|
||||
let _ = update(
|
||||
&mut state,
|
||||
AppMessage::UiEventReceived(UiEvent::AudioAppsListed {
|
||||
apps: Vec::new(),
|
||||
app_audio_supported: false,
|
||||
desktop_audio_exclusion_supported: false,
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
state.share_audio_selection,
|
||||
ShareAudioSelection::DesktopShared,
|
||||
"old PixelPass must remove the unavailable exclusion selection"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -33,12 +33,17 @@ const NODE_READY_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
/// Owns a loaded `module-echo-cancel` instance; unloads it on drop so the virtual
|
||||
/// nodes never leak past the call that created them.
|
||||
pub struct EchoCancelGuard {
|
||||
module_index: String,
|
||||
module_index: u64,
|
||||
source_name: String,
|
||||
sink_name: String,
|
||||
}
|
||||
|
||||
impl EchoCancelGuard {
|
||||
/// The pactl module identity PixelPass validates in desktop-excluding mode.
|
||||
pub fn module_index(&self) -> u64 {
|
||||
self.module_index
|
||||
}
|
||||
|
||||
pub fn source_name(&self) -> &str {
|
||||
&self.source_name
|
||||
}
|
||||
@@ -52,7 +57,7 @@ impl Drop for EchoCancelGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = Command::new("pactl")
|
||||
.arg("unload-module")
|
||||
.arg(&self.module_index)
|
||||
.arg(self.module_index.to_string())
|
||||
.output();
|
||||
crate::log_msg(&format!(
|
||||
"Echo cancel: unloaded module {}",
|
||||
@@ -103,10 +108,10 @@ pub fn enable(
|
||||
));
|
||||
}
|
||||
|
||||
let module_index = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if module_index.parse::<u64>().is_err() {
|
||||
return Err(format!("unexpected pactl output: {module_index:?}"));
|
||||
}
|
||||
let raw_module_index = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
let module_index = raw_module_index
|
||||
.parse::<u64>()
|
||||
.map_err(|_| format!("unexpected pactl output: {raw_module_index:?}"))?;
|
||||
let guard = EchoCancelGuard {
|
||||
module_index,
|
||||
source_name,
|
||||
|
||||
+11
-10
@@ -126,11 +126,10 @@ pub enum CoreCommand {
|
||||
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.
|
||||
/// `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).
|
||||
/// `audio` is typed so legacy whole-desktop, desktop-excluding, and strict
|
||||
/// per-app capture remain distinct across the UI/core boundary.
|
||||
StartScreenShare {
|
||||
audio_app: Option<String>,
|
||||
audio: crate::screenshare::ShareAudioSelection,
|
||||
settings: ScreenShareSettings,
|
||||
quality: ShareQuality,
|
||||
},
|
||||
@@ -276,7 +275,7 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
|
||||
| CoreCommand::SetPixelpassPath(_)
|
||||
| CoreCommand::ListAudioApps
|
||||
| CoreCommand::StartScreenShare {
|
||||
audio_app: _,
|
||||
audio: _,
|
||||
settings: _,
|
||||
quality: _,
|
||||
}
|
||||
@@ -369,7 +368,7 @@ pub fn coalesce_key(cmd: &CoreCommand) -> Option<CoalesceKey> {
|
||||
| CoreCommand::SetPixelpassPath(_)
|
||||
| CoreCommand::ListAudioApps
|
||||
| CoreCommand::StartScreenShare {
|
||||
audio_app: _,
|
||||
audio: _,
|
||||
settings: _,
|
||||
quality: _,
|
||||
}
|
||||
@@ -511,13 +510,12 @@ pub enum UiEvent {
|
||||
},
|
||||
/// 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).
|
||||
/// playing or enumeration isn't available. The two support bits are
|
||||
/// independent and belong to the exact resolved PixelPass binary.
|
||||
AudioAppsListed {
|
||||
apps: Vec<String>,
|
||||
app_audio_supported: bool,
|
||||
desktop_audio_exclusion_supported: bool,
|
||||
},
|
||||
/// Our own screen share started; the UI flips the Share button to "Stop".
|
||||
ScreenShareStarted,
|
||||
@@ -528,6 +526,9 @@ pub enum UiEvent {
|
||||
/// 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),
|
||||
/// One desktop-excluding status parsed from PixelPass and forwarded without
|
||||
/// translating it into a separately maintained PeerSpeak enum.
|
||||
ShareAudioExclusionStatus(crate::screenshare::AudioExclusionStatus),
|
||||
/// A validly signed peer cannot be admitted because its gossip timestamp is
|
||||
/// outside the replay freshness window. `peer_ahead` describes the peer's
|
||||
/// sender-stamped timestamp relative to this machine's clock.
|
||||
|
||||
+206
-53
@@ -1241,6 +1241,11 @@ const PING_INTERVAL: Duration = Duration::from_secs(15);
|
||||
/// Delay before the FIRST presence pass, so the endpoint's background `online()`
|
||||
/// has a moment to finish (otherwise the first probes fail and friends flash offline).
|
||||
const PING_STARTUP_DELAY: Duration = Duration::from_secs(3);
|
||||
/// How quickly the core polls owned PixelPass viewer children for natural exit.
|
||||
/// A viewer normally exits when the remote host stops sharing; without this
|
||||
/// independent tick it remains an unreaped zombie until another Watch click or
|
||||
/// the entire call ends.
|
||||
const VIEWER_REAP_INTERVAL: Duration = Duration::from_millis(500);
|
||||
|
||||
/// One outbound presence-refresh pass (W7 B2): probe every friend and emit a
|
||||
/// *definitive* status for each, so the UI self-heals every pass instead of only
|
||||
@@ -1284,6 +1289,42 @@ async fn probe_friends_once(
|
||||
}
|
||||
}
|
||||
|
||||
fn ui_event_from_pixelpass_event(event: crate::screenshare::PixelpassEvent) -> Option<UiEvent> {
|
||||
match event {
|
||||
crate::screenshare::PixelpassEvent::AppAudioRouted => Some(UiEvent::ShareAudioActive(true)),
|
||||
crate::screenshare::PixelpassEvent::AppAudioLost => Some(UiEvent::ShareAudioActive(false)),
|
||||
crate::screenshare::PixelpassEvent::AudioExclusion(status) => {
|
||||
Some(UiEvent::ShareAudioExclusionStatus(status))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Carry the actual parsed PixelPass event value across the notice channel to
|
||||
/// the UI. `Eof` remains a generation-scoped core fault rather than a UI event.
|
||||
async fn forward_host_notices(
|
||||
mut notices: mpsc::UnboundedReceiver<crate::screenshare::HostNotice>,
|
||||
ui_tx: mpsc::Sender<UiEvent>,
|
||||
fault_tx: mpsc::UnboundedSender<u64>,
|
||||
generation: u64,
|
||||
) {
|
||||
while let Some(notice) = notices.recv().await {
|
||||
match notice {
|
||||
crate::screenshare::HostNotice::Event(event) => {
|
||||
if let Some(event) = ui_event_from_pixelpass_event(event)
|
||||
&& ui_tx.send(event).await.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
crate::screenshare::HostNotice::Eof => {
|
||||
let _ = fault_tx.send(generation);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_core_loop(
|
||||
mut reliable_rx: mpsc::UnboundedReceiver<CoreCommand>,
|
||||
coalesce: CoalesceStore,
|
||||
@@ -1404,6 +1445,10 @@ async fn run_core_loop(
|
||||
// generation must appear and vanish together, or a stale fault could tear
|
||||
// down a share it doesn't belong to.
|
||||
let mut pixelpass_override: Option<String> = None;
|
||||
// Capability results are meaningful only for the exact resolved binary
|
||||
// path that produced them. A changed override/PATH resolution must be
|
||||
// re-probed before any capability-gated argv is constructed.
|
||||
let mut pixelpass_capabilities: Option<crate::screenshare::ProbedPixelpassCapabilities> = None;
|
||||
struct ActiveShare {
|
||||
generation: u64,
|
||||
ticket: String,
|
||||
@@ -1525,6 +1570,11 @@ async fn run_core_loop(
|
||||
PING_INTERVAL,
|
||||
);
|
||||
ping_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut viewer_reap_interval = tokio::time::interval_at(
|
||||
tokio::time::Instant::now() + VIEWER_REAP_INTERVAL,
|
||||
VIEWER_REAP_INTERVAL,
|
||||
);
|
||||
viewer_reap_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
loop {
|
||||
let cmd = tokio::select! {
|
||||
biased;
|
||||
@@ -1571,6 +1621,12 @@ async fn run_core_loop(
|
||||
Some(generation) = host_fault_rx.recv() => {
|
||||
CoreCommand::ScreenShareHostFault { generation }
|
||||
}
|
||||
_ = viewer_reap_interval.tick() => {
|
||||
if let Some(session) = &mut active_session {
|
||||
session.teardown.sweep_exited_viewers();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
game_change = next_game_change(&mut game_rx) => {
|
||||
// The detector worker published a new debounced game (or `None`).
|
||||
let Some(detected) = game_change else {
|
||||
@@ -3413,39 +3469,53 @@ async fn run_core_loop(
|
||||
|
||||
CoreCommand::SetPixelpassPath(path) => {
|
||||
pixelpass_override = path.filter(|p| !p.trim().is_empty());
|
||||
pixelpass_capabilities = None;
|
||||
}
|
||||
|
||||
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 =
|
||||
// Probe the versioned response from the exact binary selected
|
||||
// for this picker. The help fallback can recover legacy strict
|
||||
// per-app support, but never desktop exclusion.
|
||||
let (capabilities, apps) =
|
||||
match crate::screenshare::pixelpass_path(pixelpass_override.as_deref()) {
|
||||
Some(bin) => crate::screenshare::supports_strict_audio(&bin).await,
|
||||
None => false,
|
||||
Some(bin) => {
|
||||
let capabilities =
|
||||
crate::screenshare::probe_pixelpass_capabilities(&bin).await;
|
||||
pixelpass_capabilities =
|
||||
Some(crate::screenshare::ProbedPixelpassCapabilities {
|
||||
binary: bin,
|
||||
capabilities,
|
||||
});
|
||||
let apps = if capabilities.strict_app_audio {
|
||||
crate::screenshare::list_audio_apps().await
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
(capabilities, apps)
|
||||
}
|
||||
None => {
|
||||
pixelpass_capabilities = None;
|
||||
(
|
||||
crate::screenshare::PixelpassCapabilities::default(),
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
};
|
||||
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,
|
||||
app_audio_supported: capabilities.strict_app_audio,
|
||||
desktop_audio_exclusion_supported: capabilities.desktop_audio_exclusion,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
CoreCommand::StartScreenShare {
|
||||
audio_app,
|
||||
audio,
|
||||
settings,
|
||||
quality,
|
||||
} => {
|
||||
let Some(session) = &mut active_session else {
|
||||
let Some(session) = active_session.as_ref() else {
|
||||
let _ = ui_tx
|
||||
.send(UiEvent::Error(
|
||||
"Join a call before sharing your screen".into(),
|
||||
@@ -3467,49 +3537,72 @@ async fn run_core_loop(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// The picker probe is bound to its resolved binary. If the
|
||||
// override/PATH now resolves elsewhere, immediately re-probe
|
||||
// before constructing any capability-gated argv and fail closed
|
||||
// when the selected feature is absent.
|
||||
if !matches!(
|
||||
audio,
|
||||
crate::screenshare::ShareAudioSelection::DesktopShared
|
||||
) {
|
||||
let capabilities = crate::screenshare::capabilities_for_resolved_binary(
|
||||
&bin,
|
||||
&mut pixelpass_capabilities,
|
||||
)
|
||||
.await;
|
||||
let unsupported = match &audio {
|
||||
crate::screenshare::ShareAudioSelection::Application(_)
|
||||
if !capabilities.strict_app_audio =>
|
||||
{
|
||||
Some(
|
||||
"This PixelPass does not support strict per-app audio. Reopen the picker or update PixelPass.",
|
||||
)
|
||||
}
|
||||
crate::screenshare::ShareAudioSelection::DesktopExcluding
|
||||
if !capabilities.desktop_audio_exclusion =>
|
||||
{
|
||||
Some(
|
||||
"This PixelPass does not support desktop audio exclusion. Update PixelPass or choose another audio source.",
|
||||
)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(message) = unsupported {
|
||||
let _ = ui_tx.send(UiEvent::Error(message.into())).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let session = active_session
|
||||
.as_mut()
|
||||
.expect("session presence checked before PixelPass probe");
|
||||
let aec_module_index = matches!(
|
||||
audio,
|
||||
crate::screenshare::ShareAudioSelection::DesktopExcluding
|
||||
)
|
||||
.then(|| {
|
||||
session
|
||||
.teardown
|
||||
.echo_cancel()
|
||||
.map(crate::audio::echo_cancel::EchoCancelGuard::module_index)
|
||||
})
|
||||
.flatten();
|
||||
// Every share gets a notice forwarder — not just app-audio ones.
|
||||
// pixelpass `app_audio` events (only emitted when an app is
|
||||
// selected) become UI warnings, and the drain's terminal `Eof`
|
||||
// becomes a host fault scoped to this spawn's generation, so a
|
||||
// host that dies is torn down instead of staying advertised in
|
||||
// presence forever. On a failed spawn the sender is dropped
|
||||
// before the drain ever runs, so the forwarder just ends and no
|
||||
// fault is sent (the spawn error carries the news instead).
|
||||
// App-audio and desktop-exclusion events become UI state, and
|
||||
// the drain's terminal `Eof` becomes a generation-scoped fault.
|
||||
share_generations += 1;
|
||||
let generation = share_generations;
|
||||
let (notices_tx, mut notices_rx) =
|
||||
let (notices_tx, notices_rx) =
|
||||
tokio::sync::mpsc::unbounded_channel::<crate::screenshare::HostNotice>();
|
||||
let ui_tx_notices = ui_tx.clone();
|
||||
let fault_tx = host_fault_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(notice) = notices_rx.recv().await {
|
||||
match notice {
|
||||
crate::screenshare::HostNotice::Event(ev) => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
// Terminal by contract: nothing follows on the
|
||||
// channel, so the task ends here.
|
||||
crate::screenshare::HostNotice::Eof => {
|
||||
let _ = fault_tx.send(generation);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
forward_host_notices(notices_rx, ui_tx_notices, fault_tx, generation).await;
|
||||
});
|
||||
match crate::screenshare::spawn_host(
|
||||
&bin,
|
||||
audio_app.as_deref(),
|
||||
&audio,
|
||||
aec_module_index,
|
||||
&settings,
|
||||
quality,
|
||||
notices_tx,
|
||||
@@ -3705,10 +3798,11 @@ mod tests {
|
||||
KnownPeers, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS, MIC_LEVEL_REPORT_SAMPLES, MicLevelMeter,
|
||||
NetworkMode, PLAYBACK_HANDOFF_QUEUE_FRAMES, PeerSpeakTicket, admit_retained,
|
||||
apply_peer_volume, apply_volume, audio_datagram_len_ok, coalesce_insert, coalesce_pop,
|
||||
frame_level, mix_frames, mix_stereo_frames, next_game_change, rebuild_with_fallback,
|
||||
replace_viewer_index, send_playback_frame, should_auto_fetch, stereo_to_mono,
|
||||
forward_host_notices, frame_level, mix_frames, mix_stereo_frames, next_game_change,
|
||||
rebuild_with_fallback, replace_viewer_index, send_playback_frame, should_auto_fetch,
|
||||
stereo_to_mono,
|
||||
};
|
||||
use crate::core::messages::{CoalesceKey, CoreCommand, coalesce_key};
|
||||
use crate::core::messages::{CoalesceKey, CoreCommand, UiEvent, coalesce_key};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::mpsc::sync_channel;
|
||||
use std::time::Duration;
|
||||
@@ -3717,6 +3811,65 @@ mod tests {
|
||||
iroh::SecretKey::generate().public()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn all_exclusion_events_causally_cross_the_host_notice_channel_to_ui() {
|
||||
use crate::screenshare::{AudioExclusionStatus, HostNotice, parse_pixelpass_event};
|
||||
|
||||
let lines = [
|
||||
r#"{"event":"stream_unsupported","version":1,"stream_serial":4294967303,"reason":"port-exclusive"}"#,
|
||||
r#"{"event":"stream_status_cleared","version":1,"stream_serial":4294967303}"#,
|
||||
r#"{"event":"aec_failed","version":1,"module_index":536870919}"#,
|
||||
r#"{"event":"aec_revoked","version":1,"module_index":536870919}"#,
|
||||
r#"{"event":"foreign_aec_warning","version":1,"link_group":"echo-cancel-9999-13"}"#,
|
||||
];
|
||||
let (notice_tx, notice_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
for line in lines {
|
||||
notice_tx
|
||||
.send(HostNotice::Event(
|
||||
parse_pixelpass_event(line).expect("PixelPass event must parse"),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
drop(notice_tx);
|
||||
|
||||
let (ui_tx, mut ui_rx) = tokio::sync::mpsc::channel(8);
|
||||
let (fault_tx, mut fault_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
forward_host_notices(notice_rx, ui_tx, fault_tx, 17).await;
|
||||
|
||||
let mut statuses = Vec::new();
|
||||
while let Some(event) = ui_rx.recv().await {
|
||||
match event {
|
||||
UiEvent::ShareAudioExclusionStatus(status) => statuses.push(status),
|
||||
other => panic!("unexpected forwarded UI event: {other:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
statuses,
|
||||
vec![
|
||||
AudioExclusionStatus::StreamUnsupported {
|
||||
stream_serial: 4_294_967_303,
|
||||
reason: "port-exclusive".to_string(),
|
||||
},
|
||||
AudioExclusionStatus::StreamStatusCleared {
|
||||
stream_serial: 4_294_967_303,
|
||||
},
|
||||
AudioExclusionStatus::AecFailed {
|
||||
module_index: 536_870_919,
|
||||
},
|
||||
AudioExclusionStatus::AecRevoked {
|
||||
module_index: 536_870_919,
|
||||
},
|
||||
AudioExclusionStatus::ForeignAecWarning {
|
||||
link_group: "echo-cancel-9999-13".to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
fault_rx.try_recv().is_err(),
|
||||
"ordinary status events must not synthesize a host fault"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_watch_replaces_existing_viewer_for_same_ticket() {
|
||||
// The value type stands in for a viewer Child; only the ticket matters.
|
||||
|
||||
@@ -305,10 +305,6 @@ pub(super) struct ScreenshareTeardown<C: ChildProcess, G> {
|
||||
///
|
||||
/// ⚠️ **LAST FIELD ON PURPOSE** — see the module docs and the struct note.
|
||||
///
|
||||
/// Never read, and that is the design: the guard is held only so that its
|
||||
/// `Drop` runs, and only so that it runs *here*, last. `dead_code` is right
|
||||
/// that nothing reads it and wrong that it does nothing.
|
||||
#[allow(dead_code)]
|
||||
echo_cancel: Option<G>,
|
||||
}
|
||||
|
||||
@@ -325,6 +321,13 @@ impl<C: ChildProcess, G> ScreenshareTeardown<C, G> {
|
||||
self.host.is_some()
|
||||
}
|
||||
|
||||
/// Borrow the session-owned AEC guard without disturbing its load-bearing
|
||||
/// last-field drop order. Phase 8 uses this only to pass the module identity
|
||||
/// to PixelPass while the guard remains owned here.
|
||||
pub(super) fn echo_cancel(&self) -> Option<&G> {
|
||||
self.echo_cancel.as_ref()
|
||||
}
|
||||
|
||||
pub(super) fn set_host(&mut self, child: C) {
|
||||
self.host = Some(ReapOnDrop::new(child, "screen-share host"));
|
||||
}
|
||||
|
||||
+428
-19
@@ -60,6 +60,48 @@ const LOW_LATENCY_CACHE_CAP_MB: u32 = 1;
|
||||
/// is only a safety net so a hung pixelpass can't wedge the caller forever.
|
||||
const STARTUP_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
/// The audio source selected for one hosted screen share.
|
||||
///
|
||||
/// This is shared by the picker and the core so the new desktop-excluding
|
||||
/// choice cannot collapse back into the legacy `Option<String>` representation
|
||||
/// (where `None` could only mean whole-desktop audio).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub enum ShareAudioSelection {
|
||||
/// PixelPass's legacy whole-desktop monitor capture.
|
||||
#[default]
|
||||
DesktopShared,
|
||||
/// Whole-desktop audio with PeerSpeak-owned playback excluded.
|
||||
DesktopExcluding,
|
||||
/// Strict capture of one locally selected application.
|
||||
Application(String),
|
||||
}
|
||||
|
||||
/// One version-1 desktop-audio-exclusion status from PixelPass.
|
||||
///
|
||||
/// The parsed value itself crosses the core/UI boundary; PeerSpeak does not
|
||||
/// define a second matching enum that could silently drift from the wire.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AudioExclusionStatus {
|
||||
StreamUnsupported {
|
||||
stream_serial: u64,
|
||||
reason: String,
|
||||
},
|
||||
/// PixelPass has cleared the previously reported status for this exact
|
||||
/// stream because it became capturable or left the graph.
|
||||
StreamStatusCleared {
|
||||
stream_serial: u64,
|
||||
},
|
||||
AecFailed {
|
||||
module_index: u64,
|
||||
},
|
||||
AecRevoked {
|
||||
module_index: u64,
|
||||
},
|
||||
ForeignAecWarning {
|
||||
link_group: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// One parsed line from pixelpass's `--output json` stdout stream. Mirrors the
|
||||
/// `event` tags in pixelpass's `src/common/output.rs`. Recognized-but-unused
|
||||
/// events collapse to [`PixelpassEvent::Other`]; blank or non-JSON lines parse
|
||||
@@ -86,6 +128,9 @@ pub enum PixelpassEvent {
|
||||
/// 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,
|
||||
/// Host (desktop-excluding audio): a versioned status from the fail-closed
|
||||
/// fan-out controller.
|
||||
AudioExclusion(AudioExclusionStatus),
|
||||
/// A recognized event we don't act on (e.g. `host_info`).
|
||||
Other,
|
||||
}
|
||||
@@ -140,6 +185,32 @@ pub fn parse_pixelpass_event(line: &str) -> Option<PixelpassEvent> {
|
||||
Some("lost") => PixelpassEvent::AppAudioLost,
|
||||
_ => PixelpassEvent::Other,
|
||||
},
|
||||
"stream_unsupported" if json_u64(&v, "version") == Some(1) => {
|
||||
PixelpassEvent::AudioExclusion(AudioExclusionStatus::StreamUnsupported {
|
||||
stream_serial: json_u64(&v, "stream_serial")?,
|
||||
reason: v.get("reason")?.as_str()?.to_string(),
|
||||
})
|
||||
}
|
||||
"stream_status_cleared" if json_u64(&v, "version") == Some(1) => {
|
||||
PixelpassEvent::AudioExclusion(AudioExclusionStatus::StreamStatusCleared {
|
||||
stream_serial: json_u64(&v, "stream_serial")?,
|
||||
})
|
||||
}
|
||||
"aec_failed" if json_u64(&v, "version") == Some(1) => {
|
||||
PixelpassEvent::AudioExclusion(AudioExclusionStatus::AecFailed {
|
||||
module_index: json_u64(&v, "module_index")?,
|
||||
})
|
||||
}
|
||||
"aec_revoked" if json_u64(&v, "version") == Some(1) => {
|
||||
PixelpassEvent::AudioExclusion(AudioExclusionStatus::AecRevoked {
|
||||
module_index: json_u64(&v, "module_index")?,
|
||||
})
|
||||
}
|
||||
"foreign_aec_warning" if json_u64(&v, "version") == Some(1) => {
|
||||
PixelpassEvent::AudioExclusion(AudioExclusionStatus::ForeignAecWarning {
|
||||
link_group: v.get("link_group")?.as_str()?.to_string(),
|
||||
})
|
||||
}
|
||||
_ => PixelpassEvent::Other,
|
||||
};
|
||||
Some(ev)
|
||||
@@ -149,6 +220,10 @@ fn json_u32(v: &serde_json::Value, key: &str) -> u32 {
|
||||
v.get(key).and_then(|x| x.as_u64()).unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
fn json_u64(v: &serde_json::Value, key: &str) -> Option<u64> {
|
||||
v.get(key).and_then(|x| x.as_u64())
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -180,6 +255,48 @@ pub fn host_args(
|
||||
args.push(format!("--app={name}"));
|
||||
args.push("--strict-audio".to_string());
|
||||
}
|
||||
append_host_settings(&mut args, settings, quality);
|
||||
args
|
||||
}
|
||||
|
||||
/// Build host argv from the picker's typed audio selection.
|
||||
///
|
||||
/// The existing desktop-shared and application arms deliberately delegate to
|
||||
/// [`host_args`] so their argv stays byte-for-byte compatible. Only the new
|
||||
/// desktop-excluding arm emits the public PixelPass protocol pair, and it
|
||||
/// always includes an explicit AEC state: `off` when this PeerSpeak session did
|
||||
/// not load an echo-cancel module, otherwise the exact pactl module index.
|
||||
pub fn host_args_for_selection(
|
||||
audio: &ShareAudioSelection,
|
||||
aec_module_index: Option<u64>,
|
||||
settings: &ScreenShareSettings,
|
||||
quality: ShareQuality,
|
||||
) -> Vec<String> {
|
||||
match audio {
|
||||
ShareAudioSelection::DesktopShared => host_args(None, settings, quality),
|
||||
ShareAudioSelection::Application(name) => host_args(Some(name), settings, quality),
|
||||
ShareAudioSelection::DesktopExcluding => {
|
||||
let mut args = vec![
|
||||
"--host".to_string(),
|
||||
"--output".to_string(),
|
||||
"json".to_string(),
|
||||
"--audio-mode=desktop-excluding".to_string(),
|
||||
match aec_module_index {
|
||||
Some(index) => format!("--aec=pulse-module:{index}"),
|
||||
None => "--aec=off".to_string(),
|
||||
},
|
||||
];
|
||||
append_host_settings(&mut args, settings, quality);
|
||||
args
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn append_host_settings(
|
||||
args: &mut Vec<String>,
|
||||
settings: &ScreenShareSettings,
|
||||
quality: ShareQuality,
|
||||
) {
|
||||
if quality != ShareQuality::Auto {
|
||||
args.push(format!("--quality={}", pixelpass_quality(quality)));
|
||||
}
|
||||
@@ -199,7 +316,6 @@ pub fn host_args(
|
||||
args.push(format!("--max-viewers={max}"));
|
||||
}
|
||||
args.extend(split_extra_args(&settings.extra_host_args));
|
||||
args
|
||||
}
|
||||
|
||||
fn pixelpass_quality(quality: ShareQuality) -> &'static str {
|
||||
@@ -259,22 +375,67 @@ pub async fn list_audio_apps() -> Vec<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Hard cap on each PixelPass capability probe. Conservative: a slow or hung
|
||||
/// binary degrades to the legacy capability set, 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 {
|
||||
/// Capabilities PeerSpeak consumes from PixelPass's versioned response.
|
||||
/// Strict per-app capture and desktop exclusion are independent by contract.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct PixelpassCapabilities {
|
||||
pub strict_app_audio: bool,
|
||||
pub desktop_audio_exclusion: bool,
|
||||
}
|
||||
|
||||
/// A capability result tied to the exact resolved executable that produced it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProbedPixelpassCapabilities {
|
||||
pub binary: PathBuf,
|
||||
pub capabilities: PixelpassCapabilities,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CapabilityResponse {
|
||||
schema_version: u64,
|
||||
capabilities: CapabilityFlags,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CapabilityFlags {
|
||||
strict_app_audio: bool,
|
||||
desktop_audio_exclusion: bool,
|
||||
}
|
||||
|
||||
/// Parse the schema-1 response from `pixelpass --capabilities`. Unsupported
|
||||
/// schemas and malformed output return `None`, which selects the legacy help
|
||||
/// fallback rather than guessing at a new protocol.
|
||||
pub fn parse_pixelpass_capabilities(stdout: &[u8]) -> Option<PixelpassCapabilities> {
|
||||
let response: CapabilityResponse = serde_json::from_slice(stdout).ok()?;
|
||||
(response.schema_version == 1).then_some(PixelpassCapabilities {
|
||||
strict_app_audio: response.capabilities.strict_app_audio,
|
||||
desktop_audio_exclusion: response.capabilities.desktop_audio_exclusion,
|
||||
})
|
||||
}
|
||||
|
||||
/// Probe one resolved PixelPass binary. The versioned machine response is the
|
||||
/// primary contract. `--help` survives only as a compatibility fallback for an
|
||||
/// older PixelPass that predates `--capabilities`; it can recover strict per-app
|
||||
/// support but can never advertise desktop exclusion.
|
||||
pub async fn probe_pixelpass_capabilities(bin: &Path) -> PixelpassCapabilities {
|
||||
let primary = Command::new(bin)
|
||||
.arg("--capabilities")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true)
|
||||
.output();
|
||||
if let Ok(Ok(output)) = tokio::time::timeout(HELP_PROBE_TIMEOUT, primary).await
|
||||
&& output.status.success()
|
||||
&& let Some(capabilities) = parse_pixelpass_capabilities(&output.stdout)
|
||||
{
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
let run = Command::new(bin)
|
||||
.arg("--help")
|
||||
.stdin(Stdio::null())
|
||||
@@ -282,12 +443,42 @@ pub async fn supports_strict_audio(bin: &Path) -> bool {
|
||||
.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),
|
||||
let strict_app_audio = match tokio::time::timeout(HELP_PROBE_TIMEOUT, run).await {
|
||||
Ok(Ok(output)) => help_mentions_strict_audio(&output.stdout),
|
||||
_ => false,
|
||||
};
|
||||
PixelpassCapabilities {
|
||||
strict_app_audio,
|
||||
desktop_audio_exclusion: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return capabilities for `bin`, re-probing and replacing `cached` whenever
|
||||
/// the resolved executable path differs. This is the start-time skew guard:
|
||||
/// capability-gated argv must never be built from a probe of another binary.
|
||||
pub async fn capabilities_for_resolved_binary(
|
||||
bin: &Path,
|
||||
cached: &mut Option<ProbedPixelpassCapabilities>,
|
||||
) -> PixelpassCapabilities {
|
||||
if let Some(probe) = cached.as_ref()
|
||||
&& probe.binary == bin
|
||||
{
|
||||
return probe.capabilities;
|
||||
}
|
||||
let capabilities = probe_pixelpass_capabilities(bin).await;
|
||||
*cached = Some(ProbedPixelpassCapabilities {
|
||||
binary: bin.to_path_buf(),
|
||||
capabilities,
|
||||
});
|
||||
capabilities
|
||||
}
|
||||
|
||||
/// Compatibility helper retained for callers that only need the pre-Phase-8
|
||||
/// per-app bit.
|
||||
pub async fn supports_strict_audio(bin: &Path) -> bool {
|
||||
probe_pixelpass_capabilities(bin).await.strict_app_audio
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -393,12 +584,13 @@ pub fn is_available(config_override: Option<&str>) -> bool {
|
||||
/// them, so pixelpass keeps its own defaults in the common case.
|
||||
pub async fn spawn_host(
|
||||
bin: &Path,
|
||||
audio_app: Option<&str>,
|
||||
audio: &ShareAudioSelection,
|
||||
aec_module_index: Option<u64>,
|
||||
settings: &ScreenShareSettings,
|
||||
quality: ShareQuality,
|
||||
notices: tokio::sync::mpsc::UnboundedSender<HostNotice>,
|
||||
) -> std::io::Result<(Child, String)> {
|
||||
let args = host_args(audio_app, settings, quality);
|
||||
let args = host_args_for_selection(audio, aec_module_index, settings, quality);
|
||||
// Log the exact argv we hand pixelpass so a field log can confirm which
|
||||
// encode/quality flags (e.g. --bitrate) actually reached the host — these
|
||||
// are local flags with no ticket/secret, so logging them verbatim is safe.
|
||||
@@ -633,6 +825,7 @@ fn event_for_log(ev: &PixelpassEvent) -> String {
|
||||
PixelpassEvent::CaptureStopped => "capture_stopped".to_string(),
|
||||
PixelpassEvent::AppAudioRouted => "app_audio_routed".to_string(),
|
||||
PixelpassEvent::AppAudioLost => "app_audio_lost".to_string(),
|
||||
PixelpassEvent::AudioExclusion(status) => format!("audio_exclusion {status:?}"),
|
||||
PixelpassEvent::Other => "other".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -913,6 +1106,64 @@ mod tests {
|
||||
assert_eq!(args[4], "--strict-audio");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_excluding_argv_requires_both_public_mode_and_explicit_aec() {
|
||||
let settings = ScreenShareSettings::default();
|
||||
assert_eq!(
|
||||
host_args_for_selection(
|
||||
&ShareAudioSelection::DesktopExcluding,
|
||||
Some(536_870_919),
|
||||
&settings,
|
||||
ShareQuality::Auto,
|
||||
),
|
||||
vec![
|
||||
"--host",
|
||||
"--output",
|
||||
"json",
|
||||
"--audio-mode=desktop-excluding",
|
||||
"--aec=pulse-module:536870919",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
host_args_for_selection(
|
||||
&ShareAudioSelection::DesktopExcluding,
|
||||
None,
|
||||
&settings,
|
||||
ShareQuality::Auto,
|
||||
),
|
||||
vec![
|
||||
"--host",
|
||||
"--output",
|
||||
"json",
|
||||
"--audio-mode=desktop-excluding",
|
||||
"--aec=off",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_legacy_selections_keep_existing_argv_byte_identical() {
|
||||
let settings = ScreenShareSettings::default();
|
||||
assert_eq!(
|
||||
host_args_for_selection(
|
||||
&ShareAudioSelection::DesktopShared,
|
||||
Some(42),
|
||||
&settings,
|
||||
ShareQuality::Auto,
|
||||
),
|
||||
host_args(None, &settings, ShareQuality::Auto),
|
||||
);
|
||||
assert_eq!(
|
||||
host_args_for_selection(
|
||||
&ShareAudioSelection::Application("Firefox".to_string()),
|
||||
Some(42),
|
||||
&settings,
|
||||
ShareQuality::Auto,
|
||||
),
|
||||
host_args(Some("Firefox"), &settings, ShareQuality::Auto),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_args_blank_or_control_app_is_dropped() {
|
||||
// An empty / whitespace / control-laden selection is sanitized away,
|
||||
@@ -1217,6 +1468,106 @@ Install hint: sudo apt install gstreamer1.0-plugins-bad
|
||||
assert!(!help_mentions_strict_audio(&[0xff, 0xfe, 0x00]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_schema_keeps_strict_and_desktop_exclusion_independent() {
|
||||
assert_eq!(
|
||||
parse_pixelpass_capabilities(
|
||||
br#"{"schema_version":1,"capabilities":{"strict_app_audio":true,"desktop_audio_exclusion":false}}"#,
|
||||
),
|
||||
Some(PixelpassCapabilities {
|
||||
strict_app_audio: true,
|
||||
desktop_audio_exclusion: false,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse_pixelpass_capabilities(
|
||||
br#"{"schema_version":1,"capabilities":{"strict_app_audio":false,"desktop_audio_exclusion":true}}"#,
|
||||
),
|
||||
Some(PixelpassCapabilities {
|
||||
strict_app_audio: false,
|
||||
desktop_audio_exclusion: true,
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
parse_pixelpass_capabilities(
|
||||
br#"{"schema_version":2,"capabilities":{"strict_app_audio":true,"desktop_audio_exclusion":true}}"#,
|
||||
)
|
||||
.is_none(),
|
||||
"an unknown schema must not advertise the new mode"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_fake_pixelpass(dir: &Path, name: &str, body: &str) -> PathBuf {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, body).unwrap();
|
||||
let mut permissions = std::fs::metadata(&path).unwrap().permissions();
|
||||
permissions.set_mode(0o755);
|
||||
std::fs::set_permissions(&path, permissions).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn old_pixelpass_help_fallback_cannot_advertise_desktop_exclusion() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"peerspeak-phase8-old-pixelpass-{}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let bin = write_fake_pixelpass(
|
||||
&dir,
|
||||
"pixelpass-old",
|
||||
"#!/bin/sh\nif [ \"$1\" = \"--capabilities\" ]; then exit 2; fi\nprintf '%s\\n' 'Options: --app <APP> --strict-audio --output <OUTPUT>'\n",
|
||||
);
|
||||
|
||||
let capabilities = probe_pixelpass_capabilities(&bin).await;
|
||||
assert!(capabilities.strict_app_audio);
|
||||
assert!(!capabilities.desktop_audio_exclusion);
|
||||
assert_eq!(
|
||||
host_args_for_selection(
|
||||
&ShareAudioSelection::DesktopShared,
|
||||
None,
|
||||
&ScreenShareSettings::default(),
|
||||
ShareQuality::Auto,
|
||||
),
|
||||
vec!["--host", "--output", "json"],
|
||||
"old-PixelPass fallback must emit no new flags"
|
||||
);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn capability_cache_reprobes_when_the_resolved_binary_changes() {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("peerspeak-phase8-rebind-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let new_bin = write_fake_pixelpass(
|
||||
&dir,
|
||||
"pixelpass-new",
|
||||
"#!/bin/sh\nprintf '%s\\n' '{\"schema_version\":1,\"capabilities\":{\"strict_app_audio\":true,\"desktop_audio_exclusion\":true}}'\n",
|
||||
);
|
||||
let old_bin = write_fake_pixelpass(
|
||||
&dir,
|
||||
"pixelpass-old",
|
||||
"#!/bin/sh\nif [ \"$1\" = \"--capabilities\" ]; then exit 2; fi\nprintf '%s\\n' 'Options: --output <OUTPUT>'\n",
|
||||
);
|
||||
|
||||
let mut cached = None;
|
||||
let first = capabilities_for_resolved_binary(&new_bin, &mut cached).await;
|
||||
assert!(first.desktop_audio_exclusion);
|
||||
assert_eq!(cached.as_ref().unwrap().binary, new_bin);
|
||||
|
||||
let rebound = capabilities_for_resolved_binary(&old_bin, &mut cached).await;
|
||||
assert!(!rebound.desktop_audio_exclusion);
|
||||
assert!(!rebound.strict_app_audio);
|
||||
assert_eq!(cached.as_ref().unwrap().binary, old_bin);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
|
||||
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
|
||||
@@ -1334,6 +1685,64 @@ Install hint: sudo apt install gstreamer1.0-plugins-bad
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_all_version_one_audio_exclusion_statuses_exactly() {
|
||||
assert_eq!(
|
||||
parse_pixelpass_event(
|
||||
r#"{"event":"stream_unsupported","version":1,"stream_serial":4294967303,"reason":"port-exclusive"}"#,
|
||||
),
|
||||
Some(PixelpassEvent::AudioExclusion(
|
||||
AudioExclusionStatus::StreamUnsupported {
|
||||
stream_serial: 4_294_967_303,
|
||||
reason: "port-exclusive".to_string(),
|
||||
}
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_pixelpass_event(
|
||||
r#"{"event":"stream_status_cleared","version":1,"stream_serial":4294967303}"#,
|
||||
),
|
||||
Some(PixelpassEvent::AudioExclusion(
|
||||
AudioExclusionStatus::StreamStatusCleared {
|
||||
stream_serial: 4_294_967_303,
|
||||
}
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_pixelpass_event(r#"{"event":"aec_failed","version":1,"module_index":536870919}"#,),
|
||||
Some(PixelpassEvent::AudioExclusion(
|
||||
AudioExclusionStatus::AecFailed {
|
||||
module_index: 536_870_919,
|
||||
}
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_pixelpass_event(
|
||||
r#"{"event":"aec_revoked","version":1,"module_index":536870919}"#,
|
||||
),
|
||||
Some(PixelpassEvent::AudioExclusion(
|
||||
AudioExclusionStatus::AecRevoked {
|
||||
module_index: 536_870_919,
|
||||
}
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_pixelpass_event(
|
||||
r#"{"event":"foreign_aec_warning","version":1,"link_group":"echo-cancel-9999-13"}"#,
|
||||
),
|
||||
Some(PixelpassEvent::AudioExclusion(
|
||||
AudioExclusionStatus::ForeignAecWarning {
|
||||
link_group: "echo-cancel-9999-13".to_string(),
|
||||
}
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_pixelpass_event(r#"{"event":"aec_failed","version":2,"module_index":536870919}"#,),
|
||||
Some(PixelpassEvent::Other),
|
||||
"an unknown wire version must not be misinterpreted as version 1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognized_but_unused_event_is_other() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -435,13 +435,10 @@ where
|
||||
|
||||
let was_hovered = self.hovered_link.is_some();
|
||||
self.hovered_link = local_position.and_then(|position| {
|
||||
state.paragraph.hit_span(position).and_then(|span| {
|
||||
if spans.get(span)?.link.is_some() {
|
||||
Some(span)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
state
|
||||
.paragraph
|
||||
.hit_span(position)
|
||||
.filter(|&span| spans.get(span).is_some_and(|span| span.link.is_some()))
|
||||
});
|
||||
|
||||
if was_hovered != self.hovered_link.is_some() {
|
||||
|
||||
@@ -22,6 +22,7 @@ use std::time::Duration;
|
||||
|
||||
use peerspeak::core::CoreController;
|
||||
use peerspeak::core::messages::{CoreCommand, UiEvent};
|
||||
use peerspeak::screenshare::ShareAudioSelection;
|
||||
|
||||
const EVENT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
/// How long to listen for events that must NOT arrive. Comfortably past the
|
||||
@@ -117,7 +118,7 @@ async fn a_dead_host_is_torn_down_and_a_clean_stop_stays_clean() {
|
||||
|
||||
// ── Half 1: the host dies mid-share ─────────────────────────────────────
|
||||
assert!(controller.send(CoreCommand::StartScreenShare {
|
||||
audio_app: None,
|
||||
audio: ShareAudioSelection::DesktopShared,
|
||||
settings: Default::default(),
|
||||
quality: Default::default(),
|
||||
}));
|
||||
@@ -159,7 +160,7 @@ async fn a_dead_host_is_torn_down_and_a_clean_stop_stays_clean() {
|
||||
living_host.to_string_lossy().into_owned()
|
||||
))));
|
||||
assert!(controller.send(CoreCommand::StartScreenShare {
|
||||
audio_app: None,
|
||||
audio: ShareAudioSelection::DesktopShared,
|
||||
settings: Default::default(),
|
||||
quality: Default::default(),
|
||||
}));
|
||||
@@ -211,7 +212,7 @@ async fn a_dead_host_is_torn_down_and_a_clean_stop_stays_clean() {
|
||||
// killed host's EOF passes the staleness gate and a spurious "ended
|
||||
// unexpectedly" lands on top of the ticket error (Gemini review, P2-1).
|
||||
assert!(controller.send(CoreCommand::StartScreenShare {
|
||||
audio_app: None,
|
||||
audio: ShareAudioSelection::DesktopShared,
|
||||
settings: Default::default(),
|
||||
quality: Default::default(),
|
||||
}));
|
||||
@@ -361,7 +362,7 @@ async fn a_host_fault_pulls_the_ticket_off_presence_within_the_grace() {
|
||||
.await;
|
||||
|
||||
assert!(controller.send(CoreCommand::StartScreenShare {
|
||||
audio_app: None,
|
||||
audio: ShareAudioSelection::DesktopShared,
|
||||
settings: Default::default(),
|
||||
quality: Default::default(),
|
||||
}));
|
||||
@@ -534,7 +535,7 @@ async fn stop_share_ends_the_real_host_via_sigint_within_the_grace() {
|
||||
// after its ticket (capture starts on first viewer) — exactly the state a
|
||||
// Stop Share most often hits.
|
||||
assert!(controller.send(CoreCommand::StartScreenShare {
|
||||
audio_app: None,
|
||||
audio: ShareAudioSelection::DesktopShared,
|
||||
settings: Default::default(),
|
||||
quality: Default::default(),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user