Add configurable capture and OBS status to the panel
This commit is contained in:
1 parent
3d08d64c41
commit
e03d491353
22 files changed
+1692
-67
No files matched your search
Generated
+124
-6
@@ -621,7 +621,7 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"constant_time_eq",
|
"constant_time_eq",
|
||||||
"cpufeatures",
|
"cpufeatures 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -630,6 +630,15 @@ version = "0.1.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -833,7 +842,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.3.1",
|
||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1213,6 +1222,15 @@ dependencies = [
|
|||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -1292,6 +1310,16 @@ dependencies = [
|
|||||||
"wgpu",
|
"wgpu",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -1369,6 +1397,12 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "data-url"
|
name = "data-url"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -1435,15 +1469,25 @@ dependencies = [
|
|||||||
"syn 3.0.6",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer 0.10.4",
|
||||||
|
"crypto-common 0.1.7",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.11.3"
|
version = "0.11.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer 0.12.1",
|
||||||
"const-oid",
|
"const-oid",
|
||||||
"crypto-common",
|
"crypto-common 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1960,8 +2004,10 @@ dependencies = [
|
|||||||
"pipewire",
|
"pipewire",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-tungstenite",
|
||||||
"ureq",
|
"ureq",
|
||||||
"url",
|
"url",
|
||||||
"wayland-client",
|
"wayland-client",
|
||||||
@@ -2428,6 +2474,16 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gethostname"
|
name = "gethostname"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -2866,6 +2922,22 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
|
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"itoa",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httparse"
|
||||||
|
version = "1.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hybrid-array"
|
name = "hybrid-array"
|
||||||
version = "0.4.15"
|
version = "0.4.15"
|
||||||
@@ -5725,6 +5797,17 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.2.17",
|
||||||
|
"digest 0.10.7",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
@@ -5732,8 +5815,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.3.1",
|
||||||
"digest",
|
"digest 0.11.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6386,6 +6469,18 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-tungstenite"
|
||||||
|
version = "0.28.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"log",
|
||||||
|
"tokio",
|
||||||
|
"tungstenite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.5.11"
|
version = "0.5.11"
|
||||||
@@ -6530,6 +6625,23 @@ dependencies = [
|
|||||||
"core_maths",
|
"core_maths",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tungstenite"
|
||||||
|
version = "0.28.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"data-encoding",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
"rand 0.9.5",
|
||||||
|
"sha1",
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
"utf-8",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "type-map"
|
name = "type-map"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -6741,6 +6853,12 @@ dependencies = [
|
|||||||
"xmlwriter",
|
"xmlwriter",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf-8"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const SERVICE: &str = "org.freedesktop.impl.portal.desktop.ferese";
|
||||||
|
pub const PATH: &str = "/org/ferese/CaptureStatus";
|
||||||
|
pub const INTERFACE: &str = "org.ferese.CaptureStatus";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Session {
|
||||||
|
pub app: String,
|
||||||
|
pub sources: Vec<String>,
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Framed IPC messages and the services exposed over compositor connections.
|
//! Framed IPC messages and the services exposed over compositor connections.
|
||||||
pub mod activities;
|
pub mod activities;
|
||||||
|
pub mod capture;
|
||||||
mod connection;
|
mod connection;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
mod frame;
|
mod frame;
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ pub enum ItemKind {
|
|||||||
Network,
|
Network,
|
||||||
Audio,
|
Audio,
|
||||||
Recording,
|
Recording,
|
||||||
|
CaptureStatus,
|
||||||
Notifications,
|
Notifications,
|
||||||
Battery {
|
Battery {
|
||||||
#[serde(default = "yes")]
|
#[serde(default = "yes")]
|
||||||
@@ -161,6 +162,7 @@ impl ItemKind {
|
|||||||
Self::Network => "Network",
|
Self::Network => "Network",
|
||||||
Self::Audio => "Audio",
|
Self::Audio => "Audio",
|
||||||
Self::Recording => "Recording",
|
Self::Recording => "Recording",
|
||||||
|
Self::CaptureStatus => "Capture status",
|
||||||
Self::Notifications => "Notifications",
|
Self::Notifications => "Notifications",
|
||||||
Self::Battery { .. } => "Battery",
|
Self::Battery { .. } => "Battery",
|
||||||
Self::Clock => "Clock",
|
Self::Clock => "Clock",
|
||||||
@@ -172,7 +174,9 @@ impl ItemKind {
|
|||||||
pub fn representations(self) -> &'static [Representation] {
|
pub fn representations(self) -> &'static [Representation] {
|
||||||
use Representation::*;
|
use Representation::*;
|
||||||
match self {
|
match self {
|
||||||
Self::Activities | Self::Media | Self::Clock => &[Wide, Compact, Icon],
|
Self::Activities | Self::Media | Self::Clock | Self::CaptureStatus => {
|
||||||
|
&[Wide, Compact, Icon]
|
||||||
|
}
|
||||||
Self::Battery { percentage: true } => &[Wide, Icon],
|
Self::Battery { percentage: true } => &[Wide, Icon],
|
||||||
Self::Workspaces { .. } | Self::FocusedWindow => &[Wide],
|
Self::Workspaces { .. } | Self::FocusedWindow => &[Wide],
|
||||||
_ => &[Icon],
|
_ => &[Icon],
|
||||||
@@ -321,6 +325,7 @@ impl Item {
|
|||||||
match self.kind {
|
match self.kind {
|
||||||
ItemKind::Activities => availability.activities,
|
ItemKind::Activities => availability.activities,
|
||||||
ItemKind::Media => availability.media,
|
ItemKind::Media => availability.media,
|
||||||
|
ItemKind::CaptureStatus => availability.capture,
|
||||||
ItemKind::Network => availability.network,
|
ItemKind::Network => availability.network,
|
||||||
ItemKind::Audio => availability.audio,
|
ItemKind::Audio => availability.audio,
|
||||||
ItemKind::Notifications => availability.notifications,
|
ItemKind::Notifications => availability.notifications,
|
||||||
@@ -333,6 +338,7 @@ impl Item {
|
|||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default)]
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
pub struct Availability {
|
pub struct Availability {
|
||||||
|
pub capture: bool,
|
||||||
pub media: bool,
|
pub media: bool,
|
||||||
pub activities: bool,
|
pub activities: bool,
|
||||||
pub network: bool,
|
pub network: bool,
|
||||||
@@ -698,6 +704,7 @@ impl Panel {
|
|||||||
item("network", Network),
|
item("network", Network),
|
||||||
item("audio", Audio),
|
item("audio", Audio),
|
||||||
item("recording", Recording),
|
item("recording", Recording),
|
||||||
|
item("capture-status", CaptureStatus),
|
||||||
item("notifications", Notifications),
|
item("notifications", Notifications),
|
||||||
item("battery", Battery { percentage: true }),
|
item("battery", Battery { percentage: true }),
|
||||||
];
|
];
|
||||||
@@ -817,6 +824,7 @@ mod tests {
|
|||||||
"network",
|
"network",
|
||||||
"audio",
|
"audio",
|
||||||
"recording",
|
"recording",
|
||||||
|
"capture-status",
|
||||||
"notifications",
|
"notifications",
|
||||||
"battery",
|
"battery",
|
||||||
"clock",
|
"clock",
|
||||||
@@ -858,6 +866,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
visible(Availability {
|
visible(Availability {
|
||||||
|
capture: true,
|
||||||
activities: true,
|
activities: true,
|
||||||
media: true,
|
media: true,
|
||||||
network: true,
|
network: true,
|
||||||
@@ -870,7 +879,7 @@ mod tests {
|
|||||||
.map(|item| item.id.0.as_str())
|
.map(|item| item.id.0.as_str())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
);
|
);
|
||||||
assert_eq!(items(&panel).len(), 13);
|
assert_eq!(items(&panel).len(), 14);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 993 993" role="img" aria-label="OBS Studio">
|
||||||
|
<!-- OBS Studio logomark, from the OBS Studio application icon; monochrome adaptation. -->
|
||||||
|
<path fill="currentColor" d="M666.9,350.5 c-54.8,95.4-176.6,128.4-272,73.6c-1.8-1-3.5-2.1-5.2-3.1c-16.7-10.3-31.9-23.1-44.9-37.8c-34.4-38.8-52.3-89.4-49.9-141.1 c0.3-5.8,0.8-11.6,1.5-17.3c0.7-5.7,1.7-11.3,3-16.9c1.3-5.7,2.8-11.4,4.5-17c1.7-5.4,3.7-10.8,5.9-16.1c2.3-5.5,4.8-10.9,7.5-16.1 c2.9-5.5,6.1-10.9,9.5-16.2c3.1-4.8,6.5-9.5,10-14.1c3.9-5,8-9.7,12.3-14.3c4.4-4.6,8.9-9,13.7-13.2c4.8-4.3,9.9-8.2,15.1-12 c2.7-1.9,5.4-3.8,8.2-5.6C268,143.7,219.6,287.3,277.1,406.9c0.5,1.1,1.1,2.3,1.7,3.4c0.3,0.5,0.5,1,0.8,1.5 c0.1,0.3,0.3,0.6,0.5,0.9c0.3,0.4,0.4,0.3,0.9,0.3c2.6,0,5.2,0,7.7,0c4.8,0.1,9.5,0.4,14.3,0.8c90.3,8.5,163.5,76.9,178.2,166.4 c3.2,19.9,3.4,40.2,0.6,60.2c-5.3,37.7-21.3,73-46.2,101.9c-14.2,16.5-31,30.6-49.8,41.8c-37.4,22.1-81,31.4-124.2,26.5 c-7.8-0.9-15.6-2.2-23.2-4c-5-1.2-9.9-2.5-14.7-4.1c21.8,9.7,44.9,16.1,68.5,19c22.1,2.7,44.4,2.5,66.4-0.8 c39.3-5.9,76.7-21.3,108.7-44.9c23.5-17.3,43.7-38.7,59.7-63.1c0.7-1.1,1.4-2.1,2.1-3.2c0.3-0.4,0.9-1.1,0.8-1.5 c-0.3-0.7-0.6-1.3-1.1-1.9c-1.3-2.5-2.6-5-3.8-7.6c-2.5-5.1-4.7-10.4-6.7-15.7c-4.1-10.8-7.2-22-9.3-33.4c-4.6-24.5-4.6-49.5,0-74 c3.9-20.8,11.1-40.8,21.4-59.3c34.8-63.2,101-102.6,173.1-103.1c4.9,0,9.8,0.1,14.7,0.4c4.7,0.3,9.4,0.8,14.1,1.4 c4.6,0.6,9.2,1.4,13.8,2.4c4.5,0.9,8.9,2,13.4,3.3c4.3,1.2,8.6,2.6,12.8,4.1c4.3,1.5,8.5,3.2,12.7,5.1c4.3,1.9,8.4,3.9,12.6,6 c4.1,2.2,8.1,4.5,12.1,6.9c3.7,2.3,7.4,4.7,10.9,7.3c3.7,2.6,7.2,5.4,10.7,8.2c3.6,2.9,7.1,6,10.5,9.2c3.6,3.4,7.1,6.9,10.4,10.6 c3.3,3.6,6.5,7.4,9.6,11.3c3.4,4.3,6.6,8.8,9.7,13.4c3.1,4.7,6,9.6,8.7,14.5c3.8,6.9,7.1,14.1,10.1,21.4c3.2,8,5.8,16.1,8,24.4 c-5.8-44.3-23.7-86.1-51.7-120.9c-20.6-25.6-46.1-46.9-75-62.5c-29.7-16-62.3-25.6-95.9-28.3C672.6,350.8,669.8,350.6,666.9,350.5z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -74,6 +74,7 @@ pub const RECORD_CANCEL: &[u8] = include_bytes!("../assets/icons/status/record-c
|
|||||||
pub const RECORD_SAVING: &[u8] = include_bytes!("../assets/icons/status/record-saving.svg");
|
pub const RECORD_SAVING: &[u8] = include_bytes!("../assets/icons/status/record-saving.svg");
|
||||||
pub const RECORD_STOP: &[u8] = include_bytes!("../assets/icons/status/record-stop.svg");
|
pub const RECORD_STOP: &[u8] = include_bytes!("../assets/icons/status/record-stop.svg");
|
||||||
pub const RECORD: &[u8] = include_bytes!("../assets/icons/status/record.svg");
|
pub const RECORD: &[u8] = include_bytes!("../assets/icons/status/record.svg");
|
||||||
|
pub const OBS: &[u8] = include_bytes!("../assets/icons/status/obs.svg");
|
||||||
pub const RESTART: &[u8] = include_bytes!("../assets/icons/status/restart.svg");
|
pub const RESTART: &[u8] = include_bytes!("../assets/icons/status/restart.svg");
|
||||||
pub const SETTINGS: &[u8] = include_bytes!("../assets/icons/status/settings.svg");
|
pub const SETTINGS: &[u8] = include_bytes!("../assets/icons/status/settings.svg");
|
||||||
pub const SUSPEND: &[u8] = include_bytes!("../assets/icons/status/suspend.svg");
|
pub const SUSPEND: &[u8] = include_bytes!("../assets/icons/status/suspend.svg");
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ disappears when that output has no window title to show.
|
|||||||
| Network | `network` | Opens Wi-Fi controls |
|
| Network | `network` | Opens Wi-Fi controls |
|
||||||
| Audio | `audio` | Opens audio controls |
|
| Audio | `audio` | Opens audio controls |
|
||||||
| Recording | `recording` | Opens recording controls or stops the active recording |
|
| Recording | `recording` | Opens recording controls or stops the active recording |
|
||||||
|
| Capture status | `capture-status` | Shows screen sharing and optional OBS output status. Supports Full, Compact and Icon. |
|
||||||
| Notifications | `notifications` | Opens notifications |
|
| Notifications | `notifications` | Opens notifications |
|
||||||
| Battery | `battery` | `percentage`: boolean, default `#true` |
|
| Battery | `battery` | `percentage`: boolean, default `#true` |
|
||||||
| Clock | `clock` | Full, Compact and Icon representations |
|
| Clock | `clock` | Full, Compact and Icon representations |
|
||||||
|
|||||||
@@ -23,6 +23,44 @@ If recording fails, a notification reports the error and any unfinished
|
|||||||
|
|
||||||
## Start sharing
|
## Start sharing
|
||||||
|
|
||||||
|
### Panel status
|
||||||
|
|
||||||
|
Add **Capture status** in Settings → Panel & Shell → Edit panel → Add item.
|
||||||
|
You can move it between groups, hide it, or choose Full, Compact or Icon.
|
||||||
|
New panel presets include it. Add it yourself if you use a saved layout.
|
||||||
|
|
||||||
|
The item appears when an app starts capturing a source you've approved through
|
||||||
|
the desktop portal. Click it to see the application and shared sources.
|
||||||
|
OBS preview capture shows **Sharing**, even when OBS is not broadcasting.
|
||||||
|
Ferese's built-in recorder keeps its separate Recording item. In Morph, capture
|
||||||
|
status appears in the hub when the Capture status item is enabled.
|
||||||
|
|
||||||
|
OBS has a monochrome app icon. Other capture apps use the sharing icon, and both
|
||||||
|
follow the active panel theme. The red dot means an output is live or recording.
|
||||||
|
Sharing, paused recording, and reconnecting alone do not show that dot.
|
||||||
|
Choose Full for elapsed time, Compact for status text, or Icon for just the icon
|
||||||
|
and status dot. Click the item for separate stream and recording times.
|
||||||
|
|
||||||
|
To show OBS **LIVE**, **REC**, paused recording, and reconnecting states, enable
|
||||||
|
OBS's WebSocket server under Tools → WebSocket Server Settings, then create
|
||||||
|
`$XDG_CONFIG_HOME/ferese/obs.json` (normally `~/.config/ferese/obs.json`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"enabled": true, "port": 4455, "password": "your OBS WebSocket password"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Set the file's permissions to `600` and restart the shell. Ferese connects only
|
||||||
|
to `127.0.0.1`, reads output status, and never starts or stops OBS outputs.
|
||||||
|
It connects only if this configuration exists and is enabled. If OBS disconnects,
|
||||||
|
its output status clears. Any portal sharing session stays visible.
|
||||||
|
|
||||||
|
Ferese updates capture and OBS status when it receives an event. Elapsed time
|
||||||
|
uses a local one-second timer while a running output's indicator can be shown.
|
||||||
|
Sharing and paused-only recording need no timer. If configured OBS is unavailable,
|
||||||
|
connection retries back off to once per minute.
|
||||||
|
|
||||||
|
### Choose a source
|
||||||
|
|
||||||
Select a Wayland screen-sharing source in your application. In Ferese's picker,
|
Select a Wayland screen-sharing source in your application. In Ferese's picker,
|
||||||
choose a display, workspace, or window and click **Share**. Nothing is selected initially.
|
choose a display, workspace, or window and click **Share**. Nothing is selected initially.
|
||||||
Apps requesting multiple sources can select up to eight.
|
Apps requesting multiple sources can select up to eight.
|
||||||
|
|||||||
+124
-10
@@ -79,6 +79,7 @@ struct Session {
|
|||||||
source_types: u32,
|
source_types: u32,
|
||||||
bar_controlled: bool,
|
bar_controlled: bool,
|
||||||
recording: Option<crate::recording::Recording>,
|
recording: Option<crate::recording::Recording>,
|
||||||
|
sources: Vec<String>,
|
||||||
persist_mode: u32,
|
persist_mode: u32,
|
||||||
restore: Option<crate::restore::Restore>,
|
restore: Option<crate::restore::Restore>,
|
||||||
cancel: Arc<Cancel>,
|
cancel: Arc<Cancel>,
|
||||||
@@ -100,6 +101,35 @@ impl Default for Backend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This interface reports approved capture sessions, not broadcast status.
|
||||||
|
struct CaptureStatus(Backend);
|
||||||
|
|
||||||
|
#[zbus::interface(name = "org.ferese.CaptureStatus")]
|
||||||
|
impl CaptureStatus {
|
||||||
|
async fn get_snapshot(&self) -> String {
|
||||||
|
let sessions = self.0.sessions.lock().await;
|
||||||
|
let mut entries: Vec<_> = sessions
|
||||||
|
.values()
|
||||||
|
.filter(|session| session.phase == Phase::Running && !session.bar_controlled)
|
||||||
|
.map(|session| ferese_ipc::capture::Session {
|
||||||
|
app: session.app.clone(),
|
||||||
|
sources: session.sources.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
entries.sort_by(|a, b| (&a.app, &a.sources).cmp(&(&b.app, &b.sources)));
|
||||||
|
serde_json::to_string(&entries).expect("capture status is serializable")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[zbus(signal)]
|
||||||
|
async fn changed(emitter: &SignalEmitter<'_>) -> zbus::Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn capture_status_changed(connection: &Connection) {
|
||||||
|
if let Ok(emitter) = SignalEmitter::new(connection, ferese_ipc::capture::PATH) {
|
||||||
|
let _ = CaptureStatus::changed(&emitter).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// This private interface only transfers stop controls to the shell's own recorder.
|
// This private interface only transfers stop controls to the shell's own recorder.
|
||||||
// The ordinary portal picker and all session revocation rules still apply.
|
// The ordinary portal picker and all session revocation rules still apply.
|
||||||
struct RecorderControl(Backend);
|
struct RecorderControl(Backend);
|
||||||
@@ -262,6 +292,9 @@ impl Backend {
|
|||||||
let session = self.sessions.lock().await.remove(path);
|
let session = self.sessions.lock().await.remove(path);
|
||||||
if let Some(session) = session {
|
if let Some(session) = session {
|
||||||
session.cancel.stop();
|
session.cancel.stop();
|
||||||
|
if session.phase == Phase::Running && !session.bar_controlled {
|
||||||
|
capture_status_changed(connection).await;
|
||||||
|
}
|
||||||
if let Ok(emitter) = SignalEmitter::new(connection, path) {
|
if let Ok(emitter) = SignalEmitter::new(connection, path) {
|
||||||
let _ = SessionObject::closed(&emitter).await;
|
let _ = SessionObject::closed(&emitter).await;
|
||||||
}
|
}
|
||||||
@@ -346,6 +379,7 @@ impl Backend {
|
|||||||
source_types: 1,
|
source_types: 1,
|
||||||
bar_controlled: false,
|
bar_controlled: false,
|
||||||
recording: None,
|
recording: None,
|
||||||
|
sources: Vec::new(),
|
||||||
persist_mode: 0,
|
persist_mode: 0,
|
||||||
restore: None,
|
restore: None,
|
||||||
cancel: Arc::default(),
|
cancel: Arc::default(),
|
||||||
@@ -493,12 +527,16 @@ impl Backend {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok((children, streams, mut reply, recording))
|
Ok((children, streams, mut reply, recording, sources))
|
||||||
if !cancel.stopped.load(Ordering::SeqCst) =>
|
if !cancel.stopped.load(Ordering::SeqCst) =>
|
||||||
{
|
{
|
||||||
if let Some(session) = self.sessions.lock().await.get_mut(session_handle.as_str()) {
|
if let Some(session) = self.sessions.lock().await.get_mut(session_handle.as_str()) {
|
||||||
session.phase = Phase::Running;
|
session.phase = Phase::Running;
|
||||||
session.recording = recording;
|
session.recording = recording;
|
||||||
|
session.sources = sources;
|
||||||
|
}
|
||||||
|
if !bar_controlled {
|
||||||
|
capture_status_changed(connection).await;
|
||||||
}
|
}
|
||||||
let backend = self.clone();
|
let backend = self.clone();
|
||||||
let connection = connection.clone();
|
let connection = connection.clone();
|
||||||
@@ -680,6 +718,7 @@ async fn start_streams(
|
|||||||
Vec<(u32, Options)>,
|
Vec<(u32, Options)>,
|
||||||
Options,
|
Options,
|
||||||
Option<crate::recording::Recording>,
|
Option<crate::recording::Recording>,
|
||||||
|
Vec<String>,
|
||||||
),
|
),
|
||||||
StartError,
|
StartError,
|
||||||
> {
|
> {
|
||||||
@@ -1000,7 +1039,18 @@ async fn start_streams(
|
|||||||
})
|
})
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
Ok((children, streams, reply, recording))
|
let labels = selected
|
||||||
|
.iter()
|
||||||
|
.map(|source| {
|
||||||
|
source
|
||||||
|
.label
|
||||||
|
.chars()
|
||||||
|
.filter(|c| !c.is_control())
|
||||||
|
.take(160)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok((children, streams, reply, recording, labels))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn window_metadata(source: &Source, ready: &Ready) -> Result<Options, String> {
|
fn window_metadata(source: &Source, ready: &Ready) -> Result<Options, String> {
|
||||||
@@ -1123,15 +1173,13 @@ fn validate_selection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn supervise(mut children: Vec<Child>, cancel: Arc<Cancel>) {
|
async fn supervise(mut children: Vec<Child>, cancel: Arc<Cancel>) {
|
||||||
loop {
|
{
|
||||||
if cancel.stopped.load(Ordering::SeqCst)
|
use futures_util::{StreamExt, stream::FuturesUnordered};
|
||||||
|| children
|
let mut exits: FuturesUnordered<_> = children.iter_mut().map(Child::wait).collect();
|
||||||
.iter_mut()
|
tokio::select! {
|
||||||
.any(|c| !matches!(c.try_wait(), Ok(None)))
|
_ = cancel.wait() => (),
|
||||||
{
|
_ = exits.next() => (),
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel.stop();
|
cancel.stop();
|
||||||
@@ -1153,6 +1201,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let connection = zbus::connection::Builder::session()?
|
let connection = zbus::connection::Builder::session()?
|
||||||
.name("org.freedesktop.impl.portal.desktop.ferese")?
|
.name("org.freedesktop.impl.portal.desktop.ferese")?
|
||||||
.serve_at(PATH, backend.clone())?
|
.serve_at(PATH, backend.clone())?
|
||||||
|
.serve_at(ferese_ipc::capture::PATH, CaptureStatus(backend.clone()))?
|
||||||
.serve_at(PATH, settings.clone())?
|
.serve_at(PATH, settings.clone())?
|
||||||
.serve_at(PATH, crate::desktop::Screenshot(requests.clone()))?
|
.serve_at(PATH, crate::desktop::Screenshot(requests.clone()))?
|
||||||
.serve_at(PATH, crate::desktop::Wallpaper(requests.clone()))?
|
.serve_at(PATH, crate::desktop::Wallpaper(requests.clone()))?
|
||||||
@@ -1215,6 +1264,71 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn capture_status_includes_only_running_external_sessions() {
|
||||||
|
let backend = Backend::default();
|
||||||
|
for (index, phase, bar_controlled) in [
|
||||||
|
(0, Phase::Created, false),
|
||||||
|
(1, Phase::Starting, false),
|
||||||
|
(2, Phase::Running, false),
|
||||||
|
(3, Phase::Running, true),
|
||||||
|
] {
|
||||||
|
backend.sessions.lock().await.insert(
|
||||||
|
index.to_string(),
|
||||||
|
Session {
|
||||||
|
owner: ":1.1".into(),
|
||||||
|
app: "com.obsproject.Studio".into(),
|
||||||
|
phase,
|
||||||
|
multiple: false,
|
||||||
|
cursor: false,
|
||||||
|
source_types: 1,
|
||||||
|
bar_controlled,
|
||||||
|
recording: None,
|
||||||
|
sources: vec!["Workspace 1".into()],
|
||||||
|
persist_mode: 0,
|
||||||
|
restore: None,
|
||||||
|
cancel: Arc::default(),
|
||||||
|
work: Arc::new(backend.work.clone().try_acquire_owned().unwrap()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let status = CaptureStatus(backend.clone());
|
||||||
|
let entries: Vec<ferese_ipc::capture::Session> =
|
||||||
|
serde_json::from_str(&status.get_snapshot().await).unwrap();
|
||||||
|
assert_eq!(entries.len(), 1);
|
||||||
|
assert_eq!(entries[0].app, "com.obsproject.Studio");
|
||||||
|
assert_eq!(entries[0].sources, ["Workspace 1"]);
|
||||||
|
backend.sessions.lock().await.clear();
|
||||||
|
assert_eq!(status.get_snapshot().await, "[]");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn worker_exit_or_cancel_reaps_every_capture_child() {
|
||||||
|
for cancel_first in [false, true] {
|
||||||
|
let mut children = Vec::new();
|
||||||
|
children.push(
|
||||||
|
Command::new("sleep")
|
||||||
|
.arg("60")
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.spawn()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
if !cancel_first {
|
||||||
|
children.push(Command::new("true").kill_on_drop(true).spawn().unwrap());
|
||||||
|
}
|
||||||
|
let pid = children[0].id().unwrap();
|
||||||
|
let cancel = Arc::new(Cancel::default());
|
||||||
|
if cancel_first {
|
||||||
|
cancel.stop();
|
||||||
|
}
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), supervise(children, cancel.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(cancel.stopped.load(Ordering::SeqCst));
|
||||||
|
assert!(!std::path::Path::new(&format!("/proc/{pid}")).exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn cancelled_async_work_holds_session_capacity_until_worker_exits() {
|
async fn cancelled_async_work_holds_session_capacity_until_worker_exits() {
|
||||||
let backend = Backend::default();
|
let backend = Backend::default();
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ impl std::fmt::Display for StyleChoice {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATALOG: [CatalogItem; 14] = [
|
const CATALOG: [CatalogItem; 15] = [
|
||||||
CatalogItem(ItemKind::Overview),
|
CatalogItem(ItemKind::Overview),
|
||||||
CatalogItem(ItemKind::Workspaces {
|
CatalogItem(ItemKind::Workspaces {
|
||||||
style: ferese_panel::WorkspaceStyle::Dots,
|
style: ferese_panel::WorkspaceStyle::Dots,
|
||||||
@@ -48,6 +48,7 @@ const CATALOG: [CatalogItem; 14] = [
|
|||||||
CatalogItem(ItemKind::Network),
|
CatalogItem(ItemKind::Network),
|
||||||
CatalogItem(ItemKind::Audio),
|
CatalogItem(ItemKind::Audio),
|
||||||
CatalogItem(ItemKind::Recording),
|
CatalogItem(ItemKind::Recording),
|
||||||
|
CatalogItem(ItemKind::CaptureStatus),
|
||||||
CatalogItem(ItemKind::Notifications),
|
CatalogItem(ItemKind::Notifications),
|
||||||
CatalogItem(ItemKind::Battery { percentage: true }),
|
CatalogItem(ItemKind::Battery { percentage: true }),
|
||||||
CatalogItem(ItemKind::Clock),
|
CatalogItem(ItemKind::Clock),
|
||||||
@@ -960,6 +961,7 @@ pub(super) fn item_icon(kind: ItemKind, tint: cosmic::iced::Color) -> widget::ic
|
|||||||
ItemKind::Network => icons::WIFI_FULL,
|
ItemKind::Network => icons::WIFI_FULL,
|
||||||
ItemKind::Audio => icons::VOLUME_HIGH,
|
ItemKind::Audio => icons::VOLUME_HIGH,
|
||||||
ItemKind::Recording => icons::RECORD,
|
ItemKind::Recording => icons::RECORD,
|
||||||
|
ItemKind::CaptureStatus => icons::DISPLAY,
|
||||||
ItemKind::Notifications => icons::NOTIFICATIONS,
|
ItemKind::Notifications => icons::NOTIFICATIONS,
|
||||||
ItemKind::Battery { .. } => icons::BATTERY_75,
|
ItemKind::Battery { .. } => icons::BATTERY_75,
|
||||||
ItemKind::Clock => icons::CALENDAR,
|
ItemKind::Clock => icons::CALENDAR,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use ferese_theme::panel::{Sample, Spacing, frame};
|
|||||||
use crate::{App, Element, Message, panel_controls, visuals};
|
use crate::{App, Element, Message, panel_controls, visuals};
|
||||||
|
|
||||||
const SAMPLE_AVAILABILITY: Availability = Availability {
|
const SAMPLE_AVAILABILITY: Availability = Availability {
|
||||||
|
capture: true,
|
||||||
activities: true,
|
activities: true,
|
||||||
media: true,
|
media: true,
|
||||||
network: true,
|
network: true,
|
||||||
@@ -407,6 +408,7 @@ impl App {
|
|||||||
ItemKind::Network => icons::WIFI_FULL,
|
ItemKind::Network => icons::WIFI_FULL,
|
||||||
ItemKind::Audio => icons::VOLUME_HIGH,
|
ItemKind::Audio => icons::VOLUME_HIGH,
|
||||||
ItemKind::Recording => icons::RECORD,
|
ItemKind::Recording => icons::RECORD,
|
||||||
|
ItemKind::CaptureStatus => icons::DISPLAY,
|
||||||
ItemKind::Notifications => icons::NOTIFICATIONS,
|
ItemKind::Notifications => icons::NOTIFICATIONS,
|
||||||
ItemKind::Battery { .. } => icons::BATTERY_75,
|
ItemKind::Battery { .. } => icons::BATTERY_75,
|
||||||
ItemKind::Clock => icons::CALENDAR,
|
ItemKind::Clock => icons::CALENDAR,
|
||||||
|
|||||||
@@ -29,13 +29,15 @@ iced_accessibility.workspace = true
|
|||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
tokio = { version = "1", features = ["time", "rt", "sync"] }
|
tokio = { version = "1", features = ["time", "rt", "sync", "net", "macros"] }
|
||||||
wayland-client.workspace = true
|
wayland-client.workspace = true
|
||||||
zbus = "5.19.0"
|
zbus = "5.19.0"
|
||||||
pipewire = { version = "0.10.1", features = ["v0_3_65"] }
|
pipewire = { version = "0.10.1", features = ["v0_3_65"] }
|
||||||
image-codec = { package = "image", version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
|
image-codec = { package = "image", version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||||
url = "2.5.8"
|
url = "2.5.8"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
|
sha2 = "0.11"
|
||||||
|
tokio-tungstenite = { version = "0.28", default-features = false, features = ["connect"] }
|
||||||
ureq = { version = "2.12.1", default-features = false, features = ["tls"] }
|
ureq = { version = "2.12.1", default-features = false, features = ["tls"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ impl FereseShell {
|
|||||||
let effects = output.and_then(|output| output.effects.as_ref());
|
let effects = output.and_then(|output| output.effects.as_ref());
|
||||||
let compositor_material = effects.is_some();
|
let compositor_material = effects.is_some();
|
||||||
let availability = Availability {
|
let availability = Availability {
|
||||||
|
capture: self.capture_status.active(),
|
||||||
activities: self.activity_available(id),
|
activities: self.activity_available(id),
|
||||||
media: self.media.snapshot.selected.is_some(),
|
media: self.media.snapshot.selected.is_some(),
|
||||||
network: self.status.network.is_some(),
|
network: self.status.network.is_some(),
|
||||||
@@ -488,6 +489,7 @@ impl FereseShell {
|
|||||||
}
|
}
|
||||||
ItemKind::Network => self.view_status_item(status_ui::Menu::Network, false, selected),
|
ItemKind::Network => self.view_status_item(status_ui::Menu::Network, false, selected),
|
||||||
ItemKind::Audio => self.view_status_item(status_ui::Menu::Audio, false, selected),
|
ItemKind::Audio => self.view_status_item(status_ui::Menu::Audio, false, selected),
|
||||||
|
ItemKind::CaptureStatus => self.view_capture_status(representation, selected),
|
||||||
ItemKind::Recording => {
|
ItemKind::Recording => {
|
||||||
self.view_status_item(status_ui::Menu::Recording, false, selected)
|
self.view_status_item(status_ui::Menu::Recording, false, selected)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use cosmic::widget::{Space, container, row};
|
|||||||
use cosmic::{Element, theme};
|
use cosmic::{Element, theme};
|
||||||
|
|
||||||
const BODY_WIDTH: f32 = 28.;
|
const BODY_WIDTH: f32 = 28.;
|
||||||
|
pub(crate) const WIDTH: f32 = BODY_WIDTH + 1. + 1.5;
|
||||||
|
|
||||||
impl FereseShell {
|
impl FereseShell {
|
||||||
pub(crate) fn battery_indicator(
|
pub(crate) fn battery_indicator(
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
mod obs;
|
||||||
|
mod portal;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use cosmic::iced::Subscription;
|
||||||
|
use ferese_ipc::capture::Session;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(crate) enum Event {
|
||||||
|
Sharing(Vec<Session>),
|
||||||
|
Obs(obs::Snapshot),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct Model {
|
||||||
|
pub sessions: Vec<Session>,
|
||||||
|
pub obs: obs::Snapshot,
|
||||||
|
config: Option<Arc<obs::Config>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Model {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
config: obs::Config::load().map(Arc::new),
|
||||||
|
..Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subscription(&self) -> Subscription<Event> {
|
||||||
|
Subscription::batch([
|
||||||
|
Subscription::run(portal::stream),
|
||||||
|
self.config
|
||||||
|
.clone()
|
||||||
|
.map_or_else(Subscription::none, |config| {
|
||||||
|
Subscription::run_with(config, obs::stream)
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tick_subscription(&self, visible: bool) -> Subscription<()> {
|
||||||
|
if visible && self.obs.ticking() {
|
||||||
|
cosmic::iced::time::every(Duration::from_secs(1)).map(|_| ())
|
||||||
|
} else {
|
||||||
|
Subscription::none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply(&mut self, event: Event) {
|
||||||
|
match event {
|
||||||
|
Event::Sharing(sessions) => self.sessions = sessions,
|
||||||
|
Event::Obs(snapshot) => self.obs = snapshot,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active(&self) -> bool {
|
||||||
|
!self.sessions.is_empty() || self.obs.active()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(&self) -> Option<String> {
|
||||||
|
if let Some(label) = self.obs.label() {
|
||||||
|
return Some(label);
|
||||||
|
}
|
||||||
|
let first = self.sessions.first()?;
|
||||||
|
let app = app_name(&first.app);
|
||||||
|
Some(if self.sessions.len() == 1 {
|
||||||
|
format!("{app} · Sharing")
|
||||||
|
} else {
|
||||||
|
format!("{app} +{} · Sharing", self.sessions.len() - 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn details(&self) -> Vec<String> {
|
||||||
|
let mut details = self.obs.details();
|
||||||
|
for session in &self.sessions {
|
||||||
|
details.push(format!("{} · Sharing", app_name(&session.app)));
|
||||||
|
details.extend(session.sources.iter().cloned());
|
||||||
|
}
|
||||||
|
details
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn icon(&self) -> &'static [u8] {
|
||||||
|
if self.obs.active()
|
||||||
|
|| self
|
||||||
|
.sessions
|
||||||
|
.first()
|
||||||
|
.is_some_and(|session| is_obs(&session.app))
|
||||||
|
{
|
||||||
|
ferese_theme::icons::OBS
|
||||||
|
} else {
|
||||||
|
ferese_theme::icons::DISPLAY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bar_label(&self, compact: bool) -> String {
|
||||||
|
self.obs.bar_label(compact).unwrap_or_else(|| {
|
||||||
|
if self.sessions.len() > 1 {
|
||||||
|
format!("Sharing +{}", self.sessions.len() - 1)
|
||||||
|
} else {
|
||||||
|
"Sharing".into()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_obs(app: &str) -> bool {
|
||||||
|
matches!(app, "com.obsproject.Studio" | "obs" | "obs-studio")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_name(app: &str) -> String {
|
||||||
|
if is_obs(app) {
|
||||||
|
"OBS".into()
|
||||||
|
} else if app.is_empty() {
|
||||||
|
"Screen capture".into()
|
||||||
|
} else {
|
||||||
|
app.chars().filter(|c| !c.is_control()).take(48).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configured(panel: &ferese_panel::Panel) -> bool {
|
||||||
|
[&panel.start, &panel.center, &panel.end]
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|zone| &zone.groups)
|
||||||
|
.flat_map(|group| &group.items)
|
||||||
|
.any(|item| item.visible && item.kind == ferese_panel::ItemKind::CaptureStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::FereseShell {
|
||||||
|
pub(crate) fn capture_indicator(
|
||||||
|
&self,
|
||||||
|
size: u16,
|
||||||
|
foreground: cosmic::iced::Color,
|
||||||
|
opacity: f32,
|
||||||
|
) -> cosmic::Element<'_, cosmic::Action<crate::Message>> {
|
||||||
|
use cosmic::iced::{Alignment, Background, Border};
|
||||||
|
use cosmic::{
|
||||||
|
theme,
|
||||||
|
widget::{Space, container, row},
|
||||||
|
};
|
||||||
|
let mut indicator = row![crate::bar_icon(
|
||||||
|
self.capture_status.icon(),
|
||||||
|
size,
|
||||||
|
foreground
|
||||||
|
)]
|
||||||
|
.spacing(4)
|
||||||
|
.align_y(Alignment::Center);
|
||||||
|
if self.capture_status.obs.on_air() {
|
||||||
|
let color = self.config.theme.palette().error.scale_alpha(opacity);
|
||||||
|
indicator = indicator.push(container(Space::new()).width(5).height(5).class(
|
||||||
|
theme::Container::custom(move |_| container::Style {
|
||||||
|
background: Some(Background::Color(color)),
|
||||||
|
border: Border {
|
||||||
|
radius: 2.5.into(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
indicator.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn capture_item_enabled(&self) -> bool {
|
||||||
|
self.config.panels.iter().any(configured)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn capture_item_visible(&self, id: cosmic::iced::window::Id) -> bool {
|
||||||
|
configured(self.panel_for_bar(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn capture_timer_visible(&self) -> bool {
|
||||||
|
use crate::panel_layout::Placement;
|
||||||
|
use ferese_panel::{ItemKind, Representation, morph::Mode};
|
||||||
|
if self.snapshot.binding_mode.is_some() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.outputs
|
||||||
|
.iter()
|
||||||
|
.filter(|output| !output.hidden)
|
||||||
|
.any(|output| {
|
||||||
|
if self.menu.as_ref().is_some_and(|menu| {
|
||||||
|
menu.kind == crate::status_ui::Menu::CaptureStatus
|
||||||
|
&& menu.anchor.parent == output.bar
|
||||||
|
&& !menu.motion.closing()
|
||||||
|
}) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let panel = self.panel_for_bar(output.bar);
|
||||||
|
if !configured(panel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Some(hub) = &output.runtime.hub {
|
||||||
|
let hub = hub.borrow();
|
||||||
|
return hub.menu == Some(crate::status_ui::Menu::CaptureStatus)
|
||||||
|
|| matches!(hub.hub.mode(), Mode::Idle | Mode::Peek);
|
||||||
|
}
|
||||||
|
[&panel.start, &panel.center, &panel.end]
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|zone| &zone.groups)
|
||||||
|
.flat_map(|group| &group.items)
|
||||||
|
.filter(|item| item.visible && item.kind == ItemKind::CaptureStatus)
|
||||||
|
.any(|item| {
|
||||||
|
output.panel_resolution.as_ref().map_or(
|
||||||
|
item.representation.unwrap_or(Representation::Wide)
|
||||||
|
== Representation::Wide,
|
||||||
|
|resolution| {
|
||||||
|
matches!(
|
||||||
|
resolution.items.get(&item.id),
|
||||||
|
Some(Placement::Visible {
|
||||||
|
representation: Representation::Wide,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn view_capture_status(
|
||||||
|
&self,
|
||||||
|
representation: ferese_panel::Representation,
|
||||||
|
selected: bool,
|
||||||
|
) -> cosmic::Element<'_, cosmic::Action<crate::Message>> {
|
||||||
|
use cosmic::iced::{Alignment, Rectangle};
|
||||||
|
use cosmic::{
|
||||||
|
theme,
|
||||||
|
widget::{button, row},
|
||||||
|
};
|
||||||
|
let palette = self.config.theme.for_bar();
|
||||||
|
let metrics = crate::BarMetrics::from(self.config.panels[0].resolved_geometry());
|
||||||
|
let foreground = crate::color(if selected {
|
||||||
|
palette.accent
|
||||||
|
} else {
|
||||||
|
palette.text_primary
|
||||||
|
});
|
||||||
|
let label = self
|
||||||
|
.capture_status
|
||||||
|
.label()
|
||||||
|
.unwrap_or_else(|| "Capture status".into());
|
||||||
|
let mut content = row![self.capture_indicator(metrics.icon_size, foreground, 1.)]
|
||||||
|
.spacing(4)
|
||||||
|
.align_y(Alignment::Center);
|
||||||
|
if representation != ferese_panel::Representation::Icon {
|
||||||
|
content = content.push(
|
||||||
|
self.render
|
||||||
|
.text(
|
||||||
|
self.capture_status
|
||||||
|
.bar_label(representation == ferese_panel::Representation::Compact),
|
||||||
|
)
|
||||||
|
.size(metrics.text_size)
|
||||||
|
.class(theme::Text::Color(foreground)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let control = button::custom(crate::bar_content(content, metrics.group_item_height))
|
||||||
|
.name(label)
|
||||||
|
.padding([0., 6.])
|
||||||
|
.height(metrics.group_item_height)
|
||||||
|
.on_press_with_rectangle(|offset, bounds| {
|
||||||
|
cosmic::Action::App(crate::Message::OpenMenu(
|
||||||
|
crate::status_ui::Menu::CaptureStatus,
|
||||||
|
Rectangle {
|
||||||
|
x: (bounds.x - offset.x).round() as i32,
|
||||||
|
y: (bounds.y - offset.y).round() as i32,
|
||||||
|
width: bounds.width.round() as i32,
|
||||||
|
height: bounds.height.round() as i32,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
});
|
||||||
|
crate::motion::button(
|
||||||
|
self.render,
|
||||||
|
control,
|
||||||
|
crate::color(palette.accent),
|
||||||
|
selected,
|
||||||
|
1.,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn configured_visibility_and_representation_control_timer_work() {
|
||||||
|
use ferese_panel::{ItemKind, Representation};
|
||||||
|
let mut shell = crate::tests::shell_with_measured_panel();
|
||||||
|
shell.outputs[0].panel_resolution = None;
|
||||||
|
assert!(shell.capture_item_enabled());
|
||||||
|
assert!(shell.capture_timer_visible());
|
||||||
|
shell.outputs[0].hidden = true;
|
||||||
|
assert!(!shell.capture_timer_visible());
|
||||||
|
shell.outputs[0].hidden = false;
|
||||||
|
for representation in [
|
||||||
|
Representation::Compact,
|
||||||
|
Representation::Icon,
|
||||||
|
Representation::Wide,
|
||||||
|
] {
|
||||||
|
let item = shell.config.panels[0]
|
||||||
|
.end
|
||||||
|
.groups
|
||||||
|
.iter_mut()
|
||||||
|
.flat_map(|g| &mut g.items)
|
||||||
|
.find(|item| item.kind == ItemKind::CaptureStatus)
|
||||||
|
.unwrap();
|
||||||
|
item.representation = Some(representation);
|
||||||
|
assert_eq!(
|
||||||
|
shell.capture_timer_visible(),
|
||||||
|
representation == Representation::Wide
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let item = shell.config.panels[0]
|
||||||
|
.end
|
||||||
|
.groups
|
||||||
|
.iter_mut()
|
||||||
|
.flat_map(|g| &mut g.items)
|
||||||
|
.find(|item| item.kind == ItemKind::CaptureStatus)
|
||||||
|
.unwrap();
|
||||||
|
item.visible = false;
|
||||||
|
assert!(!shell.capture_item_enabled());
|
||||||
|
assert!(!shell.capture_timer_visible());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capture_does_not_claim_a_broadcast_and_idle_has_no_timer() {
|
||||||
|
let mut model = Model::default();
|
||||||
|
assert!(!model.active());
|
||||||
|
assert_eq!(model.tick_subscription(true).units(), 0);
|
||||||
|
model.apply(Event::Sharing(vec![Session {
|
||||||
|
app: "com.obsproject.Studio".into(),
|
||||||
|
sources: vec!["Workspace 1".into()],
|
||||||
|
}]));
|
||||||
|
assert_eq!(model.label().as_deref(), Some("OBS · Sharing"));
|
||||||
|
assert_eq!(model.bar_label(false), "Sharing");
|
||||||
|
assert_eq!(model.icon(), ferese_theme::icons::OBS);
|
||||||
|
assert!(!model.obs.on_air());
|
||||||
|
assert_eq!(model.tick_subscription(true).units(), 0);
|
||||||
|
assert_eq!(model.details(), ["OBS · Sharing", "Workspace 1"]);
|
||||||
|
model.apply(Event::Sharing(vec![]));
|
||||||
|
assert!(!model.active());
|
||||||
|
model.apply(Event::Sharing(vec![Session {
|
||||||
|
app: "Firefox".into(),
|
||||||
|
sources: vec!["Workspace 1".into()],
|
||||||
|
}]));
|
||||||
|
assert_eq!(model.icon(), ferese_theme::icons::DISPLAY);
|
||||||
|
assert_eq!(model.bar_label(true), "Sharing");
|
||||||
|
assert!(!model.obs.on_air());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||||
|
use cosmic::iced::futures::{SinkExt, Stream, StreamExt};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use tokio_tungstenite::{
|
||||||
|
MaybeTlsStream, WebSocketStream,
|
||||||
|
tungstenite::{Message, protocol::WebSocketConfig},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::Event;
|
||||||
|
|
||||||
|
#[derive(Clone, Hash, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub(super) struct Config {
|
||||||
|
#[serde(default)]
|
||||||
|
enabled: bool,
|
||||||
|
#[serde(default = "default_port")]
|
||||||
|
port: u16,
|
||||||
|
#[serde(default)]
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
fn default_port() -> u16 {
|
||||||
|
4455
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load() -> Option<Self> {
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.or_else(|| {
|
||||||
|
std::env::var_os("HOME").map(|home| std::path::PathBuf::from(home).join(".config"))
|
||||||
|
})?;
|
||||||
|
let path = base.join("ferese/obs.json");
|
||||||
|
let metadata = std::fs::metadata(&path).ok()?;
|
||||||
|
// The file may contain the OBS password. Never log its contents.
|
||||||
|
if metadata.len() > 16 * 1024 || metadata.mode() & 0o077 != 0 {
|
||||||
|
eprintln!("ferese-shell: obs.json must be private (mode 600) and smaller than 16 KiB");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let config: Self = serde_json::from_slice(&std::fs::read(path).ok()?).ok()?;
|
||||||
|
(config.enabled && config.port != 0).then_some(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct Output {
|
||||||
|
active: bool,
|
||||||
|
paused: bool,
|
||||||
|
reconnecting: bool,
|
||||||
|
duration: Duration,
|
||||||
|
sampled: Instant,
|
||||||
|
}
|
||||||
|
impl Default for Output {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
active: false,
|
||||||
|
paused: false,
|
||||||
|
reconnecting: false,
|
||||||
|
duration: Duration::ZERO,
|
||||||
|
sampled: Instant::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Output {
|
||||||
|
fn elapsed(&self) -> Duration {
|
||||||
|
self.duration
|
||||||
|
+ if self.active && !self.paused {
|
||||||
|
self.sampled.elapsed()
|
||||||
|
} else {
|
||||||
|
Duration::ZERO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn label(&self, record: bool, compact: bool) -> Option<String> {
|
||||||
|
if !self.active {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let state = if self.reconnecting {
|
||||||
|
"Reconnecting"
|
||||||
|
} else if self.paused {
|
||||||
|
"REC paused"
|
||||||
|
} else if record {
|
||||||
|
"REC"
|
||||||
|
} else {
|
||||||
|
"LIVE"
|
||||||
|
};
|
||||||
|
if compact {
|
||||||
|
return Some(state.into());
|
||||||
|
}
|
||||||
|
let seconds = self.elapsed().as_secs();
|
||||||
|
Some(format!("{state} {:02}:{:02}", seconds / 60, seconds % 60))
|
||||||
|
}
|
||||||
|
fn status(&mut self, data: &Value) -> Result<(), String> {
|
||||||
|
self.active = data["outputActive"]
|
||||||
|
.as_bool()
|
||||||
|
.ok_or("Missing OBS output status")?;
|
||||||
|
self.paused = data["outputPaused"].as_bool().unwrap_or(false);
|
||||||
|
self.reconnecting = data["outputReconnecting"].as_bool().unwrap_or(false);
|
||||||
|
self.duration = Duration::from_millis(data["outputDuration"].as_u64().unwrap_or(0));
|
||||||
|
self.sampled = Instant::now();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn event(&mut self, data: &Value) -> Result<(), String> {
|
||||||
|
let state = data["outputState"].as_str().ok_or("Missing OBS state")?;
|
||||||
|
self.duration = self.elapsed();
|
||||||
|
self.sampled = Instant::now();
|
||||||
|
match state {
|
||||||
|
"OBS_WEBSOCKET_OUTPUT_STARTED" => {
|
||||||
|
self.active = true;
|
||||||
|
self.duration = Duration::ZERO;
|
||||||
|
self.paused = false;
|
||||||
|
self.reconnecting = false;
|
||||||
|
}
|
||||||
|
"OBS_WEBSOCKET_OUTPUT_PAUSED" => self.paused = true,
|
||||||
|
"OBS_WEBSOCKET_OUTPUT_RESUMED" => self.paused = false,
|
||||||
|
"OBS_WEBSOCKET_OUTPUT_RECONNECTING" => self.reconnecting = true,
|
||||||
|
"OBS_WEBSOCKET_OUTPUT_RECONNECTED" => self.reconnecting = false,
|
||||||
|
"OBS_WEBSOCKET_OUTPUT_STOPPED" | "OBS_WEBSOCKET_OUTPUT_STARTING" => {
|
||||||
|
*self = Self::default()
|
||||||
|
}
|
||||||
|
"OBS_WEBSOCKET_OUTPUT_STOPPING" => (),
|
||||||
|
_ => return Err("Unknown OBS output state".into()),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub(crate) struct Snapshot {
|
||||||
|
stream: Output,
|
||||||
|
record: Output,
|
||||||
|
}
|
||||||
|
impl Snapshot {
|
||||||
|
pub fn active(&self) -> bool {
|
||||||
|
self.stream.active || self.record.active
|
||||||
|
}
|
||||||
|
pub fn ticking(&self) -> bool {
|
||||||
|
self.stream.active || (self.record.active && !self.record.paused)
|
||||||
|
}
|
||||||
|
pub fn label(&self) -> Option<String> {
|
||||||
|
self.bar_label(false).map(|label| format!("OBS · {label}"))
|
||||||
|
}
|
||||||
|
pub fn on_air(&self) -> bool {
|
||||||
|
(self.stream.active && !self.stream.reconnecting)
|
||||||
|
|| (self.record.active && !self.record.paused && !self.record.reconnecting)
|
||||||
|
}
|
||||||
|
pub fn bar_label(&self, compact: bool) -> Option<String> {
|
||||||
|
let stream = self.stream.label(false, compact);
|
||||||
|
let record = self.record.label(true, compact);
|
||||||
|
match (stream, record) {
|
||||||
|
(Some(stream), Some(_)) => Some(format!(
|
||||||
|
"{stream} · {}",
|
||||||
|
if self.record.paused {
|
||||||
|
"REC paused"
|
||||||
|
} else {
|
||||||
|
"REC"
|
||||||
|
}
|
||||||
|
)),
|
||||||
|
(Some(label), None) | (None, Some(label)) => Some(label),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn details(&self) -> Vec<String> {
|
||||||
|
[
|
||||||
|
self.stream.label(false, false),
|
||||||
|
self.record.label(true, false),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.map(|label| format!("OBS · {label}"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn identify(hello: &Value, password: &str) -> Result<Value, String> {
|
||||||
|
if hello["op"] != 0 || hello["d"]["rpcVersion"].as_u64().unwrap_or(0) < 1 {
|
||||||
|
return Err("Unsupported OBS handshake".into());
|
||||||
|
}
|
||||||
|
// Outputs only: no meters, previews, scene changes, or per-frame events.
|
||||||
|
let mut reply = json!({"op":1,"d":{"rpcVersion":1,"eventSubscriptions":64}});
|
||||||
|
if let Some(auth) = hello["d"].get("authentication") {
|
||||||
|
let salt = auth["salt"].as_str().ok_or("Missing OBS salt")?;
|
||||||
|
let challenge = auth["challenge"].as_str().ok_or("Missing OBS challenge")?;
|
||||||
|
let secret = STANDARD.encode(Sha256::digest(format!("{password}{salt}")));
|
||||||
|
reply["d"]["authentication"] = STANDARD
|
||||||
|
.encode(Sha256::digest(format!("{secret}{challenge}")))
|
||||||
|
.into();
|
||||||
|
}
|
||||||
|
Ok(reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Socket = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
|
||||||
|
|
||||||
|
async fn receive(socket: &mut Socket) -> Result<Value, String> {
|
||||||
|
loop {
|
||||||
|
match socket.next().await {
|
||||||
|
Some(Ok(Message::Text(text))) => {
|
||||||
|
return serde_json::from_str(&text).map_err(|_| "Invalid OBS message".into());
|
||||||
|
}
|
||||||
|
Some(Ok(Message::Ping(_))) => socket.flush().await.map_err(|_| "OBS disconnected")?,
|
||||||
|
Some(Ok(Message::Pong(_))) => (),
|
||||||
|
_ => return Err("OBS disconnected".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send(socket: &mut Socket, value: Value) -> Result<(), String> {
|
||||||
|
tokio::time::timeout(
|
||||||
|
Duration::from_secs(3),
|
||||||
|
socket.send(Message::Text(value.to_string().into())),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "OBS write timed out")?
|
||||||
|
.map_err(|_| "OBS disconnected".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request(socket: &mut Socket, record: bool, generation: u64) -> Result<(), String> {
|
||||||
|
send(
|
||||||
|
socket,
|
||||||
|
json!({"op":6,"d":{
|
||||||
|
"requestType": if record { "GetRecordStatus" } else { "GetStreamStatus" },
|
||||||
|
"requestId": format!("{}-{generation}", if record { "record" } else { "stream" })
|
||||||
|
}}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect(config: &Config) -> Result<Socket, String> {
|
||||||
|
let limits = WebSocketConfig::default()
|
||||||
|
.read_buffer_size(4096)
|
||||||
|
.write_buffer_size(0)
|
||||||
|
.max_write_buffer_size(64 * 1024)
|
||||||
|
.max_message_size(Some(64 * 1024))
|
||||||
|
.max_frame_size(Some(64 * 1024));
|
||||||
|
// Credentials are sent only to the user's local OBS instance.
|
||||||
|
let (mut socket, _) = tokio_tungstenite::connect_async_with_config(
|
||||||
|
format!("ws://127.0.0.1:{}", config.port),
|
||||||
|
Some(limits),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "OBS unavailable")?;
|
||||||
|
let hello = receive(&mut socket).await?;
|
||||||
|
send(&mut socket, identify(&hello, &config.password)?).await?;
|
||||||
|
if receive(&mut socket).await?["op"] != 2 {
|
||||||
|
return Err("OBS identification failed".into());
|
||||||
|
}
|
||||||
|
Ok(socket)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn stream(config: &Arc<Config>) -> impl Stream<Item = Event> + use<> {
|
||||||
|
let config = config.clone();
|
||||||
|
cosmic::iced::stream::channel(1, async move |mut output| {
|
||||||
|
let mut retry = 1;
|
||||||
|
loop {
|
||||||
|
let connection = tokio::time::timeout(Duration::from_secs(5), connect(&config)).await;
|
||||||
|
if let Ok(Ok(mut socket)) = connection {
|
||||||
|
let mut snapshot = Snapshot::default();
|
||||||
|
let mut generations = [0u64; 2];
|
||||||
|
let mut pending = [true; 2];
|
||||||
|
let mut deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
let connected = Instant::now();
|
||||||
|
let result: Result<(), String> = async {
|
||||||
|
request(&mut socket, false, 0).await?;
|
||||||
|
request(&mut socket, true, 0).await?;
|
||||||
|
loop {
|
||||||
|
let message = if pending.iter().any(|pending| *pending) {
|
||||||
|
tokio::time::timeout_at(deadline.into(), receive(&mut socket))
|
||||||
|
.await
|
||||||
|
.map_err(|_| "OBS status timed out")??
|
||||||
|
} else {
|
||||||
|
receive(&mut socket).await?
|
||||||
|
};
|
||||||
|
let data = &message["d"];
|
||||||
|
match message["op"].as_u64() {
|
||||||
|
Some(5) => {
|
||||||
|
let index = match data["eventType"].as_str() {
|
||||||
|
Some("StreamStateChanged") => 0,
|
||||||
|
Some("RecordStateChanged") => 1,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let state = if index == 0 {
|
||||||
|
&mut snapshot.stream
|
||||||
|
} else {
|
||||||
|
&mut snapshot.record
|
||||||
|
};
|
||||||
|
state.event(&data["eventData"])?;
|
||||||
|
generations[index] += 1;
|
||||||
|
pending[index] = true;
|
||||||
|
deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
request(&mut socket, index == 1, generations[index]).await?;
|
||||||
|
}
|
||||||
|
Some(7) => {
|
||||||
|
let id = data["requestId"].as_str().unwrap_or_default();
|
||||||
|
let index = if id == format!("stream-{}", generations[0]) {
|
||||||
|
0
|
||||||
|
} else if id == format!("record-{}", generations[1]) {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !pending[index] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if data["requestStatus"]["result"] != true {
|
||||||
|
return Err("OBS status unavailable".into());
|
||||||
|
}
|
||||||
|
let state = if index == 0 {
|
||||||
|
&mut snapshot.stream
|
||||||
|
} else {
|
||||||
|
&mut snapshot.record
|
||||||
|
};
|
||||||
|
state.status(&data["responseData"])?;
|
||||||
|
pending[index] = false;
|
||||||
|
}
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
if output.send(Event::Obs(snapshot.clone())).await.is_err() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
let _ = result;
|
||||||
|
if connected.elapsed() >= Duration::from_secs(30) {
|
||||||
|
retry = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if output.send(Event::Obs(Snapshot::default())).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(retry)).await;
|
||||||
|
retry = (retry * 2).min(60);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn websocket_ignores_stale_replies_waits_for_events_and_clears_disconnects() {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let config = Arc::new(Config {
|
||||||
|
enabled: true,
|
||||||
|
port: listener.local_addr().unwrap().port(),
|
||||||
|
password: String::new(),
|
||||||
|
});
|
||||||
|
let (close, closed) = tokio::sync::oneshot::channel();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let (socket, _) = listener.accept().await.unwrap();
|
||||||
|
let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap();
|
||||||
|
socket
|
||||||
|
.send(Message::Text(
|
||||||
|
json!({"op":0,"d":{"rpcVersion":1}}).to_string().into(),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let identify: Value =
|
||||||
|
serde_json::from_str(socket.next().await.unwrap().unwrap().to_text().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(identify["d"]["eventSubscriptions"], 64);
|
||||||
|
socket
|
||||||
|
.send(Message::Text(
|
||||||
|
json!({"op":2,"d":{"negotiatedRpcVersion":1}})
|
||||||
|
.to_string()
|
||||||
|
.into(),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
for id in ["stream-0", "record-0"] {
|
||||||
|
let request: Value =
|
||||||
|
serde_json::from_str(socket.next().await.unwrap().unwrap().to_text().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(request["d"]["requestId"], id);
|
||||||
|
}
|
||||||
|
socket.send(Message::Text(json!({"op":5,"d":{"eventType":"StreamStateChanged","eventData":{"outputState":"OBS_WEBSOCKET_OUTPUT_STARTED"}}}).to_string().into())).await.unwrap();
|
||||||
|
let request: Value =
|
||||||
|
serde_json::from_str(socket.next().await.unwrap().unwrap().to_text().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(request["d"]["requestId"], "stream-1");
|
||||||
|
for (id, data) in [
|
||||||
|
("stream-0", json!({"outputActive":false,"outputDuration":0})),
|
||||||
|
(
|
||||||
|
"record-0",
|
||||||
|
json!({"outputActive":true,"outputPaused":true,"outputDuration":12000}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"stream-1",
|
||||||
|
json!({"outputActive":true,"outputDuration":70000}),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
socket.send(Message::Text(json!({"op":7,"d":{"requestId":id,"requestStatus":{"result":true},"responseData":data}}).to_string().into())).await.unwrap();
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
tokio::time::timeout(Duration::from_millis(100), socket.next())
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"idle subscriber sent another request"
|
||||||
|
);
|
||||||
|
closed.await.unwrap();
|
||||||
|
socket.close(None).await.unwrap();
|
||||||
|
});
|
||||||
|
let events = stream(&config);
|
||||||
|
tokio::pin!(events);
|
||||||
|
tokio::time::timeout(Duration::from_secs(3), async {
|
||||||
|
loop {
|
||||||
|
let Some(Event::Obs(snapshot)) = events.next().await else {
|
||||||
|
panic!("missing OBS state");
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
snapshot.stream.active,
|
||||||
|
"stale initial response hid the live stream"
|
||||||
|
);
|
||||||
|
if snapshot.label().as_deref() == Some("OBS · LIVE 01:10 · REC paused") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
tokio::time::timeout(Duration::from_millis(150), events.next())
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"idle subscriber produced another update"
|
||||||
|
);
|
||||||
|
close.send(()).unwrap();
|
||||||
|
let Some(Event::Obs(snapshot)) =
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), events.next())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!("missing disconnect");
|
||||||
|
};
|
||||||
|
assert!(!snapshot.active());
|
||||||
|
assert!(!snapshot.ticking());
|
||||||
|
server.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn idle_capture_pause_reconnect_and_simultaneous_outputs() {
|
||||||
|
let mut state = Snapshot::default();
|
||||||
|
assert!(!state.active());
|
||||||
|
assert!(!state.ticking());
|
||||||
|
assert!(!state.on_air());
|
||||||
|
state
|
||||||
|
.stream
|
||||||
|
.event(&json!({"outputState":"OBS_WEBSOCKET_OUTPUT_STARTING"}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(!state.active());
|
||||||
|
state
|
||||||
|
.stream
|
||||||
|
.status(&json!({"outputActive":true,"outputDuration":74000}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(state.label().as_deref(), Some("OBS · LIVE 01:14"));
|
||||||
|
assert_eq!(state.bar_label(true).as_deref(), Some("LIVE"));
|
||||||
|
assert!(state.on_air());
|
||||||
|
state
|
||||||
|
.record
|
||||||
|
.status(&json!({"outputActive":true,"outputDuration":12000}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(state.details().len(), 2);
|
||||||
|
assert!(state.label().unwrap().ends_with(" · REC"));
|
||||||
|
state
|
||||||
|
.stream
|
||||||
|
.event(&json!({"outputState":"OBS_WEBSOCKET_OUTPUT_RECONNECTING"}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(!state.label().unwrap().contains("LIVE"));
|
||||||
|
assert!(
|
||||||
|
state.on_air(),
|
||||||
|
"recording is still active while the stream reconnects"
|
||||||
|
);
|
||||||
|
state
|
||||||
|
.record
|
||||||
|
.event(&json!({"outputState":"OBS_WEBSOCKET_OUTPUT_PAUSED"}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
!state.on_air(),
|
||||||
|
"reconnecting and paused outputs are not on air"
|
||||||
|
);
|
||||||
|
state
|
||||||
|
.stream
|
||||||
|
.event(&json!({"outputState":"OBS_WEBSOCKET_OUTPUT_STOPPED"}))
|
||||||
|
.unwrap();
|
||||||
|
state
|
||||||
|
.record
|
||||||
|
.event(&json!({"outputState":"OBS_WEBSOCKET_OUTPUT_PAUSED"}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(state.active());
|
||||||
|
assert!(!state.ticking());
|
||||||
|
assert!(state.label().unwrap().contains("paused"));
|
||||||
|
assert_eq!(state.bar_label(true).as_deref(), Some("REC paused"));
|
||||||
|
assert!(!state.on_air());
|
||||||
|
assert_eq!(state.record.elapsed(), state.record.duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authentication_subscribes_only_to_output_events() {
|
||||||
|
let hello = json!({"op":0,"d":{"rpcVersion":1,"authentication":{"salt":"salt","challenge":"challenge"}}});
|
||||||
|
let reply = identify(&hello, "password").unwrap();
|
||||||
|
let secret = STANDARD.encode(Sha256::digest(b"passwordsalt"));
|
||||||
|
let expected = STANDARD.encode(Sha256::digest(format!("{secret}challenge")));
|
||||||
|
assert_eq!(reply["d"]["authentication"], expected);
|
||||||
|
assert_eq!(reply["d"]["eventSubscriptions"], 64);
|
||||||
|
assert!(identify(&json!({"op":2,"d":{}}), "password").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use cosmic::iced::futures::{SinkExt, Stream, StreamExt};
|
||||||
|
use ferese_ipc::capture::{INTERFACE, PATH, SERVICE, Session};
|
||||||
|
|
||||||
|
use super::Event;
|
||||||
|
|
||||||
|
async fn snapshot(proxy: &zbus::Proxy<'_>) -> zbus::Result<Vec<Session>> {
|
||||||
|
let reply = tokio::time::timeout(
|
||||||
|
Duration::from_secs(3),
|
||||||
|
proxy.call_with_flags::<_, _, String>(
|
||||||
|
"GetSnapshot",
|
||||||
|
zbus::proxy::MethodFlags::NoAutoStart.into(),
|
||||||
|
&(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let text = match reply {
|
||||||
|
Ok(Ok(Some(text))) => text,
|
||||||
|
Ok(Err(zbus::Error::MethodError(name, _, _)))
|
||||||
|
if matches!(
|
||||||
|
name.as_str(),
|
||||||
|
"org.freedesktop.DBus.Error.ServiceUnknown"
|
||||||
|
| "org.freedesktop.DBus.Error.NameHasNoOwner"
|
||||||
|
| "org.freedesktop.DBus.Error.UnknownMethod"
|
||||||
|
| "org.freedesktop.DBus.Error.UnknownInterface"
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
Ok(Err(error)) => return Err(error),
|
||||||
|
_ => return Err(zbus::Error::Failure("Capture status timed out".into())),
|
||||||
|
};
|
||||||
|
if text.len() > 64 * 1024 {
|
||||||
|
return Err(zbus::Error::Failure(
|
||||||
|
"Capture status exceeds its limit".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
serde_json::from_str::<Vec<Session>>(&text)
|
||||||
|
.ok()
|
||||||
|
.filter(|sessions| sessions.len() <= 8 && sessions.iter().all(|s| s.sources.len() <= 8))
|
||||||
|
.ok_or_else(|| zbus::Error::Failure("Invalid capture status".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn stream() -> impl Stream<Item = Event> {
|
||||||
|
stream_at(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream_at(address: Option<String>) -> impl Stream<Item = Event> {
|
||||||
|
cosmic::iced::stream::channel(1, async move |mut output| {
|
||||||
|
loop {
|
||||||
|
let result: Result<(), zbus::Error> = async {
|
||||||
|
let connection = if let Some(address) = &address {
|
||||||
|
zbus::connection::Builder::address(address.as_str())?
|
||||||
|
.build()
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
zbus::Connection::session().await?
|
||||||
|
};
|
||||||
|
let proxy = zbus::Proxy::new(&connection, SERVICE, PATH, INTERFACE).await?;
|
||||||
|
let mut owners = proxy.receive_owner_changed().await?;
|
||||||
|
let mut changes = proxy.receive_signal("Changed").await?;
|
||||||
|
let mut last = snapshot(&proxy).await?;
|
||||||
|
if output.send(Event::Sharing(last.clone())).await.is_err() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
owner = owners.next() => {
|
||||||
|
let Some(owner) = owner else { break; };
|
||||||
|
// Forget the previous service before reading its replacement.
|
||||||
|
last.clear();
|
||||||
|
if output.send(Event::Sharing(vec![])).await.is_err() { return Ok(()); }
|
||||||
|
if owner.is_none() { continue; }
|
||||||
|
}
|
||||||
|
change = changes.next() => {
|
||||||
|
if change.is_none() { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let next = snapshot(&proxy).await?;
|
||||||
|
if next != last {
|
||||||
|
last = next;
|
||||||
|
if output.send(Event::Sharing(last.clone())).await.is_err() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
let _ = result;
|
||||||
|
if output.send(Event::Sharing(vec![])).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicUsize, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Status(Arc<AtomicUsize>);
|
||||||
|
#[zbus::interface(name = "org.ferese.CaptureStatus")]
|
||||||
|
impl Status {
|
||||||
|
fn get_snapshot(&self) -> String {
|
||||||
|
self.0.fetch_add(1, Ordering::Relaxed);
|
||||||
|
serde_json::to_string(&vec![Session {
|
||||||
|
app: "test".into(),
|
||||||
|
sources: vec!["Workspace 2".into()],
|
||||||
|
}])
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
#[zbus(signal)]
|
||||||
|
async fn changed(emitter: &zbus::object_server::SignalEmitter<'_>) -> zbus::Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Bus(std::process::Child);
|
||||||
|
impl Drop for Bus {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self.0.kill();
|
||||||
|
let _ = self.0.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires dbus-daemon; uses a private test bus"]
|
||||||
|
async fn portal_owner_changes_recover_without_polling_or_activation() {
|
||||||
|
use std::io::BufRead;
|
||||||
|
let mut bus = Bus(std::process::Command::new("dbus-daemon")
|
||||||
|
.args(["--session", "--nofork", "--nopidfile", "--print-address=1"])
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.unwrap());
|
||||||
|
let mut address = String::new();
|
||||||
|
std::io::BufReader::new(bus.0.stdout.take().unwrap())
|
||||||
|
.read_line(&mut address)
|
||||||
|
.unwrap();
|
||||||
|
let address = address.trim().to_owned();
|
||||||
|
let events = stream_at(Some(address.clone()));
|
||||||
|
tokio::pin!(events);
|
||||||
|
let Some(Event::Sharing(sessions)) =
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), events.next())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!("missing initial state");
|
||||||
|
};
|
||||||
|
assert!(sessions.is_empty());
|
||||||
|
let reads = Arc::new(AtomicUsize::new(0));
|
||||||
|
let server = zbus::connection::Builder::address(address.as_str())
|
||||||
|
.unwrap()
|
||||||
|
.name(SERVICE)
|
||||||
|
.unwrap()
|
||||||
|
.serve_at(PATH, Status(reads.clone()))
|
||||||
|
.unwrap()
|
||||||
|
.build()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
|
loop {
|
||||||
|
if let Some(Event::Sharing(sessions)) = events.next().await
|
||||||
|
&& sessions.len() == 1
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let before = reads.load(Ordering::Relaxed);
|
||||||
|
assert!(
|
||||||
|
tokio::time::timeout(Duration::from_millis(100), events.next())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
reads.load(Ordering::Relaxed),
|
||||||
|
before,
|
||||||
|
"idle subscriber polled the portal"
|
||||||
|
);
|
||||||
|
let emitter = zbus::object_server::SignalEmitter::new(&server, PATH).unwrap();
|
||||||
|
Status::changed(&emitter).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
tokio::time::timeout(Duration::from_millis(100), events.next())
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"unchanged snapshot was republished"
|
||||||
|
);
|
||||||
|
assert_eq!(reads.load(Ordering::Relaxed), before + 1);
|
||||||
|
server.release_name(SERVICE).await.unwrap();
|
||||||
|
let Some(Event::Sharing(sessions)) =
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), events.next())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!("missing owner loss");
|
||||||
|
};
|
||||||
|
assert!(sessions.is_empty());
|
||||||
|
server.request_name(SERVICE).await.unwrap();
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
|
loop {
|
||||||
|
if let Some(Event::Sharing(sessions)) = events.next().await
|
||||||
|
&& sessions.len() == 1
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ mod surfaces;
|
|||||||
use bar::{bar_content, output_bar_hidden};
|
use bar::{bar_content, output_bar_hidden};
|
||||||
use surfaces::{EffectsBinding, EffectsContext, native_wayland_surface};
|
use surfaces::{EffectsBinding, EffectsContext, native_wayland_surface};
|
||||||
|
|
||||||
|
mod capture_status;
|
||||||
mod clock;
|
mod clock;
|
||||||
mod compositor_ipc;
|
mod compositor_ipc;
|
||||||
mod config;
|
mod config;
|
||||||
@@ -167,6 +168,7 @@ struct FereseShell {
|
|||||||
status_service: status::Service,
|
status_service: status::Service,
|
||||||
status: status::Snapshot,
|
status: status::Snapshot,
|
||||||
recorder: recording::Recorder,
|
recorder: recording::Recorder,
|
||||||
|
capture_status: capture_status::Model,
|
||||||
media: media::Model,
|
media: media::Model,
|
||||||
activities: activities::Model,
|
activities: activities::Model,
|
||||||
calendar_offset: i32,
|
calendar_offset: i32,
|
||||||
@@ -237,6 +239,7 @@ enum Message {
|
|||||||
),
|
),
|
||||||
ClockChanged(String),
|
ClockChanged(String),
|
||||||
RecorderEvent(recording::Event),
|
RecorderEvent(recording::Event),
|
||||||
|
CaptureStatus(capture_status::Event),
|
||||||
RecorderElapsed,
|
RecorderElapsed,
|
||||||
MediaUpdated(std::sync::Arc<ferese_ipc::media::Snapshot>),
|
MediaUpdated(std::sync::Arc<ferese_ipc::media::Snapshot>),
|
||||||
MediaAction(serde_json::Value),
|
MediaAction(serde_json::Value),
|
||||||
@@ -354,6 +357,7 @@ impl cosmic::Application for FereseShell {
|
|||||||
status_service: status::Service::start(config.status.settings_command.clone()),
|
status_service: status::Service::start(config.status.settings_command.clone()),
|
||||||
status: status::Snapshot::default(),
|
status: status::Snapshot::default(),
|
||||||
recorder: recording::Recorder::default(),
|
recorder: recording::Recorder::default(),
|
||||||
|
capture_status: capture_status::Model::new(),
|
||||||
media: media::Model::default(),
|
media: media::Model::default(),
|
||||||
activities: activities::Model::default(),
|
activities: activities::Model::default(),
|
||||||
calendar_offset: 0,
|
calendar_offset: 0,
|
||||||
@@ -453,6 +457,16 @@ impl cosmic::Application for FereseShell {
|
|||||||
self.media_tick_subscription().map(|_| Message::MediaTick),
|
self.media_tick_subscription().map(|_| Message::MediaTick),
|
||||||
self.media.art.subscription().map(Message::MediaArt),
|
self.media.art.subscription().map(Message::MediaArt),
|
||||||
self.recorder.subscription().map(Message::RecorderEvent),
|
self.recorder.subscription().map(Message::RecorderEvent),
|
||||||
|
if self.capture_item_enabled() {
|
||||||
|
self.capture_status
|
||||||
|
.subscription()
|
||||||
|
.map(Message::CaptureStatus)
|
||||||
|
} else {
|
||||||
|
Subscription::none()
|
||||||
|
},
|
||||||
|
self.capture_status
|
||||||
|
.tick_subscription(self.capture_timer_visible())
|
||||||
|
.map(|_| Message::RecorderElapsed),
|
||||||
self.recorder
|
self.recorder
|
||||||
.elapsed_subscription()
|
.elapsed_subscription()
|
||||||
.map(|_| Message::RecorderElapsed),
|
.map(|_| Message::RecorderElapsed),
|
||||||
@@ -947,6 +961,10 @@ impl cosmic::Application for FereseShell {
|
|||||||
self.recorder.handle(event);
|
self.recorder.handle(event);
|
||||||
self.sync_hub_activity()
|
self.sync_hub_activity()
|
||||||
}
|
}
|
||||||
|
Message::CaptureStatus(event) => {
|
||||||
|
self.capture_status.apply(event);
|
||||||
|
self.sync_hub_activity()
|
||||||
|
}
|
||||||
Message::RecorderElapsed => Task::none(),
|
Message::RecorderElapsed => Task::none(),
|
||||||
Message::PanelScale(id, scale) => {
|
Message::PanelScale(id, scale) => {
|
||||||
if let Some(entry) = self.outputs.iter_mut().find(|entry| entry.bar == id)
|
if let Some(entry) = self.outputs.iter_mut().find(|entry| entry.bar == id)
|
||||||
@@ -1368,6 +1386,7 @@ mod tests {
|
|||||||
status_service: status::Service::start(None),
|
status_service: status::Service::start(None),
|
||||||
status: Default::default(),
|
status: Default::default(),
|
||||||
recorder: Default::default(),
|
recorder: Default::default(),
|
||||||
|
capture_status: Default::default(),
|
||||||
media: Default::default(),
|
media: Default::default(),
|
||||||
activities: Default::default(),
|
activities: Default::default(),
|
||||||
calendar_offset: 0,
|
calendar_offset: 0,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ pub(super) fn content_width(panel: &Panel, size: Option<(i32, i32)>) -> f64 {
|
|||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
enum Peek {
|
enum Peek {
|
||||||
Recording,
|
Recording,
|
||||||
|
Capture,
|
||||||
Battery,
|
Battery,
|
||||||
Media,
|
Media,
|
||||||
Date,
|
Date,
|
||||||
@@ -284,7 +285,7 @@ impl FereseShell {
|
|||||||
entered,
|
entered,
|
||||||
enabled: self.panel_for_bar(id).morph.peek_on_hover,
|
enabled: self.panel_for_bar(id).morph.peek_on_hover,
|
||||||
peek: self.hub_peek_content(id),
|
peek: self.hub_peek_content(id),
|
||||||
priority: self.hub_priority(),
|
priority: self.hub_priority_for(id),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -343,7 +344,7 @@ impl FereseShell {
|
|||||||
};
|
};
|
||||||
if self.snapshot.binding_mode.is_some()
|
if self.snapshot.binding_mode.is_some()
|
||||||
|| self.bar_hidden(id)
|
|| self.bar_hidden(id)
|
||||||
|| self.hub_priority().is_some()
|
|| self.hub_priority_for(id).is_some()
|
||||||
|| !matches!(
|
|| !matches!(
|
||||||
self.hub_mode(id),
|
self.hub_mode(id),
|
||||||
Some(Mode::Idle | Mode::Peek | Mode::Toast)
|
Some(Mode::Idle | Mode::Peek | Mode::Toast)
|
||||||
@@ -479,7 +480,7 @@ impl FereseShell {
|
|||||||
{
|
{
|
||||||
return runtime.hub.target_size()[0].min(runtime.available[0]) as f32;
|
return runtime.hub.target_size()[0].min(runtime.available[0]) as f32;
|
||||||
}
|
}
|
||||||
let artwork_width = if self.hub_priority().is_some() {
|
let artwork_width = if self.hub_priority_for(id).is_some() {
|
||||||
64.
|
64.
|
||||||
} else if self
|
} else if self
|
||||||
.media
|
.media
|
||||||
@@ -720,7 +721,7 @@ impl FereseShell {
|
|||||||
let content: Element<'_, cosmic::Action<Message>> = match mode {
|
let content: Element<'_, cosmic::Action<Message>> = match mode {
|
||||||
Mode::Idle | Mode::Peek
|
Mode::Idle | Mode::Peek
|
||||||
if self.has_compact_activities(id)
|
if self.has_compact_activities(id)
|
||||||
&& self.hub_priority().is_none()
|
&& self.hub_priority_for(id).is_none()
|
||||||
&& !(mode == Mode::Peek && peek == Peek::Workspaces) =>
|
&& !(mode == Mode::Peek && peek == Peek::Workspaces) =>
|
||||||
{
|
{
|
||||||
self.activity_compact(id, opacity, intrinsic)
|
self.activity_compact(id, opacity, intrinsic)
|
||||||
@@ -736,10 +737,12 @@ impl FereseShell {
|
|||||||
.clock
|
.clock
|
||||||
.split_once(", ")
|
.split_once(", ")
|
||||||
.map_or(self.clock.as_str(), |(_, time)| time);
|
.map_or(self.clock.as_str(), |(_, time)| time);
|
||||||
let priority = self.hub_priority();
|
let priority = self.hub_priority_for(id);
|
||||||
let activity = if mode == Mode::Idle {
|
let activity = if mode == Mode::Idle {
|
||||||
priority
|
priority
|
||||||
} else if mode == Mode::Peek && matches!(peek, Peek::Recording | Peek::Battery) {
|
} else if mode == Mode::Peek
|
||||||
|
&& matches!(peek, Peek::Recording | Peek::Capture | Peek::Battery)
|
||||||
|
{
|
||||||
Some(peek)
|
Some(peek)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -766,7 +769,7 @@ impl FereseShell {
|
|||||||
.map_or_else(|| time.to_owned(), |notice| notice.title.clone())
|
.map_or_else(|| time.to_owned(), |notice| notice.title.clone())
|
||||||
} else if mode == Mode::Peek {
|
} else if mode == Mode::Peek {
|
||||||
match peek {
|
match peek {
|
||||||
Peek::Recording | Peek::Battery => String::new(),
|
Peek::Recording | Peek::Capture | Peek::Battery => String::new(),
|
||||||
Peek::Media => self.media.snapshot.selected.as_ref().map_or_else(
|
Peek::Media => self.media.snapshot.selected.as_ref().map_or_else(
|
||||||
|| "Playback ended".to_owned(),
|
|| "Playback ended".to_owned(),
|
||||||
|player| player.label().to_owned(),
|
|player| player.label().to_owned(),
|
||||||
@@ -806,16 +809,17 @@ impl FereseShell {
|
|||||||
.to_owned()
|
.to_owned()
|
||||||
};
|
};
|
||||||
let kind = if let Some(activity) = activity {
|
let kind = if let Some(activity) = activity {
|
||||||
if activity == Peek::Recording {
|
match activity {
|
||||||
status_ui::Menu::Overflow
|
Peek::Capture => status_ui::Menu::CaptureStatus,
|
||||||
} else {
|
Peek::Recording => status_ui::Menu::Overflow,
|
||||||
status_ui::Menu::Battery
|
_ => status_ui::Menu::Battery,
|
||||||
}
|
}
|
||||||
} else if mode == Mode::Toast {
|
} else if mode == Mode::Toast {
|
||||||
status_ui::Menu::Notifications
|
status_ui::Menu::Notifications
|
||||||
} else if mode == Mode::Peek {
|
} else if mode == Mode::Peek {
|
||||||
match peek {
|
match peek {
|
||||||
Peek::Recording => status_ui::Menu::Overflow,
|
Peek::Recording => status_ui::Menu::Overflow,
|
||||||
|
Peek::Capture => status_ui::Menu::CaptureStatus,
|
||||||
Peek::Battery => status_ui::Menu::Battery,
|
Peek::Battery => status_ui::Menu::Battery,
|
||||||
Peek::Media => status_ui::Menu::Media,
|
Peek::Media => status_ui::Menu::Media,
|
||||||
Peek::Date => status_ui::Menu::Workspaces,
|
Peek::Date => status_ui::Menu::Workspaces,
|
||||||
@@ -857,7 +861,7 @@ impl FereseShell {
|
|||||||
),
|
),
|
||||||
Mode::Peek => {
|
Mode::Peek => {
|
||||||
let leading = match peek {
|
let leading = match peek {
|
||||||
Peek::Recording | Peek::Battery => {
|
Peek::Recording | Peek::Capture | Peek::Battery => {
|
||||||
unreachable!("activity strip handled above")
|
unreachable!("activity strip handled above")
|
||||||
}
|
}
|
||||||
Peek::Media => self.hub_music_art(opacity),
|
Peek::Media => self.hub_music_art(opacity),
|
||||||
@@ -952,6 +956,10 @@ impl FereseShell {
|
|||||||
.center_y(content_height),
|
.center_y(content_height),
|
||||||
)
|
)
|
||||||
.name(match activity {
|
.name(match activity {
|
||||||
|
Some(Peek::Capture) => format!(
|
||||||
|
"{}. Open capture status",
|
||||||
|
self.capture_status.label().unwrap_or_default()
|
||||||
|
),
|
||||||
Some(Peek::Recording) => format!(
|
Some(Peek::Recording) => format!(
|
||||||
"{}. Open recording controls",
|
"{}. Open recording controls",
|
||||||
self.recorder.elapsed().map_or_else(
|
self.recorder.elapsed().map_or_else(
|
||||||
@@ -1246,6 +1254,10 @@ impl FereseShell {
|
|||||||
ItemKind::QuickSettings => (icons::morph::SETTINGS, Some(status_ui::Menu::System)),
|
ItemKind::QuickSettings => (icons::morph::SETTINGS, Some(status_ui::Menu::System)),
|
||||||
ItemKind::NightLight => (ferese_theme::icons::MOON, Some(status_ui::Menu::NightLight)),
|
ItemKind::NightLight => (ferese_theme::icons::MOON, Some(status_ui::Menu::NightLight)),
|
||||||
ItemKind::Network => (icons::WIFI, Some(status_ui::Menu::Network)),
|
ItemKind::Network => (icons::WIFI, Some(status_ui::Menu::Network)),
|
||||||
|
ItemKind::CaptureStatus if self.capture_status.active() => (
|
||||||
|
self.capture_status.icon(),
|
||||||
|
Some(status_ui::Menu::CaptureStatus),
|
||||||
|
),
|
||||||
ItemKind::Recording => (
|
ItemKind::Recording => (
|
||||||
match self.recorder.state {
|
match self.recorder.state {
|
||||||
crate::recording::State::Selecting => icons::RECORD_CANCEL,
|
crate::recording::State::Selecting => icons::RECORD_CANCEL,
|
||||||
|
|||||||
@@ -26,7 +26,17 @@ impl FereseShell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn hub_priority(&self) -> Option<Peek> {
|
pub(super) fn hub_priority(&self) -> Option<Peek> {
|
||||||
if matches!(
|
self.hub_priority_with_capture(self.capture_item_enabled())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn hub_priority_for(&self, id: window::Id) -> Option<Peek> {
|
||||||
|
self.hub_priority_with_capture(self.capture_item_visible(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hub_priority_with_capture(&self, capture: bool) -> Option<Peek> {
|
||||||
|
if capture && self.capture_status.active() {
|
||||||
|
Some(Peek::Capture)
|
||||||
|
} else if matches!(
|
||||||
self.recorder.state,
|
self.recorder.state,
|
||||||
recording::State::Recording(_) | recording::State::Saving
|
recording::State::Recording(_) | recording::State::Saving
|
||||||
) {
|
) {
|
||||||
@@ -37,7 +47,12 @@ impl FereseShell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn hub_peek_content(&self, id: window::Id) -> (Peek, f64) {
|
pub(super) fn hub_peek_content(&self, id: window::Id) -> (Peek, f64) {
|
||||||
if let Some(priority) = self.hub_priority() {
|
if let Some(priority) = self.hub_priority_for(id) {
|
||||||
|
if priority == Peek::Capture {
|
||||||
|
let label = self.capture_status.bar_label(false);
|
||||||
|
let width = (label.chars().count() as f64 * 7. + 72.).clamp(240., 440.);
|
||||||
|
return (priority, width);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
priority,
|
priority,
|
||||||
if priority == Peek::Recording && self.hub_low_battery().is_some() {
|
if priority == Peek::Recording && self.hub_low_battery().is_some() {
|
||||||
@@ -94,14 +109,19 @@ impl FereseShell {
|
|||||||
if self.snapshot.binding_mode.is_some() {
|
if self.snapshot.binding_mode.is_some() {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
let priority = self.hub_priority();
|
|
||||||
let changes: Vec<_> = self
|
let changes: Vec<_> = self
|
||||||
.outputs
|
.outputs
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|entry| entry.runtime.hub.is_some())
|
.filter(|entry| entry.runtime.hub.is_some())
|
||||||
.map(|entry| (entry.bar, self.hub_peek_content(entry.bar)))
|
.map(|entry| {
|
||||||
|
(
|
||||||
|
entry.bar,
|
||||||
|
self.hub_peek_content(entry.bar),
|
||||||
|
self.hub_priority_for(entry.bar),
|
||||||
|
)
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Task::batch(changes.into_iter().map(|(id, peek)| {
|
Task::batch(changes.into_iter().map(|(id, peek, priority)| {
|
||||||
Task::batch([
|
Task::batch([
|
||||||
self.transition_hub(id, Event::Activity { priority, peek }),
|
self.transition_hub(id, Event::Activity { priority, peek }),
|
||||||
self.transition_hub(id, Event::Resize),
|
self.transition_hub(id, Event::Resize),
|
||||||
@@ -144,6 +164,25 @@ impl FereseShell {
|
|||||||
.width(width)
|
.width(width)
|
||||||
.into();
|
.into();
|
||||||
}
|
}
|
||||||
|
if priority == Peek::Capture && self.capture_status.active() {
|
||||||
|
let foreground =
|
||||||
|
crate::color(self.config.theme.for_bar().text_primary).scale_alpha(opacity);
|
||||||
|
let mut label = self.capture_status.bar_label(false);
|
||||||
|
if matches!(self.recorder.state, recording::State::Recording(_)) {
|
||||||
|
label.push_str(" + REC");
|
||||||
|
}
|
||||||
|
return row![
|
||||||
|
self.capture_indicator(18, foreground, opacity),
|
||||||
|
self.render
|
||||||
|
.text(label)
|
||||||
|
.size(13)
|
||||||
|
.class(theme::Text::Color(foreground)),
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.width(width)
|
||||||
|
.into();
|
||||||
|
}
|
||||||
let elapsed = self.recorder.elapsed();
|
let elapsed = self.recorder.elapsed();
|
||||||
let saving = matches!(self.recorder.state, recording::State::Saving);
|
let saving = matches!(self.recorder.state, recording::State::Saving);
|
||||||
let mut strip = row::with_capacity(4)
|
let mut strip = row::with_capacity(4)
|
||||||
|
|||||||
@@ -60,11 +60,39 @@ impl FereseShell {
|
|||||||
} else {
|
} else {
|
||||||
theme.text_muted
|
theme.text_muted
|
||||||
});
|
});
|
||||||
let indicator = if kind == Menu::Battery {
|
let mut indicator = if kind == Menu::Battery {
|
||||||
self.battery_indicator(theme, 1.0, battery_percentage)
|
self.battery_indicator(theme, 1.0, battery_percentage)
|
||||||
} else {
|
} else {
|
||||||
accented_icon(source, metrics.icon_size, foreground, color(theme.accent)).into()
|
accented_icon(source, metrics.icon_size, foreground, color(theme.accent)).into()
|
||||||
};
|
};
|
||||||
|
if kind == Menu::Notifications {
|
||||||
|
let unread = self
|
||||||
|
.status
|
||||||
|
.notifications
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|n| n.count > 0 && !n.dnd);
|
||||||
|
let dot = container(cosmic::widget::Space::new())
|
||||||
|
.width(4)
|
||||||
|
.height(4)
|
||||||
|
.class(theme::Container::custom(move |_| container::Style {
|
||||||
|
background: unread.then_some(Background::Color(foreground)),
|
||||||
|
border: Border {
|
||||||
|
shape: BorderShape::Continuous,
|
||||||
|
radius: self.render.radius(2.0).into(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
}));
|
||||||
|
// Keep the popup anchor stable when reading clears the badge.
|
||||||
|
indicator = cosmic::iced::widget::stack![
|
||||||
|
indicator,
|
||||||
|
container(dot)
|
||||||
|
.width(metrics.icon_size)
|
||||||
|
.height(metrics.icon_size)
|
||||||
|
.align_x(Alignment::End),
|
||||||
|
]
|
||||||
|
.into();
|
||||||
|
}
|
||||||
let mut content = row![indicator].spacing(4).align_y(Alignment::Center);
|
let mut content = row![indicator].spacing(4).align_y(Alignment::Center);
|
||||||
|
|
||||||
if kind == Menu::Recording
|
if kind == Menu::Recording
|
||||||
@@ -77,32 +105,13 @@ impl FereseShell {
|
|||||||
.class(theme::Text::Color(foreground)),
|
.class(theme::Text::Color(foreground)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if kind == Menu::Notifications {
|
|
||||||
// Reading notifications clears the dot. Keep its space so the
|
|
||||||
// resulting panel allocation does not dismiss the new popover.
|
|
||||||
let unread = self
|
|
||||||
.status
|
|
||||||
.notifications
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|n| n.count > 0 && !n.dnd);
|
|
||||||
content = content.push(
|
|
||||||
container(cosmic::widget::Space::new())
|
|
||||||
.width(4)
|
|
||||||
.height(4)
|
|
||||||
.class(theme::Container::custom(move |_| container::Style {
|
|
||||||
background: unread.then_some(Background::Color(foreground)),
|
|
||||||
border: Border {
|
|
||||||
shape: BorderShape::Continuous,
|
|
||||||
radius: self.render.radius(2.0).into(),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
..Default::default()
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let content = bar_content(content, metrics.group_item_height);
|
let content = bar_content(content, metrics.group_item_height);
|
||||||
let recording_busy = kind == Menu::Recording && self.recorder.busy();
|
let recording_busy = kind == Menu::Recording && self.recorder.busy();
|
||||||
|
let icon_width = if kind == Menu::Battery {
|
||||||
|
crate::battery_indicator::WIDTH
|
||||||
|
} else {
|
||||||
|
f32::from(metrics.icon_size)
|
||||||
|
};
|
||||||
let control = button::custom(content)
|
let control = button::custom(content)
|
||||||
.name(if recording_busy {
|
.name(if recording_busy {
|
||||||
self.recorder.label().into()
|
self.recorder.label().into()
|
||||||
@@ -111,10 +120,7 @@ impl FereseShell {
|
|||||||
} else {
|
} else {
|
||||||
status_label(kind, &self.status)
|
status_label(kind, &self.status)
|
||||||
})
|
})
|
||||||
.padding([
|
.padding([0.0, ((metrics.height - icon_width) * 0.5).max(4.0)])
|
||||||
0.0,
|
|
||||||
((metrics.height - f32::from(metrics.icon_size)) * 0.5).max(4.0),
|
|
||||||
])
|
|
||||||
.height(metrics.group_item_height)
|
.height(metrics.group_item_height)
|
||||||
.on_press_with_rectangle(move |offset, bounds| {
|
.on_press_with_rectangle(move |offset, bounds| {
|
||||||
if kind == Menu::Recording {
|
if kind == Menu::Recording {
|
||||||
@@ -165,6 +171,7 @@ fn status_label(kind: Menu, s: &Snapshot) -> String {
|
|||||||
Menu::Calendar => "Calendar".into(),
|
Menu::Calendar => "Calendar".into(),
|
||||||
Menu::Overflow => "More panel items".into(),
|
Menu::Overflow => "More panel items".into(),
|
||||||
Menu::Recording => "Screen recording".into(),
|
Menu::Recording => "Screen recording".into(),
|
||||||
|
Menu::CaptureStatus => "Capture status".into(),
|
||||||
Menu::Media => "Now playing".into(),
|
Menu::Media => "Now playing".into(),
|
||||||
Menu::Battery => s.battery.as_ref().map_or_else(
|
Menu::Battery => s.battery.as_ref().map_or_else(
|
||||||
|| "Battery: unavailable".into(),
|
|| "Battery: unavailable".into(),
|
||||||
@@ -226,6 +233,7 @@ pub(super) fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
|
|||||||
Menu::Calendar => (ferese_theme::icons::CALENDAR, true),
|
Menu::Calendar => (ferese_theme::icons::CALENDAR, true),
|
||||||
Menu::Overflow => (ferese_theme::icons::CHEVRON_DOWN, true),
|
Menu::Overflow => (ferese_theme::icons::CHEVRON_DOWN, true),
|
||||||
Menu::Recording => (ferese_theme::icons::RECORD, true),
|
Menu::Recording => (ferese_theme::icons::RECORD, true),
|
||||||
|
Menu::CaptureStatus => (ferese_theme::icons::DISPLAY, true),
|
||||||
Menu::Media => (ferese_theme::icons::MEDIA, true),
|
Menu::Media => (ferese_theme::icons::MEDIA, true),
|
||||||
Menu::Battery => {
|
Menu::Battery => {
|
||||||
let Some(b) = &s.battery else {
|
let Some(b) = &s.battery else {
|
||||||
@@ -263,6 +271,63 @@ pub(super) fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::status;
|
use crate::status;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_hitboxes_stay_aligned_when_badges_and_battery_values_change() {
|
||||||
|
use cosmic::iced::Size;
|
||||||
|
use cosmic::iced::advanced::renderer::Headless;
|
||||||
|
use cosmic::iced::advanced::{layout, widget};
|
||||||
|
|
||||||
|
let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new(
|
||||||
|
cosmic::iced::Font::default(),
|
||||||
|
cosmic::iced::Pixels(14.),
|
||||||
|
Some("tiny-skia"),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let mut shell = crate::tests::shell_with_measured_panel();
|
||||||
|
let measure = |shell: &FereseShell, kind, percentage| {
|
||||||
|
let mut view = shell.view_status_item(kind, percentage, false);
|
||||||
|
let mut tree = widget::Tree::new(&view);
|
||||||
|
view.as_widget_mut()
|
||||||
|
.layout(
|
||||||
|
&mut tree,
|
||||||
|
&renderer,
|
||||||
|
&layout::Limits::new(Size::ZERO, Size::new(1000., 1000.)),
|
||||||
|
)
|
||||||
|
.size()
|
||||||
|
};
|
||||||
|
for height in [28., 36., 48.] {
|
||||||
|
shell.config.panels[0].geometry.height = Some(height);
|
||||||
|
let ordinary = measure(&shell, Menu::System, false);
|
||||||
|
for (count, dnd) in [(0, false), (3, false), (0, false), (3, true)] {
|
||||||
|
shell.status.notifications = Some(status::Notifications { count, dnd });
|
||||||
|
assert_eq!(
|
||||||
|
measure(&shell, Menu::Notifications, false),
|
||||||
|
ordinary,
|
||||||
|
"notification badges must stay inside the normal icon hitbox"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for percent in [0, 9, 50, 100] {
|
||||||
|
shell.status.battery = Some(status::Battery {
|
||||||
|
percent,
|
||||||
|
status: "Discharging".into(),
|
||||||
|
});
|
||||||
|
for percentage in [false, true] {
|
||||||
|
let battery = measure(&shell, Menu::Battery, percentage);
|
||||||
|
assert_eq!(battery.height, ordinary.height);
|
||||||
|
assert!(
|
||||||
|
battery.width <= ordinary.width.max(38.5),
|
||||||
|
"battery adds excess horizontal padding: {battery:?} vs {ordinary:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
battery.width >= crate::battery_indicator::WIDTH + 8.,
|
||||||
|
"battery must retain space around the body and terminal"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn icons_follow_real_states() {
|
fn icons_follow_real_states() {
|
||||||
let mut s = Snapshot {
|
let mut s = Snapshot {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ pub enum Menu {
|
|||||||
Battery,
|
Battery,
|
||||||
Calendar,
|
Calendar,
|
||||||
Recording,
|
Recording,
|
||||||
|
CaptureStatus,
|
||||||
Media,
|
Media,
|
||||||
Notifications,
|
Notifications,
|
||||||
System,
|
System,
|
||||||
@@ -101,6 +102,7 @@ impl Menu {
|
|||||||
Self::Battery => "Battery",
|
Self::Battery => "Battery",
|
||||||
Self::Calendar => "Calendar",
|
Self::Calendar => "Calendar",
|
||||||
Self::Recording => "Screen recording",
|
Self::Recording => "Screen recording",
|
||||||
|
Self::CaptureStatus => "Capture status",
|
||||||
Self::Media => "Now playing",
|
Self::Media => "Now playing",
|
||||||
Self::Notifications => "Notifications",
|
Self::Notifications => "Notifications",
|
||||||
}
|
}
|
||||||
@@ -113,7 +115,7 @@ impl Menu {
|
|||||||
Self::Bluetooth => status.bluetooth.is_some(),
|
Self::Bluetooth => status.bluetooth.is_some(),
|
||||||
Self::Audio => status.audio.is_some(),
|
Self::Audio => status.audio.is_some(),
|
||||||
Self::Battery => status.battery.is_some(),
|
Self::Battery => status.battery.is_some(),
|
||||||
Self::Calendar | Self::Recording | Self::Media => true,
|
Self::Calendar | Self::Recording | Self::CaptureStatus | Self::Media => true,
|
||||||
Self::Notifications => status.notifications.is_some(),
|
Self::Notifications => status.notifications.is_some(),
|
||||||
Self::System | Self::Overflow | Self::Workspaces | Self::Activities => true,
|
Self::System | Self::Overflow | Self::Workspaces | Self::Activities => true,
|
||||||
}
|
}
|
||||||
@@ -300,6 +302,33 @@ impl FereseShell {
|
|||||||
}
|
}
|
||||||
rows
|
rows
|
||||||
}
|
}
|
||||||
|
Menu::CaptureStatus => {
|
||||||
|
for detail in self.capture_status.details() {
|
||||||
|
rows = rows.push(
|
||||||
|
self.render
|
||||||
|
.text(detail)
|
||||||
|
.size(13)
|
||||||
|
.class(theme::Text::Color(style.primary)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(elapsed) = self.recorder.elapsed() {
|
||||||
|
rows = rows.push(
|
||||||
|
self.render
|
||||||
|
.text(format!("Ferese · REC {elapsed}"))
|
||||||
|
.size(13)
|
||||||
|
.class(theme::Text::Color(style.primary)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !self.capture_status.active() {
|
||||||
|
rows = rows.push(
|
||||||
|
self.render
|
||||||
|
.text("No active screen sharing or OBS output")
|
||||||
|
.size(13)
|
||||||
|
.class(theme::Text::Color(style.muted)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rows
|
||||||
|
}
|
||||||
Menu::Recording => rows.push(menu_button(
|
Menu::Recording => rows.push(menu_button(
|
||||||
self.render,
|
self.render,
|
||||||
if self.recorder.busy() {
|
if self.recorder.busy() {
|
||||||
@@ -443,6 +472,7 @@ impl FereseShell {
|
|||||||
}
|
}
|
||||||
Menu::Notifications => notification_controls(self, rows, style, false),
|
Menu::Notifications => notification_controls(self, rows, style, false),
|
||||||
Menu::Activities => rows.push(self.activity_list(menu.anchor.parent, p)),
|
Menu::Activities => rows.push(self.activity_list(menu.anchor.parent, p)),
|
||||||
|
Menu::CaptureStatus => rows.push(self.view_hub_menu(kind, p)),
|
||||||
Menu::Recording | Menu::Workspaces => rows,
|
Menu::Recording | Menu::Workspaces => rows,
|
||||||
Menu::Overflow => self.view_panel_overflow(rows),
|
Menu::Overflow => self.view_panel_overflow(rows),
|
||||||
Menu::Media => media::view(self, rows, style),
|
Menu::Media => media::view(self, rows, style),
|
||||||
@@ -581,6 +611,79 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capture_status_popover_shows_application_and_source() {
|
||||||
|
use cosmic::iced::advanced::renderer::Headless;
|
||||||
|
use cosmic::iced::advanced::{Layout, layout, widget};
|
||||||
|
use cosmic::iced::{Rectangle, Size};
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Labels(Vec<String>);
|
||||||
|
impl widget::Operation for Labels {
|
||||||
|
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||||
|
visit(self);
|
||||||
|
}
|
||||||
|
fn text(&mut self, _: Option<&widget::Id>, _: Rectangle, text: &str) {
|
||||||
|
self.0.push(text.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new(
|
||||||
|
cosmic::iced::Font::default(),
|
||||||
|
cosmic::iced::Pixels(14.),
|
||||||
|
Some("tiny-skia"),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let mut app = crate::tests::shell_with_measured_panel();
|
||||||
|
app.capture_status
|
||||||
|
.apply(crate::capture_status::Event::Sharing(vec![
|
||||||
|
ferese_ipc::capture::Session {
|
||||||
|
app: "com.obsproject.Studio".into(),
|
||||||
|
sources: vec!["Workspace 2".into()],
|
||||||
|
},
|
||||||
|
]));
|
||||||
|
let bar = app.outputs[0].bar;
|
||||||
|
drop(app.open_menu(
|
||||||
|
Menu::CaptureStatus,
|
||||||
|
PopoverAnchor {
|
||||||
|
parent: bar,
|
||||||
|
panel: app.config.panels[0].id.clone(),
|
||||||
|
item: None,
|
||||||
|
rectangle: Rectangle {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 100,
|
||||||
|
height: 28,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
));
|
||||||
|
for hub in [false, true] {
|
||||||
|
let mut view = if hub {
|
||||||
|
app.view_hub_menu(Menu::CaptureStatus, 1.)
|
||||||
|
} else {
|
||||||
|
app.view_status_menu_at(Instant::now() + Duration::from_secs(1))
|
||||||
|
};
|
||||||
|
let mut tree = widget::Tree::new(&view);
|
||||||
|
let node = view.as_widget_mut().layout(
|
||||||
|
&mut tree,
|
||||||
|
&renderer,
|
||||||
|
&layout::Limits::new(Size::ZERO, Size::new(300., 500.)),
|
||||||
|
);
|
||||||
|
let mut labels = Labels::default();
|
||||||
|
view.as_widget_mut()
|
||||||
|
.operate(&mut tree, Layout::new(&node), &renderer, &mut labels);
|
||||||
|
assert!(
|
||||||
|
labels.0.iter().any(|text| text == "OBS · Sharing"),
|
||||||
|
"{hub}: {:?}",
|
||||||
|
labels.0
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
labels.0.iter().any(|text| text == "Workspace 2"),
|
||||||
|
"{hub}: {:?}",
|
||||||
|
labels.0
|
||||||
|
);
|
||||||
|
assert!(!labels.0.iter().any(|text| text.contains("LIVE")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn activity_popups_scroll_to_the_last_row_in_regular_panels() {
|
fn activity_popups_scroll_to_the_last_row_in_regular_panels() {
|
||||||
use cosmic::iced::advanced::renderer::Headless;
|
use cosmic::iced::advanced::renderer::Headless;
|
||||||
|
|||||||
Reference in new issue
Block a user