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>
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>