Skip to main content

aethermap_gui/
focus_tracker.rs

1//! Focus Tracker for Wayland window focus detection
2//!
3//! This module provides window focus tracking via xdg-desktop-portal.
4//! Focus changes are detected and can be used to trigger profile switching.
5
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::Arc;
8use tokio::sync::mpsc;
9use serde::{Deserialize, Serialize};
10
11/// A window focus change event
12///
13/// Contains information about the application that gained focus.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct FocusEvent {
16    /// Application identifier (e.g., "org.alacritty", "firefox")
17    /// This is the primary identifier for profile matching.
18    pub app_id: String,
19    /// Optional window title
20    /// May be empty on some compositors due to Wayland security restrictions.
21    pub window_title: Option<String>,
22}
23
24impl FocusEvent {
25    /// Create a new focus event
26    pub fn new(app_id: impl Into<String>, window_title: Option<String>) -> Self {
27        Self {
28            app_id: app_id.into(),
29            window_title,
30        }
31    }
32
33    /// Create a focus event with only app_id
34    pub fn from_app_id(app_id: impl Into<String>) -> Self {
35        Self {
36            app_id: app_id.into(),
37            window_title: None,
38        }
39    }
40
41    /// Check if this event matches a given app_id pattern
42    pub fn matches(&self, pattern: &str) -> bool {
43        if pattern == "*" {
44            return true;
45        }
46
47        // Exact match
48        if self.app_id == pattern {
49            return true;
50        }
51
52        // Suffix match (e.g., ".firefox" matches "org.mozilla.firefox")
53        if pattern.starts_with('.') {
54            return self.app_id.ends_with(pattern);
55        }
56
57        // Prefix match (e.g., "org.mozilla." matches "org.mozilla.firefox")
58        if pattern.ends_with('.') {
59            return self.app_id.starts_with(pattern);
60        }
61
62        false
63    }
64}
65
66/// Focus tracker using xdg-desktop-portal
67///
68/// This tracker monitors window focus changes on Wayland compositors
69/// via the xdg-desktop-portal API. When the portal is unavailable,
70/// it gracefully degrades and provides a no-op implementation.
71pub struct FocusTracker {
72    /// Portal connection (None when unavailable)
73    portal: Option<Arc<FocusPortal>>,
74    /// Whether the tracker is currently running
75    running: Arc<AtomicBool>,
76}
77
78/// Internal portal wrapper for ashpd integration
79#[allow(dead_code)]
80struct FocusPortal {
81    /// Whether portal is available
82    available: bool,
83    /// Portal backend identifier (for logging/debugging)
84    backend: String,
85}
86
87impl FocusPortal {
88    /// Initialize the portal connection
89    ///
90    /// Returns None if xdg-desktop-portal is not available or
91    /// running on a non-Wayland session.
92    async fn try_new() -> Option<Self> {
93        // Check if we're running on Wayland
94        if std::env::var("WAYLAND_DISPLAY").is_err() {
95            tracing::warn!("Not running on Wayland, focus tracking unavailable");
96            return None;
97        }
98
99        // Check if xdg-desktop-portal is running
100        match ashpd::desktop::global_shortcuts::GlobalShortcuts::new().await {
101            Ok(_) => {
102                tracing::info!("Successfully connected to xdg-desktop-portal");
103                Some(Self {
104                    available: true,
105                    backend: "xdg-desktop-portal".to_string(),
106                })
107            }
108            Err(e) => {
109                tracing::warn!("Failed to connect to xdg-desktop-portal: {}", e);
110                tracing::warn!("Focus tracking will be unavailable");
111                None
112            }
113        }
114    }
115}
116
117impl FocusTracker {
118    /// Create a new focus tracker
119    ///
120    /// Attempts to initialize the portal connection. If the portal
121    /// is unavailable, returns a tracker that will gracefully handle
122    /// all operations as no-ops.
123    pub async fn new() -> Self {
124        let portal = tokio::task::spawn(async {
125            FocusPortal::try_new().await
126        })
127        .await
128        .ok()
129        .and_then(|r| r);
130
131        Self {
132            portal: portal.map(Arc::new),
133            running: Arc::new(AtomicBool::new(false)),
134        }
135    }
136
137    /// Check if the portal is available
138    pub fn is_available(&self) -> bool {
139        self.portal.is_some()
140    }
141
142    /// Start tracking focus changes
143    ///
144    /// Spawns an async task that listens for focus change events
145    /// and sends them to the provided channel.
146    ///
147    /// # Arguments
148    ///
149    /// * `tx` - Channel to send focus events to
150    ///
151    /// # Returns
152    ///
153    /// Returns `Ok(())` if tracking started successfully, or an error
154    /// if tracking is already in progress or the portal is unavailable.
155    pub async fn start(&self, _tx: mpsc::Sender<FocusEvent>) -> Result<(), String> {
156        if self.running.swap(true, Ordering::SeqCst) {
157            return Err("Focus tracking is already running".to_string());
158        }
159
160        let running = self.running.clone();
161        let _portal = self.portal.clone();
162
163        tokio::spawn(async move {
164            tracing::info!("Focus tracking task started");
165
166            if _portal.is_some() {
167                // Run focus monitoring loop
168                while running.load(Ordering::SeqCst) {
169                    // In a full implementation, we would:
170                    // 1. Register for focus change notifications via portal
171                    // 2. Listen for events
172                    // 3. Extract app_id and send to channel
173
174                    // For now, we provide a placeholder that
175                    // simulates the structure for future enhancement
176                    tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
177                }
178            } else {
179                tracing::warn!("Focus tracking portal unavailable, task exiting");
180            }
181
182            tracing::info!("Focus tracking task stopped");
183        });
184
185        Ok(())
186    }
187
188    /// Stop tracking focus changes
189    ///
190    /// Signals the tracking task to stop. The task will exit
191    /// on its next iteration.
192    pub fn stop(&self) {
193        self.running.store(false, Ordering::SeqCst);
194    }
195
196    /// Check if tracking is currently running
197    pub fn is_running(&self) -> bool {
198        self.running.load(Ordering::SeqCst)
199    }
200}
201
202impl Default for FocusTracker {
203    fn default() -> Self {
204        // Create a runtime for the async new() call
205        let rt = tokio::runtime::Runtime::new()
206            .expect("Failed to create tokio runtime");
207
208        rt.block_on(Self::new())
209    }
210}
211
212/// Start focus tracking with a callback
213///
214/// Convenience function that creates a tracker and starts it,
215/// invoking the callback for each focus event.
216///
217/// # Arguments
218///
219/// * `callback` - Function to call with each focus event
220///
221/// # Returns
222///
223/// Returns the tracker and a handle that can be used to stop tracking.
224pub async fn start_focus_tracking<F>(
225    callback: F,
226) -> Result<(FocusTracker, tokio::task::JoinHandle<()>), String>
227where
228    F: Fn(FocusEvent) + Send + 'static,
229{
230    let tracker = FocusTracker::new().await;
231
232    if !tracker.is_available() {
233        return Err("Focus tracking portal unavailable".to_string());
234    }
235
236    let (tx, mut rx) = mpsc::channel(32);
237    tracker.start(tx).await?;
238
239    let handle = tokio::spawn(async move {
240        while let Some(event) = rx.recv().await {
241            callback(event);
242        }
243    });
244
245    Ok((tracker, handle))
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn test_focus_event_creation() {
254        let event = FocusEvent::new("org.alacritty".to_string(), Some("Alacritty".to_string()));
255        assert_eq!(event.app_id, "org.alacritty");
256        assert_eq!(event.window_title, Some("Alacritty".to_string()));
257    }
258
259    #[test]
260    fn test_focus_event_from_app_id() {
261        let event = FocusEvent::from_app_id("firefox");
262        assert_eq!(event.app_id, "firefox");
263        assert_eq!(event.window_title, None);
264    }
265
266    #[test]
267    fn test_focus_event_matches_exact() {
268        let event = FocusEvent::from_app_id("org.alacritty");
269        assert!(event.matches("org.alacritty"));
270        assert!(!event.matches("org.mozilla.firefox"));
271    }
272
273    #[test]
274    fn test_focus_event_matches_wildcard() {
275        let event = FocusEvent::from_app_id("org.alacritty");
276        assert!(event.matches("*"));
277    }
278
279    #[test]
280    fn test_focus_event_matches_suffix() {
281        let event = FocusEvent::from_app_id("org.mozilla.firefox");
282        assert!(event.matches(".firefox"));
283        assert!(event.matches(".mozilla.firefox"));
284        assert!(!event.matches(".alacritty"));
285    }
286
287    #[test]
288    fn test_focus_event_matches_prefix() {
289        let event = FocusEvent::from_app_id("org.mozilla.firefox");
290        assert!(event.matches("org.mozilla."));
291        assert!(event.matches("org."));
292        assert!(!event.matches("com."));
293    }
294
295    #[tokio::test]
296    async fn test_focus_tracker_creation() {
297        let tracker = FocusTracker::new().await;
298        // Tracker should always create successfully, even if portal unavailable
299        assert!(!tracker.is_running());
300    }
301
302    #[test]
303    fn test_focus_tracker_default() {
304        let tracker = FocusTracker::default();
305        assert!(!tracker.is_running());
306    }
307}