@capgo/capacitor-ssl-pinning 8.0.0

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.
@@ -0,0 +1,314 @@
1
+ import Capacitor
2
+ import Foundation
3
+ import Security
4
+
5
+ struct SSLPinningConfiguration {
6
+ let certs: [String]
7
+ let excludedDomains: [String]
8
+
9
+ static let empty = SSLPinningConfiguration(certs: [], excludedDomains: [])
10
+
11
+ var configured: Bool {
12
+ !certs.isEmpty
13
+ }
14
+
15
+ var asJSObject: [String: Any] {
16
+ [
17
+ "configured": configured,
18
+ "certs": certs,
19
+ "excludedDomains": excludedDomains
20
+ ]
21
+ }
22
+
23
+ static func from(_ pluginConfig: PluginConfig) -> SSLPinningConfiguration {
24
+ let certs = (pluginConfig.getArray("certs") as? [String]) ?? []
25
+ let excludedDomains = (pluginConfig.getArray("excludedDomains") as? [String]) ?? []
26
+ return SSLPinningConfiguration(certs: certs, excludedDomains: excludedDomains)
27
+ }
28
+ }
29
+
30
+ enum SSLPinningMatcher {
31
+ static func isExcluded(url: URL, excludedDomains: [String]) -> Bool {
32
+ for rawValue in excludedDomains where !rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
33
+ guard let excludedURL = URL(string: rawValue) else {
34
+ if url.absoluteString.hasPrefix(rawValue) {
35
+ return true
36
+ }
37
+ continue
38
+ }
39
+
40
+ guard excludedURL.scheme?.caseInsensitiveCompare(url.scheme ?? "") == .orderedSame else {
41
+ continue
42
+ }
43
+ guard excludedURL.host?.caseInsensitiveCompare(url.host ?? "") == .orderedSame else {
44
+ continue
45
+ }
46
+
47
+ let excludedPort = excludedURL.port ?? excludedURL.defaultPort
48
+ let requestPort = url.port ?? url.defaultPort
49
+ guard excludedPort == requestPort else {
50
+ continue
51
+ }
52
+
53
+ let excludedPath = excludedURL.path
54
+ if excludedPath.isEmpty || excludedPath == "/" {
55
+ return true
56
+ }
57
+
58
+ if url.path.hasPrefix(excludedPath) {
59
+ return true
60
+ }
61
+ }
62
+
63
+ return false
64
+ }
65
+ }
66
+
67
+ final class SSLPinningCertificateStore {
68
+ static let shared = SSLPinningCertificateStore()
69
+
70
+ private var cachedPaths: [String] = []
71
+ private var cachedCertificates: [SecCertificate] = []
72
+
73
+ private init() {}
74
+
75
+ func certificates(for configuredPaths: [String]) throws -> [SecCertificate] {
76
+ if configuredPaths == cachedPaths {
77
+ return cachedCertificates
78
+ }
79
+
80
+ let loadedCertificates = try configuredPaths.map(loadCertificate)
81
+ cachedPaths = configuredPaths
82
+ cachedCertificates = loadedCertificates
83
+ return loadedCertificates
84
+ }
85
+
86
+ private func loadCertificate(from configuredPath: String) throws -> SecCertificate {
87
+ let fileName = URL(fileURLWithPath: configuredPath).lastPathComponent
88
+ guard let resourceURL = Bundle.main.resourceURL?.appendingPathComponent("public/certs/\(fileName)"),
89
+ let certificateData = try? Data(contentsOf: resourceURL),
90
+ let certificate = SecCertificateCreateWithData(nil, certificateData as CFData)
91
+ else {
92
+ throw NSError(
93
+ domain: "SSLPinning",
94
+ code: -1,
95
+ userInfo: [NSLocalizedDescriptionKey: "Missing bundled certificate \(fileName)"]
96
+ )
97
+ }
98
+
99
+ return certificate
100
+ }
101
+ }
102
+
103
+ enum SSLPinningTrustEvaluator {
104
+ static func evaluate(
105
+ challenge: URLAuthenticationChallenge,
106
+ configuration: SSLPinningConfiguration
107
+ ) -> (URLSession.AuthChallengeDisposition, URLCredential?) {
108
+ guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
109
+ let trust = challenge.protectionSpace.serverTrust
110
+ else {
111
+ return (.performDefaultHandling, nil)
112
+ }
113
+
114
+ if let url = url(from: challenge.protectionSpace),
115
+ SSLPinningMatcher.isExcluded(url: url, excludedDomains: configuration.excludedDomains) {
116
+ return (.performDefaultHandling, nil)
117
+ }
118
+
119
+ do {
120
+ let certificates = try SSLPinningCertificateStore.shared.certificates(for: configuration.certs)
121
+ SecTrustSetAnchorCertificates(trust, certificates as CFArray)
122
+ SecTrustSetAnchorCertificatesOnly(trust, true)
123
+
124
+ var evaluationError: CFError?
125
+ if SecTrustEvaluateWithError(trust, &evaluationError) {
126
+ return (.useCredential, URLCredential(trust: trust))
127
+ }
128
+
129
+ return (.cancelAuthenticationChallenge, nil)
130
+ } catch {
131
+ return (.cancelAuthenticationChallenge, nil)
132
+ }
133
+ }
134
+
135
+ private static func url(from protectionSpace: URLProtectionSpace) -> URL? {
136
+ guard let host = protectionSpace.host.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) else {
137
+ return nil
138
+ }
139
+ let scheme = (protectionSpace.protocol ?? "https").lowercased()
140
+ let port = protectionSpace.port > 0 ? ":\(protectionSpace.port)" : ""
141
+ return URL(string: "\(scheme)://\(host)\(port)")
142
+ }
143
+ }
144
+
145
+ final class SSLPinningURLSessionDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate {
146
+ private let configuration: SSLPinningConfiguration
147
+ private let disableRedirects: Bool
148
+
149
+ init(configuration: SSLPinningConfiguration, disableRedirects: Bool) {
150
+ self.configuration = configuration
151
+ self.disableRedirects = disableRedirects
152
+ }
153
+
154
+ func urlSession(
155
+ _ session: URLSession,
156
+ didReceive challenge: URLAuthenticationChallenge,
157
+ completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
158
+ ) {
159
+ let result = SSLPinningTrustEvaluator.evaluate(challenge: challenge, configuration: configuration)
160
+ completionHandler(result.0, result.1)
161
+ }
162
+
163
+ func urlSession(
164
+ _ session: URLSession,
165
+ task: URLSessionTask,
166
+ willPerformHTTPRedirection response: HTTPURLResponse,
167
+ newRequest request: URLRequest,
168
+ completionHandler: @escaping (URLRequest?) -> Void
169
+ ) {
170
+ completionHandler(disableRedirects ? nil : request)
171
+ }
172
+ }
173
+
174
+ private extension URL {
175
+ var defaultPort: Int? {
176
+ switch scheme?.lowercased() {
177
+ case "http":
178
+ return 80
179
+ case "https":
180
+ return 443
181
+ default:
182
+ return nil
183
+ }
184
+ }
185
+ }
186
+
187
+ private struct SSLPinningRequestOptions {
188
+ let call: CAPPluginCall
189
+ let configuration: SSLPinningConfiguration
190
+ let config: InstanceConfiguration?
191
+ let httpMethod: String?
192
+ let urlString: String
193
+ let method: String
194
+ let params: [String: Any]
195
+ let responseType: String
196
+ let timeoutMs: Double
197
+ let shouldEncodeUrlParams: Bool
198
+ let dataType: String
199
+ let disableRedirects: Bool
200
+ let headers: [String: Any]
201
+
202
+ static func make(
203
+ from payload: NSDictionary
204
+ ) throws -> SSLPinningRequestOptions? {
205
+ guard let call = payload["call"] as? CAPPluginCall else {
206
+ return nil
207
+ }
208
+
209
+ let httpMethod = payload["httpMethod"] as? String
210
+ let config = payload["config"] as? InstanceConfiguration
211
+ let configuration = config.map { SSLPinningConfiguration.from($0.getPluginConfig("SSLPinning")) } ?? .empty
212
+
213
+ guard configuration.configured else {
214
+ try HttpRequestHandler.request(call, httpMethod, config)
215
+ return nil
216
+ }
217
+
218
+ guard var urlString = call.getString("url") else {
219
+ throw URLError(.badURL)
220
+ }
221
+ if urlString == urlString.removingPercentEncoding {
222
+ guard let encodedUrlString = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
223
+ throw URLError(.badURL)
224
+ }
225
+ urlString = encodedUrlString
226
+ }
227
+
228
+ guard let rawURL = URL(string: urlString) else {
229
+ throw URLError(.badURL)
230
+ }
231
+ if SSLPinningMatcher.isExcluded(url: rawURL, excludedDomains: configuration.excludedDomains) {
232
+ try HttpRequestHandler.request(call, httpMethod, config)
233
+ return nil
234
+ }
235
+
236
+ var headers = (call.getObject("headers") ?? [:]) as [String: Any]
237
+ if let userAgentString = config?.overridenUserAgentString,
238
+ headers["User-Agent"] == nil,
239
+ headers["user-agent"] == nil {
240
+ headers["User-Agent"] = userAgentString
241
+ }
242
+
243
+ return SSLPinningRequestOptions(
244
+ call: call,
245
+ configuration: configuration,
246
+ config: config,
247
+ httpMethod: httpMethod,
248
+ urlString: urlString,
249
+ method: httpMethod ?? call.getString("method", "GET"),
250
+ params: (call.getObject("params") ?? [:]) as [String: Any],
251
+ responseType: call.getString("responseType") ?? "text",
252
+ timeoutMs: call.getDouble("connectTimeout") ?? call.getDouble("readTimeout") ?? 600000.0,
253
+ shouldEncodeUrlParams: call.getBool("shouldEncodeUrlParams", true),
254
+ dataType: call.getString("dataType") ?? "any",
255
+ disableRedirects: call.getBool("disableRedirects") ?? false,
256
+ headers: headers
257
+ )
258
+ }
259
+ }
260
+
261
+ @objc(SSLPinningHttpRequestHandlerClass)
262
+ public final class SSLPinningHttpRequestHandlerClass: NSObject {
263
+ @objc public static func request(_ payload: NSDictionary) {
264
+ do {
265
+ guard let options = try SSLPinningRequestOptions.make(from: payload) else {
266
+ return
267
+ }
268
+
269
+ let requestBuilder = try buildRequest(options: options)
270
+ let delegate = SSLPinningURLSessionDelegate(configuration: options.configuration, disableRedirects: options.disableRedirects)
271
+ let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)
272
+ let task = session.dataTask(with: requestBuilder.getUrlRequest()) { data, response, error in
273
+ session.invalidateAndCancel()
274
+
275
+ if let error {
276
+ options.call.reject(error.localizedDescription, (error as NSError).domain, error, nil)
277
+ return
278
+ }
279
+
280
+ guard let httpResponse = response as? HTTPURLResponse else {
281
+ options.call.reject("Missing HTTP response")
282
+ return
283
+ }
284
+
285
+ HttpRequestHandler.setCookiesFromResponse(httpResponse, options.config)
286
+ let resolvedResponseType = ResponseType(rawValue: options.responseType) ?? .default
287
+ options.call.resolve(HttpRequestHandler.buildResponse(data, httpResponse, responseType: resolvedResponseType))
288
+ }
289
+
290
+ task.resume()
291
+ } catch {
292
+ let call = payload["call"] as? CAPPluginCall
293
+ call?.reject(error.localizedDescription, (error as NSError).domain, error, nil)
294
+ }
295
+ }
296
+
297
+ private static func buildRequest(options: SSLPinningRequestOptions) throws -> CapacitorUrlRequest {
298
+ let requestBuilder = try HttpRequestHandler.CapacitorHttpRequestBuilder()
299
+ .setUrl(options.urlString)
300
+ .setMethod(options.method)
301
+ .setUrlParams(options.params, options.shouldEncodeUrlParams)
302
+ .openConnection()
303
+ .build()
304
+
305
+ requestBuilder.setRequestHeaders(options.headers)
306
+ requestBuilder.setTimeout(options.timeoutMs / 1000.0)
307
+
308
+ if let data = options.call.options["data"] as? JSValue {
309
+ try requestBuilder.setRequestBody(data, options.dataType)
310
+ }
311
+
312
+ return requestBuilder
313
+ }
314
+ }
@@ -0,0 +1,22 @@
1
+ import XCTest
2
+ @testable import SSLPinningPlugin
3
+
4
+ final class SSLPinningPluginTests: XCTestCase {
5
+ func testExcludedOriginMatchesWithoutPath() {
6
+ guard let url = URL(string: "https://analytics.google.com/collect?v=2") else {
7
+ XCTFail("Expected valid URL")
8
+ return
9
+ }
10
+ XCTAssertTrue(SSLPinningMatcher.isExcluded(url: url, excludedDomains: ["https://analytics.google.com"]))
11
+ }
12
+
13
+ func testExcludedPathRequiresPrefixMatch() {
14
+ guard let allowedURL = URL(string: "https://api.example.com/v2/users"),
15
+ let excludedURL = URL(string: "https://api.example.com/v1/users") else {
16
+ XCTFail("Expected valid URLs")
17
+ return
18
+ }
19
+ XCTAssertFalse(SSLPinningMatcher.isExcluded(url: allowedURL, excludedDomains: ["https://api.example.com/v1"]))
20
+ XCTAssertTrue(SSLPinningMatcher.isExcluded(url: excludedURL, excludedDomains: ["https://api.example.com/v1"]))
21
+ }
22
+ }
package/package.json ADDED
@@ -0,0 +1,95 @@
1
+ {
2
+ "name": "@capgo/capacitor-ssl-pinning",
3
+ "version": "8.0.0",
4
+ "description": "Capacitor SSL pinning plugin for Android and iOS that integrates with CapacitorHttp.",
5
+ "main": "dist/plugin.cjs.js",
6
+ "module": "dist/esm/index.js",
7
+ "types": "dist/esm/index.d.ts",
8
+ "unpkg": "dist/plugin.js",
9
+ "files": [
10
+ "android/src/main/",
11
+ "android/build.gradle",
12
+ "dist/",
13
+ "ios/Sources",
14
+ "ios/Tests",
15
+ "scripts/",
16
+ "Package.swift",
17
+ "CapgoCapacitorSslPinning.podspec"
18
+ ],
19
+ "author": "Martin Donadieu <martin@capgo.app>",
20
+ "license": "MPL-2.0",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/Cap-go/capacitor-ssl-pinning.git"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/Cap-go/capacitor-ssl-pinning/issues"
27
+ },
28
+ "homepage": "https://capgo.app/docs/plugins/ssl-pinning/",
29
+ "keywords": [
30
+ "capacitor",
31
+ "plugin",
32
+ "ssl",
33
+ "pinning",
34
+ "https",
35
+ "http",
36
+ "security"
37
+ ],
38
+ "scripts": {
39
+ "verify": "bun run verify:ios && bun run verify:android && bun run verify:web",
40
+ "verify:ios": "xcodebuild -scheme CapgoCapacitorSslPinning -destination generic/platform=iOS",
41
+ "verify:android": "cd android && ./gradlew clean build test && cd ..",
42
+ "verify:web": "bun run build",
43
+ "lint": "bun run eslint && bun run prettier -- --check && bun run swiftlint -- lint",
44
+ "fmt": "bun run eslint -- --fix && bun run prettier -- --write && bun run swiftlint -- --fix --format",
45
+ "eslint": "eslint . --ext .ts",
46
+ "prettier": "prettier-pretty-check \"**/*.{css,html,ts,js,java,mjs,json}\" --plugin=prettier-plugin-java",
47
+ "swiftlint": "node-swiftlint",
48
+ "docgen": "docgen --api SSLPinningPlugin --output-readme README.md --output-json dist/docs.json",
49
+ "build": "bun run clean && bun run docgen && tsc && rollup -c rollup.config.mjs",
50
+ "clean": "rimraf ./dist",
51
+ "watch": "tsc --watch",
52
+ "prepublishOnly": "bun run build",
53
+ "check:wiring": "node scripts/check-capacitor-plugin-wiring.mjs",
54
+ "prepare:certs": "bun run scripts/prepare-certs.mjs",
55
+ "capacitor:sync:before": "bun run prepare:certs",
56
+ "capacitor:update:before": "bun run prepare:certs"
57
+ },
58
+ "devDependencies": {
59
+ "@capacitor/android": "^8.0.0",
60
+ "@capacitor/cli": "^8.0.0",
61
+ "@capacitor/core": "^8.0.0",
62
+ "@capacitor/docgen": "^0.3.1",
63
+ "@capacitor/ios": "^8.0.0",
64
+ "@ionic/eslint-config": "^0.4.0",
65
+ "@ionic/prettier-config": "^4.0.0",
66
+ "@ionic/swiftlint-config": "^2.0.0",
67
+ "@types/node": "^24.10.1",
68
+ "eslint": "^8.57.1",
69
+ "eslint-plugin-import": "^2.31.0",
70
+ "husky": "^9.1.7",
71
+ "prettier": "^3.6.2",
72
+ "prettier-pretty-check": "^0.2.0",
73
+ "prettier-plugin-java": "^2.7.7",
74
+ "rimraf": "^6.1.0",
75
+ "rollup": "^4.53.2",
76
+ "swiftlint": "^2.0.0",
77
+ "typescript": "^5.9.3"
78
+ },
79
+ "peerDependencies": {
80
+ "@capacitor/core": ">=8.0.0"
81
+ },
82
+ "prettier": "@ionic/prettier-config",
83
+ "swiftlint": "@ionic/swiftlint-config",
84
+ "eslintConfig": {
85
+ "extends": "@ionic/eslint-config/recommended"
86
+ },
87
+ "capacitor": {
88
+ "ios": {
89
+ "src": "ios"
90
+ },
91
+ "android": {
92
+ "src": "android"
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Capacitor plugin wiring/name checker.
4
+ *
5
+ * Enforces that runtime plugin name matches across:
6
+ * - JS: registerPlugin('Name')
7
+ * - Android: @CapacitorPlugin(name = "Name")
8
+ * - iOS: CAPBridgedPlugin jsName = "Name"
9
+ *
10
+ * And (when iOS is declared in package.json capacitor config):
11
+ * - CocoaPods podspec s.name matches SwiftPM Package(name: ...) and .library(name: ...)
12
+ *
13
+ * Usage:
14
+ * node scripts/check-capacitor-plugin-wiring.mjs # checks current working dir
15
+ * node scripts/check-capacitor-plugin-wiring.mjs --dir path # checks given plugin dir
16
+ */
17
+
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+
21
+ const SKIP_DIRS = new Set([
22
+ 'node_modules',
23
+ 'dist',
24
+ 'build',
25
+ '.build',
26
+ '.gradle',
27
+ 'Pods',
28
+ 'DerivedData',
29
+ '.swiftpm',
30
+ '.git',
31
+ ]);
32
+
33
+ function readText(p) {
34
+ try {
35
+ return fs.readFileSync(p, 'utf8');
36
+ } catch {
37
+ return '';
38
+ }
39
+ }
40
+
41
+ function exists(p) {
42
+ try {
43
+ fs.accessSync(p);
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ function walkFiles(rootDir, exts) {
51
+ const out = [];
52
+ const stack = [rootDir];
53
+ while (stack.length) {
54
+ const dir = stack.pop();
55
+ let entries;
56
+ try {
57
+ entries = fs.readdirSync(dir, { withFileTypes: true });
58
+ } catch {
59
+ continue;
60
+ }
61
+ for (const e of entries) {
62
+ if (e.isDirectory()) {
63
+ if (SKIP_DIRS.has(e.name)) continue;
64
+ stack.push(path.join(dir, e.name));
65
+ continue;
66
+ }
67
+ if (!e.isFile()) continue;
68
+ for (const ext of exts) {
69
+ if (e.name.endsWith(ext)) {
70
+ out.push(path.join(dir, e.name));
71
+ break;
72
+ }
73
+ }
74
+ }
75
+ }
76
+ out.sort();
77
+ return out;
78
+ }
79
+
80
+ function uniq(arr) {
81
+ const out = [];
82
+ for (const x of arr) {
83
+ if (!x) continue;
84
+ if (!out.includes(x)) out.push(x);
85
+ }
86
+ return out;
87
+ }
88
+
89
+ function parseArgs(argv) {
90
+ const out = { dir: process.cwd() };
91
+ for (let i = 2; i < argv.length; i++) {
92
+ const a = argv[i];
93
+ if (a === '--dir' || a === '--pluginDir') {
94
+ out.dir = path.resolve(argv[++i] || '.');
95
+ continue;
96
+ }
97
+ }
98
+ return out;
99
+ }
100
+
101
+ const args = parseArgs(process.argv);
102
+ const pluginDir = args.dir;
103
+ const pkgPath = path.join(pluginDir, 'package.json');
104
+
105
+ if (!exists(pkgPath)) {
106
+ console.error(`[wiring] ERROR: missing package.json in ${pluginDir}`);
107
+ process.exit(2);
108
+ }
109
+
110
+ let pkg;
111
+ try {
112
+ pkg = JSON.parse(readText(pkgPath));
113
+ } catch (e) {
114
+ console.error(`[wiring] ERROR: invalid package.json (${pkgPath}): ${e?.message || e}`);
115
+ process.exit(2);
116
+ }
117
+
118
+ const cap = typeof pkg.capacitor === 'object' && pkg.capacitor ? pkg.capacitor : {};
119
+ const supportsAndroid = typeof cap.android === 'object' && cap.android;
120
+ const supportsIos = typeof cap.ios === 'object' && cap.ios;
121
+
122
+ // Not a Capacitor plugin package (e.g. meta/workspace package).
123
+ // We only enforce wiring rules for actual plugin packages declaring a `capacitor` config.
124
+ if (!supportsAndroid && !supportsIos) {
125
+ process.exit(0);
126
+ }
127
+
128
+ // ---------------- JS (registerPlugin) ----------------
129
+ const jsSrcDir = path.join(pluginDir, 'src');
130
+ let jsName = '';
131
+ if (exists(jsSrcDir)) {
132
+ const jsFiles = walkFiles(jsSrcDir, ['.ts', '.js']);
133
+ const reRegister = /registerPlugin(?:<[^>]*>)?\(\s*['"]([^'"]+)['"]/;
134
+ for (const f of jsFiles) {
135
+ const m = reRegister.exec(readText(f));
136
+ if (m) {
137
+ jsName = m[1];
138
+ break;
139
+ }
140
+ }
141
+ }
142
+
143
+ // ---------------- Android (@CapacitorPlugin) ----------------
144
+ let androidNames = [];
145
+ if (supportsAndroid) {
146
+ const androidMain = path.join(pluginDir, 'android', 'src', 'main');
147
+ const files = walkFiles(androidMain, ['.java', '.kt']);
148
+ const foundAnnotations = [];
149
+ for (const f of files) {
150
+ const txt = readText(f);
151
+ if (!txt.includes('@CapacitorPlugin')) continue;
152
+ foundAnnotations.push(f);
153
+ const m =
154
+ /@CapacitorPlugin\(\s*name\s*=\s*"([^"]+)"/.exec(txt) ||
155
+ /@CapacitorPlugin\(\s*name\s*=\s*([A-Za-z0-9_]+)\b/.exec(txt);
156
+ if (m) androidNames.push(m[1]);
157
+ }
158
+ androidNames = uniq(androidNames);
159
+
160
+ // Enforce explicit name attribute to prevent silent class-name drift.
161
+ if (foundAnnotations.length && !androidNames.length) {
162
+ console.error(
163
+ `[wiring] ERROR: Android has @CapacitorPlugin but none specify name = \"...\". Add an explicit name to the plugin class.`,
164
+ );
165
+ process.exit(1);
166
+ }
167
+ }
168
+
169
+ // ---------------- iOS (jsName) ----------------
170
+ let iosJsNames = [];
171
+ if (supportsIos) {
172
+ const iosDir = path.join(pluginDir, 'ios');
173
+ const scanRoot = exists(path.join(iosDir, 'Sources')) ? path.join(iosDir, 'Sources') : iosDir;
174
+ const swiftFiles = walkFiles(scanRoot, ['.swift']);
175
+ const reJsName = /\bjsName\s*=\s*"([^"]+)"/g;
176
+ for (const f of swiftFiles) {
177
+ const txt = readText(f);
178
+ if (!txt.includes('jsName')) continue;
179
+ let m;
180
+ while ((m = reJsName.exec(txt))) iosJsNames.push(m[1]);
181
+ }
182
+ iosJsNames = uniq(iosJsNames);
183
+ }
184
+
185
+ // ---------------- Podspec/SPM ----------------
186
+ function parsePodspecName(podspecPath) {
187
+ const txt = readText(podspecPath);
188
+ const m = /\bs\.name\s*=\s*'([^']+)'/.exec(txt);
189
+ return m ? m[1] : '';
190
+ }
191
+
192
+ function parseSpmNames(packageSwiftPath) {
193
+ const txt = readText(packageSwiftPath);
194
+ const pkg = /Package\(\s*name\s*:\s*"([^"]+)"/.exec(txt)?.[1] || '';
195
+ const libs = [];
196
+ const reLib = /\.library\(\s*name\s*:\s*"([^"]+)"/g;
197
+ let m;
198
+ while ((m = reLib.exec(txt))) libs.push(m[1]);
199
+ return { pkgName: pkg, libNames: uniq(libs) };
200
+ }
201
+
202
+ // ---------------- Validate ----------------
203
+ const errors = [];
204
+
205
+ if (!jsName) {
206
+ errors.push("JS: no registerPlugin('...') found under src/");
207
+ }
208
+
209
+ if (supportsAndroid) {
210
+ if (!androidNames.length) errors.push('Android: missing @CapacitorPlugin(name = "...")');
211
+ if (jsName && androidNames.length && androidNames.some((n) => n !== jsName)) {
212
+ errors.push(`Android: @CapacitorPlugin(name)=${JSON.stringify(androidNames)} != JS registerPlugin=${jsName}`);
213
+ }
214
+ }
215
+
216
+ if (supportsIos) {
217
+ if (!iosJsNames.length) errors.push('iOS: missing jsName = "..." in Swift sources');
218
+ if (jsName && iosJsNames.length && iosJsNames.some((n) => n !== jsName)) {
219
+ errors.push(`iOS: jsName=${JSON.stringify(iosJsNames)} != JS registerPlugin=${jsName}`);
220
+ }
221
+
222
+ const podspecs = fs
223
+ .readdirSync(pluginDir, { withFileTypes: true })
224
+ .filter((e) => e.isFile() && e.name.endsWith('.podspec'))
225
+ .map((e) => path.join(pluginDir, e.name))
226
+ .sort();
227
+ if (!podspecs.length) errors.push('iOS: missing *.podspec at plugin root');
228
+ if (podspecs.length > 1)
229
+ errors.push(`iOS: multiple podspecs at plugin root: ${podspecs.map((p) => path.basename(p))}`);
230
+
231
+ const pkgSwift = path.join(pluginDir, 'Package.swift');
232
+ if (!exists(pkgSwift)) {
233
+ errors.push('iOS: missing Package.swift at plugin root');
234
+ } else if (podspecs.length) {
235
+ const podName = parsePodspecName(podspecs[0]);
236
+ const { pkgName, libNames } = parseSpmNames(pkgSwift);
237
+ if (!podName) errors.push("Podspec: missing s.name = '...'");
238
+ if (!pkgName) errors.push('SPM: missing Package(name: "...")');
239
+ if (podName && pkgName && podName !== pkgName) {
240
+ errors.push(`Podspec: s.name=${podName} != Package(name)=${pkgName}`);
241
+ }
242
+ if (pkgName && libNames.length && !libNames.includes(pkgName)) {
243
+ errors.push(`SPM: Package(name)=${pkgName} not present in .library(name) list ${JSON.stringify(libNames)}`);
244
+ }
245
+ }
246
+ }
247
+
248
+ if (errors.length) {
249
+ const relDir = path.relative(process.cwd(), pluginDir) || '.';
250
+ console.error(`[wiring] FAIL in ${relDir}`);
251
+ for (const e of errors) console.error(`- ${e}`);
252
+ process.exit(1);
253
+ }
254
+
255
+ process.exit(0);