@capgo/capacitor-inappbrowser 8.6.15

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 (59) hide show
  1. package/CapgoCapacitorInappbrowser.podspec +17 -0
  2. package/LICENSE +373 -0
  3. package/Package.swift +28 -0
  4. package/README.md +1610 -0
  5. package/android/build.gradle +64 -0
  6. package/android/src/main/AndroidManifest.xml +22 -0
  7. package/android/src/main/assets/proxy-bridge.js +586 -0
  8. package/android/src/main/java/ee/forgr/capacitor_inappbrowser/ActiveWebViewSupport.java +10 -0
  9. package/android/src/main/java/ee/forgr/capacitor_inappbrowser/CustomSchemeInterceptSupport.java +51 -0
  10. package/android/src/main/java/ee/forgr/capacitor_inappbrowser/InAppBrowserPlugin.java +1820 -0
  11. package/android/src/main/java/ee/forgr/capacitor_inappbrowser/MobileAppBridgeScript.java +122 -0
  12. package/android/src/main/java/ee/forgr/capacitor_inappbrowser/NativeProxyRule.java +128 -0
  13. package/android/src/main/java/ee/forgr/capacitor_inappbrowser/Options.java +725 -0
  14. package/android/src/main/java/ee/forgr/capacitor_inappbrowser/ProxyBridge.java +93 -0
  15. package/android/src/main/java/ee/forgr/capacitor_inappbrowser/ProxyRequestSupport.java +869 -0
  16. package/android/src/main/java/ee/forgr/capacitor_inappbrowser/ProxyResponseRouting.java +35 -0
  17. package/android/src/main/java/ee/forgr/capacitor_inappbrowser/SafeAreaInsetsSupport.java +28 -0
  18. package/android/src/main/java/ee/forgr/capacitor_inappbrowser/WebViewCallbacks.java +42 -0
  19. package/android/src/main/java/ee/forgr/capacitor_inappbrowser/WebViewDialog.java +6022 -0
  20. package/android/src/main/java/ee/forgr/capacitor_inappbrowser/WebViewPermissionSupport.java +58 -0
  21. package/android/src/main/res/.gitkeep +0 -0
  22. package/android/src/main/res/drawable/arrow_back_disabled.xml +9 -0
  23. package/android/src/main/res/drawable/arrow_back_enabled.xml +9 -0
  24. package/android/src/main/res/drawable/arrow_forward_disabled.xml +9 -0
  25. package/android/src/main/res/drawable/arrow_forward_enabled.xml +9 -0
  26. package/android/src/main/res/drawable/ic_clear_24px.xml +9 -0
  27. package/android/src/main/res/drawable/ic_refresh.xml +9 -0
  28. package/android/src/main/res/drawable/ic_share.xml +10 -0
  29. package/android/src/main/res/layout/activity_browser.xml +32 -0
  30. package/android/src/main/res/layout/bridge_layout_main.xml +15 -0
  31. package/android/src/main/res/layout/content_browser.xml +17 -0
  32. package/android/src/main/res/layout/tool_bar.xml +87 -0
  33. package/android/src/main/res/values/browser_theme.xml +3 -0
  34. package/android/src/main/res/values/colors.xml +5 -0
  35. package/android/src/main/res/values/dimens.xml +3 -0
  36. package/android/src/main/res/values/strings.xml +13 -0
  37. package/android/src/main/res/values/styles.xml +4 -0
  38. package/android/src/main/res/values/themes.xml +27 -0
  39. package/android/src/main/res/xml/file_paths.xml +14 -0
  40. package/dist/docs.json +3200 -0
  41. package/dist/esm/definitions.d.ts +1507 -0
  42. package/dist/esm/definitions.js +40 -0
  43. package/dist/esm/definitions.js.map +1 -0
  44. package/dist/esm/index.d.ts +6 -0
  45. package/dist/esm/index.js +102 -0
  46. package/dist/esm/index.js.map +1 -0
  47. package/dist/esm/web.d.ts +47 -0
  48. package/dist/esm/web.js +120 -0
  49. package/dist/esm/web.js.map +1 -0
  50. package/dist/plugin.cjs.js +270 -0
  51. package/dist/plugin.cjs.js.map +1 -0
  52. package/dist/plugin.js +273 -0
  53. package/dist/plugin.js.map +1 -0
  54. package/ios/Sources/InAppBrowserPlugin/Enums.swift +65 -0
  55. package/ios/Sources/InAppBrowserPlugin/InAppBrowserPlugin.swift +1932 -0
  56. package/ios/Sources/InAppBrowserPlugin/ProxySchemeHandler.swift +1185 -0
  57. package/ios/Sources/InAppBrowserPlugin/WKWebView+SchemeHandling.swift +43 -0
  58. package/ios/Sources/InAppBrowserPlugin/WKWebViewController.swift +3032 -0
  59. package/package.json +139 -0
