Add native ScreenCast portal with PipeWire monitor streams
This commit is contained in:
1 parent
cb512eece9
commit
e7d92280d6
21 files changed
+2228
-15
No files matched your search
Generated
+261
-2
@@ -181,6 +181,22 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotate-snippets"
|
||||||
|
version = "0.11.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"unicode-width 0.2.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "appendlist"
|
name = "appendlist"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
@@ -508,6 +524,25 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bindgen"
|
||||||
|
version = "0.72.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
||||||
|
dependencies = [
|
||||||
|
"annotate-snippets",
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"cexpr",
|
||||||
|
"clang-sys",
|
||||||
|
"itertools",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"regex",
|
||||||
|
"rustc-hash 2.1.3",
|
||||||
|
"shlex 1.3.0",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -739,6 +774,25 @@ dependencies = [
|
|||||||
"shlex 2.0.1",
|
"shlex 2.0.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cexpr"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
||||||
|
dependencies = [
|
||||||
|
"nom 7.1.3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-expr"
|
||||||
|
version = "0.20.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fe4ece8474b5f766c63426647e7b4b316b67431ade1036a8313cee24a03ae917"
|
||||||
|
dependencies = [
|
||||||
|
"smallvec",
|
||||||
|
"target-lexicon",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@@ -761,6 +815,17 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clang-sys"
|
||||||
|
version = "1.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a"
|
||||||
|
dependencies = [
|
||||||
|
"glob",
|
||||||
|
"libc",
|
||||||
|
"libloading",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clipboard-win"
|
name = "clipboard-win"
|
||||||
version = "5.4.1"
|
version = "5.4.1"
|
||||||
@@ -877,6 +942,12 @@ version = "0.10.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cookie-factory"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
@@ -1463,6 +1534,12 @@ 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 = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b"
|
checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "either"
|
||||||
|
version = "1.18.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "endi"
|
name = "endi"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -1700,7 +1777,7 @@ version = "0.6.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2"
|
checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"toml",
|
"toml 0.5.11",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2094,6 +2171,12 @@ version = "0.25.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3"
|
checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "glob"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "glow"
|
name = "glow"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
@@ -2222,6 +2305,12 @@ version = "0.4.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hermit-abi"
|
name = "hermit-abi"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
@@ -2796,6 +2885,15 @@ dependencies = [
|
|||||||
"windows-sys 0.48.0",
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
@@ -3143,6 +3241,33 @@ dependencies = [
|
|||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libspa"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "882f7427e7989dcc9d388b7f05c4630390a1d7696f9ffa469cd4a7a48f0b4c40"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"cc",
|
||||||
|
"cookie-factory",
|
||||||
|
"libc",
|
||||||
|
"libspa-sys",
|
||||||
|
"nom 8.0.0",
|
||||||
|
"rustix 1.1.5",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libspa-sys"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3b6e17bdaf63ed0d5e4144022624032b41fd9733112e8c74ac26fc9bf1291924"
|
||||||
|
dependencies = [
|
||||||
|
"bindgen",
|
||||||
|
"cc",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libudev-sys"
|
name = "libudev-sys"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -3377,6 +3502,12 @@ dependencies = [
|
|||||||
"unicase",
|
"unicase",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minimal-lexical"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -3493,6 +3624,25 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom"
|
||||||
|
version = "7.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"minimal-lexical",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom"
|
||||||
|
version = "8.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "notify"
|
name = "notify"
|
||||||
version = "8.2.0"
|
version = "8.2.0"
|
||||||
@@ -3931,7 +4081,7 @@ version = "0.18.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0"
|
checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck",
|
"heck 0.4.1",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"proc-macro2-diagnostics",
|
"proc-macro2-diagnostics",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -4113,6 +4263,31 @@ dependencies = [
|
|||||||
"futures-io",
|
"futures-io",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pipewire"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bde71084c4e25959d68f1ea54daa75e5ecdb338e5caf0b5510143b79baa32d5c"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"libc",
|
||||||
|
"libspa",
|
||||||
|
"libspa-sys",
|
||||||
|
"pipewire-sys",
|
||||||
|
"rustix 1.1.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pipewire-sys"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ce653f53e63e5b93853218092ee9a8906a5d082c92f3f1db26316955dd63ce0"
|
||||||
|
dependencies = [
|
||||||
|
"bindgen",
|
||||||
|
"libspa-sys",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkg-config"
|
name = "pkg-config"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
@@ -4471,6 +4646,18 @@ dependencies = [
|
|||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex"
|
||||||
|
version = "1.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-automata",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex-automata"
|
name = "regex-automata"
|
||||||
version = "0.4.18"
|
version = "0.4.18"
|
||||||
@@ -4771,6 +4958,15 @@ dependencies = [
|
|||||||
"syn 3.0.6",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -5178,6 +5374,19 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-deps"
|
||||||
|
version = "7.0.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-expr",
|
||||||
|
"heck 0.5.0",
|
||||||
|
"pkg-config",
|
||||||
|
"toml 1.1.6+spec-1.1.0",
|
||||||
|
"version-compare",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "taffy"
|
name = "taffy"
|
||||||
version = "0.9.2"
|
version = "0.9.2"
|
||||||
@@ -5190,6 +5399,12 @@ dependencies = [
|
|||||||
"slotmap",
|
"slotmap",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "target-lexicon"
|
||||||
|
version = "0.13.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
@@ -5390,6 +5605,21 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "1.1.6+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde_core",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_parser",
|
||||||
|
"toml_writer",
|
||||||
|
"winnow 1.0.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "1.1.1+spec-1.1.0"
|
version = "1.1.1+spec-1.1.0"
|
||||||
@@ -5420,6 +5650,12 @@ dependencies = [
|
|||||||
"winnow 1.0.4",
|
"winnow 1.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_writer"
|
||||||
|
version = "1.1.2+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
@@ -5704,6 +5940,12 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version-compare"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
@@ -6958,6 +7200,23 @@ version = "3.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5"
|
checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xdg-desktop-portal-ferese"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"ferese-config",
|
||||||
|
"libc",
|
||||||
|
"libcosmic",
|
||||||
|
"memmap2 0.9.11",
|
||||||
|
"pipewire",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
"wayland-client",
|
||||||
|
"wayland-protocols-wlr",
|
||||||
|
"zbus",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xkbcommon"
|
name = "xkbcommon"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ members = [
|
|||||||
"shell/ferese-shell",
|
"shell/ferese-shell",
|
||||||
"shell/ferese-settings",
|
"shell/ferese-settings",
|
||||||
"shell/ferese-lock",
|
"shell/ferese-lock",
|
||||||
|
"portal",
|
||||||
]
|
]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
||||||
|
|||||||
+19
-7
@@ -13,7 +13,7 @@ desktop instead of direct access to the graphics device.
|
|||||||
|
|
||||||
Build with **Rust 1.95 or newer**, a C/C++ toolchain, CMake, pkg-config, and the
|
Build with **Rust 1.95 or newer**, a C/C++ toolchain, CMake, pkg-config, and the
|
||||||
native development libraries below. The installer builds Ferese, its shell,
|
native development libraries below. The installer builds Ferese, its shell,
|
||||||
Settings, command-line tools, and locker together. It does not install system
|
Settings, command-line tools, locker, and ScreenCast portal together. It does not install system
|
||||||
packages or enable services for you.
|
packages or enable services for you.
|
||||||
|
|
||||||
### Fedora
|
### Fedora
|
||||||
@@ -22,7 +22,8 @@ packages or enable services for you.
|
|||||||
sudo dnf install git curl gcc gcc-c++ make cmake pkgconf-pkg-config \
|
sudo dnf install git curl gcc gcc-c++ make cmake pkgconf-pkg-config \
|
||||||
wayland-devel libxkbcommon-devel libinput-devel systemd-devel libseat-devel \
|
wayland-devel libxkbcommon-devel libinput-devel systemd-devel libseat-devel \
|
||||||
mesa-libgbm-devel mesa-libEGL-devel libdrm-devel fontconfig-devel \
|
mesa-libgbm-devel mesa-libEGL-devel libdrm-devel fontconfig-devel \
|
||||||
freetype-devel expat-devel dbus-daemon dbus-tools desktop-file-utils foot pam
|
freetype-devel expat-devel dbus-daemon dbus-tools desktop-file-utils foot pam \
|
||||||
|
clang clang-devel pipewire-devel pipewire wireplumber xdg-desktop-portal xdg-desktop-portal-gtk
|
||||||
```
|
```
|
||||||
|
|
||||||
### Arch Linux
|
### Arch Linux
|
||||||
@@ -30,7 +31,8 @@ sudo dnf install git curl gcc gcc-c++ make cmake pkgconf-pkg-config \
|
|||||||
```sh
|
```sh
|
||||||
sudo pacman -S --needed base-devel git curl cmake pkgconf wayland libxkbcommon \
|
sudo pacman -S --needed base-devel git curl cmake pkgconf wayland libxkbcommon \
|
||||||
libinput systemd seatd mesa libdrm fontconfig freetype2 expat dbus \
|
libinput systemd seatd mesa libdrm fontconfig freetype2 expat dbus \
|
||||||
desktop-file-utils foot pam
|
desktop-file-utils foot pam clang pipewire libpipewire wireplumber \
|
||||||
|
xdg-desktop-portal xdg-desktop-portal-gtk
|
||||||
```
|
```
|
||||||
|
|
||||||
### Debian and Ubuntu
|
### Debian and Ubuntu
|
||||||
@@ -40,7 +42,9 @@ sudo apt update
|
|||||||
sudo apt install build-essential git curl cmake pkg-config libwayland-dev \
|
sudo apt install build-essential git curl cmake pkg-config libwayland-dev \
|
||||||
libxkbcommon-dev libinput-dev libudev-dev libseat-dev libgbm-dev libegl-dev \
|
libxkbcommon-dev libinput-dev libudev-dev libseat-dev libgbm-dev libegl-dev \
|
||||||
libdrm-dev libfontconfig1-dev libfreetype-dev libexpat1-dev dbus-bin \
|
libdrm-dev libfontconfig1-dev libfreetype-dev libexpat1-dev dbus-bin \
|
||||||
dbus-user-session desktop-file-utils foot libpam0g
|
dbus-user-session desktop-file-utils foot libpam0g clang libclang-dev \
|
||||||
|
libpipewire-0.3-dev libspa-0.2-dev pipewire wireplumber \
|
||||||
|
xdg-desktop-portal xdg-desktop-portal-gtk
|
||||||
```
|
```
|
||||||
|
|
||||||
These are package recipes, not a claim that every distribution release has been
|
These are package recipes, not a claim that every distribution release has been
|
||||||
@@ -86,7 +90,9 @@ The installer adds:
|
|||||||
- Versioned releases under `/usr/local/lib/ferese/releases/`, with `current` and
|
- Versioned releases under `/usr/local/lib/ferese/releases/`, with `current` and
|
||||||
`previous` links for upgrades and rollback.
|
`previous` links for upgrades and rollback.
|
||||||
- Commands under `/usr/local/bin/`, including `ferese-session`, `ferese-settings`,
|
- Commands under `/usr/local/bin/`, including `ferese-session`, `ferese-settings`,
|
||||||
`feresectl`, `ferese-lock`, and `ferese-screenshot`.
|
`feresectl`, `ferese-lock`, `ferese-screenshot`, and
|
||||||
|
`xdg-desktop-portal-ferese`.
|
||||||
|
- D-Bus activation and Ferese-specific ScreenCast portal registration.
|
||||||
- A Wayland session entry at `/usr/share/wayland-sessions/ferese.desktop`.
|
- A Wayland session entry at `/usr/share/wayland-sessions/ferese.desktop`.
|
||||||
- A Settings application entry, icons, the default wallpaper, and an example config.
|
- A Settings application entry, icons, the default wallpaper, and an example config.
|
||||||
- `/etc/pam.d/ferese-lock`, using your distribution's authentication stack.
|
- `/etc/pam.d/ferese-lock`, using your distribution's authentication stack.
|
||||||
@@ -203,7 +209,7 @@ To preview a source build before installing, run these commands from the checkou
|
|||||||
inside an existing Wayland desktop:
|
inside an existing Wayland desktop:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo build --release --locked -p ferese -p ferese-shell -p ferese-settings -p feresectl -p ferese-lock
|
cargo build --release --locked -p ferese -p ferese-shell -p ferese-settings -p feresectl -p ferese-lock -p xdg-desktop-portal-ferese
|
||||||
target/release/ferese --backend nested --grant-effects --grant-shell-control -- \
|
target/release/ferese --backend nested --grant-effects --grant-shell-control -- \
|
||||||
target/release/ferese-shell
|
target/release/ferese-shell
|
||||||
```
|
```
|
||||||
@@ -245,7 +251,7 @@ Installer options:
|
|||||||
|
|
||||||
`--dry-run` prints the planned commands. `--offline` requires dependencies to be
|
`--dry-run` prints the planned commands. `--offline` requires dependencies to be
|
||||||
cached already. `--skip-build` installs the binaries already in `target/release/`;
|
cached already. `--skip-build` installs the binaries already in `target/release/`;
|
||||||
use it only after building all five components from the intended revision.
|
use it only after building all six components from the intended revision.
|
||||||
Run `./scripts/install.sh --help` for all options.
|
Run `./scripts/install.sh --help` for all options.
|
||||||
|
|
||||||
## Logout and recovery
|
## Logout and recovery
|
||||||
@@ -299,3 +305,9 @@ sudo mv /usr/share/wayland-sessions/ferese.desktop \
|
|||||||
|
|
||||||
For a command-based greeter, remove Ferese from its session choices using that
|
For a command-based greeter, remove Ferese from its session choices using that
|
||||||
greeter's configuration. Reinstalling restores the desktop session entry.
|
greeter's configuration. Reinstalling restores the desktop session entry.
|
||||||
|
|
||||||
|
## Screen sharing and recording
|
||||||
|
|
||||||
|
The installer includes Ferese’s native ScreenCast portal. See
|
||||||
|
[Screen sharing](screen-sharing.md) for consent, PipeWire setup, supported sources,
|
||||||
|
and current recording limitations.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Screen sharing and recording
|
||||||
|
|
||||||
|
Ferese includes a ScreenCast portal backend, `xdg-desktop-portal-ferese`.
|
||||||
|
Applications use the standard desktop portal to request a display, then receive
|
||||||
|
video through PipeWire. A recorder or conferencing application handles encoding,
|
||||||
|
saving files, or sending video; those tasks do not run inside the compositor.
|
||||||
|
|
||||||
|
## Start sharing
|
||||||
|
|
||||||
|
Use a Wayland screen-sharing or recording source in your application. Ferese
|
||||||
|
opens a display picker. Select a display and press **Share**. Nothing is selected
|
||||||
|
by default. Applications that request multiple displays can share up to four.
|
||||||
|
The picker uses your Ferese theme colors, font, and shell corner radius.
|
||||||
|
|
||||||
|
A **screen sharing** window remains visible while sharing. Press **Stop sharing**
|
||||||
|
or close that window to revoke the session. Closing the requesting application,
|
||||||
|
locking Ferese, disconnecting the selected display, or changing its capture size
|
||||||
|
also ends sharing. A new request needs fresh consent; selections are not saved.
|
||||||
|
Everything visible on a shared display, including notifications, is included.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
The [standard installer](installation.md) builds and installs the backend, its
|
||||||
|
D-Bus activation entry, and a Ferese-specific portal preference file. Install
|
||||||
|
`pipewire`, a PipeWire session manager such as `wireplumber`,
|
||||||
|
`xdg-desktop-portal`, and `xdg-desktop-portal-gtk` using your distribution's package
|
||||||
|
manager. The GTK backend supplies other portal interfaces such as file selection.
|
||||||
|
|
||||||
|
Log into a new Ferese session after installation. The session launcher imports
|
||||||
|
its Wayland display into the D-Bus activation environment. Nested previews leave
|
||||||
|
the host activation environment alone.
|
||||||
|
|
||||||
|
Existing custom window rules can float the picker and sharing indicator:
|
||||||
|
|
||||||
|
```kdl
|
||||||
|
window-rule app-id="dev.ferese.ScreenShare" floating=#true
|
||||||
|
```
|
||||||
|
|
||||||
|
The packaged example configuration already includes this rule. Existing user
|
||||||
|
configuration is preserved during installation.
|
||||||
|
|
||||||
|
## Current capabilities
|
||||||
|
|
||||||
|
- Monitor capture, one PipeWire stream per selected display, at up to 30 fps.
|
||||||
|
- Hidden or embedded pointer, as requested by the application.
|
||||||
|
- Bounded frame storage and reusable shared-memory buffers.
|
||||||
|
- Cancellation during selection, an explicit stop control, and process cleanup.
|
||||||
|
- ScreenCast backend version 3, without persistent or automatic consent.
|
||||||
|
|
||||||
|
Window-only sharing, region selection, audio capture, and a built-in recorder
|
||||||
|
control in the shell are not implemented yet. Audio can be handled separately by
|
||||||
|
the recording application. Video currently uses CPU copies through the
|
||||||
|
compositor's shared-memory screencopy path, not DMA-BUF zero-copy. High-resolution
|
||||||
|
or multiple-display recording can therefore use significant CPU and memory
|
||||||
|
bandwidth. A paused stream checks capture availability once per second so locking
|
||||||
|
also ends sessions without an active consumer.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
Confirm that `XDG_CURRENT_DESKTOP` contains `Ferese`, PipeWire is running, and
|
||||||
|
`FERESE_ENABLE_SCREENCOPY` was not set to `0` before launching the session.
|
||||||
|
|
||||||
|
The installed preference is
|
||||||
|
`/usr/share/xdg-desktop-portal/ferese-portals.conf`. A user override under
|
||||||
|
`~/.config/xdg-desktop-portal/` can take precedence. Its ScreenCast preference
|
||||||
|
should name `ferese`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[preferred]
|
||||||
|
default=gtk;
|
||||||
|
org.freedesktop.impl.portal.ScreenCast=ferese;
|
||||||
|
```
|
||||||
|
|
||||||
|
If capture dimensions change, stop and start sharing again. Ferese ends the old
|
||||||
|
session instead of feeding incorrectly sized frames to the application.
|
||||||
|
|
||||||
|
For development, `xdg-desktop-portal-ferese --sources` lists displays available
|
||||||
|
on the current `WAYLAND_DISPLAY`. This command does not start a stream.
|
||||||
@@ -29,6 +29,7 @@ theme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window-rule app-id="dev.ferese.Settings" floating=#true
|
window-rule app-id="dev.ferese.Settings" floating=#true
|
||||||
|
window-rule app-id="dev.ferese.ScreenShare" floating=#true
|
||||||
window-rule title="Choose a wallpaper" floating=#true
|
window-rule title="Choose a wallpaper" floating=#true
|
||||||
|
|
||||||
desktop-widgets {
|
desktop-widgets {
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ export PATH="$release_dir:$PATH"
|
|||||||
unset WAYLAND_SOCKET
|
unset WAYLAND_SOCKET
|
||||||
if [[ $backend == drm ]]; then
|
if [[ $backend == drm ]]; then
|
||||||
unset DISPLAY WAYLAND_DISPLAY
|
unset DISPLAY WAYLAND_DISPLAY
|
||||||
|
export FERESE_SESSION_IMPORT_ENV=1
|
||||||
|
else
|
||||||
|
export FERESE_SESSION_IMPORT_ENV=0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $backend == drm ]] && command -v systemctl >/dev/null; then
|
if [[ $backend == drm ]] && command -v systemctl >/dev/null; then
|
||||||
@@ -39,7 +42,7 @@ if [[ $backend == drm ]] && command -v systemctl >/dev/null; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
command=("$release_dir/ferese" --backend "$backend" --grant-effects
|
command=("$release_dir/ferese" --backend "$backend" --grant-effects
|
||||||
--grant-shell-control -- "$release_dir/ferese-shell")
|
--grant-shell-control -- "$release_dir/ferese-session-shell")
|
||||||
if [[ -z ${DBUS_SESSION_BUS_ADDRESS:-} ]]; then
|
if [[ -z ${DBUS_SESSION_BUS_ADDRESS:-} ]]; then
|
||||||
exec dbus-run-session -- "${command[@]}"
|
exec dbus-run-session -- "${command[@]}"
|
||||||
else
|
else
|
||||||
|
|||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
# The compositor supplies its new Wayland socket before this helper starts.
|
||||||
|
# Nested previews must never replace the host session's activation environment.
|
||||||
|
if [[ ${FERESE_SESSION_IMPORT_ENV:-0} == 1 ]]; then
|
||||||
|
dbus-update-activation-environment --systemd \
|
||||||
|
WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP XDG_SESSION_TYPE || \
|
||||||
|
dbus-update-activation-environment \
|
||||||
|
WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP XDG_SESSION_TYPE || true
|
||||||
|
fi
|
||||||
|
release_dir=$(dirname -- "$(readlink -f -- "$0")")
|
||||||
|
exec "$release_dir/ferese-shell"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[preferred]
|
||||||
|
default=gtk;
|
||||||
|
org.freedesktop.impl.portal.ScreenCast=ferese;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[portal]
|
||||||
|
DBusName=org.freedesktop.impl.portal.desktop.ferese
|
||||||
|
Interfaces=org.freedesktop.impl.portal.ScreenCast;
|
||||||
|
UseIn=Ferese;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[D-BUS Service]
|
||||||
|
Name=org.freedesktop.impl.portal.desktop.ferese
|
||||||
|
Exec=/usr/local/lib/ferese/current/xdg-desktop-portal-ferese
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "xdg-desktop-portal-ferese"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version = "1.95"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
libcosmic.workspace = true
|
||||||
|
ferese-config = { path = "../crates/ferese-config" }
|
||||||
|
libc.workspace = true
|
||||||
|
memmap2 = "0.9"
|
||||||
|
pipewire = { version = "0.10.1", features = ["v0_3_65"] }
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync", "process", "io-util"] }
|
||||||
|
wayland-client.workspace = true
|
||||||
|
wayland-protocols-wlr.workspace = true
|
||||||
|
zbus = { version = "5.19.0", default-features = false, features = ["tokio"] }
|
||||||
@@ -0,0 +1,597 @@
|
|||||||
|
//! ScreenCast portal backend. Only the portal frontend may create sessions;
|
||||||
|
//! consent never survives a session, a process restart, or a capture failure.
|
||||||
|
use crate::{
|
||||||
|
capture::{Capture, Source},
|
||||||
|
picker::Prompt,
|
||||||
|
stream::Ready,
|
||||||
|
};
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
process::Stdio,
|
||||||
|
sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
|
||||||
|
process::{Child, Command},
|
||||||
|
sync::{Mutex, Notify},
|
||||||
|
};
|
||||||
|
use zbus::{
|
||||||
|
Connection,
|
||||||
|
message::Header,
|
||||||
|
object_server::SignalEmitter,
|
||||||
|
zvariant::{OwnedObjectPath, OwnedValue, Value},
|
||||||
|
};
|
||||||
|
type Options = HashMap<String, OwnedValue>;
|
||||||
|
type Reply = (u32, Options);
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum StartError {
|
||||||
|
Cancelled,
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
impl From<String> for StartError {
|
||||||
|
fn from(message: String) -> Self {
|
||||||
|
Self::Failed(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl From<&str> for StartError {
|
||||||
|
fn from(message: &str) -> Self {
|
||||||
|
Self::Failed(message.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const FRONTEND: &str = "org.freedesktop.portal.Desktop";
|
||||||
|
const PATH: &str = "/org/freedesktop/portal/desktop";
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Cancel {
|
||||||
|
stopped: AtomicBool,
|
||||||
|
changed: Notify,
|
||||||
|
}
|
||||||
|
impl Cancel {
|
||||||
|
fn stop(&self) {
|
||||||
|
self.stopped.store(true, Ordering::SeqCst);
|
||||||
|
self.changed.notify_one();
|
||||||
|
}
|
||||||
|
async fn wait(&self) {
|
||||||
|
if !self.stopped.load(Ordering::SeqCst) {
|
||||||
|
self.changed.notified().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
enum Phase {
|
||||||
|
Created,
|
||||||
|
Selected,
|
||||||
|
Starting,
|
||||||
|
Running,
|
||||||
|
}
|
||||||
|
struct Session {
|
||||||
|
owner: String,
|
||||||
|
app: String,
|
||||||
|
phase: Phase,
|
||||||
|
multiple: bool,
|
||||||
|
cursor: bool,
|
||||||
|
cancel: Arc<Cancel>,
|
||||||
|
}
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct Backend {
|
||||||
|
sessions: Arc<Mutex<HashMap<String, Session>>>,
|
||||||
|
}
|
||||||
|
async fn authorize(connection: &Connection, header: &Header<'_>) -> zbus::fdo::Result<String> {
|
||||||
|
let sender = header
|
||||||
|
.sender()
|
||||||
|
.ok_or_else(|| zbus::fdo::Error::AccessDenied("Missing caller".into()))?;
|
||||||
|
let dbus = zbus::fdo::DBusProxy::new(connection).await?;
|
||||||
|
let owner = dbus.get_name_owner(FRONTEND.try_into().unwrap()).await?;
|
||||||
|
if sender.as_str() != owner.as_str() {
|
||||||
|
return Err(zbus::fdo::Error::AccessDenied(
|
||||||
|
"Use the ScreenCast desktop portal".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(sender.to_string())
|
||||||
|
}
|
||||||
|
fn error(message: &str) -> zbus::fdo::Error {
|
||||||
|
zbus::fdo::Error::InvalidArgs(message.into())
|
||||||
|
}
|
||||||
|
fn source_options(options: &Options) -> zbus::fdo::Result<(bool, bool)> {
|
||||||
|
let number = |key: &str, default| {
|
||||||
|
options
|
||||||
|
.get(key)
|
||||||
|
.map(u32::try_from)
|
||||||
|
.transpose()
|
||||||
|
.map(|v| v.unwrap_or(default))
|
||||||
|
.map_err(|_| error("Invalid option type"))
|
||||||
|
};
|
||||||
|
let types = number("types", 1)?;
|
||||||
|
let cursor = number("cursor_mode", 1)?;
|
||||||
|
if types == 0 || types & !1 != 0 {
|
||||||
|
return Err(error("Only monitor sharing is supported"));
|
||||||
|
}
|
||||||
|
if cursor != 1 && cursor != 2 {
|
||||||
|
return Err(error("Unsupported cursor mode"));
|
||||||
|
}
|
||||||
|
let multiple = options
|
||||||
|
.get("multiple")
|
||||||
|
.map(bool::try_from)
|
||||||
|
.transpose()
|
||||||
|
.map_err(|_| error("Invalid multiple option"))?
|
||||||
|
.unwrap_or(false);
|
||||||
|
Ok((multiple, cursor == 2))
|
||||||
|
}
|
||||||
|
impl Backend {
|
||||||
|
async fn end(&self, connection: &Connection, path: &str) {
|
||||||
|
let session = self.sessions.lock().await.remove(path);
|
||||||
|
if let Some(session) = session {
|
||||||
|
session.cancel.stop();
|
||||||
|
if let Ok(emitter) = SignalEmitter::new(connection, path) {
|
||||||
|
let _ = SessionObject::closed(&emitter).await;
|
||||||
|
}
|
||||||
|
// A Close method may still hold the interface read lock.
|
||||||
|
let conn = connection.clone();
|
||||||
|
let path = path.to_owned();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = conn
|
||||||
|
.object_server()
|
||||||
|
.remove::<SessionObject, _>(path.as_str())
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[zbus::interface(name = "org.freedesktop.impl.portal.ScreenCast")]
|
||||||
|
impl Backend {
|
||||||
|
#[zbus(property, name = "version")]
|
||||||
|
fn version(&self) -> u32 {
|
||||||
|
3
|
||||||
|
}
|
||||||
|
#[zbus(property)]
|
||||||
|
fn available_source_types(&self) -> u32 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
#[zbus(property)]
|
||||||
|
fn available_cursor_modes(&self) -> u32 {
|
||||||
|
3
|
||||||
|
}
|
||||||
|
async fn create_session(
|
||||||
|
&self,
|
||||||
|
_handle: OwnedObjectPath,
|
||||||
|
session_handle: OwnedObjectPath,
|
||||||
|
app_id: String,
|
||||||
|
_options: Options,
|
||||||
|
#[zbus(connection)] connection: &Connection,
|
||||||
|
#[zbus(header)] header: Header<'_>,
|
||||||
|
) -> zbus::fdo::Result<Reply> {
|
||||||
|
let owner = authorize(connection, &header).await?;
|
||||||
|
if !session_handle
|
||||||
|
.as_str()
|
||||||
|
.starts_with("/org/freedesktop/portal/desktop/session/")
|
||||||
|
|| app_id.len() > 512
|
||||||
|
{
|
||||||
|
return Err(error("Invalid session"));
|
||||||
|
}
|
||||||
|
let mut sessions = self.sessions.lock().await;
|
||||||
|
if sessions.len() >= 8 || sessions.contains_key(session_handle.as_str()) {
|
||||||
|
return Err(zbus::fdo::Error::LimitsExceeded(
|
||||||
|
"Session limit reached or duplicate session".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let inserted = connection
|
||||||
|
.object_server()
|
||||||
|
.at(
|
||||||
|
session_handle.clone(),
|
||||||
|
SessionObject {
|
||||||
|
backend: self.clone(),
|
||||||
|
path: session_handle.to_string(),
|
||||||
|
owner: owner.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !inserted {
|
||||||
|
return Err(error("Session object already exists"));
|
||||||
|
}
|
||||||
|
sessions.insert(
|
||||||
|
session_handle.to_string(),
|
||||||
|
Session {
|
||||||
|
owner,
|
||||||
|
app: app_id,
|
||||||
|
phase: Phase::Created,
|
||||||
|
multiple: false,
|
||||||
|
cursor: false,
|
||||||
|
cancel: Arc::default(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok((0, Options::new()))
|
||||||
|
}
|
||||||
|
async fn select_sources(
|
||||||
|
&self,
|
||||||
|
_handle: OwnedObjectPath,
|
||||||
|
session_handle: OwnedObjectPath,
|
||||||
|
app_id: String,
|
||||||
|
options: Options,
|
||||||
|
#[zbus(connection)] connection: &Connection,
|
||||||
|
#[zbus(header)] header: Header<'_>,
|
||||||
|
) -> zbus::fdo::Result<Reply> {
|
||||||
|
let owner = authorize(connection, &header).await?;
|
||||||
|
let settings = source_options(&options);
|
||||||
|
let mut sessions = self.sessions.lock().await;
|
||||||
|
let session = sessions
|
||||||
|
.get_mut(session_handle.as_str())
|
||||||
|
.ok_or_else(|| error("Unknown session"))?;
|
||||||
|
if session.owner != owner
|
||||||
|
|| session.app != app_id
|
||||||
|
|| !matches!(session.phase, Phase::Created | Phase::Selected)
|
||||||
|
{
|
||||||
|
return Err(error("Invalid session state"));
|
||||||
|
}
|
||||||
|
match settings {
|
||||||
|
Ok((multiple, cursor)) => {
|
||||||
|
session.multiple = multiple;
|
||||||
|
session.cursor = cursor;
|
||||||
|
session.phase = Phase::Selected;
|
||||||
|
Ok((0, Options::new()))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
drop(sessions);
|
||||||
|
self.end(connection, session_handle.as_str()).await;
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[allow(clippy::too_many_arguments)] // ScreenCast D-Bus method signature.
|
||||||
|
async fn start(
|
||||||
|
&self,
|
||||||
|
handle: OwnedObjectPath,
|
||||||
|
session_handle: OwnedObjectPath,
|
||||||
|
app_id: String,
|
||||||
|
_parent_window: String,
|
||||||
|
_options: Options,
|
||||||
|
#[zbus(connection)] connection: &Connection,
|
||||||
|
#[zbus(header)] header: Header<'_>,
|
||||||
|
) -> zbus::fdo::Result<Reply> {
|
||||||
|
let owner = authorize(connection, &header).await?;
|
||||||
|
if !handle
|
||||||
|
.as_str()
|
||||||
|
.starts_with("/org/freedesktop/portal/desktop/request/")
|
||||||
|
{
|
||||||
|
return Err(error("Invalid request path"));
|
||||||
|
}
|
||||||
|
let (multiple, cursor, cancel) = {
|
||||||
|
let mut sessions = self.sessions.lock().await;
|
||||||
|
let session = sessions
|
||||||
|
.get_mut(session_handle.as_str())
|
||||||
|
.ok_or_else(|| error("Unknown session"))?;
|
||||||
|
if session.owner != owner || session.app != app_id || session.phase != Phase::Selected {
|
||||||
|
return Err(error("Select sources before starting a session"));
|
||||||
|
}
|
||||||
|
session.phase = Phase::Starting;
|
||||||
|
(session.multiple, session.cursor, session.cancel.clone())
|
||||||
|
};
|
||||||
|
let inserted = connection
|
||||||
|
.object_server()
|
||||||
|
.at(
|
||||||
|
handle.clone(),
|
||||||
|
Request {
|
||||||
|
cancel: cancel.clone(),
|
||||||
|
owner,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match inserted {
|
||||||
|
Ok(true) => (),
|
||||||
|
result => {
|
||||||
|
self.end(connection, session_handle.as_str()).await;
|
||||||
|
return Err(match result {
|
||||||
|
Err(e) => e.into(),
|
||||||
|
_ => error("Request object already exists"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let result = tokio::select! {
|
||||||
|
_ = cancel.wait() => Err(StartError::Cancelled),
|
||||||
|
result = tokio::time::timeout(Duration::from_secs(120), start_streams(&app_id, multiple, cursor)) => result.unwrap_or_else(|_| Err(StartError::Failed("Sharing request timed out".into()))),
|
||||||
|
};
|
||||||
|
let _ = connection
|
||||||
|
.object_server()
|
||||||
|
.remove::<Request, _>(handle)
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Ok((children, indicator, streams)) if !cancel.stopped.load(Ordering::SeqCst) => {
|
||||||
|
if let Some(session) = self.sessions.lock().await.get_mut(session_handle.as_str()) {
|
||||||
|
session.phase = Phase::Running;
|
||||||
|
}
|
||||||
|
let backend = self.clone();
|
||||||
|
let connection = connection.clone();
|
||||||
|
let path = session_handle.to_string();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
supervise(children, indicator, cancel).await;
|
||||||
|
backend.end(&connection, &path).await;
|
||||||
|
});
|
||||||
|
let streams: Vec<(u32, Options)> = streams
|
||||||
|
.into_iter()
|
||||||
|
.map(|ready| {
|
||||||
|
(
|
||||||
|
ready.node,
|
||||||
|
HashMap::from([("source_type".into(), OwnedValue::from(1u32))]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut reply = Options::new();
|
||||||
|
reply.insert(
|
||||||
|
"streams".into(),
|
||||||
|
OwnedValue::try_from(Value::from(streams))
|
||||||
|
.map_err(|e| error(&e.to_string()))?,
|
||||||
|
);
|
||||||
|
Ok((0, reply))
|
||||||
|
}
|
||||||
|
result => {
|
||||||
|
let code = match result {
|
||||||
|
Err(StartError::Failed(message)) => {
|
||||||
|
eprintln!("ferese portal: {message}");
|
||||||
|
2
|
||||||
|
}
|
||||||
|
_ => 1,
|
||||||
|
};
|
||||||
|
self.end(connection, session_handle.as_str()).await;
|
||||||
|
Ok((code, Options::new()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
struct Request {
|
||||||
|
cancel: Arc<Cancel>,
|
||||||
|
owner: String,
|
||||||
|
}
|
||||||
|
#[zbus::interface(name = "org.freedesktop.impl.portal.Request")]
|
||||||
|
impl Request {
|
||||||
|
fn close(&self, #[zbus(header)] header: Header<'_>) -> zbus::fdo::Result<()> {
|
||||||
|
if header.sender().map(|s| s.as_str()) != Some(self.owner.as_str()) {
|
||||||
|
return Err(zbus::fdo::Error::AccessDenied("Wrong request owner".into()));
|
||||||
|
}
|
||||||
|
self.cancel.stop();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
struct SessionObject {
|
||||||
|
backend: Backend,
|
||||||
|
path: String,
|
||||||
|
owner: String,
|
||||||
|
}
|
||||||
|
#[zbus::interface(name = "org.freedesktop.impl.portal.Session")]
|
||||||
|
impl SessionObject {
|
||||||
|
async fn close(
|
||||||
|
&self,
|
||||||
|
#[zbus(connection)] connection: &Connection,
|
||||||
|
#[zbus(header)] header: Header<'_>,
|
||||||
|
) -> zbus::fdo::Result<()> {
|
||||||
|
if header.sender().map(|s| s.as_str()) != Some(self.owner.as_str()) {
|
||||||
|
return Err(zbus::fdo::Error::AccessDenied("Wrong session owner".into()));
|
||||||
|
}
|
||||||
|
self.backend.end(connection, &self.path).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
#[zbus(signal)]
|
||||||
|
async fn closed(emitter: &SignalEmitter<'_>) -> zbus::Result<()>;
|
||||||
|
}
|
||||||
|
// All helpers die with the service, including a picker awaiting user input.
|
||||||
|
fn child_command() -> Result<Command, String> {
|
||||||
|
let mut command = Command::new(std::env::current_exe().map_err(|e| e.to_string())?);
|
||||||
|
let parent = std::process::id() as libc::pid_t;
|
||||||
|
// SAFETY: only async-signal-safe libc calls run between fork and exec.
|
||||||
|
unsafe {
|
||||||
|
command.pre_exec(move || {
|
||||||
|
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) != 0 {
|
||||||
|
return Err(std::io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
if libc::getppid() != parent {
|
||||||
|
libc::_exit(1);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
command.kill_on_drop(true);
|
||||||
|
Ok(command)
|
||||||
|
}
|
||||||
|
async fn picker(prompt: &Prompt) -> Result<Child, String> {
|
||||||
|
let mut child = child_command()?
|
||||||
|
.arg("--picker")
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::inherit())
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let mut input = child.stdin.take().unwrap();
|
||||||
|
input
|
||||||
|
.write_all(&serde_json::to_vec(prompt).unwrap())
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
drop(input);
|
||||||
|
Ok(child)
|
||||||
|
}
|
||||||
|
async fn start_streams(
|
||||||
|
app: &str,
|
||||||
|
multiple: bool,
|
||||||
|
cursor: bool,
|
||||||
|
) -> Result<(Vec<Child>, Child, Vec<Ready>), StartError> {
|
||||||
|
let sources = tokio::task::spawn_blocking(|| {
|
||||||
|
Capture::connect(&AtomicBool::new(false)).map(|capture| capture.sources())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())??;
|
||||||
|
let prompt = Prompt {
|
||||||
|
app: app.to_owned(),
|
||||||
|
sources,
|
||||||
|
multiple,
|
||||||
|
indicator: false,
|
||||||
|
};
|
||||||
|
let selection = picker(&prompt)
|
||||||
|
.await?
|
||||||
|
.wait_with_output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if !selection.status.success() {
|
||||||
|
return Err(StartError::Cancelled);
|
||||||
|
}
|
||||||
|
let names: Vec<String> =
|
||||||
|
serde_json::from_slice(&selection.stdout).map_err(|_| StartError::Cancelled)?;
|
||||||
|
let selected = validate_selection(&prompt.sources, &names, multiple)?;
|
||||||
|
let mut children = Vec::new();
|
||||||
|
let mut streams = Vec::new();
|
||||||
|
for source in &selected {
|
||||||
|
let mut child = child_command()?
|
||||||
|
.args([
|
||||||
|
"--stream",
|
||||||
|
&source.name,
|
||||||
|
if cursor { "embedded" } else { "hidden" },
|
||||||
|
])
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::inherit())
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let mut line = String::new();
|
||||||
|
let output = child.stdout.take().unwrap();
|
||||||
|
tokio::time::timeout(
|
||||||
|
Duration::from_secs(10),
|
||||||
|
BufReader::new(output).read_line(&mut line),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "PipeWire stream startup timed out")?
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let ready: Ready =
|
||||||
|
serde_json::from_str(&line).map_err(|_| "PipeWire stream could not start")?;
|
||||||
|
if ready.node == u32::MAX {
|
||||||
|
return Err("Invalid PipeWire node".into());
|
||||||
|
}
|
||||||
|
streams.push(ready);
|
||||||
|
children.push(child);
|
||||||
|
}
|
||||||
|
let indicator = picker(&Prompt {
|
||||||
|
app: app.to_owned(),
|
||||||
|
sources: selected,
|
||||||
|
multiple,
|
||||||
|
indicator: true,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok((children, indicator, streams))
|
||||||
|
}
|
||||||
|
fn validate_selection(
|
||||||
|
sources: &[Source],
|
||||||
|
names: &[String],
|
||||||
|
multiple: bool,
|
||||||
|
) -> Result<Vec<Source>, String> {
|
||||||
|
if names.is_empty() || names.len() > 4 || (!multiple && names.len() != 1) {
|
||||||
|
return Err("Invalid display selection".into());
|
||||||
|
}
|
||||||
|
let mut selected = Vec::new();
|
||||||
|
for name in names {
|
||||||
|
if selected.iter().any(|s: &Source| &s.name == name) {
|
||||||
|
return Err("Duplicate display selection".into());
|
||||||
|
}
|
||||||
|
selected.push(
|
||||||
|
sources
|
||||||
|
.iter()
|
||||||
|
.find(|s| &s.name == name)
|
||||||
|
.ok_or("Unknown display selection")?
|
||||||
|
.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(selected)
|
||||||
|
}
|
||||||
|
async fn supervise(mut children: Vec<Child>, mut indicator: Child, cancel: Arc<Cancel>) {
|
||||||
|
loop {
|
||||||
|
if cancel.stopped.load(Ordering::SeqCst)
|
||||||
|
|| !matches!(indicator.try_wait(), Ok(None))
|
||||||
|
|| children
|
||||||
|
.iter_mut()
|
||||||
|
.any(|c| !matches!(c.try_wait(), Ok(None)))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
cancel.stop();
|
||||||
|
for child in &mut children {
|
||||||
|
let _ = child.kill().await;
|
||||||
|
}
|
||||||
|
let _ = indicator.kill().await;
|
||||||
|
}
|
||||||
|
pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let backend = Backend::default();
|
||||||
|
let connection = zbus::connection::Builder::session()?
|
||||||
|
.name("org.freedesktop.impl.portal.desktop.ferese")?
|
||||||
|
.serve_at(PATH, backend.clone())?
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
// Frontend death must revoke every stream, even if Session.Close never arrives.
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
if backend.sessions.lock().await.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let owner = match zbus::fdo::DBusProxy::new(&connection).await {
|
||||||
|
Ok(proxy) => proxy
|
||||||
|
.get_name_owner(FRONTEND.try_into().unwrap())
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.map(|name| name.to_string()),
|
||||||
|
Err(_) => None,
|
||||||
|
};
|
||||||
|
let stale: Vec<_> = backend
|
||||||
|
.sessions
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, session)| owner.as_deref() != Some(session.owner.as_str()))
|
||||||
|
.map(|(path, _)| path.clone())
|
||||||
|
.collect();
|
||||||
|
for path in stale {
|
||||||
|
backend.end(&connection, &path).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn rejects_unimplemented_sources_and_cursor_modes() {
|
||||||
|
assert_eq!(source_options(&Options::new()).unwrap(), (false, false));
|
||||||
|
for bits in [0u32, 2, 3, 4] {
|
||||||
|
assert!(source_options(&HashMap::from([("types".into(), bits.into())])).is_err());
|
||||||
|
}
|
||||||
|
assert!(source_options(&HashMap::from([("cursor_mode".into(), 4u32.into())])).is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn rejects_empty_unknown_duplicate_and_excess_selections() {
|
||||||
|
let source = Source {
|
||||||
|
name: "test".into(),
|
||||||
|
label: "Test".into(),
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
};
|
||||||
|
assert!(validate_selection(std::slice::from_ref(&source), &[], false).is_err());
|
||||||
|
assert!(
|
||||||
|
validate_selection(std::slice::from_ref(&source), &["other".into()], false).is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
validate_selection(
|
||||||
|
std::slice::from_ref(&source),
|
||||||
|
&["test".into(), "test".into()],
|
||||||
|
true
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
validate_selection(&[source], &["test".into()], false)
|
||||||
|
.unwrap()
|
||||||
|
.len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
//! Bounded, cancellable Wayland capture. No screenshots or external capture tools.
|
||||||
|
use memmap2::MmapMut;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
fs::File,
|
||||||
|
os::fd::{AsFd, AsRawFd, FromRawFd},
|
||||||
|
sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use wayland_client::{
|
||||||
|
Connection, Dispatch, EventQueue, QueueHandle, WEnum,
|
||||||
|
protocol::{wl_buffer, wl_output, wl_registry, wl_shm, wl_shm_pool},
|
||||||
|
};
|
||||||
|
use wayland_protocols_wlr::screencopy::v1::client::{
|
||||||
|
zwlr_screencopy_frame_v1 as frame, zwlr_screencopy_manager_v1 as manager,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Source {
|
||||||
|
pub name: String,
|
||||||
|
pub label: String,
|
||||||
|
pub width: i32,
|
||||||
|
pub height: i32,
|
||||||
|
pub x: i32,
|
||||||
|
pub y: i32,
|
||||||
|
pub scale: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Output {
|
||||||
|
global: u32,
|
||||||
|
proxy: wl_output::WlOutput,
|
||||||
|
source: Source,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Frame {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub stride: u32,
|
||||||
|
pub pixels: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Buffer {
|
||||||
|
_file: File,
|
||||||
|
map: MmapMut,
|
||||||
|
proxy: wl_buffer::WlBuffer,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
stride: u32,
|
||||||
|
format: wl_shm::Format,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Buffer {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.proxy.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct State {
|
||||||
|
outputs: Vec<Output>,
|
||||||
|
manager: Option<manager::ZwlrScreencopyManagerV1>,
|
||||||
|
shm: Option<wl_shm::WlShm>,
|
||||||
|
buffer: Option<Buffer>,
|
||||||
|
result: Option<Result<(), String>>,
|
||||||
|
flipped: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Capture {
|
||||||
|
connection: Connection,
|
||||||
|
queue: EventQueue<State>,
|
||||||
|
state: State,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Capture {
|
||||||
|
pub fn connect(stop: &AtomicBool) -> Result<Self, String> {
|
||||||
|
let connection = Connection::connect_to_env().map_err(|e| e.to_string())?;
|
||||||
|
let queue = connection.new_event_queue();
|
||||||
|
connection.display().get_registry(&queue.handle(), ());
|
||||||
|
let mut this = Self {
|
||||||
|
connection,
|
||||||
|
queue,
|
||||||
|
state: State::default(),
|
||||||
|
};
|
||||||
|
// Two syncs deliver registry bindings and output metadata without an
|
||||||
|
// uninterruptible roundtrip on a stalled compositor.
|
||||||
|
this.sync(stop)?;
|
||||||
|
this.sync(stop)?;
|
||||||
|
if this.state.manager.is_none() || this.state.shm.is_none() {
|
||||||
|
return Err("Ferese screen capture is unavailable in this session".into());
|
||||||
|
}
|
||||||
|
Ok(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync(&mut self, stop: &AtomicBool) -> Result<(), String> {
|
||||||
|
let done = Arc::new(AtomicBool::new(false));
|
||||||
|
self.connection
|
||||||
|
.display()
|
||||||
|
.sync(&self.queue.handle(), done.clone());
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
while !done.load(Ordering::Relaxed) {
|
||||||
|
self.dispatch(stop, deadline)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sources(&self) -> Vec<Source> {
|
||||||
|
self.state
|
||||||
|
.outputs
|
||||||
|
.iter()
|
||||||
|
.map(|o| o.source.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn frame(
|
||||||
|
&mut self,
|
||||||
|
name: &str,
|
||||||
|
cursor: bool,
|
||||||
|
stop: &AtomicBool,
|
||||||
|
mut pixels: Vec<u8>,
|
||||||
|
) -> Result<Frame, String> {
|
||||||
|
let output = self
|
||||||
|
.state
|
||||||
|
.outputs
|
||||||
|
.iter()
|
||||||
|
.find(|o| o.source.name == name)
|
||||||
|
.ok_or("Shared monitor disconnected")?;
|
||||||
|
|
||||||
|
self.state.result = None;
|
||||||
|
self.state.flipped = false;
|
||||||
|
|
||||||
|
let frame = self.state.manager.as_ref().unwrap().capture_output(
|
||||||
|
cursor as i32,
|
||||||
|
&output.proxy,
|
||||||
|
&self.queue.handle(),
|
||||||
|
(),
|
||||||
|
);
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
|
||||||
|
while self.state.result.is_none() {
|
||||||
|
if let Err(error) = self.dispatch(stop, deadline) {
|
||||||
|
frame.destroy();
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
frame.destroy();
|
||||||
|
self.state.result.take().unwrap()?;
|
||||||
|
|
||||||
|
let buffer = self
|
||||||
|
.state
|
||||||
|
.buffer
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("Capture supplied no buffer")?;
|
||||||
|
pixels.resize(buffer.map.len(), 0);
|
||||||
|
pixels.copy_from_slice(&buffer.map);
|
||||||
|
|
||||||
|
if self.state.flipped {
|
||||||
|
let stride = buffer.stride as usize;
|
||||||
|
for y in 0..buffer.height as usize / 2 {
|
||||||
|
let bottom = (buffer.height as usize - y - 1) * stride;
|
||||||
|
let (top, tail) = pixels.split_at_mut(bottom);
|
||||||
|
top[y * stride..(y + 1) * stride].swap_with_slice(&mut tail[..stride]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Frame {
|
||||||
|
width: buffer.width,
|
||||||
|
height: buffer.height,
|
||||||
|
stride: buffer.stride,
|
||||||
|
pixels,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch(&mut self, stop: &AtomicBool, deadline: Instant) -> Result<(), String> {
|
||||||
|
if stop.load(Ordering::Relaxed) {
|
||||||
|
return Err("Capture cancelled".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return Err("Compositor capture timed out".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.queue
|
||||||
|
.dispatch_pending(&mut self.state)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
self.connection.flush().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if let Some(read) = self.queue.prepare_read() {
|
||||||
|
let mut fd = libc::pollfd {
|
||||||
|
fd: read.connection_fd().as_raw_fd(),
|
||||||
|
events: libc::POLLIN,
|
||||||
|
revents: 0,
|
||||||
|
};
|
||||||
|
// SAFETY: a single valid pollfd, bounded timeout; Wayland owns the FD.
|
||||||
|
let ready = unsafe { libc::poll(&mut fd, 1, 20) };
|
||||||
|
|
||||||
|
if ready > 0 {
|
||||||
|
read.read().map_err(|e| e.to_string())?;
|
||||||
|
} else if ready < 0
|
||||||
|
&& std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted
|
||||||
|
{
|
||||||
|
return Err(std::io::Error::last_os_error().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.queue
|
||||||
|
.dispatch_pending(&mut self.state)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<wl_registry::WlRegistry, ()> for State {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
registry: &wl_registry::WlRegistry,
|
||||||
|
event: wl_registry::Event,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
qh: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
match event {
|
||||||
|
wl_registry::Event::Global {
|
||||||
|
name,
|
||||||
|
interface,
|
||||||
|
version,
|
||||||
|
} => match interface.as_str() {
|
||||||
|
"wl_output" => state.outputs.push(Output {
|
||||||
|
global: name,
|
||||||
|
proxy: registry.bind(name, version.min(4), qh, name),
|
||||||
|
source: Source {
|
||||||
|
name: format!("output-{name}"),
|
||||||
|
label: "Display".into(),
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"wl_shm" => state.shm = Some(registry.bind(name, 1, qh, ())),
|
||||||
|
"zwlr_screencopy_manager_v1" => {
|
||||||
|
state.manager = Some(registry.bind(name, version.min(3), qh, ()))
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
},
|
||||||
|
wl_registry::Event::GlobalRemove { name } => state.outputs.retain(|o| o.global != name),
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<wl_output::WlOutput, u32> for State {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
_: &wl_output::WlOutput,
|
||||||
|
event: wl_output::Event,
|
||||||
|
name: &u32,
|
||||||
|
_: &Connection,
|
||||||
|
_: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
let Some(output) = state.outputs.iter_mut().find(|o| o.global == *name) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
match event {
|
||||||
|
wl_output::Event::Name { name } => output.source.name = name,
|
||||||
|
wl_output::Event::Description { description } => output.source.label = description,
|
||||||
|
wl_output::Event::Geometry { x, y, .. } => {
|
||||||
|
output.source.x = x;
|
||||||
|
output.source.y = y;
|
||||||
|
}
|
||||||
|
wl_output::Event::Scale { factor } => output.source.scale = factor.max(1),
|
||||||
|
wl_output::Event::Mode {
|
||||||
|
flags: WEnum::Value(flags),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
..
|
||||||
|
} if flags.contains(wl_output::Mode::Current) => {
|
||||||
|
output.source.width = width;
|
||||||
|
output.source.height = height;
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<frame::ZwlrScreencopyFrameV1, ()> for State {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
frame: &frame::ZwlrScreencopyFrameV1,
|
||||||
|
event: frame::Event,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
qh: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
match event {
|
||||||
|
frame::Event::Buffer {
|
||||||
|
format: WEnum::Value(format),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
stride,
|
||||||
|
} => {
|
||||||
|
let result = (|| {
|
||||||
|
if format != wl_shm::Format::Argb8888 && format != wl_shm::Format::Xrgb8888 {
|
||||||
|
return Err("Unsupported capture pixel format".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = buffer_size(width, height, stride)?;
|
||||||
|
if let Some(buffer) = &state.buffer
|
||||||
|
&& (buffer.width, buffer.height, buffer.stride, buffer.format)
|
||||||
|
== (width, height, stride, format)
|
||||||
|
{
|
||||||
|
frame.copy(&buffer.proxy);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// SAFETY: fixed NUL-terminated name and a newly owned memfd.
|
||||||
|
let fd = unsafe {
|
||||||
|
libc::memfd_create(c"ferese-screencast".as_ptr(), libc::MFD_CLOEXEC)
|
||||||
|
};
|
||||||
|
|
||||||
|
if fd < 0 {
|
||||||
|
return Err(std::io::Error::last_os_error().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = unsafe { File::from_raw_fd(fd) };
|
||||||
|
file.set_len(bytes as u64).map_err(|e| e.to_string())?;
|
||||||
|
// SAFETY: the file has the checked mapping length and lives with the mapping.
|
||||||
|
let map = unsafe { MmapMut::map_mut(&file) }.map_err(|e| e.to_string())?;
|
||||||
|
let pool = state
|
||||||
|
.shm
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("Missing shared memory global")?
|
||||||
|
.create_pool(file.as_fd(), bytes as i32, qh, ());
|
||||||
|
let proxy = pool.create_buffer(
|
||||||
|
0,
|
||||||
|
width as i32,
|
||||||
|
height as i32,
|
||||||
|
stride as i32,
|
||||||
|
format,
|
||||||
|
qh,
|
||||||
|
(),
|
||||||
|
);
|
||||||
|
|
||||||
|
pool.destroy();
|
||||||
|
frame.copy(&proxy);
|
||||||
|
state.buffer = Some(Buffer {
|
||||||
|
_file: file,
|
||||||
|
map,
|
||||||
|
proxy,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
stride,
|
||||||
|
format,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok::<(), String>(())
|
||||||
|
})();
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
state.result = Some(Err(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frame::Event::Ready { .. } => state.result = Some(Ok(())),
|
||||||
|
frame::Event::Failed => {
|
||||||
|
state.result = Some(Err(
|
||||||
|
"Capture ended: session locked, monitor removed, or compositor unavailable"
|
||||||
|
.into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
frame::Event::Flags {
|
||||||
|
flags: WEnum::Value(flags),
|
||||||
|
} => state.flipped = flags.contains(frame::Flags::YInvert),
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn buffer_size(width: u32, height: u32, stride: u32) -> Result<usize, String> {
|
||||||
|
let row = width.checked_mul(4).ok_or("Capture dimensions overflow")?;
|
||||||
|
let bytes = stride
|
||||||
|
.checked_mul(height)
|
||||||
|
.ok_or("Capture dimensions overflow")?;
|
||||||
|
|
||||||
|
if width == 0 || height == 0 || stride < row || bytes > 128 * 1024 * 1024 {
|
||||||
|
return Err("Unsupported capture dimensions".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(bytes as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<wayland_client::protocol::wl_callback::WlCallback, Arc<AtomicBool>> for State {
|
||||||
|
fn event(
|
||||||
|
_: &mut Self,
|
||||||
|
_: &wayland_client::protocol::wl_callback::WlCallback,
|
||||||
|
_: wayland_client::protocol::wl_callback::Event,
|
||||||
|
done: &Arc<AtomicBool>,
|
||||||
|
_: &Connection,
|
||||||
|
_: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
done.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wayland_client::delegate_noop!(State: ignore wl_shm::WlShm);
|
||||||
|
wayland_client::delegate_noop!(State: ignore wl_shm_pool::WlShmPool);
|
||||||
|
wayland_client::delegate_noop!(State: ignore wl_buffer::WlBuffer);
|
||||||
|
wayland_client::delegate_noop!(State: ignore manager::ZwlrScreencopyManagerV1);
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn rejects_unbounded_and_invalid_buffers() {
|
||||||
|
assert_eq!(buffer_size(640, 480, 2560).unwrap(), 1228800);
|
||||||
|
assert!(buffer_size(640, 480, 12).is_err());
|
||||||
|
assert!(buffer_size(0, 480, 0).is_err());
|
||||||
|
assert!(buffer_size(u32::MAX, 2, u32::MAX).is_err());
|
||||||
|
assert!(buffer_size(20000, 20000, 80000).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
mod backend;
|
||||||
|
mod capture;
|
||||||
|
mod picker;
|
||||||
|
mod stream;
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||||
|
|
||||||
|
match args.as_slice() {
|
||||||
|
[flag, name, cursor] if flag == "--stream" => {
|
||||||
|
stream::run(name.clone(), cursor == "embedded")
|
||||||
|
}
|
||||||
|
[flag] if flag == "--sources" => {
|
||||||
|
let capture = capture::Capture::connect(&std::sync::atomic::AtomicBool::new(false))?;
|
||||||
|
println!("{}", serde_json::to_string(&capture.sources())?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
[flag] if flag == "--picker" => picker::run(),
|
||||||
|
[] => tokio::runtime::Runtime::new()?.block_on(backend::run()),
|
||||||
|
_ => Err("Usage: xdg-desktop-portal-ferese [--sources]".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
use crate::capture::Source;
|
||||||
|
use cosmic::{
|
||||||
|
ApplicationExt, Element,
|
||||||
|
app::{Core, Settings, Task},
|
||||||
|
iced::Length,
|
||||||
|
widget::{button, column, container, row, text},
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Prompt {
|
||||||
|
pub app: String,
|
||||||
|
pub sources: Vec<Source>,
|
||||||
|
pub multiple: bool,
|
||||||
|
pub indicator: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
enum Message {
|
||||||
|
Select(usize),
|
||||||
|
Share,
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Picker {
|
||||||
|
core: Core,
|
||||||
|
prompt: Prompt,
|
||||||
|
selected: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut input = String::new();
|
||||||
|
std::io::stdin()
|
||||||
|
.take(64 * 1024)
|
||||||
|
.read_to_string(&mut input)?;
|
||||||
|
let prompt: Prompt = serde_json::from_str(&input)?;
|
||||||
|
|
||||||
|
if prompt.sources.is_empty() || prompt.sources.len() > 16 {
|
||||||
|
return Err("No shareable displays".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (theme, font) = appearance();
|
||||||
|
cosmic::app::run::<Picker>(
|
||||||
|
Settings::default()
|
||||||
|
.size(cosmic::iced::Size::new(480., 440.))
|
||||||
|
.theme(theme)
|
||||||
|
.default_font(font)
|
||||||
|
.default_text_size(14.)
|
||||||
|
.is_daemon(false),
|
||||||
|
prompt,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl cosmic::Application for Picker {
|
||||||
|
type Executor = cosmic::executor::Default;
|
||||||
|
type Flags = Prompt;
|
||||||
|
type Message = Message;
|
||||||
|
const APP_ID: &'static str = "dev.ferese.ScreenShare";
|
||||||
|
|
||||||
|
fn core(&self) -> &Core {
|
||||||
|
&self.core
|
||||||
|
}
|
||||||
|
|
||||||
|
fn core_mut(&mut self) -> &mut Core {
|
||||||
|
&mut self.core
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(core: Core, prompt: Prompt) -> (Self, Task<Message>) {
|
||||||
|
// No preselected source: clicking Share must be a deliberate choice.
|
||||||
|
let mut app = Self {
|
||||||
|
core,
|
||||||
|
prompt,
|
||||||
|
selected: Vec::new(),
|
||||||
|
};
|
||||||
|
let title = if app.prompt.indicator {
|
||||||
|
"Ferese — screen sharing"
|
||||||
|
} else {
|
||||||
|
"Ferese — share your screen"
|
||||||
|
};
|
||||||
|
let task = app
|
||||||
|
.core
|
||||||
|
.main_window_id()
|
||||||
|
.map(|id| app.set_window_title(title.into(), id))
|
||||||
|
.unwrap_or_else(Task::none);
|
||||||
|
|
||||||
|
(app, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, message: Message) -> Task<Message> {
|
||||||
|
match message {
|
||||||
|
Message::Select(index) if index < self.prompt.sources.len() => {
|
||||||
|
if self.selected.contains(&index) {
|
||||||
|
self.selected.retain(|i| *i != index);
|
||||||
|
} else {
|
||||||
|
if !self.prompt.multiple {
|
||||||
|
self.selected.clear();
|
||||||
|
}
|
||||||
|
if self.selected.len() < 4 {
|
||||||
|
self.selected.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::Share if !self.selected.is_empty() => {
|
||||||
|
let names: Vec<_> = self
|
||||||
|
.selected
|
||||||
|
.iter()
|
||||||
|
.map(|i| &self.prompt.sources[*i].name)
|
||||||
|
.collect();
|
||||||
|
println!("{}", serde_json::to_string(&names).unwrap());
|
||||||
|
let _ = std::io::stdout().flush();
|
||||||
|
return cosmic::iced::exit();
|
||||||
|
}
|
||||||
|
Message::Cancel => return cosmic::iced::exit(),
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self) -> Element<'_, Message> {
|
||||||
|
let app = if self.prompt.app.is_empty() {
|
||||||
|
"An application"
|
||||||
|
} else {
|
||||||
|
&self.prompt.app
|
||||||
|
};
|
||||||
|
let mut content = column([])
|
||||||
|
.spacing(16)
|
||||||
|
.push(
|
||||||
|
text(if self.prompt.indicator {
|
||||||
|
"Your screen is being shared"
|
||||||
|
} else {
|
||||||
|
"Share your screen"
|
||||||
|
})
|
||||||
|
.size(24),
|
||||||
|
)
|
||||||
|
.push(text(if self.prompt.indicator {
|
||||||
|
format!("{app} can see the displays below.")
|
||||||
|
} else {
|
||||||
|
format!("{app} wants to see your screen. Choose what to share.")
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (index, source) in self.prompt.sources.iter().enumerate() {
|
||||||
|
let label = format!(
|
||||||
|
"{}{} — {} × {}",
|
||||||
|
if self.selected.contains(&index) {
|
||||||
|
"✓ "
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
},
|
||||||
|
source.name,
|
||||||
|
source.width,
|
||||||
|
source.height
|
||||||
|
);
|
||||||
|
|
||||||
|
if self.prompt.indicator {
|
||||||
|
content = content.push(text(label));
|
||||||
|
} else {
|
||||||
|
content = content.push(
|
||||||
|
button::text(label)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.on_press(Message::Select(index)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content = content.push(text(
|
||||||
|
"Everything visible on a shared display, including notifications, may be seen.",
|
||||||
|
));
|
||||||
|
|
||||||
|
if self.prompt.indicator {
|
||||||
|
content = content.push(button::destructive("Stop sharing").on_press(Message::Cancel));
|
||||||
|
} else {
|
||||||
|
let mut share = button::suggested("Share");
|
||||||
|
if !self.selected.is_empty() {
|
||||||
|
share = share.on_press(Message::Share);
|
||||||
|
}
|
||||||
|
content = content.push(
|
||||||
|
row([])
|
||||||
|
.spacing(12)
|
||||||
|
.push(button::text("Cancel").on_press(Message::Cancel))
|
||||||
|
.push(share),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
container(content).padding(24).width(Length::Fill).into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn appearance() -> (cosmic::Theme, cosmic::font::Font) {
|
||||||
|
let document = ferese_config::config_path()
|
||||||
|
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||||
|
.and_then(|s| ferese_config::Document::parse(&s).ok());
|
||||||
|
let string = |key: &str, fallback: &str| {
|
||||||
|
document
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|d| d.get(key))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(fallback)
|
||||||
|
.to_owned()
|
||||||
|
};
|
||||||
|
let rgba = |key, fallback| {
|
||||||
|
let s = string(key, fallback);
|
||||||
|
let parse = |s: &str| {
|
||||||
|
let hex = s.strip_prefix('#')?;
|
||||||
|
let n = u32::from_str_radix(hex, 16).ok()?;
|
||||||
|
Some(match hex.len() {
|
||||||
|
6 => (n << 8) | 255,
|
||||||
|
8 => n,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let n = parse(&s).unwrap_or_else(|| parse(fallback).unwrap());
|
||||||
|
cosmic::cosmic_theme::palette::Srgba::new(
|
||||||
|
((n >> 24) & 255) as f32 / 255.,
|
||||||
|
((n >> 16) & 255) as f32 / 255.,
|
||||||
|
((n >> 8) & 255) as f32 / 255.,
|
||||||
|
(n & 255) as f32 / 255.,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let background = rgba("theme.colors.surface_base", "#111821");
|
||||||
|
let foreground = rgba("theme.colors.text_primary", "#F4F7FB");
|
||||||
|
let accent = rgba("theme.colors.accent", "#3D7BE6");
|
||||||
|
let radius = document
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|d| d.get("theme.geometry.shell_radius"))
|
||||||
|
.and_then(|v| v.as_f64())
|
||||||
|
.unwrap_or(14.)
|
||||||
|
.clamp(0., 40.) as f32;
|
||||||
|
let corners = cosmic::cosmic_theme::CornerRadii {
|
||||||
|
radius_xs: [radius.min(4.); 4],
|
||||||
|
radius_s: [radius.min(8.); 4],
|
||||||
|
radius_m: [radius; 4],
|
||||||
|
radius_l: [radius; 4],
|
||||||
|
radius_xl: [radius; 4],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let builder = if background.red + background.green + background.blue > 1.8 {
|
||||||
|
cosmic::cosmic_theme::ThemeBuilder::light()
|
||||||
|
} else {
|
||||||
|
cosmic::cosmic_theme::ThemeBuilder::dark()
|
||||||
|
};
|
||||||
|
let theme = cosmic::Theme::custom(std::sync::Arc::new(
|
||||||
|
builder
|
||||||
|
.bg_color(background)
|
||||||
|
.primary_container_bg(background)
|
||||||
|
.text_tint(foreground.color)
|
||||||
|
.accent(accent.color)
|
||||||
|
.corner_radii(corners)
|
||||||
|
.build(),
|
||||||
|
));
|
||||||
|
let family: &'static str =
|
||||||
|
Box::leak(string("theme.typography.font_family", "Inter").into_boxed_str());
|
||||||
|
|
||||||
|
(
|
||||||
|
theme,
|
||||||
|
cosmic::font::Font {
|
||||||
|
family: cosmic::iced::font::Family::Name(family),
|
||||||
|
..cosmic::font::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
//! PipeWire runs outside the compositor. Keep one frame, drop old frames under
|
||||||
|
//! backpressure, and destroy the node when the portal's control pipe closes.
|
||||||
|
use crate::capture::{Capture, Frame};
|
||||||
|
use pipewire::{self as pw, properties::properties, spa};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use spa::pod::Pod;
|
||||||
|
use std::{
|
||||||
|
io::{Read, Write},
|
||||||
|
sync::{
|
||||||
|
Arc, Mutex,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Ready {
|
||||||
|
pub node: u32,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Data {
|
||||||
|
latest: Arc<Mutex<Frame>>,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
streaming: Arc<AtomicBool>,
|
||||||
|
fresh: Arc<AtomicBool>,
|
||||||
|
announced: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn int_property(key: u32, value: i32) -> spa::pod::Property {
|
||||||
|
spa::pod::Property {
|
||||||
|
key,
|
||||||
|
flags: spa::pod::PropertyFlags::empty(),
|
||||||
|
value: spa::pod::Value::Int(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn pod(object: spa::pod::Object) -> Vec<u8> {
|
||||||
|
spa::pod::serialize::PodSerializer::serialize(
|
||||||
|
std::io::Cursor::new(Vec::new()),
|
||||||
|
&spa::pod::Value::Object(object),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.0
|
||||||
|
.into_inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(name: String, cursor: bool) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let pipe_stop = stop.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut byte = [0u8];
|
||||||
|
let _ = std::io::stdin().read(&mut byte);
|
||||||
|
pipe_stop.store(true, Ordering::Relaxed);
|
||||||
|
});
|
||||||
|
let mut capture = Capture::connect(&stop)?;
|
||||||
|
let first = capture.frame(&name, cursor, &stop, Vec::new())?;
|
||||||
|
let (width, height) = (first.width, first.height);
|
||||||
|
let latest = Arc::new(Mutex::new(first));
|
||||||
|
let streaming = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
|
let fresh = Arc::new(AtomicBool::new(false));
|
||||||
|
pw::init();
|
||||||
|
|
||||||
|
let mainloop = pw::main_loop::MainLoopRc::new(None)?;
|
||||||
|
let context = pw::context::ContextRc::new(&mainloop, None)?;
|
||||||
|
let core = context.connect_rc(None)?;
|
||||||
|
let stream = pw::stream::StreamBox::new(
|
||||||
|
&core,
|
||||||
|
"Ferese ScreenCast",
|
||||||
|
properties! {
|
||||||
|
"media.class" => "Video/Source",
|
||||||
|
"media.type" => "Video",
|
||||||
|
"media.category" => "Capture",
|
||||||
|
"media.role" => "Screen",
|
||||||
|
"node.name" => format!("ferese.screencast.{}", std::process::id()),
|
||||||
|
"node.description" => format!("Ferese — {name}"),
|
||||||
|
"node.virtual" => "true",
|
||||||
|
"node.want-driver" => "true",
|
||||||
|
"node.always-process" => "false",
|
||||||
|
"node.pause-on-idle" => "true",
|
||||||
|
"node.latency" => "1/30",
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
let data = Data {
|
||||||
|
latest: latest.clone(),
|
||||||
|
stop: stop.clone(),
|
||||||
|
streaming: streaming.clone(),
|
||||||
|
fresh: fresh.clone(),
|
||||||
|
announced: false,
|
||||||
|
};
|
||||||
|
let _listener = stream
|
||||||
|
.add_local_listener_with_user_data(data)
|
||||||
|
.state_changed(move |stream, data, _, state| {
|
||||||
|
data.fresh.store(false, Ordering::SeqCst);
|
||||||
|
data.streaming.store(
|
||||||
|
matches!(state, pw::stream::StreamState::Streaming),
|
||||||
|
Ordering::Relaxed,
|
||||||
|
);
|
||||||
|
match state {
|
||||||
|
pw::stream::StreamState::Paused | pw::stream::StreamState::Streaming
|
||||||
|
if !data.announced =>
|
||||||
|
{
|
||||||
|
let node = stream.node_id();
|
||||||
|
if node != u32::MAX {
|
||||||
|
data.announced = true;
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string(&Ready {
|
||||||
|
node,
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
let _ = std::io::stdout().flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pw::stream::StreamState::Unconnected if data.announced => {
|
||||||
|
data.stop.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
pw::stream::StreamState::Error(error) => {
|
||||||
|
eprintln!("PipeWire stream: {error}");
|
||||||
|
data.stop.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.param_changed(move |stream, data, id, param| {
|
||||||
|
if id != spa::param::ParamType::Format.as_raw() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(param) = param else { return };
|
||||||
|
let mut format = spa::param::video::VideoInfoRaw::default();
|
||||||
|
if format.parse(param).is_err()
|
||||||
|
|| format.size().width != width
|
||||||
|
|| format.size().height != height
|
||||||
|
|| format.format() != spa::param::video::VideoFormat::BGRx
|
||||||
|
{
|
||||||
|
data.stop.store(true, Ordering::Relaxed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let buffers = pod(spa::pod::object!(
|
||||||
|
spa::utils::SpaTypes::ObjectParamBuffers,
|
||||||
|
spa::param::ParamType::Buffers,
|
||||||
|
int_property(spa::sys::SPA_PARAM_BUFFERS_buffers, 4),
|
||||||
|
int_property(spa::sys::SPA_PARAM_BUFFERS_blocks, 1),
|
||||||
|
int_property(
|
||||||
|
spa::sys::SPA_PARAM_BUFFERS_size,
|
||||||
|
(width * height * 4) as i32
|
||||||
|
),
|
||||||
|
int_property(spa::sys::SPA_PARAM_BUFFERS_stride, (width * 4) as i32),
|
||||||
|
int_property(spa::sys::SPA_PARAM_BUFFERS_align, 16)
|
||||||
|
));
|
||||||
|
if stream
|
||||||
|
.update_params(&mut [Pod::from_bytes(&buffers).unwrap()])
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
data.stop.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.process(|stream, data| {
|
||||||
|
let Some(mut buffer) = stream.dequeue_buffer() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(target) = buffer.datas_mut().first_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if data.stop.load(Ordering::Relaxed) || !data.fresh.load(Ordering::SeqCst) {
|
||||||
|
*target.chunk_mut().size_mut() = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let frame = data.latest.lock().unwrap();
|
||||||
|
let row = frame.width as usize * 4;
|
||||||
|
let bytes = row * frame.height as usize;
|
||||||
|
let Some(destination) = target.data() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if destination.len() < bytes {
|
||||||
|
data.stop.store(true, Ordering::Relaxed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (dst, src) in destination[..bytes]
|
||||||
|
.chunks_exact_mut(row)
|
||||||
|
.zip(frame.pixels.chunks_exact(frame.stride as usize))
|
||||||
|
{
|
||||||
|
dst.copy_from_slice(&src[..row]);
|
||||||
|
}
|
||||||
|
*target.chunk_mut().offset_mut() = 0;
|
||||||
|
*target.chunk_mut().size_mut() = bytes as u32;
|
||||||
|
*target.chunk_mut().stride_mut() = row as i32;
|
||||||
|
})
|
||||||
|
.register()?;
|
||||||
|
|
||||||
|
let format = pod(spa::pod::object!(
|
||||||
|
spa::utils::SpaTypes::ObjectParamFormat,
|
||||||
|
spa::param::ParamType::EnumFormat,
|
||||||
|
spa::pod::property!(
|
||||||
|
spa::param::format::FormatProperties::MediaType,
|
||||||
|
Id,
|
||||||
|
spa::param::format::MediaType::Video
|
||||||
|
),
|
||||||
|
spa::pod::property!(
|
||||||
|
spa::param::format::FormatProperties::MediaSubtype,
|
||||||
|
Id,
|
||||||
|
spa::param::format::MediaSubtype::Raw
|
||||||
|
),
|
||||||
|
spa::pod::property!(
|
||||||
|
spa::param::format::FormatProperties::VideoFormat,
|
||||||
|
Id,
|
||||||
|
spa::param::video::VideoFormat::BGRx
|
||||||
|
),
|
||||||
|
spa::pod::property!(
|
||||||
|
spa::param::format::FormatProperties::VideoSize,
|
||||||
|
Rectangle,
|
||||||
|
spa::utils::Rectangle { width, height }
|
||||||
|
),
|
||||||
|
spa::pod::property!(
|
||||||
|
spa::param::format::FormatProperties::VideoFramerate,
|
||||||
|
Fraction,
|
||||||
|
spa::utils::Fraction { num: 30, denom: 1 }
|
||||||
|
)
|
||||||
|
));
|
||||||
|
|
||||||
|
stream.connect(
|
||||||
|
spa::utils::Direction::Output,
|
||||||
|
None,
|
||||||
|
pw::stream::StreamFlags::MAP_BUFFERS | pw::stream::StreamFlags::DRIVER,
|
||||||
|
&mut [Pod::from_bytes(&format).unwrap()],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let interval = Duration::from_nanos(1_000_000_000 / 30);
|
||||||
|
let capture_stop = stop.clone();
|
||||||
|
let capture_streaming = streaming.clone();
|
||||||
|
let capture_thread = std::thread::spawn(move || {
|
||||||
|
let mut spare = Vec::new();
|
||||||
|
let mut checked = Instant::now() - Duration::from_secs(1);
|
||||||
|
while !capture_stop.load(Ordering::Relaxed) {
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
if capture_streaming.load(Ordering::Relaxed)
|
||||||
|
|| checked.elapsed() >= Duration::from_secs(1)
|
||||||
|
{
|
||||||
|
checked = Instant::now();
|
||||||
|
match capture.frame(&name, cursor, &capture_stop, std::mem::take(&mut spare)) {
|
||||||
|
Ok(frame) if frame.width == width && frame.height == height => {
|
||||||
|
spare = std::mem::replace(&mut *latest.lock().unwrap(), frame).pixels;
|
||||||
|
fresh.store(capture_streaming.load(Ordering::Relaxed), Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
eprintln!("Shared monitor changed size; start sharing again");
|
||||||
|
capture_stop.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("{error}");
|
||||||
|
capture_stop.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::thread::sleep(interval.saturating_sub(start.elapsed()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let mut next_frame = Instant::now();
|
||||||
|
|
||||||
|
while !stop.load(Ordering::Relaxed) {
|
||||||
|
let active = streaming.load(Ordering::Relaxed);
|
||||||
|
let timeout = if active {
|
||||||
|
next_frame
|
||||||
|
.saturating_duration_since(Instant::now())
|
||||||
|
.min(Duration::from_millis(10))
|
||||||
|
} else {
|
||||||
|
Duration::from_millis(10)
|
||||||
|
};
|
||||||
|
mainloop
|
||||||
|
.loop_()
|
||||||
|
.iterate(pw::loop_::Timeout::Finite(timeout));
|
||||||
|
if streaming.load(Ordering::Relaxed) && Instant::now() >= next_frame {
|
||||||
|
let _ = stream.trigger_process();
|
||||||
|
next_frame = Instant::now() + interval;
|
||||||
|
} else if !streaming.load(Ordering::Relaxed) {
|
||||||
|
next_frame = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = stream.disconnect();
|
||||||
|
let _ = capture_thread.join();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ PAGES = [
|
|||||||
('desktop-widgets', 'Desktop widgets', ROOT / 'docs/desktop-widgets.md'),
|
('desktop-widgets', 'Desktop widgets', ROOT / 'docs/desktop-widgets.md'),
|
||||||
('notifications', 'Notifications', ROOT / 'docs/notifications.md'),
|
('notifications', 'Notifications', ROOT / 'docs/notifications.md'),
|
||||||
('locking', 'Native locker', ROOT / 'docs/locking.md'),
|
('locking', 'Native locker', ROOT / 'docs/locking.md'),
|
||||||
|
('screen-sharing', 'Screen sharing', ROOT / 'docs/screen-sharing.md'),
|
||||||
]
|
]
|
||||||
md = MarkdownIt('commonmark', {'html': False}).enable('table')
|
md = MarkdownIt('commonmark', {'html': False}).enable('table')
|
||||||
OUTPUT.mkdir(parents=True, exist_ok=True)
|
OUTPUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ session_entry=/usr/share/wayland-sessions/ferese.desktop
|
|||||||
[[ ! -e $release_dir ]] || { echo "Release already exists: $release_dir" >&2; exit 1; }
|
[[ ! -e $release_dir ]] || { echo "Release already exists: $release_dir" >&2; exit 1; }
|
||||||
|
|
||||||
# Refuse to overwrite unrelated installations.
|
# Refuse to overwrite unrelated installations.
|
||||||
for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-screenshot; do
|
for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-screenshot xdg-desktop-portal-ferese; do
|
||||||
link=/usr/local/bin/$name
|
link=/usr/local/bin/$name
|
||||||
if [[ -e $link || -L $link ]]; then
|
if [[ -e $link || -L $link ]]; then
|
||||||
[[ -L $link && $(readlink -- "$link") == "$install_root/current/$name" ]] || {
|
[[ -L $link && $(readlink -- "$link") == "$install_root/current/$name" ]] || {
|
||||||
@@ -39,7 +39,7 @@ if [[ -e $session_entry ]]; then
|
|||||||
echo "Different session entry exists: $session_entry" >&2; exit 1;
|
echo "Different session entry exists: $session_entry" >&2; exit 1;
|
||||||
}
|
}
|
||||||
fi
|
fi
|
||||||
for name in ferese ferese-shell ferese-settings feresectl ferese-lock; do
|
for name in ferese ferese-shell ferese-settings feresectl ferese-lock xdg-desktop-portal-ferese; do
|
||||||
[[ -x $repo_dir/target/release/$name ]] || { echo "Missing release binary: $name" >&2; exit 1; }
|
[[ -x $repo_dir/target/release/$name ]] || { echo "Missing release binary: $name" >&2; exit 1; }
|
||||||
done
|
done
|
||||||
for name in ferese.png ferese.svg; do
|
for name in ferese.png ferese.svg; do
|
||||||
@@ -57,6 +57,21 @@ elif [[ ! -f /etc/pam.d/login && ! -f /etc/pam.d/ferese-lock ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# These files are owned by Ferese; preserve unrelated administrator configuration.
|
||||||
|
for entry in \
|
||||||
|
'ferese.portal:portals/ferese.portal' \
|
||||||
|
'ferese-portals.conf:ferese-portals.conf'; do
|
||||||
|
source=${entry%%:*}
|
||||||
|
destination=/usr/share/xdg-desktop-portal/${entry#*:}
|
||||||
|
if [[ -e $destination ]] && ! cmp -s "$repo_dir/packaging/portal/$source" "$destination"; then
|
||||||
|
echo "Unmanaged portal configuration exists: $destination" >&2; exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
service=/usr/local/share/dbus-1/services/org.freedesktop.impl.portal.desktop.ferese.service
|
||||||
|
if [[ -e $service ]] && ! cmp -s "$repo_dir/packaging/portal/$(basename "$service")" "$service"; then
|
||||||
|
echo "Unmanaged portal service exists: $service" >&2; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
desktop-file-validate "$repo_dir/packaging/ferese.desktop"
|
desktop-file-validate "$repo_dir/packaging/ferese.desktop"
|
||||||
desktop-file-validate "$repo_dir/packaging/dev.ferese.Settings.desktop"
|
desktop-file-validate "$repo_dir/packaging/dev.ferese.Settings.desktop"
|
||||||
|
|
||||||
@@ -64,10 +79,11 @@ install -d -m 0755 -- "$release_dir" /usr/local/bin /usr/share/wayland-sessions
|
|||||||
if [[ -e $session_entry ]]; then
|
if [[ -e $session_entry ]]; then
|
||||||
install -m 0644 -- "$session_entry" "$release_dir/session.previous.desktop"
|
install -m 0644 -- "$session_entry" "$release_dir/session.previous.desktop"
|
||||||
fi
|
fi
|
||||||
for name in ferese ferese-shell ferese-settings feresectl ferese-lock; do
|
for name in ferese ferese-shell ferese-settings feresectl ferese-lock xdg-desktop-portal-ferese; do
|
||||||
install -m 0755 -- "$repo_dir/target/release/$name" "$release_dir/$name"
|
install -m 0755 -- "$repo_dir/target/release/$name" "$release_dir/$name"
|
||||||
done
|
done
|
||||||
install -m 0755 -- "$repo_dir/packaging/ferese-session" "$release_dir/ferese-session"
|
install -m 0755 -- "$repo_dir/packaging/ferese-session" "$release_dir/ferese-session"
|
||||||
|
install -m 0755 -- "$repo_dir/packaging/ferese-session-shell" "$release_dir/ferese-session-shell"
|
||||||
install -m 0755 -- "$repo_dir/packaging/ferese-screenshot" "$release_dir/ferese-screenshot"
|
install -m 0755 -- "$repo_dir/packaging/ferese-screenshot" "$release_dir/ferese-screenshot"
|
||||||
install -m 0644 -- "$repo_dir/packaging/config.kdl" "$release_dir/config.example.kdl"
|
install -m 0644 -- "$repo_dir/packaging/config.kdl" "$release_dir/config.example.kdl"
|
||||||
install -D -m 0644 -- "$repo_dir/assets/fonts/Comfortaa-LICENSE.txt" "$release_dir/licenses/Comfortaa-LICENSE.txt"
|
install -D -m 0644 -- "$repo_dir/assets/fonts/Comfortaa-LICENSE.txt" "$release_dir/licenses/Comfortaa-LICENSE.txt"
|
||||||
@@ -85,12 +101,15 @@ if [[ -L $install_root/current ]]; then
|
|||||||
fi
|
fi
|
||||||
ln -s -- "releases/$release_id" "$install_root/current-$release_id"
|
ln -s -- "releases/$release_id" "$install_root/current-$release_id"
|
||||||
mv -Tf -- "$install_root/current-$release_id" "$install_root/current"
|
mv -Tf -- "$install_root/current-$release_id" "$install_root/current"
|
||||||
for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-screenshot; do
|
for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-screenshot xdg-desktop-portal-ferese; do
|
||||||
ln -sfn -- "$install_root/current/$name" "/usr/local/bin/$name"
|
ln -sfn -- "$install_root/current/$name" "/usr/local/bin/$name"
|
||||||
done
|
done
|
||||||
install -m 0644 -- "$repo_dir/packaging/ferese.desktop" "$session_entry"
|
install -m 0644 -- "$repo_dir/packaging/ferese.desktop" "$session_entry"
|
||||||
install -D -m 0644 -- "$repo_dir/packaging/dev.ferese.Settings.desktop" /usr/local/share/applications/dev.ferese.Settings.desktop
|
install -D -m 0644 -- "$repo_dir/packaging/dev.ferese.Settings.desktop" /usr/local/share/applications/dev.ferese.Settings.desktop
|
||||||
install -D -m 0644 -- "$repo_dir/packaging/icons/ferese.svg" /usr/local/share/icons/hicolor/scalable/apps/dev.ferese.Settings.svg
|
install -D -m 0644 -- "$repo_dir/packaging/icons/ferese.svg" /usr/local/share/icons/hicolor/scalable/apps/dev.ferese.Settings.svg
|
||||||
install -D -m 0644 -- "$repo_dir/packaging/icons/ferese.svg" /usr/local/share/icons/hicolor/scalable/apps/ferese.svg
|
install -D -m 0644 -- "$repo_dir/packaging/icons/ferese.svg" /usr/local/share/icons/hicolor/scalable/apps/ferese.svg
|
||||||
|
install -D -m 0644 -- "$repo_dir/packaging/portal/ferese.portal" /usr/share/xdg-desktop-portal/portals/ferese.portal
|
||||||
|
install -D -m 0644 -- "$repo_dir/packaging/portal/ferese-portals.conf" /usr/share/xdg-desktop-portal/ferese-portals.conf
|
||||||
|
install -D -m 0644 -- "$repo_dir/packaging/portal/org.freedesktop.impl.portal.desktop.ferese.service" "$service"
|
||||||
if command -v update-desktop-database >/dev/null; then update-desktop-database /usr/local/share/applications || true; fi
|
if command -v update-desktop-database >/dev/null; then update-desktop-database /usr/local/share/applications || true; fi
|
||||||
echo "Installed Ferese $release_id. Select Ferese at your login screen, or run /usr/local/bin/ferese-session from a local TTY."
|
echo "Installed Ferese $release_id. Select Ferese at your login screen, or run /usr/local/bin/ferese-session from a local TTY."
|
||||||
+1
-1
@@ -49,7 +49,7 @@ desktop-file-validate "$repo_dir/packaging/ferese.desktop"
|
|||||||
|
|
||||||
build=(cargo build --manifest-path "$repo_dir/Cargo.toml"
|
build=(cargo build --manifest-path "$repo_dir/Cargo.toml"
|
||||||
--target-dir "$repo_dir/target" --release --locked
|
--target-dir "$repo_dir/target" --release --locked
|
||||||
-p ferese -p ferese-shell -p ferese-settings -p feresectl -p ferese-lock)
|
-p ferese -p ferese-shell -p ferese-settings -p feresectl -p ferese-lock -p xdg-desktop-portal-ferese)
|
||||||
if $offline; then build+=(--offline); fi
|
if $offline; then build+=(--offline); fi
|
||||||
if ! $skip_build; then
|
if ! $skip_build; then
|
||||||
((EUID != 0)) || fail 'build as your normal user, without sudo (or use --skip-build)'
|
((EUID != 0)) || fail 'build as your normal user, without sudo (or use --skip-build)'
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""Opt-in portal integration test; only captures a temporary nested compositor.
|
||||||
|
|
||||||
|
FERESE_TEST_PORTAL=1 python3 scripts/tests/test_portal_isolated.py
|
||||||
|
Add FERESE_TEST_PORTAL_CONSENT=1 to exercise the actual picker interactively.
|
||||||
|
Requires built binaries, Python GI/GStreamer (pipewiresrc), dbus-daemon,
|
||||||
|
xdg-desktop-portal and bwrap. Host PAM and D-Bus activation are never modified.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import selectors
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.environ.get("FERESE_TEST_PORTAL") == "1", "nested portal test is opt-in")
|
||||||
|
class PortalTest(unittest.TestCase):
|
||||||
|
def test_portal_capture_cancel_and_lock(self):
|
||||||
|
import gi
|
||||||
|
gi.require_version("Gst", "1.0")
|
||||||
|
from gi.repository import Gio, GLib, Gst
|
||||||
|
Gst.init(None)
|
||||||
|
repo = Path(__file__).resolve().parents[2]
|
||||||
|
binary = repo / os.environ.get("FERESE_TEST_PORTAL_BINARY", "target/debug/xdg-desktop-portal-ferese")
|
||||||
|
compositor_binary = repo / os.environ.get("FERESE_TEST_BINARY", "target/release/ferese")
|
||||||
|
processes, logs = [], []
|
||||||
|
with tempfile.TemporaryDirectory(prefix="ferese-portal-test-") as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
runtime = root / "runtime"
|
||||||
|
runtime.mkdir(mode=0o700)
|
||||||
|
config = root / "config/ferese"
|
||||||
|
config.mkdir(parents=True)
|
||||||
|
(config / "config.kdl").write_text('window-rule app-id="dev.ferese.ScreenShare" floating=#true\n')
|
||||||
|
display = Path(os.environ["WAYLAND_DISPLAY"])
|
||||||
|
if not display.is_absolute():
|
||||||
|
display = Path(os.environ["XDG_RUNTIME_DIR"]) / display
|
||||||
|
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"),
|
||||||
|
WAYLAND_DISPLAY=str(display), FERESE_ENABLE_SCREENCOPY="1",
|
||||||
|
PIPEWIRE_RUNTIME_DIR=os.environ["XDG_RUNTIME_DIR"], XDG_CURRENT_DESKTOP="Ferese",
|
||||||
|
XDG_DESKTOP_PORTAL_DIR=str(repo / "packaging/portal"))
|
||||||
|
|
||||||
|
def launch(name, command, **kwargs):
|
||||||
|
log = (root / (name + ".log")).open("w")
|
||||||
|
logs.append(log)
|
||||||
|
child = subprocess.Popen(command, env=env, stderr=log,
|
||||||
|
stdout=kwargs.pop("stdout", log), start_new_session=True, **kwargs)
|
||||||
|
processes.append(child)
|
||||||
|
return child
|
||||||
|
|
||||||
|
def until(predicate, seconds=10):
|
||||||
|
deadline = time.monotonic() + seconds
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if predicate():
|
||||||
|
return
|
||||||
|
while GLib.MainContext.default().iteration(False):
|
||||||
|
pass
|
||||||
|
time.sleep(.02)
|
||||||
|
self.fail("Timed out waiting for test condition")
|
||||||
|
|
||||||
|
def read_node(worker):
|
||||||
|
with selectors.DefaultSelector() as selector:
|
||||||
|
selector.register(worker.stdout, selectors.EVENT_READ)
|
||||||
|
self.assertTrue(selector.select(10), "stream did not publish a node")
|
||||||
|
return json.loads(worker.stdout.readline())["node"]
|
||||||
|
|
||||||
|
def consume(node, fd=None):
|
||||||
|
source = f'pipewiresrc path={node} num-buffers=15'
|
||||||
|
if fd is not None:
|
||||||
|
source += f' fd={fd}'
|
||||||
|
pipeline = Gst.parse_launch(source + ' ! video/x-raw,format=BGRx ! appsink name=sink sync=false emit-signals=true')
|
||||||
|
sizes = []
|
||||||
|
def sample(sink):
|
||||||
|
buffer = sink.emit("pull-sample").get_buffer()
|
||||||
|
sizes.append(buffer.get_size())
|
||||||
|
return Gst.FlowReturn.OK
|
||||||
|
pipeline.get_by_name("sink").connect("new-sample", sample)
|
||||||
|
try:
|
||||||
|
pipeline.set_state(Gst.State.PLAYING)
|
||||||
|
message = pipeline.get_bus().timed_pop_filtered(15 * Gst.SECOND, Gst.MessageType.EOS | Gst.MessageType.ERROR)
|
||||||
|
self.assertIsNotNone(message, "PipeWire consumer timed out")
|
||||||
|
self.assertEqual(message.type, Gst.MessageType.EOS)
|
||||||
|
self.assertEqual(len(sizes), 15)
|
||||||
|
self.assertGreater(min(sizes), 0)
|
||||||
|
finally:
|
||||||
|
pipeline.set_state(Gst.State.NULL)
|
||||||
|
|
||||||
|
try:
|
||||||
|
launch("compositor", [str(compositor_binary), "--backend", "nested"])
|
||||||
|
sockets = lambda: [p for p in runtime.glob("wayland-*") if not p.name.endswith(".lock")]
|
||||||
|
until(sockets)
|
||||||
|
env["WAYLAND_DISPLAY"] = str(sockets()[0])
|
||||||
|
# Private bus, including the real portal frontend and permission store.
|
||||||
|
bus = launch("bus", ["dbus-daemon", "--session", "--nofork", "--print-address=1"], stdout=subprocess.PIPE)
|
||||||
|
env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().decode().strip()
|
||||||
|
connection = Gio.DBusConnection.new_for_address_sync(env["DBUS_SESSION_BUS_ADDRESS"],
|
||||||
|
Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, None, None)
|
||||||
|
backend = launch("backend", [str(binary)])
|
||||||
|
frontend = launch("frontend", [os.environ.get("FERESE_TEST_PORTAL_FRONTEND", "/usr/libexec/xdg-desktop-portal")])
|
||||||
|
name, path, iface = "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.ScreenCast"
|
||||||
|
responses = {}
|
||||||
|
connection.signal_subscribe(name, "org.freedesktop.portal.Request", "Response", None, None,
|
||||||
|
Gio.DBusSignalFlags.NONE, lambda c, s, p, i, n, args, _: responses.update({p: args.unpack()}), None)
|
||||||
|
def call(method, args):
|
||||||
|
return connection.call_sync(name, path, iface, method, args, GLib.VariantType("(o)"),
|
||||||
|
Gio.DBusCallFlags.NONE, 10000, None).unpack()[0]
|
||||||
|
def request(method, args, timeout=10):
|
||||||
|
handle = call(method, args)
|
||||||
|
until(lambda: handle in responses, timeout)
|
||||||
|
code, result = responses.pop(handle)
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
return result
|
||||||
|
def has_frontend():
|
||||||
|
return connection.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus",
|
||||||
|
"NameHasOwner", GLib.Variant("(s)", (name,)), GLib.VariantType("(b)"), Gio.DBusCallFlags.NONE, 1000, None).unpack()[0]
|
||||||
|
until(has_frontend)
|
||||||
|
# Direct implementation calls must not bypass the public frontend.
|
||||||
|
with self.assertRaises(GLib.Error):
|
||||||
|
connection.call_sync("org.freedesktop.impl.portal.desktop.ferese", path, "org.freedesktop.impl.portal.ScreenCast",
|
||||||
|
"CreateSession", GLib.Variant("(oosa{sv})", (path + "/request/test", path + "/session/test", "", {})),
|
||||||
|
None, Gio.DBusCallFlags.NONE, 5000, None)
|
||||||
|
def create(token):
|
||||||
|
session = request("CreateSession", GLib.Variant("(a{sv})", ({"session_handle_token": GLib.Variant("s", token)},)))["session_handle"]
|
||||||
|
request("SelectSources", GLib.Variant("(oa{sv})", (session, {"types": GLib.Variant("u", 1)})))
|
||||||
|
return session
|
||||||
|
def helpers():
|
||||||
|
return [pid for children in Path(f"/proc/{backend.pid}/task").glob("*/children")
|
||||||
|
for pid in children.read_text().split()]
|
||||||
|
session = create("canceltest")
|
||||||
|
handle = call("Start", GLib.Variant("(osa{sv})", (session, "", {})))
|
||||||
|
until(helpers)
|
||||||
|
connection.call_sync(name, handle, "org.freedesktop.portal.Request", "Close", None, None, Gio.DBusCallFlags.NONE, 5000, None)
|
||||||
|
until(lambda: not helpers())
|
||||||
|
print("PASS: public session negotiation, caller authorization, request cancellation", flush=True)
|
||||||
|
if os.environ.get("FERESE_TEST_PORTAL_CONSENT") == "1":
|
||||||
|
session = create("consenttest")
|
||||||
|
print("Select the temporary display and click Share in the nested preview.", flush=True)
|
||||||
|
result = request("Start", GLib.Variant("(osa{sv})", (session, "", {})), 120)
|
||||||
|
reply, fds = connection.call_with_unix_fd_list_sync(name, path, iface, "OpenPipeWireRemote",
|
||||||
|
GLib.Variant("(oa{sv})", (session, {})), GLib.VariantType("(h)"), Gio.DBusCallFlags.NONE, 5000, None, None)
|
||||||
|
fd = fds.get(reply.unpack()[0])
|
||||||
|
try:
|
||||||
|
consume(result["streams"][0][0], fd)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
connection.call_sync(name, session, "org.freedesktop.portal.Session", "Close", None, None, Gio.DBusCallFlags.NONE, 5000, None)
|
||||||
|
print("PASS: native consent and video through restricted portal FD", flush=True)
|
||||||
|
session = create("frontenddeath")
|
||||||
|
call("Start", GLib.Variant("(osa{sv})", (session, "", {})))
|
||||||
|
until(helpers)
|
||||||
|
frontend.terminate()
|
||||||
|
frontend.wait(timeout=5)
|
||||||
|
until(lambda: not helpers())
|
||||||
|
print("PASS: frontend death revokes pending consent", flush=True)
|
||||||
|
sources = json.loads(subprocess.check_output([str(binary), "--sources"], env=env, timeout=10))
|
||||||
|
worker = launch("capture", [str(binary), "--stream", sources[0]["name"], "hidden"], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
|
||||||
|
node = read_node(worker)
|
||||||
|
consume(node)
|
||||||
|
worker.stdin.close()
|
||||||
|
self.assertEqual(worker.wait(timeout=7), 0)
|
||||||
|
print("PASS: frame delivery and control-pipe shutdown", flush=True)
|
||||||
|
# Test revocation with NO consumer: paused captures must also end on lock.
|
||||||
|
worker = launch("paused", [str(binary), "--stream", sources[0]["name"], "hidden"], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
|
||||||
|
read_node(worker)
|
||||||
|
pam = root / "pam"
|
||||||
|
pam.mkdir()
|
||||||
|
(pam / "ferese-lock").write_text("auth required pam_deny.so\naccount required pam_deny.so\n")
|
||||||
|
env["FERESE_LOCK_READY"] = "1"
|
||||||
|
locker = launch("locker", ["bwrap", "--bind", "/", "/", "--dev-bind", "/dev", "/dev", "--ro-bind", str(pam), "/etc/pam.d", "--unshare-user", "--", str(repo / "target/release/ferese-lock")])
|
||||||
|
self.assertEqual(locker.wait(timeout=20), 0)
|
||||||
|
self.assertEqual(worker.wait(timeout=7), 0)
|
||||||
|
print("PASS: paused capture revoked by session lock", flush=True)
|
||||||
|
except BaseException:
|
||||||
|
for log in logs:
|
||||||
|
log.flush()
|
||||||
|
print(Path(log.name).name, Path(log.name).read_text()[-3000:])
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
for child in reversed(processes):
|
||||||
|
try:
|
||||||
|
os.killpg(child.pid, signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
child.wait(timeout=3)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
os.killpg(child.pid, signal.SIGKILL)
|
||||||
|
child.wait()
|
||||||
|
for stream in (child.stdin, child.stdout):
|
||||||
|
if stream is not None:
|
||||||
|
stream.close()
|
||||||
|
for log in logs:
|
||||||
|
log.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -59,3 +59,5 @@ cargo fmt --all --check
|
|||||||
```
|
```
|
||||||
|
|
||||||
The [source repository](https://github.com/ferese-wm/ferese) is the place to explore implementation details and report issues. When reporting a problem, include how to reproduce it, whether you used the nested or DRM backend, and relevant logs.
|
The [source repository](https://github.com/ferese-wm/ferese) is the place to explore implementation details and report issues. When reporting a problem, include how to reproduce it, whether you used the nested or DRM backend, and relevant logs.
|
||||||
|
|
||||||
|
[Screen sharing](screen-sharing.md) covers display consent, PipeWire streams, and recording support.
|
||||||
Reference in new issue
Block a user