Files
screen_cast/scripts/install-receiver.sh
fegger 41f71fd217 feat(pi): add a Wi-Fi hotspot mode for routerless direct streaming
scripts/pi-hotspot.sh turns the receiver into a WPA2 access point via
NetworkManager (ipv4 shared mode gives the Pi built-in DHCP/NAT at
10.42.0.1), so a sender connects directly with no router in between —
which also sidesteps LAN quirks like unreachable 6to4 addresses, since
the direct link is plain private IPv4. The passphrase is generated on
first use and stored root-only in /etc/screencast-hotspot.conf;
on|off|status subcommands manage it, and off restores normal client
Wi-Fi. The hotspot owns wlan0 while active (documented). No receiver
changes were needed: it already announces on every interface.

nmcli property syntax validated against NetworkManager 1.58 with a
disposable never-activated profile; actual AP bring-up can only be
validated on the Pi.
2026-09-07 14:13:48 +02:00

212 lines
8.9 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# install-receiver.sh — build and install the screencast RECEIVER on a
# small ARM board (e.g. a Raspberry Pi Zero 2 W).
#
# Why a separate script: the sender pulls in PipeWire and the
# xdg-desktop-portal capture stack, which a headless receiver board neither
# has nor needs. This script configures the build with -Dsender=false so
# only the receiver binary is compiled.
#
# Usage (from the repository root, on the target machine):
#
# sudo ./scripts/install-receiver.sh
#
# Environment overrides:
# SC_RECEIVER_INSTALL_DIR install target (default /usr/local/bin)
# SC_RECEIVER_SKIP_TESTS set to 1 to skip the test suite (faster install)
# SC_RECEIVER_SERVICE set to 0 to skip the systemd service setup
#
# Requirements:
# - Debian/Raspberry Pi OS (apt) with GCC 13 or newer (the code uses
# C++20 <format>; Raspberry Pi OS Trixie or newer ships a suitable GCC)
# - a running avahi-daemon (installed and enabled by this script)
# - a graphical session or KMS console for the receiver window
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BUILD_DIR="${REPO_ROOT}/build-receiver"
INSTALL_DIR="${SC_RECEIVER_INSTALL_DIR:-/usr/local/bin}"
SKIP_TESTS="${SC_RECEIVER_SKIP_TESTS:-0}"
ENABLE_SERVICE="${SC_RECEIVER_SERVICE:-1}"
log() { printf '\n=== %s\n' "$*"; }
die() { printf 'install-receiver: error: %s\n' "$*" >&2; exit 1; }
TEMP_SWAP=""
SDL_DIR=""
cleanup() {
if [ -n "${TEMP_SWAP}" ]; then
swapoff "${TEMP_SWAP}" 2>/dev/null || true
rm -f "${TEMP_SWAP}"
fi
if [ -n "${SDL_DIR}" ]; then
rm -rf "${SDL_DIR}"
fi
}
trap cleanup EXIT
[ "$(id -u)" = 0 ] || die "run with sudo (installs to ${INSTALL_DIR})"
[ -f "${REPO_ROOT}/meson.build" ] || die "run from the screen_cast repository"
command -v apt-get >/dev/null 2>&1 || die "this script supports apt-based systems only"
export DEBIAN_FRONTEND=noninteractive
# --- build and runtime dependencies -----------------------------------------
log "installing build and runtime dependencies (this may take a while)"
apt-get update -qq || apt-get update
apt-get install -y --no-install-recommends \
build-essential \
meson \
ninja-build \
pkg-config \
libavcodec-dev \
libavutil-dev \
libswscale-dev \
nlohmann-json3-dev \
libavahi-client-dev \
avahi-daemon \
ca-certificates
# The receiver renders with SDL3. Older releases do not package it; build a
# minimal SDL3 from source in that case.
if ! apt-get install -y --no-install-recommends libsdl3-dev; then
log "libsdl3-dev is not packaged; building SDL3 from source (15-30 min on small boards)"
apt-get install -y --no-install-recommends cmake git
SDL_DIR="$(mktemp -d)"
git clone --depth 1 --branch release-3.2.x \
https://github.com/libsdl-org/SDL.git "${SDL_DIR}/SDL"
cmake -S "${SDL_DIR}/SDL" -B "${SDL_DIR}/build" \
-DCMAKE_BUILD_TYPE=Release \
-DSDL_TESTS=OFF \
-DSDL_INSTALL_DOCS=OFF \
-DSDL_EXAMPLES=OFF
cmake --build "${SDL_DIR}/build" --parallel "$(nproc)"
cmake --install "${SDL_DIR}/build"
fi
# --- compiler sanity check ---------------------------------------------------
log "checking the compiler for C++20 <format> support"
if ! printf '#include <format>\nint main() { return 0; }\n' |
g++ -std=c++20 -x c++ - -o /dev/null 2>/dev/null; then
die "g++ lacks C++20 <format>; install GCC 13 or newer (Raspberry Pi OS \
Trixie or newer ships one). Aborting before a long build."
fi
# --- memory-constrained boards ----------------------------------------------
# The signaling unit includes nlohmann/json.hpp, which can peak well above
# the RAM of small boards; the OOM killer then terminates the compiler
# ("Killed signal terminated program cc1plus"). Give such boards temporary
# swap and a single compile job.
BUILD_JOBS="$(nproc)"
MEM_TOTAL_KB="$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)"
if [ "${MEM_TOTAL_KB}" -lt 1500000 ]; then
SWAP_TOTAL_KB="$(awk '/^SwapTotal:/ {print $2}' /proc/meminfo)"
if [ "${SWAP_TOTAL_KB}" -lt 1000000 ]; then
FREE_KB="$(df -k --output=avail / | awk 'NR==2 {print $1}')"
if [ "${FREE_KB}" -lt 1572864 ]; then
die "board has little RAM and not enough free disk for 1 GB of temporary build swap; build on a larger machine or add swap manually"
fi
log "low-memory board: adding 1 GB of temporary swap for the build (removed afterwards)"
TEMP_SWAP="/var/tmp/screencast-build.swap"
if ! (fallocate -l 1G "${TEMP_SWAP}" 2>/dev/null ||
dd if=/dev/zero of="${TEMP_SWAP}" bs=1M count=1024 status=none); then
die "failed to write the swapfile"
fi
chmod 600 "${TEMP_SWAP}"
if ! mkswap "${TEMP_SWAP}" >/dev/null 2>&1 || ! swapon "${TEMP_SWAP}" 2>/dev/null; then
# Some filesystems (e.g. btrfs) refuse swapfiles outright.
rm -f "${TEMP_SWAP}"
TEMP_SWAP=""
log "WARNING: could not activate a swapfile on this filesystem; continuing with a single compile job — the build may still run out of memory"
fi
fi
BUILD_JOBS=1
log "low-memory board: compiling with a single job"
fi
# --- build the receiver ------------------------------------------------------
log "configuring a receiver-only build in ${BUILD_DIR}"
rm -rf "${BUILD_DIR}"
meson setup "${BUILD_DIR}" "${REPO_ROOT}" -Dsender=false
log "compiling (this takes a while on small boards)"
meson compile -C "${BUILD_DIR}" --jobs "${BUILD_JOBS}"
if [ "${SKIP_TESTS}" != "1" ]; then
log "running the test suite"
meson test -C "${BUILD_DIR}" --print-errorlogs
fi
# --- install -----------------------------------------------------------------
log "installing the receiver to ${INSTALL_DIR}"
install -m 755 "${BUILD_DIR}/src/app/screencast" "${INSTALL_DIR}/screencast"
systemctl enable --now avahi-daemon 2>/dev/null || true
# UDP receive buffers clamp to net.core.rmem_max (stock Debian: ~208 KB).
# A keyframe burst at higher bitrates is larger than that, and the packet
# loss it causes makes every keyframe undecodable (black screen). Applied
# idempotently; buffers are allocated lazily, so this costs no RAM at rest.
if [ "$(cat /proc/sys/net/core/rmem_max)" -lt 4194304 ]; then
log "raising net.core.rmem_max to 4 MB for RTP burst absorption"
sysctl -w net.core.rmem_max=4194304 >/dev/null
printf 'net.core.rmem_max=4194304\n' > /etc/sysctl.d/99-screencast-rmem.conf
fi
# --- systemd autostart --------------------------------------------------------
if [ "${ENABLE_SERVICE}" = "1" ] && command -v systemctl >/dev/null 2>&1; then
log "installing the systemd service (autostart on boot)"
RUN_USER="${SUDO_USER:-root}"
# Headless KMSDRM rendering needs device access; skip groups that do
# not exist on this system.
if [ "${RUN_USER}" != "root" ] && id "${RUN_USER}" >/dev/null 2>&1; then
for group in video render input; do
if getent group "${group}" >/dev/null 2>&1; then
usermod -aG "${group}" "${RUN_USER}" || true
fi
done
fi
sed -e "s|__SC_RECEIVER_BIN__|${INSTALL_DIR}/screencast|g" \
-e "s|__SC_RECEIVER_USER__|${RUN_USER}|g" \
"${REPO_ROOT}/systemd/screencast-receiver.service" \
> /etc/systemd/system/screencast-receiver.service
chmod 644 /etc/systemd/system/screencast-receiver.service
systemctl daemon-reload
systemctl enable --now screencast-receiver.service
else
log "skipping the systemd service (disabled or systemd not available)"
fi
log "done."
cat <<'EOF'
The receiver is installed and starts automatically at boot.
systemctl status screencast-receiver # is it running?
journalctl -u screencast-receiver -f # follow its logs
sudo systemctl stop screencast-receiver # stop gracefully (Ctrl-C like)
sudo systemctl disable --now screencast-receiver # turn autostart off
Notes for small boards (e.g. Pi Zero 2 W):
- No window manager is required: the receiver renders directly to the
kernel display pipeline (SDL KMSDRM). Use Raspberry Pi OS Lite with
NO desktop enabled — a running compositor would own the display and
the receiver could not start. No console autologin is needed either.
- The service owns tty7 and switches to it at boot (Ctrl+Alt+F1 returns
to the console login, Ctrl+Alt+F7 to the screencast).
- Disable console blanking so the picture never goes dark: add
consoleblank=0 to /boot/firmware/cmdline.txt and reboot.
- Decoding is software H.264; expect smooth playback for small streams
and a lower frame rate at high resolutions.
- Stop the receiver via systemctl (never kill -9; stale mDNS records
would break discovery for ~75 minutes).
- On a desktop session instead, edit the installed service file and
uncomment the Environment lines for XDG_RUNTIME_DIR/WAYLAND_DISPLAY.
- avahi-daemon must stay enabled; it is what senders discover.
- For a routerless direct link: scripts/pi-hotspot.sh on turns the Pi
into a Wi-Fi access point (see docs/RUNBOOK.md).
EOF