Files
peerspeak/docs/screenshare-integration.md
molluskandClaude Opus 4.8 d9e544607a feat: screen sharing via pixelpass (Discord-style, presence-borne ticket)
Surface pixelpass screen-sharing from inside a peerspeak room. peerspeak owns
voice, pixelpass owns pixels — they're never Cargo deps of each other; the
contract is pixelpass's CLI flags + its `--output json` stdout stream.

Modelled on Discord: multiple simultaneous sharers, a 🔴 Live badge + 👁 Watch
on each sharing peer's card, and in-progress shares visible to late joiners.

- New `src/screenshare` module: pure `parse_pixelpass_event` seam + `pixelpass_path`
  discovery (13 unit tests), async `spawn_host` (→ ticket) and `spawn_viewer`
  (→ parse connected{url} → open mpv, vlc fallback). No new deps.
- Sharing rides presence: `PeerState.sharing: Option<ticket>` (serde-defaulted),
  so the existing gossip re-announce delivers the offer to late joiners for free
  and a PeerUpdated fires on start/stop — no separate gossip message needed.
- core: Start/Stop/ViewShare commands; host + viewer children tracked in the
  session, killed on stop/leave (kill_on_drop backstop). Viewer limit left to
  pixelpass's bandwidth-measured cap.
- UI: Share/Stop button (graceful "needs pixelpass" disabled state), Live badge
  + Watch on peer cards, Sharing badge on the self card. Verified by screenshot.
- config: optional `pixelpass_path` override (hand-editable).

Tests-green; the 2-machine gossip/remote path is not yet field-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 15:46:34 -04:00

5.6 KiB

Screen-share integration (peerspeak ↔ pixelpass)

Design decisions for surfacing pixelpass screen-sharing from inside a peerspeak room. Locked 2026-06-06; implemented 2026-06-06 (src/screenshare/mod.rs + presence-borne ticket; tests-green, the 2-machine gossip path not yet field-verified). peerspeak owns voice, pixelpass owns pixels — this doc is the seam between them.

Open questions — resolved (Discord-modelled)

  • Concurrency: multiple simultaneous sharers, Discord-style. Sharing rides presence (PeerState.sharing: Option<ticket>), so each sharer shows one offer; no artificial one-at-a-time cap.
  • Late joiners: yes — because sharing is presence, the existing gossip re-announce delivers an in-progress offer to a new joiner for free (no separate ScreenShareOffer message was needed; the doc's sketch is superseded by the presence-field approach).
  • Viewer limit: not overridden — pixelpass bandwidth-measures its own safe cap; a full host's viewer_refused surfaces as a UI error.
  • UI placement: per-peer card (🔴 Live + 👁 Watch on the sharer's card; 🔴 Sharing badge + Stop on the self card) — the Discord "Live badge" model.
  • Player: viewer parses connected{url} and opens mpv (vlc fallback), mirroring pixelpass's own low-latency invocation.

Core principle: mutually optional, runtime-only coupling

Neither tool is a Cargo dependency of the other, in either direction.

  • peerspeak works fully without pixelpass installed; the screen-share control just degrades (greyed / "needs pixelpass — install it").
  • pixelpass works fully without peerspeak; nothing here makes it require voice.
  • The integration is runtime subprocess + a stable contract, never a crate import. The contract peerspeak depends on is pixelpass's CLI flags + JSON event stream, treated as a public API.
  • Keep the contract symmetric so the reverse direction (pixelpass launching peerspeak for a "call these viewers" button) stays possible later. No master/slave assumption.

Chosen approach: B — auto ticket-exchange over peerspeak's gossip plane

  1. User clicks Share screen in a peerspeak room.
  2. peerspeak spawns pixelpass --host --output json as a child process (video only — see audio decision below).
  3. peerspeak scrapes the {"event":"ticket","value":"..."} line from the child's stdout (emitted once at host startup).
  4. peerspeak broadcasts a new typed gossip message, ScreenShareOffer { from, ticket }, over the same gossip plane that already carries chat + presence.
  5. Each peer's peerspeak shows " is sharing — [View]".
  6. Clicking View spawns pixelpass <ticket> --output json as a viewer; it opens in its own pixelpass window (not embedded in iced).
  7. When the sharer stops, peerspeak kills the host child and broadcasts a ScreenShareEnded { from } (or equivalent) so viewers' indicators clear.

Rejected: A (manual ticket via chat — barely integrated) and C (deep embed / shared iroh identity — large, fights pixelpass's window model and the two-tools philosophy).

Why this is cheap

pixelpass was built to be driven as a child process:

  • --output json exists explicitly for a front-end (its own --gui re-execs pixelpass --host --output json and parses the stream). See pixelpass/src/common/output.rs.
  • Host emits: ticket, host_info, viewer_joined, viewer_left, capture, viewer_refused. Viewer emits: connected { url }. Host reads a kick command on stdin.
  • The ticket is the capability. The data-plane host (serve.rs) gates viewers only by max_viewers — there is no friends-list ACL on the stream (friends is a separate control-plane/presence concept the headless CLI ignores). So distributing the ticket over gossip = granting room access, with no ACL to fight.
  • pixelpass is 1 host → N viewers (asymmetric), which maps cleanly onto "I share, the room watches."

Locked sub-decisions

  • Audio: video only on the room-launch path. Do not pass pixelpass's audio capture, so screen-share audio can't echo/double with peerspeak's voice mix.
  • Separate window for the viewer (pixelpass's own), not embedded in iced.
  • Binary discovery: look up pixelpass on $PATH; allow a config override for a non-standard location. Absence is a normal, handled state, not an error.

Open questions for implementation

  • Concurrency / who can share: one sharer at a time, or multiple simultaneous shares (N independent host processes + N offers + a list UI)?
  • Subprocess supervision: how peerspeak tracks child lifetime, surfaces viewer_refused / capture failures, and cleans up on room-leave or crash (pixelpass has --repair for orphaned PipeWire state).
  • Offer lifecycle on the gossip plane: late joiners — do we re-announce active shares to someone who joins mid-share?
  • UI placement: where the Share button + incoming-offer indicators live across the three room layouts (3-Column / Bottom Dock / Drawer).

Build sketch (peerspeak side, for next session)

  1. New gossip message variant(s): ScreenShareOffer/ScreenShareEnded in the gossip wire type; bump/relax parsing compatibly.
  2. A screenshare subprocess supervisor module (spawn host, parse JSON stdout, track child, kill on stop) — keep the JSON-parsing a pure testable seam.
  3. AppMessage + state: StartScreenShare, StopScreenShare, ViewShare(EndpointId), active-share map, "pixelpass present?" probe.
  4. UI: Share button + per-peer "View" affordance; graceful disabled state when the binary is absent.