catalyst-core-internal 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +4 -4
  2. package/bin/catalyst.js +8 -1
  3. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/BridgeMessageValidator.kt +3 -11
  4. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +12 -1
  5. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +18 -3
  6. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/CatalystPlugin.kt +7 -0
  7. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/GeneratedPluginIndex.kt +6 -0
  8. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/PluginBridge.kt +253 -0
  9. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/SecurityBridgeTest.kt +199 -0
  10. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/plugins/PluginBridgeTest.kt +139 -0
  11. package/dist/native/bridge/hooks.js +4 -4
  12. package/dist/native/bridge/useBaseHook.js +5 -4
  13. package/dist/native/bridge/utils/NativeBridge.js +4 -4
  14. package/dist/native/buildAppAndroid.js +2 -2
  15. package/dist/native/buildAppIos.js +10 -17
  16. package/dist/native/internal-plugins/device-info-plugin/android/DeviceInfoPlugin.kt +43 -0
  17. package/dist/native/internal-plugins/device-info-plugin/ios/DeviceInfoPlugin.swift +28 -0
  18. package/dist/native/internal-plugins/device-info-plugin/manifest.json +19 -0
  19. package/dist/native/internalPluginUtils.js +1 -0
  20. package/dist/native/iosnativeWebView/Sources/Core/Plugins/CatalystPlugin.swift +5 -0
  21. package/dist/native/iosnativeWebView/Sources/Core/Plugins/GeneratedPluginIndex.swift +6 -0
  22. package/dist/native/iosnativeWebView/Sources/Core/Plugins/PluginBridge.swift +364 -0
  23. package/dist/native/iosnativeWebView/Sources/Core/Utils/CacheManager.swift +13 -2
  24. package/dist/native/iosnativeWebView/Sources/Core/WebView/NativeBridge.swift +13 -2
  25. package/dist/native/iosnativeWebView/Sources/Core/WebView/WeakScriptMessageHandler.swift +14 -0
  26. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +6 -0
  27. package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.pbxproj +4 -0
  28. package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +36 -0
  29. package/dist/native/iosnativeWebView/iosnativeWebView.xctestplan +1 -0
  30. package/dist/native/iosnativeWebView/iosnativeWebViewTests/BridgeCommandHandlerSecurityTests.swift +212 -0
  31. package/dist/native/iosnativeWebView/iosnativeWebViewTests/FrameworkServerUtilsTests.swift +14 -4
  32. package/dist/native/iosnativeWebView/iosnativeWebViewTests/PluginBridgeTests.swift +160 -0
  33. package/dist/native/iosnativeWebView/iosnativeWebViewTests/ScreenSecureManagerTests.swift +121 -0
  34. package/dist/native/iosnativeWebView/iosnativeWebViewTests/WebViewTests.swift +9 -21
  35. package/dist/native/plugin-bridge/PluginBridge.js +1 -0
  36. package/dist/native/pluginComposerAndroid.js +9 -0
  37. package/dist/native/pluginComposerIos.js +7 -0
  38. package/dist/scripts/plugins.js +1 -0
  39. package/package.json +3 -2
  40. package/mcp_v2/conversion-tasks.json +0 -371
  41. package/mcp_v2/knowledge-base.json +0 -1450
  42. package/mcp_v2/lib/helpers.js +0 -145
  43. package/mcp_v2/mcp.js +0 -366
  44. package/mcp_v2/package.json +0 -13
  45. package/mcp_v2/schema.sql +0 -88
  46. package/mcp_v2/setup.js +0 -262
  47. package/mcp_v2/tools/build.js +0 -449
  48. package/mcp_v2/tools/config.js +0 -262
  49. package/mcp_v2/tools/conversion.js +0 -492
  50. package/mcp_v2/tools/debug.js +0 -62
  51. package/mcp_v2/tools/knowledge.js +0 -213
  52. package/mcp_v2/tools/sync.js +0 -21
  53. package/mcp_v2/tools/tasks.js +0 -844
@@ -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
- let started = frameworkServer.startServer()
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
- // Only test file serving if server started
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
- var decisionReceived = false
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
- XCTAssertTrue(decisionReceived, "Navigation decision should be received")
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
- var decisionReceived = false
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
- XCTAssertTrue(decisionReceived, "Navigation decision should be received")
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};