Files
screen_cast/docs/RUNBOOK.md
T
fegger 9a24933203 fix(android): pass PROTOCOL_DNS_SD so mDNS registration works
Android 16 added NsdManager.checkProtocol(): registerService with protocol
0 threw IllegalArgumentException: Unsupported protocol — silently, since
the old code swallowed the exception into a dead reflection fallback and
start() overwrote the failure status with the Listening line. mDNS never
actually advertised (the Phase 8 session streamed via --peer, masking it).

Also advertise after the listening status so a registration failure stays
visible, and log the exception and the registration success.

Validated live on the Fairphone 6: the desktop --discover lists the phone
and a full --send session decodes (in-band SPS 320x240 -> 2496x1040) and
renders.
2026-09-10 12:35:00 +02:00

13 KiB

Runbook — build and validation commands

Reusable validation steps for this repository. Run the relevant ones before considering a change complete.

Build and test

meson setup build          # once
meson compile -C build
meson test -C build --print-errorlogs

Memory and correctness checks (codec changes)

Run the codec round-trip test under valgrind after touching the encoder or decoder:

valgrind --leak-check=full --errors-for-leak-kinds=definite \
    build/tests/test_codec_roundtrip

Expected: no "definitely lost" bytes and no "Invalid read/write" errors. FFmpeg may keep some "still reachable" allocations at exit; that is normal.

This check caught two real defects in the Phase 2 decoder: a per-packet payload leak and an unpadded extradata buffer over-read. Keep using it.

Capture smoke test (Phase 3, manual)

Requires a running desktop session with xdg-desktop-portal and a backend that implements the ScreenCast portal (e.g. Hyprland, GNOME, KDE). The portal shows an interactive source picker, so this cannot run unattended.

meson compile -C build                       # builds tools/capture_smoke
./build/tools/capture_smoke 10 out.h264      # captures 10 frames
ffprobe -v error -show_entries stream=codec_name,width,height out.h264

Expected: the tool prints the negotiated resolution/format/stride and writes a non-empty file; ffprobe reports codec_name=h264 and the correct width/height. The encoder prepends its Annex-B SPS/PPS extradata so the file is a self-contained elementary stream.

If you see impl_ext_end_proxy called from wrong context warnings, a PipeWire proxy operation ran without the thread-loop lock — all pw calls that send messages (connect, stream, core) must happen under pw_thread_loop_lock.

LAN discovery and signaling (Phase 6)

Two machines on the same LAN (mDNS via avahi-daemon, which must be running on both):

screencast --receive                # machine A: announce + window
screencast --send                   # machine B: discovers A, negotiates, streams
screencast --discover               # list receivers on the LAN

--send without --peer requires exactly one discovered receiver. Use --peer HOST[:PORT] to target a receiver directly (signaling port defaults to 5005), e.g. --peer 192.168.178.20.

Operational note: never kill -9 a running receiver — a SIGKILLed process cannot withdraw its mDNS registration, and avahi-daemon then keeps broken records for that service name until they expire (~75 min), making resolution time out for every peer. If that happens, systemctl restart avahi-daemon clears it. Always stop screencast with Ctrl-C (SIGTERM).

Receiver as a systemd service (autostart)

scripts/install-receiver.sh installs and enables screencast-receiver.service (see systemd/screencast-receiver.service). It starts at boot after the network and avahi-daemon, and restarts on failure. On a headless console SDL renders via KMSDRM; on a desktop autologin, edit /etc/systemd/system/screencast-receiver.service and uncomment the Environment lines for XDG_RUNTIME_DIR/WAYLAND_DISPLAY.

systemctl status  screencast-receiver          # is it running?
journalctl -u screencast-receiver -f           # follow logs
sudo systemctl stop screencast-receiver        # graceful; withdraws mDNS
sudo systemctl disable --now screencast-receiver  # turn autostart off

Verified behavior: systemctl stop results in a clean exit (success, no restart) and the mDNS announcement is withdrawn — senders stop finding the receiver immediately.

The receiver can run as a Wi-Fi access point, so a sender connects to the Pi directly with no router in between (on such a direct link the Pi is plain private IPv4 — no 6to4 or router quirks):

