├── .github ├── FUNDING.yml ├── README.md ├── showcase │ ├── phase_1.png │ ├── phase_2.png │ ├── phase_3.png │ └── phase_4.png └── workflows │ └── rust.yml.bak ├── .gitignore ├── Cargo.toml ├── LICENSE ├── docs └── widgets.md ├── examples ├── helium.json └── style.css ├── src ├── builder │ ├── layer_builder.rs │ ├── mod.rs │ └── widgets_builder.rs ├── config │ ├── mod.rs │ └── user_config.rs ├── main.rs ├── modules │ ├── battery.rs │ ├── brightness.rs │ ├── hyprland.rs │ ├── mod.rs │ └── tray.rs ├── network │ ├── hyprland_socket.rs │ └── mod.rs ├── utils │ ├── command.rs │ ├── constants.rs │ ├── file_handler.rs │ ├── listener.rs │ ├── mod.rs │ └── regex_matcher.rs └── widgets │ ├── LabelWidget.rs │ └── mod.rs └── stray ├── Cargo.toml ├── README.md ├── examples └── simple.rs └── src ├── dbus ├── dbusmenu_proxy.rs ├── mod.rs ├── notifier_item_proxy.rs ├── notifier_watcher_proxy.rs └── notifier_watcher_service.rs ├── error.rs ├── lib.rs ├── message ├── menu.rs ├── mod.rs └── tray.rs ├── notifier_host └── mod.rs └── notifier_watcher ├── mod.rs └── notifier_address.rs /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: pwnwriter 2 | -------------------------------------------------------------------------------- /.github/README.md: -------------------------------------------------------------------------------- 1 |

2 |

He 1s2 💭 — A noble, light and aesthetic bar for Wayland // wlroots

3 |

4 |

5 | Crate Release 6 | 7 | MIT LICENSE 8 | Ko-fi 9 |
10 | 11 |

12 | 13 | 14 | 15 | 16 | ## WIP 17 | 18 | ![image](showcase/phase_4.png) 19 | 20 | 21 | 22 | 23 |

24 |

