diff --git a/Cargo.lock b/Cargo.lock index e49955a..e2be3b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -621,7 +621,7 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.3.1", ] [[package]] @@ -630,6 +630,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "block-buffer" version = "0.12.1" @@ -833,7 +842,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -1213,6 +1222,15 @@ dependencies = [ "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]] name = "cpufeatures" version = "0.3.1" @@ -1292,6 +1310,16 @@ dependencies = [ "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]] name = "crypto-common" version = "0.2.2" @@ -1369,6 +1397,12 @@ dependencies = [ "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]] name = "data-url" version = "0.3.2" @@ -1435,15 +1469,25 @@ dependencies = [ "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]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", + "block-buffer 0.12.1", "const-oid", - "crypto-common", + "crypto-common 0.2.2", ] [[package]] @@ -1960,8 +2004,10 @@ dependencies = [ "pipewire", "serde", "serde_json", + "sha2", "tempfile", "tokio", + "tokio-tungstenite", "ureq", "url", "wayland-client", @@ -2428,6 +2474,16 @@ dependencies = [ "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]] name = "gethostname" version = "1.1.0" @@ -2866,6 +2922,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "hybrid-array" version = "0.4.15" @@ -5725,6 +5797,17 @@ dependencies = [ "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]] name = "sha2" version = "0.11.0" @@ -5732,8 +5815,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -6386,6 +6469,18 @@ dependencies = [ "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]] name = "toml" version = "0.5.11" @@ -6530,6 +6625,23 @@ dependencies = [ "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]] name = "type-map" version = "0.5.1" @@ -6741,6 +6853,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/crates/ferese-ipc/src/capture.rs b/crates/ferese-ipc/src/capture.rs new file mode 100644 index 0000000..835459d --- /dev/null +++ b/crates/ferese-ipc/src/capture.rs @@ -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, +} diff --git a/crates/ferese-ipc/src/lib.rs b/crates/ferese-ipc/src/lib.rs index 47aa7ba..b7c074a 100644 --- a/crates/ferese-ipc/src/lib.rs +++ b/crates/ferese-ipc/src/lib.rs @@ -1,5 +1,6 @@ //! Framed IPC messages and the services exposed over compositor connections. pub mod activities; +pub mod capture; mod connection; pub mod events; mod frame; diff --git a/crates/ferese-panel/src/panel/composition.rs b/crates/ferese-panel/src/panel/composition.rs index d10c3a8..237b299 100644 --- a/crates/ferese-panel/src/panel/composition.rs +++ b/crates/ferese-panel/src/panel/composition.rs @@ -126,6 +126,7 @@ pub enum ItemKind { Network, Audio, Recording, + CaptureStatus, Notifications, Battery { #[serde(default = "yes")] @@ -161,6 +162,7 @@ impl ItemKind { Self::Network => "Network", Self::Audio => "Audio", Self::Recording => "Recording", + Self::CaptureStatus => "Capture status", Self::Notifications => "Notifications", Self::Battery { .. } => "Battery", Self::Clock => "Clock", @@ -172,7 +174,9 @@ impl ItemKind { pub fn representations(self) -> &'static [Representation] { use Representation::*; 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::Workspaces { .. } | Self::FocusedWindow => &[Wide], _ => &[Icon], @@ -321,6 +325,7 @@ impl Item { match self.kind { ItemKind::Activities => availability.activities, ItemKind::Media => availability.media, + ItemKind::CaptureStatus => availability.capture, ItemKind::Network => availability.network, ItemKind::Audio => availability.audio, ItemKind::Notifications => availability.notifications, @@ -333,6 +338,7 @@ impl Item { #[derive(Clone, Copy, Debug, Default)] pub struct Availability { + pub capture: bool, pub media: bool, pub activities: bool, pub network: bool, @@ -698,6 +704,7 @@ impl Panel { item("network", Network), item("audio", Audio), item("recording", Recording), + item("capture-status", CaptureStatus), item("notifications", Notifications), item("battery", Battery { percentage: true }), ]; @@ -817,6 +824,7 @@ mod tests { "network", "audio", "recording", + "capture-status", "notifications", "battery", "clock", @@ -858,6 +866,7 @@ mod tests { ); assert_eq!( visible(Availability { + capture: true, activities: true, media: true, network: true, @@ -870,7 +879,7 @@ mod tests { .map(|item| item.id.0.as_str()) .collect::>() ); - assert_eq!(items(&panel).len(), 13); + assert_eq!(items(&panel).len(), 14); } #[test] diff --git a/crates/ferese-theme/assets/icons/status/obs.svg b/crates/ferese-theme/assets/icons/status/obs.svg new file mode 100644 index 0000000..f6bf1ac --- /dev/null +++ b/crates/ferese-theme/assets/icons/status/obs.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/ferese-theme/src/icons.rs b/crates/ferese-theme/src/icons.rs index 79f8173..d261d5b 100644 --- a/crates/ferese-theme/src/icons.rs +++ b/crates/ferese-theme/src/icons.rs @@ -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_STOP: &[u8] = include_bytes!("../assets/icons/status/record-stop.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 SETTINGS: &[u8] = include_bytes!("../assets/icons/status/settings.svg"); pub const SUSPEND: &[u8] = include_bytes!("../assets/icons/status/suspend.svg"); diff --git a/docs/panel-configuration.md b/docs/panel-configuration.md index d519526..1b39499 100644 --- a/docs/panel-configuration.md +++ b/docs/panel-configuration.md @@ -203,6 +203,7 @@ disappears when that output has no window title to show. | Network | `network` | Opens Wi-Fi controls | | Audio | `audio` | Opens audio controls | | 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 | | Battery | `battery` | `percentage`: boolean, default `#true` | | Clock | `clock` | Full, Compact and Icon representations | diff --git a/docs/screen-sharing.md b/docs/screen-sharing.md index 0305037..05872fb 100644 --- a/docs/screen-sharing.md +++ b/docs/screen-sharing.md @@ -23,6 +23,44 @@ If recording fails, a notification reports the error and any unfinished ## 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, choose a display, workspace, or window and click **Share**. Nothing is selected initially. Apps requesting multiple sources can select up to eight. diff --git a/portal/src/backend.rs b/portal/src/backend.rs index 11ad20e..91c6556 100644 --- a/portal/src/backend.rs +++ b/portal/src/backend.rs @@ -79,6 +79,7 @@ struct Session { source_types: u32, bar_controlled: bool, recording: Option, + sources: Vec, persist_mode: u32, restore: Option, cancel: Arc, @@ -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. // The ordinary portal picker and all session revocation rules still apply. struct RecorderControl(Backend); @@ -262,6 +292,9 @@ impl Backend { let session = self.sessions.lock().await.remove(path); if let Some(session) = session { session.cancel.stop(); + if session.phase == Phase::Running && !session.bar_controlled { + capture_status_changed(connection).await; + } if let Ok(emitter) = SignalEmitter::new(connection, path) { let _ = SessionObject::closed(&emitter).await; } @@ -346,6 +379,7 @@ impl Backend { source_types: 1, bar_controlled: false, recording: None, + sources: Vec::new(), persist_mode: 0, restore: None, cancel: Arc::default(), @@ -493,12 +527,16 @@ impl Backend { .await; match result { - Ok((children, streams, mut reply, recording)) + Ok((children, streams, mut reply, recording, sources)) if !cancel.stopped.load(Ordering::SeqCst) => { if let Some(session) = self.sessions.lock().await.get_mut(session_handle.as_str()) { session.phase = Phase::Running; session.recording = recording; + session.sources = sources; + } + if !bar_controlled { + capture_status_changed(connection).await; } let backend = self.clone(); let connection = connection.clone(); @@ -680,6 +718,7 @@ async fn start_streams( Vec<(u32, Options)>, Options, Option, + Vec, ), StartError, > { @@ -1000,7 +1039,18 @@ async fn start_streams( }) .collect::, _>>()?; - 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 { @@ -1123,15 +1173,13 @@ fn validate_selection( } async fn supervise(mut children: Vec, cancel: Arc) { - 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(); @@ -1153,6 +1201,7 @@ pub async fn run() -> Result<(), Box> { let connection = zbus::connection::Builder::session()? .name("org.freedesktop.impl.portal.desktop.ferese")? .serve_at(PATH, backend.clone())? + .serve_at(ferese_ipc::capture::PATH, CaptureStatus(backend.clone()))? .serve_at(PATH, settings.clone())? .serve_at(PATH, crate::desktop::Screenshot(requests.clone()))? .serve_at(PATH, crate::desktop::Wallpaper(requests.clone()))? @@ -1215,6 +1264,71 @@ pub async fn run() -> Result<(), Box> { mod tests { 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 = + 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] async fn cancelled_async_work_holds_session_capacity_until_worker_exits() { let backend = Backend::default(); diff --git a/shell/ferese-settings/src/panel_controls.rs b/shell/ferese-settings/src/panel_controls.rs index 07827fd..2307fbd 100644 --- a/shell/ferese-settings/src/panel_controls.rs +++ b/shell/ferese-settings/src/panel_controls.rs @@ -35,7 +35,7 @@ impl std::fmt::Display for StyleChoice { } } -const CATALOG: [CatalogItem; 14] = [ +const CATALOG: [CatalogItem; 15] = [ CatalogItem(ItemKind::Overview), CatalogItem(ItemKind::Workspaces { style: ferese_panel::WorkspaceStyle::Dots, @@ -48,6 +48,7 @@ const CATALOG: [CatalogItem; 14] = [ CatalogItem(ItemKind::Network), CatalogItem(ItemKind::Audio), CatalogItem(ItemKind::Recording), + CatalogItem(ItemKind::CaptureStatus), CatalogItem(ItemKind::Notifications), CatalogItem(ItemKind::Battery { percentage: true }), 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::Audio => icons::VOLUME_HIGH, ItemKind::Recording => icons::RECORD, + ItemKind::CaptureStatus => icons::DISPLAY, ItemKind::Notifications => icons::NOTIFICATIONS, ItemKind::Battery { .. } => icons::BATTERY_75, ItemKind::Clock => icons::CALENDAR, diff --git a/shell/ferese-settings/src/panel_preview.rs b/shell/ferese-settings/src/panel_preview.rs index 8336e82..ff4a0b6 100644 --- a/shell/ferese-settings/src/panel_preview.rs +++ b/shell/ferese-settings/src/panel_preview.rs @@ -7,6 +7,7 @@ use ferese_theme::panel::{Sample, Spacing, frame}; use crate::{App, Element, Message, panel_controls, visuals}; const SAMPLE_AVAILABILITY: Availability = Availability { + capture: true, activities: true, media: true, network: true, @@ -407,6 +408,7 @@ impl App { ItemKind::Network => icons::WIFI_FULL, ItemKind::Audio => icons::VOLUME_HIGH, ItemKind::Recording => icons::RECORD, + ItemKind::CaptureStatus => icons::DISPLAY, ItemKind::Notifications => icons::NOTIFICATIONS, ItemKind::Battery { .. } => icons::BATTERY_75, ItemKind::Clock => icons::CALENDAR, diff --git a/shell/ferese-shell/Cargo.toml b/shell/ferese-shell/Cargo.toml index 1b74016..ea6aadb 100644 --- a/shell/ferese-shell/Cargo.toml +++ b/shell/ferese-shell/Cargo.toml @@ -29,13 +29,15 @@ iced_accessibility.workspace = true serde.workspace = true serde_json.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 zbus = "5.19.0" pipewire = { version = "0.10.1", features = ["v0_3_65"] } image-codec = { package = "image", version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] } url = "2.5.8" 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"] } [dev-dependencies] diff --git a/shell/ferese-shell/src/bar.rs b/shell/ferese-shell/src/bar.rs index 7cd0b2b..8012477 100644 --- a/shell/ferese-shell/src/bar.rs +++ b/shell/ferese-shell/src/bar.rs @@ -94,6 +94,7 @@ impl FereseShell { let effects = output.and_then(|output| output.effects.as_ref()); let compositor_material = effects.is_some(); let availability = Availability { + capture: self.capture_status.active(), activities: self.activity_available(id), media: self.media.snapshot.selected.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::Audio => self.view_status_item(status_ui::Menu::Audio, false, selected), + ItemKind::CaptureStatus => self.view_capture_status(representation, selected), ItemKind::Recording => { self.view_status_item(status_ui::Menu::Recording, false, selected) } diff --git a/shell/ferese-shell/src/battery_indicator.rs b/shell/ferese-shell/src/battery_indicator.rs index 37b70f3..1e6325b 100644 --- a/shell/ferese-shell/src/battery_indicator.rs +++ b/shell/ferese-shell/src/battery_indicator.rs @@ -4,6 +4,7 @@ use cosmic::widget::{Space, container, row}; use cosmic::{Element, theme}; const BODY_WIDTH: f32 = 28.; +pub(crate) const WIDTH: f32 = BODY_WIDTH + 1. + 1.5; impl FereseShell { pub(crate) fn battery_indicator( diff --git a/shell/ferese-shell/src/capture_status.rs b/shell/ferese-shell/src/capture_status.rs new file mode 100644 index 0000000..df1d088 --- /dev/null +++ b/shell/ferese-shell/src/capture_status.rs @@ -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), + Obs(obs::Snapshot), +} + +#[derive(Default)] +pub(crate) struct Model { + pub sessions: Vec, + pub obs: obs::Snapshot, + config: Option>, +} + +impl Model { + pub fn new() -> Self { + Self { + config: obs::Config::load().map(Arc::new), + ..Self::default() + } + } + + pub fn subscription(&self) -> Subscription { + 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 { + 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 { + 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> { + 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> { + 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()); + } +} diff --git a/shell/ferese-shell/src/capture_status/obs.rs b/shell/ferese-shell/src/capture_status/obs.rs new file mode 100644 index 0000000..d4db7a7 --- /dev/null +++ b/shell/ferese-shell/src/capture_status/obs.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + [ + 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 { + 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>; + +async fn receive(socket: &mut Socket) -> Result { + 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 { + 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) -> impl Stream + 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()); + } +} diff --git a/shell/ferese-shell/src/capture_status/portal.rs b/shell/ferese-shell/src/capture_status/portal.rs new file mode 100644 index 0000000..6b7f6a4 --- /dev/null +++ b/shell/ferese-shell/src/capture_status/portal.rs @@ -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> { + 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::>(&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 { + stream_at(None) +} + +fn stream_at(address: Option) -> impl Stream { + 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); + #[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(); + } +} diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 06c2f64..b198171 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -6,6 +6,7 @@ mod surfaces; use bar::{bar_content, output_bar_hidden}; use surfaces::{EffectsBinding, EffectsContext, native_wayland_surface}; +mod capture_status; mod clock; mod compositor_ipc; mod config; @@ -167,6 +168,7 @@ struct FereseShell { status_service: status::Service, status: status::Snapshot, recorder: recording::Recorder, + capture_status: capture_status::Model, media: media::Model, activities: activities::Model, calendar_offset: i32, @@ -237,6 +239,7 @@ enum Message { ), ClockChanged(String), RecorderEvent(recording::Event), + CaptureStatus(capture_status::Event), RecorderElapsed, MediaUpdated(std::sync::Arc), MediaAction(serde_json::Value), @@ -354,6 +357,7 @@ impl cosmic::Application for FereseShell { status_service: status::Service::start(config.status.settings_command.clone()), status: status::Snapshot::default(), recorder: recording::Recorder::default(), + capture_status: capture_status::Model::new(), media: media::Model::default(), activities: activities::Model::default(), calendar_offset: 0, @@ -453,6 +457,16 @@ impl cosmic::Application for FereseShell { self.media_tick_subscription().map(|_| Message::MediaTick), self.media.art.subscription().map(Message::MediaArt), 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 .elapsed_subscription() .map(|_| Message::RecorderElapsed), @@ -947,6 +961,10 @@ impl cosmic::Application for FereseShell { self.recorder.handle(event); self.sync_hub_activity() } + Message::CaptureStatus(event) => { + self.capture_status.apply(event); + self.sync_hub_activity() + } Message::RecorderElapsed => Task::none(), Message::PanelScale(id, scale) => { 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: Default::default(), recorder: Default::default(), + capture_status: Default::default(), media: Default::default(), activities: Default::default(), calendar_offset: 0, diff --git a/shell/ferese-shell/src/panel_hub.rs b/shell/ferese-shell/src/panel_hub.rs index ed27a5c..351da30 100644 --- a/shell/ferese-shell/src/panel_hub.rs +++ b/shell/ferese-shell/src/panel_hub.rs @@ -42,6 +42,7 @@ pub(super) fn content_width(panel: &Panel, size: Option<(i32, i32)>) -> f64 { #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Peek { Recording, + Capture, Battery, Media, Date, @@ -284,7 +285,7 @@ impl FereseShell { entered, enabled: self.panel_for_bar(id).morph.peek_on_hover, 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() || self.bar_hidden(id) - || self.hub_priority().is_some() + || self.hub_priority_for(id).is_some() || !matches!( self.hub_mode(id), 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; } - let artwork_width = if self.hub_priority().is_some() { + let artwork_width = if self.hub_priority_for(id).is_some() { 64. } else if self .media @@ -720,7 +721,7 @@ impl FereseShell { let content: Element<'_, cosmic::Action> = match mode { Mode::Idle | Mode::Peek if self.has_compact_activities(id) - && self.hub_priority().is_none() + && self.hub_priority_for(id).is_none() && !(mode == Mode::Peek && peek == Peek::Workspaces) => { self.activity_compact(id, opacity, intrinsic) @@ -736,10 +737,12 @@ impl FereseShell { .clock .split_once(", ") .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 { 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) } else { None @@ -766,7 +769,7 @@ impl FereseShell { .map_or_else(|| time.to_owned(), |notice| notice.title.clone()) } else if mode == Mode::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( || "Playback ended".to_owned(), |player| player.label().to_owned(), @@ -806,16 +809,17 @@ impl FereseShell { .to_owned() }; let kind = if let Some(activity) = activity { - if activity == Peek::Recording { - status_ui::Menu::Overflow - } else { - status_ui::Menu::Battery + match activity { + Peek::Capture => status_ui::Menu::CaptureStatus, + Peek::Recording => status_ui::Menu::Overflow, + _ => status_ui::Menu::Battery, } } else if mode == Mode::Toast { status_ui::Menu::Notifications } else if mode == Mode::Peek { match peek { Peek::Recording => status_ui::Menu::Overflow, + Peek::Capture => status_ui::Menu::CaptureStatus, Peek::Battery => status_ui::Menu::Battery, Peek::Media => status_ui::Menu::Media, Peek::Date => status_ui::Menu::Workspaces, @@ -857,7 +861,7 @@ impl FereseShell { ), Mode::Peek => { let leading = match peek { - Peek::Recording | Peek::Battery => { + Peek::Recording | Peek::Capture | Peek::Battery => { unreachable!("activity strip handled above") } Peek::Media => self.hub_music_art(opacity), @@ -952,6 +956,10 @@ impl FereseShell { .center_y(content_height), ) .name(match activity { + Some(Peek::Capture) => format!( + "{}. Open capture status", + self.capture_status.label().unwrap_or_default() + ), Some(Peek::Recording) => format!( "{}. Open recording controls", self.recorder.elapsed().map_or_else( @@ -1246,6 +1254,10 @@ impl FereseShell { ItemKind::QuickSettings => (icons::morph::SETTINGS, Some(status_ui::Menu::System)), ItemKind::NightLight => (ferese_theme::icons::MOON, Some(status_ui::Menu::NightLight)), 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 => ( match self.recorder.state { crate::recording::State::Selecting => icons::RECORD_CANCEL, diff --git a/shell/ferese-shell/src/panel_hub/activity.rs b/shell/ferese-shell/src/panel_hub/activity.rs index dc8856a..30ac247 100644 --- a/shell/ferese-shell/src/panel_hub/activity.rs +++ b/shell/ferese-shell/src/panel_hub/activity.rs @@ -26,7 +26,17 @@ impl FereseShell { } pub(super) fn hub_priority(&self) -> Option { - if matches!( + self.hub_priority_with_capture(self.capture_item_enabled()) + } + + pub(super) fn hub_priority_for(&self, id: window::Id) -> Option { + self.hub_priority_with_capture(self.capture_item_visible(id)) + } + + fn hub_priority_with_capture(&self, capture: bool) -> Option { + if capture && self.capture_status.active() { + Some(Peek::Capture) + } else if matches!( self.recorder.state, recording::State::Recording(_) | recording::State::Saving ) { @@ -37,7 +47,12 @@ impl FereseShell { } 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 ( priority, if priority == Peek::Recording && self.hub_low_battery().is_some() { @@ -94,14 +109,19 @@ impl FereseShell { if self.snapshot.binding_mode.is_some() { return Task::none(); } - let priority = self.hub_priority(); let changes: Vec<_> = self .outputs .iter() .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(); - Task::batch(changes.into_iter().map(|(id, peek)| { + Task::batch(changes.into_iter().map(|(id, peek, priority)| { Task::batch([ self.transition_hub(id, Event::Activity { priority, peek }), self.transition_hub(id, Event::Resize), @@ -144,6 +164,25 @@ impl FereseShell { .width(width) .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 saving = matches!(self.recorder.state, recording::State::Saving); let mut strip = row::with_capacity(4) diff --git a/shell/ferese-shell/src/status_ui/bar.rs b/shell/ferese-shell/src/status_ui/bar.rs index 27c746c..89a9990 100644 --- a/shell/ferese-shell/src/status_ui/bar.rs +++ b/shell/ferese-shell/src/status_ui/bar.rs @@ -60,11 +60,39 @@ impl FereseShell { } else { theme.text_muted }); - let indicator = if kind == Menu::Battery { + let mut indicator = if kind == Menu::Battery { self.battery_indicator(theme, 1.0, battery_percentage) } else { 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); if kind == Menu::Recording @@ -77,32 +105,13 @@ impl FereseShell { .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 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) .name(if recording_busy { self.recorder.label().into() @@ -111,10 +120,7 @@ impl FereseShell { } else { status_label(kind, &self.status) }) - .padding([ - 0.0, - ((metrics.height - f32::from(metrics.icon_size)) * 0.5).max(4.0), - ]) + .padding([0.0, ((metrics.height - icon_width) * 0.5).max(4.0)]) .height(metrics.group_item_height) .on_press_with_rectangle(move |offset, bounds| { if kind == Menu::Recording { @@ -165,6 +171,7 @@ fn status_label(kind: Menu, s: &Snapshot) -> String { Menu::Calendar => "Calendar".into(), Menu::Overflow => "More panel items".into(), Menu::Recording => "Screen recording".into(), + Menu::CaptureStatus => "Capture status".into(), Menu::Media => "Now playing".into(), Menu::Battery => s.battery.as_ref().map_or_else( || "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::Overflow => (ferese_theme::icons::CHEVRON_DOWN, true), Menu::Recording => (ferese_theme::icons::RECORD, true), + Menu::CaptureStatus => (ferese_theme::icons::DISPLAY, true), Menu::Media => (ferese_theme::icons::MEDIA, true), Menu::Battery => { let Some(b) = &s.battery else { @@ -263,6 +271,63 @@ pub(super) fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) { mod tests { use super::*; 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] fn icons_follow_real_states() { let mut s = Snapshot { diff --git a/shell/ferese-shell/src/status_ui/mod.rs b/shell/ferese-shell/src/status_ui/mod.rs index bea5f0f..e5af0cf 100644 --- a/shell/ferese-shell/src/status_ui/mod.rs +++ b/shell/ferese-shell/src/status_ui/mod.rs @@ -48,6 +48,7 @@ pub enum Menu { Battery, Calendar, Recording, + CaptureStatus, Media, Notifications, System, @@ -101,6 +102,7 @@ impl Menu { Self::Battery => "Battery", Self::Calendar => "Calendar", Self::Recording => "Screen recording", + Self::CaptureStatus => "Capture status", Self::Media => "Now playing", Self::Notifications => "Notifications", } @@ -113,7 +115,7 @@ impl Menu { Self::Bluetooth => status.bluetooth.is_some(), Self::Audio => status.audio.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::System | Self::Overflow | Self::Workspaces | Self::Activities => true, } @@ -300,6 +302,33 @@ impl FereseShell { } 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( self.render, if self.recorder.busy() { @@ -443,6 +472,7 @@ impl FereseShell { } Menu::Notifications => notification_controls(self, rows, style, false), 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::Overflow => self.view_panel_overflow(rows), Menu::Media => media::view(self, rows, style), @@ -581,6 +611,79 @@ mod tests { use super::*; 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); + 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] fn activity_popups_scroll_to_the_last_row_in_regular_panels() { use cosmic::iced::advanced::renderer::Headless;