Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
511927569b | ||
|
|
b260d57dc4 | ||
|
|
ad70ce5ea9 | ||
|
|
6c275faf28 | ||
|
|
2edd7f0fa8 | ||
|
|
f4a4dd37c9 | ||
|
|
56d0d6c2e2 | ||
|
|
675f25f266 | ||
|
|
e54d625f2a | ||
|
|
f926dbea4e | ||
|
|
24e0d0e799 |
Generated
+331
-43
@@ -242,7 +242,7 @@ dependencies = [
|
||||
"serde_repr",
|
||||
"tokio",
|
||||
"url",
|
||||
"zbus",
|
||||
"zbus 4.4.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -269,6 +269,20 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
|
||||
dependencies = [
|
||||
"async-task",
|
||||
"concurrent-queue",
|
||||
"fastrand",
|
||||
"futures-lite",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.6.0"
|
||||
@@ -711,7 +725,7 @@ dependencies = [
|
||||
"iana-time-zone",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1487,6 +1501,7 @@ checksum = "6caa4eca47cc2358e2c5ae60843a94118e338f87099c6af4170e6e968e8d77cb"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"egui",
|
||||
"egui-winit",
|
||||
"glow",
|
||||
"log",
|
||||
"memoffset",
|
||||
@@ -1893,8 +1908,8 @@ dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"rustversion",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1914,7 +1929,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||
dependencies = [
|
||||
"rustix 1.1.4",
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2385,7 +2400,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2582,7 +2597,7 @@ dependencies = [
|
||||
"socket2",
|
||||
"widestring",
|
||||
"windows-registry",
|
||||
"windows-result",
|
||||
"windows-result 0.4.1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -2816,7 +2831,7 @@ dependencies = [
|
||||
"simd_cesu8",
|
||||
"thiserror 2.0.18",
|
||||
"walkdir",
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2888,6 +2903,19 @@ version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
|
||||
[[package]]
|
||||
name = "ksni"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7ca513d0be42df5edb485af9f44a12b2cb85af773d91c27dc796d1c58b78edc"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"pastey",
|
||||
"serde",
|
||||
"tokio",
|
||||
"zbus 5.15.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kurbo"
|
||||
version = "0.13.1"
|
||||
@@ -2925,7 +2953,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3053,6 +3081,18 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f"
|
||||
|
||||
[[package]]
|
||||
name = "mac-notification-sys"
|
||||
version = "0.6.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"objc2 0.6.4",
|
||||
"objc2-foundation 0.3.2",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -3363,8 +3403,8 @@ dependencies = [
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"web-sys",
|
||||
"windows",
|
||||
"windows-result",
|
||||
"windows 0.62.2",
|
||||
"windows-result 0.4.1",
|
||||
"wmi",
|
||||
]
|
||||
|
||||
@@ -3480,6 +3520,20 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-rust"
|
||||
version = "4.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50ff2e74231b72c832d82982193b417f230945be6bdb5575b251d941d31adb00"
|
||||
dependencies = [
|
||||
"futures-lite",
|
||||
"log",
|
||||
"mac-notification-sys",
|
||||
"serde",
|
||||
"tauri-winrt-notification",
|
||||
"zbus 5.15.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -3972,7 +4026,7 @@ dependencies = [
|
||||
"libc",
|
||||
"redox_syscall 0.5.18",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3981,6 +4035,12 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "1.0.0"
|
||||
@@ -4107,9 +4167,14 @@ dependencies = [
|
||||
"dialoguer",
|
||||
"directories",
|
||||
"eframe",
|
||||
"egui_glow",
|
||||
"glutin",
|
||||
"glutin-winit",
|
||||
"iroh",
|
||||
"iroh-tickets",
|
||||
"ksni",
|
||||
"nix 0.30.1",
|
||||
"notify-rust",
|
||||
"pipewire",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4121,6 +4186,7 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"ureq",
|
||||
"uuid",
|
||||
"winit",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
@@ -4154,7 +4220,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"indexmap",
|
||||
"quick-xml",
|
||||
"quick-xml 0.39.4",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
@@ -4360,6 +4426,15 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.37.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.39.4"
|
||||
@@ -5263,6 +5338,18 @@ version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c"
|
||||
|
||||
[[package]]
|
||||
name = "tauri-winrt-notification"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
|
||||
dependencies = [
|
||||
"quick-xml 0.37.5",
|
||||
"thiserror 2.0.18",
|
||||
"windows 0.61.3",
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -5793,6 +5880,7 @@ checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -6141,7 +6229,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml",
|
||||
"quick-xml 0.39.4",
|
||||
"quote",
|
||||
]
|
||||
|
||||
@@ -6367,16 +6455,38 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.61.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
||||
dependencies = [
|
||||
"windows-collections 0.2.0",
|
||||
"windows-core 0.61.2",
|
||||
"windows-future 0.2.1",
|
||||
"windows-link 0.1.3",
|
||||
"windows-numerics 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core",
|
||||
"windows-future",
|
||||
"windows-numerics",
|
||||
"windows-collections 0.3.2",
|
||||
"windows-core 0.62.2",
|
||||
"windows-future 0.3.2",
|
||||
"windows-numerics 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
|
||||
dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6385,7 +6495,20 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6396,9 +6519,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
|
||||
dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
"windows-link 0.1.3",
|
||||
"windows-threading 0.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6407,9 +6541,9 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
"windows-threading",
|
||||
"windows-core 0.62.2",
|
||||
"windows-link 0.2.1",
|
||||
"windows-threading 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6434,20 +6568,36 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
|
||||
dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
"windows-core 0.62.2",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6456,9 +6606,18 @@ version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6467,7 +6626,16 @@ version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6476,7 +6644,7 @@ version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6512,7 +6680,7 @@ version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6546,13 +6714,31 @@ dependencies = [
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-version"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6828,8 +7014,8 @@ dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"windows",
|
||||
"windows-core",
|
||||
"windows 0.62.2",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6990,9 +7176,45 @@ dependencies = [
|
||||
"uds_windows",
|
||||
"windows-sys 0.52.0",
|
||||
"xdg-home",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
"zbus_macros 4.4.0",
|
||||
"zbus_names 3.0.0",
|
||||
"zvariant 4.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-executor",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-process",
|
||||
"async-recursion",
|
||||
"async-task",
|
||||
"async-trait",
|
||||
"blocking",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
"libc",
|
||||
"ordered-stream",
|
||||
"rustix 1.1.4",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow",
|
||||
"zbus_macros 5.15.0",
|
||||
"zbus_names 4.3.2",
|
||||
"zvariant 5.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7005,7 +7227,22 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"zvariant_utils",
|
||||
"zvariant_utils 2.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"zbus_names 4.3.2",
|
||||
"zvariant 5.11.0",
|
||||
"zvariant_utils 3.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7016,7 +7253,18 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"zvariant",
|
||||
"zvariant 4.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_names"
|
||||
version = "4.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow",
|
||||
"zvariant 5.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7145,7 +7393,21 @@ dependencies = [
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"url",
|
||||
"zvariant_derive",
|
||||
"zvariant_derive 4.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"winnow",
|
||||
"zvariant_derive 5.11.0",
|
||||
"zvariant_utils 3.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7158,7 +7420,20 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"zvariant_utils",
|
||||
"zvariant_utils 2.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "5.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"zvariant_utils 3.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7171,3 +7446,16 @@ dependencies = [
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "3.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
+22
-1
@@ -34,6 +34,27 @@ ureq = { version = "3", default-features = false, features = ["rustls"] }
|
||||
toml = "1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||
eframe = { version = "0.34.2", default-features = false, features = ["glow", "default_fonts", "wayland", "x11"], optional = true }
|
||||
# Desktop notifications on viewer join/leave. Default features give the
|
||||
# pure-Rust zbus backend (no system libdbus, no image crate).
|
||||
notify-rust = { version = "4", optional = true }
|
||||
# System-tray icon (StatusNotifierItem over D-Bus). Pure-Rust, riding the same
|
||||
# zbus stack notify-rust already pulls — no GTK, no libappindicator/C libdbus.
|
||||
ksni = { version = "0.3", optional = true }
|
||||
# Hand-rolled windowing stack for the GUI (replaces eframe::run_native) so we
|
||||
# can drop the OS window on "hide to tray" — the only way to truly hide a
|
||||
# toplevel on Wayland — and recreate it on Show. All of these are already pulled
|
||||
# in transitively by eframe; making them direct adds no new crates to vet.
|
||||
# eframe is kept for its egui re-export + icon_data PNG decoder. egui_glow needs
|
||||
# its (non-default) `winit` feature for the `EguiGlow` integration type; eframe
|
||||
# pulls egui_glow but without that feature, so we enable it here.
|
||||
egui_glow = { version = "0.34.2", default-features = false, features = ["winit", "wayland", "x11"], optional = true }
|
||||
# winit's default set minus `wayland-csd-adwaita`: KWin (and most desktop
|
||||
# compositors) draw server-side decorations, and eframe never enabled CSD
|
||||
# either, so dropping it keeps the dependency tree identical to before (no
|
||||
# sctk-adwaita / tiny-skia / ttf-parser pulled in just for a fallback titlebar).
|
||||
winit = { version = "0.30", default-features = false, features = ["rwh_06", "x11", "wayland", "wayland-dlopen"], optional = true }
|
||||
glutin = { version = "0.32", optional = true }
|
||||
glutin-winit = { version = "0.5", optional = true }
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
@@ -43,4 +64,4 @@ strip = "symbols"
|
||||
[features]
|
||||
# Opt-in graphical front-end (pixelpass --gui). Default-off so the headless
|
||||
# build never pulls the GUI toolkit tree.
|
||||
gui = ["dep:eframe"]
|
||||
gui = ["dep:eframe", "dep:notify-rust", "dep:ksni", "dep:egui_glow", "dep:winit", "dep:glutin", "dep:glutin-winit"]
|
||||
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 mollusk
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 mollusk
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -83,8 +83,15 @@ pixelpass --gui
|
||||
```
|
||||
|
||||
Host: pick quality / max-viewers / options, click **Start hosting**, and the
|
||||
share code appears with a copy button alongside a live viewer count. View:
|
||||
paste a code, pick mpv or VLC, click **Connect** and the player launches.
|
||||
share code appears with a copy button. Connected viewers are listed with a
|
||||
**Kick** button each, and a desktop notification fires as they join or leave.
|
||||
View: paste a code, pick mpv or VLC, click **Connect** and the player launches.
|
||||
|
||||
A system-tray icon shows current status. **Settings → "Keep running in the
|
||||
tray when I close the window"** (off by default) makes the close button hide
|
||||
the window — truly, by dropping it — while any active stream keeps running in
|
||||
the child; reopen it from the tray. (Plain close still quits when the option is
|
||||
off, or when no system tray is present.)
|
||||
|
||||
The window is a thin driver — it runs the same headless `pixelpass` as a
|
||||
child process and reads its event stream, so the GUI is purely additive and
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
@@ -0,0 +1,12 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=pixelpass
|
||||
GenericName=Screen Sharing
|
||||
Comment=P2P screen sharing over iroh — no port forwarding, no signup
|
||||
Exec=pixelpass --gui
|
||||
Icon=pixelpass
|
||||
StartupWMClass=pixelpass
|
||||
Terminal=false
|
||||
Categories=Network;RemoteAccess;
|
||||
Keywords=screen;share;sharing;remote;p2p;iroh;cast;
|
||||
StartupNotify=true
|
||||
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
|
||||
<title>pixelpass</title>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="256" y2="256" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#4338ca"/>
|
||||
<stop offset="1" stop-color="#7c3aed"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="8" y="8" width="240" height="240" rx="56" fill="url(#bg)"/>
|
||||
<!-- pixel stream: squares fading cyan -> white, "passed" toward the arrow -->
|
||||
<rect x="50.25" y="181.29" width="16" height="16" rx="3.2" fill="#2dd5ef"/>
|
||||
<rect x="67" y="124.44" width="20" height="20" rx="4" fill="#62dff2"/>
|
||||
<rect x="96.25" y="82.09" width="24" height="24" rx="4.8" fill="#98e8f6"/>
|
||||
<rect x="134.04" y="55.77" width="28" height="28" rx="5.6" fill="#c9f1f9"/>
|
||||
<path d="M 225.5 57.5 L 183.1 85.8 L 176.9 42.2 Z" fill="#f8fafc"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 875 B |
@@ -0,0 +1,6 @@
|
||||
# makepkg build artifacts
|
||||
src/
|
||||
pkg/
|
||||
/pixelpass/
|
||||
*.pkg.tar.*
|
||||
*.log
|
||||
@@ -0,0 +1,63 @@
|
||||
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
||||
#
|
||||
# Local versioned package, built from the local git repo on `main`.
|
||||
# For a tagged release, switch the source fragment to `#tag=v0.1.0`.
|
||||
|
||||
pkgname=pixelpass
|
||||
pkgver=0.1.0
|
||||
pkgrel=1
|
||||
pkgdesc='P2P screen sharing over iroh — no port forwarding, no signup'
|
||||
arch=('x86_64')
|
||||
url='file:///home/mollusk/git/butter/pixelpass'
|
||||
license=('MIT' 'Apache-2.0')
|
||||
depends=(
|
||||
'gstreamer' # gst-launch-1.0 / gst-inspect-1.0
|
||||
'gst-plugins-base' # videoscale (quality-preset downscale)
|
||||
'gst-plugins-good' # ximagesrc (X11 capture) + pulsesrc
|
||||
'gst-plugins-bad' # h264parse, mpegtsmux, aacparse
|
||||
'gst-libav' # avenc_aac (audio encode)
|
||||
'gst-plugin-va' # vah264enc (default hardware H.264 encoder)
|
||||
'libpulse' # pactl (audio routing / device control)
|
||||
'hicolor-icon-theme' # owns the scalable icon dir
|
||||
'libglvnd' # libGL for the egui (glow) GUI
|
||||
'libxkbcommon' # GUI keyboard handling (winit)
|
||||
'wayland' # GUI Wayland backend libs
|
||||
)
|
||||
optdepends=(
|
||||
'mpv: recommended stream viewer (the GUI launches mpv)'
|
||||
'vlc: alternative stream viewer'
|
||||
'gst-plugins-ugly: software x264 encoding for `pixelpass --no-hwencode`'
|
||||
'gst-plugin-pipewire: screen capture on Wayland sessions'
|
||||
'xorg-xwininfo: share a single window on X11 (`pixelpass --window`)'
|
||||
)
|
||||
makedepends=('cargo' 'git')
|
||||
options=('!lto')
|
||||
_branch='main'
|
||||
source=("$pkgname::git+file:///home/mollusk/git/butter/pixelpass#branch=$_branch")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
prepare() {
|
||||
cd "$srcdir/$pkgname"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "$srcdir/$pkgname"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
# --features gui so the .desktop launcher (pixelpass --gui) works.
|
||||
cargo build --frozen --release --features gui
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$srcdir/$pkgname"
|
||||
install -Dm0755 "target/release/$pkgname" "$pkgdir/usr/bin/$pkgname"
|
||||
install -Dm0644 assets/pixelpass.desktop \
|
||||
"$pkgdir/usr/share/applications/$pkgname.desktop"
|
||||
install -Dm0644 assets/pixelpass.svg \
|
||||
"$pkgdir/usr/share/icons/hicolor/scalable/apps/$pkgname.svg"
|
||||
install -Dm0644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||
install -Dm0644 LICENSE-MIT "$pkgdir/usr/share/licenses/$pkgname/LICENSE-MIT"
|
||||
install -Dm0644 LICENSE-APACHE "$pkgdir/usr/share/licenses/$pkgname/LICENSE-APACHE"
|
||||
}
|
||||
+16
-8
@@ -1,8 +1,7 @@
|
||||
//! Persistent user-level config at `~/.config/pixelpass/config.toml`.
|
||||
//!
|
||||
//! Right now this only tracks the bandwidth pre-flight result. Future
|
||||
//! preferences (default player, default bitrate, etc.) can hang off the
|
||||
//! same file under their own `[section]`.
|
||||
//! It tracks the bandwidth pre-flight result and the GUI's preferences.
|
||||
//! Further settings can hang off the same file under their own `[section]`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -16,6 +15,18 @@ use std::path::PathBuf;
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub bandwidth: BandwidthEntry,
|
||||
#[serde(default)]
|
||||
pub gui: GuiSettings,
|
||||
}
|
||||
|
||||
/// Preferences for the `pixelpass --gui` front-end.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct GuiSettings {
|
||||
/// When true, the window's close button hides the app to the system tray
|
||||
/// (keeping any live stream running) instead of quitting. Defaults to
|
||||
/// false — closing quits, which is what people expect.
|
||||
#[serde(default)]
|
||||
pub close_to_tray: bool,
|
||||
}
|
||||
|
||||
/// Result of the first-run upstream measurement.
|
||||
@@ -37,18 +48,15 @@ pub struct BandwidthEntry {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[derive(Default)]
|
||||
pub enum BandwidthStatus {
|
||||
#[default]
|
||||
Unmeasured,
|
||||
Measured,
|
||||
Skipped,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl Default for BandwidthStatus {
|
||||
fn default() -> Self {
|
||||
Self::Unmeasured
|
||||
}
|
||||
}
|
||||
|
||||
fn default_status() -> BandwidthStatus {
|
||||
BandwidthStatus::Unmeasured
|
||||
|
||||
+11
-3
@@ -22,7 +22,11 @@ pub fn set_json(enabled: bool) {
|
||||
JSON_ENABLED.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn json_enabled() -> bool {
|
||||
/// Whether the JSON event stream is on — i.e. we're being driven by a
|
||||
/// machine front-end (the `--gui` shell-out) rather than a human terminal.
|
||||
/// Gates features that only make sense under that front-end, like the
|
||||
/// stdin command channel the host reads `kick` requests from.
|
||||
pub fn json_enabled() -> bool {
|
||||
JSON_ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
@@ -42,8 +46,12 @@ pub enum Event<'a> {
|
||||
max_viewers: u32,
|
||||
max_viewers_source: &'a str,
|
||||
},
|
||||
/// Active viewer count changed.
|
||||
ViewerCount { active: u32, max: u32 },
|
||||
/// A viewer joined. `id` is the viewer's endpoint id; `active` is the new
|
||||
/// total after the join.
|
||||
ViewerJoined { id: &'a str, active: u32, max: u32 },
|
||||
/// A viewer left — disconnected on their own or kicked by the host. `id`
|
||||
/// is the viewer's endpoint id; `active` is the new total after.
|
||||
ViewerLeft { id: &'a str, active: u32, max: u32 },
|
||||
/// Capture pipeline lifecycle (spawned on first viewer, torn down on last).
|
||||
Capture { state: CaptureState },
|
||||
/// A viewer was turned away (host full, or capture spawn failed).
|
||||
|
||||
+82
-36
@@ -6,17 +6,18 @@
|
||||
//! egui app drains each frame. stderr is captured into a small ring so a
|
||||
//! failed launch (e.g. a missing gst plugin) can be surfaced in the window.
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use eframe::egui;
|
||||
use nix::sys::signal::{Signal, kill};
|
||||
use nix::unistd::Pid;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::Waker;
|
||||
|
||||
/// One parsed event from the child's stdout. Owned mirror of
|
||||
/// [`crate::common::output::Event`] (which borrows for emit); kept separate so
|
||||
/// the wire format and the parser can evolve independently.
|
||||
@@ -35,7 +36,13 @@ pub enum ChildEvent {
|
||||
max_viewers: u32,
|
||||
max_viewers_source: String,
|
||||
},
|
||||
ViewerCount {
|
||||
ViewerJoined {
|
||||
id: String,
|
||||
active: u32,
|
||||
max: u32,
|
||||
},
|
||||
ViewerLeft {
|
||||
id: String,
|
||||
active: u32,
|
||||
max: u32,
|
||||
},
|
||||
@@ -60,23 +67,35 @@ pub enum CaptureState {
|
||||
const STDERR_TAIL_MAX: usize = 60;
|
||||
|
||||
pub struct ChildProc {
|
||||
child: Child,
|
||||
/// `Some` while the child is owned here; `Drop` takes it to hand off to a
|
||||
/// detached reaper thread (see the `Drop` impl).
|
||||
child: Option<Child>,
|
||||
pub rx: Receiver<ChildEvent>,
|
||||
stderr_tail: Arc<Mutex<Vec<String>>>,
|
||||
/// Write end of the child's stdin, for the line-based command channel
|
||||
/// (see [`ChildProc::send_command`]). `None` once it's been closed.
|
||||
stdin: Option<ChildStdin>,
|
||||
}
|
||||
|
||||
impl ChildProc {
|
||||
/// Spawn `pixelpass <args>` as a child, wiring up the event reader. `ctx`
|
||||
/// is repainted whenever an event arrives so the UI updates live.
|
||||
pub fn spawn(args: &[String], ctx: egui::Context) -> std::io::Result<Self> {
|
||||
/// Spawn `pixelpass <args>` as a child, wiring up the event reader. The
|
||||
/// `waker` is pinged whenever an event arrives so the UI thread wakes to
|
||||
/// drain it — this wakes the winit event loop directly (via an
|
||||
/// `EventLoopProxy`), so it works even when the window is hidden to the tray
|
||||
/// and no frames are running (egui's own repaint callback would not fire
|
||||
/// repeatedly in that idle state — see [`super::Waker`]).
|
||||
pub fn spawn(args: &[String], waker: Waker) -> std::io::Result<Self> {
|
||||
let exe = std::env::current_exe()?;
|
||||
let mut child = Command::new(exe)
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
// Piped so we can send line commands (e.g. `kick <id>`); the host
|
||||
// only reads it when driven this way (`--output json`).
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
let stdin = child.stdin.take();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let stdout = child.stdout.take().expect("stdout piped");
|
||||
std::thread::spawn(move || {
|
||||
@@ -90,9 +109,14 @@ impl ChildProc {
|
||||
if tx.send(ev).is_err() {
|
||||
break; // app gone
|
||||
}
|
||||
ctx.request_repaint();
|
||||
waker.wake();
|
||||
}
|
||||
}
|
||||
// stdout closed → the child has exited (player closed, connection
|
||||
// ended, or a failed launch). Wake once more so the UI reaps it and
|
||||
// clears the "running" view, even if no final event was emitted and
|
||||
// the window is hidden to the tray.
|
||||
waker.wake();
|
||||
});
|
||||
|
||||
let stderr_tail = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
@@ -111,46 +135,64 @@ impl ChildProc {
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
child,
|
||||
child: Some(child),
|
||||
rx,
|
||||
stderr_tail,
|
||||
stdin,
|
||||
})
|
||||
}
|
||||
|
||||
/// Send one newline-terminated command to the child over its stdin (the
|
||||
/// host parses these as `kick <endpoint-id>`). Best-effort: a closed pipe
|
||||
/// (child already gone) just drops the command.
|
||||
pub fn send_command(&mut self, cmd: &str) {
|
||||
let Some(stdin) = self.stdin.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = writeln!(stdin, "{cmd}") {
|
||||
tracing::warn!("failed to send command to host child: {e}");
|
||||
self.stdin = None; // pipe is dead; stop trying
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the child is still running.
|
||||
pub fn is_alive(&mut self) -> bool {
|
||||
matches!(self.child.try_wait(), Ok(None))
|
||||
matches!(self.child.as_mut().map(Child::try_wait), Some(Ok(None)))
|
||||
}
|
||||
|
||||
/// The last captured stderr lines, joined — for error display.
|
||||
pub fn stderr_tail(&self) -> String {
|
||||
self.stderr_tail.lock().unwrap().join("\n")
|
||||
}
|
||||
|
||||
/// Gracefully stop the child: SIGINT (so the host runs its ctrl-c teardown
|
||||
/// — tears down capture, closes the endpoint), with a ~2 s grace period
|
||||
/// before a hard kill. Idempotent.
|
||||
pub fn stop(&mut self) {
|
||||
if matches!(self.child.try_wait(), Ok(Some(_))) {
|
||||
return; // already exited
|
||||
}
|
||||
let _ = kill(Pid::from_raw(self.child.id() as i32), Signal::SIGINT);
|
||||
for _ in 0..40 {
|
||||
if matches!(self.child.try_wait(), Ok(Some(_))) {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ChildProc {
|
||||
fn drop(&mut self) {
|
||||
// Closing the window (dropping the app, hence the session) must not
|
||||
// orphan a live host child streaming to viewers.
|
||||
self.stop();
|
||||
// Leaving a host/viewer screen, or closing the window, must not orphan
|
||||
// a live child — but it must also not *block*. eframe runs this drop
|
||||
// synchronously while it destroys the window, so a grace-period wait
|
||||
// here freezes the window mid-close: the first click looks like it did
|
||||
// nothing (the stream just drops) and the window only goes away on a
|
||||
// second click. So SIGINT now — synchronously, so the host always gets
|
||||
// its ctrl-c teardown (capture down, endpoint closed) even if we exit
|
||||
// right after — then reap on a detached thread instead of waiting.
|
||||
let Some(mut child) = self.child.take() else {
|
||||
return;
|
||||
};
|
||||
if matches!(child.try_wait(), Ok(Some(_))) {
|
||||
return; // already exited; nothing to signal or reap
|
||||
}
|
||||
let _ = kill(Pid::from_raw(child.id() as i32), Signal::SIGINT);
|
||||
std::thread::spawn(move || {
|
||||
for _ in 0..40 {
|
||||
if matches!(child.try_wait(), Ok(Some(_))) {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,10 +247,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewer_count_round_trips() {
|
||||
fn viewer_join_leave_round_trip() {
|
||||
assert!(matches!(
|
||||
parse(Event::ViewerCount { active: 2, max: 4 }),
|
||||
ChildEvent::ViewerCount { active: 2, max: 4 }
|
||||
parse(Event::ViewerJoined { id: "nodeXYZ", active: 2, max: 4 }),
|
||||
ChildEvent::ViewerJoined { id, active: 2, max: 4 } if id == "nodeXYZ"
|
||||
));
|
||||
assert!(matches!(
|
||||
parse(Event::ViewerLeft { id: "nodeXYZ", active: 1, max: 4 }),
|
||||
ChildEvent::ViewerLeft { id, active: 1, max: 4 } if id == "nodeXYZ"
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
+742
-48
@@ -8,30 +8,599 @@
|
||||
//! child's JSON event stream (see [`crate::common::output`]) to drive what it
|
||||
//! shows. That keeps the fragile capture stack sealed in a separate process:
|
||||
//! the GUI can be closed or crash without taking a live stream down.
|
||||
//!
|
||||
//! ## Windowing: why we hand-roll the loop instead of using `eframe`
|
||||
//!
|
||||
//! The "keep running in the tray" feature needs to truly *hide* the window
|
||||
//! while a stream keeps running in the child. On Wayland there is no
|
||||
//! unmap-but-keep-alive request in xdg-shell, so winit's `set_visible(false)`
|
||||
//! is a deliberate no-op there — the only way to make a toplevel vanish is to
|
||||
//! **destroy its surface** and recreate it later. `eframe::run_native` owns the
|
||||
//! one window it will never let you drop, so we replace it with a hand-rolled
|
||||
//! [`winit::application::ApplicationHandler`] + `glutin` + [`egui_glow`] loop:
|
||||
//!
|
||||
//! * **Hide to tray** ([`Gfx::hide`]) parks the GL context as *not-current*
|
||||
//! and drops the [`winit::window::Window`] + its GL surface — the Wayland
|
||||
//! surface is genuinely gone, natively, on both Wayland and X11.
|
||||
//! * **Show** ([`Gfx::show`], on a tray click) recreates the window + surface
|
||||
//! and makes the parked context current again.
|
||||
//!
|
||||
//! The GL context, `glutin` display/config, and the `egui_glow` painter (with
|
||||
//! its uploaded font/texture atlas) and `egui-winit` state (with its clipboard
|
||||
//! connection) are **kept** across the cycle — only the OS window and its
|
||||
//! surface churn. That preserves the hard-won Wayland clipboard integration and
|
||||
//! avoids re-uploading textures on every show.
|
||||
//!
|
||||
//! Wakeups are routed through winit's [`winit::event_loop::EventLoopProxy`]
|
||||
//! (see [`Waker`] and [`tray`]) rather than egui's repaint callback, because a
|
||||
//! child event or tray click must wake the loop even while the window is
|
||||
//! dropped and no egui frame is running.
|
||||
|
||||
mod child;
|
||||
mod tray;
|
||||
|
||||
use std::num::NonZeroU32;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use eframe::egui;
|
||||
use egui_glow::EguiGlow;
|
||||
use egui_glow::egui_winit;
|
||||
use egui_glow::glow::{self, HasContext as _};
|
||||
use glutin::config::{Config, ConfigTemplateBuilder};
|
||||
use glutin::context::{
|
||||
ContextApi, ContextAttributesBuilder, NotCurrentContext, NotCurrentGlContext as _,
|
||||
PossiblyCurrentContext, PossiblyCurrentGlContext as _,
|
||||
};
|
||||
use glutin::display::{Display, GetGlDisplay as _, GlDisplay as _};
|
||||
use glutin::surface::{
|
||||
GlSurface as _, Surface, SurfaceAttributesBuilder, SwapInterval, WindowSurface,
|
||||
};
|
||||
use glutin_winit::{ApiPreference, DisplayBuilder};
|
||||
use winit::application::ApplicationHandler;
|
||||
use winit::event::WindowEvent;
|
||||
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy};
|
||||
use winit::raw_window_handle::HasWindowHandle as _;
|
||||
use winit::window::{Window, WindowAttributes, WindowId};
|
||||
|
||||
use self::child::{ChildEvent, ChildProc};
|
||||
use self::tray::{TrayAction, TrayHandle, TrayStatus};
|
||||
|
||||
/// Launch the GUI event loop. Blocks until the window is closed. Runs on the
|
||||
/// main thread (a winit requirement), which is where `main` calls it from.
|
||||
/// Initial / minimum window size, in logical points.
|
||||
const INNER_SIZE: [f64; 2] = [520.0, 480.0];
|
||||
const MIN_INNER_SIZE: [f64; 2] = [460.0, 380.0];
|
||||
|
||||
/// Events delivered to the winit loop from off the UI thread (or from egui).
|
||||
pub enum UserEvent {
|
||||
/// Something changed off the UI thread — a headless child emitted a JSON
|
||||
/// event or exited (see [`Waker`]). Drain it on the next tick, and repaint
|
||||
/// if a window is currently shown.
|
||||
Wake,
|
||||
/// A system-tray icon/menu action (see [`tray`]).
|
||||
Tray(TrayAction),
|
||||
}
|
||||
|
||||
/// A cheap, cloneable handle that wakes the winit event loop from any thread.
|
||||
///
|
||||
/// Used by the headless-child reader threads: pinging this wakes the loop
|
||||
/// **even when the window has been dropped to the tray** (no egui frame is
|
||||
/// running then, so egui's own repaint callback would go quiet after the first
|
||||
/// request — see the egui repaint bookkeeping). That's what keeps join/leave
|
||||
/// desktop notifications and the tray tooltip live while hidden.
|
||||
#[derive(Clone)]
|
||||
pub struct Waker {
|
||||
proxy: EventLoopProxy<UserEvent>,
|
||||
}
|
||||
|
||||
impl Waker {
|
||||
/// Wake the loop. A send error only means the loop has already exited, in
|
||||
/// which case there is nothing left to wake.
|
||||
pub fn wake(&self) {
|
||||
let _ = self.proxy.send_event(UserEvent::Wake);
|
||||
}
|
||||
}
|
||||
|
||||
/// Window attributes used for both the first window and every recreated one, so
|
||||
/// a window restored from the tray is identical to the original.
|
||||
fn window_attributes() -> WindowAttributes {
|
||||
use winit::dpi::LogicalSize;
|
||||
// Wayland sources the titlebar/taskbar icon from the .desktop file matched
|
||||
// by this app_id (NOT from `with_window_icon`, which Wayland ignores) — it
|
||||
// must equal the installed pixelpass.desktop. `with_name(general, instance)`
|
||||
// sets the Wayland app_id to `general`.
|
||||
use winit::platform::wayland::WindowAttributesExtWayland as _;
|
||||
|
||||
let mut attrs = WindowAttributes::default()
|
||||
.with_title("PixelPass")
|
||||
.with_inner_size(LogicalSize::new(INNER_SIZE[0], INNER_SIZE[1]))
|
||||
.with_min_inner_size(LogicalSize::new(MIN_INNER_SIZE[0], MIN_INNER_SIZE[1]))
|
||||
// Stay unmapped until the first frame is painted, to avoid a flash of an
|
||||
// empty window (on Wayland the surface only maps on the first swap
|
||||
// anyway; this matters mostly for X11).
|
||||
.with_visible(false)
|
||||
.with_name("pixelpass", "pixelpass");
|
||||
|
||||
// X11 titlebar/taskbar icon (`_NET_WM_ICON`), embedded at compile time so
|
||||
// the binary stays self-contained. Wayland ignores it (uses app_id above);
|
||||
// X11 falls back to it. Non-fatal on failure.
|
||||
match eframe::icon_data::from_png_bytes(include_bytes!("../../assets/pixelpass-256.png")) {
|
||||
Ok(icon) => match winit::window::Icon::from_rgba(icon.rgba, icon.width, icon.height) {
|
||||
Ok(winit_icon) => attrs = attrs.with_window_icon(Some(winit_icon)),
|
||||
Err(e) => tracing::warn!("could not build X11 window icon: {e}"),
|
||||
},
|
||||
Err(e) => tracing::warn!("could not load embedded window icon: {e}"),
|
||||
}
|
||||
attrs
|
||||
}
|
||||
|
||||
/// Build a GL surface for `window` using the established display + config.
|
||||
fn create_surface(
|
||||
display: &Display,
|
||||
config: &Config,
|
||||
window: &Window,
|
||||
) -> anyhow::Result<Surface<WindowSurface>> {
|
||||
let (w, h): (u32, u32) = window.inner_size().into();
|
||||
let attrs = SurfaceAttributesBuilder::<WindowSurface>::new().build(
|
||||
window.window_handle()?.as_raw(),
|
||||
NonZeroU32::new(w).unwrap_or(NonZeroU32::MIN),
|
||||
NonZeroU32::new(h).unwrap_or(NonZeroU32::MIN),
|
||||
);
|
||||
// SAFETY: `window` outlives the surface — both live in the same `WinState`
|
||||
// and are dropped together — so the raw handle stays valid for the
|
||||
// surface's lifetime.
|
||||
let surface = unsafe { display.create_window_surface(config, &attrs)? };
|
||||
Ok(surface)
|
||||
}
|
||||
|
||||
/// GL + egui state that persists for the whole GUI session. Only the OS window
|
||||
/// and its surface are dropped/recreated on hide/show (see the module docs);
|
||||
/// everything here outlives the cycle.
|
||||
struct Gfx {
|
||||
gl: Arc<glow::Context>,
|
||||
gl_display: Display,
|
||||
gl_config: Config,
|
||||
egui_glow: EguiGlow,
|
||||
/// egui→window command state (title changes etc.), threaded through
|
||||
/// [`egui_winit::process_viewport_commands`] each frame.
|
||||
viewport_info: egui::ViewportInfo,
|
||||
win: WinState,
|
||||
}
|
||||
|
||||
/// Whether the window is currently mapped. The GL *context* is preserved in
|
||||
/// both states; `Between` is only a momentary placeholder during a transition.
|
||||
// Exactly one `WinState` exists (it's a field of the single `Gfx`), never a
|
||||
// collection of them, so the inter-variant size gap costs ~250 idle bytes once
|
||||
// — not worth boxing the window/surface that every frame touches.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum WinState {
|
||||
Shown {
|
||||
window: Window,
|
||||
surface: Surface<WindowSurface>,
|
||||
context: PossiblyCurrentContext,
|
||||
},
|
||||
Hidden {
|
||||
context: NotCurrentContext,
|
||||
},
|
||||
/// Transient placeholder held only inside [`Gfx::hide`] / [`Gfx::show`].
|
||||
Between,
|
||||
}
|
||||
|
||||
impl Gfx {
|
||||
/// Full first-time initialization: pick a GL config, create the window,
|
||||
/// context, and surface, make the context current, and build the egui glow
|
||||
/// integration. Runs once, from `resumed`.
|
||||
fn create(event_loop: &ActiveEventLoop) -> anyhow::Result<Self> {
|
||||
let template = ConfigTemplateBuilder::new()
|
||||
.prefer_hardware_accelerated(None)
|
||||
.with_depth_size(0)
|
||||
.with_stencil_size(0)
|
||||
.with_transparency(false);
|
||||
|
||||
let (maybe_window, gl_config) = DisplayBuilder::new()
|
||||
.with_preference(ApiPreference::FallbackEgl)
|
||||
.with_window_attributes(Some(window_attributes()))
|
||||
.build(event_loop, template, |mut configs| {
|
||||
configs.next().expect("no GL config matched")
|
||||
})
|
||||
.map_err(|e| anyhow::anyhow!("failed to choose a GL config: {e}"))?;
|
||||
|
||||
let gl_display = gl_config.display();
|
||||
|
||||
// On EGL/Wayland the window is built during `build`; on GLX it is
|
||||
// deferred until the visual is known, so finalize it here.
|
||||
let window = match maybe_window {
|
||||
Some(w) => w,
|
||||
None => glutin_winit::finalize_window(event_loop, window_attributes(), &gl_config)?,
|
||||
};
|
||||
|
||||
let raw = window.window_handle()?.as_raw();
|
||||
let ctx_attrs = ContextAttributesBuilder::new().build(Some(raw));
|
||||
// Fall back to a GLES context if a core GL context can't be created.
|
||||
let gles_attrs = ContextAttributesBuilder::new()
|
||||
.with_context_api(ContextApi::Gles(None))
|
||||
.build(Some(raw));
|
||||
// SAFETY: `raw` comes from `window`, which lives at least as long as the
|
||||
// context (both are owned by this `Gfx`).
|
||||
let not_current = unsafe {
|
||||
gl_display
|
||||
.create_context(&gl_config, &ctx_attrs)
|
||||
.or_else(|_| gl_display.create_context(&gl_config, &gles_attrs))
|
||||
}
|
||||
.map_err(|e| anyhow::anyhow!("failed to create a GL context: {e}"))?;
|
||||
|
||||
let surface = create_surface(&gl_display, &gl_config, &window)?;
|
||||
let context = not_current
|
||||
.make_current(&surface)
|
||||
.map_err(|e| anyhow::anyhow!("failed to make the GL context current: {e}"))?;
|
||||
// Vsync, so the frame loop self-throttles and idles cheaply.
|
||||
let _ = surface.set_swap_interval(&context, SwapInterval::Wait(NonZeroU32::MIN));
|
||||
|
||||
// SAFETY: the loader is only called while `gl_display` is alive, which
|
||||
// outlives the returned context.
|
||||
let gl = Arc::new(unsafe {
|
||||
glow::Context::from_loader_function(|s| match std::ffi::CString::new(s) {
|
||||
Ok(name) => gl_display.get_proc_address(name.as_c_str()),
|
||||
Err(_) => std::ptr::null(),
|
||||
})
|
||||
});
|
||||
|
||||
let egui_glow = EguiGlow::new(event_loop, gl.clone(), None, None, true);
|
||||
|
||||
window.set_visible(true);
|
||||
window.request_redraw();
|
||||
|
||||
Ok(Self {
|
||||
gl,
|
||||
gl_display,
|
||||
gl_config,
|
||||
egui_glow,
|
||||
viewport_info: egui::ViewportInfo::default(),
|
||||
win: WinState::Shown {
|
||||
window,
|
||||
surface,
|
||||
context,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn shown_window(&self) -> Option<&Window> {
|
||||
match &self.win {
|
||||
WinState::Shown { window, .. } => Some(window),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hide to tray: park the context as not-current and drop the window +
|
||||
/// surface. The Wayland surface is genuinely destroyed (the only way to
|
||||
/// hide a toplevel there).
|
||||
fn hide(&mut self) {
|
||||
match std::mem::replace(&mut self.win, WinState::Between) {
|
||||
WinState::Shown {
|
||||
window,
|
||||
surface,
|
||||
context,
|
||||
} => match context.make_not_current() {
|
||||
Ok(not_current) => {
|
||||
// Order matters: the context must be made not-current before
|
||||
// its surface is dropped.
|
||||
drop(surface);
|
||||
drop(window);
|
||||
self.win = WinState::Hidden {
|
||||
context: not_current,
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("hide-to-tray: make_not_current failed: {e}");
|
||||
// Leave `Between` (window already gone); a later Show will
|
||||
// log that there's no parked context and the user can quit
|
||||
// from the tray. This effectively never happens.
|
||||
}
|
||||
},
|
||||
other => self.win = other, // already hidden / transient
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore from the tray: recreate the window + surface and make the parked
|
||||
/// context current again.
|
||||
fn show(&mut self, event_loop: &ActiveEventLoop) -> anyhow::Result<()> {
|
||||
match std::mem::replace(&mut self.win, WinState::Between) {
|
||||
WinState::Hidden { context } => {
|
||||
let window =
|
||||
glutin_winit::finalize_window(event_loop, window_attributes(), &self.gl_config)?;
|
||||
let surface = create_surface(&self.gl_display, &self.gl_config, &window)?;
|
||||
let context = context
|
||||
.make_current(&surface)
|
||||
.map_err(|e| anyhow::anyhow!("failed to make the GL context current: {e}"))?;
|
||||
let _ = surface.set_swap_interval(&context, SwapInterval::Wait(NonZeroU32::MIN));
|
||||
window.set_visible(true);
|
||||
window.request_redraw();
|
||||
self.win = WinState::Shown {
|
||||
window,
|
||||
surface,
|
||||
context,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
WinState::Shown {
|
||||
window,
|
||||
surface,
|
||||
context,
|
||||
} => {
|
||||
// Already shown — just nudge a repaint and restore.
|
||||
window.request_redraw();
|
||||
self.win = WinState::Shown {
|
||||
window,
|
||||
surface,
|
||||
context,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
WinState::Between => {
|
||||
anyhow::bail!("no parked GL context to restore the window from")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one egui frame and paint it. Returns how long egui wants to wait
|
||||
/// before the next repaint (`Duration::MAX` == idle, sleep until an event).
|
||||
/// No-op returning `MAX` if the window isn't currently shown.
|
||||
fn paint_frame(&mut self, run_ui: impl FnMut(&mut egui::Ui)) -> Duration {
|
||||
let WinState::Shown {
|
||||
window,
|
||||
surface,
|
||||
context,
|
||||
} = &self.win
|
||||
else {
|
||||
return Duration::MAX;
|
||||
};
|
||||
let eg = &mut self.egui_glow;
|
||||
|
||||
let raw_input = eg.egui_winit.take_egui_input(window);
|
||||
let egui::FullOutput {
|
||||
platform_output,
|
||||
textures_delta,
|
||||
shapes,
|
||||
pixels_per_point,
|
||||
viewport_output,
|
||||
} = eg.egui_ctx.run_ui(raw_input, run_ui);
|
||||
|
||||
eg.egui_winit.handle_platform_output(window, platform_output);
|
||||
|
||||
// Apply any window commands egui emitted (we issue none directly, but
|
||||
// egui may request e.g. IME changes) and read the next repaint delay.
|
||||
let (repaint_delay, commands) = match viewport_output.get(&egui::ViewportId::ROOT) {
|
||||
Some(out) => (out.repaint_delay, out.commands.clone()),
|
||||
None => (Duration::MAX, Vec::new()),
|
||||
};
|
||||
if !commands.is_empty() {
|
||||
let mut actions = Vec::new();
|
||||
egui_winit::process_viewport_commands(
|
||||
&eg.egui_ctx,
|
||||
&mut self.viewport_info,
|
||||
commands,
|
||||
window,
|
||||
&mut actions,
|
||||
);
|
||||
}
|
||||
|
||||
let clipped = eg.egui_ctx.tessellate(shapes, pixels_per_point);
|
||||
for (id, image_delta) in &textures_delta.set {
|
||||
eg.painter.set_texture(*id, image_delta);
|
||||
}
|
||||
|
||||
let dimensions: [u32; 2] = window.inner_size().into();
|
||||
// SAFETY: the context is current (we are in the `Shown` arm) and `gl`
|
||||
// belongs to it.
|
||||
unsafe {
|
||||
self.gl.clear_color(0.08, 0.08, 0.08, 1.0);
|
||||
self.gl.clear(glow::COLOR_BUFFER_BIT);
|
||||
}
|
||||
eg.painter
|
||||
.paint_primitives(dimensions, pixels_per_point, &clipped);
|
||||
for id in &textures_delta.free {
|
||||
eg.painter.free_texture(*id);
|
||||
}
|
||||
|
||||
if let Err(e) = surface.swap_buffers(context) {
|
||||
tracing::warn!("GL swap_buffers failed: {e}");
|
||||
}
|
||||
repaint_delay
|
||||
}
|
||||
}
|
||||
|
||||
/// The winit application: owns the persistent GL/egui state ([`Gfx`]) and the
|
||||
/// PixelPass UI logic ([`PixelPassApp`]), and routes events between them.
|
||||
struct App {
|
||||
state: PixelPassApp,
|
||||
/// `None` until the first `resumed`; `Some` for the rest of the session
|
||||
/// (the window inside may be Shown or Hidden).
|
||||
gfx: Option<Gfx>,
|
||||
/// When the next repaint is due, derived from egui's per-frame delay and
|
||||
/// external wakes. `None` == idle (sleep until an event).
|
||||
repaint_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn shown_window(&self) -> Option<&Window> {
|
||||
self.gfx.as_ref().and_then(Gfx::shown_window)
|
||||
}
|
||||
|
||||
fn request_redraw(&self) {
|
||||
if let Some(w) = self.shown_window() {
|
||||
w.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
/// Run an egui frame now (if shown) and record the next repaint deadline.
|
||||
fn redraw(&mut self) {
|
||||
let App {
|
||||
gfx,
|
||||
state,
|
||||
repaint_at,
|
||||
..
|
||||
} = self;
|
||||
let Some(gfx) = gfx.as_mut() else { return };
|
||||
let delay = gfx.paint_frame(|ui| state.draw(ui));
|
||||
*repaint_at = (delay < Duration::MAX).then(|| Instant::now() + delay);
|
||||
}
|
||||
|
||||
/// The window's close button: hide to the tray if enabled and a tray is
|
||||
/// actually present, otherwise quit.
|
||||
fn on_close(&mut self, event_loop: &ActiveEventLoop) {
|
||||
let hide = self.state.close_to_tray
|
||||
&& self
|
||||
.state
|
||||
.tray
|
||||
.as_ref()
|
||||
.is_some_and(TrayHandle::registered);
|
||||
if hide {
|
||||
if let Some(gfx) = self.gfx.as_mut() {
|
||||
gfx.hide();
|
||||
}
|
||||
self.repaint_at = None;
|
||||
} else {
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ApplicationHandler<UserEvent> for App {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.gfx.is_some() {
|
||||
return; // already initialized
|
||||
}
|
||||
match Gfx::create(event_loop) {
|
||||
Ok(gfx) => self.gfx = Some(gfx),
|
||||
Err(e) => {
|
||||
tracing::error!("GUI: could not create the window/GL context: {e}");
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn window_event(
|
||||
&mut self,
|
||||
event_loop: &ActiveEventLoop,
|
||||
_id: WindowId,
|
||||
event: WindowEvent,
|
||||
) {
|
||||
if matches!(event, WindowEvent::CloseRequested) {
|
||||
self.on_close(event_loop);
|
||||
return;
|
||||
}
|
||||
if matches!(event, WindowEvent::RedrawRequested) {
|
||||
self.redraw();
|
||||
return;
|
||||
}
|
||||
if let WindowEvent::Resized(size) = &event
|
||||
&& let Some(gfx) = self.gfx.as_ref()
|
||||
&& let WinState::Shown {
|
||||
surface, context, ..
|
||||
} = &gfx.win
|
||||
&& let (Some(w), Some(h)) = (NonZeroU32::new(size.width), NonZeroU32::new(size.height))
|
||||
{
|
||||
surface.resize(context, w, h);
|
||||
}
|
||||
|
||||
// Feed the event to egui and repaint if it wants one.
|
||||
let Some(gfx) = self.gfx.as_mut() else { return };
|
||||
let WinState::Shown { window, .. } = &gfx.win else {
|
||||
return;
|
||||
};
|
||||
let response = gfx.egui_glow.egui_winit.on_window_event(window, &event);
|
||||
if response.repaint {
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
|
||||
match event {
|
||||
UserEvent::Wake => {
|
||||
// A child emitted/closed: update state (and fire notifications /
|
||||
// tray tooltip) even while hidden, then repaint if shown.
|
||||
self.state.tick();
|
||||
self.request_redraw();
|
||||
}
|
||||
UserEvent::Tray(TrayAction::Show) => {
|
||||
if let Some(gfx) = self.gfx.as_mut()
|
||||
&& let Err(e) = gfx.show(event_loop)
|
||||
{
|
||||
tracing::error!("could not restore the window from the tray: {e}");
|
||||
}
|
||||
self.state.tick();
|
||||
}
|
||||
UserEvent::Tray(TrayAction::Quit) => {
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
let flow = match self.repaint_at {
|
||||
None => ControlFlow::Wait,
|
||||
Some(at) if at > Instant::now() => ControlFlow::WaitUntil(at),
|
||||
Some(_) => {
|
||||
// Due now: ask for a frame if there's a window to paint into;
|
||||
// otherwise (hidden) drop the pending repaint and sleep.
|
||||
if let Some(w) = self.shown_window() {
|
||||
w.request_redraw();
|
||||
} else {
|
||||
self.repaint_at = None;
|
||||
}
|
||||
ControlFlow::Wait
|
||||
}
|
||||
};
|
||||
event_loop.set_control_flow(flow);
|
||||
}
|
||||
|
||||
fn exiting(&mut self, _event_loop: &ActiveEventLoop) {
|
||||
// Release GL resources only if the context is current (window shown);
|
||||
// when hidden the context isn't current, and the process is exiting
|
||||
// anyway so the driver reclaims everything.
|
||||
if let Some(gfx) = self.gfx.as_mut()
|
||||
&& matches!(gfx.win, WinState::Shown { .. })
|
||||
{
|
||||
gfx.egui_glow.painter.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Launch the GUI. Blocks until the window is closed (or the tray Quit is
|
||||
/// chosen). Runs on the main thread, a winit requirement, which is where `main`
|
||||
/// calls it from.
|
||||
pub fn run() -> anyhow::Result<()> {
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_inner_size([520.0, 480.0])
|
||||
.with_min_inner_size([460.0, 380.0])
|
||||
.with_title("PixelPass"),
|
||||
..Default::default()
|
||||
let event_loop = EventLoop::<UserEvent>::with_user_event()
|
||||
.build()
|
||||
.map_err(|e| anyhow::anyhow!("failed to build the event loop: {e}"))?;
|
||||
event_loop.set_control_flow(ControlFlow::Wait);
|
||||
|
||||
let proxy = event_loop.create_proxy();
|
||||
let waker = Waker {
|
||||
proxy: proxy.clone(),
|
||||
};
|
||||
// The tray runs on its own thread and wakes us via the proxy.
|
||||
let tray = tray::start(proxy.clone());
|
||||
let close_to_tray = crate::common::config::load()
|
||||
.map(|c| c.gui.close_to_tray)
|
||||
.unwrap_or(false);
|
||||
|
||||
let state = PixelPassApp {
|
||||
screen: Screen::default(),
|
||||
host: HostState::default(),
|
||||
viewer: ViewerState::default(),
|
||||
tray,
|
||||
close_to_tray,
|
||||
waker,
|
||||
};
|
||||
let mut app = App {
|
||||
state,
|
||||
gfx: None,
|
||||
repaint_at: None,
|
||||
};
|
||||
|
||||
eframe::run_native(
|
||||
"PixelPass",
|
||||
options,
|
||||
Box::new(|_cc| Ok(Box::new(PixelPassApp::default()))),
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("GUI failed to start: {e}"))
|
||||
event_loop
|
||||
.run_app(&mut app)
|
||||
.map_err(|e| anyhow::anyhow!("GUI event loop error: {e}"))
|
||||
}
|
||||
|
||||
/// Best-effort clipboard write. Returns whether it succeeded so callers can
|
||||
@@ -78,6 +647,34 @@ fn short_id(id: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire a desktop notification, on a detached thread so the D-Bus round-trip
|
||||
/// can't stall the egui frame. Best-effort: with no notification daemon it
|
||||
/// just does nothing. (notify-rust talks D-Bus via pure-Rust zbus, so this
|
||||
/// needs no system libdbus and no GTK event loop.)
|
||||
fn notify(summary: &'static str, body: String) {
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = notify_rust::Notification::new()
|
||||
.appname("PixelPass")
|
||||
.summary(summary)
|
||||
.body(&body)
|
||||
.show()
|
||||
{
|
||||
tracing::warn!("desktop notification failed: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Persist just the close-to-tray preference, preserving the rest of the
|
||||
/// on-disk config (e.g. the bandwidth section the headless child may have
|
||||
/// written). Best-effort: a write failure is logged, not surfaced.
|
||||
fn persist_close_to_tray(value: bool) {
|
||||
let mut cfg = crate::common::config::load().unwrap_or_default();
|
||||
cfg.gui.close_to_tray = value;
|
||||
if let Err(e) = crate::common::config::save(&cfg) {
|
||||
tracing::warn!("failed to save settings: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Which screen the single window is currently showing.
|
||||
#[derive(Default, PartialEq)]
|
||||
enum Screen {
|
||||
@@ -85,6 +682,7 @@ enum Screen {
|
||||
Menu,
|
||||
Host,
|
||||
Viewer,
|
||||
Settings,
|
||||
}
|
||||
|
||||
/// Quality preset choices, mirroring `cli::Quality`. Map to the `--quality`
|
||||
@@ -148,6 +746,7 @@ impl PlayerSel {
|
||||
|
||||
/// Host-screen state: the config form fields plus, once started, the running
|
||||
/// child and the latest values parsed from its event stream.
|
||||
#[derive(Default)]
|
||||
struct HostState {
|
||||
// form
|
||||
quality: QualitySel,
|
||||
@@ -168,26 +767,9 @@ struct HostState {
|
||||
copied: bool,
|
||||
last_refusal: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for HostState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
quality: QualitySel::default(),
|
||||
max_viewers: 0,
|
||||
no_hwencode: false,
|
||||
window: false,
|
||||
proc: None,
|
||||
ticket: None,
|
||||
info: None,
|
||||
active: 0,
|
||||
max: 0,
|
||||
capturing: false,
|
||||
copied: false,
|
||||
last_refusal: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
/// Endpoint ids of the currently-connected viewers, in arrival order.
|
||||
/// Drives the per-viewer list and its Kick buttons.
|
||||
viewers: Vec<String>,
|
||||
}
|
||||
|
||||
/// The host config summary echoed back by the child's `host_info` event.
|
||||
@@ -217,29 +799,61 @@ struct ViewerState {
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
/// The PixelPass UI logic — screens, the running children, and the tray. Knows
|
||||
/// nothing about windowing; [`App`] drives its [`PixelPassApp::draw`] each frame
|
||||
/// and [`PixelPassApp::tick`] on each wake.
|
||||
struct PixelPassApp {
|
||||
screen: Screen,
|
||||
host: HostState,
|
||||
viewer: ViewerState,
|
||||
/// System-tray handle; `None` if the tray couldn't start, in which case the
|
||||
/// close button always quits (never hides).
|
||||
tray: Option<TrayHandle>,
|
||||
/// Persisted preference: when true (and a tray is present), the close button
|
||||
/// hides to the tray instead of quitting. Loaded at startup, written on
|
||||
/// toggle in Settings.
|
||||
close_to_tray: bool,
|
||||
/// Wakes the winit loop when a spawned child emits/exits.
|
||||
waker: Waker,
|
||||
}
|
||||
|
||||
impl eframe::App for PixelPassApp {
|
||||
// eframe 0.34 hands us the central-panel `ui` directly.
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
// Drain any pending child events before drawing this frame.
|
||||
impl PixelPassApp {
|
||||
/// Drain child output and reflect it into the tray. Runs on every wake,
|
||||
/// whether or not a window is shown, so notifications and the tray tooltip
|
||||
/// stay live while hidden.
|
||||
fn tick(&mut self) {
|
||||
self.pump_host_events();
|
||||
self.pump_viewer_events();
|
||||
self.sync_tray_status();
|
||||
}
|
||||
|
||||
/// Render the current screen. Called from inside the egui frame.
|
||||
fn draw(&mut self, ui: &mut egui::Ui) {
|
||||
match self.screen {
|
||||
Screen::Menu => self.menu(ui),
|
||||
Screen::Host => self.host(ui),
|
||||
Screen::Viewer => self.viewer(ui),
|
||||
Screen::Settings => self.settings(ui),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror current activity into the tray icon's tooltip/menu.
|
||||
fn sync_tray_status(&mut self) {
|
||||
let status = if self.host.proc.is_some() {
|
||||
TrayStatus::Hosting {
|
||||
active: self.host.active,
|
||||
max: self.host.max,
|
||||
}
|
||||
} else if self.viewer.proc.is_some() {
|
||||
TrayStatus::Viewing
|
||||
} else {
|
||||
TrayStatus::Idle
|
||||
};
|
||||
if let Some(tray) = &mut self.tray {
|
||||
tray.set_status(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PixelPassApp {
|
||||
fn menu(&mut self, ui: &mut egui::Ui) {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(24.0);
|
||||
@@ -268,9 +882,52 @@ impl PixelPassApp {
|
||||
self.screen = Screen::Viewer;
|
||||
self.prefill_viewer_ticket();
|
||||
}
|
||||
ui.add_space(20.0);
|
||||
if ui.button("⚙ Settings").clicked() {
|
||||
self.screen = Screen::Settings;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn settings(&mut self, ui: &mut egui::Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("← Menu").clicked() {
|
||||
self.screen = Screen::Menu;
|
||||
}
|
||||
ui.heading("Settings");
|
||||
});
|
||||
ui.separator();
|
||||
ui.add_space(4.0);
|
||||
|
||||
let resp = ui.checkbox(
|
||||
&mut self.close_to_tray,
|
||||
"Keep running in the tray when I close the window",
|
||||
);
|
||||
if resp.changed() {
|
||||
persist_close_to_tray(self.close_to_tray);
|
||||
}
|
||||
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"Off: closing the window quits PixelPass.\n\
|
||||
On: closing hides it to the system tray and any active stream \
|
||||
keeps running — reopen it from the tray icon.",
|
||||
)
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
|
||||
// The option does nothing without a tray to hide into; say so plainly.
|
||||
if !self.tray.as_ref().is_some_and(TrayHandle::registered) {
|
||||
ui.add_space(8.0);
|
||||
ui.colored_label(
|
||||
egui::Color32::from_rgb(220, 160, 60),
|
||||
"⚠ No system tray detected — this option has no effect right now.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Host screen ──────────────────────────────────────────────────────
|
||||
|
||||
fn host(&mut self, ui: &mut egui::Ui) {
|
||||
@@ -337,7 +994,7 @@ impl PixelPassApp {
|
||||
.add_sized([160.0, 36.0], egui::Button::new("Start hosting"))
|
||||
.clicked()
|
||||
{
|
||||
self.start_host(ui.ctx().clone());
|
||||
self.start_host();
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
@@ -362,6 +1019,23 @@ impl PixelPassApp {
|
||||
ui.add_space(6.0);
|
||||
ui.label(format!("Viewers: {} / {}", self.host.active, self.host.max));
|
||||
|
||||
// Per-viewer list with a Kick button each. Collect the click first so
|
||||
// we're not borrowing self.host.viewers while we reach for the child.
|
||||
let mut kick: Option<String> = None;
|
||||
for id in &self.host.viewers {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(format!("• endpoint {}", short_id(id)));
|
||||
if ui.small_button("Kick").clicked() {
|
||||
kick = Some(id.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(id) = kick
|
||||
&& let Some(p) = &mut self.host.proc
|
||||
{
|
||||
p.send_command(&format!("kick {id}"));
|
||||
}
|
||||
|
||||
if let Some(info) = &self.host.info {
|
||||
ui.label(
|
||||
egui::RichText::new(format!("{} · {}", info.display, info.capture))
|
||||
@@ -443,7 +1117,7 @@ impl PixelPassApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn start_host(&mut self, ctx: egui::Context) {
|
||||
fn start_host(&mut self) {
|
||||
self.host.error = None;
|
||||
self.host.last_refusal = None;
|
||||
self.host.ticket = None;
|
||||
@@ -452,6 +1126,7 @@ impl PixelPassApp {
|
||||
self.host.max = 0;
|
||||
self.host.capturing = false;
|
||||
self.host.copied = false;
|
||||
self.host.viewers.clear();
|
||||
|
||||
let mut args = vec![
|
||||
"--host".to_string(),
|
||||
@@ -471,7 +1146,7 @@ impl PixelPassApp {
|
||||
args.push("--window".to_string());
|
||||
}
|
||||
|
||||
match ChildProc::spawn(&args, ctx) {
|
||||
match ChildProc::spawn(&args, self.waker.clone()) {
|
||||
Ok(p) => self.host.proc = Some(p),
|
||||
Err(e) => self.host.error = Some(format!("Couldn't start host: {e}")),
|
||||
}
|
||||
@@ -483,6 +1158,7 @@ impl PixelPassApp {
|
||||
self.host.capturing = false;
|
||||
self.host.ticket = None;
|
||||
self.host.copied = false;
|
||||
self.host.viewers.clear();
|
||||
}
|
||||
|
||||
/// Drain the host child's event channel into state, and detect an
|
||||
@@ -543,9 +1219,27 @@ impl PixelPassApp {
|
||||
cap_source: max_viewers_source,
|
||||
});
|
||||
}
|
||||
ChildEvent::ViewerCount { active, max } => {
|
||||
ChildEvent::ViewerJoined { id, active, max } => {
|
||||
self.host.active = active;
|
||||
self.host.max = max;
|
||||
if !self.host.viewers.contains(&id) {
|
||||
notify(
|
||||
"PixelPass — viewer connected",
|
||||
format!("endpoint {} is now watching ({active}/{max})", short_id(&id)),
|
||||
);
|
||||
self.host.viewers.push(id);
|
||||
}
|
||||
}
|
||||
ChildEvent::ViewerLeft { id, active, max } => {
|
||||
self.host.active = active;
|
||||
self.host.max = max;
|
||||
if self.host.viewers.iter().any(|v| v == &id) {
|
||||
notify(
|
||||
"PixelPass — viewer disconnected",
|
||||
format!("endpoint {} left ({active}/{max})", short_id(&id)),
|
||||
);
|
||||
self.host.viewers.retain(|v| v != &id);
|
||||
}
|
||||
}
|
||||
ChildEvent::Capture { state } => {
|
||||
self.host.capturing = matches!(state, child::CaptureState::Started);
|
||||
@@ -669,7 +1363,7 @@ impl PixelPassApp {
|
||||
.on_disabled_hover_text("Paste a valid share code first.")
|
||||
.clicked();
|
||||
if decoded_id.is_some() && (connect_clicked || enter_pressed) {
|
||||
self.start_viewer(ui.ctx().clone());
|
||||
self.start_viewer();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -713,7 +1407,7 @@ impl PixelPassApp {
|
||||
self.viewer.focus_ticket = true;
|
||||
}
|
||||
|
||||
fn start_viewer(&mut self, ctx: egui::Context) {
|
||||
fn start_viewer(&mut self) {
|
||||
self.viewer.error = None;
|
||||
self.viewer.url = None;
|
||||
self.viewer.launched = false;
|
||||
@@ -721,7 +1415,7 @@ impl PixelPassApp {
|
||||
let ticket = self.viewer.ticket_input.trim().to_string();
|
||||
self.viewer.connecting_to = ticket_endpoint_id(&ticket).map(|id| short_id(&id));
|
||||
let args = vec![ticket, "--output".to_string(), "json".to_string()];
|
||||
match ChildProc::spawn(&args, ctx) {
|
||||
match ChildProc::spawn(&args, self.waker.clone()) {
|
||||
Ok(p) => self.viewer.proc = Some(p),
|
||||
Err(e) => self.viewer.error = Some(format!("Couldn't connect: {e}")),
|
||||
}
|
||||
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
//! System-tray (StatusNotifierItem) integration for the GUI.
|
||||
//!
|
||||
//! The tray runs on its **own dedicated thread** with its own current-thread
|
||||
//! tokio runtime, fully decoupled from the winit event loop (which owns the
|
||||
//! main thread) and from the process-wide `#[tokio::main]` runtime. It talks to
|
||||
//! the egui app purely over winit's event channel and a status channel:
|
||||
//!
|
||||
//! * tray → app: a [`super::UserEvent::Tray`] carrying a [`TrayAction`]
|
||||
//! (Show / Quit), pushed through the [`winit::event_loop::EventLoopProxy`].
|
||||
//! Using the proxy (not egui's repaint) is essential: a tray click must
|
||||
//! wake the winit loop even when the window has been **dropped** (hidden to
|
||||
//! tray), so the loop can recreate it.
|
||||
//! * app → tray: [`TrayStatus`] (idle / hosting / viewing), pushed on change.
|
||||
//!
|
||||
//! Why a separate thread instead of `Handle::current().spawn`: updating the
|
||||
//! tray from the egui thread would need `block_on`, which panics when called
|
||||
//! from inside the running runtime. Keeping ksni's async wholly on its own
|
||||
//! runtime sidesteps that and keeps the frame loop non-blocking.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use ksni::TrayMethods;
|
||||
use winit::event_loop::EventLoopProxy;
|
||||
|
||||
use super::UserEvent;
|
||||
|
||||
/// What the user picked from the tray icon or its menu (tray thread → app),
|
||||
/// delivered as a [`UserEvent::Tray`].
|
||||
pub enum TrayAction {
|
||||
/// Left-click, or the "Show window" item: bring the window back.
|
||||
Show,
|
||||
/// The "Quit" item: really exit (the close button only hides to tray).
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// What the tray icon's tooltip/menu reflect (app → tray thread).
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TrayStatus {
|
||||
Idle,
|
||||
Hosting { active: u32, max: u32 },
|
||||
Viewing,
|
||||
}
|
||||
|
||||
fn status_text(status: TrayStatus) -> String {
|
||||
match status {
|
||||
TrayStatus::Idle => "Idle".to_string(),
|
||||
TrayStatus::Hosting { active, max } => {
|
||||
format!("Hosting — {active} of {max} viewer(s) connected")
|
||||
}
|
||||
TrayStatus::Viewing => "Viewing a stream".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle held by the egui app for the lifetime of the window. Dropping it
|
||||
/// closes the app→tray channel, which ends the tray thread and removes the icon.
|
||||
pub struct TrayHandle {
|
||||
status_tx: tokio::sync::mpsc::UnboundedSender<TrayStatus>,
|
||||
/// Set true once the tray actually registered with a StatusNotifier host.
|
||||
/// The app must not divert the window's close to a tray that never appeared.
|
||||
registered: Arc<AtomicBool>,
|
||||
/// Last status pushed, so we don't spam D-Bus with no-op updates.
|
||||
last_sent: Option<TrayStatus>,
|
||||
}
|
||||
|
||||
impl TrayHandle {
|
||||
/// Whether a system tray is actually showing our icon. Until this is true,
|
||||
/// hiding the window would strand it with no way back.
|
||||
pub fn registered(&self) -> bool {
|
||||
self.registered.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Push a status change to the tray, deduped against the last one sent.
|
||||
pub fn set_status(&mut self, status: TrayStatus) {
|
||||
if self.last_sent != Some(status) {
|
||||
let _ = self.status_tx.send(status);
|
||||
self.last_sent = Some(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PixelPassTray {
|
||||
status: TrayStatus,
|
||||
/// ARGB pixmap, so the icon shows even where the themed "pixelpass" name
|
||||
/// can't be resolved (e.g. running the dev binary before `make install`).
|
||||
icon: Vec<ksni::Icon>,
|
||||
/// Wakes the winit loop and delivers the action — works even when the
|
||||
/// window has been dropped to the tray (no egui frame is running then).
|
||||
proxy: EventLoopProxy<UserEvent>,
|
||||
}
|
||||
|
||||
impl PixelPassTray {
|
||||
fn notify(&self, action: TrayAction) {
|
||||
let _ = self.proxy.send_event(UserEvent::Tray(action));
|
||||
}
|
||||
}
|
||||
|
||||
impl ksni::Tray for PixelPassTray {
|
||||
fn id(&self) -> String {
|
||||
"pixelpass".to_string()
|
||||
}
|
||||
|
||||
fn title(&self) -> String {
|
||||
"PixelPass".to_string()
|
||||
}
|
||||
|
||||
// Themed icon (matches the installed hicolor/scalable/apps/pixelpass.svg);
|
||||
// icon_pixmap below is the always-works fallback.
|
||||
fn icon_name(&self) -> String {
|
||||
"pixelpass".to_string()
|
||||
}
|
||||
|
||||
fn icon_pixmap(&self) -> Vec<ksni::Icon> {
|
||||
self.icon.clone()
|
||||
}
|
||||
|
||||
fn status(&self) -> ksni::Status {
|
||||
ksni::Status::Active
|
||||
}
|
||||
|
||||
fn tool_tip(&self) -> ksni::ToolTip {
|
||||
ksni::ToolTip {
|
||||
title: "PixelPass".to_string(),
|
||||
description: status_text(self.status),
|
||||
icon_name: "pixelpass".to_string(),
|
||||
icon_pixmap: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn activate(&mut self, _x: i32, _y: i32) {
|
||||
self.notify(TrayAction::Show);
|
||||
}
|
||||
|
||||
fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
|
||||
use ksni::menu::{MenuItem, StandardItem};
|
||||
vec![
|
||||
// Non-clickable status line.
|
||||
StandardItem {
|
||||
label: status_text(self.status),
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
MenuItem::Separator,
|
||||
StandardItem {
|
||||
label: "Show window".to_string(),
|
||||
activate: Box::new(|t: &mut Self| t.notify(TrayAction::Show)),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
StandardItem {
|
||||
label: "Quit PixelPass".to_string(),
|
||||
icon_name: "application-exit".to_string(),
|
||||
activate: Box::new(|t: &mut Self| t.notify(TrayAction::Quit)),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode the embedded PNG (RGBA) and convert to the ARGB pixmap ksni wants.
|
||||
/// Reuses eframe's PNG decoder so we don't take a direct `image` dependency.
|
||||
fn load_icon() -> Option<Vec<ksni::Icon>> {
|
||||
let icon =
|
||||
eframe::icon_data::from_png_bytes(include_bytes!("../../assets/pixelpass-256.png")).ok()?;
|
||||
let mut data = icon.rgba; // RGBA8, row-major
|
||||
for px in data.chunks_exact_mut(4) {
|
||||
px.rotate_right(1); // [r,g,b,a] -> [a,r,g,b], network byte order
|
||||
}
|
||||
Some(vec![ksni::Icon {
|
||||
width: icon.width as i32,
|
||||
height: icon.height as i32,
|
||||
data,
|
||||
}])
|
||||
}
|
||||
|
||||
/// Start the tray on its own thread. Returns a handle for the app to drive it,
|
||||
/// or `None` if the icon couldn't be decoded or the thread couldn't spawn (in
|
||||
/// which case the GUI simply runs without a tray — close behaves as before).
|
||||
pub fn start(proxy: EventLoopProxy<UserEvent>) -> Option<TrayHandle> {
|
||||
let icon = load_icon()?;
|
||||
let (status_tx, mut status_rx) = tokio::sync::mpsc::unbounded_channel::<TrayStatus>();
|
||||
let registered = Arc::new(AtomicBool::new(false));
|
||||
let registered_thread = registered.clone();
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("pixelpass-tray".to_string())
|
||||
.spawn(move || {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
tracing::warn!("tray: could not build runtime: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
rt.block_on(async move {
|
||||
let tray = PixelPassTray {
|
||||
status: TrayStatus::Idle,
|
||||
icon,
|
||||
proxy,
|
||||
};
|
||||
let handle = match tray.spawn().await {
|
||||
Ok(handle) => handle,
|
||||
Err(e) => {
|
||||
// No StatusNotifier host (no system tray) — degrade
|
||||
// gracefully: the window keeps its normal close.
|
||||
tracing::warn!("tray: not available, running without it: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
registered_thread.store(true, Ordering::Release);
|
||||
|
||||
// Apply status changes until the app drops its sender (on quit),
|
||||
// which ends this loop, the runtime, the thread, and the icon.
|
||||
while let Some(status) = status_rx.recv().await {
|
||||
let _ = handle
|
||||
.update(move |t: &mut PixelPassTray| t.status = status)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
Some(TrayHandle {
|
||||
status_tx,
|
||||
registered,
|
||||
last_sent: None,
|
||||
})
|
||||
}
|
||||
+97
-20
@@ -10,6 +10,7 @@ use anyhow::{Result, bail};
|
||||
use iroh::endpoint::{Connection, presets};
|
||||
use iroh::{Endpoint, EndpointAddr};
|
||||
use iroh_tickets::endpoint::EndpointTicket;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
@@ -23,15 +24,28 @@ use crate::common::{
|
||||
use self::pipeline::CaptureHandle;
|
||||
use self::quality::EffectiveQuality;
|
||||
|
||||
/// Messages from per-viewer tasks to the capture supervisor.
|
||||
/// Messages from per-viewer tasks (and the GUI command channel) to the
|
||||
/// capture supervisor.
|
||||
// The shared `Viewer` suffix is the point — these are all viewer lifecycle
|
||||
// messages — so keep the descriptive names.
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
enum SupervisorMsg {
|
||||
/// A new viewer wants in. Supervisor replies with the local capture
|
||||
/// HTTP port to connect to, or an error string if the host is full or
|
||||
/// capture spawn failed.
|
||||
AddViewer(oneshot::Sender<Result<u16, String>>),
|
||||
/// A new viewer wants in. Supervisor replies with the local capture HTTP
|
||||
/// port to connect to, or an error string if the host is full or capture
|
||||
/// spawn failed. `cancel` is the viewer's own token — the supervisor keeps
|
||||
/// it so a later `KickViewer` can tear this viewer's stream down.
|
||||
AddViewer {
|
||||
id: String,
|
||||
cancel: CancellationToken,
|
||||
reply: oneshot::Sender<Result<u16, String>>,
|
||||
},
|
||||
/// A viewer's session ended. Supervisor decrements the count and tears
|
||||
/// down capture if it just hit zero.
|
||||
RemoveViewer,
|
||||
RemoveViewer { id: String },
|
||||
/// Host asked (via the GUI command channel) to disconnect a viewer by
|
||||
/// endpoint id. Cancels that viewer's token; the normal teardown path then
|
||||
/// emits the `ViewerLeft`.
|
||||
KickViewer { id: String },
|
||||
}
|
||||
|
||||
pub async fn run(opts: HostOpts) -> Result<()> {
|
||||
@@ -113,6 +127,15 @@ pub async fn run(opts: HostOpts) -> Result<()> {
|
||||
sup_rx,
|
||||
));
|
||||
|
||||
// Command channel for the GUI front-end: read `kick <endpoint-id>` lines
|
||||
// off stdin. Only when machine-driven (`--output json`) — a human host has
|
||||
// nothing to type here, and we don't want to swallow terminal input. Runs
|
||||
// on a plain OS thread (not a tokio task) so a read parked on stdin can't
|
||||
// hold up runtime shutdown on Ctrl+C; the thread dies with the process.
|
||||
if output::json_enabled() {
|
||||
spawn_kick_listener(sup_tx.clone());
|
||||
}
|
||||
|
||||
accept_loop(&endpoint, sup_tx.clone(), cancel.clone()).await;
|
||||
|
||||
drop(sup_tx);
|
||||
@@ -159,9 +182,18 @@ async fn handle_peer(
|
||||
cancel: CancellationToken,
|
||||
) {
|
||||
let remote = conn.remote_id();
|
||||
let id = remote.to_string();
|
||||
// This viewer's own kill switch: the supervisor holds a clone so a `kick`
|
||||
// can cancel it, and the stream select! below watches it.
|
||||
let peer_cancel = CancellationToken::new();
|
||||
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
if sup_tx.send(SupervisorMsg::AddViewer(reply_tx)).await.is_err() {
|
||||
let add = SupervisorMsg::AddViewer {
|
||||
id: id.clone(),
|
||||
cancel: peer_cancel.clone(),
|
||||
reply: reply_tx,
|
||||
};
|
||||
if sup_tx.send(add).await.is_err() {
|
||||
tracing::warn!(%remote, "supervisor channel closed; dropping peer");
|
||||
return;
|
||||
}
|
||||
@@ -182,7 +214,7 @@ async fn handle_peer(
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!(%remote, "accept_bi failed: {e:#}");
|
||||
let _ = sup_tx.send(SupervisorMsg::RemoveViewer).await;
|
||||
let _ = sup_tx.send(SupervisorMsg::RemoveViewer { id }).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -193,7 +225,7 @@ async fn handle_peer(
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::warn!(%remote, "connect_to_capture failed: {e:#}");
|
||||
let _ = sup_tx.send(SupervisorMsg::RemoveViewer).await;
|
||||
let _ = sup_tx.send(SupervisorMsg::RemoveViewer { id }).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -207,10 +239,34 @@ async fn handle_peer(
|
||||
_ = cancel.cancelled() => {
|
||||
tracing::info!(%remote, "cancellation during stream");
|
||||
}
|
||||
_ = peer_cancel.cancelled() => {
|
||||
tracing::info!(%remote, "kicked by host");
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("[pixelpass] viewer disconnected: {remote}");
|
||||
let _ = sup_tx.send(SupervisorMsg::RemoveViewer).await;
|
||||
let _ = sup_tx.send(SupervisorMsg::RemoveViewer { id }).await;
|
||||
}
|
||||
|
||||
/// Read `kick <endpoint-id>` lines off stdin and forward them to the
|
||||
/// supervisor. Runs on a detached OS thread (see the call site for why). Ends
|
||||
/// when stdin hits EOF (the GUI closed the pipe) or the supervisor is gone.
|
||||
fn spawn_kick_listener(sup_tx: mpsc::Sender<SupervisorMsg>) {
|
||||
use std::io::BufRead;
|
||||
std::thread::spawn(move || {
|
||||
let stdin = std::io::stdin();
|
||||
for line in stdin.lock().lines().map_while(Result::ok) {
|
||||
let Some(id) = line.trim().strip_prefix("kick ") else {
|
||||
continue;
|
||||
};
|
||||
let msg = SupervisorMsg::KickViewer { id: id.trim().to_string() };
|
||||
// blocking_send is valid here: this is a plain thread, not inside
|
||||
// the tokio runtime. An Err means the supervisor closed — stop.
|
||||
if sup_tx.blocking_send(msg).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Owns the single shared CaptureHandle and the active viewer count. Spawns
|
||||
@@ -225,11 +281,15 @@ async fn supervise(
|
||||
mut rx: mpsc::Receiver<SupervisorMsg>,
|
||||
) {
|
||||
let mut handle: Option<CaptureHandle> = None;
|
||||
let mut count: u32 = 0;
|
||||
// Active viewers, keyed by endpoint id, holding each one's kill switch.
|
||||
// The count is just `viewers.len()`. (A given endpoint connecting twice is
|
||||
// a non-case here: each viewer process uses a fresh ephemeral identity.)
|
||||
let mut viewers: HashMap<String, CancellationToken> = HashMap::new();
|
||||
|
||||
while let Some(msg) = rx.recv().await {
|
||||
match msg {
|
||||
SupervisorMsg::AddViewer(reply) => {
|
||||
SupervisorMsg::AddViewer { id, cancel, reply } => {
|
||||
let count = viewers.len() as u32;
|
||||
if count >= max_viewers {
|
||||
let reason =
|
||||
format!("host is full ({count} of {max_viewers} viewers connected)");
|
||||
@@ -255,16 +315,22 @@ async fn supervise(
|
||||
}
|
||||
|
||||
let port = handle.as_ref().expect("handle was just set").local_port();
|
||||
count += 1;
|
||||
viewers.insert(id.clone(), cancel);
|
||||
let active = viewers.len() as u32;
|
||||
let _ = reply.send(Ok(port));
|
||||
output::emit(output::Event::ViewerCount { active: count, max: max_viewers });
|
||||
tracing::info!(active = count, cap = max_viewers, "viewer joined");
|
||||
output::emit(output::Event::ViewerJoined { id: &id, active, max: max_viewers });
|
||||
tracing::info!(active, cap = max_viewers, "viewer joined");
|
||||
}
|
||||
SupervisorMsg::RemoveViewer => {
|
||||
count = count.saturating_sub(1);
|
||||
output::emit(output::Event::ViewerCount { active: count, max: max_viewers });
|
||||
tracing::info!(active = count, cap = max_viewers, "viewer left");
|
||||
if count == 0
|
||||
SupervisorMsg::RemoveViewer { id } => {
|
||||
// A given viewer task only ever sends RemoveViewer once, but the
|
||||
// map remove is the source of truth either way.
|
||||
if viewers.remove(&id).is_none() {
|
||||
continue;
|
||||
}
|
||||
let active = viewers.len() as u32;
|
||||
output::emit(output::Event::ViewerLeft { id: &id, active, max: max_viewers });
|
||||
tracing::info!(active, cap = max_viewers, "viewer left");
|
||||
if active == 0
|
||||
&& let Some(h) = handle.take()
|
||||
{
|
||||
tracing::info!("last viewer left — tearing down capture");
|
||||
@@ -274,6 +340,17 @@ async fn supervise(
|
||||
});
|
||||
}
|
||||
}
|
||||
SupervisorMsg::KickViewer { id } => {
|
||||
match viewers.get(&id) {
|
||||
// Cancel the viewer's token; its handle_peer select! wakes,
|
||||
// sends RemoveViewer, and the leave is emitted there.
|
||||
Some(cancel) => {
|
||||
tracing::info!(%id, "kicking viewer");
|
||||
cancel.cancel();
|
||||
}
|
||||
None => tracing::debug!(%id, "kick for unknown/already-gone viewer"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-6
@@ -13,7 +13,7 @@ pub async fn run(cli: Cli) -> Result<()> {
|
||||
let theme = ColorfulTheme::default();
|
||||
let choice = Select::with_theme(&theme)
|
||||
.with_prompt("What do you want to do?")
|
||||
.items(&[
|
||||
.items([
|
||||
"Host (share my screen)",
|
||||
"View (watch someone else's screen)",
|
||||
])
|
||||
@@ -112,7 +112,7 @@ fn pick_quality(theme: &ColorfulTheme) -> Result<Quality> {
|
||||
|
||||
let choice = Select::with_theme(theme)
|
||||
.with_prompt("What quality should the viewer(s) get?")
|
||||
.items(&items)
|
||||
.items(items)
|
||||
.default(0)
|
||||
.interact()?;
|
||||
|
||||
@@ -138,7 +138,7 @@ pub async fn run_reconfigure() -> Result<()> {
|
||||
async fn preflight_if_needed(theme: &ColorfulTheme) {
|
||||
let mut cfg = config::load().unwrap_or_default();
|
||||
match cfg.bandwidth.status {
|
||||
config::BandwidthStatus::Measured | config::BandwidthStatus::Skipped => return,
|
||||
config::BandwidthStatus::Measured | config::BandwidthStatus::Skipped => (),
|
||||
config::BandwidthStatus::Unmeasured => {
|
||||
eprintln!();
|
||||
eprintln!("First-time setup");
|
||||
@@ -154,7 +154,7 @@ async fn preflight_if_needed(theme: &ColorfulTheme) {
|
||||
|
||||
let Ok(choice) = Select::with_theme(theme)
|
||||
.with_prompt("What would you like to do?")
|
||||
.items(&[
|
||||
.items([
|
||||
"Run the bandwidth test (recommended)",
|
||||
"Skip — use the conservative default",
|
||||
])
|
||||
@@ -180,7 +180,7 @@ async fn preflight_if_needed(theme: &ColorfulTheme) {
|
||||
eprintln!();
|
||||
let Ok(choice) = Select::with_theme(theme)
|
||||
.with_prompt("Last bandwidth test failed. Try again?")
|
||||
.items(&[
|
||||
.items([
|
||||
"Yes — retry now",
|
||||
"No — use the conservative default",
|
||||
])
|
||||
@@ -341,7 +341,7 @@ pub fn prompt_player() -> Result<Player> {
|
||||
let theme = ColorfulTheme::default();
|
||||
let choice = Select::with_theme(&theme)
|
||||
.with_prompt("Connected. Pick a player to launch")
|
||||
.items(&["mpv", "VLC"])
|
||||
.items(["mpv", "VLC"])
|
||||
.default(0)
|
||||
.interact()?;
|
||||
Ok(if choice == 0 { Player::Mpv } else { Player::Vlc })
|
||||
|
||||
Reference in New Issue
Block a user