Compare commits
114
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbf6744444 | ||
|
|
e0fe47d5a9 | ||
|
|
4a844ac6fa | ||
|
|
b74ed17823 | ||
|
|
8973e5dc19 | ||
|
|
f90bee63f7 | ||
|
|
557c1030a7 | ||
|
|
66a0dd54df | ||
|
|
8206864a43 | ||
|
|
ab597c332d | ||
|
|
279903e56e | ||
|
|
65fde92628 | ||
|
|
2183084ec8 | ||
|
|
f35bab0379 | ||
|
|
31084edcfa | ||
|
|
a46c4cd20c | ||
|
|
6ead1fe9f8 | ||
|
|
d54e2b99fc | ||
|
|
87de5213fe | ||
|
|
9b6c8bb5c3 | ||
|
|
40604c716c | ||
|
|
3b92bcbe52 | ||
|
|
b6240c17c5 | ||
|
|
c1b21b32c7 | ||
|
|
b0ff20fe3f | ||
|
|
b5c03e7705 | ||
|
|
31b33e9e5a | ||
|
|
e16b7190bb | ||
|
|
c39ab081d9 | ||
|
|
646f35d3eb | ||
|
|
ff7daee34e | ||
|
|
85fdebeb66 | ||
|
|
cfc480044f | ||
|
|
6d0bf99076 | ||
|
|
04bc0a808a | ||
|
|
b0fa259187 | ||
|
|
1a746461b4 | ||
|
|
9e839ca452 | ||
|
|
f5d0333366 | ||
|
|
14fc1af716 | ||
|
|
9b9328f6a9 | ||
|
|
2d0143f1aa | ||
|
|
e8273b364e | ||
|
|
472991c11f | ||
|
|
ccd8c33f81 | ||
|
|
c876c61ec6 | ||
|
|
b1d73caedf | ||
|
|
40960c7476 | ||
|
|
0a4bb554e9 | ||
|
|
7e470fb2c5 | ||
|
|
9d685c2c48 | ||
|
|
42ffe8928c | ||
|
|
69ddc58133 | ||
|
|
09a07f5303 | ||
|
|
eb077d81f0 | ||
|
|
32131b0ccb | ||
|
|
035aa4b256 | ||
|
|
f85c0c22c7 | ||
|
|
c30418a0f5 | ||
|
|
14245cbf08 | ||
|
|
a740376ea9 | ||
|
|
e1ed89026d | ||
|
|
a7ea1fd9df | ||
|
|
7a03dee12f | ||
|
|
6f1ccf3923 | ||
|
|
8cd2d63a87 | ||
|
|
511927569b | ||
|
|
b260d57dc4 | ||
|
|
ad70ce5ea9 | ||
|
|
6c275faf28 | ||
|
|
2edd7f0fa8 | ||
|
|
f4a4dd37c9 | ||
|
|
56d0d6c2e2 | ||
|
|
675f25f266 | ||
|
|
e54d625f2a | ||
|
|
f926dbea4e | ||
|
|
24e0d0e799 | ||
|
|
e8f86b0ac2 | ||
|
|
5d519ede78 | ||
|
|
ccb183219f | ||
|
|
d23848decc | ||
|
|
48f5510699 | ||
|
|
125e44c033 | ||
|
|
57328f740c | ||
|
|
0187bc9bcf | ||
|
|
90e0dc8621 | ||
|
|
0be92f36a5 | ||
|
|
6f0fd088f6 | ||
|
|
e7ded10db8 | ||
|
|
6619bc9b0f | ||
|
|
29d8850bc5 | ||
|
|
8044a42f98 | ||
|
|
7483b9aae8 | ||
|
|
45e5d7ef37 | ||
|
|
cd127a9704 | ||
|
|
0c9d8eb9f9 | ||
|
|
7fa5d410f9 | ||
|
|
8674f907f2 | ||
|
|
25a5b597f7 | ||
|
|
54ebe96ca1 | ||
|
|
a144665f41 | ||
|
|
339a9d49e4 | ||
|
|
8d32ded412 | ||
|
|
9625511c65 | ||
|
|
e1a018fdf7 | ||
|
|
f939441e31 | ||
|
|
153febe078 | ||
|
|
ffe5a90686 | ||
|
|
74b4101d4f | ||
|
|
6e4d30bfa9 | ||
|
|
15766834f1 | ||
|
|
0a253bd919 | ||
|
|
3aa8d73ea0 | ||
|
|
8619df10d5 |
Generated
+2488
-558
File diff suppressed because it is too large
Load Diff
+59
-3
@@ -2,16 +2,37 @@
|
||||
name = "pixelpass"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "P2P screen sharing CLI over iroh + ffmpeg"
|
||||
description = "P2P screen sharing CLI over iroh"
|
||||
license = "MIT OR Apache-2.0"
|
||||
publish = false
|
||||
|
||||
# Debian/Ubuntu packaging (cargo-deb). Headless default build (no `gui` feature) —
|
||||
# that is exactly what peerspeak spawns as a child. Runtime shared-lib deps
|
||||
# (libpipewire, libc, …) are resolved by dpkg-shlibdeps via `depends = "$auto"`.
|
||||
# Build inside a Debian/Ubuntu distrobox, then `cargo deb --no-build`.
|
||||
[package.metadata.deb]
|
||||
maintainer = "mollusk <jitty+lc1iz0dc@protonmail.com>"
|
||||
section = "net"
|
||||
priority = "optional"
|
||||
# $auto covers linked shared libs (dpkg-shlibdeps). The GStreamer capture stack
|
||||
# and pactl are invoked as *subprocesses* (gst-launch-1.0 / gst-inspect-1.0 /
|
||||
# pactl), so shlibdeps can't see them — list them explicitly or a fresh Ubuntu
|
||||
# host bails at `deps::check_host_binaries` before emitting its ticket. Covers
|
||||
# both backends: pipewiresrc (Wayland), ximagesrc (X11, in plugins-good), the
|
||||
# VAAPI + software H.264 encoders, the AAC/TS mux tail, and the PulseAudio src.
|
||||
depends = "$auto, gstreamer1.0-tools, gstreamer1.0-plugins-base, gstreamer1.0-plugins-good, gstreamer1.0-plugins-bad, gstreamer1.0-plugins-ugly, gstreamer1.0-libav, gstreamer1.0-pipewire, gstreamer1.0-pulseaudio, pulseaudio-utils, x11-utils"
|
||||
recommends = "mpv"
|
||||
extended-description = "Peer-to-peer screen sharing over iroh (QUIC). Companion to peerspeak: shares a window or screen directly to a peer with no central server, driven via the CLI and its JSON event stream."
|
||||
assets = [
|
||||
["target/release/pixelpass", "usr/bin/", "755"],
|
||||
]
|
||||
|
||||
[[bin]]
|
||||
name = "pixelpass"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
iroh = "1.0.0-rc.0"
|
||||
iroh = "1.0.2"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-util", "net", "signal", "process", "sync", "time"] }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
@@ -27,11 +48,46 @@ ashpd = { version = "0.9", default-features = false, features = ["tokio"] }
|
||||
pipewire = "0.9"
|
||||
x11rb = { version = "0.13", default-features = false, features = ["allow-unsafe-code"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
iroh-tickets = "1.0.0-rc.0"
|
||||
iroh-tickets = "1.0.0"
|
||||
dialoguer = { version = "0.12", default-features = false }
|
||||
arboard = { version = "3", default-features = false, features = ["wayland-data-control"] }
|
||||
ureq = { version = "3", default-features = false, features = ["rustls"] }
|
||||
toml = "1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||
eframe = { version = "0.34.2", default-features = false, features = ["glow", "default_fonts", "wayland", "x11"], optional = true }
|
||||
# Desktop notifications on viewer join/leave. Default features give the
|
||||
# pure-Rust zbus backend (no system libdbus, no image crate).
|
||||
notify-rust = { version = "4", optional = true }
|
||||
# System-tray icon (StatusNotifierItem over D-Bus). Pure-Rust, riding the same
|
||||
# zbus stack notify-rust already pulls — no GTK, no libappindicator/C libdbus.
|
||||
ksni = { version = "0.3", optional = true }
|
||||
# Hand-rolled windowing stack for the GUI (replaces eframe::run_native) so we
|
||||
# can drop the OS window on "hide to tray" — the only way to truly hide a
|
||||
# toplevel on Wayland — and recreate it on Show. All of these are already pulled
|
||||
# in transitively by eframe; making them direct adds no new crates to vet.
|
||||
# eframe is kept for its egui re-export + icon_data PNG decoder. egui_glow needs
|
||||
# its (non-default) `winit` feature for the `EguiGlow` integration type; eframe
|
||||
# pulls egui_glow but without that feature, so we enable it here.
|
||||
egui_glow = { version = "0.34.2", default-features = false, features = ["winit", "wayland", "x11"], optional = true }
|
||||
# winit's default set minus `wayland-csd-adwaita`: KWin (and most desktop
|
||||
# compositors) draw server-side decorations, and eframe never enabled CSD
|
||||
# either, so dropping it keeps the dependency tree identical to before (no
|
||||
# sctk-adwaita / tiny-skia / ttf-parser pulled in just for a fallback titlebar).
|
||||
winit = { version = "0.30", default-features = false, features = ["rwh_06", "x11", "wayland", "wayland-dlopen"], optional = true }
|
||||
glutin = { version = "0.32", optional = true }
|
||||
glutin-winit = { version = "0.5", optional = true }
|
||||
# QR-encode the host ticket so a phone (or a second laptop with a webcam) can
|
||||
# pick it up without typing 140 chars. default-features = false to skip the
|
||||
# `image` crate dep tree — we render the modules to an `egui::ColorImage`
|
||||
# directly.
|
||||
qrcode = { version = "0.14", default-features = false, optional = true }
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
strip = "symbols"
|
||||
|
||||
[features]
|
||||
# Opt-in graphical front-end (pixelpass --gui). Default-off so the headless
|
||||
# build never pulls the GUI toolkit tree.
|
||||
gui = ["dep:eframe", "dep:notify-rust", "dep:ksni", "dep:egui_glow", "dep:winit", "dep:glutin", "dep:glutin-winit", "dep:qrcode"]
|
||||
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 mollusk
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 mollusk
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,421 @@
|
||||
# pixelpass
|
||||
|
||||
P2P screen sharing CLI for Linux. Single binary, hole-punched over
|
||||
[iroh](https://www.iroh.computer/) — no port forwarding, no signup, no
|
||||
server-side accounts. Hardware-encoded H.264 + AAC audio, viewed in
|
||||
mpv or VLC.
|
||||
|
||||
Built for people who just want to show their screen to a friend
|
||||
without spinning up a Discord call or fighting with NAT.
|
||||
|
||||
## Status
|
||||
|
||||
**v0.1.0** — verified end-to-end on the public internet (LTE relay path,
|
||||
~2s latency, real carrier-grade NAT) as of 2026-05-20.
|
||||
|
||||
Working:
|
||||
- Wayland capture via the screencast portal (KDE Plasma 6 confirmed; other
|
||||
Wayland compositors with the portal should work but are untested)
|
||||
- X11 capture via `ximagesrc` (whole screen, or a single window with
|
||||
`--window`); selected automatically, or forced with `--display-server x11`
|
||||
- VAAPI H.264 encode in GStreamer (RDNA3 confirmed; other VAAPI-capable
|
||||
GPUs should work), with a software x264 fallback via `--no-hwencode`
|
||||
- Audio capture of the default sink's monitor, with optional per-app
|
||||
routing (`--app <name>`)
|
||||
- `--repair` cleanup of orphaned PipeWire state left by a crashed host
|
||||
- `--doctor` environment diagnostic (capture/encode deps, VA-API H.264,
|
||||
viewer player, relay reachability) — see [Diagnostics](#diagnostics)
|
||||
- iroh QUIC bi-stream tunnel, direct-UDP and relay paths both verified
|
||||
- Interactive Host/View menu with clipboard auto-copy and mpv/VLC picker
|
||||
- Headless mode for scripts (`pixelpass <ticket>`)
|
||||
- Multi-viewer fanout (default 2, configurable via `--max-viewers`;
|
||||
shared gst pipeline, one broadcast channel per host)
|
||||
- First-run upstream bandwidth pre-flight, persisted to
|
||||
`~/.config/pixelpass/config.toml` and used to auto-size the default
|
||||
viewer cap
|
||||
- Quality presets (`--quality source|high|medium|low|auto`) that trade
|
||||
resolution + bitrate for upload bandwidth, plus an `Auto` mode that
|
||||
derives quality from the bandwidth pre-flight
|
||||
|
||||
Not yet built (deferred, not blocking):
|
||||
- Per-monitor selection on a multi-monitor X11 host — `ximagesrc` grabs the
|
||||
whole root canvas; single-monitor cropping needs xrandr region coords
|
||||
- `use-damage=true` CPU optimization for the X11 capture path
|
||||
|
||||
## Quick start
|
||||
|
||||
### Interactive (recommended)
|
||||
|
||||
```sh
|
||||
pixelpass
|
||||
```
|
||||
|
||||
On the host machine: pick "Host", share a monitor via the portal dialog,
|
||||
the ticket lands on your clipboard. Send it to your viewer however you
|
||||
like (chat, email, paste in a note). The same ticket works for multiple
|
||||
viewers up to your `--max-viewers` cap.
|
||||
|
||||
The very first host launch offers a one-time upstream bandwidth test
|
||||
(~5 s, ~5 MB to Cloudflare's open speed-test endpoint) so it can pick
|
||||
a sensible default for the viewer cap. You can skip it and a
|
||||
conservative default (2 viewers) is used; re-run it later with
|
||||
`pixelpass --reconfigure`.
|
||||
|
||||
On the viewer machine: run `pixelpass`, pick "View", paste the ticket,
|
||||
pick mpv or VLC. The player launches detached and the stream starts.
|
||||
|
||||
### Headless
|
||||
|
||||
```sh
|
||||
# host: prints a ticket on stdout, waits for a peer
|
||||
pixelpass
|
||||
|
||||
# viewer: skips the menu
|
||||
pixelpass <ticket>
|
||||
# then run the printed mpv command in another terminal
|
||||
```
|
||||
|
||||
### Graphical (optional)
|
||||
|
||||
A small window front-end is available in builds compiled with the `gui`
|
||||
feature (see [Build](#build)):
|
||||
|
||||
```sh
|
||||
pixelpass --gui
|
||||
```
|
||||
|
||||
Host: pick quality / max-viewers / options, click **Start hosting**, and the
|
||||
share code appears with a copy button. Connected viewers are listed with a
|
||||
**Kick** button each, and a desktop notification fires as they join or leave.
|
||||
View: paste a code, pick mpv or VLC, click **Connect** and the player launches.
|
||||
|
||||
A system-tray icon shows current status. **Settings → "Keep running in the
|
||||
tray when I close the window"** (off by default) makes the close button hide
|
||||
the window — truly, by dropping it — while any active stream keeps running in
|
||||
the child; reopen it from the tray. (Plain close still quits when the option is
|
||||
off, or when no system tray is present.)
|
||||
|
||||
The window is a thin driver — it runs the same headless `pixelpass` as a
|
||||
child process and reads its event stream, so the GUI is purely additive and
|
||||
the capture machinery is untouched by it. On a build without the feature,
|
||||
`--gui` prints a hint to rebuild with it.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Linux (Wayland or X11; the backend is autodetected)
|
||||
- A VAAPI-capable GPU and the right driver:
|
||||
- AMD: `libva-mesa-driver`
|
||||
- Intel: `intel-media-driver` (modern iGPUs) or `intel-vaapi-driver` (older)
|
||||
- NVIDIA: `libva-nvidia-driver` (untested)
|
||||
- `vainfo` from `libva-utils` should list at least one H.264 entrypoint
|
||||
- GStreamer with these plugin packages installed:
|
||||
- `gstreamer`, `gst-plugins-base`, `gst-plugins-good`, `gst-plugins-bad`,
|
||||
`gst-plugins-ugly`, `gst-libav`, `gst-plugin-va`, `gst-plugin-pipewire`
|
||||
- A player: `mpv` (recommended) or `vlc`
|
||||
- If you use VLC, two split plugin packages are also needed on Arch-family
|
||||
distros — the base `vlc` package does not pull them in:
|
||||
- `vlc-plugin-dvb` — provides the MPEG-TS demuxer (`libts_plugin.so`).
|
||||
Without it, VLC can't parse the container.
|
||||
- `vlc-plugin-ffmpeg` — provides the H.264 decoder
|
||||
(`libavcodec_plugin.so`). Without it, VLC parses the container,
|
||||
identifies the codec as H.264, then errors with
|
||||
`Codec h264 ... is not supported`.
|
||||
mpv ships its own decoder stack and doesn't share either dependency.
|
||||
- PipeWire (for screencast portal + audio capture)
|
||||
|
||||
On Arch / CachyOS / EndeavourOS:
|
||||
|
||||
```sh
|
||||
sudo pacman -S gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad \
|
||||
gst-plugins-ugly gst-libav gst-plugin-va gst-plugin-pipewire \
|
||||
libva-utils mpv
|
||||
# plus your GPU's VAAPI driver
|
||||
# plus, if you want to use VLC instead of mpv:
|
||||
sudo pacman -S vlc vlc-plugin-dvb vlc-plugin-ffmpeg
|
||||
```
|
||||
|
||||
If the viewer is running on battery, set the CPU governor to performance
|
||||
or balanced — power-saver can choke even hardware-decoded 1080p H.264.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
`pixelpass --doctor` prints a one-shot report of everything the above
|
||||
requirements cover and exits — run it on any machine before a real session:
|
||||
|
||||
```sh
|
||||
pixelpass --doctor
|
||||
```
|
||||
|
||||
It checks, and prints a `✓ / ! / ✗` line for each:
|
||||
|
||||
- **display server** — Wayland vs. X11 (autodetected), the raw session env
|
||||
vars, and the X server's vendor/version (so an xlibre server is visible)
|
||||
- **capture** — the GStreamer tools plus the source element for your backend
|
||||
(`pipewiresrc` on Wayland, `ximagesrc` on X11)
|
||||
- **encode** — whether hardware H.264 works (the `vah264enc` plugin, a DRM
|
||||
render node, and a VA-API H.264 *encode* entrypoint via `vainfo`), and
|
||||
whether the software `x264enc` fallback is available. This is the usual
|
||||
culprit when a viewer "can't connect": a GPU with no H.264 encode entrypoint
|
||||
produces no video under the default encoder — the report tells you to host
|
||||
with `--no-hwencode`
|
||||
- **mux / audio** — the TS mux + AAC + PulseAudio tail, and `pactl`
|
||||
- **viewer** — whether `mpv` or `vlc` is installed
|
||||
- **network** — binds a real endpoint and checks a relay is reachable
|
||||
|
||||
Each failing line includes a distro-aware install hint, and the closing summary
|
||||
says whether the machine can host and how. The exit code is non-zero if any
|
||||
hard requirement is missing, so it can gate a script or CI.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
cargo build --release
|
||||
./target/release/pixelpass --help
|
||||
```
|
||||
|
||||
`rustc` 1.95+ / edition 2024.
|
||||
|
||||
The optional graphical front-end (`pixelpass --gui`) is behind a default-off
|
||||
cargo feature so the headless build stays lean (it pulls the egui/eframe
|
||||
windowing stack). Build it with:
|
||||
|
||||
```sh
|
||||
cargo build --release --features gui
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
Host Viewer
|
||||
──── ──────
|
||||
Wayland portal (ashpd) ──> PipeWire fd ─┐ (X11: ximagesrc, no portal)
|
||||
│
|
||||
▼
|
||||
gst-launch: <source> -> videorate -> vah264enc/x264enc ->
|
||||
h264parse -> mpegtsmux
|
||||
(audio: pulsesrc <sink>.monitor ->
|
||||
avenc_aac -> aacparse ─┘)
|
||||
│ stdout
|
||||
▼
|
||||
tokio HTTP server (in-process, ~30 lines)
|
||||
│
|
||||
▼
|
||||
iroh QUIC bi-stream (ALPN pixelpass/0) ◄══════════►
|
||||
│
|
||||
▼
|
||||
tokio TcpListener
|
||||
on 127.0.0.1:rand
|
||||
│
|
||||
▼
|
||||
mpv / VLC HTTP client
|
||||
```
|
||||
|
||||
The viewer's player connects to a localhost HTTP server, which is
|
||||
just one end of the iroh tunnel. The host's HTTP server sits on the
|
||||
other end and streams GStreamer's stdout (an MPEG-TS containing
|
||||
H.264 + AAC) through with no demux or remux.
|
||||
|
||||
iroh handles NAT traversal: direct UDP if hole-punching succeeds,
|
||||
relay path otherwise. Both have been verified end-to-end.
|
||||
|
||||
## Why these choices
|
||||
|
||||
- **iroh over Holesail / dumbpipe / Tailscale**: single Rust dep, no Node
|
||||
runtime, no signup, no daemon — fits the "one self-contained binary"
|
||||
goal.
|
||||
- **GStreamer for capture/encode, not ffmpeg**: stride/format pitfalls
|
||||
when bridging raw video between processes; one in-process pipeline
|
||||
sidesteps them.
|
||||
- **In-process Rust HTTP server, not ffmpeg-as-server**: ffmpeg's
|
||||
`-listen 1` is one-shot and probe-budget-sensitive; the Rust task is
|
||||
pure passthrough with no codec assumptions.
|
||||
- **MPEG-TS over fragmented MP4**: every player on Linux handles it
|
||||
out of the box. AV1-in-MPEG-TS was tried and is unworkable through
|
||||
libavformat — if AV1 ever comes back, it has to ride a different
|
||||
container.
|
||||
- **VAAPI H.264 over x264**: ~5% of one CPU core instead of ~50% on
|
||||
the host's hardware.
|
||||
|
||||
## Configuration
|
||||
|
||||
`pixelpass` keeps a small TOML config at `~/.config/pixelpass/config.toml`
|
||||
(or the XDG equivalent). Right now it only stores the result of the
|
||||
bandwidth pre-flight:
|
||||
|
||||
```toml
|
||||
[bandwidth]
|
||||
status = "measured" # measured | skipped | failed | unmeasured
|
||||
upstream_mbps = 8.78 # safe estimate (raw * 0.8)
|
||||
measured_at = "2026-05-21T20:41:16Z"
|
||||
```
|
||||
|
||||
- `pixelpass --reconfigure` re-runs the test (e.g. after an ISP change).
|
||||
- Deleting the file resets pixelpass to first-run state.
|
||||
- Skip is sticky — once you skip the test, pixelpass won't ask again
|
||||
unless you reconfigure.
|
||||
|
||||
## Relay
|
||||
|
||||
By default pixelpass uses iroh's bundled relay servers to coordinate the
|
||||
P2P connection (peers still hole-punch a direct UDP path when they can; the
|
||||
relay is the fallback and the rendezvous point). You can point it at a
|
||||
different relay — a self-hosted one, or n0's staging/production servers —
|
||||
with either:
|
||||
|
||||
```bash
|
||||
pixelpass --relay https://relay.example/ # host or viewer
|
||||
PIXELPASS_RELAY=https://relay.example/ pixelpass … # env-var form
|
||||
```
|
||||
|
||||
The flag applies to both host and viewer and takes precedence over the
|
||||
environment variable. The env-var form is handy for the `--gui` front-end,
|
||||
since the GUI's child host/viewer processes inherit it; the `--gui --relay`
|
||||
flag form is forwarded to them too. Both ends must use the same relay to
|
||||
find each other.
|
||||
|
||||
## Themes
|
||||
|
||||
The `--gui` front-end ships three colour themes — **Default Dark**,
|
||||
**Catppuccin Mocha**, and **Catppuccin Latte** — and you can add your own.
|
||||
Pick one under **Settings → Appearance**; the choice is remembered.
|
||||
|
||||
A theme is a small TOML file of named colours:
|
||||
|
||||
```toml
|
||||
name = "My Theme"
|
||||
dark = true # base egui defaults to start from (dark or light)
|
||||
|
||||
window_bg = "#1b1b1f" # window background
|
||||
panel_bg = "#242429" # panels / frames
|
||||
input_bg = "#141417" # text fields, the ticket box
|
||||
text = "#e6e6ea" # primary text
|
||||
weak_text = "#a0a0a8" # hints, secondary text
|
||||
accent = "#5aa0f2" # selection, links, the active control
|
||||
button_bg = "#33333a" # buttons at rest
|
||||
button_hovered = "#44444d"
|
||||
streaming = "#6fdc8c" # "● Streaming"
|
||||
waiting = "#f2c14e" # "● Waiting for viewers…"
|
||||
success = "#6fdc8c" # "✓ Copied", valid-code confirmation
|
||||
warning = "#f0a85a" # non-fatal warnings
|
||||
error = "#f2756f" # errors
|
||||
```
|
||||
|
||||
Colours are `#rrggbb` hex strings. Any field you leave out falls back to
|
||||
Default Dark, so partial files are fine.
|
||||
|
||||
Two ways to make one:
|
||||
|
||||
- **In the app:** Settings → Appearance → *Edit / create a theme* gives you a
|
||||
colour picker per field with a live preview, and **Save** writes a `.toml`.
|
||||
- **By hand:** drop a `.toml` into `~/.config/pixelpass/themes/` (the XDG
|
||||
config dir). It appears in the picker next time you open Settings.
|
||||
|
||||
Sharing a theme is just sending someone the file. A user theme whose `name`
|
||||
matches a built-in overrides that built-in.
|
||||
|
||||
## Audio
|
||||
|
||||
By default pixelpass captures the default sink's monitor — the viewer
|
||||
hears whatever the host hears. `--app <name>` narrows that to a single
|
||||
application: pixelpass creates a per-PID null-sink and uses libpipewire to
|
||||
reroute matching `Stream/Output/Audio` nodes (by `application.name`) into
|
||||
it, so the viewer hears just that app instead of the whole desktop. In the
|
||||
interactive menu you can pick the app from a list of what's currently
|
||||
playing.
|
||||
|
||||
Microphone capture is intentionally out of scope — pixelpass is a
|
||||
screen-share tool meant to be paired with a dedicated voice app (Mumble,
|
||||
TeamSpeak, Discord, …) for two-way talk.
|
||||
|
||||
If a host crashes mid-session it can leave orphaned `pixelpass_capture_*`
|
||||
null-sinks and their paired loopbacks loaded in PipeWire. Run
|
||||
`pixelpass --repair` to unload them and exit.
|
||||
|
||||
## Display server
|
||||
|
||||
The capture backend is autodetected from the environment
|
||||
(`WAYLAND_DISPLAY` → Wayland, else `DISPLAY` → X11, else
|
||||
`XDG_SESSION_TYPE`). Override it with `--display-server wayland|x11` — for
|
||||
example to force the X11 path while running inside a Wayland session (an
|
||||
Xwayland or Xephyr `DISPLAY`).
|
||||
|
||||
- **Wayland** goes through the screencast portal: a "Share Screen?" dialog
|
||||
appears when the first viewer connects, and you pick the monitor (or
|
||||
window, with `--window`) there.
|
||||
- **X11** uses `ximagesrc` and starts silently when the first viewer
|
||||
connects — the ticket is the access control, there's no portal gate.
|
||||
`--window` runs an `xwininfo` picker (click the window you want to
|
||||
share); without it the whole root window is captured.
|
||||
|
||||
Encoding is hardware VAAPI (`vah264enc`) by default. `--no-hwencode`
|
||||
switches to software x264 (`x264enc tune=zerolatency`) for hosts without a
|
||||
working VAAPI H.264 entrypoint — higher CPU, no GPU needed. This applies
|
||||
to both backends.
|
||||
|
||||
## Multi-viewer
|
||||
|
||||
One gst capture pipeline fans out to N concurrent viewers via a
|
||||
`tokio::sync::broadcast` channel. The same ticket is reusable: as long
|
||||
as a viewer is connected, capture stays alive; when the last one
|
||||
leaves, the pipeline tears down and the portal stops streaming. A new
|
||||
viewer connecting after that re-triggers the portal dialog.
|
||||
|
||||
Capacity is bounded by upstream bandwidth (each viewer is its own
|
||||
encrypted egress). The default cap comes from the bandwidth pre-flight
|
||||
result; `--max-viewers <N>` overrides it. When the cap is hit,
|
||||
additional connections are politely refused with a "host is full"
|
||||
message and the host keeps running.
|
||||
|
||||
For more viewers, drop the per-viewer bitrate: e.g. `pixelpass
|
||||
--bitrate 2500 --max-viewers 4` fits four 2.5 Mbps streams in roughly
|
||||
12 Mbps of upstream. The `--quality` presets below are the friendlier
|
||||
way to do the same thing.
|
||||
|
||||
## Quality
|
||||
|
||||
`--quality <preset>` bundles a max video height, bitrate, and framerate —
|
||||
resolution is a quality-per-bitrate knob, so the three only make sense
|
||||
together. Quality is **host-global**: one encode pipeline fans out to every
|
||||
viewer, so the sharer picks one quality for everyone (per-viewer quality
|
||||
would need per-viewer encodes, which kills the fanout).
|
||||
|
||||
| Preset | Max height | Bitrate | fps |
|
||||
|----------|-------------------|-----------|-----|
|
||||
| `source` | native (no scale) | 6000 kbps | 30 |
|
||||
| `high` | 1080p | 4000 kbps | 30 |
|
||||
| `medium` | 720p | 2500 kbps | 30 |
|
||||
| `low` | 480p | 1000 kbps | 30 |
|
||||
| `auto` | derived (below) | derived | 30 |
|
||||
|
||||
`auto` (the default) picks the highest preset whose bitrate fits your
|
||||
measured safe upstream divided by the viewer cap — so quality is sized for
|
||||
the worst case, since it's baked in when capture starts and can't drop when
|
||||
a second viewer joins. With no `--max-viewers`, it sizes for a single
|
||||
viewer. If there's no bandwidth measurement yet, `auto` falls back to
|
||||
`medium` (run `pixelpass --reconfigure` to measure). In the interactive
|
||||
menu, omitting `--quality` shows a picker instead of assuming `auto`.
|
||||
|
||||
Downscaling preserves the source aspect ratio with square pixels and snaps
|
||||
to even dimensions (H.264 requires them). Power users can override
|
||||
individual fields: `--max-height N`, `--bitrate N`, and `--framerate N`
|
||||
each take precedence over the chosen preset's value for that field.
|
||||
|
||||
## Known limitations and gotchas
|
||||
|
||||
- **VLC needs `vlc-plugin-dvb` and `vlc-plugin-ffmpeg`** on Arch-family
|
||||
distros — the base `vlc` package doesn't pull these in, and missing
|
||||
either one breaks playback (the first kills the demuxer, the second
|
||||
kills the H.264 decoder). pixelpass warns at player-launch time if
|
||||
either plugin isn't on disk. mpv doesn't share these dependencies.
|
||||
- **Audio echo** if the host plays the stream through speakers and
|
||||
captures system audio — expected, the mic / monitor picks up the
|
||||
playback. Headphones bypass it.
|
||||
- **Late joiners see ~2 s of garbage** before the next keyframe lets
|
||||
their decoder lock. Expected behavior, not a bug.
|
||||
- **VAAPI driver must be package-tracked**, not an orphaned `.so` on
|
||||
disk. mpv's `--hwdec=auto` silently falls back to software decode
|
||||
otherwise, which then chokes on a low-power viewer.
|
||||
|
||||
## License
|
||||
|
||||
MIT OR Apache-2.0, your pick.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
@@ -0,0 +1,12 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=pixelpass
|
||||
GenericName=Screen Sharing
|
||||
Comment=P2P screen sharing over iroh — no port forwarding, no signup
|
||||
Exec=pixelpass --gui
|
||||
Icon=pixelpass
|
||||
StartupWMClass=pixelpass
|
||||
Terminal=false
|
||||
Categories=Network;RemoteAccess;
|
||||
Keywords=screen;share;sharing;remote;p2p;iroh;cast;
|
||||
StartupNotify=true
|
||||
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
|
||||
<title>pixelpass</title>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="256" y2="256" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#4338ca"/>
|
||||
<stop offset="1" stop-color="#7c3aed"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="8" y="8" width="240" height="240" rx="56" fill="url(#bg)"/>
|
||||
<!-- pixel stream: squares fading cyan -> white, "passed" toward the arrow -->
|
||||
<rect x="50.25" y="181.29" width="16" height="16" rx="3.2" fill="#2dd5ef"/>
|
||||
<rect x="67" y="124.44" width="20" height="20" rx="4" fill="#62dff2"/>
|
||||
<rect x="96.25" y="82.09" width="24" height="24" rx="4.8" fill="#98e8f6"/>
|
||||
<rect x="134.04" y="55.77" width="28" height="28" rx="5.6" fill="#c9f1f9"/>
|
||||
<path d="M 225.5 57.5 L 183.1 85.8 L 176.9 42.2 Z" fill="#f8fafc"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 875 B |
@@ -0,0 +1,3 @@
|
||||
.tools/
|
||||
AppDir/
|
||||
*.AppImage
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/sh
|
||||
# AppRun for the PixelPass AppImage.
|
||||
#
|
||||
# PixelPass is an orchestrator: it shells out to gst-launch-1.0, pactl, and a
|
||||
# player (mpv/vlc) found on the host PATH. We prepend our own usr/bin so any
|
||||
# bundled helpers win, but the host's tools remain reachable — that's why this
|
||||
# app suits AppImage (no sandbox) better than a Flatpak.
|
||||
HERE="$(dirname "$(readlink -f "$0")")"
|
||||
export PATH="$HERE/usr/bin:$PATH"
|
||||
|
||||
BIN="$HERE/usr/bin/pixelpass"
|
||||
|
||||
# With no arguments and no controlling terminal — i.e. launched from a file
|
||||
# manager or the .desktop entry — open the GUI. From a terminal, or with any
|
||||
# argument (a ticket, --host, --gui, --repair, …), pass through so the CLI and
|
||||
# the interactive menu both work.
|
||||
if [ "$#" -eq 0 ] && [ ! -t 0 ]; then
|
||||
exec "$BIN" --gui
|
||||
fi
|
||||
exec "$BIN" "$@"
|
||||
@@ -0,0 +1,72 @@
|
||||
# PixelPass AppImage
|
||||
|
||||
A "thin" AppImage: the gui-enabled `pixelpass` binary, a launcher (`AppRun`),
|
||||
and the desktop entry + icon. Run `./build-appimage.sh` to produce
|
||||
`pixelpass-<version>-x86_64.AppImage`.
|
||||
|
||||
## Why thin
|
||||
|
||||
`pixelpass` is an orchestrator — it links almost nothing (only `libpipewire`,
|
||||
which is excludelisted because it must match the host daemon) and instead
|
||||
**shells out** to `gst-launch-1.0`, `pactl`, and a player (`mpv`/`vlc`) found on
|
||||
the host `PATH`. The GUI's graphics libraries (`libGL`, `libwayland-*`,
|
||||
`libxkbcommon`, X11) are dlopen'd at runtime and are likewise on the AppImage
|
||||
excludelist — every desktop already has a matching set. So there is nothing
|
||||
useful to bundle, and bundling the graphics stack would only risk driver
|
||||
mismatches. The AppImage therefore carries just the binary.
|
||||
|
||||
This also explains why PixelPass suits AppImage better than Flatpak: the
|
||||
no-sandbox model lets the bundled binary freely spawn the host's `gst-launch`,
|
||||
`pactl`, and player, which a Flatpak sandbox would block.
|
||||
|
||||
## Host requirements
|
||||
|
||||
The AppImage runs on any reasonably current glibc-based distro that has:
|
||||
|
||||
- **GStreamer + plugins** — `gst-launch-1.0`/`gst-inspect-1.0` plus base,
|
||||
good/bad/ugly, libav, and the PipeWire plugin (the binary tells you the exact
|
||||
package names for your distro if something is missing).
|
||||
- **PipeWire** (with the PulseAudio shim, for `pactl`).
|
||||
- **A player** — `mpv` (preferred) or `vlc` — for the viewer side.
|
||||
- For X11 single-window capture: `xwininfo`.
|
||||
|
||||
These are the same dependencies the Arch package lists; the AppImage just spares
|
||||
you the Rust toolchain.
|
||||
|
||||
## Building for broad compatibility (lower glibc baseline)
|
||||
|
||||
An AppImage requires a host glibc **at least as new** as the build host's. Built
|
||||
straight on a rolling distro (e.g. CachyOS, glibc 2.43) the AppImage only runs
|
||||
on equally-new systems. Build inside an older base for wider reach. The script
|
||||
honours `CARGO_TARGET_DIR`, so an isolated toolchain won't clobber your host's
|
||||
`target/`:
|
||||
|
||||
```sh
|
||||
# One-time: an Ubuntu 24.04 distrobox (docker or podman backend).
|
||||
distrobox create --yes --image ubuntu:24.04 --name pixelpass-build
|
||||
distrobox enter pixelpass-build -- sudo apt-get update
|
||||
distrobox enter pixelpass-build -- sudo apt-get install -y \
|
||||
build-essential cmake clang libclang-dev pkg-config \
|
||||
libpipewire-0.3-dev libspa-0.2-dev curl ca-certificates file
|
||||
# Install rustup inside the box (edition 2024 needs rustc >= 1.85), then:
|
||||
distrobox enter pixelpass-build -- env \
|
||||
CARGO_TARGET_DIR=~/.cache/pixelpass-ubuntu/target \
|
||||
./packaging/appimage/build-appimage.sh
|
||||
```
|
||||
|
||||
**Why Ubuntu 24.04 and not something older:** PixelPass's `pipewire` crate
|
||||
binds the system's PipeWire headers via bindgen, and anything older than ~PW 1.0
|
||||
(e.g. Ubuntu 22.04's 0.3.48) fails to compile (missing struct fields / wrong
|
||||
types). And since PixelPass *is* a PipeWire/portal/Wayland app, it can only run
|
||||
on distros new enough to have modern PipeWire anyway — so an ancient glibc base
|
||||
buys nothing. 24.04 (glibc 2.39, PW 1.0.5) is the sweet spot.
|
||||
|
||||
The 24.04-built binary's baseline is **glibc 2.39** — and the only 2.39 symbols
|
||||
are two *weak* `pidfd_*` references from Rust std's process spawning (everything
|
||||
else is ≤ 2.35). That covers Ubuntu 24.04+, Debian 13+, Fedora 40+, and current
|
||||
rolling distros.
|
||||
|
||||
## Caveats
|
||||
|
||||
- **Hardware encode (VAAPI `vah264enc`)** uses the host GPU driver; it can't be
|
||||
bundled. The software path (`--no-hwencode`, x264) always works.
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build a "thin" PixelPass AppImage: the gui-enabled release binary plus only
|
||||
# its non-excludelisted shared libraries. The graphics stack (libGL, wayland,
|
||||
# xkbcommon, X11) is intentionally left to the host — those libs are on the
|
||||
# AppImage excludelist because they must match the host driver — and the
|
||||
# runtime tools PixelPass shells out to (gst-launch-1.0, pactl, mpv/vlc) are
|
||||
# expected on the host PATH, the same contract the Arch package documents.
|
||||
#
|
||||
# Usage: packaging/appimage/build-appimage.sh
|
||||
# Output: packaging/appimage/pixelpass-x86_64.AppImage
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo="$(cd "$here/../.." && pwd)"
|
||||
tools="$here/.tools"
|
||||
appdir="$here/AppDir"
|
||||
mkdir -p "$tools"
|
||||
|
||||
# linuxdeploy is itself an AppImage; run it without FUSE so this works on hosts
|
||||
# (and CI) that lack libfuse2.
|
||||
export APPIMAGE_EXTRACT_AND_RUN=1
|
||||
# Embed the version from Cargo.toml into the AppImage filename metadata.
|
||||
VERSION="$(grep -m1 '^version' "$repo/Cargo.toml" | sed -E 's/.*"(.*)".*/\1/')"
|
||||
export VERSION
|
||||
|
||||
echo ">> building release binary (--features gui)"
|
||||
( cd "$repo" && cargo build --release --features gui )
|
||||
# Honour CARGO_TARGET_DIR so an isolated build (e.g. inside an old-glibc
|
||||
# distrobox) doesn't have to clobber the host's target/.
|
||||
bin="${CARGO_TARGET_DIR:-$repo/target}/release/pixelpass"
|
||||
|
||||
echo ">> fetching linuxdeploy"
|
||||
ld="$tools/linuxdeploy-x86_64.AppImage"
|
||||
if [ ! -x "$ld" ]; then
|
||||
curl -fL --retry 3 -o "$ld" \
|
||||
"https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
|
||||
chmod +x "$ld"
|
||||
fi
|
||||
|
||||
echo ">> assembling AppDir"
|
||||
rm -rf "$appdir"
|
||||
mkdir -p "$appdir/usr/bin"
|
||||
install -m755 "$bin" "$appdir/usr/bin/pixelpass"
|
||||
|
||||
echo ">> running linuxdeploy (bundles libs, builds the AppImage)"
|
||||
# -e: analyse this binary for libraries to bundle (only libpipewire et al. that
|
||||
# aren't excludelisted will be copied; glibc + graphics libs are skipped).
|
||||
# -d/-i: desktop entry + icon for desktop integration.
|
||||
# --custom-apprun: our launcher that opens --gui from a file manager.
|
||||
( cd "$here" && OUTPUT="pixelpass-${VERSION}-x86_64.AppImage" "$ld" \
|
||||
--appdir "$appdir" \
|
||||
-e "$bin" \
|
||||
-d "$repo/assets/pixelpass.desktop" \
|
||||
-i "$repo/assets/pixelpass-256.png" \
|
||||
--icon-filename pixelpass \
|
||||
--custom-apprun "$here/AppRun" \
|
||||
--output appimage )
|
||||
|
||||
echo ">> done: $here/pixelpass-${VERSION}-x86_64.AppImage"
|
||||
@@ -0,0 +1,6 @@
|
||||
# makepkg build artifacts
|
||||
src/
|
||||
pkg/
|
||||
/pixelpass/
|
||||
*.pkg.tar.*
|
||||
*.log
|
||||
@@ -0,0 +1,65 @@
|
||||
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
||||
#
|
||||
# Versioned package, built from the public gitbutter repo on `main`.
|
||||
# For a tagged release, switch the source fragment to `#tag=v0.1.0`.
|
||||
|
||||
pkgname=pixelpass
|
||||
pkgver=0.1.0
|
||||
pkgrel=1
|
||||
pkgdesc='P2P screen sharing over iroh — no port forwarding, no signup'
|
||||
arch=('x86_64')
|
||||
url='https://gitbutter.xyz/mollusk/pixelpass'
|
||||
license=('MIT' 'Apache-2.0' 'OFL-1.1')
|
||||
depends=(
|
||||
'gstreamer' # gst-launch-1.0 / gst-inspect-1.0
|
||||
'gst-plugins-base' # videoscale (quality-preset downscale)
|
||||
'gst-plugins-good' # ximagesrc (X11 capture) + pulsesrc
|
||||
'gst-plugins-bad' # h264parse, mpegtsmux, aacparse
|
||||
'gst-libav' # avenc_aac (audio encode)
|
||||
'gst-plugin-va' # vah264enc (default hardware H.264 encoder)
|
||||
'libpulse' # pactl (audio routing / device control)
|
||||
'hicolor-icon-theme' # owns the scalable icon dir
|
||||
'libglvnd' # libGL for the egui (glow) GUI
|
||||
'libxkbcommon' # GUI keyboard handling (winit)
|
||||
'wayland' # GUI Wayland backend libs
|
||||
)
|
||||
optdepends=(
|
||||
'mpv: recommended stream viewer (the GUI launches mpv)'
|
||||
'vlc: alternative stream viewer'
|
||||
'gst-plugins-ugly: software x264 encoding for `pixelpass --no-hwencode`'
|
||||
'gst-plugin-pipewire: screen capture on Wayland sessions'
|
||||
'xorg-xwininfo: share a single window on X11 (`pixelpass --window`)'
|
||||
)
|
||||
makedepends=('cargo' 'git')
|
||||
options=('!lto')
|
||||
_branch='main'
|
||||
source=("$pkgname::git+https://gitbutter.xyz/mollusk/pixelpass.git#branch=$_branch")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
prepare() {
|
||||
cd "$srcdir/$pkgname"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "$srcdir/$pkgname"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
# --features gui so the .desktop launcher (pixelpass --gui) works.
|
||||
cargo build --frozen --release --features gui
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$srcdir/$pkgname"
|
||||
install -Dm0755 "target/release/$pkgname" "$pkgdir/usr/bin/$pkgname"
|
||||
install -Dm0644 assets/pixelpass.desktop \
|
||||
"$pkgdir/usr/share/applications/$pkgname.desktop"
|
||||
install -Dm0644 assets/pixelpass.svg \
|
||||
"$pkgdir/usr/share/icons/hicolor/scalable/apps/$pkgname.svg"
|
||||
install -Dm0644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||
install -Dm0644 LICENSE-MIT "$pkgdir/usr/share/licenses/$pkgname/LICENSE-MIT"
|
||||
install -Dm0644 LICENSE-APACHE "$pkgdir/usr/share/licenses/$pkgname/LICENSE-APACHE"
|
||||
install -Dm0644 assets/NotoSans-OFL.txt \
|
||||
"$pkgdir/usr/share/licenses/$pkgname/NotoSans-OFL.txt"
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# Debian / Ubuntu `.deb` build
|
||||
|
||||
This documents how the `pixelpass_*.deb` is produced. The deb **recipe itself**
|
||||
lives in-repo as the `[package.metadata.deb]` block in `Cargo.toml` (cargo-deb's
|
||||
equivalent of a PKGBUILD); this file documents only the build environment.
|
||||
|
||||
pixelpass is the screen-share companion to peerspeak and is built the same way
|
||||
in the same box. See peerspeak's `packaging/debian/README.md` for the full
|
||||
rationale behind each step — this is the short version.
|
||||
|
||||
## TL;DR
|
||||
|
||||
```sh
|
||||
distrobox enter peerspeak-bookworm -- bash -lc '
|
||||
source ~/.cargo/env
|
||||
cd ~/git/butter/pixelpass
|
||||
export CARGO_TARGET_DIR=~/.cache/cargo-deb-targets/pixelpass # MANDATORY
|
||||
cargo deb
|
||||
'
|
||||
# output: $CARGO_TARGET_DIR/debian/pixelpass_<version>-1_amd64.deb
|
||||
```
|
||||
|
||||
## Build environment
|
||||
|
||||
- **Base: the same Debian 12 (bookworm) distrobox `peerspeak-bookworm`**
|
||||
(glibc 2.36) used for peerspeak. **Never build on the Arch host** (newer glibc
|
||||
+ shared `$HOME`/`target/` would link Arch C objects into the binary).
|
||||
- **Use a box-local, pixelpass-specific `CARGO_TARGET_DIR`** (distinct from
|
||||
peerspeak's) so the two never share an artifact cache:
|
||||
`export CARGO_TARGET_DIR=~/.cache/cargo-deb-targets/pixelpass`.
|
||||
- Toolchain provisioning (rustup stable + `cargo-deb` + `build-essential`
|
||||
`pkg-config`) is identical to peerspeak's README. pixelpass itself links few
|
||||
C libraries — the heavy GStreamer stack it uses is invoked as subprocesses,
|
||||
not linked (see below), so it adds no extra `*-dev` build-deps beyond the base.
|
||||
|
||||
## Why `Depends` lists the whole GStreamer stack explicitly
|
||||
|
||||
pixelpass does its screen capture by shelling out to the GStreamer CLI
|
||||
(`gst-launch-1.0` / `gst-inspect-1.0`) and to `pactl`, **not** by linking the
|
||||
GStreamer libraries. That means `dpkg-shlibdeps` (which only sees linked `.so`
|
||||
files) cannot detect them, so `$auto` alone would ship a `.deb` whose `Depends`
|
||||
omits the entire capture stack. A fresh Ubuntu host would then fail at
|
||||
pixelpass's own `deps::check_host_binaries` startup probe — *before* it ever
|
||||
prints a connection ticket, which is exactly the field bug that motivated this.
|
||||
|
||||
So the `Cargo.toml` `depends` hard-codes the runtime stack on top of `$auto`:
|
||||
|
||||
```
|
||||
$auto, gstreamer1.0-tools, gstreamer1.0-plugins-base,
|
||||
gstreamer1.0-plugins-good, gstreamer1.0-plugins-bad,
|
||||
gstreamer1.0-plugins-ugly, gstreamer1.0-libav, gstreamer1.0-pipewire,
|
||||
gstreamer1.0-pulseaudio, pulseaudio-utils, x11-utils
|
||||
```
|
||||
|
||||
This covers both capture backends (`pipewiresrc` on Wayland, `ximagesrc` on X11
|
||||
from plugins-good), the VAAPI + software H.264 encoders, the AAC/TS mux tail,
|
||||
the PulseAudio source, and the `pactl`/`xdpyinfo` helpers.
|
||||
|
||||
## glibc floor
|
||||
|
||||
Same as peerspeak: built against glibc 2.36 → runs on Debian 12+ / Ubuntu
|
||||
24.04+. (pixelpass's own linked-library floor is lower, ~2.39-era, but it is
|
||||
always shipped alongside peerspeak, whose 2.36 floor governs the pair.)
|
||||
@@ -51,3 +51,39 @@ else
|
||||
echo "$MPV_LOG" | tail -30 | sed 's/^/ /'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── quality-preset downscale check ────────────────────────────────────────
|
||||
# Mirrors the videoscale step host/pipeline.rs inserts for a non-Source preset:
|
||||
# AFTER videoconvert (scaling system-memory NV12, not the raw source format) and
|
||||
# pinned to an exact even WxH computed from the source size. A 16:9 source @
|
||||
# 480p wants width 853.3 -> 852 even. Asserts the negotiated size matches and
|
||||
# the encoder accepts it. Guards both the "even-width caveat" and the
|
||||
# negotiation/placement regression that squashed the picture.
|
||||
SCALED="${TMPDIR:-/tmp}/pixelpass-smoke-scaled-$$.ts"
|
||||
trap 'rm -f "$OUT" "$SCALED"' EXIT
|
||||
echo "[smoke] downscale check: 1920x1080 -> 852x480 (videoscale after videoconvert)"
|
||||
gst-launch-1.0 -q \
|
||||
mpegtsmux name=mux ! queue ! filesink location="$SCALED" \
|
||||
videotestsrc num-buffers=30 is-live=false \
|
||||
! video/x-raw,width=1920,height=1080,framerate=30/1 \
|
||||
! videorate ! video/x-raw,framerate=30/1 \
|
||||
! queue ! videoconvert ! video/x-raw,format=NV12 \
|
||||
! videoscale ! video/x-raw,format=NV12,width=852,height=480 \
|
||||
! vah264enc rate-control=cbr bitrate=1000 key-int-max=60 \
|
||||
! h264parse config-interval=-1 \
|
||||
! video/x-h264,stream-format=byte-stream,alignment=au ! mux. \
|
||||
audiotestsrc num-buffers=47 is-live=false \
|
||||
! audioconvert ! audioresample ! audio/x-raw,rate=48000,channels=2 \
|
||||
! avenc_aac bitrate=128000 ! aacparse ! mux.
|
||||
|
||||
SCALED_DIMS=$(ffprobe -v error -select_streams v:0 -show_entries stream=width,height \
|
||||
-of csv=p=0 "$SCALED" | head -1)
|
||||
SCALED_W=${SCALED_DIMS%,*}
|
||||
SCALED_H=${SCALED_DIMS#*,}
|
||||
echo " negotiated ${SCALED_W}x${SCALED_H}"
|
||||
if [[ "$SCALED_H" == "480" && $((SCALED_W % 2)) -eq 0 && "$SCALED_W" -lt 1920 ]]; then
|
||||
echo "[smoke] PASS — downscale produced an even width below source (proportional)"
|
||||
else
|
||||
echo "[smoke] FAIL: expected even width < 1920 at height 480, got ${SCALED_W}x${SCALED_H}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
+136
-18
@@ -4,7 +4,7 @@ use clap::{Parser, ValueEnum};
|
||||
#[command(
|
||||
name = "pixelpass",
|
||||
version,
|
||||
about = "P2P screen sharing over iroh + ffmpeg",
|
||||
about = "P2P screen sharing over iroh",
|
||||
long_about = "Run with no arguments for an interactive Host/View menu. \
|
||||
Pass a ticket positionally to skip the menu and view headlessly."
|
||||
)]
|
||||
@@ -13,6 +13,12 @@ pub struct Cli {
|
||||
pub ticket: Option<String>,
|
||||
|
||||
// ── host options ──────────────────────────────────────────────────
|
||||
/// Run as host without the interactive menu. Equivalent to picking
|
||||
/// "Host" in the menu, but headless — for scripting and the --gui
|
||||
/// front-end, which drives this binary as a child process.
|
||||
#[arg(long)]
|
||||
pub host: bool,
|
||||
|
||||
/// Pick a single window instead of the whole screen.
|
||||
#[arg(long)]
|
||||
pub window: bool,
|
||||
@@ -21,29 +27,51 @@ pub struct Cli {
|
||||
#[arg(long, value_name = "NAME")]
|
||||
pub app: Option<String>,
|
||||
|
||||
/// Mix in the default microphone source.
|
||||
/// With `--app`, never fall back to whole-desktop audio. By default an
|
||||
/// app-filtered host mirrors the default sink's monitor until (and again
|
||||
/// after) the chosen app's streams route, so the viewer isn't left in
|
||||
/// silence. That fallback also captures everything else playing — including
|
||||
/// a voice call the sharer is in — so a caller can hear themselves echoed.
|
||||
/// `--strict-audio` suppresses the fallback entirely: the viewer hears only
|
||||
/// the chosen app, and silence when it isn't producing audio. Ignored
|
||||
/// without `--app`.
|
||||
#[arg(long)]
|
||||
pub mic: bool,
|
||||
pub strict_audio: bool,
|
||||
|
||||
/// Override display server autodetection.
|
||||
#[arg(long, value_enum)]
|
||||
pub display_server: Option<DisplayServerArg>,
|
||||
|
||||
/// Encode bitrate in kbps.
|
||||
#[arg(long, default_value_t = 6000)]
|
||||
pub bitrate: u32,
|
||||
/// Quality preset. Bundles a max video height, bitrate, and framerate.
|
||||
/// `auto` derives them from the saved bandwidth pre-flight (falls back to
|
||||
/// `medium` when no measurement exists). Defaults to `auto`; in the
|
||||
/// interactive menu, omitting this shows a picker instead.
|
||||
#[arg(long, value_enum)]
|
||||
pub quality: Option<Quality>,
|
||||
|
||||
/// Capture framerate.
|
||||
#[arg(long, default_value_t = 30)]
|
||||
pub framerate: u32,
|
||||
/// Cap the encoded video height (px); width follows the source aspect.
|
||||
/// Power-user override — takes precedence over the preset's height.
|
||||
#[arg(long, value_name = "N")]
|
||||
pub max_height: Option<u32>,
|
||||
|
||||
/// Encode bitrate in kbps. Overrides the quality preset's bitrate.
|
||||
#[arg(long)]
|
||||
pub bitrate: Option<u32>,
|
||||
|
||||
/// Capture framerate. Overrides the quality preset's framerate.
|
||||
#[arg(long)]
|
||||
pub framerate: Option<u32>,
|
||||
|
||||
/// Disable VAAPI HW encode; force software x264.
|
||||
#[arg(long)]
|
||||
pub no_hwencode: bool,
|
||||
|
||||
/// Use low-latency SRT transport instead of HTTP MPEG-TS (Phase 2/3).
|
||||
/// Maximum number of concurrent viewers. Additional connections are
|
||||
/// politely refused with a "host full" message. Defaults to the
|
||||
/// connection-aware recommendation from the bandwidth pre-flight if
|
||||
/// available, otherwise 2.
|
||||
#[arg(long)]
|
||||
pub low_latency: bool,
|
||||
pub max_viewers: Option<u32>,
|
||||
|
||||
// ── viewer options ────────────────────────────────────────────────
|
||||
/// Local TCP port for the viewer to expose (default: random).
|
||||
@@ -51,6 +79,24 @@ pub struct Cli {
|
||||
pub port: u16,
|
||||
|
||||
// ── global ────────────────────────────────────────────────────────
|
||||
/// Relay server URL to use instead of the bundled defaults, e.g.
|
||||
/// `https://relay.example/`. Applies to both host and viewer. Falls back
|
||||
/// to the `PIXELPASS_RELAY` environment variable. Use this to get off the
|
||||
/// pre-release default relays or to point at a self-hosted relay.
|
||||
#[arg(long, value_name = "URL")]
|
||||
pub relay: Option<String>,
|
||||
|
||||
/// Launch the graphical front-end (a window with Host/View controls)
|
||||
/// instead of the terminal menu. Requires a build with `--features gui`.
|
||||
#[arg(long)]
|
||||
pub gui: bool,
|
||||
|
||||
/// Emit machine-readable events on stdout (one JSON object per line)
|
||||
/// alongside the human banner on stderr. For scripts and the --gui
|
||||
/// front-end. Currently only `json` is supported.
|
||||
#[arg(long, value_enum, value_name = "FORMAT")]
|
||||
pub output: Option<OutputFormat>,
|
||||
|
||||
/// Trace-level logging.
|
||||
#[arg(long, short)]
|
||||
pub verbose: bool,
|
||||
@@ -58,6 +104,32 @@ pub struct Cli {
|
||||
/// Clean up orphaned PipeWire state from a crashed host run, then exit.
|
||||
#[arg(long)]
|
||||
pub repair: bool,
|
||||
|
||||
/// Print an environment diagnostic report (display server, capture/encode
|
||||
/// dependencies, VA-API H.264 support, viewer player, relay reachability),
|
||||
/// then exit. Use this to check a machine can host or view before a real
|
||||
/// session — especially to confirm hardware H.264 encode works, since a GPU
|
||||
/// without it silently produces no video under the default encoder.
|
||||
#[arg(long)]
|
||||
pub doctor: bool,
|
||||
|
||||
/// Re-run the bandwidth pre-flight test, save the result, then exit.
|
||||
/// Use this if your connection has changed (new ISP, moved house, etc.)
|
||||
/// or if the previously saved test result is stale.
|
||||
#[arg(long)]
|
||||
pub reconfigure: bool,
|
||||
|
||||
/// Run the read-only audio-exclusion dry-run audit against the live
|
||||
/// PipeWire graph, then exit on ctrl-c. Emits one JSON object per line to
|
||||
/// stderr (or to `PIXELPASS_AUDIO_AUDIT_FILE`) describing which audio
|
||||
/// streams would be eligible for a screen share and why the rest would not.
|
||||
/// Creates no links and changes no routing.
|
||||
///
|
||||
/// Hidden: this is development instrumentation for the screen-share audio
|
||||
/// exclusion work (impl plan phase 5), not a user-facing feature, and the
|
||||
/// record schema is free to change until phase 6 fixes it.
|
||||
#[arg(long, hide = true)]
|
||||
pub audit_audio: bool,
|
||||
}
|
||||
|
||||
#[derive(ValueEnum, Clone, Copy, Debug)]
|
||||
@@ -66,23 +138,60 @@ pub enum DisplayServerArg {
|
||||
X11,
|
||||
}
|
||||
|
||||
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum OutputFormat {
|
||||
/// One JSON object per line on stdout.
|
||||
Json,
|
||||
}
|
||||
|
||||
/// Quality preset. Each fixed preset bundles a (max-height, bitrate, fps)
|
||||
/// tuple — resolution is a quality-per-bitrate knob, so the three only make
|
||||
/// sense together. `Auto` has no fixed tuple; it picks one of the others from
|
||||
/// the bandwidth pre-flight at host startup. See `host::quality`.
|
||||
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Quality {
|
||||
/// Native source resolution, 6000 kbps, 30 fps (no downscale).
|
||||
Source,
|
||||
/// Up to 1080p, 4000 kbps, 30 fps.
|
||||
High,
|
||||
/// Up to 720p, 2500 kbps, 30 fps.
|
||||
Medium,
|
||||
/// Up to 480p, 1000 kbps, 30 fps.
|
||||
Low,
|
||||
/// Derive from the measured upstream; falls back to `medium` when unmeasured.
|
||||
Auto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HostOpts {
|
||||
pub window: bool,
|
||||
pub app: Option<String>,
|
||||
pub mic: bool,
|
||||
/// With `app` set, suppress the whole-desktop loopback fallback so the
|
||||
/// viewer only ever hears the chosen app (silence when it's quiet). No
|
||||
/// effect when `app` is None.
|
||||
pub strict_audio: bool,
|
||||
pub display_server: Option<DisplayServerArg>,
|
||||
pub bitrate: u32,
|
||||
pub framerate: u32,
|
||||
/// Chosen preset (Auto = derive at startup). Defaults to Auto.
|
||||
pub quality: Quality,
|
||||
/// Raw `--bitrate` override (kbps); None = use the preset's bitrate.
|
||||
pub bitrate: Option<u32>,
|
||||
/// Raw `--framerate` override; None = use the preset's framerate.
|
||||
pub framerate: Option<u32>,
|
||||
/// Raw `--max-height` override (px); None = use the preset's height.
|
||||
pub max_height: Option<u32>,
|
||||
pub no_hwencode: bool,
|
||||
pub low_latency: bool,
|
||||
pub max_viewers: Option<u32>,
|
||||
pub interactive: bool,
|
||||
/// Relay override (resolved from `--relay` / `PIXELPASS_RELAY`); None = defaults.
|
||||
pub relay: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ViewerOpts {
|
||||
pub port: u16,
|
||||
pub interactive: bool,
|
||||
/// Relay override (resolved from `--relay` / `PIXELPASS_RELAY`); None = defaults.
|
||||
pub relay: Option<String>,
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
@@ -90,17 +199,26 @@ impl Cli {
|
||||
HostOpts {
|
||||
window: self.window,
|
||||
app: self.app,
|
||||
mic: self.mic,
|
||||
strict_audio: self.strict_audio,
|
||||
display_server: self.display_server,
|
||||
// No `--quality` and nothing picked interactively → the documented
|
||||
// default, Auto.
|
||||
quality: self.quality.unwrap_or(Quality::Auto),
|
||||
bitrate: self.bitrate,
|
||||
framerate: self.framerate,
|
||||
max_height: self.max_height,
|
||||
no_hwencode: self.no_hwencode,
|
||||
low_latency: self.low_latency,
|
||||
max_viewers: self.max_viewers,
|
||||
interactive,
|
||||
relay: crate::common::endpoint::relay_override(self.relay.as_deref()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_viewer_opts(self, interactive: bool) -> ViewerOpts {
|
||||
ViewerOpts { port: self.port, interactive }
|
||||
ViewerOpts {
|
||||
port: self.port,
|
||||
interactive,
|
||||
relay: crate::common::endpoint::relay_override(self.relay.as_deref()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-1
@@ -1,5 +1,14 @@
|
||||
/// ALPN identifying the pixelpass wire protocol on the iroh tunnel.
|
||||
/// ALPN identifying the pixelpass video wire protocol on the iroh tunnel.
|
||||
///
|
||||
/// Bump the version suffix whenever the wire format changes. Today the wire is
|
||||
/// "raw MPEG-TS bytes copied bidirectionally," so bumps will be rare.
|
||||
pub const ALPN: &[u8] = b"pixelpass/0";
|
||||
|
||||
/// ALPN for the friends control plane — the always-on presence endpoint that
|
||||
/// carries friend requests and shared codes between peers' GUIs. Separate from
|
||||
/// [`ALPN`] so the same machine can run a control endpoint and a video endpoint
|
||||
/// without their accept loops colliding, and so a control dial never lands on a
|
||||
/// bare video host (which doesn't speak this protocol). GUI-only, like the rest
|
||||
/// of the friends stack.
|
||||
#[cfg(feature = "gui")]
|
||||
pub const CONTROL_ALPN: &[u8] = b"pixelpass/ctrl/0";
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! One-shot upstream bandwidth measurement against Cloudflare's open
|
||||
//! speed-test endpoint. POST a fixed payload, time it, derive Mbps.
|
||||
//!
|
||||
//! Run via `tokio::task::spawn_blocking` from async contexts — ureq is a
|
||||
//! blocking client and we don't want to wedge the tokio runtime during
|
||||
//! the test.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const ENDPOINT: &str = "https://speed.cloudflare.com/__up";
|
||||
const PAYLOAD_BYTES: usize = 5 * 1024 * 1024; // 5 MiB
|
||||
const HTTP_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// Multiplier applied to the raw measurement. TCP slow-start, ramp-up, and
|
||||
/// real-world contention all mean a one-shot upstream test slightly
|
||||
/// overestimates sustainable throughput; clamp to 80% for headroom.
|
||||
const SAFETY_FACTOR: f64 = 0.80;
|
||||
|
||||
/// Result of a successful measurement.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Measurement {
|
||||
/// Raw measured throughput in megabits per second.
|
||||
pub raw_mbps: f64,
|
||||
/// `raw_mbps * SAFETY_FACTOR` — the value to use when sizing things.
|
||||
pub safe_mbps: f64,
|
||||
/// How long the upload took.
|
||||
pub elapsed: Duration,
|
||||
}
|
||||
|
||||
/// Blocking upload-speed test. Call from a `spawn_blocking` task.
|
||||
pub fn measure_upstream_blocking() -> Result<Measurement> {
|
||||
let payload = vec![0u8; PAYLOAD_BYTES];
|
||||
let agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(HTTP_TIMEOUT))
|
||||
.build()
|
||||
.new_agent();
|
||||
|
||||
let start = Instant::now();
|
||||
let response = agent
|
||||
.post(ENDPOINT)
|
||||
.content_type("application/octet-stream")
|
||||
.send(&payload[..])
|
||||
.context("upload request to Cloudflare failed")?;
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("Cloudflare returned HTTP {status}");
|
||||
}
|
||||
|
||||
let bits = (PAYLOAD_BYTES as f64) * 8.0;
|
||||
let seconds = elapsed.as_secs_f64().max(0.001);
|
||||
let raw_mbps = bits / seconds / 1_000_000.0;
|
||||
let safe_mbps = raw_mbps * SAFETY_FACTOR;
|
||||
|
||||
Ok(Measurement {
|
||||
raw_mbps,
|
||||
safe_mbps,
|
||||
elapsed,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert a safe-upstream Mbps figure plus the host's per-viewer bitrate
|
||||
/// (kbps for video, ignoring audio + protocol overhead which we account for
|
||||
/// via SAFETY_FACTOR) into a recommended viewer count. Floors to at least 1.
|
||||
pub fn recommended_max_viewers(safe_mbps: f64, bitrate_kbps: u32) -> u32 {
|
||||
let per_viewer_mbps = (bitrate_kbps as f64) / 1000.0;
|
||||
// Guard non-finite / non-positive inputs (only reachable from a corrupted
|
||||
// config): a NaN safe_mbps would cast to 0 and an infinite one to u32::MAX,
|
||||
// both of which break the "at least 1" contract.
|
||||
if !safe_mbps.is_finite() || safe_mbps <= 0.0 || per_viewer_mbps <= 0.0 {
|
||||
return 1;
|
||||
}
|
||||
let n = (safe_mbps / per_viewer_mbps).floor();
|
||||
if n < 1.0 { 1 } else { n as u32 }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::recommended_max_viewers;
|
||||
|
||||
#[test]
|
||||
fn divides_bandwidth_by_per_viewer_bitrate() {
|
||||
// 8 Mbps safe / 2 Mbps each = 4 viewers.
|
||||
assert_eq!(recommended_max_viewers(8.0, 2000), 4);
|
||||
// Floors the fractional part: 7.9 / 2 = 3.95 -> 3.
|
||||
assert_eq!(recommended_max_viewers(7.9, 2000), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floors_to_at_least_one() {
|
||||
// Not even enough for one viewer still allows one (best effort).
|
||||
assert_eq!(recommended_max_viewers(0.5, 2000), 1);
|
||||
// Zero / unknown bitrate can't size a budget; floor to one.
|
||||
assert_eq!(recommended_max_viewers(8.0, 0), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_inputs_floor_to_one() {
|
||||
// A corrupted config must not yield 0 (NaN) or u32::MAX (Inf).
|
||||
assert_eq!(recommended_max_viewers(f64::NAN, 2000), 1);
|
||||
assert_eq!(recommended_max_viewers(f64::INFINITY, 2000), 1);
|
||||
assert_eq!(recommended_max_viewers(-5.0, 2000), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Persistent user-level config at `~/.config/pixelpass/config.toml`.
|
||||
//!
|
||||
//! It tracks the bandwidth pre-flight result and the GUI's preferences.
|
||||
//! Further settings can hang off the same file under their own `[section]`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub bandwidth: BandwidthEntry,
|
||||
#[serde(default)]
|
||||
pub gui: GuiSettings,
|
||||
}
|
||||
|
||||
/// Preferences for the `pixelpass --gui` front-end.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GuiSettings {
|
||||
/// When true, the window's close button hides the app to the system tray
|
||||
/// (keeping any live stream running) instead of quitting. Defaults to
|
||||
/// false — closing quits, which is what people expect.
|
||||
#[serde(default)]
|
||||
pub close_to_tray: bool,
|
||||
/// When true, the host screen renders a QR-code panel for the ticket.
|
||||
/// Defaults to true; the toggle exists for users who prefer the plain
|
||||
/// text-only host screen.
|
||||
#[serde(default = "default_true")]
|
||||
pub show_qr: bool,
|
||||
/// Name of the active GUI colour theme (a built-in, or a user file in
|
||||
/// `~/.config/pixelpass/themes/`). Defaults to the built-in Default Dark.
|
||||
#[serde(default = "default_theme")]
|
||||
pub theme: String,
|
||||
/// The display name shown to friends (in requests and shared codes).
|
||||
/// Seeded from the login name; editable in Settings.
|
||||
#[serde(default = "default_display_name")]
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
impl Default for GuiSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
close_to_tray: false,
|
||||
show_qr: true,
|
||||
theme: default_theme(),
|
||||
display_name: default_display_name(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_theme() -> String {
|
||||
"Default Dark".to_string()
|
||||
}
|
||||
|
||||
/// Seed the friends display name from the login name, falling back to a
|
||||
/// generic label when `$USER` isn't set.
|
||||
fn default_display_name() -> String {
|
||||
std::env::var("USER")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| "PixelPass user".to_string())
|
||||
}
|
||||
|
||||
/// Result of the first-run upstream measurement.
|
||||
///
|
||||
/// `status = "unmeasured"` means we've never asked the user — show the
|
||||
/// first-run dialog. `"measured"` means we have a number. `"skipped"`
|
||||
/// means the user opted out (sticky — don't ask again). `"failed"`
|
||||
/// means the last attempt errored and we should ask the user on next
|
||||
/// interactive launch whether to retry.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct BandwidthEntry {
|
||||
#[serde(default = "default_status")]
|
||||
pub status: BandwidthStatus,
|
||||
#[serde(default)]
|
||||
pub upstream_mbps: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub measured_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[derive(Default)]
|
||||
pub enum BandwidthStatus {
|
||||
#[default]
|
||||
Unmeasured,
|
||||
Measured,
|
||||
Skipped,
|
||||
Failed,
|
||||
}
|
||||
|
||||
fn default_status() -> BandwidthStatus {
|
||||
BandwidthStatus::Unmeasured
|
||||
}
|
||||
|
||||
/// Returns `~/.config/pixelpass/config.toml` (or the XDG equivalent on other
|
||||
/// platforms). The parent directory is created lazily by [`save`].
|
||||
pub fn config_path() -> Result<PathBuf> {
|
||||
let dirs = ProjectDirs::from("", "", "pixelpass")
|
||||
.context("could not locate a config directory for pixelpass")?;
|
||||
Ok(dirs.config_dir().join("config.toml"))
|
||||
}
|
||||
|
||||
/// Returns the loaded config, or a `Default` instance if the file doesn't
|
||||
/// exist yet. Bubble up parse errors so we don't silently overwrite a
|
||||
/// hand-edited config the user is debugging.
|
||||
pub fn load() -> Result<Config> {
|
||||
let path = config_path()?;
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(s) => toml::from_str::<Config>(&s)
|
||||
.with_context(|| format!("failed to parse {}", path.display())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()),
|
||||
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomic write via tempfile-in-same-dir + rename.
|
||||
pub fn save(cfg: &Config) -> Result<()> {
|
||||
let path = config_path()?;
|
||||
let parent = path
|
||||
.parent()
|
||||
.context("config path has no parent directory")?;
|
||||
fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
|
||||
let serialized = toml::to_string_pretty(cfg).context("failed to serialize config to TOML")?;
|
||||
|
||||
let tmp = parent.join(format!(".config.toml.tmp.{}", std::process::id()));
|
||||
{
|
||||
let mut f = fs::File::create(&tmp)
|
||||
.with_context(|| format!("failed to create {}", tmp.display()))?;
|
||||
f.write_all(serialized.as_bytes())
|
||||
.with_context(|| format!("failed to write {}", tmp.display()))?;
|
||||
f.sync_all().ok();
|
||||
}
|
||||
fs::rename(&tmp, &path)
|
||||
.with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
//! Friends control-plane protocol and service.
|
||||
//!
|
||||
//! This is the always-on presence channel that rides the [`CONTROL_ALPN`]
|
||||
//! endpoint (bound with the persistent identity — see
|
||||
//! [`super::endpoint::bind_control`]). It's how two peers' GUIs exchange friend
|
||||
//! requests and pushed share-codes, independent of any video session.
|
||||
//!
|
||||
//! Wire shape: **one message per connection.** The sender opens a bi-stream,
|
||||
//! writes the JSON-encoded [`ControlMsg`], and finishes its send side (EOF
|
||||
//! delimits the message — no length framing needed). The receiver reads to EOF,
|
||||
//! parses, hands the message up, then writes a one-byte [`ACK`] back so the
|
||||
//! sender knows it was delivered *and* parsed. That delivery signal is what
|
||||
//! lets the host-side code-push queue (a later phase) tell "sent" from "friend
|
||||
//! was offline." A friend's *reply* (accept/decline) is a separate later
|
||||
//! connection in the other direction, because acceptance can happen minutes
|
||||
//! after the request — not a response on the same stream.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use iroh::endpoint::{Incoming, VarInt};
|
||||
use iroh::{Endpoint, EndpointAddr, EndpointId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::alpn::CONTROL_ALPN;
|
||||
|
||||
/// Upper bound on a single control message. Generous for a display name plus a
|
||||
/// share-code ticket (~150 chars); rejects a peer trying to make us buffer a
|
||||
/// huge blob.
|
||||
const MAX_MSG: usize = 64 * 1024;
|
||||
|
||||
/// One-byte application acknowledgement the receiver returns once it has parsed
|
||||
/// a message. ASCII ACK (0x06).
|
||||
const ACK: &[u8] = b"\x06";
|
||||
|
||||
/// Bound on each phase of the send handshake, so a half-dead peer or relay
|
||||
/// can't park a sender (or an inbound handler) forever.
|
||||
const IO_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// A message on the friends control plane.
|
||||
///
|
||||
/// `#[serde(tag = "type")]` keeps the JSON self-describing and lets us add
|
||||
/// variants without breaking older peers (an unknown tag fails to parse and is
|
||||
/// logged, rather than being silently misread as another variant).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ControlMsg {
|
||||
/// "I'm online; here's my current display name." A presence/name refresh.
|
||||
Hello { name: String },
|
||||
/// Ask the recipient to become friends.
|
||||
FriendRequest { name: String },
|
||||
/// Accept a request the recipient previously sent us.
|
||||
FriendAccept { name: String },
|
||||
/// Decline a pending request, or cancel an outgoing one.
|
||||
FriendDecline,
|
||||
/// A host pushing a freshly generated share-code to an accepted friend.
|
||||
ShareCode { name: String, ticket: String },
|
||||
}
|
||||
|
||||
/// A received control message, paired with the *authenticated* sender id (the
|
||||
/// connection's verified remote public key — not a value the peer can spoof in
|
||||
/// the payload, which is why no variant carries a sender id).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Inbound {
|
||||
pub from: EndpointId,
|
||||
pub msg: ControlMsg,
|
||||
}
|
||||
|
||||
fn encode(msg: &ControlMsg) -> Result<Vec<u8>> {
|
||||
serde_json::to_vec(msg).context("failed to encode control message")
|
||||
}
|
||||
|
||||
fn decode(bytes: &[u8]) -> Result<ControlMsg> {
|
||||
serde_json::from_slice(bytes).context("failed to decode control message")
|
||||
}
|
||||
|
||||
/// Deliver one message to `peer` over `endpoint`, returning once the recipient
|
||||
/// has acknowledged it. An error means it was *not* delivered (peer offline,
|
||||
/// unreachable, or rejected the stream) — the caller can queue and retry.
|
||||
///
|
||||
/// `peer` is usually a bare [`EndpointId`] — friends store only the stable id,
|
||||
/// and n0 DNS discovery resolves it to a live address. The full [`EndpointAddr`]
|
||||
/// form exists for callers that already hold one (and for hermetic tests).
|
||||
pub async fn send(
|
||||
endpoint: &Endpoint,
|
||||
peer: impl Into<EndpointAddr>,
|
||||
msg: &ControlMsg,
|
||||
) -> Result<()> {
|
||||
let payload = encode(msg)?;
|
||||
let conn = tokio::time::timeout(IO_TIMEOUT, endpoint.connect(peer, CONTROL_ALPN))
|
||||
.await
|
||||
.context("timed out connecting to peer")?
|
||||
.context("failed to connect to peer")?;
|
||||
|
||||
let io = async {
|
||||
let (mut send, mut recv) = conn
|
||||
.open_bi()
|
||||
.await
|
||||
.context("failed to open control stream")?;
|
||||
send.write_all(&payload)
|
||||
.await
|
||||
.context("failed to write control message")?;
|
||||
send.finish().context("failed to finish control stream")?;
|
||||
// Read the peer's ACK. read_to_end returns once the peer finishes its
|
||||
// send side, so this also serves as "the peer is done with us."
|
||||
let ack = recv
|
||||
.read_to_end(ACK.len() + 1)
|
||||
.await
|
||||
.context("peer closed the control stream without acknowledging")?;
|
||||
if ack != ACK {
|
||||
bail!(
|
||||
"peer sent an unexpected acknowledgement ({} bytes)",
|
||||
ack.len()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
let result = tokio::time::timeout(IO_TIMEOUT, io)
|
||||
.await
|
||||
.context("timed out sending control message")?;
|
||||
// Clean close so the peer's `closed().await` returns promptly either way.
|
||||
conn.close(VarInt::from_u32(0), b"done");
|
||||
result
|
||||
}
|
||||
|
||||
/// Run the control-plane accept loop, forwarding every received message to
|
||||
/// `tx`. Returns when the endpoint stops accepting (i.e. it was closed).
|
||||
pub async fn serve(endpoint: Endpoint, tx: mpsc::Sender<Inbound>) {
|
||||
while let Some(incoming) = endpoint.accept().await {
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle(incoming, &tx).await {
|
||||
tracing::warn!("control: inbound connection failed: {e:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
tracing::info!("control: endpoint stopped accepting");
|
||||
}
|
||||
|
||||
async fn handle(incoming: Incoming, tx: &mpsc::Sender<Inbound>) -> Result<()> {
|
||||
let conn = incoming
|
||||
.await
|
||||
.context("inbound control connection failed")?;
|
||||
let from = conn.remote_id();
|
||||
|
||||
let msg = async {
|
||||
let (mut send, mut recv) = conn
|
||||
.accept_bi()
|
||||
.await
|
||||
.context("failed to accept control stream")?;
|
||||
let bytes = recv
|
||||
.read_to_end(MAX_MSG)
|
||||
.await
|
||||
.context("failed to read control message")?;
|
||||
let msg = decode(&bytes)?;
|
||||
// ACK only after a successful parse, so the sender's delivery signal
|
||||
// means "received and understood."
|
||||
send.write_all(ACK).await.context("failed to write ack")?;
|
||||
send.finish().context("failed to finish ack stream")?;
|
||||
Ok::<_, anyhow::Error>(msg)
|
||||
};
|
||||
|
||||
let msg = tokio::time::timeout(IO_TIMEOUT, msg)
|
||||
.await
|
||||
.context("timed out reading control message")??;
|
||||
|
||||
// Hand the message up first, so it reaches the UI promptly even when the
|
||||
// sender is slow to close (a degraded link could otherwise delay a friend
|
||||
// request / pushed code by up to IO_TIMEOUT).
|
||||
tx.send(Inbound { from, msg })
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("control: receiver dropped"))?;
|
||||
|
||||
// Then wait (briefly) for the sender's close so our ACK has flushed before
|
||||
// the connection is dropped at the end of this scope.
|
||||
let _ = tokio::time::timeout(IO_TIMEOUT, conn.closed()).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn control_msg_round_trips() {
|
||||
let cases = [
|
||||
ControlMsg::Hello {
|
||||
name: "alice".into(),
|
||||
},
|
||||
ControlMsg::FriendRequest { name: "bob".into() },
|
||||
ControlMsg::FriendAccept {
|
||||
name: "carol".into(),
|
||||
},
|
||||
ControlMsg::FriendDecline,
|
||||
ControlMsg::ShareCode {
|
||||
name: "dave".into(),
|
||||
ticket: "endpointaa…".into(),
|
||||
},
|
||||
];
|
||||
for msg in cases {
|
||||
let bytes = encode(&msg).unwrap();
|
||||
assert_eq!(decode(&bytes).unwrap(), msg);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_tag_is_rejected() {
|
||||
assert!(decode(br#"{"type":"nonsense"}"#).is_err());
|
||||
}
|
||||
|
||||
/// Bind a control-plane endpoint with a *fresh* random key, so two of them
|
||||
/// in one test get distinct ids (two real machines each have their own
|
||||
/// persistent key; `bind_control` would give both the same one here, and
|
||||
/// iroh refuses "connecting to ourself").
|
||||
async fn bind_test_control() -> Endpoint {
|
||||
iroh::Endpoint::builder(iroh::endpoint::presets::N0)
|
||||
.secret_key(iroh::SecretKey::generate())
|
||||
.alpns(vec![CONTROL_ALPN.to_vec()])
|
||||
.bind()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// End-to-end over two real iroh endpoints on this machine. Ignored by
|
||||
/// default — it binds endpoints and waits on the relay, so it's slow and
|
||||
/// network-dependent. Run with `cargo test -- --ignored control`.
|
||||
#[tokio::test]
|
||||
#[ignore = "binds real iroh endpoints; run on demand"]
|
||||
async fn loopback_delivers_and_acks() {
|
||||
let server = bind_test_control().await;
|
||||
let client = bind_test_control().await;
|
||||
// Connect by full addr so the test doesn't depend on DNS discovery.
|
||||
server.online().await;
|
||||
client.online().await;
|
||||
let server_addr = server.addr();
|
||||
|
||||
let (tx, mut rx) = mpsc::channel(4);
|
||||
let server_ep = server.clone();
|
||||
let serve_task = tokio::spawn(async move { serve(server_ep, tx).await });
|
||||
|
||||
let msg = ControlMsg::FriendRequest {
|
||||
name: "tester".into(),
|
||||
};
|
||||
// Full addr (not just the id) so the test doesn't depend on DNS discovery.
|
||||
send(&client, server_addr.clone(), &msg).await.unwrap();
|
||||
|
||||
let got = tokio::time::timeout(Duration::from_secs(15), rx.recv())
|
||||
.await
|
||||
.expect("no inbound within 15s")
|
||||
.expect("channel closed");
|
||||
assert_eq!(got.msg, msg);
|
||||
assert_eq!(got.from, client.addr().id);
|
||||
|
||||
server.close().await;
|
||||
client.close().await;
|
||||
serve_task.abort();
|
||||
}
|
||||
}
|
||||
+119
-32
@@ -2,21 +2,49 @@ use anyhow::{Result, bail};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::cli::HostOpts;
|
||||
use crate::common::display::DisplayServer;
|
||||
|
||||
pub fn check_host_binaries(display: DisplayServer) -> Result<()> {
|
||||
if display == DisplayServer::Wayland {
|
||||
require("gst-launch-1.0")?;
|
||||
require("gst-inspect-1.0")?;
|
||||
require("pactl")?;
|
||||
require_gst_element("pipewiresrc")?;
|
||||
require_gst_element("vah264enc")?;
|
||||
require_gst_element("h264parse")?;
|
||||
require_gst_element("mpegtsmux")?;
|
||||
require_gst_element("pulsesrc")?;
|
||||
require_gst_element("avenc_aac")?;
|
||||
require_gst_element("aacparse")?;
|
||||
pub fn check_host_binaries(display: DisplayServer, opts: &HostOpts) -> Result<()> {
|
||||
// Unknown is handled (and rejected) by the caller; nothing to check here.
|
||||
if display == DisplayServer::Unknown {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Shared across both backends: the gst tools, audio routing, and the
|
||||
// encode/mux tail elements.
|
||||
require("gst-launch-1.0")?;
|
||||
require("gst-inspect-1.0")?;
|
||||
require("pactl")?;
|
||||
// videoscale (downscale for the quality presets) lives in plugins-base,
|
||||
// the same package the gst tools need, so this rarely fails on its own —
|
||||
// but check it for a clear error if a partial install is missing it.
|
||||
require_gst_element("videoscale")?;
|
||||
require_gst_element("h264parse")?;
|
||||
require_gst_element("mpegtsmux")?;
|
||||
require_gst_element("pulsesrc")?;
|
||||
require_gst_element("avenc_aac")?;
|
||||
require_gst_element("aacparse")?;
|
||||
|
||||
// Encoder depends on --no-hwencode (software x264 vs hardware VAAPI).
|
||||
if opts.no_hwencode {
|
||||
require_gst_element("x264enc")?;
|
||||
} else {
|
||||
require_gst_element("vah264enc")?;
|
||||
}
|
||||
|
||||
// Per-backend video source, plus the X11 window-picker when --window is set.
|
||||
match display {
|
||||
DisplayServer::Wayland => require_gst_element("pipewiresrc")?,
|
||||
DisplayServer::X11 => {
|
||||
require_gst_element("ximagesrc")?;
|
||||
if opts.window {
|
||||
require("xwininfo")?;
|
||||
}
|
||||
}
|
||||
DisplayServer::Unknown => unreachable!("early-returned above"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -28,12 +56,7 @@ fn require(bin: &str) -> Result<PathBuf> {
|
||||
}
|
||||
|
||||
fn require_gst_element(name: &str) -> Result<()> {
|
||||
let ok = Command::new("gst-inspect-1.0")
|
||||
.args(["--exists", name])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if !ok {
|
||||
if !gst_element_exists(name) {
|
||||
bail!(
|
||||
"GStreamer element `{name}` not available.\n{}",
|
||||
install_hint_for_gst_element(name)
|
||||
@@ -42,7 +65,17 @@ fn require_gst_element(name: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn which(bin: &str) -> Option<PathBuf> {
|
||||
/// Whether a GStreamer element is registered, via `gst-inspect-1.0 --exists`.
|
||||
/// Non-bailing counterpart to [`require_gst_element`] for the `doctor` report.
|
||||
pub(crate) fn gst_element_exists(name: &str) -> bool {
|
||||
Command::new("gst-inspect-1.0")
|
||||
.args(["--exists", name])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn which(bin: &str) -> Option<PathBuf> {
|
||||
let path = std::env::var_os("PATH")?;
|
||||
for dir in std::env::split_paths(&path) {
|
||||
let candidate = dir.join(bin);
|
||||
@@ -53,60 +86,110 @@ fn which(bin: &str) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
fn install_hint_for_bin(bin: &str) -> String {
|
||||
pub(crate) fn install_hint_for_bin(bin: &str) -> String {
|
||||
let distro = detect_distro();
|
||||
let pkg = match bin {
|
||||
"gst-launch-1.0" | "gst-inspect-1.0" => match distro.as_deref() {
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gstreamer gst-plugins-base",
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||
"gstreamer gst-plugins-base"
|
||||
}
|
||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-tools",
|
||||
Some("fedora" | "nobara") => "gstreamer1 gstreamer1-plugins-base-tools",
|
||||
_ => "gstreamer + tools",
|
||||
},
|
||||
"pactl" => match distro.as_deref() {
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "libpulse",
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => "libpulse",
|
||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "pulseaudio-utils",
|
||||
Some("fedora" | "nobara") => "pulseaudio-utils",
|
||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "pulseaudio-utils",
|
||||
_ => "pulseaudio-utils (provides `pactl`)",
|
||||
},
|
||||
"xwininfo" => match distro.as_deref() {
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||
"xorg-xwininfo"
|
||||
}
|
||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "x11-utils",
|
||||
Some("fedora" | "nobara") => "xorg-x11-utils",
|
||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "xwininfo",
|
||||
_ => "xwininfo (X11 window-info utility)",
|
||||
},
|
||||
_ => bin,
|
||||
};
|
||||
install_command(&distro, pkg)
|
||||
}
|
||||
|
||||
fn install_hint_for_gst_element(name: &str) -> String {
|
||||
pub(crate) fn install_hint_for_gst_element(name: &str) -> String {
|
||||
let distro = detect_distro();
|
||||
let pkg = match name {
|
||||
"pipewiresrc" => match distro.as_deref() {
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugin-pipewire",
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||
"gst-plugin-pipewire"
|
||||
}
|
||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-pipewire",
|
||||
Some("fedora" | "nobara") => "pipewire-gstreamer",
|
||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "pipewire-gstreamer",
|
||||
_ => "the GStreamer PipeWire plugin",
|
||||
},
|
||||
"vah264enc" => match distro.as_deref() {
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugin-va",
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||
"gst-plugin-va"
|
||||
}
|
||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-bad",
|
||||
Some("fedora" | "nobara") => "gstreamer1-plugins-bad-free",
|
||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-bad",
|
||||
_ => "the GStreamer VA-API plugin (requires an H.264-capable GPU; almost all modern GPUs)",
|
||||
_ => {
|
||||
"the GStreamer VA-API plugin (requires an H.264-capable GPU; almost all modern GPUs)"
|
||||
}
|
||||
},
|
||||
"x264enc" => match distro.as_deref() {
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||
"gst-plugins-ugly"
|
||||
}
|
||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-ugly",
|
||||
Some("fedora" | "nobara") => "gstreamer1-plugins-ugly",
|
||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-ugly",
|
||||
_ => "the GStreamer x264 plugin (plugins-ugly)",
|
||||
},
|
||||
"ximagesrc" => match distro.as_deref() {
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||
"gst-plugins-good"
|
||||
}
|
||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-good",
|
||||
Some("fedora" | "nobara") => "gstreamer1-plugins-good",
|
||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-good",
|
||||
_ => "the GStreamer X11 plugin (plugins-good)",
|
||||
},
|
||||
"videoscale" => match distro.as_deref() {
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||
"gst-plugins-base"
|
||||
}
|
||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-base",
|
||||
Some("fedora" | "nobara") => "gstreamer1-plugins-base",
|
||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-base",
|
||||
_ => "the GStreamer plugins-base set",
|
||||
},
|
||||
"h264parse" | "mpegtsmux" | "aacparse" => match distro.as_deref() {
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-bad",
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||
"gst-plugins-bad"
|
||||
}
|
||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-bad",
|
||||
Some("fedora" | "nobara") => "gstreamer1-plugins-bad-free",
|
||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-bad",
|
||||
_ => "the GStreamer plugins-bad set",
|
||||
},
|
||||
"pulsesrc" => match distro.as_deref() {
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-good",
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||
"gst-plugins-good"
|
||||
}
|
||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-pulseaudio",
|
||||
Some("fedora" | "nobara") => "gstreamer1-plugins-good",
|
||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-good",
|
||||
_ => "the GStreamer PulseAudio plugin",
|
||||
},
|
||||
"avenc_aac" => match distro.as_deref() {
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-libav",
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||
"gst-libav"
|
||||
}
|
||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-libav",
|
||||
Some("fedora" | "nobara") => "gstreamer1-libav",
|
||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-libav",
|
||||
@@ -119,16 +202,20 @@ fn install_hint_for_gst_element(name: &str) -> String {
|
||||
|
||||
fn install_command(distro: &Option<String>, pkg: &str) -> String {
|
||||
let cmd = match distro.as_deref() {
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => format!("sudo pacman -S {pkg}"),
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||
format!("sudo pacman -S {pkg}")
|
||||
}
|
||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => format!("sudo apt install {pkg}"),
|
||||
Some("fedora" | "nobara") => format!("sudo dnf install {pkg}"),
|
||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => format!("sudo zypper install {pkg}"),
|
||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => {
|
||||
format!("sudo zypper install {pkg}")
|
||||
}
|
||||
_ => format!("install the `{pkg}` package via your distro's package manager"),
|
||||
};
|
||||
format!("Install hint: {cmd}")
|
||||
}
|
||||
|
||||
fn detect_distro() -> Option<String> {
|
||||
pub(crate) fn detect_distro() -> Option<String> {
|
||||
let contents = std::fs::read_to_string("/etc/os-release").ok()?;
|
||||
for line in contents.lines() {
|
||||
if let Some(rest) = line.strip_prefix("ID=") {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Shared iroh endpoint construction.
|
||||
//!
|
||||
//! Two planes, two identities:
|
||||
//!
|
||||
//! * The **video** plane (host/viewer sessions) binds with an *ephemeral*
|
||||
//! keypair — a fresh `EndpointId` per run. Each session is a throwaway tunnel,
|
||||
//! and keeping its id ephemeral means a screen-share leaks no stable
|
||||
//! fingerprint.
|
||||
//! * The **control** plane (the always-on friends presence service) binds with
|
||||
//! the machine's *persistent* identity (see [`identity`]), so peers can find
|
||||
//! and recognise each other across launches.
|
||||
//!
|
||||
//! They must use different identities because both can be live at once on the
|
||||
//! same machine (the GUI's control endpoint while a host session runs), and
|
||||
//! iroh routes by `EndpointId` — two live endpoints sharing one id would make
|
||||
//! relay delivery ambiguous.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use iroh::endpoint::presets;
|
||||
use iroh::{Endpoint, RelayMap, RelayMode, RelayUrl};
|
||||
|
||||
use super::alpn::ALPN;
|
||||
|
||||
/// Environment variable consulted when `--relay` isn't passed. Lets the GUI's
|
||||
/// child processes and scripted runs inherit a relay choice without a flag.
|
||||
pub const RELAY_ENV: &str = "PIXELPASS_RELAY";
|
||||
|
||||
/// Resolve the relay override: explicit `--relay` wins, else `PIXELPASS_RELAY`,
|
||||
/// else `None` (use the bundled defaults).
|
||||
pub fn relay_override(flag: Option<&str>) -> Option<String> {
|
||||
flag.map(str::to_owned).or_else(|| {
|
||||
std::env::var(RELAY_ENV)
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
/// Bind a **video-plane** endpoint (host/viewer) with an ephemeral identity.
|
||||
///
|
||||
/// With no `relay` override we use [`presets::N0`] — n0 DNS discovery, the
|
||||
/// library's default relays, and the chosen crypto provider. With an override
|
||||
/// we keep all of that but swap in a single custom relay via
|
||||
/// [`RelayMode::Custom`]; this is how a user gets off the rc's bundled
|
||||
/// (canary-grade) relays or points at a self-hosted one. Discovery is
|
||||
/// unchanged, so peers still resolve each other by endpoint id.
|
||||
pub async fn bind(relay: Option<&str>) -> Result<Endpoint> {
|
||||
// No `secret_key` set → iroh mints a fresh ephemeral keypair for this run.
|
||||
bind_with(relay, None, ALPN).await
|
||||
}
|
||||
|
||||
/// Bind the **control-plane** endpoint with the machine's persistent identity
|
||||
/// (see [`super::identity`]) and the friends [`super::alpn::CONTROL_ALPN`]. Its
|
||||
/// `EndpointId` is the stable id friends know you by.
|
||||
#[cfg(feature = "gui")]
|
||||
pub async fn bind_control(relay: Option<&str>) -> Result<Endpoint> {
|
||||
let secret_key = super::identity::load_or_create()?;
|
||||
bind_with(relay, Some(secret_key), super::alpn::CONTROL_ALPN).await
|
||||
}
|
||||
|
||||
/// Shared builder: optional persistent key (None → ephemeral) + the plane's ALPN.
|
||||
async fn bind_with(
|
||||
relay: Option<&str>,
|
||||
key: Option<iroh::SecretKey>,
|
||||
alpn: &[u8],
|
||||
) -> Result<Endpoint> {
|
||||
let mut builder = Endpoint::builder(presets::N0).alpns(vec![alpn.to_vec()]);
|
||||
if let Some(key) = key {
|
||||
builder = builder.secret_key(key);
|
||||
}
|
||||
|
||||
if let Some(url) = relay {
|
||||
let url = RelayUrl::from_str(url).with_context(|| {
|
||||
format!("invalid relay URL {url:?} (expected e.g. https://relay.example/)")
|
||||
})?;
|
||||
builder = builder.relay_mode(RelayMode::Custom(RelayMap::from(url)));
|
||||
}
|
||||
|
||||
builder
|
||||
.bind()
|
||||
.await
|
||||
.context("failed to bind the iroh endpoint")
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
//! Persistent friends store at `~/.config/pixelpass/friends.toml`.
|
||||
//!
|
||||
//! Kept in its own file rather than a `[friends]` section of `config.toml` so
|
||||
//! the headless CLI — which never manages friends and would round-trip the
|
||||
//! config without this knowledge — can't drop the list on a `--reconfigure`.
|
||||
//! Same reasoning as the separate `identity.key`.
|
||||
//!
|
||||
//! A friend is identified by their stable control-plane [`EndpointId`] (the id
|
||||
//! from [`super::endpoint::bind_control`]). `EndpointId` serialises as its
|
||||
//! string form in TOML, so the file is human-readable and hand-editable.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use iroh::EndpointId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Where a friendship sits in the mutual-consent handshake.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FriendState {
|
||||
/// We've sent them a request and are waiting for them to accept.
|
||||
PendingOutgoing,
|
||||
/// They've requested us; waiting for the local user to accept or decline.
|
||||
PendingIncoming,
|
||||
/// Both sides have agreed — a real friend.
|
||||
Accepted,
|
||||
}
|
||||
|
||||
/// One entry in the friends list.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Friend {
|
||||
pub id: EndpointId,
|
||||
/// Display name — seeded from the name the peer reported, locally editable.
|
||||
pub name: String,
|
||||
pub state: FriendState,
|
||||
/// Whether the host auto-shares its session code with this friend. Toggled
|
||||
/// on the host's share picker; persisted here so the choice survives a
|
||||
/// restart. Defaults to `true` so a newly added friend is included (and an
|
||||
/// older `friends.toml` without the field loads as share-with-all).
|
||||
#[serde(default = "default_share")]
|
||||
pub share: bool,
|
||||
}
|
||||
|
||||
fn default_share() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// The persisted friends list. Serialises as a TOML array of tables
|
||||
/// (`[[friends]]`).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct FriendStore {
|
||||
#[serde(default)]
|
||||
pub friends: Vec<Friend>,
|
||||
}
|
||||
|
||||
/// Returns `~/.config/pixelpass/friends.toml`. Shares the config directory with
|
||||
/// [`super::config`]; the parent is created on save.
|
||||
pub fn friends_path() -> Result<PathBuf> {
|
||||
Ok(super::config::config_path()?
|
||||
.parent()
|
||||
.context("config path has no parent directory")?
|
||||
.join("friends.toml"))
|
||||
}
|
||||
|
||||
/// Load the store, or a default (empty) one if the file doesn't exist yet.
|
||||
/// Parse errors bubble up so a hand-edit being debugged isn't silently
|
||||
/// overwritten.
|
||||
pub fn load() -> Result<FriendStore> {
|
||||
let path = friends_path()?;
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(s) => toml::from_str(&s).with_context(|| format!("failed to parse {}", path.display())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FriendStore::default()),
|
||||
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
impl FriendStore {
|
||||
/// Atomic write via tempfile-in-same-dir + rename (mirrors
|
||||
/// [`super::config::save`]).
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let path = friends_path()?;
|
||||
let parent = path
|
||||
.parent()
|
||||
.context("friends path has no parent directory")?;
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
|
||||
let serialized = toml::to_string_pretty(self).context("failed to serialize friends")?;
|
||||
let tmp = parent.join(format!(".friends.toml.tmp.{}", std::process::id()));
|
||||
{
|
||||
let mut f = fs::File::create(&tmp)
|
||||
.with_context(|| format!("failed to create {}", tmp.display()))?;
|
||||
f.write_all(serialized.as_bytes())
|
||||
.with_context(|| format!("failed to write {}", tmp.display()))?;
|
||||
f.sync_all().ok();
|
||||
}
|
||||
fs::rename(&tmp, &path)
|
||||
.with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn find(&self, id: &EndpointId) -> Option<&Friend> {
|
||||
self.friends.iter().find(|f| &f.id == id)
|
||||
}
|
||||
|
||||
pub fn find_mut(&mut self, id: &EndpointId) -> Option<&mut Friend> {
|
||||
self.friends.iter_mut().find(|f| &f.id == id)
|
||||
}
|
||||
|
||||
/// True iff this id is a fully-accepted friend — the gate the code-push
|
||||
/// (Phase 4) and "is this a known friend?" checks use.
|
||||
pub fn is_accepted(&self, id: &EndpointId) -> bool {
|
||||
matches!(
|
||||
self.find(id),
|
||||
Some(Friend {
|
||||
state: FriendState::Accepted,
|
||||
..
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/// Insert a new friend, or update an existing one's `name`/`state` in place.
|
||||
/// Returns a mutable reference to the stored entry.
|
||||
pub fn upsert(&mut self, id: EndpointId, name: String, state: FriendState) -> &mut Friend {
|
||||
if let Some(idx) = self.friends.iter().position(|f| f.id == id) {
|
||||
let f = &mut self.friends[idx];
|
||||
f.name = name;
|
||||
f.state = state;
|
||||
f
|
||||
} else {
|
||||
self.friends.push(Friend {
|
||||
id,
|
||||
name,
|
||||
state,
|
||||
share: true,
|
||||
});
|
||||
self.friends.last_mut().expect("just pushed")
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a friend by id. Returns whether an entry was removed.
|
||||
pub fn remove(&mut self, id: &EndpointId) -> bool {
|
||||
let before = self.friends.len();
|
||||
self.friends.retain(|f| &f.id != id);
|
||||
self.friends.len() != before
|
||||
}
|
||||
|
||||
/// Apply an inbound friend request. Returns `true` if the friendship is now
|
||||
/// settled at [`Accepted`] and the caller should reply with a `FriendAccept`
|
||||
/// — either because we'd already sent them a request (a mutual match) or
|
||||
/// because they're an existing friend re-announcing (we never downgrade an
|
||||
/// [`Accepted`] friend back to pending; a peer who lost their store and
|
||||
/// re-adds us just gets re-confirmed). Otherwise it's recorded as
|
||||
/// [`PendingIncoming`] for the user to act on and `false` is returned.
|
||||
///
|
||||
/// [`Accepted`]: FriendState::Accepted
|
||||
/// [`PendingIncoming`]: FriendState::PendingIncoming
|
||||
pub fn on_friend_request(&mut self, id: EndpointId, name: String) -> bool {
|
||||
match self.find(&id).map(|f| f.state) {
|
||||
Some(FriendState::PendingOutgoing | FriendState::Accepted) => {
|
||||
self.upsert(id, name, FriendState::Accepted);
|
||||
true
|
||||
}
|
||||
_ => {
|
||||
self.upsert(id, name, FriendState::PendingIncoming);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply an inbound acceptance of a request we sent. Returns `true` only if
|
||||
/// it advanced one of *our* outgoing requests to [`Accepted`]. An accept for
|
||||
/// any other state is ignored: a stranger's, or one for a peer still in
|
||||
/// [`PendingIncoming`] (their request, awaiting our decision) — honouring the
|
||||
/// latter would let a peer mark itself accepted without the local user's
|
||||
/// consent.
|
||||
///
|
||||
/// [`Accepted`]: FriendState::Accepted
|
||||
/// [`PendingIncoming`]: FriendState::PendingIncoming
|
||||
pub fn on_friend_accept(&mut self, id: EndpointId, name: String) -> bool {
|
||||
if matches!(
|
||||
self.find(&id).map(|f| f.state),
|
||||
Some(FriendState::PendingOutgoing)
|
||||
) {
|
||||
self.upsert(id, name, FriendState::Accepted);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_id() -> EndpointId {
|
||||
iroh::SecretKey::generate().public()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_toml() {
|
||||
let mut store = FriendStore::default();
|
||||
store.upsert(sample_id(), "Alice".into(), FriendState::Accepted);
|
||||
store.upsert(sample_id(), "Bob".into(), FriendState::PendingIncoming);
|
||||
|
||||
let toml = toml::to_string_pretty(&store).unwrap();
|
||||
let back: FriendStore = toml::from_str(&toml).unwrap();
|
||||
assert_eq!(back.friends, store.friends);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_friends_default_to_shared_and_survive_round_trip() {
|
||||
let mut store = FriendStore::default();
|
||||
let id = sample_id();
|
||||
store.upsert(id, "Alice".into(), FriendState::Accepted);
|
||||
assert!(store.find(&id).unwrap().share, "new friends start shared");
|
||||
|
||||
// An older friends.toml predating the field loads as share-with-all.
|
||||
let toml = format!("[[friends]]\nid = \"{id}\"\nname = \"Legacy\"\nstate = \"accepted\"\n");
|
||||
let back: FriendStore = toml::from_str(&toml).unwrap();
|
||||
assert!(back.friends[0].share);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_preserves_share_across_refresh() {
|
||||
let mut store = FriendStore::default();
|
||||
let id = sample_id();
|
||||
store.upsert(id, "Alice".into(), FriendState::Accepted);
|
||||
store.find_mut(&id).unwrap().share = false;
|
||||
// A later name/presence refresh re-upserts the same peer; the share
|
||||
// choice must not be reset by it.
|
||||
store.upsert(id, "Alice (new name)".into(), FriendState::Accepted);
|
||||
assert!(!store.find(&id).unwrap().share);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_updates_in_place() {
|
||||
let mut store = FriendStore::default();
|
||||
let id = sample_id();
|
||||
store.upsert(id, "Old".into(), FriendState::PendingOutgoing);
|
||||
store.upsert(id, "New".into(), FriendState::Accepted);
|
||||
assert_eq!(store.friends.len(), 1);
|
||||
let f = store.find(&id).unwrap();
|
||||
assert_eq!(f.name, "New");
|
||||
assert_eq!(f.state, FriendState::Accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_accepted_only_for_accepted_state() {
|
||||
let mut store = FriendStore::default();
|
||||
let pending = sample_id();
|
||||
let friend = sample_id();
|
||||
store.upsert(pending, "P".into(), FriendState::PendingOutgoing);
|
||||
store.upsert(friend, "F".into(), FriendState::Accepted);
|
||||
assert!(!store.is_accepted(&pending));
|
||||
assert!(store.is_accepted(&friend));
|
||||
assert!(!store.is_accepted(&sample_id()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_reports_whether_present() {
|
||||
let mut store = FriendStore::default();
|
||||
let id = sample_id();
|
||||
store.upsert(id, "X".into(), FriendState::Accepted);
|
||||
assert!(store.remove(&id));
|
||||
assert!(!store.remove(&id));
|
||||
assert!(store.friends.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incoming_request_from_stranger_is_pending() {
|
||||
let mut store = FriendStore::default();
|
||||
let id = sample_id();
|
||||
let mutual = store.on_friend_request(id, "Stranger".into());
|
||||
assert!(!mutual);
|
||||
assert_eq!(store.find(&id).unwrap().state, FriendState::PendingIncoming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incoming_request_matching_our_outgoing_is_mutual() {
|
||||
let mut store = FriendStore::default();
|
||||
let id = sample_id();
|
||||
// We asked them first…
|
||||
store.upsert(id, "Pal".into(), FriendState::PendingOutgoing);
|
||||
// …then their request arrives — that's a mutual match.
|
||||
let mutual = store.on_friend_request(id, "Pal".into());
|
||||
assert!(mutual);
|
||||
assert_eq!(store.find(&id).unwrap().state, FriendState::Accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_advances_known_peer_only() {
|
||||
let mut store = FriendStore::default();
|
||||
let known = sample_id();
|
||||
store.upsert(known, "Known".into(), FriendState::PendingOutgoing);
|
||||
assert!(store.on_friend_accept(known, "Known".into()));
|
||||
assert_eq!(store.find(&known).unwrap().state, FriendState::Accepted);
|
||||
// An accept from someone we never asked is ignored.
|
||||
let stranger = sample_id();
|
||||
assert!(!store.on_friend_accept(stranger, "Nope".into()));
|
||||
assert!(store.find(&stranger).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_does_not_advance_a_pending_incoming_peer() {
|
||||
// They asked us and we haven't decided yet; an unsolicited FriendAccept
|
||||
// from them must not auto-accept on our behalf (consent bypass).
|
||||
let mut store = FriendStore::default();
|
||||
let id = sample_id();
|
||||
store.upsert(id, "Theirs".into(), FriendState::PendingIncoming);
|
||||
assert!(!store.on_friend_accept(id, "Theirs".into()));
|
||||
assert_eq!(store.find(&id).unwrap().state, FriendState::PendingIncoming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_does_not_downgrade_an_accepted_friend() {
|
||||
// A current friend re-sending a request (e.g. after losing their store)
|
||||
// must stay accepted; the call signals a re-confirm rather than a
|
||||
// downgrade to pending.
|
||||
let mut store = FriendStore::default();
|
||||
let id = sample_id();
|
||||
store.upsert(id, "Pal".into(), FriendState::Accepted);
|
||||
let settled = store.on_friend_request(id, "Pal (reinstalled)".into());
|
||||
assert!(settled);
|
||||
assert_eq!(store.find(&id).unwrap().state, FriendState::Accepted);
|
||||
assert_eq!(store.find(&id).unwrap().name, "Pal (reinstalled)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Persistent node identity at `~/.config/pixelpass/identity.key`.
|
||||
//!
|
||||
//! Without this, [`super::endpoint::bind`] would let iroh mint a fresh random
|
||||
//! keypair on every launch, so a peer's `EndpointId` would change each run.
|
||||
//! The friends system identifies people by that id (it's the public key already
|
||||
//! embedded in every share code), so it must stay stable across launches — and
|
||||
//! across roles: the same machine gets the same id whether it's hosting,
|
||||
//! viewing, or just sitting in the GUI.
|
||||
//!
|
||||
//! The key is the ed25519 secret (32 bytes) stored as hex on its own line, in a
|
||||
//! `0600` file separate from `config.toml` — it's a secret, not a preference,
|
||||
//! and keeping it out of the TOML means a hand-edit or a config reset can't
|
||||
//! clobber your identity.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use iroh::SecretKey;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Returns `~/.config/pixelpass/identity.key` (or the XDG equivalent). Shares
|
||||
/// the config directory with [`super::config`]; the parent is created on save.
|
||||
pub fn identity_path() -> Result<PathBuf> {
|
||||
Ok(super::config::config_path()?
|
||||
.parent()
|
||||
.context("config path has no parent directory")?
|
||||
.join("identity.key"))
|
||||
}
|
||||
|
||||
/// Load the persisted secret key, or generate-and-save one on first run.
|
||||
///
|
||||
/// A malformed file is a hard error rather than a silent regenerate: silently
|
||||
/// minting a new identity would orphan every friend who has the old id, so we'd
|
||||
/// rather fail loud and let the user notice (and decide) than lose it quietly.
|
||||
pub fn load_or_create() -> Result<SecretKey> {
|
||||
let path = identity_path()?;
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(s) => parse_key(s.trim())
|
||||
.with_context(|| format!("failed to parse the identity key at {}", path.display())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
let key = SecretKey::generate();
|
||||
save(&key)?;
|
||||
tracing::info!(id = %key.public(), "generated a new persistent identity");
|
||||
Ok(key)
|
||||
}
|
||||
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_key(hex: &str) -> Result<SecretKey> {
|
||||
let bytes = decode_hex(hex)?;
|
||||
let arr: [u8; 32] = bytes
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("identity key must be 32 bytes (64 hex chars)"))?;
|
||||
Ok(SecretKey::from_bytes(&arr))
|
||||
}
|
||||
|
||||
/// Atomic, `0600` write: tempfile-in-same-dir, chmod, then rename. Same
|
||||
/// approach as [`super::config::save`], but with restrictive perms applied
|
||||
/// before the rename so the secret is never briefly world-readable.
|
||||
pub fn save(key: &SecretKey) -> Result<()> {
|
||||
let path = identity_path()?;
|
||||
let parent = path
|
||||
.parent()
|
||||
.context("identity path has no parent directory")?;
|
||||
fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
|
||||
let tmp = parent.join(format!(".identity.key.tmp.{}", std::process::id()));
|
||||
{
|
||||
let mut f = fs::File::create(&tmp)
|
||||
.with_context(|| format!("failed to create {}", tmp.display()))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
f.set_permissions(fs::Permissions::from_mode(0o600))
|
||||
.with_context(|| format!("failed to chmod {}", tmp.display()))?;
|
||||
}
|
||||
f.write_all(encode_hex(&key.to_bytes()).as_bytes())
|
||||
.with_context(|| format!("failed to write {}", tmp.display()))?;
|
||||
f.write_all(b"\n").ok();
|
||||
f.sync_all().ok();
|
||||
}
|
||||
fs::rename(&tmp, &path)
|
||||
.with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_hex(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
s.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn decode_hex(s: &str) -> Result<Vec<u8>> {
|
||||
if !s.len().is_multiple_of(2) {
|
||||
bail!("hex string has an odd length");
|
||||
}
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| {
|
||||
u8::from_str_radix(&s[i..i + 2], 16)
|
||||
.with_context(|| format!("invalid hex byte at offset {i}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hex_round_trips() {
|
||||
let bytes: Vec<u8> = (0u8..=255).collect();
|
||||
let encoded = encode_hex(&bytes);
|
||||
assert_eq!(encoded.len(), bytes.len() * 2);
|
||||
assert_eq!(decode_hex(&encoded).unwrap(), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_round_trips_through_hex() {
|
||||
let key = SecretKey::generate();
|
||||
let hex = encode_hex(&key.to_bytes());
|
||||
let parsed = parse_key(&hex).unwrap();
|
||||
assert_eq!(parsed.to_bytes(), key.to_bytes());
|
||||
assert_eq!(parsed.public(), key.public());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_length() {
|
||||
assert!(parse_key("dead").is_err());
|
||||
assert!(parse_key("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_odd_and_nonhex() {
|
||||
assert!(decode_hex("abc").is_err());
|
||||
assert!(decode_hex("zz").is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,19 @@
|
||||
pub mod alpn;
|
||||
pub mod bandwidth;
|
||||
pub mod config;
|
||||
// The friends stack (persistent identity + control plane) is GUI-only — a
|
||||
// headless CLI host runs no presence service — so it's gated with the feature
|
||||
// that pulls the rest of the GUI, keeping the headless build lean.
|
||||
#[cfg(feature = "gui")]
|
||||
pub mod control;
|
||||
pub mod deps;
|
||||
pub mod display;
|
||||
pub mod endpoint;
|
||||
#[cfg(feature = "gui")]
|
||||
pub mod friends;
|
||||
#[cfg(feature = "gui")]
|
||||
pub mod identity;
|
||||
pub mod output;
|
||||
pub mod process;
|
||||
pub mod signal;
|
||||
pub mod tunnel;
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Machine-readable event stream for non-interactive front-ends.
|
||||
//!
|
||||
//! When enabled with `--output json`, the host and viewer emit one JSON
|
||||
//! object per line on **stdout**. The human banner and `tracing` logs stay
|
||||
//! on **stderr**, so the two streams never interleave and a parser reading
|
||||
//! stdout sees only events. Each line is flushed immediately so a front-end
|
||||
//! reading the pipe gets events live rather than in block-buffered chunks.
|
||||
//!
|
||||
//! This is the shell-out counterpart to an in-process event channel: the
|
||||
//! `--gui` front-end re-execs this binary as `pixelpass --host --output json`
|
||||
//! and parses these lines to drive its window.
|
||||
|
||||
use std::io::Write;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
static JSON_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Turn JSON event output on. Called once at startup from `--output json`.
|
||||
pub fn set_json(enabled: bool) {
|
||||
JSON_ENABLED.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Whether the JSON event stream is on — i.e. we're being driven by a
|
||||
/// machine front-end (the `--gui` shell-out) rather than a human terminal.
|
||||
/// Gates features that only make sense under that front-end, like the
|
||||
/// stdin command channel the host reads `kick` requests from.
|
||||
pub fn json_enabled() -> bool {
|
||||
JSON_ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// One event in the stdout stream. Serialized as `{"event":"<tag>", ...}`.
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "event", rename_all = "snake_case")]
|
||||
pub enum Event<'a> {
|
||||
/// The relay-only ticket the viewer needs. Emitted once at host startup.
|
||||
Ticket { value: &'a str },
|
||||
/// One-shot host configuration summary, mirroring the banner fields.
|
||||
HostInfo {
|
||||
display_server: &'a str,
|
||||
capture: &'a str,
|
||||
quality: &'a str,
|
||||
dimensions: &'a str,
|
||||
hw_encode: bool,
|
||||
max_viewers: u32,
|
||||
max_viewers_source: &'a str,
|
||||
},
|
||||
/// A viewer joined. `id` is the viewer's endpoint id; `active` is the new
|
||||
/// total after the join.
|
||||
ViewerJoined { id: &'a str, active: u32, max: u32 },
|
||||
/// A viewer left — disconnected on their own or kicked by the host. `id`
|
||||
/// is the viewer's endpoint id; `active` is the new total after.
|
||||
ViewerLeft { id: &'a str, active: u32, max: u32 },
|
||||
/// Capture pipeline lifecycle (spawned on first viewer, torn down on last).
|
||||
Capture { state: CaptureState },
|
||||
/// A viewer was turned away (host full, or capture spawn failed).
|
||||
ViewerRefused { reason: &'a str },
|
||||
/// Viewer-side: the local player URL is ready to open.
|
||||
Connected { url: &'a str },
|
||||
/// Per-app audio routing state (only emitted when `--app` is set). `routed`
|
||||
/// = the chosen app's audio is now reaching viewers; `lost` = its last
|
||||
/// stream went away. Under `--strict-audio`, `lost` means viewers currently
|
||||
/// hear silence; without it, viewers fall back to whole-desktop audio.
|
||||
AppAudio { state: AppAudioState },
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CaptureState {
|
||||
Started,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AppAudioState {
|
||||
Routed,
|
||||
Lost,
|
||||
}
|
||||
|
||||
/// Emit one event as a JSON line on stdout, flushed. No-op unless JSON
|
||||
/// output was enabled with [`set_json`], so call sites can sprinkle these
|
||||
/// unconditionally without branching.
|
||||
pub fn emit(event: Event) {
|
||||
if !json_enabled() {
|
||||
return;
|
||||
}
|
||||
match serde_json::to_string(&event) {
|
||||
Ok(line) => {
|
||||
let mut out = std::io::stdout().lock();
|
||||
// Best-effort: a closed pipe (front-end gone) shouldn't crash the
|
||||
// host — it keeps streaming to any viewers already connected.
|
||||
let _ = writeln!(out, "{line}");
|
||||
let _ = out.flush();
|
||||
}
|
||||
Err(e) => tracing::warn!("failed to serialize event: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// The app_audio event is the wire contract peerspeak parses to drive its
|
||||
// echo warning; pin the exact shape so a rename here is caught here.
|
||||
#[test]
|
||||
fn app_audio_event_wire_shape() {
|
||||
let routed = serde_json::to_string(&Event::AppAudio {
|
||||
state: AppAudioState::Routed,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(routed, r#"{"event":"app_audio","state":"routed"}"#);
|
||||
|
||||
let lost = serde_json::to_string(&Event::AppAudio {
|
||||
state: AppAudioState::Lost,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(lost, r#"{"event":"app_audio","state":"lost"}"#);
|
||||
}
|
||||
}
|
||||
+18
-5
@@ -6,10 +6,19 @@ use std::process::{Command, Stdio};
|
||||
///
|
||||
/// The child gets its own session via `setsid(2)` and null stdio, so it
|
||||
/// survives the parent exiting and doesn't take a SIGKILL cascade when
|
||||
/// pixelpass dies. The `Child` is dropped immediately — `std::process::Child::drop`
|
||||
/// does not kill the process on Unix.
|
||||
/// pixelpass dies.
|
||||
///
|
||||
/// A detached reaper thread `wait()`s the child so it doesn't linger as a
|
||||
/// `<defunct>` zombie under a long-lived parent — the `--gui` front-end launches
|
||||
/// players itself and lives for the whole session, and `std::process::Child`
|
||||
/// (unlike tokio's) has no orphan reaping, so simply dropping the handle would
|
||||
/// leak a zombie per closed player. If the parent exits while the player is
|
||||
/// still up, the reaper thread dies with it but the `setsid`'d player survives
|
||||
/// and is reaped by init. (A double-fork would also avoid the zombie, but
|
||||
/// `fork(2)` followed by non-trivial work in this multithreaded process is
|
||||
/// unsound — the reaper thread is the safe equivalent.)
|
||||
pub fn spawn_detached(prog: &str, args: &[&str]) -> io::Result<()> {
|
||||
unsafe {
|
||||
let child = unsafe {
|
||||
Command::new(prog)
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
@@ -19,7 +28,11 @@ pub fn spawn_detached(prog: &str, args: &[&str]) -> io::Result<()> {
|
||||
nix::unistd::setsid().ok();
|
||||
Ok(())
|
||||
})
|
||||
.spawn()?;
|
||||
}
|
||||
.spawn()?
|
||||
};
|
||||
std::thread::spawn(move || {
|
||||
let mut child = child;
|
||||
let _ = child.wait();
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+20
-3
@@ -1,5 +1,15 @@
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::signal::unix::{Signal, SignalKind};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// A stream of SIGTERMs, for the callers that need to shut down cleanly when
|
||||
/// something other than a human at a terminal asks them to (`timeout`, a test
|
||||
/// harness, a service manager). Ctrl-c alone covers only the interactive case.
|
||||
pub fn terminate_stream() -> Result<Signal> {
|
||||
tokio::signal::unix::signal(SignalKind::terminate())
|
||||
.context("could not install a SIGTERM handler")
|
||||
}
|
||||
|
||||
/// Install a ctrl-c handler that triggers the returned token.
|
||||
///
|
||||
/// The first ctrl-c cancels gracefully; a second ctrl-c terminates the process.
|
||||
@@ -7,10 +17,17 @@ pub fn install_ctrl_c() -> CancellationToken {
|
||||
let token = CancellationToken::new();
|
||||
let trigger = token.clone();
|
||||
tokio::spawn(async move {
|
||||
if tokio::signal::ctrl_c().await.is_ok() {
|
||||
tracing::info!("ctrl-c received, shutting down");
|
||||
trigger.cancel();
|
||||
if let Err(e) = tokio::signal::ctrl_c().await {
|
||||
// Installing the handler failed — ctrl-c won't trigger a graceful
|
||||
// shutdown. Say so instead of failing silently; the user can still
|
||||
// kill the process, and the second-ctrl-c arm below would only fail
|
||||
// the same way, so bail out of the task.
|
||||
tracing::warn!("could not install ctrl-c handler: {e}; ctrl-c won't shut down cleanly");
|
||||
return;
|
||||
}
|
||||
tracing::info!("ctrl-c received, shutting down");
|
||||
trigger.cancel();
|
||||
|
||||
if tokio::signal::ctrl_c().await.is_ok() {
|
||||
tracing::warn!("second ctrl-c — exiting now");
|
||||
std::process::exit(130);
|
||||
|
||||
+648
@@ -0,0 +1,648 @@
|
||||
//! `pixelpass doctor` — environment diagnostics.
|
||||
//!
|
||||
//! Screen-share failures are usually not pixelpass bugs but environment gaps:
|
||||
//! a missing GStreamer plugin, an X vs. Wayland mismatch, or — the common one —
|
||||
//! a GPU/driver with no working VA-API H.264 encoder, so the default
|
||||
//! `vah264enc` pipeline never produces a byte and the viewer "can't connect."
|
||||
//! `doctor` probes all of that up front and prints one actionable report, so a
|
||||
//! remote tester can read it over a call instead of us guessing from logs. It
|
||||
//! also validates any X11/Wayland test environment we stand up.
|
||||
//!
|
||||
//! Unlike [`crate::common::deps::check_host_binaries`], which bails on the first
|
||||
//! missing dependency, doctor runs *every* check and reports them together — a
|
||||
//! diagnostic wants the whole picture, not the first failure.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::common::deps;
|
||||
use crate::common::display::DisplayServer;
|
||||
use crate::common::endpoint;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
/// Working as needed.
|
||||
Ok,
|
||||
/// Degraded but not fatal (e.g. a fallback path is available).
|
||||
Warn,
|
||||
/// Screen-sharing will not work until this is fixed.
|
||||
Fail,
|
||||
/// Neutral fact, no judgement.
|
||||
Info,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
fn icon(self) -> char {
|
||||
match self {
|
||||
Self::Ok => '✓',
|
||||
Self::Warn => '!',
|
||||
Self::Fail => '✗',
|
||||
Self::Info => '·',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One line in the report: a status, a short label, a detail, and an optional
|
||||
/// remediation hint printed on its own indented line.
|
||||
pub struct Check {
|
||||
pub status: Status,
|
||||
pub label: String,
|
||||
pub detail: String,
|
||||
pub hint: Option<String>,
|
||||
}
|
||||
|
||||
impl Check {
|
||||
fn new(status: Status, label: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status,
|
||||
label: label.into(),
|
||||
detail: detail.into(),
|
||||
hint: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn ok(label: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||
Self::new(Status::Ok, label, detail)
|
||||
}
|
||||
fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||
Self::new(Status::Warn, label, detail)
|
||||
}
|
||||
fn fail(label: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||
Self::new(Status::Fail, label, detail)
|
||||
}
|
||||
fn info(label: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||
Self::new(Status::Info, label, detail)
|
||||
}
|
||||
|
||||
fn with_hint(mut self, hint: impl Into<String>) -> Self {
|
||||
self.hint = Some(hint.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Tally of the non-trivial statuses across every section.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Summary {
|
||||
pub fails: usize,
|
||||
pub warns: usize,
|
||||
}
|
||||
|
||||
/// A named group of checks, printed under a header.
|
||||
struct Section {
|
||||
name: &'static str,
|
||||
checks: Vec<Check>,
|
||||
}
|
||||
|
||||
/// Run all diagnostics and print the report. Always prints; the process exit
|
||||
/// code is non-zero only when a hard failure (a `Fail`) was found, so scripts
|
||||
/// and CI can gate on it while a human still sees everything.
|
||||
pub async fn run(relay: Option<String>) -> Result<()> {
|
||||
let display = DisplayServer::detect();
|
||||
|
||||
let sections = vec![
|
||||
system_section(display),
|
||||
capture_section(display),
|
||||
encode_section(),
|
||||
mux_audio_section(),
|
||||
viewer_section(),
|
||||
network_section(relay.as_deref()).await,
|
||||
];
|
||||
|
||||
print_report(§ions);
|
||||
|
||||
let summary = summarize(sections.iter().flat_map(|s| s.checks.iter()));
|
||||
print_summary(summary, §ions);
|
||||
|
||||
if summary.fails > 0 {
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── sections ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn system_section(display: DisplayServer) -> Section {
|
||||
let mut checks = vec![
|
||||
Check::info(
|
||||
"pixelpass",
|
||||
format!("{} (gui: {})", env!("CARGO_PKG_VERSION"), gui_built()),
|
||||
),
|
||||
Check::info("distro", distro_detail()),
|
||||
display_check(display),
|
||||
];
|
||||
|
||||
// Probe the actual X server when one is reachable — this is where an xlibre
|
||||
// vs. Xorg difference (the thing we most want to see on a tester's box)
|
||||
// shows up. Skip it on a pure Wayland session with no X at all.
|
||||
if display == DisplayServer::X11 || std::env::var_os("DISPLAY").is_some() {
|
||||
checks.push(x_server_check());
|
||||
}
|
||||
|
||||
Section {
|
||||
name: "System",
|
||||
checks,
|
||||
}
|
||||
}
|
||||
|
||||
fn capture_section(display: DisplayServer) -> Section {
|
||||
let mut checks = vec![
|
||||
bin_check("gst-launch-1.0", "gstreamer tools"),
|
||||
bin_check("gst-inspect-1.0", "gstreamer tools"),
|
||||
];
|
||||
|
||||
match display {
|
||||
DisplayServer::Wayland => {
|
||||
checks.push(gst_check("pipewiresrc", "Wayland capture"));
|
||||
}
|
||||
DisplayServer::X11 => {
|
||||
checks.push(gst_check("ximagesrc", "X11 capture"));
|
||||
checks.push(match deps::which("xwininfo") {
|
||||
Some(p) => Check::ok("window picker", p.display().to_string())
|
||||
.with_hint("needed only for `--window` (share a single window)"),
|
||||
None => Check::info("window picker", "xwininfo not found")
|
||||
.with_hint("optional — only `--window` needs it"),
|
||||
});
|
||||
}
|
||||
DisplayServer::Unknown => {
|
||||
checks.push(
|
||||
Check::info("capture backend", "unknown — cannot probe a source element")
|
||||
.with_hint("force one with `--display-server x11|wayland` when hosting"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
name: "Capture (host)",
|
||||
checks,
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_section() -> Section {
|
||||
Section {
|
||||
name: "Encode",
|
||||
checks: vec![hardware_encode_check(), software_encode_check()],
|
||||
}
|
||||
}
|
||||
|
||||
/// The load-bearing check for the common "viewer can't connect" report: the
|
||||
/// default host pipeline uses `vah264enc`, which needs both the GStreamer VA
|
||||
/// plugin *and* a GPU/driver that actually exposes an H.264 encode entrypoint.
|
||||
/// A box with the plugin but no encode entrypoint (or no render node) produces
|
||||
/// no video — the exact silent failure `--no-hwencode` works around.
|
||||
fn hardware_encode_check() -> Check {
|
||||
if !deps::gst_element_exists("vah264enc") {
|
||||
return Check::warn("hardware H.264", "vah264enc plugin not installed").with_hint(format!(
|
||||
"{} — or just host with `--no-hwencode` (software x264)",
|
||||
deps::install_hint_for_gst_element("vah264enc")
|
||||
));
|
||||
}
|
||||
|
||||
if !has_render_node() {
|
||||
return Check::warn(
|
||||
"hardware H.264",
|
||||
"vah264enc present, but no DRM render node (/dev/dri/renderD*)",
|
||||
)
|
||||
.with_hint("GPU encode is unavailable here — host with `--no-hwencode`");
|
||||
}
|
||||
|
||||
match vainfo_output() {
|
||||
Some(out) if vainfo_has_h264_encode(&out) => Check::ok(
|
||||
"hardware H.264",
|
||||
"VA-API H.264 encode available (vah264enc)",
|
||||
),
|
||||
Some(_) => Check::warn(
|
||||
"hardware H.264",
|
||||
"vah264enc present, but VA-API reports no H.264 encode entrypoint",
|
||||
)
|
||||
.with_hint("this GPU/driver can't hardware-encode H.264 — host with `--no-hwencode`"),
|
||||
None => Check::info(
|
||||
"hardware H.264",
|
||||
"vah264enc + render node present; couldn't confirm the VA-API encode entrypoint",
|
||||
)
|
||||
.with_hint("install `vainfo` (libva-utils) to verify, or just test a real host session"),
|
||||
}
|
||||
}
|
||||
|
||||
fn software_encode_check() -> Check {
|
||||
if deps::gst_element_exists("x264enc") {
|
||||
Check::ok("software H.264", "x264enc available (`--no-hwencode`)")
|
||||
} else {
|
||||
Check::warn("software H.264", "x264enc not installed").with_hint(format!(
|
||||
"{} — the fallback for GPUs without VA-API H.264 encode",
|
||||
deps::install_hint_for_gst_element("x264enc")
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn mux_audio_section() -> Section {
|
||||
// These live in plugins-bad/-good/-libav and plugins-base; all are required
|
||||
// for either backend, so a miss here is a hard Fail.
|
||||
let tail = [
|
||||
"h264parse",
|
||||
"mpegtsmux",
|
||||
"aacparse",
|
||||
"avenc_aac",
|
||||
"pulsesrc",
|
||||
"videoscale",
|
||||
];
|
||||
let missing: Vec<&str> = tail
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|e| !deps::gst_element_exists(e))
|
||||
.collect();
|
||||
|
||||
let tail_check = if missing.is_empty() {
|
||||
Check::ok("mux + audio tail", tail.join(", "))
|
||||
} else {
|
||||
Check::fail(
|
||||
"mux + audio tail",
|
||||
format!("missing: {}", missing.join(", ")),
|
||||
)
|
||||
.with_hint(deps::install_hint_for_gst_element(missing[0]))
|
||||
};
|
||||
|
||||
Section {
|
||||
name: "Mux / audio",
|
||||
checks: vec![tail_check, bin_check("pactl", "pactl")],
|
||||
}
|
||||
}
|
||||
|
||||
fn viewer_section() -> Section {
|
||||
let mpv = deps::which("mpv");
|
||||
let vlc = deps::which("vlc");
|
||||
|
||||
let check = match (mpv, vlc) {
|
||||
(Some(p), _) => Check::ok("player", format!("mpv ({})", p.display())),
|
||||
(None, Some(p)) => Check::ok("player", format!("vlc ({})", p.display()))
|
||||
.with_hint("mpv is the recommended player; vlc needs the dvb + ffmpeg plugins"),
|
||||
(None, None) => Check::warn("player", "neither mpv nor vlc found")
|
||||
.with_hint("a viewer needs one of them; the GUI launches mpv by default"),
|
||||
};
|
||||
|
||||
Section {
|
||||
name: "Viewer",
|
||||
checks: vec![check],
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind a real video-plane endpoint and wait briefly for a relay, mirroring
|
||||
/// what a host does. Directly relevant to "couldn't connect": if this machine
|
||||
/// can't reach a relay, hole-punching to a peer is unlikely to work either.
|
||||
async fn network_section(relay: Option<&str>) -> Section {
|
||||
let check = match endpoint::bind(relay).await {
|
||||
Ok(ep) => {
|
||||
let online = tokio::time::timeout(Duration::from_secs(8), ep.online())
|
||||
.await
|
||||
.is_ok();
|
||||
let relay_count = ep.addr().addrs.iter().filter(|a| a.is_relay()).count();
|
||||
let where_ = relay.map(|r| format!(" ({r})")).unwrap_or_default();
|
||||
// Close gracefully so iroh doesn't log a scary "Endpoint dropped
|
||||
// without calling close" error into the middle of the report.
|
||||
ep.close().await;
|
||||
|
||||
if online && relay_count > 0 {
|
||||
Check::ok("relay", format!("home relay reachable{where_}"))
|
||||
} else if online {
|
||||
Check::warn(
|
||||
"relay",
|
||||
format!("endpoint online but no relay address{where_}"),
|
||||
)
|
||||
.with_hint(
|
||||
"n0 DNS discovery may still connect peers, but relay fallback is degraded",
|
||||
)
|
||||
} else {
|
||||
Check::warn("relay", format!("no relay connected within 8s{where_}")).with_hint(
|
||||
"check connectivity/firewall; peers behind NAT rely on the relay to rendezvous",
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(e) => Check::fail("relay", format!("could not bind endpoint: {e}")),
|
||||
};
|
||||
|
||||
Section {
|
||||
name: "Network",
|
||||
checks: vec![check],
|
||||
}
|
||||
}
|
||||
|
||||
// ── small check builders ────────────────────────────────────────────────────
|
||||
|
||||
fn bin_check(bin: &str, label: &str) -> Check {
|
||||
match deps::which(bin) {
|
||||
Some(p) => Check::ok(label, format!("{bin} ({})", p.display())),
|
||||
None => Check::fail(label, format!("{bin} not found on PATH"))
|
||||
.with_hint(deps::install_hint_for_bin(bin)),
|
||||
}
|
||||
}
|
||||
|
||||
fn gst_check(element: &str, label: &str) -> Check {
|
||||
if deps::gst_element_exists(element) {
|
||||
Check::ok(label, element.to_string())
|
||||
} else {
|
||||
Check::fail(
|
||||
label,
|
||||
format!("GStreamer element `{element}` not available"),
|
||||
)
|
||||
.with_hint(deps::install_hint_for_gst_element(element))
|
||||
}
|
||||
}
|
||||
|
||||
fn display_check(display: DisplayServer) -> Check {
|
||||
let env = display_env_summary();
|
||||
match display {
|
||||
DisplayServer::Wayland => Check::ok("display server", format!("Wayland ({env})")),
|
||||
DisplayServer::X11 => Check::ok("display server", format!("X11 ({env})")),
|
||||
DisplayServer::Unknown => Check::fail("display server", format!("undetected ({env})"))
|
||||
.with_hint(
|
||||
"no WAYLAND_DISPLAY/DISPLAY/XDG_SESSION_TYPE — capture can't start; \
|
||||
run inside a graphical session or pass `--display-server`",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to the X server and report its vendor + version. This is how an
|
||||
/// xlibre server distinguishes itself from stock Xorg (vendor string / release
|
||||
/// number), which is exactly what we want to see on a tester's machine.
|
||||
fn x_server_check() -> Check {
|
||||
use x11rb::connection::Connection;
|
||||
match x11rb::connect(None) {
|
||||
Ok((conn, _screen)) => {
|
||||
let setup = conn.setup();
|
||||
let vendor = String::from_utf8_lossy(&setup.vendor);
|
||||
let detail = format!(
|
||||
"vendor \"{}\", protocol {}.{}, release {}",
|
||||
vendor.trim(),
|
||||
setup.protocol_major_version,
|
||||
setup.protocol_minor_version,
|
||||
setup.release_number,
|
||||
);
|
||||
let label = "X server";
|
||||
if vendor.to_lowercase().contains("xlibre") {
|
||||
Check::info(label, format!("XLibre — {detail}"))
|
||||
} else {
|
||||
Check::info(label, detail)
|
||||
}
|
||||
}
|
||||
Err(_) => Check::info("X server", "DISPLAY set but the X server is unreachable"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── environment helpers ─────────────────────────────────────────────────────
|
||||
|
||||
fn gui_built() -> &'static str {
|
||||
if cfg!(feature = "gui") { "yes" } else { "no" }
|
||||
}
|
||||
|
||||
fn distro_detail() -> String {
|
||||
let id = deps::detect_distro();
|
||||
let pretty = os_release_field("PRETTY_NAME");
|
||||
match (id, pretty) {
|
||||
(Some(id), Some(p)) => format!("{id} ({p})"),
|
||||
(Some(id), None) => id,
|
||||
(None, Some(p)) => p,
|
||||
(None, None) => "unknown".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn os_release_field(key: &str) -> Option<String> {
|
||||
let contents = std::fs::read_to_string("/etc/os-release").ok()?;
|
||||
for line in contents.lines() {
|
||||
if let Some(rest) = line.strip_prefix(&format!("{key}=")) {
|
||||
return Some(rest.trim_matches('"').to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn display_env_summary() -> String {
|
||||
let mut parts = Vec::new();
|
||||
for var in [
|
||||
"WAYLAND_DISPLAY",
|
||||
"DISPLAY",
|
||||
"XDG_SESSION_TYPE",
|
||||
"XDG_CURRENT_DESKTOP",
|
||||
] {
|
||||
if let Some(v) = std::env::var_os(var) {
|
||||
parts.push(format!("{var}={}", v.to_string_lossy()));
|
||||
}
|
||||
}
|
||||
if parts.is_empty() {
|
||||
"no display env vars set".to_string()
|
||||
} else {
|
||||
parts.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
fn has_render_node() -> bool {
|
||||
let Ok(entries) = std::fs::read_dir("/dev/dri") else {
|
||||
return false;
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.any(|e| e.file_name().to_string_lossy().starts_with("renderD"))
|
||||
}
|
||||
|
||||
fn vainfo_output() -> Option<String> {
|
||||
deps::which("vainfo")?;
|
||||
let out = std::process::Command::new("vainfo").output().ok()?;
|
||||
// vainfo prints its profile/entrypoint table to stdout; some builds also
|
||||
// spill driver banners to stderr. Concatenate both so parsing is robust.
|
||||
let mut s = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
s.push_str(&String::from_utf8_lossy(&out.stderr));
|
||||
Some(s)
|
||||
}
|
||||
|
||||
/// Pure: does a `vainfo` dump advertise an H.264 *encode* entrypoint? vainfo
|
||||
/// lists one `VAProfile… : VAEntrypoint…` pair per line; hardware H.264 encode
|
||||
/// is any `VAProfileH264*` profile paired with an `EncSlice`/`EncSliceLP`
|
||||
/// entrypoint. VLD-only H.264 (decode) does not count.
|
||||
fn vainfo_has_h264_encode(output: &str) -> bool {
|
||||
output.lines().any(|line| {
|
||||
line.contains("VAProfileH264")
|
||||
&& (line.contains("VAEntrypointEncSlice") || line.contains("VAEntrypointEncSliceLP"))
|
||||
})
|
||||
}
|
||||
|
||||
// ── reporting ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn print_report(sections: &[Section]) {
|
||||
println!("pixelpass doctor\n");
|
||||
for section in sections {
|
||||
println!("{}", section.name);
|
||||
for check in §ion.checks {
|
||||
println!(
|
||||
" {} {:<16} {}",
|
||||
check.status.icon(),
|
||||
check.label,
|
||||
check.detail
|
||||
);
|
||||
if let Some(hint) = &check.hint {
|
||||
println!(" → {hint}");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize<'a>(checks: impl Iterator<Item = &'a Check>) -> Summary {
|
||||
let mut summary = Summary::default();
|
||||
for check in checks {
|
||||
match check.status {
|
||||
Status::Fail => summary.fails += 1,
|
||||
Status::Warn => summary.warns += 1,
|
||||
Status::Ok | Status::Info => {}
|
||||
}
|
||||
}
|
||||
summary
|
||||
}
|
||||
|
||||
fn print_summary(summary: Summary, sections: &[Section]) {
|
||||
let hosting = hosting_verdict(sections);
|
||||
let counts = match (summary.fails, summary.warns) {
|
||||
(0, 0) => "all checks passed".to_string(),
|
||||
(0, w) => format!("{w} warning{}", plural(w)),
|
||||
(f, 0) => format!("{f} failure{}", plural(f)),
|
||||
(f, w) => format!("{f} failure{}, {w} warning{}", plural(f), plural(w)),
|
||||
};
|
||||
println!("Summary: {counts}. {hosting}");
|
||||
}
|
||||
|
||||
fn plural(n: usize) -> &'static str {
|
||||
if n == 1 { "" } else { "s" }
|
||||
}
|
||||
|
||||
/// A one-line verdict on whether this box can host, and how. Reads the actual
|
||||
/// encode + capture checks rather than the raw tally so the advice is specific.
|
||||
fn hosting_verdict(sections: &[Section]) -> String {
|
||||
let find = |section: &str, label: &str| -> Option<Status> {
|
||||
sections
|
||||
.iter()
|
||||
.find(|s| s.name == section)?
|
||||
.checks
|
||||
.iter()
|
||||
.find(|c| c.label == label)
|
||||
.map(|c| c.status)
|
||||
};
|
||||
|
||||
let hw = find("Encode", "hardware H.264");
|
||||
let sw_ok = find("Encode", "software H.264") == Some(Status::Ok);
|
||||
let capture_broken = sections
|
||||
.iter()
|
||||
.find(|s| s.name == "Capture (host)")
|
||||
.map(|s| s.checks.iter().any(|c| c.status == Status::Fail))
|
||||
.unwrap_or(false);
|
||||
|
||||
if capture_broken {
|
||||
"Hosting will fail: the capture backend is incomplete (see Capture above).".to_string()
|
||||
} else if hw == Some(Status::Ok) {
|
||||
"Hosting will work (hardware H.264 encode).".to_string()
|
||||
} else if hw == Some(Status::Info) && sw_ok {
|
||||
// Plugin + render node present but VA-API unverified (no vainfo): the
|
||||
// default encoder is likely fine; `--no-hwencode` is the safe fallback.
|
||||
"Hosting should work (hardware H.264 likely; `--no-hwencode` is the fallback).".to_string()
|
||||
} else if sw_ok {
|
||||
"Hosting should work with `--no-hwencode` (software H.264 encode).".to_string()
|
||||
} else {
|
||||
"Hosting may fail: no working H.264 encoder found (see Encode above).".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn vainfo_detects_h264_encode_entrypoint() {
|
||||
// Realistic AMD/RADV-style dump: H.264 has both decode (VLD) and encode.
|
||||
let dump = "\
|
||||
VAProfileH264Main : VAEntrypointVLD
|
||||
VAProfileH264Main : VAEntrypointEncSlice
|
||||
VAProfileH264High : VAEntrypointVLD
|
||||
VAProfileHEVCMain : VAEntrypointEncSlice";
|
||||
assert!(vainfo_has_h264_encode(dump));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vainfo_low_power_encode_counts() {
|
||||
let dump = "VAProfileH264ConstrainedBaseline: VAEntrypointEncSliceLP";
|
||||
assert!(vainfo_has_h264_encode(dump));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vainfo_decode_only_h264_is_not_encode() {
|
||||
// Decode-only H.264 (VLD) plus HEVC encode must NOT be read as H.264
|
||||
// encode — this is exactly the "default encoder fails" case.
|
||||
let dump = "\
|
||||
VAProfileH264Main : VAEntrypointVLD
|
||||
VAProfileH264High : VAEntrypointVLD
|
||||
VAProfileHEVCMain : VAEntrypointEncSlice";
|
||||
assert!(!vainfo_has_h264_encode(dump));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vainfo_empty_is_not_encode() {
|
||||
assert!(!vainfo_has_h264_encode(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_counts_fails_and_warns_only() {
|
||||
let checks = [
|
||||
Check::ok("a", "x"),
|
||||
Check::info("b", "x"),
|
||||
Check::warn("c", "x"),
|
||||
Check::warn("d", "x"),
|
||||
Check::fail("e", "x"),
|
||||
];
|
||||
let summary = summarize(checks.iter());
|
||||
assert_eq!(summary, Summary { fails: 1, warns: 2 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosting_verdict_prefers_hardware_then_software() {
|
||||
let hw = vec![Section {
|
||||
name: "Encode",
|
||||
checks: vec![
|
||||
Check::ok("hardware H.264", "ok"),
|
||||
Check::ok("software H.264", "ok"),
|
||||
],
|
||||
}];
|
||||
assert!(hosting_verdict(&hw).contains("hardware"));
|
||||
|
||||
let sw = vec![Section {
|
||||
name: "Encode",
|
||||
checks: vec![
|
||||
Check::warn("hardware H.264", "no"),
|
||||
Check::ok("software H.264", "ok"),
|
||||
],
|
||||
}];
|
||||
assert!(sw_verdict_uses_no_hwencode(&hosting_verdict(&sw)));
|
||||
|
||||
let none = vec![Section {
|
||||
name: "Encode",
|
||||
checks: vec![
|
||||
Check::warn("hardware H.264", "no"),
|
||||
Check::warn("software H.264", "no"),
|
||||
],
|
||||
}];
|
||||
assert!(hosting_verdict(&none).contains("may fail"));
|
||||
}
|
||||
|
||||
fn sw_verdict_uses_no_hwencode(v: &str) -> bool {
|
||||
v.contains("--no-hwencode")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_failure_dominates_verdict() {
|
||||
let sections = vec![
|
||||
Section {
|
||||
name: "Capture (host)",
|
||||
checks: vec![Check::fail("X11 capture", "missing")],
|
||||
},
|
||||
Section {
|
||||
name: "Encode",
|
||||
checks: vec![Check::ok("hardware H.264", "ok")],
|
||||
},
|
||||
];
|
||||
assert!(hosting_verdict(§ions).contains("capture"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
//! Drives a headless `pixelpass` child process for the GUI.
|
||||
//!
|
||||
//! The GUI re-execs this same binary (via [`std::env::current_exe`]) in
|
||||
//! headless mode with `--output json`, then reads the child's JSON event
|
||||
//! stream on a background thread and forwards parsed events over a channel the
|
||||
//! egui app drains each frame. stderr is captured into a small ring so a
|
||||
//! failed launch (e.g. a missing gst plugin) can be surfaced in the window.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use nix::sys::signal::{Signal, kill};
|
||||
use nix::unistd::Pid;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::Waker;
|
||||
|
||||
/// One parsed event from the child's stdout. Owned mirror of
|
||||
/// [`crate::common::output::Event`] (which borrows for emit); kept separate so
|
||||
/// the wire format and the parser can evolve independently.
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(tag = "event", rename_all = "snake_case")]
|
||||
pub enum ChildEvent {
|
||||
Ticket {
|
||||
value: String,
|
||||
},
|
||||
HostInfo {
|
||||
display_server: String,
|
||||
capture: String,
|
||||
quality: String,
|
||||
dimensions: String,
|
||||
hw_encode: bool,
|
||||
max_viewers: u32,
|
||||
max_viewers_source: String,
|
||||
},
|
||||
ViewerJoined {
|
||||
id: String,
|
||||
active: u32,
|
||||
max: u32,
|
||||
},
|
||||
ViewerLeft {
|
||||
id: String,
|
||||
active: u32,
|
||||
max: u32,
|
||||
},
|
||||
Capture {
|
||||
state: CaptureState,
|
||||
},
|
||||
ViewerRefused {
|
||||
reason: String,
|
||||
},
|
||||
Connected {
|
||||
url: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CaptureState {
|
||||
Started,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
const STDERR_TAIL_MAX: usize = 60;
|
||||
|
||||
pub struct ChildProc {
|
||||
/// `Some` while the child is owned here; `Drop` takes it to hand off to a
|
||||
/// detached reaper thread (see the `Drop` impl).
|
||||
child: Option<Child>,
|
||||
pub rx: Receiver<ChildEvent>,
|
||||
stderr_tail: Arc<Mutex<Vec<String>>>,
|
||||
/// Write end of the child's stdin, for the line-based command channel
|
||||
/// (see [`ChildProc::send_command`]). `None` once it's been closed.
|
||||
stdin: Option<ChildStdin>,
|
||||
}
|
||||
|
||||
impl ChildProc {
|
||||
/// Spawn `pixelpass <args>` as a child, wiring up the event reader. The
|
||||
/// `waker` is pinged whenever an event arrives so the UI thread wakes to
|
||||
/// drain it — this wakes the winit event loop directly (via an
|
||||
/// `EventLoopProxy`), so it works even when the window is hidden to the tray
|
||||
/// and no frames are running (egui's own repaint callback would not fire
|
||||
/// repeatedly in that idle state — see [`super::Waker`]).
|
||||
pub fn spawn(args: &[String], waker: Waker) -> std::io::Result<Self> {
|
||||
let exe = std::env::current_exe()?;
|
||||
let mut child = Command::new(exe)
|
||||
.args(args)
|
||||
// Piped so we can send line commands (e.g. `kick <id>`); the host
|
||||
// only reads it when driven this way (`--output json`).
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
let stdin = child.stdin.take();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let stdout = child.stdout.take().expect("stdout piped");
|
||||
std::thread::spawn(move || {
|
||||
let reader = BufReader::new(stdout);
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Ignore any non-event line rather than dropping the stream.
|
||||
if let Ok(ev) = serde_json::from_str::<ChildEvent>(&line) {
|
||||
if tx.send(ev).is_err() {
|
||||
break; // app gone
|
||||
}
|
||||
waker.wake();
|
||||
}
|
||||
}
|
||||
// stdout closed → the child has exited (player closed, connection
|
||||
// ended, or a failed launch). Wake once more so the UI reaps it and
|
||||
// clears the "running" view, even if no final event was emitted and
|
||||
// the window is hidden to the tray.
|
||||
waker.wake();
|
||||
});
|
||||
|
||||
let stderr_tail = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
let stderr = child.stderr.take().expect("stderr piped");
|
||||
let tail = stderr_tail.clone();
|
||||
std::thread::spawn(move || {
|
||||
let reader = BufReader::new(stderr);
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
let mut t = tail.lock().unwrap();
|
||||
t.push(line);
|
||||
let overflow = t.len().saturating_sub(STDERR_TAIL_MAX);
|
||||
if overflow > 0 {
|
||||
t.drain(0..overflow);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
child: Some(child),
|
||||
rx,
|
||||
stderr_tail,
|
||||
stdin,
|
||||
})
|
||||
}
|
||||
|
||||
/// Send one newline-terminated command to the child over its stdin (the
|
||||
/// host parses these as `kick <endpoint-id>`). Best-effort: a closed pipe
|
||||
/// (child already gone) just drops the command.
|
||||
pub fn send_command(&mut self, cmd: &str) {
|
||||
let Some(stdin) = self.stdin.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = writeln!(stdin, "{cmd}") {
|
||||
tracing::warn!("failed to send command to host child: {e}");
|
||||
self.stdin = None; // pipe is dead; stop trying
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the child is still running.
|
||||
pub fn is_alive(&mut self) -> bool {
|
||||
matches!(self.child.as_mut().map(Child::try_wait), Some(Ok(None)))
|
||||
}
|
||||
|
||||
/// The last captured stderr lines, joined — for error display.
|
||||
pub fn stderr_tail(&self) -> String {
|
||||
self.stderr_tail.lock().unwrap().join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ChildProc {
|
||||
fn drop(&mut self) {
|
||||
// Leaving a host/viewer screen, or closing the window, must not orphan
|
||||
// a live child — but it must also not *block*. eframe runs this drop
|
||||
// synchronously while it destroys the window, so a grace-period wait
|
||||
// here freezes the window mid-close: the first click looks like it did
|
||||
// nothing (the stream just drops) and the window only goes away on a
|
||||
// second click. So SIGINT now — synchronously, so the host always gets
|
||||
// its ctrl-c teardown (capture down, endpoint closed) even if we exit
|
||||
// right after — then reap on a detached thread instead of waiting.
|
||||
let Some(mut child) = self.child.take() else {
|
||||
return;
|
||||
};
|
||||
if matches!(child.try_wait(), Ok(Some(_))) {
|
||||
return; // already exited; nothing to signal or reap
|
||||
}
|
||||
let _ = kill(Pid::from_raw(child.id() as i32), Signal::SIGINT);
|
||||
std::thread::spawn(move || {
|
||||
for _ in 0..40 {
|
||||
if matches!(child.try_wait(), Ok(Some(_))) {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::common::output::{CaptureState as EmitState, Event};
|
||||
|
||||
// The GUI parses what the headless child emits. These round-trip the
|
||||
// emitter's own types (`common::output::Event`) through the parser
|
||||
// (`ChildEvent`) so a rename on either side of the wire fails here rather
|
||||
// than silently breaking the GUI at runtime.
|
||||
fn parse(emit: Event) -> ChildEvent {
|
||||
let line = serde_json::to_string(&emit).unwrap();
|
||||
serde_json::from_str::<ChildEvent>(&line).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_round_trips() {
|
||||
assert!(matches!(
|
||||
parse(Event::Ticket { value: "endpointXYZ" }),
|
||||
ChildEvent::Ticket { value } if value == "endpointXYZ"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_info_round_trips() {
|
||||
let ev = parse(Event::HostInfo {
|
||||
display_server: "Wayland",
|
||||
capture: "fullscreen + system-audio",
|
||||
quality: "Medium",
|
||||
dimensions: "≤720p / 2500 kbps / 30 fps",
|
||||
hw_encode: true,
|
||||
max_viewers: 3,
|
||||
max_viewers_source: "user-specified",
|
||||
});
|
||||
match ev {
|
||||
ChildEvent::HostInfo {
|
||||
display_server,
|
||||
quality,
|
||||
hw_encode,
|
||||
max_viewers,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(display_server, "Wayland");
|
||||
assert_eq!(quality, "Medium");
|
||||
assert!(hw_encode);
|
||||
assert_eq!(max_viewers, 3);
|
||||
}
|
||||
other => panic!("expected HostInfo, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewer_join_leave_round_trip() {
|
||||
assert!(matches!(
|
||||
parse(Event::ViewerJoined { id: "nodeXYZ", active: 2, max: 4 }),
|
||||
ChildEvent::ViewerJoined { id, active: 2, max: 4 } if id == "nodeXYZ"
|
||||
));
|
||||
assert!(matches!(
|
||||
parse(Event::ViewerLeft { id: "nodeXYZ", active: 1, max: 4 }),
|
||||
ChildEvent::ViewerLeft { id, active: 1, max: 4 } if id == "nodeXYZ"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_state_round_trips() {
|
||||
assert!(matches!(
|
||||
parse(Event::Capture {
|
||||
state: EmitState::Started
|
||||
}),
|
||||
ChildEvent::Capture {
|
||||
state: CaptureState::Started
|
||||
}
|
||||
));
|
||||
assert!(matches!(
|
||||
parse(Event::Capture {
|
||||
state: EmitState::Stopped
|
||||
}),
|
||||
ChildEvent::Capture {
|
||||
state: CaptureState::Stopped
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_and_connected_round_trip() {
|
||||
assert!(matches!(
|
||||
parse(Event::ViewerRefused { reason: "host is full" }),
|
||||
ChildEvent::ViewerRefused { reason } if reason == "host is full"
|
||||
));
|
||||
assert!(matches!(
|
||||
parse(Event::Connected { url: "http://127.0.0.1:5000" }),
|
||||
ChildEvent::Connected { url } if url == "http://127.0.0.1:5000"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Share-code wrapping: carrying the host's stable friend id alongside the
|
||||
//! one-shot video ticket.
|
||||
//!
|
||||
//! A bare video ticket identifies only the host's *ephemeral* video endpoint,
|
||||
//! so two people who meet over one can't learn each other's stable friend id —
|
||||
//! the thing the friends system needs. The GUI host therefore wraps its ticket
|
||||
//! with its control-plane [`EndpointId`]; the viewer unwraps it, dials the
|
||||
//! video ticket as before, and now also knows who to befriend (and announces
|
||||
//! itself back over the control plane so the host learns the viewer in turn).
|
||||
//!
|
||||
//! Format: `pixelpassF1:<host-control-id>.<bare-ticket>`. Both the id and the
|
||||
//! ticket are base32 text with no `.`, so a single `.` separator is
|
||||
//! unambiguous. [`unwrap`] is lenient: anything without the prefix is treated
|
||||
//! as a bare ticket, so a plain CLI ticket pasted into the GUI still works (it
|
||||
//! just offers no friend option). The host name isn't carried here — the
|
||||
//! viewer's announcement triggers a name exchange over the control plane.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use iroh::EndpointId;
|
||||
|
||||
/// Prefix marking a wrapped friend code. The `F1` is the wrap-format version,
|
||||
/// bumped if the layout ever changes.
|
||||
const MAGIC: &str = "pixelpassF1:";
|
||||
|
||||
/// Wrap a bare ticket with the host's control id, for display/copy/QR.
|
||||
pub fn wrap(host_id: EndpointId, ticket: &str) -> String {
|
||||
format!("{MAGIC}{host_id}.{ticket}")
|
||||
}
|
||||
|
||||
/// Split an input into `(host control id if it was a wrapped code, bare
|
||||
/// ticket)`. A bare or unrecognised input yields `(None, trimmed input)` so the
|
||||
/// viewer path stays identical to before for plain tickets.
|
||||
pub fn unwrap(code: &str) -> (Option<EndpointId>, String) {
|
||||
let code = code.trim();
|
||||
if let Some(rest) = code.strip_prefix(MAGIC)
|
||||
&& let Some((id_str, ticket)) = rest.split_once('.')
|
||||
&& let Ok(id) = EndpointId::from_str(id_str)
|
||||
&& !ticket.is_empty()
|
||||
{
|
||||
return (Some(id), ticket.to_string());
|
||||
}
|
||||
(None, code.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_id() -> EndpointId {
|
||||
iroh::SecretKey::generate().public()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_unwrap_round_trips() {
|
||||
let id = sample_id();
|
||||
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
|
||||
let code = wrap(id, ticket);
|
||||
let (got_id, got_ticket) = unwrap(&code);
|
||||
assert_eq!(got_id, Some(id));
|
||||
assert_eq!(got_ticket, ticket);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_ticket_passes_through() {
|
||||
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
|
||||
let (id, got) = unwrap(ticket);
|
||||
assert_eq!(id, None);
|
||||
assert_eq!(got, ticket);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trims_surrounding_whitespace() {
|
||||
let ticket = "endpointaabwxjex";
|
||||
let (id, got) = unwrap(&format!(" {} ", wrap(sample_id(), ticket)));
|
||||
assert!(id.is_some());
|
||||
assert_eq!(got, ticket);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_wrapped_code_falls_back_to_bare() {
|
||||
// Prefix present but the id isn't a valid EndpointId → treat the whole
|
||||
// thing as a (doomed) bare ticket rather than panicking.
|
||||
let (id, got) = unwrap("pixelpassF1:not-an-id.endpointaa");
|
||||
assert_eq!(id, None);
|
||||
assert_eq!(got, "pixelpassF1:not-an-id.endpointaa");
|
||||
}
|
||||
}
|
||||
+2755
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,299 @@
|
||||
//! The always-on friends presence service.
|
||||
//!
|
||||
//! A control-plane iroh endpoint ([`endpoint::bind_control`]) that lives for the
|
||||
//! whole GUI session on its own thread with a current-thread tokio runtime — the
|
||||
//! GUI is a synchronous winit/egui loop, so iroh's async work can't run on it
|
||||
//! (the same reason [`super::tray`] has its own thread + runtime).
|
||||
//!
|
||||
//! Inbound control messages are forwarded over a std mpsc channel the UI drains
|
||||
//! each [`super::PixelPassApp::tick`]; the [`Waker`] is pinged on arrival so a
|
||||
//! message wakes the loop even while the window is hidden to the tray — the same
|
||||
//! trick the headless-child reader uses.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::thread;
|
||||
|
||||
use iroh::{Endpoint, EndpointId};
|
||||
use tokio::sync::mpsc as tmpsc;
|
||||
|
||||
use super::Waker;
|
||||
use crate::common::{
|
||||
control::{self, ControlMsg, Inbound},
|
||||
endpoint, identity,
|
||||
};
|
||||
|
||||
/// A command the UI hands the presence service over [`PresenceHandle`].
|
||||
enum Command {
|
||||
/// Deliver one message, once, fire-and-forget (friend request/accept/decline
|
||||
/// and the presence `Hello`). A failure is logged, not retried.
|
||||
Send { peer: EndpointId, msg: ControlMsg },
|
||||
/// Begin — or replace — a share campaign: push `msg` (a
|
||||
/// [`ControlMsg::ShareCode`]) to every peer in `peers`, retrying the ones
|
||||
/// that are offline until they're reached or the campaign is stopped. Each
|
||||
/// success emits a [`PresenceEvent::ShareDelivered`]. Replaces any campaign
|
||||
/// already running (a fresh host session supersedes the previous code).
|
||||
StartShare {
|
||||
msg: ControlMsg,
|
||||
peers: Vec<EndpointId>,
|
||||
},
|
||||
/// Stop the active share campaign — the host stopped or left the screen, so
|
||||
/// the perishable code is no longer valid and offline friends shouldn't keep
|
||||
/// being chased.
|
||||
StopShare,
|
||||
}
|
||||
|
||||
/// Something the service surfaces to the UI, drained each tick.
|
||||
pub enum PresenceEvent {
|
||||
/// A control message arrived from a peer.
|
||||
Message(Inbound),
|
||||
/// A share-campaign code reached `peer` (its ACK came back). Lets the host
|
||||
/// screen flip that friend's row from "retrying" to "delivered."
|
||||
ShareDelivered { peer: EndpointId },
|
||||
}
|
||||
|
||||
/// How long to wait before re-attempting delivery to friends who were offline
|
||||
/// on the previous round of a share campaign.
|
||||
const SHARE_RETRY: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Handle the GUI holds for the presence service. Dropping it doesn't stop the
|
||||
/// service (the thread is detached; the endpoint closes when the process exits)
|
||||
/// — it just stops the UI from draining inbound messages.
|
||||
pub struct PresenceHandle {
|
||||
/// Our stable control-plane id — what friends know us by, and what we embed
|
||||
/// in a wrapped share code so a viewer can find us.
|
||||
id: EndpointId,
|
||||
/// Service events (inbound messages + share receipts), drained by
|
||||
/// [`PresenceHandle::drain`] each tick.
|
||||
rx: Receiver<PresenceEvent>,
|
||||
/// Commands handed to the service thread. Unbounded tokio sender so the sync
|
||||
/// UI can enqueue without blocking or being inside the runtime.
|
||||
out_tx: tmpsc::UnboundedSender<Command>,
|
||||
}
|
||||
|
||||
impl PresenceHandle {
|
||||
/// Our stable control-plane id.
|
||||
pub fn id(&self) -> EndpointId {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// Pull every service event received since the last call. Collected by the
|
||||
/// caller so it can take `&mut self` while handling them.
|
||||
pub fn drain(&self) -> Vec<PresenceEvent> {
|
||||
std::iter::from_fn(|| self.rx.try_recv().ok()).collect()
|
||||
}
|
||||
|
||||
/// Enqueue a one-shot message for delivery to `peer`. Fire-and-forget from
|
||||
/// the UI's view; the service connects, delivers, and logs a failure. A send
|
||||
/// error here only means the service thread is gone.
|
||||
pub fn send(&self, peer: EndpointId, msg: ControlMsg) {
|
||||
self.command(Command::Send { peer, msg });
|
||||
}
|
||||
|
||||
/// Begin (or replace) a share campaign pushing `msg` to `peers`, retrying
|
||||
/// offline friends until [`PresenceHandle::stop_share`] or the next call.
|
||||
pub fn start_share(&self, msg: ControlMsg, peers: Vec<EndpointId>) {
|
||||
self.command(Command::StartShare { msg, peers });
|
||||
}
|
||||
|
||||
/// Stop the active share campaign (host stopped — the code is now stale).
|
||||
pub fn stop_share(&self) {
|
||||
self.command(Command::StopShare);
|
||||
}
|
||||
|
||||
fn command(&self, cmd: Command) {
|
||||
if self.out_tx.send(cmd).is_err() {
|
||||
tracing::warn!("presence: service thread gone; dropping command");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the presence service. Returns `None` if the persistent identity can't
|
||||
/// be loaded — the GUI then simply runs without friends features rather than
|
||||
/// refusing to start. The endpoint binds asynchronously on the spawned thread;
|
||||
/// our id is known immediately because it derives from the saved key, so we can
|
||||
/// fail-fast and log it without waiting on the relay handshake.
|
||||
pub fn start(waker: Waker, relay: Option<String>) -> Option<PresenceHandle> {
|
||||
let id: EndpointId = match identity::load_or_create() {
|
||||
Ok(key) => key.public(),
|
||||
Err(e) => {
|
||||
tracing::warn!("presence: no identity, friends features disabled: {e:#}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
tracing::info!(%id, "presence: starting control service");
|
||||
|
||||
let (tx, rx) = mpsc::channel::<PresenceEvent>();
|
||||
let (out_tx, out_rx) = tmpsc::unbounded_channel::<Command>();
|
||||
thread::Builder::new()
|
||||
.name("pixelpass-presence".into())
|
||||
.spawn(move || run(relay, id, tx, out_rx, waker))
|
||||
.map_err(|e| tracing::warn!("presence: could not spawn service thread: {e}"))
|
||||
.ok()?;
|
||||
|
||||
Some(PresenceHandle { id, rx, out_tx })
|
||||
}
|
||||
|
||||
/// Thread body: a current-thread tokio runtime that binds the control endpoint,
|
||||
/// runs the accept loop, bridges inbound messages to the UI channel, and
|
||||
/// delivers outbound messages the UI enqueues.
|
||||
fn run(
|
||||
relay: Option<String>,
|
||||
id: EndpointId,
|
||||
tx: mpsc::Sender<PresenceEvent>,
|
||||
mut out_rx: tmpsc::UnboundedReceiver<Command>,
|
||||
waker: Waker,
|
||||
) {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
tracing::error!("presence: failed to build runtime: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
rt.block_on(async move {
|
||||
let ep = match endpoint::bind_control(relay.as_deref()).await {
|
||||
Ok(ep) => ep,
|
||||
Err(e) => {
|
||||
tracing::error!("presence: failed to bind control endpoint: {e:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
tracing::info!(%id, "presence: control endpoint online");
|
||||
|
||||
// One async→sync bridge for *everything* the UI sees: every producer
|
||||
// (the accept loop and the share campaign) pushes a `PresenceEvent` into
|
||||
// `ui_tx`; this task drains it onto the std channel and wakes the loop so
|
||||
// the event lands even while the window is hidden to the tray.
|
||||
let (ui_tx, mut ui_rx) = tmpsc::channel::<PresenceEvent>(64);
|
||||
let forward = tokio::spawn(async move {
|
||||
while let Some(event) = ui_rx.recv().await {
|
||||
if tx.send(event).is_err() {
|
||||
break; // UI gone
|
||||
}
|
||||
waker.wake();
|
||||
}
|
||||
});
|
||||
|
||||
// Wrap inbound control messages as events and feed the bridge.
|
||||
let (itx, mut irx) = tmpsc::channel::<Inbound>(32);
|
||||
let inbound_ui = ui_tx.clone();
|
||||
let inbound = tokio::spawn(async move {
|
||||
while let Some(msg) = irx.recv().await {
|
||||
if inbound_ui.send(PresenceEvent::Message(msg)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle UI commands: one-shot sends each on their own task, and a single
|
||||
// abortable share campaign (StartShare replaces it, StopShare cancels it).
|
||||
let cmd_ep = ep.clone();
|
||||
let commands = tokio::spawn(async move {
|
||||
let mut share: Option<tokio::task::JoinHandle<()>> = None;
|
||||
while let Some(cmd) = out_rx.recv().await {
|
||||
match cmd {
|
||||
Command::Send { peer, msg } => {
|
||||
let ep = cmd_ep.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = control::send(&ep, peer, &msg).await {
|
||||
tracing::warn!(%peer, "presence: outbound send failed: {e:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
Command::StartShare { msg, peers } => {
|
||||
if let Some(t) = share.take() {
|
||||
t.abort();
|
||||
}
|
||||
let ep = cmd_ep.clone();
|
||||
let ui = ui_tx.clone();
|
||||
share = Some(tokio::spawn(run_share(ep, msg, peers, ui)));
|
||||
}
|
||||
Command::StopShare => {
|
||||
if let Some(t) = share.take() {
|
||||
t.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
control::serve(ep, itx).await;
|
||||
forward.abort();
|
||||
inbound.abort();
|
||||
commands.abort();
|
||||
});
|
||||
}
|
||||
|
||||
/// Push `msg` to every peer in `peers`, retrying the ones that are offline every
|
||||
/// [`SHARE_RETRY`] until all are delivered (or the task is aborted by a
|
||||
/// StartShare/StopShare). Emits one [`PresenceEvent::ShareDelivered`] per peer
|
||||
/// the moment its ACK comes back — that ACK *is* the delivery signal.
|
||||
///
|
||||
/// Each round fires all still-pending peers **concurrently**, so a single
|
||||
/// offline friend's ~10s connect timeout doesn't serialise the whole round
|
||||
/// (which it did when peers were tried one at a time).
|
||||
async fn run_share(
|
||||
ep: Endpoint,
|
||||
msg: ControlMsg,
|
||||
mut pending: Vec<EndpointId>,
|
||||
ui: tmpsc::Sender<PresenceEvent>,
|
||||
) {
|
||||
// The code is immutable for the campaign's life; share it across the
|
||||
// per-peer tasks via an `Arc` rather than re-cloning the payload each round.
|
||||
let msg = Arc::new(msg);
|
||||
while !pending.is_empty() {
|
||||
let mut round = tokio::task::JoinSet::new();
|
||||
for peer in pending {
|
||||
let ep = ep.clone();
|
||||
let msg = Arc::clone(&msg);
|
||||
round.spawn(async move {
|
||||
match control::send(&ep, peer, &msg).await {
|
||||
Ok(()) => (peer, true),
|
||||
Err(e) => {
|
||||
tracing::debug!(%peer, "presence: share not yet delivered: {e:#}");
|
||||
(peer, false)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let mut still = Vec::new();
|
||||
while let Some(joined) = round.join_next().await {
|
||||
let (peer, delivered) = match joined {
|
||||
Ok(outcome) => outcome,
|
||||
// A send task panicking is unexpected; log and drop that peer
|
||||
// from the campaign rather than abort the whole round. (A
|
||||
// campaign-level abort drops this future entirely — we never
|
||||
// observe that as a JoinError here.)
|
||||
Err(e) => {
|
||||
tracing::warn!("presence: share task failed: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if delivered {
|
||||
tracing::info!(%peer, "presence: shared code delivered");
|
||||
if ui
|
||||
.send(PresenceEvent::ShareDelivered { peer })
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return; // UI gone — nothing left to report to
|
||||
}
|
||||
} else {
|
||||
still.push(peer);
|
||||
}
|
||||
}
|
||||
|
||||
if still.is_empty() {
|
||||
break;
|
||||
}
|
||||
pending = still;
|
||||
tokio::time::sleep(SHARE_RETRY).await;
|
||||
}
|
||||
tracing::info!("presence: share campaign complete");
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
//! User-customisable colour themes for the GUI.
|
||||
//!
|
||||
//! A theme is a small, curated *semantic* palette — backgrounds, text, an
|
||||
//! accent, and the handful of status colours the app uses (streaming, waiting,
|
||||
//! success, warning, error). That's deliberately a fixed set rather than a
|
||||
//! passthrough of every [`egui::Visuals`] field: it's easy to author by hand,
|
||||
//! covers the whole look of the app, and stays stable across egui upgrades.
|
||||
//!
|
||||
//! Themes serialise to TOML with colours as `#rrggbb` hex strings. Three
|
||||
//! themes ship built in; users drop their own `*.toml` files in
|
||||
//! `~/.config/pixelpass/themes/` (or save one from the in-app editor) and they
|
||||
//! show up alongside the built-ins. A user file whose `name` matches a built-in
|
||||
//! overrides it.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use directories::ProjectDirs;
|
||||
use eframe::egui::{self, Color32};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One colour theme: a curated semantic palette.
|
||||
///
|
||||
/// `#[serde(default)]` on the container means any field missing from a TOML
|
||||
/// file falls back to the corresponding field of [`Theme::default`] (the
|
||||
/// built-in Default Dark), so a partial or hand-trimmed file still loads.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Theme {
|
||||
/// Display name, shown in the picker and used as the file stem on save.
|
||||
pub name: String,
|
||||
/// Base egui defaults to start from before applying the palette overrides.
|
||||
pub dark: bool,
|
||||
|
||||
// ── chrome ────────────────────────────────────────────────────────
|
||||
/// Window background.
|
||||
#[serde(with = "hex")]
|
||||
pub window_bg: Color32,
|
||||
/// Panel / frame background.
|
||||
#[serde(with = "hex")]
|
||||
pub panel_bg: Color32,
|
||||
/// Text-input and read-only field background (the ticket box, etc.).
|
||||
#[serde(with = "hex")]
|
||||
pub input_bg: Color32,
|
||||
/// Primary text.
|
||||
#[serde(with = "hex")]
|
||||
pub text: Color32,
|
||||
/// Secondary / de-emphasised text (hints, the version line).
|
||||
#[serde(with = "hex")]
|
||||
pub weak_text: Color32,
|
||||
/// Accent: selection, hyperlinks, and the active/pressed widget fill.
|
||||
#[serde(with = "hex")]
|
||||
pub accent: Color32,
|
||||
/// Button (and other interactive widget) resting background.
|
||||
#[serde(with = "hex")]
|
||||
pub button_bg: Color32,
|
||||
/// Button background on hover.
|
||||
#[serde(with = "hex")]
|
||||
pub button_hovered: Color32,
|
||||
|
||||
// ── semantic status colours ───────────────────────────────────────
|
||||
/// "● Streaming" indicator.
|
||||
#[serde(with = "hex")]
|
||||
pub streaming: Color32,
|
||||
/// "● Waiting for viewers…" indicator.
|
||||
#[serde(with = "hex")]
|
||||
pub waiting: Color32,
|
||||
/// Success notes, e.g. "✓ Copied to clipboard".
|
||||
#[serde(with = "hex")]
|
||||
pub success: Color32,
|
||||
/// Non-fatal warnings, e.g. a host-full refusal.
|
||||
#[serde(with = "hex")]
|
||||
pub warning: Color32,
|
||||
/// Errors.
|
||||
#[serde(with = "hex")]
|
||||
pub error: Color32,
|
||||
}
|
||||
|
||||
impl Default for Theme {
|
||||
fn default() -> Self {
|
||||
default_dark()
|
||||
}
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
/// Build the egui [`Visuals`](egui::Visuals) this theme describes. Starts
|
||||
/// from egui's dark or light defaults (so anything the palette doesn't name
|
||||
/// stays sensible) and overrides the curated fields.
|
||||
pub fn visuals(&self) -> egui::Visuals {
|
||||
use egui::{Stroke, Visuals};
|
||||
|
||||
let mut v = if self.dark {
|
||||
Visuals::dark()
|
||||
} else {
|
||||
Visuals::light()
|
||||
};
|
||||
v.dark_mode = self.dark;
|
||||
|
||||
v.window_fill = self.window_bg;
|
||||
v.panel_fill = self.panel_bg;
|
||||
v.faint_bg_color = self.panel_bg;
|
||||
v.extreme_bg_color = self.input_bg;
|
||||
v.override_text_color = Some(self.text);
|
||||
// `.weak()` text resolves via `weak_text_color()`, which derives from
|
||||
// `text` unless this is set — so without it the weak-text field is dead.
|
||||
v.weak_text_color = Some(self.weak_text);
|
||||
v.hyperlink_color = self.accent;
|
||||
v.error_fg_color = self.error;
|
||||
v.warn_fg_color = self.warning;
|
||||
|
||||
// A translucent accent reads well as a selection highlight on either a
|
||||
// light or dark base.
|
||||
v.selection.bg_fill =
|
||||
Color32::from_rgba_unmultiplied(self.accent.r(), self.accent.g(), self.accent.b(), 96);
|
||||
v.selection.stroke = Stroke::new(1.0, self.accent);
|
||||
|
||||
let text_stroke = Stroke::new(1.0, self.text);
|
||||
let weak_stroke = Stroke::new(1.0, self.weak_text);
|
||||
|
||||
v.widgets.noninteractive.bg_fill = self.panel_bg;
|
||||
v.widgets.noninteractive.weak_bg_fill = self.panel_bg;
|
||||
v.widgets.noninteractive.fg_stroke = weak_stroke;
|
||||
|
||||
v.widgets.inactive.bg_fill = self.button_bg;
|
||||
v.widgets.inactive.weak_bg_fill = self.button_bg;
|
||||
v.widgets.inactive.fg_stroke = text_stroke;
|
||||
|
||||
v.widgets.hovered.bg_fill = self.button_hovered;
|
||||
v.widgets.hovered.weak_bg_fill = self.button_hovered;
|
||||
v.widgets.hovered.fg_stroke = text_stroke;
|
||||
|
||||
v.widgets.active.bg_fill = self.accent;
|
||||
v.widgets.active.weak_bg_fill = self.accent;
|
||||
v.widgets.active.fg_stroke = text_stroke;
|
||||
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
// ── built-in themes ───────────────────────────────────────────────────────
|
||||
|
||||
/// Names of the built-in themes, in picker order.
|
||||
pub const BUILTIN_NAMES: [&str; 3] = ["Default Dark", "Catppuccin Mocha", "Catppuccin Latte"];
|
||||
|
||||
/// Parse a built-in's hex literal, panicking on a typo (these are compile-time
|
||||
/// constants we control, so a bad value is a bug, not user input).
|
||||
fn c(hex: &str) -> Color32 {
|
||||
parse_hex(hex).expect("built-in theme hex is valid")
|
||||
}
|
||||
|
||||
/// The default theme — a neutral dark palette. Also [`Theme::default`].
|
||||
pub fn default_dark() -> Theme {
|
||||
Theme {
|
||||
name: "Default Dark".to_string(),
|
||||
dark: true,
|
||||
window_bg: c("#1b1b1f"),
|
||||
panel_bg: c("#242429"),
|
||||
input_bg: c("#141417"),
|
||||
text: c("#e6e6ea"),
|
||||
weak_text: c("#a0a0a8"),
|
||||
accent: c("#5aa0f2"),
|
||||
button_bg: c("#33333a"),
|
||||
button_hovered: c("#44444d"),
|
||||
streaming: c("#6fdc8c"),
|
||||
waiting: c("#f2c14e"),
|
||||
success: c("#6fdc8c"),
|
||||
warning: c("#f0a85a"),
|
||||
error: c("#f2756f"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Catppuccin Mocha (dark). <https://github.com/catppuccin/catppuccin>
|
||||
fn catppuccin_mocha() -> Theme {
|
||||
Theme {
|
||||
name: "Catppuccin Mocha".to_string(),
|
||||
dark: true,
|
||||
window_bg: c("#1e1e2e"),
|
||||
panel_bg: c("#181825"),
|
||||
input_bg: c("#11111b"),
|
||||
text: c("#cdd6f4"),
|
||||
weak_text: c("#a6adc8"),
|
||||
accent: c("#cba6f7"),
|
||||
button_bg: c("#313244"),
|
||||
button_hovered: c("#45475a"),
|
||||
streaming: c("#a6e3a1"),
|
||||
waiting: c("#f9e2af"),
|
||||
success: c("#a6e3a1"),
|
||||
warning: c("#fab387"),
|
||||
error: c("#f38ba8"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Catppuccin Latte (light). <https://github.com/catppuccin/catppuccin>
|
||||
fn catppuccin_latte() -> Theme {
|
||||
Theme {
|
||||
name: "Catppuccin Latte".to_string(),
|
||||
dark: false,
|
||||
window_bg: c("#eff1f5"),
|
||||
panel_bg: c("#e6e9ef"),
|
||||
input_bg: c("#dce0e8"),
|
||||
text: c("#4c4f69"),
|
||||
weak_text: c("#6c6f85"),
|
||||
accent: c("#8839ef"),
|
||||
button_bg: c("#ccd0da"),
|
||||
button_hovered: c("#bcc0cc"),
|
||||
streaming: c("#40a02b"),
|
||||
waiting: c("#df8e1d"),
|
||||
success: c("#40a02b"),
|
||||
warning: c("#fe640b"),
|
||||
error: c("#d20f39"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The built-in themes, in [`BUILTIN_NAMES`] order.
|
||||
pub fn builtins() -> Vec<Theme> {
|
||||
vec![default_dark(), catppuccin_mocha(), catppuccin_latte()]
|
||||
}
|
||||
|
||||
/// Whether `name` is one of the built-ins (which are read-only — the editor
|
||||
/// nudges you to save under a new name).
|
||||
pub fn is_builtin(name: &str) -> bool {
|
||||
BUILTIN_NAMES.contains(&name)
|
||||
}
|
||||
|
||||
// ── on-disk themes ──────────────────────────────────────────────────────────
|
||||
|
||||
/// `~/.config/pixelpass/themes/` (or the XDG equivalent). Not created until a
|
||||
/// theme is saved.
|
||||
pub fn themes_dir() -> Result<PathBuf> {
|
||||
let dirs = ProjectDirs::from("", "", "pixelpass")
|
||||
.context("could not locate a config directory for pixelpass")?;
|
||||
Ok(dirs.config_dir().join("themes"))
|
||||
}
|
||||
|
||||
/// Parse every `*.toml` in the themes dir into a [`Theme`]. A file that fails
|
||||
/// to parse is logged and skipped rather than aborting the whole list, so one
|
||||
/// bad file can't hide the rest. Returns themes sorted by name.
|
||||
pub fn list_user_themes() -> Vec<Theme> {
|
||||
let Ok(dir) = themes_dir() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
return Vec::new(); // dir doesn't exist yet → no user themes
|
||||
};
|
||||
|
||||
let mut out = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
|
||||
continue;
|
||||
}
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(s) => match toml::from_str::<Theme>(&s) {
|
||||
Ok(mut t) => {
|
||||
// Fall back to the file stem if the file omits a name.
|
||||
if t.name.trim().is_empty() {
|
||||
t.name = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("Unnamed")
|
||||
.to_string();
|
||||
}
|
||||
out.push(t);
|
||||
}
|
||||
Err(e) => tracing::warn!("skipping theme {}: {e}", path.display()),
|
||||
},
|
||||
Err(e) => tracing::warn!("could not read theme {}: {e}", path.display()),
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|t| t.name.to_lowercase());
|
||||
out
|
||||
}
|
||||
|
||||
/// Built-ins plus user themes, in picker order: built-ins first (a user file
|
||||
/// with a matching `name` overrides the built-in's colours in place), then any
|
||||
/// remaining user themes alphabetically.
|
||||
pub fn all_themes() -> Vec<Theme> {
|
||||
let users = list_user_themes();
|
||||
let mut out: Vec<Theme> = builtins()
|
||||
.into_iter()
|
||||
.map(|b| {
|
||||
users
|
||||
.iter()
|
||||
.find(|u| u.name == b.name)
|
||||
.cloned()
|
||||
.unwrap_or(b)
|
||||
})
|
||||
.collect();
|
||||
for u in users {
|
||||
if !is_builtin(&u.name) {
|
||||
out.push(u);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The theme with this `name`, or Default Dark if it can't be found (e.g. the
|
||||
/// config names a theme whose file was deleted).
|
||||
pub fn load_named(name: &str) -> Theme {
|
||||
all_themes()
|
||||
.into_iter()
|
||||
.find(|t| t.name == name)
|
||||
.unwrap_or_else(default_dark)
|
||||
}
|
||||
|
||||
/// Write `theme` to `<themes_dir>/<slug>.toml` and return the path. Overwrites
|
||||
/// an existing file with the same slug (i.e. saving a tweaked theme under the
|
||||
/// same name updates it in place).
|
||||
pub fn save_theme(theme: &Theme) -> Result<PathBuf> {
|
||||
let dir = themes_dir()?;
|
||||
std::fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
|
||||
|
||||
let slug = slugify(&theme.name);
|
||||
let path = dir.join(format!("{slug}.toml"));
|
||||
let body = toml::to_string_pretty(theme).context("failed to serialise theme to TOML")?;
|
||||
let contents = format!(
|
||||
"# PixelPass theme. Colours are #rrggbb hex strings.\n\
|
||||
# Edit and re-pick it in Settings, or drop more .toml files in this folder.\n\n\
|
||||
{body}"
|
||||
);
|
||||
std::fs::write(&path, contents)
|
||||
.with_context(|| format!("failed to write {}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Lowercase, replace runs of non-alphanumerics with a single hyphen, trim
|
||||
/// hyphens. Empty input becomes `theme`.
|
||||
fn slugify(name: &str) -> String {
|
||||
let mut slug = String::new();
|
||||
let mut prev_hyphen = false;
|
||||
for ch in name.trim().chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
slug.push(ch.to_ascii_lowercase());
|
||||
prev_hyphen = false;
|
||||
} else if !prev_hyphen {
|
||||
slug.push('-');
|
||||
prev_hyphen = true;
|
||||
}
|
||||
}
|
||||
let slug = slug.trim_matches('-').to_string();
|
||||
if slug.is_empty() {
|
||||
"theme".to_string()
|
||||
} else {
|
||||
slug
|
||||
}
|
||||
}
|
||||
|
||||
// ── hex colour parsing ────────────────────────────────────────────────────
|
||||
|
||||
/// Parse `#rrggbb` into an opaque [`Color32`] (the leading `#` is optional).
|
||||
/// An 8-digit `#rrggbbaa` is accepted leniently but its alpha is ignored —
|
||||
/// theme colours are opaque, and `Color32`'s premultiplied storage can't
|
||||
/// round-trip a straight alpha losslessly anyway. Returns `None` on malformed
|
||||
/// input.
|
||||
pub fn parse_hex(s: &str) -> Option<Color32> {
|
||||
let s = s.trim();
|
||||
let s = s.strip_prefix('#').unwrap_or(s);
|
||||
if !matches!(s.len(), 6 | 8) || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let byte = |i: usize| u8::from_str_radix(&s[i..i + 2], 16).ok();
|
||||
Some(Color32::from_rgb(byte(0)?, byte(2)?, byte(4)?))
|
||||
}
|
||||
|
||||
/// Format a [`Color32`] as opaque `#rrggbb`.
|
||||
pub fn to_hex(c: Color32) -> String {
|
||||
let [r, g, b, _] = c.to_srgba_unmultiplied();
|
||||
format!("#{r:02x}{g:02x}{b:02x}")
|
||||
}
|
||||
|
||||
/// serde adaptor so `Color32` fields round-trip as hex strings in TOML.
|
||||
mod hex {
|
||||
use super::{parse_hex, to_hex};
|
||||
use eframe::egui::Color32;
|
||||
use serde::{Deserialize, Deserializer, Serializer, de::Error};
|
||||
|
||||
pub fn serialize<S: Serializer>(c: &Color32, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&to_hex(*c))
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Color32, D::Error> {
|
||||
let s = String::deserialize(d)?;
|
||||
parse_hex(&s).ok_or_else(|| {
|
||||
D::Error::custom(format!(
|
||||
"invalid hex colour {s:?} (expected #rrggbb or #rrggbbaa)"
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hex_round_trips() {
|
||||
for (input, expect) in [
|
||||
("#1e1e2e", Color32::from_rgb(0x1e, 0x1e, 0x2e)),
|
||||
("aabbcc", Color32::from_rgb(0xaa, 0xbb, 0xcc)),
|
||||
// 8-digit is accepted but the alpha is dropped (opaque rgb).
|
||||
("#11223344", Color32::from_rgb(0x11, 0x22, 0x33)),
|
||||
] {
|
||||
assert_eq!(parse_hex(input).expect("parses"), expect);
|
||||
}
|
||||
assert_eq!(to_hex(Color32::from_rgb(0x1e, 0x1e, 0x2e)), "#1e1e2e");
|
||||
// Opaque colours round-trip exactly.
|
||||
let c = Color32::from_rgb(0xab, 0xcd, 0xef);
|
||||
assert_eq!(parse_hex(&to_hex(c)), Some(c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_rejects_garbage() {
|
||||
for bad in ["", "#fff", "#12345", "nothex", "#gggggg", "#1234567"] {
|
||||
assert!(parse_hex(bad).is_none(), "{bad:?} should not parse");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_toml_round_trips() {
|
||||
let original = catppuccin_mocha();
|
||||
let toml = toml::to_string_pretty(&original).unwrap();
|
||||
let parsed: Theme = toml::from_str(&toml).unwrap();
|
||||
assert_eq!(original, parsed);
|
||||
// Colours serialise as hex strings, not RGBA tables.
|
||||
assert!(toml.contains("window_bg = \"#1e1e2e\""), "{toml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_toml_fills_from_default() {
|
||||
// Only a name and one colour; everything else must fall back to Default Dark.
|
||||
let parsed: Theme = toml::from_str("name = \"Partial\"\naccent = \"#ff0000\"").unwrap();
|
||||
let base = default_dark();
|
||||
assert_eq!(parsed.name, "Partial");
|
||||
assert_eq!(parsed.accent, Color32::from_rgb(0xff, 0, 0));
|
||||
assert_eq!(parsed.window_bg, base.window_bg); // filled from default
|
||||
assert_eq!(parsed.text, base.text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_is_filesystem_safe() {
|
||||
assert_eq!(slugify("Catppuccin Mocha"), "catppuccin-mocha");
|
||||
assert_eq!(slugify(" My Theme!! "), "my-theme");
|
||||
assert_eq!(slugify("***"), "theme");
|
||||
assert_eq!(slugify("Solarized/Dark"), "solarized-dark");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtins_match_names() {
|
||||
let names: Vec<String> = builtins().iter().map(|t| t.name.clone()).collect();
|
||||
let expected: Vec<String> = BUILTIN_NAMES.iter().map(|s| s.to_string()).collect();
|
||||
assert_eq!(names, expected);
|
||||
for t in builtins() {
|
||||
assert!(is_builtin(&t.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
//! System-tray (StatusNotifierItem) integration for the GUI.
|
||||
//!
|
||||
//! The tray runs on its **own dedicated thread** with its own current-thread
|
||||
//! tokio runtime, fully decoupled from the winit event loop (which owns the
|
||||
//! main thread) and from the process-wide `#[tokio::main]` runtime. It talks to
|
||||
//! the egui app purely over winit's event channel and a status channel:
|
||||
//!
|
||||
//! * tray → app: a [`super::UserEvent::Tray`] carrying a [`TrayAction`]
|
||||
//! (Show / Quit), pushed through the [`winit::event_loop::EventLoopProxy`].
|
||||
//! Using the proxy (not egui's repaint) is essential: a tray click must
|
||||
//! wake the winit loop even when the window has been **dropped** (hidden to
|
||||
//! tray), so the loop can recreate it.
|
||||
//! * app → tray: [`TrayStatus`] (idle / hosting / viewing), pushed on change.
|
||||
//!
|
||||
//! Why a separate thread instead of `Handle::current().spawn`: updating the
|
||||
//! tray from the egui thread would need `block_on`, which panics when called
|
||||
//! from inside the running runtime. Keeping ksni's async wholly on its own
|
||||
//! runtime sidesteps that and keeps the frame loop non-blocking.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use ksni::TrayMethods;
|
||||
use winit::event_loop::EventLoopProxy;
|
||||
|
||||
use super::UserEvent;
|
||||
|
||||
/// What the user picked from the tray icon or its menu (tray thread → app),
|
||||
/// delivered as a [`UserEvent::Tray`].
|
||||
pub enum TrayAction {
|
||||
/// Left-click, or the "Show window" item: bring the window back.
|
||||
Show,
|
||||
/// The "Quit" item: really exit (the close button only hides to tray).
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// What the tray icon's tooltip/menu reflect (app → tray thread).
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TrayStatus {
|
||||
Idle,
|
||||
Hosting { active: u32, max: u32 },
|
||||
Viewing,
|
||||
}
|
||||
|
||||
fn status_text(status: TrayStatus) -> String {
|
||||
match status {
|
||||
TrayStatus::Idle => "Idle".to_string(),
|
||||
TrayStatus::Hosting { active, max } => {
|
||||
format!("Hosting — {active} of {max} viewer(s) connected")
|
||||
}
|
||||
TrayStatus::Viewing => "Viewing a stream".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle held by the egui app for the lifetime of the window. Dropping it
|
||||
/// closes the app→tray channel, which ends the tray thread and removes the icon.
|
||||
pub struct TrayHandle {
|
||||
status_tx: tokio::sync::mpsc::UnboundedSender<TrayStatus>,
|
||||
/// Set true once the tray actually registered with a StatusNotifier host.
|
||||
/// The app must not divert the window's close to a tray that never appeared.
|
||||
registered: Arc<AtomicBool>,
|
||||
/// Last status pushed, so we don't spam D-Bus with no-op updates.
|
||||
last_sent: Option<TrayStatus>,
|
||||
}
|
||||
|
||||
impl TrayHandle {
|
||||
/// Whether a system tray is actually showing our icon. Until this is true,
|
||||
/// hiding the window would strand it with no way back.
|
||||
pub fn registered(&self) -> bool {
|
||||
self.registered.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Push a status change to the tray, deduped against the last one sent.
|
||||
pub fn set_status(&mut self, status: TrayStatus) {
|
||||
if self.last_sent != Some(status) {
|
||||
let _ = self.status_tx.send(status);
|
||||
self.last_sent = Some(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PixelPassTray {
|
||||
status: TrayStatus,
|
||||
/// ARGB pixmap, so the icon shows even where the themed "pixelpass" name
|
||||
/// can't be resolved (e.g. running the dev binary before `make install`).
|
||||
icon: Vec<ksni::Icon>,
|
||||
/// Wakes the winit loop and delivers the action — works even when the
|
||||
/// window has been dropped to the tray (no egui frame is running then).
|
||||
proxy: EventLoopProxy<UserEvent>,
|
||||
/// Shared with [`TrayHandle`]; kept in sync with the watcher's presence via
|
||||
/// the `watcher_online`/`watcher_offline` callbacks so the app never diverts
|
||||
/// a close to a tray that has since disappeared.
|
||||
registered: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl PixelPassTray {
|
||||
fn notify(&self, action: TrayAction) {
|
||||
let _ = self.proxy.send_event(UserEvent::Tray(action));
|
||||
}
|
||||
}
|
||||
|
||||
impl ksni::Tray for PixelPassTray {
|
||||
fn id(&self) -> String {
|
||||
"pixelpass".to_string()
|
||||
}
|
||||
|
||||
fn title(&self) -> String {
|
||||
"PixelPass".to_string()
|
||||
}
|
||||
|
||||
// Themed icon (matches the installed hicolor/scalable/apps/pixelpass.svg);
|
||||
// icon_pixmap below is the always-works fallback.
|
||||
fn icon_name(&self) -> String {
|
||||
"pixelpass".to_string()
|
||||
}
|
||||
|
||||
fn icon_pixmap(&self) -> Vec<ksni::Icon> {
|
||||
self.icon.clone()
|
||||
}
|
||||
|
||||
fn status(&self) -> ksni::Status {
|
||||
ksni::Status::Active
|
||||
}
|
||||
|
||||
fn tool_tip(&self) -> ksni::ToolTip {
|
||||
ksni::ToolTip {
|
||||
title: "PixelPass".to_string(),
|
||||
description: status_text(self.status),
|
||||
icon_name: "pixelpass".to_string(),
|
||||
icon_pixmap: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn activate(&mut self, _x: i32, _y: i32) {
|
||||
self.notify(TrayAction::Show);
|
||||
}
|
||||
|
||||
/// The StatusNotifierWatcher came back (e.g. the panel restarted). Mark the
|
||||
/// tray live again so close-to-tray can resume hiding the window.
|
||||
fn watcher_online(&self) {
|
||||
self.registered.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
/// The watcher went away (panel restart, tray plugin disabled, …). Clear the
|
||||
/// flag so a subsequent close quits normally instead of destroying the window
|
||||
/// into a tray that no longer exists, and force the window back now in case
|
||||
/// it was already hidden (otherwise it'd be stranded with no way to restore).
|
||||
/// Returning `true` keeps the service alive so it re-registers if the watcher
|
||||
/// returns.
|
||||
fn watcher_offline(&self, reason: ksni::OfflineReason) -> bool {
|
||||
tracing::warn!("tray: StatusNotifierWatcher offline ({reason:?}); restoring window");
|
||||
self.registered.store(false, Ordering::Release);
|
||||
self.notify(TrayAction::Show);
|
||||
true
|
||||
}
|
||||
|
||||
fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
|
||||
use ksni::menu::{MenuItem, StandardItem};
|
||||
vec![
|
||||
// Non-clickable status line.
|
||||
StandardItem {
|
||||
label: status_text(self.status),
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
MenuItem::Separator,
|
||||
StandardItem {
|
||||
label: "Show window".to_string(),
|
||||
activate: Box::new(|t: &mut Self| t.notify(TrayAction::Show)),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
StandardItem {
|
||||
label: "Quit PixelPass".to_string(),
|
||||
icon_name: "application-exit".to_string(),
|
||||
activate: Box::new(|t: &mut Self| t.notify(TrayAction::Quit)),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode the embedded PNG (RGBA) and convert to the ARGB pixmap ksni wants.
|
||||
/// Reuses eframe's PNG decoder so we don't take a direct `image` dependency.
|
||||
fn load_icon() -> Option<Vec<ksni::Icon>> {
|
||||
let icon =
|
||||
eframe::icon_data::from_png_bytes(include_bytes!("../../assets/pixelpass-256.png")).ok()?;
|
||||
let mut data = icon.rgba; // RGBA8, row-major
|
||||
for px in data.chunks_exact_mut(4) {
|
||||
px.rotate_right(1); // [r,g,b,a] -> [a,r,g,b], network byte order
|
||||
}
|
||||
Some(vec![ksni::Icon {
|
||||
width: icon.width as i32,
|
||||
height: icon.height as i32,
|
||||
data,
|
||||
}])
|
||||
}
|
||||
|
||||
/// Start the tray on its own thread. Returns a handle for the app to drive it,
|
||||
/// or `None` if the icon couldn't be decoded or the thread couldn't spawn (in
|
||||
/// which case the GUI simply runs without a tray — close behaves as before).
|
||||
pub fn start(proxy: EventLoopProxy<UserEvent>) -> Option<TrayHandle> {
|
||||
let icon = load_icon()?;
|
||||
let (status_tx, mut status_rx) = tokio::sync::mpsc::unbounded_channel::<TrayStatus>();
|
||||
let registered = Arc::new(AtomicBool::new(false));
|
||||
let registered_thread = registered.clone();
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("pixelpass-tray".to_string())
|
||||
.spawn(move || {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
tracing::warn!("tray: could not build runtime: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
rt.block_on(async move {
|
||||
let tray = PixelPassTray {
|
||||
status: TrayStatus::Idle,
|
||||
icon,
|
||||
proxy,
|
||||
registered: registered_thread.clone(),
|
||||
};
|
||||
let handle = match tray.spawn().await {
|
||||
Ok(handle) => handle,
|
||||
Err(e) => {
|
||||
// No StatusNotifier host (no system tray) — degrade
|
||||
// gracefully: the window keeps its normal close.
|
||||
tracing::warn!("tray: not available, running without it: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
registered_thread.store(true, Ordering::Release);
|
||||
|
||||
// Apply status changes until the app drops its sender (on quit),
|
||||
// which ends this loop, the runtime, the thread, and the icon.
|
||||
while let Some(status) = status_rx.recv().await {
|
||||
let _ = handle
|
||||
.update(move |t: &mut PixelPassTray| t.status = status)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
Some(TrayHandle {
|
||||
status_tx,
|
||||
registered,
|
||||
last_sent: None,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
//! Phase 4 — the AEC identity validation state machine (impl plan §4, design
|
||||
//! v3.4 §5.2/§5.3).
|
||||
//!
|
||||
//! peerspeak's echo canceller (`module-echo-cancel`) creates four graph nodes
|
||||
//! that all carry `pulse.module.id == <the index pactl returned>`, and the
|
||||
//! playback leg among them is a `Stream/Output/Audio` node wired straight to
|
||||
//! the speakers — a fan-out candidate that would copy the whole remote call
|
||||
//! into the share unless it is excluded (v3.4 §5.2, measured ≈desktop level).
|
||||
//! The taint engine (phase 2) already excludes it *given* the module index in
|
||||
//! [`ExclusionCtx::aec_module_id`](crate::host::taint::ExclusionCtx); this
|
||||
//! module is what decides, at runtime and fail-closed, whether that index may
|
||||
//! be trusted and handed over.
|
||||
//!
|
||||
//! **Why a state machine and not a one-shot check (v3.4 §5.3).** The identity
|
||||
//! is an *observed correlation on PipeWire 1.6.8*, not a documented contract,
|
||||
//! and a start-time enumeration races in both directions: peerspeak's
|
||||
//! `enable()` returns before the playback hazard leg is even in the graph, and
|
||||
//! pixelpass's capture spawns lazily on the first viewer, at a moment peerspeak
|
||||
//! does not control. So validation is a bounded epoch, and the identity can be
|
||||
//! *lost* mid-share (the module unloads) as well as *gained*.
|
||||
//!
|
||||
//! **The two traps this is shaped around:**
|
||||
//!
|
||||
//! - **Revocation is loss of the whole module identity, not one leg corking**
|
||||
//! (v3.4 §5.3). Each [`AecValidator::observe`] rescans the snapshot for *any*
|
||||
//! node bearing the index; [`AecState::Validated`] drops to
|
||||
//! [`AecState::Revoked`] only when that set becomes **empty**. A single leg
|
||||
//! corking or relinking (still ≥1 present) stays `Validated` — getting this
|
||||
//! wrong turns a normal cork into a spurious share-wide audio stop.
|
||||
//! - **Module indices are reused verbatim across unload/reload** (v3.4 §5.2
|
||||
//! correction 3 — both a reload's module index *and* its `node.link-group`
|
||||
//! came back byte-identical, and node ids were recycled *and reassigned
|
||||
//! across legs*). So [`AecState::Failed`] and [`AecState::Revoked`] are
|
||||
//! **sticky terminal**: a later node reappearing with the same index does
|
||||
//! **not** un-revoke and alias onto the new module. A genuine reload gets a
|
||||
//! *fresh* [`AecValidator`] (peerspeak re-tells pixelpass the index on every
|
||||
//! load), never a resurrected one.
|
||||
//!
|
||||
//! **Scope.** This is the validation state machine + `--aec` parsing only.
|
||||
//! Foreign / second-AEC detection (a non-owned `echo-cancel-*` group, v3.4
|
||||
//! §5.4 / D3) and the `foreign_aec_warning`/`aec_failed`/`aec_revoked` status
|
||||
//! *events* are phase 6's, which reads this machine's [`AecState`]. Wiring the
|
||||
//! parsed [`AecConfig`] out of the CLI and calling [`AecValidator::observe`]
|
||||
//! in the recompute loop is integration (phases 5/8). The node-side
|
||||
//! `pulse.module.id` parse (JSON-number-vs-string, u64-not-u32) is phase 3's
|
||||
//! adapter; this module consumes the already-parsed
|
||||
//! [`NodeProps::pulse_module_id`](crate::host::taint::snapshot::NodeProps).
|
||||
|
||||
#![allow(dead_code)] // Wired into `--aec` parsing + the recompute loop by later phases.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use crate::host::observer::Millis;
|
||||
use crate::host::taint::snapshot::GraphSnapshot;
|
||||
|
||||
/// The parsed `--aec=off|pulse-module:<idx>` argument (decision D5).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AecConfig {
|
||||
/// `--aec=off` — peerspeak's AEC is not in play, so there is nothing to
|
||||
/// exclude and fan-out proceeds with no AEC identity. Not the same as an
|
||||
/// *absent* argument (that default is the caller's; see [`parse_aec_arg`]).
|
||||
Off,
|
||||
/// `--aec=pulse-module:<idx>` — validate this live module index before
|
||||
/// trusting it. The index is compared as `u64`, never `u32` (v3.4 §5.2).
|
||||
PulseModule(u64),
|
||||
}
|
||||
|
||||
/// Why an `--aec` argument was rejected. Rejection is fatal at the CLI edge —
|
||||
/// there is no fail-closed *default* index, because a wrong index would exclude
|
||||
/// the wrong node (or nothing), so a malformed value must not silently become
|
||||
/// "no AEC".
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AecParseError {
|
||||
/// The value was empty.
|
||||
Empty,
|
||||
/// Not `off` and not `pulse-module:...`.
|
||||
UnknownForm,
|
||||
/// `pulse-module:` with nothing after the colon.
|
||||
MissingIndex,
|
||||
/// The index was not a bare `u64` decimal (sign, whitespace, non-digit, or
|
||||
/// `> u64::MAX`).
|
||||
InvalidIndex,
|
||||
}
|
||||
|
||||
/// Parse one `--aec` value. `off` and `pulse-module:<idx>` are the only forms.
|
||||
///
|
||||
/// The index accepts values `> u32::MAX` (v3.4 §5.2: `pulse.module.id` sits
|
||||
/// next to the `object.serial` u32-truncation bug, so it is only ever compared
|
||||
/// as `u64`) and requires a **bare decimal** — stricter than Rust's [`u64`]
|
||||
/// parser, which also accepts a leading `+`. Rejected: any sign, surrounding or
|
||||
/// interior whitespace, non-decimal digits, and overflow. Matching is exact and
|
||||
/// case-sensitive: the argument is machine-generated by peerspeak from
|
||||
/// `EchoCancelGuard::module_index`, not typed by a user.
|
||||
///
|
||||
/// ⚠️ **Producer contract** (Codex phase-4 review, finding 5): because the
|
||||
/// grammar is narrower than Rust's parser, peerspeak must emit a bare decimal.
|
||||
/// `pactl load-module` returns an unsigned decimal, so the stored index is
|
||||
/// already canonical and no reachable value is rejected; if peerspeak ever
|
||||
/// changes how it formats the index it must canonicalize (`value.to_string()`),
|
||||
/// not widen this parser — the narrow grammar is the point.
|
||||
pub fn parse_aec_arg(value: &str) -> Result<AecConfig, AecParseError> {
|
||||
if value.is_empty() {
|
||||
return Err(AecParseError::Empty);
|
||||
}
|
||||
if value == "off" {
|
||||
return Ok(AecConfig::Off);
|
||||
}
|
||||
if let Some(index) = value.strip_prefix("pulse-module:") {
|
||||
if index.is_empty() {
|
||||
return Err(AecParseError::MissingIndex);
|
||||
}
|
||||
// A bare decimal only: reject a leading sign (Rust's `u64` parser
|
||||
// accepts `+7`), interior/surrounding whitespace, and any non-digit,
|
||||
// before letting the parser catch overflow. Leading zeros are harmless.
|
||||
if !index.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(AecParseError::InvalidIndex);
|
||||
}
|
||||
return index
|
||||
.parse::<u64>()
|
||||
.map(AecConfig::PulseModule)
|
||||
.map_err(|_| AecParseError::InvalidIndex);
|
||||
}
|
||||
Err(AecParseError::UnknownForm)
|
||||
}
|
||||
|
||||
/// The validation epoch (v3.4 §5.3, verbatim).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AecState {
|
||||
/// `--aec=off` — no AEC identity, fan-out proceeds with no exclusion.
|
||||
/// Terminal.
|
||||
NotConfigured,
|
||||
/// Waiting for the first node bearing the index. **No fan-out occurs here**
|
||||
/// — silence is the safe direction. Ends at `Validated` on first sight, or
|
||||
/// `Failed` once the graph is fully enumerated and the bounded deadline
|
||||
/// passes with the index never seen.
|
||||
Validating,
|
||||
/// The index was observed live. Fan-out is permitted, excluding that
|
||||
/// identity transitively (phase 2 / v3.4 §6.1).
|
||||
Validated,
|
||||
/// The deadline expired with the index never observed. **Fail closed** — no
|
||||
/// fan-out; the caller reports a capability failure rather than sharing.
|
||||
/// Sticky terminal.
|
||||
Failed,
|
||||
/// The whole module identity disappeared mid-share (every node bearing the
|
||||
/// index gone). **Stop fan-out now** and drop the owned link proxies; do
|
||||
/// not keep the numeric index and hope, because it is reused. Sticky
|
||||
/// terminal — see the module header's second trap.
|
||||
Revoked,
|
||||
}
|
||||
|
||||
/// The bounded, read-only AEC identity validator. Fold the live graph in with
|
||||
/// [`AecValidator::observe`] once per recompute; read the result with
|
||||
/// [`AecValidator::state`], [`AecValidator::fan_out_permitted`], and
|
||||
/// [`AecValidator::validated_module_id`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AecValidator {
|
||||
/// The index to validate. `None` iff [`AecConfig::Off`] (state stays
|
||||
/// [`AecState::NotConfigured`] forever).
|
||||
target: Option<u64>,
|
||||
state: AecState,
|
||||
/// The `Validating → Failed` budget, applied *after* the deadline is armed.
|
||||
timeout: Millis,
|
||||
/// The absolute `Failed` deadline, armed the first time the graph reports
|
||||
/// ready (the "registry sync barrier" of v3.4 §5.3) and never re-armed —
|
||||
/// `graph_ready` is dynamic and can flap, but the epoch budget must not
|
||||
/// restart. `None` until then: while the initial enumeration is still in
|
||||
/// flight, a not-yet-seen index is *unknown*, not *absent*, so it must not
|
||||
/// time out to `Failed`.
|
||||
deadline: Option<Millis>,
|
||||
}
|
||||
|
||||
impl AecValidator {
|
||||
/// `timeout` is the `Validating → Failed` budget, counted from the moment
|
||||
/// the graph first becomes ready (not from construction). An `Off` config
|
||||
/// starts (and stays) [`AecState::NotConfigured`].
|
||||
pub fn new(config: AecConfig, timeout: Millis) -> Self {
|
||||
match config {
|
||||
AecConfig::Off => Self {
|
||||
target: None,
|
||||
state: AecState::NotConfigured,
|
||||
timeout,
|
||||
deadline: None,
|
||||
},
|
||||
AecConfig::PulseModule(index) => Self {
|
||||
target: Some(index),
|
||||
state: AecState::Validating,
|
||||
timeout,
|
||||
deadline: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(&self) -> AecState {
|
||||
self.state
|
||||
}
|
||||
|
||||
/// The validated index to place in
|
||||
/// [`ExclusionCtx::aec_module_id`](crate::host::taint::ExclusionCtx) —
|
||||
/// `Some` **only** in [`AecState::Validated`]. `None` everywhere else,
|
||||
/// including `NotConfigured` (no AEC ⇒ nothing to exclude) and the
|
||||
/// fail-closed states (whose `None` must be paired with
|
||||
/// [`Self::fan_out_permitted`] `== false`, i.e. no fan-out at all — *not*
|
||||
/// a fan-out that merely skips AEC exclusion).
|
||||
pub fn validated_module_id(&self) -> Option<u64> {
|
||||
match self.state {
|
||||
AecState::Validated => self.target,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether fan-out may proceed at all right now. True only in
|
||||
/// [`AecState::NotConfigured`] (fan out, no exclusion) and
|
||||
/// [`AecState::Validated`] (fan out, excluding the identity). `Validating`,
|
||||
/// `Failed` and `Revoked` all forbid it — silence over echo.
|
||||
pub fn fan_out_permitted(&self) -> bool {
|
||||
matches!(self.state, AecState::NotConfigured | AecState::Validated)
|
||||
}
|
||||
|
||||
/// Fold one recompute's view of the graph into the machine.
|
||||
///
|
||||
/// `graph_ready` is the observer's dynamic readiness
|
||||
/// ([`Projection::graph_ready`](crate::host::observer::Projection)); `now`
|
||||
/// is a monotonic millisecond clock. Positive evidence (a node bearing the
|
||||
/// index) is authoritative and validates regardless of `graph_ready` —
|
||||
/// seeing the node *is* seeing it — but the `Failed` deadline only begins
|
||||
/// once `graph_ready` has first become true, so a slow initial enumeration
|
||||
/// can never masquerade as a genuinely-absent module.
|
||||
pub fn observe(&mut self, snapshot: &GraphSnapshot, graph_ready: bool, now: Millis) {
|
||||
// `Off` (NotConfigured) and both sticky terminals are no-ops: there is
|
||||
// nothing to look for, and a reappearing reused index must not revive a
|
||||
// Failed/Revoked epoch (v3.4 §5.2 correction 3).
|
||||
let Some(target) = self.target else {
|
||||
return;
|
||||
};
|
||||
match self.state {
|
||||
AecState::Validating => {
|
||||
// Presence is checked *before* the deadline on purpose: a
|
||||
// demonstrably-present identity validates regardless of the
|
||||
// clock, even if the node is first seen just past the deadline
|
||||
// (Codex phase-4 review, finding 2). The deadline only bounds
|
||||
// the wait for an identity that is never seen — seeing it, late
|
||||
// or not, is ground truth that the module exists, and excluding
|
||||
// a real echo leg is always the safe answer. (A `Failed` can
|
||||
// still pre-empt this when a `Tick`-only observation crosses the
|
||||
// deadline first; that only makes the machine *more* fail-closed,
|
||||
// never less.)
|
||||
if self.index_present(snapshot, target) {
|
||||
self.state = AecState::Validated;
|
||||
return;
|
||||
}
|
||||
// Arm the deadline once, on the first ready graph.
|
||||
if self.deadline.is_none() && graph_ready {
|
||||
self.deadline = Some(now.saturating_add(self.timeout));
|
||||
}
|
||||
if self.deadline.is_some_and(|deadline| now >= deadline) {
|
||||
self.state = AecState::Failed;
|
||||
}
|
||||
}
|
||||
AecState::Validated => {
|
||||
// Revocation is the whole identity gone (no node bears the
|
||||
// index), not one leg corking — see the module header.
|
||||
//
|
||||
// ⚠️ **Deliberately NOT gated on `graph_ready`** (Codex
|
||||
// phase-4 review, findings 1 + 4). Two forces pull opposite
|
||||
// ways and this is the resolution:
|
||||
//
|
||||
// - Gating revoke on readiness would avoid a *spurious* revoke
|
||||
// from a transient empty snapshot seen while the module is
|
||||
// still live. But for the AEC that transient does not exist:
|
||||
// its four nodes are two `Stream/*` legs plus a null-sink-like
|
||||
// virtual sink/source, none of which claim a `device.id`, so
|
||||
// the phase-3 observer never *withholds* them
|
||||
// (`observer::classify` withholds only device-claiming nodes).
|
||||
// `index_present` therefore goes false only on a genuine
|
||||
// `global_remove` of every leg — a real unload — and a real
|
||||
// unload *should* revoke.
|
||||
// - Worse, gating on readiness would REOPEN the reused-index
|
||||
// alias trap: if an unload+reload (indices recycle, §5.2
|
||||
// correction 3) both complete inside one not-ready churn
|
||||
// window, the ready snapshot would already show the *new*
|
||||
// module's node and we would never observe the empty gap —
|
||||
// silently aliasing onto an unrelated module. Revoking the
|
||||
// instant the gap appears, ready or not, is what closes it.
|
||||
//
|
||||
// This correctness rests on the phase-5/6 integration contract:
|
||||
// **one `observe` per graph event, no coalescing across a module
|
||||
// lifetime boundary.** Under coalescing, the empty gap between an
|
||||
// old unload and a reused-index reload can be skipped. The
|
||||
// robust fix that would not depend on that contract is a
|
||||
// serial-continuity / observer-generation signal (the AEC nodes'
|
||||
// `object.serial`s are fresh across a reload even when the index
|
||||
// is not) — owed to a later hardening round, not built here.
|
||||
if !self.index_present(snapshot, target) {
|
||||
self.state = AecState::Revoked;
|
||||
}
|
||||
}
|
||||
AecState::NotConfigured | AecState::Failed | AecState::Revoked => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any node in the snapshot bears the target module index. The same
|
||||
/// exact-`u64`-equality predicate the taint engine roots on
|
||||
/// (`taint/mod.rs`), kept here so "is the identity live?" has one
|
||||
/// definition.
|
||||
fn index_present(&self, snapshot: &GraphSnapshot, target: u64) -> bool {
|
||||
snapshot
|
||||
.nodes()
|
||||
.any(|node| node.props.pulse_module_id == Some(target))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
//! Phase 4 exit gate (impl plan §4): a fake-clock / event-sequence transition
|
||||
//! matrix, because these are timing semantics a live poke cannot cover.
|
||||
|
||||
use super::*;
|
||||
use crate::host::taint::snapshot::{
|
||||
GlobalId, GraphSnapshot, MediaRole, NodeProps, NodeSnapshot, Serial,
|
||||
};
|
||||
|
||||
/// A `Stream/Output/Audio` node carrying `pulse.module.id == module` (or none).
|
||||
/// Only the fields the validator reads matter; the rest take their defaults.
|
||||
fn node(serial: u64, module: Option<u64>) -> NodeSnapshot {
|
||||
NodeSnapshot {
|
||||
serial: Serial(serial),
|
||||
id: GlobalId(serial as u32),
|
||||
name: None,
|
||||
role: MediaRole::StreamOutput,
|
||||
props: NodeProps {
|
||||
pulse_module_id: module,
|
||||
..NodeProps::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// A snapshot holding exactly the given nodes (no ports/links/clients — the
|
||||
/// validator reads only nodes).
|
||||
fn snapshot(nodes: Vec<NodeSnapshot>) -> GraphSnapshot {
|
||||
GraphSnapshot::new(nodes, vec![], vec![], vec![])
|
||||
}
|
||||
|
||||
fn empty() -> GraphSnapshot {
|
||||
snapshot(vec![])
|
||||
}
|
||||
|
||||
const IDX: u64 = 536_870_919; // 0x20000007 — a real pipewire-pulse module index.
|
||||
const TIMEOUT: Millis = 2_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing (D5): off / pulse-module:<idx> / > u32::MAX / absent / malformed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parses_off() {
|
||||
assert_eq!(parse_aec_arg("off"), Ok(AecConfig::Off));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_pulse_module_index() {
|
||||
assert_eq!(
|
||||
parse_aec_arg("pulse-module:536870919"),
|
||||
Ok(AecConfig::PulseModule(536_870_919)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_index_beyond_u32() {
|
||||
// v3.4 §5.2: compare as u64, never u32. A value one past u32::MAX must
|
||||
// round-trip, not truncate or reject.
|
||||
let big = u64::from(u32::MAX) + 1;
|
||||
assert_eq!(
|
||||
parse_aec_arg(&format!("pulse-module:{big}")),
|
||||
Ok(AecConfig::PulseModule(big)),
|
||||
);
|
||||
assert_eq!(
|
||||
parse_aec_arg(&format!("pulse-module:{}", u64::MAX)),
|
||||
Ok(AecConfig::PulseModule(u64::MAX)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty() {
|
||||
assert_eq!(parse_aec_arg(""), Err(AecParseError::Empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_form() {
|
||||
assert_eq!(parse_aec_arg("on"), Err(AecParseError::UnknownForm));
|
||||
assert_eq!(parse_aec_arg("module:5"), Err(AecParseError::UnknownForm));
|
||||
assert_eq!(parse_aec_arg("536870919"), Err(AecParseError::UnknownForm));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_index() {
|
||||
assert_eq!(
|
||||
parse_aec_arg("pulse-module:"),
|
||||
Err(AecParseError::MissingIndex),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_index() {
|
||||
for bad in [
|
||||
"pulse-module:-1", // sign
|
||||
"pulse-module:+7", // sign
|
||||
"pulse-module: 7", // leading whitespace
|
||||
"pulse-module:7 ", // trailing whitespace
|
||||
"pulse-module:0x7", // hex
|
||||
"pulse-module:7.0", // non-integer
|
||||
"pulse-module:abc", // non-numeric
|
||||
"pulse-module:18446744073709551616", // u64::MAX + 1 (overflow)
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_aec_arg(bad),
|
||||
Err(AecParseError::InvalidIndex),
|
||||
"{bad} should be InvalidIndex",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NotConfigured (--aec=off): benign, terminal, fan-out with no exclusion.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn off_is_not_configured_and_permits_fan_out_with_no_identity() {
|
||||
let mut v = AecValidator::new(AecConfig::Off, TIMEOUT);
|
||||
assert_eq!(v.state(), AecState::NotConfigured);
|
||||
assert!(v.fan_out_permitted());
|
||||
assert_eq!(v.validated_module_id(), None);
|
||||
|
||||
// Even a snapshot full of module nodes never moves it off NotConfigured.
|
||||
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 10_000);
|
||||
assert_eq!(v.state(), AecState::NotConfigured);
|
||||
assert!(v.fan_out_permitted());
|
||||
assert_eq!(v.validated_module_id(), None);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row: Validating → Validated on first matching node; no fan-out before.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn validating_forbids_fan_out_and_exposes_no_identity() {
|
||||
let v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||
assert_eq!(v.state(), AecState::Validating);
|
||||
assert!(!v.fan_out_permitted());
|
||||
assert_eq!(v.validated_module_id(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validating_to_validated_on_first_matching_node() {
|
||||
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||
// A node with a *different* index does not validate.
|
||||
v.observe(&snapshot(vec![node(1, Some(IDX + 1))]), true, 0);
|
||||
assert_eq!(v.state(), AecState::Validating);
|
||||
|
||||
v.observe(&snapshot(vec![node(2, Some(IDX))]), true, 100);
|
||||
assert_eq!(v.state(), AecState::Validated);
|
||||
assert!(v.fan_out_permitted());
|
||||
assert_eq!(v.validated_module_id(), Some(IDX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positive_evidence_validates_even_before_graph_ready() {
|
||||
// Seeing the node is authoritative; readiness only gates the Failed clock.
|
||||
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||
v.observe(&snapshot(vec![node(1, Some(IDX))]), false, 0);
|
||||
assert_eq!(v.state(), AecState::Validated);
|
||||
assert_eq!(v.validated_module_id(), Some(IDX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validated_index_is_compared_beyond_u32() {
|
||||
let big = u64::from(u32::MAX) + 7;
|
||||
let mut v = AecValidator::new(AecConfig::PulseModule(big), TIMEOUT);
|
||||
// A node whose id equals `big` only in its low 32 bits must not match.
|
||||
v.observe(
|
||||
&snapshot(vec![node(1, Some(big & u64::from(u32::MAX)))]),
|
||||
true,
|
||||
0,
|
||||
);
|
||||
assert_eq!(v.state(), AecState::Validating);
|
||||
|
||||
v.observe(&snapshot(vec![node(2, Some(big))]), true, 1);
|
||||
assert_eq!(v.state(), AecState::Validated);
|
||||
assert_eq!(v.validated_module_id(), Some(big));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row: Validating → Failed on deadline expiry; and the deadline is armed only
|
||||
// once the graph is ready (the registry sync barrier).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn validating_to_failed_on_deadline_expiry() {
|
||||
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||
v.observe(&empty(), true, 0); // arms deadline at 0 + 2000
|
||||
assert_eq!(v.state(), AecState::Validating);
|
||||
|
||||
v.observe(&empty(), true, TIMEOUT - 1);
|
||||
assert_eq!(v.state(), AecState::Validating);
|
||||
|
||||
v.observe(&empty(), true, TIMEOUT); // now >= deadline
|
||||
assert_eq!(v.state(), AecState::Failed);
|
||||
assert!(!v.fan_out_permitted());
|
||||
assert_eq!(v.validated_module_id(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deadline_is_not_armed_until_graph_ready() {
|
||||
// The whole point of arming-on-ready: a slow initial enumeration is
|
||||
// "unknown", not "absent", and must never time out to Failed.
|
||||
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||
// Long past the would-be deadline, but the graph has never been ready.
|
||||
v.observe(&empty(), false, 10 * TIMEOUT);
|
||||
assert_eq!(v.state(), AecState::Validating);
|
||||
// Still no Failed even much later, as long as ready stays false.
|
||||
v.observe(&empty(), false, 100 * TIMEOUT);
|
||||
assert_eq!(v.state(), AecState::Validating);
|
||||
|
||||
// And when readiness finally arrives, the FULL budget starts *there*, not
|
||||
// relative to construction (Codex phase-4 review, finding 3): a mutant that
|
||||
// armed a construction-relative deadline would fail immediately here.
|
||||
let late = 200_000;
|
||||
v.observe(&empty(), true, late); // first ready → arm at `late`
|
||||
assert_eq!(v.state(), AecState::Validating);
|
||||
v.observe(&empty(), true, late + TIMEOUT - 1);
|
||||
assert_eq!(v.state(), AecState::Validating);
|
||||
v.observe(&empty(), true, late + TIMEOUT);
|
||||
assert_eq!(v.state(), AecState::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_positive_evidence_wins_over_expired_deadline() {
|
||||
// A node first seen just past the deadline still validates: the deadline
|
||||
// only bounds the wait for an identity that is never seen, and a
|
||||
// demonstrably-present module is ground truth (Codex phase-4 review,
|
||||
// finding 2). Reachable only when the first post-deadline observation
|
||||
// carries the node with no intervening Tick-only observation.
|
||||
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||
v.observe(&empty(), true, 0); // arm deadline at 2000
|
||||
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, TIMEOUT + 1);
|
||||
assert_eq!(v.state(), AecState::Validated);
|
||||
assert_eq!(v.validated_module_id(), Some(IDX));
|
||||
|
||||
// Whereas a Tick-only observation that crosses the deadline first pre-empts
|
||||
// it to Failed (stickily), even if the node then shows up — fail-closed.
|
||||
let mut w = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||
w.observe(&empty(), true, 0);
|
||||
w.observe(&empty(), true, TIMEOUT); // Tick-only crosses the line first
|
||||
assert_eq!(w.state(), AecState::Failed);
|
||||
w.observe(&snapshot(vec![node(1, Some(IDX))]), true, TIMEOUT + 1);
|
||||
assert_eq!(w.state(), AecState::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revokes_on_empty_even_while_not_ready() {
|
||||
// Revocation is deliberately NOT gated on graph_ready (Codex phase-4 review,
|
||||
// findings 1 + 4): the instant every node bearing the index is gone we
|
||||
// revoke, ready or not, because gating on readiness would let an
|
||||
// unload+reload that reused the index inside one not-ready churn window
|
||||
// silently alias onto the new module. A mutant adding `&& graph_ready` to
|
||||
// the revoke guard survives every other test but dies here.
|
||||
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0);
|
||||
assert_eq!(v.state(), AecState::Validated);
|
||||
v.observe(&empty(), false, 10); // identity gone during not-ready churn
|
||||
assert_eq!(v.state(), AecState::Revoked);
|
||||
assert!(!v.fan_out_permitted());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deadline_armed_once_survives_ready_flapping() {
|
||||
// graph_ready is dynamic (it drops back to false while a Link is binding).
|
||||
// The epoch budget must be armed on the *first* ready and not restarted.
|
||||
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||
v.observe(&empty(), true, 1_000); // arm at 1000 → deadline 3000
|
||||
v.observe(&empty(), false, 2_000); // ready flaps off; must not disarm
|
||||
assert_eq!(v.state(), AecState::Validating);
|
||||
// At the original deadline it fails, even though ready is false now — the
|
||||
// budget did not restart from the flap.
|
||||
v.observe(&empty(), false, 3_000);
|
||||
assert_eq!(v.state(), AecState::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_is_sticky_even_if_the_index_reappears() {
|
||||
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||
v.observe(&empty(), true, 0);
|
||||
v.observe(&empty(), true, TIMEOUT);
|
||||
assert_eq!(v.state(), AecState::Failed);
|
||||
|
||||
// A node bearing the index shows up late — must not resurrect the epoch.
|
||||
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, TIMEOUT + 1);
|
||||
assert_eq!(v.state(), AecState::Failed);
|
||||
assert!(!v.fan_out_permitted());
|
||||
assert_eq!(v.validated_module_id(), None);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row: partial-node disappearance ⇒ stays Validated; all gone ⇒ Revoked.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn partial_leg_disappearance_stays_validated() {
|
||||
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||
// The module's four nodes all carry the index.
|
||||
let four = snapshot(vec![
|
||||
node(1, Some(IDX)),
|
||||
node(2, Some(IDX)),
|
||||
node(3, Some(IDX)),
|
||||
node(4, Some(IDX)),
|
||||
]);
|
||||
v.observe(&four, true, 0);
|
||||
assert_eq!(v.state(), AecState::Validated);
|
||||
|
||||
// Three legs cork/relink away; one still bears the index → still Validated.
|
||||
v.observe(&snapshot(vec![node(4, Some(IDX))]), true, 10);
|
||||
assert_eq!(v.state(), AecState::Validated);
|
||||
assert_eq!(v.validated_module_id(), Some(IDX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_nodes_gone_revokes() {
|
||||
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0);
|
||||
assert_eq!(v.state(), AecState::Validated);
|
||||
|
||||
// The whole identity unloads: no node bears the index any more.
|
||||
v.observe(&empty(), true, 10);
|
||||
assert_eq!(v.state(), AecState::Revoked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoked_stops_fan_out_and_exposes_no_identity() {
|
||||
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0);
|
||||
v.observe(&empty(), true, 10);
|
||||
assert_eq!(v.state(), AecState::Revoked);
|
||||
assert!(!v.fan_out_permitted());
|
||||
assert_eq!(v.validated_module_id(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_node_that_merely_changes_index_revokes() {
|
||||
// Not a disappearance in the id sense, but the *identity* is gone: no node
|
||||
// bears our index any more, even though a same-serial node lingers.
|
||||
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0);
|
||||
v.observe(&snapshot(vec![node(1, Some(IDX + 1))]), true, 10);
|
||||
assert_eq!(v.state(), AecState::Revoked);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row: a retained stale index does not alias onto a reloaded module — indices
|
||||
// ARE reused (v3.4 §5.2 correction 3). This is the sharpest safety property.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn revoked_index_does_not_alias_onto_a_reloaded_module() {
|
||||
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0);
|
||||
v.observe(&empty(), true, 10);
|
||||
assert_eq!(v.state(), AecState::Revoked);
|
||||
|
||||
// A *different* module later reloads and pactl hands it the very same
|
||||
// index (measured: 536870919 came back verbatim). A resurrecting machine
|
||||
// would silently start excluding this unrelated module's node. Ours must
|
||||
// stay Revoked and fail closed; a real reload gets a fresh validator.
|
||||
v.observe(&snapshot(vec![node(99, Some(IDX))]), true, 20);
|
||||
assert_eq!(v.state(), AecState::Revoked);
|
||||
assert!(!v.fan_out_permitted());
|
||||
assert_eq!(v.validated_module_id(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_validator_re_validates_the_reused_index() {
|
||||
// The counterpart: because peerspeak re-tells pixelpass the index on every
|
||||
// load, the correct response to a reload is a new machine, which validates
|
||||
// the reused index cleanly — proving stickiness costs nothing legitimate.
|
||||
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0);
|
||||
assert_eq!(v.state(), AecState::Validated);
|
||||
assert_eq!(v.validated_module_id(), Some(IDX));
|
||||
}
|
||||
@@ -0,0 +1,765 @@
|
||||
//! Per-app audio routing.
|
||||
//!
|
||||
//! Two cooperating layers:
|
||||
//!
|
||||
//! - **Null-sink + loopback** (pactl shell-out): a per-PID null-sink
|
||||
//! `pixelpass_capture_<pid>` plus a `module-loopback` that mirrors the
|
||||
//! default sink's monitor into it. gst captures from the null-sink's
|
||||
//! monitor, so the viewer hears whatever the user hears — by default.
|
||||
//!
|
||||
//! - **Per-stream rerouting** (libpipewire on a dedicated OS thread):
|
||||
//! when [`HostOpts::app`] is set, a [`StreamRouter`] subscribes to the
|
||||
//! PipeWire registry, finds `Stream/Output/Audio` nodes whose
|
||||
//! `application.name` matches the filter, and writes
|
||||
//! `target.object` to the "default" metadata so WirePlumber reroutes
|
||||
//! them to our null-sink. Once at least one stream is actually routed,
|
||||
//! the loopback is unloaded — otherwise the viewer would hear the
|
||||
//! filtered audio twice (once via the routed stream, once via the
|
||||
//! default-sink monitor loopback).
|
||||
//!
|
||||
//! - **Local monitor** (pactl shell-out, app mode only): rerouting *moves*
|
||||
//! the chosen app off the sharer's speakers into the null-sink, so without
|
||||
//! this the sharer would go deaf to the very content they're sharing. We
|
||||
//! mirror the null-sink's monitor back to `@DEFAULT_SINK@` so the sharer
|
||||
//! hears it too. Only the chosen app is in the null-sink — never the
|
||||
//! desktop/call — so this can't echo back into the capture. It is loaded on
|
||||
//! the first routed stream (after the default-sink loopback is gone, so the
|
||||
//! two never coexist and feed back) and unloaded when the app stops.
|
||||
//!
|
||||
//! pactl is the right tool for the one-shot null-sink/loopback graph
|
||||
//! mutations. libpipewire is dragged in only when per-stream filtering
|
||||
//! is requested, because that needs registry-event subscription.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::process::Command;
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
use crate::cli::HostOpts;
|
||||
|
||||
/// Owns the pactl-loaded modules plus, when filtering is active, the
|
||||
/// libpipewire stream-router thread. Drop unloads modules as a backstop;
|
||||
/// prefer [`Routing::shutdown`] explicitly so failures get logged.
|
||||
pub struct Routing {
|
||||
sink_module: Option<u32>,
|
||||
/// Shared with the event task so it can `take()` and unload on the
|
||||
/// first successful route. `Routing::shutdown` unloads whatever
|
||||
/// remains.
|
||||
loopback_module: Arc<Mutex<Option<u32>>>,
|
||||
/// The `null-sink.monitor → @DEFAULT_SINK@` loopback that lets the sharer
|
||||
/// hear the routed app. Shared with the event task, which loads it on the
|
||||
/// first routed stream and unloads it when the app stops. `None` outside
|
||||
/// app mode and whenever no app is currently routed.
|
||||
local_monitor_module: Arc<Mutex<Option<u32>>>,
|
||||
sink_name: String,
|
||||
stream_router: Option<StreamRouter>,
|
||||
event_task: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Routing {
|
||||
/// Create the per-PID null-sink + loopback. If `opts.app` is set,
|
||||
/// also spawn the libpipewire thread that reroutes matching streams.
|
||||
pub async fn start(opts: &HostOpts) -> Result<Self> {
|
||||
let pid = std::process::id();
|
||||
let sink_name = format!("pixelpass_capture_{pid}");
|
||||
|
||||
let sink_module = load_module(&["module-null-sink", &format!("sink_name={sink_name}")])
|
||||
.context("failed to load module-null-sink")?;
|
||||
|
||||
// In strict per-app mode we never mirror the default sink: the viewer
|
||||
// must hear *only* the chosen app, never the whole desktop (which would
|
||||
// leak e.g. a voice call the sharer is in back to viewers — the echo
|
||||
// bug A23). Without strict mode (whole-desktop share, or best-effort
|
||||
// app filtering) we load the monitor loopback so the viewer hears
|
||||
// system audio immediately and during any gap before the app routes.
|
||||
// 20ms loopback latency keeps the mirrored audio tight; pactl's
|
||||
// default of 200ms is enough to be perceptible.
|
||||
let strict_app = opts.app.is_some() && opts.strict_audio;
|
||||
let loopback_module = if strict_app {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
load_module(&[
|
||||
"module-loopback",
|
||||
"source=@DEFAULT_SINK@.monitor",
|
||||
&format!("sink={sink_name}"),
|
||||
"latency_msec=20",
|
||||
])
|
||||
.context("failed to load module-loopback (null-sink cleaned up on Drop)")?,
|
||||
)
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
sink_module,
|
||||
?loopback_module,
|
||||
strict_app,
|
||||
%sink_name,
|
||||
"audio routing: null-sink ready (loopback skipped in strict app mode)"
|
||||
);
|
||||
|
||||
let loopback_arc = Arc::new(Mutex::new(loopback_module));
|
||||
let local_monitor_arc = Arc::new(Mutex::new(None));
|
||||
let mut routing = Self {
|
||||
sink_module: Some(sink_module),
|
||||
loopback_module: Arc::clone(&loopback_arc),
|
||||
local_monitor_module: Arc::clone(&local_monitor_arc),
|
||||
sink_name: sink_name.clone(),
|
||||
stream_router: None,
|
||||
event_task: None,
|
||||
};
|
||||
|
||||
if let Some(app) = &opts.app {
|
||||
let (router, mut event_rx) = StreamRouter::spawn(app.clone(), sink_name.clone())?;
|
||||
let loopback_for_task = Arc::clone(&loopback_arc);
|
||||
let local_monitor_for_task = Arc::clone(&local_monitor_arc);
|
||||
let sink_name_for_task = sink_name.clone();
|
||||
let strict = opts.strict_audio;
|
||||
let event_task = tokio::spawn(async move {
|
||||
use crate::common::output::{self, AppAudioState};
|
||||
while let Some(ev) = event_rx.recv().await {
|
||||
match ev {
|
||||
Event::FirstRoutedStream => {
|
||||
let mid = loopback_for_task.lock().unwrap().take();
|
||||
if let Some(id) = mid {
|
||||
tracing::info!(
|
||||
"audio routing: first stream routed → unloading default-sink loopback"
|
||||
);
|
||||
unload_module(id);
|
||||
}
|
||||
// Mirror the routed app back to the sharer's own
|
||||
// speakers so they hear the content they're sharing.
|
||||
// Loaded *after* the default-sink loopback is gone so
|
||||
// the two never coexist (which would feed back), and
|
||||
// sourced from the null-sink monitor — the chosen app
|
||||
// only, never the desktop/call — so it can't echo into
|
||||
// the capture.
|
||||
if local_monitor_for_task.lock().unwrap().is_none() {
|
||||
match load_module(&[
|
||||
"module-loopback",
|
||||
&format!("source={sink_name_for_task}.monitor"),
|
||||
"sink=@DEFAULT_SINK@",
|
||||
"latency_msec=20",
|
||||
]) {
|
||||
Ok(id) => {
|
||||
tracing::info!(
|
||||
module = id,
|
||||
"audio routing: local monitor loaded (sharer hears the shared app)"
|
||||
);
|
||||
*local_monitor_for_task.lock().unwrap() = Some(id);
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
"audio routing: failed to load local monitor loopback: {e:#}"
|
||||
),
|
||||
}
|
||||
}
|
||||
// Tell the front-end the chosen app's audio is live.
|
||||
output::emit(output::Event::AppAudio {
|
||||
state: AppAudioState::Routed,
|
||||
});
|
||||
}
|
||||
Event::LastRoutedStreamGone => {
|
||||
// Routed app exited/paused mid-session. Notify the
|
||||
// front-end either way; the recovery differs by mode.
|
||||
output::emit(output::Event::AppAudio {
|
||||
state: AppAudioState::Lost,
|
||||
});
|
||||
// The shared app is gone, so its null-sink is silent:
|
||||
// stop mirroring it to the sharer's speakers. Re-loads
|
||||
// on the next FirstRoutedStream if the app resumes.
|
||||
if let Some(id) = local_monitor_for_task.lock().unwrap().take() {
|
||||
tracing::info!(
|
||||
module = id,
|
||||
"audio routing: last routed stream gone → unloading local monitor"
|
||||
);
|
||||
unload_module(id);
|
||||
}
|
||||
if strict {
|
||||
// Strict mode: do NOT restore the whole-desktop
|
||||
// loopback. Viewers hear silence until the app
|
||||
// produces audio again — never the rest of the
|
||||
// desktop (call included).
|
||||
tracing::info!(
|
||||
"audio routing: strict mode — last routed stream gone, leaving viewers silent"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Best-effort mode: restore the default-sink loopback
|
||||
// so the viewer hears system audio again instead of
|
||||
// silence.
|
||||
if loopback_for_task.lock().unwrap().is_some() {
|
||||
continue;
|
||||
}
|
||||
tracing::info!(
|
||||
"audio routing: last routed stream gone → restoring default-sink loopback"
|
||||
);
|
||||
match load_module(&[
|
||||
"module-loopback",
|
||||
"source=@DEFAULT_SINK@.monitor",
|
||||
&format!("sink={sink_name_for_task}"),
|
||||
"latency_msec=20",
|
||||
]) {
|
||||
Ok(id) => {
|
||||
*loopback_for_task.lock().unwrap() = Some(id);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"audio routing: failed to re-load loopback: {e:#}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
routing.stream_router = Some(router);
|
||||
routing.event_task = Some(event_task);
|
||||
}
|
||||
|
||||
// Strict per-app mode suppresses the default-sink loopback, so until the
|
||||
// chosen app's first stream routes the viewer hears *silence*. Emit an
|
||||
// initial `lost` at capture start (capture is lazy — this runs on the
|
||||
// first viewer) so the front-end can warn from the outset rather than
|
||||
// only after an app that *was* routed later stops (audit A23 P2/F1):
|
||||
// `LastRoutedStreamGone`→`lost` never fires for an app that never routed.
|
||||
if let Some(state) = initial_app_audio_state(opts) {
|
||||
crate::common::output::emit(crate::common::output::Event::AppAudio { state });
|
||||
}
|
||||
|
||||
Ok(routing)
|
||||
}
|
||||
|
||||
pub fn sink_name(&self) -> &str {
|
||||
&self.sink_name
|
||||
}
|
||||
|
||||
/// Stop the stream router (if any), then unload loopback (if still
|
||||
/// loaded), then unload the null-sink. Order matters: PipeWire can
|
||||
/// leave zombie links if you destroy a sink with active inputs.
|
||||
///
|
||||
/// Every step is a `take()`, so this is idempotent — `Drop` calls it again
|
||||
/// as a backstop and the second run is a no-op.
|
||||
fn cleanup(&mut self) {
|
||||
if let Some(router) = self.stream_router.take() {
|
||||
router.shutdown();
|
||||
}
|
||||
if let Some(task) = self.event_task.take() {
|
||||
task.abort();
|
||||
}
|
||||
if let Some(id) = self.loopback_module.lock().unwrap().take() {
|
||||
unload_module(id);
|
||||
}
|
||||
// Unload the local monitor before the null-sink it reads from, so the
|
||||
// sink has no active loopback reader when it's destroyed.
|
||||
if let Some(id) = self.local_monitor_module.lock().unwrap().take() {
|
||||
unload_module(id);
|
||||
}
|
||||
if let Some(id) = self.sink_module.take() {
|
||||
unload_module(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume the routing and tear it all down now. `Drop` is the backstop;
|
||||
/// the real work lives in [`cleanup`](Self::cleanup).
|
||||
pub fn shutdown(mut self) {
|
||||
self.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Routing {
|
||||
fn drop(&mut self) {
|
||||
self.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/// The app-audio state to announce at capture start, if any. Only strict per-app
|
||||
/// mode warrants one: there the loopback is suppressed, so the viewer hears
|
||||
/// silence until the chosen app's first stream routes — surface that as an
|
||||
/// initial `lost`. In every other mode (whole-desktop, or best-effort app
|
||||
/// filtering) the loopback keeps audio flowing from the outset, so there is no
|
||||
/// initial gap to report. Pure: no I/O, so the emit decision is unit-testable.
|
||||
pub(super) fn initial_app_audio_state(
|
||||
opts: &HostOpts,
|
||||
) -> Option<crate::common::output::AppAudioState> {
|
||||
(opts.app.is_some() && opts.strict_audio).then_some(crate::common::output::AppAudioState::Lost)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// App enumeration (interactive picker source)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// One deduplicated app currently producing audio. The picker in
|
||||
/// interactive mode shows these as the per-app capture choices.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct App {
|
||||
pub name: String,
|
||||
pub stream_count: u32,
|
||||
}
|
||||
|
||||
/// Enumerate apps currently sending audio to any sink, deduplicated by
|
||||
/// `application.name`. Returns an empty Vec if nothing is playing.
|
||||
pub fn list_playing_apps() -> Result<Vec<App>> {
|
||||
let output = Command::new("pactl")
|
||||
.args(["-f", "json", "list", "sink-inputs"])
|
||||
.output()
|
||||
.context("failed to run `pactl -f json list sink-inputs`")?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"pactl list sink-inputs failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
parse_sink_inputs(&output.stdout)
|
||||
}
|
||||
|
||||
fn parse_sink_inputs(stdout: &[u8]) -> Result<Vec<App>> {
|
||||
let entries: Vec<SinkInput> =
|
||||
serde_json::from_slice(stdout).context("pactl returned unparseable JSON")?;
|
||||
let mut counts: BTreeMap<String, u32> = BTreeMap::new();
|
||||
for entry in entries {
|
||||
let Some(name) = entry.properties.application_name else {
|
||||
continue;
|
||||
};
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
*counts.entry(trimmed.to_string()).or_insert(0) += 1;
|
||||
}
|
||||
Ok(counts
|
||||
.into_iter()
|
||||
.map(|(name, stream_count)| App { name, stream_count })
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SinkInput {
|
||||
properties: SinkInputProperties,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SinkInputProperties {
|
||||
#[serde(rename = "application.name")]
|
||||
application_name: Option<String>,
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// pactl module helpers
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn load_module(args: &[&str]) -> Result<u32> {
|
||||
let output = Command::new("pactl")
|
||||
.arg("load-module")
|
||||
.args(args)
|
||||
.output()
|
||||
.context("failed to run pactl load-module")?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"pactl load-module failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
let id_str = String::from_utf8(output.stdout)
|
||||
.context("pactl returned non-UTF-8")?
|
||||
.trim()
|
||||
.to_string();
|
||||
// Genuinely 32-bit, unlike `object.serial`: this is a PulseAudio module
|
||||
// index (`pa_module.index`, `uint32_t`), which `pactl unload-module` takes
|
||||
// back verbatim. Do not widen it.
|
||||
id_str
|
||||
.parse::<u32>()
|
||||
.with_context(|| format!("pactl returned unexpected module ID: {id_str:?}"))
|
||||
}
|
||||
|
||||
fn unload_module(id: u32) {
|
||||
let result = Command::new("pactl")
|
||||
.arg("unload-module")
|
||||
.arg(id.to_string())
|
||||
.output();
|
||||
match result {
|
||||
Ok(output) if output.status.success() => {
|
||||
tracing::info!(module = id, "audio routing: unloaded pactl module");
|
||||
}
|
||||
Ok(output) => {
|
||||
tracing::warn!(
|
||||
module = id,
|
||||
stderr = %String::from_utf8_lossy(&output.stderr).trim(),
|
||||
"audio routing: pactl unload-module exited non-zero"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
module = id,
|
||||
"audio routing: failed to run pactl unload-module: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Per-stream routing (libpipewire thread)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Command from tokio → libpipewire thread.
|
||||
enum Cmd {
|
||||
/// Clear `target.object` for everything we routed, then quit the
|
||||
/// MainLoop so the thread joins.
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
/// Event from libpipewire thread → tokio. The pair drives loopback
|
||||
/// oscillation: unload on `FirstRoutedStream`, re-load on
|
||||
/// `LastRoutedStreamGone`. Both fire on count-transitions (0→N and N→0
|
||||
/// respectively), not on every change.
|
||||
enum Event {
|
||||
/// At least one stream is now routed to our sink. Receiver unloads
|
||||
/// the default-sink loopback so the filtered audio isn't doubled.
|
||||
FirstRoutedStream,
|
||||
/// The last routed stream just disappeared (app closed, paused,
|
||||
/// switched output). Receiver re-loads the default-sink loopback so
|
||||
/// the viewer doesn't go silent.
|
||||
LastRoutedStreamGone,
|
||||
}
|
||||
|
||||
/// Handle to the libpipewire stream-router thread.
|
||||
pub struct StreamRouter {
|
||||
cmd_tx: pipewire::channel::Sender<Cmd>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl StreamRouter {
|
||||
/// Spawn the libpipewire thread. Returns the router handle and the
|
||||
/// event receiver tokio side polls.
|
||||
fn spawn(
|
||||
filter_name: String,
|
||||
sink_name: String,
|
||||
) -> Result<(Self, tokio::sync::mpsc::UnboundedReceiver<Event>)> {
|
||||
let (cmd_tx, cmd_rx) = pipewire::channel::channel::<Cmd>();
|
||||
let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
|
||||
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("pixelpass-pw-router".to_string())
|
||||
.spawn(move || {
|
||||
if let Err(e) = run_router(filter_name, sink_name, cmd_rx, event_tx) {
|
||||
tracing::warn!("audio routing: libpipewire thread exited with error: {e:#}");
|
||||
}
|
||||
})
|
||||
.context("failed to spawn libpipewire router thread")?;
|
||||
|
||||
Ok((
|
||||
Self {
|
||||
cmd_tx,
|
||||
thread: Some(thread),
|
||||
},
|
||||
event_rx,
|
||||
))
|
||||
}
|
||||
|
||||
fn shutdown(mut self) {
|
||||
// Best-effort: if the send fails the thread is already gone.
|
||||
let _ = self.cmd_tx.send(Cmd::Shutdown);
|
||||
if let Some(t) = self.thread.take()
|
||||
&& let Err(e) = t.join()
|
||||
{
|
||||
tracing::warn!("audio routing: pw thread join failed: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Body of the libpipewire thread. Owns MainLoop, registry listener, and
|
||||
/// all PipeWire proxies for the duration of the routing session.
|
||||
fn run_router(
|
||||
filter_name: String,
|
||||
sink_name: String,
|
||||
cmd_rx: pipewire::channel::Receiver<Cmd>,
|
||||
event_tx: tokio::sync::mpsc::UnboundedSender<Event>,
|
||||
) -> Result<()> {
|
||||
use pipewire::{self as pw, types::ObjectType};
|
||||
|
||||
let main_loop =
|
||||
pw::main_loop::MainLoopRc::new(None).context("pw main loop construction failed")?;
|
||||
let context =
|
||||
pw::context::ContextRc::new(&main_loop, None).context("pw context construction failed")?;
|
||||
let core = context
|
||||
.connect_rc(None)
|
||||
.context("pw core connect failed (is the daemon running?)")?;
|
||||
let registry = core.get_registry_rc().context("pw get_registry failed")?;
|
||||
|
||||
let state = Rc::new(RefCell::new(RouterState {
|
||||
sink_serial: None,
|
||||
default_metadata: None,
|
||||
routed_node_ids: Vec::new(),
|
||||
pending: Vec::new(),
|
||||
}));
|
||||
|
||||
// Cmd handler: clear metadata for routed streams, then quit.
|
||||
let main_loop_for_cmd = main_loop.clone();
|
||||
let state_for_cmd = Rc::clone(&state);
|
||||
let _cmd_recv = cmd_rx.attach(main_loop.loop_(), move |cmd| match cmd {
|
||||
Cmd::Shutdown => {
|
||||
let s = state_for_cmd.borrow();
|
||||
if let Some(meta) = &s.default_metadata {
|
||||
for &nid in &s.routed_node_ids {
|
||||
meta.set_property(nid, "target.object", None, None);
|
||||
}
|
||||
if !s.routed_node_ids.is_empty() {
|
||||
tracing::info!(
|
||||
n = s.routed_node_ids.len(),
|
||||
"audio routing: cleared target.object on routed streams before quitting"
|
||||
);
|
||||
}
|
||||
}
|
||||
main_loop_for_cmd.quit();
|
||||
}
|
||||
});
|
||||
|
||||
let filter_lower = filter_name.to_ascii_lowercase();
|
||||
let sink_name_owned = sink_name.clone();
|
||||
let registry_weak = registry.downgrade();
|
||||
let state_for_reg = Rc::clone(&state);
|
||||
let event_tx_for_reg = event_tx.clone();
|
||||
let state_for_remove = Rc::clone(&state);
|
||||
let event_tx_for_remove = event_tx.clone();
|
||||
|
||||
let _reg_listener = registry
|
||||
.add_listener_local()
|
||||
.global(move |obj| {
|
||||
let Some(reg) = registry_weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
match obj.type_ {
|
||||
ObjectType::Node => {
|
||||
let Some(props) = obj.props.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if props.get("node.name") == Some(sink_name_owned.as_str()) {
|
||||
match props.get("object.serial").and_then(parse_object_serial) {
|
||||
Some(serial) => {
|
||||
state_for_reg.borrow_mut().sink_serial = Some(serial);
|
||||
tracing::info!(serial, "audio routing: pixelpass sink registered");
|
||||
try_flush(&state_for_reg, &event_tx_for_reg);
|
||||
}
|
||||
// Never silently: without a serial `try_flush` can
|
||||
// never route anything, so the whole app-filter mode
|
||||
// is dead and the only symptom is missing audio.
|
||||
None => tracing::warn!(
|
||||
node_id = obj.id,
|
||||
serial = props.get("object.serial").unwrap_or("<absent>"),
|
||||
"audio routing: pixelpass sink has no usable object.serial; \
|
||||
stream rerouting disabled"
|
||||
),
|
||||
}
|
||||
return;
|
||||
}
|
||||
if props.get("media.class") != Some("Stream/Output/Audio") {
|
||||
return;
|
||||
}
|
||||
let Some(app) = props.get("application.name") else {
|
||||
return;
|
||||
};
|
||||
if !app.eq_ignore_ascii_case(&filter_lower) {
|
||||
return;
|
||||
}
|
||||
tracing::info!(
|
||||
node_id = obj.id,
|
||||
%app,
|
||||
"audio routing: matched stream, queued for route"
|
||||
);
|
||||
state_for_reg.borrow_mut().pending.push(obj.id);
|
||||
try_flush(&state_for_reg, &event_tx_for_reg);
|
||||
}
|
||||
ObjectType::Metadata => {
|
||||
let Some(props) = obj.props.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if props.get("metadata.name") != Some("default") {
|
||||
return;
|
||||
}
|
||||
let metadata: pw::metadata::Metadata = match reg.bind(obj) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!("audio routing: bind default metadata failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
state_for_reg.borrow_mut().default_metadata = Some(metadata);
|
||||
tracing::info!("audio routing: default metadata bound");
|
||||
try_flush(&state_for_reg, &event_tx_for_reg);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.global_remove(move |id| {
|
||||
handle_global_remove(&state_for_remove, &event_tx_for_remove, id);
|
||||
})
|
||||
.register();
|
||||
|
||||
tracing::info!(filter = %filter_name, "audio routing: pw thread running");
|
||||
main_loop.run();
|
||||
tracing::info!("audio routing: pw thread exiting");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse a PipeWire `object.serial` property value.
|
||||
///
|
||||
/// `object.serial` is a **64-bit** monotonically-increasing counter
|
||||
/// (`pw_global`'s serial is `uint64_t`); it is *not* a `pw` object id
|
||||
/// (those are `u32` and get recycled — the serial exists precisely so
|
||||
/// that recycled ids can be disambiguated). Parsing it as `u32` silently
|
||||
/// yields `None` past `u32::MAX`, which on a long-lived daemon means the
|
||||
/// sink is never registered and no stream is ever routed.
|
||||
///
|
||||
/// Strict on purpose: PipeWire emits a bare decimal, so anything else
|
||||
/// (empty, signed, whitespace-padded, non-numeric, overflowing) is a
|
||||
/// property we do not understand and must not guess at. Leading zeroes
|
||||
/// are accepted — they are unambiguous and parse to the same value.
|
||||
pub(crate) fn parse_object_serial(raw: &str) -> Option<u64> {
|
||||
if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
raw.parse::<u64>().ok()
|
||||
}
|
||||
|
||||
struct RouterState {
|
||||
/// See [`parse_object_serial`] — 64-bit, and not interchangeable with
|
||||
/// the `u32` node ids in `routed_node_ids` / `pending`.
|
||||
sink_serial: Option<u64>,
|
||||
default_metadata: Option<pipewire::metadata::Metadata>,
|
||||
routed_node_ids: Vec<u32>,
|
||||
pending: Vec<u32>,
|
||||
}
|
||||
|
||||
/// Drop the vanished node from `routed_node_ids` and `pending`. If it
|
||||
/// was the last routed stream, emit `LastRoutedStreamGone` so the
|
||||
/// tokio side restores the default-sink loopback.
|
||||
fn handle_global_remove(
|
||||
state: &Rc<RefCell<RouterState>>,
|
||||
event_tx: &tokio::sync::mpsc::UnboundedSender<Event>,
|
||||
id: u32,
|
||||
) {
|
||||
let mut s = state.borrow_mut();
|
||||
let was_routed = !s.routed_node_ids.is_empty();
|
||||
s.routed_node_ids.retain(|&x| x != id);
|
||||
s.pending.retain(|&x| x != id);
|
||||
if was_routed && s.routed_node_ids.is_empty() {
|
||||
tracing::info!(
|
||||
node_id = id,
|
||||
"audio routing: last routed stream disappeared"
|
||||
);
|
||||
let _ = event_tx.send(Event::LastRoutedStreamGone);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain pending streams to the sink, but only once both prerequisites
|
||||
/// (sink serial known + default metadata bound) are in place. Emits
|
||||
/// `FirstRoutedStream` when routed count crosses 0→N (so it fires
|
||||
/// each time the count comes back up from zero, not just the first
|
||||
/// time — pairs with `LastRoutedStreamGone` to oscillate the loopback).
|
||||
fn try_flush(
|
||||
state: &Rc<RefCell<RouterState>>,
|
||||
event_tx: &tokio::sync::mpsc::UnboundedSender<Event>,
|
||||
) {
|
||||
let mut s = state.borrow_mut();
|
||||
let Some(serial) = s.sink_serial else { return };
|
||||
if s.default_metadata.is_none() {
|
||||
return;
|
||||
}
|
||||
if s.pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
let was_empty = s.routed_node_ids.is_empty();
|
||||
let serial_str = serial.to_string();
|
||||
let pending = std::mem::take(&mut s.pending);
|
||||
if let Some(meta) = &s.default_metadata {
|
||||
for nid in &pending {
|
||||
meta.set_property(*nid, "target.object", Some("Spa:Id"), Some(&serial_str));
|
||||
tracing::info!(
|
||||
node_id = *nid,
|
||||
sink_serial = serial,
|
||||
"audio routing: stream routed to pixelpass sink"
|
||||
);
|
||||
}
|
||||
}
|
||||
s.routed_node_ids.extend(pending);
|
||||
if was_empty && !s.routed_node_ids.is_empty() {
|
||||
let _ = event_tx.send(Event::FirstRoutedStream);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn object_serial_parses_past_u32() {
|
||||
// The regression this fix exists for: a serial one past `u32::MAX`
|
||||
// used to parse as `None` and silently disable rerouting.
|
||||
let beyond = u64::from(u32::MAX) + 1;
|
||||
assert_eq!(parse_object_serial(&beyond.to_string()), Some(beyond));
|
||||
assert_eq!(
|
||||
parse_object_serial(&u64::MAX.to_string()),
|
||||
Some(u64::MAX),
|
||||
"the full 64-bit range must round-trip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_serial_accepts_ordinary_serials() {
|
||||
// Without this the valid cases are only 1, 10 and 20 digits long, and
|
||||
// a length-gated mutant (`if (2..10).contains(&raw.len()) { None }`)
|
||||
// survives the whole suite while rejecting every serial a freshly
|
||||
// started daemon actually hands out. (Codex, round 1.)
|
||||
for serial in 0_u64..=1024 {
|
||||
assert_eq!(parse_object_serial(&serial.to_string()), Some(serial));
|
||||
}
|
||||
assert_eq!(parse_object_serial("123456789"), Some(123_456_789));
|
||||
assert_eq!(
|
||||
parse_object_serial("007"),
|
||||
Some(7),
|
||||
"leading zeroes are fine"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_serial_boundary_values() {
|
||||
assert_eq!(parse_object_serial("0"), Some(0));
|
||||
assert_eq!(parse_object_serial("1"), Some(1));
|
||||
let max32 = u64::from(u32::MAX);
|
||||
assert_eq!(parse_object_serial(&max32.to_string()), Some(max32));
|
||||
assert_eq!(
|
||||
parse_object_serial(&(max32 - 1).to_string()),
|
||||
Some(max32 - 1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_serial_round_trips_through_the_metadata_string() {
|
||||
// `try_flush` writes the serial back out as a decimal string for
|
||||
// `target.object`; widening must not introduce a formatting change.
|
||||
for raw in ["0", "4294967296", "18446744073709551615"] {
|
||||
let parsed = parse_object_serial(raw).expect("valid serial");
|
||||
assert_eq!(parsed.to_string(), raw);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_serial_rejects_malformed() {
|
||||
for raw in [
|
||||
"",
|
||||
" 12",
|
||||
"12 ",
|
||||
"+12",
|
||||
"-1",
|
||||
"1.0",
|
||||
"0x10",
|
||||
"12a",
|
||||
"abc",
|
||||
// u64::MAX + 1 — overflow must be rejected, not wrapped.
|
||||
"18446744073709551616",
|
||||
] {
|
||||
assert_eq!(parse_object_serial(raw), None, "should reject {raw:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! O5 measurement, pure (impl plan §5.2).
|
||||
//!
|
||||
//! v3.4 §6.4 asserts "a full recompute per graph event is fine for v1". The
|
||||
//! impl plan closes O5 by refusing to let that rest on a node count: what has
|
||||
//! to be recorded is the **graph-event rate**, the **recompute duration
|
||||
//! distribution and maximum**, and **whether events queue behind recompute or
|
||||
//! logging**.
|
||||
//!
|
||||
//! Everything here is arithmetic over samples the caller supplies. The clock
|
||||
//! reads live at the I/O edge ([`super::sink`]), which is what keeps the
|
||||
//! statistics unit-testable: a test feeds a hand-written sample sequence and
|
||||
//! asserts the summary exactly, with no timing flake.
|
||||
//!
|
||||
//! **The queueing measure is a proxy, and a one-directional one.** libpipewire
|
||||
//! dispatches registry callbacks serially on its own loop thread and exposes no
|
||||
//! queue depth, so nothing here can read a backlog directly. What it can see is
|
||||
//! that the observer thread was *continuously busy*: if an event begins being
|
||||
//! handled within [`QUEUE_THRESHOLD_US`] of the previous sample's completion,
|
||||
//! it was almost certainly already waiting while that recompute ran. That makes
|
||||
//! [`Summary::queued_events`] a **lower bound** — a genuine backlog always shows
|
||||
//! up in it, but a burst that happens to arrive exactly as the loop goes idle is
|
||||
//! counted as un-queued. Combined with [`Summary::busy_fraction`] (which needs
|
||||
//! no inference at all) it is enough to answer O5 in the direction that matters:
|
||||
//! a low busy fraction with zero queued events is headroom, and anything else is
|
||||
//! a number to argue about rather than an assumption to inherit.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::host::observer::EventKind;
|
||||
|
||||
/// An event beginning this close behind the previous sample's completion is
|
||||
/// counted as having queued. Deliberately tight: the cost of being wrong in the
|
||||
/// generous direction is a metric that overstates backlog and sends a later
|
||||
/// round chasing a non-problem.
|
||||
pub const QUEUE_THRESHOLD_US: u64 = 100;
|
||||
|
||||
/// Upper bounds of the duration histogram, microseconds. A twelfth (overflow)
|
||||
/// bucket catches everything at or above the last bound. Log-ish spacing: the
|
||||
/// interesting question is which order of magnitude a recompute lands in, not
|
||||
/// its exact microsecond.
|
||||
pub const BUCKET_BOUNDS_US: [u64; 11] = [
|
||||
50, 100, 250, 500, 1_000, 2_500, 5_000, 10_000, 25_000, 50_000, 100_000,
|
||||
];
|
||||
|
||||
/// Human labels for the histogram buckets, parallel to [`BUCKET_BOUNDS_US`]
|
||||
/// plus the overflow bucket.
|
||||
pub const BUCKET_LABELS: [&str; 12] = [
|
||||
"<50us", "<100us", "<250us", "<500us", "<1ms", "<2.5ms", "<5ms", "<10ms", "<25ms", "<50ms",
|
||||
"<100ms", ">=100ms",
|
||||
];
|
||||
|
||||
/// A bucketed duration distribution with exact count, sum and maximum.
|
||||
///
|
||||
/// Bounded memory by construction — the audit runs for as long as a share does,
|
||||
/// and keeping every sample to compute an exact percentile would grow without
|
||||
/// limit. The maximum, which is the number O5 actually cares about, is kept
|
||||
/// exactly; percentiles are reported as the bucket they fall in.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Histogram {
|
||||
buckets: [u64; 12],
|
||||
count: u64,
|
||||
sum_us: u64,
|
||||
max_us: u64,
|
||||
}
|
||||
|
||||
impl Histogram {
|
||||
pub fn record(&mut self, us: u64) {
|
||||
let index = BUCKET_BOUNDS_US
|
||||
.iter()
|
||||
.position(|&bound| us < bound)
|
||||
.unwrap_or(BUCKET_BOUNDS_US.len());
|
||||
self.buckets[index] += 1;
|
||||
self.count += 1;
|
||||
self.sum_us = self.sum_us.saturating_add(us);
|
||||
self.max_us = self.max_us.max(us);
|
||||
}
|
||||
|
||||
pub fn count(&self) -> u64 {
|
||||
self.count
|
||||
}
|
||||
|
||||
pub fn max_us(&self) -> u64 {
|
||||
self.max_us
|
||||
}
|
||||
|
||||
pub fn sum_us(&self) -> u64 {
|
||||
self.sum_us
|
||||
}
|
||||
|
||||
pub fn mean_us(&self) -> Option<u64> {
|
||||
(self.count > 0).then(|| self.sum_us / self.count)
|
||||
}
|
||||
|
||||
/// The label of the bucket the `q`-quantile falls in (`q` in `0.0..=1.0`),
|
||||
/// or `None` when nothing has been recorded.
|
||||
///
|
||||
/// Uses the *nearest-rank* definition: the bucket containing the
|
||||
/// `ceil(q · count)`-th sample in ascending order. Reported as a bucket
|
||||
/// rather than a number because interpolating inside a bucket would invent
|
||||
/// precision the histogram does not have.
|
||||
pub fn quantile_bucket(&self, q: f64) -> Option<&'static str> {
|
||||
if self.count == 0 {
|
||||
return None;
|
||||
}
|
||||
let q = q.clamp(0.0, 1.0);
|
||||
// Rank is 1-based; q = 0 still names the bucket holding the smallest
|
||||
// sample rather than degenerating to "no samples".
|
||||
let rank = ((q * self.count as f64).ceil() as u64).max(1);
|
||||
let mut cumulative = 0u64;
|
||||
for (index, &n) in self.buckets.iter().enumerate() {
|
||||
cumulative += n;
|
||||
if cumulative >= rank {
|
||||
return Some(BUCKET_LABELS[index]);
|
||||
}
|
||||
}
|
||||
// Unreachable while `count` is the sum of the buckets, but returning the
|
||||
// top bucket is the fail-loud answer rather than a panic in a metric.
|
||||
Some(BUCKET_LABELS[BUCKET_LABELS.len() - 1])
|
||||
}
|
||||
|
||||
/// Non-empty buckets as `(label, count)`, ascending. Empty buckets are
|
||||
/// dropped so a summary line stays readable.
|
||||
pub fn distribution(&self) -> Vec<(&'static str, u64)> {
|
||||
self.buckets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|&(_, &n)| n > 0)
|
||||
.map(|(index, &n)| (BUCKET_LABELS[index], n))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// One handled event, as timed by the I/O edge.
|
||||
///
|
||||
/// Ticks are the AEC validator's clock, not graph changes, so [`Metrics`] counts
|
||||
/// them separately — folding them into the event rate would inflate it by a
|
||||
/// constant 4 Hz and hide the real graph churn.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Sample {
|
||||
/// Monotonic microseconds (since observer start) at which handling began.
|
||||
pub at_us: u64,
|
||||
/// Microseconds between the previous sample's completion and `at_us`. Zero
|
||||
/// for the first sample.
|
||||
pub gap_us: u64,
|
||||
/// Time spent in the AEC observe + taint recompute.
|
||||
pub recompute_us: u64,
|
||||
/// Time spent serialising and writing the record, zero when nothing was
|
||||
/// emitted. Separate from `recompute_us` because O5 asks about queueing
|
||||
/// behind recompute **or logging** — and if logging turns out to dominate,
|
||||
/// that is a fixable problem of a different kind.
|
||||
pub emit_us: u64,
|
||||
pub kind: EventKind,
|
||||
}
|
||||
|
||||
/// Rolling O5 state. Fold samples in with [`Metrics::record`]; read with
|
||||
/// [`Metrics::summary`].
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Metrics {
|
||||
graph_events: u64,
|
||||
tick_events: u64,
|
||||
emitted_records: u64,
|
||||
recompute: Histogram,
|
||||
emit: Histogram,
|
||||
busy_us: u64,
|
||||
queued_events: u64,
|
||||
first_event_us: Option<u64>,
|
||||
last_completion_us: u64,
|
||||
}
|
||||
|
||||
impl Metrics {
|
||||
pub fn record(&mut self, sample: Sample) {
|
||||
match sample.kind {
|
||||
EventKind::Graph => self.graph_events += 1,
|
||||
EventKind::Tick => self.tick_events += 1,
|
||||
}
|
||||
self.recompute.record(sample.recompute_us);
|
||||
if sample.emit_us > 0 {
|
||||
self.emitted_records += 1;
|
||||
self.emit.record(sample.emit_us);
|
||||
}
|
||||
self.busy_us = self
|
||||
.busy_us
|
||||
.saturating_add(sample.recompute_us)
|
||||
.saturating_add(sample.emit_us);
|
||||
|
||||
// The first sample has no predecessor to have queued behind.
|
||||
if self.first_event_us.is_some() && sample.gap_us <= QUEUE_THRESHOLD_US {
|
||||
self.queued_events += 1;
|
||||
}
|
||||
self.first_event_us.get_or_insert(sample.at_us);
|
||||
self.last_completion_us = sample
|
||||
.at_us
|
||||
.saturating_add(sample.recompute_us)
|
||||
.saturating_add(sample.emit_us);
|
||||
}
|
||||
|
||||
pub fn summary(&self) -> Summary {
|
||||
let span_us = self
|
||||
.first_event_us
|
||||
.map(|first| self.last_completion_us.saturating_sub(first))
|
||||
.unwrap_or(0);
|
||||
// A rate needs a span to divide by; one event in zero elapsed time has
|
||||
// no rate, and reporting a made-up one is worse than reporting none.
|
||||
let graph_events_per_sec = (span_us > 0)
|
||||
.then(|| self.graph_events as f64 * 1_000_000.0 / span_us as f64)
|
||||
.map(round_2);
|
||||
let busy_fraction = (span_us > 0).then(|| round_4(self.busy_us as f64 / span_us as f64));
|
||||
|
||||
Summary {
|
||||
graph_events: self.graph_events,
|
||||
tick_events: self.tick_events,
|
||||
emitted_records: self.emitted_records,
|
||||
span_us,
|
||||
graph_events_per_sec,
|
||||
recompute_max_us: self.recompute.max_us(),
|
||||
recompute_mean_us: self.recompute.mean_us(),
|
||||
recompute_p50: self.recompute.quantile_bucket(0.50),
|
||||
recompute_p90: self.recompute.quantile_bucket(0.90),
|
||||
recompute_p99: self.recompute.quantile_bucket(0.99),
|
||||
recompute_distribution: self.recompute.distribution(),
|
||||
emit_max_us: self.emit.max_us(),
|
||||
emit_mean_us: self.emit.mean_us(),
|
||||
emit_distribution: self.emit.distribution(),
|
||||
busy_us: self.busy_us,
|
||||
busy_fraction,
|
||||
queued_events: self.queued_events,
|
||||
queue_threshold_us: QUEUE_THRESHOLD_US,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The O5 answer, as emitted.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct Summary {
|
||||
pub graph_events: u64,
|
||||
pub tick_events: u64,
|
||||
pub emitted_records: u64,
|
||||
/// First event to last completion, microseconds.
|
||||
pub span_us: u64,
|
||||
pub graph_events_per_sec: Option<f64>,
|
||||
pub recompute_max_us: u64,
|
||||
pub recompute_mean_us: Option<u64>,
|
||||
pub recompute_p50: Option<&'static str>,
|
||||
pub recompute_p90: Option<&'static str>,
|
||||
pub recompute_p99: Option<&'static str>,
|
||||
pub recompute_distribution: Vec<(&'static str, u64)>,
|
||||
pub emit_max_us: u64,
|
||||
pub emit_mean_us: Option<u64>,
|
||||
pub emit_distribution: Vec<(&'static str, u64)>,
|
||||
/// Total observer-thread time spent recomputing and logging.
|
||||
pub busy_us: u64,
|
||||
/// `busy_us / span_us` — the share of wall time the observer thread could
|
||||
/// not be servicing PipeWire. Needs no inference, unlike `queued_events`.
|
||||
pub busy_fraction: Option<f64>,
|
||||
/// Events that began within `queue_threshold_us` of the previous sample's
|
||||
/// completion — a **lower bound** on backlog, see the module header.
|
||||
pub queued_events: u64,
|
||||
pub queue_threshold_us: u64,
|
||||
}
|
||||
|
||||
/// Keep the JSON readable: a rate to two decimals and a fraction to four are
|
||||
/// well past the precision any of this is good to.
|
||||
fn round_2(value: f64) -> f64 {
|
||||
(value * 100.0).round() / 100.0
|
||||
}
|
||||
|
||||
fn round_4(value: f64) -> f64 {
|
||||
(value * 10_000.0).round() / 10_000.0
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
//! Phase 5 — dry-run audit mode 🚦 (impl plan §5).
|
||||
//!
|
||||
//! **This phase adds no capability. Its entire purpose is to be wrong loudly
|
||||
//! and safely.** It runs phases 2–4 against the *live* graph on every graph
|
||||
//! event and reports what they conclude. It creates no links, loads no modules,
|
||||
//! and changes no routing — the only thing it produces is a line of JSON.
|
||||
//!
|
||||
//! Why this is the gate the plan marks 🚦: the defects that matter here are
|
||||
//! graph-*reasoning* defects. The 57 phase-2 fixture tests prove the engine
|
||||
//! matches my model of PipeWire; only a live run proves my model matches
|
||||
//! PipeWire. A wrong answer at this phase costs a log line. The same wrong
|
||||
//! answer in phase 6 costs an echo — the sharer's own voice, copied back into
|
||||
//! the share, which is the failure this whole design exists to prevent.
|
||||
//!
|
||||
//! ## The one structural requirement (§5.1)
|
||||
//!
|
||||
//! Every emitted record carries the **complete candidate universe partitioned
|
||||
//! into exact eligible and excluded sets**, with a stable reason code on each
|
||||
//! excluded row — never a spot check on named nodes. Checking only the nodes a
|
||||
//! row names constrains nothing about the rest, and it lets the degenerate
|
||||
//! "exclude everything" implementation pass: that build is silent, produces no
|
||||
//! echo, and satisfies any assertion phrased purely as *this must be excluded*.
|
||||
//! Asserting the eligible half of each row is what fails it. That requirement is
|
||||
//! also the plan's answer to open question O7 (over-exclusion needs no separate
|
||||
//! gate — it is subsumed by this one).
|
||||
//!
|
||||
//! ## What is deliberately *not* here
|
||||
//!
|
||||
//! - **No link creation, and no code path that could reach one.** The auditor
|
||||
//! consumes a [`Projection`] and returns a record. It has no handle to
|
||||
//! anything mutable.
|
||||
//! - **No stdout.** Records go to stderr as JSON Lines
|
||||
//! ([`sink`]) because peerspeak parses pixelpass's stdout event stream
|
||||
//! (`screenshare/mod.rs:92`); a stray line there corrupts it.
|
||||
//! - **No `--aec` CLI flag.** That surface is phase 7's mode selector. The audit
|
||||
//! takes its AEC identity from `PIXELPASS_AUDIO_AUDIT_AEC` through the
|
||||
//! *same* [`parse_aec_arg`] the real flag will use, so the parser and the
|
||||
//! validator are both exercised without committing to a public interface
|
||||
//! before it is designed.
|
||||
//!
|
||||
//! ## Fan-out gating vs. taint (read before interpreting a record)
|
||||
//!
|
||||
//! Two independent things can exclude a candidate and the record keeps them
|
||||
//! distinguishable:
|
||||
//!
|
||||
//! - The **taint engine** (phase 2) excludes individual nodes with its own
|
||||
//! reason codes — `peerspeak-owned`, `aec-identity`, `tainted-upstream`, …
|
||||
//! - The **AEC validator** (phase 4) can forbid fan-out *entirely*, regardless
|
||||
//! of taint, whenever the configured identity is unvalidated, failed or
|
||||
//! revoked. Silence over echo.
|
||||
//!
|
||||
//! When the gate is shut, a candidate the engine would have called eligible is
|
||||
//! reported excluded with an audit-level reason ([`GateReason`]); a candidate
|
||||
//! the engine excluded on its own keeps *its* reason, because that names the
|
||||
//! mechanism that actually applies to it. `fan_out_permitted` on the record
|
||||
//! carries the gate state, so the two cases are always tellable apart.
|
||||
//!
|
||||
//! **Consequence for the §5.1 matrix:** every row whose point is the
|
||||
//! eligible/excluded partition must run with `PIXELPASS_AUDIO_AUDIT_AEC=off`
|
||||
//! (state `NotConfigured`, gate open). Row 12 — the AEC lifecycle row — is the
|
||||
//! one that runs with a real `pulse-module:<idx>`, and the gate slamming shut is
|
||||
//! precisely what it asserts.
|
||||
|
||||
#![allow(dead_code)] // Trigger paths are wired by `sink` + `run`; rows are read by tests.
|
||||
|
||||
pub mod metrics;
|
||||
pub mod run;
|
||||
pub mod sink;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::host::aec::{AecConfig, AecState, AecValidator};
|
||||
use crate::host::observer::{EventKind, Millis, Projection, Readiness};
|
||||
use crate::host::taint::snapshot::Serial;
|
||||
use crate::host::taint::{Decisions, Eligibility, ExclusionCtx, StickyState, evaluate};
|
||||
|
||||
/// How long the AEC validator may sit in `Validating` after the graph first
|
||||
/// reports ready before failing closed. Generous relative to the observer's own
|
||||
/// 2 s readiness budget: in the audit a `Failed` is a diagnostic, and timing out
|
||||
/// early would report an absent module that was merely slow to appear.
|
||||
pub const AEC_VALIDATION_TIMEOUT_MILLIS: Millis = 5_000;
|
||||
|
||||
/// Everything the auditor needs beyond the live graph.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct AuditConfig {
|
||||
/// The AEC identity to validate, as parsed from
|
||||
/// `PIXELPASS_AUDIO_AUDIT_AEC`. Defaults to [`AecConfig::Off`] — an audit
|
||||
/// run is not a share, so "there is no echo canceller in play" is the
|
||||
/// honest default, and it is what leaves the fan-out gate open for the
|
||||
/// partition rows.
|
||||
pub aec: AecConfig,
|
||||
pub aec_timeout: Millis,
|
||||
}
|
||||
|
||||
impl Default for AuditConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
aec: AecConfig::Off,
|
||||
aec_timeout: AEC_VALIDATION_TIMEOUT_MILLIS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An audit-level exclusion: the AEC validator has shut the fan-out gate. These
|
||||
/// codes are disjoint from the taint engine's
|
||||
/// [`Reason::code`](crate::host::taint::Reason::code) values, so a reader never
|
||||
/// has to know which layer produced a code to interpret it.
|
||||
// The shared `Aec` prefix is the point: `GateReason::Validating` and
|
||||
// `AecState::Validating` would be one careless glob import away from being
|
||||
// confused, and these three are the *audit's* view of that machine, not the
|
||||
// machine itself.
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GateReason {
|
||||
/// The configured AEC identity has not been seen yet. Not an error — the
|
||||
/// module may still be loading — but no fan-out happens meanwhile.
|
||||
AecValidating,
|
||||
/// The deadline passed with the identity never observed.
|
||||
AecFailed,
|
||||
/// The whole identity disappeared mid-run: every node bearing the index is
|
||||
/// gone (v3.4 §5.3).
|
||||
AecRevoked,
|
||||
}
|
||||
|
||||
impl GateReason {
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::AecValidating => "aec-validating",
|
||||
Self::AecFailed => "aec-failed",
|
||||
Self::AecRevoked => "aec-revoked",
|
||||
}
|
||||
}
|
||||
|
||||
/// The gate reason implied by a validator state, or `None` when fan-out is
|
||||
/// permitted. Mirrors [`AecValidator::fan_out_permitted`] — kept as one
|
||||
/// `match` over the same enum so the two cannot drift: every state that
|
||||
/// permits fan-out maps to `None` and every state that forbids it maps to a
|
||||
/// code.
|
||||
pub fn from_state(state: AecState) -> Option<Self> {
|
||||
match state {
|
||||
AecState::NotConfigured | AecState::Validated => None,
|
||||
AecState::Validating => Some(Self::AecValidating),
|
||||
AecState::Failed => Some(Self::AecFailed),
|
||||
AecState::Revoked => Some(Self::AecRevoked),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable string for an [`AecState`], for the record's `aec_state` field.
|
||||
///
|
||||
/// Defined here rather than on [`AecState`] to keep the merged phase-4 module
|
||||
/// untouched by a reporting concern.
|
||||
fn aec_state_code(state: AecState) -> &'static str {
|
||||
match state {
|
||||
AecState::NotConfigured => "not-configured",
|
||||
AecState::Validating => "validating",
|
||||
AecState::Validated => "validated",
|
||||
AecState::Failed => "failed",
|
||||
AecState::Revoked => "revoked",
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable string for the observer's readiness epoch.
|
||||
fn readiness_code(readiness: Readiness) -> &'static str {
|
||||
match readiness {
|
||||
Readiness::Waiting => "waiting",
|
||||
Readiness::Complete => "complete",
|
||||
Readiness::TimedOut => "timed-out",
|
||||
}
|
||||
}
|
||||
|
||||
/// One candidate node's effective answer. `reason` is `None` exactly when
|
||||
/// `eligible` is true.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct AuditRow {
|
||||
pub serial: u64,
|
||||
pub name: Option<String>,
|
||||
pub eligible: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<&'static str>,
|
||||
/// The exclusion was carried over from a previous snapshot rather than
|
||||
/// derived from the current topology (phase-2 stickiness).
|
||||
pub sticky: bool,
|
||||
}
|
||||
|
||||
/// A tainted node of *any* media role, not just fan-out candidates. Candidates
|
||||
/// already appear in [`AuditBody::candidates`]; this is the diagnostic view —
|
||||
/// when a candidate's exclusion is a surprise, the taint that reached it is the
|
||||
/// next question, and it usually sits on a node that is not itself a candidate.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct TaintRow {
|
||||
pub serial: u64,
|
||||
pub name: Option<String>,
|
||||
pub reason: &'static str,
|
||||
pub sticky: bool,
|
||||
}
|
||||
|
||||
/// The decision content of one recompute — everything except which recompute it
|
||||
/// was. Split out from [`AuditRecord`] so "did anything actually change?" is a
|
||||
/// derived `==` rather than a hand-maintained field comparison that a later
|
||||
/// field addition could silently fall out of.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct AuditBody {
|
||||
/// The observer's dynamic readiness. False ⇒ every candidate is excluded
|
||||
/// `graph-not-ready`; no decision from a partial graph is a decision.
|
||||
pub graph_ready: bool,
|
||||
/// The sticky readiness epoch, which distinguishes the three ways
|
||||
/// `graph_ready` can be false (see [`Projection::readiness`]).
|
||||
pub epoch: &'static str,
|
||||
pub aec_state: &'static str,
|
||||
/// The index handed to the taint engine — `Some` only while `Validated`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aec_module_id: Option<u64>,
|
||||
/// Whether the AEC validator permits fan-out at all right now.
|
||||
pub fan_out_permitted: bool,
|
||||
/// The audit-level reason fan-out is forbidden, when it is.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gate_reason: Option<&'static str>,
|
||||
/// **The complete candidate universe**, ascending by serial — every
|
||||
/// `Stream/Output/Audio` node in the snapshot, partitioned. §5.1's exact
|
||||
/// partition is `candidates`, not a subset of it.
|
||||
pub candidates: Vec<AuditRow>,
|
||||
pub eligible_count: usize,
|
||||
pub excluded_count: usize,
|
||||
/// Taint across all node roles, ascending by serial.
|
||||
pub taint: Vec<TaintRow>,
|
||||
}
|
||||
|
||||
impl AuditBody {
|
||||
/// Serials of eligible candidates, ascending — the half of the partition an
|
||||
/// exclude-everything build fails.
|
||||
pub fn eligible(&self) -> Vec<u64> {
|
||||
self.candidates
|
||||
.iter()
|
||||
.filter(|row| row.eligible)
|
||||
.map(|row| row.serial)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `(serial, reason code)` for excluded candidates, ascending.
|
||||
pub fn excluded(&self) -> Vec<(u64, &'static str)> {
|
||||
self.candidates
|
||||
.iter()
|
||||
.filter(|row| !row.eligible)
|
||||
.map(|row| (row.serial, row.reason.unwrap_or("?")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The eligible candidate with this name, if any. Convenience for the
|
||||
/// matrix rows, which name nodes rather than serials.
|
||||
pub fn row_named(&self, name: &str) -> Option<&AuditRow> {
|
||||
self.candidates
|
||||
.iter()
|
||||
.find(|row| row.name.as_deref() == Some(name))
|
||||
}
|
||||
}
|
||||
|
||||
/// One recompute, as emitted.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct AuditRecord {
|
||||
/// Monotonic per-run counter over *every* recompute, emitted or suppressed,
|
||||
/// so a gap in the emitted sequence is visibly a suppression rather than a
|
||||
/// lost line.
|
||||
pub seq: u64,
|
||||
pub trigger: &'static str,
|
||||
/// Observer-clock milliseconds at which this recompute ran.
|
||||
pub at_ms: Millis,
|
||||
#[serde(flatten)]
|
||||
pub body: AuditBody,
|
||||
}
|
||||
|
||||
/// What one [`Auditor::observe`] produced.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AuditOutcome {
|
||||
pub record: AuditRecord,
|
||||
/// Whether the record should be written. See [`Auditor::observe`].
|
||||
pub emit: bool,
|
||||
}
|
||||
|
||||
/// The dry-run auditor: phases 2–4 folded together over a live projection.
|
||||
///
|
||||
/// Read-only by construction — it borrows a [`Projection`] and owns only the
|
||||
/// state phases 2 and 4 thread explicitly ([`StickyState`], [`AecValidator`]).
|
||||
/// There is no field here through which a link could be created.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Auditor {
|
||||
validator: AecValidator,
|
||||
sticky: StickyState,
|
||||
seq: u64,
|
||||
/// The body of the last record actually written, for change suppression.
|
||||
last_emitted: Option<AuditBody>,
|
||||
}
|
||||
|
||||
impl Auditor {
|
||||
pub fn new(config: AuditConfig) -> Self {
|
||||
Self {
|
||||
validator: AecValidator::new(config.aec, config.aec_timeout),
|
||||
sticky: StickyState::default(),
|
||||
seq: 0,
|
||||
last_emitted: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn aec_state(&self) -> AecState {
|
||||
self.validator.state()
|
||||
}
|
||||
|
||||
pub fn sticky(&self) -> &StickyState {
|
||||
&self.sticky
|
||||
}
|
||||
|
||||
/// Fold one projection into the audit.
|
||||
///
|
||||
/// **Called once per applied registry event — never on a coalesced batch.**
|
||||
/// That is not a performance preference, it is the phase-4 integration
|
||||
/// contract (`aec/mod.rs`, the `Validated` arm): revocation is detected by
|
||||
/// observing the *empty gap* between a module unload and the next reload,
|
||||
/// and module indices are reused verbatim (v3.4 §5.2 correction 3). Coalesce
|
||||
/// across that gap and a fresh module silently inherits a dead module's
|
||||
/// validated identity. [`sink`] is what upholds this, by running the
|
||||
/// recompute inline on the observer thread rather than polling
|
||||
/// [`RegistryObserverHandle::latest`](crate::host::observer::adapter::RegistryObserverHandle::latest),
|
||||
/// which coalesces by nature.
|
||||
///
|
||||
/// `emit` is true for every graph-triggered recompute, and for a
|
||||
/// tick-triggered one only when the decision content changed. Ticks arrive
|
||||
/// at a constant 4 Hz purely to drive the AEC deadline; emitting an
|
||||
/// identical record four times a second would bury the graph events the
|
||||
/// audit exists to show. `seq` still advances on suppressed records, so
|
||||
/// nothing about the run is silently unaccounted for.
|
||||
pub fn observe(
|
||||
&mut self,
|
||||
projection: &Projection,
|
||||
kind: EventKind,
|
||||
now: Millis,
|
||||
) -> AuditOutcome {
|
||||
self.seq += 1;
|
||||
|
||||
// Phase 4 first: its verdict is an *input* to phase 2 via
|
||||
// `ExclusionCtx::aec_module_id`, so observing the graph in the other
|
||||
// order would evaluate taint against the previous recompute's identity.
|
||||
self.validator
|
||||
.observe(&projection.snapshot, projection.graph_ready, now);
|
||||
let aec_state = self.validator.state();
|
||||
let gate_reason = GateReason::from_state(aec_state);
|
||||
|
||||
let ctx = ExclusionCtx {
|
||||
aec_module_id: self.validator.validated_module_id(),
|
||||
pipewire_pulse_pid: projection.pipewire_pulse_pid,
|
||||
// The audit creates nothing, so it owns nothing. Another host's
|
||||
// capture sink is still caught — by the `pixelpass_capture_*` name
|
||||
// prefix (v3.4 §6.2), which is what §5.1 row 7 exercises — so an
|
||||
// empty set costs the matrix nothing.
|
||||
pixelpass_owned: Default::default(),
|
||||
graph_ready: projection.graph_ready,
|
||||
};
|
||||
|
||||
let (decisions, sticky) = evaluate(&projection.snapshot, &ctx, &self.sticky);
|
||||
self.sticky = sticky;
|
||||
|
||||
let body = build_body(
|
||||
projection,
|
||||
&decisions,
|
||||
aec_state,
|
||||
self.validator.validated_module_id(),
|
||||
gate_reason,
|
||||
);
|
||||
|
||||
let emit = kind == EventKind::Graph || self.last_emitted.as_ref() != Some(&body);
|
||||
if emit {
|
||||
self.last_emitted = Some(body.clone());
|
||||
}
|
||||
|
||||
AuditOutcome {
|
||||
record: AuditRecord {
|
||||
seq: self.seq,
|
||||
trigger: kind.code(),
|
||||
at_ms: now,
|
||||
body,
|
||||
},
|
||||
emit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_body(
|
||||
projection: &Projection,
|
||||
decisions: &Decisions,
|
||||
aec_state: AecState,
|
||||
aec_module_id: Option<u64>,
|
||||
gate_reason: Option<GateReason>,
|
||||
) -> AuditBody {
|
||||
let candidates: Vec<AuditRow> = decisions
|
||||
.candidates
|
||||
.values()
|
||||
.map(|decision| {
|
||||
// The engine's own reason wins when it has one: it names the
|
||||
// mechanism that actually excluded *this* node, which is what the
|
||||
// §5.1 rows assert. The gate reason applies only to candidates the
|
||||
// engine would have passed — otherwise a shut gate would erase every
|
||||
// reason code in the record and the matrix would stop constraining
|
||||
// the engine at all.
|
||||
let (eligible, reason, sticky) = match decision.eligibility {
|
||||
Eligibility::NotEligible { reason, sticky } => (false, Some(reason.code()), sticky),
|
||||
Eligibility::Eligible => match gate_reason {
|
||||
Some(gate) => (false, Some(gate.code()), false),
|
||||
None => (true, None, false),
|
||||
},
|
||||
};
|
||||
AuditRow {
|
||||
serial: decision.serial.0,
|
||||
name: decision.name.clone(),
|
||||
eligible,
|
||||
reason,
|
||||
sticky,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let eligible_count = candidates.iter().filter(|row| row.eligible).count();
|
||||
|
||||
let taint: Vec<TaintRow> = decisions
|
||||
.taint
|
||||
.iter()
|
||||
.map(|(&serial, entry)| TaintRow {
|
||||
serial: serial.0,
|
||||
name: node_name(projection, serial),
|
||||
reason: entry.reason.code(),
|
||||
sticky: entry.sticky,
|
||||
})
|
||||
.collect();
|
||||
|
||||
AuditBody {
|
||||
graph_ready: projection.graph_ready,
|
||||
epoch: readiness_code(projection.readiness),
|
||||
aec_state: aec_state_code(aec_state),
|
||||
aec_module_id,
|
||||
fan_out_permitted: gate_reason.is_none(),
|
||||
gate_reason: gate_reason.map(GateReason::code),
|
||||
excluded_count: candidates.len() - eligible_count,
|
||||
eligible_count,
|
||||
candidates,
|
||||
taint,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_name(projection: &Projection, serial: Serial) -> Option<String> {
|
||||
projection
|
||||
.snapshot
|
||||
.node(serial)
|
||||
.and_then(|node| node.name.clone())
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Triggering the dry-run audit: environment parsing and the two entry points.
|
||||
//!
|
||||
//! The impl plan §5 specifies a **hidden trigger**, `PIXELPASS_AUDIO_AUDIT=1`.
|
||||
//! It is honoured in two places, which answer two different questions:
|
||||
//!
|
||||
//! - **Inside a real `pixelpass host` run** ([`spawn_if_enabled`]) — proves the
|
||||
//! audit works in the code path phase 6 will actually mutate. This is the
|
||||
//! plan-literal reading of the trigger.
|
||||
//! - **Standalone** ([`run_standalone`], behind the hidden `--audit-audio`
|
||||
//! flag) — observer plus auditor and nothing else: no iroh endpoint, no
|
||||
//! display-server detection, no capture pipeline, no ticket. This is what
|
||||
//! drives the §5.1 matrix, because a row that fails should fail for a reason
|
||||
//! about *audio*, not because a relay was unreachable.
|
||||
//!
|
||||
//! Both paths run the same [`AuditSink`] over the same observer, so neither is a
|
||||
//! simulation of the other.
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
use super::sink::AuditSink;
|
||||
use super::{AEC_VALIDATION_TIMEOUT_MILLIS, AuditConfig};
|
||||
use crate::common::signal;
|
||||
use crate::host::aec::{AecConfig, AecParseError, parse_aec_arg};
|
||||
use crate::host::observer::adapter::RegistryObserverHandle;
|
||||
|
||||
/// The hidden trigger (impl plan §5). Exactly `1` enables the audit; anything
|
||||
/// else, including `true` or `yes`, does not.
|
||||
///
|
||||
/// Deliberately strict. This variable can only arrive by someone typing it, and
|
||||
/// a value that *looks* enabling but is not would produce a silent no-op — the
|
||||
/// single most annoying failure mode for a diagnostic tool. A mistyped value
|
||||
/// gets a warning (see [`enabled`]) rather than silence.
|
||||
pub const AUDIT_ENV: &str = "PIXELPASS_AUDIO_AUDIT";
|
||||
|
||||
/// The AEC identity for the audit, in the `--aec` grammar (`off` or
|
||||
/// `pulse-module:<idx>`). Absent ⇒ `off`.
|
||||
pub const AUDIT_AEC_ENV: &str = "PIXELPASS_AUDIO_AUDIT_AEC";
|
||||
|
||||
/// Redirect the JSON Lines stream to this file instead of stderr.
|
||||
pub const AUDIT_FILE_ENV: &str = "PIXELPASS_AUDIO_AUDIT_FILE";
|
||||
|
||||
/// Whether the hidden trigger is set.
|
||||
pub fn enabled() -> bool {
|
||||
match std::env::var(AUDIT_ENV) {
|
||||
Ok(value) if value == "1" => true,
|
||||
Ok(value) => {
|
||||
tracing::warn!(
|
||||
"{AUDIT_ENV}={value:?} is not `1`; the audio audit stays off. \
|
||||
Set {AUDIT_ENV}=1 to enable it."
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the audit configuration from the environment.
|
||||
///
|
||||
/// A malformed `PIXELPASS_AUDIO_AUDIT_AEC` is **fatal**, matching the phase-4
|
||||
/// rule that a bad `--aec` value must not silently become "no AEC": there is no
|
||||
/// fail-closed default index, so a wrong or dropped one would exclude the wrong
|
||||
/// node (or nothing at all) and the audit would confidently report a partition
|
||||
/// computed against an identity nobody asked for.
|
||||
pub fn config_from_env() -> Result<AuditConfig> {
|
||||
let aec = match std::env::var(AUDIT_AEC_ENV) {
|
||||
Ok(raw) => parse_aec_arg(&raw).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"{AUDIT_AEC_ENV}={raw:?} is not a valid AEC argument ({}). \
|
||||
Expected `off` or `pulse-module:<index>`, where the index is a bare decimal.",
|
||||
describe(e)
|
||||
)
|
||||
})?,
|
||||
Err(std::env::VarError::NotPresent) => AecConfig::Off,
|
||||
Err(e) => bail!("{AUDIT_AEC_ENV} is not readable: {e}"),
|
||||
};
|
||||
Ok(AuditConfig {
|
||||
aec,
|
||||
aec_timeout: AEC_VALIDATION_TIMEOUT_MILLIS,
|
||||
})
|
||||
}
|
||||
|
||||
fn describe(error: AecParseError) -> &'static str {
|
||||
match error {
|
||||
AecParseError::Empty => "the value was empty",
|
||||
AecParseError::UnknownForm => "not `off` and not `pulse-module:...`",
|
||||
AecParseError::MissingIndex => "`pulse-module:` with no index after the colon",
|
||||
AecParseError::InvalidIndex => {
|
||||
"the index was not a bare decimal (no sign, whitespace, or non-digits) that fits in u64"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the JSON Lines go. Stderr unless `PIXELPASS_AUDIO_AUDIT_FILE` names a
|
||||
/// file, which is appended to rather than truncated — a matrix run that restarts
|
||||
/// the process mid-scenario should not lose the rows it already recorded.
|
||||
fn writer_from_env() -> Result<Box<dyn Write + Send>> {
|
||||
match std::env::var(AUDIT_FILE_ENV) {
|
||||
Ok(path) if !path.is_empty() => {
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.with_context(|| format!("{AUDIT_FILE_ENV}={path:?} could not be opened"))?;
|
||||
tracing::info!("audio audit: writing records to {path}");
|
||||
Ok(Box::new(file))
|
||||
}
|
||||
_ => Ok(Box::new(std::io::stderr())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct the sink and spawn the observer behind it.
|
||||
fn spawn_audit() -> Result<RegistryObserverHandle> {
|
||||
let config = config_from_env()?;
|
||||
let sink = AuditSink::new(config, writer_from_env()?);
|
||||
tracing::info!(
|
||||
aec = ?config.aec,
|
||||
"audio audit: dry run active — decisions are logged, no links are created"
|
||||
);
|
||||
RegistryObserverHandle::spawn_with_sink(Some(Box::new(sink)))
|
||||
}
|
||||
|
||||
/// Start the audit if the hidden trigger is set, for a `pixelpass host` run.
|
||||
///
|
||||
/// The returned handle must be held for the lifetime of the run: dropping it
|
||||
/// stops the observer thread and flushes the final O5 summary.
|
||||
///
|
||||
/// Returns `Err` only when the trigger *was* set and starting failed — a
|
||||
/// misconfigured audit is worth failing the run over, because the alternative is
|
||||
/// a host that silently is not being audited while its operator believes it is.
|
||||
pub fn spawn_if_enabled() -> Result<Option<RegistryObserverHandle>> {
|
||||
if !enabled() {
|
||||
return Ok(None);
|
||||
}
|
||||
spawn_audit().map(Some)
|
||||
}
|
||||
|
||||
/// The standalone audit: run the observer and the auditor, and nothing else,
|
||||
/// until ctrl-c.
|
||||
///
|
||||
/// Does not consult [`AUDIT_ENV`] — reaching this function required passing the
|
||||
/// hidden `--audit-audio` flag, which is already an explicit request. The
|
||||
/// environment still supplies the AEC identity and the output file.
|
||||
pub async fn run_standalone() -> Result<()> {
|
||||
let cancel = signal::install_ctrl_c();
|
||||
let handle = spawn_audit()?;
|
||||
|
||||
eprintln!(
|
||||
"pixelpass audio audit (dry run): observing the live PipeWire graph.\n\
|
||||
No links are created and no routing changes. Ctrl-C to stop."
|
||||
);
|
||||
|
||||
// SIGTERM as well as ctrl-c, because this mode is driven by scripts as much
|
||||
// as by hand — `timeout`, a matrix harness, and systemd all send SIGTERM,
|
||||
// and the default disposition would kill the process before the sink's
|
||||
// `Drop` writes the final O5 summary. Losing that summary is losing the
|
||||
// whole §5.2 measurement for that run.
|
||||
let mut sigterm = signal::terminate_stream()?;
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => {}
|
||||
_ = sigterm.recv() => tracing::info!("SIGTERM received, shutting down"),
|
||||
}
|
||||
// Explicit rather than incidental: this drop stops the PipeWire thread,
|
||||
// which drops the sink, which writes the final metrics line. Letting it fall
|
||||
// out of scope would do the same thing, but the ordering is the point.
|
||||
drop(handle);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! The audit's I/O edge: timing, JSON Lines emission, O5 accounting.
|
||||
//!
|
||||
//! Everything impure about phase 5 lives here, and it is deliberately thin —
|
||||
//! read the clock, call [`Auditor::observe`], write a line, fold a
|
||||
//! [`metrics::Sample`]. The decisions are all upstream in the pure core, which
|
||||
//! is why the matrix can be argued about in unit tests rather than only in front
|
||||
//! of a live daemon.
|
||||
//!
|
||||
//! ## Why this runs on the observer thread
|
||||
//!
|
||||
//! [`AuditSink`] is a [`ProjectionSink`], invoked inline from the PipeWire
|
||||
//! observer thread once per applied registry event. The obvious alternative —
|
||||
//! a consumer task polling
|
||||
//! [`RegistryObserverHandle::latest`](super::super::observer::adapter::RegistryObserverHandle::latest)
|
||||
//! — was rejected: polling **coalesces**, and phase 4's revocation logic
|
||||
//! detects a module unload by observing the *empty gap* before the next module
|
||||
//! appears. Module indices are reused verbatim across an unload/reload (v3.4
|
||||
//! §5.2 correction 3), so a poller that misses the gap silently aliases a fresh
|
||||
//! module onto a dead module's validated identity. Running inline is what makes
|
||||
//! "one `observe` per graph event, no coalescing" — the contract phase 4
|
||||
//! documents as owed — actually true.
|
||||
//!
|
||||
//! The cost of that choice is that recompute and logging happen on the thread
|
||||
//! servicing PipeWire, which is precisely the risk O5 asks about. That is not an
|
||||
//! accident: this arrangement puts the cost exactly where the measurement can
|
||||
//! see it. See [`metrics`].
|
||||
//!
|
||||
//! ## Output contract
|
||||
//!
|
||||
//! One JSON object per line, to **stderr** by default, each tagged with a `kind`
|
||||
//! discriminator (`"audit"` or `"metrics"`). Never stdout: peerspeak parses
|
||||
//! pixelpass's stdout event stream, and the impl plan §5 is explicit that
|
||||
//! unstructured output must not go there. `PIXELPASS_AUDIO_AUDIT_FILE`
|
||||
//! redirects the records to a file instead, which is how the §5.1 matrix is
|
||||
//! driven — it separates the audit stream from interleaved `tracing` output
|
||||
//! without needing either side to change format.
|
||||
|
||||
use std::io::Write;
|
||||
use std::time::Instant;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use super::metrics::{self, Metrics, Summary};
|
||||
use super::{AuditConfig, AuditRecord, Auditor};
|
||||
use crate::host::observer::adapter::ProjectionSink;
|
||||
use crate::host::observer::{EventKind, Millis, Projection};
|
||||
|
||||
/// Emit a rolling metrics line every this many ticks. Ticks are 250 ms, so this
|
||||
/// is every 10 s — often enough that a run killed abruptly still leaves a
|
||||
/// usable O5 record, rare enough that it does not crowd out the audit records.
|
||||
const SUMMARY_INTERVAL_TICKS: u64 = 40;
|
||||
|
||||
/// The live audit: pure auditor + clock + writer.
|
||||
pub struct AuditSink {
|
||||
auditor: Auditor,
|
||||
metrics: Metrics,
|
||||
writer: Box<dyn Write + Send>,
|
||||
/// Set once the first sample has completed, so the first event is not
|
||||
/// counted as having queued behind a predecessor that does not exist.
|
||||
last_completion_us: Option<u64>,
|
||||
ticks_since_summary: u64,
|
||||
/// Wall-clock origin for the microsecond timings. Only used for durations,
|
||||
/// never for the AEC deadline — that runs on the observer's own clock,
|
||||
/// handed in as `now_us`, so the validator and the readiness epoch cannot
|
||||
/// disagree about what time it is.
|
||||
epoch: Instant,
|
||||
}
|
||||
|
||||
impl AuditSink {
|
||||
pub fn new(config: AuditConfig, writer: Box<dyn Write + Send>) -> Self {
|
||||
Self {
|
||||
auditor: Auditor::new(config),
|
||||
metrics: Metrics::default(),
|
||||
writer,
|
||||
last_completion_us: None,
|
||||
ticks_since_summary: 0,
|
||||
epoch: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsed_us(&self) -> u64 {
|
||||
u64::try_from(self.epoch.elapsed().as_micros()).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
/// Write one line. Failures are logged once per occurrence and otherwise
|
||||
/// ignored: a broken stderr must not take down the observer thread, and the
|
||||
/// audit is diagnostic — losing a line is a worse audit, not a worse share.
|
||||
fn write_line<T: Serialize>(&mut self, line: &T) {
|
||||
match serde_json::to_string(line) {
|
||||
Ok(json) => {
|
||||
if let Err(e) = writeln!(self.writer, "{json}") {
|
||||
tracing::warn!("audit: failed to write record: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("audit: failed to serialise record: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_summary(&mut self, at_ms: Millis) {
|
||||
let summary = self.metrics.summary();
|
||||
self.write_line(&MetricsLine {
|
||||
kind: "metrics",
|
||||
at_ms,
|
||||
summary: &summary,
|
||||
});
|
||||
let _ = self.writer.flush();
|
||||
}
|
||||
}
|
||||
|
||||
impl ProjectionSink for AuditSink {
|
||||
fn on_projection(&mut self, projection: &Projection, kind: EventKind, now_us: u64) {
|
||||
let at_us = self.elapsed_us();
|
||||
let gap_us = self
|
||||
.last_completion_us
|
||||
.map(|previous| at_us.saturating_sub(previous))
|
||||
.unwrap_or(0);
|
||||
|
||||
let recompute_start = self.elapsed_us();
|
||||
let outcome = self.auditor.observe(projection, kind, now_us / 1_000);
|
||||
let recompute_us = self.elapsed_us().saturating_sub(recompute_start);
|
||||
|
||||
let emit_us = if outcome.emit {
|
||||
let emit_start = self.elapsed_us();
|
||||
self.write_line(&AuditLine {
|
||||
kind: "audit",
|
||||
recompute_us,
|
||||
record: &outcome.record,
|
||||
});
|
||||
// Flushed per record so a run ended with SIGKILL (or a matrix row
|
||||
// that reads the file while the process is still up) still shows
|
||||
// every decision made before that instant. The cost is measured, not
|
||||
// assumed — it is inside `emit_us`.
|
||||
let _ = self.writer.flush();
|
||||
self.elapsed_us().saturating_sub(emit_start).max(1)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
self.metrics.record(metrics::Sample {
|
||||
at_us,
|
||||
gap_us,
|
||||
recompute_us,
|
||||
emit_us,
|
||||
kind,
|
||||
});
|
||||
self.last_completion_us = Some(self.elapsed_us());
|
||||
|
||||
if kind == EventKind::Tick {
|
||||
self.ticks_since_summary += 1;
|
||||
if self.ticks_since_summary >= SUMMARY_INTERVAL_TICKS {
|
||||
self.ticks_since_summary = 0;
|
||||
self.write_summary(now_us / 1_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AuditSink {
|
||||
/// The final O5 record. The observer thread drops its sink when the main
|
||||
/// loop quits, so an ordinary ctrl-c leaves a complete summary behind
|
||||
/// without the runner having to ask for one.
|
||||
fn drop(&mut self) {
|
||||
let at_ms = self.elapsed_us() / 1_000;
|
||||
self.write_summary(at_ms);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AuditLine<'a> {
|
||||
kind: &'static str,
|
||||
/// This record's own recompute cost, so a surprising row can be correlated
|
||||
/// with a cost spike without cross-referencing the periodic summary.
|
||||
recompute_us: u64,
|
||||
#[serde(flatten)]
|
||||
record: &'a AuditRecord,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MetricsLine<'a> {
|
||||
kind: &'static str,
|
||||
at_ms: Millis,
|
||||
#[serde(flatten)]
|
||||
summary: &'a Summary,
|
||||
}
|
||||
@@ -0,0 +1,896 @@
|
||||
//! Pure tests for the phase-5 auditor and its O5 metrics.
|
||||
//!
|
||||
//! Two things are being tested here and they are worth keeping distinct:
|
||||
//!
|
||||
//! - **Audit-layer behaviour** — the fan-out gate, record suppression, sequence
|
||||
//! accounting, epoch reporting, and above all that every record carries the
|
||||
//! *complete* candidate universe (§5.1). These are properties nothing else
|
||||
//! tests, because nothing else exists at this layer.
|
||||
//! - **A few §5.1 matrix shapes in fixture form** — row 1 (owner-bridge
|
||||
//! forwarder), row 3 (two modules, one tainted), row 12 (AEC lifecycle). These
|
||||
//! are *not* re-litigating phase 2, whose 57 tests already own those verdicts.
|
||||
//! They exist so that a plumbing mistake between the engine and the record —
|
||||
//! a dropped reason code, an inverted partition — fails here, at compile-time
|
||||
//! speed, rather than only in front of a live daemon.
|
||||
//!
|
||||
//! The live half of the gate cannot live in this file by definition: a fixture
|
||||
//! tests my model against my own assumptions, and §5's whole argument is that
|
||||
//! only a live run tests my model against PipeWire. See the matrix runs recorded
|
||||
//! in the phase-5 results file.
|
||||
|
||||
use super::metrics::{BUCKET_LABELS, Metrics, QUEUE_THRESHOLD_US, Sample};
|
||||
use super::*;
|
||||
use crate::host::aec::AecConfig;
|
||||
use crate::host::observer::{EventKind, Readiness};
|
||||
use crate::host::taint::fixture::{self, Graph, NodeRef};
|
||||
use crate::host::taint::snapshot::{GraphSnapshot, MediaRole};
|
||||
|
||||
const AEC_MODULE: u64 = 7;
|
||||
const TIMEOUT: Millis = 5_000;
|
||||
|
||||
fn ready(snapshot: GraphSnapshot) -> Projection {
|
||||
Projection {
|
||||
snapshot,
|
||||
pipewire_pulse_pid: Some(fixture::PULSE_PID),
|
||||
graph_ready: true,
|
||||
readiness: Readiness::Complete,
|
||||
}
|
||||
}
|
||||
|
||||
fn not_ready(snapshot: GraphSnapshot, readiness: Readiness) -> Projection {
|
||||
Projection {
|
||||
snapshot,
|
||||
pipewire_pulse_pid: Some(fixture::PULSE_PID),
|
||||
graph_ready: false,
|
||||
readiness,
|
||||
}
|
||||
}
|
||||
|
||||
fn auditor_off() -> Auditor {
|
||||
Auditor::new(AuditConfig {
|
||||
aec: AecConfig::Off,
|
||||
aec_timeout: TIMEOUT,
|
||||
})
|
||||
}
|
||||
|
||||
fn auditor_aec(index: u64) -> Auditor {
|
||||
Auditor::new(AuditConfig {
|
||||
aec: AecConfig::PulseModule(index),
|
||||
aec_timeout: TIMEOUT,
|
||||
})
|
||||
}
|
||||
|
||||
/// One graph-triggered recompute at `now`.
|
||||
fn observe(auditor: &mut Auditor, projection: &Projection, now: Millis) -> AuditOutcome {
|
||||
auditor.observe(projection, EventKind::Graph, now)
|
||||
}
|
||||
|
||||
/// Candidate names split into (eligible, excluded-with-reason), which is how the
|
||||
/// §5.1 rows are phrased. Names rather than serials so a failure reads as the
|
||||
/// scenario rather than as an integer.
|
||||
fn partition(body: &AuditBody) -> (Vec<&str>, Vec<(&str, &str)>) {
|
||||
let eligible = body
|
||||
.candidates
|
||||
.iter()
|
||||
.filter(|row| row.eligible)
|
||||
.map(|row| row.name.as_deref().unwrap_or("<unnamed>"))
|
||||
.collect();
|
||||
let excluded = body
|
||||
.candidates
|
||||
.iter()
|
||||
.filter(|row| !row.eligible)
|
||||
.map(|row| {
|
||||
(
|
||||
row.name.as_deref().unwrap_or("<unnamed>"),
|
||||
row.reason.unwrap_or("<none>"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
(eligible, excluded)
|
||||
}
|
||||
|
||||
// ── the §5.1 structural requirement ───────────────────────────────────────
|
||||
|
||||
/// The record must contain **every** `Stream/Output/Audio` node, not only the
|
||||
/// interesting ones. This is the property the whole exact-partition requirement
|
||||
/// rests on: if the record could omit a candidate, then asserting a complete
|
||||
/// partition over the record would still not constrain the graph.
|
||||
#[test]
|
||||
fn the_record_carries_the_complete_candidate_universe() {
|
||||
let mut graph = Graph::new();
|
||||
graph.app_node("music", MediaRole::StreamOutput, 100);
|
||||
graph.app_node("game", MediaRole::StreamOutput, 101);
|
||||
graph.app_node("recorder", MediaRole::StreamInput, 102);
|
||||
graph.device_node("speakers", MediaRole::Sink);
|
||||
let projection = ready(graph.build());
|
||||
|
||||
let outcome = observe(&mut auditor_off(), &projection, 0);
|
||||
let (eligible, excluded) = partition(&outcome.record.body);
|
||||
|
||||
// Both playback streams, neither the capture stream nor the sink. Ordered by
|
||||
// serial (creation order), which is what makes the partition assertions in
|
||||
// every other row stable rather than dependent on a hash iteration.
|
||||
assert_eq!(eligible, vec!["music", "game"]);
|
||||
assert!(excluded.is_empty(), "unexpected exclusions: {excluded:?}");
|
||||
assert_eq!(outcome.record.body.candidates.len(), 2);
|
||||
assert_eq!(outcome.record.body.eligible_count, 2);
|
||||
assert_eq!(outcome.record.body.excluded_count, 0);
|
||||
}
|
||||
|
||||
/// The fail-closed default asserted at the boundary (impl plan §4, phase 2's
|
||||
/// "one addition"): nothing in, nothing eligible — and, just as importantly, no
|
||||
/// panic and no invented row.
|
||||
#[test]
|
||||
fn an_empty_graph_yields_an_empty_partition() {
|
||||
let projection = ready(Graph::new().build());
|
||||
let outcome = observe(&mut auditor_off(), &projection, 0);
|
||||
|
||||
assert!(outcome.record.body.candidates.is_empty());
|
||||
assert!(outcome.record.body.taint.is_empty());
|
||||
assert_eq!(outcome.record.body.eligible_count, 0);
|
||||
assert_eq!(outcome.record.body.excluded_count, 0);
|
||||
assert!(outcome.record.body.fan_out_permitted);
|
||||
}
|
||||
|
||||
/// `eligible_count + excluded_count` is the candidate count, always. A partition
|
||||
/// that does not partition would let a row's two assertions both pass while the
|
||||
/// record described no coherent state.
|
||||
#[test]
|
||||
fn the_counts_always_partition_the_candidates() {
|
||||
let mut graph = Graph::new();
|
||||
graph.app_node("music", MediaRole::StreamOutput, 100);
|
||||
graph.peerspeak_node("peerspeak-playback", 200);
|
||||
let projection = ready(graph.build());
|
||||
|
||||
let body = observe(&mut auditor_off(), &projection, 0).record.body;
|
||||
assert_eq!(
|
||||
body.eligible_count + body.excluded_count,
|
||||
body.candidates.len()
|
||||
);
|
||||
assert_eq!(body.eligible().len(), body.eligible_count);
|
||||
assert_eq!(body.excluded().len(), body.excluded_count);
|
||||
}
|
||||
|
||||
/// Every excluded row names a reason and every eligible row does not. The
|
||||
/// §5.1 rows assert "excluded, with reason code" — a `None` reason on an
|
||||
/// excluded row would make that assertion unwritable.
|
||||
#[test]
|
||||
fn reason_presence_is_exactly_the_exclusion() {
|
||||
let mut graph = Graph::new();
|
||||
graph.app_node("music", MediaRole::StreamOutput, 100);
|
||||
graph.peerspeak_node("peerspeak-playback", 200);
|
||||
let projection = ready(graph.build());
|
||||
|
||||
for row in observe(&mut auditor_off(), &projection, 0)
|
||||
.record
|
||||
.body
|
||||
.candidates
|
||||
{
|
||||
assert_eq!(
|
||||
row.eligible,
|
||||
row.reason.is_none(),
|
||||
"row {row:?} has eligibility and reason out of step"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── readiness ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// No decision made from a partial graph is a decision. Note this is asserted on
|
||||
/// the *eligible* half too: an implementation that reported nothing at all while
|
||||
/// not ready would also be wrong, because the audit must still show what it can
|
||||
/// see.
|
||||
#[test]
|
||||
fn a_not_ready_graph_excludes_every_candidate() {
|
||||
let mut graph = Graph::new();
|
||||
graph.app_node("music", MediaRole::StreamOutput, 100);
|
||||
graph.app_node("game", MediaRole::StreamOutput, 101);
|
||||
let projection = not_ready(graph.build(), Readiness::Waiting);
|
||||
|
||||
let body = observe(&mut auditor_off(), &projection, 0).record.body;
|
||||
let (eligible, excluded) = partition(&body);
|
||||
|
||||
assert!(eligible.is_empty());
|
||||
assert_eq!(
|
||||
excluded,
|
||||
vec![("music", "graph-not-ready"), ("game", "graph-not-ready"),]
|
||||
);
|
||||
assert!(!body.graph_ready);
|
||||
}
|
||||
|
||||
/// The three ways `graph_ready` can be false are distinguishable in the record.
|
||||
/// Collapsing them would make a timed-out observer — a fail-closed *fault* —
|
||||
/// indistinguishable from an enumeration that is merely still running.
|
||||
#[test]
|
||||
fn the_epoch_distinguishes_the_ways_a_graph_can_be_unready() {
|
||||
let mut graph = Graph::new();
|
||||
graph.app_node("music", MediaRole::StreamOutput, 100);
|
||||
let snapshot = graph.build();
|
||||
|
||||
for (readiness, expected) in [
|
||||
(Readiness::Waiting, "waiting"),
|
||||
(Readiness::TimedOut, "timed-out"),
|
||||
// A completed epoch momentarily blocked on a current obligation: the
|
||||
// interesting one, because `graph_ready` alone makes it look like a
|
||||
// brand-new observer.
|
||||
(Readiness::Complete, "complete"),
|
||||
] {
|
||||
let projection = not_ready(snapshot.clone(), readiness);
|
||||
let body = observe(&mut auditor_off(), &projection, 0).record.body;
|
||||
assert_eq!(body.epoch, expected);
|
||||
assert!(!body.graph_ready);
|
||||
}
|
||||
|
||||
let body = observe(&mut auditor_off(), &ready(snapshot), 0).record.body;
|
||||
assert_eq!(body.epoch, "complete");
|
||||
assert!(body.graph_ready);
|
||||
}
|
||||
|
||||
// ── the fan-out gate (phase 4 → audit) ────────────────────────────────────
|
||||
|
||||
/// `--aec=off` leaves the gate open: `NotConfigured` is "there is no echo
|
||||
/// canceller", not "we failed to find one".
|
||||
#[test]
|
||||
fn aec_off_leaves_the_gate_open() {
|
||||
let mut graph = Graph::new();
|
||||
graph.app_node("music", MediaRole::StreamOutput, 100);
|
||||
let projection = ready(graph.build());
|
||||
|
||||
let body = observe(&mut auditor_off(), &projection, 0).record.body;
|
||||
assert_eq!(body.aec_state, "not-configured");
|
||||
assert!(body.fan_out_permitted);
|
||||
assert_eq!(body.gate_reason, None);
|
||||
assert_eq!(body.aec_module_id, None);
|
||||
assert_eq!(partition(&body).0, vec!["music"]);
|
||||
}
|
||||
|
||||
/// While the configured identity has not been seen, nothing may fan out —
|
||||
/// silence over echo — and the record says why in a code, not in prose.
|
||||
#[test]
|
||||
fn a_validating_gate_excludes_every_engine_eligible_candidate() {
|
||||
let mut graph = Graph::new();
|
||||
graph.app_node("music", MediaRole::StreamOutput, 100);
|
||||
graph.app_node("game", MediaRole::StreamOutput, 101);
|
||||
let projection = ready(graph.build());
|
||||
|
||||
let body = observe(&mut auditor_aec(AEC_MODULE), &projection, 0)
|
||||
.record
|
||||
.body;
|
||||
let (eligible, excluded) = partition(&body);
|
||||
|
||||
assert_eq!(body.aec_state, "validating");
|
||||
assert!(!body.fan_out_permitted);
|
||||
assert_eq!(body.gate_reason, Some("aec-validating"));
|
||||
assert!(eligible.is_empty());
|
||||
assert_eq!(
|
||||
excluded,
|
||||
vec![("music", "aec-validating"), ("game", "aec-validating")]
|
||||
);
|
||||
}
|
||||
|
||||
/// A shut gate must not erase the engine's own reason codes. If it did, every
|
||||
/// §5.1 row run under a shut gate would report one uniform code and the matrix
|
||||
/// would stop constraining the taint engine at all — the record would say
|
||||
/// "nothing may fan out" while hiding *which* nodes were tainted and how.
|
||||
#[test]
|
||||
fn a_shut_gate_preserves_the_engines_own_reasons() {
|
||||
let mut graph = Graph::new();
|
||||
graph.app_node("music", MediaRole::StreamOutput, 100);
|
||||
graph.peerspeak_node("peerspeak-playback", 200);
|
||||
let projection = ready(graph.build());
|
||||
|
||||
let body = observe(&mut auditor_aec(AEC_MODULE), &projection, 0)
|
||||
.record
|
||||
.body;
|
||||
let (_, excluded) = partition(&body);
|
||||
|
||||
assert!(!body.fan_out_permitted);
|
||||
assert_eq!(
|
||||
excluded,
|
||||
vec![
|
||||
("music", "aec-validating"),
|
||||
// Tagged, so it keeps the reason that actually applies to it.
|
||||
("peerspeak-playback", "peerspeak-owned"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// The deadline is armed on the first ready graph, so a slow enumeration reads
|
||||
/// as "unknown", not "absent" (the phase-4 user design call). Past it with the
|
||||
/// identity never seen, the gate latches shut.
|
||||
#[test]
|
||||
fn the_gate_fails_closed_after_the_deadline() {
|
||||
let mut graph = Graph::new();
|
||||
graph.app_node("music", MediaRole::StreamOutput, 100);
|
||||
let snapshot = graph.build();
|
||||
let mut auditor = auditor_aec(AEC_MODULE);
|
||||
|
||||
// Still enumerating well past the timeout: not a failure, because absence
|
||||
// has not been established.
|
||||
let waiting = not_ready(snapshot.clone(), Readiness::Waiting);
|
||||
let body = observe(&mut auditor, &waiting, TIMEOUT * 3).record.body;
|
||||
assert_eq!(body.aec_state, "validating");
|
||||
|
||||
// Ready arms the deadline; the clock has to advance past it from here.
|
||||
let projection = ready(snapshot);
|
||||
let body = observe(&mut auditor, &projection, TIMEOUT * 3).record.body;
|
||||
assert_eq!(body.aec_state, "validating");
|
||||
|
||||
let body = observe(&mut auditor, &projection, TIMEOUT * 6 + 1)
|
||||
.record
|
||||
.body;
|
||||
assert_eq!(body.aec_state, "failed");
|
||||
assert_eq!(body.gate_reason, Some("aec-failed"));
|
||||
assert_eq!(partition(&body).1, vec![("music", "aec-failed")]);
|
||||
}
|
||||
|
||||
// ── §5.1 row 12: the AEC lifecycle ────────────────────────────────────────
|
||||
|
||||
/// Build the four nodes `module-echo-cancel` creates, all bearing one index:
|
||||
/// two `Stream/*` legs plus the virtual sink/source pair (v3.4 §5.2). The
|
||||
/// playback leg is the hazard — a `Stream/Output/Audio` wired to the speakers.
|
||||
fn aec_nodes(graph: &mut Graph, index: u64) -> Vec<NodeRef> {
|
||||
vec![
|
||||
graph.module_node("echo-cancel-playback", MediaRole::StreamOutput, index),
|
||||
graph.module_node("echo-cancel-capture", MediaRole::StreamInput, index),
|
||||
graph.module_node("echo-cancel-sink", MediaRole::Sink, index),
|
||||
graph.module_node("echo-cancel-source", MediaRole::Source, index),
|
||||
]
|
||||
}
|
||||
|
||||
/// §5.1 row 12: AEC loaded → validated, its playback leg excluded by identity
|
||||
/// while everything else stays eligible → unloaded → `Revoked`, gate shut.
|
||||
///
|
||||
/// The eligible half is the load-bearing assertion in the first phase: an
|
||||
/// implementation that excluded the whole graph the moment an AEC appeared would
|
||||
/// satisfy "the four nodes are excluded" and still be wrong.
|
||||
#[test]
|
||||
fn row_12_aec_loaded_then_unloaded_validates_then_revokes() {
|
||||
let mut graph = Graph::new();
|
||||
graph.app_node("music", MediaRole::StreamOutput, 100);
|
||||
let aec = aec_nodes(&mut graph, AEC_MODULE);
|
||||
let mut auditor = auditor_aec(AEC_MODULE);
|
||||
|
||||
let loaded = ready(graph.build());
|
||||
let body = observe(&mut auditor, &loaded, 0).record.body;
|
||||
let (eligible, excluded) = partition(&body);
|
||||
|
||||
assert_eq!(body.aec_state, "validated");
|
||||
assert!(body.fan_out_permitted);
|
||||
assert_eq!(body.aec_module_id, Some(AEC_MODULE));
|
||||
assert_eq!(eligible, vec!["music"]);
|
||||
assert_eq!(excluded, vec![("echo-cancel-playback", "aec-identity")]);
|
||||
|
||||
// Every node bearing the index goes away: a real unload.
|
||||
let unloaded = ready(graph.build_without(&aec));
|
||||
let body = observe(&mut auditor, &unloaded, 1).record.body;
|
||||
let (eligible, excluded) = partition(&body);
|
||||
|
||||
assert_eq!(body.aec_state, "revoked");
|
||||
assert!(!body.fan_out_permitted);
|
||||
assert_eq!(body.gate_reason, Some("aec-revoked"));
|
||||
assert_eq!(body.aec_module_id, None);
|
||||
assert!(eligible.is_empty());
|
||||
assert_eq!(excluded, vec![("music", "aec-revoked")]);
|
||||
}
|
||||
|
||||
/// One leg corking is not a revocation (v3.4 §5.3). Getting this wrong turns an
|
||||
/// ordinary cork into a share-wide audio stop, so the audit must report the
|
||||
/// identity as still live.
|
||||
#[test]
|
||||
fn row_12_partial_leg_loss_does_not_revoke() {
|
||||
let mut graph = Graph::new();
|
||||
graph.app_node("music", MediaRole::StreamOutput, 100);
|
||||
let aec = aec_nodes(&mut graph, AEC_MODULE);
|
||||
let mut auditor = auditor_aec(AEC_MODULE);
|
||||
|
||||
let body = observe(&mut auditor, &ready(graph.build()), 0).record.body;
|
||||
assert_eq!(body.aec_state, "validated");
|
||||
|
||||
// The capture leg alone disappears; three nodes still bear the index.
|
||||
let partial = ready(graph.build_without(&aec[1..2]));
|
||||
let body = observe(&mut auditor, &partial, 1).record.body;
|
||||
|
||||
assert_eq!(body.aec_state, "validated");
|
||||
assert!(body.fan_out_permitted);
|
||||
assert_eq!(partition(&body).0, vec!["music"]);
|
||||
}
|
||||
|
||||
/// Revocation is sticky terminal: module indices are reused verbatim across an
|
||||
/// unload/reload (v3.4 §5.2 correction 3), so a reappearing index must not
|
||||
/// resurrect the epoch and alias onto an unrelated module. A genuine reload gets
|
||||
/// a fresh validator, never this one.
|
||||
#[test]
|
||||
fn row_12_a_reused_index_does_not_resurrect_a_revoked_epoch() {
|
||||
let mut graph = Graph::new();
|
||||
graph.app_node("music", MediaRole::StreamOutput, 100);
|
||||
let aec = aec_nodes(&mut graph, AEC_MODULE);
|
||||
let mut auditor = auditor_aec(AEC_MODULE);
|
||||
|
||||
observe(&mut auditor, &ready(graph.build()), 0);
|
||||
let unloaded = graph.build_without(&aec);
|
||||
let body = observe(&mut auditor, &ready(unloaded), 1).record.body;
|
||||
assert_eq!(body.aec_state, "revoked");
|
||||
|
||||
// A second module comes back with the same index — different objects,
|
||||
// identical number.
|
||||
let mut reloaded = Graph::new();
|
||||
reloaded.app_node("music", MediaRole::StreamOutput, 100);
|
||||
aec_nodes(&mut reloaded, AEC_MODULE);
|
||||
let body = observe(&mut auditor, &ready(reloaded.build()), 2)
|
||||
.record
|
||||
.body;
|
||||
|
||||
assert_eq!(body.aec_state, "revoked");
|
||||
assert!(!body.fan_out_permitted);
|
||||
assert_eq!(body.gate_reason, Some("aec-revoked"));
|
||||
}
|
||||
|
||||
// ── §5.1 rows in fixture form (plumbing, not phase-2 verdicts) ────────────
|
||||
|
||||
/// §5.1 row 1: a `module-null-sink` + `module-loopback` forwarder. The output
|
||||
/// leg is excluded across the **owner bridge** — naming the mechanism, not a
|
||||
/// link walk — while an identically-shaped forwarder with no tainted input stays
|
||||
/// eligible. The second half is what an exclude-everything build fails.
|
||||
#[test]
|
||||
fn row_1_owner_bridge_forwarder_with_an_untainted_control() {
|
||||
let mut graph = Graph::new();
|
||||
// Tainted root: peerspeak's own call playback, feeding a sink the forwarder
|
||||
// reads back out.
|
||||
let call = graph.peerspeak_node("peerspeak-call", 200);
|
||||
let sink = graph.module_node("tainted-null-sink", MediaRole::Sink, 30);
|
||||
graph.link(call, sink);
|
||||
let capture = graph.module_node("tainted-loopback-capture", MediaRole::StreamInput, 30);
|
||||
let playback = graph.module_node("tainted-loopback-playback", MediaRole::StreamOutput, 30);
|
||||
graph.link(sink, capture);
|
||||
let _ = playback;
|
||||
|
||||
// Control: the same shape, fed by nothing tainted.
|
||||
let clean_sink = graph.module_node("clean-null-sink", MediaRole::Sink, 31);
|
||||
let clean_capture = graph.module_node("clean-loopback-capture", MediaRole::StreamInput, 31);
|
||||
let clean_playback = graph.module_node("clean-loopback-playback", MediaRole::StreamOutput, 31);
|
||||
graph.link(clean_sink, clean_capture);
|
||||
let _ = clean_playback;
|
||||
|
||||
let body = observe(&mut auditor_off(), &ready(graph.build()), 0)
|
||||
.record
|
||||
.body;
|
||||
let (eligible, excluded) = partition(&body);
|
||||
|
||||
assert_eq!(eligible, vec!["clean-loopback-playback"]);
|
||||
assert_eq!(
|
||||
excluded,
|
||||
vec![
|
||||
("peerspeak-call", "peerspeak-owned"),
|
||||
("tainted-loopback-playback", "tainted-owner-bridge"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// §5.1 row 3: two Pulse modules, one tainted input. **The other module's output
|
||||
/// must be eligible** — this is the row that makes a wrong pipewire-pulse-PID
|
||||
/// fusion observable, because fusing all Pulse-created nodes into one owner
|
||||
/// would drag the innocent module's output leg down with the tainted one.
|
||||
#[test]
|
||||
fn row_3_one_tainted_module_does_not_taint_the_other() {
|
||||
let mut graph = Graph::new();
|
||||
let call = graph.peerspeak_node("peerspeak-call", 200);
|
||||
let sink = graph.module_node("null-sink-a", MediaRole::Sink, 40);
|
||||
graph.link(call, sink);
|
||||
let capture_a = graph.module_node("module-a-capture", MediaRole::StreamInput, 40);
|
||||
let playback_a = graph.module_node("module-a-playback", MediaRole::StreamOutput, 40);
|
||||
graph.link(sink, capture_a);
|
||||
let _ = playback_a;
|
||||
|
||||
// A second, entirely independent module reading an untainted source.
|
||||
let mic = graph.device_node("microphone", MediaRole::Source);
|
||||
let capture_b = graph.module_node("module-b-capture", MediaRole::StreamInput, 41);
|
||||
let playback_b = graph.module_node("module-b-playback", MediaRole::StreamOutput, 41);
|
||||
graph.link(mic, capture_b);
|
||||
let _ = playback_b;
|
||||
|
||||
let body = observe(&mut auditor_off(), &ready(graph.build()), 0)
|
||||
.record
|
||||
.body;
|
||||
let (eligible, excluded) = partition(&body);
|
||||
|
||||
assert_eq!(eligible, vec!["module-b-playback"]);
|
||||
assert_eq!(
|
||||
excluded,
|
||||
vec![
|
||||
("peerspeak-call", "peerspeak-owned"),
|
||||
("module-a-playback", "tainted-owner-bridge"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// §5.1 row 7 (cycle prevention, v3.4 §6.2): a forwarder reading *another*
|
||||
/// pixelpass host's capture sink must be excluded by its **named output
|
||||
/// serial**, or two hosts sharing to each other build an audio cycle.
|
||||
#[test]
|
||||
fn row_7_a_forwarder_reading_another_hosts_capture_sink_is_excluded() {
|
||||
let mut graph = Graph::new();
|
||||
// The other host's own client, in *this* graph — a node pointing at a client
|
||||
// that does not exist would exercise the unresolved-owner path instead of the
|
||||
// capture-sink-name path this row is about.
|
||||
let other_client = graph.client(Some(fixture::PULSE_PID));
|
||||
let other_sink = graph.node(
|
||||
"pixelpass_capture_deadbeef",
|
||||
MediaRole::Sink,
|
||||
fixture::app(other_client, 300),
|
||||
);
|
||||
let capture = graph.module_node("cycle-loopback-capture", MediaRole::StreamInput, 50);
|
||||
let playback = graph.module_node("cycle-loopback-playback", MediaRole::StreamOutput, 50);
|
||||
graph.link(other_sink, capture);
|
||||
let _ = playback;
|
||||
graph.app_node("music", MediaRole::StreamOutput, 100);
|
||||
|
||||
let body = observe(&mut auditor_off(), &ready(graph.build()), 0)
|
||||
.record
|
||||
.body;
|
||||
let (eligible, excluded) = partition(&body);
|
||||
|
||||
assert_eq!(eligible, vec!["music"]);
|
||||
assert_eq!(
|
||||
excluded,
|
||||
vec![("cycle-loopback-playback", "tainted-owner-bridge")]
|
||||
);
|
||||
// The sink itself is tainted, by the mechanism that names it.
|
||||
let sink_taint = body
|
||||
.taint
|
||||
.iter()
|
||||
.find(|row| row.name.as_deref() == Some("pixelpass_capture_deadbeef"))
|
||||
.expect("the other host's capture sink must be tainted");
|
||||
assert_eq!(sink_taint.reason, "pixelpass-owned");
|
||||
}
|
||||
|
||||
/// Sticky taint (§5.1 row 10) is reported as sticky, not silently folded into
|
||||
/// an ordinary exclusion. The flag is how the audit distinguishes "this is
|
||||
/// tainted right now" from "this was tainted and its owner has not fully torn
|
||||
/// down" — two different things to be surprised by.
|
||||
#[test]
|
||||
fn sticky_exclusions_are_flagged_as_sticky() {
|
||||
let mut graph = Graph::new();
|
||||
let call = graph.peerspeak_node("peerspeak-call", 200);
|
||||
let sink = graph.module_node("null-sink", MediaRole::Sink, 60);
|
||||
graph.link(call, sink);
|
||||
let capture = graph.module_node("loopback-capture", MediaRole::StreamInput, 60);
|
||||
graph.module_node("loopback-playback", MediaRole::StreamOutput, 60);
|
||||
graph.link(sink, capture);
|
||||
|
||||
let mut auditor = auditor_off();
|
||||
let body = observe(&mut auditor, &ready(graph.build()), 0).record.body;
|
||||
let playback = body
|
||||
.row_named("loopback-playback")
|
||||
.expect("the output leg must be a candidate");
|
||||
assert!(!playback.eligible);
|
||||
assert!(!playback.sticky, "first sight is not sticky");
|
||||
|
||||
// The tainted input leg goes away; the output leg lives on.
|
||||
let body = observe(&mut auditor, &ready(graph.build_without(&[capture])), 1)
|
||||
.record
|
||||
.body;
|
||||
let playback = body
|
||||
.row_named("loopback-playback")
|
||||
.expect("the output leg must still be a candidate");
|
||||
assert!(!playback.eligible);
|
||||
assert!(playback.sticky, "the taint is carried over, and says so");
|
||||
}
|
||||
|
||||
// ── record accounting ─────────────────────────────────────────────────────
|
||||
|
||||
/// Ticks exist to drive the AEC deadline, not to describe the graph. Emitting an
|
||||
/// identical record four times a second would bury the graph events the audit
|
||||
/// exists to show — but a tick that *does* change something must still be
|
||||
/// emitted, or a `Validating → Failed` transition (which only a tick can cause)
|
||||
/// would never appear in the log at all.
|
||||
#[test]
|
||||
fn an_unchanged_tick_is_suppressed_but_a_changed_one_is_not() {
|
||||
let mut graph = Graph::new();
|
||||
graph.app_node("music", MediaRole::StreamOutput, 100);
|
||||
let projection = ready(graph.build());
|
||||
let mut auditor = auditor_aec(AEC_MODULE);
|
||||
|
||||
assert!(auditor.observe(&projection, EventKind::Graph, 0).emit);
|
||||
assert!(
|
||||
!auditor.observe(&projection, EventKind::Tick, 100).emit,
|
||||
"an identical tick record is noise"
|
||||
);
|
||||
assert!(
|
||||
!auditor.observe(&projection, EventKind::Tick, 200).emit,
|
||||
"still noise"
|
||||
);
|
||||
|
||||
// The deadline expires on a tick: the state changes, so this one is emitted.
|
||||
let outcome = auditor.observe(&projection, EventKind::Tick, TIMEOUT + 1);
|
||||
assert!(outcome.emit);
|
||||
assert_eq!(outcome.record.body.aec_state, "failed");
|
||||
}
|
||||
|
||||
/// A graph event always emits, even when the decision content is identical — a
|
||||
/// suppressed graph event would erase the evidence that the graph changed at all,
|
||||
/// and "PipeWire told us something and nothing moved" is itself a finding.
|
||||
#[test]
|
||||
fn an_unchanged_graph_event_still_emits() {
|
||||
let mut graph = Graph::new();
|
||||
graph.app_node("music", MediaRole::StreamOutput, 100);
|
||||
let projection = ready(graph.build());
|
||||
let mut auditor = auditor_off();
|
||||
|
||||
assert!(observe(&mut auditor, &projection, 0).emit);
|
||||
assert!(observe(&mut auditor, &projection, 1).emit);
|
||||
}
|
||||
|
||||
/// `seq` counts every recompute, emitted or not, so a gap in the emitted
|
||||
/// sequence is visibly a suppression rather than a lost line. Without this, a
|
||||
/// reader cannot tell a quiet audit from a broken one.
|
||||
#[test]
|
||||
fn seq_counts_suppressed_recomputes_too() {
|
||||
let mut graph = Graph::new();
|
||||
graph.app_node("music", MediaRole::StreamOutput, 100);
|
||||
let projection = ready(graph.build());
|
||||
let mut auditor = auditor_off();
|
||||
|
||||
assert_eq!(observe(&mut auditor, &projection, 0).record.seq, 1);
|
||||
let suppressed = auditor.observe(&projection, EventKind::Tick, 1);
|
||||
assert!(!suppressed.emit);
|
||||
assert_eq!(suppressed.record.seq, 2);
|
||||
assert_eq!(observe(&mut auditor, &projection, 2).record.seq, 3);
|
||||
}
|
||||
|
||||
/// Suppression compares against the last record actually *written*, not the last
|
||||
/// one computed. Comparing against the last computed record would let a change
|
||||
/// that appears and reverts between two ticks vanish from the log entirely,
|
||||
/// leaving a reader with a record that no longer matches the state.
|
||||
#[test]
|
||||
fn suppression_compares_against_the_last_emitted_record() {
|
||||
let mut graph = Graph::new();
|
||||
graph.app_node("music", MediaRole::StreamOutput, 100);
|
||||
let with_music = ready(graph.build());
|
||||
let empty = ready(Graph::new().build());
|
||||
let mut auditor = auditor_off();
|
||||
|
||||
assert!(observe(&mut auditor, &with_music, 0).emit);
|
||||
// A tick sees a different graph and emits.
|
||||
assert!(auditor.observe(&empty, EventKind::Tick, 1).emit);
|
||||
// The next tick sees the original graph again — different from what was last
|
||||
// written, so it must be emitted.
|
||||
assert!(auditor.observe(&with_music, EventKind::Tick, 2).emit);
|
||||
// And now it matches the last written record.
|
||||
assert!(!auditor.observe(&with_music, EventKind::Tick, 3).emit);
|
||||
}
|
||||
|
||||
/// The trigger and clock are reported verbatim, which is what lets the O5 event
|
||||
/// rate be recomputed from the record stream alone rather than trusted from the
|
||||
/// summary.
|
||||
#[test]
|
||||
fn the_record_reports_its_trigger_and_clock() {
|
||||
let projection = ready(Graph::new().build());
|
||||
let mut auditor = auditor_off();
|
||||
|
||||
let outcome = auditor.observe(&projection, EventKind::Graph, 42);
|
||||
assert_eq!(outcome.record.trigger, "graph");
|
||||
assert_eq!(outcome.record.at_ms, 42);
|
||||
|
||||
let outcome = auditor.observe(&projection, EventKind::Tick, 43);
|
||||
assert_eq!(outcome.record.trigger, "tick");
|
||||
assert_eq!(outcome.record.at_ms, 43);
|
||||
}
|
||||
|
||||
/// The taint view spans every media role, not just candidates. A candidate's
|
||||
/// exclusion is usually explained by taint on a node that is not itself a
|
||||
/// candidate — the sink in the middle of a forwarder — and without that the
|
||||
/// record shows the verdict but not the evidence.
|
||||
#[test]
|
||||
fn the_taint_view_covers_non_candidate_roles() {
|
||||
let mut graph = Graph::new();
|
||||
let call = graph.peerspeak_node("peerspeak-call", 200);
|
||||
let sink = graph.module_node("null-sink", MediaRole::Sink, 70);
|
||||
graph.link(call, sink);
|
||||
|
||||
let body = observe(&mut auditor_off(), &ready(graph.build()), 0)
|
||||
.record
|
||||
.body;
|
||||
let tainted: Vec<(&str, &str)> = body
|
||||
.taint
|
||||
.iter()
|
||||
.map(|row| (row.name.as_deref().unwrap_or("?"), row.reason))
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
tainted.contains(&("null-sink", "tainted-upstream")),
|
||||
"the sink is not a candidate but its taint is what explains the row: {tainted:?}"
|
||||
);
|
||||
assert!(tainted.contains(&("peerspeak-call", "peerspeak-owned")));
|
||||
}
|
||||
|
||||
/// A record must serialise to a single line. Newlines inside a JSON Lines
|
||||
/// record would split one record into two unparseable ones — and node names come
|
||||
/// from PipeWire properties, which are attacker-adjacent free text.
|
||||
#[test]
|
||||
fn a_record_serialises_to_exactly_one_line() {
|
||||
let mut graph = Graph::new();
|
||||
graph.app_node("evil\nname\r\nwith breaks", MediaRole::StreamOutput, 100);
|
||||
let projection = ready(graph.build());
|
||||
|
||||
let outcome = observe(&mut auditor_off(), &projection, 0);
|
||||
let json = serde_json::to_string(&outcome.record).expect("a record must serialise");
|
||||
assert_eq!(json.lines().count(), 1, "record split across lines: {json}");
|
||||
assert!(
|
||||
json.contains(r"evil\nname"),
|
||||
"the name must survive escaped"
|
||||
);
|
||||
}
|
||||
|
||||
// ── O5 metrics ────────────────────────────────────────────────────────────
|
||||
|
||||
fn sample(kind: EventKind, at_us: u64, gap_us: u64, recompute_us: u64, emit_us: u64) -> Sample {
|
||||
Sample {
|
||||
at_us,
|
||||
gap_us,
|
||||
recompute_us,
|
||||
emit_us,
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bucket bounds are exclusive upper bounds, so a value exactly on a bound lands
|
||||
/// in the next bucket up. Asserted because an off-by-one here silently shifts
|
||||
/// the whole distribution the O5 conclusion rests on.
|
||||
#[test]
|
||||
fn histogram_bounds_are_exclusive_upper_bounds() {
|
||||
let mut metrics = Metrics::default();
|
||||
for us in [0, 49, 50, 99_999, 100_000, 1_000_000] {
|
||||
metrics.record(sample(EventKind::Graph, 0, 1_000, us, 0));
|
||||
}
|
||||
let summary = metrics.summary();
|
||||
|
||||
assert_eq!(
|
||||
summary.recompute_distribution,
|
||||
vec![
|
||||
("<50us", 2), // 0 and 49
|
||||
("<100us", 1), // 50
|
||||
("<100ms", 1), // 99_999
|
||||
(">=100ms", 2), // 100_000 and 1_000_000
|
||||
]
|
||||
);
|
||||
assert_eq!(summary.recompute_max_us, 1_000_000);
|
||||
}
|
||||
|
||||
/// The maximum is exact, not bucketed. O5 asks for the maximum specifically, and
|
||||
/// ">= 100 ms" is not an answer to "how bad does it get?".
|
||||
#[test]
|
||||
fn the_maximum_is_exact_not_bucketed() {
|
||||
let mut metrics = Metrics::default();
|
||||
metrics.record(sample(EventKind::Graph, 0, 1_000, 137, 0));
|
||||
metrics.record(sample(EventKind::Graph, 0, 1_000, 4_211, 0));
|
||||
metrics.record(sample(EventKind::Graph, 0, 1_000, 90, 0));
|
||||
|
||||
let summary = metrics.summary();
|
||||
assert_eq!(summary.recompute_max_us, 4_211);
|
||||
assert_eq!(summary.recompute_mean_us, Some((137 + 4_211 + 90) / 3));
|
||||
}
|
||||
|
||||
/// Nearest-rank quantiles over the buckets.
|
||||
#[test]
|
||||
fn quantiles_use_nearest_rank_over_the_buckets() {
|
||||
let mut metrics = Metrics::default();
|
||||
// 99 fast samples and one very slow one: the tail must show up at p99 and
|
||||
// nowhere earlier, which is the whole reason for reporting p99 at all.
|
||||
for _ in 0..99 {
|
||||
metrics.record(sample(EventKind::Graph, 0, 1_000, 10, 0));
|
||||
}
|
||||
metrics.record(sample(EventKind::Graph, 0, 1_000, 200_000, 0));
|
||||
|
||||
let summary = metrics.summary();
|
||||
assert_eq!(summary.recompute_p50, Some("<50us"));
|
||||
assert_eq!(summary.recompute_p90, Some("<50us"));
|
||||
assert_eq!(summary.recompute_p99, Some("<50us"));
|
||||
assert_eq!(summary.recompute_max_us, 200_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_histogram_reports_no_quantiles_and_no_rate() {
|
||||
let summary = Metrics::default().summary();
|
||||
assert_eq!(summary.recompute_p50, None);
|
||||
assert_eq!(summary.recompute_mean_us, None);
|
||||
assert_eq!(summary.graph_events_per_sec, None);
|
||||
assert_eq!(summary.busy_fraction, None);
|
||||
assert_eq!(summary.recompute_max_us, 0);
|
||||
assert!(summary.recompute_distribution.is_empty());
|
||||
}
|
||||
|
||||
/// Ticks are counted separately from graph events. Folding them in would inflate
|
||||
/// the measured event rate by a constant 4 Hz and hide the real graph churn —
|
||||
/// which is the number O5 is actually about.
|
||||
#[test]
|
||||
fn ticks_do_not_count_toward_the_graph_event_rate() {
|
||||
let mut metrics = Metrics::default();
|
||||
// Two graph events one second apart, with ticks in between.
|
||||
metrics.record(sample(EventKind::Graph, 0, 0, 100, 0));
|
||||
for i in 1..4 {
|
||||
metrics.record(sample(EventKind::Tick, i * 250_000, 250_000, 100, 0));
|
||||
}
|
||||
metrics.record(sample(EventKind::Graph, 1_000_000, 250_000, 100, 0));
|
||||
|
||||
let summary = metrics.summary();
|
||||
assert_eq!(summary.graph_events, 2);
|
||||
assert_eq!(summary.tick_events, 3);
|
||||
// Span runs to the last sample's completion: 1_000_000 + 100 µs.
|
||||
assert_eq!(summary.span_us, 1_000_100);
|
||||
assert_eq!(summary.graph_events_per_sec, Some(2.0));
|
||||
}
|
||||
|
||||
/// The queueing proxy: an event beginning within the threshold of the previous
|
||||
/// sample's completion was almost certainly already waiting. The first sample is
|
||||
/// never counted — it has no predecessor to have queued behind, and counting it
|
||||
/// would put a phantom backlog in every run.
|
||||
#[test]
|
||||
fn the_queueing_proxy_counts_back_to_back_events_only() {
|
||||
let mut metrics = Metrics::default();
|
||||
metrics.record(sample(EventKind::Graph, 0, 0, 100, 0));
|
||||
metrics.record(sample(EventKind::Graph, 100, QUEUE_THRESHOLD_US, 100, 0));
|
||||
metrics.record(sample(
|
||||
EventKind::Graph,
|
||||
200,
|
||||
QUEUE_THRESHOLD_US + 1,
|
||||
100,
|
||||
0,
|
||||
));
|
||||
metrics.record(sample(EventKind::Graph, 300, 0, 100, 0));
|
||||
|
||||
let summary = metrics.summary();
|
||||
assert_eq!(
|
||||
summary.queued_events, 2,
|
||||
"exactly the two within the threshold, never the first sample"
|
||||
);
|
||||
assert_eq!(summary.queue_threshold_us, QUEUE_THRESHOLD_US);
|
||||
}
|
||||
|
||||
/// Emission cost is tracked separately from recompute cost, and a suppressed
|
||||
/// record contributes neither an emitted-record count nor an emit sample —
|
||||
/// otherwise the logging distribution would be diluted by every tick that wrote
|
||||
/// nothing.
|
||||
#[test]
|
||||
fn emission_cost_is_tracked_separately_from_recompute() {
|
||||
let mut metrics = Metrics::default();
|
||||
metrics.record(sample(EventKind::Graph, 0, 0, 300, 80));
|
||||
metrics.record(sample(EventKind::Tick, 1_000, 900, 200, 0));
|
||||
metrics.record(sample(EventKind::Graph, 2_000, 900, 400, 120));
|
||||
|
||||
let summary = metrics.summary();
|
||||
assert_eq!(summary.emitted_records, 2);
|
||||
assert_eq!(summary.emit_max_us, 120);
|
||||
assert_eq!(summary.emit_mean_us, Some(100));
|
||||
assert_eq!(
|
||||
summary.emit_distribution,
|
||||
vec![("<100us", 1), ("<250us", 1)]
|
||||
);
|
||||
// Busy time is recompute *and* logging: 300+80+200+400+120.
|
||||
assert_eq!(summary.busy_us, 1_100);
|
||||
}
|
||||
|
||||
/// The busy fraction needs no inference, unlike the queueing proxy, so it is the
|
||||
/// number the O5 verdict should lean on.
|
||||
#[test]
|
||||
fn the_busy_fraction_is_the_share_of_wall_time_spent_working() {
|
||||
let mut metrics = Metrics::default();
|
||||
metrics.record(sample(EventKind::Graph, 0, 0, 100, 0));
|
||||
// Ends at 1_000_000 + 900 → a span of 1_000_900 µs with 1_000 µs of work.
|
||||
metrics.record(sample(EventKind::Graph, 1_000_000, 999_900, 900, 0));
|
||||
|
||||
let summary = metrics.summary();
|
||||
assert_eq!(summary.busy_us, 1_000);
|
||||
assert_eq!(summary.span_us, 1_000_900);
|
||||
assert_eq!(summary.busy_fraction, Some(0.001));
|
||||
}
|
||||
|
||||
/// Bucket labels and bounds must stay parallel, or the distribution mislabels
|
||||
/// itself — a silent failure that would misreport every O5 result.
|
||||
#[test]
|
||||
fn bucket_labels_cover_every_bound_plus_overflow() {
|
||||
assert_eq!(
|
||||
BUCKET_LABELS.len(),
|
||||
super::metrics::BUCKET_BOUNDS_US.len() + 1
|
||||
);
|
||||
}
|
||||
+16
-31
@@ -1,40 +1,25 @@
|
||||
//! Capture dispatcher. Selects the per-display-server pipeline and returns its
|
||||
//! [`CaptureHandle`]. Each backend owns its own children and tears them down
|
||||
//! via its own Drop / shutdown.
|
||||
//! Capture dispatcher. Picks the per-display-server source backend and returns
|
||||
//! the shared [`pipeline::CaptureHandle`]. The handle itself, its teardown, and
|
||||
//! the whole encode/serve tail are display-agnostic and live in
|
||||
//! [`super::pipeline`]; the backends differ only in how they obtain a video
|
||||
//! source element.
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::cli::HostOpts;
|
||||
use crate::common::display::DisplayServer;
|
||||
use crate::host::wayland;
|
||||
use crate::host::pipeline::CaptureHandle;
|
||||
use crate::host::quality::EffectiveQuality;
|
||||
use crate::host::{wayland, x11};
|
||||
|
||||
pub enum CaptureHandle {
|
||||
Wayland(wayland::CaptureHandle),
|
||||
}
|
||||
|
||||
impl CaptureHandle {
|
||||
pub fn local_port(&self) -> u16 {
|
||||
match self {
|
||||
CaptureHandle::Wayland(h) => h.local_port(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn shutdown(self) {
|
||||
match self {
|
||||
CaptureHandle::Wayland(h) => h.shutdown().await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn spawn(display: DisplayServer, opts: &HostOpts) -> Result<CaptureHandle> {
|
||||
pub async fn spawn(
|
||||
display: DisplayServer,
|
||||
opts: &HostOpts,
|
||||
quality: &EffectiveQuality,
|
||||
) -> Result<CaptureHandle> {
|
||||
match display {
|
||||
DisplayServer::Wayland => {
|
||||
let h = wayland::start(opts).await?;
|
||||
Ok(CaptureHandle::Wayland(h))
|
||||
}
|
||||
DisplayServer::X11 => {
|
||||
bail!("X11 capture pipeline not yet implemented (Phase 2 follow-up)");
|
||||
}
|
||||
DisplayServer::Wayland => wayland::start(opts, quality).await,
|
||||
DisplayServer::X11 => x11::start(opts, quality).await,
|
||||
DisplayServer::Unknown => unreachable!("caller guarantees display != Unknown"),
|
||||
}
|
||||
}
|
||||
|
||||
+483
-60
@@ -1,18 +1,60 @@
|
||||
pub mod aec;
|
||||
pub mod audio;
|
||||
pub mod audit;
|
||||
mod capture;
|
||||
mod observer;
|
||||
mod pipeline;
|
||||
mod quality;
|
||||
mod serve;
|
||||
pub mod taint;
|
||||
mod wayland;
|
||||
mod x11;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use iroh::Endpoint;
|
||||
use iroh::endpoint::{Connection, presets};
|
||||
use iroh::endpoint::Connection;
|
||||
use iroh::{Endpoint, EndpointAddr};
|
||||
use iroh_tickets::endpoint::EndpointTicket;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::cli::HostOpts;
|
||||
use crate::common::{alpn::ALPN, deps, display::DisplayServer, signal};
|
||||
use crate::common::{
|
||||
bandwidth, config, config::BandwidthStatus, deps, display::DisplayServer, endpoint, output,
|
||||
signal, tunnel,
|
||||
};
|
||||
|
||||
use self::pipeline::CaptureHandle;
|
||||
use self::quality::EffectiveQuality;
|
||||
|
||||
/// Messages from per-viewer tasks (and the GUI command channel) to the
|
||||
/// capture supervisor.
|
||||
// The shared `Viewer` suffix is the point — these are all viewer lifecycle
|
||||
// messages — so keep the descriptive names.
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
enum SupervisorMsg {
|
||||
/// A new viewer wants in. Supervisor replies with the local capture HTTP
|
||||
/// port to connect to, or an error string if the host is full or capture
|
||||
/// spawn failed. `cancel` is the viewer's own token — the supervisor keeps
|
||||
/// it so a later `KickViewer` can tear this viewer's stream down.
|
||||
AddViewer {
|
||||
id: String,
|
||||
cancel: CancellationToken,
|
||||
reply: oneshot::Sender<Result<u16, String>>,
|
||||
},
|
||||
/// A viewer's session ended. Supervisor decrements the count and tears
|
||||
/// down capture if it just hit zero.
|
||||
RemoveViewer { id: String },
|
||||
/// Host asked (via the GUI command channel) to disconnect a viewer by
|
||||
/// endpoint id. Cancels that viewer's token; the normal teardown path then
|
||||
/// emits the `ViewerLeft`.
|
||||
KickViewer { id: String },
|
||||
}
|
||||
|
||||
pub async fn run(opts: HostOpts) -> Result<()> {
|
||||
let display = DisplayServer::resolve(opts.display_server);
|
||||
deps::check_host_binaries(display)?;
|
||||
deps::check_host_binaries(display, &opts)?;
|
||||
|
||||
if display == DisplayServer::Unknown {
|
||||
bail!(
|
||||
@@ -21,108 +63,428 @@ pub async fn run(opts: HostOpts) -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve quality first: Auto sizes its bandwidth budget against the viewer
|
||||
// cap the host will honor. To avoid a circular dependency (the auto-derived
|
||||
// cap itself depends on bitrate), Auto sizes against the user's explicit
|
||||
// --max-viewers when given, else a single viewer. The resulting effective
|
||||
// bitrate then feeds the cap resolution below.
|
||||
let sizing_viewers = opts.max_viewers.filter(|&n| n > 0).unwrap_or(1);
|
||||
let quality = quality::resolve(&opts, sizing_viewers);
|
||||
|
||||
let resolution = resolve_max_viewers(&opts, quality.bitrate);
|
||||
if resolution.value == 0 {
|
||||
bail!("--max-viewers must be at least 1");
|
||||
}
|
||||
|
||||
let cancel = signal::install_ctrl_c();
|
||||
|
||||
let endpoint = Endpoint::builder(presets::N0)
|
||||
.alpns(vec![ALPN.to_vec()])
|
||||
.bind()
|
||||
.await?;
|
||||
// Phase 5 dry-run audit, off unless `PIXELPASS_AUDIO_AUDIT=1`. Read-only:
|
||||
// it observes the graph and logs what phases 2–4 conclude, creating no
|
||||
// links. Bound to a name so the handle lives as long as the run — dropping
|
||||
// it stops the observer thread and flushes the final O5 summary.
|
||||
let _audio_audit = audit::run::spawn_if_enabled()?;
|
||||
|
||||
let endpoint = endpoint::bind(opts.relay.as_deref()).await?;
|
||||
|
||||
// Relay-only ticket: wait for the home relay to connect, then keep only
|
||||
// the endpoint id + relay URL and drop the direct IP candidates. The relay
|
||||
// coordinates hole-punching to a direct path right after connect, so this
|
||||
// doesn't change whether peers can reach each other — it just keeps the
|
||||
// ticket short (~140 vs ~320 chars) and stops it from leaking LAN /
|
||||
// Docker-bridge addresses to whoever receives the ticket. Awaiting online()
|
||||
// first guarantees the relay URL is actually present (addr() right after
|
||||
// bind can return before the relay handshake completes); the 15s cap means
|
||||
// a relay outage degrades to a possibly-incomplete ticket rather than a hang
|
||||
// (n0 DNS discovery still resolves the id in that case).
|
||||
if tokio::time::timeout(Duration::from_secs(15), endpoint.online())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!("home relay not connected within 15s; ticket may be incomplete");
|
||||
}
|
||||
let addr = endpoint.addr();
|
||||
let ticket = EndpointTicket::new(addr);
|
||||
let clipboard_ok = opts.interactive && copy_to_clipboard(&ticket.to_string());
|
||||
print_host_banner(&ticket, display, &opts, clipboard_ok);
|
||||
let relay_only =
|
||||
EndpointAddr::new(addr.id).with_addrs(addr.addrs.iter().filter(|a| a.is_relay()).cloned());
|
||||
let ticket = EndpointTicket::new(relay_only);
|
||||
let ticket_str = ticket.to_string();
|
||||
let clipboard_ok = opts.interactive && copy_to_clipboard(&ticket_str);
|
||||
print_host_banner(&ticket, display, &opts, &quality, &resolution, clipboard_ok);
|
||||
|
||||
let result = accept_loop(&endpoint, display, &opts, cancel.clone()).await;
|
||||
output::emit(output::Event::Ticket { value: &ticket_str });
|
||||
let display_str = format!("{display:?}");
|
||||
let capture = capture_summary(&opts);
|
||||
let dims = quality.dimensions_summary();
|
||||
let cap_source = resolution.source.label();
|
||||
output::emit(output::Event::HostInfo {
|
||||
display_server: &display_str,
|
||||
capture: &capture,
|
||||
quality: &quality.label,
|
||||
dimensions: &dims,
|
||||
hw_encode: !opts.no_hwencode,
|
||||
max_viewers: resolution.value,
|
||||
max_viewers_source: &cap_source,
|
||||
});
|
||||
|
||||
let (sup_tx, sup_rx) = mpsc::channel::<SupervisorMsg>(16);
|
||||
let supervisor = tokio::spawn(supervise(
|
||||
opts.clone(),
|
||||
quality,
|
||||
display,
|
||||
resolution.value,
|
||||
sup_rx,
|
||||
));
|
||||
|
||||
// Command channel for the GUI front-end: read `kick <endpoint-id>` lines
|
||||
// off stdin. Only when machine-driven (`--output json`) — a human host has
|
||||
// nothing to type here, and we don't want to swallow terminal input. Runs
|
||||
// on a plain OS thread (not a tokio task) so a read parked on stdin can't
|
||||
// hold up runtime shutdown on Ctrl+C; the thread dies with the process.
|
||||
if output::json_enabled() {
|
||||
spawn_kick_listener(sup_tx.clone());
|
||||
}
|
||||
|
||||
accept_loop(&endpoint, sup_tx.clone(), cancel.clone()).await;
|
||||
|
||||
drop(sup_tx);
|
||||
let _ = supervisor.await;
|
||||
|
||||
endpoint.close().await;
|
||||
result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn accept_loop(
|
||||
endpoint: &Endpoint,
|
||||
display: DisplayServer,
|
||||
opts: &HostOpts,
|
||||
sup_tx: mpsc::Sender<SupervisorMsg>,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<()> {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => {
|
||||
tracing::info!("cancellation requested before any peer connected");
|
||||
Ok(())
|
||||
}
|
||||
accepted = endpoint.accept() => {
|
||||
let Some(incoming) = accepted else {
|
||||
bail!("endpoint stopped accepting connections");
|
||||
};
|
||||
let conn = incoming.await?;
|
||||
let remote = conn.remote_id();
|
||||
tracing::info!(%remote, "peer connected");
|
||||
eprintln!("\n[pixelpass] peer connected: {remote}\n");
|
||||
handle_peer(conn, display, opts, cancel).await
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => {
|
||||
tracing::info!("cancellation requested — closing accept loop");
|
||||
return;
|
||||
}
|
||||
accepted = endpoint.accept() => {
|
||||
let Some(incoming) = accepted else {
|
||||
tracing::info!("endpoint stopped accepting connections");
|
||||
return;
|
||||
};
|
||||
let conn = match incoming.await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!("incoming connection failed: {e:#}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let sup_tx = sup_tx.clone();
|
||||
let cancel = cancel.clone();
|
||||
tokio::spawn(handle_peer(conn, sup_tx, cancel));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_peer(
|
||||
conn: Connection,
|
||||
display: DisplayServer,
|
||||
opts: &HostOpts,
|
||||
sup_tx: mpsc::Sender<SupervisorMsg>,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<()> {
|
||||
let (quic_send, quic_recv) = conn.accept_bi().await?;
|
||||
) {
|
||||
let remote = conn.remote_id();
|
||||
let id = remote.to_string();
|
||||
// This viewer's own kill switch: the supervisor holds a clone so a `kick`
|
||||
// can cancel it, and the stream select! below watches it.
|
||||
let peer_cancel = CancellationToken::new();
|
||||
|
||||
let capture_handle = capture::spawn(display, opts).await?;
|
||||
let port = capture_handle.local_port();
|
||||
let tcp = wayland::connect_to_capture(port, std::time::Duration::from_secs(5)).await?;
|
||||
|
||||
let bridge = crate::common::tunnel::bridge(quic_send, quic_recv, tcp);
|
||||
|
||||
tokio::select! {
|
||||
res = bridge => {
|
||||
if let Err(e) = res {
|
||||
tracing::warn!("bridge ended with error: {e:#}");
|
||||
} else {
|
||||
tracing::info!("bridge closed cleanly");
|
||||
}
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
let add = SupervisorMsg::AddViewer {
|
||||
id: id.clone(),
|
||||
cancel: peer_cancel.clone(),
|
||||
reply: reply_tx,
|
||||
};
|
||||
if sup_tx.send(add).await.is_err() {
|
||||
tracing::warn!(%remote, "supervisor channel closed; dropping peer");
|
||||
return;
|
||||
}
|
||||
let port = match reply_rx.await {
|
||||
Ok(Ok(p)) => p,
|
||||
Ok(Err(reason)) => {
|
||||
tracing::warn!(%remote, %reason, "refusing viewer");
|
||||
eprintln!("[pixelpass] refusing viewer {remote}: {reason}");
|
||||
return;
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!(%remote, "supervisor reply dropped; dropping peer");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let (quic_send, quic_recv) = match conn.accept_bi().await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!(%remote, "accept_bi failed: {e:#}");
|
||||
let _ = sup_tx.send(SupervisorMsg::RemoveViewer { id }).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!("[pixelpass] viewer connected: {remote}");
|
||||
|
||||
let tcp = match serve::connect_to_capture(port, Duration::from_secs(5)).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::warn!(%remote, "connect_to_capture failed: {e:#}");
|
||||
let _ = sup_tx.send(SupervisorMsg::RemoveViewer { id }).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let bridge = tunnel::bridge(quic_send, quic_recv, tcp);
|
||||
tokio::select! {
|
||||
res = bridge => match res {
|
||||
Ok(()) => tracing::info!(%remote, "bridge closed cleanly"),
|
||||
Err(e) => tracing::info!(%remote, "bridge ended: {e:#}"),
|
||||
},
|
||||
_ = cancel.cancelled() => {
|
||||
tracing::info!("cancellation requested during stream");
|
||||
tracing::info!(%remote, "cancellation during stream");
|
||||
}
|
||||
_ = peer_cancel.cancelled() => {
|
||||
tracing::info!(%remote, "kicked by host");
|
||||
}
|
||||
}
|
||||
|
||||
capture_handle.shutdown().await;
|
||||
Ok(())
|
||||
eprintln!("[pixelpass] viewer disconnected: {remote}");
|
||||
let _ = sup_tx.send(SupervisorMsg::RemoveViewer { id }).await;
|
||||
}
|
||||
|
||||
/// Read `kick <endpoint-id>` lines off stdin and forward them to the
|
||||
/// supervisor. Runs on a detached OS thread (see the call site for why). Ends
|
||||
/// when stdin hits EOF (the GUI closed the pipe) or the supervisor is gone.
|
||||
fn spawn_kick_listener(sup_tx: mpsc::Sender<SupervisorMsg>) {
|
||||
use std::io::BufRead;
|
||||
std::thread::spawn(move || {
|
||||
let stdin = std::io::stdin();
|
||||
for line in stdin.lock().lines().map_while(Result::ok) {
|
||||
let Some(id) = line.trim().strip_prefix("kick ") else {
|
||||
continue;
|
||||
};
|
||||
let msg = SupervisorMsg::KickViewer {
|
||||
id: id.trim().to_string(),
|
||||
};
|
||||
// blocking_send is valid here: this is a plain thread, not inside
|
||||
// the tokio runtime. An Err means the supervisor closed — stop.
|
||||
if sup_tx.blocking_send(msg).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Owns the single shared CaptureHandle and the active viewer count. Spawns
|
||||
/// capture lazily on the first AddViewer; tears it down when the count drops
|
||||
/// back to zero. Enforces the max-viewers cap by refusing AddViewer when
|
||||
/// the count is already at the cap.
|
||||
async fn supervise(
|
||||
opts: HostOpts,
|
||||
quality: EffectiveQuality,
|
||||
display: DisplayServer,
|
||||
max_viewers: u32,
|
||||
mut rx: mpsc::Receiver<SupervisorMsg>,
|
||||
) {
|
||||
let mut handle: Option<CaptureHandle> = None;
|
||||
// Active viewers, keyed by endpoint id, holding each one's kill switch.
|
||||
// The count is just `viewers.len()`. (A given endpoint connecting twice is
|
||||
// a non-case here: each viewer process uses a fresh ephemeral identity.)
|
||||
let mut viewers: HashMap<String, CancellationToken> = HashMap::new();
|
||||
|
||||
while let Some(msg) = rx.recv().await {
|
||||
match msg {
|
||||
SupervisorMsg::AddViewer { id, cancel, reply } => {
|
||||
let count = viewers.len() as u32;
|
||||
if count >= max_viewers {
|
||||
let reason =
|
||||
format!("host is full ({count} of {max_viewers} viewers connected)");
|
||||
output::emit(output::Event::ViewerRefused { reason: &reason });
|
||||
let _ = reply.send(Err(reason));
|
||||
continue;
|
||||
}
|
||||
|
||||
if handle.is_none() {
|
||||
tracing::info!("first viewer arriving — spawning capture");
|
||||
match capture::spawn(display, &opts, &quality).await {
|
||||
Ok(h) => {
|
||||
handle = Some(h);
|
||||
output::emit(output::Event::Capture {
|
||||
state: output::CaptureState::Started,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = reply.send(Err(format!("capture spawn failed: {e:#}")));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let port = handle.as_ref().expect("handle was just set").local_port();
|
||||
viewers.insert(id.clone(), cancel);
|
||||
let active = viewers.len() as u32;
|
||||
let _ = reply.send(Ok(port));
|
||||
output::emit(output::Event::ViewerJoined {
|
||||
id: &id,
|
||||
active,
|
||||
max: max_viewers,
|
||||
});
|
||||
tracing::info!(active, cap = max_viewers, "viewer joined");
|
||||
}
|
||||
SupervisorMsg::RemoveViewer { id } => {
|
||||
// A given viewer task only ever sends RemoveViewer once, but the
|
||||
// map remove is the source of truth either way.
|
||||
if viewers.remove(&id).is_none() {
|
||||
continue;
|
||||
}
|
||||
let active = viewers.len() as u32;
|
||||
output::emit(output::Event::ViewerLeft {
|
||||
id: &id,
|
||||
active,
|
||||
max: max_viewers,
|
||||
});
|
||||
tracing::info!(active, cap = max_viewers, "viewer left");
|
||||
if active == 0
|
||||
&& let Some(h) = handle.take()
|
||||
{
|
||||
tracing::info!("last viewer left — tearing down capture");
|
||||
h.shutdown().await;
|
||||
output::emit(output::Event::Capture {
|
||||
state: output::CaptureState::Stopped,
|
||||
});
|
||||
}
|
||||
}
|
||||
SupervisorMsg::KickViewer { id } => {
|
||||
match viewers.get(&id) {
|
||||
// Cancel the viewer's token; its handle_peer select! wakes,
|
||||
// sends RemoveViewer, and the leave is emitted there.
|
||||
Some(cancel) => {
|
||||
tracing::info!(%id, "kicking viewer");
|
||||
cancel.cancel();
|
||||
}
|
||||
None => tracing::debug!(%id, "kick for unknown/already-gone viewer"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(h) = handle.take() {
|
||||
tracing::info!("host shutdown — tearing down capture");
|
||||
h.shutdown().await;
|
||||
output::emit(output::Event::Capture {
|
||||
state: output::CaptureState::Stopped,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn print_host_banner(
|
||||
ticket: &EndpointTicket,
|
||||
display: DisplayServer,
|
||||
opts: &HostOpts,
|
||||
quality: &EffectiveQuality,
|
||||
resolution: &MaxViewersResolution,
|
||||
clipboard_ok: bool,
|
||||
) {
|
||||
eprintln!();
|
||||
eprintln!("┌─ PixelPass · host ─────────────────────────────────────────");
|
||||
eprintln!("│ display server : {display:?}");
|
||||
eprintln!("│ capture : {}", capture_summary(opts));
|
||||
eprintln!("│ bitrate / fps : {} kbps @ {} fps", opts.bitrate, opts.framerate);
|
||||
eprintln!("│ hw encode : {}", if opts.no_hwencode { "off" } else { "auto (VAAPI if available)" });
|
||||
eprintln!(
|
||||
"│ quality : {} — {}",
|
||||
quality.label,
|
||||
quality.dimensions_summary()
|
||||
);
|
||||
eprintln!("│ ({})", quality.note);
|
||||
eprintln!(
|
||||
"│ hw encode : {}",
|
||||
if opts.no_hwencode {
|
||||
"off (software x264)"
|
||||
} else {
|
||||
"on (VAAPI H.264)"
|
||||
}
|
||||
);
|
||||
eprintln!(
|
||||
"│ max viewers : {} ({})",
|
||||
resolution.value,
|
||||
resolution.source.label()
|
||||
);
|
||||
eprintln!("│");
|
||||
if clipboard_ok {
|
||||
eprintln!("│ Your share code has been copied to your clipboard.");
|
||||
eprintln!("│ Send it to your viewer. (If clipboard didn't work, the");
|
||||
eprintln!("│ Send it to your viewer(s). (If clipboard didn't work, the");
|
||||
eprintln!("│ code is also shown below for manual copy.)");
|
||||
} else {
|
||||
eprintln!("│ Share this ticket with your viewer:");
|
||||
eprintln!("│ Share this ticket with your viewer(s):");
|
||||
}
|
||||
eprintln!("│");
|
||||
eprintln!("│ pixelpass {ticket}");
|
||||
eprintln!("│");
|
||||
eprintln!("│ Capture will not start until the viewer connects.");
|
||||
eprintln!("│ Press Ctrl+C to stop.");
|
||||
eprintln!("│ Capture starts when the first viewer connects, runs while");
|
||||
eprintln!("│ any viewer is connected, and tears down when the last one");
|
||||
eprintln!("│ leaves. Press Ctrl+C to stop the host entirely.");
|
||||
eprintln!("└────────────────────────────────────────────────────────────");
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
/// How we arrived at the final viewer cap. Surfaced in the banner so the
|
||||
/// user can tell at a glance whether the number is what they specified,
|
||||
/// what their measured upstream supports, or just the fallback default.
|
||||
struct MaxViewersResolution {
|
||||
value: u32,
|
||||
source: MaxViewersSource,
|
||||
}
|
||||
|
||||
enum MaxViewersSource {
|
||||
/// User passed --max-viewers explicitly.
|
||||
UserFlag,
|
||||
/// Derived from the saved bandwidth measurement.
|
||||
BandwidthMeasurement { safe_mbps: f64 },
|
||||
/// No flag, no measurement — falling back.
|
||||
DefaultFallback,
|
||||
}
|
||||
|
||||
impl MaxViewersSource {
|
||||
fn label(&self) -> String {
|
||||
match self {
|
||||
MaxViewersSource::UserFlag => "user-specified".to_string(),
|
||||
MaxViewersSource::BandwidthMeasurement { safe_mbps } => {
|
||||
format!("auto: {safe_mbps:.1} Mbps measured upstream")
|
||||
}
|
||||
MaxViewersSource::DefaultFallback => {
|
||||
"default — run `pixelpass --reconfigure` for a connection-aware value".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_max_viewers(opts: &HostOpts, effective_bitrate: u32) -> MaxViewersResolution {
|
||||
if let Some(n) = opts.max_viewers {
|
||||
return MaxViewersResolution {
|
||||
value: n,
|
||||
source: MaxViewersSource::UserFlag,
|
||||
};
|
||||
}
|
||||
if let Ok(cfg) = config::load()
|
||||
&& cfg.bandwidth.status == BandwidthStatus::Measured
|
||||
&& let Some(upstream) = cfg.bandwidth.upstream_mbps
|
||||
{
|
||||
let n = bandwidth::recommended_max_viewers(upstream, effective_bitrate);
|
||||
return MaxViewersResolution {
|
||||
value: n,
|
||||
source: MaxViewersSource::BandwidthMeasurement {
|
||||
safe_mbps: upstream,
|
||||
},
|
||||
};
|
||||
}
|
||||
MaxViewersResolution {
|
||||
value: 2,
|
||||
source: MaxViewersSource::DefaultFallback,
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_to_clipboard(text: &str) -> bool {
|
||||
match arboard::Clipboard::new().and_then(|mut cb| cb.set_text(text.to_owned())) {
|
||||
Ok(()) => true,
|
||||
@@ -136,12 +498,73 @@ fn copy_to_clipboard(text: &str) -> bool {
|
||||
fn capture_summary(opts: &HostOpts) -> String {
|
||||
let mut bits = vec![if opts.window { "window" } else { "fullscreen" }.to_string()];
|
||||
if let Some(app) = &opts.app {
|
||||
bits.push(format!("app-audio={app}"));
|
||||
if opts.strict_audio {
|
||||
bits.push(format!("app-audio={app} (strict)"));
|
||||
} else {
|
||||
bits.push(format!("app-audio={app}"));
|
||||
}
|
||||
} else {
|
||||
bits.push("system-audio".to_string());
|
||||
}
|
||||
if opts.mic {
|
||||
bits.push("mic".to_string());
|
||||
}
|
||||
bits.join(" + ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cli::Quality;
|
||||
|
||||
fn opts(app: Option<&str>, strict_audio: bool) -> HostOpts {
|
||||
HostOpts {
|
||||
window: false,
|
||||
app: app.map(str::to_string),
|
||||
strict_audio,
|
||||
display_server: None,
|
||||
quality: Quality::Auto,
|
||||
bitrate: None,
|
||||
framerate: None,
|
||||
max_height: None,
|
||||
no_hwencode: false,
|
||||
max_viewers: None,
|
||||
interactive: false,
|
||||
relay: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_summary_reflects_audio_mode() {
|
||||
assert_eq!(
|
||||
capture_summary(&opts(None, false)),
|
||||
"fullscreen + system-audio"
|
||||
);
|
||||
assert_eq!(
|
||||
capture_summary(&opts(Some("Firefox"), false)),
|
||||
"fullscreen + app-audio=Firefox"
|
||||
);
|
||||
// strict only shows when an app is selected.
|
||||
assert_eq!(
|
||||
capture_summary(&opts(Some("Firefox"), true)),
|
||||
"fullscreen + app-audio=Firefox (strict)"
|
||||
);
|
||||
assert_eq!(
|
||||
capture_summary(&opts(None, true)),
|
||||
"fullscreen + system-audio"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_app_audio_is_lost_only_in_strict_app_mode() {
|
||||
use crate::common::output::AppAudioState;
|
||||
use crate::host::audio::initial_app_audio_state;
|
||||
// Strict + app: announce silence up front (loopback suppressed).
|
||||
assert_eq!(
|
||||
initial_app_audio_state(&opts(Some("Firefox"), true)),
|
||||
Some(AppAudioState::Lost)
|
||||
);
|
||||
// Best-effort app (no strict): loopback covers the gap → no initial event.
|
||||
assert_eq!(initial_app_audio_state(&opts(Some("Firefox"), false)), None);
|
||||
// Whole-desktop (strict is ignored without --app): no per-app events.
|
||||
assert_eq!(initial_app_audio_state(&opts(None, true)), None);
|
||||
assert_eq!(initial_app_audio_state(&opts(None, false)), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
//! PipeWire I/O adapter for the pure registry observer.
|
||||
//!
|
||||
//! This module owns a read-only PipeWire main-loop thread, translates registry
|
||||
//! callbacks into [`RegEvent`]s, and publishes the latest [`Projection`] for
|
||||
//! consumers running outside the PipeWire thread.
|
||||
|
||||
use super::classify::DeviceClaim;
|
||||
use super::{EventKind, LinkEndpoints, NodeObservation, Projection, RegEvent, RegistryModel};
|
||||
use crate::host::audio::parse_object_serial;
|
||||
use crate::host::taint::snapshot::{
|
||||
ClientSnapshot, GlobalId, MediaRole, NodeProps, PortDirection, PortSnapshot, Serial,
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use pipewire::{self as pw, types::ObjectType};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const READINESS_TIMEOUT_MILLIS: u64 = 2_000;
|
||||
const TICK_INTERVAL: Duration = Duration::from_millis(250);
|
||||
|
||||
/// A consumer that sees **every** projection, one per applied registry event,
|
||||
/// on the observer thread.
|
||||
///
|
||||
/// This exists because polling [`RegistryObserverHandle::latest`] coalesces, and
|
||||
/// some consumers cannot tolerate that. Phase 4's AEC validator is the concrete
|
||||
/// case: it detects a module unload by observing the *empty gap* before the next
|
||||
/// module appears, and PipeWire reuses module indices verbatim across an
|
||||
/// unload/reload (v3.4 §5.2 correction 3), so a consumer that misses the gap
|
||||
/// silently aliases a fresh module onto a dead module's validated identity.
|
||||
///
|
||||
/// **Implementations run inline on the PipeWire loop thread.** Whatever they do
|
||||
/// delays the next registry callback, so they must be bounded and must not
|
||||
/// block. The phase-5 audit is the only implementor and measures its own cost
|
||||
/// for exactly this reason.
|
||||
pub trait ProjectionSink: Send {
|
||||
/// `now_us` is monotonic microseconds since the observer started — the same
|
||||
/// clock that drives [`RegEvent::Tick`], so a sink's notion of time cannot
|
||||
/// drift from the readiness epoch's.
|
||||
fn on_projection(&mut self, projection: &Projection, kind: EventKind, now_us: u64);
|
||||
}
|
||||
|
||||
/// Tokio-side access to the observer's most recent coherent projection.
|
||||
pub struct RegistryObserverHandle {
|
||||
latest: Arc<Mutex<Option<Projection>>>,
|
||||
shutdown_tx: pw::channel::Sender<()>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl RegistryObserverHandle {
|
||||
/// Spawn the read-only PipeWire registry observer.
|
||||
pub fn spawn() -> Result<Self> {
|
||||
Self::spawn_with_sink(None)
|
||||
}
|
||||
|
||||
/// Spawn the observer with a per-event [`ProjectionSink`] attached.
|
||||
///
|
||||
/// The sink is moved onto the observer thread and dropped when that thread
|
||||
/// exits, which is what lets a sink emit a final summary on shutdown without
|
||||
/// the caller arranging one.
|
||||
pub fn spawn_with_sink(sink: Option<Box<dyn ProjectionSink>>) -> Result<Self> {
|
||||
let latest = Arc::new(Mutex::new(None));
|
||||
let latest_for_thread = Arc::clone(&latest);
|
||||
let (shutdown_tx, shutdown_rx) = pw::channel::channel::<()>();
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("pixelpass-pw-observer".to_string())
|
||||
.spawn(move || {
|
||||
if let Err(e) = run_observer(latest_for_thread, shutdown_rx, sink) {
|
||||
tracing::warn!(
|
||||
"registry observer: libpipewire thread exited with error: {e:#}"
|
||||
);
|
||||
}
|
||||
})
|
||||
.context("failed to spawn libpipewire registry observer thread")?;
|
||||
|
||||
Ok(Self {
|
||||
latest,
|
||||
shutdown_tx,
|
||||
thread: Some(thread),
|
||||
})
|
||||
}
|
||||
|
||||
/// Return a clone of the latest projection, or `None` before the first
|
||||
/// registry event has been applied.
|
||||
pub fn latest(&self) -> Option<Projection> {
|
||||
self.latest
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RegistryObserverHandle {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.shutdown_tx.send(());
|
||||
if let Some(thread) = self.thread.take()
|
||||
&& let Err(e) = thread.join()
|
||||
{
|
||||
tracing::warn!("registry observer: pw thread join failed: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BoundLink {
|
||||
_proxy: pw::link::Link,
|
||||
_listener: pw::link::LinkListener,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct LiveGlobal {
|
||||
bound_link: Option<BoundLink>,
|
||||
}
|
||||
|
||||
struct ObserverState {
|
||||
model: RegistryModel,
|
||||
latest: Arc<Mutex<Option<Projection>>>,
|
||||
last_candidate: Option<u32>,
|
||||
live_globals: BTreeMap<GlobalId, VecDeque<LiveGlobal>>,
|
||||
sink: Option<Box<dyn ProjectionSink>>,
|
||||
started_at: Instant,
|
||||
}
|
||||
|
||||
impl ObserverState {
|
||||
/// `started_at` is the observer's single time origin, shared with the
|
||||
/// readiness tick timer — so a sink's `now_us` and a `RegEvent::Tick`'s
|
||||
/// `now` are the same clock, not two that drift.
|
||||
fn new(
|
||||
latest: Arc<Mutex<Option<Projection>>>,
|
||||
sink: Option<Box<dyn ProjectionSink>>,
|
||||
started_at: Instant,
|
||||
) -> Self {
|
||||
Self {
|
||||
model: RegistryModel::new(0, READINESS_TIMEOUT_MILLIS),
|
||||
latest,
|
||||
last_candidate: None,
|
||||
live_globals: BTreeMap::new(),
|
||||
sink,
|
||||
started_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(&mut self, event: RegEvent) {
|
||||
// Taken before the model consumes the event: the sink is told what kind
|
||||
// of observation produced the projection, and deriving that from the
|
||||
// event itself is what stops the two from ever disagreeing.
|
||||
let kind = event.kind();
|
||||
self.model.apply(event);
|
||||
|
||||
let candidate = self.model.pulse_pid_candidate();
|
||||
if candidate != self.last_candidate {
|
||||
self.last_candidate = candidate;
|
||||
if let Some(pid) = candidate {
|
||||
let comm = std::fs::read_to_string(format!("/proc/{pid}/comm"))
|
||||
.ok()
|
||||
.map(|comm| comm.trim_end_matches(['\r', '\n']).to_string());
|
||||
// Folded into the model directly rather than through `apply`, so
|
||||
// one registry event still yields exactly one sink call — the
|
||||
// no-coalescing contract cuts both ways, and a *duplicated*
|
||||
// observation would make the O5 event rate a fiction.
|
||||
self.model.apply(RegEvent::ProcCommProbed { pid, comm });
|
||||
}
|
||||
}
|
||||
|
||||
self.publish(kind);
|
||||
}
|
||||
|
||||
fn publish(&mut self, kind: EventKind) {
|
||||
let projection = self.model.project();
|
||||
if let Some(sink) = self.sink.as_mut() {
|
||||
let now_us = u64::try_from(self.started_at.elapsed().as_micros()).unwrap_or(u64::MAX);
|
||||
sink.on_projection(&projection, kind, now_us);
|
||||
}
|
||||
// Published after the sink has seen it, so the projection is moved
|
||||
// rather than cloned — the snapshot is the largest thing the observer
|
||||
// owns and this runs on every event.
|
||||
*self
|
||||
.latest
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(projection);
|
||||
}
|
||||
|
||||
/// Record the global's id and apply its add event as one step, so the
|
||||
/// bound-link FIFO stays provably lockstep with the model's own `live_ids`
|
||||
/// index. Recording only on *applied* adds (never on unknown object types
|
||||
/// or globals dropped for a missing serial) is what keeps the two id
|
||||
/// queues the same length per id — otherwise a phantom slot ahead of a
|
||||
/// bound Link would be popped on removal, leaking that Link's proxy.
|
||||
fn add(&mut self, id: GlobalId, event: RegEvent) {
|
||||
self.live_globals
|
||||
.entry(id)
|
||||
.or_default()
|
||||
.push_back(LiveGlobal::default());
|
||||
self.apply(event);
|
||||
}
|
||||
|
||||
fn attach_bound_link(&mut self, id: GlobalId, bound_link: BoundLink) {
|
||||
let Some(global) = self.live_globals.get_mut(&id).and_then(VecDeque::back_mut) else {
|
||||
tracing::warn!(
|
||||
global_id = id.0,
|
||||
"registry observer: link bind completed without a live global slot"
|
||||
);
|
||||
return;
|
||||
};
|
||||
global.bound_link = Some(bound_link);
|
||||
}
|
||||
|
||||
fn remove_global(&mut self, id: GlobalId) -> Option<BoundLink> {
|
||||
let (bound_link, empty) = {
|
||||
let globals = self.live_globals.get_mut(&id)?;
|
||||
let bound_link = globals.pop_front().and_then(|global| global.bound_link);
|
||||
(bound_link, globals.is_empty())
|
||||
};
|
||||
if empty {
|
||||
self.live_globals.remove(&id);
|
||||
}
|
||||
bound_link
|
||||
}
|
||||
}
|
||||
|
||||
fn run_observer(
|
||||
latest: Arc<Mutex<Option<Projection>>>,
|
||||
shutdown_rx: pw::channel::Receiver<()>,
|
||||
sink: Option<Box<dyn ProjectionSink>>,
|
||||
) -> Result<()> {
|
||||
let started_at = Instant::now();
|
||||
let main_loop =
|
||||
pw::main_loop::MainLoopRc::new(None).context("pw main loop construction failed")?;
|
||||
let context =
|
||||
pw::context::ContextRc::new(&main_loop, None).context("pw context construction failed")?;
|
||||
let core = context
|
||||
.connect_rc(None)
|
||||
.context("pw core connect failed (is the daemon running?)")?;
|
||||
let registry = core.get_registry_rc().context("pw get_registry failed")?;
|
||||
let state = Rc::new(RefCell::new(ObserverState::new(latest, sink, started_at)));
|
||||
|
||||
let main_loop_for_shutdown = main_loop.clone();
|
||||
let _shutdown_receiver = shutdown_rx.attach(main_loop.loop_(), move |()| {
|
||||
main_loop_for_shutdown.quit();
|
||||
});
|
||||
|
||||
let pending_sync = Rc::new(Cell::new(None));
|
||||
let pending_sync_for_done = Rc::clone(&pending_sync);
|
||||
let state_for_done = Rc::clone(&state);
|
||||
let _core_listener = core
|
||||
.add_listener_local()
|
||||
.done(move |id, seq| {
|
||||
if id == pw::core::PW_ID_CORE && pending_sync_for_done.get() == Some(seq) {
|
||||
pending_sync_for_done.set(None);
|
||||
state_for_done.borrow_mut().apply(RegEvent::ServerSynced);
|
||||
}
|
||||
})
|
||||
.error(|id, seq, res, message| {
|
||||
tracing::warn!(
|
||||
id,
|
||||
seq,
|
||||
result = res,
|
||||
%message,
|
||||
"registry observer: PipeWire core error"
|
||||
);
|
||||
})
|
||||
.register();
|
||||
|
||||
let registry_weak = registry.downgrade();
|
||||
let state_for_global = Rc::clone(&state);
|
||||
let state_for_remove = Rc::clone(&state);
|
||||
let _registry_listener = registry
|
||||
.add_listener_local()
|
||||
.global(move |obj| {
|
||||
let id = GlobalId(obj.id);
|
||||
|
||||
match obj.type_ {
|
||||
ObjectType::Node => {
|
||||
let Some(props) = obj.props.as_ref() else {
|
||||
tracing::warn!(
|
||||
node_id = obj.id,
|
||||
"registry observer: Node has no properties; dropping"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let Some(serial) = parse_serial(obj.id, "Node", props.get("object.serial"))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let node_props = NodeProps {
|
||||
peerspeak_owned: truthy(props.get("peerspeak.owned")),
|
||||
pulse_module_id: props
|
||||
.get("pulse.module.id")
|
||||
.and_then(|value| value.parse::<u64>().ok()),
|
||||
link_group: props.get("node.link-group").map(str::to_owned),
|
||||
client_id: props
|
||||
.get("client.id")
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.map(GlobalId),
|
||||
process_id: props
|
||||
.get("application.process.id")
|
||||
.and_then(|value| value.parse::<u32>().ok()),
|
||||
passthrough: truthy(props.get("node.passthrough")),
|
||||
session_device: false,
|
||||
};
|
||||
let observation = NodeObservation {
|
||||
serial,
|
||||
id,
|
||||
name: props.get("node.name").map(str::to_owned),
|
||||
role: MediaRole::parse(props.get("media.class")),
|
||||
props: node_props,
|
||||
device_claim: DeviceClaim {
|
||||
device_id: props
|
||||
.get("device.id")
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.map(GlobalId),
|
||||
device_api: props.get("device.api").map(str::to_owned),
|
||||
factory_name: props.get("factory.name").map(str::to_owned),
|
||||
alsa_driver_name: props.get("alsa.driver_name").map(str::to_owned),
|
||||
},
|
||||
};
|
||||
state_for_global
|
||||
.borrow_mut()
|
||||
.add(id, RegEvent::NodeAdded(observation));
|
||||
}
|
||||
ObjectType::Port => {
|
||||
let Some(props) = obj.props.as_ref() else {
|
||||
tracing::warn!(
|
||||
port_id = obj.id,
|
||||
"registry observer: Port has no properties; dropping"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let Some(serial) = parse_serial(obj.id, "Port", props.get("object.serial"))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(node) = props
|
||||
.get("node.id")
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.map(GlobalId)
|
||||
else {
|
||||
tracing::warn!(
|
||||
port_id = obj.id,
|
||||
node_id = props.get("node.id").unwrap_or("<absent>"),
|
||||
"registry observer: Port has no usable node.id; dropping"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let direction = match props.get("port.direction") {
|
||||
Some("in") => PortDirection::In,
|
||||
Some("out") => PortDirection::Out,
|
||||
direction => {
|
||||
tracing::warn!(
|
||||
port_id = obj.id,
|
||||
direction = direction.unwrap_or("<absent>"),
|
||||
"registry observer: Port has no usable direction; dropping"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
state_for_global.borrow_mut().add(
|
||||
id,
|
||||
RegEvent::PortAdded(PortSnapshot {
|
||||
serial,
|
||||
id,
|
||||
node,
|
||||
direction,
|
||||
exclusive: truthy(props.get("port.exclusive")),
|
||||
monitor: truthy(props.get("port.monitor")),
|
||||
}),
|
||||
);
|
||||
}
|
||||
ObjectType::Client => {
|
||||
let Some(props) = obj.props.as_ref() else {
|
||||
tracing::warn!(
|
||||
client_id = obj.id,
|
||||
"registry observer: Client has no properties; dropping"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let Some(serial) = parse_serial(obj.id, "Client", props.get("object.serial"))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
state_for_global.borrow_mut().add(
|
||||
id,
|
||||
RegEvent::ClientAdded(ClientSnapshot {
|
||||
serial,
|
||||
id,
|
||||
sec_pid: props
|
||||
.get("pipewire.sec.pid")
|
||||
.and_then(|value| value.parse::<u32>().ok()),
|
||||
}),
|
||||
);
|
||||
}
|
||||
ObjectType::Device => {
|
||||
state_for_global
|
||||
.borrow_mut()
|
||||
.add(id, RegEvent::DeviceAdded { id });
|
||||
}
|
||||
ObjectType::Link => {
|
||||
let Some(props) = obj.props.as_ref() else {
|
||||
tracing::warn!(
|
||||
link_id = obj.id,
|
||||
"registry observer: Link has no properties; dropping"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let Some(serial) = parse_serial(obj.id, "Link", props.get("object.serial"))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let endpoints = link_endpoints_from_props(props);
|
||||
state_for_global.borrow_mut().add(
|
||||
id,
|
||||
RegEvent::LinkAdded {
|
||||
serial,
|
||||
id,
|
||||
endpoints,
|
||||
},
|
||||
);
|
||||
if endpoints.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(registry) = registry_weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let link: pw::link::Link = match registry.bind(obj) {
|
||||
Ok(link) => link,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
link_id = obj.id,
|
||||
"registry observer: failed to bind Link for endpoints: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let resolved = Rc::new(Cell::new(false));
|
||||
let resolved_for_info = Rc::clone(&resolved);
|
||||
let state_for_info = Rc::downgrade(&state_for_global);
|
||||
let listener = link
|
||||
.add_listener_local()
|
||||
.info(move |info| {
|
||||
if resolved_for_info.replace(true) {
|
||||
return;
|
||||
}
|
||||
let endpoints = LinkEndpoints {
|
||||
output_node: GlobalId(info.output_node_id()),
|
||||
input_node: GlobalId(info.input_node_id()),
|
||||
output_port: optional_global_id(info.output_port_id()),
|
||||
input_port: optional_global_id(info.input_port_id()),
|
||||
};
|
||||
if let Some(state) = state_for_info.upgrade() {
|
||||
state
|
||||
.borrow_mut()
|
||||
.apply(RegEvent::LinkEndpointsResolved { serial, endpoints });
|
||||
}
|
||||
})
|
||||
.register();
|
||||
state_for_global.borrow_mut().attach_bound_link(
|
||||
id,
|
||||
BoundLink {
|
||||
_proxy: link,
|
||||
_listener: listener,
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.global_remove(move |id| {
|
||||
let id = GlobalId(id);
|
||||
let bound_link = state_for_remove.borrow_mut().remove_global(id);
|
||||
state_for_remove
|
||||
.borrow_mut()
|
||||
.apply(RegEvent::Removed { id });
|
||||
drop(bound_link);
|
||||
})
|
||||
.register();
|
||||
|
||||
pending_sync.set(Some(
|
||||
core.sync(0)
|
||||
.context("registry observer: initial core.sync failed")?,
|
||||
));
|
||||
|
||||
let state_for_tick = Rc::clone(&state);
|
||||
let timer = main_loop.loop_().add_timer(move |_| {
|
||||
let now = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
state_for_tick.borrow_mut().apply(RegEvent::Tick { now });
|
||||
});
|
||||
timer
|
||||
.update_timer(Some(TICK_INTERVAL), Some(TICK_INTERVAL))
|
||||
.into_result()
|
||||
.context("registry observer: failed to arm readiness timer")?;
|
||||
|
||||
tracing::info!("registry observer: pw thread running");
|
||||
main_loop.run();
|
||||
tracing::info!("registry observer: pw thread exiting");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_serial(id: u32, kind: &str, raw: Option<&str>) -> Option<Serial> {
|
||||
match raw.and_then(parse_object_serial) {
|
||||
Some(serial) => Some(Serial(serial)),
|
||||
None => {
|
||||
tracing::warn!(
|
||||
global_id = id,
|
||||
object_type = kind,
|
||||
serial = raw.unwrap_or("<absent>"),
|
||||
"registry observer: global has no usable object.serial; dropping"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn truthy(value: Option<&str>) -> bool {
|
||||
value.is_some_and(|value| value != "false" && value != "0")
|
||||
}
|
||||
|
||||
fn link_endpoints_from_props(props: &pw::spa::utils::dict::DictRef) -> Option<LinkEndpoints> {
|
||||
let output_node = props.get("link.output.node")?.parse::<u32>().ok()?;
|
||||
let input_node = props.get("link.input.node")?.parse::<u32>().ok()?;
|
||||
Some(LinkEndpoints {
|
||||
output_node: GlobalId(output_node),
|
||||
input_node: GlobalId(input_node),
|
||||
output_port: props
|
||||
.get("link.output.port")
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.map(GlobalId),
|
||||
input_port: props
|
||||
.get("link.input.port")
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.map(GlobalId),
|
||||
})
|
||||
}
|
||||
|
||||
fn optional_global_id(id: u32) -> Option<GlobalId> {
|
||||
(id != pw::constants::ID_ANY).then_some(GlobalId(id))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::Command;
|
||||
|
||||
struct PactlModule {
|
||||
id: Option<u32>,
|
||||
}
|
||||
|
||||
impl PactlModule {
|
||||
fn load(name: &str, args: &[String]) -> Self {
|
||||
let output = Command::new("pactl")
|
||||
.arg("load-module")
|
||||
.arg(name)
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("pactl must be installed for the live observer test");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"pactl load-module {name} failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
let id = String::from_utf8(output.stdout)
|
||||
.expect("pactl module id must be UTF-8")
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.expect("pactl module id must be a u32");
|
||||
Self { id: Some(id) }
|
||||
}
|
||||
|
||||
fn unload(mut self) {
|
||||
let id = self.id.take().expect("module must still be loaded");
|
||||
let output = Command::new("pactl")
|
||||
.arg("unload-module")
|
||||
.arg(id.to_string())
|
||||
.output()
|
||||
.expect("pactl must be installed for the live observer test");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"pactl unload-module {id} failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PactlModule {
|
||||
fn drop(&mut self) {
|
||||
if let Some(id) = self.id.take() {
|
||||
let _ = Command::new("pactl")
|
||||
.arg("unload-module")
|
||||
.arg(id.to_string())
|
||||
.output();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for(
|
||||
observer: &RegistryObserverHandle,
|
||||
predicate: impl Fn(&Projection) -> bool,
|
||||
) -> Projection {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while Instant::now() < deadline {
|
||||
if let Some(projection) = observer.latest()
|
||||
&& predicate(&projection)
|
||||
{
|
||||
return projection;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
panic!("timed out waiting for the registry projection");
|
||||
}
|
||||
|
||||
fn has_node(projection: &Projection, name: &str) -> bool {
|
||||
projection
|
||||
.snapshot
|
||||
.nodes()
|
||||
.any(|node| node.name.as_deref() == Some(name))
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs live pipewire"]
|
||||
fn live_topology_diff_tracks_null_sink_and_loopback() {
|
||||
pw::init();
|
||||
let observer = RegistryObserverHandle::spawn().expect("observer thread must spawn");
|
||||
let baseline = wait_for(&observer, |projection| projection.graph_ready);
|
||||
let baseline_links = baseline.snapshot.links().count();
|
||||
|
||||
let unique = format!("pixelpass_observer_test_{}", std::process::id());
|
||||
let capture_name = format!("{unique}_capture");
|
||||
let playback_name = format!("{unique}_playback");
|
||||
let null_sink = PactlModule::load("module-null-sink", &[format!("sink_name={unique}")]);
|
||||
let with_sink = wait_for(&observer, |projection| has_node(projection, &unique));
|
||||
let sink_links = with_sink.snapshot.links().count();
|
||||
|
||||
let loopback = PactlModule::load(
|
||||
"module-loopback",
|
||||
&[
|
||||
format!("source={unique}.monitor"),
|
||||
format!("sink={unique}"),
|
||||
format!("source_output_properties=node.name={capture_name}"),
|
||||
format!("sink_input_properties=node.name={playback_name}"),
|
||||
],
|
||||
);
|
||||
wait_for(&observer, |projection| {
|
||||
has_node(projection, &capture_name)
|
||||
&& has_node(projection, &playback_name)
|
||||
&& projection.snapshot.links().count() > sink_links
|
||||
});
|
||||
|
||||
loopback.unload();
|
||||
null_sink.unload();
|
||||
wait_for(&observer, |projection| {
|
||||
!has_node(projection, &unique)
|
||||
&& !has_node(projection, &capture_name)
|
||||
&& !has_node(projection, &playback_name)
|
||||
&& projection.snapshot.links().count() <= baseline_links
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! The `session_device` classifier — pure, no PipeWire.
|
||||
//!
|
||||
//! `NodeProps::session_device` (see [`super::super::taint::snapshot`]) is a
|
||||
//! **positive high-confidence** claim that a node is a passive hardware
|
||||
//! terminal: a real sound card's sink or source that terminates audio rather
|
||||
//! than forwarding it. Setting it *removes* two protections at once — the
|
||||
//! node's coarse owner keys and its ability to trip the fail-closed backstop
|
||||
//! — so a false positive is a **leak**, and the whole classifier is shaped so
|
||||
//! that anything less than a positive identification resolves to `false`.
|
||||
//!
|
||||
//! The observer (phase 3) owes this classification; the adapter must never
|
||||
//! stuff a raw property through. Two facts from the design (v3.4 §6.1.1,
|
||||
//! Codex rounds 2–4) drive the shape here:
|
||||
//!
|
||||
//! - `device.id` / `device.api` describe *which* Device a node belongs to and
|
||||
//! *how* that Device is reached — **neither promises the node passively
|
||||
//! terminates audio.** A filter chain associated with a card satisfies
|
||||
//! both. So the discriminator is `factory.name` on an **allowlist** of
|
||||
//! real hardware-PCM factories, never a substring or a denylist: an unknown
|
||||
//! factory is not a device.
|
||||
//! - The backing Device must actually have been observed. A node that claims
|
||||
//! a `device.id` we have not yet resolved is **withheld**, not admitted with
|
||||
//! a provisional `false` — a provisional `false` during the not-ready
|
||||
//! window fuses sink and mic on the shared session client and that fusion
|
||||
//! can persist as sticky over-exclusion (round-3 finding 3).
|
||||
|
||||
use crate::host::taint::snapshot::GlobalId;
|
||||
|
||||
/// Factory names that positively identify a passive hardware-PCM terminal.
|
||||
///
|
||||
/// **An allowlist, deliberately.** Membership *removes* protections, so the
|
||||
/// safe error direction is to leave a genuine-but-unlisted device off the
|
||||
/// list (it merely keeps its owner keys — over-exclusion, no echo). Adding a
|
||||
/// backend here is a security-relevant change and wants the same measurement
|
||||
/// the ALSA entries got (snapshot.rs `session_device` contract: the target
|
||||
/// box's five ALSA nodes carry `factory.name=api.alsa.pcm.{sink,source}`; the
|
||||
/// three `support.null-audio-sink` nodes carry neither).
|
||||
///
|
||||
/// `support.null-audio-sink`, `*.loopback`, and any filter factory are
|
||||
/// intentionally **absent**: those forward audio, which is exactly the shape
|
||||
/// this feature must be able to exclude.
|
||||
///
|
||||
/// ⚠️ **ALSA only, and only these two, because they are the only factories
|
||||
/// measured on the target box.** BlueZ was previously listed here as
|
||||
/// `api.bluez5.pcm.{sink,source}` — those are invented; the real BlueZ
|
||||
/// terminals are `api.bluez5.media.{sink,source}` with profile aliases
|
||||
/// (Codex phase-3 review, finding 5). Rather than allowlist an unmeasured
|
||||
/// guess, BlueZ is left off entirely: a real Bluetooth sink then keeps its
|
||||
/// owner keys (over-exclusion — safe). Add BlueZ back only with a *measured*
|
||||
/// factory name and a fixture.
|
||||
const HARDWARE_PCM_FACTORIES: &[&str] = &[
|
||||
// ALSA — measured on the target box.
|
||||
"api.alsa.pcm.sink",
|
||||
"api.alsa.pcm.source",
|
||||
];
|
||||
|
||||
/// ALSA drivers that expose a hardware-PCM `factory.name` but are **not**
|
||||
/// passive terminals — audio written in reappears on their capture side
|
||||
/// through a path the PipeWire Link graph cannot see, so classifying them
|
||||
/// `session_device` (which drops owner keys and the fail-closed backstop)
|
||||
/// would let tainted audio loop back untainted (Codex phase-3 review,
|
||||
/// finding 2). `factory.name` alone cannot distinguish these from a real
|
||||
/// card — `snd_aloop` presents as `api.alsa.pcm.{sink,source}` exactly like
|
||||
/// `snd_hda_intel` — so a real ALSA terminal must present an `alsa.driver_name`
|
||||
/// that is **present and not on this denylist**; a missing driver fails closed
|
||||
/// (see [`classify`]). `snd_dummy` is intentionally absent: it is virtual but
|
||||
/// does not couple playback to capture, so it is not a loopback hazard.
|
||||
const NON_TERMINAL_ALSA_DRIVERS: &[&str] = &["snd_aloop"];
|
||||
|
||||
/// The three node properties the classifier reads, exactly as the adapter
|
||||
/// parsed them off the Node global. Kept separate from
|
||||
/// [`super::super::taint::snapshot::NodeProps`] because these feed the
|
||||
/// *decision* whose output is the `session_device` field — they are inputs,
|
||||
/// not part of the graph the engine reasons over.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct DeviceClaim {
|
||||
/// `device.id` — the Device this node belongs to, if any. Absent on
|
||||
/// `Stream/*` nodes, which is exactly why their absence means "not a
|
||||
/// device", not "unknown".
|
||||
pub device_id: Option<GlobalId>,
|
||||
/// `device.api` — the access API of that Device (e.g. `alsa`, `bluez5`).
|
||||
/// Its mere presence is **not** sufficient (a card-associated filter has
|
||||
/// it too); required only as a corroborating signal alongside the factory
|
||||
/// allowlist.
|
||||
pub device_api: Option<String>,
|
||||
/// `factory.name` — the discriminator. Only an allowlisted hardware-PCM
|
||||
/// factory earns `session_device`.
|
||||
pub factory_name: Option<String>,
|
||||
/// `alsa.driver_name` — the kernel driver behind an ALSA node (e.g.
|
||||
/// `snd_hda_intel`, `snd_usb_audio`, `snd_aloop`). Needed because the
|
||||
/// factory allowlist cannot tell a real card from a loopback driver that
|
||||
/// shares the same factory. `session_device` requires this to be
|
||||
/// **present and not** on [`NON_TERMINAL_ALSA_DRIVERS`]; a driver on the
|
||||
/// denylist, or an absent value, both fail closed (see [`classify`]).
|
||||
/// May be absent on non-ALSA backends or on version pairings that do not
|
||||
/// copy `alsa.*` onto the node.
|
||||
pub alsa_driver_name: Option<String>,
|
||||
}
|
||||
|
||||
/// The outcome of classifying one node's device claim.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Classification {
|
||||
/// No `device.id` — a `Stream/*` node. Admit with `session_device=false`.
|
||||
NotADevice,
|
||||
/// A `device.id` is claimed but the backing Device has not been resolved
|
||||
/// yet. **Withhold the node and keep the readiness epoch not-ready**;
|
||||
/// re-classify when the Device is observed.
|
||||
Withhold { device_id: GlobalId },
|
||||
/// Positively a passive hardware terminal. Admit with
|
||||
/// `session_device=true`.
|
||||
SessionDevice,
|
||||
/// Backed by a *resolved* Device but not a hardware-PCM terminal — a
|
||||
/// filter or virtual node on a card, an unknown factory, or a Device with
|
||||
/// no `device.api`. Admit with `session_device=false` (fail closed).
|
||||
NotSessionDevice,
|
||||
}
|
||||
|
||||
/// Classify a node's device claim.
|
||||
///
|
||||
/// `device_resolved` is whether [`DeviceClaim::device_id`] has been observed
|
||||
/// as a Device global; it is only consulted when a `device_id` is present.
|
||||
/// Pure: the model supplies `device_resolved` from its resolved-Device set,
|
||||
/// and the I/O of *binding* the Device lives in the adapter.
|
||||
pub fn classify(claim: &DeviceClaim, device_resolved: bool) -> Classification {
|
||||
let Some(device_id) = claim.device_id else {
|
||||
// No backing Device: a stream. Not withheld, not a device.
|
||||
return Classification::NotADevice;
|
||||
};
|
||||
if !device_resolved {
|
||||
// Backed by a Device we have not seen — the one case that blocks
|
||||
// readiness. A provisional answer here is the leak the contract
|
||||
// forbids.
|
||||
return Classification::Withhold { device_id };
|
||||
}
|
||||
let on_factory_allowlist = claim
|
||||
.factory_name
|
||||
.as_deref()
|
||||
.is_some_and(|f| HARDWARE_PCM_FACTORIES.contains(&f));
|
||||
// A **present, non-denied** ALSA driver is required — absence fails closed
|
||||
// (Codex phase-3 re-review). `alsa.driver_name` is not copied onto the
|
||||
// node on every PipeWire/WirePlumber version pairing (PipeWire ≥1.2.6
|
||||
// stopped overwriting node props with card props; WirePlumber only began
|
||||
// copying `alsa.*` onto nodes in 0.5.13), so a *missing* value must not be
|
||||
// read as "not a loopback" — that is exactly the hole an `snd_aloop` node
|
||||
// without the property would slip through. A real card whose node lacks
|
||||
// the driver is instead over-excluded (keeps its owner keys — safe);
|
||||
// recovering `session_device` for it needs reading the driver from the
|
||||
// backing Device global, which is owed to a later round.
|
||||
let driver_ok = claim
|
||||
.alsa_driver_name
|
||||
.as_deref()
|
||||
.is_some_and(|d| !NON_TERMINAL_ALSA_DRIVERS.contains(&d));
|
||||
let is_hardware_pcm = claim.device_api.is_some() && on_factory_allowlist && driver_ok;
|
||||
if is_hardware_pcm {
|
||||
Classification::SessionDevice
|
||||
} else {
|
||||
// Resolved, but not positively a terminal: fail closed to false so
|
||||
// the node keeps its owner keys and its backstop.
|
||||
Classification::NotSessionDevice
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
//! The registry observer's **pure core** (impl plan §4, phase 3).
|
||||
//!
|
||||
//! This is my half of the phase-3 split: a reducer that folds a stream of
|
||||
//! typed [`RegEvent`]s into a live model of the PipeWire graph and projects
|
||||
//! the [`GraphSnapshot`] + context the taint engine (phase 2) consumes. **No
|
||||
//! PipeWire types appear here** — the I/O adapter (Codex's half) translates
|
||||
//! live registry callbacks, Link/Device binds, `/proc` reads, and the
|
||||
//! `core.sync`/`done` round-trip into these events and feeds them in. Every
|
||||
//! test in this module builds the event stream by hand.
|
||||
//!
|
||||
//! Three things this core is shaped to get right, each an exit-gate row:
|
||||
//!
|
||||
//! - **Removal by recycled id.** `global_remove` names only a 32-bit global
|
||||
//! id, and those recycle. The model keeps an insertion-ordered index per id
|
||||
//! so a removal accounts for the *oldest* generation first, and the
|
||||
//! snapshot projection treats any id still claimed by two live objects as
|
||||
//! [`IdLookup::Ambiguous`] — fail closed (v3.4 §6.1.3).
|
||||
//! - **The readiness epoch.** `graph_ready` is false until the initial graph
|
||||
//! is fully observed: the server has synced **and** no binds/withheld nodes
|
||||
//! remain outstanding. A bounded timeout makes it fail closed. It gates
|
||||
//! sticky *retirement* only; withholding after completion is per-object.
|
||||
//! - **Withholding on unresolved devices.** A node claiming a `device.id`
|
||||
//! whose Device we have not observed is held out of the snapshot entirely
|
||||
//! rather than admitted with a provisional `session_device` (see
|
||||
//! [`classify`]).
|
||||
//!
|
||||
//! **Two accepted limitations (Codex phase-3 review, findings 3 and 4), both
|
||||
//! low-reachability, owed to a later hardening round:**
|
||||
//!
|
||||
//! - *A Link dropped for a missing `object.serial`/props is unrepresented.*
|
||||
//! The adapter drops such a global before it reaches [`RegistryModel`], so
|
||||
//! readiness can reach `Complete` while permanently omitting that Link — an
|
||||
//! invisible edge that could hide tainted ancestry. **Not reachable in
|
||||
//! practice:** PipeWire's native protocol defines `object.serial` as the
|
||||
//! unique identity every global carries, so a Link without one requires a
|
||||
//! protocol/server failure, not ordinary churn. (The live gate is
|
||||
//! consistent with this but does not *prove* it — it only counts Links the
|
||||
//! strict parser already admitted.) A full fix needs a pure
|
||||
//! "required-observation-failed" token that holds readiness false; deferred
|
||||
//! rather than built for a case that does not occur.
|
||||
//! - *Removal generation ordering assumes no removal is silently lost.* On a
|
||||
//! recycled id with two live claimants, [`Self::on_removed`] retires the
|
||||
//! oldest generation first; if the *first* generation's removal was never
|
||||
//! delivered, a later removal is misattributed. PipeWire's registry does not
|
||||
//! silently drop `global_remove`, so this needs callback loss to trigger.
|
||||
//! The snapshot treats the two-claimant window as [`IdLookup::Ambiguous`]
|
||||
//! (fail closed) meanwhile.
|
||||
|
||||
#![allow(dead_code)] // Wired by the phase-3 adapter (Codex's half) and consumed by later phases.
|
||||
|
||||
pub mod adapter;
|
||||
pub mod classify;
|
||||
pub mod pulse_pid;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use crate::host::taint::snapshot::{
|
||||
ClientSnapshot, GlobalId, GraphSnapshot, LinkSnapshot, MediaRole, NodeProps, NodeSnapshot,
|
||||
PortSnapshot, Serial,
|
||||
};
|
||||
use classify::{Classification, DeviceClaim};
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
|
||||
/// A monotonic millisecond clock value, supplied by the adapter via
|
||||
/// [`RegEvent::Tick`]. Kept as a bare integer rather than
|
||||
/// [`std::time::Instant`] so the readiness timeout is deterministic in tests.
|
||||
pub type Millis = u64;
|
||||
|
||||
/// A Node as observed off the registry, before `session_device` has been
|
||||
/// decided. The adapter fills [`NodeProps`] with everything it can parse and
|
||||
/// leaves `session_device` at its `false` default; the model overwrites it
|
||||
/// from the [`classify`] result once the backing Device (if any) is resolved.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct NodeObservation {
|
||||
pub serial: Serial,
|
||||
pub id: GlobalId,
|
||||
pub name: Option<String>,
|
||||
pub role: MediaRole,
|
||||
pub props: NodeProps,
|
||||
pub device_claim: DeviceClaim,
|
||||
}
|
||||
|
||||
/// The four endpoint references a Link carries. Node endpoints are required —
|
||||
/// a Link with unknown nodes is useless — so this whole struct is what the
|
||||
/// adapter must resolve (from the global's props if present, else by binding
|
||||
/// `LinkInfoRef`, the correctness path) before a Link enters the snapshot.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct LinkEndpoints {
|
||||
pub output_node: GlobalId,
|
||||
pub input_node: GlobalId,
|
||||
pub output_port: Option<GlobalId>,
|
||||
pub input_port: Option<GlobalId>,
|
||||
}
|
||||
|
||||
/// A typed observation of the live graph. The adapter produces these; the
|
||||
/// model consumes them in [`RegistryModel::apply`].
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RegEvent {
|
||||
/// A Node global appeared. Admitted immediately unless it claims an
|
||||
/// unresolved Device (then withheld — see [`classify`]).
|
||||
NodeAdded(NodeObservation),
|
||||
/// A Port global appeared.
|
||||
PortAdded(PortSnapshot),
|
||||
/// A Client global appeared. Feeds pulse-PID derivation via `sec_pid`.
|
||||
ClientAdded(ClientSnapshot),
|
||||
/// A Device global appeared. Resolves any nodes withheld on its id.
|
||||
DeviceAdded { id: GlobalId },
|
||||
/// A Link global appeared. `endpoints` is `Some` when the global carried
|
||||
/// them (the optimisation) and `None` when the adapter must bind to learn
|
||||
/// them (the correctness path) — the latter is an outstanding obligation
|
||||
/// until a matching [`RegEvent::LinkEndpointsResolved`] arrives.
|
||||
LinkAdded {
|
||||
serial: Serial,
|
||||
id: GlobalId,
|
||||
endpoints: Option<LinkEndpoints>,
|
||||
},
|
||||
/// The bind-`LinkInfoRef` fallback resolved a Link's endpoints.
|
||||
LinkEndpointsResolved {
|
||||
serial: Serial,
|
||||
endpoints: LinkEndpoints,
|
||||
},
|
||||
/// The adapter read `/proc/<pid>/comm` (`None` = the read failed / the
|
||||
/// process is gone). Validates the pulse-PID candidate.
|
||||
ProcCommProbed { pid: u32, comm: Option<String> },
|
||||
/// Any global was removed. Only its 32-bit id is known.
|
||||
Removed { id: GlobalId },
|
||||
/// A `core.sync()` issued after the initial enumeration completed its
|
||||
/// round-trip (`done`). One half of readiness; the other is that no
|
||||
/// binds/withheld nodes are still outstanding.
|
||||
ServerSynced,
|
||||
/// A monotonic clock sample. Drives the readiness timeout only.
|
||||
Tick { now: Millis },
|
||||
}
|
||||
|
||||
/// What kind of observation drove a projection.
|
||||
///
|
||||
/// Derived from the event itself ([`RegEvent::kind`]) rather than passed
|
||||
/// alongside it, so a consumer's view of "was this a real graph change?" cannot
|
||||
/// disagree with what the model was actually fed. The distinction matters to the
|
||||
/// phase-5 audit twice over: ticks arrive at a constant rate and would inflate
|
||||
/// any measured graph-event rate, and a record that is identical to the previous
|
||||
/// one is worth suppressing on a tick but never on a graph event.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EventKind {
|
||||
/// A registry observation: an add, a removal, a link resolution, a `/proc`
|
||||
/// probe, or the server sync.
|
||||
Graph,
|
||||
/// The periodic clock sample. Carries no graph information; it exists so the
|
||||
/// readiness timeout and the AEC validation deadline have a clock.
|
||||
Tick,
|
||||
}
|
||||
|
||||
impl EventKind {
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::Graph => "graph",
|
||||
Self::Tick => "tick",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RegEvent {
|
||||
pub fn kind(&self) -> EventKind {
|
||||
match self {
|
||||
Self::Tick { .. } => EventKind::Tick,
|
||||
_ => EventKind::Graph,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which slot in the id index a live object occupies. `global_remove` gives
|
||||
/// only the id, so the index remembers what each id currently holds. A Node
|
||||
/// slot's serial may live in either the admitted or the withheld map.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Slot {
|
||||
Node(Serial),
|
||||
Port(Serial),
|
||||
Link(Serial),
|
||||
Client(Serial),
|
||||
Device,
|
||||
}
|
||||
|
||||
/// The readiness epoch. A one-time transition out of [`Readiness::Waiting`];
|
||||
/// both terminal states are sticky (a completed graph is not un-completed by
|
||||
/// later per-object withholding, and a timed-out observer stays fail-closed
|
||||
/// for its lifetime).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Readiness {
|
||||
/// The initial enumeration is still in flight.
|
||||
Waiting,
|
||||
/// The initial enumeration finished at least once (server synced with no
|
||||
/// obligations then outstanding). **Sticky** — later per-object
|
||||
/// withholding does not revert it. Note this is *not* the same as
|
||||
/// [`RegistryModel::graph_ready`], which additionally requires no *current*
|
||||
/// obligation (Codex finding 1); `Complete` only records that the epoch
|
||||
/// was reached.
|
||||
Complete,
|
||||
/// The bounded deadline passed with obligations outstanding.
|
||||
/// `graph_ready` stays false — fail closed.
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
/// The pure handoff to the taint engine: a coherent [`GraphSnapshot`] plus the
|
||||
/// two context fields phase 3 owns. The caller merges these into
|
||||
/// [`crate::host::taint::ExclusionCtx`] alongside `aec_module_id` (phase 4)
|
||||
/// and `pixelpass_owned` (pixelpass's own tracking).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Projection {
|
||||
pub snapshot: GraphSnapshot,
|
||||
pub pipewire_pulse_pid: Option<u32>,
|
||||
pub graph_ready: bool,
|
||||
/// The sticky readiness epoch behind `graph_ready`. Carried so a consumer
|
||||
/// can tell the three not-ready causes apart — enumeration still in flight
|
||||
/// ([`Readiness::Waiting`]), a fail-closed timeout ([`Readiness::TimedOut`]),
|
||||
/// or a completed epoch momentarily blocked on a current obligation
|
||||
/// ([`Readiness::Complete`] with `graph_ready == false`). `graph_ready`
|
||||
/// alone collapses all three into "no". The phase-5 audit reports it as the
|
||||
/// epoch column; nothing gates on it.
|
||||
pub readiness: Readiness,
|
||||
}
|
||||
|
||||
/// The live model. Folds [`RegEvent`]s; project with [`RegistryModel::project`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RegistryModel {
|
||||
// Admitted objects, keyed by their never-recycled serial.
|
||||
nodes: BTreeMap<Serial, NodeSnapshot>,
|
||||
ports: BTreeMap<Serial, PortSnapshot>,
|
||||
links: BTreeMap<Serial, LinkSnapshot>,
|
||||
clients: BTreeMap<Serial, ClientSnapshot>,
|
||||
|
||||
/// Nodes held out of the snapshot pending their Device's resolution.
|
||||
withheld: BTreeMap<Serial, NodeObservation>,
|
||||
/// Links whose endpoints the adapter is still binding; the id is kept so
|
||||
/// removal and resolution can find them.
|
||||
pending_links: BTreeMap<Serial, GlobalId>,
|
||||
|
||||
/// Live Device global ids, ref-counted so a recycled id is only
|
||||
/// considered resolved while a Device actually holds it.
|
||||
resolved_devices: BTreeMap<GlobalId, usize>,
|
||||
|
||||
/// Insertion-ordered holders of each live global id. `global_remove`
|
||||
/// accounts for the oldest generation first (v3.4 §6.1.3).
|
||||
live_ids: BTreeMap<GlobalId, VecDeque<Slot>>,
|
||||
|
||||
/// `/proc/<pid>/comm` reads keyed by pid, for pulse-PID validation.
|
||||
probed_comm: BTreeMap<u32, Option<String>>,
|
||||
|
||||
server_synced: bool,
|
||||
readiness: Readiness,
|
||||
deadline: Millis,
|
||||
last_now: Millis,
|
||||
}
|
||||
|
||||
impl RegistryModel {
|
||||
/// `now` seeds the clock; `timeout` is the readiness budget. The deadline
|
||||
/// is `now + timeout`; a [`RegEvent::Tick`] at or past it while still
|
||||
/// [`Readiness::Waiting`] fails the epoch closed.
|
||||
pub fn new(now: Millis, timeout: Millis) -> Self {
|
||||
Self {
|
||||
nodes: BTreeMap::new(),
|
||||
ports: BTreeMap::new(),
|
||||
links: BTreeMap::new(),
|
||||
clients: BTreeMap::new(),
|
||||
withheld: BTreeMap::new(),
|
||||
pending_links: BTreeMap::new(),
|
||||
resolved_devices: BTreeMap::new(),
|
||||
live_ids: BTreeMap::new(),
|
||||
probed_comm: BTreeMap::new(),
|
||||
server_synced: false,
|
||||
readiness: Readiness::Waiting,
|
||||
deadline: now.saturating_add(timeout),
|
||||
last_now: now,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn readiness(&self) -> Readiness {
|
||||
self.readiness
|
||||
}
|
||||
|
||||
/// Whether the graph is trustworthy enough to make eligibility and sticky
|
||||
/// **retirement** decisions right now.
|
||||
///
|
||||
/// This is **dynamic**, not the sticky [`Readiness::Complete`] flag: it is
|
||||
/// true only when the initial enumeration has completed **and** there are
|
||||
/// no current obligations outstanding (a node withheld on an unresolved
|
||||
/// Device, or a Link still being bound). The distinction is the fix for
|
||||
/// Codex phase-3 review finding 1: a Link whose endpoints are still
|
||||
/// resolving is an **invisible edge** — it is absent from the snapshot,
|
||||
/// not merely dangling — so a decision made while one exists can miss real
|
||||
/// tainted ancestry and wrongly report a candidate eligible. Unresolved
|
||||
/// ancestry ⇒ fail closed is the governing invariant (v3.4 §6.1), and an
|
||||
/// unresolved Link is unresolved ancestry, so `graph_ready` must drop back
|
||||
/// to false whenever one is pending — even after the initial epoch.
|
||||
///
|
||||
/// [`Readiness::Complete`] stays sticky (it records that the initial
|
||||
/// enumeration happened, for logging and to distinguish "not started" from
|
||||
/// "momentarily churning"); `graph_ready` layers the dynamic obligation
|
||||
/// check on top. Downstream (phase 6) may debounce the brief blips a
|
||||
/// normal Link bind causes; the observer's job is to report the truth.
|
||||
pub fn graph_ready(&self) -> bool {
|
||||
matches!(self.readiness, Readiness::Complete) && !self.obligations_outstanding()
|
||||
}
|
||||
|
||||
/// The pulse-PID candidate the adapter should be probing (`None` = no
|
||||
/// repeated `sec_pid`, nothing to probe). Exposed so the adapter re-probes
|
||||
/// only when the candidate changes.
|
||||
pub fn pulse_pid_candidate(&self) -> Option<u32> {
|
||||
let clients: Vec<ClientSnapshot> = self.clients.values().cloned().collect();
|
||||
pulse_pid::candidate(&clients)
|
||||
}
|
||||
|
||||
/// Fold one observation into the model.
|
||||
pub fn apply(&mut self, event: RegEvent) {
|
||||
match event {
|
||||
RegEvent::NodeAdded(obs) => self.on_node_added(obs),
|
||||
RegEvent::PortAdded(port) => {
|
||||
self.push_id(port.id, Slot::Port(port.serial));
|
||||
self.ports.insert(port.serial, port);
|
||||
}
|
||||
RegEvent::ClientAdded(client) => {
|
||||
self.push_id(client.id, Slot::Client(client.serial));
|
||||
self.clients.insert(client.serial, client);
|
||||
// A new client can change the pulse candidate; the adapter
|
||||
// learns that via `pulse_pid_candidate`. No readiness effect.
|
||||
}
|
||||
RegEvent::DeviceAdded { id } => self.on_device_added(id),
|
||||
RegEvent::LinkAdded {
|
||||
serial,
|
||||
id,
|
||||
endpoints,
|
||||
} => self.on_link_added(serial, id, endpoints),
|
||||
RegEvent::LinkEndpointsResolved { serial, endpoints } => {
|
||||
self.on_link_resolved(serial, endpoints)
|
||||
}
|
||||
RegEvent::ProcCommProbed { pid, comm } => {
|
||||
self.probed_comm.insert(pid, comm);
|
||||
}
|
||||
RegEvent::Removed { id } => self.on_removed(id),
|
||||
RegEvent::ServerSynced => {
|
||||
self.server_synced = true;
|
||||
self.maybe_complete();
|
||||
}
|
||||
RegEvent::Tick { now } => {
|
||||
self.last_now = now;
|
||||
self.maybe_timeout(now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_node_added(&mut self, obs: NodeObservation) {
|
||||
self.push_id(obs.id, Slot::Node(obs.serial));
|
||||
let resolved = obs
|
||||
.device_claim
|
||||
.device_id
|
||||
.is_some_and(|id| self.device_resolved(id));
|
||||
match classify::classify(&obs.device_claim, resolved) {
|
||||
Classification::Withhold { .. } => {
|
||||
self.withheld.insert(obs.serial, obs);
|
||||
}
|
||||
Classification::SessionDevice => self.admit_node(obs, true),
|
||||
Classification::NotADevice | Classification::NotSessionDevice => {
|
||||
self.admit_node(obs, false)
|
||||
}
|
||||
}
|
||||
// Withholding a node adds an obligation; admitting one can never
|
||||
// complete readiness on its own, but re-check is cheap and keeps the
|
||||
// invariant local.
|
||||
self.maybe_complete();
|
||||
}
|
||||
|
||||
fn admit_node(&mut self, obs: NodeObservation, session_device: bool) {
|
||||
let mut props = obs.props;
|
||||
props.session_device = session_device;
|
||||
self.nodes.insert(
|
||||
obs.serial,
|
||||
NodeSnapshot {
|
||||
serial: obs.serial,
|
||||
id: obs.id,
|
||||
name: obs.name,
|
||||
role: obs.role,
|
||||
props,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn on_device_added(&mut self, id: GlobalId) {
|
||||
self.push_id(id, Slot::Device);
|
||||
*self.resolved_devices.entry(id).or_insert(0) += 1;
|
||||
// Admit every node that was withheld waiting on exactly this Device.
|
||||
let ready: Vec<Serial> = self
|
||||
.withheld
|
||||
.iter()
|
||||
.filter(|(_, obs)| obs.device_claim.device_id == Some(id))
|
||||
.map(|(&serial, _)| serial)
|
||||
.collect();
|
||||
for serial in ready {
|
||||
if let Some(obs) = self.withheld.remove(&serial) {
|
||||
// Resolved now, so classify yields a terminal answer, never
|
||||
// Withhold again.
|
||||
let session_device = matches!(
|
||||
classify::classify(&obs.device_claim, true),
|
||||
Classification::SessionDevice
|
||||
);
|
||||
self.admit_node(obs, session_device);
|
||||
}
|
||||
}
|
||||
self.maybe_complete();
|
||||
}
|
||||
|
||||
fn on_link_added(&mut self, serial: Serial, id: GlobalId, endpoints: Option<LinkEndpoints>) {
|
||||
self.push_id(id, Slot::Link(serial));
|
||||
match endpoints {
|
||||
Some(e) => {
|
||||
self.links.insert(serial, link_snapshot(serial, id, e));
|
||||
}
|
||||
None => {
|
||||
// Correctness path: withhold the Link until the bind fallback
|
||||
// resolves it. Counts as an outstanding obligation.
|
||||
self.pending_links.insert(serial, id);
|
||||
}
|
||||
}
|
||||
self.maybe_complete();
|
||||
}
|
||||
|
||||
fn on_link_resolved(&mut self, serial: Serial, endpoints: LinkEndpoints) {
|
||||
// `remove` also guards against a stale resolution for a Link already
|
||||
// gone: unknown serial ⇒ ignore.
|
||||
if let Some(id) = self.pending_links.remove(&serial) {
|
||||
self.links
|
||||
.insert(serial, link_snapshot(serial, id, endpoints));
|
||||
self.maybe_complete();
|
||||
}
|
||||
}
|
||||
|
||||
fn on_removed(&mut self, id: GlobalId) {
|
||||
let Some(queue) = self.live_ids.get_mut(&id) else {
|
||||
tracing::warn!(global_id = id.0, "observer: remove for an id we never saw");
|
||||
return;
|
||||
};
|
||||
// Oldest generation first — the id may be shared during a
|
||||
// missed-removal window.
|
||||
let slot = queue.pop_front();
|
||||
if queue.is_empty() {
|
||||
self.live_ids.remove(&id);
|
||||
}
|
||||
match slot {
|
||||
Some(Slot::Node(serial)) => {
|
||||
if self.nodes.remove(&serial).is_none() {
|
||||
// Was still withheld — drop the obligation.
|
||||
self.withheld.remove(&serial);
|
||||
}
|
||||
}
|
||||
Some(Slot::Port(serial)) => {
|
||||
self.ports.remove(&serial);
|
||||
}
|
||||
Some(Slot::Link(serial)) => {
|
||||
self.links.remove(&serial);
|
||||
self.pending_links.remove(&serial);
|
||||
}
|
||||
Some(Slot::Client(serial)) => {
|
||||
self.clients.remove(&serial);
|
||||
}
|
||||
Some(Slot::Device) => {
|
||||
if let Some(count) = self.resolved_devices.get_mut(&id) {
|
||||
*count -= 1;
|
||||
if *count == 0 {
|
||||
self.resolved_devices.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(global_id = id.0, "observer: empty id slot on remove");
|
||||
}
|
||||
}
|
||||
// A removal can drain the last obligation (a withheld node or pending
|
||||
// link vanished before it resolved).
|
||||
self.maybe_complete();
|
||||
}
|
||||
|
||||
fn push_id(&mut self, id: GlobalId, slot: Slot) {
|
||||
self.live_ids.entry(id).or_default().push_back(slot);
|
||||
}
|
||||
|
||||
fn device_resolved(&self, id: GlobalId) -> bool {
|
||||
self.resolved_devices.get(&id).is_some_and(|&n| n > 0)
|
||||
}
|
||||
|
||||
/// Every obligation that must clear before the initial graph is trusted:
|
||||
/// no node withheld on an unresolved Device, no Link awaiting its bind.
|
||||
fn obligations_outstanding(&self) -> bool {
|
||||
!self.withheld.is_empty() || !self.pending_links.is_empty()
|
||||
}
|
||||
|
||||
/// Completion needs no clock — only the sync flag and an empty obligation
|
||||
/// set — so it may fire on any mutating event. Sticky once reached.
|
||||
fn maybe_complete(&mut self) {
|
||||
if self.readiness != Readiness::Waiting {
|
||||
return;
|
||||
}
|
||||
if self.server_synced && !self.obligations_outstanding() {
|
||||
self.readiness = Readiness::Complete;
|
||||
tracing::info!("observer: readiness epoch reached (synced + no obligations)");
|
||||
}
|
||||
}
|
||||
|
||||
/// Only the timeout consults the clock.
|
||||
fn maybe_timeout(&mut self, now: Millis) {
|
||||
if self.readiness != Readiness::Waiting {
|
||||
return;
|
||||
}
|
||||
if now >= self.deadline {
|
||||
self.readiness = Readiness::TimedOut;
|
||||
tracing::warn!(
|
||||
withheld = self.withheld.len(),
|
||||
pending_links = self.pending_links.len(),
|
||||
"observer: readiness epoch timed out with obligations outstanding — fail closed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// pipewire-pulse's PID from the current clients, validated against the
|
||||
/// probed `comm`. `None` whenever anything is ambiguous or unconfirmed —
|
||||
/// the safe answer (key 4 unusable).
|
||||
fn pulse_pid(&self) -> Option<u32> {
|
||||
let candidate = self.pulse_pid_candidate()?;
|
||||
let comm = self.probed_comm.get(&candidate).and_then(|c| c.as_deref());
|
||||
pulse_pid::validate(candidate, comm)
|
||||
}
|
||||
|
||||
/// Project the current state into the taint engine's inputs.
|
||||
pub fn project(&self) -> Projection {
|
||||
let snapshot = GraphSnapshot::new(
|
||||
self.nodes.values().cloned().collect(),
|
||||
self.ports.values().cloned().collect(),
|
||||
self.links.values().cloned().collect(),
|
||||
self.clients.values().cloned().collect(),
|
||||
);
|
||||
Projection {
|
||||
snapshot,
|
||||
pipewire_pulse_pid: self.pulse_pid(),
|
||||
graph_ready: self.graph_ready(),
|
||||
readiness: self.readiness,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn link_snapshot(serial: Serial, id: GlobalId, e: LinkEndpoints) -> LinkSnapshot {
|
||||
LinkSnapshot {
|
||||
serial,
|
||||
id,
|
||||
output_node: e.output_node,
|
||||
input_node: e.input_node,
|
||||
output_port: e.output_port,
|
||||
input_port: e.input_port,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Deriving pipewire-pulse's own PID — pure, no PipeWire and no `/proc` I/O.
|
||||
//!
|
||||
//! The owner bridge's key 4 is `application.process.id`. For a stream created
|
||||
//! by a **Pulse-emulated** client that PID is *pipewire-pulse's own*, shared
|
||||
//! verbatim across every unrelated Pulse app, so bridging on it would fuse
|
||||
//! every Pulse module into one tainted owner (design v3.4 §5.2 correction 5,
|
||||
//! §6.1.2). The engine therefore needs to know that one PID so it can refuse
|
||||
//! to bridge on it — and **every** way of deriving it can fail, in which case
|
||||
//! the safe answer is `None`: key 4 becomes unusable (coarser, never wrong).
|
||||
//!
|
||||
//! The derivation is split into two pure stages so the I/O — reading
|
||||
//! `/proc/<pid>/comm` — stays in the adapter:
|
||||
//!
|
||||
//! 1. [`candidate`] finds the PID that *looks* like pulse from the graph
|
||||
//! alone: the `pipewire.sec.pid` value shared across multiple Clients.
|
||||
//! Native PipeWire clients carry their own distinct PID; only the
|
||||
//! Pulse shim repeats one value, so a repeated value is the signal.
|
||||
//! 2. [`validate`] confirms that candidate against the `comm` the adapter
|
||||
//! read from `/proc`. This is what closes **PID reuse**: a recycled PID
|
||||
//! that coincidentally repeats in the graph is rejected because
|
||||
//! `/proc/<pid>/comm` now names a different process.
|
||||
//!
|
||||
//! Any failure at either stage — no repeated value, two repeated values,
|
||||
//! the property missing, `/proc` gone, a `comm` mismatch — yields `None`.
|
||||
|
||||
use crate::host::taint::snapshot::ClientSnapshot;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// The kernel `comm` of the pipewire-pulse process. `comm` is truncated to
|
||||
/// 15 bytes by the kernel; `pipewire-pulse` is 14 bytes, so it is exact —
|
||||
/// and exact is the only safe match, since a prefix match would accept a
|
||||
/// recycled PID belonging to e.g. `pipewire-pulseX`.
|
||||
const PULSE_COMM: &str = "pipewire-pulse";
|
||||
|
||||
/// Stage 1: the PID that looks like pipewire-pulse from the client graph.
|
||||
///
|
||||
/// Returns `Some(pid)` only when **exactly one** `pipewire.sec.pid` value is
|
||||
/// shared by two or more clients. Rationale, matched to the failure matrix:
|
||||
///
|
||||
/// - **consistent** — one value repeats, the rest (native clients) are
|
||||
/// distinct ⇒ that value.
|
||||
/// - **inconsistent** — two or more values each repeat ⇒ we cannot tell which
|
||||
/// is pulse ⇒ `None`.
|
||||
/// - **missing property** — the Pulse clients carry no `sec_pid` ⇒ nothing
|
||||
/// repeats ⇒ `None`.
|
||||
///
|
||||
/// A count threshold of two is deliberate: a single client carrying a PID is
|
||||
/// indistinguishable from a lone native app, and pulse always mints many.
|
||||
pub fn candidate(clients: &[ClientSnapshot]) -> Option<u32> {
|
||||
let mut counts: BTreeMap<u32, usize> = BTreeMap::new();
|
||||
for client in clients {
|
||||
if let Some(pid) = client.sec_pid {
|
||||
*counts.entry(pid).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Every PID seen on 2+ clients is a pulse candidate. If there is exactly
|
||||
// one such PID we trust it; zero or several ⇒ fail closed.
|
||||
let mut repeated = counts.iter().filter(|&(_, &n)| n >= 2).map(|(&pid, _)| pid);
|
||||
let first = repeated.next()?;
|
||||
if repeated.next().is_some() {
|
||||
// Ambiguous: more than one value repeats.
|
||||
return None;
|
||||
}
|
||||
Some(first)
|
||||
}
|
||||
|
||||
/// Stage 2: confirm the candidate against the `comm` read from
|
||||
/// `/proc/<candidate>/comm`.
|
||||
///
|
||||
/// `comm` is `None` when the adapter's read failed — the `/proc` entry is
|
||||
/// gone (the process exited between derivation and probe) — which is itself a
|
||||
/// reason to fail closed. A present-but-different `comm` is the **PID reuse**
|
||||
/// guard: the number is live but now belongs to someone else.
|
||||
pub fn validate(candidate: u32, comm: Option<&str>) -> Option<u32> {
|
||||
match comm {
|
||||
Some(PULSE_COMM) => Some(candidate),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The two stages composed, for callers that already hold the probed `comm`.
|
||||
/// The model keeps them separate (it recomputes the candidate as clients
|
||||
/// churn, and only re-probes when the candidate *changes*), so this is a
|
||||
/// convenience for tests and for the fully-resolved path.
|
||||
pub fn derive(clients: &[ClientSnapshot], comm_of: impl Fn(u32) -> Option<String>) -> Option<u32> {
|
||||
let candidate = candidate(clients)?;
|
||||
validate(candidate, comm_of(candidate).as_deref())
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
//! Pure exit-gate coverage for the phase-3 observer core.
|
||||
//!
|
||||
//! Five of the six exit-gate rows live here (the sixth — a live create/destroy
|
||||
//! topology diff — needs the daemon and belongs to the adapter). Each test
|
||||
//! builds the [`RegEvent`] stream by hand; nothing links PipeWire.
|
||||
//!
|
||||
//! Carrying the phase-0a lesson: the id/pid/serial tests use **interior**
|
||||
//! values, not just 1 and a huge number, so a middle-of-range mistake cannot
|
||||
//! hide.
|
||||
|
||||
use super::classify::{Classification, DeviceClaim, classify};
|
||||
use super::pulse_pid;
|
||||
use super::*;
|
||||
use crate::host::taint::snapshot::{
|
||||
ClientSnapshot, GlobalId, IdLookup, MediaRole, NodeProps, PortDirection, PortSnapshot, Serial,
|
||||
};
|
||||
|
||||
// ---- builders -------------------------------------------------------------
|
||||
|
||||
fn ser(n: u64) -> Serial {
|
||||
Serial(n)
|
||||
}
|
||||
fn gid(n: u32) -> GlobalId {
|
||||
GlobalId(n)
|
||||
}
|
||||
fn model() -> RegistryModel {
|
||||
// now=0, a 5 s readiness budget.
|
||||
RegistryModel::new(0, 5000)
|
||||
}
|
||||
|
||||
fn no_device() -> DeviceClaim {
|
||||
DeviceClaim::default()
|
||||
}
|
||||
|
||||
fn hw_claim(device_id: u32, api: &str, factory: &str) -> DeviceClaim {
|
||||
DeviceClaim {
|
||||
device_id: Some(gid(device_id)),
|
||||
device_api: Some(api.to_string()),
|
||||
factory_name: Some(factory.to_string()),
|
||||
alsa_driver_name: Some("snd_hda_intel".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Stream/Output/Audio` node with no backing Device — admitted at once.
|
||||
fn stream_out(serial: u64, id: u32) -> RegEvent {
|
||||
RegEvent::NodeAdded(NodeObservation {
|
||||
serial: ser(serial),
|
||||
id: gid(id),
|
||||
name: Some(format!("stream-{id}")),
|
||||
role: MediaRole::StreamOutput,
|
||||
props: NodeProps::default(),
|
||||
device_claim: no_device(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A node backed by a Device (withheld until that Device resolves).
|
||||
fn device_node(serial: u64, id: u32, role: MediaRole, claim: DeviceClaim) -> RegEvent {
|
||||
RegEvent::NodeAdded(NodeObservation {
|
||||
serial: ser(serial),
|
||||
id: gid(id),
|
||||
name: Some(format!("dev-node-{id}")),
|
||||
role,
|
||||
props: NodeProps::default(),
|
||||
device_claim: claim,
|
||||
})
|
||||
}
|
||||
|
||||
fn client(serial: u64, id: u32, sec_pid: Option<u32>) -> RegEvent {
|
||||
RegEvent::ClientAdded(ClientSnapshot {
|
||||
serial: ser(serial),
|
||||
id: gid(id),
|
||||
sec_pid,
|
||||
})
|
||||
}
|
||||
|
||||
fn port(serial: u64, id: u32, node_id: u32, dir: PortDirection) -> RegEvent {
|
||||
RegEvent::PortAdded(PortSnapshot {
|
||||
serial: ser(serial),
|
||||
id: gid(id),
|
||||
node: gid(node_id),
|
||||
direction: dir,
|
||||
exclusive: false,
|
||||
monitor: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn endpoints(out_node: u32, in_node: u32) -> LinkEndpoints {
|
||||
LinkEndpoints {
|
||||
output_node: gid(out_node),
|
||||
input_node: gid(in_node),
|
||||
output_port: None,
|
||||
input_port: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// classify() — session_device
|
||||
// ==========================================================================
|
||||
|
||||
#[test]
|
||||
fn classify_no_device_is_not_a_device() {
|
||||
assert_eq!(classify(&no_device(), false), Classification::NotADevice);
|
||||
// `device_resolved` is irrelevant with no device_id.
|
||||
assert_eq!(classify(&no_device(), true), Classification::NotADevice);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_unresolved_device_withholds() {
|
||||
let claim = hw_claim(42, "alsa", "api.alsa.pcm.sink");
|
||||
assert_eq!(
|
||||
classify(&claim, false),
|
||||
Classification::Withhold { device_id: gid(42) }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_resolved_hardware_pcm_is_session_device() {
|
||||
// Only the measured ALSA factories are allowlisted (finding 5: the BlueZ
|
||||
// entries were invented and were removed).
|
||||
for factory in ["api.alsa.pcm.sink", "api.alsa.pcm.source"] {
|
||||
assert_eq!(
|
||||
classify(&hw_claim(7, "alsa", factory), true),
|
||||
Classification::SessionDevice,
|
||||
"factory {factory} should be a session device"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_invented_bluez_factories_are_not_session_devices() {
|
||||
// Finding 5: `api.bluez5.pcm.*` is not a real factory name; whatever it is,
|
||||
// it is not on the measured allowlist, so it fails closed to false
|
||||
// (over-exclusion, safe) rather than being trusted.
|
||||
for factory in ["api.bluez5.pcm.sink", "api.bluez5.pcm.source"] {
|
||||
let claim = DeviceClaim {
|
||||
device_id: Some(gid(7)),
|
||||
device_api: Some("bluez5".to_string()),
|
||||
factory_name: Some(factory.to_string()),
|
||||
alsa_driver_name: None,
|
||||
};
|
||||
assert_eq!(classify(&claim, true), Classification::NotSessionDevice);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_alsa_without_driver_name_fails_closed() {
|
||||
// Codex re-review: a missing `alsa.driver_name` must NOT grant
|
||||
// session_device — an snd_aloop node whose driver prop was not copied onto
|
||||
// the node would otherwise slip through. Absence fails closed.
|
||||
for factory in ["api.alsa.pcm.sink", "api.alsa.pcm.source"] {
|
||||
let claim = DeviceClaim {
|
||||
device_id: Some(gid(7)),
|
||||
device_api: Some("alsa".to_string()),
|
||||
factory_name: Some(factory.to_string()),
|
||||
alsa_driver_name: None,
|
||||
};
|
||||
assert_eq!(
|
||||
classify(&claim, true),
|
||||
Classification::NotSessionDevice,
|
||||
"absent driver on {factory} must fail closed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_snd_aloop_is_not_a_session_device() {
|
||||
// Finding 2: an ALSA loopback presents with an allowlisted factory and
|
||||
// device.api=alsa exactly like a real card, but forwards audio through a
|
||||
// kernel hop the Link graph cannot see. It must NOT earn session_device.
|
||||
for factory in ["api.alsa.pcm.sink", "api.alsa.pcm.source"] {
|
||||
let claim = DeviceClaim {
|
||||
device_id: Some(gid(7)),
|
||||
device_api: Some("alsa".to_string()),
|
||||
factory_name: Some(factory.to_string()),
|
||||
alsa_driver_name: Some("snd_aloop".to_string()),
|
||||
};
|
||||
assert_eq!(
|
||||
classify(&claim, true),
|
||||
Classification::NotSessionDevice,
|
||||
"snd_aloop {factory} must fail closed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_resolved_but_not_hardware_pcm_fails_closed() {
|
||||
// A null sink, a loopback, and an unknown factory are all forwarders, not
|
||||
// terminals: resolved, but session_device stays false.
|
||||
for factory in ["support.null-audio-sink", "api.alsa.pcm.loopback", "wat"] {
|
||||
assert_eq!(
|
||||
classify(&hw_claim(7, "alsa", factory), true),
|
||||
Classification::NotSessionDevice,
|
||||
"factory {factory} must not be a session device"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_missing_device_api_fails_closed() {
|
||||
// Even with an allowlisted factory, no device.api ⇒ not positively a
|
||||
// real-backend terminal.
|
||||
let claim = DeviceClaim {
|
||||
device_id: Some(gid(7)),
|
||||
device_api: None,
|
||||
factory_name: Some("api.alsa.pcm.sink".to_string()),
|
||||
alsa_driver_name: Some("snd_hda_intel".to_string()),
|
||||
};
|
||||
assert_eq!(classify(&claim, true), Classification::NotSessionDevice);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_allowlist_is_exact_not_substring() {
|
||||
// A factory that merely *contains* an allowlisted name must not pass.
|
||||
let claim = hw_claim(7, "alsa", "api.alsa.pcm.sink.evil");
|
||||
assert_eq!(classify(&claim, true), Classification::NotSessionDevice);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// pulse_pid — the six-case derivation matrix
|
||||
// ==========================================================================
|
||||
|
||||
fn clients_with(pids: &[Option<u32>]) -> Vec<ClientSnapshot> {
|
||||
pids.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &sec_pid)| ClientSnapshot {
|
||||
serial: ser(1000 + i as u64),
|
||||
id: gid(200 + i as u32),
|
||||
sec_pid,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pid_candidate_consistent_repeated_value() {
|
||||
// interior pid values, not 1 / u32::MAX.
|
||||
let cs = clients_with(&[Some(4137), Some(4137), Some(9001), Some(12034)]);
|
||||
assert_eq!(pulse_pid::candidate(&cs), Some(4137));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pid_candidate_inconsistent_two_repeats_is_none() {
|
||||
let cs = clients_with(&[Some(4137), Some(4137), Some(9001), Some(9001)]);
|
||||
assert_eq!(pulse_pid::candidate(&cs), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pid_candidate_missing_property_is_none() {
|
||||
let cs = clients_with(&[None, None, None]);
|
||||
assert_eq!(pulse_pid::candidate(&cs), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pid_candidate_single_occurrence_is_none() {
|
||||
// A lone native client carrying its own pid is indistinguishable from a
|
||||
// one-client pulse; the >=2 threshold rejects it.
|
||||
let cs = clients_with(&[Some(4137), Some(9001), Some(12034)]);
|
||||
assert_eq!(pulse_pid::candidate(&cs), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pid_validate_matches_pulse_comm() {
|
||||
assert_eq!(
|
||||
pulse_pid::validate(4137, Some("pipewire-pulse")),
|
||||
Some(4137)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pid_validate_proc_missing_is_none() {
|
||||
// case 4: /proc entry gone.
|
||||
assert_eq!(pulse_pid::validate(4137, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pid_validate_comm_mismatch_is_none() {
|
||||
// case 5: a different process holds the number.
|
||||
assert_eq!(pulse_pid::validate(4137, Some("firefox")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pid_validate_reuse_named_other_process_is_none() {
|
||||
// case 6: PID reuse — the number is live but /proc names someone else.
|
||||
assert_eq!(pulse_pid::validate(4137, Some("Xwayland")), None);
|
||||
// and a truncation-adjacent near-miss must not pass an exact match.
|
||||
assert_eq!(pulse_pid::validate(4137, Some("pipewire-pulseX")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pid_derive_end_to_end_valid() {
|
||||
let cs = clients_with(&[Some(4137), Some(4137), Some(9001)]);
|
||||
let got = pulse_pid::derive(&cs, |pid| {
|
||||
(pid == 4137).then(|| "pipewire-pulse".to_string())
|
||||
});
|
||||
assert_eq!(got, Some(4137));
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// model — pulse pid through project()
|
||||
// ==========================================================================
|
||||
|
||||
/// Drive the model to Complete so `project` reflects a trusted graph, without
|
||||
/// caring about the specific objects.
|
||||
fn drive_ready(m: &mut RegistryModel) {
|
||||
m.apply(RegEvent::ServerSynced);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_pulse_pid_valid_through_projection() {
|
||||
let mut m = model();
|
||||
m.apply(client(1, 200, Some(4137)));
|
||||
m.apply(client(2, 201, Some(4137)));
|
||||
m.apply(client(3, 202, Some(9001)));
|
||||
assert_eq!(m.pulse_pid_candidate(), Some(4137));
|
||||
m.apply(RegEvent::ProcCommProbed {
|
||||
pid: 4137,
|
||||
comm: Some("pipewire-pulse".to_string()),
|
||||
});
|
||||
assert_eq!(m.project().pipewire_pulse_pid, Some(4137));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_pulse_pid_none_until_probed() {
|
||||
let mut m = model();
|
||||
m.apply(client(1, 200, Some(4137)));
|
||||
m.apply(client(2, 201, Some(4137)));
|
||||
// candidate exists, but no /proc confirmation yet ⇒ fail closed.
|
||||
assert_eq!(m.project().pipewire_pulse_pid, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_pulse_pid_none_on_comm_mismatch() {
|
||||
let mut m = model();
|
||||
m.apply(client(1, 200, Some(4137)));
|
||||
m.apply(client(2, 201, Some(4137)));
|
||||
m.apply(RegEvent::ProcCommProbed {
|
||||
pid: 4137,
|
||||
comm: Some("firefox".to_string()),
|
||||
});
|
||||
assert_eq!(m.project().pipewire_pulse_pid, None);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// model — add / remove of all four object types
|
||||
// ==========================================================================
|
||||
|
||||
#[test]
|
||||
fn model_adds_all_four_object_types() {
|
||||
let mut m = model();
|
||||
m.apply(stream_out(100, 50));
|
||||
m.apply(port(101, 60, 50, PortDirection::Out));
|
||||
m.apply(client(102, 70, Some(4137)));
|
||||
m.apply(RegEvent::LinkAdded {
|
||||
serial: ser(103),
|
||||
id: gid(80),
|
||||
endpoints: Some(endpoints(50, 55)),
|
||||
});
|
||||
let snap = m.project().snapshot;
|
||||
assert_eq!(snap.nodes().count(), 1);
|
||||
assert_eq!(snap.ports().count(), 1);
|
||||
assert_eq!(snap.clients().count(), 1);
|
||||
assert_eq!(snap.links().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_removes_all_four_object_types() {
|
||||
let mut m = model();
|
||||
m.apply(stream_out(100, 50));
|
||||
m.apply(port(101, 60, 50, PortDirection::Out));
|
||||
m.apply(client(102, 70, Some(4137)));
|
||||
m.apply(RegEvent::LinkAdded {
|
||||
serial: ser(103),
|
||||
id: gid(80),
|
||||
endpoints: Some(endpoints(50, 55)),
|
||||
});
|
||||
|
||||
m.apply(RegEvent::Removed { id: gid(50) });
|
||||
m.apply(RegEvent::Removed { id: gid(60) });
|
||||
m.apply(RegEvent::Removed { id: gid(70) });
|
||||
m.apply(RegEvent::Removed { id: gid(80) });
|
||||
|
||||
let snap = m.project().snapshot;
|
||||
assert_eq!(snap.nodes().count(), 0);
|
||||
assert_eq!(snap.ports().count(), 0);
|
||||
assert_eq!(snap.clients().count(), 0);
|
||||
assert_eq!(snap.links().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_remove_of_unknown_id_is_harmless() {
|
||||
let mut m = model();
|
||||
m.apply(stream_out(100, 50));
|
||||
m.apply(RegEvent::Removed { id: gid(999) });
|
||||
assert_eq!(m.project().snapshot.nodes().count(), 1);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// model — recycled global id, oldest generation first (fail closed)
|
||||
// ==========================================================================
|
||||
|
||||
#[test]
|
||||
fn model_recycled_id_is_ambiguous_until_removal_accounted() {
|
||||
let mut m = model();
|
||||
// A missed removal: two live nodes claim id 50 (serials 100 then 200).
|
||||
m.apply(stream_out(100, 50));
|
||||
m.apply(stream_out(200, 50));
|
||||
|
||||
// The snapshot fails closed: id 50 is ambiguous.
|
||||
let snap = m.project().snapshot;
|
||||
assert_eq!(snap.node_by_id(gid(50)), Some(IdLookup::Ambiguous));
|
||||
assert_eq!(snap.nodes().count(), 2);
|
||||
|
||||
// One removal accounts for the OLDEST generation (serial 100); the newer
|
||||
// node survives and the id is unambiguous again.
|
||||
m.apply(RegEvent::Removed { id: gid(50) });
|
||||
let snap = m.project().snapshot;
|
||||
assert_eq!(snap.node_by_id(gid(50)), Some(IdLookup::Unique(ser(200))));
|
||||
assert!(snap.node(ser(200)).is_some());
|
||||
assert!(snap.node(ser(100)).is_none());
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// model — Link endpoint resolution (bind fallback path)
|
||||
// ==========================================================================
|
||||
|
||||
#[test]
|
||||
fn model_link_with_endpoints_appears_immediately() {
|
||||
let mut m = model();
|
||||
m.apply(RegEvent::LinkAdded {
|
||||
serial: ser(103),
|
||||
id: gid(80),
|
||||
endpoints: Some(endpoints(50, 55)),
|
||||
});
|
||||
assert_eq!(m.project().snapshot.links().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_link_without_endpoints_is_withheld_until_resolved() {
|
||||
let mut m = model();
|
||||
// The correctness path: the global carried no endpoint props.
|
||||
m.apply(RegEvent::LinkAdded {
|
||||
serial: ser(103),
|
||||
id: gid(80),
|
||||
endpoints: None,
|
||||
});
|
||||
// Not in the snapshot yet, and it blocks readiness.
|
||||
assert_eq!(m.project().snapshot.links().count(), 0);
|
||||
m.apply(RegEvent::ServerSynced);
|
||||
assert!(!m.graph_ready(), "pending link must hold readiness");
|
||||
|
||||
// The bind fallback resolves it.
|
||||
m.apply(RegEvent::LinkEndpointsResolved {
|
||||
serial: ser(103),
|
||||
endpoints: endpoints(50, 55),
|
||||
});
|
||||
let snap = m.project().snapshot;
|
||||
assert_eq!(snap.links().count(), 1);
|
||||
let link = snap.links().next().unwrap();
|
||||
assert_eq!(link.output_node, gid(50));
|
||||
assert_eq!(link.input_node, gid(55));
|
||||
assert!(
|
||||
m.graph_ready(),
|
||||
"resolving the last obligation completes readiness"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_stale_link_resolution_is_ignored() {
|
||||
let mut m = model();
|
||||
m.apply(RegEvent::LinkAdded {
|
||||
serial: ser(103),
|
||||
id: gid(80),
|
||||
endpoints: None,
|
||||
});
|
||||
// Link removed before the bind returned.
|
||||
m.apply(RegEvent::Removed { id: gid(80) });
|
||||
// A late resolution for the gone link must not resurrect it.
|
||||
m.apply(RegEvent::LinkEndpointsResolved {
|
||||
serial: ser(103),
|
||||
endpoints: endpoints(50, 55),
|
||||
});
|
||||
assert_eq!(m.project().snapshot.links().count(), 0);
|
||||
m.apply(RegEvent::ServerSynced);
|
||||
assert!(
|
||||
m.graph_ready(),
|
||||
"the obligation cleared when the link was removed"
|
||||
);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// model — readiness epoch
|
||||
// ==========================================================================
|
||||
|
||||
#[test]
|
||||
fn model_readiness_waits_for_sync() {
|
||||
let mut m = model();
|
||||
m.apply(stream_out(100, 50));
|
||||
assert_eq!(m.readiness(), Readiness::Waiting);
|
||||
assert!(!m.project().graph_ready);
|
||||
m.apply(RegEvent::ServerSynced);
|
||||
assert_eq!(m.readiness(), Readiness::Complete);
|
||||
assert!(m.project().graph_ready);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_readiness_does_not_release_with_obligation_outstanding() {
|
||||
let mut m = model();
|
||||
// A node withheld on an unresolved device is an outstanding obligation.
|
||||
m.apply(device_node(
|
||||
100,
|
||||
50,
|
||||
MediaRole::Sink,
|
||||
hw_claim(42, "alsa", "api.alsa.pcm.sink"),
|
||||
));
|
||||
m.apply(RegEvent::ServerSynced);
|
||||
// Synced, but the withheld node keeps the epoch shut.
|
||||
assert_eq!(m.readiness(), Readiness::Waiting);
|
||||
assert!(!m.graph_ready());
|
||||
|
||||
// Resolving the device admits the node and completes readiness.
|
||||
m.apply(RegEvent::DeviceAdded { id: gid(42) });
|
||||
assert_eq!(m.readiness(), Readiness::Complete);
|
||||
assert!(m.graph_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_readiness_times_out_fail_closed() {
|
||||
let mut m = model();
|
||||
m.apply(device_node(
|
||||
100,
|
||||
50,
|
||||
MediaRole::Sink,
|
||||
hw_claim(42, "alsa", "api.alsa.pcm.sink"),
|
||||
));
|
||||
m.apply(RegEvent::ServerSynced);
|
||||
assert_eq!(m.readiness(), Readiness::Waiting);
|
||||
|
||||
// The device never resolves; the deadline passes.
|
||||
m.apply(RegEvent::Tick { now: 5000 });
|
||||
assert_eq!(m.readiness(), Readiness::TimedOut);
|
||||
assert!(!m.graph_ready(), "timeout fails closed");
|
||||
|
||||
// Finding 6: TimedOut must be sticky. Resolving the obligation, syncing
|
||||
// again, and ticking further must NOT flip it to Complete — a timed-out
|
||||
// observer stays fail-closed for its lifetime.
|
||||
m.apply(RegEvent::DeviceAdded { id: gid(42) });
|
||||
m.apply(RegEvent::ServerSynced);
|
||||
m.apply(RegEvent::Tick { now: 6000 });
|
||||
assert_eq!(m.readiness(), Readiness::TimedOut, "timeout is sticky");
|
||||
assert!(!m.graph_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_tick_before_deadline_does_not_time_out() {
|
||||
let mut m = model();
|
||||
m.apply(device_node(
|
||||
100,
|
||||
50,
|
||||
MediaRole::Sink,
|
||||
hw_claim(42, "alsa", "api.alsa.pcm.sink"),
|
||||
));
|
||||
m.apply(RegEvent::Tick { now: 4999 });
|
||||
assert_eq!(m.readiness(), Readiness::Waiting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_complete_epoch_is_sticky_but_graph_ready_is_dynamic() {
|
||||
let mut m = model();
|
||||
m.apply(RegEvent::ServerSynced);
|
||||
assert_eq!(m.readiness(), Readiness::Complete);
|
||||
assert!(m.graph_ready());
|
||||
// A node withheld AFTER completion does not revert the sticky EPOCH...
|
||||
m.apply(device_node(
|
||||
100,
|
||||
50,
|
||||
MediaRole::Sink,
|
||||
hw_claim(42, "alsa", "api.alsa.pcm.sink"),
|
||||
));
|
||||
assert_eq!(m.readiness(), Readiness::Complete, "epoch stays sticky");
|
||||
// ...but graph_ready DOES drop while the obligation is outstanding
|
||||
// (Codex finding 1: unresolved ancestry ⇒ fail closed, even post-epoch).
|
||||
assert!(
|
||||
!m.graph_ready(),
|
||||
"an outstanding obligation makes decisions unsafe"
|
||||
);
|
||||
// A late timeout Tick is inert once Complete.
|
||||
m.apply(RegEvent::Tick { now: 100_000 });
|
||||
assert_eq!(m.readiness(), Readiness::Complete);
|
||||
// Resolving the obligation restores graph_ready.
|
||||
m.apply(RegEvent::DeviceAdded { id: gid(42) });
|
||||
assert!(m.graph_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_pending_link_drops_graph_ready_after_completion() {
|
||||
// Codex finding 1, the leak that mattered: a real Link added post-epoch
|
||||
// whose endpoints are still binding is an INVISIBLE edge (absent from the
|
||||
// snapshot, not dangling). graph_ready must go false until it resolves,
|
||||
// or a candidate can be reported eligible while tainted ancestry it cannot
|
||||
// see already carries call audio.
|
||||
let mut m = model();
|
||||
m.apply(RegEvent::ServerSynced);
|
||||
assert!(m.graph_ready());
|
||||
|
||||
m.apply(RegEvent::LinkAdded {
|
||||
serial: ser(300),
|
||||
id: gid(90),
|
||||
endpoints: None,
|
||||
});
|
||||
assert!(!m.graph_ready(), "an unresolved link must gate decisions");
|
||||
// The snapshot genuinely omits it, which is exactly why graph_ready must
|
||||
// compensate.
|
||||
assert_eq!(m.project().snapshot.links().count(), 0);
|
||||
assert!(!m.project().graph_ready);
|
||||
|
||||
m.apply(RegEvent::LinkEndpointsResolved {
|
||||
serial: ser(300),
|
||||
endpoints: endpoints(50, 55),
|
||||
});
|
||||
assert!(m.graph_ready(), "resolved ⇒ decisions safe again");
|
||||
assert_eq!(m.project().snapshot.links().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_withheld_node_removed_clears_obligation() {
|
||||
let mut m = model();
|
||||
m.apply(device_node(
|
||||
100,
|
||||
50,
|
||||
MediaRole::Sink,
|
||||
hw_claim(42, "alsa", "api.alsa.pcm.sink"),
|
||||
));
|
||||
m.apply(RegEvent::ServerSynced);
|
||||
assert_eq!(m.readiness(), Readiness::Waiting);
|
||||
// The withheld node disappears before its device ever showed up.
|
||||
m.apply(RegEvent::Removed { id: gid(50) });
|
||||
assert_eq!(m.readiness(), Readiness::Complete);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// model — device withholding & session_device flag
|
||||
// ==========================================================================
|
||||
|
||||
#[test]
|
||||
fn model_device_first_admits_node_immediately() {
|
||||
let mut m = model();
|
||||
// Device enumerated before the node that references it.
|
||||
m.apply(RegEvent::DeviceAdded { id: gid(42) });
|
||||
m.apply(device_node(
|
||||
100,
|
||||
50,
|
||||
MediaRole::Sink,
|
||||
hw_claim(42, "alsa", "api.alsa.pcm.sink"),
|
||||
));
|
||||
let snap = m.project().snapshot;
|
||||
let node = snap.node(ser(100)).expect("node admitted immediately");
|
||||
assert!(
|
||||
node.props.session_device,
|
||||
"hardware sink is a session device"
|
||||
);
|
||||
// No obligation ⇒ a sync completes readiness.
|
||||
m.apply(RegEvent::ServerSynced);
|
||||
assert!(m.graph_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_withheld_node_admitted_with_correct_session_device() {
|
||||
let mut m = model();
|
||||
// A real hardware sink and a card-associated filter share client/device
|
||||
// ancestry but classify differently once the device resolves.
|
||||
m.apply(device_node(
|
||||
100,
|
||||
50,
|
||||
MediaRole::Sink,
|
||||
hw_claim(42, "alsa", "api.alsa.pcm.sink"),
|
||||
));
|
||||
m.apply(device_node(
|
||||
200,
|
||||
51,
|
||||
MediaRole::Sink,
|
||||
hw_claim(42, "alsa", "support.null-audio-sink"),
|
||||
));
|
||||
// Both withheld until the device resolves.
|
||||
assert_eq!(m.project().snapshot.nodes().count(), 0);
|
||||
m.apply(RegEvent::DeviceAdded { id: gid(42) });
|
||||
|
||||
let snap = m.project().snapshot;
|
||||
assert_eq!(
|
||||
snap.nodes().count(),
|
||||
2,
|
||||
"both admitted once the device resolved"
|
||||
);
|
||||
assert!(
|
||||
snap.node(ser(100)).unwrap().props.session_device,
|
||||
"the real hardware sink is a session device"
|
||||
);
|
||||
assert!(
|
||||
!snap.node(ser(200)).unwrap().props.session_device,
|
||||
"the null sink sharing the same device is not"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_withheld_filter_admitted_as_not_session_device() {
|
||||
let mut m = model();
|
||||
m.apply(device_node(
|
||||
200,
|
||||
51,
|
||||
MediaRole::Sink,
|
||||
hw_claim(42, "alsa", "support.null-audio-sink"),
|
||||
));
|
||||
m.apply(RegEvent::DeviceAdded { id: gid(42) });
|
||||
let snap = m.project().snapshot;
|
||||
assert!(
|
||||
!snap.node(ser(200)).unwrap().props.session_device,
|
||||
"a null sink on a card is not a session device"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
//! Display-server-agnostic capture pipeline. The video *source* element is the
|
||||
//! only part that differs between Wayland (`pipewiresrc`, after a portal
|
||||
//! handshake) and X11 (`ximagesrc`); everything downstream — the videorate cap,
|
||||
//! the encoder, `h264parse`, `mpegtsmux`, the whole audio branch, the gst spawn,
|
||||
//! the [`Serve`] fanout binding, and the [`CaptureHandle`] lifecycle — is shared
|
||||
//! and lives here. Backends call [`spawn`] with just their source-element args.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use nix::sys::signal::{Signal, kill};
|
||||
use nix::unistd::Pid;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::time::timeout;
|
||||
|
||||
use super::audio::Routing;
|
||||
use super::quality::EffectiveQuality;
|
||||
use super::serve::Serve;
|
||||
use crate::cli::HostOpts;
|
||||
|
||||
pub struct CaptureHandle {
|
||||
gst: Option<Child>,
|
||||
audio: Option<Routing>,
|
||||
serve: Option<Serve>,
|
||||
}
|
||||
|
||||
impl CaptureHandle {
|
||||
pub fn local_port(&self) -> u16 {
|
||||
self.serve
|
||||
.as_ref()
|
||||
.expect("serve is always Some until shutdown")
|
||||
.local_port()
|
||||
}
|
||||
|
||||
/// Graceful teardown: SIGTERM gst, give it ~1s to exit, then SIGKILL,
|
||||
/// unload audio routing (if any), then tear down the serve layer.
|
||||
/// The serve reader will see EOF on gst stdout and exit on its own;
|
||||
/// serve.shutdown() is the backstop.
|
||||
pub async fn shutdown(mut self) {
|
||||
if let Some(child) = self.gst.as_mut()
|
||||
&& let Some(pid) = child.id()
|
||||
{
|
||||
let _ = kill(Pid::from_raw(pid as i32), Signal::SIGTERM);
|
||||
}
|
||||
if let Some(child) = self.gst.as_mut() {
|
||||
let _ = timeout(Duration::from_millis(1000), child.wait()).await;
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
if let Some(audio) = self.audio.take() {
|
||||
audio.shutdown();
|
||||
}
|
||||
if let Some(serve) = self.serve.take() {
|
||||
serve.shutdown().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CaptureHandle {
|
||||
fn drop(&mut self) {
|
||||
if let Some(child) = self.gst.as_mut() {
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
// Routing's and Serve's own Drop impls handle the rest.
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the shared gst pipeline for a backend that supplies `source_args`
|
||||
/// (the video-source element + its properties, e.g. `["pipewiresrc", "fd=7",
|
||||
/// …]` or `["ximagesrc", "use-damage=false", …]`). `source_dims` is the source
|
||||
/// pixel size when the backend knows it (Wayland from the portal, X11 from
|
||||
/// root/window geometry); it lets a downscale preset compute an exact even
|
||||
/// target resolution and skip scaling when the source is already small enough.
|
||||
/// `after_spawn` runs once, immediately after the gst child is launched —
|
||||
/// Wayland uses it to `close` the pipewire fd it leaked into the child; X11
|
||||
/// passes a no-op.
|
||||
pub async fn spawn(
|
||||
opts: &HostOpts,
|
||||
quality: &EffectiveQuality,
|
||||
source_dims: Option<(u32, u32)>,
|
||||
source_args: Vec<String>,
|
||||
after_spawn: impl FnOnce(),
|
||||
) -> Result<CaptureHandle> {
|
||||
let (audio_routing, audio_device) = setup_audio(opts).await?;
|
||||
let args = build_args(&source_args, &audio_device, opts, quality, source_dims);
|
||||
|
||||
let mut gst_cmd = Command::new("gst-launch-1.0");
|
||||
gst_cmd
|
||||
.args(&args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit());
|
||||
if std::env::var_os("PIXELPASS_GST_DEBUG").is_some() {
|
||||
gst_cmd.env("GST_DEBUG", "3");
|
||||
}
|
||||
let mut gst = gst_cmd.spawn().context("failed to spawn gst-launch-1.0")?;
|
||||
|
||||
// Backend-specific post-spawn cleanup (Wayland closes its leaked pw fd here,
|
||||
// once gst has inherited its own copy).
|
||||
after_spawn();
|
||||
|
||||
let gst_stdout = gst
|
||||
.stdout
|
||||
.take()
|
||||
.context("gst-launch-1.0 stdout pipe unavailable")?;
|
||||
|
||||
// Hand stdout to the serve layer, which binds the localhost HTTP listener
|
||||
// and runs the broadcast fanout. No demux/remux, no codec assumptions.
|
||||
let serve = Serve::bind(gst_stdout).await?;
|
||||
|
||||
Ok(CaptureHandle {
|
||||
gst: Some(gst),
|
||||
audio: audio_routing,
|
||||
serve: Some(serve),
|
||||
})
|
||||
}
|
||||
|
||||
/// Decide whether per-app audio routing is active and produce the `device=…`
|
||||
/// argument for `pulsesrc`. Routing activates when either `--app` is set
|
||||
/// (per-stream rerouting to a per-PID null-sink) or `PIXELPASS_AUDIO_VIA_NULL_SINK=1`
|
||||
/// is set (no app filter — captures everything via the null-sink, used for
|
||||
/// dogfooding the loopback path). Otherwise we capture the default sink's
|
||||
/// monitor (system audio out), not the default source (the mic).
|
||||
async fn setup_audio(opts: &HostOpts) -> Result<(Option<Routing>, String)> {
|
||||
let routing_requested =
|
||||
opts.app.is_some() || std::env::var_os("PIXELPASS_AUDIO_VIA_NULL_SINK").is_some();
|
||||
let audio_routing = if routing_requested {
|
||||
Some(
|
||||
Routing::start(opts)
|
||||
.await
|
||||
.context("audio routing setup failed")?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let audio_device = if let Some(r) = &audio_routing {
|
||||
format!("device={}.monitor", r.sink_name())
|
||||
} else {
|
||||
let default = default_audio_monitor().await?;
|
||||
format!("device={default}")
|
||||
};
|
||||
Ok((audio_routing, audio_device))
|
||||
}
|
||||
|
||||
/// Build the full gst-launch argument vector: MPEG-TS mux + fdsink, then the
|
||||
/// video branch (caller's `source` → videorate cap → optional downscale →
|
||||
/// encoder → h264parse → mux.), then the audio branch (pulsesrc → AAC → mux.).
|
||||
/// Bitrate, framerate, and the downscale height come from the resolved
|
||||
/// [`EffectiveQuality`]; the encoder and the `videoconvert` target format are
|
||||
/// selected by `opts.no_hwencode` (hardware VAAPI wants NV12, software x264
|
||||
/// wants I420).
|
||||
fn build_args(
|
||||
source: &[String],
|
||||
audio_device: &str,
|
||||
opts: &HostOpts,
|
||||
quality: &EffectiveQuality,
|
||||
source_dims: Option<(u32, u32)>,
|
||||
) -> Vec<String> {
|
||||
let key_interval = (quality.framerate * 2).to_string();
|
||||
let bitrate = quality.bitrate.to_string();
|
||||
let framerate_caps = format!("video/x-raw,framerate={}/1", quality.framerate);
|
||||
|
||||
let (raw_format, encoder_args): (&str, Vec<String>) = if opts.no_hwencode {
|
||||
(
|
||||
"video/x-raw,format=I420",
|
||||
vec![
|
||||
"x264enc".into(),
|
||||
"tune=zerolatency".into(),
|
||||
"speed-preset=ultrafast".into(),
|
||||
format!("bitrate={bitrate}"),
|
||||
format!("key-int-max={key_interval}"),
|
||||
],
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"video/x-raw,format=NV12",
|
||||
vec![
|
||||
"vah264enc".into(),
|
||||
"rate-control=cbr".into(),
|
||||
format!("bitrate={bitrate}"),
|
||||
format!("key-int-max={key_interval}"),
|
||||
],
|
||||
)
|
||||
};
|
||||
|
||||
// muxer + sink
|
||||
let mut args: Vec<String> = vec![
|
||||
"mpegtsmux".into(),
|
||||
"name=mux".into(),
|
||||
"!".into(),
|
||||
"queue".into(),
|
||||
"!".into(),
|
||||
];
|
||||
// Debug A/V-drift tap: when PIXELPASS_TS_DUMP=<path> is set, tee the exact
|
||||
// muxed TS both to fd=1 (normal serve path, unchanged) and to a file, so the
|
||||
// host-side stream can be ffprobe'd for capture-side audio/video PTS drift.
|
||||
// Each tee branch has its own queue so the disk sink can't backpressure the
|
||||
// live serve branch. No effect when unset. (Mirrors PIXELPASS_GST_DEBUG.)
|
||||
if let Some(dump) = std::env::var_os("PIXELPASS_TS_DUMP") {
|
||||
let path = dump.to_string_lossy().into_owned();
|
||||
args.extend([
|
||||
"tee".into(),
|
||||
"name=dbgtee".into(),
|
||||
"!".into(),
|
||||
"queue".into(),
|
||||
"!".into(),
|
||||
"fdsink".into(),
|
||||
"fd=1".into(),
|
||||
"dbgtee.".into(),
|
||||
"!".into(),
|
||||
"queue".into(),
|
||||
"!".into(),
|
||||
"filesink".into(),
|
||||
format!("location={path}"),
|
||||
]);
|
||||
} else {
|
||||
args.extend(["fdsink".into(), "fd=1".into()]);
|
||||
}
|
||||
|
||||
// Downscale step for the quality presets. `None` = encode at native size
|
||||
// (the "Source" preset, or a source already at/below the target height — we
|
||||
// never upscale). When the source dimensions are known we pin an exact even
|
||||
// WxH preserving the source aspect; H.264 4:2:0 needs even dims, so width is
|
||||
// rounded to even and height is forced even (preset heights already are; a
|
||||
// raw --max-height override is rounded down). When dims are unknown (a rare
|
||||
// X11 geometry-read failure) we fall back to height-only + square pixels +
|
||||
// an even-stepped width range and let videoscale negotiate.
|
||||
let scale_caps: Option<String> = match quality.max_height {
|
||||
None => {
|
||||
tracing::info!(preset = %quality.label, "encoding at native resolution (no downscale)");
|
||||
None
|
||||
}
|
||||
Some(max_h) => {
|
||||
let h = (max_h & !1).max(2);
|
||||
match source_dims {
|
||||
Some((sw, sh)) if sh > h => {
|
||||
let w = ((sw as u64 * h as u64 + sh as u64 / 2) / sh as u64) as u32;
|
||||
let w = (w & !1).max(2);
|
||||
tracing::info!(
|
||||
preset = %quality.label,
|
||||
from = %format!("{sw}x{sh}"),
|
||||
to = %format!("{w}x{h}"),
|
||||
"downscaling video"
|
||||
);
|
||||
Some(format!("{raw_format},width={w},height={h}"))
|
||||
}
|
||||
Some((sw, sh)) => {
|
||||
tracing::info!(
|
||||
preset = %quality.label,
|
||||
source = %format!("{sw}x{sh}"),
|
||||
max_height = h,
|
||||
"source already at/below preset height — encoding native (no upscale)"
|
||||
);
|
||||
None
|
||||
}
|
||||
None => {
|
||||
tracing::info!(
|
||||
preset = %quality.label,
|
||||
max_height = h,
|
||||
"downscaling to max height (source size unknown — width follows negotiation)"
|
||||
);
|
||||
Some(format!(
|
||||
"{raw_format},height={h},pixel-aspect-ratio=1/1,width=[2,8192,2]"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// video branch — videorate caps to the target fps so we don't ship at the
|
||||
// monitor's refresh rate (e.g. 180Hz) and pile up frames in the demuxer
|
||||
// queue faster than realtime. videoscale (when scaling) runs *after*
|
||||
// videoconvert so it operates on system-memory NV12/I420: scaling
|
||||
// pipewiresrc's raw output directly can hit a format/memory (e.g. DMABuf)
|
||||
// that software videoscale won't negotiate.
|
||||
args.extend(source.iter().cloned());
|
||||
args.extend([
|
||||
"!".into(),
|
||||
"videorate".into(),
|
||||
"!".into(),
|
||||
framerate_caps,
|
||||
"!".into(),
|
||||
"queue".into(),
|
||||
"!".into(),
|
||||
"videoconvert".into(),
|
||||
"!".into(),
|
||||
raw_format.into(),
|
||||
"!".into(),
|
||||
]);
|
||||
if let Some(caps) = scale_caps {
|
||||
args.extend(["videoscale".into(), "!".into(), caps, "!".into()]);
|
||||
}
|
||||
args.extend(encoder_args);
|
||||
args.extend([
|
||||
"!".into(),
|
||||
"h264parse".into(),
|
||||
"config-interval=-1".into(),
|
||||
"!".into(),
|
||||
"video/x-h264,stream-format=byte-stream,alignment=au".into(),
|
||||
"!".into(),
|
||||
"mux.".into(),
|
||||
]);
|
||||
|
||||
// audio branch — capture the default sink's MONITOR (system audio out),
|
||||
// not the default source (which is the mic).
|
||||
args.extend([
|
||||
"pulsesrc".into(),
|
||||
audio_device.to_string(),
|
||||
"do-timestamp=true".into(),
|
||||
"!".into(),
|
||||
"queue".into(),
|
||||
"!".into(),
|
||||
"audioconvert".into(),
|
||||
"!".into(),
|
||||
"audioresample".into(),
|
||||
"!".into(),
|
||||
"audio/x-raw,rate=48000,channels=2".into(),
|
||||
"!".into(),
|
||||
"avenc_aac".into(),
|
||||
"bitrate=128000".into(),
|
||||
"!".into(),
|
||||
"aacparse".into(),
|
||||
"!".into(),
|
||||
"mux.".into(),
|
||||
]);
|
||||
|
||||
args
|
||||
}
|
||||
|
||||
async fn default_audio_monitor() -> Result<String> {
|
||||
let output = Command::new("pactl")
|
||||
.arg("get-default-sink")
|
||||
.output()
|
||||
.await
|
||||
.context(
|
||||
"failed to run `pactl get-default-sink` (install pulseaudio-utils or pipewire-pulse)",
|
||||
)?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"pactl get-default-sink failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
let sink = String::from_utf8(output.stdout)
|
||||
.context("default sink name was not UTF-8")?
|
||||
.trim()
|
||||
.to_string();
|
||||
if sink.is_empty() {
|
||||
bail!("pactl get-default-sink returned no name (is a sound server running?)");
|
||||
}
|
||||
Ok(format!("{sink}.monitor"))
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
//! Resolution / quality presets. A preset bundles `(max_height, bitrate, fps)`
|
||||
//! because resolution is a *quality-per-bitrate* knob, not a standalone one —
|
||||
//! the three are only useful together. Quality is **host-global**: one encode
|
||||
//! pipeline fans out to every viewer over the broadcast channel, so the sharer
|
||||
//! picks one quality for everyone (per-viewer quality would need per-viewer
|
||||
//! encodes, which kills the fanout).
|
||||
//!
|
||||
//! [`resolve`] turns the raw CLI/picker choice into a concrete
|
||||
//! [`EffectiveQuality`] the pipeline encodes at, applying — in order — the
|
||||
//! chosen preset (or an Auto derivation from the bandwidth pre-flight), then
|
||||
//! any explicit `--bitrate` / `--framerate` / `--max-height` field overrides.
|
||||
|
||||
use crate::cli::{HostOpts, Quality};
|
||||
use crate::common::{config, config::BandwidthStatus};
|
||||
|
||||
/// A fixed preset's concrete settings. `max_height = None` means encode at the
|
||||
/// native source resolution (no `videoscale` element is inserted at all).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Preset {
|
||||
max_height: Option<u32>,
|
||||
bitrate: u32, // kbps
|
||||
framerate: u32,
|
||||
}
|
||||
|
||||
impl Quality {
|
||||
/// The fixed tuple for a preset. `Auto` returns `None` — it has no fixed
|
||||
/// values and resolves to one of the others at runtime (see [`resolve_auto`]).
|
||||
fn preset(self) -> Option<Preset> {
|
||||
let p = match self {
|
||||
Quality::Source => Preset {
|
||||
max_height: None,
|
||||
bitrate: 6000,
|
||||
framerate: 30,
|
||||
},
|
||||
Quality::High => Preset {
|
||||
max_height: Some(1080),
|
||||
bitrate: 4000,
|
||||
framerate: 30,
|
||||
},
|
||||
Quality::Medium => Preset {
|
||||
max_height: Some(720),
|
||||
bitrate: 2500,
|
||||
framerate: 30,
|
||||
},
|
||||
Quality::Low => Preset {
|
||||
max_height: Some(480),
|
||||
bitrate: 1000,
|
||||
framerate: 30,
|
||||
},
|
||||
Quality::Auto => return None,
|
||||
};
|
||||
Some(p)
|
||||
}
|
||||
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Quality::Source => "Source",
|
||||
Quality::High => "High",
|
||||
Quality::Medium => "Medium",
|
||||
Quality::Low => "Low",
|
||||
Quality::Auto => "Auto",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed presets in descending quality order — Auto walks this to find the
|
||||
/// best one whose per-viewer bitrate fits the measured upstream budget.
|
||||
const AUTO_LADDER: [Quality; 4] = [
|
||||
Quality::Source,
|
||||
Quality::High,
|
||||
Quality::Medium,
|
||||
Quality::Low,
|
||||
];
|
||||
|
||||
/// Auto's fallback when there is no usable bandwidth measurement.
|
||||
const AUTO_FALLBACK: Quality = Quality::Medium;
|
||||
|
||||
/// Fully-resolved quality: the concrete values the pipeline will encode at,
|
||||
/// plus human-readable strings for the host banner.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EffectiveQuality {
|
||||
/// `None` = native resolution (omit `videoscale`); `Some(h)` = scale to height `h`.
|
||||
pub max_height: Option<u32>,
|
||||
pub bitrate: u32, // kbps
|
||||
pub framerate: u32,
|
||||
/// Short label, e.g. `"High"` or `"Auto → Medium"`.
|
||||
pub label: String,
|
||||
/// Provenance note for the banner, e.g. `"user-specified"` or
|
||||
/// `"auto: 8.8 Mbps safe ÷ 1 viewer"`.
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
impl EffectiveQuality {
|
||||
/// `WxH-ish / bitrate / fps` summary for the banner. Width is unknown until
|
||||
/// capture (the source dictates it), so height is shown as `?xN` / `native`.
|
||||
pub fn dimensions_summary(&self) -> String {
|
||||
let res = match self.max_height {
|
||||
Some(h) => format!("≤{h}p"),
|
||||
None => "native".to_string(),
|
||||
};
|
||||
format!("{res} / {} kbps / {} fps", self.bitrate, self.framerate)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the host's quality choice into concrete encode settings.
|
||||
///
|
||||
/// `sizing_viewers` is the viewer count Auto sizes its budget against (the
|
||||
/// resolved `--max-viewers` cap, so quality is chosen for the worst case —
|
||||
/// quality is baked in at capture-spawn and can't drop when viewer #2 joins).
|
||||
pub fn resolve(opts: &HostOpts, sizing_viewers: u32) -> EffectiveQuality {
|
||||
// 1. Base preset: a fixed tuple, or an Auto derivation.
|
||||
let (base, label, base_note) = match opts.quality {
|
||||
Quality::Auto => resolve_auto(measured_safe_mbps(), sizing_viewers),
|
||||
q => {
|
||||
let p = q.preset().expect("non-Auto presets always have a tuple");
|
||||
(p, q.name().to_string(), "user-specified".to_string())
|
||||
}
|
||||
};
|
||||
|
||||
let mut eff = EffectiveQuality {
|
||||
max_height: base.max_height,
|
||||
bitrate: base.bitrate,
|
||||
framerate: base.framerate,
|
||||
label,
|
||||
note: base_note,
|
||||
};
|
||||
|
||||
// 2. Per-field overrides win over the preset (precedence rule).
|
||||
let mut overridden = Vec::new();
|
||||
if let Some(b) = opts.bitrate {
|
||||
eff.bitrate = b;
|
||||
overridden.push("bitrate");
|
||||
}
|
||||
if let Some(f) = opts.framerate {
|
||||
eff.framerate = f;
|
||||
overridden.push("fps");
|
||||
}
|
||||
if let Some(h) = opts.max_height {
|
||||
eff.max_height = Some(h);
|
||||
overridden.push("max-height");
|
||||
}
|
||||
if !overridden.is_empty() {
|
||||
eff.note = format!("{}; override: {}", eff.note, overridden.join(", "));
|
||||
}
|
||||
|
||||
eff
|
||||
}
|
||||
|
||||
/// Auto: pick the highest preset whose per-viewer bitrate fits the measured
|
||||
/// safe upstream divided by the viewer count. Falls back to [`AUTO_FALLBACK`]
|
||||
/// when there's no usable measurement. Pure (no config I/O) so it's testable;
|
||||
/// [`resolve`] supplies the measurement via [`measured_safe_mbps`].
|
||||
fn resolve_auto(safe_mbps: Option<f64>, sizing_viewers: u32) -> (Preset, String, String) {
|
||||
match safe_mbps {
|
||||
Some(safe_mbps) => {
|
||||
let n = sizing_viewers.max(1);
|
||||
let budget_mbps = safe_mbps / n as f64;
|
||||
let chosen = AUTO_LADDER
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|q| {
|
||||
let kbps = q.preset().expect("ladder is fixed presets").bitrate;
|
||||
(kbps as f64) / 1000.0 <= budget_mbps
|
||||
})
|
||||
.unwrap_or(Quality::Low);
|
||||
let preset = chosen.preset().expect("ladder is fixed presets");
|
||||
(
|
||||
preset,
|
||||
format!("Auto → {}", chosen.name()),
|
||||
format!(
|
||||
"auto: {safe_mbps:.1} Mbps safe ÷ {n} viewer(s) = {budget_mbps:.1} Mbps each"
|
||||
),
|
||||
)
|
||||
}
|
||||
None => {
|
||||
let preset = AUTO_FALLBACK.preset().expect("fallback is a fixed preset");
|
||||
(
|
||||
preset,
|
||||
format!("Auto → {}", AUTO_FALLBACK.name()),
|
||||
"auto fallback — no bandwidth measurement (run `pixelpass --reconfigure`)"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The saved safe-upstream figure, only when the pre-flight actually measured
|
||||
/// one. Skipped/failed/unmeasured all return `None` so Auto falls back.
|
||||
fn measured_safe_mbps() -> Option<f64> {
|
||||
let cfg = config::load().ok()?;
|
||||
if cfg.bandwidth.status == BandwidthStatus::Measured {
|
||||
cfg.bandwidth.upstream_mbps
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cli::{DisplayServerArg, HostOpts};
|
||||
|
||||
/// A HostOpts with no overrides, parameterized by quality + max_viewers.
|
||||
fn opts(quality: Quality, max_viewers: Option<u32>) -> HostOpts {
|
||||
HostOpts {
|
||||
window: false,
|
||||
app: None,
|
||||
strict_audio: false,
|
||||
display_server: None::<DisplayServerArg>,
|
||||
quality,
|
||||
bitrate: None,
|
||||
framerate: None,
|
||||
max_height: None,
|
||||
no_hwencode: false,
|
||||
max_viewers,
|
||||
interactive: false,
|
||||
relay: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_presets_pass_through_their_tuple() {
|
||||
let e = resolve(&opts(Quality::Medium, None), 1);
|
||||
assert_eq!(e.max_height, Some(720));
|
||||
assert_eq!(e.bitrate, 2500);
|
||||
assert_eq!(e.framerate, 30);
|
||||
assert_eq!(e.label, "Medium");
|
||||
assert_eq!(e.note, "user-specified");
|
||||
|
||||
// Source is the native (no-scale) preset.
|
||||
assert_eq!(resolve(&opts(Quality::Source, None), 1).max_height, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_picks_highest_preset_that_fits_budget() {
|
||||
// Ample upstream, single viewer → Source fits (6 Mbps <= 8.78).
|
||||
let (p, label, _) = resolve_auto(Some(8.78), 1);
|
||||
assert_eq!(p.bitrate, 6000);
|
||||
assert_eq!(label, "Auto → Source");
|
||||
|
||||
// 10 Mbps split across 2 viewers = 5 each → Source(6) no, High(4) yes.
|
||||
let (p, label, _) = resolve_auto(Some(10.0), 2);
|
||||
assert_eq!(p.bitrate, 4000);
|
||||
assert_eq!(label, "Auto → High");
|
||||
|
||||
// Tight budget falls to the bottom of the ladder, never below Low.
|
||||
let (p, _, _) = resolve_auto(Some(0.3), 1);
|
||||
assert_eq!(p.bitrate, 1000); // Low
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_without_measurement_falls_back_to_medium() {
|
||||
let (p, label, note) = resolve_auto(None, 1);
|
||||
assert_eq!(p.bitrate, 2500); // Medium
|
||||
assert_eq!(p.max_height, Some(720));
|
||||
assert_eq!(label, "Auto → Medium");
|
||||
assert!(note.contains("reconfigure"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_flags_override_preset_fields() {
|
||||
let mut o = opts(Quality::High, None);
|
||||
o.bitrate = Some(9000);
|
||||
o.framerate = Some(60);
|
||||
let e = resolve(&o, 1);
|
||||
assert_eq!(e.bitrate, 9000); // override wins
|
||||
assert_eq!(e.framerate, 60); // override wins
|
||||
assert_eq!(e.max_height, Some(1080)); // untouched preset field
|
||||
assert!(e.note.contains("override: bitrate, fps"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_height_override_is_rounded_even_and_applies_to_source() {
|
||||
// Odd override rounds down to even in the pipeline; here we just assert
|
||||
// the override replaces the (native) Source height with the raw value;
|
||||
// the even-rounding happens in pipeline::build_args.
|
||||
let mut o = opts(Quality::Source, None);
|
||||
o.max_height = Some(900);
|
||||
let e = resolve(&o, 1);
|
||||
assert_eq!(e.max_height, Some(900));
|
||||
assert!(e.note.contains("override: max-height"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//! Display-server-agnostic serving layer: takes a capture child's stdout
|
||||
//! producing MPEG-TS bytes and fans them out to N concurrent HTTP viewers
|
||||
//! on a localhost port. One reader task pumps stdout chunks into a
|
||||
//! tokio::sync::broadcast channel; the accept loop spawns one drain task
|
||||
//! per accepted TCP connection. Slow consumers see Lagged and skip ahead;
|
||||
//! MPEG-TS resyncs at the next keyframe.
|
||||
//!
|
||||
//! Backends (host/wayland.rs, future host/x11.rs) build their own gst
|
||||
//! pipeline and hand the resulting ChildStdout to [`Serve::bind`].
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::process::ChildStdout;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::{Instant, sleep};
|
||||
|
||||
/// Broadcast-channel capacity in chunks. Each chunk is up to 64 KiB from
|
||||
/// the capture child's stdout, so 16 chunks ≈ 1 MiB ≈ ~2 s of buffered
|
||||
/// jitter at typical bitrates. A viewer that falls behind by more than
|
||||
/// this gets Lagged and skips ahead — MPEG-TS recovers at the next
|
||||
/// keyframe.
|
||||
const FANOUT_CAPACITY: usize = 16;
|
||||
|
||||
/// Size of each chunk read from the capture child's stdout.
|
||||
const READ_CHUNK: usize = 64 * 1024;
|
||||
|
||||
/// Owns the localhost HTTP listener and the two long-running tasks that
|
||||
/// pump bytes from a capture child to all connected viewers.
|
||||
pub struct Serve {
|
||||
port: u16,
|
||||
reader: Option<JoinHandle<()>>,
|
||||
server: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Serve {
|
||||
/// Bind a localhost listener on a random port, set up the broadcast
|
||||
/// fanout, and spawn the reader + accept-loop tasks. The provided
|
||||
/// `stdout` is assumed to produce MPEG-TS bytes.
|
||||
pub async fn bind(stdout: ChildStdout) -> Result<Self> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.context("could not bind local capture HTTP listener")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
|
||||
let (tx, _) = broadcast::channel::<Arc<Vec<u8>>>(FANOUT_CAPACITY);
|
||||
let reader = tokio::spawn(pump_to_broadcast(stdout, tx.clone()));
|
||||
let server = tokio::spawn(run_accept_loop(listener, tx));
|
||||
|
||||
Ok(Self {
|
||||
port,
|
||||
reader: Some(reader),
|
||||
server: Some(server),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn local_port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// Abort the reader and accept-loop tasks. Backends typically call this
|
||||
/// after killing their capture child so the reader sees stdout EOF and
|
||||
/// exits on its own; the abort is a backstop.
|
||||
pub async fn shutdown(mut self) {
|
||||
if let Some(task) = self.reader.take() {
|
||||
task.abort();
|
||||
}
|
||||
if let Some(task) = self.server.take() {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Serve {
|
||||
fn drop(&mut self) {
|
||||
if let Some(task) = self.reader.as_ref() {
|
||||
task.abort();
|
||||
}
|
||||
if let Some(task) = self.server.as_ref() {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to the local capture HTTP listener, retrying until it's up or
|
||||
/// we time out. Returns the connected socket — the bridge layer pipes
|
||||
/// QUIC↔this socket once it's open.
|
||||
pub async fn connect_to_capture(port: u16, max_wait: Duration) -> Result<TcpStream> {
|
||||
let deadline = Instant::now() + max_wait;
|
||||
loop {
|
||||
match TcpStream::connect(("127.0.0.1", port)).await {
|
||||
Ok(stream) => return Ok(stream),
|
||||
Err(_) if Instant::now() < deadline => {
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
Err(e) => bail!("capture HTTP listener never came up on 127.0.0.1:{port}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the capture child's stdout in chunks and broadcast each to all
|
||||
/// current subscribers. `broadcast::send` returns Err when there are no
|
||||
/// receivers; we ignore it so the capture child isn't backpressured
|
||||
/// waiting for a viewer.
|
||||
async fn pump_to_broadcast(mut stdout: ChildStdout, tx: broadcast::Sender<Arc<Vec<u8>>>) {
|
||||
let mut buf = vec![0u8; READ_CHUNK];
|
||||
loop {
|
||||
match stdout.read(&mut buf).await {
|
||||
Ok(0) => {
|
||||
tracing::info!("capture stdout EOF — fanout reader exiting");
|
||||
return;
|
||||
}
|
||||
Ok(n) => {
|
||||
let chunk = Arc::new(buf[..n].to_vec());
|
||||
let _ = tx.send(chunk);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("capture stdout read error: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_accept_loop(listener: TcpListener, tx: broadcast::Sender<Arc<Vec<u8>>>) {
|
||||
loop {
|
||||
let sock = match listener.accept().await {
|
||||
Ok((s, _)) => s,
|
||||
Err(e) => {
|
||||
// Most accept errors are transient (EMFILE from a brief FD spike,
|
||||
// EINTR, etc.). Bailing on the first one would kill the entire
|
||||
// viewer fanout for the rest of the session.
|
||||
tracing::warn!("capture HTTP accept failed (continuing): {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let rx = tx.subscribe();
|
||||
tokio::spawn(serve_one_viewer(sock, rx));
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_one_viewer(mut sock: TcpStream, mut rx: broadcast::Receiver<Arc<Vec<u8>>>) {
|
||||
if !drain_http_request(&mut sock).await {
|
||||
return;
|
||||
}
|
||||
|
||||
const RESPONSE: &[u8] = b"HTTP/1.1 200 OK\r\n\
|
||||
Content-Type: video/mp2t\r\n\
|
||||
Cache-Control: no-cache, no-store\r\n\
|
||||
Connection: close\r\n\
|
||||
\r\n";
|
||||
if sock.write_all(RESPONSE).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(chunk) => {
|
||||
if sock.write_all(&chunk).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
||||
tracing::warn!(
|
||||
skipped,
|
||||
"viewer fanout lagged — MPEG-TS will resync at next keyframe"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn drain_http_request(sock: &mut TcpStream) -> bool {
|
||||
let mut buf = [0u8; 1024];
|
||||
let mut total = Vec::with_capacity(512);
|
||||
loop {
|
||||
match sock.read(&mut buf).await {
|
||||
Ok(0) => return false,
|
||||
Ok(n) => total.extend_from_slice(&buf[..n]),
|
||||
Err(_) => return false,
|
||||
}
|
||||
if total.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
return true;
|
||||
}
|
||||
if total.len() > 16 * 1024 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
//! Synthetic graph builders for the taint-engine tests.
|
||||
//!
|
||||
//! Serials are handed out monotonically and never reused, exactly as
|
||||
//! PipeWire does; global ids are handed out separately and **may be reused
|
||||
//! on purpose**, which is what the recycling tests need.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::snapshot::{
|
||||
ClientSnapshot, GlobalId, GraphSnapshot, LinkSnapshot, MediaRole, NodeProps, NodeSnapshot,
|
||||
PortDirection, PortSnapshot, Serial,
|
||||
};
|
||||
|
||||
/// pipewire-pulse's PID, as measured on the target machine.
|
||||
pub const PULSE_PID: u32 = 2541;
|
||||
/// WirePlumber's PID — one process owning every device node on the box.
|
||||
pub const SESSION_PID: u32 = 900;
|
||||
|
||||
/// A node's identity in a fixture: what tests pass around.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub struct NodeRef {
|
||||
pub serial: Serial,
|
||||
pub id: GlobalId,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Graph {
|
||||
next_serial: u64,
|
||||
next_id: u32,
|
||||
nodes: Vec<NodeSnapshot>,
|
||||
ports: Vec<PortSnapshot>,
|
||||
links: Vec<LinkSnapshot>,
|
||||
clients: Vec<ClientSnapshot>,
|
||||
/// One client connection per process / per module, which is what the
|
||||
/// live graph looks like. Tests that need the *split*-client shape
|
||||
/// (GStreamer opens one per stream) pass clients explicitly instead.
|
||||
client_by_app: BTreeMap<u32, GlobalId>,
|
||||
client_by_module: BTreeMap<u64, GlobalId>,
|
||||
session_client: Option<GlobalId>,
|
||||
}
|
||||
|
||||
impl Graph {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
// Start past u32::MAX so every fixture also exercises the phase
|
||||
// 0a widening: a serial that a u32 model would have truncated.
|
||||
next_serial: u64::from(u32::MAX) + 1,
|
||||
next_id: 1,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn serial(&mut self) -> Serial {
|
||||
self.next_serial += 1;
|
||||
Serial(self.next_serial)
|
||||
}
|
||||
|
||||
fn id(&mut self) -> GlobalId {
|
||||
self.next_id += 1;
|
||||
GlobalId(self.next_id)
|
||||
}
|
||||
|
||||
/// A client object. `sec_pid` is `pipewire.sec.pid` — pipewire-pulse's
|
||||
/// PID for Pulse-emulated clients.
|
||||
pub fn client(&mut self, sec_pid: Option<u32>) -> GlobalId {
|
||||
let serial = self.serial();
|
||||
let id = self.id();
|
||||
self.clients.push(ClientSnapshot {
|
||||
serial,
|
||||
id,
|
||||
sec_pid,
|
||||
});
|
||||
id
|
||||
}
|
||||
|
||||
/// The client connection an ordinary process holds — one per PID,
|
||||
/// created on demand.
|
||||
pub fn client_of_app(&mut self, pid: u32) -> GlobalId {
|
||||
if let Some(id) = self.client_by_app.get(&pid) {
|
||||
return *id;
|
||||
}
|
||||
let id = self.client(Some(PULSE_PID));
|
||||
self.client_by_app.insert(pid, id);
|
||||
id
|
||||
}
|
||||
|
||||
/// An ordinary application stream: its own client, its own PID.
|
||||
pub fn app_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
|
||||
let client = self.client_of_app(pid);
|
||||
self.node(name, role, app(client, pid))
|
||||
}
|
||||
|
||||
/// The client a pactl module holds. Measured: each module gets its own
|
||||
/// (`sink-sunshine-*` were clients 83/86/92), which is why one tainted
|
||||
/// module does not fuse with the next.
|
||||
pub fn client_of_module(&mut self, module: u64) -> GlobalId {
|
||||
match self.client_by_module.get(&module) {
|
||||
Some(id) => *id,
|
||||
None => {
|
||||
let id = self.client(Some(PULSE_PID));
|
||||
self.client_by_module.insert(module, id);
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A leg of a pactl-loaded module: one client per module, and the
|
||||
/// node's `application.process.id` is **pipewire-pulse's own**, because
|
||||
/// pipewire-pulse genuinely is the client.
|
||||
pub fn module_node(&mut self, name: &str, role: MediaRole, module: u64) -> NodeRef {
|
||||
let client = self.client_of_module(module);
|
||||
self.node(name, role, pulse_module(client, module, PULSE_PID))
|
||||
}
|
||||
|
||||
/// A leg joined to its siblings by `node.link-group` — loopback,
|
||||
/// filter-chain, echo-cancel.
|
||||
pub fn group_node(&mut self, name: &str, role: MediaRole, group: &str, pid: u32) -> NodeRef {
|
||||
let client = self.client_of_app(pid);
|
||||
self.node(name, role, link_group(group, client, pid))
|
||||
}
|
||||
|
||||
/// A device node as the session manager creates it: no strong key,
|
||||
/// WirePlumber's client and PID — shared with every other device — and
|
||||
/// a `device.id`, which is what marks it as session-manager-exported.
|
||||
pub fn device_node(&mut self, name: &str, role: MediaRole) -> NodeRef {
|
||||
let session = match self.session_client {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let id = self.client(None);
|
||||
self.session_client = Some(id);
|
||||
id
|
||||
}
|
||||
};
|
||||
self.node(name, role, device(session, SESSION_PID))
|
||||
}
|
||||
|
||||
/// A node that *belongs to* a Device but is not a passive device node —
|
||||
/// a filter associated with a card. Phase 3 must not classify this as a
|
||||
/// session device, or it loses both its coarse owner keys and its
|
||||
/// ability to trip the fail-closed backstop.
|
||||
pub fn device_associated_filter(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
|
||||
let client = self.client_of_app(pid);
|
||||
self.node(name, role, app(client, pid))
|
||||
}
|
||||
|
||||
/// A **virtual** sink an application created natively: an `Audio/Sink`
|
||||
/// with no `device.id` and no strong key, sharing one client with the
|
||||
/// stream that re-emits what it receives. Coarse keys must still bridge
|
||||
/// these two, or the whole call leaks through the re-emitting leg.
|
||||
pub fn native_virtual_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
|
||||
let client = self.client_of_app(pid);
|
||||
self.node(name, role, app(client, pid))
|
||||
}
|
||||
|
||||
pub fn peerspeak_node(&mut self, name: &str, pid: u32) -> NodeRef {
|
||||
let client = self.client_of_app(pid);
|
||||
self.node(name, MediaRole::StreamOutput, peerspeak_owned(client, pid))
|
||||
}
|
||||
|
||||
pub fn node(&mut self, name: &str, role: MediaRole, props: NodeProps) -> NodeRef {
|
||||
let id = self.id();
|
||||
self.node_with_id(name, role, id, props)
|
||||
}
|
||||
|
||||
/// Force a global id — for reproducing id recycling after teardown.
|
||||
pub fn node_with_id(
|
||||
&mut self,
|
||||
name: &str,
|
||||
role: MediaRole,
|
||||
id: GlobalId,
|
||||
props: NodeProps,
|
||||
) -> NodeRef {
|
||||
let serial = self.serial();
|
||||
self.nodes.push(NodeSnapshot {
|
||||
serial,
|
||||
id,
|
||||
name: Some(name.to_string()),
|
||||
role,
|
||||
props,
|
||||
});
|
||||
NodeRef { serial, id }
|
||||
}
|
||||
|
||||
pub fn port(&mut self, node: NodeRef, direction: PortDirection, exclusive: bool) {
|
||||
let serial = self.serial();
|
||||
let id = self.id();
|
||||
self.ports.push(PortSnapshot {
|
||||
serial,
|
||||
id,
|
||||
node: node.id,
|
||||
direction,
|
||||
exclusive,
|
||||
monitor: false,
|
||||
});
|
||||
}
|
||||
|
||||
/// A signal edge: audio flows `from → to`.
|
||||
pub fn link(&mut self, from: NodeRef, to: NodeRef) {
|
||||
self.link_ids(from.id, to.id);
|
||||
}
|
||||
|
||||
/// A link naming raw ids, so a test can dangle an endpoint.
|
||||
pub fn link_ids(&mut self, from: GlobalId, to: GlobalId) {
|
||||
let serial = self.serial();
|
||||
let id = self.id();
|
||||
self.links.push(LinkSnapshot {
|
||||
serial,
|
||||
id,
|
||||
output_node: from,
|
||||
input_node: to,
|
||||
output_port: None,
|
||||
input_port: None,
|
||||
});
|
||||
}
|
||||
|
||||
/// An id that belongs to nothing — for unresolved-endpoint tests.
|
||||
pub fn dangling_id(&mut self) -> GlobalId {
|
||||
self.id()
|
||||
}
|
||||
|
||||
pub fn build(&self) -> GraphSnapshot {
|
||||
self.build_without(&[])
|
||||
}
|
||||
|
||||
/// A later snapshot in which some nodes have gone away, along with
|
||||
/// their ports and every link touching them. Surviving objects keep
|
||||
/// their serials, which is what makes sticky-taint sequences testable.
|
||||
pub fn build_without(&self, dropped: &[NodeRef]) -> GraphSnapshot {
|
||||
let gone_serials: Vec<Serial> = dropped.iter().map(|n| n.serial).collect();
|
||||
let nodes: Vec<NodeSnapshot> = self
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|n| !gone_serials.contains(&n.serial))
|
||||
.cloned()
|
||||
.collect();
|
||||
// Filter by what was *dropped*, not by what is live: a link to an id
|
||||
// that never had a node is a dangling endpoint, and dropping those
|
||||
// here would quietly disarm every unresolved-ancestry test.
|
||||
let gone_ids: Vec<GlobalId> = dropped.iter().map(|n| n.id).collect();
|
||||
GraphSnapshot::new(
|
||||
nodes,
|
||||
self.ports
|
||||
.iter()
|
||||
.filter(|p| !gone_ids.contains(&p.node))
|
||||
.cloned()
|
||||
.collect(),
|
||||
self.links
|
||||
.iter()
|
||||
.filter(|l| !gone_ids.contains(&l.output_node) && !gone_ids.contains(&l.input_node))
|
||||
.cloned()
|
||||
.collect(),
|
||||
self.clients.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Drop clients too — full owner teardown.
|
||||
///
|
||||
/// Invalidates the per-app/per-module caches as well: leaving them
|
||||
/// stale made a later `client_of_app` hand back the *removed* client's
|
||||
/// id, so a test that meant "a brand-new client after teardown" was
|
||||
/// really building a node pointing at a client object that no longer
|
||||
/// existed (Codex round 1, finding 8).
|
||||
pub fn drop_clients(&mut self, ids: &[GlobalId]) {
|
||||
self.clients.retain(|c| !ids.contains(&c.id));
|
||||
self.client_by_app.retain(|_, id| !ids.contains(id));
|
||||
self.client_by_module.retain(|_, id| !ids.contains(id));
|
||||
if self.session_client.is_some_and(|id| ids.contains(&id)) {
|
||||
self.session_client = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// A client that reuses a global id a dead client had — the recycling
|
||||
/// case, with a fresh serial.
|
||||
pub fn client_with_id(&mut self, id: GlobalId, sec_pid: Option<u32>) -> GlobalId {
|
||||
let serial = self.serial();
|
||||
self.clients.push(ClientSnapshot {
|
||||
serial,
|
||||
id,
|
||||
sec_pid,
|
||||
});
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
/// An ordinary application stream: real PID, one client connection.
|
||||
pub fn app(client: GlobalId, pid: u32) -> NodeProps {
|
||||
NodeProps {
|
||||
client_id: Some(client),
|
||||
process_id: Some(pid),
|
||||
..NodeProps::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A pactl-module-created stream: the daemon is the client, so the node's
|
||||
/// `application.process.id` is pipewire-pulse's own.
|
||||
pub fn pulse_module(client: GlobalId, module: u64, pulse_pid: u32) -> NodeProps {
|
||||
NodeProps {
|
||||
pulse_module_id: Some(module),
|
||||
client_id: Some(client),
|
||||
process_id: Some(pulse_pid),
|
||||
..NodeProps::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A PipeWire-module leg joined to its siblings by `node.link-group`
|
||||
/// (loopback, filter-chain, echo-cancel).
|
||||
pub fn link_group(group: &str, client: GlobalId, pid: u32) -> NodeProps {
|
||||
NodeProps {
|
||||
link_group: Some(group.to_string()),
|
||||
client_id: Some(client),
|
||||
process_id: Some(pid),
|
||||
..NodeProps::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A device node as the session manager creates it: no strong key, and the
|
||||
/// session manager's own client and PID — shared with every other device.
|
||||
///
|
||||
/// Measured 2026-07-21: real ALSA device nodes carry the shared
|
||||
/// `client.id` but **no** `application.process.id` at all. Giving them one
|
||||
/// here is deliberately *more* pessimistic than reality — it hands the
|
||||
/// engine a second coarse key it could fuse devices on, so a test that
|
||||
/// passes here also passes against the real props.
|
||||
pub fn device(session_client: GlobalId, session_pid: u32) -> NodeProps {
|
||||
NodeProps {
|
||||
client_id: Some(session_client),
|
||||
process_id: Some(session_pid),
|
||||
session_device: true,
|
||||
..NodeProps::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn peerspeak_owned(client: GlobalId, pid: u32) -> NodeProps {
|
||||
NodeProps {
|
||||
peerspeak_owned: true,
|
||||
..app(client, pid)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,959 @@
|
||||
//! The taint engine — decides which `Stream/Output/Audio` nodes may be
|
||||
//! fanned out into the screen-share capture without echoing peerspeak's own
|
||||
//! audio back at the viewer.
|
||||
//!
|
||||
//! Implements design v3.4 §6.1–§6.1.3 (`peerspeak/docs/
|
||||
//! screenshare-audio-exclusion-plan.md`), phase 2 of the implementation
|
||||
//! plan. **Pure**: no PipeWire types appear in any signature, nothing here
|
||||
//! touches the daemon, and every test builds its own graph.
|
||||
//!
|
||||
//! ## The one-sentence predicate
|
||||
//!
|
||||
//! > A node is eligible only if **no** signal path reaches it from a
|
||||
//! > peerspeak-owned node, the live AEC identity, or any pixelpass-owned
|
||||
//! > object. **Unresolvable ancestry is not eligible.**
|
||||
//!
|
||||
//! That last sentence is the invariant the whole design rests on: every
|
||||
//! other failure mode in here degrades into over-exclusion (one app's audio
|
||||
//! silently missing from the share) rather than into echo.
|
||||
//!
|
||||
//! ## Why a graph walk and not a property check
|
||||
//!
|
||||
//! Exclusion does not propagate downstream by itself. Any node that
|
||||
//! re-emits audio it received is a fresh, *untagged* `Stream/Output/Audio`
|
||||
//! carrying the mix — including the one peerspeak playback stream that was
|
||||
//! correctly excluded one hop earlier. EasyEffects, `module-loopback`,
|
||||
//! combine-sinks, tunnel/RTP sinks and virtual-sink forwarders all have this
|
||||
//! shape, and at least one such topology has been observed live on the
|
||||
//! target machine.
|
||||
//!
|
||||
//! Taint therefore flows over **three** edge types:
|
||||
//!
|
||||
//! 1. **Link edges** — `link.output.node → link.input.node`.
|
||||
//! 2. **Sink → monitor** — free at node granularity: the monitor connection
|
||||
//! *is* a real Link whose output node is the sink node itself (measured).
|
||||
//! A port-granular walk would need a synthetic edge; a node-granular one
|
||||
//! does not.
|
||||
//! 3. **Owner bridges** — the intra-process hop the graph cannot see. See
|
||||
//! [`owner`]; this is the hard one.
|
||||
//!
|
||||
//! ## Stickiness
|
||||
//!
|
||||
//! Taint is **sticky per owner** for the duration of the share, because a
|
||||
//! topological recompute forgets *buffered* audio: an app can read a tainted
|
||||
//! monitor into a 5-second ring buffer, then have its input leg vanish, and
|
||||
//! a purely topological engine would relink its output while it is still
|
||||
//! emitting peerspeak's audio out of that buffer. No graph event marks the
|
||||
//! moment a buffer drains.
|
||||
//!
|
||||
//! Stickiness is keyed on [`Serial`] — never on a node id, `client.id`,
|
||||
//! module index or `link-group` string, **all of which recycle on this
|
||||
//! stack**. An entry is cleared only once every member object has
|
||||
//! disappeared; a key that reappears after full teardown is a new owner and
|
||||
//! starts clean.
|
||||
//!
|
||||
//! ## ⚠️ KNOWN OPEN GAP — buffered audio across a full PipeWire teardown of
|
||||
//! ## a still-live process (Codex phase-2 rounds 5–6) — DESIGN DECISION OWED
|
||||
//!
|
||||
//! **This is an in-threat-model echo gap, not an outside-the-model one — an
|
||||
//! earlier version of this note wrongly scoped it to keyless streams.**
|
||||
//!
|
||||
//! The scenario, entirely with a real PID-bearing app (a recorder, a DAW,
|
||||
//! a GStreamer pipeline): it reads the call into an application buffer,
|
||||
//! **fully** tears down its PipeWire Node *and* Client while keeping that
|
||||
//! buffer, then — still the same live process — opens a fresh Client and a
|
||||
//! `Stream/Output/Audio` and replays. Every old serial is gone, so
|
||||
//! [`seed_sticky`] refuses to apply the remembered PID fingerprint (the
|
||||
//! fingerprint is lifetime-scoped to a live serial member, because bare keys
|
||||
//! recycle); no reader is live in the new epoch, so the backstop does not
|
||||
//! fire; the replayed leg is eligible.
|
||||
//!
|
||||
//! It is real and reachable by non-adversarial software. It also sits
|
||||
//! exactly on the design's stated boundary (v3.4 §6.1.3: "a key that
|
||||
//! reappears after full teardown is a new owner and starts clean"), so
|
||||
//! closing it is a **design change**, not a local bug fix:
|
||||
//!
|
||||
//! - **Option A — accept as a documented v1 limitation.** Contrived in
|
||||
//! practice (most apps hold their PipeWire connection open for their
|
||||
//! lifetime; the round-2 fix already covers the common
|
||||
//! idle-a-client-and-open-another case), never a *silent* correctness
|
||||
//! regression since it is written down, and phase 5's dry run would show
|
||||
//! it. But it is a known echo path, which sits badly against the feature's
|
||||
//! fail-closed ethos.
|
||||
//! - **Option B — process-generation lifetime.** Key the fingerprint's
|
||||
//! lifetime on the owning **process** being alive — PID + `/proc` start
|
||||
//! time (or a pidfd) to defeat PID reuse — instead of on a live PipeWire
|
||||
//! object. Phase 3 supplies process liveness; §6.1.3's node/client-only
|
||||
//! lifetime definition is revised. Closes the PID-bearing case; the truly
|
||||
//! keyless sub-case (no PID at all) genuinely *is* outside the threat
|
||||
//! model and stays a documented limit.
|
||||
//!
|
||||
//! The choice is the designer's (it revises the security surface). Until it
|
||||
//! is made, `a_fingerprint_does_not_outlive_its_owner` encodes Option A's
|
||||
//! behaviour — flip it if B is chosen. Owed to the design doc as round 8.
|
||||
|
||||
// Phase 2 lands the engine behind its own test surface and nothing else:
|
||||
// the registry observer that will feed it is phase 3, so in a non-test
|
||||
// build every item here is legitimately unreachable for now.
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod owner;
|
||||
pub mod snapshot;
|
||||
|
||||
// `pub` so the phase-5 audit's pure tests can drive the auditor with the same
|
||||
// graph builder the taint fixtures use — one fixture vocabulary, so an audit
|
||||
// test and a taint test describing the same topology cannot drift apart.
|
||||
#[cfg(test)]
|
||||
pub mod fixture;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
|
||||
use owner::{OwnerComponents, OwnerKey};
|
||||
use snapshot::{GraphSnapshot, IdLookup, MediaRole, NodeSnapshot, Serial};
|
||||
|
||||
/// The `node.name` prefix of a pixelpass capture sink. Any host's sink
|
||||
/// counts, not just ours — fanning out a stream that is downstream of
|
||||
/// *another* pixelpass host's capture sink builds a cycle (v3.4 §6.2).
|
||||
pub const CAPTURE_SINK_PREFIX: &str = "pixelpass_capture_";
|
||||
|
||||
/// `node.link-group` prefix that marks *some* echo canceller. Hazard
|
||||
/// detection only — it does **not** identify peerspeak's instance, which is
|
||||
/// what `pulse.module.id` is for (v3.4 §5.2 correction 4).
|
||||
pub const ECHO_CANCEL_GROUP_PREFIX: &str = "echo-cancel-";
|
||||
|
||||
/// Why a node is tainted or excluded. Stable machine-readable codes: this
|
||||
/// value is the phase 5 audit output, the phase 6 status event, and the
|
||||
/// eventual answer to "why isn't this app being shared?".
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
|
||||
pub enum Reason {
|
||||
/// Carries the `peerspeak.owned` tag (v3.4 §5.1).
|
||||
PeerspeakOwned,
|
||||
/// `pulse.module.id` equals the live AEC module index — exact equality
|
||||
/// only. "Has any `pulse.module.id`" is explicitly rejected as a rule:
|
||||
/// tunnel/RTP/loopback modules may be the only carrier of audio the
|
||||
/// user legitimately wants shared (v3.4 §5.2 correction 2).
|
||||
AecIdentity,
|
||||
/// A pixelpass-owned object, ours or another host's capture sink.
|
||||
PixelpassOwned,
|
||||
/// An `echo-cancel-*` group that is **not** our validated identity.
|
||||
/// Decision D3: warn and exclude rather than fan out.
|
||||
ForeignEchoCancel,
|
||||
/// Reached by a signal path from a tainted node (link or monitor edge).
|
||||
TaintedUpstream,
|
||||
/// Reached across an owner bridge; the key that did it, when the
|
||||
/// tainted member shares one directly rather than transitively.
|
||||
TaintedOwnerBridge { key: Option<OwnerKey> },
|
||||
/// A link endpoint, or a node's own id, could not be resolved in this
|
||||
/// snapshot. Fail closed (v3.4 §6.1.4).
|
||||
UnresolvedAncestry,
|
||||
/// A tainted capture stream whose owner cannot be bounded by any usable
|
||||
/// key, so its sibling output legs cannot be identified. Fail closed
|
||||
/// (v3.4 §6.1.1, final paragraph).
|
||||
UnresolvedOwner,
|
||||
/// The observer has not reached a complete, coherent view of the graph
|
||||
/// yet. No decision made from a partial graph is a decision.
|
||||
GraphNotReady,
|
||||
/// A `port.exclusive` port — fan-out will be refused (v3.4 §6.2). Local
|
||||
/// to the node; does not propagate.
|
||||
PortExclusive,
|
||||
/// An encoded/passthrough stream — a second link would corrupt it.
|
||||
/// Local to the node; does not propagate.
|
||||
Passthrough,
|
||||
}
|
||||
|
||||
impl Reason {
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::PeerspeakOwned => "peerspeak-owned",
|
||||
Self::AecIdentity => "aec-identity",
|
||||
Self::PixelpassOwned => "pixelpass-owned",
|
||||
Self::ForeignEchoCancel => "foreign-echo-cancel",
|
||||
Self::TaintedUpstream => "tainted-upstream",
|
||||
Self::TaintedOwnerBridge { .. } => "tainted-owner-bridge",
|
||||
Self::UnresolvedAncestry => "unresolved-ancestry",
|
||||
Self::UnresolvedOwner => "unresolved-owner",
|
||||
Self::GraphNotReady => "graph-not-ready",
|
||||
Self::PortExclusive => "port-exclusive",
|
||||
Self::Passthrough => "passthrough",
|
||||
}
|
||||
}
|
||||
|
||||
/// Lower wins. A node can acquire taint several ways in one recompute
|
||||
/// and the reported reason must not depend on traversal order, or the
|
||||
/// audit output is unstable and the fixture tests are flaky. Explicit
|
||||
/// priority, not BFS arrival order.
|
||||
fn priority(self) -> u8 {
|
||||
match self {
|
||||
Self::PeerspeakOwned => 0,
|
||||
Self::AecIdentity => 1,
|
||||
Self::PixelpassOwned => 2,
|
||||
Self::ForeignEchoCancel => 3,
|
||||
Self::TaintedUpstream => 4,
|
||||
Self::TaintedOwnerBridge { .. } => 5,
|
||||
Self::UnresolvedAncestry => 6,
|
||||
Self::UnresolvedOwner => 7,
|
||||
// Non-propagating; never competes with the taint reasons above
|
||||
// because it is only consulted for untainted candidates.
|
||||
Self::GraphNotReady => 8,
|
||||
Self::PortExclusive => 9,
|
||||
Self::Passthrough => 10,
|
||||
}
|
||||
}
|
||||
|
||||
/// Does this reason spread to downstream nodes and owner siblings?
|
||||
fn propagates(self) -> bool {
|
||||
self.priority() <= Self::UnresolvedOwner.priority()
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the engine needs that is not in the graph itself.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ExclusionCtx {
|
||||
/// The **validated** live AEC module index, or `None` for `--aec=off`.
|
||||
/// The validation state machine (phase 4) owns the transitions; if it
|
||||
/// is still `Validating` or has `Failed`, its caller must not fan out at
|
||||
/// all rather than passing `None` here, which would merely mean "there
|
||||
/// is no AEC".
|
||||
pub aec_module_id: Option<u64>,
|
||||
/// pipewire-pulse's own PID, derived by the observer (phase 3) from a
|
||||
/// consistent `pipewire.sec.pid` across Pulse clients validated against
|
||||
/// `/proc/<pid>/comm`. `None` is safe but coarse — see [`owner`].
|
||||
pub pipewire_pulse_pid: Option<u32>,
|
||||
/// Serials of objects pixelpass itself created this run.
|
||||
pub pixelpass_owned: BTreeSet<Serial>,
|
||||
/// False until the readiness epoch has been reached (phase 3). Every
|
||||
/// candidate is then ineligible: a decision from a partial graph is not
|
||||
/// a decision.
|
||||
pub graph_ready: bool,
|
||||
}
|
||||
|
||||
/// Object identity for sticky bookkeeping. Always a [`Serial`] — never a
|
||||
/// recyclable id (v3.4 §6.1.3).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
|
||||
pub enum ObjectRef {
|
||||
Node(Serial),
|
||||
Client(Serial),
|
||||
}
|
||||
|
||||
/// One owner that has been tainted, and every object observed to constitute
|
||||
/// it. Cleared only when **all** of them are gone.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct StickyOwner {
|
||||
/// Every object seen to be part of this owner, ever. Membership
|
||||
/// accumulates: that is what makes "clear only once all member objects
|
||||
/// have disappeared" true across churn.
|
||||
pub members: BTreeSet<ObjectRef>,
|
||||
/// Owner keys remembered across connections — strong keys and a usable
|
||||
/// process id, never `client.id`. Applied only while some serial member
|
||||
/// above is still live, which is what keeps a recyclable key from
|
||||
/// resurrecting a dead owner.
|
||||
///
|
||||
/// Needed because a live Client is not the same thing as a live owner:
|
||||
/// a process can leave one connection idle and open a second, and
|
||||
/// GStreamer opens one connection per stream as a matter of course, so
|
||||
/// following connections alone lets the next leg escape (Codex round 2,
|
||||
/// finding 2).
|
||||
pub fingerprints: BTreeSet<owner::Fingerprint>,
|
||||
/// The reason recorded for each node that was tainted in its own right.
|
||||
/// Kept per node rather than collapsed to one owner-wide reason, or a
|
||||
/// forwarder's output leg inherits its *input* leg's `tainted-upstream`
|
||||
/// and the audit output stops naming the mechanism that actually
|
||||
/// excluded it.
|
||||
pub node_reasons: BTreeMap<Serial, Reason>,
|
||||
}
|
||||
|
||||
impl StickyOwner {
|
||||
/// The reason to apply to a member: its own recorded one, or — for a
|
||||
/// leg that appeared later — the fact that it belongs to a tainted
|
||||
/// owner, which is a bridge by definition.
|
||||
fn reason_for(&self, serial: Serial) -> Reason {
|
||||
self.node_reasons
|
||||
.get(&serial)
|
||||
.copied()
|
||||
.unwrap_or(Reason::TaintedOwnerBridge { key: None })
|
||||
}
|
||||
}
|
||||
|
||||
/// Threaded explicitly through [`evaluate`] so stickiness is testable as a
|
||||
/// sequence of snapshots rather than as hidden mutable state.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct StickyState {
|
||||
pub owners: Vec<StickyOwner>,
|
||||
}
|
||||
|
||||
impl StickyState {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.owners.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Eligibility {
|
||||
Eligible,
|
||||
NotEligible {
|
||||
reason: Reason,
|
||||
/// The taint was carried over from a previous snapshot rather than
|
||||
/// derived from the current topology.
|
||||
sticky: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct NodeDecision {
|
||||
pub serial: Serial,
|
||||
pub name: Option<String>,
|
||||
pub eligibility: Eligibility,
|
||||
}
|
||||
|
||||
impl NodeDecision {
|
||||
pub fn is_eligible(&self) -> bool {
|
||||
matches!(self.eligibility, Eligibility::Eligible)
|
||||
}
|
||||
|
||||
pub fn reason(&self) -> Option<Reason> {
|
||||
match self.eligibility {
|
||||
Eligibility::Eligible => None,
|
||||
Eligibility::NotEligible { reason, .. } => Some(reason),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub struct TaintEntry {
|
||||
pub reason: Reason,
|
||||
pub sticky: bool,
|
||||
}
|
||||
|
||||
/// The result of one recompute.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Decisions {
|
||||
/// Every `Stream/Output/Audio` node in the snapshot — the complete
|
||||
/// candidate universe, so callers can assert an exact partition rather
|
||||
/// than spot-checking named nodes.
|
||||
pub candidates: BTreeMap<Serial, NodeDecision>,
|
||||
/// Taint over *all* node roles, for diagnostics and for the phase 5
|
||||
/// audit output.
|
||||
pub taint: BTreeMap<Serial, TaintEntry>,
|
||||
}
|
||||
|
||||
impl Decisions {
|
||||
/// Serials of eligible candidates, ascending.
|
||||
pub fn eligible(&self) -> Vec<Serial> {
|
||||
self.candidates
|
||||
.values()
|
||||
.filter(|d| d.is_eligible())
|
||||
.map(|d| d.serial)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `(serial, reason code)` for excluded candidates, ascending.
|
||||
pub fn excluded(&self) -> Vec<(Serial, &'static str)> {
|
||||
self.candidates
|
||||
.values()
|
||||
.filter_map(|d| d.reason().map(|r| (d.serial, r.code())))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Recompute eligibility for the whole graph.
|
||||
///
|
||||
/// Full recompute per graph event is the v1 design; there is deliberately
|
||||
/// no incremental dirty-set.
|
||||
///
|
||||
/// ⚠️ **Cost is not O(V+E), despite what v3.4 §6.4 says.** Each fixpoint
|
||||
/// pass re-runs a full link BFS *and* a full owner scan, and the bridge
|
||||
/// scans every tainted source in a component for each target, so the bound
|
||||
/// is `O(D · (V + E + Σ_C |sources_C|·|targets_C|))` — worst case
|
||||
/// `O(D · (V² + E))` — for an owner-bridge depth D. D is 1 for every
|
||||
/// topology observed so far and 2 for a forwarder feeding a forwarder, and
|
||||
/// components on a real desktop are two or three nodes; the quadratic term
|
||||
/// needs one owner with many legs. A 60-layer chain test guards the depth
|
||||
/// dimension only. Phase 5 records the real recompute-duration
|
||||
/// distribution and maximum, which is what "full recompute is fine for v1"
|
||||
/// should rest on — measured headroom, not a node count.
|
||||
pub fn evaluate(
|
||||
snapshot: &GraphSnapshot,
|
||||
ctx: &ExclusionCtx,
|
||||
prior: &StickyState,
|
||||
) -> (Decisions, StickyState) {
|
||||
let components = OwnerComponents::build(snapshot, ctx.pipewire_pulse_pid);
|
||||
let keys = owner::OwnerKeyIndex::build(snapshot, ctx.pipewire_pulse_pid);
|
||||
|
||||
let mut taint: BTreeMap<Serial, Reason> = BTreeMap::new();
|
||||
let mut sticky_serials: BTreeSet<Serial> = BTreeSet::new();
|
||||
|
||||
seed_local_roots(snapshot, ctx, &mut taint);
|
||||
seed_sticky(
|
||||
snapshot,
|
||||
&keys,
|
||||
prior,
|
||||
&components,
|
||||
&mut taint,
|
||||
&mut sticky_serials,
|
||||
);
|
||||
|
||||
// Monotone fixpoint: every step only adds taint, or lowers a node's
|
||||
// reason priority, both of which are bounded. Link propagation and the
|
||||
// owner bridge feed each other — a bridged output leg has downstream
|
||||
// links, and a downstream monitor reader bridges to its own siblings —
|
||||
// so neither can be run once.
|
||||
let edges = downstream_edges(snapshot, &mut taint);
|
||||
loop {
|
||||
let mut changed = false;
|
||||
changed |= propagate_links(&edges.edges, &mut taint);
|
||||
changed |= propagate_owner_bridge(&keys, &components, &edges, &mut taint);
|
||||
changed |= propagate_unresolved_owner(snapshot, &keys, &edges, &mut taint);
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let decisions = build_decisions(snapshot, ctx, &taint, &sticky_serials);
|
||||
// ⚠️ Readiness gates **retirement only**, never addition (Codex rounds
|
||||
// 1 and 2, which caught the two halves of this in turn). An object
|
||||
// missing from an untrustworthy snapshot has not been observed to
|
||||
// disappear, so retiring on that basis erases history and the next
|
||||
// ready recompute hands back a clean bill of health. But taint
|
||||
// *observed* during a not-ready epoch is real — a reader can consume
|
||||
// and buffer the call and then vanish before readiness — so discarding
|
||||
// additions was the same defect pointing the other way.
|
||||
let next_sticky = build_sticky(snapshot, &keys, &components, &taint, prior, ctx.graph_ready);
|
||||
(decisions, next_sticky)
|
||||
}
|
||||
|
||||
/// Roots that are visible on the node itself.
|
||||
fn seed_local_roots(
|
||||
snapshot: &GraphSnapshot,
|
||||
ctx: &ExclusionCtx,
|
||||
taint: &mut BTreeMap<Serial, Reason>,
|
||||
) {
|
||||
for node in snapshot.nodes() {
|
||||
if let Some(reason) = local_root_reason(node, ctx) {
|
||||
raise(taint, node.serial, reason);
|
||||
}
|
||||
// A node whose own global id is ambiguous cannot be the reliable
|
||||
// endpoint of any link, so its ancestry is unresolvable.
|
||||
if snapshot.node_by_id(node.id) == Some(IdLookup::Ambiguous) {
|
||||
raise(taint, node.serial, Reason::UnresolvedAncestry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn local_root_reason(node: &NodeSnapshot, ctx: &ExclusionCtx) -> Option<Reason> {
|
||||
if node.props.peerspeak_owned {
|
||||
return Some(Reason::PeerspeakOwned);
|
||||
}
|
||||
if let (Some(module), Some(aec)) = (node.props.pulse_module_id, ctx.aec_module_id)
|
||||
&& module == aec
|
||||
{
|
||||
return Some(Reason::AecIdentity);
|
||||
}
|
||||
if ctx.pixelpass_owned.contains(&node.serial)
|
||||
|| node
|
||||
.name
|
||||
.as_deref()
|
||||
.is_some_and(|name| name.starts_with(CAPTURE_SINK_PREFIX))
|
||||
{
|
||||
return Some(Reason::PixelpassOwned);
|
||||
}
|
||||
if node
|
||||
.props
|
||||
.link_group
|
||||
.as_deref()
|
||||
.is_some_and(|group| group.starts_with(ECHO_CANCEL_GROUP_PREFIX))
|
||||
{
|
||||
return Some(Reason::ForeignEchoCancel);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Carry taint forward from previous snapshots (v3.4 §6.1.3).
|
||||
///
|
||||
/// An owner is re-seeded from three kinds of evidence, all lifetime-scoped
|
||||
/// to a still-live member: its own surviving nodes, nodes on a surviving
|
||||
/// **Client**, and nodes presenting a remembered owner **fingerprint**.
|
||||
fn seed_sticky(
|
||||
snapshot: &GraphSnapshot,
|
||||
keys: &owner::OwnerKeyIndex,
|
||||
prior: &StickyState,
|
||||
components: &OwnerComponents,
|
||||
taint: &mut BTreeMap<Serial, Reason>,
|
||||
sticky_serials: &mut BTreeSet<Serial>,
|
||||
) {
|
||||
for entry in &prior.owners {
|
||||
let mut live_nodes: Vec<Serial> = Vec::new();
|
||||
for member in &entry.members {
|
||||
match member {
|
||||
ObjectRef::Node(serial) => {
|
||||
if snapshot.node(*serial).is_some() {
|
||||
live_nodes.push(*serial);
|
||||
}
|
||||
}
|
||||
// A surviving **Client** re-seeds too. An app can close
|
||||
// every stream it had while keeping its PipeWire connection
|
||||
// open, then open a fresh one — Firefox does exactly this.
|
||||
ObjectRef::Client(serial) => {
|
||||
live_nodes.extend(nodes_of_client(snapshot, keys, *serial));
|
||||
}
|
||||
}
|
||||
}
|
||||
if live_nodes.is_empty() && !entry.members.iter().any(|m| is_live(snapshot, *m)) {
|
||||
// Nothing of this owner remains; its fingerprints are just
|
||||
// recyclable strings now and must not be applied to anyone.
|
||||
continue;
|
||||
}
|
||||
// Fingerprints reach a *new connection* of the same still-live
|
||||
// process, which neither of the two paths above can see.
|
||||
for fingerprint in &entry.fingerprints {
|
||||
live_nodes.extend(
|
||||
snapshot
|
||||
.nodes()
|
||||
.filter(|node| keys.has_fingerprint(node.serial, fingerprint))
|
||||
.map(|node| node.serial),
|
||||
);
|
||||
}
|
||||
// The owner is sticky, not the individual node: a leg that appears
|
||||
// later in the same still-live owner inherits the taint.
|
||||
for serial in live_nodes {
|
||||
for member in components.members_with(serial) {
|
||||
let reason = entry.reason_for(*member);
|
||||
if raise(taint, *member, reason) || taint.get(member) == Some(&reason) {
|
||||
sticky_serials.insert(*member);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Nodes currently attached to a client, by the client's **serial**. The
|
||||
/// client's snapshot-local id is resolved fresh each time, so a recycled id
|
||||
/// can never resurrect a dead owner.
|
||||
///
|
||||
/// Nodes for which `client.id` is not a usable owner key — session-manager
|
||||
/// device nodes — are excluded, or the shared `WirePlumber [export]` Client
|
||||
/// would drag every sound card on the box into one sticky owner.
|
||||
///
|
||||
/// The same gate is applied when *recording* clients into a sticky entry
|
||||
/// (`owner::client_serials_of`). Either one alone closes the leak; both are
|
||||
/// kept because they answer different questions ("may this client be
|
||||
/// remembered?" and "may this client speak for that node?"), and the
|
||||
/// regression test kills the removal of the pair.
|
||||
fn nodes_of_client(
|
||||
snapshot: &GraphSnapshot,
|
||||
keys: &owner::OwnerKeyIndex,
|
||||
client: Serial,
|
||||
) -> Vec<Serial> {
|
||||
let Some(id) = snapshot
|
||||
.clients()
|
||||
.find(|c| c.serial == client)
|
||||
.map(|c| c.id)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
snapshot
|
||||
.nodes()
|
||||
.filter(|node| node.props.client_id == Some(id))
|
||||
.filter(|node| keys.uses_client_key(node.serial))
|
||||
.map(|node| node.serial)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `output node → input nodes`, resolving snapshot-local ids. An endpoint
|
||||
/// that does not resolve taints the *other* end as unresolved ancestry when
|
||||
/// that other end is the input side — we cannot know what is feeding it.
|
||||
fn downstream_edges(snapshot: &GraphSnapshot, taint: &mut BTreeMap<Serial, Reason>) -> Edges {
|
||||
let mut edges: BTreeMap<Serial, Vec<Serial>> = BTreeMap::new();
|
||||
let mut receivers: BTreeSet<Serial> = BTreeSet::new();
|
||||
for link in snapshot.links() {
|
||||
let from = snapshot.node_by_id(link.output_node);
|
||||
let to = snapshot.node_by_id(link.input_node);
|
||||
match (from, to) {
|
||||
(Some(IdLookup::Unique(from)), Some(IdLookup::Unique(to))) => {
|
||||
edges.entry(from).or_default().push(to);
|
||||
receivers.insert(to);
|
||||
}
|
||||
(_, Some(IdLookup::Unique(to))) => {
|
||||
// Something feeds this node and we cannot say what.
|
||||
raise(taint, to, Reason::UnresolvedAncestry);
|
||||
receivers.insert(to);
|
||||
}
|
||||
(_, Some(IdLookup::Ambiguous)) => {
|
||||
// Several nodes claim the input id and we cannot say which
|
||||
// one this link feeds, so every claimant is a receiver.
|
||||
// They are already tainted as unresolved by their own
|
||||
// ambiguous id — but taint without receiver status cannot
|
||||
// start an owner bridge, so their sibling output legs stayed
|
||||
// Eligible (Codex round 2, finding 3).
|
||||
receivers.extend(
|
||||
snapshot
|
||||
.nodes_with_id(link.input_node)
|
||||
.map(|node| node.serial),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for targets in edges.values_mut() {
|
||||
targets.sort_unstable();
|
||||
targets.dedup();
|
||||
}
|
||||
// A node that receives audio by *role* counts even with no inbound link
|
||||
// yet: a pixelpass capture sink is a taint root the moment it exists,
|
||||
// and its owner's re-emitting leg must be bridged from it immediately.
|
||||
receivers.extend(
|
||||
snapshot
|
||||
.nodes()
|
||||
.filter(|node| node.role.receives_audio())
|
||||
.map(|node| node.serial),
|
||||
);
|
||||
Edges { edges, receivers }
|
||||
}
|
||||
|
||||
/// Resolved signal edges plus the set of nodes that can receive audio.
|
||||
struct Edges {
|
||||
edges: BTreeMap<Serial, Vec<Serial>>,
|
||||
/// ⚠️ Membership is "appears as a resolved `link.input.node`" **or**
|
||||
/// "has a receiving role" — deliberately not role alone. Codex round 1:
|
||||
/// a node whose `media.class` is absent or unexpected (`Other`), or an
|
||||
/// `Audio/Source` that is really a filter output, can sit on an inbound
|
||||
/// link carrying tainted audio; inferring "receives audio" from the role
|
||||
/// alone left such a node unable to start an owner bridge, and its
|
||||
/// sibling output leg stayed Eligible while re-emitting the call.
|
||||
receivers: BTreeSet<Serial>,
|
||||
}
|
||||
|
||||
fn propagate_links(
|
||||
downstream: &BTreeMap<Serial, Vec<Serial>>,
|
||||
taint: &mut BTreeMap<Serial, Reason>,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
let mut queue: VecDeque<Serial> = taint
|
||||
.iter()
|
||||
.filter(|(_, reason)| reason.propagates())
|
||||
.map(|(serial, _)| *serial)
|
||||
.collect();
|
||||
while let Some(serial) = queue.pop_front() {
|
||||
let Some(targets) = downstream.get(&serial) else {
|
||||
continue;
|
||||
};
|
||||
for target in targets {
|
||||
if raise(taint, *target, Reason::TaintedUpstream) {
|
||||
changed = true;
|
||||
queue.push_back(*target);
|
||||
}
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// The conditional owner bridge (v3.4 §6.1.1): taint crosses to an owner's
|
||||
/// other legs **only** when the tainted member is one that actually
|
||||
/// receives audio. The naive "this owner has both an input and an output
|
||||
/// leg ⇒ exclude the output" rule would exclude every app using a
|
||||
/// microphone, Firefox in a video call included.
|
||||
fn propagate_owner_bridge(
|
||||
keys: &owner::OwnerKeyIndex,
|
||||
components: &OwnerComponents,
|
||||
edges: &Edges,
|
||||
taint: &mut BTreeMap<Serial, Reason>,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
for members in components.components() {
|
||||
let sources: BTreeSet<Serial> = members
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|serial| {
|
||||
taint.get(serial).is_some_and(|r| r.propagates())
|
||||
&& edges.receivers.contains(serial)
|
||||
})
|
||||
.collect();
|
||||
if sources.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for target in members {
|
||||
if sources.contains(target) {
|
||||
continue;
|
||||
}
|
||||
// Name the strongest key shared directly with any tainted
|
||||
// member; `None` means the two are only transitively related.
|
||||
let key = sources
|
||||
.iter()
|
||||
.filter_map(|source| keys.strongest_shared(*source, *target))
|
||||
.min();
|
||||
changed |= raise(taint, *target, Reason::TaintedOwnerBridge { key });
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Fail-closed backstop for an owner we cannot bound (v3.4 §6.1.1, final
|
||||
/// paragraph): something read tainted audio and nothing about the output
|
||||
/// legs on this box lets us enumerate which of them are its siblings, so we
|
||||
/// cannot know which one is re-emitting what it read. Exclude the output
|
||||
/// legs that are equally unbounded.
|
||||
///
|
||||
/// The trigger and the sweep, precisely (both edges hard-won across four
|
||||
/// Codex rounds):
|
||||
///
|
||||
/// - **Trigger — any tainted receiver that is not a real device node.** A
|
||||
/// tainted hardware sink is the normal case, not an anomaly (peerspeak's
|
||||
/// playback taints the default sink every recompute), so device nodes do
|
||||
/// not trip it. The source does **not** have to be unbounded: a reader
|
||||
/// with a `node.link-group` whose re-emitting leg carries none is bounded
|
||||
/// while its sibling is unfindable (round 1).
|
||||
/// - **Sweep — depends on whether any tainted reader is itself unbounded.**
|
||||
/// A *bounded* reader's siblings are exactly the outputs sharing its key,
|
||||
/// so only the unbounded outputs (which could share its unknowable-only-
|
||||
/// in-part identity) are swept; a differently-keyed output is provably a
|
||||
/// different owner. An *unbounded* reader could be **any** owner — a real
|
||||
/// process may present no PID on its reading leg (round 4) — so every
|
||||
/// output candidate is swept, real apps included.
|
||||
///
|
||||
/// **Two tiers, because a tainted reader we cannot bound is a bigger
|
||||
/// unknown than one we can** (Codex round 3 — the mirror image of the
|
||||
/// round-1 case):
|
||||
///
|
||||
/// - A *bounded* tainted reader has a strong key or a usable PID, so its
|
||||
/// siblings are exactly the output legs sharing that key. Any output leg
|
||||
/// that is *itself* bounded by a **different** key is provably a different
|
||||
/// owner and stays eligible; only unbounded output legs are its possible
|
||||
/// siblings. → exclude unbounded outputs.
|
||||
/// - An *unbounded* tainted reader has nothing that identifies its owner, so
|
||||
/// its re-emitting leg could be **any** output on the box, and no property
|
||||
/// on an output leg can prove it is unrelated. → exclude every output
|
||||
/// candidate.
|
||||
///
|
||||
/// ⚠️ I tried to narrow this to "daemon-owned outputs only", on the
|
||||
/// theory that an unbounded reader must be daemon-owned (a real app has a
|
||||
/// PID, which would bound it) so a real-PID output is provably a different
|
||||
/// owner. **Codex refuted it (round 4):** `application.process.id` is
|
||||
/// optional and client-controlled, so a real process can present *no* PID
|
||||
/// on its reading leg (unbounded) and a real PID on its output leg — one
|
||||
/// owner, spared by the narrowing, leaking the call. Only `pipewire.*`
|
||||
/// properties have protected identity; app properties cannot carry a
|
||||
/// soundness argument. So: exclude everything. The trigger is genuinely
|
||||
/// anomalous — a keyless reader actively consuming the call; EasyEffects
|
||||
/// and loopbacks carry a `node.link-group` and are *bounded*, so they do
|
||||
/// not trip this tier — and phase 5's dry run surfaces it before it can
|
||||
/// gate anything real.
|
||||
fn propagate_unresolved_owner(
|
||||
snapshot: &GraphSnapshot,
|
||||
keys: &owner::OwnerKeyIndex,
|
||||
edges: &Edges,
|
||||
taint: &mut BTreeMap<Serial, Reason>,
|
||||
) -> bool {
|
||||
let mut has_tainted_reader = false;
|
||||
let mut has_unbounded_tainted_reader = false;
|
||||
for node in snapshot.nodes() {
|
||||
let is_tainted_reader = !node.props.session_device
|
||||
&& edges.receivers.contains(&node.serial)
|
||||
&& taint.get(&node.serial).is_some_and(|r| r.propagates());
|
||||
if is_tainted_reader {
|
||||
has_tainted_reader = true;
|
||||
has_unbounded_tainted_reader |= !keys.is_bounded(node.serial);
|
||||
}
|
||||
}
|
||||
if !has_tainted_reader {
|
||||
return false;
|
||||
}
|
||||
let mut changed = false;
|
||||
for node in snapshot.nodes() {
|
||||
if node.role == MediaRole::StreamOutput
|
||||
&& (has_unbounded_tainted_reader || !keys.is_bounded(node.serial))
|
||||
{
|
||||
changed |= raise(taint, node.serial, Reason::UnresolvedOwner);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn build_decisions(
|
||||
snapshot: &GraphSnapshot,
|
||||
ctx: &ExclusionCtx,
|
||||
taint: &BTreeMap<Serial, Reason>,
|
||||
sticky_serials: &BTreeSet<Serial>,
|
||||
) -> Decisions {
|
||||
let mut candidates = BTreeMap::new();
|
||||
for node in snapshot.nodes().filter(|n| n.role.is_candidate()) {
|
||||
let sticky = sticky_serials.contains(&node.serial);
|
||||
let eligibility = if !ctx.graph_ready {
|
||||
Eligibility::NotEligible {
|
||||
reason: Reason::GraphNotReady,
|
||||
sticky: false,
|
||||
}
|
||||
} else if let Some(reason) = taint.get(&node.serial) {
|
||||
Eligibility::NotEligible {
|
||||
reason: *reason,
|
||||
sticky,
|
||||
}
|
||||
} else if let Some(reason) = local_exclusion(snapshot, node) {
|
||||
Eligibility::NotEligible {
|
||||
reason,
|
||||
sticky: false,
|
||||
}
|
||||
} else {
|
||||
Eligibility::Eligible
|
||||
};
|
||||
candidates.insert(
|
||||
node.serial,
|
||||
NodeDecision {
|
||||
serial: node.serial,
|
||||
name: node.name.clone(),
|
||||
eligibility,
|
||||
},
|
||||
);
|
||||
}
|
||||
Decisions {
|
||||
candidates,
|
||||
taint: taint
|
||||
.iter()
|
||||
.map(|(serial, reason)| {
|
||||
(
|
||||
*serial,
|
||||
TaintEntry {
|
||||
reason: *reason,
|
||||
sticky: sticky_serials.contains(serial),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Node-local reasons a link cannot be created even though the node is
|
||||
/// clean. These do not propagate — an exclusive-port stream is unlinkable,
|
||||
/// not hazardous.
|
||||
fn local_exclusion(snapshot: &GraphSnapshot, node: &NodeSnapshot) -> Option<Reason> {
|
||||
if node.props.passthrough {
|
||||
return Some(Reason::Passthrough);
|
||||
}
|
||||
if snapshot.ports_of(node.id).any(|port| port.exclusive) {
|
||||
return Some(Reason::PortExclusive);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Sticky bookkeeping for the next recompute: every tainted owner, with
|
||||
/// every object observed to constitute it, merged with any prior entry that
|
||||
/// still overlaps. Members accumulate — that is what makes "clear only once
|
||||
/// all member objects have disappeared" true across churn.
|
||||
fn build_sticky(
|
||||
snapshot: &GraphSnapshot,
|
||||
keys: &owner::OwnerKeyIndex,
|
||||
components: &OwnerComponents,
|
||||
taint: &BTreeMap<Serial, Reason>,
|
||||
prior: &StickyState,
|
||||
retire_absent: bool,
|
||||
) -> StickyState {
|
||||
let mut entries: Vec<StickyOwner> = Vec::new();
|
||||
|
||||
// Carry forward prior entries that still have at least one live member.
|
||||
// An entry with none is gone for good: serials never recycle, so a
|
||||
// vanished member can never come back — but only a *trustworthy*
|
||||
// snapshot is allowed to conclude that a member is absent.
|
||||
for entry in &prior.owners {
|
||||
if !retire_absent
|
||||
|| entry
|
||||
.members
|
||||
.iter()
|
||||
.any(|member| is_live(snapshot, *member))
|
||||
{
|
||||
entries.push(entry.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for members in components.components() {
|
||||
let node_reasons: BTreeMap<Serial, Reason> = members
|
||||
.iter()
|
||||
.filter_map(|serial| {
|
||||
taint
|
||||
.get(serial)
|
||||
.filter(|reason| reason.propagates())
|
||||
.map(|reason| (*serial, *reason))
|
||||
})
|
||||
.collect();
|
||||
if node_reasons.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut refs: BTreeSet<ObjectRef> = members.iter().map(|s| ObjectRef::Node(*s)).collect();
|
||||
refs.extend(
|
||||
owner::client_serials_of(snapshot, keys, members)
|
||||
.into_iter()
|
||||
.map(ObjectRef::Client),
|
||||
);
|
||||
let fingerprints = members
|
||||
.iter()
|
||||
.flat_map(|serial| keys.fingerprints(*serial))
|
||||
.collect();
|
||||
entries.push(StickyOwner {
|
||||
members: refs,
|
||||
fingerprints,
|
||||
node_reasons,
|
||||
});
|
||||
}
|
||||
|
||||
StickyState {
|
||||
owners: merge_overlapping(entries),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_live(snapshot: &GraphSnapshot, member: ObjectRef) -> bool {
|
||||
match member {
|
||||
ObjectRef::Node(serial) => snapshot.node(serial).is_some(),
|
||||
ObjectRef::Client(serial) => snapshot.clients().any(|c| c.serial == serial),
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge entries that share any member, keeping the strongest reason.
|
||||
/// Owners fuse over time (a component that gains a leg belonging to a
|
||||
/// previously separate sticky owner is one owner now); splitting them back
|
||||
/// apart would drop taint, which is the unsafe direction.
|
||||
fn merge_overlapping(mut entries: Vec<StickyOwner>) -> Vec<StickyOwner> {
|
||||
let mut merged: Vec<StickyOwner> = Vec::new();
|
||||
while let Some(mut entry) = entries.pop() {
|
||||
let mut absorbed = true;
|
||||
while absorbed {
|
||||
absorbed = false;
|
||||
let mut rest = Vec::with_capacity(entries.len());
|
||||
for other in entries.drain(..) {
|
||||
if entry.members.is_disjoint(&other.members) {
|
||||
rest.push(other);
|
||||
} else {
|
||||
for (serial, reason) in other.node_reasons {
|
||||
entry
|
||||
.node_reasons
|
||||
.entry(serial)
|
||||
.and_modify(|existing| {
|
||||
if reason.priority() < existing.priority() {
|
||||
*existing = reason;
|
||||
}
|
||||
})
|
||||
.or_insert(reason);
|
||||
}
|
||||
entry.members.extend(other.members);
|
||||
entry.fingerprints.extend(other.fingerprints);
|
||||
absorbed = true;
|
||||
}
|
||||
}
|
||||
entries = rest;
|
||||
}
|
||||
merged.push(entry);
|
||||
}
|
||||
merged.sort_by(|a, b| a.members.iter().next().cmp(&b.members.iter().next()));
|
||||
merged
|
||||
}
|
||||
|
||||
/// Record `reason` for `serial` if it is new or strictly stronger than what
|
||||
/// is already recorded. Returns whether anything changed — the fixpoint's
|
||||
/// termination argument rests on this being monotone.
|
||||
fn raise(taint: &mut BTreeMap<Serial, Reason>, serial: Serial, reason: Reason) -> bool {
|
||||
match taint.get(&serial) {
|
||||
Some(existing) if existing.priority() <= reason.priority() => false,
|
||||
_ => {
|
||||
taint.insert(serial, reason);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
//! The owner bridge — grouping nodes that belong to the same *owner* even
|
||||
//! though the graph shows no Link between them.
|
||||
//!
|
||||
//! This is the subtlest part of the design (v3.4 §6.1.2). Measured fact it
|
||||
//! exists to handle: a `module-loopback` forwarder's input leg and output
|
||||
//! leg have **no Link between them**, so walking Links alone from the
|
||||
//! leaking output leg finds no inbound links at all — a dead end that reads
|
||||
//! as "clean". The legs are related only by shared properties.
|
||||
//!
|
||||
//! ## The rule
|
||||
//!
|
||||
//! A union of keys, strongest first:
|
||||
//!
|
||||
//! | # | key | scope |
|
||||
//! | --- | --- | --- |
|
||||
//! | 1 | `node.link-group` | per module/filter instance |
|
||||
//! | 2 | `pulse.module.id` | per pactl module |
|
||||
//! | 3 | `client.id` | per **connection** |
|
||||
//! | 4 | `application.process.id` | per process |
|
||||
//!
|
||||
//! ⚠️ **"Resolves" means the two legs carry the key AND the values are
|
||||
//! EQUAL — not "the first key present".** A first-present implementation
|
||||
//! reproduces the exact measured leak: for `gst-launch pulsesrc ! pulsesink`
|
||||
//! both legs carry `client.id` (209 and 210) but the values *differ*, so
|
||||
//! first-present stops at key 3, sees a mismatch, and concludes "different
|
||||
//! owners". The legs are in fact one process (`application.process.id`
|
||||
//! 20172 on both). So: try each key in order, and a key resolves only if
|
||||
//! both legs carry it and the values are equal; otherwise fall through.
|
||||
//!
|
||||
//! ## Two exceptions, both guarding against mass over-exclusion
|
||||
//!
|
||||
//! 1. **Never bridge on key 4 when the value is pipewire-pulse's own PID**
|
||||
//! (v3.4 §6.1.2). Module-created streams all carry the daemon's PID, so
|
||||
//! bridging on it fuses every Pulse module into one owner and a single
|
||||
//! tainted module input would exclude every module-created stream on the
|
||||
//! box. Keys 1 and 2 already cover those cases precisely.
|
||||
//!
|
||||
//! 2. **Coarse keys (3 and 4) may not bridge nodes exported from a real
|
||||
//! `Device`** — i.e. nodes carrying `device.id`. ⚠️ This rule is *not*
|
||||
//! in design v3.4; it was found while implementing, and it is the exact
|
||||
//! analogue of exception 1 for the session manager.
|
||||
//! ✅ **MEASURED on the live graph 2026-07-21:**
|
||||
//!
|
||||
//! | node | `client.id` | `device.id` | `factory.name` |
|
||||
//! | --- | --- | --- | --- |
|
||||
//! | 5 × `alsa_{output,input}.*` | **42** (`WirePlumber [export]`) | 43/45/46 | `api.alsa.pcm.{sink,source}` |
|
||||
//! | 3 × `sink-sunshine-*` | 83 / 86 / 92 (each its own) | **absent** | `support.null-audio-sink` |
|
||||
//!
|
||||
//! So one shared coarse key genuinely does relate every hardware device
|
||||
//! on the box, and `device.id` cleanly separates that set from virtual
|
||||
//! sinks. Without the rule, the hardware sink carrying peerspeak's
|
||||
//! playback (tainted by design, every single recompute) would bridge to
|
||||
//! *every other device node including the microphone source*, whose
|
||||
//! readers would then taint their owners' playback legs — reproducing
|
||||
//! precisely the §6.1.1 catastrophe ("excludes any app using a
|
||||
//! microphone") through a different door.
|
||||
//!
|
||||
//! ⚠️ **Keyed on `device.id`, NOT on `media.class` being `Audio/Sink`.**
|
||||
//! The first cut suppressed coarse keys for every device-*role* node,
|
||||
//! and Codex refuted it: a **native virtual sink** — an app that creates
|
||||
//! an `Audio/Sink` plus a re-emitting stream on one client, with no
|
||||
//! `link-group` and no `pulse.module.id` — would then have had its only
|
||||
//! correlation stripped, and it would have leaked the whole call. Such a
|
||||
//! sink has no `device.id`, so it now bridges on `client.id` as it
|
||||
//! should.
|
||||
//!
|
||||
//! Grouping is **transitive** (union-find). That is the fail-closed
|
||||
//! direction: bigger owner components mean more taint, never less.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::snapshot::{GlobalId, GraphSnapshot, NodeSnapshot, Serial};
|
||||
|
||||
/// Which key bridged two legs. Ordered strongest first; the `Ord` derive is
|
||||
/// load-bearing for "report the strongest shared key".
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
|
||||
pub enum OwnerKey {
|
||||
LinkGroup,
|
||||
PulseModuleId,
|
||||
ClientId,
|
||||
ProcessId,
|
||||
}
|
||||
|
||||
impl OwnerKey {
|
||||
/// Stable, machine-readable — this ends up in the phase 5 audit output
|
||||
/// and the phase 6 status event.
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::LinkGroup => "node.link-group",
|
||||
Self::PulseModuleId => "pulse.module.id",
|
||||
Self::ClientId => "client.id",
|
||||
Self::ProcessId => "application.process.id",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The value a node presents for a given key, if it presents one at all.
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
|
||||
enum KeyValue {
|
||||
Text(String),
|
||||
Num(u64),
|
||||
}
|
||||
|
||||
/// Owner keys usable on this node, strongest first.
|
||||
///
|
||||
/// A key that is present but unusable (the pipewire-pulse PID; a coarse key
|
||||
/// on a device node) is **absent** here — that is the whole mechanism of the
|
||||
/// two exceptions.
|
||||
fn keys_of(node: &NodeSnapshot, pipewire_pulse_pid: Option<u32>) -> Vec<(OwnerKey, KeyValue)> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(group) = &node.props.link_group {
|
||||
out.push((OwnerKey::LinkGroup, KeyValue::Text(group.clone())));
|
||||
}
|
||||
if let Some(module) = node.props.pulse_module_id {
|
||||
out.push((OwnerKey::PulseModuleId, KeyValue::Num(module)));
|
||||
}
|
||||
// Exception 2: coarse keys never bridge passive session-manager device
|
||||
// nodes — they all share the session manager's client.
|
||||
if node.props.session_device {
|
||||
return out;
|
||||
}
|
||||
if let Some(client) = node.props.client_id {
|
||||
out.push((OwnerKey::ClientId, KeyValue::Num(u64::from(client.0))));
|
||||
}
|
||||
if let Some(pid) = node.props.process_id {
|
||||
// Exception 1. Note the fail-closed asymmetry when the daemon PID is
|
||||
// unknown (`None`): the exception does *not* fire, key 4 applies to
|
||||
// everything, and Pulse modules fuse into one owner. That is broad
|
||||
// over-exclusion — annoying and safe — which is the direction v3.4
|
||||
// §6.1.2's failure-mode paragraph asks for.
|
||||
if Some(pid) != pipewire_pulse_pid {
|
||||
out.push((OwnerKey::ProcessId, KeyValue::Num(u64::from(pid))));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Can this node's owner be positively bounded — i.e. can we enumerate its
|
||||
/// sibling legs and be right?
|
||||
///
|
||||
/// ⚠️ Not the same as "has any usable key", and the difference is a leak.
|
||||
/// `client.id` alone does **not** bound an owner: that is the measured
|
||||
/// GStreamer refutation, where one process presented two different
|
||||
/// `client.id`s for its two legs. So an owner is bounded only by a strong
|
||||
/// key (link-group / pulse.module.id) or by a *usable* process id — usable
|
||||
/// meaning key 4 was not suppressed as pipewire-pulse's own PID.
|
||||
///
|
||||
/// The case this exists for is v3.4 §12's "module forwarder with neither
|
||||
/// `link-group` nor `pulse.module.id`": its process id is the daemon's and
|
||||
/// therefore suppressed, its two legs may carry different `client.id`s, and
|
||||
/// nothing else relates them. Its sibling output leg cannot be found, so
|
||||
/// the engine must fail closed rather than declare it clean
|
||||
/// (v3.4 §6.1.1, final paragraph).
|
||||
pub fn owner_is_bounded(node: &NodeSnapshot, pipewire_pulse_pid: Option<u32>) -> bool {
|
||||
keys_of(node, pipewire_pulse_pid)
|
||||
.iter()
|
||||
.any(|(key, _)| *key != OwnerKey::ClientId)
|
||||
}
|
||||
|
||||
/// Owner keys computed once per snapshot.
|
||||
///
|
||||
/// `keys_of` allocates a `Vec` and clones the `link-group` string, and the
|
||||
/// bridge asks for keys once per (tainted member × component member) pair —
|
||||
/// so recomputing was the hot spot in an otherwise linear pass.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct OwnerKeyIndex {
|
||||
keys: BTreeMap<Serial, Vec<(OwnerKey, KeyValue)>>,
|
||||
}
|
||||
|
||||
impl OwnerKeyIndex {
|
||||
pub fn build(snapshot: &GraphSnapshot, pipewire_pulse_pid: Option<u32>) -> Self {
|
||||
Self {
|
||||
keys: snapshot
|
||||
.nodes()
|
||||
.map(|node| (node.serial, keys_of(node, pipewire_pulse_pid)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The strongest key these two nodes share directly, if any.
|
||||
pub fn strongest_shared(&self, a: Serial, b: Serial) -> Option<OwnerKey> {
|
||||
let (Some(a_keys), Some(b_keys)) = (self.keys.get(&a), self.keys.get(&b)) else {
|
||||
return None;
|
||||
};
|
||||
// Stored strongest-first, so the first match is the strongest.
|
||||
a_keys.iter().find_map(|(key, value)| {
|
||||
b_keys
|
||||
.iter()
|
||||
.any(|(other_key, other_value)| other_key == key && other_value == value)
|
||||
.then_some(*key)
|
||||
})
|
||||
}
|
||||
|
||||
/// Is `client.id` a usable owner key for this node?
|
||||
///
|
||||
/// ⚠️ Load-bearing for sticky state. A device node's `client.id` is
|
||||
/// suppressed by exception 2, so recording the session manager's Client
|
||||
/// as a *member* of a tainted device's sticky owner would smuggle the
|
||||
/// suppressed key back in: the next recompute would expand that Client
|
||||
/// to every hardware node on the box — the microphone included — and
|
||||
/// the §6.1.1 catastrophe would arrive one epoch late instead of never.
|
||||
/// (Codex round 2, finding 1.)
|
||||
pub fn uses_client_key(&self, serial: Serial) -> bool {
|
||||
self.keys
|
||||
.get(&serial)
|
||||
.is_some_and(|keys| keys.iter().any(|(key, _)| *key == OwnerKey::ClientId))
|
||||
}
|
||||
|
||||
/// The owner keys that are safe to remember *across* connections, for
|
||||
/// sticky taint: the strong keys plus a usable process id.
|
||||
///
|
||||
/// `client.id` is deliberately excluded — it identifies a *connection*,
|
||||
/// and the whole point of a fingerprint is to survive one process
|
||||
/// closing a connection and opening another. A live Client member is
|
||||
/// what covers the same-connection case, precisely.
|
||||
///
|
||||
/// These are recyclable strings and numbers, so they are only ever
|
||||
/// applied while some **serial** member of the owner is still live
|
||||
/// (v3.4 §6.1.3): while the process is alive, its PID cannot have been
|
||||
/// handed to anyone else.
|
||||
pub fn fingerprints(&self, serial: Serial) -> Vec<Fingerprint> {
|
||||
self.keys
|
||||
.get(&serial)
|
||||
.map(|keys| {
|
||||
keys.iter()
|
||||
.filter(|(key, _)| *key != OwnerKey::ClientId)
|
||||
.map(|(key, value)| Fingerprint(*key, value.clone()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Does this node currently present `fingerprint`?
|
||||
pub fn has_fingerprint(&self, serial: Serial, fingerprint: &Fingerprint) -> bool {
|
||||
self.keys.get(&serial).is_some_and(|keys| {
|
||||
keys.iter()
|
||||
.any(|(key, value)| *key == fingerprint.0 && *value == fingerprint.1)
|
||||
})
|
||||
}
|
||||
|
||||
/// See [`owner_is_bounded`].
|
||||
pub fn is_bounded(&self, serial: Serial) -> bool {
|
||||
self.keys
|
||||
.get(&serial)
|
||||
.is_some_and(|keys| keys.iter().any(|(key, _)| *key != OwnerKey::ClientId))
|
||||
}
|
||||
}
|
||||
|
||||
/// The strongest key two nodes share, or `None` if they share none. Used to
|
||||
/// *name* the key in a bridge decision; membership itself is transitive and
|
||||
/// comes from [`OwnerComponents`].
|
||||
pub fn strongest_shared_key(
|
||||
a: &NodeSnapshot,
|
||||
b: &NodeSnapshot,
|
||||
pipewire_pulse_pid: Option<u32>,
|
||||
) -> Option<OwnerKey> {
|
||||
let a_keys = keys_of(a, pipewire_pulse_pid);
|
||||
let b_keys = keys_of(b, pipewire_pulse_pid);
|
||||
// `keys_of` yields strongest-first, so the first match is the strongest.
|
||||
a_keys.iter().find_map(|(key, value)| {
|
||||
b_keys
|
||||
.iter()
|
||||
.any(|(other_key, other_value)| other_key == key && other_value == value)
|
||||
.then_some(*key)
|
||||
})
|
||||
}
|
||||
|
||||
/// A remembered owner key — see [`OwnerKeyIndex::fingerprints`].
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
|
||||
pub struct Fingerprint(OwnerKey, KeyValue);
|
||||
|
||||
/// Nodes partitioned into owner components.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct OwnerComponents {
|
||||
/// node serial → component index.
|
||||
of_node: BTreeMap<Serial, usize>,
|
||||
/// component index → member node serials, ascending.
|
||||
members: Vec<Vec<Serial>>,
|
||||
}
|
||||
|
||||
impl OwnerComponents {
|
||||
pub fn build(snapshot: &GraphSnapshot, pipewire_pulse_pid: Option<u32>) -> Self {
|
||||
let serials: Vec<Serial> = snapshot.nodes().map(|n| n.serial).collect();
|
||||
let index: BTreeMap<Serial, usize> =
|
||||
serials.iter().enumerate().map(|(i, s)| (*s, i)).collect();
|
||||
let mut uf = UnionFind::new(serials.len());
|
||||
|
||||
// Group by (key, value) and union within each group. Equivalent to
|
||||
// the pairwise "some key resolves" rule, and O(n log n).
|
||||
let mut buckets: BTreeMap<(OwnerKey, KeyValue), Vec<usize>> = BTreeMap::new();
|
||||
for node in snapshot.nodes() {
|
||||
let slot = index[&node.serial];
|
||||
for (key, value) in keys_of(node, pipewire_pulse_pid) {
|
||||
buckets.entry((key, value)).or_default().push(slot);
|
||||
}
|
||||
}
|
||||
for group in buckets.values() {
|
||||
for pair in group.windows(2) {
|
||||
uf.union(pair[0], pair[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Compact roots into dense component indices, deterministically.
|
||||
let mut root_to_component: BTreeMap<usize, usize> = BTreeMap::new();
|
||||
let mut members: Vec<Vec<Serial>> = Vec::new();
|
||||
let mut of_node = BTreeMap::new();
|
||||
for (slot, serial) in serials.iter().enumerate() {
|
||||
let root = uf.find(slot);
|
||||
let component = *root_to_component.entry(root).or_insert_with(|| {
|
||||
members.push(Vec::new());
|
||||
members.len() - 1
|
||||
});
|
||||
members[component].push(*serial);
|
||||
of_node.insert(*serial, component);
|
||||
}
|
||||
Self { of_node, members }
|
||||
}
|
||||
|
||||
pub fn component_of(&self, serial: Serial) -> Option<usize> {
|
||||
self.of_node.get(&serial).copied()
|
||||
}
|
||||
|
||||
/// Member serials of the component containing `serial`, including it.
|
||||
/// Empty if the node is not in this snapshot.
|
||||
pub fn members_with(&self, serial: Serial) -> &[Serial] {
|
||||
match self.component_of(serial) {
|
||||
Some(component) => &self.members[component],
|
||||
None => &[],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn components(&self) -> impl Iterator<Item = &[Serial]> {
|
||||
self.members.iter().map(Vec::as_slice)
|
||||
}
|
||||
}
|
||||
|
||||
struct UnionFind {
|
||||
parent: Vec<usize>,
|
||||
}
|
||||
|
||||
impl UnionFind {
|
||||
fn new(len: usize) -> Self {
|
||||
Self {
|
||||
parent: (0..len).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn find(&mut self, mut node: usize) -> usize {
|
||||
while self.parent[node] != node {
|
||||
self.parent[node] = self.parent[self.parent[node]];
|
||||
node = self.parent[node];
|
||||
}
|
||||
node
|
||||
}
|
||||
|
||||
fn union(&mut self, a: usize, b: usize) {
|
||||
let (a, b) = (self.find(a), self.find(b));
|
||||
if a != b {
|
||||
// Lowest root wins, so components are deterministic.
|
||||
let (low, high) = if a < b { (a, b) } else { (b, a) };
|
||||
self.parent[high] = low;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Client objects belonging to an owner component, so sticky taint can be
|
||||
/// keyed on every object that constitutes the owner (v3.4 §6.1.3: clear the
|
||||
/// entry only once **all** member objects are gone).
|
||||
pub fn client_serials_of(
|
||||
snapshot: &GraphSnapshot,
|
||||
keys: &OwnerKeyIndex,
|
||||
nodes: &[Serial],
|
||||
) -> Vec<Serial> {
|
||||
let mut out: Vec<Serial> = nodes
|
||||
.iter()
|
||||
// Only nodes for which `client.id` is a *usable* owner key. See
|
||||
// `uses_client_key`: recording a device node's shared session-manager
|
||||
// Client here would defeat exception 2 on the next recompute.
|
||||
.filter(|serial| keys.uses_client_key(**serial))
|
||||
.filter_map(|serial| snapshot.node(*serial))
|
||||
.filter_map(|node| node.props.client_id)
|
||||
// An ambiguous client id means two Clients claim it and we cannot
|
||||
// say which one is ours, so remember both: an entry that recorded
|
||||
// neither could be retired while its owner was still live.
|
||||
.flat_map(|id: GlobalId| snapshot.clients_with_id(id).map(|client| client.serial))
|
||||
.collect();
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
//! The plain, owned graph model the taint engine reasons over.
|
||||
//!
|
||||
//! **No PipeWire types appear in this file, by design** (impl plan §4,
|
||||
//! phase 2). The registry observer (phase 3) translates live globals into
|
||||
//! these structs; every test builds them by hand. Nothing here ever links
|
||||
//! against libpipewire.
|
||||
//!
|
||||
//! Two id-ish things live in this model and confusing them is the bug the
|
||||
//! whole file is shaped to prevent:
|
||||
//!
|
||||
//! - [`Serial`] — `object.serial`, 64-bit, monotonic, **never reused**.
|
||||
//! This is *identity*. Sticky taint is keyed on it.
|
||||
//! - [`GlobalId`] — the PipeWire global id, 32-bit and **recycled**. It is
|
||||
//! a *lookup key within one snapshot* and nothing else: links name their
|
||||
//! endpoints with it, nodes name their client with it. It must never
|
||||
//! outlive the snapshot it was read from (design v3.4 §6.1.3).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// `object.serial` — 64-bit, monotonic, never recycled. Identity.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
|
||||
pub struct Serial(pub u64);
|
||||
|
||||
/// A PipeWire global id — 32-bit and **recycled**. Snapshot-local lookup
|
||||
/// key only; see the module docs.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
|
||||
pub struct GlobalId(pub u32);
|
||||
|
||||
/// What a node does with audio, parsed from `media.class`.
|
||||
///
|
||||
/// Taint is computed at **node** granularity (v3.4 §6.1 edge type 2: the
|
||||
/// monitor connection is already a real Link whose output node is the sink
|
||||
/// itself, so a node-level walk crosses `app → sink → monitor-reader` for
|
||||
/// free). Ports exist in the model for link creation in phase 6 and for the
|
||||
/// `port.exclusive` predicate, not for taint.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
|
||||
pub enum MediaRole {
|
||||
/// `Stream/Output/Audio` — an application playing audio. The only
|
||||
/// fan-out candidate.
|
||||
StreamOutput,
|
||||
/// `Stream/Input/Audio` — an application capturing audio.
|
||||
StreamInput,
|
||||
/// `Audio/Sink` — a real or virtual sink.
|
||||
Sink,
|
||||
/// `Audio/Source` — a real or virtual source.
|
||||
Source,
|
||||
/// `Audio/Duplex`. ⚠️ Node granularity smears taint across both roles
|
||||
/// of these; accepted for v1 as fail-closed over-exclusion
|
||||
/// (v3.4 §6.1, edge type 2 caveat).
|
||||
Duplex,
|
||||
/// Anything else, including video and unparseable/absent `media.class`.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl MediaRole {
|
||||
pub fn parse(media_class: Option<&str>) -> Self {
|
||||
match media_class {
|
||||
Some("Stream/Output/Audio") => Self::StreamOutput,
|
||||
Some("Stream/Input/Audio") => Self::StreamInput,
|
||||
Some("Audio/Sink") => Self::Sink,
|
||||
Some("Audio/Source") => Self::Source,
|
||||
Some("Audio/Duplex") => Self::Duplex,
|
||||
_ => Self::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Can this node *receive* audio? This is the gate on the owner bridge:
|
||||
/// taint crosses the intra-process hop only when the owner is actually
|
||||
/// reading tainted audio (v3.4 §6.1.1 — "this client has both an input
|
||||
/// and an output leg ⇒ exclude the output" is the catastrophic rule
|
||||
/// that excludes every app with a microphone).
|
||||
///
|
||||
/// `Sink` counts: EasyEffects' `ee_sink` is an `Audio/Sink` that
|
||||
/// receives the tainted mix, and its re-emitting leg is joined to it by
|
||||
/// `node.link-group` with no Link between them.
|
||||
pub fn receives_audio(self) -> bool {
|
||||
matches!(self, Self::StreamInput | Self::Sink | Self::Duplex)
|
||||
}
|
||||
|
||||
/// Device-ish nodes — everything that is not a `Stream/*`. Coarse owner
|
||||
/// keys are not allowed to bridge these; see [`super::owner`].
|
||||
pub fn is_device_role(self) -> bool {
|
||||
matches!(self, Self::Sink | Self::Source | Self::Duplex)
|
||||
}
|
||||
|
||||
/// Only `Stream/Output/Audio` nodes are fan-out candidates (v3.4 §6.2).
|
||||
pub fn is_candidate(self) -> bool {
|
||||
matches!(self, Self::StreamOutput)
|
||||
}
|
||||
}
|
||||
|
||||
/// The subset of node properties the engine actually reasons about.
|
||||
///
|
||||
/// Deliberately a struct of parsed fields rather than a property bag: the
|
||||
/// parsing (and its failure modes) belongs at the observer boundary, and a
|
||||
/// bag invites `props.get("...")` typos that silently read `None` — which
|
||||
/// on this feature means "not tainted".
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct NodeProps {
|
||||
/// `peerspeak.owned` is present and truthy (v3.4 §5.1). A correctness
|
||||
/// mechanism, explicitly *not* a security boundary.
|
||||
pub peerspeak_owned: bool,
|
||||
/// `pulse.module.id`, parsed as `u64` — never `u32`, per v3.4 §5.2's
|
||||
/// parse-defensively note and the phase 0a truncation bug.
|
||||
pub pulse_module_id: Option<u64>,
|
||||
/// `node.link-group` — owner key 1, and the `echo-cancel-` hazard
|
||||
/// prefix (v3.4 §5.4 / D3).
|
||||
pub link_group: Option<String>,
|
||||
/// `client.id` — owner key 3. A **connection**, not an owner: GStreamer
|
||||
/// opens one per stream (v3.4 §6.1.2, measured refutation).
|
||||
pub client_id: Option<GlobalId>,
|
||||
/// `application.process.id` **on the node** — owner key 4. For
|
||||
/// module-created streams this is pipewire-pulse's own PID, which is
|
||||
/// why [`super::ExclusionCtx::pipewire_pulse_pid`] exists.
|
||||
pub process_id: Option<u32>,
|
||||
/// The stream negotiated an encoded/passthrough format; a second link
|
||||
/// would refuse or corrupt it (v3.4 §6.2).
|
||||
pub passthrough: bool,
|
||||
/// This node is a **passive device node exported by the session
|
||||
/// manager** — a real sound card's sink or source, not something that
|
||||
/// forwards audio.
|
||||
///
|
||||
/// ⚠️ **A positive high-confidence classification the observer owes, not
|
||||
/// a raw property** (Codex rounds 2–3). PipeWire defines `device.id`
|
||||
/// only as "the Device this node belongs to" and `device.api` as that
|
||||
/// Device's access API; **neither promises the node passively terminates
|
||||
/// audio**, so a card-associated filter can satisfy both. Setting this
|
||||
/// flag *removes* two protections at once — the node's coarse owner keys
|
||||
/// (`owner` exception 2) and its ability to trip the fail-closed
|
||||
/// backstop — so a false positive is a leak, not over-exclusion.
|
||||
///
|
||||
/// **Phase-3 contract:**
|
||||
/// - Set `true` only on positively-identified passive hardware
|
||||
/// terminals: a resolved `device.id` on a real backend
|
||||
/// (`device.api` present) whose `factory.name` is on an **explicit
|
||||
/// hardware-PCM allowlist** — `api.alsa.pcm.sink`, `api.alsa.pcm.source`,
|
||||
/// and the equivalent for other real backends (bluez5, v4l2 for the
|
||||
/// media case) as phase 3 enumerates them — never a filter, loopback,
|
||||
/// or `support.null-audio-sink` factory. An allowlist, not a
|
||||
/// substring or a denylist: an unknown factory is not a device.
|
||||
/// Measured discriminator on the
|
||||
/// target box: the five ALSA nodes carry `device.api=alsa` +
|
||||
/// `factory.name=api.alsa.pcm.*` and share `client.id=42`
|
||||
/// (`WirePlumber [export]`); the three `support.null-audio-sink` nodes
|
||||
/// carry neither. (`node.physical` was measured **null** on the ALSA
|
||||
/// nodes here, so it is *not* a usable discriminator — do not rely on
|
||||
/// it.)
|
||||
/// - **Fail closed: unknown ⇒ `false`.** A node that cannot be
|
||||
/// positively classified keeps its owner keys and can trip the
|
||||
/// backstop; both are the safe direction.
|
||||
/// - A node MUST NOT enter a snapshot with this field provisional. If
|
||||
/// the Device backing a node has not yet been bound, withhold the node
|
||||
/// and keep the epoch not-ready — otherwise a provisional `false`
|
||||
/// during not-ready fuses sink and mic on the shared session client
|
||||
/// and that fusion can persist as sticky over-exclusion (round-3
|
||||
/// finding 3).
|
||||
///
|
||||
/// ⚠️ **A false positive is leak-capable — do not treat it as braced.**
|
||||
/// I claimed a mis-classified filter could not leak because its legs
|
||||
/// share a `node.link-group` (strong-key bridge) or trip the unbounded
|
||||
/// backstop. Codex refuted it (round 4): a filter *without* a shared
|
||||
/// strong key, marked `session_device=true`, cannot activate the
|
||||
/// backstop from its reading leg, so a differently-keyed re-emitting leg
|
||||
/// leaks. Those braces catch *some* shapes, not all. The only real
|
||||
/// defence is a correct classifier — hence "positive high-confidence"
|
||||
/// and "fail closed to false" above, without exception.
|
||||
///
|
||||
/// What it is for: every real device node shares the session manager's
|
||||
/// `client.id`, so coarse owner keys must not bridge them — else
|
||||
/// peerspeak's playback (which taints the default sink every recompute)
|
||||
/// would reach the microphone. See [`super::owner`] exception 2.
|
||||
pub session_device: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct NodeSnapshot {
|
||||
pub serial: Serial,
|
||||
pub id: GlobalId,
|
||||
/// `node.name`, for diagnostics and for `pixelpass_capture_*` ancestry
|
||||
/// detection (v3.4 §6.2, cycle prevention).
|
||||
pub name: Option<String>,
|
||||
pub role: MediaRole,
|
||||
pub props: NodeProps,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum PortDirection {
|
||||
In,
|
||||
Out,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PortSnapshot {
|
||||
pub serial: Serial,
|
||||
pub id: GlobalId,
|
||||
/// Owning node, by snapshot-local id.
|
||||
pub node: GlobalId,
|
||||
pub direction: PortDirection,
|
||||
/// `port.exclusive` — fan-out will be refused (v3.4 §6.2).
|
||||
pub exclusive: bool,
|
||||
/// `port.monitor`. Recorded for phase 6 link creation; taint does not
|
||||
/// need it at node granularity.
|
||||
pub monitor: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LinkSnapshot {
|
||||
pub serial: Serial,
|
||||
pub id: GlobalId,
|
||||
/// `link.output.node` — the node audio flows **from**.
|
||||
pub output_node: GlobalId,
|
||||
/// `link.input.node` — the node audio flows **to**.
|
||||
pub input_node: GlobalId,
|
||||
pub output_port: Option<GlobalId>,
|
||||
pub input_port: Option<GlobalId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ClientSnapshot {
|
||||
pub serial: Serial,
|
||||
pub id: GlobalId,
|
||||
/// `pipewire.sec.pid` — for Pulse-emulated clients this is
|
||||
/// **pipewire-pulse's** PID, identical across every unrelated app
|
||||
/// (v3.4 §5.2 correction 5). Phase 3 derives the daemon PID from the
|
||||
/// consistency of this value; the engine only consumes the result.
|
||||
pub sec_pid: Option<u32>,
|
||||
}
|
||||
|
||||
/// How a snapshot-local id resolves.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum IdLookup {
|
||||
Unique(Serial),
|
||||
/// Two live objects in one snapshot claim the same global id — the
|
||||
/// observer missed a removal, so the recycled id is ambiguous. Every
|
||||
/// edge touching it is treated as unresolved, i.e. fail closed.
|
||||
Ambiguous,
|
||||
}
|
||||
|
||||
/// One coherent observation of the graph.
|
||||
///
|
||||
/// Built through [`GraphSnapshot::new`] so the id indexes and the ambiguity
|
||||
/// detection cannot be skipped.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct GraphSnapshot {
|
||||
nodes: BTreeMap<Serial, NodeSnapshot>,
|
||||
ports: BTreeMap<Serial, PortSnapshot>,
|
||||
links: BTreeMap<Serial, LinkSnapshot>,
|
||||
clients: BTreeMap<Serial, ClientSnapshot>,
|
||||
node_ids: BTreeMap<GlobalId, IdLookup>,
|
||||
client_ids: BTreeMap<GlobalId, IdLookup>,
|
||||
}
|
||||
|
||||
impl GraphSnapshot {
|
||||
pub fn new(
|
||||
nodes: Vec<NodeSnapshot>,
|
||||
ports: Vec<PortSnapshot>,
|
||||
links: Vec<LinkSnapshot>,
|
||||
clients: Vec<ClientSnapshot>,
|
||||
) -> Self {
|
||||
let node_ids = index_ids(nodes.iter().map(|n| (n.id, n.serial)));
|
||||
let client_ids = index_ids(clients.iter().map(|c| (c.id, c.serial)));
|
||||
Self {
|
||||
nodes: nodes.into_iter().map(|n| (n.serial, n)).collect(),
|
||||
ports: ports.into_iter().map(|p| (p.serial, p)).collect(),
|
||||
links: links.into_iter().map(|l| (l.serial, l)).collect(),
|
||||
clients: clients.into_iter().map(|c| (c.serial, c)).collect(),
|
||||
node_ids,
|
||||
client_ids,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn nodes(&self) -> impl Iterator<Item = &NodeSnapshot> {
|
||||
self.nodes.values()
|
||||
}
|
||||
|
||||
pub fn node(&self, serial: Serial) -> Option<&NodeSnapshot> {
|
||||
self.nodes.get(&serial)
|
||||
}
|
||||
|
||||
pub fn links(&self) -> impl Iterator<Item = &LinkSnapshot> {
|
||||
self.links.values()
|
||||
}
|
||||
|
||||
pub fn ports(&self) -> impl Iterator<Item = &PortSnapshot> {
|
||||
self.ports.values()
|
||||
}
|
||||
|
||||
pub fn clients(&self) -> impl Iterator<Item = &ClientSnapshot> {
|
||||
self.clients.values()
|
||||
}
|
||||
|
||||
/// Resolve a snapshot-local node id. `None` means "no such node in this
|
||||
/// snapshot", which for a link endpoint means unresolved ancestry.
|
||||
pub fn node_by_id(&self, id: GlobalId) -> Option<IdLookup> {
|
||||
self.node_ids.get(&id).copied()
|
||||
}
|
||||
|
||||
pub fn client_by_id(&self, id: GlobalId) -> Option<IdLookup> {
|
||||
self.client_ids.get(&id).copied()
|
||||
}
|
||||
|
||||
/// Every node claiming a global id. More than one means the id is
|
||||
/// [`IdLookup::Ambiguous`] and each claimant must be treated as a
|
||||
/// possible endpoint of any link naming it.
|
||||
pub fn nodes_with_id(&self, id: GlobalId) -> impl Iterator<Item = &NodeSnapshot> {
|
||||
self.nodes.values().filter(move |node| node.id == id)
|
||||
}
|
||||
|
||||
/// Every client claiming a global id — same fail-closed reasoning.
|
||||
pub fn clients_with_id(&self, id: GlobalId) -> impl Iterator<Item = &ClientSnapshot> {
|
||||
self.clients.values().filter(move |client| client.id == id)
|
||||
}
|
||||
|
||||
/// Ports belonging to a node, by the node's snapshot-local id.
|
||||
pub fn ports_of(&self, node: GlobalId) -> impl Iterator<Item = &PortSnapshot> {
|
||||
self.ports.values().filter(move |p| p.node == node)
|
||||
}
|
||||
}
|
||||
|
||||
fn index_ids(entries: impl Iterator<Item = (GlobalId, Serial)>) -> BTreeMap<GlobalId, IdLookup> {
|
||||
let mut out: BTreeMap<GlobalId, IdLookup> = BTreeMap::new();
|
||||
for (id, serial) in entries {
|
||||
out.entry(id)
|
||||
.and_modify(|slot| {
|
||||
if *slot != IdLookup::Unique(serial) {
|
||||
*slot = IdLookup::Ambiguous;
|
||||
}
|
||||
})
|
||||
.or_insert(IdLookup::Unique(serial));
|
||||
}
|
||||
out
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+38
-242
@@ -1,9 +1,9 @@
|
||||
//! Wayland capture: ashpd ScreenCast portal → PipeWire fd → gst-launch
|
||||
//! pipewiresrc → MPEG-TS on gst stdout → in-process HTTP server bound on a
|
||||
//! random localhost port. The host bridge TCP-connects to that server and
|
||||
//! pumps bytes to QUIC.
|
||||
//! Wayland capture: ashpd ScreenCast portal → PipeWire fd → `pipewiresrc`.
|
||||
//! This module owns only the portal handshake and the source-element args;
|
||||
//! the shared encode/mux tail, gst spawn, and serving live in
|
||||
//! [`super::pipeline`].
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use anyhow::{Context, Result};
|
||||
use ashpd::{
|
||||
WindowIdentifier,
|
||||
desktop::{
|
||||
@@ -12,68 +12,24 @@ use ashpd::{
|
||||
},
|
||||
};
|
||||
use nix::fcntl::{FcntlArg, FdFlag, fcntl};
|
||||
use nix::sys::signal::{Signal, kill};
|
||||
use nix::unistd::{Pid, close};
|
||||
use std::os::fd::{AsFd, IntoRawFd, OwnedFd, RawFd};
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::process::{Child, ChildStdout, Command};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::{Instant, sleep, timeout};
|
||||
use std::os::fd::{AsFd, AsRawFd, OwnedFd, RawFd};
|
||||
|
||||
use super::pipeline::{self, CaptureHandle};
|
||||
use super::quality::EffectiveQuality;
|
||||
use crate::cli::HostOpts;
|
||||
|
||||
pub struct CaptureHandle {
|
||||
port: u16,
|
||||
gst: Option<Child>,
|
||||
server: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl CaptureHandle {
|
||||
pub fn local_port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// Graceful teardown: SIGTERM gst, give it ~1s to exit, then SIGKILL, then
|
||||
/// abort the HTTP server task. Call this before dropping; Drop only fires
|
||||
/// the kill backstop.
|
||||
pub async fn shutdown(mut self) {
|
||||
if let Some(child) = self.gst.as_mut()
|
||||
&& let Some(pid) = child.id()
|
||||
{
|
||||
let _ = kill(Pid::from_raw(pid as i32), Signal::SIGTERM);
|
||||
}
|
||||
if let Some(child) = self.gst.as_mut() {
|
||||
let _ = timeout(Duration::from_millis(1000), child.wait()).await;
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
if let Some(task) = self.server.take() {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CaptureHandle {
|
||||
fn drop(&mut self) {
|
||||
if let Some(child) = self.gst.as_mut() {
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
if let Some(task) = self.server.as_ref() {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start(opts: &HostOpts) -> Result<CaptureHandle> {
|
||||
pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<CaptureHandle> {
|
||||
// 1. Negotiate the screencast session with the portal.
|
||||
let proxy = Screencast::new()
|
||||
.await
|
||||
.context("could not reach the xdg-desktop-portal ScreenCast interface")?;
|
||||
let session = proxy.create_session().await?;
|
||||
|
||||
let source = if opts.window { SourceType::Window } else { SourceType::Monitor };
|
||||
let source = if opts.window {
|
||||
SourceType::Window
|
||||
} else {
|
||||
SourceType::Monitor
|
||||
};
|
||||
proxy
|
||||
.select_sources(
|
||||
&session,
|
||||
@@ -104,155 +60,33 @@ pub async fn start(opts: &HostOpts) -> Result<CaptureHandle> {
|
||||
let pw_fd: OwnedFd = proxy.open_pipe_wire_remote(&session).await?;
|
||||
tracing::info!(node_id, width = w, height = h, "portal handshake complete");
|
||||
// The fd is CLOEXEC by default; the gst child needs to inherit it across
|
||||
// exec. We then leak it via into_raw_fd so its lifetime spans the spawn,
|
||||
// and close the parent's copy once gst is running.
|
||||
// exec, so clear CLOEXEC. We keep the OwnedFd alive across the spawn (gst
|
||||
// inherits its own copy at exec) by moving it into the after_spawn hook,
|
||||
// which drops — and so closes — the parent's copy once gst is running. If
|
||||
// pipeline::spawn errors *before* calling the hook (e.g. audio setup or the
|
||||
// gst spawn fails), the unused closure is dropped, dropping the fd just the
|
||||
// same — so the portal fd never leaks on the error path.
|
||||
clear_cloexec(&pw_fd)?;
|
||||
let raw_fd: RawFd = pw_fd.into_raw_fd();
|
||||
let raw_fd: RawFd = pw_fd.as_raw_fd();
|
||||
|
||||
// 2. Bind the in-process HTTP listener on a random localhost port.
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.context("could not bind local capture HTTP listener")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let source_args = vec![
|
||||
"pipewiresrc".to_string(),
|
||||
format!("fd={raw_fd}"),
|
||||
format!("path={node_id}"),
|
||||
"do-timestamp=true".to_string(),
|
||||
];
|
||||
|
||||
// 3. Spawn gst-launch with the full pipeline: video AND audio captured,
|
||||
// encoded, and muxed into MPEG-TS inside gst. Output goes to stdout,
|
||||
// which we pipe straight to our HTTP server task — no demux/remux,
|
||||
// no codec assumptions.
|
||||
let key_interval = (opts.framerate * 2).to_string();
|
||||
let bitrate = opts.bitrate.to_string();
|
||||
let audio_monitor = default_audio_monitor().await?;
|
||||
let audio_device = format!("device={audio_monitor}");
|
||||
let mut gst_cmd = Command::new("gst-launch-1.0");
|
||||
gst_cmd
|
||||
.args([
|
||||
// muxer + sink
|
||||
"mpegtsmux",
|
||||
"name=mux",
|
||||
"!",
|
||||
"queue",
|
||||
"!",
|
||||
"fdsink",
|
||||
"fd=1",
|
||||
// video branch — videorate caps to 30fps so we don't ship at the
|
||||
// monitor's refresh rate (e.g. 180Hz) and pile up frames in mpv's
|
||||
// demuxer queue faster than realtime.
|
||||
"pipewiresrc",
|
||||
&format!("fd={raw_fd}"),
|
||||
&format!("path={node_id}"),
|
||||
"do-timestamp=true",
|
||||
"!",
|
||||
"videorate",
|
||||
"!",
|
||||
&format!("video/x-raw,framerate={}/1", opts.framerate),
|
||||
"!",
|
||||
"queue",
|
||||
"!",
|
||||
"videoconvert",
|
||||
"!",
|
||||
"video/x-raw,format=NV12",
|
||||
"!",
|
||||
"vah264enc",
|
||||
"rate-control=cbr",
|
||||
&format!("bitrate={bitrate}"),
|
||||
&format!("key-int-max={key_interval}"),
|
||||
"!",
|
||||
"h264parse",
|
||||
"config-interval=-1",
|
||||
"!",
|
||||
"video/x-h264,stream-format=byte-stream,alignment=au",
|
||||
"!",
|
||||
"mux.",
|
||||
// audio branch — capture the default sink's MONITOR (system audio
|
||||
// out), not the default source (which is the mic).
|
||||
"pulsesrc",
|
||||
&audio_device,
|
||||
"do-timestamp=true",
|
||||
"!",
|
||||
"queue",
|
||||
"!",
|
||||
"audioconvert",
|
||||
"!",
|
||||
"audioresample",
|
||||
"!",
|
||||
"audio/x-raw,rate=48000,channels=2",
|
||||
"!",
|
||||
"avenc_aac",
|
||||
"bitrate=128000",
|
||||
"!",
|
||||
"aacparse",
|
||||
"!",
|
||||
"mux.",
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit());
|
||||
if std::env::var_os("PIXELPASS_GST_DEBUG").is_some() {
|
||||
gst_cmd.env("GST_DEBUG", "3");
|
||||
}
|
||||
let mut gst = gst_cmd.spawn().context("failed to spawn gst-launch-1.0")?;
|
||||
// Parent no longer needs the pipewire fd — gst inherited its own copy.
|
||||
let _ = close(raw_fd);
|
||||
|
||||
let gst_stdout = gst
|
||||
.stdout
|
||||
.take()
|
||||
.context("gst-launch-1.0 stdout pipe unavailable")?;
|
||||
|
||||
// 4. Spawn the HTTP server task. It owns the listener + gst stdout: it
|
||||
// accepts one client (the host's bridge socket via connect_to_capture),
|
||||
// drains the HTTP request, writes a fixed MPEG-TS response, then
|
||||
// copies gst stdout to the socket forever.
|
||||
let server = tokio::spawn(serve_capture(listener, gst_stdout));
|
||||
|
||||
Ok(CaptureHandle {
|
||||
port,
|
||||
gst: Some(gst),
|
||||
server: Some(server),
|
||||
})
|
||||
}
|
||||
|
||||
async fn serve_capture(listener: TcpListener, mut gst_stdout: ChildStdout) {
|
||||
let mut sock = match listener.accept().await {
|
||||
Ok((s, _)) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!("capture HTTP accept failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if !drain_http_request(&mut sock).await {
|
||||
return;
|
||||
}
|
||||
|
||||
const RESPONSE: &[u8] = b"HTTP/1.1 200 OK\r\n\
|
||||
Content-Type: video/mp2t\r\n\
|
||||
Cache-Control: no-cache, no-store\r\n\
|
||||
Connection: close\r\n\
|
||||
\r\n";
|
||||
if sock.write_all(RESPONSE).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = tokio::io::copy(&mut gst_stdout, &mut sock).await;
|
||||
}
|
||||
|
||||
async fn drain_http_request(sock: &mut TcpStream) -> bool {
|
||||
let mut buf = [0u8; 1024];
|
||||
let mut total = Vec::with_capacity(512);
|
||||
loop {
|
||||
match sock.read(&mut buf).await {
|
||||
Ok(0) => return false,
|
||||
Ok(n) => total.extend_from_slice(&buf[..n]),
|
||||
Err(_) => return false,
|
||||
}
|
||||
if total.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
return true;
|
||||
}
|
||||
if total.len() > 16 * 1024 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
pipeline::spawn(
|
||||
opts,
|
||||
quality,
|
||||
Some((w as u32, h as u32)),
|
||||
source_args,
|
||||
move || {
|
||||
// Parent no longer needs the pipewire fd — gst inherited its own copy.
|
||||
drop(pw_fd);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn clear_cloexec(fd: &impl AsFd) -> Result<()> {
|
||||
@@ -262,41 +96,3 @@ fn clear_cloexec(fd: &impl AsFd) -> Result<()> {
|
||||
fcntl(fd.as_fd(), FcntlArg::F_SETFD(flags)).context("F_SETFD on pipewire fd")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Connect to the in-process capture HTTP listener, retrying until it's up or
|
||||
/// we time out. Returns the connected socket — the listener accepts exactly
|
||||
/// one connection (the bridge socket), so this stream IS the bridge socket.
|
||||
pub async fn connect_to_capture(port: u16, max_wait: Duration) -> Result<TcpStream> {
|
||||
let deadline = Instant::now() + max_wait;
|
||||
loop {
|
||||
match TcpStream::connect(("127.0.0.1", port)).await {
|
||||
Ok(stream) => return Ok(stream),
|
||||
Err(_) if Instant::now() < deadline => {
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
Err(e) => bail!("capture HTTP listener never came up on 127.0.0.1:{port}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn default_audio_monitor() -> Result<String> {
|
||||
let output = Command::new("pactl")
|
||||
.arg("get-default-sink")
|
||||
.output()
|
||||
.await
|
||||
.context("failed to run `pactl get-default-sink` (install pulseaudio-utils or pipewire-pulse)")?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"pactl get-default-sink failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
let sink = String::from_utf8(output.stdout)
|
||||
.context("default sink name was not UTF-8")?
|
||||
.trim()
|
||||
.to_string();
|
||||
if sink.is_empty() {
|
||||
bail!("pactl get-default-sink returned no name (is a sound server running?)");
|
||||
}
|
||||
Ok(format!("{sink}.monitor"))
|
||||
}
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
//! X11 capture: `ximagesrc` → the shared encode/mux tail in [`super::pipeline`].
|
||||
//! Unlike Wayland there's no portal and no fd hand-off — `ximagesrc` opens its
|
||||
//! own X connection from `$DISPLAY`. The whole root window is captured by
|
||||
//! default; `--window` resolves a single window's XID via an `xwininfo`
|
||||
//! click-picker. The ticket is the access control, so capture starts silently
|
||||
//! when the first viewer connects (no host-side consent prompt).
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use tokio::process::Command;
|
||||
use x11rb::connection::Connection;
|
||||
use x11rb::protocol::xproto::ConnectionExt;
|
||||
|
||||
use super::pipeline::{self, CaptureHandle};
|
||||
use super::quality::EffectiveQuality;
|
||||
use crate::cli::HostOpts;
|
||||
|
||||
pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<CaptureHandle> {
|
||||
let xid = if opts.window {
|
||||
Some(pick_window().await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Geometry mirrors Wayland's portal-handshake log line and feeds the
|
||||
// downscale presets (so they can compute an exact target size). A failure
|
||||
// here shouldn't abort capture — ximagesrc will surface a real error if the
|
||||
// X connection is genuinely unusable, and the scaler falls back to a
|
||||
// height-only negotiation when dims are unknown.
|
||||
let source_dims = match read_geometry(xid) {
|
||||
Ok((w, h)) => {
|
||||
tracing::info!(width = w, height = h, xid = ?xid, "X11 capture geometry");
|
||||
Some((w as u32, h as u32))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("could not read X11 geometry (capture will still try): {e:#}");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// XDamage capture (`use-damage=true`) only re-grabs changed screen
|
||||
// regions instead of copying the whole root window every frame. On a busy
|
||||
// desktop that is the difference between a usable framerate and ~1 fps —
|
||||
// `use-damage=false` does a full XGetImage per frame, which collapses on
|
||||
// servers without working MIT-SHM (and pins the CPU everywhere else).
|
||||
// Kept as the default; `PIXELPASS_X11_NO_DAMAGE=1` restores full-frame
|
||||
// capture if a driver produces partial-update artifacts with damage on.
|
||||
let use_damage = if std::env::var_os("PIXELPASS_X11_NO_DAMAGE").is_some() {
|
||||
"use-damage=false"
|
||||
} else {
|
||||
"use-damage=true"
|
||||
};
|
||||
let mut source_args = vec![
|
||||
"ximagesrc".to_string(),
|
||||
// show-pointer matches Wayland's CursorMode::Embedded.
|
||||
use_damage.to_string(),
|
||||
"show-pointer=true".to_string(),
|
||||
];
|
||||
if let Some(xid) = xid {
|
||||
source_args.push(format!("xid={xid}"));
|
||||
}
|
||||
|
||||
// X11 has no leaked fd to clean up, so the post-spawn hook is a no-op.
|
||||
pipeline::spawn(opts, quality, source_dims, source_args, || {}).await
|
||||
}
|
||||
|
||||
/// Run `xwininfo` and let the user click the window they want to share, then
|
||||
/// parse the `Window id: 0x…` line out of its output. Returns the numeric XID.
|
||||
async fn pick_window() -> Result<u32> {
|
||||
eprintln!("[pixelpass] click the window you want to share…");
|
||||
let output = Command::new("xwininfo")
|
||||
.output()
|
||||
.await
|
||||
.context("failed to run `xwininfo` (install xorg-xwininfo)")?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"xwininfo failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
for line in text.lines() {
|
||||
// e.g. "xwininfo: Window id: 0x3a00007 \"xterm\""
|
||||
if let Some((_, rest)) = line.split_once("Window id: ") {
|
||||
let token = rest.split_whitespace().next().unwrap_or("");
|
||||
let hex = token.strip_prefix("0x").unwrap_or(token);
|
||||
if let Ok(xid) = u32::from_str_radix(hex, 16) {
|
||||
return Ok(xid);
|
||||
}
|
||||
}
|
||||
}
|
||||
bail!("could not parse a window id from xwininfo output");
|
||||
}
|
||||
|
||||
/// Read pixel dimensions: the selected window's geometry when `--window` was
|
||||
/// used, otherwise the root window of the screen named by `$DISPLAY`.
|
||||
fn read_geometry(xid: Option<u32>) -> Result<(u16, u16)> {
|
||||
let (conn, screen_num) =
|
||||
x11rb::connect(None).context("could not connect to the X server (is DISPLAY set?)")?;
|
||||
match xid {
|
||||
Some(id) => {
|
||||
let geo = conn
|
||||
.get_geometry(id)
|
||||
.context("GetGeometry request failed")?
|
||||
.reply()
|
||||
.context("GetGeometry reply failed")?;
|
||||
Ok((geo.width, geo.height))
|
||||
}
|
||||
None => {
|
||||
let screen = &conn.setup().roots[screen_num];
|
||||
Ok((screen.width_in_pixels, screen.height_in_pixels))
|
||||
}
|
||||
}
|
||||
}
|
||||
+276
-11
@@ -3,7 +3,8 @@ use dialoguer::{Input, Select, theme::ColorfulTheme};
|
||||
use iroh_tickets::endpoint::EndpointTicket;
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::cli::Cli;
|
||||
use crate::cli::{Cli, Quality};
|
||||
use crate::common::{bandwidth, config};
|
||||
use crate::{host, viewer};
|
||||
|
||||
pub async fn run(cli: Cli) -> Result<()> {
|
||||
@@ -12,7 +13,7 @@ pub async fn run(cli: Cli) -> Result<()> {
|
||||
let theme = ColorfulTheme::default();
|
||||
let choice = Select::with_theme(&theme)
|
||||
.with_prompt("What do you want to do?")
|
||||
.items(&[
|
||||
.items([
|
||||
"Host (share my screen)",
|
||||
"View (watch someone else's screen)",
|
||||
])
|
||||
@@ -20,7 +21,17 @@ pub async fn run(cli: Cli) -> Result<()> {
|
||||
.interact()?;
|
||||
|
||||
match choice {
|
||||
0 => host::run(cli.into_host_opts(true)).await,
|
||||
0 => {
|
||||
preflight_if_needed(&theme).await;
|
||||
let mut cli = cli;
|
||||
if cli.app.is_none() {
|
||||
cli.app = pick_app(&theme)?;
|
||||
}
|
||||
if cli.quality.is_none() {
|
||||
cli.quality = Some(pick_quality(&theme)?);
|
||||
}
|
||||
host::run(cli.into_host_opts(true)).await
|
||||
}
|
||||
_ => {
|
||||
let ticket = prompt_ticket(&theme)?;
|
||||
viewer::run(ticket, cli.into_viewer_opts(true)).await
|
||||
@@ -28,6 +39,215 @@ pub async fn run(cli: Cli) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Picker for the per-app audio capture choice. Lists apps currently
|
||||
/// producing audio (deduped by `application.name`); user picks one or
|
||||
/// the "all system audio" default. Bypassed when `--app NAME` was given
|
||||
/// on the CLI.
|
||||
fn pick_app(theme: &ColorfulTheme) -> Result<Option<String>> {
|
||||
let apps = match host::audio::list_playing_apps() {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
tracing::warn!("could not enumerate playing apps: {e:#}");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!();
|
||||
eprintln!("Audio capture");
|
||||
eprintln!("─────────────");
|
||||
if apps.is_empty() {
|
||||
eprintln!("No other apps are currently producing audio.");
|
||||
eprintln!("Start your game / music / call first if you want to pick it specifically.");
|
||||
}
|
||||
eprintln!();
|
||||
|
||||
let mut items = vec!["Capture all system audio (default)".to_string()];
|
||||
for app in &apps {
|
||||
items.push(if app.stream_count == 1 {
|
||||
app.name.clone()
|
||||
} else {
|
||||
format!("{} ({} streams)", app.name, app.stream_count)
|
||||
});
|
||||
}
|
||||
|
||||
let choice = Select::with_theme(theme)
|
||||
.with_prompt("What audio should the viewer hear?")
|
||||
.items(&items)
|
||||
.default(0)
|
||||
.interact()?;
|
||||
|
||||
if choice == 0 {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(apps[choice - 1].name.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Picker for the encode quality preset. Mirrors [`pick_app`]; bypassed when
|
||||
/// `--quality` was given on the CLI. Quality is host-global (the same stream
|
||||
/// fans out to every viewer), so this is the one choice that sets it for all.
|
||||
fn pick_quality(theme: &ColorfulTheme) -> Result<Quality> {
|
||||
eprintln!();
|
||||
eprintln!("Quality");
|
||||
eprintln!("───────");
|
||||
eprintln!("Lower presets trade resolution + bitrate for less upload usage.");
|
||||
eprintln!("The same quality is sent to every viewer.");
|
||||
eprintln!();
|
||||
|
||||
// Order mirrors the labels below; index maps back to a Quality.
|
||||
let choices = [
|
||||
Quality::Auto,
|
||||
Quality::Source,
|
||||
Quality::High,
|
||||
Quality::Medium,
|
||||
Quality::Low,
|
||||
];
|
||||
let items = [
|
||||
"Auto — pick from my measured upstream (recommended)",
|
||||
"Source — native resolution, 6000 kbps",
|
||||
"High — up to 1080p, 4000 kbps",
|
||||
"Medium — up to 720p, 2500 kbps",
|
||||
"Low — up to 480p, 1000 kbps",
|
||||
];
|
||||
|
||||
let choice = Select::with_theme(theme)
|
||||
.with_prompt("What quality should the viewer(s) get?")
|
||||
.items(items)
|
||||
.default(0)
|
||||
.interact()?;
|
||||
|
||||
Ok(choices[choice])
|
||||
}
|
||||
|
||||
/// `pixelpass --reconfigure` entry point: unconditionally re-run the
|
||||
/// bandwidth pre-flight test, save the result, and return. Used to
|
||||
/// refresh a stale measurement (e.g. user moved house, changed ISP).
|
||||
pub async fn run_reconfigure() -> Result<()> {
|
||||
eprintln!();
|
||||
eprintln!("Re-running bandwidth pre-flight test…");
|
||||
let mut cfg = config::load().unwrap_or_default();
|
||||
run_bandwidth_test(&mut cfg).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// First-run pre-flight gate. Called once, when the user picks "Host" in
|
||||
/// the interactive menu. Behavior by saved status:
|
||||
/// - Unmeasured (first ever launch): explain + offer Run / Skip
|
||||
/// - Failed (previous attempt errored): offer Retry / give-up-and-skip
|
||||
/// - Measured or Skipped: silent — never re-prompts
|
||||
async fn preflight_if_needed(theme: &ColorfulTheme) {
|
||||
let mut cfg = config::load().unwrap_or_default();
|
||||
match cfg.bandwidth.status {
|
||||
config::BandwidthStatus::Measured | config::BandwidthStatus::Skipped => (),
|
||||
config::BandwidthStatus::Unmeasured => {
|
||||
eprintln!();
|
||||
eprintln!("First-time setup");
|
||||
eprintln!("────────────────");
|
||||
eprintln!("PixelPass can measure your upload speed to recommend a safe");
|
||||
eprintln!("default for how many viewers your connection can handle.");
|
||||
eprintln!("The test takes about 5 seconds and uploads ~5 MB to");
|
||||
eprintln!("Cloudflare's open speed-test endpoint.");
|
||||
eprintln!();
|
||||
eprintln!("If you skip, a conservative default (2 viewers) is used.");
|
||||
eprintln!("You can run the test later with `pixelpass --reconfigure`.");
|
||||
eprintln!();
|
||||
|
||||
let Ok(choice) = Select::with_theme(theme)
|
||||
.with_prompt("What would you like to do?")
|
||||
.items([
|
||||
"Run the bandwidth test (recommended)",
|
||||
"Skip — use the conservative default",
|
||||
])
|
||||
.default(0)
|
||||
.interact()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
if choice == 1 {
|
||||
cfg.bandwidth = config::BandwidthEntry {
|
||||
status: config::BandwidthStatus::Skipped,
|
||||
upstream_mbps: None,
|
||||
measured_at: None,
|
||||
};
|
||||
let _ = config::save(&cfg);
|
||||
eprintln!("Pre-flight skipped.");
|
||||
return;
|
||||
}
|
||||
run_bandwidth_test(&mut cfg).await;
|
||||
}
|
||||
config::BandwidthStatus::Failed => {
|
||||
eprintln!();
|
||||
let Ok(choice) = Select::with_theme(theme)
|
||||
.with_prompt("Last bandwidth test failed. Try again?")
|
||||
.items(["Yes — retry now", "No — use the conservative default"])
|
||||
.default(0)
|
||||
.interact()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
if choice == 1 {
|
||||
cfg.bandwidth = config::BandwidthEntry {
|
||||
status: config::BandwidthStatus::Skipped,
|
||||
upstream_mbps: None,
|
||||
measured_at: None,
|
||||
};
|
||||
let _ = config::save(&cfg);
|
||||
eprintln!("OK — using the conservative default.");
|
||||
return;
|
||||
}
|
||||
run_bandwidth_test(&mut cfg).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_bandwidth_test(cfg: &mut config::Config) {
|
||||
eprintln!();
|
||||
eprintln!("Measuring upstream…");
|
||||
|
||||
let result = tokio::task::spawn_blocking(bandwidth::measure_upstream_blocking).await;
|
||||
let measurement = match result {
|
||||
Ok(Ok(m)) => m,
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("Test failed: {e:#}");
|
||||
eprintln!("Marking as failed — you'll be asked again on next launch.");
|
||||
cfg.bandwidth = config::BandwidthEntry {
|
||||
status: config::BandwidthStatus::Failed,
|
||||
upstream_mbps: None,
|
||||
measured_at: None,
|
||||
};
|
||||
let _ = config::save(cfg);
|
||||
return;
|
||||
}
|
||||
Err(join_err) => {
|
||||
eprintln!("Test task panicked: {join_err}");
|
||||
cfg.bandwidth = config::BandwidthEntry {
|
||||
status: config::BandwidthStatus::Failed,
|
||||
upstream_mbps: None,
|
||||
measured_at: None,
|
||||
};
|
||||
let _ = config::save(cfg);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!(
|
||||
"Measured {:.2} Mbps up (safe estimate {:.2} Mbps, took {:.1}s).",
|
||||
measurement.raw_mbps,
|
||||
measurement.safe_mbps,
|
||||
measurement.elapsed.as_secs_f64()
|
||||
);
|
||||
cfg.bandwidth = config::BandwidthEntry {
|
||||
status: config::BandwidthStatus::Measured,
|
||||
upstream_mbps: Some(measurement.safe_mbps),
|
||||
measured_at: Some(chrono::Utc::now()),
|
||||
};
|
||||
if let Err(e) = config::save(cfg) {
|
||||
eprintln!("Warning: failed to save result: {e:#}");
|
||||
}
|
||||
}
|
||||
|
||||
fn print_welcome() {
|
||||
eprintln!();
|
||||
eprintln!("Welcome to PixelPass.");
|
||||
@@ -59,29 +279,74 @@ impl Player {
|
||||
Player::Mpv => crate::common::process::spawn_detached(
|
||||
"mpv",
|
||||
&[
|
||||
// No `--untimed`: it ignores audio timestamps and drifts a
|
||||
// shared video out of sync. Pacing to audio keeps A/V synced.
|
||||
// Also leave hwdec at the `low-latency` default (software
|
||||
// decode): forcing `--hwdec=auto` froze some viewers on
|
||||
// frame 1 while audio kept playing.
|
||||
"--profile=low-latency",
|
||||
"--untimed",
|
||||
"--hwdec=auto",
|
||||
"--audio-buffer=0.2",
|
||||
"--demuxer-max-bytes=2M",
|
||||
"--demuxer-readahead-secs=0.5",
|
||||
url,
|
||||
],
|
||||
),
|
||||
Player::Vlc => crate::common::process::spawn_detached(
|
||||
"vlc",
|
||||
&["--network-caching=200", "--live-caching=200", url],
|
||||
),
|
||||
Player::Vlc => {
|
||||
warn_if_vlc_plugins_missing();
|
||||
crate::common::process::spawn_detached(
|
||||
"vlc",
|
||||
&["--network-caching=200", "--live-caching=200", url],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// On Arch-family distros, the base `vlc` package omits two plugins
|
||||
// pixelpass needs: the MPEG-TS demuxer (`vlc-plugin-dvb`) and the
|
||||
// libavcodec-based H.264 decoder (`vlc-plugin-ffmpeg`). Missing either
|
||||
// produces a confusing error chain — warn at launch.
|
||||
fn warn_if_vlc_plugins_missing() {
|
||||
const REQUIRED: &[(&str, &str)] = &[
|
||||
(
|
||||
"/usr/lib/vlc/plugins/demux/libts_plugin.so",
|
||||
"vlc-plugin-dvb",
|
||||
),
|
||||
(
|
||||
"/usr/lib/vlc/plugins/codec/libavcodec_plugin.so",
|
||||
"vlc-plugin-ffmpeg",
|
||||
),
|
||||
];
|
||||
let missing: Vec<&(&str, &str)> = REQUIRED
|
||||
.iter()
|
||||
.filter(|(p, _)| !std::path::Path::new(p).exists())
|
||||
.collect();
|
||||
if missing.is_empty() {
|
||||
return;
|
||||
}
|
||||
eprintln!();
|
||||
eprintln!("Warning: VLC is missing plugins pixelpass needs:");
|
||||
for (path, pkg) in &missing {
|
||||
eprintln!(" - {path} (install `{pkg}`)");
|
||||
}
|
||||
eprintln!("On Arch / CachyOS / EndeavourOS: `sudo pacman -S {}`.", {
|
||||
let names: Vec<&str> = missing.iter().map(|(_, p)| *p).collect();
|
||||
names.join(" ")
|
||||
});
|
||||
eprintln!("mpv is unaffected.");
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
pub fn prompt_player() -> Result<Player> {
|
||||
let theme = ColorfulTheme::default();
|
||||
let choice = Select::with_theme(&theme)
|
||||
.with_prompt("Connected. Pick a player to launch")
|
||||
.items(&["mpv", "VLC"])
|
||||
.items(["mpv", "VLC"])
|
||||
.default(0)
|
||||
.interact()?;
|
||||
Ok(if choice == 0 { Player::Mpv } else { Player::Vlc })
|
||||
Ok(if choice == 0 {
|
||||
Player::Mpv
|
||||
} else {
|
||||
Player::Vlc
|
||||
})
|
||||
}
|
||||
|
||||
+68
-2
@@ -1,5 +1,8 @@
|
||||
mod cli;
|
||||
mod common;
|
||||
mod doctor;
|
||||
#[cfg(feature = "gui")]
|
||||
mod gui;
|
||||
mod host;
|
||||
mod interactive;
|
||||
mod repair;
|
||||
@@ -16,10 +19,60 @@ async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
init_tracing(cli.verbose);
|
||||
|
||||
if matches!(cli.output, Some(cli::OutputFormat::Json)) {
|
||||
common::output::set_json(true);
|
||||
}
|
||||
|
||||
if cli.gui {
|
||||
#[cfg(feature = "gui")]
|
||||
{
|
||||
return gui::run(cli.relay);
|
||||
}
|
||||
#[cfg(not(feature = "gui"))]
|
||||
{
|
||||
anyhow::bail!(
|
||||
"this binary was built without GUI support. Rebuild with \
|
||||
`cargo build --release --features gui` to use --gui."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostics run before pipewire::init() (they don't need it) and work
|
||||
// regardless of the `gui` feature, so a headless tester can probe their box.
|
||||
if cli.doctor {
|
||||
let relay = common::endpoint::relay_override(cli.relay.as_deref());
|
||||
return doctor::run(relay).await;
|
||||
}
|
||||
|
||||
// libpipewire requires global init before any pw_* call. Idempotent;
|
||||
// safe to call even when the per-app audio thread never spawns.
|
||||
pipewire::init();
|
||||
|
||||
if cli.repair {
|
||||
return repair::run().await;
|
||||
}
|
||||
|
||||
// Read-only diagnostic: observe the graph, report what the audio-exclusion
|
||||
// engine concludes, create nothing. Placed before the host/viewer dispatch
|
||||
// because it is neither — it shares no screen and connects to no peer.
|
||||
if cli.audit_audio {
|
||||
return host::audit::run::run_standalone().await;
|
||||
}
|
||||
|
||||
if cli.reconfigure {
|
||||
return interactive::run_reconfigure().await;
|
||||
}
|
||||
|
||||
if cli.host {
|
||||
if cli.ticket.is_some() {
|
||||
anyhow::bail!(
|
||||
"--host and a ticket argument are mutually exclusive: --host shares your \
|
||||
screen, a ticket views someone else's."
|
||||
);
|
||||
}
|
||||
return host::run(cli.into_host_opts(false)).await;
|
||||
}
|
||||
|
||||
match cli.ticket.as_deref() {
|
||||
Some(s) => {
|
||||
let ticket: EndpointTicket = s.parse().map_err(|e| {
|
||||
@@ -35,7 +88,20 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
|
||||
fn init_tracing(verbose: bool) {
|
||||
let default = if verbose { "pixelpass=trace,iroh=info" } else { "pixelpass=info,iroh=warn" };
|
||||
let default = if verbose {
|
||||
"pixelpass=trace,iroh=info"
|
||||
} else {
|
||||
"pixelpass=info,iroh=warn"
|
||||
};
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default));
|
||||
tracing_subscriber::fmt().with_env_filter(filter).with_target(false).init();
|
||||
// Tracing MUST write to stderr. `tracing_subscriber::fmt()` defaults its
|
||||
// writer to stdout, but with `--output json` stdout carries the JSON event
|
||||
// stream the `--gui` front-end parses (see `common::output`) — logging there
|
||||
// interleaves log lines into that stream (corrupting events and starving the
|
||||
// GUI's stderr-tail diagnostics). Pin it to stderr to honor that contract.
|
||||
tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_env_filter(filter)
|
||||
.with_target(false)
|
||||
.init();
|
||||
}
|
||||
|
||||
+231
-7
@@ -1,12 +1,236 @@
|
||||
//! `--repair`: clean up any null sinks / loopbacks that a crashed pixelpass
|
||||
//! host left behind. Phase 2 will scan PipeWire for nodes tagged with the
|
||||
//! `pixelpass.session = <uuid>` property and destroy them.
|
||||
//! `--repair`: clean up null-sinks and loopbacks left behind by a crashed
|
||||
//! pixelpass host. Identifies orphans by the `pixelpass_capture_<pid>`
|
||||
//! name pattern + dead-PID check, then unloads paired loopbacks first
|
||||
//! (mirrors `Routing::shutdown`'s order so PipeWire doesn't leave zombie
|
||||
//! links). Live PIDs — including this process and any other running
|
||||
//! pixelpass — are left alone.
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result, bail};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
const SINK_NAME_PREFIX: &str = "pixelpass_capture_";
|
||||
|
||||
pub async fn run() -> Result<()> {
|
||||
eprintln!("[pixelpass] --repair: PipeWire scan not yet implemented (Phase 2).");
|
||||
eprintln!(" Run `pactl list short sinks | grep pixelpass` to spot orphans,");
|
||||
eprintln!(" and `pactl unload-module <id>` to remove them manually.");
|
||||
let modules = list_modules().context("failed to list pactl modules")?;
|
||||
|
||||
let mut dead_sinks: Vec<OrphanSink> = Vec::new();
|
||||
let mut dead_pids: HashSet<u32> = HashSet::new();
|
||||
let mut live_skipped: u32 = 0;
|
||||
|
||||
for m in &modules {
|
||||
if m.name != "module-null-sink" {
|
||||
continue;
|
||||
}
|
||||
let Some(sink_name) = extract_kv(&m.args, "sink_name") else {
|
||||
continue;
|
||||
};
|
||||
let Some(pid_str) = sink_name.strip_prefix(SINK_NAME_PREFIX) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(pid) = pid_str.parse::<u32>() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if is_pid_alive(pid) {
|
||||
live_skipped += 1;
|
||||
continue;
|
||||
}
|
||||
dead_pids.insert(pid);
|
||||
dead_sinks.push(OrphanSink {
|
||||
id: m.id,
|
||||
sink_name: sink_name.to_string(),
|
||||
pid,
|
||||
});
|
||||
}
|
||||
|
||||
let mut dead_loopbacks: Vec<u32> = Vec::new();
|
||||
for m in &modules {
|
||||
if m.name != "module-loopback" {
|
||||
continue;
|
||||
}
|
||||
// A pixelpass loopback references a capture sink either as its
|
||||
// destination (`sink=pixelpass_capture_<pid>` — the default→null
|
||||
// mirror) or as its source (`source=pixelpass_capture_<pid>.monitor`
|
||||
// — the local monitor that lets the sharer hear the app). Match both.
|
||||
let Some(pid) = loopback_capture_pid(&m.args) else {
|
||||
continue;
|
||||
};
|
||||
if dead_pids.contains(&pid) {
|
||||
dead_loopbacks.push(m.id);
|
||||
}
|
||||
}
|
||||
|
||||
if dead_sinks.is_empty() && dead_loopbacks.is_empty() {
|
||||
if live_skipped > 0 {
|
||||
println!(
|
||||
"[pixelpass] --repair: nothing to clean up ({live_skipped} live pixelpass host(s) left alone)."
|
||||
);
|
||||
} else {
|
||||
println!("[pixelpass] --repair: nothing to clean up.");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut unloaded = 0u32;
|
||||
let mut failed = 0u32;
|
||||
|
||||
for id in &dead_loopbacks {
|
||||
match unload_module(*id) {
|
||||
Ok(()) => {
|
||||
println!("[pixelpass] --repair: unloaded loopback module #{id}");
|
||||
unloaded += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[pixelpass] --repair: failed to unload loopback #{id}: {e:#}");
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
for orphan in &dead_sinks {
|
||||
match unload_module(orphan.id) {
|
||||
Ok(()) => {
|
||||
println!(
|
||||
"[pixelpass] --repair: unloaded {} (orphaned from pid {})",
|
||||
orphan.sink_name, orphan.pid
|
||||
);
|
||||
unloaded += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[pixelpass] --repair: failed to unload {} (#{}): {e:#}",
|
||||
orphan.sink_name, orphan.id
|
||||
);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if live_skipped > 0 {
|
||||
println!("[pixelpass] --repair: left {live_skipped} live pixelpass host(s) alone.");
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
bail!("--repair: {failed} module(s) failed to unload (see errors above)");
|
||||
}
|
||||
println!("[pixelpass] --repair: cleaned up {unloaded} module(s).");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Module {
|
||||
id: u32,
|
||||
name: String,
|
||||
args: String,
|
||||
}
|
||||
|
||||
struct OrphanSink {
|
||||
id: u32,
|
||||
sink_name: String,
|
||||
pid: u32,
|
||||
}
|
||||
|
||||
fn list_modules() -> Result<Vec<Module>> {
|
||||
let output = Command::new("pactl")
|
||||
.args(["list", "short", "modules"])
|
||||
.output()
|
||||
.context("failed to run `pactl list short modules`")?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"pactl list short modules failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
let text = String::from_utf8(output.stdout).context("pactl returned non-UTF-8")?;
|
||||
let mut modules = Vec::new();
|
||||
// `pactl list short modules` is tab-separated, but some modules have
|
||||
// multi-line `{ ... }` argument blocks that wrap onto continuation
|
||||
// lines starting with whitespace. The wrap lines never parse as a
|
||||
// u32 ID, so the simple per-line + parse-id filter is robust.
|
||||
for line in text.lines() {
|
||||
let mut parts = line.splitn(4, '\t');
|
||||
let Some(id_str) = parts.next() else { continue };
|
||||
let Ok(id) = id_str.parse::<u32>() else {
|
||||
continue;
|
||||
};
|
||||
let Some(name) = parts.next() else { continue };
|
||||
let args = parts.next().unwrap_or("").to_string();
|
||||
modules.push(Module {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
args,
|
||||
});
|
||||
}
|
||||
Ok(modules)
|
||||
}
|
||||
|
||||
/// The `pixelpass_capture_<pid>` PID a loopback references, whether the capture
|
||||
/// sink is its destination (`sink=pixelpass_capture_<pid>`) or its source
|
||||
/// (`source=pixelpass_capture_<pid>.monitor`). `None` for unrelated loopbacks.
|
||||
fn loopback_capture_pid(args: &str) -> Option<u32> {
|
||||
let from_sink = extract_kv(args, "sink").and_then(|v| v.strip_prefix(SINK_NAME_PREFIX));
|
||||
let from_source = extract_kv(args, "source")
|
||||
.and_then(|v| v.strip_prefix(SINK_NAME_PREFIX))
|
||||
.and_then(|rest| rest.strip_suffix(".monitor"));
|
||||
from_sink
|
||||
.or(from_source)
|
||||
.and_then(|pid| pid.parse::<u32>().ok())
|
||||
}
|
||||
|
||||
fn extract_kv<'a>(args: &'a str, key: &str) -> Option<&'a str> {
|
||||
for token in args.split_whitespace() {
|
||||
if let Some(rest) = token.strip_prefix(key)
|
||||
&& let Some(value) = rest.strip_prefix('=')
|
||||
{
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_pid_alive(pid: u32) -> bool {
|
||||
Path::new(&format!("/proc/{pid}")).exists()
|
||||
}
|
||||
|
||||
fn unload_module(id: u32) -> Result<()> {
|
||||
let output = Command::new("pactl")
|
||||
.arg("unload-module")
|
||||
.arg(id.to_string())
|
||||
.output()
|
||||
.context("failed to run pactl unload-module")?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"pactl unload-module #{id}: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn loopback_pid_matches_default_null_mirror_by_sink() {
|
||||
// The default→null loopback: capture sink is the destination.
|
||||
let args = "source=@DEFAULT_SINK@.monitor sink=pixelpass_capture_4242 latency_msec=20";
|
||||
assert_eq!(loopback_capture_pid(args), Some(4242));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_pid_matches_local_monitor_by_source() {
|
||||
// The local monitor: capture sink's monitor is the source, and the
|
||||
// destination is the real default sink (not a pixelpass name).
|
||||
let args = "source=pixelpass_capture_4242.monitor sink=@DEFAULT_SINK@ latency_msec=20";
|
||||
assert_eq!(loopback_capture_pid(args), Some(4242));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_pid_ignores_unrelated_loopback() {
|
||||
assert_eq!(
|
||||
loopback_capture_pid("source=alsa_output.pci.monitor sink=some_other_sink"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+79
-33
@@ -1,50 +1,96 @@
|
||||
use anyhow::{Context, Result};
|
||||
use iroh::Endpoint;
|
||||
use iroh::endpoint::presets;
|
||||
use anyhow::{Context, Result, bail};
|
||||
use iroh_tickets::endpoint::EndpointTicket;
|
||||
use std::time::Duration;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::cli::ViewerOpts;
|
||||
use crate::common::{alpn::ALPN, signal};
|
||||
use crate::common::{alpn::ALPN, endpoint, output, signal};
|
||||
|
||||
/// Cap on the initial QUIC connect. `endpoint.connect()` has no built-in
|
||||
/// deadline, so an offline host / stale code / unreachable relay otherwise
|
||||
/// hangs forever with no feedback (the silent "connecting…" failure mode).
|
||||
/// Matches the host's 15s `online()` cap.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
pub async fn run(ticket: EndpointTicket, opts: ViewerOpts) -> Result<()> {
|
||||
let cancel = signal::install_ctrl_c();
|
||||
|
||||
let endpoint = Endpoint::builder(presets::N0)
|
||||
.alpns(vec![ALPN.to_vec()])
|
||||
.bind()
|
||||
.await?;
|
||||
let endpoint = endpoint::bind(opts.relay.as_deref()).await?;
|
||||
|
||||
let addr = ticket.endpoint_addr().clone();
|
||||
tracing::info!(remote = %addr.id, "connecting to host");
|
||||
let conn = endpoint.connect(addr, ALPN).await?;
|
||||
let (quic_send, quic_recv) = conn.open_bi().await?;
|
||||
|
||||
let listener = TcpListener::bind(("127.0.0.1", opts.port)).await?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let url = format!("http://127.0.0.1:{port}");
|
||||
|
||||
if opts.interactive {
|
||||
let player = crate::interactive::prompt_player()?;
|
||||
player
|
||||
.spawn(&url)
|
||||
.with_context(|| "failed to launch player")?;
|
||||
print_viewer_banner_interactive();
|
||||
} else {
|
||||
print_viewer_banner(&url);
|
||||
}
|
||||
|
||||
let result = tokio::select! {
|
||||
accepted = listener.accept() => {
|
||||
let (tcp, peer) = accepted?;
|
||||
tracing::info!(%peer, "local viewer connected");
|
||||
crate::common::tunnel::bridge(quic_send, quic_recv, tcp).await
|
||||
}
|
||||
// Bound the connect attempt and let ctrl-c abort it, so the viewer fails
|
||||
// loud (and the GUI surfaces the error) instead of spinning indefinitely.
|
||||
let conn = tokio::select! {
|
||||
_ = cancel.cancelled() => {
|
||||
tracing::info!("ctrl-c received before local viewer connected");
|
||||
Ok(())
|
||||
tracing::info!("ctrl-c received before the connection was established");
|
||||
endpoint.close().await;
|
||||
return Ok(());
|
||||
}
|
||||
result = tokio::time::timeout(CONNECT_TIMEOUT, endpoint.connect(addr, ALPN)) => match result {
|
||||
Ok(Ok(conn)) => conn,
|
||||
Ok(Err(e)) => {
|
||||
endpoint.close().await;
|
||||
bail!("failed to connect to the host: {e:#}");
|
||||
}
|
||||
Err(_) => {
|
||||
endpoint.close().await;
|
||||
bail!(
|
||||
"couldn't reach the host within {}s — it may be offline, the share \
|
||||
code may be stale, or the relay may be unreachable. Check that the \
|
||||
host is running, then re-copy the code and try again.",
|
||||
CONNECT_TIMEOUT.as_secs()
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
// Everything past the established connection runs in one block so any error
|
||||
// (open_bi, bind, local_addr, accept) is captured rather than `?`-propagated
|
||||
// straight out of the function — that would skip the close below and leak the
|
||||
// endpoint. The connect-phase arms above close explicitly for the same reason.
|
||||
let result = async {
|
||||
let (quic_send, quic_recv) = conn.open_bi().await?;
|
||||
|
||||
let listener = TcpListener::bind(("127.0.0.1", opts.port)).await?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let url = format!("http://127.0.0.1:{port}");
|
||||
output::emit(output::Event::Connected { url: &url });
|
||||
|
||||
if opts.interactive {
|
||||
let player = crate::interactive::prompt_player()?;
|
||||
player
|
||||
.spawn(&url)
|
||||
.with_context(|| "failed to launch player")?;
|
||||
print_viewer_banner_interactive();
|
||||
} else {
|
||||
print_viewer_banner(&url);
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
accepted = listener.accept() => {
|
||||
let (tcp, peer) = accepted?;
|
||||
tracing::info!(%peer, "local viewer connected");
|
||||
// Race the bridge against ctrl-c so a disconnect lands promptly
|
||||
// mid-stream (mirrors the host's handle_peer). Without this, the
|
||||
// cancel token is set but nothing checks it once the player has
|
||||
// connected — ctrl-c is ignored until a second press, and a GUI
|
||||
// "Disconnect" only takes effect via the child's SIGKILL backstop.
|
||||
tokio::select! {
|
||||
res = crate::common::tunnel::bridge(quic_send, quic_recv, tcp) => res,
|
||||
_ = cancel.cancelled() => {
|
||||
tracing::info!("ctrl-c received during stream — disconnecting");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = cancel.cancelled() => {
|
||||
tracing::info!("ctrl-c received before local viewer connected");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
.await;
|
||||
|
||||
endpoint.close().await;
|
||||
result
|
||||
@@ -56,7 +102,7 @@ fn print_viewer_banner(url: &str) {
|
||||
eprintln!("│ Connected to host. Open the stream in your player:");
|
||||
eprintln!("│");
|
||||
eprintln!(
|
||||
"│ mpv --profile=low-latency --untimed --hwdec=auto --audio-buffer=0.2 --demuxer-max-bytes=2M --demuxer-readahead-secs=0.5 {url}"
|
||||
"│ mpv --profile=low-latency --audio-buffer=0.2 --demuxer-max-bytes=2M --demuxer-readahead-secs=0.5 {url}"
|
||||
);
|
||||
eprintln!("│ vlc --network-caching=200 --live-caching=200 {url}");
|
||||
eprintln!("│");
|
||||
|
||||
Reference in New Issue
Block a user