sudo scripts/pi-hotspot.sh on            # prints SSID + generated password
sudo scripts/pi-hotspot.sh status         # shows the saved credentials
sudo scripts/pi-hotspot.sh off            # back to normal router Wi-Fi

While the hotspot is active it owns the Wi-Fi interface: the Pi leaves any router network, and the Pi is reachable at 10.42.0.1 (SSH included). On the sender, join the hotspot's Wi-Fi and run screencast --send — discovery and streaming then flow over the direct link. The passphrase is generated on first use and stored in /etc/screencast-hotspot.conf (root only). Override with --ssid, --psk, or --band a (5 GHz).

Headless receiver (no window manager)

The receiver does not need X11, Wayland, or any window manager: SDL3's KMSDRM backend renders straight to the kernel display pipeline. This is the recommended setup for small boards (Raspberry Pi OS Lite):

  1. No desktop may be enabled — a running compositor holds the DRM master and the receiver cannot take it (the two modes are mutually exclusive). No console autologin is needed either.
  2. The service renders on its own VT (tty7) and switches to it at boot: Ctrl+Alt+F1 returns to the console login, Ctrl+Alt+F7 back to the screencast. (A service must not target tty1: acquiring a tty that the console session or getty owns blocks the start forever.)
  3. Disable console blanking: append consoleblank=0 to /boot/firmware/cmdline.txt and reboot.

Under KMSDRM the receiver goes fullscreen automatically, with aspect-preserving letterboxing (a 4:3 desktop on a 16:9 TV shows black bars, not a stretched picture) and no mouse cursor. On desktop sessions --fullscreen forces the same behavior.

A window manager only comes with the desktop-session alternative, where the receiver runs inside it (uncomment the Environment= lines in the service file as described above).

GTK panel and waybar widget (Phase 7)

Build the GUI alongside the CLI (meson configure build -Dgui=true, then recompile; installs as screencast-gui):

screencast-gui          # receiver list → pick one → bitrate → Start
screencast waybar       # one JSON line for a waybar custom module
screencast waybar --toggle   # stop a running sender / restart the last

