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;