65 changed files with 1689 additions and 15809 deletions
Generated
+1260 -698
View File
File diff suppressed because it is too large Load Diff
+6 -50
View File
@@ -6,33 +6,12 @@ description = "P2P screen sharing CLI over iroh"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
publish = false 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]] [[bin]]
name = "pixelpass" name = "pixelpass"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
iroh = "1.0.2" iroh = "1.0.0-rc.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-util", "net", "signal", "process", "sync", "time"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-util", "net", "signal", "process", "sync", "time"] }
tokio-util = { version = "0.7", features = ["io"] } tokio-util = { version = "0.7", features = ["io"] }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
@@ -48,39 +27,16 @@ ashpd = { version = "0.9", default-features = false, features = ["tokio"] }
pipewire = "0.9" pipewire = "0.9"
x11rb = { version = "0.13", default-features = false, features = ["allow-unsafe-code"] } x11rb = { version = "0.13", default-features = false, features = ["allow-unsafe-code"] }
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
iroh-tickets = "1.0.0" iroh-tickets = "1.0.0-rc.0"
dialoguer = { version = "0.12", default-features = false } dialoguer = { version = "0.12", default-features = false }
arboard = { version = "3", default-features = false, features = ["wayland-data-control"] } arboard = { version = "3", default-features = false, features = ["wayland-data-control"] }
ureq = { version = "3", default-features = false, features = ["rustls"] } ureq = { version = "3", default-features = false, features = ["rustls"] }
toml = "1" toml = "1"
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } 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 } 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 tray-icon = { version = "0.24.0", optional = true }
# pure-Rust zbus backend (no system libdbus, no image crate). notify-rust = { version = "4.17.0", optional = true }
notify-rust = { version = "4", optional = true } gtk = { version = "0.18.2", 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] [profile.release]
lto = "thin" lto = "thin"
@@ -90,4 +46,4 @@ strip = "symbols"
[features] [features]
# Opt-in graphical front-end (pixelpass --gui). Default-off so the headless # Opt-in graphical front-end (pixelpass --gui). Default-off so the headless
# build never pulls the GUI toolkit tree. # 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"] gui = ["dep:eframe", "dep:tray-icon", "dep:notify-rust", "dep:gtk"]
-202
View File
@@ -1,202 +0,0 @@
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
View File
@@ -1,21 +0,0 @@
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.
+2 -99
View File
@@ -23,8 +23,6 @@ Working:
- Audio capture of the default sink's monitor, with optional per-app - Audio capture of the default sink's monitor, with optional per-app
routing (`--app <name>`) routing (`--app <name>`)
- `--repair` cleanup of orphaned PipeWire state left by a crashed host - `--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 - iroh QUIC bi-stream tunnel, direct-UDP and relay paths both verified
- Interactive Host/View menu with clipboard auto-copy and mpv/VLC picker - Interactive Host/View menu with clipboard auto-copy and mpv/VLC picker
- Headless mode for scripts (`pixelpass <ticket>`) - Headless mode for scripts (`pixelpass <ticket>`)
@@ -85,15 +83,8 @@ pixelpass --gui
``` ```
Host: pick quality / max-viewers / options, click **Start hosting**, and the Host: pick quality / max-viewers / options, click **Start hosting**, and the
share code appears with a copy button. Connected viewers are listed with a share code appears with a copy button alongside a live viewer count. View:
**Kick** button each, and a desktop notification fires as they join or leave. paste a code, pick mpv or VLC, click **Connect** and the player launches.
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 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 child process and reads its event stream, so the GUI is purely additive and
@@ -137,35 +128,6 @@ sudo pacman -S vlc vlc-plugin-dvb vlc-plugin-ffmpeg
If the viewer is running on battery, set the CPU governor to performance 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. 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 ## Build
```sh ```sh
@@ -254,65 +216,6 @@ measured_at = "2026-05-21T20:41:16Z"
- Skip is sticky — once you skip the test, pixelpass won't ask again - Skip is sticky — once you skip the test, pixelpass won't ask again
unless you reconfigure. 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 ## Audio
By default pixelpass captures the default sink's monitor — the viewer By default pixelpass captures the default sink's monitor — the viewer
-93
View File
@@ -1,93 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 7.9 KiB

-12
View File
@@ -1,12 +0,0 @@
[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
-16
View File
@@ -1,16 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 875 B

-3
View File
@@ -1,3 +0,0 @@
.tools/
AppDir/
*.AppImage
-20
View File
@@ -1,20 +0,0 @@
#!/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" "$@"
-72
View File
@@ -1,72 +0,0 @@
# 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.
-59
View File
@@ -1,59 +0,0 @@
#!/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"
-6
View File
@@ -1,6 +0,0 @@
# makepkg build artifacts
src/
pkg/
/pixelpass/
*.pkg.tar.*
*.log
-65
View File
@@ -1,65 +0,0 @@
# 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"
}
-63
View File
@@ -1,63 +0,0 @@
# 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.)
+1 -53
View File
@@ -27,17 +27,6 @@ pub struct Cli {
#[arg(long, value_name = "NAME")] #[arg(long, value_name = "NAME")]
pub app: Option<String>, pub app: Option<String>,
/// 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 strict_audio: bool,
/// Override display server autodetection. /// Override display server autodetection.
#[arg(long, value_enum)] #[arg(long, value_enum)]
pub display_server: Option<DisplayServerArg>, pub display_server: Option<DisplayServerArg>,
@@ -79,13 +68,6 @@ pub struct Cli {
pub port: u16, pub port: u16,
// ── global ──────────────────────────────────────────────────────── // ── 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) /// Launch the graphical front-end (a window with Host/View controls)
/// instead of the terminal menu. Requires a build with `--features gui`. /// instead of the terminal menu. Requires a build with `--features gui`.
#[arg(long)] #[arg(long)]
@@ -105,31 +87,11 @@ pub struct Cli {
#[arg(long)] #[arg(long)]
pub repair: bool, 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. /// Re-run the bandwidth pre-flight test, save the result, then exit.
/// Use this if your connection has changed (new ISP, moved house, etc.) /// Use this if your connection has changed (new ISP, moved house, etc.)
/// or if the previously saved test result is stale. /// or if the previously saved test result is stale.
#[arg(long)] #[arg(long)]
pub reconfigure: bool, 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)] #[derive(ValueEnum, Clone, Copy, Debug)]
@@ -166,10 +128,6 @@ pub enum Quality {
pub struct HostOpts { pub struct HostOpts {
pub window: bool, pub window: bool,
pub app: Option<String>, pub app: Option<String>,
/// 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 display_server: Option<DisplayServerArg>,
/// Chosen preset (Auto = derive at startup). Defaults to Auto. /// Chosen preset (Auto = derive at startup). Defaults to Auto.
pub quality: Quality, pub quality: Quality,
@@ -182,16 +140,12 @@ pub struct HostOpts {
pub no_hwencode: bool, pub no_hwencode: bool,
pub max_viewers: Option<u32>, pub max_viewers: Option<u32>,
pub interactive: bool, pub interactive: bool,
/// Relay override (resolved from `--relay` / `PIXELPASS_RELAY`); None = defaults.
pub relay: Option<String>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ViewerOpts { pub struct ViewerOpts {
pub port: u16, pub port: u16,
pub interactive: bool, pub interactive: bool,
/// Relay override (resolved from `--relay` / `PIXELPASS_RELAY`); None = defaults.
pub relay: Option<String>,
} }
impl Cli { impl Cli {
@@ -199,7 +153,6 @@ impl Cli {
HostOpts { HostOpts {
window: self.window, window: self.window,
app: self.app, app: self.app,
strict_audio: self.strict_audio,
display_server: self.display_server, display_server: self.display_server,
// No `--quality` and nothing picked interactively → the documented // No `--quality` and nothing picked interactively → the documented
// default, Auto. // default, Auto.
@@ -210,15 +163,10 @@ impl Cli {
no_hwencode: self.no_hwencode, no_hwencode: self.no_hwencode,
max_viewers: self.max_viewers, max_viewers: self.max_viewers,
interactive, interactive,
relay: crate::common::endpoint::relay_override(self.relay.as_deref()),
} }
} }
pub fn into_viewer_opts(self, interactive: bool) -> ViewerOpts { pub fn into_viewer_opts(self, interactive: bool) -> ViewerOpts {
ViewerOpts { ViewerOpts { port: self.port, interactive }
port: self.port,
interactive,
relay: crate::common::endpoint::relay_override(self.relay.as_deref()),
}
} }
} }
+1 -10
View File
@@ -1,14 +1,5 @@
/// ALPN identifying the pixelpass video wire protocol on the iroh tunnel. /// ALPN identifying the pixelpass wire protocol on the iroh tunnel.
/// ///
/// Bump the version suffix whenever the wire format changes. Today the wire is /// Bump the version suffix whenever the wire format changes. Today the wire is
/// "raw MPEG-TS bytes copied bidirectionally," so bumps will be rare. /// "raw MPEG-TS bytes copied bidirectionally," so bumps will be rare.
pub const ALPN: &[u8] = b"pixelpass/0"; 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";
+1 -33
View File
@@ -65,41 +65,9 @@ pub fn measure_upstream_blocking() -> Result<Measurement> {
/// via SAFETY_FACTOR) into a recommended viewer count. Floors to at least 1. /// 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 { pub fn recommended_max_viewers(safe_mbps: f64, bitrate_kbps: u32) -> u32 {
let per_viewer_mbps = (bitrate_kbps as f64) / 1000.0; let per_viewer_mbps = (bitrate_kbps as f64) / 1000.0;
// Guard non-finite / non-positive inputs (only reachable from a corrupted if per_viewer_mbps <= 0.0 {
// 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; return 1;
} }
let n = (safe_mbps / per_viewer_mbps).floor(); let n = (safe_mbps / per_viewer_mbps).floor();
if n < 1.0 { 1 } else { n as u32 } 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);
}
}
+13 -59
View File
@@ -1,7 +1,8 @@
//! Persistent user-level config at `~/.config/pixelpass/config.toml`. //! Persistent user-level config at `~/.config/pixelpass/config.toml`.
//! //!
//! It tracks the bandwidth pre-flight result and the GUI's preferences. //! Right now this only tracks the bandwidth pre-flight result. Future
//! Further settings can hang off the same file under their own `[section]`. //! preferences (default player, default bitrate, etc.) can hang off the
//! same file under their own `[section]`.
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
@@ -15,59 +16,6 @@ use std::path::PathBuf;
pub struct Config { pub struct Config {
#[serde(default)] #[serde(default)]
pub bandwidth: BandwidthEntry, 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. /// Result of the first-run upstream measurement.
@@ -89,15 +37,19 @@ pub struct BandwidthEntry {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum BandwidthStatus { pub enum BandwidthStatus {
#[default]
Unmeasured, Unmeasured,
Measured, Measured,
Skipped, Skipped,
Failed, Failed,
} }
impl Default for BandwidthStatus {
fn default() -> Self {
Self::Unmeasured
}
}
fn default_status() -> BandwidthStatus { fn default_status() -> BandwidthStatus {
BandwidthStatus::Unmeasured BandwidthStatus::Unmeasured
} }
@@ -129,9 +81,11 @@ pub fn save(cfg: &Config) -> Result<()> {
let parent = path let parent = path
.parent() .parent()
.context("config path has no parent directory")?; .context("config path has no parent directory")?;
fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
let serialized = toml::to_string_pretty(cfg).context("failed to serialize config to TOML")?; 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 tmp = parent.join(format!(".config.toml.tmp.{}", std::process::id()));
{ {
-260
View File
@@ -1,260 +0,0 @@
//! 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();
}
}
+24 -55
View File
@@ -56,7 +56,12 @@ fn require(bin: &str) -> Result<PathBuf> {
} }
fn require_gst_element(name: &str) -> Result<()> { fn require_gst_element(name: &str) -> Result<()> {
if !gst_element_exists(name) { let ok = Command::new("gst-inspect-1.0")
.args(["--exists", name])
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
bail!( bail!(
"GStreamer element `{name}` not available.\n{}", "GStreamer element `{name}` not available.\n{}",
install_hint_for_gst_element(name) install_hint_for_gst_element(name)
@@ -65,17 +70,7 @@ fn require_gst_element(name: &str) -> Result<()> {
Ok(()) Ok(())
} }
/// Whether a GStreamer element is registered, via `gst-inspect-1.0 --exists`. fn which(bin: &str) -> Option<PathBuf> {
/// 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")?; let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) { for dir in std::env::split_paths(&path) {
let candidate = dir.join(bin); let candidate = dir.join(bin);
@@ -86,28 +81,24 @@ pub(crate) fn which(bin: &str) -> Option<PathBuf> {
None None
} }
pub(crate) fn install_hint_for_bin(bin: &str) -> String { fn install_hint_for_bin(bin: &str) -> String {
let distro = detect_distro(); let distro = detect_distro();
let pkg = match bin { let pkg = match bin {
"gst-launch-1.0" | "gst-inspect-1.0" => match distro.as_deref() { "gst-launch-1.0" | "gst-inspect-1.0" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => { Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gstreamer gst-plugins-base",
"gstreamer gst-plugins-base"
}
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-tools", Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-tools",
Some("fedora" | "nobara") => "gstreamer1 gstreamer1-plugins-base-tools", Some("fedora" | "nobara") => "gstreamer1 gstreamer1-plugins-base-tools",
_ => "gstreamer + tools", _ => "gstreamer + tools",
}, },
"pactl" => match distro.as_deref() { "pactl" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => "libpulse", Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "libpulse",
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "pulseaudio-utils", Some("debian" | "ubuntu" | "pop" | "linuxmint") => "pulseaudio-utils",
Some("fedora" | "nobara") => "pulseaudio-utils", Some("fedora" | "nobara") => "pulseaudio-utils",
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "pulseaudio-utils", Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "pulseaudio-utils",
_ => "pulseaudio-utils (provides `pactl`)", _ => "pulseaudio-utils (provides `pactl`)",
}, },
"xwininfo" => match distro.as_deref() { "xwininfo" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => { Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "xorg-xwininfo",
"xorg-xwininfo"
}
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "x11-utils", Some("debian" | "ubuntu" | "pop" | "linuxmint") => "x11-utils",
Some("fedora" | "nobara") => "xorg-x11-utils", Some("fedora" | "nobara") => "xorg-x11-utils",
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "xwininfo", Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "xwininfo",
@@ -118,78 +109,60 @@ pub(crate) fn install_hint_for_bin(bin: &str) -> String {
install_command(&distro, pkg) install_command(&distro, pkg)
} }
pub(crate) fn install_hint_for_gst_element(name: &str) -> String { fn install_hint_for_gst_element(name: &str) -> String {
let distro = detect_distro(); let distro = detect_distro();
let pkg = match name { let pkg = match name {
"pipewiresrc" => match distro.as_deref() { "pipewiresrc" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => { Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugin-pipewire",
"gst-plugin-pipewire"
}
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-pipewire", Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-pipewire",
Some("fedora" | "nobara") => "pipewire-gstreamer", Some("fedora" | "nobara") => "pipewire-gstreamer",
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "pipewire-gstreamer", Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "pipewire-gstreamer",
_ => "the GStreamer PipeWire plugin", _ => "the GStreamer PipeWire plugin",
}, },
"vah264enc" => match distro.as_deref() { "vah264enc" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => { Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugin-va",
"gst-plugin-va"
}
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-bad", Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-bad",
Some("fedora" | "nobara") => "gstreamer1-plugins-bad-free", Some("fedora" | "nobara") => "gstreamer1-plugins-bad-free",
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-bad", 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() { "x264enc" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => { Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-ugly",
"gst-plugins-ugly"
}
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-ugly", Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-ugly",
Some("fedora" | "nobara") => "gstreamer1-plugins-ugly", Some("fedora" | "nobara") => "gstreamer1-plugins-ugly",
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-ugly", Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-ugly",
_ => "the GStreamer x264 plugin (plugins-ugly)", _ => "the GStreamer x264 plugin (plugins-ugly)",
}, },
"ximagesrc" => match distro.as_deref() { "ximagesrc" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => { Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-good",
"gst-plugins-good"
}
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-good", Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-good",
Some("fedora" | "nobara") => "gstreamer1-plugins-good", Some("fedora" | "nobara") => "gstreamer1-plugins-good",
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-good", Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-good",
_ => "the GStreamer X11 plugin (plugins-good)", _ => "the GStreamer X11 plugin (plugins-good)",
}, },
"videoscale" => match distro.as_deref() { "videoscale" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => { Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-base",
"gst-plugins-base"
}
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-base", Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-base",
Some("fedora" | "nobara") => "gstreamer1-plugins-base", Some("fedora" | "nobara") => "gstreamer1-plugins-base",
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-base", Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-base",
_ => "the GStreamer plugins-base set", _ => "the GStreamer plugins-base set",
}, },
"h264parse" | "mpegtsmux" | "aacparse" => match distro.as_deref() { "h264parse" | "mpegtsmux" | "aacparse" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => { Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-bad",
"gst-plugins-bad"
}
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-bad", Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-bad",
Some("fedora" | "nobara") => "gstreamer1-plugins-bad-free", Some("fedora" | "nobara") => "gstreamer1-plugins-bad-free",
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-bad", Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-bad",
_ => "the GStreamer plugins-bad set", _ => "the GStreamer plugins-bad set",
}, },
"pulsesrc" => match distro.as_deref() { "pulsesrc" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => { Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-good",
"gst-plugins-good"
}
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-pulseaudio", Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-pulseaudio",
Some("fedora" | "nobara") => "gstreamer1-plugins-good", Some("fedora" | "nobara") => "gstreamer1-plugins-good",
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-good", Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-good",
_ => "the GStreamer PulseAudio plugin", _ => "the GStreamer PulseAudio plugin",
}, },
"avenc_aac" => match distro.as_deref() { "avenc_aac" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => { Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-libav",
"gst-libav"
}
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-libav", Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-libav",
Some("fedora" | "nobara") => "gstreamer1-libav", Some("fedora" | "nobara") => "gstreamer1-libav",
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-libav", Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-libav",
@@ -202,20 +175,16 @@ pub(crate) fn install_hint_for_gst_element(name: &str) -> String {
fn install_command(distro: &Option<String>, pkg: &str) -> String { fn install_command(distro: &Option<String>, pkg: &str) -> String {
let cmd = match distro.as_deref() { let cmd = match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => { Some("arch" | "cachyos" | "manjaro" | "endeavouros") => format!("sudo pacman -S {pkg}"),
format!("sudo pacman -S {pkg}")
}
Some("debian" | "ubuntu" | "pop" | "linuxmint") => format!("sudo apt install {pkg}"), Some("debian" | "ubuntu" | "pop" | "linuxmint") => format!("sudo apt install {pkg}"),
Some("fedora" | "nobara") => format!("sudo dnf install {pkg}"), Some("fedora" | "nobara") => format!("sudo dnf install {pkg}"),
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => { Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => format!("sudo zypper install {pkg}"),
format!("sudo zypper install {pkg}")
}
_ => format!("install the `{pkg}` package via your distro's package manager"), _ => format!("install the `{pkg}` package via your distro's package manager"),
}; };
format!("Install hint: {cmd}") format!("Install hint: {cmd}")
} }
pub(crate) fn detect_distro() -> Option<String> { fn detect_distro() -> Option<String> {
let contents = std::fs::read_to_string("/etc/os-release").ok()?; let contents = std::fs::read_to_string("/etc/os-release").ok()?;
for line in contents.lines() { for line in contents.lines() {
if let Some(rest) = line.strip_prefix("ID=") { if let Some(rest) = line.strip_prefix("ID=") {
-84
View File
@@ -1,84 +0,0 @@
//! 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")
}
-331
View File
@@ -1,331 +0,0 @@
//! 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)");
}
}
-141
View File
@@ -1,141 +0,0 @@
//! 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());
}
}
-10
View File
@@ -1,18 +1,8 @@
pub mod alpn; pub mod alpn;
pub mod bandwidth; pub mod bandwidth;
pub mod config; 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 deps;
pub mod display; pub mod display;
pub mod endpoint;
#[cfg(feature = "gui")]
pub mod friends;
#[cfg(feature = "gui")]
pub mod identity;
pub mod output; pub mod output;
pub mod process; pub mod process;
pub mod signal; pub mod signal;
+3 -43
View File
@@ -22,11 +22,7 @@ pub fn set_json(enabled: bool) {
JSON_ENABLED.store(enabled, Ordering::Relaxed); JSON_ENABLED.store(enabled, Ordering::Relaxed);
} }
/// Whether the JSON event stream is on — i.e. we're being driven by a fn json_enabled() -> bool {
/// 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) JSON_ENABLED.load(Ordering::Relaxed)
} }
@@ -46,11 +42,9 @@ pub enum Event<'a> {
max_viewers: u32, max_viewers: u32,
max_viewers_source: &'a str, max_viewers_source: &'a str,
}, },
/// A viewer joined. `id` is the viewer's endpoint id; `active` is the new /// A new viewer joined.
/// total after the join.
ViewerJoined { id: &'a str, active: u32, max: u32 }, ViewerJoined { id: &'a str, active: u32, max: u32 },
/// A viewer left — disconnected on their own or kicked by the host. `id` /// A viewer disconnected.
/// is the viewer's endpoint id; `active` is the new total after.
ViewerLeft { id: &'a str, active: u32, max: u32 }, ViewerLeft { id: &'a str, active: u32, max: u32 },
/// Capture pipeline lifecycle (spawned on first viewer, torn down on last). /// Capture pipeline lifecycle (spawned on first viewer, torn down on last).
Capture { state: CaptureState }, Capture { state: CaptureState },
@@ -58,11 +52,6 @@ pub enum Event<'a> {
ViewerRefused { reason: &'a str }, ViewerRefused { reason: &'a str },
/// Viewer-side: the local player URL is ready to open. /// Viewer-side: the local player URL is ready to open.
Connected { url: &'a str }, 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)] #[derive(Serialize)]
@@ -72,13 +61,6 @@ pub enum CaptureState {
Stopped, 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 /// 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 /// output was enabled with [`set_json`], so call sites can sprinkle these
/// unconditionally without branching. /// unconditionally without branching.
@@ -97,25 +79,3 @@ pub fn emit(event: Event) {
Err(e) => tracing::warn!("failed to serialize event: {e}"), 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"}"#);
}
}
+5 -18
View File
@@ -6,19 +6,10 @@ use std::process::{Command, Stdio};
/// ///
/// The child gets its own session via `setsid(2)` and null stdio, so it /// 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 /// survives the parent exiting and doesn't take a SIGKILL cascade when
/// pixelpass dies. /// pixelpass dies. The `Child` is dropped immediately — `std::process::Child::drop`
/// /// does not kill the process on Unix.
/// 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<()> { pub fn spawn_detached(prog: &str, args: &[&str]) -> io::Result<()> {
let child = unsafe { unsafe {
Command::new(prog) Command::new(prog)
.args(args) .args(args)
.stdin(Stdio::null()) .stdin(Stdio::null())
@@ -28,11 +19,7 @@ pub fn spawn_detached(prog: &str, args: &[&str]) -> io::Result<()> {
nix::unistd::setsid().ok(); nix::unistd::setsid().ok();
Ok(()) Ok(())
}) })
.spawn()? .spawn()?;
}; }
std::thread::spawn(move || {
let mut child = child;
let _ = child.wait();
});
Ok(()) Ok(())
} }
+3 -20
View File
@@ -1,15 +1,5 @@
use anyhow::{Context, Result};
use tokio::signal::unix::{Signal, SignalKind};
use tokio_util::sync::CancellationToken; 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. /// Install a ctrl-c handler that triggers the returned token.
/// ///
/// The first ctrl-c cancels gracefully; a second ctrl-c terminates the process. /// The first ctrl-c cancels gracefully; a second ctrl-c terminates the process.
@@ -17,17 +7,10 @@ pub fn install_ctrl_c() -> CancellationToken {
let token = CancellationToken::new(); let token = CancellationToken::new();
let trigger = token.clone(); let trigger = token.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = tokio::signal::ctrl_c().await { if tokio::signal::ctrl_c().await.is_ok() {
// Installing the handler failed — ctrl-c won't trigger a graceful tracing::info!("ctrl-c received, shutting down");
// shutdown. Say so instead of failing silently; the user can still trigger.cancel();
// 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() { if tokio::signal::ctrl_c().await.is_ok() {
tracing::warn!("second ctrl-c — exiting now"); tracing::warn!("second ctrl-c — exiting now");
std::process::exit(130); std::process::exit(130);
-648
View File
@@ -1,648 +0,0 @@
//! `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(&sections);
let summary = summarize(sections.iter().flat_map(|s| s.checks.iter()));
print_summary(summary, &sections);
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 &section.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(&sections).contains("capture"));
}
}
+46 -79
View File
@@ -6,18 +6,17 @@
//! egui app drains each frame. stderr is captured into a small ring so a //! 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. //! failed launch (e.g. a missing gst plugin) can be surfaced in the window.
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader};
use std::process::{Child, ChildStdin, Command, Stdio}; use std::process::{Child, Command, Stdio};
use std::sync::mpsc::Receiver; use std::sync::mpsc::Receiver;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use eframe::egui;
use nix::sys::signal::{Signal, kill}; use nix::sys::signal::{Signal, kill};
use nix::unistd::Pid; use nix::unistd::Pid;
use serde::Deserialize; use serde::Deserialize;
use super::Waker;
/// One parsed event from the child's stdout. Owned mirror of /// One parsed event from the child's stdout. Owned mirror of
/// [`crate::common::output::Event`] (which borrows for emit); kept separate so /// [`crate::common::output::Event`] (which borrows for emit); kept separate so
/// the wire format and the parser can evolve independently. /// the wire format and the parser can evolve independently.
@@ -67,36 +66,26 @@ pub enum CaptureState {
const STDERR_TAIL_MAX: usize = 60; const STDERR_TAIL_MAX: usize = 60;
pub struct ChildProc { pub struct ChildProc {
/// `Some` while the child is owned here; `Drop` takes it to hand off to a child: Child,
/// detached reaper thread (see the `Drop` impl).
child: Option<Child>,
pub rx: Receiver<ChildEvent>, pub rx: Receiver<ChildEvent>,
stderr_tail: Arc<Mutex<Vec<String>>>, stderr_tail: Arc<Mutex<Vec<String>>>,
/// Write end of the child's stdin, for the line-based command channel stdin: std::process::ChildStdin,
/// (see [`ChildProc::send_command`]). `None` once it's been closed.
stdin: Option<ChildStdin>,
} }
impl ChildProc { impl ChildProc {
/// Spawn `pixelpass <args>` as a child, wiring up the event reader. The /// Spawn `pixelpass <args>` as a child, wiring up the event reader. `ctx`
/// `waker` is pinged whenever an event arrives so the UI thread wakes to /// is repainted whenever an event arrives so the UI updates live.
/// drain it — this wakes the winit event loop directly (via an pub fn spawn(args: &[String], ctx: egui::Context) -> std::io::Result<Self> {
/// `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 exe = std::env::current_exe()?;
let mut child = Command::new(exe) let mut child = Command::new(exe)
.args(args) .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()) .stdin(Stdio::piped())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.spawn()?; .spawn()?;
let stdin = child.stdin.take();
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
let stdin = child.stdin.take().expect("stdin piped");
let stdout = child.stdout.take().expect("stdout piped"); let stdout = child.stdout.take().expect("stdout piped");
std::thread::spawn(move || { std::thread::spawn(move || {
let reader = BufReader::new(stdout); let reader = BufReader::new(stdout);
@@ -109,14 +98,9 @@ impl ChildProc {
if tx.send(ev).is_err() { if tx.send(ev).is_err() {
break; // app gone break; // app gone
} }
waker.wake(); ctx.request_repaint();
} }
} }
// 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_tail = Arc::new(Mutex::new(Vec::<String>::new()));
@@ -135,64 +119,55 @@ impl ChildProc {
}); });
Ok(Self { Ok(Self {
child: Some(child), child,
rx, rx,
stderr_tail, stderr_tail,
stdin, 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. /// Whether the child is still running.
pub fn is_alive(&mut self) -> bool { pub fn is_alive(&mut self) -> bool {
matches!(self.child.as_mut().map(Child::try_wait), Some(Ok(None))) matches!(self.child.try_wait(), Ok(None))
} }
/// The last captured stderr lines, joined — for error display. /// The last captured stderr lines, joined — for error display.
pub fn stderr_tail(&self) -> String { pub fn stderr_tail(&self) -> String {
self.stderr_tail.lock().unwrap().join("\n") self.stderr_tail.lock().unwrap().join("\n")
} }
/// Send a newline-terminated command to the child.
pub fn send_command(&mut self, cmd: &str) {
use std::io::Write;
if let Err(e) = writeln!(self.stdin, "{cmd}") {
tracing::warn!("failed to send command to child: {e}");
}
}
/// Gracefully stop the child: SIGINT (so the host runs its ctrl-c teardown
/// — tears down capture, closes the endpoint), with a ~2 s grace period
/// before a hard kill. Idempotent.
pub fn stop(&mut self) {
if matches!(self.child.try_wait(), Ok(Some(_))) {
return; // already exited
}
let _ = kill(Pid::from_raw(self.child.id() as i32), Signal::SIGINT);
for _ in 0..40 {
if matches!(self.child.try_wait(), Ok(Some(_))) {
return;
}
std::thread::sleep(Duration::from_millis(50));
}
let _ = self.child.kill();
let _ = self.child.wait();
}
} }
impl Drop for ChildProc { impl Drop for ChildProc {
fn drop(&mut self) { fn drop(&mut self) {
// Leaving a host/viewer screen, or closing the window, must not orphan // Closing the window (dropping the app, hence the session) must not
// a live child — but it must also not *block*. eframe runs this drop // orphan a live host child streaming to viewers.
// synchronously while it destroys the window, so a grace-period wait self.stop();
// 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();
});
} }
} }
@@ -247,7 +222,7 @@ mod tests {
} }
#[test] #[test]
fn viewer_join_leave_round_trip() { fn viewer_events_round_trips() {
assert!(matches!( assert!(matches!(
parse(Event::ViewerJoined { id: "nodeXYZ", active: 2, max: 4 }), parse(Event::ViewerJoined { id: "nodeXYZ", active: 2, max: 4 }),
ChildEvent::ViewerJoined { id, active: 2, max: 4 } if id == "nodeXYZ" ChildEvent::ViewerJoined { id, active: 2, max: 4 } if id == "nodeXYZ"
@@ -261,20 +236,12 @@ mod tests {
#[test] #[test]
fn capture_state_round_trips() { fn capture_state_round_trips() {
assert!(matches!( assert!(matches!(
parse(Event::Capture { parse(Event::Capture { state: EmitState::Started }),
state: EmitState::Started ChildEvent::Capture { state: CaptureState::Started }
}),
ChildEvent::Capture {
state: CaptureState::Started
}
)); ));
assert!(matches!( assert!(matches!(
parse(Event::Capture { parse(Event::Capture { state: EmitState::Stopped }),
state: EmitState::Stopped ChildEvent::Capture { state: CaptureState::Stopped }
}),
ChildEvent::Capture {
state: CaptureState::Stopped
}
)); ));
} }
-88
View File
@@ -1,88 +0,0 @@
//! 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");
}
}
+140 -2014
View File
File diff suppressed because it is too large Load Diff
-299
View File
@@ -1,299 +0,0 @@
//! 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");
}
-456
View File
@@ -1,456 +0,0 @@
//! 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
View File
@@ -1,257 +0,0 @@
//! 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,
})
}
-311
View File
@@ -1,311 +0,0 @@
//! 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))
}
}
-374
View File
@@ -1,374 +0,0 @@
//! 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));
}
+55 -281
View File
@@ -17,15 +17,6 @@
//! filtered audio twice (once via the routed stream, once via the //! filtered audio twice (once via the routed stream, once via the
//! default-sink monitor loopback). //! 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 //! pactl is the right tool for the one-shot null-sink/loopback graph
//! mutations. libpipewire is dragged in only when per-stream filtering //! mutations. libpipewire is dragged in only when per-stream filtering
//! is requested, because that needs registry-event subscription. //! is requested, because that needs registry-event subscription.
@@ -49,11 +40,6 @@ pub struct Routing {
/// first successful route. `Routing::shutdown` unloads whatever /// first successful route. `Routing::shutdown` unloads whatever
/// remains. /// remains.
loopback_module: Arc<Mutex<Option<u32>>>, 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, sink_name: String,
stream_router: Option<StreamRouter>, stream_router: Option<StreamRouter>,
event_task: Option<tokio::task::JoinHandle<()>>, event_task: Option<tokio::task::JoinHandle<()>>,
@@ -66,46 +52,31 @@ impl Routing {
let pid = std::process::id(); let pid = std::process::id();
let sink_name = format!("pixelpass_capture_{pid}"); let sink_name = format!("pixelpass_capture_{pid}");
let sink_module = load_module(&["module-null-sink", &format!("sink_name={sink_name}")]) let sink_module =
.context("failed to load module-null-sink")?; 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 // 20ms loopback latency keeps the mirrored audio tight; pactl's
// default of 200ms is enough to be perceptible. // default of 200ms is enough to be perceptible.
let strict_app = opts.app.is_some() && opts.strict_audio; let loopback_module = load_module(&[
let loopback_module = if strict_app { "module-loopback",
None "source=@DEFAULT_SINK@.monitor",
} else { &format!("sink={sink_name}"),
Some( "latency_msec=20",
load_module(&[ ])
"module-loopback", .context("failed to load module-loopback (null-sink will be cleaned up on Drop)")?;
"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!( tracing::info!(
sink_module, sink_module,
?loopback_module, loopback_module,
strict_app,
%sink_name, %sink_name,
"audio routing: null-sink ready (loopback skipped in strict app mode)" "audio routing: null-sink + loopback ready"
); );
let loopback_arc = Arc::new(Mutex::new(loopback_module)); let loopback_arc = Arc::new(Mutex::new(Some(loopback_module)));
let local_monitor_arc = Arc::new(Mutex::new(None));
let mut routing = Self { let mut routing = Self {
sink_module: Some(sink_module), sink_module: Some(sink_module),
loopback_module: Arc::clone(&loopback_arc), loopback_module: Arc::clone(&loopback_arc),
local_monitor_module: Arc::clone(&local_monitor_arc),
sink_name: sink_name.clone(), sink_name: sink_name.clone(),
stream_router: None, stream_router: None,
event_task: None, event_task: None,
@@ -114,11 +85,8 @@ impl Routing {
if let Some(app) = &opts.app { if let Some(app) = &opts.app {
let (router, mut event_rx) = StreamRouter::spawn(app.clone(), sink_name.clone())?; let (router, mut event_rx) = StreamRouter::spawn(app.clone(), sink_name.clone())?;
let loopback_for_task = Arc::clone(&loopback_arc); 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 sink_name_for_task = sink_name.clone();
let strict = opts.strict_audio;
let event_task = tokio::spawn(async move { let event_task = tokio::spawn(async move {
use crate::common::output::{self, AppAudioState};
while let Some(ev) = event_rx.recv().await { while let Some(ev) = event_rx.recv().await {
match ev { match ev {
Event::FirstRoutedStream => { Event::FirstRoutedStream => {
@@ -129,66 +97,11 @@ impl Routing {
); );
unload_module(id); 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 => { Event::LastRoutedStreamGone => {
// Routed app exited/paused mid-session. Notify the // Routed app exited mid-session. Restore the
// front-end either way; the recovery differs by mode. // default-sink loopback so the viewer hears
output::emit(output::Event::AppAudio { // system audio again instead of silence.
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() { if loopback_for_task.lock().unwrap().is_some() {
continue; continue;
} }
@@ -218,16 +131,6 @@ impl Routing {
routing.event_task = Some(event_task); 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) Ok(routing)
} }
@@ -238,10 +141,7 @@ impl Routing {
/// Stop the stream router (if any), then unload loopback (if still /// Stop the stream router (if any), then unload loopback (if still
/// loaded), then unload the null-sink. Order matters: PipeWire can /// loaded), then unload the null-sink. Order matters: PipeWire can
/// leave zombie links if you destroy a sink with active inputs. /// leave zombie links if you destroy a sink with active inputs.
/// pub fn shutdown(mut self) {
/// 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() { if let Some(router) = self.stream_router.take() {
router.shutdown(); router.shutdown();
} }
@@ -251,39 +151,27 @@ impl Routing {
if let Some(id) = self.loopback_module.lock().unwrap().take() { if let Some(id) = self.loopback_module.lock().unwrap().take() {
unload_module(id); unload_module(id);
} }
// Unload the local monitor before the null-sink it reads from, so the if let Some(id) = self.sink_module.take() {
// sink has no active loopback reader when it's destroyed. unload_module(id);
if let Some(id) = self.local_monitor_module.lock().unwrap().take() { }
}
}
impl Drop for Routing {
fn drop(&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_module(id);
} }
if let Some(id) = self.sink_module.take() { if let Some(id) = self.sink_module.take() {
unload_module(id); 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)
} }
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────
@@ -319,9 +207,7 @@ fn parse_sink_inputs(stdout: &[u8]) -> Result<Vec<App>> {
serde_json::from_slice(stdout).context("pactl returned unparseable JSON")?; serde_json::from_slice(stdout).context("pactl returned unparseable JSON")?;
let mut counts: BTreeMap<String, u32> = BTreeMap::new(); let mut counts: BTreeMap<String, u32> = BTreeMap::new();
for entry in entries { for entry in entries {
let Some(name) = entry.properties.application_name else { let Some(name) = entry.properties.application_name else { continue };
continue;
};
let trimmed = name.trim(); let trimmed = name.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
continue; continue;
@@ -365,9 +251,6 @@ fn load_module(args: &[&str]) -> Result<u32> {
.context("pactl returned non-UTF-8")? .context("pactl returned non-UTF-8")?
.trim() .trim()
.to_string(); .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 id_str
.parse::<u32>() .parse::<u32>()
.with_context(|| format!("pactl returned unexpected module ID: {id_str:?}")) .with_context(|| format!("pactl returned unexpected module ID: {id_str:?}"))
@@ -478,14 +361,16 @@ fn run_router(
) -> Result<()> { ) -> Result<()> {
use pipewire::{self as pw, types::ObjectType}; use pipewire::{self as pw, types::ObjectType};
let main_loop = let main_loop = pw::main_loop::MainLoopRc::new(None)
pw::main_loop::MainLoopRc::new(None).context("pw main loop construction failed")?; .context("pw main loop construction failed")?;
let context = let context = pw::context::ContextRc::new(&main_loop, None)
pw::context::ContextRc::new(&main_loop, None).context("pw context construction failed")?; .context("pw context construction failed")?;
let core = context let core = context
.connect_rc(None) .connect_rc(None)
.context("pw core connect failed (is the daemon running?)")?; .context("pw core connect failed (is the daemon running?)")?;
let registry = core.get_registry_rc().context("pw get_registry failed")?; let registry = core
.get_registry_rc()
.context("pw get_registry failed")?;
let state = Rc::new(RefCell::new(RouterState { let state = Rc::new(RefCell::new(RouterState {
sink_serial: None, sink_serial: None,
@@ -526,39 +411,28 @@ fn run_router(
let _reg_listener = registry let _reg_listener = registry
.add_listener_local() .add_listener_local()
.global(move |obj| { .global(move |obj| {
let Some(reg) = registry_weak.upgrade() else { let Some(reg) = registry_weak.upgrade() else { return };
return;
};
match obj.type_ { match obj.type_ {
ObjectType::Node => { ObjectType::Node => {
let Some(props) = obj.props.as_ref() else { let Some(props) = obj.props.as_ref() else { return };
return;
};
if props.get("node.name") == Some(sink_name_owned.as_str()) { if props.get("node.name") == Some(sink_name_owned.as_str()) {
match props.get("object.serial").and_then(parse_object_serial) { if let Some(serial) = props
Some(serial) => { .get("object.serial")
state_for_reg.borrow_mut().sink_serial = Some(serial); .and_then(|s| s.parse::<u32>().ok())
tracing::info!(serial, "audio routing: pixelpass sink registered"); {
try_flush(&state_for_reg, &event_tx_for_reg); state_for_reg.borrow_mut().sink_serial = Some(serial);
} tracing::info!(
// Never silently: without a serial `try_flush` can serial,
// never route anything, so the whole app-filter mode "audio routing: pixelpass sink registered"
// is dead and the only symptom is missing audio. );
None => tracing::warn!( try_flush(&state_for_reg, &event_tx_for_reg);
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; return;
} }
if props.get("media.class") != Some("Stream/Output/Audio") { if props.get("media.class") != Some("Stream/Output/Audio") {
return; return;
} }
let Some(app) = props.get("application.name") else { let Some(app) = props.get("application.name") else { return };
return;
};
if !app.eq_ignore_ascii_case(&filter_lower) { if !app.eq_ignore_ascii_case(&filter_lower) {
return; return;
} }
@@ -571,9 +445,7 @@ fn run_router(
try_flush(&state_for_reg, &event_tx_for_reg); try_flush(&state_for_reg, &event_tx_for_reg);
} }
ObjectType::Metadata => { ObjectType::Metadata => {
let Some(props) = obj.props.as_ref() else { let Some(props) = obj.props.as_ref() else { return };
return;
};
if props.get("metadata.name") != Some("default") { if props.get("metadata.name") != Some("default") {
return; return;
} }
@@ -602,30 +474,8 @@ fn run_router(
Ok(()) 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 { struct RouterState {
/// See [`parse_object_serial`] — 64-bit, and not interchangeable with sink_serial: Option<u32>,
/// the `u32` node ids in `routed_node_ids` / `pending`.
sink_serial: Option<u64>,
default_metadata: Option<pipewire::metadata::Metadata>, default_metadata: Option<pipewire::metadata::Metadata>,
routed_node_ids: Vec<u32>, routed_node_ids: Vec<u32>,
pending: Vec<u32>, pending: Vec<u32>,
@@ -687,79 +537,3 @@ fn try_flush(
let _ = event_tx.send(Event::FirstRoutedStream); 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:?}");
}
}
}
-269
View File
@@ -1,269 +0,0 @@
//! 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
}
-455
View File
@@ -1,455 +0,0 @@
//! 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 24 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 24 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())
}
-170
View File
@@ -1,170 +0,0 @@
//! 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(())
}
-184
View File
@@ -1,184 +0,0 @@
//! 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,
}
-896
View File
@@ -1,896 +0,0 @@
//! 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
);
}
+44 -186
View File
@@ -1,43 +1,35 @@
pub mod aec;
pub mod audio; pub mod audio;
pub mod audit;
mod capture; mod capture;
mod observer;
mod pipeline; mod pipeline;
mod quality; mod quality;
mod serve; mod serve;
pub mod taint;
mod wayland; mod wayland;
mod x11; mod x11;
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use iroh::endpoint::Connection; use iroh::endpoint::{Connection, presets};
use iroh::{Endpoint, EndpointAddr}; use iroh::{Endpoint, EndpointAddr};
use iroh_tickets::endpoint::EndpointTicket; use iroh_tickets::endpoint::EndpointTicket;
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Duration; use std::time::Duration;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::{mpsc, oneshot}; use tokio::sync::{mpsc, oneshot};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use crate::cli::HostOpts; use crate::cli::HostOpts;
use crate::common::{ use crate::common::{
bandwidth, config, config::BandwidthStatus, deps, display::DisplayServer, endpoint, output, alpn::ALPN, bandwidth, config, config::BandwidthStatus, deps, display::DisplayServer, output,
signal, tunnel, signal, tunnel,
}; };
use self::pipeline::CaptureHandle; use self::pipeline::CaptureHandle;
use self::quality::EffectiveQuality; use self::quality::EffectiveQuality;
/// Messages from per-viewer tasks (and the GUI command channel) to the /// Messages from per-viewer tasks to the capture supervisor.
/// 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 { enum SupervisorMsg {
/// A new viewer wants in. Supervisor replies with the local capture HTTP /// A new viewer wants in. Supervisor replies with the local capture
/// port to connect to, or an error string if the host is full or capture /// HTTP port to connect to, or an error string if the host is full or
/// spawn failed. `cancel` is the viewer's own token — the supervisor keeps /// capture spawn failed.
/// it so a later `KickViewer` can tear this viewer's stream down.
AddViewer { AddViewer {
id: String, id: String,
cancel: CancellationToken, cancel: CancellationToken,
@@ -46,9 +38,7 @@ enum SupervisorMsg {
/// A viewer's session ended. Supervisor decrements the count and tears /// A viewer's session ended. Supervisor decrements the count and tears
/// down capture if it just hit zero. /// down capture if it just hit zero.
RemoveViewer { id: String }, RemoveViewer { id: String },
/// Host asked (via the GUI command channel) to disconnect a viewer by /// Request to kick a specific viewer.
/// endpoint id. Cancels that viewer's token; the normal teardown path then
/// emits the `ViewerLeft`.
KickViewer { id: String }, KickViewer { id: String },
} }
@@ -78,13 +68,10 @@ pub async fn run(opts: HostOpts) -> Result<()> {
let cancel = signal::install_ctrl_c(); let cancel = signal::install_ctrl_c();
// Phase 5 dry-run audit, off unless `PIXELPASS_AUDIO_AUDIT=1`. Read-only: let endpoint = Endpoint::builder(presets::N0)
// it observes the graph and logs what phases 24 conclude, creating no .alpns(vec![ALPN.to_vec()])
// links. Bound to a name so the handle lives as long as the run — dropping .bind()
// it stops the observer thread and flushes the final O5 summary. .await?;
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 // 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 // the endpoint id + relay URL and drop the direct IP candidates. The relay
@@ -134,14 +121,16 @@ pub async fn run(opts: HostOpts) -> Result<()> {
sup_rx, sup_rx,
)); ));
// Command channel for the GUI front-end: read `kick <endpoint-id>` lines // Stdin listener for "kick <id>"
// off stdin. Only when machine-driven (`--output json`) — a human host has let stdin_sup_tx = sup_tx.clone();
// nothing to type here, and we don't want to swallow terminal input. Runs tokio::spawn(async move {
// on a plain OS thread (not a tokio task) so a read parked on stdin can't let mut lines = BufReader::new(tokio::io::stdin()).lines();
// hold up runtime shutdown on Ctrl+C; the thread dies with the process. while let Ok(Some(line)) = lines.next_line().await {
if output::json_enabled() { if let Some(id) = line.strip_prefix("kick ") {
spawn_kick_listener(sup_tx.clone()); let _ = stdin_sup_tx.send(SupervisorMsg::KickViewer { id: id.trim().to_string() }).await;
} }
}
});
accept_loop(&endpoint, sup_tx.clone(), cancel.clone()).await; accept_loop(&endpoint, sup_tx.clone(), cancel.clone()).await;
@@ -189,18 +178,11 @@ async fn handle_peer(
cancel: CancellationToken, cancel: CancellationToken,
) { ) {
let remote = conn.remote_id(); let remote = conn.remote_id();
let id = remote.to_string(); let id_str = 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 peer_cancel = CancellationToken::new();
let (reply_tx, reply_rx) = oneshot::channel(); let (reply_tx, reply_rx) = oneshot::channel();
let add = SupervisorMsg::AddViewer { if sup_tx.send(SupervisorMsg::AddViewer { id: id_str.clone(), cancel: peer_cancel.clone(), reply: reply_tx }).await.is_err() {
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"); tracing::warn!(%remote, "supervisor channel closed; dropping peer");
return; return;
} }
@@ -221,7 +203,7 @@ async fn handle_peer(
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
tracing::warn!(%remote, "accept_bi failed: {e:#}"); tracing::warn!(%remote, "accept_bi failed: {e:#}");
let _ = sup_tx.send(SupervisorMsg::RemoveViewer { id }).await; let _ = sup_tx.send(SupervisorMsg::RemoveViewer { id: id_str }).await;
return; return;
} }
}; };
@@ -232,7 +214,7 @@ async fn handle_peer(
Ok(t) => t, Ok(t) => t,
Err(e) => { Err(e) => {
tracing::warn!(%remote, "connect_to_capture failed: {e:#}"); tracing::warn!(%remote, "connect_to_capture failed: {e:#}");
let _ = sup_tx.send(SupervisorMsg::RemoveViewer { id }).await; let _ = sup_tx.send(SupervisorMsg::RemoveViewer { id: id_str }).await;
return; return;
} }
}; };
@@ -252,30 +234,7 @@ async fn handle_peer(
} }
eprintln!("[pixelpass] viewer disconnected: {remote}"); eprintln!("[pixelpass] viewer disconnected: {remote}");
let _ = sup_tx.send(SupervisorMsg::RemoveViewer { id }).await; let _ = sup_tx.send(SupervisorMsg::RemoveViewer { id: id_str }).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 /// Owns the single shared CaptureHandle and the active viewer count. Spawns
@@ -290,15 +249,12 @@ async fn supervise(
mut rx: mpsc::Receiver<SupervisorMsg>, mut rx: mpsc::Receiver<SupervisorMsg>,
) { ) {
let mut handle: Option<CaptureHandle> = None; let mut handle: Option<CaptureHandle> = None;
// Active viewers, keyed by endpoint id, holding each one's kill switch. let mut count: u32 = 0;
// 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(); let mut viewers: HashMap<String, CancellationToken> = HashMap::new();
while let Some(msg) = rx.recv().await { while let Some(msg) = rx.recv().await {
match msg { match msg {
SupervisorMsg::AddViewer { id, cancel, reply } => { SupervisorMsg::AddViewer { id, cancel, reply } => {
let count = viewers.len() as u32;
if count >= max_viewers { if count >= max_viewers {
let reason = let reason =
format!("host is full ({count} of {max_viewers} viewers connected)"); format!("host is full ({count} of {max_viewers} viewers connected)");
@@ -324,30 +280,18 @@ async fn supervise(
} }
let port = handle.as_ref().expect("handle was just set").local_port(); let port = handle.as_ref().expect("handle was just set").local_port();
count += 1;
viewers.insert(id.clone(), cancel); viewers.insert(id.clone(), cancel);
let active = viewers.len() as u32;
let _ = reply.send(Ok(port)); let _ = reply.send(Ok(port));
output::emit(output::Event::ViewerJoined { output::emit(output::Event::ViewerJoined { id: &id, active: count, max: max_viewers });
id: &id, tracing::info!(active = count, cap = max_viewers, "viewer joined");
active,
max: max_viewers,
});
tracing::info!(active, cap = max_viewers, "viewer joined");
} }
SupervisorMsg::RemoveViewer { id } => { SupervisorMsg::RemoveViewer { id } => {
// A given viewer task only ever sends RemoveViewer once, but the viewers.remove(&id);
// map remove is the source of truth either way. count = count.saturating_sub(1);
if viewers.remove(&id).is_none() { output::emit(output::Event::ViewerLeft { id: &id, active: count, max: max_viewers });
continue; tracing::info!(active = count, cap = max_viewers, "viewer left");
} if count == 0
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() && let Some(h) = handle.take()
{ {
tracing::info!("last viewer left — tearing down capture"); tracing::info!("last viewer left — tearing down capture");
@@ -358,14 +302,9 @@ async fn supervise(
} }
} }
SupervisorMsg::KickViewer { id } => { SupervisorMsg::KickViewer { id } => {
match viewers.get(&id) { if let Some(cancel) = viewers.get(&id) {
// Cancel the viewer's token; its handle_peer select! wakes, tracing::info!(%id, "kicking viewer");
// sends RemoveViewer, and the leave is emitted there. cancel.cancel();
Some(cancel) => {
tracing::info!(%id, "kicking viewer");
cancel.cancel();
}
None => tracing::debug!(%id, "kick for unknown/already-gone viewer"),
} }
} }
} }
@@ -392,25 +331,10 @@ fn print_host_banner(
eprintln!("┌─ PixelPass · host ─────────────────────────────────────────"); eprintln!("┌─ PixelPass · host ─────────────────────────────────────────");
eprintln!("│ display server : {display:?}"); eprintln!("│ display server : {display:?}");
eprintln!("│ capture : {}", capture_summary(opts)); eprintln!("│ capture : {}", capture_summary(opts));
eprintln!( eprintln!("│ quality : {}{}", quality.label, quality.dimensions_summary());
"│ quality : {}{}",
quality.label,
quality.dimensions_summary()
);
eprintln!("│ ({})", quality.note); eprintln!("│ ({})", quality.note);
eprintln!( eprintln!("│ hw encode : {}", if opts.no_hwencode { "off (software x264)" } else { "on (VAAPI H.264)" });
"│ hw encode : {}", eprintln!("│ max viewers : {} ({})", resolution.value, resolution.source.label());
if opts.no_hwencode {
"off (software x264)"
} else {
"on (VAAPI H.264)"
}
);
eprintln!(
"│ max viewers : {} ({})",
resolution.value,
resolution.source.label()
);
eprintln!(""); eprintln!("");
if clipboard_ok { if clipboard_ok {
eprintln!("│ Your share code has been copied to your clipboard."); eprintln!("│ Your share code has been copied to your clipboard.");
@@ -474,9 +398,7 @@ fn resolve_max_viewers(opts: &HostOpts, effective_bitrate: u32) -> MaxViewersRes
let n = bandwidth::recommended_max_viewers(upstream, effective_bitrate); let n = bandwidth::recommended_max_viewers(upstream, effective_bitrate);
return MaxViewersResolution { return MaxViewersResolution {
value: n, value: n,
source: MaxViewersSource::BandwidthMeasurement { source: MaxViewersSource::BandwidthMeasurement { safe_mbps: upstream },
safe_mbps: upstream,
},
}; };
} }
MaxViewersResolution { MaxViewersResolution {
@@ -498,73 +420,9 @@ fn copy_to_clipboard(text: &str) -> bool {
fn capture_summary(opts: &HostOpts) -> String { fn capture_summary(opts: &HostOpts) -> String {
let mut bits = vec![if opts.window { "window" } else { "fullscreen" }.to_string()]; let mut bits = vec![if opts.window { "window" } else { "fullscreen" }.to_string()];
if let Some(app) = &opts.app { if let Some(app) = &opts.app {
if opts.strict_audio { bits.push(format!("app-audio={app}"));
bits.push(format!("app-audio={app} (strict)"));
} else {
bits.push(format!("app-audio={app}"));
}
} else { } else {
bits.push("system-audio".to_string()); bits.push("system-audio".to_string());
} }
bits.join(" + ") 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);
}
}
-659
View File
@@ -1,659 +0,0 @@
//! 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
});
}
}
-161
View File
@@ -1,161 +0,0 @@
//! 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 24) 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
}
}
-557
View File
@@ -1,557 +0,0 @@
//! 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,
}
}
-89
View File
@@ -1,89 +0,0 @@
//! 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())
}
-717
View File
@@ -1,717 +0,0 @@
//! 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"
);
}
+3 -28
View File
@@ -189,32 +189,9 @@ fn build_args(
"!".into(), "!".into(),
"queue".into(), "queue".into(),
"!".into(), "!".into(),
"fdsink".into(),
"fd=1".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 // Downscale step for the quality presets. `None` = encode at native size
// (the "Source" preset, or a source already at/below the target height — we // (the "Source" preset, or a source already at/below the target height — we
@@ -331,9 +308,7 @@ async fn default_audio_monitor() -> Result<String> {
.arg("get-default-sink") .arg("get-default-sink")
.output() .output()
.await .await
.context( .context("failed to run `pactl get-default-sink` (install pulseaudio-utils or pipewire-pulse)")?;
"failed to run `pactl get-default-sink` (install pulseaudio-utils or pipewire-pulse)",
)?;
if !output.status.success() { if !output.status.success() {
bail!( bail!(
"pactl get-default-sink failed: {}", "pactl get-default-sink failed: {}",
+7 -33
View File
@@ -27,26 +27,10 @@ impl Quality {
/// values and resolves to one of the others at runtime (see [`resolve_auto`]). /// values and resolves to one of the others at runtime (see [`resolve_auto`]).
fn preset(self) -> Option<Preset> { fn preset(self) -> Option<Preset> {
let p = match self { let p = match self {
Quality::Source => Preset { Quality::Source => Preset { max_height: None, bitrate: 6000, framerate: 30 },
max_height: None, Quality::High => Preset { max_height: Some(1080), bitrate: 4000, framerate: 30 },
bitrate: 6000, Quality::Medium => Preset { max_height: Some(720), bitrate: 2500, framerate: 30 },
framerate: 30, Quality::Low => Preset { max_height: Some(480), bitrate: 1000, 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, Quality::Auto => return None,
}; };
Some(p) Some(p)
@@ -65,12 +49,7 @@ impl Quality {
/// Fixed presets in descending quality order — Auto walks this to find the /// Fixed presets in descending quality order — Auto walks this to find the
/// best one whose per-viewer bitrate fits the measured upstream budget. /// best one whose per-viewer bitrate fits the measured upstream budget.
const AUTO_LADDER: [Quality; 4] = [ const AUTO_LADDER: [Quality; 4] = [Quality::Source, Quality::High, Quality::Medium, Quality::Low];
Quality::Source,
Quality::High,
Quality::Medium,
Quality::Low,
];
/// Auto's fallback when there is no usable bandwidth measurement. /// Auto's fallback when there is no usable bandwidth measurement.
const AUTO_FALLBACK: Quality = Quality::Medium; const AUTO_FALLBACK: Quality = Quality::Medium;
@@ -167,9 +146,7 @@ fn resolve_auto(safe_mbps: Option<f64>, sizing_viewers: u32) -> (Preset, String,
( (
preset, preset,
format!("Auto → {}", chosen.name()), format!("Auto → {}", chosen.name()),
format!( format!("auto: {safe_mbps:.1} Mbps safe ÷ {n} viewer(s) = {budget_mbps:.1} Mbps each"),
"auto: {safe_mbps:.1} Mbps safe ÷ {n} viewer(s) = {budget_mbps:.1} Mbps each"
),
) )
} }
None => { None => {
@@ -177,8 +154,7 @@ fn resolve_auto(safe_mbps: Option<f64>, sizing_viewers: u32) -> (Preset, String,
( (
preset, preset,
format!("Auto → {}", AUTO_FALLBACK.name()), format!("Auto → {}", AUTO_FALLBACK.name()),
"auto fallback — no bandwidth measurement (run `pixelpass --reconfigure`)" "auto fallback — no bandwidth measurement (run `pixelpass --reconfigure`)".to_string(),
.to_string(),
) )
} }
} }
@@ -205,7 +181,6 @@ mod tests {
HostOpts { HostOpts {
window: false, window: false,
app: None, app: None,
strict_audio: false,
display_server: None::<DisplayServerArg>, display_server: None::<DisplayServerArg>,
quality, quality,
bitrate: None, bitrate: None,
@@ -214,7 +189,6 @@ mod tests {
no_hwencode: false, no_hwencode: false,
max_viewers, max_viewers,
interactive: false, interactive: false,
relay: None,
} }
} }
+2 -5
View File
@@ -130,11 +130,8 @@ async fn run_accept_loop(listener: TcpListener, tx: broadcast::Sender<Arc<Vec<u8
let sock = match listener.accept().await { let sock = match listener.accept().await {
Ok((s, _)) => s, Ok((s, _)) => s,
Err(e) => { Err(e) => {
// Most accept errors are transient (EMFILE from a brief FD spike, tracing::warn!("capture HTTP accept failed: {e}");
// EINTR, etc.). Bailing on the first one would kill the entire return;
// viewer fanout for the rest of the session.
tracing::warn!("capture HTTP accept failed (continuing): {e}");
continue;
} }
}; };
let rx = tx.subscribe(); let rx = tx.subscribe();
-338
View File
@@ -1,338 +0,0 @@
//! 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)
}
}
-959
View File
@@ -1,959 +0,0 @@
//! 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 56) — 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
}
}
}
-390
View File
@@ -1,390 +0,0 @@
//! 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
}
-332
View File
@@ -1,332 +0,0 @@
//! 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 23). 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
+11 -23
View File
@@ -12,7 +12,8 @@ use ashpd::{
}, },
}; };
use nix::fcntl::{FcntlArg, FdFlag, fcntl}; use nix::fcntl::{FcntlArg, FdFlag, fcntl};
use std::os::fd::{AsFd, AsRawFd, OwnedFd, RawFd}; use nix::unistd::close;
use std::os::fd::{AsFd, IntoRawFd, OwnedFd, RawFd};
use super::pipeline::{self, CaptureHandle}; use super::pipeline::{self, CaptureHandle};
use super::quality::EffectiveQuality; use super::quality::EffectiveQuality;
@@ -25,11 +26,7 @@ pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<Captur
.context("could not reach the xdg-desktop-portal ScreenCast interface")?; .context("could not reach the xdg-desktop-portal ScreenCast interface")?;
let session = proxy.create_session().await?; let session = proxy.create_session().await?;
let source = if opts.window { let source = if opts.window { SourceType::Window } else { SourceType::Monitor };
SourceType::Window
} else {
SourceType::Monitor
};
proxy proxy
.select_sources( .select_sources(
&session, &session,
@@ -60,14 +57,11 @@ pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<Captur
let pw_fd: OwnedFd = proxy.open_pipe_wire_remote(&session).await?; let pw_fd: OwnedFd = proxy.open_pipe_wire_remote(&session).await?;
tracing::info!(node_id, width = w, height = h, "portal handshake complete"); 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 // The fd is CLOEXEC by default; the gst child needs to inherit it across
// exec, so clear CLOEXEC. We keep the OwnedFd alive across the spawn (gst // exec. We then leak it via into_raw_fd so its lifetime spans the spawn,
// inherits its own copy at exec) by moving it into the after_spawn hook, // and close the parent's copy once gst is running (the pipeline's
// which drops — and so closes — the parent's copy once gst is running. If // after_spawn hook below).
// 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)?; clear_cloexec(&pw_fd)?;
let raw_fd: RawFd = pw_fd.as_raw_fd(); let raw_fd: RawFd = pw_fd.into_raw_fd();
let source_args = vec![ let source_args = vec![
"pipewiresrc".to_string(), "pipewiresrc".to_string(),
@@ -76,16 +70,10 @@ pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<Captur
"do-timestamp=true".to_string(), "do-timestamp=true".to_string(),
]; ];
pipeline::spawn( pipeline::spawn(opts, quality, Some((w as u32, h as u32)), source_args, move || {
opts, // Parent no longer needs the pipewire fd — gst inherited its own copy.
quality, let _ = close(raw_fd);
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 .await
} }
+4 -14
View File
@@ -37,22 +37,12 @@ pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<Captur
} }
}; };
// 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![ let mut source_args = vec![
"ximagesrc".to_string(), "ximagesrc".to_string(),
// show-pointer matches Wayland's CursorMode::Embedded. // Full frames (no damage regions) to avoid partial-update artifacts;
use_damage.to_string(), // use-damage=true is a later CPU optimization. show-pointer matches
// Wayland's CursorMode::Embedded.
"use-damage=false".to_string(),
"show-pointer=true".to_string(), "show-pointer=true".to_string(),
]; ];
if let Some(xid) = xid { if let Some(xid) = xid {
+12 -16
View File
@@ -13,7 +13,7 @@ pub async fn run(cli: Cli) -> Result<()> {
let theme = ColorfulTheme::default(); let theme = ColorfulTheme::default();
let choice = Select::with_theme(&theme) let choice = Select::with_theme(&theme)
.with_prompt("What do you want to do?") .with_prompt("What do you want to do?")
.items([ .items(&[
"Host (share my screen)", "Host (share my screen)",
"View (watch someone else's screen)", "View (watch someone else's screen)",
]) ])
@@ -112,7 +112,7 @@ fn pick_quality(theme: &ColorfulTheme) -> Result<Quality> {
let choice = Select::with_theme(theme) let choice = Select::with_theme(theme)
.with_prompt("What quality should the viewer(s) get?") .with_prompt("What quality should the viewer(s) get?")
.items(items) .items(&items)
.default(0) .default(0)
.interact()?; .interact()?;
@@ -138,7 +138,7 @@ pub async fn run_reconfigure() -> Result<()> {
async fn preflight_if_needed(theme: &ColorfulTheme) { async fn preflight_if_needed(theme: &ColorfulTheme) {
let mut cfg = config::load().unwrap_or_default(); let mut cfg = config::load().unwrap_or_default();
match cfg.bandwidth.status { match cfg.bandwidth.status {
config::BandwidthStatus::Measured | config::BandwidthStatus::Skipped => (), config::BandwidthStatus::Measured | config::BandwidthStatus::Skipped => return,
config::BandwidthStatus::Unmeasured => { config::BandwidthStatus::Unmeasured => {
eprintln!(); eprintln!();
eprintln!("First-time setup"); eprintln!("First-time setup");
@@ -154,7 +154,7 @@ async fn preflight_if_needed(theme: &ColorfulTheme) {
let Ok(choice) = Select::with_theme(theme) let Ok(choice) = Select::with_theme(theme)
.with_prompt("What would you like to do?") .with_prompt("What would you like to do?")
.items([ .items(&[
"Run the bandwidth test (recommended)", "Run the bandwidth test (recommended)",
"Skip — use the conservative default", "Skip — use the conservative default",
]) ])
@@ -180,7 +180,10 @@ async fn preflight_if_needed(theme: &ColorfulTheme) {
eprintln!(); eprintln!();
let Ok(choice) = Select::with_theme(theme) let Ok(choice) = Select::with_theme(theme)
.with_prompt("Last bandwidth test failed. Try again?") .with_prompt("Last bandwidth test failed. Try again?")
.items(["Yes — retry now", "No — use the conservative default"]) .items(&[
"Yes — retry now",
"No — use the conservative default",
])
.default(0) .default(0)
.interact() .interact()
else { else {
@@ -279,12 +282,9 @@ impl Player {
Player::Mpv => crate::common::process::spawn_detached( Player::Mpv => crate::common::process::spawn_detached(
"mpv", "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", "--profile=low-latency",
"--untimed",
"--hwdec=auto",
"--audio-buffer=0.2", "--audio-buffer=0.2",
"--demuxer-max-bytes=2M", "--demuxer-max-bytes=2M",
"--demuxer-readahead-secs=0.5", "--demuxer-readahead-secs=0.5",
@@ -341,12 +341,8 @@ pub fn prompt_player() -> Result<Player> {
let theme = ColorfulTheme::default(); let theme = ColorfulTheme::default();
let choice = Select::with_theme(&theme) let choice = Select::with_theme(&theme)
.with_prompt("Connected. Pick a player to launch") .with_prompt("Connected. Pick a player to launch")
.items(["mpv", "VLC"]) .items(&["mpv", "VLC"])
.default(0) .default(0)
.interact()?; .interact()?;
Ok(if choice == 0 { Ok(if choice == 0 { Player::Mpv } else { Player::Vlc })
Player::Mpv
} else {
Player::Vlc
})
} }
+2 -21
View File
@@ -1,6 +1,5 @@
mod cli; mod cli;
mod common; mod common;
mod doctor;
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
mod gui; mod gui;
mod host; mod host;
@@ -26,7 +25,7 @@ async fn main() -> Result<()> {
if cli.gui { if cli.gui {
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
{ {
return gui::run(cli.relay); return gui::run();
} }
#[cfg(not(feature = "gui"))] #[cfg(not(feature = "gui"))]
{ {
@@ -37,13 +36,6 @@ async fn main() -> Result<()> {
} }
} }
// 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; // libpipewire requires global init before any pw_* call. Idempotent;
// safe to call even when the per-app audio thread never spawns. // safe to call even when the per-app audio thread never spawns.
pipewire::init(); pipewire::init();
@@ -52,13 +44,6 @@ async fn main() -> Result<()> {
return repair::run().await; 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 { if cli.reconfigure {
return interactive::run_reconfigure().await; return interactive::run_reconfigure().await;
} }
@@ -88,11 +73,7 @@ async fn main() -> Result<()> {
} }
fn init_tracing(verbose: bool) { fn init_tracing(verbose: bool) {
let default = if verbose { let default = if verbose { "pixelpass=trace,iroh=info" } else { "pixelpass=info,iroh=warn" };
"pixelpass=trace,iroh=info"
} else {
"pixelpass=info,iroh=warn"
};
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default)); let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default));
// Tracing MUST write to stderr. `tracing_subscriber::fmt()` defaults its // Tracing MUST write to stderr. `tracing_subscriber::fmt()` defaults its
// writer to stdout, but with `--output json` stdout carries the JSON event // writer to stdout, but with `--output json` stdout carries the JSON event
+11 -50
View File
@@ -50,11 +50,13 @@ pub async fn run() -> Result<()> {
if m.name != "module-loopback" { if m.name != "module-loopback" {
continue; continue;
} }
// A pixelpass loopback references a capture sink either as its let Some(sink) = extract_kv(&m.args, "sink") else {
// destination (`sink=pixelpass_capture_<pid>` — the default→null continue;
// 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_str) = sink.strip_prefix(SINK_NAME_PREFIX) else {
let Some(pid) = loopback_capture_pid(&m.args) else { continue;
};
let Ok(pid) = pid_str.parse::<u32>() else {
continue; continue;
}; };
if dead_pids.contains(&pid) { if dead_pids.contains(&pid) {
@@ -108,7 +110,9 @@ pub async fn run() -> Result<()> {
} }
if live_skipped > 0 { if live_skipped > 0 {
println!("[pixelpass] --repair: left {live_skipped} live pixelpass host(s) alone."); println!(
"[pixelpass] --repair: left {live_skipped} live pixelpass host(s) alone."
);
} }
if failed > 0 { if failed > 0 {
@@ -150,9 +154,7 @@ fn list_modules() -> Result<Vec<Module>> {
for line in text.lines() { for line in text.lines() {
let mut parts = line.splitn(4, '\t'); let mut parts = line.splitn(4, '\t');
let Some(id_str) = parts.next() else { continue }; let Some(id_str) = parts.next() else { continue };
let Ok(id) = id_str.parse::<u32>() else { let Ok(id) = id_str.parse::<u32>() else { continue };
continue;
};
let Some(name) = parts.next() else { continue }; let Some(name) = parts.next() else { continue };
let args = parts.next().unwrap_or("").to_string(); let args = parts.next().unwrap_or("").to_string();
modules.push(Module { modules.push(Module {
@@ -164,19 +166,6 @@ fn list_modules() -> Result<Vec<Module>> {
Ok(modules) 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> { fn extract_kv<'a>(args: &'a str, key: &str) -> Option<&'a str> {
for token in args.split_whitespace() { for token in args.split_whitespace() {
if let Some(rest) = token.strip_prefix(key) if let Some(rest) = token.strip_prefix(key)
@@ -206,31 +195,3 @@ fn unload_module(id: u32) -> Result<()> {
} }
Ok(()) 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
);
}
}
+33 -46
View File
@@ -1,10 +1,12 @@
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use iroh::Endpoint;
use iroh::endpoint::presets;
use iroh_tickets::endpoint::EndpointTicket; use iroh_tickets::endpoint::EndpointTicket;
use std::time::Duration; use std::time::Duration;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use crate::cli::ViewerOpts; use crate::cli::ViewerOpts;
use crate::common::{alpn::ALPN, endpoint, output, signal}; use crate::common::{alpn::ALPN, output, signal};
/// Cap on the initial QUIC connect. `endpoint.connect()` has no built-in /// Cap on the initial QUIC connect. `endpoint.connect()` has no built-in
/// deadline, so an offline host / stale code / unreachable relay otherwise /// deadline, so an offline host / stale code / unreachable relay otherwise
@@ -15,7 +17,10 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
pub async fn run(ticket: EndpointTicket, opts: ViewerOpts) -> Result<()> { pub async fn run(ticket: EndpointTicket, opts: ViewerOpts) -> Result<()> {
let cancel = signal::install_ctrl_c(); let cancel = signal::install_ctrl_c();
let endpoint = endpoint::bind(opts.relay.as_deref()).await?; let endpoint = Endpoint::builder(presets::N0)
.alpns(vec![ALPN.to_vec()])
.bind()
.await?;
let addr = ticket.endpoint_addr().clone(); let addr = ticket.endpoint_addr().clone();
tracing::info!(remote = %addr.id, "connecting to host"); tracing::info!(remote = %addr.id, "connecting to host");
@@ -45,52 +50,34 @@ pub async fn run(ticket: EndpointTicket, opts: ViewerOpts) -> Result<()> {
} }
}, },
}; };
// Everything past the established connection runs in one block so any error let (quic_send, quic_recv) = conn.open_bi().await?;
// (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 listener = TcpListener::bind(("127.0.0.1", opts.port)).await?;
let port = listener.local_addr()?.port(); let port = listener.local_addr()?.port();
let url = format!("http://127.0.0.1:{port}"); let url = format!("http://127.0.0.1:{port}");
output::emit(output::Event::Connected { url: &url }); output::emit(output::Event::Connected { url: &url });
if opts.interactive { if opts.interactive {
let player = crate::interactive::prompt_player()?; let player = crate::interactive::prompt_player()?;
player player
.spawn(&url) .spawn(&url)
.with_context(|| "failed to launch player")?; .with_context(|| "failed to launch player")?;
print_viewer_banner_interactive(); print_viewer_banner_interactive();
} else { } else {
print_viewer_banner(&url); 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;
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
}
_ = cancel.cancelled() => {
tracing::info!("ctrl-c received before local viewer connected");
Ok(())
}
};
endpoint.close().await; endpoint.close().await;
result result
@@ -102,7 +89,7 @@ fn print_viewer_banner(url: &str) {
eprintln!("│ Connected to host. Open the stream in your player:"); eprintln!("│ Connected to host. Open the stream in your player:");
eprintln!(""); eprintln!("");
eprintln!( eprintln!(
"│ mpv --profile=low-latency --audio-buffer=0.2 --demuxer-max-bytes=2M --demuxer-readahead-secs=0.5 {url}" "│ mpv --profile=low-latency --untimed --hwdec=auto --audio-buffer=0.2 --demuxer-max-bytes=2M --demuxer-readahead-secs=0.5 {url}"
); );
eprintln!("│ vlc --network-caching=200 --live-caching=200 {url}"); eprintln!("│ vlc --network-caching=200 --live-caching=200 {url}");
eprintln!(""); eprintln!("");