diff --git a/Cargo.lock b/Cargo.lock index 7205f3ac..03b5d8ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -841,6 +841,17 @@ dependencies = [ "zbus", ] +[[package]] +name = "cosmic-applet-status-area" +version = "0.1.0" +dependencies = [ + "futures", + "libcosmic", + "serde", + "tokio", + "zbus", +] + [[package]] name = "cosmic-applet-time" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 96e8ad79..4cade2a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "cosmic-applet-network", "cosmic-applet-notifications", "cosmic-applet-power", + "cosmic-applet-status-area", "cosmic-applet-time", "cosmic-applet-workspaces", "cosmic-panel-button", diff --git a/applets/cosmic-applet-status-area/src/status_menu.rs b/applets/cosmic-applet-status-area/src/status_menu.rs new file mode 100644 index 00000000..56bf2c72 --- /dev/null +++ b/applets/cosmic-applet-status-area/src/status_menu.rs @@ -0,0 +1,345 @@ +use cascade::cascade; +use futures::StreamExt; +use gtk4::{ + gdk_pixbuf, + glib::{self, clone}, + prelude::*, + subclass::prelude::*, +}; +use std::{cell::RefCell, collections::HashMap, io}; +use zbus::dbus_proxy; +use zvariant::OwnedValue; + +use crate::deref_cell::DerefCell; + +struct Menu { + box_: gtk4::Box, + children: Vec, +} + +#[derive(Default)] +pub struct StatusMenuInner { + menu_button: DerefCell, + vbox: DerefCell, + item: DerefCell>, + dbus_menu: DerefCell>, + menus: RefCell>, +} + +#[glib::object_subclass] +impl ObjectSubclass for StatusMenuInner { + const NAME: &'static str = "S76StatusMenu"; + type ParentType = gtk4::Widget; + type Type = StatusMenu; + + fn class_init(klass: &mut Self::Class) { + klass.set_layout_manager_type::(); + } +} + +impl ObjectImpl for StatusMenuInner { + fn constructed(&self, obj: &StatusMenu) { + let vbox = cascade! { + gtk4::Box::new(gtk4::Orientation::Vertical, 0); + }; + + let menu_button = cascade! { + libcosmic_applet::AppletButton::new(); + ..set_parent(obj); + ..set_popover_child(Some(&vbox)); + }; + + self.menu_button.set(menu_button); + self.vbox.set(vbox); + } + + fn dispose(&self, _obj: &StatusMenu) { + self.menu_button.unparent(); + } +} + +impl WidgetImpl for StatusMenuInner {} + +glib::wrapper! { + pub struct StatusMenu(ObjectSubclass) + @extends gtk4::Widget; +} + +impl StatusMenu { + pub async fn new(name: &str) -> zbus::Result { + let (dest, path) = if let Some(idx) = name.find('/') { + (&name[..idx], &name[idx..]) + } else { + (name, "/StatusNotifierItem") + }; + + let connection = zbus::Connection::session().await?; + let item = StatusNotifierItemProxy::builder(&connection) + .destination(dest.to_string())? + .path(path.to_string())? + .build() + .await?; + let obj = glib::Object::new::(&[]).unwrap(); + let icon_name = item.icon_name().await?; + obj.inner().menu_button.set_button_icon_name(&icon_name); + + let menu = item.menu().await?; + let menu = DBusMenuProxy::builder(&connection) + .destination(dest.to_string())? + .path(menu)? + .build() + .await?; + let layout = menu.get_layout(0, -1, &[]).await?.1; + + let mut layout_updated_stream = menu.receive_layout_updated().await?; + glib::MainContext::default().spawn_local(clone!(@strong obj => async move { + while let Some(evt) = layout_updated_stream.next().await { + let args = match evt.args() { + Ok(args) => args, + Err(_) => { continue; }, + }; + obj.layout_updated(args.revision, args.parent); + } + })); + + obj.inner().item.set(item); + obj.inner().dbus_menu.set(menu); + + println!("{:#?}", layout); + obj.populate_menu(&obj.inner().vbox, &layout); + + Ok(obj) + } + + fn inner(&self) -> &StatusMenuInner { + StatusMenuInner::from_instance(self) + } + + fn layout_updated(&self, _revision: u32, parent: i32) { + let mut menus = self.inner().menus.borrow_mut(); + + if let Some(Menu { box_, children }) = menus.remove(&parent) { + let mut next_child = box_.first_child(); + while let Some(child) = next_child { + next_child = child.next_sibling(); + box_.remove(&child); + } + + fn remove_child_menus(menus: &mut HashMap, children: Vec) { + for i in children { + if let Some(menu) = menus.remove(&i) { + remove_child_menus(menus, menu.children); + } + } + } + remove_child_menus(&mut menus, children); + + glib::MainContext::default().spawn_local(clone!(@weak self as self_ => async move { + match self_.inner().dbus_menu.get_layout(parent, -1, &[]).await { + Ok((_, layout)) => self_.populate_menu(&box_, &layout), + Err(err) => eprintln!("Failed to call 'GetLayout': {}", err), + } + })); + } + } + + fn populate_menu(&self, box_: >k4::Box, layout: &Layout) { + let mut children = Vec::new(); + + for i in layout.children() { + children.push(i.id()); + + if i.type_().as_deref() == Some("separator") { + let separator = cascade! { + gtk4::Separator::new(gtk4::Orientation::Horizontal); + ..set_visible(i.visible()); + }; + box_.append(&separator); + } else if let Some(label) = i.label() { + let mut label = label.to_string(); + if let Some(toggle_state) = i.toggle_state() { + if toggle_state != 0 { + label = format!("✓ {}", label); + } + } + + let label_widget = cascade! { + gtk4::Label::new(Some(&label)); + ..set_halign(gtk4::Align::Start); + ..set_hexpand(true); + ..set_use_underline(true); + }; + + let hbox = cascade! { + gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + ..append(&label_widget); + }; + + if let Some(icon_data) = i.icon_data() { + let icon_data = io::Cursor::new(icon_data.to_vec()); + let pixbuf = gdk_pixbuf::Pixbuf::from_read(icon_data).unwrap(); // XXX unwrap + let image = cascade! { + gtk4::Image::from_pixbuf(Some(&pixbuf)); + ..set_halign(gtk4::Align::End); + }; + hbox.append(&image); + } + + let id = i.id(); + let close_on_click = i.children_display().as_deref() != Some("submenu"); + let button = cascade! { + gtk4::Button::new(); + ..set_child(Some(&hbox)); + ..style_context().add_class("flat"); + ..set_visible(i.visible()); + ..set_sensitive(i.enabled()); + ..connect_clicked(clone!(@weak self as self_ => move |_| { + // XXX data, timestamp + if close_on_click { + self_.inner().menu_button.popdown(); + } + glib::MainContext::default().spawn_local(clone!(@strong self_ => async move { + let _ = self_.inner().dbus_menu.event(id, "clicked", &0.into(), 0).await; + })); + })); + }; + box_.append(&button); + + if i.children_display().as_deref() == Some("submenu") { + let vbox = cascade! { + gtk4::Box::new(gtk4::Orientation::Vertical, 0); + }; + + let revealer = cascade! { + gtk4::Revealer::new(); + ..set_child(Some(&vbox)); + }; + + self.populate_menu(&vbox, &i); + + box_.append(&revealer); + + button.connect_clicked(move |_| { + revealer.set_reveal_child(!revealer.reveals_child()); + }); + } + } + } + + self.inner().menus.borrow_mut().insert( + layout.id(), + Menu { + box_: box_.clone(), + children, + }, + ); + } +} + +#[dbus_proxy(interface = "org.kde.StatusNotifierItem")] +trait StatusNotifierItem { + #[dbus_proxy(property)] + fn icon_name(&self) -> zbus::Result; + + #[dbus_proxy(property)] + fn menu(&self) -> zbus::Result; +} + +#[derive(Debug)] +pub struct Layout(i32, LayoutProps, Vec); + +impl<'a> serde::Deserialize<'a> for Layout { + fn deserialize>(deserializer: D) -> Result { + let (id, props, children) = + <(i32, LayoutProps, Vec<(zvariant::Signature<'_>, Self)>)>::deserialize(deserializer)?; + Ok(Self(id, props, children.into_iter().map(|x| x.1).collect())) + } +} + +impl zvariant::Type for Layout { + fn signature() -> zvariant::Signature<'static> { + zvariant::Signature::try_from("(ia{sv}av)").unwrap() + } +} + +#[derive(Debug, zvariant::DeserializeDict, zvariant::Type)] +pub struct LayoutProps { + #[zvariant(rename = "accessible-desc")] + accessible_desc: Option, + #[zvariant(rename = "children-display")] + children_display: Option, + label: Option, + enabled: Option, + visible: Option, + #[zvariant(rename = "type")] + type_: Option, + #[zvariant(rename = "toggle-type")] + toggle_type: Option, + #[zvariant(rename = "toggle-state")] + toggle_state: Option, + #[zvariant(rename = "icon-data")] + icon_data: Option>, +} + +#[allow(dead_code)] +impl Layout { + fn id(&self) -> i32 { + self.0 + } + + fn children(&self) -> &[Self] { + &self.2 + } + + fn accessible_desc(&self) -> Option<&str> { + self.1.accessible_desc.as_deref() + } + + fn children_display(&self) -> Option<&str> { + self.1.children_display.as_deref() + } + + fn label(&self) -> Option<&str> { + self.1.label.as_deref() + } + + fn enabled(&self) -> bool { + self.1.enabled.unwrap_or(true) + } + + fn visible(&self) -> bool { + self.1.visible.unwrap_or(true) + } + + fn type_(&self) -> Option<&str> { + self.1.type_.as_deref() + } + + fn toggle_type(&self) -> Option<&str> { + self.1.toggle_type.as_deref() + } + + fn toggle_state(&self) -> Option { + self.1.toggle_state + } + + fn icon_data(&self) -> Option<&[u8]> { + self.1.icon_data.as_deref() + } +} + +#[dbus_proxy(interface = "com.canonical.dbusmenu")] +trait DBusMenu { + fn get_layout( + &self, + parent_id: i32, + recursion_depth: i32, + property_names: &[&str], + ) -> zbus::Result<(u32, Layout)>; + + fn event(&self, id: i32, event_id: &str, data: &OwnedValue, timestamp: u32) + -> zbus::Result<()>; + + #[dbus_proxy(signal)] + fn layout_updated(&self, revision: u32, parent: i32) -> zbus::Result<()>; +} diff --git a/cosmic-applet-status-area/Cargo.toml b/cosmic-applet-status-area/Cargo.toml new file mode 100644 index 00000000..2336f5fe --- /dev/null +++ b/cosmic-applet-status-area/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "cosmic-applet-status-area" +version = "0.1.0" +edition = "2021" +license = "GPL-3.0-or-later" + +[dependencies] +futures = "0.3" +libcosmic.workspace = true +serde = "1" +tokio = { version = "1.23.0" } +zbus = { version = "3", default-features = false, features = ["tokio"] } diff --git a/cosmic-applet-status-area/data/com.system76.CosmicAppletStatusArea.desktop b/cosmic-applet-status-area/data/com.system76.CosmicAppletStatusArea.desktop new file mode 100644 index 00000000..a14cb11d --- /dev/null +++ b/cosmic-applet-status-area/data/com.system76.CosmicAppletStatusArea.desktop @@ -0,0 +1,13 @@ +[Desktop Entry] +Name=Cosmic Applet Status Area +Comment=Applet for Cosmic Panel +Type=Application +Exec=cosmic-applet-status-area +Terminal=false +Categories=GNOME;GTK; +Keywords=Gnome;GTK; +# Translators: Do NOT translate or transliterate this text (this is an icon file name)! +Icon=com.system76.CosmicAppletStatusArea +StartupNotify=true +NoDisplay=true +X-CosmicApplet=true diff --git a/cosmic-applet-status-area/data/icons/scalable/app/com.system76.CosmicAppletStatusArea.svg b/cosmic-applet-status-area/data/icons/scalable/app/com.system76.CosmicAppletStatusArea.svg new file mode 100644 index 00000000..c2bd5b1b --- /dev/null +++ b/cosmic-applet-status-area/data/icons/scalable/app/com.system76.CosmicAppletStatusArea.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cosmic-applet-status-area/src/components/app.rs b/cosmic-applet-status-area/src/components/app.rs new file mode 100644 index 00000000..1ce881ab --- /dev/null +++ b/cosmic-applet-status-area/src/components/app.rs @@ -0,0 +1,215 @@ +use cosmic::{ + app::{self, Command}, + iced::{ + self, + wayland::{ + popup::{destroy_popup, get_popup}, + window::resize_window, + }, + window, Subscription, + }, + iced_style::application, + Theme, +}; +use std::collections::BTreeMap; + +use crate::{components::status_menu, subscriptions::status_notifier_watcher}; + +// XXX copied from libcosmic +const APPLET_PADDING: u32 = 8; + +#[derive(Clone, Debug)] +pub enum Msg { + Closed(window::Id), + // XXX don't use index (unique window id? or I guess that's created and destroyed) + StatusMenu((usize, status_menu::Msg)), + StatusNotifier(status_notifier_watcher::Event), + TogglePopup(usize), +} + +#[derive(Default)] +struct App { + core: app::Core, + connection: Option, + menus: BTreeMap, + open_menu: Option, + max_menu_id: usize, + max_popup_id: u128, + popup: Option, +} + +impl App { + fn next_menu_id(&mut self) -> usize { + self.max_menu_id += 1; + self.max_menu_id + } + + fn next_popup_id(&mut self) -> window::Id { + self.max_popup_id += 1; + window::Id(self.max_popup_id) + } + + fn resize_window(&self) -> Command { + let icon_size = self.core.applet_helper.suggested_size().0 as u32 + APPLET_PADDING * 2; + let n = self.menus.len() as u32; + resize_window(window::Id(0), 1.max(icon_size * n), icon_size) + } +} + +impl cosmic::Application for App { + type Message = Msg; + type Executor = iced::executor::Default; + type Flags = (); + const APP_ID: &'static str = "com.system76.CosmicAppletStatusArea"; + + fn init(core: app::Core, _flags: ()) -> (Self, app::Command) { + ( + Self { + core, + ..Self::default() + }, + Command::none(), + ) + } + + fn core(&self) -> &app::Core { + &self.core + } + + fn core_mut(&mut self) -> &mut app::Core { + &mut self.core + } + + fn style(&self) -> Option<::Style> { + Some(app::applet::style()) + } + + fn update(&mut self, message: Msg) -> Command { + match message { + Msg::Closed(surface) => { + if self.popup == Some(surface) { + self.popup = None; + self.open_menu = None; + } + Command::none() + } + Msg::StatusMenu((id, msg)) => match self.menus.get_mut(&id) { + Some(state) => state + .update(msg) + .map(move |msg| app::message::app(Msg::StatusMenu((id, msg)))), + None => Command::none(), + }, + Msg::StatusNotifier(event) => match event { + status_notifier_watcher::Event::Connected(connection) => { + self.connection = Some(connection); + Command::none() + } + status_notifier_watcher::Event::Registered(name) => { + let (state, cmd) = status_menu::State::new(name); + let id = self.next_menu_id(); + self.menus.insert(id, state); + Command::batch([ + self.resize_window(), + cmd.map(move |msg| app::message::app(Msg::StatusMenu((id, msg)))), + ]) + } + status_notifier_watcher::Event::Unregistered(name) => { + if let Some((id, _)) = + self.menus.iter().find(|(_id, menu)| menu.name() == &name) + { + let id = *id; + self.menus.remove(&id); + if self.open_menu == Some(id) { + self.open_menu = None; + if let Some(popup_id) = self.popup { + return destroy_popup(popup_id); + } + } + } + self.resize_window() + } + status_notifier_watcher::Event::Error(err) => { + eprintln!("Status notifier error: {}", err); + Command::none() + } + }, + Msg::TogglePopup(id) => { + self.open_menu = if self.open_menu != Some(id) { + Some(id) + } else { + None + }; + // Reuse popup if a different menu is opened. + // Had issue creating new one. Does it make a difference? + if self.open_menu.is_some() { + if self.popup.is_none() { + let id = self.next_popup_id(); + let popup_settings = self.core.applet_helper.get_popup_settings( + window::Id(0), + id, + None, + None, + None, + ); + self.popup = Some(id); + return get_popup(popup_settings); + } + } else if let Some(id) = self.popup { + return destroy_popup(id); + } + Command::none() + } + } + } + + fn subscription(&self) -> Subscription { + let mut subscriptions = Vec::new(); + + subscriptions.push(status_notifier_watcher::subscription().map(Msg::StatusNotifier)); + + for (id, menu) in self.menus.iter() { + subscriptions.push(menu.subscription().with(*id).map(Msg::StatusMenu)); + } + + iced::Subscription::batch(subscriptions) + } + + fn view(&self) -> cosmic::Element<'_, Msg> { + // XXX connect open event + iced::widget::row( + self.menus + .iter() + .map(|(id, menu)| { + self.core + .applet_helper + .icon_button(menu.icon_name()) + .on_press(Msg::TogglePopup(*id)) + .into() + }) + .collect(), + ) + .into() + } + + fn view_window(&self, _surface: window::Id) -> cosmic::Element<'_, Msg> { + match self.open_menu { + Some(id) => match self.menus.get(&id) { + Some(menu) => self + .core + .applet_helper + .popup_container(menu.popup_view().map(move |msg| Msg::StatusMenu((id, msg)))) + .into(), + None => unreachable!(), + }, + None => iced::widget::text("").into(), + } + } + + fn on_close_requested(&self, id: window::Id) -> Option { + Some(Msg::Closed(id)) + } +} + +pub fn main() -> iced::Result { + app::applet::run::(true, ()) +} diff --git a/cosmic-applet-status-area/src/components/mod.rs b/cosmic-applet-status-area/src/components/mod.rs new file mode 100644 index 00000000..d73d0fa3 --- /dev/null +++ b/cosmic-applet-status-area/src/components/mod.rs @@ -0,0 +1,2 @@ +pub mod app; +pub mod status_menu; diff --git a/cosmic-applet-status-area/src/components/status_menu.rs b/cosmic-applet-status-area/src/components/status_menu.rs new file mode 100644 index 00000000..fa804f44 --- /dev/null +++ b/cosmic-applet-status-area/src/components/status_menu.rs @@ -0,0 +1,170 @@ +use cosmic::{iced, theme}; + +use crate::subscriptions::status_notifier_item::{Layout, StatusNotifierItem}; + +#[derive(Clone, Debug)] +pub enum Msg { + Layout(Result), + Click(i32, bool), +} + +pub struct State { + item: StatusNotifierItem, + layout: Option, + expanded: Option, +} + +impl State { + pub fn new(item: StatusNotifierItem) -> (Self, iced::Command) { + ( + Self { + item, + layout: None, + expanded: None, + }, + iced::Command::none(), + ) + } + + pub fn update(&mut self, message: Msg) -> iced::Command { + match message { + Msg::Layout(layout) => { + match layout { + Ok(layout) => { + self.layout = Some(layout); + } + Err(err) => eprintln!("Error getting layout from icon: {}", err), + } + iced::Command::none() + } + Msg::Click(id, is_submenu) => { + let menu_proxy = self.item.menu_proxy().clone(); + tokio::spawn(async move { + let _ = menu_proxy.event(id, "clicked", &0.into(), 0).await; + }); + if is_submenu { + self.expanded = if self.expanded != Some(id) { + Some(id) + } else { + None + }; + } else { + // TODO: Close menu? + } + iced::Command::none() + } + } + } + + pub fn name(&self) -> &str { + self.item.name() + } + + pub fn icon_name(&self) -> &str { + self.item.icon_name() + } + + pub fn popup_view(&self) -> cosmic::Element { + if let Some(layout) = self.layout.as_ref() { + layout_view(layout, self.expanded) + } else { + iced::widget::text("").into() + } + } + + pub fn subscription(&self) -> iced::Subscription { + self.item.layout_subscription().map(Msg::Layout) + } +} + +fn layout_view(layout: &Layout, expanded: Option) -> cosmic::Element { + iced::widget::column( + layout + .children() + .iter() + .filter_map(|i| { + if !i.visible() { + None + } else if i.type_().as_deref() == Some("separator") { + Some(iced::widget::horizontal_rule(2).into()) + } else if let Some(label) = i.label() { + // Strip _ when not doubled + // TODO: interpret as "access key"? And label with underline. + let mut is_underscore = false; + let label = label + .chars() + .filter(|c| { + let prev_is_underscore = is_underscore; + is_underscore = !is_underscore && *c == '_'; + *c != '_' || prev_is_underscore + }) + .collect::(); + + let is_submenu = i.children_display().as_deref() == Some("submenu"); + let is_expanded = expanded == Some(i.id()); + + let text = iced::widget::text(label).width(iced::Length::Fill); + + let mut children: Vec> = vec![text.into()]; + if is_submenu { + let icon = cosmic::widget::icon( + if is_expanded { + "go-down-symbolic" + } else { + "go-next-symbolic" + }, + 14, + ) + .style(theme::Svg::Symbolic); + children.push(icon.into()); + } + if let Some(icon_data) = i.icon_data() { + let handle = iced::widget::image::Handle::from_memory(icon_data.to_vec()); + children.insert(0, iced::widget::Image::new(handle).into()); + } else if let Some(icon_name) = i.icon_name() { + let icon = cosmic::widget::icon(icon_name, 14).style(theme::Svg::Symbolic); + children.insert(0, icon.into()); + } + if i.toggle_state() == Some(1) { + let icon = cosmic::widget::icon("emblem-ok-symbolic", 14) + .style(theme::Svg::Symbolic); + children.push(icon.into()); + } + let button = row_button(children).on_press(Msg::Click(i.id(), is_submenu)); + + if is_submenu && is_expanded { + Some( + iced::widget::column![ + button, + // XXX nested + iced::widget::container(layout_view(i, None)).padding( + iced::Padding { + left: 12., + ..iced::Padding::ZERO + } + ) + ] + .into(), + ) + } else { + Some(button.into()) + } + } else { + None + } + }) + .collect(), + ) + .into() +} + +fn row_button(content: Vec>) -> iced::widget::Button { + cosmic::widget::button(cosmic::app::applet::applet_button_theme()) + .custom(vec![iced::widget::Row::with_children(content) + .spacing(8) + .align_items(iced::Alignment::Center) + .width(iced::Length::Fill) + .into()]) + .width(iced::Length::Fill) + .padding([8, 24]) +} diff --git a/cosmic-applet-status-area/src/main.rs b/cosmic-applet-status-area/src/main.rs new file mode 100644 index 00000000..611ec5e6 --- /dev/null +++ b/cosmic-applet-status-area/src/main.rs @@ -0,0 +1,6 @@ +mod components; +mod subscriptions; + +fn main() -> cosmic::iced::Result { + components::app::main() +} diff --git a/cosmic-applet-status-area/src/subscriptions/mod.rs b/cosmic-applet-status-area/src/subscriptions/mod.rs new file mode 100644 index 00000000..3bd364d3 --- /dev/null +++ b/cosmic-applet-status-area/src/subscriptions/mod.rs @@ -0,0 +1,2 @@ +pub mod status_notifier_item; +pub mod status_notifier_watcher; diff --git a/cosmic-applet-status-area/src/subscriptions/status_notifier_item.rs b/cosmic-applet-status-area/src/subscriptions/status_notifier_item.rs new file mode 100644 index 00000000..6d4bcd97 --- /dev/null +++ b/cosmic-applet-status-area/src/subscriptions/status_notifier_item.rs @@ -0,0 +1,207 @@ +use cosmic::iced; +use futures::{FutureExt, StreamExt}; +use zbus::zvariant::{self, OwnedValue}; + +#[derive(Clone, Debug)] +pub struct StatusNotifierItem { + name: String, + icon_name: String, + _item_proxy: StatusNotifierItemProxy<'static>, + menu_proxy: DBusMenuProxy<'static>, +} + +impl StatusNotifierItem { + pub async fn new(connection: &zbus::Connection, name: String) -> zbus::Result { + let (dest, path) = if let Some(idx) = name.find('/') { + (&name[..idx], &name[idx..]) + } else { + (name.as_str(), "/StatusNotifierItem") + }; + + let item_proxy = StatusNotifierItemProxy::builder(&connection) + .destination(dest.to_string())? + .path(path.to_string())? + .build() + .await?; + + let icon_name = item_proxy.icon_name().await?; + + let menu_path = item_proxy.menu().await?; + let menu_proxy = DBusMenuProxy::builder(&connection) + .destination(dest.to_string())? + .path(menu_path)? + .build() + .await?; + + Ok(Self { + name, + icon_name, + _item_proxy: item_proxy, + menu_proxy, + }) + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn icon_name(&self) -> &str { + &self.icon_name + } + + // TODO: Only fetch changed part of layout, if that's any faster + pub fn layout_subscription(&self) -> iced::Subscription> { + let menu_proxy = self.menu_proxy.clone(); + iced::subscription::run_with_id( + format!("status-notifier-item-{}", &self.name), + async move { + let initial = futures::stream::once(get_layout(menu_proxy.clone())); + let layout_updated_stream = menu_proxy.receive_layout_updated().await.unwrap(); + let updates = layout_updated_stream.then(move |_| get_layout(menu_proxy.clone())); + initial.chain(updates) + } + .flatten_stream(), + ) + } + + pub fn menu_proxy(&self) -> &DBusMenuProxy<'static> { + &self.menu_proxy + } +} + +async fn get_layout(menu_proxy: DBusMenuProxy<'static>) -> Result { + match menu_proxy.get_layout(0, -1, &[]).await { + Ok((_, layout)) => Ok(layout), + Err(err) => Err(err.to_string()), + } +} + +#[zbus::dbus_proxy(interface = "org.kde.StatusNotifierItem")] +trait StatusNotifierItem { + #[dbus_proxy(property)] + fn icon_name(&self) -> zbus::Result; + + #[dbus_proxy(property)] + fn menu(&self) -> zbus::Result; +} + +#[derive(Clone, Debug)] +pub struct Layout(i32, LayoutProps, Vec); + +impl<'a> serde::Deserialize<'a> for Layout { + fn deserialize>(deserializer: D) -> Result { + let (id, props, children) = + <(i32, LayoutProps, Vec<(zvariant::Signature<'_>, Self)>)>::deserialize(deserializer)?; + Ok(Self(id, props, children.into_iter().map(|x| x.1).collect())) + } +} + +impl zvariant::Type for Layout { + fn signature() -> zvariant::Signature<'static> { + zvariant::Signature::try_from("(ia{sv}av)").unwrap() + } +} + +#[derive(Clone, Debug, zvariant::DeserializeDict)] +pub struct LayoutProps { + #[zvariant(rename = "accessible-desc")] + accessible_desc: Option, + #[zvariant(rename = "children-display")] + children_display: Option, + label: Option, + enabled: Option, + visible: Option, + #[zvariant(rename = "type")] + type_: Option, + #[zvariant(rename = "toggle-type")] + toggle_type: Option, + #[zvariant(rename = "toggle-state")] + toggle_state: Option, + #[zvariant(rename = "icon-data")] + icon_data: Option>, + #[zvariant(rename = "icon-name")] + icon_name: Option, + disposition: Option, + shortcut: Option, +} + +impl zvariant::Type for LayoutProps { + fn signature() -> zvariant::Signature<'static> { + zvariant::Signature::try_from("a{sv}").unwrap() + } +} + +#[allow(dead_code)] +impl Layout { + pub fn id(&self) -> i32 { + self.0 + } + + pub fn children(&self) -> &[Self] { + &self.2 + } + + pub fn accessible_desc(&self) -> Option<&str> { + self.1.accessible_desc.as_deref() + } + + pub fn children_display(&self) -> Option<&str> { + self.1.children_display.as_deref() + } + + pub fn label(&self) -> Option<&str> { + self.1.label.as_deref() + } + + pub fn enabled(&self) -> bool { + self.1.enabled.unwrap_or(true) + } + + pub fn visible(&self) -> bool { + self.1.visible.unwrap_or(true) + } + + pub fn type_(&self) -> Option<&str> { + self.1.type_.as_deref() + } + + pub fn toggle_type(&self) -> Option<&str> { + self.1.toggle_type.as_deref() + } + + pub fn toggle_state(&self) -> Option { + self.1.toggle_state + } + + pub fn icon_data(&self) -> Option<&[u8]> { + self.1.icon_data.as_deref() + } + + pub fn icon_name(&self) -> Option<&str> { + self.1.icon_name.as_deref() + } + + pub fn disposition(&self) -> Option<&str> { + self.1.disposition.as_deref() + } + + pub fn shortcut(&self) -> Option<&str> { + self.1.shortcut.as_deref() + } +} + +#[zbus::dbus_proxy(interface = "com.canonical.dbusmenu")] +trait DBusMenu { + fn get_layout( + &self, + parent_id: i32, + recursion_depth: i32, + property_names: &[&str], + ) -> zbus::Result<(u32, Layout)>; + + fn event(&self, id: i32, event_id: &str, data: &OwnedValue, timestamp: u32) + -> zbus::Result<()>; + + #[dbus_proxy(signal)] + fn layout_updated(&self, revision: u32, parent: i32) -> zbus::Result<()>; +} diff --git a/cosmic-applet-status-area/src/subscriptions/status_notifier_watcher/client.rs b/cosmic-applet-status-area/src/subscriptions/status_notifier_watcher/client.rs new file mode 100644 index 00000000..0333e9e4 --- /dev/null +++ b/cosmic-applet-status-area/src/subscriptions/status_notifier_watcher/client.rs @@ -0,0 +1,72 @@ +use futures::{Stream, StreamExt}; +use std::pin::Pin; + +use super::Event; +use crate::subscriptions::status_notifier_item::StatusNotifierItem; + +// TODO: Don't use trait object +pub type EventStream = Pin + Send>>; + +#[zbus::dbus_proxy( + interface = "org.kde.StatusNotifierWatcher", + default_service = "org.kde.StatusNotifierWatcher", + default_path = "/StatusNotifierWatcher" +)] +trait StatusNotifierWatcher { + fn register_status_notifier_host(&self, name: &str) -> zbus::Result<()>; + + #[dbus_proxy(property)] + fn registered_status_notifier_items(&self) -> zbus::Result>; + + #[dbus_proxy(signal)] + fn status_notifier_item_registered(&self, name: &str) -> zbus::Result<()>; + + #[dbus_proxy(signal)] + fn status_notifier_item_unregistered(&self, name: &str) -> zbus::Result<()>; +} + +pub async fn watch(connection: &zbus::Connection) -> zbus::Result { + let watcher = StatusNotifierWatcherProxy::new(&connection).await?; + + let name = connection.unique_name().unwrap().as_str(); + if let Err(err) = watcher.register_status_notifier_host(name).await { + eprintln!("Failed to register status notifier host: {}", err); + } + + let connection_clone = connection.clone(); + let registered_stream = watcher + .receive_status_notifier_item_registered() + .await? + .then(move |evt| Box::pin(item_registered(connection_clone.clone(), evt))); + let unregistered_stream = watcher + .receive_status_notifier_item_unregistered() + .await? + .map(|evt| match evt.args() { + Ok(args) => Event::Unregistered(args.name.to_string()), + Err(err) => Event::Error(err.to_string()), + }); + + let items = watcher.registered_status_notifier_items().await?; + let connection = connection.clone(); + let items_stream = futures::stream::iter(items.into_iter()) + .then(move |name| status_notifier_item(connection.clone(), name)); + + Ok(Box::pin(items_stream.chain(futures::stream_select!( + registered_stream, + unregistered_stream + )))) +} + +async fn item_registered(connection: zbus::Connection, evt: StatusNotifierItemRegistered) -> Event { + match evt.args() { + Ok(args) => status_notifier_item(connection, args.name.to_string()).await, + Err(err) => Event::Error(err.to_string()), + } +} + +async fn status_notifier_item(connection: zbus::Connection, name: String) -> Event { + match StatusNotifierItem::new(&connection, name).await { + Ok(item) => Event::Registered(item), + Err(err) => Event::Error(err.to_string()), + } +} diff --git a/cosmic-applet-status-area/src/subscriptions/status_notifier_watcher/mod.rs b/cosmic-applet-status-area/src/subscriptions/status_notifier_watcher/mod.rs new file mode 100644 index 00000000..a4cd4b88 --- /dev/null +++ b/cosmic-applet-status-area/src/subscriptions/status_notifier_watcher/mod.rs @@ -0,0 +1,58 @@ +// TODO: Both this and server proxy could emit same events, have way to generate stream from either? + +use cosmic::iced; +use futures::StreamExt; + +use crate::subscriptions::status_notifier_item::StatusNotifierItem; + +mod client; +mod server; + +#[derive(Clone, Debug)] +pub enum Event { + Connected(zbus::Connection), + Registered(StatusNotifierItem), + Unregistered(String), + Error(String), // XXX +} + +enum State { + NotConnected, + Connected(client::EventStream), + Failed, +} + +pub fn subscription() -> iced::Subscription { + iced::subscription::unfold( + "status-notifier-watcher", + State::NotConnected, + |state| async move { + match state { + State::NotConnected => match connect().await { + Ok((connection, stream)) => { + (Event::Connected(connection), State::Connected(stream)) + } + Err(err) => (Event::Error(err.to_string()), State::Failed), + }, + State::Connected(mut stream) => match stream.next().await { + Some(event) => (event, State::Connected(stream)), + None => iced::futures::future::pending().await, + }, + State::Failed => iced::futures::future::pending().await, + } + }, + ) +} + +async fn connect() -> zbus::Result<(zbus::Connection, client::EventStream)> { + // Connect to session dbus socket + let connection = zbus::Connection::session().await?; + + // Start `StatusNotifierWatcher` service, if there isn't one running already + server::create_service(&connection).await?; + + // Connect client and listen for registered/unregistered + let stream = client::watch(&connection).await?; + + Ok((connection, stream)) +} diff --git a/cosmic-applet-status-area/src/subscriptions/status_notifier_watcher/server.rs b/cosmic-applet-status-area/src/subscriptions/status_notifier_watcher/server.rs new file mode 100644 index 00000000..a0333304 --- /dev/null +++ b/cosmic-applet-status-area/src/subscriptions/status_notifier_watcher/server.rs @@ -0,0 +1,137 @@ +// TODO: `g_bus_own_name` like abstraction in zbus + +#![allow(non_snake_case)] + +use futures::prelude::*; +use zbus::{ + dbus_interface, + fdo::{DBusProxy, RequestNameFlags, RequestNameReply}, + names::{BusName, UniqueName, WellKnownName}, + MessageHeader, Result, SignalContext, +}; + +const NAME: WellKnownName = + WellKnownName::from_static_str_unchecked("org.kde.StatusNotifierWatcher"); +const OBJECT_PATH: &str = "/StatusNotifierWatcher"; + +#[derive(Default)] +struct StatusNotifierWatcher { + items: Vec<(UniqueName<'static>, String)>, +} + +#[dbus_interface(name = "org.kde.StatusNotifierWatcher")] +impl StatusNotifierWatcher { + async fn register_status_notifier_item( + &mut self, + service: &str, + #[zbus(header)] hdr: MessageHeader<'_>, + #[zbus(signal_context)] ctxt: SignalContext<'_>, + ) { + let sender = hdr.sender().unwrap().unwrap(); + let service = if service.starts_with('/') { + format!("{}{}", sender, service) + } else { + service.to_string() + }; + Self::status_notifier_item_registered(&ctxt, &service) + .await + .unwrap(); + + self.items.push((sender.to_owned(), service)); + } + + fn register_status_notifier_host(&self, _service: &str) { + // XXX emit registed/unregistered + } + + #[dbus_interface(property)] + fn registered_status_notifier_items(&self) -> Vec { + self.items.iter().map(|(_, x)| x.clone()).collect() + } + + #[dbus_interface(property)] + fn is_status_notifier_host_registered(&self) -> bool { + true + } + + #[dbus_interface(property)] + fn protocol_version(&self) -> i32 { + 0 + } + + #[dbus_interface(signal)] + async fn status_notifier_item_registered(ctxt: &SignalContext<'_>, service: &str) + -> Result<()>; + + #[dbus_interface(signal)] + async fn status_notifier_item_unregistered( + ctxt: &SignalContext<'_>, + service: &str, + ) -> Result<()>; + + #[dbus_interface(signal)] + async fn status_notifier_host_registered(ctxt: &SignalContext<'_>) -> Result<()>; + + #[dbus_interface(signal)] + async fn status_notifier_host_unregistered(ctxt: &SignalContext<'_>) -> Result<()>; +} + +pub async fn create_service(connection: &zbus::Connection) -> zbus::Result<()> { + connection + .object_server() + .at(OBJECT_PATH, StatusNotifierWatcher::default()) + .await?; + let interface = connection + .object_server() + .interface::<_, StatusNotifierWatcher>(OBJECT_PATH) + .await + .unwrap(); + let dbus_proxy = DBusProxy::new(&connection).await?; + let mut name_owner_changed_stream = dbus_proxy.receive_name_owner_changed().await?; + + let flags = RequestNameFlags::AllowReplacement.into(); + match dbus_proxy.request_name(NAME.as_ref(), flags).await? { + RequestNameReply::InQueue => { + eprintln!("Bus name '{}' already owned", NAME); + } + _ => {} + } + + let connection = connection.clone(); + tokio::spawn(async move { + let mut have_bus_name = false; + let unique_name = connection.unique_name().map(|x| x.as_ref()); + while let Some(evt) = name_owner_changed_stream.next().await { + let args = match evt.args() { + Ok(args) => args, + Err(_) => { + continue; + } + }; + if args.name.as_ref() == NAME { + if args.new_owner.as_ref() == unique_name.as_ref() { + eprintln!("Acquired bus name: {}", NAME); + have_bus_name = true; + } else if have_bus_name { + eprintln!("Lost bus name: {}", NAME); + have_bus_name = false; + } + } else if let BusName::Unique(name) = &args.name { + let mut interface = interface.get_mut().await; + if let Some(idx) = interface + .items + .iter() + .position(|(unique_name, _)| unique_name == name) + { + let ctxt = zbus::SignalContext::new(&connection, OBJECT_PATH).unwrap(); + let service = interface.items.remove(idx).1; + StatusNotifierWatcher::status_notifier_item_unregistered(&ctxt, &service) + .await + .unwrap(); + } + } + } + }); + + Ok(()) +} diff --git a/justfile b/justfile index 363f7fb8..923b763a 100644 --- a/justfile +++ b/justfile @@ -45,6 +45,7 @@ _install_notifications: (_install 'com.system76.CosmicAppletNotifications' 'cosm _install_power: (_install 'com.system76.CosmicAppletPower' 'cosmic-applet-power') _install_workspace: (_install 'com.system76.CosmicAppletWorkspaces' 'cosmic-applet-workspaces') _install_time: (_install 'com.system76.CosmicAppletTime' 'cosmic-applet-time') +_install_status_area: (_install 'com.system76.CosmicAppletStatusArea' 'cosmic-applet-status-area') # TODO: Turn this into one configurable applet? _install_panel_button: (_install_bin 'cosmic-panel-button') @@ -53,7 +54,7 @@ _install_app_button: (_install_button 'com.system76.CosmicPanelAppButton' 'cosmi _install_workspaces_button: (_install_button 'com.system76.CosmicPanelWorkspacesButton' 'cosmic-panel-workspaces-button') # Installs files into the system -install: _install_app_list _install_audio _install_battery _install_bluetooth _install_graphics _install_network _install_notifications _install_power _install_workspace _install_time _install_panel_button _install_app_button _install_workspaces_button +install: _install_app_list _install_audio _install_battery _install_bluetooth _install_graphics _install_network _install_notifications _install_power _install_workspace _install_time _install_panel_button _install_app_button _install_workspaces_button _install_status_area # Extracts vendored dependencies if vendor=1 _extract_vendor: