@smileid/usesmileid_vision_face 12.1.1 → 12.2.1-SNAPSHOT.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,15 +1,17 @@
1
1
  Full release notes: [Smile ID docs](https://docs.usesmileid.com/developer-resources/sdks/mobile/release-notes/react-native-expo).
2
2
 
3
+ ## 12.2.0
4
+
5
+ See the repository CHANGELOG for the 12.2.0 release notes.
6
+
3
7
  ## 12.1.1
4
8
 
5
9
  See the repository CHANGELOG for the 12.1.1 release notes.
6
10
 
7
-
8
11
  ## 12.1.0
9
12
 
10
13
  See the repository CHANGELOG for the 12.1.0 release notes.
11
14
 
12
-
13
15
  ## 12.0.2
14
16
 
15
17
  See the repository CHANGELOG for the 12.0.2 release notes.
@@ -1,6 +1,8 @@
1
1
  // MIRRORED FILE — duplicated in both RN Expo iOS vision modules
2
- // (usesmileid_vision_face / usesmileid_vision_document); the per-module pod
3
- // targets can't share sources. Apply any edit to BOTH copies in the same commit.
2
+ // (usesmileid_vision_face / usesmileid_vision_document). The per-module pod
3
+ // targets can't share sources, and the bridge pod that both depend on doesn't
4
+ // link the native UseSmileIDBridge package this conforms to. Apply any edit to
5
+ // BOTH copies in the same commit.
4
6
 
5
7
  import Foundation
6
8
  import UIKit
@@ -17,11 +19,34 @@ import UseSmileIDBridge
17
19
  final class ResizingFrameStorage: FrameStorage {
18
20
  /// Longest-edge pixel bound applied to document frames before encoding.
19
21
  private static let documentMaxDimension = 1600
20
- /// Outward padding fraction applied to the guide-rect crop on each side
21
- /// before cropping. Fifteen percent of the box's own width/height keeps the
22
- /// document's corners and edges from being clipped when the card is framed
23
- /// tight against the on-screen guide.
24
- private static let documentCropPaddingFraction: CGFloat = 0.15
22
+ /// The hidden border kept around the guide rect: this fraction of the saved
23
+ /// image's short side on every edge, the rule of the native bridges'
24
+ /// `DocumentCropGeometry.documentBorderFraction` and the selfie's interior
25
+ /// crop. The border is where presentation-attack cues live; the backend reads
26
+ /// it, and the preview shows only the interior.
27
+ static let documentBorderFraction: CGFloat = 0.15
28
+
29
+ /// The uniform border, in pixels, to grow `guide` by: the b that is
30
+ /// `documentBorderFraction` of the grown rect's own short side,
31
+ /// b = f × s / (1 − 2f) for the guide's short side s. Shrinks to the most
32
+ /// every edge of an `imageSize` image can hold; 0 for an empty guide or one
33
+ /// touching an edge. Mirrors the native bridges' `DocumentCropGeometry.borderAround`.
34
+ static func borderAround(_ guide: CGRect, imageSize: CGSize) -> CGFloat {
35
+ let shortSide = min(guide.width, guide.height)
36
+ guard shortSide > 0 else { return 0 }
37
+ let wanted = documentBorderFraction * shortSide / (1 - 2 * documentBorderFraction)
38
+ let room = min(guide.minX, guide.minY, imageSize.width - guide.maxX, imageSize.height - guide.maxY)
39
+ return max(min(wanted, room), 0)
40
+ }
41
+
42
+ /// A `border` around a crop of `size` as a fraction of the crop's short side,
43
+ /// held to `documentBorderFraction` so the crop's pixel rounding never reports
44
+ /// more border than was kept; 0 for an empty crop.
45
+ static func borderFraction(_ border: CGFloat, of size: CGSize) -> CGFloat {
46
+ let shortSide = min(size.width, size.height)
47
+ guard shortSide > 0 else { return 0 }
48
+ return min(max(border / shortSide, 0), documentBorderFraction)
49
+ }
25
50
 
26
51
  private let sessionDirectory: URL
27
52
  private let captureType: CaptureType
@@ -34,12 +59,18 @@ final class ResizingFrameStorage: FrameStorage {
34
59
  private let livenessQuality: CGFloat
35
60
  private let documentQuality: CGFloat
36
61
  private let writer: Writer
62
+ /// The hidden border each document this storage saved kept, recorded by the
63
+ /// write tasks once their file is on disk and read after `flush()`.
64
+ private let savedBorders = SavedDocumentBorders()
37
65
  // The analysis tick calls `setCropRegion` on the module's async-function
38
66
  // thread; `persist` reads the value on that same serialised scan path before
39
67
  // handing it (by value) to the background encode task. The Swift value is a
40
68
  // `Sendable` struct captured by value into `Writer.enqueue`, so no background
41
69
  // task ever observes a torn read of this property.
42
- nonisolated(unsafe) private var cropRegion: ViewportCropRegion?
70
+ //
71
+ // Read back as `FrameStorage.cropRegion`, which the document transitioner judges the detected
72
+ // document against, so auto-capture waits for the document inside the outline (ios-v12 #370).
73
+ nonisolated(unsafe) private(set) var cropRegion: ViewportCropRegion?
43
74
 
44
75
  init(
45
76
  sessionDirectory: URL,
@@ -124,7 +155,8 @@ final class ResizingFrameStorage: FrameStorage {
124
155
  resize: resize,
125
156
  maxDimension: maxDimension,
126
157
  quality: quality,
127
- documentCropRegion: documentCropRegion
158
+ documentCropRegion: documentCropRegion,
159
+ savedBorders: savedBorders
128
160
  )
129
161
  }
130
162
  }
@@ -133,6 +165,16 @@ final class ResizingFrameStorage: FrameStorage {
133
165
  try await writer.flush()
134
166
  }
135
167
 
168
+ /// The hidden border the document at `url` was saved with, as a fraction of its
169
+ /// short side on every edge, once `flush()` has returned:
170
+ /// `documentBorderFraction` when the frame had room for all of it, less where it
171
+ /// did not, and 0 for a document saved without a guide crop or a file this
172
+ /// storage did not write. The screens that show the document hide exactly this
173
+ /// much.
174
+ func documentBorderFraction(at url: URL) -> CGFloat {
175
+ savedBorders.fraction(for: url)
176
+ }
177
+
136
178
  func clear() {
137
179
  // Selfie and document captures share one session directory; delete
138
180
  // only this role's own frames.
@@ -143,6 +185,7 @@ final class ResizingFrameStorage: FrameStorage {
143
185
  )) ?? []
144
186
  for fileURL in contents where ownsForClear(fileURL) {
145
187
  try? fileManager.removeItem(at: fileURL)
188
+ savedBorders.record(0, for: fileURL)
146
189
  }
147
190
  }
148
191
 
@@ -170,17 +213,24 @@ final class ResizingFrameStorage: FrameStorage {
170
213
  resize: (Int, Int)?,
171
214
  maxDimension: Int?,
172
215
  quality: CGFloat,
173
- documentCropRegion: ViewportCropRegion?
216
+ documentCropRegion: ViewportCropRegion?,
217
+ savedBorders: SavedDocumentBorders
174
218
  ) {
175
219
  let task = Task<Void, Error>.detached(priority: .utility) {
176
- try Self.encodeAndWrite(
177
- image: image,
178
- to: url,
179
- resize: resize,
180
- maxDimension: maxDimension,
181
- quality: quality,
182
- documentCropRegion: documentCropRegion
183
- )
220
+ do {
221
+ let borderFraction = try Self.encodeAndWrite(
222
+ image: image,
223
+ to: url,
224
+ resize: resize,
225
+ maxDimension: maxDimension,
226
+ quality: quality,
227
+ documentCropRegion: documentCropRegion
228
+ )
229
+ savedBorders.record(borderFraction, for: url)
230
+ } catch {
231
+ savedBorders.record(0, for: url)
232
+ throw error
233
+ }
184
234
  }
185
235
  inFlight.append(task)
186
236
  }
@@ -208,7 +258,7 @@ final class ResizingFrameStorage: FrameStorage {
208
258
  maxDimension: Int?,
209
259
  quality: CGFloat,
210
260
  documentCropRegion: ViewportCropRegion?
211
- ) throws {
261
+ ) throws -> CGFloat {
212
262
  try FileManager.default.createDirectory(
213
263
  at: url.deletingLastPathComponent(),
214
264
  withIntermediateDirectories: true
@@ -219,6 +269,8 @@ final class ResizingFrameStorage: FrameStorage {
219
269
  // render the file sideways.
220
270
  let upright = image.normalizingOrientation()
221
271
  let data: Data?
272
+ // The border the saved document kept, as a fraction of its short side.
273
+ var borderFraction: CGFloat = 0
222
274
  if let (width, height) = resize, let cgImage = upright.cgImage {
223
275
  // Center-crop and scale to the token-resolved target size
224
276
  // (selfie / liveness path).
@@ -236,20 +288,25 @@ final class ResizingFrameStorage: FrameStorage {
236
288
  let pixelWidth = cgImage.width
237
289
  let pixelHeight = cgImage.height
238
290
  let box = region.toFrameBoundingBox(frameWidth: pixelWidth, frameHeight: pixelHeight)
239
- // Pad the guide rect outward by documentCropPaddingFraction of its
240
- // own size on each side so the document's corners and edges keep
241
- // breathing room and are never clipped when framed tight. Intersect
242
- // with the image bounds so a padded edge never runs off-frame.
243
- let padX = CGFloat(box.width) * ResizingFrameStorage.documentCropPaddingFraction
244
- let padY = CGFloat(box.height) * ResizingFrameStorage.documentCropPaddingFraction
245
- let cropRect = CGRect(
246
- x: (CGFloat(box.left) - padX) * CGFloat(pixelWidth),
247
- y: (CGFloat(box.top) - padY) * CGFloat(pixelHeight),
248
- width: (CGFloat(box.width) + padX * 2) * CGFloat(pixelWidth),
249
- height: (CGFloat(box.height) + padY * 2) * CGFloat(pixelHeight)
250
- ).intersection(CGRect(x: 0, y: 0, width: pixelWidth, height: pixelHeight))
291
+ // Grow the guide rect by the hidden border (documentBorderFraction of
292
+ // the saved image's short side, uniform on every edge), which the
293
+ // preview never shows; clamp to the image bounds.
294
+ let imageSize = CGSize(width: pixelWidth, height: pixelHeight)
295
+ let guide = CGRect(
296
+ x: CGFloat(box.left) * imageSize.width,
297
+ y: CGFloat(box.top) * imageSize.height,
298
+ width: CGFloat(box.width) * imageSize.width,
299
+ height: CGFloat(box.height) * imageSize.height
300
+ )
301
+ let border = ResizingFrameStorage.borderAround(guide, imageSize: imageSize)
302
+ let cropRect = guide.insetBy(dx: -border, dy: -border)
303
+ .intersection(CGRect(origin: .zero, size: imageSize))
251
304
  if let cropped = cgImage.cropping(to: cropRect) {
252
305
  cgImage = cropped
306
+ borderFraction = ResizingFrameStorage.borderFraction(
307
+ border,
308
+ of: CGSize(width: cropped.width, height: cropped.height)
309
+ )
253
310
  }
254
311
  }
255
312
  let pixelWidth = cgImage.width
@@ -277,10 +334,27 @@ final class ResizingFrameStorage: FrameStorage {
277
334
  throw ResizingFrameStorageError.encodeFailed(url: url)
278
335
  }
279
336
  try data.write(to: url, options: .atomic)
337
+ return borderFraction
280
338
  }
281
339
  }
282
340
  }
283
341
 
342
+ /// The hidden border each document a `ResizingFrameStorage` saved kept, as a
343
+ /// fraction of its short side, by file. Shared with the detached write tasks, so it
344
+ /// is lock-guarded.
345
+ final class SavedDocumentBorders: @unchecked Sendable {
346
+ private let lock = NSLock()
347
+ private var fractions: [URL: CGFloat] = [:]
348
+
349
+ func record(_ fraction: CGFloat, for url: URL) {
350
+ lock.withLock { fractions[url.standardizedFileURL] = fraction > 0 ? fraction : nil }
351
+ }
352
+
353
+ func fraction(for url: URL) -> CGFloat {
354
+ lock.withLock { fractions[url.standardizedFileURL] ?? 0 }
355
+ }
356
+ }
357
+
284
358
  enum ResizingFrameStorageError: Error, Sendable {
285
359
  case encodeFailed(url: URL)
286
360
  }
@@ -1,49 +1,16 @@
1
- import CoreMedia
2
1
  import Kamera
3
- import CoreImage
4
2
  import UIKit
5
3
 
6
-
7
- private extension CGImagePropertyOrientation {
8
- /// Kamera rotation + mirror metadata → the preview's selfie-mirrored view. The mirror
9
- /// applies after the rotation, so the quarter-turn cases swap (`.right` ↔ `.leftMirrored`).
10
- init(rotationDegrees: Int, isMirrored: Bool) {
11
- let normalised = ((rotationDegrees % 360) + 360) % 360
12
- switch (normalised, isMirrored) {
13
- case (90, false): self = .right
14
- case (90, true): self = .leftMirrored
15
- case (180, false): self = .down
16
- case (180, true): self = .downMirrored
17
- case (270, false): self = .left
18
- case (270, true): self = .rightMirrored
19
- case (_, true): self = .upMirrored
20
- default: self = .up
21
- }
22
- }
23
- }
24
-
25
4
  /// Scans the latest `KameraFrameHub` frame; no pending frame returns the `no_frame` wire error.
26
5
  enum UseSmileIDVisionFaceKameraTick {
27
- /// Reused across ticks — per-tick creation recompiles the GPU pipeline.
28
- private static let ciContext = CIContext(options: [.useSoftwareRenderer: false])
29
-
30
6
  static func analyze(arguments: [String: Any?]?) -> Any {
31
7
  guard let frame = KameraFrameHub.take() else {
32
8
  return ["error": "no_frame"]
33
9
  }
34
- guard let concrete = frame as? CVPixelBufferKameraFrame else {
35
- frame.close()
36
- return ["error": "frame_conversion_failed"]
37
- }
38
-
39
- let orientedImage = CIImage(cvPixelBuffer: concrete.pixelBuffer)
40
- .oriented(CGImagePropertyOrientation(
41
- rotationDegrees: frame.rotationDegrees,
42
- isMirrored: frame.isMirrored
43
- ))
44
- let cgImage = ciContext.createCGImage(orientedImage, from: orientedImage.extent)
45
- frame.close()
46
- guard let cgImage else {
10
+ // The session reads the live frame's readings as the scan runs; closing it afterwards releases
11
+ // it to the pipeline for the next one.
12
+ defer { frame.close() }
13
+ guard frame is CVPixelBufferKameraFrame else {
47
14
  return ["error": "frame_conversion_failed"]
48
15
  }
49
16
 
@@ -51,7 +18,7 @@ enum UseSmileIDVisionFaceKameraTick {
51
18
  let rotation = (arguments?["rotation"] as? NSNumber)?.intValue ?? 0
52
19
 
53
20
  guard let result = UseSmileIDVisionFaceSessionHolder.shared.scanAndDrain(
54
- image: UIImage(cgImage: cgImage),
21
+ frame: frame,
55
22
  rotation: rotation
56
23
  ) else {
57
24
  return ["error": "session_not_configured"]
@@ -129,12 +129,11 @@ private extension Dictionary where Key == String, Value == Any? {
129
129
  func toFaceScanConfig() -> FaceScanConfig {
130
130
  let base = FaceDetectorMode.from(token: self["faceDetectorMode"] as? String).config
131
131
  return FaceScanConfig(
132
- selfieCaptureTimeout: (self["selfieCaptureTimeout"] as? NSNumber)?.intValue ?? 100_000,
133
- // Default yields 1 selfie + 7 liveness frames — the server requires
134
- // `liveness_images >= 6`, so anything below 7 causes submission rejection.
135
- numSamples: (self["numSamples"] as? NSNumber)?.intValue ?? 8,
136
- sampleInterval: (self["sampleInterval"] as? NSNumber)?.intValue ?? 350,
137
- stayInFoundDuration: (self["stayInFoundDuration"] as? NSNumber)?.intValue ?? 3_000,
132
+ numSamples: (self["numSamples"] as? NSNumber)?.intValue ?? ThresholdConfig.FaceCapture.numSamples,
133
+ sampleInterval: (self["sampleInterval"] as? NSNumber)?.intValue
134
+ ?? ThresholdConfig.FaceCapture.sampleIntervalMs,
135
+ stayInFoundDuration: (self["stayInFoundDuration"] as? NSNumber)?.intValue
136
+ ?? ThresholdConfig.FaceCapture.defaultStayInFoundDuration,
138
137
  faceDetectorConfig: FaceDetectorConfig(
139
138
  minFillThreshold: (self["minFillThreshold"] as? NSNumber)?.floatValue ?? base.minFillThreshold,
140
139
  maxFillThreshold: (self["maxFillThreshold"] as? NSNumber)?.floatValue ?? base.maxFillThreshold,
@@ -262,12 +261,22 @@ extension UseSmileIDBridge.UseSmileIDScanState {
262
261
  if let captureProgress {
263
262
  map["captureProgress"] = Double(captureProgress)
264
263
  }
265
- // Latched pose-match flag for the ESS head turn — drives the grey→green
266
- // direction arrow. Emit only while matched (omit ⇒ false on the JS side),
264
+ // Latched pose-match flag for the ESS head turn — keeps the direction arrow
265
+ // green through a hold at the threshold. Emit only while matched (omit ⇒ false on the JS side),
267
266
  // matching the optional-field omission convention above.
268
267
  if poseMatched {
269
268
  map["poseMatched"] = true
270
269
  }
270
+ switch captureStep {
271
+ case .holdStill: map["captureStep"] = "holdStill"
272
+ case .smile: map["captureStep"] = "smile"
273
+ // None yet, and a step a newer bridge adds crosses as none.
274
+ case nil: break
275
+ @unknown default: break
276
+ }
277
+ if let turnProgress {
278
+ map["turnProgress"] = Double(turnProgress)
279
+ }
271
280
  return map
272
281
  }
273
282
  }
@@ -1,4 +1,5 @@
1
1
  import Foundation
2
+ import Kamera
2
3
  import UIKit
3
4
  import UseSmileIDBridge
4
5
  import UseSmileIDVisionFace
@@ -66,7 +67,11 @@ final class UseSmileIDVisionFaceSessionHolder {
66
67
 
67
68
  /// Run `session.scan` + `drainCaptureReadyEvents` as one atomic unit, blocking the
68
69
  /// caller. Returns `nil` when no session is configured; never throws.
69
- func scanAndDrain(image: UIImage, rotation: Int) -> [String: Any]? {
70
+ ///
71
+ /// The session analyses the kamera `frame`'s pixel buffer as the native capture screens do, with
72
+ /// the readings kamera took on it (device motion, tracked face, live-frame quality), and draws an
73
+ /// image only for a frame a capture keeps. The caller keeps `frame` open until this returns.
74
+ func scanAndDrain(frame: any KameraFrame, rotation: Int) -> [String: Any]? {
70
75
  var result: [String: Any]?
71
76
  operationQueue.sync {
72
77
  guard let currentSession = session else {
@@ -74,8 +79,14 @@ final class UseSmileIDVisionFaceSessionHolder {
74
79
  }
75
80
  let resultBox = ScanResultBox()
76
81
  let semaphore = DispatchSemaphore(value: 0)
82
+ let frameBox = KameraFrameBox(frame)
77
83
  Task.detached(priority: .userInitiated) {
78
- let scanResult = await currentSession.scan(image: image, rotation: rotation)
84
+ guard let scanResult = await currentSession.scan(kameraFrame: frameBox.frame, rotation: rotation) else {
85
+ // A frame with no pixel buffer; the tick only hands over pixel-buffer frames.
86
+ resultBox.value = ["error": "frame_conversion_failed"]
87
+ semaphore.signal()
88
+ return
89
+ }
79
90
  let nativeOutput = scanResult.output as? UseSmileIDBridge.FaceDetectorOutput
80
91
  let state = scanResult.state
81
92
  let events = currentSession.drainCaptureReadyEvents()
@@ -93,6 +104,16 @@ final class UseSmileIDVisionFaceSessionHolder {
93
104
  }
94
105
  }
95
106
 
107
+ /// Carries the kamera frame into the scan Task. Kamera frames guard their planes with their own
108
+ /// lock, and the caller blocks on the Task while it reads the frame.
109
+ private final class KameraFrameBox: @unchecked Sendable {
110
+ let frame: any KameraFrame
111
+
112
+ init(_ frame: any KameraFrame) {
113
+ self.frame = frame
114
+ }
115
+ }
116
+
96
117
  /// Shuttles a scan result from the cooperative-pool Task to the semaphore-blocked caller.
97
118
  private final class ScanResultBox: @unchecked Sendable {
98
119
  var value: [String: Any] = [:]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smileid/usesmileid_vision_face",
3
- "version": "12.1.1",
3
+ "version": "12.2.1-SNAPSHOT.44",
4
4
  "description": "Apple Vision face detection implementation for the UseSmileID React Native (Expo) SDK.",
5
5
  "homepage": "https://usesmileid.com",
6
6
  "repository": {
@@ -46,8 +46,8 @@
46
46
  "CHANGELOG.md"
47
47
  ],
48
48
  "dependencies": {
49
- "@smileid/usesmileid_bridge": "12.1.1",
50
- "@smileid/usesmileid_platform_interface": "12.1.1"
49
+ "@smileid/usesmileid_bridge": "12.2.1-SNAPSHOT.44",
50
+ "@smileid/usesmileid_platform_interface": "12.2.1-SNAPSHOT.44"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "expo": ">=56.0.0",
@@ -55,9 +55,9 @@
55
55
  "react-native": ">=0.83.0"
56
56
  },
57
57
  "devDependencies": {
58
- "@types/node": "^26.4.1",
59
- "expo": "~57.0.20",
60
- "expo-modules-core": "^57.0.16",
58
+ "@types/node": "^26.6.4",
59
+ "expo": "~57.0.26",
60
+ "expo-modules-core": "^57.0.20",
61
61
  "react-native": "^0.86.3",
62
62
  "typescript": "^6.0.3",
63
63
  "xcode": "^3.0.1"
@@ -1 +1 @@
1
- "use strict";var __createBinding=this&&this.__createBinding||(Object.create?(function(i,r,t,e){e===void 0&&(e=t);var o=Object.getOwnPropertyDescriptor(r,t);(!o||("get"in o?!r.__esModule:o.writable||o.configurable))&&(o={enumerable:!0,get:function(){return r[t]}}),Object.defineProperty(i,e,o)}):(function(i,r,t,e){e===void 0&&(e=t),i[e]=r[t]})),__setModuleDefault=this&&this.__setModuleDefault||(Object.create?(function(i,r){Object.defineProperty(i,"default",{enumerable:!0,value:r})}):function(i,r){i.default=r}),__importStar=this&&this.__importStar||(function(){var i=function(r){return i=Object.getOwnPropertyNames||function(t){var e=[];for(var o in t)Object.prototype.hasOwnProperty.call(t,o)&&(e[e.length]=o);return e},i(r)};return function(r){if(r&&r.__esModule)return r;var t={};if(r!=null)for(var e=i(r),o=0;o<e.length;o++)e[o]!=="default"&&__createBinding(t,r,e[o]);return __setModuleDefault(t,r),t}})();Object.defineProperty(exports,"__esModule",{value:!0}),exports.SPM_PRODUCTS=void 0;const config_plugins_1=require("expo/config-plugins"),fs=__importStar(require("fs")),path=__importStar(require("path")),deployment_target_1=require("./lib/deployment_target"),podfile_1=require("./lib/podfile"),signature_dedup_1=require("./lib/signature_dedup"),spm_1=require("./lib/spm"),IOS_DEPLOYMENT_TARGET="16.4",SMILE_ID_PACKAGE={url:"https://github.com/smileidentity/ios-spm",requirement:{kind:"exactVersion",version:"12.1.1"}},POD_TARGET="UseSmileIDVisionFaceExpo";exports.SPM_PRODUCTS=["UseSmileIDVisionFace"];const PODFILE_MARKER="usesmileid_vision_face",BRIDGE_POD_TARGET="UseSmileIDBridgeExpo",BRIDGE_SPM_PRODUCTS=["UseSmileIDBridge"],BRIDGE_PODFILE_MARKER="usesmileid_bridge",withVisionFaceSpm=(i,r)=>(i=(0,deployment_target_1.withIosDeploymentTargetFloor)(i,IOS_DEPLOYMENT_TARGET),i=(0,config_plugins_1.withXcodeProject)(i,t=>{const e=t.modRequest.projectName;if(!e)throw new Error("[usesmileid_vision_face] Unable to resolve host app native target name from Expo config.");return(0,spm_1.linkSpmPackage)(t.modResults,SMILE_ID_PACKAGE,{targetName:e,products:[...exports.SPM_PRODUCTS]}),r?.iosSignatureAggregation==="disabled"?(0,signature_dedup_1.disableSignatureAggregation)(t.modResults,e):(0,signature_dedup_1.ensureSignatureDedupPhase)(t.modResults,e),t}),i=(0,config_plugins_1.withDangerousMod)(i,["ios",t=>{const e=path.join(t.modRequest.platformProjectRoot,"Podfile"),o=fs.readFileSync(e,"utf-8");let n=(0,podfile_1.injectPodfileSpmBlock)(o,{marker:PODFILE_MARKER,package:SMILE_ID_PACKAGE,podTarget:POD_TARGET,products:[...exports.SPM_PRODUCTS]});return n=(0,podfile_1.injectPodfileSpmBlock)(n,{marker:BRIDGE_PODFILE_MARKER,package:SMILE_ID_PACKAGE,podTarget:BRIDGE_POD_TARGET,products:[...BRIDGE_SPM_PRODUCTS],required:!0}),n=(0,podfile_1.injectPodfileKameraLinkBlock)(n),n=(0,podfile_1.injectPodfileSignatureDedupBlock)(n),n!==o&&fs.writeFileSync(e,n),t}]),i);exports.default=withVisionFaceSpm;
1
+ "use strict";var __createBinding=this&&this.__createBinding||(Object.create?(function(i,r,t,e){e===void 0&&(e=t);var o=Object.getOwnPropertyDescriptor(r,t);(!o||("get"in o?!r.__esModule:o.writable||o.configurable))&&(o={enumerable:!0,get:function(){return r[t]}}),Object.defineProperty(i,e,o)}):(function(i,r,t,e){e===void 0&&(e=t),i[e]=r[t]})),__setModuleDefault=this&&this.__setModuleDefault||(Object.create?(function(i,r){Object.defineProperty(i,"default",{enumerable:!0,value:r})}):function(i,r){i.default=r}),__importStar=this&&this.__importStar||(function(){var i=function(r){return i=Object.getOwnPropertyNames||function(t){var e=[];for(var o in t)Object.prototype.hasOwnProperty.call(t,o)&&(e[e.length]=o);return e},i(r)};return function(r){if(r&&r.__esModule)return r;var t={};if(r!=null)for(var e=i(r),o=0;o<e.length;o++)e[o]!=="default"&&__createBinding(t,r,e[o]);return __setModuleDefault(t,r),t}})();Object.defineProperty(exports,"__esModule",{value:!0}),exports.SPM_PRODUCTS=void 0;const config_plugins_1=require("expo/config-plugins"),fs=__importStar(require("fs")),path=__importStar(require("path")),deployment_target_1=require("./lib/deployment_target"),podfile_1=require("./lib/podfile"),signature_dedup_1=require("./lib/signature_dedup"),spm_1=require("./lib/spm"),IOS_DEPLOYMENT_TARGET="16.4",SMILE_ID_PACKAGE={url:"https://github.com/smileidentity/ios-spm",requirement:{kind:"exactVersion",version:"12.2.0-SNAPSHOT.20261011230808.38182841286"}},POD_TARGET="UseSmileIDVisionFaceExpo";exports.SPM_PRODUCTS=["UseSmileIDVisionFace"];const PODFILE_MARKER="usesmileid_vision_face",BRIDGE_POD_TARGET="UseSmileIDBridgeExpo",BRIDGE_SPM_PRODUCTS=["UseSmileIDBridge"],BRIDGE_PODFILE_MARKER="usesmileid_bridge",withVisionFaceSpm=(i,r)=>(i=(0,deployment_target_1.withIosDeploymentTargetFloor)(i,IOS_DEPLOYMENT_TARGET),i=(0,config_plugins_1.withXcodeProject)(i,t=>{const e=t.modRequest.projectName;if(!e)throw new Error("[usesmileid_vision_face] Unable to resolve host app native target name from Expo config.");return(0,spm_1.linkSpmPackage)(t.modResults,SMILE_ID_PACKAGE,{targetName:e,products:[...exports.SPM_PRODUCTS]}),r?.iosSignatureAggregation==="disabled"?(0,signature_dedup_1.disableSignatureAggregation)(t.modResults,e):(0,signature_dedup_1.ensureSignatureDedupPhase)(t.modResults,e),t}),i=(0,config_plugins_1.withDangerousMod)(i,["ios",t=>{const e=path.join(t.modRequest.platformProjectRoot,"Podfile"),o=fs.readFileSync(e,"utf-8");let n=(0,podfile_1.injectPodfileSpmBlock)(o,{marker:PODFILE_MARKER,package:SMILE_ID_PACKAGE,podTarget:POD_TARGET,products:[...exports.SPM_PRODUCTS]});return n=(0,podfile_1.injectPodfileSpmBlock)(n,{marker:BRIDGE_PODFILE_MARKER,package:SMILE_ID_PACKAGE,podTarget:BRIDGE_POD_TARGET,products:[...BRIDGE_SPM_PRODUCTS],required:!0}),n=(0,podfile_1.injectPodfileKameraLinkBlock)(n),n=(0,podfile_1.injectPodfileSignatureDedupBlock)(n),n!==o&&fs.writeFileSync(e,n),t}]),i);exports.default=withVisionFaceSpm;
@@ -145,7 +145,7 @@ begin
145
145
  # @rpath/Kamera.framework/Kamera"). Xcode embeds a binary product only for an app
146
146
  # target that depends on it, so the app target takes both products directly.
147
147
  kamera_spm_url = 'https://github.com/smileidentity/kamera-spm'
148
- kamera_spm_version = JSON.parse(File.read(kamera_package_json))['version'].sub(/-SNAPSHOT.*$/, '')
148
+ kamera_spm_version = JSON.parse(File.read(kamera_package_json))['version'].sub(/-SNAPSHOT$/, '')
149
149
  kamera_spm_app_products = %w[Kamera KameraVision]
150
150
  # A reference an app added by hand may spell the same repository differently (.git suffix,
151
151
  # trailing slash, case); a second reference to one repository fails Xcode's resolution.
@@ -1,119 +0,0 @@
1
- // MIRRORED FILE — this file is duplicated byte-identical across the two
2
- // RN Expo iOS bridge modules. Canonical location:
3
- // usesmileid_vision_face/ios/UseSmileIDVisionFaceModule/
4
- // UseSmileIDAnalyzerExecutor.swift
5
- // Mirror location:
6
- // usesmileid_vision_document/ios/UseSmileIDVisionDocumentModule/
7
- // UseSmileIDAnalyzerExecutor.swift
8
- //
9
- // Each RN module is its own CocoaPods bridge target with its own
10
- // `source_files` glob; source cannot be shared across them without
11
- // inventing a new pod. The helper is duplicated verbatim into both
12
- // vision modules. Any edit here MUST be copied to the other location in
13
- // the same commit.
14
- //
15
- // Purpose: collapse the analyze / flush plumbing of the two RN Expo
16
- // vision modules into a single generic executor whose Task closures
17
- // capture only @Sendable values (closures + locals) — no module-class
18
- // state, no implicit `self`. This forces both modules to share one set
19
- // of Sendable correctness properties by construction: if one compiles
20
- // cleanly under strict concurrency, the other must too.
21
- //
22
- // RN-specific differences from the Flutter twin:
23
- // • RN modules receive pre-decoded JPEG/PNG bytes (`UIImage(data:)`),
24
- // so the executor accepts `UIImage` directly rather than Pigeon's
25
- // `YuvImage` and an associated decoder. JPEG decoding happens in
26
- // the per-module wrapper before the executor is invoked.
27
- // • The completion signature is `(Result<PigeonResult, Error>) -> Void`
28
- // identical to Flutter — the per-module wrapper translates that
29
- // into Expo's `Promise.resolve`/`reject`. Keeping the executor's
30
- // public surface a `Result` consumer means both bridges share the
31
- // same Task discipline.
32
-
33
- import ExpoModulesCore
34
- import UIKit
35
- import UseSmileIDBridge
36
-
37
- /// Bridges an Expo Modules @escaping completion handler into Swift 6
38
- /// Task captures. Expo's `Promise` is itself reference-typed but not
39
- /// `Sendable`; the executor never captures a `Promise` — it captures a
40
- /// `Result` consumer closure that the per-module wrapper translates
41
- /// into `resolve`/`reject`. This box mirrors the Flutter PigeonCompletion
42
- /// pattern.
43
- final class ExecutorCompletion<T>: @unchecked Sendable {
44
- let call: (T) -> Void
45
- init(_ fn: @escaping (T) -> Void) { self.call = fn }
46
- }
47
-
48
- /// Generic helper that owns the analyze / flush bridging between the
49
- /// RN Expo Module's completion-based API and the native session's async
50
- /// API.
51
- ///
52
- /// The class itself is `@unchecked Sendable` because it stores only
53
- /// `@Sendable` closures (which are themselves Sendable). The downstream
54
- /// `Task.detached` bodies capture those closures — never an instance of a
55
- /// concrete module class — so the compiler can prove the Task body is
56
- /// Sendable-by-construction.
57
- ///
58
- /// `NativeOutput` is the concrete native analyzer output type (e.g.
59
- /// `FaceDetectorOutput`, `DocumentDetectorOutput`); `Wire` is the
60
- /// JS-side wire dictionary the module returns to JS (typed as
61
- /// `[String: Any?]` after Expo bridge marshalling).
62
- final class UseSmileIDAnalyzerExecutor<NativeOutput: AnalyzerOutput, Wire>: @unchecked Sendable {
63
-
64
- private let scan: @Sendable (UIImage, Int) async -> (NativeOutput?, UseSmileIDScanState)
65
- private let drainEvents: @Sendable () -> [UseSmileIDBridge.CaptureReadyEvent]
66
- private let flushFn: @Sendable () async throws -> Void
67
- private let buildResult: @Sendable (NativeOutput?, UseSmileIDScanState, [UseSmileIDBridge.CaptureReadyEvent]) -> Wire
68
-
69
- init(
70
- scan: @escaping @Sendable (UIImage, Int) async -> (NativeOutput?, UseSmileIDScanState),
71
- drainEvents: @escaping @Sendable () -> [UseSmileIDBridge.CaptureReadyEvent],
72
- flush: @escaping @Sendable () async throws -> Void,
73
- buildResult: @escaping @Sendable (NativeOutput?, UseSmileIDScanState, [UseSmileIDBridge.CaptureReadyEvent]) -> Wire
74
- ) {
75
- self.scan = scan
76
- self.drainEvents = drainEvents
77
- self.flushFn = flush
78
- self.buildResult = buildResult
79
- }
80
-
81
- /// Hands the already-decoded `UIImage` to the captured `scan` closure
82
- /// on a detached task. The Task's capture list explicitly lists every
83
- /// value it consumes so the compiler can verify the closure is
84
- /// Sendable without falling back on implicit `self` capture.
85
- ///
86
- /// The `completion` consumer is a `Result<Wire, Error>` — the
87
- /// per-module wrapper translates that into `Promise.resolve` /
88
- /// `Promise.reject`.
89
- func analyze(
90
- image: UIImage,
91
- rotation: Int,
92
- completion: @escaping (Result<Wire, Error>) -> Void
93
- ) {
94
- let scan = self.scan
95
- let drainEvents = self.drainEvents
96
- let buildResult = self.buildResult
97
- let completionBox = ExecutorCompletion(completion)
98
- Task.detached(priority: .userInitiated) {
99
- let (output, state) = await scan(image, rotation)
100
- let events = drainEvents()
101
- completionBox.call(.success(buildResult(output, state, events)))
102
- }
103
- }
104
-
105
- /// Awaits the captured `flushFn` on a detached task. Mirrors `analyze`'s
106
- /// capture discipline — no `self`, no class state.
107
- func flush(completion: @escaping (Result<Void, Error>) -> Void) {
108
- let flushFn = self.flushFn
109
- let completionBox = ExecutorCompletion(completion)
110
- Task.detached(priority: .userInitiated) {
111
- do {
112
- try await flushFn()
113
- completionBox.call(.success(()))
114
- } catch {
115
- completionBox.call(.failure(error))
116
- }
117
- }
118
- }
119
- }