Skip to main content

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}