Compare commits
7 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
092b46af07 | ||
|
|
b5ea779c06 | ||
|
|
99a04c800b | ||
|
|
17abeed7ae | ||
|
|
a80c49593d | ||
|
|
7ae40e58a3 | ||
|
|
1cf82d739e |
33 changed files with 1298 additions and 350 deletions
|
|
@ -1,11 +1,11 @@
|
||||||
name: beta release
|
name: beta release
|
||||||
|
|
||||||
# Publishes a beta-track build on every push to `beta` — a frozen
|
# Publishes a beta-track build from a `vX.Y.Z-rc.N` tag. The leftover
|
||||||
# stabilization branch cut from `dev` when ready to stabilize; only
|
# `beta` branch trigger is kept so an old branch push does not go silent;
|
||||||
# fix/<issue> branches merged into `beta` should land here afterward.
|
# do not use that branch — cut beta from an RC tag. See CONTRIBUTING.md.
|
||||||
# See bread-ecosystem's docs/release-channels.md for the three-track policy.
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
tags: ['v*-rc.*']
|
||||||
branches: ['beta']
|
branches: ['beta']
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
@ -16,7 +16,7 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
rm -rf src && mkdir src
|
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
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||||
|
|
||||||
- name: build
|
- name: build
|
||||||
|
|
@ -25,19 +25,21 @@ jobs:
|
||||||
- name: compute beta version
|
- name: compute beta version
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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
|
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 \
|
LATEST_TAG="$(git ls-remote --tags --refs \
|
||||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \
|
"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
|
if [ -n "${LATEST_TAG}" ]; then
|
||||||
CUR="${LATEST_TAG}"
|
CUR="${LATEST_TAG}"
|
||||||
else
|
else
|
||||||
CUR="$(grep -m1 '^version' breadcast/Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
|
CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
|
||||||
fi
|
fi
|
||||||
IFS='.' read -r MA MI PA <<< "${CUR}"
|
IFS='.' read -r MA MI PA <<< "${CUR}"
|
||||||
SHA="$(git rev-parse --short HEAD)"
|
SHA="$(git rev-parse --short HEAD)"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ name: check
|
||||||
# main and triggers a dev-track release build.
|
# main and triggers a dev-track release build.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ['feature/**', 'fix/**']
|
branches: ['main', 'feature/**', 'fix/**']
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
name: dev release
|
name: dev release
|
||||||
|
|
||||||
# Publishes a dev-track build on every push to `dev` —
|
# Publishes a dev-track build on every push to `main` (single-trunk).
|
||||||
# separate from release.yml's tag-triggered stable releases. See
|
# The leftover `dev` trigger is kept so an old branch push still works
|
||||||
# bread-ecosystem's docs/release-channels.md for the three-track policy
|
# until that branch is deleted. See CONTRIBUTING.md.
|
||||||
# this is part of.
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ['dev']
|
branches: ['main', 'dev']
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
@ -16,7 +15,7 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
rm -rf src && mkdir src
|
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
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||||
|
|
||||||
- name: build
|
- name: build
|
||||||
|
|
@ -37,7 +36,7 @@ jobs:
|
||||||
if [ -n "${LATEST_TAG}" ]; then
|
if [ -n "${LATEST_TAG}" ]; then
|
||||||
CUR="${LATEST_TAG}"
|
CUR="${LATEST_TAG}"
|
||||||
else
|
else
|
||||||
CUR="$(grep -m1 '^version' breadcast/Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
|
CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
|
||||||
fi
|
fi
|
||||||
IFS='.' read -r MA MI PA <<< "${CUR}"
|
IFS='.' read -r MA MI PA <<< "${CUR}"
|
||||||
SHA="$(git rev-parse --short HEAD)"
|
SHA="$(git rev-parse --short HEAD)"
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
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]
|
runs-on: [self-hosted, hestia]
|
||||||
steps:
|
steps:
|
||||||
- name: checkout
|
- name: checkout
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -35,3 +35,6 @@ CLAUDE.md
|
||||||
|
|
||||||
# graphify knowledge-graph output (local tool cache, not for commit)
|
# graphify knowledge-graph output (local tool cache, not for commit)
|
||||||
graphify-out/
|
graphify-out/
|
||||||
|
|
||||||
|
# .freebuff local tool state (not for commit)
|
||||||
|
.freebuff/
|
||||||
|
|
|
||||||
22
AGENTS.md
22
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.
|
- `github` — GitHub mirror. Push both when publishing.
|
||||||
|
|
||||||
## CI
|
## CI
|
||||||
- `release.yml` triggers on `push: tags: ['v*']` — tag a release to cut
|
- `release.yml` triggers on `push: tags: ['v*']` but skips any tag whose
|
||||||
the signed stable build.
|
name contains `-rc` — a plain `vX.Y.Z` cuts the signed stable build.
|
||||||
- Leftover: `dev-release.yml` still triggers on `push: branches: ['dev']`
|
- `beta-release.yml` publishes a beta-track build from a `vX.Y.Z-rc.N`
|
||||||
and `beta-release.yml` still triggers on `push: branches: ['beta']`.
|
tag (and still on leftover `beta` so an old branch push does not go silent).
|
||||||
Those files are leftover from the three-branch model. Do **not** rewrite
|
- `dev-release.yml` publishes a dev-track build on push to `main` (and
|
||||||
them unless you are deliberately migrating CI; document and follow
|
still on leftover `dev` so an old branch push does not go silent).
|
||||||
single-trunk `main` + RC tags instead.
|
- `check.yml` runs clippy + test on `main`, `feature/**`, and `fix/**`.
|
||||||
- `check.yml` runs clippy + test on `feature/**` and `fix/**`.
|
|
||||||
- No separate lint/PR-check pipeline on ordinary commits to `main`.
|
|
||||||
|
|
||||||
## Product cut
|
## Product cut
|
||||||
breadcast is a **bakery product**, not shipped on the BOS ISO, and not part
|
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.
|
validated against real hardware. `EVENTS.md` is the bus contract.
|
||||||
|
|
||||||
## Pins
|
## Pins
|
||||||
`bread-theme` and `bread-utils` pin
|
`bread-theme` pins `git.breadway.dev/Breadway/bread-ecosystem` tag
|
||||||
`git.breadway.dev/Breadway/bread-ecosystem` tag `v0.7.1`. Do not switch
|
`v0.7.4` (per-monitor palette). `bread-utils` pins the same repo at
|
||||||
those to github.com or `branch = "main"`.
|
`v0.7.2`. Do not switch those to github.com or `branch = "main"`.
|
||||||
|
|
||||||
## Don't
|
## Don't
|
||||||
- Don't embed credentials in remote URLs — SSH or a credential helper only.
|
- Don't embed credentials in remote URLs — SSH or a credential helper only.
|
||||||
|
|
|
||||||
228
Cargo.lock
generated
228
Cargo.lock
generated
|
|
@ -4,9 +4,9 @@ version = 4
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
@ -63,9 +63,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.91"
|
version = "0.1.92"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
|
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|
@ -92,9 +92,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aws-lc-rs"
|
name = "aws-lc-rs"
|
||||||
version = "1.17.3"
|
version = "1.18.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1"
|
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-sys",
|
"aws-lc-sys",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
|
|
@ -102,9 +102,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aws-lc-sys"
|
name = "aws-lc-sys"
|
||||||
version = "0.43.0"
|
version = "0.44.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c"
|
checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"cmake",
|
"cmake",
|
||||||
|
|
@ -132,8 +132,8 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-theme"
|
name = "bread-theme"
|
||||||
version = "0.7.2"
|
version = "0.7.4"
|
||||||
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73"
|
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#fcba3760387e2523edb71350f8efea3bc851b21e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs",
|
"dirs",
|
||||||
"gtk4",
|
"gtk4",
|
||||||
|
|
@ -187,6 +187,7 @@ dependencies = [
|
||||||
"gstreamer",
|
"gstreamer",
|
||||||
"gstreamer-app",
|
"gstreamer-app",
|
||||||
"gstreamer-video",
|
"gstreamer-video",
|
||||||
|
"if-addrs 0.15.0",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
"rupnp",
|
"rupnp",
|
||||||
"rust_cast",
|
"rust_cast",
|
||||||
|
|
@ -253,14 +254,14 @@ checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"glib-sys",
|
"glib-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"system-deps",
|
"system-deps 7.0.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.4.0"
|
version = "1.4.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
|
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"jobserver",
|
"jobserver",
|
||||||
|
|
@ -429,9 +430,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flume"
|
name = "flume"
|
||||||
|
|
@ -452,24 +453,24 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.33"
|
version = "0.3.34"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
|
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-core"
|
name = "futures-core"
|
||||||
version = "0.3.33"
|
version = "0.3.34"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
|
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-executor"
|
name = "futures-executor"
|
||||||
version = "0.3.33"
|
version = "0.3.34"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
|
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
|
|
@ -478,9 +479,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-io"
|
name = "futures-io"
|
||||||
version = "0.3.33"
|
version = "0.3.34"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
|
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-lite"
|
name = "futures-lite"
|
||||||
|
|
@ -497,32 +498,32 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-macro"
|
name = "futures-macro"
|
||||||
version = "0.3.33"
|
version = "0.3.34"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
|
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-sink"
|
name = "futures-sink"
|
||||||
version = "0.3.33"
|
version = "0.3.34"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
|
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-task"
|
||||||
version = "0.3.33"
|
version = "0.3.34"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
|
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-util"
|
name = "futures-util"
|
||||||
version = "0.3.33"
|
version = "0.3.34"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
|
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-macro",
|
"futures-macro",
|
||||||
|
|
@ -553,7 +554,7 @@ dependencies = [
|
||||||
"glib-sys",
|
"glib-sys",
|
||||||
"gobject-sys",
|
"gobject-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"system-deps",
|
"system-deps 7.0.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -586,7 +587,7 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"pango-sys",
|
"pango-sys",
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
"system-deps",
|
"system-deps 7.0.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -653,7 +654,7 @@ dependencies = [
|
||||||
"glib-sys",
|
"glib-sys",
|
||||||
"gobject-sys",
|
"gobject-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"system-deps",
|
"system-deps 7.0.8",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -717,7 +718,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233"
|
checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"system-deps",
|
"system-deps 7.0.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -728,7 +729,7 @@ checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"glib-sys",
|
"glib-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"system-deps",
|
"system-deps 7.0.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -749,7 +750,7 @@ checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"glib-sys",
|
"glib-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"system-deps",
|
"system-deps 7.0.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -780,7 +781,7 @@ dependencies = [
|
||||||
"graphene-sys",
|
"graphene-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"pango-sys",
|
"pango-sys",
|
||||||
"system-deps",
|
"system-deps 7.0.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -805,7 +806,7 @@ dependencies = [
|
||||||
"pastey",
|
"pastey",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -833,7 +834,7 @@ dependencies = [
|
||||||
"gstreamer-base-sys",
|
"gstreamer-base-sys",
|
||||||
"gstreamer-sys",
|
"gstreamer-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"system-deps",
|
"system-deps 7.0.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -860,7 +861,7 @@ dependencies = [
|
||||||
"gobject-sys",
|
"gobject-sys",
|
||||||
"gstreamer-sys",
|
"gstreamer-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"system-deps",
|
"system-deps 7.0.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -873,7 +874,7 @@ dependencies = [
|
||||||
"glib-sys",
|
"glib-sys",
|
||||||
"gobject-sys",
|
"gobject-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"system-deps",
|
"system-deps 7.0.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -889,7 +890,7 @@ dependencies = [
|
||||||
"gstreamer-base",
|
"gstreamer-base",
|
||||||
"gstreamer-video-sys",
|
"gstreamer-video-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -903,7 +904,7 @@ dependencies = [
|
||||||
"gstreamer-base-sys",
|
"gstreamer-base-sys",
|
||||||
"gstreamer-sys",
|
"gstreamer-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"system-deps",
|
"system-deps 7.0.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -929,9 +930,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gtk4-layer-shell"
|
name = "gtk4-layer-shell"
|
||||||
version = "0.8.0"
|
version = "0.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a"
|
checksum = "17c28ea0f4676fdaaae7ff2413a24d0d35c8657424f84856c1103c73454c9da4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"gdk4",
|
"gdk4",
|
||||||
|
|
@ -944,15 +945,15 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gtk4-layer-shell-sys"
|
name = "gtk4-layer-shell-sys"
|
||||||
version = "0.6.0"
|
version = "0.6.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f566a5ec5bcc454e7fcf2ab76930887ced5365afce12c1e5201bb296b95f1b9"
|
checksum = "bcf19bb884ef0ef55b9e6b2b369c39b4fcc0c41e3a0c1cbc8c267720338b690b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gdk4-sys",
|
"gdk4-sys",
|
||||||
"glib-sys",
|
"glib-sys",
|
||||||
"gtk4-sys",
|
"gtk4-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"system-deps",
|
"system-deps 8.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -983,7 +984,7 @@ dependencies = [
|
||||||
"gsk4-sys",
|
"gsk4-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"pango-sys",
|
"pango-sys",
|
||||||
"system-deps",
|
"system-deps 7.0.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1035,9 +1036,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http-body-util"
|
name = "http-body-util"
|
||||||
version = "0.1.4"
|
version = "0.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
|
checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
|
@ -1155,9 +1156,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.103"
|
version = "0.3.104"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
|
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
|
@ -1193,9 +1194,9 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.18"
|
version = "0.1.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
|
checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
@ -1286,9 +1287,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-integer"
|
name = "num-integer"
|
||||||
version = "0.1.46"
|
version = "0.1.47"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
@ -1369,7 +1370,7 @@ dependencies = [
|
||||||
"glib-sys",
|
"glib-sys",
|
||||||
"gobject-sys",
|
"gobject-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"system-deps",
|
"system-deps 7.0.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1392,9 +1393,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkg-config"
|
name = "pkg-config"
|
||||||
version = "0.3.33"
|
version = "0.3.34"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
|
|
@ -1505,9 +1506,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex-automata"
|
name = "regex-automata"
|
||||||
version = "0.4.16"
|
version = "0.4.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
|
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
"memchr",
|
"memchr",
|
||||||
|
|
@ -1572,7 +1573,7 @@ dependencies = [
|
||||||
"rustls-native-certs",
|
"rustls-native-certs",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1612,9 +1613,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.42"
|
version = "0.23.43"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
|
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"log",
|
"log",
|
||||||
|
|
@ -1648,9 +1649,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-webpki"
|
name = "rustls-webpki"
|
||||||
version = "0.103.13"
|
version = "0.103.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"ring",
|
"ring",
|
||||||
|
|
@ -1906,6 +1907,19 @@ dependencies = [
|
||||||
"version-compare",
|
"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]]
|
[[package]]
|
||||||
name = "target-lexicon"
|
name = "target-lexicon"
|
||||||
version = "0.13.5"
|
version = "0.13.5"
|
||||||
|
|
@ -1936,11 +1950,11 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.19"
|
version = "2.0.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
|
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl 2.0.19",
|
"thiserror-impl 2.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1956,9 +1970,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror-impl"
|
name = "thiserror-impl"
|
||||||
version = "2.0.19"
|
version = "2.0.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
|
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|
@ -2005,13 +2019,13 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-macros"
|
name = "tokio-macros"
|
||||||
version = "2.7.1"
|
version = "2.7.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
|
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2200,9 +2214,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.24.0"
|
version = "1.24.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
|
checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
|
|
@ -2238,9 +2252,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.126"
|
version = "0.2.127"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
|
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
|
@ -2251,9 +2265,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.126"
|
version = "0.2.127"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
|
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"wasm-bindgen-macro-support",
|
"wasm-bindgen-macro-support",
|
||||||
|
|
@ -2261,9 +2275,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro-support"
|
name = "wasm-bindgen-macro-support"
|
||||||
version = "0.2.126"
|
version = "0.2.127"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
|
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
|
|
@ -2274,9 +2288,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-shared"
|
name = "wasm-bindgen-shared"
|
||||||
version = "0.2.126"
|
version = "0.2.127"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
|
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
@ -2476,15 +2490,15 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xml-rs"
|
name = "xml-rs"
|
||||||
version = "0.8.28"
|
version = "0.8.29"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
|
checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zbus"
|
name = "zbus"
|
||||||
version = "5.18.0"
|
version = "5.19.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a"
|
checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-broadcast",
|
"async-broadcast",
|
||||||
"async-recursion",
|
"async-recursion",
|
||||||
|
|
@ -2512,14 +2526,14 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zbus_macros"
|
name = "zbus_macros"
|
||||||
version = "5.18.0"
|
version = "5.19.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119"
|
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro-crate",
|
"proc-macro-crate",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.3",
|
||||||
"zbus_names",
|
"zbus_names",
|
||||||
"zvariant",
|
"zvariant",
|
||||||
"zvariant_utils",
|
"zvariant_utils",
|
||||||
|
|
@ -2536,6 +2550,15 @@ dependencies = [
|
||||||
"zvariant",
|
"zvariant",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zcheapstr"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zeroize"
|
name = "zeroize"
|
||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
|
|
@ -2550,40 +2573,41 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zvariant"
|
name = "zvariant"
|
||||||
version = "5.13.1"
|
version = "5.14.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911"
|
checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"endi",
|
"endi",
|
||||||
"enumflags2",
|
"enumflags2",
|
||||||
"serde",
|
"serde",
|
||||||
"winnow 1.0.4",
|
"winnow 1.0.4",
|
||||||
|
"zcheapstr",
|
||||||
"zvariant_derive",
|
"zvariant_derive",
|
||||||
"zvariant_utils",
|
"zvariant_utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zvariant_derive"
|
name = "zvariant_derive"
|
||||||
version = "5.13.1"
|
version = "5.14.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12"
|
checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro-crate",
|
"proc-macro-crate",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.3",
|
||||||
"zvariant_utils",
|
"zvariant_utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zvariant_utils"
|
name = "zvariant_utils"
|
||||||
version = "3.5.0"
|
version = "4.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7"
|
checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"serde",
|
"serde",
|
||||||
"syn 2.0.119",
|
"syn 3.0.3",
|
||||||
"winnow 1.0.4",
|
"winnow 1.0.4",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ discovery-driven emit half.
|
||||||
|
|
||||||
| Event | Data | When |
|
| Event | Data | When |
|
||||||
|-------|------|------|
|
|-------|------|------|
|
||||||
| `bread.cast.device_found` | `{ "id": "<mdns id or DLNA description URL>", "name": "<friendly name>", "model": "<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": "<mdns id or DLNA description URL>", "name": "<friendly name>", "model": "<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": "<id>", "device_name": "<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_started` | `{ "device_id": "<id>", "device_name": "<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_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": "<id>", "error": "<message>" }` | 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). |
|
| `bread.cast.mirroring_failed` | `{ "device_id": "<id>", "error": "<message>" }` | 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). |
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
name = "breadcast"
|
name = "breadcast"
|
||||||
description = "Cast your screen to any Chromecast/Google TV or DLNA renderer — daemon + GTK4 popup"
|
description = "Cast your screen to any Chromecast/Google TV or DLNA renderer — daemon + GTK4 popup"
|
||||||
binaries = ["breadcast", "breadcastd"]
|
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
|
# breadcast-core/src/pipeline/mod.rs) -- a separate Arch package from
|
||||||
# gst-plugins-bad itself, not bundled into it. jsoncpp/openssl: runtime
|
# gst-plugins-bad itself, not bundled into it. jsoncpp/openssl: runtime
|
||||||
# shared-library deps of breadcastd itself (not just a build dep of
|
# shared-library deps of breadcastd itself (not just a build dep of
|
||||||
|
|
@ -14,6 +16,7 @@ system_deps = [
|
||||||
"gtk4",
|
"gtk4",
|
||||||
"gtk4-layer-shell",
|
"gtk4-layer-shell",
|
||||||
"gstreamer",
|
"gstreamer",
|
||||||
|
"gst-plugins-base",
|
||||||
"gst-plugin-pipewire",
|
"gst-plugin-pipewire",
|
||||||
"gst-plugins-bad",
|
"gst-plugins-bad",
|
||||||
"gst-plugin-hlssink3",
|
"gst-plugin-hlssink3",
|
||||||
|
|
|
||||||
|
|
@ -209,9 +209,20 @@ CastStreamSender* breadcast_caststream_sender_create(
|
||||||
// than once process-wide only if ShutDown() was called first -- breadcast
|
// than once process-wide only if ShutDown() was called first -- breadcast
|
||||||
// only ever has one active cast-streaming session at a time, so this
|
// only ever has one active cast-streaming session at a time, so this
|
||||||
// assumption (baked into PlatformClientPosix's own singleton design) holds.
|
// 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::PlatformClientPosix::Create(std::chrono::milliseconds(50));
|
||||||
openscreen::TaskRunner& task_runner =
|
openscreen::PlatformClientPosix* instance =
|
||||||
openscreen::PlatformClientPosix::GetInstance()->GetTaskRunner();
|
openscreen::PlatformClientPosix::GetInstance();
|
||||||
|
if (instance == nullptr) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
openscreen::TaskRunner& task_runner = instance->GetTaskRunner();
|
||||||
|
|
||||||
breadcast_caststream::VideoParams params;
|
breadcast_caststream::VideoParams params;
|
||||||
params.width = width;
|
params.width = width;
|
||||||
|
|
@ -255,6 +266,13 @@ CastStreamSender* breadcast_caststream_sender_create(
|
||||||
}
|
}
|
||||||
|
|
||||||
void breadcast_caststream_sender_negotiate(CastStreamSender* sender) {
|
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] {
|
sender->environment->task_runner().PostTask([sender] {
|
||||||
if (sender->shutting_down.load(std::memory_order_acquire) ||
|
if (sender->shutting_down.load(std::memory_order_acquire) ||
|
||||||
!sender->session) {
|
!sender->session) {
|
||||||
|
|
@ -272,6 +290,10 @@ void breadcast_caststream_sender_on_message(CastStreamSender* sender,
|
||||||
size_t message_namespace_len,
|
size_t message_namespace_len,
|
||||||
const char* message,
|
const char* message,
|
||||||
size_t message_len) {
|
size_t message_len) {
|
||||||
|
if (!sender || sender->shutting_down.load(std::memory_order_acquire) ||
|
||||||
|
!sender->environment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
auto source = std::make_shared<std::string>(source_id, source_id_len);
|
auto source = std::make_shared<std::string>(source_id, source_id_len);
|
||||||
auto ns = std::make_shared<std::string>(message_namespace, message_namespace_len);
|
auto ns = std::make_shared<std::string>(message_namespace, message_namespace_len);
|
||||||
auto body = std::make_shared<std::string>(message, message_len);
|
auto body = std::make_shared<std::string>(message, message_len);
|
||||||
|
|
@ -289,6 +311,10 @@ int32_t breadcast_caststream_sender_enqueue_frame(CastStreamSender* sender,
|
||||||
size_t data_len,
|
size_t data_len,
|
||||||
int32_t is_key_frame,
|
int32_t is_key_frame,
|
||||||
int64_t capture_time_us) {
|
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)) {
|
if (!sender->negotiated.load(std::memory_order_acquire)) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
@ -378,6 +404,12 @@ int32_t breadcast_caststream_sender_enqueue_frame(CastStreamSender* sender,
|
||||||
switch (video_sender->EnqueueFrame(frame)) {
|
switch (video_sender->EnqueueFrame(frame)) {
|
||||||
case Sender::OK:
|
case Sender::OK:
|
||||||
sender->enqueue_ok.fetch_add(1, std::memory_order_relaxed);
|
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;
|
break;
|
||||||
case Sender::PAYLOAD_TOO_LARGE:
|
case Sender::PAYLOAD_TOO_LARGE:
|
||||||
sender->enqueue_payload_too_large.fetch_add(1, std::memory_order_relaxed);
|
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) {
|
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) {
|
int32_t breadcast_caststream_sender_estimated_bandwidth_bps(CastStreamSender* sender) {
|
||||||
|
if (!sender) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
return sender->estimated_bandwidth_bps.load(std::memory_order_relaxed);
|
return sender->estimated_bandwidth_bps.load(std::memory_order_relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -437,7 +483,10 @@ void breadcast_caststream_sender_destroy(CastStreamSender* sender) {
|
||||||
|
|
||||||
// These must be torn down on the TaskRunner thread (they hold raw
|
// These must be torn down on the TaskRunner thread (they hold raw
|
||||||
// references into it and into `environment`), so hop over there and block
|
// references into it and into `environment`), so hop over there and block
|
||||||
// until it's done before shutting the TaskRunner itself down.
|
// 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<void> done;
|
std::promise<void> done;
|
||||||
std::future<void> done_future = done.get_future();
|
std::future<void> done_future = done.get_future();
|
||||||
sender->environment->task_runner().PostTask([sender, &done] {
|
sender->environment->task_runner().PostTask([sender, &done] {
|
||||||
|
|
@ -447,8 +496,11 @@ void breadcast_caststream_sender_destroy(CastStreamSender* sender) {
|
||||||
done.set_value();
|
done.set_value();
|
||||||
});
|
});
|
||||||
done_future.wait();
|
done_future.wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openscreen::PlatformClientPosix::GetInstance() != nullptr) {
|
||||||
openscreen::PlatformClientPosix::ShutDown();
|
openscreen::PlatformClientPosix::ShutDown();
|
||||||
|
}
|
||||||
delete sender;
|
delete sender;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,15 +88,18 @@ void breadcast_caststream_sender_on_message(CastStreamSender* sender,
|
||||||
const char* message,
|
const char* message,
|
||||||
size_t message_len);
|
size_t message_len);
|
||||||
|
|
||||||
// Enqueues one encoded video access unit (Annex-B H.264) for sending.
|
// Posts one encoded video access unit (Annex-B H.264) onto openscreen's
|
||||||
// `data` is copied before this returns, so the caller may reuse/free its
|
// TaskRunner for sending. `data` is copied before this returns, so the
|
||||||
// buffer immediately after. `capture_time_us` is only used to derive the
|
// caller may reuse/free its buffer immediately after. `capture_time_us` is
|
||||||
// RTP timestamp's relative spacing between frames (it does not need to be
|
// only used to derive the RTP timestamp's relative spacing between frames
|
||||||
// wall-clock-accurate, just monotonically increasing and proportional to
|
// (it does not need to be wall-clock-accurate, just monotonically
|
||||||
// real elapsed time between frames). Returns 0 if queued, nonzero if the
|
// increasing and proportional to real elapsed time between frames).
|
||||||
// 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
|
// Returns 0 if the frame was *posted* (not necessarily accepted --
|
||||||
// when this happens rather than treating it as fatal).
|
// 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,
|
int32_t breadcast_caststream_sender_enqueue_frame(CastStreamSender* sender,
|
||||||
const uint8_t* data,
|
const uint8_t* data,
|
||||||
size_t data_len,
|
size_t data_len,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
//! Raw FFI bindings to `src/facade.h`/`src/facade.cc`, which wrap a pruned,
|
//! Raw FFI bindings to `src/facade.h`/`src/facade.cc`, which wrap a pruned,
|
||||||
//! vendored subset of `chromium/openscreen`'s Cast Streaming sender (see
|
//! vendored subset of `chromium/openscreen`'s Cast Streaming sender (see
|
||||||
//! `vendor/openscreen/PATCHES.md`). This crate is intentionally low-level and
|
//! `vendor/openscreen/PATCHES.md`). This crate is intentionally low-level and
|
||||||
//! unsafe -- see `breadcast-caststream` (not this crate) for the ergonomic,
|
//! unsafe -- see `breadcast-core::caststream` for the ergonomic, thread-safe
|
||||||
//! thread-safe wrapper most callers should use instead.
|
//! wrapper most callers should use instead.
|
||||||
//!
|
//!
|
||||||
//! # Threading contract
|
//! # Threading contract
|
||||||
//!
|
//!
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@ gstreamer-video = "0.25"
|
||||||
tiny_http = "0.12"
|
tiny_http = "0.12"
|
||||||
rupnp = "3.0.0"
|
rupnp = "3.0.0"
|
||||||
futures-util = "0.3.33"
|
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" }
|
breadcast-caststream-sys = { path = "../breadcast-caststream-sys" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,10 @@ impl CaptureSession {
|
||||||
.open(&token_path)
|
.open(&token_path)
|
||||||
{
|
{
|
||||||
use std::io::Write;
|
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());
|
let _ = file.write_all(token.as_bytes());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -225,16 +225,18 @@ impl CastStreamSender {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enqueues one encoded video access unit (Annex-B H.264) for sending.
|
/// Posts one encoded video access unit (Annex-B H.264) onto the C++
|
||||||
/// `capture_time_us` only needs to be monotonically increasing and
|
/// TaskRunner. `capture_time_us` only needs to be monotonically
|
||||||
/// proportional to real elapsed time between frames -- it does not need
|
/// increasing and proportional to real elapsed time between frames --
|
||||||
/// to be wall-clock-accurate.
|
/// it does not need to be wall-clock-accurate.
|
||||||
///
|
///
|
||||||
/// Returns an error if the session isn't negotiated yet or the frame
|
/// Returns an error only if the session isn't negotiated (or is
|
||||||
/// was rejected under backpressure; callers should treat the latter as
|
/// shutting down). A `Ok(())` means the frame was *posted*, not that
|
||||||
/// a dropped frame, not a fatal condition (see
|
/// `Sender::EnqueueFrame` accepted it -- accept/reject is visible via
|
||||||
/// [`Self::needs_key_frame`]/[`Self::estimated_bandwidth_bps`] for how
|
/// [`Self::enqueue_stats`], and a reject that breaks the H.264
|
||||||
/// to react).
|
/// 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<()> {
|
pub fn enqueue_frame(&self, data: &[u8], is_key_frame: bool, capture_time_us: i64) -> Result<()> {
|
||||||
let result = unsafe {
|
let result = unsafe {
|
||||||
breadcast_caststream_sender_enqueue_frame(
|
breadcast_caststream_sender_enqueue_frame(
|
||||||
|
|
@ -246,7 +248,7 @@ impl CastStreamSender {
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
if result != 0 {
|
if result != 0 {
|
||||||
bail!("frame not enqueued (session not negotiated yet)");
|
bail!("frame not posted (session not negotiated or shutting down)");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
/// 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
|
/// an unordered choice can silently pick a stale/unreachable one, and pick
|
||||||
/// a *different* one across otherwise-identical runs.
|
/// a *different* one across otherwise-identical runs.
|
||||||
fn pick_address(addresses: &[(ScopedIp, Instant)]) -> Option<IpAddr> {
|
fn pick_address(addresses: &[(ScopedIp, Instant)]) -> Option<String> {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let mut candidates: Vec<&(ScopedIp, Instant)> = addresses
|
let mut candidates: Vec<&(ScopedIp, Instant)> = addresses
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -50,7 +50,10 @@ fn pick_address(addresses: &[(ScopedIp, Instant)]) -> Option<IpAddr> {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.or_else(|| candidates.first())
|
.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.";
|
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");
|
warn!(%id, "cast device resolved with no usable addresses, skipping");
|
||||||
continue;
|
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::<std::net::Ipv6Addr>().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());
|
fullname_to_id.insert(info.get_fullname().to_string(), id.clone());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use tokio::sync::mpsc;
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
use super::device::DlnaDevice;
|
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
|
/// How often to re-issue an SSDP search burst. Unlike mDNS (continuous
|
||||||
/// multicast browsing via `mdns-sd` in [`crate::discovery`]), SSDP has no
|
/// multicast browsing via `mdns-sd` in [`crate::discovery`]), SSDP has no
|
||||||
|
|
@ -69,7 +69,7 @@ impl DlnaDiscovery {
|
||||||
let mut known: HashMap<String, (DlnaDevice, u32)> = HashMap::new();
|
let mut known: HashMap<String, (DlnaDevice, u32)> = HashMap::new();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let search_target = SearchTarget::URN(AV_TRANSPORT);
|
let search_target = SearchTarget::URN(MEDIA_RENDERER);
|
||||||
match rupnp::discover(&search_target, SEARCH_TIMEOUT, None).await {
|
match rupnp::discover(&search_target, SEARCH_TIMEOUT, None).await {
|
||||||
Ok(stream) => {
|
Ok(stream) => {
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
|
|
@ -86,6 +86,10 @@ impl DlnaDiscovery {
|
||||||
};
|
};
|
||||||
|
|
||||||
let url = device.url().to_string();
|
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());
|
confirmed.insert(url.clone());
|
||||||
|
|
||||||
if let Some(entry) = known.get_mut(&url) {
|
if let Some(entry) = known.get_mut(&url) {
|
||||||
|
|
|
||||||
|
|
@ -21,3 +21,7 @@ pub use discovery::{DlnaDiscovery, DlnaDiscoveryEvent};
|
||||||
pub use session::DlnaSession;
|
pub use session::DlnaSession;
|
||||||
|
|
||||||
const AV_TRANSPORT: URN = URN::service("schemas-upnp-org", "AVTransport", 1);
|
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);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use rupnp::Service;
|
use rupnp::Service;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use super::device::DlnaDevice;
|
use super::device::DlnaDevice;
|
||||||
use super::AV_TRANSPORT;
|
use super::AV_TRANSPORT;
|
||||||
|
|
@ -56,18 +57,50 @@ impl DlnaSession {
|
||||||
/// renderer would reject with no useful diagnostic.
|
/// renderer would reject with no useful diagnostic.
|
||||||
pub async fn load(&self, content_url: &str) -> Result<()> {
|
pub async fn load(&self, content_url: &str) -> Result<()> {
|
||||||
let escaped = xml_escape(content_url);
|
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#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/"><item id="0" parentID="-1" restricted="1"><dc:title>breadcast</dc:title><upnp:class>object.item.videoItem</upnp:class><res protocolInfo="http-get:*:application/vnd.apple.mpegurl:*">{escaped}</res></item></DIDL-Lite>"#
|
||||||
|
);
|
||||||
|
let metadata = xml_escape(&didl);
|
||||||
let set_uri_payload = format!(
|
let set_uri_payload = format!(
|
||||||
"<InstanceID>0</InstanceID><CurrentURI>{escaped}</CurrentURI><CurrentURIMetaData></CurrentURIMetaData>"
|
"<InstanceID>0</InstanceID><CurrentURI>{escaped}</CurrentURI><CurrentURIMetaData>{metadata}</CurrentURIMetaData>"
|
||||||
);
|
);
|
||||||
self.service
|
self.service
|
||||||
.action(&self.device_url, "SetAVTransportURI", &set_uri_payload)
|
.action(&self.device_url, "SetAVTransportURI", &set_uri_payload)
|
||||||
.await
|
.await
|
||||||
.context("SetAVTransportURI failed")?;
|
.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", "<InstanceID>0</InstanceID><Speed>1</Speed>")
|
.action(&self.device_url, "Play", "<InstanceID>0</InstanceID><Speed>1</Speed>")
|
||||||
.await
|
.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(())
|
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());
|
let mut escaped = String::with_capacity(input.len());
|
||||||
for c in input.chars() {
|
for c in input.chars() {
|
||||||
match c {
|
match c {
|
||||||
|
|
@ -120,3 +153,14 @@ fn xml_escape(input: &str) -> String {
|
||||||
}
|
}
|
||||||
escaped
|
escaped
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::xml_escape;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn xml_escape_covers_the_five_markup_chars() {
|
||||||
|
assert_eq!(xml_escape(r#"a&b<c>d"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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,9 @@ const WORKER_THREADS: usize = 8;
|
||||||
|
|
||||||
/// Serves `root` (the `hlssink3` output directory: `playlist.m3u8` +
|
/// Serves `root` (the `hlssink3` output directory: `playlist.m3u8` +
|
||||||
/// `segment*.ts`) over plain HTTP. Runs a small fixed pool of worker
|
/// `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
|
/// threads. [`HttpServer::shutdown`] (also invoked from `Drop`) unblocks
|
||||||
/// shutdown yet — matches the smoke-testing scope of the rest of Phase 2).
|
/// 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
|
/// Every servable path is namespaced under a random token
|
||||||
/// (`/<token>/playlist.m3u8`, etc. — see [`HttpServer::token`]) rather than
|
/// (`/<token>/playlist.m3u8`, etc. — see [`HttpServer::token`]) rather than
|
||||||
|
|
@ -37,6 +38,7 @@ const WORKER_THREADS: usize = 8;
|
||||||
pub struct HttpServer {
|
pub struct HttpServer {
|
||||||
addr: SocketAddr,
|
addr: SocketAddr,
|
||||||
token: String,
|
token: String,
|
||||||
|
server: Option<Arc<tiny_http::Server>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HttpServer {
|
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
|
/// 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
|
/// doc for why that can't just be `self.addr()`), including the
|
||||||
/// unguessable path token every request must carry.
|
/// unguessable path token every request must carry.
|
||||||
pub fn url(&self, host: std::net::IpAddr, relative: &str) -> String {
|
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();
|
.is_ok();
|
||||||
if !read_ok {
|
if !read_ok {
|
||||||
// Unreachable in practice on Linux, but better than a zero-entropy
|
// Unreachable in practice on Linux, but better than a zero-entropy
|
||||||
// token if it ever happened.
|
// 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};
|
use std::hash::{Hash, Hasher};
|
||||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
std::time::SystemTime::now().hash(&mut hasher);
|
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);
|
std::process::id().hash(&mut hasher);
|
||||||
bytes[..8].copy_from_slice(&hasher.finish().to_le_bytes());
|
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()
|
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
|
/// 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
|
/// falling back to a full 200 response for those is always a valid
|
||||||
/// response under the HTTP spec).
|
/// 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=")?;
|
let spec = value.strip_prefix("bytes=")?;
|
||||||
if spec.contains(',') || len == 0 {
|
if spec.contains(',') || len == 0 {
|
||||||
return None;
|
return None;
|
||||||
|
|
@ -233,7 +275,11 @@ fn handle_request(request: tiny_http::Request, root: &Path, token: &str) -> Resu
|
||||||
];
|
];
|
||||||
|
|
||||||
let (status, body) = match range {
|
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())));
|
headers.push(("Content-Range".to_string(), format!("bytes {start}-{end}/{}", data.len())));
|
||||||
(206u16, data[start..=end].to_vec())
|
(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))
|
.respond(tiny_http::Response::empty(status))
|
||||||
.context("failed to write HTTP error response")
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ use std::net::{IpAddr, Ipv4Addr};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
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
|
/// Finds this machine's LAN-reachable IPv4 address by enumerating network
|
||||||
/// interfaces directly, rather than the more common "UDP-connect to a
|
/// interfaces directly, rather than the more common "UDP-connect to a
|
||||||
/// public address and read back the local endpoint" trick — that trick
|
/// 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
|
/// 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.
|
/// embed this machine's own address in a URL handed to a receiver device.
|
||||||
pub fn local_lan_ip() -> Result<IpAddr> {
|
pub fn local_lan_ip() -> Result<IpAddr> {
|
||||||
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<IpAddr> {
|
||||||
|
pick_lan_ip(Some(peer))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick_lan_ip(peer: Option<IpAddr>) -> Result<IpAddr> {
|
||||||
|
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<Vec<(String, Ipv4Addr, u8)>> {
|
||||||
|
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<Vec<(String, Ipv4Addr, u8)>> {
|
||||||
let output = std::process::Command::new("ip")
|
let output = std::process::Command::new("ip")
|
||||||
.args(["-4", "-o", "addr", "show", "scope", "global", "up"])
|
.args(["-4", "-o", "addr", "show", "scope", "global", "up"])
|
||||||
.output()
|
.output()
|
||||||
|
|
@ -29,7 +84,7 @@ pub fn local_lan_ip() -> Result<IpAddr> {
|
||||||
}
|
}
|
||||||
let text = String::from_utf8_lossy(&output.stdout);
|
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() {
|
for line in text.lines() {
|
||||||
// Format: "3: wlan0 inet 10.179.161.89/23 brd ... scope global dynamic wlan0"
|
// Format: "3: wlan0 inet 10.179.161.89/23 brd ... scope global dynamic wlan0"
|
||||||
let mut fields = line.split_whitespace();
|
let mut fields = line.split_whitespace();
|
||||||
|
|
@ -42,20 +97,65 @@ pub fn local_lan_ip() -> Result<IpAddr> {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(cidr) = fields.next() else { continue };
|
let Some(cidr) = fields.next() else { continue };
|
||||||
let Some(addr) = cidr.split('/').next().and_then(|a| a.parse::<Ipv4Addr>().ok()) else { continue };
|
let mut parts = cidr.split('/');
|
||||||
|
let Some(addr) = parts.next().and_then(|a| a.parse::<Ipv4Addr>().ok()) else { continue };
|
||||||
|
let prefix: u8 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(32);
|
||||||
if !addr.is_private() {
|
if !addr.is_private() {
|
||||||
continue;
|
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<Vec<(String, Ipv4Addr, u8)>> {
|
||||||
|
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?")
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -451,6 +451,7 @@ pub fn pull_encoded_frame(appsink: &gst_app::AppSink) -> Result<Option<(Vec<u8>,
|
||||||
let poll = gst::ClockTime::from_mseconds(CAPTURE_STALL_POLL.as_millis() as u64);
|
let poll = gst::ClockTime::from_mseconds(CAPTURE_STALL_POLL.as_millis() as u64);
|
||||||
let mut stalled_for = Duration::ZERO;
|
let mut stalled_for = Duration::ZERO;
|
||||||
loop {
|
loop {
|
||||||
|
let pulled_at = std::time::Instant::now();
|
||||||
let Some(sample) = appsink.try_pull_sample(Some(poll)) else {
|
let Some(sample) = appsink.try_pull_sample(Some(poll)) else {
|
||||||
// EOS is the ordinary end: the user hit "Stop sharing" in the
|
// EOS is the ordinary end: the user hit "Stop sharing" in the
|
||||||
// portal, or the source went away.
|
// portal, or the source went away.
|
||||||
|
|
@ -458,16 +459,18 @@ pub fn pull_encoded_frame(appsink: &gst_app::AppSink) -> Result<Option<(Vec<u8>,
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
// Teardown from another thread (`CastMirrorSession::stop` sets
|
// Teardown from another thread (`CastMirrorSession::stop` sets
|
||||||
// the pipeline to Null) makes the sink flush, and a flushing
|
// the pipeline to Null) flushes the sink *before*
|
||||||
// sink returns `None` *immediately* rather than after the
|
// `current_state()` leaves Playing. A flushing sink returns
|
||||||
// timeout. Treat that as a clean end too -- otherwise this would
|
// `None` immediately; counting that as stall time used to
|
||||||
// busy-spin for the whole stall budget and then report a
|
// raise a spurious "capture stalled" on every normal stop.
|
||||||
// spurious "capture stalled" on every normal stop.
|
if !matches!(appsink.current_state(), gst::State::Playing | gst::State::Paused)
|
||||||
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);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
stalled_for += CAPTURE_STALL_POLL;
|
stalled_for += pulled_at.elapsed();
|
||||||
if stalled_for < CAPTURE_STALL_TIMEOUT {
|
if stalled_for < CAPTURE_STALL_TIMEOUT {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -529,11 +532,56 @@ pub enum RunOutcome {
|
||||||
Timeout,
|
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<RunOutcome> {
|
||||||
|
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
|
/// Blocks the calling thread until the pipeline reports an error or EOS, or
|
||||||
/// `timeout` elapses (whichever first). Returns which of those happened, or
|
/// `timeout` elapses (whichever first). Returns which of those happened, or
|
||||||
/// `Err` on a real pipeline error. Meant for smoke-testing from a
|
/// `Err` on a real pipeline error. Meant for smoke-testing from a
|
||||||
/// synchronous `main`/example; the real daemon will want an async/watch-based
|
/// synchronous `main`/example; the daemon uses [`run_until_eos_or_error`].
|
||||||
/// version instead of blocking a thread.
|
|
||||||
pub fn run_until_error_or_timeout(pipeline: &gst::Pipeline, timeout: gst::ClockTime) -> Result<RunOutcome> {
|
pub fn run_until_error_or_timeout(pipeline: &gst::Pipeline, timeout: gst::ClockTime) -> Result<RunOutcome> {
|
||||||
let bus = pipeline.bus().context("pipeline has no bus")?;
|
let bus = pipeline.bus().context("pipeline has no bus")?;
|
||||||
let deadline = std::time::Instant::now() + std::time::Duration::from(timeout);
|
let deadline = std::time::Instant::now() + std::time::Duration::from(timeout);
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ path = "src/main.rs"
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
breadcast-core = { path = "../breadcast-core" }
|
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"] }
|
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["gtk"] }
|
||||||
gtk4 = { version = "0.11", features = ["v4_12"] }
|
gtk4 = { version = "0.11", features = ["v4_12"] }
|
||||||
gtk4-layer-shell = "0.8"
|
gtk4-layer-shell = "0.8"
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,11 @@ pub fn build_css(palette: &Palette) -> String {
|
||||||
color: {overlay};
|
color: {overlay};
|
||||||
padding: 24px 8px;
|
padding: 24px 8px;
|
||||||
}}
|
}}
|
||||||
|
.cast-error-label {{
|
||||||
|
color: #e35b5b;
|
||||||
|
font-size: 0.85em;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}}
|
||||||
.cast-stop-button {{
|
.cast-stop-button {{
|
||||||
background-color: alpha(#e35b5b, 0.15);
|
background-color: alpha(#e35b5b, 0.15);
|
||||||
color: #e35b5b;
|
color: #e35b5b;
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ impl IpcClient {
|
||||||
let path = socket_path()?;
|
let path = socket_path()?;
|
||||||
let write_half = UnixStream::connect(&path)
|
let write_half = UnixStream::connect(&path)
|
||||||
.with_context(|| format!("failed to connect to breadcastd at {} — is it running?", path.display()))?;
|
.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 read_half = write_half.try_clone().context("failed to duplicate the IPC socket handle")?;
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@ use std::rc::Rc;
|
||||||
use bread_theme::load_palette;
|
use bread_theme::load_palette;
|
||||||
use breadcast_core::ipc::{DeviceInfo, Protocol, ServerMessage, StateInfo};
|
use breadcast_core::ipc::{DeviceInfo, Protocol, ServerMessage, StateInfo};
|
||||||
use gtk4::prelude::*;
|
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;
|
use ipc_client::IpcClient;
|
||||||
|
|
||||||
const PANEL_WIDTH: i32 = 360;
|
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()));
|
bread_theme::gtk::apply_app_css(|| css::build_css(&load_palette()));
|
||||||
|
|
||||||
let window = bread_utils::gtk_popup::new_overlay_window(app, "breadcast");
|
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);
|
let panel = GBox::new(Orientation::Vertical, 8);
|
||||||
panel.add_css_class("cast-panel");
|
panel.add_css_class("cast-panel");
|
||||||
|
|
@ -69,12 +72,50 @@ fn build_ui(app: &Application) {
|
||||||
empty_label.add_css_class("cast-empty-label");
|
empty_label.add_css_class("cast-empty-label");
|
||||||
panel.append(&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));
|
window.set_child(Some(&panel));
|
||||||
bread_utils::gtk_popup::close_on_outside_click(&window, &panel, {
|
bread_utils::gtk_popup::close_on_outside_click(&window, &panel, {
|
||||||
let window = window.clone();
|
let window = window.clone();
|
||||||
move || window.close()
|
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() {
|
match IpcClient::connect() {
|
||||||
Ok((client, rx)) => {
|
Ok((client, rx)) => {
|
||||||
let client = Rc::new(RefCell::new(client));
|
let client = Rc::new(RefCell::new(client));
|
||||||
|
|
@ -83,10 +124,16 @@ fn build_ui(app: &Application) {
|
||||||
|
|
||||||
list.connect_row_activated({
|
list.connect_row_activated({
|
||||||
let client = client.clone();
|
let client = client.clone();
|
||||||
|
let window = window.clone();
|
||||||
move |_, row| {
|
move |_, row| {
|
||||||
let Some(device_id) = (unsafe { row.data::<String>("device_id") }) else { return };
|
let Some(device_id) = (unsafe { row.data::<String>("device_id") }) else { return };
|
||||||
let device_id = unsafe { device_id.as_ref() }.clone();
|
let device_id = unsafe { device_id.as_ref() }.clone();
|
||||||
let _ = client.borrow_mut().send("start_cast", serde_json::json!({ "device_id": device_id }));
|
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 list = list.clone();
|
||||||
let empty_label = empty_label.clone();
|
let empty_label = empty_label.clone();
|
||||||
|
let error_label = error_label.clone();
|
||||||
let status_pill = status_pill.clone();
|
let status_pill = status_pill.clone();
|
||||||
let stop_button = stop_button.clone();
|
let stop_button = stop_button.clone();
|
||||||
glib::timeout_add_local(std::time::Duration::from_millis(100), move || {
|
glib::timeout_add_local(std::time::Duration::from_millis(100), move || {
|
||||||
while let Ok(message) = rx.try_recv() {
|
loop {
|
||||||
handle_server_message(message, &list, &empty_label, &status_pill, &stop_button);
|
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
|
glib::ControlFlow::Continue
|
||||||
});
|
});
|
||||||
|
|
@ -124,6 +186,7 @@ fn handle_server_message(
|
||||||
message: ServerMessage,
|
message: ServerMessage,
|
||||||
list: &ListBox,
|
list: &ListBox,
|
||||||
empty_label: &Label,
|
empty_label: &Label,
|
||||||
|
error_label: &Label,
|
||||||
status_pill: &Label,
|
status_pill: &Label,
|
||||||
stop_button: &Button,
|
stop_button: &Button,
|
||||||
) {
|
) {
|
||||||
|
|
@ -137,6 +200,8 @@ fn handle_server_message(
|
||||||
}
|
}
|
||||||
ServerMessage::Response { error: Some(error), .. } => {
|
ServerMessage::Response { error: Some(error), .. } => {
|
||||||
eprintln!("breadcast: request failed: {error}");
|
eprintln!("breadcast: request failed: {error}");
|
||||||
|
error_label.set_label(&error);
|
||||||
|
error_label.set_visible(true);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
|
|
@ -144,7 +209,10 @@ fn handle_server_message(
|
||||||
let Some((event, data)) = payload else { return };
|
let Some((event, data)) = payload else { return };
|
||||||
match event.as_str() {
|
match event.as_str() {
|
||||||
"device_list_changed" => update_device_list(list, empty_label, data),
|
"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);
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,11 @@ impl CastMirrorSession {
|
||||||
/// receiver dropping the connection) back to the daemon actor, so it
|
/// receiver dropping the connection) back to the daemon actor, so it
|
||||||
/// can transition back to `Idle` and notify GUI clients even if nobody
|
/// can transition back to `Idle` and notify GUI clients even if nobody
|
||||||
/// called `stop()`.
|
/// called `stop()`.
|
||||||
pub async fn start(device: CastDevice, daemon_tx: tokio::sync::mpsc::Sender<DaemonCommand>) -> Result<Self> {
|
pub async fn start(
|
||||||
|
device: CastDevice,
|
||||||
|
daemon_tx: tokio::sync::mpsc::Sender<DaemonCommand>,
|
||||||
|
generation: u64,
|
||||||
|
) -> Result<Self> {
|
||||||
let capture = CaptureSession::start().await.context("failed to start portal screen capture")?;
|
let capture = CaptureSession::start().await.context("failed to start portal screen capture")?;
|
||||||
let video_node_id = capture.video_node_id();
|
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
|
// OFFER below rather than re-derived there, so the advertised stream
|
||||||
// and the encoded stream cannot drift apart.
|
// and the encoded stream cannot drift apart.
|
||||||
let (pipeline, appsink, encoder, video_params) =
|
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();
|
let pipeline_watch = pipeline.clone();
|
||||||
std::thread::spawn(move || {
|
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"),
|
Ok(outcome) => tracing::debug!(?outcome, "encode pipeline bus watcher ended"),
|
||||||
Err(e) => tracing::error!(error = ?e, "encode pipeline error"),
|
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
|
// (milliseconds on a LAN) but still blocking I/O -- run it off the
|
||||||
// async worker thread pool rather than stalling it, even briefly.
|
// async worker thread pool rather than stalling it, even briefly.
|
||||||
let device_for_connect = device.clone();
|
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(
|
CastSession::connect_app(
|
||||||
&device_for_connect,
|
&device_for_connect,
|
||||||
CastDeviceApp::Custom(breadcast_core::caststream::MIRRORING_APP_ID.to_string()),
|
CastDeviceApp::Custom(breadcast_core::caststream::MIRRORING_APP_ID.to_string()),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.await
|
.await;
|
||||||
.context("connect_app task panicked")?
|
let (session, _media_events, raw_messages) = match connect {
|
||||||
.context("failed to connect and launch the Mirroring receiver")?;
|
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) =
|
let (sender, stream_events) =
|
||||||
CastStreamSender::start(&device.host, "sender-0", session.transport_id(), video_params)
|
match CastStreamSender::start(&device.host, "sender-0", session.transport_id(), video_params) {
|
||||||
.context("failed to start the Cast Streaming session")?;
|
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 sender = Arc::new(sender);
|
||||||
|
|
||||||
let message_pump = {
|
let message_pump = {
|
||||||
|
|
@ -138,9 +165,12 @@ impl CastMirrorSession {
|
||||||
};
|
};
|
||||||
|
|
||||||
let negotiated = Arc::new(AtomicBool::new(false));
|
let negotiated = Arc::new(AtomicBool::new(false));
|
||||||
|
let failed = Arc::new(AtomicBool::new(false));
|
||||||
let event_pump = {
|
let event_pump = {
|
||||||
let session = session.clone();
|
let session = session.clone();
|
||||||
let negotiated = negotiated.clone();
|
let negotiated = negotiated.clone();
|
||||||
|
let failed = failed.clone();
|
||||||
|
let daemon_tx = daemon_tx.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
while let Ok(event) = stream_events.recv() {
|
while let Ok(event) = stream_events.recv() {
|
||||||
match event {
|
match event {
|
||||||
|
|
@ -150,37 +180,68 @@ impl CastMirrorSession {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CastStreamEvent::Negotiated => negotiated.store(true, Ordering::Release),
|
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"),
|
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");
|
tracing::info!(device = %device.name, "sending Cast Streaming OFFER");
|
||||||
|
if let Some(sender) = started.sender.as_ref() {
|
||||||
sender.negotiate();
|
sender.negotiate();
|
||||||
|
}
|
||||||
|
|
||||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(10);
|
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;
|
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) {
|
if !negotiated.load(Ordering::Acquire) {
|
||||||
// Bounded for the same reason `Self::stop`'s calls are -- an
|
started.stop().await;
|
||||||
// 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;
|
|
||||||
anyhow::bail!("never received an ANSWER from {} (negotiation timed out)", device.name);
|
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");
|
tracing::info!(device = %device.name, "mirroring started");
|
||||||
|
|
||||||
let frame_pump = {
|
let frame_pump = {
|
||||||
let device_name = device.name.clone();
|
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 || {
|
std::thread::spawn(move || {
|
||||||
let result = frame_pump_loop(&appsink, &encoder, &sender);
|
let result = frame_pump_loop(&appsink, &encoder, &sender);
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
|
|
@ -190,19 +251,12 @@ impl CastMirrorSession {
|
||||||
// was, very recently) still alive. If the channel is full or
|
// was, very recently) still alive. If the channel is full or
|
||||||
// closed, there's nothing more useful to do from this
|
// closed, there's nothing more useful to do from this
|
||||||
// thread than drop the notification.
|
// 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 {
|
Ok(started)
|
||||||
pipeline,
|
|
||||||
session,
|
|
||||||
capture: Some(capture),
|
|
||||||
sender: Some(sender),
|
|
||||||
message_pump: Some(message_pump),
|
|
||||||
event_pump: Some(event_pump),
|
|
||||||
frame_pump: Some(frame_pump),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tears down the session. Order matters and is not interchangeable:
|
/// Tears down the session. Order matters and is not interchangeable:
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,29 @@ pub enum DaemonCommand {
|
||||||
/// dropping the connection or stopping playback) -- as opposed to
|
/// dropping the connection or stopping playback) -- as opposed to
|
||||||
/// `StopCast` being called. Either way the daemon needs to forget the
|
/// `StopCast` being called. Either way the daemon needs to forget the
|
||||||
/// (now-dead) session and go back to `Idle`.
|
/// (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<StartedSession, StartFailed>,
|
||||||
|
reply: oneshot::Sender<Result<(), String>>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<ServerMessage>, bread_client: BreadClient) -> mpsc::Sender<DaemonCommand> {
|
pub fn spawn(events_tx: broadcast::Sender<ServerMessage>, bread_client: BreadClient) -> mpsc::Sender<DaemonCommand> {
|
||||||
|
|
@ -45,6 +67,8 @@ pub fn spawn(events_tx: broadcast::Sender<ServerMessage>, bread_client: BreadCli
|
||||||
dlna_devices: HashMap::new(),
|
dlna_devices: HashMap::new(),
|
||||||
state: StateInfo::Idle,
|
state: StateInfo::Idle,
|
||||||
active_session: None,
|
active_session: None,
|
||||||
|
starting: false,
|
||||||
|
start_generation: 0,
|
||||||
events_tx,
|
events_tx,
|
||||||
bread_client,
|
bread_client,
|
||||||
self_tx,
|
self_tx,
|
||||||
|
|
@ -59,7 +83,7 @@ pub fn spawn(events_tx: broadcast::Sender<ServerMessage>, bread_client: BreadCli
|
||||||
/// The currently active mirroring session, if any -- exactly one of the two
|
/// The currently active mirroring session, if any -- exactly one of the two
|
||||||
/// protocol-specific session types, chosen by which device map `StartCast`
|
/// protocol-specific session types, chosen by which device map `StartCast`
|
||||||
/// found the requested device id in.
|
/// found the requested device id in.
|
||||||
enum ActiveSession {
|
pub(crate) enum ActiveSession {
|
||||||
Cast(CastMirrorSession),
|
Cast(CastMirrorSession),
|
||||||
Dlna(Box<DlnaMirrorSession>),
|
Dlna(Box<DlnaMirrorSession>),
|
||||||
}
|
}
|
||||||
|
|
@ -73,9 +97,16 @@ impl ActiveSession {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `host` strings come straight from mDNS resolution, so this just checks
|
/// `host` strings come straight from mDNS resolution, and mdns-sd renders a
|
||||||
/// the address, not whether a zone id is attached (mDNS never gives us one).
|
/// 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 {
|
fn is_link_local_v6(host: &str) -> bool {
|
||||||
|
let host = host.split('%').next().unwrap_or(host);
|
||||||
matches!(host.parse::<std::net::IpAddr>(), Ok(std::net::IpAddr::V6(v6)) if (v6.segments()[0] & 0xffc0) == 0xfe80)
|
matches!(host.parse::<std::net::IpAddr>(), Ok(std::net::IpAddr::V6(v6)) if (v6.segments()[0] & 0xffc0) == 0xfe80)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,6 +117,13 @@ struct Daemon {
|
||||||
dlna_devices: HashMap<String, DlnaDevice>,
|
dlna_devices: HashMap<String, DlnaDevice>,
|
||||||
state: StateInfo,
|
state: StateInfo,
|
||||||
active_session: Option<ActiveSession>,
|
active_session: Option<ActiveSession>,
|
||||||
|
/// 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<ServerMessage>,
|
events_tx: broadcast::Sender<ServerMessage>,
|
||||||
/// Used to publish `bread.cast.mirroring_started`/`.stopped`/`.failed`
|
/// Used to publish `bread.cast.mirroring_started`/`.stopped`/`.failed`
|
||||||
/// on state transitions -- see `bread_events.rs`. A no-op if breadd
|
/// 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;
|
self.start_cast(device_id, reply).await;
|
||||||
}
|
}
|
||||||
DaemonCommand::StopCast { reply } => {
|
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() {
|
if let Some(session) = self.active_session.take() {
|
||||||
session.stop().await;
|
session.stop().await;
|
||||||
bread_events::emit_mirroring_stopped(&self.bread_client);
|
bread_events::emit_mirroring_stopped(&self.bread_client);
|
||||||
|
|
@ -119,19 +163,48 @@ impl Daemon {
|
||||||
self.broadcast_state();
|
self.broadcast_state();
|
||||||
let _ = reply.send(Ok(()));
|
let _ = reply.send(Ok(()));
|
||||||
}
|
}
|
||||||
DaemonCommand::SessionEnded => {
|
DaemonCommand::SessionEnded { generation } => {
|
||||||
// The session already tore itself down (that's what
|
// Ignore a late notification from a session that already
|
||||||
// triggered this) -- just drop our handle to it and update
|
// stopped (or whose start was cancelled). An abandoned
|
||||||
// state. Ignored if this arrives after an explicit
|
// pump after a 5s join timeout used to take() the *next*
|
||||||
// `StopCast` already cleared `active_session` (the
|
// session and flip the UI to Idle while it was still live.
|
||||||
// background pump/poll it came from may briefly outlive
|
if generation != self.start_generation {
|
||||||
// that call).
|
return;
|
||||||
if self.active_session.take().is_some() {
|
}
|
||||||
|
// 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");
|
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.state = StateInfo::Idle;
|
||||||
self.broadcast_state();
|
self.broadcast_state();
|
||||||
bread_events::emit_mirroring_stopped(&self.bread_client);
|
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) => {
|
DaemonCommand::CastDeviceFound(device) => {
|
||||||
// mDNS resolves one physical device on every local address
|
// mDNS resolves one physical device on every local address
|
||||||
|
|
@ -147,9 +220,9 @@ impl Daemon {
|
||||||
);
|
);
|
||||||
if should_replace {
|
if should_replace {
|
||||||
self.cast_devices.insert(device.id.clone(), device);
|
self.cast_devices.insert(device.id.clone(), device);
|
||||||
}
|
|
||||||
self.broadcast_devices();
|
self.broadcast_devices();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
DaemonCommand::CastDeviceLost(id) => {
|
DaemonCommand::CastDeviceLost(id) => {
|
||||||
self.cast_devices.remove(&id);
|
self.cast_devices.remove(&id);
|
||||||
self.broadcast_devices();
|
self.broadcast_devices();
|
||||||
|
|
@ -166,54 +239,100 @@ impl Daemon {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn start_cast(&mut self, device_id: String, reply: oneshot::Sender<Result<(), String>>) {
|
async fn start_cast(&mut self, device_id: String, reply: oneshot::Sender<Result<(), String>>) {
|
||||||
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()));
|
let _ = reply.send(Err("already casting -- stop the current session first".to_string()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(device) = self.cast_devices.get(&device_id).cloned() {
|
if let Some(device) = self.cast_devices.get(&device_id).cloned() {
|
||||||
match CastMirrorSession::start(device.clone(), self.self_tx.clone()).await {
|
self.starting = true;
|
||||||
Ok(session) => {
|
let generation = self.start_generation;
|
||||||
self.active_session = Some(ActiveSession::Cast(session));
|
let daemon_tx = self.self_tx.clone();
|
||||||
self.state =
|
tokio::spawn(async move {
|
||||||
StateInfo::Casting { device_id: device.id.clone(), device_name: device.name.clone(), protocol: Protocol::Cast };
|
let outcome = match CastMirrorSession::start(device.clone(), daemon_tx.clone(), generation).await {
|
||||||
self.broadcast_state();
|
Ok(session) => Ok(StartedSession {
|
||||||
bread_events::emit_mirroring_started(&self.bread_client, &device.id, &device.name, "cast");
|
session: ActiveSession::Cast(session),
|
||||||
let _ = reply.send(Ok(()));
|
device_id: device.id,
|
||||||
}
|
device_name: device.name,
|
||||||
Err(e) => {
|
protocol: Protocol::Cast,
|
||||||
|
}),
|
||||||
// `{e:#}` (not `{e}`/`to_string()`) so the full anyhow
|
// `{e:#}` (not `{e}`/`to_string()`) so the full anyhow
|
||||||
// context chain reaches the caller/GUI instead of just
|
// context chain reaches the caller/GUI instead of just
|
||||||
// the outermost ".context()" message.
|
// the outermost ".context()" message.
|
||||||
bread_events::emit_mirroring_failed(&self.bread_client, &device.id, &format!("{e:#}"));
|
Err(e) => Err(StartFailed { device_id: device.id, error: format!("{e:#}") }),
|
||||||
let _ = reply.send(Err(format!("{e:#}")));
|
};
|
||||||
}
|
let _ = daemon_tx.send(DaemonCommand::StartFinished { generation, outcome, reply }).await;
|
||||||
}
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(device) = self.dlna_devices.get(&device_id).cloned() {
|
if let Some(device) = self.dlna_devices.get(&device_id).cloned() {
|
||||||
match DlnaMirrorSession::start(device.clone(), self.self_tx.clone()).await {
|
self.starting = true;
|
||||||
Ok(session) => {
|
let generation = self.start_generation;
|
||||||
self.active_session = Some(ActiveSession::Dlna(Box::new(session)));
|
let daemon_tx = self.self_tx.clone();
|
||||||
self.state = StateInfo::Casting {
|
tokio::spawn(async move {
|
||||||
device_id: device.url.clone(),
|
let outcome = match DlnaMirrorSession::start(device.clone(), daemon_tx.clone(), generation).await {
|
||||||
device_name: device.friendly_name.clone(),
|
Ok(session) => Ok(StartedSession {
|
||||||
|
session: ActiveSession::Dlna(Box::new(session)),
|
||||||
|
device_id: device.url,
|
||||||
|
device_name: device.friendly_name,
|
||||||
protocol: Protocol::Dlna,
|
protocol: Protocol::Dlna,
|
||||||
|
}),
|
||||||
|
Err(e) => Err(StartFailed { device_id: device.url, error: format!("{e:#}") }),
|
||||||
};
|
};
|
||||||
self.broadcast_state();
|
let _ = daemon_tx.send(DaemonCommand::StartFinished { generation, outcome, reply }).await;
|
||||||
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:#}")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
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<StartedSession, StartFailed>,
|
||||||
|
reply: oneshot::Sender<Result<(), String>>,
|
||||||
|
) {
|
||||||
|
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<DeviceInfo> {
|
fn device_list(&self) -> Vec<DeviceInfo> {
|
||||||
|
|
@ -241,3 +360,159 @@ impl Daemon {
|
||||||
let _ = self.events_tx.send(ServerMessage::Event { event: "device_list_changed".to_string(), data });
|
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<ServerMessage>) {
|
||||||
|
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<Result<(), String>>) -> 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::…%<iface>`, 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,11 @@
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use breadcast_core::http_server::HttpServer;
|
||||||
use breadcast_core::net::local_lan_ip;
|
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 breadcast_core::{CaptureSession, DlnaDevice, DlnaSession};
|
||||||
use gstreamer as gst;
|
use gstreamer as gst;
|
||||||
use gstreamer::prelude::*;
|
use gstreamer::prelude::*;
|
||||||
|
|
@ -27,11 +30,20 @@ use crate::daemon::DaemonCommand;
|
||||||
/// this is a poll, not a push.
|
/// this is a poll, not a push.
|
||||||
const POLL_INTERVAL: Duration = Duration::from_secs(3);
|
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 {
|
pub struct DlnaMirrorSession {
|
||||||
pipeline: gst::Pipeline,
|
pipeline: gst::Pipeline,
|
||||||
session: DlnaSession,
|
session: DlnaSession,
|
||||||
capture: Option<CaptureSession>,
|
capture: Option<CaptureSession>,
|
||||||
|
http: HttpServer,
|
||||||
|
output_dir: std::path::PathBuf,
|
||||||
poll_task: tokio::task::JoinHandle<()>,
|
poll_task: tokio::task::JoinHandle<()>,
|
||||||
|
stall_task: tokio::task::JoinHandle<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DlnaMirrorSession {
|
impl DlnaMirrorSession {
|
||||||
|
|
@ -43,42 +55,57 @@ impl DlnaMirrorSession {
|
||||||
/// renderer stopping playback on its own, a GStreamer error, or the
|
/// renderer stopping playback on its own, a GStreamer error, or the
|
||||||
/// renderer becoming unreachable) back to the daemon actor — mirrors
|
/// renderer becoming unreachable) back to the daemon actor — mirrors
|
||||||
/// `CastMirrorSession::start`'s same use of it.
|
/// `CastMirrorSession::start`'s same use of it.
|
||||||
pub async fn start(device: DlnaDevice, daemon_tx: tokio::sync::mpsc::Sender<DaemonCommand>) -> Result<Self> {
|
pub async fn start(
|
||||||
|
device: DlnaDevice,
|
||||||
|
daemon_tx: tokio::sync::mpsc::Sender<DaemonCommand>,
|
||||||
|
generation: u64,
|
||||||
|
) -> Result<Self> {
|
||||||
let capture = CaptureSession::start().await.context("failed to start portal screen capture")?;
|
let capture = CaptureSession::start().await.context("failed to start portal screen capture")?;
|
||||||
let video_node_id = capture.video_node_id();
|
let video_node_id = capture.video_node_id();
|
||||||
|
|
||||||
let output_dir = hls_output_dir("dlna-mirror")?;
|
let output_dir = match hls_output_dir("dlna-mirror") {
|
||||||
let pipeline =
|
Ok(dir) => dir,
|
||||||
build_video_pipeline(video_node_id, &output_dir).context("failed to build the encode pipeline")?;
|
Err(e) => {
|
||||||
|
let _ = capture.close().await;
|
||||||
// Fire-and-forget, same as `CastMirrorSession::start`'s identical
|
return Err(e);
|
||||||
// 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"),
|
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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) {
|
||||||
let lan_ip = local_lan_ip().context("failed to determine this machine's LAN-reachable 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
|
// Bind an ephemeral port (`:0`) rather than a fixed one like the
|
||||||
// `dlna_mirror_test` example uses -- the daemon may need to run
|
// `dlna_mirror_test` example uses -- the daemon may need to run
|
||||||
// alongside that example, or a future concurrent-session mode,
|
// alongside that example, or a future concurrent-session mode,
|
||||||
// without a bind conflict. `HttpServer::start`'s worker threads
|
// without a bind conflict. The server is held on the session and
|
||||||
// outlive this session once it stops (documented pre-existing
|
// shut down in `stop()` so the last screen-recording segments are
|
||||||
// limitation, see `http_server.rs` -- not something introduced
|
// not left reachable on the LAN.
|
||||||
// here); one leaked idle listener per DLNA cast is an accepted
|
let http = match HttpServer::start("0.0.0.0:0", output_dir.clone()) {
|
||||||
// cost until that gets a real shutdown path.
|
Ok(http) => http,
|
||||||
let http = breadcast_core::http_server::HttpServer::start("0.0.0.0:0", output_dir.clone())
|
Err(e) => {
|
||||||
.context("failed to start the HLS HTTP server")?;
|
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");
|
let stream_url = http.url(lan_ip, "playlist.m3u8");
|
||||||
|
|
||||||
// Two segments, not three: this is a "don't hand the renderer a 404
|
// 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
|
// `build_video_pipeline`'s note on HLS latency). Two is the minimum
|
||||||
// that still proves the encoder is genuinely producing output rather
|
// that still proves the encoder is genuinely producing output rather
|
||||||
// than having emitted one segment and stalled.
|
// than having emitted one segment and stalled.
|
||||||
wait_for_playlist_segments(&output_dir.join("playlist.m3u8"), 2, Duration::from_secs(20))
|
if let Err(e) = wait_for_playlist_segments(&output_dir.join("playlist.m3u8"), 2, Duration::from_secs(20)).await
|
||||||
.await
|
{
|
||||||
.context("encode pipeline never produced playable HLS segments")?;
|
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")?;
|
let session = match DlnaSession::connect(&device).await {
|
||||||
session.load(&stream_url).await.context("renderer rejected the stream load")?;
|
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");
|
tracing::info!(device = %device.friendly_name, %stream_url, "DLNA mirroring started");
|
||||||
|
|
||||||
let poll_task = {
|
let poll_task = {
|
||||||
let session = session.clone();
|
let session = session.clone();
|
||||||
let device_name = device.friendly_name.clone();
|
let device_name = device.friendly_name.clone();
|
||||||
|
let daemon_tx = daemon_tx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(POLL_INTERVAL).await;
|
tokio::time::sleep(POLL_INTERVAL).await;
|
||||||
match session.transport_state().await {
|
match session.transport_state().await {
|
||||||
Ok(state) if state == "STOPPED" || state == "NO_MEDIA_PRESENT" => {
|
Ok(state) if state == "STOPPED" || state == "NO_MEDIA_PRESENT" => {
|
||||||
tracing::info!(device = %device_name, %state, "DLNA renderer ended playback on its own");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(device = %device_name, error = ?e, "DLNA transport state poll failed, treating renderer as gone");
|
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;
|
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,
|
/// 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) {
|
pub async fn stop(mut self) {
|
||||||
// A request, not a wait -- if the poll task is mid-poll and sends
|
// 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:
|
// one more `SessionEnded` right as this races it, that's harmless:
|
||||||
// `daemon.rs`'s handler already no-ops when `active_session` was
|
// `daemon.rs`'s handler already no-ops when `active_session` was
|
||||||
// already cleared by this explicit stop.
|
// already cleared by this explicit stop.
|
||||||
self.poll_task.abort();
|
self.poll_task.abort();
|
||||||
|
self.stall_task.abort();
|
||||||
|
|
||||||
if let Err(e) = self.session.stop().await {
|
if let Err(e) = self.session.stop().await {
|
||||||
tracing::warn!(error = ?e, "failed to cleanly stop the DLNA session");
|
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) {
|
if let Err(e) = self.pipeline.set_state(gst::State::Null) {
|
||||||
tracing::warn!(error = ?e, "failed to stop the encode pipeline cleanly");
|
tracing::warn!(error = ?e, "failed to stop the encode pipeline cleanly");
|
||||||
}
|
}
|
||||||
|
self.http.shutdown();
|
||||||
if let Some(capture) = self.capture.take() {
|
if let Some(capture) = self.capture.take() {
|
||||||
if let Err(e) = capture.close().await {
|
match tokio::time::timeout(Duration::from_secs(5), capture.close()).await {
|
||||||
tracing::warn!(error = ?e, "failed to cleanly close the portal capture session");
|
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<HttpServer>,
|
||||||
|
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<std::net::IpAddr> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@
|
||||||
//! a time, but nothing here assumes that) can connect concurrently; each
|
//! a time, but nothing here assumes that) can connect concurrently; each
|
||||||
//! gets its own copy of every broadcast event.
|
//! gets its own copy of every broadcast event.
|
||||||
|
|
||||||
|
use std::os::unix::fs::DirBuilderExt;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use breadcast_core::ipc::{ClientRequest, ServerMessage, socket_path};
|
use breadcast_core::ipc::{ClientRequest, ServerMessage, socket_path};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
@ -31,7 +33,13 @@ use crate::daemon::DaemonCommand;
|
||||||
pub async fn serve(daemon_tx: mpsc::Sender<DaemonCommand>, events_tx: broadcast::Sender<ServerMessage>) -> Result<()> {
|
pub async fn serve(daemon_tx: mpsc::Sender<DaemonCommand>, events_tx: broadcast::Sender<ServerMessage>) -> Result<()> {
|
||||||
let socket_path = socket_path()?;
|
let socket_path = socket_path()?;
|
||||||
if let Some(parent) = socket_path.parent() {
|
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()))?;
|
.with_context(|| format!("failed to create socket dir {}", parent.display()))?;
|
||||||
}
|
}
|
||||||
// A stale socket file from an unclean previous exit makes bind() fail
|
// A stale socket file from an unclean previous exit makes bind() fail
|
||||||
|
|
@ -45,7 +53,18 @@ pub async fn serve(daemon_tx: mpsc::Sender<DaemonCommand>, events_tx: broadcast:
|
||||||
tracing::info!(path = %socket_path.display(), "IPC socket listening");
|
tracing::info!(path = %socket_path.display(), "IPC socket listening");
|
||||||
|
|
||||||
loop {
|
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 daemon_tx = daemon_tx.clone();
|
||||||
let events_rx = events_tx.subscribe();
|
let events_rx = events_tx.subscribe();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
|
@ -106,10 +125,14 @@ async fn handle_connection(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// 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 response = handle_request(request, &daemon_tx).await;
|
||||||
if writer_tx.send(response).is_err() {
|
let _ = writer_tx.send(response);
|
||||||
break;
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
forward_task.abort();
|
forward_task.abort();
|
||||||
|
|
|
||||||
|
|
@ -73,9 +73,18 @@ async fn main() -> anyhow::Result<()> {
|
||||||
tracing::info!(?device, "Cast device found");
|
tracing::info!(?device, "Cast device found");
|
||||||
bread_events::emit_device_found(&bread_client, &device.id, &device.name, &device.model, "cast");
|
bread_events::emit_device_found(&bread_client, &device.id, &device.name, &device.model, "cast");
|
||||||
known_cast_devices.insert(device.id.clone(), device.clone());
|
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 }) => {
|
Some(DiscoveryEvent::Lost { id }) => {
|
||||||
tracing::info!(%id, "Cast device lost");
|
tracing::info!(%id, "Cast device lost");
|
||||||
known_cast_devices.remove(&id);
|
known_cast_devices.remove(&id);
|
||||||
|
|
@ -99,9 +108,13 @@ async fn main() -> anyhow::Result<()> {
|
||||||
tracing::info!(?device, "DLNA device found");
|
tracing::info!(?device, "DLNA device found");
|
||||||
bread_events::emit_device_found(&bread_client, &device.url, &device.friendly_name, "DLNA renderer", "dlna");
|
bread_events::emit_device_found(&bread_client, &device.url, &device.friendly_name, "DLNA renderer", "dlna");
|
||||||
known_dlna_devices.insert(device.url.clone(), device.clone());
|
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 }) => {
|
Some(DlnaDiscoveryEvent::Lost { url }) => {
|
||||||
tracing::info!(%url, "DLNA device lost");
|
tracing::info!(%url, "DLNA device lost");
|
||||||
known_dlna_devices.remove(&url);
|
known_dlna_devices.remove(&url);
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ PartOf=graphical-session.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
ExecStart=%h/.cargo/bin/breadcastd
|
ExecStart=%h/.local/bin/breadcastd
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
|
|
||||||
|
|
|
||||||
13
vendor/rust_cast-0.21.0/PATCHES.md
vendored
13
vendor/rust_cast-0.21.0/PATCHES.md
vendored
|
|
@ -22,11 +22,14 @@ channel claims the namespace.
|
||||||
|
|
||||||
## The patch
|
## The patch
|
||||||
|
|
||||||
`src/lib.rs`: added `CastDevice::send_message<M: Serialize>(&self, namespace,
|
`src/lib.rs`: added `CastDevice::send_message(&self, namespace: &str,
|
||||||
destination, message)`, built the same way `ReceiverChannel::broadcast_message()`
|
destination: &str, message: &str)`, built the same way
|
||||||
is internally, but with a caller-supplied `destination` instead of a
|
`ReceiverChannel::broadcast_message()` is internally, but with a
|
||||||
hardcoded `"*"`. See the doc comment on that method for the exact rationale
|
caller-supplied `destination` instead of a hardcoded `"*"`. The payload is
|
||||||
(marked "LOCAL PATCH (breadcast, not upstream)").
|
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
|
## Rolling the pin
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue