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
346fn 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;