Add configurable capture and OBS status to the panel
This commit is contained in:
1 parent
3d08d64c41
commit
e03d491353
22 files changed
+1692
-67
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
mod obs;
|
||||
mod portal;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use cosmic::iced::Subscription;
|
||||
use ferese_ipc::capture::Session;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum Event {
|
||||
Sharing(Vec<Session>),
|
||||
Obs(obs::Snapshot),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Model {
|
||||
pub sessions: Vec<Session>,
|
||||
pub obs: obs::Snapshot,
|
||||
config: Option<Arc<obs::Config>>,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
config: obs::Config::load().map(Arc::new),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscription(&self) -> Subscription<Event> {
|
||||
Subscription::batch([
|
||||
Subscription::run(portal::stream),
|
||||
self.config
|
||||
.clone()
|
||||
.map_or_else(Subscription::none, |config| {
|
||||
Subscription::run_with(config, obs::stream)
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn tick_subscription(&self, visible: bool) -> Subscription<()> {
|
||||
if visible && self.obs.ticking() {
|
||||
cosmic::iced::time::every(Duration::from_secs(1)).map(|_| ())
|
||||
} else {
|
||||
Subscription::none()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply(&mut self, event: Event) {
|
||||
match event {
|
||||
Event::Sharing(sessions) => self.sessions = sessions,
|
||||
Event::Obs(snapshot) => self.obs = snapshot,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active(&self) -> bool {
|
||||
!self.sessions.is_empty() || self.obs.active()
|
||||
}
|
||||
|
||||
pub fn label(&self) -> Option<String> {
|
||||
if let Some(label) = self.obs.label() {
|
||||
return Some(label);
|
||||
}
|
||||
let first = self.sessions.first()?;
|
||||
let app = app_name(&first.app);
|
||||
Some(if self.sessions.len() == 1 {
|
||||
format!("{app} · Sharing")
|
||||
} else {
|
||||
format!("{app} +{} · Sharing", self.sessions.len() - 1)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn details(&self) -> Vec<String> {
|
||||
let mut details = self.obs.details();
|
||||
for session in &self.sessions {
|
||||
details.push(format!("{} · Sharing", app_name(&session.app)));
|
||||
details.extend(session.sources.iter().cloned());
|
||||
}
|
||||
details
|
||||
}
|
||||
|
||||
pub fn icon(&self) -> &'static [u8] {
|
||||
if self.obs.active()
|
||||
|| self
|
||||
.sessions
|
||||
.first()
|
||||
.is_some_and(|session| is_obs(&session.app))
|
||||
{
|
||||
ferese_theme::icons::OBS
|
||||
} else {
|
||||
ferese_theme::icons::DISPLAY
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bar_label(&self, compact: bool) -> String {
|
||||
self.obs.bar_label(compact).unwrap_or_else(|| {
|
||||
if self.sessions.len() > 1 {
|
||||
format!("Sharing +{}", self.sessions.len() - 1)
|
||||
} else {
|
||||
"Sharing".into()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn is_obs(app: &str) -> bool {
|
||||
matches!(app, "com.obsproject.Studio" | "obs" | "obs-studio")
|
||||
}
|
||||
|
||||
fn app_name(app: &str) -> String {
|
||||
if is_obs(app) {
|
||||
"OBS".into()
|
||||
} else if app.is_empty() {
|
||||
"Screen capture".into()
|
||||
} else {
|
||||
app.chars().filter(|c| !c.is_control()).take(48).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn configured(panel: &ferese_panel::Panel) -> bool {
|
||||
[&panel.start, &panel.center, &panel.end]
|
||||
.into_iter()
|
||||
.flat_map(|zone| &zone.groups)
|
||||
.flat_map(|group| &group.items)
|
||||
.any(|item| item.visible && item.kind == ferese_panel::ItemKind::CaptureStatus)
|
||||
}
|
||||
|
||||
impl crate::FereseShell {
|
||||
pub(crate) fn capture_indicator(
|
||||
&self,
|
||||
size: u16,
|
||||
foreground: cosmic::iced::Color,
|
||||
opacity: f32,
|
||||
) -> cosmic::Element<'_, cosmic::Action<crate::Message>> {
|
||||
use cosmic::iced::{Alignment, Background, Border};
|
||||
use cosmic::{
|
||||
theme,
|
||||
widget::{Space, container, row},
|
||||
};
|
||||
let mut indicator = row![crate::bar_icon(
|
||||
self.capture_status.icon(),
|
||||
size,
|
||||
foreground
|
||||
)]
|
||||
.spacing(4)
|
||||
.align_y(Alignment::Center);
|
||||
if self.capture_status.obs.on_air() {
|
||||
let color = self.config.theme.palette().error.scale_alpha(opacity);
|
||||
indicator = indicator.push(container(Space::new()).width(5).height(5).class(
|
||||
theme::Container::custom(move |_| container::Style {
|
||||
background: Some(Background::Color(color)),
|
||||
border: Border {
|
||||
radius: 2.5.into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}),
|
||||
));
|
||||
}
|
||||
indicator.into()
|
||||
}
|
||||
|
||||
pub(crate) fn capture_item_enabled(&self) -> bool {
|
||||
self.config.panels.iter().any(configured)
|
||||
}
|
||||
|
||||
pub(crate) fn capture_item_visible(&self, id: cosmic::iced::window::Id) -> bool {
|
||||
configured(self.panel_for_bar(id))
|
||||
}
|
||||
|
||||
pub(crate) fn capture_timer_visible(&self) -> bool {
|
||||
use crate::panel_layout::Placement;
|
||||
use ferese_panel::{ItemKind, Representation, morph::Mode};
|
||||
if self.snapshot.binding_mode.is_some() {
|
||||
return false;
|
||||
}
|
||||
self.outputs
|
||||
.iter()
|
||||
.filter(|output| !output.hidden)
|
||||
.any(|output| {
|
||||
if self.menu.as_ref().is_some_and(|menu| {
|
||||
menu.kind == crate::status_ui::Menu::CaptureStatus
|
||||
&& menu.anchor.parent == output.bar
|
||||
&& !menu.motion.closing()
|
||||
}) {
|
||||
return true;
|
||||
}
|
||||
let panel = self.panel_for_bar(output.bar);
|
||||
if !configured(panel) {
|
||||
return false;
|
||||
}
|
||||
if let Some(hub) = &output.runtime.hub {
|
||||
let hub = hub.borrow();
|
||||
return hub.menu == Some(crate::status_ui::Menu::CaptureStatus)
|
||||
|| matches!(hub.hub.mode(), Mode::Idle | Mode::Peek);
|
||||
}
|
||||
[&panel.start, &panel.center, &panel.end]
|
||||
.into_iter()
|
||||
.flat_map(|zone| &zone.groups)
|
||||
.flat_map(|group| &group.items)
|
||||
.filter(|item| item.visible && item.kind == ItemKind::CaptureStatus)
|
||||
.any(|item| {
|
||||
output.panel_resolution.as_ref().map_or(
|
||||
item.representation.unwrap_or(Representation::Wide)
|
||||
== Representation::Wide,
|
||||
|resolution| {
|
||||
matches!(
|
||||
resolution.items.get(&item.id),
|
||||
Some(Placement::Visible {
|
||||
representation: Representation::Wide,
|
||||
..
|
||||
})
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn view_capture_status(
|
||||
&self,
|
||||
representation: ferese_panel::Representation,
|
||||
selected: bool,
|
||||
) -> cosmic::Element<'_, cosmic::Action<crate::Message>> {
|
||||
use cosmic::iced::{Alignment, Rectangle};
|
||||
use cosmic::{
|
||||
theme,
|
||||
widget::{button, row},
|
||||
};
|
||||
let palette = self.config.theme.for_bar();
|
||||
let metrics = crate::BarMetrics::from(self.config.panels[0].resolved_geometry());
|
||||
let foreground = crate::color(if selected {
|
||||
palette.accent
|
||||
} else {
|
||||
palette.text_primary
|
||||
});
|
||||
let label = self
|
||||
.capture_status
|
||||
.label()
|
||||
.unwrap_or_else(|| "Capture status".into());
|
||||
let mut content = row![self.capture_indicator(metrics.icon_size, foreground, 1.)]
|
||||
.spacing(4)
|
||||
.align_y(Alignment::Center);
|
||||
if representation != ferese_panel::Representation::Icon {
|
||||
content = content.push(
|
||||
self.render
|
||||
.text(
|
||||
self.capture_status
|
||||
.bar_label(representation == ferese_panel::Representation::Compact),
|
||||
)
|
||||
.size(metrics.text_size)
|
||||
.class(theme::Text::Color(foreground)),
|
||||
);
|
||||
}
|
||||
let control = button::custom(crate::bar_content(content, metrics.group_item_height))
|
||||
.name(label)
|
||||
.padding([0., 6.])
|
||||
.height(metrics.group_item_height)
|
||||
.on_press_with_rectangle(|offset, bounds| {
|
||||
cosmic::Action::App(crate::Message::OpenMenu(
|
||||
crate::status_ui::Menu::CaptureStatus,
|
||||
Rectangle {
|
||||
x: (bounds.x - offset.x).round() as i32,
|
||||
y: (bounds.y - offset.y).round() as i32,
|
||||
width: bounds.width.round() as i32,
|
||||
height: bounds.height.round() as i32,
|
||||
},
|
||||
))
|
||||
});
|
||||
crate::motion::button(
|
||||
self.render,
|
||||
control,
|
||||
crate::color(palette.accent),
|
||||
selected,
|
||||
1.,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn configured_visibility_and_representation_control_timer_work() {
|
||||
use ferese_panel::{ItemKind, Representation};
|
||||
let mut shell = crate::tests::shell_with_measured_panel();
|
||||
shell.outputs[0].panel_resolution = None;
|
||||
assert!(shell.capture_item_enabled());
|
||||
assert!(shell.capture_timer_visible());
|
||||
shell.outputs[0].hidden = true;
|
||||
assert!(!shell.capture_timer_visible());
|
||||
shell.outputs[0].hidden = false;
|
||||
for representation in [
|
||||
Representation::Compact,
|
||||
Representation::Icon,
|
||||
Representation::Wide,
|
||||
] {
|
||||
let item = shell.config.panels[0]
|
||||
.end
|
||||
.groups
|
||||
.iter_mut()
|
||||
.flat_map(|g| &mut g.items)
|
||||
.find(|item| item.kind == ItemKind::CaptureStatus)
|
||||
.unwrap();
|
||||
item.representation = Some(representation);
|
||||
assert_eq!(
|
||||
shell.capture_timer_visible(),
|
||||
representation == Representation::Wide
|
||||
);
|
||||
}
|
||||
let item = shell.config.panels[0]
|
||||
.end
|
||||
.groups
|
||||
.iter_mut()
|
||||
.flat_map(|g| &mut g.items)
|
||||
.find(|item| item.kind == ItemKind::CaptureStatus)
|
||||
.unwrap();
|
||||
item.visible = false;
|
||||
assert!(!shell.capture_item_enabled());
|
||||
assert!(!shell.capture_timer_visible());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_does_not_claim_a_broadcast_and_idle_has_no_timer() {
|
||||
let mut model = Model::default();
|
||||
assert!(!model.active());
|
||||
assert_eq!(model.tick_subscription(true).units(), 0);
|
||||
model.apply(Event::Sharing(vec![Session {
|
||||
app: "com.obsproject.Studio".into(),
|
||||
sources: vec!["Workspace 1".into()],
|
||||
}]));
|
||||
assert_eq!(model.label().as_deref(), Some("OBS · Sharing"));
|
||||
assert_eq!(model.bar_label(false), "Sharing");
|
||||
assert_eq!(model.icon(), ferese_theme::icons::OBS);
|
||||
assert!(!model.obs.on_air());
|
||||
assert_eq!(model.tick_subscription(true).units(), 0);
|
||||
assert_eq!(model.details(), ["OBS · Sharing", "Workspace 1"]);
|
||||
model.apply(Event::Sharing(vec![]));
|
||||
assert!(!model.active());
|
||||
model.apply(Event::Sharing(vec![Session {
|
||||
app: "Firefox".into(),
|
||||
sources: vec!["Workspace 1".into()],
|
||||
}]));
|
||||
assert_eq!(model.icon(), ferese_theme::icons::DISPLAY);
|
||||
assert_eq!(model.bar_label(true), "Sharing");
|
||||
assert!(!model.obs.on_air());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use cosmic::iced::futures::{SinkExt, Stream, StreamExt};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio_tungstenite::{
|
||||
MaybeTlsStream, WebSocketStream,
|
||||
tungstenite::{Message, protocol::WebSocketConfig},
|
||||
};
|
||||
|
||||
use super::Event;
|
||||
|
||||
#[derive(Clone, Hash, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct Config {
|
||||
#[serde(default)]
|
||||
enabled: bool,
|
||||
#[serde(default = "default_port")]
|
||||
port: u16,
|
||||
#[serde(default)]
|
||||
password: String,
|
||||
}
|
||||
fn default_port() -> u16 {
|
||||
4455
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Option<Self> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| {
|
||||
std::env::var_os("HOME").map(|home| std::path::PathBuf::from(home).join(".config"))
|
||||
})?;
|
||||
let path = base.join("ferese/obs.json");
|
||||
let metadata = std::fs::metadata(&path).ok()?;
|
||||
// The file may contain the OBS password. Never log its contents.
|
||||
if metadata.len() > 16 * 1024 || metadata.mode() & 0o077 != 0 {
|
||||
eprintln!("ferese-shell: obs.json must be private (mode 600) and smaller than 16 KiB");
|
||||
return None;
|
||||
}
|
||||
let config: Self = serde_json::from_slice(&std::fs::read(path).ok()?).ok()?;
|
||||
(config.enabled && config.port != 0).then_some(config)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Output {
|
||||
active: bool,
|
||||
paused: bool,
|
||||
reconnecting: bool,
|
||||
duration: Duration,
|
||||
sampled: Instant,
|
||||
}
|
||||
impl Default for Output {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
active: false,
|
||||
paused: false,
|
||||
reconnecting: false,
|
||||
duration: Duration::ZERO,
|
||||
sampled: Instant::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Output {
|
||||
fn elapsed(&self) -> Duration {
|
||||
self.duration
|
||||
+ if self.active && !self.paused {
|
||||
self.sampled.elapsed()
|
||||
} else {
|
||||
Duration::ZERO
|
||||
}
|
||||
}
|
||||
fn label(&self, record: bool, compact: bool) -> Option<String> {
|
||||
if !self.active {
|
||||
return None;
|
||||
}
|
||||
let state = if self.reconnecting {
|
||||
"Reconnecting"
|
||||
} else if self.paused {
|
||||
"REC paused"
|
||||
} else if record {
|
||||
"REC"
|
||||
} else {
|
||||
"LIVE"
|
||||
};
|
||||
if compact {
|
||||
return Some(state.into());
|
||||
}
|
||||
let seconds = self.elapsed().as_secs();
|
||||
Some(format!("{state} {:02}:{:02}", seconds / 60, seconds % 60))
|
||||
}
|
||||
fn status(&mut self, data: &Value) -> Result<(), String> {
|
||||
self.active = data["outputActive"]
|
||||
.as_bool()
|
||||
.ok_or("Missing OBS output status")?;
|
||||
self.paused = data["outputPaused"].as_bool().unwrap_or(false);
|
||||
self.reconnecting = data["outputReconnecting"].as_bool().unwrap_or(false);
|
||||
self.duration = Duration::from_millis(data["outputDuration"].as_u64().unwrap_or(0));
|
||||
self.sampled = Instant::now();
|
||||
Ok(())
|
||||
}
|
||||
fn event(&mut self, data: &Value) -> Result<(), String> {
|
||||
let state = data["outputState"].as_str().ok_or("Missing OBS state")?;
|
||||
self.duration = self.elapsed();
|
||||
self.sampled = Instant::now();
|
||||
match state {
|
||||
"OBS_WEBSOCKET_OUTPUT_STARTED" => {
|
||||
self.active = true;
|
||||
self.duration = Duration::ZERO;
|
||||
self.paused = false;
|
||||
self.reconnecting = false;
|
||||
}
|
||||
"OBS_WEBSOCKET_OUTPUT_PAUSED" => self.paused = true,
|
||||
"OBS_WEBSOCKET_OUTPUT_RESUMED" => self.paused = false,
|
||||
"OBS_WEBSOCKET_OUTPUT_RECONNECTING" => self.reconnecting = true,
|
||||
"OBS_WEBSOCKET_OUTPUT_RECONNECTED" => self.reconnecting = false,
|
||||
"OBS_WEBSOCKET_OUTPUT_STOPPED" | "OBS_WEBSOCKET_OUTPUT_STARTING" => {
|
||||
*self = Self::default()
|
||||
}
|
||||
"OBS_WEBSOCKET_OUTPUT_STOPPING" => (),
|
||||
_ => return Err("Unknown OBS output state".into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Snapshot {
|
||||
stream: Output,
|
||||
record: Output,
|
||||
}
|
||||
impl Snapshot {
|
||||
pub fn active(&self) -> bool {
|
||||
self.stream.active || self.record.active
|
||||
}
|
||||
pub fn ticking(&self) -> bool {
|
||||
self.stream.active || (self.record.active && !self.record.paused)
|
||||
}
|
||||
pub fn label(&self) -> Option<String> {
|
||||
self.bar_label(false).map(|label| format!("OBS · {label}"))
|
||||
}
|
||||
pub fn on_air(&self) -> bool {
|
||||
(self.stream.active && !self.stream.reconnecting)
|
||||
|| (self.record.active && !self.record.paused && !self.record.reconnecting)
|
||||
}
|
||||
pub fn bar_label(&self, compact: bool) -> Option<String> {
|
||||
let stream = self.stream.label(false, compact);
|
||||
let record = self.record.label(true, compact);
|
||||
match (stream, record) {
|
||||
(Some(stream), Some(_)) => Some(format!(
|
||||
"{stream} · {}",
|
||||
if self.record.paused {
|
||||
"REC paused"
|
||||
} else {
|
||||
"REC"
|
||||
}
|
||||
)),
|
||||
(Some(label), None) | (None, Some(label)) => Some(label),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn details(&self) -> Vec<String> {
|
||||
[
|
||||
self.stream.label(false, false),
|
||||
self.record.label(true, false),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|label| format!("OBS · {label}"))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn identify(hello: &Value, password: &str) -> Result<Value, String> {
|
||||
if hello["op"] != 0 || hello["d"]["rpcVersion"].as_u64().unwrap_or(0) < 1 {
|
||||
return Err("Unsupported OBS handshake".into());
|
||||
}
|
||||
// Outputs only: no meters, previews, scene changes, or per-frame events.
|
||||
let mut reply = json!({"op":1,"d":{"rpcVersion":1,"eventSubscriptions":64}});
|
||||
if let Some(auth) = hello["d"].get("authentication") {
|
||||
let salt = auth["salt"].as_str().ok_or("Missing OBS salt")?;
|
||||
let challenge = auth["challenge"].as_str().ok_or("Missing OBS challenge")?;
|
||||
let secret = STANDARD.encode(Sha256::digest(format!("{password}{salt}")));
|
||||
reply["d"]["authentication"] = STANDARD
|
||||
.encode(Sha256::digest(format!("{secret}{challenge}")))
|
||||
.into();
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
type Socket = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
|
||||
async fn receive(socket: &mut Socket) -> Result<Value, String> {
|
||||
loop {
|
||||
match socket.next().await {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
return serde_json::from_str(&text).map_err(|_| "Invalid OBS message".into());
|
||||
}
|
||||
Some(Ok(Message::Ping(_))) => socket.flush().await.map_err(|_| "OBS disconnected")?,
|
||||
Some(Ok(Message::Pong(_))) => (),
|
||||
_ => return Err("OBS disconnected".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send(socket: &mut Socket, value: Value) -> Result<(), String> {
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(3),
|
||||
socket.send(Message::Text(value.to_string().into())),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "OBS write timed out")?
|
||||
.map_err(|_| "OBS disconnected".into())
|
||||
}
|
||||
|
||||
async fn request(socket: &mut Socket, record: bool, generation: u64) -> Result<(), String> {
|
||||
send(
|
||||
socket,
|
||||
json!({"op":6,"d":{
|
||||
"requestType": if record { "GetRecordStatus" } else { "GetStreamStatus" },
|
||||
"requestId": format!("{}-{generation}", if record { "record" } else { "stream" })
|
||||
}}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn connect(config: &Config) -> Result<Socket, String> {
|
||||
let limits = WebSocketConfig::default()
|
||||
.read_buffer_size(4096)
|
||||
.write_buffer_size(0)
|
||||
.max_write_buffer_size(64 * 1024)
|
||||
.max_message_size(Some(64 * 1024))
|
||||
.max_frame_size(Some(64 * 1024));
|
||||
// Credentials are sent only to the user's local OBS instance.
|
||||
let (mut socket, _) = tokio_tungstenite::connect_async_with_config(
|
||||
format!("ws://127.0.0.1:{}", config.port),
|
||||
Some(limits),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "OBS unavailable")?;
|
||||
let hello = receive(&mut socket).await?;
|
||||
send(&mut socket, identify(&hello, &config.password)?).await?;
|
||||
if receive(&mut socket).await?["op"] != 2 {
|
||||
return Err("OBS identification failed".into());
|
||||
}
|
||||
Ok(socket)
|
||||
}
|
||||
|
||||
pub(super) fn stream(config: &Arc<Config>) -> impl Stream<Item = Event> + use<> {
|
||||
let config = config.clone();
|
||||
cosmic::iced::stream::channel(1, async move |mut output| {
|
||||
let mut retry = 1;
|
||||
loop {
|
||||
let connection = tokio::time::timeout(Duration::from_secs(5), connect(&config)).await;
|
||||
if let Ok(Ok(mut socket)) = connection {
|
||||
let mut snapshot = Snapshot::default();
|
||||
let mut generations = [0u64; 2];
|
||||
let mut pending = [true; 2];
|
||||
let mut deadline = Instant::now() + Duration::from_secs(5);
|
||||
let connected = Instant::now();
|
||||
let result: Result<(), String> = async {
|
||||
request(&mut socket, false, 0).await?;
|
||||
request(&mut socket, true, 0).await?;
|
||||
loop {
|
||||
let message = if pending.iter().any(|pending| *pending) {
|
||||
tokio::time::timeout_at(deadline.into(), receive(&mut socket))
|
||||
.await
|
||||
.map_err(|_| "OBS status timed out")??
|
||||
} else {
|
||||
receive(&mut socket).await?
|
||||
};
|
||||
let data = &message["d"];
|
||||
match message["op"].as_u64() {
|
||||
Some(5) => {
|
||||
let index = match data["eventType"].as_str() {
|
||||
Some("StreamStateChanged") => 0,
|
||||
Some("RecordStateChanged") => 1,
|
||||
_ => continue,
|
||||
};
|
||||
let state = if index == 0 {
|
||||
&mut snapshot.stream
|
||||
} else {
|
||||
&mut snapshot.record
|
||||
};
|
||||
state.event(&data["eventData"])?;
|
||||
generations[index] += 1;
|
||||
pending[index] = true;
|
||||
deadline = Instant::now() + Duration::from_secs(5);
|
||||
request(&mut socket, index == 1, generations[index]).await?;
|
||||
}
|
||||
Some(7) => {
|
||||
let id = data["requestId"].as_str().unwrap_or_default();
|
||||
let index = if id == format!("stream-{}", generations[0]) {
|
||||
0
|
||||
} else if id == format!("record-{}", generations[1]) {
|
||||
1
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
if !pending[index] {
|
||||
continue;
|
||||
}
|
||||
if data["requestStatus"]["result"] != true {
|
||||
return Err("OBS status unavailable".into());
|
||||
}
|
||||
let state = if index == 0 {
|
||||
&mut snapshot.stream
|
||||
} else {
|
||||
&mut snapshot.record
|
||||
};
|
||||
state.status(&data["responseData"])?;
|
||||
pending[index] = false;
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
if output.send(Event::Obs(snapshot.clone())).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
.await;
|
||||
let _ = result;
|
||||
if connected.elapsed() >= Duration::from_secs(30) {
|
||||
retry = 1;
|
||||
}
|
||||
}
|
||||
if output.send(Event::Obs(Snapshot::default())).await.is_err() {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(retry)).await;
|
||||
retry = (retry * 2).min(60);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_ignores_stale_replies_waits_for_events_and_clears_disconnects() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let config = Arc::new(Config {
|
||||
enabled: true,
|
||||
port: listener.local_addr().unwrap().port(),
|
||||
password: String::new(),
|
||||
});
|
||||
let (close, closed) = tokio::sync::oneshot::channel();
|
||||
let server = tokio::spawn(async move {
|
||||
let (socket, _) = listener.accept().await.unwrap();
|
||||
let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap();
|
||||
socket
|
||||
.send(Message::Text(
|
||||
json!({"op":0,"d":{"rpcVersion":1}}).to_string().into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let identify: Value =
|
||||
serde_json::from_str(socket.next().await.unwrap().unwrap().to_text().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(identify["d"]["eventSubscriptions"], 64);
|
||||
socket
|
||||
.send(Message::Text(
|
||||
json!({"op":2,"d":{"negotiatedRpcVersion":1}})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
for id in ["stream-0", "record-0"] {
|
||||
let request: Value =
|
||||
serde_json::from_str(socket.next().await.unwrap().unwrap().to_text().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(request["d"]["requestId"], id);
|
||||
}
|
||||
socket.send(Message::Text(json!({"op":5,"d":{"eventType":"StreamStateChanged","eventData":{"outputState":"OBS_WEBSOCKET_OUTPUT_STARTED"}}}).to_string().into())).await.unwrap();
|
||||
let request: Value =
|
||||
serde_json::from_str(socket.next().await.unwrap().unwrap().to_text().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(request["d"]["requestId"], "stream-1");
|
||||
for (id, data) in [
|
||||
("stream-0", json!({"outputActive":false,"outputDuration":0})),
|
||||
(
|
||||
"record-0",
|
||||
json!({"outputActive":true,"outputPaused":true,"outputDuration":12000}),
|
||||
),
|
||||
(
|
||||
"stream-1",
|
||||
json!({"outputActive":true,"outputDuration":70000}),
|
||||
),
|
||||
] {
|
||||
socket.send(Message::Text(json!({"op":7,"d":{"requestId":id,"requestStatus":{"result":true},"responseData":data}}).to_string().into())).await.unwrap();
|
||||
}
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(100), socket.next())
|
||||
.await
|
||||
.is_err(),
|
||||
"idle subscriber sent another request"
|
||||
);
|
||||
closed.await.unwrap();
|
||||
socket.close(None).await.unwrap();
|
||||
});
|
||||
let events = stream(&config);
|
||||
tokio::pin!(events);
|
||||
tokio::time::timeout(Duration::from_secs(3), async {
|
||||
loop {
|
||||
let Some(Event::Obs(snapshot)) = events.next().await else {
|
||||
panic!("missing OBS state");
|
||||
};
|
||||
assert!(
|
||||
snapshot.stream.active,
|
||||
"stale initial response hid the live stream"
|
||||
);
|
||||
if snapshot.label().as_deref() == Some("OBS · LIVE 01:10 · REC paused") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(150), events.next())
|
||||
.await
|
||||
.is_err(),
|
||||
"idle subscriber produced another update"
|
||||
);
|
||||
close.send(()).unwrap();
|
||||
let Some(Event::Obs(snapshot)) =
|
||||
tokio::time::timeout(Duration::from_secs(1), events.next())
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("missing disconnect");
|
||||
};
|
||||
assert!(!snapshot.active());
|
||||
assert!(!snapshot.ticking());
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_capture_pause_reconnect_and_simultaneous_outputs() {
|
||||
let mut state = Snapshot::default();
|
||||
assert!(!state.active());
|
||||
assert!(!state.ticking());
|
||||
assert!(!state.on_air());
|
||||
state
|
||||
.stream
|
||||
.event(&json!({"outputState":"OBS_WEBSOCKET_OUTPUT_STARTING"}))
|
||||
.unwrap();
|
||||
assert!(!state.active());
|
||||
state
|
||||
.stream
|
||||
.status(&json!({"outputActive":true,"outputDuration":74000}))
|
||||
.unwrap();
|
||||
assert_eq!(state.label().as_deref(), Some("OBS · LIVE 01:14"));
|
||||
assert_eq!(state.bar_label(true).as_deref(), Some("LIVE"));
|
||||
assert!(state.on_air());
|
||||
state
|
||||
.record
|
||||
.status(&json!({"outputActive":true,"outputDuration":12000}))
|
||||
.unwrap();
|
||||
assert_eq!(state.details().len(), 2);
|
||||
assert!(state.label().unwrap().ends_with(" · REC"));
|
||||
state
|
||||
.stream
|
||||
.event(&json!({"outputState":"OBS_WEBSOCKET_OUTPUT_RECONNECTING"}))
|
||||
.unwrap();
|
||||
assert!(!state.label().unwrap().contains("LIVE"));
|
||||
assert!(
|
||||
state.on_air(),
|
||||
"recording is still active while the stream reconnects"
|
||||
);
|
||||
state
|
||||
.record
|
||||
.event(&json!({"outputState":"OBS_WEBSOCKET_OUTPUT_PAUSED"}))
|
||||
.unwrap();
|
||||
assert!(
|
||||
!state.on_air(),
|
||||
"reconnecting and paused outputs are not on air"
|
||||
);
|
||||
state
|
||||
.stream
|
||||
.event(&json!({"outputState":"OBS_WEBSOCKET_OUTPUT_STOPPED"}))
|
||||
.unwrap();
|
||||
state
|
||||
.record
|
||||
.event(&json!({"outputState":"OBS_WEBSOCKET_OUTPUT_PAUSED"}))
|
||||
.unwrap();
|
||||
assert!(state.active());
|
||||
assert!(!state.ticking());
|
||||
assert!(state.label().unwrap().contains("paused"));
|
||||
assert_eq!(state.bar_label(true).as_deref(), Some("REC paused"));
|
||||
assert!(!state.on_air());
|
||||
assert_eq!(state.record.elapsed(), state.record.duration);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_subscribes_only_to_output_events() {
|
||||
let hello = json!({"op":0,"d":{"rpcVersion":1,"authentication":{"salt":"salt","challenge":"challenge"}}});
|
||||
let reply = identify(&hello, "password").unwrap();
|
||||
let secret = STANDARD.encode(Sha256::digest(b"passwordsalt"));
|
||||
let expected = STANDARD.encode(Sha256::digest(format!("{secret}challenge")));
|
||||
assert_eq!(reply["d"]["authentication"], expected);
|
||||
assert_eq!(reply["d"]["eventSubscriptions"], 64);
|
||||
assert!(identify(&json!({"op":2,"d":{}}), "password").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use cosmic::iced::futures::{SinkExt, Stream, StreamExt};
|
||||
use ferese_ipc::capture::{INTERFACE, PATH, SERVICE, Session};
|
||||
|
||||
use super::Event;
|
||||
|
||||
async fn snapshot(proxy: &zbus::Proxy<'_>) -> zbus::Result<Vec<Session>> {
|
||||
let reply = tokio::time::timeout(
|
||||
Duration::from_secs(3),
|
||||
proxy.call_with_flags::<_, _, String>(
|
||||
"GetSnapshot",
|
||||
zbus::proxy::MethodFlags::NoAutoStart.into(),
|
||||
&(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let text = match reply {
|
||||
Ok(Ok(Some(text))) => text,
|
||||
Ok(Err(zbus::Error::MethodError(name, _, _)))
|
||||
if matches!(
|
||||
name.as_str(),
|
||||
"org.freedesktop.DBus.Error.ServiceUnknown"
|
||||
| "org.freedesktop.DBus.Error.NameHasNoOwner"
|
||||
| "org.freedesktop.DBus.Error.UnknownMethod"
|
||||
| "org.freedesktop.DBus.Error.UnknownInterface"
|
||||
) =>
|
||||
{
|
||||
return Ok(vec![]);
|
||||
}
|
||||
Ok(Err(error)) => return Err(error),
|
||||
_ => return Err(zbus::Error::Failure("Capture status timed out".into())),
|
||||
};
|
||||
if text.len() > 64 * 1024 {
|
||||
return Err(zbus::Error::Failure(
|
||||
"Capture status exceeds its limit".into(),
|
||||
));
|
||||
}
|
||||
serde_json::from_str::<Vec<Session>>(&text)
|
||||
.ok()
|
||||
.filter(|sessions| sessions.len() <= 8 && sessions.iter().all(|s| s.sources.len() <= 8))
|
||||
.ok_or_else(|| zbus::Error::Failure("Invalid capture status".into()))
|
||||
}
|
||||
|
||||
pub(super) fn stream() -> impl Stream<Item = Event> {
|
||||
stream_at(None)
|
||||
}
|
||||
|
||||
fn stream_at(address: Option<String>) -> impl Stream<Item = Event> {
|
||||
cosmic::iced::stream::channel(1, async move |mut output| {
|
||||
loop {
|
||||
let result: Result<(), zbus::Error> = async {
|
||||
let connection = if let Some(address) = &address {
|
||||
zbus::connection::Builder::address(address.as_str())?
|
||||
.build()
|
||||
.await?
|
||||
} else {
|
||||
zbus::Connection::session().await?
|
||||
};
|
||||
let proxy = zbus::Proxy::new(&connection, SERVICE, PATH, INTERFACE).await?;
|
||||
let mut owners = proxy.receive_owner_changed().await?;
|
||||
let mut changes = proxy.receive_signal("Changed").await?;
|
||||
let mut last = snapshot(&proxy).await?;
|
||||
if output.send(Event::Sharing(last.clone())).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
loop {
|
||||
tokio::select! {
|
||||
owner = owners.next() => {
|
||||
let Some(owner) = owner else { break; };
|
||||
// Forget the previous service before reading its replacement.
|
||||
last.clear();
|
||||
if output.send(Event::Sharing(vec![])).await.is_err() { return Ok(()); }
|
||||
if owner.is_none() { continue; }
|
||||
}
|
||||
change = changes.next() => {
|
||||
if change.is_none() { break; }
|
||||
}
|
||||
}
|
||||
let next = snapshot(&proxy).await?;
|
||||
if next != last {
|
||||
last = next;
|
||||
if output.send(Event::Sharing(last.clone())).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
let _ = result;
|
||||
if output.send(Event::Sharing(vec![])).await.is_err() {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
struct Status(Arc<AtomicUsize>);
|
||||
#[zbus::interface(name = "org.ferese.CaptureStatus")]
|
||||
impl Status {
|
||||
fn get_snapshot(&self) -> String {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
serde_json::to_string(&vec![Session {
|
||||
app: "test".into(),
|
||||
sources: vec!["Workspace 2".into()],
|
||||
}])
|
||||
.unwrap()
|
||||
}
|
||||
#[zbus(signal)]
|
||||
async fn changed(emitter: &zbus::object_server::SignalEmitter<'_>) -> zbus::Result<()>;
|
||||
}
|
||||
|
||||
struct Bus(std::process::Child);
|
||||
impl Drop for Bus {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires dbus-daemon; uses a private test bus"]
|
||||
async fn portal_owner_changes_recover_without_polling_or_activation() {
|
||||
use std::io::BufRead;
|
||||
let mut bus = Bus(std::process::Command::new("dbus-daemon")
|
||||
.args(["--session", "--nofork", "--nopidfile", "--print-address=1"])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap());
|
||||
let mut address = String::new();
|
||||
std::io::BufReader::new(bus.0.stdout.take().unwrap())
|
||||
.read_line(&mut address)
|
||||
.unwrap();
|
||||
let address = address.trim().to_owned();
|
||||
let events = stream_at(Some(address.clone()));
|
||||
tokio::pin!(events);
|
||||
let Some(Event::Sharing(sessions)) =
|
||||
tokio::time::timeout(Duration::from_secs(2), events.next())
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("missing initial state");
|
||||
};
|
||||
assert!(sessions.is_empty());
|
||||
let reads = Arc::new(AtomicUsize::new(0));
|
||||
let server = zbus::connection::Builder::address(address.as_str())
|
||||
.unwrap()
|
||||
.name(SERVICE)
|
||||
.unwrap()
|
||||
.serve_at(PATH, Status(reads.clone()))
|
||||
.unwrap()
|
||||
.build()
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
if let Some(Event::Sharing(sessions)) = events.next().await
|
||||
&& sessions.len() == 1
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let before = reads.load(Ordering::Relaxed);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(100), events.next())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
reads.load(Ordering::Relaxed),
|
||||
before,
|
||||
"idle subscriber polled the portal"
|
||||
);
|
||||
let emitter = zbus::object_server::SignalEmitter::new(&server, PATH).unwrap();
|
||||
Status::changed(&emitter).await.unwrap();
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(100), events.next())
|
||||
.await
|
||||
.is_err(),
|
||||
"unchanged snapshot was republished"
|
||||
);
|
||||
assert_eq!(reads.load(Ordering::Relaxed), before + 1);
|
||||
server.release_name(SERVICE).await.unwrap();
|
||||
let Some(Event::Sharing(sessions)) =
|
||||
tokio::time::timeout(Duration::from_secs(2), events.next())
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("missing owner loss");
|
||||
};
|
||||
assert!(sessions.is_empty());
|
||||
server.request_name(SERVICE).await.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
if let Some(Event::Sharing(sessions)) = events.next().await
|
||||
&& sessions.len() == 1
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ mod surfaces;
|
||||
use bar::{bar_content, output_bar_hidden};
|
||||
use 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user