Skip to main content

selection_capture/
engine.rs

1use crate::cache::{prioritize_profile_method, record_method_outcome};
2use crate::profile::AppProfileUpdate;
3use crate::traits::{AppAdapter, AppProfileStore, CancelSignal, CapturePlatform};
4use crate::types::{
5    default_method_order, status_from_failure_kind, update_for_method_result, ActiveApp,
6    CaptureFailure, CaptureFailureContext, CaptureMethod, CaptureOptions, CaptureOutcome,
7    CaptureStatus, CaptureSuccess, CaptureTrace, CleanupStatus, FailureKind, PlatformAttemptResult,
8    TraceEvent, UserHint, WouldBlock,
9};
10use std::thread;
11use std::time::{Duration, Instant};
12
13#[derive(Clone, Debug)]
14struct ScheduledAttempt {
15    method: CaptureMethod,
16    delays: Vec<Duration>,
17    next_attempt_idx: usize,
18    next_due: Instant,
19    order: usize,
20}
21
22pub fn capture(
23    platform: &impl CapturePlatform,
24    store: &impl AppProfileStore,
25    cancel: &impl CancelSignal,
26    adapters: &[&dyn AppAdapter],
27    options: &CaptureOptions,
28) -> CaptureOutcome {
29    let start = Instant::now();
30    let deadline = start + options.overall_timeout;
31    let focused_window_frame_snapshot = platform.focused_window_frame();
32
33    let mut trace = if options.collect_trace {
34        Some(CaptureTrace::default())
35    } else {
36        None
37    };
38    push_trace(&mut trace, TraceEvent::CaptureStarted);
39
40    let active_app = platform.active_app();
41    if let Some(app) = active_app.clone() {
42        push_trace(&mut trace, TraceEvent::ActiveAppDetected(app));
43    }
44
45    let methods = resolve_methods(store, active_app.as_ref(), adapters, options);
46    let mut methods_tried = Vec::new();
47    let mut last_failure: Option<FailureKind> = None;
48
49    let mut schedule = build_capture_schedule(&methods, options, start);
50    while !schedule.is_empty() {
51        if cancel.is_cancelled() {
52            push_trace(&mut trace, TraceEvent::Cancelled);
53            return finish_failure(
54                platform,
55                trace,
56                CaptureStatus::Cancelled,
57                None,
58                active_app.clone(),
59                methods_tried,
60                None,
61                false,
62                start,
63            );
64        }
65
66        let now = Instant::now();
67        if now >= deadline {
68            push_trace(&mut trace, TraceEvent::TimedOut);
69            return finish_failure(
70                platform,
71                trace,
72                CaptureStatus::TimedOut,
73                None,
74                active_app.clone(),
75                methods_tried,
76                None,
77                false,
78                start,
79            );
80        }
81
82        let Some(next_index) =
83            select_next_scheduled_attempt(&schedule, options.interleave_method_retries)
84        else {
85            break;
86        };
87        let next = &schedule[next_index];
88        if now < next.next_due {
89            let wait = next.next_due.saturating_duration_since(now);
90            let remaining = deadline.saturating_duration_since(now);
91            if remaining < wait {
92                push_trace(
93                    &mut trace,
94                    TraceEvent::RetryWaitSkipped {
95                        method: next.method,
96                        remaining_budget: remaining,
97                        needed_delay: wait,
98                    },
99                );
100                break;
101            }
102
103            push_trace(
104                &mut trace,
105                TraceEvent::RetryWaitStarted {
106                    method: next.method,
107                    delay: wait,
108                },
109            );
110            if wait_with_polling(wait, deadline, cancel, options.retry_policy.poll_interval) {
111                push_trace(&mut trace, TraceEvent::Cancelled);
112                return finish_failure(
113                    platform,
114                    trace,
115                    CaptureStatus::Cancelled,
116                    None,
117                    active_app.clone(),
118                    methods_tried,
119                    None,
120                    false,
121                    start,
122                );
123            }
124            continue;
125        }
126
127        let method = schedule[next_index].method;
128        methods_tried.push(method);
129        push_trace(&mut trace, TraceEvent::MethodStarted(method));
130        let attempt_started_at = Instant::now();
131        let result = platform.attempt(method, active_app.as_ref());
132        push_trace(
133            &mut trace,
134            TraceEvent::MethodFinished {
135                method,
136                elapsed: attempt_started_at.elapsed(),
137            },
138        );
139        store_profile_update(store, active_app.as_ref(), method, &result);
140
141        if let PlatformAttemptResult::Success(text) = result {
142            push_trace(&mut trace, TraceEvent::MethodSucceeded(method));
143            return finish_success(
144                platform,
145                trace,
146                text,
147                method,
148                start,
149                focused_window_frame_snapshot,
150            );
151        }
152        if let Some(kind) = record_attempt_failure(&mut trace, method, &result) {
153            last_failure = Some(kind);
154        }
155
156        schedule[next_index].next_attempt_idx += 1;
157        let next_attempt_idx = schedule[next_index].next_attempt_idx;
158        if next_attempt_idx >= schedule[next_index].delays.len() {
159            schedule.remove(next_index);
160            continue;
161        }
162        schedule[next_index].next_due =
163            Instant::now() + schedule[next_index].delays[next_attempt_idx];
164    }
165
166    let status = last_failure
167        .map(status_from_failure_kind)
168        .unwrap_or(CaptureStatus::StrategyExhausted);
169
170    finish_failure(
171        platform,
172        trace,
173        status,
174        None,
175        active_app,
176        methods_tried,
177        None,
178        false,
179        start,
180    )
181}
182
183pub fn try_capture(
184    platform: &impl CapturePlatform,
185    store: &impl AppProfileStore,
186    cancel: &impl CancelSignal,
187    adapters: &[&dyn AppAdapter],
188    options: &CaptureOptions,
189) -> Result<CaptureOutcome, WouldBlock> {
190    let start = Instant::now();
191    let deadline = start + options.overall_timeout;
192    let focused_window_frame_snapshot = platform.focused_window_frame();
193
194    let mut trace = if options.collect_trace {
195        Some(CaptureTrace::default())
196    } else {
197        None
198    };
199    push_trace(&mut trace, TraceEvent::CaptureStarted);
200
201    let active_app = platform.active_app();
202    if let Some(app) = active_app.clone() {
203        push_trace(&mut trace, TraceEvent::ActiveAppDetected(app));
204    }
205
206    let methods = resolve_methods(store, active_app.as_ref(), adapters, options);
207    let mut methods_tried = Vec::new();
208    let mut last_failure: Option<FailureKind> = None;
209    let mut would_block = false;
210
211    for method in methods {
212        if cancel.is_cancelled() {
213            push_trace(&mut trace, TraceEvent::Cancelled);
214            return Ok(finish_failure(
215                platform,
216                trace,
217                CaptureStatus::Cancelled,
218                None,
219                active_app.clone(),
220                methods_tried,
221                None,
222                false,
223                start,
224            ));
225        }
226
227        if Instant::now() >= deadline {
228            push_trace(&mut trace, TraceEvent::TimedOut);
229            return Ok(finish_failure(
230                platform,
231                trace,
232                CaptureStatus::TimedOut,
233                None,
234                active_app.clone(),
235                methods_tried,
236                None,
237                false,
238                start,
239            ));
240        }
241
242        let delays = method.retry_delays(&options.retry_policy);
243        if delays.is_empty() {
244            continue;
245        }
246
247        if delays[0] > Duration::ZERO {
248            would_block = true;
249            continue;
250        }
251
252        methods_tried.push(method);
253        push_trace(&mut trace, TraceEvent::MethodStarted(method));
254        let attempt_started_at = Instant::now();
255        let result = platform.attempt(method, active_app.as_ref());
256        push_trace(
257            &mut trace,
258            TraceEvent::MethodFinished {
259                method,
260                elapsed: attempt_started_at.elapsed(),
261            },
262        );
263        store_profile_update(store, active_app.as_ref(), method, &result);
264
265        if let PlatformAttemptResult::Success(text) = result {
266            push_trace(&mut trace, TraceEvent::MethodSucceeded(method));
267            return Ok(finish_success(
268                platform,
269                trace,
270                text,
271                method,
272                start,
273                focused_window_frame_snapshot,
274            ));
275        }
276        if let Some(kind) = record_attempt_failure(&mut trace, method, &result) {
277            last_failure = Some(kind);
278        }
279
280        if delays.len() > 1 {
281            would_block = true;
282        }
283    }
284
285    if would_block {
286        return Err(WouldBlock);
287    }
288
289    let status = last_failure
290        .map(status_from_failure_kind)
291        .unwrap_or(CaptureStatus::StrategyExhausted);
292    Ok(finish_failure(
293        platform,
294        trace,
295        status,
296        None,
297        active_app,
298        methods_tried,
299        None,
300        false,
301        start,
302    ))
303}
304
305fn resolve_methods(
306    store: &impl AppProfileStore,
307    active_app: Option<&ActiveApp>,
308    adapters: &[&dyn AppAdapter],
309    options: &CaptureOptions,
310) -> Vec<CaptureMethod> {
311    if let Some(methods) = &options.strategy_override {
312        return methods.clone();
313    }
314    if let Some(app) = active_app {
315        for adapter in adapters {
316            if adapter.matches(app) {
317                if let Some(methods) = adapter.strategy_override(app) {
318                    return methods;
319                }
320            }
321        }
322
323        let profile = store.load(app);
324        return prioritize_profile_method(
325            default_method_order(options.allow_clipboard_borrow),
326            Some(&profile),
327        );
328    }
329
330    default_method_order(options.allow_clipboard_borrow)
331}
332
333fn store_profile_update(
334    store: &impl AppProfileStore,
335    active_app: Option<&ActiveApp>,
336    method: CaptureMethod,
337    result: &PlatformAttemptResult,
338) {
339    if let Some(app) = active_app {
340        record_method_outcome(&app.bundle_id, method, result);
341        let update: AppProfileUpdate = update_for_method_result(method, result);
342        store.merge_update(app, update);
343    }
344}
345
346/// Records trace events for a non-success attempt result and returns the resulting
347/// `FailureKind` if one applies. Returns `None` for `Unavailable` (no state change).
348/// Does NOT handle the `Success` variant — callers must check that first.
349fn record_attempt_failure(
350    trace: &mut Option<CaptureTrace>,
351    method: CaptureMethod,
352    result: &PlatformAttemptResult,
353) -> Option<FailureKind> {
354    match result {
355        PlatformAttemptResult::EmptySelection => {
356            push_trace(trace, TraceEvent::MethodReturnedEmpty(method));
357            Some(FailureKind::EmptySelection)
358        }
359        PlatformAttemptResult::PermissionDenied => {
360            push_trace(
361                trace,
362                TraceEvent::MethodFailed {
363                    method,
364                    kind: FailureKind::PermissionDenied,
365                },
366            );
367            Some(FailureKind::PermissionDenied)
368        }
369        PlatformAttemptResult::AppBlocked => {
370            push_trace(
371                trace,
372                TraceEvent::MethodFailed {
373                    method,
374                    kind: FailureKind::AppBlocked,
375                },
376            );
377            Some(FailureKind::AppBlocked)
378        }
379        PlatformAttemptResult::ClipboardBorrowAmbiguous => {
380            push_trace(
381                trace,
382                TraceEvent::MethodFailed {
383                    method,
384                    kind: FailureKind::ClipboardAmbiguous,
385                },
386            );
387            Some(FailureKind::ClipboardAmbiguous)
388        }
389        PlatformAttemptResult::Unavailable | PlatformAttemptResult::Success(_) => None,
390    }
391}
392
393fn build_capture_schedule(
394    methods: &[CaptureMethod],
395    options: &CaptureOptions,
396    start: Instant,
397) -> Vec<ScheduledAttempt> {
398    let mut schedule = Vec::new();
399    for (order, method) in methods.iter().copied().enumerate() {
400        let delays = method.retry_delays(&options.retry_policy);
401        if delays.is_empty() {
402            continue;
403        }
404        schedule.push(ScheduledAttempt {
405            method,
406            delays: delays.to_vec(),
407            next_attempt_idx: 0,
408            next_due: start,
409            order,
410        });
411    }
412    schedule
413}
414
415fn select_next_scheduled_attempt(
416    schedule: &[ScheduledAttempt],
417    interleave_method_retries: bool,
418) -> Option<usize> {
419    if !interleave_method_retries {
420        return schedule
421            .iter()
422            .enumerate()
423            .min_by_key(|(_, attempt)| attempt.order)
424            .map(|(index, _)| index);
425    }
426    schedule
427        .iter()
428        .enumerate()
429        .min_by_key(|(_, attempt)| (attempt.next_due, attempt.order))
430        .map(|(index, _)| index)
431}
432
433fn finish_success(
434    platform: &impl CapturePlatform,
435    mut trace: Option<CaptureTrace>,
436    text: String,
437    method: CaptureMethod,
438    started_at: Instant,
439    focused_window_frame_snapshot: Option<crate::types::CGRect>,
440) -> CaptureOutcome {
441    let cleanup_status = platform.cleanup();
442    finalize_trace(&mut trace, cleanup_status, started_at.elapsed());
443    CaptureOutcome::Success(CaptureSuccess {
444        text,
445        method,
446        focused_window_frame: focused_window_frame_snapshot
447            .or_else(|| platform.focused_window_frame()),
448        trace,
449    })
450}
451
452#[allow(clippy::too_many_arguments)]
453fn finish_failure(
454    platform: &impl CapturePlatform,
455    mut trace: Option<CaptureTrace>,
456    status: CaptureStatus,
457    hint: Option<UserHint>,
458    active_app: Option<ActiveApp>,
459    methods_tried: Vec<CaptureMethod>,
460    last_method: Option<CaptureMethod>,
461    cleanup_failed: bool,
462    started_at: Instant,
463) -> CaptureOutcome {
464    let cleanup_status = platform.cleanup();
465    let cleanup_failed = cleanup_failed || cleanup_status == CleanupStatus::ClipboardRestoreFailed;
466    finalize_trace(&mut trace, cleanup_status, started_at.elapsed());
467
468    CaptureOutcome::Failure(CaptureFailure {
469        status,
470        hint,
471        trace,
472        cleanup_failed,
473        context: CaptureFailureContext {
474            status,
475            active_app,
476            methods_tried,
477            last_method,
478        },
479    })
480}
481
482fn push_trace(trace: &mut Option<CaptureTrace>, event: TraceEvent) {
483    if let Some(trace) = trace.as_mut() {
484        trace.events.push(event);
485    }
486}
487
488fn finalize_trace(
489    trace: &mut Option<CaptureTrace>,
490    status: CleanupStatus,
491    total_elapsed: Duration,
492) {
493    if let Some(trace) = trace.as_mut() {
494        trace.cleanup_status = status;
495        trace.total_elapsed = total_elapsed;
496        trace.events.push(TraceEvent::CleanupFinished(status));
497    }
498}
499
500fn wait_with_polling(
501    total: Duration,
502    deadline: Instant,
503    cancel: &impl CancelSignal,
504    poll_interval: Duration,
505) -> bool {
506    let start = Instant::now();
507    while start.elapsed() < total {
508        if cancel.is_cancelled() {
509            return true;
510        }
511        let now = Instant::now();
512        if now >= deadline {
513            return false;
514        }
515        let remaining_delay = total.saturating_sub(start.elapsed());
516        let remaining_budget = deadline.saturating_duration_since(now);
517        let step = min_duration(
518            min_duration(remaining_delay, remaining_budget),
519            poll_interval,
520        );
521        if step.is_zero() {
522            return false;
523        }
524        thread::sleep(step);
525    }
526    cancel.is_cancelled()
527}
528
529fn min_duration(a: Duration, b: Duration) -> Duration {
530    if a <= b {
531        a
532    } else {
533        b
534    }
535}
536
537#[cfg(test)]
538#[path = "engine_tests.rs"]
539mod tests;