Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04bc0a808a | ||
|
|
b0fa259187 | ||
|
|
1a746461b4 | ||
|
|
9e839ca452 | ||
|
|
f5d0333366 | ||
|
|
14fc1af716 | ||
|
|
9b9328f6a9 | ||
|
|
2d0143f1aa | ||
|
|
e8273b364e | ||
|
|
472991c11f | ||
|
|
ccd8c33f81 | ||
|
|
c876c61ec6 | ||
|
|
b1d73caedf | ||
|
|
40960c7476 | ||
|
|
0a4bb554e9 | ||
|
|
7e470fb2c5 | ||
|
|
9d685c2c48 | ||
|
|
42ffe8928c | ||
|
|
69ddc58133 | ||
|
|
09a07f5303 | ||
|
|
eb077d81f0 | ||
|
|
32131b0ccb | ||
|
|
035aa4b256 | ||
|
|
f85c0c22c7 | ||
|
|
c30418a0f5 | ||
|
|
14245cbf08 | ||
|
|
a740376ea9 | ||
|
|
e1ed89026d | ||
|
|
a7ea1fd9df | ||
|
|
7a03dee12f | ||
|
|
6f1ccf3923 | ||
|
|
8cd2d63a87 | ||
|
|
511927569b | ||
|
|
b260d57dc4 | ||
|
|
ad70ce5ea9 | ||
|
|
6c275faf28 | ||
|
|
2edd7f0fa8 | ||
|
|
f4a4dd37c9 | ||
|
|
56d0d6c2e2 | ||
|
|
675f25f266 | ||
|
|
e54d625f2a | ||
|
|
f926dbea4e | ||
|
|
24e0d0e799 |
Generated
+338
-43
@@ -242,7 +242,7 @@ dependencies = [
|
|||||||
"serde_repr",
|
"serde_repr",
|
||||||
"tokio",
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
"zbus",
|
"zbus 4.4.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -269,6 +269,20 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"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]]
|
[[package]]
|
||||||
name = "async-io"
|
name = "async-io"
|
||||||
version = "2.6.0"
|
version = "2.6.0"
|
||||||
@@ -711,7 +725,7 @@ dependencies = [
|
|||||||
"iana-time-zone",
|
"iana-time-zone",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"serde",
|
"serde",
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1487,6 +1501,7 @@ checksum = "6caa4eca47cc2358e2c5ae60843a94118e338f87099c6af4170e6e968e8d77cb"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"egui",
|
"egui",
|
||||||
|
"egui-winit",
|
||||||
"glow",
|
"glow",
|
||||||
"log",
|
"log",
|
||||||
"memoffset",
|
"memoffset",
|
||||||
@@ -1893,8 +1908,8 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"rustversion",
|
"rustversion",
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
"windows-result",
|
"windows-result 0.4.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1914,7 +1929,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rustix 1.1.4",
|
"rustix 1.1.4",
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2385,7 +2400,7 @@ dependencies = [
|
|||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"windows-core",
|
"windows-core 0.62.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2582,7 +2597,7 @@ dependencies = [
|
|||||||
"socket2",
|
"socket2",
|
||||||
"widestring",
|
"widestring",
|
||||||
"windows-registry",
|
"windows-registry",
|
||||||
"windows-result",
|
"windows-result 0.4.1",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2816,7 +2831,7 @@ dependencies = [
|
|||||||
"simd_cesu8",
|
"simd_cesu8",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2888,6 +2903,19 @@ version = "3.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
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]]
|
[[package]]
|
||||||
name = "kurbo"
|
name = "kurbo"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
@@ -2925,7 +2953,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3053,6 +3081,18 @@ version = "0.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f"
|
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]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -3363,8 +3403,8 @@ dependencies = [
|
|||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tracing",
|
"tracing",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"windows",
|
"windows 0.62.2",
|
||||||
"windows-result",
|
"windows-result 0.4.1",
|
||||||
"wmi",
|
"wmi",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3480,6 +3520,20 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@@ -3972,7 +4026,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"redox_syscall 0.5.18",
|
"redox_syscall 0.5.18",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3981,6 +4035,12 @@ version = "1.0.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pastey"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pem-rfc7468"
|
name = "pem-rfc7468"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@@ -4107,10 +4167,16 @@ dependencies = [
|
|||||||
"dialoguer",
|
"dialoguer",
|
||||||
"directories",
|
"directories",
|
||||||
"eframe",
|
"eframe",
|
||||||
|
"egui_glow",
|
||||||
|
"glutin",
|
||||||
|
"glutin-winit",
|
||||||
"iroh",
|
"iroh",
|
||||||
"iroh-tickets",
|
"iroh-tickets",
|
||||||
|
"ksni",
|
||||||
"nix 0.30.1",
|
"nix 0.30.1",
|
||||||
|
"notify-rust",
|
||||||
"pipewire",
|
"pipewire",
|
||||||
|
"qrcode",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
@@ -4121,6 +4187,7 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"ureq",
|
"ureq",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"winit",
|
||||||
"x11rb",
|
"x11rb",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4154,7 +4221,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"quick-xml",
|
"quick-xml 0.39.4",
|
||||||
"serde",
|
"serde",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
@@ -4354,12 +4421,27 @@ version = "0.1.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qrcode"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-error"
|
name = "quick-error"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
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]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.39.4"
|
version = "0.39.4"
|
||||||
@@ -5263,6 +5345,18 @@ version = "0.13.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c"
|
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]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
@@ -5793,6 +5887,7 @@ checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
"serde_core",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6141,7 +6236,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
|
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quick-xml",
|
"quick-xml 0.39.4",
|
||||||
"quote",
|
"quote",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6367,16 +6462,38 @@ version = "0.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
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]]
|
[[package]]
|
||||||
name = "windows"
|
name = "windows"
|
||||||
version = "0.62.2"
|
version = "0.62.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-collections",
|
"windows-collections 0.3.2",
|
||||||
"windows-core",
|
"windows-core 0.62.2",
|
||||||
"windows-future",
|
"windows-future 0.3.2",
|
||||||
"windows-numerics",
|
"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]]
|
[[package]]
|
||||||
@@ -6385,7 +6502,20 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
@@ -6396,9 +6526,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-implement",
|
"windows-implement",
|
||||||
"windows-interface",
|
"windows-interface",
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
"windows-result",
|
"windows-result 0.4.1",
|
||||||
"windows-strings",
|
"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]]
|
[[package]]
|
||||||
@@ -6407,9 +6548,9 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-core",
|
"windows-core 0.62.2",
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
"windows-threading",
|
"windows-threading 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6434,20 +6575,36 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
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]]
|
[[package]]
|
||||||
name = "windows-numerics"
|
name = "windows-numerics"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-core",
|
"windows-core 0.62.2",
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6456,9 +6613,18 @@ version = "0.6.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
"windows-result",
|
"windows-result 0.4.1",
|
||||||
"windows-strings",
|
"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]]
|
[[package]]
|
||||||
@@ -6467,7 +6633,16 @@ version = "0.4.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
@@ -6476,7 +6651,7 @@ version = "0.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6512,7 +6687,7 @@ version = "0.61.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6546,13 +6721,31 @@ dependencies = [
|
|||||||
"windows_x86_64_msvc 0.52.6",
|
"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]]
|
[[package]]
|
||||||
name = "windows-threading"
|
name = "windows-threading"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
@@ -6828,8 +7021,8 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows",
|
"windows 0.62.2",
|
||||||
"windows-core",
|
"windows-core 0.62.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6990,9 +7183,45 @@ dependencies = [
|
|||||||
"uds_windows",
|
"uds_windows",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
"xdg-home",
|
"xdg-home",
|
||||||
"zbus_macros",
|
"zbus_macros 4.4.0",
|
||||||
"zbus_names",
|
"zbus_names 3.0.0",
|
||||||
"zvariant",
|
"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]]
|
[[package]]
|
||||||
@@ -7005,7 +7234,22 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
@@ -7016,7 +7260,18 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"static_assertions",
|
"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]]
|
[[package]]
|
||||||
@@ -7145,7 +7400,21 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
"url",
|
"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]]
|
[[package]]
|
||||||
@@ -7158,7 +7427,20 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
@@ -7171,3 +7453,16 @@ dependencies = [
|
|||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"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",
|
||||||
|
]
|
||||||
|
|||||||
+27
-1
@@ -34,6 +34,32 @@ ureq = { version = "3", default-features = false, features = ["rustls"] }
|
|||||||
toml = "1"
|
toml = "1"
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
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 }
|
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 }
|
||||||
|
# QR-encode the host ticket so a phone (or a second laptop with a webcam) can
|
||||||
|
# pick it up without typing 140 chars. default-features = false to skip the
|
||||||
|
# `image` crate dep tree — we render the modules to an `egui::ColorImage`
|
||||||
|
# directly.
|
||||||
|
qrcode = { version = "0.14", default-features = false, optional = true }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
@@ -43,4 +69,4 @@ strip = "symbols"
|
|||||||
[features]
|
[features]
|
||||||
# Opt-in graphical front-end (pixelpass --gui). Default-off so the headless
|
# Opt-in graphical front-end (pixelpass --gui). Default-off so the headless
|
||||||
# build never pulls the GUI toolkit tree.
|
# 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", "dep:qrcode"]
|
||||||
|
|||||||
+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
|
Host: pick quality / max-viewers / options, click **Start hosting**, and the
|
||||||
share code appears with a copy button alongside a live viewer count. View:
|
share code appears with a copy button. Connected viewers are listed with a
|
||||||
paste a code, pick mpv or VLC, click **Connect** and the player launches.
|
**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
|
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
|
child process and reads its event stream, so the GUI is purely additive and
|
||||||
@@ -216,6 +223,65 @@ measured_at = "2026-05-21T20:41:16Z"
|
|||||||
- Skip is sticky — once you skip the test, pixelpass won't ask again
|
- Skip is sticky — once you skip the test, pixelpass won't ask again
|
||||||
unless you reconfigure.
|
unless you reconfigure.
|
||||||
|
|
||||||
|
## Relay
|
||||||
|
|
||||||
|
By default pixelpass uses iroh's bundled relay servers to coordinate the
|
||||||
|
P2P connection (peers still hole-punch a direct UDP path when they can; the
|
||||||
|
relay is the fallback and the rendezvous point). You can point it at a
|
||||||
|
different relay — a self-hosted one, or n0's staging/production servers —
|
||||||
|
with either:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pixelpass --relay https://relay.example/ # host or viewer
|
||||||
|
PIXELPASS_RELAY=https://relay.example/ pixelpass … # env-var form
|
||||||
|
```
|
||||||
|
|
||||||
|
The flag applies to both host and viewer and takes precedence over the
|
||||||
|
environment variable. The env-var form is handy for the `--gui` front-end,
|
||||||
|
since the GUI's child host/viewer processes inherit it; the `--gui --relay`
|
||||||
|
flag form is forwarded to them too. Both ends must use the same relay to
|
||||||
|
find each other.
|
||||||
|
|
||||||
|
## Themes
|
||||||
|
|
||||||
|
The `--gui` front-end ships three colour themes — **Default Dark**,
|
||||||
|
**Catppuccin Mocha**, and **Catppuccin Latte** — and you can add your own.
|
||||||
|
Pick one under **Settings → Appearance**; the choice is remembered.
|
||||||
|
|
||||||
|
A theme is a small TOML file of named colours:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
name = "My Theme"
|
||||||
|
dark = true # base egui defaults to start from (dark or light)
|
||||||
|
|
||||||
|
window_bg = "#1b1b1f" # window background
|
||||||
|
panel_bg = "#242429" # panels / frames
|
||||||
|
input_bg = "#141417" # text fields, the ticket box
|
||||||
|
text = "#e6e6ea" # primary text
|
||||||
|
weak_text = "#a0a0a8" # hints, secondary text
|
||||||
|
accent = "#5aa0f2" # selection, links, the active control
|
||||||
|
button_bg = "#33333a" # buttons at rest
|
||||||
|
button_hovered = "#44444d"
|
||||||
|
streaming = "#6fdc8c" # "● Streaming"
|
||||||
|
waiting = "#f2c14e" # "● Waiting for viewers…"
|
||||||
|
success = "#6fdc8c" # "✓ Copied", valid-code confirmation
|
||||||
|
warning = "#f0a85a" # non-fatal warnings
|
||||||
|
error = "#f2756f" # errors
|
||||||
|
```
|
||||||
|
|
||||||
|
Colours are `#rrggbb` hex strings. Any field you leave out falls back to
|
||||||
|
Default Dark, so partial files are fine.
|
||||||
|
|
||||||
|
Two ways to make one:
|
||||||
|
|
||||||
|
- **In the app:** Settings → Appearance → *Edit / create a theme* gives you a
|
||||||
|
colour picker per field with a live preview, and **Save** writes a `.toml`.
|
||||||
|
- **By hand:** drop a `.toml` into `~/.config/pixelpass/themes/` (the XDG
|
||||||
|
config dir). It appears in the picker next time you open Settings.
|
||||||
|
|
||||||
|
Sharing a theme is just sending someone the file. A user theme whose `name`
|
||||||
|
matches a built-in overrides that built-in.
|
||||||
|
|
||||||
## Audio
|
## Audio
|
||||||
|
|
||||||
By default pixelpass captures the default sink's monitor — the viewer
|
By default pixelpass captures the default sink's monitor — the viewer
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
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,3 @@
|
|||||||
|
.tools/
|
||||||
|
AppDir/
|
||||||
|
*.AppImage
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# AppRun for the PixelPass AppImage.
|
||||||
|
#
|
||||||
|
# PixelPass is an orchestrator: it shells out to gst-launch-1.0, pactl, and a
|
||||||
|
# player (mpv/vlc) found on the host PATH. We prepend our own usr/bin so any
|
||||||
|
# bundled helpers win, but the host's tools remain reachable — that's why this
|
||||||
|
# app suits AppImage (no sandbox) better than a Flatpak.
|
||||||
|
HERE="$(dirname "$(readlink -f "$0")")"
|
||||||
|
export PATH="$HERE/usr/bin:$PATH"
|
||||||
|
|
||||||
|
BIN="$HERE/usr/bin/pixelpass"
|
||||||
|
|
||||||
|
# With no arguments and no controlling terminal — i.e. launched from a file
|
||||||
|
# manager or the .desktop entry — open the GUI. From a terminal, or with any
|
||||||
|
# argument (a ticket, --host, --gui, --repair, …), pass through so the CLI and
|
||||||
|
# the interactive menu both work.
|
||||||
|
if [ "$#" -eq 0 ] && [ ! -t 0 ]; then
|
||||||
|
exec "$BIN" --gui
|
||||||
|
fi
|
||||||
|
exec "$BIN" "$@"
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# PixelPass AppImage
|
||||||
|
|
||||||
|
A "thin" AppImage: the gui-enabled `pixelpass` binary, a launcher (`AppRun`),
|
||||||
|
and the desktop entry + icon. Run `./build-appimage.sh` to produce
|
||||||
|
`pixelpass-<version>-x86_64.AppImage`.
|
||||||
|
|
||||||
|
## Why thin
|
||||||
|
|
||||||
|
`pixelpass` is an orchestrator — it links almost nothing (only `libpipewire`,
|
||||||
|
which is excludelisted because it must match the host daemon) and instead
|
||||||
|
**shells out** to `gst-launch-1.0`, `pactl`, and a player (`mpv`/`vlc`) found on
|
||||||
|
the host `PATH`. The GUI's graphics libraries (`libGL`, `libwayland-*`,
|
||||||
|
`libxkbcommon`, X11) are dlopen'd at runtime and are likewise on the AppImage
|
||||||
|
excludelist — every desktop already has a matching set. So there is nothing
|
||||||
|
useful to bundle, and bundling the graphics stack would only risk driver
|
||||||
|
mismatches. The AppImage therefore carries just the binary.
|
||||||
|
|
||||||
|
This also explains why PixelPass suits AppImage better than Flatpak: the
|
||||||
|
no-sandbox model lets the bundled binary freely spawn the host's `gst-launch`,
|
||||||
|
`pactl`, and player, which a Flatpak sandbox would block.
|
||||||
|
|
||||||
|
## Host requirements
|
||||||
|
|
||||||
|
The AppImage runs on any reasonably current glibc-based distro that has:
|
||||||
|
|
||||||
|
- **GStreamer + plugins** — `gst-launch-1.0`/`gst-inspect-1.0` plus base,
|
||||||
|
good/bad/ugly, libav, and the PipeWire plugin (the binary tells you the exact
|
||||||
|
package names for your distro if something is missing).
|
||||||
|
- **PipeWire** (with the PulseAudio shim, for `pactl`).
|
||||||
|
- **A player** — `mpv` (preferred) or `vlc` — for the viewer side.
|
||||||
|
- For X11 single-window capture: `xwininfo`.
|
||||||
|
|
||||||
|
These are the same dependencies the Arch package lists; the AppImage just spares
|
||||||
|
you the Rust toolchain.
|
||||||
|
|
||||||
|
## Building for broad compatibility (lower glibc baseline)
|
||||||
|
|
||||||
|
An AppImage requires a host glibc **at least as new** as the build host's. Built
|
||||||
|
straight on a rolling distro (e.g. CachyOS, glibc 2.43) the AppImage only runs
|
||||||
|
on equally-new systems. Build inside an older base for wider reach. The script
|
||||||
|
honours `CARGO_TARGET_DIR`, so an isolated toolchain won't clobber your host's
|
||||||
|
`target/`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# One-time: an Ubuntu 24.04 distrobox (docker or podman backend).
|
||||||
|
distrobox create --yes --image ubuntu:24.04 --name pixelpass-build
|
||||||
|
distrobox enter pixelpass-build -- sudo apt-get update
|
||||||
|
distrobox enter pixelpass-build -- sudo apt-get install -y \
|
||||||
|
build-essential cmake clang libclang-dev pkg-config \
|
||||||
|
libpipewire-0.3-dev libspa-0.2-dev curl ca-certificates file
|
||||||
|
# Install rustup inside the box (edition 2024 needs rustc >= 1.85), then:
|
||||||
|
distrobox enter pixelpass-build -- env \
|
||||||
|
CARGO_TARGET_DIR=~/.cache/pixelpass-ubuntu/target \
|
||||||
|
./packaging/appimage/build-appimage.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why Ubuntu 24.04 and not something older:** PixelPass's `pipewire` crate
|
||||||
|
binds the system's PipeWire headers via bindgen, and anything older than ~PW 1.0
|
||||||
|
(e.g. Ubuntu 22.04's 0.3.48) fails to compile (missing struct fields / wrong
|
||||||
|
types). And since PixelPass *is* a PipeWire/portal/Wayland app, it can only run
|
||||||
|
on distros new enough to have modern PipeWire anyway — so an ancient glibc base
|
||||||
|
buys nothing. 24.04 (glibc 2.39, PW 1.0.5) is the sweet spot.
|
||||||
|
|
||||||
|
The 24.04-built binary's baseline is **glibc 2.39** — and the only 2.39 symbols
|
||||||
|
are two *weak* `pidfd_*` references from Rust std's process spawning (everything
|
||||||
|
else is ≤ 2.35). That covers Ubuntu 24.04+, Debian 13+, Fedora 40+, and current
|
||||||
|
rolling distros.
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
- **Hardware encode (VAAPI `vah264enc`)** uses the host GPU driver; it can't be
|
||||||
|
bundled. The software path (`--no-hwencode`, x264) always works.
|
||||||
Executable
+59
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build a "thin" PixelPass AppImage: the gui-enabled release binary plus only
|
||||||
|
# its non-excludelisted shared libraries. The graphics stack (libGL, wayland,
|
||||||
|
# xkbcommon, X11) is intentionally left to the host — those libs are on the
|
||||||
|
# AppImage excludelist because they must match the host driver — and the
|
||||||
|
# runtime tools PixelPass shells out to (gst-launch-1.0, pactl, mpv/vlc) are
|
||||||
|
# expected on the host PATH, the same contract the Arch package documents.
|
||||||
|
#
|
||||||
|
# Usage: packaging/appimage/build-appimage.sh
|
||||||
|
# Output: packaging/appimage/pixelpass-x86_64.AppImage
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo="$(cd "$here/../.." && pwd)"
|
||||||
|
tools="$here/.tools"
|
||||||
|
appdir="$here/AppDir"
|
||||||
|
mkdir -p "$tools"
|
||||||
|
|
||||||
|
# linuxdeploy is itself an AppImage; run it without FUSE so this works on hosts
|
||||||
|
# (and CI) that lack libfuse2.
|
||||||
|
export APPIMAGE_EXTRACT_AND_RUN=1
|
||||||
|
# Embed the version from Cargo.toml into the AppImage filename metadata.
|
||||||
|
VERSION="$(grep -m1 '^version' "$repo/Cargo.toml" | sed -E 's/.*"(.*)".*/\1/')"
|
||||||
|
export VERSION
|
||||||
|
|
||||||
|
echo ">> building release binary (--features gui)"
|
||||||
|
( cd "$repo" && cargo build --release --features gui )
|
||||||
|
# Honour CARGO_TARGET_DIR so an isolated build (e.g. inside an old-glibc
|
||||||
|
# distrobox) doesn't have to clobber the host's target/.
|
||||||
|
bin="${CARGO_TARGET_DIR:-$repo/target}/release/pixelpass"
|
||||||
|
|
||||||
|
echo ">> fetching linuxdeploy"
|
||||||
|
ld="$tools/linuxdeploy-x86_64.AppImage"
|
||||||
|
if [ ! -x "$ld" ]; then
|
||||||
|
curl -fL --retry 3 -o "$ld" \
|
||||||
|
"https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
|
||||||
|
chmod +x "$ld"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ">> assembling AppDir"
|
||||||
|
rm -rf "$appdir"
|
||||||
|
mkdir -p "$appdir/usr/bin"
|
||||||
|
install -m755 "$bin" "$appdir/usr/bin/pixelpass"
|
||||||
|
|
||||||
|
echo ">> running linuxdeploy (bundles libs, builds the AppImage)"
|
||||||
|
# -e: analyse this binary for libraries to bundle (only libpipewire et al. that
|
||||||
|
# aren't excludelisted will be copied; glibc + graphics libs are skipped).
|
||||||
|
# -d/-i: desktop entry + icon for desktop integration.
|
||||||
|
# --custom-apprun: our launcher that opens --gui from a file manager.
|
||||||
|
( cd "$here" && OUTPUT="pixelpass-${VERSION}-x86_64.AppImage" "$ld" \
|
||||||
|
--appdir "$appdir" \
|
||||||
|
-e "$bin" \
|
||||||
|
-d "$repo/assets/pixelpass.desktop" \
|
||||||
|
-i "$repo/assets/pixelpass-256.png" \
|
||||||
|
--icon-filename pixelpass \
|
||||||
|
--custom-apprun "$here/AppRun" \
|
||||||
|
--output appimage )
|
||||||
|
|
||||||
|
echo ">> done: $here/pixelpass-${VERSION}-x86_64.AppImage"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# makepkg build artifacts
|
||||||
|
src/
|
||||||
|
pkg/
|
||||||
|
/pixelpass/
|
||||||
|
*.pkg.tar.*
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# 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' 'OFL-1.1')
|
||||||
|
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"
|
||||||
|
install -Dm0644 assets/NotoSans-OFL.txt \
|
||||||
|
"$pkgdir/usr/share/licenses/$pkgname/NotoSans-OFL.txt"
|
||||||
|
}
|
||||||
+17
-1
@@ -68,6 +68,13 @@ pub struct Cli {
|
|||||||
pub port: u16,
|
pub port: u16,
|
||||||
|
|
||||||
// ── global ────────────────────────────────────────────────────────
|
// ── global ────────────────────────────────────────────────────────
|
||||||
|
/// Relay server URL to use instead of the bundled defaults, e.g.
|
||||||
|
/// `https://relay.example/`. Applies to both host and viewer. Falls back
|
||||||
|
/// to the `PIXELPASS_RELAY` environment variable. Use this to get off the
|
||||||
|
/// pre-release default relays or to point at a self-hosted relay.
|
||||||
|
#[arg(long, value_name = "URL")]
|
||||||
|
pub relay: Option<String>,
|
||||||
|
|
||||||
/// Launch the graphical front-end (a window with Host/View controls)
|
/// Launch the graphical front-end (a window with Host/View controls)
|
||||||
/// instead of the terminal menu. Requires a build with `--features gui`.
|
/// instead of the terminal menu. Requires a build with `--features gui`.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -140,12 +147,16 @@ pub struct HostOpts {
|
|||||||
pub no_hwencode: bool,
|
pub no_hwencode: bool,
|
||||||
pub max_viewers: Option<u32>,
|
pub max_viewers: Option<u32>,
|
||||||
pub interactive: bool,
|
pub interactive: bool,
|
||||||
|
/// Relay override (resolved from `--relay` / `PIXELPASS_RELAY`); None = defaults.
|
||||||
|
pub relay: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ViewerOpts {
|
pub struct ViewerOpts {
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub interactive: bool,
|
pub interactive: bool,
|
||||||
|
/// Relay override (resolved from `--relay` / `PIXELPASS_RELAY`); None = defaults.
|
||||||
|
pub relay: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Cli {
|
impl Cli {
|
||||||
@@ -163,10 +174,15 @@ impl Cli {
|
|||||||
no_hwencode: self.no_hwencode,
|
no_hwencode: self.no_hwencode,
|
||||||
max_viewers: self.max_viewers,
|
max_viewers: self.max_viewers,
|
||||||
interactive,
|
interactive,
|
||||||
|
relay: crate::common::endpoint::relay_override(self.relay.as_deref()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn into_viewer_opts(self, interactive: bool) -> ViewerOpts {
|
pub fn into_viewer_opts(self, interactive: bool) -> ViewerOpts {
|
||||||
ViewerOpts { port: self.port, interactive }
|
ViewerOpts {
|
||||||
|
port: self.port,
|
||||||
|
interactive,
|
||||||
|
relay: crate::common::endpoint::relay_override(self.relay.as_deref()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -1,5 +1,14 @@
|
|||||||
/// ALPN identifying the pixelpass wire protocol on the iroh tunnel.
|
/// ALPN identifying the pixelpass video wire protocol on the iroh tunnel.
|
||||||
///
|
///
|
||||||
/// Bump the version suffix whenever the wire format changes. Today the wire is
|
/// Bump the version suffix whenever the wire format changes. Today the wire is
|
||||||
/// "raw MPEG-TS bytes copied bidirectionally," so bumps will be rare.
|
/// "raw MPEG-TS bytes copied bidirectionally," so bumps will be rare.
|
||||||
pub const ALPN: &[u8] = b"pixelpass/0";
|
pub const ALPN: &[u8] = b"pixelpass/0";
|
||||||
|
|
||||||
|
/// ALPN for the friends control plane — the always-on presence endpoint that
|
||||||
|
/// carries friend requests and shared codes between peers' GUIs. Separate from
|
||||||
|
/// [`ALPN`] so the same machine can run a control endpoint and a video endpoint
|
||||||
|
/// without their accept loops colliding, and so a control dial never lands on a
|
||||||
|
/// bare video host (which doesn't speak this protocol). GUI-only, like the rest
|
||||||
|
/// of the friends stack.
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
pub const CONTROL_ALPN: &[u8] = b"pixelpass/ctrl/0";
|
||||||
|
|||||||
+33
-1
@@ -65,9 +65,41 @@ pub fn measure_upstream_blocking() -> Result<Measurement> {
|
|||||||
/// via SAFETY_FACTOR) into a recommended viewer count. Floors to at least 1.
|
/// via SAFETY_FACTOR) into a recommended viewer count. Floors to at least 1.
|
||||||
pub fn recommended_max_viewers(safe_mbps: f64, bitrate_kbps: u32) -> u32 {
|
pub fn recommended_max_viewers(safe_mbps: f64, bitrate_kbps: u32) -> u32 {
|
||||||
let per_viewer_mbps = (bitrate_kbps as f64) / 1000.0;
|
let per_viewer_mbps = (bitrate_kbps as f64) / 1000.0;
|
||||||
if per_viewer_mbps <= 0.0 {
|
// Guard non-finite / non-positive inputs (only reachable from a corrupted
|
||||||
|
// config): a NaN safe_mbps would cast to 0 and an infinite one to u32::MAX,
|
||||||
|
// both of which break the "at least 1" contract.
|
||||||
|
if !safe_mbps.is_finite() || safe_mbps <= 0.0 || per_viewer_mbps <= 0.0 {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
let n = (safe_mbps / per_viewer_mbps).floor();
|
let n = (safe_mbps / per_viewer_mbps).floor();
|
||||||
if n < 1.0 { 1 } else { n as u32 }
|
if n < 1.0 { 1 } else { n as u32 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::recommended_max_viewers;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn divides_bandwidth_by_per_viewer_bitrate() {
|
||||||
|
// 8 Mbps safe / 2 Mbps each = 4 viewers.
|
||||||
|
assert_eq!(recommended_max_viewers(8.0, 2000), 4);
|
||||||
|
// Floors the fractional part: 7.9 / 2 = 3.95 -> 3.
|
||||||
|
assert_eq!(recommended_max_viewers(7.9, 2000), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn floors_to_at_least_one() {
|
||||||
|
// Not even enough for one viewer still allows one (best effort).
|
||||||
|
assert_eq!(recommended_max_viewers(0.5, 2000), 1);
|
||||||
|
// Zero / unknown bitrate can't size a budget; floor to one.
|
||||||
|
assert_eq!(recommended_max_viewers(8.0, 0), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn degenerate_inputs_floor_to_one() {
|
||||||
|
// A corrupted config must not yield 0 (NaN) or u32::MAX (Inf).
|
||||||
|
assert_eq!(recommended_max_viewers(f64::NAN, 2000), 1);
|
||||||
|
assert_eq!(recommended_max_viewers(f64::INFINITY, 2000), 1);
|
||||||
|
assert_eq!(recommended_max_viewers(-5.0, 2000), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+59
-13
@@ -1,8 +1,7 @@
|
|||||||
//! Persistent user-level config at `~/.config/pixelpass/config.toml`.
|
//! Persistent user-level config at `~/.config/pixelpass/config.toml`.
|
||||||
//!
|
//!
|
||||||
//! Right now this only tracks the bandwidth pre-flight result. Future
|
//! It tracks the bandwidth pre-flight result and the GUI's preferences.
|
||||||
//! preferences (default player, default bitrate, etc.) can hang off the
|
//! Further settings can hang off the same file under their own `[section]`.
|
||||||
//! same file under their own `[section]`.
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
@@ -16,6 +15,59 @@ use std::path::PathBuf;
|
|||||||
pub struct Config {
|
pub struct Config {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub bandwidth: BandwidthEntry,
|
pub bandwidth: BandwidthEntry,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gui: GuiSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preferences for the `pixelpass --gui` front-end.
|
||||||
|
#[derive(Debug, Clone, 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,
|
||||||
|
/// When true, the host screen renders a QR-code panel for the ticket.
|
||||||
|
/// Defaults to true; the toggle exists for users who prefer the plain
|
||||||
|
/// text-only host screen.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_qr: bool,
|
||||||
|
/// Name of the active GUI colour theme (a built-in, or a user file in
|
||||||
|
/// `~/.config/pixelpass/themes/`). Defaults to the built-in Default Dark.
|
||||||
|
#[serde(default = "default_theme")]
|
||||||
|
pub theme: String,
|
||||||
|
/// The display name shown to friends (in requests and shared codes).
|
||||||
|
/// Seeded from the login name; editable in Settings.
|
||||||
|
#[serde(default = "default_display_name")]
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GuiSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
close_to_tray: false,
|
||||||
|
show_qr: true,
|
||||||
|
theme: default_theme(),
|
||||||
|
display_name: default_display_name(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_theme() -> String {
|
||||||
|
"Default Dark".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seed the friends display name from the login name, falling back to a
|
||||||
|
/// generic label when `$USER` isn't set.
|
||||||
|
fn default_display_name() -> String {
|
||||||
|
std::env::var("USER")
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| "PixelPass user".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of the first-run upstream measurement.
|
/// Result of the first-run upstream measurement.
|
||||||
@@ -37,19 +89,15 @@ pub struct BandwidthEntry {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
|
#[derive(Default)]
|
||||||
pub enum BandwidthStatus {
|
pub enum BandwidthStatus {
|
||||||
|
#[default]
|
||||||
Unmeasured,
|
Unmeasured,
|
||||||
Measured,
|
Measured,
|
||||||
Skipped,
|
Skipped,
|
||||||
Failed,
|
Failed,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for BandwidthStatus {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Unmeasured
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_status() -> BandwidthStatus {
|
fn default_status() -> BandwidthStatus {
|
||||||
BandwidthStatus::Unmeasured
|
BandwidthStatus::Unmeasured
|
||||||
}
|
}
|
||||||
@@ -81,11 +129,9 @@ pub fn save(cfg: &Config) -> Result<()> {
|
|||||||
let parent = path
|
let parent = path
|
||||||
.parent()
|
.parent()
|
||||||
.context("config path has no parent directory")?;
|
.context("config path has no parent directory")?;
|
||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?;
|
||||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
|
||||||
|
|
||||||
let serialized =
|
let serialized = toml::to_string_pretty(cfg).context("failed to serialize config to TOML")?;
|
||||||
toml::to_string_pretty(cfg).context("failed to serialize config to TOML")?;
|
|
||||||
|
|
||||||
let tmp = parent.join(format!(".config.toml.tmp.{}", std::process::id()));
|
let tmp = parent.join(format!(".config.toml.tmp.{}", std::process::id()));
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
//! Friends control-plane protocol and service.
|
||||||
|
//!
|
||||||
|
//! This is the always-on presence channel that rides the [`CONTROL_ALPN`]
|
||||||
|
//! endpoint (bound with the persistent identity — see
|
||||||
|
//! [`super::endpoint::bind_control`]). It's how two peers' GUIs exchange friend
|
||||||
|
//! requests and pushed share-codes, independent of any video session.
|
||||||
|
//!
|
||||||
|
//! Wire shape: **one message per connection.** The sender opens a bi-stream,
|
||||||
|
//! writes the JSON-encoded [`ControlMsg`], and finishes its send side (EOF
|
||||||
|
//! delimits the message — no length framing needed). The receiver reads to EOF,
|
||||||
|
//! parses, hands the message up, then writes a one-byte [`ACK`] back so the
|
||||||
|
//! sender knows it was delivered *and* parsed. That delivery signal is what
|
||||||
|
//! lets the host-side code-push queue (a later phase) tell "sent" from "friend
|
||||||
|
//! was offline." A friend's *reply* (accept/decline) is a separate later
|
||||||
|
//! connection in the other direction, because acceptance can happen minutes
|
||||||
|
//! after the request — not a response on the same stream.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use iroh::endpoint::{Incoming, VarInt};
|
||||||
|
use iroh::{Endpoint, EndpointAddr, EndpointId};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
use super::alpn::CONTROL_ALPN;
|
||||||
|
|
||||||
|
/// Upper bound on a single control message. Generous for a display name plus a
|
||||||
|
/// share-code ticket (~150 chars); rejects a peer trying to make us buffer a
|
||||||
|
/// huge blob.
|
||||||
|
const MAX_MSG: usize = 64 * 1024;
|
||||||
|
|
||||||
|
/// One-byte application acknowledgement the receiver returns once it has parsed
|
||||||
|
/// a message. ASCII ACK (0x06).
|
||||||
|
const ACK: &[u8] = b"\x06";
|
||||||
|
|
||||||
|
/// Bound on each phase of the send handshake, so a half-dead peer or relay
|
||||||
|
/// can't park a sender (or an inbound handler) forever.
|
||||||
|
const IO_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// A message on the friends control plane.
|
||||||
|
///
|
||||||
|
/// `#[serde(tag = "type")]` keeps the JSON self-describing and lets us add
|
||||||
|
/// variants without breaking older peers (an unknown tag fails to parse and is
|
||||||
|
/// logged, rather than being silently misread as another variant).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum ControlMsg {
|
||||||
|
/// "I'm online; here's my current display name." A presence/name refresh.
|
||||||
|
Hello { name: String },
|
||||||
|
/// Ask the recipient to become friends.
|
||||||
|
FriendRequest { name: String },
|
||||||
|
/// Accept a request the recipient previously sent us.
|
||||||
|
FriendAccept { name: String },
|
||||||
|
/// Decline a pending request, or cancel an outgoing one.
|
||||||
|
FriendDecline,
|
||||||
|
/// A host pushing a freshly generated share-code to an accepted friend.
|
||||||
|
ShareCode { name: String, ticket: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A received control message, paired with the *authenticated* sender id (the
|
||||||
|
/// connection's verified remote public key — not a value the peer can spoof in
|
||||||
|
/// the payload, which is why no variant carries a sender id).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Inbound {
|
||||||
|
pub from: EndpointId,
|
||||||
|
pub msg: ControlMsg,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode(msg: &ControlMsg) -> Result<Vec<u8>> {
|
||||||
|
serde_json::to_vec(msg).context("failed to encode control message")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode(bytes: &[u8]) -> Result<ControlMsg> {
|
||||||
|
serde_json::from_slice(bytes).context("failed to decode control message")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deliver one message to `peer` over `endpoint`, returning once the recipient
|
||||||
|
/// has acknowledged it. An error means it was *not* delivered (peer offline,
|
||||||
|
/// unreachable, or rejected the stream) — the caller can queue and retry.
|
||||||
|
///
|
||||||
|
/// `peer` is usually a bare [`EndpointId`] — friends store only the stable id,
|
||||||
|
/// and n0 DNS discovery resolves it to a live address. The full [`EndpointAddr`]
|
||||||
|
/// form exists for callers that already hold one (and for hermetic tests).
|
||||||
|
pub async fn send(
|
||||||
|
endpoint: &Endpoint,
|
||||||
|
peer: impl Into<EndpointAddr>,
|
||||||
|
msg: &ControlMsg,
|
||||||
|
) -> Result<()> {
|
||||||
|
let payload = encode(msg)?;
|
||||||
|
let conn = tokio::time::timeout(IO_TIMEOUT, endpoint.connect(peer, CONTROL_ALPN))
|
||||||
|
.await
|
||||||
|
.context("timed out connecting to peer")?
|
||||||
|
.context("failed to connect to peer")?;
|
||||||
|
|
||||||
|
let io = async {
|
||||||
|
let (mut send, mut recv) = conn
|
||||||
|
.open_bi()
|
||||||
|
.await
|
||||||
|
.context("failed to open control stream")?;
|
||||||
|
send.write_all(&payload)
|
||||||
|
.await
|
||||||
|
.context("failed to write control message")?;
|
||||||
|
send.finish().context("failed to finish control stream")?;
|
||||||
|
// Read the peer's ACK. read_to_end returns once the peer finishes its
|
||||||
|
// send side, so this also serves as "the peer is done with us."
|
||||||
|
let ack = recv
|
||||||
|
.read_to_end(ACK.len() + 1)
|
||||||
|
.await
|
||||||
|
.context("peer closed the control stream without acknowledging")?;
|
||||||
|
if ack != ACK {
|
||||||
|
bail!(
|
||||||
|
"peer sent an unexpected acknowledgement ({} bytes)",
|
||||||
|
ack.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = tokio::time::timeout(IO_TIMEOUT, io)
|
||||||
|
.await
|
||||||
|
.context("timed out sending control message")?;
|
||||||
|
// Clean close so the peer's `closed().await` returns promptly either way.
|
||||||
|
conn.close(VarInt::from_u32(0), b"done");
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the control-plane accept loop, forwarding every received message to
|
||||||
|
/// `tx`. Returns when the endpoint stops accepting (i.e. it was closed).
|
||||||
|
pub async fn serve(endpoint: Endpoint, tx: mpsc::Sender<Inbound>) {
|
||||||
|
while let Some(incoming) = endpoint.accept().await {
|
||||||
|
let tx = tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = handle(incoming, &tx).await {
|
||||||
|
tracing::warn!("control: inbound connection failed: {e:#}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tracing::info!("control: endpoint stopped accepting");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle(incoming: Incoming, tx: &mpsc::Sender<Inbound>) -> Result<()> {
|
||||||
|
let conn = incoming
|
||||||
|
.await
|
||||||
|
.context("inbound control connection failed")?;
|
||||||
|
let from = conn.remote_id();
|
||||||
|
|
||||||
|
let msg = async {
|
||||||
|
let (mut send, mut recv) = conn
|
||||||
|
.accept_bi()
|
||||||
|
.await
|
||||||
|
.context("failed to accept control stream")?;
|
||||||
|
let bytes = recv
|
||||||
|
.read_to_end(MAX_MSG)
|
||||||
|
.await
|
||||||
|
.context("failed to read control message")?;
|
||||||
|
let msg = decode(&bytes)?;
|
||||||
|
// ACK only after a successful parse, so the sender's delivery signal
|
||||||
|
// means "received and understood."
|
||||||
|
send.write_all(ACK).await.context("failed to write ack")?;
|
||||||
|
send.finish().context("failed to finish ack stream")?;
|
||||||
|
Ok::<_, anyhow::Error>(msg)
|
||||||
|
};
|
||||||
|
|
||||||
|
let msg = tokio::time::timeout(IO_TIMEOUT, msg)
|
||||||
|
.await
|
||||||
|
.context("timed out reading control message")??;
|
||||||
|
|
||||||
|
// Wait (briefly) for the sender's close so our ACK flushes before the
|
||||||
|
// connection is dropped at the end of this scope.
|
||||||
|
let _ = tokio::time::timeout(IO_TIMEOUT, conn.closed()).await;
|
||||||
|
|
||||||
|
tx.send(Inbound { from, msg })
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("control: receiver dropped"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn control_msg_round_trips() {
|
||||||
|
let cases = [
|
||||||
|
ControlMsg::Hello {
|
||||||
|
name: "alice".into(),
|
||||||
|
},
|
||||||
|
ControlMsg::FriendRequest { name: "bob".into() },
|
||||||
|
ControlMsg::FriendAccept {
|
||||||
|
name: "carol".into(),
|
||||||
|
},
|
||||||
|
ControlMsg::FriendDecline,
|
||||||
|
ControlMsg::ShareCode {
|
||||||
|
name: "dave".into(),
|
||||||
|
ticket: "endpointaa…".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for msg in cases {
|
||||||
|
let bytes = encode(&msg).unwrap();
|
||||||
|
assert_eq!(decode(&bytes).unwrap(), msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_tag_is_rejected() {
|
||||||
|
assert!(decode(br#"{"type":"nonsense"}"#).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind a control-plane endpoint with a *fresh* random key, so two of them
|
||||||
|
/// in one test get distinct ids (two real machines each have their own
|
||||||
|
/// persistent key; `bind_control` would give both the same one here, and
|
||||||
|
/// iroh refuses "connecting to ourself").
|
||||||
|
async fn bind_test_control() -> Endpoint {
|
||||||
|
iroh::Endpoint::builder(iroh::endpoint::presets::N0)
|
||||||
|
.secret_key(iroh::SecretKey::generate())
|
||||||
|
.alpns(vec![CONTROL_ALPN.to_vec()])
|
||||||
|
.bind()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End-to-end over two real iroh endpoints on this machine. Ignored by
|
||||||
|
/// default — it binds endpoints and waits on the relay, so it's slow and
|
||||||
|
/// network-dependent. Run with `cargo test -- --ignored control`.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "binds real iroh endpoints; run on demand"]
|
||||||
|
async fn loopback_delivers_and_acks() {
|
||||||
|
let server = bind_test_control().await;
|
||||||
|
let client = bind_test_control().await;
|
||||||
|
// Connect by full addr so the test doesn't depend on DNS discovery.
|
||||||
|
server.online().await;
|
||||||
|
client.online().await;
|
||||||
|
let server_addr = server.addr();
|
||||||
|
|
||||||
|
let (tx, mut rx) = mpsc::channel(4);
|
||||||
|
let server_ep = server.clone();
|
||||||
|
let serve_task = tokio::spawn(async move { serve(server_ep, tx).await });
|
||||||
|
|
||||||
|
let msg = ControlMsg::FriendRequest {
|
||||||
|
name: "tester".into(),
|
||||||
|
};
|
||||||
|
// Full addr (not just the id) so the test doesn't depend on DNS discovery.
|
||||||
|
send(&client, server_addr.clone(), &msg).await.unwrap();
|
||||||
|
|
||||||
|
let got = tokio::time::timeout(Duration::from_secs(15), rx.recv())
|
||||||
|
.await
|
||||||
|
.expect("no inbound within 15s")
|
||||||
|
.expect("channel closed");
|
||||||
|
assert_eq!(got.msg, msg);
|
||||||
|
assert_eq!(got.from, client.addr().id);
|
||||||
|
|
||||||
|
server.close().await;
|
||||||
|
client.close().await;
|
||||||
|
serve_task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
-14
@@ -85,20 +85,24 @@ fn install_hint_for_bin(bin: &str) -> String {
|
|||||||
let distro = detect_distro();
|
let distro = detect_distro();
|
||||||
let pkg = match bin {
|
let pkg = match bin {
|
||||||
"gst-launch-1.0" | "gst-inspect-1.0" => match distro.as_deref() {
|
"gst-launch-1.0" | "gst-inspect-1.0" => match distro.as_deref() {
|
||||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gstreamer gst-plugins-base",
|
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||||
|
"gstreamer gst-plugins-base"
|
||||||
|
}
|
||||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-tools",
|
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-tools",
|
||||||
Some("fedora" | "nobara") => "gstreamer1 gstreamer1-plugins-base-tools",
|
Some("fedora" | "nobara") => "gstreamer1 gstreamer1-plugins-base-tools",
|
||||||
_ => "gstreamer + tools",
|
_ => "gstreamer + tools",
|
||||||
},
|
},
|
||||||
"pactl" => match distro.as_deref() {
|
"pactl" => match distro.as_deref() {
|
||||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "libpulse",
|
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => "libpulse",
|
||||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "pulseaudio-utils",
|
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "pulseaudio-utils",
|
||||||
Some("fedora" | "nobara") => "pulseaudio-utils",
|
Some("fedora" | "nobara") => "pulseaudio-utils",
|
||||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "pulseaudio-utils",
|
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "pulseaudio-utils",
|
||||||
_ => "pulseaudio-utils (provides `pactl`)",
|
_ => "pulseaudio-utils (provides `pactl`)",
|
||||||
},
|
},
|
||||||
"xwininfo" => match distro.as_deref() {
|
"xwininfo" => match distro.as_deref() {
|
||||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "xorg-xwininfo",
|
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||||
|
"xorg-xwininfo"
|
||||||
|
}
|
||||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "x11-utils",
|
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "x11-utils",
|
||||||
Some("fedora" | "nobara") => "xorg-x11-utils",
|
Some("fedora" | "nobara") => "xorg-x11-utils",
|
||||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "xwininfo",
|
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "xwininfo",
|
||||||
@@ -113,56 +117,74 @@ fn install_hint_for_gst_element(name: &str) -> String {
|
|||||||
let distro = detect_distro();
|
let distro = detect_distro();
|
||||||
let pkg = match name {
|
let pkg = match name {
|
||||||
"pipewiresrc" => match distro.as_deref() {
|
"pipewiresrc" => match distro.as_deref() {
|
||||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugin-pipewire",
|
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||||
|
"gst-plugin-pipewire"
|
||||||
|
}
|
||||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-pipewire",
|
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-pipewire",
|
||||||
Some("fedora" | "nobara") => "pipewire-gstreamer",
|
Some("fedora" | "nobara") => "pipewire-gstreamer",
|
||||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "pipewire-gstreamer",
|
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "pipewire-gstreamer",
|
||||||
_ => "the GStreamer PipeWire plugin",
|
_ => "the GStreamer PipeWire plugin",
|
||||||
},
|
},
|
||||||
"vah264enc" => match distro.as_deref() {
|
"vah264enc" => match distro.as_deref() {
|
||||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugin-va",
|
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||||
|
"gst-plugin-va"
|
||||||
|
}
|
||||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-bad",
|
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-bad",
|
||||||
Some("fedora" | "nobara") => "gstreamer1-plugins-bad-free",
|
Some("fedora" | "nobara") => "gstreamer1-plugins-bad-free",
|
||||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-bad",
|
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-bad",
|
||||||
_ => "the GStreamer VA-API plugin (requires an H.264-capable GPU; almost all modern GPUs)",
|
_ => {
|
||||||
|
"the GStreamer VA-API plugin (requires an H.264-capable GPU; almost all modern GPUs)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"x264enc" => match distro.as_deref() {
|
"x264enc" => match distro.as_deref() {
|
||||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-ugly",
|
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||||
|
"gst-plugins-ugly"
|
||||||
|
}
|
||||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-ugly",
|
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-ugly",
|
||||||
Some("fedora" | "nobara") => "gstreamer1-plugins-ugly",
|
Some("fedora" | "nobara") => "gstreamer1-plugins-ugly",
|
||||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-ugly",
|
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-ugly",
|
||||||
_ => "the GStreamer x264 plugin (plugins-ugly)",
|
_ => "the GStreamer x264 plugin (plugins-ugly)",
|
||||||
},
|
},
|
||||||
"ximagesrc" => match distro.as_deref() {
|
"ximagesrc" => match distro.as_deref() {
|
||||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-good",
|
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||||
|
"gst-plugins-good"
|
||||||
|
}
|
||||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-good",
|
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-good",
|
||||||
Some("fedora" | "nobara") => "gstreamer1-plugins-good",
|
Some("fedora" | "nobara") => "gstreamer1-plugins-good",
|
||||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-good",
|
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-good",
|
||||||
_ => "the GStreamer X11 plugin (plugins-good)",
|
_ => "the GStreamer X11 plugin (plugins-good)",
|
||||||
},
|
},
|
||||||
"videoscale" => match distro.as_deref() {
|
"videoscale" => match distro.as_deref() {
|
||||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-base",
|
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||||
|
"gst-plugins-base"
|
||||||
|
}
|
||||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-base",
|
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-base",
|
||||||
Some("fedora" | "nobara") => "gstreamer1-plugins-base",
|
Some("fedora" | "nobara") => "gstreamer1-plugins-base",
|
||||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-base",
|
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-base",
|
||||||
_ => "the GStreamer plugins-base set",
|
_ => "the GStreamer plugins-base set",
|
||||||
},
|
},
|
||||||
"h264parse" | "mpegtsmux" | "aacparse" => match distro.as_deref() {
|
"h264parse" | "mpegtsmux" | "aacparse" => match distro.as_deref() {
|
||||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-bad",
|
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||||
|
"gst-plugins-bad"
|
||||||
|
}
|
||||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-bad",
|
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-bad",
|
||||||
Some("fedora" | "nobara") => "gstreamer1-plugins-bad-free",
|
Some("fedora" | "nobara") => "gstreamer1-plugins-bad-free",
|
||||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-bad",
|
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-bad",
|
||||||
_ => "the GStreamer plugins-bad set",
|
_ => "the GStreamer plugins-bad set",
|
||||||
},
|
},
|
||||||
"pulsesrc" => match distro.as_deref() {
|
"pulsesrc" => match distro.as_deref() {
|
||||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-good",
|
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||||
|
"gst-plugins-good"
|
||||||
|
}
|
||||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-pulseaudio",
|
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-pulseaudio",
|
||||||
Some("fedora" | "nobara") => "gstreamer1-plugins-good",
|
Some("fedora" | "nobara") => "gstreamer1-plugins-good",
|
||||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-good",
|
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-good",
|
||||||
_ => "the GStreamer PulseAudio plugin",
|
_ => "the GStreamer PulseAudio plugin",
|
||||||
},
|
},
|
||||||
"avenc_aac" => match distro.as_deref() {
|
"avenc_aac" => match distro.as_deref() {
|
||||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-libav",
|
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||||
|
"gst-libav"
|
||||||
|
}
|
||||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-libav",
|
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-libav",
|
||||||
Some("fedora" | "nobara") => "gstreamer1-libav",
|
Some("fedora" | "nobara") => "gstreamer1-libav",
|
||||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-libav",
|
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-libav",
|
||||||
@@ -175,10 +197,14 @@ fn install_hint_for_gst_element(name: &str) -> String {
|
|||||||
|
|
||||||
fn install_command(distro: &Option<String>, pkg: &str) -> String {
|
fn install_command(distro: &Option<String>, pkg: &str) -> String {
|
||||||
let cmd = match distro.as_deref() {
|
let cmd = match distro.as_deref() {
|
||||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => format!("sudo pacman -S {pkg}"),
|
Some("arch" | "cachyos" | "manjaro" | "endeavouros" | "artix" | "garuda") => {
|
||||||
|
format!("sudo pacman -S {pkg}")
|
||||||
|
}
|
||||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => format!("sudo apt install {pkg}"),
|
Some("debian" | "ubuntu" | "pop" | "linuxmint") => format!("sudo apt install {pkg}"),
|
||||||
Some("fedora" | "nobara") => format!("sudo dnf install {pkg}"),
|
Some("fedora" | "nobara") => format!("sudo dnf install {pkg}"),
|
||||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => format!("sudo zypper install {pkg}"),
|
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => {
|
||||||
|
format!("sudo zypper install {pkg}")
|
||||||
|
}
|
||||||
_ => format!("install the `{pkg}` package via your distro's package manager"),
|
_ => format!("install the `{pkg}` package via your distro's package manager"),
|
||||||
};
|
};
|
||||||
format!("Install hint: {cmd}")
|
format!("Install hint: {cmd}")
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//! Shared iroh endpoint construction.
|
||||||
|
//!
|
||||||
|
//! Two planes, two identities:
|
||||||
|
//!
|
||||||
|
//! * The **video** plane (host/viewer sessions) binds with an *ephemeral*
|
||||||
|
//! keypair — a fresh `EndpointId` per run. Each session is a throwaway tunnel,
|
||||||
|
//! and keeping its id ephemeral means a screen-share leaks no stable
|
||||||
|
//! fingerprint.
|
||||||
|
//! * The **control** plane (the always-on friends presence service) binds with
|
||||||
|
//! the machine's *persistent* identity (see [`identity`]), so peers can find
|
||||||
|
//! and recognise each other across launches.
|
||||||
|
//!
|
||||||
|
//! They must use different identities because both can be live at once on the
|
||||||
|
//! same machine (the GUI's control endpoint while a host session runs), and
|
||||||
|
//! iroh routes by `EndpointId` — two live endpoints sharing one id would make
|
||||||
|
//! relay delivery ambiguous.
|
||||||
|
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use iroh::endpoint::presets;
|
||||||
|
use iroh::{Endpoint, RelayMap, RelayMode, RelayUrl};
|
||||||
|
|
||||||
|
use super::alpn::ALPN;
|
||||||
|
|
||||||
|
/// Environment variable consulted when `--relay` isn't passed. Lets the GUI's
|
||||||
|
/// child processes and scripted runs inherit a relay choice without a flag.
|
||||||
|
pub const RELAY_ENV: &str = "PIXELPASS_RELAY";
|
||||||
|
|
||||||
|
/// Resolve the relay override: explicit `--relay` wins, else `PIXELPASS_RELAY`,
|
||||||
|
/// else `None` (use the bundled defaults).
|
||||||
|
pub fn relay_override(flag: Option<&str>) -> Option<String> {
|
||||||
|
flag.map(str::to_owned).or_else(|| {
|
||||||
|
std::env::var(RELAY_ENV)
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind a **video-plane** endpoint (host/viewer) with an ephemeral identity.
|
||||||
|
///
|
||||||
|
/// With no `relay` override we use [`presets::N0`] — n0 DNS discovery, the
|
||||||
|
/// library's default relays, and the chosen crypto provider. With an override
|
||||||
|
/// we keep all of that but swap in a single custom relay via
|
||||||
|
/// [`RelayMode::Custom`]; this is how a user gets off the rc's bundled
|
||||||
|
/// (canary-grade) relays or points at a self-hosted one. Discovery is
|
||||||
|
/// unchanged, so peers still resolve each other by endpoint id.
|
||||||
|
pub async fn bind(relay: Option<&str>) -> Result<Endpoint> {
|
||||||
|
// No `secret_key` set → iroh mints a fresh ephemeral keypair for this run.
|
||||||
|
bind_with(relay, None, ALPN).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind the **control-plane** endpoint with the machine's persistent identity
|
||||||
|
/// (see [`super::identity`]) and the friends [`super::alpn::CONTROL_ALPN`]. Its
|
||||||
|
/// `EndpointId` is the stable id friends know you by.
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
pub async fn bind_control(relay: Option<&str>) -> Result<Endpoint> {
|
||||||
|
let secret_key = super::identity::load_or_create()?;
|
||||||
|
bind_with(relay, Some(secret_key), super::alpn::CONTROL_ALPN).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared builder: optional persistent key (None → ephemeral) + the plane's ALPN.
|
||||||
|
async fn bind_with(
|
||||||
|
relay: Option<&str>,
|
||||||
|
key: Option<iroh::SecretKey>,
|
||||||
|
alpn: &[u8],
|
||||||
|
) -> Result<Endpoint> {
|
||||||
|
let mut builder = Endpoint::builder(presets::N0).alpns(vec![alpn.to_vec()]);
|
||||||
|
if let Some(key) = key {
|
||||||
|
builder = builder.secret_key(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(url) = relay {
|
||||||
|
let url = RelayUrl::from_str(url).with_context(|| {
|
||||||
|
format!("invalid relay URL {url:?} (expected e.g. https://relay.example/)")
|
||||||
|
})?;
|
||||||
|
builder = builder.relay_mode(RelayMode::Custom(RelayMap::from(url)));
|
||||||
|
}
|
||||||
|
|
||||||
|
builder
|
||||||
|
.bind()
|
||||||
|
.await
|
||||||
|
.context("failed to bind the iroh endpoint")
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
//! Persistent friends store at `~/.config/pixelpass/friends.toml`.
|
||||||
|
//!
|
||||||
|
//! Kept in its own file rather than a `[friends]` section of `config.toml` so
|
||||||
|
//! the headless CLI — which never manages friends and would round-trip the
|
||||||
|
//! config without this knowledge — can't drop the list on a `--reconfigure`.
|
||||||
|
//! Same reasoning as the separate `identity.key`.
|
||||||
|
//!
|
||||||
|
//! A friend is identified by their stable control-plane [`EndpointId`] (the id
|
||||||
|
//! from [`super::endpoint::bind_control`]). `EndpointId` serialises as its
|
||||||
|
//! string form in TOML, so the file is human-readable and hand-editable.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use iroh::EndpointId;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Where a friendship sits in the mutual-consent handshake.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum FriendState {
|
||||||
|
/// We've sent them a request and are waiting for them to accept.
|
||||||
|
PendingOutgoing,
|
||||||
|
/// They've requested us; waiting for the local user to accept or decline.
|
||||||
|
PendingIncoming,
|
||||||
|
/// Both sides have agreed — a real friend.
|
||||||
|
Accepted,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry in the friends list.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Friend {
|
||||||
|
pub id: EndpointId,
|
||||||
|
/// Display name — seeded from the name the peer reported, locally editable.
|
||||||
|
pub name: String,
|
||||||
|
pub state: FriendState,
|
||||||
|
/// Whether the host auto-shares its session code with this friend. Toggled
|
||||||
|
/// on the host's share picker; persisted here so the choice survives a
|
||||||
|
/// restart. Defaults to `true` so a newly added friend is included (and an
|
||||||
|
/// older `friends.toml` without the field loads as share-with-all).
|
||||||
|
#[serde(default = "default_share")]
|
||||||
|
pub share: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_share() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The persisted friends list. Serialises as a TOML array of tables
|
||||||
|
/// (`[[friends]]`).
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct FriendStore {
|
||||||
|
#[serde(default)]
|
||||||
|
pub friends: Vec<Friend>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `~/.config/pixelpass/friends.toml`. Shares the config directory with
|
||||||
|
/// [`super::config`]; the parent is created on save.
|
||||||
|
pub fn friends_path() -> Result<PathBuf> {
|
||||||
|
Ok(super::config::config_path()?
|
||||||
|
.parent()
|
||||||
|
.context("config path has no parent directory")?
|
||||||
|
.join("friends.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the store, or a default (empty) one if the file doesn't exist yet.
|
||||||
|
/// Parse errors bubble up so a hand-edit being debugged isn't silently
|
||||||
|
/// overwritten.
|
||||||
|
pub fn load() -> Result<FriendStore> {
|
||||||
|
let path = friends_path()?;
|
||||||
|
match fs::read_to_string(&path) {
|
||||||
|
Ok(s) => toml::from_str(&s).with_context(|| format!("failed to parse {}", path.display())),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FriendStore::default()),
|
||||||
|
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FriendStore {
|
||||||
|
/// Atomic write via tempfile-in-same-dir + rename (mirrors
|
||||||
|
/// [`super::config::save`]).
|
||||||
|
pub fn save(&self) -> Result<()> {
|
||||||
|
let path = friends_path()?;
|
||||||
|
let parent = path
|
||||||
|
.parent()
|
||||||
|
.context("friends path has no parent directory")?;
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||||
|
|
||||||
|
let serialized = toml::to_string_pretty(self).context("failed to serialize friends")?;
|
||||||
|
let tmp = parent.join(format!(".friends.toml.tmp.{}", std::process::id()));
|
||||||
|
{
|
||||||
|
let mut f = fs::File::create(&tmp)
|
||||||
|
.with_context(|| format!("failed to create {}", tmp.display()))?;
|
||||||
|
f.write_all(serialized.as_bytes())
|
||||||
|
.with_context(|| format!("failed to write {}", tmp.display()))?;
|
||||||
|
f.sync_all().ok();
|
||||||
|
}
|
||||||
|
fs::rename(&tmp, &path)
|
||||||
|
.with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find(&self, id: &EndpointId) -> Option<&Friend> {
|
||||||
|
self.friends.iter().find(|f| &f.id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_mut(&mut self, id: &EndpointId) -> Option<&mut Friend> {
|
||||||
|
self.friends.iter_mut().find(|f| &f.id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True iff this id is a fully-accepted friend — the gate the code-push
|
||||||
|
/// (Phase 4) and "is this a known friend?" checks use.
|
||||||
|
pub fn is_accepted(&self, id: &EndpointId) -> bool {
|
||||||
|
matches!(
|
||||||
|
self.find(id),
|
||||||
|
Some(Friend {
|
||||||
|
state: FriendState::Accepted,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a new friend, or update an existing one's `name`/`state` in place.
|
||||||
|
/// Returns a mutable reference to the stored entry.
|
||||||
|
pub fn upsert(&mut self, id: EndpointId, name: String, state: FriendState) -> &mut Friend {
|
||||||
|
if let Some(idx) = self.friends.iter().position(|f| f.id == id) {
|
||||||
|
let f = &mut self.friends[idx];
|
||||||
|
f.name = name;
|
||||||
|
f.state = state;
|
||||||
|
f
|
||||||
|
} else {
|
||||||
|
self.friends.push(Friend {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
state,
|
||||||
|
share: true,
|
||||||
|
});
|
||||||
|
self.friends.last_mut().expect("just pushed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a friend by id. Returns whether an entry was removed.
|
||||||
|
pub fn remove(&mut self, id: &EndpointId) -> bool {
|
||||||
|
let before = self.friends.len();
|
||||||
|
self.friends.retain(|f| &f.id != id);
|
||||||
|
self.friends.len() != before
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply an inbound friend request. Returns `true` if it *completes a mutual
|
||||||
|
/// match* — we'd already sent them one, so they're now [`Accepted`] and the
|
||||||
|
/// caller should reply with a `FriendAccept`. Otherwise it's recorded as
|
||||||
|
/// [`PendingIncoming`] for the user to act on and `false` is returned.
|
||||||
|
///
|
||||||
|
/// [`Accepted`]: FriendState::Accepted
|
||||||
|
/// [`PendingIncoming`]: FriendState::PendingIncoming
|
||||||
|
pub fn on_friend_request(&mut self, id: EndpointId, name: String) -> bool {
|
||||||
|
if matches!(
|
||||||
|
self.find(&id).map(|f| f.state),
|
||||||
|
Some(FriendState::PendingOutgoing)
|
||||||
|
) {
|
||||||
|
self.upsert(id, name, FriendState::Accepted);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
self.upsert(id, name, FriendState::PendingIncoming);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply an inbound acceptance of a request we sent. Returns `true` if it
|
||||||
|
/// advanced a friendship to [`Accepted`] (i.e. we actually knew this peer);
|
||||||
|
/// an accept from a stranger is ignored.
|
||||||
|
///
|
||||||
|
/// [`Accepted`]: FriendState::Accepted
|
||||||
|
pub fn on_friend_accept(&mut self, id: EndpointId, name: String) -> bool {
|
||||||
|
if self.find(&id).is_some() {
|
||||||
|
self.upsert(id, name, FriendState::Accepted);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sample_id() -> EndpointId {
|
||||||
|
iroh::SecretKey::generate().public()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trips_through_toml() {
|
||||||
|
let mut store = FriendStore::default();
|
||||||
|
store.upsert(sample_id(), "Alice".into(), FriendState::Accepted);
|
||||||
|
store.upsert(sample_id(), "Bob".into(), FriendState::PendingIncoming);
|
||||||
|
|
||||||
|
let toml = toml::to_string_pretty(&store).unwrap();
|
||||||
|
let back: FriendStore = toml::from_str(&toml).unwrap();
|
||||||
|
assert_eq!(back.friends, store.friends);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_friends_default_to_shared_and_survive_round_trip() {
|
||||||
|
let mut store = FriendStore::default();
|
||||||
|
let id = sample_id();
|
||||||
|
store.upsert(id, "Alice".into(), FriendState::Accepted);
|
||||||
|
assert!(store.find(&id).unwrap().share, "new friends start shared");
|
||||||
|
|
||||||
|
// An older friends.toml predating the field loads as share-with-all.
|
||||||
|
let toml = format!("[[friends]]\nid = \"{id}\"\nname = \"Legacy\"\nstate = \"accepted\"\n");
|
||||||
|
let back: FriendStore = toml::from_str(&toml).unwrap();
|
||||||
|
assert!(back.friends[0].share);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upsert_preserves_share_across_refresh() {
|
||||||
|
let mut store = FriendStore::default();
|
||||||
|
let id = sample_id();
|
||||||
|
store.upsert(id, "Alice".into(), FriendState::Accepted);
|
||||||
|
store.find_mut(&id).unwrap().share = false;
|
||||||
|
// A later name/presence refresh re-upserts the same peer; the share
|
||||||
|
// choice must not be reset by it.
|
||||||
|
store.upsert(id, "Alice (new name)".into(), FriendState::Accepted);
|
||||||
|
assert!(!store.find(&id).unwrap().share);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upsert_updates_in_place() {
|
||||||
|
let mut store = FriendStore::default();
|
||||||
|
let id = sample_id();
|
||||||
|
store.upsert(id, "Old".into(), FriendState::PendingOutgoing);
|
||||||
|
store.upsert(id, "New".into(), FriendState::Accepted);
|
||||||
|
assert_eq!(store.friends.len(), 1);
|
||||||
|
let f = store.find(&id).unwrap();
|
||||||
|
assert_eq!(f.name, "New");
|
||||||
|
assert_eq!(f.state, FriendState::Accepted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_accepted_only_for_accepted_state() {
|
||||||
|
let mut store = FriendStore::default();
|
||||||
|
let pending = sample_id();
|
||||||
|
let friend = sample_id();
|
||||||
|
store.upsert(pending, "P".into(), FriendState::PendingOutgoing);
|
||||||
|
store.upsert(friend, "F".into(), FriendState::Accepted);
|
||||||
|
assert!(!store.is_accepted(&pending));
|
||||||
|
assert!(store.is_accepted(&friend));
|
||||||
|
assert!(!store.is_accepted(&sample_id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_reports_whether_present() {
|
||||||
|
let mut store = FriendStore::default();
|
||||||
|
let id = sample_id();
|
||||||
|
store.upsert(id, "X".into(), FriendState::Accepted);
|
||||||
|
assert!(store.remove(&id));
|
||||||
|
assert!(!store.remove(&id));
|
||||||
|
assert!(store.friends.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn incoming_request_from_stranger_is_pending() {
|
||||||
|
let mut store = FriendStore::default();
|
||||||
|
let id = sample_id();
|
||||||
|
let mutual = store.on_friend_request(id, "Stranger".into());
|
||||||
|
assert!(!mutual);
|
||||||
|
assert_eq!(store.find(&id).unwrap().state, FriendState::PendingIncoming);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn incoming_request_matching_our_outgoing_is_mutual() {
|
||||||
|
let mut store = FriendStore::default();
|
||||||
|
let id = sample_id();
|
||||||
|
// We asked them first…
|
||||||
|
store.upsert(id, "Pal".into(), FriendState::PendingOutgoing);
|
||||||
|
// …then their request arrives — that's a mutual match.
|
||||||
|
let mutual = store.on_friend_request(id, "Pal".into());
|
||||||
|
assert!(mutual);
|
||||||
|
assert_eq!(store.find(&id).unwrap().state, FriendState::Accepted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accept_advances_known_peer_only() {
|
||||||
|
let mut store = FriendStore::default();
|
||||||
|
let known = sample_id();
|
||||||
|
store.upsert(known, "Known".into(), FriendState::PendingOutgoing);
|
||||||
|
assert!(store.on_friend_accept(known, "Known".into()));
|
||||||
|
assert_eq!(store.find(&known).unwrap().state, FriendState::Accepted);
|
||||||
|
// An accept from someone we never asked is ignored.
|
||||||
|
let stranger = sample_id();
|
||||||
|
assert!(!store.on_friend_accept(stranger, "Nope".into()));
|
||||||
|
assert!(store.find(&stranger).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
//! Persistent node identity at `~/.config/pixelpass/identity.key`.
|
||||||
|
//!
|
||||||
|
//! Without this, [`super::endpoint::bind`] would let iroh mint a fresh random
|
||||||
|
//! keypair on every launch, so a peer's `EndpointId` would change each run.
|
||||||
|
//! The friends system identifies people by that id (it's the public key already
|
||||||
|
//! embedded in every share code), so it must stay stable across launches — and
|
||||||
|
//! across roles: the same machine gets the same id whether it's hosting,
|
||||||
|
//! viewing, or just sitting in the GUI.
|
||||||
|
//!
|
||||||
|
//! The key is the ed25519 secret (32 bytes) stored as hex on its own line, in a
|
||||||
|
//! `0600` file separate from `config.toml` — it's a secret, not a preference,
|
||||||
|
//! and keeping it out of the TOML means a hand-edit or a config reset can't
|
||||||
|
//! clobber your identity.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use iroh::SecretKey;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Returns `~/.config/pixelpass/identity.key` (or the XDG equivalent). Shares
|
||||||
|
/// the config directory with [`super::config`]; the parent is created on save.
|
||||||
|
pub fn identity_path() -> Result<PathBuf> {
|
||||||
|
Ok(super::config::config_path()?
|
||||||
|
.parent()
|
||||||
|
.context("config path has no parent directory")?
|
||||||
|
.join("identity.key"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the persisted secret key, or generate-and-save one on first run.
|
||||||
|
///
|
||||||
|
/// A malformed file is a hard error rather than a silent regenerate: silently
|
||||||
|
/// minting a new identity would orphan every friend who has the old id, so we'd
|
||||||
|
/// rather fail loud and let the user notice (and decide) than lose it quietly.
|
||||||
|
pub fn load_or_create() -> Result<SecretKey> {
|
||||||
|
let path = identity_path()?;
|
||||||
|
match fs::read_to_string(&path) {
|
||||||
|
Ok(s) => parse_key(s.trim())
|
||||||
|
.with_context(|| format!("failed to parse the identity key at {}", path.display())),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
let key = SecretKey::generate();
|
||||||
|
save(&key)?;
|
||||||
|
tracing::info!(id = %key.public(), "generated a new persistent identity");
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_key(hex: &str) -> Result<SecretKey> {
|
||||||
|
let bytes = decode_hex(hex)?;
|
||||||
|
let arr: [u8; 32] = bytes
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| anyhow::anyhow!("identity key must be 32 bytes (64 hex chars)"))?;
|
||||||
|
Ok(SecretKey::from_bytes(&arr))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomic, `0600` write: tempfile-in-same-dir, chmod, then rename. Same
|
||||||
|
/// approach as [`super::config::save`], but with restrictive perms applied
|
||||||
|
/// before the rename so the secret is never briefly world-readable.
|
||||||
|
pub fn save(key: &SecretKey) -> Result<()> {
|
||||||
|
let path = identity_path()?;
|
||||||
|
let parent = path
|
||||||
|
.parent()
|
||||||
|
.context("identity path has no parent directory")?;
|
||||||
|
fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?;
|
||||||
|
|
||||||
|
let tmp = parent.join(format!(".identity.key.tmp.{}", std::process::id()));
|
||||||
|
{
|
||||||
|
let mut f = fs::File::create(&tmp)
|
||||||
|
.with_context(|| format!("failed to create {}", tmp.display()))?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
f.set_permissions(fs::Permissions::from_mode(0o600))
|
||||||
|
.with_context(|| format!("failed to chmod {}", tmp.display()))?;
|
||||||
|
}
|
||||||
|
f.write_all(encode_hex(&key.to_bytes()).as_bytes())
|
||||||
|
.with_context(|| format!("failed to write {}", tmp.display()))?;
|
||||||
|
f.write_all(b"\n").ok();
|
||||||
|
f.sync_all().ok();
|
||||||
|
}
|
||||||
|
fs::rename(&tmp, &path)
|
||||||
|
.with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_hex(bytes: &[u8]) -> String {
|
||||||
|
let mut s = String::with_capacity(bytes.len() * 2);
|
||||||
|
for b in bytes {
|
||||||
|
s.push_str(&format!("{b:02x}"));
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_hex(s: &str) -> Result<Vec<u8>> {
|
||||||
|
if !s.len().is_multiple_of(2) {
|
||||||
|
bail!("hex string has an odd length");
|
||||||
|
}
|
||||||
|
(0..s.len())
|
||||||
|
.step_by(2)
|
||||||
|
.map(|i| {
|
||||||
|
u8::from_str_radix(&s[i..i + 2], 16)
|
||||||
|
.with_context(|| format!("invalid hex byte at offset {i}"))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hex_round_trips() {
|
||||||
|
let bytes: Vec<u8> = (0u8..=255).collect();
|
||||||
|
let encoded = encode_hex(&bytes);
|
||||||
|
assert_eq!(encoded.len(), bytes.len() * 2);
|
||||||
|
assert_eq!(decode_hex(&encoded).unwrap(), bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_round_trips_through_hex() {
|
||||||
|
let key = SecretKey::generate();
|
||||||
|
let hex = encode_hex(&key.to_bytes());
|
||||||
|
let parsed = parse_key(&hex).unwrap();
|
||||||
|
assert_eq!(parsed.to_bytes(), key.to_bytes());
|
||||||
|
assert_eq!(parsed.public(), key.public());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_wrong_length() {
|
||||||
|
assert!(parse_key("dead").is_err());
|
||||||
|
assert!(parse_key("").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_odd_and_nonhex() {
|
||||||
|
assert!(decode_hex("abc").is_err());
|
||||||
|
assert!(decode_hex("zz").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
pub mod alpn;
|
pub mod alpn;
|
||||||
pub mod bandwidth;
|
pub mod bandwidth;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
// The friends stack (persistent identity + control plane) is GUI-only — a
|
||||||
|
// headless CLI host runs no presence service — so it's gated with the feature
|
||||||
|
// that pulls the rest of the GUI, keeping the headless build lean.
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
pub mod control;
|
||||||
pub mod deps;
|
pub mod deps;
|
||||||
pub mod display;
|
pub mod display;
|
||||||
|
pub mod endpoint;
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
pub mod friends;
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
pub mod identity;
|
||||||
pub mod output;
|
pub mod output;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
pub mod signal;
|
pub mod signal;
|
||||||
|
|||||||
+11
-3
@@ -22,7 +22,11 @@ pub fn set_json(enabled: bool) {
|
|||||||
JSON_ENABLED.store(enabled, Ordering::Relaxed);
|
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)
|
JSON_ENABLED.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,8 +46,12 @@ pub enum Event<'a> {
|
|||||||
max_viewers: u32,
|
max_viewers: u32,
|
||||||
max_viewers_source: &'a str,
|
max_viewers_source: &'a str,
|
||||||
},
|
},
|
||||||
/// Active viewer count changed.
|
/// A viewer joined. `id` is the viewer's endpoint id; `active` is the new
|
||||||
ViewerCount { active: u32, max: u32 },
|
/// 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 pipeline lifecycle (spawned on first viewer, torn down on last).
|
||||||
Capture { state: CaptureState },
|
Capture { state: CaptureState },
|
||||||
/// A viewer was turned away (host full, or capture spawn failed).
|
/// A viewer was turned away (host full, or capture spawn failed).
|
||||||
|
|||||||
@@ -7,10 +7,17 @@ pub fn install_ctrl_c() -> CancellationToken {
|
|||||||
let token = CancellationToken::new();
|
let token = CancellationToken::new();
|
||||||
let trigger = token.clone();
|
let trigger = token.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if tokio::signal::ctrl_c().await.is_ok() {
|
if let Err(e) = tokio::signal::ctrl_c().await {
|
||||||
|
// Installing the handler failed — ctrl-c won't trigger a graceful
|
||||||
|
// shutdown. Say so instead of failing silently; the user can still
|
||||||
|
// kill the process, and the second-ctrl-c arm below would only fail
|
||||||
|
// the same way, so bail out of the task.
|
||||||
|
tracing::warn!("could not install ctrl-c handler: {e}; ctrl-c won't shut down cleanly");
|
||||||
|
return;
|
||||||
|
}
|
||||||
tracing::info!("ctrl-c received, shutting down");
|
tracing::info!("ctrl-c received, shutting down");
|
||||||
trigger.cancel();
|
trigger.cancel();
|
||||||
}
|
|
||||||
if tokio::signal::ctrl_c().await.is_ok() {
|
if tokio::signal::ctrl_c().await.is_ok() {
|
||||||
tracing::warn!("second ctrl-c — exiting now");
|
tracing::warn!("second ctrl-c — exiting now");
|
||||||
std::process::exit(130);
|
std::process::exit(130);
|
||||||
|
|||||||
+94
-40
@@ -6,17 +6,18 @@
|
|||||||
//! egui app drains each frame. stderr is captured into a small ring so a
|
//! 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.
|
//! failed launch (e.g. a missing gst plugin) can be surfaced in the window.
|
||||||
|
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader, Write};
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||||
use std::sync::mpsc::Receiver;
|
use std::sync::mpsc::Receiver;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use eframe::egui;
|
|
||||||
use nix::sys::signal::{Signal, kill};
|
use nix::sys::signal::{Signal, kill};
|
||||||
use nix::unistd::Pid;
|
use nix::unistd::Pid;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use super::Waker;
|
||||||
|
|
||||||
/// One parsed event from the child's stdout. Owned mirror of
|
/// One parsed event from the child's stdout. Owned mirror of
|
||||||
/// [`crate::common::output::Event`] (which borrows for emit); kept separate so
|
/// [`crate::common::output::Event`] (which borrows for emit); kept separate so
|
||||||
/// the wire format and the parser can evolve independently.
|
/// the wire format and the parser can evolve independently.
|
||||||
@@ -35,7 +36,13 @@ pub enum ChildEvent {
|
|||||||
max_viewers: u32,
|
max_viewers: u32,
|
||||||
max_viewers_source: String,
|
max_viewers_source: String,
|
||||||
},
|
},
|
||||||
ViewerCount {
|
ViewerJoined {
|
||||||
|
id: String,
|
||||||
|
active: u32,
|
||||||
|
max: u32,
|
||||||
|
},
|
||||||
|
ViewerLeft {
|
||||||
|
id: String,
|
||||||
active: u32,
|
active: u32,
|
||||||
max: u32,
|
max: u32,
|
||||||
},
|
},
|
||||||
@@ -60,23 +67,35 @@ pub enum CaptureState {
|
|||||||
const STDERR_TAIL_MAX: usize = 60;
|
const STDERR_TAIL_MAX: usize = 60;
|
||||||
|
|
||||||
pub struct ChildProc {
|
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>,
|
pub rx: Receiver<ChildEvent>,
|
||||||
stderr_tail: Arc<Mutex<Vec<String>>>,
|
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 {
|
impl ChildProc {
|
||||||
/// Spawn `pixelpass <args>` as a child, wiring up the event reader. `ctx`
|
/// Spawn `pixelpass <args>` as a child, wiring up the event reader. The
|
||||||
/// is repainted whenever an event arrives so the UI updates live.
|
/// `waker` is pinged whenever an event arrives so the UI thread wakes to
|
||||||
pub fn spawn(args: &[String], ctx: egui::Context) -> std::io::Result<Self> {
|
/// 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 exe = std::env::current_exe()?;
|
||||||
let mut child = Command::new(exe)
|
let mut child = Command::new(exe)
|
||||||
.args(args)
|
.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())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
.spawn()?;
|
.spawn()?;
|
||||||
|
|
||||||
|
let stdin = child.stdin.take();
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
let stdout = child.stdout.take().expect("stdout piped");
|
let stdout = child.stdout.take().expect("stdout piped");
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
@@ -90,9 +109,14 @@ impl ChildProc {
|
|||||||
if tx.send(ev).is_err() {
|
if tx.send(ev).is_err() {
|
||||||
break; // app gone
|
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()));
|
let stderr_tail = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||||
@@ -111,46 +135,64 @@ impl ChildProc {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
child,
|
child: Some(child),
|
||||||
rx,
|
rx,
|
||||||
stderr_tail,
|
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.
|
/// Whether the child is still running.
|
||||||
pub fn is_alive(&mut self) -> bool {
|
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.
|
/// The last captured stderr lines, joined — for error display.
|
||||||
pub fn stderr_tail(&self) -> String {
|
pub fn stderr_tail(&self) -> String {
|
||||||
self.stderr_tail.lock().unwrap().join("\n")
|
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 {
|
impl Drop for ChildProc {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
// Closing the window (dropping the app, hence the session) must not
|
// Leaving a host/viewer screen, or closing the window, must not orphan
|
||||||
// orphan a live host child streaming to viewers.
|
// a live child — but it must also not *block*. eframe runs this drop
|
||||||
self.stop();
|
// 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,22 +247,34 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn viewer_count_round_trips() {
|
fn viewer_join_leave_round_trip() {
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
parse(Event::ViewerCount { active: 2, max: 4 }),
|
parse(Event::ViewerJoined { id: "nodeXYZ", active: 2, max: 4 }),
|
||||||
ChildEvent::ViewerCount { 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"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn capture_state_round_trips() {
|
fn capture_state_round_trips() {
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
parse(Event::Capture { state: EmitState::Started }),
|
parse(Event::Capture {
|
||||||
ChildEvent::Capture { state: CaptureState::Started }
|
state: EmitState::Started
|
||||||
|
}),
|
||||||
|
ChildEvent::Capture {
|
||||||
|
state: CaptureState::Started
|
||||||
|
}
|
||||||
));
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
parse(Event::Capture { state: EmitState::Stopped }),
|
parse(Event::Capture {
|
||||||
ChildEvent::Capture { state: CaptureState::Stopped }
|
state: EmitState::Stopped
|
||||||
|
}),
|
||||||
|
ChildEvent::Capture {
|
||||||
|
state: CaptureState::Stopped
|
||||||
|
}
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
//! Share-code wrapping: carrying the host's stable friend id alongside the
|
||||||
|
//! one-shot video ticket.
|
||||||
|
//!
|
||||||
|
//! A bare video ticket identifies only the host's *ephemeral* video endpoint,
|
||||||
|
//! so two people who meet over one can't learn each other's stable friend id —
|
||||||
|
//! the thing the friends system needs. The GUI host therefore wraps its ticket
|
||||||
|
//! with its control-plane [`EndpointId`]; the viewer unwraps it, dials the
|
||||||
|
//! video ticket as before, and now also knows who to befriend (and announces
|
||||||
|
//! itself back over the control plane so the host learns the viewer in turn).
|
||||||
|
//!
|
||||||
|
//! Format: `pixelpassF1:<host-control-id>.<bare-ticket>`. Both the id and the
|
||||||
|
//! ticket are base32 text with no `.`, so a single `.` separator is
|
||||||
|
//! unambiguous. [`unwrap`] is lenient: anything without the prefix is treated
|
||||||
|
//! as a bare ticket, so a plain CLI ticket pasted into the GUI still works (it
|
||||||
|
//! just offers no friend option). The host name isn't carried here — the
|
||||||
|
//! viewer's announcement triggers a name exchange over the control plane.
|
||||||
|
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use iroh::EndpointId;
|
||||||
|
|
||||||
|
/// Prefix marking a wrapped friend code. The `F1` is the wrap-format version,
|
||||||
|
/// bumped if the layout ever changes.
|
||||||
|
const MAGIC: &str = "pixelpassF1:";
|
||||||
|
|
||||||
|
/// Wrap a bare ticket with the host's control id, for display/copy/QR.
|
||||||
|
pub fn wrap(host_id: EndpointId, ticket: &str) -> String {
|
||||||
|
format!("{MAGIC}{host_id}.{ticket}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split an input into `(host control id if it was a wrapped code, bare
|
||||||
|
/// ticket)`. A bare or unrecognised input yields `(None, trimmed input)` so the
|
||||||
|
/// viewer path stays identical to before for plain tickets.
|
||||||
|
pub fn unwrap(code: &str) -> (Option<EndpointId>, String) {
|
||||||
|
let code = code.trim();
|
||||||
|
if let Some(rest) = code.strip_prefix(MAGIC)
|
||||||
|
&& let Some((id_str, ticket)) = rest.split_once('.')
|
||||||
|
&& let Ok(id) = EndpointId::from_str(id_str)
|
||||||
|
&& !ticket.is_empty()
|
||||||
|
{
|
||||||
|
return (Some(id), ticket.to_string());
|
||||||
|
}
|
||||||
|
(None, code.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sample_id() -> EndpointId {
|
||||||
|
iroh::SecretKey::generate().public()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrap_unwrap_round_trips() {
|
||||||
|
let id = sample_id();
|
||||||
|
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
|
||||||
|
let code = wrap(id, ticket);
|
||||||
|
let (got_id, got_ticket) = unwrap(&code);
|
||||||
|
assert_eq!(got_id, Some(id));
|
||||||
|
assert_eq!(got_ticket, ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bare_ticket_passes_through() {
|
||||||
|
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
|
||||||
|
let (id, got) = unwrap(ticket);
|
||||||
|
assert_eq!(id, None);
|
||||||
|
assert_eq!(got, ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trims_surrounding_whitespace() {
|
||||||
|
let ticket = "endpointaabwxjex";
|
||||||
|
let (id, got) = unwrap(&format!(" {} ", wrap(sample_id(), ticket)));
|
||||||
|
assert!(id.is_some());
|
||||||
|
assert_eq!(got, ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_wrapped_code_falls_back_to_bare() {
|
||||||
|
// Prefix present but the id isn't a valid EndpointId → treat the whole
|
||||||
|
// thing as a (doomed) bare ticket rather than panicking.
|
||||||
|
let (id, got) = unwrap("pixelpassF1:not-an-id.endpointaa");
|
||||||
|
assert_eq!(id, None);
|
||||||
|
assert_eq!(got, "pixelpassF1:not-an-id.endpointaa");
|
||||||
|
}
|
||||||
|
}
|
||||||
+1991
-76
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,299 @@
|
|||||||
|
//! The always-on friends presence service.
|
||||||
|
//!
|
||||||
|
//! A control-plane iroh endpoint ([`endpoint::bind_control`]) that lives for the
|
||||||
|
//! whole GUI session on its own thread with a current-thread tokio runtime — the
|
||||||
|
//! GUI is a synchronous winit/egui loop, so iroh's async work can't run on it
|
||||||
|
//! (the same reason [`super::tray`] has its own thread + runtime).
|
||||||
|
//!
|
||||||
|
//! Inbound control messages are forwarded over a std mpsc channel the UI drains
|
||||||
|
//! each [`super::PixelPassApp::tick`]; the [`Waker`] is pinged on arrival so a
|
||||||
|
//! message wakes the loop even while the window is hidden to the tray — the same
|
||||||
|
//! trick the headless-child reader uses.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::mpsc::{self, Receiver};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use iroh::{Endpoint, EndpointId};
|
||||||
|
use tokio::sync::mpsc as tmpsc;
|
||||||
|
|
||||||
|
use super::Waker;
|
||||||
|
use crate::common::{
|
||||||
|
control::{self, ControlMsg, Inbound},
|
||||||
|
endpoint, identity,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A command the UI hands the presence service over [`PresenceHandle`].
|
||||||
|
enum Command {
|
||||||
|
/// Deliver one message, once, fire-and-forget (friend request/accept/decline
|
||||||
|
/// and the presence `Hello`). A failure is logged, not retried.
|
||||||
|
Send { peer: EndpointId, msg: ControlMsg },
|
||||||
|
/// Begin — or replace — a share campaign: push `msg` (a
|
||||||
|
/// [`ControlMsg::ShareCode`]) to every peer in `peers`, retrying the ones
|
||||||
|
/// that are offline until they're reached or the campaign is stopped. Each
|
||||||
|
/// success emits a [`PresenceEvent::ShareDelivered`]. Replaces any campaign
|
||||||
|
/// already running (a fresh host session supersedes the previous code).
|
||||||
|
StartShare {
|
||||||
|
msg: ControlMsg,
|
||||||
|
peers: Vec<EndpointId>,
|
||||||
|
},
|
||||||
|
/// Stop the active share campaign — the host stopped or left the screen, so
|
||||||
|
/// the perishable code is no longer valid and offline friends shouldn't keep
|
||||||
|
/// being chased.
|
||||||
|
StopShare,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Something the service surfaces to the UI, drained each tick.
|
||||||
|
pub enum PresenceEvent {
|
||||||
|
/// A control message arrived from a peer.
|
||||||
|
Message(Inbound),
|
||||||
|
/// A share-campaign code reached `peer` (its ACK came back). Lets the host
|
||||||
|
/// screen flip that friend's row from "retrying" to "delivered."
|
||||||
|
ShareDelivered { peer: EndpointId },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long to wait before re-attempting delivery to friends who were offline
|
||||||
|
/// on the previous round of a share campaign.
|
||||||
|
const SHARE_RETRY: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// Handle the GUI holds for the presence service. Dropping it doesn't stop the
|
||||||
|
/// service (the thread is detached; the endpoint closes when the process exits)
|
||||||
|
/// — it just stops the UI from draining inbound messages.
|
||||||
|
pub struct PresenceHandle {
|
||||||
|
/// Our stable control-plane id — what friends know us by, and what we embed
|
||||||
|
/// in a wrapped share code so a viewer can find us.
|
||||||
|
id: EndpointId,
|
||||||
|
/// Service events (inbound messages + share receipts), drained by
|
||||||
|
/// [`PresenceHandle::drain`] each tick.
|
||||||
|
rx: Receiver<PresenceEvent>,
|
||||||
|
/// Commands handed to the service thread. Unbounded tokio sender so the sync
|
||||||
|
/// UI can enqueue without blocking or being inside the runtime.
|
||||||
|
out_tx: tmpsc::UnboundedSender<Command>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PresenceHandle {
|
||||||
|
/// Our stable control-plane id.
|
||||||
|
pub fn id(&self) -> EndpointId {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull every service event received since the last call. Collected by the
|
||||||
|
/// caller so it can take `&mut self` while handling them.
|
||||||
|
pub fn drain(&self) -> Vec<PresenceEvent> {
|
||||||
|
std::iter::from_fn(|| self.rx.try_recv().ok()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enqueue a one-shot message for delivery to `peer`. Fire-and-forget from
|
||||||
|
/// the UI's view; the service connects, delivers, and logs a failure. A send
|
||||||
|
/// error here only means the service thread is gone.
|
||||||
|
pub fn send(&self, peer: EndpointId, msg: ControlMsg) {
|
||||||
|
self.command(Command::Send { peer, msg });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Begin (or replace) a share campaign pushing `msg` to `peers`, retrying
|
||||||
|
/// offline friends until [`PresenceHandle::stop_share`] or the next call.
|
||||||
|
pub fn start_share(&self, msg: ControlMsg, peers: Vec<EndpointId>) {
|
||||||
|
self.command(Command::StartShare { msg, peers });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the active share campaign (host stopped — the code is now stale).
|
||||||
|
pub fn stop_share(&self) {
|
||||||
|
self.command(Command::StopShare);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command(&self, cmd: Command) {
|
||||||
|
if self.out_tx.send(cmd).is_err() {
|
||||||
|
tracing::warn!("presence: service thread gone; dropping command");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the presence service. Returns `None` if the persistent identity can't
|
||||||
|
/// be loaded — the GUI then simply runs without friends features rather than
|
||||||
|
/// refusing to start. The endpoint binds asynchronously on the spawned thread;
|
||||||
|
/// our id is known immediately because it derives from the saved key, so we can
|
||||||
|
/// fail-fast and log it without waiting on the relay handshake.
|
||||||
|
pub fn start(waker: Waker, relay: Option<String>) -> Option<PresenceHandle> {
|
||||||
|
let id: EndpointId = match identity::load_or_create() {
|
||||||
|
Ok(key) => key.public(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("presence: no identity, friends features disabled: {e:#}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tracing::info!(%id, "presence: starting control service");
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::channel::<PresenceEvent>();
|
||||||
|
let (out_tx, out_rx) = tmpsc::unbounded_channel::<Command>();
|
||||||
|
thread::Builder::new()
|
||||||
|
.name("pixelpass-presence".into())
|
||||||
|
.spawn(move || run(relay, id, tx, out_rx, waker))
|
||||||
|
.map_err(|e| tracing::warn!("presence: could not spawn service thread: {e}"))
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
Some(PresenceHandle { id, rx, out_tx })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thread body: a current-thread tokio runtime that binds the control endpoint,
|
||||||
|
/// runs the accept loop, bridges inbound messages to the UI channel, and
|
||||||
|
/// delivers outbound messages the UI enqueues.
|
||||||
|
fn run(
|
||||||
|
relay: Option<String>,
|
||||||
|
id: EndpointId,
|
||||||
|
tx: mpsc::Sender<PresenceEvent>,
|
||||||
|
mut out_rx: tmpsc::UnboundedReceiver<Command>,
|
||||||
|
waker: Waker,
|
||||||
|
) {
|
||||||
|
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(rt) => rt,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("presence: failed to build runtime: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rt.block_on(async move {
|
||||||
|
let ep = match endpoint::bind_control(relay.as_deref()).await {
|
||||||
|
Ok(ep) => ep,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("presence: failed to bind control endpoint: {e:#}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tracing::info!(%id, "presence: control endpoint online");
|
||||||
|
|
||||||
|
// One async→sync bridge for *everything* the UI sees: every producer
|
||||||
|
// (the accept loop and the share campaign) pushes a `PresenceEvent` into
|
||||||
|
// `ui_tx`; this task drains it onto the std channel and wakes the loop so
|
||||||
|
// the event lands even while the window is hidden to the tray.
|
||||||
|
let (ui_tx, mut ui_rx) = tmpsc::channel::<PresenceEvent>(64);
|
||||||
|
let forward = tokio::spawn(async move {
|
||||||
|
while let Some(event) = ui_rx.recv().await {
|
||||||
|
if tx.send(event).is_err() {
|
||||||
|
break; // UI gone
|
||||||
|
}
|
||||||
|
waker.wake();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrap inbound control messages as events and feed the bridge.
|
||||||
|
let (itx, mut irx) = tmpsc::channel::<Inbound>(32);
|
||||||
|
let inbound_ui = ui_tx.clone();
|
||||||
|
let inbound = tokio::spawn(async move {
|
||||||
|
while let Some(msg) = irx.recv().await {
|
||||||
|
if inbound_ui.send(PresenceEvent::Message(msg)).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle UI commands: one-shot sends each on their own task, and a single
|
||||||
|
// abortable share campaign (StartShare replaces it, StopShare cancels it).
|
||||||
|
let cmd_ep = ep.clone();
|
||||||
|
let commands = tokio::spawn(async move {
|
||||||
|
let mut share: Option<tokio::task::JoinHandle<()>> = None;
|
||||||
|
while let Some(cmd) = out_rx.recv().await {
|
||||||
|
match cmd {
|
||||||
|
Command::Send { peer, msg } => {
|
||||||
|
let ep = cmd_ep.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = control::send(&ep, peer, &msg).await {
|
||||||
|
tracing::warn!(%peer, "presence: outbound send failed: {e:#}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Command::StartShare { msg, peers } => {
|
||||||
|
if let Some(t) = share.take() {
|
||||||
|
t.abort();
|
||||||
|
}
|
||||||
|
let ep = cmd_ep.clone();
|
||||||
|
let ui = ui_tx.clone();
|
||||||
|
share = Some(tokio::spawn(run_share(ep, msg, peers, ui)));
|
||||||
|
}
|
||||||
|
Command::StopShare => {
|
||||||
|
if let Some(t) = share.take() {
|
||||||
|
t.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
control::serve(ep, itx).await;
|
||||||
|
forward.abort();
|
||||||
|
inbound.abort();
|
||||||
|
commands.abort();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push `msg` to every peer in `peers`, retrying the ones that are offline every
|
||||||
|
/// [`SHARE_RETRY`] until all are delivered (or the task is aborted by a
|
||||||
|
/// StartShare/StopShare). Emits one [`PresenceEvent::ShareDelivered`] per peer
|
||||||
|
/// the moment its ACK comes back — that ACK *is* the delivery signal.
|
||||||
|
///
|
||||||
|
/// Each round fires all still-pending peers **concurrently**, so a single
|
||||||
|
/// offline friend's ~10s connect timeout doesn't serialise the whole round
|
||||||
|
/// (which it did when peers were tried one at a time).
|
||||||
|
async fn run_share(
|
||||||
|
ep: Endpoint,
|
||||||
|
msg: ControlMsg,
|
||||||
|
mut pending: Vec<EndpointId>,
|
||||||
|
ui: tmpsc::Sender<PresenceEvent>,
|
||||||
|
) {
|
||||||
|
// The code is immutable for the campaign's life; share it across the
|
||||||
|
// per-peer tasks via an `Arc` rather than re-cloning the payload each round.
|
||||||
|
let msg = Arc::new(msg);
|
||||||
|
while !pending.is_empty() {
|
||||||
|
let mut round = tokio::task::JoinSet::new();
|
||||||
|
for peer in pending {
|
||||||
|
let ep = ep.clone();
|
||||||
|
let msg = Arc::clone(&msg);
|
||||||
|
round.spawn(async move {
|
||||||
|
match control::send(&ep, peer, &msg).await {
|
||||||
|
Ok(()) => (peer, true),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(%peer, "presence: share not yet delivered: {e:#}");
|
||||||
|
(peer, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut still = Vec::new();
|
||||||
|
while let Some(joined) = round.join_next().await {
|
||||||
|
let (peer, delivered) = match joined {
|
||||||
|
Ok(outcome) => outcome,
|
||||||
|
// A send task panicking is unexpected; log and drop that peer
|
||||||
|
// from the campaign rather than abort the whole round. (A
|
||||||
|
// campaign-level abort drops this future entirely — we never
|
||||||
|
// observe that as a JoinError here.)
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("presence: share task failed: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if delivered {
|
||||||
|
tracing::info!(%peer, "presence: shared code delivered");
|
||||||
|
if ui
|
||||||
|
.send(PresenceEvent::ShareDelivered { peer })
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return; // UI gone — nothing left to report to
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
still.push(peer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if still.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pending = still;
|
||||||
|
tokio::time::sleep(SHARE_RETRY).await;
|
||||||
|
}
|
||||||
|
tracing::info!("presence: share campaign complete");
|
||||||
|
}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
//! User-customisable colour themes for the GUI.
|
||||||
|
//!
|
||||||
|
//! A theme is a small, curated *semantic* palette — backgrounds, text, an
|
||||||
|
//! accent, and the handful of status colours the app uses (streaming, waiting,
|
||||||
|
//! success, warning, error). That's deliberately a fixed set rather than a
|
||||||
|
//! passthrough of every [`egui::Visuals`] field: it's easy to author by hand,
|
||||||
|
//! covers the whole look of the app, and stays stable across egui upgrades.
|
||||||
|
//!
|
||||||
|
//! Themes serialise to TOML with colours as `#rrggbb` hex strings. Three
|
||||||
|
//! themes ship built in; users drop their own `*.toml` files in
|
||||||
|
//! `~/.config/pixelpass/themes/` (or save one from the in-app editor) and they
|
||||||
|
//! show up alongside the built-ins. A user file whose `name` matches a built-in
|
||||||
|
//! overrides it.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use directories::ProjectDirs;
|
||||||
|
use eframe::egui::{self, Color32};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// One colour theme: a curated semantic palette.
|
||||||
|
///
|
||||||
|
/// `#[serde(default)]` on the container means any field missing from a TOML
|
||||||
|
/// file falls back to the corresponding field of [`Theme::default`] (the
|
||||||
|
/// built-in Default Dark), so a partial or hand-trimmed file still loads.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Theme {
|
||||||
|
/// Display name, shown in the picker and used as the file stem on save.
|
||||||
|
pub name: String,
|
||||||
|
/// Base egui defaults to start from before applying the palette overrides.
|
||||||
|
pub dark: bool,
|
||||||
|
|
||||||
|
// ── chrome ────────────────────────────────────────────────────────
|
||||||
|
/// Window background.
|
||||||
|
#[serde(with = "hex")]
|
||||||
|
pub window_bg: Color32,
|
||||||
|
/// Panel / frame background.
|
||||||
|
#[serde(with = "hex")]
|
||||||
|
pub panel_bg: Color32,
|
||||||
|
/// Text-input and read-only field background (the ticket box, etc.).
|
||||||
|
#[serde(with = "hex")]
|
||||||
|
pub input_bg: Color32,
|
||||||
|
/// Primary text.
|
||||||
|
#[serde(with = "hex")]
|
||||||
|
pub text: Color32,
|
||||||
|
/// Secondary / de-emphasised text (hints, the version line).
|
||||||
|
#[serde(with = "hex")]
|
||||||
|
pub weak_text: Color32,
|
||||||
|
/// Accent: selection, hyperlinks, and the active/pressed widget fill.
|
||||||
|
#[serde(with = "hex")]
|
||||||
|
pub accent: Color32,
|
||||||
|
/// Button (and other interactive widget) resting background.
|
||||||
|
#[serde(with = "hex")]
|
||||||
|
pub button_bg: Color32,
|
||||||
|
/// Button background on hover.
|
||||||
|
#[serde(with = "hex")]
|
||||||
|
pub button_hovered: Color32,
|
||||||
|
|
||||||
|
// ── semantic status colours ───────────────────────────────────────
|
||||||
|
/// "● Streaming" indicator.
|
||||||
|
#[serde(with = "hex")]
|
||||||
|
pub streaming: Color32,
|
||||||
|
/// "● Waiting for viewers…" indicator.
|
||||||
|
#[serde(with = "hex")]
|
||||||
|
pub waiting: Color32,
|
||||||
|
/// Success notes, e.g. "✓ Copied to clipboard".
|
||||||
|
#[serde(with = "hex")]
|
||||||
|
pub success: Color32,
|
||||||
|
/// Non-fatal warnings, e.g. a host-full refusal.
|
||||||
|
#[serde(with = "hex")]
|
||||||
|
pub warning: Color32,
|
||||||
|
/// Errors.
|
||||||
|
#[serde(with = "hex")]
|
||||||
|
pub error: Color32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Theme {
|
||||||
|
fn default() -> Self {
|
||||||
|
default_dark()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Theme {
|
||||||
|
/// Build the egui [`Visuals`](egui::Visuals) this theme describes. Starts
|
||||||
|
/// from egui's dark or light defaults (so anything the palette doesn't name
|
||||||
|
/// stays sensible) and overrides the curated fields.
|
||||||
|
pub fn visuals(&self) -> egui::Visuals {
|
||||||
|
use egui::{Stroke, Visuals};
|
||||||
|
|
||||||
|
let mut v = if self.dark {
|
||||||
|
Visuals::dark()
|
||||||
|
} else {
|
||||||
|
Visuals::light()
|
||||||
|
};
|
||||||
|
v.dark_mode = self.dark;
|
||||||
|
|
||||||
|
v.window_fill = self.window_bg;
|
||||||
|
v.panel_fill = self.panel_bg;
|
||||||
|
v.faint_bg_color = self.panel_bg;
|
||||||
|
v.extreme_bg_color = self.input_bg;
|
||||||
|
v.override_text_color = Some(self.text);
|
||||||
|
// `.weak()` text resolves via `weak_text_color()`, which derives from
|
||||||
|
// `text` unless this is set — so without it the weak-text field is dead.
|
||||||
|
v.weak_text_color = Some(self.weak_text);
|
||||||
|
v.hyperlink_color = self.accent;
|
||||||
|
v.error_fg_color = self.error;
|
||||||
|
v.warn_fg_color = self.warning;
|
||||||
|
|
||||||
|
// A translucent accent reads well as a selection highlight on either a
|
||||||
|
// light or dark base.
|
||||||
|
v.selection.bg_fill =
|
||||||
|
Color32::from_rgba_unmultiplied(self.accent.r(), self.accent.g(), self.accent.b(), 96);
|
||||||
|
v.selection.stroke = Stroke::new(1.0, self.accent);
|
||||||
|
|
||||||
|
let text_stroke = Stroke::new(1.0, self.text);
|
||||||
|
let weak_stroke = Stroke::new(1.0, self.weak_text);
|
||||||
|
|
||||||
|
v.widgets.noninteractive.bg_fill = self.panel_bg;
|
||||||
|
v.widgets.noninteractive.weak_bg_fill = self.panel_bg;
|
||||||
|
v.widgets.noninteractive.fg_stroke = weak_stroke;
|
||||||
|
|
||||||
|
v.widgets.inactive.bg_fill = self.button_bg;
|
||||||
|
v.widgets.inactive.weak_bg_fill = self.button_bg;
|
||||||
|
v.widgets.inactive.fg_stroke = text_stroke;
|
||||||
|
|
||||||
|
v.widgets.hovered.bg_fill = self.button_hovered;
|
||||||
|
v.widgets.hovered.weak_bg_fill = self.button_hovered;
|
||||||
|
v.widgets.hovered.fg_stroke = text_stroke;
|
||||||
|
|
||||||
|
v.widgets.active.bg_fill = self.accent;
|
||||||
|
v.widgets.active.weak_bg_fill = self.accent;
|
||||||
|
v.widgets.active.fg_stroke = text_stroke;
|
||||||
|
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── built-in themes ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Names of the built-in themes, in picker order.
|
||||||
|
pub const BUILTIN_NAMES: [&str; 3] = ["Default Dark", "Catppuccin Mocha", "Catppuccin Latte"];
|
||||||
|
|
||||||
|
/// Parse a built-in's hex literal, panicking on a typo (these are compile-time
|
||||||
|
/// constants we control, so a bad value is a bug, not user input).
|
||||||
|
fn c(hex: &str) -> Color32 {
|
||||||
|
parse_hex(hex).expect("built-in theme hex is valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The default theme — a neutral dark palette. Also [`Theme::default`].
|
||||||
|
pub fn default_dark() -> Theme {
|
||||||
|
Theme {
|
||||||
|
name: "Default Dark".to_string(),
|
||||||
|
dark: true,
|
||||||
|
window_bg: c("#1b1b1f"),
|
||||||
|
panel_bg: c("#242429"),
|
||||||
|
input_bg: c("#141417"),
|
||||||
|
text: c("#e6e6ea"),
|
||||||
|
weak_text: c("#a0a0a8"),
|
||||||
|
accent: c("#5aa0f2"),
|
||||||
|
button_bg: c("#33333a"),
|
||||||
|
button_hovered: c("#44444d"),
|
||||||
|
streaming: c("#6fdc8c"),
|
||||||
|
waiting: c("#f2c14e"),
|
||||||
|
success: c("#6fdc8c"),
|
||||||
|
warning: c("#f0a85a"),
|
||||||
|
error: c("#f2756f"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Catppuccin Mocha (dark). <https://github.com/catppuccin/catppuccin>
|
||||||
|
fn catppuccin_mocha() -> Theme {
|
||||||
|
Theme {
|
||||||
|
name: "Catppuccin Mocha".to_string(),
|
||||||
|
dark: true,
|
||||||
|
window_bg: c("#1e1e2e"),
|
||||||
|
panel_bg: c("#181825"),
|
||||||
|
input_bg: c("#11111b"),
|
||||||
|
text: c("#cdd6f4"),
|
||||||
|
weak_text: c("#a6adc8"),
|
||||||
|
accent: c("#cba6f7"),
|
||||||
|
button_bg: c("#313244"),
|
||||||
|
button_hovered: c("#45475a"),
|
||||||
|
streaming: c("#a6e3a1"),
|
||||||
|
waiting: c("#f9e2af"),
|
||||||
|
success: c("#a6e3a1"),
|
||||||
|
warning: c("#fab387"),
|
||||||
|
error: c("#f38ba8"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Catppuccin Latte (light). <https://github.com/catppuccin/catppuccin>
|
||||||
|
fn catppuccin_latte() -> Theme {
|
||||||
|
Theme {
|
||||||
|
name: "Catppuccin Latte".to_string(),
|
||||||
|
dark: false,
|
||||||
|
window_bg: c("#eff1f5"),
|
||||||
|
panel_bg: c("#e6e9ef"),
|
||||||
|
input_bg: c("#dce0e8"),
|
||||||
|
text: c("#4c4f69"),
|
||||||
|
weak_text: c("#6c6f85"),
|
||||||
|
accent: c("#8839ef"),
|
||||||
|
button_bg: c("#ccd0da"),
|
||||||
|
button_hovered: c("#bcc0cc"),
|
||||||
|
streaming: c("#40a02b"),
|
||||||
|
waiting: c("#df8e1d"),
|
||||||
|
success: c("#40a02b"),
|
||||||
|
warning: c("#fe640b"),
|
||||||
|
error: c("#d20f39"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The built-in themes, in [`BUILTIN_NAMES`] order.
|
||||||
|
pub fn builtins() -> Vec<Theme> {
|
||||||
|
vec![default_dark(), catppuccin_mocha(), catppuccin_latte()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `name` is one of the built-ins (which are read-only — the editor
|
||||||
|
/// nudges you to save under a new name).
|
||||||
|
pub fn is_builtin(name: &str) -> bool {
|
||||||
|
BUILTIN_NAMES.contains(&name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── on-disk themes ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// `~/.config/pixelpass/themes/` (or the XDG equivalent). Not created until a
|
||||||
|
/// theme is saved.
|
||||||
|
pub fn themes_dir() -> Result<PathBuf> {
|
||||||
|
let dirs = ProjectDirs::from("", "", "pixelpass")
|
||||||
|
.context("could not locate a config directory for pixelpass")?;
|
||||||
|
Ok(dirs.config_dir().join("themes"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse every `*.toml` in the themes dir into a [`Theme`]. A file that fails
|
||||||
|
/// to parse is logged and skipped rather than aborting the whole list, so one
|
||||||
|
/// bad file can't hide the rest. Returns themes sorted by name.
|
||||||
|
pub fn list_user_themes() -> Vec<Theme> {
|
||||||
|
let Ok(dir) = themes_dir() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||||
|
return Vec::new(); // dir doesn't exist yet → no user themes
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match std::fs::read_to_string(&path) {
|
||||||
|
Ok(s) => match toml::from_str::<Theme>(&s) {
|
||||||
|
Ok(mut t) => {
|
||||||
|
// Fall back to the file stem if the file omits a name.
|
||||||
|
if t.name.trim().is_empty() {
|
||||||
|
t.name = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("Unnamed")
|
||||||
|
.to_string();
|
||||||
|
}
|
||||||
|
out.push(t);
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!("skipping theme {}: {e}", path.display()),
|
||||||
|
},
|
||||||
|
Err(e) => tracing::warn!("could not read theme {}: {e}", path.display()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort_by_key(|t| t.name.to_lowercase());
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Built-ins plus user themes, in picker order: built-ins first (a user file
|
||||||
|
/// with a matching `name` overrides the built-in's colours in place), then any
|
||||||
|
/// remaining user themes alphabetically.
|
||||||
|
pub fn all_themes() -> Vec<Theme> {
|
||||||
|
let users = list_user_themes();
|
||||||
|
let mut out: Vec<Theme> = builtins()
|
||||||
|
.into_iter()
|
||||||
|
.map(|b| {
|
||||||
|
users
|
||||||
|
.iter()
|
||||||
|
.find(|u| u.name == b.name)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(b)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for u in users {
|
||||||
|
if !is_builtin(&u.name) {
|
||||||
|
out.push(u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The theme with this `name`, or Default Dark if it can't be found (e.g. the
|
||||||
|
/// config names a theme whose file was deleted).
|
||||||
|
pub fn load_named(name: &str) -> Theme {
|
||||||
|
all_themes()
|
||||||
|
.into_iter()
|
||||||
|
.find(|t| t.name == name)
|
||||||
|
.unwrap_or_else(default_dark)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `theme` to `<themes_dir>/<slug>.toml` and return the path. Overwrites
|
||||||
|
/// an existing file with the same slug (i.e. saving a tweaked theme under the
|
||||||
|
/// same name updates it in place).
|
||||||
|
pub fn save_theme(theme: &Theme) -> Result<PathBuf> {
|
||||||
|
let dir = themes_dir()?;
|
||||||
|
std::fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
|
||||||
|
|
||||||
|
let slug = slugify(&theme.name);
|
||||||
|
let path = dir.join(format!("{slug}.toml"));
|
||||||
|
let body = toml::to_string_pretty(theme).context("failed to serialise theme to TOML")?;
|
||||||
|
let contents = format!(
|
||||||
|
"# PixelPass theme. Colours are #rrggbb hex strings.\n\
|
||||||
|
# Edit and re-pick it in Settings, or drop more .toml files in this folder.\n\n\
|
||||||
|
{body}"
|
||||||
|
);
|
||||||
|
std::fs::write(&path, contents)
|
||||||
|
.with_context(|| format!("failed to write {}", path.display()))?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lowercase, replace runs of non-alphanumerics with a single hyphen, trim
|
||||||
|
/// hyphens. Empty input becomes `theme`.
|
||||||
|
fn slugify(name: &str) -> String {
|
||||||
|
let mut slug = String::new();
|
||||||
|
let mut prev_hyphen = false;
|
||||||
|
for ch in name.trim().chars() {
|
||||||
|
if ch.is_ascii_alphanumeric() {
|
||||||
|
slug.push(ch.to_ascii_lowercase());
|
||||||
|
prev_hyphen = false;
|
||||||
|
} else if !prev_hyphen {
|
||||||
|
slug.push('-');
|
||||||
|
prev_hyphen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let slug = slug.trim_matches('-').to_string();
|
||||||
|
if slug.is_empty() {
|
||||||
|
"theme".to_string()
|
||||||
|
} else {
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── hex colour parsing ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Parse `#rrggbb` into an opaque [`Color32`] (the leading `#` is optional).
|
||||||
|
/// An 8-digit `#rrggbbaa` is accepted leniently but its alpha is ignored —
|
||||||
|
/// theme colours are opaque, and `Color32`'s premultiplied storage can't
|
||||||
|
/// round-trip a straight alpha losslessly anyway. Returns `None` on malformed
|
||||||
|
/// input.
|
||||||
|
pub fn parse_hex(s: &str) -> Option<Color32> {
|
||||||
|
let s = s.trim();
|
||||||
|
let s = s.strip_prefix('#').unwrap_or(s);
|
||||||
|
if !matches!(s.len(), 6 | 8) || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let byte = |i: usize| u8::from_str_radix(&s[i..i + 2], 16).ok();
|
||||||
|
Some(Color32::from_rgb(byte(0)?, byte(2)?, byte(4)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a [`Color32`] as opaque `#rrggbb`.
|
||||||
|
pub fn to_hex(c: Color32) -> String {
|
||||||
|
let [r, g, b, _] = c.to_srgba_unmultiplied();
|
||||||
|
format!("#{r:02x}{g:02x}{b:02x}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// serde adaptor so `Color32` fields round-trip as hex strings in TOML.
|
||||||
|
mod hex {
|
||||||
|
use super::{parse_hex, to_hex};
|
||||||
|
use eframe::egui::Color32;
|
||||||
|
use serde::{Deserialize, Deserializer, Serializer, de::Error};
|
||||||
|
|
||||||
|
pub fn serialize<S: Serializer>(c: &Color32, s: S) -> Result<S::Ok, S::Error> {
|
||||||
|
s.serialize_str(&to_hex(*c))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Color32, D::Error> {
|
||||||
|
let s = String::deserialize(d)?;
|
||||||
|
parse_hex(&s).ok_or_else(|| {
|
||||||
|
D::Error::custom(format!(
|
||||||
|
"invalid hex colour {s:?} (expected #rrggbb or #rrggbbaa)"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hex_round_trips() {
|
||||||
|
for (input, expect) in [
|
||||||
|
("#1e1e2e", Color32::from_rgb(0x1e, 0x1e, 0x2e)),
|
||||||
|
("aabbcc", Color32::from_rgb(0xaa, 0xbb, 0xcc)),
|
||||||
|
// 8-digit is accepted but the alpha is dropped (opaque rgb).
|
||||||
|
("#11223344", Color32::from_rgb(0x11, 0x22, 0x33)),
|
||||||
|
] {
|
||||||
|
assert_eq!(parse_hex(input).expect("parses"), expect);
|
||||||
|
}
|
||||||
|
assert_eq!(to_hex(Color32::from_rgb(0x1e, 0x1e, 0x2e)), "#1e1e2e");
|
||||||
|
// Opaque colours round-trip exactly.
|
||||||
|
let c = Color32::from_rgb(0xab, 0xcd, 0xef);
|
||||||
|
assert_eq!(parse_hex(&to_hex(c)), Some(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hex_rejects_garbage() {
|
||||||
|
for bad in ["", "#fff", "#12345", "nothex", "#gggggg", "#1234567"] {
|
||||||
|
assert!(parse_hex(bad).is_none(), "{bad:?} should not parse");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn theme_toml_round_trips() {
|
||||||
|
let original = catppuccin_mocha();
|
||||||
|
let toml = toml::to_string_pretty(&original).unwrap();
|
||||||
|
let parsed: Theme = toml::from_str(&toml).unwrap();
|
||||||
|
assert_eq!(original, parsed);
|
||||||
|
// Colours serialise as hex strings, not RGBA tables.
|
||||||
|
assert!(toml.contains("window_bg = \"#1e1e2e\""), "{toml}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn partial_toml_fills_from_default() {
|
||||||
|
// Only a name and one colour; everything else must fall back to Default Dark.
|
||||||
|
let parsed: Theme = toml::from_str("name = \"Partial\"\naccent = \"#ff0000\"").unwrap();
|
||||||
|
let base = default_dark();
|
||||||
|
assert_eq!(parsed.name, "Partial");
|
||||||
|
assert_eq!(parsed.accent, Color32::from_rgb(0xff, 0, 0));
|
||||||
|
assert_eq!(parsed.window_bg, base.window_bg); // filled from default
|
||||||
|
assert_eq!(parsed.text, base.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slugify_is_filesystem_safe() {
|
||||||
|
assert_eq!(slugify("Catppuccin Mocha"), "catppuccin-mocha");
|
||||||
|
assert_eq!(slugify(" My Theme!! "), "my-theme");
|
||||||
|
assert_eq!(slugify("***"), "theme");
|
||||||
|
assert_eq!(slugify("Solarized/Dark"), "solarized-dark");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builtins_match_names() {
|
||||||
|
let names: Vec<String> = builtins().iter().map(|t| t.name.clone()).collect();
|
||||||
|
let expected: Vec<String> = BUILTIN_NAMES.iter().map(|s| s.to_string()).collect();
|
||||||
|
assert_eq!(names, expected);
|
||||||
|
for t in builtins() {
|
||||||
|
assert!(is_builtin(&t.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+257
@@ -0,0 +1,257 @@
|
|||||||
|
//! 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>,
|
||||||
|
/// Shared with [`TrayHandle`]; kept in sync with the watcher's presence via
|
||||||
|
/// the `watcher_online`/`watcher_offline` callbacks so the app never diverts
|
||||||
|
/// a close to a tray that has since disappeared.
|
||||||
|
registered: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The StatusNotifierWatcher came back (e.g. the panel restarted). Mark the
|
||||||
|
/// tray live again so close-to-tray can resume hiding the window.
|
||||||
|
fn watcher_online(&self) {
|
||||||
|
self.registered.store(true, Ordering::Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The watcher went away (panel restart, tray plugin disabled, …). Clear the
|
||||||
|
/// flag so a subsequent close quits normally instead of destroying the window
|
||||||
|
/// into a tray that no longer exists, and force the window back now in case
|
||||||
|
/// it was already hidden (otherwise it'd be stranded with no way to restore).
|
||||||
|
/// Returning `true` keeps the service alive so it re-registers if the watcher
|
||||||
|
/// returns.
|
||||||
|
fn watcher_offline(&self, reason: ksni::OfflineReason) -> bool {
|
||||||
|
tracing::warn!("tray: StatusNotifierWatcher offline ({reason:?}); restoring window");
|
||||||
|
self.registered.store(false, Ordering::Release);
|
||||||
|
self.notify(TrayAction::Show);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
registered: registered_thread.clone(),
|
||||||
|
};
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
+33
-31
@@ -52,8 +52,7 @@ impl Routing {
|
|||||||
let pid = std::process::id();
|
let pid = std::process::id();
|
||||||
let sink_name = format!("pixelpass_capture_{pid}");
|
let sink_name = format!("pixelpass_capture_{pid}");
|
||||||
|
|
||||||
let sink_module =
|
let sink_module = load_module(&["module-null-sink", &format!("sink_name={sink_name}")])
|
||||||
load_module(&["module-null-sink", &format!("sink_name={sink_name}")])
|
|
||||||
.context("failed to load module-null-sink")?;
|
.context("failed to load module-null-sink")?;
|
||||||
|
|
||||||
// 20ms loopback latency keeps the mirrored audio tight; pactl's
|
// 20ms loopback latency keeps the mirrored audio tight; pactl's
|
||||||
@@ -141,7 +140,10 @@ impl Routing {
|
|||||||
/// Stop the stream router (if any), then unload loopback (if still
|
/// Stop the stream router (if any), then unload loopback (if still
|
||||||
/// loaded), then unload the null-sink. Order matters: PipeWire can
|
/// loaded), then unload the null-sink. Order matters: PipeWire can
|
||||||
/// leave zombie links if you destroy a sink with active inputs.
|
/// leave zombie links if you destroy a sink with active inputs.
|
||||||
pub fn shutdown(mut self) {
|
///
|
||||||
|
/// Every step is a `take()`, so this is idempotent — `Drop` calls it again
|
||||||
|
/// as a backstop and the second run is a no-op.
|
||||||
|
fn cleanup(&mut self) {
|
||||||
if let Some(router) = self.stream_router.take() {
|
if let Some(router) = self.stream_router.take() {
|
||||||
router.shutdown();
|
router.shutdown();
|
||||||
}
|
}
|
||||||
@@ -155,22 +157,17 @@ impl Routing {
|
|||||||
unload_module(id);
|
unload_module(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Consume the routing and tear it all down now. `Drop` is the backstop;
|
||||||
|
/// the real work lives in [`cleanup`](Self::cleanup).
|
||||||
|
pub fn shutdown(mut self) {
|
||||||
|
self.cleanup();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for Routing {
|
impl Drop for Routing {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if let Some(router) = self.stream_router.take() {
|
self.cleanup();
|
||||||
router.shutdown();
|
|
||||||
}
|
|
||||||
if let Some(task) = self.event_task.take() {
|
|
||||||
task.abort();
|
|
||||||
}
|
|
||||||
if let Some(id) = self.loopback_module.lock().unwrap().take() {
|
|
||||||
unload_module(id);
|
|
||||||
}
|
|
||||||
if let Some(id) = self.sink_module.take() {
|
|
||||||
unload_module(id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +204,9 @@ fn parse_sink_inputs(stdout: &[u8]) -> Result<Vec<App>> {
|
|||||||
serde_json::from_slice(stdout).context("pactl returned unparseable JSON")?;
|
serde_json::from_slice(stdout).context("pactl returned unparseable JSON")?;
|
||||||
let mut counts: BTreeMap<String, u32> = BTreeMap::new();
|
let mut counts: BTreeMap<String, u32> = BTreeMap::new();
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let Some(name) = entry.properties.application_name else { continue };
|
let Some(name) = entry.properties.application_name else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let trimmed = name.trim();
|
let trimmed = name.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
@@ -361,16 +360,14 @@ fn run_router(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use pipewire::{self as pw, types::ObjectType};
|
use pipewire::{self as pw, types::ObjectType};
|
||||||
|
|
||||||
let main_loop = pw::main_loop::MainLoopRc::new(None)
|
let main_loop =
|
||||||
.context("pw main loop construction failed")?;
|
pw::main_loop::MainLoopRc::new(None).context("pw main loop construction failed")?;
|
||||||
let context = pw::context::ContextRc::new(&main_loop, None)
|
let context =
|
||||||
.context("pw context construction failed")?;
|
pw::context::ContextRc::new(&main_loop, None).context("pw context construction failed")?;
|
||||||
let core = context
|
let core = context
|
||||||
.connect_rc(None)
|
.connect_rc(None)
|
||||||
.context("pw core connect failed (is the daemon running?)")?;
|
.context("pw core connect failed (is the daemon running?)")?;
|
||||||
let registry = core
|
let registry = core.get_registry_rc().context("pw get_registry failed")?;
|
||||||
.get_registry_rc()
|
|
||||||
.context("pw get_registry failed")?;
|
|
||||||
|
|
||||||
let state = Rc::new(RefCell::new(RouterState {
|
let state = Rc::new(RefCell::new(RouterState {
|
||||||
sink_serial: None,
|
sink_serial: None,
|
||||||
@@ -411,20 +408,21 @@ fn run_router(
|
|||||||
let _reg_listener = registry
|
let _reg_listener = registry
|
||||||
.add_listener_local()
|
.add_listener_local()
|
||||||
.global(move |obj| {
|
.global(move |obj| {
|
||||||
let Some(reg) = registry_weak.upgrade() else { return };
|
let Some(reg) = registry_weak.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
match obj.type_ {
|
match obj.type_ {
|
||||||
ObjectType::Node => {
|
ObjectType::Node => {
|
||||||
let Some(props) = obj.props.as_ref() else { return };
|
let Some(props) = obj.props.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
if props.get("node.name") == Some(sink_name_owned.as_str()) {
|
if props.get("node.name") == Some(sink_name_owned.as_str()) {
|
||||||
if let Some(serial) = props
|
if let Some(serial) = props
|
||||||
.get("object.serial")
|
.get("object.serial")
|
||||||
.and_then(|s| s.parse::<u32>().ok())
|
.and_then(|s| s.parse::<u32>().ok())
|
||||||
{
|
{
|
||||||
state_for_reg.borrow_mut().sink_serial = Some(serial);
|
state_for_reg.borrow_mut().sink_serial = Some(serial);
|
||||||
tracing::info!(
|
tracing::info!(serial, "audio routing: pixelpass sink registered");
|
||||||
serial,
|
|
||||||
"audio routing: pixelpass sink registered"
|
|
||||||
);
|
|
||||||
try_flush(&state_for_reg, &event_tx_for_reg);
|
try_flush(&state_for_reg, &event_tx_for_reg);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -432,7 +430,9 @@ fn run_router(
|
|||||||
if props.get("media.class") != Some("Stream/Output/Audio") {
|
if props.get("media.class") != Some("Stream/Output/Audio") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some(app) = props.get("application.name") else { return };
|
let Some(app) = props.get("application.name") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
if !app.eq_ignore_ascii_case(&filter_lower) {
|
if !app.eq_ignore_ascii_case(&filter_lower) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -445,7 +445,9 @@ fn run_router(
|
|||||||
try_flush(&state_for_reg, &event_tx_for_reg);
|
try_flush(&state_for_reg, &event_tx_for_reg);
|
||||||
}
|
}
|
||||||
ObjectType::Metadata => {
|
ObjectType::Metadata => {
|
||||||
let Some(props) = obj.props.as_ref() else { return };
|
let Some(props) = obj.props.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
if props.get("metadata.name") != Some("default") {
|
if props.get("metadata.name") != Some("default") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+131
-30
@@ -7,31 +7,45 @@ mod wayland;
|
|||||||
mod x11;
|
mod x11;
|
||||||
|
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use iroh::endpoint::{Connection, presets};
|
use iroh::endpoint::Connection;
|
||||||
use iroh::{Endpoint, EndpointAddr};
|
use iroh::{Endpoint, EndpointAddr};
|
||||||
use iroh_tickets::endpoint::EndpointTicket;
|
use iroh_tickets::endpoint::EndpointTicket;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::{mpsc, oneshot};
|
use tokio::sync::{mpsc, oneshot};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
use crate::cli::HostOpts;
|
use crate::cli::HostOpts;
|
||||||
use crate::common::{
|
use crate::common::{
|
||||||
alpn::ALPN, bandwidth, config, config::BandwidthStatus, deps, display::DisplayServer, output,
|
bandwidth, config, config::BandwidthStatus, deps, display::DisplayServer, endpoint, output,
|
||||||
signal, tunnel,
|
signal, tunnel,
|
||||||
};
|
};
|
||||||
|
|
||||||
use self::pipeline::CaptureHandle;
|
use self::pipeline::CaptureHandle;
|
||||||
use self::quality::EffectiveQuality;
|
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 {
|
enum SupervisorMsg {
|
||||||
/// A new viewer wants in. Supervisor replies with the local capture
|
/// A new viewer wants in. Supervisor replies with the local capture HTTP
|
||||||
/// HTTP port to connect to, or an error string if the host is full or
|
/// port to connect to, or an error string if the host is full or capture
|
||||||
/// capture spawn failed.
|
/// spawn failed. `cancel` is the viewer's own token — the supervisor keeps
|
||||||
AddViewer(oneshot::Sender<Result<u16, String>>),
|
/// 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
|
/// A viewer's session ended. Supervisor decrements the count and tears
|
||||||
/// down capture if it just hit zero.
|
/// 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<()> {
|
pub async fn run(opts: HostOpts) -> Result<()> {
|
||||||
@@ -60,10 +74,7 @@ pub async fn run(opts: HostOpts) -> Result<()> {
|
|||||||
|
|
||||||
let cancel = signal::install_ctrl_c();
|
let cancel = signal::install_ctrl_c();
|
||||||
|
|
||||||
let endpoint = Endpoint::builder(presets::N0)
|
let endpoint = endpoint::bind(opts.relay.as_deref()).await?;
|
||||||
.alpns(vec![ALPN.to_vec()])
|
|
||||||
.bind()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Relay-only ticket: wait for the home relay to connect, then keep only
|
// Relay-only ticket: wait for the home relay to connect, then keep only
|
||||||
// the endpoint id + relay URL and drop the direct IP candidates. The relay
|
// the endpoint id + relay URL and drop the direct IP candidates. The relay
|
||||||
@@ -113,6 +124,15 @@ pub async fn run(opts: HostOpts) -> Result<()> {
|
|||||||
sup_rx,
|
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;
|
accept_loop(&endpoint, sup_tx.clone(), cancel.clone()).await;
|
||||||
|
|
||||||
drop(sup_tx);
|
drop(sup_tx);
|
||||||
@@ -159,9 +179,18 @@ async fn handle_peer(
|
|||||||
cancel: CancellationToken,
|
cancel: CancellationToken,
|
||||||
) {
|
) {
|
||||||
let remote = conn.remote_id();
|
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();
|
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");
|
tracing::warn!(%remote, "supervisor channel closed; dropping peer");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -182,7 +211,7 @@ async fn handle_peer(
|
|||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(%remote, "accept_bi failed: {e:#}");
|
tracing::warn!(%remote, "accept_bi failed: {e:#}");
|
||||||
let _ = sup_tx.send(SupervisorMsg::RemoveViewer).await;
|
let _ = sup_tx.send(SupervisorMsg::RemoveViewer { id }).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -193,7 +222,7 @@ async fn handle_peer(
|
|||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(%remote, "connect_to_capture failed: {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;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -207,10 +236,36 @@ async fn handle_peer(
|
|||||||
_ = cancel.cancelled() => {
|
_ = cancel.cancelled() => {
|
||||||
tracing::info!(%remote, "cancellation during stream");
|
tracing::info!(%remote, "cancellation during stream");
|
||||||
}
|
}
|
||||||
|
_ = peer_cancel.cancelled() => {
|
||||||
|
tracing::info!(%remote, "kicked by host");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
eprintln!("[pixelpass] viewer disconnected: {remote}");
|
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
|
/// Owns the single shared CaptureHandle and the active viewer count. Spawns
|
||||||
@@ -225,11 +280,15 @@ async fn supervise(
|
|||||||
mut rx: mpsc::Receiver<SupervisorMsg>,
|
mut rx: mpsc::Receiver<SupervisorMsg>,
|
||||||
) {
|
) {
|
||||||
let mut handle: Option<CaptureHandle> = None;
|
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 {
|
while let Some(msg) = rx.recv().await {
|
||||||
match msg {
|
match msg {
|
||||||
SupervisorMsg::AddViewer(reply) => {
|
SupervisorMsg::AddViewer { id, cancel, reply } => {
|
||||||
|
let count = viewers.len() as u32;
|
||||||
if count >= max_viewers {
|
if count >= max_viewers {
|
||||||
let reason =
|
let reason =
|
||||||
format!("host is full ({count} of {max_viewers} viewers connected)");
|
format!("host is full ({count} of {max_viewers} viewers connected)");
|
||||||
@@ -255,16 +314,30 @@ async fn supervise(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let port = handle.as_ref().expect("handle was just set").local_port();
|
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));
|
let _ = reply.send(Ok(port));
|
||||||
output::emit(output::Event::ViewerCount { active: count, max: max_viewers });
|
output::emit(output::Event::ViewerJoined {
|
||||||
tracing::info!(active = count, cap = max_viewers, "viewer joined");
|
id: &id,
|
||||||
|
active,
|
||||||
|
max: max_viewers,
|
||||||
|
});
|
||||||
|
tracing::info!(active, cap = max_viewers, "viewer joined");
|
||||||
}
|
}
|
||||||
SupervisorMsg::RemoveViewer => {
|
SupervisorMsg::RemoveViewer { id } => {
|
||||||
count = count.saturating_sub(1);
|
// A given viewer task only ever sends RemoveViewer once, but the
|
||||||
output::emit(output::Event::ViewerCount { active: count, max: max_viewers });
|
// map remove is the source of truth either way.
|
||||||
tracing::info!(active = count, cap = max_viewers, "viewer left");
|
if viewers.remove(&id).is_none() {
|
||||||
if count == 0
|
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()
|
&& let Some(h) = handle.take()
|
||||||
{
|
{
|
||||||
tracing::info!("last viewer left — tearing down capture");
|
tracing::info!("last viewer left — tearing down capture");
|
||||||
@@ -274,6 +347,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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,10 +382,25 @@ fn print_host_banner(
|
|||||||
eprintln!("┌─ PixelPass · host ─────────────────────────────────────────");
|
eprintln!("┌─ PixelPass · host ─────────────────────────────────────────");
|
||||||
eprintln!("│ display server : {display:?}");
|
eprintln!("│ display server : {display:?}");
|
||||||
eprintln!("│ capture : {}", capture_summary(opts));
|
eprintln!("│ capture : {}", capture_summary(opts));
|
||||||
eprintln!("│ quality : {} — {}", quality.label, quality.dimensions_summary());
|
eprintln!(
|
||||||
|
"│ quality : {} — {}",
|
||||||
|
quality.label,
|
||||||
|
quality.dimensions_summary()
|
||||||
|
);
|
||||||
eprintln!("│ ({})", quality.note);
|
eprintln!("│ ({})", quality.note);
|
||||||
eprintln!("│ hw encode : {}", if opts.no_hwencode { "off (software x264)" } else { "on (VAAPI H.264)" });
|
eprintln!(
|
||||||
eprintln!("│ max viewers : {} ({})", resolution.value, resolution.source.label());
|
"│ hw encode : {}",
|
||||||
|
if opts.no_hwencode {
|
||||||
|
"off (software x264)"
|
||||||
|
} else {
|
||||||
|
"on (VAAPI H.264)"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
"│ max viewers : {} ({})",
|
||||||
|
resolution.value,
|
||||||
|
resolution.source.label()
|
||||||
|
);
|
||||||
eprintln!("│");
|
eprintln!("│");
|
||||||
if clipboard_ok {
|
if clipboard_ok {
|
||||||
eprintln!("│ Your share code has been copied to your clipboard.");
|
eprintln!("│ Your share code has been copied to your clipboard.");
|
||||||
@@ -365,7 +464,9 @@ fn resolve_max_viewers(opts: &HostOpts, effective_bitrate: u32) -> MaxViewersRes
|
|||||||
let n = bandwidth::recommended_max_viewers(upstream, effective_bitrate);
|
let n = bandwidth::recommended_max_viewers(upstream, effective_bitrate);
|
||||||
return MaxViewersResolution {
|
return MaxViewersResolution {
|
||||||
value: n,
|
value: n,
|
||||||
source: MaxViewersSource::BandwidthMeasurement { safe_mbps: upstream },
|
source: MaxViewersSource::BandwidthMeasurement {
|
||||||
|
safe_mbps: upstream,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
MaxViewersResolution {
|
MaxViewersResolution {
|
||||||
|
|||||||
@@ -308,7 +308,9 @@ async fn default_audio_monitor() -> Result<String> {
|
|||||||
.arg("get-default-sink")
|
.arg("get-default-sink")
|
||||||
.output()
|
.output()
|
||||||
.await
|
.await
|
||||||
.context("failed to run `pactl get-default-sink` (install pulseaudio-utils or pipewire-pulse)")?;
|
.context(
|
||||||
|
"failed to run `pactl get-default-sink` (install pulseaudio-utils or pipewire-pulse)",
|
||||||
|
)?;
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
bail!(
|
bail!(
|
||||||
"pactl get-default-sink failed: {}",
|
"pactl get-default-sink failed: {}",
|
||||||
|
|||||||
+32
-7
@@ -27,10 +27,26 @@ impl Quality {
|
|||||||
/// values and resolves to one of the others at runtime (see [`resolve_auto`]).
|
/// values and resolves to one of the others at runtime (see [`resolve_auto`]).
|
||||||
fn preset(self) -> Option<Preset> {
|
fn preset(self) -> Option<Preset> {
|
||||||
let p = match self {
|
let p = match self {
|
||||||
Quality::Source => Preset { max_height: None, bitrate: 6000, framerate: 30 },
|
Quality::Source => Preset {
|
||||||
Quality::High => Preset { max_height: Some(1080), bitrate: 4000, framerate: 30 },
|
max_height: None,
|
||||||
Quality::Medium => Preset { max_height: Some(720), bitrate: 2500, framerate: 30 },
|
bitrate: 6000,
|
||||||
Quality::Low => Preset { max_height: Some(480), bitrate: 1000, framerate: 30 },
|
framerate: 30,
|
||||||
|
},
|
||||||
|
Quality::High => Preset {
|
||||||
|
max_height: Some(1080),
|
||||||
|
bitrate: 4000,
|
||||||
|
framerate: 30,
|
||||||
|
},
|
||||||
|
Quality::Medium => Preset {
|
||||||
|
max_height: Some(720),
|
||||||
|
bitrate: 2500,
|
||||||
|
framerate: 30,
|
||||||
|
},
|
||||||
|
Quality::Low => Preset {
|
||||||
|
max_height: Some(480),
|
||||||
|
bitrate: 1000,
|
||||||
|
framerate: 30,
|
||||||
|
},
|
||||||
Quality::Auto => return None,
|
Quality::Auto => return None,
|
||||||
};
|
};
|
||||||
Some(p)
|
Some(p)
|
||||||
@@ -49,7 +65,12 @@ impl Quality {
|
|||||||
|
|
||||||
/// Fixed presets in descending quality order — Auto walks this to find the
|
/// Fixed presets in descending quality order — Auto walks this to find the
|
||||||
/// best one whose per-viewer bitrate fits the measured upstream budget.
|
/// best one whose per-viewer bitrate fits the measured upstream budget.
|
||||||
const AUTO_LADDER: [Quality; 4] = [Quality::Source, Quality::High, Quality::Medium, Quality::Low];
|
const AUTO_LADDER: [Quality; 4] = [
|
||||||
|
Quality::Source,
|
||||||
|
Quality::High,
|
||||||
|
Quality::Medium,
|
||||||
|
Quality::Low,
|
||||||
|
];
|
||||||
|
|
||||||
/// Auto's fallback when there is no usable bandwidth measurement.
|
/// Auto's fallback when there is no usable bandwidth measurement.
|
||||||
const AUTO_FALLBACK: Quality = Quality::Medium;
|
const AUTO_FALLBACK: Quality = Quality::Medium;
|
||||||
@@ -146,7 +167,9 @@ fn resolve_auto(safe_mbps: Option<f64>, sizing_viewers: u32) -> (Preset, String,
|
|||||||
(
|
(
|
||||||
preset,
|
preset,
|
||||||
format!("Auto → {}", chosen.name()),
|
format!("Auto → {}", chosen.name()),
|
||||||
format!("auto: {safe_mbps:.1} Mbps safe ÷ {n} viewer(s) = {budget_mbps:.1} Mbps each"),
|
format!(
|
||||||
|
"auto: {safe_mbps:.1} Mbps safe ÷ {n} viewer(s) = {budget_mbps:.1} Mbps each"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -154,7 +177,8 @@ fn resolve_auto(safe_mbps: Option<f64>, sizing_viewers: u32) -> (Preset, String,
|
|||||||
(
|
(
|
||||||
preset,
|
preset,
|
||||||
format!("Auto → {}", AUTO_FALLBACK.name()),
|
format!("Auto → {}", AUTO_FALLBACK.name()),
|
||||||
"auto fallback — no bandwidth measurement (run `pixelpass --reconfigure`)".to_string(),
|
"auto fallback — no bandwidth measurement (run `pixelpass --reconfigure`)"
|
||||||
|
.to_string(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,6 +213,7 @@ mod tests {
|
|||||||
no_hwencode: false,
|
no_hwencode: false,
|
||||||
max_viewers,
|
max_viewers,
|
||||||
interactive: false,
|
interactive: false,
|
||||||
|
relay: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -130,8 +130,11 @@ async fn run_accept_loop(listener: TcpListener, tx: broadcast::Sender<Arc<Vec<u8
|
|||||||
let sock = match listener.accept().await {
|
let sock = match listener.accept().await {
|
||||||
Ok((s, _)) => s,
|
Ok((s, _)) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("capture HTTP accept failed: {e}");
|
// Most accept errors are transient (EMFILE from a brief FD spike,
|
||||||
return;
|
// EINTR, etc.). Bailing on the first one would kill the entire
|
||||||
|
// viewer fanout for the rest of the session.
|
||||||
|
tracing::warn!("capture HTTP accept failed (continuing): {e}");
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let rx = tx.subscribe();
|
let rx = tx.subscribe();
|
||||||
|
|||||||
+13
-3
@@ -26,7 +26,11 @@ pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<Captur
|
|||||||
.context("could not reach the xdg-desktop-portal ScreenCast interface")?;
|
.context("could not reach the xdg-desktop-portal ScreenCast interface")?;
|
||||||
let session = proxy.create_session().await?;
|
let session = proxy.create_session().await?;
|
||||||
|
|
||||||
let source = if opts.window { SourceType::Window } else { SourceType::Monitor };
|
let source = if opts.window {
|
||||||
|
SourceType::Window
|
||||||
|
} else {
|
||||||
|
SourceType::Monitor
|
||||||
|
};
|
||||||
proxy
|
proxy
|
||||||
.select_sources(
|
.select_sources(
|
||||||
&session,
|
&session,
|
||||||
@@ -70,10 +74,16 @@ pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<Captur
|
|||||||
"do-timestamp=true".to_string(),
|
"do-timestamp=true".to_string(),
|
||||||
];
|
];
|
||||||
|
|
||||||
pipeline::spawn(opts, quality, Some((w as u32, h as u32)), source_args, move || {
|
pipeline::spawn(
|
||||||
|
opts,
|
||||||
|
quality,
|
||||||
|
Some((w as u32, h as u32)),
|
||||||
|
source_args,
|
||||||
|
move || {
|
||||||
// Parent no longer needs the pipewire fd — gst inherited its own copy.
|
// Parent no longer needs the pipewire fd — gst inherited its own copy.
|
||||||
let _ = close(raw_fd);
|
let _ = close(raw_fd);
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-10
@@ -13,7 +13,7 @@ pub async fn run(cli: Cli) -> Result<()> {
|
|||||||
let theme = ColorfulTheme::default();
|
let theme = ColorfulTheme::default();
|
||||||
let choice = Select::with_theme(&theme)
|
let choice = Select::with_theme(&theme)
|
||||||
.with_prompt("What do you want to do?")
|
.with_prompt("What do you want to do?")
|
||||||
.items(&[
|
.items([
|
||||||
"Host (share my screen)",
|
"Host (share my screen)",
|
||||||
"View (watch someone else's screen)",
|
"View (watch someone else's screen)",
|
||||||
])
|
])
|
||||||
@@ -112,7 +112,7 @@ fn pick_quality(theme: &ColorfulTheme) -> Result<Quality> {
|
|||||||
|
|
||||||
let choice = Select::with_theme(theme)
|
let choice = Select::with_theme(theme)
|
||||||
.with_prompt("What quality should the viewer(s) get?")
|
.with_prompt("What quality should the viewer(s) get?")
|
||||||
.items(&items)
|
.items(items)
|
||||||
.default(0)
|
.default(0)
|
||||||
.interact()?;
|
.interact()?;
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ pub async fn run_reconfigure() -> Result<()> {
|
|||||||
async fn preflight_if_needed(theme: &ColorfulTheme) {
|
async fn preflight_if_needed(theme: &ColorfulTheme) {
|
||||||
let mut cfg = config::load().unwrap_or_default();
|
let mut cfg = config::load().unwrap_or_default();
|
||||||
match cfg.bandwidth.status {
|
match cfg.bandwidth.status {
|
||||||
config::BandwidthStatus::Measured | config::BandwidthStatus::Skipped => return,
|
config::BandwidthStatus::Measured | config::BandwidthStatus::Skipped => (),
|
||||||
config::BandwidthStatus::Unmeasured => {
|
config::BandwidthStatus::Unmeasured => {
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("First-time setup");
|
eprintln!("First-time setup");
|
||||||
@@ -154,7 +154,7 @@ async fn preflight_if_needed(theme: &ColorfulTheme) {
|
|||||||
|
|
||||||
let Ok(choice) = Select::with_theme(theme)
|
let Ok(choice) = Select::with_theme(theme)
|
||||||
.with_prompt("What would you like to do?")
|
.with_prompt("What would you like to do?")
|
||||||
.items(&[
|
.items([
|
||||||
"Run the bandwidth test (recommended)",
|
"Run the bandwidth test (recommended)",
|
||||||
"Skip — use the conservative default",
|
"Skip — use the conservative default",
|
||||||
])
|
])
|
||||||
@@ -180,10 +180,7 @@ async fn preflight_if_needed(theme: &ColorfulTheme) {
|
|||||||
eprintln!();
|
eprintln!();
|
||||||
let Ok(choice) = Select::with_theme(theme)
|
let Ok(choice) = Select::with_theme(theme)
|
||||||
.with_prompt("Last bandwidth test failed. Try again?")
|
.with_prompt("Last bandwidth test failed. Try again?")
|
||||||
.items(&[
|
.items(["Yes — retry now", "No — use the conservative default"])
|
||||||
"Yes — retry now",
|
|
||||||
"No — use the conservative default",
|
|
||||||
])
|
|
||||||
.default(0)
|
.default(0)
|
||||||
.interact()
|
.interact()
|
||||||
else {
|
else {
|
||||||
@@ -341,8 +338,12 @@ pub fn prompt_player() -> Result<Player> {
|
|||||||
let theme = ColorfulTheme::default();
|
let theme = ColorfulTheme::default();
|
||||||
let choice = Select::with_theme(&theme)
|
let choice = Select::with_theme(&theme)
|
||||||
.with_prompt("Connected. Pick a player to launch")
|
.with_prompt("Connected. Pick a player to launch")
|
||||||
.items(&["mpv", "VLC"])
|
.items(["mpv", "VLC"])
|
||||||
.default(0)
|
.default(0)
|
||||||
.interact()?;
|
.interact()?;
|
||||||
Ok(if choice == 0 { Player::Mpv } else { Player::Vlc })
|
Ok(if choice == 0 {
|
||||||
|
Player::Mpv
|
||||||
|
} else {
|
||||||
|
Player::Vlc
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -25,7 +25,7 @@ async fn main() -> Result<()> {
|
|||||||
if cli.gui {
|
if cli.gui {
|
||||||
#[cfg(feature = "gui")]
|
#[cfg(feature = "gui")]
|
||||||
{
|
{
|
||||||
return gui::run();
|
return gui::run(cli.relay);
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "gui"))]
|
#[cfg(not(feature = "gui"))]
|
||||||
{
|
{
|
||||||
@@ -73,7 +73,11 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn init_tracing(verbose: bool) {
|
fn init_tracing(verbose: bool) {
|
||||||
let default = if verbose { "pixelpass=trace,iroh=info" } else { "pixelpass=info,iroh=warn" };
|
let default = if verbose {
|
||||||
|
"pixelpass=trace,iroh=info"
|
||||||
|
} else {
|
||||||
|
"pixelpass=info,iroh=warn"
|
||||||
|
};
|
||||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default));
|
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default));
|
||||||
// Tracing MUST write to stderr. `tracing_subscriber::fmt()` defaults its
|
// Tracing MUST write to stderr. `tracing_subscriber::fmt()` defaults its
|
||||||
// writer to stdout, but with `--output json` stdout carries the JSON event
|
// writer to stdout, but with `--output json` stdout carries the JSON event
|
||||||
|
|||||||
+4
-4
@@ -110,9 +110,7 @@ pub async fn run() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if live_skipped > 0 {
|
if live_skipped > 0 {
|
||||||
println!(
|
println!("[pixelpass] --repair: left {live_skipped} live pixelpass host(s) alone.");
|
||||||
"[pixelpass] --repair: left {live_skipped} live pixelpass host(s) alone."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if failed > 0 {
|
if failed > 0 {
|
||||||
@@ -154,7 +152,9 @@ fn list_modules() -> Result<Vec<Module>> {
|
|||||||
for line in text.lines() {
|
for line in text.lines() {
|
||||||
let mut parts = line.splitn(4, '\t');
|
let mut parts = line.splitn(4, '\t');
|
||||||
let Some(id_str) = parts.next() else { continue };
|
let Some(id_str) = parts.next() else { continue };
|
||||||
let Ok(id) = id_str.parse::<u32>() else { continue };
|
let Ok(id) = id_str.parse::<u32>() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let Some(name) = parts.next() else { continue };
|
let Some(name) = parts.next() else { continue };
|
||||||
let args = parts.next().unwrap_or("").to_string();
|
let args = parts.next().unwrap_or("").to_string();
|
||||||
modules.push(Module {
|
modules.push(Module {
|
||||||
|
|||||||
+11
-9
@@ -1,12 +1,10 @@
|
|||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use iroh::Endpoint;
|
|
||||||
use iroh::endpoint::presets;
|
|
||||||
use iroh_tickets::endpoint::EndpointTicket;
|
use iroh_tickets::endpoint::EndpointTicket;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
use crate::cli::ViewerOpts;
|
use crate::cli::ViewerOpts;
|
||||||
use crate::common::{alpn::ALPN, output, signal};
|
use crate::common::{alpn::ALPN, endpoint, output, signal};
|
||||||
|
|
||||||
/// Cap on the initial QUIC connect. `endpoint.connect()` has no built-in
|
/// Cap on the initial QUIC connect. `endpoint.connect()` has no built-in
|
||||||
/// deadline, so an offline host / stale code / unreachable relay otherwise
|
/// deadline, so an offline host / stale code / unreachable relay otherwise
|
||||||
@@ -17,10 +15,7 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
|||||||
pub async fn run(ticket: EndpointTicket, opts: ViewerOpts) -> Result<()> {
|
pub async fn run(ticket: EndpointTicket, opts: ViewerOpts) -> Result<()> {
|
||||||
let cancel = signal::install_ctrl_c();
|
let cancel = signal::install_ctrl_c();
|
||||||
|
|
||||||
let endpoint = Endpoint::builder(presets::N0)
|
let endpoint = endpoint::bind(opts.relay.as_deref()).await?;
|
||||||
.alpns(vec![ALPN.to_vec()])
|
|
||||||
.bind()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let addr = ticket.endpoint_addr().clone();
|
let addr = ticket.endpoint_addr().clone();
|
||||||
tracing::info!(remote = %addr.id, "connecting to host");
|
tracing::info!(remote = %addr.id, "connecting to host");
|
||||||
@@ -50,6 +45,11 @@ pub async fn run(ticket: EndpointTicket, opts: ViewerOpts) -> Result<()> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
// Everything past the established connection runs in one block so any error
|
||||||
|
// (open_bi, bind, local_addr, accept) is captured rather than `?`-propagated
|
||||||
|
// straight out of the function — that would skip the close below and leak the
|
||||||
|
// endpoint. The connect-phase arms above close explicitly for the same reason.
|
||||||
|
let result = async {
|
||||||
let (quic_send, quic_recv) = conn.open_bi().await?;
|
let (quic_send, quic_recv) = conn.open_bi().await?;
|
||||||
|
|
||||||
let listener = TcpListener::bind(("127.0.0.1", opts.port)).await?;
|
let listener = TcpListener::bind(("127.0.0.1", opts.port)).await?;
|
||||||
@@ -67,7 +67,7 @@ pub async fn run(ticket: EndpointTicket, opts: ViewerOpts) -> Result<()> {
|
|||||||
print_viewer_banner(&url);
|
print_viewer_banner(&url);
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = tokio::select! {
|
tokio::select! {
|
||||||
accepted = listener.accept() => {
|
accepted = listener.accept() => {
|
||||||
let (tcp, peer) = accepted?;
|
let (tcp, peer) = accepted?;
|
||||||
tracing::info!(%peer, "local viewer connected");
|
tracing::info!(%peer, "local viewer connected");
|
||||||
@@ -77,7 +77,9 @@ pub async fn run(ticket: EndpointTicket, opts: ViewerOpts) -> Result<()> {
|
|||||||
tracing::info!("ctrl-c received before local viewer connected");
|
tracing::info!("ctrl-c received before local viewer connected");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
endpoint.close().await;
|
endpoint.close().await;
|
||||||
result
|
result
|
||||||
|
|||||||
Reference in New Issue
Block a user