Codex authored the adapter half of the phase-3 split; I reviewed it and
applied one robustness fix (below). Additive/read-only — does not yet replace
the existing audio.rs router (that migration is a later integration phase).
adapter.rs: a dedicated libpipewire main-loop thread translating registry
globals into RegEvents and publishing the latest Projection via
RegistryObserverHandle::latest(). core.sync(0)/done is matched one-shot →
ServerSynced; a 250 ms loop timer emits Tick for the fail-closed readiness
timeout; pulse-PID candidates are probed from /proc/<pid>/comm only when the
candidate changes; Links missing endpoint props are bound (LinkInfoRef, weak
back-ref to avoid the listener cycle) and resolved via LinkEndpointsResolved.
mod.rs: `pub mod adapter;`. audio.rs: parse_object_serial → pub(crate) so the
adapter reuses the strict 64-bit parser.
Review fix: record_global was called unconditionally per global (including
unknown object types and dropped globals), which could desync the bound-link
FIFO from the model's live_ids and leak a Link proxy on a recycled id. Now
folded into `add()` so a slot is recorded only when an Added event is applied
— the two id queues are provably lockstep.
Exit gate complete: 5 pure rows + the live topology-diff row (row 6) — the
#[ignore] adapter test PASSES on this host against the live daemon
(module-null-sink + module-loopback observed appearing and disappearing).
cargo test --bins 117 + 1 live green, clippy clean, no fmt sweep.
Co-Authored-By: Codex (gpt-5.6-sol) <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
My half of the phase-3 split (impl plan §4). Pure, no PipeWire: the adapter
(Codex's half) translates live registry callbacks / binds / /proc reads /
core.sync into RegEvents and feeds this reducer.
- RegistryModel::apply folds RegEvents into serial-keyed maps with an
insertion-ordered id index so global_remove accounts for the oldest
generation first; recycled ids stay Ambiguous until accounted (v3.4 §6.1.3).
- Readiness epoch: graph_ready false until ServerSynced + no outstanding
obligations (withheld nodes, pending link binds); bounded timeout fails
closed. Gates sticky retirement only; sticky once terminal.
- session_device classifier: hardware-PCM factory allowlist, exact match,
fail closed to false; a node on an unresolved Device is withheld, never
admitted provisional.
- pulse-PID derivation split into pure candidate (repeated sec_pid) + validate
(/proc comm), so the 6-case failure matrix is unit-testable; any failure =>
None (key 4 unusable).
34 tests cover 5 of 6 exit-gate rows (the live topology-diff row is the
adapter's). cargo test --bins 117 green, fmt + clippy clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex refuted my round-5 disposition and was right: the buffered-echo gap
is NOT limited to keyless/unbounded readers. A normal PID-bearing app —
recorder, DAW, GStreamer — can read the call, buffer it in application
memory, fully tear down its PipeWire Node *and* Client, then (still the
same live process) open a fresh Client + output and replay. `seed_sticky`
drops the PID fingerprint once every old serial is gone, so the replayed
leg is Eligible. That is in-threat-model, so my "outside the threat model"
claim was false.
- Rewrote the module-doc gap note honestly: in-threat-model, reachable by
non-adversarial software, sitting on the design's §6.1.3 "full teardown
⇒ starts clean" boundary. Framed the two options — (A) accept as a
documented v1 limitation, (B) process-generation lifetime (PID + /proc
start-time, phase 3 supplies liveness, §6.1.3 revised). This is a
designer's decision (it revises the security surface); NOT resolved in
code. `a_fingerprint_does_not_outlive_its_owner` currently encodes
Option A and flips under B.
- P2 (fixed): pinned the ambiguous-client-id branch. A mutation
remembering only the first of two clients claiming one global id
survived the suite; added a test scoped to the ambiguous owner (the
global count was masked by the peerspeak owner's client). Verified the
`.next()` mutation now fails it.
57 tests. Phase 2 is NOT converged — the buffered-echo design decision is
owed to the user before merge.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Round 5 convergence check. Codex confirmed F1(broad rule)/F2(doc)/
link-group fingerprint complete, and raised three more:
- P2 (fixed): a mutation deleting the *role-based* receiver insertion
survived all 55 tests — every tested bridge source also had an inbound
link. A pixelpass capture sink is a taint root before anything links
into it, and its re-emitting sibling must bridge from it on role alone.
Added `a_local_root_receiver_bridges_without_an_inbound_link`; mutation
now killed.
- P3 (fixed): doc drift. The backstop's preamble still described the old
"targets must be unbounded / apps never swept" rule; rewritten to the
two-tier trigger/sweep. The `session_device` factory guidance now says
explicit allowlist, not "and the like".
- P1 (dispositioned as a documented v1 limitation, not fixed): a buffered
echo across a *full* teardown of an *unbounded* reader. Grounds, in the
module docs: (1) it needs a stream exposing no PID/module-id/link-group,
which is malformed/identity-hiding and outside v3.4 §2's non-adversarial
threat model; (2) it contradicts the design's explicit "reappears after
full teardown ⇒ new owner, starts clean" (§6.1.3), so closing it is a
design change; (3) the only closed-form fix is a whole-share hammer
(one keyless stream ⇒ desktop unshareable for the share). Reachable
cases — a reader live now — are already covered by the backstop.
Owed to the design doc as a round-8 note.
56 tests. Taking the P1 disposition to Codex for ratification, then to
the user as a design decision.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Round 4 adjudicated my three round-3 pushbacks. Codex ruled: F2 bool seam
sufficient (YES), F3 accepted as a phase-3 contract not a phase-2 blocker
(YES) — but my F1 narrowing was unsound (NO), with a clean counterexample.
F1 (conceded): I had narrowed "unbounded tainted reader ⇒ exclude every
output" to spare outputs carrying a real, non-daemon PID, arguing an
unbounded reader must be daemon-owned. Codex refuted it:
`application.process.id` is optional and client-controlled, so one real
process can present NO pid on its reading leg (unbounded) and a real pid
on its output leg — the narrowing spares that output and leaks the call.
App properties cannot carry a soundness argument; only `pipewire.*` has
protected identity. Reverted to the broad rule: an unbounded tainted
reader excludes the whole candidate universe. Added the exact
counterexample as a test (`a_real_app_with_no_pid_on_its_reader_leg...`)
and kept a bounded-reader test to show the round-1 blast-radius guarantee
still holds for the bounded tier.
F2 (doc corrected): removed the "a mis-classified filter is still braced"
claim — Codex showed a filter with no shared strong key, wrongly marked
`session_device`, cannot trip the backstop from its reading leg and leaks
through a differently-keyed output. A false positive is now documented as
leak-capable; the only defence is the correct positive classifier.
F3 (link-group fingerprint, pinned): a mutation dropping LinkGroup
fingerprints survived all 53 tests, because the strong-key fingerprint
test used pulse.module.id. Added a link-group new-connection test.
Mutation-verified 2/2. 55 tests.
Phase-2 open item is now only F3-as-phase-3-contract, which Codex accepted
is not a phase-2 blocker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Round 3 was the second verification round. One real leak, one accepted
narrowing of Codex's own suggested fix, two contract strengthenings, and
a test-gap fix.
F1 (P1, real leak, fixed): the inverse of round-1 finding 4. A tainted
reader that is itself *unbounded* (client.id only, daemon PID suppressed)
whose re-emitting leg carried an *unmatched* strong key left that leg
"bounded" and Eligible. An unbounded reader cannot be positively related
to any output, so a strong key that does not match it back proves nothing.
Two-tier backstop. A bounded tainted reader excludes only unbounded
outputs (a differently-keyed output is provably a different owner). An
unbounded tainted reader also excludes daemon-owned outputs — but NOT
ordinary apps.
⚠️ Deliberately narrower than Codex's suggested "exclude every output".
An unbounded reader is necessarily daemon-owned (a real app has its own
PID, which is a usable key, so it would be bounded), so its sibling is
another daemon leg, never an app. Sweeping in real apps would lose the
round-1 "blast radius stays small" guarantee for no safety gain. When
the daemon PID is unknown the app/leg distinction collapses and the rule
degrades to Codex's exclude-all. Both directions are pinned by tests,
and the over-aggressive variant fails the spares-real-apps test.
F2 (contract, strengthened): `session_device` is documented as a positive
high-confidence phase-3 classification, not `device.id`+`device.api`
(measured insufficient — a card filter can carry both; node.physical is
null on the real ALSA nodes so it is not a discriminator). Fail closed:
unknown ⇒ false. Documented why a mis-classified filter still does not
leak in practice — its legs share a link-group (strong-key bridge) and an
unbounded reading leg trips the two-tier backstop.
F5 (P2, test gap): a mutation keeping only PID fingerprints survived all
49 tests. Added a strong-key (pulse.module.id) new-connection fixture.
Mutation-verified 3/3 including the over-aggressive counter-mutation.
Still OWED to round 3, carried to round 4 for adjudication: finding 3
(a not-ready epoch can persist provisional owner *fusion* as sticky
over-exclusion). It is over-exclusion, never an echo leak, and closing it
needs a readiness/provenance model decision rather than a local patch —
see the round-4 handoff. 53 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The verification round earned its place: five of the six round-1 fixes
were partial, and two of the gaps were worse than the bugs they replaced.
1. ⚠️ The round-1 sticky fix smuggled the suppressed key back in.
`client_serials_of` recorded the shared `WirePlumber [export]` client as
a member of a tainted hardware sink's owner, so the *second* recompute
expanded that client to every sound card on the box, tainted the
microphone, and excluded every app holding one — the §6.1.1 catastrophe
arriving one epoch late instead of never. `client.id` may now only be
recorded, or expanded, for nodes where it is a usable owner key.
The regression test evaluates an unchanged snapshot three times: a
correct engine's answer must not drift when nothing has.
2. Sticky followed a surviving *connection*, not a surviving *owner*. A
process can leave one client idle and open a second — GStreamer opens
one per stream as a matter of course — and the new leg escaped.
`StickyOwner` now carries owner **fingerprints** (strong keys and a
usable PID, never `client.id`), applied only while some serial member
is still live, so a recyclable key cannot resurrect a dead owner.
3. An **ambiguous** link input endpoint tainted every claimant but made
none of them a receiver, so their sibling output legs stayed eligible.
Taint without receiver status cannot start an owner bridge.
4. `device.id` is a raw observation, not the classification the coarse-key
exception needs — PipeWire defines it only as "the Device this node
belongs to", so a forwarding node carrying one would have lost both its
owner keys and its ability to trip the backstop. Replaced by
`session_device`, a phase-3 obligation (`device.id` AND `device.api`)
documented to fail closed when it cannot classify.
5. Readiness now gates sticky **retirement only**. Round 1 stopped a
not-ready epoch erasing history; it also stopped it recording any, so a
reader could consume and buffer the call during that epoch, vanish
before readiness, and leave its output eligible.
6. Added the unresolved-output-plus-unknown-role fixture: deleting one
`receivers.insert` survived all 42 previous tests.
Mutation-verified: 7/7 reverts killed by their intended test. Two attempts
did not land first time and both were my error, not the engine's — the
client-key guard is applied at two sites so removing one is not a revert
(removing the pair is, and that is killed), and the fingerprint-lifetime
test put the recycled node in a snapshot *after* the entry had already
been retired, so the guard was never consulted. Rewritten to place it in
the same snapshot that first sees the owner gone.
Cost comment corrected again, to O(D·(V+E+Σ|sources|·|targets|)).
49 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
All five were reachable, all five now have a regression test, and each
test was verified by injecting the mutation that reverts its fix.
1. Sticky taint ignored surviving Client members. An app can close every
stream while keeping its PipeWire connection open and then open a new
one — Firefox does this constantly — and the new leg came back
Eligible while the owner's buffers still held the call. Sticky seeding
now resolves live Client serials to their current nodes.
2. "Receives audio" was inferred from `media.class` alone, so a node with
an absent or unexpected class sitting on a real inbound link could not
start an owner bridge and its sibling re-emitted the call. A node is
now a receiver if it appears as a resolved `link.input.node` OR has a
receiving role.
3. The device-node coarse-key exception was keyed on `media.class` being
`Audio/Sink|Source|Duplex`, which also stripped the only correlation a
*native virtual sink* has (own client, no link-group, no module id).
Now keyed on `device.id`, measured on the live graph as the exact
discriminator: the 5 ALSA nodes carry device.id 43/45/46 and share
`client.id` 42 (`WirePlumber [export]`); the 3 `support.null-audio-sink`
nodes carry no device.id and hold their own clients.
4. The unbounded-owner backstop required the tainted *reader* to be
unbounded. Properties can be asymmetric — a reader with a link-group
whose re-emitting leg has none is bounded while its sibling is not
findable — so that condition is dropped; targets stay restricted to
unbounded output legs, which keeps the blast radius small.
5. A not-ready snapshot could retire sticky owners, erasing taint history
on the strength of a graph already declared untrustworthy. `evaluate`
now returns the prior state unchanged while `!graph_ready`.
Test-quality findings, also fixed:
- a single pass of each rule survived all 32 tests (every fixture needed
at most one owner hop) → two-chained-forwarder test with a clean
control, plus a 60-layer chain to catch an accidental blow-up
- first-write-wins `raise()` survived → a node reached by bridge on one
pass and by a direct link on the next must report the stronger reason
- `drop_clients` left the fixture's client caches stale, so "a fresh
client after teardown" was really a dangling id; the recycling row now
reuses node id, client id AND `pulse.module.id` verbatim
Also corrected the cost claim: this is O((V+E)·D) for owner-bridge depth
D, not O(V+E) as v3.4 §6.4 states. Owner keys are now computed once per
snapshot instead of per candidate pair.
42 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements design v3.4 §6.1–§6.1.3 behind a fixture test surface. No
PipeWire types in any signature; nothing here links against libpipewire.
Not wired into anything yet — phase 3's registry observer is what will
feed it, so the module is `#![allow(dead_code)]` for now.
evaluate(&GraphSnapshot, &ExclusionCtx, &StickyState)
-> (Decisions, StickyState)
- snapshot.rs: owned Node/Port/Link/Client model keyed on `Serial`
(object.serial, 64-bit, identity) with `GlobalId` retained strictly as
a snapshot-local lookup key. Two live objects claiming one id resolve
as `Ambiguous`, which fails closed.
- owner.rs: the owner bridge — the key union (link-group, pulse.module.id,
client.id, application.process.id) with equality-not-first-present
semantics, transitive union-find components, and both suppression rules.
- mod.rs: monotone fixpoint over link edges, the conditional owner bridge
(gated on the tainted member being one that *receives* audio) and the
unbounded-owner backstop, then sticky merge. Stable `Reason` codes with
an explicit priority so the reported reason never depends on traversal
order.
Three judgement calls that go beyond what v3.4 spells out, all flagged
in the source:
1. Coarse keys (client.id, application.process.id) may not bridge
device-role nodes. Every ALSA device is created by one WirePlumber
process, so they share a client and a PID; peerspeak's playback taints
the default sink on every recompute, and without this rule that taint
reaches the microphone source and then every app holding a mic loses
its playback — the §6.1.1 catastrophe by another route.
2. "Owner is bounded" is not "has a usable key": client.id alone does not
bound an owner (the measured GStreamer split-client refutation), so
the fail-closed backstop keys on strong keys or a usable PID.
3. Sticky entries record a reason per node rather than one per owner, so
a forwarder's output leg keeps `tainted-owner-bridge` instead of
inheriting its input leg's `tainted-upstream`.
32 fixture tests, each asserting an exact partition of the full candidate
universe rather than spot-checking named nodes: v3.4 §12's matrix, the
impl plan's degenerate-snapshot boundary, and the eligible half of every
scenario so an exclude-everything build fails.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex round 1 (P3): the valid cases were only 1, 10 and 20 digits long,
so `if (2..10).contains(&raw.len()) { return None }` survived all four
tests while rejecting every serial a freshly started daemon hands out.
Verified: that mutant passes the old suite and fails the new test.
Also corrects the doc comment — leading zeroes are accepted (harmless
and unambiguous), only whitespace padding is rejected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`object.serial` is a 64-bit PipeWire counter, not a u32 object id.
Parsing it with `parse::<u32>()` returns None past u32::MAX, which
silently leaves `RouterState::sink_serial` unset — `try_flush` then
routes nothing and app-filter mode is dead with no diagnostic.
- factor the parse into a pure `parse_object_serial(&str) -> Option<u64>`
(strict decimal; rejects signs, padding, overflow) with unit tests at
the u32 boundary, past it, and at u64::MAX
- widen `RouterState::sink_serial` to `Option<u64>`
- log a warning when the sink's serial is unusable instead of returning
silently
- audit the other `parse::<u32>` in this file: `load_module` returns a
PulseAudio module index (uint32_t), genuinely 32-bit — annotated, not
changed
Prerequisite for the taint engine's lifetime-awareness, which is keyed
on object.serial (screenshare-audio-exclusion-impl-plan.md §1, §2/0a).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When PIXELPASS_TS_DUMP=<path> is set, tee the muxed MPEG-TS to a file in
addition to the normal fd=1 serve path, so the host-side stream can be
ffprobe'd for capture-side audio/video PTS drift. Each tee branch gets its
own queue so the disk sink cannot backpressure the live serve branch.
No effect when the variable is unset, mirroring PIXELPASS_GST_DEBUG.
Used to establish that the host produces an A/V-clean realtime stream
(+/-18 ms over 170 s), ruling out the capture side in the screen-share
drift investigation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
X11 full-desktop capture used `ximagesrc use-damage=false`, which copies
the whole root window every frame. On servers without working MIT-SHM
(and CPU-bound everywhere else) this collapses to ~1 fps — a field test
over an xlibre host played back at roughly one frame per minute. Default
to `use-damage=true` (XDamage re-grabs only changed regions); keep
`PIXELPASS_X11_NO_DAMAGE=1` as an escape hatch for driver artifacts.
Also drop `--untimed` from both mpv invocations (viewer banner + the
interactive launcher). `--untimed` displays each frame as it decodes and
ignores audio timestamps, which drifts a shared *video* progressively
out of sync with its audio. Pacing to the audio clock keeps A/V synced
at a negligible latency cost.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In strict per-app mode the stream router *moves* the chosen app's output
off the sharer's speakers into the private capture null-sink, so the
viewer heard it but the sharer went silent — you couldn't watch a video
together because only the remote side had audio.
Add a "local monitor" loopback (null-sink.monitor → @DEFAULT_SINK@) that
mirrors the routed app back to the sharer's own speakers. It carries only
the chosen app (never the desktop/voice call), so it can't echo into the
capture, and it's loaded on the first routed stream — after the default
loopback is unloaded — so the two are never live at once (no feedback).
Unloaded when the app stops and torn down before the null-sink on cleanup.
Extend `--repair` to recognise this loopback by its `source=` arg (it
targets @DEFAULT_SINK@, not a pixelpass name) so a crashed host's local
monitor is swept too. New pure `loopback_capture_pid` + 3 unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In strict per-app mode the default-sink loopback is suppressed, so until the
chosen app's first stream routes the viewer hears silence. Previously no event
fired for an app that never routed (`lost` only fires on an N→0 transition
after a prior route), so peerspeak couldn't warn — the share looked normal but
was silent. Emit a `lost` at capture start (lazy, on first viewer) when, and
only when, `--app` + `--strict-audio` are both set; whole-desktop and
best-effort modes keep audio flowing via the loopback and emit nothing.
Factored the emit decision into the pure, unit-tested `initial_app_audio_state`;
derive Debug/PartialEq/Eq on AppAudioState so it can be asserted on.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
With --app, pixelpass mirrors the default-sink monitor (whole desktop) until the
chosen app's streams route, and restores that loopback if the app's audio later
stops. That fallback captures everything playing — including a voice call the
sharer is in — so a caller watching the share can hear themselves echoed back
(peerspeak bug A23: the per-app pick alone is best-effort, not a guarantee).
- New --strict-audio flag (HostOpts.strict_audio): with --app, never load the
default-sink loopback (not at startup, not on LastRoutedStreamGone). The viewer
hears only the chosen app, and silence when it's quiet — never the rest of the
desktop. No effect without --app; standalone best-effort behavior is unchanged.
- New app_audio JSON event ({"event":"app_audio","state":"routed"|"lost"}),
emitted whenever --app is set, so a front-end (peerspeak) can tell when the
chosen app's audio is actually live vs. dropped and warn accordingly.
- Banner capture summary shows "(strict)" when active.
Unknown-event-tolerant: pixelpass's own --gui child parser skips lines it can't
deserialize, so app_audio doesn't disturb it. 10 tests (+2: wire-shape + banner),
clippy --all-targets clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Found in a wider bug audit of the streaming/process-management code.
- Viewer ctrl-c/SIGINT was ignored mid-stream: viewer::run raced the
cancel token only against listener.accept(), not the bridge itself, so
once the local player connected nothing checked it. CLI needed a second
ctrl-c to quit and a GUI "Disconnect" only took effect via the child's 2s
SIGKILL backstop (and the host saw the viewer ~2s longer). Now races the
bridge against cancel, mirroring the host's handle_peer. (viewer/mod.rs)
- Wayland portal pipewire fd leaked on a capture-setup error: wayland::start
into_raw_fd'd the fd and relied on pipeline::spawn's after_spawn hook to
close it, but setup_audio/gst-spawn can ?-return before the hook runs,
leaking the fd per failed attempt. Now the OwnedFd is moved into the hook,
so it's closed whether the hook runs or (on early error) the unused closure
is dropped. (host/wayland.rs)
- Detached players (mpv/vlc) zombied under the long-lived GUI: spawn_detached
dropped the std Child, which has no orphan reaping, so each closed player
left a <defunct> entry until the GUI exited. Now a detached thread wait()s
it; the setsid'd player still survives a parent exit (init reaps it then).
A double-fork was avoided deliberately — fork(2) + non-trivial work in this
multithreaded process is unsound. (common/process.rs)
47 gui / 8 headless tests pass, clippy + fmt clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A newer rustfmt wraps over-long match arms and call expressions that the
version main was last formatted with left on one line. Pure formatting,
no semantic change — split out so the friends-list feature commits stay
focused on real changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both host and viewer hardcoded presets::N0, pinning every session to the
bundled relays (which on iroh rc.0 are the canary-grade defaults). Add a
shared common::endpoint::bind() that keeps N0's DNS discovery + crypto but
swaps in a RelayMode::Custom single-relay map when --relay (or the
PIXELPASS_RELAY env var, so GUI children inherit it) is set.
Lets users point at a self-hosted relay or staging today; the production
relays (*.relay.iroh.network) speak a newer protocol that rc.0 rejects
("invalid iroh-relay version header"), so they only become usable — and
the default — after an iroh GA bump. Verified: override connects cleanly
through staging; bad URLs are rejected before any network work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
shutdown() and Drop had byte-identical bodies that had to be kept in
sync. Extract a private cleanup(&mut self); shutdown() consumes self and
calls it, Drop calls it as the backstop. Every step is a take(), so the
second run is a no-op.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previously a single EMFILE / EINTR on listener.accept() returned from
run_accept_loop entirely, killing the host's HTTP viewer fanout for the
rest of the session. Most accept errors are transient — log and loop.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Track viewers by endpoint id instead of a bare count. The JSON event
stream gains viewer_joined / viewer_left (each carrying the id),
replacing viewer_count; active/max still ride along so the count
display is unchanged.
The host screen now renders one row per connected viewer with a Kick
button. Clicking it sends `kick <id>` to the headless child over a new
stdin command channel, which the host turns into a per-viewer
CancellationToken cancel; the existing teardown path then emits the
leave, so a kick and a self-disconnect look identical downstream.
The stdin channel only runs under --output json (the GUI shell-out) and
on a detached OS thread, so a read parked on stdin can't hold up the
host's Ctrl+C shutdown.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds common/output.rs: a process-global JSON-lines emitter for
non-interactive front-ends. With --output json, host and viewer emit one
JSON object per line on stdout (ticket, host_info, viewer_count, capture
start/stop, viewer_refused, connected), flushed per line; the human banner
and tracing logs stay on stderr so the two never interleave. No-op when the
flag is absent, so call sites emit unconditionally.
This is the shell-out counterpart to an in-process event channel: the
upcoming --gui front-end re-execs this binary as `pixelpass --host
--output json` and parses these lines to drive its window. serde_json was
already in the tree from the bandwidth pre-flight.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Window size in the viewer is an unreliable proxy for the encoded
resolution (mpv clamps/scales to the screen), making it hard to tell
whether a preset's downscale actually took effect. Log the concrete
decision host-side when capture spawns:
- "downscaling video from=1920x1080 to=1280x720" when scaling,
- "encoding at native resolution" for Source,
- "source already at/below preset height" when no upscale is needed,
- the unknown-dims fallback case too.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Live medium-quality stream errored with "negotiation problem" on the
host and rendered a squashed, garbled picture in the viewer. Two causes,
both from inserting videoscale before videoconvert with PAR+range caps:
- videoscale was scaling pipewiresrc's raw output directly. The portal
source's format/memory (e.g. DMABuf) isn't something software videoscale
negotiates — the original pipeline always fed pipewiresrc through
videoconvert first. Move videoscale *after* videoconvert so it operates
on system-memory NV12/I420.
- `pixel-aspect-ratio=1/1` + a width range over-constrained negotiation
and risked a non-square-PAR / distorted result. Instead compute an exact
even WxH from the known source dimensions (Wayland: portal size; X11:
root/window geometry), preserving aspect, and pin it fully in the caps.
This is also downscale-only now — a source already at/below the target
height is left native instead of upscaled. Unknown dims (rare X11
geometry failure) fall back to the height-only + square-pixel + even
width-range negotiation.
source_dims threaded through pipeline::spawn from both backends. Smoke
test updated to mirror the new ordering (1920x1080 -> 852x480, videoscale
after videoconvert) and still asserts an even sub-source width.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add a host-global quality knob (Discord-style) so the sharer can trade
resolution + bitrate for upload bandwidth. Quality is host-global by
design: one encode pipeline fans out to every viewer, so per-viewer
quality is out of scope (it would kill the broadcast fanout).
- New `--quality source|high|medium|low|auto` (ValueEnum) bundling a
(max-height, bitrate, fps) tuple per preset; `auto` derives the preset
from the saved bandwidth pre-flight (safe_mbps / viewer cap), falling
back to `medium` when unmeasured. Default is auto; the interactive
Host branch shows a picker when --quality is omitted (mirrors pick_app).
- `--max-height N` raw override; `--bitrate`/`--framerate` changed to
Option so an explicit flag overrides just that field of the preset
(precedence rule), leaving the rest of the preset intact.
- host/quality.rs: Preset table + resolve(); pure resolve_auto() split
from the config read for testability. 5 unit tests lock preset
pass-through, the Auto ladder, the unmeasured fallback, and override
precedence.
- pipeline::build_args inserts `videoscale ! video/x-raw,height=N,
pixel-aspect-ratio=1/1,width=[2,8192,2]` only for non-Source presets.
PAR 1/1 forces a proportional downscale (without it videoscale keeps
full width and squashes PAR — no bandwidth win); the even-stepped width
range + even-rounded height satisfy H.264 4:2:0. EffectiveQuality is
threaded capture -> wayland/x11 -> pipeline; max_viewers is now sized
against the effective (post-preset) bitrate.
- Banner gains a quality line (preset label + ≤Np/kbps/fps + provenance).
- deps.rs checks `videoscale`; smoke-pipeline.sh adds a 1080->480
downscale check asserting an even width below source.
- README: --quality preset table, Auto behavior, host-global note,
--max-height/--bitrate/--framerate override precedence.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
pixelpass is a screen-share tool meant to be paired with a dedicated
voice app (Mumble, TeamSpeak, Discord, …) for two-way talk — it never
mixes a mic. The --mic flag was declared, shown in the host banner, and
documented as working, but was never wired into the gst pipeline (a
no-op). Removed the flag from Cli + HostOpts + into_host_opts, dropped
it from the banner capture summary, and replaced the README's "--mic
mixes the mic" claim with an explicit out-of-scope note.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extract the display-agnostic encode/mux tail out of wayland.rs into a new
host/pipeline.rs: CaptureHandle + lifecycle, audio routing setup, the gst
arg builder, the spawn, and Serve::bind now live there. Backends supply
only their video-source element args plus a post-spawn hook (Wayland uses
it to close its leaked pipewire fd; X11 passes a no-op). capture.rs
collapses to a thin dispatcher; its CaptureHandle enum is gone.
Add host/x11.rs: ximagesrc (use-damage=false show-pointer=true), whole
root window by default or a single window via --window (xwininfo
click-picker → xid). x11rb reads geometry for an info log, justifying the
previously-vestigial dep. No portal, no fd dance — capture starts
silently when the first viewer connects (the ticket is the access
control). Viewer is display-agnostic and unchanged.
Wire --no-hwencode for real (was a no-op): the shared tail now selects
x264enc(tune=zerolatency,ultrafast)/I420 vs vah264enc/NV12 and switches
the videoconvert target format to match. Applies to both backends.
deps.rs: check_host_binaries now takes &HostOpts and checks shared
elements for both backends, encoder by --no-hwencode, source per backend
(pipewiresrc/ximagesrc), and xwininfo only when X11 + --window. Install
hints added for x264enc, ximagesrc, xwininfo.
Verified: warning-free build; smoke test still passes (tail unchanged);
ximagesrc + both encoder tails produce mpv-decodable H.264 against an
Xwayland root. Interactive cross-machine end-to-end pending.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The host ticket embedded every direct IP candidate the endpoint
discovered — on this machine that was 10 addrs, 7 of them useless
Docker-bridge gateways (172.16.0.0/12) plus LAN/public v4/v6. That
bloated the ticket to ~320 chars and leaked local network topology to
whoever received it.
Keep only the endpoint id + relay URL (~140 chars). The relay
coordinates hole-punching to a direct path after connect, so peer
reachability is unchanged; the direct addrs in the ticket only ever
shaved a moment off the first connection attempt, and n0 DNS discovery
already publishes the full addr keyed by id as a backstop.
Await endpoint.online() (15s cap) before building the ticket so the
relay URL is reliably populated; a relay outage degrades to a
possibly-incomplete ticket rather than a hang.
Experimental — isolated on feat/short-ticket pending an end-to-end
cross-machine connect test before merging to main.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Subscribe registry.global_remove so we know when routed stream nodes
vanish; drop them from routed_node_ids and emit LastRoutedStreamGone
on the N→0 transition. Tokio side re-runs `pactl load-module
module-loopback` with the same args as start, restoring the
default-sink monitor mirror so the viewer hears system audio again
instead of going silent when the routed app exits mid-session.
FirstRoutedStream now fires on every 0→N transition (not just the
first), so the pair oscillates cleanly: each app open/close cycle
unloads → re-loads the loopback.
Verified cross-machine 2026-05-22 16:29 EDT — host with Strawberry
picked, laptop viewer over mpv with YouTube playing on host as a
control. Strawberry audible on laptop, YouTube silent (route active).
Quit Strawberry → YouTube became audible (loopback restored).
Reopened Strawberry → routed again, YouTube dropped out (loopback
unloaded). Clean Ctrl+C teardown.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When opts.app is set, a dedicated OS thread runs a libpipewire
MainLoop, subscribes to the registry, and writes target.object to
the "default" metadata so WirePlumber reroutes matching streams to
our per-PID null-sink. Activation is now opts.app.is_some() OR the
existing PIXELPASS_AUDIO_VIA_NULL_SINK env var (kept for
no-filter dogfooding).
Threading: tokio side spawns a std::thread; the two sides bridge via
pipewire::channel for cmd→thread (Shutdown) and tokio::sync::mpsc
for event→tokio (FirstRoutedStream). Cross-thread quit goes through
the libpipewire channel so MainLoop is only mutated from its own
thread. Shutdown clears target.object on every routed stream before
quitting so WirePlumber doesn't log orphans.
Routing decisions:
- Filter is case-insensitive equality on application.name (predictable;
no surprise matches from substring).
- target.object is written as Spa:Id with the sink's object.serial.
- Default-sink loopback stays loaded until the first stream is
actually routed — avoids viewer silence if the user picks an app
that isn't producing sound yet. On first route, the event task
takes() the loopback module ID and unloads it.
Session 2 picker explainer + (app pick saved: ...) banner softening
both removed; banner is back to plain app-audio=NAME.
Verified end-to-end cross-machine: desktop host with Strawberry
selected, laptop viewer over mpv. Strawberry audible on the laptop;
YouTube playback started on the desktop was NOT audible on the
laptop. Routing isolates the filtered app.
Session 4 still open: recreate loopback when the last filtered stream
disappears (avoid silence), handle app-disappears-mid-session,
multi-instance, --repair coupling for orphan sink cleanup.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
list_playing_apps() shells out to `pactl -f json list sink-inputs`,
parses with serde_json, dedupes by application.name (BTreeMap for
stable ordering), returns Vec<App { name, stream_count }>.
Picker fires in interactive::run after preflight, before host::run.
Bypassed when --app NAME is on the CLI. Shows the apps with a
"per-app routing isn't live yet" explainer so users aren't surprised
that audio still captures system-wide. Empty-list path shows the
default + a "start your app first" hint so the feature stays
discoverable.
Banner softened to `system-audio (app pick saved: <name>)` when
opts.app is set — keeps the choice visible without lying about what
gets captured. Routing activation still gated on the
PIXELPASS_AUDIO_VIA_NULL_SINK env var (session 1's locked decision
#2); --app flips to that activation in session 3 once per-stream
filtering exists.
Verified end-to-end interactively: Strawberry shows up in the picker
during music playback, both default and app-pick paths advance into
the portal handshake, banner matches choice.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Session 1 of the per-app audio routing feature. Adds host/audio.rs
with a Routing struct that owns the lifecycle of two pactl-loaded
modules: a per-PID null-sink (pixelpass_capture_<pid>) and a loopback
mirroring @DEFAULT_SINK@.monitor into it at 20ms latency. Activated
by PIXELPASS_AUDIO_VIA_NULL_SINK=1 — kept hidden behind an env var
because without per-stream filtering (session 3) the user-facing
behavior of --app foo would be identical to no flag, which would
mislead users about what the flag does.
When the env var is set, wayland::start substitutes the gst pulsesrc
device from {DEFAULT_SINK}.monitor to pixelpass_capture_<pid>.monitor;
audio still works end-to-end via the loopback. CaptureHandle owns the
Routing alongside gst and serve; teardown order is gst → audio → serve
so streams unlink from the null-sink before the sink is destroyed.
Lifecycle is via pactl shell-outs rather than pipewire-rs. Null-sink
+ loopback are one-shot graph mutations with no event subscription;
the libpipewire route would mean dragging a MainLoop thread in for no
benefit until session 3 needs stream events.
Known cosmetic: the null-sink appears in Plasma's audio mixer as a
user-facing volume slider. Pactl's sink_properties= quoting is fiddly
enough that the device.hidden=true fix is parked for a follow-up.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
handle_peer's `bridge ended with error: ...` log fired at WARN every
time a viewer cleanly closed — but bridge can only end three ways
(peer-close, local-socket-close, cancellation), none of which are real
errors. Collapsed to INFO for both Ok and Err arms; the message itself
still carries any error detail.
Also removed the `--low-latency` CLI flag and its HostOpts field. It
was a placeholder for an unimplemented Phase-2/3 SRT transport, never
read anywhere, and was generating a persistent dead_code warning. If
SRT ever happens, the flag can come back fresh.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The broadcast fanout, supervisor-facing listener bind, accept loop, and
per-viewer drain were all sitting inside host/wayland.rs even though
none of it is Wayland-specific. Move them to host/serve.rs so the X11
backend can share the same serving layer with a one-line constructor
call instead of copy-pasting (and drifting on) the fanout code.
No behavior change. Wayland's CaptureHandle now wraps a serve::Serve
instead of owning the listener/reader/server fields directly; gst
pipeline construction is unchanged. connect_to_capture moves alongside
Serve since it pairs with it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
First-run host launch now offers a one-time upstream measurement
against speed.cloudflare.com/__up via ureq (~5 MB POST, ~5s). The
result lives at ~/.config/pixelpass/config.toml under [bandwidth]
and feeds the default --max-viewers calculation on subsequent runs.
Sticky semantics for the dialog:
- Unmeasured: first-run prompt (Run / Skip)
- Measured / Skipped: silent — never re-prompts
- Failed: ask again on next launch (Retry / give up → Skipped)
`pixelpass --reconfigure` re-runs the test unconditionally for users
whose connection has changed (new ISP, moved house, etc.).
--max-viewers is now Option<u32>. When unset, host startup loads the
saved measurement, runs recommended_max_viewers(safe_mbps, bitrate),
and surfaces the source in the banner: "max viewers : N (auto: X.X
Mbps measured upstream)" — or user-specified / default fallback.
User verified end-to-end on 2026-05-21 16:54 EDT: first-run dialog,
skip path, run path, --reconfigure refresh, and banner integration
all work as expected.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
One gst capture pipeline now fans out to N concurrent viewers via a
tokio::sync::broadcast<Arc<Vec<u8>>>. The HTTP listener accepts forever;
each accepted connection spawns a sender task draining its own
broadcast::Receiver. Slow consumers see Lagged and skip ahead — MPEG-TS
resyncs at the next keyframe.
Host runtime is now lazy + sticky: a supervisor task owns the capture
handle and viewer count. First viewer triggers capture::spawn; last
viewer triggers shutdown. Subsequent reconnects re-trigger the portal
dialog as expected. --max-viewers (default 2) caps concurrent viewers;
additional connections get a "host is full" refusal and are dropped.
Banner updated to reflect the new lifecycle and viewer cap.
NOT YET RUNTIME-VERIFIED. cargo build is clean and the pipeline-level
smoke test still passes, but the multi-viewer behavior (cap enforcement,
lazy-sticky restart, concurrent fanout) requires manual end-to-end
testing with the portal dialog + multiple mpv instances.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This reverts commit 0a253bd919.
The Content-Type change was a misdiagnosis. The real cause of VLC's
"no demux modules matched" was a missing `vlc-plugin-dvb` package on
the test machine — Arch/CachyOS ship the MPEG-TS demuxer plugin
(`libts_plugin.so`) in a separate package from `vlc`. Without it, VLC
falls through to the PS demuxer and misidentifies the H.264 stream.
With the package installed, `video/mp2t` opens cleanly.
`video/mp2t` is the correct Content-Type for an MPEG-TS stream and is
what we should be sending. Documentation of the package requirement
and a runtime check follow in a separate commit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
VLC parses Content-Type before invoking the demuxer chain. With
video/mp2t it commits to demux="ts" by MIME alone, bypassing
byte-probing; when the ts demuxer's Open fails on the live HTTP stream
("no demux modules matched"), the input never opens. mpv probes
regardless of Content-Type.
Reproduced deterministically with a Python shim that mimics our
response headers byte-for-byte: only the Content-Type matters.
Changing it to application/octet-stream (or any non-video MIME, or
omitting the header) makes VLC fall back to byte-probing, which
finds the TS sync pattern and opens cleanly. mpv unaffected.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bare `pixelpass` now opens a dialoguer-driven Host/View menu instead of
going straight to host mode. Host path copies the ticket to the system
clipboard via arboard with silent print-only fallback. View path
prompts for the ticket, then after the local listener binds prompts
mpv-vs-VLC and spawns it detached (setsid + null stdio) so the player
survives pixelpass exiting.
Headless invocations (`pixelpass <ticket>`, `pixelpass --repair`)
unchanged. Per spec at ~/Documents/pixelpass-interactive-mode-spec.md.
`pulsesrc` with no `device=` reads PulseAudio's default source —
which is the user's microphone, not system audio output. The stream
was technically working but the laptop was hearing the desktop's
mic (or silence on systems without one) instead of system audio.
At host startup, shell out to `pactl get-default-sink` to discover
the current default sink, then pass `device=<sink>.monitor` to
pulsesrc. Resolving at session-start covers users who switch outputs
(speakers vs headset vs HDMI) between sessions. pactl added to the
host's required-binary list.
Verified cross-machine: audio came through clearly with the prior
~1s latency floor preserved.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Symptom: cross-machine streams drifted ~6-9 seconds per minute. mpv
showed continuous "Audio device underrun detected" with video packets
piling up in its demuxer queue while audio queue stayed at 0.
Root causes:
- pipewiresrc captured at the host monitor's refresh rate (180Hz),
not at the configured framerate. The encoder + mux produced 6x more
frames per wallclock second than mpv could consume at realtime,
burying audio packet density in mpv's demuxer queue.
- `--profile=low-latency` sets `audio-buffer=0`, which is too
aggressive — any sub-millisecond network jitter immediately
starves the audio device. Underruns slowed mpv's audio clock, and
with `video-sync=audio` (also from low-latency profile) video
followed.
Fixes:
- `videorate ! video/x-raw,framerate=<fps>/1` after pipewiresrc to
cap input to the requested rate deterministically.
- mpv command grows `--audio-buffer=0.2 --demuxer-max-bytes=2M
--demuxer-readahead-secs=0.5`: small audio buffer to absorb network
jitter, demuxer cap to prevent runaway buildup.
A leaky-queues attempt landed and was reverted in the same commit —
it removed backpressure without addressing the root cause and made
things worse.
Verified cross-machine 6m51s: drift held at ~1s floor, zero audio
underruns, perfect A-V sync.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous ffmpeg-as-HTTP-server pipeline shape held back two
improvements at once. ffmpeg as the runtime server lost a one-shot
`-listen 1` accept to a probe-and-discard health check, and forced
us to size analyze/probe budgets carefully so ffmpeg would serve
before our deadline. Replacing it with a small tokio task that
accepts once, drains the HTTP request, writes a fixed 200 OK, then
`tokio::io::copy`s gst stdout to the socket removes all of that.
VAAPI H.264 (vah264enc) drops CPU encode from ~50% of a core to
single-digit %. An earlier attempt at vaav1enc had to be abandoned:
libavformat cannot demux AV1-in-MPEG-TS with the custom mapping
even with a 20MB probe budget — mpv reports video=eof. H.264 keeps
the hardware win on the well-trodden demuxer path.
scripts/smoke-pipeline.sh mirrors the runtime pipeline with
videotestsrc/audiotestsrc into a file and asserts that mpv reports
`video=playing` (not video=eof). The naive --frames=10 check was
a false positive when no video stream is recognized; the verbose
grep is the real gate.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Moves the full capture+encode+mux pipeline into gst-launch, leaving
ffmpeg as a thin HTTP server. Verified end-to-end on KDE Plasma 6
Wayland: screencast portal → mpv mirror-tunnel rendering in real time.
Pipeline:
pipewiresrc(do-timestamp) → videoconvert → x264enc (zerolatency
ultrafast) → h264parse(config-interval=-1) → byte-stream caps →
mpegtsmux ← (aacparse ← avenc_aac ← audioconvert ← pulsesrc) →
fdsink fd=1
ffmpeg -fflags nobuffer+discardcorrupt+genpts -flags low_delay
-analyzeduration 0 -probesize 32 -f mpegts -i pipe:0 -c copy
-f mpegts -listen 1 http://127.0.0.1:N
Why each piece is load-bearing (do not relitigate without cause):
- x264enc + h264parse + byte-stream caps: raw video over a pipe hits
stride/format negotiation problems (green screens with mis-aligned
rows). Encoding inside gst sidesteps that entirely.
- mpegtsmux inside gst: H.264 Annex B carries no timestamps. Without
a container, ffmpeg sees "Timestamps are unset" and downstream
muxing breaks. mpegts in gst preserves pipewiresrc's clock.
- byte-stream + alignment=au caps: h264parse defaults to AVC format
(length-prefixed NALUs) for some downstreams; ffmpeg's mpegts
demuxer needs Annex B start codes.
- audio in gst (pulsesrc + avenc_aac): keeping ffmpeg as a pure
passthrough (`-c copy`) avoids ffmpeg's audio-input dependency
delaying HTTP serving until both inputs are ready.
- `-analyzeduration 0 -probesize 32`: stop ffmpeg from buffering 5MB
/ 5s of input before deciding it understands the stream.
- Also fixes a separate one-shot bug from earlier: the previous
health-probe in wait_for_listener consumed ffmpeg's single
`-listen 1` accept slot, so the actual bridge connect hit
Connection refused. Replaced with connect_to_capture which
returns the bridge socket.
Adds dep checks for pipewiresrc, x264enc, h264parse, mpegtsmux,
pulsesrc, avenc_aac, aacparse with per-distro install hints.
Known gap: VLC currently shows a green screen against the stream
even though mpv works fine. Likely VLC-specific demuxer/latency
settings, not a pipeline correctness issue — to investigate as a
follow-up. mpv is the recommended client either way.
Implements the Wayland host pipeline from plan §4.5:
ashpd ScreenCast portal
-> CreateSession + SelectSources + Start + OpenPipeWireRemote
-> (pipewire fd, node_id, width, height)
gst-launch-1.0 pipewiresrc fd=N path=NODE_ID ! videoconvert
! video/x-raw,format=NV12 ! fdsink fd=1
ffmpeg
-f rawvideo -pix_fmt nv12 -video_size WxH -i pipe:0
-f pulse -i default
-c:v libx264 -preset ultrafast -tune zerolatency
-c:a aac -f mpegts -listen 1 http://127.0.0.1:<rand>
Phase 1 ships software x264 per plan §7; VAAPI is Phase 2.
src/host/wayland.rs is the new module. capture.rs becomes a thin
dispatcher with a CaptureHandle enum (Wayland today, X11 next).
host/mod.rs swaps the 150ms sleep for a poll-until-listener-ready
helper, and calls handle.shutdown().await for an orderly SIGTERM /
1s grace / SIGKILL teardown. The Drop impl is the panic backstop.
The pipewire fd handoff clears CLOEXEC before gst-launch spawn and
closes the parent's copy of the raw fd after the child has it.
Also deletes the empty src/host/tunnel.rs and src/viewer/tunnel.rs
placeholder files — the generic bridge in common/tunnel.rs is doing
the work, and there's no host- or viewer-specific tunnel concern
worth a module yet.