Files
peerspeak/docs/screenshare-integration.md
T
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

107 lines
5.6 KiB
Markdown

# 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 "**<name> 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.