Skip to content

Hyprland support - #90

Open
alchemy wants to merge 29 commits into
forkline:mainfrom
alchemy:hyprland-support
Open

alchemy wants to merge 29 commits into
forkline:mainfrom
alchemy:hyprland-support

Conversation

@alchemy

@alchemy alchemy commented Sep 5, 2026

Copy link
Copy Markdown

Hyprland support, plus WFD negotiation fixes that affect every backend

Adds a Hyprland backend for VirtualOutput so --extend works there, and
fixes a set of protocol bugs found while getting it working against real
hardware. Verified end to end against an LG webOS TV (OLED55B9PLA): extended
desktop, picture, working mouse and keyboard.

Please read the second section even if you don't care about Hyprland — the
negotiation fixes are in crates/rtsp and crates/daemon and change
behaviour for Sway users too.


1. Hyprland backend

crates/external gains a hyprland module behind the unchanged VirtualOutput
API; mod sway is untouched. Backend is detected at runtime.

  • Output creation via hyprctl output create headless, configured with
    hyprctl eval 'hl.monitor{...}'. hyprctl keyword monitor cannot be used:
    since Hyprland's Lua config (0.55+) it prints an error to stdout and still
    exits 0, so the first attempt silently left the output at Hyprland's default
    resolution with no failure anywhere. There is now a post-check that the
    requested mode actually landed, because a clean exit status doesn't mean it did.
  • Portal output selection through a one-shot custom_picker_binary override in
    ~/.config/hypr/xdph.conf, armed for exactly one request and then removed.
    An existing custom_picker_binary is preserved and chained as the fallback.
  • position = "auto" rather than a hardcoded offset — the Sway backend's
    pos 1920 0 is wrong on any primary output that isn't exactly 1920 wide.

Creation is transactional: a guard unwinds the output and the portal config in
reverse if any later step fails.

2. WFD negotiation fixes — these affect Sway too

M4 selected nothing. select_video_formats echoed the sink's entire
advertised bitmap back as the source's M4 wfd_video_formats. M4 is a
selection: the spec requires exactly one bit set across CEA/VESA/HH, naming
the single format the source will send. Echoing the bitmap says "I might send
any of these" and leaves the sink to guess the geometry of what arrives.

The parameter was being parsed at the wrong offsets. wfd_video_formats is
<native> <preferred-display-mode-supported> followed by comma-separated codec
entries of eleven fields. A real LG sink sends:

40 00 01 10 000194FF 155575DF 00000555 00 0000 0000 1F none none

The leading 40 is the native mode, not a codec tag — so matching a "40 "
prefix as "H.264 SVC" found the right codec by coincidence. The H.265 check read
field 3 as a codec mask and tested bit 4; field 3 is the H.264 level, and
4.2 encodes as 10, so every level-4.2 sink was reported HEVC-capable.
Extend mode forced H.264 and masked it — mirror mode would have sent an HEVC
stream on the strength of a misread level
. The daemon repeated the mistake as
a substring test (formats.contains("02"), which also matches a CEA bitmap of
00000200, a latency field, or a max-hres of 0200).

Replaced with a real parser, the CEA table from spec Table 5-13, and a selector
that walks every advertised entry and picks the best progressive mode the
pipeline can produce. Profile and level are chosen, not taken as the lowest
advertised bit — a sink offering 3.1/4.0/4.2 would otherwise be told 3.1
alongside a 1080p selection, and 3.1 caps at 3600 macroblocks per frame against
1080p's 8160. Entries lacking constrained baseline are rejected, since that is
the only profile the encoder emits.

The selection is then honoured: carried on the daemon, driving the stream
config, with videoscale and width/height caps so a mode below the virtual
output's size is produced rather than merely announced.

No 4K. Classic WFD's resolution tables have no 4K entries, so extend mode is
1080p and the CLI help says so. The TV above advertises CEA=0x000194FF, whose
best progressive entry is 1920x1080p30 — sending 3840x2160 produced a spinner
with everything green locally.

3. An idle virtual output produces no frames

Worth calling out because it is not obvious and looks like a working session.

wlroots compositors emit a screencopy frame only when an output is damaged,
and a freshly created output with no windows and nothing moving never is.
Measured at pipewiresrc: one frame at startup, then 30+ seconds of complete
silence. Audio keeps flowing through the same muxer the whole time, so state
reaches Streaming, keepalives run, bytes move on the wire — and the sink has
no video stream to lock onto.

Two causes, and either fix alone leaves the symptom:

  • Re-applying the output's own monitor configuration makes Hyprland repaint it.
    Measured: video began flowing 1.1s after the call, having not started at all
    in the six seconds before. The nudge reads the live configuration and
    re-applies exactly that, so it doesn't clobber layout or scaling.
  • imagefreeze allow-replace=true is-live=true then re-pushes the most recent
    frame at the negotiated rate, so the stream doesn't fall quiet again when the
    desktop stops changing. It cannot help alone — it has nothing to repeat until
    a first frame exists.

compositor and videorate were measured first; neither repeats, both being
input-driven. A live black base layer under compositor does work and was
rejected at 19.6s CPU per 10s wall at 1080p30 against 1.7s without — roughly
two cores burned continuously to solve a start-up problem. livesync and
fallbackswitch would also work but live in gst-plugins-rs, which isn't
installed by default and would become a new requirement for every user.

The nudge is Hyprland-only. Sway almost certainly has the same damage
behaviour and is not addressed here; I have no Sway system to test on. Happy to
add an equivalent if you can point at the right call.

4. Robustness and smaller fixes

  • Abrupt termination. Drop doesn't run on SIGKILL, so the headless
    output, the portal config block and the virtual audio sink could all leak.
    Each now leaves a breadcrumb carrying the owning pid; the next run checks
    /proc liveness and clears only genuinely stale resources. SIGINT/SIGTERM
    are installed before the session starts, not after discovery, so a signal
    during startup tears down exactly as much as exists.
  • Reverse RTSP. Miracast has the sink open the TCP connection, and the
    source still drives the M1–M16 exchange. A packet capture showed the TV
    connecting, waiting exactly 10s in silence, then sending RST — the server was
    reactive and never spoke first. The listener binds the local P2P address
    rather than 0.0.0.0, so it isn't reachable from the wider LAN, and rejects
    peers outside the P2P group.
  • --json emits one JSON object per line on stdout with logs routed to
    stderr, so stdout stays parseable. Events are emitted from
    start_negotiated_stream, the path every real session takes — previously
    StreamingStarted never fired and a consumer saw negotiated then nothing
    forever while media flowed.
  • --interface replaces the hardcoded wlan0, which matches almost no
    current system.

