catalyst-core-internal 0.1.0 → 0.1.3
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 +4 -4
- package/bin/catalyst.js +8 -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 +12 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +18 -3
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/CatalystPlugin.kt +5 -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 +240 -0
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/plugins/PluginBridgeTest.kt +121 -0
- package/dist/native/bridge/useBaseHook.js +1 -1
- package/dist/native/buildAppAndroid.js +2 -2
- package/dist/native/buildAppIos.js +10 -17
- package/dist/native/internal-plugins/device-info-plugin/android/DeviceInfoPlugin.kt +43 -0
- package/dist/native/internal-plugins/device-info-plugin/ios/DeviceInfoPlugin.swift +28 -0
- package/dist/native/internal-plugins/device-info-plugin/manifest.json +19 -0
- package/dist/native/internalPluginUtils.js +1 -0
- 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/NativeBridge.swift +13 -2
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WeakScriptMessageHandler.swift +14 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +6 -0
- package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.pbxproj +4 -0
- 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/scripts/plugins.js +1 -0
- package/package.json +3 -2
|
@@ -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
|
+
final class PluginBridgeContext {
|
|
213
|
+
weak var webView: WKWebView?
|
|
214
|
+
weak var viewController: UIViewController?
|
|
215
|
+
|
|
216
|
+
private let systemPluginId = "__bridge__"
|
|
217
|
+
private let bridgeErrorEvent = "PLUGIN_BRIDGE_ERROR"
|
|
218
|
+
|
|
219
|
+
let pluginId: String
|
|
220
|
+
let command: String?
|
|
221
|
+
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
|
+
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
|
+
}
|
|
@@ -17,6 +17,8 @@ class NativeBridge: NSObject, BridgeCommandHandlerDelegate, BridgeFileHandlerDel
|
|
|
17
17
|
weak var webView: WKWebView?
|
|
18
18
|
private weak var viewController: UIViewController?
|
|
19
19
|
private weak var webViewModel: WebViewModel?
|
|
20
|
+
private var messageHandlerProxy: WeakScriptMessageHandler?
|
|
21
|
+
private var isRegistered = false
|
|
20
22
|
|
|
21
23
|
// Protocol-based notification handler (injected at runtime)
|
|
22
24
|
private var notificationHandler: NotificationHandlerProtocol = NullNotificationHandler.shared
|
|
@@ -133,10 +135,16 @@ class NativeBridge: NSObject, BridgeCommandHandlerDelegate, BridgeFileHandlerDel
|
|
|
133
135
|
|
|
134
136
|
// Register the JavaScript interface with the WebView
|
|
135
137
|
func register() {
|
|
138
|
+
guard !isRegistered else { return }
|
|
136
139
|
let registerStart = CFAbsoluteTimeGetCurrent()
|
|
137
140
|
|
|
138
|
-
let userContentController = webView?.configuration.userContentController
|
|
139
|
-
|
|
141
|
+
guard let userContentController = webView?.configuration.userContentController else {
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
let proxy = WeakScriptMessageHandler(delegate: self)
|
|
145
|
+
userContentController.add(proxy, name: "NativeBridge")
|
|
146
|
+
messageHandlerProxy = proxy
|
|
147
|
+
isRegistered = true
|
|
140
148
|
|
|
141
149
|
let registerTime = (CFAbsoluteTimeGetCurrent() - registerStart) * 1000
|
|
142
150
|
logWithTimestamp("✅ NativeBridge registered (took \(String(format: "%.2f", registerTime))ms)")
|
|
@@ -144,7 +152,10 @@ class NativeBridge: NSObject, BridgeCommandHandlerDelegate, BridgeFileHandlerDel
|
|
|
144
152
|
|
|
145
153
|
// Unregister to prevent memory leaks
|
|
146
154
|
func unregister() {
|
|
155
|
+
guard isRegistered else { return }
|
|
147
156
|
webView?.configuration.userContentController.removeScriptMessageHandler(forName: "NativeBridge")
|
|
157
|
+
messageHandlerProxy = nil
|
|
158
|
+
isRegistered = false
|
|
148
159
|
|
|
149
160
|
if let listenerId = networkStatusListenerId {
|
|
150
161
|
NetworkMonitor.shared.removeListener(listenerId)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import WebKit
|
|
2
|
+
|
|
3
|
+
final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler {
|
|
4
|
+
weak var delegate: WKScriptMessageHandler?
|
|
5
|
+
|
|
6
|
+
init(delegate: WKScriptMessageHandler) {
|
|
7
|
+
self.delegate = delegate
|
|
8
|
+
super.init()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
12
|
+
delegate?.userContentController(userContentController, didReceive: message)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -121,6 +121,8 @@ public struct WebView: UIViewRepresentable, Equatable {
|
|
|
121
121
|
// Clean up native bridge
|
|
122
122
|
coordinator.nativeBridge?.unregister()
|
|
123
123
|
coordinator.nativeBridge = nil
|
|
124
|
+
coordinator.pluginBridge?.unregister()
|
|
125
|
+
coordinator.pluginBridge = nil
|
|
124
126
|
coordinator.hostingController = nil
|
|
125
127
|
|
|
126
128
|
// Unregister custom URL protocol
|
|
@@ -132,6 +134,7 @@ public struct WebView: UIViewRepresentable, Equatable {
|
|
|
132
134
|
public class Coordinator: NSObject {
|
|
133
135
|
var parent: WebView
|
|
134
136
|
var nativeBridge: NativeBridge?
|
|
137
|
+
var pluginBridge: PluginBridge?
|
|
135
138
|
var hostingController: UIViewController?
|
|
136
139
|
var isObserverAdded = false
|
|
137
140
|
|
|
@@ -146,6 +149,7 @@ public struct WebView: UIViewRepresentable, Equatable {
|
|
|
146
149
|
|
|
147
150
|
// Create and register the native bridge
|
|
148
151
|
let bridge = NativeBridge(webView: webView, viewController: hostingController)
|
|
152
|
+
let pluginBridge = PluginBridge(webView: webView, viewController: hostingController)
|
|
149
153
|
|
|
150
154
|
// Inject WebViewModel for safe area handling
|
|
151
155
|
Task { @MainActor in
|
|
@@ -156,7 +160,9 @@ public struct WebView: UIViewRepresentable, Equatable {
|
|
|
156
160
|
bridge.setNotificationHandler(NotificationHandlerProvider.shared)
|
|
157
161
|
|
|
158
162
|
bridge.register()
|
|
163
|
+
pluginBridge.register()
|
|
159
164
|
self.nativeBridge = bridge
|
|
165
|
+
self.pluginBridge = pluginBridge
|
|
160
166
|
}
|
|
161
167
|
|
|
162
168
|
override public func observeValue(forKeyPath keyPath: String?,
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
E9F699E02EE065B0005E972E /* NotificationHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F699DF2EE065B0005E972E /* NotificationHandlerTests.swift */; };
|
|
24
24
|
E9F699E22EE06650005E972E /* FrameworkServerUtilsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F699E12EE06650005E972E /* FrameworkServerUtilsTests.swift */; };
|
|
25
25
|
E9F699E42EE0696A005E972E /* BootTimingUtilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F699E32EE0696A005E972E /* BootTimingUtilityTests.swift */; };
|
|
26
|
+
F2B100012F11111100AAA001 /* PluginBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B100022F11111100AAA001 /* PluginBridgeTests.swift */; };
|
|
26
27
|
F1234AC22E990180008C7F58 /* localhost.p12 in Resources */ = {isa = PBXBuildFile; fileRef = F1234AC32E990180008C7F58 /* localhost.p12 */; };
|
|
27
28
|
/* End PBXBuildFile section */
|
|
28
29
|
|
|
@@ -66,6 +67,7 @@
|
|
|
66
67
|
E9F699DF2EE065B0005E972E /* NotificationHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationHandlerTests.swift; sourceTree = "<group>"; };
|
|
67
68
|
E9F699E12EE06650005E972E /* FrameworkServerUtilsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameworkServerUtilsTests.swift; sourceTree = "<group>"; };
|
|
68
69
|
E9F699E32EE0696A005E972E /* BootTimingUtilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BootTimingUtilityTests.swift; sourceTree = "<group>"; };
|
|
70
|
+
F2B100022F11111100AAA001 /* PluginBridgeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginBridgeTests.swift; sourceTree = "<group>"; };
|
|
69
71
|
F1234AC32E990180008C7F58 /* localhost.p12 */ = {isa = PBXFileReference; lastKnownFileType = file; path = localhost.p12; sourceTree = "<group>"; };
|
|
70
72
|
XCCONFIG001000000001 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
|
|
71
73
|
XCCONFIG001000000002 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
|
|
@@ -152,6 +154,7 @@
|
|
|
152
154
|
E9F699DB2EE06482005E972E /* CacheManagerTests.swift */,
|
|
153
155
|
E9F699D92EE063D5005E972E /* WebViewTests.swift */,
|
|
154
156
|
E9F699D72EE06068005E972E /* NativeBridgeTests.swift */,
|
|
157
|
+
F2B100022F11111100AAA001 /* PluginBridgeTests.swift */,
|
|
155
158
|
E9F699D52EE05D74005E972E /* BridgeMessageValidatorTests.swift */,
|
|
156
159
|
E9F699D32EE05984005E972E /* ConfigMappingTests.swift */,
|
|
157
160
|
E9F699D02EDEFEF2005E972E /* URLWhitelistManagerTests.swift */,
|
|
@@ -321,6 +324,7 @@
|
|
|
321
324
|
E9F699DC2EE06483005E972E /* CacheManagerTests.swift in Sources */,
|
|
322
325
|
E9F699D42EE05984005E972E /* ConfigMappingTests.swift in Sources */,
|
|
323
326
|
E9F699D82EE06068005E972E /* NativeBridgeTests.swift in Sources */,
|
|
327
|
+
F2B100012F11111100AAA001 /* PluginBridgeTests.swift in Sources */,
|
|
324
328
|
E9F699DA2EE063D5005E972E /* WebViewTests.swift in Sources */,
|
|
325
329
|
E9F699E42EE0696A005E972E /* BootTimingUtilityTests.swift in Sources */,
|
|
326
330
|
E9F699E22EE06650005E972E /* FrameworkServerUtilsTests.swift in Sources */,
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
import WebKit
|
|
3
|
+
import UIKit
|
|
4
|
+
@testable import CatalystCore
|
|
5
|
+
|
|
6
|
+
final class PluginBridgeTests: XCTestCase {
|
|
7
|
+
|
|
8
|
+
private var bridge: PluginBridge!
|
|
9
|
+
private var mockWebView: PluginMockWKWebView!
|
|
10
|
+
private var mockViewController: UIViewController!
|
|
11
|
+
private var testExpectation: XCTestExpectation!
|
|
12
|
+
|
|
13
|
+
@MainActor
|
|
14
|
+
override func setUp() {
|
|
15
|
+
super.setUp()
|
|
16
|
+
mockWebView = PluginMockWKWebView()
|
|
17
|
+
mockViewController = UIViewController()
|
|
18
|
+
bridge = PluginBridge(webView: mockWebView, viewController: mockViewController)
|
|
19
|
+
bridge.register()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@MainActor
|
|
23
|
+
override func tearDown() {
|
|
24
|
+
bridge.unregister()
|
|
25
|
+
bridge = nil
|
|
26
|
+
mockWebView = nil
|
|
27
|
+
mockViewController = nil
|
|
28
|
+
testExpectation = nil
|
|
29
|
+
super.tearDown()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@MainActor
|
|
33
|
+
func testMessageHandling_InvalidHandler_EmitsInvalidPayloadError() async {
|
|
34
|
+
testExpectation = expectation(description: "Invalid handler should emit bridge error")
|
|
35
|
+
mockWebView.onEvaluateJavaScript = { script in
|
|
36
|
+
if script.contains("PLUGIN_BRIDGE_ERROR") && script.contains("INVALID_PAYLOAD") {
|
|
37
|
+
self.testExpectation.fulfill()
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let message = PluginMockScriptMessage(
|
|
42
|
+
name: "NativeBridge",
|
|
43
|
+
body: [
|
|
44
|
+
"pluginId": "device-info-plugin",
|
|
45
|
+
"command": "getDeviceInfo"
|
|
46
|
+
]
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
bridge.handleMessage(message)
|
|
50
|
+
|
|
51
|
+
await fulfillment(of: [testExpectation], timeout: 2.0)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@MainActor
|
|
55
|
+
func testMessageHandling_UnknownPlugin_EmitsPluginNotFoundError() async {
|
|
56
|
+
testExpectation = expectation(description: "Unknown plugin should emit plugin not found error")
|
|
57
|
+
mockWebView.onEvaluateJavaScript = { script in
|
|
58
|
+
if script.contains("PLUGIN_BRIDGE_ERROR") && script.contains("PLUGIN_NOT_FOUND") {
|
|
59
|
+
self.testExpectation.fulfill()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let message = PluginMockScriptMessage(
|
|
64
|
+
name: "PluginBridge",
|
|
65
|
+
body: [
|
|
66
|
+
"pluginId": "missing-plugin",
|
|
67
|
+
"command": "ping",
|
|
68
|
+
"requestId": "req-42"
|
|
69
|
+
]
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
bridge.handleMessage(message)
|
|
73
|
+
|
|
74
|
+
await fulfillment(of: [testExpectation], timeout: 2.0)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@MainActor
|
|
78
|
+
func testCallbackHandling_ArbitraryCallback_DispatchesPluginEnvelope() async {
|
|
79
|
+
testExpectation = expectation(description: "Arbitrary callback should dispatch plugin envelope")
|
|
80
|
+
mockWebView.onEvaluateJavaScript = { script in
|
|
81
|
+
if script.contains("PluginBridgeWeb.dispatch") &&
|
|
82
|
+
script.contains("sync-plugin") &&
|
|
83
|
+
script.contains("ON_FAILURE") &&
|
|
84
|
+
script.contains("req-9") &&
|
|
85
|
+
script.contains("syncData") {
|
|
86
|
+
self.testExpectation.fulfill()
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let context = PluginBridgeContext(
|
|
91
|
+
webView: mockWebView,
|
|
92
|
+
viewController: mockViewController,
|
|
93
|
+
pluginId: "sync-plugin",
|
|
94
|
+
command: "syncData",
|
|
95
|
+
requestId: "req-9"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
context.callback(eventName: "ON_FAILURE", data: ["reason": "network"])
|
|
99
|
+
|
|
100
|
+
await fulfillment(of: [testExpectation], timeout: 2.0)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@MainActor
|
|
104
|
+
func testCallbackHandling_ValidCallback_DispatchesPluginEnvelope() async {
|
|
105
|
+
testExpectation = expectation(description: "Valid callback should dispatch plugin envelope")
|
|
106
|
+
mockWebView.onEvaluateJavaScript = { script in
|
|
107
|
+
if script.contains("PluginBridgeWeb.dispatch") &&
|
|
108
|
+
script.contains("sync-plugin") &&
|
|
109
|
+
script.contains("ON_SUCCESS") &&
|
|
110
|
+
script.contains("req-10") {
|
|
111
|
+
self.testExpectation.fulfill()
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let context = PluginBridgeContext(
|
|
116
|
+
webView: mockWebView,
|
|
117
|
+
viewController: mockViewController,
|
|
118
|
+
pluginId: "sync-plugin",
|
|
119
|
+
command: "syncData",
|
|
120
|
+
requestId: "req-10"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
context.callback(eventName: "ON_SUCCESS", data: ["status": "ok"])
|
|
124
|
+
|
|
125
|
+
await fulfillment(of: [testExpectation], timeout: 2.0)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private final class PluginMockWKWebView: WKWebView {
|
|
130
|
+
var onEvaluateJavaScript: ((String) -> Void)?
|
|
131
|
+
var evaluatedScripts: [String] = []
|
|
132
|
+
|
|
133
|
+
override init(frame: CGRect, configuration: WKWebViewConfiguration) {
|
|
134
|
+
super.init(frame: frame, configuration: configuration)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
convenience init() {
|
|
138
|
+
let config = WKWebViewConfiguration()
|
|
139
|
+
self.init(frame: .zero, configuration: config)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
required init?(coder: NSCoder) {
|
|
143
|
+
fatalError("init(coder:) has not been implemented")
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@MainActor
|
|
147
|
+
override func evaluateJavaScript(_ javaScriptString: String, completionHandler: ((Any?, Error?) -> Void)? = nil) {
|
|
148
|
+
evaluatedScripts.append(javaScriptString)
|
|
149
|
+
onEvaluateJavaScript?(javaScriptString)
|
|
150
|
+
|
|
151
|
+
DispatchQueue.main.async {
|
|
152
|
+
completionHandler?(nil, nil)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private struct PluginMockScriptMessage: PluginBridgeMessage {
|
|
158
|
+
let name: String
|
|
159
|
+
let body: Any
|
|
160
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.default=void 0;class PluginNativeBridge{constructor(){this.handlers=new Map();this.requestCount=0;this.isInitialized=false;}createRequestId=()=>{this.requestCount+=1;return`plugin_${Date.now()}_${this.requestCount}`;};normalizeRequiredString=(value,fieldName)=>{if(typeof value!=="string"||!value.trim()){throw new Error(`${fieldName} must be a non-empty string`);}return value.trim();};normalizeCommand=command=>{if(command==null){return null;}return this.normalizeRequiredString(command,"command");};getHandlerKey=({pluginId,eventName,command=null}={})=>{return JSON.stringify([this.normalizeRequiredString(pluginId,"pluginId"),this.normalizeRequiredString(eventName,"eventName"),this.normalizeCommand(command)]);};getHandlerSet=(key,create=false)=>{let handlers=this.handlers.get(key);if(!handlers&&create){handlers=new Set();this.handlers.set(key,handlers);}return handlers||null;};addHandler=({pluginId,eventName,command}={},handler)=>{if(typeof handler!=="function"){throw new Error("handler must be a function");}this.getHandlerSet(this.getHandlerKey({pluginId,eventName,command}),true).add(handler);};removeHandler=({pluginId,eventName,command}={},handler)=>{const key=this.getHandlerKey({pluginId,eventName,command});const handlers=this.getHandlerSet(key,false);if(!handlers){return false;}if(handler==null){handlers.clear();this.handlers.delete(key);return true;}const removed=handlers.delete(handler);if(handlers.size===0){this.handlers.delete(key);}return removed;};ensureInitialized=()=>{if(typeof window==="undefined"||this.isInitialized){return this;}window.PluginBridgeWeb=window.PluginBridgeWeb||{};window.PluginBridgeWeb.callback=this.dispatchCallback;window.PluginBridgeWeb.dispatch=message=>{let parsed=message;if(typeof parsed==="string"){try{parsed=JSON.parse(parsed);}catch{return false;}}if(!parsed||typeof parsed!=="object"){return false;}const payload=Object.prototype.hasOwnProperty.call(parsed,"payload")?parsed.payload:null;return this.dispatchCallback(parsed.pluginId,parsed.eventName,payload,parsed.requestId??null,parsed.command??null);};this.isInitialized=true;return this;};hasAndroidBridge=()=>typeof window!=="undefined"&&!!window.PluginBridge;hasIOSBridge=()=>typeof window!=="undefined"&&!!window.webkit?.messageHandlers?.PluginBridge;init=()=>this.ensureInitialized();assertInitialized=()=>{if(!this.isInitialized){throw new Error("PluginBridge.init() must be called before using emit() or register()");}};reportHandlerError=error=>{if(typeof queueMicrotask==="function"){queueMicrotask(()=>{throw error;});return;}setTimeout(()=>{throw error;},0);};emit=({pluginId,command,data=null}={})=>{const normalizedPluginId=this.normalizeRequiredString(pluginId,"pluginId");const normalizedCommand=this.normalizeRequiredString(command,"command");if(typeof window==="undefined"){throw new Error("PluginBridge is not available in this environment");}this.assertInitialized();const payload={pluginId:normalizedPluginId,command:normalizedCommand,data,requestId:this.createRequestId()};if(this.hasAndroidBridge()){window.PluginBridge.emit(JSON.stringify(payload));return;}if(this.hasIOSBridge()){window.webkit.messageHandlers.PluginBridge.postMessage(payload);return;}throw new Error("PluginBridge is not available in this environment");};register=({pluginId,eventName,command,handler}={})=>{this.assertInitialized();this.addHandler({pluginId,eventName,command},handler);return()=>this.removeHandler({pluginId,eventName,command},handler);};unregister=({pluginId,eventName,command,handler}={})=>{if(typeof handler!=="function"){throw new Error("handler must be a function");}return this.removeHandler({pluginId,eventName,command},handler);};dispatchCallback=(pluginId,eventName,payload,requestId=null,command=null)=>{void requestId;const normalizedPluginId=typeof pluginId==="string"&&pluginId.trim()?pluginId.trim():null;const normalizedEventName=typeof eventName==="string"&&eventName.trim()?eventName.trim():null;if(normalizedPluginId==null||normalizedEventName==null){return false;}const normalizedCommand=typeof command==="string"&&command.trim()?command.trim():null;const toCall=new Set();const collectHandlers=commandScope=>{const handlers=this.getHandlerSet(JSON.stringify([normalizedPluginId,normalizedEventName,commandScope]),false);if(!handlers){return;}handlers.forEach(handler=>{toCall.add(handler);});};if(normalizedCommand!=null){collectHandlers(normalizedCommand);}collectHandlers(null);if(toCall.size===0){return false;}const meta={pluginId:normalizedPluginId,eventName:normalizedEventName,command:normalizedCommand};toCall.forEach(handler=>{try{handler(payload,meta);}catch(error){this.reportHandlerError(error);}});return true;};}const pluginNativeBridge=new PluginNativeBridge();var _default=exports.default=pluginNativeBridge;
|