Author SHA1 Message Date
molluskandClaude Opus 4.8 87de5213fe audio: cover ordinary serial lengths in parse tests
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>
2026-07-21 16:00:34 -04:00
molluskandClaude Opus 4.8 9b6c8bb5c3 audio: parse object.serial as u64 (phase 0a)
`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>
2026-07-21 15:47:30 -04:00
molluskandClaude Opus 4.8 40604c716c debug: add PIXELPASS_TS_DUMP tap for A/V drift analysis
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>
2026-07-19 14:49:42 -04:00
mollusk 3b92bcbe52 chore: update dependencies for RustSec advisories 2026-07-15 06:29:05 -04:00
molluskandClaude Opus 4.8 b6240c17c5 viewer: drop forced --hwdec=auto (froze video on frame 1)
The screen-share viewer ran mpv with --profile=low-latency (hwdec off by
default) and then forced --hwdec=auto back on. On some drivers the HW H.264
decoder stalls mid-stream: a viewer receiving a software-x264 share froze on
the first frame while audio kept playing (one MPEG-TS byte stream, so bytes
were still flowing — the video decoder gave up, the audio decoder didn't).
Screen-share H.264 at these bitrates decodes trivially in software, so leave
hwdec at the low-latency default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 21:15:11 -04:00
molluskandClaude Opus 4.8 c1b21b32c7 Add pixelpass --doctor environment diagnostic
Screen-share failures are usually environment gaps, not pixelpass bugs —
most often a GPU/driver with no working VA-API H.264 encoder, so the
default vah264enc pipeline produces no video and the viewer "can't
connect." doctor probes the whole chain and prints one actionable report
so a remote tester can read it over a call instead of us guessing from
logs, and it validates any X11/Wayland test environment we stand up.

Checks (each a ✓/!/✗ line with a distro-aware install hint):
- display server (Wayland/X11 + session env), and the X server vendor/
  version so an xlibre server is distinguishable from stock Xorg
- capture: gst tools + the backend's source element (pipewiresrc/ximagesrc)
- encode: hardware H.264 (vah264enc + DRM render node + a VA-API H.264
  *encode* entrypoint parsed from vainfo) and the software x264 fallback
- mux/audio tail + pactl
- viewer player (mpv/vlc)
- network: binds a real endpoint and checks relay reachability

Unlike deps::check_host_binaries (bails on first miss), doctor runs every
check and reports them together. Closes with a specific hosting verdict and
exits non-zero on any hard failure so scripts/CI can gate. Pure seams
(vainfo entrypoint parse, summary tally, hosting verdict) are unit-tested;
deps.rs gained pub(crate) which/gst_element_exists/install-hint/distro
helpers so doctor reuses the same package-name knowledge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 03:19:06 -04:00
molluskandClaude Opus 4.8 b0ff20fe3f host/x11: default to XDamage capture; drop --untimed from viewers
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>
2026-07-03 20:29:44 -04:00
molluskandClaude Opus 4.8 b5c03e7705 fix(host): let the sharer hear the app they're sharing (local monitor)
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>
2026-07-03 19:03:17 -04:00
molluskandClaude Opus 4.8 31b33e9e5a docs(deb): document the Debian .deb build environment
Companion to peerspeak's packaging/debian/README. Captures the shared bookworm
distrobox build, the box-local CARGO_TARGET_DIR, and — most importantly — why
the GStreamer capture stack is hard-coded into Depends (invoked as subprocesses,
invisible to dpkg-shlibdeps).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 00:03:49 -04:00
molluskandClaude Opus 4.8 e16b7190bb packaging: build from public gitbutter repo instead of local path
The PKGBUILD url + source pointed at file:///home/mollusk/git/butter/pixelpass,
a local-only path no one else could build from. The repo is public on
gitbutter, so point both at the anonymous HTTPS clone URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:59:11 -04:00
molluskandClaude Opus 4.8 c39ab081d9 packaging: pull GStreamer capture stack into .deb runtime deps
pixelpass invokes the GStreamer tools and pactl as subprocesses, not as
linked libraries, so dpkg-shlibdeps (`depends = "$auto"`) never sees them.
On a fresh Ubuntu host that means `deps::check_host_binaries` bails before
the host emits its ticket — peerspeak then reports the generic "pixelpass
host exited before emitting a ticket" (first 2-human field hit, 2026-06-26).

List the runtime stack explicitly so `apt install ./pixelpass.deb` pulls in
gstreamer1.0-{tools,plugins-base,plugins-good,plugins-bad,plugins-ugly,libav,
pipewire,pulseaudio}, pulseaudio-utils and x11-utils. Recommends mpv.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 23:10:08 -04:00
molluskandClaude Opus 4.8 646f35d3eb host/audio: emit initial app_audio "lost" at strict capture start (A23 P2/F1)
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>
2026-06-26 22:09:58 -04:00
molluskandClaude Opus 4.8 ff7daee34e packaging: add cargo-deb metadata for Debian/Ubuntu .deb builds
Add a [package.metadata.deb] block so the headless default build (no `gui`
feature) — the variant peerspeak spawns as a child — can be packaged with
`cargo deb` from inside a Debian/Ubuntu distrobox. Ships only the pixelpass
binary; runtime shared-lib deps resolved by dpkg-shlibdeps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:30:17 -04:00
molluskandClaude Opus 4.8 85fdebeb66 feat(audio): add --strict-audio + app_audio route-status events
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>
2026-06-26 17:44:02 -04:00
molluskandClaude Opus 4.8 cfc480044f fix: three robustness bugs outside the friends list
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>
2026-05-31 15:27:07 -04:00
molluskandClaude Opus 4.8 6d0bf99076 fix(friends): five robustness bugs in the friends/control plane
Found in a bug audit of the just-merged friends-list feature. No crashes
or security holes, but five real state/correctness bugs:

- Host child dying on its own left the share campaign running, so it kept
  pushing a now-dead ticket to friends (retrying offline ones forever) and
  leaked share_status/met/share_code. The unexpected-exit path now captures
  the stderr error, then routes through the full stop_host() teardown
  (notably stop_share). (gui/mod.rs pump_host_events)

- on_friend_request downgraded an already-Accepted friend back to
  PendingIncoming when they re-sent a request (e.g. after losing their
  store). It now stays Accepted and re-confirms. (friends.rs)

- on_friend_accept advanced *any* known peer to Accepted, including a
  PendingIncoming one — a peer could mark itself accepted without the local
  user's consent. Now only a PendingOutgoing request we sent is honoured.
  (friends.rs)

- A ShareCode redelivered by an ACK-loss retry fired a duplicate desktop
  notification. push_notice now reports whether the code is new/changed and
  only then toasts. (gui/mod.rs)

- An inbound control message could be delayed up to IO_TIMEOUT on a degraded
  link because handle() awaited the sender's close before forwarding it.
  Forward to the UI first, then await close so the ACK still flushes.
  (control.rs)

Adds two friends-store transition tests (accept ignores a pending-incoming
peer; request doesn't downgrade an accepted friend). 47 gui / 8 headless
tests pass, clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:10:22 -04:00
molluskandClaude Opus 4.8 04bc0a808a perf(friends): fire share-campaign rounds concurrently
run_share tried offline peers one at a time, so a single unreachable
friend's ~10s control-plane connect timeout serialised the whole round
(N offline peers → up to N×10s per round). Spawn each round's sends into
a JoinSet and collect as they finish: a round now takes ~one timeout
regardless of how many friends are offline. Delivery receipts are still
emitted one-per-peer as each ACK lands; the code is shared across tasks
via an Arc instead of re-cloning the payload per peer per round.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 22:01:42 -04:00
molluskandClaude Opus 4.8 b0fa259187 feat(friends): persist per-friend share preference across launches
The host's "auto-share my code with" picker was session-scoped — an
in-memory exclusion set that reset to share-with-all on every launch.

Move the preference onto the friend itself: a `share: bool` on `Friend`
in friends.toml, `#[serde(default = true)]` so new friends are included
and an older file without the field loads as share-with-all. The picker
now toggles the stored flag and persists immediately (like the other
settings), and `selected_share_targets` filters on it. This drops the
parallel `share_excluded` state and is self-cleaning: removing a friend
takes their preference with them, no stale ids linger.

`upsert`'s update path leaves `share` untouched, so a name/presence
refresh can't reset the user's choice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 22:01:42 -04:00
molluskandClaude Opus 4.8 1a746461b4 feat(friends): push share codes to friends on hosting + receive bell (phase 4)
The payoff phase: on Start-Hosting, auto-push the wrapped share code to the
selected accepted friends, and surface codes friends push us in a bell.

Sending (host side):
- A share-target picker on the host form lists accepted friends as checkboxes;
  selection is stored as the *exclusion* set so the default ships to everyone
  and a friend added mid-session is included automatically.
- When the child reports its ticket, the wrapped code is pushed to the selected
  friends, gated by FriendStore::is_accepted.
- Delivery is online-now + retry-while-hosting: the presence service runs an
  abortable share campaign that retries offline friends every 5s until they're
  reached or hosting stops. The control-plane ACK is the delivered/failed
  signal; each success emits a ShareDelivered receipt.
- The running host screen shows a live "delivered ✓ / offline, retrying" row
  per targeted friend.

Receiving (viewer side):
- The previously-stubbed ShareCode handler now honours codes from accepted
  friends only, records a notice (deduped per friend), and fires a desktop
  notification.
- A top-right bell with a white-on-red badge counts pending notices; its panel
  lets you Watch a code (opens the viewer with it prefilled) or dismiss it.

Presence service refactor: the fire-and-forget Outbound becomes a Command enum
(Send / StartShare / StopShare) and the UI now drains a PresenceEvent enum
(Message / ShareDelivered) over one unified async→sync bridge.

Tests: +2 for the share-target selection rule (43 gui pass). clippy + fmt clean
on both feature sets; smoke-launch shows the control endpoint online, no panic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 17:01:19 -04:00
molluskandClaude Opus 4.8 9e839ca452 feat(friends): friend store, mutual handshake, and Friends UI (phase 3)
Build the friends feature on top of the phase-2 control plane: you can
now befriend someone you've connected with and manage a contacts list.

- common/friends.rs: a persisted FriendStore in its own friends.toml
  (kept out of config.toml so a headless --reconfigure can't clobber it,
  same as identity.key). Friends are keyed by stable control EndpointId;
  state is PendingOutgoing / PendingIncoming / Accepted. The handshake
  transitions (on_friend_request → mutual-match detection, on_friend_
  accept) are pure and unit-tested.

- gui/code.rs: the bootstrap. The GUI host wraps its share code as
  `pixelpassF1:<control-id>.<ticket>` so a viewer learns the host's
  stable id; unwrap is lenient, so a bare/CLI ticket still works (no
  friend offer). The video/streaming path is untouched.

- presence service gains an outbound path (unbounded channel → per-msg
  send tasks) and exposes our control id for wrapping codes.

- gui wiring: on connect, the viewer announces itself to the host with a
  Hello (carrying our display name); the host replies once, so both ends
  learn each other and an "Add friend" offer appears on the running
  host/view screens. Incoming requests/accepts/declines fold into the
  store with desktop notifications. New Friends screen (accept/decline/
  remove, edit your display name, see your id) reachable from the menu,
  which shows a pending-request count. New [gui] display_name setting,
  seeded from $USER.

Verified: friends store + handshake transitions covered by unit tests
(7); code wrap/unwrap round-trips (4); the control loopback still passes;
the live GUI starts clean with the presence endpoint online. fmt +
clippy clean on both features; 41 gui + 8 headless tests pass. The full
two-party UX (connect → mutual add → persisted) wants a cross-machine
manual check, as usual.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 16:42:14 -04:00
molluskandClaude Opus 4.8 f5d0333366 feat(friends): always-on control plane for the presence service (phase 2)
Stand up the friends control plane: a persistent-identity iroh endpoint
that's online for the whole GUI session, separate from the ephemeral
video sessions, ready to carry friend requests and pushed share-codes.

Identity split by plane (common/endpoint.rs): the video plane (host/
viewer) goes back to ephemeral per-session keypairs, while the new
bind_control() binds with the machine's persistent identity. They must
differ — the GUI's control endpoint and a host's video endpoint can be
live at once, and iroh routes by EndpointId, so a shared id would make
relay delivery ambiguous. Bonus: a screen-share now leaks no stable id.

common/control.rs — the protocol: a ControlMsg enum (Hello / Friend
Request / FriendAccept / FriendDecline / ShareCode) with one-message-
per-connection framing (EOF-delimited JSON) and a one-byte ACK the
receiver returns only after a successful parse, so send() gets a real
delivered/failed signal (the basis for the later code-push queue). The
sender id is taken from the connection's verified remote key, never the
payload. send() takes impl Into<EndpointAddr> so production dials a bare
EndpointId (discovery resolves it) while tests use a full addr.

gui/presence.rs — the service: a dedicated thread + current-thread tokio
runtime (mirroring the tray) binds the control endpoint and runs the
accept loop, bridging inbound messages to a std mpsc the UI drains each
tick and pinging the Waker so they land even while hidden to the tray.

The whole friends stack (identity, control, CONTROL_ALPN, bind_control)
is gated behind the `gui` feature — a headless CLI host runs no presence
service — keeping the headless build lean and warning-free.

Verified: loopback test delivers a FriendRequest across two real iroh
endpoints with the correct authenticated sender id; the live GUI binds
its control endpoint on launch under the persistent identity. fmt +
clippy clean on both feature sets; headless and gui test suites pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 16:25:45 -04:00
molluskandClaude Opus 4.8 14fc1af716 style: apply current rustfmt to the tree
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>
2026-05-30 16:25:33 -04:00
molluskandClaude Opus 4.8 9b9328f6a9 feat(identity): persist a stable node identity across launches
iroh otherwise mints a fresh keypair every run, so a peer's EndpointId
changed on each launch. The friends system (in progress) identifies
people by that id — the public key already embedded in every share
code — so it has to stay stable across launches and across roles.

Add common::identity: load-or-create an ed25519 secret key stored as
hex in a 0600 ~/.config/pixelpass/identity.key, separate from
config.toml so a config reset or hand-edit can't clobber it. A
malformed file is a hard error rather than a silent regenerate, since
quietly minting a new identity would orphan every existing friend.

endpoint::bind() now feeds this key to the builder, so host and viewer
share one stable EndpointId. Verified end-to-end: two launches against
the same config dir emit byte-identical tickets; a fresh dir differs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 15:55:09 -04:00
molluskandClaude Opus 4.8 2d0143f1aa feat(gui): keyboard shortcuts + a Shortcuts reference screen
Window-focused shortcuts via a handle_keys() dispatch: H/V/S on the menu,
Space/Enter to start hosting and C to copy the code on the Host screen, F1
to open the new Shortcuts screen, and Esc to back out (existing). Letter/Space
actions only fire when no widget holds focus, so they don't clash with typing
or egui's Space/Enter widget activation; popups keep their own key handling.
New Screen::Shortcuts lists every binding behind a menu button. Also enforce
that leaving Settings closes the theme editor, so a draft preview can't leak.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 05:21:47 -04:00
molluskandClaude Opus 4.8 e8273b364e feat(gui): Esc backs out one level
Pressing Esc on the Host/View screens stops the session and returns to the
menu (mirroring the '← Menu' button); on Settings it returns to the menu, or
closes the theme editor back to the picker if one is open. Suppressed while a
popup (colour picker, dropdown) is open so Esc just dismisses the popup there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 05:10:16 -04:00
molluskandClaude Opus 4.8 472991c11f feat(gui): group the theme editor into labelled sections
The editor was one flat 13-row colour list. Split it into Surfaces / Text &
accent / Buttons / Status colours, each a bold subheading over its own grid,
via a new color_section helper. Pure layout — no behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 05:02:05 -04:00
molluskandClaude Opus 4.8 ccd8c33f81 fix(gui): make the window background and weak-text colours actually apply
Two theme fields had no visible effect:
- window_bg mapped only to egui's window_fill, but the app draws on the bare
  background layer with no panel, so that's never painted — the real backdrop
  was a hardcoded GL clear colour. Paint a themed background rect (window_bg)
  behind everything in draw() instead.
- weak_text was dead: egui's weak_text_color() derives from the text colour
  unless Visuals::weak_text_color is set, which it wasn't. Set it.

Audited the rest (panel/input bg, text, accent, button, hover, and the five
status colours) — those already resolve to the right egui fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 04:04:33 -04:00
molluskandClaude Opus 4.8 c876c61ec6 fix(gui): scroll the Settings body and add a Defaults reset to the editor
The Settings screen grew past a short window once the Appearance section
landed, forcing a manual resize to reach the Save button. Wrap the body in a
vertical ScrollArea (header stays pinned), mirroring the Host screen. Also
add a '↺ Defaults' button to the right of Save in the theme editor that
resets the draft to the original Default Dark palette (previews live).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 03:09:40 -04:00
molluskandClaude Opus 4.8 b1d73caedf docs(readme): document the GUI theme system
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 02:51:04 -04:00
molluskandClaude Opus 4.8 40960c7476 feat(gui): apply themes live + theme picker and in-app editor
Load the saved theme at startup and apply it to egui's visuals (cloning the
global style so the font scaling is preserved); the egui context persists
across the hide/show window cycle, so it sticks. Route the previously
hardcoded status colours (streaming/waiting/success/warning/error) through
the active theme so a theme re-skins the whole app, not just the chrome (the
QR code stays black-on-white so it remains scannable). Settings gains an
Appearance section: a picker that switches themes live and persists the
choice, and an editor with a colour button per palette field, a live
preview, and Save (writes a .toml). The picker refreshes from disk when
Settings opens, so dropped-in files appear without a restart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 02:51:03 -04:00
molluskandClaude Opus 4.8 0a4bb554e9 feat(gui): add a colour theme model with built-ins and file I/O
New gui::theme module: a Theme is a curated semantic palette (backgrounds,
text, accent, button, and the status colours) that serialises to TOML with
#rrggbb hex colours and builds an egui::Visuals. Missing fields fall back to
the built-in Default Dark via #[serde(default)], so partial/hand-trimmed
files still load. Three built-ins ship (Default Dark, Catppuccin Mocha,
Catppuccin Latte); user themes live as *.toml in ~/.config/pixelpass/themes/
and a user file overrides a built-in of the same name. Adds a `theme` field
to the GUI config (default "Default Dark"). Zero new deps (toml + a few
lines of hex parsing). 6 unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 02:51:03 -04:00
molluskandClaude Opus 4.8 7e470fb2c5 docs(readme): document --relay / PIXELPASS_RELAY
Add a Relay section covering the flag and env-var forms, precedence,
the GUI-child forwarding, and the same-relay-on-both-ends requirement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 02:23:25 -04:00
molluskandClaude Opus 4.8 9d685c2c48 feat(gui): forward --relay flag to host/viewer children
gui::run() dropped the parsed --relay value, so a relay chosen on the
GUI command line (pixelpass --gui --relay URL) never reached the
headless host/viewer children -- only the PIXELPASS_RELAY env-var form
propagated (via inheritance). Thread the flag into the app and append
--relay <url> to both child arg vectors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 02:23:25 -04:00
molluskandClaude Opus 4.8 42ffe8928c fix(deps): recognise Artix and Garuda for install hints
detect_distro only matched arch/cachyos/manjaro/endeavouros, so on
Artix (Friend 2's box) and Garuda the missing-dependency hints fell
through to the generic message instead of a pacman command. Both are
pacman-based with identical package names, so add them to every
Arch-family match arm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 02:23:25 -04:00
molluskandClaude Opus 4.8 69ddc58133 feat(packaging): honour CARGO_TARGET_DIR + document distrobox build
build-appimage.sh now reads the binary from CARGO_TARGET_DIR when set, so a
broad-compat build inside an old-glibc distrobox can use an isolated target
dir without clobbering the host's. README documents the Ubuntu 24.04
distrobox recipe and why older bases don't work (the pipewire crate needs
PW >= ~1.0 headers; and a PipeWire/portal app can't run on ancient distros
anyway). Resulting baseline: glibc 2.39 (the only 2.39 symbols are weak
pidfd refs from Rust std; everything else is <= 2.35).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 16:29:05 -04:00
molluskandClaude Opus 4.8 09a07f5303 feat(packaging): add a thin AppImage build
build-appimage.sh produces pixelpass-<version>-x86_64.AppImage via
linuxdeploy. PixelPass links almost nothing (only libpipewire, which is
excludelisted) and shells out to gst-launch-1.0/pactl/a player on the host
PATH, while its GUI graphics libs are dlopen'd and also excludelisted — so
the AppImage bundles just the binary, AppRun, desktop entry, and icon
(~13 MB, zero bundled libs). The no-sandbox model lets the bundled binary
spawn the host's tools, which is why AppImage fits this orchestrator better
than Flatpak.