5. Host prerequisites (documented in the README)

Both fail silently — the sink simply never connects:

  • Port 7236/tcp must be reachable for the sink's return connection. Note
    that systemctl is-active ufw reporting inactive does not mean its rules
    are unloaded; that cost several debugging rounds here.
  • Strict reverse-path filtering (rp_filter=1) drops P2P packets before any
    firewall rule sees them. nstat -az | grep IPReversePathFilter is the tell.

Testing

  • 33 test binaries green; clippy clean; each commit builds independently.
  • New coverage for the WFD parser, level sufficiency, multi-entry selection and
    profile rejection. Four existing tests asserted the H.265 misdetection or the
    removed 0000000000000017 mask — that mask never meant anything, and the
    reasons are recorded at each site rather than the tests being deleted.
  • Hardware: LG webOS TV OLED55B9PLA. Repeated sessions with packet captures,
    pipewiresrc frame counts and interface byte counters.
  • Every CLI flag and value in the README diff was checked against the built
    binary.

Known limitations

  • HDCP not implemented. This TV advertises HDCP2.1 port=53002 but does not
    require it; a sink that enforces it will fail.
  • Reconnect cooldown. Sinks generally need 30–60s before accepting a new
    session; rapid reconnects fail reliably.
  • Sink-side scaling. This TV renders the 1080p stream 1:1 on its 4K panel
    (a quarter of the screen) with its own full-screen toggle. It reports
    preferred-display-mode-supported = 00, so the source cannot request a
    display mode — I believe this is webOS UI behaviour rather than something WFD
    gives us a lever for, but I'd welcome correction.
  • Two concurrent sessions still contend over xdph.conf; there is
    containment but no session lock.
  • Sway is untested by me — the negotiation changes affect it and I'd
    appreciate a check.

alchemy and others added 29 commits September 4, 2026 12:52
VirtualOutput::create() was hard-wired to Sway (swaymsg create_output /
get_outputs) and to xdg-desktop-portal-wlr's config-file output_name=
trick for auto-targeting the portal. Neither works on Hyprland: swaymsg
doesn't speak Hyprland's IPC, and Hyprland ships its own separate portal
backend (xdg-desktop-portal-hyprland) that doesn't read portal-wlr's
config at all.

Adds a parallel Hyprland backend behind the same public API (no changes
needed in crates/daemon):

- Compositor detected once from $HYPRLAND_INSTANCE_SIGNATURE /
  $SWAYSOCK, not by probing binaries on PATH.
- Headless output created via `hyprctl output create headless`, found by
  diffing `hyprctl monitors all -j` before/after (small bounded retry
  for IPC-timing safety), sized and auto-placed via
  `hyprctl keyword monitor name,WxH@60,auto,1` — "auto" placement
  instead of Sway's hardcoded `pos 1920 0`, which assumes a 1920px-wide
  primary output.
- Portal auto-target via xdg-desktop-portal-hyprland's
  `screencopy:custom_picker_binary` config hook (confirmed against the
  installed binary and matching upstream source, not just docs — see
  ARCH.md in the netcast repo, "Build vs. adopt: swaybeam"). Since the
  portal passes no requester identity to the picker, the installed
  wrapper script is marker-file gated: it only answers non-interactively
  while swaybeam has a pending capture of its own, and falls through to
  the real hyprland-share-picker otherwise, so it can't hijack an
  unrelated app's screen-share/screenshot prompt.
- Full teardown (`hyprctl output remove`, marker removed, xdph.conf
  restored byte-for-byte or deleted if we created it fresh) rather than
  Sway's disable-and-reuse — a Hyprland headless output has no
  meaningful "disabled, parked for reuse" state worth keeping around.

Verified: `cargo build --workspace`, `cargo test -p swaybeam-external`,
`cargo clippy -p swaybeam-external --no-deps` all clean. Not yet
exercised against a live Hyprland session (that touches the user's real
~/.config/hypr/xdph.conf and restarts a running portal service, so it
needs an explicit go-ahead rather than being run as part of this patch).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Caught before the first live run: the Hyprland picker wrapper's fallback
was hardcoded to hyprland-share-picker. Checking the actual machine
first showed xdph.conf already had a custom picker configured
(hyprland-preview-share-picker, from the hyprland-preview-share-picker
package) -- the hardcoded fallback would have silently downgraded it to
the stock picker for any request that isn't swaybeam's own.

Adds original_picker_binary(): a small best-effort scan of the
pre-swaybeam xdph.conf for an existing screencopy.custom_picker_binary,
used as the wrapper's fallback instead of a hardcoded default. Also
guards the crash-recovery edge case -- a prior swaybeam run's own
wrapper still configured (cleanup skipped, e.g. a -9) -- which would
otherwise exec-loop by falling through to itself.

Also adds examples/hyprland_smoke_test.rs (deliberately excluded from
the normal test suite -- it has real side effects on a live session) and
runs it end to end against this machine's actual Hyprland session:

  - create(): HEADLESS-1 appeared at 1920x1080, auto-placed at a
    non-overlapping position; xdph.conf gained the managed block below
    the untouched original; the installed wrapper correctly fell through
    to hyprland-preview-share-picker (verified by invoking it directly)
    and correctly answered [SELECTION]allow-token/screen:HEADLESS-1 with
    the marker present; xdg-desktop-portal-hyprland stayed active
    through the restart.
  - cleanup(): HEADLESS-1 gone from `hyprctl monitors`, xdph.conf
    restored byte-for-byte to its pre-swaybeam content, marker file
    removed, portal still active.

cargo test -p swaybeam-external and clippy clean (6 tests, including a
regression test built from the exact live xdph.conf content above).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`daemon` mode was swaybeam's only real end-to-end session driver
(discover -> connect -> [virtual output] -> negotiate -> stream -> wait
for Ctrl+C -> teardown) but gave external callers almost nothing to key
off of: `status` was a hardcoded stub ("connected": false, always),
`--json` was accepted by the daemon subcommand's argv but silently
ignored, and daemon.rs's own DaemonEvent channel (Started / Connected /
Negotiated / StreamingStarted / StreamingStopped / ErrorOccurred / Ended)
already existed but only Started/Connected/Negotiated/StreamingStarted/
StreamingStopped were ever actually sent, nothing subscribed to it, and
there was no event for virtual-output creation at all -- the one signal
an external wrapper managing extend mode most needs (the compositor-
assigned output name).

