catalyst-core 0.1.0-beta.2 → 0.1.0-beta.4

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.
Files changed (103) hide show
  1. package/README.md +1 -1
  2. package/bin/catalyst.js +8 -1
  3. package/changelog.md +6 -0
  4. package/dist/native/androidProject/app/build.gradle.kts +9 -0
  5. package/dist/native/androidProject/app/src/main/AndroidManifest.xml +2 -1
  6. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/BridgeMessageValidator.kt +1 -1
  7. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +17 -1
  8. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +31 -3
  9. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt +148 -0
  10. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeCameraManager.kt +337 -0
  11. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/TransitionManager.kt +202 -0
  12. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/WebCacheManager.kt +3 -3
  13. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/BarcodeDetector.kt +123 -0
  14. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/CameraSessionManager.kt +244 -0
  15. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/HoldController.kt +70 -0
  16. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/TorchController.kt +57 -0
  17. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/VideoStreamState.kt +41 -0
  18. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/VideoStreamStateMachine.kt +40 -0
  19. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/ViewfinderMapper.kt +75 -0
  20. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/ZoomController.kt +156 -0
  21. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/CatalystPlugin.kt +7 -0
  22. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/GeneratedPluginIndex.kt +6 -0
  23. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/PluginBridge.kt +253 -0
  24. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/security/FridaDetector.kt +16 -5
  25. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt +42 -5
  26. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/FrameworkServerUtils.kt +7 -3
  27. package/dist/native/androidProject/app/src/main/res/drawable/debug_viewfinder_border.xml +8 -0
  28. package/dist/native/androidProject/app/src/main/res/layout/activity_main.xml +37 -0
  29. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/plugins/PluginBridgeTest.kt +139 -0
  30. package/dist/native/androidSetup.js +1 -1
  31. package/dist/native/bridge/WebBridge.js +5 -5
  32. package/dist/native/bridge/constants/NativeInterfaces.js +9 -5
  33. package/dist/native/bridge/hooks/index.js +1 -0
  34. package/dist/native/bridge/hooks/useCamera.js +9 -0
  35. package/dist/native/bridge/hooks/useDataProtection.js +2 -0
  36. package/dist/native/bridge/hooks/useDeviceInfo.js +2 -0
  37. package/dist/native/bridge/hooks/useFilePicker.js +2 -0
  38. package/dist/native/bridge/hooks/useGoogleSignIn.js +1 -0
  39. package/dist/native/bridge/hooks/useHapticFeedback.js +1 -0
  40. package/dist/native/bridge/hooks/useIntent.js +1 -0
  41. package/dist/native/bridge/hooks/useNativeTransition.js +43 -0
  42. package/dist/native/bridge/hooks/useNetworkStatus.js +3 -0
  43. package/dist/native/bridge/hooks/useNotification.js +2 -0
  44. package/dist/native/bridge/hooks/useSafeArea.js +2 -0
  45. package/dist/native/bridge/hooks/useVideoStream.js +6 -0
  46. package/dist/native/bridge/hooks/utils.js +1 -0
  47. package/dist/native/bridge/hooks.js +1 -137
  48. package/dist/native/bridge/useBaseHook.js +12 -7
  49. package/dist/native/bridge/utils/NativeBridge.js +41 -6
  50. package/dist/native/buildAppAndroid.js +7 -9
  51. package/dist/native/buildAppIos.js +14 -22
  52. package/dist/native/internalPluginUtils.js +1 -0
  53. package/dist/native/iosnativeWebView/Package.resolved +36 -0
  54. package/dist/native/iosnativeWebView/Sources/Core/Camera/BarcodeDetector.swift +116 -0
  55. package/dist/native/iosnativeWebView/Sources/Core/Camera/CameraPreviewView.swift +58 -0
  56. package/dist/native/iosnativeWebView/Sources/Core/Camera/CameraSessionManager.swift +308 -0
  57. package/dist/native/iosnativeWebView/Sources/Core/Camera/HoldController.swift +63 -0
  58. package/dist/native/iosnativeWebView/Sources/Core/Camera/NativeCameraManager.swift +194 -0
  59. package/dist/native/iosnativeWebView/Sources/Core/Camera/TorchController.swift +60 -0
  60. package/dist/native/iosnativeWebView/Sources/Core/Camera/VideoStreamState.swift +44 -0
  61. package/dist/native/iosnativeWebView/Sources/Core/Camera/VideoStreamStateMachine.swift +60 -0
  62. package/dist/native/iosnativeWebView/Sources/Core/Camera/ZoomController.swift +130 -0
  63. package/dist/native/iosnativeWebView/Sources/Core/Constants/CatalystConstants.swift +11 -1
  64. package/dist/native/iosnativeWebView/Sources/Core/Plugins/CatalystPlugin.swift +5 -0
  65. package/dist/native/iosnativeWebView/Sources/Core/Plugins/GeneratedPluginIndex.swift +6 -0
  66. package/dist/native/iosnativeWebView/Sources/Core/Plugins/PluginBridge.swift +364 -0
  67. package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeMessageValidator.swift +4 -1
  68. package/dist/native/iosnativeWebView/Sources/Core/WebView/NativeBridge.swift +227 -4
  69. package/dist/native/iosnativeWebView/Sources/Core/WebView/TransitionManager.swift +169 -0
  70. package/dist/native/iosnativeWebView/Sources/Core/WebView/WeakScriptMessageHandler.swift +14 -0
  71. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +38 -7
  72. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +6 -2
  73. package/dist/native/iosnativeWebView/iosnativeWebView/ContentView.swift +15 -4
  74. package/dist/native/iosnativeWebView/iosnativeWebView/Info.plist +11 -17
  75. package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.pbxproj +8 -2
  76. package/dist/native/iosnativeWebView/iosnativeWebViewTests/PluginBridgeTests.swift +160 -0
  77. package/dist/native/plugin-bridge/PluginBridge.js +1 -0
  78. package/dist/native/pluginComposerAndroid.js +9 -0
  79. package/dist/native/pluginComposerIos.js +7 -0
  80. package/dist/native/renameAndroidProject.js +3 -3
  81. package/dist/native/setupServer.js +2 -3
  82. package/dist/native/utils.js +4 -1
  83. package/dist/otel.js +70 -2
  84. package/dist/scripts/devServe.js +1 -1
  85. package/dist/scripts/plugins.js +1 -0
  86. package/dist/scripts/registerAliases.js +6 -2
  87. package/dist/scripts/scriptUtils.js +2 -1
  88. package/dist/scripts/serve.js +2 -1
  89. package/dist/scripts/start.js +4 -1
  90. package/dist/server/renderer/extract.js +5 -5
  91. package/dist/server/renderer/handler.js +15 -14
  92. package/dist/server/utils/validator.js +1 -1
  93. package/dist/webpack/base.babel.js +2 -1
  94. package/dist/webpack/development.client.babel.js +6 -2
  95. package/dist/webpack/production.ssr.babel.js +5 -1
  96. package/mcp_v2/knowledge-base.json +1564 -1067
  97. package/mcp_v2/lib/helpers.js +19 -7
  98. package/mcp_v2/tools/build.js +1 -1
  99. package/mcp_v2/tools/config.js +16 -4
  100. package/mcp_v2/tools/conversion.js +11 -11
  101. package/mcp_v2/tools/knowledge.js +16 -3
  102. package/mcp_v2/tools/tasks.js +13 -4
  103. package/package.json +2 -2
