use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use futures_lite::{StreamExt as _, future};
use tracing::{debug, warn};
use zbus::blocking::Connection;
use zbus::blocking::connection::Builder;
use zbus::proxy;
use super::{FrontmostSource, PublishAppId, RECONNECT_DELAY, StopToken, lock_unpoisoned};
const METHOD_TIMEOUT: Duration = Duration::from_secs(5);
#[proxy(
interface = "org.openlogi.Frontmost",
default_service = "org.openlogi.Frontmost",
default_path = "/org/openlogi/Frontmost",
gen_blocking = true
)]
trait Frontmost {
#[zbus(name = "GetFocusedWmClass")]
fn get_focused_wm_class(&self) -> zbus::Result<String>;
#[zbus(signal, name = "FocusedWmClassChanged")]
fn focused_wm_class_changed(&self, wm_class: &str) -> zbus::Result<()>;
}
struct GnomeShellSource {
conn: Option<Connection>,
}
impl GnomeShellSource {
fn connect_bus() -> Option<Connection> {
Builder::session()
.map_err(|e| debug!("gnome-shell: no session bus: {e}"))
.ok()?
.method_timeout(METHOD_TIMEOUT)
.build()
.map_err(|e| debug!("gnome-shell: connection build failed: {e}"))
.ok()
}
fn connect() -> Option<Self> {
let conn = Self::connect_bus()?;
Self::snapshot(&conn)
.map_err(|e| debug!("gnome-shell: OpenLogi extension not reachable: {e}"))
.ok()?;
Some(Self { conn: Some(conn) })
}
fn snapshot(conn: &Connection) -> zbus::Result<Option<String>> {
let proxy = FrontmostProxyBlocking::new(conn)?;
proxy.get_focused_wm_class().map(app_id)
}
async fn observe_connection(
conn: &Connection,
stop: &StopToken,
publish: &PublishAppId,
) -> ConnectionOutcome {
let proxy = match FrontmostProxy::new(conn.inner()).await {
Ok(proxy) => proxy,
Err(error) => {
debug!("gnome-shell: async proxy build failed: {error}");
return ConnectionOutcome::Disconnected;
}
};
let mut focus_changes = match proxy.receive_focused_wm_class_changed().await {
Ok(changes) => changes,
Err(error) => {
debug!("gnome-shell: focus-signal subscription failed: {error}");
return ConnectionOutcome::Disconnected;
}
};
let mut owner_changes = match proxy.inner().receive_owner_changed().await {
Ok(changes) => changes,
Err(error) => {
debug!("gnome-shell: owner-change subscription failed: {error}");
return ConnectionOutcome::Disconnected;
}
};
match proxy.get_focused_wm_class().await {
Ok(wm_class) => publish(app_id(wm_class)),
Err(error) => {
debug!("gnome-shell: initial snapshot failed: {error}");
return ConnectionOutcome::Disconnected;
}
}
loop {
if stop.is_requested() {
return ConnectionOutcome::Stopped;
}
let event = future::race(
async { GnomeEvent::Focus(focus_changes.next().await) },
async { GnomeEvent::Owner(owner_changes.next().await) },
)
.await;
match event {
GnomeEvent::Focus(Some(signal)) => match signal.args() {
Ok(args) => publish(app_id(args.wm_class().to_string())),
Err(error) => warn!("gnome-shell: malformed focus signal: {error}"),
},
GnomeEvent::Owner(Some(Some(_))) => {
match proxy.get_focused_wm_class().await {
Ok(wm_class) => publish(app_id(wm_class)),
Err(error) => debug!("gnome-shell: recovery snapshot failed: {error}"),
}
}
GnomeEvent::Owner(Some(None)) => publish(None),
GnomeEvent::Focus(None) | GnomeEvent::Owner(None) => {
return if stop.is_requested() {
ConnectionOutcome::Stopped
} else {
ConnectionOutcome::Disconnected
};
}
}
}
}
}
fn app_id(wm_class: String) -> Option<String> {
(!wm_class.is_empty()).then_some(wm_class)
}
enum GnomeEvent<T, U> {
Focus(Option<T>),
Owner(Option<U>),
}
enum ConnectionOutcome {
Stopped,
Disconnected,
}
impl FrontmostSource for GnomeShellSource {
fn frontmost_app_id(&mut self) -> Option<String> {
if self.conn.is_none() {
self.conn = Self::connect_bus();
}
let result = Self::snapshot(self.conn.as_ref()?)
.map_err(|e| debug!("gnome-shell: snapshot failed: {e}"));
if result.is_err() {
self.conn = None;
}
result.ok().flatten()
}
fn observe(
mut self: Box<Self>,
stop: StopToken,
publish: PublishAppId,
) -> Box<dyn FrontmostSource> {
let active_connection = Arc::new(Mutex::new(None::<Connection>));
let stop_state = stop.state();
let connection_to_close = Arc::clone(&active_connection);
let stopper = thread::Builder::new()
.name("openlogi-frontmost-gnome-stop".into())
.spawn(move || {
stop_state.wait();
if let Some(conn) = lock_unpoisoned(&connection_to_close).take()
&& let Err(error) = conn.close()
{
debug!("gnome-shell: failed to close observer connection: {error}");
}
})
.unwrap_or_else(|error| panic!("failed to start GNOME stop helper: {error}"));
loop {
if stop.is_requested() {
break;
}
if self.conn.is_none() {
self.conn = Self::connect_bus();
}
let Some(conn) = self.conn.as_ref() else {
publish(None);
if stop.wait_timeout(RECONNECT_DELAY) {
break;
}
continue;
};
*lock_unpoisoned(&active_connection) = Some(conn.clone());
let outcome =
futures_lite::future::block_on(Self::observe_connection(conn, &stop, &publish));
lock_unpoisoned(&active_connection).take();
self.conn = None;
match outcome {
ConnectionOutcome::Stopped => break,
ConnectionOutcome::Disconnected => {
publish(None);
if stop.wait_timeout(RECONNECT_DELAY) {
break;
}
}
}
}
if let Err(panic) = stopper.join() {
warn!("gnome-shell: stop helper panicked: {panic:?}");
}
self.conn = None;
self
}
fn name(&self) -> &'static str {
"gnome-shell"
}
}
pub(super) fn candidate() -> Option<Box<dyn FrontmostSource>> {
GnomeShellSource::connect().map(|s| Box::new(s) as Box<dyn FrontmostSource>)
}
#[cfg(test)]
mod tests {
use super::app_id;
#[test]
fn empty_wm_class_represents_no_focused_application() {
assert_eq!(app_id(String::new()), None);
assert_eq!(
app_id("org.example.App".into()).as_deref(),
Some("org.example.App")
);
}
}