AppRun opens --gui when launched with no args and no controlling terminal
(file manager / .desktop), and passes through otherwise so the CLI,
interactive menu, and viewer all work. README documents the host-deps
contract + the glibc-baseline and VAAPI caveats.

Verified: builds to 13 MB; --version/--help work; the GUI launches cleanly
on a live Wayland session via both --gui and the no-tty AppRun path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 16:10:02 -04:00
molluskandClaude Opus 4.8 eb077d81f0 feat(relay): add --relay / PIXELPASS_RELAY override
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>
2026-05-28 15:59:36 -04:00
molluskandClaude Opus 4.8 32131b0ccb fix(bandwidth): floor recommended viewers to 1 on non-finite input
recommended_max_viewers() promises "at least 1", but a NaN safe_mbps cast
to 0 and an infinite one to u32::MAX. Guard non-finite / non-positive
inputs up front. Add unit tests covering the normal path, the floor, and
the NaN/Inf/negative degenerate cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 15:40:05 -04:00
molluskandClaude Opus 4.8 035aa4b256 fix(signal): warn instead of silently dropping a failed ctrl-c handler
install_ctrl_c() used `if ctrl_c().await.is_ok()`, so if the handler
failed to install, ctrl-c silently stopped working with no diagnostic.
Match on the Result and log a warning (then bail the task — the second
arm would only fail the same way).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 15:40:05 -04:00
molluskandClaude Opus 4.8 f85c0c22c7 refactor(audio): dedup Routing teardown into a shared cleanup()
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>
2026-05-28 15:40:05 -04:00
molluskandClaude Opus 4.8 c30418a0f5 fix(gui/tray): track watcher loss and never strand the window
The `registered` flag was one-way: set true once the tray registered, but
never cleared if the StatusNotifierWatcher later disappeared (panel restart,
tray plugin disabled, tray app killed). After that, a Wayland close-to-tray
would destroy the window into a tray that no longer exists, with no recovery
short of SIGTERM.

