catalyst-core 0.1.0-canary.9 → 0.2.0-beta.2
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 +34 -8
- 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 +270 -78
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +34 -5
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt +149 -1
- 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/OfflineCacheService.kt +374 -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 +156 -39
- 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/CustomWebviewTest.kt +7 -2
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/plugins/PluginBridgeTest.kt +139 -0
- package/dist/native/androidSetup.js +1 -0
- 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/buildAndroid/assets.js +52 -0
- package/dist/native/buildAndroid/build.js +19 -0
- package/dist/native/buildAndroid/config.js +1 -0
- package/dist/native/buildAndroid/index.js +1 -0
- package/dist/native/buildAppAndroid.js +2 -150
- package/dist/native/buildAppIos.js +2 -312
- package/dist/native/buildIos/assets.js +8 -0
- package/dist/native/buildIos/build.js +40 -0
- package/dist/native/buildIos/config.js +71 -0
- package/dist/native/buildIos/index.js +6 -0
- package/dist/native/buildIos/plugins.js +45 -0
- package/dist/native/internalPluginUtils.js +7 -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/Constants/ConfigConstants.swift +2 -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/Utils/CacheManager.swift +39 -4
- package/dist/native/iosnativeWebView/Sources/Core/Utils/OfflineCacheService.swift +683 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/OfflineURLSchemeHandler.swift +94 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/ResourceURLProtocol.swift +19 -6
- package/dist/native/iosnativeWebView/Sources/Core/Utils/SafeAreaUtils.swift +1 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeCommandHandler.swift +1 -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 +53 -8
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +88 -4
- package/dist/native/iosnativeWebView/iosnativeWebView/ContentView.swift +30 -11
- package/dist/native/iosnativeWebView/iosnativeWebView/Info.plist +11 -17
- package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.pbxproj +8 -2
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/BootTimingUtilityTests.swift +1 -1
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/ConfigMappingTests.swift +4 -2
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/PluginBridgeTests.swift +160 -0
- package/dist/native/plugin-bridge/PluginBridge.js +1 -0
- package/dist/native/pluginComposerAndroid.js +28 -0
- package/dist/native/pluginComposerIos.js +16 -0
- package/dist/native/renameAndroidProject.js +5 -5
- package/dist/native/setupEmulatorIos.js +6 -4
- package/dist/native/setupServer.js +2 -3
- package/dist/native/utils.js +4 -1
- package/dist/offline/catalyst-sw.js +1 -0
- package/dist/offline/registerServiceWorker.js +1 -0
- package/dist/otel.js +70 -2
- package/dist/router/ClientRouter.js +1 -1
- package/dist/scripts/build.js +1 -1
- package/dist/scripts/cleanCache.js +1 -0
- package/dist/scripts/devBuild.js +1 -1
- package/dist/scripts/devServe.js +1 -1
- package/dist/scripts/generateOfflineManifest.js +1 -0
- package/dist/scripts/loadEnvironmentVariables.js +1 -2
- 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 +7 -2
- package/dist/server/expressServer.js +4 -8
- package/dist/server/renderer/document/Body.js +4 -4
- package/dist/server/renderer/document/Head.js +2 -2
- package/dist/server/renderer/extract.js +31 -27
- package/dist/server/renderer/handler.js +47 -24
- package/dist/server/renderer/index.js +1 -1
- package/dist/server/startServer.js +57 -7
- package/dist/server/utils/userAgentUtil.js +9 -3
- package/dist/server/utils/validator.js +1 -1
- package/dist/webpack/babel-plugins/remove-client.plugin.js +2 -2
- package/dist/webpack/babel-plugins/remove-ssr.plugin.js +2 -2
- package/dist/webpack/babel.config.client.js +1 -1
- package/dist/webpack/babel.config.ssr.js +1 -1
- package/dist/webpack/base.babel.js +7 -8
- package/dist/webpack/development.client.babel.js +7 -16
- package/dist/webpack/production.client.babel.js +1 -1
- package/dist/webpack/production.ssr.babel.js +5 -1
- package/mcp_v2/knowledge-base.json +206 -100
- package/mcp_v2/lib/helpers.js +42 -8
- package/mcp_v2/lib/seed.js +88 -0
- package/mcp_v2/mcp.js +150 -4
- package/mcp_v2/setup.js +30 -193
- 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/github.js +1117 -0
- package/mcp_v2/tools/knowledge.js +16 -3
- package/mcp_v2/tools/sync.js +179 -10
- package/mcp_v2/tools/tasks.js +13 -4
- package/package.json +20 -8
|
@@ -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
|
|
@@ -24,8 +24,9 @@ public enum ConfigConstants {
|
|
|
24
24
|
|
|
25
25
|
public static let appInfo = "test-build"
|
|
26
26
|
public static let cachePattern: [String] = []
|
|
27
|
-
public static let LOCAL_IP = "localhost"
|
|
27
|
+
public static let LOCAL_IP = "localhost" // swiftlint:disable:this identifier_name
|
|
28
28
|
public static let port = ""
|
|
29
|
+
public static let initial_url = "" // swiftlint:disable:this identifier_name
|
|
29
30
|
|
|
30
31
|
public enum SplashScreen {
|
|
31
32
|
public static let imageHeight = 200
|
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Foundation
|
|
2
|
+
import CryptoKit
|
|
2
3
|
import os
|
|
3
4
|
|
|
4
5
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app", category: "CacheManager")
|
|
@@ -118,8 +119,8 @@ public final class CacheManager {
|
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
private func getCacheKey(for url: URL) -> String {
|
|
121
|
-
|
|
122
|
-
|
|
122
|
+
let digest = SHA256.hash(data: Data(url.absoluteString.utf8))
|
|
123
|
+
return digest.map { String(format: "%02x", $0) }.joined()
|
|
123
124
|
}
|
|
124
125
|
|
|
125
126
|
private func getCacheFilePath(for url: URL) -> URL {
|
|
@@ -140,6 +141,11 @@ public final class CacheManager {
|
|
|
140
141
|
|
|
141
142
|
return false
|
|
142
143
|
}
|
|
144
|
+
|
|
145
|
+
func shouldCacheRequest(_ request: URLRequest) -> Bool {
|
|
146
|
+
guard let url = request.url else { return false }
|
|
147
|
+
return shouldCacheURL(url) || OfflineCacheService.shared.shouldCacheOfflineRouteSubresource(request)
|
|
148
|
+
}
|
|
143
149
|
|
|
144
150
|
private func getCacheState(for timestamp: Date) -> CacheState {
|
|
145
151
|
let age = Date().timeIntervalSince(timestamp)
|
|
@@ -181,6 +187,10 @@ public final class CacheManager {
|
|
|
181
187
|
logger.info("🔴 Cache expired for: \(urlString)")
|
|
182
188
|
continuation.resume(returning: (nil, .expired, nil))
|
|
183
189
|
}
|
|
190
|
+
} else if let cachedResponse = manager.session.configuration.urlCache?.cachedResponse(for: request) {
|
|
191
|
+
let mimeType = (cachedResponse.response as? HTTPURLResponse)?.mimeType ?? "application/octet-stream"
|
|
192
|
+
logger.info("🟢 URLCache fallback hit for: \(urlString)")
|
|
193
|
+
continuation.resume(returning: (cachedResponse.data, .fresh, mimeType))
|
|
184
194
|
} else {
|
|
185
195
|
logger.info("❌ Cache miss for: \(urlString)")
|
|
186
196
|
continuation.resume(returning: (nil, .expired, nil))
|
|
@@ -193,10 +203,13 @@ public final class CacheManager {
|
|
|
193
203
|
logger.info("🔄 Starting revalidation for: \(request.url?.absoluteString ?? "")")
|
|
194
204
|
|
|
195
205
|
do {
|
|
196
|
-
let
|
|
206
|
+
let configuration = URLSessionConfiguration.ephemeral
|
|
207
|
+
configuration.protocolClasses = []
|
|
208
|
+
let networkSession = URLSession(configuration: configuration)
|
|
209
|
+
let (data, response) = try await networkSession.data(for: request)
|
|
197
210
|
|
|
198
211
|
if let httpResponse = response as? HTTPURLResponse,
|
|
199
|
-
isCacheableResponse(httpResponse) {
|
|
212
|
+
isCacheableResponse(httpResponse, for: request) {
|
|
200
213
|
storeCachedResponse(httpResponse, data: data, for: request)
|
|
201
214
|
logger.info("Resource revalidated: \(request.url?.absoluteString ?? "")")
|
|
202
215
|
}
|
|
@@ -263,6 +276,28 @@ public final class CacheManager {
|
|
|
263
276
|
guard let url = response.url else { return false }
|
|
264
277
|
return (200...299 ~= response.statusCode) && shouldCacheURL(url)
|
|
265
278
|
}
|
|
279
|
+
|
|
280
|
+
func isCacheableResponse(_ response: HTTPURLResponse, for request: URLRequest) -> Bool {
|
|
281
|
+
guard let url = response.url, 200...299 ~= response.statusCode else { return false }
|
|
282
|
+
|
|
283
|
+
let matchesLegacyPattern = shouldCacheURL(url)
|
|
284
|
+
let offlineRouteSubresource = OfflineCacheService.shared.shouldCacheOfflineRouteSubresource(request)
|
|
285
|
+
guard matchesLegacyPattern || offlineRouteSubresource else { return false }
|
|
286
|
+
|
|
287
|
+
if offlineRouteSubresource && !matchesLegacyPattern && isDocumentOrDataResponse(response) {
|
|
288
|
+
return false
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return true
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private func isDocumentOrDataResponse(_ response: HTTPURLResponse) -> Bool {
|
|
295
|
+
let mimeType = response.mimeType?.lowercased() ?? ""
|
|
296
|
+
return mimeType == "text/html" ||
|
|
297
|
+
mimeType == "application/json" ||
|
|
298
|
+
mimeType.hasSuffix("+json") ||
|
|
299
|
+
mimeType == "text/event-stream"
|
|
300
|
+
}
|
|
266
301
|
|
|
267
302
|
func getCacheStatistics() -> (memoryUsed: Int, diskUsed: Int) {
|
|
268
303
|
let memoryUsed = session.configuration.urlCache?.currentMemoryUsage ?? 0
|