Skip to main content

selection_capture/
rich_engine.rs

1use crate::engine::{capture, try_capture};
2#[cfg(all(feature = "linux-alpha", target_os = "linux"))]
3use crate::linux::try_selected_rtf_by_atspi;
4#[cfg(target_os = "macos")]
5use crate::macos::try_selected_rtf_by_ax;
6use crate::rich_clipboard::{RichClipboardPayload, RichClipboardReader, SystemRichClipboardReader};
7use crate::rich_convert::convert_to_markdown;
8use crate::rich_types::{
9    CaptureRichOptions, CaptureRichOutcome, CaptureRichSuccess, CapturedContent, ContentMetadata,
10    RichConversion, RichPayload, RichSource,
11};
12use crate::traits::{AppAdapter, AppProfileStore, CancelSignal, CapturePlatform};
13use crate::types::{ActiveApp, CaptureOutcome, CaptureTrace, TraceEvent, WouldBlock};
14#[cfg(all(feature = "windows-beta", target_os = "windows"))]
15use crate::windows::try_selected_rtf_by_uia;
16use std::time::{SystemTime, UNIX_EPOCH};
17
18pub fn capture_rich(
19    platform: &impl CapturePlatform,
20    store: &impl AppProfileStore,
21    cancel: &impl CancelSignal,
22    adapters: &[&dyn AppAdapter],
23    options: &CaptureRichOptions,
24) -> CaptureRichOutcome {
25    let reader = SystemRichClipboardReader;
26    capture_rich_with_reader(platform, store, cancel, adapters, options, &reader)
27}
28
29pub fn try_capture_rich(
30    platform: &impl CapturePlatform,
31    store: &impl AppProfileStore,
32    cancel: &impl CancelSignal,
33    adapters: &[&dyn AppAdapter],
34    options: &CaptureRichOptions,
35) -> Result<CaptureRichOutcome, WouldBlock> {
36    let reader = SystemRichClipboardReader;
37    try_capture_rich_with_reader(platform, store, cancel, adapters, options, &reader)
38}
39
40fn capture_rich_with_reader(
41    platform: &impl CapturePlatform,
42    store: &impl AppProfileStore,
43    cancel: &impl CancelSignal,
44    adapters: &[&dyn AppAdapter],
45    options: &CaptureRichOptions,
46    reader: &impl RichClipboardReader,
47) -> CaptureRichOutcome {
48    capture_rich_with_reader_and_direct_reader(
49        platform,
50        store,
51        cancel,
52        adapters,
53        options,
54        reader,
55        &read_direct_rtf_for_current_selection,
56    )
57}
58
59fn capture_rich_with_reader_and_direct_reader(
60    platform: &impl CapturePlatform,
61    store: &impl AppProfileStore,
62    cancel: &impl CancelSignal,
63    adapters: &[&dyn AppAdapter],
64    options: &CaptureRichOptions,
65    reader: &impl RichClipboardReader,
66    direct_rtf_reader: &impl Fn() -> Option<String>,
67) -> CaptureRichOutcome {
68    let outcome = capture(platform, store, cancel, adapters, &options.base);
69    enrich_capture_outcome(platform, outcome, options, reader, direct_rtf_reader)
70}
71
72fn try_capture_rich_with_reader(
73    platform: &impl CapturePlatform,
74    store: &impl AppProfileStore,
75    cancel: &impl CancelSignal,
76    adapters: &[&dyn AppAdapter],
77    options: &CaptureRichOptions,
78    reader: &impl RichClipboardReader,
79) -> Result<CaptureRichOutcome, WouldBlock> {
80    try_capture_rich_with_reader_and_direct_reader(
81        platform,
82        store,
83        cancel,
84        adapters,
85        options,
86        reader,
87        &read_direct_rtf_for_current_selection,
88    )
89}
90
91fn try_capture_rich_with_reader_and_direct_reader(
92    platform: &impl CapturePlatform,
93    store: &impl AppProfileStore,
94    cancel: &impl CancelSignal,
95    adapters: &[&dyn AppAdapter],
96    options: &CaptureRichOptions,
97    reader: &impl RichClipboardReader,
98    direct_rtf_reader: &impl Fn() -> Option<String>,
99) -> Result<CaptureRichOutcome, WouldBlock> {
100    let outcome = try_capture(platform, store, cancel, adapters, &options.base)?;
101    Ok(enrich_capture_outcome(
102        platform,
103        outcome,
104        options,
105        reader,
106        direct_rtf_reader,
107    ))
108}
109
110fn enrich_capture_outcome(
111    platform: &impl CapturePlatform,
112    outcome: CaptureOutcome,
113    options: &CaptureRichOptions,
114    reader: &impl RichClipboardReader,
115    _direct_rtf_reader: &impl Fn() -> Option<String>,
116) -> CaptureRichOutcome {
117    match outcome {
118        CaptureOutcome::Failure(failure) => CaptureRichOutcome::Failure(failure),
119        CaptureOutcome::Success(success) => {
120            let plain_text = success.text;
121
122            #[cfg(target_os = "macos")]
123            if options.allow_direct_accessibility_rich && success.method.is_ax() {
124                if let Some(rtf) = _direct_rtf_reader() {
125                    if rtf.len() <= options.max_rich_payload_bytes {
126                        let markdown = maybe_convert_to_markdown(
127                            options,
128                            None,
129                            Some(rtf.as_str()),
130                            &plain_text,
131                        );
132                        let metadata = ContentMetadata {
133                            active_app: detect_active_app(success.trace.as_ref())
134                                .or_else(|| platform.active_app()),
135                            method: success.method,
136                            source: RichSource::AccessibilityAttributed,
137                            captured_at_unix_ms: unix_epoch_millis(),
138                            plain_text_hash: hash_text(&plain_text),
139                        };
140
141                        return CaptureRichOutcome::Success(CaptureRichSuccess {
142                            content: CapturedContent::Rich(RichPayload {
143                                plain_text: plain_text.clone(),
144                                html: None,
145                                rtf: Some(rtf),
146                                markdown,
147                                metadata,
148                            }),
149                            method: success.method,
150                            trace: success.trace,
151                        });
152                    }
153                }
154            }
155
156            if !options.prefer_rich || !options.allow_clipboard_rich {
157                return CaptureRichOutcome::Success(CaptureRichSuccess {
158                    content: CapturedContent::Plain(plain_text),
159                    method: success.method,
160                    trace: success.trace,
161                });
162            }
163
164            let Some(payload) = reader.read() else {
165                return CaptureRichOutcome::Success(CaptureRichSuccess {
166                    content: CapturedContent::Plain(plain_text),
167                    method: success.method,
168                    trace: success.trace,
169                });
170            };
171
172            if exceeds_payload_limit(&payload, options.max_rich_payload_bytes) {
173                return CaptureRichOutcome::Success(CaptureRichSuccess {
174                    content: CapturedContent::Plain(plain_text),
175                    method: success.method,
176                    trace: success.trace,
177                });
178            }
179
180            if options.require_plain_text_match
181                && !clipboard_plain_text_matches(payload.plain_text.as_deref(), &plain_text)
182            {
183                return CaptureRichOutcome::Success(CaptureRichSuccess {
184                    content: CapturedContent::Plain(plain_text),
185                    method: success.method,
186                    trace: success.trace,
187                });
188            }
189
190            let source = if payload.html.is_some() {
191                RichSource::ClipboardHtml
192            } else if payload.rtf.is_some() {
193                RichSource::ClipboardRtf
194            } else {
195                return CaptureRichOutcome::Success(CaptureRichSuccess {
196                    content: CapturedContent::Plain(plain_text),
197                    method: success.method,
198                    trace: success.trace,
199                });
200            };
201
202            let metadata = ContentMetadata {
203                active_app: detect_active_app(success.trace.as_ref())
204                    .or_else(|| platform.active_app()),
205                method: success.method,
206                source,
207                captured_at_unix_ms: unix_epoch_millis(),
208                plain_text_hash: hash_text(&plain_text),
209            };
210
211            let html = payload.html;
212            let rtf = payload.rtf;
213            let markdown =
214                maybe_convert_to_markdown(options, html.as_deref(), rtf.as_deref(), &plain_text);
215
216            let rich_payload = RichPayload {
217                plain_text: plain_text.clone(),
218                html,
219                rtf,
220                markdown,
221                metadata,
222            };
223
224            CaptureRichOutcome::Success(CaptureRichSuccess {
225                content: CapturedContent::Rich(rich_payload),
226                method: success.method,
227                trace: success.trace,
228            })
229        }
230    }
231}
232
233#[cfg(target_os = "macos")]
234fn read_direct_rtf_for_current_selection() -> Option<String> {
235    try_selected_rtf_by_ax()
236}
237
238#[cfg(all(feature = "windows-beta", target_os = "windows"))]
239fn read_direct_rtf_for_current_selection() -> Option<String> {
240    try_selected_rtf_by_uia()
241}
242
243#[cfg(all(feature = "linux-alpha", target_os = "linux"))]
244fn read_direct_rtf_for_current_selection() -> Option<String> {
245    try_selected_rtf_by_atspi()
246}
247
248#[cfg(not(any(
249    target_os = "macos",
250    all(feature = "windows-beta", target_os = "windows"),
251    all(feature = "linux-alpha", target_os = "linux")
252)))]
253fn read_direct_rtf_for_current_selection() -> Option<String> {
254    None
255}
256
257fn maybe_convert_to_markdown(
258    options: &CaptureRichOptions,
259    html: Option<&str>,
260    rtf: Option<&str>,
261    plain_text: &str,
262) -> Option<String> {
263    match options.conversion {
264        Some(RichConversion::Markdown) => convert_to_markdown(html, rtf, plain_text),
265        None => None,
266    }
267}
268
269fn exceeds_payload_limit(payload: &RichClipboardPayload, max_bytes: usize) -> bool {
270    payload
271        .html
272        .as_ref()
273        .is_some_and(|value| value.len() > max_bytes)
274        || payload
275            .rtf
276            .as_ref()
277            .is_some_and(|value| value.len() > max_bytes)
278}
279
280fn clipboard_plain_text_matches(clipboard_text: Option<&str>, plain_text: &str) -> bool {
281    let Some(clipboard_text) = clipboard_text else {
282        return false;
283    };
284    normalize_for_match(clipboard_text) == normalize_for_match(plain_text)
285}
286
287fn normalize_for_match(input: &str) -> String {
288    let normalized_line_endings = input.replace("\r\n", "\n").replace('\r', "\n");
289    normalized_line_endings.trim_end_matches('\n').to_string()
290}
291
292fn detect_active_app(trace: Option<&CaptureTrace>) -> Option<ActiveApp> {
293    trace.and_then(|trace| {
294        trace.events.iter().find_map(|event| match event {
295            TraceEvent::ActiveAppDetected(app) => Some(app.clone()),
296            _ => None,
297        })
298    })
299}
300
301fn unix_epoch_millis() -> u128 {
302    match SystemTime::now().duration_since(UNIX_EPOCH) {
303        Ok(duration) => duration.as_millis(),
304        Err(_) => 0,
305    }
306}
307
308/// FNV-1a 64-bit hash — deterministic and stable across process restarts and Rust versions.
309/// Unlike `DefaultHasher`, this produces identical output for the same input in every run,
310/// making it safe for deduplication, change-detection, and persistent comparison.
311fn hash_text(text: &str) -> u64 {
312    const FNV_OFFSET: u64 = 14_695_981_039_346_656_037;
313    const FNV_PRIME: u64 = 1_099_511_628_211;
314    let mut hash = FNV_OFFSET;
315    for byte in text.as_bytes() {
316        hash ^= *byte as u64;
317        hash = hash.wrapping_mul(FNV_PRIME);
318    }
319    hash
320}
321
322#[cfg(test)]
323#[path = "rich_engine_tests.rs"]
324mod tests;