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
+1692 -67

No files matched your search

+3 -1
View File
@@ -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]
+2
View File
@@ -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)
}
@@ -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(
+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 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<ferese_ipc::media::Snapshot>),
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,
+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)]
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<Message>> = 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,
+44 -5
View File
@@ -26,7 +26,17 @@ impl FereseShell {
}
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,
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)
+94 -29
View File
@@ -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 {
+104 -1
View File
@@ -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<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]
fn activity_popups_scroll_to_the_last_row_in_regular_panels() {
use cosmic::iced::advanced::renderer::Headless;