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
@@ -17,11 +17,17 @@ class NativeBridge: NSObject, BridgeCommandHandlerDelegate, BridgeFileHandlerDel
17
17
  weak var webView: WKWebView?
18
18
  private weak var viewController: UIViewController?
19
19
  private weak var webViewModel: WebViewModel?
20
+ private var cameraManager: NativeCameraManager?
21
+ private var messageHandlerProxy: WeakScriptMessageHandler?
22
+ private var isRegistered = false
20
23
 
21
24
  // Protocol-based notification handler (injected at runtime)
22
25
  private var notificationHandler: NotificationHandlerProtocol = NullNotificationHandler.shared
23
26
  private var networkStatusListenerId: UUID?
24
27
 
28
+ // Transition manager — snapshot overlay pattern for native page transitions
29
+ private var transitionManager: TransitionManager?
30
+
25
31
  // Lazy initialization for non-critical handlers
26
32
  private lazy var imageHandler: ImageHandler = {
27
33
  logWithTimestamp("🔧 ImageHandler initialized (lazy)")
@@ -68,17 +74,38 @@ class NativeBridge: NSObject, BridgeCommandHandlerDelegate, BridgeFileHandlerDel
68
74
  return handler
69
75
  }()
70
76
 
71
- init(webView: WKWebView, viewController: UIViewController) {
77
+ init(webView: WKWebView, viewController: UIViewController, cameraManager: NativeCameraManager? = nil) {
72
78
  let initStart = CFAbsoluteTimeGetCurrent()
73
79
 
74
80
  self.webView = webView
75
81
  self.viewController = viewController
82
+ self.cameraManager = cameraManager
76
83
 
77
84
  // Only initialize critical JS interface immediately
78
85
  self.jsInterface = BridgeJavaScriptInterface(webView: webView)
79
86
 
80
87
  super.init()
81
88
 
89
+ // Wire camera events back through the bridge
90
+ cameraManager?.rewireEvents { [weak self] eventName, payload in
91
+ if let payload {
92
+ self?.sendJSONCallback(eventName: eventName, data: payload)
93
+ } else {
94
+ self?.sendCallback(eventName: eventName)
95
+ }
96
+ }
97
+ cameraManager?.rewireError { [weak self] message in
98
+ self?.sendErrorCallback(eventName: "ON_CAMERA_ERROR", error: message, code: "CAMERA_ERROR")
99
+ }
100
+
101
+ // Wire transition manager
102
+ let tm = TransitionManager(webView: webView)
103
+ tm.onEvent = { [weak self] eventName in
104
+ self?.sendCallback(eventName: eventName)
105
+ }
106
+ self.transitionManager = tm
107
+
108
+
82
109
  setupNotificationNavigationHandler()
83
110
 
84
111
  let initTime = (CFAbsoluteTimeGetCurrent() - initStart) * 1000
@@ -133,10 +160,16 @@ class NativeBridge: NSObject, BridgeCommandHandlerDelegate, BridgeFileHandlerDel
133
160
 
134
161
  // Register the JavaScript interface with the WebView
135
162
  func register() {
163
+ guard !isRegistered else { return }
136
164
  let registerStart = CFAbsoluteTimeGetCurrent()
137
165
 
138
- let userContentController = webView?.configuration.userContentController
139
- userContentController?.add(self, name: "NativeBridge")
166
+ guard let userContentController = webView?.configuration.userContentController else {
167
+ return
168
+ }
169
+ let proxy = WeakScriptMessageHandler(delegate: self)
170
+ userContentController.add(proxy, name: "NativeBridge")
171
+ messageHandlerProxy = proxy
172
+ isRegistered = true
140
173
 
141
174
  let registerTime = (CFAbsoluteTimeGetCurrent() - registerStart) * 1000
142
175
  logWithTimestamp("✅ NativeBridge registered (took \(String(format: "%.2f", registerTime))ms)")
@@ -144,12 +177,19 @@ class NativeBridge: NSObject, BridgeCommandHandlerDelegate, BridgeFileHandlerDel
144
177
 
145
178
  // Unregister to prevent memory leaks
146
179
  func unregister() {
180
+ guard isRegistered else { return }
147
181
  webView?.configuration.userContentController.removeScriptMessageHandler(forName: "NativeBridge")
148
-
182
+ messageHandlerProxy = nil
183
+ isRegistered = false
184
+
185
+
149
186
  if let listenerId = networkStatusListenerId {
150
187
  NetworkMonitor.shared.removeListener(listenerId)
151
188
  networkStatusListenerId = nil
152
189
  }
190
+
191
+ transitionManager?.cleanup()
192
+ transitionManager = nil
153
193
  }
154
194
 
155
195
  private func startNetworkMonitoringIfNeeded() {
@@ -282,6 +322,25 @@ extension NativeBridge: WKScriptMessageHandler {
282
322
  commandHandler.unsubscribeFromTopic(config)
283
323
  case "getSubscribedTopics":
284
324
  commandHandler.getSubscribedTopics()
325
+ // Video stream commands
326
+ case "startVideoStream":
327
+ handleStartVideoStream(params: params)
328
+ case "stopVideoStream":
329
+ logger.info("📹 stopVideoStream received")
330
+ if cameraManager == nil { logger.error("📹 stopVideoStream — cameraManager is nil") }
331
+ cameraManager?.stop()
332
+ case "flipVideoStream":
333
+ logger.info("📹 flipVideoStream received")
334
+ if cameraManager == nil { logger.error("📹 flipVideoStream — cameraManager is nil") }
335
+ cameraManager?.flip()
336
+ case "sendVideoStreamCommand":
337
+ handleVideoStreamCommand(params: params)
338
+ case "setVideoStreamZoom":
339
+ handleSetVideoStreamZoom(params: params)
340
+ case "setVideoStreamTorch":
341
+ handleSetVideoStreamTorch(params: params)
342
+ case "setVideoStreamFps":
343
+ handleSetVideoStreamFps(params: params)
285
344
  case "getSafeArea":
286
345
  getSafeArea()
287
346
  case "setScreenSecure":
@@ -290,12 +349,176 @@ extension NativeBridge: WKScriptMessageHandler {
290
349
  commandHandler.getScreenSecure()
291
350
  case "clearWebData":
292
351
  commandHandler.clearWebData()
352
+
353
+ // Transition commands
354
+ case "startTransition":
355
+ let optionsString = delegateHandler.extractStringParam(from: params)
356
+ handleStartTransition(optionsString: optionsString)
357
+ case "commitTransition":
358
+ DispatchQueue.main.async { [weak self] in self?.transitionManager?.commitTransition() }
359
+ case "cancelTransition":
360
+ DispatchQueue.main.async { [weak self] in self?.transitionManager?.cancelTransition() }
361
+
293
362
  default:
294
363
  // This should never happen due to validation, but keeping for safety
295
364
  logger.error("Unexpected command reached execution: \(command)")
296
365
  }
297
366
  }
298
367
 
368
+ // MARK: - Video Stream Helpers
369
+
370
+ /// JS always sends params as JSON.stringify(options) — a String — because Android requires it.
371
+ /// Parse it into a dictionary here rather than removing the stringify on the JS side.
372
+ private func jsonStringToDict(_ params: Any?) -> [String: Any]? {
373
+ // Fast path: WKWebView decoded it already (unlikely for video stream commands but handle gracefully)
374
+ if let d = params as? [String: Any] { return d }
375
+ if let d = (params as? NSDictionary) as? [String: Any] { return d }
376
+ // Normal path: params is a JSON string from JSON.stringify()
377
+ guard let str = params as? String,
378
+ let data = str.data(using: .utf8),
379
+ let obj = try? JSONSerialization.jsonObject(with: data),
380
+ let dict = obj as? [String: Any] else { return nil }
381
+ return dict
382
+ }
383
+
384
+ private func handleStartVideoStream(params: Any?) {
385
+ logger.info("📹 startVideoStream received — raw params type: \(type(of: params))")
386
+ guard let cam = cameraManager else {
387
+ logger.error("📹 startVideoStream — cameraManager is nil")
388
+ return
389
+ }
390
+ var facing = "back"
391
+ var autoZoom = false
392
+ var initialZoom: Float = 1.0
393
+ var scanFormat = "all"
394
+ var fpsMin: Int? = nil
395
+ var fpsMax: Int? = nil
396
+
397
+ if let dict = jsonStringToDict(params) {
398
+ logger.info("📹 startVideoStream — dict keys: \(dict.keys.sorted().joined(separator: ", "))")
399
+ facing = dict["facing"] as? String ?? facing
400
+ autoZoom = dict["autoZoom"] as? Bool ?? autoZoom
401
+ scanFormat = dict["format"] as? String ?? scanFormat
402
+ // zoom options are nested under 'zoom' key: { auto: Bool, initial: Float }
403
+ if let zoomDict = dict["zoom"] as? [String: Any] {
404
+ autoZoom = (zoomDict["auto"] as? NSNumber)?.boolValue ?? autoZoom
405
+ initialZoom = (zoomDict["initial"] as? NSNumber)?.floatValue ?? initialZoom
406
+ }
407
+ if let fps = dict["fps"] as? [String: Any] {
408
+ fpsMin = fps["min"] as? Int
409
+ fpsMax = fps["max"] as? Int
410
+ }
411
+ } else {
412
+ logger.warning("📹 startVideoStream — could not parse params (type=\(type(of: params))), using defaults")
413
+ }
414
+
415
+ logger.info("📹 startVideoStream — starting with facing=\(facing) autoZoom=\(autoZoom) initialZoom=\(initialZoom) format=\(scanFormat) fpsMin=\(String(describing: fpsMin)) fpsMax=\(String(describing: fpsMax))")
416
+ cam.start(facing: facing, autoZoom: autoZoom, initialZoom: initialZoom,
417
+ scanFormat: scanFormat, fpsMin: fpsMin, fpsMax: fpsMax)
418
+ }
419
+
420
+ private func handleVideoStreamCommand(params: Any?) {
421
+ guard let cam = cameraManager,
422
+ let dict = jsonStringToDict(params),
423
+ let type = dict["type"] as? String else {
424
+ logger.error("📹 handleVideoStreamCommand — guard failed: cameraManager=\(self.cameraManager != nil) params=\(String(describing: params))")
425
+ return
426
+ }
427
+
428
+ switch type {
429
+ case "zoom":
430
+ if let value = dict["value"] as? NSNumber {
431
+ cam.setZoom(multiplier: value.floatValue)
432
+ }
433
+ case "torch":
434
+ if let value = dict["value"] as? Bool {
435
+ cam.setTorch(value)
436
+ }
437
+ case "fps":
438
+ let fpsDict = dict["value"] as? [String: Any]
439
+ let min = fpsDict?["min"] as? Int
440
+ let max = fpsDict?["max"] as? Int
441
+ cam.setFps(min: min, max: max)
442
+ default:
443
+ logger.warning("Unknown videoStream command type: \(type)")
444
+ }
445
+ }
446
+
447
+ private func handleSetVideoStreamZoom(params: Any?) {
448
+ logger.info("📹 setVideoStreamZoom received — raw params: \(String(describing: params))")
449
+ guard let cam = cameraManager else {
450
+ logger.error("📹 setVideoStreamZoom — cameraManager is nil")
451
+ return
452
+ }
453
+ guard let raw = params as? String, let multiplier = Float(raw) else {
454
+ logger.error("📹 setVideoStreamZoom — expected String number, got: \(String(describing: params))")
455
+ return
456
+ }
457
+ logger.info("📹 setVideoStreamZoom — multiplier=\(multiplier)x")
458
+ cam.setZoom(multiplier: multiplier)
459
+ }
460
+
461
+ private func handleSetVideoStreamTorch(params: Any?) {
462
+ logger.info("📹 setVideoStreamTorch received — raw params: \(String(describing: params))")
463
+ guard let cam = cameraManager else {
464
+ logger.error("📹 setVideoStreamTorch — cameraManager is nil")
465
+ return
466
+ }
467
+ // JS sends JSON.stringify({on: bool})
468
+ guard let dict = jsonStringToDict(params), let on = dict["on"] as? Bool else {
469
+ logger.error("📹 setVideoStreamTorch — expected JSON string {on: Bool}, got: \(String(describing: params))")
470
+ return
471
+ }
472
+ logger.info("📹 setVideoStreamTorch — on=\(on)")
473
+ cam.setTorch(on)
474
+ }
475
+
476
+ private func handleSetVideoStreamFps(params: Any?) {
477
+ logger.info("📹 setVideoStreamFps received — raw params: \(String(describing: params))")
478
+ guard let cam = cameraManager else {
479
+ logger.error("📹 setVideoStreamFps — cameraManager is nil")
480
+ return
481
+ }
482
+ // JS sends JSON.stringify({min, max})
483
+ guard let dict = jsonStringToDict(params) else {
484
+ logger.error("📹 setVideoStreamFps — expected JSON string {min, max}, got: \(String(describing: params))")
485
+ return
486
+ }
487
+ let min = dict["min"] as? Int
488
+ let max = dict["max"] as? Int
489
+ logger.info("📹 setVideoStreamFps — min=\(String(describing: min)) max=\(String(describing: max))")
490
+ cam.setFps(min: min, max: max)
491
+ }
492
+
493
+ // MARK: - Transition Helpers
494
+
495
+ private func handleStartTransition(optionsString: String?) {
496
+ let options: [String: Any]
497
+ if let str = optionsString,
498
+ let data = str.data(using: .utf8),
499
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
500
+ options = obj
501
+ } else {
502
+ options = [:]
503
+ }
504
+
505
+ let type = options["type"] as? String ?? "slide"
506
+ let direction = options["direction"] as? String ?? "left"
507
+ let duration = min(max((options["duration"] as? Int ?? 300), 0), 2000)
508
+ let defaultTimeout = max(duration * 3, 800)
509
+ let timeout = min(max((options["timeout"] as? Int ?? defaultTimeout), 100), 10000)
510
+
511
+ DispatchQueue.main.async { [weak self] in
512
+ self?.transitionManager?.startTransition(
513
+ type: type,
514
+ direction: direction,
515
+ duration: duration,
516
+ timeout: timeout
517
+ )
518
+ }
519
+ }
520
+
521
+
299
522
  // MARK: - Safe Area Methods
300
523
 
301
524
  /// Get current safe area insets and send via callback
@@ -0,0 +1,169 @@
1
+ import UIKit
2
+ import WebKit
3
+ import os
4
+
5
+ private let transitionLogger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app", category: "TransitionManager")
6
+
7
+ /// Manages native page transitions using a snapshot-overlay pattern.
8
+ ///
9
+ /// Flow:
10
+ /// startTransition() — captures a snapshotView of the current WebView, places it as an
11
+ /// overlay on the key window covering the skeleton rendered by the router.
12
+ /// commitTransition() — animates the overlay out (slide or fade) to reveal the new page.
13
+ /// cancelTransition() — removes the overlay immediately without animating.
14
+ ///
15
+ /// Safety timer: a DispatchWorkItem fires after `timeout` ms if commitTransition() was
16
+ /// never called. It force-removes the overlay and fires ON_TRANSITION_TIMEOUT to JS.
17
+ ///
18
+ /// Thread safety: all public methods must be called on the main thread.
19
+ class TransitionManager {
20
+
21
+ private weak var webView: WKWebView?
22
+ private var overlayView: UIView?
23
+ private var safetyItem: DispatchWorkItem?
24
+ private var activeDuration: TimeInterval = 0.3
25
+ private var activeType: String = "slide"
26
+ private var activeDirection: String = "left"
27
+
28
+ // Callback to fire JS events — injected by NativeBridge
29
+ var onEvent: (String) -> Void = { _ in }
30
+
31
+ init(webView: WKWebView) {
32
+ self.webView = webView
33
+ }
34
+
35
+ // MARK: - Public API
36
+
37
+ func startTransition(type: String, direction: String, duration: Int, timeout: Int) {
38
+ // Cancel any orphaned transition first
39
+ cancelTransitionInternal(notify: false)
40
+
41
+ activeType = type
42
+ activeDirection = direction
43
+ activeDuration = Double(duration) / 1000.0
44
+
45
+ guard let snapshot = captureSnapshot() else {
46
+ transitionLogger.warning("startTransition — snapshot failed, transition skipped")
47
+ return
48
+ }
49
+
50
+ guard let window = webView?.window ?? UIApplication.shared.connectedScenes
51
+ .compactMap({ ($0 as? UIWindowScene)?.keyWindow })
52
+ .first else {
53
+ transitionLogger.warning("startTransition — no key window found")
54
+ return
55
+ }
56
+
57
+ snapshot.frame = window.bounds
58
+ snapshot.autoresizingMask = [.flexibleWidth, .flexibleHeight]
59
+ window.addSubview(snapshot)
60
+ overlayView = snapshot
61
+
62
+ transitionLogger.debug("startTransition — overlay attached (type=\(type) direction=\(direction) duration=\(duration)ms timeout=\(timeout)ms)")
63
+
64
+ // Arm safety timer
65
+ let item = DispatchWorkItem { [weak self] in
66
+ transitionLogger.warning("startTransition — safety timeout fired, force-removing overlay")
67
+ self?.cancelTransitionInternal(notify: false)
68
+ self?.onEvent("ON_TRANSITION_TIMEOUT")
69
+ }
70
+ safetyItem = item
71
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(timeout), execute: item)
72
+ }
73
+
74
+ func commitTransition() {
75
+ guard let overlay = overlayView else {
76
+ transitionLogger.warning("commitTransition — no active overlay, ignoring")
77
+ return
78
+ }
79
+
80
+ disarmSafetyTimer()
81
+
82
+ switch activeType {
83
+ case "fade":
84
+ animateFadeOut(overlay: overlay, duration: activeDuration)
85
+ default:
86
+ animateSlideOut(overlay: overlay, duration: activeDuration, direction: activeDirection)
87
+ }
88
+ }
89
+
90
+ func cancelTransition() {
91
+ cancelTransitionInternal(notify: true)
92
+ }
93
+
94
+ func cleanup() {
95
+ cancelTransitionInternal(notify: false)
96
+ }
97
+
98
+ // MARK: - Private helpers
99
+
100
+ private func captureSnapshot() -> UIView? {
101
+ guard let webView else { return nil }
102
+ // snapshotView is GPU-backed and fast — no bitmap copy needed on iOS
103
+ return webView.snapshotView(afterScreenUpdates: false)
104
+ }
105
+
106
+ private func animateSlideOut(overlay: UIView, duration: TimeInterval, direction: String) {
107
+ guard let window = overlay.superview else { return }
108
+ let w = window.bounds.width
109
+ let h = window.bounds.height
110
+
111
+ let endTranslation: CGPoint
112
+ switch direction {
113
+ case "right": endTranslation = CGPoint(x: -w, y: 0)
114
+ case "up": endTranslation = CGPoint(x: 0, y: h)
115
+ case "down": endTranslation = CGPoint(x: 0, y: -h)
116
+ default: endTranslation = CGPoint(x: w, y: 0) // "left" — overlay slides out to the right
117
+ }
118
+
119
+ UIView.animate(
120
+ withDuration: duration,
121
+ delay: 0,
122
+ options: [.curveEaseInOut],
123
+ animations: {
124
+ overlay.transform = CGAffineTransform(translationX: endTranslation.x, y: endTranslation.y)
125
+ },
126
+ completion: { [weak self] _ in
127
+ self?.removeOverlay(overlay)
128
+ self?.onEvent("ON_TRANSITION_COMMITTED")
129
+ }
130
+ )
131
+ }
132
+
133
+ private func animateFadeOut(overlay: UIView, duration: TimeInterval) {
134
+ UIView.animate(
135
+ withDuration: duration,
136
+ delay: 0,
137
+ options: [.curveEaseInOut],
138
+ animations: {
139
+ overlay.alpha = 0
140
+ },
141
+ completion: { [weak self] _ in
142
+ self?.removeOverlay(overlay)
143
+ self?.onEvent("ON_TRANSITION_COMMITTED")
144
+ }
145
+ )
146
+ }
147
+
148
+ private func cancelTransitionInternal(notify: Bool) {
149
+ disarmSafetyTimer()
150
+ if let overlay = overlayView {
151
+ removeOverlay(overlay)
152
+ if notify {
153
+ onEvent("ON_TRANSITION_CANCELLED")
154
+ }
155
+ }
156
+ }
157
+
158
+ private func removeOverlay(_ overlay: UIView) {
159
+ overlay.removeFromSuperview()
160
+ if overlayView === overlay {
161
+ overlayView = nil
162
+ }
163
+ }
164
+
165
+ private func disarmSafetyTimer() {
166
+ safetyItem?.cancel()
167
+ safetyItem = nil
168
+ }
169
+ }
@@ -0,0 +1,14 @@
1
+ import WebKit
2
+
3
+ final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler {
4
+ weak var delegate: WKScriptMessageHandler?
5
+
6
+ init(delegate: WKScriptMessageHandler) {
7
+ self.delegate = delegate
8
+ super.init()
9
+ }
10
+
11
+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
12
+ delegate?.userContentController(userContentController, didReceive: message)
13
+ }
14
+ }
@@ -7,6 +7,7 @@ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app"
7
7
  public struct WebView: UIViewRepresentable, Equatable {
8
8
  let urlString: String
9
9
  @ObservedObject var viewModel: WebViewModel
10
+ let cameraManager: NativeCameraManager
10
11
  private let navigationDelegate: WebViewNavigationDelegate
11
12
 
12
13
  // Equatable conformance - only recreate if URL changes
@@ -14,12 +15,13 @@ public struct WebView: UIViewRepresentable, Equatable {
14
15
  return lhs.urlString == rhs.urlString
15
16
  }
16
17
 
17
- public init(urlString: String, viewModel: WebViewModel) {
18
+ public init(urlString: String, viewModel: WebViewModel, cameraManager: NativeCameraManager) {
18
19
  let start = CFAbsoluteTimeGetCurrent()
19
20
  self.urlString = urlString
20
21
  self.viewModel = viewModel
22
+ self.cameraManager = cameraManager
21
23
  let initialURL = URL(string: urlString)
22
- self.navigationDelegate = WebViewNavigationDelegate(viewModel: viewModel, initialURL: initialURL)
24
+ self.navigationDelegate = WebViewNavigationDelegate(viewModel: viewModel, initialURL: initialURL, cameraManager: cameraManager)
23
25
 
24
26
  // Register our custom URL protocol for caching
25
27
  let protocolStart = CFAbsoluteTimeGetCurrent()
@@ -40,9 +42,6 @@ public struct WebView: UIViewRepresentable, Equatable {
40
42
  // Hook kept for legacy behavior; currently a no-op on iOS 15+.
41
43
  WebKitConfig.applySharedProcessPoolIfNeeded(to: configuration)
42
44
 
43
- #if DEBUG
44
- configuration.preferences.setValue(true, forKey: "developerExtrasEnabled")
45
- #endif
46
45
  let preferences = WKWebpagePreferences()
47
46
  preferences.allowsContentJavaScript = true
48
47
  configuration.defaultWebpagePreferences = preferences
@@ -54,6 +53,15 @@ public struct WebView: UIViewRepresentable, Equatable {
54
53
 
55
54
  webView.navigationDelegate = navigationDelegate
56
55
  webView.allowsBackForwardNavigationGestures = true
56
+ webView.isOpaque = false
57
+ webView.backgroundColor = .clear
58
+ webView.scrollView.backgroundColor = .clear
59
+
60
+ // Add our pinch recognizer to route camera zoom when streaming.
61
+ // Web page zoom is disabled via user-scalable=no in the viewport meta tag.
62
+ let pinchGesture = UIPinchGestureRecognizer(target: context.coordinator,
63
+ action: #selector(Coordinator.handleCameraPinch(_:)))
64
+ webView.addGestureRecognizer(pinchGesture)
57
65
 
58
66
  #if DEBUG
59
67
  // Enable Safari Web Inspector (only available in iOS 16.4+)
@@ -101,7 +109,11 @@ public struct WebView: UIViewRepresentable, Equatable {
101
109
  }
102
110
 
103
111
  public func updateUIView(_ webView: WKWebView, context: Context) {
104
- // Intentionally empty to prevent reloading
112
+ #if DEBUG
113
+ if #available(iOS 16.4, *) {
114
+ webView.isInspectable = true
115
+ }
116
+ #endif
105
117
  }
106
118
 
107
119
  public func makeCoordinator() -> Coordinator {
@@ -121,6 +133,8 @@ public struct WebView: UIViewRepresentable, Equatable {
121
133
  // Clean up native bridge
122
134
  coordinator.nativeBridge?.unregister()
123
135
  coordinator.nativeBridge = nil
136
+ coordinator.pluginBridge?.unregister()
137
+ coordinator.pluginBridge = nil
124
138
  coordinator.hostingController = nil
125
139
 
126
140
  // Unregister custom URL protocol
@@ -132,6 +146,7 @@ public struct WebView: UIViewRepresentable, Equatable {
132
146
  public class Coordinator: NSObject {
133
147
  var parent: WebView
134
148
  var nativeBridge: NativeBridge?
149
+ var pluginBridge: PluginBridge?
135
150
  var hostingController: UIViewController?
136
151
  var isObserverAdded = false
137
152
 
@@ -145,7 +160,8 @@ public struct WebView: UIViewRepresentable, Equatable {
145
160
  self.hostingController = hostingController
146
161
 
147
162
  // Create and register the native bridge
148
- let bridge = NativeBridge(webView: webView, viewController: hostingController)
163
+ let bridge = NativeBridge(webView: webView, viewController: hostingController, cameraManager: parent.cameraManager)
164
+ let pluginBridge = PluginBridge(webView: webView, viewController: hostingController)
149
165
 
150
166
  // Inject WebViewModel for safe area handling
151
167
  Task { @MainActor in
@@ -156,9 +172,24 @@ public struct WebView: UIViewRepresentable, Equatable {
156
172
  bridge.setNotificationHandler(NotificationHandlerProvider.shared)
157
173
 
158
174
  bridge.register()
175
+ pluginBridge.register()
159
176
  self.nativeBridge = bridge
177
+ self.pluginBridge = pluginBridge
160
178
  }
161
179
 
180
+ @objc func handleCameraPinch(_ gesture: UIPinchGestureRecognizer) {
181
+ // No-op when camera is not streaming — don't accidentally zoom the page
182
+ guard parent.cameraManager.isStreaming else { return }
183
+ switch gesture.state {
184
+ case .began:
185
+ parent.cameraManager.handlePinchBegan()
186
+ case .changed:
187
+ parent.cameraManager.handlePinchChanged(scale: gesture.scale)
188
+ default:
189
+ break
190
+ }
191
+ }
192
+
162
193
  override public func observeValue(forKeyPath keyPath: String?,
163
194
  of object: Any?,
164
195
  change: [NSKeyValueChangeKey : Any]?,
@@ -11,11 +11,13 @@ class WebViewNavigationDelegate: NSObject, WKNavigationDelegate {
11
11
  private var offlinePageVisible = false
12
12
  private var lastTargetURL: URL?
13
13
  private let initialURL: URL?
14
-
15
- init(viewModel: WebViewModel, initialURL: URL?) {
14
+ private weak var cameraManager: NativeCameraManager?
15
+
16
+ init(viewModel: WebViewModel, initialURL: URL?, cameraManager: NativeCameraManager? = nil) {
16
17
  self.viewModel = viewModel
17
18
  self.initialURL = initialURL
18
19
  self.lastTargetURL = initialURL
20
+ self.cameraManager = cameraManager
19
21
  super.init()
20
22
  }
21
23
 
@@ -199,6 +201,8 @@ class WebViewNavigationDelegate: NSObject, WKNavigationDelegate {
199
201
 
200
202
  func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
201
203
  logWithTimestamp("📡 didStartProvisionalNavigation - loading started")
204
+ // Stop camera on navigation — mirrors Android CustomWebview onPageStarted lambda
205
+ cameraManager?.stop()
202
206
  Task { @MainActor in
203
207
  if !isOfflinePageURL(webView.url) {
204
208
  offlinePageVisible = false
@@ -7,21 +7,31 @@ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app"
7
7
 
8
8
  public struct ContentView: View {
9
9
  @StateObject private var webViewModel = WebViewModel()
10
-
10
+ // @StateObject ensures cameraManager survives SwiftUI view rebuilds.
11
+ @StateObject private var cameraManager = NativeCameraManager(
12
+ onEvent: { _, _ in }, // placeholder; real handler set in NativeBridge
13
+ onError: { _ in }
14
+ )
15
+
11
16
  public init() {}
12
17
 
13
18
  public var body: some View {
14
19
  ZStack {
20
+ // Camera preview layer — sits behind the WebView (index 0)
21
+ CameraPreviewView(cameraManager: cameraManager)
22
+ .ignoresSafeArea()
23
+ .allowsHitTesting(false) // touches pass through to WebView
24
+
15
25
  // Normal remote URL - isolated from state changes
16
26
  // Conditionally apply edge-to-edge based on config (matches Android behavior)
17
27
  if ConfigConstants.EdgeToEdge.enabled {
18
- WebViewContainer(urlString: ConfigConstants.url, viewModel: webViewModel)
28
+ WebViewContainer(urlString: ConfigConstants.url, viewModel: webViewModel, cameraManager: cameraManager)
19
29
  .ignoresSafeArea()
20
30
  .onAppear {
21
31
  logger.info("WebView appeared with URL: \(ConfigConstants.url) [Edge-to-edge: enabled]")
22
32
  }
23
33
  } else {
24
- WebViewContainer(urlString: ConfigConstants.url, viewModel: webViewModel)
34
+ WebViewContainer(urlString: ConfigConstants.url, viewModel: webViewModel, cameraManager: cameraManager)
25
35
  .onAppear {
26
36
  logger.info("WebView appeared with URL: \(ConfigConstants.url) [Edge-to-edge: disabled, respecting safe areas]")
27
37
  }
@@ -62,9 +72,10 @@ public struct ContentView: View {
62
72
  struct WebViewContainer: View {
63
73
  let urlString: String
64
74
  @ObservedObject var viewModel: WebViewModel
75
+ let cameraManager: NativeCameraManager
65
76
 
66
77
  var body: some View {
67
- WebView(urlString: urlString, viewModel: viewModel)
78
+ WebView(urlString: urlString, viewModel: viewModel, cameraManager: cameraManager)
68
79
  .onAppear {
69
80
  logWithTimestamp("🌐 WebView appeared with URL: \(urlString)")
70
81
  }