The waybar module (installed to this machine's ~/.config/waybar/ — see custom/screencast there): the app icon as the module face (dimmed while idle, green tint while streaming; the / text from the JSON is hidden in the CSS), tooltip with receiver, bitrate, elapsed; left-click toggles streaming to the last receiver via the state file, right-click opens the panel. The sender publishes its state to $XDG_RUNTIME_DIR/screencast/sender.json, so every front-end — CLI, GUI, widget — agrees on what is running.

Icons: screencast_icon/screencast_256.png is the single app icon. meson install puts it in share/icons/hicolor/256x256/apps/screencast.png; the GTK panel uses it by themed name (set_icon_name — GTK4 removed the pixel-buffer window icon, so themed icons only), and the waybar CSS references the installed path. The SDL receiver window embeds the PNG at build time (icon_png_data.h generated by scripts/icon_to_header.py) and sets it via SDL_SetWindowIcon when SDL3_image is available (optional dependency; no pkg-config ships with it, so meson finds the library directly). Icons surface in compositor taskbars/switchers, not in title bars (neither GTK4's CSD nor Hyprland's decorations draw them). Restart waybar after changing style.css to pick up icon changes.

Under the hood: resilience (Phase 7)

The receiver absorbs loss in two stages and recovers actively:

  1. Jitter window: RTP packets are re-ordered by sequence number in a small buffer (16 packets / 60 ms), so Wi-Fi reordering is not misread as loss. In-order streams release immediately (zero added latency).
  2. PLI feedback: when a frame arrives genuinely damaged, the receiver drops it and asks the sender for a keyframe over the signaling channel (rate-limited to one request per 500 ms). The sender re-encodes a keyframe immediately — recovery takes one frame time instead of waiting out the rest of the GOP.

Hardware decode: the receiver probes h264_v4l2m2m (the VideoCore path on Raspberry Pi) and falls back to software automatically; the journal says which path is active. --swdecode forces software.

Sender / receiver loopback (Phase 5, manual)

Two terminals on the same desktop session:

./build/src/app/screencast --receive        # terminal 1: window appears
./build/src/app/screencast --send           # terminal 2: portal source picker

Expected: the receiver window shows the captured desktop in near real time. If the receiver starts after the sender, a few non-existing PPS decode errors are normal until the next keyframe arrives (the GOP is ~0.4s); video should appear within a second. Stop either side with Ctrl-C. Optional flags: --port, --peer HOST[:PORT], --bitrate KBPS, --target window.

Notes: the encoder runs a VBV that caps keyframe bursts to roughly two frame periods of bytes, and the receiver requests a 4MB UDP receive buffer (clamped by net.core.rmem_max; the install script raises it to 4 MB and persists the change). The remaining bottleneck on Wi-Fi-only boards is the wireless link itself — lower --bitrate until the stream is stable.

The headless equivalent runs as part of meson test (udp loopback test): synthetic frames → encode → packetize → localhost UDP → depacketize → decode, no portal or window involved.

Android receiver app (Phase 8)

Toolchain: JDK 21, system gradle (no wrapper), Android SDK with android-36. AGP 9 has built-in Kotlin — do not apply org.jetbrains.kotlin.android and do not use kotlinOptions {}.

cd android
gradle :app:assembleDebug            # -> app/build/outputs/apk/debug/app-debug.apk
gradle :app:testDebugUnitTest        # 25 JVM tests (rtp, jitter, depacketizer)

Device test (validated on a Fairphone 6, Android 16 / API 36):

adb install -r app/build/outputs/apk/debug/app-debug.apk
adb shell am start -n screen_cast.receiver/screen_cast.ReceiverActivity
# activity FQN is screen_cast.ReceiverActivity (namespace), not
# screen_cast.receiver.ReceiverActivity
adb shell uiautomator dump /sdcard/ui.xml && adb shell cat /sdcard/ui.xml   # status text
adb exec-out screencap -p > phone.png                                      # visual check

On the sender: screencast --send --peer <phone-ip>:5005 (add --target window to pick a specific window; the portal's monitor source picks the first output on multi-monitor Hyprland/GTK-portal setups).

Platform quirks found while validating (Android 16 / API 36):

  • android.permission.INTERNET is required — NsdService rejects registration without it.
  • NsdManager.registerService on Android 16 validates the protocol argument (NsdManager.checkProtocol): the historical 0 throws IllegalArgumentException: Unsupported protocol — pass NsdManager.PROTOCOL_DNS_SD. This was silent for a long time: the app swallowed the exception and the failure status was overwritten by the "Listening…" line, so mDNS never advertised even though the UI looked fine (discovery only worked via --peer). Diagnosed via adb shell dumpsys servicediscovery (the client's mClientRequests stays empty when no request was ever issued) plus logging the exception.
  • DatagramSocket.localPort gives the bound port; .port is -1 for unconnected datagram sockets, and .localAddress is an InetAddress (Inet6Address on Android), not an InetSocketAddress.
  • MediaCodec: pass the output Surface to configure() — the codec must start in surface mode (setOutputSurface afterwards only switches an already-surface-mode codec to a new surface; a codec configured without a surface can never take one, per the API docs); call start(); render with releaseOutputBuffer(index, render=true); the C2 AVC decoder requires a concrete width/height at configure (use a placeholder — the in-band SPS reconfigures it).
  • MediaFormat.format() / KEY_MIME_TYPE are not in the API-36 public surface.
  • NSD: the classic registerService(info, flags, RegistrationListener) API exists from API 16 through 36 (verified with javap on the android-36 SDK jar) — no fallback is needed. A reflection fallback targeting a pre-36 "ResolutionListener" was removed: that class never existed at any API level, so the fallback could only ever fail.
  • The C2 decoder scales its output to the Surface (android._video-scaling) — letterbox by sizing the TextureView to the video aspect (centered on a black window background), not with a transform matrix (double scale).
  • mDNS does not cross subnets; --peer is the reliable path.

Formatting

find include src tests -type f \( -name '*.cpp' -o -name '*.h' \) \
    -exec clang-format --dry-run --Werror {} +