Implement ksni's `watcher_online`/`watcher_offline` callbacks to keep the
shared flag in sync. On offline, also force the window back (idempotent
`show()`) so a window that was already hidden when the watcher died isn't
stranded, and return true to keep the service alive for re-registration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 15:33:11 -04:00
molluskandClaude Opus 4.8 14245cbf08 fix(viewer): close the endpoint on all post-connect failure paths
open_bi/bind/local_addr/accept all `?`-propagated straight out of run(),
skipping the endpoint.close().await at the end and leaking the iroh
Endpoint on any post-connect error. Wrap the post-connect body in one
block whose result is captured, then close unconditionally — matching
the explicit-close idiom of the connect-phase select arms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 15:24:47 -04:00
molluskandClaude Opus 4.7 a740376ea9 fix(serve): continue past transient accept errors
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>
2026-05-28 05:52:48 -04:00
molluskandClaude Opus 4.7 e1ed89026d gui: Settings toggle to hide the host QR-code panel
Adds a `show_qr` preference (default on) to GuiSettings, with a
checkbox in Settings and a corresponding gate on the host-screen render.
Persists to config.toml alongside the existing close-to-tray setting.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 05:38:58 -04:00
40 changed files with 5344 additions and 828 deletions
Generated
+584 -591
View File
File diff suppressed because it is too large Load Diff
+23 -2
View File
@@ -6,12 +6,33 @@ description = "P2P screen sharing CLI over iroh"
license = "MIT OR Apache-2.0"
publish = false
# Debian/Ubuntu packaging (cargo-deb). Headless default build (no `gui` feature) —
# that is exactly what peerspeak spawns as a child. Runtime shared-lib deps
# (libpipewire, libc, …) are resolved by dpkg-shlibdeps via `depends = "$auto"`.
# Build inside a Debian/Ubuntu distrobox, then `cargo deb --no-build`.
[package.metadata.deb]
maintainer = "mollusk <jitty+lc1iz0dc@protonmail.com>"
section = "net"
priority = "optional"
# $auto covers linked shared libs (dpkg-shlibdeps). The GStreamer capture stack
# and pactl are invoked as *subprocesses* (gst-launch-1.0 / gst-inspect-1.0 /
# pactl), so shlibdeps can't see them — list them explicitly or a fresh Ubuntu
# host bails at `deps::check_host_binaries` before emitting its ticket. Covers
# both backends: pipewiresrc (Wayland), ximagesrc (X11, in plugins-good), the
# VAAPI + software H.264 encoders, the AAC/TS mux tail, and the PulseAudio src.
depends = "$auto, gstreamer1.0-tools, gstreamer1.0-plugins-base, gstreamer1.0-plugins-good, gstreamer1.0-plugins-bad, gstreamer1.0-plugins-ugly, gstreamer1.0-libav, gstreamer1.0-pipewire, gstreamer1.0-pulseaudio, pulseaudio-utils, x11-utils"
recommends = "mpv"
extended-description = "Peer-to-peer screen sharing over iroh (QUIC). Companion to peerspeak: shares a window or screen directly to a peer with no central server, driven via the CLI and its JSON event stream."
assets = [
["target/release/pixelpass", "usr/bin/", "755"],
]
[[bin]]
name = "pixelpass"
path = "src/main.rs"
[dependencies]
iroh = "1.0.0-rc.0"
iroh = "1.0.2"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-util", "net", "signal", "process", "sync", "time"] }
tokio-util = { version = "0.7", features = ["io"] }
clap = { version = "4", features = ["derive"] }
@@ -27,7 +48,7 @@ ashpd = { version = "0.9", default-features = false, features = ["tokio"] }
pipewire = "0.9"
x11rb = { version = "0.13", default-features = false, features = ["allow-unsafe-code"] }
uuid = { version = "1", features = ["v4"] }
iroh-tickets = "1.0.0-rc.0"
iroh-tickets = "1.0.0"
dialoguer = { version = "0.12", default-features = false }
arboard = { version = "3", default-features = false, features = ["wayland-data-control"] }
ureq = { version = "3", default-features = false, features = ["rustls"] }
+90
View File
@@ -23,6 +23,8 @@ Working:
- Audio capture of the default sink's monitor, with optional per-app
routing (`--app <name>`)
- `--repair` cleanup of orphaned PipeWire state left by a crashed host
- `--doctor` environment diagnostic (capture/encode deps, VA-API H.264,
viewer player, relay reachability) — see [Diagnostics](#diagnostics)
- iroh QUIC bi-stream tunnel, direct-UDP and relay paths both verified
- Interactive Host/View menu with clipboard auto-copy and mpv/VLC picker
- Headless mode for scripts (`pixelpass <ticket>`)
@@ -135,6 +137,35 @@ sudo pacman -S vlc vlc-plugin-dvb vlc-plugin-ffmpeg
If the viewer is running on battery, set the CPU governor to performance
or balanced — power-saver can choke even hardware-decoded 1080p H.264.
## Diagnostics
`pixelpass --doctor` prints a one-shot report of everything the above
requirements cover and exits — run it on any machine before a real session:
```sh
pixelpass --doctor
```
It checks, and prints a `✓ / ! / ✗` line for each:
- **display server** — Wayland vs. X11 (autodetected), the raw session env
vars, and the X server's vendor/version (so an xlibre server is visible)
- **capture** — the GStreamer tools plus the source element for your backend
(`pipewiresrc` on Wayland, `ximagesrc` on X11)
- **encode** — whether hardware H.264 works (the `vah264enc` plugin, a DRM
render node, and a VA-API H.264 *encode* entrypoint via `vainfo`), and
whether the software `x264enc` fallback is available. This is the usual
culprit when a viewer "can't connect": a GPU with no H.264 encode entrypoint
produces no video under the default encoder — the report tells you to host
with `--no-hwencode`
- **mux / audio** — the TS mux + AAC + PulseAudio tail, and `pactl`
- **viewer** — whether `mpv` or `vlc` is installed
- **network** — binds a real endpoint and checks a relay is reachable
Each failing line includes a distro-aware install hint, and the closing summary
says whether the machine can host and how. The exit code is non-zero if any
hard requirement is missing, so it can gate a script or CI.
## Build
```sh
@@ -223,6 +254,65 @@ measured_at = "2026-05-21T20:41:16Z"
- Skip is sticky — once you skip the test, pixelpass won't ask again
unless you reconfigure.
## Relay
By default pixelpass uses iroh's bundled relay servers to coordinate the
P2P connection (peers still hole-punch a direct UDP path when they can; the
relay is the fallback and the rendezvous point). You can point it at a
different relay — a self-hosted one, or n0's staging/production servers —
with either:
```bash
pixelpass --relay https://relay.example/ # host or viewer
PIXELPASS_RELAY=https://relay.example/ pixelpass … # env-var form
```
The flag applies to both host and viewer and takes precedence over the
environment variable. The env-var form is handy for the `--gui` front-end,
since the GUI's child host/viewer processes inherit it; the `--gui --relay`
flag form is forwarded to them too. Both ends must use the same relay to
find each other.
## Themes
The `--gui` front-end ships three colour themes — **Default Dark**,
**Catppuccin Mocha**, and **Catppuccin Latte** — and you can add your own.
Pick one under **Settings → Appearance**; the choice is remembered.
A theme is a small TOML file of named colours:
```toml
name = "My Theme"
dark = true # base egui defaults to start from (dark or light)
window_bg = "#1b1b1f" # window background
panel_bg = "#242429" # panels / frames
input_bg = "#141417" # text fields, the ticket box
text = "#e6e6ea" # primary text
weak_text = "#a0a0a8" # hints, secondary text
accent = "#5aa0f2" # selection, links, the active control
button_bg = "#33333a" # buttons at rest
button_hovered = "#44444d"
streaming = "#6fdc8c" # "● Streaming"
waiting = "#f2c14e" # "● Waiting for viewers…"
success = "#6fdc8c" # "✓ Copied", valid-code confirmation
warning = "#f0a85a" # non-fatal warnings
error = "#f2756f" # errors
```
Colours are `#rrggbb` hex strings. Any field you leave out falls back to
Default Dark, so partial files are fine.
Two ways to make one:
- **In the app:** Settings → Appearance → *Edit / create a theme* gives you a
colour picker per field with a live preview, and **Save** writes a `.toml`.
- **By hand:** drop a `.toml` into `~/.config/pixelpass/themes/` (the XDG
config dir). It appears in the picker next time you open Settings.
Sharing a theme is just sending someone the file. A user theme whose `name`
matches a built-in overrides that built-in.
## Audio
By default pixelpass captures the default sink's monitor — the viewer
+3
View File
@@ -0,0 +1,3 @@
.tools/
AppDir/
*.AppImage
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
# AppRun for the PixelPass AppImage.
#
# PixelPass is an orchestrator: it shells out to gst-launch-1.0, pactl, and a
# player (mpv/vlc) found on the host PATH. We prepend our own usr/bin so any
# bundled helpers win, but the host's tools remain reachable — that's why this
# app suits AppImage (no sandbox) better than a Flatpak.
HERE="$(dirname "$(readlink -f "$0")")"
export PATH="$HERE/usr/bin:$PATH"
BIN="$HERE/usr/bin/pixelpass"
# With no arguments and no controlling terminal — i.e. launched from a file
# manager or the .desktop entry — open the GUI. From a terminal, or with any
# argument (a ticket, --host, --gui, --repair, …), pass through so the CLI and
# the interactive menu both work.
if [ "$#" -eq 0 ] && [ ! -t 0 ]; then
exec "$BIN" --gui
fi
exec "$BIN" "$@"
+72
View File
@@ -0,0 +1,72 @@
# PixelPass AppImage
A "thin" AppImage: the gui-enabled `pixelpass` binary, a launcher (`AppRun`),
and the desktop entry + icon. Run `./build-appimage.sh` to produce
`pixelpass-<version>-x86_64.AppImage`.
## Why thin
`pixelpass` is an orchestrator — it links almost nothing (only `libpipewire`,
which is excludelisted because it must match the host daemon) and instead
**shells out** to `gst-launch-1.0`, `pactl`, and a player (`mpv`/`vlc`) found on
the host `PATH`. The GUI's graphics libraries (`libGL`, `libwayland-*`,
`libxkbcommon`, X11) are dlopen'd at runtime and are likewise on the AppImage
excludelist — every desktop already has a matching set. So there is nothing
useful to bundle, and bundling the graphics stack would only risk driver
mismatches. The AppImage therefore carries just the binary.
This also explains why PixelPass suits AppImage better than Flatpak: the
no-sandbox model lets the bundled binary freely spawn the host's `gst-launch`,
`pactl`, and player, which a Flatpak sandbox would block.
## Host requirements
The AppImage runs on any reasonably current glibc-based distro that has:
- **GStreamer + plugins** — `gst-launch-1.0`/`gst-inspect-1.0` plus base,
good/bad/ugly, libav, and the PipeWire plugin (the binary tells you the exact
package names for your distro if something is missing).
- **PipeWire** (with the PulseAudio shim, for `pactl`).
- **A player** — `mpv` (preferred) or `vlc` — for the viewer side.
- For X11 single-window capture: `xwininfo`.
These are the same dependencies the Arch package lists; the AppImage just spares
you the Rust toolchain.
## Building for broad compatibility (lower glibc baseline)
An AppImage requires a host glibc **at least as new** as the build host's. Built
straight on a rolling distro (e.g. CachyOS, glibc 2.43) the AppImage only runs
on equally-new systems. Build inside an older base for wider reach. The script
honours `CARGO_TARGET_DIR`, so an isolated toolchain won't clobber your host's
`target/`:
```sh
# One-time: an Ubuntu 24.04 distrobox (docker or podman backend).
distrobox create --yes --image ubuntu:24.04 --name pixelpass-build
distrobox enter pixelpass-build -- sudo apt-get update
distrobox enter pixelpass-build -- sudo apt-get install -y \
build-essential cmake clang libclang-dev pkg-config \
libpipewire-0.3-dev libspa-0.2-dev curl ca-certificates file
# Install rustup inside the box (edition 2024 needs rustc >= 1.85), then:
distrobox enter pixelpass-build -- env \
CARGO_TARGET_DIR=~/.cache/pixelpass-ubuntu/target \
./packaging/appimage/build-appimage.sh
```
**Why Ubuntu 24.04 and not something older:** PixelPass's `pipewire` crate
binds the system's PipeWire headers via bindgen, and anything older than ~PW 1.0
(e.g. Ubuntu 22.04's 0.3.48) fails to compile (missing struct fields / wrong
types). And since PixelPass *is* a PipeWire/portal/Wayland app, it can only run
on distros new enough to have modern PipeWire anyway — so an ancient glibc base
buys nothing. 24.04 (glibc 2.39, PW 1.0.5) is the sweet spot.
The 24.04-built binary's baseline is **glibc 2.39** — and the only 2.39 symbols
are two *weak* `pidfd_*` references from Rust std's process spawning (everything
else is ≤ 2.35). That covers Ubuntu 24.04+, Debian 13+, Fedora 40+, and current
rolling distros.
## Caveats
- **Hardware encode (VAAPI `vah264enc`)** uses the host GPU driver; it can't be
bundled. The software path (`--no-hwencode`, x264) always works.
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Build a "thin" PixelPass AppImage: the gui-enabled release binary plus only
# its non-excludelisted shared libraries. The graphics stack (libGL, wayland,
# xkbcommon, X11) is intentionally left to the host — those libs are on the
# AppImage excludelist because they must match the host driver — and the
# runtime tools PixelPass shells out to (gst-launch-1.0, pactl, mpv/vlc) are
# expected on the host PATH, the same contract the Arch package documents.
#
# Usage: packaging/appimage/build-appimage.sh
# Output: packaging/appimage/pixelpass-x86_64.AppImage
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "$here/../.." && pwd)"
tools="$here/.tools"
appdir="$here/AppDir"
mkdir -p "$tools"
# linuxdeploy is itself an AppImage; run it without FUSE so this works on hosts
# (and CI) that lack libfuse2.
export APPIMAGE_EXTRACT_AND_RUN=1
# Embed the version from Cargo.toml into the AppImage filename metadata.
VERSION="$(grep -m1 '^version' "$repo/Cargo.toml" | sed -E 's/.*"(.*)".*/\1/')"
export VERSION
echo ">> building release binary (--features gui)"
( cd "$repo" && cargo build --release --features gui )
# Honour CARGO_TARGET_DIR so an isolated build (e.g. inside an old-glibc
# distrobox) doesn't have to clobber the host's target/.
bin="${CARGO_TARGET_DIR:-$repo/target}/release/pixelpass"
echo ">> fetching linuxdeploy"
ld="$tools/linuxdeploy-x86_64.AppImage"
if [ ! -x "$ld" ]; then
curl -fL --retry 3 -o "$ld" \
"https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
chmod +x "$ld"
fi
echo ">> assembling AppDir"
rm -rf "$appdir"
mkdir -p "$appdir/usr/bin"
install -m755 "$bin" "$appdir/usr/bin/pixelpass"
echo ">> running linuxdeploy (bundles libs, builds the AppImage)"
# -e: analyse this binary for libraries to bundle (only libpipewire et al. that
# aren't excludelisted will be copied; glibc + graphics libs are skipped).
# -d/-i: desktop entry + icon for desktop integration.
# --custom-apprun: our launcher that opens --gui from a file manager.
( cd "$here" && OUTPUT="pixelpass-${VERSION}-x86_64.AppImage" "$ld" \
--appdir "$appdir" \
-e "$bin" \
-d "$repo/assets/pixelpass.desktop" \
-i "$repo/assets/pixelpass-256.png" \
--icon-filename pixelpass \
--custom-apprun "$here/AppRun" \
--output appimage )
echo ">> done: $here/pixelpass-${VERSION}-x86_64.AppImage"
+3 -3
View File
@@ -1,6 +1,6 @@
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
#
# Local versioned package, built from the local git repo on `main`.
# 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
@@ -8,7 +8,7 @@ pkgver=0.1.0
pkgrel=1
pkgdesc='P2P screen sharing over iroh — no port forwarding, no signup'
arch=('x86_64')
url='file:///home/mollusk/git/butter/pixelpass'
url='https://gitbutter.xyz/mollusk/pixelpass'
license=('MIT' 'Apache-2.0' 'OFL-1.1')
depends=(
'gstreamer' # gst-launch-1.0 / gst-inspect-1.0
@@ -33,7 +33,7 @@ optdepends=(
makedepends=('cargo' 'git')
options=('!lto')
_branch='main'
source=("$pkgname::git+file:///home/mollusk/git/butter/pixelpass#branch=$_branch")
source=("$pkgname::git+https://gitbutter.xyz/mollusk/pixelpass.git#branch=$_branch")
sha256sums=('SKIP')
prepare() {
+63
View File
@@ -0,0 +1,63 @@
# Debian / Ubuntu `.deb` build
This documents how the `pixelpass_*.deb` is produced. The deb **recipe itself**
lives in-repo as the `[package.metadata.deb]` block in `Cargo.toml` (cargo-deb's
equivalent of a PKGBUILD); this file documents only the build environment.
pixelpass is the screen-share companion to peerspeak and is built the same way
in the same box. See peerspeak's `packaging/debian/README.md` for the full
rationale behind each step — this is the short version.
## TL;DR
```sh
distrobox enter peerspeak-bookworm -- bash -lc '
source ~/.cargo/env
cd ~/git/butter/pixelpass
export CARGO_TARGET_DIR=~/.cache/cargo-deb-targets/pixelpass # MANDATORY
cargo deb
'
# output: $CARGO_TARGET_DIR/debian/pixelpass_<version>-1_amd64.deb
```
## Build environment
- **Base: the same Debian 12 (bookworm) distrobox `peerspeak-bookworm`**
(glibc 2.36) used for peerspeak. **Never build on the Arch host** (newer glibc
+ shared `$HOME`/`target/` would link Arch C objects into the binary).
- **Use a box-local, pixelpass-specific `CARGO_TARGET_DIR`** (distinct from
peerspeak's) so the two never share an artifact cache:
`export CARGO_TARGET_DIR=~/.cache/cargo-deb-targets/pixelpass`.
- Toolchain provisioning (rustup stable + `cargo-deb` + `build-essential`
`pkg-config`) is identical to peerspeak's README. pixelpass itself links few
C libraries — the heavy GStreamer stack it uses is invoked as subprocesses,
not linked (see below), so it adds no extra `*-dev` build-deps beyond the base.
## Why `Depends` lists the whole GStreamer stack explicitly
pixelpass does its screen capture by shelling out to the GStreamer CLI
(`gst-launch-1.0` / `gst-inspect-1.0`) and to `pactl`, **not** by linking the
GStreamer libraries. That means `dpkg-shlibdeps` (which only sees linked `.so`
files) cannot detect them, so `$auto` alone would ship a `.deb` whose `Depends`
omits the entire capture stack. A fresh Ubuntu host would then fail at
pixelpass's own `deps::check_host_binaries` startup probe — *before* it ever
prints a connection ticket, which is exactly the field bug that motivated this.
So the `Cargo.toml` `depends` hard-codes the runtime stack on top of `$auto`:
```
$auto, gstreamer1.0-tools, gstreamer1.0-plugins-base,
gstreamer1.0-plugins-good, gstreamer1.0-plugins-bad,
gstreamer1.0-plugins-ugly, gstreamer1.0-libav, gstreamer1.0-pipewire,
gstreamer1.0-pulseaudio, pulseaudio-utils, x11-utils
```
This covers both capture backends (`pipewiresrc` on Wayland, `ximagesrc` on X11
from plugins-good), the VAAPI + software H.264 encoders, the AAC/TS mux tail,
the PulseAudio source, and the `pactl`/`xdpyinfo` helpers.
## glibc floor
Same as peerspeak: built against glibc 2.36 → runs on Debian 12+ / Ubuntu
24.04+. (pixelpass's own linked-library floor is lower, ~2.39-era, but it is
always shipped alongside peerspeak, whose 2.36 floor governs the pair.)
+41 -1
View File
@@ -27,6 +27,17 @@ pub struct Cli {
#[arg(long, value_name = "NAME")]
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.
#[arg(long, value_enum)]
pub display_server: Option<DisplayServerArg>,
@@ -68,6 +79,13 @@ pub struct Cli {
pub port: u16,
// ── global ────────────────────────────────────────────────────────
/// Relay server URL to use instead of the bundled defaults, e.g.
/// `https://relay.example/`. Applies to both host and viewer. Falls back
/// to the `PIXELPASS_RELAY` environment variable. Use this to get off the
/// pre-release default relays or to point at a self-hosted relay.
#[arg(long, value_name = "URL")]
pub relay: Option<String>,
/// Launch the graphical front-end (a window with Host/View controls)
/// instead of the terminal menu. Requires a build with `--features gui`.
#[arg(long)]
@@ -87,6 +105,14 @@ pub struct Cli {
#[arg(long)]
pub repair: bool,
/// Print an environment diagnostic report (display server, capture/encode
/// dependencies, VA-API H.264 support, viewer player, relay reachability),
/// then exit. Use this to check a machine can host or view before a real
/// session — especially to confirm hardware H.264 encode works, since a GPU
/// without it silently produces no video under the default encoder.
#[arg(long)]
pub doctor: bool,
/// Re-run the bandwidth pre-flight test, save the result, then exit.
/// Use this if your connection has changed (new ISP, moved house, etc.)
/// or if the previously saved test result is stale.
@@ -128,6 +154,10 @@ pub enum Quality {
pub struct HostOpts {
pub window: bool,
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>,
/// Chosen preset (Auto = derive at startup). Defaults to Auto.
pub quality: Quality,
@@ -140,12 +170,16 @@ pub struct HostOpts {
pub no_hwencode: bool,
pub max_viewers: Option<u32>,
pub interactive: bool,
/// Relay override (resolved from `--relay` / `PIXELPASS_RELAY`); None = defaults.
pub relay: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ViewerOpts {
pub port: u16,
pub interactive: bool,
/// Relay override (resolved from `--relay` / `PIXELPASS_RELAY`); None = defaults.
pub relay: Option<String>,
}
impl Cli {
@@ -153,6 +187,7 @@ impl Cli {
HostOpts {
window: self.window,
app: self.app,
strict_audio: self.strict_audio,
display_server: self.display_server,
// No `--quality` and nothing picked interactively → the documented
// default, Auto.
@@ -163,10 +198,15 @@ impl Cli {
no_hwencode: self.no_hwencode,
max_viewers: self.max_viewers,
interactive,
relay: crate::common::endpoint::relay_override(self.relay.as_deref()),
}
}
pub fn into_viewer_opts(self, interactive: bool) -> ViewerOpts {
ViewerOpts { port: self.port, interactive }
ViewerOpts {
port: self.port,
interactive,
relay: crate::common::endpoint::relay_override(self.relay.as_deref()),
}
}
}
+10 -1
View File
@@ -1,5 +1,14 @@
/// ALPN identifying the pixelpass wire protocol on the iroh tunnel.
/// ALPN identifying the pixelpass video wire protocol on the iroh tunnel.
///
/// Bump the version suffix whenever the wire format changes. Today the wire is
/// "raw MPEG-TS bytes copied bidirectionally," so bumps will be rare.
pub const ALPN: &[u8] = b"pixelpass/0";
/// ALPN for the friends control plane — the always-on presence endpoint that
/// carries friend requests and shared codes between peers' GUIs. Separate from
/// [`ALPN`] so the same machine can run a control endpoint and a video endpoint
/// without their accept loops colliding, and so a control dial never lands on a
/// bare video host (which doesn't speak this protocol). GUI-only, like the rest
/// of the friends stack.
#[cfg(feature = "gui")]
pub const CONTROL_ALPN: &[u8] = b"pixelpass/ctrl/0";
+33 -1
View File
@@ -65,9 +65,41 @@ pub fn measure_upstream_blocking() -> Result<Measurement> {
/// via SAFETY_FACTOR) into a recommended viewer count. Floors to at least 1.
pub fn recommended_max_viewers(safe_mbps: f64, bitrate_kbps: u32) -> u32 {
let per_viewer_mbps = (bitrate_kbps as f64) / 1000.0;
if per_viewer_mbps <= 0.0 {
// Guard non-finite / non-positive inputs (only reachable from a corrupted
// config): a NaN safe_mbps would cast to 0 and an infinite one to u32::MAX,
// both of which break the "at least 1" contract.
if !safe_mbps.is_finite() || safe_mbps <= 0.0 || per_viewer_mbps <= 0.0 {
return 1;
}
let n = (safe_mbps / per_viewer_mbps).floor();
if n < 1.0 { 1 } else { n as u32 }
}
#[cfg(test)]
mod tests {
use super::recommended_max_viewers;
#[test]
fn divides_bandwidth_by_per_viewer_bitrate() {
// 8 Mbps safe / 2 Mbps each = 4 viewers.
assert_eq!(recommended_max_viewers(8.0, 2000), 4);
// Floors the fractional part: 7.9 / 2 = 3.95 -> 3.
assert_eq!(recommended_max_viewers(7.9, 2000), 3);
}
#[test]
fn floors_to_at_least_one() {
// Not even enough for one viewer still allows one (best effort).
assert_eq!(recommended_max_viewers(0.5, 2000), 1);
// Zero / unknown bitrate can't size a budget; floor to one.
assert_eq!(recommended_max_viewers(8.0, 0), 1);
}
#[test]
fn degenerate_inputs_floor_to_one() {
// A corrupted config must not yield 0 (NaN) or u32::MAX (Inf).
assert_eq!(recommended_max_viewers(f64::NAN, 2000), 1);
assert_eq!(recommended_max_viewers(f64::INFINITY, 2000), 1);
assert_eq!(recommended_max_viewers(-5.0, 2000), 1);
}
}
+44 -6
View File
@@ -20,13 +20,54 @@ pub struct Config {
}
/// Preferences for the `pixelpass --gui` front-end.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[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.
@@ -57,7 +98,6 @@ pub enum BandwidthStatus {
Failed,
}
fn default_status() -> BandwidthStatus {
BandwidthStatus::Unmeasured
}
@@ -89,11 +129,9 @@ pub fn save(cfg: &Config) -> Result<()> {
let parent = path
.parent()
.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()));
{
+260
View File
@@ -0,0 +1,260 @@
//! Friends control-plane protocol and service.
//!
//! This is the always-on presence channel that rides the [`CONTROL_ALPN`]
//! endpoint (bound with the persistent identity — see
//! [`super::endpoint::bind_control`]). It's how two peers' GUIs exchange friend
//! requests and pushed share-codes, independent of any video session.
//!
//! Wire shape: **one message per connection.** The sender opens a bi-stream,
//! writes the JSON-encoded [`ControlMsg`], and finishes its send side (EOF
//! delimits the message — no length framing needed). The receiver reads to EOF,
//! parses, hands the message up, then writes a one-byte [`ACK`] back so the
//! sender knows it was delivered *and* parsed. That delivery signal is what
//! lets the host-side code-push queue (a later phase) tell "sent" from "friend
//! was offline." A friend's *reply* (accept/decline) is a separate later
//! connection in the other direction, because acceptance can happen minutes
//! after the request — not a response on the same stream.
use std::time::Duration;
use anyhow::{Context, Result, bail};
use iroh::endpoint::{Incoming, VarInt};
use iroh::{Endpoint, EndpointAddr, EndpointId};
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use super::alpn::CONTROL_ALPN;
/// Upper bound on a single control message. Generous for a display name plus a
/// share-code ticket (~150 chars); rejects a peer trying to make us buffer a
/// huge blob.
const MAX_MSG: usize = 64 * 1024;
/// One-byte application acknowledgement the receiver returns once it has parsed
/// a message. ASCII ACK (0x06).
const ACK: &[u8] = b"\x06";
/// Bound on each phase of the send handshake, so a half-dead peer or relay
/// can't park a sender (or an inbound handler) forever.
const IO_TIMEOUT: Duration = Duration::from_secs(10);
/// A message on the friends control plane.
///
/// `#[serde(tag = "type")]` keeps the JSON self-describing and lets us add
/// variants without breaking older peers (an unknown tag fails to parse and is
/// logged, rather than being silently misread as another variant).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ControlMsg {
/// "I'm online; here's my current display name." A presence/name refresh.
Hello { name: String },
/// Ask the recipient to become friends.
FriendRequest { name: String },
/// Accept a request the recipient previously sent us.
FriendAccept { name: String },
/// Decline a pending request, or cancel an outgoing one.
FriendDecline,
/// A host pushing a freshly generated share-code to an accepted friend.
ShareCode { name: String, ticket: String },
}
/// A received control message, paired with the *authenticated* sender id (the
/// connection's verified remote public key — not a value the peer can spoof in
/// the payload, which is why no variant carries a sender id).
#[derive(Debug, Clone)]
pub struct Inbound {
pub from: EndpointId,
pub msg: ControlMsg,
}
fn encode(msg: &ControlMsg) -> Result<Vec<u8>> {
serde_json::to_vec(msg).context("failed to encode control message")
}
fn decode(bytes: &[u8]) -> Result<ControlMsg> {
serde_json::from_slice(bytes).context("failed to decode control message")
}
/// Deliver one message to `peer` over `endpoint`, returning once the recipient
/// has acknowledged it. An error means it was *not* delivered (peer offline,
/// unreachable, or rejected the stream) — the caller can queue and retry.
///
/// `peer` is usually a bare [`EndpointId`] — friends store only the stable id,
/// and n0 DNS discovery resolves it to a live address. The full [`EndpointAddr`]
/// form exists for callers that already hold one (and for hermetic tests).
pub async fn send(
endpoint: &Endpoint,
peer: impl Into<EndpointAddr>,
msg: &ControlMsg,
) -> Result<()> {
let payload = encode(msg)?;
let conn = tokio::time::timeout(IO_TIMEOUT, endpoint.connect(peer, CONTROL_ALPN))
.await
.context("timed out connecting to peer")?
.context("failed to connect to peer")?;
let io = async {
let (mut send, mut recv) = conn
.open_bi()
.await
.context("failed to open control stream")?;
send.write_all(&payload)
.await
.context("failed to write control message")?;
send.finish().context("failed to finish control stream")?;
// Read the peer's ACK. read_to_end returns once the peer finishes its
// send side, so this also serves as "the peer is done with us."
let ack = recv
.read_to_end(ACK.len() + 1)
.await
.context("peer closed the control stream without acknowledging")?;
if ack != ACK {
bail!(
"peer sent an unexpected acknowledgement ({} bytes)",
ack.len()
);
}
Ok(())
};
let result = tokio::time::timeout(IO_TIMEOUT, io)
.await
.context("timed out sending control message")?;
// Clean close so the peer's `closed().await` returns promptly either way.
conn.close(VarInt::from_u32(0), b"done");
result
}
/// Run the control-plane accept loop, forwarding every received message to
/// `tx`. Returns when the endpoint stops accepting (i.e. it was closed).
pub async fn serve(endpoint: Endpoint, tx: mpsc::Sender<Inbound>) {
while let Some(incoming) = endpoint.accept().await {
let tx = tx.clone();
tokio::spawn(async move {
if let Err(e) = handle(incoming, &tx).await {
tracing::warn!("control: inbound connection failed: {e:#}");
}
});
}
tracing::info!("control: endpoint stopped accepting");
}
async fn handle(incoming: Incoming, tx: &mpsc::Sender<Inbound>) -> Result<()> {
let conn = incoming
.await
.context("inbound control connection failed")?;
let from = conn.remote_id();
let msg = async {
let (mut send, mut recv) = conn
.accept_bi()
.await
.context("failed to accept control stream")?;
let bytes = recv
.read_to_end(MAX_MSG)
.await
.context("failed to read control message")?;
let msg = decode(&bytes)?;
// ACK only after a successful parse, so the sender's delivery signal
// means "received and understood."
send.write_all(ACK).await.context("failed to write ack")?;
send.finish().context("failed to finish ack stream")?;
Ok::<_, anyhow::Error>(msg)
};
let msg = tokio::time::timeout(IO_TIMEOUT, msg)
.await
.context("timed out reading control message")??;
// Hand the message up first, so it reaches the UI promptly even when the
// sender is slow to close (a degraded link could otherwise delay a friend
// request / pushed code by up to IO_TIMEOUT).
tx.send(Inbound { from, msg })
.await
.map_err(|_| anyhow::anyhow!("control: receiver dropped"))?;
// Then wait (briefly) for the sender's close so our ACK has flushed before
// the connection is dropped at the end of this scope.
let _ = tokio::time::timeout(IO_TIMEOUT, conn.closed()).await;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn control_msg_round_trips() {
let cases = [
ControlMsg::Hello {
name: "alice".into(),
},
ControlMsg::FriendRequest { name: "bob".into() },
ControlMsg::FriendAccept {
name: "carol".into(),
},
ControlMsg::FriendDecline,
ControlMsg::ShareCode {
name: "dave".into(),
ticket: "endpointaa…".into(),
},
];
for msg in cases {
let bytes = encode(&msg).unwrap();
assert_eq!(decode(&bytes).unwrap(), msg);
}
}
#[test]
fn unknown_tag_is_rejected() {
assert!(decode(br#"{"type":"nonsense"}"#).is_err());
}
/// Bind a control-plane endpoint with a *fresh* random key, so two of them
/// in one test get distinct ids (two real machines each have their own
/// persistent key; `bind_control` would give both the same one here, and
/// iroh refuses "connecting to ourself").
async fn bind_test_control() -> Endpoint {
iroh::Endpoint::builder(iroh::endpoint::presets::N0)
.secret_key(iroh::SecretKey::generate())
.alpns(vec![CONTROL_ALPN.to_vec()])
.bind()
.await
.unwrap()
}
/// End-to-end over two real iroh endpoints on this machine. Ignored by
/// default — it binds endpoints and waits on the relay, so it's slow and
/// network-dependent. Run with `cargo test -- --ignored control`.
#[tokio::test]
#[ignore = "binds real iroh endpoints; run on demand"]
async fn loopback_delivers_and_acks() {
let server = bind_test_control().await;
let client = bind_test_control().await;
// Connect by full addr so the test doesn't depend on DNS discovery.
server.online().await;
client.online().await;
let server_addr = server.addr();
let (tx, mut rx) = mpsc::channel(4);
let server_ep = server.clone();
let serve_task = tokio::spawn(async move { serve(server_ep, tx).await });
let msg = ControlMsg::FriendRequest {
name: "tester".into(),
};
// Full addr (not just the id) so the test doesn't depend on DNS discovery.
send(&client, server_addr.clone(), &msg).await.unwrap();
let got = tokio::time::timeout(Duration::from_secs(15), rx.recv())
.await
.expect("no inbound within 15s")
.expect("channel closed");
assert_eq!(got.msg, msg);
assert_eq!(got.from, client.addr().id);
server.close().await;
client.close().await;
serve_task.abort();
}
}
+55 -24
View File
@@ -56,12 +56,7 @@ fn require(bin: &str) -> Result<PathBuf> {
}
fn require_gst_element(name: &str) -> Result<()> {
let ok = Command::new("gst-inspect-1.0")
.args(["--exists", name])
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
if !gst_element_exists(name) {
bail!(
"GStreamer element `{name}` not available.\n{}",
install_hint_for_gst_element(name)
@@ -70,7 +65,17 @@ fn require_gst_element(name: &str) -> Result<()> {
Ok(())
}
fn which(bin: &str) -> Option<PathBuf> {
/// Whether a GStreamer element is registered, via `gst-inspect-1.0 --exists`.
/// Non-bailing counterpart to [`require_gst_element`] for the `doctor` report.
pub(crate) fn gst_element_exists(name: &str) -> bool {
Command::new("gst-inspect-1.0")
.args(["--exists", name])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub(crate) fn which(bin: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let candidate = dir.join(bin);
@@ -81,24 +86,28 @@ fn which(bin: &str) -> Option<PathBuf> {
None
}
fn install_hint_for_bin(bin: &str) -> String {
pub(crate) fn install_hint_for_bin(bin: &str) -> String {
let distro = detect_distro();
let pkg = match bin {
"gst-launch-1.0" | "gst-inspect-1.0" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gstreamer gst-plugins-base",
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
"gstreamer gst-plugins-base"
}
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-tools",
Some("fedora" | "nobara") => "gstreamer1 gstreamer1-plugins-base-tools",
_ => "gstreamer + tools",
},
"pactl" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "libpulse",
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => "libpulse",
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "pulseaudio-utils",
Some("fedora" | "nobara") => "pulseaudio-utils",
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "pulseaudio-utils",
_ => "pulseaudio-utils (provides `pactl`)",
},
"xwininfo" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "xorg-xwininfo",
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
"xorg-xwininfo"
}
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "x11-utils",
Some("fedora" | "nobara") => "xorg-x11-utils",
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "xwininfo",
@@ -109,60 +118,78 @@ fn install_hint_for_bin(bin: &str) -> String {
install_command(&distro, pkg)
}
fn install_hint_for_gst_element(name: &str) -> String {
pub(crate) fn install_hint_for_gst_element(name: &str) -> String {
let distro = detect_distro();
let pkg = match name {
"pipewiresrc" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugin-pipewire",
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
"gst-plugin-pipewire"
}
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-pipewire",
Some("fedora" | "nobara") => "pipewire-gstreamer",
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "pipewire-gstreamer",
_ => "the GStreamer PipeWire plugin",
},
"vah264enc" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugin-va",
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
"gst-plugin-va"
}
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-bad",
Some("fedora" | "nobara") => "gstreamer1-plugins-bad-free",
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-bad",
_ => "the GStreamer VA-API plugin (requires an H.264-capable GPU; almost all modern GPUs)",
_ => {
"the GStreamer VA-API plugin (requires an H.264-capable GPU; almost all modern GPUs)"
}
},
"x264enc" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-ugly",
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
"gst-plugins-ugly"
}
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-ugly",
Some("fedora" | "nobara") => "gstreamer1-plugins-ugly",
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-ugly",
_ => "the GStreamer x264 plugin (plugins-ugly)",
},
"ximagesrc" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-good",
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
"gst-plugins-good"
}
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-good",
Some("fedora" | "nobara") => "gstreamer1-plugins-good",
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-good",
_ => "the GStreamer X11 plugin (plugins-good)",
},
"videoscale" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-base",
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
"gst-plugins-base"
}
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-base",
Some("fedora" | "nobara") => "gstreamer1-plugins-base",
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-base",
_ => "the GStreamer plugins-base set",
},
"h264parse" | "mpegtsmux" | "aacparse" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-bad",
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
"gst-plugins-bad"
}
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-bad",
Some("fedora" | "nobara") => "gstreamer1-plugins-bad-free",
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-bad",
_ => "the GStreamer plugins-bad set",
},
"pulsesrc" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-good",
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
"gst-plugins-good"
}
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-pulseaudio",
Some("fedora" | "nobara") => "gstreamer1-plugins-good",
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-good",
_ => "the GStreamer PulseAudio plugin",
},
"avenc_aac" => match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-libav",
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
"gst-libav"
}
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-libav",
Some("fedora" | "nobara") => "gstreamer1-libav",
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-libav",
@@ -175,16 +202,20 @@ fn install_hint_for_gst_element(name: &str) -> String {
fn install_command(distro: &Option<String>, pkg: &str) -> String {
let cmd = match distro.as_deref() {
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => format!("sudo pacman -S {pkg}"),
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
format!("sudo pacman -S {pkg}")
}
Some("debian" | "ubuntu" | "pop" | "linuxmint") => format!("sudo apt install {pkg}"),
Some("fedora" | "nobara") => format!("sudo dnf install {pkg}"),
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => format!("sudo zypper install {pkg}"),
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => {
format!("sudo zypper install {pkg}")
}
_ => format!("install the `{pkg}` package via your distro's package manager"),
};
format!("Install hint: {cmd}")
}
fn detect_distro() -> Option<String> {
pub(crate) fn detect_distro() -> Option<String> {
let contents = std::fs::read_to_string("/etc/os-release").ok()?;
for line in contents.lines() {
if let Some(rest) = line.strip_prefix("ID=") {
+84
View File
@@ -0,0 +1,84 @@
//! Shared iroh endpoint construction.
//!
//! Two planes, two identities:
//!
//! * The **video** plane (host/viewer sessions) binds with an *ephemeral*
//! keypair — a fresh `EndpointId` per run. Each session is a throwaway tunnel,
//! and keeping its id ephemeral means a screen-share leaks no stable
//! fingerprint.
//! * The **control** plane (the always-on friends presence service) binds with
//! the machine's *persistent* identity (see [`identity`]), so peers can find
//! and recognise each other across launches.
//!
//! They must use different identities because both can be live at once on the
//! same machine (the GUI's control endpoint while a host session runs), and
//! iroh routes by `EndpointId` — two live endpoints sharing one id would make
//! relay delivery ambiguous.
use std::str::FromStr;
use anyhow::{Context, Result};
use iroh::endpoint::presets;
use iroh::{Endpoint, RelayMap, RelayMode, RelayUrl};
use super::alpn::ALPN;
/// Environment variable consulted when `--relay` isn't passed. Lets the GUI's
/// child processes and scripted runs inherit a relay choice without a flag.
pub const RELAY_ENV: &str = "PIXELPASS_RELAY";
/// Resolve the relay override: explicit `--relay` wins, else `PIXELPASS_RELAY`,
/// else `None` (use the bundled defaults).
pub fn relay_override(flag: Option<&str>) -> Option<String> {
flag.map(str::to_owned).or_else(|| {
std::env::var(RELAY_ENV)
.ok()
.filter(|s| !s.trim().is_empty())
})
}
/// Bind a **video-plane** endpoint (host/viewer) with an ephemeral identity.
///
/// With no `relay` override we use [`presets::N0`] — n0 DNS discovery, the
/// library's default relays, and the chosen crypto provider. With an override
/// we keep all of that but swap in a single custom relay via
/// [`RelayMode::Custom`]; this is how a user gets off the rc's bundled
/// (canary-grade) relays or points at a self-hosted one. Discovery is
/// unchanged, so peers still resolve each other by endpoint id.
pub async fn bind(relay: Option<&str>) -> Result<Endpoint> {
// No `secret_key` set → iroh mints a fresh ephemeral keypair for this run.
bind_with(relay, None, ALPN).await
}
/// Bind the **control-plane** endpoint with the machine's persistent identity
/// (see [`super::identity`]) and the friends [`super::alpn::CONTROL_ALPN`]. Its
/// `EndpointId` is the stable id friends know you by.
#[cfg(feature = "gui")]
pub async fn bind_control(relay: Option<&str>) -> Result<Endpoint> {
let secret_key = super::identity::load_or_create()?;
bind_with(relay, Some(secret_key), super::alpn::CONTROL_ALPN).await
}
/// Shared builder: optional persistent key (None → ephemeral) + the plane's ALPN.
async fn bind_with(
relay: Option<&str>,
key: Option<iroh::SecretKey>,
alpn: &[u8],
) -> Result<Endpoint> {
let mut builder = Endpoint::builder(presets::N0).alpns(vec![alpn.to_vec()]);
if let Some(key) = key {
builder = builder.secret_key(key);
}
if let Some(url) = relay {
let url = RelayUrl::from_str(url).with_context(|| {
format!("invalid relay URL {url:?} (expected e.g. https://relay.example/)")
})?;
builder = builder.relay_mode(RelayMode::Custom(RelayMap::from(url)));
}
builder
.bind()
.await
.context("failed to bind the iroh endpoint")
}
+331
View File
@@ -0,0 +1,331 @@
//! Persistent friends store at `~/.config/pixelpass/friends.toml`.
//!
//! Kept in its own file rather than a `[friends]` section of `config.toml` so
//! the headless CLI — which never manages friends and would round-trip the
//! config without this knowledge — can't drop the list on a `--reconfigure`.
//! Same reasoning as the separate `identity.key`.
//!
//! A friend is identified by their stable control-plane [`EndpointId`] (the id
//! from [`super::endpoint::bind_control`]). `EndpointId` serialises as its
//! string form in TOML, so the file is human-readable and hand-editable.
use anyhow::{Context, Result};
use iroh::EndpointId;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::PathBuf;
/// Where a friendship sits in the mutual-consent handshake.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FriendState {
/// We've sent them a request and are waiting for them to accept.
PendingOutgoing,
/// They've requested us; waiting for the local user to accept or decline.
PendingIncoming,
/// Both sides have agreed — a real friend.
Accepted,
}
/// One entry in the friends list.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Friend {
pub id: EndpointId,
/// Display name — seeded from the name the peer reported, locally editable.
pub name: String,
pub state: FriendState,
/// Whether the host auto-shares its session code with this friend. Toggled
/// on the host's share picker; persisted here so the choice survives a
/// restart. Defaults to `true` so a newly added friend is included (and an
/// older `friends.toml` without the field loads as share-with-all).
#[serde(default = "default_share")]
pub share: bool,
}
fn default_share() -> bool {
true
}
/// The persisted friends list. Serialises as a TOML array of tables
/// (`[[friends]]`).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FriendStore {
#[serde(default)]
pub friends: Vec<Friend>,
}
/// Returns `~/.config/pixelpass/friends.toml`. Shares the config directory with
/// [`super::config`]; the parent is created on save.
pub fn friends_path() -> Result<PathBuf> {
Ok(super::config::config_path()?
.parent()
.context("config path has no parent directory")?
.join("friends.toml"))
}
/// Load the store, or a default (empty) one if the file doesn't exist yet.
/// Parse errors bubble up so a hand-edit being debugged isn't silently
/// overwritten.
pub fn load() -> Result<FriendStore> {
let path = friends_path()?;
match fs::read_to_string(&path) {
Ok(s) => toml::from_str(&s).with_context(|| format!("failed to parse {}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FriendStore::default()),
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
}
}
impl FriendStore {
/// Atomic write via tempfile-in-same-dir + rename (mirrors
/// [`super::config::save`]).
pub fn save(&self) -> Result<()> {
let path = friends_path()?;
let parent = path
.parent()
.context("friends path has no parent directory")?;
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
let serialized = toml::to_string_pretty(self).context("failed to serialize friends")?;
let tmp = parent.join(format!(".friends.toml.tmp.{}", std::process::id()));
{
let mut f = fs::File::create(&tmp)
.with_context(|| format!("failed to create {}", tmp.display()))?;
f.write_all(serialized.as_bytes())
.with_context(|| format!("failed to write {}", tmp.display()))?;
f.sync_all().ok();
}
fs::rename(&tmp, &path)
.with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?;
Ok(())
}
pub fn find(&self, id: &EndpointId) -> Option<&Friend> {
self.friends.iter().find(|f| &f.id == id)
}
pub fn find_mut(&mut self, id: &EndpointId) -> Option<&mut Friend> {
self.friends.iter_mut().find(|f| &f.id == id)
}
/// True iff this id is a fully-accepted friend — the gate the code-push
/// (Phase 4) and "is this a known friend?" checks use.
pub fn is_accepted(&self, id: &EndpointId) -> bool {
matches!(
self.find(id),
Some(Friend {
state: FriendState::Accepted,
..
})
)
}
/// Insert a new friend, or update an existing one's `name`/`state` in place.
/// Returns a mutable reference to the stored entry.
pub fn upsert(&mut self, id: EndpointId, name: String, state: FriendState) -> &mut Friend {
if let Some(idx) = self.friends.iter().position(|f| f.id == id) {
let f = &mut self.friends[idx];
f.name = name;
f.state = state;
f
} else {
self.friends.push(Friend {
id,
name,
state,
share: true,
});
self.friends.last_mut().expect("just pushed")
}
}
/// Remove a friend by id. Returns whether an entry was removed.
pub fn remove(&mut self, id: &EndpointId) -> bool {
let before = self.friends.len();
self.friends.retain(|f| &f.id != id);
self.friends.len() != before
}
/// Apply an inbound friend request. Returns `true` if the friendship is now
/// settled at [`Accepted`] and the caller should reply with a `FriendAccept`
/// — either because we'd already sent them a request (a mutual match) or
/// because they're an existing friend re-announcing (we never downgrade an
/// [`Accepted`] friend back to pending; a peer who lost their store and
/// re-adds us just gets re-confirmed). Otherwise it's recorded as
/// [`PendingIncoming`] for the user to act on and `false` is returned.
///
/// [`Accepted`]: FriendState::Accepted
/// [`PendingIncoming`]: FriendState::PendingIncoming
pub fn on_friend_request(&mut self, id: EndpointId, name: String) -> bool {
match self.find(&id).map(|f| f.state) {
Some(FriendState::PendingOutgoing | FriendState::Accepted) => {
self.upsert(id, name, FriendState::Accepted);
true
}
_ => {
self.upsert(id, name, FriendState::PendingIncoming);
false
}
}
}
/// Apply an inbound acceptance of a request we sent. Returns `true` only if
/// it advanced one of *our* outgoing requests to [`Accepted`]. An accept for
/// any other state is ignored: a stranger's, or one for a peer still in
/// [`PendingIncoming`] (their request, awaiting our decision) — honouring the
/// latter would let a peer mark itself accepted without the local user's
/// consent.
///
/// [`Accepted`]: FriendState::Accepted
/// [`PendingIncoming`]: FriendState::PendingIncoming
pub fn on_friend_accept(&mut self, id: EndpointId, name: String) -> bool {
if matches!(
self.find(&id).map(|f| f.state),
Some(FriendState::PendingOutgoing)
) {
self.upsert(id, name, FriendState::Accepted);
true
} else {
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_id() -> EndpointId {
iroh::SecretKey::generate().public()
}
#[test]
fn round_trips_through_toml() {
let mut store = FriendStore::default();
store.upsert(sample_id(), "Alice".into(), FriendState::Accepted);
store.upsert(sample_id(), "Bob".into(), FriendState::PendingIncoming);
let toml = toml::to_string_pretty(&store).unwrap();
let back: FriendStore = toml::from_str(&toml).unwrap();
assert_eq!(back.friends, store.friends);
}
#[test]
fn new_friends_default_to_shared_and_survive_round_trip() {
let mut store = FriendStore::default();
let id = sample_id();
store.upsert(id, "Alice".into(), FriendState::Accepted);
assert!(store.find(&id).unwrap().share, "new friends start shared");
// An older friends.toml predating the field loads as share-with-all.
let toml = format!("[[friends]]\nid = \"{id}\"\nname = \"Legacy\"\nstate = \"accepted\"\n");
let back: FriendStore = toml::from_str(&toml).unwrap();
assert!(back.friends[0].share);
}
#[test]
fn upsert_preserves_share_across_refresh() {
let mut store = FriendStore::default();
let id = sample_id();
store.upsert(id, "Alice".into(), FriendState::Accepted);
store.find_mut(&id).unwrap().share = false;
// A later name/presence refresh re-upserts the same peer; the share
// choice must not be reset by it.
store.upsert(id, "Alice (new name)".into(), FriendState::Accepted);
assert!(!store.find(&id).unwrap().share);
}
#[test]
fn upsert_updates_in_place() {
let mut store = FriendStore::default();
let id = sample_id();
store.upsert(id, "Old".into(), FriendState::PendingOutgoing);
store.upsert(id, "New".into(), FriendState::Accepted);
assert_eq!(store.friends.len(), 1);
let f = store.find(&id).unwrap();
assert_eq!(f.name, "New");
assert_eq!(f.state, FriendState::Accepted);
}
#[test]
fn is_accepted_only_for_accepted_state() {
let mut store = FriendStore::default();
let pending = sample_id();
let friend = sample_id();
store.upsert(pending, "P".into(), FriendState::PendingOutgoing);
store.upsert(friend, "F".into(), FriendState::Accepted);
assert!(!store.is_accepted(&pending));
assert!(store.is_accepted(&friend));
assert!(!store.is_accepted(&sample_id()));
}
#[test]
fn remove_reports_whether_present() {
let mut store = FriendStore::default();
let id = sample_id();
store.upsert(id, "X".into(), FriendState::Accepted);
assert!(store.remove(&id));
assert!(!store.remove(&id));
assert!(store.friends.is_empty());
}
#[test]
fn incoming_request_from_stranger_is_pending() {
let mut store = FriendStore::default();
let id = sample_id();
let mutual = store.on_friend_request(id, "Stranger".into());
assert!(!mutual);
assert_eq!(store.find(&id).unwrap().state, FriendState::PendingIncoming);
}
#[test]
fn incoming_request_matching_our_outgoing_is_mutual() {
let mut store = FriendStore::default();
let id = sample_id();
// We asked them first…
store.upsert(id, "Pal".into(), FriendState::PendingOutgoing);
// …then their request arrives — that's a mutual match.
let mutual = store.on_friend_request(id, "Pal".into());
assert!(mutual);
assert_eq!(store.find(&id).unwrap().state, FriendState::Accepted);
}
#[test]
fn accept_advances_known_peer_only() {
let mut store = FriendStore::default();
let known = sample_id();
store.upsert(known, "Known".into(), FriendState::PendingOutgoing);
assert!(store.on_friend_accept(known, "Known".into()));
assert_eq!(store.find(&known).unwrap().state, FriendState::Accepted);
// An accept from someone we never asked is ignored.
let stranger = sample_id();
assert!(!store.on_friend_accept(stranger, "Nope".into()));
assert!(store.find(&stranger).is_none());
}
#[test]
fn accept_does_not_advance_a_pending_incoming_peer() {
// They asked us and we haven't decided yet; an unsolicited FriendAccept
// from them must not auto-accept on our behalf (consent bypass).
let mut store = FriendStore::default();
let id = sample_id();
store.upsert(id, "Theirs".into(), FriendState::PendingIncoming);
assert!(!store.on_friend_accept(id, "Theirs".into()));
assert_eq!(store.find(&id).unwrap().state, FriendState::PendingIncoming);
}
#[test]
fn request_does_not_downgrade_an_accepted_friend() {
// A current friend re-sending a request (e.g. after losing their store)
// must stay accepted; the call signals a re-confirm rather than a
// downgrade to pending.
let mut store = FriendStore::default();
let id = sample_id();
store.upsert(id, "Pal".into(), FriendState::Accepted);
let settled = store.on_friend_request(id, "Pal (reinstalled)".into());
assert!(settled);
assert_eq!(store.find(&id).unwrap().state, FriendState::Accepted);
assert_eq!(store.find(&id).unwrap().name, "Pal (reinstalled)");
}
}
+141
View File
@@ -0,0 +1,141 @@
//! Persistent node identity at `~/.config/pixelpass/identity.key`.
//!
//! Without this, [`super::endpoint::bind`] would let iroh mint a fresh random
//! keypair on every launch, so a peer's `EndpointId` would change each run.
//! The friends system identifies people by that id (it's the public key already
//! embedded in every share code), so it must stay stable across launches — and
//! across roles: the same machine gets the same id whether it's hosting,
//! viewing, or just sitting in the GUI.
//!
//! The key is the ed25519 secret (32 bytes) stored as hex on its own line, in a
//! `0600` file separate from `config.toml` — it's a secret, not a preference,
//! and keeping it out of the TOML means a hand-edit or a config reset can't
//! clobber your identity.
use anyhow::{Context, Result, bail};
use iroh::SecretKey;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
/// Returns `~/.config/pixelpass/identity.key` (or the XDG equivalent). Shares
/// the config directory with [`super::config`]; the parent is created on save.
pub fn identity_path() -> Result<PathBuf> {
Ok(super::config::config_path()?
.parent()
.context("config path has no parent directory")?
.join("identity.key"))
}
/// Load the persisted secret key, or generate-and-save one on first run.
///
/// A malformed file is a hard error rather than a silent regenerate: silently
/// minting a new identity would orphan every friend who has the old id, so we'd
/// rather fail loud and let the user notice (and decide) than lose it quietly.
pub fn load_or_create() -> Result<SecretKey> {
let path = identity_path()?;
match fs::read_to_string(&path) {
Ok(s) => parse_key(s.trim())
.with_context(|| format!("failed to parse the identity key at {}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let key = SecretKey::generate();
save(&key)?;
tracing::info!(id = %key.public(), "generated a new persistent identity");
Ok(key)
}
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
}
}
fn parse_key(hex: &str) -> Result<SecretKey> {
let bytes = decode_hex(hex)?;
let arr: [u8; 32] = bytes
.try_into()
.map_err(|_| anyhow::anyhow!("identity key must be 32 bytes (64 hex chars)"))?;
Ok(SecretKey::from_bytes(&arr))
}
/// Atomic, `0600` write: tempfile-in-same-dir, chmod, then rename. Same
/// approach as [`super::config::save`], but with restrictive perms applied
/// before the rename so the secret is never briefly world-readable.
pub fn save(key: &SecretKey) -> Result<()> {
let path = identity_path()?;
let parent = path
.parent()
.context("identity path has no parent directory")?;
fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?;
let tmp = parent.join(format!(".identity.key.tmp.{}", std::process::id()));
{
let mut f = fs::File::create(&tmp)
.with_context(|| format!("failed to create {}", tmp.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
f.set_permissions(fs::Permissions::from_mode(0o600))
.with_context(|| format!("failed to chmod {}", tmp.display()))?;
}
f.write_all(encode_hex(&key.to_bytes()).as_bytes())
.with_context(|| format!("failed to write {}", tmp.display()))?;
f.write_all(b"\n").ok();
f.sync_all().ok();
}
fs::rename(&tmp, &path)
.with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?;
Ok(())
}
fn encode_hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
fn decode_hex(s: &str) -> Result<Vec<u8>> {
if !s.len().is_multiple_of(2) {
bail!("hex string has an odd length");
}
(0..s.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&s[i..i + 2], 16)
.with_context(|| format!("invalid hex byte at offset {i}"))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_round_trips() {
let bytes: Vec<u8> = (0u8..=255).collect();
let encoded = encode_hex(&bytes);
assert_eq!(encoded.len(), bytes.len() * 2);
assert_eq!(decode_hex(&encoded).unwrap(), bytes);
}
#[test]
fn key_round_trips_through_hex() {
let key = SecretKey::generate();
let hex = encode_hex(&key.to_bytes());
let parsed = parse_key(&hex).unwrap();
assert_eq!(parsed.to_bytes(), key.to_bytes());
assert_eq!(parsed.public(), key.public());
}
#[test]
fn rejects_wrong_length() {
assert!(parse_key("dead").is_err());
assert!(parse_key("").is_err());
}
#[test]
fn rejects_odd_and_nonhex() {
assert!(decode_hex("abc").is_err());
assert!(decode_hex("zz").is_err());
}
}
+10
View File
@@ -1,8 +1,18 @@
pub mod alpn;
pub mod bandwidth;
pub mod config;
// The friends stack (persistent identity + control plane) is GUI-only — a
// headless CLI host runs no presence service — so it's gated with the feature
// that pulls the rest of the GUI, keeping the headless build lean.
#[cfg(feature = "gui")]
pub mod control;
pub mod deps;
pub mod display;
pub mod endpoint;
#[cfg(feature = "gui")]
pub mod friends;
#[cfg(feature = "gui")]
pub mod identity;
pub mod output;
pub mod process;
pub mod signal;
+34
View File
@@ -58,6 +58,11 @@ pub enum Event<'a> {
ViewerRefused { reason: &'a str },
/// Viewer-side: the local player URL is ready to open.
Connected { url: &'a str },
/// Per-app audio routing state (only emitted when `--app` is set). `routed`
/// = the chosen app's audio is now reaching viewers; `lost` = its last
/// stream went away. Under `--strict-audio`, `lost` means viewers currently
/// hear silence; without it, viewers fall back to whole-desktop audio.
AppAudio { state: AppAudioState },
}
#[derive(Serialize)]
@@ -67,6 +72,13 @@ pub enum CaptureState {
Stopped,
}
#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AppAudioState {
Routed,
Lost,
}
/// Emit one event as a JSON line on stdout, flushed. No-op unless JSON
/// output was enabled with [`set_json`], so call sites can sprinkle these
/// unconditionally without branching.
@@ -85,3 +97,25 @@ pub fn emit(event: Event) {
Err(e) => tracing::warn!("failed to serialize event: {e}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
// The app_audio event is the wire contract peerspeak parses to drive its
// echo warning; pin the exact shape so a rename here is caught here.
#[test]
fn app_audio_event_wire_shape() {
let routed = serde_json::to_string(&Event::AppAudio {
state: AppAudioState::Routed,
})
.unwrap();
assert_eq!(routed, r#"{"event":"app_audio","state":"routed"}"#);
let lost = serde_json::to_string(&Event::AppAudio {
state: AppAudioState::Lost,
})
.unwrap();
assert_eq!(lost, r#"{"event":"app_audio","state":"lost"}"#);
}
}
+18 -5
View File
@@ -6,10 +6,19 @@ use std::process::{Command, Stdio};
///
/// The child gets its own session via `setsid(2)` and null stdio, so it
/// survives the parent exiting and doesn't take a SIGKILL cascade when
/// pixelpass dies. The `Child` is dropped immediately — `std::process::Child::drop`
/// does not kill the process on Unix.
/// pixelpass dies.
///
/// A detached reaper thread `wait()`s the child so it doesn't linger as a
/// `<defunct>` zombie under a long-lived parent — the `--gui` front-end launches
/// players itself and lives for the whole session, and `std::process::Child`
/// (unlike tokio's) has no orphan reaping, so simply dropping the handle would
/// leak a zombie per closed player. If the parent exits while the player is
/// still up, the reaper thread dies with it but the `setsid`'d player survives
/// and is reaped by init. (A double-fork would also avoid the zombie, but
/// `fork(2)` followed by non-trivial work in this multithreaded process is
/// unsound — the reaper thread is the safe equivalent.)
pub fn spawn_detached(prog: &str, args: &[&str]) -> io::Result<()> {
unsafe {
let child = unsafe {
Command::new(prog)
.args(args)
.stdin(Stdio::null())
@@ -19,7 +28,11 @@ pub fn spawn_detached(prog: &str, args: &[&str]) -> io::Result<()> {
nix::unistd::setsid().ok();
Ok(())
})
.spawn()?;
}
.spawn()?
};
std::thread::spawn(move || {
let mut child = child;
let _ = child.wait();
});
Ok(())
}
+10 -3
View File
@@ -7,10 +7,17 @@ pub fn install_ctrl_c() -> CancellationToken {
let token = CancellationToken::new();
let trigger = token.clone();
tokio::spawn(async move {
if tokio::signal::ctrl_c().await.is_ok() {
tracing::info!("ctrl-c received, shutting down");
trigger.cancel();
if let Err(e) = tokio::signal::ctrl_c().await {
// Installing the handler failed — ctrl-c won't trigger a graceful
// shutdown. Say so instead of failing silently; the user can still
// kill the process, and the second-ctrl-c arm below would only fail
// the same way, so bail out of the task.
tracing::warn!("could not install ctrl-c handler: {e}; ctrl-c won't shut down cleanly");
return;
}
tracing::info!("ctrl-c received, shutting down");
trigger.cancel();
if tokio::signal::ctrl_c().await.is_ok() {
tracing::warn!("second ctrl-c — exiting now");
std::process::exit(130);
+648
View File
@@ -0,0 +1,648 @@
//! `pixelpass doctor` — environment diagnostics.
//!
//! Screen-share failures are usually not pixelpass bugs but environment gaps:
//! a missing GStreamer plugin, an X vs. Wayland mismatch, or — the common one —
//! a GPU/driver with no working VA-API H.264 encoder, so the default
//! `vah264enc` pipeline never produces a byte and the viewer "can't connect."
//! `doctor` probes all of that up front and prints one actionable report, so a
//! remote tester can read it over a call instead of us guessing from logs. It
//! also validates any X11/Wayland test environment we stand up.
//!
//! Unlike [`crate::common::deps::check_host_binaries`], which bails on the first
//! missing dependency, doctor runs *every* check and reports them together — a
//! diagnostic wants the whole picture, not the first failure.
use anyhow::Result;
use std::time::Duration;
use crate::common::deps;
use crate::common::display::DisplayServer;
use crate::common::endpoint;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
/// Working as needed.
Ok,
/// Degraded but not fatal (e.g. a fallback path is available).
Warn,
/// Screen-sharing will not work until this is fixed.
Fail,
/// Neutral fact, no judgement.
Info,
}
impl Status {
fn icon(self) -> char {
match self {
Self::Ok => '✓',
Self::Warn => '!',
Self::Fail => '✗',
Self::Info => '·',
}
}
}
/// One line in the report: a status, a short label, a detail, and an optional
/// remediation hint printed on its own indented line.
pub struct Check {
pub status: Status,
pub label: String,
pub detail: String,
pub hint: Option<String>,
}
impl Check {
fn new(status: Status, label: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
status,
label: label.into(),
detail: detail.into(),
hint: None,
}
}
fn ok(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self::new(Status::Ok, label, detail)
}
fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self::new(Status::Warn, label, detail)
}
fn fail(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self::new(Status::Fail, label, detail)
}
fn info(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self::new(Status::Info, label, detail)
}
fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
}
/// Tally of the non-trivial statuses across every section.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Summary {
pub fails: usize,
pub warns: usize,
}
/// A named group of checks, printed under a header.
struct Section {
name: &'static str,
checks: Vec<Check>,
}
/// Run all diagnostics and print the report. Always prints; the process exit
/// code is non-zero only when a hard failure (a `Fail`) was found, so scripts
/// and CI can gate on it while a human still sees everything.
pub async fn run(relay: Option<String>) -> Result<()> {
let display = DisplayServer::detect();
let sections = vec![
system_section(display),
capture_section(display),
encode_section(),
mux_audio_section(),
viewer_section(),
network_section(relay.as_deref()).await,
];
print_report(&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"));
}
}
+12 -4
View File
@@ -261,12 +261,20 @@ mod tests {
#[test]
fn capture_state_round_trips() {
assert!(matches!(
parse(Event::Capture { state: EmitState::Started }),
ChildEvent::Capture { state: CaptureState::Started }
parse(Event::Capture {
state: EmitState::Started
}),
ChildEvent::Capture {
state: CaptureState::Started
}
));
assert!(matches!(
parse(Event::Capture { state: EmitState::Stopped }),
ChildEvent::Capture { state: CaptureState::Stopped }
parse(Event::Capture {
state: EmitState::Stopped
}),
ChildEvent::Capture {
state: CaptureState::Stopped
}
));
}
+88
View File
@@ -0,0 +1,88 @@
//! Share-code wrapping: carrying the host's stable friend id alongside the
//! one-shot video ticket.
//!
//! A bare video ticket identifies only the host's *ephemeral* video endpoint,
//! so two people who meet over one can't learn each other's stable friend id —
//! the thing the friends system needs. The GUI host therefore wraps its ticket
//! with its control-plane [`EndpointId`]; the viewer unwraps it, dials the
//! video ticket as before, and now also knows who to befriend (and announces
//! itself back over the control plane so the host learns the viewer in turn).
//!
//! Format: `pixelpassF1:<host-control-id>.<bare-ticket>`. Both the id and the
//! ticket are base32 text with no `.`, so a single `.` separator is
//! unambiguous. [`unwrap`] is lenient: anything without the prefix is treated
//! as a bare ticket, so a plain CLI ticket pasted into the GUI still works (it
//! just offers no friend option). The host name isn't carried here — the
//! viewer's announcement triggers a name exchange over the control plane.
use std::str::FromStr;
use iroh::EndpointId;
/// Prefix marking a wrapped friend code. The `F1` is the wrap-format version,
/// bumped if the layout ever changes.
const MAGIC: &str = "pixelpassF1:";
/// Wrap a bare ticket with the host's control id, for display/copy/QR.
pub fn wrap(host_id: EndpointId, ticket: &str) -> String {
format!("{MAGIC}{host_id}.{ticket}")
}
/// Split an input into `(host control id if it was a wrapped code, bare
/// ticket)`. A bare or unrecognised input yields `(None, trimmed input)` so the
/// viewer path stays identical to before for plain tickets.
pub fn unwrap(code: &str) -> (Option<EndpointId>, String) {
let code = code.trim();
if let Some(rest) = code.strip_prefix(MAGIC)
&& let Some((id_str, ticket)) = rest.split_once('.')
&& let Ok(id) = EndpointId::from_str(id_str)
&& !ticket.is_empty()
{
return (Some(id), ticket.to_string());
}
(None, code.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_id() -> EndpointId {
iroh::SecretKey::generate().public()
}
#[test]
fn wrap_unwrap_round_trips() {
let id = sample_id();
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
let code = wrap(id, ticket);
let (got_id, got_ticket) = unwrap(&code);
assert_eq!(got_id, Some(id));
assert_eq!(got_ticket, ticket);
}
#[test]
fn bare_ticket_passes_through() {
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
let (id, got) = unwrap(ticket);
assert_eq!(id, None);
assert_eq!(got, ticket);
}
#[test]
fn trims_surrounding_whitespace() {
let ticket = "endpointaabwxjex";
let (id, got) = unwrap(&format!(" {} ", wrap(sample_id(), ticket)));
assert!(id.is_some());
assert_eq!(got, ticket);
}
#[test]
fn malformed_wrapped_code_falls_back_to_bare() {
// Prefix present but the id isn't a valid EndpointId → treat the whole
// thing as a (doomed) bare ticket rather than panicking.
let (id, got) = unwrap("pixelpassF1:not-an-id.endpointaa");
assert_eq!(id, None);
assert_eq!(got, "pixelpassF1:not-an-id.endpointaa");
}
}
+1225 -41
View File
File diff suppressed because it is too large Load Diff
+299
View File
@@ -0,0 +1,299 @@
//! The always-on friends presence service.
//!
//! A control-plane iroh endpoint ([`endpoint::bind_control`]) that lives for the
//! whole GUI session on its own thread with a current-thread tokio runtime — the
//! GUI is a synchronous winit/egui loop, so iroh's async work can't run on it
//! (the same reason [`super::tray`] has its own thread + runtime).
//!
//! Inbound control messages are forwarded over a std mpsc channel the UI drains
//! each [`super::PixelPassApp::tick`]; the [`Waker`] is pinged on arrival so a
//! message wakes the loop even while the window is hidden to the tray — the same
//! trick the headless-child reader uses.
use std::sync::Arc;
use std::sync::mpsc::{self, Receiver};
use std::thread;
use iroh::{Endpoint, EndpointId};
use tokio::sync::mpsc as tmpsc;
use super::Waker;
use crate::common::{
control::{self, ControlMsg, Inbound},
endpoint, identity,
};
/// A command the UI hands the presence service over [`PresenceHandle`].
enum Command {
/// Deliver one message, once, fire-and-forget (friend request/accept/decline
/// and the presence `Hello`). A failure is logged, not retried.
Send { peer: EndpointId, msg: ControlMsg },
/// Begin — or replace — a share campaign: push `msg` (a
/// [`ControlMsg::ShareCode`]) to every peer in `peers`, retrying the ones
/// that are offline until they're reached or the campaign is stopped. Each
/// success emits a [`PresenceEvent::ShareDelivered`]. Replaces any campaign
/// already running (a fresh host session supersedes the previous code).
StartShare {
msg: ControlMsg,
peers: Vec<EndpointId>,
},
/// Stop the active share campaign — the host stopped or left the screen, so
/// the perishable code is no longer valid and offline friends shouldn't keep
/// being chased.
StopShare,
}
/// Something the service surfaces to the UI, drained each tick.
pub enum PresenceEvent {
/// A control message arrived from a peer.
Message(Inbound),
/// A share-campaign code reached `peer` (its ACK came back). Lets the host
/// screen flip that friend's row from "retrying" to "delivered."
ShareDelivered { peer: EndpointId },
}
/// How long to wait before re-attempting delivery to friends who were offline
/// on the previous round of a share campaign.
const SHARE_RETRY: std::time::Duration = std::time::Duration::from_secs(5);
/// Handle the GUI holds for the presence service. Dropping it doesn't stop the
/// service (the thread is detached; the endpoint closes when the process exits)
/// — it just stops the UI from draining inbound messages.
pub struct PresenceHandle {
/// Our stable control-plane id — what friends know us by, and what we embed
/// in a wrapped share code so a viewer can find us.
id: EndpointId,
/// Service events (inbound messages + share receipts), drained by
/// [`PresenceHandle::drain`] each tick.
rx: Receiver<PresenceEvent>,
/// Commands handed to the service thread. Unbounded tokio sender so the sync
/// UI can enqueue without blocking or being inside the runtime.
out_tx: tmpsc::UnboundedSender<Command>,
}
impl PresenceHandle {
/// Our stable control-plane id.
pub fn id(&self) -> EndpointId {
self.id
}
/// Pull every service event received since the last call. Collected by the
/// caller so it can take `&mut self` while handling them.
pub fn drain(&self) -> Vec<PresenceEvent> {
std::iter::from_fn(|| self.rx.try_recv().ok()).collect()
}
/// Enqueue a one-shot message for delivery to `peer`. Fire-and-forget from
/// the UI's view; the service connects, delivers, and logs a failure. A send
/// error here only means the service thread is gone.
pub fn send(&self, peer: EndpointId, msg: ControlMsg) {
self.command(Command::Send { peer, msg });
}
/// Begin (or replace) a share campaign pushing `msg` to `peers`, retrying
/// offline friends until [`PresenceHandle::stop_share`] or the next call.
pub fn start_share(&self, msg: ControlMsg, peers: Vec<EndpointId>) {
self.command(Command::StartShare { msg, peers });
}
/// Stop the active share campaign (host stopped — the code is now stale).
pub fn stop_share(&self) {
self.command(Command::StopShare);
}
fn command(&self, cmd: Command) {
if self.out_tx.send(cmd).is_err() {
tracing::warn!("presence: service thread gone; dropping command");
}
}
}
/// Start the presence service. Returns `None` if the persistent identity can't
/// be loaded — the GUI then simply runs without friends features rather than
/// refusing to start. The endpoint binds asynchronously on the spawned thread;
/// our id is known immediately because it derives from the saved key, so we can
/// fail-fast and log it without waiting on the relay handshake.
pub fn start(waker: Waker, relay: Option<String>) -> Option<PresenceHandle> {
let id: EndpointId = match identity::load_or_create() {
Ok(key) => key.public(),
Err(e) => {
tracing::warn!("presence: no identity, friends features disabled: {e:#}");
return None;
}
};
tracing::info!(%id, "presence: starting control service");
let (tx, rx) = mpsc::channel::<PresenceEvent>();
let (out_tx, out_rx) = tmpsc::unbounded_channel::<Command>();
thread::Builder::new()
.name("pixelpass-presence".into())
.spawn(move || run(relay, id, tx, out_rx, waker))
.map_err(|e| tracing::warn!("presence: could not spawn service thread: {e}"))
.ok()?;
Some(PresenceHandle { id, rx, out_tx })
}
/// Thread body: a current-thread tokio runtime that binds the control endpoint,
/// runs the accept loop, bridges inbound messages to the UI channel, and
/// delivers outbound messages the UI enqueues.
fn run(
relay: Option<String>,
id: EndpointId,
tx: mpsc::Sender<PresenceEvent>,
mut out_rx: tmpsc::UnboundedReceiver<Command>,
waker: Waker,
) {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
tracing::error!("presence: failed to build runtime: {e}");
return;
}
};
rt.block_on(async move {
let ep = match endpoint::bind_control(relay.as_deref()).await {
Ok(ep) => ep,
Err(e) => {
tracing::error!("presence: failed to bind control endpoint: {e:#}");
return;
}
};
tracing::info!(%id, "presence: control endpoint online");
// One async→sync bridge for *everything* the UI sees: every producer
// (the accept loop and the share campaign) pushes a `PresenceEvent` into
// `ui_tx`; this task drains it onto the std channel and wakes the loop so
// the event lands even while the window is hidden to the tray.
let (ui_tx, mut ui_rx) = tmpsc::channel::<PresenceEvent>(64);
let forward = tokio::spawn(async move {
while let Some(event) = ui_rx.recv().await {
if tx.send(event).is_err() {
break; // UI gone
}
waker.wake();
}
});
// Wrap inbound control messages as events and feed the bridge.
let (itx, mut irx) = tmpsc::channel::<Inbound>(32);
let inbound_ui = ui_tx.clone();
let inbound = tokio::spawn(async move {
while let Some(msg) = irx.recv().await {
if inbound_ui.send(PresenceEvent::Message(msg)).await.is_err() {
break;
}
}
});
// Handle UI commands: one-shot sends each on their own task, and a single
// abortable share campaign (StartShare replaces it, StopShare cancels it).
let cmd_ep = ep.clone();
let commands = tokio::spawn(async move {
let mut share: Option<tokio::task::JoinHandle<()>> = None;
while let Some(cmd) = out_rx.recv().await {
match cmd {
Command::Send { peer, msg } => {
let ep = cmd_ep.clone();
tokio::spawn(async move {
if let Err(e) = control::send(&ep, peer, &msg).await {
tracing::warn!(%peer, "presence: outbound send failed: {e:#}");
}
});
}
Command::StartShare { msg, peers } => {
if let Some(t) = share.take() {
t.abort();
}
let ep = cmd_ep.clone();
let ui = ui_tx.clone();
share = Some(tokio::spawn(run_share(ep, msg, peers, ui)));
}
Command::StopShare => {
if let Some(t) = share.take() {
t.abort();
}
}
}
}
});
control::serve(ep, itx).await;
forward.abort();
inbound.abort();
commands.abort();
});
}
/// Push `msg` to every peer in `peers`, retrying the ones that are offline every
/// [`SHARE_RETRY`] until all are delivered (or the task is aborted by a
/// StartShare/StopShare). Emits one [`PresenceEvent::ShareDelivered`] per peer
/// the moment its ACK comes back — that ACK *is* the delivery signal.
///
/// Each round fires all still-pending peers **concurrently**, so a single
/// offline friend's ~10s connect timeout doesn't serialise the whole round
/// (which it did when peers were tried one at a time).
async fn run_share(
ep: Endpoint,
msg: ControlMsg,
mut pending: Vec<EndpointId>,
ui: tmpsc::Sender<PresenceEvent>,
) {
// The code is immutable for the campaign's life; share it across the
// per-peer tasks via an `Arc` rather than re-cloning the payload each round.
let msg = Arc::new(msg);
while !pending.is_empty() {
let mut round = tokio::task::JoinSet::new();
for peer in pending {
let ep = ep.clone();
let msg = Arc::clone(&msg);
round.spawn(async move {
match control::send(&ep, peer, &msg).await {
Ok(()) => (peer, true),
Err(e) => {
tracing::debug!(%peer, "presence: share not yet delivered: {e:#}");
(peer, false)
}
}
});
}
let mut still = Vec::new();
while let Some(joined) = round.join_next().await {
let (peer, delivered) = match joined {
Ok(outcome) => outcome,
// A send task panicking is unexpected; log and drop that peer
// from the campaign rather than abort the whole round. (A
// campaign-level abort drops this future entirely — we never
// observe that as a JoinError here.)
Err(e) => {
tracing::warn!("presence: share task failed: {e}");
continue;
}
};
if delivered {
tracing::info!(%peer, "presence: shared code delivered");
if ui
.send(PresenceEvent::ShareDelivered { peer })
.await
.is_err()
{
return; // UI gone — nothing left to report to
}
} else {
still.push(peer);
}
}
if still.is_empty() {
break;
}
pending = still;
tokio::time::sleep(SHARE_RETRY).await;
}
tracing::info!("presence: share campaign complete");
}
+456
View File
@@ -0,0 +1,456 @@
//! User-customisable colour themes for the GUI.
//!
//! A theme is a small, curated *semantic* palette — backgrounds, text, an
//! accent, and the handful of status colours the app uses (streaming, waiting,
//! success, warning, error). That's deliberately a fixed set rather than a
//! passthrough of every [`egui::Visuals`] field: it's easy to author by hand,
//! covers the whole look of the app, and stays stable across egui upgrades.
//!
//! Themes serialise to TOML with colours as `#rrggbb` hex strings. Three
//! themes ship built in; users drop their own `*.toml` files in
//! `~/.config/pixelpass/themes/` (or save one from the in-app editor) and they
//! show up alongside the built-ins. A user file whose `name` matches a built-in
//! overrides it.
use std::path::PathBuf;
use anyhow::{Context, Result};
use directories::ProjectDirs;
use eframe::egui::{self, Color32};
use serde::{Deserialize, Serialize};
/// One colour theme: a curated semantic palette.
///
/// `#[serde(default)]` on the container means any field missing from a TOML
/// file falls back to the corresponding field of [`Theme::default`] (the
/// built-in Default Dark), so a partial or hand-trimmed file still loads.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Theme {
/// Display name, shown in the picker and used as the file stem on save.
pub name: String,
/// Base egui defaults to start from before applying the palette overrides.
pub dark: bool,
// ── chrome ────────────────────────────────────────────────────────
/// Window background.
#[serde(with = "hex")]
pub window_bg: Color32,
/// Panel / frame background.
#[serde(with = "hex")]
pub panel_bg: Color32,
/// Text-input and read-only field background (the ticket box, etc.).
#[serde(with = "hex")]
pub input_bg: Color32,
/// Primary text.
#[serde(with = "hex")]
pub text: Color32,
/// Secondary / de-emphasised text (hints, the version line).
#[serde(with = "hex")]
pub weak_text: Color32,
/// Accent: selection, hyperlinks, and the active/pressed widget fill.
#[serde(with = "hex")]
pub accent: Color32,
/// Button (and other interactive widget) resting background.
#[serde(with = "hex")]
pub button_bg: Color32,
/// Button background on hover.
#[serde(with = "hex")]
pub button_hovered: Color32,
// ── semantic status colours ───────────────────────────────────────
/// "● Streaming" indicator.
#[serde(with = "hex")]
pub streaming: Color32,
/// "● Waiting for viewers…" indicator.
#[serde(with = "hex")]
pub waiting: Color32,
/// Success notes, e.g. "✓ Copied to clipboard".
#[serde(with = "hex")]
pub success: Color32,
/// Non-fatal warnings, e.g. a host-full refusal.
#[serde(with = "hex")]
pub warning: Color32,
/// Errors.
#[serde(with = "hex")]
pub error: Color32,
}
impl Default for Theme {
fn default() -> Self {
default_dark()
}
}
impl Theme {
/// Build the egui [`Visuals`](egui::Visuals) this theme describes. Starts
/// from egui's dark or light defaults (so anything the palette doesn't name
/// stays sensible) and overrides the curated fields.
pub fn visuals(&self) -> egui::Visuals {
use egui::{Stroke, Visuals};
let mut v = if self.dark {
Visuals::dark()
} else {
Visuals::light()
};
v.dark_mode = self.dark;
v.window_fill = self.window_bg;
v.panel_fill = self.panel_bg;
v.faint_bg_color = self.panel_bg;
v.extreme_bg_color = self.input_bg;
v.override_text_color = Some(self.text);
// `.weak()` text resolves via `weak_text_color()`, which derives from
// `text` unless this is set — so without it the weak-text field is dead.
v.weak_text_color = Some(self.weak_text);
v.hyperlink_color = self.accent;
v.error_fg_color = self.error;
v.warn_fg_color = self.warning;
// A translucent accent reads well as a selection highlight on either a
// light or dark base.
v.selection.bg_fill =
Color32::from_rgba_unmultiplied(self.accent.r(), self.accent.g(), self.accent.b(), 96);
v.selection.stroke = Stroke::new(1.0, self.accent);
let text_stroke = Stroke::new(1.0, self.text);
let weak_stroke = Stroke::new(1.0, self.weak_text);
v.widgets.noninteractive.bg_fill = self.panel_bg;
v.widgets.noninteractive.weak_bg_fill = self.panel_bg;
v.widgets.noninteractive.fg_stroke = weak_stroke;
v.widgets.inactive.bg_fill = self.button_bg;
v.widgets.inactive.weak_bg_fill = self.button_bg;
v.widgets.inactive.fg_stroke = text_stroke;
v.widgets.hovered.bg_fill = self.button_hovered;
v.widgets.hovered.weak_bg_fill = self.button_hovered;
v.widgets.hovered.fg_stroke = text_stroke;
v.widgets.active.bg_fill = self.accent;
v.widgets.active.weak_bg_fill = self.accent;
v.widgets.active.fg_stroke = text_stroke;
v
}
}
// ── built-in themes ───────────────────────────────────────────────────────
/// Names of the built-in themes, in picker order.
pub const BUILTIN_NAMES: [&str; 3] = ["Default Dark", "Catppuccin Mocha", "Catppuccin Latte"];
/// Parse a built-in's hex literal, panicking on a typo (these are compile-time
/// constants we control, so a bad value is a bug, not user input).
fn c(hex: &str) -> Color32 {
parse_hex(hex).expect("built-in theme hex is valid")
}
/// The default theme — a neutral dark palette. Also [`Theme::default`].
pub fn default_dark() -> Theme {
Theme {
name: "Default Dark".to_string(),
dark: true,
window_bg: c("#1b1b1f"),
panel_bg: c("#242429"),
input_bg: c("#141417"),
text: c("#e6e6ea"),
weak_text: c("#a0a0a8"),
accent: c("#5aa0f2"),
button_bg: c("#33333a"),
button_hovered: c("#44444d"),
streaming: c("#6fdc8c"),
waiting: c("#f2c14e"),
success: c("#6fdc8c"),
warning: c("#f0a85a"),
error: c("#f2756f"),
}
}
/// Catppuccin Mocha (dark). <https://github.com/catppuccin/catppuccin>
fn catppuccin_mocha() -> Theme {
Theme {
name: "Catppuccin Mocha".to_string(),
dark: true,
window_bg: c("#1e1e2e"),
panel_bg: c("#181825"),
input_bg: c("#11111b"),
text: c("#cdd6f4"),
weak_text: c("#a6adc8"),
accent: c("#cba6f7"),
button_bg: c("#313244"),
button_hovered: c("#45475a"),
streaming: c("#a6e3a1"),
waiting: c("#f9e2af"),
success: c("#a6e3a1"),
warning: c("#fab387"),
error: c("#f38ba8"),
}
}
/// Catppuccin Latte (light). <https://github.com/catppuccin/catppuccin>
fn catppuccin_latte() -> Theme {
Theme {
name: "Catppuccin Latte".to_string(),
dark: false,
window_bg: c("#eff1f5"),
panel_bg: c("#e6e9ef"),
input_bg: c("#dce0e8"),
text: c("#4c4f69"),
weak_text: c("#6c6f85"),
accent: c("#8839ef"),
button_bg: c("#ccd0da"),
button_hovered: c("#bcc0cc"),
streaming: c("#40a02b"),
waiting: c("#df8e1d"),
success: c("#40a02b"),
warning: c("#fe640b"),
error: c("#d20f39"),
}
}
/// The built-in themes, in [`BUILTIN_NAMES`] order.
pub fn builtins() -> Vec<Theme> {
vec![default_dark(), catppuccin_mocha(), catppuccin_latte()]
}
/// Whether `name` is one of the built-ins (which are read-only — the editor
/// nudges you to save under a new name).
pub fn is_builtin(name: &str) -> bool {
BUILTIN_NAMES.contains(&name)
}
// ── on-disk themes ──────────────────────────────────────────────────────────
/// `~/.config/pixelpass/themes/` (or the XDG equivalent). Not created until a
/// theme is saved.
pub fn themes_dir() -> Result<PathBuf> {
let dirs = ProjectDirs::from("", "", "pixelpass")
.context("could not locate a config directory for pixelpass")?;
Ok(dirs.config_dir().join("themes"))
}
/// Parse every `*.toml` in the themes dir into a [`Theme`]. A file that fails
/// to parse is logged and skipped rather than aborting the whole list, so one
/// bad file can't hide the rest. Returns themes sorted by name.
pub fn list_user_themes() -> Vec<Theme> {
let Ok(dir) = themes_dir() else {
return Vec::new();
};
let Ok(entries) = std::fs::read_dir(&dir) else {
return Vec::new(); // dir doesn't exist yet → no user themes
};
let mut out = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
continue;
}
match std::fs::read_to_string(&path) {
Ok(s) => match toml::from_str::<Theme>(&s) {
Ok(mut t) => {
// Fall back to the file stem if the file omits a name.
if t.name.trim().is_empty() {
t.name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Unnamed")
.to_string();
}
out.push(t);
}
Err(e) => tracing::warn!("skipping theme {}: {e}", path.display()),
},
Err(e) => tracing::warn!("could not read theme {}: {e}", path.display()),
}
}
out.sort_by_key(|t| t.name.to_lowercase());
out
}
/// Built-ins plus user themes, in picker order: built-ins first (a user file
/// with a matching `name` overrides the built-in's colours in place), then any
/// remaining user themes alphabetically.
pub fn all_themes() -> Vec<Theme> {
let users = list_user_themes();
let mut out: Vec<Theme> = builtins()
.into_iter()
.map(|b| {
users
.iter()
.find(|u| u.name == b.name)
.cloned()
.unwrap_or(b)
})
.collect();
for u in users {
if !is_builtin(&u.name) {
out.push(u);
}
}
out
}
/// The theme with this `name`, or Default Dark if it can't be found (e.g. the
/// config names a theme whose file was deleted).
pub fn load_named(name: &str) -> Theme {
all_themes()
.into_iter()
.find(|t| t.name == name)
.unwrap_or_else(default_dark)
}
/// Write `theme` to `<themes_dir>/<slug>.toml` and return the path. Overwrites
/// an existing file with the same slug (i.e. saving a tweaked theme under the
/// same name updates it in place).
pub fn save_theme(theme: &Theme) -> Result<PathBuf> {
let dir = themes_dir()?;
std::fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
let slug = slugify(&theme.name);
let path = dir.join(format!("{slug}.toml"));
let body = toml::to_string_pretty(theme).context("failed to serialise theme to TOML")?;
let contents = format!(
"# PixelPass theme. Colours are #rrggbb hex strings.\n\
# Edit and re-pick it in Settings, or drop more .toml files in this folder.\n\n\
{body}"
);
std::fs::write(&path, contents)
.with_context(|| format!("failed to write {}", path.display()))?;
Ok(path)
}
/// Lowercase, replace runs of non-alphanumerics with a single hyphen, trim
/// hyphens. Empty input becomes `theme`.
fn slugify(name: &str) -> String {
let mut slug = String::new();
let mut prev_hyphen = false;
for ch in name.trim().chars() {
if ch.is_ascii_alphanumeric() {
slug.push(ch.to_ascii_lowercase());
prev_hyphen = false;
} else if !prev_hyphen {
slug.push('-');
prev_hyphen = true;
}
}
let slug = slug.trim_matches('-').to_string();
if slug.is_empty() {
"theme".to_string()
} else {
slug
}
}
// ── hex colour parsing ────────────────────────────────────────────────────
/// Parse `#rrggbb` into an opaque [`Color32`] (the leading `#` is optional).
/// An 8-digit `#rrggbbaa` is accepted leniently but its alpha is ignored —
/// theme colours are opaque, and `Color32`'s premultiplied storage can't
/// round-trip a straight alpha losslessly anyway. Returns `None` on malformed
/// input.
pub fn parse_hex(s: &str) -> Option<Color32> {
let s = s.trim();
let s = s.strip_prefix('#').unwrap_or(s);
if !matches!(s.len(), 6 | 8) || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let byte = |i: usize| u8::from_str_radix(&s[i..i + 2], 16).ok();
Some(Color32::from_rgb(byte(0)?, byte(2)?, byte(4)?))
}
/// Format a [`Color32`] as opaque `#rrggbb`.
pub fn to_hex(c: Color32) -> String {
let [r, g, b, _] = c.to_srgba_unmultiplied();
format!("#{r:02x}{g:02x}{b:02x}")
}
/// serde adaptor so `Color32` fields round-trip as hex strings in TOML.
mod hex {
use super::{parse_hex, to_hex};
use eframe::egui::Color32;
use serde::{Deserialize, Deserializer, Serializer, de::Error};
pub fn serialize<S: Serializer>(c: &Color32, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&to_hex(*c))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Color32, D::Error> {
let s = String::deserialize(d)?;
parse_hex(&s).ok_or_else(|| {
D::Error::custom(format!(
"invalid hex colour {s:?} (expected #rrggbb or #rrggbbaa)"
))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_round_trips() {
for (input, expect) in [
("#1e1e2e", Color32::from_rgb(0x1e, 0x1e, 0x2e)),
("aabbcc", Color32::from_rgb(0xaa, 0xbb, 0xcc)),
// 8-digit is accepted but the alpha is dropped (opaque rgb).
("#11223344", Color32::from_rgb(0x11, 0x22, 0x33)),
] {
assert_eq!(parse_hex(input).expect("parses"), expect);
}
assert_eq!(to_hex(Color32::from_rgb(0x1e, 0x1e, 0x2e)), "#1e1e2e");
// Opaque colours round-trip exactly.
let c = Color32::from_rgb(0xab, 0xcd, 0xef);
assert_eq!(parse_hex(&to_hex(c)), Some(c));
}
#[test]
fn hex_rejects_garbage() {
for bad in ["", "#fff", "#12345", "nothex", "#gggggg", "#1234567"] {
assert!(parse_hex(bad).is_none(), "{bad:?} should not parse");
}
}
#[test]
fn theme_toml_round_trips() {
let original = catppuccin_mocha();
let toml = toml::to_string_pretty(&original).unwrap();
let parsed: Theme = toml::from_str(&toml).unwrap();
assert_eq!(original, parsed);
// Colours serialise as hex strings, not RGBA tables.
assert!(toml.contains("window_bg = \"#1e1e2e\""), "{toml}");
}
#[test]
fn partial_toml_fills_from_default() {
// Only a name and one colour; everything else must fall back to Default Dark.
let parsed: Theme = toml::from_str("name = \"Partial\"\naccent = \"#ff0000\"").unwrap();
let base = default_dark();
assert_eq!(parsed.name, "Partial");
assert_eq!(parsed.accent, Color32::from_rgb(0xff, 0, 0));
assert_eq!(parsed.window_bg, base.window_bg); // filled from default
assert_eq!(parsed.text, base.text);
}
#[test]
fn slugify_is_filesystem_safe() {
assert_eq!(slugify("Catppuccin Mocha"), "catppuccin-mocha");
assert_eq!(slugify(" My Theme!! "), "my-theme");
assert_eq!(slugify("***"), "theme");
assert_eq!(slugify("Solarized/Dark"), "solarized-dark");
}
#[test]
fn builtins_match_names() {
let names: Vec<String> = builtins().iter().map(|t| t.name.clone()).collect();
let expected: Vec<String> = BUILTIN_NAMES.iter().map(|s| s.to_string()).collect();
assert_eq!(names, expected);
for t in builtins() {
assert!(is_builtin(&t.name));
}
}
}
+24
View File
@@ -87,6 +87,10 @@ struct PixelPassTray {
/// 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 {
@@ -131,6 +135,25 @@ impl ksni::Tray for PixelPassTray {
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![
@@ -202,6 +225,7 @@ pub fn start(proxy: EventLoopProxy<UserEvent>) -> Option<TrayHandle> {
status: TrayStatus::Idle,
icon,
proxy,
registered: registered_thread.clone(),
};
let handle = match tray.spawn().await {
Ok(handle) => handle,
+278 -52
View File
@@ -17,6 +17,15 @@
//! filtered audio twice (once via the routed stream, once via the
//! default-sink monitor loopback).
//!
//! - **Local monitor** (pactl shell-out, app mode only): rerouting *moves*
//! the chosen app off the sharer's speakers into the null-sink, so without
//! this the sharer would go deaf to the very content they're sharing. We
//! mirror the null-sink's monitor back to `@DEFAULT_SINK@` so the sharer
//! hears it too. Only the chosen app is in the null-sink — never the
//! desktop/call — so this can't echo back into the capture. It is loaded on
//! the first routed stream (after the default-sink loopback is gone, so the
//! two never coexist and feed back) and unloaded when the app stops.
//!
//! pactl is the right tool for the one-shot null-sink/loopback graph
//! mutations. libpipewire is dragged in only when per-stream filtering
//! is requested, because that needs registry-event subscription.
@@ -40,6 +49,11 @@ pub struct Routing {
/// first successful route. `Routing::shutdown` unloads whatever
/// remains.
loopback_module: Arc<Mutex<Option<u32>>>,
/// The `null-sink.monitor → @DEFAULT_SINK@` loopback that lets the sharer
/// hear the routed app. Shared with the event task, which loads it on the
/// first routed stream and unloads it when the app stops. `None` outside
/// app mode and whenever no app is currently routed.
local_monitor_module: Arc<Mutex<Option<u32>>>,
sink_name: String,
stream_router: Option<StreamRouter>,
event_task: Option<tokio::task::JoinHandle<()>>,
@@ -52,31 +66,46 @@ impl Routing {
let pid = std::process::id();
let sink_name = format!("pixelpass_capture_{pid}");
let sink_module =
load_module(&["module-null-sink", &format!("sink_name={sink_name}")])
.context("failed to load module-null-sink")?;
let sink_module = load_module(&["module-null-sink", &format!("sink_name={sink_name}")])
.context("failed to load module-null-sink")?;
// In strict per-app mode we never mirror the default sink: the viewer
// must hear *only* the chosen app, never the whole desktop (which would
// leak e.g. a voice call the sharer is in back to viewers — the echo
// bug A23). Without strict mode (whole-desktop share, or best-effort
// app filtering) we load the monitor loopback so the viewer hears
// system audio immediately and during any gap before the app routes.
// 20ms loopback latency keeps the mirrored audio tight; pactl's
// default of 200ms is enough to be perceptible.
let loopback_module = load_module(&[
"module-loopback",
"source=@DEFAULT_SINK@.monitor",
&format!("sink={sink_name}"),
"latency_msec=20",
])
.context("failed to load module-loopback (null-sink will be cleaned up on Drop)")?;
let strict_app = opts.app.is_some() && opts.strict_audio;
let loopback_module = if strict_app {
None
} else {
Some(
load_module(&[
"module-loopback",
"source=@DEFAULT_SINK@.monitor",
&format!("sink={sink_name}"),
"latency_msec=20",
])
.context("failed to load module-loopback (null-sink cleaned up on Drop)")?,
)
};
tracing::info!(
sink_module,
loopback_module,
?loopback_module,
strict_app,
%sink_name,
"audio routing: null-sink + loopback ready"
"audio routing: null-sink ready (loopback skipped in strict app mode)"
);
let loopback_arc = Arc::new(Mutex::new(Some(loopback_module)));
let loopback_arc = Arc::new(Mutex::new(loopback_module));
let local_monitor_arc = Arc::new(Mutex::new(None));
let mut routing = Self {
sink_module: Some(sink_module),
loopback_module: Arc::clone(&loopback_arc),
local_monitor_module: Arc::clone(&local_monitor_arc),
sink_name: sink_name.clone(),
stream_router: None,
event_task: None,
@@ -85,8 +114,11 @@ impl Routing {
if let Some(app) = &opts.app {
let (router, mut event_rx) = StreamRouter::spawn(app.clone(), sink_name.clone())?;
let loopback_for_task = Arc::clone(&loopback_arc);
let local_monitor_for_task = Arc::clone(&local_monitor_arc);
let sink_name_for_task = sink_name.clone();
let strict = opts.strict_audio;
let event_task = tokio::spawn(async move {
use crate::common::output::{self, AppAudioState};
while let Some(ev) = event_rx.recv().await {
match ev {
Event::FirstRoutedStream => {
@@ -97,11 +129,66 @@ impl Routing {
);
unload_module(id);
}
// Mirror the routed app back to the sharer's own
// speakers so they hear the content they're sharing.
// Loaded *after* the default-sink loopback is gone so
// the two never coexist (which would feed back), and
// sourced from the null-sink monitor — the chosen app
// only, never the desktop/call — so it can't echo into
// the capture.
if local_monitor_for_task.lock().unwrap().is_none() {
match load_module(&[
"module-loopback",
&format!("source={sink_name_for_task}.monitor"),
"sink=@DEFAULT_SINK@",
"latency_msec=20",
]) {
Ok(id) => {
tracing::info!(
module = id,
"audio routing: local monitor loaded (sharer hears the shared app)"
);
*local_monitor_for_task.lock().unwrap() = Some(id);
}
Err(e) => tracing::warn!(
"audio routing: failed to load local monitor loopback: {e:#}"
),
}
}
// Tell the front-end the chosen app's audio is live.
output::emit(output::Event::AppAudio {
state: AppAudioState::Routed,
});
}
Event::LastRoutedStreamGone => {
// Routed app exited mid-session. Restore the
// default-sink loopback so the viewer hears
// system audio again instead of silence.
// Routed app exited/paused mid-session. Notify the
// front-end either way; the recovery differs by mode.
output::emit(output::Event::AppAudio {
state: AppAudioState::Lost,
});
// The shared app is gone, so its null-sink is silent:
// stop mirroring it to the sharer's speakers. Re-loads
// on the next FirstRoutedStream if the app resumes.
if let Some(id) = local_monitor_for_task.lock().unwrap().take() {
tracing::info!(
module = id,
"audio routing: last routed stream gone → unloading local monitor"
);
unload_module(id);
}
if strict {
// Strict mode: do NOT restore the whole-desktop
// loopback. Viewers hear silence until the app
// produces audio again — never the rest of the
// desktop (call included).
tracing::info!(
"audio routing: strict mode — last routed stream gone, leaving viewers silent"
);
continue;
}
// Best-effort mode: restore the default-sink loopback
// so the viewer hears system audio again instead of
// silence.
if loopback_for_task.lock().unwrap().is_some() {
continue;
}
@@ -131,6 +218,16 @@ impl Routing {
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)
}
@@ -141,7 +238,10 @@ impl Routing {
/// Stop the stream router (if any), then unload loopback (if still
/// loaded), then unload the null-sink. Order matters: PipeWire can
/// leave zombie links if you destroy a sink with active inputs.
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() {
router.shutdown();
}
@@ -151,29 +251,41 @@ impl Routing {
if let Some(id) = self.loopback_module.lock().unwrap().take() {
unload_module(id);
}
// Unload the local monitor before the null-sink it reads from, so the
// sink has no active loopback reader when it's destroyed.
if let Some(id) = self.local_monitor_module.lock().unwrap().take() {
unload_module(id);
}
if let Some(id) = self.sink_module.take() {
unload_module(id);
}
}
/// Consume the routing and tear it all down now. `Drop` is the backstop;
/// the real work lives in [`cleanup`](Self::cleanup).
pub fn shutdown(mut self) {
self.cleanup();
}
}
impl Drop for Routing {
fn drop(&mut self) {
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);
}
if let Some(id) = self.sink_module.take() {
unload_module(id);
}
self.cleanup();
}
}
/// The app-audio state to announce at capture start, if any. Only strict per-app
/// mode warrants one: there the loopback is suppressed, so the viewer hears
/// silence until the chosen app's first stream routes — surface that as an
/// initial `lost`. In every other mode (whole-desktop, or best-effort app
/// filtering) the loopback keeps audio flowing from the outset, so there is no
/// initial gap to report. Pure: no I/O, so the emit decision is unit-testable.
pub(super) fn initial_app_audio_state(
opts: &HostOpts,
) -> Option<crate::common::output::AppAudioState> {
(opts.app.is_some() && opts.strict_audio).then_some(crate::common::output::AppAudioState::Lost)
}
// ──────────────────────────────────────────────────────────────────────
// App enumeration (interactive picker source)
// ──────────────────────────────────────────────────────────────────────
@@ -207,7 +319,9 @@ fn parse_sink_inputs(stdout: &[u8]) -> Result<Vec<App>> {
serde_json::from_slice(stdout).context("pactl returned unparseable JSON")?;
let mut counts: BTreeMap<String, u32> = BTreeMap::new();
for entry in entries {
let Some(name) = entry.properties.application_name else { continue };
let Some(name) = entry.properties.application_name else {
continue;
};
let trimmed = name.trim();
if trimmed.is_empty() {
continue;
@@ -251,6 +365,9 @@ fn load_module(args: &[&str]) -> Result<u32> {
.context("pactl returned non-UTF-8")?
.trim()
.to_string();
// Genuinely 32-bit, unlike `object.serial`: this is a PulseAudio module
// index (`pa_module.index`, `uint32_t`), which `pactl unload-module` takes
// back verbatim. Do not widen it.
id_str
.parse::<u32>()
.with_context(|| format!("pactl returned unexpected module ID: {id_str:?}"))
@@ -361,16 +478,14 @@ fn run_router(
) -> Result<()> {
use pipewire::{self as pw, types::ObjectType};
let main_loop = pw::main_loop::MainLoopRc::new(None)
.context("pw main loop construction failed")?;
let context = pw::context::ContextRc::new(&main_loop, None)
.context("pw context construction failed")?;
let 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 registry = core.get_registry_rc().context("pw get_registry failed")?;
let state = Rc::new(RefCell::new(RouterState {
sink_serial: None,
@@ -411,28 +526,39 @@ fn run_router(
let _reg_listener = registry
.add_listener_local()
.global(move |obj| {
let Some(reg) = registry_weak.upgrade() else { return };
let Some(reg) = registry_weak.upgrade() else {
return;
};
match obj.type_ {
ObjectType::Node => {
let Some(props) = obj.props.as_ref() else { return };
let Some(props) = obj.props.as_ref() else {
return;
};
if props.get("node.name") == Some(sink_name_owned.as_str()) {
if let Some(serial) = props
.get("object.serial")
.and_then(|s| s.parse::<u32>().ok())
{
state_for_reg.borrow_mut().sink_serial = Some(serial);
tracing::info!(
serial,
"audio routing: pixelpass sink registered"
);
try_flush(&state_for_reg, &event_tx_for_reg);
match props.get("object.serial").and_then(parse_object_serial) {
Some(serial) => {
state_for_reg.borrow_mut().sink_serial = Some(serial);
tracing::info!(serial, "audio routing: pixelpass sink registered");
try_flush(&state_for_reg, &event_tx_for_reg);
}
// Never silently: without a serial `try_flush` can
// never route anything, so the whole app-filter mode
// is dead and the only symptom is missing audio.
None => tracing::warn!(
node_id = obj.id,
serial = props.get("object.serial").unwrap_or("<absent>"),
"audio routing: pixelpass sink has no usable object.serial; \
stream rerouting disabled"
),
}
return;
}
if props.get("media.class") != Some("Stream/Output/Audio") {
return;
}
let Some(app) = props.get("application.name") else { return };
let Some(app) = props.get("application.name") else {
return;
};
if !app.eq_ignore_ascii_case(&filter_lower) {
return;
}
@@ -445,7 +571,9 @@ fn run_router(
try_flush(&state_for_reg, &event_tx_for_reg);
}
ObjectType::Metadata => {
let Some(props) = obj.props.as_ref() else { return };
let Some(props) = obj.props.as_ref() else {
return;
};
if props.get("metadata.name") != Some("default") {
return;
}
@@ -474,8 +602,30 @@ fn run_router(
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.
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 {
sink_serial: Option<u32>,
/// See [`parse_object_serial`] — 64-bit, and not interchangeable with
/// the `u32` node ids in `routed_node_ids` / `pending`.
sink_serial: Option<u64>,
default_metadata: Option<pipewire::metadata::Metadata>,
routed_node_ids: Vec<u32>,
pending: Vec<u32>,
@@ -537,3 +687,79 @@ fn try_flush(
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:?}");
}
}
}
+102 -14
View File
@@ -7,7 +7,7 @@ mod wayland;
mod x11;
use anyhow::{Result, bail};
use iroh::endpoint::{Connection, presets};
use iroh::endpoint::Connection;
use iroh::{Endpoint, EndpointAddr};
use iroh_tickets::endpoint::EndpointTicket;
use std::collections::HashMap;
@@ -17,7 +17,7 @@ use tokio_util::sync::CancellationToken;
use crate::cli::HostOpts;
use crate::common::{
alpn::ALPN, bandwidth, config, config::BandwidthStatus, deps, display::DisplayServer, output,
bandwidth, config, config::BandwidthStatus, deps, display::DisplayServer, endpoint, output,
signal, tunnel,
};
@@ -74,10 +74,7 @@ pub async fn run(opts: HostOpts) -> Result<()> {
let cancel = signal::install_ctrl_c();
let endpoint = Endpoint::builder(presets::N0)
.alpns(vec![ALPN.to_vec()])
.bind()
.await?;
let endpoint = endpoint::bind(opts.relay.as_deref()).await?;
// 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
@@ -259,7 +256,9 @@ fn spawn_kick_listener(sup_tx: mpsc::Sender<SupervisorMsg>) {
let Some(id) = line.trim().strip_prefix("kick ") else {
continue;
};
let msg = SupervisorMsg::KickViewer { id: id.trim().to_string() };
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() {
@@ -318,7 +317,11 @@ async fn supervise(
viewers.insert(id.clone(), cancel);
let active = viewers.len() as u32;
let _ = reply.send(Ok(port));
output::emit(output::Event::ViewerJoined { id: &id, active, max: max_viewers });
output::emit(output::Event::ViewerJoined {
id: &id,
active,
max: max_viewers,
});
tracing::info!(active, cap = max_viewers, "viewer joined");
}
SupervisorMsg::RemoveViewer { id } => {
@@ -328,7 +331,11 @@ async fn supervise(
continue;
}
let active = viewers.len() as u32;
output::emit(output::Event::ViewerLeft { id: &id, active, max: max_viewers });
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()
@@ -375,10 +382,25 @@ fn print_host_banner(
eprintln!("┌─ PixelPass · host ─────────────────────────────────────────");
eprintln!("│ display server : {display:?}");
eprintln!("│ capture : {}", capture_summary(opts));
eprintln!("│ quality : {}{}", quality.label, quality.dimensions_summary());
eprintln!(
"│ quality : {} — {}",
quality.label,
quality.dimensions_summary()
);
eprintln!("│ ({})", quality.note);
eprintln!("│ hw encode : {}", if opts.no_hwencode { "off (software x264)" } else { "on (VAAPI H.264)" });
eprintln!("│ max viewers : {} ({})", resolution.value, resolution.source.label());
eprintln!(
"│ hw encode : {}",
if opts.no_hwencode {
"off (software x264)"
} else {
"on (VAAPI H.264)"
}
);
eprintln!(
"│ max viewers : {} ({})",
resolution.value,
resolution.source.label()
);
eprintln!("");
if clipboard_ok {
eprintln!("│ Your share code has been copied to your clipboard.");
@@ -442,7 +464,9 @@ fn resolve_max_viewers(opts: &HostOpts, effective_bitrate: u32) -> MaxViewersRes
let n = bandwidth::recommended_max_viewers(upstream, effective_bitrate);
return MaxViewersResolution {
value: n,
source: MaxViewersSource::BandwidthMeasurement { safe_mbps: upstream },
source: MaxViewersSource::BandwidthMeasurement {
safe_mbps: upstream,
},
};
}
MaxViewersResolution {
@@ -464,9 +488,73 @@ fn copy_to_clipboard(text: &str) -> bool {
fn capture_summary(opts: &HostOpts) -> String {
let mut bits = vec![if opts.window { "window" } else { "fullscreen" }.to_string()];
if let Some(app) = &opts.app {
bits.push(format!("app-audio={app}"));
if opts.strict_audio {
bits.push(format!("app-audio={app} (strict)"));
} else {
bits.push(format!("app-audio={app}"));
}
} else {
bits.push("system-audio".to_string());
}
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);
}
}
+28 -3
View File
@@ -189,9 +189,32 @@ fn build_args(
"!".into(),
"queue".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
// (the "Source" preset, or a source already at/below the target height — we
@@ -308,7 +331,9 @@ async fn default_audio_monitor() -> Result<String> {
.arg("get-default-sink")
.output()
.await
.context("failed to run `pactl get-default-sink` (install pulseaudio-utils or pipewire-pulse)")?;
.context(
"failed to run `pactl get-default-sink` (install pulseaudio-utils or pipewire-pulse)",
)?;
if !output.status.success() {
bail!(
"pactl get-default-sink failed: {}",
+33 -7
View File
@@ -27,10 +27,26 @@ impl Quality {
/// values and resolves to one of the others at runtime (see [`resolve_auto`]).
fn preset(self) -> Option<Preset> {
let p = match self {
Quality::Source => Preset { max_height: None, bitrate: 6000, framerate: 30 },
Quality::High => Preset { max_height: Some(1080), bitrate: 4000, framerate: 30 },
Quality::Medium => Preset { max_height: Some(720), bitrate: 2500, framerate: 30 },
Quality::Low => Preset { max_height: Some(480), bitrate: 1000, framerate: 30 },
Quality::Source => Preset {
max_height: None,
bitrate: 6000,
framerate: 30,
},
Quality::High => Preset {
max_height: Some(1080),
bitrate: 4000,
framerate: 30,
},
Quality::Medium => Preset {
max_height: Some(720),
bitrate: 2500,
framerate: 30,
},
Quality::Low => Preset {
max_height: Some(480),
bitrate: 1000,
framerate: 30,
},
Quality::Auto => return None,
};
Some(p)
@@ -49,7 +65,12 @@ impl Quality {
/// Fixed presets in descending quality order — Auto walks this to find the
/// best one whose per-viewer bitrate fits the measured upstream budget.
const AUTO_LADDER: [Quality; 4] = [Quality::Source, Quality::High, Quality::Medium, Quality::Low];
const AUTO_LADDER: [Quality; 4] = [
Quality::Source,
Quality::High,
Quality::Medium,
Quality::Low,
];
/// Auto's fallback when there is no usable bandwidth measurement.
const AUTO_FALLBACK: Quality = Quality::Medium;
@@ -146,7 +167,9 @@ fn resolve_auto(safe_mbps: Option<f64>, sizing_viewers: u32) -> (Preset, String,
(
preset,
format!("Auto → {}", chosen.name()),
format!("auto: {safe_mbps:.1} Mbps safe ÷ {n} viewer(s) = {budget_mbps:.1} Mbps each"),
format!(
"auto: {safe_mbps:.1} Mbps safe ÷ {n} viewer(s) = {budget_mbps:.1} Mbps each"
),
)
}
None => {
@@ -154,7 +177,8 @@ fn resolve_auto(safe_mbps: Option<f64>, sizing_viewers: u32) -> (Preset, String,
(
preset,
format!("Auto → {}", AUTO_FALLBACK.name()),
"auto fallback — no bandwidth measurement (run `pixelpass --reconfigure`)".to_string(),
"auto fallback — no bandwidth measurement (run `pixelpass --reconfigure`)"
.to_string(),
)
}
}
@@ -181,6 +205,7 @@ mod tests {
HostOpts {
window: false,
app: None,
strict_audio: false,
display_server: None::<DisplayServerArg>,
quality,
bitrate: None,
@@ -189,6 +214,7 @@ mod tests {
no_hwencode: false,
max_viewers,
interactive: false,
relay: None,
}
}
+5 -2
View File
@@ -130,8 +130,11 @@ async fn run_accept_loop(listener: TcpListener, tx: broadcast::Sender<Arc<Vec<u8
let sock = match listener.accept().await {
Ok((s, _)) => s,
Err(e) => {
tracing::warn!("capture HTTP accept failed: {e}");
return;
// Most accept errors are transient (EMFILE from a brief FD spike,
// EINTR, etc.). Bailing on the first one would kill the entire
// viewer fanout for the rest of the session.
tracing::warn!("capture HTTP accept failed (continuing): {e}");
continue;
}
};
let rx = tx.subscribe();
+23 -11
View File
@@ -12,8 +12,7 @@ use ashpd::{
},
};
use nix::fcntl::{FcntlArg, FdFlag, fcntl};
use nix::unistd::close;
use std::os::fd::{AsFd, IntoRawFd, OwnedFd, RawFd};
use std::os::fd::{AsFd, AsRawFd, OwnedFd, RawFd};
use super::pipeline::{self, CaptureHandle};
use super::quality::EffectiveQuality;
@@ -26,7 +25,11 @@ pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<Captur
.context("could not reach the xdg-desktop-portal ScreenCast interface")?;
let session = proxy.create_session().await?;
let source = if opts.window { SourceType::Window } else { SourceType::Monitor };
let source = if opts.window {
SourceType::Window
} else {
SourceType::Monitor
};
proxy
.select_sources(
&session,
@@ -57,11 +60,14 @@ pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<Captur
let pw_fd: OwnedFd = proxy.open_pipe_wire_remote(&session).await?;
tracing::info!(node_id, width = w, height = h, "portal handshake complete");
// The fd is CLOEXEC by default; the gst child needs to inherit it across
// exec. We then leak it via into_raw_fd so its lifetime spans the spawn,
// and close the parent's copy once gst is running (the pipeline's
// after_spawn hook below).
// exec, so clear CLOEXEC. We keep the OwnedFd alive across the spawn (gst
// inherits its own copy at exec) by moving it into the after_spawn hook,
// which drops — and so closes — the parent's copy once gst is running. If
// pipeline::spawn errors *before* calling the hook (e.g. audio setup or the
// gst spawn fails), the unused closure is dropped, dropping the fd just the
// same — so the portal fd never leaks on the error path.
clear_cloexec(&pw_fd)?;
let raw_fd: RawFd = pw_fd.into_raw_fd();
let raw_fd: RawFd = pw_fd.as_raw_fd();
let source_args = vec![
"pipewiresrc".to_string(),
@@ -70,10 +76,16 @@ pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<Captur
"do-timestamp=true".to_string(),
];
pipeline::spawn(opts, quality, Some((w as u32, h as u32)), source_args, move || {
// Parent no longer needs the pipewire fd — gst inherited its own copy.
let _ = close(raw_fd);
})
pipeline::spawn(
opts,
quality,
Some((w as u32, h as u32)),
source_args,
move || {
// Parent no longer needs the pipewire fd — gst inherited its own copy.
drop(pw_fd);
},
)
.await
}
+14 -4
View File
@@ -37,12 +37,22 @@ 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![
"ximagesrc".to_string(),
// Full frames (no damage regions) to avoid partial-update artifacts;
// use-damage=true is a later CPU optimization. show-pointer matches
// Wayland's CursorMode::Embedded.
"use-damage=false".to_string(),
// show-pointer matches Wayland's CursorMode::Embedded.
use_damage.to_string(),
"show-pointer=true".to_string(),
];
if let Some(xid) = xid {
+11 -7
View File
@@ -180,10 +180,7 @@ async fn preflight_if_needed(theme: &ColorfulTheme) {
eprintln!();
let Ok(choice) = Select::with_theme(theme)
.with_prompt("Last bandwidth test failed. Try again?")
.items([
"Yes — retry now",
"No — use the conservative default",
])
.items(["Yes — retry now", "No — use the conservative default"])
.default(0)
.interact()
else {
@@ -282,9 +279,12 @@ impl Player {
Player::Mpv => crate::common::process::spawn_detached(
"mpv",
&[
// No `--untimed`: it ignores audio timestamps and drifts a
// shared video out of sync. Pacing to audio keeps A/V synced.
// Also leave hwdec at the `low-latency` default (software
// decode): forcing `--hwdec=auto` froze some viewers on
// frame 1 while audio kept playing.
"--profile=low-latency",
"--untimed",
"--hwdec=auto",
"--audio-buffer=0.2",
"--demuxer-max-bytes=2M",
"--demuxer-readahead-secs=0.5",
@@ -344,5 +344,9 @@ pub fn prompt_player() -> Result<Player> {
.items(["mpv", "VLC"])
.default(0)
.interact()?;
Ok(if choice == 0 { Player::Mpv } else { Player::Vlc })
Ok(if choice == 0 {
Player::Mpv
} else {
Player::Vlc
})
}
+14 -2
View File
@@ -1,5 +1,6 @@
mod cli;
mod common;
mod doctor;
#[cfg(feature = "gui")]
mod gui;
mod host;
@@ -25,7 +26,7 @@ async fn main() -> Result<()> {
if cli.gui {
#[cfg(feature = "gui")]
{
return gui::run();
return gui::run(cli.relay);
}
#[cfg(not(feature = "gui"))]
{
@@ -36,6 +37,13 @@ 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;
// safe to call even when the per-app audio thread never spawns.
pipewire::init();
@@ -73,7 +81,11 @@ async fn main() -> Result<()> {
}
fn init_tracing(verbose: bool) {
let default = if verbose { "pixelpass=trace,iroh=info" } else { "pixelpass=info,iroh=warn" };
let default = if verbose {
"pixelpass=trace,iroh=info"
} else {
"pixelpass=info,iroh=warn"
};
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default));
// Tracing MUST write to stderr. `tracing_subscriber::fmt()` defaults its
// writer to stdout, but with `--output json` stdout carries the JSON event
+50 -11
View File
@@ -50,13 +50,11 @@ pub async fn run() -> Result<()> {
if m.name != "module-loopback" {
continue;
}
let Some(sink) = extract_kv(&m.args, "sink") else {
continue;
};
let Some(pid_str) = sink.strip_prefix(SINK_NAME_PREFIX) else {
continue;
};
let Ok(pid) = pid_str.parse::<u32>() else {
// A pixelpass loopback references a capture sink either as its
// destination (`sink=pixelpass_capture_<pid>` — the default→null
// mirror) or as its source (`source=pixelpass_capture_<pid>.monitor`
// — the local monitor that lets the sharer hear the app). Match both.
let Some(pid) = loopback_capture_pid(&m.args) else {
continue;
};
if dead_pids.contains(&pid) {
@@ -110,9 +108,7 @@ pub async fn run() -> Result<()> {
}
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 {
@@ -154,7 +150,9 @@ fn list_modules() -> Result<Vec<Module>> {
for line in text.lines() {
let mut parts = line.splitn(4, '\t');
let Some(id_str) = parts.next() else { continue };
let Ok(id) = id_str.parse::<u32>() else { continue };
let Ok(id) = id_str.parse::<u32>() else {
continue;
};
let Some(name) = parts.next() else { continue };
let args = parts.next().unwrap_or("").to_string();
modules.push(Module {
@@ -166,6 +164,19 @@ fn list_modules() -> Result<Vec<Module>> {
Ok(modules)
}
/// The `pixelpass_capture_<pid>` PID a loopback references, whether the capture
/// sink is its destination (`sink=pixelpass_capture_<pid>`) or its source
/// (`source=pixelpass_capture_<pid>.monitor`). `None` for unrelated loopbacks.
fn loopback_capture_pid(args: &str) -> Option<u32> {
let from_sink = extract_kv(args, "sink").and_then(|v| v.strip_prefix(SINK_NAME_PREFIX));
let from_source = extract_kv(args, "source")
.and_then(|v| v.strip_prefix(SINK_NAME_PREFIX))
.and_then(|rest| rest.strip_suffix(".monitor"));
from_sink
.or(from_source)
.and_then(|pid| pid.parse::<u32>().ok())
}
fn extract_kv<'a>(args: &'a str, key: &str) -> Option<&'a str> {
for token in args.split_whitespace() {
if let Some(rest) = token.strip_prefix(key)
@@ -195,3 +206,31 @@ fn unload_module(id: u32) -> Result<()> {
}
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
);
}
}
+46 -33
View File
@@ -1,12 +1,10 @@
use anyhow::{Context, Result, bail};
use iroh::Endpoint;
use iroh::endpoint::presets;
use iroh_tickets::endpoint::EndpointTicket;
use std::time::Duration;
use tokio::net::TcpListener;
use crate::cli::ViewerOpts;
use crate::common::{alpn::ALPN, output, signal};
use crate::common::{alpn::ALPN, endpoint, output, signal};
/// Cap on the initial QUIC connect. `endpoint.connect()` has no built-in
/// deadline, so an offline host / stale code / unreachable relay otherwise
@@ -17,10 +15,7 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
pub async fn run(ticket: EndpointTicket, opts: ViewerOpts) -> Result<()> {
let cancel = signal::install_ctrl_c();
let endpoint = Endpoint::builder(presets::N0)
.alpns(vec![ALPN.to_vec()])
.bind()
.await?;
let endpoint = endpoint::bind(opts.relay.as_deref()).await?;
let addr = ticket.endpoint_addr().clone();
tracing::info!(remote = %addr.id, "connecting to host");
@@ -50,34 +45,52 @@ pub async fn run(ticket: EndpointTicket, opts: ViewerOpts) -> Result<()> {
}
},
};
let (quic_send, quic_recv) = conn.open_bi().await?;
// Everything past the established connection runs in one block so any error
// (open_bi, bind, local_addr, accept) is captured rather than `?`-propagated
// straight out of the function — that would skip the close below and leak the
// endpoint. The connect-phase arms above close explicitly for the same reason.
let result = async {
let (quic_send, quic_recv) = conn.open_bi().await?;
let listener = TcpListener::bind(("127.0.0.1", opts.port)).await?;
let port = listener.local_addr()?.port();
let url = format!("http://127.0.0.1:{port}");
output::emit(output::Event::Connected { url: &url });
let listener = TcpListener::bind(("127.0.0.1", opts.port)).await?;
let port = listener.local_addr()?.port();
let url = format!("http://127.0.0.1:{port}");
output::emit(output::Event::Connected { url: &url });
if opts.interactive {
let player = crate::interactive::prompt_player()?;
player
.spawn(&url)
.with_context(|| "failed to launch player")?;
print_viewer_banner_interactive();
} else {
print_viewer_banner(&url);
if opts.interactive {
let player = crate::interactive::prompt_player()?;
player
.spawn(&url)
.with_context(|| "failed to launch player")?;
print_viewer_banner_interactive();
} else {
print_viewer_banner(&url);
}
tokio::select! {
accepted = listener.accept() => {
let (tcp, peer) = accepted?;
tracing::info!(%peer, "local viewer connected");
// Race the bridge against ctrl-c so a disconnect lands promptly
// mid-stream (mirrors the host's handle_peer). Without this, the
// cancel token is set but nothing checks it once the player has
// connected — ctrl-c is ignored until a second press, and a GUI
// "Disconnect" only takes effect via the child's SIGKILL backstop.
tokio::select! {
res = crate::common::tunnel::bridge(quic_send, quic_recv, tcp) => res,
_ = cancel.cancelled() => {
tracing::info!("ctrl-c received during stream — disconnecting");
Ok(())
}
}
}
_ = cancel.cancelled() => {
tracing::info!("ctrl-c received before local viewer connected");
Ok(())
}
}
}
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(())
}
};
.await;
endpoint.close().await;
result
@@ -89,7 +102,7 @@ fn print_viewer_banner(url: &str) {
eprintln!("│ Connected to host. Open the stream in your player:");
eprintln!("");
eprintln!(
"│ mpv --profile=low-latency --untimed --hwdec=auto --audio-buffer=0.2 --demuxer-max-bytes=2M --demuxer-readahead-secs=0.5 {url}"
"│ mpv --profile=low-latency --audio-buffer=0.2 --demuxer-max-bytes=2M --demuxer-readahead-secs=0.5 {url}"
);
eprintln!("│ vlc --network-caching=200 --live-caching=200 {url}");
eprintln!("");