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