crates/daemon:
  - Add DaemonEvent::VirtualOutputCreated { name, width, height },
    emitted right after both VirtualOutput::create() call sites succeed.
  - Emit the previously-dead Discovered(sinks) after every discovery.
  - run() split into a thin wrapper + run_inner(): the wrapper sends
    ErrorOccurred(e) on any failure and Ended unconditionally at the end,
    so a caller watching only the event stream (not also awaiting
    run()'s Result) still sees every run_inner() `?` early-return
    surface as a terminal event instead of silently vanishing.

crates/cli:
  - `daemon --json` now actually means something: subscribes to the
    event stream before calling run() (so Started can't be missed),
    drains it on a separate task into one JSON object per line on
    stdout, and drops the Daemon (closing the channel) before awaiting
    that task so buffered end-of-run events (Ended in particular) are
    guaranteed to be printed before the process exits. Without --json,
    behavior is unchanged byte-for-byte.
  - daemon_event_json()/sink_json() give this a stable wire shape,
    pinned by a unit test -- a field rename here is a breaking change
    for the omarchy-wireless-displayd wrapper this exists for.

Verified live (no real Miracast sink on this network, so this exercises
the empty-discovery/error path specifically):

  $ swaybeam --json daemon
  {"event":"started"}
  {"event":"discovered","sinks":[]}
  {"event":"error","message":"No Miracast sinks discovered"}
  {"event":"ended"}

cargo test -p swaybeam-daemon -p swaybeam-cli -p swaybeam-external and
clippy (same three crates) both clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Found while preparing a smoke test against a real TV: discover/connect/
disconnect/daemon all hardcoded interface_name: "wlan0".to_string() with
no override anywhere in the CLI. "wlan0" is the old kernel-numbered
interface naming; most current systems (predictable network interface
naming, e.g. this machine) use something like "wlp0s20f3" instead, so
every one of these commands would silently talk to a nonexistent
device and find nothing, regardless of what's actually reachable over
Wi-Fi Direct.

Adds a global `--interface` flag (default "wlan0", preserving prior
behavior when unset) threaded through discover/connect/disconnect/daemon,
replacing the hardcoded literal at each call site. `daemon_command`
picked up an 8th argument for it, which pushed it over clippy's
too_many_arguments default (7) -- allowed rather than restructured into
a config struct, since this function already reads as "the CLI-args to
DaemonConfig-fields adapter" and a struct wrapper wouldn't change that.

cargo test -p swaybeam-cli and clippy clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Found live, against a real TV: swaybeam --json daemon reported
{"event":"virtual_output_created","width":3840,"height":2160,...} but
`hyprctl monitors` showed the headless output sitting at 1920x1080 the
whole time. `hyprctl keyword monitor "name,3840x2160@60,auto,1"` was
silently doing nothing.

Root cause, confirmed directly: since Hyprland 0.55 the config engine
is Lua-based and `hyprctl keyword` only works for legacy scalar
keywords. For "monitor" specifically it prints "keyword can't work
with non-legacy parsers. Use eval." to stdout -- but still exits 0, so
the previous code's status.success() check could never have caught
this. This is not swaybeam-specific fallout: omarchy's own first-party
Display panel hits the identical dead call
(omacom/omarchy#6968), it's this Hyprland version's config
migration.

The working replacement, reverse-engineered against the real
`hyprctl eval` Lua API (field names discovered via its own "unknown
field 'x'" errors, since none of this is written up anywhere queryable
yet):

    hyprctl eval 'hl.monitor({output = "NAME", mode = "WxH@60",
                              position = "auto", scale = 1})'

Also worth noting for the next person: `hyprctl eval`'s exit status IS
meaningful for actual Lua errors (unlike `keyword`'s), but it still
exits 0 and prints "ok" when hl.monitor is pointed at a nonexistent
output name -- confirmed live. So this patch checks the exit status
*and* re-reads `hyprctl monitors -j` to confirm the requested
resolution actually landed before calling create_virtual_output a
success, rather than trusting either signal alone.

examples/hyprland_smoke_test.rs takes an optional resolution arg now
(auto|4k|1080|720) so this could be re-verified directly rather than
just at the one size the daemon defaults to. Re-ran it live at both
1080p and 4K after the fix: both land at the exact requested
resolution now (previously 4K silently stuck at 1080p), and both clean
up fully (output removed, xdph.conf restored, portal healthy).

cargo test -p swaybeam-external and clippy clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
determine_sink_role() was an honest stub upstream -- its own comment
said "just use force mechanism, or fallback to default" -- and always
returned server mode unless the user passed --client manually. Found
live against a real LG TV: the daemon opened an RTSP server on
0.0.0.0:7236 and waited 15s for a PLAY that was never coming, because
the TV (as P2P group owner) was never going to connect to us.

crates/net already resolves the group owner's address as part of
connecting (Sink::go_ip_address, alongside our own Sink::ip_address)
-- it just wasn't being used for this decision. If it differs from our
own address, the peer is the GO, and per WFD convention a GO sink runs
its own RTSP server and expects the source to connect to it as a
client.

Verified live: with this fix, the daemon correctly logs "sink is the
GO, negotiating as RTSP client" and attempts to connect to the TV's
192.168.49.1:7236 instead of opening its own server nobody would ever
reach. (The TV refused that connection and the existing reverse-listen
fallback also timed out -- a separate, further-upstream negotiation
issue, not this role-selection bug; see ARCH.md for where that's
picked up.)

cargo test -p swaybeam-daemon and clippy clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tempt 1

Found live against a real LG TV, after the previous role-detection fix
correctly chose client mode: the daemon still failed, because a
connection-refused on the very first attempt short-circuited straight
to the reverse-listen fallback rather than retrying -- the existing
12x300ms retry loop only applied to other error kinds.

Removed that special case: connection-refused now goes through the
same retry budget as everything else (bumped to 20x500ms = 10s, up
from 12x300ms = 3.6s, since a real TV's screen-share session/UI plausibly
needs more than 3.6s to settle after P2P group formation). Falling back
to negotiate_as_reverse_client() now happens only after the full retry
budget is exhausted, regardless of error kind, rather than immediately
on the first refusal.

Live result: retrying did NOT fix the underlying connection -- all 16+
attempts over 8s got an identical, immediate ECONNREFUSED, which rules
out "the TV's RTSP server just needs a moment to come up" as the
explanation. Something else is going on (a capability/IE mismatch, or
this TV genuinely doesn't serve RTSP on the GO address for this kind of
session). Still keeping this fix: retrying instead of bailing on
attempt 1 is correct regardless of what this specific TV turns out to
need, and it now gives the reverse-listen fallback a fair chance
instead of triggering it prematurely on every device that merely needs
a moment.

Separately and more urgently: this same run's daemon process died
mid-retry with no further log output and none of the graceful-teardown
logging every other run (including outright failures) has shown --
no "Daemon error:", no virtual-output/audio-sink cleanup. Left a real
mess (HEADLESS-10 output, the managed xdph.conf block, the marker file,
and the virtual audio sink all still present, default sink still
pointed at it) that needed manual recovery. No coredump, nothing in
journalctl/dmesg suggesting a crash or OOM -- cause undetermined, quite
possibly an artifact of this dev session's process lifecycle rather
than swaybeam itself, but the *exposure* is real either way: anything
that ends this process outside its own graceful path (a crash, a
forceful kill, a suspend) leaks compositor and audio state with no
recovery path. Worth a startup self-check (detect and clean up a
stale/orphaned prior session's HEADLESS-*/managed xdph.conf
block/virtual sink) as separate follow-up work; not attempted here.

cargo test -p swaybeam-daemon and clippy clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirmed live (previous commit's message) that a process ending
outside its own graceful path -- crash, forceful kill, a suspend that
doesn't resume cleanly -- skips Rust's Drop entirely and leaves real
state behind: a headless output, the xdph.conf edit, the marker file,
and (not previously exercised) the virtual audio sink, still the
default output with its module loaded. Nothing recovered any of it.

Two-part fix:

1. Each resource now writes a small breadcrumb to
   $XDG_STATE_HOME/swaybeam/ the moment it's created, and removes it as
   the last step of its own graceful cleanup:
     - crates/external: hyprland-output.name (just the output name).
     - crates/audio: audio-sink.state (sink_name/module_index/
       previous_default, plain text -- this crate has no serde
       dependency and the shape doesn't need one).
   A new cleanup_stale() in each crate, called at the very start of
   every `daemon` run before anything else, uses a leftover breadcrumb
   to recover precisely: remove the stale headless output, unload the
   stale audio module and restore the real previous default, and (new
   in crates/external) strip *exactly* the sentinel-delimited block
   `write_xdph_config` appends out of xdph.conf, leaving everything the
   user actually had untouched. Strip-by-sentinel is what makes this
   possible without the in-memory `original` snapshot a crash would
   have already lost -- write_xdph_config only ever appends, so
   whatever precedes the start sentinel is the user's real original
   content, recoverable from the file alone.

2. `run_inner()`'s "wait to stop" step only ever caught SIGINT
   (ctrl_c()). `timeout`'s default signal, systemd stopping a unit, and
   most process managers' "please stop" all send SIGTERM instead --
   previously that killed this process exactly like a SIGKILL would,
   no stop_stream/disconnect/cleanup, straight into the mess (1)
   recovers from. Now selects on SIGINT and SIGTERM together, so either
   one triggers the same graceful path SIGINT already had. SIGKILL
   still can't be caught by design (no signal handler can) -- (1) is
   what recovers from that specific case.

Verified live end to end: reproduced the exact failure (SIGKILL'd
`hyprland_smoke_test` mid-session, manually staged a matching stale
audio sink + breadcrumb the same way), then ran `swaybeam daemon` and
confirmed everything was gone afterward -- headless output removed,
xdph.conf restored to its real original byte-for-byte, marker and both
breadcrumbs gone, audio module unloaded, default sink restored -- all
before discovery even started. (The SIGTERM-while-actively-streaming
path specifically is code-reviewed and passes existing tests but not
yet exercised live -- that needs a real completed session, still
blocked on the RTSP negotiation issue from the previous commits.)

cargo test --workspace and clippy --workspace clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Packet capture against the real LG TV (tcpdump, unblocked via
`setcap cap_net_raw,cap_net_admin+eip` on the binary rather than sudo)
directly contradicted the previous commit's (b23f331) reasoning: the TV
repeatedly SYNs *our* port 7236 from the moment the P2P group forms --
it wants the traditional roles (source = RTSP server) regardless of it
being the P2P group owner. Every outbound connection we made to it in
client mode got an instant RST.

The GO-address heuristic wasn't just wrong, it was actively harmful:
guessing client mode first burns the whole retry budget dialing a
connection nobody answers, while the TV's own patience for *its*
connection attempt (observed: ~8s before the P2P group gets torn down)
runs out in parallel -- by the time that would have fallen back to
server mode, the TV was already gone.

determine_sink_role() now defaults to server mode again (matching the
original, pre-b23f331 behavior), informed this time by direct evidence
instead of a WFD-spec role assumption that doesn't hold for real
hardware. --client remains as an explicit override for a sink that
genuinely needs the reverse role. The GO address is now purely
informational (debug-logged), not used to pick a role.

Re-tested live in server mode: the TV's SYN to our port 7236 is now met
with total silence (no SYN-ACK, no RST) -- that specific signature
pointed straight at the local firewall, confirmed by reading
/etc/nftables.conf directly: policy drop on input, nothing allowing
port 7236 in. See ARCH.md in the netcast repo ("Packet capture: the
real root cause") for the fix that needs applying outside this
session (a real terminal, root) before this can be re-tested again.

cargo test -p swaybeam-daemon and clippy clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…peWire node

Two final blockers, both found with packet captures against a real LG TV.

1. negotiate_as_server() listens but is purely *reactive*: its
   handle_connection() blocks on socket.read() waiting for the sink to
   send the first RTSP request. No real WFD sink does that -- the spec
   has the *source* drive (M1 OPTIONS, M3/M4 capabilities, M5
   wfd_trigger_method: SETUP, then the sink answers SETUP+PLAY).
   Capture proof: the TCP handshake completed, then both sides sat
   silent for exactly 10s -- zero RTSP bytes in either direction --
   before the TV gave up and sent RST.

   negotiate_as_reverse_client() already implements the correct
   source-driven sequence over a connection the *sink* opened, which is
   exactly this case. Routed the non-force-client path there instead,
   with reverse_rtsp_target() factoring out the peer-address resolution
   negotiate_as_client already did. negotiate_as_server()'s reactive
   model is left in place but is likely dead for real sinks.

2. The GStreamer pipeline hardcoded
   `pipewiresrc target-object=xdg-desktop-portal-wlr` -- another
   Sway-ism. On Hyprland the portal is xdg-desktop-portal-hyprland, so
   pipewiresrc could not resolve its target and the pipeline failed with
   StateChangeError right after capture started. Replaced with
   `path={node_id}`, using the node id the portal already handed us --
   compositor-agnostic and more precise than any portal name.

Verified live, end to end, against the LG TV: RTSP negotiation
completes, capture starts, pipeline reaches Playing, "Streaming
active", HEADLESS-20 present at 3840x2160 in `hyprctl monitors`, and
RTP flowing continuously to 192.168.49.1:53000 (confirmed by capture on
the p2p interface).

cargo test -p swaybeam-daemon and clippy clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Classic Miracast/WFD tops out at 1920x1080: the CEA/VESA/HH resolution
bitmaps a sink advertises in wfd_video_formats have no 4K entries at
all (that needs WFD 2.0 extensions).

Confirmed live against a real LG B9 OLED -- a 4K-capable TV, which
still advertised CEA=0x000194FF, whose set bits decode to a maximum of
1920x1080p30. Sending it 3840x2160 produced a perfectly healthy-looking
session from our side (RTSP negotiated, pipeline Playing, steady RTP
flow to 192.168.49.1:53000, Hyprland showing the extended output,
windows draggable onto it) while the TV sat on a spinner the whole
time -- receiving H.264 at a resolution it never agreed to and cannot
decode.

extend_mode now creates the virtual output at 1920x1080 and encodes to
match, at 10 Mbps instead of 20. Both the run_inner() setup and
start_negotiated_stream()'s override are changed together -- they have
to agree, and previously both hardcoded 4K independently.

Kept a fixed size rather than deriving it from the sink's advertised
formats, deliberately: the virtual output must exist *before* the RTSP
capability exchange that reveals them, so deriving it properly means
reordering those two phases. parse_resolution_from_wfd_formats()
already decodes the bitmaps and is what that refactor should feed
from -- noted in the comment for whoever picks it up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
start_negotiated_stream() never sent DaemonEvent::StreamingStarted --
only the older start_stream() did. But both negotiate_as_client and
negotiate_as_reverse_client land in start_negotiated_stream, so *every*
real session went through the path that stayed silent.

Consequence for anything consuming the event stream: the session
reaches "negotiated" and then nothing, forever, while media is really
flowing. Caught live -- the omarchy-wireless-display panel sat on
"Connecting…" through a fully working, streaming session with RTP
confirmed on the wire.

This is fallout from my own earlier event-plumbing commit (71db106),
which wired up the variants without noticing the client/reverse paths
bypass start_stream entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…purity

[P1] `swaybeam daemon --json` was rejected with exit code 2 -- only the
pre-subcommand form worked -- while this project's own docs, comments and
commit messages all use the post-subcommand form. `--json` and
`--interface` are now `global = true`, so both positions work for every
subcommand. Verified: all four combinations of {--json, --interface} x
{before, after} the subcommand now parse.

[P1] Daemon events were emitted out of lifecycle order. Negotiated was
sent from negotiate() *after* it returned, but negotiate_as_{client,
reverse_client,server} each do the RTSP exchange *and* start the stream --
so StreamingStarted (emitted deep inside start_negotiated_stream) landed
first, and a consumer tracking status would flip streaming -> connecting.
Both events now come from start_negotiated_stream, Negotiated at its top,
so the order is structural rather than incidental. This was fallout from
my own two earlier commits (71db106, 70f400a).

[P2] Hyprland output creation was not transactional. `hyprctl output
create headless` hands back an output before its name is known, and
monitor discovery, the mode-set and the applied-resolution check could all
return early before the breadcrumb naming it was written -- leaking an
output that cleanup_stale() could never identify. A NewOutputGuard now
owns the output from the moment it exists and removes it on drop, by name
once known and by re-running the creation diff before that (the worst
window is exactly the one where the name was never resolved). It is
disarmed only when create_virtual_output is about to return successfully.
The breadcrumb also moved earlier, to right after the name is known, so
the unnamed window is as small as possible.

[P2] JSON stdout was not reliably JSON-only. tracing_subscriber's default
writer is stdout, so RUST_LOG or any error event interleaved formatted log
lines with the NDJSON event stream, breaking line-by-line parsers. In
--json mode tracing now writes to stderr; stdout stays machine-only.
Verified with RUST_LOG enabled: every stdout line parses as JSON (`jq -e`
over the whole stream), logs land on stderr.

cargo test and clippy clean across external/daemon/cli.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…text

The extend-mode banner still announced "4K virtual output" after the
implementation moved to 1080p (ffeb076) -- the string lives in
crates/cli while the change was in crates/daemon, so it was missed.

Two adjacent gaps fixed while here:

- `--extend` had no help text at all, and the distinction between it and
  `--external` is not self-evident. Both now say what they do, and
  `--external` notes that its `4k` value is almost never negotiable --
  the same stale-4K claim the banner was making, just in the value list
  instead of a message.

- Writing that help surfaced a mistake in the previous commit: clap
  renders doc comments as user-facing help, so the `global = true`
  rationale I wrote as a doc comment on `--json` was being printed in
  `--help`. Implementation notes moved to plain comments; the doc
  comments now describe the flags for users.

The remaining "4K" string maps ExternalResolutionChoice::FourK back for
display and is correct -- it echoes what the user explicitly asked for.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…p safety

[P1] Reverse RTSP streamed to the local machine whenever swaybeam was the
P2P group owner. accept_reverse() knew the connecting TV's peer_addr --
it even warned when it disagreed with the caller's hint -- and then used
the hint anyway. That hint is the P2P subnet's .1 address, which is the
sink only when the *sink* is GO; when the source is GO, .1 is us, so
SetupResult.destination_ip and PeerPlayInfo.dest_ip both pointed the RTP
stream back at the local machine. Now the accepted peer wins: the
connection came from the sink by definition. Both destinations derive
from server_addr, so the single fix covers both.

  e2e_tests' reverse-negotiation case asserted the *old* behaviour (a
  loopback test passing a 192.168.49.1 hint while the fake TV connected
  from 127.0.0.1, then asserting the hint won). Updated to assert the
  peer address, with the reasoning recorded there -- it was encoding the
  bug.

[P1] The portal auto-picker marker stayed armed for the whole session, so
every unrelated browser/OBS/portal screen-share request during a stream
was silently redirected to swaybeam's output -- defeating the point of
gating it at all. The marker is now consumed by the picker script itself:
one marker answers exactly one request, and it's removed *before*
answering, so a crash mid-reply can't leave a live hijack armed. Verified
live: first invocation answers with our output and consumes the marker;
a second invocation during the same live session falls through to the
real picker.

[P2] Normal cleanup could clobber xdph.conf. It wrote back the snapshot
taken at create time, losing any user or package edit made during the
session, and deleted the file outright when it hadn't existed before.
Now it strips only the sentinel-delimited block from the *current*
contents -- what cleanup_stale already did. With the markers missing it
leaves the file alone rather than overwriting someone's edits. Verified:
xdph.conf byte-identical to baseline after a session.

[P2] The output-creation guard ended too early -- disarmed when
create_virtual_output returned, while picker install, xdph.conf write and
marker write all still ran before VirtualOutput existed. It now returns
still armed, learns about the portal override and marker as each is
applied, and unwinds all of them in reverse on any early return. The
caller disarms only once VirtualOutput owns teardown.

[P2] Failed cleanup discarded its own recovery information: both
remove_output() and the audio cleanup dropped the breadcrumb even when
the removal failed, turning a transient hyprctl/pactl failure into a
permanent leak with nothing on disk left to find it. The breadcrumb is
now kept unless the resource is confirmed gone -- either the removal
succeeded, or it's absent from `hyprctl monitors` / `pactl list sinks`.
Costs one warning per later run; the old behaviour cost the resource.

cargo test --workspace: 145 passed, 0 failed. clippy clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…arker timing, signal scope

[P1] Reverse RTSP accepted the first connection on *every* interface.
accept_reverse bound 0.0.0.0, so any host on the ordinary LAN could race
the TV during the 15s accept window and be handed the screen stream --
made materially worse by the previous commit, which (correctly) started
trusting the peer address. Two layers now: the daemon binds the local
P2P address rather than 0.0.0.0, so the listener isn't reachable off the
P2P group at all; and accept_reverse loops, rejecting peers that are
neither the expected sink nor on its /24. Binding is the boundary, the
peer check is defence in depth.

[P1] A second swaybeam destroyed the first one's live session.
cleanup_stale ran unconditionally at startup, but breadcrumbs
deliberately exist for the whole of a live session -- so instance B read
instance A's breadcrumbs, concluded they were stale, and removed A's
virtual output and audio sink mid-stream. Breadcrumbs now record the
owning pid, and cleanup_stale leaves anything whose owner is still
running alone (checking /proc for both liveness and that the pid is
still a swaybeam process, so pid reuse can't fool it either way).
Breadcrumbs from before this change parse as unowned, which is correct:
they can only be leftovers.

  This is containment, not mutual exclusion -- two concurrent sessions
  still contend over xdph.conf and the marker, and B warns about exactly
  that. A real session lock is the fuller fix and is noted rather than
  half-built.

[P2] The one-shot marker was armed far too early. It was written at
output creation, but the portal isn't called until after RTSP
negotiation -- tens of seconds later. Any screen-share request in that
window consumed the marker, got swaybeam's output, and left swaybeam
itself falling through to the interactive picker. Arming moved to
VirtualOutput::arm_portal_target(), called immediately before the portal
request.

[P2] Signal handling covered only the streaming wait. It was installed
after discovery, connection, output creation, negotiation and stream
startup -- precisely the stretch where 10-15s timeouts stack up, and a
SIGTERM there killed the process outright with resources already
created. run() now installs both handlers before the session begins and
races them against the entire lifecycle, with teardown lifted out of
run_inner into its own method so a signal during startup unwinds exactly
as much as exists.

Verified live against the LG TV:
  - SIGTERM ~9s into startup (output and audio sink created, negotiation
    pending): "Received SIGTERM during startup", full graceful teardown,
    xdph.conf byte-identical to baseline, no breadcrumbs.
  - Instance B started against a live instance A: both crates refused to
    touch A's resources, and A's log shows it reached "Streaming active"
    straight through B's startup, output and sink intact.

cargo test --workspace: 150 passed, 0 failed. clippy clean workspace-wide.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
M4 is a selection, not a capability list: the spec requires exactly one
bit set across the CEA/VESA/HH bitmaps, naming the single format the
source will actually send. select_video_formats returned the sink's whole
advertised bitmap verbatim, which tells the sink "I might send any of
these" and leaves it to guess the geometry of what arrives.

Parsing was wrong in the same place. wfd_video_formats is
`<native> <preferred-display-mode-supported>` followed by comma-separated
codec entries of eleven fields, but the code indexed it as if the codec
tag came first. A real LG sink sends

    40 00 01 10 000194FF 155575DF 00000555 00 0000 0000 1F none none

so the leading `40` is the native mode, and matching a "40 " prefix as
"H.264 SVC" found the right codec purely by coincidence. The H.265 check
read field 3 as a codec mask and tested bit 4 -- field 3 is the H.264
level, and 4.2 encodes as `10`, so every level-4.2 sink came back
HEVC-capable. Extend mode forced H.264 and hid it; mirror mode would have
sent this TV an HEVC stream on the strength of a misread level. The daemon
made the same mistake by substring: `formats.contains("02")` matches a CEA
bitmap of 00000200, a latency field, a max-hres of 0200.

Adds a real parser, the CEA table from spec Table 5-13, and a selector
that walks every advertised codec entry and picks the best progressive
mode the pipeline can produce. Profile and level are chosen rather than
taken as the lowest advertised bit: a sink offering levels 3.1, 4.0 and
4.2 would otherwise be told 3.1 alongside a 1080p selection, and 3.1 caps
at 3600 macroblocks per frame against 1080p's 8160. Entries that do not
offer constrained baseline are rejected outright, since that is the only
profile the encoder emits.

The selection is then honoured: it is carried on the daemon, drives the
stream config, and the pipeline gained videoscale plus width/height caps
so a mode below the virtual output's size is actually produced rather than
merely announced. It is cleared on teardown and before each negotiation,
so a reused daemon cannot stream the previous sink's geometry.

build_video_formats was itself malformed -- four fields where the
parameter takes thirteen -- which only went unnoticed because the old
parser never looked past field 3.

Four tests asserted the H.265 misdetection or the removed mask. They now
assert the corrected behaviour, each with the reason recorded at the site,
alongside new coverage for level sufficiency, multi-entry selection and
profile rejection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A newly created virtual output shows nothing on the sink until something
incidental happens on the desktop -- measured against a real LG TV as 35
seconds of silence after the pipeline reached PLAYING, and in other runs
longer. Audio flows through the same muxer throughout, so the session
looks entirely healthy: state reaches Streaming, RTSP keepalives run,
bytes move on the wire, and the TV sits on a spinner.

Two separate causes, and fixing either alone leaves the symptom.

wlroots compositors emit a screencopy frame only when an output is
damaged, and an output with no windows and nothing moving is never
damaged, so the capture source sits at zero frames indefinitely.
Re-applying the output's own monitor configuration makes Hyprland repaint
it; measured, video began flowing 1.1s after the call having not started
at all in the six seconds before. The nudge reads the live configuration
first and re-applies exactly that -- an earlier attempt hardcoded
`position = "auto", scale = 1` and silently reset an output Hyprland had
auto-scaled to 1.25. It runs after the pipeline is live, because a frame
produced before then is missed, and from the negotiated path, which is
where every real session goes.

That gets the first frame; the stream then falls quiet again the moment
the desktop stops changing. imagefreeze allow-replace=true swaps in each
new frame as it arrives while is-live=true re-pushes the most recent one
at the negotiated rate in between. Measured: a 0.1 fps source becomes a
steady 30 fps, and holds 30 fps when the source stops dead after a single
frame. It cannot help on its own -- it has nothing to repeat until the
first frame exists -- which is what the nudge supplies.

compositor and videorate were measured first and neither repeats; both
are input-driven, so an idle source stalls them too. A live black base
layer under compositor does work, and was rejected: 19.6s of CPU per 10s
of wall clock at 1080p30 against 1.7s without, roughly two cores burned
continuously to solve a start-up problem. livesync and fallbackswitch
would also work but live in gst-plugins-rs, which is not installed by
default and would become a new requirement for every user.

imagefreeze sits after videoconvert so conversion runs on real frames
only, not on all thirty repeats a second. The nudge is best-effort: a
failure costs a slow first frame, not a broken session. Sway is left
alone, having its own backend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
None of this branch's changes had reached the README, and several of its
existing claims were wrong for the code as it now stands.

--extend was undocumented entirely, despite being the reason this branch
exists. Documents it along with the two things that are surprising about
it: the one-shot portal picker override, and that an idle virtual output
produces no frames until the compositor is nudged to repaint it.

The 4K examples promised something WFD cannot deliver. The resolution
tables sinks advertise have no 4K entries at all, so `--width 3840` is
capped to the sink's best mode; a real 4K TV caps at 1920x1080p30.
Replaced with a smaller example and an explanation that resolution is
negotiated rather than chosen.

The codec section documented four `--codec` values, two of which the CLI
rejects: it accepts auto, h264 and h264-sw only, and `stream` has no
--codec at all. The table also implied H.265 and AV1 were selectable.
Both corrected, with a note on why auto-selection cannot reach H.265
without requesting wfd2_video_formats.

Adds the host prerequisites whose absence fails silently -- port 7236 must
be reachable for the sink's return connection, and strict reverse-path
filtering drops P2P packets before any firewall rule sees them -- and
names xdg-desktop-portal-hyprland alongside -wlr, since the Hyprland
backend needs the former.

Status no longer says hardware testing is pending; it has been verified
end to end against an LG webOS TV.

Every documented flag and value in this diff was checked against the built
binary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A Samsung sink fails pairing with "NetworkManager error: No IP address
assigned". The cause is the negotiated P2P role: swaybeam assumed it would
always be the client, and as group owner nothing is going to hand it an
address -- the group owner is the side that assigns them. The DHCP client
then waits out its 45-second timeout and the session dies.

An LG sink hid this by always taking the group owner role itself and
supplying `ip_addr=`/`go_ip_addr=` through the P2P IP Address Allocation
extension. Observed against a Samsung: it uses neither. The group-started
line carries no address fields in *either* role, and the role itself is
decided by a random tie-break -- both sides default to GO intent 7, and
neither end of that is ours to change: wpa_supplicant's D-Bus interface is
root-only (`policy context="default"` denies it outright, which also makes
this crate's D-Bus P2P paths dead code for an unprivileged process), and
NetworkManager exposes no GO-intent property. Measured three group-owner
outcomes in four attempts.

So the role has to be handled rather than avoided:

parse_group_started_line required `ip_addr=` and `go_ip_addr=`, so a line
lacking them parsed as None -- the caller then reported that no group had
started, for a group that had started perfectly well, and discarded the
role with it. Both fields are now optional and the role is captured.

When the role is GO, the already-formed group is reapplied with
`ipv4.method=shared`, so NetworkManager assigns our address and runs
dnsmasq for the sink. Reapply rather than a fresh activation, so the group
is not renegotiated into a possibly different role. The GO address is then
our own rather than a .1 derived from it, which as group owner would point
at nothing.

Verified against the Samsung: role GO detected, reconfigured, and
`Got IP address: 10.42.0.1` where the same sink previously failed. Three
parser tests cover the client-with-allocation, group-owner and
client-without-allocation lines, using the exact strings observed from both
televisions.

The session still does not complete: dnsmasq offers 10.42.0.10-254 but the
sink never sends a DHCPDISCOVER, so RTSP times out waiting for it to
connect back. Being the group owner means acting as a DHCP server, which
needs inbound UDP 67 -- untested, and not something this commit addresses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Being the P2P group owner makes swaybeam the DHCP server for the sink, so
inbound UDP 67 on the p2p interface is as much a prerequisite as TCP 7236 --
and just as silent when missing: dnsmasq offers leases nobody asks for and
RTSP times out waiting for a sink that never got an address.

Also spells out that every table with an input hook must accept, not just
the one you happen to check. A ufw rule permitting DHCP does nothing if
/etc/nftables.conf keeps its own `inet filter` chain at `policy drop` --
which is exactly the configuration this was diagnosed on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The RTSP keepalive loop already detected everything that ends a session --
a TEARDOWN from the sink, the control connection dropping, a read error --
and returned. It returned into a detached task nobody watched. Meanwhile
run() held on a stop signal and nothing else, so the daemon went on
streaming into a session that no longer existed, its last emitted event
still saying "streaming".

Reported live: a Samsung sink stops showing anything after about a minute
while the panel goes on offering a disconnect button for it, recoverable
only by pressing that button.

The keepalive task now reports why it returned, and the streaming hold
races that against the stop signal. An unprompted end emits ErrorOccurred
with the reason, so a UI reading the event stream can say what happened
rather than silently dropping back to idle.

This does not address why the sink gives up -- that is a radio problem, not
a protocol one: the group runs on 2.4GHz channel 1 while this host's AP is
on 5GHz channel 40, and a single radio serves both by time-slicing, which
starves the stream in multi-second gaps. Neither the P2P channel nor the GO
intent that decides who picks it is reachable from an unprivileged process:
wpa_supplicant's D-Bus interface is root-only and NetworkManager's wifi-p2p
setting exposes only peer, wps-method and wfd-ies.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
NetworkManager's shared mode picks 10.42.0.1/24 for itself. Every Miracast
implementation lives on 192.168.49.0/24 -- an LG sink acting as group owner
handed us 192.168.49.10 with itself on .1 -- and a Samsung sink turns out to
require it: on 10.42.0.x it never sent so much as a DHCPDISCOVER, so
dnsmasq offered leases nobody asked for and RTSP timed out waiting for a
sink with no address.

Pinning ipv4.addresses alongside method=shared fixes that outright.
Measured, same sink, same session:

    Got IP address: 192.168.49.1
    DHCP, IP range 192.168.49.10 -- 192.168.49.254
    DHCPDISCOVER(p2p-wlp0s20-13) d6:9d:c0:78:7c:6a
    DHCPACK(p2p-wlp0s20-13) 192.168.49.32 d6:9d:c0:78:7c:6a

and the sink now displays a picture, where before it never received one.

Media delivery is still not continuous -- the stream runs in bursts of six
to nineteen seconds separated by fifteen to twenty seconds of nothing --
but that is a separate fault, downstream of addressing. The encoder is not
implicated: both x264enc and vah264enc were measured producing steady
output from a deliberately starved source in the same pipeline shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
After reapplying IPv4 as shared, the code fell through to a poll for "an
address on the p2p interface". NetworkManager can briefly hold its own
shared default (10.42.0.1) there before the pinned 192.168.49.1 lands, so
that poll sometimes returned the wrong one -- after which the reverse RTSP
listener tried to bind an address the interface no longer had and the
session died with "Cannot assign requested address".

Observed both ways on consecutive runs against the same sink, which is what
a race looks like. Now it waits for the address actually asked for, and
says so if it never arrives rather than proceeding on a stale one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Installing currently means cloning a branch of a fork and building with a
Rust toolchain. A tagged release with prebuilt archives removes both steps
for anyone who just wants to run it, and gives the plugin a version to pin
instead of "whatever is on the branch today".

Both architectures build on native runners. Cross-compiling to arm64 would
mean assembling an arm64 sysroot for the whole GStreamer and PipeWire
stack, which is considerably more fragile than using the arm64 runner
GitHub provides.

Follows the conventions already in this repo: actions/checkout@v7,
dtolnay/rust-toolchain, and the apt list rust.yml already builds with, so
the dependency set is one that is known to work here rather than a fresh
guess. Publishing uses the preinstalled gh CLI, so the only step holding a
write token needs no third-party action. Builds with --locked, so a release
comes from the committed Cargo.lock rather than whatever resolves that day.

Deliberately does not repeat fmt, clippy or tests: rust.yml already runs
them on v[0-9]* tags. It does run in parallel with them rather than after,
so a tag with failing tests would still publish -- noted in the workflow
header along with the one-line change to gate it, since that is a trade
rather than an oversight.

The archives are dynamically linked and need the GStreamer, PipeWire and
portal packages from the README plus glibc 2.39 or newer; the release notes
say so and point at `swaybeam doctor`. A static build would not help, since
GStreamer loads its plugins as shared objects regardless.

Packaging logic was dry-run locally against a real build: a 3.3 MiB archive
containing the stripped binary, LICENSE and README, with a sha256 alongside.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
  - Fix RTP/RTCP ports, stream pacing, and pixel aspect ratio
  - Default Samsung to software H.264
  - Add persistent PC identity setup for LG full-screen sharing
  - Remove unused PipeWire Rust bindings and Nix workarounds
The test job installed only -dev packages. Those carry headers, which is
all the compile needs, but a GStreamer element exists at runtime only if
its plugin package is installed. So
reserved_sockets_send_rtp_and_rtcp_on_loopback failed on the v0.5.0 tag
with `no element "x264enc"` -- x264enc lives in gstreamer1.0-plugins-ugly,
which was never installed. The message reads like a code fault and is a
missing package.

Reproduced locally by hiding that one plugin from the test binary, which
gives the identical failure at the identical line, and passes again with
it present.

Installs the plugin packages for every element the suite instantiates
rather than only the one that happened to fail first, since gst parse
reports just the first unknown element and the next one would surface as a
second red run:

  base      videotestsrc, videoconvert, videoscale
  good      udpsink, rtpbin, rtpmp2tpay, imagefreeze, pulsesrc
  bad       mpegtsmux, h264parse, x265enc, svtav1enc, faac
  ugly      x264enc
  pipewire  pipewiresrc

Only the test job changes. clippy, docs and build compile without running
a pipeline, so the header-only set is right for them.

The test itself is left alone. It verifies that RTP leaves the reserved
socket, that RTCP sender reports leave the reserved RTCP socket, and that
an inbound receiver report reaches rtpbin's session, over the same
pipeline shape production uses. Making it tolerate a missing plugin would
turn a real failure into a silent pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Publishing the v0.5.0 tag failed with "Resource not accessible by
integration" while trying to update an existing release. The permission is
the symptom; the fault is that two workflows publish the same release.

release.yml had already created it, so rust.yml's softprops step found it
and attempted an update its job had no contents:write for. Granting that
permission would only have let both write, and they disagree about what:

  - rust.yml named assets from Cargo.toml, which still reads 0.4.3, so a
    v0.5.0 release would have carried archives labelled 0.4.3
  - release.yml names them from the tag and builds arm64 as well, which
    rust.yml never did

So rust.yml's build job goes back to being a compile check and release.yml
owns publishing, which it already has the permission for and has already
done successfully once.

aur-publish is disabled here too, left on manual dispatch. It is upstream's
machinery for upstream's packages: .ci/generate-pkgbuild.sh writes PKGBUILDs
naming `swaybeam` and `swaybeam-bin` with forkline URLs and upstream's
maintainer. From this fork it would fail on AUR_* secrets that are not set
-- or, if they were, push this fork's build over upstream's AUR packages.
The fork's package is swaybeam-hyprland-bin, maintained separately against
release.yml's archives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant