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