@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.
- package/CapgoCapacitorInappbrowser.podspec +17 -0
- package/LICENSE +373 -0
- package/Package.swift +28 -0
- package/README.md +1610 -0
- package/android/build.gradle +64 -0
- package/android/src/main/AndroidManifest.xml +22 -0
- package/android/src/main/assets/proxy-bridge.js +586 -0
- package/android/src/main/java/ee/forgr/capacitor_inappbrowser/ActiveWebViewSupport.java +10 -0
- package/android/src/main/java/ee/forgr/capacitor_inappbrowser/CustomSchemeInterceptSupport.java +51 -0
- package/android/src/main/java/ee/forgr/capacitor_inappbrowser/InAppBrowserPlugin.java +1820 -0
- package/android/src/main/java/ee/forgr/capacitor_inappbrowser/MobileAppBridgeScript.java +122 -0
- package/android/src/main/java/ee/forgr/capacitor_inappbrowser/NativeProxyRule.java +128 -0
- package/android/src/main/java/ee/forgr/capacitor_inappbrowser/Options.java +725 -0
- package/android/src/main/java/ee/forgr/capacitor_inappbrowser/ProxyBridge.java +93 -0
- package/android/src/main/java/ee/forgr/capacitor_inappbrowser/ProxyRequestSupport.java +869 -0
- package/android/src/main/java/ee/forgr/capacitor_inappbrowser/ProxyResponseRouting.java +35 -0
- package/android/src/main/java/ee/forgr/capacitor_inappbrowser/SafeAreaInsetsSupport.java +28 -0
- package/android/src/main/java/ee/forgr/capacitor_inappbrowser/WebViewCallbacks.java +42 -0
- package/android/src/main/java/ee/forgr/capacitor_inappbrowser/WebViewDialog.java +6022 -0
- package/android/src/main/java/ee/forgr/capacitor_inappbrowser/WebViewPermissionSupport.java +58 -0
- package/android/src/main/res/.gitkeep +0 -0
- package/android/src/main/res/drawable/arrow_back_disabled.xml +9 -0
- package/android/src/main/res/drawable/arrow_back_enabled.xml +9 -0
- package/android/src/main/res/drawable/arrow_forward_disabled.xml +9 -0
- package/android/src/main/res/drawable/arrow_forward_enabled.xml +9 -0
- package/android/src/main/res/drawable/ic_clear_24px.xml +9 -0
- package/android/src/main/res/drawable/ic_refresh.xml +9 -0
- package/android/src/main/res/drawable/ic_share.xml +10 -0
- package/android/src/main/res/layout/activity_browser.xml +32 -0
- package/android/src/main/res/layout/bridge_layout_main.xml +15 -0
- package/android/src/main/res/layout/content_browser.xml +17 -0
- package/android/src/main/res/layout/tool_bar.xml +87 -0
- package/android/src/main/res/values/browser_theme.xml +3 -0
- package/android/src/main/res/values/colors.xml +5 -0
- package/android/src/main/res/values/dimens.xml +3 -0
- package/android/src/main/res/values/strings.xml +13 -0
- package/android/src/main/res/values/styles.xml +4 -0
- package/android/src/main/res/values/themes.xml +27 -0
- package/android/src/main/res/xml/file_paths.xml +14 -0
- package/dist/docs.json +3200 -0
- package/dist/esm/definitions.d.ts +1507 -0
- package/dist/esm/definitions.js +40 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +6 -0
- package/dist/esm/index.js +102 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +47 -0
- package/dist/esm/web.js +120 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +270 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +273 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/InAppBrowserPlugin/Enums.swift +65 -0
- package/ios/Sources/InAppBrowserPlugin/InAppBrowserPlugin.swift +1932 -0
- package/ios/Sources/InAppBrowserPlugin/ProxySchemeHandler.swift +1185 -0
- package/ios/Sources/InAppBrowserPlugin/WKWebView+SchemeHandling.swift +43 -0
- package/ios/Sources/InAppBrowserPlugin/WKWebViewController.swift +3032 -0
- 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
|