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.
Files changed (148) 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 +34 -8
  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 +270 -78
  8. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +34 -5
  9. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt +149 -1
  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/OfflineCacheService.kt +374 -0
  12. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/TransitionManager.kt +202 -0
  13. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/WebCacheManager.kt +156 -39
  14. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/BarcodeDetector.kt +123 -0
  15. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/CameraSessionManager.kt +244 -0
  16. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/HoldController.kt +70 -0
  17. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/TorchController.kt +57 -0
  18. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/VideoStreamState.kt +41 -0
  19. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/VideoStreamStateMachine.kt +40 -0
  20. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/ViewfinderMapper.kt +75 -0
  21. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/ZoomController.kt +156 -0
  22. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/CatalystPlugin.kt +7 -0
  23. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/GeneratedPluginIndex.kt +6 -0
  24. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/PluginBridge.kt +253 -0
  25. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/security/FridaDetector.kt +16 -5
  26. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt +42 -5
  27. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/FrameworkServerUtils.kt +7 -3
  28. package/dist/native/androidProject/app/src/main/res/drawable/debug_viewfinder_border.xml +8 -0
  29. package/dist/native/androidProject/app/src/main/res/layout/activity_main.xml +37 -0
  30. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/CustomWebviewTest.kt +7 -2
  31. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/plugins/PluginBridgeTest.kt +139 -0
  32. package/dist/native/androidSetup.js +1 -0
  33. package/dist/native/bridge/WebBridge.js +5 -5
  34. package/dist/native/bridge/constants/NativeInterfaces.js +9 -5
  35. package/dist/native/bridge/hooks/index.js +1 -0
  36. package/dist/native/bridge/hooks/useCamera.js +9 -0
  37. package/dist/native/bridge/hooks/useDataProtection.js +2 -0
  38. package/dist/native/bridge/hooks/useDeviceInfo.js +2 -0
  39. package/dist/native/bridge/hooks/useFilePicker.js +2 -0
  40. package/dist/native/bridge/hooks/useGoogleSignIn.js +1 -0
  41. package/dist/native/bridge/hooks/useHapticFeedback.js +1 -0
  42. package/dist/native/bridge/hooks/useIntent.js +1 -0
  43. package/dist/native/bridge/hooks/useNativeTransition.js +43 -0
  44. package/dist/native/bridge/hooks/useNetworkStatus.js +3 -0
  45. package/dist/native/bridge/hooks/useNotification.js +2 -0
  46. package/dist/native/bridge/hooks/useSafeArea.js +2 -0
  47. package/dist/native/bridge/hooks/useVideoStream.js +6 -0
  48. package/dist/native/bridge/hooks/utils.js +1 -0
  49. package/dist/native/bridge/hooks.js +1 -137
  50. package/dist/native/bridge/useBaseHook.js +12 -7
  51. package/dist/native/bridge/utils/NativeBridge.js +41 -6
  52. package/dist/native/buildAndroid/assets.js +52 -0
  53. package/dist/native/buildAndroid/build.js +19 -0
  54. package/dist/native/buildAndroid/config.js +1 -0
  55. package/dist/native/buildAndroid/index.js +1 -0
  56. package/dist/native/buildAppAndroid.js +2 -150
  57. package/dist/native/buildAppIos.js +2 -312
  58. package/dist/native/buildIos/assets.js +8 -0
  59. package/dist/native/buildIos/build.js +40 -0
  60. package/dist/native/buildIos/config.js +71 -0
  61. package/dist/native/buildIos/index.js +6 -0
  62. package/dist/native/buildIos/plugins.js +45 -0
  63. package/dist/native/internalPluginUtils.js +7 -0
  64. package/dist/native/iosnativeWebView/Package.resolved +36 -0
  65. package/dist/native/iosnativeWebView/Sources/Core/Camera/BarcodeDetector.swift +116 -0
  66. package/dist/native/iosnativeWebView/Sources/Core/Camera/CameraPreviewView.swift +58 -0
  67. package/dist/native/iosnativeWebView/Sources/Core/Camera/CameraSessionManager.swift +308 -0
  68. package/dist/native/iosnativeWebView/Sources/Core/Camera/HoldController.swift +63 -0
  69. package/dist/native/iosnativeWebView/Sources/Core/Camera/NativeCameraManager.swift +194 -0
  70. package/dist/native/iosnativeWebView/Sources/Core/Camera/TorchController.swift +60 -0
  71. package/dist/native/iosnativeWebView/Sources/Core/Camera/VideoStreamState.swift +44 -0
  72. package/dist/native/iosnativeWebView/Sources/Core/Camera/VideoStreamStateMachine.swift +60 -0
  73. package/dist/native/iosnativeWebView/Sources/Core/Camera/ZoomController.swift +130 -0
  74. package/dist/native/iosnativeWebView/Sources/Core/Constants/CatalystConstants.swift +11 -1
  75. package/dist/native/iosnativeWebView/Sources/Core/Constants/ConfigConstants.swift +2 -1
  76. package/dist/native/iosnativeWebView/Sources/Core/Plugins/CatalystPlugin.swift +5 -0
  77. package/dist/native/iosnativeWebView/Sources/Core/Plugins/GeneratedPluginIndex.swift +6 -0
  78. package/dist/native/iosnativeWebView/Sources/Core/Plugins/PluginBridge.swift +364 -0
  79. package/dist/native/iosnativeWebView/Sources/Core/Utils/CacheManager.swift +39 -4
  80. package/dist/native/iosnativeWebView/Sources/Core/Utils/OfflineCacheService.swift +683 -0
  81. package/dist/native/iosnativeWebView/Sources/Core/Utils/OfflineURLSchemeHandler.swift +94 -0
  82. package/dist/native/iosnativeWebView/Sources/Core/Utils/ResourceURLProtocol.swift +19 -6
  83. package/dist/native/iosnativeWebView/Sources/Core/Utils/SafeAreaUtils.swift +1 -0
  84. package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeCommandHandler.swift +1 -0
  85. package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeMessageValidator.swift +4 -1
  86. package/dist/native/iosnativeWebView/Sources/Core/WebView/NativeBridge.swift +227 -4
  87. package/dist/native/iosnativeWebView/Sources/Core/WebView/TransitionManager.swift +169 -0
  88. package/dist/native/iosnativeWebView/Sources/Core/WebView/WeakScriptMessageHandler.swift +14 -0
  89. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +53 -8
  90. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +88 -4
  91. package/dist/native/iosnativeWebView/iosnativeWebView/ContentView.swift +30 -11
  92. package/dist/native/iosnativeWebView/iosnativeWebView/Info.plist +11 -17
  93. package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.pbxproj +8 -2
  94. package/dist/native/iosnativeWebView/iosnativeWebViewTests/BootTimingUtilityTests.swift +1 -1
  95. package/dist/native/iosnativeWebView/iosnativeWebViewTests/ConfigMappingTests.swift +4 -2
  96. package/dist/native/iosnativeWebView/iosnativeWebViewTests/PluginBridgeTests.swift +160 -0
  97. package/dist/native/plugin-bridge/PluginBridge.js +1 -0
  98. package/dist/native/pluginComposerAndroid.js +28 -0
  99. package/dist/native/pluginComposerIos.js +16 -0
  100. package/dist/native/renameAndroidProject.js +5 -5
  101. package/dist/native/setupEmulatorIos.js +6 -4
  102. package/dist/native/setupServer.js +2 -3
  103. package/dist/native/utils.js +4 -1
  104. package/dist/offline/catalyst-sw.js +1 -0
  105. package/dist/offline/registerServiceWorker.js +1 -0
  106. package/dist/otel.js +70 -2
  107. package/dist/router/ClientRouter.js +1 -1
  108. package/dist/scripts/build.js +1 -1
  109. package/dist/scripts/cleanCache.js +1 -0
  110. package/dist/scripts/devBuild.js +1 -1
  111. package/dist/scripts/devServe.js +1 -1
  112. package/dist/scripts/generateOfflineManifest.js +1 -0
  113. package/dist/scripts/loadEnvironmentVariables.js +1 -2
  114. package/dist/scripts/plugins.js +1 -0
  115. package/dist/scripts/registerAliases.js +6 -2
  116. package/dist/scripts/scriptUtils.js +2 -1
  117. package/dist/scripts/serve.js +2 -1
  118. package/dist/scripts/start.js +7 -2
  119. package/dist/server/expressServer.js +4 -8
  120. package/dist/server/renderer/document/Body.js +4 -4
  121. package/dist/server/renderer/document/Head.js +2 -2
  122. package/dist/server/renderer/extract.js +31 -27
  123. package/dist/server/renderer/handler.js +47 -24
  124. package/dist/server/renderer/index.js +1 -1
  125. package/dist/server/startServer.js +57 -7
  126. package/dist/server/utils/userAgentUtil.js +9 -3
  127. package/dist/server/utils/validator.js +1 -1
  128. package/dist/webpack/babel-plugins/remove-client.plugin.js +2 -2
  129. package/dist/webpack/babel-plugins/remove-ssr.plugin.js +2 -2
  130. package/dist/webpack/babel.config.client.js +1 -1
  131. package/dist/webpack/babel.config.ssr.js +1 -1
  132. package/dist/webpack/base.babel.js +7 -8
  133. package/dist/webpack/development.client.babel.js +7 -16
  134. package/dist/webpack/production.client.babel.js +1 -1
  135. package/dist/webpack/production.ssr.babel.js +5 -1
  136. package/mcp_v2/knowledge-base.json +206 -100
  137. package/mcp_v2/lib/helpers.js +42 -8
  138. package/mcp_v2/lib/seed.js +88 -0
  139. package/mcp_v2/mcp.js +150 -4
  140. package/mcp_v2/setup.js +30 -193
  141. package/mcp_v2/tools/build.js +1 -1
  142. package/mcp_v2/tools/config.js +16 -4
  143. package/mcp_v2/tools/conversion.js +11 -11
  144. package/mcp_v2/tools/github.js +1117 -0
  145. package/mcp_v2/tools/knowledge.js +16 -3
  146. package/mcp_v2/tools/sync.js +179 -10
  147. package/mcp_v2/tools/tasks.js +13 -4
  148. 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,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
+ }
@@ -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
- return url.absoluteString.replacingOccurrences(of: "/", with: "_")
122
- .replacingOccurrences(of: ":", with: "_")
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 (data, response) = try await session.data(for: request)
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