@@ -0,0 +1,58 @@
1
+ import SwiftUI
2
+ import UIKit
3
+ import AVFoundation
4
+
5
+ /// UIViewRepresentable that hosts the camera preview layer.
6
+ /// Placed at ZStack index 0 (behind WebViewContainer).
7
+ /// The preview layer is managed by CameraSessionManager — this view just
8
+ /// provides the UIView container that CameraSessionManager embeds into.
9
+ public struct CameraPreviewView: UIViewRepresentable {
10
+
11
+ public let cameraManager: NativeCameraManager
12
+
13
+ public init(cameraManager: NativeCameraManager) {
14
+ self.cameraManager = cameraManager
15
+ }
16
+
17
+ public func makeUIView(context: Context) -> UIView {
18
+ let view = UIView()
19
+ view.backgroundColor = .clear
20
+ cameraManager.setPreviewView(view)
21
+
22
+ // Pinch gesture for zoom
23
+ let pinch = UIPinchGestureRecognizer(target: context.coordinator,
24
+ action: #selector(Coordinator.handlePinch(_:)))
25
+ view.addGestureRecognizer(pinch)
26
+ return view
27
+ }
28
+
29
+ public func updateUIView(_ uiView: UIView, context: Context) {
30
+ // updateUIView is always called on the main thread — update frame directly.
31
+ uiView.layer.sublayers?
32
+ .compactMap { $0 as? AVCaptureVideoPreviewLayer }
33
+ .forEach { $0.frame = uiView.bounds }
34
+ }
35
+
36
+ public func makeCoordinator() -> Coordinator {
37
+ Coordinator(cameraManager: cameraManager)
38
+ }
39
+
40
+ public class Coordinator: NSObject {
41
+ private let cameraManager: NativeCameraManager
42
+
43
+ public init(cameraManager: NativeCameraManager) {
44
+ self.cameraManager = cameraManager
45
+ }
46
+
47
+ @objc func handlePinch(_ recognizer: UIPinchGestureRecognizer) {
48
+ switch recognizer.state {
49
+ case .began:
50
+ cameraManager.handlePinchBegan()
51
+ case .changed:
52
+ cameraManager.handlePinchChanged(scale: recognizer.scale)
53
+ default:
54
+ break
55
+ }
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,308 @@
1
+ import Foundation
2
+ import AVFoundation
3
+ import UIKit
4
+ import os
5
+
6
+ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app", category: "CameraSessionManager")
7
+
8
+ /// Owns the AVCaptureSession lifecycle: configure, start, stop, flip, setFps.
9
+ /// Mirrors Android's CameraSessionManager.kt — same public API shape.
10
+ class CameraSessionManager: NSObject {
11
+
12
+ private let stateMachine: VideoStreamStateMachine
13
+ private let zoomController: ZoomController
14
+ private let torchController: TorchController
15
+ private let barcodeDetector: BarcodeDetector
16
+ private let onReady: () -> Void
17
+ private let onStopped: () -> Void
18
+ private let onError: (String) -> Void
19
+
20
+ private var session: AVCaptureSession?
21
+ private var previewLayer: AVCaptureVideoPreviewLayer?
22
+ private weak var previewView: UIView?
23
+ private let sessionQueue = DispatchQueue(label: "com.catalyst.camera.session")
24
+
25
+ // Session params — stored so flip/setFps can rebind with same settings.
26
+ // paramsLock guards reads/writes since callers may be on any thread while
27
+ // sessionQueue reads them concurrently.
28
+ private let paramsLock = NSLock()
29
+ private var _currentFacing: String = "back"
30
+ private var _currentFpsMin: Int?
31
+ private var _currentFpsMax: Int?
32
+ private var _currentScanFormat: String = "all"
33
+ private var _currentInitialZoom: Float = 1.0
34
+
35
+ private var currentFacing: String {
36
+ get { paramsLock.lock(); defer { paramsLock.unlock() }; return _currentFacing }
37
+ set { paramsLock.lock(); _currentFacing = newValue; paramsLock.unlock() }
38
+ }
39
+ private var currentFpsMin: Int? {
40
+ get { paramsLock.lock(); defer { paramsLock.unlock() }; return _currentFpsMin }
41
+ set { paramsLock.lock(); _currentFpsMin = newValue; paramsLock.unlock() }
42
+ }
43
+ private var currentFpsMax: Int? {
44
+ get { paramsLock.lock(); defer { paramsLock.unlock() }; return _currentFpsMax }
45
+ set { paramsLock.lock(); _currentFpsMax = newValue; paramsLock.unlock() }
46
+ }
47
+ private var currentScanFormat: String {
48
+ get { paramsLock.lock(); defer { paramsLock.unlock() }; return _currentScanFormat }
49
+ set { paramsLock.lock(); _currentScanFormat = newValue; paramsLock.unlock() }
50
+ }
51
+ private var currentInitialZoom: Float {
52
+ get { paramsLock.lock(); defer { paramsLock.unlock() }; return _currentInitialZoom }
53
+ set { paramsLock.lock(); _currentInitialZoom = newValue; paramsLock.unlock() }
54
+ }
55
+
56
+ init(stateMachine: VideoStreamStateMachine,
57
+ zoomController: ZoomController,
58
+ torchController: TorchController,
59
+ barcodeDetector: BarcodeDetector,
60
+ onReady: @escaping () -> Void,
61
+ onStopped: @escaping () -> Void,
62
+ onError: @escaping (String) -> Void) {
63
+ self.stateMachine = stateMachine
64
+ self.zoomController = zoomController
65
+ self.torchController = torchController
66
+ self.barcodeDetector = barcodeDetector
67
+ self.onReady = onReady
68
+ self.onStopped = onStopped
69
+ self.onError = onError
70
+ }
71
+
72
+ // MARK: - Public API
73
+
74
+ func setPreviewView(_ view: UIView) {
75
+ previewView = view
76
+ }
77
+
78
+ func start(facing: String,
79
+ initialZoom: Float,
80
+ scanFormat: String,
81
+ fpsMin: Int?,
82
+ fpsMax: Int?) {
83
+ currentFacing = facing
84
+ currentInitialZoom = initialZoom
85
+ currentScanFormat = scanFormat
86
+ currentFpsMin = fpsMin
87
+ currentFpsMax = fpsMax
88
+
89
+ checkPermissionAndBind()
90
+ }
91
+
92
+ func stop() {
93
+ sessionQueue.async { [weak self] in
94
+ self?.tearDownSession()
95
+ }
96
+ }
97
+
98
+ func flip() {
99
+ currentFacing = (currentFacing == "back") ? "front" : "back"
100
+ logger.debug("flip() → \(self.currentFacing)")
101
+ sessionQueue.async { [weak self] in
102
+ self?.rebindSession()
103
+ }
104
+ }
105
+
106
+ func setFps(min: Int?, max: Int?) {
107
+ logger.debug("setFps(\(String(describing: min)), \(String(describing: max))) — applying live or rebind")
108
+ currentFpsMin = min
109
+ currentFpsMax = max
110
+ sessionQueue.async { [weak self] in
111
+ self?.applyFpsToLiveDevice()
112
+ }
113
+ }
114
+
115
+ func cleanup() {
116
+ sessionQueue.async { [weak self] in
117
+ self?.tearDownSession()
118
+ }
119
+ }
120
+
121
+ // MARK: - Permission
122
+
123
+ private func checkPermissionAndBind() {
124
+ switch AVCaptureDevice.authorizationStatus(for: .video) {
125
+ case .authorized:
126
+ sessionQueue.async { [weak self] in self?.bindSession() }
127
+ case .notDetermined:
128
+ AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
129
+ if granted {
130
+ self?.sessionQueue.async { self?.bindSession() }
131
+ } else {
132
+ self?.stateMachine.transition(to: .idle)
133
+ self?.onError("Camera permission denied")
134
+ }
135
+ }
136
+ default:
137
+ stateMachine.transition(to: .idle)
138
+ onError("Camera permission denied")
139
+ }
140
+ }
141
+
142
+ // MARK: - Session lifecycle
143
+
144
+ private func bindSession() {
145
+ let newSession = AVCaptureSession()
146
+ newSession.beginConfiguration()
147
+ newSession.sessionPreset = .hd1920x1080
148
+
149
+ // Input
150
+ let position: AVCaptureDevice.Position = (currentFacing == "front") ? .front : .back
151
+ guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position),
152
+ let input = try? AVCaptureDeviceInput(device: device) else {
153
+ stateMachine.transition(to: .idle)
154
+ onError("Failed to open camera device")
155
+ return
156
+ }
157
+ guard newSession.canAddInput(input) else {
158
+ stateMachine.transition(to: .idle)
159
+ onError("Cannot add camera input")
160
+ return
161
+ }
162
+ newSession.addInput(input)
163
+
164
+ // FPS
165
+ configureFPS(device: device, fpsMin: currentFpsMin, fpsMax: currentFpsMax)
166
+
167
+ // Preview layer — must be created before addOutput so transforms are correct
168
+ let layer = AVCaptureVideoPreviewLayer(session: newSession)
169
+ layer.videoGravity = .resizeAspectFill
170
+
171
+ newSession.commitConfiguration()
172
+
173
+ // Attach barcode detector (adds AVCaptureMetadataOutput to session)
174
+ barcodeDetector.attach(to: newSession, previewLayer: layer, scanFormat: currentScanFormat)
175
+
176
+ // Controllers
177
+ zoomController.attachDevice(device)
178
+ torchController.attachDevice(device, facing: currentFacing)
179
+
180
+ // Apply initial zoom before starting
181
+ zoomController.applyZoomMultiplier(CGFloat(currentInitialZoom))
182
+ torchController.notifyReset()
183
+
184
+ self.session = newSession
185
+ self.previewLayer = layer
186
+
187
+ newSession.startRunning()
188
+
189
+ // Embed preview layer and fire onReady on main thread.
190
+ // Preview layer insertion is best-effort — previewView is a weak ref to a
191
+ // SwiftUI-managed UIView that should always be ready by the time JS calls start(),
192
+ // but we don't gate onReady() on it. Matches Android: onReady fires as soon as
193
+ // the camera is bound, regardless of the preview container state.
194
+ DispatchQueue.main.async { [weak self] in
195
+ guard let self else { return }
196
+ if let view = self.previewView {
197
+ layer.frame = view.bounds
198
+ view.layer.insertSublayer(layer, at: 0)
199
+ }
200
+ self.stateMachine.transition(to: .streaming)
201
+ self.onReady()
202
+ logger.debug("Session started — facing=\(self.currentFacing)")
203
+ }
204
+ }
205
+
206
+ private func tearDownSession() {
207
+ guard let session else { return }
208
+ session.stopRunning()
209
+ barcodeDetector.detach(from: session)
210
+ zoomController.detachDevice()
211
+ torchController.detachDevice()
212
+ self.session = nil
213
+
214
+ DispatchQueue.main.async { [weak self] in
215
+ self?.previewLayer?.removeFromSuperlayer()
216
+ self?.previewLayer = nil
217
+ self?.stateMachine.transition(to: .idle)
218
+ self?.onStopped()
219
+ }
220
+ logger.debug("Session torn down")
221
+ }
222
+
223
+ private func rebindSession() {
224
+ guard let session else { return }
225
+ session.stopRunning()
226
+ barcodeDetector.detach(from: session)
227
+ zoomController.detachDevice()
228
+ torchController.detachDevice()
229
+ self.session = nil
230
+ let oldPreview = self.previewLayer
231
+ self.previewLayer = nil
232
+ DispatchQueue.main.async {
233
+ oldPreview?.removeFromSuperlayer()
234
+ }
235
+ bindSession()
236
+ }
237
+
238
+ /// Apply FPS change to the live device without stopping/restarting the session.
239
+ /// Eliminates the screen blank that rebindSession() causes.
240
+ /// Falls back to rebindSession() if no active session exists yet.
241
+ private func applyFpsToLiveDevice() {
242
+ guard let session,
243
+ let input = session.inputs.first as? AVCaptureDeviceInput else {
244
+ logger.warning("applyFpsToLiveDevice — no active session or input, skipping (stream not yet started)")
245
+ return
246
+ }
247
+ let device = input.device
248
+ guard let fpsMin = currentFpsMin, let fpsMax = currentFpsMax else {
249
+ logger.debug("applyFpsToLiveDevice — no FPS values set, skipping")
250
+ return
251
+ }
252
+
253
+ let targetMin = CMTime(value: 1, timescale: CMTimeScale(fpsMin))
254
+ let targetMax = CMTime(value: 1, timescale: CMTimeScale(fpsMax))
255
+
256
+ let supported = device.activeFormat.videoSupportedFrameRateRanges.first { range in
257
+ range.minFrameDuration <= targetMin && range.maxFrameDuration >= targetMax
258
+ }
259
+ guard supported != nil else {
260
+ logger.warning("applyFpsToLiveDevice — FPS \(fpsMin)-\(fpsMax) unsupported by current format, falling back to rebind")
261
+ rebindSession()
262
+ return
263
+ }
264
+
265
+ // Wrap in beginConfiguration/commitConfiguration to prevent AVCaptureSession
266
+ // from triggering an internal _buildAndRunGraph (which causes a screen blank).
267
+ // lockForConfiguration must be called inside the begin/commit pair.
268
+ session.beginConfiguration()
269
+ do {
270
+ try device.lockForConfiguration()
271
+ device.activeVideoMinFrameDuration = targetMax // fastest rate = 1/maxFps
272
+ device.activeVideoMaxFrameDuration = targetMin // slowest rate = 1/minFps
273
+ device.unlockForConfiguration()
274
+ } catch {
275
+ session.commitConfiguration()
276
+ logger.error("applyFpsToLiveDevice — lockForConfiguration failed: \(error.localizedDescription), falling back to rebind")
277
+ rebindSession()
278
+ return
279
+ }
280
+ session.commitConfiguration()
281
+ logger.debug("applyFpsToLiveDevice — FPS set to \(fpsMin)-\(fpsMax) without session rebind")
282
+ }
283
+
284
+ // MARK: - FPS configuration
285
+
286
+ private func configureFPS(device: AVCaptureDevice, fpsMin: Int?, fpsMax: Int?) {
287
+ guard let fpsMin, let fpsMax else { return }
288
+ let targetMin = CMTime(value: 1, timescale: CMTimeScale(fpsMin))
289
+ let targetMax = CMTime(value: 1, timescale: CMTimeScale(fpsMax))
290
+
291
+ let supported = device.activeFormat.videoSupportedFrameRateRanges.first { range in
292
+ range.minFrameDuration <= targetMin && range.maxFrameDuration >= targetMax
293
+ }
294
+ guard supported != nil else {
295
+ logger.warning("FPS range \(fpsMin)-\(fpsMax) not supported by device format, ignoring")
296
+ return
297
+ }
298
+ do {
299
+ try device.lockForConfiguration()
300
+ device.activeVideoMinFrameDuration = targetMax // fastest rate = 1/maxFps
301
+ device.activeVideoMaxFrameDuration = targetMin // slowest rate = 1/minFps
302
+ device.unlockForConfiguration()
303
+ logger.debug("FPS set to \(fpsMin)-\(fpsMax)")
304
+ } catch {
305
+ logger.error("Failed to set FPS: \(error.localizedDescription)")
306
+ }
307
+ }
308
+ }
@@ -0,0 +1,63 @@
1
+ import Foundation
2
+ import os
3
+
4
+ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app", category: "HoldController")
5
+
6
+ /// Manages QR hold state — after each detection, results are suppressed for HOLD_DURATION
7
+ /// to prevent the same QR from firing repeatedly.
8
+ /// Uses a suppressResults flag on BarcodeDetector instead of stopping the session,
9
+ /// so the camera pipeline stays live and there is no flicker.
10
+ class HoldController {
11
+
12
+ private let holdDuration: TimeInterval = 0.2 // 200ms, matches Android
13
+
14
+ private let stateMachine: VideoStreamStateMachine
15
+ private let barcodeDetector: BarcodeDetector
16
+ private var holdWorkItem: DispatchWorkItem?
17
+ private let serialQueue = DispatchQueue(label: "com.catalyst.camera.hold")
18
+
19
+ private(set) var lastDetectedValue: String?
20
+
21
+ init(stateMachine: VideoStreamStateMachine, barcodeDetector: BarcodeDetector) {
22
+ self.stateMachine = stateMachine
23
+ self.barcodeDetector = barcodeDetector
24
+ }
25
+
26
+ /// Call after a new QR value is confirmed.
27
+ func startHold() {
28
+ guard stateMachine.transition(to: .hold) else { return }
29
+
30
+ barcodeDetector.suppressResults = true
31
+ logger.debug("Hold started — results suppressed for \(self.holdDuration * 1000, format: .fixed(precision: 0))ms")
32
+
33
+ let workItem = DispatchWorkItem { [weak self] in
34
+ guard let self, self.stateMachine.isActive else { return }
35
+ self.barcodeDetector.suppressResults = false
36
+ self.stateMachine.transition(to: .streaming)
37
+ logger.debug("Hold ended — results resumed")
38
+ }
39
+ holdWorkItem = workItem
40
+ serialQueue.asyncAfter(deadline: .now() + holdDuration, execute: workItem)
41
+ }
42
+
43
+ /// Call on stop() or flip() to cancel any pending hold and clear detection memory.
44
+ func reset() {
45
+ serialQueue.async { [weak self] in
46
+ guard let self else { return }
47
+ self.holdWorkItem?.cancel()
48
+ self.holdWorkItem = nil
49
+ self.barcodeDetector.suppressResults = false
50
+ self.lastDetectedValue = nil
51
+ logger.debug("Hold state reset")
52
+ }
53
+ }
54
+
55
+ /// Returns true if this value is new (not a repeat of the last detected value).
56
+ func isNewValue(_ value: String) -> Bool {
57
+ serialQueue.sync {
58
+ if value == lastDetectedValue { return false }
59
+ lastDetectedValue = value
60
+ return true
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,194 @@
1
+ import Foundation
2
+ import AVFoundation
3
+ import UIKit
4
+ import os
5
+
6
+ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app", category: "NativeCameraManager")
7
+
8
+ /// Mutable reference wrapper so all sub-controller closures share the same callback
9
+ /// even after rewireEvents()/rewireError() replace it post-construction.
10
+ /// NSLock guards concurrent handler replacement vs. invocation.
11
+ private final class EventBox {
12
+ private let lock = NSLock()
13
+ private var _handler: (String, [String: Any]?) -> Void
14
+ init(_ h: @escaping (String, [String: Any]?) -> Void) { _handler = h }
15
+
16
+ func call(_ event: String, _ payload: [String: Any]?) {
17
+ lock.lock()
18
+ let h = _handler
19
+ lock.unlock()
20
+ h(event, payload)
21
+ }
22
+
23
+ func replace(_ h: @escaping (String, [String: Any]?) -> Void) {
24
+ lock.lock()
25
+ _handler = h
26
+ lock.unlock()
27
+ }
28
+ }
29
+ private final class ErrorBox {
30
+ private let lock = NSLock()
31
+ private var _handler: (String) -> Void
32
+ init(_ h: @escaping (String) -> Void) { _handler = h }
33
+
34
+ func call(_ msg: String) {
35
+ lock.lock()
36
+ let h = _handler
37
+ lock.unlock()
38
+ h(msg)
39
+ }
40
+
41
+ func replace(_ h: @escaping (String) -> Void) {
42
+ lock.lock()
43
+ _handler = h
44
+ lock.unlock()
45
+ }
46
+ }
47
+
48
+ /// Thin facade — wires all camera sub-components and exposes the public API
49
+ /// that NativeBridge.swift calls. Mirrors Android's NativeCameraManager.kt.
50
+ public class NativeCameraManager: ObservableObject {
51
+
52
+ private let stateMachine: VideoStreamStateMachine
53
+ private let zoomController: ZoomController
54
+ private let torchController: TorchController
55
+ private let barcodeDetector: BarcodeDetector
56
+ private let sessionManager: CameraSessionManager
57
+ private let holdController: HoldController
58
+
59
+ // Boxes — all sub-controller closures close over these references,
60
+ // so updating .handler here propagates to all of them immediately.
61
+ private let eventBox: EventBox
62
+ private let errorBox: ErrorBox
63
+
64
+ public init(onEvent: @escaping (String, [String: Any]?) -> Void,
65
+ onError: @escaping (String) -> Void) {
66
+ let eBox = EventBox(onEvent)
67
+ let errBox = ErrorBox(onError)
68
+ self.eventBox = eBox
69
+ self.errorBox = errBox
70
+
71
+ let sm = VideoStreamStateMachine()
72
+ self.stateMachine = sm
73
+
74
+ zoomController = ZoomController(
75
+ stateMachine: sm,
76
+ onZoomChanged: { zoomLevel, minZoom, maxZoom in
77
+ let payload: [String: Any] = [
78
+ "zoomLevel": round(Double(zoomLevel) * 10) / 10,
79
+ "minZoom": round(Double(minZoom) * 10) / 10,
80
+ "maxZoom": round(Double(maxZoom) * 10) / 10
81
+ ]
82
+ eBox.call("ON_ZOOM_CHANGED", payload)
83
+ }
84
+ )
85
+
86
+ torchController = TorchController(
87
+ onTorchChanged: { enabled in
88
+ eBox.call("ON_TORCH_CHANGED", ["enabled": enabled])
89
+ }
90
+ )
91
+
92
+ barcodeDetector = BarcodeDetector()
93
+
94
+ sessionManager = CameraSessionManager(
95
+ stateMachine: sm,
96
+ zoomController: zoomController,
97
+ torchController: torchController,
98
+ barcodeDetector: barcodeDetector,
99
+ onReady: { eBox.call("ON_VIDEO_STREAM_READY", nil) },
100
+ onStopped: { eBox.call("ON_VIDEO_STREAM_STOPPED", nil) },
101
+ onError: { msg in errBox.call(msg) }
102
+ )
103
+
104
+ holdController = HoldController(stateMachine: sm, barcodeDetector: barcodeDetector)
105
+
106
+ // Wire detection handler now that holdController exists
107
+ barcodeDetector.detectionHandler = { [weak holdController, weak sm] value, type, bounds in
108
+ guard sm?.state != .hold else { return }
109
+ guard let hc = holdController else { return }
110
+ if hc.isNewValue(value) {
111
+ let payload: [String: Any] = ["value": value, "format": BarcodeDetector.formatName(type)]
112
+ eBox.call("ON_QR_DETECTED", payload)
113
+ logger.debug("QR detected (new): \(value)")
114
+ }
115
+ hc.startHold()
116
+ }
117
+ }
118
+
119
+ // MARK: - Public API
120
+
121
+ public func setPreviewView(_ view: UIView) {
122
+ sessionManager.setPreviewView(view)
123
+ }
124
+
125
+ public func start(facing: String = "back",
126
+ autoZoom: Bool = false, // accepted, silently ignored on iOS
127
+ initialZoom: Float = 1.0,
128
+ scanFormat: String = "all",
129
+ fpsMin: Int? = nil,
130
+ fpsMax: Int? = nil) {
131
+ guard !stateMachine.isActive else { return }
132
+ stateMachine.transition(to: .starting)
133
+ sessionManager.start(
134
+ facing: facing,
135
+ initialZoom: initialZoom,
136
+ scanFormat: scanFormat,
137
+ fpsMin: fpsMin,
138
+ fpsMax: fpsMax
139
+ )
140
+ }
141
+
142
+ public func stop() {
143
+ guard stateMachine.isActive else { return }
144
+ holdController.reset()
145
+ stateMachine.transition(to: .stopping)
146
+ sessionManager.stop()
147
+ }
148
+
149
+ public func flip() {
150
+ guard stateMachine.isActive else { return }
151
+ holdController.reset()
152
+ stateMachine.transition(to: .flipping)
153
+ sessionManager.flip()
154
+ }
155
+
156
+ public func setZoom(multiplier: Float) {
157
+ zoomController.setZoom(multiplier: multiplier)
158
+ }
159
+
160
+ public func setTorch(_ on: Bool) {
161
+ torchController.setTorch(on)
162
+ }
163
+
164
+ public func setFps(min: Int?, max: Int?) {
165
+ guard stateMachine.isActive else { return }
166
+ sessionManager.setFps(min: min, max: max)
167
+ }
168
+
169
+ public func handlePinchBegan() {
170
+ zoomController.handlePinchBegan()
171
+ }
172
+
173
+ public func handlePinchChanged(scale: CGFloat) {
174
+ zoomController.handlePinchChanged(scale: scale)
175
+ }
176
+
177
+ public func cleanup() {
178
+ sessionManager.cleanup()
179
+ }
180
+
181
+ /// Replace the event callback post-construction (called by NativeBridge after init).
182
+ /// Updates the shared EventBox so all sub-controller closures see the new handler.
183
+ public func rewireEvents(_ handler: @escaping (String, [String: Any]?) -> Void) {
184
+ eventBox.replace(handler)
185
+ }
186
+
187
+ /// Replace the error callback post-construction (called by NativeBridge after init).
188
+ /// Updates the shared ErrorBox so all sub-controller closures see the new handler.
189
+ public func rewireError(_ handler: @escaping (String) -> Void) {
190
+ errorBox.replace(handler)
191
+ }
192
+
193
+ public var isStreaming: Bool { stateMachine.isActive }
194
+ }
@@ -0,0 +1,60 @@
1
+ import Foundation
2
+ import AVFoundation
3
+ import os
4
+
5
+ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app", category: "TorchController")
6
+
7
+ /// Manages torch (flashlight) state.
8
+ /// Guards against front-camera torch calls.
9
+ /// Fires ON_TORCH_CHANGED via onTorchChanged callback.
10
+ class TorchController {
11
+
12
+ private var device: AVCaptureDevice?
13
+ private var currentFacing: String = "back"
14
+ private let onTorchChanged: (Bool) -> Void
15
+
16
+ init(onTorchChanged: @escaping (Bool) -> Void) {
17
+ self.onTorchChanged = onTorchChanged
18
+ }
19
+
20
+ func attachDevice(_ device: AVCaptureDevice, facing: String) {
21
+ self.device = device
22
+ self.currentFacing = facing
23
+ }
24
+
25
+ func detachDevice() {
26
+ device = nil
27
+ }
28
+
29
+ func setTorch(_ on: Bool) {
30
+ guard let device else {
31
+ logger.warning("setTorch(\(on)) — device is nil, skipping")
32
+ return
33
+ }
34
+ guard currentFacing == "back" else {
35
+ logger.warning("setTorch(\(on)) — front camera has no torch, ignoring")
36
+ return
37
+ }
38
+ guard device.hasTorch else {
39
+ logger.warning("setTorch(\(on)) — device has no torch")
40
+ return
41
+ }
42
+
43
+ do {
44
+ try device.lockForConfiguration()
45
+ device.torchMode = on ? .on : .off
46
+ device.unlockForConfiguration()
47
+ onTorchChanged(on)
48
+ logger.debug("setTorch(\(on))")
49
+ } catch {
50
+ logger.error("setTorch(\(on)) failed: \(error.localizedDescription)")
51
+ onTorchChanged(false)
52
+ }
53
+ }
54
+
55
+ /// Called after every session bind — torch always resets to off on session start.
56
+ func notifyReset() {
57
+ onTorchChanged(false)
58
+ logger.debug("Torch reset to off (new session)")
59
+ }
60
+ }