catalyst-core-internal 0.1.2 → 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 +3 -11
- 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/SecurityBridgeTest.kt +199 -0
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/plugins/PluginBridgeTest.kt +121 -0
- package/dist/native/bridge/hooks.js +4 -4
- package/dist/native/bridge/useBaseHook.js +5 -4
- package/dist/native/bridge/utils/NativeBridge.js +4 -4
- 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/Utils/CacheManager.swift +13 -2
- 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/iosnativeWebView.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +36 -0
- package/dist/native/iosnativeWebView/iosnativeWebView.xctestplan +1 -0
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/BridgeCommandHandlerSecurityTests.swift +212 -0
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/FrameworkServerUtilsTests.swift +14 -4
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/PluginBridgeTests.swift +160 -0
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/ScreenSecureManagerTests.swift +121 -0
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/WebViewTests.swift +9 -21
- 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
- package/mcp_v2/conversion-tasks.json +0 -371
- package/mcp_v2/knowledge-base.json +0 -1450
- package/mcp_v2/lib/helpers.js +0 -145
- package/mcp_v2/mcp.js +0 -366
- package/mcp_v2/package.json +0 -13
- package/mcp_v2/schema.sql +0 -88
- package/mcp_v2/setup.js +0 -262
- package/mcp_v2/tools/build.js +0 -449
- package/mcp_v2/tools/config.js +0 -262
- package/mcp_v2/tools/conversion.js +0 -492
- package/mcp_v2/tools/debug.js +0 -62
- package/mcp_v2/tools/knowledge.js +0 -213
- package/mcp_v2/tools/sync.js +0 -21
- package/mcp_v2/tools/tasks.js +0 -844
package/dist/native/iosnativeWebView/iosnativeWebViewTests/BridgeCommandHandlerSecurityTests.swift
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
import WebKit
|
|
3
|
+
@testable import CatalystCore
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Unit tests for BridgeCommandHandler — security commands.
|
|
7
|
+
*
|
|
8
|
+
* Coverage:
|
|
9
|
+
* 1. setScreenSecure routing (3 tests)
|
|
10
|
+
* 2. getScreenSecure routing (2 tests)
|
|
11
|
+
* 3. clearWebData routing (3 tests)
|
|
12
|
+
*
|
|
13
|
+
* Total: 8 tests
|
|
14
|
+
*
|
|
15
|
+
* Each test drives the full bridge pipeline (NativeBridge → BridgeCommandHandler)
|
|
16
|
+
* via a MockWKWebView and captures the evaluateJavaScript call that the delegate
|
|
17
|
+
* would fire back to the web layer.
|
|
18
|
+
*
|
|
19
|
+
* MockWKWebView and MockWKScriptMessage are defined in NativeBridgeTests.swift
|
|
20
|
+
* and BridgeMessageValidatorTests.swift respectively (shared across the test target).
|
|
21
|
+
*/
|
|
22
|
+
final class BridgeCommandHandlerSecurityTests: XCTestCase {
|
|
23
|
+
|
|
24
|
+
var bridge: NativeBridge!
|
|
25
|
+
var mockWebView: MockWKWebView!
|
|
26
|
+
var mockViewController: UIViewController!
|
|
27
|
+
|
|
28
|
+
override func setUp() {
|
|
29
|
+
super.setUp()
|
|
30
|
+
mockWebView = MockWKWebView()
|
|
31
|
+
mockViewController = UIViewController()
|
|
32
|
+
bridge = NativeBridge(webView: mockWebView, viewController: mockViewController)
|
|
33
|
+
bridge.register()
|
|
34
|
+
// Ensure a clean screen-secure state before each test
|
|
35
|
+
ScreenSecureManager.shared.setScreenSecure(false)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
override func tearDown() {
|
|
39
|
+
bridge.unregister()
|
|
40
|
+
bridge = nil
|
|
41
|
+
mockWebView = nil
|
|
42
|
+
mockViewController = nil
|
|
43
|
+
ScreenSecureManager.shared.setScreenSecure(false)
|
|
44
|
+
super.tearDown()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ============================================================
|
|
48
|
+
// CATEGORY 1: setScreenSecure routing (3 tests)
|
|
49
|
+
// ============================================================
|
|
50
|
+
|
|
51
|
+
func testSetScreenSecure_Enable_FiresOnScreenSecureSetCallback() {
|
|
52
|
+
let exp = expectation(description: "ON_SCREEN_SECURE_SET callback fired")
|
|
53
|
+
|
|
54
|
+
mockWebView.onEvaluateJavaScript = { script in
|
|
55
|
+
if script.contains("ON_SCREEN_SECURE_SET") {
|
|
56
|
+
exp.fulfill()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let message = createMessage(command: "setScreenSecure", data: ["enable": true])
|
|
61
|
+
bridge.userContentController(mockWebView.configuration.userContentController,
|
|
62
|
+
didReceive: message)
|
|
63
|
+
|
|
64
|
+
wait(for: [exp], timeout: 2.0)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func testSetScreenSecure_Enable_CallbackContainsSecureTrue() {
|
|
68
|
+
let exp = expectation(description: "ON_SCREEN_SECURE_SET payload has secure:true")
|
|
69
|
+
|
|
70
|
+
mockWebView.onEvaluateJavaScript = { script in
|
|
71
|
+
if script.contains("ON_SCREEN_SECURE_SET") {
|
|
72
|
+
XCTAssertTrue(
|
|
73
|
+
script.contains("\"secure\"") || script.contains("secure"),
|
|
74
|
+
"Callback should include secure field"
|
|
75
|
+
)
|
|
76
|
+
XCTAssertTrue(
|
|
77
|
+
script.contains("true"),
|
|
78
|
+
"secure should be true after enabling"
|
|
79
|
+
)
|
|
80
|
+
exp.fulfill()
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let message = createMessage(command: "setScreenSecure", data: ["enable": true])
|
|
85
|
+
bridge.userContentController(mockWebView.configuration.userContentController,
|
|
86
|
+
didReceive: message)
|
|
87
|
+
|
|
88
|
+
wait(for: [exp], timeout: 2.0)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
func testSetScreenSecure_InvalidParams_FiresErrorCallback() {
|
|
92
|
+
// Passing a non-dict, non-string param is handled by the fallback in
|
|
93
|
+
// BridgeCommandHandler.setScreenSecure — it fires ON_SCREEN_SECURE_ERROR.
|
|
94
|
+
let exp = expectation(description: "ON_SCREEN_SECURE_ERROR fired for nil params")
|
|
95
|
+
|
|
96
|
+
mockWebView.onEvaluateJavaScript = { script in
|
|
97
|
+
if script.contains("ON_SCREEN_SECURE_ERROR") {
|
|
98
|
+
exp.fulfill()
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Route via bridge with data that contains no "enable" key at all
|
|
103
|
+
let message = createMessage(command: "setScreenSecure", data: [:])
|
|
104
|
+
bridge.userContentController(mockWebView.configuration.userContentController,
|
|
105
|
+
didReceive: message)
|
|
106
|
+
|
|
107
|
+
// The empty dict triggers the else branch → ON_SCREEN_SECURE_ERROR
|
|
108
|
+
wait(for: [exp], timeout: 2.0)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ============================================================
|
|
112
|
+
// CATEGORY 2: getScreenSecure routing (2 tests)
|
|
113
|
+
// ============================================================
|
|
114
|
+
|
|
115
|
+
func testGetScreenSecure_FiresOnScreenSecureStatusCallback() {
|
|
116
|
+
let exp = expectation(description: "ON_SCREEN_SECURE_STATUS callback fired")
|
|
117
|
+
|
|
118
|
+
mockWebView.onEvaluateJavaScript = { script in
|
|
119
|
+
if script.contains("ON_SCREEN_SECURE_STATUS") {
|
|
120
|
+
exp.fulfill()
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let message = createMessage(command: "getScreenSecure", data: [:])
|
|
125
|
+
bridge.userContentController(mockWebView.configuration.userContentController,
|
|
126
|
+
didReceive: message)
|
|
127
|
+
|
|
128
|
+
wait(for: [exp], timeout: 2.0)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
func testGetScreenSecure_ReflectsCurrentState() {
|
|
132
|
+
// Set state first, then query it via the bridge
|
|
133
|
+
ScreenSecureManager.shared.setScreenSecure(true)
|
|
134
|
+
|
|
135
|
+
let exp = expectation(description: "ON_SCREEN_SECURE_STATUS reflects secure:true")
|
|
136
|
+
|
|
137
|
+
mockWebView.onEvaluateJavaScript = { script in
|
|
138
|
+
if script.contains("ON_SCREEN_SECURE_STATUS") {
|
|
139
|
+
XCTAssertTrue(script.contains("true"),
|
|
140
|
+
"Status callback should reflect secure=true")
|
|
141
|
+
exp.fulfill()
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let message = createMessage(command: "getScreenSecure", data: [:])
|
|
146
|
+
bridge.userContentController(mockWebView.configuration.userContentController,
|
|
147
|
+
didReceive: message)
|
|
148
|
+
|
|
149
|
+
wait(for: [exp], timeout: 2.0)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ============================================================
|
|
153
|
+
// CATEGORY 3: clearWebData routing (3 tests)
|
|
154
|
+
// ============================================================
|
|
155
|
+
|
|
156
|
+
func testClearWebData_WhenWebViewPresent_FiresOnWebDataClearedCallback() {
|
|
157
|
+
// BridgeCommandHandler needs a webView injected to proceed past the guard
|
|
158
|
+
// NativeBridge injects it during register(), so the mock is already set.
|
|
159
|
+
let exp = expectation(description: "ON_WEB_DATA_CLEARED callback fired")
|
|
160
|
+
|
|
161
|
+
mockWebView.onEvaluateJavaScript = { script in
|
|
162
|
+
if script.contains("ON_WEB_DATA_CLEARED") {
|
|
163
|
+
exp.fulfill()
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let message = createMessage(command: "clearWebData", data: [:])
|
|
168
|
+
bridge.userContentController(mockWebView.configuration.userContentController,
|
|
169
|
+
didReceive: message)
|
|
170
|
+
|
|
171
|
+
// clearWebData is async (WKWebsiteDataStore removal) — allow extra time
|
|
172
|
+
wait(for: [exp], timeout: 5.0)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
func testClearWebData_SuccessPayloadContainsSuccessTrue() {
|
|
176
|
+
let exp = expectation(description: "clearWebData success payload has success:true")
|
|
177
|
+
|
|
178
|
+
mockWebView.onEvaluateJavaScript = { script in
|
|
179
|
+
if script.contains("ON_WEB_DATA_CLEARED") {
|
|
180
|
+
XCTAssertTrue(script.contains("true"),
|
|
181
|
+
"success field should be true in cleared callback")
|
|
182
|
+
exp.fulfill()
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let message = createMessage(command: "clearWebData", data: [:])
|
|
187
|
+
bridge.userContentController(mockWebView.configuration.userContentController,
|
|
188
|
+
didReceive: message)
|
|
189
|
+
|
|
190
|
+
wait(for: [exp], timeout: 5.0)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
func testClearWebData_CommandAcceptedByValidator() {
|
|
194
|
+
// Validates command reaches the handler without being rejected by BridgeMessageValidator
|
|
195
|
+
let messageBody: [String: Any] = ["command": "clearWebData", "data": [:]]
|
|
196
|
+
let message = MockWKScriptMessage(name: "NativeBridge", body: messageBody)
|
|
197
|
+
let result = BridgeMessageValidator.validate(message: message)
|
|
198
|
+
|
|
199
|
+
XCTAssertTrue(result.isValid, "clearWebData should pass BridgeMessageValidator")
|
|
200
|
+
XCTAssertEqual(result.command, "clearWebData")
|
|
201
|
+
XCTAssertNil(result.error)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ============================================================
|
|
205
|
+
// Helpers
|
|
206
|
+
// ============================================================
|
|
207
|
+
|
|
208
|
+
private func createMessage(command: String, data: [String: Any]) -> WKScriptMessage {
|
|
209
|
+
let body: [String: Any] = ["command": command, "data": data]
|
|
210
|
+
return MockWKScriptMessage(name: "NativeBridge", body: body)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -47,12 +47,16 @@ final class FrameworkServerUtilsTests: XCTestCase {
|
|
|
47
47
|
// CATEGORY 1: HTTPS Server Setup (2 tests)
|
|
48
48
|
// ========================================
|
|
49
49
|
|
|
50
|
-
func testHTTPSServerSetup_ServerInitialization() {
|
|
50
|
+
func testHTTPSServerSetup_ServerInitialization() async {
|
|
51
51
|
// Test server initialization
|
|
52
52
|
|
|
53
53
|
// Note: Server may fail to start in test environment due to network restrictions
|
|
54
|
-
// This test verifies the API works without crashing
|
|
55
|
-
|
|
54
|
+
// This test verifies the API works without crashing.
|
|
55
|
+
// Run startServer() off the main thread to avoid UI-responsiveness warnings
|
|
56
|
+
// (NWListener setup must not block the main thread).
|
|
57
|
+
let started = await Task.detached(priority: .userInitiated) {
|
|
58
|
+
self.frameworkServer.startServer()
|
|
59
|
+
}.value
|
|
56
60
|
|
|
57
61
|
// If server starts successfully, verify state
|
|
58
62
|
if started {
|
|
@@ -143,8 +147,14 @@ final class FrameworkServerUtilsTests: XCTestCase {
|
|
|
143
147
|
// Start server first
|
|
144
148
|
let started = frameworkServer.startServer()
|
|
145
149
|
|
|
146
|
-
//
|
|
150
|
+
// Wait briefly for NWListener stateUpdateHandler to fire (it's async on a background queue).
|
|
151
|
+
// The listener can flip isServerRunning back to false if it fails after startServer() returns.
|
|
147
152
|
if started {
|
|
153
|
+
Thread.sleep(forTimeInterval: 0.3)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Only test file serving if server is still running after NWListener settled
|
|
157
|
+
if started && frameworkServer.isRunning() {
|
|
148
158
|
// Create a temporary test file
|
|
149
159
|
let testData = "Test file content".data(using: .utf8)!
|
|
150
160
|
let tempURL = FileManager.default.temporaryDirectory
|
|
@@ -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,121 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
@testable import CatalystCore
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Unit tests for ScreenSecureManager
|
|
6
|
+
*
|
|
7
|
+
* Coverage:
|
|
8
|
+
* 1. State management (3 tests)
|
|
9
|
+
* 2. setScreenSecure behaviour (3 tests)
|
|
10
|
+
* 3. Overlay race condition guard (2 tests)
|
|
11
|
+
*
|
|
12
|
+
* Total: 8 tests
|
|
13
|
+
*
|
|
14
|
+
* Note: UIWindow creation and scene observation require a running app / UI host.
|
|
15
|
+
* Tests here focus on the state machine and guard logic that can be exercised
|
|
16
|
+
* without a UIWindowScene. Overlay installation side-effects are verified
|
|
17
|
+
* indirectly via the guard flags and isScreenSecure state.
|
|
18
|
+
*/
|
|
19
|
+
final class ScreenSecureManagerTests: XCTestCase {
|
|
20
|
+
|
|
21
|
+
var manager: ScreenSecureManager!
|
|
22
|
+
|
|
23
|
+
override func setUp() {
|
|
24
|
+
super.setUp()
|
|
25
|
+
// Use the shared singleton; reset state by disabling screen security
|
|
26
|
+
manager = ScreenSecureManager.shared
|
|
27
|
+
manager.setScreenSecure(false)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
override func tearDown() {
|
|
31
|
+
// Leave the singleton clean for other tests
|
|
32
|
+
manager.setScreenSecure(false)
|
|
33
|
+
super.tearDown()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ============================================================
|
|
37
|
+
// CATEGORY 1: State management (3 tests)
|
|
38
|
+
// ============================================================
|
|
39
|
+
|
|
40
|
+
func testInitialState_IsNotSecure() {
|
|
41
|
+
// After setUp calls setScreenSecure(false), isScreenSecure must be false
|
|
42
|
+
XCTAssertFalse(manager.isScreenSecure, "Initial state should not be secure")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func testSetScreenSecure_True_UpdatesState() {
|
|
46
|
+
manager.setScreenSecure(true)
|
|
47
|
+
|
|
48
|
+
XCTAssertTrue(manager.isScreenSecure, "isScreenSecure should be true after enabling")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func testSetScreenSecure_False_UpdatesState() {
|
|
52
|
+
manager.setScreenSecure(true)
|
|
53
|
+
manager.setScreenSecure(false)
|
|
54
|
+
|
|
55
|
+
XCTAssertFalse(manager.isScreenSecure, "isScreenSecure should be false after disabling")
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ============================================================
|
|
59
|
+
// CATEGORY 2: setScreenSecure behaviour (3 tests)
|
|
60
|
+
// ============================================================
|
|
61
|
+
|
|
62
|
+
func testSetScreenSecure_EnableThenDisable_StateIsConsistent() {
|
|
63
|
+
manager.setScreenSecure(true)
|
|
64
|
+
XCTAssertTrue(manager.isScreenSecure)
|
|
65
|
+
|
|
66
|
+
manager.setScreenSecure(false)
|
|
67
|
+
XCTAssertFalse(manager.isScreenSecure)
|
|
68
|
+
|
|
69
|
+
// Re-enable
|
|
70
|
+
manager.setScreenSecure(true)
|
|
71
|
+
XCTAssertTrue(manager.isScreenSecure)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func testSetScreenSecure_DisableWhenAlreadyDisabled_NoStateChange() {
|
|
75
|
+
// Should be a no-op and not throw
|
|
76
|
+
manager.setScreenSecure(false)
|
|
77
|
+
manager.setScreenSecure(false)
|
|
78
|
+
|
|
79
|
+
XCTAssertFalse(manager.isScreenSecure, "Repeated disable should leave state as false")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func testSetScreenSecure_EnableWhenAlreadyEnabled_NoStateChange() {
|
|
83
|
+
manager.setScreenSecure(true)
|
|
84
|
+
manager.setScreenSecure(true)
|
|
85
|
+
|
|
86
|
+
XCTAssertTrue(manager.isScreenSecure, "Repeated enable should leave state as true")
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ============================================================
|
|
90
|
+
// CATEGORY 3: Overlay race condition guard (2 tests)
|
|
91
|
+
// ============================================================
|
|
92
|
+
|
|
93
|
+
func testSceneWillDeactivate_WhenNotSecure_OverlayNotInstalled() {
|
|
94
|
+
// When isScreenSecure is false, sceneWillDeactivate must be a no-op.
|
|
95
|
+
// We verify by checking state is still false after the notification fires.
|
|
96
|
+
manager.setScreenSecure(false)
|
|
97
|
+
|
|
98
|
+
NotificationCenter.default.post(
|
|
99
|
+
name: UIScene.willDeactivateNotification,
|
|
100
|
+
object: nil
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
// State must not have changed
|
|
104
|
+
XCTAssertFalse(manager.isScreenSecure,
|
|
105
|
+
"State should remain false when secure mode is off")
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func testSceneDidActivate_AfterEnable_StateUnchanged() {
|
|
109
|
+
// sceneDidActivate removes the overlay but does NOT reset isScreenSecure.
|
|
110
|
+
manager.setScreenSecure(true)
|
|
111
|
+
|
|
112
|
+
NotificationCenter.default.post(
|
|
113
|
+
name: UIScene.didActivateNotification,
|
|
114
|
+
object: nil
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
// The secure flag must survive a return-to-foreground event
|
|
118
|
+
XCTAssertTrue(manager.isScreenSecure,
|
|
119
|
+
"isScreenSecure should remain true after scene reactivation")
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -38,7 +38,7 @@ final class WebViewTests: XCTestCase {
|
|
|
38
38
|
viewModel = WebViewModel()
|
|
39
39
|
|
|
40
40
|
// Create navigation delegate
|
|
41
|
-
navigationDelegate = WebViewNavigationDelegate(viewModel: viewModel)
|
|
41
|
+
navigationDelegate = WebViewNavigationDelegate(viewModel: viewModel, initialURL: nil)
|
|
42
42
|
|
|
43
43
|
// Create a basic WKWebView for testing
|
|
44
44
|
let config = WKWebViewConfiguration()
|
|
@@ -124,7 +124,7 @@ final class WebViewTests: XCTestCase {
|
|
|
124
124
|
// ========================================
|
|
125
125
|
|
|
126
126
|
@MainActor
|
|
127
|
-
func testNavigationHandling_AllowedURLLoading() {
|
|
127
|
+
func testNavigationHandling_AllowedURLLoading() async {
|
|
128
128
|
// Test that allowed URLs are permitted for navigation
|
|
129
129
|
|
|
130
130
|
let allowedURL = URL(string: "https://example.com")!
|
|
@@ -138,29 +138,23 @@ final class WebViewTests: XCTestCase {
|
|
|
138
138
|
#endif
|
|
139
139
|
|
|
140
140
|
let navigationAction = createNavigationAction(url: allowedURL)
|
|
141
|
-
|
|
141
|
+
let expectation = XCTestExpectation(description: "Navigation decision received")
|
|
142
142
|
var allowedDecision = false
|
|
143
143
|
|
|
144
144
|
navigationDelegate.webView(mockWebView,
|
|
145
145
|
decidePolicyFor: navigationAction) { policy in
|
|
146
|
-
decisionReceived = true
|
|
147
146
|
allowedDecision = (policy == .allow)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Wait briefly for async decision
|
|
151
|
-
let expectation = XCTestExpectation(description: "Navigation decision")
|
|
152
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
153
147
|
expectation.fulfill()
|
|
154
148
|
}
|
|
155
|
-
wait(for: [expectation], timeout: 1.0)
|
|
156
149
|
|
|
157
|
-
|
|
150
|
+
await fulfillment(of: [expectation], timeout: 2.0)
|
|
151
|
+
|
|
158
152
|
XCTAssertTrue(allowedDecision || !URLWhitelistManager.shared.isAccessControlEnabled,
|
|
159
153
|
"Allowed URL should be permitted")
|
|
160
154
|
}
|
|
161
155
|
|
|
162
156
|
@MainActor
|
|
163
|
-
func testNavigationHandling_BlockedURLNavigation() {
|
|
157
|
+
func testNavigationHandling_BlockedURLNavigation() async {
|
|
164
158
|
// Test that blocked URLs are rejected
|
|
165
159
|
|
|
166
160
|
let blockedURL = URL(string: "https://blocked.com")!
|
|
@@ -174,23 +168,17 @@ final class WebViewTests: XCTestCase {
|
|
|
174
168
|
#endif
|
|
175
169
|
|
|
176
170
|
let navigationAction = createNavigationAction(url: blockedURL)
|
|
177
|
-
|
|
171
|
+
let expectation = XCTestExpectation(description: "Navigation decision received")
|
|
178
172
|
var blockedDecision = false
|
|
179
173
|
|
|
180
174
|
navigationDelegate.webView(mockWebView,
|
|
181
175
|
decidePolicyFor: navigationAction) { policy in
|
|
182
|
-
decisionReceived = true
|
|
183
176
|
blockedDecision = (policy == .cancel)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Wait briefly for async decision
|
|
187
|
-
let expectation = XCTestExpectation(description: "Navigation decision")
|
|
188
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
189
177
|
expectation.fulfill()
|
|
190
178
|
}
|
|
191
|
-
wait(for: [expectation], timeout: 1.0)
|
|
192
179
|
|
|
193
|
-
|
|
180
|
+
await fulfillment(of: [expectation], timeout: 2.0)
|
|
181
|
+
|
|
194
182
|
XCTAssertTrue(blockedDecision, "Blocked URL should be cancelled")
|
|
195
183
|
}
|
|
196
184
|
|
|
@@ -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;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";const fs=require("fs");const path=require("path");const{discoverInternalPlugins}=require("./internalPluginUtils.js");const PLUGINS_PACKAGE_PARTS=["io","yourname","androidproject","plugins"];function isDir(dirPath){return fs.existsSync(dirPath)&&fs.statSync(dirPath).isDirectory();}function ensureDir(dirPath){if(!fs.existsSync(dirPath)){fs.mkdirSync(dirPath,{recursive:true});}}function sanitizeForPath(value){return value.replace(/[^a-zA-Z0-9_-]/g,"_");}function mustBeNonEmptyString(value,fieldName,sourcePath){if(typeof value!=="string"||!value.trim()){throw new Error(`Invalid '${fieldName}' in ${sourcePath}`);}return value.trim();}function asUniqueSorted(values){return[...new Set(values)].sort();}function parsePluginToggleConfig(pluginConfig){if(pluginConfig==null){return{};}if(typeof pluginConfig!=="object"||Array.isArray(pluginConfig)){throw new Error("'WEBVIEW_CONFIG.plugins' must be an object with boolean values");}const toggles={};for(const[key,value]of Object.entries(pluginConfig)){const normalizedKey=mustBeNonEmptyString(key,"plugins.<key>","WEBVIEW_CONFIG");if(typeof value!=="boolean"){throw new Error(`'WEBVIEW_CONFIG.plugins.${normalizedKey}' must be boolean`);}toggles[normalizedKey]=value;}return toggles;}function selectPluginsByConfig(plugins,pluginConfig,log){const toggles=parsePluginToggleConfig(pluginConfig);const matchedKeys=new Set();const selected=[];for(const plugin of plugins){const selectorKeys=plugin.configKey?[plugin.configKey,plugin.id]:[plugin.id];const matches=[];for(const key of selectorKeys){if(Object.prototype.hasOwnProperty.call(toggles,key)){matches.push({key,value:toggles[key]});matchedKeys.add(key);}}const uniqueValues=[...new Set(matches.map(entry=>entry.value))];if(uniqueValues.length>1){throw new Error(`Conflicting toggle values for plugin '${plugin.id}' across keys: ${matches.map(entry=>`${entry.key}=${entry.value}`).join(", ")}`);}const enabled=matches.length===0?false:matches[0].value;if(enabled){selected.push(plugin);}else{log(`Plugin disabled by config: ${plugin.id}`,"info");}}const unknownKeys=Object.keys(toggles).filter(key=>!matchedKeys.has(key));if(unknownKeys.length>0){throw new Error(`Unknown plugin toggle key(s) in WEBVIEW_CONFIG.plugins: ${unknownKeys.join(", ")}`);}return selected;}function parseDependency(dependency,pluginId){const parts=dependency.split(":");if(parts.length<3){throw new Error(`Dependency '${dependency}' in plugin '${pluginId}' must be in 'group:artifact:version' format`);}return{key:`${parts[0]}:${parts[1]}`,version:parts.slice(2).join(":")};}function validatePlugins(plugins){const pluginIds=new Set();const configKeys=new Set();const dependencies=new Map();const selectorKeys=new Map();for(const plugin of plugins){if(pluginIds.has(plugin.id)){throw new Error(`Duplicate plugin id detected: ${plugin.id}`);}pluginIds.add(plugin.id);if(plugin.configKey){if(configKeys.has(plugin.configKey)){throw new Error(`Duplicate configKey detected: ${plugin.configKey}`);}configKeys.add(plugin.configKey);}for(const[field,selector]of[["id",plugin.id],["configKey",plugin.configKey]]){if(!selector){continue;}const existing=selectorKeys.get(selector);if(existing&&existing.pluginId!==plugin.id){throw new Error(`Plugin selector collision for '${selector}': '${existing.pluginId}' (${existing.field}) conflicts with '${plugin.id}' (${field})`);}selectorKeys.set(selector,{pluginId:plugin.id,field});}if(new Set(plugin.commands).size!==plugin.commands.length){throw new Error(`Duplicate command(s) detected within plugin '${plugin.id}'`);}for(const dependency of plugin.android?.dependencies||[]){const parsed=parseDependency(dependency,plugin.id);const existing=dependencies.get(parsed.key);if(existing&&existing.version!==parsed.version){throw new Error(`Dependency version conflict for '${parsed.key}': '${existing.version}' in '${existing.pluginId}', '${parsed.version}' in '${plugin.id}'`);}if(!existing){dependencies.set(parsed.key,{version:parsed.version,pluginId:plugin.id});}}}}function selectPluginsForPlatform(plugins,platform,log){const selected=[];for(const plugin of plugins){if(plugin.platforms.includes(platform)){selected.push(plugin);continue;}log(`Plugin enabled but not supported on ${platform}: ${plugin.id}`,"info");}return selected;}function walkFiles(rootDir,predicate,results=[]){if(!isDir(rootDir)){return results;}for(const entry of fs.readdirSync(rootDir,{withFileTypes:true})){const fullPath=path.join(rootDir,entry.name);if(entry.isDirectory()){walkFiles(fullPath,predicate,results);continue;}if(predicate(entry.name,fullPath)){results.push(fullPath);}}return results;}function resolvePluginClassSourcePath(plugin){const className=plugin.android.className.split(".").pop();const candidateNames=new Set([`${className}.kt`,`${className}.java`]);const candidates=walkFiles(plugin.android.sourceDir,name=>candidateNames.has(name));if(candidates.length>1){throw new Error(`Declared class '${plugin.android.className}' for selected plugin '${plugin.id}' resolved to multiple source files under ${plugin.android.sourceDir}`);}return candidates[0]||null;}function validateSelectedPluginSources(plugins){for(const plugin of plugins){if(!plugin.android){throw new Error(`Android config missing for selected plugin '${plugin.id}'`);}if(!isDir(plugin.android.sourceDir)){throw new Error(`Android source directory missing for selected plugin '${plugin.id}'`);}const codeFiles=walkFiles(plugin.android.sourceDir,name=>name.endsWith(".kt")||name.endsWith(".java"));if(codeFiles.length===0){throw new Error(`No Android source files found for selected plugin '${plugin.id}'`);}if(!resolvePluginClassSourcePath(plugin)){throw new Error(`Declared class '${plugin.android.className}' for selected plugin '${plugin.id}' was not found as a .kt or .java file under ${plugin.android.sourceDir}`);}}}function copyTree(sourceDir,targetDir){if(!isDir(sourceDir)){return;}ensureDir(targetDir);for(const filePath of walkFiles(sourceDir,()=>true)){const targetPath=path.join(targetDir,path.relative(sourceDir,filePath));ensureDir(path.dirname(targetPath));fs.copyFileSync(filePath,targetPath);}}function copyAndroidPluginSources(plugins,javaRoot,log){const internalRoot=path.join(javaRoot,...PLUGINS_PACKAGE_PARTS,"internal");fs.rmSync(internalRoot,{recursive:true,force:true});ensureDir(internalRoot);let copiedCount=0;for(const plugin of plugins){const pluginOutputDir=path.join(internalRoot,sanitizeForPath(plugin.id));const codeFiles=walkFiles(plugin.android.sourceDir,name=>name.endsWith(".kt")||name.endsWith(".java"));for(const sourcePath of codeFiles){const targetPath=path.join(pluginOutputDir,path.relative(plugin.android.sourceDir,sourcePath));ensureDir(path.dirname(targetPath));fs.copyFileSync(sourcePath,targetPath);copiedCount++;}}log(`Copied ${copiedCount} Android plugin source file(s)`,"info");}function copyPluginAssets(plugins,androidProjectPath,log){const baseAssetsDir=path.join(androidProjectPath,"app","src","main","assets","plugins");fs.rmSync(baseAssetsDir,{recursive:true,force:true});ensureDir(baseAssetsDir);for(const plugin of plugins){const pluginAssetsDir=path.join(baseAssetsDir,sanitizeForPath(plugin.id));copyTree(path.join(plugin.pluginDir,"assets","common"),path.join(pluginAssetsDir,"common"));copyTree(path.join(plugin.pluginDir,"assets","android"),path.join(pluginAssetsDir,"android"));}log("Plugin assets copied to app/src/main/assets/plugins","info");}function formatKotlinMap(entries,emptyLiteral="emptyMap()"){return entries.length===0?emptyLiteral:`mapOf(\n${entries.join(",\n")}\n )`;}function generatePluginRegistryFiles(plugins,javaRoot){const pluginIdToClassName={};const pluginToCommands={};for(const plugin of plugins){pluginIdToClassName[plugin.id]=plugin.android.className;pluginToCommands[plugin.id]=asUniqueSorted(plugin.commands);}const classEntries=Object.keys(pluginIdToClassName).sort().map(pluginId=>` ${JSON.stringify(pluginId)} to ${JSON.stringify(pluginIdToClassName[pluginId])}`);const commandEntries=Object.keys(pluginToCommands).sort().map(pluginId=>{const commands=pluginToCommands[pluginId];const commandSet=commands.length?`setOf(${commands.map(value=>JSON.stringify(value)).join(", ")})`:"emptySet()";return` ${JSON.stringify(pluginId)} to ${commandSet}`;});const indexContent=`package io.yourname.androidproject.plugins
|
|
2
|
+
|
|
3
|
+
object GeneratedPluginIndex {
|
|
4
|
+
val pluginIdToClassName: Map<String, String> = ${formatKotlinMap(classEntries)}
|
|
5
|
+
val pluginToCommands: Map<String, Set<String>> = ${formatKotlinMap(commandEntries)}
|
|
6
|
+
}
|
|
7
|
+
`;const pluginsDir=path.join(javaRoot,...PLUGINS_PACKAGE_PARTS);ensureDir(pluginsDir);fs.writeFileSync(path.join(pluginsDir,"GeneratedPluginIndex.kt"),indexContent);fs.rmSync(path.join(pluginsDir,"GeneratedPluginMeta.kt"),{force:true});}function escapeRegexLiteral(value){return value.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");}function updateAndroidManifestPermissions(manifestPath,selectedPermissions,allKnownPluginPermissions){const uniquePermissions=asUniqueSorted(selectedPermissions);const knownPermissions=asUniqueSorted(allKnownPluginPermissions);let manifest=fs.readFileSync(manifestPath,"utf8");const beginMarker="<!-- CATALYST_PLUGIN_PERMISSIONS_START -->";const endMarker="<!-- CATALYST_PLUGIN_PERMISSIONS_END -->";const markerRegex=/[ \t]*<!-- CATALYST_PLUGIN_PERMISSIONS_START -->[\s\S]*?<!-- CATALYST_PLUGIN_PERMISSIONS_END -->\s*/g;manifest=manifest.replace(markerRegex,"");// Migration cleanup for legacy entries previously written without markers.
|
|
8
|
+
for(const permission of knownPermissions){const escaped=escapeRegexLiteral(permission);const legacyRegex=new RegExp(`^[ \\t]*<uses-permission\\s+android:name="${escaped}"\\s*/>\\s*\\n?`,"gm");manifest=manifest.replace(legacyRegex,"");}if(uniquePermissions.length>0){const permissionLines=uniquePermissions.map(permission=>` <uses-permission android:name="${permission}" />`).join("\n");const managedBlock=` ${beginMarker}\n${permissionLines}\n ${endMarker}\n`;manifest=manifest.replace(/<application\b/,`${managedBlock} <application`);}fs.writeFileSync(manifestPath,manifest);}function findDependenciesBlockRange(gradleText,gradlePath){const headerMatch=gradleText.match(/^dependencies\s*\{/m);if(!headerMatch||headerMatch.index==null){throw new Error(`Could not find dependencies block in ${gradlePath}`);}const openBraceIndex=gradleText.indexOf("{",headerMatch.index);if(openBraceIndex===-1){throw new Error(`Malformed dependencies block in ${gradlePath}`);}let depth=0;for(let index=openBraceIndex;index<gradleText.length;index++){const ch=gradleText[index];if(ch==="{")depth++;if(ch==="}")depth--;if(depth===0){return{openBraceIndex,blockEnd:index};}}throw new Error(`Could not find end of dependencies block in ${gradlePath}`);}function updateGradleDependencies(gradlePath,selectedDependencies,allKnownPluginDependencies){const uniqueDependencies=asUniqueSorted(selectedDependencies);const knownDependencies=asUniqueSorted(allKnownPluginDependencies);let gradle=fs.readFileSync(gradlePath,"utf8");const{openBraceIndex,blockEnd}=findDependenciesBlockRange(gradle,gradlePath);let blockBody=gradle.slice(openBraceIndex+1,blockEnd);const beginMarker="// CATALYST_PLUGIN_DEPENDENCIES_START";const endMarker="// CATALYST_PLUGIN_DEPENDENCIES_END";const markerRegex=/[ \t]*\/\/ CATALYST_PLUGIN_DEPENDENCIES_START[\s\S]*?\/\/ CATALYST_PLUGIN_DEPENDENCIES_END\s*/g;blockBody=blockBody.replace(markerRegex,"");// Migration cleanup for legacy entries previously written without markers.
|
|
9
|
+
for(const dependency of knownDependencies){const escaped=escapeRegexLiteral(dependency);const legacyRegex=new RegExp(`^[ \\t]*implementation\\("${escaped}"\\)\\s*\\n?`,"gm");blockBody=blockBody.replace(legacyRegex,"");}if(uniqueDependencies.length>0){const managedLines=uniqueDependencies.map(dependency=>` implementation("${dependency}")`).join("\n");blockBody=`${blockBody}\n ${beginMarker}\n${managedLines}\n ${endMarker}\n`;}gradle=`${gradle.slice(0,openBraceIndex+1)}${blockBody}${gradle.slice(blockEnd)}`;fs.writeFileSync(gradlePath,gradle);}function updateProguardKeepRules(proguardPath,selectedClassNames,allKnownPluginClassNames){const uniqueClassNames=asUniqueSorted(selectedClassNames);const knownClassNames=asUniqueSorted(allKnownPluginClassNames);let rules=fs.readFileSync(proguardPath,"utf8");const beginMarker="# CATALYST_PLUGIN_KEEP_START";const endMarker="# CATALYST_PLUGIN_KEEP_END";const markerRegex=/[ \t]*# CATALYST_PLUGIN_KEEP_START[\s\S]*?# CATALYST_PLUGIN_KEEP_END\s*/g;rules=rules.replace(markerRegex,"");for(const className of knownClassNames){const escaped=escapeRegexLiteral(className);const legacyRegex=new RegExp(`^[ \\t]*-keep class ${escaped} \\{ \\*; \\}\\s*\\n?`,"gm");rules=rules.replace(legacyRegex,"");}if(uniqueClassNames.length>0){const keepLines=uniqueClassNames.map(className=>`-keep class ${className} { *; }`).join("\n");rules=`${rules.trimEnd()}\n\n${beginMarker}\n${keepLines}\n${endMarker}\n`;}fs.writeFileSync(proguardPath,rules);}function composeAndroidPlugins({corePluginsRoot,androidProjectPath,pluginConfig,log}){const discovered=discoverInternalPlugins(corePluginsRoot,log);validatePlugins(discovered);const enabled=selectPluginsByConfig(discovered,pluginConfig,log);const selected=selectPluginsForPlatform(enabled,"android",log);validateSelectedPluginSources(selected);const javaRoot=path.join(androidProjectPath,"app","src","main","java");const manifestPath=path.join(androidProjectPath,"app","src","main","AndroidManifest.xml");const gradlePath=path.join(androidProjectPath,"app","build.gradle.kts");const proguardPath=path.join(androidProjectPath,"app","proguard-rules.pro");copyAndroidPluginSources(selected,javaRoot,log);copyPluginAssets(selected,androidProjectPath,log);generatePluginRegistryFiles(selected,javaRoot);updateAndroidManifestPermissions(manifestPath,selected.flatMap(plugin=>plugin.android.permissions),discovered.flatMap(plugin=>plugin.android?.permissions||[]));updateGradleDependencies(gradlePath,selected.flatMap(plugin=>plugin.android.dependencies),discovered.flatMap(plugin=>plugin.android?.dependencies||[]));updateProguardKeepRules(proguardPath,selected.map(plugin=>plugin.android?.className).filter(Boolean),discovered.map(plugin=>plugin.android?.className).filter(Boolean));log(`Plugin composition complete (${selected.length} enabled plugin(s))`,"success");return{pluginCount:selected.length,commandCount:selected.reduce((total,plugin)=>total+plugin.commands.length,0)};}module.exports={composeAndroidPlugins};
|