diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5321f40..a6706b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,48 @@ concurrency: cancel-in-progress: true jobs: + linux: + name: Linux CLI and capture lifecycle + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 20 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + with: + components: rustfmt + - name: Install native dependencies + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libgstreamer1.0-dev \ + libgstreamer-plugins-base1.0-dev gstreamer1.0-pipewire \ + gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly \ + gstreamer1.0-libav ffmpeg dbus-daemon xvfb x11-apps pipewire \ + pulseaudio + - name: Build and test + run: | + cargo fmt --check + cargo check --workspace --locked + cargo test --workspace --locked + dbus-run-session -- cargo test -p linux portal_roundtrip --locked -- --ignored + cargo test -p linux node_watch --locked -- --ignored + cargo test -p linux pulse_server --locked -- --ignored --test-threads=1 + ./scripts/package-cli-linux.sh + - name: Test packaged CLI + run: | + stage="$(mktemp -d)" + trap 'rm -rf "$stage"' EXIT + tar -xzf dist/cli/linux/wrec-cli-x86_64-unknown-linux-gnu.tar.gz -C "$stage" + python3 scripts/test-cli-linux.py "$stage" + python3 scripts/test-capture-linux.py "$stage/bin/wrec" + - name: Upload Linux CLI package + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: wrec-cli-linux + path: dist/cli/linux/*.tar.gz + rust: name: Rust checks runs-on: blacksmith-6vcpu-macos-latest diff --git a/Cargo.lock b/Cargo.lock index 58b0e25..0bdc87b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,12 +17,167 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "ashpd" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8bd58b44ea371b48d21cdc217380cfcafd4b2bb1ad50d27514ec5beca71a2d" +dependencies = [ + "enumflags2", + "futures-channel", + "futures-util", + "rand", + "serde", + "serde_repr", + "tokio", + "url", + "zbus", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -32,12 +187,31 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cc" version = "1.2.62" @@ -48,6 +222,16 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfg-expr" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2b34126159980f92da2a08bdec0694fd80fb5eb9e48aff25d20a0d8dfa710d" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -71,6 +255,15 @@ dependencies = [ "wrec-channel", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "config" version = "0.3.0" @@ -93,6 +286,31 @@ dependencies = [ "wrec-channel", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "ctrlc" version = "3.5.2" @@ -100,8 +318,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" dependencies = [ "dispatch2", - "nix", - "windows-sys", + "nix 0.31.3", + "windows-sys 0.61.2", ] [[package]] @@ -112,6 +330,7 @@ dependencies = [ "control", "domain", "libc", + "linux", "macos", "rusqlite", "serde", @@ -121,6 +340,16 @@ dependencies = [ "wrec-channel", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch2" version = "0.3.1" @@ -133,6 +362,17 @@ dependencies = [ "objc2", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "domain" version = "0.3.0" @@ -144,6 +384,75 @@ dependencies = [ "wrec-channel", ] +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -156,6 +465,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -169,149 +484,687 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] -name = "hashbrown" -version = "0.16.1" +name = "form_urlencoded" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ - "foldhash", + "percent-encoding", ] [[package]] -name = "hashlink" -version = "0.11.0" +name = "futures-channel" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ - "hashbrown", + "futures-core", ] [[package]] -name = "itoa" -version = "1.0.18" +name = "futures-core" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] -name = "js-sys" -version = "0.3.98" +name = "futures-executor" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ - "cfg-if", - "once_cell", - "wasm-bindgen", + "futures-core", + "futures-task", + "futures-util", ] [[package]] -name = "lazy_static" -version = "1.5.0" +name = "futures-io" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] -name = "libc" -version = "0.2.186" +name = "futures-lite" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] [[package]] -name = "libsqlite3-sys" -version = "0.37.0" +name = "futures-macro" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ - "cc", - "pkg-config", - "vcpkg", + "proc-macro2", + "quote", + "syn 3.0.5", ] [[package]] -name = "log" -version = "0.4.29" +name = "futures-sink" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] -name = "macos" -version = "0.3.0" +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ - "anyhow", - "domain", - "objc2", - "objc2-av-foundation", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-media", - "objc2-core-video", - "objc2-foundation", - "objc2-metal", - "objc2-screen-capture-kit", - "objc2-video-toolbox", - "serde", - "serde_json", - "tracing", + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", ] [[package]] -name = "matchers" -version = "0.2.0" +name = "generic-array" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ - "regex-automata", + "typenum", + "version_check", ] [[package]] -name = "memchr" -version = "2.8.0" +name = "gethostname" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] [[package]] -name = "nix" -version = "0.31.3" +name = "getrandom" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "bitflags", "cfg-if", - "cfg_aliases", "libc", + "wasi", ] [[package]] -name = "nu-ansi-term" -version = "0.50.3" +name = "getrandom" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "windows-sys", + "cfg-if", + "libc", + "r-efi", + "wasip2", ] [[package]] -name = "objc2" -version = "0.6.4" +name = "gio-sys" +version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "521e93a7e56fc89e84aea9a52cfc9436816a4b363b030260b699950ff1336c83" dependencies = [ - "objc2-encode", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys 0.59.0", ] [[package]] -name = "objc2-av-foundation" -version = "0.3.2" +name = "glib" +version = "0.20.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0" +checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683" dependencies = [ "bitflags", - "block2", - "dispatch2", - "objc2", - "objc2-avf-audio", - "objc2-core-audio-types", - "objc2-core-foundation", - "objc2-core-graphics", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-macros" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8084af62f09475a3f529b1629c10c429d7600ee1398ae12dd3bf175d74e7145" +dependencies = [ + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ab79e1ed126803a8fb827e3de0e2ff95191912b8db65cee467edb56fc4cc215" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9aca94bb73989e3cfdbf8f2e0f1f6da04db4d291c431f444838925c4c63eda" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gstreamer" +version = "0.23.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8757a87f3706560037a01a9f06a59fcc7bdb0864744dcf73546606e60c4316e1" +dependencies = [ + "cfg-if", + "futures-channel", + "futures-core", + "futures-util", + "glib", + "gstreamer-sys", + "itertools", + "libc", + "muldiv", + "num-integer", + "num-rational", + "once_cell", + "option-operations", + "paste", + "pin-project-lite", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "gstreamer-allocators" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45f3c36aa5bc98e5dbfd19a27536373167ab1ad734ffafb84d5d65203fe75208" +dependencies = [ + "glib", + "gstreamer", + "gstreamer-allocators-sys", + "libc", + "once_cell", +] + +[[package]] +name = "gstreamer-allocators-sys" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c005b6cfae0c7e97b0ea95921f561fc8a8786b734cec25c29363a52305074a10" +dependencies = [ + "glib-sys", + "gobject-sys", + "gstreamer-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gstreamer-sys" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "feea73b4d92dbf9c24a203c9cd0bcc740d584f6b5960d5faf359febf288919b2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux" +version = "0.3.0" +dependencies = [ + "ashpd", + "domain", + "futures-util", + "gstreamer", + "gstreamer-allocators", + "libc", + "serde", + "serde_json", + "tokio", + "x11-dl", + "x11rb", + "zbus", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "macos" +version = "0.3.0" +dependencies = [ + "anyhow", + "domain", + "objc2", + "objc2-av-foundation", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-media", + "objc2-core-video", + "objc2-foundation", + "objc2-metal", + "objc2-screen-capture-kit", + "objc2-video-toolbox", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muldiv" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956787520e75e9bd233246045d19f42fb73242759cc57fba9611d940ae96d4b0" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-av-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0" +dependencies = [ + "bitflags", + "block2", + "dispatch2", + "objc2", + "objc2-avf-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-core-graphics", "objc2-core-image", "objc2-core-video", "objc2-foundation", @@ -552,18 +1405,98 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "option-operations" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c26d27bb1aeab65138e4bf7666045169d1717febcc9ff870166be8348b223d0" +dependencies = [ + "paste", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -582,6 +1515,42 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -624,6 +1593,19 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -657,7 +1639,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -673,6 +1655,37 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -688,12 +1701,38 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "sqlite-wasm-rs" version = "0.5.4" @@ -707,65 +1746,229 @@ dependencies = [ ] [[package]] -name = "syn" -version = "2.0.117" +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "unicode-ident", + "syn 3.0.5", ] [[package]] -name = "thiserror" -version = "1.0.69" +name = "toml" +version = "1.0.7+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "dd28d57d8a6f6e458bc0b8784f8fdcc4b99a437936056fa122cb234f18656a96" dependencies = [ - "thiserror-impl 1.0.69", + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 1.0.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", ] [[package]] -name = "thiserror" -version = "2.0.18" +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ - "thiserror-impl 2.0.18", + "serde_core", ] [[package]] -name = "thiserror-impl" -version = "1.0.69" +name = "toml_datetime" +version = "1.0.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" dependencies = [ - "proc-macro2", - "quote", - "syn", + "serde_core", ] [[package]] -name = "thiserror-impl" -version = "2.0.18" +name = "toml_edit" +version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ - "proc-macro2", - "quote", - "syn", + "indexmap", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow 0.7.15", ] [[package]] -name = "thread_local" -version = "1.1.9" +name = "toml_parser" +version = "1.0.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" dependencies = [ - "cfg-if", + "winnow 1.0.4", ] +[[package]] +name = "toml_writer" +version = "1.0.7+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" + [[package]] name = "tracing" version = "0.1.44" @@ -785,7 +1988,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -827,12 +2030,54 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "valuable" version = "0.1.1" @@ -845,6 +2090,33 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.121" @@ -877,7 +2149,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -890,12 +2162,52 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -905,6 +2217,91 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wrec-channel" version = "0.3.0" @@ -912,12 +2309,244 @@ dependencies = [ "serde", ] +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "xtask" version = "0.3.0" +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tokio", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e3c6377872d72510393f688a555d7097b0f741995c7a00f0407f786dd486b2d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "url", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] diff --git a/Cargo.toml b/Cargo.toml index d44334a..2fb8035 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/domain", "crates/daemon", "crates/macos", + "crates/linux", "crates/xtask", ] resolver = "2" diff --git a/README.md b/README.md index 9b86902..205b91f 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,10 @@ Wrec records displays or windows with a native ScreenCaptureKit pipeline, writes hardware-encoded `.mov` files, and gives you both a native SwiftUI menu-bar app and a JSON-friendly CLI for scripts and agents. +The Linux CLI records Wayland and X11 desktops to `.mp4` files. It prefers +VA-API or NVIDIA hardware encoding and falls back to software encoding when +needed. See [Linux setup and limitations](packaging/linux/README.md). + > [!NOTE] > Wrec is still early public software. Release builds are not notarized, so > macOS blocks the first launch of an app downloaded with a browser. The diff --git a/crates/cli/src/run.rs b/crates/cli/src/run.rs index 90d13d0..53184a8 100644 --- a/crates/cli/src/run.rs +++ b/crates/cli/src/run.rs @@ -64,6 +64,13 @@ pub fn record(args: RecordArgs) -> ExitCode { } let completed = wait_for_job(job.id, json_output)?; + if !json_output { + // Warnings raised while recording, such as lost media. + for warning in completed.warnings.iter().skip(job.warnings.len()) { + eprintln!("warning: {}", warning.message); + eprintln!("next: {}", warning.next); + } + } Ok(match completed.status { JobStatus::Completed => ExitCode::SUCCESS, JobStatus::Failed | JobStatus::Cancelled => ExitCode::FAILURE, diff --git a/crates/control/src/client.rs b/crates/control/src/client.rs index baa7326..880a672 100644 --- a/crates/control/src/client.rs +++ b/crates/control/src/client.rs @@ -255,6 +255,7 @@ pub fn ensure_daemon() -> Result<(), AgentError> { next: "Create the directory manually or set WREC_HOME to a writable path.".into(), })?; + #[cfg(target_os = "macos")] if std::env::var_os("WREC_DAEMON_BIN").is_none() && std::env::var_os("WREC_HEADLESS").as_deref() != Some(std::ffi::OsStr::new("1")) { @@ -338,6 +339,7 @@ pub fn ensure_daemon() -> Result<(), AgentError> { }) } +#[cfg(target_os = "macos")] fn launch_app_daemon(client: &DaemonClient) -> Result<(), AgentError> { let channel = Channel::current(); let status = Command::new("/usr/bin/open") @@ -603,6 +605,16 @@ fn daemon_candidates(current: &Path) -> Vec { .filter(|_| current_dir.file_name().is_some_and(|name| name == "deps")); [ + #[cfg(target_os = "linux")] + current_dir + .parent() + .filter(|_| current_dir.file_name().is_some_and(|name| name == "bin")) + .map(|prefix| { + prefix + .join("lib") + .join(Channel::current().runtime_dir_name()) + .join("daemon") + }), Some(current_dir.join("daemon")), profile_dir.map(|dir| dir.join("daemon")), Some(installed_daemon_path()), @@ -623,7 +635,14 @@ fn daemon_executable_launch(path: PathBuf) -> Option { return None; } - if cargo_profile_dir(&path).is_some() { + #[cfg(target_os = "linux")] + { + use std::os::unix::fs::PermissionsExt; + if std::fs::metadata(&path).ok()?.permissions().mode() & 0o111 == 0 { + return None; + } + } + if cfg!(target_os = "linux") || cargo_profile_dir(&path).is_some() { return Some(DaemonLaunch::executable(path)); } @@ -718,10 +737,18 @@ mod tests { } #[test] - fn daemon_candidates_prefer_sibling_daemon() { + fn daemon_candidates_prefer_the_platform_package_layout() { let candidates = daemon_candidates(&PathBuf::from("/tmp/wrec/bin/wrec")); + #[cfg(target_os = "macos")] assert_eq!(candidates[0], PathBuf::from("/tmp/wrec/bin/daemon")); + #[cfg(target_os = "linux")] + assert_eq!( + candidates[0], + PathBuf::from("/tmp/wrec/lib") + .join(wrec_channel::Channel::current().runtime_dir_name()) + .join("daemon") + ); } #[test] @@ -746,6 +773,11 @@ mod tests { let daemon = debug.join("daemon"); std::fs::create_dir_all(&debug).unwrap(); std::fs::write(&daemon, "").unwrap(); + #[cfg(target_os = "linux")] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&daemon, std::fs::Permissions::from_mode(0o755)).unwrap(); + } let launch = daemon_executable_launch(daemon.clone()).unwrap(); @@ -756,7 +788,32 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[cfg(target_os = "linux")] #[test] + fn linux_package_finds_runtime_relative_to_install_prefix() { + let candidates = daemon_candidates(std::path::Path::new("/opt/wrec/bin/wrec")); + assert!(candidates.contains( + &PathBuf::from("/opt/wrec/lib") + .join(wrec_channel::Channel::current().runtime_dir_name()) + .join("daemon") + )); + let dir = std::env::temp_dir().join(format!("wrec-linux-daemon-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let daemon = dir.join("daemon"); + std::fs::write(&daemon, "").unwrap(); + #[cfg(target_os = "linux")] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&daemon, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let launch = daemon_executable_launch(daemon.clone()).unwrap(); + assert_eq!(launch.program, daemon); + assert!(launch.envs.is_empty()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + #[cfg(target_os = "macos")] fn packaged_daemon_launch_requires_sibling_capture_engine() { let dir = std::env::temp_dir().join(format!( "wrec-control-packaged-daemon-{}", @@ -766,6 +823,11 @@ mod tests { let capture_engine = dir.join("capture-engine"); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(&daemon, "").unwrap(); + #[cfg(target_os = "linux")] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&daemon, std::fs::Permissions::from_mode(0o755)).unwrap(); + } assert!(daemon_executable_launch(daemon.clone()).is_none()); diff --git a/crates/daemon/Cargo.toml b/crates/daemon/Cargo.toml index 8293a7e..c38c66f 100644 --- a/crates/daemon/Cargo.toml +++ b/crates/daemon/Cargo.toml @@ -10,7 +10,6 @@ control = { path = "../control" } config = { path = "../config" } domain = { path = "../domain" } libc = "0.2" -macos = { path = "../macos" } rusqlite = { version = "0.39.0", features = ["bundled"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" @@ -18,6 +17,12 @@ thiserror.workspace = true tracing.workspace = true wrec-channel.workspace = true +[target.'cfg(target_os = "macos")'.dependencies] +macos = { path = "../macos" } + +[target.'cfg(target_os = "linux")'.dependencies] +linux = { path = "../linux" } + [[bin]] name = "daemon" path = "src/main.rs" diff --git a/crates/daemon/src/backend.rs b/crates/daemon/src/backend.rs index 2be6e20..6724efd 100644 --- a/crates/daemon/src/backend.rs +++ b/crates/daemon/src/backend.rs @@ -39,6 +39,7 @@ pub struct RecordingOverrides { #[derive(Debug, Clone)] pub enum BackendEvent { + Cancelled, Starting { output_path: PathBuf, }, @@ -49,6 +50,9 @@ pub enum BackendEvent { Metrics { metrics: RecorderMetrics, }, + MediaLost { + message: String, + }, Failed { message: String, }, @@ -86,6 +90,14 @@ impl WrecBackend { pub fn handle_recorder_event(&mut self, event: &RecorderEvent) -> BackendEvent { match event { + RecorderEvent::Cancelled { session_id } => { + if let Some(store) = &self.store { + store.mark_recording_cancelled(*session_id, now_ms()); + } + self.active_session_id = None; + self.active_output_path = None; + BackendEvent::Cancelled + } RecorderEvent::Starting { session_id, target, @@ -147,6 +159,22 @@ impl WrecBackend { metrics: metrics.clone(), } } + RecorderEvent::MediaLost { + session_id, + message, + } => { + self.append_event( + Some(*session_id), + EventSource::CaptureEngine, + EventLevel::Warning, + None, + message.clone(), + ); + + BackendEvent::MediaLost { + message: message.clone(), + } + } RecorderEvent::Failed { session_id, message, @@ -175,9 +203,12 @@ impl WrecBackend { success, status, } => { - let output_path = self.active_output_path.clone(); let failed_before_exit = self.failed_session_ids.remove(session_id); let success = *success && !failed_before_exit; + let output_path = self + .active_output_path + .clone() + .filter(|path| success || path.is_file()); if success { self.mark_recording_completed(*session_id, output_path.as_deref()); @@ -229,7 +260,7 @@ impl WrecBackend { fn mark_recording_started(&self, session_id: u64) { if let Some(store) = &self.store { - store.mark_recording_started(session_id); + store.mark_recording_started(session_id, now_ms()); } } @@ -606,4 +637,36 @@ mod tests { assert!(matches!(event, BackendEvent::Exited { success: false, .. })); } + + #[test] + fn failed_start_omits_missing_output_and_failed_capture_keeps_existing_output() { + let path = + std::env::temp_dir().join(format!("wrec-failure-artifact-{}", std::process::id())); + for (stage, artifact) in [ + ("portal rejected", false), + ("setup failed", false), + ("zero-frame timeout", false), + ("capture failed after frames", true), + ] { + if artifact { + std::fs::write(&path, b"partial movie fragment").unwrap(); + } + let mut backend = WrecBackend { + store: None, + active_session_id: Some(1), + active_output_path: Some(path.clone()), + failed_session_ids: HashSet::new(), + }; + let event = backend.handle_recorder_event(&RecorderEvent::Exited { + session_id: 1, + success: false, + status: stage.into(), + }); + let BackendEvent::Exited { output_path, .. } = event else { + panic!("expected exit") + }; + assert_eq!(output_path.is_some(), artifact, "{stage}"); + } + std::fs::remove_file(path).unwrap(); + } } diff --git a/crates/daemon/src/coordinator.rs b/crates/daemon/src/coordinator.rs index 5c4349d..d1dc202 100644 --- a/crates/daemon/src/coordinator.rs +++ b/crates/daemon/src/coordinator.rs @@ -10,7 +10,7 @@ use control::{ daemon_log_path, now_ms, socket_path, wrec_home, AgentError, AgentWarning, EventLevel, JobStatus, RecordingOptions, StartRecordingParams, PROTOCOL_VERSION, }; -use domain::{CaptureTarget, RecorderEngine, RecorderEvent}; +use domain::{CaptureTarget, RecorderEngine, RecorderError, RecorderEvent}; use serde_json::{json, Value}; use std::{ collections::{BTreeMap, VecDeque}, @@ -55,6 +55,40 @@ impl Coordinator { .unwrap_or(true) } + #[cfg(target_os = "linux")] + pub(crate) fn stop_for_shutdown(state: &SharedCoordinator) { + let control = { + let Ok(mut state) = lock_state(state) else { + return; + }; + state.shutdown_requested = true; + while let Some(id) = state.queue.pop_front() { + if let Some(job) = state.jobs.get_mut(&id) { + job.mark_cancelled(); + } + } + state + .active_job_id + .and_then(|id| state.jobs.get_mut(&id)) + .and_then(|job| { + job.mark_finishing(); + job.control.clone() + }) + }; + if let Some(control) = control { + if let Ok(mut engine) = control.lock() { + let _ = engine.stop(); + } + } + } + + #[cfg(target_os = "linux")] + pub(crate) fn capture_active(state: &SharedCoordinator) -> bool { + lock_state(state) + .map(|state| state.active_job_id.is_some()) + .unwrap_or(false) + } + pub(crate) fn status(&self) -> Value { json!({ "daemon_version": env!("CARGO_PKG_VERSION"), @@ -142,8 +176,8 @@ impl Coordinator { let (job, should_launch) = { let config = load_config(); let overrides = recording_overrides(¶ms.options); - let (settings, warning) = build_settings_report(&config.settings, &overrides); - let warnings = warning + let (mut settings, warning) = build_settings_report(&config.settings, &overrides); + let mut warnings = warning .map(|message| AgentWarning { code: "preset_limited".into(), message, @@ -151,6 +185,9 @@ impl Coordinator { }) .into_iter() .collect::>(); + lock_state(&state)? + .runtime + .prepare_settings(&mut settings, &mut warnings); let targets = list_targets_with_cache(&state, true)?; let target = resolve_record_target( &targets, @@ -531,22 +568,32 @@ fn run_job( }; if let Err(err) = start_result { drain_recorder_events(&state, job_id, &rx); + if matches!(err, RecorderError::Cancelled) { + if let Ok(mut state) = lock_state(&state) { + if let Some(job) = state.jobs.get_mut(&job_id) { + job.mark_cancelled(); + } + if state.active_job_id == Some(job_id) { + state.active_job_id = None; + } + } + notify_job_changed(); + return; + } finish_job_failed(&state, job_id, format!("recording failed to start: {err}")); return; } - if let Ok(mut state) = lock_state(&state) { - if let Some(job) = state.jobs.get_mut(&job_id) { - job.mark_recording(); - } - } - notify_job_changed(); - - let started = Instant::now(); + // A portal can wait for user selection after start() returns. Only the + // engine's Started event means frames are flowing and duration can begin. + let mut started = None; let mut duration_stop_requested = false; loop { match rx.recv_timeout(Duration::from_millis(200)) { Ok(event) => { + if matches!(event, RecorderEvent::Started { .. }) && started.is_none() { + started = Some(Instant::now()); + } let done = handle_recorder_event(&state, job_id, event); if done { break; @@ -559,7 +606,7 @@ fn run_job( } } - if let Some(duration_ms) = duration_ms { + if let (Some(duration_ms), Some(started)) = (duration_ms, started) { if !duration_stop_requested && started.elapsed() >= Duration::from_millis(duration_ms) { duration_stop_requested = true; append_job_message( @@ -614,6 +661,14 @@ fn handle_recorder_event( } let done = match backend_event { + BackendEvent::Cancelled => { + job.output_path = None; + job.mark_cancelled(); + if active_matches { + state.active_job_id = None; + } + true + } BackendEvent::Starting { output_path, .. } => { job.output_path = Some(output_path.clone()); job.push_event( @@ -623,6 +678,9 @@ fn handle_recorder_event( false } BackendEvent::Started => { + if job.status == JobStatus::Starting { + job.mark_recording(); + } job.push_event(EventLevel::Info, "recording started".to_string()); false } @@ -634,6 +692,18 @@ fn handle_recorder_event( job.push_metrics(metrics); false } + BackendEvent::MediaLost { message } => { + job.push_event(EventLevel::Warning, message.clone()); + job.warnings.push(AgentWarning { + code: "media_lost".into(), + message, + next: format!( + "The movie plays but has gaps. `wrec job logs {}` shows when. Record to a faster disk, close programs that load the CPU, GPU or disk, or lower --quality or --fps.", + job.id + ), + }); + false + } BackendEvent::Failed { message, .. } => { job.mark_failed(message); if active_matches { @@ -647,7 +717,11 @@ fn handle_recorder_event( output_path, .. } => { - job.output_path = output_path.or_else(|| job.output_path.clone()); + job.output_path = output_path.or_else(|| { + job.output_path + .clone() + .filter(|path| success || path.is_file()) + }); if success { job.mark_completed(format!("capture engine exited: {status}")); } else { @@ -659,7 +733,7 @@ fn handle_recorder_event( true } }; - if done { + if done || matches!(event, RecorderEvent::Started { .. }) { drop(state); notify_job_changed(); } @@ -818,6 +892,7 @@ fn target_list_lock() -> &'static Mutex<()> { /// payload: observers just re-query `wrec jobs`/`job show`, so a stale or /// coalesced delivery is harmless. fn notify_job_changed() { + #[cfg(target_os = "macos")] macos::post_distributed_notification(&Channel::current().notification_name("job-changed")); } @@ -884,6 +959,76 @@ mod tests { use control::{JobSnapshot, RecordingOptions, TargetSelector}; use domain::{CaptureSourceKind, PermissionStatus}; + #[test] + fn duration_waits_for_capture_readiness() { + let _guard = env_lock(); + isolate_env(); + let runtime = FakeRuntime::new(); + runtime + .start_gate + .store(false, std::sync::atomic::Ordering::Relaxed); + let state = Arc::new(Mutex::new(Coordinator::new(runtime.clone()))); + let value = Coordinator::record_start( + state.clone(), + StartRecordingParams { + selector: Some(TargetSelector::Id { + kind: CaptureSourceKind::Display, + id: 1, + }), + options: RecordingOptions::default(), + duration_ms: Some(100), + queue: true, + }, + ) + .unwrap(); + let id = value["job"]["id"].as_u64().unwrap(); + wait_for_status(&state, id, JobStatus::Starting); + std::thread::sleep(Duration::from_millis(350)); + assert_eq!(job_status(&state, id), JobStatus::Starting); + runtime + .start_gate + .store(true, std::sync::atomic::Ordering::Relaxed); + wait_for_status(&state, id, JobStatus::Recording); + wait_for_status(&state, id, JobStatus::Completed); + } + + #[test] + fn pending_source_selection_can_be_stopped() { + let _guard = env_lock(); + isolate_env(); + let runtime = FakeRuntime::new(); + runtime + .start_gate + .store(false, std::sync::atomic::Ordering::Relaxed); + let state = Arc::new(Mutex::new(Coordinator::new(runtime))); + let id = start_job(state.clone()).id; + wait_for_status(&state, id, JobStatus::Starting); + // Wait for the asynchronous Starting event, so the fake owns its session. + for _ in 0..50 { + if lock_state(&state).unwrap().jobs[&id].output_path.is_some() { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + Coordinator::job_stop(state.clone(), id).unwrap(); + wait_for_status(&state, id, JobStatus::Cancelled); + } + + #[cfg(target_os = "linux")] + #[test] + fn shutdown_finishes_capture_and_cancels_queued_jobs() { + let _guard = env_lock(); + isolate_env(); + let state = Arc::new(Mutex::new(Coordinator::new(FakeRuntime::new()))); + let active = start_job(state.clone()).id; + wait_for_status(&state, active, JobStatus::Recording); + let queued = start_job(state.clone()).id; + Coordinator::stop_for_shutdown(&state); + wait_for_status(&state, active, JobStatus::Completed); + assert_eq!(job_status(&state, queued), JobStatus::Cancelled); + assert!(Coordinator::shutdown_requested(&state)); + } + #[test] fn queued_job_launches_after_active_job_stops() { let _guard = env_lock(); @@ -911,6 +1056,44 @@ mod tests { wait_for_status(&state, second, JobStatus::Completed); } + #[test] + fn a_completed_job_warns_about_media_its_movie_lost() { + let _guard = env_lock(); + isolate_env(); + let state = Arc::new(Mutex::new(Coordinator::new(FakeRuntime::new()))); + let id = start_job(state.clone()).id; + wait_for_status(&state, id, JobStatus::Recording); + let message = "The movie finished but lost media: microphone is missing 0.70 s of audio."; + handle_recorder_event( + &state, + id, + RecorderEvent::MediaLost { + session_id: 100, + message: message.into(), + }, + ); + Coordinator::job_stop(state.clone(), id).unwrap(); + wait_for_status(&state, id, JobStatus::Completed); + + let job = lock_state(&state) + .unwrap() + .jobs + .get(&id) + .unwrap() + .snapshot(None); + let warning = job + .warnings + .iter() + .find(|warning| warning.code == "media_lost") + .unwrap(); + assert_eq!(warning.message, message); + assert!(warning.next.contains(&format!("wrec job logs {id}"))); + assert!(job + .events + .iter() + .any(|event| matches!(event.level, EventLevel::Warning) && event.message == message)); + } + #[test] fn queued_job_can_be_cancelled() { let _guard = env_lock(); diff --git a/crates/daemon/src/jobs.rs b/crates/daemon/src/jobs.rs index 660f24b..824e361 100644 --- a/crates/daemon/src/jobs.rs +++ b/crates/daemon/src/jobs.rs @@ -95,7 +95,7 @@ impl JobRecord { self.status = JobStatus::Cancelled; self.finished_at_ms = Some(now_ms()); self.control = None; - self.push_event(EventLevel::Warning, "queued job cancelled"); + self.push_event(EventLevel::Warning, "job cancelled"); } } diff --git a/crates/daemon/src/main.rs b/crates/daemon/src/main.rs index aada87f..5dfa44d 100644 --- a/crates/daemon/src/main.rs +++ b/crates/daemon/src/main.rs @@ -1,4 +1,11 @@ fn main() { + #[cfg(target_os = "linux")] + if std::env::args_os() + .nth(1) + .is_some_and(|argument| argument == linux::CAPTURE_WORKER_ARGUMENT) + { + linux::run_capture_worker(); + } if let Err(message) = daemon::serve_forever() { eprintln!("error: {message}"); std::process::exit(1); diff --git a/crates/daemon/src/runtime.rs b/crates/daemon/src/runtime.rs index d547a89..16bfefd 100644 --- a/crates/daemon/src/runtime.rs +++ b/crates/daemon/src/runtime.rs @@ -1,11 +1,19 @@ use control::AgentError; use domain::{CaptureTarget, PermissionStatus, RecorderEngine, RecorderError, RecorderEvent}; +#[cfg(target_os = "macos")] use macos::MacosRecorder; use std::sync::mpsc; pub(crate) trait RecordingRuntime: Clone + Send + Sync + 'static { type Engine: RecorderEngine + Send + 'static; + fn prepare_settings( + &self, + _settings: &mut domain::RecorderSettings, + _warnings: &mut Vec, + ) { + } + fn list_targets(&self) -> Result, AgentError>; fn screen_recording_permission_status(&self) -> Result; fn request_screen_recording_permission(&self) -> Result; @@ -18,9 +26,11 @@ pub(crate) trait RecordingRuntime: Clone + Send + Sync + 'static { fn new_engine(&self, events: mpsc::Sender) -> Self::Engine; } +#[cfg(target_os = "macos")] #[derive(Clone, Default)] pub(crate) struct MacosRuntime; +#[cfg(target_os = "macos")] impl RecordingRuntime for MacosRuntime { type Engine = MacosRecorder; @@ -70,6 +80,7 @@ impl RecordingRuntime for MacosRuntime { } } +#[cfg(any(target_os = "macos", test))] fn permission_error(error: RecorderError) -> AgentError { match error { RecorderError::MissingScreenRecordingPermission => { @@ -100,6 +111,7 @@ fn permission_error(error: RecorderError) -> AgentError { /// (Wrec.app or the terminal running `wrec daemon start`), not to the daemon /// binary itself, so a denial here otherwise leaves no trail in daemon.log /// pointing anyone at the actual app to go re-approve. +#[cfg(any(target_os = "macos", test))] fn warn_screen_recording_permission_missing() { tracing::warn!( "screen recording permission is not granted; grant it to the app that launched this \ @@ -107,6 +119,80 @@ fn warn_screen_recording_permission_missing() { ); } +#[cfg(target_os = "linux")] +pub(crate) use LinuxRuntime as PlatformRuntime; +#[cfg(target_os = "macos")] +pub(crate) use MacosRuntime as PlatformRuntime; + +#[cfg(target_os = "linux")] +#[derive(Clone, Default)] +pub(crate) struct LinuxRuntime; + +#[cfg(target_os = "linux")] +impl RecordingRuntime for LinuxRuntime { + type Engine = linux::LinuxRecorder; + + fn prepare_settings( + &self, + settings: &mut domain::RecorderSettings, + warnings: &mut Vec, + ) { + if settings.hide_wrec || (settings.include_microphone && settings.show_mic_indicator) { + warnings.push(control::AgentWarning { + code: "linux_settings_unavailable".into(), + message: "Linux does not provide Wrec-window exclusion or a custom microphone indicator; these settings are disabled for this job.".into(), + next: "Use the desktop's sharing controls and sound settings.".into(), + }); + } + settings.hide_wrec = false; + settings.show_mic_indicator = false; + } + + fn list_targets(&self) -> Result, AgentError> { + linux::list_targets().map_err(linux_error) + } + + fn screen_recording_permission_status(&self) -> Result { + linux::check_desktop().map_err(linux_error)?; + // The portal grants access per session when recording starts. + Ok(PermissionStatus::Unknown) + } + + fn request_screen_recording_permission(&self) -> Result { + self.screen_recording_permission_status() + } + + fn microphone_permission_status(&self) -> Result { + // Native PulseAudio clients have no separate TCC permission prompt. + // The audio server checks access when pulsesrc connects. + Ok(PermissionStatus::Granted) + } + + fn request_microphone_permission(&self) -> Result { + self.microphone_permission_status() + } + + fn open_microphone_settings(&self) -> Result<(), AgentError> { + Err(linux_error(RecorderError::Backend( + "Select the microphone in your desktop's sound settings.".into(), + ))) + } + + fn new_engine(&self, events: mpsc::Sender) -> Self::Engine { + linux::LinuxRecorder::new(events) + } +} + +#[cfg(target_os = "linux")] +fn linux_error(error: RecorderError) -> AgentError { + AgentError { + code: "linux_capture_unavailable".into(), + message: error.to_string(), + recoverable: true, + next: "Run wrec inside a Wayland desktop with its ScreenCast portal, or an X11 desktop with DISPLAY set. See packaging/linux/README.md.".into(), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/daemon/src/server.rs b/crates/daemon/src/server.rs index d0cc226..10b880c 100644 --- a/crates/daemon/src/server.rs +++ b/crates/daemon/src/server.rs @@ -1,6 +1,6 @@ use crate::{ coordinator::{lock_state, Coordinator, SharedCoordinator}, - runtime::{MacosRuntime, RecordingRuntime}, + runtime::{PlatformRuntime, RecordingRuntime}, }; use control::{ daemon_log_path, response_error, socket_path, wrec_home, AgentError, IpcRequest, IpcResponse, @@ -73,7 +73,7 @@ pub fn serve_forever() -> Result<(), String> { listener .set_nonblocking(true) .map_err(|err| format!("failed to configure {}: {err}", socket.display()))?; - let state = Arc::new(Mutex::new(Coordinator::new(MacosRuntime))); + let state = Arc::new(Mutex::new(Coordinator::new(PlatformRuntime))); while !TERMINATED.load(Ordering::SeqCst) && !Coordinator::shutdown_requested(&state) { match listener.accept() { @@ -88,12 +88,23 @@ pub fn serve_forever() -> Result<(), String> { } } + #[cfg(target_os = "linux")] + { + Coordinator::stop_for_shutdown(&state); + let deadline = std::time::Instant::now() + Duration::from_secs(15); + while Coordinator::capture_active(&state) && std::time::Instant::now() < deadline { + thread::sleep(POLL_INTERVAL); + } + if Coordinator::capture_active(&state) { + tracing::error!("capture did not finalize within 15s during daemon shutdown"); + } + } tracing::info!("daemon stopped"); let _ = std::fs::remove_file(&socket); Ok(()) } -fn handle_client(stream: UnixStream, state: SharedCoordinator) { +fn handle_client(stream: UnixStream, state: SharedCoordinator) { if let Err(err) = stream.set_nonblocking(false) { tracing::warn!("client blocking mode failed: {err}"); } diff --git a/crates/daemon/src/store.rs b/crates/daemon/src/store.rs index 01f0cc8..d933817 100644 --- a/crates/daemon/src/store.rs +++ b/crates/daemon/src/store.rs @@ -21,6 +21,7 @@ pub type Result = std::result::Result; #[derive(Debug, Clone, Copy)] pub enum RecordingStatus { + Cancelled, Starting, Recording, Completed, @@ -30,6 +31,7 @@ pub enum RecordingStatus { impl RecordingStatus { const fn as_str(self) -> &'static str { match self { + Self::Cancelled => "cancelled", Self::Starting => "starting", Self::Recording => "recording", Self::Completed => "completed", @@ -41,6 +43,7 @@ impl RecordingStatus { #[derive(Debug, Clone, Copy)] pub enum EventLevel { Info, + Warning, Error, } @@ -48,6 +51,7 @@ impl EventLevel { const fn as_str(self) -> &'static str { match self { Self::Info => "info", + Self::Warning => "warning", Self::Error => "error", } } @@ -119,6 +123,7 @@ enum StoreCommand { UpsertRecording(RecordingRecord), MarkRecordingStarted { id: u64, + started_at_ms: i64, }, MarkRecordingFinished { id: u64, @@ -172,8 +177,8 @@ impl Store { self.send(StoreCommand::UpsertRecording(recording)); } - pub fn mark_recording_started(&self, id: u64) { - self.send(StoreCommand::MarkRecordingStarted { id }); + pub fn mark_recording_started(&self, id: u64, started_at_ms: i64) { + self.send(StoreCommand::MarkRecordingStarted { id, started_at_ms }); } pub fn mark_recording_completed( @@ -201,6 +206,16 @@ impl Store { }); } + pub fn mark_recording_cancelled(&self, id: u64, stopped_at_ms: i64) { + self.send(StoreCommand::MarkRecordingFinished { + id, + status: RecordingStatus::Cancelled, + stopped_at_ms, + file_size_bytes: None, + error_message: None, + }); + } + pub fn update_dimensions(&self, id: u64, dimensions: CaptureDimensions) { self.send(StoreCommand::UpdateDimensions { id, dimensions }); } @@ -391,7 +406,9 @@ fn writer_loop(conn: Connection, receiver: mpsc::Receiver) { let kind = command.kind(); let result = match command { StoreCommand::UpsertRecording(recording) => upsert_recording(&conn, &recording), - StoreCommand::MarkRecordingStarted { id } => mark_recording_started(&conn, id), + StoreCommand::MarkRecordingStarted { id, started_at_ms } => { + mark_recording_started(&conn, id, started_at_ms) + } StoreCommand::MarkRecordingFinished { id, status, @@ -484,10 +501,14 @@ fn upsert_recording(conn: &Connection, recording: &RecordingRecord) -> rusqlite: Ok(()) } -fn mark_recording_started(conn: &Connection, id: u64) -> rusqlite::Result<()> { +fn mark_recording_started(conn: &Connection, id: u64, started_at_ms: i64) -> rusqlite::Result<()> { conn.execute( - "UPDATE recordings SET status = ?1, error_message = NULL WHERE id = ?2", - params![RecordingStatus::Recording.as_str(), u64_to_i64(id)], + "UPDATE recordings SET status = ?1, started_at_ms = ?3, error_message = NULL WHERE id = ?2", + params![ + RecordingStatus::Recording.as_str(), + u64_to_i64(id), + started_at_ms + ], )?; Ok(()) } @@ -506,7 +527,7 @@ fn mark_recording_finished( SET status = ?1, stopped_at_ms = ?2, - duration_ms = MAX(0, ?2 - started_at_ms), + duration_ms = CASE WHEN status = 'starting' AND ?1 != 'completed' THEN 0 ELSE MAX(0, ?2 - started_at_ms) END, file_size_bytes = COALESCE(?3, file_size_bytes), error_message = ?4 WHERE id = ?5 @@ -925,9 +946,9 @@ mod tests { { let store = Store::open(db.path()).expect("first open"); store.upsert_recording(sample_recording(1)); - store.mark_recording_started(1); + store.mark_recording_started(1, 1_000); store.upsert_recording(sample_recording(2)); - store.mark_recording_started(2); + store.mark_recording_started(2, 1_000); store.mark_recording_completed(2, 2000, Some(64)); } @@ -1038,7 +1059,7 @@ mod tests { let store = Store::open(db.path()).expect("open store"); store.upsert_recording(sample_recording(1)); store.mark_recording_failed(1, 2_000, "engine crashed".to_string()); - store.mark_recording_started(1); + store.mark_recording_started(1, 1_000); } let conn = read_connection(&db); @@ -1054,6 +1075,36 @@ mod tests { assert_eq!(error, None); } + #[test] + fn history_duration_excludes_picker_delay_and_cancelled_attempts() { + let db = TempDb::new("picker-duration"); + { + let store = Store::open(db.path()).unwrap(); + store.upsert_recording(sample_recording(1)); // Attempt at 1s. + store.mark_recording_started(1, 61_000); // Picker took one minute. + store.mark_recording_completed(1, 66_000, Some(1)); + store.upsert_recording(sample_recording(2)); + store.mark_recording_cancelled(2, 61_000); + } + let conn = read_connection(&db); + let recorded: (i64, i64) = conn + .query_row( + "SELECT started_at_ms, duration_ms FROM recordings WHERE id = 1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(recorded, (61_000, 5_000)); + let cancelled: (String, i64) = conn + .query_row( + "SELECT status, duration_ms FROM recordings WHERE id = 2", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(cancelled, ("cancelled".into(), 0)); + } + #[test] fn mark_recording_completed_sets_duration_and_file_size() { let db = TempDb::new("completed"); @@ -1160,12 +1211,31 @@ mod tests { assert_eq!(row.2, None); } + #[test] + fn cancelled_selection_is_persisted_without_a_capture_error() { + let db = TempDb::new("cancelled"); + { + let store = Store::open(db.path()).unwrap(); + store.upsert_recording(sample_recording(1)); + store.mark_recording_cancelled(1, 4_000); + } + let (status, error): (String, Option) = read_connection(&db) + .query_row( + "SELECT status, error_message FROM recordings WHERE id = 1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(status, "cancelled"); + assert_eq!(error, None); + } + #[test] fn updates_for_unknown_recording_change_nothing() { let db = TempDb::new("missing-row"); { let store = Store::open(db.path()).expect("open store"); - store.mark_recording_started(99); + store.mark_recording_started(99, 1_000); store.mark_recording_completed(99, 5_000, Some(1)); store.update_dimensions( 99, diff --git a/crates/daemon/src/test_support.rs b/crates/daemon/src/test_support.rs index ccb766b..6b3f22d 100644 --- a/crates/daemon/src/test_support.rs +++ b/crates/daemon/src/test_support.rs @@ -7,7 +7,7 @@ use domain::{ use std::{ path::PathBuf, sync::{ - atomic::{AtomicU64, Ordering}, + atomic::{AtomicBool, AtomicU64, Ordering}, mpsc, Arc, Mutex, MutexGuard, PoisonError, }, }; @@ -41,12 +41,15 @@ pub(crate) struct FakeRuntime { mic_request_result: Arc>, mic_requests: Arc, mic_settings_opens: Arc, + pub(crate) start_gate: Arc, } pub(crate) struct FakeEngine { events: mpsc::Sender, next_session_id: Arc, active: Option, + start_gate: Arc, + stopped: Arc, } impl FakeRuntime { @@ -63,6 +66,7 @@ impl FakeRuntime { mic_request_result: Arc::new(Mutex::new(PermissionStatus::Granted)), mic_requests: Arc::new(AtomicU64::new(0)), mic_settings_opens: Arc::new(AtomicU64::new(0)), + start_gate: Arc::new(AtomicBool::new(true)), } } @@ -125,6 +129,8 @@ impl RecordingRuntime for FakeRuntime { events, next_session_id: self.next_session_id.clone(), active: None, + start_gate: self.start_gate.clone(), + stopped: Arc::new(AtomicBool::new(false)), } } } @@ -155,12 +161,21 @@ impl RecorderEngine for FakeEngine { output_path: session.output_path.clone(), }) .unwrap(); - self.events - .send(RecorderEvent::Started { + let events = self.events.clone(); + let gate = self.start_gate.clone(); + let stopped = self.stopped.clone(); + std::thread::spawn(move || { + while !gate.load(Ordering::Relaxed) { + if stopped.load(Ordering::Relaxed) { + return; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let _ = events.send(RecorderEvent::Started { session_id: id, dimensions: None, - }) - .unwrap(); + }); + }); Ok(session) } @@ -173,6 +188,7 @@ impl RecorderEngine for FakeEngine { } fn stop(&mut self) -> RecorderResult<()> { + self.stopped.store(true, Ordering::Relaxed); let session = self .active .take() @@ -183,13 +199,18 @@ impl RecorderEngine for FakeEngine { message: "stopping recording".into(), }) .unwrap(); - self.events - .send(RecorderEvent::Exited { + let event = if !self.start_gate.load(Ordering::Relaxed) { + RecorderEvent::Cancelled { + session_id: session.id, + } + } else { + RecorderEvent::Exited { session_id: session.id, success: true, status: "exit status: 0".into(), - }) - .unwrap(); + } + }; + self.events.send(event).unwrap(); Ok(()) } } diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 8513554..fbfac48 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -257,7 +257,7 @@ pub struct CaptureDimensions { pub output_height: i64, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub enum RecorderEvent { Starting { session_id: u64, @@ -277,6 +277,11 @@ pub enum RecorderEvent { session_id: u64, metrics: RecorderMetrics, }, + /// The recording finished, but its movie is missing video frames or audio. + MediaLost { + session_id: u64, + message: String, + }, Failed { session_id: Option, message: String, @@ -286,10 +291,16 @@ pub enum RecorderEvent { success: bool, status: String, }, + Cancelled { + session_id: u64, + }, } -#[derive(Debug, thiserror::Error)] +#[derive(Debug, thiserror::Error, Serialize, Deserialize)] pub enum RecorderError { + #[error("recording cancelled before capture started")] + Cancelled, + #[error("screen recording permission is not granted")] MissingScreenRecordingPermission, diff --git a/crates/linux/Cargo.toml b/crates/linux/Cargo.toml new file mode 100644 index 0000000..b703f55 --- /dev/null +++ b/crates/linux/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "linux" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[target.'cfg(target_os = "linux")'.dependencies] +domain = { path = "../domain" } +ashpd = { version = "0.9", default-features = false, features = ["tokio"] } +tokio = { version = "1", features = ["rt", "time", "sync", "macros"] } +gstreamer = { version = "0.23", features = ["v1_22"] } +gstreamer-allocators = { version = "0.23", features = ["v1_22"] } +futures-util = { version = "0.3", default-features = false, features = ["std", "async-await"] } +x11rb = "0.13" +x11-dl = "2.21" +libc = "0.2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[target.'cfg(target_os = "linux")'.dev-dependencies] +zbus = { version = "4.4", default-features = false, features = ["tokio"] } diff --git a/crates/linux/src/desktop.rs b/crates/linux/src/desktop.rs new file mode 100644 index 0000000..cfaed37 --- /dev/null +++ b/crates/linux/src/desktop.rs @@ -0,0 +1,47 @@ +use crate::{backend, portal, x11}; +use domain::{CaptureTarget, Result}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Desktop { + Wayland, + X11, +} + +pub(crate) fn detect() -> Result { + detect_values( + std::env::var_os("WAYLAND_DISPLAY").is_some_and(|s| !s.is_empty()), + std::env::var_os("DISPLAY").is_some_and(|s| !s.is_empty()), + ) +} + +fn detect_values(wayland: bool, x11: bool) -> Result { + // DISPLAY in a Wayland session is usually XWayland, which cannot capture + // the full Wayland desktop. Use the portal whenever Wayland is active. + match (wayland, x11) { + (true, _) => Ok(Desktop::Wayland), + (false, true) => Ok(Desktop::X11), + _ => Err(backend("Linux recording requires a Wayland or X11 desktop session. This process has neither WAYLAND_DISPLAY nor DISPLAY.")), + } +} + +pub fn check_desktop() -> Result<()> { + detect().map(|_| ()) +} + +pub fn list_targets() -> Result> { + match detect()? { + Desktop::Wayland => portal::list_targets(), + Desktop::X11 => x11::list_targets(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn chooses_portal_over_xwayland_and_supports_x11_only() { + assert_eq!(detect_values(true, true).unwrap(), Desktop::Wayland); + assert_eq!(detect_values(false, true).unwrap(), Desktop::X11); + assert!(detect_values(false, false).is_err()); + } +} diff --git a/crates/linux/src/encoding.rs b/crates/linux/src/encoding.rs new file mode 100644 index 0000000..a2802cb --- /dev/null +++ b/crates/linux/src/encoding.rs @@ -0,0 +1,378 @@ +use crate::{backend, pipeline::element}; +use domain::{Codec, Quality, RecorderSettings, Result}; +use gstreamer::{self as gst, prelude::*}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Kind { + Va, + Cuda, + Nvidia, + Software, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct Mode { + pub kind: Kind, + pub factory: &'static str, + pub dmabuf: bool, +} + +pub(crate) fn modes(codec: Codec, dmabuf: bool) -> Vec { + modes_with(codec, dmabuf, |name| { + gst::ElementFactory::find(name).is_some() + }) +} + +pub(crate) fn plugin_version(factory: &gst::ElementFactory) -> Option> { + factory.plugin().map(|plugin| { + plugin + .version() + .split('.') + .map_while(|part| part.parse().ok()) + .collect() + }) +} + +fn imports_dma_buf(sink: &gst::CapsRef, version: &[u32]) -> bool { + let dma_drm = gst::Structure::builder("video/x-raw") + .field("format", "DMA_DRM") + .build(); + version >= [1, 24, 6].as_slice() + && sink.iter_with_features().any(|(structure, features)| { + !features.is_any() + && features.contains("memory:DMABuf") + && structure.has_field("format") + && structure.can_intersect(&dma_drm) + }) +} + +pub(crate) fn va_imports_dma_buf() -> bool { + gst::ElementFactory::find("vapostproc").is_some_and(|factory| { + let version = plugin_version(&factory).unwrap_or_default(); + factory.static_pad_templates().iter().any(|template| { + template.direction() == gst::PadDirection::Sink + && imports_dma_buf(&template.caps(), &version) + }) + }) +} + +fn modes_with(codec: Codec, dmabuf: bool, has: impl Fn(&str) -> bool) -> Vec { + let (va, nv, software): (&[&str], &str, &[&str]) = match codec { + Codec::H264 => ( + &["vah264lpenc", "vah264enc"], + "nvh264enc", + &["x264enc", "openh264enc"], + ), + Codec::Hevc => (&["vah265lpenc", "vah265enc"], "nvh265enc", &["x265enc"]), + }; + let mut modes = Vec::new(); + for factory in va + .iter() + .copied() + .filter(|name| has(name) && has("vapostproc")) + { + if dmabuf { + modes.push(Mode { + kind: Kind::Va, + factory, + dmabuf: true, + }); + } + modes.push(Mode { + kind: Kind::Va, + factory, + dmabuf: false, + }); + } + if has(nv) { + if has("cudaupload") && has("cudaconvertscale") { + modes.push(Mode { + kind: Kind::Cuda, + factory: nv, + dmabuf: false, + }); + } + modes.push(Mode { + kind: Kind::Nvidia, + factory: nv, + dmabuf: false, + }); + } + for factory in software.iter().copied().filter(|name| has(name)) { + modes.push(Mode { + kind: Kind::Software, + factory, + dmabuf: false, + }); + } + modes +} + +impl Mode { + pub fn description(self) -> String { + let input = if self.dmabuf { + "shared GPU buffers" + } else { + "system-memory capture" + }; + let processing = match self.kind { + Kind::Va | Kind::Cuda => "GPU conversion and encoding", + Kind::Nvidia => "CPU conversion, NVIDIA hardware encoding", + Kind::Software => "CPU conversion and software encoding", + }; + format!("{input}; {processing}; {}", self.factory) + } + + pub fn caps(self, size: Option<(i32, i32)>) -> gst::Caps { + let builder = + gst::Caps::builder("video/x-raw").field("pixel-aspect-ratio", gst::Fraction::new(1, 1)); + let builder = match self.kind { + Kind::Va => builder + .features(["memory:VAMemory"]) + .field("format", "NV12"), + Kind::Cuda => builder + .features(["memory:CUDAMemory"]) + .field("format", "NV12"), + Kind::Nvidia => builder + .features(["memory:SystemMemory"]) + .field("format", "NV12"), + Kind::Software => builder + .features(["memory:SystemMemory"]) + .field("format", "I420"), + }; + match size { + Some((w, h)) => builder.field("width", w).field("height", h).build(), + None => builder.build(), + } + } + + pub fn converters(self) -> Result> { + match self.kind { + Kind::Va => { + let converter = element("vapostproc")?; + converter.set_property("add-borders", true); + Ok(vec![converter]) + } + Kind::Cuda => { + let converter = element("cudaconvertscale")?; + if converter.find_property("add-borders").is_some() { + converter.set_property("add-borders", true); + } + Ok(vec![element("cudaupload")?, converter]) + } + _ => { + let scaler = element("videoscale")?; + scaler.set_property("add-borders", true); + Ok(vec![element("videoconvert")?, scaler]) + } + } + } + + pub fn encoder(self, settings: &RecorderSettings) -> Result { + let encoder = element(self.factory)?; + let qp = match settings.quality { + Quality::Efficient => 30u32, + Quality::Balanced => 25u32, + Quality::High => 20u32, + }; + let bitrate = match settings.quality { + Quality::Efficient => 2500u32, + Quality::Balanced => 6000u32, + Quality::High => 16000u32, + }; + match self.kind { + Kind::Va => { + encoder.set_property_from_str("rate-control", "cqp"); + for name in ["qpi", "qpp", "qpb"] { + encoder.set_property(name, qp); + } + encoder.set_property("key-int-max", settings.fps.as_u32() * 2); + encoder.set_property("b-frames", 0u32); + } + Kind::Cuda | Kind::Nvidia => { + encoder.set_property("bitrate", bitrate); + encoder.set_property("gop-size", settings.fps.as_u32() as i32 * 2); + encoder.set_property("bframes", 0u32); + } + Kind::Software => match self.factory { + "x264enc" => { + encoder.set_property_from_str("speed-preset", "ultrafast"); + encoder.set_property_from_str("tune", "zerolatency"); + encoder.set_property_from_str("pass", "qual"); + encoder.set_property("quantizer", qp); + // In quality mode this caps the rate, 2 Mbit/s by default. + encoder.set_property("bitrate", bitrate); + encoder.set_property("threads", 2u32); + encoder.set_property("key-int-max", settings.fps.as_u32() * 2); + } + "x265enc" => { + encoder.set_property_from_str("speed-preset", "ultrafast"); + encoder.set_property_from_str("tune", "zerolatency"); + encoder.set_property("qp", qp as i32); + // A second frame thread keeps each frame until the next one + // arrives, a second later on an idle screen. A third worker + // maintains throughput without keeping a second frame. + encoder.set_property("option-string", "pools=3:frame-threads=1"); + encoder.set_property("key-int-max", settings.fps.as_u32() as i32 * 2); + } + "openh264enc" => { + encoder.set_property("bitrate", bitrate * 1000); + encoder.set_property("multi-thread", 2u32); + encoder.set_property("gop-size", settings.fps.as_u32() * 2); + } + _ => return Err(backend("unsupported software encoder")), + }, + } + Ok(encoder) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn shared_gpu_buffers_need_explicit_dma_drm_and_a_sizeless_importer() { + gst::init().unwrap(); + let caps = |description: &str| description.parse::().unwrap(); + let native = caps("video/x-raw(memory:VAMemory), format={ NV12, P010_10LE }; video/x-raw(memory:DMABuf), format={ BGRA, RGBA, BGRx, RGBx, NV12, P010_10LE }; video/x-raw(ANY); video/x-raw(ANY), format={ BGRx, NV12 }"); + let formatless = caps("video/x-raw(memory:DMABuf), width=[1, 16384]"); + let modern = caps("video/x-raw(memory:VAMemory), format={ NV12, P010_10LE }; video/x-raw(memory:DMABuf), format=DMA_DRM, drm-format={ NV12:0x0200000000000901, XR24:0x0200000000000901 }; video/x-raw, format={ BGRx, NV12 }"); + assert!(!imports_dma_buf(&native, &[1, 22, 0])); + assert!(!imports_dma_buf(&native, &[1, 28, 2])); + assert!(!imports_dma_buf(&formatless, &[1, 28, 2])); + assert!(!imports_dma_buf(&modern, &[1, 24, 0])); + assert!(!imports_dma_buf(&modern, &[1, 24, 5])); + assert!(imports_dma_buf(&modern, &[1, 24, 6])); + assert!(imports_dma_buf(&modern, &[1, 28, 2])); + if let Some(factory) = gst::ElementFactory::find("vapostproc") { + if plugin_version(&factory).is_some_and(|version| version >= vec![1, 24, 6]) { + assert!(va_imports_dma_buf()); + } + } + } + + #[test] + fn prefers_hardware_and_retains_software_compatibility() { + let modes = modes_with(Codec::H264, true, |_| true); + assert!(modes[0].dmabuf); + assert_eq!(modes[0].kind, Kind::Va); + assert!(modes.iter().any(|m| m.kind == Kind::Cuda)); + assert_eq!(modes.last().unwrap().kind, Kind::Software); + assert!(modes_with(Codec::Hevc, false, |_| true) + .iter() + .all(|m| !m.dmabuf)); + let modes = modes_with(Codec::H264, false, |name| name == "openh264enc"); + assert_eq!(modes.len(), 1); + assert_eq!(modes[0].kind, Kind::Software); + } + + // An idle screen sends about one frame a second. The movie muxer holds + // audio until video reaches it, so an encoder that keeps a frame until the + // next one arrives backs audio up for seconds. + #[test] + fn software_encoders_emit_each_frame_before_the_next_arrives() { + gst::init().unwrap(); + let mut tested = Vec::new(); + for codec in [Codec::H264, Codec::Hevc] { + for mode in modes(codec, false) { + if mode.kind != Kind::Software { + continue; + } + let pipeline = gst::Pipeline::new(); + let source = element("appsrc").unwrap(); + source.set_property("is-live", true); + source.set_property_from_str("format", "time"); + source.set_property( + "caps", + gst::Caps::builder("video/x-raw") + .field("format", "I420") + .field("width", 320i32) + .field("height", 180i32) + .field("framerate", gst::Fraction::new(0, 1)) + .field("max-framerate", gst::Fraction::new(60, 1)) + .build(), + ); + let encoder = mode.encoder(&RecorderSettings::default()).unwrap(); + let sink = element("fakesink").unwrap(); + sink.set_property("sync", false); + pipeline.add_many([&source, &encoder, &sink]).unwrap(); + gst::Element::link_many([&source, &encoder, &sink]).unwrap(); + let (encoded, received) = std::sync::mpsc::channel(); + encoder.static_pad("src").unwrap().add_probe( + gst::PadProbeType::BUFFER, + move |_, _| { + let _ = encoded.send(()); + gst::PadProbeReturn::Ok + }, + ); + pipeline.set_state(gst::State::Playing).unwrap(); + let mut frame = gst::Buffer::from_mut_slice(vec![0u8; 320 * 180 * 3 / 2]); + frame.get_mut().unwrap().set_pts(gst::ClockTime::ZERO); + let pushed = source.emit_by_name::("push-buffer", &[&frame]); + assert_eq!(pushed, gst::FlowReturn::Ok); + let emitted = received.recv_timeout(std::time::Duration::from_secs(1)); + pipeline.set_state(gst::State::Null).unwrap(); + assert!(emitted.is_ok(), "{} kept a lone frame", mode.factory); + tested.push(mode.factory); + } + } + assert!(tested.contains(&"x265enc"), "tested only {tested:?}"); + } + + // Megabits per second for three seconds of 720p30 noise, which wants + // more bits than any quality allows. + fn noise_bitrate(mode: Mode, quality: Quality) -> f64 { + let pipeline = gst::parse::launch( + "videotestsrc name=source pattern=snow num-buffers=90 ! video/x-raw,format=I420,width=1280,height=720,framerate=30/1 ! identity name=input", + ) + .unwrap() + .downcast::() + .unwrap(); + let settings = RecorderSettings { + quality, + ..RecorderSettings::default() + }; + let encoder = mode.encoder(&settings).unwrap(); + let sink = element("fakesink").unwrap(); + pipeline.add_many([&encoder, &sink]).unwrap(); + gst::Element::link_many([&pipeline.by_name("input").unwrap(), &encoder, &sink]).unwrap(); + let bytes = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let counted = bytes.clone(); + encoder + .static_pad("src") + .unwrap() + .add_probe(gst::PadProbeType::BUFFER, move |_, info| { + let size = info.buffer().map_or(0, |buffer| buffer.size()); + counted.fetch_add(size as u64, std::sync::atomic::Ordering::Relaxed); + gst::PadProbeReturn::Ok + }); + pipeline.set_state(gst::State::Playing).unwrap(); + let ended = pipeline.bus().unwrap().timed_pop_filtered( + gst::ClockTime::from_seconds(60), + &[gst::MessageType::Eos, gst::MessageType::Error], + ); + pipeline.set_state(gst::State::Null).unwrap(); + assert!(matches!( + ended.map(|message| message.type_()), + Some(gst::MessageType::Eos) + )); + bytes.load(std::sync::atomic::Ordering::Relaxed) as f64 * 8.0 / 3.0 / 1e6 + } + + // x264 in quality mode caps its rate at its bitrate property, 2 Mbit/s by + // default, so every quality must set it. + #[test] + fn software_h264_spends_the_bitrate_its_quality_allows() { + gst::init().unwrap(); + let Some(mode) = modes(Codec::H264, false) + .into_iter() + .find(|mode| mode.factory == "x264enc") + else { + return; + }; + let high = noise_bitrate(mode, Quality::High); + assert!((12.0..20.0).contains(&high), "high quality: {high} Mbit/s"); + let efficient = noise_bitrate(mode, Quality::Efficient); + assert!(efficient < 3.2, "efficient quality: {efficient} Mbit/s"); + } +} diff --git a/crates/linux/src/lib.rs b/crates/linux/src/lib.rs new file mode 100644 index 0000000..1321cb6 --- /dev/null +++ b/crates/linux/src/lib.rs @@ -0,0 +1,250 @@ +#![cfg(target_os = "linux")] + +mod desktop; +mod encoding; +mod pipeline; +mod portal; +mod worker; +mod x11; + +use domain::{ + CaptureTarget, RecorderEngine, RecorderError, RecorderEvent, RecorderSettings, + RecordingSession, Result, +}; +use std::{ + path::Path, + sync::{ + atomic::{AtomicU64, Ordering}, + mpsc, Arc, + }, + time::{SystemTime, UNIX_EPOCH}, +}; +use tokio::sync::watch; + +pub use desktop::{check_desktop, list_targets}; +pub use worker::{run as run_capture_worker, ARGUMENT as CAPTURE_WORKER_ARGUMENT}; + +pub(crate) fn backend(error: impl std::fmt::Display) -> RecorderError { + RecorderError::Backend(error.to_string()) +} + +pub(crate) enum Command { + Pause(mpsc::SyncSender>), + Resume(mpsc::SyncSender>), +} + +/// Rust owns lifecycle and buffer handles. GStreamer owns the native pixel path. +pub struct LinuxRecorder { + events: mpsc::Sender, + active: Option, + stop_requested: bool, +} + +impl LinuxRecorder { + pub fn new(events: mpsc::Sender) -> Self { + Self { + events, + active: None, + stop_requested: false, + } + } + + fn control(&mut self, request: worker::Request) -> Result<()> { + self.active + .as_mut() + .ok_or_else(|| backend("no active recording"))? + .control(request) + } +} + +impl RecorderEngine for LinuxRecorder { + fn list_targets(&self) -> Result> { + list_targets() + } + + fn start( + &mut self, + target: CaptureTarget, + settings: RecorderSettings, + ) -> Result { + if self.active.is_none() && self.stop_requested { + return Err(RecorderError::Cancelled); + } + if self + .active + .as_ref() + .is_some_and(|active| !active.finished()) + { + return Err(backend("recording is already active")); + } + self.active = None; + self.stop_requested = false; + check_desktop()?; + if desktop::detect()? == desktop::Desktop::Wayland { + portal::validate_target(&target)?; + } + std::fs::create_dir_all(&settings.output_dir).map_err(backend)?; + static LAST_ID: AtomicU64 = AtomicU64::new(0); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(backend)? + .as_micros() as u64; + let previous = LAST_ID + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |last| { + Some(now.max(last.saturating_add(1))) + }) + .unwrap(); + let id = now.max(previous.saturating_add(1)); + let session = RecordingSession { + id, + output_path: settings.output_dir.join(format!("wrec-{id}.mp4")), + }; + self.active = Some(worker::Worker::spawn( + Path::new(worker::PROGRAM), + &[worker::ARGUMENT], + session.clone(), + target, + settings, + self.events.clone(), + worker::EXIT_DEADLINE, + )?); + Ok(session) + } + + fn pause(&mut self) -> Result<()> { + self.control(worker::Request::Pause) + } + fn resume(&mut self) -> Result<()> { + self.control(worker::Request::Resume) + } + fn stop(&mut self) -> Result<()> { + self.stop_requested = true; + if let Some(active) = &mut self.active { + active.stop(); + } + Ok(()) + } +} + +pub(crate) fn capture( + target: CaptureTarget, + settings: RecorderSettings, + session: RecordingSession, + events: mpsc::Sender, + receiver: mpsc::Receiver, + worker_stop: watch::Sender, + stopping: Arc, +) { + let mut stopped = worker_stop.subscribe(); + let id = session.id; + let completion_events = events.clone(); + let worker_session = session; + let _ = events.send(RecorderEvent::Starting { + session_id: id, + target: target.clone(), + settings: settings.clone(), + output_path: worker_session.output_path.clone(), + }); + let result = (|| { + if desktop::detect()? == desktop::Desktop::X11 { + let (display, xid) = x11::source(&target)?; + return pipeline::record( + &pipeline::CaptureInput::X11 { display, xid }, + &worker_session, + &settings, + &events, + receiver, + &stopped, + &*stopping, + ); + } + let runtime = portal::runtime()?; + runtime.block_on(async { + let capture = + Arc::new(portal::open(&target, settings.include_cursor, &mut stopped).await?); + let result = async { + let mut endings = capture.endings().await?; + let node_watch = portal::NodeWatch::start(capture.connect().await?).await; + if let portal::NodeWatch::Unavailable(reason) = &node_watch { + let _ = completion_events.send(RecorderEvent::Log { + session_id: Some(id), + message: format!("capture-engine: removal of the screen-cast stream will not be detected: {reason}"), + }); + } + let ending_events = completion_events.clone(); + let ending_stopping = stopping.clone(); + let worker_capture = capture.clone(); + let runtime = tokio::runtime::Handle::current(); + let mut recording = tokio::task::spawn_blocking(move || { + pipeline::record( + &pipeline::CaptureInput::PipeWire(Box::new(move || { + // Each retry needs a new protocol connection. Duplicating + // an already-used socket does not reset its remote state. + runtime.block_on(worker_capture.connect()) + })), + &worker_session, + &settings, + &events, + receiver, + &stopped, + &*stopping, + ) + }); + let result = tokio::select! { + result = &mut recording => result.map_err(backend)?, + ending = endings.next() => { + if matches!(ending, portal::Ending::Closed) { + let _ = ending_events.send(RecorderEvent::Log { + session_id: Some(id), + message: "capture-engine: the desktop closed the screen-cast session; finalizing the recording".into(), + }); + ending_stopping(pipeline::Teardown::Recording); + let _ = worker_stop.send(true); + } + recording.await.map_err(backend)? + } + }; + drop(node_watch); + result + } + .await; + // Release the remote only after all native elements have stopped using its fd. + capture.close().await; + result + }) + })(); + let (success, status) = match result { + Ok(()) => (true, "recording finalized".to_string()), + Err(RecorderError::Cancelled) => { + let _ = completion_events.send(RecorderEvent::Cancelled { session_id: id }); + return; + } + Err(error) => (false, error.to_string()), + }; + let _ = completion_events.send(RecorderEvent::Exited { + session_id: id, + success, + status, + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stop_before_start_preserves_cancellation() { + let (events, _) = mpsc::channel(); + let mut recorder = LinuxRecorder::new(events); + recorder.stop().unwrap(); + let target = CaptureTarget { + id: 0, + kind: domain::CaptureSourceKind::Display, + name: "picker".into(), + }; + assert!(matches!( + recorder.start(target, RecorderSettings::default()), + Err(RecorderError::Cancelled) + )); + } +} diff --git a/crates/linux/src/pipeline.rs b/crates/linux/src/pipeline.rs new file mode 100644 index 0000000..cd4a183 --- /dev/null +++ b/crates/linux/src/pipeline.rs @@ -0,0 +1,4894 @@ +use crate::{ + backend, + encoding::{self, Mode}, + portal::PipeWireStream, + Command, +}; +use domain::{ + CaptureDimensions, Codec, RecorderError, RecorderEvent, RecorderMetrics, RecorderSettings, + RecordingSession, Resolution, Result, +}; +use gstreamer::{self as gst, prelude::*}; +use std::{ + os::fd::AsRawFd, + path::Path, + sync::{ + atomic::{AtomicU64, AtomicU8, Ordering}, + mpsc, Arc, Mutex, + }, + time::{Duration, Instant}, +}; +use tokio::sync::watch; + +pub(crate) fn element(factory: &str) -> Result { + gst::ElementFactory::make(factory).build().map_err(|error| backend(format!("GStreamer element {factory} is unavailable: {error}. Install the Linux runtime dependencies in packaging/linux/README.md."))) +} + +pub(crate) fn check_plugins(settings: &RecorderSettings) -> Result<()> { + gst::init().map_err(backend)?; + for factory in [ + "capsfilter", + "queue", + parser_name(settings.codec), + "mp4mux", + "filesink", + ] { + element(factory)?; + } + if settings.include_system_audio || settings.include_microphone { + for factory in [ + "pulsesrc", + "audioconvert", + "audioresample", + "avenc_aac", + "aacparse", + ] { + element(factory)?; + } + } + Ok(()) +} + +fn parser_name(codec: Codec) -> &'static str { + match codec { + Codec::H264 => "h264parse", + Codec::Hevc => "h265parse", + } +} + +/// Fit inside the requested bounds without upscaling or odd chroma dimensions. +fn output_size(width: i32, height: i32, resolution: Resolution) -> (i32, i32) { + let (max_width, max_height) = match resolution { + Resolution::Native => (width, height), + Resolution::R720p => (1280, 720), + Resolution::R1080p => (1920, 1080), + Resolution::R2k => (2560, 1440), + Resolution::R4k => (3840, 2160), + }; + let scale = (max_width as f64 / width as f64) + .min(max_height as f64 / height as f64) + .min(1.0); + let even = |size: i32| (((size as f64 * scale) as i32) & !1).max(2); + (even(width), even(height)) +} + +fn capture_caps(dmabuf: bool, pipewire: bool, fps: u32) -> gst::Caps { + let caps = gst::Caps::builder("video/x-raw"); + // Wayland compositors advertise variable-rate frames (framerate=0/1). + // Bound their maximum rate without requiring a fixed-rate source. + let caps = if pipewire { + caps.field( + "max-framerate", + gst::List::new( + (1..=fps as i32) + .rev() + .map(|rate| gst::Fraction::new(rate, 1)), + ), + ) + } else { + caps.field("framerate", gst::Fraction::new(fps as i32, 1)) + }; + if dmabuf { + caps.features(["memory:DMABuf"]) + .field("format", "DMA_DRM") + .build() + } else { + caps.build() + } +} + +// qtmux guesses a 4:3 or 16:9 TV picture for widths 641 through 1052 and +// heights 480 through 576, and writes a clean aperture that makes players crop +// a window of, say, 800x528 to 704x528. mp4mux is the same muxer writing the +// ISO flavor, which declares the full picture. +fn movie_mux() -> Result { + let mux = element("mp4mux")?; + mux.set_property("fragment-duration", 10000u32); + mux.set_property_from_str("fragment-mode", "first-moov-then-finalise"); + // Preserve sub-frame timestamps around pause/resume instead of rounding + // them to the default frame-rate-derived track timescale. + mux.set_property("trak-timescale", 1_000_000u32); + Ok(mux) +} + +const WRITE_QUEUE: &str = "write-queue"; + +// mp4mux writes each sample as it muxes it, so while a write blocks every +// track waits behind it: video drops at capture within two frames, and audio +// once its queue fills. This buffer stays empty unless the disk stalls; at +// the highest bitrate-mode quality (16 Mbit/s) it covers about 16 seconds. +const WRITE_BUFFER_BYTES: u32 = 32 << 20; + +fn movie_file(pipeline: &gst::Pipeline, mux: &gst::Element, location: &str) -> Result<()> { + let queue = element("queue")?; + queue.set_property("name", WRITE_QUEUE); + queue.set_property("max-size-bytes", WRITE_BUFFER_BYTES); + queue.set_property("max-size-buffers", 0u32); + queue.set_property("max-size-time", 0u64); + let sink = element("filesink")?; + sink.set_property("location", location); + sink.set_property("sync", false); + pipeline.add_many([&queue, &sink]).map_err(backend)?; + gst::Element::link_many([mux, &queue, &sink]).map_err(backend) +} + +const CAPTURE_QUEUE: &str = "capture-queue"; + +// pipewiresrc resends the last frame after this long without a new one, so an +// idle screen still sends a frame each keepalive. +const KEEPALIVE: gst::ClockTime = gst::ClockTime::SECOND; + +fn video_queue() -> Result { + let queue = element("queue")?; + queue.set_property("name", CAPTURE_QUEUE); + queue.set_property("max-size-buffers", 2u32); + queue.set_property("max-size-bytes", 0u32); + queue.set_property("max-size-time", 0u64); + queue.set_property_from_str("leaky", "downstream"); + Ok(queue) +} + +struct PipelineGuard(gst::Pipeline); +impl Drop for PipelineGuard { + fn drop(&mut self) { + let _ = self.0.set_state(gst::State::Null); + } +} + +#[derive(Default)] +struct Counters { + frames: AtomicU64, + dropped: AtomicU64, + queued: AtomicU64, + dequeued: AtomicU64, + dimensions: Mutex>, + last_pts: Mutex>, + timeline: Mutex, +} + +#[derive(Default)] +struct Timeline { + paused_at: Option, + offset: gst::ClockTime, + accept_from: gst::ClockTime, + // Finished pauses, in running time, oldest first. + pauses: Vec<(gst::ClockTime, gst::ClockTime)>, +} + +impl Timeline { + fn pause(&mut self, now: gst::ClockTime) { + self.paused_at.get_or_insert(now); + } + + fn resume(&mut self, now: gst::ClockTime) { + if let Some(paused_at) = self.paused_at.take() { + self.offset += now.saturating_sub(paused_at); + self.accept_from = now; + self.pauses.push((paused_at, now)); + } + } + + // How much of the running time from `start` to `end` was paused. + fn paused_between(&self, start: gst::ClockTime, end: gst::ClockTime) -> gst::ClockTime { + self.paused_at + .map(|at| (at, gst::ClockTime::MAX)) + .into_iter() + .chain(self.pauses.iter().rev().copied()) + .take_while(|&(_, resumed)| resumed > start) + .map(|(paused, resumed)| end.min(resumed).saturating_sub(start.max(paused))) + .sum() + } + + fn position(&self, now: gst::ClockTime) -> gst::ClockTime { + self.paused_at.unwrap_or(now).saturating_sub(self.offset) + } + + fn retime(&self, buffer: &mut gst::Buffer) -> bool { + if self.paused_at.is_some() || buffer.pts().is_some_and(|pts| pts < self.accept_from) { + return false; + } + if self.offset > gst::ClockTime::ZERO { + // make_mut copies a shared buffer header, retaining references to + // its memory. DMA-BUF pixels remain on the GPU. + let buffer = buffer.make_mut(); + buffer.set_pts(buffer.pts().map(|pts| pts.saturating_sub(self.offset))); + buffer.set_dts(buffer.dts().map(|dts| dts.saturating_sub(self.offset))); + } + true + } +} + +pub(crate) enum CaptureInput { + PipeWire(Box Result + Send>), + X11 { + display: String, + xid: u64, + }, + #[cfg(test)] + Element(Box gst::Element + Send>), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Teardown { + Recording, + // The movie is complete on disk; native cleanup follows. + Finalized, + AttemptStarted, + AttemptFinished, +} + +struct Attempt<'a> { + mode: Mode, + counters: Arc, + last: bool, + stopping: &'a dyn Fn(Teardown), +} + +fn finished(result: &Result<()>, counters: &Counters) -> bool { + matches!(result, Ok(()) | Err(RecorderError::Cancelled)) + || counters.frames.load(Ordering::Relaxed) > 0 +} + +fn ends_recording(result: &Result<()>, counters: &Counters, last: bool) -> bool { + last || finished(result, counters) +} + +pub(crate) fn record( + capture: &CaptureInput, + session: &RecordingSession, + settings: &RecorderSettings, + events: &mpsc::Sender, + commands: mpsc::Receiver, + stop: &watch::Receiver, + stopping: &dyn Fn(Teardown), +) -> Result<()> { + if matches!(capture, CaptureInput::X11 { .. }) { + crate::x11::initialize()?; + } + check_plugins(settings)?; + let dmabuf = matches!(capture, CaptureInput::PipeWire(_)) && encoding::va_imports_dma_buf(); + let modes = encoding::modes(settings.codec, dmabuf); + if modes.is_empty() { + return Err(backend(format!("No {} encoder is installed. Install GStreamer VA/NVENC plugins or x264/x265/openh264 for software compatibility; see packaging/linux/README.md.", settings.codec.as_arg()))); + } + let mut last_error = None; + let count = modes.len(); + for (index, mode) in modes.into_iter().enumerate() { + if *stop.borrow() { + return Err(RecorderError::Cancelled); + } + let attempt = Attempt { + mode, + counters: Arc::new(Counters::default()), + last: index + 1 == count, + stopping, + }; + let _ = events.send(RecorderEvent::Log { + session_id: Some(session.id), + message: format!("capture-engine: trying {}", mode.description()), + }); + let result = record_attempt( + capture, session, settings, events, &commands, stop, &attempt, + ); + release_freed_memory(); + if !ends_recording(&result, &attempt.counters, attempt.last) { + stopping(Teardown::AttemptFinished); + } + if finished(&result, &attempt.counters) { + return result; + } + let error = result.unwrap_err(); + let _ = events.send(RecorderEvent::Log { + session_id: Some(session.id), + message: format!("capture-engine: {} could not start: {error}", mode.factory), + }); + last_error = Some(error); + } + Err(last_error.unwrap_or_else(|| backend("No recording pipeline could start"))) +} + +#[cfg(target_env = "gnu")] +fn release_freed_memory() { + extern "C" { + fn malloc_trim(pad: usize) -> std::os::raw::c_int; + } + unsafe { + malloc_trim(0); + } +} + +#[cfg(not(target_env = "gnu"))] +fn release_freed_memory() {} + +fn record_attempt( + capture: &CaptureInput, + session: &RecordingSession, + settings: &RecorderSettings, + events: &mpsc::Sender, + commands: &mpsc::Receiver, + stop: &watch::Receiver, + attempt: &Attempt<'_>, +) -> Result<()> { + if *stop.borrow() { + return Err(RecorderError::Cancelled); + } + let settings = settings.clone().with_preset_limits(); + let stream = match capture { + CaptureInput::PipeWire(connect) => Some(connect()?), + _ => None, + }; + let source_watch = match capture { + CaptureInput::X11 { xid, .. } if *xid != 0 => Some(crate::x11::SourceWatch::new(*xid)?), + _ => None, + }; + let pipeline = PipelineGuard(gst::Pipeline::new()); + pipeline.0.use_clock(Some(&gst::SystemClock::obtain())); + let mode = attempt.mode; + let source = match capture { + CaptureInput::PipeWire(_) => { + let capture = stream.as_ref().unwrap(); + let source = element("pipewiresrc")?; + source.set_property("fd", capture.fd.as_raw_fd()); + source.set_property("path", capture.node.to_string()); + source.set_property("always-copy", false); + source.set_property("min-buffers", 4i32); + source.set_property("max-buffers", 8i32); + source.set_property("keepalive-time", KEEPALIVE.mseconds() as i32); + source.set_property("resend-last", true); + source + } + CaptureInput::X11 { display, xid } => { + let source = element("ximagesrc")?; + source.set_property("display-name", display); + source.set_property("xid", *xid); + source.set_property("show-pointer", settings.include_cursor); + source.set_property("use-damage", true); + source + } + #[cfg(test)] + CaptureInput::Element(source) => source(), + }; + source.set_property("do-timestamp", true); + let input = element("capsfilter")?; + input.set_property( + "caps", + capture_caps( + mode.dmabuf, + !matches!(capture, CaptureInput::X11 { .. }), + settings.fps.as_u32(), + ), + ); + let queue = video_queue()?; + let converters = mode.converters()?; + let output = element("capsfilter")?; + output.set_property("caps", mode.caps(None)); + let encoder = mode.encoder(&settings)?; + let parser = element(parser_name(settings.codec))?; + parser.set_property("config-interval", -1i32); + let format = movie_format(settings.codec, settings.fps.as_u32())?; + let mux = movie_mux()?; + let mut chain = vec![&source, &input, &queue]; + chain.extend(converters.iter()); + chain.extend([&output, &encoder, &parser, &format]); + pipeline + .0 + .add_many(chain.iter().copied()) + .map_err(backend)?; + pipeline.0.add(&mux).map_err(backend)?; + gst::Element::link_many(chain.iter().copied()).map_err(backend)?; + movie_file( + &pipeline.0, + &mux, + session + .output_path + .to_str() + .ok_or_else(|| backend("recording output path must be UTF-8"))?, + )?; + let counters = attempt.counters.clone(); + let mut movie = Movie::new( + &pipeline.0, + &format, + &mux, + settings.include_system_audio || settings.include_microphone, + )?; + if settings.include_system_audio { + add_audio( + &mut movie, + Some("@DEFAULT_MONITOR@"), + "system audio", + &counters, + )?; + } + if settings.include_microphone { + add_audio(&mut movie, None, "microphone", &counters)?; + } + attach_capture_probe( + &source, + &output, + settings.resolution, + mode, + counters.clone(), + ); + keep_source_configuration(&queue); + if mode.kind == encoding::Kind::Va { + match_keyframes_to_capture(&encoder, settings.fps.as_u32()); + } + if rewrites_timestamps(mode, &encoder) { + keep_capture_timestamps(&encoder, &parser); + } + attach_encoded_probe(&parser, counters.clone()); + count_dropped(&queue, counters.clone()); + let _ = events.send(RecorderEvent::Log { + session_id: Some(session.id), + message: format!( + "capture-engine: selected {}; capture queue limited to 2 frames; {} MiB write buffer", + mode.description(), + WRITE_BUFFER_BYTES >> 20 + ), + }); + // Reserve a unique file so a collision never truncates an existing recording. + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&session.output_path) + .map_err(backend)?; + let result = run( + &mut movie, + session, + events, + commands, + stop, + &counters, + &|| match (&stream, &source_watch) { + (Some(stream), _) => stream.lost(), + (None, Some(watch)) => watch.lost().then_some(SOURCE_LOST), + (None, None) => None, + }, + ); + let ending = ends_recording(&result, &counters, attempt.last); + (attempt.stopping)(if result.is_ok() { + Teardown::Finalized + } else if ending { + Teardown::Recording + } else { + Teardown::AttemptStarted + }); + let _ = pipeline.0.set_state(gst::State::Null); + if matches!(result, Err(RecorderError::Cancelled)) + || counters.frames.load(Ordering::Relaxed) == 0 + { + let _ = std::fs::remove_file(&session.output_path); + } + result +} + +// The queue emits "overrun" before it rechecks its level, so the encoder can +// take a frame meanwhile and nothing is discarded. Count the frames entering +// and leaving the queue instead; buffers are not modified, since PipeWire +// reuses a frame's memory once its source buffer is released. +fn count_dropped(queue: &gst::Element, counters: Arc) { + let entering = counters.clone(); + queue + .static_pad("sink") + .unwrap() + .add_probe(gst::PadProbeType::BUFFER, move |_, _| { + entering.queued.fetch_add(1, Ordering::Relaxed); + gst::PadProbeReturn::Ok + }); + queue + .static_pad("src") + .unwrap() + .add_probe(gst::PadProbeType::BUFFER, move |_, _| { + counters.dequeued.fetch_add(1, Ordering::Relaxed); + gst::PadProbeReturn::Ok + }); +} + +// Frames rejected at capture plus frames the queue discarded: those that +// entered it, never left, and are not waiting in it. Exact once the queue is +// empty; while frames flow, a frame in transit can shift it by one. +fn dropped_frames(pipeline: &gst::Pipeline, counters: &Counters) -> u64 { + let waiting = pipeline.by_name(CAPTURE_QUEUE).map_or(0, |queue| { + queue.property::("current-level-buffers") as u64 + }); + let dequeued = counters.dequeued.load(Ordering::Relaxed); + let queued = counters.queued.load(Ordering::Relaxed); + counters.dropped.load(Ordering::Relaxed) + queued.saturating_sub(dequeued + waiting) +} + +fn attach_encoded_probe(parser: &gst::Element, counters: Arc) { + parser + .static_pad("src") + .unwrap() + .add_probe(gst::PadProbeType::BUFFER, move |_, _| { + counters.frames.fetch_add(1, Ordering::Relaxed); + gst::PadProbeReturn::Ok + }); +} + +const CAPTURE_TIME: &str = "timestamp/x-wrec-capture"; + +fn rewrites_timestamps(mode: Mode, encoder: &gst::Element) -> bool { + mode.kind == encoding::Kind::Va + && encoder + .factory() + .and_then(|factory| encoding::plugin_version(&factory)) + .is_some_and(|version| version < vec![1, 24, 3]) +} + +fn segment(pad: &gst::Pad) -> Option> { + pad.sticky_event::(0) + .and_then(|event| event.segment().clone().downcast::().ok()) +} + +const LOST_CAPTURE_TIME: &str = "A frame lost its capture timestamp in a VA encoder that replaces timestamps (GStreamer VA before 1.24.3). The recording stopped instead of writing video with incorrect timing."; + +fn lose_capture_time(pad: &gst::Pad, info: &mut gst::PadProbeInfo<'_>) -> gst::PadProbeReturn { + if let Some(element) = pad.parent_element() { + gst::element_error!(element, gst::StreamError::Format, ("{}", LOST_CAPTURE_TIME)); + } + info.flow_res = Err(gst::FlowError::Error); + gst::PadProbeReturn::Handled +} + +fn keep_capture_timestamps(encoder: &gst::Element, parser: &gst::Element) { + let reference = gst::Caps::new_empty_simple(CAPTURE_TIME); + encoder + .static_pad("sink") + .unwrap() + .add_probe(gst::PadProbeType::BUFFER, move |pad, info| { + let running_time = info + .buffer() + .and_then(|buffer| buffer.pts()) + .zip(segment(pad)) + .and_then(|(pts, segment)| segment.to_running_time(pts)); + if let (Some(running_time), Some(gst::PadProbeData::Buffer(buffer))) = + (running_time, info.data.as_mut()) + { + gst::ReferenceTimestampMeta::add( + buffer.make_mut(), + &reference, + running_time, + gst::ClockTime::NONE, + ); + return gst::PadProbeReturn::Ok; + } + lose_capture_time(pad, info) + }); + parser + .static_pad("sink") + .unwrap() + .add_probe(gst::PadProbeType::BUFFER, move |pad, info| { + let pts = info + .buffer() + .and_then(|buffer| { + buffer + .iter_meta::() + .find(|meta| { + meta.reference() + .structure(0) + .is_some_and(|structure| structure.name() == CAPTURE_TIME) + }) + .map(|meta| meta.timestamp()) + }) + .zip(segment(pad)) + .and_then(|(running_time, segment)| { + segment.position_from_running_time(running_time) + }); + if let (Some(pts), Some(gst::PadProbeData::Buffer(buffer))) = (pts, info.data.as_mut()) + { + let buffer = buffer.make_mut(); + buffer.set_pts(pts); + buffer.set_dts(pts); + return gst::PadProbeReturn::Ok; + } + lose_capture_time(pad, info) + }); +} + +// Encoded video held while the movie waits for audio to start. Past either +// limit, the movie starts without the audio tracks that have not delivered a +// buffer. Paused time does not count, since no video arrives while paused. +// Constant-QP output has no fixed bitrate, so bytes are bounded separately. +const HELD_VIDEO_LIMIT: gst::ClockTime = gst::ClockTime::from_seconds(3); +const HELD_VIDEO_BYTES: u32 = 32 << 20; +const HELD_QUEUE: &str = "held-video-queue"; + +const WAITING: u8 = 0; +const STARTED: u8 = 1; +const ENDED: u8 = 2; +const OMITTED: u8 = 3; + +struct AudioTrack { + name: &'static str, + source: gst::Element, + pad: gst::Pad, + probe: Option, + state: Arc, + // Nanoseconds of audio missing between captured buffers. + lost: Arc, + // Where in the movie the track failed, and why. + failed: Option<(gst::ClockTime, String)>, +} + +// The recording pipeline and the tracks of its movie. mp4mux writes a track +// for every pad requested from it, and a track that never received audio has +// no sample description, which makes the whole movie unreadable. AAC encoders +// only learn their format from their first input buffer, so audio pads are +// requested once each track has delivered a buffer or ended without one. +// Until then video waits in a queue instead of being dropped at capture. +// mp4mux takes nothing while any track is empty, so afterwards the same queue +// lets video wait for a stalled audio track as long as audio can wait for +// video, instead of dropping frames at capture. +struct Movie { + pipeline: gst::Pipeline, + mux: gst::Element, + video: gst::Pad, + held: Option<(gst::Element, gst::PadProbeId)>, + audio: Vec, +} + +impl Movie { + fn new( + pipeline: &gst::Pipeline, + video: &gst::Element, + mux: &gst::Element, + audio: bool, + ) -> Result { + let track = mux + .request_pad_simple("video_%u") + .ok_or_else(|| backend("the movie muxer refused a video track"))?; + let mut movie = Self { + pipeline: pipeline.clone(), + mux: mux.clone(), + video: track.clone(), + held: None, + audio: Vec::new(), + }; + if !audio { + video + .static_pad("src") + .unwrap() + .link(&track) + .map_err(backend)?; + return Ok(movie); + } + // Headroom past the limits covers the run loop's 100 ms polling, so + // held video is not pushed back to the capture queue before then. + let queue = element("queue")?; + queue.set_property("name", HELD_QUEUE); + queue.set_property( + "max-size-time", + (HELD_VIDEO_LIMIT + gst::ClockTime::SECOND).nseconds(), + ); + queue.set_property("max-size-buffers", 0u32); + queue.set_property("max-size-bytes", HELD_VIDEO_BYTES + (8 << 20)); + pipeline.add(&queue).map_err(backend)?; + video.link(&queue).map_err(backend)?; + queue + .static_pad("src") + .unwrap() + .link(&track) + .map_err(backend)?; + let probe = queue + .static_pad("src") + .unwrap() + .add_probe( + gst::PadProbeType::BLOCK | gst::PadProbeType::BUFFER, + |_, _| gst::PadProbeReturn::Ok, + ) + .unwrap(); + movie.held = Some((queue, probe)); + Ok(movie) + } + + fn add_audio( + &mut self, + name: &'static str, + queue: &gst::Element, + source: &gst::Element, + lost: Arc, + ) { + let pad = queue.static_pad("src").unwrap(); + // Linking the track sends a reconfigure upstream. The encoder answers + // with an allocation query, which waits behind this queue while mp4mux + // holds audio for the next video frame, and pulsesrc loses audio + // meanwhile. The audio caps are fixed, so nothing needs it. + pad.add_probe(gst::PadProbeType::EVENT_UPSTREAM, |_, info| { + if info + .event() + .is_some_and(|event| event.type_() == gst::EventType::Reconfigure) + { + gst::PadProbeReturn::Handled + } else { + gst::PadProbeReturn::Ok + } + }); + let state = Arc::new(AtomicU8::new(WAITING)); + let track = state.clone(); + let probe = pad.add_probe( + gst::PadProbeType::BLOCK + | gst::PadProbeType::BUFFER + | gst::PadProbeType::EVENT_DOWNSTREAM, + move |_, info| { + let next = match &info.data { + Some(gst::PadProbeData::Buffer(_)) => STARTED, + Some(gst::PadProbeData::Event(event)) + if event.type_() == gst::EventType::Eos => + { + ENDED + } + _ => return gst::PadProbeReturn::Pass, + }; + let _ = track.compare_exchange(WAITING, next, Ordering::SeqCst, Ordering::SeqCst); + if next == ENDED { + return gst::PadProbeReturn::Pass; + } + if track.load(Ordering::SeqCst) == OMITTED { + gst::PadProbeReturn::Drop + } else { + gst::PadProbeReturn::Ok + } + }, + ); + self.audio.push(AudioTrack { + name, + source: source.clone(), + pad, + probe, + state, + lost, + failed: None, + }); + } + + // Called on every pass of the run loop. Requests the audio pads once every + // track has started or ended, or the held video reached its limit, then + // releases the video. Returns the omitted tracks, each with whether it + // ended before delivering audio. + fn link_audio(&mut self) -> Result> { + let Some((queue, _)) = &self.held else { + return Ok(Vec::new()); + }; + let full = queue.property::("current-level-time") >= HELD_VIDEO_LIMIT.nseconds() + || queue.property::("current-level-bytes") >= HELD_VIDEO_BYTES; + if !full + && self + .audio + .iter() + .any(|track| track.state.load(Ordering::SeqCst) == WAITING) + { + return Ok(Vec::new()); + } + let mut omitted = Vec::new(); + for track in &mut self.audio { + let state = track.state.load(Ordering::SeqCst); + if state != STARTED + && track + .state + .compare_exchange(state, OMITTED, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + omitted.push((track.name, state == ENDED)); + continue; + } + let pad = self + .mux + .request_pad_simple("audio_%u") + .ok_or_else(|| backend("the movie muxer refused an audio track"))?; + track.pad.link(&pad).map_err(backend)?; + if let Some(probe) = track.probe.take() { + track.pad.remove_probe(probe); + } + } + let (queue, probe) = self.held.take().unwrap(); + queue.static_pad("src").unwrap().remove_probe(probe); + Ok(omitted) + } + + // The audio track whose source posted a message, if any. + fn audio_source(&mut self, message: &gst::Message) -> Option<&mut AudioTrack> { + let src = message.src()?; + self.audio.iter_mut().find(|track| { + src == track.source.upcast_ref::() || src.has_as_ancestor(&track.source) + }) + } +} + +fn add_audio( + movie: &mut Movie, + device: Option<&str>, + name: &'static str, + counters: &Arc, +) -> Result<()> { + add_audio_source(movie, &pulse_source(device)?, name, counters) +} + +fn pulse_source(device: Option<&str>) -> Result { + let source = element("pulsesrc")?; + source.set_property("provide-clock", false); + source.set_property("buffer-time", AUDIO_BUFFER.useconds() as i64); + source.set_property_from_str("slave-method", "none"); + if let Some(device) = device { + source.set_property("device", device); + } + stamp_by_arrival(&source); + Ok(source) +} + +// pulsesrc keeps this much audio for a reader that stops, and asks its +// server to hold as much: 384 kB per track at F32 stereo. +const AUDIO_BUFFER: gst::ClockTime = gst::ClockTime::SECOND; + +// pulsesrc's default buffer-time, so how late audio could get before it +// restamped. Smaller changes, like a stream's latency settling after it +// starts, are not lost audio. +const AUDIO_LATE: gst::ClockTime = gst::ClockTime::from_mseconds(200); + +// audiobasesink's default alignment-threshold: timestamps within this of +// each other are arrival jitter. +const AUDIO_JITTER: gst::ClockTime = gst::ClockTime::from_mseconds(40); + +// Slaved to the pipeline clock, pulsesrc restamps at the clock whenever the +// clock gets a whole buffer ahead of the audio read so far. After the engine +// stops for a moment that happens before it has read the audio its server +// held meanwhile, so that audio and everything after it plays late, and the +// restamp reads as lost audio. Unslaved, pulsesrc stamps by sample count and +// the held audio keeps its place; this places the count in running time. The +// first buffer ends when it arrives. Reading held audio makes it less late +// with every buffer, and losing more makes it later. Audio that instead +// stays more than AUDIO_LATE late, within AUDIO_JITTER, for AUDIO_BUFFER of +// audio lost samples on the way, so later audio moves later by the least it +// was late. count_lost_audio sees the gap. +fn stamp_by_arrival(source: &gst::Element) { + // How far timestamps sit from the sample count, and, while audio is + // late, where that began, how late it was then, and the least since. + let state = Mutex::new((None::, None::<(gst::ClockTime, i64, i64)>)); + let element = source.downgrade(); + source + .static_pad("src") + .unwrap() + .add_probe(gst::PadProbeType::BUFFER, move |_, info| { + let now = element + .upgrade() + .and_then(|element| element.current_running_time()); + let (Some(now), Some(gst::PadProbeData::Buffer(buffer))) = (now, info.data.as_mut()) + else { + return gst::PadProbeReturn::Ok; + }; + let Some((pts, duration)) = buffer.pts().zip(buffer.duration()) else { + return gst::PadProbeReturn::Ok; + }; + let arrived = now.nseconds() as i64 - (pts + duration).nseconds() as i64; + let mut state = state.lock().unwrap(); + let (offset, late) = &mut *state; + let offset = offset.get_or_insert(arrived); + let behind = arrived - *offset; + match late { + _ if behind <= AUDIO_LATE.nseconds() as i64 => *late = None, + Some((start, first, least)) + if (behind - *first).abs() <= AUDIO_JITTER.nseconds() as i64 => + { + *least = (*least).min(behind); + if pts.saturating_sub(*start) >= AUDIO_BUFFER { + *offset += *least; + *late = None; + } + } + _ => *late = Some((pts, behind, behind)), + } + let pts = (pts.nseconds() as i64 + *offset).max(0) as u64; + buffer + .make_mut() + .set_pts(gst::ClockTime::from_nseconds(pts)); + gst::PadProbeReturn::Ok + }); +} + +fn add_audio_source( + movie: &mut Movie, + source: &gst::Element, + name: &'static str, + counters: &Arc, +) -> Result<()> { + // Counted before pausing drops anything. + let lost = Arc::new(AtomicU64::new(0)); + count_lost_audio(source, lost.clone(), counters.clone()); + attach_timing_probe(source, counters.clone()); + let convert = element("audioconvert")?; + let resample = element("audioresample")?; + let caps = element("capsfilter")?; + caps.set_property( + "caps", + gst::Caps::builder("audio/x-raw") + .field("rate", 48000i32) + .field("channels", 2i32) + .build(), + ); + let encoder = element("avenc_aac")?; + encoder.set_property("bitrate", 128000i32); + let parser = element("aacparse")?; + let queue = element("queue")?; + // Holds audio while the movie waits for video, or for another audio + // track to start, which can take HELD_VIDEO_LIMIT. mp4mux writes a video + // frame only when the next one arrives and holds later audio until then, + // so at an idle screen's keepalive rate audio waits up to two keepalives, + // and a pause that swallows a frame makes it almost three. + queue.set_property( + "max-size-time", + (HELD_VIDEO_LIMIT.max(KEEPALIVE * 3) + gst::ClockTime::SECOND).nseconds(), + ); + queue.set_property("max-size-buffers", 0u32); + queue.set_property("max-size-bytes", 0u32); + let chain = [ + source, &convert, &resample, &caps, &encoder, &parser, &queue, + ]; + movie.pipeline.add_many(chain).map_err(backend)?; + gst::Element::link_many(chain).map_err(backend)?; + movie.add_audio(name, &queue, source, lost); + Ok(()) +} + +// pulsesrc keeps buffers contiguous unless it skipped audio nobody read in +// time or stamp_by_arrival moved past audio lost on the way, so a longer +// jump is lost audio. +const AUDIO_HOLE: gst::ClockTime = gst::ClockTime::from_mseconds(20); + +// Counts audio missing between consecutive captured buffers, before pausing +// drops any. When downstream does not read pulsesrc in time, its ring buffer +// overwrites the oldest audio, and the next buffer is stamped where capture +// actually resumed. Sources keep capturing while paused, so a pause leaves no +// gap, and the paused part of a gap is not in the movie. Capture timestamps +// and pauses are both in running time. +fn count_lost_audio(source: &gst::Element, lost: Arc, counters: Arc) { + // Where the next buffer should start. + let next = Mutex::new(None::); + source + .static_pad("src") + .unwrap() + .add_probe(gst::PadProbeType::BUFFER, move |_, info| { + let Some((pts, duration)) = info + .buffer() + .and_then(|buffer| buffer.pts().zip(buffer.duration())) + else { + return gst::PadProbeReturn::Ok; + }; + let mut next = next.lock().unwrap(); + if let Some(expected) = next.filter(|&expected| pts > expected) { + let paused = counters + .timeline + .lock() + .unwrap() + .paused_between(expected, pts); + let missing = pts.saturating_sub(expected).saturating_sub(paused); + if missing > AUDIO_HOLE { + lost.fetch_add(missing.nseconds(), Ordering::Relaxed); + } + } + *next = Some(pts + duration); + gst::PadProbeReturn::Ok + }); +} + +fn retime(info: &mut gst::PadProbeInfo<'_>, counters: &Counters) -> bool { + if let Some(gst::PadProbeData::Buffer(buffer)) = info.data.as_mut() { + return counters.timeline.lock().unwrap().retime(buffer); + } + true +} + +fn attach_timing_probe(source: &gst::Element, counters: Arc) { + source + .static_pad("src") + .unwrap() + .add_probe(gst::PadProbeType::BUFFER, move |_, info| { + if retime(info, &counters) { + gst::PadProbeReturn::Ok + } else { + gst::PadProbeReturn::Drop + } + }); +} + +thread_local! { + static SETTING_CANVAS: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +fn set_canvas(output: &gst::Element, caps: gst::Caps) { + SETTING_CANVAS.with(|setting| setting.set(true)); + output.set_property("caps", caps); + SETTING_CANVAS.with(|setting| setting.set(false)); +} + +fn keep_source_configuration(queue: &gst::Element) { + queue + .static_pad("src") + .unwrap() + .add_probe(gst::PadProbeType::EVENT_UPSTREAM, |_, info| { + let canvas = info + .event() + .is_some_and(|event| event.type_() == gst::EventType::Reconfigure) + && SETTING_CANVAS.with(|setting| setting.get()); + if canvas { + gst::PadProbeReturn::Handled + } else { + gst::PadProbeReturn::Ok + } + }); +} + +// A fixed frame rate, else the maximum of a variable-rate stream. +fn capture_rate(caps: &gst::CapsRef) -> Option { + caps.structure(0).and_then(|structure| { + ["framerate", "max-framerate"] + .into_iter() + .filter_map(|field| structure.get::(field).ok()) + .find(|rate| rate.numer() > 0 && rate.denom() > 0) + }) +} + +fn keyframe_interval(caps: &gst::CapsRef, fps: u32) -> u32 { + let frames = capture_rate(caps).map_or(2 * fps as u64, |rate| { + (2 * rate.numer() as u64).div_ceil(rate.denom() as u64) + }); + frames.clamp(1, 2 * fps as u64) as u32 +} + +// VA encoders read key-int-max only when new caps reconfigure them, so set it +// as each CAPS event enters the encoder, after every frame of the old rate. +fn match_keyframes_to_capture(encoder: &gst::Element, fps: u32) { + encoder.static_pad("sink").unwrap().add_probe( + gst::PadProbeType::EVENT_DOWNSTREAM, + move |pad, info| { + if let (Some(gst::EventView::Caps(caps)), Some(encoder)) = + (info.event().map(|event| event.view()), pad.parent_element()) + { + let frames = keyframe_interval(caps.caps(), fps); + if encoder.property::("key-int-max") != frames { + encoder.set_property("key-int-max", frames); + } + } + gst::PadProbeReturn::Ok + }, + ); +} + +// A capture rate change reconfigures the encoder, which writes new parameter +// sets. mp4mux stores those as an extra sample description, which a fragmented +// movie cannot reference, so the movie keeps its first description and every +// keyframe carries its own parameter sets (avc3/hev1). +fn movie_format(codec: Codec, fps: u32) -> Result { + let format = element("capsfilter")?; + format.set_property( + "caps", + match codec { + Codec::H264 => gst::Caps::builder("video/x-h264").field("stream-format", "avc3"), + Codec::Hevc => gst::Caps::builder("video/x-h265").field("stream-format", "hev1"), + } + .field("alignment", "au") + .build(), + ); + // mp4mux times each sample by the next one and takes the last sample's + // duration from its buffer. Some encoders (x265enc 1.24) leave it unset + // for variable-rate capture, and players skip a zero-length last frame, + // so a frame without one lasts a frame at the current capture rate, or at + // the requested rate when the caps carry none. + let requested = gst::ClockTime::SECOND / fps as u64; + let first = Mutex::new(None::); + let frame = Mutex::new(requested); + format.static_pad("src").unwrap().add_probe( + gst::PadProbeType::EVENT_DOWNSTREAM | gst::PadProbeType::BUFFER, + move |_, info| { + match &mut info.data { + Some(gst::PadProbeData::Event(event)) => { + if let gst::EventView::Caps(caps) = event.view() { + *frame.lock().unwrap() = capture_rate(caps.caps()) + .and_then(|rate| { + gst::ClockTime::SECOND + .mul_div_floor(rate.denom() as u64, rate.numer() as u64) + }) + .unwrap_or(requested); + *event = first.lock().unwrap().get_or_insert(event.clone()).clone(); + } + } + Some(gst::PadProbeData::Buffer(buffer)) if buffer.duration().is_none() => { + let duration = *frame.lock().unwrap(); + buffer.make_mut().set_duration(duration); + } + _ => {} + } + gst::PadProbeReturn::Ok + }, + ); + Ok(format) +} + +fn attach_capture_probe( + source: &gst::Element, + output: &gst::Element, + resolution: Resolution, + mode: Mode, + counters: Arc, +) { + let output = output.clone(); + let resized = std::sync::atomic::AtomicBool::new(false); + source.static_pad("src").unwrap().add_probe( + gst::PadProbeType::EVENT_DOWNSTREAM | gst::PadProbeType::BUFFER, + move |pad, info| { + if !retime(info, &counters) { + return gst::PadProbeReturn::Drop; + } + if let Some(gst::PadProbeData::Event(event)) = &info.data { + if let gst::EventView::Caps(caps) = event.view() { + if let Some(structure) = caps.caps().structure(0) { + if let (Ok(width), Ok(height)) = ( + structure.get::("width"), + structure.get::("height"), + ) { + if width >= 2 && height >= 2 { + let mut dimensions = counters.dimensions.lock().unwrap(); + resized.store(dimensions.is_some(), Ordering::Relaxed); + // A movie track has a fixed canvas. Window resizes are + // scaled/letterboxed into the initial output size. + let (w, h) = dimensions + .map(|d| (d.output_width as i32, d.output_height as i32)) + .unwrap_or_else(|| output_size(width, height, resolution)); + dimensions.get_or_insert(CaptureDimensions { + native_width: width.into(), + native_height: height.into(), + output_width: w.into(), + output_height: h.into(), + }); + drop(dimensions); + set_canvas(&output, mode.caps(Some((w, h)))); + } + } + } + } + } + if let Some(gst::PadProbeData::Buffer(buffer)) = &info.data { + if buffer.flags().contains(gst::BufferFlags::CORRUPTED) + && counters.last_pts.lock().unwrap().is_none() + { + return reject_capture( + pad, + info, + gst::StreamError::Failed, + UNFINISHED_FIRST_FRAME, + ); + } + if let Some(pts) = buffer.pts() { + let mut previous = counters.last_pts.lock().unwrap(); + if previous.is_some_and(|last| { + pts <= last || pts.saturating_sub(last) < gst::ClockTime::from_useconds(1) + }) { + counters.dropped.fetch_add(1, Ordering::Relaxed); + return gst::PadProbeReturn::Drop; + } + *previous = Some(pts); + } + if mode.dmabuf + && (buffer.n_memory() == 0 + || buffer.iter_memories().any(|memory| { + !memory.is_memory_type::() + })) + { + return reject_capture(pad, info, gst::StreamError::Format, NON_DMA_BUF_FRAME); + } + if resized.swap(false, Ordering::Relaxed) { + let mut allocation = + gst::query::Allocation::new(pad.current_caps().as_ref(), true); + pad.peer_query(&mut allocation); + } + } + gst::PadProbeReturn::Ok + }, + ); +} + +fn state_error(pipeline: &gst::Pipeline, fallback: impl std::fmt::Display) -> RecorderError { + if let Some(message) = pipeline.bus().and_then(|bus| { + bus.timed_pop_filtered( + gst::ClockTime::from_mseconds(100), + &[gst::MessageType::Error], + ) + }) { + if let gst::MessageView::Error(error) = message.view() { + return backend(format!( + "{}: {} ({})", + error + .src() + .map(|src| src.name().to_string()) + .unwrap_or_default(), + error.error(), + error.debug().unwrap_or_default() + )); + } + } + backend(fallback) +} + +const NON_DMA_BUF_FRAME: &str = "PipeWire delivered a non-DMA-BUF frame. This attempt requires GPU buffer sharing; another available mode will be tried before capture starts."; + +fn reject_capture( + pad: &gst::Pad, + info: &mut gst::PadProbeInfo<'_>, + error: gst::StreamError, + message: &str, +) -> gst::PadProbeReturn { + if let Some(element) = pad.parent_element() { + gst::element_error!(element, error, ("{}", message)); + } + info.flow_res = Err(gst::FlowError::Error); + gst::PadProbeReturn::Handled +} + +const UNFINISHED_FIRST_FRAME: &str = "The screen-capture source started with an unfinished frame. Its timestamp would stall capture timing, so this attempt stopped before encoding and another available mode will be tried."; + +const SOURCE_LOST: &str = + "The captured X11 window closed, was minimized, or became unavailable; the recording stopped."; + +fn run( + movie: &mut Movie, + session: &RecordingSession, + events: &mpsc::Sender, + commands: &mpsc::Receiver, + stop: &watch::Receiver, + counters: &Counters, + source_lost: &dyn Fn() -> Option<&'static str>, +) -> Result<()> { + let pipeline = &movie.pipeline.clone(); + pipeline + .set_state(gst::State::Playing) + .map_err(|error| state_error(pipeline, error))?; + let bus = pipeline + .bus() + .ok_or_else(|| backend("recording pipeline has no bus"))?; + let launched = Instant::now(); + let mut started = false; + let mut stopping = None; + let mut last_metrics = Instant::now(); + let mut omitted = Vec::new(); + let mut logged_lost = vec![0; movie.audio.len()]; + let mut most_buffered = 0; + let mut video_cut = false; + let log = |message: String| { + let _ = events.send(RecorderEvent::Log { + session_id: Some(session.id), + message, + }); + }; + loop { + if let Some(message) = stopping.is_none().then(source_lost).flatten() { + return Err(backend(message)); + } + if *stop.borrow() && stopping.is_none() { + if !started && counters.frames.load(Ordering::Relaxed) == 0 { + return Err(RecorderError::Cancelled); + } + // Reopen the input gate so EOS can carry the final frame. Keeping + // native sources PLAYING avoids PipeWire pause/resume renegotiation. + counters + .timeline + .lock() + .unwrap() + .resume(pipeline.current_running_time().unwrap_or_default()); + // A source pushing into a full queue holds its stream lock, and + // sending it EOS waits for that lock, so wait here instead. + let ending = pipeline.clone(); + std::thread::spawn(move || ending.send_event(gst::event::Eos::new())); + stopping = Some(Instant::now()); + } + if let Ok(command) = commands.try_recv() { + let (pause, reply) = match command { + Command::Pause(reply) => (true, reply), + Command::Resume(reply) => (false, reply), + }; + let result = if stopping.is_some() || !started { + Err(backend("recording is not ready for pause/resume")) + } else { + let now = pipeline.current_running_time().unwrap_or_default(); + let mut timeline = counters.timeline.lock().unwrap(); + if pause { + timeline.pause(now); + } else { + timeline.resume(now); + } + Ok(()) + }; + let _ = reply.send(result); + } + if let Some(message) = bus.timed_pop(gst::ClockTime::from_mseconds(100)) { + match message.view() { + gst::MessageView::Error(error) => { + // A failed audio source, such as pulsesrc after its server + // went away, ends its own track, and the movie keeps + // recording the others. Other audio errors fail the job. + if let Some(track) = movie.audio_source(&message) { + if track.failed.is_none() { + // pulsesrc ends its branch after a fatal error; make + // sure, without waiting on its stream lock here. + if let Some(branch) = + track.source.static_pad("src").and_then(|pad| pad.peer()) + { + std::thread::spawn(move || { + branch.send_event(gst::event::Eos::new()) + }); + } + let position = counters + .timeline + .lock() + .unwrap() + .position(pipeline.current_running_time().unwrap_or_default()); + log(format!( + "capture-engine: {} stopped at {:.1} s: {}; its track ends there and the recording continues", + track.name, + seconds(position), + error.error() + )); + track.failed = Some((position, error.error().to_string())); + } + continue; + } + if let Some(lost) = source_lost() { + return Err(backend(lost)); + } + return Err(backend(format!( + "{}: {} ({})", + error + .src() + .map(|src| src.name().to_string()) + .unwrap_or_default(), + error.error(), + error.debug().unwrap_or_default() + ))); + } + gst::MessageView::Eos(_) => { + if counters.frames.load(Ordering::Relaxed) == 0 { + return Err(backend("capture ended without an encoded frame")); + } + emit_metrics(pipeline, session, events, counters); + if let Some(message) = media_lost( + movie, + &omitted, + dropped_frames(pipeline, counters), + video_cut, + ) { + let _ = events.send(RecorderEvent::MediaLost { + session_id: session.id, + message, + }); + } + return Ok(()); + } + _ => {} + } + } + for (name, ended) in movie.link_audio()? { + log(format!( + "capture-engine: {name} sent no samples {}; the movie has no {name} track", + if ended { + "before the recording stopped".to_string() + } else { + format!( + "while {} seconds or {} MiB of video waited for it", + HELD_VIDEO_LIMIT.seconds(), + HELD_VIDEO_BYTES >> 20 + ) + } + )); + omitted.push(name); + } + if !started && counters.frames.load(Ordering::Relaxed) > 0 { + started = true; + let _ = events.send(RecorderEvent::Started { + session_id: session.id, + dimensions: *counters.dimensions.lock().unwrap(), + }); + } + if !started && launched.elapsed() > Duration::from_secs(15) { + return Err(backend("No encoded frame arrived within 15s. Check DMA-BUF support, VA-API driver access, and audio devices.")); + } + if stopping.is_some_and(|at| at.elapsed() > FINALIZING) { + // Nothing is waiting for the disk or for audio, yet video has + // not reached the muxer: the encoder or capture is stuck. End + // the video track there, so the muxer can finish the movie with + // what it has. + if !video_cut + && !movie.video.pad_flags().contains(gst::PadFlags::EOS) + && downstream_idle(pipeline) + { + video_cut = true; + log(format!( + "capture-engine: video did not reach the movie writer within {} s of stop while nothing waited downstream; ending the video track there so the movie can be finalized", + FINALIZING.as_secs() + )); + let video = movie.video.clone(); + std::thread::spawn(move || video.send_event(gst::event::Eos::new())); + } + if !video_cut || stopping.is_some_and(|at| at.elapsed() > FINALIZING * 3 / 2) { + return Err(backend(format!( + "Movie finalization timed out after {}s; only completed fragments may be playable.", + stopping.unwrap().elapsed().as_secs() + ))); + } + } + if started && last_metrics.elapsed() >= Duration::from_secs(1) { + emit_metrics(pipeline, session, events, counters); + let position = counters + .timeline + .lock() + .unwrap() + .position(pipeline.current_running_time().unwrap_or_default()); + for (track, logged) in movie.audio.iter().zip(&mut logged_lost) { + let lost = track.lost.load(Ordering::Relaxed); + if lost > *logged { + log(format!( + "capture-engine: {} lost {:.2} s of audio before {:.1} s; the movie has a gap there", + track.name, + (lost - *logged) as f64 / 1e9, + seconds(position) + )); + *logged = lost; + } + } + // The allocator keeps memory freed after a stall drains its + // buffers until it is asked to return it. + let buffered = buffered_bytes(pipeline); + if buffered < STALL_DRAINED && most_buffered >= STALL_BUFFERED { + release_freed_memory(); + most_buffered = 0; + } + most_buffered = most_buffered.max(buffered); + last_metrics = Instant::now(); + } + } +} + +// How long finalization may take before a stuck video track is ended at the +// muxer, which then has half as long again. +const FINALIZING: Duration = Duration::from_secs(10); + +// Nothing waits for the disk, and no video waits for audio. +fn downstream_idle(pipeline: &gst::Pipeline) -> bool { + buffered_bytes(pipeline) < STALL_DRAINED + && pipeline.by_name(HELD_QUEUE).map_or(true, |queue| { + queue.property::("current-level-buffers") == 0 + }) +} + +const STALL_BUFFERED: u64 = 8 << 20; +const STALL_DRAINED: u64 = 1 << 20; + +// Encoded media waiting for the disk or for a stalled track. +fn buffered_bytes(pipeline: &gst::Pipeline) -> u64 { + [WRITE_QUEUE, HELD_QUEUE] + .into_iter() + .filter_map(|name| pipeline.by_name(name)) + .map(|queue| u64::from(queue.property::("current-level-bytes"))) + .sum() +} + +fn seconds(time: gst::ClockTime) -> f64 { + time.nseconds() as f64 / 1e9 +} + +// What the finished movie is missing, if anything. A movie that plays is not +// necessarily whole. +fn media_lost( + movie: &Movie, + omitted: &[&str], + dropped_frames: u64, + video_cut: bool, +) -> Option { + let mut lost = Vec::new(); + if video_cut { + lost.push(format!( + "video did not finish within {} s of stop, so the video ends early", + FINALIZING.as_secs() + )); + } + if dropped_frames > 0 { + lost.push(format!("{dropped_frames} video frames were dropped")); + } + for track in &movie.audio { + let missing = track.lost.load(Ordering::Relaxed); + if missing > 0 { + lost.push(format!( + "{} is missing {:.2} s of audio", + track.name, + missing as f64 / 1e9 + )); + } + if let Some((position, reason)) = &track.failed { + lost.push(format!( + "{} stopped at {:.1} s ({reason}), so its track ends there", + track.name, + seconds(*position) + )); + } else if omitted.contains(&track.name) { + lost.push(format!( + "{} sent no samples, so the movie has no {} track", + track.name, track.name + )); + } + } + (!lost.is_empty()).then(|| format!("The movie finished but lost media: {}.", lost.join("; "))) +} + +fn emit_metrics( + pipeline: &gst::Pipeline, + session: &RecordingSession, + events: &mpsc::Sender, + counters: &Counters, +) { + let elapsed_secs = counters + .timeline + .lock() + .unwrap() + .position(pipeline.current_running_time().unwrap_or_default()) + .seconds(); + let output_bytes = file_size(&session.output_path); + let _ = events.send(RecorderEvent::Metrics { + session_id: session.id, + metrics: RecorderMetrics { + elapsed_secs, + output_bytes, + estimated_bitrate_mbps: if elapsed_secs > 0 { + output_bytes as f32 * 8.0 / elapsed_secs as f32 / 1_000_000.0 + } else { + 0.0 + }, + frames: Some(counters.frames.load(Ordering::Relaxed)), + dropped_frames: Some(dropped_frames(pipeline, counters)), + }, + }); +} + +fn file_size(path: &Path) -> u64 { + std::fs::metadata(path) + .map(|metadata| metadata.len()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_variable_rate_portal_frames_and_caps_their_maximum() { + gst::init().unwrap(); + let portal = gst::Caps::builder("video/x-raw") + .features(["memory:DMABuf"]) + .field("framerate", gst::Fraction::new(0, 1)) + .field("max-framerate", gst::Fraction::new(30, 1)) + .build(); + assert!(capture_caps(true, true, 30).can_intersect(&portal)); + assert!(!capture_caps(true, false, 30).can_intersect(&portal)); + assert_eq!( + capture_caps(true, true, 30) + .structure(0) + .unwrap() + .get::("max-framerate") + .unwrap() + .first() + .unwrap() + .get::() + .unwrap(), + gst::Fraction::new(30, 1) + ); + } + + fn negotiated_ceiling(capture: &gst::Caps, portal: &str) -> Option { + let portal = portal.parse::().unwrap(); + let mut common = capture.intersect_with_mode(&portal, gst::CapsIntersectMode::First); + if common.is_empty() { + return None; + } + common.fixate(); + common + .structure(0) + .unwrap() + .get::("max-framerate") + .ok() + } + + #[test] + fn portal_maximum_rates_below_the_request_become_the_ceiling() { + gst::init().unwrap(); + for dmabuf in [false, true] { + let features = if dmabuf { + "(memory:DMABuf), format=DMA_DRM, drm-format=XR24:0x0200000000000901" + } else { + ", format=BGRx" + }; + let portal = |maximum: &str| { + format!("video/x-raw{features}, framerate=0/1, max-framerate={maximum}") + }; + for (fps, maximum, ceiling) in [ + (30, "[1/1, 5/1]", 5), + (30, "[1/1, 24/1]", 24), + (30, "[1/1, 60/1]", 30), + (30, "[1/1, 144/1]", 30), + (60, "[1/1, 5/1]", 5), + (60, "[1/1, 60000/1001]", 59), + (60, "[1/1, 60/1]", 60), + (60, "[1/1, 144/1]", 60), + ] { + assert_eq!( + negotiated_ceiling(&capture_caps(dmabuf, true, fps), &portal(maximum)), + Some(gst::Fraction::new(ceiling, 1)), + "dmabuf {dmabuf}, {fps} fps requested, portal maximum {maximum}" + ); + } + for (fps, maximum) in [(30, "60/1"), (30, "[40/1, 60/1]"), (60, "144/1")] { + assert_eq!( + negotiated_ceiling(&capture_caps(dmabuf, true, fps), &portal(maximum)), + None, + "dmabuf {dmabuf}, {fps} fps requested, portal maximum {maximum}" + ); + } + } + } + + #[test] + fn dma_buf_capture_requires_an_explicit_drm_format() { + gst::init().unwrap(); + let capture = capture_caps(true, true, 60); + let caps = |description: &str| description.parse::().unwrap(); + let legacy_source = caps("video/x-raw(memory:DMABuf), format=BGRx, width=1280, height=720, framerate=0/1, max-framerate=60/1"); + let legacy_converter = caps("video/x-raw(memory:DMABuf), width=[1, 16384], height=[1, 16384], format={ BGRA, RGBA, BGRx, RGBx, NV12, P010_10LE }"); + let modern_source = caps("video/x-raw(memory:DMABuf), format=DMA_DRM, drm-format=XR24:0x0200000000000901, width=1280, height=720, framerate=0/1, max-framerate=60/1"); + let modern_converter = caps("video/x-raw(memory:DMABuf), width=[1, 16384], height=[1, 16384], format=DMA_DRM, drm-format={ NV12:0x0200000000000901, XR24:0x0200000000000901 }"); + assert!(!capture.can_intersect(&legacy_source)); + assert!(!capture.can_intersect(&legacy_converter)); + assert!(capture.can_intersect(&modern_source)); + assert!(capture.can_intersect(&modern_converter)); + assert!(capture_caps(false, true, 60).can_intersect(&caps( + "video/x-raw, format=BGRx, width=1280, height=720, framerate=0/1, max-framerate=60/1" + ))); + } + + #[test] + fn keyframes_follow_the_negotiated_capture_rate_within_the_request() { + gst::init().unwrap(); + let interval = + |caps: &str, fps| keyframe_interval(&caps.parse::().unwrap(), fps); + assert_eq!( + interval("video/x-raw, framerate=0/1, max-framerate=5/1", 60), + 10 + ); + assert_eq!( + interval("video/x-raw, framerate=0/1, max-framerate=5/1", 30), + 10 + ); + assert_eq!( + interval("video/x-raw, framerate=0/1, max-framerate=24/1", 30), + 48 + ); + assert_eq!( + interval("video/x-raw, framerate=0/1, max-framerate=30/1", 30), + 60 + ); + assert_eq!( + interval("video/x-raw, framerate=0/1, max-framerate=60/1", 60), + 120 + ); + assert_eq!( + interval("video/x-raw, framerate=0/1, max-framerate=60000/1001", 60), + 120 + ); + assert_eq!( + interval("video/x-raw, framerate=0/1, max-framerate=144/1", 60), + 120 + ); + assert_eq!(interval("video/x-raw, framerate=30/1", 30), 60); + assert_eq!(interval("video/x-raw, framerate=60/1", 60), 120); + assert_eq!( + interval("video/x-raw, framerate=24/1, max-framerate=60/1", 60), + 48 + ); + assert_eq!(interval("video/x-raw, framerate=0/1", 60), 120); + assert_eq!(interval("video/x-raw", 30), 60); + } + + fn keyframes_from_va_encoder(factory: &'static str, match_capture: bool) -> (u32, usize) { + gst::init().unwrap(); + let pipeline = PipelineGuard(gst::parse::launch("videotestsrc name=source num-buffers=25 ! video/x-raw,width=320,height=180,framerate=5/1 ! vapostproc ! video/x-raw(memory:VAMemory),format=NV12 ! identity name=encoder-input ! fakesink name=sink").unwrap().downcast::().unwrap()); + let mode = Mode { + kind: encoding::Kind::Va, + factory, + dmabuf: false, + }; + let settings = RecorderSettings { + fps: domain::FrameRate::Fps60, + codec: if factory.contains("265") { + Codec::Hevc + } else { + Codec::H264 + }, + ..RecorderSettings::default() + }; + let encoder = mode.encoder(&settings).unwrap(); + let parser = element(parser_name(settings.codec)).unwrap(); + let input = pipeline.0.by_name("encoder-input").unwrap(); + let sink = pipeline.0.by_name("sink").unwrap(); + input.unlink(&sink); + pipeline.0.add_many([&encoder, &parser]).unwrap(); + gst::Element::link_many([&input, &encoder, &parser, &sink]).unwrap(); + if match_capture { + match_keyframes_to_capture(&encoder, 60); + } + let keyframes = Arc::new(AtomicU64::new(0)); + let counted = keyframes.clone(); + sink.static_pad("sink") + .unwrap() + .add_probe(gst::PadProbeType::BUFFER, move |_, info| { + if info + .buffer() + .is_some_and(|buffer| !buffer.flags().contains(gst::BufferFlags::DELTA_UNIT)) + { + counted.fetch_add(1, Ordering::SeqCst); + } + gst::PadProbeReturn::Ok + }); + pipeline.0.set_state(gst::State::Playing).unwrap(); + let message = pipeline + .0 + .bus() + .unwrap() + .timed_pop_filtered( + gst::ClockTime::from_seconds(20), + &[gst::MessageType::Eos, gst::MessageType::Error], + ) + .unwrap(); + assert!( + matches!(message.view(), gst::MessageView::Eos(_)), + "{message:?}" + ); + ( + encoder.property::("key-int-max"), + keyframes.load(Ordering::SeqCst) as usize, + ) + } + + #[test] + fn va_keyframes_are_two_seconds_apart_at_the_capture_rate() { + gst::init().unwrap(); + for factory in ["vah264enc", "vah265enc"] { + if gst::ElementFactory::find(factory).is_none() + || gst::ElementFactory::find("vapostproc").is_none() + { + continue; + } + assert_eq!( + keyframes_from_va_encoder(factory, true), + (10, 3), + "{factory}" + ); + } + } + + #[test] + fn dimensions_preserve_aspect_and_do_not_upscale() { + assert_eq!(output_size(3840, 2160, Resolution::R1080p), (1920, 1080)); + assert_eq!(output_size(1080, 1920, Resolution::R1080p), (606, 1080)); + assert_eq!(output_size(800, 600, Resolution::R1080p), (800, 600)); + assert_eq!(output_size(1919, 1079, Resolution::Native), (1918, 1078)); + } + + struct TestRecording { + pipeline: gst::Pipeline, + session: RecordingSession, + events: mpsc::Receiver, + commands: mpsc::SyncSender, + stop: watch::Sender, + worker: Option>>, + counters: Arc, + lost: Arc, + // When run() returned, before the pipeline was set to NULL. + returned: Arc>>, + } + + impl TestRecording { + fn start(audio_tracks: usize) -> Self { + Self::start_with_audio_delays(&vec![gst::ClockTime::ZERO; audio_tracks]) + } + + fn start_with_audio_delays(audio_delays: &[gst::ClockTime]) -> Self { + Self::start_with(audio_delays, false) + } + + fn start_with(audio_delays: &[gst::ClockTime], counting_encoder: bool) -> Self { + Self::start_from( + MOVING_VIDEO, + audio_delays + .iter() + .map(|&delay| ("test audio", delayed_audio(delay))) + .collect(), + counting_encoder, + ) + } + + fn start_with_audio(audio: Vec<(&'static str, gst::Element)>) -> Self { + Self::start_from(MOVING_VIDEO, audio, false) + } + + // An idle screen: one frame per keepalive, stamped on arrival, and + // no source latency, like pipewiresrc. + fn start_idle(audio_tracks: usize) -> Self { + Self::start_from( + "appsrc name=video is-live=true format=time do-timestamp=true caps=video/x-raw,format=I420,width=320,height=180,framerate=0/1 ! capsfilter name=capture caps=video/x-raw,format=I420,width=320,height=180,framerate=0/1", + (0..audio_tracks) + .map(|_| ("test audio", delayed_audio(gst::ClockTime::ZERO))) + .collect(), + false, + ) + } + + fn start_from( + video: &str, + audio: Vec<(&'static str, gst::Element)>, + counting_encoder: bool, + ) -> Self { + gst::init().unwrap(); + static ID: AtomicU64 = AtomicU64::new(0); + let id = ID.fetch_add(1, Ordering::Relaxed); + let session = RecordingSession { + id, + output_path: std::env::temp_dir() + .join(format!("wrec-linux-test-{}-{id}.mp4", std::process::id())), + }; + // Synthetic software encoding is confined to tests. Exercise the same + // bus/control/mux code without pretending this is a hardware benchmark. + let pipeline = gst::parse::launch(&format!( + "{video} openh264enc name=encoder ! h264parse name=parser" + )) + .unwrap() + .downcast::() + .unwrap(); + // Like production, frames the encoder cannot take are dropped + // before encoding instead of stalling capture. + let queue = video_queue().unwrap(); + pipeline.add(&queue).unwrap(); + gst::Element::link_many([ + &pipeline.by_name("capture").unwrap(), + &queue, + &pipeline.by_name("encoder").unwrap(), + ]) + .unwrap(); + let mux = movie_mux().unwrap(); + pipeline.add(&mux).unwrap(); + movie_file(&pipeline, &mux, session.output_path.to_str().unwrap()).unwrap(); + let mut movie = Movie::new( + &pipeline, + &pipeline.by_name("parser").unwrap(), + &mux, + !audio.is_empty(), + ) + .unwrap(); + let counters = Arc::new(Counters::default()); + count_dropped(&queue, counters.clone()); + let video = pipeline.by_name("video").unwrap(); + attach_timing_probe(&video, counters.clone()); + if video + .factory() + .is_some_and(|factory| factory.name() == "appsrc") + { + std::thread::spawn(move || loop { + let frame = gst::Buffer::from_mut_slice(vec![0u8; 320 * 180 * 3 / 2]); + if video.emit_by_name::("push-buffer", &[&frame]) + != gst::FlowReturn::Ok + { + break; + } + std::thread::sleep(Duration::from_nanos(KEEPALIVE.nseconds())); + }); + } + let encoder = pipeline.by_name("encoder").unwrap(); + if counting_encoder { + count_frames_like_old_va_encoders(&pipeline.by_name("video").unwrap(), &encoder); + } + keep_capture_timestamps(&encoder, &pipeline.by_name("parser").unwrap()); + for (name, source) in &audio { + add_audio_source(&mut movie, source, name, &counters).unwrap(); + } + attach_encoded_probe(&pipeline.by_name("parser").unwrap(), counters.clone()); + let (events_tx, events) = mpsc::channel(); + let (commands, commands_rx) = mpsc::sync_channel(1); + let (stop, stopped) = watch::channel(false); + let worker_pipeline = pipeline.clone(); + let worker_session = session.clone(); + let worker_counters = counters.clone(); + let lost = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let worker_lost = lost.clone(); + let returned = Arc::new(Mutex::new(None)); + let worker_returned = returned.clone(); + let worker = std::thread::spawn(move || { + let _guard = PipelineGuard(worker_pipeline); + let result = run( + &mut movie, + &worker_session, + &events_tx, + &commands_rx, + &stopped, + &worker_counters, + &|| { + worker_lost + .load(Ordering::SeqCst) + .then_some(crate::portal::PIPEWIRE_LOST) + }, + ); + *worker_returned.lock().unwrap() = Some(Instant::now()); + result + }); + let recording = Self { + pipeline, + session, + events, + commands, + stop, + worker: Some(worker), + counters, + lost, + returned, + }; + loop { + if matches!( + recording + .events + .recv_timeout(Duration::from_secs(10)) + .unwrap(), + RecorderEvent::Started { .. } + ) { + break; + } + } + recording + } + + fn control(&self, pause: bool) { + let (tx, rx) = mpsc::sync_channel(1); + self.commands + .send(if pause { + Command::Pause(tx) + } else { + Command::Resume(tx) + }) + .unwrap(); + rx.recv_timeout(Duration::from_secs(5)).unwrap().unwrap(); + } + + fn finish(&mut self) -> Result<()> { + // A recording that already failed has dropped its receiver. + let _ = self.stop.send(true); + self.worker.take().unwrap().join().unwrap() + } + + fn probe(&self) -> serde_json::Value { + probe_movie(&self.session.output_path) + } + + fn logs(&self) -> Vec { + self.events + .try_iter() + .filter_map(|event| match event { + RecorderEvent::Log { message, .. } => Some(message), + _ => None, + }) + .collect() + } + + fn active_seconds(&self) -> f64 { + self.counters + .timeline + .lock() + .unwrap() + .position(self.pipeline.current_running_time().unwrap()) + .nseconds() as f64 + / 1_000_000_000.0 + } + } + + fn count_frames_like_old_va_encoders(source: &gst::Element, encoder: &gst::Element) { + source.static_pad("src").unwrap().add_probe( + gst::PadProbeType::BUFFER, + |_, info| match info.buffer().map(|buffer| buffer.offset() % 3) { + Some(2) => gst::PadProbeReturn::Drop, + _ => gst::PadProbeReturn::Ok, + }, + ); + let start = gst::ClockTime::from_seconds(60 * 60 * 1000); + let frame_duration = gst::ClockTime::SECOND / 30; + let first_pts = Mutex::new(None); + let count = AtomicU64::new(0); + encoder.static_pad("src").unwrap().add_probe( + gst::PadProbeType::BUFFER | gst::PadProbeType::EVENT_DOWNSTREAM, + move |_, info| { + match info.data.as_mut() { + Some(gst::PadProbeData::Event(event)) => { + if let gst::EventView::Segment(segment) = event.view() { + let mut segment = segment + .segment() + .clone() + .downcast::() + .unwrap(); + segment.set_start(segment.start().map(|time| time + start)); + segment.set_position(segment.position().map(|time| time + start)); + segment.set_stop(segment.stop().map(|time| time + start)); + *event = gst::event::Segment::new(&segment); + } + } + Some(gst::PadProbeData::Buffer(buffer)) => { + let first = *first_pts + .lock() + .unwrap() + .get_or_insert(buffer.pts().unwrap_or_default()); + let pts = + start + first + frame_duration * count.fetch_add(1, Ordering::Relaxed); + let buffer = buffer.make_mut(); + buffer.set_pts(pts); + buffer.set_dts(pts); + buffer.set_duration(frame_duration); + } + _ => {} + } + gst::PadProbeReturn::Ok + }, + ); + } + + fn probe_movie(movie: &Path) -> serde_json::Value { + let probe = decode_movie(movie); + let mut previous = std::collections::HashMap::new(); + for packet in probe["packets"].as_array().unwrap() { + let stream = packet["stream_index"].as_u64().unwrap(); + let dts = packet["dts"].as_i64().unwrap(); + if let Some(last) = previous.insert(stream, dts) { + assert!( + dts > last, + "stream {stream} has non-increasing DTS: {last} -> {dts}" + ); + } + } + probe + } + + fn decode_movie(movie: &Path) -> serde_json::Value { + let output = std::process::Command::new("ffprobe") + .args([ + "-v", + "error", + "-show_streams", + "-show_format", + "-show_packets", + "-of", + "json", + ]) + .arg(movie) + .output() + .expect("install ffmpeg to run Linux recording tests"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let decoded = std::process::Command::new("ffmpeg") + .args(["-v", "error", "-i"]) + .arg(movie) + // Preserve the movie's timestamp precision when decoding VFR + // into null output; rounding to 1/30 can create duplicate DTS. + .args([ + "-map", + "0", + "-vsync", + "0", + "-enc_time_base", + "-1", + "-f", + "null", + "-", + ]) + .output() + .unwrap(); + assert!( + decoded.status.success() && decoded.stderr.is_empty(), + "{}", + String::from_utf8_lossy(&decoded.stderr) + ); + serde_json::from_slice(&output.stdout).unwrap() + } + + impl Drop for TestRecording { + fn drop(&mut self) { + let _ = self.stop.send(true); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + let _ = std::fs::remove_file(&self.session.output_path); + } + } + + const MOVING_VIDEO: &str = "videotestsrc name=video is-live=true pattern=ball ! capsfilter name=capture caps=video/x-raw,width=320,height=180,framerate=30/1"; + + // Audio that starts `delay` into the recording. + fn delayed_audio(delay: gst::ClockTime) -> gst::Element { + gst::init().unwrap(); + let source = element("audiotestsrc").unwrap(); + source.set_property("is-live", true); + source + .static_pad("src") + .unwrap() + .add_probe(gst::PadProbeType::BUFFER, move |_, info| { + match info.buffer().and_then(|buffer| buffer.pts()) { + Some(pts) if pts < delay => gst::PadProbeReturn::Drop, + _ => gst::PadProbeReturn::Ok, + } + }); + source + } + + // Live capture like pulsesrc: the producer keeps real time, and when + // nobody reads its 200 ms ring buffer in time it overwrites the oldest + // audio and marks the next buffer DISCONT. A leaky queue does the same. + fn ring_buffer_audio(name: &str) -> gst::Element { + audio_bin( + name, + "queue leaky=downstream max-size-time=200000000 max-size-buffers=0 max-size-bytes=0", + ) + } + + // Audio that reaches the pipeline `lag` after it was captured. Unlike + // the ring buffer it never drops audio, however long its reader takes. + fn lagging_audio(name: &str, lag: Duration) -> gst::Element { + audio_bin( + name, + &format!( + "queue max-size-time=0 max-size-buffers=0 max-size-bytes=0 min-threshold-time={}", + lag.as_nanos() + ), + ) + } + + fn audio_bin(name: &str, queue: &str) -> gst::Element { + gst::init().unwrap(); + let bin = gst::parse::bin_from_description( + &format!("audiotestsrc name=producer is-live=true samplesperbuffer=480 ! {queue}"), + true, + ) + .unwrap(); + bin.set_property("name", name); + bin.upcast() + } + + // Blocks the pad's streaming thread once, `after` from now, for `stall`, + // like a write() or an encoder call that does not return. + fn stall_once(pad: &gst::Pad, after: Duration, stall: Duration) { + let start = Instant::now(); + let done = std::sync::atomic::AtomicBool::new(false); + pad.add_probe(gst::PadProbeType::BUFFER, move |_, _| { + if start.elapsed() >= after && !done.swap(true, Ordering::SeqCst) { + std::thread::sleep(stall); + } + gst::PadProbeReturn::Ok + }); + } + + // Seconds missing from each audio track. mp4mux stretches the packet + // before a hole over it, so a hole is packet spacing beyond one AAC frame. + fn audio_holes(probe: &serde_json::Value) -> Vec { + let packets = probe["packets"].as_array().unwrap(); + probe["streams"] + .as_array() + .unwrap() + .iter() + .filter(|stream| stream["codec_type"] == "audio") + .map(|stream| { + let mut starts: Vec = packets + .iter() + .filter(|packet| packet["stream_index"] == stream["index"]) + .map(|packet| packet["pts_time"].as_str().unwrap().parse().unwrap()) + .collect(); + starts.sort_by(f64::total_cmp); + starts + .windows(2) + .map(|pair| pair[1] - pair[0] - 1024.0 / 48000.0) + .filter(|hole| *hole > 0.03) + .sum::() + .max(0.0) + }) + .collect() + } + + // Seconds of 30 fps video missing between frames. + fn missing_video(probe: &serde_json::Value) -> f64 { + let index = probe["streams"] + .as_array() + .unwrap() + .iter() + .find(|stream| stream["codec_type"] == "video") + .unwrap()["index"] + .clone(); + let mut pts: Vec = probe["packets"] + .as_array() + .unwrap() + .iter() + .filter(|packet| packet["stream_index"] == index) + .map(|packet| packet["pts_time"].as_str().unwrap().parse().unwrap()) + .collect(); + pts.sort_by(f64::total_cmp); + pts.windows(2) + .map(|pair| pair[1] - pair[0] - 1.0 / 30.0) + .filter(|missing| *missing > 0.02) + .sum() + } + + fn pipeline_element(pipeline: &gst::Pipeline, factory: &str) -> Vec { + pipeline + .iterate_recurse() + .into_iter() + .flatten() + .filter(|element| { + element + .factory() + .is_some_and(|found| found.name() == factory) + }) + .collect() + } + + #[derive(Clone, Copy, Debug)] + enum Stall { + // write() into the movie file blocks. + Write, + // The video encoder takes no frame. + Encoder, + // The microphone captures nothing. + AudioProducer, + // The microphone's server goes away, like pulsesrc after a + // PipeWire or PulseAudio restart. + AudioFailure, + } + + struct StallOutcome { + result: Result<()>, + // Seconds missing from system audio and the microphone. + holes: Vec, + // Where each audio track ends. + audio_ends: Vec, + video_end: f64, + missing_video: f64, + dropped: u64, + logs: Vec, + media_lost: Option, + } + + impl StallOutcome { + // The loss the recording reported for a track, in seconds. + fn reported(&self, track: &str) -> f64 { + let message = self.media_lost.as_deref().unwrap_or_default(); + message + .split_once(&format!("{track} is missing ")) + .and_then(|(_, rest)| rest.split_once(" s")?.0.parse().ok()) + .unwrap_or(0.0) + } + } + + // System audio and a microphone, a stall two seconds in, then four more + // seconds. `adjust` changes the pipeline before the stall. + fn record_through( + stall: Stall, + seconds: f64, + adjust: impl FnOnce(&gst::Pipeline), + ) -> StallOutcome { + let mut recording = TestRecording::start_with_audio(vec![ + ("system audio", ring_buffer_audio("system-audio")), + ("microphone", ring_buffer_audio("microphone")), + ]); + let pipeline = &recording.pipeline; + adjust(pipeline); + let (after, length) = (Duration::from_secs(2), Duration::from_secs_f64(seconds)); + let microphone = pipeline + .by_name("microphone") + .unwrap() + .downcast::() + .unwrap() + .by_name("producer") + .unwrap(); + match stall { + Stall::Write => { + let sink = &pipeline_element(pipeline, "filesink")[0]; + stall_once(&sink.static_pad("sink").unwrap(), after, length); + } + Stall::Encoder => { + let encoder = pipeline.by_name("encoder").unwrap(); + stall_once(&encoder.static_pad("sink").unwrap(), after, length); + } + Stall::AudioProducer => { + let start = Instant::now(); + let silent = after..after + length; + microphone.static_pad("src").unwrap().add_probe( + gst::PadProbeType::BUFFER, + move |_, _| { + if silent.contains(&start.elapsed()) { + gst::PadProbeReturn::Drop + } else { + gst::PadProbeReturn::Ok + } + }, + ); + } + Stall::AudioFailure => { + let start = Instant::now(); + microphone.static_pad("src").unwrap().add_probe( + gst::PadProbeType::BUFFER, + move |pad, info| { + if start.elapsed() < after { + return gst::PadProbeReturn::Ok; + } + reject_capture( + pad, + info, + gst::StreamError::Failed, + "Disconnected: Connection terminated", + ) + }, + ); + } + } + std::thread::sleep(after + length + Duration::from_secs(4)); + let result = recording.finish(); + let probe = recording.probe(); + let dropped = dropped_frames(&recording.pipeline, &recording.counters); + let (mut logs, mut media_lost) = (Vec::new(), None); + for event in recording.events.try_iter() { + match event { + RecorderEvent::Log { message, .. } => logs.push(message), + RecorderEvent::MediaLost { message, .. } => media_lost = Some(message), + _ => {} + } + } + StallOutcome { + result, + holes: audio_holes(&probe), + audio_ends: probe["streams"] + .as_array() + .unwrap() + .iter() + .filter(|stream| stream["codec_type"] == "audio") + .map(|stream| { + let seconds = |key: &str| stream[key].as_str().unwrap().parse::().unwrap(); + seconds("start_time") + seconds("duration") + }) + .collect(), + video_end: end_seconds(&probe, "h264"), + missing_video: missing_video(&probe), + dropped, + logs, + media_lost, + } + } + + #[test] + fn a_stalled_file_write_loses_nothing_while_the_write_buffer_lasts() { + let outcome = record_through(Stall::Write, 6.0, |_| {}); + outcome.result.as_ref().unwrap(); + assert_eq!(outcome.holes, [0.0, 0.0], "audio lost to the write stall"); + assert!( + outcome.missing_video < 0.05 && outcome.dropped == 0, + "{}s of video, {} frames lost to the write stall", + outcome.missing_video, + outcome.dropped + ); + assert_eq!(outcome.media_lost, None); + } + + // Past the write buffer every track waits for the disk, as without it. + // The loss is bounded by the stall and reported as it was recorded. + #[test] + fn a_write_stall_longer_than_the_write_buffer_reports_what_it_cost() { + let outcome = record_through(Stall::Write, 6.0, |pipeline| { + pipeline + .by_name(WRITE_QUEUE) + .unwrap() + .set_property("max-size-bytes", 16u32 << 10); + }); + outcome.result.as_ref().unwrap(); + assert_reports_its_losses(&outcome); + // Audio and video each wait about four seconds for the disk. + for hole in &outcome.holes { + assert!((1.0..2.5).contains(hole), "{:?}", outcome.holes); + } + assert!( + (1.0..2.5).contains(&outcome.missing_video), + "{}", + outcome.missing_video + ); + } + + #[test] + fn an_encoder_stall_loses_its_video_and_reports_the_audio_it_cost() { + let outcome = record_through(Stall::Encoder, 6.0, |_| {}); + outcome.result.as_ref().unwrap(); + assert_reports_its_losses(&outcome); + // Audio waits as long as its queue lasts, then its source overwrites it. + for hole in &outcome.holes { + assert!((1.0..2.5).contains(hole), "{:?}", outcome.holes); + } + assert!( + (5.5..6.5).contains(&outcome.missing_video), + "{}", + outcome.missing_video + ); + } + + #[test] + fn a_stalled_audio_source_costs_only_its_own_audio() { + let outcome = record_through(Stall::AudioProducer, 2.0, |_| {}); + outcome.result.as_ref().unwrap(); + assert_reports_its_losses(&outcome); + assert_eq!( + outcome.holes[0], 0.0, + "system audio waited for the microphone" + ); + assert!( + (1.9..2.1).contains(&outcome.holes[1]), + "{:?}", + outcome.holes + ); + assert!( + outcome.missing_video < 0.05 && outcome.dropped == 0, + "{}s of video, {} frames lost waiting for the microphone", + outcome.missing_video, + outcome.dropped + ); + } + + // Video waits for audio only as long as audio waits for video, then + // drops at capture instead of building up encoded frames. + #[test] + fn an_audio_source_stalled_past_the_video_limit_reports_the_video_it_cost() { + let most = Arc::new(AtomicU64::new(0)); + let level = most.clone(); + let outcome = record_through(Stall::AudioProducer, 6.0, |pipeline| { + let held = pipeline.by_name(HELD_QUEUE).unwrap(); + held.connect("overrun", false, move |values| { + let queue = values[0].get::().unwrap(); + let frames = queue.property::("current-level-buffers"); + level.fetch_max(frames.into(), Ordering::SeqCst); + None + }); + }); + outcome.result.as_ref().unwrap(); + // The queue's time level spans the frames dropped meanwhile, so count + // the frames it holds: the limit's worth at 30 fps. + let limit = (HELD_VIDEO_LIMIT + gst::ClockTime::SECOND).seconds() * 30; + let most = most.load(Ordering::SeqCst); + assert!( + (limit..=limit + 1).contains(&most), + "{most} frames waited for the microphone" + ); + assert_reports_its_losses(&outcome); + assert!( + (5.9..6.1).contains(&outcome.holes[1]), + "{:?}", + outcome.holes + ); + assert!( + (1.5..2.5).contains(&outcome.missing_video), + "{}", + outcome.missing_video + ); + } + + #[test] + fn a_failed_audio_source_ends_its_track_and_the_recording_continues() { + let outcome = record_through(Stall::AudioFailure, 0.0, |_| {}); + outcome.result.as_ref().unwrap(); + assert!( + outcome.missing_video < 0.05 && outcome.dropped == 0, + "{}s of video, {} frames lost", + outcome.missing_video, + outcome.dropped + ); + assert!((outcome.audio_ends[0] - outcome.video_end).abs() < 0.1); + assert!( + (1.8..2.3).contains(&outcome.audio_ends[1]), + "the microphone ends at {}s", + outcome.audio_ends[1] + ); + let message = outcome.media_lost.unwrap(); + assert!( + message.contains("microphone stopped at 2.") + && message.contains("Disconnected: Connection terminated") + && message.contains("so its track ends there"), + "{message}" + ); + assert!(!message.contains("system audio"), "{message}"); + assert!( + outcome + .logs + .iter() + .any(|log| log.contains("microphone stopped at")), + "{:?}", + outcome.logs + ); + } + + // Audio captured before a pause but arriving after it is dropped by + // design. A source that delivers late makes that drop longer than a + // lost-audio hole. The source itself loses no audio, so any audio + // reported lost was dropped by pausing. Video frames a starved encoder + // drops are real losses, reported as such. + #[test] + fn audio_dropped_by_pausing_is_not_reported_lost() { + let mut recording = TestRecording::start_with_audio(vec![( + "microphone", + lagging_audio("microphone", Duration::from_millis(100)), + )]); + for _ in 0..3 { + std::thread::sleep(Duration::from_millis(700)); + recording.control(true); + std::thread::sleep(Duration::from_millis(450)); + recording.control(false); + } + std::thread::sleep(Duration::from_millis(1200)); + recording.finish().unwrap(); + recording.probe(); + for event in recording.events.try_iter() { + match event { + RecorderEvent::MediaLost { message, .. } => { + assert!(!message.contains(" of audio"), "{message}") + } + RecorderEvent::Log { message, .. } => { + assert!(!message.contains(" lost "), "{message}") + } + _ => {} + } + } + } + + // A private PulseAudio server playing a tone into a null sink, like a + // desktop's system audio. + struct PulseServer { + process: std::process::Child, + directory: std::path::PathBuf, + } + + impl PulseServer { + fn start() -> Self { + static ID: AtomicU64 = AtomicU64::new(0); + let directory = std::env::temp_dir().join(format!( + "wrec-linux-test-pulse-{}-{}", + std::process::id(), + ID.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&directory).unwrap(); + let socket = directory.join("native"); + let config = directory.join("default.pa"); + std::fs::write( + &config, + format!( + "load-module module-native-protocol-unix socket={} auth-anonymous=1\n\ + load-module module-null-sink sink_name=wrec_test rate=48000 channels=2\n\ + load-module module-sine sink=wrec_test frequency=440\n", + socket.display() + ), + ) + .unwrap(); + let process = std::process::Command::new("pulseaudio") + .args(["-n", "--daemonize=no", "--exit-idle-time=-1"]) + .args(["--use-pid-file=no", "--system=no", "-F"]) + .arg(&config) + .env("HOME", &directory) + .env("XDG_RUNTIME_DIR", &directory) + .env("PULSE_RUNTIME_PATH", &directory) + .env( + "DBUS_SESSION_BUS_ADDRESS", + format!("unix:path={}", directory.join("no-bus").display()), + ) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("pulseaudio is not installed"); + let server = Self { process, directory }; + let deadline = Instant::now() + Duration::from_secs(10); + while !socket.exists() { + assert!(Instant::now() < deadline, "pulseaudio did not start"); + std::thread::sleep(Duration::from_millis(50)); + } + server + } + + fn source(&self) -> gst::Element { + gst::init().unwrap(); + let source = pulse_source(Some("wrec_test.monitor")).unwrap(); + let socket = self.directory.join("native"); + source.set_property("server", format!("unix:{}", socket.display())); + source + } + + // The server makes no audio meanwhile, then catches up at once, like + // a server that held the audio of an engine that stopped. + fn stop_for(&self, stall: Duration) { + let pid = self.process.id() as libc::pid_t; + unsafe { libc::kill(pid, libc::SIGSTOP) }; + std::thread::sleep(stall); + unsafe { libc::kill(pid, libc::SIGCONT) }; + } + } + + impl Drop for PulseServer { + fn drop(&mut self) { + unsafe { libc::kill(self.process.id() as libc::pid_t, libc::SIGCONT) }; + let _ = self.process.kill(); + let _ = self.process.wait(); + let _ = std::fs::remove_dir_all(&self.directory); + } + } + + // Records a tone through pulsesrc while its server stops once. Returns + // the seconds missing from the audio track, the seconds reported lost, + // and where the audio ends after the video. + fn record_server_stop(stall: Duration) -> (f64, f64, f64) { + let server = PulseServer::start(); + let mut recording = + TestRecording::start_with_audio(vec![("system audio", server.source())]); + std::thread::sleep(Duration::from_secs(3)); + server.stop_for(stall); + std::thread::sleep(Duration::from_secs(4)); + recording.finish().unwrap(); + let probe = recording.probe(); + let reported = recording + .events + .try_iter() + .find_map(|event| match event { + RecorderEvent::MediaLost { message, .. } => Some(message), + _ => None, + }) + .and_then(|message| { + let (_, rest) = message.split_once("system audio is missing ")?; + rest.split_once(" s")?.0.parse().ok() + }) + .unwrap_or(0.0); + let after_video = end_seconds(&probe, "aac") - end_seconds(&probe, "h264"); + (audio_holes(&probe)[0], reported, after_video) + } + + // Slaved to the clock, pulsesrc stamped the audio the server made up late, + // and reported almost half a second lost. Run with + // `cargo test -p linux pulse_server -- --ignored`. + #[test] + #[ignore = "needs pulseaudio"] + fn audio_a_pulse_server_held_keeps_its_place() { + let (hole, reported, after_video) = record_server_stop(Duration::from_millis(250)); + assert!( + hole == 0.0 && reported == 0.0, + "{hole}s gap, {reported}s reported" + ); + assert!( + after_video.abs() < 0.05, + "audio ends {after_video}s after video" + ); + } + + // PulseAudio's null sink makes up little of a 1.5 s stop, so most of it + // is lost. The movie has that gap, the same amount is reported, and the + // audio after it plays within AUDIO_LATE of the video, which is as close + // as a loss too small to see can leave it. + #[test] + #[ignore = "needs pulseaudio"] + fn audio_a_pulse_server_lost_is_reported_and_the_rest_keeps_its_place() { + let (hole, reported, after_video) = record_server_stop(Duration::from_millis(1500)); + assert!(hole > 0.5, "{hole}s gap"); + assert!( + (reported - hole).abs() < 0.05, + "{reported}s reported, {hole}s gap" + ); + assert!( + after_video.abs() < AUDIO_LATE.nseconds() as f64 / 1e9, + "audio ends {after_video}s after video" + ); + } + + // Stops a recording `after` something in it got stuck at two seconds. + // Returns the finished recording, its result and how long after stop + // run() returned. Like pulsesrc, the audio sources' streaming threads + // block on full queues. + fn stop_while_stuck( + after: Duration, + stuck: impl FnOnce(&gst::Pipeline), + ) -> (TestRecording, Result<()>, Duration) { + let mut recording = TestRecording::start_with_audio(vec![ + ("system audio", delayed_audio(gst::ClockTime::ZERO)), + ("microphone", delayed_audio(gst::ClockTime::ZERO)), + ]); + stuck(&recording.pipeline); + std::thread::sleep(Duration::from_secs(2) + after); + let stopped = Instant::now(); + let result = recording.finish(); + let returned = recording.returned.lock().unwrap().unwrap() - stopped; + (recording, result, returned) + } + + // Stopped once the audio queues are full, so the audio sources are + // blocked, holding the stream locks that EOS needs. + #[test] + fn a_stuck_video_encoder_at_stop_still_finalizes_the_movie() { + // Stuck past finalization; teardown waits for it to return. + let (recording, result, returned) = stop_while_stuck(Duration::from_secs(6), |pipeline| { + let encoder = pipeline.by_name("encoder").unwrap(); + stall_once( + &encoder.static_pad("sink").unwrap(), + Duration::from_secs(2), + Duration::from_secs(20), + ); + }); + result.unwrap(); + assert!( + (FINALIZING..FINALIZING + Duration::from_secs(2)).contains(&returned), + "finalized {returned:?} after stop" + ); + let probe = recording.probe(); + let video = end_seconds(&probe, "h264"); + assert!((1.8..2.4).contains(&video), "video ends at {video}s"); + // Audio queued before its sources blocked made it into the movie. + let audio = end_seconds(&probe, "aac"); + assert!(audio > 5.9, "audio ends at {audio}s"); + let (mut ending, mut lost) = (false, None); + for event in recording.events.try_iter() { + match event { + RecorderEvent::Log { message, .. } => { + ending |= message.contains("ending the video track") + } + RecorderEvent::MediaLost { message, .. } => lost = Some(message), + _ => {} + } + } + assert!(ending); + assert!(lost.is_some_and(|message| message.contains("the video ends early")),); + } + + // Video waiting for the disk is not stuck, so it is never cut. + #[test] + fn a_disk_stall_at_stop_never_cuts_the_waiting_video() { + let (recording, result, _) = stop_while_stuck(Duration::from_secs(1), |pipeline| { + pipeline + .by_name(WRITE_QUEUE) + .unwrap() + .set_property("max-size-bytes", 16u32 << 10); + let sink = &pipeline_element(pipeline, "filesink")[0]; + stall_once( + &sink.static_pad("sink").unwrap(), + Duration::from_secs(2), + Duration::from_secs(14), + ); + }); + let error = result.unwrap_err(); + assert!( + error.to_string().contains("finalization timed out"), + "{error}" + ); + for event in recording.events.try_iter() { + if let RecorderEvent::Log { message, .. } = event { + assert!(!message.contains("ending the video track"), "{message}"); + } + } + } + + // The microphone's producer inside its ring-buffer bin. + fn producer(pipeline: &gst::Pipeline, name: &str) -> gst::Element { + pipeline + .by_name(name) + .unwrap() + .downcast::() + .unwrap() + .by_name("producer") + .unwrap() + } + + // The part of a stall spent paused is not in the movie, and the rest is + // lost audio. + #[test] + fn audio_lost_across_a_pause_counts_only_recorded_time() { + let mut recording = + TestRecording::start_with_audio(vec![("microphone", ring_buffer_audio("microphone"))]); + let start = Instant::now(); + let silent = Duration::from_secs(2)..Duration::from_millis(4500); + producer(&recording.pipeline, "microphone") + .static_pad("src") + .unwrap() + .add_probe(gst::PadProbeType::BUFFER, move |_, _| { + if silent.contains(&start.elapsed()) { + gst::PadProbeReturn::Drop + } else { + gst::PadProbeReturn::Ok + } + }); + std::thread::sleep(Duration::from_millis(2800)); + recording.control(true); + std::thread::sleep(Duration::from_millis(700)); + recording.control(false); + std::thread::sleep(Duration::from_secs(3)); + recording.finish().unwrap(); + let hole = audio_holes(&recording.probe())[0]; + assert!((1.6..2.0).contains(&hole), "the movie misses {hole}s"); + let message = recording + .events + .try_iter() + .find_map(|event| match event { + RecorderEvent::MediaLost { message, .. } => Some(message), + _ => None, + }) + .expect("no media loss reported"); + let reported: f64 = message + .split_once("microphone is missing ") + .and_then(|(_, rest)| rest.split_once(" s")?.0.parse().ok()) + .unwrap(); + assert!( + (reported - hole).abs() < 0.05, + "reported {reported}s, the movie misses {hole}s" + ); + } + + // Audio lost between buffers with these capture times, in seconds, with + // the timeline's pauses as (start, end), and a pause still going on. + fn lost_between(buffers: &[(f64, f64)], pauses: &[(f64, f64)], paused_at: Option) -> f64 { + gst::init().unwrap(); + let time = |seconds: f64| gst::ClockTime::from_nseconds((seconds * 1e9) as u64); + let counters = Arc::new(Counters::default()); + { + let mut timeline = counters.timeline.lock().unwrap(); + for &(start, end) in pauses { + timeline.pause(time(start)); + timeline.resume(time(end)); + } + if let Some(at) = paused_at { + timeline.pause(time(at)); + } + } + let pipeline = PipelineGuard(gst::Pipeline::new()); + let source = element("appsrc").unwrap(); + source.set_property_from_str("format", "time"); + source.set_property("caps", gst::Caps::new_empty_simple("audio/x-raw")); + let sink = element("fakesink").unwrap(); + sink.set_property("sync", false); + pipeline.0.add_many([&source, &sink]).unwrap(); + source.link(&sink).unwrap(); + let lost = Arc::new(AtomicU64::new(0)); + count_lost_audio(&source, lost.clone(), counters); + pipeline.0.set_state(gst::State::Playing).unwrap(); + for &(start, end) in buffers { + let mut buffer = gst::Buffer::new(); + let buffer_mut = buffer.get_mut().unwrap(); + buffer_mut.set_pts(time(start)); + buffer_mut.set_duration(time(end - start)); + assert_eq!( + source.emit_by_name::("push-buffer", &[&buffer]), + gst::FlowReturn::Ok + ); + } + let _ = source.emit_by_name::("end-of-stream", &[]); + let ended = pipeline.0.bus().unwrap().timed_pop_filtered( + gst::ClockTime::from_seconds(5), + &[gst::MessageType::Eos, gst::MessageType::Error], + ); + assert!(ended.is_some_and(|message| message.type_() == gst::MessageType::Eos)); + lost.load(Ordering::Relaxed) as f64 / 1e9 + } + + // Capture timestamps decide, not when buffers arrive: a source that + // delivers late still has its gap inside the pause. + #[test] + fn only_the_unpaused_part_of_a_capture_gap_is_lost() { + let near = |lost: f64, expected: f64| (lost - expected).abs() < 1e-6; + // Inside a pause from 2 s to 4 s. + let lost = lost_between(&[(2.04, 2.05), (3.95, 3.96)], &[(2.0, 4.0)], None); + assert!(near(lost, 0.0), "{lost}"); + // From 1.5 s to 4.5 s, half a second on each side of it. + let lost = lost_between(&[(1.49, 1.5), (4.5, 4.51)], &[(2.0, 4.0)], None); + assert!(near(lost, 1.0), "{lost}"); + // Into a pause that has not ended. + let lost = lost_between(&[(1.49, 1.5), (3.0, 3.01)], &[], Some(2.0)); + assert!(near(lost, 0.5), "{lost}"); + // Shorter than a hole. + let lost = lost_between(&[(1.0, 1.01), (1.02, 1.03)], &[], None); + assert!(near(lost, 0.0), "{lost}"); + } + + // The queue at the end of an audio source's branch, in front of the + // muxer. + fn audio_branch_end(pipeline: &gst::Pipeline, source: &str) -> gst::Element { + let mut element = pipeline.by_name(source).unwrap(); + while !matches!(element.factory(), Some(factory) if factory.name() == "queue") { + element = element + .static_pad("src") + .unwrap() + .peer() + .unwrap() + .parent_element() + .unwrap(); + } + element + } + + // A source that reports a fatal error but leaves its branch open still + // ends its track, so the muxer does not wait for it. Video waits for an + // audio track for up to HELD_VIDEO_LIMIT before any is dropped, so the + // track has to end at the muxer sooner. The movie's video is no measure + // of this: a starved encoder drops frames at capture whether or not a + // source failed. + #[test] + fn a_failed_audio_source_that_keeps_its_branch_open_still_ends_its_track() { + let mut recording = TestRecording::start_with_audio(vec![ + ("system audio", ring_buffer_audio("system-audio")), + ("microphone", ring_buffer_audio("microphone")), + ]); + let start = Instant::now(); + let failed = Arc::new(Mutex::new(None)); + let failing = failed.clone(); + producer(&recording.pipeline, "microphone") + .static_pad("src") + .unwrap() + .add_probe(gst::PadProbeType::BUFFER, move |pad, _| { + if start.elapsed() < Duration::from_secs(2) { + return gst::PadProbeReturn::Ok; + } + let mut failing = failing.lock().unwrap(); + if failing.is_none() { + *failing = Some(Instant::now()); + let source = pad.parent_element().unwrap(); + gst::element_error!(source, gst::ResourceError::Read, ("device unplugged")); + } + gst::PadProbeReturn::Drop + }); + let ended = Arc::new(Mutex::new(None)); + let ending = ended.clone(); + audio_branch_end(&recording.pipeline, "microphone") + .static_pad("src") + .unwrap() + .add_probe(gst::PadProbeType::EVENT_DOWNSTREAM, move |_, info| { + if info + .event() + .is_some_and(|event| event.type_() == gst::EventType::Eos) + { + ending.lock().unwrap().get_or_insert_with(Instant::now); + } + gst::PadProbeReturn::Ok + }); + std::thread::sleep(Duration::from_secs(8)); + recording.finish().unwrap(); + recording.probe(); + let waited = ended.lock().unwrap().unwrap() - failed.lock().unwrap().unwrap(); + assert!( + waited < Duration::from_nanos(HELD_VIDEO_LIMIT.nseconds()), + "the muxer waited {waited:?} for the failed microphone" + ); + let message = recording + .events + .try_iter() + .find_map(|event| match event { + RecorderEvent::MediaLost { message, .. } => Some(message), + _ => None, + }) + .unwrap(); + assert!(message.contains("microphone stopped at 2."), "{message}"); + } + + // Only a source going away is recoverable. Any other audio error fails + // the recording, as before. + #[test] + fn an_audio_encoder_error_fails_the_recording() { + let mut recording = + TestRecording::start_with_audio(vec![("microphone", ring_buffer_audio("microphone"))]); + let encoder = pipeline_element(&recording.pipeline, "avenc_aac")[0].clone(); + std::thread::sleep(Duration::from_secs(1)); + gst::element_error!(encoder, gst::LibraryError::Encode, ("encoder failed")); + let at = Instant::now(); + let error = recording.finish().unwrap_err(); + assert!(error.to_string().contains("encoder failed"), "{error}"); + assert!(at.elapsed() < Duration::from_secs(2), "{:?}", at.elapsed()); + } + + // What the recording reported matches what its movie is missing. + fn assert_reports_its_losses(outcome: &StallOutcome) { + let message = outcome + .media_lost + .as_deref() + .expect("no media loss reported"); + assert_eq!( + outcome.dropped > 0, + message.contains(&format!("{} video frames were dropped", outcome.dropped)), + "{message}" + ); + // Dropped frames account for the missing video, give or take a + // frame at either edge of each gap. + assert!( + (outcome.dropped as f64 / 30.0 - outcome.missing_video).abs() < 0.15, + "{} dropped frames, {}s of video missing", + outcome.dropped, + outcome.missing_video + ); + for (track, hole) in ["system audio", "microphone"].iter().zip(&outcome.holes) { + let reported = outcome.reported(track); + assert!( + (reported - hole).abs() < 0.05, + "{track}: reported {reported}s lost, movie misses {hole}s: {message}" + ); + assert_eq!( + *hole > 0.0, + outcome + .logs + .iter() + .any(|log| log.starts_with(&format!("capture-engine: {track} lost "))), + "{track}: {:?}", + outcome.logs + ); + } + } + + fn start_seconds(probe: &serde_json::Value, codec: &str) -> Vec { + probe["streams"] + .as_array() + .unwrap() + .iter() + .filter(|stream| stream["codec_name"] == codec) + .map(|stream| stream["start_time"].as_str().unwrap().parse().unwrap()) + .collect() + } + + // The largest gap between packets of a stream up to the given time. + fn largest_gap(probe: &serde_json::Value, kind: &str, until: f64) -> f64 { + let index = probe["streams"] + .as_array() + .unwrap() + .iter() + .find(|stream| stream["codec_type"] == kind) + .unwrap()["index"] + .clone(); + let mut pts: Vec = probe["packets"] + .as_array() + .unwrap() + .iter() + .filter(|packet| packet["stream_index"] == index) + .map(|packet| packet["pts_time"].as_str().unwrap().parse().unwrap()) + .filter(|pts| *pts <= until) + .collect(); + pts.sort_by(f64::total_cmp); + pts.windows(2) + .map(|pair| pair[1] - pair[0]) + .fold(0.0, f64::max) + } + + fn assert_late_audio_keeps_its_offset(movie: &Path, probe: &serde_json::Value, delay: f64) { + let video = start_seconds(probe, "h264"); + let audio = start_seconds(probe, "aac"); + assert_eq!((video.len(), audio.len()), (1, 2), "{}", movie.display()); + let (prompt, late) = (audio[0], audio[1]); + let gap = largest_gap(probe, "video", late + 0.5); + assert!( + gap < 0.1, + "{}: video skips {gap}s while waiting for late audio", + movie.display() + ); + assert!( + (late - prompt - delay).abs() < 0.05, + "{}: audio starts {prompt}s and {late}s, expected {delay}s apart", + movie.display() + ); + assert!( + (late - video[0] - delay).abs() < 0.1, + "{}: late audio starts {late}s, video {}s, expected {delay}s apart", + movie.display(), + video[0] + ); + } + + #[test] + fn late_audio_keeps_its_capture_timestamp_in_partial_and_finalized_movies() { + let delay = gst::ClockTime::from_seconds(1); + let mut recording = TestRecording::start_with_audio_delays(&[gst::ClockTime::ZERO, delay]); + std::thread::sleep(Duration::from_secs(13)); + let partial = recording.session.output_path.with_extension("partial.mp4"); + std::fs::copy(&recording.session.output_path, &partial).unwrap(); + recording.finish().unwrap(); + let seconds = delay.nseconds() as f64 / 1_000_000_000.0; + let checks = std::panic::catch_unwind(|| { + assert_late_audio_keeps_its_offset(&partial, &decode_movie(&partial), seconds); + let movie = &recording.session.output_path; + assert_late_audio_keeps_its_offset(movie, &probe_movie(movie), seconds); + }); + let _ = std::fs::remove_file(&partial); + if let Err(panic) = checks { + std::panic::resume_unwind(panic); + } + } + + fn end_seconds(probe: &serde_json::Value, codec: &str) -> f64 { + let stream = probe["streams"] + .as_array() + .unwrap() + .iter() + .find(|stream| stream["codec_name"] == codec) + .unwrap(); + let seconds = |key: &str| stream[key].as_str().unwrap().parse::().unwrap(); + seconds("start_time") + seconds("duration") + } + + #[test] + fn capture_timestamps_survive_encoders_that_count_frames() { + let mut recording = TestRecording::start_with(&[gst::ClockTime::ZERO], true); + std::thread::sleep(Duration::from_millis(1500)); + recording.control(true); + std::thread::sleep(Duration::from_millis(700)); + recording.control(false); + std::thread::sleep(Duration::from_millis(1500)); + let active = recording + .counters + .timeline + .lock() + .unwrap() + .position(recording.pipeline.current_running_time().unwrap()) + .nseconds() as f64 + / 1_000_000_000.0; + recording.finish().unwrap(); + let probe = recording.probe(); + let (video, audio) = (end_seconds(&probe, "h264"), end_seconds(&probe, "aac")); + assert!( + (video - active).abs() < 0.2, + "video ends at {video}s after {active}s of active recording" + ); + assert!( + (video - audio).abs() < 0.15, + "video ends at {video}s, audio at {audio}s" + ); + let index = probe["streams"] + .as_array() + .unwrap() + .iter() + .find(|stream| stream["codec_name"] == "h264") + .unwrap()["index"] + .clone(); + let mut pts: Vec = probe["packets"] + .as_array() + .unwrap() + .iter() + .filter(|packet| packet["stream_index"] == index) + .map(|packet| packet["pts"].as_i64().unwrap()) + .collect(); + pts.sort_unstable(); + let skipped = pts + .windows(2) + .filter(|pair| pair[1] - pair[0] > 50_000) + .count(); + assert!( + skipped > 10, + "{skipped} video gaps show dropped capture frames" + ); + } + + #[test] + fn lost_capture_timestamps_fail_instead_of_speeding_up_video() { + let mut recording = TestRecording::start_with(&[gst::ClockTime::ZERO], true); + std::thread::sleep(Duration::from_millis(500)); + recording + .pipeline + .by_name("encoder") + .unwrap() + .static_pad("src") + .unwrap() + .add_probe(gst::PadProbeType::BUFFER, |_, info| { + if let Some(gst::PadProbeData::Buffer(buffer)) = info.data.as_mut() { + if let Some(meta) = buffer.make_mut().meta_mut::() + { + meta.remove().unwrap(); + } + } + gst::PadProbeReturn::Ok + }); + std::thread::sleep(Duration::from_secs(1)); + let _ = recording.stop.send(true); + let error = recording + .worker + .take() + .unwrap() + .join() + .unwrap() + .unwrap_err(); + assert!(error.to_string().contains(LOST_CAPTURE_TIME), "{error}"); + } + + #[test] + fn missing_capture_time_fails_the_push_with_a_stream_error() { + gst::init().unwrap(); + let pipeline = PipelineGuard(gst::Pipeline::new()); + let encoder = element("identity").unwrap(); + let parser = element("identity").unwrap(); + let sink = element("fakesink").unwrap(); + pipeline.0.add_many([&encoder, &parser, &sink]).unwrap(); + parser.link(&sink).unwrap(); + keep_capture_timestamps(&encoder, &parser); + let _ = pipeline.0.set_state(gst::State::Playing); + let source = gst::Pad::builder(gst::PadDirection::Src).build(); + source.set_active(true).unwrap(); + source.link(&parser.static_pad("sink").unwrap()).unwrap(); + assert!(source.push_event(gst::event::StreamStart::new("capture"))); + assert!( + source.push_event(gst::event::Segment::new(&gst::FormattedSegment::< + gst::ClockTime, + >::new())) + ); + let mut buffer = gst::Buffer::new(); + buffer + .get_mut() + .unwrap() + .set_pts(gst::ClockTime::from_seconds(1)); + assert_eq!(source.push(buffer), Err(gst::FlowError::Error)); + let message = pipeline + .0 + .bus() + .unwrap() + .timed_pop_filtered(gst::ClockTime::from_seconds(5), &[gst::MessageType::Error]) + .unwrap(); + let gst::MessageView::Error(error) = message.view() else { + panic!("expected error") + }; + assert!( + error.error().to_string().contains(LOST_CAPTURE_TIME), + "{}", + error.error() + ); + } + + #[test] + fn finalizes_playable_video_and_two_audio_tracks() { + let mut recording = TestRecording::start(2); + std::thread::sleep(Duration::from_millis(600)); + recording.finish().unwrap(); + let probe = recording.probe(); + let streams = probe["streams"].as_array().unwrap(); + assert_eq!( + streams.iter().filter(|s| s["codec_name"] == "h264").count(), + 1 + ); + assert_eq!( + streams.iter().filter(|s| s["codec_name"] == "aac").count(), + 2 + ); + assert!( + probe["format"]["duration"] + .as_str() + .unwrap() + .parse::() + .unwrap() + > 0.4 + ); + } + + fn stream_counts(probe: &serde_json::Value) -> (usize, usize) { + let streams = probe["streams"].as_array().unwrap(); + let count = |codec| streams.iter().filter(|s| s["codec_name"] == codec).count(); + (count("h264"), count("aac")) + } + + #[test] + fn stopping_before_audio_arrives_keeps_the_captured_video() { + let never = gst::ClockTime::MAX; + for (delays, paused) in [ + (vec![never], false), + (vec![never], true), + (vec![gst::ClockTime::ZERO, never], false), + (vec![gst::ClockTime::ZERO, never], true), + ] { + let case = format!("audio delays {delays:?}, paused {paused}"); + let mut recording = TestRecording::start_with_audio_delays(&delays); + std::thread::sleep(Duration::from_millis(800)); + if paused { + recording.control(true); + std::thread::sleep(Duration::from_millis(500)); + } + let active = recording.active_seconds(); + recording.finish().unwrap(); + let probe = recording.probe(); + assert_eq!(stream_counts(&probe), (1, delays.len() - 1), "{case}"); + let gap = largest_gap(&probe, "video", f64::INFINITY); + assert!(gap < 0.1, "{case}: video skips {gap}s"); + let duration = end_seconds(&probe, "h264"); + assert!( + (duration - active).abs() < 0.2, + "{case}: video ends at {duration}s after {active}s of active recording" + ); + let logs = recording.logs(); + assert!( + logs.iter().any(|log| log.contains( + "test audio sent no samples before the recording stopped; the movie has no test audio track" + )), + "{case}: {logs:?}" + ); + } + } + + #[test] + fn late_audio_keeps_flowing_while_the_screen_is_idle() { + let mut recording = + TestRecording::start_with_audio_delays(&[gst::ClockTime::from_seconds(1)]); + // An idle screen sends no frames, so when the late track is linked + // mp4mux holds its audio until the next frame arrives. + let idle = gst::ClockTime::from_mseconds(500)..gst::ClockTime::from_mseconds(2200); + recording + .pipeline + .by_name("video") + .unwrap() + .static_pad("src") + .unwrap() + .add_probe(gst::PadProbeType::BUFFER, move |_, info| { + match info.buffer().and_then(|buffer| buffer.pts()) { + Some(pts) if idle.contains(&pts) => gst::PadProbeReturn::Drop, + _ => gst::PadProbeReturn::Ok, + } + }); + let wait_until = |milliseconds| { + while recording.pipeline.current_running_time().unwrap() + < gst::ClockTime::from_mseconds(milliseconds) + { + std::thread::sleep(Duration::from_millis(10)); + } + }; + // Audio that arrives while paused is dropped, so audio stuck behind + // mp4mux before the pause would leave a hole. + wait_until(2000); + recording.control(true); + wait_until(2500); + recording.control(false); + wait_until(3500); + recording.finish().unwrap(); + let probe = recording.probe(); + assert_eq!(stream_counts(&probe), (1, 1)); + let gap = largest_gap(&probe, "audio", f64::INFINITY); + assert!(gap < 0.1, "audio skips {gap}s"); + let (video, audio) = (end_seconds(&probe, "h264"), end_seconds(&probe, "aac")); + assert!( + (video - audio).abs() < 0.15, + "video ends at {video}s, audio at {audio}s" + ); + } + + #[test] + fn a_static_screen_paused_before_audio_arrives_still_finalizes() { + let mut recording = TestRecording::start_with_audio_delays(&[gst::ClockTime::MAX]); + // A static screen sends no more frames, so the held video stops + // growing and only the audio track's end releases it. + recording + .pipeline + .by_name("video") + .unwrap() + .static_pad("src") + .unwrap() + .add_probe(gst::PadProbeType::BUFFER, |_, info| { + match info.buffer().map(|buffer| buffer.offset()) { + Some(0) => gst::PadProbeReturn::Ok, + _ => gst::PadProbeReturn::Drop, + } + }); + std::thread::sleep(Duration::from_millis(300)); + let held = recording.pipeline.by_name(HELD_QUEUE).unwrap(); + let level = held.property::("current-level-time"); + std::thread::sleep(Duration::from_millis(300)); + assert_eq!(held.property::("current-level-time"), level); + recording.control(true); + std::thread::sleep(Duration::from_millis(500)); + recording.finish().unwrap(); + let probe = recording.probe(); + assert_eq!(stream_counts(&probe), (1, 0)); + let logs = recording.logs(); + assert!( + logs.iter() + .any(|log| log.contains("test audio sent no samples before the recording stopped")), + "{logs:?}" + ); + } + + #[test] + fn held_video_is_bounded_in_bytes_as_well_as_time() { + gst::init().unwrap(); + let pipeline = PipelineGuard(gst::Pipeline::new()); + let video = element("appsrc").unwrap(); + video.set_property_from_str("format", "time"); + video.set_property( + "caps", + gst::Caps::builder("video/x-h264") + .field("stream-format", "avc3") + .field("alignment", "au") + .field("width", 320i32) + .field("height", 180i32) + .field("framerate", gst::Fraction::new(0, 1)) + .build(), + ); + let mux = movie_mux().unwrap(); + let sink = element("fakesink").unwrap(); + let silent = element("queue").unwrap(); + pipeline.0.add_many([&video, &mux, &sink, &silent]).unwrap(); + mux.link(&sink).unwrap(); + let mut movie = Movie::new(&pipeline.0, &video, &mux, true).unwrap(); + movie.add_audio("test audio", &silent, &silent, Arc::default()); + let held = pipeline.0.by_name(HELD_QUEUE).unwrap(); + pipeline.0.set_state(gst::State::Playing).unwrap(); + // Large constant-QP frames 10 ms apart reach the byte limit long + // before the time limit. + let frame = 1 << 20; + for index in 0..64u64 { + let pts = gst::ClockTime::from_mseconds(10 * index); + let mut buffer = gst::Buffer::from_mut_slice(vec![0u8; frame]); + buffer.get_mut().unwrap().set_pts(pts); + let before = held.property::("current-level-bytes"); + assert_eq!( + video.emit_by_name::("push-buffer", &[&buffer]), + gst::FlowReturn::Ok + ); + let deadline = Instant::now() + Duration::from_secs(1); + while index > 0 + && held.property::("current-level-bytes") == before + && Instant::now() < deadline + { + std::thread::sleep(Duration::from_millis(1)); + } + let level = held.property::("current-level-bytes"); + let omitted = movie.link_audio().unwrap(); + if !omitted.is_empty() { + assert_eq!(omitted, vec![("test audio", false)]); + assert!( + (HELD_VIDEO_BYTES..=HELD_VIDEO_BYTES + (8 << 20)).contains(&level), + "left out at {level} held bytes" + ); + assert!(pts < HELD_VIDEO_LIMIT, "left out at {pts}"); + return; + } + } + panic!( + "held {} bytes without leaving out the silent track; {:?}", + held.property::("current-level-bytes"), + pipeline + .0 + .bus() + .unwrap() + .pop_filtered(&[gst::MessageType::Error]) + ); + } + + #[test] + fn audio_that_never_starts_is_left_out_without_dropping_video() { + let mut recording = TestRecording::start_with_audio_delays(&[gst::ClockTime::MAX]); + let deadline = Instant::now() + Duration::from_secs(6); + let omitted = loop { + match recording + .events + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + { + Ok(RecorderEvent::Log { message, .. }) if message.contains("sent no samples") => { + break Some((message, recording.active_seconds())) + } + Ok(_) => {} + Err(_) => break None, + } + }; + let (message, at) = omitted.expect("the recording never left out the silent audio track"); + assert!( + message.contains("while 3 seconds or 32 MiB of video waited for it"), + "{message}" + ); + assert!((3.0..3.5).contains(&at), "left out after {at}s"); + std::thread::sleep(Duration::from_millis(500)); + let active = recording.active_seconds(); + recording.finish().unwrap(); + let probe = recording.probe(); + assert_eq!(stream_counts(&probe), (1, 0)); + let gap = largest_gap(&probe, "video", f64::INFINITY); + assert!(gap < 0.1, "video skips {gap}s"); + let duration = end_seconds(&probe, "h264"); + assert!( + (duration - active).abs() < 0.2, + "video ends at {duration}s after {active}s of active recording" + ); + } + + #[test] + fn pause_resume_omits_paused_time_and_stop_while_paused_finalizes() { + let mut recording = TestRecording::start(2); + std::thread::sleep(Duration::from_millis(400)); + recording.control(true); + std::thread::sleep(Duration::from_millis(100)); // Drain frames already accepted. + let before = recording.counters.frames.load(Ordering::Relaxed); + std::thread::sleep(Duration::from_millis(600)); + let after = recording.counters.frames.load(Ordering::Relaxed); + assert_eq!(after, before, "video frames must stop while paused"); + recording.control(false); + std::thread::sleep(Duration::from_millis(400)); + recording.control(true); + let active_duration = recording + .counters + .timeline + .lock() + .unwrap() + .position(recording.pipeline.current_running_time().unwrap()) + .nseconds() as f64 + / 1_000_000_000.0; + recording.finish().unwrap(); + let probe = recording.probe(); + let duration = probe["format"]["duration"] + .as_str() + .unwrap() + .parse::() + .unwrap(); + assert!( + (duration - active_duration).abs() < 0.2, + "movie duration {duration}, active timeline {active_duration}" + ); + // Audio dropped while paused is not lost media. + for event in recording.events.try_iter() { + match event { + RecorderEvent::MediaLost { message, .. } => panic!("{message}"), + RecorderEvent::Log { message, .. } => { + assert!(!message.contains(" lost "), "{message}") + } + _ => {} + } + } + } + + // An idle screen sends a frame each keepalive. A short pause that swallows + // one leaves almost two keepalives between the frames around it, and mp4mux + // holds audio until the frame after those. The audio queue must absorb + // that wait: a full queue blocks pulsesrc, which then drops audio. + #[test] + fn audio_waiting_for_idle_video_across_a_pause_never_fills_its_queue() { + let mut recording = TestRecording::start_idle(2); + let full = Arc::new(std::sync::atomic::AtomicBool::new(false)); + for queue in recording.pipeline.iterate_elements().into_iter().flatten() { + let audio = queue + .factory() + .is_some_and(|factory| factory.name() == "queue") + && ![CAPTURE_QUEUE, HELD_QUEUE, WRITE_QUEUE].contains(&queue.name().as_str()); + if audio { + let full = full.clone(); + queue.connect("overrun", false, move |_| { + full.store(true, Ordering::SeqCst); + None + }); + } + } + let arrived = Arc::new(AtomicU64::new(0)); + let latest = arrived.clone(); + let pipeline = recording.pipeline.clone(); + recording + .pipeline + .by_name("video") + .unwrap() + .static_pad("src") + .unwrap() + .add_probe(gst::PadProbeType::BUFFER, move |_, _| { + let now = pipeline.current_running_time().unwrap_or_default(); + latest.store(now.nseconds(), Ordering::SeqCst); + gst::PadProbeReturn::Ok + }); + let keepalive = Duration::from_nanos(KEEPALIVE.nseconds()); + for _ in 0..2 { + // Pause three quarters of a keepalive after a frame and resume a + // quarter after the next one, the worst case short of no pause. + let frame = arrived.load(Ordering::SeqCst); + let waiting = Instant::now(); + while arrived.load(Ordering::SeqCst) == frame { + assert!(waiting.elapsed() < keepalive * 3, "idle video stopped"); + std::thread::sleep(Duration::from_millis(5)); + } + std::thread::sleep(keepalive * 3 / 4); + recording.control(true); + std::thread::sleep(keepalive / 2); + recording.control(false); + std::thread::sleep(keepalive * 3); + } + recording.finish().unwrap(); + assert!( + !full.load(Ordering::SeqCst), + "audio queue filled while mp4mux waited for idle video" + ); + recording.probe(); + } + + fn test_session() -> RecordingSession { + static ID: AtomicU64 = AtomicU64::new(0); + let id = ID.fetch_add(1, Ordering::Relaxed); + RecordingSession { + id, + output_path: std::env::temp_dir().join(format!( + "wrec-linux-record-test-{}-{id}.mp4", + std::process::id() + )), + } + } + + fn silent_settings(codec: Codec) -> RecorderSettings { + RecorderSettings { + fps: domain::FrameRate::Fps60, + codec, + quality: domain::Quality::High, + include_system_audio: false, + include_microphone: false, + ..RecorderSettings::default() + } + } + + #[test] + fn an_encoder_attempt_reports_its_cleanup_only_after_its_pipeline_is_released() { + gst::init().unwrap(); + let settings = silent_settings(Codec::H264); + let attempts = encoding::modes(settings.codec, false).len(); + assert!(attempts > 1, "this test needs two H.264 encoders"); + let sources = Arc::new(Mutex::new(Vec::>::new())); + let created = sources.clone(); + let capture = CaptureInput::Element(Box::new(move || { + let source = element("filesrc").unwrap(); + source.set_property("location", "/nonexistent/wrec-capture-source"); + created.lock().unwrap().push(source.downgrade()); + source + })); + let session = test_session(); + let (events, _received) = mpsc::channel(); + let (_commands, receiver) = mpsc::sync_channel(1); + let (_stop, stopped) = watch::channel(false); + let signals = Mutex::new(Vec::new()); + let result = record( + &capture, + &session, + &settings, + &events, + receiver, + &stopped, + &|teardown| { + let released = sources + .lock() + .unwrap() + .iter() + .all(|source| source.upgrade().is_none()); + signals.lock().unwrap().push((teardown, released)); + }, + ); + assert!(result.is_err()); + assert!(!session.output_path.exists()); + let mut expected = Vec::new(); + for _ in 1..attempts { + expected.push((Teardown::AttemptStarted, false)); + expected.push((Teardown::AttemptFinished, true)); + } + expected.push((Teardown::Recording, false)); + assert_eq!(signals.into_inner().unwrap(), expected); + } + + fn variable_rate(rate: i32) -> gst::Caps { + gst::Caps::builder("video/x-raw") + .field("format", "BGRx") + .field("width", 320i32) + .field("height", 180i32) + .field("framerate", gst::Fraction::new(0, 1)) + .field("max-framerate", gst::Fraction::new(rate, 1)) + .build() + } + + struct RateChangeRecording { + result: Result<()>, + factory: String, + session: RecordingSession, + partial: std::path::PathBuf, + scheduled: Vec, + source_rates: Vec, + metrics: RecorderMetrics, + } + + impl Drop for RateChangeRecording { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.session.output_path); + let _ = std::fs::remove_file(&self.partial); + } + } + + // record() on its own thread. Every wait on it has a deadline, so an engine + // failure fails the test instead of hanging it. Dropping it stops the + // recording and waits as long as the daemon waits for a worker to exit. + struct RecordThread { + stop: watch::Sender, + returned: mpsc::Receiver>, + } + + impl RecordThread { + fn spawn( + capture: CaptureInput, + session: &RecordingSession, + codec: Codec, + events: mpsc::Sender, + ) -> Self { + let (commands, receiver) = mpsc::sync_channel(1); + let (stop, stopped) = watch::channel(false); + let (done, returned) = mpsc::channel(); + let session = session.clone(); + std::thread::spawn(move || { + let _commands = commands; + let result = record( + &capture, + &session, + &silent_settings(codec), + &events, + receiver, + &stopped, + &|_| {}, + ); + let _ = done.send(result); + }); + Self { stop, returned } + } + + fn assert_running(&self) { + match self.returned.try_recv() { + Err(mpsc::TryRecvError::Empty) => {} + outcome => panic!("the recording ended early: {outcome:?}"), + } + } + + fn finish(&self) -> Result<()> { + self.stop.send_replace(true); + self.returned + .recv_timeout(crate::worker::EXIT_DEADLINE) + .unwrap_or_else(|error| panic!("the recording did not return after stop: {error}")) + } + } + + impl Drop for RecordThread { + fn drop(&mut self) { + self.stop.send_replace(true); + let _ = self.returned.recv_timeout(crate::worker::EXIT_DEADLINE); + } + } + + // The test's appsrc once the recording plays it. + fn playing_source( + source: &Mutex>, + recording: &RecordThread, + ) -> gst::Element { + let start = Instant::now(); + loop { + recording.assert_running(); + let created = source.lock().unwrap().clone(); + if let Some(appsrc) = + created.filter(|appsrc| appsrc.current_state() == gst::State::Playing) + { + return appsrc; + } + assert!( + start.elapsed() < Duration::from_secs(10), + "the capture source did not play within 10s" + ); + std::thread::sleep(Duration::from_millis(10)); + } + } + + // Each recording paces real frames through a leaky queue; running two at + // once on a shared encoder would turn scheduling delays into frame drops. + static RATE_CHANGE_RECORDING: Mutex<()> = Mutex::new(()); + + fn record_rate_changes(codec: Codec, phases: &[(i32, u64)]) -> RateChangeRecording { + gst::init().unwrap(); + let _sequential = RATE_CHANGE_RECORDING + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let source = Arc::new(Mutex::new(None::)); + let source_rates = Arc::new(Mutex::new(Vec::new())); + let created = source.clone(); + let observed = source_rates.clone(); + let first_rate = phases[0].0; + let capture = CaptureInput::Element(Box::new(move || { + let source = element("appsrc").unwrap(); + source.set_property("is-live", true); + source.set_property_from_str("format", "time"); + source.set_property("caps", variable_rate(first_rate)); + let observed = observed.clone(); + source.static_pad("src").unwrap().add_probe( + gst::PadProbeType::EVENT_DOWNSTREAM, + move |_, info| { + if let Some(gst::EventView::Caps(caps)) = info.event().map(|event| event.view()) + { + if let Some(rate) = caps.caps().structure(0).and_then(|structure| { + structure.get::("max-framerate").ok() + }) { + observed.lock().unwrap().push(rate.numer()); + } + } + gst::PadProbeReturn::Ok + }, + ); + *created.lock().unwrap() = Some(source.clone()); + source + })); + let session = test_session(); + let partial = session.output_path.with_extension("partial.mp4"); + let (events, received) = mpsc::channel(); + let recording = RecordThread::spawn(capture, &session, codec, events); + let appsrc = playing_source(&source, &recording); + // Stamp each frame with its schedule rather than the time the source + // thread happens to forward it, so every packet maps to one pushed frame. + let origin = appsrc.current_running_time().unwrap_or_default(); + let start = Instant::now(); + let mut due = Duration::ZERO; + let mut scheduled = Vec::new(); + 'push: for &(rate, seconds) in phases { + appsrc.set_property("caps", variable_rate(rate)); + for _ in 0..rate as u64 * seconds { + std::thread::sleep(due.saturating_sub(start.elapsed())); + let shade = scheduled.len() as u8; + let mut buffer = gst::Buffer::from_mut_slice(vec![shade; 320 * 180 * 4]); + let time = origin + gst::ClockTime::from_nseconds(due.as_nanos() as u64); + let frame = buffer.get_mut().unwrap(); + frame.set_pts(time); + frame.set_dts(time); + if appsrc.emit_by_name::("push-buffer", &[&buffer]) + != gst::FlowReturn::Ok + { + break 'push; + } + scheduled.push(due.as_secs_f64()); + due += Duration::from_secs(1) / rate as u32; + } + } + let accepted = Instant::now(); + while appsrc.property::("current-level-buffers") > 0 + && accepted.elapsed() < Duration::from_secs(5) + { + std::thread::sleep(Duration::from_millis(10)); + } + // mp4mux writes a fragment when the next keyframe reaches it, so wait + // for one that holds frames after the first rate change. + let changed = phases[0].1 as f64; + let copied = Instant::now(); + loop { + std::fs::copy(&session.output_path, &partial).unwrap(); + if packet_span(&partial).is_some_and(|span| span >= changed - 1e-3) + || copied.elapsed() > Duration::from_secs(10) + { + break; + } + std::thread::sleep(Duration::from_millis(200)); + } + let result = recording.finish(); + let events: Vec = received.try_iter().collect(); + let factory = events + .iter() + .find_map(|event| match event { + RecorderEvent::Log { message, .. } if message.contains("selected") => message + .split("; ") + .nth(2) + .map(|factory| factory.to_string()), + _ => None, + }) + .unwrap(); + let metrics = events + .iter() + .rev() + .find_map(|event| match event { + RecorderEvent::Metrics { metrics, .. } => Some(metrics.clone()), + _ => None, + }) + .unwrap(); + let source_rates = source_rates.lock().unwrap().clone(); + RateChangeRecording { + result, + factory, + session, + partial, + scheduled, + source_rates, + metrics, + } + } + + // Seconds from the first video packet to the last one. Movie timestamps + // start at the pipeline's running time, not at zero. + fn packet_span(movie: &Path) -> Option { + let output = std::process::Command::new("ffprobe") + .args(["-v", "error", "-select_streams", "v:0", "-show_entries"]) + .args(["packet=pts_time", "-of", "csv=p=0"]) + .arg(movie) + .output() + .ok()?; + let times: Vec = String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| line.trim().parse().ok()) + .collect(); + let first = times.iter().copied().reduce(f64::min)?; + times + .iter() + .copied() + .reduce(f64::max) + .map(|last| last - first) + } + + fn video_packets(probe: &serde_json::Value) -> Vec<(f64, bool)> { + let stream = &probe["streams"][0]; + assert_eq!(stream["codec_type"], "video"); + let base: Vec = stream["time_base"] + .as_str() + .unwrap() + .split('/') + .map(|part| part.parse().unwrap()) + .collect(); + probe["packets"] + .as_array() + .unwrap() + .iter() + .filter(|packet| packet["stream_index"] == 0) + .map(|packet| { + ( + packet["pts"].as_i64().unwrap() as f64 * base[0] / base[1], + packet["flags"].as_str().unwrap().starts_with('K'), + ) + }) + .collect() + } + + fn decoded_video_frames(movie: &Path) -> u64 { + let output = std::process::Command::new("ffprobe") + .args(["-v", "error", "-select_streams", "v:0", "-count_frames"]) + .args(["-show_entries", "stream=nb_read_frames", "-of", "csv=p=0"]) + .arg(movie) + .output() + .unwrap(); + assert!(output.status.success()); + String::from_utf8_lossy(&output.stdout) + .trim() + .parse() + .unwrap() + } + + fn assert_rate_changes_stay_playable(codec: Codec, name: &str, phases: &[(i32, u64)]) { + let recording = record_rate_changes(codec, phases); + let movie = &recording.session.output_path; + assert!(recording.result.is_ok(), "{:?}", recording.result); + let rates: Vec = phases.iter().map(|(rate, _)| *rate).collect(); + let mut transitions = recording.source_rates.clone(); + transitions.dedup(); + assert_eq!(transitions, rates, "the source did not change its caps"); + let probe = probe_movie(movie); + assert_eq!(probe["streams"][0]["codec_name"], name); + let packets = video_packets(&probe); + let encoded = recording.metrics.frames.unwrap(); + let dropped = recording.metrics.dropped_frames.unwrap(); + assert_eq!(packets.len() as u64, encoded); + assert_eq!(encoded + dropped, recording.scheduled.len() as u64); + assert_eq!(decoded_video_frames(movie), packets.len() as u64); + let first = packets[0].0; + let va = recording.factory.starts_with("va"); + let mut phase_start = 0.0; + let mut remaining = packets + .iter() + .map(|(pts, key)| (pts - first, *key)) + .peekable(); + for &(rate, seconds) in phases { + let phase_end = phase_start + seconds as f64; + let mut phase = Vec::new(); + while let Some((position, key)) = + remaining.next_if(|(position, _)| *position < phase_end - 0.5 / rate as f64) + { + assert!( + recording + .scheduled + .iter() + .any(|due| (position - due).abs() < 2e-6), + "a packet at {position}s matches no pushed frame" + ); + phase.push(key); + } + let interval = if va { + keyframe_interval(&variable_rate(rate), 60) as usize + } else { + 120 + }; + let keyframes: Vec = phase + .iter() + .enumerate() + .filter_map(|(index, key)| key.then_some(index)) + .collect(); + assert_eq!( + keyframes, + (0..phase.len()).step_by(interval).collect::>(), + "{} at {rate} fps from {phase_start}s", + recording.factory + ); + phase_start = phase_end; + } + assert!(remaining.next().is_none(), "packets after the last phase"); + let last_frame = 1.0 / phases.last().unwrap().0 as f64; + let span = packets.last().unwrap().0 - first + last_frame; + let duration: f64 = probe["streams"][0]["duration"] + .as_str() + .unwrap() + .parse() + .unwrap(); + assert!( + (duration - span).abs() < 1e-3, + "the movie lasts {duration}s, but its last frame ends at {span}s" + ); + for probe in [&probe, &probe_movie(&recording.partial)] { + assert_eq!( + probe["streams"][0]["codec_tag_string"], + if name == "h264" { "avc3" } else { "hev1" } + ); + assert!( + !probe.to_string().contains("New Extradata"), + "the movie switches sample descriptions" + ); + } + let partial = probe_movie(&recording.partial); + let partial_packets = video_packets(&partial); + let span = partial_packets.last().unwrap().0 - partial_packets[0].0; + assert!( + span >= phases[0].1 as f64 - 1e-3, + "the partial movie ends {span}s in, before the first rate change" + ); + assert_eq!( + decoded_video_frames(&recording.partial), + partial_packets.len() as u64 + ); + eprintln!( + "{} {rates:?}: {} frames, {dropped} dropped; partial movie has {} frames", + recording.factory, + packets.len(), + partial_packets.len() + ); + } + + const RISING: [(i32, u64); 3] = [(5, 3), (60, 6), (5, 3)]; + const FALLING: [(i32, u64); 3] = [(60, 3), (5, 4), (60, 3)]; + + #[test] + fn h264_recordings_survive_capture_rate_changes() { + assert_rate_changes_stay_playable(Codec::H264, "h264", &RISING); + assert_rate_changes_stay_playable(Codec::H264, "h264", &FALLING); + } + + #[test] + fn hevc_recordings_survive_capture_rate_changes() { + assert_rate_changes_stay_playable(Codec::Hevc, "hevc", &RISING); + assert_rate_changes_stay_playable(Codec::Hevc, "hevc", &FALLING); + } + + // Records white edges around a black picture and returns the movie. + fn record_framed_picture(codec: Codec, width: i32, height: i32) -> RecordingSession { + gst::init().unwrap(); + let caps = gst::Caps::builder("video/x-raw") + .field("format", "BGRx") + .field("width", width) + .field("height", height) + .field("framerate", gst::Fraction::new(0, 1)) + .field("max-framerate", gst::Fraction::new(10, 1)) + .build(); + let mut picture = vec![0u8; (width * height * 4) as usize]; + for (index, pixel) in picture.chunks_mut(4).enumerate() { + let (x, y) = (index as i32 % width, index as i32 / width); + if x.min(width - 1 - x).min(y).min(height - 1 - y) < EDGE { + pixel.fill(255); + } + } + let source = Arc::new(Mutex::new(None::)); + let created = source.clone(); + let capture = CaptureInput::Element(Box::new(move || { + let source = element("appsrc").unwrap(); + source.set_property("is-live", true); + source.set_property_from_str("format", "time"); + source.set_property("caps", &caps); + *created.lock().unwrap() = Some(source.clone()); + source + })); + let session = test_session(); + let (events, _received) = mpsc::channel(); + let recording = RecordThread::spawn(capture, &session, codec, events); + let appsrc = playing_source(&source, &recording); + let origin = appsrc.current_running_time().unwrap_or_default(); + for frame in 0..10 { + std::thread::sleep(Duration::from_millis(100)); + let mut buffer = gst::Buffer::from_slice(picture.clone()); + let buffer_mut = buffer.get_mut().unwrap(); + buffer_mut.set_pts(origin + gst::ClockTime::from_mseconds(100 * frame)); + assert_eq!( + appsrc.emit_by_name::("push-buffer", &[&buffer]), + gst::FlowReturn::Ok + ); + } + let pushed = Instant::now(); + while appsrc.property::("current-level-buffers") > 0 { + recording.assert_running(); + assert!( + pushed.elapsed() < Duration::from_secs(5), + "the recording took no frame for 5s" + ); + std::thread::sleep(Duration::from_millis(10)); + } + recording.finish().unwrap(); + session + } + + const EDGE: i32 = 16; + + // The pictures a player shows, decoded with FFmpeg's default cropping. + fn decoded_pictures(movie: &Path) -> Vec<(usize, usize, Vec)> { + let output = std::process::Command::new("ffmpeg") + .args(["-v", "error", "-i"]) + .arg(movie) + .args(["-map", "0:v:0", "-vsync", "0", "-pix_fmt", "gray"]) + .args(["-c:v", "pgm", "-f", "image2pipe", "-"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let mut pictures = Vec::new(); + let mut rest = &output.stdout[..]; + while !rest.is_empty() { + // "P5\n \n255\n" followed by the pixels. + let mut fields = Vec::new(); + while fields.len() < 4 { + let end = rest.iter().position(u8::is_ascii_whitespace).unwrap(); + fields.push(String::from_utf8(rest[..end].to_vec()).unwrap()); + rest = &rest[end + 1..]; + } + let width: usize = fields[1].parse().unwrap(); + let height: usize = fields[2].parse().unwrap(); + pictures.push((width, height, rest[..width * height].to_vec())); + rest = &rest[width * height..]; + } + pictures + } + + // Boxes in the movie's final index, down to each video sample entry. A + // finished movie keeps its first index inside its media data box as + // "hoov", where this walk does not look, so a partial movie's first index + // goes unchecked. + fn video_boxes(movie: &Path) -> Vec<(String, Vec)> { + fn children(data: &[u8]) -> Vec<(&str, &[u8])> { + let mut boxes = Vec::new(); + let mut rest = data; + while rest.len() >= 8 { + let size = u32::from_be_bytes(rest[..4].try_into().unwrap()) as usize; + let size = if size == 0 { rest.len() } else { size }; + if size < 8 || size > rest.len() { + break; + } + let name = std::str::from_utf8(&rest[4..8]).unwrap_or("?"); + boxes.push((name, &rest[8..size])); + rest = &rest[size..]; + } + boxes + } + fn walk(name: &str, body: &[u8], found: &mut Vec<(String, Vec)>) { + let body = match name { + "moov" | "trak" | "tapt" | "mdia" | "minf" | "stbl" => body, + // A full box with an entry count. + "stsd" => &body[8..], + // The fields of a visual sample entry come before its boxes. + "avc1" | "avc3" | "hvc1" | "hev1" => &body[78..], + _ => return, + }; + for (child, payload) in children(body) { + found.push((child.to_string(), payload.to_vec())); + walk(child, payload, found); + } + } + let data = std::fs::read(movie).unwrap(); + let mut found = Vec::new(); + for (name, body) in children(&data) { + walk(name, body, &mut found); + } + found + } + + fn assert_records_every_pixel(codec: Codec, width: i32, height: i32) { + let session = record_framed_picture(codec, width, height); + let movie = &session.output_path; + let file_type = std::fs::read(movie).unwrap()[4..12].to_vec(); + let boxes = video_boxes(movie); + let pictures = decoded_pictures(movie); + let _ = std::fs::remove_file(movie); + // Players crop to a clean aperture ("clap") or a track aperture + // ("tapt"). FFmpeg 8.0 applies clap when decoding; FFmpeg 7.0 does not. + let size = (width as u32, height as u32); + for (name, payload) in &boxes { + let field = |index: usize| { + u32::from_be_bytes(payload[index * 4..index * 4 + 4].try_into().unwrap()) + }; + let aperture = match name.as_str() { + // Width and height as fractions. + "clap" => (field(0) / field(1), field(2) / field(3)), + // A full box header, then 16.16 fixed-point width and height. + "clef" | "prof" | "enof" => (field(1) >> 16, field(2) >> 16), + _ => continue, + }; + assert_eq!(aperture, size, "{name} crops the movie's picture"); + } + assert!( + boxes + .iter() + .any(|(name, _)| name == "avcC" || name == "hvcC"), + "found no video sample entry in {boxes:?}" + ); + assert!(!pictures.is_empty()); + for (shown_width, shown_height, pixels) in pictures { + let shown = (shown_width as u32, shown_height as u32); + assert_eq!(shown, size, "the player shows a different picture"); + let luma = |x: usize, y: usize| pixels[y * shown_width + x]; + let (right, bottom) = (shown_width - 1, shown_height - 1); + let middle = (shown_width / 2, shown_height / 2); + let edges = [ + (0, middle.1), + (right, middle.1), + (middle.0, 0), + (middle.0, bottom), + (0, 0), + (right, bottom), + ]; + for (x, y) in edges { + assert!(luma(x, y) > 200, "the edge at {x},{y} is missing"); + } + for (x, y) in [ + (EDGE as usize + 2, middle.1), + (right - EDGE as usize - 2, middle.1), + middle, + ] { + assert!(luma(x, y) < 50, "the picture at {x},{y} moved"); + } + } + + // Linux names recordings ".mp4", so the file must say it is MP4. + assert_eq!( + String::from_utf8_lossy(&file_type), + "ftypmp42", + "the movie's file type" + ); + } + + // qtmux crops these sizes to a guessed TV aspect ratio; see movie_mux. + #[test] + fn h264_recordings_keep_every_pixel_at_tv_like_sizes() { + assert_records_every_pixel(Codec::H264, 800, 528); + } + + #[test] + fn hevc_recordings_keep_every_pixel_at_tv_like_sizes() { + assert_records_every_pixel(Codec::Hevc, 800, 528); + } + + // Rust heap bytes allocated and not yet freed by each thread. GStreamer + // buffers come from GLib and are not counted. + struct CountingAllocator; + + thread_local! { + static LIVE_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + + unsafe impl std::alloc::GlobalAlloc for CountingAllocator { + unsafe fn alloc(&self, layout: std::alloc::Layout) -> *mut u8 { + let _ = LIVE_BYTES.try_with(|live| live.set(live.get() + layout.size() as isize)); + unsafe { std::alloc::System.alloc(layout) } + } + + unsafe fn dealloc(&self, pointer: *mut u8, layout: std::alloc::Layout) { + let _ = LIVE_BYTES.try_with(|live| live.set(live.get() - layout.size() as isize)); + unsafe { std::alloc::System.dealloc(pointer, layout) } + } + } + + #[global_allocator] + static ALLOCATOR: CountingAllocator = CountingAllocator; + + struct QueueDrops { + dropped: u64, + dropped_while_stuck: u64, + delivered: u64, + heap_growth: isize, + } + + // Pushes frames straight into the capture queue while every frame leaving + // it waits for a permit, then reports the frames dropped and delivered and + // the heap the pushing thread kept while the consumer was stuck. + fn queue_drops(permit_during_overrun: bool, frames: u64) -> QueueDrops { + gst::init().unwrap(); + let pipeline = PipelineGuard(gst::Pipeline::new()); + let queue = video_queue().unwrap(); + let sink = element("fakesink").unwrap(); + sink.set_property("sync", false); + pipeline.0.add_many([&queue, &sink]).unwrap(); + queue.link(&sink).unwrap(); + let counters = Arc::new(Counters::default()); + count_dropped(&queue, counters.clone()); + let (permits, permitted) = mpsc::channel::<()>(); + let permitted = Mutex::new(permitted); + let delivered = Arc::new(Mutex::new(Vec::new())); + let counted = delivered.clone(); + queue + .static_pad("src") + .unwrap() + .add_probe(gst::PadProbeType::BUFFER, move |_, info| { + let _ = permitted.lock().unwrap().recv(); + if let Some(buffer) = info.buffer() { + counted.lock().unwrap().push(( + buffer.as_ptr() as usize, + buffer.flags().contains(gst::BufferFlags::DISCONT), + )); + } + gst::PadProbeReturn::Ok + }); + if permit_during_overrun { + let permits = Mutex::new(permits.clone()); + queue.connect("overrun", false, move |values| { + let queue = values[0].get::().unwrap(); + permits.lock().unwrap().send(()).unwrap(); + while queue.property::("current-level-buffers") >= 2 { + std::thread::sleep(Duration::from_millis(1)); + } + None + }); + } + pipeline.0.set_state(gst::State::Playing).unwrap(); + let input = queue.static_pad("sink").unwrap(); + input.send_event(gst::event::StreamStart::new("queue-drops")); + input.send_event(gst::event::Caps::new(&variable_rate(60))); + input.send_event(gst::event::Segment::new(&gst::FormattedSegment::< + gst::ClockTime, + >::new())); + // Keep a reference to every frame, as pipewiresrc keeps its last one, + // so a counter that wrote to a frame would have to copy it. The queue + // itself copies only the frame after a discard, to mark it DISCONT. + let mut sent = Vec::with_capacity(frames as usize); + let heap = LIVE_BYTES.with(|live| live.get()); + for frame in 0..frames { + let mut buffer = gst::Buffer::new(); + buffer + .get_mut() + .unwrap() + .set_pts(gst::ClockTime::from_mseconds(frame * 10)); + sent.push(buffer.clone()); + assert_eq!(input.chain(buffer), Ok(gst::FlowSuccess::Ok)); + if permit_during_overrun || frame == 0 { + std::thread::sleep(Duration::from_millis(20)); + } + } + let heap_growth = LIVE_BYTES.with(|live| live.get()) - heap; + let dropped_while_stuck = dropped_frames(&pipeline.0, &counters); + for _ in 0..frames { + let _ = permits.send(()); + } + input.send_event(gst::event::Eos::new()); + pipeline + .0 + .bus() + .unwrap() + .timed_pop_filtered(gst::ClockTime::from_seconds(5), &[gst::MessageType::Eos]) + .unwrap(); + let sent: std::collections::HashSet = + sent.iter().map(|buffer| buffer.as_ptr() as usize).collect(); + let delivered = delivered.lock().unwrap(); + assert!( + delivered + .iter() + .all(|(buffer, discont)| *discont || sent.contains(buffer)), + "the queue delivered a copy of a captured frame" + ); + QueueDrops { + dropped: dropped_frames(&pipeline.0, &counters), + dropped_while_stuck, + delivered: delivered.len() as u64, + heap_growth, + } + } + + #[test] + fn dropped_frames_count_only_frames_the_queue_discards() { + let raced = queue_drops(true, 6); + assert_eq!((raced.dropped, raced.delivered), (0, 6)); + let leaked = queue_drops(false, 6); + assert_eq!((leaked.dropped, leaked.delivered), (3, 3)); + } + + #[test] + fn a_stuck_encoder_does_not_grow_drop_counting() { + let stuck = queue_drops(false, 2000); + assert_eq!(stuck.dropped_while_stuck, 1997); + assert_eq!((stuck.dropped, stuck.delivered), (1997, 3)); + assert!( + stuck.heap_growth < 4096, + "counting kept {} bytes for 1997 dropped frames", + stuck.heap_growth + ); + } + + // Durations the movie receives for encoded frames that arrive with + // `duration` under each of the given encoder caps. + fn movie_durations( + caps: &[&str], + duration: Option, + ) -> Vec> { + gst::init().unwrap(); + let pipeline = PipelineGuard(gst::Pipeline::new()); + let format = movie_format(Codec::H264, 60).unwrap(); + let sink = element("fakesink").unwrap(); + pipeline.0.add_many([&format, &sink]).unwrap(); + format.link(&sink).unwrap(); + let received = Arc::new(Mutex::new(Vec::new())); + let recorded = received.clone(); + sink.static_pad("sink") + .unwrap() + .add_probe(gst::PadProbeType::BUFFER, move |_, info| { + recorded + .lock() + .unwrap() + .push(info.buffer().and_then(|buffer| buffer.duration())); + gst::PadProbeReturn::Ok + }); + pipeline.0.set_state(gst::State::Playing).unwrap(); + let input = format.static_pad("sink").unwrap(); + input.send_event(gst::event::StreamStart::new("movie-durations")); + for (index, caps) in caps.iter().enumerate() { + input.send_event(gst::event::Caps::new(&caps.parse::().unwrap())); + if index == 0 { + input.send_event(gst::event::Segment::new(&gst::FormattedSegment::< + gst::ClockTime, + >::new())); + } + let mut buffer = gst::Buffer::new(); + buffer.get_mut().unwrap().set_duration(duration); + assert_eq!(input.chain(buffer), Ok(gst::FlowSuccess::Ok)); + } + let received = received.lock().unwrap().clone(); + received + } + + #[test] + fn frames_without_a_duration_last_one_frame_at_the_capture_rate() { + let encoded = "video/x-h264, stream-format=avc3, alignment=au, width=320, height=180"; + let rated = |rate: &str| format!("{encoded}, framerate=0/1, max-framerate={rate}"); + let frame = |rate: u64| Some(gst::ClockTime::SECOND / rate); + assert_eq!( + movie_durations(&[&rated("5/1"), &rated("60/1"), encoded], None), + [frame(5), frame(60), frame(60)] + ); + assert_eq!( + movie_durations(&[&format!("{encoded}, framerate=30/1"), encoded], None), + [frame(30), frame(60)] + ); + let kept = gst::ClockTime::from_mseconds(7); + assert_eq!( + movie_durations(&[&rated("5/1"), encoded], Some(kept)), + [Some(kept), Some(kept)] + ); + } + + #[test] + fn every_attempt_that_ends_the_recording_signals_teardown() { + let idle = Counters::default(); + let encoded = Counters::default(); + encoded.frames.store(1, Ordering::Relaxed); + let failed = Err(backend("lost")); + assert!(ends_recording(&Ok(()), &encoded, false)); + assert!(ends_recording(&Ok(()), &encoded, true)); + assert!(ends_recording(&Err(RecorderError::Cancelled), &idle, false)); + assert!(ends_recording(&failed, &encoded, false)); + assert!(ends_recording(&failed, &idle, true)); + assert!(!ends_recording(&failed, &idle, false)); + } + + #[test] + fn source_loss_fails_active_and_paused_recordings_promptly() { + // The last case loses the source while video waits for audio. + for (paused, audio) in [ + (false, gst::ClockTime::ZERO), + (true, gst::ClockTime::ZERO), + (false, gst::ClockTime::MAX), + ] { + let mut recording = TestRecording::start_with_audio_delays(&[audio]); + std::thread::sleep(Duration::from_millis(300)); + if paused { + recording.control(true); + } + let at = Instant::now(); + recording.lost.store(true, Ordering::SeqCst); + let error = recording + .worker + .take() + .unwrap() + .join() + .unwrap() + .unwrap_err(); + let finished = at.elapsed(); + let returned = recording.returned.lock().unwrap().unwrap() - at; + assert!( + finished < Duration::from_secs(1), + "paused {paused}, waiting for audio {}: run returned after {returned:?}, \ + the worker finished after {finished:?}", + audio == gst::ClockTime::MAX + ); + assert_eq!( + error.to_string(), + format!("backend error: {}", crate::portal::PIPEWIRE_LOST) + ); + } + } + + #[test] + fn pipeline_errors_fail_the_recording() { + let mut recording = TestRecording::start(0); + gst::element_error!( + recording.pipeline, + gst::StreamError::Failed, + ("injected capture failure") + ); + let error = recording + .worker + .take() + .unwrap() + .join() + .unwrap() + .unwrap_err(); + assert!(error.to_string().contains("injected capture failure")); + } + + fn downstream_buffers(pipeline: &gst::Pipeline) -> Arc { + let count = Arc::new(AtomicU64::new(0)); + let counted = count.clone(); + pipeline + .by_name("sink") + .unwrap() + .static_pad("sink") + .unwrap() + .add_probe(gst::PadProbeType::BUFFER, move |_, _| { + counted.fetch_add(1, Ordering::SeqCst); + gst::PadProbeReturn::Ok + }); + count + } + + fn settle(pipeline: &gst::Pipeline) { + pipeline + .bus() + .unwrap() + .timed_pop_filtered(gst::ClockTime::from_seconds(1), &[gst::MessageType::Eos]); + let _ = pipeline.set_state(gst::State::Null); + } + + fn first_unfinished_frame(corrupted_offset: u64) -> (Option, u64) { + gst::init().unwrap(); + let pipeline = PipelineGuard(gst::parse::launch("videotestsrc num-buffers=3 name=source ! video/x-raw,width=320,height=180 ! fakesink name=sink").unwrap().downcast::().unwrap()); + let delivered = downstream_buffers(&pipeline.0); + let source = pipeline.0.by_name("source").unwrap(); + source + .static_pad("src") + .unwrap() + .add_probe(gst::PadProbeType::BUFFER, move |_, info| { + if let Some(gst::PadProbeData::Buffer(buffer)) = info.data.as_mut() { + if buffer.offset() == corrupted_offset { + buffer.make_mut().set_flags(gst::BufferFlags::CORRUPTED); + } + } + gst::PadProbeReturn::Ok + }); + attach_capture_probe( + &source, + &element("capsfilter").unwrap(), + Resolution::Native, + Mode { + kind: encoding::Kind::Software, + factory: "x264enc", + dmabuf: false, + }, + Arc::new(Counters::default()), + ); + pipeline.0.set_state(gst::State::Playing).unwrap(); + let message = pipeline + .0 + .bus() + .unwrap() + .timed_pop_filtered( + gst::ClockTime::from_seconds(5), + &[gst::MessageType::Error, gst::MessageType::Eos], + ) + .unwrap(); + let error = match message.view() { + gst::MessageView::Error(error) => Some(error.error().to_string()), + _ => None, + }; + settle(&pipeline.0); + (error, delivered.load(Ordering::SeqCst)) + } + + #[test] + fn an_unfinished_first_frame_fails_the_attempt_before_encoding() { + let (error, delivered) = first_unfinished_frame(0); + let error = error.expect("an unfinished first frame must fail"); + assert!(error.contains(UNFINISHED_FIRST_FRAME), "{error}"); + assert_eq!(delivered, 0, "no frame may follow a rejected first frame"); + assert_eq!(first_unfinished_frame(1), (None, 3)); + } + + fn source_reconfigures_while_scaling_to_720p(keep_source: bool) -> (u64, (i32, i32)) { + gst::init().unwrap(); + let pipeline = PipelineGuard(gst::parse::launch("videotestsrc num-buffers=10 name=source ! video/x-raw,width=1920,height=1080,framerate=30/1 ! queue name=queue ! videoconvert ! videoscale add-borders=true ! capsfilter name=output ! fakesink name=sink").unwrap().downcast::().unwrap()); + let source = pipeline.0.by_name("source").unwrap(); + let output = pipeline.0.by_name("output").unwrap(); + let mode = Mode { + kind: encoding::Kind::Software, + factory: "x264enc", + dmabuf: false, + }; + output.set_property("caps", mode.caps(None)); + let configured = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let reconfigures = Arc::new(AtomicU64::new(0)); + let (seen, counted) = (configured.clone(), reconfigures.clone()); + source.static_pad("src").unwrap().add_probe( + gst::PadProbeType::EVENT_DOWNSTREAM | gst::PadProbeType::EVENT_UPSTREAM, + move |_, info| { + match info.event().map(|event| event.type_()) { + Some(gst::EventType::Caps) => seen.store(true, Ordering::SeqCst), + Some(gst::EventType::Reconfigure) if seen.load(Ordering::SeqCst) => { + counted.fetch_add(1, Ordering::SeqCst); + } + _ => {} + } + gst::PadProbeReturn::Ok + }, + ); + attach_capture_probe( + &source, + &output, + Resolution::R720p, + mode, + Arc::new(Counters::default()), + ); + if keep_source { + keep_source_configuration(&pipeline.0.by_name("queue").unwrap()); + } + pipeline.0.set_state(gst::State::Playing).unwrap(); + let message = pipeline + .0 + .bus() + .unwrap() + .timed_pop_filtered( + gst::ClockTime::from_seconds(5), + &[gst::MessageType::Error, gst::MessageType::Eos], + ) + .unwrap(); + assert!( + matches!(message.view(), gst::MessageView::Eos(_)), + "{message:?}" + ); + let caps = pipeline + .0 + .by_name("sink") + .unwrap() + .static_pad("sink") + .unwrap() + .current_caps() + .unwrap(); + let structure = caps.structure(0).unwrap(); + ( + reconfigures.load(Ordering::SeqCst), + ( + structure.get::("width").unwrap(), + structure.get::("height").unwrap(), + ), + ) + } + + #[test] + fn setting_the_output_canvas_does_not_renegotiate_the_source() { + assert_eq!( + source_reconfigures_while_scaling_to_720p(true), + (0, (1280, 720)) + ); + let (reconfigures, canvas) = source_reconfigures_while_scaling_to_720p(false); + assert!(reconfigures > 0); + assert_eq!(canvas, (1280, 720)); + } + + fn resize_without_source_allocation( + converter: &str, + ) -> ( + Vec>, + u64, + u64, + ) { + gst::init().unwrap(); + let pipeline = PipelineGuard(gst::parse::launch(&format!("queue name=source ! queue name=queue ! {converter} ! capsfilter name=output ! fakesink name=sink")).unwrap().downcast::().unwrap()); + let delivered = downstream_buffers(&pipeline.0); + let allocations = Arc::new(AtomicU64::new(0)); + let counted = allocations.clone(); + pipeline + .0 + .by_name("queue") + .unwrap() + .static_pad("sink") + .unwrap() + .add_probe( + gst::PadProbeType::QUERY_DOWNSTREAM | gst::PadProbeType::PUSH, + move |_, info| { + if info + .query() + .is_some_and(|query| matches!(query.view(), gst::QueryView::Allocation(_))) + { + counted.fetch_add(1, Ordering::SeqCst); + } + gst::PadProbeReturn::Ok + }, + ); + let mode = Mode { + kind: if converter == "vapostproc" { + encoding::Kind::Va + } else { + encoding::Kind::Software + }, + factory: "x264enc", + dmabuf: false, + }; + let output = pipeline.0.by_name("output").unwrap(); + output.set_property("caps", mode.caps(None)); + attach_capture_probe( + &pipeline.0.by_name("source").unwrap(), + &output, + Resolution::Native, + mode, + Arc::new(Counters::default()), + ); + pipeline.0.set_state(gst::State::Playing).unwrap(); + let source = gst::Pad::builder(gst::PadDirection::Src).build(); + source.set_active(true).unwrap(); + source + .link( + &pipeline + .0 + .by_name("source") + .unwrap() + .static_pad("sink") + .unwrap(), + ) + .unwrap(); + assert!(source.push_event(gst::event::StreamStart::new("capture"))); + let mut results = Vec::new(); + let mut pts = gst::ClockTime::ZERO; + for (index, (width, height)) in [(1920, 1080), (1920, 1080), (1280, 720), (1280, 720)] + .into_iter() + .enumerate() + { + if index == 0 || index == 2 { + let caps = + format!("video/x-raw,format=RGBx,width={width},height={height},framerate=0/1") + .parse::() + .unwrap(); + assert!(source.push_event(gst::event::Caps::new(&caps))); + if index == 0 { + assert!(source + .push_event(gst::event::Segment::new(&gst::FormattedSegment::< + gst::ClockTime, + >::new()))); + source.peer_query(&mut gst::query::Allocation::new(Some(&caps), true)); + } + } + if index == 2 { + allocations.store(0, Ordering::SeqCst); + } + let mut buffer = gst::Buffer::with_size((width * height * 4) as usize).unwrap(); + buffer.get_mut().unwrap().set_pts(pts); + pts += gst::ClockTime::from_mseconds(33); + results.push(source.push(buffer)); + } + assert!(source.push_event(gst::event::Eos::new())); + pipeline.0.bus().unwrap().timed_pop_filtered( + gst::ClockTime::from_seconds(5), + &[gst::MessageType::Eos, gst::MessageType::Error], + ); + ( + results, + delivered.load(Ordering::SeqCst), + allocations.load(Ordering::SeqCst), + ) + } + + #[test] + fn a_source_resize_renegotiates_converter_allocation() { + let (results, delivered, allocations) = + resize_without_source_allocation("videoconvert ! videoscale add-borders=true"); + assert!(results.iter().all(|result| result.is_ok()), "{results:?}"); + assert_eq!((delivered, allocations), (4, 1)); + if gst::ElementFactory::find("vapostproc").is_some() { + let (results, delivered, _) = resize_without_source_allocation("vapostproc"); + assert!(results.iter().all(|result| result.is_ok()), "{results:?}"); + assert_eq!(delivered, 4); + } + } + + #[test] + fn rejects_cpu_buffers_before_pixel_processing() { + gst::init().unwrap(); + let pipeline = PipelineGuard(gst::parse::launch("videotestsrc num-buffers=3 name=source ! video/x-raw,width=320,height=180 ! fakesink name=sink").unwrap().downcast::().unwrap()); + let delivered = downstream_buffers(&pipeline.0); + pipeline + .0 + .by_name("source") + .unwrap() + .static_pad("src") + .unwrap() + .add_probe(gst::PadProbeType::BUFFER, |_, info| { + if let Some(gst::PadProbeData::Buffer(buffer)) = info.data.as_mut() { + if buffer.offset() >= 1 { + let size = buffer.size(); + let path = std::env::temp_dir().join(format!( + "wrec-dmabuf-{}-{}", + std::process::id(), + buffer.offset() + )); + let file = std::fs::File::options() + .read(true) + .write(true) + .create_new(true) + .open(&path) + .unwrap(); + std::fs::remove_file(&path).unwrap(); + file.set_len(size as u64).unwrap(); + let memory = unsafe { + gstreamer_allocators::DmaBufAllocator::new().alloc(file, size) + } + .unwrap(); + buffer.make_mut().replace_all_memory(memory); + } + } + gst::PadProbeReturn::Ok + }); + let output = element("capsfilter").unwrap(); + attach_capture_probe( + &pipeline.0.by_name("source").unwrap(), + &output, + Resolution::Native, + Mode { + kind: encoding::Kind::Va, + factory: "vah264enc", + dmabuf: true, + }, + Arc::new(Counters::default()), + ); + pipeline.0.set_state(gst::State::Playing).unwrap(); + let message = pipeline + .0 + .bus() + .unwrap() + .timed_pop_filtered(gst::ClockTime::from_seconds(5), &[gst::MessageType::Error]) + .unwrap(); + let gst::MessageView::Error(error) = message.view() else { + panic!("expected error") + }; + assert!(error.error().to_string().contains("non-DMA-BUF")); + settle(&pipeline.0); + assert_eq!(delivered.load(Ordering::SeqCst), 0); + } +} diff --git a/crates/linux/src/portal.rs b/crates/linux/src/portal.rs new file mode 100644 index 0000000..f2c3cee --- /dev/null +++ b/crates/linux/src/portal.rs @@ -0,0 +1,380 @@ +use crate::backend; +use ashpd::{ + desktop::{ + screencast::{CursorMode, Screencast, SourceType}, + PersistMode, Session, + }, + WindowIdentifier, +}; +use domain::{CaptureSourceKind, CaptureTarget, RecorderError, Result}; +use futures_util::{Stream, StreamExt}; +use gstreamer::{self as gst, prelude::*}; +use std::{ + os::fd::{AsRawFd, OwnedFd}, + pin::Pin, + sync::{mpsc, Arc, OnceLock}, + thread, + time::Duration, +}; +use tokio::sync::{oneshot, watch}; + +const PORTAL: &str = "org.freedesktop.portal.Desktop"; +pub(crate) const PIPEWIRE_LOST: &str = "The PipeWire connection closed, so the screen recording stopped. Restart PipeWire and WirePlumber (or the desktop session), then record again."; +pub(crate) const NODE_LOST: &str = "The desktop's screen-cast stream ended because its PipeWire node was removed, so the screen recording stopped. If the portal backend (such as xdg-desktop-portal-wlr) or the compositor exited, restart it, then record again."; +pub(crate) const PORTAL_LOST: &str = "The desktop portal (org.freedesktop.portal.Desktop) exited, so the screen recording stopped. Restart xdg-desktop-portal and its desktop backend, then record again."; + +#[cfg(test)] +#[path = "portal_tests.rs"] +mod tests; + +pub fn check_desktop() -> Result<()> { + if std::env::var_os("WAYLAND_DISPLAY").is_none() + || std::env::var_os("XDG_RUNTIME_DIR").is_none() + { + return Err(backend("Wayland capture requires WAYLAND_DISPLAY and XDG_RUNTIME_DIR from the logged-in desktop session.")); + } + Ok(()) +} + +pub(crate) fn runtime() -> Result<&'static tokio::runtime::Runtime> { + // ashpd caches its D-Bus connection globally. Its I/O tasks must outlive + // individual target queries and recordings, even when called on new threads. + static RUNTIME: std::sync::OnceLock> = + std::sync::OnceLock::new(); + RUNTIME + .get_or_init(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| error.to_string()) + }) + .as_ref() + .map_err(backend) +} + +pub fn list_targets() -> Result> { + check_desktop()?; + runtime()?.block_on(async { + tokio::time::timeout(Duration::from_secs(5), async { + let portal = Screencast::new().await.map_err(backend)?; + let types = portal.available_source_types().await.map_err(backend)?; + let mut targets = Vec::new(); + for (source, kind, name) in [ + ( + SourceType::Monitor, + CaptureSourceKind::Display, + "Choose a display in the desktop picker", + ), + ( + SourceType::Window, + CaptureSourceKind::Window, + "Choose a window in the desktop picker", + ), + ] { + if types.contains(source) { + targets.push(CaptureTarget { + id: 0, + kind, + name: name.into(), + }); + } + } + Ok(targets) + }) + .await + .map_err(|_| backend("ScreenCast portal did not respond within 5s"))? + }) +} + +pub(crate) fn validate_target(target: &CaptureTarget) -> Result<()> { + if target.id != 0 { + return Err(backend("Wayland targets use display:0 or window:0 to open the desktop picker; application names and native window IDs are unavailable.")); + } + Ok(()) +} + +pub(crate) struct Capture { + node: u32, + pub session: Session<'static, Screencast<'static>>, + // Keep the portal connection alive until the pipeline and session close. + portal: Screencast<'static>, + lost: Arc>, +} + +pub(crate) struct PipeWireStream { + pub fd: OwnedFd, + pub node: u32, + lost: Arc>, +} + +impl PipeWireStream { + fn lose(&self, message: &'static str) { + let _ = self.lost.set(message); + } + + pub(crate) fn lost(&self) -> Option<&'static str> { + if let Some(message) = self.lost.get() { + return Some(message); + } + let mut remote = libc::pollfd { + fd: self.fd.as_raw_fd(), + events: libc::POLLRDHUP, + revents: 0, + }; + let ready = unsafe { libc::poll(&mut remote, 1, 0) }; + let closed = libc::POLLHUP | libc::POLLRDHUP | libc::POLLERR | libc::POLLNVAL; + (ready > 0 && remote.revents & closed != 0).then_some(PIPEWIRE_LOST) + } +} + +pub(crate) enum NodeWatch { + Watching { + stop: mpsc::Sender<()>, + done: mpsc::Receiver<()>, + }, + Unavailable(String), +} + +impl NodeWatch { + pub(crate) async fn start(remote: PipeWireStream) -> Self { + match Self::watch(remote).await { + Ok(watch) => watch, + Err(error) => Self::Unavailable(error.to_string()), + } + } + + async fn watch(remote: PipeWireStream) -> Result { + let provider = pipewire_provider()?; + provider.set_property("fd", remote.fd.as_raw_fd()); + let bus = provider.bus(); + let (stop, stops) = mpsc::channel(); + let (finished, done) = mpsc::channel(); + let (ready, synced) = oneshot::channel(); + thread::Builder::new() + .name("wrec-pipewire-node".into()) + .spawn(move || { + let present = provider.start().is_ok() + && provider + .devices() + .iter() + .any(|device| node_id(device) == Some(remote.node)); + let _ = ready.send(present); + while present && matches!(stops.try_recv(), Err(mpsc::TryRecvError::Empty)) { + let removed = bus + .timed_pop_filtered( + gst::ClockTime::from_mseconds(100), + &[gst::MessageType::DeviceRemoved], + ) + .is_some_and(|message| match message.view() { + gst::MessageView::DeviceRemoved(removed) => { + node_id(&removed.device()) == Some(remote.node) + } + _ => false, + }); + if removed { + remote.lose(NODE_LOST); + break; + } + } + // Never stopped; see pipewire_provider. Drop its later messages. + bus.set_flushing(true); + std::mem::forget((provider, remote)); + let _ = finished.send(()); + }) + .map_err(backend)?; + let watch = Self::Watching { stop, done }; + match tokio::time::timeout(Duration::from_secs(5), synced).await { + Ok(Ok(true)) => Ok(watch), + Ok(_) => Err(backend( + "the selected node was not visible on a second PipeWire connection", + )), + Err(_) => Err(backend("PipeWire did not list its nodes within 5s")), + } + } +} + +impl Drop for NodeWatch { + fn drop(&mut self) { + if let Self::Watching { stop, done } = self { + let _ = stop.send(()); + let _ = done.recv_timeout(Duration::from_secs(3)); + } + } +} + +// A new PipeWire device provider. Callers start it and then keep it, and the fd +// it connects through, until the process exits. A capture worker records once, +// so that is one idle connection. Before PipeWire 0.3.78 (Debian 12 ships +// 0.3.65) the provider cannot be torn down safely. stop() destroys its registry +// without the PipeWire loop lock (fixed upstream in b0a7e4a2 and 140374d2), so +// an event arriving then corrupts the heap. stop() and probing also clear the +// provider's connection before draining its events, so a late node update +// dereferences NULL. The plugin shares connections by fd number, so the fd must +// stay open while the connection lives. DeviceProviderFactory::by_name returns +// one provider for the whole process, so each caller gets a new one instead. +fn pipewire_provider() -> Result { + gst::init().map_err(backend)?; + let factory = gst::DeviceProviderFactory::find("pipewiredeviceprovider") + .ok_or_else(|| backend("GStreamer's PipeWire device provider is not installed"))? + .load() + .map_err(backend)? + .downcast::() + .map_err(|_| backend("GStreamer's PipeWire device provider did not load"))?; + gst::glib::Object::with_type(factory.device_provider_type()) + .downcast::() + .map_err(|_| backend("GStreamer's PipeWire device provider did not load")) +} + +fn node_id(device: &gst::Device) -> Option { + device + .has_property("id", Some(gst::glib::Type::U32)) + .then(|| device.property::("id")) +} + +pub(crate) enum Ending { + Closed, + PortalLost, +} + +pub(crate) struct Endings<'a> { + closed: Pin + 'a>>, + owners: ashpd::zbus::proxy::OwnerChangedStream<'a>, + lost: Arc>, +} + +impl Endings<'_> { + pub(crate) async fn next(&mut self) -> Ending { + if self.lost.get().is_some() { + return Ending::PortalLost; + } + tokio::select! { + _ = self.closed.next() => Ending::Closed, + _ = self.owners.next() => { + let _ = self.lost.set(PORTAL_LOST); + Ending::PortalLost + } + } + } +} + +impl Capture { + pub async fn endings(&self) -> Result> { + let closed = self.session.receive_closed().await.map_err(backend)?; + let owners = self.portal.receive_owner_changed().await.map_err(backend)?; + let present = ashpd::zbus::fdo::DBusProxy::new(self.portal.connection()) + .await + .map_err(backend)? + .name_has_owner(PORTAL.try_into().map_err(backend)?) + .await + .map_err(backend)?; + if !present { + let _ = self.lost.set(PORTAL_LOST); + } + Ok(Endings { + closed: Box::pin(closed.map(|_| ())), + owners, + lost: self.lost.clone(), + }) + } + + pub async fn connect(&self) -> Result { + let fd = tokio::time::timeout( + Duration::from_secs(5), + self.portal.open_pipe_wire_remote(&self.session), + ) + .await + .map_err(|_| backend("Opening the PipeWire remote timed out"))? + .map_err(backend)?; + Ok(PipeWireStream { + fd, + node: self.node, + lost: self.lost.clone(), + }) + } + + pub async fn close(&self) { + let _ = tokio::time::timeout(Duration::from_secs(3), self.session.close()).await; + } +} + +pub(crate) async fn open( + target: &CaptureTarget, + cursor: bool, + stop: &mut watch::Receiver, +) -> Result { + if *stop.borrow() { + return Err(RecorderError::Cancelled); + } + let portal = tokio::time::timeout(Duration::from_secs(5), Screencast::new()) + .await + .map_err(|_| backend("ScreenCast portal connection timed out"))? + .map_err(backend)?; + let session = tokio::time::timeout(Duration::from_secs(5), portal.create_session()) + .await + .map_err(|_| backend("ScreenCast session creation timed out"))? + .map_err(backend)?; + let selected = async { + let source = match target.kind { + CaptureSourceKind::Display => SourceType::Monitor, + CaptureSourceKind::Window => SourceType::Window, + }; + let cursor = if cursor { + CursorMode::Embedded + } else { + CursorMode::Hidden + }; + if !portal + .available_cursor_modes() + .await + .map_err(backend)? + .contains(cursor) + { + return Err(backend( + "The desktop portal cannot provide the requested cursor mode.", + )); + } + portal + .select_sources( + &session, + cursor, + source.into(), + false, + None, + PersistMode::DoNot, + ) + .await + .map_err(backend)? + .response() + .map_err(backend)?; + let response = portal + .start(&session, &WindowIdentifier::default()) + .await + .map_err(backend)? + .response() + .map_err(backend)?; + if response.streams().len() != 1 { + return Err(backend( + "The desktop portal must return exactly one capture stream.", + )); + } + let node = response.streams()[0].pipe_wire_node_id(); + Ok(node) + }; + let result = tokio::select! { + result = selected => result, + _ = stop.changed() => Err(RecorderError::Cancelled), + _ = tokio::time::sleep(Duration::from_secs(120)) => Err(backend("Desktop source selection timed out after 120s; start again and choose a source.")), + }; + match result { + Ok(node) => Ok(Capture { + node, + session, + portal, + lost: Arc::new(OnceLock::new()), + }), + Err(error) => { + let _ = tokio::time::timeout(Duration::from_secs(3), session.close()).await; + Err(error) + } + } +} diff --git a/crates/linux/src/portal_tests.rs b/crates/linux/src/portal_tests.rs new file mode 100644 index 0000000..a749801 --- /dev/null +++ b/crates/linux/src/portal_tests.rs @@ -0,0 +1,443 @@ +use super::*; +use std::{ + collections::HashMap, + io::{Read, Write}, + os::{fd::AsRawFd, unix::net::UnixStream}, + sync::{ + atomic::{AtomicU32, Ordering}, + Arc, + }, +}; +use zbus::{ + message::Header, + zvariant::{OwnedObjectPath, OwnedValue, Value}, + Connection, +}; + +#[derive(Clone, Default)] +struct MockPortal { + mode: Arc, + closes: Arc, + remotes: Arc, + peers: Arc>>, + sessions: Arc>>, + selections: Arc>>, +} + +struct MockSession(Arc); + +#[zbus::interface(name = "org.freedesktop.portal.Session")] +impl MockSession { + fn close(&self) { + self.0.fetch_add(1, Ordering::Relaxed); + } +} + +fn path( + header: &Header<'_>, + options: &HashMap, + session: bool, +) -> OwnedObjectPath { + let sender = header + .sender() + .unwrap() + .as_str() + .trim_start_matches(':') + .replace('.', "_"); + let (kind, key) = if session { + ("session", "session_handle_token") + } else { + ("request", "handle_token") + }; + let token = <&str>::try_from(options.get(key).unwrap()).unwrap(); + format!("/org/freedesktop/portal/desktop/{kind}/{sender}/{token}") + .try_into() + .unwrap() +} + +async fn response( + connection: &Connection, + request: &OwnedObjectPath, + code: u32, + data: HashMap<&str, Value<'_>>, +) { + connection + .emit_signal( + None::<&str>, + request, + "org.freedesktop.portal.Request", + "Response", + &(code, data), + ) + .await + .unwrap(); +} + +#[zbus::interface(name = "org.freedesktop.portal.ScreenCast")] +impl MockPortal { + #[zbus(property, name = "version")] + fn version(&self) -> u32 { + 5 + } + #[zbus(property)] + fn available_source_types(&self) -> u32 { + 3 + } + #[zbus(property)] + fn available_cursor_modes(&self) -> u32 { + 3 + } + + async fn create_session( + &self, + options: HashMap, + #[zbus(header)] header: Header<'_>, + #[zbus(connection)] connection: &Connection, + ) -> OwnedObjectPath { + let session = path(&header, &options, true); + let request = path(&header, &options, false); + self.sessions.lock().unwrap().push(session.clone()); + connection + .object_server() + .at(session.clone(), MockSession(self.closes.clone())) + .await + .unwrap(); + response( + connection, + &request, + 0, + HashMap::from([("session_handle", Value::from(session.as_str()))]), + ) + .await; + request + } + + async fn select_sources( + &self, + _session: OwnedObjectPath, + options: HashMap, + #[zbus(header)] header: Header<'_>, + #[zbus(connection)] connection: &Connection, + ) -> OwnedObjectPath { + self.selections.lock().unwrap().push(( + u32::try_from(&options["types"]).unwrap(), + u32::try_from(&options["cursor_mode"]).unwrap(), + bool::try_from(&options["multiple"]).unwrap(), + )); + let request = path(&header, &options, false); + response(connection, &request, 0, HashMap::new()).await; + request + } + + async fn start( + &self, + _session: OwnedObjectPath, + _parent: String, + options: HashMap, + #[zbus(header)] header: Header<'_>, + #[zbus(connection)] connection: &Connection, + ) -> OwnedObjectPath { + let request = path(&header, &options, false); + match self.mode.load(Ordering::Relaxed) { + 0 => { + let streams = vec![(42u32, HashMap::<&str, Value<'_>>::new())]; + response( + connection, + &request, + 0, + HashMap::from([("streams", Value::from(streams))]), + ) + .await; + } + 1 => response(connection, &request, 1, HashMap::new()).await, + _ => {} // Leave the picker pending until wrec cancels it. + } + request + } + + fn open_pipe_wire_remote( + &self, + _session: OwnedObjectPath, + _options: HashMap, + ) -> zbus::zvariant::OwnedFd { + self.remotes.fetch_add(1, Ordering::Relaxed); + let (socket, peer) = UnixStream::pair().unwrap(); + self.peers.lock().unwrap().push(peer); + let fd: OwnedFd = socket.into(); + fd.into() + } +} + +#[test] +#[ignore = "requires an isolated D-Bus: dbus-run-session -- cargo test -p linux portal_roundtrip -- --ignored"] +fn portal_roundtrip_and_cancellation_close_sessions() { + std::env::set_var("WAYLAND_DISPLAY", "wayland-test"); + std::env::set_var("XDG_RUNTIME_DIR", std::env::temp_dir()); + let mock = MockPortal::default(); + runtime().unwrap().block_on(async { + let server = zbus::connection::Builder::session() + .unwrap() + .name("org.freedesktop.portal.Desktop") + .unwrap() + .serve_at("/org/freedesktop/portal/desktop", mock.clone()) + .unwrap() + .build() + .await + .unwrap(); + tokio::task::spawn_blocking(|| { + assert!(!list_targets().unwrap().is_empty()); + assert!(!list_targets().unwrap().is_empty()); + }) + .await + .unwrap(); + let target = CaptureTarget { + id: 0, + name: "picker".into(), + kind: CaptureSourceKind::Display, + }; + let (_stop, mut stopped) = watch::channel(false); + let capture = + tokio::time::timeout(Duration::from_secs(5), open(&target, true, &mut stopped)) + .await + .unwrap() + .unwrap(); + assert_eq!(capture.connect().await.unwrap().node, 42); + assert_eq!(capture.connect().await.unwrap().node, 42); + assert_eq!(mock.remotes.load(Ordering::Relaxed), 2); + // Retrying the encoder opens a new remote, not a second source picker. + assert_eq!(mock.selections.lock().unwrap().len(), 1); + capture.close().await; + assert_eq!(mock.closes.load(Ordering::Relaxed), 1); + assert_eq!(*mock.selections.lock().unwrap(), vec![(1, 2, false)]); + + mock.mode.store(1, Ordering::Relaxed); + let denied = + tokio::time::timeout(Duration::from_secs(5), open(&target, false, &mut stopped)) + .await + .unwrap(); + assert!(denied.is_err()); + assert_eq!(mock.closes.load(Ordering::Relaxed), 2); + + mock.mode.store(2, Ordering::Relaxed); + let (stop, mut stopped) = watch::channel(false); + let cancelled = open(&target, true, &mut stopped); + let cancel = async { + tokio::time::sleep(Duration::from_millis(200)).await; + stop.send(true).unwrap(); + }; + let (result, ()) = tokio::join!(cancelled, cancel); + assert!(matches!(result, Err(RecorderError::Cancelled))); + assert_eq!(mock.closes.load(Ordering::Relaxed), 3); + + mock.mode.store(0, Ordering::Relaxed); + let (_stop, mut stopped) = watch::channel(false); + let capture = open(&target, true, &mut stopped).await.unwrap(); + let mut endings = capture.endings().await.unwrap(); + let stream = capture.connect().await.unwrap(); + assert_eq!(stream.lost(), None); + let peer = mock.peers.lock().unwrap().pop().unwrap(); + (&peer).write_all(b"pipewire").unwrap(); + drop(peer); + assert_eq!(stream.lost(), Some(PIPEWIRE_LOST)); + let mut unread = Vec::new(); + UnixStream::from(stream.fd.try_clone().unwrap()) + .read_to_end(&mut unread) + .unwrap(); + assert_eq!( + unread, b"pipewire", + "loss detection must not consume PipeWire data" + ); + + let session = mock.sessions.lock().unwrap().last().unwrap().clone(); + server + .emit_signal( + None::<&str>, + &session, + "org.freedesktop.portal.Session", + "Closed", + &(HashMap::<&str, Value<'_>>::new(),), + ) + .await + .unwrap(); + assert!(matches!( + tokio::time::timeout(Duration::from_secs(5), endings.next()) + .await + .unwrap(), + Ending::Closed + )); + capture.close().await; + + let capture = open(&target, true, &mut stopped).await.unwrap(); + let mut endings = capture.endings().await.unwrap(); + let stream = capture.connect().await.unwrap(); + assert_eq!(stream.lost(), None); + server.release_name(PORTAL).await.unwrap(); + assert!(matches!( + tokio::time::timeout(Duration::from_secs(5), endings.next()) + .await + .unwrap(), + Ending::PortalLost + )); + assert_eq!(stream.lost(), Some(PORTAL_LOST)); + assert!(matches!(endings.next().await, Ending::PortalLost)); + drop(endings); + capture.close().await; + + server.request_name(PORTAL).await.unwrap(); + let capture = open(&target, true, &mut stopped).await.unwrap(); + let stream = capture.connect().await.unwrap(); + assert_eq!(stream.lost(), None); + assert!(capture.endings().await.is_ok()); + capture.close().await; + }); +} + +struct PrivatePipeWire { + daemon: std::process::Child, + directory: std::path::PathBuf, +} + +impl PrivatePipeWire { + fn start() -> Self { + let directory = std::env::temp_dir().join(format!("wrec-pipewire-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let daemon = std::process::Command::new("pipewire") + .env_remove("DBUS_SESSION_BUS_ADDRESS") + .env("PIPEWIRE_RUNTIME_DIR", &directory) + .env("XDG_RUNTIME_DIR", &directory) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("install pipewire to run this test"); + let pipewire = Self { daemon, directory }; + for _ in 0..50 { + if pipewire.directory.join("pipewire-0").exists() { + return pipewire; + } + std::thread::sleep(Duration::from_millis(100)); + } + panic!("private PipeWire did not create its socket") + } + + fn connect(&self) -> OwnedFd { + UnixStream::connect(self.directory.join("pipewire-0")) + .unwrap() + .into() + } + + fn stream(&self, node: u32, lost: &Arc>) -> PipeWireStream { + PipeWireStream { + fd: self.connect(), + node, + lost: lost.clone(), + } + } + + fn node(&self, name: &str) -> u32 { + for _ in 0..50 { + let fd = self.connect(); + let provider = pipewire_provider().unwrap(); + provider.set_property("fd", fd.as_raw_fd()); + provider.start().unwrap(); + let found = provider.devices().iter().find_map(|device| { + let properties = device.properties()?; + (properties.get::("node.name").ok()? == name) + .then(|| node_id(device)) + .flatten() + }); + // Kept like NodeWatch's provider: see pipewire_provider. + std::mem::forget((provider, fd)); + if let Some(node) = found { + return node; + } + std::thread::sleep(Duration::from_millis(100)); + } + panic!("PipeWire node {name} did not appear") + } +} + +impl Drop for PrivatePipeWire { + fn drop(&mut self) { + let _ = self.daemon.kill(); + let _ = self.daemon.wait(); + let _ = std::fs::remove_dir_all(&self.directory); + } +} + +fn wait_for(lost: &Arc>) -> Option<&'static str> { + for _ in 0..50 { + if let Some(message) = lost.get() { + return Some(message); + } + std::thread::sleep(Duration::from_millis(100)); + } + None +} + +#[test] +#[ignore = "requires the pipewire daemon and GStreamer PipeWire plugin: cargo test -p linux node_watch -- --ignored"] +fn node_watch_reports_removal_absence_and_stops_after_daemon_loss() { + gst::init().unwrap(); + let mut pipewire = PrivatePipeWire::start(); + let source = gst::parse::launch( + "videotestsrc is-live=true ! pipewiresink name=sink client-name=wrec-node-watch", + ) + .unwrap() + .downcast::() + .unwrap(); + let sink_fd = pipewire.connect(); + source + .by_name("sink") + .unwrap() + .set_property("fd", sink_fd.as_raw_fd()); + source.set_state(gst::State::Playing).unwrap(); + let node = pipewire.node("wrec-node-watch"); + + let lost = Arc::new(OnceLock::new()); + let watch = runtime() + .unwrap() + .block_on(NodeWatch::start(pipewire.stream(node, &lost))); + assert!(matches!(watch, NodeWatch::Watching { .. })); + std::thread::sleep(Duration::from_millis(500)); + assert_eq!( + lost.get(), + None, + "a live, idle node must not be reported lost" + ); + source.set_state(gst::State::Null).unwrap(); + assert_eq!(wait_for(&lost), Some(NODE_LOST)); + let at = std::time::Instant::now(); + drop(watch); + assert!(at.elapsed() < Duration::from_secs(1), "{:?}", at.elapsed()); + + let missing = Arc::new(OnceLock::new()); + let watch = runtime() + .unwrap() + .block_on(NodeWatch::start(pipewire.stream(node, &missing))); + assert!(matches!(watch, NodeWatch::Unavailable(_))); + assert_eq!(missing.get(), None); + drop(watch); + + let restarted_fd = pipewire.connect(); + source + .by_name("sink") + .unwrap() + .set_property("fd", restarted_fd.as_raw_fd()); + source.set_state(gst::State::Playing).unwrap(); + let node = pipewire.node("wrec-node-watch"); + let remote = Arc::new(OnceLock::new()); + let watch = runtime() + .unwrap() + .block_on(NodeWatch::start(pipewire.stream(node, &remote))); + assert!(matches!(watch, NodeWatch::Watching { .. })); + let stream = pipewire.stream(node, &remote); + assert_eq!(stream.lost(), None); + let _ = pipewire.daemon.kill(); + let _ = pipewire.daemon.wait(); + std::thread::sleep(Duration::from_millis(200)); + assert_eq!(stream.lost(), Some(PIPEWIRE_LOST)); + let at = std::time::Instant::now(); + drop(watch); + assert!(at.elapsed() < Duration::from_secs(4), "{:?}", at.elapsed()); + let _ = source.set_state(gst::State::Null); +} diff --git a/crates/linux/src/worker.rs b/crates/linux/src/worker.rs new file mode 100644 index 0000000..24840fa --- /dev/null +++ b/crates/linux/src/worker.rs @@ -0,0 +1,977 @@ +use crate::{backend, capture, Command}; +use domain::{CaptureTarget, RecorderEvent, RecorderSettings, RecordingSession, Result}; +use serde::{Deserialize, Serialize}; +use std::{ + fs::File, + io::{BufRead, BufReader, Read, Write}, + os::{ + fd::{AsFd, AsRawFd}, + unix::process::CommandExt, + }, + path::Path, + process::{ChildStderr, ChildStdin, ChildStdout, ExitStatus, Stdio}, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc, Arc, Mutex, + }, + thread, + time::{Duration, Instant}, +}; +use tokio::sync::watch; + +pub const ARGUMENT: &str = "--wrec-capture-worker"; +pub(crate) const PROGRAM: &str = "/proc/self/exe"; +pub(crate) const EXIT_DEADLINE: Duration = Duration::from_secs(20); +const REPLY_TIMEOUT: Duration = Duration::from_secs(5); +const DRAIN_WAIT: Duration = Duration::from_secs(1); +const POLL: Duration = Duration::from_millis(100); +const MESSAGE_LIMIT: u64 = 1 << 20; +const STDERR_LINE_LIMIT: u64 = 4096; + +#[derive(Serialize, Deserialize)] +pub(crate) enum Request { + Start { + session: RecordingSession, + target: CaptureTarget, + settings: RecorderSettings, + }, + Pause, + Resume, + Stop, +} + +#[derive(Serialize, Deserialize)] +enum Message { + Event(RecorderEvent), + Reply(Result<()>), + Stopping, + // The movie is complete; the worker is cleaning up native resources. + Finalized, + CleaningAttempt, + CleanedAttempt, +} + +type Received = std::result::Result; + +pub(crate) struct Worker { + stdin: ChildStdin, + replies: mpsc::Receiver>, + sent: u64, + answered: u64, + stopping: mpsc::Sender<()>, + finished: Arc, +} + +impl Worker { + pub(crate) fn spawn( + program: &Path, + arguments: &[&str], + session: RecordingSession, + target: CaptureTarget, + settings: RecorderSettings, + events: mpsc::Sender, + deadline: Duration, + ) -> Result { + let mut command = std::process::Command::new(program); + command + .arg0("wrec-capture") + .args(arguments) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .process_group(0); + let parent = std::process::id(); + unsafe { + command.pre_exec(move || die_with_parent(parent)); + } + let (ready, launched) = mpsc::sync_channel(1); + let (replies_sender, replies) = mpsc::channel(); + let (stopping, stop_requests) = mpsc::channel(); + let finished = Arc::new(AtomicBool::new(false)); + let supervisor = Supervisor { + start: Request::Start { + session: session.clone(), + target, + settings, + }, + session, + events, + replies: replies_sender, + stop_requests, + finished: finished.clone(), + deadline, + }; + thread::Builder::new() + .name("wrec-capture-supervisor".into()) + .spawn(move || supervisor.run(command, ready)) + .map_err(|error| backend(format!("could not start the capture worker: {error}")))?; + let stdin = launched + .recv() + .map_err(|_| backend("could not start the capture worker"))??; + Ok(Self { + stdin, + replies, + sent: 0, + answered: 0, + stopping, + finished, + }) + } + + pub(crate) fn control(&mut self, request: Request) -> Result<()> { + write_line(&mut self.stdin, &request).map_err(|error| { + backend(format!( + "the capture worker is not accepting commands: {error}" + )) + })?; + self.sent += 1; + let deadline = Instant::now() + REPLY_TIMEOUT; + loop { + let reply = match self + .replies + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + { + Ok(reply) => reply, + Err(mpsc::RecvTimeoutError::Timeout) => { + return Err(backend("the capture worker did not reply within 5s")) + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err(backend("the capture worker exited before replying")) + } + }; + self.answered += 1; + if self.answered == self.sent { + return reply; + } + } + } + + pub(crate) fn stop(&mut self) { + let _ = write_line(&mut self.stdin, &Request::Stop); + let _ = self.stopping.send(()); + } + + pub(crate) fn finished(&self) -> bool { + self.finished.load(Ordering::SeqCst) + } +} + +fn die_with_parent(parent: u32) -> std::io::Result<()> { + if unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL as libc::c_ulong) } != 0 { + return Err(std::io::Error::last_os_error()); + } + if unsafe { libc::getppid() } as u32 != parent { + return Err(std::io::Error::from_raw_os_error(libc::ESRCH)); + } + Ok(()) +} + +fn write_line(writer: &mut impl Write, value: &impl Serialize) -> std::io::Result<()> { + let mut line = serde_json::to_vec(value)?; + line.push(b'\n'); + writer.write_all(&line)?; + writer.flush() +} + +fn set_nonblocking(stdin: &ChildStdin) -> std::io::Result<()> { + let descriptor = stdin.as_raw_fd(); + let flags = unsafe { libc::fcntl(descriptor, libc::F_GETFL) }; + if flags < 0 || unsafe { libc::fcntl(descriptor, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 + { + return Err(std::io::Error::last_os_error()); + } + Ok(()) +} + +struct Supervisor { + start: Request, + session: RecordingSession, + events: mpsc::Sender, + replies: mpsc::Sender>, + stop_requests: mpsc::Receiver<()>, + finished: Arc, + deadline: Duration, +} + +impl Supervisor { + fn run(self, mut command: std::process::Command, ready: mpsc::SyncSender>) { + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => { + let _ = ready.send(Err(backend(format!( + "could not start the capture worker: {error}" + )))); + return; + } + }; + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let stderr = child.stderr.take().unwrap(); + let launched = write_line(&mut stdin, &self.start) + .and_then(|()| set_nonblocking(&stdin)) + .and_then(|()| Ok((read_messages(stdout)?, last_lines(stderr)?))); + let (messages, last_error) = match launched { + Ok(readers) => readers, + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + let _ = ready.send(Err(backend(format!( + "could not start the capture worker: {error}" + )))); + return; + } + }; + if ready.send(Ok(stdin)).is_err() { + let _ = child.kill(); + let _ = child.wait(); + return; + } + let mut terminal = None; + let mut finalized = false; + let mut started = false; + let mut problem = None; + let mut output_open = true; + let mut exit_by: Option = None; + let mut cleanup_by: Option = None; + let mut drained_by: Option = None; + let mut status: Option = None; + loop { + let received = if output_open { + messages.recv_timeout(POLL) + } else { + thread::sleep(POLL); + Err(mpsc::RecvTimeoutError::Timeout) + }; + match received { + Ok(Ok(Message::Event(event))) => { + if matches!( + event, + RecorderEvent::Exited { .. } | RecorderEvent::Cancelled { .. } + ) { + terminal = Some(event); + exit_by.get_or_insert(Instant::now() + self.deadline); + } else { + started |= matches!(event, RecorderEvent::Started { .. }); + let _ = self.events.send(event); + } + } + Ok(Ok(Message::Reply(reply))) => { + let _ = self.replies.send(reply); + } + Ok(Ok(Message::Stopping)) => { + exit_by.get_or_insert(Instant::now() + self.deadline); + } + Ok(Ok(Message::Finalized)) => { + finalized = true; + exit_by.get_or_insert(Instant::now() + self.deadline); + } + Ok(Ok(Message::CleaningAttempt)) => { + cleanup_by.get_or_insert(Instant::now() + self.deadline); + } + Ok(Ok(Message::CleanedAttempt)) => { + cleanup_by = None; + } + Ok(Err(error)) => { + problem = Some(format!("capture worker sent an invalid message ({error})")); + let _ = child.kill(); + status = child.wait().ok(); + break; + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + output_open = false; + exit_by.get_or_insert(Instant::now() + self.deadline); + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + if !matches!( + self.stop_requests.try_recv(), + Err(mpsc::TryRecvError::Empty) + ) { + exit_by.get_or_insert(Instant::now() + self.deadline); + } + if status.is_none() { + status = child.try_wait().ok().flatten(); + } + if status.is_some() { + let drained = *drained_by.get_or_insert(Instant::now() + DRAIN_WAIT); + if !output_open || Instant::now() >= drained { + break; + } + } else if exit_by.is_some_and(|by| Instant::now() >= by) { + let _ = child.kill(); + status = child.wait().ok(); + if terminal.is_none() { + problem = Some(format!( + "capture worker did not exit within {}s", + self.deadline.as_secs() + )); + } + break; + } else if cleanup_by.is_some_and(|by| Instant::now() >= by) { + let _ = child.kill(); + status = child.wait().ok(); + if terminal.is_none() { + problem = Some(format!( + "capture worker did not finish stopping an encoder attempt within {}s", + self.deadline.as_secs() + )); + } + break; + } + } + drop(self.replies); + // The movie was complete before native cleanup hung or crashed. + if terminal.is_none() && finalized { + let problem = problem + .take() + .unwrap_or_else(|| "capture worker exited during cleanup".to_string()); + terminal = Some(RecorderEvent::Exited { + session_id: self.session.id, + success: true, + status: format!( + "recording finalized, but the {}", + problem.trim_start_matches("the ") + ), + }); + } + let event = match (terminal, problem) { + (Some(event), None) => event, + (_, problem) => { + let empty = + std::fs::metadata(&self.session.output_path).is_ok_and(|file| file.len() == 0); + if !started || empty { + let _ = std::fs::remove_file(&self.session.output_path); + } + let problem = + problem.unwrap_or_else(|| "capture worker exited unexpectedly".to_string()); + let status = status + .map_or_else(|| "unknown status".to_string(), |status| status.to_string()); + let detail = last_error.recv_timeout(DRAIN_WAIT).unwrap_or_default(); + RecorderEvent::Exited { + session_id: self.session.id, + success: false, + status: if detail.is_empty() { + format!("{problem} ({status})") + } else { + format!("{problem} ({status}): {detail}") + }, + } + } + }; + self.finished.store(true, Ordering::SeqCst); + let _ = self.events.send(event); + } +} + +fn read_messages(stdout: ChildStdout) -> std::io::Result> { + let (sender, receiver) = mpsc::channel(); + thread::Builder::new() + .name("wrec-capture-output".into()) + .spawn(move || { + let mut reader = BufReader::new(stdout); + let mut line = Vec::new(); + loop { + line.clear(); + match reader + .by_ref() + .take(MESSAGE_LIMIT) + .read_until(b'\n', &mut line) + { + Ok(0) => return, + Ok(_) => {} + Err(error) => { + let _ = sender.send(Err(error.to_string())); + return; + } + } + if !line.ends_with(b"\n") && (line.len() as u64) < MESSAGE_LIMIT { + return; + } + let message = serde_json::from_slice(&line).map_err(|error| error.to_string()); + let invalid = message.is_err(); + if sender.send(message).is_err() || invalid { + return; + } + } + })?; + Ok(receiver) +} + +fn last_lines(stderr: ChildStderr) -> std::io::Result> { + let (sender, receiver) = mpsc::channel(); + thread::Builder::new() + .name("wrec-capture-stderr".into()) + .spawn(move || { + let mut reader = BufReader::new(stderr); + let mut line = Vec::new(); + let mut previous = String::new(); + let mut last = String::new(); + loop { + line.clear(); + match reader + .by_ref() + .take(STDERR_LINE_LIMIT) + .read_until(b'\n', &mut line) + { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + let text = String::from_utf8_lossy(&line).trim().to_string(); + let _ = writeln!( + std::io::stderr(), + "{}", + String::from_utf8_lossy(&line).trim_end() + ); + if !text.is_empty() { + previous = std::mem::replace(&mut last, text); + } + } + let _ = sender.send(format!("{previous} {last}").trim().to_string()); + })?; + Ok(receiver) +} + +// Marks where the movie was finalized in the worker's event stream; it is +// sent to the daemon as Message::Finalized, never as a log. +const FINALIZED: &str = "\0wrec-capture-finalized"; + +pub fn run() -> ! { + unsafe { + libc::prctl(libc::PR_SET_NAME, c"wrec-capture".as_ptr() as libc::c_ulong); + } + let protocol = match std::io::stdout().as_fd().try_clone_to_owned() { + Ok(protocol) => File::from(protocol), + Err(error) => fail(format!("could not open the protocol stream: {error}")), + }; + if unsafe { libc::dup2(libc::STDERR_FILENO, libc::STDOUT_FILENO) } < 0 { + fail(format!( + "could not redirect stdout: {}", + std::io::Error::last_os_error() + )); + } + let output = Arc::new(Mutex::new(protocol)); + let mut line = String::new(); + let _ = std::io::stdin().read_line(&mut line); + let Ok(Request::Start { + session, + target, + settings, + }) = serde_json::from_str(&line) + else { + fail(format!("expected a start request, received {line:?}")); + }; + let (events, outgoing) = mpsc::channel(); + let (commands, receiver) = mpsc::sync_channel(1); + let (stop, _) = watch::channel(false); + let (pending, answers) = mpsc::channel::>>(); + let reply_output = output.clone(); + let control_stop = stop.clone(); + spawn("wrec-capture-replies", move || { + for answer in answers { + let reply = answer + .recv() + .unwrap_or_else(|_| Err(backend("recording stopped before replying"))); + send(&reply_output, &Message::Reply(reply)); + } + }); + spawn("wrec-capture-control", move || { + for line in std::io::stdin().lines() { + let Ok(line) = line else { break }; + match serde_json::from_str(&line) { + Ok(Request::Pause) => queue(&commands, &pending, Command::Pause), + Ok(Request::Resume) => queue(&commands, &pending, Command::Resume), + Ok(Request::Stop) => { + control_stop.send_replace(true); + } + _ => report(&format!("ignored an invalid request: {line:?}")), + } + } + control_stop.send_replace(true); + }); + let stopping_output = output.clone(); + let finalized = events.clone(); + let stopping = Arc::new(move |teardown: crate::pipeline::Teardown| { + let message = match teardown { + crate::pipeline::Teardown::Recording => Message::Stopping, + // Behind the events sent before it, such as lost media, so the + // daemon has them even if cleanup then crashes. + crate::pipeline::Teardown::Finalized => { + let _ = finalized.send(RecorderEvent::Log { + session_id: None, + message: FINALIZED.into(), + }); + return; + } + crate::pipeline::Teardown::AttemptStarted => Message::CleaningAttempt, + crate::pipeline::Teardown::AttemptFinished => Message::CleanedAttempt, + }; + send(&stopping_output, &message) + }); + let forwarder = spawn("wrec-capture-events", move || { + forward(outgoing, |message| send(&output, message)) + }); + capture(target, settings, session, events, receiver, stop, stopping); + let _ = forwarder.join(); + std::process::exit(0) +} + +// Writes the worker's events in the order they were sent, until the last one. +fn forward(outgoing: mpsc::Receiver, mut write: impl FnMut(&Message)) { + for event in outgoing { + let finished = matches!( + event, + RecorderEvent::Exited { .. } | RecorderEvent::Cancelled { .. } + ); + if matches!(&event, RecorderEvent::Log { message, .. } if message == FINALIZED) { + write(&Message::Finalized); + continue; + } + write(&Message::Event(event)); + if finished { + break; + } + } +} + +fn spawn(name: &str, body: impl FnOnce() + Send + 'static) -> thread::JoinHandle<()> { + thread::Builder::new() + .name(name.into()) + .spawn(body) + .unwrap_or_else(|error| fail(format!("could not start {name}: {error}"))) +} + +fn queue( + commands: &mpsc::SyncSender, + pending: &mpsc::Sender>>, + command: fn(mpsc::SyncSender>) -> Command, +) { + let (reply, answer) = mpsc::sync_channel(1); + if let Err(error) = commands.try_send(command(reply.clone())) { + let _ = reply.send(Err(backend(error))); + } + let _ = pending.send(answer); +} + +fn send(output: &Mutex, message: &Message) { + let mut output = output + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let _ = write_line(&mut *output, message); +} + +fn report(message: &str) { + let _ = writeln!(std::io::stderr(), "capture worker: {message}"); +} + +fn fail(message: String) -> ! { + report(&message); + std::process::exit(2) +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::{CaptureSourceKind, RecorderError}; + use std::sync::atomic::AtomicU64; + + const TEST_DEADLINE: Duration = Duration::from_secs(1); + + struct Fake { + worker: Option, + events: mpsc::Receiver, + session: RecordingSession, + } + + impl Fake { + fn start(script: &str) -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let id = NEXT.fetch_add(1, Ordering::Relaxed); + let session = RecordingSession { + id, + output_path: std::env::temp_dir() + .join(format!("wrec-worker-test-{}-{id}.mp4", std::process::id())), + }; + let (events, received) = mpsc::channel(); + let worker = Worker::spawn( + Path::new("/bin/sh"), + &[ + "-c", + &format!(r#"echo $$ > "$0.pid"; {script}"#), + session.output_path.to_str().unwrap(), + ], + session.clone(), + CaptureTarget { + id: 0, + name: "display".into(), + kind: CaptureSourceKind::Display, + }, + RecorderSettings::default(), + events, + TEST_DEADLINE, + ) + .unwrap(); + Self { + worker: Some(worker), + events: received, + session, + } + } + + fn worker(&mut self) -> &mut Worker { + self.worker.as_mut().unwrap() + } + + fn terminal(&self) -> RecorderEvent { + loop { + let event = self.events.recv_timeout(Duration::from_secs(10)).unwrap(); + if matches!( + event, + RecorderEvent::Exited { .. } | RecorderEvent::Cancelled { .. } + ) { + if let Some(worker) = &self.worker { + assert!(worker.finished(), "terminal event published before reaping"); + } + assert!(self.reaped(), "terminal event published before reaping"); + return event; + } + } + } + + fn failure(&self) -> String { + match self.terminal() { + RecorderEvent::Exited { + success: false, + status, + .. + } => status, + event => panic!("expected a failed exit, received {event:?}"), + } + } + + fn reaped(&self) -> bool { + let pid = std::fs::read_to_string(self.pid_file()).unwrap(); + !Path::new(&format!("/proc/{}", pid.trim())).exists() + } + + fn pid_file(&self) -> std::path::PathBuf { + self.session.output_path.with_extension("mp4.pid") + } + } + + impl Drop for Fake { + fn drop(&mut self) { + let _ = std::fs::remove_file(self.pid_file()); + let _ = std::fs::remove_file(&self.session.output_path); + } + } + + #[test] + fn protocol_round_trips_replies_and_events() { + let line = serde_json::to_string(&Message::Reply(Err(RecorderError::Backend( + "not ready".into(), + )))) + .unwrap(); + let Message::Reply(Err(RecorderError::Backend(message))) = + serde_json::from_str(&line).unwrap() + else { + panic!("reply changed shape: {line}") + }; + assert_eq!(message, "not ready"); + let line = serde_json::to_string(&Message::Event(RecorderEvent::Exited { + session_id: 7, + success: true, + status: "recording finalized".into(), + })) + .unwrap(); + assert!(matches!( + serde_json::from_str(&line).unwrap(), + Message::Event(RecorderEvent::Exited { + session_id: 7, + success: true, + .. + }) + )); + } + + #[test] + fn worker_crash_fails_the_recording_with_its_last_error() { + let fake = Fake::start( + r#"read start; : > "$0"; echo 'XIO: fatal IO error on X server' >&2; echo ' after 9 requests' >&2; exit 1"#, + ); + let status = fake.failure(); + assert!(status.contains("exited unexpectedly"), "{status}"); + assert!(status.contains("exit status: 1"), "{status}"); + assert!( + status.ends_with("XIO: fatal IO error on X server after 9 requests"), + "{status}" + ); + assert!(!fake.session.output_path.exists()); + } + + #[test] + fn worker_signal_after_frames_keeps_the_partial_movie() { + let fake = Fake::start( + r#"read start; echo partial > "$0"; echo '{"Event":{"Started":{"session_id":0,"dimensions":null}}}'; kill -9 $$"#, + ); + let status = fake.failure(); + assert!(status.contains("SIGKILL"), "{status}"); + assert!(fake.session.output_path.exists()); + } + + #[test] + fn worker_crash_before_a_fragment_is_written_removes_the_empty_movie() { + let fake = Fake::start( + r#"read start; : > "$0"; echo '{"Event":{"Started":{"session_id":0,"dimensions":null}}}'; exit 1"#, + ); + fake.failure(); + assert!(!fake.session.output_path.exists()); + } + + #[test] + fn replies_reach_the_waiting_command_and_finish_follows_reaping() { + let mut fake = Fake::start( + r#"read start; read pause; echo '{"Reply":{"Err":{"Backend":"not ready"}}}'; read resume; echo '{"Reply":{"Ok":null}}'; read stop; echo '{"Event":{"Cancelled":{"session_id":0}}}'"#, + ); + assert!( + matches!(fake.worker().control(Request::Pause), Err(RecorderError::Backend(message)) if message == "not ready") + ); + fake.worker().control(Request::Resume).unwrap(); + fake.worker().stop(); + assert!(matches!(fake.terminal(), RecorderEvent::Cancelled { .. })); + assert!(matches!( + fake.events.recv_timeout(Duration::from_secs(5)), + Err(mpsc::RecvTimeoutError::Disconnected) + )); + } + + #[test] + fn late_replies_are_not_given_to_the_next_command() { + let mut fake = Fake::start( + r#"read start; read pause; sleep 6; echo '{"Reply":{"Err":{"Backend":"late pause"}}}'; read resume; echo '{"Reply":{"Ok":null}}'; read stop; exit 0"#, + ); + let error = fake.worker().control(Request::Pause).unwrap_err(); + assert!( + error.to_string().contains("did not reply within 5s"), + "{error}" + ); + fake.worker().control(Request::Resume).unwrap(); + } + + #[test] + fn worker_death_while_a_command_waits_returns_promptly() { + let mut fake = Fake::start("read start; read pause; exit 3"); + let at = Instant::now(); + let error = fake.worker().control(Request::Pause).unwrap_err(); + assert!(at.elapsed() < Duration::from_secs(2), "{:?}", at.elapsed()); + assert!( + error.to_string().contains("exited before replying"), + "{error}" + ); + assert!(fake.failure().contains("exit status: 3")); + assert!(fake.worker().control(Request::Resume).is_err()); + } + + #[test] + fn invalid_worker_output_stops_the_worker() { + let fake = Fake::start("read start; echo not-json; exec sleep 30"); + let at = Instant::now(); + let status = fake.failure(); + assert!(status.contains("invalid message"), "{status}"); + assert!(at.elapsed() < Duration::from_secs(5), "{:?}", at.elapsed()); + } + + #[test] + fn frozen_worker_is_killed_after_the_stop_deadline() { + let mut fake = Fake::start( + r#"read start; echo '{"Event":{"Started":{"session_id":0,"dimensions":null}}}'; kill -STOP $$"#, + ); + assert!(matches!( + fake.events.recv_timeout(Duration::from_secs(5)).unwrap(), + RecorderEvent::Started { .. } + )); + let error = fake.worker().control(Request::Pause).unwrap_err(); + assert!( + error.to_string().contains("did not reply within 5s"), + "{error}" + ); + assert!(!fake.worker().finished()); + let at = Instant::now(); + fake.worker().stop(); + let status = fake.failure(); + assert!(at.elapsed() >= TEST_DEADLINE, "{:?}", at.elapsed()); + assert!(status.contains("did not exit within 1s"), "{status}"); + assert!(status.contains("SIGKILL"), "{status}"); + } + + #[test] + fn final_event_waits_until_a_hung_worker_is_killed_and_reaped() { + let mut fake = Fake::start( + r#"read start; echo '{"Event":{"Exited":{"session_id":0,"success":true,"status":"recording finalized"}}}'; exec sleep 30"#, + ); + assert!(matches!( + fake.events.recv_timeout(TEST_DEADLINE / 2), + Err(mpsc::RecvTimeoutError::Timeout) + )); + assert!(!fake.worker().finished()); + assert!(matches!( + fake.terminal(), + RecorderEvent::Exited { success: true, .. } + )); + } + + #[test] + fn teardown_that_hangs_after_a_failure_is_killed_without_a_stop_request() { + let fake = Fake::start( + r#"read start; echo '{"Event":{"Started":{"session_id":0,"dimensions":null}}}'; echo '"Stopping"'; exec sleep 30"#, + ); + let at = Instant::now(); + let status = fake.failure(); + assert!(at.elapsed() >= TEST_DEADLINE, "{:?}", at.elapsed()); + assert!(at.elapsed() < Duration::from_secs(5), "{:?}", at.elapsed()); + assert!(status.contains("did not exit within 1s"), "{status}"); + } + + #[test] + fn a_hung_teardown_between_encoder_attempts_is_killed_without_a_stop_request() { + let fake = Fake::start(r#"read start; echo '"CleaningAttempt"'; exec sleep 30"#); + let at = Instant::now(); + let status = fake.failure(); + assert!(at.elapsed() >= TEST_DEADLINE, "{:?}", at.elapsed()); + assert!(at.elapsed() < Duration::from_secs(5), "{:?}", at.elapsed()); + assert!( + status.contains("did not finish stopping an encoder attempt within 1s"), + "{status}" + ); + assert!(status.contains("SIGKILL"), "{status}"); + } + + #[test] + fn repeated_attempt_teardown_reports_keep_the_first_deadline() { + let fake = Fake::start( + r#"read start; for report in 1 2 3 4 5 6; do echo '"CleaningAttempt"'; sleep 0.4; done; exec sleep 30"#, + ); + let at = Instant::now(); + let status = fake.failure(); + assert!(at.elapsed() >= TEST_DEADLINE, "{:?}", at.elapsed()); + assert!( + at.elapsed() < Duration::from_millis(1800), + "{:?}", + at.elapsed() + ); + assert!( + status.contains("did not finish stopping an encoder attempt within 1s"), + "{status}" + ); + } + + #[test] + fn a_finished_attempt_teardown_does_not_limit_the_next_attempt() { + let fake = Fake::start( + r#"read start; echo '"CleaningAttempt"'; echo '"CleanedAttempt"'; echo '{"Event":{"Started":{"session_id":0,"dimensions":null}}}'; sleep 3; echo '{"Event":{"Exited":{"session_id":0,"success":true,"status":"recording finalized"}}}'"#, + ); + let at = Instant::now(); + assert!(matches!( + fake.terminal(), + RecorderEvent::Exited { success: true, .. } + )); + assert!(at.elapsed() >= 2 * TEST_DEADLINE, "{:?}", at.elapsed()); + } + + #[test] + fn stopping_during_attempt_teardown_keeps_the_stop_deadline() { + let mut fake = Fake::start( + r#"read start; echo '"CleaningAttempt"'; read stop; sleep 0.5; echo '"CleanedAttempt"'; exec sleep 30"#, + ); + thread::sleep(Duration::from_millis(300)); + let at = Instant::now(); + fake.worker().stop(); + let status = fake.failure(); + assert!(at.elapsed() >= TEST_DEADLINE, "{:?}", at.elapsed()); + assert!(at.elapsed() < Duration::from_secs(5), "{:?}", at.elapsed()); + assert!(status.contains("did not exit within 1s"), "{status}"); + } + + #[test] + fn result_reported_before_a_hung_teardown_is_kept() { + let fake = Fake::start( + r#"read start; echo '"Stopping"'; echo '{"Event":{"Exited":{"session_id":0,"success":false,"status":"source lost"}}}'; exec sleep 30"#, + ); + assert!(matches!( + fake.terminal(), + RecorderEvent::Exited { success: false, status, .. } if status == "source lost" + )); + } + + #[test] + fn finalization_is_reported_after_the_events_before_it() { + let (events, outgoing) = mpsc::channel(); + for event in [ + RecorderEvent::MediaLost { + session_id: 0, + message: "lost".into(), + }, + RecorderEvent::Log { + session_id: None, + message: FINALIZED.into(), + }, + RecorderEvent::Exited { + session_id: 0, + success: true, + status: "recording finalized".into(), + }, + ] { + events.send(event).unwrap(); + } + let mut written = Vec::new(); + forward(outgoing, |message| { + written.push(serde_json::to_string(message).unwrap()) + }); + assert_eq!(written.len(), 3, "{written:?}"); + assert!(written[0].contains("MediaLost"), "{written:?}"); + assert_eq!(written[1], r#""Finalized""#); + assert!(written[2].contains("Exited"), "{written:?}"); + } + + #[test] + fn a_finalized_movie_survives_a_hung_cleanup() { + let fake = Fake::start(r#"read start; echo '"Finalized"'; exec sleep 30"#); + let terminal = fake.terminal(); + assert!( + matches!( + &terminal, + RecorderEvent::Exited { success: true, status, .. } + if status.starts_with("recording finalized, but the capture worker did not exit within 1s") + ), + "{terminal:?}" + ); + } + + #[test] + fn exiting_worker_is_finished_when_its_final_event_arrives() { + let fake = Fake::start( + r#"read start; echo '{"Event":{"Exited":{"session_id":0,"success":true,"status":"recording finalized"}}}'"#, + ); + let at = Instant::now(); + assert!(matches!( + fake.terminal(), + RecorderEvent::Exited { success: true, .. } + )); + assert!(at.elapsed() < TEST_DEADLINE, "{:?}", at.elapsed()); + } + + #[test] + fn dropping_the_handle_of_a_hung_worker_kills_it_after_the_deadline() { + let mut fake = Fake::start("read start; exec sleep 30"); + let at = Instant::now(); + drop(fake.worker.take()); + assert!(at.elapsed() < Duration::from_millis(200)); + assert!(fake.failure().contains("did not exit within 1s")); + } + + #[test] + fn dropping_the_handle_lets_a_responsive_worker_stop() { + let mut fake = Fake::start("read start; read stop; exit 4"); + drop(fake.worker.take()); + assert!(fake.failure().contains("exit status: 4")); + } +} diff --git a/crates/linux/src/x11.rs b/crates/linux/src/x11.rs new file mode 100644 index 0000000..6f171a7 --- /dev/null +++ b/crates/linux/src/x11.rs @@ -0,0 +1,275 @@ +use crate::backend; +use domain::{CaptureSourceKind, CaptureTarget, Result}; +use std::{ + cell::Cell, + os::raw::{c_int, c_ulong}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, OnceLock, + }, + time::{Duration, Instant}, +}; +use x11_dl::xlib; +use x11rb::{ + connection::Connection, + protocol::xproto::{AtomEnum, ConnectionExt, MapState}, + rust_connection::RustConnection, +}; + +type ErrorHandler = unsafe extern "C" fn(*mut xlib::Display, *mut xlib::XErrorEvent) -> c_int; + +static PREVIOUS_HANDLER: OnceLock> = OnceLock::new(); +static SOURCES: Mutex> = Mutex::new(Vec::new()); + +struct Source { + xid: u32, + damage: Option<(u8, u8)>, + lost: Arc, +} + +impl Source { + fn claims(&self, error: &xlib::XErrorEvent) -> bool { + if matches!(error.error_code, xlib::BadWindow | xlib::BadDrawable) + && error.resourceid == c_ulong::from(self.xid) + { + self.lost.store(true, Ordering::SeqCst); + return true; + } + self.lost.load(Ordering::SeqCst) + && self.damage == Some((error.request_code, error.error_code)) + } +} + +unsafe extern "C" fn handle_error( + display: *mut xlib::Display, + event: *mut xlib::XErrorEvent, +) -> c_int { + if let Some(error) = event.as_ref() { + if SOURCES + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .iter() + .any(|source| source.claims(error)) + { + return 0; + } + } + match PREVIOUS_HANDLER.get().copied().flatten() { + Some(previous) => previous(display, event), + None => 0, + } +} + +pub(crate) struct SourceWatch { + xid: u32, + lost: Arc, + connection: RustConnection, + checked: Cell>, +} + +impl SourceWatch { + pub(crate) fn new(xid: u64) -> Result { + let xid = u32::try_from(xid).map_err(backend)?; + let (connection, _) = x11rb::connect(None).map_err(backend)?; + let damage = connection + .query_extension(b"DAMAGE") + .map_err(backend)? + .reply() + .map_err(backend)?; + let lost = Arc::new(AtomicBool::new(false)); + SOURCES + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(Source { + xid, + damage: damage + .present + .then_some((damage.major_opcode, damage.first_error)), + lost: lost.clone(), + }); + Ok(Self { + xid, + lost, + connection, + checked: Cell::new(None), + }) + } + + pub(crate) fn lost(&self) -> bool { + if !self.lost.load(Ordering::SeqCst) + && self + .checked + .get() + .map_or(true, |at| at.elapsed() >= Duration::from_millis(500)) + { + self.checked.set(Some(Instant::now())); + let viewable = self + .connection + .get_window_attributes(self.xid) + .ok() + .and_then(|cookie| cookie.reply().ok()) + .is_some_and(|attributes| attributes.map_state == MapState::VIEWABLE); + if !viewable { + self.lost.store(true, Ordering::SeqCst); + } + } + self.lost.load(Ordering::SeqCst) + } +} + +impl Drop for SourceWatch { + fn drop(&mut self) { + SOURCES + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .retain(|source| !Arc::ptr_eq(&source.lost, &self.lost)); + } +} + +pub(crate) fn initialize() -> Result<()> { + static XLIB: OnceLock> = OnceLock::new(); + static INITIALIZED: OnceLock = OnceLock::new(); + let xlib = XLIB + .get_or_init(|| xlib::Xlib::open().map_err(|e| e.to_string())) + .as_ref() + .map_err(backend)?; + // Must run before GStreamer opens any Xlib display. Discovery uses x11rb, + // which talks to the server directly and does not initialize Xlib. + if !INITIALIZED.get_or_init(|| unsafe { (xlib.XInitThreads)() } != 0) { + return Err(backend("XInitThreads failed")); + } + PREVIOUS_HANDLER.get_or_init(|| unsafe { (xlib.XSetErrorHandler)(Some(handle_error)) }); + Ok(()) +} + +pub(crate) fn list_targets() -> Result> { + let (connection, default_screen) = x11rb::connect(None).map_err(backend)?; + let atoms = |name: &[u8]| -> Result { + Ok(connection + .intern_atom(false, name) + .map_err(backend)? + .reply() + .map_err(backend)? + .atom) + }; + let clients = atoms(b"_NET_CLIENT_LIST_STACKING")?; + let utf8_name = atoms(b"_NET_WM_NAME")?; + let mut targets = Vec::new(); + for (index, screen) in connection.setup().roots.iter().enumerate() { + targets.push(CaptureTarget { + id: if index == default_screen { + 0 + } else { + screen.root.into() + }, + kind: CaptureSourceKind::Display, + name: format!( + "X11 display {index} ({}×{})", + screen.width_in_pixels, screen.height_in_pixels + ), + }); + let reply = connection + .get_property(false, screen.root, clients, AtomEnum::WINDOW, 0, 4096) + .map_err(backend)? + .reply() + .map_err(backend)?; + let windows: Vec = reply.value32().map(|v| v.collect()).unwrap_or_else(|| { + connection + .query_tree(screen.root) + .ok() + .and_then(|c| c.reply().ok()) + .map(|r| r.children) + .unwrap_or_default() + }); + for window in windows { + let visible = connection + .get_window_attributes(window) + .ok() + .and_then(|c| c.reply().ok()) + .is_some_and(|a| a.map_state == MapState::VIEWABLE); + if !visible { + continue; + } + let property = |atom: u32| { + connection + .get_property(false, window, atom, AtomEnum::ANY, 0, 1024) + .ok() + .and_then(|c| c.reply().ok()) + .map(|r| { + String::from_utf8_lossy(&r.value) + .trim_end_matches('\0') + .to_string() + }) + }; + let name = property(utf8_name) + .filter(|s| !s.is_empty()) + .or_else(|| property(AtomEnum::WM_NAME.into())) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| format!("X11 window {window}")); + targets.push(CaptureTarget { + id: window.into(), + name, + kind: CaptureSourceKind::Window, + }); + } + } + Ok(targets) +} + +pub(crate) fn source(target: &CaptureTarget) -> Result<(String, u64)> { + let display = std::env::var("DISPLAY").map_err(backend)?; + if !list_targets()? + .iter() + .any(|t| t.id == target.id && t.kind == target.kind) + { + return Err(backend( + "The selected X11 display or window is no longer available.", + )); + } + Ok((display, target.id)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn error(resourceid: c_ulong, error_code: u8, request_code: u8) -> xlib::XErrorEvent { + xlib::XErrorEvent { + type_: 0, + display: std::ptr::null_mut(), + resourceid, + serial: 0, + error_code, + request_code, + minor_code: 0, + } + } + + #[test] + fn source_errors_only_claim_the_recorded_window() { + let source = Source { + xid: 42, + damage: None, + lost: Arc::new(AtomicBool::new(false)), + }; + assert!(!source.claims(&error(43, xlib::BadWindow, 3))); + assert!(!source.lost.load(Ordering::SeqCst)); + assert!(source.claims(&error(42, xlib::BadWindow, 3))); + assert!(source.lost.load(Ordering::SeqCst)); + assert!(!source.claims(&error(43, xlib::BadDrawable, 73))); + } + + #[test] + fn damage_cleanup_errors_require_a_lost_source_and_matching_extension() { + let source = Source { + xid: 42, + damage: Some((143, 152)), + lost: Arc::new(AtomicBool::new(false)), + }; + assert!(!source.claims(&error(99, 152, 143))); + assert!(source.claims(&error(42, xlib::BadDrawable, 73))); + assert!(source.claims(&error(99, 152, 143))); + assert!(!source.claims(&error(99, 151, 143))); + assert!(!source.claims(&error(99, 152, 144))); + } +} diff --git a/crates/macos/src/lib.rs b/crates/macos/src/lib.rs index d52bc8e..38f9b54 100644 --- a/crates/macos/src/lib.rs +++ b/crates/macos/src/lib.rs @@ -1,6 +1,8 @@ +#[cfg(target_os = "macos")] +use domain::{CaptureDimensions, CaptureSourceKind, RecorderMetrics}; use domain::{ - CaptureDimensions, CaptureSourceKind, CaptureTarget, PermissionStatus, RecorderEngine, - RecorderError, RecorderEvent, RecorderMetrics, RecorderSettings, RecordingSession, Result, + CaptureTarget, PermissionStatus, RecorderEngine, RecorderError, RecorderEvent, + RecorderSettings, RecordingSession, Result, }; static LAST_SESSION_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); diff --git a/packaging/linux/README.md b/packaging/linux/README.md new file mode 100644 index 0000000..2627ebf --- /dev/null +++ b/packaging/linux/README.md @@ -0,0 +1,484 @@ +# Linux CLI and daemon + +The Linux backend records Wayland desktops through the ScreenCast portal and +PipeWire, or X11 displays and windows through XShm/XImage. It selects an installed +encoder automatically: Intel/AMD VA-API, NVIDIA NVENC, then software encoding. +A working desktop and the required native plugins are needed. There is no Linux +GUI yet. AMD Radeon capture has been tested on GNOME Wayland; Intel and NVIDIA hardware +remain unverified. Software fallback makes recording possible without a supported GPU, +but uses more CPU. +Software 4K HEVC can also need hundreds of MiB while encoding. That memory +belongs to the capture worker described below, and the system reclaims it when +the worker exits at the end of each recording. On glibc builds, the worker also +asks the allocator to return freed heap pages between encoder attempts. + +On compatible Wayland/VA-API systems, the preferred pipeline is +`pipewiresrc → DMA-BUF → vapostproc → VA encoder → mp4mux`. Rust manages sessions +and buffer metadata without mapping video pixels. The source negotiates four to +eight buffers and the capture queue holds at most two frames. Conversion and scaling +can allocate GPU surfaces; this does not imply zero total copies. + +Linux recordings are `wrec-.mp4` files in ISO MP4 format (file type +`mp42`, also listing `mp41`, `isom` and `iso2`). macOS recordings are still +QuickTime `.mov` files. On Linux, wrec uses `mp4mux` rather than GStreamer's +QuickTime writer, `qtmux`. For widths 641 through 1052 and heights 480 through +576, `qtmux` guesses a 4:3 or 16:9 TV picture and declares a clean aperture for +it. FFmpeg 8.0 crops to that aperture, so an 800x528 window would play as +704x528; FFmpeg 7.0 ignores it. `mp4mux` is the same GStreamer muxer writing the +ISO variant, and it declares the full picture. Tracks, codecs, timing and +fragments are the same with either. The `.mp4` name matches the contents, so +tools that pick a reader or a MIME type from the extension get MP4. QuickTime +and Safari playback of Linux recordings has not been tested. + +When audio is requested, encoded video waits until each audio track starts or +ends. A track that never supplies a sample would otherwise make the whole movie +unreadable. If waiting video reaches three seconds or 32 MiB, recording continues +without the tracks that have not started and logs each omitted source. The startup +queue has four seconds and 40 MiB of capacity. Once it drains, it holds at most one +encoded frame. Audio that starts within the limit keeps its capture timestamp. +Each encoded AAC queue holds up to four seconds, about 64 KB at 128 kbps, to absorb +the movie writer's wait for the next video frame on an idle screen across a pause. + +When shared GPU buffers cannot be imported, wrec retries with system-memory +capture and hardware encoding. NVIDIA uses CUDA conversion when available, +otherwise CPU conversion before NVENC. Software encoding uses x264/OpenH264 for +H.264 and x265 for HEVC. Retries happen only before the first encoded frame and +preserve the requested codec. Job events identify the attempted and selected +paths. X11 capture uses system memory even when encoding runs on the GPU. + +On Wayland, the requested frame rate is a ceiling. wrec offers the portal every +whole-number maximum from the requested rate down to 1 fps, highest first, and +PipeWire picks the highest one the portal supports. A 60 Hz output recorded at +30 fps captures at up to 30 fps, and a 5 Hz output captures at up to 5 fps. A +fractional refresh rate below the request is rounded down. For example, a +59.94 Hz output recorded at 60 fps captures at up to 59 fps. A portal that +advertises a single fixed fractional maximum below the requested rate, +instead of a range, cannot be negotiated. + +VA encoders set their keyframe interval to about two seconds at the +negotiated capture rate, capped by the requested rate. A 5 fps capture gets +an interval of 10 frames even when 60 fps was requested. Using the requested +rate alone can stall older Radeon HEVC encoding at low capture rates. Each +new set of encoder input caps updates the interval after the previous frames +have reached the encoder. Fixed frame rates take precedence over a variable +stream's maximum rate. Missing rate information uses the requested ceiling. + +The movie writer uses frame timestamps for playback timing. Refresh-rate +changes can also change the encoder's parameter sets. The movie keeps its +initial track description and carries those parameter sets with each keyframe, +using `avc3` for H.264 and `hev1` for HEVC. This avoids changing the sample +description in a fragmented movie, which can make older versions of +GStreamer's movie muxer write unplayable fragments. Playback requires a reader +that supports these sample entries; FFmpeg playback is covered by the recording +tests. +When an encoder omits a frame's duration, wrec supplies one frame at the +current capture rate, falling back to the requested rate when the encoded caps +have no rate. The last frame then has a nonzero duration in the finalized movie. +The two-frame capture queue counts frames entering and leaving it, subtracting +frames still waiting. Queue-overrun notifications can race with the consumer +and do not prove that a frame was discarded. Counting uses constant storage and +does not modify capture buffers. The count is exact after the queue drains; +sampling while frames are moving can differ by one frame in transit. + +wrec tries shared GPU buffers only when `vapostproc` lists DMA-BUF caps with +`format=DMA_DRM` and comes from GStreamer VA 1.24.6 or later. `DMA_DRM` caps +carry the pixel format and modifier with an explicit `drm-format`. GStreamer VA +1.22 describes DMA-BUF with a plain format such as `BGRx` and no modifier. +GStreamer VA before 1.24.6 imports a DMA-BUF only when each buffer's memory +covers a whole video plane. 1.24.6 needs one byte and reads strides from video +metadata. Producers decide the sizes that PipeWire reports for a DMA-BUF. +xdg-desktop-portal-wlr reports a memory size of 0, so its buffers fail that +older check, and the frame is lost before encoding starts. With older VA, wrec +skips the shared-buffer attempt and starts with system-memory capture, still +encoding on the GPU. The skipped attempt matters on a static screen. +xdg-desktop-portal-wlr sends a frame only when the screen changes, so an +attempt that receives the initial frame and fails leaves later attempts with +no frame until something on screen changes. + +`pipewiresrc` pauses its stream briefly while starting. When that pause +interrupts a capture, xdg-desktop-portal-wlr sends the unfinished buffer marked +corrupted, with timestamp 0. GStreamer aligns a live source's timestamps to +the first buffer it sees. When that buffer is the corrupted one, every real +frame looks due hours in the future, so the source waits instead of delivering +frames. This +was seen with shared GPU buffers on xdg-desktop-portal-wlr 0.8.1 and PipeWire +1.6.2. wrec stops an attempt whose first frame is marked corrupted before +anything is encoded and moves on to the next available mode. + +wrec sets the movie's output size when the first source caps arrive. Changing +a capsfilter normally asks every upstream element to renegotiate, and +`pipewiresrc` answers by disconnecting and reconnecting its PipeWire stream, +even with identical caps. xdg-desktop-portal-wlr 0.8.1 with +ext-image-copy-capture then requests a new frame while the first one is still +pending, breaks the Wayland protocol, and exits. wrec stops that request at +the video queue, so the converters still renegotiate their output and the +capture source keeps its stream. Other renegotiation requests pass through. + +When the captured output or window changes size, `pipewiresrc` sends the new +caps without asking downstream elements to set up their buffer pools again. +GStreamer VA `vapostproc` then copies system-memory frames into a pool sized +for the old caps and rejects the first resized frame. After a size change, wrec +sends the allocation query that normal GStreamer negotiation would send, before +the next frame, so the converters size their pools for the new frames. + +## Capture worker + +xdg-desktop-portal-wlr 0.8.1 has an upstream startup race on its +ext-image-copy-capture path. A consumer pause can leave a capture frame +pending; resuming or starting the next consumer can then create a second +frame and make the backend exit with "session already has a frame object". +PipeWire's GStreamer source pauses and resumes during startup, so successful +recordings on this stack do not establish reliable startup. wrec detects the +backend loss and fails the job, but does not repair the portal. Stock builds +remain affected. Separate local patches for portal-wlr 0.8.1 cancel pending +captures when their buffers are returned or removed, leave incomplete image +buffers empty, and restart capture when new buffers have no image on a static +output or a cancelled frame may have consumed its damage. These patches are external to +wrec and require an opt-in build matched to the desktop's libraries. + +The older wlr-screencopy path also has a static-start limitation. The portal +shares a screencopy-manager binding across sessions and asks for each new +session's first frame with damage tracking. The first session consumes the +binding's initial damage; later sessions on an unchanged output can wait +without receiving an initial image. This was reproduced with portal-wlr 0.7.0, +Sway 1.7 and wlroots 0.15.1. Separate local patches for portal-wlr 0.7.1 request +a first image without damage tracking, destroy closed streams promptly, and +limit retries when the consumer holds every buffer. Stock builds remain +affected; repainting or restarting the portal is not treated as a repair. +The Linux test report distributes pinned patch series, build instructions, +checksums and stock-versus-patched evidence. The wrec package does not install +or replace the desktop portal. + +The daemon does not run native capture code. For each recording it starts a +capture worker, which is the same daemon executable started again with a private +argument and the daemon's environment. The worker opens the X11 display or the +ScreenCast portal session, runs the GStreamer pipeline, and writes the movie. +Target discovery stays in the daemon. The daemon sends the start request, pause, +resume and stop to the worker's stdin as JSON lines. The worker answers on stdout +with recorder events and pause/resume replies. Anything native libraries print +goes to the worker's stderr, which the daemon copies into its log. + +Xlib exits the whole process when its X server connection breaks, and a GPU +driver can crash its process. Either now ends only the worker. When the worker +exits before reporting a result, the daemon marks that job failed with the exit +status and the worker's last stderr lines, then starts the next queued job. The +daemon PID stays the same. The daemon keeps a partial movie only if the worker +reported encoded frames and the file is not empty. + +A worker can also freeze or hang inside a native call. The daemon gives every +worker 20 seconds to exit after a stop request, after its final event, after its +output closes, or after the worker reports that it is tearing down a failed, +lost or cancelled recording, then kills it with SIGKILL. The worker sends that +report before it stops the native pipeline, so a GPU or plugin cleanup that +hangs cannot hold the job open. Recording, idle and paused time never start the +deadline. Cleaning up a failed encoder attempt uses a separate deadline. The job's final status is +published only after the worker has been reaped, so the next queued job never +starts while the previous worker still runs. A worker killed this way before it +reported a result fails its job, unless it had already reported a finalized +movie; then the job completes and its status says that cleanup did not finish. While a worker is frozen, pause and resume fail +after the usual 5 seconds and the recording keeps its current state. + +The worker runs in its own process group, so Ctrl-C in a terminal reaches the +daemon, which then stops the worker gracefully. `kill ` does the +same and waits up to 15 seconds for finalization before the daemon exits. The +kernel kills the worker with SIGKILL as soon as the daemon process exits for any +reason, so a worker never outlives its daemon. If the daemon is killed or crashes +during a recording, the movie is not finalized. At most, it is playable through +the last completed fragment that reached the disk. Fragments still buffered by +the file writer are lost, which during a disk stall can be up to 32 MiB. In one test, killing the daemon 12 seconds into a +static-desktop recording left an empty file. + +While recording, resource use is the daemon plus its worker. Measuring the +daemon PID alone misses the capture pipeline. Find the worker with +`pgrep -P -x wrec-capture`, where the daemon PID comes from +`wrec daemon status --json`, and add its CPU and RSS to the daemon's. + +Native cleanup between encoder attempts has its own 20-second kill/reap bound. +Completing that cleanup clears only its attempt deadline; it does not clear a +user-stop or final-exit deadline, or limit the next healthy recording. The bound +covers native destructors and allocator cleanup. Repeating a cleanup-start +message does not extend an already armed deadline. + +## Install dependencies and build + +Use GStreamer 1.22 or newer and current stable Rust. The shared-buffer +DMA-BUF/VA path needs GStreamer VA 1.24.6 or newer. Ubuntu 24.04 is a starting +point; package names differ across distributions. + +```bash +sudo apt install build-essential pkg-config libgstreamer1.0-dev \ + libgstreamer-plugins-base1.0-dev gstreamer1.0-tools \ + gstreamer1.0-pipewire gstreamer1.0-plugins-base \ + gstreamer1.0-plugins-good gstreamer1.0-plugins-bad \ + gstreamer1.0-plugins-ugly gstreamer1.0-libav +cargo build --release -p cli -p daemon +``` + +Wayland needs PipeWire and the ScreenCast portal backend for the desktop, such as +`xdg-desktop-portal-gnome` or `xdg-desktop-portal-kde`. Use the backend for your +desktop. X11 needs an accessible X server and the `ximagesrc` plugin. + +### Portal frontend hangs on xdg-desktop-portal before 1.22.1 + +The portal frontend (`xdg-desktop-portal`) before 1.22.1 loads PipeWire's +realtime module each time it opens a PipeWire remote for a screen cast. That +module asks the frontend's own Realtime portal for limits over D-Bus, and the +frontend can deadlock on itself until the D-Bus call times out after 25 +seconds. While it waits, every portal call stalls. After you approve a source, +the job fails with "Opening the PipeWire remote timed out" or "ScreenCast +portal did not respond within 5s". + +This was reproduced on KDE Plasma 6.6.6 with Ubuntu's +`xdg-desktop-portal 1.21.1+ds-1ubuntu3.1` and PipeWire 1.6.2. A separate +GStreamer client reproduced it with the wrec daemon stopped: five screen casts +worked and the sixth hung. Installing RealtimeKit did not help; a session still +hung on the sixth try. The fault is in the portal frontend, not in wrec or the +desktop backend, so it can affect any desktop that runs an affected frontend. + +Upstream commit +[89f2f5e](https://github.com/flatpak/xdg-desktop-portal/commit/89f2f5e3d219bc5fd66a2505ee772b16022e8575) +("pipewire: Disable loading module-rt") fixes it and is in xdg-desktop-portal +1.22.1. Upgrade the frontend through your distribution when it ships 1.22.1 or +a build with that commit. That version was read but not run in wrec's tests. + +Until then, you can turn the realtime module off for the frontend only. wrec +never changes the portal setup itself. On the KDE system above, a frontend +started with this setting passed 10 of 10 repeated screen casts and the full +recording checks. Removing it brought the hang back on the second screen cast. +The commands below apply this setting through a systemd drop-in. Copy +PipeWire's client config and set +`module.rt = false` in its `context.properties`: + +```bash +mkdir -p ~/.config/pipewire ~/.config/systemd/user/xdg-desktop-portal.service.d +sed 's/^context.properties = {/&\n module.rt = false/' \ + /usr/share/pipewire/client.conf > ~/.config/pipewire/wrec-portal.conf +printf '[Service]\nEnvironment=PIPEWIRE_CONFIG_NAME=wrec-portal.conf\n' \ + > ~/.config/systemd/user/xdg-desktop-portal.service.d/wrec-portal.conf +systemctl --user daemon-reload +systemctl --user restart xdg-desktop-portal.service +``` + +Check that `~/.config/pipewire/wrec-portal.conf` has `module.rt = false` right +under `context.properties = {`. Only the frontend service reads this file; +other PipeWire clients keep the stock config. To undo it after upgrading: + +```bash +rm ~/.config/systemd/user/xdg-desktop-portal.service.d/wrec-portal.conf \ + ~/.config/pipewire/wrec-portal.conf +systemctl --user daemon-reload +systemctl --user restart xdg-desktop-portal.service +``` + +Install the driver for your GPU. Check `vainfo` and +`gst-inspect-1.0 vah264enc` / `vah264lpenc` for Intel/AMD, or +`gst-inspect-1.0 nvh264enc` for NVIDIA. HEVC uses the corresponding H.265 encoder. +If hardware elements are absent, inspect `x264enc`, `openh264enc`, or `x265enc`. +Distribution codec packaging and GPU capabilities vary. VA-API also needs access +to `/dev/dri/renderD*`; NVIDIA needs its driver and encode libraries. Missing +hardware plugins do not prevent software recording. + +GStreamer VA encoders before 1.24.3 replace each frame's capture timestamp with +its frame number times the nominal frame duration. When the screen delivers +fewer frames than that rate, the movie plays faster than the recorded time. +With these versions, wrec attaches each frame's capture time to the frame as a +`GstReferenceTimestampMeta` and restores it after the encoder, so VA encoding +keeps the original timing. VA 1.24.3 and later keep timestamps themselves and +skip this step. If a frame loses its capture time inside an affected encoder, +the recording fails with an error instead of writing faster video. This is not +a minimum version, and it does not switch to software encoding. + +```bash +./scripts/package-cli-linux.sh +``` + +Extract the archive into a prefix such as `~/.local`. It contains `bin/wrec` +and `lib/wrec/daemon`. Put the prefix's `bin` directory on PATH. Native libraries +remain system dependencies. The archive targets the build machine's architecture +and libc; it is not a universal binary for every Linux distribution. + +Run the CLI in the logged-in desktop session. It starts the daemon automatically +and inherits the display, D-Bus, PipeWire, and audio environment. After changing +sessions, stop and restart the daemon. A container without the desktop sockets or +GPU device access cannot record the host desktop merely because the host has a GPU. + +## Record + +```bash +wrec targets --json +wrec record --target display:0 --codec h264 --duration 10s +wrec record --target window:0 --codec h264 --no-system-audio +``` + +On Wayland, `display:0` and `window:0` open the desktop's source picker; only +advertised source types appear. On X11, use the window ID returned by `targets` +instead of `window:0`; named windows and X screen roots are enumerated directly. +A multi-monitor X screen is captured as one desktop. The captured X11 window +must stay viewable: closing it, minimizing it, or any other unmap fails the +recording within about half a second, including window managers that unmap +windows on other workspaces. Wayland is preferred when +both session variables exist, because XWayland cannot capture the whole desktop. + +The duration starts after the first encoded frame, so time spent choosing a +source does not shorten recording. The portal picker times out after two minutes; +`wrec job stop ` cancels it. Stopping before capture begins produces a cancelled +job without a movie. After capture starts, `--duration` counts wall time including +pauses; the movie omits paused time. Pause keeps the approved source streams alive but drops +video and audio before conversion/encoding; timestamps remove the pause on resume. +Rust only changes buffer metadata, preserving GPU memory. This also avoids +PipeWire source renegotiation on resume. Failed starts do not report nonexistent files. + +Wayland permission status is `unknown` outside a recording because grants belong +to individual sessions. Every recording asks for a source. Restore tokens, +unattended Wayland capture, and Wayland app-name selection are not implemented. + +System audio uses the default PulseAudio output monitor, including through +`pipewire-pulse`. `--mic` adds the default microphone as a separate AAC track. +Audio encoding runs on the CPU. Use `--no-system-audio` when no audio service is +available. Configure devices in desktop sound settings. Linux cannot apply the +shared wrec window-hiding or custom microphone-indicator options; job settings +report them disabled with a warning. The desktop controls its sharing indicator. + +Each audio source retains up to one second of raw audio, about 384 kB per +F32 stereo track, while capture latency remains 10 ms. Sample-count timestamps +preserve audio held through a short recorder stall. If audio instead stays more +than 200 ms late with steady arrival timing for a second, wrec moves later +samples back into alignment and reports the corresponding gap. + +This recovery has limits. After real loss, the correction appears about a second +later, and the intervening audio can play early by the amount lost. Loss below +200 ms can remain undetected. A stall beyond the one-second source buffer can +lose samples and leave about 100 ms of offset, as observed in the controlled +1.5-second stall. Physical microphone clock drift is not validated. These are +experimental support limits; a completed job alone does not establish A/V sync. + +The movie writer takes the next sample of every track in time order, so a track +that stops delivering holds up the others. Encoded video and each audio track +can wait about 4 seconds for the rest. Encoded movie data can wait for the disk +in a 32 MiB buffer, about 16 seconds at 16 Mbit/s. These buffers stay nearly +empty unless something stalls. Past those limits the recording keeps going with +gaps: the capture queue drops video frames, and an audio source overwrites audio +that nothing read in time. A stalled video encoder always costs the frames +captured meanwhile, since raw frames are too large to keep. + +If an audio source fails, for example because PipeWire or PulseAudio restarts, +its track ends at that point and video and the other track keep recording. An +error anywhere else in the audio chain, such as the AAC encoder, still fails the +job. A +completed job is not proof of a whole movie. When frames were dropped, audio has +gaps, or a track ended early or never started, the job finishes `completed` with +a `media_lost` warning that states what is missing, and the job events record when +each audio gap happened. `wrec record` prints the warning when it finishes. + +On Wayland, a recording watches four things besides its frames: the ScreenCast +session's `Closed` signal, the D-Bus owner of `org.freedesktop.portal.Desktop`, +the PipeWire remote socket the portal returned, and the selected PipeWire node. +Frames alone cannot show that a source ended, because a static desktop or a +paused recording may deliver none for any length of time. When the desktop +closes the session, for example from its sharing indicator, wrec finalizes the +movie as if `wrec job stop` had run. When the portal loses its owner, the +PipeWire socket hangs up, or the node is removed, the job fails within a +fraction of a second, active or paused, with an event that names the service to restart. Any +partial movie stays on disk. wrec checks the socket with `poll` and never reads +it, so PipeWire's own reader sees all of its data. + +The node watch opens a second PipeWire remote through the same portal session, +so it sees only the nodes that session may use. It runs GStreamer's PipeWire +device provider, from the `gstreamer1.0-pipewire` package that recording already +needs, and fails the recording when the device for the selected node is removed. +A portal backend such as xdg-desktop-portal-wlr owns that node, so the node +disappears when the backend exits. A compositor exit is detected only when it +causes one of the four signals above, which has not been verified. If the node is not +visible on the second remote, or PipeWire does not list its nodes within 5 +seconds, the job logs that stream removal will not be detected and records +without the watch. If a desktop removes the node before its `Closed` signal +arrives, a stop from its sharing indicator fails the job with the stream-ended +message instead of finalizing it. The watch never stops its device provider. +Before PipeWire 0.3.78, which includes Debian 12's 0.3.65, stopping the provider +can corrupt memory, so its connection stays open until the capture worker exits. + +Stop drains the encoders and finalizes the movie, including when paused. If +video still has not reached the movie writer 10 seconds after stop while nothing +waits for the disk or for audio, wrec ends the video track at the writer and +finalizes the movie with the video it already has, and the job reports the +shorter video as lost media. This rescues a movie whose encoder hung; an encoder +that is only very slow loses the frames it had not finished. If that takes more +than 5 more seconds, or a disk stalls for more than 10 seconds during +finalization, the job fails as before. The +writer requests ten-second movie fragments; encoder keyframes can close them +earlier. An interrupted recording may only be playable through its last completed +fragment. Audio sources can deliver their +first samples later than video. The writer keeps that offset with a per-track +edit list, written in the first fragment and again at finalization, instead of +moving each track to start at zero. To do this, it keeps every sample table in +memory until the movie is finalized and then writes a complete index at the end +of the file. Measure long recordings using the whole daemon process tree and +include the time needed to finalize the index. PipeWire requests one keepalive frame per second on a static +desktop. HDR is not supported. + +## Validate + +```bash +sudo apt install ffmpeg dbus-daemon xvfb x11-apps pipewire pulseaudio +cargo fmt --check +cargo check --workspace --locked +cargo test --workspace --locked +dbus-run-session -- cargo test -p linux portal_roundtrip --locked -- --ignored +cargo test -p linux node_watch --locked -- --ignored +cargo test -p linux pulse_server --locked -- --ignored --test-threads=1 +cargo build -p cli -p daemon +python3 scripts/test-capture-linux.py target/debug/wrec +``` + +The full test suite requires `x265enc` from the GStreamer runtime packages +listed above. The HEVC latency regression fails if that encoder is absent, +so a passing suite always includes it. The private PulseAudio regressions stop +only their own server to check held audio, real loss reporting and A/V alignment; +they require `pulseaudio` and run separately with the command above. + +The isolated Xvfb test records actual X11 display/window pixels through the CLI +and daemon, checks H.264/HEVC decoding and timestamps, and exercises pause/resume +and stopping while paused. It also kills the X server during a recording and +during a pause, with a second job queued. Both jobs must fail, the daemon PID must +stay the same, the worker must be reaped, and the same daemon must record again +after a new X server starts on that display. A job queued behind a closed +window, behind an unmapped window, and behind a worker frozen with SIGSTOP must +start at once and complete. +The frozen worker must be killed 20 seconds after stop, and killing the daemon +must kill its worker. It expects a machine without a hardware encoder so +software fallback is exercised. CI also tests the extracted package and headless +errors. Native pipeline tests cover AAC tracks, timing, capture errors, and strict +DMA-BUF rejection. An isolated mock portal covers options, session cleanup, +session `Closed`, portal owner loss and recovery, and PipeWire socket hangup +without consuming socket data. The node watch test starts a private PipeWire +daemon in a temporary directory, without the session bus. It checks that an +idle node is not reported, that removing the node is reported, that a missing +node disables the watch instead of failing, and that the watch stops promptly +after the daemon dies. On PipeWire before 0.3.78, running it under +`taskset -c 0` makes a provider teardown race fail reliably if one returns. + +Real hardware validation on Corex (Ryzen 9 5900H / Radeon Cezanne, Ubuntu 26.04, +GNOME Wayland, GStreamer 1.28.2) recorded H.264 displays and an animated HEVC +window through DMA-BUF and VA-API with no fallback. Video/audio decode and +strictly increasing timestamps passed, including live system audio, pause/resume, +and stopping while paused. The 1080p animated-window sample used about 4% of one +CPU core and 129 MiB peak daemon RSS. It recorded about 31 fps with a requested +60 fps ceiling; this is not sustained-60-fps validation or a general benchmark. +That run predates the capture worker. The pipeline ran inside the daemon then, so +the 129 MiB covered both roles. The worker loads its own copy of the executable +and native libraries, so the sum of daemon and worker RSS is expected to be +higher. Corex has not been measured again since the split. + +A release build on x86_64 Ubuntu with GStreamer 1.28.2, recording an isolated +Xvfb with the hardware encoder plugins hidden, measured the split directly. The +idle daemon used 9 to 11 MiB RSS. While recording an 800×600 display, the daemon +stayed near 10 MiB and the worker used 32 MiB with software H.264 and 66 MiB +with software HEVC. Native 4K software recordings peaked near 600 MiB worker RSS +and left the daemon at 11 MiB. These numbers cover software encoding on a +virtual X server, not GPU capture. + +Intel/NVIDIA, other compositors, live microphone sync, desktop sharing-control +stops, HDR, sustained 60 fps, 4K, GPU memory and power need separate validation. +For each configuration, compare CPU, RSS, GPU memory, power and compositor cost +against an idle baseline, and report GPU, driver, desktop, encoder and library +versions. Software/Xvfb tests alone cannot prove DMA-BUF import or GPU efficiency. diff --git a/scripts/package-cli-linux.sh b/scripts/package-cli-linux.sh new file mode 100755 index 0000000..0d0ddca --- /dev/null +++ b/scripts/package-cli-linux.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(uname -s)" != Linux ]]; then + echo 'Build the Linux CLI package on Linux.' >&2 + exit 1 +fi +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" +cargo build --release --locked -p cli -p daemon +task_target="${CARGO_TARGET_DIR:-$repo_root/target}" +stage="$(mktemp -d)" +trap 'rm -rf "$stage"' EXIT +install -Dm755 "$task_target/release/wrec" "$stage/bin/wrec" +install -Dm755 "$task_target/release/daemon" "$stage/lib/wrec/daemon" +install -Dm644 packaging/linux/README.md "$stage/share/doc/wrec/README.md" +install -Dm644 LICENSE "$stage/share/doc/wrec/LICENSE" +mkdir -p dist/cli/linux +archive="$repo_root/dist/cli/linux/wrec-cli-$(uname -m)-unknown-linux-gnu.tar.gz" +tar -czf "$archive" -C "$stage" bin lib share +echo "$archive" diff --git a/scripts/test-capture-linux.py b/scripts/test-capture-linux.py new file mode 100644 index 0000000..de1cfaf --- /dev/null +++ b/scripts/test-capture-linux.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +"""Record a real, isolated X11 desktop through the CLI, daemon, and native engine.""" +import ctypes +import json +import os +from pathlib import Path +import selectors +import signal +import subprocess +import sys +import tempfile +import time + + +def memory_mib(pid, field): + try: + status = Path(f"/proc/{pid}/status").read_text() + except FileNotFoundError: + return None + values = [int(line.split()[1]) / 1024 for line in status.splitlines() if line.startswith(f"{field}:")] + return values[0] if values else None + + +def workers(daemon_pid): + capture = [] + for process in Path("/proc").glob("[0-9]*"): + try: + parent = int((process / "stat").read_text().rsplit(")", 1)[1].split()[1]) + if parent == daemon_pid and (process / "comm").read_text().strip() == "wrec-capture": + capture.append(int(process.name)) + except (FileNotFoundError, ProcessLookupError, IndexError, ValueError): + pass + return capture + + +def without_workers(daemon_pid): + deadline = time.monotonic() + 5 + while workers(daemon_pid) and time.monotonic() < deadline: + time.sleep(0.1) + return not workers(daemon_pid) + + +def unmap(display, window): + xlib = ctypes.CDLL("libX11.so.6") + xlib.XOpenDisplay.restype = ctypes.c_void_p + connection = xlib.XOpenDisplay(display.encode()) + assert connection, f"could not open {display}" + xlib.XUnmapWindow(ctypes.c_void_p(connection), ctypes.c_ulong(window)) + xlib.XCloseDisplay(ctypes.c_void_p(connection)) + + +def main(): + binary = Path(sys.argv[1]).resolve() + with tempfile.TemporaryDirectory(prefix="wrec-x11-capture-") as directory: + root = Path(directory) + env = os.environ.copy() + for key in ("WAYLAND_DISPLAY", "WREC_DAEMON_BIN", "WREC_CHANNEL", "WREC_HEADLESS"): + env.pop(key, None) + env.update(WREC_HOME=str(root / "home"), WREC_DATA_DIR=str(root / "data")) + with open(root / "xvfb.log", "w") as log: + screens = ["-screen", "0", "800x600x24", "-screen", "1", "3840x2160x24", "-nolisten", "tcp"] + desktop = subprocess.Popen(["Xvfb", "-displayfd", "1", *screens], stdout=subprocess.PIPE, stderr=log, text=True) + window = None + try: + with selectors.DefaultSelector() as selector: + selector.register(desktop.stdout, selectors.EVENT_READ) + assert selector.select(10), "Xvfb did not announce a display" + env["DISPLAY"] = ":" + desktop.stdout.readline().strip() + window = subprocess.Popen(["xmessage", "-title", "Wrec capture test", "-geometry", "500x250+50+50", "Wrec Linux capture: this is a real X11 test window."], env=env, stdout=log, stderr=log) + + def run(*args): + result = subprocess.run([str(binary), *args], env=env, capture_output=True, text=True, timeout=25) + assert result.returncode == 0, (args, result.stdout, result.stderr) + return json.loads(result.stdout) + + def job(job_id): + return run("job", "show", str(job_id), "--json")["job"] + + def wait(job_id, status): + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + value = job(job_id) + if value["status"] == status: + return value + assert value["status"] not in ("failed", "cancelled"), value + time.sleep(0.1) + raise AssertionError(value) + + def submit(target, codec, duration=None): + args = ["record", "--target", target, "--codec", codec, "--no-system-audio", "--out", str(root / "movies"), "--detach", "--json"] + if duration: + args.extend(["--duration", duration]) + return run(*args)["job"]["id"] + + def verify(value, codec): + path = value["output_path"] + assert Path(path).is_file(), value + probe = subprocess.run(["ffprobe", "-v", "error", "-show_streams", "-show_packets", "-of", "json", path], capture_output=True, text=True, check=True) + data = json.loads(probe.stdout) + assert data["streams"][0]["codec_name"] == codec, data["streams"] + previous = -float("inf") + for packet in data["packets"]: + dts = int(packet["dts"]) + assert dts > previous, (previous, dts) + previous = dts + decoded = subprocess.run(["ffmpeg", "-v", "error", "-i", path, "-vsync", "0", "-enc_time_base", "-1", "-f", "null", "-"], capture_output=True, text=True) + assert decoded.returncode == 0 and not decoded.stderr, decoded.stderr + assert value["settings"]["hide_wrec"] is False + assert value["settings"]["show_mic_indicator"] is False + assert any(w["code"] == "linux_settings_unavailable" for w in value["warnings"]), value + + for _ in range(50): + targets = run("targets", "--json") + windows = [t for t in targets if t["kind"] == "window" and "Wrec capture test" in t["name"]] + if windows: + break + time.sleep(0.1) + assert windows, targets + print("PASS: X11 display and named-window discovery") + + display_id = submit("display:0", "h264", "2s") + display = wait(display_id, "completed") + verify(display, "h264") + assert any("software encoding" in e["message"] for e in display["events"]), display + print("PASS: real X11 display capture, automatic software fallback, H.264 decode, increasing timestamps") + + window_id = submit(f'window:{windows[0]["id"]}', "hevc") + wait(window_id, "recording") + time.sleep(0.5) + run("job", "pause", str(window_id), "--json") + wait(window_id, "paused") + time.sleep(0.3) + run("job", "resume", str(window_id), "--json") + time.sleep(0.5) + run("job", "pause", str(window_id), "--json") + run("job", "stop", str(window_id), "--json") + verify(wait(window_id, "completed"), "hevc") + print("PASS: real X11 window capture, HEVC decode, pause/resume, and finalization while paused") + + daemon_pid = run("daemon", "status", "--json")["pid"] + closing = subprocess.Popen(["xmessage", "-title", "Wrec closing window", "-geometry", "300x150+100+100", "Wrec Linux capture: this window closes mid-recording."], env=env, stdout=log, stderr=log) + try: + for _ in range(50): + closing_windows = [target for target in run("targets", "--json") if target["kind"] == "window" and "Wrec closing window" in target["name"]] + if closing_windows: + break + time.sleep(0.1) + assert closing_windows, "closing window was not discovered" + closing_id = submit(f'window:{closing_windows[0]["id"]}', "h264") + wait(closing_id, "recording") + after_id = submit("display:0", "h264", "2s") + assert job(after_id)["status"] == "queued", job(after_id) + time.sleep(1) + finally: + closing.terminate() + closing.wait(timeout=5) + lost = wait(closing_id, "failed") + assert any("X11 window closed" in event["message"] for event in lost["events"]), lost + assert run("daemon", "status", "--json")["pid"] == daemon_pid, "daemon restarted after the captured window closed" + verify(wait(after_id, "completed"), "h264") + assert run("daemon", "status", "--json")["pid"] == daemon_pid, "daemon restarted during capture after source loss" + print("PASS: closing a captured X11 window fails only that recording; the queued job records next in the same daemon") + + hidden = subprocess.Popen(["xmessage", "-title", "Wrec unmapped window", "-geometry", "300x150+120+120", "Wrec Linux capture: this window is unmapped mid-recording."], env=env, stdout=log, stderr=log) + try: + for _ in range(50): + hidden_windows = [target for target in run("targets", "--json") if target["kind"] == "window" and "Wrec unmapped window" in target["name"]] + if hidden_windows: + break + time.sleep(0.1) + assert hidden_windows, "unmapped window was not discovered" + hidden_id = submit(f'window:{hidden_windows[0]["id"]}', "h264") + wait(hidden_id, "recording") + after_id = submit("display:0", "h264", "2s") + time.sleep(1) + unmapped_at = time.monotonic() + unmap(env["DISPLAY"], hidden_windows[0]["id"]) + unmapped = wait(hidden_id, "failed") + assert time.monotonic() - unmapped_at < 5, "unmapping the captured window was not detected promptly" + assert any("was minimized" in event["message"] for event in unmapped["events"]), unmapped + verify(wait(after_id, "completed"), "h264") + finally: + hidden.terminate() + hidden.wait(timeout=5) + assert run("daemon", "status", "--json")["pid"] == daemon_pid, "daemon restarted after the captured window was unmapped" + print("PASS: unmapping a captured X11 window fails only that recording; the queued job records next in the same daemon") + + frozen_id = submit("display:0", "h264") + wait(frozen_id, "recording") + frozen_workers = workers(daemon_pid) + assert len(frozen_workers) == 1, frozen_workers + queued_id = submit("display:0", "h264", "2s") + assert job(queued_id)["status"] == "queued", job(queued_id) + os.kill(frozen_workers[0], signal.SIGSTOP) + run("job", "stop", str(frozen_id), "--json") + deadline = time.monotonic() + 40 + while job(frozen_id)["status"] == "finishing" and time.monotonic() < deadline: + time.sleep(0.2) + frozen = job(frozen_id) + assert frozen["status"] == "failed", frozen + assert any("did not exit within 20s" in event["message"] for event in frozen["events"]), frozen + assert not Path(f"/proc/{frozen_workers[0]}").exists(), "frozen capture worker was not reaped" + verify(wait(queued_id, "completed"), "h264") + assert run("daemon", "status", "--json")["pid"] == daemon_pid, "daemon restarted after a frozen worker" + print("PASS: a frozen capture worker is killed 20s after stop; the queued job records next in the same daemon") + + large_display = next(target for target in targets if target["kind"] == "display" and "3840×2160" in target["name"]) + worker_peak_mib = 0 + for codec in ("hevc", "h264", "hevc"): + memory_id = run("record", "--target", f'display:{large_display["id"]}', "--codec", codec, "--quality", "high", "--resolution", "native", "--no-system-audio", "--no-mic", "--duration", "2s", "--out", str(root / "movies"), "--detach", "--json")["job"]["id"] + deadline = time.monotonic() + 30 + while job(memory_id)["status"] in ("queued", "starting", "recording", "finishing") and time.monotonic() < deadline: + for worker in workers(daemon_pid): + worker_peak_mib = max(worker_peak_mib, memory_mib(worker, "VmHWM") or 0) + time.sleep(0.2) + verify(wait(memory_id, "completed"), codec) + time.sleep(0.3) + idle_rss_mib = memory_mib(daemon_pid, "VmRSS") + assert idle_rss_mib < 256, f"4K encoder retained {idle_rss_mib:.1f} MiB after teardown" + assert without_workers(daemon_pid), "capture worker outlived its recording" + assert worker_peak_mib > 0, "4K capture did not run in a wrec-capture worker" + print(f"PASS: repeated 4K capture runs in a worker ({worker_peak_mib:.1f} MiB peak worker RSS) and leaves {idle_rss_mib:.1f} MiB daemon RSS") + + display_number = env["DISPLAY"][1:] + for interrupted in ("recording", "paused"): + lost_id = submit("display:0", "h264") + wait(lost_id, "recording") + capture_workers = workers(daemon_pid) + assert len(capture_workers) == 1, capture_workers + queued_id = submit("display:0", "h264", "2s") + assert job(queued_id)["status"] == "queued", job(queued_id) + time.sleep(0.5) + if interrupted == "paused": + run("job", "pause", str(lost_id), "--json") + wait(lost_id, "paused") + desktop.kill() + desktop.wait(timeout=5) + lost = wait(lost_id, "failed") + assert any("capture worker exited unexpectedly" in event["message"] for event in lost["events"]), lost + queued = job(queued_id) + deadline = time.monotonic() + 30 + while queued["status"] not in ("completed", "failed", "cancelled") and time.monotonic() < deadline: + time.sleep(0.1) + queued = job(queued_id) + assert queued["status"] == "failed", queued + assert run("daemon", "status", "--json")["pid"] == daemon_pid, "losing the X server restarted the daemon" + assert without_workers(daemon_pid), "crashed capture worker was not reaped" + desktop = subprocess.Popen(["Xvfb", f":{display_number}", *screens], stderr=log) + for _ in range(50): + if subprocess.run([str(binary), "targets", "--json"], env=env, capture_output=True).returncode == 0: + break + time.sleep(0.1) + after_id = submit("display:0", "h264", "2s") + verify(wait(after_id, "completed"), "h264") + assert run("daemon", "status", "--json")["pid"] == daemon_pid, "daemon restarted after X server loss" + print(f"PASS: losing the X server while {interrupted} fails that job and the queued job; the same daemon records after X returns") + + orphan_id = submit("display:0", "h264") + wait(orphan_id, "recording") + orphan_workers = workers(daemon_pid) + assert len(orphan_workers) == 1, orphan_workers + os.kill(daemon_pid, signal.SIGKILL) + deadline = time.monotonic() + 5 + while Path(f"/proc/{orphan_workers[0]}").exists() and time.monotonic() < deadline: + time.sleep(0.1) + assert not Path(f"/proc/{orphan_workers[0]}").exists(), "capture worker outlived a killed daemon" + assert run("daemon", "start", "--json")["pid"] != daemon_pid, "a new daemon did not start after the old one was killed" + print("PASS: killing the daemon kills its capture worker") + run("daemon", "stop", "--json") + finally: + subprocess.run([str(binary), "daemon", "stop", "--json"], env=env, capture_output=True, timeout=20) + if window: + window.terminate() + window.wait(timeout=5) + desktop.terminate() + desktop.wait(timeout=5) + + +if __name__ == "__main__": + main() diff --git a/scripts/test-cli-linux.py b/scripts/test-cli-linux.py new file mode 100644 index 0000000..d1f7bd4 --- /dev/null +++ b/scripts/test-cli-linux.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Exercise an extracted Linux package without a desktop, GPU, or user config.""" + +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import tempfile +import time + + +def main(): + prefix = Path(sys.argv[1]).resolve() + binary = prefix / "bin" / "wrec" + with tempfile.TemporaryDirectory(prefix="wrec-linux-smoke-") as directory: + env = os.environ.copy() + for key in ("WREC_DAEMON_BIN", "WREC_CAPTURE_ENGINE_PATH", "WREC_HEADLESS", + "WREC_CHANNEL", "WAYLAND_DISPLAY", "DISPLAY", "XDG_RUNTIME_DIR"): + env.pop(key, None) + env["WREC_HOME"] = str(Path(directory) / "home") + env["WREC_DATA_DIR"] = str(Path(directory) / "data") + + def run(*args, success=True): + result = subprocess.run([str(binary), *args], env=env, text=True, + capture_output=True, timeout=20) + assert (result.returncode == 0) == success, (args, result.stdout, result.stderr) + return result + + # A sibling executable must not shadow the archive's own runtime. + decoy = prefix / "bin" / "daemon" + assert not decoy.exists(), decoy + decoy.write_text("#!/bin/sh\nexit 99\n") + decoy.chmod(0o755) + pid = None + try: + status = json.loads(run("daemon", "start", "--json").stdout) + assert status["channel"] == "release", status + pid = status["pid"] + executable = Path(f"/proc/{pid}/exe").resolve() + assert executable == prefix / "lib" / "wrec" / "daemon", executable + print("PASS: packaged CLI automatically launches its relative Linux daemon") + + result = run("targets", "--json", success=False) + assert "Wayland or X11 desktop session" in result.stdout + result.stderr + print("PASS: headless target discovery returns a useful error") + + result = run("record", "--target", "display:0", "--duration", "1s", "--json", success=False) + assert "Wayland or X11 desktop session" in result.stdout + result.stderr + assert not list(Path(directory).rglob("*.mp4")) + print("PASS: unsupported capture fails without a recording artifact") + + jobs = json.loads(run("jobs", "--json").stdout) + assert jobs["jobs"] == [], jobs + print("PASS: rejected requests leave no active or queued jobs") + + run("daemon", "stop", "--json") + socket = Path(env["WREC_HOME"]) / "wrec.sock" + for _ in range(100): + if not socket.exists(): + break + time.sleep(0.05) + assert not socket.exists(), "daemon socket survived shutdown" + pid = None + print("PASS: clean daemon shutdown removes the socket") + + status = json.loads(run("daemon", "start", "--json").stdout) + pid = status["pid"] + os.kill(pid, signal.SIGTERM) + for _ in range(100): + if not socket.exists(): + break + time.sleep(0.05) + assert not socket.exists(), "daemon socket survived SIGTERM" + pid = None + print("PASS: restarted daemon handles SIGTERM and removes the socket") + finally: + decoy.unlink() + if pid is not None: + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + pass + + +if __name__ == "__main__": + main()