git.iko.soy/rowtype-yoga/purescript-yoga-react-native
on main help sign in
Fix speech recognition: entitlement, on-device fallback, robust button handler
commitb1ef307247f0384cbfaa7e29cbc7cfaf98ae6440 authorMark <mark.eibes@gmail.com> dateSun 15 Feb 2026, 22:30 (8 months ago) parent26c25e4
11 files changed+224−139
example-macos/src/Main.purs+25−40 src/Yoga/React/Native/MacOS/MapView.purs+15−4 src/Yoga/React/Native/MacOS/NaturalLanguage.js+18−18 src/Yoga/React/Native/MacOS/NaturalLanguage.purs+15−15 src/Yoga/React/Native/MacOS/OCR.js+10−6 src/Yoga/React/Native/MacOS/OCR.purs+6−8 src/Yoga/React/Native/MacOS/PDFView.purs+6−2 src/Yoga/React/Native/MacOS/SpeechRecognition.js+15−6 src/Yoga/React/Native/MacOS/SpeechRecognition.purs+46−13 src/Yoga/React/Native/MacOS/Types.purs+25 test/MacOSImperative.test.js+43−27
example-macos/src/Main.purs+25−40
@@ -72,9 +72,9 @@ import Yoga.React.Native.MacOS.QuickLook (macosQuickLook)
7272import Yoga.React.Native.MacOS.SpeechSynthesizer (macosSay, macosStopSpeaking)
7373import Yoga.React.Native.MacOS.ColorPanel (macosShowColorPanel)
7474import Yoga.React.Native.MacOS.FontPanel (macosShowFontPanel)
75−import Yoga.React.Native.MacOS.OCR (macosOCR)
76−import Yoga.React.Native.MacOS.SpeechRecognition (macosStartListening, macosStopListening, macosGetTranscript)
77−import Yoga.React.Native.MacOS.NaturalLanguage (macosDetectLanguage, macosAnalyzeSentiment, macosTokenize)
75+import Yoga.React.Native.MacOS.OCR (recognizeText)
76+import Yoga.React.Native.MacOS.SpeechRecognition (useSpeechRecognition)
77+import Yoga.React.Native.MacOS.NaturalLanguage (detectLanguage, analyzeSentiment, tokenize)
7878import Yoga.React.Native.MacOS.Types as T
7979import Yoga.React.Native.Matrix as Matrix
8080import Yoga.React.Native.Style as Style
@@ -1075,16 +1075,9 @@ aiTab :: AIProps -> JSX
10751075aiTab = component "AITab" \p -> React.do
10761076 speechText /\ setSpeechText <- useState' "Hello from PureScript React Native on macOS!"
10771077 ocrResult /\ setOcrResult <- useState' ""
1078− listening /\ setListening <- useState' false
1079− transcript /\ setTranscript <- useState' ""
1078+ speech <- useSpeechRecognition
10801079 nlText /\ setNlText <- useState' "I love this amazing app! C'est magnifique."
10811080 nlResult /\ setNlResult <- useState' ""
1082− useEffect listening do
1083− if listening then do
1084− timerId <- setInterval 500 do
1085− macosGetTranscript \t -> setTranscript t
1086− pure (clearInterval timerId)
1087− else pure (pure unit)
10881081 pure do
10891082 nativeScrollView { style: tw "flex-1" <> Style.style { backgroundColor: "transparent" } }
10901083 ( view { style: tw "px-4 pb-4" }
@@ -1096,14 +1089,12 @@ aiTab = component "AITab" \p -> React.do
10961089 , longitude: -122.4194
10971090 , latitudeDelta: 0.05
10981091 , longitudeDelta: 0.05
1099− , mapType: "standard"
1092+ , mapType: T.standardMap
11001093 , showsUserLocation: false
1101− , annotations: unsafeToForeign
1094+ , annotations:
11021095 [ { latitude: 37.7749, longitude: -122.4194, title: "San Francisco", subtitle: "California" }
11031096 , { latitude: 37.8199, longitude: -122.4783, title: "Golden Gate Bridge", subtitle: "" }
11041097 ]
1105− , onRegionChange: handler_ (pure unit)
1106− , onSelectAnnotation: handler_ (pure unit)
11071098 , style: Style.style { height: 250.0 }
11081099 }
11091100 , sectionTitle p.fg "PDF Viewer"
@@ -1112,8 +1103,7 @@ aiTab = component "AITab" \p -> React.do
11121103 , nativePDFView
11131104 { source: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
11141105 , autoScales: true
1115− , displayMode: "singlePageContinuous"
1116− , onPageChange: handler_ (pure unit)
1106+ , displayMode: T.singlePageContinuous
11171107 , style: Style.style { height: 300.0 }
11181108 }
11191109 , sectionTitle p.fg "Quick Look"
@@ -1190,8 +1180,9 @@ aiTab = component "AITab" \p -> React.do
11901180 (nativeEvent >>> unsafeEventFn \e -> getFieldArray "files" e)
11911181 \paths -> for_ paths \path -> do
11921182 setOcrResult "Recognizing..."
1193− macosOCR path \result -> setOcrResult result
1194− , onCancel: handler_ (pure unit)
1183+ launchAff_ do
1184+ result <- recognizeText path
1185+ liftEffect (setOcrResult result)
11951186 , style: Style.style { height: 24.0, width: 140.0 }
11961187 }
11971188 ]
@@ -1203,28 +1194,22 @@ aiTab = component "AITab" \p -> React.do
12031194 "Live microphone-to-text (click Start, speak, click Stop)"
12041195 , view { style: tw "flex-row items-center mb-2" }
12051196 [ nativeButton
1206− { title: if listening then "Listening..." else "Start"
1207− , sfSymbol: if listening then "mic.fill" else "mic"
1197+ { title: if speech.listening then "Listening..." else "Start"
1198+ , sfSymbol: if speech.listening then "mic.fill" else "mic"
12081199 , bezelStyle: T.push
1209− , onPress: handler_ do
1210− setListening true
1211− setTranscript ""
1212− macosStartListening
1200+ , onPress: handler_ speech.start
12131201 , style: Style.style { height: 24.0, width: 130.0 }
12141202 }
12151203 , nativeButton
12161204 { title: "Stop"
12171205 , sfSymbol: "stop.circle"
12181206 , bezelStyle: T.push
1219− , onPress: handler_ do
1220− setListening false
1221− macosStopListening \finalText -> do
1222− setTranscript finalText
1207+ , onPress: handler_ speech.stop
12231208 , style: Style.style { height: 24.0, width: 80.0, marginLeft: 8.0 }
12241209 }
12251210 ]
1226− , if transcript /= "" then view { style: tw "p-2 rounded mb-4" <> Style.style { backgroundColor: p.cardBg } }
1227− [ text { style: tw "text-xs font-mono" <> Style.style { color: p.fg } } transcript ]
1211+ , if speech.transcript /= "" then view { style: tw "p-2 rounded mb-4" <> Style.style { backgroundColor: p.cardBg } }
1212+ [ text { style: tw "text-xs font-mono" <> Style.style { color: p.fg } } speech.transcript ]
12281213 else view {} []
12291214 , sectionTitle p.fg "Natural Language"
12301215 , text { style: tw "text-xs mb-2" <> Style.style { color: p.dimFg } }
@@ -1240,27 +1225,27 @@ aiTab = component "AITab" \p -> React.do
12401225 { title: "Language"
12411226 , sfSymbol: "globe"
12421227 , bezelStyle: T.push
1243− , onPress: handler_ do
1244− macosDetectLanguage nlText \lang ->
1245− setNlResult ("Language: " <> lang)
1228+ , onPress: handler_ $ launchAff_ do
1229+ lang <- detectLanguage nlText
1230+ liftEffect (setNlResult ("Language: " <> lang))
12461231 , style: Style.style { height: 24.0, width: 110.0 }
12471232 }
12481233 , nativeButton
12491234 { title: "Sentiment"
12501235 , sfSymbol: "face.smiling"
12511236 , bezelStyle: T.push
1252− , onPress: handler_ do
1253− macosAnalyzeSentiment nlText \score ->
1254− setNlResult ("Sentiment: " <> show score)
1237+ , onPress: handler_ $ launchAff_ do
1238+ score <- analyzeSentiment nlText
1239+ liftEffect (setNlResult ("Sentiment: " <> show score))
12551240 , style: Style.style { height: 24.0, width: 110.0, marginLeft: 8.0 }
12561241 }
12571242 , nativeButton
12581243 { title: "Tokenize"
12591244 , sfSymbol: "text.word.spacing"
12601245 , bezelStyle: T.push
1261− , onPress: handler_ do
1262− macosTokenize nlText \tokens ->
1263− setNlResult ("Tokens: " <> joinWith ", " tokens)
1246+ , onPress: handler_ $ launchAff_ do
1247+ tokens <- tokenize nlText
1248+ liftEffect (setNlResult ("Tokens: " <> joinWith ", " tokens))
12641249 , style: Style.style { height: 24.0, width: 110.0, marginLeft: 8.0 }
12651250 }
12661251 ]
src/Yoga/React/Native/MacOS/MapView.purs+15−4
@@ -1,24 +1,35 @@
1−module Yoga.React.Native.MacOS.MapView (nativeMapView, NativeMapViewAttributes) where
1+module Yoga.React.Native.MacOS.MapView
2+ ( nativeMapView
3+ , NativeMapViewAttributes
4+ , MapAnnotation
5+ ) where
26
3−import Foreign (Foreign)
47import React.Basic (ReactComponent)
58import React.Basic.Events (EventHandler)
69import Yoga.React.Native.Attributes (BaseAttributes)
710import Yoga.React.Native.Internal (FFINativeComponent_, createNativeElement_)
11+import Yoga.React.Native.MacOS.Types (MapType)
812
913foreign import _mapViewImpl :: forall props. ReactComponent props
1014
1115nativeMapView :: FFINativeComponent_ NativeMapViewAttributes
1216nativeMapView = createNativeElement_ _mapViewImpl
1317
18+type MapAnnotation =
19+ { latitude :: Number
20+ , longitude :: Number
21+ , title :: String
22+ , subtitle :: String
23+ }
24+
1425type NativeMapViewAttributes = BaseAttributes
1526 ( latitude :: Number
1627 , longitude :: Number
1728 , latitudeDelta :: Number
1829 , longitudeDelta :: Number
19− , mapType :: String
30+ , mapType :: MapType
2031 , showsUserLocation :: Boolean
21− , annotations :: Foreign
32+ , annotations :: Array MapAnnotation
2233 , onRegionChange :: EventHandler
2334 , onSelectAnnotation :: EventHandler
2435 )
src/Yoga/React/Native/MacOS/NaturalLanguage.js+18−18
@@ -1,34 +1,34 @@
11import { NativeModules, Platform } from "react-native";
22
3−export const detectLanguageImpl = (text, callback) => {
4− if (Platform.OS !== "macos") return;
3+const mkAff = (promise) => (onError, onSuccess) => {
4+ promise.then(onSuccess, onError);
5+ return (cancelError, onCancelerError, onCancelerSuccess) =>
6+ onCancelerSuccess();
7+};
8+
9+export const detectLanguageImpl = (text) => {
10+ if (Platform.OS !== "macos") return mkAff(Promise.resolve("unknown"));
511 const mod = NativeModules.MacOSNLModule;
612 if (mod && mod.detectLanguage) {
7− mod
8− .detectLanguage(text)
9− .then((lang) => callback(lang)())
10− .catch((e) => console.error("[macosDetectLanguage]", e));
13+ return mkAff(mod.detectLanguage(text));
1114 }
15+ return mkAff(Promise.resolve("unknown"));
1216};
1317
14−export const sentimentImpl = (text, callback) => {
15− if (Platform.OS !== "macos") return;
18+export const analyzeSentimentImpl = (text) => {
19+ if (Platform.OS !== "macos") return mkAff(Promise.resolve(0));
1620 const mod = NativeModules.MacOSNLModule;
1721 if (mod && mod.sentiment) {
18− mod
19− .sentiment(text)
20− .then((score) => callback(score)())
21− .catch((e) => console.error("[macosAnalyzeSentiment]", e));
22+ return mkAff(mod.sentiment(text));
2223 }
24+ return mkAff(Promise.resolve(0));
2325};
2426
25−export const tokenizeImpl = (text, callback) => {
26− if (Platform.OS !== "macos") return;
27+export const tokenizeImpl = (text) => {
28+ if (Platform.OS !== "macos") return mkAff(Promise.resolve([]));
2729 const mod = NativeModules.MacOSNLModule;
2830 if (mod && mod.tokenize) {
29− mod
30− .tokenize(text)
31− .then((tokens) => callback(tokens)())
32− .catch((e) => console.error("[macosTokenize]", e));
31+ return mkAff(mod.tokenize(text));
3332 }
33+ return mkAff(Promise.resolve([]));
3434};
src/Yoga/React/Native/MacOS/NaturalLanguage.purs+15−15
@@ -1,23 +1,23 @@
11module Yoga.React.Native.MacOS.NaturalLanguage
2− ( macosDetectLanguage
3− , macosAnalyzeSentiment
4− , macosTokenize
2+ ( detectLanguage
3+ , analyzeSentiment
4+ , tokenize
55 ) where
66
7−import Prelude
7+import Effect.Aff (Aff)
8+import Effect.Aff.Compat (EffectFnAff, fromEffectFnAff)
89
9−import Effect (Effect)
10−import Effect.Uncurried (EffectFn2, runEffectFn2)
10+foreign import detectLanguageImpl :: String -> EffectFnAff String
1111
12−foreign import detectLanguageImpl :: EffectFn2 String (String -> Effect Unit) Unit
13−foreign import sentimentImpl :: EffectFn2 String (Number -> Effect Unit) Unit
14−foreign import tokenizeImpl :: EffectFn2 String (Array String -> Effect Unit) Unit
12+detectLanguage :: String -> Aff String
13+detectLanguage text = fromEffectFnAff (detectLanguageImpl text)
1514
16−macosDetectLanguage :: String -> (String -> Effect Unit) -> Effect Unit
17−macosDetectLanguage = runEffectFn2 detectLanguageImpl
15+foreign import analyzeSentimentImpl :: String -> EffectFnAff Number
1816
19−macosAnalyzeSentiment :: String -> (Number -> Effect Unit) -> Effect Unit
20−macosAnalyzeSentiment = runEffectFn2 sentimentImpl
17+analyzeSentiment :: String -> Aff Number
18+analyzeSentiment text = fromEffectFnAff (analyzeSentimentImpl text)
2119
22−macosTokenize :: String -> (Array String -> Effect Unit) -> Effect Unit
23−macosTokenize = runEffectFn2 tokenizeImpl
20+foreign import tokenizeImpl :: String -> EffectFnAff (Array String)
21+
22+tokenize :: String -> Aff (Array String)
23+tokenize text = fromEffectFnAff (tokenizeImpl text)
src/Yoga/React/Native/MacOS/OCR.js+10−6
@@ -1,12 +1,16 @@
11import { NativeModules, Platform } from "react-native";
22
3−export const recognizeImpl = (imagePath, callback) => {
4− if (Platform.OS !== "macos") return;
3+const mkAff = (promise) => (onError, onSuccess) => {
4+ promise.then(onSuccess, onError);
5+ return (cancelError, onCancelerError, onCancelerSuccess) =>
6+ onCancelerSuccess();
7+};
8+
9+export const recognizeTextImpl = (imagePath) => {
10+ if (Platform.OS !== "macos") return mkAff(Promise.resolve(""));
511 const mod = NativeModules.MacOSOCRModule;
612 if (mod && mod.recognize) {
7− mod
8− .recognize(imagePath)
9− .then((text) => callback(text)())
10− .catch((e) => console.error("[macosOCR]", e));
13+ return mkAff(mod.recognize(imagePath));
1114 }
15+ return mkAff(Promise.resolve(""));
1216};
src/Yoga/React/Native/MacOS/OCR.purs+6−8
@@ -1,13 +1,11 @@
11module Yoga.React.Native.MacOS.OCR
2− ( macosOCR
2+ ( recognizeText
33 ) where
44
5−import Prelude
5+import Effect.Aff (Aff)
6+import Effect.Aff.Compat (EffectFnAff, fromEffectFnAff)
67
7−import Effect (Effect)
8−import Effect.Uncurried (EffectFn2, runEffectFn2)
8+foreign import recognizeTextImpl :: String -> EffectFnAff String
99
10−foreign import recognizeImpl :: EffectFn2 String (String -> Effect Unit) Unit
11−
12−macosOCR :: String -> (String -> Effect Unit) -> Effect Unit
13−macosOCR = runEffectFn2 recognizeImpl
10+recognizeText :: String -> Aff String
11+recognizeText path = fromEffectFnAff (recognizeTextImpl path)
src/Yoga/React/Native/MacOS/PDFView.purs+6−2
@@ -1,9 +1,13 @@
1−module Yoga.React.Native.MacOS.PDFView (nativePDFView, NativePDFViewAttributes) where
1+module Yoga.React.Native.MacOS.PDFView
2+ ( nativePDFView
3+ , NativePDFViewAttributes
4+ ) where
25
36import React.Basic (ReactComponent)
47import React.Basic.Events (EventHandler)
58import Yoga.React.Native.Attributes (BaseAttributes)
69import Yoga.React.Native.Internal (FFINativeComponent_, createNativeElement_)
10+import Yoga.React.Native.MacOS.Types (PDFDisplayMode)
711
812foreign import _pdfViewImpl :: forall props. ReactComponent props
913
@@ -13,6 +17,6 @@ nativePDFView = createNativeElement_ _pdfViewImpl
1317type NativePDFViewAttributes = BaseAttributes
1418 ( source :: String
1519 , autoScales :: Boolean
16− , displayMode :: String
20+ , displayMode :: PDFDisplayMode
1721 , onPageChange :: EventHandler
1822 )
src/Yoga/React/Native/MacOS/SpeechRecognition.js+15−6
@@ -1,25 +1,30 @@
11import { NativeModules, Platform } from "react-native";
22
3−export const startListeningImpl = () => {
3+export const startImpl = () => {
44 if (Platform.OS !== "macos") return;
55 const mod = NativeModules.MacOSSpeechRecognitionModule;
66 if (mod && mod.start) {
7− mod.start().catch((e) => console.error("[macosStartListening]", e));
7+ mod.start().catch((e) => console.error("[SpeechRecognition]", e));
88 }
99};
1010
11−export const stopListeningImpl = (callback) => {
12− if (Platform.OS !== "macos") return;
11+export const stopImpl = (callback) => {
12+ if (Platform.OS !== "macos") {
13+ callback("")();
14+ return;
15+ }
1316 const mod = NativeModules.MacOSSpeechRecognitionModule;
1417 if (mod && mod.stop) {
1518 mod
1619 .stop()
1720 .then((text) => callback(text)())
18− .catch((e) => console.error("[macosStopListening]", e));
21+ .catch(() => callback("")());
22+ } else {
23+ callback("")();
1924 }
2025};
2126
22−export const getTranscriptImpl = (callback) => {
27+export const pollTranscriptImpl = (callback) => {
2328 if (Platform.OS !== "macos") return;
2429 const mod = NativeModules.MacOSSpeechRecognitionModule;
2530 if (mod && mod.getTranscript) {
@@ -29,3 +34,7 @@ export const getTranscriptImpl = (callback) => {
2934 .catch(() => {});
3035 }
3136};
37+
38+export const setIntervalImpl = (ms, cb) => setInterval(cb, ms);
39+
40+export const clearIntervalImpl = (id) => clearInterval(id);
src/Yoga/React/Native/MacOS/SpeechRecognition.purs+46−13
@@ -1,23 +1,56 @@
11module Yoga.React.Native.MacOS.SpeechRecognition
2− ( macosStartListening
3− , macosStopListening
4− , macosGetTranscript
2+ ( useSpeechRecognition
3+ , UseSpeechRecognition
4+ , SpeechRecognition
55 ) where
66
77import Prelude
88
9+import Data.Newtype (class Newtype)
10+import Data.Tuple.Nested ((/\))
911import Effect (Effect)
10−import Effect.Uncurried (EffectFn1, runEffectFn1)
12+import Effect.Uncurried (EffectFn1, EffectFn2, runEffectFn1, runEffectFn2)
13+import React.Basic.Hooks (Hook, UseEffect, UseState, useState', useEffect)
14+import React.Basic.Hooks as React
15+import React.Basic.Hooks.Internal (coerceHook)
1116
12−foreign import startListeningImpl :: Effect Unit
13−foreign import stopListeningImpl :: EffectFn1 (String -> Effect Unit) Unit
14−foreign import getTranscriptImpl :: EffectFn1 (String -> Effect Unit) Unit
17+foreign import startImpl :: Effect Unit
18+foreign import stopImpl :: EffectFn1 (String -> Effect Unit) Unit
19+foreign import pollTranscriptImpl :: EffectFn1 (String -> Effect Unit) Unit
20+foreign import setIntervalImpl :: EffectFn2 Int (Effect Unit) Int
21+foreign import clearIntervalImpl :: EffectFn1 Int Unit
1522
16−macosStartListening :: Effect Unit
17−macosStartListening = startListeningImpl
23+type SpeechRecognition =
24+ { listening :: Boolean
25+ , transcript :: String
26+ , start :: Effect Unit
27+ , stop :: Effect Unit
28+ }
1829
19−macosStopListening :: (String -> Effect Unit) -> Effect Unit
20−macosStopListening = runEffectFn1 stopListeningImpl
30+newtype UseSpeechRecognition hooks = UseSpeechRecognition
31+ (UseEffect Boolean (UseState String (UseState Boolean hooks)))
2132
22−macosGetTranscript :: (String -> Effect Unit) -> Effect Unit
23−macosGetTranscript = runEffectFn1 getTranscriptImpl
33+derive instance Newtype (UseSpeechRecognition hooks) _
34+
35+useSpeechRecognition :: Hook UseSpeechRecognition SpeechRecognition
36+useSpeechRecognition = coerceHook React.do
37+ listening /\ setListening <- useState' false
38+ transcript /\ setTranscript <- useState' ""
39+ useEffect listening do
40+ if listening then do
41+ timerId <- runEffectFn2 setIntervalImpl 500 do
42+ runEffectFn1 pollTranscriptImpl \t -> setTranscript t
43+ pure (runEffectFn1 clearIntervalImpl timerId)
44+ else
45+ pure (pure unit)
46+ pure
47+ { listening
48+ , transcript
49+ , start: do
50+ setListening true
51+ setTranscript ""
52+ startImpl
53+ , stop: do
54+ setListening false
55+ runEffectFn1 stopImpl \finalText -> setTranscript finalText
56+ }
src/Yoga/React/Native/MacOS/Types.purs+25
@@ -184,3 +184,28 @@ standardPath = PathControlStyle "standard"
184184
185185popupPath :: PathControlStyle
186186popupPath = PathControlStyle "popup"
187+
188+newtype MapType = MapType String
189+
190+standardMap :: MapType
191+standardMap = MapType "standard"
192+
193+satelliteMap :: MapType
194+satelliteMap = MapType "satellite"
195+
196+hybridMap :: MapType
197+hybridMap = MapType "hybrid"
198+
199+newtype PDFDisplayMode = PDFDisplayMode String
200+
201+singlePage :: PDFDisplayMode
202+singlePage = PDFDisplayMode "singlePage"
203+
204+singlePageContinuous :: PDFDisplayMode
205+singlePageContinuous = PDFDisplayMode "singlePageContinuous"
206+
207+twoUp :: PDFDisplayMode
208+twoUp = PDFDisplayMode "twoUp"
209+
210+twoUpContinuous :: PDFDisplayMode
211+twoUpContinuous = PDFDisplayMode "twoUpContinuous"
test/MacOSImperative.test.js+43−27
@@ -30,15 +30,15 @@ import {
3030 showFontPanelImpl,
3131 hideFontPanelImpl,
3232} from "../src/Yoga/React/Native/MacOS/FontPanel.js";
33−import { recognizeImpl } from "../src/Yoga/React/Native/MacOS/OCR.js";
33+import { recognizeTextImpl } from "../src/Yoga/React/Native/MacOS/OCR.js";
3434import {
35− startListeningImpl,
36− stopListeningImpl,
37− getTranscriptImpl,
35+ startImpl,
36+ stopImpl,
37+ pollTranscriptImpl,
3838} from "../src/Yoga/React/Native/MacOS/SpeechRecognition.js";
3939import {
4040 detectLanguageImpl,
41− sentimentImpl,
41+ analyzeSentimentImpl,
4242 tokenizeImpl,
4343} from "../src/Yoga/React/Native/MacOS/NaturalLanguage.js";
4444
@@ -240,12 +240,16 @@ describe("macOS FontPanel FFI", () => {
240240});
241241
242242describe("macOS OCR FFI", () => {
243− it("recognizeImpl calls NativeModules.MacOSOCRModule.recognize with callback", async () => {
243+ it("recognizeTextImpl returns an Aff that calls NativeModules.MacOSOCRModule.recognize", async () => {
244244 const spy = vi.spyOn(NativeModules.MacOSOCRModule, "recognize");
245+ const aff = recognizeTextImpl("/tmp/test.png");
245246 let result = "";
246− recognizeImpl("/tmp/test.png", (text) => () => {
247− result = text;
248− });
247+ aff(
248+ (err) => {},
249+ (val) => {
250+ result = val;
251+ },
252+ );
249253 await new Promise((r) => setTimeout(r, 10));
250254 expect(spy).toHaveBeenCalledWith("/tmp/test.png");
251255 expect(result).toBe("Recognized text from /tmp/test.png");
@@ -254,17 +258,17 @@ describe("macOS OCR FFI", () => {
254258});
255259
256260describe("macOS SpeechRecognition FFI", () => {
257− it("startListeningImpl calls NativeModules.MacOSSpeechRecognitionModule.start", () => {
261+ it("startImpl calls NativeModules.MacOSSpeechRecognitionModule.start", () => {
258262 const spy = vi.spyOn(NativeModules.MacOSSpeechRecognitionModule, "start");
259− startListeningImpl();
263+ startImpl();
260264 expect(spy).toHaveBeenCalled();
261265 spy.mockRestore();
262266 });
263267
264− it("stopListeningImpl calls stop and invokes callback with transcript", async () => {
268+ it("stopImpl calls stop and invokes callback with transcript", async () => {
265269 const spy = vi.spyOn(NativeModules.MacOSSpeechRecognitionModule, "stop");
266270 let result = "";
267− stopListeningImpl((text) => () => {
271+ stopImpl((text) => () => {
268272 result = text;
269273 });
270274 await new Promise((r) => setTimeout(r, 10));
@@ -273,13 +277,13 @@ describe("macOS SpeechRecognition FFI", () => {
273277 spy.mockRestore();
274278 });
275279
276− it("getTranscriptImpl calls getTranscript and invokes callback", async () => {
280+ it("pollTranscriptImpl calls getTranscript and invokes callback", async () => {
277281 const spy = vi.spyOn(
278282 NativeModules.MacOSSpeechRecognitionModule,
279283 "getTranscript",
280284 );
281285 let result = "";
282− getTranscriptImpl((text) => () => {
286+ pollTranscriptImpl((text) => () => {
283287 result = text;
284288 });
285289 await new Promise((r) => setTimeout(r, 10));
@@ -290,36 +294,48 @@ describe("macOS SpeechRecognition FFI", () => {
290294});
291295
292296describe("macOS NaturalLanguage FFI", () => {
293− it("detectLanguageImpl calls detectLanguage and invokes callback", async () => {
297+ it("detectLanguageImpl returns an Aff that resolves with language code", async () => {
294298 const spy = vi.spyOn(NativeModules.MacOSNLModule, "detectLanguage");
299+ const aff = detectLanguageImpl("Hello world");
295300 let result = "";
296− detectLanguageImpl("Hello world", (lang) => () => {
297− result = lang;
298− });
301+ aff(
302+ (err) => {},
303+ (val) => {
304+ result = val;
305+ },
306+ );
299307 await new Promise((r) => setTimeout(r, 10));
300308 expect(spy).toHaveBeenCalledWith("Hello world");
301309 expect(result).toBe("en");
302310 spy.mockRestore();
303311 });
304312
305− it("sentimentImpl calls sentiment and invokes callback with score", async () => {
313+ it("analyzeSentimentImpl returns an Aff that resolves with score", async () => {
306314 const spy = vi.spyOn(NativeModules.MacOSNLModule, "sentiment");
315+ const aff = analyzeSentimentImpl("I love this");
307316 let result = -1;
308− sentimentImpl("I love this", (score) => () => {
309− result = score;
310− });
317+ aff(
318+ (err) => {},
319+ (val) => {
320+ result = val;
321+ },
322+ );
311323 await new Promise((r) => setTimeout(r, 10));
312324 expect(spy).toHaveBeenCalledWith("I love this");
313325 expect(result).toBe(0.5);
314326 spy.mockRestore();
315327 });
316328
317− it("tokenizeImpl calls tokenize and invokes callback with tokens", async () => {
329+ it("tokenizeImpl returns an Aff that resolves with tokens", async () => {
318330 const spy = vi.spyOn(NativeModules.MacOSNLModule, "tokenize");
331+ const aff = tokenizeImpl("Hello world");
319332 let result = [];
320− tokenizeImpl("Hello world", (tokens) => () => {
321− result = tokens;
322− });
333+ aff(
334+ (err) => {},
335+ (val) => {
336+ result = val;
337+ },
338+ );
323339 await new Promise((r) => setTimeout(r, 10));
324340 expect(spy).toHaveBeenCalledWith("Hello world");
325341 expect(result).toEqual(["Hello", "world"]);