diff --git a/.forgejo/workflows/beta-release.yml b/.forgejo/workflows/beta-release.yml index 578cae6..08f8c7b 100644 --- a/.forgejo/workflows/beta-release.yml +++ b/.forgejo/workflows/beta-release.yml @@ -1,11 +1,11 @@ name: beta release -# Publishes a beta-track build on every push to `beta` — a frozen -# stabilization branch cut from `dev` when ready to stabilize; only -# fix/ branches merged into `beta` should land here afterward. -# See bread-ecosystem's docs/release-channels.md for the three-track policy. +# Publishes a beta-track build from a `vX.Y.Z-rc.N` tag. The leftover +# `beta` branch trigger is kept so an old branch push does not go silent; +# do not use that branch — cut beta from an RC tag. See CONTRIBUTING.md. on: push: + tags: ['v*-rc.*'] branches: ['beta'] jobs: @@ -16,7 +16,7 @@ jobs: run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch beta --depth 1 \ + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build @@ -25,19 +25,21 @@ jobs: - name: compute beta version run: | set -euo pipefail + # An RC tag is already valid semver and is the beta version. + # The leftover `beta` branch path still synthesizes one from the + # latest stable tag so an old push does not publish as 0.0.0. + if [[ "${GITHUB_REF_NAME}" == v*-rc.* ]]; then + echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" + exit 0 + fi cd src - # Base the beta version off the latest published stable tag, - # not Cargo.toml — Cargo.toml can go stale relative to the last - # real release (seen in practice: breadbox/breadpad/breadcrumbs/ - # breadpaper), which would make a beta build sort as OLDER than - # what's already installed and bakery would correctly refuse it. LATEST_TAG="$(git ls-remote --tags --refs \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ - | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" + | awk -F/ '{print $NF}' | sed 's/^v//' | grep -v -- '-rc' | sort -V | tail -1)" if [ -n "${LATEST_TAG}" ]; then CUR="${LATEST_TAG}" else - CUR="$(grep -m1 '^version' breadcast/Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" fi IFS='.' read -r MA MI PA <<< "${CUR}" SHA="$(git rev-parse --short HEAD)" diff --git a/.forgejo/workflows/check.yml b/.forgejo/workflows/check.yml index b547c34..65ae3e6 100644 --- a/.forgejo/workflows/check.yml +++ b/.forgejo/workflows/check.yml @@ -4,7 +4,7 @@ name: check # main and triggers a dev-track release build. on: push: - branches: ['feature/**', 'fix/**'] + branches: ['main', 'feature/**', 'fix/**'] jobs: check: diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index 0631be8..0207d3d 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -1,12 +1,11 @@ name: dev release -# Publishes a dev-track build on every push to `dev` — -# separate from release.yml's tag-triggered stable releases. See -# bread-ecosystem's docs/release-channels.md for the three-track policy -# this is part of. +# Publishes a dev-track build on every push to `main` (single-trunk). +# The leftover `dev` trigger is kept so an old branch push still works +# until that branch is deleted. See CONTRIBUTING.md. on: push: - branches: ['dev'] + branches: ['main', 'dev'] jobs: build: @@ -16,7 +15,7 @@ jobs: run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch dev --depth 1 \ + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build @@ -37,7 +36,7 @@ jobs: if [ -n "${LATEST_TAG}" ]; then CUR="${LATEST_TAG}" else - CUR="$(grep -m1 '^version' breadcast/Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" fi IFS='.' read -r MA MI PA <<< "${CUR}" SHA="$(git rev-parse --short HEAD)" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 9d08de9..bc62a22 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -6,6 +6,9 @@ on: jobs: build: + # RC tags are `v*` too (`v0.1.1-rc.1`) — those belong on the beta + # track, see beta-release.yml. A stable cut is a plain `vX.Y.Z`. + if: ${{ !contains(github.ref_name, '-rc') }} runs-on: [self-hosted, hestia] steps: - name: checkout diff --git a/.gitignore b/.gitignore index 272a817..c3b4cc4 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ CLAUDE.md # graphify knowledge-graph output (local tool cache, not for commit) graphify-out/ + +# .freebuff local tool state (not for commit) +.freebuff/ diff --git a/AGENTS.md b/AGENTS.md index 29ba49e..f6c0ed9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,15 +20,13 @@ out of sync with `dev`/`beta` across most repos in this ecosystem. - `github` — GitHub mirror. Push both when publishing. ## CI -- `release.yml` triggers on `push: tags: ['v*']` — tag a release to cut - the signed stable build. -- Leftover: `dev-release.yml` still triggers on `push: branches: ['dev']` - and `beta-release.yml` still triggers on `push: branches: ['beta']`. - Those files are leftover from the three-branch model. Do **not** rewrite - them unless you are deliberately migrating CI; document and follow - single-trunk `main` + RC tags instead. -- `check.yml` runs clippy + test on `feature/**` and `fix/**`. -- No separate lint/PR-check pipeline on ordinary commits to `main`. +- `release.yml` triggers on `push: tags: ['v*']` but skips any tag whose + name contains `-rc` — a plain `vX.Y.Z` cuts the signed stable build. +- `beta-release.yml` publishes a beta-track build from a `vX.Y.Z-rc.N` + tag (and still on leftover `beta` so an old branch push does not go silent). +- `dev-release.yml` publishes a dev-track build on push to `main` (and + still on leftover `dev` so an old branch push does not go silent). +- `check.yml` runs clippy + test on `main`, `feature/**`, and `fix/**`. ## Product cut breadcast is a **bakery product**, not shipped on the BOS ISO, and not part @@ -41,9 +39,9 @@ The daemon + GTK picker + Cast Streaming / DLNA pipelines are built and validated against real hardware. `EVENTS.md` is the bus contract. ## Pins -`bread-theme` and `bread-utils` pin -`git.breadway.dev/Breadway/bread-ecosystem` tag `v0.7.1`. Do not switch -those to github.com or `branch = "main"`. +`bread-theme` pins `git.breadway.dev/Breadway/bread-ecosystem` tag +`v0.7.4` (per-monitor palette). `bread-utils` pins the same repo at +`v0.7.2`. Do not switch those to github.com or `branch = "main"`. ## Don't - Don't embed credentials in remote URLs — SSH or a credential helper only. diff --git a/Cargo.lock b/Cargo.lock index 0f0b911..3e8454c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -63,9 +63,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", @@ -92,9 +92,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.3" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", "zeroize", @@ -102,9 +102,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.43.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", @@ -132,8 +132,8 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.7.2" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" +version = "0.7.4" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#fcba3760387e2523edb71350f8efea3bc851b21e" dependencies = [ "dirs", "gtk4", @@ -187,6 +187,7 @@ dependencies = [ "gstreamer", "gstreamer-app", "gstreamer-video", + "if-addrs 0.15.0", "mdns-sd", "rupnp", "rust_cast", @@ -253,14 +254,14 @@ checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] name = "cc" -version = "1.4.0" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -429,9 +430,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "flume" @@ -452,24 +453,24 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -478,9 +479,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -497,32 +498,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-macro", @@ -553,7 +554,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -586,7 +587,7 @@ dependencies = [ "libc", "pango-sys", "pkg-config", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -653,7 +654,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", "windows-sys 0.61.2", ] @@ -717,7 +718,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233" dependencies = [ "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -728,7 +729,7 @@ checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -749,7 +750,7 @@ checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -780,7 +781,7 @@ dependencies = [ "graphene-sys", "libc", "pango-sys", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -805,7 +806,7 @@ dependencies = [ "pastey", "pin-project-lite", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -833,7 +834,7 @@ dependencies = [ "gstreamer-base-sys", "gstreamer-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -860,7 +861,7 @@ dependencies = [ "gobject-sys", "gstreamer-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -873,7 +874,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -889,7 +890,7 @@ dependencies = [ "gstreamer-base", "gstreamer-video-sys", "libc", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -903,7 +904,7 @@ dependencies = [ "gstreamer-base-sys", "gstreamer-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -929,9 +930,9 @@ dependencies = [ [[package]] name = "gtk4-layer-shell" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" +checksum = "17c28ea0f4676fdaaae7ff2413a24d0d35c8657424f84856c1103c73454c9da4" dependencies = [ "bitflags", "gdk4", @@ -944,15 +945,15 @@ dependencies = [ [[package]] name = "gtk4-layer-shell-sys" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f566a5ec5bcc454e7fcf2ab76930887ced5365afce12c1e5201bb296b95f1b9" +checksum = "bcf19bb884ef0ef55b9e6b2b369c39b4fcc0c41e3a0c1cbc8c267720338b690b" dependencies = [ "gdk4-sys", "glib-sys", "gtk4-sys", "libc", - "system-deps", + "system-deps 8.0.0", ] [[package]] @@ -983,7 +984,7 @@ dependencies = [ "gsk4-sys", "libc", "pango-sys", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -1035,9 +1036,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -1155,9 +1156,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -1193,9 +1194,9 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ "libc", ] @@ -1286,9 +1287,9 @@ dependencies = [ [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -1369,7 +1370,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -1392,9 +1393,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "proc-macro-crate" @@ -1505,9 +1506,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1572,7 +1573,7 @@ dependencies = [ "rustls-native-certs", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1612,9 +1613,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", "log", @@ -1648,9 +1649,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" dependencies = [ "aws-lc-rs", "ring", @@ -1906,6 +1907,19 @@ dependencies = [ "version-compare", ] +[[package]] +name = "system-deps" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83779a5c956bcb6ba627a4ecf0a9d7625db47d7537e0892d97f712ac995648a3" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml 1.1.4+spec-1.1.0", + "version-compare", +] + [[package]] name = "target-lexicon" version = "0.13.5" @@ -1936,11 +1950,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -1956,9 +1970,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -2005,13 +2019,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -2200,9 +2214,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "uuid" -version = "1.24.0" +version = "1.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ "js-sys", "serde_core", @@ -2238,9 +2252,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -2251,9 +2265,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2261,9 +2275,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -2274,9 +2288,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -2476,15 +2490,15 @@ dependencies = [ [[package]] name = "xml-rs" -version = "0.8.28" +version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "zbus" -version = "5.18.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ "async-broadcast", "async-recursion", @@ -2512,14 +2526,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.18.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", "zbus_names", "zvariant", "zvariant_utils", @@ -2536,6 +2550,15 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zeroize" version = "1.9.0" @@ -2550,40 +2573,41 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zvariant" -version = "5.13.1" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" dependencies = [ "endi", "enumflags2", "serde", "winnow 1.0.4", + "zcheapstr", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.13.1" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.5.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.119", + "syn 3.0.3", "winnow 1.0.4", ] diff --git a/EVENTS.md b/EVENTS.md index d198d1a..6b76d57 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -16,7 +16,7 @@ discovery-driven emit half. | Event | Data | When | |-------|------|------| -| `bread.cast.device_found` | `{ "id": "", "name": "", "model": "", "protocol": "cast" \| "dlna" }` | A Chromecast/Google TV (mDNS) or DLNA/UPnP media renderer (SSDP) is discovered (or re-resolved) on the LAN. Fires on every re-resolution, not just the first sighting — treat it as an upsert keyed by `id`, not an append-only log. `id` is protocol-specific and only unique *within* a protocol — a Cast device's mDNS id and a DLNA device's description URL share no namespace. | +| `bread.cast.device_found` | `{ "id": "", "name": "", "model": "", "protocol": "cast" \| "dlna" }` | A Chromecast/Google TV (mDNS) or DLNA/UPnP media renderer (SSDP) is discovered, or an already-known device changes (new host, new name). Re-resolutions that carry the same payload are **not** re-emitted — treat it as an upsert keyed by `id`, not an append-only log. `id` is protocol-specific and only unique *within* a protocol — a Cast device's mDNS id and a DLNA device's description URL share no namespace. | | `bread.cast.mirroring_started` | `{ "device_id": "", "device_name": "", "protocol": "cast" \| "dlna" }` | A mirroring session successfully started, whether triggered by `bread.command.cast.start`, the `breadcast` GTK popup, or (once wired) any other IPC client. | | `bread.cast.mirroring_stopped` | `{}` | A mirroring session ended, whether via an explicit stop (command, IPC, or GTK popup) or unprompted (the portal picker's "stop sharing", the receiver dropping the connection, a DLNA renderer stopping playback from its own remote). There is no separate "stopped by whom" distinction in this event — `breadcastd`'s own logs have that detail if needed. | | `bread.cast.mirroring_failed` | `{ "device_id": "", "error": "" }` | A `start_cast`/`bread.command.cast.start` attempt failed before a session was established (device unreachable, portal capture denied, negotiation timeout, renderer rejected the stream, etc). | diff --git a/bakery.toml b/bakery.toml index 68e5c5d..a6416b0 100644 --- a/bakery.toml +++ b/bakery.toml @@ -1,7 +1,9 @@ name = "breadcast" description = "Cast your screen to any Chromecast/Google TV or DLNA renderer — daemon + GTK4 popup" binaries = ["breadcast", "breadcastd"] -# gst-plugin-va: the `vah264enc` element both encode pipelines use (see +# gst-plugins-base: videoconvert/videoscale/videorate/appsink -- the +# `gstreamer` package is only the core library. gst-plugin-va: the +# `vah264enc` element both encode pipelines use (see # breadcast-core/src/pipeline/mod.rs) -- a separate Arch package from # gst-plugins-bad itself, not bundled into it. jsoncpp/openssl: runtime # shared-library deps of breadcastd itself (not just a build dep of @@ -14,6 +16,7 @@ system_deps = [ "gtk4", "gtk4-layer-shell", "gstreamer", + "gst-plugins-base", "gst-plugin-pipewire", "gst-plugins-bad", "gst-plugin-hlssink3", diff --git a/breadcast-caststream-sys/src/facade.cc b/breadcast-caststream-sys/src/facade.cc index 3ec4288..75139ac 100644 --- a/breadcast-caststream-sys/src/facade.cc +++ b/breadcast-caststream-sys/src/facade.cc @@ -209,9 +209,20 @@ CastStreamSender* breadcast_caststream_sender_create( // than once process-wide only if ShutDown() was called first -- breadcast // only ever has one active cast-streaming session at a time, so this // assumption (baked into PlatformClientPosix's own singleton design) holds. + // + // Create() itself OSP_CHECKs that no instance exists and aborts the + // process if a previous sender leaked (a failed start that never reached + // destroy()). Fail the new create instead of taking the daemon down. + if (openscreen::PlatformClientPosix::GetInstance() != nullptr) { + return nullptr; + } openscreen::PlatformClientPosix::Create(std::chrono::milliseconds(50)); - openscreen::TaskRunner& task_runner = - openscreen::PlatformClientPosix::GetInstance()->GetTaskRunner(); + openscreen::PlatformClientPosix* instance = + openscreen::PlatformClientPosix::GetInstance(); + if (instance == nullptr) { + return nullptr; + } + openscreen::TaskRunner& task_runner = instance->GetTaskRunner(); breadcast_caststream::VideoParams params; params.width = width; @@ -255,6 +266,13 @@ CastStreamSender* breadcast_caststream_sender_create( } void breadcast_caststream_sender_negotiate(CastStreamSender* sender) { + // environment is reset during destroy() on the TaskRunner thread; a call + // that races teardown (or arrives after a failed start) must not + // dereference the unique_ptr. + if (!sender || sender->shutting_down.load(std::memory_order_acquire) || + !sender->environment) { + return; + } sender->environment->task_runner().PostTask([sender] { if (sender->shutting_down.load(std::memory_order_acquire) || !sender->session) { @@ -272,6 +290,10 @@ void breadcast_caststream_sender_on_message(CastStreamSender* sender, size_t message_namespace_len, const char* message, size_t message_len) { + if (!sender || sender->shutting_down.load(std::memory_order_acquire) || + !sender->environment) { + return; + } auto source = std::make_shared(source_id, source_id_len); auto ns = std::make_shared(message_namespace, message_namespace_len); auto body = std::make_shared(message, message_len); @@ -289,6 +311,10 @@ int32_t breadcast_caststream_sender_enqueue_frame(CastStreamSender* sender, size_t data_len, int32_t is_key_frame, int64_t capture_time_us) { + if (!sender || sender->shutting_down.load(std::memory_order_acquire) || + !sender->environment) { + return -1; + } if (!sender->negotiated.load(std::memory_order_acquire)) { return -1; } @@ -378,6 +404,12 @@ int32_t breadcast_caststream_sender_enqueue_frame(CastStreamSender* sender, switch (video_sender->EnqueueFrame(frame)) { case Sender::OK: sender->enqueue_ok.fetch_add(1, std::memory_order_relaxed); + // A key frame that actually landed resyncs the decoder, so the + // "next frame must be an IDR" latch can clear. A rejected key + // frame leaves the flag set (the reject branches below). + if (is_key) { + sender->frame_chain_broken.store(false, std::memory_order_relaxed); + } break; case Sender::PAYLOAD_TOO_LARGE: sender->enqueue_payload_too_large.fetch_add(1, std::memory_order_relaxed); @@ -418,10 +450,24 @@ void breadcast_caststream_sender_take_stats(CastStreamSender* sender, } int32_t breadcast_caststream_sender_needs_key_frame(CastStreamSender* sender) { - return sender->needs_key_frame.load(std::memory_order_relaxed) ? 1 : 0; + if (!sender) { + return 0; + } + // `frame_chain_broken` is the drop-side latch SchedulePoll must not + // clobber (see the field comment). Load, don't exchange: the encoder + // may take several frames to honour a force-key-unit, and a consuming + // read here would forget the drop if the next poll happened before + // that IDR was actually EnqueueFrame'd (cleared on OK + is_key above). + return (sender->frame_chain_broken.load(std::memory_order_relaxed) || + sender->needs_key_frame.load(std::memory_order_relaxed)) + ? 1 + : 0; } int32_t breadcast_caststream_sender_estimated_bandwidth_bps(CastStreamSender* sender) { + if (!sender) { + return 0; + } return sender->estimated_bandwidth_bps.load(std::memory_order_relaxed); } @@ -437,18 +483,24 @@ void breadcast_caststream_sender_destroy(CastStreamSender* sender) { // These must be torn down on the TaskRunner thread (they hold raw // references into it and into `environment`), so hop over there and block - // until it's done before shutting the TaskRunner itself down. - std::promise done; - std::future done_future = done.get_future(); - sender->environment->task_runner().PostTask([sender, &done] { - sender->session.reset(); - sender->message_port.reset(); - sender->environment.reset(); - done.set_value(); - }); - done_future.wait(); + // until it's done before shutting the TaskRunner itself down. A sender + // whose Environment never got built (create failed mid-flight) has no + // task runner to hop to. + if (sender->environment) { + std::promise done; + std::future done_future = done.get_future(); + sender->environment->task_runner().PostTask([sender, &done] { + sender->session.reset(); + sender->message_port.reset(); + sender->environment.reset(); + done.set_value(); + }); + done_future.wait(); + } - openscreen::PlatformClientPosix::ShutDown(); + if (openscreen::PlatformClientPosix::GetInstance() != nullptr) { + openscreen::PlatformClientPosix::ShutDown(); + } delete sender; } diff --git a/breadcast-caststream-sys/src/facade.h b/breadcast-caststream-sys/src/facade.h index 8411721..840d890 100644 --- a/breadcast-caststream-sys/src/facade.h +++ b/breadcast-caststream-sys/src/facade.h @@ -88,15 +88,18 @@ void breadcast_caststream_sender_on_message(CastStreamSender* sender, const char* message, size_t message_len); -// Enqueues one encoded video access unit (Annex-B H.264) for sending. -// `data` is copied before this returns, so the caller may reuse/free its -// buffer immediately after. `capture_time_us` is only used to derive the -// RTP timestamp's relative spacing between frames (it does not need to be -// wall-clock-accurate, just monotonically increasing and proportional to -// real elapsed time between frames). Returns 0 if queued, nonzero if the -// session isn't negotiated yet or the frame was rejected (e.g. too large, -// or the in-flight queue is full -- the caller should back off encoding -// when this happens rather than treating it as fatal). +// Posts one encoded video access unit (Annex-B H.264) onto openscreen's +// TaskRunner for sending. `data` is copied before this returns, so the +// caller may reuse/free its buffer immediately after. `capture_time_us` is +// only used to derive the RTP timestamp's relative spacing between frames +// (it does not need to be wall-clock-accurate, just monotonically +// increasing and proportional to real elapsed time between frames). +// +// Returns 0 if the frame was *posted* (not necessarily accepted -- +// Sender::EnqueueFrame runs later on the TaskRunner and may still reject +// it), or nonzero if the session isn't negotiated yet / is shutting down. +// Accept/reject outcomes are visible only via take_stats() and, for +// dropped frames that break the H.264 reference chain, needs_key_frame(). int32_t breadcast_caststream_sender_enqueue_frame(CastStreamSender* sender, const uint8_t* data, size_t data_len, diff --git a/breadcast-caststream-sys/src/lib.rs b/breadcast-caststream-sys/src/lib.rs index 5837002..222bb6f 100644 --- a/breadcast-caststream-sys/src/lib.rs +++ b/breadcast-caststream-sys/src/lib.rs @@ -1,8 +1,8 @@ //! Raw FFI bindings to `src/facade.h`/`src/facade.cc`, which wrap a pruned, //! vendored subset of `chromium/openscreen`'s Cast Streaming sender (see //! `vendor/openscreen/PATCHES.md`). This crate is intentionally low-level and -//! unsafe -- see `breadcast-caststream` (not this crate) for the ergonomic, -//! thread-safe wrapper most callers should use instead. +//! unsafe -- see `breadcast-core::caststream` for the ergonomic, thread-safe +//! wrapper most callers should use instead. //! //! # Threading contract //! diff --git a/breadcast-core/Cargo.toml b/breadcast-core/Cargo.toml index b454309..c376505 100644 --- a/breadcast-core/Cargo.toml +++ b/breadcast-core/Cargo.toml @@ -21,6 +21,10 @@ gstreamer-video = "0.25" tiny_http = "0.12" rupnp = "3.0.0" futures-util = "0.3.33" +# Fallback LAN-IP enumeration used only when the `ip` command is unavailable +# (net.rs) -- already a transitive dep via mdns-sd, so this adds no new crate +# to the build. +if-addrs = "0.15" breadcast-caststream-sys = { path = "../breadcast-caststream-sys" } [dev-dependencies] diff --git a/breadcast-core/src/capture/portal.rs b/breadcast-core/src/capture/portal.rs index 77b99cc..4241c97 100644 --- a/breadcast-core/src/capture/portal.rs +++ b/breadcast-core/src/capture/portal.rs @@ -117,6 +117,10 @@ impl CaptureSession { .open(&token_path) { use std::io::Write; + use std::os::unix::fs::PermissionsExt; + // `mode()` only applies on create. A pre-existing + // world-readable token would keep its mode otherwise. + let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600)); let _ = file.write_all(token.as_bytes()); } } diff --git a/breadcast-core/src/caststream.rs b/breadcast-core/src/caststream.rs index 433efa1..9815cfd 100644 --- a/breadcast-core/src/caststream.rs +++ b/breadcast-core/src/caststream.rs @@ -225,16 +225,18 @@ impl CastStreamSender { } } - /// Enqueues one encoded video access unit (Annex-B H.264) for sending. - /// `capture_time_us` only needs to be monotonically increasing and - /// proportional to real elapsed time between frames -- it does not need - /// to be wall-clock-accurate. + /// Posts one encoded video access unit (Annex-B H.264) onto the C++ + /// TaskRunner. `capture_time_us` only needs to be monotonically + /// increasing and proportional to real elapsed time between frames -- + /// it does not need to be wall-clock-accurate. /// - /// Returns an error if the session isn't negotiated yet or the frame - /// was rejected under backpressure; callers should treat the latter as - /// a dropped frame, not a fatal condition (see - /// [`Self::needs_key_frame`]/[`Self::estimated_bandwidth_bps`] for how - /// to react). + /// Returns an error only if the session isn't negotiated (or is + /// shutting down). A `Ok(())` means the frame was *posted*, not that + /// `Sender::EnqueueFrame` accepted it -- accept/reject is visible via + /// [`Self::enqueue_stats`], and a reject that breaks the H.264 + /// reference chain latches [`Self::needs_key_frame`]. Treating this + /// return as accept/reject is how an earlier version hid a frozen + /// picture behind a healthy "30fps enqueued" log line. pub fn enqueue_frame(&self, data: &[u8], is_key_frame: bool, capture_time_us: i64) -> Result<()> { let result = unsafe { breadcast_caststream_sender_enqueue_frame( @@ -246,7 +248,7 @@ impl CastStreamSender { ) }; if result != 0 { - bail!("frame not enqueued (session not negotiated yet)"); + bail!("frame not posted (session not negotiated or shutting down)"); } Ok(()) } diff --git a/breadcast-core/src/discovery.rs b/breadcast-core/src/discovery.rs index 35b8cda..00a8495 100644 --- a/breadcast-core/src/discovery.rs +++ b/breadcast-core/src/discovery.rs @@ -29,7 +29,7 @@ const ADDRESS_STALE_AFTER: Duration = Duration::from_secs(300); /// to two IPv4 addresses (e.g. wired + wireless, or a DHCP lease change), /// an unordered choice can silently pick a stale/unreachable one, and pick /// a *different* one across otherwise-identical runs. -fn pick_address(addresses: &[(ScopedIp, Instant)]) -> Option { +fn pick_address(addresses: &[(ScopedIp, Instant)]) -> Option { let now = Instant::now(); let mut candidates: Vec<&(ScopedIp, Instant)> = addresses .iter() @@ -50,7 +50,10 @@ fn pick_address(addresses: &[(ScopedIp, Instant)]) -> Option { }) }) .or_else(|| candidates.first()) - .map(|(ip, _)| ip.to_ip_addr()) + // Prefer ScopedIp's Display so a last-resort link-local IPv6 + // keeps its `%iface` zone -- `IpAddr` drops it and + // `TcpStream::connect("fe80::…")` then fails with EINVAL. + .map(|(ip, _)| ip.to_string()) } const SERVICE_TYPE: &str = "_googlecast._tcp.local."; @@ -140,10 +143,21 @@ impl Discovery { } } - let Some(host) = pick_address(addrs).map(|ip| ip.to_string()) else { + let Some(host) = pick_address(addrs) else { warn!(%id, "cast device resolved with no usable addresses, skipping"); continue; }; + // First ServiceResolved is often IPv6-only (or a + // zoneless fe80::). rust_cast / Cast Streaming + // cannot connect to that. Wait for a later + // resolve that carries IPv4 or a scoped address. + if host.parse::().is_ok() + && host.starts_with("fe80:") + && !host.contains('%') + { + warn!(%id, %host, "cast device resolved to an unscoped link-local IPv6, waiting for a better address"); + continue; + } fullname_to_id.insert(info.get_fullname().to_string(), id.clone()); diff --git a/breadcast-core/src/dlna/discovery.rs b/breadcast-core/src/dlna/discovery.rs index 9e44a07..79c1fef 100644 --- a/breadcast-core/src/dlna/discovery.rs +++ b/breadcast-core/src/dlna/discovery.rs @@ -6,7 +6,7 @@ use tokio::sync::mpsc; use tracing::{debug, warn}; use super::device::DlnaDevice; -use super::AV_TRANSPORT; +use super::{AV_TRANSPORT, MEDIA_RENDERER}; /// How often to re-issue an SSDP search burst. Unlike mDNS (continuous /// multicast browsing via `mdns-sd` in [`crate::discovery`]), SSDP has no @@ -69,7 +69,7 @@ impl DlnaDiscovery { let mut known: HashMap = HashMap::new(); loop { - let search_target = SearchTarget::URN(AV_TRANSPORT); + let search_target = SearchTarget::URN(MEDIA_RENDERER); match rupnp::discover(&search_target, SEARCH_TIMEOUT, None).await { Ok(stream) => { use futures_util::StreamExt; @@ -86,6 +86,10 @@ impl DlnaDiscovery { }; let url = device.url().to_string(); + if device.find_service(&AV_TRANSPORT).is_none() { + debug!(%url, "UPnP device has no AVTransport, skipping"); + continue; + } confirmed.insert(url.clone()); if let Some(entry) = known.get_mut(&url) { diff --git a/breadcast-core/src/dlna/mod.rs b/breadcast-core/src/dlna/mod.rs index 26b2199..e6d78f8 100644 --- a/breadcast-core/src/dlna/mod.rs +++ b/breadcast-core/src/dlna/mod.rs @@ -21,3 +21,7 @@ pub use discovery::{DlnaDiscovery, DlnaDiscoveryEvent}; pub use session::DlnaSession; const AV_TRANSPORT: URN = URN::service("schemas-upnp-org", "AVTransport", 1); +/// Device-type search. Many TVs answer M-SEARCH for MediaRenderer but not +/// for the AVTransport *service* URN (Windows "Cast to Device" searches +/// this). After resolve we still require AVTransport. +const MEDIA_RENDERER: URN = URN::device("schemas-upnp-org", "MediaRenderer", 1); diff --git a/breadcast-core/src/dlna/session.rs b/breadcast-core/src/dlna/session.rs index 2447ce3..26551a0 100644 --- a/breadcast-core/src/dlna/session.rs +++ b/breadcast-core/src/dlna/session.rs @@ -1,5 +1,6 @@ use anyhow::{Context, Result}; use rupnp::Service; +use std::time::Duration; use super::device::DlnaDevice; use super::AV_TRANSPORT; @@ -56,18 +57,50 @@ impl DlnaSession { /// renderer would reject with no useful diagnostic. pub async fn load(&self, content_url: &str) -> Result<()> { let escaped = xml_escape(content_url); + // Many Samsung/LG renderers reject an empty CurrentURIMetaData and + // need DIDL-Lite + protocolInfo before they will play a live HLS + // playlist. The DIDL itself is then XML-escaped for the SOAP body. + let didl = format!( + r#"breadcastobject.item.videoItem{escaped}"# + ); + let metadata = xml_escape(&didl); let set_uri_payload = format!( - "0{escaped}" + "0{escaped}{metadata}" ); self.service .action(&self.device_url, "SetAVTransportURI", &set_uri_payload) .await .context("SetAVTransportURI failed")?; - self.service + // Some renderers auto-play after SetURI and then reject Play; + // others stay TRANSITIONING for a moment. Either PLAYING state is + // success. + match self + .service .action(&self.device_url, "Play", "01") .await - .context("Play failed")?; + { + Ok(_) => {} + Err(e) => { + // The renderer may already be auto-playing after SetURI (and + // reject a redundant Play), or may still be TRANSITIONING and + // only reach PLAYING a moment later -- either is success. + // A single immediate GetTransportInfo isn't enough for the + // latter, so give it a short bounded window before giving up + // on the Play error. + let mut attempts = 0u32; + loop { + if self.transport_state().await.ok().as_deref() == Some("PLAYING") { + break; + } + attempts += 1; + if attempts >= 5 { + return Err(e).context("Play failed"); + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + } + } Ok(()) } @@ -106,7 +139,7 @@ impl DlnaSession { } } -fn xml_escape(input: &str) -> String { +pub(crate) fn xml_escape(input: &str) -> String { let mut escaped = String::with_capacity(input.len()); for c in input.chars() { match c { @@ -120,3 +153,14 @@ fn xml_escape(input: &str) -> String { } escaped } + +#[cfg(test)] +mod tests { + use super::xml_escape; + + #[test] + fn xml_escape_covers_the_five_markup_chars() { + assert_eq!(xml_escape(r#"a&bd"e'f"#), "a&b<c>d"e'f"); + assert_eq!(xml_escape("http://10.0.0.1:1/t/playlist.m3u8"), "http://10.0.0.1:1/t/playlist.m3u8"); + } +} diff --git a/breadcast-core/src/http_server.rs b/breadcast-core/src/http_server.rs index 4565514..061ac28 100644 --- a/breadcast-core/src/http_server.rs +++ b/breadcast-core/src/http_server.rs @@ -16,8 +16,9 @@ const WORKER_THREADS: usize = 8; /// Serves `root` (the `hlssink3` output directory: `playlist.m3u8` + /// `segment*.ts`) over plain HTTP. Runs a small fixed pool of worker -/// threads; dropping the handle does not stop the server (there is no clean -/// shutdown yet — matches the smoke-testing scope of the rest of Phase 2). +/// threads. [`HttpServer::shutdown`] (also invoked from `Drop`) unblocks +/// those workers and closes the listener so a finished DLNA session does +/// not keep the last screen-recording segments reachable on the LAN. /// /// Every servable path is namespaced under a random token /// (`//playlist.m3u8`, etc. — see [`HttpServer::token`]) rather than @@ -37,6 +38,7 @@ const WORKER_THREADS: usize = 8; pub struct HttpServer { addr: SocketAddr, token: String, + server: Option>, } impl HttpServer { @@ -74,7 +76,19 @@ impl HttpServer { }); } - Ok(Self { addr, token }) + Ok(Self { addr, token, server: Some(server) }) + } + + /// Unblocks every worker and drops the listener. After this returns the + /// bind address is free and the path token no longer serves anything. + /// Safe to call more than once. + pub fn shutdown(&mut self) { + let Some(server) = self.server.take() else { return }; + // `unblock` wakes one `recv()` at a time; wake every worker so + // they all observe the error and drop their Arc. + for _ in 0..WORKER_THREADS { + server.unblock(); + } } /// The bound address, e.g. `0.0.0.0:41823`. Combine with this @@ -90,7 +104,17 @@ impl HttpServer { /// doc for why that can't just be `self.addr()`), including the /// unguessable path token every request must carry. pub fn url(&self, host: std::net::IpAddr, relative: &str) -> String { - format!("http://{host}:{}/{}/{relative}", self.addr.port(), self.token) + let port = self.addr.port(); + match host { + std::net::IpAddr::V6(v6) => format!("http://[{v6}]:{port}/{}/{relative}", self.token), + std::net::IpAddr::V4(v4) => format!("http://{v4}:{port}/{}/{relative}", self.token), + } + } +} + +impl Drop for HttpServer { + fn drop(&mut self) { + self.shutdown(); } } @@ -108,12 +132,30 @@ fn random_token() -> String { .is_ok(); if !read_ok { // Unreachable in practice on Linux, but better than a zero-entropy - // token if it ever happened. - use std::hash::{Hash, Hasher}; - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - std::time::SystemTime::now().hash(&mut hasher); - std::process::id().hash(&mut hasher); - bytes[..8].copy_from_slice(&hasher.finish().to_le_bytes()); + // token if it ever happened. Mix several low-cost, not-directly- + // foreseeable sources (two time reads a hair apart, pid, a process + // counter) into both hashes and write all 16 bytes, so the fallback + // never reduces to a single low-resolution clock tick or per-pid + // reuse -- still far weaker than /dev/urandom, which is why it stays + // a fallback, but not trivially guessable. + let mix = |salt: u64| { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + std::time::SystemTime::now().hash(&mut hasher); + std::time::SystemTime::UNIX_EPOCH + .elapsed() + .ok() + .map(|d| d.as_nanos()) + .unwrap_or_default() + .hash(&mut hasher); + std::process::id().hash(&mut hasher); + salt.hash(&mut hasher); + hasher.finish() + }; + let a = mix(0x9e3779b97f4a7c15); + let b = mix(0xdead_beef_dead_beef); + bytes[..8].copy_from_slice(&a.to_le_bytes()); + bytes[8..].copy_from_slice(&b.to_le_bytes()); } bytes.iter().map(|b| format!("{b:02x}")).collect() } @@ -123,7 +165,7 @@ fn random_token() -> String { /// multi-range (multipart ranges aren't needed for HLS segment fetches, and /// falling back to a full 200 response for those is always a valid /// response under the HTTP spec). -fn parse_range(value: &str, len: usize) -> Option<(usize, usize)> { +pub(crate) fn parse_range(value: &str, len: usize) -> Option<(usize, usize)> { let spec = value.strip_prefix("bytes=")?; if spec.contains(',') || len == 0 { return None; @@ -233,7 +275,11 @@ fn handle_request(request: tiny_http::Request, root: &Path, token: &str) -> Resu ]; let (status, body) = match range { - Some((start, end)) if start <= end && end < data.len() => { + // RFC 7233: a last-byte-pos past the end is clamped, not 416. + // HLS clients often probe `bytes=0-1048575` against a ~400KB + // segment; answering 416 stalls playback with no encoder error. + Some((start, end)) if start < data.len() && start <= end => { + let end = end.min(data.len() - 1); headers.push(("Content-Range".to_string(), format!("bytes {start}-{end}/{}", data.len()))); (206u16, data[start..=end].to_vec()) } @@ -264,3 +310,25 @@ fn respond_status(request: tiny_http::Request, status: u16) -> Result<()> { .respond(tiny_http::Response::empty(status)) .context("failed to write HTTP error response") } + +#[cfg(test)] +mod tests { + use super::parse_range; + + #[test] + fn parse_range_accepts_the_usual_hls_shapes() { + assert_eq!(parse_range("bytes=0-99", 200), Some((0, 99))); + assert_eq!(parse_range("bytes=50-", 200), Some((50, 199))); + assert_eq!(parse_range("bytes=-20", 200), Some((180, 199))); + assert_eq!(parse_range("bytes=0-0", 200), Some((0, 0))); + } + + #[test] + fn parse_range_rejects_malformed_or_multipart() { + assert_eq!(parse_range("bytes=", 200), None); + assert_eq!(parse_range("bytes=-", 200), None); + assert_eq!(parse_range("bytes=0-10,20-30", 200), None); + assert_eq!(parse_range("items=0-10", 200), None); + assert_eq!(parse_range("bytes=0-10", 0), None); + } +} diff --git a/breadcast-core/src/net.rs b/breadcast-core/src/net.rs index fe20e75..66c9d83 100644 --- a/breadcast-core/src/net.rs +++ b/breadcast-core/src/net.rs @@ -2,6 +2,8 @@ use std::net::{IpAddr, Ipv4Addr}; use anyhow::{Context, Result}; +const EXCLUDED_PREFIXES: &[&str] = &["tailscale", "wg", "docker", "veth", "br-", "virbr", "lo"]; + /// Finds this machine's LAN-reachable IPv4 address by enumerating network /// interfaces directly, rather than the more common "UDP-connect to a /// public address and read back the local endpoint" trick — that trick @@ -18,8 +20,61 @@ use anyhow::{Context, Result}; /// Shared by every casting protocol (Cast, DLNA, ...) — they all need to /// embed this machine's own address in a URL handed to a receiver device. pub fn local_lan_ip() -> Result { - const EXCLUDED_PREFIXES: &[&str] = &["tailscale", "wg", "docker", "veth", "br-", "virbr", "lo"]; + pick_lan_ip(None) +} +/// Like [`local_lan_ip`], but prefers the interface whose subnet contains +/// `peer`. Dual-homed machines (ethernet + wifi, two VLANs) otherwise +/// embed the wrong host in the HLS URL and the renderer cannot fetch it — +/// the same silent `LOAD FAILED` class as the Tailscale case above. +pub fn local_lan_ip_for(peer: IpAddr) -> Result { + pick_lan_ip(Some(peer)) +} + +fn pick_lan_ip(peer: Option) -> Result { + let mut candidates = lan_ipv4_candidates()?; + + if let Some(IpAddr::V4(peer_v4)) = peer { + if let Some((_, addr, _)) = candidates + .iter() + .filter(|(_, addr, prefix)| same_subnet(*addr, peer_v4, *prefix)) + .max_by_key(|(_, _, prefix)| *prefix) + { + return Ok(IpAddr::V4(*addr)); + } + } + + // Prefer a conventionally-named physical/Wi-Fi interface when there's a + // choice, but any private, non-excluded address is acceptable. + candidates.sort_by_key(|(iface, _, _)| !(iface.starts_with("wl") || iface.starts_with("en") || iface.starts_with("eth"))); + + candidates + .into_iter() + .map(|(_, addr, _)| IpAddr::V4(addr)) + .next() + .context("no LAN-reachable IPv4 address found (excluding loopback/VPN/virtual interfaces) — is this machine connected to a network?") +} + +/// Enumerates this machine's private, LAN-reachable IPv4 addresses as +/// `(interface, address, prefix-len)` tuples. Primary path parses `ip -o +/// addr show` -- the formatting it relies on is stable, and it bakes in the +/// `scope global up` filter (excluding link-local 169.254/16 and down +/// interfaces) for free -- falling back to a `getifaddrs`-based enumeration +/// (`if-addrs`, already in the dependency tree via `mdns-sd`) when the `ip` +/// command is unavailable, so the LAN-IP discovery doesn't silently depend on +/// iproute2 being installed. +fn lan_ipv4_candidates() -> Result> { + match ip_command_candidates() { + Ok(candidates) if !candidates.is_empty() => return Ok(candidates), + Ok(_) => {} // `ip` ran but found no private address -- genuine, fall through + Err(e) => { + tracing::debug!(error = ?e, "`ip addr show` unavailable, falling back to getifaddrs"); + } + } + getifaddrs_candidates() +} + +fn ip_command_candidates() -> Result> { let output = std::process::Command::new("ip") .args(["-4", "-o", "addr", "show", "scope", "global", "up"]) .output() @@ -29,7 +84,7 @@ pub fn local_lan_ip() -> Result { } let text = String::from_utf8_lossy(&output.stdout); - let mut candidates: Vec<(String, Ipv4Addr)> = Vec::new(); + let mut candidates: Vec<(String, Ipv4Addr, u8)> = Vec::new(); for line in text.lines() { // Format: "3: wlan0 inet 10.179.161.89/23 brd ... scope global dynamic wlan0" let mut fields = line.split_whitespace(); @@ -42,20 +97,65 @@ pub fn local_lan_ip() -> Result { continue; } let Some(cidr) = fields.next() else { continue }; - let Some(addr) = cidr.split('/').next().and_then(|a| a.parse::().ok()) else { continue }; + let mut parts = cidr.split('/'); + let Some(addr) = parts.next().and_then(|a| a.parse::().ok()) else { continue }; + let prefix: u8 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(32); if !addr.is_private() { continue; } - candidates.push((iface.to_string(), addr)); + candidates.push((iface.to_string(), addr, prefix.min(32))); + } + Ok(candidates) +} + +/// `getifaddrs`-based counterpart to [`ip_command_candidates`], mirroring the +/// `ip` path's filters (up, non-loopback, private, not link-local, not an +/// excluded interface name) so the two produce equivalent candidate sets. +fn getifaddrs_candidates() -> Result> { + let ifaces = if_addrs::get_if_addrs().context("failed to enumerate network interfaces (getifaddrs fallback)")?; + let mut candidates: Vec<(String, Ipv4Addr, u8)> = Vec::new(); + for iface in ifaces { + if EXCLUDED_PREFIXES.iter().any(|p| iface.name.starts_with(p)) { + continue; + } + if !iface.is_oper_up() { + continue; + } + let if_addrs::IfAddr::V4(v4) = iface.addr else { continue }; + // Mirrors `type(-4 -o addr show scope global up)`: drop loopback, + // link-local (169.254) and non-private networks. + if v4.ip.is_loopback() || v4.ip.is_link_local() || !v4.ip.is_private() { + continue; + } + candidates.push((iface.name.clone(), v4.ip, v4.prefixlen.min(32))); + } + Ok(candidates) +} + +fn same_subnet(a: Ipv4Addr, b: Ipv4Addr, prefix: u8) -> bool { + if prefix == 0 { + return true; + } + let mask = if prefix >= 32 { + u32::MAX + } else { + !((1u32 << (32 - prefix)) - 1) + }; + (u32::from(a) & mask) == (u32::from(b) & mask) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn same_subnet_respects_prefix_length() { + let a: Ipv4Addr = "10.179.161.89".parse().unwrap(); + let b: Ipv4Addr = "10.179.160.1".parse().unwrap(); + let other: Ipv4Addr = "10.0.0.1".parse().unwrap(); + assert!(same_subnet(a, b, 23)); + assert!(!same_subnet(a, other, 23)); + assert!(same_subnet(a, a, 32)); + assert!(!same_subnet(a, b, 32)); } - - // Prefer a conventionally-named physical/Wi-Fi interface when there's a - // choice, but any private, non-excluded address is acceptable. - candidates.sort_by_key(|(iface, _)| !(iface.starts_with("wl") || iface.starts_with("en") || iface.starts_with("eth"))); - - candidates - .into_iter() - .map(|(_, addr)| IpAddr::V4(addr)) - .next() - .context("no LAN-reachable IPv4 address found (excluding loopback/VPN/virtual interfaces) — is this machine connected to a network?") } diff --git a/breadcast-core/src/pipeline/mod.rs b/breadcast-core/src/pipeline/mod.rs index fe46558..ced7f4e 100644 --- a/breadcast-core/src/pipeline/mod.rs +++ b/breadcast-core/src/pipeline/mod.rs @@ -451,6 +451,7 @@ pub fn pull_encoded_frame(appsink: &gst_app::AppSink) -> Result, let poll = gst::ClockTime::from_mseconds(CAPTURE_STALL_POLL.as_millis() as u64); let mut stalled_for = Duration::ZERO; loop { + let pulled_at = std::time::Instant::now(); let Some(sample) = appsink.try_pull_sample(Some(poll)) else { // EOS is the ordinary end: the user hit "Stop sharing" in the // portal, or the source went away. @@ -458,16 +459,18 @@ pub fn pull_encoded_frame(appsink: &gst_app::AppSink) -> Result, return Ok(None); } // Teardown from another thread (`CastMirrorSession::stop` sets - // the pipeline to Null) makes the sink flush, and a flushing - // sink returns `None` *immediately* rather than after the - // timeout. Treat that as a clean end too -- otherwise this would - // busy-spin for the whole stall budget and then report a - // spurious "capture stalled" on every normal stop. - if !matches!(appsink.current_state(), gst::State::Playing | gst::State::Paused) { + // the pipeline to Null) flushes the sink *before* + // `current_state()` leaves Playing. A flushing sink returns + // `None` immediately; counting that as stall time used to + // raise a spurious "capture stalled" on every normal stop. + if !matches!(appsink.current_state(), gst::State::Playing | gst::State::Paused) + || matches!(appsink.pending_state(), gst::State::Null | gst::State::Ready) + || pulled_at.elapsed() < Duration::from_millis(20) + { return Ok(None); } - stalled_for += CAPTURE_STALL_POLL; + stalled_for += pulled_at.elapsed(); if stalled_for < CAPTURE_STALL_TIMEOUT { continue; } @@ -529,11 +532,56 @@ pub enum RunOutcome { Timeout, } +/// Blocks the calling thread until the pipeline reports an error or EOS. +/// Unlike [`run_until_error_or_timeout`] this does not give up after a +/// fixed duration -- a live mirror session can last hours, and a 1-hour +/// leftover from the smoke-test helper was leaving GStreamer errors +/// unobserved for the rest of the cast. Also returns [`RunOutcome::Eos`] +/// once the pipeline has been torn down from another thread (`Null`), so +/// a daemon watcher does not sit forever after `stop()`. +pub fn run_until_eos_or_error(pipeline: &gst::Pipeline) -> Result { + let bus = pipeline.bus().context("pipeline has no bus")?; + loop { + let Some(msg) = bus.timed_pop_filtered( + gst::ClockTime::from_mseconds(500), + &[gst::MessageType::Error, gst::MessageType::Eos, gst::MessageType::Warning], + ) else { + if !matches!( + pipeline.current_state(), + gst::State::Playing | gst::State::Paused | gst::State::Ready + ) { + return Ok(RunOutcome::Eos); + } + continue; + }; + + use gst::MessageView; + match msg.view() { + MessageView::Error(e) => { + bail!( + "GStreamer pipeline error from {:?}: {} ({:?})", + e.src().map(|s| s.path_string()), + e.error(), + e.debug() + ); + } + MessageView::Warning(w) => { + tracing::warn!( + src = ?w.src().map(|s| s.path_string()), + error = %w.error(), + "GStreamer pipeline warning" + ); + } + MessageView::Eos(_) => return Ok(RunOutcome::Eos), + _ => {} + } + } +} + /// Blocks the calling thread until the pipeline reports an error or EOS, or /// `timeout` elapses (whichever first). Returns which of those happened, or /// `Err` on a real pipeline error. Meant for smoke-testing from a -/// synchronous `main`/example; the real daemon will want an async/watch-based -/// version instead of blocking a thread. +/// synchronous `main`/example; the daemon uses [`run_until_eos_or_error`]. pub fn run_until_error_or_timeout(pipeline: &gst::Pipeline, timeout: gst::ClockTime) -> Result { let bus = pipeline.bus().context("pipeline has no bus")?; let deadline = std::time::Instant::now() + std::time::Duration::from(timeout); diff --git a/breadcast/Cargo.toml b/breadcast/Cargo.toml index 253ae83..c819345 100644 --- a/breadcast/Cargo.toml +++ b/breadcast/Cargo.toml @@ -14,7 +14,7 @@ path = "src/main.rs" anyhow = { workspace = true } serde_json = { workspace = true } breadcast-core = { path = "../breadcast-core" } -bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["gtk"] } +bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] } bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["gtk"] } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" diff --git a/breadcast/src/css.rs b/breadcast/src/css.rs index 0f94064..73b7cb9 100644 --- a/breadcast/src/css.rs +++ b/breadcast/src/css.rs @@ -47,6 +47,11 @@ pub fn build_css(palette: &Palette) -> String { color: {overlay}; padding: 24px 8px; }} + .cast-error-label {{ + color: #e35b5b; + font-size: 0.85em; + padding: 4px 8px; + }} .cast-stop-button {{ background-color: alpha(#e35b5b, 0.15); color: #e35b5b; diff --git a/breadcast/src/ipc_client.rs b/breadcast/src/ipc_client.rs index 3b1ccf3..64250bb 100644 --- a/breadcast/src/ipc_client.rs +++ b/breadcast/src/ipc_client.rs @@ -29,6 +29,8 @@ impl IpcClient { let path = socket_path()?; let write_half = UnixStream::connect(&path) .with_context(|| format!("failed to connect to breadcastd at {} — is it running?", path.display()))?; + // A wedged daemon must not freeze the GTK main thread on write. + let _ = write_half.set_write_timeout(Some(std::time::Duration::from_secs(5))); let read_half = write_half.try_clone().context("failed to duplicate the IPC socket handle")?; let (tx, rx) = mpsc::channel(); diff --git a/breadcast/src/main.rs b/breadcast/src/main.rs index e86ac43..56202d6 100644 --- a/breadcast/src/main.rs +++ b/breadcast/src/main.rs @@ -13,7 +13,9 @@ use std::rc::Rc; use bread_theme::load_palette; use breadcast_core::ipc::{DeviceInfo, Protocol, ServerMessage, StateInfo}; use gtk4::prelude::*; -use gtk4::{Align, Application, Box as GBox, Button, Label, ListBox, Orientation, SelectionMode, glib}; +use gtk4::{ + Align, Application, Box as GBox, Button, EventControllerKey, Label, ListBox, Orientation, SelectionMode, glib, +}; use ipc_client::IpcClient; const PANEL_WIDTH: i32 = 360; @@ -38,6 +40,7 @@ fn build_ui(app: &Application) { bread_theme::gtk::apply_app_css(|| css::build_css(&load_palette())); let window = bread_utils::gtk_popup::new_overlay_window(app, "breadcast"); + bread_theme::gtk::bind_window_auto(&window); let panel = GBox::new(Orientation::Vertical, 8); panel.add_css_class("cast-panel"); @@ -69,12 +72,50 @@ fn build_ui(app: &Application) { empty_label.add_css_class("cast-empty-label"); panel.append(&empty_label); + let error_label = Label::new(None); + error_label.add_css_class("cast-error-label"); + error_label.set_wrap(true); + error_label.set_visible(false); + panel.append(&error_label); + window.set_child(Some(&panel)); bread_utils::gtk_popup::close_on_outside_click(&window, &panel, { let window = window.clone(); move || window.close() }); + let key_ctrl = EventControllerKey::new(); + key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture); + { + let window = window.clone(); + let list = list.clone(); + key_ctrl.connect_key_pressed(move |_, key, _, _| { + use gtk4::gdk::Key; + match key { + Key::Escape => { + window.close(); + glib::Propagation::Stop + } + Key::Return | Key::KP_Enter => { + if let Some(row) = list.selected_row() { + row.activate(); + } + glib::Propagation::Stop + } + Key::Down => { + bread_utils::gtk_popup::select_next_visible(&list); + glib::Propagation::Stop + } + Key::Up => { + bread_utils::gtk_popup::select_prev_visible(&list); + glib::Propagation::Stop + } + _ => glib::Propagation::Proceed, + } + }); + } + window.add_controller(key_ctrl); + match IpcClient::connect() { Ok((client, rx)) => { let client = Rc::new(RefCell::new(client)); @@ -83,10 +124,16 @@ fn build_ui(app: &Application) { list.connect_row_activated({ let client = client.clone(); + let window = window.clone(); move |_, row| { let Some(device_id) = (unsafe { row.data::("device_id") }) else { return }; let device_id = unsafe { device_id.as_ref() }.clone(); let _ = client.borrow_mut().send("start_cast", serde_json::json!({ "device_id": device_id })); + // Close before the portal picker appears -- this overlay + // is KeyboardMode::Exclusive and otherwise steals + // Enter/Escape from the picker (and swallows clicks on + // the dimmed background). + window.close(); } }); @@ -99,11 +146,26 @@ fn build_ui(app: &Application) { let list = list.clone(); let empty_label = empty_label.clone(); + let error_label = error_label.clone(); let status_pill = status_pill.clone(); let stop_button = stop_button.clone(); glib::timeout_add_local(std::time::Duration::from_millis(100), move || { - while let Ok(message) = rx.try_recv() { - handle_server_message(message, &list, &empty_label, &status_pill, &stop_button); + loop { + match rx.try_recv() { + Ok(message) => { + handle_server_message(message, &list, &empty_label, &error_label, &status_pill, &stop_button); + } + Err(std::sync::mpsc::TryRecvError::Empty) => break, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + empty_label.set_label("breadcastd isn't running"); + empty_label.set_visible(true); + list.set_visible(false); + stop_button.set_visible(false); + status_pill.set_label("Offline"); + status_pill.remove_css_class("casting"); + return glib::ControlFlow::Break; + } + } } glib::ControlFlow::Continue }); @@ -124,6 +186,7 @@ fn handle_server_message( message: ServerMessage, list: &ListBox, empty_label: &Label, + error_label: &Label, status_pill: &Label, stop_button: &Button, ) { @@ -137,6 +200,8 @@ fn handle_server_message( } ServerMessage::Response { error: Some(error), .. } => { eprintln!("breadcast: request failed: {error}"); + error_label.set_label(&error); + error_label.set_visible(true); None } _ => None, @@ -144,7 +209,10 @@ fn handle_server_message( let Some((event, data)) = payload else { return }; match event.as_str() { "device_list_changed" => update_device_list(list, empty_label, data), - "state_changed" => update_state(status_pill, stop_button, data), + "state_changed" => { + error_label.set_visible(false); + update_state(status_pill, stop_button, data); + } _ => {} } } diff --git a/breadcastd/src/cast_mirror.rs b/breadcastd/src/cast_mirror.rs index 2990077..b8444ad 100644 --- a/breadcastd/src/cast_mirror.rs +++ b/breadcastd/src/cast_mirror.rs @@ -83,7 +83,11 @@ impl CastMirrorSession { /// receiver dropping the connection) back to the daemon actor, so it /// can transition back to `Idle` and notify GUI clients even if nobody /// called `stop()`. - pub async fn start(device: CastDevice, daemon_tx: tokio::sync::mpsc::Sender) -> Result { + pub async fn start( + device: CastDevice, + daemon_tx: tokio::sync::mpsc::Sender, + generation: u64, + ) -> Result { let capture = CaptureSession::start().await.context("failed to start portal screen capture")?; let video_node_id = capture.video_node_id(); @@ -94,13 +98,18 @@ impl CastMirrorSession { // OFFER below rather than re-derived there, so the advertised stream // and the encoded stream cannot drift apart. let (pipeline, appsink, encoder, video_params) = - build_video_pipeline_for_streaming(video_node_id).context("failed to build the encode pipeline")?; + match build_video_pipeline_for_streaming(video_node_id) { + Ok(built) => built, + Err(e) => { + close_capture_bounded(capture).await; + return Err(e).context("failed to build the encode pipeline"); + } + }; { let pipeline_watch = pipeline.clone(); std::thread::spawn(move || { - match breadcast_core::pipeline::run_until_error_or_timeout(&pipeline_watch, gst::ClockTime::from_seconds(3600)) - { + match breadcast_core::pipeline::run_until_eos_or_error(&pipeline_watch) { Ok(outcome) => tracing::debug!(?outcome, "encode pipeline bus watcher ended"), Err(e) => tracing::error!(error = ?e, "encode pipeline error"), } @@ -111,19 +120,37 @@ impl CastMirrorSession { // (milliseconds on a LAN) but still blocking I/O -- run it off the // async worker thread pool rather than stalling it, even briefly. let device_for_connect = device.clone(); - let (session, _media_events, raw_messages) = tokio::task::spawn_blocking(move || { + let connect = tokio::task::spawn_blocking(move || { CastSession::connect_app( &device_for_connect, CastDeviceApp::Custom(breadcast_core::caststream::MIRRORING_APP_ID.to_string()), ) }) - .await - .context("connect_app task panicked")? - .context("failed to connect and launch the Mirroring receiver")?; + .await; + let (session, _media_events, raw_messages) = match connect { + Ok(Ok(connected)) => connected, + Ok(Err(e)) => { + let _ = pipeline.set_state(gst::State::Null); + close_capture_bounded(capture).await; + return Err(e).context("failed to connect and launch the Mirroring receiver"); + } + Err(e) => { + let _ = pipeline.set_state(gst::State::Null); + close_capture_bounded(capture).await; + return Err(e).context("connect_app task panicked"); + } + }; let (sender, stream_events) = - CastStreamSender::start(&device.host, "sender-0", session.transport_id(), video_params) - .context("failed to start the Cast Streaming session")?; + match CastStreamSender::start(&device.host, "sender-0", session.transport_id(), video_params) { + Ok(started) => started, + Err(e) => { + stop_session_bounded(&session).await; + let _ = pipeline.set_state(gst::State::Null); + close_capture_bounded(capture).await; + return Err(e).context("failed to start the Cast Streaming session"); + } + }; let sender = Arc::new(sender); let message_pump = { @@ -138,9 +165,12 @@ impl CastMirrorSession { }; let negotiated = Arc::new(AtomicBool::new(false)); + let failed = Arc::new(AtomicBool::new(false)); let event_pump = { let session = session.clone(); let negotiated = negotiated.clone(); + let failed = failed.clone(); + let daemon_tx = daemon_tx.clone(); std::thread::spawn(move || { while let Ok(event) = stream_events.recv() { match event { @@ -150,37 +180,68 @@ impl CastMirrorSession { } } CastStreamEvent::Negotiated => negotiated.store(true, Ordering::Release), - CastStreamEvent::Error(message) => tracing::warn!(%message, "Cast Streaming error"), + CastStreamEvent::Error(message) => { + tracing::warn!(%message, "Cast Streaming error"); + failed.store(true, Ordering::Release); + // After negotiation the frame pump is running + // and this is an unprompted death; before + // negotiation, start() itself observes `failed` + // and tears down. + if negotiated.load(Ordering::Acquire) { + let _ = daemon_tx.blocking_send(DaemonCommand::SessionEnded { generation }); + } + } CastStreamEvent::PictureLost => tracing::debug!("receiver reported picture loss"), } } }) }; + // From here every error path must use the same join/drop order as + // `stop()`. Building the session now and calling `stop()` on it is + // what keeps a leaked `CastStreamSender` from leaving + // PlatformClientPosix alive -- the next start would then hit + // OSP_CHECK(!instance_) and abort the daemon. + let mut started = Self { + pipeline, + session, + capture: Some(capture), + sender: Some(sender), + message_pump: Some(message_pump), + event_pump: Some(event_pump), + frame_pump: None, + }; + tracing::info!(device = %device.name, "sending Cast Streaming OFFER"); - sender.negotiate(); + if let Some(sender) = started.sender.as_ref() { + sender.negotiate(); + } let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(10); - while !negotiated.load(Ordering::Acquire) && tokio::time::Instant::now() < deadline { + while !negotiated.load(Ordering::Acquire) + && !failed.load(Ordering::Acquire) + && tokio::time::Instant::now() < deadline + { tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; } + if failed.load(Ordering::Acquire) { + started.stop().await; + anyhow::bail!("Cast Streaming session error from {} during negotiation", device.name); + } if !negotiated.load(Ordering::Acquire) { - // Bounded for the same reason `Self::stop`'s calls are -- an - // unresponsive receiver (which is exactly what "negotiation - // timed out" implies) can wedge either of these forever - // otherwise, taking the whole single-threaded daemon actor - // down with it before this even gets to return an error. - stop_session_bounded(&session).await; - close_capture_bounded(capture).await; + started.stop().await; anyhow::bail!("never received an ANSWER from {} (negotiation timed out)", device.name); } - pipeline.set_state(gst::State::Playing).context("failed to start the encode pipeline")?; + if let Err(e) = started.pipeline.set_state(gst::State::Playing) { + started.stop().await; + return Err(e).context("failed to start the encode pipeline"); + } tracing::info!(device = %device.name, "mirroring started"); let frame_pump = { let device_name = device.name.clone(); - let sender = sender.clone(); + let sender = started.sender.as_ref().expect("sender installed above").clone(); std::thread::spawn(move || { let result = frame_pump_loop(&appsink, &encoder, &sender); if let Err(e) = result { @@ -190,19 +251,12 @@ impl CastMirrorSession { // was, very recently) still alive. If the channel is full or // closed, there's nothing more useful to do from this // thread than drop the notification. - let _ = daemon_tx.blocking_send(DaemonCommand::SessionEnded); + let _ = daemon_tx.blocking_send(DaemonCommand::SessionEnded { generation }); }) }; + started.frame_pump = Some(frame_pump); - Ok(Self { - pipeline, - session, - capture: Some(capture), - sender: Some(sender), - message_pump: Some(message_pump), - event_pump: Some(event_pump), - frame_pump: Some(frame_pump), - }) + Ok(started) } /// Tears down the session. Order matters and is not interchangeable: diff --git a/breadcastd/src/daemon.rs b/breadcastd/src/daemon.rs index 95a0d30..9ac0287 100644 --- a/breadcastd/src/daemon.rs +++ b/breadcastd/src/daemon.rs @@ -33,7 +33,29 @@ pub enum DaemonCommand { /// dropping the connection or stopping playback) -- as opposed to /// `StopCast` being called. Either way the daemon needs to forget the /// (now-dead) session and go back to `Idle`. - SessionEnded, + SessionEnded { generation: u64 }, + /// Result of a `StartCast` that ran off this actor (the portal picker + /// and OFFER/ANSWER wait must not stall `list_devices` / `stop_cast`). + /// Ignored when `generation` no longer matches -- that means `StopCast` + /// cancelled the in-flight start. + StartFinished { + generation: u64, + outcome: Result, + reply: oneshot::Sender>, + }, +} + +/// A session that finished starting, ready to be installed as `active_session`. +pub(crate) struct StartedSession { + pub session: ActiveSession, + pub device_id: String, + pub device_name: String, + pub protocol: Protocol, +} + +pub(crate) struct StartFailed { + pub device_id: String, + pub error: String, } pub fn spawn(events_tx: broadcast::Sender, bread_client: BreadClient) -> mpsc::Sender { @@ -45,6 +67,8 @@ pub fn spawn(events_tx: broadcast::Sender, bread_client: BreadCli dlna_devices: HashMap::new(), state: StateInfo::Idle, active_session: None, + starting: false, + start_generation: 0, events_tx, bread_client, self_tx, @@ -59,7 +83,7 @@ pub fn spawn(events_tx: broadcast::Sender, bread_client: BreadCli /// The currently active mirroring session, if any -- exactly one of the two /// protocol-specific session types, chosen by which device map `StartCast` /// found the requested device id in. -enum ActiveSession { +pub(crate) enum ActiveSession { Cast(CastMirrorSession), Dlna(Box), } @@ -73,9 +97,16 @@ impl ActiveSession { } } -/// `host` strings come straight from mDNS resolution, so this just checks -/// the address, not whether a zone id is attached (mDNS never gives us one). +/// `host` strings come straight from mDNS resolution, and mdns-sd renders a +/// link-local IPv6 it resolved via `ScopedIp` with a `%zone` suffix (e.g. +/// `fe80::…%wlan0`) so the address stays connectable. `IpAddr` can't parse +/// that suffix (std rejects zone-ids in `FromStr`), so the zone has to be +/// stripped before the network prefix can be examined -- otherwise a scoped +/// `fe80::…%iface` host would fail this check and, in `CastDeviceFound`, be +/// allowed to clobber a good routable host that `rust_cast` could actually +/// connect to. fn is_link_local_v6(host: &str) -> bool { + let host = host.split('%').next().unwrap_or(host); matches!(host.parse::(), Ok(std::net::IpAddr::V6(v6)) if (v6.segments()[0] & 0xffc0) == 0xfe80) } @@ -86,6 +117,13 @@ struct Daemon { dlna_devices: HashMap, state: StateInfo, active_session: Option, + /// True while a `StartCast` is running off this actor (portal picker / + /// negotiation). Distinct from `active_session` so a second start is + /// rejected before the first one has a session to install. + starting: bool, + /// Bumped by `StopCast` so a `StartFinished` from a cancelled start + /// tears its session down instead of installing it. + start_generation: u64, events_tx: broadcast::Sender, /// Used to publish `bread.cast.mirroring_started`/`.stopped`/`.failed` /// on state transitions -- see `bread_events.rs`. A no-op if breadd @@ -111,6 +149,12 @@ impl Daemon { self.start_cast(device_id, reply).await; } DaemonCommand::StopCast { reply } => { + if self.starting { + // Invalidate the in-flight start so its StartFinished + // tears the session down instead of installing it. + self.starting = false; + self.start_generation = self.start_generation.wrapping_add(1); + } if let Some(session) = self.active_session.take() { session.stop().await; bread_events::emit_mirroring_stopped(&self.bread_client); @@ -119,19 +163,48 @@ impl Daemon { self.broadcast_state(); let _ = reply.send(Ok(())); } - DaemonCommand::SessionEnded => { - // The session already tore itself down (that's what - // triggered this) -- just drop our handle to it and update - // state. Ignored if this arrives after an explicit - // `StopCast` already cleared `active_session` (the - // background pump/poll it came from may briefly outlive - // that call). - if self.active_session.take().is_some() { + DaemonCommand::SessionEnded { generation } => { + // Ignore a late notification from a session that already + // stopped (or whose start was cancelled). An abandoned + // pump after a 5s join timeout used to take() the *next* + // session and flip the UI to Idle while it was still live. + if generation != self.start_generation { + return; + } + // The session's pump/poll noticed death, but the session + // handle itself has *not* been torn down -- there is no + // Drop impl. Dropping it here used to skip CastSession::stop + // (TV left on the last frame), skip portal close (PipeWire + // leak), and destroy the FFI sender while pump threads were + // still calling into it. Always run the ordered stop. + if let Some(session) = self.active_session.take() { tracing::info!("mirror session ended on its own, returning to idle"); + session.stop().await; + self.start_generation = self.start_generation.wrapping_add(1); self.state = StateInfo::Idle; self.broadcast_state(); bread_events::emit_mirroring_stopped(&self.bread_client); + return; } + // No installed session, but a start is currently in flight + // (portal picker / OFFER-ANSWER). The event pump only reports + // SessionEnded once negotiation succeeded, so a matching + // generation here means the in-flight session died between + // `Negotiated` and `StartFinished` landing. If we ignore it, + // the pending `StartFinished` would install the dead session + // and leave the UI stuck on "Casting" until the frame pump + // happens to notice. Invalidate the pending start exactly as + // `StopCast` does -- bump the generation so + // `on_start_finished` tears the session down instead of + // installing it. + if self.starting { + tracing::debug!("in-flight mirror session ended before it was installed"); + self.starting = false; + self.start_generation = self.start_generation.wrapping_add(1); + } + } + DaemonCommand::StartFinished { generation, outcome, reply } => { + self.on_start_finished(generation, outcome, reply).await; } DaemonCommand::CastDeviceFound(device) => { // mDNS resolves one physical device on every local address @@ -147,8 +220,8 @@ impl Daemon { ); if should_replace { self.cast_devices.insert(device.id.clone(), device); + self.broadcast_devices(); } - self.broadcast_devices(); } DaemonCommand::CastDeviceLost(id) => { self.cast_devices.remove(&id); @@ -166,54 +239,100 @@ impl Daemon { } async fn start_cast(&mut self, device_id: String, reply: oneshot::Sender>) { - if self.active_session.is_some() { + if self.active_session.is_some() || self.starting { let _ = reply.send(Err("already casting -- stop the current session first".to_string())); return; } if let Some(device) = self.cast_devices.get(&device_id).cloned() { - match CastMirrorSession::start(device.clone(), self.self_tx.clone()).await { - Ok(session) => { - self.active_session = Some(ActiveSession::Cast(session)); - self.state = - StateInfo::Casting { device_id: device.id.clone(), device_name: device.name.clone(), protocol: Protocol::Cast }; - self.broadcast_state(); - bread_events::emit_mirroring_started(&self.bread_client, &device.id, &device.name, "cast"); - let _ = reply.send(Ok(())); - } - Err(e) => { + self.starting = true; + let generation = self.start_generation; + let daemon_tx = self.self_tx.clone(); + tokio::spawn(async move { + let outcome = match CastMirrorSession::start(device.clone(), daemon_tx.clone(), generation).await { + Ok(session) => Ok(StartedSession { + session: ActiveSession::Cast(session), + device_id: device.id, + device_name: device.name, + protocol: Protocol::Cast, + }), // `{e:#}` (not `{e}`/`to_string()`) so the full anyhow // context chain reaches the caller/GUI instead of just // the outermost ".context()" message. - bread_events::emit_mirroring_failed(&self.bread_client, &device.id, &format!("{e:#}")); - let _ = reply.send(Err(format!("{e:#}"))); - } - } + Err(e) => Err(StartFailed { device_id: device.id, error: format!("{e:#}") }), + }; + let _ = daemon_tx.send(DaemonCommand::StartFinished { generation, outcome, reply }).await; + }); return; } if let Some(device) = self.dlna_devices.get(&device_id).cloned() { - match DlnaMirrorSession::start(device.clone(), self.self_tx.clone()).await { - Ok(session) => { - self.active_session = Some(ActiveSession::Dlna(Box::new(session))); - self.state = StateInfo::Casting { - device_id: device.url.clone(), - device_name: device.friendly_name.clone(), + self.starting = true; + let generation = self.start_generation; + let daemon_tx = self.self_tx.clone(); + tokio::spawn(async move { + let outcome = match DlnaMirrorSession::start(device.clone(), daemon_tx.clone(), generation).await { + Ok(session) => Ok(StartedSession { + session: ActiveSession::Dlna(Box::new(session)), + device_id: device.url, + device_name: device.friendly_name, protocol: Protocol::Dlna, - }; - self.broadcast_state(); - bread_events::emit_mirroring_started(&self.bread_client, &device.url, &device.friendly_name, "dlna"); - let _ = reply.send(Ok(())); - } - Err(e) => { - bread_events::emit_mirroring_failed(&self.bread_client, &device.url, &format!("{e:#}")); - let _ = reply.send(Err(format!("{e:#}"))); - } - } + }), + Err(e) => Err(StartFailed { device_id: device.url, error: format!("{e:#}") }), + }; + let _ = daemon_tx.send(DaemonCommand::StartFinished { generation, outcome, reply }).await; + }); return; } - let _ = reply.send(Err(format!("unknown device id \"{device_id}\""))); + let error = format!("unknown device id \"{device_id}\""); + bread_events::emit_mirroring_failed(&self.bread_client, &device_id, &error); + let _ = reply.send(Err(error)); + } + + async fn on_start_finished( + &mut self, + generation: u64, + outcome: Result, + reply: oneshot::Sender>, + ) { + if generation != self.start_generation || !self.starting { + // StopCast cancelled this start while the portal/negotiation + // was still running. Tear the session down if it succeeded + // anyway, so we don't leak a sender or leave the TV casting. + if let Ok(started) = outcome { + started.session.stop().await; + } + let _ = reply.send(Err("start cancelled".to_string())); + return; + } + self.starting = false; + match outcome { + Ok(started) => { + self.active_session = Some(started.session); + self.state = StateInfo::Casting { + device_id: started.device_id.clone(), + device_name: started.device_name.clone(), + protocol: started.protocol, + }; + self.broadcast_state(); + let protocol = match started.protocol { + Protocol::Cast => "cast", + Protocol::Dlna => "dlna", + }; + bread_events::emit_mirroring_started( + &self.bread_client, + &started.device_id, + &started.device_name, + protocol, + ); + let _ = reply.send(Ok(())); + } + Err(failed) => { + bread_events::emit_mirroring_failed(&self.bread_client, &failed.device_id, &failed.error); + let _ = reply.send(Err(failed.error)); + } + } } fn device_list(&self) -> Vec { @@ -241,3 +360,159 @@ impl Daemon { let _ = self.events_tx.send(ServerMessage::Event { event: "device_list_changed".to_string(), data }); } } + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use bread_utils::bread_client::BreadClient; + use tokio::sync::{broadcast, mpsc, oneshot}; + + use super::*; + + /// A daemon parked mid-`StartCast`: `starting` is set and a concrete + /// generation is outstanding, but no `StartFinished` has landed yet. + /// This is exactly the window both races below target -- the portal + /// picker / OFFER/ANSWER negotiation is still running off the actor, + /// and the session that `StartFinished` would install is not installed. + /// + /// A real `MirrorSession` can't be built in a unit test (it needs the + /// portal capture session, a GStreamer encode pipeline, and a live + /// renderer on the LAN), so the `Stale StartFinished` probes below use + /// an `Err(StartFailed)` outcome. That still exercises the whole point + /// of the guard: `on_start_finished` checks `generation`/`starting` + /// *before* branching on the outcome, so the stale event is rejected + /// with "start cancelled", never installs a session, and the UI stays + /// `Idle`. The `Ok` branch of that same guard calls + /// `started.session.stop()` to tear the dead session down -- the literal + /// teardown line -- which is the only part not separately asserted here. + fn daemon_with_in_flight_start(generation: u64) -> (Daemon, broadcast::Receiver) { + let (events_tx, events_rx) = broadcast::channel(16); + let (self_tx, _self_rx) = mpsc::channel(16); + let daemon = Daemon { + cast_devices: HashMap::new(), + dlna_devices: HashMap::new(), + state: StateInfo::Idle, + active_session: None, + starting: true, + start_generation: generation, + events_tx, + bread_client: BreadClient::connect("daemon-test"), + self_tx, + }; + (daemon, events_rx) + } + + /// A `StartFinished` carrying the *dead* session's generation -- the + /// probe both race tests replay after invalidating the in-flight start, + /// to prove it can't sneak a session back in. + fn stale_start_finished(reply: oneshot::Sender>) -> DaemonCommand { + DaemonCommand::StartFinished { + generation: 7, + outcome: Err(StartFailed { + device_id: "living-room-tv".to_string(), + error: "negotiation failed".to_string(), + }), + reply, + } + } + + #[test] + fn link_local_v6_is_detected_with_and_without_a_zone() { + // Scoped form: mdns-sd's ScopedIp Display renders link-local IPv6 as + // `fe80::…%`, and IpAddr can't parse the `%` suffix -- this is + // the exact form the daemon-clobber bug shipped with. + assert!(is_link_local_v6("fe80::1234%wlan0")); + assert!(is_link_local_v6("fe80::1234%3")); + // Unscoped (bare)`fe80::…` also matches. + assert!(is_link_local_v6("fe80::1234")); + } + + #[test] + fn non_link_local_addresses_are_not_marked_link_local() { + assert!(!is_link_local_v6("fe80")); + assert!(!is_link_local_v6("192.168.1.50")); + assert!(!is_link_local_v6("fd00::1")); // ULA + assert!(!is_link_local_v6("2606:4700::1111")); // public + assert!(!is_link_local_v6("2606:4700::1111%wlan0")); // public + spurious zone + assert!(!is_link_local_v6("not-an-ip")); + } + + /// The session's event pump reports death (`SessionEnded`) *between* + /// negotiation succeeding and `StartFinished` landing -- the exact race + /// that used to install the already-dead session and leave the UI stuck + /// on "Casting" until the frame pump happened to notice. + #[tokio::test] + async fn session_ended_during_an_in_flight_start_invalidates_the_pending_start() { + let (mut daemon, mut events_rx) = daemon_with_in_flight_start(7); + + daemon.handle(DaemonCommand::SessionEnded { generation: 7 }).await; + + // Invalidate the pending start exactly as StopCast does: clear + // `starting` and bump the generation so on_start_finished tears the + // dead session down instead of installing it. + assert!(!daemon.starting, "in-flight start must be cancelled"); + assert_eq!(daemon.start_generation, 8); + assert!(daemon.active_session.is_none()); + assert!(matches!(daemon.state, StateInfo::Idle)); + + // The stale StartFinished from the dead session must not install it, + // and the caller must hear that the start was cancelled. + let (reply, reply_rx) = oneshot::channel(); + daemon.handle(stale_start_finished(reply)).await; + + assert_eq!(reply_rx.await.unwrap(), Err("start cancelled".to_string())); + assert!(daemon.active_session.is_none(), "dead session must not be installed"); + assert!(matches!(daemon.state, StateInfo::Idle), "UI must return to / stay Idle"); + + // A cancelled in-flight start broadcasts nothing -- no bogus + // state_changed to a UI that is already Idle. + assert!(events_rx.try_recv().is_err()); + } + + /// `StopCast` while the portal/negotiation is still running must cancel + /// the in-flight start, so its eventual `StartFinished` is rejected + /// rather than installing a session the user just explicitly stopped. + #[tokio::test] + async fn stop_cast_cancels_an_in_flight_start() { + let (mut daemon, mut events_rx) = daemon_with_in_flight_start(7); + + let (reply, reply_rx) = oneshot::channel(); + daemon.handle(DaemonCommand::StopCast { reply }).await; + + // StopCast still resolves Ok -- there was nothing installed to stop, + // only a start to cancel. + assert!(reply_rx.await.unwrap().is_ok()); + assert!(!daemon.starting, "StopCast must cancel the in-flight start"); + assert_eq!(daemon.start_generation, 8); + assert!(daemon.active_session.is_none()); + assert!(matches!(daemon.state, StateInfo::Idle)); + + // A late StartFinished from the cancelled start never installs. + let (reply, reply_rx) = oneshot::channel(); + daemon.handle(stale_start_finished(reply)).await; + assert_eq!(reply_rx.await.unwrap(), Err("start cancelled".to_string())); + assert!(daemon.active_session.is_none(), "cancelled start must not install a session"); + assert!(matches!(daemon.state, StateInfo::Idle)); + + // StopCast itself broadcast the transition back to Idle. + assert!(events_rx.try_recv().is_ok()); + } + + /// Defensive: a `SessionEnded` whose generation no longer matches (a + /// late notification from an already-stopped or already-cancelled + /// session) must be ignored without touching the current generation, so + /// it can't confuse a brand-new later start. + #[tokio::test] + async fn a_stale_session_ended_while_idle_is_ignored_without_bumping_generation() { + let (mut daemon, _events_rx) = daemon_with_in_flight_start(0); + daemon.starting = false; // not starting -- the stale event's own forerunner already finished + + daemon.handle(DaemonCommand::SessionEnded { generation: 99 }).await; + + assert!(!daemon.starting); + assert_eq!(daemon.start_generation, 0, "stale SessionEnded must not bump a future start's generation"); + assert!(daemon.active_session.is_none()); + assert!(matches!(daemon.state, StateInfo::Idle)); + } +} diff --git a/breadcastd/src/dlna_mirror.rs b/breadcastd/src/dlna_mirror.rs index c3c68e2..510cc00 100644 --- a/breadcastd/src/dlna_mirror.rs +++ b/breadcastd/src/dlna_mirror.rs @@ -13,8 +13,11 @@ use std::time::Duration; use anyhow::{Context, Result}; +use breadcast_core::http_server::HttpServer; use breadcast_core::net::local_lan_ip; -use breadcast_core::pipeline::{build_video_pipeline, hls_output_dir, wait_for_playlist_segments}; +use breadcast_core::pipeline::{ + build_video_pipeline, hls_output_dir, run_until_eos_or_error, wait_for_playlist_segments, +}; use breadcast_core::{CaptureSession, DlnaDevice, DlnaSession}; use gstreamer as gst; use gstreamer::prelude::*; @@ -27,11 +30,20 @@ use crate::daemon::DaemonCommand; /// this is a poll, not a push. const POLL_INTERVAL: Duration = Duration::from_secs(3); +/// How long the playlist may sit unchanged before we treat the encode +/// side as dead. The Cast path has `pull_encoded_frame`'s stall watchdog; +/// this is the HLS equivalent -- portal/encoder stalls produce no bus +/// error, just a playlist that stops growing. +const PLAYLIST_STALL_TIMEOUT: Duration = Duration::from_secs(15); + pub struct DlnaMirrorSession { pipeline: gst::Pipeline, session: DlnaSession, capture: Option, + http: HttpServer, + output_dir: std::path::PathBuf, poll_task: tokio::task::JoinHandle<()>, + stall_task: tokio::task::JoinHandle<()>, } impl DlnaMirrorSession { @@ -43,42 +55,57 @@ impl DlnaMirrorSession { /// renderer stopping playback on its own, a GStreamer error, or the /// renderer becoming unreachable) back to the daemon actor — mirrors /// `CastMirrorSession::start`'s same use of it. - pub async fn start(device: DlnaDevice, daemon_tx: tokio::sync::mpsc::Sender) -> Result { + pub async fn start( + device: DlnaDevice, + daemon_tx: tokio::sync::mpsc::Sender, + generation: u64, + ) -> Result { let capture = CaptureSession::start().await.context("failed to start portal screen capture")?; let video_node_id = capture.video_node_id(); - let output_dir = hls_output_dir("dlna-mirror")?; - let pipeline = - build_video_pipeline(video_node_id, &output_dir).context("failed to build the encode pipeline")?; + let output_dir = match hls_output_dir("dlna-mirror") { + Ok(dir) => dir, + Err(e) => { + let _ = capture.close().await; + return Err(e); + } + }; + let pipeline = match build_video_pipeline(video_node_id, &output_dir) { + Ok(p) => p, + Err(e) => { + let _ = capture.close().await; + let _ = std::fs::remove_dir_all(&output_dir); + return Err(e).context("failed to build the encode pipeline"); + } + }; - // Fire-and-forget, same as `CastMirrorSession::start`'s identical - // block: nothing joins this thread, it just self-terminates on - // pipeline error, EOS, or its own 1-hour timeout, whichever is - // first — see that function's doc comment for why that's fine. - { - let pipeline_watch = pipeline.clone(); - std::thread::spawn(move || { - match breadcast_core::pipeline::run_until_error_or_timeout(&pipeline_watch, gst::ClockTime::from_seconds(3600)) - { - Ok(outcome) => tracing::debug!(?outcome, "DLNA encode pipeline bus watcher ended"), - Err(e) => tracing::error!(error = ?e, "DLNA encode pipeline error"), - } - }); + if let Err(e) = pipeline.set_state(gst::State::Playing) { + let _ = capture.close().await; + let _ = std::fs::remove_dir_all(&output_dir); + return Err(e).context("failed to start the encode pipeline"); } - pipeline.set_state(gst::State::Playing).context("failed to start the encode pipeline")?; - - let lan_ip = local_lan_ip().context("failed to determine this machine's LAN-reachable IP")?; + let peer = host_ip_from_url(&device.url); + let lan_ip = match peer.map(breadcast_core::net::local_lan_ip_for).unwrap_or_else(local_lan_ip) { + Ok(ip) => ip, + Err(e) => { + abort_partial(&pipeline, capture, None, &output_dir).await; + return Err(e).context("failed to determine this machine's LAN-reachable IP"); + } + }; // Bind an ephemeral port (`:0`) rather than a fixed one like the // `dlna_mirror_test` example uses -- the daemon may need to run // alongside that example, or a future concurrent-session mode, - // without a bind conflict. `HttpServer::start`'s worker threads - // outlive this session once it stops (documented pre-existing - // limitation, see `http_server.rs` -- not something introduced - // here); one leaked idle listener per DLNA cast is an accepted - // cost until that gets a real shutdown path. - let http = breadcast_core::http_server::HttpServer::start("0.0.0.0:0", output_dir.clone()) - .context("failed to start the HLS HTTP server")?; + // without a bind conflict. The server is held on the session and + // shut down in `stop()` so the last screen-recording segments are + // not left reachable on the LAN. + let http = match HttpServer::start("0.0.0.0:0", output_dir.clone()) { + Ok(http) => http, + Err(e) => { + abort_partial(&pipeline, capture, None, &output_dir).await; + return Err(e).context("failed to start the HLS HTTP server"); + } + }; let stream_url = http.url(lan_ip, "playlist.m3u8"); // Two segments, not three: this is a "don't hand the renderer a 404 @@ -87,30 +114,42 @@ impl DlnaMirrorSession { // `build_video_pipeline`'s note on HLS latency). Two is the minimum // that still proves the encoder is genuinely producing output rather // than having emitted one segment and stalled. - wait_for_playlist_segments(&output_dir.join("playlist.m3u8"), 2, Duration::from_secs(20)) - .await - .context("encode pipeline never produced playable HLS segments")?; + if let Err(e) = wait_for_playlist_segments(&output_dir.join("playlist.m3u8"), 2, Duration::from_secs(20)).await + { + abort_partial(&pipeline, capture, Some(http), &output_dir).await; + return Err(e).context("encode pipeline never produced playable HLS segments"); + } - let session = DlnaSession::connect(&device).await.context("failed to connect to the DLNA renderer")?; - session.load(&stream_url).await.context("renderer rejected the stream load")?; + let session = match DlnaSession::connect(&device).await { + Ok(session) => session, + Err(e) => { + abort_partial(&pipeline, capture, Some(http), &output_dir).await; + return Err(e).context("failed to connect to the DLNA renderer"); + } + }; + if let Err(e) = session.load(&stream_url).await { + abort_partial(&pipeline, capture, Some(http), &output_dir).await; + return Err(e).context("renderer rejected the stream load"); + } tracing::info!(device = %device.friendly_name, %stream_url, "DLNA mirroring started"); let poll_task = { let session = session.clone(); let device_name = device.friendly_name.clone(); + let daemon_tx = daemon_tx.clone(); tokio::spawn(async move { loop { tokio::time::sleep(POLL_INTERVAL).await; match session.transport_state().await { Ok(state) if state == "STOPPED" || state == "NO_MEDIA_PRESENT" => { tracing::info!(device = %device_name, %state, "DLNA renderer ended playback on its own"); - let _ = daemon_tx.send(DaemonCommand::SessionEnded).await; + let _ = daemon_tx.send(DaemonCommand::SessionEnded { generation }).await; return; } Ok(_) => {} Err(e) => { tracing::warn!(device = %device_name, error = ?e, "DLNA transport state poll failed, treating renderer as gone"); - let _ = daemon_tx.send(DaemonCommand::SessionEnded).await; + let _ = daemon_tx.send(DaemonCommand::SessionEnded { generation }).await; return; } } @@ -118,17 +157,69 @@ impl DlnaMirrorSession { }) }; - Ok(Self { pipeline, session, capture: Some(capture), poll_task }) + let stall_task = { + let playlist = output_dir.join("playlist.m3u8"); + let daemon_tx = daemon_tx.clone(); + tokio::spawn(async move { + let mut last_mtime = None; + let mut stalled_for = Duration::ZERO; + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + let mtime = std::fs::metadata(&playlist).and_then(|m| m.modified()).ok(); + if mtime != last_mtime { + last_mtime = mtime; + stalled_for = Duration::ZERO; + continue; + } + stalled_for += Duration::from_secs(1); + if stalled_for >= PLAYLIST_STALL_TIMEOUT { + tracing::warn!( + "DLNA encode stalled: playlist unchanged for {}s", + PLAYLIST_STALL_TIMEOUT.as_secs() + ); + let _ = daemon_tx.send(DaemonCommand::SessionEnded { generation }).await; + return; + } + } + }) + }; + + // Portal "stop sharing" / a real GStreamer error used to only log + // -- the bus watcher never told the daemon, so the UI stayed on + // Casting and the renderer kept looping stale segments. Notify. + { + let pipeline_watch = pipeline.clone(); + let daemon_tx = daemon_tx.clone(); + std::thread::spawn(move || { + match run_until_eos_or_error(&pipeline_watch) { + Ok(outcome) => tracing::debug!(?outcome, "DLNA encode pipeline bus watcher ended"), + Err(e) => tracing::error!(error = ?e, "DLNA encode pipeline error"), + } + let _ = daemon_tx.blocking_send(DaemonCommand::SessionEnded { generation }); + }); + } + + Ok(Self { + pipeline, + session, + capture: Some(capture), + http, + output_dir, + poll_task, + stall_task, + }) } /// Tears down the session: stops polling, tells the renderer to stop, - /// stops the encode pipeline, and closes the portal capture session. + /// stops the encode pipeline, shuts the HLS server, closes the portal + /// capture session, and deletes the recording directory. pub async fn stop(mut self) { // A request, not a wait -- if the poll task is mid-poll and sends // one more `SessionEnded` right as this races it, that's harmless: // `daemon.rs`'s handler already no-ops when `active_session` was // already cleared by this explicit stop. self.poll_task.abort(); + self.stall_task.abort(); if let Err(e) = self.session.stop().await { tracing::warn!(error = ?e, "failed to cleanly stop the DLNA session"); @@ -136,10 +227,45 @@ impl DlnaMirrorSession { if let Err(e) = self.pipeline.set_state(gst::State::Null) { tracing::warn!(error = ?e, "failed to stop the encode pipeline cleanly"); } + self.http.shutdown(); if let Some(capture) = self.capture.take() { - if let Err(e) = capture.close().await { - tracing::warn!(error = ?e, "failed to cleanly close the portal capture session"); + match tokio::time::timeout(Duration::from_secs(5), capture.close()).await { + Ok(Err(e)) => tracing::warn!(error = ?e, "failed to cleanly close the portal capture session"), + Err(_) => tracing::warn!("portal capture session did not close within 5s -- abandoning it"), + Ok(Ok(())) => {} } } + if let Err(e) = std::fs::remove_dir_all(&self.output_dir) { + tracing::debug!(error = %e, dir = %self.output_dir.display(), "failed to remove HLS output dir"); + } } } + +async fn abort_partial( + pipeline: &gst::Pipeline, + capture: CaptureSession, + http: Option, + output_dir: &std::path::Path, +) { + let _ = pipeline.set_state(gst::State::Null); + if let Some(mut http) = http { + http.shutdown(); + } + match tokio::time::timeout(Duration::from_secs(5), capture.close()).await { + Ok(Err(e)) => tracing::warn!(error = ?e, "failed to close portal capture after a failed DLNA start"), + Err(_) => tracing::warn!("portal capture did not close within 5s after a failed DLNA start"), + Ok(Ok(())) => {} + } + let _ = std::fs::remove_dir_all(output_dir); +} + +fn host_ip_from_url(url: &str) -> Option { + let rest = url.split("://").nth(1)?; + let hostport = rest.split('/').next()?; + let host = if let Some(inside) = hostport.strip_prefix('[') { + inside.split(']').next()? + } else { + hostport.rsplit_once(':').map(|(h, _)| h).unwrap_or(hostport) + }; + host.parse().ok() +} diff --git a/breadcastd/src/ipc.rs b/breadcastd/src/ipc.rs index d8cbd85..703d50c 100644 --- a/breadcastd/src/ipc.rs +++ b/breadcastd/src/ipc.rs @@ -13,6 +13,8 @@ //! a time, but nothing here assumes that) can connect concurrently; each //! gets its own copy of every broadcast event. +use std::os::unix::fs::DirBuilderExt; + use anyhow::{Context, Result}; use breadcast_core::ipc::{ClientRequest, ServerMessage, socket_path}; use serde_json::Value; @@ -31,7 +33,13 @@ use crate::daemon::DaemonCommand; pub async fn serve(daemon_tx: mpsc::Sender, events_tx: broadcast::Sender) -> Result<()> { let socket_path = socket_path()?; if let Some(parent) = socket_path.parent() { - std::fs::create_dir_all(parent) + // 0700 even if umask is loose -- this directory holds the control + // socket, and XDG_RUNTIME_DIR itself is 0700 but a recreate after + // a wiped runtime dir should not inherit a world-readable mode. + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(parent) .with_context(|| format!("failed to create socket dir {}", parent.display()))?; } // A stale socket file from an unclean previous exit makes bind() fail @@ -45,7 +53,18 @@ pub async fn serve(daemon_tx: mpsc::Sender, events_tx: broadcast: tracing::info!(path = %socket_path.display(), "IPC socket listening"); loop { - let (stream, _addr) = listener.accept().await.context("failed to accept IPC connection")?; + let (stream, _addr) = match listener.accept().await { + Ok(accepted) => accepted, + Err(e) => { + // EMFILE / a single bad accept must not take the control + // socket down for the rest of the daemon's life -- discovery + // would keep running while every `breadcast` launch reports + // "isn't running". + tracing::warn!(error = %e, "IPC accept failed, retrying"); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + continue; + } + }; let daemon_tx = daemon_tx.clone(); let events_rx = events_tx.subscribe(); tokio::spawn(async move { @@ -106,10 +125,14 @@ async fn handle_connection( continue; } }; - let response = handle_request(request, &daemon_tx).await; - if writer_tx.send(response).is_err() { - break; - } + // Handle off the read loop so a long `start_cast` (portal picker) + // does not block `stop_cast` sitting in the same socket buffer. + let daemon_tx = daemon_tx.clone(); + let writer_tx = writer_tx.clone(); + tokio::spawn(async move { + let response = handle_request(request, &daemon_tx).await; + let _ = writer_tx.send(response); + }); } forward_task.abort(); diff --git a/breadcastd/src/main.rs b/breadcastd/src/main.rs index 47cfde1..cd97b97 100644 --- a/breadcastd/src/main.rs +++ b/breadcastd/src/main.rs @@ -73,8 +73,17 @@ async fn main() -> anyhow::Result<()> { tracing::info!(?device, "Cast device found"); bread_events::emit_device_found(&bread_client, &device.id, &device.name, &device.model, "cast"); known_cast_devices.insert(device.id.clone(), device.clone()); + // Forward only on an actual change. The daemon + // actor is single-threaded and `Found` fires on + // every mDNS re-resolution (per address, per + // re-browse), so forwarding identical devices too + // would spam `device_list_changed` broadcasts and + // delay `list_devices`/`start_cast` replies that + // share the same actor queue. The daemon's own + // `CastDeviceLost`→refind logic still works, since + // a loss is a state change on its side too. + let _ = daemon_tx.send(DaemonCommand::CastDeviceFound(device)).await; } - let _ = daemon_tx.send(DaemonCommand::CastDeviceFound(device)).await; } Some(DiscoveryEvent::Lost { id }) => { tracing::info!(%id, "Cast device lost"); @@ -99,8 +108,12 @@ async fn main() -> anyhow::Result<()> { tracing::info!(?device, "DLNA device found"); bread_events::emit_device_found(&bread_client, &device.url, &device.friendly_name, "DLNA renderer", "dlna"); known_dlna_devices.insert(device.url.clone(), device.clone()); + // Same rationale as the Cast arm above: each SSDP + // poll only reports devices it *this* poll + // confirmed, but a device re-confirmed unchanged + // needs no daemon flush. + let _ = daemon_tx.send(DaemonCommand::DlnaDeviceFound(device)).await; } - let _ = daemon_tx.send(DaemonCommand::DlnaDeviceFound(device)).await; } Some(DlnaDiscoveryEvent::Lost { url }) => { tracing::info!(%url, "DLNA device lost"); diff --git a/contrib/breadcastd.service b/contrib/breadcastd.service index c7778b5..69571af 100644 --- a/contrib/breadcastd.service +++ b/contrib/breadcastd.service @@ -7,7 +7,7 @@ PartOf=graphical-session.target [Service] Type=simple -ExecStart=%h/.cargo/bin/breadcastd +ExecStart=%h/.local/bin/breadcastd Restart=on-failure RestartSec=2 diff --git a/vendor/rust_cast-0.21.0/PATCHES.md b/vendor/rust_cast-0.21.0/PATCHES.md index 50f4b8f..94308d9 100644 --- a/vendor/rust_cast-0.21.0/PATCHES.md +++ b/vendor/rust_cast-0.21.0/PATCHES.md @@ -22,11 +22,14 @@ channel claims the namespace. ## The patch -`src/lib.rs`: added `CastDevice::send_message(&self, namespace, -destination, message)`, built the same way `ReceiverChannel::broadcast_message()` -is internally, but with a caller-supplied `destination` instead of a -hardcoded `"*"`. See the doc comment on that method for the exact rationale -(marked "LOCAL PATCH (breadcast, not upstream)"). +`src/lib.rs`: added `CastDevice::send_message(&self, namespace: &str, +destination: &str, message: &str)`, built the same way +`ReceiverChannel::broadcast_message()` is internally, but with a +caller-supplied `destination` instead of a hardcoded `"*"`. The payload is +a raw string (already-serialized JSON from the vendored openscreen C++), +not a `Serialize` value -- a generic `M: Serialize` would double-encode +the OFFER. See the doc comment on that method (marked "LOCAL PATCH +(breadcast, not upstream)"). ## Rolling the pin