diff --git a/.agents/MEMORY.md b/.agents/MEMORY.md index 430a353..bec252c 100644 --- a/.agents/MEMORY.md +++ b/.agents/MEMORY.md @@ -23,6 +23,14 @@ loopback; current phase is Phase 7. both 127.0.0.1 and ::1. - **Discovery dedupe**: one entry per (service_name, signaling_port) — a host with many interfaces otherwise registers dozens of address variants. +- **Receiver-only builds** (`-Dsender=false`, meson option): skip the + capture backend and sender pipeline (`SC_HAS_SENDER` guards in + main.cpp/pipelines.cpp; `--send` refuses cleanly in such builds). + `scripts/install-receiver.sh` uses this to install on small ARM boards + (user's Raspberry Pi Zero 2 W receiver) without PipeWire/portal deps, + building SDL3 from source when the distro lacks it. Requires GCC 13+ + (``). Validated on x86_64: builds, tests 5/5, --discover works, + --send refuses with a message. - **mDNS operational lesson (hit during validation)**: `kill -9` on a process holding an avahi registration leaves stale daemon records; the service then browses but never resolves (timeout for every peer) until @@ -117,9 +125,12 @@ None. ## Open questions - GUI framework (Qt6 vs. none / CLI only) — deferred to later phase. -- Hardware acceleration strategy (VAAPI / Vulkan Video / NVENC) — evaluate after - software encode path works. -- IPv6 at the transport layer — revisit when LAN streaming lands (Phase 6+). +- Hardware acceleration strategy (VAAPI / Vulkan Video / NVENC) — evaluate + after software encode works; note the Pi Zero 2 W receiver use case makes + hardware **decode** (V4L2/MMAL) the more urgent half. +- IPv6 at the transport layer — RESOLVED in Phase 6: dual-stack everywhere + (AF_UNSPEC resolution, IPV6_V6ONLY=0 listeners); validated over both + families. ## Forward-looking review notes (for later phases) diff --git a/README.md b/README.md index 87b5f00..9820a7c 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,21 @@ A native Linux peer-to-peer screencast application. - **Send** your desktop or a window to another Linux machine. - **Receive** a stream and render it in a window. -- Built with **C++20**, **Meson**, **PipeWire**, **FFmpeg**, **RTP/UDP**, and - **SDL**. +- Discover receivers on the LAN via **mDNS/Avahi**; negotiate sessions with + **JSON signaling**; stream **H.264 over RTP/UDP**. -> This project is in early development. The H.264 codec path is implemented; -> capture, transport, and rendering are still in progress. +Built with **C++20**, **Meson**, **PipeWire**, **FFmpeg**, and **SDL3**. ## Quick start Requirements: -- C++20 compiler (GCC 12+, Clang 16+) -- Meson >= 0.63 -- Ninja +- C++20 compiler with `` (GCC 13+, Clang 18+) +- Meson >= 0.63, Ninja - FFmpeg development packages (`libavcodec`, `libavutil`, `libswscale`) -- (later phases) PipeWire dev, SDL2/3 dev +- SDL3 development package (`sdl3`) +- nlohmann JSON (`nlohmann_json`) and Avahi client (`avahi-client`) +- For the sender only: PipeWire dev (`libpipewire-0.3`) and libportal Build and run tests: @@ -28,12 +28,35 @@ meson compile -C build meson test -C build --print-errorlogs ``` -Run with verbose test output: +Stream between two machines: ```sh -meson test -C build -v +screencast --receive # machine A: announces itself, opens a window +screencast --send # machine B: discovers A, negotiates, streams ``` +See `docs/RUNBOOK.md` for all modes, flags, and validation procedures. + +## Receiver on a small ARM board (e.g. Raspberry Pi Zero 2 W) + +The receiver does not need the sender's PipeWire/portal capture stack. On +the board, run: + +```sh +sudo ./scripts/install-receiver.sh +screencast --receive +``` + +The script installs the dependencies, builds a receiver-only binary +(`-Dsender=false`), runs the test suite, and installs to `/usr/local/bin` +(override with `SC_RECEIVER_INSTALL_DIR`). It needs Raspberry Pi OS Trixie +or newer (GCC 13+ for C++20 ``), builds SDL3 from source when the +distribution does not package it, and enables `avahi-daemon`. + +Performance note: decoding is software H.264; on very small boards expect +smooth playback for modest resolutions and reduced frame rates at high +resolutions. Hardware decode is planned for Phase 7. + ## Architecture See `docs/ARCHITECTURE.md` for module boundaries and design rules. @@ -42,8 +65,8 @@ See `docs/ARCHITECTURE.md` for module boundaries and design rules. Development is split into phases in `docs/PHASES.md`. -Current phase: **Phase 3 — PipeWire screen capture**. +Current phase: **Phase 7 — Resilience and polish**. ## License -MIT — see `LICENSE` (to be added). +MIT — see `LICENSE` (to be added). \ No newline at end of file diff --git a/meson.build b/meson.build index 159b4c6..c72b1d1 100644 --- a/meson.build +++ b/meson.build @@ -10,8 +10,10 @@ project('screen_cast', 'cpp', # Public and private include directories are declared in `src/meson.build`. subdir('src') -# Manual smoke tools -subdir('tools') +# Manual smoke tools are sender-side. +if get_option('sender') + subdir('tools') +endif # Tests enable_tests = get_option('tests') diff --git a/meson_options.txt b/meson_options.txt index 1ba0de3..116d728 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -3,3 +3,6 @@ option('tests', type : 'boolean', value : true, option('gui', type : 'boolean', value : false, description : 'Build the optional GUI target (deferred to a later phase)') + +option('sender', type : 'boolean', value : true, + description : 'Build the sender; requires the PipeWire and xdg-desktop-portal development packages. Disable for receiver-only targets such as small ARM boards.') \ No newline at end of file diff --git a/scripts/install-receiver.sh b/scripts/install-receiver.sh new file mode 100755 index 0000000..22bf076 --- /dev/null +++ b/scripts/install-receiver.sh @@ -0,0 +1,120 @@ +#!/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) +# +# Requirements: +# - Debian/Raspberry Pi OS (apt) with GCC 13 or newer (the code uses +# C++20 ; 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}" + +log() { printf '\n=== %s\n' "$*"; } +die() { printf 'install-receiver: error: %s\n' "$*" >&2; exit 1; } + +[ "$(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)" + trap 'rm -rf "${SDL_DIR}"' EXIT + 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 support" +if ! printf '#include \nint main() { return 0; }\n' | + g++ -std=c++20 -x c++ - -o /dev/null 2>/dev/null; then + die "g++ lacks C++20 ; install GCC 13 or newer (Raspberry Pi OS \ +Trixie or newer ships one). Aborting before a long build." +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 few minutes on small boards)" +meson compile -C "${BUILD_DIR}" + +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 + +log "done." +cat <<'EOF' + +The receiver is installed. On this machine run: + + screencast --receive + +and on the sending machine: + + screencast --send # discovers this receiver on the LAN + +Notes for small boards (e.g. Pi Zero 2 W): + - Decoding is software H.264; expect smooth playback for small streams + and a lower frame rate at high resolutions. + - Stop the receiver with Ctrl-C; never kill -9 (it would leave stale + mDNS records that break discovery for ~75 minutes). + - The receiver needs avahi-daemon (enabled) and a graphical session or + KMS console to open its window. +EOF \ No newline at end of file diff --git a/src/app/main.cpp b/src/app/main.cpp index e303222..89a571c 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -31,6 +31,8 @@ void handle_interrupt(int) { g_interrupted.store(true); } +#ifdef SC_HAS_SENDER + sc::Endpoint parse_endpoint(std::string_view address, std::uint16_t default_port) { sc::Endpoint endpoint; const std::size_t separator = address.rfind(':'); @@ -47,6 +49,8 @@ sc::Endpoint parse_endpoint(std::string_view address, std::uint16_t default_port return endpoint; } +#endif // SC_HAS_SENDER + std::string make_session_id() { static std::mt19937 engine{std::random_device{}()}; std::string id; @@ -97,6 +101,8 @@ std::vector discover_peers(int timeout_seconds, std::string& return peers; } +#ifdef SC_HAS_SENDER + int run_sender(const sc::SendCommand& command) { // 1. Find the receiver's signaling endpoint: explicit --peer, or discover // exactly one receiver on the LAN. @@ -202,6 +208,8 @@ int run_sender(const sc::SendCommand& command) { return 0; } +#endif // SC_HAS_SENDER + int run_receiver(const sc::ReceiveCommand& command) { sc::ReceiverPipelineConfig config; config.local_rtp_endpoint = sc::Endpoint{"0.0.0.0", static_cast(command.local_rtp_port)}; @@ -247,7 +255,13 @@ int main(int argc, char* argv[]) { std::signal(SIGTERM, handle_interrupt); if (const sc::SendCommand* send = std::get_if(&*command)) { +#ifdef SC_HAS_SENDER return run_sender(*send); +#else + (void)send; + std::cerr << "screencast: this is a receiver-only build; --send is not available\n"; + return 1; +#endif } if (const sc::ReceiveCommand* receive = std::get_if(&*command)) { return run_receiver(*receive); diff --git a/src/app/meson.build b/src/app/meson.build index 029016e..c686210 100644 --- a/src/app/meson.build +++ b/src/app/meson.build @@ -1,9 +1,23 @@ # The screencast application binary wiring every module together. +# Receiver-only builds (-Dsender=false) exclude the capture backend and the +# sender pipeline. -executable('screencast', - [ - 'cli.cpp', - 'main.cpp', - 'pipelines.cpp', - ], - dependencies : [sc_capture_dep, sc_codec_dep, sc_network_dep, sc_render_dep]) \ No newline at end of file +screencast_sources = [ + 'cli.cpp', + 'main.cpp', + 'pipelines.cpp', +] + +screencast_dependencies = [sc_codec_dep, sc_network_dep, sc_render_dep] +screencast_arguments = [] + +if build_sender + screencast_dependencies += [sc_capture_dep] + screencast_arguments += ['-DSC_HAS_SENDER=1'] +endif + +screencast = executable('screencast', + screencast_sources, + cpp_args : screencast_arguments, + dependencies : screencast_dependencies, + install : true) \ No newline at end of file diff --git a/src/app/pipelines.cpp b/src/app/pipelines.cpp index e420b88..ec345bf 100644 --- a/src/app/pipelines.cpp +++ b/src/app/pipelines.cpp @@ -28,6 +28,8 @@ std::string receiver_service_name() { } // namespace +#ifdef SC_HAS_SENDER + class SenderPipeline::Impl { public: explicit Impl(SenderPipelineConfig config) : config_(std::move(config)) {} @@ -121,6 +123,8 @@ void SenderPipeline::stop() { impl_->stop(); } +#endif // SC_HAS_SENDER + class ReceiverPipeline::Impl { public: explicit Impl(ReceiverPipelineConfig config) : config_(std::move(config)) {} diff --git a/src/meson.build b/src/meson.build index 50cf8a1..1476027 100644 --- a/src/meson.build +++ b/src/meson.build @@ -1,7 +1,12 @@ # Core public headers / include dependency. sc_core_inc = include_directories('../include') -subdir('capture') +# The sender needs the PipeWire / xdg-desktop-portal capture backend; +# receiver-only builds skip it entirely. +build_sender = get_option('sender') +if build_sender + subdir('capture') +endif # Phase 2 codec dependencies. dep_avcodec = dependency('libavcodec')