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, 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;