Add configurable capture and OBS status to the panel

This commit is contained in:
Samuel Onoja committed 2026-10-25 16:35:51 +01:00
1 parent 3d08d64c41
commit e03d491353
22 files changed
+1691 -66

No files matched your search

Generated
+124 -6
View File
@@ -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"
+11
View File
@@ -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
View File
@@ -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;
+11 -2
View File
@@ -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

+1
View File
@@ -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");
+1
View File
@@ -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 |
+38
View File
@@ -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.
+123 -9
View File
@@ -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)
|| children
.iter_mut()
.any(|c| !matches!(c.try_wait(), Ok(None)))
{ {
break; use futures_util::{StreamExt, stream::FuturesUnordered};
let mut exits: FuturesUnordered<_> = children.iter_mut().map(Child::wait).collect();
tokio::select! {
_ = cancel.wait() => (),
_ = exits.next() => (),
} }
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();
+3 -1
View File
@@ -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,
+3 -1
View File
@@ -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]
+2
View File
@@ -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(
+350
View File
@@ -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();
}
}
+19
View File
@@ -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,
+24 -12
View File
@@ -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,
+44 -5
View File
@@ -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)
+94 -29
View File
@@ -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 {
+104 -1
View File
@@ -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;