visionkit/async_api.rs
1//! Async API for `VisionKit`
2//!
3//! Enabled with the `async` Cargo feature. Every type here is an executor-agnostic
4//! [`Future`] backed by a C callback fired from a Swift `Task { … }` thunk.
5//!
6//! ## Available types
7//!
8//! | Type | Wraps |
9//! |---|---|
10//! | [`AsyncImageAnalyzer`] | `ImageAnalyzer.analyze(imageAt:orientation:configuration:) async throws` |
11//! | [`AsyncOverlaySubjects`] | `ImageAnalysisOverlayView.subjects async` and `.subject(at:) async` |
12//!
13//! ## Platform notes
14//!
15//! On macOS the subject APIs (`subjects`, `subject(at:)`) live on
16//! `ImageAnalysisOverlayView` (Rust: [`LiveTextInteraction`]), not on
17//! `ImageAnalysis` as on iOS. [`AsyncOverlaySubjects`] wraps this macOS surface.
18//!
19//! `DataScannerViewController` is an **iOS-only** API and is not available on
20//! macOS. Its multi-delegate live-scan surface is a Tier-2 (Stream) concern in
21//! any case. No wrappers are provided here.
22//!
23//! ## Design
24//!
25//! * Each async Swift API gets a `@_cdecl` thunk that accepts a C callback
26//! `(result, error, ctx)` and a `ctx` opaque pointer. The thunk spawns a
27//! Swift `Task`, awaits the Apple API, then fires the callback.
28//! * The Rust side wraps that in a typed `Future` newtype backed by
29//! [`AsyncCompletionFuture<T>`](doom_fish_utils::completion::AsyncCompletionFuture)
30//! and maps the `String` error to [`VisionKitError`].
31//! * Works with any async executor (`pollster`, Tokio, async-std, …).
32//!
33//! ## Example
34//!
35//! ```rust,no_run
36//! use visionkit::async_api::AsyncImageAnalyzer;
37//! use visionkit::{ImageAnalysisTypes, ImageAnalyzerConfiguration, ImageOrientation};
38//!
39//! # pollster::block_on(async {
40//! let cfg = ImageAnalyzerConfiguration::new(ImageAnalysisTypes::TEXT);
41//! let analysis = AsyncImageAnalyzer::new()?
42//! .analyze_image_at_path("/path/to/image.png", ImageOrientation::Up, &cfg)?
43//! .await?;
44//! println!("transcript: {}", analysis.transcript()?);
45//! # Ok::<_, Box<dyn std::error::Error>>(())
46//! # });
47//! ```
48
49use std::ffi::{c_void, CStr};
50use std::future::Future;
51use std::pin::Pin;
52use std::sync::Arc;
53use std::task::{Context, Poll, Wake, Waker};
54
55use doom_fish_utils::completion::{error_from_cstr, AsyncCompletion, AsyncCompletionFuture};
56use serde::Deserialize;
57
58use crate::error::VisionKitError;
59use crate::ffi;
60use crate::image_analysis::ImageAnalysis;
61use crate::image_analyzer::{ImageAnalyzerConfiguration, ImageOrientation};
62use crate::live_text_interaction::LiveTextInteraction;
63use crate::private::{json_cstring, path_to_cstring};
64
65// ============================================================================
66// AnalysisSubjectBounds
67// ============================================================================
68
69/// Bounds rectangle for an `ImageAnalysisOverlayView.Subject` (macOS) or
70/// `ImageAnalysisInteraction.Subject` (iOS).
71///
72/// The coordinate space matches what VisionKit reports for the analysis
73/// resolution. `x` and `y` are the origin (top-left corner).
74#[derive(Debug, Clone, PartialEq, Deserialize)]
75pub struct AnalysisSubjectBounds {
76 /// X coordinate of the origin.
77 pub x: f64,
78 /// Y coordinate of the origin.
79 pub y: f64,
80 /// Width of the bounding rectangle.
81 pub width: f64,
82 /// Height of the bounding rectangle.
83 pub height: f64,
84}
85
86// ============================================================================
87// Helpers
88// ============================================================================
89
90/// Copy a non-null C-string result pointer (reinterpreted as `*const i8`) to a `String`.
91///
92/// # Safety
93/// `ptr` must be a valid, null-terminated C string that remains valid for the
94/// duration of this call. The callback contract guarantees this: the Swift
95/// bridge passes a string from a `withCString` closure, so the pointer is live
96/// for the duration of the (synchronous) callback invocation.
97unsafe fn cstring_result_to_string(ptr: *const c_void) -> String {
98 CStr::from_ptr(ptr.cast::<i8>())
99 .to_str()
100 .map_or_else(|_| String::new(), str::to_owned)
101}
102
103// ============================================================================
104// AsyncImageAnalyzer
105// ============================================================================
106
107extern "C" fn analyze_cb(result: *const c_void, error: *const i8, ctx: *mut c_void) {
108 if !error.is_null() {
109 let msg = unsafe { error_from_cstr(error) };
110 unsafe { AsyncCompletion::<ImageAnalysis>::complete_err(ctx, msg) };
111 } else if !result.is_null() {
112 // result is a retained VKImageAnalysisBox pointer
113 let analysis = ImageAnalysis::from_token(result.cast_mut());
114 unsafe { AsyncCompletion::complete_ok(ctx, analysis) };
115 } else {
116 unsafe {
117 AsyncCompletion::<ImageAnalysis>::complete_err(ctx, "Unknown error".into());
118 };
119 }
120}
121
122/// Future returned by [`AsyncImageAnalyzer::analyze_image_at_path`].
123#[must_use = "futures do nothing unless polled"]
124pub struct AnalyzeImageFuture {
125 inner: AsyncCompletionFuture<ImageAnalysis>,
126}
127
128impl std::fmt::Debug for AnalyzeImageFuture {
129 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130 f.debug_struct("AnalyzeImageFuture").finish_non_exhaustive()
131 }
132}
133
134impl Future for AnalyzeImageFuture {
135 type Output = Result<ImageAnalysis, VisionKitError>;
136
137 fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
138 Pin::new(&mut self.inner)
139 .poll(cx)
140 .map(|r| r.map_err(VisionKitError::Unknown))
141 }
142}
143
144/// True-async entry point for `ImageAnalyzer.analyze(imageAt:orientation:configuration:)`.
145///
146/// Requires macOS 13+. The returned [`AnalyzeImageFuture`] resolves to an
147/// [`ImageAnalysis`] that can then be inspected synchronously via its existing
148/// methods or have its subjects queried via [`AsyncOverlaySubjects`].
149pub struct AsyncImageAnalyzer {
150 token: *mut c_void,
151}
152
153// SAFETY: the underlying VKImageAnalyzerBox is reference-counted by Swift ARC
154// and the callback fires exactly once. The callback carries a reference to the
155// token across threads, and since the token is ARC-counted by Swift, it remains
156// valid regardless of which thread completes the async operation. Sync is safe
157// because VKImageAnalyzerBox is thread-safe (it's an immutable system framework
158// class with no thread-local state).
159unsafe impl Send for AsyncImageAnalyzer {}
160unsafe impl Sync for AsyncImageAnalyzer {}
161
162impl Drop for AsyncImageAnalyzer {
163 fn drop(&mut self) {
164 if !self.token.is_null() {
165 unsafe { ffi::image_analyzer::vk_image_analyzer_release(self.token) };
166 }
167 }
168}
169
170impl AsyncImageAnalyzer {
171 /// Create a new `AsyncImageAnalyzer`.
172 ///
173 /// # Errors
174 ///
175 /// Returns [`VisionKitError::UnavailableOnThisMacOS`] if the device does
176 /// not run macOS 13 or later.
177 pub fn new() -> Result<Self, VisionKitError> {
178 let token = unsafe { ffi::image_analyzer::vk_image_analyzer_new() };
179 if token.is_null() {
180 return Err(VisionKitError::UnavailableOnThisMacOS(
181 "ImageAnalyzer requires macOS 13+".to_owned(),
182 ));
183 }
184 Ok(Self { token })
185 }
186
187 /// Returns `true` when the current Mac supports `ImageAnalyzer`.
188 #[must_use]
189 pub fn is_supported() -> bool {
190 unsafe { ffi::image_analyzer::vk_image_analyzer_is_supported() != 0 }
191 }
192
193 /// Asynchronously analyze the image at `path` and return an [`ImageAnalysis`].
194 ///
195 /// This is a true async wrapper: the Swift bridge spawns a
196 /// `Task { @MainActor … }`, calls
197 /// `await analyzer.analyze(imageAt:orientation:configuration:)`, and fires
198 /// a C callback when done. The returned [`AnalyzeImageFuture`] resolves
199 /// when that callback fires.
200 ///
201 /// # Errors
202 ///
203 /// Returns [`VisionKitError`] if the path cannot be represented as a C
204 /// string, if the configuration cannot be serialized, or if VisionKit
205 /// reports an analysis error.
206 pub fn analyze_image_at_path<P: AsRef<std::path::Path>>(
207 &self,
208 path: P,
209 orientation: ImageOrientation,
210 configuration: &ImageAnalyzerConfiguration,
211 ) -> Result<AnalyzeImageFuture, VisionKitError> {
212 let path_cs = path_to_cstring(path.as_ref())?;
213 let cfg_cs = json_cstring(configuration)?;
214 let (future, ctx) = AsyncCompletion::create();
215 unsafe {
216 ffi::image_analyzer::vk_image_analyzer_analyze_image_async(
217 self.token,
218 path_cs.as_ptr(),
219 orientation.raw_value(),
220 cfg_cs.as_ptr(),
221 analyze_cb,
222 ctx,
223 );
224 }
225 Ok(AnalyzeImageFuture { inner: future })
226 }
227}
228
229// ============================================================================
230// AsyncOverlaySubjects
231// ============================================================================
232
233extern "C" fn subjects_cb(result: *const c_void, error: *const i8, ctx: *mut c_void) {
234 if !error.is_null() {
235 let msg = unsafe { error_from_cstr(error) };
236 unsafe { AsyncCompletion::<String>::complete_err(ctx, msg) };
237 } else if !result.is_null() {
238 let json = unsafe { cstring_result_to_string(result) };
239 unsafe { AsyncCompletion::complete_ok(ctx, json) };
240 } else {
241 unsafe { AsyncCompletion::<String>::complete_err(ctx, "Unknown error".into()) };
242 }
243}
244
245extern "C" fn subject_at_cb(result: *const c_void, error: *const i8, ctx: *mut c_void) {
246 if !error.is_null() {
247 let msg = unsafe { error_from_cstr(error) };
248 unsafe { AsyncCompletion::<String>::complete_err(ctx, msg) };
249 } else if !result.is_null() {
250 let json = unsafe { cstring_result_to_string(result) };
251 unsafe { AsyncCompletion::complete_ok(ctx, json) };
252 } else {
253 unsafe { AsyncCompletion::<String>::complete_err(ctx, "Unknown error".into()) };
254 }
255}
256
257/// Future returned by [`AsyncOverlaySubjects::subjects`].
258#[must_use = "futures do nothing unless polled"]
259pub struct SubjectsFuture {
260 inner: AsyncCompletionFuture<String>,
261}
262
263impl std::fmt::Debug for SubjectsFuture {
264 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265 f.debug_struct("SubjectsFuture").finish_non_exhaustive()
266 }
267}
268
269impl Future for SubjectsFuture {
270 type Output = Result<Vec<AnalysisSubjectBounds>, VisionKitError>;
271
272 fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
273 Pin::new(&mut self.inner).poll(cx).map(|r| {
274 r.map_err(VisionKitError::Unknown).and_then(|json| {
275 serde_json::from_str::<Vec<AnalysisSubjectBounds>>(&json).map_err(|e| {
276 VisionKitError::Unknown(format!(
277 "failed to decode subjects JSON from Swift bridge: {e}"
278 ))
279 })
280 })
281 })
282 }
283}
284
285/// Future returned by [`AsyncOverlaySubjects::subject_at`].
286#[must_use = "futures do nothing unless polled"]
287pub struct SubjectAtFuture {
288 inner: AsyncCompletionFuture<String>,
289}
290
291impl std::fmt::Debug for SubjectAtFuture {
292 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293 f.debug_struct("SubjectAtFuture").finish_non_exhaustive()
294 }
295}
296
297impl Future for SubjectAtFuture {
298 type Output = Result<Option<AnalysisSubjectBounds>, VisionKitError>;
299
300 fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
301 Pin::new(&mut self.inner).poll(cx).map(|r| {
302 r.map_err(VisionKitError::Unknown).and_then(|json| {
303 serde_json::from_str::<Option<AnalysisSubjectBounds>>(&json).map_err(|e| {
304 VisionKitError::Unknown(format!(
305 "failed to decode subject-at JSON from Swift bridge: {e}"
306 ))
307 })
308 })
309 })
310 }
311}
312
313/// True-async entry point for `ImageAnalysisOverlayView.subjects` and
314/// `ImageAnalysisOverlayView.subject(at:)` (macOS).
315///
316/// On macOS the subject APIs live on `ImageAnalysisOverlayView`
317/// (Rust: [`LiveTextInteraction`]), not directly on `ImageAnalysis`.
318/// Both require macOS 13+ and the [`crate::ImageAnalysisTypes::VISUAL_LOOK_UP`]
319/// analysis type in the configuration used when the analysis was performed.
320///
321/// # Note on subject bounds coordinate space
322///
323/// Bounds are in the same coordinate space that VisionKit uses when populating
324/// the overlay view. That is, they are relative to the view's bounds as
325/// configured via `setPreferredInteractionTypes` and `setAnalysis(_:)`.
326pub struct AsyncOverlaySubjects {
327 // Raw token of the underlying VKLiveTextInteractionBox.
328 // Caller must ensure the LiveTextInteraction outlives the returned futures.
329 token: *mut c_void,
330}
331
332// SAFETY: the underlying VKLiveTextInteractionBox is ARC-counted and the callback
333// fires exactly once. The callback carries a reference to the token across threads,
334// and since the token is ARC-counted by Swift, it remains valid regardless of which
335// thread completes the async operation. Sync is NOT implemented because the
336// underlying ImageAnalysisOverlayView is a UI component that must be accessed from
337// the main thread.
338unsafe impl Send for AsyncOverlaySubjects {}
339
340impl AsyncOverlaySubjects {
341 /// Wrap a [`LiveTextInteraction`] for async subject queries.
342 ///
343 /// The `interaction` must remain alive for as long as the returned
344 /// futures are pending.
345 #[must_use]
346 pub fn new(interaction: &LiveTextInteraction) -> Self {
347 Self {
348 token: interaction.raw_token(),
349 }
350 }
351
352 /// Asynchronously retrieve all subjects as their bounds rectangles.
353 ///
354 /// Internally calls `await overlayView.subjects` on the Swift side
355 /// (requires `@MainActor`). Each subject's `bounds` is accessed
356 /// synchronously within that actor context.
357 ///
358 /// Returns an empty `Vec` when no subjects are found or when the analysis
359 /// did not include [`crate::ImageAnalysisTypes::VISUAL_LOOK_UP`].
360 #[must_use = "returns a future that must be awaited"]
361 pub fn subjects(&self) -> SubjectsFuture {
362 let (future, ctx) = AsyncCompletion::create();
363 unsafe {
364 ffi::live_text_interaction::vk_live_text_overlay_subjects_async(
365 self.token,
366 subjects_cb,
367 ctx,
368 );
369 }
370 SubjectsFuture { inner: future }
371 }
372
373 /// Asynchronously find the subject at the given overlay-coordinate point.
374 ///
375 /// Returns `Ok(None)` when no subject is present at `(x, y)`.
376 #[must_use = "returns a future that must be awaited"]
377 pub fn subject_at(&self, x: f64, y: f64) -> SubjectAtFuture {
378 let (future, ctx) = AsyncCompletion::create();
379 unsafe {
380 ffi::live_text_interaction::vk_live_text_overlay_subject_at_async(
381 self.token,
382 x,
383 y,
384 subject_at_cb,
385 ctx,
386 );
387 }
388 SubjectAtFuture { inner: future }
389 }
390}
391
392// ============================================================================
393// block_on — run-loop-aware executor
394// ============================================================================
395
396struct NoopWake;
397impl Wake for NoopWake {
398 fn wake(self: Arc<Self>) {}
399 fn wake_by_ref(self: &Arc<Self>) {}
400}
401
402/// Drive a VisionKit async future to completion while pumping the Obj-C main
403/// run loop between polls.
404///
405/// **Must be called from the main thread.** VisionKit Swift `Task { @MainActor
406/// in }` thunks dispatch work to the main actor; without run-loop pumping the
407/// calling thread would deadlock when the main run loop is not free (e.g.
408/// inside `pollster::block_on`).
409///
410/// # Example
411///
412/// ```rust,no_run
413/// use visionkit::async_api::{AsyncImageAnalyzer, block_on};
414/// use visionkit::{ImageAnalysisTypes, ImageAnalyzerConfiguration, ImageOrientation};
415///
416/// let cfg = ImageAnalyzerConfiguration::new(ImageAnalysisTypes::TEXT);
417/// let analysis = block_on(async {
418/// AsyncImageAnalyzer::new()?
419/// .analyze_image_at_path("/path/to/image.png", ImageOrientation::Up, &cfg)?
420/// .await
421/// });
422/// ```
423pub fn block_on<F: Future>(future: F) -> F::Output {
424 let waker = Waker::from(Arc::new(NoopWake));
425 let cx = &mut Context::from_waker(&waker);
426 let mut future = std::pin::pin!(future);
427 loop {
428 match future.as_mut().poll(cx) {
429 Poll::Ready(val) => return val,
430 Poll::Pending => {
431 // Pump Obj-C RunLoop.main for 10 ms so Swift @MainActor Tasks
432 // can make progress before the next poll.
433 unsafe { ffi::image_analyzer::vk_pump_main_run_loop(10) };
434 }
435 }
436 }
437}