Skip to main content

selection_capture/
linux.rs

1use crate::linux_observer::{
2    drain_events_for_monitor as linux_observer_drain_events_for_monitor, LinuxObserverBridge,
3};
4use crate::linux_runtime_adapter::install_default_linux_runtime_adapter_if_absent;
5#[cfg(target_os = "linux")]
6use crate::linux_shell::LinuxCommandSpec;
7#[cfg(test)]
8use crate::linux_shell::LinuxSession;
9#[cfg(any(target_os = "linux", test))]
10use crate::linux_shell::{
11    clipboard_command_plan, detect_linux_session, primary_selection_command_plan,
12};
13use crate::linux_subscriber::ensure_linux_native_subscriber_hook_installed;
14#[cfg(all(feature = "rich-content", target_os = "linux"))]
15use crate::rich_convert::plain_text_to_minimal_rtf;
16use crate::traits::{CapturePlatform, MonitorPlatform};
17use crate::types::{ActiveApp, CGRect, CaptureMethod, CleanupStatus, PlatformAttemptResult};
18#[cfg(target_os = "linux")]
19use crate::types::{CGPoint, CGSize};
20use std::collections::VecDeque;
21#[cfg(target_os = "linux")]
22use std::env;
23#[cfg(target_os = "linux")]
24use std::process::Command;
25use std::sync::Mutex;
26use std::time::Duration;
27
28#[derive(Debug, Default)]
29pub struct LinuxPlatform;
30
31pub struct LinuxSelectionMonitor {
32    last_emitted: Mutex<Option<String>>,
33    native_event_queue: Mutex<VecDeque<String>>,
34    native_events_dropped: Mutex<u64>,
35    native_queue_capacity: usize,
36    poll_interval: Duration,
37    backend: LinuxMonitorBackend,
38    native_observer_attached: bool,
39    native_event_pump: Option<LinuxNativeEventPump>,
40}
41
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum LinuxMonitorBackend {
44    Polling,
45    NativeEventPreferred,
46}
47
48#[derive(Clone, Copy, Debug)]
49pub struct LinuxSelectionMonitorOptions {
50    pub poll_interval: Duration,
51    pub backend: LinuxMonitorBackend,
52    pub native_queue_capacity: usize,
53    pub native_event_pump: Option<LinuxNativeEventPump>,
54}
55
56pub type LinuxNativeEventPump = fn() -> Vec<String>;
57
58trait LinuxBackend {
59    fn attempt_atspi(&self) -> PlatformAttemptResult;
60    fn attempt_x11_selection(&self) -> PlatformAttemptResult;
61    fn attempt_clipboard(&self) -> PlatformAttemptResult;
62}
63
64#[derive(Debug, Default)]
65struct DefaultLinuxBackend;
66
67impl LinuxBackend for DefaultLinuxBackend {
68    fn attempt_atspi(&self) -> PlatformAttemptResult {
69        #[cfg(target_os = "linux")]
70        {
71            match read_atspi_text() {
72                Ok(Some(text)) => {
73                    let trimmed = text.trim();
74                    if trimmed.is_empty() {
75                        PlatformAttemptResult::EmptySelection
76                    } else {
77                        PlatformAttemptResult::Success(trimmed.to_string())
78                    }
79                }
80                Ok(None) => PlatformAttemptResult::EmptySelection,
81                Err(_) => PlatformAttemptResult::Unavailable,
82            }
83        }
84        #[cfg(not(target_os = "linux"))]
85        {
86            PlatformAttemptResult::Unavailable
87        }
88    }
89
90    fn attempt_x11_selection(&self) -> PlatformAttemptResult {
91        #[cfg(target_os = "linux")]
92        {
93            match read_primary_selection_text() {
94                Ok(Some(text)) => {
95                    let trimmed = text.trim();
96                    if trimmed.is_empty() {
97                        PlatformAttemptResult::EmptySelection
98                    } else {
99                        PlatformAttemptResult::Success(trimmed.to_string())
100                    }
101                }
102                Ok(None) => PlatformAttemptResult::EmptySelection,
103                Err(_) => PlatformAttemptResult::Unavailable,
104            }
105        }
106        #[cfg(not(target_os = "linux"))]
107        {
108            PlatformAttemptResult::Unavailable
109        }
110    }
111
112    fn attempt_clipboard(&self) -> PlatformAttemptResult {
113        #[cfg(target_os = "linux")]
114        {
115            match read_clipboard_text() {
116                Ok(Some(text)) => {
117                    let trimmed = text.trim();
118                    if trimmed.is_empty() {
119                        PlatformAttemptResult::EmptySelection
120                    } else {
121                        PlatformAttemptResult::Success(trimmed.to_string())
122                    }
123                }
124                Ok(None) => PlatformAttemptResult::EmptySelection,
125                Err(_) => PlatformAttemptResult::Unavailable,
126            }
127        }
128        #[cfg(not(target_os = "linux"))]
129        {
130            PlatformAttemptResult::Unavailable
131        }
132    }
133}
134
135impl LinuxPlatform {
136    pub fn new() -> Self {
137        Self
138    }
139
140    pub fn attempt_atspi(&self) -> PlatformAttemptResult {
141        self.backend().attempt_atspi()
142    }
143
144    pub fn attempt_x11_selection(&self) -> PlatformAttemptResult {
145        self.backend().attempt_x11_selection()
146    }
147
148    pub fn attempt_clipboard(&self) -> PlatformAttemptResult {
149        self.backend().attempt_clipboard()
150    }
151
152    fn backend(&self) -> DefaultLinuxBackend {
153        DefaultLinuxBackend
154    }
155
156    fn dispatch_attempt<B: LinuxBackend>(
157        backend: &B,
158        method: CaptureMethod,
159    ) -> PlatformAttemptResult {
160        match method {
161            CaptureMethod::AccessibilityPrimary => backend.attempt_atspi(),
162            CaptureMethod::AccessibilityRange => backend.attempt_x11_selection(),
163            CaptureMethod::ClipboardBorrow | CaptureMethod::SyntheticCopy => {
164                backend.attempt_clipboard()
165            }
166        }
167    }
168}
169
170impl Default for LinuxSelectionMonitor {
171    fn default() -> Self {
172        Self::new_with_options(LinuxSelectionMonitorOptions::default())
173    }
174}
175
176impl Default for LinuxSelectionMonitorOptions {
177    fn default() -> Self {
178        Self {
179            poll_interval: Duration::from_millis(120),
180            backend: LinuxMonitorBackend::Polling,
181            native_queue_capacity: 256,
182            native_event_pump: None,
183        }
184    }
185}
186
187impl LinuxSelectionMonitor {
188    pub fn new(poll_interval: Duration) -> Self {
189        Self::new_with_options(LinuxSelectionMonitorOptions {
190            poll_interval,
191            backend: LinuxMonitorBackend::Polling,
192            native_queue_capacity: 256,
193            native_event_pump: None,
194        })
195    }
196
197    pub fn new_with_options(options: LinuxSelectionMonitorOptions) -> Self {
198        if matches!(options.backend, LinuxMonitorBackend::NativeEventPreferred) {
199            install_default_linux_runtime_adapter_if_absent();
200            ensure_linux_native_subscriber_hook_installed();
201        }
202        let native_observer_attached =
203            matches!(options.backend, LinuxMonitorBackend::NativeEventPreferred)
204                && LinuxObserverBridge::acquire();
205        let native_event_pump = if native_observer_attached {
206            options
207                .native_event_pump
208                .or(Some(linux_observer_drain_events_for_monitor))
209        } else {
210            options.native_event_pump
211        };
212
213        Self {
214            last_emitted: Mutex::new(None),
215            native_event_queue: Mutex::new(VecDeque::new()),
216            native_events_dropped: Mutex::new(0),
217            native_queue_capacity: options.native_queue_capacity.max(1),
218            poll_interval: options.poll_interval,
219            backend: options.backend,
220            native_observer_attached,
221            native_event_pump,
222        }
223    }
224
225    pub fn backend(&self) -> LinuxMonitorBackend {
226        self.backend
227    }
228
229    pub fn poll_interval(&self) -> Duration {
230        self.poll_interval
231    }
232
233    pub fn enqueue_native_selection_event<T>(&self, text: T) -> bool
234    where
235        T: Into<String>,
236    {
237        let text = text.into();
238        let trimmed = text.trim();
239        if trimmed.is_empty() {
240            return false;
241        }
242        if let Ok(mut queue) = self.native_event_queue.lock() {
243            if queue.back().map(|s| s == trimmed).unwrap_or(false) {
244                return false;
245            }
246            if queue.len() >= self.native_queue_capacity {
247                queue.pop_front();
248                if let Ok(mut dropped) = self.native_events_dropped.lock() {
249                    *dropped += 1;
250                }
251            }
252            queue.push_back(trimmed.to_string());
253            return true;
254        }
255        false
256    }
257
258    pub fn enqueue_native_selection_events<I, T>(&self, events: I) -> usize
259    where
260        I: IntoIterator<Item = T>,
261        T: Into<String>,
262    {
263        let mut accepted = 0usize;
264        for event in events {
265            if self.enqueue_native_selection_event(event.into()) {
266                accepted += 1;
267            }
268        }
269        accepted
270    }
271
272    pub fn native_queue_depth(&self) -> usize {
273        self.native_event_queue
274            .lock()
275            .map(|queue| queue.len())
276            .unwrap_or(0)
277    }
278
279    pub fn native_events_dropped(&self) -> u64 {
280        self.native_events_dropped
281            .lock()
282            .map(|dropped| *dropped)
283            .unwrap_or(0)
284    }
285
286    pub fn poll_native_event_pump_once(&self) -> usize {
287        let Some(pump) = self.native_event_pump else {
288            return 0;
289        };
290        self.enqueue_native_selection_events(pump())
291    }
292
293    fn next_selection_text(&self) -> Option<String> {
294        if matches!(self.backend, LinuxMonitorBackend::NativeEventPreferred) {
295            let _ = self.poll_native_event_pump_once();
296            if let Some(next) = self.native_event_queue.lock().ok()?.pop_front() {
297                return self.emit_if_new(next);
298            }
299        }
300        let next = self.read_selection_text()?;
301        self.emit_if_new(next)
302    }
303
304    fn emit_if_new(&self, next: String) -> Option<String> {
305        let mut last = self.last_emitted.lock().ok()?;
306        if last.as_ref() == Some(&next) {
307            return None;
308        }
309        *last = Some(next.clone());
310        Some(next)
311    }
312
313    fn read_selection_text(&self) -> Option<String> {
314        #[cfg(target_os = "linux")]
315        {
316            let atspi = read_atspi_text().ok().flatten();
317            if let Some(next) = atspi {
318                let trimmed = next.trim();
319                if !trimmed.is_empty() {
320                    return Some(trimmed.to_string());
321                }
322            }
323
324            let primary = read_primary_selection_text().ok().flatten();
325            if let Some(next) = primary {
326                let trimmed = next.trim();
327                if !trimmed.is_empty() {
328                    return Some(trimmed.to_string());
329                }
330            }
331            None
332        }
333        #[cfg(not(target_os = "linux"))]
334        {
335            None
336        }
337    }
338}
339
340impl CapturePlatform for LinuxPlatform {
341    fn active_app(&self) -> Option<ActiveApp> {
342        #[cfg(target_os = "linux")]
343        {
344            read_active_app().ok().flatten()
345        }
346        #[cfg(not(target_os = "linux"))]
347        {
348            None
349        }
350    }
351
352    fn focused_window_frame(&self) -> Option<CGRect> {
353        #[cfg(target_os = "linux")]
354        {
355            read_focused_window_frame().ok().flatten()
356        }
357        #[cfg(not(target_os = "linux"))]
358        {
359            None
360        }
361    }
362
363    fn attempt(&self, method: CaptureMethod, _app: Option<&ActiveApp>) -> PlatformAttemptResult {
364        Self::dispatch_attempt(&self.backend(), method)
365    }
366
367    fn cleanup(&self) -> CleanupStatus {
368        CleanupStatus::Clean
369    }
370}
371
372impl MonitorPlatform for LinuxSelectionMonitor {
373    fn next_selection_change(&self) -> Option<String> {
374        self.next_selection_text()
375    }
376}
377
378impl Drop for LinuxSelectionMonitor {
379    fn drop(&mut self) {
380        if self.native_observer_attached {
381            let _ = LinuxObserverBridge::release();
382        }
383    }
384}
385
386#[cfg(target_os = "linux")]
387fn read_clipboard_text() -> Result<Option<String>, String> {
388    let session = detect_linux_session(
389        env::var("WAYLAND_DISPLAY").ok().as_deref(),
390        env::var("DISPLAY").ok().as_deref(),
391    );
392    try_linux_text_commands(clipboard_command_plan(session))
393}
394
395#[cfg(target_os = "linux")]
396fn read_primary_selection_text() -> Result<Option<String>, String> {
397    let session = detect_linux_session(
398        env::var("WAYLAND_DISPLAY").ok().as_deref(),
399        env::var("DISPLAY").ok().as_deref(),
400    );
401    try_linux_text_commands(primary_selection_command_plan(session))
402}
403
404#[cfg(target_os = "linux")]
405fn try_linux_text_commands(commands: &[LinuxCommandSpec]) -> Result<Option<String>, String> {
406    let mut errors = Vec::new();
407
408    for command in commands {
409        let output = match Command::new(command.program).args(command.args).output() {
410            Ok(output) => output,
411            Err(err) => {
412                errors.push(format!("{}: {err}", command.program));
413                continue;
414            }
415        };
416
417        if !output.status.success() {
418            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
419            errors.push(format!("{}: {stderr}", command.program));
420            continue;
421        }
422
423        let stdout = String::from_utf8(output.stdout).map_err(|err| err.to_string())?;
424        return Ok(normalize_linux_text_stdout(&stdout));
425    }
426
427    Err(errors.join("; "))
428}
429
430#[cfg(target_os = "linux")]
431fn normalize_linux_text_stdout(stdout: &str) -> Option<String> {
432    let text = stdout.replace("\r\n", "\n");
433    let normalized = text.trim_end_matches(['\r', '\n']);
434    if normalized.is_empty() {
435        None
436    } else {
437        Some(normalized.to_string())
438    }
439}
440
441#[cfg(target_os = "linux")]
442fn read_atspi_text() -> Result<Option<String>, String> {
443    let script = r#"
444import re
445import subprocess
446import sys
447
448def call(cmd):
449    proc = subprocess.run(cmd, capture_output=True, text=True)
450    if proc.returncode != 0:
451        raise RuntimeError((proc.stderr or proc.stdout).strip())
452    return proc.stdout.strip()
453
454def parse_address(output):
455    match = re.search(r"'([^']+)'", output)
456    return match.group(1) if match else None
457
458def parse_reference(output):
459    match = re.search(r"\('([^']+)'\s*,\s*objectpath\s*'([^']+)'\)", output)
460    if not match:
461        match = re.search(r"\('([^']+)'\s*,\s*'([^']+)'\)", output)
462    if not match:
463        return None, None
464    return match.group(1), match.group(2)
465
466def parse_int(output):
467    match = re.search(r"(-?\d+)", output)
468    return int(match.group(1)) if match else None
469
470def parse_text(output):
471    match = re.search(r"\('((?:\\'|[^'])*)',\)", output)
472    if not match:
473        return None
474    return match.group(1).replace("\\\\", "\\").replace("\\'", "'")
475
476try:
477    addr_out = call([
478        "gdbus", "call",
479        "--session",
480        "--dest", "org.a11y.Bus",
481        "--object-path", "/org/a11y/bus",
482        "--method", "org.a11y.Bus.GetAddress",
483    ])
484    address = parse_address(addr_out)
485    if not address:
486        print("")
487        sys.exit(0)
488
489    active_out = call([
490        "gdbus", "call",
491        "--address", address,
492        "--dest", "org.a11y.atspi.Registry",
493        "--object-path", "/org/a11y/atspi/accessible/root",
494        "--method", "org.a11y.atspi.Collection.GetActiveDescendant",
495    ])
496    bus, path = parse_reference(active_out)
497    if not bus or not path or path == "/org/a11y/atspi/null":
498        print("")
499        sys.exit(0)
500
501    nsel = 0
502    try:
503        nsel_out = call([
504            "gdbus", "call",
505            "--address", address,
506            "--dest", bus,
507            "--object-path", path,
508            "--method", "org.a11y.atspi.Text.GetNSelections",
509        ])
510        nsel = parse_int(nsel_out) or 0
511    except Exception:
512        nsel = 0
513
514    if nsel > 0:
515        selection_out = call([
516            "gdbus", "call",
517            "--address", address,
518            "--dest", bus,
519            "--object-path", path,
520            "--method", "org.a11y.atspi.Text.GetSelection",
521            "0",
522        ])
523        bounds = re.findall(r"(-?\d+)", selection_out)
524        if len(bounds) >= 2:
525            start = int(bounds[0])
526            end = int(bounds[1])
527            if end > start:
528                selected_out = call([
529                    "gdbus", "call",
530                    "--address", address,
531                    "--dest", bus,
532                    "--object-path", path,
533                    "--method", "org.a11y.atspi.Text.GetText",
534                    str(start),
535                    str(end),
536                ])
537                selected_text = parse_text(selected_out)
538                if selected_text and selected_text.strip():
539                    print(selected_text)
540                    sys.exit(0)
541
542    try:
543        all_text_out = call([
544            "gdbus", "call",
545            "--address", address,
546            "--dest", bus,
547            "--object-path", path,
548            "--method", "org.a11y.atspi.Text.GetText",
549            "0",
550            "-1",
551        ])
552        all_text = parse_text(all_text_out)
553        if all_text and all_text.strip():
554            print(all_text)
555            sys.exit(0)
556    except Exception:
557        pass
558
559    try:
560        name_out = call([
561            "gdbus", "call",
562            "--address", address,
563            "--dest", bus,
564            "--object-path", path,
565            "--method", "org.freedesktop.DBus.Properties.Get",
566            "org.a11y.atspi.Accessible",
567            "Name",
568        ])
569        name = parse_text(name_out)
570        if name and name.strip():
571            print(name)
572            sys.exit(0)
573    except Exception:
574        pass
575
576    print("")
577except Exception as err:
578    sys.stderr.write(str(err))
579    sys.exit(1)
580"#;
581
582    let output = Command::new("python3")
583        .args(["-c", script])
584        .output()
585        .map_err(|err| err.to_string())?;
586
587    if !output.status.success() {
588        return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
589    }
590
591    let stdout = String::from_utf8(output.stdout).map_err(|err| err.to_string())?;
592    Ok(normalize_linux_text_stdout(&stdout))
593}
594
595pub(crate) fn linux_default_runtime_event_source() -> Option<String> {
596    #[cfg(target_os = "linux")]
597    {
598        read_atspi_text().ok().flatten()
599    }
600    #[cfg(not(target_os = "linux"))]
601    {
602        None
603    }
604}
605
606#[cfg(all(feature = "rich-content", target_os = "linux"))]
607pub(crate) fn try_selected_rtf_by_atspi() -> Option<String> {
608    let text = read_atspi_text().ok().flatten()?;
609    let trimmed = text.trim();
610    if trimmed.is_empty() {
611        None
612    } else {
613        Some(plain_text_to_minimal_rtf(trimmed))
614    }
615}
616
617#[cfg(target_os = "linux")]
618fn read_active_app() -> Result<Option<ActiveApp>, String> {
619    let pid = read_active_window_pid()?;
620    let name = read_process_name(pid)?;
621    let bundle_id =
622        read_process_exe_path(pid)?.unwrap_or_else(|| format!("process://{}", name.to_lowercase()));
623
624    Ok(Some(ActiveApp { bundle_id, name }))
625}
626
627#[cfg(target_os = "linux")]
628fn read_active_window_pid() -> Result<u32, String> {
629    let output = Command::new("xdotool")
630        .args(["getactivewindow", "getwindowpid"])
631        .output()
632        .map_err(|err| err.to_string())?;
633
634    if !output.status.success() {
635        return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
636    }
637
638    let stdout = String::from_utf8(output.stdout).map_err(|err| err.to_string())?;
639    let pid = stdout
640        .trim()
641        .parse::<u32>()
642        .map_err(|err| err.to_string())?;
643    Ok(pid)
644}
645
646#[cfg(target_os = "linux")]
647fn read_process_name(pid: u32) -> Result<String, String> {
648    let pid_arg = pid.to_string();
649    let output = Command::new("ps")
650        .args(["-p", pid_arg.as_str(), "-o", "comm="])
651        .output()
652        .map_err(|err| err.to_string())?;
653
654    if !output.status.success() {
655        return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
656    }
657
658    let stdout = String::from_utf8(output.stdout).map_err(|err| err.to_string())?;
659    let name = stdout.trim();
660    if name.is_empty() {
661        return Err("empty process name".to_string());
662    }
663    Ok(name.to_string())
664}
665
666#[cfg(target_os = "linux")]
667fn read_process_exe_path(pid: u32) -> Result<Option<String>, String> {
668    let exe_link = format!("/proc/{pid}/exe");
669    let output = Command::new("readlink")
670        .arg(exe_link)
671        .output()
672        .map_err(|err| err.to_string())?;
673
674    if !output.status.success() {
675        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
676        if stderr.is_empty() {
677            return Ok(None);
678        }
679        return Err(stderr);
680    }
681
682    let stdout = String::from_utf8(output.stdout).map_err(|err| err.to_string())?;
683    let path = stdout.trim();
684    if path.is_empty() {
685        Ok(None)
686    } else {
687        Ok(Some(path.to_string()))
688    }
689}
690
691#[cfg(target_os = "linux")]
692fn read_focused_window_frame() -> Result<Option<CGRect>, String> {
693    if let Ok(frame) = read_focused_window_frame_by_xdotool() {
694        return Ok(frame);
695    }
696    read_focused_window_frame_by_atspi()
697}
698
699#[cfg(target_os = "linux")]
700fn read_focused_window_frame_by_xdotool() -> Result<Option<CGRect>, String> {
701    let output = Command::new("xdotool")
702        .args(["getactivewindow", "getwindowgeometry", "--shell"])
703        .output()
704        .map_err(|err| err.to_string())?;
705
706    if !output.status.success() {
707        return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
708    }
709
710    let stdout = String::from_utf8(output.stdout).map_err(|err| err.to_string())?;
711    let mut x = None;
712    let mut y = None;
713    let mut width = None;
714    let mut height = None;
715
716    for line in stdout.lines() {
717        if let Some((key, value)) = line.split_once('=') {
718            match key.trim() {
719                "X" => x = value.trim().parse::<f64>().ok(),
720                "Y" => y = value.trim().parse::<f64>().ok(),
721                "WIDTH" => width = value.trim().parse::<f64>().ok(),
722                "HEIGHT" => height = value.trim().parse::<f64>().ok(),
723                _ => {}
724            }
725        }
726    }
727
728    match (x, y, width, height) {
729        (Some(x), Some(y), Some(width), Some(height)) if width > 0.0 && height > 0.0 => {
730            Ok(Some(CGRect {
731                origin: CGPoint { x, y },
732                size: CGSize { width, height },
733            }))
734        }
735        _ => Ok(None),
736    }
737}
738
739#[cfg(target_os = "linux")]
740fn read_focused_window_frame_by_atspi() -> Result<Option<CGRect>, String> {
741    let script = r#"
742import re
743import subprocess
744import sys
745
746def call(cmd):
747    proc = subprocess.run(cmd, capture_output=True, text=True)
748    if proc.returncode != 0:
749        raise RuntimeError((proc.stderr or proc.stdout).strip())
750    return proc.stdout.strip()
751
752def parse_address(output):
753    match = re.search(r"'([^']+)'", output)
754    return match.group(1) if match else None
755
756def parse_reference(output):
757    match = re.search(r"\('([^']+)'\s*,\s*objectpath\s*'([^']+)'\)", output)
758    if not match:
759        match = re.search(r"\('([^']+)'\s*,\s*'([^']+)'\)", output)
760    if not match:
761        return None, None
762    return match.group(1), match.group(2)
763
764def parse_extents(output):
765    vals = [int(v) for v in re.findall(r"-?\d+", output)]
766    if len(vals) >= 4:
767        return vals[0], vals[1], vals[2], vals[3]
768    return None
769
770try:
771    addr_out = call([
772        "gdbus", "call",
773        "--session",
774        "--dest", "org.a11y.Bus",
775        "--object-path", "/org/a11y/bus",
776        "--method", "org.a11y.Bus.GetAddress",
777    ])
778    address = parse_address(addr_out)
779    if not address:
780        print("")
781        sys.exit(0)
782
783    active_out = call([
784        "gdbus", "call",
785        "--address", address,
786        "--dest", "org.a11y.atspi.Registry",
787        "--object-path", "/org/a11y/atspi/accessible/root",
788        "--method", "org.a11y.atspi.Collection.GetActiveDescendant",
789    ])
790    bus, path = parse_reference(active_out)
791    if not bus or not path or path == "/org/a11y/atspi/null":
792        print("")
793        sys.exit(0)
794
795    # CoordType::Screen = 0
796    extents_out = call([
797        "gdbus", "call",
798        "--address", address,
799        "--dest", bus,
800        "--object-path", path,
801        "--method", "org.a11y.atspi.Component.GetExtents",
802        "0",
803    ])
804    extents = parse_extents(extents_out)
805    if not extents:
806        print("")
807        sys.exit(0)
808    x, y, w, h = extents
809    if w <= 0 or h <= 0:
810        print("")
811        sys.exit(0)
812    print(f"{x},{y},{w},{h}")
813except Exception as err:
814    sys.stderr.write(str(err))
815    sys.exit(1)
816"#;
817
818    let output = Command::new("python3")
819        .args(["-c", script])
820        .output()
821        .map_err(|err| err.to_string())?;
822
823    if !output.status.success() {
824        return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
825    }
826
827    let stdout = String::from_utf8(output.stdout).map_err(|err| err.to_string())?;
828    parse_linux_rect_line(&stdout)
829}
830
831#[cfg(target_os = "linux")]
832fn parse_linux_rect_line(stdout: &str) -> Result<Option<CGRect>, String> {
833    let line = stdout.trim();
834    if line.is_empty() {
835        return Ok(None);
836    }
837    let parts = line.split(',').map(str::trim).collect::<Vec<_>>();
838    if parts.len() != 4 {
839        return Ok(None);
840    }
841
842    let x = parts[0].parse::<f64>().map_err(|err| err.to_string())?;
843    let y = parts[1].parse::<f64>().map_err(|err| err.to_string())?;
844    let width = parts[2].parse::<f64>().map_err(|err| err.to_string())?;
845    let height = parts[3].parse::<f64>().map_err(|err| err.to_string())?;
846
847    if width <= 0.0 || height <= 0.0 {
848        return Ok(None);
849    }
850
851    Ok(Some(CGRect {
852        origin: CGPoint { x, y },
853        size: CGSize { width, height },
854    }))
855}
856
857#[cfg(test)]
858#[path = "linux_tests.rs"]
859mod tests;