@@ -0,0 +1,1185 @@
1
+ // swiftlint:disable file_length
2
+ import Foundation
3
+ import WebKit
4
+ import Capacitor
5
+
6
+ struct NativeProxyRule {
7
+ enum Action: String {
8
+ case `continue`
9
+ case cancel
10
+ case delegateToJs
11
+ }
12
+
13
+ let id: String?
14
+ let urlRegex: NSRegularExpression?
15
+ let methodRegex: NSRegularExpression?
16
+ let headerRegex: NSRegularExpression?
17
+ let bodyRegex: NSRegularExpression?
18
+ let statusRegex: NSRegularExpression?
19
+ let responseHeaderRegex: NSRegularExpression?
20
+ let responseBodyRegex: NSRegularExpression?
21
+ let mainFrameOnly: Bool
22
+ let action: Action
23
+
24
+ func matches(
25
+ url: String,
26
+ method: String,
27
+ headers: String,
28
+ body: String?,
29
+ isMainFrame: Bool,
30
+ status: Int?,
31
+ responseHeaders: String?,
32
+ responseBody: String?
33
+ ) -> Bool {
34
+ if mainFrameOnly && !isMainFrame {
35
+ return false
36
+ }
37
+ if !matches(urlRegex, value: url) { return false }
38
+ if !matches(methodRegex, value: method) { return false }
39
+ if !matches(headerRegex, value: headers) { return false }
40
+ if !matches(bodyRegex, value: body) { return false }
41
+ if !matches(statusRegex, value: status.map(String.init)) { return false }
42
+ if !matches(responseHeaderRegex, value: responseHeaders) { return false }
43
+ if !matches(responseBodyRegex, value: responseBody) { return false }
44
+ return true
45
+ }
46
+
47
+ private func matches(_ regex: NSRegularExpression?, value: String?) -> Bool {
48
+ guard let regex else { return true }
49
+ guard let value else { return false }
50
+ let range = NSRange(location: 0, length: value.utf16.count)
51
+ return regex.firstMatch(in: value, options: [], range: range) != nil
52
+ }
53
+
54
+ static func from(dictionary: [String: Any]) throws -> NativeProxyRule {
55
+ func compile(_ key: String) throws -> NSRegularExpression? {
56
+ guard let raw = dictionary[key] as? String, !raw.isEmpty else { return nil }
57
+ return try NSRegularExpression(pattern: raw, options: [.caseInsensitive])
58
+ }
59
+
60
+ let action = Action(rawValue: dictionary["action"] as? String ?? "continue") ?? .continue
61
+ return try NativeProxyRule(
62
+ id: dictionary["id"] as? String,
63
+ urlRegex: compile("urlRegex"),
64
+ methodRegex: compile("methodRegex"),
65
+ headerRegex: compile("headerRegex"),
66
+ bodyRegex: compile("bodyRegex"),
67
+ statusRegex: compile("statusRegex"),
68
+ responseHeaderRegex: compile("responseHeaderRegex"),
69
+ responseBodyRegex: compile("responseBodyRegex"),
70
+ mainFrameOnly: dictionary["mainFrameOnly"] as? Bool ?? false,
71
+ action: action
72
+ )
73
+ }
74
+ }
75
+
76
+ struct NativeRequestContext {
77
+ var url: String
78
+ var method: String
79
+ var headers: [String: String]
80
+ var base64Body: String?
81
+ var isMainFrame: Bool
82
+ }
83
+
84
+ struct NativeResponseData {
85
+ var statusCode: Int
86
+ var headers: [String: String]
87
+ var body: Data
88
+ var contentType: String
89
+ }
90
+
91
+ struct LegacyProxyRequestsConfiguration {
92
+ let isEnabled: Bool
93
+ let urlRegex: NSRegularExpression?
94
+ }
95
+
96
+ enum ProxySchemeRequestSupport {
97
+ private static let httpMethodLocale = Locale(identifier: "en_US_POSIX")
98
+ private static let crossOriginOverrideHeaderNames = [
99
+ "Authorization",
100
+ "Cookie",
101
+ "Cookie2",
102
+ "Origin",
103
+ "Proxy-Authorization",
104
+ "Referer"
105
+ ]
106
+
107
+ enum JsResponseResolutionAction {
108
+ case finishCachedResponse
109
+ case executeNativePipeline
110
+ case executeInboundDecision
111
+ }
112
+
113
+ enum TimeoutResolutionAction {
114
+ case fallbackToNative
115
+ case finishCachedResponse
116
+ case failRequest
117
+ }
118
+
119
+ enum RequestBuildError: LocalizedError, Equatable {
120
+ case invalidURL
121
+ case invalidBase64Body
122
+
123
+ var errorDescription: String? {
124
+ switch self {
125
+ case .invalidURL:
126
+ return "Invalid request URL"
127
+ case .invalidBase64Body:
128
+ return "Invalid base64 request body"
129
+ }
130
+ }
131
+ }
132
+
133
+ static func isMainFrameRequest(_ request: URLRequest, fallback: Bool = false) -> Bool {
134
+ guard let url = request.url else { return fallback }
135
+ guard let mainDocumentURL = request.mainDocumentURL else { return fallback }
136
+ return mainDocumentURL.absoluteString == url.absoluteString
137
+ }
138
+
139
+ static func sanitizedOverrideURL(_ rawURL: String?, fallback: String) -> String {
140
+ guard let rawURL = rawURL?.trimmingCharacters(in: .whitespacesAndNewlines), !rawURL.isEmpty else {
141
+ return fallback
142
+ }
143
+
144
+ if let absoluteURL = URL(string: rawURL), absoluteURL.scheme?.isEmpty == false {
145
+ return absoluteURL.absoluteString
146
+ }
147
+
148
+ guard
149
+ let fallbackURL = URL(string: fallback),
150
+ let resolvedURL = URL(string: rawURL, relativeTo: fallbackURL)?.absoluteURL,
151
+ resolvedURL.scheme?.isEmpty == false
152
+ else {
153
+ return fallback
154
+ }
155
+
156
+ return resolvedURL.absoluteString
157
+ }
158
+
159
+ static func resolvedResponseURL(_ response: URLResponse?, fallback: String) -> String {
160
+ guard
161
+ let responseURL = response?.url?.absoluteString,
162
+ !responseURL.isEmpty
163
+ else {
164
+ return fallback
165
+ }
166
+ return responseURL
167
+ }
168
+
169
+ static func prepareOverrideHeaders(
170
+ originalHeaders: [String: String],
171
+ requestURL: String,
172
+ overrideURL: String
173
+ ) -> [String: String] {
174
+ guard isCrossOriginRequest(requestURL, overrideURL) else {
175
+ return originalHeaders
176
+ }
177
+
178
+ return originalHeaders.filter { header, _ in
179
+ !crossOriginOverrideHeaderNames.contains { $0.caseInsensitiveCompare(header) == .orderedSame }
180
+ }
181
+ }
182
+
183
+ static func responseCookieURL(_ response: URLResponse?, fallback: String) -> URL? {
184
+ if let responseURL = response?.url {
185
+ return responseURL
186
+ }
187
+ return URL(string: fallback)
188
+ }
189
+
190
+ static func responseCookies(
191
+ from storage: HTTPCookieStorage = .shared,
192
+ response: URLResponse?,
193
+ fallback: String
194
+ ) -> [HTTPCookie] {
195
+ guard let cookieURL = responseCookieURL(response, fallback: fallback) else {
196
+ return []
197
+ }
198
+ return storage.cookies(for: cookieURL) ?? []
199
+ }
200
+
201
+ static func resolvedRedirectHeaders(_ headers: [String: String]?, fallback: [String: String]) -> [String: String] {
202
+ guard let headers else {
203
+ return [:]
204
+ }
205
+ return headers
206
+ }
207
+
208
+ static func resolvedRedirectBody(_ body: String?, method: String, fallback: String?) -> String? {
209
+ guard supportsRequestBody(method: method) else {
210
+ return nil
211
+ }
212
+ return body ?? fallback
213
+ }
214
+
215
+ static func responseCookies(from headers: [String: String], fallback: String) -> [HTTPCookie] {
216
+ guard let cookieURL = URL(string: fallback) else {
217
+ return []
218
+ }
219
+
220
+ let cookieHeaders = headers.reduce(into: [String: String]()) { result, entry in
221
+ if entry.key.caseInsensitiveCompare("Set-Cookie") == .orderedSame ||
222
+ entry.key.caseInsensitiveCompare("Set-Cookie2") == .orderedSame {
223
+ result[entry.key] = entry.value
224
+ }
225
+ }
226
+
227
+ guard !cookieHeaders.isEmpty else {
228
+ return []
229
+ }
230
+
231
+ return HTTPCookie.cookies(withResponseHeaderFields: cookieHeaders, for: cookieURL)
232
+ }
233
+
234
+ static func timeoutResolutionAction(phase: String, hasCachedResponse: Bool, hasPendingRedirect: Bool) -> TimeoutResolutionAction {
235
+ if phase == "outbound" || hasPendingRedirect {
236
+ return .fallbackToNative
237
+ }
238
+ if phase == "inbound", hasCachedResponse {
239
+ return .finishCachedResponse
240
+ }
241
+ return .failRequest
242
+ }
243
+
244
+ static func jsResponseResolutionAction(phase: String, hasCachedResponse: Bool) -> JsResponseResolutionAction {
245
+ if hasCachedResponse {
246
+ return .finishCachedResponse
247
+ }
248
+ if phase == "outbound" {
249
+ return .executeNativePipeline
250
+ }
251
+ return .executeInboundDecision
252
+ }
253
+
254
+ static func shouldFollowDelegatedRedirect(
255
+ phase: String,
256
+ hasPendingRedirect: Bool,
257
+ hasDirectResponse: Bool,
258
+ isCanceled: Bool
259
+ ) -> Bool {
260
+ phase == "inbound" && hasPendingRedirect && !hasDirectResponse && !isCanceled
261
+ }
262
+
263
+ static func timeoutTokenMatches(scheduledToken: UUID, currentToken: UUID?) -> Bool {
264
+ currentToken == scheduledToken
265
+ }
266
+
267
+ static func normalizedRequestMethod(_ method: String?) -> String {
268
+ guard let trimmedMethod = method?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmedMethod.isEmpty else {
269
+ return "GET"
270
+ }
271
+ return trimmedMethod.uppercased(with: httpMethodLocale)
272
+ }
273
+
274
+ static func legacyProxyRequestsConfiguration(from rawValue: Any?) -> LegacyProxyRequestsConfiguration {
275
+ if let enabled = rawValue as? Bool {
276
+ return LegacyProxyRequestsConfiguration(isEnabled: enabled, urlRegex: nil)
277
+ }
278
+
279
+ // String regex mode is Android-only. iOS ignores it instead of rejecting cross-platform calls.
280
+ return LegacyProxyRequestsConfiguration(isEnabled: false, urlRegex: nil)
281
+ }
282
+
283
+ static func shouldUseLegacyCatchAllRule(
284
+ legacyProxyRequests: Bool,
285
+ hasOutboundRules: Bool,
286
+ hasInboundRules: Bool,
287
+ phase: String
288
+ ) -> Bool {
289
+ legacyProxyRequests && phase == "outbound" && !hasOutboundRules && !hasInboundRules
290
+ }
291
+
292
+ static func decodedRequestBody(from base64Body: String?) throws -> Data? {
293
+ guard let base64Body else {
294
+ return nil
295
+ }
296
+ guard !base64Body.isEmpty else {
297
+ return nil
298
+ }
299
+ guard let bodyData = Data(base64Encoded: base64Body) else {
300
+ throw RequestBuildError.invalidBase64Body
301
+ }
302
+ return bodyData
303
+ }
304
+
305
+ static func decodedResponseBody(from base64Body: String?) throws -> Data {
306
+ guard let base64Body else {
307
+ return Data()
308
+ }
309
+ guard !base64Body.isEmpty else {
310
+ return Data()
311
+ }
312
+ guard let bodyData = Data(base64Encoded: base64Body) else {
313
+ throw RequestBuildError.invalidBase64Body
314
+ }
315
+ return bodyData
316
+ }
317
+
318
+ static func supportsRequestBody(method: String) -> Bool {
319
+ let uppercasedMethod = method.uppercased()
320
+ return uppercasedMethod != "GET" && uppercasedMethod != "HEAD"
321
+ }
322
+
323
+ static func resolvedOverrideBody(
324
+ from override: [String: Any],
325
+ method: String,
326
+ fallback: String?
327
+ ) -> String? {
328
+ guard supportsRequestBody(method: method) else {
329
+ return nil
330
+ }
331
+
332
+ guard override.keys.contains("body") else {
333
+ return fallback
334
+ }
335
+
336
+ if override["body"] is NSNull {
337
+ return nil
338
+ }
339
+
340
+ return override["body"] as? String ?? fallback
341
+ }
342
+
343
+ static func normalizedResponseHeaders(from response: HTTPURLResponse?) -> [String: String] {
344
+ normalizedResponseHeaders(from: response?.allHeaderFields ?? [:])
345
+ }
346
+
347
+ static func normalizedResponseHeaders(from headerFields: [AnyHashable: Any]) -> [String: String] {
348
+ var headers: [String: String] = [:]
349
+ for (key, value) in headerFields {
350
+ guard let headerName = key as? String else {
351
+ continue
352
+ }
353
+
354
+ if let headerValue = value as? String {
355
+ headers[headerName] = headerValue
356
+ continue
357
+ }
358
+
359
+ if let headerValues = value as? [String] {
360
+ headers[headerName] = headerValues.joined(separator: ", ")
361
+ continue
362
+ }
363
+
364
+ if let headerValues = value as? [Any] {
365
+ let stringValues = headerValues.map { String(describing: $0) }
366
+ headers[headerName] = stringValues.joined(separator: ", ")
367
+ continue
368
+ }
369
+
370
+ headers[headerName] = String(describing: value)
371
+ }
372
+
373
+ return headers
374
+ }
375
+
376
+ private static func isCrossOriginRequest(_ firstURL: String, _ secondURL: String) -> Bool {
377
+ guard
378
+ let first = URL(string: firstURL),
379
+ let second = URL(string: secondURL)
380
+ else {
381
+ return true
382
+ }
383
+
384
+ return
385
+ first.scheme?.caseInsensitiveCompare(second.scheme ?? "") != .orderedSame ||
386
+ first.host?.caseInsensitiveCompare(second.host ?? "") != .orderedSame ||
387
+ effectivePort(first) != effectivePort(second)
388
+ }
389
+
390
+ private static func effectivePort(_ url: URL) -> Int {
391
+ if let port = url.port {
392
+ return port
393
+ }
394
+ switch url.scheme?.lowercased() {
395
+ case "http":
396
+ return 80
397
+ case "https":
398
+ return 443
399
+ default:
400
+ return -1
401
+ }
402
+ }
403
+ }
404
+
405
+ final class PendingProxyTask {
406
+ let schemeTask: WKURLSchemeTask
407
+ var requestContext: NativeRequestContext
408
+ var responseData: NativeResponseData?
409
+ var phase: String
410
+ var urlSessionTask: URLSessionDataTask?
411
+ var redirectRequest: URLRequest?
412
+ var timeoutToken: UUID?
413
+ var canceled = false
414
+
415
+ init(schemeTask: WKURLSchemeTask, requestContext: NativeRequestContext, phase: String) {
416
+ self.schemeTask = schemeTask
417
+ self.requestContext = requestContext
418
+ self.phase = phase
419
+ }
420
+ }
421
+
422
+ // swiftlint:disable type_body_length
423
+ public class ProxySchemeHandler: NSObject, WKURLSchemeHandler, URLSessionTaskDelegate {
424
+ /// HTTP status codes for which URLSession actually triggers
425
+ /// `urlSession(_:task:willPerformHTTPRedirection:newRequest:completionHandler:)`.
426
+ /// Scoped narrowly so other 3xx responses (300, 304, 305, 306) flow through the
427
+ /// dataTask completion handler normally — 304 Not Modified in particular must
428
+ /// still reach finish() or the scheme task strands.
429
+ static let redirectStatusCodes: Set<Int> = [301, 302, 303, 307, 308]
430
+
431
+ weak var plugin: InAppBrowserPlugin?
432
+ private var pendingTasks: [String: PendingProxyTask] = [:]
433
+ private var stoppedRequests: [String: UUID] = [:]
434
+ private var timedOutRequests: [String: String] = [:]
435
+ private let taskLock = NSLock()
436
+ private let webviewId: String
437
+ private let proxyTimeoutSeconds: TimeInterval = 10
438
+ private let legacyProxyRequests: Bool
439
+ private let legacyProxyRequestURLRegex: NSRegularExpression?
440
+ private let outboundRules: [NativeProxyRule]
441
+ private let inboundRules: [NativeProxyRule]
442
+
443
+ private lazy var session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
444
+
445
+ init(
446
+ plugin: InAppBrowserPlugin,
447
+ webviewId: String,
448
+ legacyProxyRequests: Bool,
449
+ legacyProxyRequestURLRegex: NSRegularExpression?,
450
+ outboundRules: [NativeProxyRule],
451
+ inboundRules: [NativeProxyRule]
452
+ ) {
453
+ self.plugin = plugin
454
+ self.webviewId = webviewId
455
+ self.legacyProxyRequests = legacyProxyRequests
456
+ self.legacyProxyRequestURLRegex = legacyProxyRequestURLRegex
457
+ self.outboundRules = outboundRules
458
+ self.inboundRules = inboundRules
459
+ super.init()
460
+ }
461
+
462
+ func duplicate(for webviewId: String) -> ProxySchemeHandler? {
463
+ guard let plugin else { return nil }
464
+ return ProxySchemeHandler(
465
+ plugin: plugin,
466
+ webviewId: webviewId,
467
+ legacyProxyRequests: legacyProxyRequests,
468
+ legacyProxyRequestURLRegex: legacyProxyRequestURLRegex,
469
+ outboundRules: outboundRules,
470
+ inboundRules: inboundRules
471
+ )
472
+ }
473
+
474
+ public func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
475
+ guard let url = urlSchemeTask.request.url else {
476
+ urlSchemeTask.didFailWithError(
477
+ NSError(
478
+ domain: "ProxySchemeHandler",
479
+ code: -1,
480
+ userInfo: [NSLocalizedDescriptionKey: "No URL in request"]
481
+ )
482
+ )
483
+ return
484
+ }
485
+
486
+ let requestId = UUID().uuidString
487
+ let requestContext = NativeRequestContext(
488
+ url: url.absoluteString,
489
+ method: ProxySchemeRequestSupport.normalizedRequestMethod(urlSchemeTask.request.httpMethod),
490
+ headers: urlSchemeTask.request.allHTTPHeaderFields ?? [:],
491
+ base64Body: extractBody(from: urlSchemeTask.request),
492
+ isMainFrame: ProxySchemeRequestSupport.isMainFrameRequest(urlSchemeTask.request)
493
+ )
494
+ let pendingTask = PendingProxyTask(schemeTask: urlSchemeTask, requestContext: requestContext, phase: "outbound")
495
+
496
+ taskLock.lock()
497
+ pendingTasks[requestId] = pendingTask
498
+ taskLock.unlock()
499
+
500
+ routeCurrentRequest(requestId: requestId)
501
+ }
502
+
503
+ public func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
504
+ taskLock.lock()
505
+ if let entry = pendingTasks.first(where: { $0.value.schemeTask === urlSchemeTask }) {
506
+ pendingTasks.removeValue(forKey: entry.key)
507
+ let cleanupToken = UUID()
508
+ stoppedRequests[entry.key] = cleanupToken
509
+ let sessionTask = entry.value.urlSessionTask
510
+ taskLock.unlock()
511
+ sessionTask?.cancel()
512
+ scheduleStoppedRequestCleanup(requestId: entry.key, token: cleanupToken)
513
+ return
514
+ }
515
+ taskLock.unlock()
516
+ }
517
+
518
+ func handleResponse(requestId: String, phase: String?, responseData: [String: Any]?) {
519
+ taskLock.lock()
520
+ let wasStopped = stoppedRequests.removeValue(forKey: requestId) != nil
521
+ guard let pendingTask = pendingTasks[requestId] else {
522
+ taskLock.unlock()
523
+ return
524
+ }
525
+ if let timedOutPhase = timedOutRequests[requestId], (phase ?? pendingTask.phase) == timedOutPhase {
526
+ timedOutRequests.removeValue(forKey: requestId)
527
+ taskLock.unlock()
528
+ return
529
+ }
530
+ if let phase, phase != pendingTask.phase {
531
+ taskLock.unlock()
532
+ return
533
+ }
534
+ pendingTask.timeoutToken = nil
535
+ taskLock.unlock()
536
+
537
+ if wasStopped { return }
538
+
539
+ if let responseData {
540
+ if let cancel = responseData["cancel"] as? Bool, cancel {
541
+ pendingTask.canceled = true
542
+ }
543
+
544
+ let directResponse = (responseData["response"] as? [String: Any]) ?? responseData
545
+ let hasDirectResponse = directResponse["status"] != nil
546
+ if directResponse["status"] != nil {
547
+ do {
548
+ pendingTask.responseData = try makeNativeResponse(from: directResponse)
549
+ pendingTask.redirectRequest = nil
550
+ } catch {
551
+ pendingTask.canceled = true
552
+ }
553
+ }
554
+
555
+ if ProxySchemeRequestSupport.shouldFollowDelegatedRedirect(
556
+ phase: pendingTask.phase,
557
+ hasPendingRedirect: pendingTask.redirectRequest != nil,
558
+ hasDirectResponse: hasDirectResponse,
559
+ isCanceled: pendingTask.canceled
560
+ ) {
561
+ if let requestOverride = responseData["request"] as? [String: Any] {
562
+ followPendingRedirect(requestId: requestId, requestOverride: requestOverride)
563
+ } else {
564
+ followPendingRedirect(requestId: requestId)
565
+ }
566
+ return
567
+ }
568
+
569
+ if let requestOverride = responseData["request"] as? [String: Any] {
570
+ pendingTask.requestContext = applyRequestOverride(requestOverride, to: pendingTask.requestContext)
571
+ }
572
+ }
573
+
574
+ if pendingTask.canceled {
575
+ finishWithCanceledResponse(task: pendingTask)
576
+ removePendingTask(requestId: requestId)
577
+ return
578
+ }
579
+
580
+ switch ProxySchemeRequestSupport.jsResponseResolutionAction(
581
+ phase: pendingTask.phase,
582
+ hasCachedResponse: pendingTask.responseData != nil
583
+ ) {
584
+ case .finishCachedResponse:
585
+ guard let responseData = pendingTask.responseData else { return }
586
+ removePendingTask(requestId: requestId)
587
+ syncResponseCookies(from: responseData, fallbackURL: pendingTask.requestContext.url) {
588
+ self.finish(task: pendingTask, with: responseData)
589
+ }
590
+ case .executeNativePipeline:
591
+ executeNativePipeline(requestId: requestId)
592
+ case .executeInboundDecision:
593
+ executeInboundDecision(requestId: requestId)
594
+ }
595
+ }
596
+
597
+ private func executeNativePipeline(requestId: String) {
598
+ guard let pendingTask = pendingTask(for: requestId) else { return }
599
+ let request: URLRequest
600
+ do {
601
+ request = try makeURLRequest(from: pendingTask.requestContext)
602
+ } catch {
603
+ pendingTask.schemeTask.didFailWithError(
604
+ NSError(
605
+ domain: "ProxySchemeHandler",
606
+ code: -3,
607
+ userInfo: [NSLocalizedDescriptionKey: error.localizedDescription]
608
+ )
609
+ )
610
+ removePendingTask(requestId: requestId)
611
+ return
612
+ }
613
+ let task = session.dataTask(with: request) { [weak self] data, response, error in
614
+ guard let self else { return }
615
+ guard let pendingTask = self.pendingTask(for: requestId) else { return }
616
+
617
+ if let error {
618
+ if (error as NSError).code == NSURLErrorCancelled, pendingTask.redirectRequest != nil {
619
+ return
620
+ }
621
+ if (error as NSError).code != NSURLErrorCancelled {
622
+ pendingTask.schemeTask.didFailWithError(error)
623
+ }
624
+ self.removePendingTask(requestId: requestId)
625
+ return
626
+ }
627
+
628
+ let httpResponse = response as? HTTPURLResponse
629
+ // Actual HTTP redirect status codes are the exclusive responsibility of
630
+ // urlSession(_:task:willPerformHTTPRedirection:newRequest:completionHandler:).
631
+ // When that delegate calls completionHandler(nil) to suppress URLSession's
632
+ // automatic redirect-following, URLSession still surfaces the redirect body
633
+ // to this completion handler as a normal response. Without this guard, the
634
+ // willPerformHTTPRedirection path and this completion handler both race to
635
+ // mutate pendingTask.responseData and call executeInboundDecision; the late-
636
+ // firing one overwrites the redirect-target state and ends up handing the
637
+ // empty redirect body to the WKURLSchemeTask via finish(), short-circuiting
638
+ // the actual redirect-target fetch.
639
+ //
640
+ // Scoped narrowly to the codes that actually trigger willPerformHTTPRedirection
641
+ // (301, 302, 303, 307, 308). Other 3xx — 300, 304 Not Modified, 305, 306 — are
642
+ // NOT redirects and the delegate doesn't fire for them, so they must flow
643
+ // through this completion handler normally (a 304 in particular needs the
644
+ // scheme task to be didFinish()'d with the cached/empty body, not stranded).
645
+ if let httpResponse, ProxySchemeHandler.redirectStatusCodes.contains(httpResponse.statusCode) {
646
+ return
647
+ }
648
+ let responseHeaders = ProxySchemeRequestSupport.normalizedResponseHeaders(from: httpResponse)
649
+ pendingTask.requestContext.url = ProxySchemeRequestSupport.resolvedResponseURL(
650
+ response,
651
+ fallback: pendingTask.requestContext.url
652
+ )
653
+ let nativeResponse = NativeResponseData(
654
+ statusCode: httpResponse?.statusCode ?? 200,
655
+ headers: responseHeaders,
656
+ body: data ?? Data(),
657
+ contentType: responseHeaders["Content-Type"] ?? responseHeaders["content-type"] ?? "application/octet-stream"
658
+ )
659
+ self.syncResponseCookies(
660
+ from: response,
661
+ fallbackURL: pendingTask.requestContext.url
662
+ ) {
663
+ guard let pendingTask = self.pendingTask(for: requestId) else { return }
664
+ pendingTask.responseData = nativeResponse
665
+ self.executeInboundDecision(requestId: requestId)
666
+ }
667
+ }
668
+
669
+ taskLock.lock()
670
+ pendingTasks[requestId]?.urlSessionTask = task
671
+ taskLock.unlock()
672
+ task.resume()
673
+ }
674
+
675
+ private func executeInboundDecision(requestId: String) {
676
+ guard let pendingTask = pendingTask(for: requestId), let responseData = pendingTask.responseData else { return }
677
+ pendingTask.phase = "inbound"
678
+
679
+ if let inboundRule = firstMatchingRule(for: pendingTask.requestContext, responseData: responseData, phase: "inbound") {
680
+ switch inboundRule.action {
681
+ case .cancel:
682
+ finishWithCanceledResponse(task: pendingTask)
683
+ removePendingTask(requestId: requestId)
684
+ case .continue:
685
+ if pendingTask.redirectRequest != nil {
686
+ followPendingRedirect(requestId: requestId)
687
+ } else {
688
+ finish(task: pendingTask, with: responseData)
689
+ removePendingTask(requestId: requestId)
690
+ }
691
+ case .delegateToJs:
692
+ emitProxyEvent(requestId: requestId, pendingTask: pendingTask)
693
+ scheduleTimeout(for: requestId)
694
+ }
695
+ } else {
696
+ if pendingTask.redirectRequest != nil {
697
+ followPendingRedirect(requestId: requestId)
698
+ } else {
699
+ finish(task: pendingTask, with: responseData)
700
+ removePendingTask(requestId: requestId)
701
+ }
702
+ }
703
+ }
704
+
705
+ private func emitProxyEvent(requestId: String, pendingTask: PendingProxyTask) {
706
+ var eventData: [String: Any] = [
707
+ "requestId": requestId,
708
+ "phase": pendingTask.phase,
709
+ "url": pendingTask.requestContext.url,
710
+ "method": pendingTask.requestContext.method,
711
+ "headers": pendingTask.requestContext.headers,
712
+ "body": pendingTask.requestContext.base64Body as Any,
713
+ "webviewId": webviewId
714
+ ]
715
+ if let responseData = pendingTask.responseData {
716
+ eventData["status"] = responseData.statusCode
717
+ eventData["responseHeaders"] = responseData.headers
718
+ eventData["responseBody"] = responseData.body.base64EncodedString()
719
+ }
720
+ plugin?.notifyListeners("proxyRequest", data: eventData)
721
+ }
722
+
723
+ private func scheduleTimeout(for requestId: String) {
724
+ let timeoutToken = UUID()
725
+ taskLock.lock()
726
+ guard let pendingTask = pendingTasks[requestId] else {
727
+ taskLock.unlock()
728
+ return
729
+ }
730
+ pendingTask.timeoutToken = timeoutToken
731
+ taskLock.unlock()
732
+
733
+ DispatchQueue.global().asyncAfter(deadline: .now() + proxyTimeoutSeconds) { [weak self] in
734
+ guard let self else { return }
735
+ self.taskLock.lock()
736
+ guard
737
+ let pendingTask = self.pendingTasks[requestId],
738
+ ProxySchemeRequestSupport.timeoutTokenMatches(
739
+ scheduledToken: timeoutToken,
740
+ currentToken: pendingTask.timeoutToken
741
+ )
742
+ else {
743
+ self.taskLock.unlock()
744
+ return
745
+ }
746
+ let responseData = pendingTask.responseData
747
+ let phase = pendingTask.phase
748
+ let hasPendingRedirect = pendingTask.redirectRequest != nil
749
+ pendingTask.timeoutToken = nil
750
+
751
+ switch ProxySchemeRequestSupport.timeoutResolutionAction(
752
+ phase: phase,
753
+ hasCachedResponse: responseData != nil,
754
+ hasPendingRedirect: hasPendingRedirect
755
+ ) {
756
+ case .fallbackToNative:
757
+ self.timedOutRequests[requestId] = phase
758
+ self.taskLock.unlock()
759
+ self.fallbackToNativePipeline(requestId: requestId)
760
+ return
761
+ case .finishCachedResponse:
762
+ self.pendingTasks.removeValue(forKey: requestId)
763
+ self.taskLock.unlock()
764
+ guard let responseData else { return }
765
+ self.syncResponseCookies(from: responseData, fallbackURL: pendingTask.requestContext.url) {
766
+ self.finish(task: pendingTask, with: responseData)
767
+ }
768
+ return
769
+ case .failRequest:
770
+ self.pendingTasks.removeValue(forKey: requestId)
771
+ self.taskLock.unlock()
772
+ }
773
+
774
+ pendingTask.schemeTask.didFailWithError(
775
+ NSError(
776
+ domain: "ProxySchemeHandler",
777
+ code: NSURLErrorTimedOut,
778
+ userInfo: [NSLocalizedDescriptionKey: "Proxy handler did not respond within \(Int(self.proxyTimeoutSeconds)) seconds"]
779
+ )
780
+ )
781
+ }
782
+ }
783
+
784
+ public func urlSession(
785
+ _ session: URLSession,
786
+ task: URLSessionTask,
787
+ willPerformHTTPRedirection response: HTTPURLResponse,
788
+ newRequest request: URLRequest,
789
+ completionHandler: @escaping (URLRequest?) -> Void
790
+ ) {
791
+ guard let requestId = requestId(for: task) else {
792
+ completionHandler(request)
793
+ return
794
+ }
795
+
796
+ guard let pendingTask = pendingTask(for: requestId) else {
797
+ completionHandler(nil)
798
+ return
799
+ }
800
+
801
+ let redirectResponse = NativeResponseData(
802
+ statusCode: response.statusCode,
803
+ headers: ProxySchemeRequestSupport.normalizedResponseHeaders(from: response),
804
+ body: Data(),
805
+ contentType: response.value(forHTTPHeaderField: "Content-Type") ?? "application/octet-stream"
806
+ )
807
+
808
+ pendingTask.requestContext.url = ProxySchemeRequestSupport.resolvedResponseURL(
809
+ response,
810
+ fallback: pendingTask.requestContext.url
811
+ )
812
+ pendingTask.responseData = redirectResponse
813
+ pendingTask.redirectRequest = request
814
+ completionHandler(nil)
815
+
816
+ syncResponseCookies(from: response, fallbackURL: pendingTask.requestContext.url) { [weak self] in
817
+ self?.executeInboundDecision(requestId: requestId)
818
+ }
819
+ }
820
+
821
+ private func pendingTask(for requestId: String) -> PendingProxyTask? {
822
+ taskLock.lock()
823
+ let task = pendingTasks[requestId]
824
+ taskLock.unlock()
825
+ return task
826
+ }
827
+
828
+ private func removePendingTask(requestId: String) {
829
+ taskLock.lock()
830
+ pendingTasks.removeValue(forKey: requestId)
831
+ stoppedRequests.removeValue(forKey: requestId)
832
+ timedOutRequests.removeValue(forKey: requestId)
833
+ taskLock.unlock()
834
+ }
835
+
836
+ private func routeCurrentRequest(requestId: String) {
837
+ guard let pendingTask = pendingTask(for: requestId) else { return }
838
+ pendingTask.phase = "outbound"
839
+ pendingTask.responseData = nil
840
+
841
+ if let outboundRule = firstMatchingRule(for: pendingTask.requestContext, responseData: nil, phase: "outbound") {
842
+ switch outboundRule.action {
843
+ case .cancel:
844
+ finishWithCanceledResponse(task: pendingTask)
845
+ removePendingTask(requestId: requestId)
846
+ case .continue:
847
+ executeNativePipeline(requestId: requestId)
848
+ case .delegateToJs:
849
+ emitProxyEvent(requestId: requestId, pendingTask: pendingTask)
850
+ scheduleTimeout(for: requestId)
851
+ }
852
+ } else {
853
+ executeNativePipeline(requestId: requestId)
854
+ }
855
+ }
856
+
857
+ private func followPendingRedirect(requestId: String, requestOverride: [String: Any]? = nil) {
858
+ guard let pendingTask = pendingTask(for: requestId), let redirectRequest = pendingTask.redirectRequest else {
859
+ executeNativePipeline(requestId: requestId)
860
+ return
861
+ }
862
+
863
+ var redirectContext = makeRequestContext(from: redirectRequest, fallback: pendingTask.requestContext)
864
+ if let requestOverride {
865
+ redirectContext = applyRequestOverride(requestOverride, to: redirectContext)
866
+ }
867
+
868
+ pendingTask.requestContext = redirectContext
869
+ pendingTask.responseData = nil
870
+ pendingTask.redirectRequest = nil
871
+ pendingTask.urlSessionTask = nil
872
+ routeCurrentRequest(requestId: requestId)
873
+ }
874
+
875
+ private func fallbackToNativePipeline(requestId: String) {
876
+ guard let pendingTask = pendingTask(for: requestId) else { return }
877
+ if pendingTask.phase == "inbound", pendingTask.redirectRequest != nil {
878
+ followPendingRedirect(requestId: requestId)
879
+ return
880
+ }
881
+ executeNativePipeline(requestId: requestId)
882
+ }
883
+
884
+ private func requestId(for task: URLSessionTask) -> String? {
885
+ taskLock.lock()
886
+ defer { taskLock.unlock() }
887
+ return pendingTasks.first(where: { $0.value.urlSessionTask?.taskIdentifier == task.taskIdentifier })?.key
888
+ }
889
+
890
+ private func scheduleStoppedRequestCleanup(requestId: String, token: UUID) {
891
+ DispatchQueue.global().asyncAfter(deadline: .now() + proxyTimeoutSeconds) { [weak self] in
892
+ guard let self else { return }
893
+ self.taskLock.lock()
894
+ defer { self.taskLock.unlock() }
895
+ guard self.stoppedRequests[requestId] == token else { return }
896
+ self.stoppedRequests.removeValue(forKey: requestId)
897
+ }
898
+ }
899
+
900
+ private func firstMatchingRule(for requestContext: NativeRequestContext, responseData: NativeResponseData?, phase: String) -> NativeProxyRule? {
901
+ let usesLegacyCatchAllRule = ProxySchemeRequestSupport.shouldUseLegacyCatchAllRule(
902
+ legacyProxyRequests: legacyProxyRequests,
903
+ hasOutboundRules: !outboundRules.isEmpty,
904
+ hasInboundRules: !inboundRules.isEmpty,
905
+ phase: phase
906
+ )
907
+ let rules: [NativeProxyRule]
908
+
909
+ if usesLegacyCatchAllRule {
910
+ rules = [
911
+ NativeProxyRule(
912
+ id: nil,
913
+ urlRegex: legacyProxyRequestURLRegex,
914
+ methodRegex: nil,
915
+ headerRegex: nil,
916
+ bodyRegex: nil,
917
+ statusRegex: nil,
918
+ responseHeaderRegex: nil,
919
+ responseBodyRegex: nil,
920
+ mainFrameOnly: false,
921
+ action: .delegateToJs
922
+ )
923
+ ]
924
+ } else {
925
+ rules = phase == "outbound" ? outboundRules : inboundRules
926
+ }
927
+
928
+ let serializedHeaders = serialize(headers: requestContext.headers)
929
+ let decodedBody = decodeBase64Body(requestContext.base64Body)
930
+ let serializedResponseHeaders = responseData.map { serialize(headers: $0.headers) }
931
+ let decodedResponseBody = responseData.flatMap { String(data: $0.body, encoding: .utf8) }
932
+ let status = responseData?.statusCode
933
+
934
+ return rules.first {
935
+ $0.matches(
936
+ url: requestContext.url,
937
+ method: requestContext.method,
938
+ headers: serializedHeaders,
939
+ body: decodedBody,
940
+ isMainFrame: requestContext.isMainFrame,
941
+ status: status,
942
+ responseHeaders: serializedResponseHeaders,
943
+ responseBody: decodedResponseBody
944
+ )
945
+ }
946
+ }
947
+
948
+ private func makeURLRequest(from context: NativeRequestContext) throws -> URLRequest {
949
+ guard let url = URL(string: context.url) else {
950
+ throw ProxySchemeRequestSupport.RequestBuildError.invalidURL
951
+ }
952
+ var request = URLRequest(url: url)
953
+ request.httpMethod = context.method
954
+ // URLSession will auto-decompress gzip/br/zstd responses only when
955
+ // Accept-Encoding is NOT present in the request headers (URLSession
956
+ // adds its own and owns the decompression in that case). If we forward
957
+ // an Accept-Encoding value supplied by the JS handler (or carried over
958
+ // by URLSession's own redirect-rewriting), URLSession assumes the
959
+ // caller will handle decompression and passes the raw compressed bytes
960
+ // through, which the WKURLSchemeTask then renders as gibberish in the
961
+ // WebView. Strip it so URLSession's transparent decompression kicks in.
962
+ let filteredHeaders = context.headers.filter { key, _ in
963
+ key.caseInsensitiveCompare("Accept-Encoding") != .orderedSame
964
+ }
965
+ request.allHTTPHeaderFields = filteredHeaders
966
+ if
967
+ ProxySchemeRequestSupport.supportsRequestBody(method: context.method),
968
+ let bodyData = try ProxySchemeRequestSupport.decodedRequestBody(from: context.base64Body) {
969
+ request.httpBody = bodyData
970
+ }
971
+ return request
972
+ }
973
+
974
+ private func makeRequestContext(from request: URLRequest, fallback: NativeRequestContext) -> NativeRequestContext {
975
+ let method = ProxySchemeRequestSupport.normalizedRequestMethod(request.httpMethod)
976
+ return NativeRequestContext(
977
+ url: request.url?.absoluteString ?? fallback.url,
978
+ method: method,
979
+ headers: ProxySchemeRequestSupport.resolvedRedirectHeaders(request.allHTTPHeaderFields, fallback: fallback.headers),
980
+ base64Body: ProxySchemeRequestSupport.resolvedRedirectBody(
981
+ extractBody(from: request),
982
+ method: method,
983
+ fallback: fallback.base64Body
984
+ ),
985
+ isMainFrame: ProxySchemeRequestSupport.isMainFrameRequest(request, fallback: fallback.isMainFrame)
986
+ )
987
+ }
988
+
989
+ private func makeNativeResponse(from responseData: [String: Any]) throws -> NativeResponseData {
990
+ let status = responseData["status"] as? Int ?? 200
991
+ let headers = responseData["headers"] as? [String: String] ?? [:]
992
+ let body = try ProxySchemeRequestSupport.decodedResponseBody(from: responseData["body"] as? String)
993
+ return NativeResponseData(
994
+ statusCode: status,
995
+ headers: headers,
996
+ body: body,
997
+ contentType: headers["Content-Type"] ?? headers["content-type"] ?? "application/octet-stream"
998
+ )
999
+ }
1000
+
1001
+ private func applyRequestOverride(_ override: [String: Any], to context: NativeRequestContext) -> NativeRequestContext {
1002
+ let resolvedURL = ProxySchemeRequestSupport.sanitizedOverrideURL(override["url"] as? String, fallback: context.url)
1003
+ let resolvedMethod = ProxySchemeRequestSupport.normalizedRequestMethod((override["method"] as? String) ?? context.method)
1004
+ let resolvedHeaders = (override["headers"] as? [String: String]) ??
1005
+ ProxySchemeRequestSupport.prepareOverrideHeaders(
1006
+ originalHeaders: context.headers,
1007
+ requestURL: context.url,
1008
+ overrideURL: resolvedURL
1009
+ )
1010
+ let resolvedBody = ProxySchemeRequestSupport.resolvedOverrideBody(
1011
+ from: override,
1012
+ method: resolvedMethod,
1013
+ fallback: context.base64Body
1014
+ )
1015
+
1016
+ return NativeRequestContext(
1017
+ url: resolvedURL,
1018
+ method: resolvedMethod,
1019
+ headers: resolvedHeaders,
1020
+ base64Body: resolvedBody,
1021
+ isMainFrame: context.isMainFrame
1022
+ )
1023
+ }
1024
+
1025
+ private func finish(task: PendingProxyTask, with responseData: NativeResponseData) {
1026
+ guard let url = URL(string: task.requestContext.url),
1027
+ let httpResponse = HTTPURLResponse(
1028
+ url: url,
1029
+ statusCode: responseData.statusCode,
1030
+ httpVersion: "HTTP/1.1",
1031
+ headerFields: responseData.headers
1032
+ )
1033
+ else {
1034
+ task.schemeTask.didFailWithError(
1035
+ NSError(
1036
+ domain: "ProxySchemeHandler",
1037
+ code: -2,
1038
+ userInfo: [NSLocalizedDescriptionKey: "Failed to create response"]
1039
+ )
1040
+ )
1041
+ return
1042
+ }
1043
+ task.schemeTask.didReceive(httpResponse)
1044
+ task.schemeTask.didReceive(responseData.body)
1045
+ task.schemeTask.didFinish()
1046
+ }
1047
+
1048
+ private func finishWithCanceledResponse(task: PendingProxyTask) {
1049
+ let responseData = NativeResponseData(statusCode: 204, headers: [:], body: Data(), contentType: "text/plain")
1050
+ finish(task: task, with: responseData)
1051
+ }
1052
+
1053
+ private func syncResponseCookies(from response: URLResponse?, fallbackURL: String, completion: @escaping () -> Void) {
1054
+ guard !fallbackURL.isEmpty else {
1055
+ completion()
1056
+ return
1057
+ }
1058
+ let cookies = ProxySchemeRequestSupport.responseCookies(
1059
+ response: response,
1060
+ fallback: fallbackURL
1061
+ )
1062
+ guard !cookies.isEmpty else {
1063
+ completion()
1064
+ return
1065
+ }
1066
+ writeCookiesOnMain(cookies, completion: completion)
1067
+ }
1068
+
1069
+ private func syncResponseCookies(from responseData: NativeResponseData, fallbackURL: String, completion: @escaping () -> Void) {
1070
+ guard !fallbackURL.isEmpty else {
1071
+ completion()
1072
+ return
1073
+ }
1074
+ let cookies = ProxySchemeRequestSupport.responseCookies(from: responseData.headers, fallback: fallbackURL)
1075
+ guard !cookies.isEmpty else {
1076
+ completion()
1077
+ return
1078
+ }
1079
+ writeCookiesOnMain(cookies, completion: completion)
1080
+ }
1081
+
1082
+ /// Writes cookies into the WebView cookie store on the main thread.
1083
+ ///
1084
+ /// `WKWebsiteDataStore.httpCookieStore` and `WKHTTPCookieStore.setCookie` are both
1085
+ /// `@MainActor`-only on iOS 17+. URLSession delegate callbacks (including
1086
+ /// `urlSession(_:task:willPerformHTTPRedirection:newRequest:)`) and the timeout
1087
+ /// closure dispatched via `DispatchQueue.global().asyncAfter` run on background
1088
+ /// queues, so reaching the cookie store from there triggers Main Thread Checker
1089
+ /// warnings and risks runtime crashes in release builds. Hop to main here so all
1090
+ /// `syncResponseCookies` callers stay correct regardless of their own thread context.
1091
+ private func writeCookiesOnMain(_ cookies: [HTTPCookie], completion: @escaping () -> Void) {
1092
+ DispatchQueue.main.async { [weak self] in
1093
+ guard
1094
+ let self,
1095
+ let plugin = self.plugin,
1096
+ let cookieStore = plugin.cookieStore(for: self.webviewId)
1097
+ else {
1098
+ completion()
1099
+ return
1100
+ }
1101
+ let group = DispatchGroup()
1102
+ for cookie in cookies {
1103
+ group.enter()
1104
+ cookieStore.setCookie(cookie) {
1105
+ group.leave()
1106
+ }
1107
+ }
1108
+ group.notify(queue: .main) {
1109
+ completion()
1110
+ }
1111
+ }
1112
+ }
1113
+
1114
+ private func extractBody(from request: URLRequest) -> String? {
1115
+ if let body = request.httpBody {
1116
+ return body.base64EncodedString()
1117
+ }
1118
+ if let stream = request.httpBodyStream {
1119
+ let data = Data(reading: stream)
1120
+ return data.isEmpty ? nil : data.base64EncodedString()
1121
+ }
1122
+ return nil
1123
+ }
1124
+
1125
+ private func decodeBase64Body(_ body: String?) -> String? {
1126
+ guard let body, let data = Data(base64Encoded: body) else { return nil }
1127
+ return String(data: data, encoding: .utf8)
1128
+ }
1129
+
1130
+ private func serialize(headers: [String: String]) -> String {
1131
+ guard let data = try? JSONSerialization.data(withJSONObject: headers, options: [.sortedKeys]),
1132
+ let string = String(data: data, encoding: .utf8) else {
1133
+ return "{}"
1134
+ }
1135
+ return string
1136
+ }
1137
+
1138
+ func cancelAllPendingTasks() {
1139
+ taskLock.lock()
1140
+ let pending = pendingTasks
1141
+ pendingTasks.removeAll()
1142
+ stoppedRequests.removeAll()
1143
+ taskLock.unlock()
1144
+ session.invalidateAndCancel()
1145
+
1146
+ for (_, task) in pending {
1147
+ task.urlSessionTask?.cancel()
1148
+ task.schemeTask.didFailWithError(
1149
+ NSError(
1150
+ domain: "ProxySchemeHandler",
1151
+ code: NSURLErrorCancelled,
1152
+ userInfo: [NSLocalizedDescriptionKey: "WebView closed"]
1153
+ )
1154
+ )
1155
+ }
1156
+ }
1157
+
1158
+ func hasPendingProxyRequest(_ requestId: String) -> Bool {
1159
+ taskLock.lock()
1160
+ defer { taskLock.unlock() }
1161
+ return pendingTasks[requestId] != nil
1162
+ }
1163
+ }
1164
+ // swiftlint:enable type_body_length
1165
+
1166
+ extension ProxySchemeHandler: ProxyRequestLocating {}
1167
+
1168
+ extension Data {
1169
+ init(reading input: InputStream) {
1170
+ self.init()
1171
+ input.open()
1172
+ let bufferSize = 1024
1173
+ let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
1174
+ while input.hasBytesAvailable {
1175
+ let read = input.read(buffer, maxLength: bufferSize)
1176
+ if read < 0 {
1177
+ break
1178
+ }
1179
+ self.append(buffer, count: read)
1180
+ }
1181
+ buffer.deallocate()
1182
+ input.close()
1183
+ }
1184
+ }
1185
+ // swiftlint:enable file_length