use std::sync::{Arc, Condvar, Mutex};
use std::time::Duration;
use crate::error::{Error, Result, Unsupported};
use crate::geometry::{Edge, LogicalPoint, LogicalRect, LogicalSize};
use crate::menu::{Icon, Menu, MenuId};
use crate::platform::{Appearance, Platform};
use crate::theme::MenuOptions;
use crate::{Tray, TrayCommand};
mod a11y;
mod sni;
#[cfg(feature = "wayland-styled")]
mod wayland;
#[cfg(feature = "x11-popup")]
mod x11;
use ksni::blocking::{Handle, TrayMethods};
use sni::MuriSni;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub(crate) enum LinuxMenuPresenter {
NativeDbusMenu,
X11Popup,
WaylandLayerShell,
}
pub(crate) fn detect_linux_presenter() -> LinuxMenuPresenter {
if wayland_display_present() {
if desktop_is_gnome() {
return LinuxMenuPresenter::NativeDbusMenu;
}
#[cfg(feature = "wayland-styled")]
{
if wayland_env_suggests_layer_shell() {
return LinuxMenuPresenter::WaylandLayerShell;
}
}
return LinuxMenuPresenter::NativeDbusMenu;
}
#[cfg(feature = "x11-popup")]
{
if x11::is_available() {
return LinuxMenuPresenter::X11Popup;
}
}
LinuxMenuPresenter::NativeDbusMenu
}
fn wayland_display_present() -> bool {
std::env::var_os("WAYLAND_DISPLAY").is_some_and(|v| !v.is_empty())
}
fn desktop_is_gnome() -> bool {
[
"XDG_CURRENT_DESKTOP",
"XDG_SESSION_DESKTOP",
"DESKTOP_SESSION",
]
.iter()
.filter_map(|k| std::env::var(k).ok())
.any(|v| v.to_ascii_lowercase().contains("gnome"))
}
#[cfg(feature = "wayland-styled")]
fn wayland_env_suggests_layer_shell() -> bool {
const LAYER_SHELL_DESKTOPS: &[&str] = &[
"sway", "hyprland", "river", "wayfire", "labwc", "cosmic", "kde", "plasma", "wlroots",
];
[
"XDG_CURRENT_DESKTOP",
"XDG_SESSION_DESKTOP",
"DESKTOP_SESSION",
]
.iter()
.filter_map(|k| std::env::var(k).ok())
.any(|v| {
let v = v.to_ascii_lowercase();
LAYER_SHELL_DESKTOPS.iter().any(|d| v.contains(d))
})
}
#[derive(Default)]
#[non_exhaustive]
pub struct LinuxAnchor;
impl LinuxAnchor {
pub fn new() -> Self {
LinuxAnchor
}
fn anchor_rect(&self) -> Result<LogicalRect> {
Err(Error::Unsupported(Unsupported::TrayAnchor))
}
fn supports_tray_anchor(&self) -> bool {
false
}
}
impl std::fmt::Debug for LinuxAnchor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("LinuxAnchor")
}
}
#[derive(Default)]
pub struct LinuxPlatform {
anchor: LinuxAnchor,
service: Option<Handle<MuriSni>>,
}
impl std::fmt::Debug for LinuxPlatform {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LinuxPlatform")
.field("anchor", &self.anchor)
.field("installed", &self.service.is_some())
.finish()
}
}
impl LinuxPlatform {
pub fn new() -> Self {
LinuxPlatform {
anchor: LinuxAnchor::new(),
service: None,
}
}
}
impl Drop for LinuxPlatform {
fn drop(&mut self) {
if let Some(service) = self.service.take() {
service.shutdown().wait();
}
}
}
impl Platform for LinuxPlatform {
fn install_tray(&mut self, icon: &Icon, tooltip: Option<&str>) -> Result<()> {
let mut tray = Tray::new(icon.clone());
if let Some(tip) = tooltip {
tray = tray.tooltip(tip);
}
let handle = MuriSni {
tray,
visible: true,
presenter: detect_linux_presenter(),
}
.spawn()
.map_err(|e| {
Error::TrayInstall(format!("SNI/StatusNotifierItem registration failed: {e}"))
})?;
if let Some(old) = self.service.take() {
old.shutdown().wait();
}
self.service = Some(handle);
Ok(())
}
fn tray_anchor_rect(&self) -> Result<LogicalRect> {
self.anchor.anchor_rect()
}
fn supports_tray_anchor(&self) -> bool {
self.anchor.supports_tray_anchor()
}
fn cursor_position(&self) -> Option<LogicalPoint> {
#[cfg(feature = "x11-popup")]
{
if x11::is_available() {
return x11::cursor_position();
}
}
None
}
fn appearance(&self) -> Appearance {
system_appearance()
}
fn system_menu_font(&self) -> Option<crate::platform::SystemFont> {
system_menu_font()
}
fn work_area(&self) -> LogicalRect {
LogicalRect::new(LogicalPoint::new(0.0, 0.0), LogicalSize::new(1440.0, 900.0))
}
fn run_tray(self, tray: Tray) -> Result<()> {
let (report, _wait) = std::sync::mpsc::channel();
run_sni_loop(tray, &report)
}
fn spawn_tray(self, tray: Tray) -> Result<()> {
super::spawn_tray_thread(tray, run_sni_loop)
}
fn open_popup_session(
&mut self,
menu: Menu,
options: MenuOptions,
on_click: &(dyn Fn(&MenuId) + '_),
anchor: LogicalRect,
edge: Edge,
) -> Result<()> {
#[cfg(feature = "x11-popup")]
{
if x11::is_available() {
let dark = self.appearance().is_dark();
x11::open_popup_session(menu, options, on_click, anchor, edge, dark)
} else {
Err(x11::wayland_unsupported())
}
}
#[cfg(not(feature = "x11-popup"))]
{
let _ = (menu, options, on_click, anchor, edge);
Err(Error::Unsupported(Unsupported::ClientPositioning))
}
}
}
#[cfg(feature = "x11-popup")]
pub(super) fn open_x11_popup_at_cursor(tray: &Tray) {
let Some(point) = x11::cursor_position() else {
return;
};
let anchor = LogicalRect::new(point, LogicalSize::new(0.0, 0.0));
let dark = system_appearance().is_dark();
let _a11y = a11y::PopupA11y::attach(&tray.menu);
let dispatch = |id: &MenuId| tray.dispatch(id);
let _ = x11::open_popup_session(
tray.menu.clone(),
tray.options.clone(),
&dispatch,
anchor,
Edge::Bottom,
dark,
);
}
fn run_sni_loop(tray: Tray, report: &super::InstallReport) -> Result<()> {
let commands = Arc::clone(&tray.commands);
let waker_slot = Arc::clone(&tray.waker);
let handle = match (MuriSni {
tray,
visible: true,
presenter: detect_linux_presenter(),
}
.spawn())
{
Ok(handle) => {
let _ = report.send(Ok(()));
handle
}
Err(e) => {
let err =
Error::TrayInstall(format!("SNI/StatusNotifierItem registration failed: {e}"));
let _ = report.send(Err(Error::TrayInstall(format!(
"SNI/StatusNotifierItem registration failed: {e}"
))));
return Err(err);
}
};
let wake: Arc<(Mutex<bool>, Condvar)> = Arc::new((Mutex::new(false), Condvar::new()));
{
let wake = Arc::clone(&wake);
if let Ok(mut slot) = waker_slot.lock() {
*slot = Some(Box::new(move || {
let (lock, cvar) = &*wake;
if let Ok(mut flag) = lock.lock() {
*flag = true;
}
cvar.notify_all();
}));
}
}
loop {
let pending: Vec<TrayCommand> = commands
.lock()
.map(|mut q| std::mem::take(&mut *q))
.unwrap_or_default();
for command in pending {
if matches!(command, TrayCommand::Shutdown) {
handle.shutdown().wait();
return Ok(());
}
apply_command(&handle, command);
}
if handle.is_closed() {
break;
}
let (lock, cvar) = &*wake;
let Ok(mut flag) = lock.lock() else { break };
while !*flag {
let (guard, timeout) = cvar
.wait_timeout(flag, Duration::from_millis(500))
.unwrap_or_else(|e| e.into_inner());
flag = guard;
if timeout.timed_out() {
break;
}
}
*flag = false;
}
Ok(())
}
fn apply_command(handle: &Handle<MuriSni>, command: TrayCommand) {
match command {
TrayCommand::SetMenu(menu) => {
handle.update(move |s| s.tray.menu = menu);
}
TrayCommand::SetIcon(icon) => {
handle.update(move |s| s.tray.icon = icon);
}
TrayCommand::SetTooltip(tooltip) => {
handle.update(move |s| s.tray.tooltip = tooltip);
}
TrayCommand::SetTitle(title) => {
handle.update(move |s| s.tray.title = title);
}
TrayCommand::SetVisible(visible) => {
handle.update(move |s| s.visible = visible);
}
TrayCommand::Open | TrayCommand::Close => {}
TrayCommand::Shutdown => debug_assert!(
false,
"TrayCommand::Shutdown must be intercepted by run_sni_loop's drain, \
not routed through apply_command"
),
TrayCommand::SetTheme(theme) => drop(theme),
TrayCommand::SetOptions(options) => drop(options),
TrayCommand::QueryAnchorRect(reply) => {
let _ = reply.send(None);
}
}
}
fn system_menu_font() -> Option<crate::platform::SystemFont> {
use crate::platform::{SystemFont, SystemFontSource};
const STYLES: &[&str] = &[
"Bold",
"Italic",
"Oblique",
"Light",
"Medium",
"Regular",
"Thin",
"Black",
"Semilight",
"Semibold",
"Heavy",
"Condensed",
];
let out = std::process::Command::new("gsettings")
.args(["get", "org.gnome.desktop.interface", "font-name"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let raw = String::from_utf8(out.stdout).ok()?;
let spec = raw.trim().trim_matches(['\'', '"']).trim();
let mut parts: Vec<&str> = spec.split_whitespace().collect();
let point_size = parts.last().and_then(|s| s.parse::<f32>().ok());
if point_size.is_some() {
parts.pop();
}
while parts
.last()
.is_some_and(|w| STYLES.iter().any(|s| s.eq_ignore_ascii_case(w)))
{
parts.pop();
}
let family = parts.join(" ");
if family.is_empty() {
return None;
}
Some(SystemFont {
source: SystemFontSource::Family(family),
point_size: point_size.unwrap_or(0.0),
})
}
#[cfg(feature = "x11-popup")]
pub(super) fn system_accent() -> Option<(u8, u8, u8, u8)> {
let out = std::process::Command::new("gsettings")
.args(["get", "org.gnome.desktop.interface", "accent-color"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let raw = String::from_utf8(out.stdout).ok()?;
let (r, g, b) = match raw.trim().trim_matches(['\'', '"']).trim() {
"blue" => (0x35, 0x84, 0xe4),
"teal" => (0x21, 0x90, 0xa4),
"green" => (0x3a, 0x94, 0x4a),
"yellow" => (0xc8, 0x88, 0x00),
"orange" => (0xed, 0x5b, 0x00),
"red" => (0xe6, 0x2d, 0x42),
"pink" => (0xd5, 0x61, 0x99),
"purple" => (0x91, 0x41, 0xac),
"slate" => (0x6f, 0x83, 0x96),
_ => return None,
};
Some((r, g, b, 255))
}
fn system_appearance() -> Appearance {
portal_color_scheme_is_dark()
.map(Appearance::from_is_dark)
.unwrap_or(Appearance::Light)
}
fn portal_color_scheme_is_dark() -> Option<bool> {
use zbus::blocking::Connection;
use zbus::zvariant::Value;
fn scheme_is_dark(value: &Value) -> Option<bool> {
match value {
Value::U32(n) => Some(*n == 1),
Value::Value(inner) => scheme_is_dark(inner),
_ => None,
}
}
let connection = Connection::session().ok()?;
let reply = connection
.call_method(
Some("org.freedesktop.portal.Desktop"),
"/org/freedesktop/portal/desktop",
Some("org.freedesktop.portal.Settings"),
"ReadOne",
&("org.freedesktop.appearance", "color-scheme"),
)
.ok()?;
let body = reply.body();
let value: Value = body.deserialize().ok()?;
scheme_is_dark(&value)
}