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