Copyright © 2023 pwnwriter xyz ☘️ 25 | 26 | -------------------------------------------------------------------------------- /.github/showcase/phase_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metis-os/heliumbar/eaf9bc3f5d3f0d33dc983a614a7a1cead8b05ccd/.github/showcase/phase_1.png -------------------------------------------------------------------------------- /.github/showcase/phase_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metis-os/heliumbar/eaf9bc3f5d3f0d33dc983a614a7a1cead8b05ccd/.github/showcase/phase_2.png -------------------------------------------------------------------------------- /.github/showcase/phase_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metis-os/heliumbar/eaf9bc3f5d3f0d33dc983a614a7a1cead8b05ccd/.github/showcase/phase_3.png -------------------------------------------------------------------------------- /.github/showcase/phase_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metis-os/heliumbar/eaf9bc3f5d3f0d33dc983a614a7a1cead8b05ccd/.github/showcase/phase_4.png -------------------------------------------------------------------------------- /.github/workflows/rust.yml.bak: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | toolchain: 19 | - stable 20 | - beta 21 | - nightly 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }} 26 | - name: Build 27 | run: cargo build --verbose 28 | - name: Run tests 29 | run: cargo test --verbose 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | .vscode 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "heliumbar" 3 | version = "0.1.0" 4 | edition = "2021" 5 | authors = [ "PwnWriter < hey@pwnwriter.xyz >" ] 6 | description = "💭 A noble, light and aesthetic bar for Wayland // wlroots" 7 | readme = "README.md" 8 | repository = "https://github.com/pwnwriter/heliumbar" 9 | homepage = "https://github.com/pwnwriter/heliumbar.git" 10 | license = "MIT" 11 | keywords = ["unixporn", "Hyprland", "Wayland-bar"] 12 | categories = ["accessibility", "command-line" ] 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | 16 | [dependencies] 17 | gtk = { version = "0.17.0", package = "gtk" } 18 | tokio = { version = "1.26.0", features = ["full"] } 19 | gtk-layer-shell = "0.6.1" 20 | json = "0.12.4" 21 | glib = "0.18.1" 22 | regex = "1.9.3" 23 | tokio-uds = "0.2" 24 | inotify = "0.10.2" 25 | stray = {path = "stray"} 26 | once_cell = "1.18.0" 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 PwnWriter < pwnwriter.xyz > 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/widgets.md: -------------------------------------------------------------------------------- 1 | This is helper documentation 2 | -------------------------------------------------------------------------------- /examples/helium.json: -------------------------------------------------------------------------------- 1 | { 2 | "alpha":0, 3 | "align":"top", 4 | "background":"#000000", 5 | "widgets":{ 6 | "battery":{ 7 | "format":" {percentage} %", 8 | "type":"battery", 9 | "is_json":false, 10 | "align":"left", 11 | "refresh-rate":1 12 | }, 13 | "brightness":{ 14 | "format":"󰃜 {} %", 15 | "align":"left", 16 | "type":"brightness", 17 | "refresh-rate":1 18 | }, 19 | 20 | "tray":{ 21 | "type":"tray", 22 | "align":"right" 23 | }, 24 | "hyprland":{ 25 | "format":"󱂬 {workspace}:{activewindow}", 26 | "type":"hyprland", 27 | "align":"center" 28 | 29 | } 30 | 31 | } 32 | 33 | 34 | } 35 | -------------------------------------------------------------------------------- /examples/style.css: -------------------------------------------------------------------------------- 1 | .root{ 2 | font-size:15px; 3 | /* margin-top:10px; */ 4 | padding:5px 5px; 5 | } 6 | 7 | .battery,.brightness,.tray,.hyprland{ 8 | background-color:#5c5f77; 9 | padding:8px 20px; 10 | border-radius:7px; 11 | color:white; 12 | } 13 | 14 | .battery{ 15 | border-radius:7px 0 0 7px; 16 | padding-right:5px; 17 | } 18 | .brightness{ 19 | border-radius:0px 7px 7px 0; 20 | padding-left:5px; 21 | } 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/builder/layer_builder.rs: -------------------------------------------------------------------------------- 1 | // HELIUMBAR ui / ux 2 | 3 | use crate::builder::widgets_builder::build_widgets; 4 | use crate::config; 5 | use gtk::gdk::*; 6 | use gtk::prelude::*; 7 | use gtk::Orientation; 8 | use gtk::{Application, ApplicationWindow}; 9 | use gtk_layer_shell; 10 | use gtk_layer_shell::Edge; 11 | 12 | pub fn build_ui(app: &Application) { 13 | let window = ApplicationWindow::new(app); 14 | gtk_layer_shell::init_for_window(&window); 15 | 16 | gtk_layer_shell::set_layer(&window, gtk_layer_shell::Layer::Top); 17 | 18 | gtk_layer_shell::auto_exclusive_zone_enable(&window); 19 | window.set_app_paintable(true); 20 | 21 | let config = read_window_config(); 22 | 23 | let mut orientation = Orientation::Horizontal; 24 | 25 | if let Some(data) = config { 26 | match &data.position { 27 | Edge::Left => orientation = Orientation::Vertical, 28 | Edge::Right => orientation = Orientation::Vertical, 29 | _ => (), 30 | } 31 | align_layer(&window, &data.position); 32 | window.connect_draw(move |win, context| draw(win, context, &data)); 33 | } else { 34 | align_layer(&window, &Edge::Top); 35 | } 36 | 37 | let display = Display::default().expect("Error happening"); 38 | let monitor = display.monitor(0).expect("Error getting monitor"); 39 | gtk_layer_shell::set_monitor(&window, &monitor); 40 | 41 | window.connect_destroy(|_| gtk::main_quit()); 42 | build_widgets(&window, orientation); 43 | } 44 | 45 | pub fn align_layer(window: &ApplicationWindow, align: &Edge) { 46 | gtk_layer_shell::set_anchor(window, Edge::Left, true); 47 | gtk_layer_shell::set_anchor(window, Edge::Right, true); 48 | gtk_layer_shell::set_anchor(window, Edge::Top, true); 49 | gtk_layer_shell::set_anchor(window, Edge::Bottom, true); 50 | match align { 51 | Edge::Top => { 52 | gtk_layer_shell::set_anchor(window, Edge::Bottom, false); 53 | } 54 | Edge::Bottom => { 55 | gtk_layer_shell::set_anchor(window, Edge::Top, false); 56 | } 57 | Edge::Left => { 58 | gtk_layer_shell::set_anchor(window, Edge::Right, false); 59 | } 60 | Edge::Right => { 61 | gtk_layer_shell::set_anchor(window, Edge::Left, false); 62 | } 63 | _ => { 64 | gtk_layer_shell::set_anchor(window, Edge::Bottom, false); 65 | } 66 | } //match 67 | } 68 | pub struct LayerConfig { 69 | alpha: f64, 70 | color: (f64, f64, f64), 71 | position: Edge, 72 | } 73 | 74 | pub fn draw(_: &ApplicationWindow, context: &cairo::Context, config: &LayerConfig) -> Inhibit { 75 | context.set_source_rgba(config.color.0, config.color.1, config.color.2, config.alpha); 76 | context.set_operator(cairo::Operator::Screen); 77 | context.paint().unwrap_or_default(); 78 | Inhibit(false) 79 | } 80 | 81 | pub fn extract_color(color: &str) -> Option<(f64, f64, f64)> { 82 | if color.len() != 7 || !color.starts_with('#') { 83 | return None; 84 | } 85 | let r_color = u8::from_str_radix(&color[1..3], 16).ok()? as f64 / 255_f64; 86 | let g_color = u8::from_str_radix(&color[3..5], 16).ok()? as f64 / 255_f64; 87 | let b_color = u8::from_str_radix(&color[5..7], 16).ok()? as f64 / 255_f64; 88 | Some((r_color, g_color, b_color)) 89 | } 90 | 91 | pub fn read_window_config() -> Option { 92 | let config = config::user_config::read_config(); 93 | if config.is_err() { 94 | return None; 95 | } 96 | let config = config.unwrap(); 97 | let alpha: f64 = config["alpha"].as_f64().unwrap_or_default(); 98 | let color: String = config["background"] 99 | .as_str() 100 | .unwrap_or("#000000") 101 | .to_string(); 102 | let position: String = config["align"].as_str().unwrap_or("top").to_string(); 103 | let pos: Edge; 104 | if position == "top" { 105 | pos = Edge::Top; 106 | } else if position == "bottom" { 107 | pos = Edge::Bottom; 108 | } else if position == "left" { 109 | pos = Edge::Left; 110 | } else if position == "right" { 111 | pos = Edge::Right; 112 | } else { 113 | pos = Edge::Top; 114 | } 115 | 116 | let mut sep_col = extract_color(&color); 117 | if sep_col.is_none() { 118 | sep_col = Some((0.0, 0.0, 0.0)); 119 | } 120 | Some(LayerConfig { 121 | alpha, 122 | color: sep_col.unwrap(), 123 | position: pos, 124 | }) 125 | } 126 | -------------------------------------------------------------------------------- /src/builder/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod layer_builder; 2 | pub mod widgets_builder; 3 | -------------------------------------------------------------------------------- /src/builder/widgets_builder.rs: -------------------------------------------------------------------------------- 1 | use gtk::prelude::*; 2 | use gtk::ApplicationWindow; 3 | use gtk::Orientation; 4 | 5 | use crate::config; 6 | use crate::modules; 7 | use crate::utils; 8 | use crate::widgets::LabelWidget; 9 | // s 10 | fn build_config_else_default( 11 | centered: >k::Box, 12 | configs: &Result, 13 | ) -> bool { 14 | if let Err(error) = configs { 15 | let label = gtk::Label::builder().label(error).margin_start(40).build(); 16 | centered.add(&label); 17 | return false; 18 | } 19 | let configs = configs.as_ref().unwrap(); 20 | 21 | if !configs.has_key("widgets") { 22 | let label = gtk::Label::builder() 23 | .label( 24 | "No widgets found in your config,please add some widgets to show in the status bar", 25 | ) 26 | .margin_start(40) 27 | .build(); 28 | centered.add(&label); 29 | return false; 30 | }; 31 | true 32 | } 33 | 34 | fn load_css() { 35 | let user = std::env::var("HOME"); 36 | if let Err(err) = user { 37 | println!("{}", err); 38 | return; 39 | } 40 | let mut path = user.unwrap(); 41 | path.push_str(utils::constants::CONFIG_STYLE); 42 | 43 | let provider = gtk::CssProvider::new(); 44 | if let Err(err) = provider.load_from_path(&path) { 45 | println!("{}", err); 46 | return; 47 | } 48 | 49 | let screen = gtk::gdk::Screen::default(); 50 | if screen.is_none() { 51 | return 52 | } 53 | 54 | gtk::StyleContext::add_provider_for_screen( 55 | &screen.unwrap(), 56 | &provider, 57 | gtk::STYLE_PROVIDER_PRIORITY_USER, 58 | ); 59 | } 60 | 61 | pub fn build_widgets(window: &ApplicationWindow, orientation: Orientation) { 62 | // let orientation = Orientation::Horizontal; 63 | let root = gtk::Box::new(orientation, 0); 64 | let left = gtk::Box::new(orientation, 0); 65 | let centered = gtk::Box::new(orientation, 0); 66 | let right = gtk::Box::new(orientation, 0); 67 | 68 | root.style_context().add_class("root"); 69 | left.style_context().add_class("left"); 70 | centered.style_context().add_class("center"); 71 | right.style_context().add_class("right"); 72 | 73 | root.set_center_widget(Some(¢ered)); 74 | root.pack_end(&right, false, true, 0); 75 | root.add(&left); 76 | 77 | let configs = config::user_config::read_config(); 78 | if build_config_else_default(¢ered, &configs) { 79 | render_widgets(left, right, centered, configs.unwrap()); 80 | } 81 | 82 | window.add(&root); 83 | load_css(); 84 | window.show_all(); 85 | } 86 | 87 | pub enum Align { 88 | LEFT, 89 | CENTER, 90 | RIGHT, 91 | } 92 | pub struct WidgetConfig { 93 | pub format: String, 94 | // pub type_of_widget: String, 95 | pub align: Align, 96 | pub command: String, 97 | pub refresh_rate: i64, 98 | pub tooltip: String, 99 | pub name_of_widget: String, 100 | pub is_json: bool, 101 | } 102 | 103 | pub fn check_alignment(align: &String) -> Align { 104 | if align == "left" { 105 | return Align::LEFT; 106 | } else if align == "right" { 107 | return Align::RIGHT; 108 | } else if align == "center" { 109 | return Align::CENTER; 110 | } else { 111 | return Align::LEFT; 112 | } 113 | } 114 | 115 | pub fn render_widgets( 116 | left: gtk::Box, 117 | right: gtk::Box, 118 | centered: gtk::Box, 119 | configs: json::JsonValue, 120 | ) { 121 | let mut modules_name: Vec = Vec::new(); 122 | modules_name.push("hyprland".to_string()); 123 | modules_name.push("battery".to_string()); 124 | modules_name.push("cpu".to_string()); 125 | modules_name.push("ram".to_string()); 126 | modules_name.push("time".to_string()); 127 | modules_name.push("brightness".to_string()); 128 | modules_name.push("volume".to_string()); 129 | modules_name.push("tray".to_string()); 130 | 131 | let widgets = configs["widgets"].entries(); 132 | for (key, value_json) in widgets { 133 | let format = value_json["format"].as_str().unwrap_or("").to_string(); 134 | let type_of_widget = value_json["type"].as_str().unwrap_or("").to_string(); 135 | let align = check_alignment(&value_json["align"].as_str().unwrap_or("").to_string()); 136 | let command = value_json["command"].as_str().unwrap_or("").to_string(); 137 | let refresh_rate = value_json["refresh-rate"].as_i64().unwrap_or(0); 138 | let tooltip = value_json["tooltip"].as_str().unwrap_or("").to_string(); 139 | let is_json = value_json["is_json"].as_bool().unwrap_or(false); 140 | let name_of_widget = key.to_string(); 141 | 142 | let data = WidgetConfig { 143 | format, 144 | // type_of_widget, 145 | align, 146 | command, 147 | refresh_rate, 148 | is_json, 149 | tooltip, 150 | name_of_widget, 151 | }; 152 | if modules_name.contains(&type_of_widget) { 153 | handle_builtin_widgets(&left, ¢ered, &right, data, &type_of_widget); 154 | } else if type_of_widget == "label" { 155 | LabelWidget::build_label(&left, ¢ered, &right, data); 156 | } else { 157 | LabelWidget::build_label(&left, ¢ered, &right, data); 158 | } 159 | } //for 160 | } 161 | 162 | fn handle_builtin_widgets( 163 | left: >k::Box, 164 | centered: >k::Box, 165 | right: >k::Box, 166 | config: WidgetConfig, 167 | type_of_widget: &String, 168 | ) { 169 | // println!("{}", type_of_widget); 170 | if type_of_widget == "hyprland" { 171 | modules::hyprland::build_label(&left, ¢ered, &right, config); 172 | } else if type_of_widget == "battery" { 173 | modules::battery::build_label(left, ¢ered, &right, config); 174 | } else if type_of_widget == "brightness" { 175 | modules::brightness::build_label(left, ¢ered, &right, config); 176 | } else if type_of_widget == "tray" { 177 | modules::tray::build_label(left, centered, right, config); 178 | } 179 | } 180 | 181 | pub fn build_and_align( 182 | text: &String, 183 | left: >k::Box, 184 | center: >k::Box, 185 | right: >k::Box, 186 | config: &WidgetConfig, 187 | ) -> gtk::Label { 188 | let label = gtk::Label::builder().label(text).build(); 189 | label.style_context().add_class(&config.name_of_widget); 190 | match config.align { 191 | Align::CENTER => center.add(&label), 192 | Align::LEFT => left.add(&label), 193 | Align::RIGHT => right.add(&label), 194 | } 195 | 196 | return label; 197 | } 198 | -------------------------------------------------------------------------------- /src/config/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod user_config; 2 | 3 | pub use user_config::*; 4 | -------------------------------------------------------------------------------- /src/config/user_config.rs: -------------------------------------------------------------------------------- 1 | // use json; 2 | //hello 3 | 4 | use crate::utils; 5 | 6 | pub fn read_file(path: &String) -> Option { 7 | let data = match std::fs::read_to_string(&path) { 8 | Ok(content) => content, 9 | Err(err) => { 10 | if err.kind() == std::io::ErrorKind::NotFound { 11 | println!("{} file is not found in your system", path); 12 | return None; 13 | } else if err.kind() == std::io::ErrorKind::PermissionDenied { 14 | println!("Permission denied to access the {} file", path); 15 | return None; 16 | } else { 17 | println!("Something went wrong reading the {} file", path); 18 | return None; 19 | } 20 | } //error 21 | }; //reading the string 22 | Some(data) 23 | } 24 | 25 | pub fn get_config_path() -> Option { 26 | let user = std::env::var("HOME"); 27 | if let Err(_) = user { 28 | println!("Unble to find the username of the system"); 29 | return None; 30 | } 31 | let mut path = String::from(&user.unwrap()); 32 | path.push_str(utils::constants::CONFIG_PATH); 33 | Some(path) 34 | } 35 | 36 | pub fn read_config() -> Result { 37 | let path = get_config_path(); 38 | 39 | if let None = path { 40 | return Err("Unable to find the config path".to_string()); 41 | } 42 | let data = read_file(&path.unwrap()); 43 | if let None = data { 44 | return Err("Unable to read the config file".to_string()); 45 | } 46 | parse_config(data) 47 | } 48 | 49 | pub fn parse_config(data: Option) -> Result { 50 | let config = json::parse(&data.unwrap()); 51 | 52 | if let Err(error) = config { 53 | println!("Error occur parsing the json. Switching to default conf"); 54 | return Err(error.to_string()); 55 | } 56 | return Ok(config.unwrap()); 57 | } 58 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod builder; 2 | mod config; 3 | mod modules; 4 | mod network; 5 | mod utils; 6 | mod widgets; 7 | 8 | use gtk::prelude::{ApplicationExt, ApplicationExtManual}; 9 | use utils::command::run; 10 | 11 | use crate::builder::layer_builder::build_ui; 12 | use stray::{ 13 | message::{ 14 | menu::{MenuType, TrayMenu}, 15 | tray::{IconPixmap, StatusNotifierItem}, 16 | NotifierItemCommand, 17 | }, 18 | NotifierItemMessage, StatusNotifierWatcher, 19 | }; 20 | fn main() -> gtk::glib::ExitCode { 21 | const APP_ID: &str = "com.heliumbar"; 22 | 23 | let app = gtk::Application::builder().application_id(APP_ID).build(); 24 | // println!("{:?}", std::thread::current().id().to_owned()); 25 | app.connect_activate(build_ui); 26 | // tray(); 27 | app.run() 28 | } //main 29 | 30 | fn tray() { 31 | let (_sender, receiver) = tokio::sync::mpsc::channel(50); 32 | 33 | std::thread::spawn(move || { 34 | let tokio_runtime = tokio::runtime::Runtime::new().unwrap(); 35 | 36 | tokio_runtime.block_on(async { 37 | let watcher = StatusNotifierWatcher::new(receiver).await.unwrap(); 38 | 39 | let mut notifier_host = watcher.create_notifier_host("Hybrid").await; 40 | if let Err(err) = notifier_host { 41 | println!("Error::{}", err); 42 | return; 43 | } 44 | let mut notifier_host = notifier_host.unwrap(); 45 | while let Ok(msg) = notifier_host.recv().await { 46 | match msg { 47 | NotifierItemMessage::Update { 48 | address, 49 | item, 50 | menu, 51 | } => { 52 | println!("update:{}{:?}", address, (*item).icon_name); 53 | } //on update, 54 | NotifierItemMessage::Remove { address } => { 55 | println!("Removed:{}", address); 56 | } //remove 57 | } //match msg 58 | } //while 59 | }) //runtime async fun 60 | }); 61 | } 62 | 63 | //////////////////////// 64 | fn watcher_file() { 65 | let path = "/sys/class/backlight/amdgpu_bl1/brightness"; 66 | let mut inotify = inotify::Inotify::init().unwrap(); 67 | let watch = inotify.watches().add(path, inotify::WatchMask::MODIFY); 68 | if let Err(err) = watch { 69 | println!("{}", err); 70 | return; 71 | } 72 | // let watch = watch.unwrap(); 73 | let mut buffer = [0u8; 4096]; 74 | loop { 75 | let events = inotify.read_events_blocking(&mut buffer); 76 | if let Err(err) = events { 77 | println!("{}", err); 78 | } 79 | //for loop 80 | } 81 | } //watcher -------------------------------------------------------------------------------- /src/modules/battery.rs: -------------------------------------------------------------------------------- 1 | use json::{Error, JsonValue}; 2 | use std::collections::HashMap; 3 | use std::fs::{self, File}; 4 | use std::io::{Read, Seek}; 5 | use std::time::Duration; 6 | 7 | use crate::builder::widgets_builder::{self, Align, WidgetConfig}; 8 | use crate::utils::constants::BATTERY_PATH; 9 | use crate::utils::file_handler::{get_particular_dir_path, read_file_for_monitor}; 10 | use crate::utils::{command, listener, regex_matcher}; 11 | use glib::{MainContext, Receiver, Sender}; 12 | use gtk::prelude::*; 13 | // use super::workspace::listen; 14 | 15 | pub fn build_label(left: >k::Box, center: >k::Box, right: >k::Box, config: WidgetConfig) { 16 | let original: String = config.format.clone(); 17 | 18 | let label = widgets_builder::build_and_align(&original, &left, ¢er, &right, &config); 19 | // println!("{}", text); 20 | update_widget(label, original, config.refresh_rate); 21 | } 22 | 23 | fn update_widget(label: gtk::Label, original: String, refresh_rate: i64) { 24 | // let path = "/sys/class/power_supply/BAT0/capacity"; 25 | let base_path = get_particular_dir_path(BATTERY_PATH.to_string(), "capacity".to_string()); 26 | if let None = base_path { 27 | return; 28 | } 29 | let base_path = base_path.unwrap(); 30 | let path = format!("{}/capacity", base_path); 31 | let mut buffer = [0u8; 30]; 32 | let mut file = match File::open(path) { 33 | Ok(file) => file, 34 | Err(err) => { 35 | println!("{}", err); 36 | return; 37 | } 38 | }; 39 | let interval = if refresh_rate > 0 { 40 | std::time::Duration::from_secs(refresh_rate as u64) 41 | } else { 42 | std::time::Duration::from_secs(1) 43 | }; 44 | let (sender, receiver) = MainContext::channel::<(String, String)>(glib::Priority::DEFAULT); 45 | //lister 46 | listener::listen(receiver, original, label); //listen and update according to it 47 | std::thread::spawn(move || { 48 | let mut previous_state: String = read_file_for_monitor(&mut file, &mut buffer); 49 | sender 50 | .send(("percentage".to_string(), previous_state.to_owned())) 51 | .unwrap_or_default(); 52 | let mut current_state: String; 53 | loop { 54 | std::thread::sleep(interval); 55 | current_state = read_file_for_monitor(&mut file, &mut buffer); 56 | if previous_state == current_state { 57 | continue; 58 | } 59 | previous_state = current_state; 60 | sender 61 | .send(("percentage".to_string(), previous_state.to_owned())) 62 | .unwrap_or_default(); 63 | // sender.send((previous_state.parse::().unwrap_or(1.0) / 1000000.0).to_string()); 64 | } //loop 65 | }); //thread 66 | } //update widget 67 | -------------------------------------------------------------------------------- /src/modules/brightness.rs: -------------------------------------------------------------------------------- 1 | use json::{Error, JsonValue}; 2 | use std::collections::HashMap; 3 | use std::fs::{self, File}; 4 | use std::io::{Read, Seek}; 5 | use std::time::Duration; 6 | 7 | use crate::builder::widgets_builder::{self, Align, WidgetConfig}; 8 | use crate::utils::constants::{BATTERY_PATH, BRIGHTNESS_PATH}; 9 | use crate::utils::file_handler::{get_particular_dir_path, read_file_for_monitor}; 10 | use crate::utils::{command, listener, regex_matcher}; 11 | use glib::{MainContext, Receiver, Sender}; 12 | use gtk::prelude::*; 13 | // use super::workspace::listen; 14 | 15 | pub fn build_label(left: >k::Box, center: >k::Box, right: >k::Box, config: WidgetConfig) { 16 | let original: String = config.format.clone(); 17 | 18 | let label = widgets_builder::build_and_align(&original, &left, ¢er, &right, &config); 19 | // println!("{}", text); 20 | update_widget(label, original, config.refresh_rate); 21 | } 22 | 23 | fn update_widget(label: gtk::Label, original: String, refresh_rate: i64) { 24 | // let path = "/sys/class/power_supply/BAT0/capacity"; 25 | let base_path = get_particular_dir_path(BRIGHTNESS_PATH.to_string(), "brightness".to_string()); 26 | if let None = base_path { 27 | return; 28 | } 29 | let base_path = base_path.unwrap(); 30 | let path = format!("{}/brightness", base_path); 31 | let mut buffer = [0u8; 60]; 32 | let max = match File::open(format!("{}/max_brightness", base_path)) { 33 | Ok(mut file) => read_file_for_monitor(&mut file, &mut buffer) 34 | .parse::() 35 | .unwrap_or(255.0), 36 | Err(err) => { 37 | println!("{}", err); 38 | 255.0 39 | } 40 | }; //max 41 | let mut file = match File::open(&path) { 42 | Ok(file) => file, 43 | Err(err) => { 44 | println!("{}", err); 45 | return; 46 | } 47 | }; 48 | let interval = if refresh_rate > 0 { 49 | std::time::Duration::from_secs(refresh_rate as u64) 50 | } else { 51 | std::time::Duration::from_secs(1) 52 | }; 53 | let (sender, receiver) = MainContext::channel::<(String, String)>(glib::Priority::DEFAULT); 54 | //lister 55 | listener::listen(receiver, original, label); //listen and update according to it 56 | //inotify 57 | let mut inotify = inotify::Inotify::init().unwrap(); 58 | let watch = inotify.watches().add(&path, inotify::WatchMask::MODIFY); 59 | if let Err(err) = watch { 60 | println!("{}", err); 61 | return; 62 | } // 63 | 64 | //inotify 65 | std::thread::spawn(move || { 66 | let mut previous_state: String = read_file_for_monitor(&mut file, &mut buffer); 67 | sender 68 | .send(( 69 | "".to_string(), 70 | (((previous_state.parse::().unwrap_or(1.0) / max) * 100.0) as i64).to_string(), 71 | )) 72 | .unwrap_or_default(); 73 | let mut current_state: String; 74 | loop { 75 | if let Err(err) = inotify.read_events_blocking(&mut buffer) { 76 | println!("{}", err); 77 | std::thread::sleep(interval); 78 | } 79 | // println!("I am changing now"); 80 | current_state = read_file_for_monitor(&mut file, &mut buffer); 81 | if previous_state == current_state { 82 | continue; 83 | } 84 | previous_state = current_state; 85 | sender 86 | .send(( 87 | "".to_string(), 88 | (((previous_state.parse::().unwrap_or(1.0) / max) * 100.0) as i64) 89 | .to_string(), 90 | )) 91 | .unwrap_or_default(); 92 | // sender.send((previous_state.parse::().unwrap_or(1.0) / 1000000.0).to_string()); 93 | } //loop 94 | }); //thread 95 | } //update widget 96 | -------------------------------------------------------------------------------- /src/modules/hyprland.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use gtk::traits::ContainerExt; 4 | use json::{Error, JsonValue}; 5 | 6 | use crate::builder::widgets_builder::{self, Align, WidgetConfig}; 7 | use crate::network::hyprland_socket::listen; 8 | use crate::utils::{command, regex_matcher}; 9 | use glib::MainContext; 10 | use gtk::prelude::*; 11 | // use super::workspace::listen; 12 | 13 | fn setup_default(mut text: String) -> (String, Result) { 14 | text = text 15 | .replace("{workspace}", "{workspace.id}") 16 | .replace("{activewindow}", "{title}"); 17 | let out = command::run(&"hyprctl activewindow -j".to_string()) 18 | .trim() 19 | .to_string(); 20 | let mut jsondata = json::parse(&out); 21 | if let Ok(json) = jsondata { 22 | jsondata = Ok(json); 23 | } 24 | if let Some(data) = regex_matcher::format(&text, &out) { 25 | text = data; 26 | } 27 | 28 | return (text, jsondata); 29 | } 30 | 31 | pub fn build_label(left: >k::Box, center: >k::Box, right: >k::Box, config: WidgetConfig) { 32 | let original: String = config.format.clone(); 33 | 34 | let (text, jsondata) = setup_default(original.clone()); 35 | 36 | let label = widgets_builder::build_and_align(&text, &left, ¢er, &right, &config); 37 | // println!("{}", text); 38 | 39 | update_widget(label, original, jsondata); 40 | } //build_label 41 | 42 | pub fn update_widget( 43 | label: gtk::Label, 44 | original: String, 45 | jsondata: Result, 46 | ) { 47 | let (sender, receiver) = MainContext::channel::<(String, String)>(glib::Priority::DEFAULT); 48 | 49 | let rt = tokio::runtime::Builder::new_current_thread() 50 | .enable_all() 51 | .build(); 52 | 53 | if let Err(err) = rt { 54 | println!("{}", err); 55 | return; 56 | } 57 | let mut params = regex_matcher::get_params(&original); 58 | if params.len() == 0 { 59 | return; 60 | } 61 | std::thread::spawn(move || rt.unwrap().block_on(listen(sender))); 62 | 63 | //listen for the socket 64 | params = set_current_win_wor(params, jsondata); 65 | hyprland_signal_receiver(receiver, params, original, label); 66 | } 67 | 68 | fn set_current_win_wor( 69 | mut params: HashMap, 70 | json: Result, 71 | ) -> HashMap { 72 | if let Ok(data) = json { 73 | if params.contains_key("workspace") { 74 | params.insert( 75 | "workspace".to_string(), 76 | data["workspace"]["name"].as_str().unwrap_or("").to_string(), 77 | ); 78 | } //if workspace 79 | if params.contains_key("activewindow") { 80 | params.insert( 81 | "activewindow".to_string(), 82 | data["title"].as_str().unwrap_or("").to_string(), 83 | ); 84 | } //if workspace 85 | } //jsonvale 86 | 87 | return params; 88 | } 89 | 90 | fn hyprland_signal_receiver( 91 | receiver: glib::Receiver<(String, String)>, 92 | mut params: HashMap, 93 | original: String, 94 | label: gtk::Label, 95 | ) { 96 | let mut format_text: String = String::new(); 97 | //reciver is here 98 | receiver.attach(None, move |(name, value)| { 99 | // println!("{}", name); 100 | 101 | if params.contains_key(&name) { 102 | // 103 | if name == "activewindow" { 104 | if let Some((_class, title)) = value.split_once(",") { 105 | params.insert(name.trim().to_string(), title.trim().to_string()); 106 | } 107 | } else { 108 | params.insert(name.trim().to_string(), value.trim().to_string()); 109 | } 110 | format_text = original.clone(); 111 | for (key, value) in params.clone().into_iter() { 112 | format_text = format_text.replace(&format!("{{{}}}", key), &value); 113 | } 114 | label.set_text(&format_text); 115 | } 116 | glib::ControlFlow::Continue 117 | }); 118 | } -------------------------------------------------------------------------------- /src/modules/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod battery; 2 | pub mod brightness; 3 | pub mod hyprland; 4 | pub mod tray; 5 | -------------------------------------------------------------------------------- /src/modules/tray.rs: -------------------------------------------------------------------------------- 1 | use gtk::glib; 2 | use gtk::prelude::*; 3 | use gtk::{IconLookupFlags, IconTheme, Image, Menu, MenuBar, MenuItem, SeparatorMenuItem}; 4 | use once_cell::sync::Lazy; 5 | use std::collections::HashMap; 6 | use std::sync::Mutex; 7 | use std::thread; 8 | use stray::message::menu::{MenuType, TrayMenu}; 9 | use stray::message::tray::{IconPixmap, StatusNotifierItem}; 10 | use stray::message::{NotifierItemCommand, NotifierItemMessage}; 11 | use stray::StatusNotifierWatcher; 12 | use tokio::runtime::Runtime; 13 | use tokio::sync::mpsc; 14 | 15 | use crate::builder::widgets_builder; 16 | use crate::builder::widgets_builder::Align; 17 | use crate::builder::widgets_builder::WidgetConfig; 18 | 19 | struct NotifierItem { 20 | item: StatusNotifierItem, 21 | menu: Option, 22 | } 23 | 24 | pub struct StatusNotifierWrapper { 25 | menu: stray::message::menu::MenuItem, 26 | } 27 | 28 | static STATE: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); 29 | 30 | impl StatusNotifierWrapper { 31 | fn into_menu_item( 32 | self, 33 | sender: mpsc::Sender, 34 | notifier_address: String, 35 | menu_path: String, 36 | ) -> MenuItem { 37 | let item: Box> = match self.menu.menu_type { 38 | MenuType::Separator => Box::new(SeparatorMenuItem::new()), 39 | MenuType::Standard => Box::new(MenuItem::with_label(self.menu.label.as_str())), 40 | }; 41 | 42 | let item = (*item).as_ref().clone(); 43 | 44 | { 45 | let sender = sender.clone(); 46 | let notifier_address = notifier_address.clone(); 47 | let menu_path = menu_path.clone(); 48 | 49 | item.connect_activate(move |_item| { 50 | sender 51 | .try_send(NotifierItemCommand::MenuItemClicked { 52 | submenu_id: self.menu.id, 53 | menu_path: menu_path.clone(), 54 | notifier_address: notifier_address.clone(), 55 | }) 56 | .unwrap(); 57 | }); 58 | }; 59 | 60 | let submenu = Menu::new(); 61 | if !self.menu.submenu.is_empty() { 62 | for submenu_item in self.menu.submenu.iter().cloned() { 63 | let submenu_item = StatusNotifierWrapper { menu: submenu_item }; 64 | let submenu_item = submenu_item.into_menu_item( 65 | sender.clone(), 66 | notifier_address.clone(), 67 | menu_path.clone(), 68 | ); 69 | submenu.append(&submenu_item); 70 | } 71 | 72 | item.set_submenu(Some(&submenu)); 73 | } 74 | 75 | item 76 | } 77 | } 78 | 79 | impl NotifierItem { 80 | fn get_icon(&self) -> Option { 81 | match &self.item.icon_pixmap { 82 | None => self.get_icon_from_theme(), 83 | Some(pixmaps) => self.get_icon_from_pixmaps(pixmaps), 84 | } 85 | } 86 | 87 | fn get_icon_from_pixmaps(&self, pixmaps: &[IconPixmap]) -> Option { 88 | let pixmap = pixmaps 89 | .iter() 90 | .find(|pm| pm.height > 20 && pm.height < 32) 91 | .expect("No icon of suitable size found"); 92 | 93 | let pixbuf = gtk::gdk_pixbuf::Pixbuf::new( 94 | gtk::gdk_pixbuf::Colorspace::Rgb, 95 | true, 96 | 8, 97 | pixmap.width, 98 | pixmap.height, 99 | ) 100 | .expect("Failed to allocate pixbuf"); 101 | 102 | for y in 0..pixmap.height { 103 | for x in 0..pixmap.width { 104 | let index = (y * pixmap.width + x) * 4; 105 | let a = pixmap.pixels[index as usize]; 106 | let r = pixmap.pixels[(index + 1) as usize]; 107 | let g = pixmap.pixels[(index + 2) as usize]; 108 | let b = pixmap.pixels[(index + 3) as usize]; 109 | pixbuf.put_pixel(x as u32, y as u32, r, g, b, a); 110 | } 111 | } 112 | 113 | Some(Image::from_pixbuf(Some(&pixbuf))) 114 | } 115 | 116 | fn get_icon_from_theme(&self) -> Option { 117 | let theme = gtk::IconTheme::default().unwrap_or(IconTheme::new()); 118 | theme.rescan_if_needed(); 119 | 120 | if let Some(path) = self.item.icon_theme_path.as_ref() { 121 | theme.append_search_path(path); 122 | } 123 | 124 | let icon_name = self.item.icon_name.as_ref().unwrap(); 125 | let icon = theme.lookup_icon(icon_name, 24, IconLookupFlags::GENERIC_FALLBACK); 126 | 127 | icon.map(|i| Image::from_pixbuf(i.load_icon().ok().as_ref())) 128 | } 129 | } 130 | 131 | pub fn build_label(left: >k::Box, center: >k::Box, right: >k::Box, config: WidgetConfig) { 132 | // println!("{}", text); 133 | // println!("Ia m here2"); 134 | let menu_bar = MenuBar::new(); 135 | menu_bar.style_context().add_class(&config.name_of_widget); 136 | match config.align { 137 | Align::CENTER => center.add(&menu_bar), 138 | Align::LEFT => left.add(&menu_bar), 139 | Align::RIGHT => right.add(&menu_bar), 140 | } 141 | // println!("Ia m here"); 142 | update_widget(menu_bar); 143 | } 144 | 145 | fn update_widget(menu_bar: MenuBar) { 146 | let (sender, receiver) = mpsc::channel(32); 147 | let (cmd_tx, cmd_rx) = mpsc::channel(32); 148 | 149 | spawn_local_handler(menu_bar, receiver, cmd_tx); 150 | start_communication_thread(sender, cmd_rx); 151 | } 152 | 153 | fn spawn_local_handler( 154 | v_box: MenuBar, 155 | mut receiver: mpsc::Receiver, 156 | cmd_tx: mpsc::Sender, 157 | ) { 158 | let main_context = glib::MainContext::default(); 159 | let future = async move { 160 | while let Some(item) = receiver.recv().await { 161 | let mut state = STATE.lock().unwrap(); 162 | 163 | match item { 164 | NotifierItemMessage::Update { 165 | address: id, 166 | item, 167 | menu, 168 | } => { 169 | state.insert(id, NotifierItem { item: *item, menu }); 170 | } 171 | NotifierItemMessage::Remove { address } => { 172 | state.remove(&address); 173 | } 174 | } 175 | 176 | for child in v_box.children() { 177 | v_box.remove(&child); 178 | } 179 | 180 | for (address, notifier_item) in state.iter() { 181 | if let Some(icon) = notifier_item.get_icon() { 182 | // Create the menu 183 | 184 | let menu_item = MenuItem::new(); 185 | let menu_item_box = gtk::Box::default(); 186 | menu_item_box.add(&icon); 187 | menu_item.add(&menu_item_box); 188 | 189 | if let Some(tray_menu) = ¬ifier_item.menu { 190 | let menu = Menu::new(); 191 | tray_menu 192 | .submenus 193 | .iter() 194 | .map(|submenu| StatusNotifierWrapper { 195 | menu: submenu.to_owned(), 196 | }) 197 | .map(|item| { 198 | let menu_path = 199 | notifier_item.item.menu.as_ref().unwrap().to_string(); 200 | let address = address.to_string(); 201 | item.into_menu_item(cmd_tx.clone(), address, menu_path) 202 | }) 203 | .for_each(|item| menu.append(&item)); 204 | 205 | if !tray_menu.submenus.is_empty() { 206 | menu_item.set_submenu(Some(&menu)); 207 | } 208 | } 209 | v_box.append(&menu_item); 210 | }; 211 | 212 | v_box.show_all(); 213 | } 214 | } 215 | }; 216 | 217 | main_context.spawn_local(future); 218 | } 219 | 220 | fn start_communication_thread( 221 | sender: mpsc::Sender, 222 | cmd_rx: mpsc::Receiver, 223 | ) { 224 | thread::spawn(move || { 225 | let runtime = Runtime::new().expect("Failed to create tokio RT"); 226 | 227 | runtime.block_on(async { 228 | let tray = StatusNotifierWatcher::new(cmd_rx).await.unwrap(); 229 | let mut host = tray.create_notifier_host("MyHost").await.unwrap(); 230 | 231 | while let Ok(message) = host.recv().await { 232 | sender 233 | .send(message) 234 | .await 235 | .expect("failed to send message to UI"); 236 | } 237 | 238 | host.destroy().await.unwrap(); 239 | }) 240 | }); 241 | } 242 | -------------------------------------------------------------------------------- /src/network/hyprland_socket.rs: -------------------------------------------------------------------------------- 1 | use tokio::io::{AsyncBufReadExt, BufReader}; 2 | async fn connect_socket() -> Option { 3 | let uuid = std::env::var("HYPRLAND_INSTANCE_SIGNATURE"); 4 | if let Err(err) = uuid { 5 | println!("{}", err); 6 | return None; 7 | } 8 | let path = format!("/tmp/hypr/{}/.socket2.sock", uuid.unwrap()); 9 | let stream = tokio::net::UnixStream::connect(path).await; 10 | if let Err(err) = stream { 11 | println!("{}", err); 12 | return None; 13 | } //if 14 | 15 | return Some(stream.unwrap()); 16 | } 17 | 18 | pub async fn listen(sender: glib::Sender<(String, String)>) { 19 | let stream = if let Some(stream) = connect_socket().await { 20 | stream 21 | } else { 22 | return; 23 | }; 24 | 25 | let mut buffer = String::new(); 26 | let mut reader = BufReader::new(stream); 27 | loop { 28 | while reader.read_line(&mut buffer).await.unwrap_or_default() > 0 { 29 | if let Some((action_name, action_value)) = buffer.split_once(">>") { 30 | sender 31 | .send((action_name.to_string(), action_value.to_string())) 32 | .unwrap_or_default(); 33 | } 34 | buffer.clear(); 35 | } //while 36 | } //loop 37 | } //func 38 | -------------------------------------------------------------------------------- /src/network/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod hyprland_socket; 2 | -------------------------------------------------------------------------------- /src/utils/command.rs: -------------------------------------------------------------------------------- 1 | // 2 | pub fn run(command: &String) -> String { 3 | match std::process::Command::new("zsh") 4 | .arg("-c") 5 | .arg(command) 6 | .output() 7 | { 8 | Ok(output) => { 9 | return String::from_utf8(output.stdout).unwrap(); 10 | } 11 | 12 | Err(err) => return err.to_string(), 13 | } //match 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/constants.rs: -------------------------------------------------------------------------------- 1 | pub const CONFIG_PATH: &str = "/.config/heliumbar/helium.json"; 2 | pub const CONFIG_STYLE: &str = "/.config/heliumbar/style.css"; 3 | pub const BATTERY_PATH: &str = "/sys/class/power_supply/"; 4 | pub const BRIGHTNESS_PATH: &str = "/sys/class/backlight/"; 5 | -------------------------------------------------------------------------------- /src/utils/file_handler.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io::{Read, Seek}; 3 | pub fn read_file_for_monitor(file: &mut File, buffer: &mut [u8]) -> String { 4 | match file.seek(std::io::SeekFrom::Start(0)) { 5 | Ok(_) => (), 6 | Err(err) => { 7 | println!("{}", err); 8 | return "".to_string(); 9 | } 10 | } //match 11 | 12 | let stat = file.read(buffer); 13 | if let Err(err) = stat { 14 | println!("{}", err); 15 | return "".to_string(); 16 | } 17 | let stat = stat.unwrap(); 18 | if stat > 0 { 19 | String::from_utf8_lossy(&buffer[0..stat]).trim().to_string() 20 | } else { 21 | "".to_string() 22 | } 23 | } 24 | 25 | pub fn get_particular_dir_path(path: String, file_to_search: String) -> Option { 26 | let file_dirs = std::fs::read_dir(path); 27 | if let Err(err) = file_dirs { 28 | println!("{}", err); 29 | return None; 30 | } 31 | for each in file_dirs.unwrap() { 32 | if let Ok(dir) = each { 33 | let dir = dir.path(); 34 | if dir.is_dir() { 35 | let found_dir = dir.to_str(); 36 | if let Some(found_dir) = found_dir { 37 | let final_path = format!("{}/{}", found_dir, file_to_search); 38 | if std::path::Path::new(&final_path).exists() { 39 | return Some(found_dir.to_string()); 40 | } //if path 41 | } //findal 42 | } //if dir 43 | } //found some files 44 | } 45 | 46 | return None; 47 | } 48 | -------------------------------------------------------------------------------- /src/utils/listener.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::regex_matcher; 2 | use gtk::prelude::*; 3 | use std::collections::HashMap; 4 | 5 | // 6 | 7 | pub fn listen(receiver: glib::Receiver<(String, String)>, original: String, label: gtk::Label) { 8 | let mut params = regex_matcher::get_params(&original); 9 | let mut format_text: String = String::new(); 10 | //reciver is here 11 | receiver.attach(None, move |(name, value)| { 12 | // println!("{}", name); 13 | 14 | if params.contains_key(&name) { 15 | // 16 | params.insert(name.trim().to_string(), value.trim().to_string()); 17 | format_text = original.clone(); 18 | for (key, value) in params.clone().into_iter() { 19 | format_text = format_text.replace(&format!("{{{}}}", key), &value); 20 | } 21 | label.set_text(&format_text); 22 | } 23 | glib::ControlFlow::Continue 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod command; 2 | pub mod constants; 3 | pub mod file_handler; 4 | pub mod listener; 5 | pub mod regex_matcher; 6 | -------------------------------------------------------------------------------- /src/utils/regex_matcher.rs: -------------------------------------------------------------------------------- 1 | use json::{self, JsonValue}; 2 | use std::collections::HashMap; 3 | pub fn format(string: &str, json_data: &str) -> Option { 4 | let json_parse = json::parse(&json_data); 5 | if let Err(err) = json_parse { 6 | println!("{}", err); 7 | return None; 8 | } 9 | let json_parse = json_parse.unwrap(); 10 | 11 | let mut is_in_block = false; 12 | let mut word: String = String::new(); 13 | let mut out = string.to_string(); 14 | let mut temp; 15 | 16 | for c in string.chars() { 17 | if c == '{' { 18 | is_in_block = true; 19 | continue; 20 | } //if {} 21 | 22 | if is_in_block { 23 | if c != '}' { 24 | word.push(c); 25 | } else { 26 | is_in_block = false; 27 | let data: Vec<&str> = word.split(".").collect(); 28 | if data.len() == 1 { 29 | temp = json_parse[&word].to_string(); 30 | } else if data.len() == 2 { 31 | temp = json_parse[data[0]][data[1]].to_string(); 32 | } else { 33 | temp = json_parse[data[0]][data[1]][data[2]].to_string(); 34 | } 35 | out = out.replace(&format!("{{{}}}", word), &temp); 36 | word.clear(); 37 | } 38 | } 39 | } //for loop 40 | 41 | return Some(out); 42 | // for capture in re.captures_iter(string) { 43 | // println!("{}", capture.get(1).unwrap().as_str()); 44 | // } 45 | } 46 | 47 | pub fn get_params(string: &String) -> HashMap { 48 | let mut is_in_block = false; 49 | let mut word: String = String::new(); 50 | // let mut array = Vec::::new(); 51 | let mut params: HashMap = HashMap::new(); 52 | 53 | for c in string.chars() { 54 | if c == '{' { 55 | is_in_block = true; 56 | continue; 57 | } //if {} 58 | if is_in_block { 59 | if c != '}' { 60 | word.push(c); 61 | } else { 62 | is_in_block = false; 63 | // println!("{}", word); 64 | params.insert(word.clone(), "".to_string()); 65 | // array.push(word.clone()); 66 | word.clear(); 67 | } 68 | } 69 | } //for loop 70 | 71 | return params; 72 | } // 73 | -------------------------------------------------------------------------------- /src/widgets/LabelWidget.rs: -------------------------------------------------------------------------------- 1 | use gtk::traits::ContainerExt; 2 | 3 | use crate::builder::widgets_builder::{self, Align, WidgetConfig}; 4 | use crate::utils::{command, regex_matcher}; 5 | use glib::MainContext; 6 | use gtk::prelude::*; 7 | use std::io::BufRead; 8 | use std::io::BufReader; 9 | use std::process::{Command, Stdio}; 10 | pub fn build_label(left: >k::Box, center: >k::Box, right: >k::Box, config: WidgetConfig) { 11 | let original: String = config.format.clone(); 12 | let mut text = original.clone(); 13 | if config.command.len() > 0 && config.refresh_rate == 0 { 14 | let out = command::run(&config.command).trim().to_string(); 15 | if config.is_json { 16 | if let Some(data) = regex_matcher::format(&original, &out) { 17 | text = data; 18 | } 19 | } 20 | //if json 21 | else { 22 | text = original.replace("{}", &out); 23 | } 24 | } //if command 25 | 26 | let label = widgets_builder::build_and_align(&text, &left, ¢er, &right, &config); 27 | 28 | if config.refresh_rate > 0 && config.command.len() > 0 { 29 | update_widget( 30 | label, 31 | original, 32 | config.is_json, 33 | config.refresh_rate, 34 | &config.command, 35 | ); 36 | } 37 | // println!("lenght of command{}", config.command.len()); 38 | } 39 | 40 | pub fn update_widget( 41 | label: gtk::Label, 42 | original: String, 43 | is_json: bool, 44 | refresh_rate: i64, 45 | command: &str, 46 | ) { 47 | let child = Command::new("zsh") 48 | .arg("-c") 49 | .arg(&format!( 50 | "while true; do;{};sleep {};done", 51 | command, refresh_rate 52 | )) 53 | .stdout(Stdio::piped()) 54 | .spawn(); 55 | 56 | if let Err(error) = child { 57 | println!("{}", error); 58 | return; 59 | } 60 | let stdout = child.unwrap().stdout.take().unwrap(); 61 | let (sender, receiver) = MainContext::channel::(glib::Priority::DEFAULT); 62 | 63 | std::thread::spawn(move || { 64 | let reader = BufReader::new(stdout); 65 | for line in reader.lines() { 66 | if let Ok(data) = line { 67 | sender.send(data.to_string()).unwrap(); 68 | } //if 69 | } 70 | }); 71 | 72 | receiver.attach(None, move |data| { 73 | // println!("receiver found here{}", data); 74 | if is_json { 75 | if let Some(out) = regex_matcher::format(&original, &data) { 76 | label.set_text(&out); 77 | } 78 | } else { 79 | label.set_text(&original.replace("{}", &data.trim().to_string())); 80 | } 81 | glib::ControlFlow::Continue 82 | }); 83 | } 84 | -------------------------------------------------------------------------------- /src/widgets/mod.rs: -------------------------------------------------------------------------------- 1 | #[allow(non_snake_case)] 2 | #[allow(non_snake_case)] 3 | pub mod LabelWidget; 4 | -------------------------------------------------------------------------------- /stray/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "stray" 3 | version = "0.1.3" 4 | edition = "2021" 5 | license = "MIT" 6 | description = "A freedesktop StatusNotifierWatcher implementation" 7 | repository = "https://github.com/oknozor/stray" 8 | readme = "README.md" 9 | 10 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 11 | 12 | [dependencies] 13 | tokio = { version = "1.17.0", features = ["rt-multi-thread", "macros"] } 14 | tokio-stream = "0.1.8" 15 | zbus = { version = "3.13.1", default-features = false, features = ["tokio", "gvariant"] } 16 | anyhow = "1.0.56" 17 | serde = "1.0.136" 18 | byteorder = "1.4.3" 19 | chrono = "0.4.19" 20 | log = "0.4.17" 21 | thiserror = "1.0.31" 22 | tracing = "0.1" 23 | 24 | [[example]] 25 | path = "examples/simple.rs" 26 | name = "simple" 27 | -------------------------------------------------------------------------------- /stray/README.md: -------------------------------------------------------------------------------- 1 | # Stray 2 | 3 | Stray is a minimal [SystemNotifierWatcher](https://www.freedesktop.org/wiki/Specifications/StatusNotifierItem/StatusNotifierWatcher/) 4 | implementation which goal is to provide a minimalistic API to access tray icons and menu. 5 | 6 | ## Examples 7 | 8 | ### Start the system tray and listen for changes 9 | ```rust, ignore 10 | use stray::{SystemTray}; 11 | use tokio_stream::StreamExt; 12 | use stray::message::NotifierItemMessage; 13 | use stray::message::NotifierItemCommand; 14 | 15 | #[tokio::main] 16 | async fn main() { 17 | 18 | // A mpsc channel to send menu activation requests later 19 | let (ui_tx, ui_rx) = tokio::sync::mpsc::channel(32); 20 | let mut tray = SystemTray::new(ui_rx).await; 21 | 22 | while let Some(message) = tray.next().await { 23 | match message { 24 | NotifierItemMessage::Update { address: id, item, menu } => { 25 | println!("NotifierItem updated : 26 | id = {id}, 27 | item = {item:?}, 28 | menu = {menu:?}" 29 | ) 30 | } 31 | NotifierItemMessage::Remove { address: id } => { 32 | println!("NotifierItem removed : id = {id}"); 33 | } 34 | } 35 | } 36 | } 37 | ``` 38 | 39 | ### Send menu activation request to the system tray 40 | 41 | ```rust, ignore 42 | // Assuming we stored our menu items in some UI state we can send menu item activation request: 43 | use stray::message::NotifierItemCommand; 44 | 45 | ui_tx.clone().try_send(NotifierItemCommand::MenuItemClicked { 46 | // The submenu to activate 47 | submenu_id: 32, 48 | // dbus menu path, available in the `StatusNotifierItem` 49 | menu_path: "/org/ayatana/NotificationItem/Element1/Menu".to_string(), 50 | // the notifier address we previously got from `NotifierItemMessage::Update` 51 | notifier_address: ":1.2161".to_string(), 52 | }).unwrap(); 53 | ``` 54 | 55 | ### Gtk example 56 | 57 | For a detailed, real life example, you can take a look at the [gtk-tray](https://github.com/oknozor/stray/tree/main/gtk-tray). 58 | 59 | ```shell 60 | git clone git@github.com:oknozor/stray.git 61 | cd stray/gtk-tray 62 | cargo run 63 | ``` 64 | -------------------------------------------------------------------------------- /stray/examples/simple.rs: -------------------------------------------------------------------------------- 1 | use stray::StatusNotifierWatcher; 2 | use tokio::join; 3 | use tokio::sync::mpsc; 4 | 5 | #[tokio::main] 6 | async fn main() -> stray::error::Result<()> { 7 | let (_cmd_tx, cmd_rx) = mpsc::channel(10); 8 | let tray = StatusNotifierWatcher::new(cmd_rx).await?; 9 | 10 | let mut host_one = tray.create_notifier_host("host_one").await.unwrap(); 11 | let mut host_two = tray.create_notifier_host("host_two").await.unwrap(); 12 | 13 | let one = tokio::spawn(async move { 14 | while let Ok(mesage) = host_one.recv().await { 15 | println!("Message from host one {:?}", mesage); 16 | } 17 | }); 18 | 19 | let two = tokio::spawn(async move { 20 | let mut count = 0; 21 | while let Ok(mesage) = host_two.recv().await { 22 | count += 1; 23 | if count > 5 { 24 | break; 25 | } 26 | println!("Message from host two {:?}", mesage); 27 | } 28 | 29 | host_two.destroy().await?; 30 | stray::error::Result::<()>::Ok(()) 31 | }); 32 | 33 | let _ = join!(one, two); 34 | Ok(()) 35 | } 36 | -------------------------------------------------------------------------------- /stray/src/dbus/dbusmenu_proxy.rs: -------------------------------------------------------------------------------- 1 | //! # DBus interface proxy for: `com.canonical.dbusmenu` 2 | //! 3 | //! This code was generated by `zbus-xmlgen` `2.0.1` from DBus introspection data. 4 | //! Source: `org.cannonical.indicator.xml`. 5 | //! 6 | //! You may prefer to adapt it, instead of using it verbatim. 7 | //! 8 | //! More information can be found in the 9 | //! [Writing a client proxy](https://dbus.pages.freedesktop.org/zbus/client.html) 10 | //! section of the zbus documentation. 11 | //! 12 | 13 | use std::collections::HashMap; 14 | 15 | use zbus::dbus_proxy; 16 | use zbus::zvariant::OwnedValue; 17 | 18 | use serde::{Deserialize, Serialize}; 19 | use zbus::zvariant::Type; 20 | 21 | #[derive(Deserialize, Serialize, Type, PartialEq, Debug)] 22 | pub struct MenuLayout { 23 | pub id: u32, 24 | pub fields: SubMenuLayout, 25 | } 26 | 27 | #[derive(Deserialize, Serialize, Type, PartialEq, Debug)] 28 | pub struct SubMenuLayout { 29 | pub id: i32, 30 | pub fields: HashMap, 31 | pub submenus: Vec, 32 | } 33 | 34 | #[allow(dead_code)] 35 | type GroupProperties = Vec<(i32, HashMap)>; 36 | 37 | #[dbus_proxy(interface = "com.canonical.dbusmenu", assume_defaults = true)] 38 | trait DBusMenu { 39 | fn about_to_show(&self, id: i32) -> zbus::Result; 40 | 41 | fn event( 42 | &self, 43 | id: i32, 44 | event_id: &str, 45 | data: &zbus::zvariant::Value<'_>, 46 | timestamp: u32, 47 | ) -> zbus::Result<()>; 48 | 49 | fn get_group_properties( 50 | &self, 51 | ids: &[i32], 52 | property_names: &[&str], 53 | ) -> zbus::Result<(u32, GroupProperties)>; 54 | 55 | fn get_layout( 56 | &self, 57 | parent_id: i32, 58 | recursion_depth: i32, 59 | property_names: &[&str], 60 | ) -> zbus::Result; 61 | 62 | fn get_property(&self, id: i32, name: &str) -> zbus::Result; 63 | 64 | #[dbus_proxy(signal)] 65 | fn item_activation_requested(&self, id: i32, timestamp: u32) -> zbus::Result<()>; 66 | 67 | #[dbus_proxy(signal)] 68 | fn items_properties_updated( 69 | &self, 70 | updated_props: Vec<(i32, HashMap<&str, zbus::zvariant::Value<'_>>)>, 71 | removed_props: Vec<(i32, Vec<&str>)>, 72 | ) -> zbus::Result<()>; 73 | 74 | #[dbus_proxy(signal)] 75 | fn layout_updated(&self, revision: u32, parent: i32) -> zbus::Result<()>; 76 | 77 | #[dbus_proxy(property)] 78 | fn status(&self) -> zbus::Result; 79 | 80 | #[dbus_proxy(property)] 81 | fn version(&self) -> zbus::Result; 82 | } 83 | -------------------------------------------------------------------------------- /stray/src/dbus/mod.rs: -------------------------------------------------------------------------------- 1 | pub(super) mod dbusmenu_proxy; 2 | pub(super) mod notifier_item_proxy; 3 | pub(super) mod notifier_watcher_proxy; 4 | pub(super) mod notifier_watcher_service; 5 | -------------------------------------------------------------------------------- /stray/src/dbus/notifier_item_proxy.rs: -------------------------------------------------------------------------------- 1 | //! # DBus interface proxy for: `org.kde.StatusNotifierItem` 2 | //! 3 | //! This code was generated by `zbus-xmlgen` `2.0.1` from DBus introspection data. 4 | //! Source: `status-notifier-item.xml`. 5 | //! 6 | //! You may prefer to adapt it, instead of using it verbatim. 7 | //! 8 | //! More information can be found in the 9 | //! [Writing a client proxy](https://dbus.pages.freedesktop.org/zbus/client.html) 10 | //! section of the zbus documentation. 11 | //! 12 | 13 | use zbus::dbus_proxy; 14 | 15 | #[allow(dead_code)] 16 | type ToolTip = (String, Vec<(i32, i32, Vec)>); 17 | 18 | #[dbus_proxy(interface = "org.kde.StatusNotifierItem", assume_defaults = true)] 19 | trait StatusNotifierItem { 20 | /// Activate method 21 | fn activate(&self, x: i32, y: i32) -> zbus::Result<()>; 22 | 23 | /// ContextMenu method 24 | fn context_menu(&self, x: i32, y: i32) -> zbus::Result<()>; 25 | 26 | /// Scroll method 27 | fn scroll(&self, delta: i32, orientation: &str) -> zbus::Result<()>; 28 | 29 | /// SecondaryActivate method 30 | fn secondary_activate(&self, x: i32, y: i32) -> zbus::Result<()>; 31 | 32 | /// NewAttentionIcon signal 33 | #[dbus_proxy(signal)] 34 | fn new_attention_icon(&self) -> zbus::Result<()>; 35 | 36 | /// NewIcon signal 37 | #[dbus_proxy(signal)] 38 | fn new_icon(&self) -> zbus::Result<()>; 39 | 40 | /// NewOverlayIcon signal 41 | #[dbus_proxy(signal)] 42 | fn new_overlay_icon(&self) -> zbus::Result<()>; 43 | 44 | /// NewStatus signal 45 | #[dbus_proxy(signal)] 46 | fn new_status(&self, status: &str) -> zbus::Result<()>; 47 | 48 | /// NewTitle signal 49 | #[dbus_proxy(signal)] 50 | fn new_title(&self) -> zbus::Result<()>; 51 | 52 | /// NewToolTip signal 53 | #[dbus_proxy(signal)] 54 | fn new_tool_tip(&self) -> zbus::Result<()>; 55 | 56 | /// AttentionIconName property 57 | #[dbus_proxy(property)] 58 | fn attention_icon_name(&self) -> zbus::Result; 59 | 60 | /// AttentionIconPixmap property 61 | #[dbus_proxy(property)] 62 | fn attention_icon_pixmap(&self) -> zbus::Result)>>; 63 | 64 | /// AttentionMovieName property 65 | #[dbus_proxy(property)] 66 | fn attention_movie_name(&self) -> zbus::Result; 67 | 68 | /// Category property 69 | #[dbus_proxy(property)] 70 | fn category(&self) -> zbus::Result; 71 | 72 | /// IconName property 73 | #[dbus_proxy(property)] 74 | fn icon_name(&self) -> zbus::Result; 75 | 76 | /// IconPixmap property 77 | #[dbus_proxy(property)] 78 | fn icon_pixmap(&self) -> zbus::Result)>>; 79 | 80 | /// IconThemePath property 81 | #[dbus_proxy(property)] 82 | fn icon_theme_path(&self) -> zbus::Result; 83 | 84 | /// Id property 85 | #[dbus_proxy(property)] 86 | fn id(&self) -> zbus::Result; 87 | 88 | /// ItemIsMenu property 89 | #[dbus_proxy(property)] 90 | fn item_is_menu(&self) -> zbus::Result; 91 | 92 | /// Menu property 93 | #[dbus_proxy(property)] 94 | fn menu(&self) -> zbus::Result; 95 | 96 | /// OverlayIconName property 97 | #[dbus_proxy(property)] 98 | fn overlay_icon_name(&self) -> zbus::Result; 99 | 100 | /// OverlayIconPixmap property 101 | #[dbus_proxy(property)] 102 | fn overlay_icon_pixmap(&self) -> zbus::Result)>>; 103 | 104 | /// Status property 105 | #[dbus_proxy(property)] 106 | fn status(&self) -> zbus::Result; 107 | 108 | /// Title property 109 | #[dbus_proxy(property)] 110 | fn title(&self) -> zbus::Result; 111 | 112 | /// ToolTip property 113 | #[dbus_proxy(property)] 114 | fn tool_tip(&self) -> zbus::Result; 115 | } 116 | -------------------------------------------------------------------------------- /stray/src/dbus/notifier_watcher_proxy.rs: -------------------------------------------------------------------------------- 1 | //! # DBus interface proxy for: `org.kde.StatusNotifierWatcher` 2 | //! 3 | //! This code was generated by `zbus-xmlgen` `2.0.1` from DBus introspection data. 4 | //! Source: `notfifier-watcher.xml`. 5 | //! 6 | //! You may prefer to adapt it, instead of using it verbatim. 7 | //! 8 | //! More information can be found in the 9 | //! [Writing a client proxy](https://dbus.pages.freedesktop.org/zbus/client.html) 10 | //! section of the zbus documentation. 11 | //! 12 | 13 | use zbus::dbus_proxy; 14 | 15 | #[dbus_proxy( 16 | interface = "org.kde.StatusNotifierWatcher", 17 | default_path = "/StatusNotifierWatcher" 18 | )] 19 | pub(crate) trait StatusNotifierWatcher { 20 | fn register_status_notifier_host(&self, service: &str) -> zbus::Result<()>; 21 | 22 | fn unregister_status_notifier_item(&self, service: &str) -> zbus::Result<()>; 23 | 24 | fn register_status_notifier_item(&self, service: &str) -> zbus::Result<()>; 25 | 26 | #[dbus_proxy(signal)] 27 | fn status_notifier_host_registered(&self) -> zbus::Result<()>; 28 | 29 | #[dbus_proxy(signal)] 30 | fn status_notifier_host_unregistered(&self) -> zbus::Result<()>; 31 | 32 | #[dbus_proxy(signal)] 33 | fn status_notifier_item_registered(&self, service: &str) -> zbus::Result<()>; 34 | 35 | #[dbus_proxy(signal)] 36 | fn status_notifier_item_unregistered(&self, service: &str) -> zbus::Result<()>; 37 | 38 | #[dbus_proxy(property)] 39 | fn is_status_notifier_host_registered(&self) -> zbus::Result; 40 | 41 | #[dbus_proxy(property)] 42 | fn protocol_version(&self) -> zbus::Result; 43 | 44 | #[dbus_proxy(property)] 45 | fn registered_status_notifier_items(&self) -> zbus::Result>; 46 | } 47 | -------------------------------------------------------------------------------- /stray/src/dbus/notifier_watcher_service.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | use tokio::sync::broadcast; 3 | 4 | use zbus::dbus_interface; 5 | use zbus::Result; 6 | use zbus::{MessageHeader, SignalContext}; 7 | 8 | use crate::NotifierItemMessage; 9 | 10 | pub struct DbusNotifierWatcher { 11 | pub status_notifier_hosts: HashSet, 12 | pub registered_status_notifier_items: HashSet, 13 | pub protocol_version: i32, 14 | pub is_status_notifier_host_registered: bool, 15 | pub sender: broadcast::Sender, 16 | } 17 | 18 | impl DbusNotifierWatcher { 19 | pub(crate) fn new(sender: broadcast::Sender) -> Self { 20 | DbusNotifierWatcher { 21 | registered_status_notifier_items: HashSet::new(), 22 | protocol_version: 0, 23 | is_status_notifier_host_registered: false, 24 | status_notifier_hosts: HashSet::new(), 25 | sender, 26 | } 27 | } 28 | } 29 | 30 | impl DbusNotifierWatcher { 31 | pub async fn remove_notifier(&mut self, notifier_address: &str) -> Result<()> { 32 | let to_remove = self 33 | .registered_status_notifier_items 34 | .iter() 35 | .find(|item| item.contains(notifier_address)) 36 | .cloned(); 37 | 38 | if let Some(notifier) = to_remove { 39 | let removed = self.registered_status_notifier_items.remove(¬ifier); 40 | if removed { 41 | self.sender 42 | .send(NotifierItemMessage::Remove { 43 | address: notifier_address.to_string(), 44 | }) 45 | .expect("Failed to dispatch notifier item removed message"); 46 | } 47 | } 48 | 49 | Ok(()) 50 | } 51 | } 52 | 53 | #[allow(dead_code)] 54 | #[dbus_interface(name = "org.kde.StatusNotifierWatcher")] 55 | impl DbusNotifierWatcher { 56 | async fn register_status_notifier_host( 57 | &mut self, 58 | service: &str, 59 | #[zbus(signal_context)] ctxt: SignalContext<'_>, 60 | ) { 61 | tracing::info!("StatusNotifierHost registered: '{}'", service); 62 | self.status_notifier_hosts.insert(service.to_string()); 63 | self.is_status_notifier_host_registered = true; 64 | self.is_status_notifier_host_registered_changed(&ctxt) 65 | .await 66 | .unwrap(); 67 | } 68 | 69 | async fn register_status_notifier_item( 70 | &mut self, 71 | service: &str, 72 | #[zbus(header)] header: MessageHeader<'_>, 73 | #[zbus(signal_context)] ctxt: SignalContext<'_>, 74 | ) { 75 | let address = header 76 | .sender() 77 | .expect("Failed to get message sender in header") 78 | .map(|name| name.to_string()) 79 | .expect("Failed to get unique name for notifier"); 80 | 81 | let notifier_item = format!("{}{}", address, service); 82 | 83 | self.registered_status_notifier_items 84 | .insert(notifier_item.clone()); 85 | 86 | tracing::info!("StatusNotifierItem registered: '{}'", notifier_item); 87 | 88 | Self::status_notifier_item_registered(&ctxt, ¬ifier_item) 89 | .await 90 | .unwrap(); 91 | } 92 | 93 | async fn unregister_status_notifier_item(&mut self, service: &str) { 94 | self.remove_notifier(service) 95 | .await 96 | .expect("Failed to unregister StatusNotifierItem") 97 | } 98 | 99 | #[dbus_interface(signal)] 100 | async fn status_notifier_host_registered(ctxt: &SignalContext<'_>) -> Result<()>; 101 | 102 | #[dbus_interface(signal)] 103 | async fn status_notifier_host_unregistered(ctxt: &SignalContext<'_>) -> Result<()>; 104 | 105 | #[dbus_interface(signal)] 106 | async fn status_notifier_item_registered(ctxt: &SignalContext<'_>, service: &str) 107 | -> Result<()>; 108 | 109 | #[dbus_interface(signal)] 110 | async fn status_notifier_item_unregistered( 111 | ctxt: &SignalContext<'_>, 112 | service: &str, 113 | ) -> Result<()>; 114 | 115 | #[dbus_interface(property)] 116 | async fn is_status_notifier_host_registered(&self) -> bool { 117 | self.is_status_notifier_host_registered 118 | } 119 | 120 | #[dbus_interface(property)] 121 | async fn protocol_version(&self) -> i32 { 122 | self.protocol_version 123 | } 124 | 125 | #[dbus_interface(property)] 126 | fn registered_status_notifier_items(&self) -> Vec { 127 | self.registered_status_notifier_items 128 | .iter() 129 | .cloned() 130 | .collect() 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /stray/src/error.rs: -------------------------------------------------------------------------------- 1 | use crate::NotifierItemMessage; 2 | use thiserror::Error; 3 | use tokio::sync::broadcast; 4 | 5 | pub type Result = std::result::Result; 6 | 7 | #[derive(Error, Debug)] 8 | pub enum StatusNotifierWatcherError { 9 | #[error("Dbus connection error")] 10 | DbusError(#[from] zbus::Error), 11 | #[error("Invalid DBus interface name")] 12 | InterfaceNameError(#[from] zbus::names::Error), 13 | #[error("Failed to call DBus standard interface method")] 14 | DBusStandardInterfaceError(#[from] zbus::fdo::Error), 15 | #[error("Serialization error")] 16 | ZvariantError(#[from] zbus::zvariant::Error), 17 | #[error("Service path {0} was not understood")] 18 | DbusAddressError(String), 19 | #[error("Failed to broadcast message to notifier hosts")] 20 | BroadCastSendError(#[from] broadcast::error::SendError), 21 | #[error("Error receiving broadcast message")] 22 | BroadCastRecvError(#[from] broadcast::error::RecvError), 23 | } 24 | -------------------------------------------------------------------------------- /stray/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![doc = include_str ! ("../README.md")] 2 | 3 | pub use tokio; 4 | use zbus::names::InterfaceName; 5 | 6 | use crate::dbus::dbusmenu_proxy::MenuLayout; 7 | use crate::message::tray::StatusNotifierItem; 8 | use dbus::notifier_watcher_service::DbusNotifierWatcher; 9 | 10 | mod dbus; 11 | mod notifier_host; 12 | mod notifier_watcher; 13 | 14 | pub mod error; 15 | /// Messages sent and received by the [`SystemTray`] 16 | pub mod message; 17 | 18 | pub use message::NotifierItemMessage; 19 | pub use notifier_watcher::StatusNotifierWatcher; 20 | -------------------------------------------------------------------------------- /stray/src/message/menu.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | use std::str; 3 | use std::str::FromStr; 4 | 5 | use zbus::zvariant::{OwnedValue, Structure, Value}; 6 | 7 | use crate::dbus::dbusmenu_proxy::MenuLayout; 8 | 9 | /// A menu that should be displayed when clicking corresponding tray icon 10 | #[derive(Debug, Serialize, Clone)] 11 | pub struct TrayMenu { 12 | /// The unique identifier of the menu 13 | pub id: u32, 14 | /// A recursive list of submenus 15 | pub submenus: Vec, 16 | } 17 | 18 | /// Represent an entry in a menu as described in [com.canonical.dbusmenu](https://github.com/AyatanaIndicators/libdbusmenu/blob/4d03141aea4e2ad0f04ab73cf1d4f4bcc4a19f6c/libdbusmenu-glib/dbus-menu.xml#L75) 19 | /// This implementation currently support a sub section of the spec, if you feel something is missing don't hesitate to submit an issue. 20 | #[derive(Debug, Serialize, Clone)] 21 | pub struct MenuItem { 22 | /// Unique numeric id 23 | pub id: i32, 24 | /// If the menu item has children this property should be set to "submenu" 25 | pub children_display: Option, 26 | /// Text of the item, 27 | pub label: String, 28 | /// Whether the item can be activated or not. 29 | pub enabled: bool, 30 | /// True if the item is visible in the menu. 31 | pub visible: bool, 32 | /// Icon name of the item, following the freedesktop.org icon spec. 33 | pub icon_name: Option, 34 | /// Describe the current state of a "togglable" item. Can be one of: 35 | /// - Some(true): on 36 | /// - Some(false): off 37 | /// - None: indeterminate 38 | pub toggle_state: ToggleState, 39 | /// How the menuitem feels the information it's displaying to the 40 | /// user should be presented. 41 | pub toggle_type: ToggleType, 42 | /// Either a standard menu item or a separator [`MenuType`] 43 | pub menu_type: MenuType, 44 | /// How the menuitem feels the information it's displaying to the user should be presented. 45 | pub disposition: Disposition, 46 | /// A submenu for this item, typically this would ve revealed to the user by hovering the current item 47 | pub submenu: Vec, 48 | } 49 | 50 | impl Default for MenuItem { 51 | fn default() -> Self { 52 | Self { 53 | id: 0, 54 | children_display: None, 55 | label: "".to_string(), 56 | enabled: true, 57 | visible: true, 58 | icon_name: None, 59 | toggle_state: ToggleState::Indeterminate, 60 | toggle_type: ToggleType::CannotBeToggled, 61 | menu_type: MenuType::Standard, 62 | disposition: Disposition::Normal, 63 | submenu: vec![], 64 | } 65 | } 66 | } 67 | 68 | /// How the menuitem feels the information it's displaying to the 69 | /// user should be presented. 70 | #[derive(Debug, Serialize, Copy, Clone, Eq, PartialEq)] 71 | pub enum ToggleType { 72 | /// Item is an independent togglable item 73 | Checkmark, 74 | /// Item is part of a group where only one item can be 75 | /// toggled at a time 76 | Radio, 77 | /// Item cannot be toggled 78 | CannotBeToggled, 79 | } 80 | 81 | /// Either a standard menu item or a separator 82 | #[derive(Debug, Serialize, Copy, Clone, Eq, PartialEq)] 83 | pub enum MenuType { 84 | /// a separator 85 | Separator, 86 | /// an item which can be clicked to trigger an action or show another menu 87 | Standard, 88 | } 89 | 90 | /// How the menuitem feels the information it's displaying to the 91 | /// user should be presented. 92 | #[derive(Debug, Serialize, Copy, Clone, Eq, PartialEq)] 93 | pub enum Disposition { 94 | /// a standard menu item 95 | Normal, 96 | /// providing additional information to the user 97 | Informative, 98 | /// looking at potentially harmful results 99 | Warning, 100 | /// something bad could potentially happen 101 | Alert, 102 | } 103 | 104 | /// Describe the current state of a "togglable" item. 105 | #[derive(Debug, Serialize, Copy, Clone, Eq, PartialEq)] 106 | pub enum ToggleState { 107 | /// This item is toggled 108 | On, 109 | /// Item is not toggled 110 | Off, 111 | /// Item is not toggalble 112 | Indeterminate, 113 | } 114 | 115 | impl FromStr for MenuType { 116 | type Err = zbus::zvariant::Error; 117 | 118 | fn from_str(s: &str) -> Result { 119 | match s { 120 | "standard" => Ok(MenuType::Standard), 121 | "separator" => Ok(MenuType::Separator), 122 | _ => Err(zbus::zvariant::Error::IncorrectType), 123 | } 124 | } 125 | } 126 | 127 | impl FromStr for ToggleType { 128 | type Err = zbus::zvariant::Error; 129 | 130 | fn from_str(s: &str) -> Result { 131 | match s { 132 | "checkmark" => Ok(ToggleType::Checkmark), 133 | "radio" => Ok(ToggleType::Radio), 134 | _ => Err(zbus::zvariant::Error::IncorrectType), 135 | } 136 | } 137 | } 138 | 139 | impl FromStr for Disposition { 140 | type Err = zbus::zvariant::Error; 141 | 142 | fn from_str(s: &str) -> Result { 143 | match s { 144 | "normal" => Ok(Disposition::Normal), 145 | "informative" => Ok(Disposition::Informative), 146 | "warning" => Ok(Disposition::Warning), 147 | "alert" => Ok(Disposition::Alert), 148 | _ => Err(zbus::zvariant::Error::IncorrectType), 149 | } 150 | } 151 | } 152 | 153 | impl From for ToggleState { 154 | fn from(value: bool) -> Self { 155 | if value { 156 | ToggleState::On 157 | } else { 158 | ToggleState::Indeterminate 159 | } 160 | } 161 | } 162 | 163 | impl TryFrom for TrayMenu { 164 | type Error = zbus::zvariant::Error; 165 | 166 | fn try_from(value: MenuLayout) -> Result { 167 | let mut submenus = vec![]; 168 | for menu in &value.fields.submenus { 169 | let menu = MenuItem::try_from(menu)?; 170 | submenus.push(menu); 171 | } 172 | 173 | Ok(TrayMenu { 174 | id: value.id, 175 | submenus, 176 | }) 177 | } 178 | } 179 | 180 | impl TryFrom<&OwnedValue> for MenuItem { 181 | type Error = zbus::zvariant::Error; 182 | 183 | fn try_from(value: &OwnedValue) -> Result { 184 | let structure = value 185 | .downcast_ref::() 186 | .expect("Expected a layout"); 187 | 188 | let mut fields = structure.fields().iter(); 189 | let mut menu = MenuItem::default(); 190 | 191 | if let Some(Value::I32(id)) = fields.next() { 192 | menu.id = *id; 193 | } 194 | 195 | if let Some(Value::Dict(dict)) = fields.next() { 196 | menu.children_display = dict 197 | .get::("children_display")? 198 | .map(str::to_string); 199 | 200 | // see: https://github.com/AyatanaIndicators/libdbusmenu/blob/4d03141aea4e2ad0f04ab73cf1d4f4bcc4a19f6c/libdbusmenu-glib/dbus-menu.xml#L75 201 | menu.label = dict 202 | .get::("label")? 203 | .map(|label| label.replace('_', "")) 204 | .unwrap_or_default(); 205 | 206 | if let Some(enabled) = dict.get::("enabled")? { 207 | menu.enabled = *enabled 208 | } 209 | 210 | if let Some(visible) = dict.get::("visible")? { 211 | menu.visible = *visible; 212 | } 213 | 214 | menu.icon_name = dict.get::("icon-name")?.map(str::to_string); 215 | 216 | if let Some(disposition) = dict 217 | .get::("disposition") 218 | .ok() 219 | .flatten() 220 | .map(Disposition::from_str) 221 | .and_then(Result::ok) 222 | { 223 | menu.disposition = disposition; 224 | } 225 | 226 | menu.toggle_state = dict 227 | .get::("toggle-state") 228 | .ok() 229 | .flatten() 230 | .map(|value| ToggleState::from(*value)) 231 | .unwrap_or(ToggleState::Indeterminate); 232 | 233 | menu.toggle_type = dict 234 | .get::("toggle-type") 235 | .ok() 236 | .flatten() 237 | .map(ToggleType::from_str) 238 | .and_then(Result::ok) 239 | .unwrap_or(ToggleType::CannotBeToggled); 240 | 241 | menu.menu_type = dict 242 | .get::("type") 243 | .ok() 244 | .flatten() 245 | .map(MenuType::from_str) 246 | .and_then(Result::ok) 247 | .unwrap_or(MenuType::Standard); 248 | }; 249 | 250 | if let Some(Value::Array(array)) = fields.next() { 251 | let mut submenu = vec![]; 252 | for value in array.iter() { 253 | let value = OwnedValue::from(value); 254 | let menu = MenuItem::try_from(&value)?; 255 | submenu.push(menu); 256 | } 257 | 258 | menu.submenu = submenu; 259 | } 260 | 261 | Ok(menu) 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /stray/src/message/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::message::menu::TrayMenu; 2 | use crate::message::tray::StatusNotifierItem; 3 | use serde::Serialize; 4 | 5 | /// Implementation of [com.canonical.dbusmenu](https://github.com/AyatanaIndicators/libdbusmenu/blob/4d03141aea4e2ad0f04ab73cf1d4f4bcc4a19f6c/libdbusmenu-glib/dbus-menu.xml#L75) 6 | pub mod menu; 7 | /// Implementation of [StatusNotifierItem](https://freedesktop.org/wiki/Specifications/StatusNotifierItem) 8 | pub mod tray; 9 | 10 | /// Messages send via by [`crate::SystemTray`] 11 | #[derive(Debug, Serialize, Clone)] 12 | pub enum NotifierItemMessage { 13 | /// Notify the state of an item along with its menu 14 | Update { 15 | /// The address of the NotifierItem on dbus, this will be required 16 | /// to request the activation of a manu entry via [`NotifierItemCommand::MenuItemClicked`] 17 | /// and remove the item when it is closed by the user. 18 | address: String, 19 | /// the status [`StatusNotifierItem`] and its metadata, to build a system tray ui 20 | /// the minimal would be to display it's icon and use it's menu address to send menu activation 21 | /// requests. 22 | item: Box, 23 | /// The menu layout of the item. 24 | menu: Option, 25 | }, 26 | /// A [`StatusNotifierItem`] has been removed from the tray 27 | Remove { 28 | /// The dbus address of the item, it serves as an unique identifier. 29 | address: String, 30 | }, 31 | } 32 | 33 | /// Command to send to a [`StatusNotifierItem`] 34 | #[derive(Debug)] 35 | pub enum NotifierItemCommand { 36 | /// Request activation of a menu item 37 | MenuItemClicked { 38 | /// Unique identifier of the item, see: [`crate::message::menu::MenuItem`] 39 | submenu_id: i32, 40 | /// DBus path of the menu item, (see: [`StatusNotifierItem`]) 41 | menu_path: String, 42 | /// Dbus address of the [`StatusNotifierItem`] 43 | notifier_address: String, 44 | }, 45 | } 46 | -------------------------------------------------------------------------------- /stray/src/message/tray.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::str::FromStr; 3 | 4 | use anyhow::anyhow; 5 | use serde::{Deserialize, Serialize}; 6 | use zbus::zvariant::{Array, ObjectPath, OwnedValue, Structure}; 7 | 8 | type DBusProperties = HashMap; 9 | 10 | struct PropsWrapper(DBusProperties); 11 | 12 | /// An Icon used for reporting the status of an application to the user or provide a quick access 13 | /// to common actions performed by that application. You can read the full specification at 14 | /// [freedesktop.org/wiki/Specifications/StatusNotifierItem](https://freedesktop.org/wiki/Specifications/StatusNotifierItem) 15 | /// or take a look at [the reference implementation](https://github.com/AyatanaIndicators/libayatana-appindicator/blob/c43a76e643ab930725d20d306bc3ca5e7874eebe/src/notification-item.xml) 16 | /// 17 | /// Note that this implementation is not feature complete. It only contains the minimal data 18 | /// needed to build a system tray and display tray menus. If you feel something important is 19 | /// should be added please reach out. 20 | #[derive(Serialize, Debug, Clone)] 21 | pub struct StatusNotifierItem { 22 | /// It's a name that should be unique for this application and consistent between sessions, 23 | /// such as the application name itself. 24 | pub id: String, 25 | /// Describes the category of this item. 26 | pub category: Category, 27 | /// Describes the status of this item or of the associated application. 28 | pub status: Status, 29 | /// The StatusNotifierItem can carry an icon that can be used by the visualization to identify the item. 30 | /// An icon can either be identified by its Freedesktop-compliant icon name, carried by 31 | /// this property of by the icon data itself, carried by the property IconPixmap. 32 | /// Visualizations are encouraged to prefer icon names over icon pixmaps if both are available 33 | pub icon_name: Option, 34 | /// Carries an ARGB32 binary representation of the icon, the format of icon data used in this specification 35 | /// is described in Section Icons 36 | pub icon_accessible_desc: Option, 37 | /// The Freedesktop-compliant name of an icon. this can be used by the visualization to indicate 38 | /// that the item is in RequestingAttention state. 39 | pub attention_icon_name: Option, 40 | /// It's a name that describes the application, it can be more descriptive than Id. 41 | pub title: Option, 42 | pub icon_theme_path: Option, 43 | pub icon_pixmap: Option>, 44 | /// DBus path to an object which should implement the com.canonical.dbusmenu interface 45 | /// This can be used to retrieve the wigdet menu via gtk/qt libdbusmenu implementation 46 | /// Instead of building it from the raw data 47 | pub menu: Option, 48 | } 49 | 50 | #[derive(Serialize, Debug, Clone)] 51 | #[serde(rename_all = "PascalCase")] 52 | pub enum Status { 53 | /// The item doesn't convey important information to the user, it can be considered an 54 | /// "idle" status and is likely that visualizations will chose to hide it. 55 | Passive, 56 | /// The item is active, is more important that the item will be shown in some way to the user. 57 | Active, 58 | } 59 | 60 | impl FromStr for Status { 61 | type Err = anyhow::Error; 62 | 63 | fn from_str(s: &str) -> Result { 64 | match s { 65 | "Passive" => Ok(Status::Active), 66 | "Active" => Ok(Status::Passive), 67 | other => Err(anyhow!( 68 | "Unknown 'Status' for status notifier item {}", 69 | other 70 | )), 71 | } 72 | } 73 | } 74 | 75 | /// Describes the category of this item. 76 | #[derive(Serialize, Debug, Clone)] 77 | #[serde(rename_all = "PascalCase")] 78 | pub enum Category { 79 | /// The item describes the status of a generic application, for instance the current state 80 | /// of a media player. In the case where the category of the item can not be known, such as 81 | /// when the item is being proxied from another incompatible or emulated system, 82 | /// ApplicationStatus can be used a sensible default fallback. 83 | ApplicationStatus, 84 | /// The item describes the status of communication oriented applications, like an instant 85 | /// messenger or an email client. 86 | Communications, 87 | /// The item describes services of the system not seen as a stand alone application by the user, 88 | /// such as an indicator for the activity of a disk indexing service. 89 | SystemServices, 90 | /// The item describes the state and control of a particular hardware, such as an indicator 91 | /// of the battery charge or sound card volume control. 92 | Hardware, 93 | } 94 | 95 | impl FromStr for Category { 96 | type Err = anyhow::Error; 97 | 98 | fn from_str(s: &str) -> Result { 99 | match s { 100 | "ApplicationStatus" => Ok(Category::ApplicationStatus), 101 | "Communications" => Ok(Category::Communications), 102 | "SystemServices" => Ok(Category::SystemServices), 103 | "Hardware" => Ok(Category::Hardware), 104 | other => Err(anyhow!( 105 | "Unknown 'Status' for status notifier item {}", 106 | other 107 | )), 108 | } 109 | } 110 | } 111 | 112 | #[derive(Deserialize, Serialize, Debug, Clone)] 113 | pub struct IconPixmap { 114 | pub width: i32, 115 | pub height: i32, 116 | pub pixels: Vec, 117 | } 118 | 119 | impl IconPixmap { 120 | fn from_array(a: &Array<'_>) -> Option> { 121 | let mut pixmaps = vec![]; 122 | 123 | a.iter().for_each(|b| { 124 | let s = b.downcast_ref::(); 125 | let fields = s.unwrap().fields(); 126 | let width = fields[0].downcast_ref::().unwrap(); 127 | let height = fields[1].downcast_ref::().unwrap(); 128 | let pixel_values = fields[2].downcast_ref::().unwrap().get(); 129 | let mut pixels = vec![]; 130 | pixel_values.iter().for_each(|p| { 131 | pixels.push(*p.downcast_ref::().unwrap()); 132 | }); 133 | pixmaps.push(IconPixmap { 134 | width: *width, 135 | height: *height, 136 | pixels, 137 | }) 138 | }); 139 | 140 | Some(pixmaps) 141 | } 142 | } 143 | 144 | impl TryFrom for StatusNotifierItem { 145 | type Error = anyhow::Error; 146 | fn try_from(props: HashMap) -> anyhow::Result { 147 | let props = PropsWrapper(props); 148 | match props.get_string("Id") { 149 | None => Err(anyhow!("StatusNotifier item should have an id")), 150 | Some(id) => Ok(StatusNotifierItem { 151 | id, 152 | title: props.get_string("Title"), 153 | category: props.get_category()?, 154 | icon_name: props.get_string("IconName"), 155 | status: props.get_status()?, 156 | icon_accessible_desc: props.get_string("IconAccessibleDesc"), 157 | attention_icon_name: props.get_string("AttentionIconName"), 158 | icon_theme_path: props.get_string("IconThemePath"), 159 | icon_pixmap: props.get_icon_pixmap(), 160 | menu: props.get_object_path("Menu"), 161 | }), 162 | } 163 | } 164 | } 165 | 166 | impl PropsWrapper { 167 | fn get_string(&self, key: &str) -> Option { 168 | self.0 169 | .get(key) 170 | .and_then(|value| value.downcast_ref::().map(|value| value.to_string())) 171 | } 172 | 173 | fn get_object_path(&self, key: &str) -> Option { 174 | self.0.get(key).and_then(|value| { 175 | value 176 | .downcast_ref::() 177 | .map(|value| value.to_string()) 178 | }) 179 | } 180 | 181 | fn get_category(&self) -> anyhow::Result { 182 | self.0 183 | .get("Category") 184 | .and_then(|value| value.downcast_ref::().map(Category::from_str)) 185 | .unwrap_or_else(|| Err(anyhow!("'Category' not found for item"))) 186 | } 187 | 188 | fn get_status(&self) -> anyhow::Result { 189 | self.0 190 | .get("Status") 191 | .and_then(|value| value.downcast_ref::().map(Status::from_str)) 192 | .unwrap_or_else(|| Err(anyhow!("'Status' not found for item"))) 193 | } 194 | 195 | fn get_icon_pixmap(&self) -> Option> { 196 | self.0 197 | .get("IconPixmap") 198 | .and_then(|value| value.downcast_ref::().map(IconPixmap::from_array)) 199 | .unwrap_or(None) 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /stray/src/notifier_host/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::dbus::notifier_watcher_proxy::StatusNotifierWatcherProxy; 2 | use crate::error::{Result, StatusNotifierWatcherError}; 3 | use crate::{NotifierItemMessage, StatusNotifierWatcher}; 4 | use tokio::sync::broadcast; 5 | use zbus::{Connection, ConnectionBuilder}; 6 | 7 | pub struct NotifierHost { 8 | wellknown_name: String, 9 | rx: broadcast::Receiver, 10 | conn: Connection, 11 | } 12 | 13 | impl StatusNotifierWatcher { 14 | pub async fn create_notifier_host(&self, unique_id: &str) -> Result { 15 | let pid = std::process::id(); 16 | let id = &unique_id; 17 | let wellknown_name = format!("org.freedesktop.StatusNotifierHost-{pid}-{id}"); 18 | 19 | let conn = ConnectionBuilder::session()? 20 | .name(wellknown_name.as_str())? 21 | .build() 22 | .await?; 23 | 24 | let status_notifier_proxy = StatusNotifierWatcherProxy::new(&conn).await?; 25 | 26 | status_notifier_proxy 27 | .register_status_notifier_host(&wellknown_name) 28 | .await?; 29 | 30 | Ok(NotifierHost { 31 | wellknown_name, 32 | rx: self.tx.subscribe(), 33 | conn, 34 | }) 35 | } 36 | } 37 | 38 | impl NotifierHost { 39 | pub async fn recv(&mut self) -> Result { 40 | self.rx 41 | .recv() 42 | .await 43 | .map_err(StatusNotifierWatcherError::from) 44 | } 45 | 46 | /// This is used to drop the StatusNotifierHost and tell Dbus to release the name 47 | pub async fn destroy(self) -> Result<()> { 48 | let _ = self.conn.release_name(self.wellknown_name.as_str()).await?; 49 | Ok(()) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /stray/src/notifier_watcher/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::dbus::dbusmenu_proxy::DBusMenuProxy; 2 | use crate::dbus::notifier_item_proxy::StatusNotifierItemProxy; 3 | use crate::dbus::notifier_watcher_proxy::StatusNotifierWatcherProxy; 4 | use crate::error::Result; 5 | use crate::message::menu::TrayMenu; 6 | use crate::message::NotifierItemCommand; 7 | use crate::notifier_watcher::notifier_address::NotifierAddress; 8 | use crate::{ 9 | DbusNotifierWatcher, InterfaceName, MenuLayout, NotifierItemMessage, StatusNotifierItem, 10 | }; 11 | use tokio::sync::{broadcast, mpsc}; 12 | use tokio_stream::StreamExt; 13 | use zbus::fdo::PropertiesProxy; 14 | use zbus::{Connection, ConnectionBuilder}; 15 | 16 | pub(crate) mod notifier_address; 17 | 18 | /// Wrap the implementation of [org.freedesktop.StatusNotifierWatcher](https://www.freedesktop.org/wiki/Specifications/StatusNotifierItem/StatusNotifierWatcher/) 19 | /// and [org.freedesktop.StatusNotifierHost](https://www.freedesktop.org/wiki/Specifications/StatusNotifierItem/StatusNotifierHost/). 20 | #[derive(Debug)] 21 | pub struct StatusNotifierWatcher { 22 | pub(crate) tx: broadcast::Sender, 23 | _rx: broadcast::Receiver, 24 | } 25 | 26 | impl StatusNotifierWatcher { 27 | /// Creates a new system stray and register a [StatusNotifierWatcher](https://www.freedesktop.org/wiki/Specifications/StatusNotifierItem/StatusNotifierWatcher/) and [StatusNotifierHost](https://www.freedesktop.org/wiki/Specifications/StatusNotifierItem/StatusNotifierHost/) on dbus. 28 | /// Once created you can receive [`StatusNotifierItem`]. Once created you can start to poll message 29 | /// using the [`Stream`] implementation. 30 | pub async fn new(cmd_rx: mpsc::Receiver) -> Result { 31 | let (tx, rx) = broadcast::channel(5); 32 | 33 | { 34 | tracing::info!("Starting notifier watcher"); 35 | let tx = tx.clone(); 36 | 37 | tokio::spawn(async move { 38 | start_notifier_watcher(tx) 39 | .await 40 | .expect("Unexpected StatusNotifierError"); 41 | }); 42 | } 43 | 44 | tokio::spawn(async move { 45 | dispatch_ui_command(cmd_rx) 46 | .await 47 | .expect("Unexpected error while dispatching UI command"); 48 | }); 49 | 50 | Ok(StatusNotifierWatcher { tx, _rx: rx }) 51 | } 52 | } 53 | 54 | // Forward UI command to the Dbus menu proxy 55 | async fn dispatch_ui_command(mut cmd_rx: mpsc::Receiver) -> Result<()> { 56 | let connection = Connection::session().await?; 57 | 58 | while let Some(command) = cmd_rx.recv().await { 59 | match command { 60 | NotifierItemCommand::MenuItemClicked { 61 | submenu_id: id, 62 | menu_path, 63 | notifier_address, 64 | } => { 65 | let dbus_menu_proxy = DBusMenuProxy::builder(&connection) 66 | .destination(notifier_address) 67 | .unwrap() 68 | .path(menu_path) 69 | .unwrap() 70 | .build() 71 | .await?; 72 | 73 | dbus_menu_proxy 74 | .event( 75 | id, 76 | "clicked", 77 | &zbus::zvariant::Value::I32(32), 78 | chrono::offset::Local::now().timestamp_subsec_micros(), 79 | ) 80 | .await?; 81 | } 82 | } 83 | } 84 | 85 | Ok(()) 86 | } 87 | 88 | async fn start_notifier_watcher(sender: broadcast::Sender) -> Result<()> { 89 | let watcher = DbusNotifierWatcher::new(sender.clone()); 90 | 91 | let connection = ConnectionBuilder::session()? 92 | .name("org.kde.StatusNotifierWatcher")? 93 | .serve_at("/StatusNotifierWatcher", watcher)? 94 | .build() 95 | .await?; 96 | 97 | let status_notifier_removed = { 98 | let connection = connection.clone(); 99 | tokio::spawn(async move { 100 | status_notifier_removed_handle(connection).await?; 101 | Result::<()>::Ok(()) 102 | }) 103 | }; 104 | 105 | let status_notifier = 106 | tokio::spawn(async move { status_notifier_handle(connection, sender).await.unwrap() }); 107 | 108 | tokio::spawn(async move { 109 | let (r1, r2) = tokio::join!(status_notifier, status_notifier_removed,); 110 | if let Err(err) = r1 { 111 | tracing::error!("Status notifier error: {err:?}") 112 | } 113 | 114 | if let Err(err) = r2 { 115 | tracing::error!("Status notifier removed error: {err:?}") 116 | } 117 | }); 118 | 119 | Ok(()) 120 | } 121 | 122 | // Listen for 'NameOwnerChanged' on DBus whenever a service is removed 123 | // send 'UnregisterStatusNotifierItem' request to 'StatusNotifierWatcher' via dbus 124 | async fn status_notifier_removed_handle(connection: Connection) -> Result<()> { 125 | let dbus_proxy = zbus::fdo::DBusProxy::new(&connection).await.unwrap(); 126 | 127 | let mut changed = dbus_proxy 128 | .receive_name_owner_changed() 129 | .await 130 | .expect("fail to receive Dbus NameOwnerChanged"); 131 | 132 | while let Some(signal) = changed.next().await { 133 | let args = signal.args().expect("Failed to get signal args"); 134 | let old = args.old_owner(); 135 | let new = args.new_owner(); 136 | 137 | if old.is_some() && new.is_none() { 138 | let old_owner: String = old.as_ref().unwrap().to_string(); 139 | let watcher_proxy = StatusNotifierWatcherProxy::new(&connection) 140 | .await 141 | .expect("Failed to open StatusNotifierWatcherProxy"); 142 | 143 | if let Err(err) = watcher_proxy 144 | .unregister_status_notifier_item(&old_owner) 145 | .await 146 | { 147 | tracing::error!("Failed to unregister status notifier: {err:?}") 148 | } 149 | } 150 | } 151 | 152 | Ok(()) 153 | } 154 | 155 | // 1. Start StatusNotifierHost on DBus 156 | // 2. Query already registered StatusNotifier, call GetAll to update the UI and listen for property changes via Dbus.PropertiesChanged 157 | // 3. subscribe to StatusNotifierWatcher.RegisteredStatusNotifierItems 158 | // 4. Whenever a new notifier is registered repeat steps 2 159 | // FIXME : Move this to HOST 160 | async fn status_notifier_handle( 161 | connection: Connection, 162 | sender: broadcast::Sender, 163 | ) -> Result<()> { 164 | let status_notifier_proxy = StatusNotifierWatcherProxy::new(&connection).await?; 165 | 166 | let notifier_items: Vec = status_notifier_proxy 167 | .registered_status_notifier_items() 168 | .await?; 169 | 170 | tracing::info!("Got {} notifier items", notifier_items.len()); 171 | 172 | // Start watching for all registered notifier items 173 | for service in notifier_items.iter() { 174 | let service = NotifierAddress::from_notifier_service(service); 175 | if let Ok(notifier_address) = service { 176 | let connection = connection.clone(); 177 | let sender = sender.clone(); 178 | watch_notifier_props(notifier_address, connection, sender).await?; 179 | } 180 | } 181 | 182 | // Listen for new notifier items 183 | let mut new_notifier = status_notifier_proxy 184 | .receive_status_notifier_item_registered() 185 | .await?; 186 | 187 | while let Some(notifier) = new_notifier.next().await { 188 | let args = notifier.args()?; 189 | let service: &str = args.service(); 190 | tracing::info!( 191 | "StatusNotifierItemRegistered signal received service={}", 192 | service 193 | ); 194 | 195 | let service = NotifierAddress::from_notifier_service(service); 196 | if let Ok(notifier_address) = service { 197 | let connection = connection.clone(); 198 | let sender = sender.clone(); 199 | tokio::spawn(async move { 200 | watch_notifier_props(notifier_address, connection, sender).await?; 201 | Result::<()>::Ok(()) 202 | }); 203 | } 204 | } 205 | 206 | Ok(()) 207 | } 208 | 209 | // Listen for PropertiesChanged on DBus and send an update request on change 210 | async fn watch_notifier_props( 211 | address_parts: NotifierAddress, 212 | connection: Connection, 213 | sender: broadcast::Sender, 214 | ) -> Result<()> { 215 | tokio::spawn(async move { 216 | // Connect to DBus.Properties 217 | let dbus_properties_proxy = zbus::fdo::PropertiesProxy::builder(&connection) 218 | .destination(address_parts.destination.as_str())? 219 | .path(address_parts.path.as_str())? 220 | .build() 221 | .await?; 222 | 223 | // call Properties.GetAll once and send an update to the UI 224 | fetch_properties_and_update( 225 | sender.clone(), 226 | &dbus_properties_proxy, 227 | address_parts.destination.clone(), 228 | connection.clone(), 229 | ) 230 | .await?; 231 | 232 | // Connect to the notifier proxy to watch for properties change 233 | let notifier_item_proxy = StatusNotifierItemProxy::builder(&connection) 234 | .destination(address_parts.destination.as_str())? 235 | .path(address_parts.path.as_str())? 236 | .build() 237 | .await?; 238 | 239 | let mut props_changed = notifier_item_proxy.receive_all_signals().await?; 240 | 241 | // Whenever a property change query all props and update the UI 242 | while props_changed.next().await.is_some() { 243 | fetch_properties_and_update( 244 | sender.clone(), 245 | &dbus_properties_proxy, 246 | address_parts.destination.clone(), 247 | connection.clone(), 248 | ) 249 | .await?; 250 | } 251 | 252 | Result::<()>::Ok(()) 253 | }); 254 | 255 | Ok(()) 256 | } 257 | 258 | // Fetch Properties from DBus proxy and send an update to the UI channel 259 | async fn fetch_properties_and_update( 260 | sender: broadcast::Sender, 261 | dbus_properties_proxy: &PropertiesProxy<'_>, 262 | item_address: String, 263 | connection: Connection, 264 | ) -> Result<()> { 265 | let interface = InterfaceName::from_static_str("org.kde.StatusNotifierItem")?; 266 | let props = dbus_properties_proxy.get_all(interface).await?; 267 | let item = StatusNotifierItem::try_from(props); 268 | 269 | // Only send item that maps correctly to our internal StatusNotifierItem representation 270 | if let Ok(item) = item { 271 | let menu = match &item.menu { 272 | None => None, 273 | Some(menu_address) => watch_menu( 274 | item_address.clone(), 275 | item.clone(), 276 | connection.clone(), 277 | menu_address.clone(), 278 | sender.clone(), 279 | ) 280 | .await 281 | .ok(), 282 | }; 283 | 284 | tracing::info!("StatusNotifierItem updated, dbus-address={item_address}"); 285 | 286 | sender 287 | .send(NotifierItemMessage::Update { 288 | address: item_address.to_string(), 289 | item: Box::new(item), 290 | menu, 291 | }) 292 | .expect("Failed to dispatch NotifierItemMessage"); 293 | } 294 | 295 | Ok(()) 296 | } 297 | 298 | async fn watch_menu( 299 | item_address: String, 300 | item: StatusNotifierItem, 301 | connection: Connection, 302 | menu_address: String, 303 | sender: broadcast::Sender, 304 | ) -> Result { 305 | let dbus_menu_proxy = DBusMenuProxy::builder(&connection) 306 | .destination(item_address.as_str())? 307 | .path(menu_address.as_str())? 308 | .build() 309 | .await?; 310 | 311 | let menu: MenuLayout = dbus_menu_proxy.get_layout(0, 10, &[]).await.unwrap(); 312 | 313 | tokio::spawn(async move { 314 | let dbus_menu_proxy = DBusMenuProxy::builder(&connection) 315 | .destination(item_address.as_str())? 316 | .path(menu_address.as_str())? 317 | .build() 318 | .await?; 319 | 320 | let mut props_changed = dbus_menu_proxy.receive_all_signals().await?; 321 | 322 | while props_changed.next().await.is_some() { 323 | let menu: MenuLayout = dbus_menu_proxy.get_layout(0, 10, &[]).await.unwrap(); 324 | let menu = TrayMenu::try_from(menu).ok(); 325 | sender.send(NotifierItemMessage::Update { 326 | address: item_address.to_string(), 327 | item: Box::new(item.clone()), 328 | menu, 329 | })?; 330 | } 331 | anyhow::Result::<(), anyhow::Error>::Ok(()) 332 | }); 333 | 334 | TrayMenu::try_from(menu).map_err(Into::into) 335 | } 336 | -------------------------------------------------------------------------------- /stray/src/notifier_watcher/notifier_address.rs: -------------------------------------------------------------------------------- 1 | use crate::error; 2 | use crate::error::StatusNotifierWatcherError; 3 | 4 | // A helper to convert RegisterStatusNotifier calls to 5 | // StatusNotifier address parts 6 | #[derive(Debug)] 7 | pub(crate) struct NotifierAddress { 8 | // Notifier destination on the bus, ex: ":1.522" 9 | pub(crate) destination: String, 10 | // The notifier object path, ex: "/org/ayatana/NotificationItem/Element1" 11 | pub(crate) path: String, 12 | } 13 | 14 | impl NotifierAddress { 15 | pub(crate) fn from_notifier_service(service: &str) -> error::Result { 16 | if let Some((destination, path)) = service.split_once('/') { 17 | Ok(NotifierAddress { 18 | destination: destination.to_string(), 19 | path: format!("/{}", path), 20 | }) 21 | } else if service.starts_with(':') { 22 | Ok(NotifierAddress { 23 | destination: service[0..6].to_string(), 24 | path: "/StatusNotifierItem".to_string(), 25 | }) 26 | } else { 27 | Err(StatusNotifierWatcherError::DbusAddressError( 28 | service.to_string(), 29 | )) 30 | } 31 | } 32 | } 33 | --------------------------------------------------------------------------------