@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.
- package/CapgoCapacitorSslPinning.podspec +17 -0
- package/LICENSE +373 -0
- package/Package.swift +28 -0
- package/README.md +141 -0
- package/android/build.gradle +59 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/io/ionic/sslpinning/CertificateLoader.java +99 -0
- package/android/src/main/java/io/ionic/sslpinning/SSLPinning.java +68 -0
- package/android/src/main/res/.gitkeep +0 -0
- package/dist/docs.json +86 -0
- package/dist/esm/definitions.d.ts +61 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +6 -0
- package/dist/esm/web.js +16 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +30 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +33 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/SSLPinningPlugin/SSLPinningPlugin.swift +37 -0
- package/ios/Sources/SSLPinningPlugin/SSLPinningSupport.swift +314 -0
- package/ios/Tests/SSLPinningPluginTests/SSLPinningPluginTests.swift +22 -0
- package/package.json +95 -0
- package/scripts/check-capacitor-plugin-wiring.mjs +255 -0
- package/scripts/prepare-certs.mjs +51 -0
|
@@ -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);
|