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,44 @@
1
+ import Foundation
2
+
3
+ // MARK: - VideoStreamState
4
+ //
5
+ // Valid transitions:
6
+ // IDLE → STARTING (start() called)
7
+ // STARTING → STREAMING (session running)
8
+ // STARTING → IDLE (permission denied or bind failure)
9
+ // STREAMING → HOLD (QR detected)
10
+ // HOLD → STREAMING (hold expired)
11
+ // STREAMING → FLIPPING (flip() called)
12
+ // FLIPPING → STREAMING (rebind complete)
13
+ // STREAMING → STOPPING (stop() called)
14
+ // FLIPPING → STOPPING (stop() called mid-flip)
15
+ // HOLD → STOPPING (stop() called during hold)
16
+ // STOPPING → IDLE (session torn down)
17
+
18
+ enum VideoStreamState {
19
+ case idle
20
+ case starting
21
+ case streaming
22
+ case hold
23
+ case flipping
24
+ case stopping
25
+
26
+ func canTransitionTo(_ next: VideoStreamState) -> Bool {
27
+ switch self {
28
+ case .idle: return next == .starting
29
+ case .starting: return next == .streaming || next == .idle
30
+ case .streaming: return next == .hold || next == .flipping || next == .stopping
31
+ case .hold: return next == .streaming || next == .stopping
32
+ case .flipping: return next == .streaming || next == .stopping
33
+ case .stopping: return next == .idle
34
+ }
35
+ }
36
+
37
+ var isActive: Bool {
38
+ return self == .streaming || self == .hold || self == .flipping
39
+ }
40
+ }
41
+
42
+ protocol VideoStreamStateListener: AnyObject {
43
+ func onStateChanged(prev: VideoStreamState, next: VideoStreamState)
44
+ }
@@ -0,0 +1,60 @@
1
+ import Foundation
2
+ import os
3
+
4
+ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app", category: "VideoStreamStateMachine")
5
+
6
+ private final class WeakListenerBox {
7
+ weak var value: VideoStreamStateListener?
8
+ init(_ value: VideoStreamStateListener) { self.value = value }
9
+ }
10
+
11
+ class VideoStreamStateMachine {
12
+
13
+ private var _state: VideoStreamState = .idle
14
+ private var listeners: [WeakListenerBox] = []
15
+ private let lock = NSLock()
16
+
17
+ var state: VideoStreamState {
18
+ lock.lock()
19
+ defer { lock.unlock() }
20
+ return _state
21
+ }
22
+
23
+ var isActive: Bool {
24
+ lock.lock()
25
+ defer { lock.unlock() }
26
+ return _state.isActive
27
+ }
28
+
29
+ func addListener(_ listener: VideoStreamStateListener) {
30
+ lock.lock()
31
+ listeners.append(WeakListenerBox(listener))
32
+ lock.unlock()
33
+ }
34
+
35
+ func removeListener(_ listener: VideoStreamStateListener) {
36
+ lock.lock()
37
+ listeners.removeAll { $0.value === listener }
38
+ lock.unlock()
39
+ }
40
+
41
+ @discardableResult
42
+ func transition(to next: VideoStreamState) -> Bool {
43
+ lock.lock()
44
+ let prev = _state
45
+ guard prev.canTransitionTo(next) else {
46
+ lock.unlock()
47
+ logger.warning("Invalid transition: \(String(describing: prev)) → \(String(describing: next)), ignoring")
48
+ return false
49
+ }
50
+ _state = next
51
+ // Compact nilled-out weak refs and snapshot live listeners under the lock
52
+ listeners.removeAll { $0.value == nil }
53
+ let snapshot = listeners.compactMap { $0.value }
54
+ lock.unlock()
55
+
56
+ logger.debug("State: \(String(describing: prev)) → \(String(describing: next))")
57
+ snapshot.forEach { $0.onStateChanged(prev: prev, next: next) }
58
+ return true
59
+ }
60
+ }
@@ -0,0 +1,130 @@
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: "ZoomController")
7
+
8
+ /// Handles zoom operations:
9
+ /// - setZoom(multiplier:) via bridge — 1.0 = 1x, 2.0 = 2x
10
+ /// - pinch-to-zoom via UIPinchGestureRecognizer
11
+ ///
12
+ /// Fires ON_ZOOM_CHANGED via onZoomChanged callback after each zoom change.
13
+ /// Payload: zoomLevel (Float multiplier), minZoom, maxZoom.
14
+ ///
15
+ /// Note: iOS has no ML Kit auto-zoom suggestion equivalent.
16
+ /// autoZoom option in start() is accepted but silently ignored on iOS.
17
+ class ZoomController: NSObject {
18
+
19
+ private var device: AVCaptureDevice?
20
+ private let stateMachine: VideoStreamStateMachine
21
+ private let onZoomChanged: (Float, Float, Float) -> Void // (zoomLevel, minZoom, maxZoom)
22
+
23
+ // Track current zoom so pinch can compute relative delta
24
+ private var zoomAtPinchStart: CGFloat = 1.0
25
+ private var isObservingZoom = false
26
+
27
+ init(stateMachine: VideoStreamStateMachine,
28
+ onZoomChanged: @escaping (Float, Float, Float) -> Void) {
29
+ self.stateMachine = stateMachine
30
+ self.onZoomChanged = onZoomChanged
31
+ }
32
+
33
+ func attachDevice(_ device: AVCaptureDevice) {
34
+ if isObservingZoom {
35
+ self.device?.removeObserver(self, forKeyPath: #keyPath(AVCaptureDevice.videoZoomFactor))
36
+ isObservingZoom = false
37
+ }
38
+ self.device = device
39
+ device.addObserver(self, forKeyPath: #keyPath(AVCaptureDevice.videoZoomFactor), options: [.new], context: nil)
40
+ isObservingZoom = true
41
+ }
42
+
43
+ func detachDevice() {
44
+ if isObservingZoom {
45
+ device?.removeObserver(self, forKeyPath: #keyPath(AVCaptureDevice.videoZoomFactor))
46
+ isObservingZoom = false
47
+ }
48
+ device = nil
49
+ }
50
+
51
+ deinit {
52
+ if isObservingZoom {
53
+ device?.removeObserver(self, forKeyPath: #keyPath(AVCaptureDevice.videoZoomFactor))
54
+ }
55
+ }
56
+
57
+ override func observeValue(forKeyPath keyPath: String?,
58
+ of object: Any?,
59
+ change: [NSKeyValueChangeKey: Any]?,
60
+ context: UnsafeMutableRawPointer?) {
61
+ guard keyPath == #keyPath(AVCaptureDevice.videoZoomFactor),
62
+ let device = object as? AVCaptureDevice else {
63
+ super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
64
+ return
65
+ }
66
+ let current = Float(device.videoZoomFactor)
67
+ let minZ = Float(device.minAvailableVideoZoomFactor)
68
+ let maxZ = Float(device.maxAvailableVideoZoomFactor)
69
+ DispatchQueue.main.async { [weak self] in
70
+ self?.onZoomChanged(current, minZ, maxZ)
71
+ }
72
+ }
73
+
74
+ // MARK: - Bridge API
75
+
76
+ /// Called by NativeBridge setVideoStreamZoom — snaps instantly (no animation ramp).
77
+ func setZoom(multiplier: Float) {
78
+ logger.debug("setZoom(\(multiplier)x) called from bridge")
79
+ applyZoomMultiplier(CGFloat(multiplier), animated: false)
80
+ }
81
+
82
+ /// Apply zoom to the device.
83
+ /// - animated: false → cancel any in-progress ramp and assign directly (bridge calls).
84
+ /// true → let AVFoundation smooth-ramp (pinch gesture).
85
+ func applyZoomMultiplier(_ multiplier: CGFloat, animated: Bool = false) {
86
+ guard let device else {
87
+ logger.warning("applyZoomMultiplier(\(multiplier)x) — device is nil, skipping")
88
+ return
89
+ }
90
+ let minFactor = device.minAvailableVideoZoomFactor
91
+ let maxFactor = device.maxAvailableVideoZoomFactor
92
+ let clamped = multiplier.clamped(to: minFactor...maxFactor)
93
+ do {
94
+ try device.lockForConfiguration()
95
+ if animated {
96
+ // Smooth ramp for pinch — preserve natural feel.
97
+ // KVO on videoZoomFactor fires onZoomChanged as the ramp progresses.
98
+ device.ramp(toVideoZoomFactor: clamped, withRate: 8.0)
99
+ } else {
100
+ // Instant snap for bridge calls.
101
+ // cancelVideoZoomRamp + direct assign triggers one KVO notification.
102
+ device.cancelVideoZoomRamp()
103
+ device.videoZoomFactor = clamped
104
+ }
105
+ device.unlockForConfiguration()
106
+ logger.debug("applyZoomMultiplier(\(multiplier)x) → \(clamped)x animated=\(animated)")
107
+ } catch {
108
+ logger.error("applyZoomMultiplier failed: \(error.localizedDescription)")
109
+ }
110
+ }
111
+
112
+ // MARK: - Pinch gesture
113
+
114
+ func handlePinchBegan() {
115
+ zoomAtPinchStart = device?.videoZoomFactor ?? 1.0
116
+ }
117
+
118
+ func handlePinchChanged(scale: CGFloat) {
119
+ guard stateMachine.isActive else { return }
120
+ applyZoomMultiplier(zoomAtPinchStart * scale, animated: true)
121
+ }
122
+ }
123
+
124
+ // MARK: - Comparable+clamped helper (avoids importing simd)
125
+
126
+ private extension Comparable {
127
+ func clamped(to range: ClosedRange<Self>) -> Self {
128
+ min(max(self, range.lowerBound), range.upperBound)
129
+ }
130
+ }
@@ -73,7 +73,17 @@ public enum CatalystConstants {
73
73
  "getSafeArea",
74
74
  "setScreenSecure",
75
75
  "getScreenSecure",
76
- "clearWebData"
76
+ "clearWebData",
77
+ "startVideoStream",
78
+ "stopVideoStream",
79
+ "flipVideoStream",
80
+ "sendVideoStreamCommand",
81
+ "setVideoStreamZoom",
82
+ "setVideoStreamTorch",
83
+ "setVideoStreamFps",
84
+ "startTransition",
85
+ "commitTransition",
86
+ "cancelTransition"
77
87
  ]
78
88
 
79
89
  // Check if notifications are enabled via config
@@ -0,0 +1,5 @@
1
+ import Foundation
2
+
3
+ public protocol CatalystPlugin {
4
+ func handle(command: String, data: Any?, bridge: PluginBridgeContext)
5
+ }
@@ -0,0 +1,6 @@
1
+ import Foundation
2
+
3
+ enum GeneratedPluginIndex {
4
+ static let pluginFactories: [String: () -> CatalystPlugin] = [:]
5
+ static let pluginToCommands: [String: Set<String>] = [:]
6
+ }
@@ -0,0 +1,364 @@
1
+ import Foundation
2
+ import WebKit
3
+ import UIKit
4
+ import os
5
+
6
+ private let pluginBridgeLogger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app", category: "PluginBridge")
7
+
8
+ private struct PluginBridgeValidationError: LocalizedError {
9
+ let message: String
10
+ let code: String
11
+
12
+ var errorDescription: String? {
13
+ message
14
+ }
15
+ }
16
+
17
+ private struct PluginRequest {
18
+ let pluginId: String
19
+ let command: String
20
+ let data: Any?
21
+ let requestId: String?
22
+ }
23
+
24
+ protocol PluginBridgeMessage {
25
+ var name: String { get }
26
+ var body: Any { get }
27
+ }
28
+
29
+ extension WKScriptMessage: PluginBridgeMessage {}
30
+
31
+ final class PluginBridge: NSObject {
32
+ private weak var webView: WKWebView?
33
+ private weak var viewController: UIViewController?
34
+ private var messageHandlerProxy: WeakScriptMessageHandler?
35
+ private var isRegistered = false
36
+
37
+ private let pluginFactories = GeneratedPluginIndex.pluginFactories
38
+ private let pluginToCommands = GeneratedPluginIndex.pluginToCommands
39
+
40
+ private let bridgeName = "PluginBridge"
41
+ private let errorEvent = "PLUGIN_BRIDGE_ERROR"
42
+ private let systemPluginId = "__bridge__"
43
+
44
+ init(webView: WKWebView, viewController: UIViewController) {
45
+ self.webView = webView
46
+ self.viewController = viewController
47
+ super.init()
48
+ }
49
+
50
+ func register() {
51
+ guard !isRegistered else { return }
52
+ guard let userContentController = webView?.configuration.userContentController else {
53
+ return
54
+ }
55
+ let proxy = WeakScriptMessageHandler(delegate: self)
56
+ userContentController.add(proxy, name: bridgeName)
57
+ messageHandlerProxy = proxy
58
+ isRegistered = true
59
+ }
60
+
61
+ func unregister() {
62
+ guard isRegistered else { return }
63
+ webView?.configuration.userContentController.removeScriptMessageHandler(forName: bridgeName)
64
+ messageHandlerProxy = nil
65
+ isRegistered = false
66
+ }
67
+
68
+ deinit {
69
+ unregister()
70
+ }
71
+
72
+ private func parseRequest(_ message: PluginBridgeMessage) throws -> PluginRequest {
73
+ guard message.name == bridgeName else {
74
+ throw PluginBridgeValidationError(message: "Invalid message handler", code: "INVALID_PAYLOAD")
75
+ }
76
+
77
+ let body: [String: Any]
78
+ if let dictionary = message.body as? [String: Any] {
79
+ body = dictionary
80
+ } else if let dictionary = message.body as? NSDictionary,
81
+ let castedBody = dictionary as? [String: Any] {
82
+ body = castedBody
83
+ } else {
84
+ throw PluginBridgeValidationError(message: "Payload must be an object", code: "INVALID_PAYLOAD")
85
+ }
86
+
87
+ guard JSONSerialization.isValidJSONObject(body) else {
88
+ throw PluginBridgeValidationError(message: "Payload must be JSON-serializable", code: "INVALID_PAYLOAD")
89
+ }
90
+
91
+ let payloadData: Data
92
+ do {
93
+ payloadData = try JSONSerialization.data(withJSONObject: body)
94
+ } catch {
95
+ throw PluginBridgeValidationError(message: "Invalid payload", code: "INVALID_PAYLOAD")
96
+ }
97
+
98
+ if payloadData.count > CatalystConstants.Bridge.maxMessageSize {
99
+ throw PluginBridgeValidationError(message: "Payload exceeds maximum size", code: "INVALID_PAYLOAD")
100
+ }
101
+
102
+ let pluginId = (body["pluginId"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
103
+ let command = (body["command"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
104
+ let requestId = (body["requestId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
105
+
106
+ return PluginRequest(
107
+ pluginId: pluginId,
108
+ command: command,
109
+ data: body["data"],
110
+ requestId: requestId?.isEmpty == true ? nil : requestId
111
+ )
112
+ }
113
+
114
+ private func hasPlugin(_ pluginId: String) -> Bool {
115
+ pluginFactories[pluginId] != nil
116
+ }
117
+
118
+ private func hasCommand(pluginId: String, command: String) -> Bool {
119
+ pluginToCommands[pluginId]?.contains(command) ?? false
120
+ }
121
+
122
+ private func sendBridgeError(message: String, code: String, request: PluginRequest?) {
123
+ let bridge = PluginBridgeContext(
124
+ webView: webView,
125
+ viewController: viewController,
126
+ pluginId: systemPluginId,
127
+ command: request?.command,
128
+ requestId: request?.requestId
129
+ )
130
+ var payload: [String: Any] = [
131
+ "message": message,
132
+ "code": code,
133
+ "pluginId": request?.pluginId ?? systemPluginId,
134
+ ]
135
+ if let command = request?.command, !command.isEmpty {
136
+ payload["command"] = command
137
+ }
138
+ bridge.callback(eventName: errorEvent, data: payload)
139
+ }
140
+
141
+ func handleMessage(_ message: PluginBridgeMessage) {
142
+ var request: PluginRequest?
143
+
144
+ do {
145
+ let parsedRequest = try parseRequest(message)
146
+ request = parsedRequest
147
+
148
+ if parsedRequest.pluginId.isEmpty {
149
+ sendBridgeError(message: "pluginId is required", code: "INVALID_PAYLOAD", request: parsedRequest)
150
+ return
151
+ }
152
+ if parsedRequest.command.isEmpty {
153
+ sendBridgeError(message: "command is required", code: "INVALID_PAYLOAD", request: parsedRequest)
154
+ return
155
+ }
156
+
157
+ if !hasPlugin(parsedRequest.pluginId) {
158
+ sendBridgeError(
159
+ message: "Unsupported plugin: \(parsedRequest.pluginId)",
160
+ code: "PLUGIN_NOT_FOUND",
161
+ request: parsedRequest
162
+ )
163
+ return
164
+ }
165
+
166
+ if !hasCommand(pluginId: parsedRequest.pluginId, command: parsedRequest.command) {
167
+ sendBridgeError(
168
+ message: "Unsupported command '\(parsedRequest.command)' for plugin '\(parsedRequest.pluginId)'",
169
+ code: "COMMAND_NOT_SUPPORTED",
170
+ request: parsedRequest
171
+ )
172
+ return
173
+ }
174
+
175
+ guard let factory = pluginFactories[parsedRequest.pluginId] else {
176
+ sendBridgeError(
177
+ message: "No plugin registered for id: \(parsedRequest.pluginId)",
178
+ code: "PLUGIN_NOT_REGISTERED",
179
+ request: parsedRequest
180
+ )
181
+ return
182
+ }
183
+
184
+ let plugin = factory()
185
+ let bridge = PluginBridgeContext(
186
+ webView: webView,
187
+ viewController: viewController,
188
+ pluginId: parsedRequest.pluginId,
189
+ command: parsedRequest.command,
190
+ requestId: parsedRequest.requestId
191
+ )
192
+ plugin.handle(command: parsedRequest.command, data: parsedRequest.data, bridge: bridge)
193
+ } catch let error as PluginBridgeValidationError {
194
+ sendBridgeError(message: error.message, code: error.code, request: request)
195
+ } catch {
196
+ pluginBridgeLogger.error("Plugin command failed: \(error.localizedDescription)")
197
+ sendBridgeError(
198
+ message: "Plugin execution failed: \(error.localizedDescription)",
199
+ code: "PLUGIN_EXECUTION_FAILED",
200
+ request: request
201
+ )
202
+ }
203
+ }
204
+ }
205
+
206
+ extension PluginBridge: WKScriptMessageHandler {
207
+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
208
+ handleMessage(message)
209
+ }
210
+ }
211
+
212
+ public final class PluginBridgeContext {
213
+ public weak var webView: WKWebView?
214
+ public weak var viewController: UIViewController?
215
+
216
+ private let systemPluginId = "__bridge__"
217
+ private let bridgeErrorEvent = "PLUGIN_BRIDGE_ERROR"
218
+
219
+ public let pluginId: String
220
+ public let command: String?
221
+ public let requestId: String?
222
+
223
+ init(
224
+ webView: WKWebView?,
225
+ viewController: UIViewController?,
226
+ pluginId: String,
227
+ command: String?,
228
+ requestId: String?
229
+ ) {
230
+ self.webView = webView
231
+ self.viewController = viewController
232
+ self.pluginId = pluginId
233
+ self.command = command
234
+ self.requestId = requestId
235
+ }
236
+
237
+ public func callback(
238
+ eventName: String,
239
+ data: Any?,
240
+ command: String? = nil
241
+ ) {
242
+ let resolvedRequestId = self.requestId
243
+ let resolvedCommand = command ?? self.command
244
+ let trimmedEventName = eventName.trimmingCharacters(in: .whitespacesAndNewlines)
245
+ guard !trimmedEventName.isEmpty else {
246
+ emitBridgeError(
247
+ message: "Rejected callback with blank event name for plugin \(self.pluginId)",
248
+ code: "INVALID_CALLBACK",
249
+ requestId: resolvedRequestId,
250
+ command: resolvedCommand
251
+ )
252
+ return
253
+ }
254
+ if !dispatchEnvelope(
255
+ pluginId: pluginId,
256
+ eventName: trimmedEventName,
257
+ payload: data,
258
+ requestId: resolvedRequestId,
259
+ command: resolvedCommand
260
+ ) {
261
+ if pluginId == systemPluginId && trimmedEventName == bridgeErrorEvent {
262
+ pluginBridgeLogger.error("Failed to dispatch bridge error event for plugin \(self.pluginId)")
263
+ return
264
+ }
265
+
266
+ emitBridgeError(
267
+ message: "Failed to dispatch callback \(trimmedEventName) for plugin \(self.pluginId)",
268
+ code: "PLUGIN_EXECUTION_FAILED",
269
+ requestId: resolvedRequestId,
270
+ command: resolvedCommand
271
+ )
272
+ }
273
+ }
274
+
275
+ private func emitBridgeError(
276
+ message: String,
277
+ code: String,
278
+ requestId: String?,
279
+ command: String?
280
+ ) {
281
+ var payload: [String: Any] = [
282
+ "message": message,
283
+ "code": code,
284
+ "pluginId": pluginId,
285
+ ]
286
+
287
+ if let command = command, !command.isEmpty {
288
+ payload["command"] = command
289
+ }
290
+
291
+ if !dispatchEnvelope(
292
+ pluginId: systemPluginId,
293
+ eventName: bridgeErrorEvent,
294
+ payload: payload,
295
+ requestId: requestId,
296
+ command: command,
297
+ logFailures: false
298
+ ) {
299
+ pluginBridgeLogger.error("Failed to dispatch bridge error for plugin \(self.pluginId): \(message)")
300
+ }
301
+ }
302
+
303
+ private func dispatchEnvelope(
304
+ pluginId: String,
305
+ eventName: String,
306
+ payload: Any?,
307
+ requestId: String?,
308
+ command: String?,
309
+ logFailures: Bool = true
310
+ ) -> Bool {
311
+ guard let webView = webView else {
312
+ if logFailures {
313
+ pluginBridgeLogger.error("WebView unavailable for plugin callback \(eventName)")
314
+ }
315
+ return false
316
+ }
317
+
318
+ let envelope: [String: Any] = [
319
+ "pluginId": pluginId,
320
+ "eventName": eventName,
321
+ "payload": payload ?? NSNull(),
322
+ "requestId": requestId ?? NSNull(),
323
+ "command": command ?? NSNull(),
324
+ ]
325
+
326
+ guard JSONSerialization.isValidJSONObject(envelope),
327
+ let envelopeData = try? JSONSerialization.data(withJSONObject: envelope),
328
+ let envelopeJson = String(data: envelopeData, encoding: .utf8) else {
329
+ if logFailures {
330
+ pluginBridgeLogger.error("Failed to serialize plugin callback envelope for \(pluginId).\(eventName)")
331
+ }
332
+ return false
333
+ }
334
+
335
+ let envelopeLiteral = javaScriptStringLiteral(envelopeJson)
336
+ let script = "window.PluginBridgeWeb && window.PluginBridgeWeb.dispatch(\(envelopeLiteral));"
337
+
338
+ DispatchQueue.main.async {
339
+ webView.evaluateJavaScript(
340
+ script,
341
+ completionHandler: { _, error in
342
+ if let error = error {
343
+ pluginBridgeLogger.error(
344
+ "Plugin callback JS failed for \(pluginId).\(eventName): \(error.localizedDescription)"
345
+ )
346
+ }
347
+ }
348
+ )
349
+ }
350
+
351
+ return true
352
+ }
353
+
354
+ private func javaScriptStringLiteral(_ value: String) -> String {
355
+ let escaped = value
356
+ .replacingOccurrences(of: "\\", with: "\\\\")
357
+ .replacingOccurrences(of: "\"", with: "\\\"")
358
+ .replacingOccurrences(of: "\n", with: "\\n")
359
+ .replacingOccurrences(of: "\r", with: "\\r")
360
+ .replacingOccurrences(of: "\u{2028}", with: "\\u2028")
361
+ .replacingOccurrences(of: "\u{2029}", with: "\\u2029")
362
+ return "\"\(escaped)\""
363
+ }
364
+ }
@@ -370,7 +370,10 @@ class BridgeMessageValidator {
370
370
  "cancelLocalNotification",
371
371
  "subscribeToTopic",
372
372
  "unsubscribeFromTopic",
373
- "getSafeArea"
373
+ "getSafeArea",
374
+ "startTransition",
375
+ "commitTransition",
376
+ "cancelTransition"
374
377
  ]
375
378
 
376
379
  if flexibleCommands.contains(command) {