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.
- package/README.md +1 -1
- package/bin/catalyst.js +8 -1
- package/changelog.md +6 -0
- package/dist/native/androidProject/app/build.gradle.kts +9 -0
- package/dist/native/androidProject/app/src/main/AndroidManifest.xml +2 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/BridgeMessageValidator.kt +1 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +17 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +31 -3
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt +148 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeCameraManager.kt +337 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/TransitionManager.kt +202 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/WebCacheManager.kt +3 -3
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/BarcodeDetector.kt +123 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/CameraSessionManager.kt +244 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/HoldController.kt +70 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/TorchController.kt +57 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/VideoStreamState.kt +41 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/VideoStreamStateMachine.kt +40 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/ViewfinderMapper.kt +75 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/ZoomController.kt +156 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/CatalystPlugin.kt +7 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/GeneratedPluginIndex.kt +6 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/PluginBridge.kt +253 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/security/FridaDetector.kt +16 -5
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt +42 -5
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/FrameworkServerUtils.kt +7 -3
- package/dist/native/androidProject/app/src/main/res/drawable/debug_viewfinder_border.xml +8 -0
- package/dist/native/androidProject/app/src/main/res/layout/activity_main.xml +37 -0
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/plugins/PluginBridgeTest.kt +139 -0
- package/dist/native/androidSetup.js +1 -1
- package/dist/native/bridge/WebBridge.js +5 -5
- package/dist/native/bridge/constants/NativeInterfaces.js +9 -5
- package/dist/native/bridge/hooks/index.js +1 -0
- package/dist/native/bridge/hooks/useCamera.js +9 -0
- package/dist/native/bridge/hooks/useDataProtection.js +2 -0
- package/dist/native/bridge/hooks/useDeviceInfo.js +2 -0
- package/dist/native/bridge/hooks/useFilePicker.js +2 -0
- package/dist/native/bridge/hooks/useGoogleSignIn.js +1 -0
- package/dist/native/bridge/hooks/useHapticFeedback.js +1 -0
- package/dist/native/bridge/hooks/useIntent.js +1 -0
- package/dist/native/bridge/hooks/useNativeTransition.js +43 -0
- package/dist/native/bridge/hooks/useNetworkStatus.js +3 -0
- package/dist/native/bridge/hooks/useNotification.js +2 -0
- package/dist/native/bridge/hooks/useSafeArea.js +2 -0
- package/dist/native/bridge/hooks/useVideoStream.js +6 -0
- package/dist/native/bridge/hooks/utils.js +1 -0
- package/dist/native/bridge/hooks.js +1 -137
- package/dist/native/bridge/useBaseHook.js +12 -7
- package/dist/native/bridge/utils/NativeBridge.js +41 -6
- package/dist/native/buildAppAndroid.js +7 -9
- package/dist/native/buildAppIos.js +14 -22
- package/dist/native/internalPluginUtils.js +1 -0
- package/dist/native/iosnativeWebView/Package.resolved +36 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/BarcodeDetector.swift +116 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/CameraPreviewView.swift +58 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/CameraSessionManager.swift +308 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/HoldController.swift +63 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/NativeCameraManager.swift +194 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/TorchController.swift +60 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/VideoStreamState.swift +44 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/VideoStreamStateMachine.swift +60 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/ZoomController.swift +130 -0
- package/dist/native/iosnativeWebView/Sources/Core/Constants/CatalystConstants.swift +11 -1
- package/dist/native/iosnativeWebView/Sources/Core/Plugins/CatalystPlugin.swift +5 -0
- package/dist/native/iosnativeWebView/Sources/Core/Plugins/GeneratedPluginIndex.swift +6 -0
- package/dist/native/iosnativeWebView/Sources/Core/Plugins/PluginBridge.swift +364 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeMessageValidator.swift +4 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/NativeBridge.swift +227 -4
- package/dist/native/iosnativeWebView/Sources/Core/WebView/TransitionManager.swift +169 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WeakScriptMessageHandler.swift +14 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +38 -7
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +6 -2
- package/dist/native/iosnativeWebView/iosnativeWebView/ContentView.swift +15 -4
- package/dist/native/iosnativeWebView/iosnativeWebView/Info.plist +11 -17
- package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.pbxproj +8 -2
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/PluginBridgeTests.swift +160 -0
- package/dist/native/plugin-bridge/PluginBridge.js +1 -0
- package/dist/native/pluginComposerAndroid.js +9 -0
- package/dist/native/pluginComposerIos.js +7 -0
- package/dist/native/renameAndroidProject.js +3 -3
- package/dist/native/setupServer.js +2 -3
- package/dist/native/utils.js +4 -1
- package/dist/otel.js +70 -2
- package/dist/scripts/devServe.js +1 -1
- package/dist/scripts/plugins.js +1 -0
- package/dist/scripts/registerAliases.js +6 -2
- package/dist/scripts/scriptUtils.js +2 -1
- package/dist/scripts/serve.js +2 -1
- package/dist/scripts/start.js +4 -1
- package/dist/server/renderer/extract.js +5 -5
- package/dist/server/renderer/handler.js +15 -14
- package/dist/server/utils/validator.js +1 -1
- package/dist/webpack/base.babel.js +2 -1
- package/dist/webpack/development.client.babel.js +6 -2
- package/dist/webpack/production.ssr.babel.js +5 -1
- package/mcp_v2/knowledge-base.json +1564 -1067
- package/mcp_v2/lib/helpers.js +19 -7
- package/mcp_v2/tools/build.js +1 -1
- package/mcp_v2/tools/config.js +16 -4
- package/mcp_v2/tools/conversion.js +11 -11
- package/mcp_v2/tools/knowledge.js +16 -3
- package/mcp_v2/tools/tasks.js +13 -4
- 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,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) {
|