1use crate::profile::TriState;
2use std::time::Duration;
3
4#[cfg(target_os = "macos")]
5pub use core_graphics_types::geometry::{CGPoint, CGRect, CGSize};
6
7#[cfg(not(target_os = "macos"))]
8#[derive(Clone, Copy, Debug, Default, PartialEq)]
9pub struct CGPoint {
10 pub x: f64,
11 pub y: f64,
12}
13
14#[cfg(not(target_os = "macos"))]
15#[derive(Clone, Copy, Debug, Default, PartialEq)]
16pub struct CGSize {
17 pub width: f64,
18 pub height: f64,
19}
20
21#[cfg(not(target_os = "macos"))]
22#[derive(Clone, Copy, Debug, Default, PartialEq)]
23pub struct CGRect {
24 pub origin: CGPoint,
25 pub size: CGSize,
26}
27
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct ActiveApp {
30 pub bundle_id: String,
31 pub name: String,
32}
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum CaptureMethod {
36 AccessibilityPrimary,
37 AccessibilityRange,
38 ClipboardBorrow,
39 SyntheticCopy,
40}
41
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum CaptureStatus {
44 EmptySelection,
45 PermissionDenied,
46 AppBlocked,
47 ClipboardBorrowAmbiguous,
48 StrategyExhausted,
49 TimedOut,
50 Cancelled,
51}
52
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub enum FailureKind {
55 PermissionDenied,
56 AppBlocked,
57 EmptySelection,
58 ClipboardAmbiguous,
59 TimedOut,
60 Cancelled,
61}
62
63#[derive(Clone, Copy, Debug, PartialEq, Eq)]
64pub enum CleanupStatus {
65 Clean,
66 ClipboardRestoreFailed,
67}
68
69#[derive(Clone, Copy, Debug, PartialEq, Eq)]
70pub enum UserHint {
71 GrantAccessibilityPermission,
72 GrantAutomationPermission,
73 TryManualCopy,
74 AppBlocksDirectCapture,
75 RetryInFocusedApp,
76}
77
78#[derive(Clone, Debug, PartialEq, Eq)]
79pub struct RetryPolicy {
80 pub primary_accessibility: Vec<Duration>,
81 pub range_accessibility: Vec<Duration>,
82 pub clipboard: Vec<Duration>,
83 pub poll_interval: Duration,
84}
85
86impl Default for RetryPolicy {
87 fn default() -> Self {
88 Self {
89 primary_accessibility: vec![Duration::from_millis(0), Duration::from_millis(60)],
90 range_accessibility: vec![Duration::from_millis(0)],
91 clipboard: vec![Duration::from_millis(120), Duration::from_millis(220)],
92 poll_interval: Duration::from_millis(20),
93 }
94 }
95}
96
97#[derive(Clone, Debug, PartialEq, Eq)]
98pub struct CaptureOptions {
99 pub allow_clipboard_borrow: bool,
100 pub retry_policy: RetryPolicy,
101 pub interleave_method_retries: bool,
102 pub collect_trace: bool,
103 pub overall_timeout: Duration,
104 pub strategy_override: Option<Vec<CaptureMethod>>,
105}
106
107impl Default for CaptureOptions {
108 fn default() -> Self {
109 Self {
110 allow_clipboard_borrow: true,
111 retry_policy: RetryPolicy::default(),
112 interleave_method_retries: true,
113 collect_trace: false,
114 overall_timeout: Duration::from_millis(500),
115 strategy_override: None,
116 }
117 }
118}
119
120#[derive(Clone, Debug, PartialEq, Eq)]
121pub enum TraceEvent {
122 CaptureStarted,
123 ActiveAppDetected(ActiveApp),
124 MethodStarted(CaptureMethod),
125 MethodFinished {
126 method: CaptureMethod,
127 elapsed: Duration,
128 },
129 MethodSucceeded(CaptureMethod),
130 MethodReturnedEmpty(CaptureMethod),
131 MethodFailed {
132 method: CaptureMethod,
133 kind: FailureKind,
134 },
135 RetryWaitStarted {
136 method: CaptureMethod,
137 delay: Duration,
138 },
139 RetryWaitSkipped {
140 method: CaptureMethod,
141 remaining_budget: Duration,
142 needed_delay: Duration,
143 },
144 Cancelled,
145 TimedOut,
146 CleanupFinished(CleanupStatus),
147}
148
149#[derive(Clone, Debug, PartialEq, Eq)]
150pub struct CaptureTrace {
151 pub events: Vec<TraceEvent>,
152 pub cleanup_status: CleanupStatus,
153 pub total_elapsed: Duration,
154}
155
156impl Default for CaptureTrace {
157 fn default() -> Self {
158 Self {
159 events: Vec::new(),
160 cleanup_status: CleanupStatus::Clean,
161 total_elapsed: Duration::ZERO,
162 }
163 }
164}
165
166#[derive(Clone, Debug)]
167pub struct CaptureSuccess {
168 pub text: String,
169 pub method: CaptureMethod,
170 pub focused_window_frame: Option<CGRect>,
171 pub trace: Option<CaptureTrace>,
172}
173
174impl PartialEq for CaptureSuccess {
175 fn eq(&self, other: &Self) -> bool {
176 self.text == other.text
177 && self.method == other.method
178 && self.trace == other.trace
179 && rect_option_eq(self.focused_window_frame, other.focused_window_frame)
180 }
181}
182
183fn rect_option_eq(left: Option<CGRect>, right: Option<CGRect>) -> bool {
184 match (left, right) {
185 (Some(a), Some(b)) => {
186 a.origin.x.to_bits() == b.origin.x.to_bits()
187 && a.origin.y.to_bits() == b.origin.y.to_bits()
188 && a.size.width.to_bits() == b.size.width.to_bits()
189 && a.size.height.to_bits() == b.size.height.to_bits()
190 }
191 (None, None) => true,
192 _ => false,
193 }
194}
195
196#[derive(Clone, Debug, PartialEq, Eq)]
197pub struct CaptureFailureContext {
198 pub status: CaptureStatus,
199 pub active_app: Option<ActiveApp>,
200 pub methods_tried: Vec<CaptureMethod>,
201 pub last_method: Option<CaptureMethod>,
202}
203
204#[derive(Clone, Debug, PartialEq, Eq)]
205pub struct CaptureFailure {
206 pub status: CaptureStatus,
207 pub hint: Option<UserHint>,
208 pub trace: Option<CaptureTrace>,
209 pub cleanup_failed: bool,
210 pub context: CaptureFailureContext,
211}
212
213#[derive(Clone, Debug, PartialEq)]
214pub enum CaptureOutcome {
215 Success(CaptureSuccess),
216 Failure(CaptureFailure),
217}
218
219#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
220pub struct WouldBlock;
221
222#[derive(Clone, Debug, PartialEq, Eq)]
223pub enum PlatformAttemptResult {
224 Success(String),
225 EmptySelection,
226 PermissionDenied,
227 AppBlocked,
228 ClipboardBorrowAmbiguous,
229 Unavailable,
230}
231
232impl PlatformAttemptResult {
233 pub fn failure_kind(self) -> Option<FailureKind> {
234 match self {
235 Self::EmptySelection => Some(FailureKind::EmptySelection),
236 Self::PermissionDenied => Some(FailureKind::PermissionDenied),
237 Self::AppBlocked => Some(FailureKind::AppBlocked),
238 Self::ClipboardBorrowAmbiguous => Some(FailureKind::ClipboardAmbiguous),
239 Self::Unavailable | Self::Success(_) => None,
240 }
241 }
242}
243
244impl CaptureMethod {
245 pub fn is_ax(self) -> bool {
246 matches!(self, Self::AccessibilityPrimary | Self::AccessibilityRange)
247 }
248
249 pub fn is_clipboard(self) -> bool {
250 matches!(self, Self::ClipboardBorrow | Self::SyntheticCopy)
251 }
252
253 pub fn retry_delays(self, policy: &RetryPolicy) -> &[Duration] {
254 match self {
255 Self::AccessibilityPrimary => &policy.primary_accessibility,
256 Self::AccessibilityRange => &policy.range_accessibility,
257 Self::ClipboardBorrow | Self::SyntheticCopy => &policy.clipboard,
258 }
259 }
260}
261
262pub fn default_method_order(allow_clipboard_borrow: bool) -> Vec<CaptureMethod> {
263 let mut methods = vec![
264 CaptureMethod::AccessibilityPrimary,
265 CaptureMethod::AccessibilityRange,
266 ];
267 if allow_clipboard_borrow {
268 methods.push(CaptureMethod::ClipboardBorrow);
269 }
270 methods
271}
272
273pub fn status_from_failure_kind(kind: FailureKind) -> CaptureStatus {
274 match kind {
275 FailureKind::PermissionDenied => CaptureStatus::PermissionDenied,
276 FailureKind::AppBlocked => CaptureStatus::AppBlocked,
277 FailureKind::EmptySelection => CaptureStatus::EmptySelection,
278 FailureKind::ClipboardAmbiguous => CaptureStatus::ClipboardBorrowAmbiguous,
279 FailureKind::TimedOut => CaptureStatus::TimedOut,
280 FailureKind::Cancelled => CaptureStatus::Cancelled,
281 }
282}
283
284pub fn update_for_method_result(
285 method: CaptureMethod,
286 result: &PlatformAttemptResult,
287) -> crate::profile::AppProfileUpdate {
288 let mut update = crate::profile::AppProfileUpdate::default();
289 if method.is_ax() {
290 update.ax_supported = match result {
291 PlatformAttemptResult::Success(_) => Some(TriState::Yes),
292 PlatformAttemptResult::PermissionDenied | PlatformAttemptResult::AppBlocked => {
293 Some(TriState::No)
294 }
295 _ => None,
296 };
297 }
298 if method.is_clipboard() {
299 update.clipboard_borrow_supported = match result {
300 PlatformAttemptResult::Success(_) => Some(TriState::Yes),
301 PlatformAttemptResult::PermissionDenied | PlatformAttemptResult::AppBlocked => {
302 Some(TriState::No)
303 }
304 _ => None,
305 };
306 }
307 if let PlatformAttemptResult::Success(_) = result {
308 update.last_success_method = Some(method);
309 } else if let Some(kind) = result.clone().failure_kind() {
310 update.last_failure_kind = Some(kind);
311 }
312 update
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 #[test]
320 fn default_method_order_includes_clipboard_when_allowed() {
321 assert_eq!(
322 default_method_order(true),
323 vec![
324 CaptureMethod::AccessibilityPrimary,
325 CaptureMethod::AccessibilityRange,
326 CaptureMethod::ClipboardBorrow,
327 ]
328 );
329 }
330
331 #[test]
332 fn default_method_order_excludes_clipboard_when_disallowed() {
333 assert_eq!(
334 default_method_order(false),
335 vec![
336 CaptureMethod::AccessibilityPrimary,
337 CaptureMethod::AccessibilityRange,
338 ]
339 );
340 }
341
342 #[test]
343 fn retry_delays_use_platform_neutral_policy_fields() {
344 let policy = RetryPolicy {
345 primary_accessibility: vec![Duration::from_millis(1)],
346 range_accessibility: vec![Duration::from_millis(2)],
347 clipboard: vec![Duration::from_millis(3)],
348 poll_interval: Duration::from_millis(4),
349 };
350
351 assert_eq!(
352 CaptureMethod::AccessibilityPrimary.retry_delays(&policy),
353 &[Duration::from_millis(1)]
354 );
355 assert_eq!(
356 CaptureMethod::AccessibilityRange.retry_delays(&policy),
357 &[Duration::from_millis(2)]
358 );
359 assert_eq!(
360 CaptureMethod::ClipboardBorrow.retry_delays(&policy),
361 &[Duration::from_millis(3)]
362 );
363 assert_eq!(
364 CaptureMethod::SyntheticCopy.retry_delays(&policy),
365 &[Duration::from_millis(3)]
366 );
367 }
368}