Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hyprland support, plus WFD negotiation fixes that affect every backend
Adds a Hyprland backend for
VirtualOutputso--extendworks there, andfixes 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/rtspandcrates/daemonand changebehaviour for Sway users too.
1. Hyprland backend
crates/externalgains ahyprlandmodule behind the unchangedVirtualOutputAPI;
mod swayis untouched. Backend is detected at runtime.hyprctl output create headless, configured withhyprctl eval 'hl.monitor{...}'.hyprctl keyword monitorcannot 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.
custom_picker_binaryoverride in~/.config/hypr/xdph.conf, armed for exactly one request and then removed.An existing
custom_picker_binaryis preserved and chained as the fallback.position = "auto"rather than a hardcoded offset — the Sway backend'spos 1920 0is 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_formatsechoed the sink's entireadvertised bitmap back as the source's M4
wfd_video_formats. M4 is aselection: 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_formatsis<native> <preferred-display-mode-supported>followed by comma-separated codecentries of eleven fields. A real LG sink sends:
The leading
40is 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 of00000200, a latency field, or a max-hres of0200).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
videoscaleand width/height caps so a mode below the virtualoutput'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, whosebest 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 completesilence. Audio keeps flowing through the same muxer the whole time, so state
reaches
Streaming, keepalives run, bytes move on the wire — and the sink hasno video stream to lock onto.
Two causes, and either fix alone leaves the symptom:
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=truethen re-pushes the most recentframe 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.
compositorandvideoratewere measured first; neither repeats, both beinginput-driven. A live black base layer under
compositordoes work and wasrejected at 19.6s CPU per 10s wall at 1080p30 against 1.7s without — roughly
two cores burned continuously to solve a start-up problem.
livesyncandfallbackswitchwould also work but live in gst-plugins-rs, which isn'tinstalled 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
Dropdoesn't run onSIGKILL, so the headlessoutput, 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
/procliveness and clears only genuinely stale resources.SIGINT/SIGTERMare installed before the session starts, not after discovery, so a signal
during startup tears down exactly as much as exists.
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 rejectspeers outside the P2P group.
--jsonemits one JSON object per line on stdout with logs routed tostderr, so stdout stays parseable. Events are emitted from
start_negotiated_stream, the path every real session takes — previouslyStreamingStartednever fired and a consumer sawnegotiatedthen nothingforever while media flowed.
--interfacereplaces the hardcodedwlan0, which matches almost nocurrent system.
5. Host prerequisites (documented in the README)
Both fail silently — the sink simply never connects:
that
systemctl is-active ufwreportinginactivedoes not mean its rulesare unloaded; that cost several debugging rounds here.
rp_filter=1) drops P2P packets before anyfirewall rule sees them.
nstat -az | grep IPReversePathFilteris the tell.Testing
profile rejection. Four existing tests asserted the H.265 misdetection or the
removed
0000000000000017mask — that mask never meant anything, and thereasons are recorded at each site rather than the tests being deleted.
pipewiresrcframe counts and interface byte counters.binary.
Known limitations
HDCP2.1 port=53002but does notrequire it; a sink that enforces it will fail.
session; rapid reconnects fail reliably.
(a quarter of the screen) with its own full-screen toggle. It reports
preferred-display-mode-supported = 00, so the source cannot request adisplay mode — I believe this is webOS UI behaviour rather than something WFD
gives us a lever for, but I'd welcome correction.
xdph.conf; there iscontainment but no session lock.
appreciate a check.