Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6a88d15c0 | ||
|
|
a17b930524 | ||
|
|
6100abef33 | ||
|
|
49bd2ba687 | ||
|
|
6b0b23ef69 | ||
|
|
f422150c84 | ||
|
|
86d333d4dc | ||
|
|
fad65a4fcf | ||
|
|
3878e716dd | ||
|
|
961705ffa9 | ||
|
|
7d44808a5e | ||
|
|
e31d3db986 | ||
|
|
87a2209a85 | ||
|
|
9e8c8b4ace | ||
|
|
7e4f2f2127 | ||
|
|
b6eac330ca | ||
|
|
7fb1c96ca9 | ||
|
|
80a5b73e39 | ||
|
|
adf7d1c0e2 | ||
|
|
bcb597a0ea | ||
|
|
79b24fd567 | ||
|
|
efadc228eb |
@@ -117,6 +117,18 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alsa"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3"
|
||||||
|
dependencies = [
|
||||||
|
"alsa-sys",
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "alsa-sys"
|
name = "alsa-sys"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -1041,6 +1053,20 @@ dependencies = [
|
|||||||
"coreaudio-sys",
|
"coreaudio-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coreaudio-rs"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"libc",
|
||||||
|
"objc2-audio-toolbox",
|
||||||
|
"objc2-core-audio",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coreaudio-sys"
|
name = "coreaudio-sys"
|
||||||
version = "0.2.18"
|
version = "0.2.18"
|
||||||
@@ -1080,14 +1106,14 @@ version = "0.15.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
|
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alsa",
|
"alsa 0.9.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"coreaudio-rs",
|
"coreaudio-rs 0.11.3",
|
||||||
"dasp_sample",
|
"dasp_sample",
|
||||||
"jni 0.21.1",
|
"jni 0.21.1",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"mach2",
|
"mach2 0.4.3",
|
||||||
"ndk 0.8.0",
|
"ndk 0.8.0",
|
||||||
"ndk-context",
|
"ndk-context",
|
||||||
"oboe",
|
"oboe",
|
||||||
@@ -1097,6 +1123,36 @@ dependencies = [
|
|||||||
"windows 0.54.0",
|
"windows 0.54.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpal"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb"
|
||||||
|
dependencies = [
|
||||||
|
"alsa 0.10.0",
|
||||||
|
"coreaudio-rs 0.13.0",
|
||||||
|
"dasp_sample",
|
||||||
|
"jni 0.21.1",
|
||||||
|
"js-sys",
|
||||||
|
"libc",
|
||||||
|
"mach2 0.5.0",
|
||||||
|
"ndk 0.9.0",
|
||||||
|
"ndk-context",
|
||||||
|
"num-derive",
|
||||||
|
"num-traits",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-audio-toolbox",
|
||||||
|
"objc2-avf-audio",
|
||||||
|
"objc2-core-audio",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"windows 0.62.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -1564,6 +1620,15 @@ 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 = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encoding_rs"
|
||||||
|
version = "0.8.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "endi"
|
name = "endi"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -1699,6 +1764,12 @@ dependencies = [
|
|||||||
"zune-inflate",
|
"zune-inflate",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "extended"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.4.1"
|
version = "2.4.1"
|
||||||
@@ -2557,6 +2628,7 @@ dependencies = [
|
|||||||
"iced_core",
|
"iced_core",
|
||||||
"log",
|
"log",
|
||||||
"rustc-hash 2.1.2",
|
"rustc-hash 2.1.2",
|
||||||
|
"tokio",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasmtimer",
|
"wasmtimer",
|
||||||
]
|
]
|
||||||
@@ -3565,6 +3637,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mach2"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "malloc_buf"
|
name = "malloc_buf"
|
||||||
version = "0.0.6"
|
version = "0.0.6"
|
||||||
@@ -4252,6 +4333,31 @@ dependencies = [
|
|||||||
"objc2-quartz-core 0.3.2",
|
"objc2-quartz-core 0.3.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-audio-toolbox"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"libc",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-core-audio",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-avf-audio"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
|
||||||
|
dependencies = [
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-cloud-kit"
|
name = "objc2-cloud-kit"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -4287,6 +4393,29 @@ dependencies = [
|
|||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-audio"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
|
||||||
|
dependencies = [
|
||||||
|
"dispatch2",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-audio-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-core-data"
|
name = "objc2-core-data"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -4742,13 +4871,13 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cpal",
|
"cpal 0.15.3",
|
||||||
"dirs",
|
"dirs",
|
||||||
"iced",
|
"iced",
|
||||||
"image",
|
"image",
|
||||||
@@ -4759,11 +4888,13 @@ dependencies = [
|
|||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
"rfd",
|
"rfd",
|
||||||
"ringbuf",
|
"ringbuf",
|
||||||
|
"rodio",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5198,6 +5329,16 @@ version = "0.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_distr"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
"rand 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_pcg"
|
name = "rand_pcg"
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
@@ -5487,12 +5628,34 @@ dependencies = [
|
|||||||
"portable-atomic-util",
|
"portable-atomic-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rodio"
|
||||||
|
version = "0.22.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d0a536bb79db59098ef71a4dd4246c02eb87b316deceb1b68e0cde7167ec01eb"
|
||||||
|
dependencies = [
|
||||||
|
"cpal 0.17.1",
|
||||||
|
"dasp_sample",
|
||||||
|
"num-rational",
|
||||||
|
"rand 0.10.1",
|
||||||
|
"rand_distr",
|
||||||
|
"rtrb",
|
||||||
|
"symphonia",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "roxmltree"
|
name = "roxmltree"
|
||||||
version = "0.20.0"
|
version = "0.20.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rtrb"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ade083ccbb4bf536df69d1f6432cc23deb7acccff86b183f3923a6fd56a1153"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -6156,6 +6319,153 @@ dependencies = [
|
|||||||
"zeno",
|
"zeno",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
"symphonia-bundle-flac",
|
||||||
|
"symphonia-bundle-mp3",
|
||||||
|
"symphonia-codec-aac",
|
||||||
|
"symphonia-codec-pcm",
|
||||||
|
"symphonia-codec-vorbis",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-format-isomp4",
|
||||||
|
"symphonia-format-ogg",
|
||||||
|
"symphonia-format-riff",
|
||||||
|
"symphonia-metadata",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-bundle-flac"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
"symphonia-utils-xiph",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-bundle-mp3"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-codec-aac"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-codec-pcm"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-codec-vorbis"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-utils-xiph",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-core"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
|
||||||
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"bytemuck",
|
||||||
|
"lazy_static",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-format-isomp4"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5"
|
||||||
|
dependencies = [
|
||||||
|
"encoding_rs",
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
"symphonia-utils-xiph",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-format-ogg"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
"symphonia-utils-xiph",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-format-riff"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
|
||||||
|
dependencies = [
|
||||||
|
"extended",
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-metadata"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
|
||||||
|
dependencies = [
|
||||||
|
"encoding_rs",
|
||||||
|
"lazy_static",
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-utils-xiph"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
|
||||||
|
dependencies = [
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.117"
|
version = "2.0.117"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
# Application crate, not a crates.io library — refuse `cargo publish` and let
|
# Application crate, not a crates.io library — refuse `cargo publish` and let
|
||||||
# cargo-deny's [licenses.private] skip the missing-license check.
|
# cargo-deny's [licenses.private] skip the missing-license check.
|
||||||
@@ -28,7 +28,7 @@ async-trait = "0.1.89"
|
|||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
bytes = "1.11.1"
|
bytes = "1.11.1"
|
||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
iced = { version = "0.14.0", features = ["canvas", "image"] }
|
iced = { version = "0.14.0", features = ["canvas", "image", "tokio"] }
|
||||||
# W4 custom avatars: decode/resize an arbitrary user image (png/jpeg only to keep
|
# W4 custom avatars: decode/resize an arbitrary user image (png/jpeg only to keep
|
||||||
# the codec surface small). The matching native file picker (`rfd`) is platform-
|
# the codec surface small). The matching native file picker (`rfd`) is platform-
|
||||||
# gated below — its backend differs per OS (xdg-portal on Linux, Win32 on Windows).
|
# gated below — its backend differs per OS (xdg-portal on Linux, Win32 on Windows).
|
||||||
@@ -38,6 +38,7 @@ iroh-gossip = "0.99.0"
|
|||||||
opus = "0.3.1"
|
opus = "0.3.1"
|
||||||
rand = "0.10.1"
|
rand = "0.10.1"
|
||||||
ringbuf = "0.5.0"
|
ringbuf = "0.5.0"
|
||||||
|
rodio = "0.22.2"
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.150"
|
serde_json = "1.0.150"
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
@@ -64,3 +65,12 @@ rfd = { version = "0.17", default-features = false }
|
|||||||
# Windows audio backend: cpal drives WASAPI for capture/playback behind the
|
# Windows audio backend: cpal drives WASAPI for capture/playback behind the
|
||||||
# AudioBackend trait (src/audio/cpal_impl.rs). The Linux counterpart is pipewire.
|
# AudioBackend trait (src/audio/cpal_impl.rs). The Linux counterpart is pipewire.
|
||||||
cpal = "0.15"
|
cpal = "0.15"
|
||||||
|
# Win32 FFI for game detection (no new crate: windows-sys is already pulled in
|
||||||
|
# transitively by cpal/rfd). Registry reads the Steam RunningAppID + install path;
|
||||||
|
# Toolhelp enumerates running processes for the non-Steam process-scan fallback.
|
||||||
|
windows-sys = { version = "0.61", features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_System_Registry",
|
||||||
|
"Win32_System_Diagnostics_ToolHelp",
|
||||||
|
"Win32_System_Threading",
|
||||||
|
] }
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 606 B After Width: | Height: | Size: 843 B |
|
Before Width: | Height: | Size: 994 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 5.7 KiB |
@@ -1,41 +1,66 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
||||||
<!-- PeerSpeak app icon: in-app mic glyph + P2P mesh nodes, Catppuccin Mocha. -->
|
<title>PeerSpeak</title>
|
||||||
|
<desc>Two luminous voices meet directly to form a flowing S.</desc>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="tile" x1="32" y1="20" x2="225" y2="239" gradientUnits="userSpaceOnUse">
|
||||||
<stop offset="0" stop-color="#1e1e2e"/>
|
<stop stop-color="#101d42"/>
|
||||||
<stop offset="1" stop-color="#181825"/>
|
<stop offset="0.5" stop-color="#071225"/>
|
||||||
|
<stop offset="1" stop-color="#160b31"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<linearGradient id="voice" x1="45" y1="76" x2="214" y2="184" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#35efff"/>
|
||||||
|
<stop offset="0.42" stop-color="#2583ff"/>
|
||||||
|
<stop offset="0.68" stop-color="#8a42ff"/>
|
||||||
|
<stop offset="1" stop-color="#ff3cdd"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="edge" x1="30" y1="31" x2="225" y2="231" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#2fe9ff" stop-opacity="0.7"/>
|
||||||
|
<stop offset="0.48" stop-color="#386dff" stop-opacity="0.18"/>
|
||||||
|
<stop offset="1" stop-color="#eb42ff" stop-opacity="0.65"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="core">
|
||||||
|
<stop stop-color="#ffffff"/>
|
||||||
|
<stop offset="0.28" stop-color="#baf7ff"/>
|
||||||
|
<stop offset="0.62" stop-color="#7b67ff" stop-opacity="0.65"/>
|
||||||
|
<stop offset="1" stop-color="#7b67ff" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="shadow" x="-35%" y="-35%" width="170%" height="170%">
|
||||||
|
<feGaussianBlur stdDeviation="6"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="soft-shadow" x="-20%" y="-20%" width="140%" height="150%">
|
||||||
|
<feDropShadow dx="0" dy="7" stdDeviation="7" flood-color="#000611" flood-opacity="0.8"/>
|
||||||
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<!-- Rounded-square tile -->
|
<!-- A dark stage makes the cyan/violet conversation mark legible at taskbar size. -->
|
||||||
<rect x="20" y="20" width="216" height="216" rx="48" fill="url(#tile)"
|
<rect x="8" y="8" width="240" height="240" rx="55" fill="url(#tile)"/>
|
||||||
stroke="#313244" stroke-width="3"/>
|
<rect x="9.5" y="9.5" width="237" height="237" rx="53.5" fill="none" stroke="url(#edge)" stroke-width="3"/>
|
||||||
|
|
||||||
<!-- P2P mesh: edges (under nodes + mic) -->
|
<!-- Broad color glow, kept behind the silhouette. -->
|
||||||
<g stroke="#45475a" stroke-width="6" stroke-linecap="round" fill="none">
|
<path d="M36 128 C50 128 48 101 60 101 C72 101 68 153 81 153 C94 153 90 112 104 112 C115 112 116 128 128 128 C140 128 141 144 152 144 C166 144 162 103 175 103 C188 103 184 155 196 155 C208 155 206 128 220 128"
|
||||||
<line x1="74" y1="74" x2="128" y2="128"/>
|
fill="none" stroke="url(#voice)" stroke-width="25" stroke-linecap="round"
|
||||||
<line x1="182" y1="74" x2="128" y2="128"/>
|
opacity="0.5" filter="url(#shadow)"/>
|
||||||
<line x1="74" y1="182" x2="128" y2="128"/>
|
|
||||||
<line x1="182" y1="182" x2="128" y2="128"/>
|
|
||||||
<line x1="74" y1="74" x2="182" y2="74"/>
|
|
||||||
<line x1="74" y1="182" x2="182" y2="182"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- P2P mesh: peer nodes -->
|
<!-- The two waveform halves are equal peers and meet at one bright point. -->
|
||||||
<g fill="#b4befe">
|
<path d="M36 128 C50 128 48 101 60 101 C72 101 68 153 81 153 C94 153 90 112 104 112 C115 112 116 128 128 128"
|
||||||
<circle cx="74" cy="74" r="11"/>
|
fill="none" stroke="url(#voice)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<circle cx="182" cy="74" r="11"/>
|
<path d="M128 128 C140 128 141 144 152 144 C166 144 162 103 175 103 C188 103 184 155 196 155 C208 155 206 128 220 128"
|
||||||
<circle cx="74" cy="182" r="11"/>
|
fill="none" stroke="url(#voice)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<circle cx="182" cy="182" r="11"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Microphone (hero) — same geometry as the in-app Mic icon, scaled 6.4x -->
|
<!-- A single flowing connection turns the conversation into PeerSpeak's S-mark. -->
|
||||||
<g fill="none" stroke="#89b4fa" stroke-width="13"
|
<path d="M160 66 C142 52 108 57 101 78 C94 98 113 111 128 119 C148 130 163 141 157 164 C151 188 117 199 94 184"
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
fill="none" stroke="#050b1b" stroke-opacity="0.72" stroke-width="33"
|
||||||
<rect x="108.8" y="68.8" width="38.4" height="70.4" rx="19.2"/>
|
stroke-linecap="round" stroke-linejoin="round" filter="url(#soft-shadow)"/>
|
||||||
<path d="M 169.6 123.2 A 41.6 41.6 0 0 0 86.4 123.2"/>
|
<path d="M160 66 C142 52 108 57 101 78 C94 98 113 111 128 119 C148 130 163 141 157 164 C151 188 117 199 94 184"
|
||||||
<line x1="128" y1="164.8" x2="128" y2="187.2"/>
|
fill="none" stroke="url(#voice)" stroke-width="25"
|
||||||
<line x1="105.6" y1="187.2" x2="150.4" y2="187.2"/>
|
stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</g>
|
<path d="M157 65 C139 56 113 62 108 79" fill="none" stroke="#bdf9ff"
|
||||||
|
stroke-opacity="0.68" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<path d="M153 166 C146 184 118 192 98 181" fill="none" stroke="#f4a8ff"
|
||||||
|
stroke-opacity="0.52" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- The direct connection is the brightest and simplest detail. -->
|
||||||
|
<circle cx="128" cy="128" r="30" fill="url(#core)" opacity="0.78" filter="url(#shadow)"/>
|
||||||
|
<circle cx="128" cy="128" r="6.5" fill="#ffffff"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 3.9 KiB |
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
||||||
pkgname=peerspeak-git
|
pkgname=peerspeak-git
|
||||||
_pkgname=peerspeak
|
_pkgname=peerspeak
|
||||||
pkgver=0.2.0.r218.gcbba4b6
|
pkgver=0.3.0.r229.g7fb1c96
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 364 KiB |
@@ -2,6 +2,10 @@ use crate::core::{CoreController, messages::{CoreCommand, UiEvent}};
|
|||||||
use crate::network::PeerState;
|
use crate::network::PeerState;
|
||||||
use crate::notify::{self, Sound};
|
use crate::notify::{self, Sound};
|
||||||
use crate::audio::eq::{EqSettings, EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN};
|
use crate::audio::eq::{EqSettings, EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN};
|
||||||
|
use crate::audio::clip_player::{
|
||||||
|
ClipPlayer, SharedClipStatus, format_time as format_clip_time, progress as clip_progress,
|
||||||
|
seek_target, status_snapshot,
|
||||||
|
};
|
||||||
use crate::audio::{AudioDevice, enumerate_audio_devices};
|
use crate::audio::{AudioDevice, enumerate_audio_devices};
|
||||||
use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout};
|
use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout};
|
||||||
use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding};
|
use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding};
|
||||||
@@ -41,10 +45,11 @@ pub enum SettingsCategory {
|
|||||||
Appearance,
|
Appearance,
|
||||||
Network,
|
Network,
|
||||||
Notifications,
|
Notifications,
|
||||||
|
Games,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SettingsCategory {
|
impl SettingsCategory {
|
||||||
const ALL: [SettingsCategory; 7] = [
|
const ALL: [SettingsCategory; 8] = [
|
||||||
SettingsCategory::Audio,
|
SettingsCategory::Audio,
|
||||||
SettingsCategory::Hotkeys,
|
SettingsCategory::Hotkeys,
|
||||||
SettingsCategory::Recording,
|
SettingsCategory::Recording,
|
||||||
@@ -52,6 +57,7 @@ impl SettingsCategory {
|
|||||||
SettingsCategory::Appearance,
|
SettingsCategory::Appearance,
|
||||||
SettingsCategory::Network,
|
SettingsCategory::Network,
|
||||||
SettingsCategory::Notifications,
|
SettingsCategory::Notifications,
|
||||||
|
SettingsCategory::Games,
|
||||||
];
|
];
|
||||||
|
|
||||||
fn label(self) -> &'static str {
|
fn label(self) -> &'static str {
|
||||||
@@ -63,6 +69,7 @@ impl SettingsCategory {
|
|||||||
SettingsCategory::Appearance => "Appearance",
|
SettingsCategory::Appearance => "Appearance",
|
||||||
SettingsCategory::Network => "Network",
|
SettingsCategory::Network => "Network",
|
||||||
SettingsCategory::Notifications => "Notifications",
|
SettingsCategory::Notifications => "Notifications",
|
||||||
|
SettingsCategory::Games => "Games",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +82,7 @@ impl SettingsCategory {
|
|||||||
SettingsCategory::Appearance => "Layout and theme",
|
SettingsCategory::Appearance => "Layout and theme",
|
||||||
SettingsCategory::Network => "Relay and privacy mode",
|
SettingsCategory::Network => "Relay and privacy mode",
|
||||||
SettingsCategory::Notifications => "Chimes and sounds",
|
SettingsCategory::Notifications => "Chimes and sounds",
|
||||||
|
SettingsCategory::Games => "Detection, presence, backgrounds",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,6 +291,15 @@ pub enum AppMessage {
|
|||||||
AttachmentFilePicked(Option<(String, Vec<u8>)>),
|
AttachmentFilePicked(Option<(String, Vec<u8>)>),
|
||||||
/// Save (downloading first if needed) a received attachment to disk.
|
/// Save (downloading first if needed) a received attachment to disk.
|
||||||
SaveAttachment(crate::files::AttachmentId),
|
SaveAttachment(crate::files::AttachmentId),
|
||||||
|
/// Result of the async save dialog: a status line to show, or None if cancelled.
|
||||||
|
AttachmentSaved(Option<String>),
|
||||||
|
/// Fetch (if needed) and start an inline audio attachment.
|
||||||
|
PlayAudio(crate::files::AttachmentId),
|
||||||
|
PauseAudio,
|
||||||
|
ResumeAudio,
|
||||||
|
SeekAudio(crate::files::AttachmentId, f32),
|
||||||
|
/// Redraw cadence while an inline clip is active.
|
||||||
|
AudioTick,
|
||||||
/// Send the current chat input line (Enter or the Send button).
|
/// Send the current chat input line (Enter or the Send button).
|
||||||
ChatSubmit,
|
ChatSubmit,
|
||||||
/// Open a clicked chat link in the system browser (A13).
|
/// Open a clicked chat link in the system browser (A13).
|
||||||
@@ -329,6 +346,47 @@ pub enum AppMessage {
|
|||||||
ShutdownCommandSent(bool),
|
ShutdownCommandSent(bool),
|
||||||
/// Fallback close if the core does not acknowledge shutdown promptly.
|
/// Fallback close if the core does not acknowledge shutdown promptly.
|
||||||
ShutdownTimeout,
|
ShutdownTimeout,
|
||||||
|
// --- Game detection (W17/W18) ---
|
||||||
|
/// Toggle broadcasting the detected game to peers (opt-in, default off).
|
||||||
|
ToggleGamePresence(bool),
|
||||||
|
/// Choose the manual detection override (Auto / None / the current game).
|
||||||
|
GameOverrideSelected(GameOverrideChoice),
|
||||||
|
/// Add-mapping form edits (executable basename → display name).
|
||||||
|
GameMapExeChanged(String),
|
||||||
|
GameMapNameChanged(String),
|
||||||
|
/// Commit the add-mapping form into the process map.
|
||||||
|
AddGameMapping,
|
||||||
|
/// Remove a process→name mapping by its executable key.
|
||||||
|
RemoveGameMapping(String),
|
||||||
|
/// Open the native picker to set a per-game background for the given game id.
|
||||||
|
PickGameBackground(String),
|
||||||
|
/// Result of the per-game background picker: (game id, chosen bytes or None).
|
||||||
|
GameBackgroundPicked(String, Option<Vec<u8>>),
|
||||||
|
/// Clear a per-game background mapping by game id.
|
||||||
|
RemoveGameBackground(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The manual game-detection override as shown in the Settings picker. Maps to a
|
||||||
|
/// [`crate::game::ManualOverride`] using the app's currently-detected game for the
|
||||||
|
/// `Current` choice (so "force this game" carries the live id + name).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum GameOverrideChoice {
|
||||||
|
/// Trust auto-detection (default).
|
||||||
|
Auto,
|
||||||
|
/// Force "not playing" — never broadcast a game.
|
||||||
|
None,
|
||||||
|
/// Pin the game currently detected (only offered while something is detected).
|
||||||
|
Current,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for GameOverrideChoice {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(match self {
|
||||||
|
GameOverrideChoice::Auto => "Auto-detect",
|
||||||
|
GameOverrideChoice::None => "None (don't show a game)",
|
||||||
|
GameOverrideChoice::Current => "Pin current game",
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> {
|
fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> {
|
||||||
@@ -368,6 +426,18 @@ pub struct AppState {
|
|||||||
/// doesn't read the file from disk on every redraw. Loaded on startup and
|
/// doesn't read the file from disk on every redraw. Loaded on startup and
|
||||||
/// refreshed when the background is changed/removed. `None` = no custom bg.
|
/// refreshed when the background is changed/removed. `None` = no custom bg.
|
||||||
background_image: Option<bytes::Bytes>,
|
background_image: Option<bytes::Bytes>,
|
||||||
|
/// The locally-detected running game (game detection), as reported by core.
|
||||||
|
/// Drives the per-game background (W18) and a local "Playing …" indicator.
|
||||||
|
/// `None` = nothing detected. Independent of whether we broadcast it to peers
|
||||||
|
/// (that's `config.game_presence_enabled`).
|
||||||
|
current_game: Option<crate::game::DetectedGame>,
|
||||||
|
/// Add-mapping form state in the Games settings: the executable basename and
|
||||||
|
/// display name being entered for a new process→name mapping.
|
||||||
|
game_map_exe_input: String,
|
||||||
|
game_map_name_input: String,
|
||||||
|
/// Current manual-override selection shown in the Games settings picker.
|
||||||
|
/// Session-only (not persisted); defaults to Auto each launch.
|
||||||
|
game_override: GameOverrideChoice,
|
||||||
peers: HashMap<EndpointId, PeerState>,
|
peers: HashMap<EndpointId, PeerState>,
|
||||||
audio_levels: HashMap<EndpointId, f32>,
|
audio_levels: HashMap<EndpointId, f32>,
|
||||||
/// Peers we've locally muted (their audio isn't mixed into our output).
|
/// Peers we've locally muted (their audio isn't mixed into our output).
|
||||||
@@ -390,6 +460,15 @@ pub struct AppState {
|
|||||||
/// Attachment ids the user asked to save before the bytes arrived; when the
|
/// Attachment ids the user asked to save before the bytes arrived; when the
|
||||||
/// fetch completes a save dialog is opened for them.
|
/// fetch completes a save dialog is opened for them.
|
||||||
pending_saves: std::collections::HashSet<crate::files::AttachmentId>,
|
pending_saves: std::collections::HashSet<crate::files::AttachmentId>,
|
||||||
|
/// Clip ids waiting for the existing attachment fetch path to return bytes.
|
||||||
|
pending_plays: HashSet<crate::files::AttachmentId>,
|
||||||
|
/// Filename-hinted audio whose fetched bytes or decoder validation failed;
|
||||||
|
/// these entries fall back to the normal file chip.
|
||||||
|
invalid_audio: HashSet<crate::files::AttachmentId>,
|
||||||
|
/// Independent system-default-device player for chat clips. It never enters
|
||||||
|
/// the call capture/mixer path.
|
||||||
|
clip_player: ClipPlayer,
|
||||||
|
clip_status: SharedClipStatus,
|
||||||
/// Last known window size, tracked so divider clamps stay valid on resize.
|
/// Last known window size, tracked so divider clamps stay valid on resize.
|
||||||
/// (The divider positions themselves are persisted in `config`.)
|
/// (The divider positions themselves are persisted in `config`.)
|
||||||
window_size: Size,
|
window_size: Size,
|
||||||
@@ -450,6 +529,27 @@ pub struct AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
|
fn reset_room_state(&mut self) {
|
||||||
|
self.clip_player.stop();
|
||||||
|
self.peers.clear();
|
||||||
|
self.audio_levels.clear();
|
||||||
|
self.locally_muted.clear();
|
||||||
|
self.chat_messages.clear();
|
||||||
|
self.chat_input.clear();
|
||||||
|
self.attachment_data.clear();
|
||||||
|
self.image_handle_cache.clear();
|
||||||
|
self.pending_saves.clear();
|
||||||
|
self.pending_plays.clear();
|
||||||
|
self.invalid_audio.clear();
|
||||||
|
self.connecting.clear();
|
||||||
|
self.ever_connected.clear();
|
||||||
|
self.recording = false;
|
||||||
|
self.recording_started = None;
|
||||||
|
self.call_started = None;
|
||||||
|
self.mic_level = 0.0;
|
||||||
|
self.self_sharing = false;
|
||||||
|
}
|
||||||
|
|
||||||
fn custom_sound_path(&self, sound: Sound) -> &str {
|
fn custom_sound_path(&self, sound: Sound) -> &str {
|
||||||
let opt = match sound {
|
let opt = match sound {
|
||||||
Sound::SelfJoin => &self.config.custom_sound_self_join,
|
Sound::SelfJoin => &self.config.custom_sound_self_join,
|
||||||
@@ -492,6 +592,10 @@ impl Default for AppState {
|
|||||||
let _ = controller.send(CoreCommand::SetRecordingMode(config.recording_mode));
|
let _ = controller.send(CoreCommand::SetRecordingMode(config.recording_mode));
|
||||||
let _ = controller.send(CoreCommand::SetPixelpassPath(config.pixelpass_path.clone()));
|
let _ = controller.send(CoreCommand::SetPixelpassPath(config.pixelpass_path.clone()));
|
||||||
let _ = controller.send(CoreCommand::SetPresenceMode(config.presence_mode));
|
let _ = controller.send(CoreCommand::SetPresenceMode(config.presence_mode));
|
||||||
|
// Game detection (W17/W18): seed the opt-in broadcast flag + the user's
|
||||||
|
// process→name mappings from config. The manual override starts at Auto.
|
||||||
|
let _ = controller.send(CoreCommand::SetGamePresenceEnabled(config.game_presence_enabled));
|
||||||
|
let _ = controller.send(CoreCommand::SetGameProcessMap(config.game_process_map.clone()));
|
||||||
for (peer, settings) in &config.peer_eq {
|
for (peer, settings) in &config.peer_eq {
|
||||||
if let Ok(id) = peer.parse::<EndpointId>() {
|
if let Ok(id) = peer.parse::<EndpointId>() {
|
||||||
let _ = controller.send(CoreCommand::SetPeerEq(id, *settings));
|
let _ = controller.send(CoreCommand::SetPeerEq(id, *settings));
|
||||||
@@ -522,6 +626,7 @@ impl Default for AppState {
|
|||||||
let selected_output = output_devices.iter().find(|d| d.name == config.output_device).cloned();
|
let selected_output = output_devices.iter().find(|d| d.name == config.output_device).cloned();
|
||||||
|
|
||||||
let background_image = load_background_bytes(&config);
|
let background_image = load_background_bytes(&config);
|
||||||
|
let (clip_player, clip_status) = ClipPlayer::new();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
// Pre-fill the nickname with the last one used (or "Peer" by default).
|
// Pre-fill the nickname with the last one used (or "Peer" by default).
|
||||||
@@ -542,6 +647,10 @@ impl Default for AppState {
|
|||||||
selected_output,
|
selected_output,
|
||||||
config,
|
config,
|
||||||
background_image,
|
background_image,
|
||||||
|
current_game: None,
|
||||||
|
game_map_exe_input: String::new(),
|
||||||
|
game_map_name_input: String::new(),
|
||||||
|
game_override: GameOverrideChoice::Auto,
|
||||||
peers: HashMap::new(),
|
peers: HashMap::new(),
|
||||||
audio_levels: HashMap::new(),
|
audio_levels: HashMap::new(),
|
||||||
locally_muted: HashSet::new(),
|
locally_muted: HashSet::new(),
|
||||||
@@ -552,6 +661,10 @@ impl Default for AppState {
|
|||||||
attachment_data: HashMap::new(),
|
attachment_data: HashMap::new(),
|
||||||
image_handle_cache: HashMap::new(),
|
image_handle_cache: HashMap::new(),
|
||||||
pending_saves: std::collections::HashSet::new(),
|
pending_saves: std::collections::HashSet::new(),
|
||||||
|
pending_plays: HashSet::new(),
|
||||||
|
invalid_audio: HashSet::new(),
|
||||||
|
clip_player,
|
||||||
|
clip_status,
|
||||||
chat_input: String::new(),
|
chat_input: String::new(),
|
||||||
window_size: Size::new(ww, wh),
|
window_size: Size::new(ww, wh),
|
||||||
layout_picker_open: false,
|
layout_picker_open: false,
|
||||||
@@ -597,6 +710,23 @@ fn load_background_bytes(config: &AppConfig) -> Option<bytes::Bytes> {
|
|||||||
std::fs::read(path).ok().map(bytes::Bytes::from)
|
std::fs::read(path).ok().map(bytes::Bytes::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The effective background for the current state: the per-game override (W18) when
|
||||||
|
/// the running `game` has a mapping in `config.game_backgrounds`, otherwise the
|
||||||
|
/// single custom background (W16). A mapped-but-missing/unreadable per-game file
|
||||||
|
/// falls back to the default WITHOUT forgetting the mapping (the file may return).
|
||||||
|
fn effective_background_bytes(
|
||||||
|
config: &AppConfig,
|
||||||
|
game: Option<&crate::game::DetectedGame>,
|
||||||
|
) -> Option<bytes::Bytes> {
|
||||||
|
if let Some(g) = game
|
||||||
|
&& let Some(path) = config.game_backgrounds.get(&g.id)
|
||||||
|
&& let Ok(bytes) = std::fs::read(path)
|
||||||
|
{
|
||||||
|
return Some(bytes::Bytes::from(bytes));
|
||||||
|
}
|
||||||
|
load_background_bytes(config)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run_gui() -> iced::Result {
|
pub fn run_gui() -> iced::Result {
|
||||||
// Restore the last window size (saved on close). Position is restored too,
|
// Restore the last window size (saved on close). Position is restored too,
|
||||||
// but only on X11 — Wayland's xdg-shell gives clients no way to set their own
|
// but only on X11 — Wayland's xdg-shell gives clients no way to set their own
|
||||||
@@ -676,10 +806,15 @@ fn initial_window_position(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn subscription(_state: &AppState) -> Subscription<AppMessage> {
|
fn subscription(state: &AppState) -> Subscription<AppMessage> {
|
||||||
let core_sub = Subscription::run(core_subscription).map(AppMessage::UiEventReceived);
|
let core_sub = Subscription::run(core_subscription).map(AppMessage::UiEventReceived);
|
||||||
let event_sub = iced::event::listen().map(AppMessage::EventOccurred);
|
let event_sub = iced::event::listen().map(AppMessage::EventOccurred);
|
||||||
Subscription::batch(vec![core_sub, event_sub])
|
let audio_sub = if status_snapshot(&state.clip_status).playing_id.is_some() {
|
||||||
|
iced::time::every(std::time::Duration::from_millis(250)).map(|_| AppMessage::AudioTick)
|
||||||
|
} else {
|
||||||
|
Subscription::none()
|
||||||
|
};
|
||||||
|
Subscription::batch(vec![core_sub, event_sub, audio_sub])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn shutdown_timeout_task() -> Task<AppMessage> {
|
fn shutdown_timeout_task() -> Task<AppMessage> {
|
||||||
@@ -871,6 +1006,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
let output_device = state.selected_output.as_ref().map(|d| d.name.clone());
|
let output_device = state.selected_output.as_ref().map(|d| d.name.clone());
|
||||||
if !state.ticket_input.is_empty() {
|
if !state.ticket_input.is_empty() {
|
||||||
state.status_message = "Joining room...".to_string();
|
state.status_message = "Joining room...".to_string();
|
||||||
|
// Clear any prior room's UI state now, at initiation, so an early
|
||||||
|
// `PeerJoined` for the new room (which can beat `RoomJoined`) isn't
|
||||||
|
// wiped. From Home this is a no-op (already cleared on leave).
|
||||||
|
state.reset_room_state();
|
||||||
// Remember this nickname for next launch.
|
// Remember this nickname for next launch.
|
||||||
state.config.username = state.name.clone();
|
state.config.username = state.name.clone();
|
||||||
state.config.save();
|
state.config.save();
|
||||||
@@ -891,6 +1030,8 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
|
let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
|
||||||
let output_device = state.selected_output.as_ref().map(|d| d.name.clone());
|
let output_device = state.selected_output.as_ref().map(|d| d.name.clone());
|
||||||
state.status_message = "Creating room...".to_string();
|
state.status_message = "Creating room...".to_string();
|
||||||
|
// Clear any prior room's UI state at initiation (see JoinPressed).
|
||||||
|
state.reset_room_state();
|
||||||
// Remember this nickname for next launch.
|
// Remember this nickname for next launch.
|
||||||
state.config.username = state.name.clone();
|
state.config.username = state.name.clone();
|
||||||
state.config.save();
|
state.config.save();
|
||||||
@@ -930,6 +1071,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
AppMessage::UiEventReceived(event) => {
|
AppMessage::UiEventReceived(event) => {
|
||||||
match event {
|
match event {
|
||||||
UiEvent::RoomJoined { ticket, self_id } => {
|
UiEvent::RoomJoined { ticket, self_id } => {
|
||||||
|
// NB: do NOT clear peers here. `PeerJoined` rides a separate
|
||||||
|
// channel sender (the gossip event task) and routinely arrives
|
||||||
|
// BEFORE this `RoomJoined` (which the core emits only after audio
|
||||||
|
// + echo-cancel setup), so clearing here would wipe a peer that
|
||||||
|
// already announced → an empty roster. Room-scoped state is reset
|
||||||
|
// at join *initiation* instead (see the Join* handlers).
|
||||||
// Remember this gathering for one-click rejoin (W7 P5). The
|
// Remember this gathering for one-click rejoin (W7 P5). The
|
||||||
// emitted ticket is the canonical room door (topic + member
|
// emitted ticket is the canonical room door (topic + member
|
||||||
// addr + label); push_recent de-dupes by topic and persists.
|
// addr + label); push_recent de-dupes by topic and persists.
|
||||||
@@ -951,23 +1098,18 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
notify::play(Sound::SelfJoin, state.config.custom_sound_self_join.as_deref());
|
notify::play(Sound::SelfJoin, state.config.custom_sound_self_join.as_deref());
|
||||||
}
|
}
|
||||||
UiEvent::RoomLeft => {
|
UiEvent::RoomLeft => {
|
||||||
|
state.reset_room_state();
|
||||||
state.ticket = "".to_string();
|
state.ticket = "".to_string();
|
||||||
state.peers.clear();
|
|
||||||
state.audio_levels.clear();
|
|
||||||
state.locally_muted.clear();
|
|
||||||
state.call_started = None;
|
|
||||||
state.recording = false;
|
|
||||||
state.recording_started = None;
|
|
||||||
state.chat_messages.clear();
|
|
||||||
state.chat_input.clear();
|
|
||||||
state.connecting.clear();
|
|
||||||
state.ever_connected.clear();
|
|
||||||
state.status_message = "Ready to connect".to_string();
|
state.status_message = "Ready to connect".to_string();
|
||||||
state.current_screen = Screen::Home;
|
state.current_screen = Screen::Home;
|
||||||
state.mic_level = 0.0;
|
|
||||||
state.self_sharing = false;
|
|
||||||
notify::play(Sound::SelfLeave, state.config.custom_sound_self_leave.as_deref());
|
notify::play(Sound::SelfLeave, state.config.custom_sound_self_leave.as_deref());
|
||||||
}
|
}
|
||||||
|
UiEvent::RoomReset => {
|
||||||
|
state.reset_room_state();
|
||||||
|
state.ticket = "".to_string();
|
||||||
|
state.status_message = "Ready to connect".to_string();
|
||||||
|
state.current_screen = Screen::Home;
|
||||||
|
}
|
||||||
UiEvent::PeerJoined { id, state: peer_state } => {
|
UiEvent::PeerJoined { id, state: peer_state } => {
|
||||||
state.peers.insert(id, peer_state);
|
state.peers.insert(id, peer_state);
|
||||||
notify::play(Sound::PeerJoin, state.config.custom_sound_peer_join.as_deref());
|
notify::play(Sound::PeerJoin, state.config.custom_sound_peer_join.as_deref());
|
||||||
@@ -1056,13 +1198,18 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let needs_save = state.pending_saves.remove(&id);
|
let needs_save = state.pending_saves.remove(&id);
|
||||||
|
let needs_play = state.pending_plays.remove(&id);
|
||||||
state.attachment_data.insert(id, AttachmentState::Ready(data));
|
state.attachment_data.insert(id, AttachmentState::Ready(data));
|
||||||
|
if needs_play {
|
||||||
|
play_ready_audio(state, id);
|
||||||
|
}
|
||||||
if needs_save {
|
if needs_save {
|
||||||
save_attachment_to_disk(state, id);
|
return save_attachment_task(state, id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
UiEvent::AttachmentFailed { id, error } => {
|
UiEvent::AttachmentFailed { id, error } => {
|
||||||
state.pending_saves.remove(&id);
|
state.pending_saves.remove(&id);
|
||||||
|
state.pending_plays.remove(&id);
|
||||||
state.attachment_data.insert(id, AttachmentState::Failed(error.clone()));
|
state.attachment_data.insert(id, AttachmentState::Failed(error.clone()));
|
||||||
state.status_message = format!("Attachment failed: {error}");
|
state.status_message = format!("Attachment failed: {error}");
|
||||||
}
|
}
|
||||||
@@ -1101,6 +1248,15 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
format!("Presence mode stayed {mode}")
|
format!("Presence mode stayed {mode}")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
UiEvent::GameChanged(detected) => {
|
||||||
|
// The locally-detected game changed: switch the per-game
|
||||||
|
// background (W18) if one is mapped, else fall back to the
|
||||||
|
// default. Presence broadcasting is handled in core, gated by
|
||||||
|
// the opt-in toggle; this is purely local presentation.
|
||||||
|
state.current_game = detected;
|
||||||
|
state.background_image =
|
||||||
|
effective_background_bytes(&state.config, state.current_game.as_ref());
|
||||||
|
}
|
||||||
UiEvent::ShutdownComplete => {
|
UiEvent::ShutdownComplete => {
|
||||||
if state.closing {
|
if state.closing {
|
||||||
return iced::exit();
|
return iced::exit();
|
||||||
@@ -1278,6 +1434,8 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
|
let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
|
||||||
let output_device = state.selected_output.as_ref().map(|d| d.name.clone());
|
let output_device = state.selected_output.as_ref().map(|d| d.name.clone());
|
||||||
state.status_message = "Joining your friend's room...".to_string();
|
state.status_message = "Joining your friend's room...".to_string();
|
||||||
|
// Clear any prior room's UI state at initiation (see JoinPressed).
|
||||||
|
state.reset_room_state();
|
||||||
state.config.username = state.name.clone();
|
state.config.username = state.name.clone();
|
||||||
state.config.save();
|
state.config.save();
|
||||||
state.mic_test_active = false;
|
state.mic_test_active = false;
|
||||||
@@ -1297,6 +1455,8 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
|
let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
|
||||||
let output_device = state.selected_output.as_ref().map(|d| d.name.clone());
|
let output_device = state.selected_output.as_ref().map(|d| d.name.clone());
|
||||||
state.status_message = "Rejoining a recent room...".to_string();
|
state.status_message = "Rejoining a recent room...".to_string();
|
||||||
|
// Clear any prior room's UI state at initiation (see JoinPressed).
|
||||||
|
state.reset_room_state();
|
||||||
state.config.username = state.name.clone();
|
state.config.username = state.name.clone();
|
||||||
state.config.save();
|
state.config.save();
|
||||||
state.mic_test_active = false;
|
state.mic_test_active = false;
|
||||||
@@ -1536,6 +1696,126 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
state.config.background_dim = dim.clamp(0.0, 1.0);
|
state.config.background_dim = dim.clamp(0.0, 1.0);
|
||||||
state.config.save();
|
state.config.save();
|
||||||
}
|
}
|
||||||
|
AppMessage::ToggleGamePresence(enabled) => {
|
||||||
|
state.config.game_presence_enabled = enabled;
|
||||||
|
state.config.save();
|
||||||
|
// Core publishes/clears our game immediately (D8).
|
||||||
|
let _ = state.controller.send(CoreCommand::SetGamePresenceEnabled(enabled));
|
||||||
|
}
|
||||||
|
AppMessage::GameOverrideSelected(choice) => {
|
||||||
|
state.game_override = choice;
|
||||||
|
let override_ = match choice {
|
||||||
|
GameOverrideChoice::Auto => crate::game::ManualOverride::Auto,
|
||||||
|
GameOverrideChoice::None => crate::game::ManualOverride::ForceNone,
|
||||||
|
// "Pin current" carries the live detection; nothing detected = Auto.
|
||||||
|
GameOverrideChoice::Current => match &state.current_game {
|
||||||
|
Some(g) => crate::game::ManualOverride::Force(g.clone()),
|
||||||
|
None => crate::game::ManualOverride::Auto,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let _ = state.controller.send(CoreCommand::SetGameOverride(override_));
|
||||||
|
}
|
||||||
|
AppMessage::GameMapExeChanged(val) => {
|
||||||
|
state.game_map_exe_input = val;
|
||||||
|
}
|
||||||
|
AppMessage::GameMapNameChanged(val) => {
|
||||||
|
state.game_map_name_input = val;
|
||||||
|
}
|
||||||
|
AppMessage::AddGameMapping => {
|
||||||
|
let exe = crate::game::normalize_exe(&state.game_map_exe_input);
|
||||||
|
let name = state.game_map_name_input.trim().to_string();
|
||||||
|
if !exe.is_empty() && !name.is_empty() {
|
||||||
|
state.config.game_process_map.insert(exe, name);
|
||||||
|
state.config.save();
|
||||||
|
state.game_map_exe_input.clear();
|
||||||
|
state.game_map_name_input.clear();
|
||||||
|
let _ = state
|
||||||
|
.controller
|
||||||
|
.send(CoreCommand::SetGameProcessMap(state.config.game_process_map.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppMessage::RemoveGameMapping(exe) => {
|
||||||
|
if state.config.game_process_map.remove(&exe).is_some() {
|
||||||
|
state.config.save();
|
||||||
|
let _ = state
|
||||||
|
.controller
|
||||||
|
.send(CoreCommand::SetGameProcessMap(state.config.game_process_map.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppMessage::PickGameBackground(game_id) => {
|
||||||
|
// Native picker off the UI thread; result tagged with the game id.
|
||||||
|
return Task::perform(
|
||||||
|
async {
|
||||||
|
let handle = rfd::AsyncFileDialog::new()
|
||||||
|
.add_filter("Images", &["png", "jpg", "jpeg", "webp", "bmp"])
|
||||||
|
.set_title("Choose a per-game background")
|
||||||
|
.pick_file()
|
||||||
|
.await;
|
||||||
|
match handle {
|
||||||
|
Some(h) => Some(h.read().await),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
move |bytes| AppMessage::GameBackgroundPicked(game_id, bytes),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
AppMessage::GameBackgroundPicked(game_id, picked) => {
|
||||||
|
if let Some(bytes) = picked {
|
||||||
|
match crate::background::process_background(&bytes) {
|
||||||
|
Ok(png) => match AppConfig::game_background_path(&game_id) {
|
||||||
|
Some(path) => {
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(dir);
|
||||||
|
}
|
||||||
|
match std::fs::write(&path, &png) {
|
||||||
|
Ok(()) => {
|
||||||
|
state
|
||||||
|
.config
|
||||||
|
.game_backgrounds
|
||||||
|
.insert(game_id.clone(), path.to_string_lossy().into_owned());
|
||||||
|
state.config.save();
|
||||||
|
// Apply at once if it's the running game.
|
||||||
|
if state.current_game.as_ref().map(|g| g.id.as_str())
|
||||||
|
== Some(game_id.as_str())
|
||||||
|
{
|
||||||
|
state.background_image = effective_background_bytes(
|
||||||
|
&state.config,
|
||||||
|
state.current_game.as_ref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
state.status_message = "Game background updated.".to_string();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
state.status_message =
|
||||||
|
format!("Couldn't save game background: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
state.status_message =
|
||||||
|
"Couldn't find a config directory to save the background."
|
||||||
|
.to_string();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
state.status_message = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppMessage::RemoveGameBackground(game_id) => {
|
||||||
|
if let Some(path) = AppConfig::game_background_path(&game_id) {
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
if state.config.game_backgrounds.remove(&game_id).is_some() {
|
||||||
|
state.config.save();
|
||||||
|
if state.current_game.as_ref().map(|g| g.id.as_str()) == Some(game_id.as_str()) {
|
||||||
|
state.background_image =
|
||||||
|
effective_background_bytes(&state.config, state.current_game.as_ref());
|
||||||
|
}
|
||||||
|
state.status_message = "Game background removed.".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
AppMessage::ToggleDrawerChat => {
|
AppMessage::ToggleDrawerChat => {
|
||||||
state.drawer_chat_open = !state.drawer_chat_open;
|
state.drawer_chat_open = !state.drawer_chat_open;
|
||||||
}
|
}
|
||||||
@@ -1622,7 +1902,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
// If we already have the bytes, save now; otherwise fetch from the
|
// If we already have the bytes, save now; otherwise fetch from the
|
||||||
// sender and save when AttachmentReady arrives (pending_saves).
|
// sender and save when AttachmentReady arrives (pending_saves).
|
||||||
if matches!(state.attachment_data.get(&id), Some(AttachmentState::Ready(_))) {
|
if matches!(state.attachment_data.get(&id), Some(AttachmentState::Ready(_))) {
|
||||||
save_attachment_to_disk(state, id);
|
return save_attachment_task(state, id);
|
||||||
} else if let Some((from, att)) = find_attachment_source(state, id) {
|
} else if let Some((from, att)) = find_attachment_source(state, id) {
|
||||||
if let Ok(eid) = from.parse::<EndpointId>() {
|
if let Ok(eid) = from.parse::<EndpointId>() {
|
||||||
state.pending_saves.insert(id);
|
state.pending_saves.insert(id);
|
||||||
@@ -1635,6 +1915,50 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppMessage::AttachmentSaved(msg) => {
|
||||||
|
if let Some(m) = msg {
|
||||||
|
state.status_message = m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppMessage::PlayAudio(id) => {
|
||||||
|
if matches!(state.attachment_data.get(&id), Some(AttachmentState::Ready(_))) {
|
||||||
|
play_ready_audio(state, id);
|
||||||
|
} else if let Some((from, att)) = find_attachment_source(state, id) {
|
||||||
|
if let Ok(eid) = from.parse::<EndpointId>() {
|
||||||
|
// Repeated clicks while the transfer is pending must not
|
||||||
|
// launch duplicate fetches.
|
||||||
|
if state.pending_plays.insert(id) {
|
||||||
|
state.status_message = format!("Loading {}…", att.name);
|
||||||
|
let _ = state
|
||||||
|
.controller
|
||||||
|
.send(CoreCommand::FetchAttachment { from: eid, attachment: att });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.status_message = "Can't play: unknown sender.".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppMessage::PauseAudio => state.clip_player.pause(),
|
||||||
|
AppMessage::ResumeAudio => state.clip_player.resume(),
|
||||||
|
AppMessage::SeekAudio(id, fraction) => {
|
||||||
|
let clip = status_snapshot(&state.clip_status);
|
||||||
|
if clip.playing_id == Some(id)
|
||||||
|
&& let Some(total) = clip.total
|
||||||
|
{
|
||||||
|
state.clip_player.seek(seek_target(fraction, total));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppMessage::AudioTick => {
|
||||||
|
let clip = status_snapshot(&state.clip_status);
|
||||||
|
if let Some(failure) = clip.failure {
|
||||||
|
if failure.invalid_audio {
|
||||||
|
state.invalid_audio.insert(failure.id);
|
||||||
|
}
|
||||||
|
state.pending_plays.remove(&failure.id);
|
||||||
|
state.status_message = format!("Audio playback failed: {}", failure.error);
|
||||||
|
state.clip_player.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
AppMessage::OpenUrl(url) => {
|
AppMessage::OpenUrl(url) => {
|
||||||
// Defence in depth: only ever hand http(s) URLs to the opener. The
|
// Defence in depth: only ever hand http(s) URLs to the opener. The
|
||||||
// link span's href came from `linkify`, which only emits http/https,
|
// link span's href came from `linkify`, which only emits http/https,
|
||||||
@@ -1885,14 +2209,36 @@ fn find_attachment_source(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write a ready attachment's bytes to a user-chosen location via a native save
|
/// Validate cached bytes and hand them to the independent clip player. A false
|
||||||
/// dialog. The default filename comes from the (already-sanitized) descriptor.
|
/// filename hint falls back to the generic file chip without reaching rodio.
|
||||||
/// No-op if the bytes aren't ready. Sync dialog: the brief block is acceptable
|
fn play_ready_audio(state: &mut AppState, id: crate::files::AttachmentId) {
|
||||||
/// for a deliberate save action.
|
|
||||||
fn save_attachment_to_disk(state: &mut AppState, id: crate::files::AttachmentId) {
|
|
||||||
let Some(AttachmentState::Ready(data)) = state.attachment_data.get(&id) else {
|
let Some(AttachmentState::Ready(data)) = state.attachment_data.get(&id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
if crate::files::is_probably_audio(data) {
|
||||||
|
state.invalid_audio.remove(&id);
|
||||||
|
state.clip_player.play(id, data.clone());
|
||||||
|
} else {
|
||||||
|
state.invalid_audio.insert(id);
|
||||||
|
state.status_message = "This attachment is not valid supported audio.".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a ready attachment's bytes to a user-chosen location via a native save
|
||||||
|
/// dialog. The default filename comes from the (already-sanitized) descriptor.
|
||||||
|
/// Build a Task that opens the native save dialog off the UI thread and writes
|
||||||
|
/// the (already-fetched) attachment bytes to the chosen path. No-op task if the
|
||||||
|
/// bytes aren't ready.
|
||||||
|
///
|
||||||
|
/// MUST be async (`rfd::AsyncFileDialog`): the blocking `rfd::FileDialog` spins
|
||||||
|
/// its own GTK loop, and invoking it from inside iced's running event loop with
|
||||||
|
/// the Linux `xdg-desktop-portal`/GTK backend wedges the dialog — Save/Cancel
|
||||||
|
/// stop responding. The file *picker* paths already use the async variant; this
|
||||||
|
/// is the one save path that must match.
|
||||||
|
fn save_attachment_task(state: &AppState, id: crate::files::AttachmentId) -> Task<AppMessage> {
|
||||||
|
let Some(AttachmentState::Ready(data)) = state.attachment_data.get(&id) else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
let data = data.clone();
|
let data = data.clone();
|
||||||
let default_name = state
|
let default_name = state
|
||||||
.chat_messages
|
.chat_messages
|
||||||
@@ -1904,16 +2250,23 @@ fn save_attachment_to_disk(state: &mut AppState, id: crate::files::AttachmentId)
|
|||||||
.map(|a| a.name.clone())
|
.map(|a| a.name.clone())
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| "download".to_string());
|
.unwrap_or_else(|| "download".to_string());
|
||||||
if let Some(path) = rfd::FileDialog::new()
|
Task::perform(
|
||||||
.set_file_name(default_name)
|
async move {
|
||||||
.set_title("Save attachment")
|
let handle = rfd::AsyncFileDialog::new()
|
||||||
.save_file()
|
.set_file_name(default_name)
|
||||||
{
|
.set_title("Save attachment")
|
||||||
match std::fs::write(&path, &data) {
|
.save_file()
|
||||||
Ok(()) => state.status_message = format!("Saved {}", path.display()),
|
.await;
|
||||||
Err(e) => state.status_message = format!("Save failed: {e}"),
|
match handle {
|
||||||
}
|
Some(h) => match std::fs::write(h.path(), &data) {
|
||||||
}
|
Ok(()) => Some(format!("Saved {}", h.path().display())),
|
||||||
|
Err(e) => Some(format!("Save failed: {e}")),
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AppMessage::AttachmentSaved,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn horizontal_space() -> iced::widget::Space {
|
fn horizontal_space() -> iced::widget::Space {
|
||||||
@@ -3088,6 +3441,126 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.spacing(10)
|
.spacing(10)
|
||||||
.width(iced::Length::Fill)
|
.width(iced::Length::Fill)
|
||||||
.into(),
|
.into(),
|
||||||
|
SettingsCategory::Games => {
|
||||||
|
// What's running right now (local detection), for context.
|
||||||
|
let detected_label = match &state.current_game {
|
||||||
|
Some(g) => match &g.name {
|
||||||
|
Some(name) => format!("Currently detected: {name}"),
|
||||||
|
None => "Currently detected: (a game, no name available)".to_string(),
|
||||||
|
},
|
||||||
|
None => "Currently detected: nothing".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Manual override picker: Auto / None, plus "Pin current" when a
|
||||||
|
// game is detected.
|
||||||
|
let mut override_opts = vec![GameOverrideChoice::Auto, GameOverrideChoice::None];
|
||||||
|
if state.current_game.is_some() {
|
||||||
|
override_opts.push(GameOverrideChoice::Current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-game background row for the running game (if any).
|
||||||
|
let game_bg_section: Element<'_, AppMessage> = match &state.current_game {
|
||||||
|
Some(g) => {
|
||||||
|
let has_bg = state.config.game_backgrounds.contains_key(&g.id);
|
||||||
|
let id_for_pick = g.id.clone();
|
||||||
|
let mut controls = row![
|
||||||
|
button(text("Set background for this game").size(13))
|
||||||
|
.on_press(AppMessage::PickGameBackground(id_for_pick)),
|
||||||
|
]
|
||||||
|
.spacing(8);
|
||||||
|
if has_bg {
|
||||||
|
let id_for_remove = g.id.clone();
|
||||||
|
controls = controls.push(
|
||||||
|
button(text("Remove").size(13))
|
||||||
|
.on_press(AppMessage::RemoveGameBackground(id_for_remove)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
controls.into()
|
||||||
|
}
|
||||||
|
None => text("Start a game to set its background.")
|
||||||
|
.size(12)
|
||||||
|
.color(color_subtext)
|
||||||
|
.into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// The list of configured per-game backgrounds (by stable id).
|
||||||
|
let mut bg_list = column![].spacing(4).width(iced::Length::Fill);
|
||||||
|
for id in state.config.game_backgrounds.keys() {
|
||||||
|
let id_owned = id.clone();
|
||||||
|
bg_list = bg_list.push(
|
||||||
|
row![
|
||||||
|
text(id.clone()).size(12).width(iced::Length::Fill),
|
||||||
|
button(text("Remove").size(12))
|
||||||
|
.on_press(AppMessage::RemoveGameBackground(id_owned)),
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.width(iced::Length::Fill),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The process→name mappings (non-Steam games), with an add form.
|
||||||
|
let mut map_list = column![].spacing(4).width(iced::Length::Fill);
|
||||||
|
for (exe, name) in &state.config.game_process_map {
|
||||||
|
let exe_owned = exe.clone();
|
||||||
|
map_list = map_list.push(
|
||||||
|
row![
|
||||||
|
text(format!("{exe} → {name}")).size(12).width(iced::Length::Fill),
|
||||||
|
button(text("Remove").size(12))
|
||||||
|
.on_press(AppMessage::RemoveGameMapping(exe_owned)),
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.width(iced::Length::Fill),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
column![
|
||||||
|
section_header("Game presence"),
|
||||||
|
column![
|
||||||
|
checkbox(state.config.game_presence_enabled)
|
||||||
|
.label("Show the game I'm playing to people in the call")
|
||||||
|
.on_toggle(AppMessage::ToggleGamePresence),
|
||||||
|
text("Off by default. When on, your detected game appears next to your avatar for everyone in the room.")
|
||||||
|
.size(11).color(color_subtext),
|
||||||
|
vertical_space(6.0),
|
||||||
|
text(detected_label).size(12).color(color_subtext),
|
||||||
|
row![
|
||||||
|
text("Override:").size(13),
|
||||||
|
pick_list(
|
||||||
|
override_opts,
|
||||||
|
Some(state.game_override),
|
||||||
|
AppMessage::GameOverrideSelected,
|
||||||
|
),
|
||||||
|
].spacing(8),
|
||||||
|
].spacing(6).width(iced::Length::Fill),
|
||||||
|
vertical_space(section_gap),
|
||||||
|
section_header("Per-game background"),
|
||||||
|
column![
|
||||||
|
text("Give a game its own UI background; it switches automatically while you play. Falls back to your custom background (Appearance) otherwise.")
|
||||||
|
.size(11).color(color_subtext),
|
||||||
|
game_bg_section,
|
||||||
|
bg_list,
|
||||||
|
].spacing(8).width(iced::Length::Fill),
|
||||||
|
vertical_space(section_gap),
|
||||||
|
section_header("Non-Steam games"),
|
||||||
|
column![
|
||||||
|
text("Steam games are detected automatically. For other launchers, map an executable name to a display name.")
|
||||||
|
.size(11).color(color_subtext),
|
||||||
|
row![
|
||||||
|
text_input("executable (e.g. hl2_linux)", &state.game_map_exe_input)
|
||||||
|
.on_input(AppMessage::GameMapExeChanged)
|
||||||
|
.width(iced::Length::Fill),
|
||||||
|
text_input("shown name (e.g. Half-Life 2)", &state.game_map_name_input)
|
||||||
|
.on_input(AppMessage::GameMapNameChanged)
|
||||||
|
.width(iced::Length::Fill),
|
||||||
|
button(text("Add").size(13)).on_press(AppMessage::AddGameMapping),
|
||||||
|
].spacing(8).width(iced::Length::Fill),
|
||||||
|
map_list,
|
||||||
|
].spacing(8).width(iced::Length::Fill),
|
||||||
|
]
|
||||||
|
.spacing(10)
|
||||||
|
.width(iced::Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let category_button = |category: SettingsCategory| -> Element<'_, AppMessage> {
|
let category_button = |category: SettingsCategory| -> Element<'_, AppMessage> {
|
||||||
@@ -3407,6 +3880,22 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
]
|
]
|
||||||
.spacing(10)
|
.spacing(10)
|
||||||
.align_y(iced::alignment::Vertical::Center),
|
.align_y(iced::alignment::Vertical::Center),
|
||||||
|
// Our own detected game (W17/W18). When we're broadcasting it, it
|
||||||
|
// matches what peers see; otherwise it's marked "not shared".
|
||||||
|
{
|
||||||
|
let el: Element<'_, AppMessage> =
|
||||||
|
match state.current_game.as_ref().and_then(|g| g.name.as_deref()) {
|
||||||
|
Some(name) if state.config.game_presence_enabled => {
|
||||||
|
text(format!("Playing {name}")).size(11).color(color_blue).into()
|
||||||
|
}
|
||||||
|
Some(name) => text(format!("Playing {name} (not shared)"))
|
||||||
|
.size(11)
|
||||||
|
.color(color_subtext)
|
||||||
|
.into(),
|
||||||
|
None => text("").into(),
|
||||||
|
};
|
||||||
|
el
|
||||||
|
},
|
||||||
// Live "you're sharing" badge — only present while sharing.
|
// Live "you're sharing" badge — only present while sharing.
|
||||||
{
|
{
|
||||||
let el: Element<'_, AppMessage> = if state.self_sharing {
|
let el: Element<'_, AppMessage> = if state.self_sharing {
|
||||||
@@ -3554,10 +4043,20 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
let mut card_content = column![
|
let mut card_content = column![
|
||||||
row![
|
row![
|
||||||
avatar_view(&peer.avatar, &peer.name, &peer_id.to_string(), 38.0),
|
avatar_view(&peer.avatar, &peer.name, &peer_id.to_string(), 38.0),
|
||||||
column![
|
{
|
||||||
text(&peer.name).size(16).color(color_text),
|
// Name + id, plus a "Playing <game>" line when the peer is
|
||||||
text(format!("ID: {}", short_id(&peer_id.to_string()))).size(11).color(color_subtext)
|
// broadcasting a game (game presence, W17).
|
||||||
],
|
let mut name_col = column![
|
||||||
|
text(&peer.name).size(16).color(color_text),
|
||||||
|
text(format!("ID: {}", short_id(&peer_id.to_string()))).size(11).color(color_subtext),
|
||||||
|
];
|
||||||
|
if let Some(game) = &peer.game {
|
||||||
|
name_col = name_col.push(
|
||||||
|
text(format!("Playing {game}")).size(11).color(color_blue),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
name_col
|
||||||
|
},
|
||||||
add_friend_el,
|
add_friend_el,
|
||||||
horizontal_space(),
|
horizontal_space(),
|
||||||
share_el,
|
share_el,
|
||||||
@@ -3833,6 +4332,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.color(color_subtext),
|
.color(color_subtext),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
let clip_status = status_snapshot(&state.clip_status);
|
||||||
for m in &state.chat_messages {
|
for m in &state.chat_messages {
|
||||||
let name_color = if m.mine { color_green } else { color_lavender };
|
let name_color = if m.mine { color_green } else { color_lavender };
|
||||||
// Split the (already-sanitized) message into text + URL spans so
|
// Split the (already-sanitized) message into text + URL spans so
|
||||||
@@ -3899,6 +4399,87 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.color(color_subtext)
|
.color(color_subtext)
|
||||||
.into(),
|
.into(),
|
||||||
}
|
}
|
||||||
|
} else if crate::files::looks_like_audio_name(&att.name)
|
||||||
|
&& !state.invalid_audio.contains(&att.id)
|
||||||
|
{
|
||||||
|
let active = clip_status.playing_id == Some(att.id);
|
||||||
|
let loading = state.pending_plays.contains(&att.id)
|
||||||
|
&& !matches!(data, Some(AttachmentState::Ready(_)));
|
||||||
|
let position = if active {
|
||||||
|
clip_status.position
|
||||||
|
} else {
|
||||||
|
std::time::Duration::ZERO
|
||||||
|
};
|
||||||
|
let total = active.then_some(clip_status.total).flatten();
|
||||||
|
let play_button = if loading {
|
||||||
|
button(text("Loading…").size(12))
|
||||||
|
} else if active && clip_status.paused {
|
||||||
|
button(text("Play").size(12)).on_press(AppMessage::ResumeAudio)
|
||||||
|
} else if active {
|
||||||
|
button(text("Pause").size(12)).on_press(AppMessage::PauseAudio)
|
||||||
|
} else {
|
||||||
|
button(text("Play").size(12))
|
||||||
|
.on_press(AppMessage::PlayAudio(att.id))
|
||||||
|
}
|
||||||
|
.style(b_style(
|
||||||
|
color_blue,
|
||||||
|
color_lavender,
|
||||||
|
color_crust,
|
||||||
|
6.0,
|
||||||
|
))
|
||||||
|
.padding(6);
|
||||||
|
let elapsed = format_clip_time(position);
|
||||||
|
let duration = total
|
||||||
|
.map(format_clip_time)
|
||||||
|
.unwrap_or_else(|| "--:--".to_string());
|
||||||
|
column![
|
||||||
|
row![
|
||||||
|
text(format!(
|
||||||
|
"{} ({})",
|
||||||
|
att.name,
|
||||||
|
crate::files::human_size(att.size)
|
||||||
|
))
|
||||||
|
.size(12)
|
||||||
|
.color(color_text),
|
||||||
|
button(text(if matches!(data, Some(AttachmentState::Ready(_))) {
|
||||||
|
"Save"
|
||||||
|
} else {
|
||||||
|
"Download"
|
||||||
|
})
|
||||||
|
.size(12))
|
||||||
|
.on_press(AppMessage::SaveAttachment(att.id))
|
||||||
|
.style(b_style(
|
||||||
|
color_surface,
|
||||||
|
color_overlay,
|
||||||
|
color_text,
|
||||||
|
6.0,
|
||||||
|
))
|
||||||
|
.padding(6),
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(iced::alignment::Vertical::Center),
|
||||||
|
row![
|
||||||
|
play_button,
|
||||||
|
slider(
|
||||||
|
0.0..=1.0,
|
||||||
|
if active {
|
||||||
|
clip_progress(position, total)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
},
|
||||||
|
move |fraction| AppMessage::SeekAudio(att.id, fraction),
|
||||||
|
)
|
||||||
|
.step(0.001)
|
||||||
|
.width(iced::Length::Fixed(180.0)),
|
||||||
|
text(format!("{elapsed} / {duration}"))
|
||||||
|
.size(11)
|
||||||
|
.color(color_subtext),
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(iced::alignment::Vertical::Center),
|
||||||
|
]
|
||||||
|
.spacing(4)
|
||||||
|
.into()
|
||||||
} else {
|
} else {
|
||||||
let ready =
|
let ready =
|
||||||
matches!(data, Some(AttachmentState::Ready(_)));
|
matches!(data, Some(AttachmentState::Ready(_)));
|
||||||
@@ -5214,10 +5795,82 @@ impl Program<AppMessage> for Icon {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
format_duration, initial_window_position, reconnect_attempt_chime, reconnected_chime,
|
format_duration, initial_window_position, reconnect_attempt_chime, reconnected_chime,
|
||||||
set_peer_gate_config, set_peer_volume_config, AppConfig, GateMeter, METER_MAX,
|
set_peer_gate_config, set_peer_volume_config, AppConfig, AppState, AttachmentState,
|
||||||
|
ChatEntry, GateMeter, METER_MAX,
|
||||||
};
|
};
|
||||||
use iroh::SecretKey;
|
use iroh::SecretKey;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_room_state_clears_all_room_scoped_state() {
|
||||||
|
let mut state = AppState::default();
|
||||||
|
let peer = SecretKey::generate().public();
|
||||||
|
let attachment_id = [9u8; 32];
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
|
||||||
|
state.peers.insert(peer, crate::network::PeerState {
|
||||||
|
name: "Peer".to_string(),
|
||||||
|
is_muted: false,
|
||||||
|
addr: iroh::EndpointAddr::from(peer),
|
||||||
|
sharing: None,
|
||||||
|
avatar: crate::avatar::Avatar::default(),
|
||||||
|
game: None,
|
||||||
|
});
|
||||||
|
state.audio_levels.insert(peer, 0.5);
|
||||||
|
state.locally_muted.insert(peer);
|
||||||
|
state.chat_messages.push(ChatEntry {
|
||||||
|
name: "Peer".to_string(),
|
||||||
|
text: "old room".to_string(),
|
||||||
|
mine: false,
|
||||||
|
from: Some(peer.to_string()),
|
||||||
|
attachment: None,
|
||||||
|
});
|
||||||
|
state.chat_input = "draft".to_string();
|
||||||
|
state.attachment_data.insert(attachment_id, AttachmentState::Ready(vec![1]));
|
||||||
|
state.image_handle_cache.insert(
|
||||||
|
attachment_id,
|
||||||
|
iced::widget::image::Handle::from_bytes(vec![1]),
|
||||||
|
);
|
||||||
|
state.pending_saves.insert(attachment_id);
|
||||||
|
state.pending_plays.insert(attachment_id);
|
||||||
|
state.invalid_audio.insert(attachment_id);
|
||||||
|
state.connecting.insert(peer);
|
||||||
|
state.ever_connected.insert(peer);
|
||||||
|
state.recording = true;
|
||||||
|
state.recording_started = Some(now);
|
||||||
|
state.call_started = Some(now);
|
||||||
|
state.mic_level = 0.75;
|
||||||
|
state.self_sharing = true;
|
||||||
|
state.clip_status.lock().unwrap().playing_id = Some(attachment_id);
|
||||||
|
|
||||||
|
state.reset_room_state();
|
||||||
|
|
||||||
|
assert!(state.peers.is_empty());
|
||||||
|
assert!(state.audio_levels.is_empty());
|
||||||
|
assert!(state.locally_muted.is_empty());
|
||||||
|
assert!(state.chat_messages.is_empty());
|
||||||
|
assert!(state.chat_input.is_empty());
|
||||||
|
assert!(state.attachment_data.is_empty());
|
||||||
|
assert!(state.image_handle_cache.is_empty());
|
||||||
|
assert!(state.pending_saves.is_empty());
|
||||||
|
assert!(state.pending_plays.is_empty());
|
||||||
|
assert!(state.invalid_audio.is_empty());
|
||||||
|
assert!(state.connecting.is_empty());
|
||||||
|
assert!(state.ever_connected.is_empty());
|
||||||
|
assert!(!state.recording);
|
||||||
|
assert!(state.recording_started.is_none());
|
||||||
|
assert!(state.call_started.is_none());
|
||||||
|
assert_eq!(state.mic_level, 0.0);
|
||||||
|
assert!(!state.self_sharing);
|
||||||
|
|
||||||
|
for _ in 0..50 {
|
||||||
|
if crate::audio::clip_player::status_snapshot(&state.clip_status).playing_id.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||||
|
}
|
||||||
|
panic!("clip player did not stop during room reset");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn peer_gate_persists_when_on_and_clears_when_off() {
|
fn peer_gate_persists_when_on_and_clears_when_off() {
|
||||||
let mut config = AppConfig::default();
|
let mut config = AppConfig::default();
|
||||||
@@ -5314,10 +5967,14 @@ mod tests {
|
|||||||
let labels: Vec<_> = SettingsCategory::ALL.iter().map(|c| c.label()).collect();
|
let labels: Vec<_> = SettingsCategory::ALL.iter().map(|c| c.label()).collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
labels,
|
labels,
|
||||||
vec!["Audio", "Hotkeys", "Recording", "Profile", "Appearance", "Network", "Notifications"]
|
vec![
|
||||||
|
"Audio", "Hotkeys", "Recording", "Profile", "Appearance", "Network",
|
||||||
|
"Notifications", "Games"
|
||||||
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(SettingsCategory::Audio.hint(), "Devices, mic gate, echo");
|
assert_eq!(SettingsCategory::Audio.hint(), "Devices, mic gate, echo");
|
||||||
assert_eq!(SettingsCategory::Profile.hint(), "Avatar and identity");
|
assert_eq!(SettingsCategory::Profile.hint(), "Avatar and identity");
|
||||||
|
assert_eq!(SettingsCategory::Games.hint(), "Detection, presence, backgrounds");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
//! Independent playback engine for inline chat audio attachments.
|
||||||
|
//!
|
||||||
|
//! The rodio device sink stays on a dedicated OS thread and never enters iced
|
||||||
|
//! state or the call-audio pipeline. The GUI sends small commands and reads a
|
||||||
|
//! shared status snapshot at its redraw cadence.
|
||||||
|
|
||||||
|
use crate::files::AttachmentId;
|
||||||
|
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source, decoder::DecoderError};
|
||||||
|
use std::io::Cursor;
|
||||||
|
use std::sync::{Arc, Mutex, mpsc};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// State published by the playback thread for the GUI.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct ClipStatus {
|
||||||
|
pub playing_id: Option<AttachmentId>,
|
||||||
|
pub position: Duration,
|
||||||
|
pub total: Option<Duration>,
|
||||||
|
pub paused: bool,
|
||||||
|
/// Set when output initialization or decoding rejects the requested clip.
|
||||||
|
/// The app consumes this as a signal to fall back to the normal file chip.
|
||||||
|
pub failure: Option<ClipFailure>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ClipFailure {
|
||||||
|
pub id: AttachmentId,
|
||||||
|
pub error: String,
|
||||||
|
/// Decoder rejection means the filename hint should fall back to a file
|
||||||
|
/// chip. Output-device failures remain retryable as audio.
|
||||||
|
pub invalid_audio: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SharedClipStatus = Arc<Mutex<ClipStatus>>;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum ClipCommand {
|
||||||
|
Play(AttachmentId, Vec<u8>),
|
||||||
|
Pause,
|
||||||
|
Resume,
|
||||||
|
Seek(Duration),
|
||||||
|
Stop,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cheap, `Send` command handle for the dedicated playback thread.
|
||||||
|
pub struct ClipPlayer {
|
||||||
|
command_tx: mpsc::Sender<ClipCommand>,
|
||||||
|
status: SharedClipStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClipPlayer {
|
||||||
|
/// Start the playback worker. The system output device is opened lazily on
|
||||||
|
/// first Play, so merely launching PeerSpeak never claims another stream.
|
||||||
|
pub fn new() -> (Self, SharedClipStatus) {
|
||||||
|
let (command_tx, command_rx) = mpsc::channel();
|
||||||
|
let status = Arc::new(Mutex::new(ClipStatus::default()));
|
||||||
|
let worker_status = Arc::clone(&status);
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("peerspeak-clip-player".to_string())
|
||||||
|
.spawn(move || playback_worker(command_rx, worker_status))
|
||||||
|
.expect("failed to spawn clip playback thread");
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
command_tx,
|
||||||
|
status: Arc::clone(&status),
|
||||||
|
},
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn play(&self, id: AttachmentId, bytes: Vec<u8>) {
|
||||||
|
update_status(&self.status, |status| {
|
||||||
|
status.playing_id = Some(id);
|
||||||
|
status.position = Duration::ZERO;
|
||||||
|
status.total = None;
|
||||||
|
status.paused = false;
|
||||||
|
status.failure = None;
|
||||||
|
});
|
||||||
|
let _ = self.command_tx.send(ClipCommand::Play(id, bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pause(&self) {
|
||||||
|
let _ = self.command_tx.send(ClipCommand::Pause);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resume(&self) {
|
||||||
|
let _ = self.command_tx.send(ClipCommand::Resume);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn seek(&self, position: Duration) {
|
||||||
|
let _ = self.command_tx.send(ClipCommand::Seek(position));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&self) {
|
||||||
|
let _ = self.command_tx.send(ClipCommand::Stop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn playback_worker(command_rx: mpsc::Receiver<ClipCommand>, status: SharedClipStatus) {
|
||||||
|
let mut output: Option<MixerDeviceSink> = None;
|
||||||
|
let mut player: Option<Player> = None;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match command_rx.recv_timeout(Duration::from_millis(100)) {
|
||||||
|
Ok(ClipCommand::Play(id, bytes)) => {
|
||||||
|
// In-memory readers do not expose file metadata to rodio. Pass
|
||||||
|
// the known attachment length explicitly so formats without a
|
||||||
|
// duration in their headers (notably MP3 and Vorbis) can derive
|
||||||
|
// a total duration and support reliable seeking.
|
||||||
|
let source = match decode_clip(bytes) {
|
||||||
|
Ok(source) => source,
|
||||||
|
Err(error) => {
|
||||||
|
fail(
|
||||||
|
&status,
|
||||||
|
id,
|
||||||
|
format!("unsupported or invalid audio: {error}"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let total = source.total_duration();
|
||||||
|
|
||||||
|
if output.is_none() {
|
||||||
|
match DeviceSinkBuilder::open_default_sink() {
|
||||||
|
Ok(sink) => {
|
||||||
|
player = Some(Player::connect_new(sink.mixer()));
|
||||||
|
output = Some(sink);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
fail(
|
||||||
|
&status,
|
||||||
|
id,
|
||||||
|
format!("audio output unavailable: {error}"),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(player) = player.as_ref() {
|
||||||
|
player.clear();
|
||||||
|
player.append(source);
|
||||||
|
player.play();
|
||||||
|
update_status(&status, |s| {
|
||||||
|
s.playing_id = Some(id);
|
||||||
|
s.position = Duration::ZERO;
|
||||||
|
s.total = total;
|
||||||
|
s.paused = false;
|
||||||
|
s.failure = None;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ClipCommand::Pause) => {
|
||||||
|
if let Some(player) = player.as_ref() {
|
||||||
|
player.pause();
|
||||||
|
update_status(&status, |s| s.paused = true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ClipCommand::Resume) => {
|
||||||
|
if let Some(player) = player.as_ref() {
|
||||||
|
player.play();
|
||||||
|
update_status(&status, |s| s.paused = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ClipCommand::Seek(position)) => {
|
||||||
|
if let Some(player) = player.as_ref()
|
||||||
|
&& player.try_seek(position).is_ok()
|
||||||
|
{
|
||||||
|
update_status(&status, |s| s.position = position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ClipCommand::Stop) => {
|
||||||
|
if let Some(player) = player.as_ref() {
|
||||||
|
player.clear();
|
||||||
|
}
|
||||||
|
reset(&status);
|
||||||
|
}
|
||||||
|
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||||
|
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(player) = player.as_ref() {
|
||||||
|
let (active, failed) = status
|
||||||
|
.lock()
|
||||||
|
.map(|s| (s.playing_id.is_some(), s.failure.is_some()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
if active && !failed && player.empty() {
|
||||||
|
reset(&status);
|
||||||
|
} else if active && !failed {
|
||||||
|
update_status(&status, |s| {
|
||||||
|
s.position = player.get_pos();
|
||||||
|
s.paused = player.is_paused();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_clip(bytes: Vec<u8>) -> Result<Decoder<Cursor<Vec<u8>>>, DecoderError> {
|
||||||
|
let byte_len = bytes.len() as u64;
|
||||||
|
Decoder::builder()
|
||||||
|
.with_data(Cursor::new(bytes))
|
||||||
|
.with_byte_len(byte_len)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail(status: &SharedClipStatus, id: AttachmentId, error: String, invalid_audio: bool) {
|
||||||
|
crate::log_msg(&format!("Inline audio playback failed: {error}"));
|
||||||
|
update_status(status, |s| {
|
||||||
|
// Keep the id active until the GUI observes the failure on its next
|
||||||
|
// tick. This guarantees the active-only timer cannot disappear in the
|
||||||
|
// small window between sending Play and decoder/output failure.
|
||||||
|
s.playing_id = Some(id);
|
||||||
|
s.position = Duration::ZERO;
|
||||||
|
s.total = None;
|
||||||
|
s.paused = false;
|
||||||
|
s.failure = Some(ClipFailure {
|
||||||
|
id,
|
||||||
|
error,
|
||||||
|
invalid_audio,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(status: &SharedClipStatus) {
|
||||||
|
update_status(status, |s| *s = ClipStatus::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_status(status: &SharedClipStatus, update: impl FnOnce(&mut ClipStatus)) {
|
||||||
|
if let Ok(mut status) = status.lock() {
|
||||||
|
update(&mut status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status_snapshot(status: &SharedClipStatus) -> ClipStatus {
|
||||||
|
status.lock().map(|s| s.clone()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format clip time as `mm:ss` (hours are folded into minutes).
|
||||||
|
pub fn format_time(duration: Duration) -> String {
|
||||||
|
let seconds = duration.as_secs();
|
||||||
|
format!("{}:{:02}", seconds / 60, seconds % 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Playback progress in `0.0..=1.0`; unknown and zero durations report zero.
|
||||||
|
pub fn progress(position: Duration, total: Option<Duration>) -> f32 {
|
||||||
|
let Some(total) = total.filter(|duration| !duration.is_zero()) else {
|
||||||
|
return 0.0;
|
||||||
|
};
|
||||||
|
(position.as_secs_f64() / total.as_secs_f64()).clamp(0.0, 1.0) as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a slider fraction into a clamped position within a clip.
|
||||||
|
pub fn seek_target(fraction: f32, total: Duration) -> Duration {
|
||||||
|
total.mul_f64(f64::from(fraction.clamp(0.0, 1.0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn in_memory_mp3_reports_duration() {
|
||||||
|
// One headerless constant-bitrate MP3 frame repeated to model files
|
||||||
|
// that do not carry an Xing/VBR duration header.
|
||||||
|
let frame = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode("//sQxAAABIQVWVRggDCqCKiDNlAAAAGgS4BgAmTT2AQAABCxOD5d7gQOfqBAEHS4Ph/EAIRI7//0A0KBNpABgMRIDCSI04PcIFdF0PJKFgzlUf5eAoF8BRIPfh4FTvUDQl+dUi5pc0w=")
|
||||||
|
.expect("valid test fixture");
|
||||||
|
let bytes = frame.repeat(20);
|
||||||
|
|
||||||
|
let decoder = decode_clip(bytes).expect("CBR MP3 should decode");
|
||||||
|
assert!(decoder.total_duration().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formats_clip_time() {
|
||||||
|
assert_eq!(format_time(Duration::ZERO), "0:00");
|
||||||
|
assert_eq!(format_time(Duration::from_secs(65)), "1:05");
|
||||||
|
assert_eq!(format_time(Duration::from_secs(3_661)), "61:01");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_handles_unknown_zero_and_clamps() {
|
||||||
|
assert_eq!(progress(Duration::from_secs(1), None), 0.0);
|
||||||
|
assert_eq!(progress(Duration::from_secs(1), Some(Duration::ZERO)), 0.0);
|
||||||
|
assert_eq!(
|
||||||
|
progress(Duration::from_secs(5), Some(Duration::from_secs(10))),
|
||||||
|
0.5
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
progress(Duration::from_secs(20), Some(Duration::from_secs(10))),
|
||||||
|
1.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seek_target_clamps_fraction() {
|
||||||
|
let total = Duration::from_secs(100);
|
||||||
|
assert_eq!(seek_target(0.25, total), Duration::from_secs(25));
|
||||||
|
assert_eq!(seek_target(-1.0, total), Duration::ZERO);
|
||||||
|
assert_eq!(seek_target(2.0, total), total);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,18 @@ const NODE_READY_TIMEOUT: Duration = Duration::from_secs(3);
|
|||||||
/// nodes never leak past the call that created them.
|
/// nodes never leak past the call that created them.
|
||||||
pub struct EchoCancelGuard {
|
pub struct EchoCancelGuard {
|
||||||
module_index: String,
|
module_index: String,
|
||||||
|
source_name: String,
|
||||||
|
sink_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EchoCancelGuard {
|
||||||
|
pub fn source_name(&self) -> &str {
|
||||||
|
&self.source_name
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sink_name(&self) -> &str {
|
||||||
|
&self.sink_name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for EchoCancelGuard {
|
impl Drop for EchoCancelGuard {
|
||||||
@@ -58,12 +70,16 @@ pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result<Echo
|
|||||||
// don't stack duplicate modules / fight over the virtual node names.
|
// don't stack duplicate modules / fight over the virtual node names.
|
||||||
unload_stale();
|
unload_stale();
|
||||||
|
|
||||||
|
let owner_pid = std::process::id();
|
||||||
|
let source_name = format!("{EC_SOURCE}.{owner_pid}");
|
||||||
|
let sink_name = format!("{EC_SINK}.{owner_pid}");
|
||||||
|
|
||||||
let mut cmd = Command::new("pactl");
|
let mut cmd = Command::new("pactl");
|
||||||
cmd.arg("load-module")
|
cmd.arg("load-module")
|
||||||
.arg("module-echo-cancel")
|
.arg("module-echo-cancel")
|
||||||
.arg("aec_method=webrtc")
|
.arg("aec_method=webrtc")
|
||||||
.arg(format!("source_name={EC_SOURCE}"))
|
.arg(format!("source_name={source_name}"))
|
||||||
.arg(format!("sink_name={EC_SINK}"));
|
.arg(format!("sink_name={sink_name}"));
|
||||||
if let Some(src) = real_source.filter(|s| !s.is_empty()) {
|
if let Some(src) = real_source.filter(|s| !s.is_empty()) {
|
||||||
cmd.arg(format!("source_master={src}"));
|
cmd.arg(format!("source_master={src}"));
|
||||||
}
|
}
|
||||||
@@ -85,12 +101,12 @@ pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result<Echo
|
|||||||
if module_index.parse::<u64>().is_err() {
|
if module_index.parse::<u64>().is_err() {
|
||||||
return Err(format!("unexpected pactl output: {module_index:?}"));
|
return Err(format!("unexpected pactl output: {module_index:?}"));
|
||||||
}
|
}
|
||||||
let guard = EchoCancelGuard { module_index };
|
let guard = EchoCancelGuard { module_index, source_name, sink_name };
|
||||||
|
|
||||||
// The virtual nodes appear shortly after the module loads; wait for both so
|
// The virtual nodes appear shortly after the module loads; wait for both so
|
||||||
// the subsequent capture/playback streams can actually target them. If they
|
// the subsequent capture/playback streams can actually target them. If they
|
||||||
// never show, drop the guard (unloads) and report failure.
|
// never show, drop the guard (unloads) and report failure.
|
||||||
if !wait_for_nodes() {
|
if !wait_for_nodes(guard.source_name(), guard.sink_name()) {
|
||||||
return Err("echo-cancel virtual nodes did not appear in time".to_string());
|
return Err("echo-cancel virtual nodes did not appear in time".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,10 +118,10 @@ pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result<Echo
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Polls until both virtual nodes exist or the timeout elapses.
|
/// Polls until both virtual nodes exist or the timeout elapses.
|
||||||
fn wait_for_nodes() -> bool {
|
fn wait_for_nodes(source_name: &str, sink_name: &str) -> bool {
|
||||||
let deadline = Instant::now() + NODE_READY_TIMEOUT;
|
let deadline = Instant::now() + NODE_READY_TIMEOUT;
|
||||||
loop {
|
loop {
|
||||||
if node_present("sources", EC_SOURCE) && node_present("sinks", EC_SINK) {
|
if node_present("sources", source_name) && node_present("sinks", sink_name) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if Instant::now() >= deadline {
|
if Instant::now() >= deadline {
|
||||||
@@ -126,8 +142,30 @@ fn node_present(kind: &str, name: &str) -> bool {
|
|||||||
.any(|line| line.split('\t').nth(1) == Some(name))
|
.any(|line| line.split('\t').nth(1) == Some(name))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unloads any leftover `module-echo-cancel` instance we previously created
|
fn pid_from_ec_args(args: &str) -> Option<u32> {
|
||||||
/// (identified by our virtual node names in its argument string). Best-effort.
|
let source_prefix = format!("source_name={EC_SOURCE}.");
|
||||||
|
args.split_whitespace()
|
||||||
|
.find_map(|arg| arg.strip_prefix(&source_prefix))?
|
||||||
|
.parse()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ec_module_is_stale(args: &str, is_alive: impl Fn(u32) -> bool) -> bool {
|
||||||
|
pid_from_ec_args(args).is_some_and(|pid| !is_alive(pid))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn process_is_alive(pid: u32) -> bool {
|
||||||
|
std::path::Path::new("/proc").join(pid.to_string()).exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
fn process_is_alive(_pid: u32) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unloads leftover PeerSpeak `module-echo-cancel` instances only when their
|
||||||
|
/// owning process is gone. Best-effort and conservative on non-Linux platforms.
|
||||||
fn unload_stale() {
|
fn unload_stale() {
|
||||||
let Ok(out) = Command::new("pactl").arg("list").arg("modules").arg("short").output() else {
|
let Ok(out) = Command::new("pactl").arg("list").arg("modules").arg("short").output() else {
|
||||||
return;
|
return;
|
||||||
@@ -137,7 +175,10 @@ fn unload_stale() {
|
|||||||
let index = cols.next().unwrap_or("");
|
let index = cols.next().unwrap_or("");
|
||||||
let name = cols.next().unwrap_or("");
|
let name = cols.next().unwrap_or("");
|
||||||
let args = cols.next().unwrap_or("");
|
let args = cols.next().unwrap_or("");
|
||||||
if name == "module-echo-cancel" && args.contains(EC_SOURCE) && index.parse::<u64>().is_ok() {
|
if name == "module-echo-cancel"
|
||||||
|
&& ec_module_is_stale(args, process_is_alive)
|
||||||
|
&& index.parse::<u64>().is_ok()
|
||||||
|
{
|
||||||
let _ = Command::new("pactl").arg("unload-module").arg(index).output();
|
let _ = Command::new("pactl").arg("unload-module").arg(index).output();
|
||||||
crate::log_msg(&format!("Echo cancel: cleaned up stale module {index}"));
|
crate::log_msg(&format!("Echo cancel: cleaned up stale module {index}"));
|
||||||
}
|
}
|
||||||
@@ -155,12 +196,42 @@ mod tests {
|
|||||||
#[ignore]
|
#[ignore]
|
||||||
fn enable_creates_and_unloads_nodes() {
|
fn enable_creates_and_unloads_nodes() {
|
||||||
let guard = enable(None, None).expect("module-echo-cancel should load");
|
let guard = enable(None, None).expect("module-echo-cancel should load");
|
||||||
assert!(node_present("sources", EC_SOURCE), "cleaned source must exist");
|
let source_name = guard.source_name().to_string();
|
||||||
assert!(node_present("sinks", EC_SINK), "reference sink must exist");
|
let sink_name = guard.sink_name().to_string();
|
||||||
|
assert!(node_present("sources", &source_name), "cleaned source must exist");
|
||||||
|
assert!(node_present("sinks", &sink_name), "reference sink must exist");
|
||||||
drop(guard);
|
drop(guard);
|
||||||
// Give pactl a moment to tear the nodes down.
|
// Give pactl a moment to tear the nodes down.
|
||||||
std::thread::sleep(Duration::from_millis(300));
|
std::thread::sleep(Duration::from_millis(300));
|
||||||
assert!(!node_present("sources", EC_SOURCE), "source must be gone after unload");
|
assert!(!node_present("sources", &source_name), "source must be gone after unload");
|
||||||
assert!(!node_present("sinks", EC_SINK), "sink must be gone after unload");
|
assert!(!node_present("sinks", &sink_name), "sink must be gone after unload");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_owner_pid_only_from_our_source_name() {
|
||||||
|
assert_eq!(
|
||||||
|
pid_from_ec_args(
|
||||||
|
"aec_method=webrtc source_name=peerspeak_echocancel_source.4242 sink_name=peerspeak_echocancel_sink.4242"
|
||||||
|
),
|
||||||
|
Some(4242)
|
||||||
|
);
|
||||||
|
assert_eq!(pid_from_ec_args("aec_method=webrtc"), None);
|
||||||
|
assert_eq!(
|
||||||
|
pid_from_ec_args("source_name=peerspeak_echocancel_source.not-a-pid"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(pid_from_ec_args("source_name=someone_elses_source.4242"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_decision_keeps_live_and_foreign_modules() {
|
||||||
|
let ours = "source_name=peerspeak_echocancel_source.4242";
|
||||||
|
assert!(!ec_module_is_stale(ours, |pid| pid == 4242));
|
||||||
|
assert!(ec_module_is_stale(ours, |_| false));
|
||||||
|
assert!(!ec_module_is_stale("source_name=foreign.4242", |_| false));
|
||||||
|
assert!(!ec_module_is_stale(
|
||||||
|
"source_name=peerspeak_echocancel_source.malformed",
|
||||||
|
|_| false
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ pub trait AudioBackend: Send + Sync {
|
|||||||
fn stop(&self) -> Result<(), AudioError>;
|
fn stop(&self) -> Result<(), AudioError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub mod clip_player;
|
||||||
pub mod eq;
|
pub mod eq;
|
||||||
pub mod gate;
|
pub mod gate;
|
||||||
pub mod limiter;
|
pub mod limiter;
|
||||||
|
|||||||
@@ -29,6 +29,32 @@ const SILENCE_CHUNK: usize = FRAME_SAMPLES * 256;
|
|||||||
/// can drift if the capture clock runs ahead of the mixer cycle; past it the
|
/// can drift if the capture clock runs ahead of the mixer cycle; past it the
|
||||||
/// oldest mic audio is dropped. Mirrors `recorder::MAX_MIC_FIFO`.
|
/// oldest mic audio is dropped. Mirrors `recorder::MAX_MIC_FIFO`.
|
||||||
const MAX_MIC_FIFO: usize = 48_000 / 5;
|
const MAX_MIC_FIFO: usize = 48_000 / 5;
|
||||||
|
const MAX_SESSION_DIR_ATTEMPTS: usize = 1_000;
|
||||||
|
|
||||||
|
/// Create a collision-free session directory for a timestamp. The base
|
||||||
|
/// timestamp is tried first, followed by `-2`, `-3`, and so on; an existing
|
||||||
|
/// recording is never reopened or overwritten.
|
||||||
|
pub fn create_session_dir(base: &Path, now_unix_secs: u64) -> io::Result<PathBuf> {
|
||||||
|
let filename = crate::audio::recorder::timestamp_filename(now_unix_secs);
|
||||||
|
let stem = filename.trim_end_matches(".wav");
|
||||||
|
for attempt in 1..=MAX_SESSION_DIR_ATTEMPTS {
|
||||||
|
let name = if attempt == 1 {
|
||||||
|
stem.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{stem}-{attempt}")
|
||||||
|
};
|
||||||
|
let path = base.join(name);
|
||||||
|
match std::fs::create_dir(&path) {
|
||||||
|
Ok(()) => return Ok(path),
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(io::Error::new(
|
||||||
|
io::ErrorKind::AlreadyExists,
|
||||||
|
"multitrack directory suffixes exhausted",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// One output track: its WAV writer plus whether it has been written *this*
|
/// One output track: its WAV writer plus whether it has been written *this*
|
||||||
/// cycle (so `end_cycle` knows which tracks to pad with silence).
|
/// cycle (so `end_cycle` knows which tracks to pad with silence).
|
||||||
@@ -263,6 +289,19 @@ mod tests {
|
|||||||
assert_eq!(track_filename("!!!", &id), format!("peer-{short}.wav"));
|
assert_eq!(track_filename("!!!", &id), format!("peer-{short}.wav"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_second_sessions_get_unique_directories_without_reuse() {
|
||||||
|
let base = tmpdir("collision");
|
||||||
|
let first = create_session_dir(&base, 1_700_000_000).unwrap();
|
||||||
|
std::fs::write(first.join("sentinel"), b"keep me").unwrap();
|
||||||
|
|
||||||
|
let second = create_session_dir(&base, 1_700_000_000).unwrap();
|
||||||
|
|
||||||
|
assert_ne!(second, first);
|
||||||
|
assert_eq!(std::fs::read(first.join("sentinel")).unwrap(), b"keep me");
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn all_tracks_equal_length_after_n_cycles() {
|
fn all_tracks_equal_length_after_n_cycles() {
|
||||||
let dir = tmpdir("equal");
|
let dir = tmpdir("equal");
|
||||||
|
|||||||
@@ -151,11 +151,9 @@ fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_n
|
|||||||
let data = &mut datas[0];
|
let data = &mut datas[0];
|
||||||
let size = data.chunk().size() as usize;
|
let size = data.chunk().size() as usize;
|
||||||
if let Some(slice) = data.data() {
|
if let Some(slice) = data.data() {
|
||||||
// Each sample is 2 bytes (S16LE)
|
for_each_capture_sample(slice, size, |sample| {
|
||||||
for chunk in slice[..size].chunks_exact(2) {
|
|
||||||
let sample = i16::from_le_bytes([chunk[0], chunk[1]]);
|
|
||||||
let _ = user_data.producer.try_push(sample);
|
let _ = user_data.producer.try_push(sample);
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,6 +222,16 @@ fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_n
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Visit the complete S16LE samples in the portion PipeWire reports as filled.
|
||||||
|
/// Clamp the reported byte count to the mapped slice before indexing: a bad
|
||||||
|
/// chunk size must not panic from the realtime capture callback.
|
||||||
|
fn for_each_capture_sample(slice: &[u8], size: usize, mut visit: impl FnMut(i16)) {
|
||||||
|
let size = size.min(slice.len());
|
||||||
|
for chunk in slice[..size].chunks_exact(2) {
|
||||||
|
visit(i16::from_le_bytes([chunk[0], chunk[1]]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Frames the playback RT callback should produce this cycle.
|
/// Frames the playback RT callback should produce this cycle.
|
||||||
///
|
///
|
||||||
/// `requested` is the graph's per-cycle quantum from `Buffer::requested()` (0 if
|
/// `requested` is the graph's per-cycle quantum from `Buffer::requested()` (0 if
|
||||||
@@ -263,6 +271,25 @@ fn drain_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reserve exact occupancy before making a frame visible to the consumer.
|
||||||
|
/// `after_reserve` is empty in production and lets the regression test force a
|
||||||
|
/// consumer interleaving at the critical ordering boundary.
|
||||||
|
fn publish_frame<P: Producer<Item = i16>>(
|
||||||
|
fill: &AtomicUsize,
|
||||||
|
dropped: &AtomicU64,
|
||||||
|
producer: &mut P,
|
||||||
|
frame: &[i16],
|
||||||
|
after_reserve: impl FnOnce(),
|
||||||
|
) {
|
||||||
|
fill.fetch_add(frame.len(), Ordering::Relaxed);
|
||||||
|
after_reserve();
|
||||||
|
let pushed = producer.push_slice(frame);
|
||||||
|
if pushed != frame.len() {
|
||||||
|
fill.fetch_sub(frame.len() - pushed, Ordering::Relaxed);
|
||||||
|
dropped.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn frames_to_produce(requested: usize, mapped_frames: usize) -> usize {
|
fn frames_to_produce(requested: usize, mapped_frames: usize) -> usize {
|
||||||
/// Safe per-cycle fallback when the graph doesn't report a quantum.
|
/// Safe per-cycle fallback when the graph doesn't report a quantum.
|
||||||
const FALLBACK_FRAMES: usize = 1024;
|
const FALLBACK_FRAMES: usize = 1024;
|
||||||
@@ -522,10 +549,12 @@ fn run_playback(
|
|||||||
worker_dropped.fetch_add(1, Ordering::Relaxed);
|
worker_dropped.fetch_add(1, Ordering::Relaxed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for &sample in &frame {
|
// Reserve occupancy BEFORE publishing samples. Otherwise the RT
|
||||||
let _ = producer.try_push(sample);
|
// consumer can pop a newly-visible sample before it is counted and
|
||||||
}
|
// wrap the exact fill gauge to usize::MAX, wedging mixer pacing.
|
||||||
worker_fill.fetch_add(frame.len(), Ordering::Relaxed);
|
// `push_slice` also publishes the frame as one operation rather than
|
||||||
|
// exposing a half-written stereo pair.
|
||||||
|
publish_frame(&worker_fill, &worker_dropped, &mut producer, &frame, || {});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -577,8 +606,9 @@ fn run_playback(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{drain_loop, frames_to_produce};
|
use super::{drain_loop, for_each_capture_sample, frames_to_produce, publish_frame};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use ringbuf::{HeapRb, traits::{Consumer, Producer, Split}};
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use std::{sync::mpsc, thread};
|
use std::{sync::mpsc, thread};
|
||||||
@@ -614,6 +644,39 @@ mod tests {
|
|||||||
assert_eq!(frames_to_produce(1024, 0), 0);
|
assert_eq!(frames_to_produce(1024, 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capture_size_larger_than_mapping_is_clamped() {
|
||||||
|
let mut samples = Vec::new();
|
||||||
|
for_each_capture_sample(&[1, 0, 2, 0, 3], usize::MAX, |sample| {
|
||||||
|
samples.push(sample)
|
||||||
|
});
|
||||||
|
assert_eq!(samples, vec![1, 2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn occupancy_is_reserved_before_frame_is_published() {
|
||||||
|
let rb = HeapRb::<i16>::new(8);
|
||||||
|
let (mut producer, mut consumer) = rb.split();
|
||||||
|
assert!(producer.try_push(7).is_ok());
|
||||||
|
|
||||||
|
let fill = AtomicUsize::new(1);
|
||||||
|
let dropped = AtomicU64::new(0);
|
||||||
|
publish_frame(&fill, &dropped, &mut producer, &[10, 11], || {
|
||||||
|
// Force the consumer to drain the old sample after the new frame's
|
||||||
|
// occupancy is reserved but before that frame is published.
|
||||||
|
assert_eq!(consumer.try_pop(), Some(7));
|
||||||
|
assert_eq!(fill.fetch_sub(1, Ordering::Relaxed), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(fill.load(Ordering::Relaxed), 2);
|
||||||
|
assert_eq!(consumer.try_pop(), Some(10));
|
||||||
|
assert_eq!(fill.fetch_sub(1, Ordering::Relaxed), 2);
|
||||||
|
assert_eq!(consumer.try_pop(), Some(11));
|
||||||
|
assert_eq!(fill.fetch_sub(1, Ordering::Relaxed), 1);
|
||||||
|
assert_eq!(fill.load(Ordering::Relaxed), 0);
|
||||||
|
assert_eq!(dropped.load(Ordering::Relaxed), 0);
|
||||||
|
}
|
||||||
|
|
||||||
// --- drain_loop (A7: worker must not hang shutdown) ---
|
// --- drain_loop (A7: worker must not hang shutdown) ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
//! and patches the two size fields on [`Recorder::finalize`].
|
//! and patches the two size fields on [`Recorder::finalize`].
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::fs::File;
|
use std::fs::{File, OpenOptions};
|
||||||
use std::io::{self, Seek, SeekFrom, Write};
|
use std::io::{self, Seek, SeekFrom, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ const BITS_PER_SAMPLE: u16 = 16;
|
|||||||
const CHANNELS: u16 = 1;
|
const CHANNELS: u16 = 1;
|
||||||
const RIFF_DATA_OVERHEAD: u64 = 36;
|
const RIFF_DATA_OVERHEAD: u64 = 36;
|
||||||
const MAX_RIFF_DATA_BYTES: u64 = u32::MAX as u64 - RIFF_DATA_OVERHEAD;
|
const MAX_RIFF_DATA_BYTES: u64 = u32::MAX as u64 - RIFF_DATA_OVERHEAD;
|
||||||
|
const MAX_NAME_ATTEMPTS: usize = 1_000;
|
||||||
|
|
||||||
/// Cap on buffered mic samples (~200ms). Bounds how far recording lag can drift
|
/// Cap on buffered mic samples (~200ms). Bounds how far recording lag can drift
|
||||||
/// if the capture clock runs persistently faster than playout — past this we drop
|
/// if the capture clock runs persistently faster than playout — past this we drop
|
||||||
@@ -42,7 +43,12 @@ pub struct WavWriter {
|
|||||||
impl WavWriter {
|
impl WavWriter {
|
||||||
/// Create the file and write the 44-byte header with zeroed size fields.
|
/// Create the file and write the 44-byte header with zeroed size fields.
|
||||||
pub fn new(path: &Path) -> io::Result<Self> {
|
pub fn new(path: &Path) -> io::Result<Self> {
|
||||||
let mut file = File::create(path)?;
|
Self::from_file(File::create(path)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a WAV in an already-opened file. This lets callers choose atomic
|
||||||
|
/// create-new semantics instead of the truncating behavior of `File::create`.
|
||||||
|
fn from_file(mut file: File) -> io::Result<Self> {
|
||||||
file.write_all(&Self::header(0))?;
|
file.write_all(&Self::header(0))?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
file,
|
file,
|
||||||
@@ -125,13 +131,31 @@ impl Recorder {
|
|||||||
/// Create a recording at `dir/<timestamped>.wav`. The directory is assumed to
|
/// Create a recording at `dir/<timestamped>.wav`. The directory is assumed to
|
||||||
/// exist (the caller creates it).
|
/// exist (the caller creates it).
|
||||||
pub fn create(dir: &Path, now_unix_secs: u64) -> io::Result<Self> {
|
pub fn create(dir: &Path, now_unix_secs: u64) -> io::Result<Self> {
|
||||||
let path = dir.join(timestamp_filename(now_unix_secs));
|
let filename = timestamp_filename(now_unix_secs);
|
||||||
let writer = WavWriter::new(&path)?;
|
let stem = filename.trim_end_matches(".wav");
|
||||||
Ok(Self {
|
for attempt in 1..=MAX_NAME_ATTEMPTS {
|
||||||
writer,
|
let name = if attempt == 1 {
|
||||||
mic_fifo: VecDeque::new(),
|
filename.clone()
|
||||||
path,
|
} else {
|
||||||
})
|
format!("{stem}-{attempt}.wav")
|
||||||
|
};
|
||||||
|
let path = dir.join(name);
|
||||||
|
match OpenOptions::new().write(true).create_new(true).open(&path) {
|
||||||
|
Ok(file) => {
|
||||||
|
return Ok(Self {
|
||||||
|
writer: WavWriter::from_file(file)?,
|
||||||
|
mic_fifo: VecDeque::new(),
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(io::Error::new(
|
||||||
|
io::ErrorKind::AlreadyExists,
|
||||||
|
"recording filename suffixes exhausted",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The path being written.
|
/// The path being written.
|
||||||
@@ -210,6 +234,30 @@ mod tests {
|
|||||||
assert_eq!(timestamp_filename(0), "peerspeak-1970-01-01_000000.wav");
|
assert_eq!(timestamp_filename(0), "peerspeak-1970-01-01_000000.wav");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_second_recordings_get_unique_files_without_truncation() {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"peerspeak-collision-{}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
|
||||||
|
let mut first = Recorder::create(&dir, 1_700_000_000).unwrap();
|
||||||
|
first.write_frame(&[123, 456]).unwrap();
|
||||||
|
let first_path = first.path().to_path_buf();
|
||||||
|
first.finalize().unwrap();
|
||||||
|
let original = std::fs::read(&first_path).unwrap();
|
||||||
|
|
||||||
|
let second = Recorder::create(&dir, 1_700_000_000).unwrap();
|
||||||
|
let second_path = second.path().to_path_buf();
|
||||||
|
assert_ne!(second_path, first_path);
|
||||||
|
assert_eq!(std::fs::read(&first_path).unwrap(), original);
|
||||||
|
second.finalize().unwrap();
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn wav_header_round_trips_sizes() {
|
fn wav_header_round_trips_sizes() {
|
||||||
let dir = std::env::temp_dir();
|
let dir = std::env::temp_dir();
|
||||||
|
|||||||
@@ -45,6 +45,22 @@ pub fn process_background(raw: &[u8]) -> Result<Vec<u8>, String> {
|
|||||||
Ok(png.into_inner())
|
Ok(png.into_inner())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A filesystem-safe, app-owned filename for the processed PNG of a per-game
|
||||||
|
/// background (W18), derived from the game's stable id by hashing rather than
|
||||||
|
/// embedding the raw id: keeps the name short and safe (ids contain `:` and
|
||||||
|
/// arbitrary executable basenames) and avoids leaking the id into the filesystem.
|
||||||
|
/// Deterministic and dependency-free (FNV-1a 64-bit), so the same game id always
|
||||||
|
/// maps to the same file.
|
||||||
|
pub fn game_background_filename(game_id: &str) -> String {
|
||||||
|
// FNV-1a, 64-bit.
|
||||||
|
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||||
|
for b in game_id.as_bytes() {
|
||||||
|
hash ^= *b as u64;
|
||||||
|
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||||
|
}
|
||||||
|
format!("game-bg-{hash:016x}.png")
|
||||||
|
}
|
||||||
|
|
||||||
/// The legibility scrim drawn between the background image and the UI: the active
|
/// The legibility scrim drawn between the background image and the UI: the active
|
||||||
/// theme's base colour at `dim` alpha (clamped to `0.0..=1.0`). A higher `dim`
|
/// theme's base colour at `dim` alpha (clamped to `0.0..=1.0`). A higher `dim`
|
||||||
/// recedes the image so body text and panel chrome stay readable, and it re-tints
|
/// recedes the image so body text and panel chrome stay readable, and it re-tints
|
||||||
@@ -90,6 +106,17 @@ mod tests {
|
|||||||
assert!(process_background(b"definitely not an image").is_err());
|
assert!(process_background(b"definitely not an image").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_background_filename_is_stable_safe_and_distinct() {
|
||||||
|
let a = game_background_filename("steam:730");
|
||||||
|
// Stable for the same id.
|
||||||
|
assert_eq!(a, game_background_filename("steam:730"));
|
||||||
|
// Distinct ids → distinct files (no `:` or path chars leak through).
|
||||||
|
assert_ne!(a, game_background_filename("exe:hl2_linux"));
|
||||||
|
assert!(a.starts_with("game-bg-") && a.ends_with(".png"));
|
||||||
|
assert!(!a.contains(':') && !a.contains('/') && !a.contains('\\'));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scrim_color_sets_alpha_and_keeps_rgb() {
|
fn scrim_color_sets_alpha_and_keeps_rgb() {
|
||||||
let base = Color::from_rgb(0.1, 0.2, 0.3);
|
let base = Color::from_rgb(0.1, 0.2, 0.3);
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
addr: endpoint_a.addr(),
|
addr: endpoint_a.addr(),
|
||||||
sharing: None,
|
sharing: None,
|
||||||
avatar: Default::default(),
|
avatar: Default::default(),
|
||||||
|
game: None,
|
||||||
};
|
};
|
||||||
room_a.join(&ticket_str, state_a, vec![]).await?;
|
room_a.join(&ticket_str, state_a, vec![]).await?;
|
||||||
println!("Node A joined topic.");
|
println!("Node A joined topic.");
|
||||||
@@ -83,6 +84,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
addr: endpoint_b.addr(),
|
addr: endpoint_b.addr(),
|
||||||
sharing: None,
|
sharing: None,
|
||||||
avatar: Default::default(),
|
avatar: Default::default(),
|
||||||
|
game: None,
|
||||||
};
|
};
|
||||||
room_b.join(&ticket_str, state_b, vec![]).await?;
|
room_b.join(&ticket_str, state_b, vec![]).await?;
|
||||||
println!("Node B joined topic.");
|
println!("Node B joined topic.");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::notify::Sound;
|
use crate::notify::Sound;
|
||||||
use crate::theme::AppTheme;
|
use crate::theme::AppTheme;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
@@ -199,6 +199,26 @@ pub struct AppConfig {
|
|||||||
/// `crate::background::scrim_color`.
|
/// `crate::background::scrim_color`.
|
||||||
#[serde(default = "default_background_dim")]
|
#[serde(default = "default_background_dim")]
|
||||||
pub background_dim: f32,
|
pub background_dim: f32,
|
||||||
|
/// Broadcast the detected game as presence next to our avatar (game-detection
|
||||||
|
/// feature). **Opt-in, default OFF.** Enabling immediately publishes the
|
||||||
|
/// current game; disabling immediately publishes `game: None`. Toggling this
|
||||||
|
/// is the only thing that puts our game on the wire — detection itself (for the
|
||||||
|
/// local background) runs regardless.
|
||||||
|
#[serde(default)]
|
||||||
|
pub game_presence_enabled: bool,
|
||||||
|
/// Per-game UI background overrides (W18), keyed by stable game id
|
||||||
|
/// (`steam:730`, `exe:hl2_linux`) → path to the processed PNG we wrote in the
|
||||||
|
/// config dir (see `game_background_path`). The running game's entry wins; with
|
||||||
|
/// no entry we fall back to the single custom `background`. Local-only; never
|
||||||
|
/// sent to peers. `BTreeMap` for deterministic serialization.
|
||||||
|
#[serde(default)]
|
||||||
|
pub game_backgrounds: BTreeMap<String, String>,
|
||||||
|
/// User process→display-name mappings for non-Steam game detection, keyed by
|
||||||
|
/// normalized executable basename (`hl2_linux`) → the name to show/broadcast
|
||||||
|
/// (`Half-Life 2`). Only exact mappings here are ever matched (we never guess a
|
||||||
|
/// game from an arbitrary process). Local-only.
|
||||||
|
#[serde(default)]
|
||||||
|
pub game_process_map: BTreeMap<String, String>,
|
||||||
/// What a call recording captures (mixed / per-peer stems / both).
|
/// What a call recording captures (mixed / per-peer stems / both).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub recording_mode: RecordingMode,
|
pub recording_mode: RecordingMode,
|
||||||
@@ -305,6 +325,9 @@ impl Default for AppConfig {
|
|||||||
avatar: crate::avatar::Avatar::default(),
|
avatar: crate::avatar::Avatar::default(),
|
||||||
background: None,
|
background: None,
|
||||||
background_dim: default_background_dim(),
|
background_dim: default_background_dim(),
|
||||||
|
game_presence_enabled: false,
|
||||||
|
game_backgrounds: BTreeMap::new(),
|
||||||
|
game_process_map: BTreeMap::new(),
|
||||||
recording_mode: RecordingMode::default(),
|
recording_mode: RecordingMode::default(),
|
||||||
custom_sound_self_join: None,
|
custom_sound_self_join: None,
|
||||||
custom_sound_peer_join: None,
|
custom_sound_peer_join: None,
|
||||||
@@ -375,17 +398,31 @@ impl AppConfig {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Path the processed custom-background PNG (W16) is written to, alongside
|
/// Path to a processed-background PNG of the given filename, alongside
|
||||||
/// `config.json` in the app config dir. We store our own downscaled copy here
|
/// `config.json` in the app config dir. We store our own downscaled copies here
|
||||||
/// (rather than base64 in the config) so the JSON stays small.
|
/// (rather than base64 in the config) so the JSON stays small. Used for both
|
||||||
pub fn background_path() -> Option<PathBuf> {
|
/// the single custom background and the per-game backgrounds.
|
||||||
|
fn background_dir_path(filename: &str) -> Option<PathBuf> {
|
||||||
dirs::config_dir().map(|mut p| {
|
dirs::config_dir().map(|mut p| {
|
||||||
p.push("peerspeak");
|
p.push("peerspeak");
|
||||||
p.push("background.png");
|
p.push(filename);
|
||||||
p
|
p
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Path the single custom-background PNG (W16) is written to.
|
||||||
|
pub fn background_path() -> Option<PathBuf> {
|
||||||
|
Self::background_dir_path("background.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path the processed per-game background PNG (W18) for `game_id` is written
|
||||||
|
/// to. The filename is an app-owned hash of the id (see
|
||||||
|
/// `crate::background::game_background_filename`), so raw game ids never appear
|
||||||
|
/// on disk and the name is always filesystem-safe.
|
||||||
|
pub fn game_background_path(game_id: &str) -> Option<PathBuf> {
|
||||||
|
Self::background_dir_path(&crate::background::game_background_filename(game_id))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn load() -> Self {
|
pub fn load() -> Self {
|
||||||
if let Some(path) = Self::config_path()
|
if let Some(path) = Self::config_path()
|
||||||
&& let Ok(contents) = fs::read_to_string(&path)
|
&& let Ok(contents) = fs::read_to_string(&path)
|
||||||
@@ -480,6 +517,51 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_backward_compat_game_detection_fields() {
|
||||||
|
// A config that predates the game-detection feature (W18) — and crucially
|
||||||
|
// still carries the W16 single `background` as a plain string — must
|
||||||
|
// deserialize without error. `AppConfig::load()` silently replaces ANY
|
||||||
|
// deserialize failure with full defaults, so a broken migration here would
|
||||||
|
// wipe everyone's settings; this guards that the additive fields kept the
|
||||||
|
// old shape loadable and that `background` was NOT retyped.
|
||||||
|
let legacy_json = r#"{
|
||||||
|
"input_device": "",
|
||||||
|
"output_device": "",
|
||||||
|
"noise_gate_threshold": 0.01,
|
||||||
|
"username": "Eric",
|
||||||
|
"background": "/home/eric/.config/peerspeak/background.png",
|
||||||
|
"background_dim": 0.4
|
||||||
|
}"#;
|
||||||
|
let cfg: AppConfig = serde_json::from_str(legacy_json).unwrap();
|
||||||
|
// The pre-existing single background survives untouched (still Option<String>).
|
||||||
|
assert_eq!(cfg.background.as_deref(), Some("/home/eric/.config/peerspeak/background.png"));
|
||||||
|
assert!((cfg.background_dim - 0.4).abs() < f32::EPSILON);
|
||||||
|
// The new game-detection fields default to off/empty → silent, opt-in upgrade.
|
||||||
|
assert!(!cfg.game_presence_enabled);
|
||||||
|
assert!(cfg.game_backgrounds.is_empty());
|
||||||
|
assert!(cfg.game_process_map.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_game_maps_serialize_deterministically() {
|
||||||
|
// BTreeMap ordering makes the serialized config stable across runs.
|
||||||
|
let mut cfg = AppConfig::default();
|
||||||
|
cfg.game_backgrounds.insert("steam:730".into(), "/a.png".into());
|
||||||
|
cfg.game_backgrounds.insert("exe:hl2_linux".into(), "/b.png".into());
|
||||||
|
cfg.game_process_map.insert("hl2_linux".into(), "Half-Life 2".into());
|
||||||
|
let json = serde_json::to_string(&cfg).unwrap();
|
||||||
|
// Keys appear in sorted order (exe: before steam:).
|
||||||
|
let bg = json.find("game_backgrounds").unwrap();
|
||||||
|
let exe_at = json[bg..].find("exe:hl2_linux").unwrap();
|
||||||
|
let steam_at = json[bg..].find("steam:730").unwrap();
|
||||||
|
assert!(exe_at < steam_at, "BTreeMap keys must serialize sorted");
|
||||||
|
// Full round-trip preserves the maps.
|
||||||
|
let back: AppConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back.game_backgrounds, cfg.game_backgrounds);
|
||||||
|
assert_eq!(back.game_process_map, cfg.game_process_map);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_window_size_fields() {
|
fn test_window_size_fields() {
|
||||||
// Default impl is the standard launch size.
|
// Default impl is the standard launch size.
|
||||||
|
|||||||
@@ -89,12 +89,26 @@ pub enum CoreCommand {
|
|||||||
/// invisible) and the outbound ping scheduler (invisible = fully dark). Sent at
|
/// invisible) and the outbound ping scheduler (invisible = fully dark). Sent at
|
||||||
/// startup from config and whenever the user changes it.
|
/// startup from config and whenever the user changes it.
|
||||||
SetPresenceMode(PresenceMode),
|
SetPresenceMode(PresenceMode),
|
||||||
|
/// Toggle broadcasting the detected game as presence (game detection). Opt-in,
|
||||||
|
/// default OFF. Enabling immediately publishes the current game; disabling
|
||||||
|
/// immediately publishes `game: None`. Detection for the local background runs
|
||||||
|
/// regardless. Sent at startup from config and on user toggle.
|
||||||
|
SetGamePresenceEnabled(bool),
|
||||||
|
/// Set the manual game-detection override (`Auto` / `None` / a forced game).
|
||||||
|
/// Forwarded to the detector and applied immediately (bypasses debounce).
|
||||||
|
SetGameOverride(crate::game::ManualOverride),
|
||||||
|
/// Replace the user process→display-name mappings used by the non-Steam
|
||||||
|
/// detection fallback. Sent at startup from config and after Settings edits.
|
||||||
|
SetGameProcessMap(std::collections::BTreeMap<String, String>),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum UiEvent {
|
pub enum UiEvent {
|
||||||
RoomJoined { ticket: String, self_id: String },
|
RoomJoined { ticket: String, self_id: String },
|
||||||
RoomLeft,
|
RoomLeft,
|
||||||
|
/// Clear room-scoped UI state after a failed in-call room switch, without a
|
||||||
|
/// leave chime. The persistent identity remains unchanged.
|
||||||
|
RoomReset,
|
||||||
PeerJoined { id: EndpointId, state: PeerState },
|
PeerJoined { id: EndpointId, state: PeerState },
|
||||||
PeerLeft { id: EndpointId },
|
PeerLeft { id: EndpointId },
|
||||||
/// The fixed reconnect grace expired and bounded background gossip recovery
|
/// The fixed reconnect grace expired and bounded background gossip recovery
|
||||||
@@ -150,6 +164,12 @@ pub enum UiEvent {
|
|||||||
/// failure, this carries the previous truthful mode. The GUI must mirror +
|
/// failure, this carries the previous truthful mode. The GUI must mirror +
|
||||||
/// persist this so its presence picker matches the endpoint's discovery state.
|
/// persist this so its presence picker matches the endpoint's discovery state.
|
||||||
PresenceModeReverted { mode: PresenceMode },
|
PresenceModeReverted { mode: PresenceMode },
|
||||||
|
/// The locally-detected running game changed (game detection). Carries the
|
||||||
|
/// debounced `DetectedGame` (id + display name + source) or `None` when nothing
|
||||||
|
/// is detected. The GUI uses the stable `id` to switch the per-game background
|
||||||
|
/// (W18) and may show a local "Playing …" indicator. Emitted regardless of
|
||||||
|
/// whether game presence is being broadcast — the broadcast is core's own job.
|
||||||
|
GameChanged(Option<crate::game::DetectedGame>),
|
||||||
/// Core finished orderly app shutdown and the GUI can exit.
|
/// Core finished orderly app shutdown and the GUI can exit.
|
||||||
ShutdownComplete,
|
ShutdownComplete,
|
||||||
Error(String),
|
Error(String),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use crate::audio::eq::{Eq, EqSettings};
|
|||||||
use crate::codec::{AudioEncoder, opus_impl::OpusEncoder};
|
use crate::codec::{AudioEncoder, opus_impl::OpusEncoder};
|
||||||
use crate::core::jitter::{JitterBuffer, FRAME_SAMPLES};
|
use crate::core::jitter::{JitterBuffer, FRAME_SAMPLES};
|
||||||
use crate::network::{
|
use crate::network::{
|
||||||
NetworkTransport, RoomState, PeerState, RoomEvent, ConnEvent, PeerSpeakTicket,
|
NetworkTransport, RoomState, SelfPresence, RoomEvent, ConnEvent, PeerSpeakTicket,
|
||||||
iroh_impl::{IrohTransport, AudioRouter, FileRouter},
|
iroh_impl::{IrohTransport, AudioRouter, FileRouter},
|
||||||
gossip::IrohGossipState,
|
gossip::IrohGossipState,
|
||||||
};
|
};
|
||||||
@@ -80,6 +80,33 @@ fn audio_datagram_len_ok(len: usize) -> bool {
|
|||||||
(4..=4 + MAX_OPUS_PAYLOAD).contains(&len)
|
(4..=4 + MAX_OPUS_PAYLOAD).contains(&len)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The presence label to broadcast for a detected game: its display name,
|
||||||
|
/// sanitized + length-capped, or `None` when there's no game or no broadcastable
|
||||||
|
/// name (a Steam appid without a manifest name, or a label that sanitizes empty).
|
||||||
|
/// Sanitizing here as well as at the gossip ingest boundary keeps the outgoing
|
||||||
|
/// value clean even though every peer re-sanitizes on receipt.
|
||||||
|
fn game_presence_label(game: Option<&crate::game::DetectedGame>) -> Option<String> {
|
||||||
|
game.and_then(|g| g.name.as_deref())
|
||||||
|
.map(crate::sanitize::sanitize_game_label)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait for the next game update. A closed sender permanently disables the
|
||||||
|
/// source by clearing the receiver; subsequent calls remain pending instead of
|
||||||
|
/// leaving an always-ready closed branch in the core `select!` loop.
|
||||||
|
async fn next_game_change(
|
||||||
|
game_rx: &mut Option<tokio::sync::watch::Receiver<Option<crate::game::DetectedGame>>>,
|
||||||
|
) -> Option<Option<crate::game::DetectedGame>> {
|
||||||
|
let Some(rx) = game_rx.as_mut() else {
|
||||||
|
return std::future::pending().await;
|
||||||
|
};
|
||||||
|
if rx.changed().await.is_err() {
|
||||||
|
*game_rx = None;
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(rx.borrow_and_update().clone())
|
||||||
|
}
|
||||||
|
|
||||||
fn arm_discovery_retry(
|
fn arm_discovery_retry(
|
||||||
discovery_deadline: &mut Option<tokio::time::Instant>,
|
discovery_deadline: &mut Option<tokio::time::Instant>,
|
||||||
now: tokio::time::Instant,
|
now: tokio::time::Instant,
|
||||||
@@ -105,14 +132,14 @@ type GraceTimers = Arc<std::sync::Mutex<HashMap<EndpointId, tokio::task::JoinHan
|
|||||||
type SeenConnected = Arc<std::sync::Mutex<HashSet<EndpointId>>>;
|
type SeenConnected = Arc<std::sync::Mutex<HashSet<EndpointId>>>;
|
||||||
|
|
||||||
type KnownPeers =
|
type KnownPeers =
|
||||||
Arc<std::sync::Mutex<HashMap<String, HashMap<EndpointId, EndpointAddr>>>>;
|
Arc<std::sync::Mutex<HashMap<[u8; 32], HashMap<EndpointId, EndpointAddr>>>>;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct RecoveryContext {
|
struct RecoveryContext {
|
||||||
coordinator: RecoveryCoordinator,
|
coordinator: RecoveryCoordinator,
|
||||||
room_state: Arc<IrohGossipState>,
|
room_state: Arc<IrohGossipState>,
|
||||||
known_peers: KnownPeers,
|
known_peers: KnownPeers,
|
||||||
ticket: String,
|
topic_id: [u8; 32],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RecoveryContext {
|
impl RecoveryContext {
|
||||||
@@ -120,7 +147,7 @@ impl RecoveryContext {
|
|||||||
self.known_peers
|
self.known_peers
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.get(&self.ticket)
|
.get(&self.topic_id)
|
||||||
.and_then(|peers| peers.get(peer_id))
|
.and_then(|peers| peers.get(peer_id))
|
||||||
.cloned()
|
.cloned()
|
||||||
}
|
}
|
||||||
@@ -130,7 +157,7 @@ impl RecoveryContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn forget(&self, peer_id: EndpointId) {
|
fn forget(&self, peer_id: EndpointId) {
|
||||||
if let Some(peers) = self.known_peers.lock().unwrap().get_mut(&self.ticket) {
|
if let Some(peers) = self.known_peers.lock().unwrap().get_mut(&self.topic_id) {
|
||||||
peers.remove(&peer_id);
|
peers.remove(&peer_id);
|
||||||
}
|
}
|
||||||
self.coordinator.cancel(peer_id);
|
self.coordinator.cancel(peer_id);
|
||||||
@@ -917,10 +944,38 @@ async fn run_core_loop(
|
|||||||
let peer_gate = Arc::new(Mutex::new(HashMap::<EndpointId, f32>::new()));
|
let peer_gate = Arc::new(Mutex::new(HashMap::<EndpointId, f32>::new()));
|
||||||
// Peers locally muted by us: decoded for level metering but not mixed.
|
// Peers locally muted by us: decoded for level metering but not mixed.
|
||||||
let locally_muted = Arc::new(Mutex::new(HashSet::<EndpointId>::new()));
|
let locally_muted = Arc::new(Mutex::new(HashSet::<EndpointId>::new()));
|
||||||
let mut current_name = "Anonymous".to_string();
|
// Sticky identity fields of our own presence (display name + W4 avatar), set on
|
||||||
// Our chosen avatar (W4), set on Join and changeable via SetAvatar; included
|
// Join and changed via SetName/SetAvatar. Combined with the volatile per-announce
|
||||||
// in every self-state we announce over presence.
|
// fields (mute/addr/share ticket) by `SelfPresence::to_state` — the single place
|
||||||
let mut current_avatar = crate::avatar::Avatar::default();
|
// our `PeerState` is built. Defaults match the prior `current_name`/`current_avatar`.
|
||||||
|
let mut presence = SelfPresence {
|
||||||
|
name: "Anonymous".to_string(),
|
||||||
|
avatar: crate::avatar::Avatar::default(),
|
||||||
|
game: None,
|
||||||
|
};
|
||||||
|
// Game detection (W17/W18): a background worker polls Steam state + the process
|
||||||
|
// list and publishes the debounced running game on a watch channel. Detection
|
||||||
|
// runs continuously (the GUI uses it for the local per-game background); whether
|
||||||
|
// the game is *broadcast* as presence is gated by `game_presence_enabled` (opt-in,
|
||||||
|
// seeded false; the GUI sends `SetGamePresenceEnabled` from config at startup).
|
||||||
|
// The override + process map start at their defaults and are set via commands.
|
||||||
|
let (game_detector, mut game_rx) = match crate::game::detector::GameDetector::spawn(
|
||||||
|
crate::game::ManualOverride::Auto,
|
||||||
|
std::collections::BTreeMap::new(),
|
||||||
|
) {
|
||||||
|
Ok(detector) => {
|
||||||
|
let rx = detector.subscribe();
|
||||||
|
(Some(detector), Some(rx))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
crate::log_msg(&format!("game detector unavailable: {e}"));
|
||||||
|
(None, None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut game_presence_enabled = false;
|
||||||
|
// The latest debounced detection, kept regardless of the broadcast toggle so a
|
||||||
|
// later opt-in can immediately publish whatever is currently running.
|
||||||
|
let mut current_game: Option<crate::game::DetectedGame> = None;
|
||||||
let mut network_mode = NetworkMode::default();
|
let mut network_mode = NetworkMode::default();
|
||||||
// Pixelpass binary override (config), and the ticket of our own active screen
|
// Pixelpass binary override (config), and the ticket of our own active screen
|
||||||
// share (rides our presence so the room — incl. late joiners — can watch).
|
// share (rides our presence so the room — incl. late joiners — can watch).
|
||||||
@@ -1027,6 +1082,31 @@ async fn run_core_loop(
|
|||||||
Some(cmd) => cmd,
|
Some(cmd) => cmd,
|
||||||
None => break,
|
None => break,
|
||||||
},
|
},
|
||||||
|
game_change = next_game_change(&mut game_rx) => {
|
||||||
|
// The detector worker published a new debounced game (or `None`).
|
||||||
|
let Some(detected) = game_change else {
|
||||||
|
// Worker gone unexpectedly. The helper fused this source, so
|
||||||
|
// this logs once and the closed channel cannot spin select!.
|
||||||
|
crate::log_msg("game detector stopped; disabling game detection");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
current_game = detected.clone();
|
||||||
|
// Always tell the GUI for the local per-game background + indicator.
|
||||||
|
let _ = ui_tx.send(UiEvent::GameChanged(detected.clone())).await;
|
||||||
|
// Broadcast as presence only when opted in; re-announce if in a room.
|
||||||
|
if game_presence_enabled {
|
||||||
|
presence.game = game_presence_label(detected.as_ref());
|
||||||
|
if let Some(session) = &active_session {
|
||||||
|
let self_state = presence.to_state(
|
||||||
|
is_muted.load(Ordering::Relaxed),
|
||||||
|
net.endpoint.addr(),
|
||||||
|
current_sharing.clone(),
|
||||||
|
);
|
||||||
|
let _ = session.room_state.update_self_state(self_state).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
_ = ping_interval.tick() => {
|
_ = ping_interval.tick() => {
|
||||||
// Fully dark while Invisible (the user's choice): don't even probe,
|
// Fully dark while Invisible (the user's choice): don't even probe,
|
||||||
// so nothing we do touches a friend's machine. Otherwise refresh in a
|
// so nothing we do touches a friend's machine. Otherwise refresh in a
|
||||||
@@ -1125,8 +1205,9 @@ async fn run_core_loop(
|
|||||||
}
|
}
|
||||||
|
|
||||||
CoreCommand::Join { name, ticket, room_name, input_device, output_device, echo_cancellation, avatar } => {
|
CoreCommand::Join { name, ticket, room_name, input_device, output_device, echo_cancellation, avatar } => {
|
||||||
current_name = name.clone();
|
presence.name = name.clone();
|
||||||
current_avatar = avatar;
|
presence.avatar = avatar;
|
||||||
|
let was_in_room = active_session.is_some();
|
||||||
|
|
||||||
// Finalize any recording before tearing down the old session — its
|
// Finalize any recording before tearing down the old session — its
|
||||||
// capture/mixer feeders are about to stop.
|
// capture/mixer feeders are about to stop.
|
||||||
@@ -1139,6 +1220,7 @@ async fn run_core_loop(
|
|||||||
session.shutdown(audio_backend.clone()).await;
|
session.shutdown(audio_backend.clone()).await;
|
||||||
net.audio_router.clear();
|
net.audio_router.clear();
|
||||||
net.file_router.clear();
|
net.file_router.clear();
|
||||||
|
*current_room.lock().unwrap() = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If a network-mode / identity change was deferred while a call was
|
// If a network-mode / identity change was deferred while a call was
|
||||||
@@ -1188,6 +1270,17 @@ async fn run_core_loop(
|
|||||||
));
|
));
|
||||||
ticket_str
|
ticket_str
|
||||||
};
|
};
|
||||||
|
let topic_id = match PeerSpeakTicket::topic_of(&ticket_str) {
|
||||||
|
Some(topic_id) => topic_id,
|
||||||
|
None => {
|
||||||
|
crate::log_msg("Error invalid room ticket");
|
||||||
|
if was_in_room {
|
||||||
|
let _ = ui_tx.send(UiEvent::RoomReset).await;
|
||||||
|
}
|
||||||
|
let _ = ui_tx.send(UiEvent::Error("invalid room ticket".to_string())).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Per-session transport over the persistent endpoint, bound to the
|
// Per-session transport over the persistent endpoint, bound to the
|
||||||
// persistent audio router so this call's inbound audio links route
|
// persistent audio router so this call's inbound audio links route
|
||||||
@@ -1206,22 +1299,20 @@ async fn run_core_loop(
|
|||||||
|
|
||||||
// Fresh join starts not sharing; clear any stale share ticket.
|
// Fresh join starts not sharing; clear any stale share ticket.
|
||||||
current_sharing = None;
|
current_sharing = None;
|
||||||
let self_state = PeerState {
|
let self_state = presence.to_state(
|
||||||
name: current_name.clone(),
|
is_muted.load(Ordering::Relaxed),
|
||||||
is_muted: is_muted.load(Ordering::Relaxed),
|
endpoint.addr(),
|
||||||
addr: endpoint.addr(),
|
None,
|
||||||
sharing: None,
|
);
|
||||||
avatar: current_avatar.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Snapshot THIS room's retained peers (by ticket) as extra bootstrap
|
// Snapshot THIS room's retained peers (by topic) as extra bootstrap
|
||||||
// targets so a rejoin can dial them (A8) — including after a detour
|
// targets so a rejoin can dial them (A8) — including after a detour
|
||||||
// through another room, since the per-ticket archive isn't cleared.
|
// through another room, since the per-topic archive isn't cleared.
|
||||||
// Resolution rides the persistent address book.
|
// Resolution rides the persistent address book.
|
||||||
let extra_bootstrap: Vec<EndpointAddr> = known_peers
|
let extra_bootstrap: Vec<EndpointAddr> = known_peers
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.get(&ticket_str)
|
.get(&topic_id)
|
||||||
.map(|peers| peers.values().cloned().collect())
|
.map(|peers| peers.values().cloned().collect())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
@@ -1238,6 +1329,9 @@ async fn run_core_loop(
|
|||||||
));
|
));
|
||||||
if let Err(e) = room_state.join(&ticket_str, self_state.clone(), extra_bootstrap).await {
|
if let Err(e) = room_state.join(&ticket_str, self_state.clone(), extra_bootstrap).await {
|
||||||
crate::log_msg(&format!("Error room_state.join failed: {:?}", e));
|
crate::log_msg(&format!("Error room_state.join failed: {:?}", e));
|
||||||
|
if was_in_room {
|
||||||
|
let _ = ui_tx.send(UiEvent::RoomReset).await;
|
||||||
|
}
|
||||||
let _ = ui_tx.send(UiEvent::Error(format!("Failed to join room: {}", e))).await;
|
let _ = ui_tx.send(UiEvent::Error(format!("Failed to join room: {}", e))).await;
|
||||||
net.audio_router.clear();
|
net.audio_router.clear();
|
||||||
net.file_router.clear();
|
net.file_router.clear();
|
||||||
@@ -1264,12 +1358,11 @@ async fn run_core_loop(
|
|||||||
output_device.as_deref(),
|
output_device.as_deref(),
|
||||||
) {
|
) {
|
||||||
Ok(guard) => {
|
Ok(guard) => {
|
||||||
|
let source_name = guard.source_name().to_string();
|
||||||
|
let sink_name = guard.sink_name().to_string();
|
||||||
echo_cancel_guard = Some(guard);
|
echo_cancel_guard = Some(guard);
|
||||||
crate::log_msg("Echo cancellation enabled");
|
crate::log_msg("Echo cancellation enabled");
|
||||||
(
|
(Some(source_name), Some(sink_name))
|
||||||
Some(crate::audio::echo_cancel::EC_SOURCE.to_string()),
|
|
||||||
Some(crate::audio::echo_cancel::EC_SINK.to_string()),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
crate::log_msg(&format!(
|
crate::log_msg(&format!(
|
||||||
@@ -1290,6 +1383,9 @@ async fn run_core_loop(
|
|||||||
let (capture_target, playback_target) = (input_device.clone(), output_device.clone());
|
let (capture_target, playback_target) = (input_device.clone(), output_device.clone());
|
||||||
|
|
||||||
if let Err(e) = audio_backend.start_capture(capture_tx, capture_target) {
|
if let Err(e) = audio_backend.start_capture(capture_tx, capture_target) {
|
||||||
|
if was_in_room {
|
||||||
|
let _ = ui_tx.send(UiEvent::RoomReset).await;
|
||||||
|
}
|
||||||
let _ = ui_tx.send(UiEvent::Error(format!("Failed to start capture: {}", e))).await;
|
let _ = ui_tx.send(UiEvent::Error(format!("Failed to start capture: {}", e))).await;
|
||||||
let _ = room_state.leave().await;
|
let _ = room_state.leave().await;
|
||||||
net.audio_router.clear();
|
net.audio_router.clear();
|
||||||
@@ -1302,6 +1398,9 @@ async fn run_core_loop(
|
|||||||
// production to the hardware clock instead of a fixed timer.
|
// production to the hardware clock instead of a fixed timer.
|
||||||
let ring_fill = Arc::new(AtomicUsize::new(0));
|
let ring_fill = Arc::new(AtomicUsize::new(0));
|
||||||
if let Err(e) = audio_backend.start_playback(playback_rx, playback_target, ring_fill.clone()) {
|
if let Err(e) = audio_backend.start_playback(playback_rx, playback_target, ring_fill.clone()) {
|
||||||
|
if was_in_room {
|
||||||
|
let _ = ui_tx.send(UiEvent::RoomReset).await;
|
||||||
|
}
|
||||||
let _ = ui_tx.send(UiEvent::Error(format!("Failed to start playback: {}", e))).await;
|
let _ = ui_tx.send(UiEvent::Error(format!("Failed to start playback: {}", e))).await;
|
||||||
let _ = audio_backend.stop();
|
let _ = audio_backend.stop();
|
||||||
let _ = room_state.leave().await;
|
let _ = room_state.leave().await;
|
||||||
@@ -1603,23 +1702,57 @@ async fn run_core_loop(
|
|||||||
// track in Both mode) one aligned frame per cycle; Mixed mode
|
// track in Both mode) one aligned frame per cycle; Mixed mode
|
||||||
// writes the single blended file as before.
|
// writes the single blended file as before.
|
||||||
if mt_active {
|
if mt_active {
|
||||||
if let Some(mt) = multitrack_mixer.lock().unwrap().as_mut() {
|
let write_err = multitrack_mixer
|
||||||
let res = (|| -> std::io::Result<()> {
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.as_mut()
|
||||||
|
.map(|mt| -> std::io::Result<()> {
|
||||||
for (id, f) in &stems {
|
for (id, f) in &stems {
|
||||||
mt.write_peer(*id, f)?;
|
mt.write_peer(*id, f)?;
|
||||||
}
|
}
|
||||||
mt.write_mix(&record_mix)?;
|
mt.write_mix(&record_mix)?;
|
||||||
mt.end_cycle()
|
mt.end_cycle()
|
||||||
})();
|
})
|
||||||
if let Err(e) = res {
|
.transpose()
|
||||||
crate::log_msg(&format!("Multitrack write failed: {e}"));
|
.err();
|
||||||
}
|
if let Some(e) = write_err {
|
||||||
|
crate::log_msg(&format!("Multitrack write failed: {e}"));
|
||||||
|
stop_recording(
|
||||||
|
&recorder_mixer,
|
||||||
|
&is_recording_mixer,
|
||||||
|
&multitrack_mixer,
|
||||||
|
&is_multitrack_mixer,
|
||||||
|
&ui_tx_mixer,
|
||||||
|
).await;
|
||||||
|
let _ = ui_tx_mixer
|
||||||
|
.send(UiEvent::Error(format!(
|
||||||
|
"Recording stopped — write failed: {e}"
|
||||||
|
)))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
} else if is_recording_mixer.load(Ordering::Relaxed) {
|
||||||
|
let write_err = recorder_mixer
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.as_mut()
|
||||||
|
.map(|rec| rec.write_frame(&record_mix))
|
||||||
|
.transpose()
|
||||||
|
.err();
|
||||||
|
if let Some(e) = write_err {
|
||||||
|
crate::log_msg(&format!("Recording write failed: {e}"));
|
||||||
|
stop_recording(
|
||||||
|
&recorder_mixer,
|
||||||
|
&is_recording_mixer,
|
||||||
|
&multitrack_mixer,
|
||||||
|
&is_multitrack_mixer,
|
||||||
|
&ui_tx_mixer,
|
||||||
|
).await;
|
||||||
|
let _ = ui_tx_mixer
|
||||||
|
.send(UiEvent::Error(format!(
|
||||||
|
"Recording stopped — write failed: {e}"
|
||||||
|
)))
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
} else if is_recording_mixer.load(Ordering::Relaxed)
|
|
||||||
&& let Some(rec) = recorder_mixer.lock().unwrap().as_mut()
|
|
||||||
&& let Err(e) = rec.write_frame(&record_mix)
|
|
||||||
{
|
|
||||||
crate::log_msg(&format!("Recording write failed: {e}"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let frame_to_send = if is_deafened_clone.load(Ordering::Relaxed) {
|
let frame_to_send = if is_deafened_clone.load(Ordering::Relaxed) {
|
||||||
@@ -1646,6 +1779,9 @@ async fn run_core_loop(
|
|||||||
let mut room_events = match room_state.subscribe_events().await {
|
let mut room_events = match room_state.subscribe_events().await {
|
||||||
Ok(rx) => rx,
|
Ok(rx) => rx,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
if was_in_room {
|
||||||
|
let _ = ui_tx.send(UiEvent::RoomReset).await;
|
||||||
|
}
|
||||||
let _ = ui_tx.send(UiEvent::Error(format!("Failed to subscribe events: {}", e))).await;
|
let _ = ui_tx.send(UiEvent::Error(format!("Failed to subscribe events: {}", e))).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1660,16 +1796,16 @@ async fn run_core_loop(
|
|||||||
let multitrack_events = multitrack.clone();
|
let multitrack_events = multitrack.clone();
|
||||||
let is_multitrack_events = is_multitrack.clone();
|
let is_multitrack_events = is_multitrack.clone();
|
||||||
let known_peers_events = known_peers.clone();
|
let known_peers_events = known_peers.clone();
|
||||||
// The ticket of the room this event loop serves, so peer add/remove
|
// The topic of the room this event loop serves, so peer add/remove
|
||||||
// updates the right per-ticket bucket in `known_peers` (A8 archive).
|
// updates the right per-topic bucket in `known_peers` (A8 archive).
|
||||||
let ticket_events = ticket_str.clone();
|
let room_topic = topic_id;
|
||||||
let (recovery_coordinator, recovery_task) =
|
let (recovery_coordinator, recovery_task) =
|
||||||
RecoveryCoordinator::spawn(room_state.clone());
|
RecoveryCoordinator::spawn(room_state.clone());
|
||||||
let recovery_context = RecoveryContext {
|
let recovery_context = RecoveryContext {
|
||||||
coordinator: recovery_coordinator,
|
coordinator: recovery_coordinator,
|
||||||
room_state: room_state.clone(),
|
room_state: room_state.clone(),
|
||||||
known_peers: known_peers.clone(),
|
known_peers: known_peers.clone(),
|
||||||
ticket: ticket_str.clone(),
|
topic_id,
|
||||||
};
|
};
|
||||||
let recovery_events = recovery_context.clone();
|
let recovery_events = recovery_context.clone();
|
||||||
// Friends store + ui sender, so a connected peer who is a friend has
|
// Friends store + ui sender, so a connected peer who is a friend has
|
||||||
@@ -1706,12 +1842,12 @@ async fn run_core_loop(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
// Retain this peer under this room's ticket as a
|
// Retain this peer under this room's topic as a
|
||||||
// future rejoin bootstrap target (A8).
|
// future rejoin bootstrap target (A8).
|
||||||
known_peers_events
|
known_peers_events
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.entry(ticket_events.clone())
|
.entry(room_topic)
|
||||||
.or_default()
|
.or_default()
|
||||||
.insert(peer_id, state.addr.clone());
|
.insert(peer_id, state.addr.clone());
|
||||||
// If a multitrack recording is live, give this peer
|
// If a multitrack recording is live, give this peer
|
||||||
@@ -1767,7 +1903,7 @@ async fn run_core_loop(
|
|||||||
known_peers_events
|
known_peers_events
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.entry(ticket_events.clone())
|
.entry(room_topic)
|
||||||
.or_default()
|
.or_default()
|
||||||
.insert(peer_id, state.addr.clone());
|
.insert(peer_id, state.addr.clone());
|
||||||
let _ = ui_tx_events.send(UiEvent::PeerUpdated { id: peer_id, state }).await;
|
let _ = ui_tx_events.send(UiEvent::PeerUpdated { id: peer_id, state }).await;
|
||||||
@@ -1827,6 +1963,9 @@ async fn run_core_loop(
|
|||||||
let mut conn_events = match transport.subscribe_conn_events().await {
|
let mut conn_events = match transport.subscribe_conn_events().await {
|
||||||
Ok(rx) => rx,
|
Ok(rx) => rx,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
if was_in_room {
|
||||||
|
let _ = ui_tx.send(UiEvent::RoomReset).await;
|
||||||
|
}
|
||||||
let _ = ui_tx.send(UiEvent::Error(format!("Failed to subscribe conn events: {}", e))).await;
|
let _ = ui_tx.send(UiEvent::Error(format!("Failed to subscribe conn events: {}", e))).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1917,29 +2056,25 @@ async fn run_core_loop(
|
|||||||
is_muted.store(new_state, Ordering::Relaxed);
|
is_muted.store(new_state, Ordering::Relaxed);
|
||||||
|
|
||||||
if let Some(session) = &active_session {
|
if let Some(session) = &active_session {
|
||||||
let self_state = PeerState {
|
let self_state = presence.to_state(
|
||||||
name: current_name.clone(),
|
new_state,
|
||||||
is_muted: new_state,
|
net.endpoint.addr(),
|
||||||
addr: net.endpoint.addr(),
|
current_sharing.clone(),
|
||||||
sharing: current_sharing.clone(),
|
);
|
||||||
avatar: current_avatar.clone(),
|
|
||||||
};
|
|
||||||
let _ = session.room_state.update_self_state(self_state).await;
|
let _ = session.room_state.update_self_state(self_state).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CoreCommand::SetAvatar(avatar) => {
|
CoreCommand::SetAvatar(avatar) => {
|
||||||
current_avatar = avatar;
|
presence.avatar = avatar;
|
||||||
// Re-announce presence so the room (incl. late joiners, via the
|
// Re-announce presence so the room (incl. late joiners, via the
|
||||||
// retained presence) picks up the new avatar (W4).
|
// retained presence) picks up the new avatar (W4).
|
||||||
if let Some(session) = &active_session {
|
if let Some(session) = &active_session {
|
||||||
let self_state = PeerState {
|
let self_state = presence.to_state(
|
||||||
name: current_name.clone(),
|
is_muted.load(Ordering::Relaxed),
|
||||||
is_muted: is_muted.load(Ordering::Relaxed),
|
net.endpoint.addr(),
|
||||||
addr: net.endpoint.addr(),
|
current_sharing.clone(),
|
||||||
sharing: current_sharing.clone(),
|
);
|
||||||
avatar: current_avatar.clone(),
|
|
||||||
};
|
|
||||||
let _ = session.room_state.update_self_state(self_state).await;
|
let _ = session.room_state.update_self_state(self_state).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2184,6 +2319,39 @@ async fn run_core_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CoreCommand::SetGamePresenceEnabled(enabled) => {
|
||||||
|
game_presence_enabled = enabled;
|
||||||
|
// Recompute our broadcast label: the current game when enabling,
|
||||||
|
// cleared when disabling. Publish immediately (D8) so peers see the
|
||||||
|
// game appear/disappear at once, not on the next detector tick.
|
||||||
|
presence.game = if enabled {
|
||||||
|
game_presence_label(current_game.as_ref())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(session) = &active_session {
|
||||||
|
let self_state = presence.to_state(
|
||||||
|
is_muted.load(Ordering::Relaxed),
|
||||||
|
net.endpoint.addr(),
|
||||||
|
current_sharing.clone(),
|
||||||
|
);
|
||||||
|
let _ = session.room_state.update_self_state(self_state).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CoreCommand::SetGameOverride(override_) => {
|
||||||
|
// Applied on the detector's next poll, immediately (bypasses debounce).
|
||||||
|
if let Some(detector) = &game_detector {
|
||||||
|
detector.set_override(override_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CoreCommand::SetGameProcessMap(map) => {
|
||||||
|
if let Some(detector) = &game_detector {
|
||||||
|
detector.set_process_map(map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
CoreCommand::SetRecordingMode(mode) => {
|
CoreCommand::SetRecordingMode(mode) => {
|
||||||
recording_mode = mode;
|
recording_mode = mode;
|
||||||
}
|
}
|
||||||
@@ -2205,11 +2373,13 @@ async fn run_core_loop(
|
|||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let result: Result<String, String> = if recording_mode.is_multitrack() {
|
let result: Result<String, String> = if recording_mode.is_multitrack() {
|
||||||
// Multitrack/Both: a per-session directory of stems.
|
// Multitrack/Both: a per-session directory of stems.
|
||||||
let stamp = crate::audio::recorder::timestamp_filename(now);
|
std::fs::create_dir_all(&base)
|
||||||
let session_dir = base.join(stamp.trim_end_matches(".wav"));
|
|
||||||
std::fs::create_dir_all(&session_dir)
|
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
.and_then(|_| {
|
.and_then(|_| {
|
||||||
|
crate::audio::multitrack::create_session_dir(&base, now)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
.and_then(|session_dir| {
|
||||||
MultitrackRecorder::create(
|
MultitrackRecorder::create(
|
||||||
&session_dir,
|
&session_dir,
|
||||||
FRAME_SAMPLES,
|
FRAME_SAMPLES,
|
||||||
@@ -2337,13 +2507,11 @@ async fn run_core_loop(
|
|||||||
crate::log_msg("Screen share host started");
|
crate::log_msg("Screen share host started");
|
||||||
session.screenshare_host = Some(child);
|
session.screenshare_host = Some(child);
|
||||||
current_sharing = Some(ticket.clone());
|
current_sharing = Some(ticket.clone());
|
||||||
let self_state = PeerState {
|
let self_state = presence.to_state(
|
||||||
name: current_name.clone(),
|
is_muted.load(Ordering::Relaxed),
|
||||||
is_muted: is_muted.load(Ordering::Relaxed),
|
net.endpoint.addr(),
|
||||||
addr: net.endpoint.addr(),
|
Some(ticket),
|
||||||
sharing: Some(ticket),
|
);
|
||||||
avatar: current_avatar.clone(),
|
|
||||||
};
|
|
||||||
let _ = session.room_state.update_self_state(self_state).await;
|
let _ = session.room_state.update_self_state(self_state).await;
|
||||||
let _ = ui_tx.send(UiEvent::ScreenShareStarted).await;
|
let _ = ui_tx.send(UiEvent::ScreenShareStarted).await;
|
||||||
}
|
}
|
||||||
@@ -2362,13 +2530,11 @@ async fn run_core_loop(
|
|||||||
let _ = child.kill().await;
|
let _ = child.kill().await;
|
||||||
crate::log_msg("Screen share host stopped");
|
crate::log_msg("Screen share host stopped");
|
||||||
}
|
}
|
||||||
let self_state = PeerState {
|
let self_state = presence.to_state(
|
||||||
name: current_name.clone(),
|
is_muted.load(Ordering::Relaxed),
|
||||||
is_muted: is_muted.load(Ordering::Relaxed),
|
net.endpoint.addr(),
|
||||||
addr: net.endpoint.addr(),
|
None,
|
||||||
sharing: None,
|
);
|
||||||
avatar: current_avatar.clone(),
|
|
||||||
};
|
|
||||||
let _ = session.room_state.update_self_state(self_state).await;
|
let _ = session.room_state.update_self_state(self_state).await;
|
||||||
}
|
}
|
||||||
let _ = ui_tx.send(UiEvent::ScreenShareStopped).await;
|
let _ = ui_tx.send(UiEvent::ScreenShareStopped).await;
|
||||||
@@ -2410,14 +2576,66 @@ async fn run_core_loop(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
apply_volume, audio_datagram_len_ok, frame_level, mix_frames, mix_stereo_frames,
|
apply_volume, audio_datagram_len_ok, frame_level, mix_frames, mix_stereo_frames,
|
||||||
stereo_to_mono, MicLevelMeter, MAX_OPUS_PAYLOAD, MIC_LEVEL_REPORT_SAMPLES,
|
next_game_change, stereo_to_mono, KnownPeers, MicLevelMeter, PeerSpeakTicket,
|
||||||
|
MAX_OPUS_PAYLOAD, MIC_LEVEL_REPORT_SAMPLES,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retained_peers_are_shared_by_topic_across_restamped_tickets() {
|
||||||
|
let topic_id = [23u8; 32];
|
||||||
|
let original_host = iroh::SecretKey::generate().public();
|
||||||
|
let member_host = iroh::SecretKey::generate().public();
|
||||||
|
let retained_peer = iroh::SecretKey::generate().public();
|
||||||
|
let original = PeerSpeakTicket {
|
||||||
|
host_addr: iroh::EndpointAddr::from(original_host),
|
||||||
|
topic_id,
|
||||||
|
name: "Room".to_string(),
|
||||||
|
}.to_string();
|
||||||
|
let restamped = PeerSpeakTicket {
|
||||||
|
host_addr: iroh::EndpointAddr::from(member_host),
|
||||||
|
topic_id,
|
||||||
|
name: "Room".to_string(),
|
||||||
|
}.to_string();
|
||||||
|
assert_ne!(original, restamped);
|
||||||
|
|
||||||
|
let original_topic = PeerSpeakTicket::topic_of(&original).unwrap();
|
||||||
|
let restamped_topic = PeerSpeakTicket::topic_of(&restamped).unwrap();
|
||||||
|
assert_eq!(original_topic, restamped_topic);
|
||||||
|
|
||||||
|
let retained_addr = iroh::EndpointAddr::from(retained_peer);
|
||||||
|
let known_peers: KnownPeers =
|
||||||
|
std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
|
||||||
|
known_peers
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.entry(original_topic)
|
||||||
|
.or_default()
|
||||||
|
.insert(retained_peer, retained_addr.clone());
|
||||||
|
|
||||||
|
let found = known_peers
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&restamped_topic)
|
||||||
|
.and_then(|peers| peers.get(&retained_peer))
|
||||||
|
.cloned();
|
||||||
|
assert_eq!(found, Some(retained_addr));
|
||||||
|
}
|
||||||
|
|
||||||
/// A frame of constant amplitude with the given sample count.
|
/// A frame of constant amplitude with the given sample count.
|
||||||
fn frame(amp: i16, len: usize) -> Vec<i16> {
|
fn frame(amp: i16, len: usize) -> Vec<i16> {
|
||||||
vec![amp; len]
|
vec![amp; len]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn closed_game_watch_is_fused_after_one_ready_event() {
|
||||||
|
let (tx, rx) = tokio::sync::watch::channel(None);
|
||||||
|
let mut rx = Some(rx);
|
||||||
|
drop(tx);
|
||||||
|
|
||||||
|
assert_eq!(next_game_change(&mut rx).await, None);
|
||||||
|
assert!(rx.is_none(), "closed receiver must disable its select source");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mic_meter_reports_only_after_enough_samples() {
|
fn mic_meter_reports_only_after_enough_samples() {
|
||||||
let mut m = MicLevelMeter::new();
|
let mut m = MicLevelMeter::new();
|
||||||
|
|||||||
@@ -125,6 +125,29 @@ pub fn is_probably_image(bytes: &[u8]) -> bool {
|
|||||||
png || jpeg || gif || bmp || webp
|
png || jpeg || gif || bmp || webp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sniff the leading bytes for an audio container supported by the inline clip
|
||||||
|
/// player. Audio remains [`AttachmentKind::File`] on the wire; this receiver-side
|
||||||
|
/// check confirms that a filename-based player hint actually contains WAV, MP3,
|
||||||
|
/// Ogg Vorbis, or FLAC data before playback is attempted.
|
||||||
|
pub fn is_probably_audio(bytes: &[u8]) -> bool {
|
||||||
|
let flac = bytes.starts_with(b"fLaC");
|
||||||
|
let ogg = bytes.starts_with(b"OggS");
|
||||||
|
let wav = bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WAVE";
|
||||||
|
let mp3_id3 = bytes.starts_with(b"ID3");
|
||||||
|
let mp3_frame = bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] & 0xE0 == 0xE0;
|
||||||
|
flac || ogg || wav || mp3_id3 || mp3_frame
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a sanitized attachment name has an extension supported by the
|
||||||
|
/// inline audio player. This is only a pre-fetch presentation hint; fetched
|
||||||
|
/// bytes are confirmed with [`is_probably_audio`] before being decoded.
|
||||||
|
pub fn looks_like_audio_name(name: &str) -> bool {
|
||||||
|
let Some((_, extension)) = name.rsplit_once('.') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
matches!(extension.to_ascii_lowercase().as_str(), "wav" | "mp3" | "ogg" | "oga" | "flac")
|
||||||
|
}
|
||||||
|
|
||||||
/// The attachment kind for some file bytes: [`AttachmentKind::Image`] if it
|
/// The attachment kind for some file bytes: [`AttachmentKind::Image`] if it
|
||||||
/// sniffs as an image container, else [`AttachmentKind::File`].
|
/// sniffs as an image container, else [`AttachmentKind::File`].
|
||||||
pub fn classify(bytes: &[u8]) -> AttachmentKind {
|
pub fn classify(bytes: &[u8]) -> AttachmentKind {
|
||||||
@@ -242,9 +265,59 @@ mod tests {
|
|||||||
assert!(!is_probably_image(b""));
|
assert!(!is_probably_image(b""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_sniffing_recognizes_supported_containers() {
|
||||||
|
assert!(is_probably_audio(b"fLaC\0\0\0\x22"));
|
||||||
|
assert!(is_probably_audio(b"OggS\0\x02"));
|
||||||
|
|
||||||
|
let mut wav = b"RIFF".to_vec();
|
||||||
|
wav.extend_from_slice(&[0, 0, 0, 0]);
|
||||||
|
wav.extend_from_slice(b"WAVE");
|
||||||
|
assert!(is_probably_audio(&wav));
|
||||||
|
|
||||||
|
assert!(is_probably_audio(b"ID3\x04\0\0"));
|
||||||
|
assert!(is_probably_audio(&[0xFF, 0xFB, 0x90, 0x64]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_sniffing_disambiguates_wav_from_webp() {
|
||||||
|
let mut wav = b"RIFF".to_vec();
|
||||||
|
wav.extend_from_slice(&[0, 0, 0, 0]);
|
||||||
|
wav.extend_from_slice(b"WAVE");
|
||||||
|
assert!(is_probably_audio(&wav));
|
||||||
|
assert!(!is_probably_image(&wav));
|
||||||
|
|
||||||
|
let mut webp = b"RIFF".to_vec();
|
||||||
|
webp.extend_from_slice(&[0, 0, 0, 0]);
|
||||||
|
webp.extend_from_slice(b"WEBP");
|
||||||
|
assert!(is_probably_image(&webp));
|
||||||
|
assert!(!is_probably_audio(&webp));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_sniffing_rejects_non_audio() {
|
||||||
|
assert!(!is_probably_audio(b"%PDF-1.7"));
|
||||||
|
assert!(!is_probably_audio(&[0x89, b'P', b'N', b'G']));
|
||||||
|
assert!(!is_probably_audio(&[]));
|
||||||
|
assert!(!is_probably_audio(&[0xFF]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_name_detection_is_case_insensitive() {
|
||||||
|
for name in ["clip.wav", "clip.mp3", "clip.ogg", "clip.oga", "clip.flac"] {
|
||||||
|
assert!(looks_like_audio_name(name), "{name}");
|
||||||
|
}
|
||||||
|
assert!(looks_like_audio_name("VOICE.MP3"));
|
||||||
|
assert!(looks_like_audio_name("mix.FlAc"));
|
||||||
|
assert!(!looks_like_audio_name("recording"));
|
||||||
|
assert!(!looks_like_audio_name("notes.pdf"));
|
||||||
|
assert!(!looks_like_audio_name("photo.webp"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_maps_sniff_to_kind() {
|
fn classify_maps_sniff_to_kind() {
|
||||||
assert_eq!(classify(&[0xFF, 0xD8, 0xFF]), AttachmentKind::Image);
|
assert_eq!(classify(&[0xFF, 0xD8, 0xFF]), AttachmentKind::Image);
|
||||||
|
assert_eq!(classify(b"fLaC\0\0\0\x22"), AttachmentKind::File);
|
||||||
assert_eq!(classify(b"plain text"), AttachmentKind::File);
|
assert_eq!(classify(b"plain text"), AttachmentKind::File);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
//! The detector service (§5): one cancellable background worker that polls the OS
|
||||||
|
//! adapters, runs the pure matcher + debouncer, and publishes the stable detected
|
||||||
|
//! game on a watch channel — only when it changes, so a flapping detector can't
|
||||||
|
//! spam `PeerState` re-announces.
|
||||||
|
//!
|
||||||
|
//! All the OS reads (Steam files / registry, the process scan) are blocking, so
|
||||||
|
//! the worker is a dedicated `std::thread`, not a tokio task; it owns the
|
||||||
|
//! [`SteamProbe`] cache and the [`Debouncer`] across ticks. The per-tick decision
|
||||||
|
//! is factored into the pure [`poll_once`] so the wiring of resolve + match +
|
||||||
|
//! debounce is unit-tested without any I/O.
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
builtin_denylist, match_processes, resolve, Debouncer, DetectedGame, ManualOverride,
|
||||||
|
};
|
||||||
|
use super::scan;
|
||||||
|
use super::steam::SteamProbe;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::io;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread::JoinHandle;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::watch;
|
||||||
|
|
||||||
|
/// How often the detector samples Steam state + the process list.
|
||||||
|
pub const POLL_INTERVAL: Duration = Duration::from_secs(3);
|
||||||
|
/// Granularity of the cancellable sleep between polls, so a stop request is
|
||||||
|
/// honored promptly instead of after a full [`POLL_INTERVAL`].
|
||||||
|
const SLEEP_TICK: Duration = Duration::from_millis(200);
|
||||||
|
|
||||||
|
/// Apply one poll's worth of inputs to the debouncer, returning the new published
|
||||||
|
/// value **iff it changed** (the signal to re-announce presence / switch the
|
||||||
|
/// background). Pure: the caller supplies the already-fetched Steam detection and
|
||||||
|
/// process list, so resolve + match + debounce are testable with zero I/O.
|
||||||
|
pub fn poll_once(
|
||||||
|
debouncer: &mut Debouncer,
|
||||||
|
override_: &ManualOverride,
|
||||||
|
steam: Option<DetectedGame>,
|
||||||
|
processes: &[String],
|
||||||
|
process_map: &BTreeMap<String, String>,
|
||||||
|
denylist: &std::collections::BTreeSet<&str>,
|
||||||
|
) -> Option<Option<DetectedGame>> {
|
||||||
|
let matched = match_processes(processes, process_map, denylist);
|
||||||
|
let res = resolve(override_, steam, &matched);
|
||||||
|
if debouncer.observe(res.game, res.immediate) {
|
||||||
|
Some(debouncer.current().cloned())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared, live-updatable inputs to the detector, written by core (manual override
|
||||||
|
/// changes, config edits to the process map) and read each poll by the worker.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct DetectorInputs {
|
||||||
|
pub override_: Mutex<ManualOverride>,
|
||||||
|
pub process_map: Mutex<BTreeMap<String, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A running detector service. Holds the watch receiver for detected-game changes
|
||||||
|
/// and the shared inputs; dropping it (or calling [`stop`](Self::stop)) ends the
|
||||||
|
/// worker thread.
|
||||||
|
pub struct GameDetector {
|
||||||
|
inputs: Arc<DetectorInputs>,
|
||||||
|
rx: watch::Receiver<Option<DetectedGame>>,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
worker: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GameDetector {
|
||||||
|
/// Spawn the detector worker. `process_map` seeds the non-Steam mappings;
|
||||||
|
/// `override_` seeds the manual override (usually `Auto`). The worker runs
|
||||||
|
/// until [`stop`](Self::stop) or the returned `GameDetector` is dropped.
|
||||||
|
pub fn spawn(
|
||||||
|
override_: ManualOverride,
|
||||||
|
process_map: BTreeMap<String, String>,
|
||||||
|
) -> io::Result<Self> {
|
||||||
|
let inputs = Arc::new(DetectorInputs {
|
||||||
|
override_: Mutex::new(override_),
|
||||||
|
process_map: Mutex::new(process_map),
|
||||||
|
});
|
||||||
|
let (tx, rx) = watch::channel(None);
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
|
let worker_inputs = inputs.clone();
|
||||||
|
let worker_stop = stop.clone();
|
||||||
|
let worker = std::thread::Builder::new()
|
||||||
|
.name("game-detector".to_string())
|
||||||
|
.spawn(move || worker_loop(worker_inputs, tx, worker_stop))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
inputs,
|
||||||
|
rx,
|
||||||
|
stop,
|
||||||
|
worker: Some(worker),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A clone of the watch receiver for detected-game changes. The current value
|
||||||
|
/// is `None` until the first non-empty detection is debounced in.
|
||||||
|
pub fn subscribe(&self) -> watch::Receiver<Option<DetectedGame>> {
|
||||||
|
self.rx.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the manual override (applied on the next poll, immediately,
|
||||||
|
/// bypassing debounce).
|
||||||
|
pub fn set_override(&self, override_: ManualOverride) {
|
||||||
|
*self.inputs.override_.lock().unwrap() = override_;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the user process→name mappings (e.g. after a Settings edit).
|
||||||
|
pub fn set_process_map(&self, map: BTreeMap<String, String>) {
|
||||||
|
*self.inputs.process_map.lock().unwrap() = map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signal the worker to exit. Idempotent; also happens on drop.
|
||||||
|
pub fn stop(&self) {
|
||||||
|
self.stop.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for GameDetector {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop();
|
||||||
|
if let Some(worker) = self.worker.take() {
|
||||||
|
let _ = worker.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The blocking worker loop: probe, decide, publish on change, sleep (cancellably).
|
||||||
|
fn worker_loop(
|
||||||
|
inputs: Arc<DetectorInputs>,
|
||||||
|
tx: watch::Sender<Option<DetectedGame>>,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
) {
|
||||||
|
let denylist = builtin_denylist();
|
||||||
|
let mut steam = SteamProbe::new();
|
||||||
|
let mut debouncer = Debouncer::default();
|
||||||
|
|
||||||
|
while !stop.load(Ordering::Relaxed) {
|
||||||
|
let override_ = inputs.override_.lock().unwrap().clone();
|
||||||
|
let process_map = inputs.process_map.lock().unwrap().clone();
|
||||||
|
|
||||||
|
let steam_game = steam.detect();
|
||||||
|
let processes = scan::running_executables();
|
||||||
|
|
||||||
|
if let Some(new_current) =
|
||||||
|
poll_once(&mut debouncer, &override_, steam_game, &processes, &process_map, &denylist)
|
||||||
|
{
|
||||||
|
// A closed receiver means core shut down; stop quietly.
|
||||||
|
if tx.send(new_current).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancellable sleep: wake promptly on a stop request.
|
||||||
|
let mut slept = Duration::ZERO;
|
||||||
|
while slept < POLL_INTERVAL && !stop.load(Ordering::Relaxed) {
|
||||||
|
std::thread::sleep(SLEEP_TICK);
|
||||||
|
slept += SLEEP_TICK;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::super::GameSource;
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn game(id: &str, name: &str, source: GameSource) -> DetectedGame {
|
||||||
|
DetectedGame { id: id.into(), name: Some(name.into()), source }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
||||||
|
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn poll_once_debounces_steam_detection() {
|
||||||
|
let deny = builtin_denylist();
|
||||||
|
let mut d = Debouncer::default();
|
||||||
|
let steam = game("steam:730", "CS2", GameSource::Steam);
|
||||||
|
let empty = BTreeMap::new();
|
||||||
|
|
||||||
|
// First poll: detected but not yet published (needs two hits).
|
||||||
|
assert_eq!(
|
||||||
|
poll_once(&mut d, &ManualOverride::Auto, Some(steam.clone()), &[], &empty, &deny),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
// Second poll: published.
|
||||||
|
assert_eq!(
|
||||||
|
poll_once(&mut d, &ManualOverride::Auto, Some(steam.clone()), &[], &empty, &deny),
|
||||||
|
Some(Some(steam))
|
||||||
|
);
|
||||||
|
// Third identical poll: no change event.
|
||||||
|
assert_eq!(
|
||||||
|
poll_once(&mut d, &ManualOverride::Auto, Some(game("steam:730", "CS2", GameSource::Steam)), &[], &empty, &deny),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn poll_once_matches_process_when_no_steam() {
|
||||||
|
let deny = builtin_denylist();
|
||||||
|
let mut d = Debouncer::default();
|
||||||
|
let procs = vec!["/games/hl2_linux".to_string()];
|
||||||
|
let user = map(&[("hl2_linux", "Half-Life 2")]);
|
||||||
|
|
||||||
|
poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny);
|
||||||
|
let change = poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny);
|
||||||
|
let published = change.expect("should publish on second hit").expect("a game");
|
||||||
|
assert_eq!(published.id, "exe:hl2_linux");
|
||||||
|
assert_eq!(published.name.as_deref(), Some("Half-Life 2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn poll_once_manual_override_is_immediate() {
|
||||||
|
let deny = builtin_denylist();
|
||||||
|
let mut d = Debouncer::default();
|
||||||
|
let forced = game("steam:220", "HL2", GameSource::Steam);
|
||||||
|
// Even with a live Steam detection of something else, the override wins now.
|
||||||
|
let other = game("steam:730", "CS2", GameSource::Steam);
|
||||||
|
let change = poll_once(
|
||||||
|
&mut d,
|
||||||
|
&ManualOverride::Force(forced.clone()),
|
||||||
|
Some(other),
|
||||||
|
&[],
|
||||||
|
&BTreeMap::new(),
|
||||||
|
&deny,
|
||||||
|
);
|
||||||
|
assert_eq!(change, Some(Some(forced)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spawn_and_stop_is_clean() {
|
||||||
|
// Smoke test the lifecycle: spawning and stopping must not panic, and the
|
||||||
|
// initial published value is None.
|
||||||
|
let det = GameDetector::spawn(ManualOverride::Auto, BTreeMap::new()).unwrap();
|
||||||
|
assert_eq!(*det.subscribe().borrow(), None);
|
||||||
|
det.set_override(ManualOverride::ForceNone);
|
||||||
|
det.set_process_map(map(&[("x", "X")]));
|
||||||
|
det.stop();
|
||||||
|
// Dropping also stops; no hang/panic.
|
||||||
|
drop(det);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
//! Game detection, game-presence, and game-reactive backgrounds.
|
||||||
|
//!
|
||||||
|
//! A single local "what game is running" detector feeds two consumers:
|
||||||
|
//! 1. **Local** — a per-game UI background that auto-switches (extends W16).
|
||||||
|
//! 2. **Broadcast** — a `Playing <name>` status next to our avatar in every peer's
|
||||||
|
//! roster, riding the gossip presence plane like nickname + avatar.
|
||||||
|
//!
|
||||||
|
//! This module is structured testable-seams-first: the *pure* logic lives here
|
||||||
|
//! (the stable-id scheme, the priority [`resolve`] matcher, the [`Debouncer`], and
|
||||||
|
//! the process-name [`match_processes`] mapping), unit-tested with zero I/O. The OS
|
||||||
|
//! edges — Steam state/file reads ([`steam`]) and the running-process scan
|
||||||
|
//! ([`scan`]) — feed already-parsed values into these pure functions, and the
|
||||||
|
//! cancellable poll service ([`detector`]) wires them together.
|
||||||
|
|
||||||
|
pub mod detector;
|
||||||
|
pub mod scan;
|
||||||
|
pub mod steam;
|
||||||
|
pub mod vdf;
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
/// Where a detected game came from. Encodes the trust/priority tier directly:
|
||||||
|
/// a manual override beats live Steam state, which beats a matched process. Used
|
||||||
|
/// only for prioritization and as a presentation hint — never trusted as identity.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum GameSource {
|
||||||
|
/// The user forced a specific game (or "none") via the manual override.
|
||||||
|
Manual,
|
||||||
|
/// Steam's live `RunningAppID` resolved against an `appmanifest`.
|
||||||
|
Steam,
|
||||||
|
/// A running process matched against the user's process→name mappings.
|
||||||
|
Process,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A game the local detector currently believes is running.
|
||||||
|
///
|
||||||
|
/// `id` is the stable, namespaced identity used as the config key for backgrounds
|
||||||
|
/// (`steam:730`, `exe:hl2_linux`) — **never** the mutable display name. `name` is
|
||||||
|
/// the human label shown locally and broadcast as presence; it is `None` only for
|
||||||
|
/// the Steam appid-without-manifest case, where the background can still switch by
|
||||||
|
/// `id` but nothing is broadcast (per the "don't invent `Steam App 123`" rule).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DetectedGame {
|
||||||
|
/// Stable namespaced identity. Config-key safe; survives renames.
|
||||||
|
pub id: String,
|
||||||
|
/// Trustworthy human name; `None` = id-only (Steam manifest unavailable).
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// Provenance / priority tier.
|
||||||
|
pub source: GameSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DetectedGame {
|
||||||
|
/// The Steam namespaced id for an appid: `steam:<appid>`.
|
||||||
|
pub fn steam_id(app_id: u32) -> String {
|
||||||
|
format!("steam:{app_id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The process namespaced id for an executable identity: `exe:<normalized>`.
|
||||||
|
pub fn exe_id(exe: &str) -> String {
|
||||||
|
format!("exe:{}", normalize_exe(exe))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user's manual override sitting above both detectors (D2). Small by design.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub enum ManualOverride {
|
||||||
|
/// Trust the auto-detector (default).
|
||||||
|
#[default]
|
||||||
|
Auto,
|
||||||
|
/// Force "not playing anything" regardless of what is detected.
|
||||||
|
ForceNone,
|
||||||
|
/// Force a specific game (the user picked it from the known-games list).
|
||||||
|
Force(DetectedGame),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outcome of [`resolve`]: the chosen game (if any) plus whether the choice is
|
||||||
|
/// a manual override and so should **bypass the [`Debouncer`]** (apply immediately).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Resolution {
|
||||||
|
pub game: Option<DetectedGame>,
|
||||||
|
/// `true` when a manual override (`ForceNone`/`Force`) decided the value.
|
||||||
|
pub immediate: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the detector priority (D2 / §5): **manual override → Steam → mapped
|
||||||
|
/// process → none**. Pure; the adapters resolve `steam`/`processes` into
|
||||||
|
/// `DetectedGame`s and this only picks the winner. `processes` is in the adapter's
|
||||||
|
/// deterministic priority order (see [`match_processes`]); its first entry wins.
|
||||||
|
pub fn resolve(
|
||||||
|
override_: &ManualOverride,
|
||||||
|
steam: Option<DetectedGame>,
|
||||||
|
processes: &[DetectedGame],
|
||||||
|
) -> Resolution {
|
||||||
|
match override_ {
|
||||||
|
ManualOverride::ForceNone => Resolution { game: None, immediate: true },
|
||||||
|
ManualOverride::Force(g) => Resolution { game: Some(g.clone()), immediate: true },
|
||||||
|
ManualOverride::Auto => {
|
||||||
|
let game = steam.or_else(|| processes.first().cloned());
|
||||||
|
Resolution { game, immediate: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Samples required before a *new* game is accepted/switched to.
|
||||||
|
pub const ACCEPT_HITS: u32 = 2;
|
||||||
|
/// Consecutive "no game" samples before a currently-shown game is cleared. At the
|
||||||
|
/// ~3 s poll cadence this is ~9 s, absorbing a brief Steam stale/crash blip.
|
||||||
|
pub const CLEAR_MISSES: u32 = 3;
|
||||||
|
|
||||||
|
/// Debounces a stream of raw per-poll detections into a stable published value, so
|
||||||
|
/// a flapping detector can't repeatedly re-announce the entire `PeerState` (which
|
||||||
|
/// can carry the ~48 KB avatar). Pure state machine — the service feeds it samples
|
||||||
|
/// and re-announces only when [`observe`](Debouncer::observe) reports a change.
|
||||||
|
///
|
||||||
|
/// A switch to a different game needs [`ACCEPT_HITS`] matching samples; clearing a
|
||||||
|
/// game needs [`CLEAR_MISSES`] consecutive misses. A manual override
|
||||||
|
/// (`immediate = true`) applies at once, bypassing both counters.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Debouncer {
|
||||||
|
current: Option<DetectedGame>,
|
||||||
|
pending: Option<DetectedGame>,
|
||||||
|
pending_hits: u32,
|
||||||
|
misses: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Debouncer {
|
||||||
|
/// The currently published, debounced value.
|
||||||
|
pub fn current(&self) -> Option<&DetectedGame> {
|
||||||
|
self.current.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed one poll result. `immediate` (a manual override is active) bypasses the
|
||||||
|
/// debounce. Returns `true` iff the published [`current`](Self::current) value
|
||||||
|
/// changed — the signal for the service to re-announce presence / switch the
|
||||||
|
/// background.
|
||||||
|
pub fn observe(&mut self, sample: Option<DetectedGame>, immediate: bool) -> bool {
|
||||||
|
if immediate {
|
||||||
|
let changed = self.current != sample;
|
||||||
|
self.current = sample;
|
||||||
|
self.pending = None;
|
||||||
|
self.pending_hits = 0;
|
||||||
|
self.misses = 0;
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
match sample {
|
||||||
|
Some(game) => {
|
||||||
|
self.misses = 0;
|
||||||
|
if self.current.as_ref() == Some(&game) {
|
||||||
|
// Already publishing this game; drop any half-counted switch.
|
||||||
|
self.pending = None;
|
||||||
|
self.pending_hits = 0;
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
if self.pending.as_ref() == Some(&game) {
|
||||||
|
self.pending_hits += 1;
|
||||||
|
} else {
|
||||||
|
self.pending = Some(game);
|
||||||
|
self.pending_hits = 1;
|
||||||
|
}
|
||||||
|
if self.pending_hits >= ACCEPT_HITS {
|
||||||
|
self.current = self.pending.take();
|
||||||
|
self.pending_hits = 0;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// A miss never counts toward a *switch*; drop any pending candidate.
|
||||||
|
self.pending = None;
|
||||||
|
self.pending_hits = 0;
|
||||||
|
if self.current.is_some() {
|
||||||
|
self.misses += 1;
|
||||||
|
if self.misses >= CLEAR_MISSES {
|
||||||
|
self.current = None;
|
||||||
|
self.misses = 0;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize a raw executable name/path to a stable identity for matching and ids:
|
||||||
|
/// take the final path component (handling both `/` and `\\` separators) and
|
||||||
|
/// lowercase it. Keeps any extension (`minecraft.exe` stays distinct from a
|
||||||
|
/// hypothetical `minecraft`), trims surrounding whitespace.
|
||||||
|
pub fn normalize_exe(raw: &str) -> String {
|
||||||
|
raw.rsplit(['/', '\\']).next().unwrap_or(raw).trim().to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Launcher/helper executables that must NEVER be reported as a game even if a
|
||||||
|
/// mapping names them — defense against a mis-entered mapping turning the launcher
|
||||||
|
/// itself into "the game". Normalized (lowercase basename) for comparison.
|
||||||
|
const BUILTIN_DENYLIST: &[&str] = &[
|
||||||
|
"steam",
|
||||||
|
"steam.exe",
|
||||||
|
"steamwebhelper",
|
||||||
|
"steamwebhelper.exe",
|
||||||
|
"steamerrorreporter",
|
||||||
|
"gameoverlayui",
|
||||||
|
"reaper",
|
||||||
|
"lutris",
|
||||||
|
"heroic",
|
||||||
|
"heroic.exe",
|
||||||
|
"legendary",
|
||||||
|
"gogdl",
|
||||||
|
"wine",
|
||||||
|
"wine64",
|
||||||
|
"wineserver",
|
||||||
|
"wine-preloader",
|
||||||
|
"proton",
|
||||||
|
"pressure-vessel-wrap",
|
||||||
|
"explorer.exe",
|
||||||
|
"services.exe",
|
||||||
|
"svchost.exe",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The built-in launcher/helper denylist as a set, for membership checks.
|
||||||
|
pub fn builtin_denylist() -> BTreeSet<&'static str> {
|
||||||
|
BUILTIN_DENYLIST.iter().copied().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Match the currently-running executables against the user's explicit
|
||||||
|
/// process→display-name mappings, returning detected games in **deterministic
|
||||||
|
/// priority order** (sorted by stable id) with duplicates removed.
|
||||||
|
///
|
||||||
|
/// Conservative by construction (§3): only exact normalized-basename matches to a
|
||||||
|
/// user mapping count — we never guess that an arbitrary long-running process is a
|
||||||
|
/// game. Any executable on `denylist` is rejected even if mapped, so a launcher or
|
||||||
|
/// helper can't be promoted to "the game".
|
||||||
|
///
|
||||||
|
/// `user_map` keys are matched against the normalized basename of each running
|
||||||
|
/// entry; the key itself is normalized too, so the caller may store either
|
||||||
|
/// `Half-Life 2` style display values keyed by `hl2_linux` or `HL2_Linux`.
|
||||||
|
pub fn match_processes(
|
||||||
|
running: &[String],
|
||||||
|
user_map: &BTreeMap<String, String>,
|
||||||
|
denylist: &BTreeSet<&str>,
|
||||||
|
) -> Vec<DetectedGame> {
|
||||||
|
// Normalize the user map once so lookups are basename/case-insensitive.
|
||||||
|
let normalized_map: BTreeMap<String, &String> =
|
||||||
|
user_map.iter().map(|(k, v)| (normalize_exe(k), v)).collect();
|
||||||
|
|
||||||
|
let mut seen: BTreeSet<String> = BTreeSet::new();
|
||||||
|
let mut out: Vec<DetectedGame> = Vec::new();
|
||||||
|
for raw in running {
|
||||||
|
let norm = normalize_exe(raw);
|
||||||
|
if norm.is_empty() || denylist.contains(norm.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(name) = normalized_map.get(&norm) {
|
||||||
|
let id = format!("exe:{norm}");
|
||||||
|
if seen.insert(id.clone()) {
|
||||||
|
out.push(DetectedGame {
|
||||||
|
id,
|
||||||
|
name: Some((*name).clone()),
|
||||||
|
source: GameSource::Process,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Deterministic priority: stable order independent of process-scan order.
|
||||||
|
out.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn steam_game(app_id: u32, name: &str) -> DetectedGame {
|
||||||
|
DetectedGame {
|
||||||
|
id: DetectedGame::steam_id(app_id),
|
||||||
|
name: Some(name.to_string()),
|
||||||
|
source: GameSource::Steam,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ids / normalization ----------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stable_ids_are_namespaced() {
|
||||||
|
assert_eq!(DetectedGame::steam_id(730), "steam:730");
|
||||||
|
assert_eq!(DetectedGame::exe_id("/usr/games/hl2_linux"), "exe:hl2_linux");
|
||||||
|
assert_eq!(DetectedGame::exe_id("C:\\Games\\Minecraft.exe"), "exe:minecraft.exe");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_handles_both_separators_and_case() {
|
||||||
|
assert_eq!(normalize_exe("/opt/Foo/Bar.x86_64"), "bar.x86_64");
|
||||||
|
assert_eq!(normalize_exe("D:\\a\\b\\GAME.EXE"), "game.exe");
|
||||||
|
assert_eq!(normalize_exe(" spaced.bin "), "spaced.bin");
|
||||||
|
assert_eq!(normalize_exe("bare"), "bare");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- resolve priority --------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_prefers_steam_over_process_in_auto() {
|
||||||
|
let steam = steam_game(730, "CS2");
|
||||||
|
let procs = vec![DetectedGame {
|
||||||
|
id: "exe:foo".into(),
|
||||||
|
name: Some("Foo".into()),
|
||||||
|
source: GameSource::Process,
|
||||||
|
}];
|
||||||
|
let r = resolve(&ManualOverride::Auto, Some(steam.clone()), &procs);
|
||||||
|
assert_eq!(r.game, Some(steam));
|
||||||
|
assert!(!r.immediate);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_falls_back_to_first_process_then_none() {
|
||||||
|
let procs = vec![
|
||||||
|
DetectedGame { id: "exe:a".into(), name: Some("A".into()), source: GameSource::Process },
|
||||||
|
DetectedGame { id: "exe:b".into(), name: Some("B".into()), source: GameSource::Process },
|
||||||
|
];
|
||||||
|
let r = resolve(&ManualOverride::Auto, None, &procs);
|
||||||
|
assert_eq!(r.game.as_ref().unwrap().id, "exe:a");
|
||||||
|
let none = resolve(&ManualOverride::Auto, None, &[]);
|
||||||
|
assert_eq!(none.game, None);
|
||||||
|
assert!(!none.immediate);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_manual_override_wins_and_is_immediate() {
|
||||||
|
let steam = steam_game(730, "CS2");
|
||||||
|
// ForceNone overrides a live Steam detection, immediately.
|
||||||
|
let r = resolve(&ManualOverride::ForceNone, Some(steam.clone()), &[]);
|
||||||
|
assert_eq!(r.game, None);
|
||||||
|
assert!(r.immediate);
|
||||||
|
// Force(x) overrides too.
|
||||||
|
let forced = steam_game(220, "HL2");
|
||||||
|
let r = resolve(&ManualOverride::Force(forced.clone()), Some(steam), &[]);
|
||||||
|
assert_eq!(r.game, Some(forced));
|
||||||
|
assert!(r.immediate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- debounce ----------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debounce_requires_two_hits_to_switch() {
|
||||||
|
let mut d = Debouncer::default();
|
||||||
|
let g = steam_game(730, "CS2");
|
||||||
|
// First sighting: not yet published.
|
||||||
|
assert!(!d.observe(Some(g.clone()), false));
|
||||||
|
assert_eq!(d.current(), None);
|
||||||
|
// Second consecutive sighting: now published.
|
||||||
|
assert!(d.observe(Some(g.clone()), false));
|
||||||
|
assert_eq!(d.current(), Some(&g));
|
||||||
|
// Steady state: same game, no further change events.
|
||||||
|
assert!(!d.observe(Some(g.clone()), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debounce_requires_three_misses_to_clear() {
|
||||||
|
let mut d = Debouncer::default();
|
||||||
|
let g = steam_game(730, "CS2");
|
||||||
|
d.observe(Some(g.clone()), false);
|
||||||
|
d.observe(Some(g.clone()), false);
|
||||||
|
assert_eq!(d.current(), Some(&g));
|
||||||
|
// Two misses: still shown (absorbs a transient blip).
|
||||||
|
assert!(!d.observe(None, false));
|
||||||
|
assert!(!d.observe(None, false));
|
||||||
|
assert_eq!(d.current(), Some(&g));
|
||||||
|
// Third miss: cleared.
|
||||||
|
assert!(d.observe(None, false));
|
||||||
|
assert_eq!(d.current(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debounce_blip_during_clear_resets_miss_count() {
|
||||||
|
let mut d = Debouncer::default();
|
||||||
|
let g = steam_game(730, "CS2");
|
||||||
|
d.observe(Some(g.clone()), false);
|
||||||
|
d.observe(Some(g.clone()), false);
|
||||||
|
// Miss, miss, then the game reappears: miss count resets, stays published.
|
||||||
|
d.observe(None, false);
|
||||||
|
d.observe(None, false);
|
||||||
|
assert!(!d.observe(Some(g.clone()), false));
|
||||||
|
assert_eq!(d.current(), Some(&g));
|
||||||
|
// It now takes a fresh run of three misses to clear.
|
||||||
|
d.observe(None, false);
|
||||||
|
d.observe(None, false);
|
||||||
|
assert!(d.observe(None, false));
|
||||||
|
assert_eq!(d.current(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debounce_immediate_bypasses_counters() {
|
||||||
|
let mut d = Debouncer::default();
|
||||||
|
let g = steam_game(730, "CS2");
|
||||||
|
// A manual override publishes on the first sample.
|
||||||
|
assert!(d.observe(Some(g.clone()), true));
|
||||||
|
assert_eq!(d.current(), Some(&g));
|
||||||
|
// ForceNone clears immediately.
|
||||||
|
assert!(d.observe(None, true));
|
||||||
|
assert_eq!(d.current(), None);
|
||||||
|
// Re-issuing the same immediate value is not a change.
|
||||||
|
d.observe(Some(g.clone()), true);
|
||||||
|
assert!(!d.observe(Some(g.clone()), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debounce_switching_games_needs_two_hits_of_the_new_one() {
|
||||||
|
let mut d = Debouncer::default();
|
||||||
|
let a = steam_game(1, "A");
|
||||||
|
let b = steam_game(2, "B");
|
||||||
|
d.observe(Some(a.clone()), false);
|
||||||
|
d.observe(Some(a.clone()), false);
|
||||||
|
assert_eq!(d.current(), Some(&a));
|
||||||
|
// One sample of B does not switch.
|
||||||
|
assert!(!d.observe(Some(b.clone()), false));
|
||||||
|
assert_eq!(d.current(), Some(&a));
|
||||||
|
// Second consecutive B switches.
|
||||||
|
assert!(d.observe(Some(b.clone()), false));
|
||||||
|
assert_eq!(d.current(), Some(&b));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- process matching --------------------------------------------------
|
||||||
|
|
||||||
|
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
||||||
|
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_processes_matches_only_explicit_mappings() {
|
||||||
|
let user = map(&[("hl2_linux", "Half-Life 2")]);
|
||||||
|
let deny = builtin_denylist();
|
||||||
|
let running = vec![
|
||||||
|
"/usr/bin/firefox".to_string(),
|
||||||
|
"/games/Half-Life 2/hl2_linux".to_string(),
|
||||||
|
"/usr/bin/htop".to_string(),
|
||||||
|
];
|
||||||
|
let got = match_processes(&running, &user, &deny);
|
||||||
|
assert_eq!(got.len(), 1);
|
||||||
|
assert_eq!(got[0].id, "exe:hl2_linux");
|
||||||
|
assert_eq!(got[0].name.as_deref(), Some("Half-Life 2"));
|
||||||
|
assert_eq!(got[0].source, GameSource::Process);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_processes_rejects_denylisted_even_if_mapped() {
|
||||||
|
// A mis-entered mapping naming the Steam client must not win.
|
||||||
|
let user = map(&[("steam", "Steam (oops)"), ("mygame", "My Game")]);
|
||||||
|
let deny = builtin_denylist();
|
||||||
|
let running = vec!["/usr/bin/steam".into(), "/opt/mygame".into()];
|
||||||
|
let got = match_processes(&running, &user, &deny);
|
||||||
|
assert_eq!(got.len(), 1);
|
||||||
|
assert_eq!(got[0].id, "exe:mygame");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_processes_is_deterministic_and_deduped() {
|
||||||
|
let user = map(&[("zed", "Zed"), ("alpha", "Alpha")]);
|
||||||
|
let deny = builtin_denylist();
|
||||||
|
// Same game twice (two processes) + reverse discovery order.
|
||||||
|
let running = vec![
|
||||||
|
"/b/zed".into(),
|
||||||
|
"/a/alpha".into(),
|
||||||
|
"/c/alpha".into(),
|
||||||
|
];
|
||||||
|
let got = match_processes(&running, &user, &deny);
|
||||||
|
// Deduped to two, sorted by id (alpha before zed) regardless of scan order.
|
||||||
|
assert_eq!(got.iter().map(|g| g.id.as_str()).collect::<Vec<_>>(), vec!["exe:alpha", "exe:zed"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_processes_ignores_unmapped_and_case_folds() {
|
||||||
|
let user = map(&[("Game.x86_64", "The Game")]);
|
||||||
|
let deny = builtin_denylist();
|
||||||
|
let running = vec!["/x/GAME.X86_64".into(), "/y/random".into()];
|
||||||
|
let got = match_processes(&running, &user, &deny);
|
||||||
|
assert_eq!(got.len(), 1);
|
||||||
|
assert_eq!(got[0].name.as_deref(), Some("The Game"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
//! Running-process enumeration for the non-Steam detection fallback (D6/D7):
|
||||||
|
//! native adapters only — `/proc` on Linux, Toolhelp on Windows — so there is no
|
||||||
|
//! `sysinfo` dependency and the audit surface stays small.
|
||||||
|
//!
|
||||||
|
//! This module is *just the OS edge*: it returns the list of running executable
|
||||||
|
//! paths/names. The trustworthy part — turning that list into a game via the
|
||||||
|
//! user's explicit mappings and the launcher denylist — is the pure
|
||||||
|
//! [`match_processes`](super::match_processes), unit-tested in the parent module.
|
||||||
|
|
||||||
|
/// Enumerate the executables of currently-running processes as paths/basenames.
|
||||||
|
/// Best-effort: processes we can't introspect (other users') are skipped rather
|
||||||
|
/// than erroring. The result is fed to [`match_processes`](super::match_processes),
|
||||||
|
/// which normalizes each entry to a basename before matching.
|
||||||
|
pub fn running_executables() -> Vec<String> {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
linux_proc_executables()
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
windows_toolhelp_executables()
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "linux", windows)))]
|
||||||
|
{
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn linux_proc_executables() -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let Ok(entries) = std::fs::read_dir("/proc") else {
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let Some(name) = name.to_str() else { continue };
|
||||||
|
// Only numeric entries are processes.
|
||||||
|
if !name.bytes().all(|b| b.is_ascii_digit()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let proc_dir = entry.path();
|
||||||
|
// Prefer the real exe path (full, untruncated); fall back to `comm`, which
|
||||||
|
// is readable for all processes but truncated to 15 bytes.
|
||||||
|
if let Ok(exe) = std::fs::read_link(proc_dir.join("exe"))
|
||||||
|
&& let Some(s) = exe.to_str()
|
||||||
|
{
|
||||||
|
out.push(s.to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(comm) = std::fs::read_to_string(proc_dir.join("comm")) {
|
||||||
|
let trimmed = comm.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
out.push(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn windows_toolhelp_executables() -> Vec<String> {
|
||||||
|
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
|
||||||
|
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
|
||||||
|
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
|
||||||
|
TH32CS_SNAPPROCESS,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
// SAFETY: standard Toolhelp snapshot of all processes; handle checked below.
|
||||||
|
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
|
||||||
|
if snapshot == INVALID_HANDLE_VALUE {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
|
||||||
|
entry.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
|
||||||
|
// SAFETY: entry is zeroed with dwSize set, as Process32FirstW requires.
|
||||||
|
let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) };
|
||||||
|
while ok != 0 {
|
||||||
|
// szExeFile is a NUL-terminated UTF-16 array (the basename, e.g. game.exe).
|
||||||
|
let end = entry.szExeFile.iter().position(|&c| c == 0).unwrap_or(entry.szExeFile.len());
|
||||||
|
let name = String::from_utf16_lossy(&entry.szExeFile[..end]);
|
||||||
|
if !name.is_empty() {
|
||||||
|
out.push(name);
|
||||||
|
}
|
||||||
|
// SAFETY: same valid snapshot + entry struct.
|
||||||
|
ok = unsafe { Process32NextW(snapshot, &mut entry) };
|
||||||
|
}
|
||||||
|
// SAFETY: snapshot handle came from CreateToolhelp32Snapshot above.
|
||||||
|
unsafe { CloseHandle(snapshot) };
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn enumerates_at_least_this_process() {
|
||||||
|
// The test runner itself is a process, so /proc enumeration must be
|
||||||
|
// non-empty and include something that normalizes to our own exe basename.
|
||||||
|
let exes = running_executables();
|
||||||
|
assert!(!exes.is_empty(), "expected to see running processes via /proc");
|
||||||
|
// Our own /proc/self/exe basename should appear among them.
|
||||||
|
let me = std::fs::read_link("/proc/self/exe")
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.file_name().map(|f| f.to_string_lossy().into_owned()));
|
||||||
|
if let Some(me) = me {
|
||||||
|
let me_norm = super::super::normalize_exe(&me);
|
||||||
|
assert!(
|
||||||
|
exes.iter().any(|e| super::super::normalize_exe(e) == me_norm),
|
||||||
|
"running list should include our own executable {me_norm:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,544 @@
|
|||||||
|
//! Steam detection adapter: the primary signal (D1). Reads Steam's live
|
||||||
|
//! `RunningAppID` and resolves it to a display name via the plain-text
|
||||||
|
//! `appmanifest_<appid>.acf`, with no dependency on the binary `appinfo.vdf`.
|
||||||
|
//!
|
||||||
|
//! The *parsing* is pure and unit-tested ([`parse_running_app_id`],
|
||||||
|
//! [`parse_library_paths`], [`parse_app_name`], all over file contents). The fs /
|
||||||
|
//! Windows-registry reads are the thin edge, and [`SteamProbe`] caches roots,
|
||||||
|
//! library list, and resolved names — invalidating by mtime — so the 3 s detector
|
||||||
|
//! poll does not rescan every library each tick (Codex hardening).
|
||||||
|
|
||||||
|
use super::vdf::{self, Value};
|
||||||
|
use super::{DetectedGame, GameSource};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
/// Max bytes read from any single Steam state file. These are small text files
|
||||||
|
/// (a manifest is a few KB); the cap stops a corrupt/hostile giant file from being
|
||||||
|
/// slurped into memory before the parser's own depth guard kicks in.
|
||||||
|
const MAX_STEAM_FILE_BYTES: u64 = 4 * 1024 * 1024;
|
||||||
|
/// SteamPath is a local filesystem path. Four KiB is deliberately generous and
|
||||||
|
/// prevents a corrupt registry length from driving an enormous allocation.
|
||||||
|
#[cfg(any(windows, test))]
|
||||||
|
const MAX_STEAM_PATH_BYTES: u32 = 4 * 1024;
|
||||||
|
|
||||||
|
#[cfg(any(windows, test))]
|
||||||
|
fn validate_reg_len(len: u32) -> Option<usize> {
|
||||||
|
(len != 0 && len.is_multiple_of(2) && len <= MAX_STEAM_PATH_BYTES)
|
||||||
|
.then_some(len as usize / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(windows, test))]
|
||||||
|
fn decode_reg_sz(mut buf: Vec<u16>, returned_bytes: u32) -> Option<String> {
|
||||||
|
let units = validate_reg_len(returned_bytes)?;
|
||||||
|
if units > buf.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
buf.truncate(units);
|
||||||
|
while buf.last() == Some(&0) {
|
||||||
|
buf.pop();
|
||||||
|
}
|
||||||
|
Some(String::from_utf16_lossy(&buf))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the live `RunningAppID` out of a Steam `registry.vdf` (the Linux/macOS
|
||||||
|
/// client's emulated-registry text file). Returns the appid only when present and
|
||||||
|
/// nonzero — `0`/absent is the "no game" state. Pure.
|
||||||
|
pub fn parse_running_app_id(registry_vdf: &str) -> Option<u32> {
|
||||||
|
let root = vdf::parse(registry_vdf).ok()?;
|
||||||
|
let raw = root
|
||||||
|
.get_path(&["Registry", "HKCU", "Software", "Valve", "Steam", "RunningAppID"])
|
||||||
|
.and_then(Value::as_str)?;
|
||||||
|
let id: u32 = raw.trim().parse().ok()?;
|
||||||
|
(id != 0).then_some(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the library folder paths out of a `libraryfolders.vdf`, handling **both**
|
||||||
|
/// the current shape (`"0" { "path" "..." }`) and the legacy shape
|
||||||
|
/// (`"1" "/path"`, the path as a direct string value). Non-numeric keys
|
||||||
|
/// (`contentstatsid`, …) are skipped. Pure; paths are returned as-is (escapes
|
||||||
|
/// already decoded by the VDF parser), including ones on offline drives — the
|
||||||
|
/// caller checks existence.
|
||||||
|
pub fn parse_library_paths(libraryfolders_vdf: &str) -> Vec<PathBuf> {
|
||||||
|
let Ok(root) = vdf::parse(libraryfolders_vdf) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
// The root may or may not wrap entries in a "libraryfolders" object.
|
||||||
|
let container = root.get("libraryfolders").unwrap_or(&root);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (key, val) in container.entries() {
|
||||||
|
// Only numeric-keyed entries are library folders.
|
||||||
|
if key.parse::<u32>().is_err() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = match val {
|
||||||
|
Value::Str(s) => Some(s.as_str()),
|
||||||
|
Value::Obj(_) => val.get("path").and_then(Value::as_str),
|
||||||
|
};
|
||||||
|
if let Some(p) = path
|
||||||
|
&& !p.is_empty()
|
||||||
|
{
|
||||||
|
out.push(PathBuf::from(p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the human `name` out of an `appmanifest_<appid>.acf`. Pure.
|
||||||
|
pub fn parse_app_name(appmanifest_acf: &str) -> Option<String> {
|
||||||
|
let root = vdf::parse(appmanifest_acf).ok()?;
|
||||||
|
root.get_path(&["AppState", "name"])
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read at most [`MAX_STEAM_FILE_BYTES`] of a file as UTF-8 (lossy), or `None` if
|
||||||
|
/// it is missing/unreadable. The thin fs edge under the pure parsers above.
|
||||||
|
fn read_capped(path: &Path) -> Option<String> {
|
||||||
|
use std::io::Read;
|
||||||
|
let file = std::fs::File::open(path).ok()?;
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
file.take(MAX_STEAM_FILE_BYTES).read_to_end(&mut buf).ok()?;
|
||||||
|
Some(String::from_utf8_lossy(&buf).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mtime_of(path: &Path) -> Option<SystemTime> {
|
||||||
|
std::fs::metadata(path).ok()?.modified().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A library list cached against its source file's mtime.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct CachedLibraries {
|
||||||
|
source: Option<PathBuf>,
|
||||||
|
mtime: Option<SystemTime>,
|
||||||
|
paths: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A per-appid resolved name cached against the manifest's mtime. `name` is `None`
|
||||||
|
/// when the manifest exists but carries no usable name, or wasn't found.
|
||||||
|
struct CachedManifest {
|
||||||
|
mtime: Option<SystemTime>,
|
||||||
|
name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stateful Steam probe with mtime-invalidated caches. Construct once and call
|
||||||
|
/// [`detect`](Self::detect) each poll; all reads are blocking, so the detector
|
||||||
|
/// service runs it off the async worker.
|
||||||
|
pub struct SteamProbe {
|
||||||
|
roots: Vec<PathBuf>,
|
||||||
|
libraries: CachedLibraries,
|
||||||
|
manifests: HashMap<u32, CachedManifest>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SteamProbe {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SteamProbe {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
roots: discover_roots(),
|
||||||
|
libraries: CachedLibraries::default(),
|
||||||
|
manifests: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One detection pass: read the live `RunningAppID`, and if a game is running,
|
||||||
|
/// resolve its name from the appmanifest (cached). Returns a `DetectedGame`
|
||||||
|
/// with `name: None` when the appid is known but no manifest name is available
|
||||||
|
/// — the background can still switch by id, but presence must not invent a name.
|
||||||
|
pub fn detect(&mut self) -> Option<DetectedGame> {
|
||||||
|
let app_id = self.running_app_id()?;
|
||||||
|
let name = self.app_name(app_id);
|
||||||
|
Some(DetectedGame {
|
||||||
|
id: DetectedGame::steam_id(app_id),
|
||||||
|
name,
|
||||||
|
source: GameSource::Steam,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live RunningAppID (nonzero), or `None`.
|
||||||
|
///
|
||||||
|
/// Platform notes: on **Windows** the real registry's `RunningAppID` is updated
|
||||||
|
/// live, so we read it. On **Linux** the client's `registry.vdf` is only
|
||||||
|
/// rewritten on Steam *shutdown* — it's stale while a game runs — so the live
|
||||||
|
/// signal is the running game process's `SteamAppId` environment variable
|
||||||
|
/// (`/proc/<pid>/environ`, readable for our own processes; the same approach
|
||||||
|
/// MangoHud uses); `registry.vdf` stays as a best-effort fallback. Other Unix
|
||||||
|
/// (macOS) only has the `registry.vdf` fallback for now.
|
||||||
|
fn running_app_id(&self) -> Option<u32> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
win::running_app_id()
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
running_app_id_from_environ().or_else(registry_running_app_id)
|
||||||
|
}
|
||||||
|
#[cfg(not(any(windows, target_os = "linux")))]
|
||||||
|
{
|
||||||
|
registry_running_app_id()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve (and cache) the display name for an appid by locating its
|
||||||
|
/// `appmanifest_<appid>.acf` across the known libraries.
|
||||||
|
fn app_name(&mut self, app_id: u32) -> Option<String> {
|
||||||
|
let manifest = self.find_manifest(app_id)?;
|
||||||
|
let mtime = mtime_of(&manifest);
|
||||||
|
if let Some(cached) = self.manifests.get(&app_id)
|
||||||
|
&& cached.mtime == mtime
|
||||||
|
{
|
||||||
|
return cached.name.clone();
|
||||||
|
}
|
||||||
|
let name = read_capped(&manifest).and_then(|c| parse_app_name(&c));
|
||||||
|
self.manifests.insert(app_id, CachedManifest { mtime, name: name.clone() });
|
||||||
|
name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The path to an appid's manifest, if it exists in any library.
|
||||||
|
fn find_manifest(&mut self, app_id: u32) -> Option<PathBuf> {
|
||||||
|
let filename = format!("appmanifest_{app_id}.acf");
|
||||||
|
for lib in self.library_paths() {
|
||||||
|
let candidate = lib.join("steamapps").join(&filename);
|
||||||
|
if candidate.exists() {
|
||||||
|
return Some(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All Steam library folder paths, cached and refreshed only when the source
|
||||||
|
/// `libraryfolders.vdf` changes (mtime). Discovered from the known roots.
|
||||||
|
fn library_paths(&mut self) -> Vec<PathBuf> {
|
||||||
|
// Locate the libraryfolders.vdf to watch (first existing across roots).
|
||||||
|
let source = self
|
||||||
|
.roots
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.join("steamapps").join("libraryfolders.vdf"))
|
||||||
|
.find(|p| p.exists());
|
||||||
|
|
||||||
|
let mtime = source.as_deref().and_then(mtime_of);
|
||||||
|
if self.libraries.source == source && self.libraries.mtime == mtime && source.is_some() {
|
||||||
|
return self.libraries.paths.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
if let Some(ref src) = source
|
||||||
|
&& let Some(contents) = read_capped(src)
|
||||||
|
{
|
||||||
|
paths = parse_library_paths(&contents);
|
||||||
|
}
|
||||||
|
// Always include the roots themselves: the install dir is an implicit
|
||||||
|
// library even if libraryfolders.vdf is missing or lists only extras.
|
||||||
|
for root in &self.roots {
|
||||||
|
if !paths.contains(root) {
|
||||||
|
paths.push(root.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.libraries = CachedLibraries { source, mtime, paths: paths.clone() };
|
||||||
|
paths
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Candidate Steam install roots that actually exist on this machine (each is a
|
||||||
|
/// directory containing a `steamapps` folder). Covers native, Flatpak, and Snap
|
||||||
|
/// layouts on Linux; on Windows the install path comes from the registry.
|
||||||
|
fn discover_roots() -> Vec<PathBuf> {
|
||||||
|
let mut roots = Vec::new();
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
if let Some(p) = win::install_path() {
|
||||||
|
roots.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
for rel in [
|
||||||
|
".steam/steam",
|
||||||
|
".steam/root",
|
||||||
|
".local/share/Steam",
|
||||||
|
".var/app/com.valvesoftware.Steam/.local/share/Steam",
|
||||||
|
"snap/steam/common/.local/share/Steam",
|
||||||
|
] {
|
||||||
|
roots.push(home.join(rel));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep only roots that exist and look like a Steam install.
|
||||||
|
roots.retain(|p| p.join("steamapps").is_dir());
|
||||||
|
roots.sort();
|
||||||
|
roots.dedup();
|
||||||
|
roots
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Candidate `registry.vdf` locations (Linux/macOS emulated registry).
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn registry_vdf_candidates() -> Vec<PathBuf> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
out.push(home.join(".steam/registry.vdf"));
|
||||||
|
out.push(home.join(".steam/steam/registry.vdf"));
|
||||||
|
out.push(home.join(".var/app/com.valvesoftware.Steam/.steam/registry.vdf"));
|
||||||
|
out.push(home.join("snap/steam/common/.steam/registry.vdf"));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort `RunningAppID` from the on-disk `registry.vdf`. ⚠️ Stale while a
|
||||||
|
/// game runs (Steam rewrites the file only on shutdown), so this is a *fallback*
|
||||||
|
/// behind the live `/proc` `SteamAppId` scan on Linux — not the primary signal.
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn registry_running_app_id() -> Option<u32> {
|
||||||
|
for path in registry_vdf_candidates() {
|
||||||
|
if let Some(contents) = read_capped(&path)
|
||||||
|
&& let Some(id) = parse_running_app_id(&contents)
|
||||||
|
{
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a Steam appid out of a process's raw `environ` blob (NUL-separated
|
||||||
|
/// `KEY=VALUE` pairs), reading the `SteamAppId` variable Steam exports to every
|
||||||
|
/// game process. Returns the appid only when present and nonzero. Pure +
|
||||||
|
/// unit-tested; the `/proc` iteration is the thin edge in
|
||||||
|
/// [`running_app_id_from_environ`].
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn parse_steam_app_id_from_environ(environ: &[u8]) -> Option<u32> {
|
||||||
|
for kv in environ.split(|&b| b == 0) {
|
||||||
|
if let Some(val) = kv.strip_prefix(b"SteamAppId=")
|
||||||
|
&& let Ok(s) = std::str::from_utf8(val)
|
||||||
|
&& let Ok(id) = s.trim().parse::<u32>()
|
||||||
|
&& id != 0
|
||||||
|
{
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live Steam appid of a running game, found by scanning `/proc/<pid>/environ`
|
||||||
|
/// for the `SteamAppId` Steam exports to the game's process tree. `environ` is
|
||||||
|
/// readable only for our own processes — exactly the ones a Steam game we launched
|
||||||
|
/// runs as — and we skip the rest. The live signal that replaces the stale
|
||||||
|
/// on-disk `registry.vdf` on Linux.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn running_app_id_from_environ() -> Option<u32> {
|
||||||
|
let entries = std::fs::read_dir("/proc").ok()?;
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let Some(name) = name.to_str() else { continue };
|
||||||
|
if !name.bytes().all(|b| b.is_ascii_digit()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Cap the read: an environ is small; this bounds a pathological case.
|
||||||
|
if let Some(environ) = read_capped(&entry.path().join("environ"))
|
||||||
|
&& let Some(id) = parse_steam_app_id_from_environ(environ.as_bytes())
|
||||||
|
{
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod win {
|
||||||
|
//! Windows registry reads via direct Win32 FFI (windows-sys), no `winreg`
|
||||||
|
//! crate. Steam stores both the live `RunningAppID` and its install path under
|
||||||
|
//! `HKCU\Software\Valve\Steam`.
|
||||||
|
use super::{decode_reg_sz, validate_reg_len};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
|
||||||
|
use windows_sys::Win32::System::Registry::{
|
||||||
|
RegCloseKey, RegOpenKeyExW, RegQueryValueExW, HKEY, HKEY_CURRENT_USER, KEY_READ,
|
||||||
|
REG_DWORD, REG_SZ,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// UTF-16, NUL-terminated, for a Win32 wide-string argument.
|
||||||
|
fn wide(s: &str) -> Vec<u16> {
|
||||||
|
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open `HKCU\Software\Valve\Steam` for reading; `None` if absent.
|
||||||
|
fn open_steam_key() -> Option<HKEY> {
|
||||||
|
let subkey = wide("Software\\Valve\\Steam");
|
||||||
|
let mut hkey: HKEY = std::ptr::null_mut();
|
||||||
|
// SAFETY: valid HKEY constant, NUL-terminated subkey, out-param for the handle.
|
||||||
|
let rc = unsafe {
|
||||||
|
RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut hkey)
|
||||||
|
};
|
||||||
|
(rc == ERROR_SUCCESS).then_some(hkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live `RunningAppID` REG_DWORD, nonzero, or `None`.
|
||||||
|
pub fn running_app_id() -> Option<u32> {
|
||||||
|
let hkey = open_steam_key()?;
|
||||||
|
let name = wide("RunningAppID");
|
||||||
|
let mut kind: u32 = 0;
|
||||||
|
let mut data: u32 = 0;
|
||||||
|
let mut len = std::mem::size_of::<u32>() as u32;
|
||||||
|
// SAFETY: out-params sized for a DWORD; data buffer is a u32 we own.
|
||||||
|
let rc = unsafe {
|
||||||
|
RegQueryValueExW(
|
||||||
|
hkey,
|
||||||
|
name.as_ptr(),
|
||||||
|
std::ptr::null(),
|
||||||
|
&mut kind,
|
||||||
|
&mut data as *mut u32 as *mut u8,
|
||||||
|
&mut len,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// SAFETY: handle came from RegOpenKeyExW above.
|
||||||
|
unsafe { RegCloseKey(hkey) };
|
||||||
|
if rc == ERROR_SUCCESS && kind == REG_DWORD && data != 0 {
|
||||||
|
Some(data)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Steam install directory from `HKCU\...\Steam\SteamPath`, if it exists.
|
||||||
|
pub fn install_path() -> Option<PathBuf> {
|
||||||
|
let hkey = open_steam_key()?;
|
||||||
|
let name = wide("SteamPath");
|
||||||
|
let mut kind: u32 = 0;
|
||||||
|
let mut len: u32 = 0;
|
||||||
|
// First query the size.
|
||||||
|
// SAFETY: null data ptr with a zeroed len asks for the required size.
|
||||||
|
let rc = unsafe {
|
||||||
|
RegQueryValueExW(
|
||||||
|
hkey,
|
||||||
|
name.as_ptr(),
|
||||||
|
std::ptr::null(),
|
||||||
|
&mut kind,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
&mut len,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if rc != ERROR_SUCCESS || kind != REG_SZ {
|
||||||
|
// SAFETY: valid handle.
|
||||||
|
unsafe { RegCloseKey(hkey) };
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let Some(units) = validate_reg_len(len) else {
|
||||||
|
// SAFETY: valid handle.
|
||||||
|
unsafe { RegCloseKey(hkey) };
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let mut buf = vec![0u16; units];
|
||||||
|
let mut len2 = len;
|
||||||
|
// SAFETY: buffer sized to the queried byte length.
|
||||||
|
let rc = unsafe {
|
||||||
|
RegQueryValueExW(
|
||||||
|
hkey,
|
||||||
|
name.as_ptr(),
|
||||||
|
std::ptr::null(),
|
||||||
|
&mut kind,
|
||||||
|
buf.as_mut_ptr() as *mut u8,
|
||||||
|
&mut len2,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// SAFETY: valid handle.
|
||||||
|
unsafe { RegCloseKey(hkey) };
|
||||||
|
if rc != ERROR_SUCCESS || kind != REG_SZ || len2 > len {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(PathBuf::from(decode_reg_sz(buf, len2)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_string_lengths_are_bounded_and_trimmed() {
|
||||||
|
assert_eq!(validate_reg_len(5), None, "odd byte lengths are invalid UTF-16");
|
||||||
|
assert_eq!(validate_reg_len(MAX_STEAM_PATH_BYTES + 2), None);
|
||||||
|
assert_eq!(validate_reg_len(8), Some(4));
|
||||||
|
|
||||||
|
let raw = "C:\\Steam\0ignored".encode_utf16().collect::<Vec<_>>();
|
||||||
|
let returned_bytes = ("C:\\Steam\0".encode_utf16().count() * 2) as u32;
|
||||||
|
assert_eq!(decode_reg_sz(raw, returned_bytes).as_deref(), Some("C:\\Steam"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn running_app_id_reads_nonzero_and_rejects_zero() {
|
||||||
|
let running = r#""Registry" { "HKCU" { "Software" { "Valve" { "Steam" {
|
||||||
|
"RunningAppID" "440"
|
||||||
|
} } } } }"#;
|
||||||
|
assert_eq!(parse_running_app_id(running), Some(440));
|
||||||
|
let idle = r#""Registry" { "HKCU" { "Software" { "Valve" { "Steam" {
|
||||||
|
"RunningAppID" "0"
|
||||||
|
} } } } }"#;
|
||||||
|
assert_eq!(parse_running_app_id(idle), None);
|
||||||
|
// Missing key / garbage → None, no panic.
|
||||||
|
assert_eq!(parse_running_app_id(r#""Registry" { }"#), None);
|
||||||
|
assert_eq!(parse_running_app_id("not vdf at all {{{"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn library_paths_handles_current_and_legacy_shapes() {
|
||||||
|
let current = r#""libraryfolders" {
|
||||||
|
"0" { "path" "/home/eric/.local/share/Steam" "label" "" }
|
||||||
|
"1" { "path" "/mnt/games/SteamLibrary" }
|
||||||
|
"contentstatsid" "12345"
|
||||||
|
}"#;
|
||||||
|
let got = parse_library_paths(current);
|
||||||
|
assert_eq!(got, vec![
|
||||||
|
PathBuf::from("/home/eric/.local/share/Steam"),
|
||||||
|
PathBuf::from("/mnt/games/SteamLibrary"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Legacy shape: numeric keys map straight to path strings.
|
||||||
|
let legacy = r#""LibraryFolders" {
|
||||||
|
"TimeNextStatsReport" "9999"
|
||||||
|
"ContentStatsID" "42"
|
||||||
|
"1" "/mnt/old/SteamLibrary"
|
||||||
|
}"#;
|
||||||
|
let got = parse_library_paths(legacy);
|
||||||
|
assert_eq!(got, vec![PathBuf::from("/mnt/old/SteamLibrary")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn library_paths_empty_on_garbage() {
|
||||||
|
assert!(parse_library_paths("totally broken {{{").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn steam_app_id_parsed_from_environ_blob() {
|
||||||
|
// A realistic NUL-separated environ with SteamAppId among other vars.
|
||||||
|
let environ = b"PATH=/usr/bin\0SteamAppId=440\0HOME=/home/x\0SteamGameId=440\0";
|
||||||
|
assert_eq!(parse_steam_app_id_from_environ(environ), Some(440));
|
||||||
|
// Nonzero requirement: SteamAppId=0 (the launcher itself) is ignored.
|
||||||
|
assert_eq!(parse_steam_app_id_from_environ(b"SteamAppId=0\0FOO=bar\0"), None);
|
||||||
|
// Absent → None (a non-Steam process).
|
||||||
|
assert_eq!(parse_steam_app_id_from_environ(b"PATH=/usr/bin\0HOME=/home/x\0"), None);
|
||||||
|
// Not fooled by a different var that merely contains the substring.
|
||||||
|
assert_eq!(parse_steam_app_id_from_environ(b"MY_SteamAppId=999\0"), None);
|
||||||
|
// Garbage value → None, no panic.
|
||||||
|
assert_eq!(parse_steam_app_id_from_environ(b"SteamAppId=notanumber\0"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_name_extracts_and_filters_empty() {
|
||||||
|
let acf = r#""AppState" { "appid" "440" "name" "Team Fortress 2" }"#;
|
||||||
|
assert_eq!(parse_app_name(acf), Some("Team Fortress 2".to_string()));
|
||||||
|
// Empty name → None (don't broadcast a blank).
|
||||||
|
let blank = r#""AppState" { "appid" "440" "name" "" }"#;
|
||||||
|
assert_eq!(parse_app_name(blank), None);
|
||||||
|
// Missing name → None.
|
||||||
|
assert_eq!(parse_app_name(r#""AppState" { "appid" "440" }"#), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
//! A small, defensive parser for Valve's KeyValues / VDF text format, used by
|
||||||
|
//! `appmanifest_<appid>.acf`, `libraryfolders.vdf`, and `~/.steam/registry.vdf`.
|
||||||
|
//!
|
||||||
|
//! Pure (operates on already-read file *contents*) and unit-tested, per the
|
||||||
|
//! testable-seams-first workflow — the file I/O and size caps live in the Steam
|
||||||
|
//! adapter. Deliberately a real recursive-descent KeyValues parser rather than a
|
||||||
|
//! `"name"`-line regex: escapes, nesting, and truncation will eventually break a
|
||||||
|
//! regex (Codex's "use a real VDF parser" hardening). Hardened against hostile
|
||||||
|
//! input with a recursion-depth cap, so a deeply nested file errors instead of
|
||||||
|
//! overflowing the stack, and never panics on malformed/truncated input.
|
||||||
|
|
||||||
|
/// Max object nesting depth accepted before bailing out. Real Steam files nest a
|
||||||
|
/// handful of levels (`registry.vdf` is the deepest at ~6); this is generous while
|
||||||
|
/// still bounding a malicious file.
|
||||||
|
const MAX_DEPTH: usize = 32;
|
||||||
|
|
||||||
|
/// A parsed KeyValues value: either a leaf string or a nested object. Child order
|
||||||
|
/// is preserved and duplicate keys are kept (KeyValues permits them); lookups
|
||||||
|
/// return the first match.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Value {
|
||||||
|
Str(String),
|
||||||
|
Obj(Vec<(String, Value)>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Value {
|
||||||
|
/// The leaf string at this node, if it is a string (not an object).
|
||||||
|
pub fn as_str(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
Value::Str(s) => Some(s),
|
||||||
|
Value::Obj(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first child value under `key`, if this is an object containing it.
|
||||||
|
/// Case-insensitive on the key (KeyValues keys are conventionally
|
||||||
|
/// case-insensitive, and Steam is inconsistent, e.g. `AppState`/`appid`).
|
||||||
|
pub fn get(&self, key: &str) -> Option<&Value> {
|
||||||
|
match self {
|
||||||
|
Value::Obj(pairs) => pairs
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _)| k.eq_ignore_ascii_case(key))
|
||||||
|
.map(|(_, v)| v),
|
||||||
|
Value::Str(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Follow a chain of object keys, returning the value at the end of the path.
|
||||||
|
/// `root.get_path(&["AppState", "name"])`.
|
||||||
|
pub fn get_path<'a>(&'a self, path: &[&str]) -> Option<&'a Value> {
|
||||||
|
let mut cur = self;
|
||||||
|
for key in path {
|
||||||
|
cur = cur.get(key)?;
|
||||||
|
}
|
||||||
|
Some(cur)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterate the (key, value) child pairs if this is an object.
|
||||||
|
pub fn entries(&self) -> &[(String, Value)] {
|
||||||
|
match self {
|
||||||
|
Value::Obj(pairs) => pairs,
|
||||||
|
Value::Str(_) => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse KeyValues/VDF text into a top-level object (the sequence of root
|
||||||
|
/// key→value pairs). Returns `Err` on unbalanced braces, a key with no value, or
|
||||||
|
/// nesting past [`MAX_DEPTH`]. Never panics.
|
||||||
|
pub fn parse(input: &str) -> Result<Value, String> {
|
||||||
|
let mut lexer = Lexer { rest: input };
|
||||||
|
let obj = parse_object(&mut lexer, 0, true)?;
|
||||||
|
Ok(Value::Obj(obj))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a run of `key value` pairs. `top_level` parses until EOF; otherwise it
|
||||||
|
/// parses until a closing `}` (which it consumes).
|
||||||
|
fn parse_object(
|
||||||
|
lexer: &mut Lexer,
|
||||||
|
depth: usize,
|
||||||
|
top_level: bool,
|
||||||
|
) -> Result<Vec<(String, Value)>, String> {
|
||||||
|
if depth > MAX_DEPTH {
|
||||||
|
return Err("VDF nesting too deep".to_string());
|
||||||
|
}
|
||||||
|
let mut pairs = Vec::new();
|
||||||
|
loop {
|
||||||
|
match lexer.next_token()? {
|
||||||
|
None => {
|
||||||
|
if top_level {
|
||||||
|
return Ok(pairs);
|
||||||
|
}
|
||||||
|
return Err("unexpected end of input inside object".to_string());
|
||||||
|
}
|
||||||
|
Some(Token::Close) => {
|
||||||
|
if top_level {
|
||||||
|
return Err("unexpected '}' at top level".to_string());
|
||||||
|
}
|
||||||
|
return Ok(pairs);
|
||||||
|
}
|
||||||
|
Some(Token::Open) => {
|
||||||
|
return Err("expected key, found '{'".to_string());
|
||||||
|
}
|
||||||
|
Some(Token::Str(key)) => {
|
||||||
|
// A key must be followed by a value: a string or a nested object.
|
||||||
|
match lexer.next_token()? {
|
||||||
|
Some(Token::Str(val)) => pairs.push((key, Value::Str(val))),
|
||||||
|
Some(Token::Open) => {
|
||||||
|
let child = parse_object(lexer, depth + 1, false)?;
|
||||||
|
pairs.push((key, Value::Obj(child)));
|
||||||
|
}
|
||||||
|
Some(Token::Close) => {
|
||||||
|
return Err(format!("key '{key}' has no value (found '}}')"));
|
||||||
|
}
|
||||||
|
None => return Err(format!("key '{key}' has no value (end of input)")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Token {
|
||||||
|
Open,
|
||||||
|
Close,
|
||||||
|
Str(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Lexer<'a> {
|
||||||
|
rest: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Lexer<'_> {
|
||||||
|
/// Produce the next token, skipping whitespace and `//` line comments.
|
||||||
|
fn next_token(&mut self) -> Result<Option<Token>, String> {
|
||||||
|
loop {
|
||||||
|
self.rest = self.rest.trim_start();
|
||||||
|
if self.rest.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
// Line comments: `//` to end of line.
|
||||||
|
if let Some(after) = self.rest.strip_prefix("//") {
|
||||||
|
match after.find('\n') {
|
||||||
|
Some(nl) => self.rest = &after[nl + 1..],
|
||||||
|
None => {
|
||||||
|
self.rest = "";
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut chars = self.rest.char_indices();
|
||||||
|
let (_, first) = chars.next().expect("non-empty checked above");
|
||||||
|
return match first {
|
||||||
|
'{' => {
|
||||||
|
self.advance_bytes(first.len_utf8());
|
||||||
|
Ok(Some(Token::Open))
|
||||||
|
}
|
||||||
|
'}' => {
|
||||||
|
self.advance_bytes(first.len_utf8());
|
||||||
|
Ok(Some(Token::Close))
|
||||||
|
}
|
||||||
|
'"' => self.lex_quoted(),
|
||||||
|
_ => Ok(Some(self.lex_bareword())),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn advance_bytes(&mut self, n: usize) {
|
||||||
|
self.rest = &self.rest[n..];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lex a `"..."` string, decoding `\\ \" \n \t` escapes. Errors if unterminated.
|
||||||
|
fn lex_quoted(&mut self) -> Result<Option<Token>, String> {
|
||||||
|
// Skip the opening quote.
|
||||||
|
self.advance_bytes(1);
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut chars = self.rest.char_indices();
|
||||||
|
while let Some((i, c)) = chars.next() {
|
||||||
|
match c {
|
||||||
|
'"' => {
|
||||||
|
// Consume through the closing quote.
|
||||||
|
self.rest = &self.rest[i + 1..];
|
||||||
|
return Ok(Some(Token::Str(out)));
|
||||||
|
}
|
||||||
|
'\\' => {
|
||||||
|
// Decode the escape.
|
||||||
|
match chars.next() {
|
||||||
|
Some((_, esc)) => out.push(match esc {
|
||||||
|
'n' => '\n',
|
||||||
|
't' => '\t',
|
||||||
|
'r' => '\r',
|
||||||
|
// `\\`, `\"`, and anything else: take the literal char.
|
||||||
|
other => other,
|
||||||
|
}),
|
||||||
|
None => return Err("unterminated escape in quoted string".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => out.push(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err("unterminated quoted string".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lex an unquoted token: run of non-whitespace, non-brace, non-quote chars.
|
||||||
|
fn lex_bareword(&mut self) -> Token {
|
||||||
|
let end = self
|
||||||
|
.rest
|
||||||
|
.find(|c: char| c.is_whitespace() || matches!(c, '{' | '}' | '"'))
|
||||||
|
.unwrap_or(self.rest.len());
|
||||||
|
let word = self.rest[..end].to_string();
|
||||||
|
self.rest = &self.rest[end..];
|
||||||
|
Token::Str(word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_appmanifest_name() {
|
||||||
|
// A trimmed-down real appmanifest_<id>.acf.
|
||||||
|
let acf = r#"
|
||||||
|
"AppState"
|
||||||
|
{
|
||||||
|
"appid" "730"
|
||||||
|
"name" "Counter-Strike 2"
|
||||||
|
"StateFlags" "4"
|
||||||
|
"installdir" "Counter-Strike Global Offensive"
|
||||||
|
"UserConfig"
|
||||||
|
{
|
||||||
|
"language" "english"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let root = parse(acf).unwrap();
|
||||||
|
assert_eq!(root.get_path(&["AppState", "name"]).and_then(Value::as_str), Some("Counter-Strike 2"));
|
||||||
|
assert_eq!(root.get_path(&["AppState", "appid"]).and_then(Value::as_str), Some("730"));
|
||||||
|
// Case-insensitive key lookup.
|
||||||
|
assert_eq!(root.get_path(&["appstate", "NAME"]).and_then(Value::as_str), Some("Counter-Strike 2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_libraryfolders_paths_with_escaped_backslashes() {
|
||||||
|
// Windows paths arrive with doubled backslashes (escaped).
|
||||||
|
let vdf = r#"
|
||||||
|
"libraryfolders"
|
||||||
|
{
|
||||||
|
"0"
|
||||||
|
{
|
||||||
|
"path" "C:\\Program Files (x86)\\Steam"
|
||||||
|
"apps"
|
||||||
|
{
|
||||||
|
"730" "35000000000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"1"
|
||||||
|
{
|
||||||
|
"path" "/home/eric/.local/share/Steam"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let root = parse(vdf).unwrap();
|
||||||
|
let lf = root.get("libraryfolders").unwrap();
|
||||||
|
assert_eq!(lf.get_path(&["0", "path"]).and_then(Value::as_str), Some(r"C:\Program Files (x86)\Steam"));
|
||||||
|
assert_eq!(lf.get_path(&["1", "path"]).and_then(Value::as_str), Some("/home/eric/.local/share/Steam"));
|
||||||
|
// The library folder ids are iterable for discovery.
|
||||||
|
let ids: Vec<&str> = lf.entries().iter().map(|(k, _)| k.as_str()).collect();
|
||||||
|
assert_eq!(ids, vec!["0", "1"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_registry_running_appid_deep_path() {
|
||||||
|
let reg = r#"
|
||||||
|
"Registry"
|
||||||
|
{
|
||||||
|
"HKCU"
|
||||||
|
{
|
||||||
|
"Software"
|
||||||
|
{
|
||||||
|
"Valve"
|
||||||
|
{
|
||||||
|
"Steam"
|
||||||
|
{
|
||||||
|
"RunningAppID" "570"
|
||||||
|
"language" "english"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let root = parse(reg).unwrap();
|
||||||
|
let appid = root
|
||||||
|
.get_path(&["Registry", "HKCU", "Software", "Valve", "Steam", "RunningAppID"])
|
||||||
|
.and_then(Value::as_str);
|
||||||
|
assert_eq!(appid, Some("570"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handles_comments_and_barewords() {
|
||||||
|
let vdf = "// a comment\n\"root\"\n{\n\tbarekey barevalue // trailing\n}\n";
|
||||||
|
let root = parse(vdf).unwrap();
|
||||||
|
assert_eq!(root.get_path(&["root", "barekey"]).and_then(Value::as_str), Some("barevalue"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_malformed_without_panicking() {
|
||||||
|
// Unbalanced braces.
|
||||||
|
assert!(parse("\"a\" {").is_err());
|
||||||
|
// Stray closing brace.
|
||||||
|
assert!(parse("}").is_err());
|
||||||
|
// Key with no value at EOF.
|
||||||
|
assert!(parse("\"lonely\"").is_err());
|
||||||
|
// Unterminated quoted string.
|
||||||
|
assert!(parse("\"key\" \"unterminated").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_pathologically_deep_nesting() {
|
||||||
|
// Build MAX_DEPTH+5 nested objects; must error, not overflow the stack.
|
||||||
|
let mut s = String::new();
|
||||||
|
for i in 0..(MAX_DEPTH + 5) {
|
||||||
|
s.push_str(&format!("\"k{i}\" {{"));
|
||||||
|
}
|
||||||
|
for _ in 0..(MAX_DEPTH + 5) {
|
||||||
|
s.push('}');
|
||||||
|
}
|
||||||
|
assert!(parse(&s).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_keys_return_none_not_error() {
|
||||||
|
let root = parse("\"AppState\" { \"appid\" \"1\" }").unwrap();
|
||||||
|
assert_eq!(root.get_path(&["AppState", "name"]), None);
|
||||||
|
assert_eq!(root.get_path(&["Nope"]), None);
|
||||||
|
// Treating a string as an object yields None rather than panicking.
|
||||||
|
assert_eq!(root.get_path(&["AppState", "appid", "deeper"]), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ pub mod recents;
|
|||||||
pub mod discovery;
|
pub mod discovery;
|
||||||
pub mod hotkeys;
|
pub mod hotkeys;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
|
pub mod game;
|
||||||
|
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ use crate::protocol::GOSSIP_SIG_DOMAIN;
|
|||||||
/// reasonable cross-peer clock skew without leaving a wide replay window.
|
/// reasonable cross-peer clock skew without leaving a wide replay window.
|
||||||
const GOSSIP_FRESHNESS_MS: u64 = 120_000;
|
const GOSSIP_FRESHNESS_MS: u64 = 120_000;
|
||||||
|
|
||||||
|
/// Hard cap on an inbound gossip frame before it is deserialized. The largest
|
||||||
|
/// legitimate payload is an `Announce` carrying a full custom avatar (≤48 KB
|
||||||
|
/// base64, [`crate::avatar::CUSTOM_MAX_B64`]) plus the small presence/signature
|
||||||
|
/// fields — about 49 KB on the wire. This cap sits comfortably above that while
|
||||||
|
/// bounding the work/allocation a hostile peer can force: `serde_json::from_slice`
|
||||||
|
/// allocates while parsing, so post-deserialize string caps do NOT prevent abuse —
|
||||||
|
/// the size must be checked *before* parsing (security hardening, Codex find).
|
||||||
|
const MAX_GOSSIP_FRAME_BYTES: usize = 128 * 1024;
|
||||||
|
|
||||||
/// A gossip message plus the authentication envelope that proves who sent it.
|
/// A gossip message plus the authentication envelope that proves who sent it.
|
||||||
/// `author` is the claimed sender (an `EndpointId`, which *is* an ed25519 public
|
/// `author` is the claimed sender (an `EndpointId`, which *is* an ed25519 public
|
||||||
/// key); `sig` is that key's signature over [`signable_bytes`], so a forged
|
/// key); `sig` is that key's signature over [`signable_bytes`], so a forged
|
||||||
@@ -124,12 +133,13 @@ fn admit_state_mutation(
|
|||||||
|
|
||||||
fn peer_state_for_log(state: &PeerState) -> String {
|
fn peer_state_for_log(state: &PeerState) -> String {
|
||||||
format!(
|
format!(
|
||||||
"name={:?}, muted={}, addr_id={}, addrs={}, sharing={}",
|
"name={:?}, muted={}, addr_id={}, addrs={}, sharing={}, game={:?}",
|
||||||
state.name,
|
state.name,
|
||||||
state.is_muted,
|
state.is_muted,
|
||||||
crate::short_id(&state.addr.id.to_string()),
|
crate::short_id(&state.addr.id.to_string()),
|
||||||
state.addr.addrs.len(),
|
state.addr.addrs.len(),
|
||||||
state.sharing.is_some()
|
state.sharing.is_some(),
|
||||||
|
state.game
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,6 +353,17 @@ impl RoomState for IrohGossipState {
|
|||||||
match res {
|
match res {
|
||||||
Ok(iroh_gossip::api::Event::Received(msg)) => {
|
Ok(iroh_gossip::api::Event::Received(msg)) => {
|
||||||
crate::log_msg(&format!("Gossip received Event::Received from delivery={:?}", msg.delivered_from));
|
crate::log_msg(&format!("Gossip received Event::Received from delivery={:?}", msg.delivered_from));
|
||||||
|
// Reject oversized frames BEFORE deserializing: parsing
|
||||||
|
// allocates, so a size check has to precede `from_slice` to
|
||||||
|
// bound the memory a hostile peer can make us hold.
|
||||||
|
if msg.content.len() > MAX_GOSSIP_FRAME_BYTES {
|
||||||
|
crate::log_msg(&format!(
|
||||||
|
"Gossip dropped oversized frame: {} bytes > {} cap",
|
||||||
|
msg.content.len(),
|
||||||
|
MAX_GOSSIP_FRAME_BYTES
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
match serde_json::from_slice::<GossipPayload>(&msg.content) {
|
match serde_json::from_slice::<GossipPayload>(&msg.content) {
|
||||||
Ok(payload) => {
|
Ok(payload) => {
|
||||||
// Authenticate before trusting `author` for ANY
|
// Authenticate before trusting `author` for ANY
|
||||||
@@ -406,6 +427,15 @@ impl RoomState for IrohGossipState {
|
|||||||
// peer-supplied: cap/validate once at ingest
|
// peer-supplied: cap/validate once at ingest
|
||||||
// so invalid offers never render a Watch button.
|
// so invalid offers never render a Watch button.
|
||||||
state.sharing = state.sharing.and_then(crate::screenshare::sanitize_ticket);
|
state.sharing = state.sharing.and_then(crate::screenshare::sanitize_ticket);
|
||||||
|
// The game-presence label is untrusted
|
||||||
|
// peer text like the name: sanitize +
|
||||||
|
// length-cap at ingest (strip bidi/control,
|
||||||
|
// 64-char/256-byte cap). An empty result
|
||||||
|
// means "no game" rather than a blank label.
|
||||||
|
state.game = state.game.and_then(|g| {
|
||||||
|
let cleaned = crate::sanitize::sanitize_game_label(&g);
|
||||||
|
(!cleaned.is_empty()).then_some(cleaned)
|
||||||
|
});
|
||||||
disconnected_peers.lock().unwrap().remove(&payload.author);
|
disconnected_peers.lock().unwrap().remove(&payload.author);
|
||||||
let (is_new, state_changed) = {
|
let (is_new, state_changed) = {
|
||||||
let mut peer_map = peers.lock().unwrap();
|
let mut peer_map = peers.lock().unwrap();
|
||||||
@@ -676,6 +706,7 @@ mod tests {
|
|||||||
addr,
|
addr,
|
||||||
sharing: None,
|
sharing: None,
|
||||||
avatar: crate::avatar::Avatar::default(),
|
avatar: crate::avatar::Avatar::default(),
|
||||||
|
game: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -687,6 +718,16 @@ mod tests {
|
|||||||
EndpointAddr::from(id)
|
EndpointAddr::from(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn peer_state_log_includes_game() {
|
||||||
|
let mut state = sample_peer_state_for(fresh_id());
|
||||||
|
state.game = Some("Half-Life 2".to_string());
|
||||||
|
assert!(peer_state_for_log(&state).contains("game=Some(\"Half-Life 2\")"));
|
||||||
|
|
||||||
|
state.game = None;
|
||||||
|
assert!(peer_state_for_log(&state).contains("game=None"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bootstrap_client_dials_host() {
|
fn bootstrap_client_dials_host() {
|
||||||
// A non-host (client) with no retained peers dials just the ticket host.
|
// A non-host (client) with no retained peers dials just the ticket host.
|
||||||
|
|||||||
@@ -489,6 +489,15 @@ impl iroh::protocol::ProtocolHandler for FileRouter {
|
|||||||
// Finish either way: an unknown id closes with an empty body, which
|
// Finish either way: an unknown id closes with an empty body, which
|
||||||
// the fetcher reads as a zero-length result and treats as "gone".
|
// the fetcher reads as a zero-length result and treats as "gone".
|
||||||
let _ = send.finish();
|
let _ = send.finish();
|
||||||
|
// CRITICAL: `finish()` only marks the stream's EOF — it does NOT wait
|
||||||
|
// for the written bytes to be delivered and acknowledged. If we return
|
||||||
|
// here the `connection` drops, and its CONNECTION_CLOSE can race ahead
|
||||||
|
// of the still-in-flight stream data, so the fetcher's read aborts with
|
||||||
|
// "connection lost". Wait for the fetcher to receive everything and
|
||||||
|
// close the connection itself (it drops `conn` right after read_to_end);
|
||||||
|
// that close is our signal the transfer landed. Bounded so a fetcher
|
||||||
|
// that vanishes can't pin this task forever.
|
||||||
|
let _ = tokio::time::timeout(FILE_FETCH_TIMEOUT, connection.closed()).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,58 @@ pub struct PeerState {
|
|||||||
/// peers/configs that predate the field still deserialize (→ monogram).
|
/// peers/configs that predate the field still deserialize (→ monogram).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub avatar: crate::avatar::Avatar,
|
pub avatar: crate::avatar::Avatar,
|
||||||
|
/// The game this peer is currently playing, as a display string only (shown as
|
||||||
|
/// `Playing <name>` next to their avatar). Opt-in and **untrusted** like
|
||||||
|
/// `name`: sanitized + length-capped at the gossip ingest boundary. `None` when
|
||||||
|
/// the peer isn't sharing a game (feature off / nothing detected). Only the
|
||||||
|
/// display string rides the wire — never the appid or detection source, to
|
||||||
|
/// avoid fingerprinting and coupling the protocol to detector internals.
|
||||||
|
/// Defaulted so peers/configs predating the field still deserialize.
|
||||||
|
#[serde(default)]
|
||||||
|
pub game: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The locally-owned, "sticky" pieces of our own presence: the identity fields
|
||||||
|
/// that change only on explicit user action and persist for the whole core
|
||||||
|
/// session. The remaining `PeerState` fields are *volatile* — mute state, current
|
||||||
|
/// `addr`, and the active screen-share ticket are read fresh at each announce — so
|
||||||
|
/// they are passed into [`SelfPresence::to_state`] rather than stored here.
|
||||||
|
///
|
||||||
|
/// This is the single source of truth for building our own `PeerState`: core
|
||||||
|
/// reconstructs self-state in several command branches (join, mute toggle, avatar
|
||||||
|
/// change, screen-share start/stop), and centralizing the `PeerState` literal here
|
||||||
|
/// means a new presence field is added in exactly one place instead of at every
|
||||||
|
/// call site.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct SelfPresence {
|
||||||
|
pub name: String,
|
||||||
|
pub avatar: crate::avatar::Avatar,
|
||||||
|
/// The display label of the game we're currently broadcasting, or `None` when
|
||||||
|
/// game presence is off / nothing is detected. Already sanitized + capped
|
||||||
|
/// (see `crate::sanitize::sanitize_game_label`) before being stored here, so
|
||||||
|
/// the outgoing announce carries a safe value.
|
||||||
|
pub game: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SelfPresence {
|
||||||
|
/// Combine the sticky identity fields with the volatile per-announce fields
|
||||||
|
/// (`is_muted`, current `addr`, active-share `sharing` ticket) into a full
|
||||||
|
/// `PeerState` ready to announce over the gossip presence plane.
|
||||||
|
pub fn to_state(
|
||||||
|
&self,
|
||||||
|
is_muted: bool,
|
||||||
|
addr: iroh::EndpointAddr,
|
||||||
|
sharing: Option<String>,
|
||||||
|
) -> PeerState {
|
||||||
|
PeerState {
|
||||||
|
name: self.name.clone(),
|
||||||
|
is_muted,
|
||||||
|
addr,
|
||||||
|
sharing,
|
||||||
|
avatar: self.avatar.clone(),
|
||||||
|
game: self.game.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -250,6 +302,7 @@ mod tests {
|
|||||||
addr,
|
addr,
|
||||||
sharing: None,
|
sharing: None,
|
||||||
avatar: crate::avatar::Avatar::default(),
|
avatar: crate::avatar::Avatar::default(),
|
||||||
|
game: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,6 +411,29 @@ mod tests {
|
|||||||
assert_eq!(PeerSpeakTicket::restamp("not-a-ticket", EndpointAddr::from(me)), "not-a-ticket");
|
assert_eq!(PeerSpeakTicket::restamp("not-a-ticket", EndpointAddr::from(me)), "not-a-ticket");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn self_presence_builds_peer_state_with_volatile_fields() {
|
||||||
|
let addr = EndpointAddr::from(SecretKey::generate().public());
|
||||||
|
let presence = SelfPresence {
|
||||||
|
name: "Alice".to_string(),
|
||||||
|
avatar: crate::avatar::Avatar::default(),
|
||||||
|
game: Some("Half-Life 2".to_string()),
|
||||||
|
};
|
||||||
|
// Volatile fields come from the call; sticky fields from the struct.
|
||||||
|
let muted = presence.to_state(true, addr.clone(), Some("ticket".to_string()));
|
||||||
|
assert_eq!(muted.name, "Alice");
|
||||||
|
assert!(muted.is_muted);
|
||||||
|
assert_eq!(muted.addr.id, addr.id);
|
||||||
|
assert_eq!(muted.sharing.as_deref(), Some("ticket"));
|
||||||
|
assert_eq!(muted.avatar, crate::avatar::Avatar::default());
|
||||||
|
assert_eq!(muted.game.as_deref(), Some("Half-Life 2"));
|
||||||
|
// The same sticky presence yields different volatile fields per announce.
|
||||||
|
let unmuted = presence.to_state(false, addr.clone(), None);
|
||||||
|
assert!(!unmuted.is_muted);
|
||||||
|
assert_eq!(unmuted.sharing, None);
|
||||||
|
assert_eq!(unmuted.name, muted.name);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_peer_state_serde_round_trip() {
|
fn test_peer_state_serde_round_trip() {
|
||||||
let original = sample_peer_state();
|
let original = sample_peer_state();
|
||||||
|
|||||||
@@ -26,7 +26,13 @@ pub const FRIENDS_PROTO: u32 = 1;
|
|||||||
/// v2 (0.3.0): `GossipMessage::Chat` gained an optional file attachment
|
/// v2 (0.3.0): `GossipMessage::Chat` gained an optional file attachment
|
||||||
/// (`ChatAttachment`), so a pre-v2 peer can't interpret/serve chat files — bumped
|
/// (`ChatAttachment`), so a pre-v2 peer can't interpret/serve chat files — bumped
|
||||||
/// to fail fast rather than half-work.
|
/// to fail fast rather than half-work.
|
||||||
pub const GOSSIP_PROTO: u32 = 2;
|
///
|
||||||
|
/// v3 (0.4.0): `PeerState` gained an optional `game` presence field (the
|
||||||
|
/// `Playing <name>` status). The field is `#[serde(default)]`, so the bump isn't
|
||||||
|
/// strictly required for decoding — but per the versioning discipline a wire-shape
|
||||||
|
/// change is isolated into its own topic + signature domain so v2 and v3 peers
|
||||||
|
/// never share a swarm. Resync everyone, exactly like the W4 avatar bump.
|
||||||
|
pub const GOSSIP_PROTO: u32 = 3;
|
||||||
/// File-transfer plane version (chat attachment request/stream shape). Bump on
|
/// File-transfer plane version (chat attachment request/stream shape). Bump on
|
||||||
/// any change. Mirrored in [`FILES_ALPN`].
|
/// any change. Mirrored in [`FILES_ALPN`].
|
||||||
pub const FILES_PROTO: u32 = 1;
|
pub const FILES_PROTO: u32 = 1;
|
||||||
@@ -41,7 +47,7 @@ pub const FILES_ALPN: &[u8] = b"peerspeak/files/1";
|
|||||||
/// ed25519 gossip signature domain: `peerspeak-gossip-v<GOSSIP_PROTO>`. Carries
|
/// ed25519 gossip signature domain: `peerspeak-gossip-v<GOSSIP_PROTO>`. Carries
|
||||||
/// the gossip protocol version into every signed payload — a version mismatch
|
/// the gossip protocol version into every signed payload — a version mismatch
|
||||||
/// fails verification (cryptographic separation between gossip versions).
|
/// fails verification (cryptographic separation between gossip versions).
|
||||||
pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v2";
|
pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v3";
|
||||||
|
|
||||||
/// Version-namespace a room topic so peers on different gossip protocol versions
|
/// Version-namespace a room topic so peers on different gossip protocol versions
|
||||||
/// derive **different subscription topics from the same ticket** and therefore
|
/// derive **different subscription topics from the same ticket** and therefore
|
||||||
|
|||||||
@@ -27,19 +27,50 @@ fn is_spoofing_format_char(c: char) -> bool {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Max characters kept for a broadcast game-presence label after sanitizing
|
||||||
|
/// (game titles run longer than nicknames, so a wider cap than [`NAME_MAX_CHARS`]),
|
||||||
|
/// bounded additionally by [`GAME_LABEL_MAX_BYTES`] so a multibyte-heavy string
|
||||||
|
/// can't blow the presence frame.
|
||||||
|
pub const GAME_LABEL_MAX_CHARS: usize = 64;
|
||||||
|
/// Max UTF-8 bytes kept for a broadcast game-presence label, applied on top of
|
||||||
|
/// [`GAME_LABEL_MAX_CHARS`]. Caps the on-wire size regardless of scalar width.
|
||||||
|
pub const GAME_LABEL_MAX_BYTES: usize = 256;
|
||||||
|
|
||||||
|
/// Shared cleaning for untrusted short labels: strip bidi / zero-width spoofing
|
||||||
|
/// format characters, turn control characters into spaces, collapse any whitespace
|
||||||
|
/// run to a single space, and trim the ends. Length capping is the caller's job.
|
||||||
|
fn clean_label(input: &str) -> String {
|
||||||
|
let cleaned: String = input
|
||||||
|
.chars()
|
||||||
|
.filter(|c| !is_spoofing_format_char(*c))
|
||||||
|
.map(|c| if c.is_control() { ' ' } else { c })
|
||||||
|
.collect();
|
||||||
|
cleaned.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
/// Sanitize an untrusted peer display name for safe rendering. Strips bidi /
|
/// Sanitize an untrusted peer display name for safe rendering. Strips bidi /
|
||||||
/// zero-width format characters, turns control characters into spaces, collapses
|
/// zero-width format characters, turns control characters into spaces, collapses
|
||||||
/// any whitespace run to a single space, trims the ends, and caps the length at
|
/// any whitespace run to a single space, trims the ends, and caps the length at
|
||||||
/// [`NAME_MAX_CHARS`]. Returns `""` if nothing usable remains (callers may
|
/// [`NAME_MAX_CHARS`]. Returns `""` if nothing usable remains (callers may
|
||||||
/// substitute a placeholder such as a short id).
|
/// substitute a placeholder such as a short id).
|
||||||
pub fn sanitize_name(input: &str) -> String {
|
pub fn sanitize_name(input: &str) -> String {
|
||||||
let cleaned: String = input
|
clean_label(input).chars().take(NAME_MAX_CHARS).collect()
|
||||||
.chars()
|
}
|
||||||
.filter(|c| !is_spoofing_format_char(*c))
|
|
||||||
.map(|c| if c.is_control() { ' ' } else { c })
|
/// Sanitize an untrusted game-presence label (the `Playing <name>` status that
|
||||||
.collect();
|
/// rides the gossip presence plane). Same spoof/control cleaning as
|
||||||
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
|
/// [`sanitize_name`], but capped at [`GAME_LABEL_MAX_CHARS`] scalars AND
|
||||||
collapsed.chars().take(NAME_MAX_CHARS).collect()
|
/// [`GAME_LABEL_MAX_BYTES`] bytes. Apply on BOTH the outgoing label we detect and
|
||||||
|
/// any incoming peer label. Returns `""` if nothing usable remains (no broadcast).
|
||||||
|
pub fn sanitize_game_label(input: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for c in clean_label(input).chars().take(GAME_LABEL_MAX_CHARS) {
|
||||||
|
if out.len() + c.len_utf8() > GAME_LABEL_MAX_BYTES {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A piece of a chat message after URL detection: literal text or a link.
|
/// A piece of a chat message after URL detection: literal text or a link.
|
||||||
@@ -135,6 +166,46 @@ mod tests {
|
|||||||
assert_eq!(sanitize_name(&long).chars().count(), NAME_MAX_CHARS);
|
assert_eq!(sanitize_name(&long).chars().count(), NAME_MAX_CHARS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- sanitize_game_label ----------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_label_keeps_ordinary_titles_and_strips_spoofing() {
|
||||||
|
assert_eq!(sanitize_game_label("Half-Life 2"), "Half-Life 2");
|
||||||
|
// Same spoof/control cleaning as names.
|
||||||
|
assert_eq!(sanitize_game_label("Doom\u{202E}txt"), "Doomtxt");
|
||||||
|
assert_eq!(sanitize_game_label("a\u{0}b\r\nc"), "a b c");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_label_caps_chars_wider_than_names() {
|
||||||
|
// A game label keeps more than a name's 48 (up to 64), so a title between
|
||||||
|
// the two caps survives in full.
|
||||||
|
let mid = "g".repeat(56);
|
||||||
|
assert_eq!(sanitize_game_label(&mid).chars().count(), 56);
|
||||||
|
let long = "g".repeat(GAME_LABEL_MAX_CHARS + 100);
|
||||||
|
assert_eq!(sanitize_game_label(&long).chars().count(), GAME_LABEL_MAX_CHARS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_label_caps_bytes_for_multibyte_titles() {
|
||||||
|
// Each '世' is 3 bytes; 64 of them = 192 bytes (under 256) → all kept.
|
||||||
|
let cjk = "世".repeat(GAME_LABEL_MAX_CHARS);
|
||||||
|
let out = sanitize_game_label(&cjk);
|
||||||
|
assert_eq!(out.chars().count(), GAME_LABEL_MAX_CHARS);
|
||||||
|
assert!(out.len() <= GAME_LABEL_MAX_BYTES);
|
||||||
|
// Emoji are 4 bytes; the byte cap bites before the char cap (256/4 = 64,
|
||||||
|
// but the leading clean keeps them as a run) — never exceeds the byte cap.
|
||||||
|
let emoji = "🎮".repeat(GAME_LABEL_MAX_CHARS);
|
||||||
|
let out = sanitize_game_label(&emoji);
|
||||||
|
assert!(out.len() <= GAME_LABEL_MAX_BYTES);
|
||||||
|
assert!(out.chars().all(|c| c == '🎮'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_label_empty_when_nothing_usable() {
|
||||||
|
assert_eq!(sanitize_game_label("\u{0}\r\n\t "), "");
|
||||||
|
}
|
||||||
|
|
||||||
// --- linkify -----------------------------------------------------------
|
// --- linkify -----------------------------------------------------------
|
||||||
|
|
||||||
/// Concatenating every segment's inner text must reproduce the input exactly.
|
/// Concatenating every segment's inner text must reproduce the input exactly.
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
//! End-to-end loopback test for the chat file-transfer plane (`FILES_ALPN`).
|
||||||
|
//!
|
||||||
|
//! Spins up two real iroh endpoints on localhost (relay disabled, addresses
|
||||||
|
//! exchanged directly), registers the production [`FileRouter`] on each, serves a
|
||||||
|
//! multi-megabyte blob on one side, and fetches it from the other through the
|
||||||
|
//! real `serve_attachment`/`fetch_attachment` path.
|
||||||
|
//!
|
||||||
|
//! This is the regression guard for the "file fetch: read failed: connection
|
||||||
|
//! lost" bug: the serve handler used to return (and drop the connection) the
|
||||||
|
//! instant it called `finish()`, so the CONNECTION_CLOSE raced ahead of the
|
||||||
|
//! still-in-flight stream data and the fetcher's `read_to_end` aborted. A blob
|
||||||
|
//! large enough to span many packets makes that race deterministic — the fix
|
||||||
|
//! (waiting on `connection.closed()` before returning) keeps the link up until
|
||||||
|
//! the fetcher has the bytes.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use iroh::address_lookup::memory::MemoryLookup;
|
||||||
|
use iroh::endpoint::presets;
|
||||||
|
use iroh::protocol::Router;
|
||||||
|
use iroh::{Endpoint, RelayMode};
|
||||||
|
|
||||||
|
use peerspeak::files::{ChatAttachment, AttachmentKind};
|
||||||
|
use peerspeak::network::NetworkTransport;
|
||||||
|
use peerspeak::network::iroh_impl::{FileRouter, IrohTransport};
|
||||||
|
use peerspeak::protocol::FILES_ALPN;
|
||||||
|
|
||||||
|
struct Node {
|
||||||
|
endpoint: Endpoint,
|
||||||
|
transport: Arc<IrohTransport>,
|
||||||
|
_router: Router,
|
||||||
|
lookup: MemoryLookup,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_node() -> Node {
|
||||||
|
let lookup = MemoryLookup::new();
|
||||||
|
let endpoint = Endpoint::builder(presets::Minimal)
|
||||||
|
.secret_key(iroh::SecretKey::generate())
|
||||||
|
.relay_mode(RelayMode::Disabled)
|
||||||
|
.address_lookup(lookup.clone())
|
||||||
|
.bind()
|
||||||
|
.await
|
||||||
|
.expect("bind endpoint");
|
||||||
|
|
||||||
|
let transport = Arc::new(IrohTransport::new(endpoint.clone()));
|
||||||
|
// Mirror production: a persistent FileRouter bound to this session's transport
|
||||||
|
// is what the router accepts inbound file fetches on.
|
||||||
|
let file_router = FileRouter::new();
|
||||||
|
file_router.bind(&transport);
|
||||||
|
let router = Router::builder(endpoint.clone())
|
||||||
|
.accept(FILES_ALPN, file_router)
|
||||||
|
.spawn();
|
||||||
|
|
||||||
|
Node { endpoint, transport, _router: router, lookup }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pseudo-random-ish multi-megabyte payload spanning many QUIC packets, so a
|
||||||
|
/// premature connection close on the serve side reliably corrupts/aborts the read.
|
||||||
|
fn big_blob() -> Vec<u8> {
|
||||||
|
(0..(2 * 1024 * 1024u32))
|
||||||
|
.map(|i| (i.wrapping_mul(2654435761) >> 13) as u8)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn loopback_attachment_round_trips_intact() {
|
||||||
|
let server = spawn_node().await;
|
||||||
|
let client = spawn_node().await;
|
||||||
|
|
||||||
|
// Seed each side with the other's full address so direct dialing works.
|
||||||
|
server.lookup.add_endpoint_info(client.endpoint.addr());
|
||||||
|
client.lookup.add_endpoint_info(server.endpoint.addr());
|
||||||
|
|
||||||
|
let server_id = server.endpoint.id();
|
||||||
|
let client_id = client.endpoint.id();
|
||||||
|
|
||||||
|
// The serve handler gates on room membership (the audio admission roster), so
|
||||||
|
// the server must admit the client before it will answer the fetch.
|
||||||
|
server.transport.admit_audio_sender(client_id);
|
||||||
|
client.transport.admit_audio_sender(server_id);
|
||||||
|
|
||||||
|
// The fetcher dials the retained full address; seed it so fetch_attachment
|
||||||
|
// doesn't have to fall back to a bare-id lookup.
|
||||||
|
client.transport.connect_peer(server.endpoint.addr()).await;
|
||||||
|
|
||||||
|
let blob = big_blob();
|
||||||
|
let id = [42u8; 32];
|
||||||
|
server.transport.serve_attachment(id, Arc::new(blob.clone()));
|
||||||
|
|
||||||
|
let att = ChatAttachment {
|
||||||
|
name: "exterior-landscape.jpg".to_string(),
|
||||||
|
size: blob.len() as u64,
|
||||||
|
kind: AttachmentKind::Image,
|
||||||
|
id,
|
||||||
|
};
|
||||||
|
|
||||||
|
let fetched = tokio::time::timeout(
|
||||||
|
Duration::from_secs(30),
|
||||||
|
client.transport.fetch_attachment(server_id, &att),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("fetch did not time out")
|
||||||
|
.expect("fetch succeeded");
|
||||||
|
|
||||||
|
assert_eq!(fetched.len(), blob.len(), "fetched the full blob");
|
||||||
|
assert_eq!(fetched, blob, "fetched bytes match served bytes exactly");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn loopback_unknown_id_reports_gone() {
|
||||||
|
let server = spawn_node().await;
|
||||||
|
let client = spawn_node().await;
|
||||||
|
|
||||||
|
server.lookup.add_endpoint_info(client.endpoint.addr());
|
||||||
|
client.lookup.add_endpoint_info(server.endpoint.addr());
|
||||||
|
|
||||||
|
let server_id = server.endpoint.id();
|
||||||
|
let client_id = client.endpoint.id();
|
||||||
|
server.transport.admit_audio_sender(client_id);
|
||||||
|
client.transport.admit_audio_sender(server_id);
|
||||||
|
client.transport.connect_peer(server.endpoint.addr()).await;
|
||||||
|
|
||||||
|
// Never served — the handler closes with an empty body and the fetcher must
|
||||||
|
// surface that as an error, not hang or return empty bytes.
|
||||||
|
let att = ChatAttachment {
|
||||||
|
name: "missing.bin".to_string(),
|
||||||
|
size: 4096,
|
||||||
|
kind: AttachmentKind::File,
|
||||||
|
id: [7u8; 32],
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
Duration::from_secs(30),
|
||||||
|
client.transport.fetch_attachment(server_id, &att),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("fetch did not time out");
|
||||||
|
|
||||||
|
assert!(result.is_err(), "unknown id should error, got {result:?}");
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ fn state(name: &str, addr: EndpointAddr) -> PeerState {
|
|||||||
addr,
|
addr,
|
||||||
sharing: None,
|
sharing: None,
|
||||||
avatar: Default::default(),
|
avatar: Default::default(),
|
||||||
|
game: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||