@capgo/capacitor-inappbrowser 8.7.6 → 8.7.8
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/android/src/main/java/ee/forgr/capacitor_inappbrowser/CustomSchemeInterceptSupport.java +3 -1
- package/android/src/main/java/ee/forgr/capacitor_inappbrowser/HtmlDataUrlSupport.java +49 -0
- package/android/src/main/java/ee/forgr/capacitor_inappbrowser/ProxyRequestSupport.java +3 -0
- package/android/src/main/java/ee/forgr/capacitor_inappbrowser/WebViewDialog.java +29 -1
- package/ios/Sources/InAppBrowserPlugin/HtmlDataUrlSupport.swift +36 -0
- package/ios/Sources/InAppBrowserPlugin/InAppBrowserPlugin.swift +33 -12
- package/ios/Sources/InAppBrowserPlugin/WKWebViewController.swift +399 -8
- package/package.json +1 -1
package/android/src/main/java/ee/forgr/capacitor_inappbrowser/CustomSchemeInterceptSupport.java
CHANGED
|
@@ -7,7 +7,9 @@ import java.util.Set;
|
|
|
7
7
|
|
|
8
8
|
final class CustomSchemeInterceptSupport {
|
|
9
9
|
|
|
10
|
-
private static final Set<String> EXCLUDED_SCHEMES = new HashSet<>(
|
|
10
|
+
private static final Set<String> EXCLUDED_SCHEMES = new HashSet<>(
|
|
11
|
+
Arrays.asList("http", "https", "file", "data", "tel", "mailto", "sms")
|
|
12
|
+
);
|
|
11
13
|
|
|
12
14
|
private CustomSchemeInterceptSupport() {}
|
|
13
15
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
package ee.forgr.capacitor_inappbrowser;
|
|
2
|
+
|
|
3
|
+
import java.nio.charset.StandardCharsets;
|
|
4
|
+
import java.util.Base64;
|
|
5
|
+
import java.util.Locale;
|
|
6
|
+
|
|
7
|
+
final class HtmlDataUrlSupport {
|
|
8
|
+
|
|
9
|
+
private static final String MIME_PREFIX = "data:text/html";
|
|
10
|
+
private static final String BASE64_MARKER = ";base64,";
|
|
11
|
+
|
|
12
|
+
private HtmlDataUrlSupport() {}
|
|
13
|
+
|
|
14
|
+
static boolean isHtmlBase64DataUrl(String url) {
|
|
15
|
+
return parseHtml(url) != null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
static boolean isDataUrl(String url) {
|
|
19
|
+
return url != null && url.toLowerCase(Locale.ROOT).startsWith("data:");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static String parseHtml(String url) {
|
|
23
|
+
if (url == null || url.isEmpty()) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
String lower = url.toLowerCase(Locale.ROOT);
|
|
28
|
+
if (!lower.startsWith(MIME_PREFIX)) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
int base64Marker = lower.indexOf(BASE64_MARKER);
|
|
33
|
+
if (base64Marker < 0) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
String payload = url.substring(base64Marker + BASE64_MARKER.length());
|
|
38
|
+
if (payload.isEmpty()) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
byte[] decoded = Base64.getDecoder().decode(payload);
|
|
44
|
+
return new String(decoded, StandardCharsets.UTF_8);
|
|
45
|
+
} catch (IllegalArgumentException ignored) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -120,6 +120,9 @@ final class ProxyRequestSupport {
|
|
|
120
120
|
if (initialUrl == null || initialUrl.isBlank()) {
|
|
121
121
|
return false;
|
|
122
122
|
}
|
|
123
|
+
if (HtmlDataUrlSupport.isDataUrl(initialUrl)) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
123
126
|
if (!options.shouldEnableNativeProxy()) {
|
|
124
127
|
return false;
|
|
125
128
|
}
|
|
@@ -2718,7 +2718,9 @@ public class WebViewDialog extends Dialog implements ProxyResponseRouting.ProxyR
|
|
|
2718
2718
|
String httpBody = _options.getHttpBody();
|
|
2719
2719
|
|
|
2720
2720
|
if (!_options.isPopupWindowMode()) {
|
|
2721
|
-
if (
|
|
2721
|
+
if (loadHtmlDataUrlIfNeeded(this._options.getUrl())) {
|
|
2722
|
+
// Inline HTML loaded from data:text/html;base64 URL.
|
|
2723
|
+
} else if (shouldBootstrapInitialLegacyProxyLoad()) {
|
|
2722
2724
|
loadInitialLegacyProxyContent(requestHeaders, httpMethod, httpBody);
|
|
2723
2725
|
} else if (supportsRequestBody(httpMethod) && httpBody != null) {
|
|
2724
2726
|
// For POST/PUT/PATCH requests with body
|
|
@@ -3832,6 +3834,10 @@ public class WebViewDialog extends Dialog implements ProxyResponseRouting.ProxyR
|
|
|
3832
3834
|
}
|
|
3833
3835
|
|
|
3834
3836
|
try {
|
|
3837
|
+
if (loadHtmlDataUrlIfNeeded(url)) {
|
|
3838
|
+
return;
|
|
3839
|
+
}
|
|
3840
|
+
|
|
3835
3841
|
Map<String, String> requestHeaders = new HashMap<>();
|
|
3836
3842
|
if (_options.getHeaders() != null) {
|
|
3837
3843
|
Iterator<String> keys = _options.getHeaders().keys();
|
|
@@ -4663,6 +4669,10 @@ public class WebViewDialog extends Dialog implements ProxyResponseRouting.ProxyR
|
|
|
4663
4669
|
String url = request.getUrl().toString();
|
|
4664
4670
|
Log.d("InAppBrowser", "shouldOverrideUrlLoading: " + url);
|
|
4665
4671
|
|
|
4672
|
+
if (HtmlDataUrlSupport.isDataUrl(url)) {
|
|
4673
|
+
return false;
|
|
4674
|
+
}
|
|
4675
|
+
|
|
4666
4676
|
boolean isNotHttpOrHttps = !url.startsWith("https://") && !url.startsWith("http://");
|
|
4667
4677
|
|
|
4668
4678
|
// If preventDeeplink is true, don't handle any non-http(s) URLs
|
|
@@ -5769,6 +5779,20 @@ public class WebViewDialog extends Dialog implements ProxyResponseRouting.ProxyR
|
|
|
5769
5779
|
.replace("___CAPGO_PROXY_REGEX___", escapeJavaScriptLiteral(proxyRegexSource));
|
|
5770
5780
|
}
|
|
5771
5781
|
|
|
5782
|
+
private boolean loadHtmlDataUrlIfNeeded(String url) {
|
|
5783
|
+
if (_webView == null) {
|
|
5784
|
+
return false;
|
|
5785
|
+
}
|
|
5786
|
+
|
|
5787
|
+
String html = HtmlDataUrlSupport.parseHtml(url);
|
|
5788
|
+
if (html == null) {
|
|
5789
|
+
return false;
|
|
5790
|
+
}
|
|
5791
|
+
|
|
5792
|
+
_webView.loadDataWithBaseURL("about:blank", html, "text/html", "utf-8", null);
|
|
5793
|
+
return true;
|
|
5794
|
+
}
|
|
5795
|
+
|
|
5772
5796
|
private boolean shouldUseNativeProxy() {
|
|
5773
5797
|
return _options != null && _options.shouldEnableNativeProxy();
|
|
5774
5798
|
}
|
|
@@ -5782,6 +5806,10 @@ public class WebViewDialog extends Dialog implements ProxyResponseRouting.ProxyR
|
|
|
5782
5806
|
return;
|
|
5783
5807
|
}
|
|
5784
5808
|
|
|
5809
|
+
if (loadHtmlDataUrlIfNeeded(_options.getUrl())) {
|
|
5810
|
+
return;
|
|
5811
|
+
}
|
|
5812
|
+
|
|
5785
5813
|
Map<String, String> headers = requestHeaders != null ? requestHeaders : new HashMap<>();
|
|
5786
5814
|
if (supportsRequestBody(httpMethod) && httpBody != null) {
|
|
5787
5815
|
byte[] postData = httpBody.getBytes(StandardCharsets.UTF_8);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
enum HtmlDataUrlSupport {
|
|
4
|
+
private static let mimePrefix = "data:text/html"
|
|
5
|
+
private static let base64Marker = ";base64,"
|
|
6
|
+
|
|
7
|
+
static func isHtmlBase64DataUrl(_ urlString: String) -> Bool {
|
|
8
|
+
parseHtml(from: urlString) != nil
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
static func isDataUrl(_ urlString: String) -> Bool {
|
|
12
|
+
urlString.lowercased().hasPrefix("data:")
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
static func parseHtml(from urlString: String) -> String? {
|
|
16
|
+
let lowercased = urlString.lowercased()
|
|
17
|
+
guard lowercased.hasPrefix(mimePrefix) else {
|
|
18
|
+
return nil
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
guard let base64Range = lowercased.range(of: base64Marker) else {
|
|
22
|
+
return nil
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let payloadStart = urlString.index(
|
|
26
|
+
urlString.startIndex,
|
|
27
|
+
offsetBy: lowercased.distance(from: lowercased.startIndex, to: base64Range.upperBound)
|
|
28
|
+
)
|
|
29
|
+
let payload = String(urlString[payloadStart...])
|
|
30
|
+
guard !payload.isEmpty, let data = Data(base64Encoded: payload) else {
|
|
31
|
+
return nil
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return String(data: data, encoding: .utf8)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -98,7 +98,7 @@ public class CapgoInAppBrowserPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
98
98
|
case aware = "AWARE"
|
|
99
99
|
case fakeVisible = "FAKE_VISIBLE"
|
|
100
100
|
}
|
|
101
|
-
private let pluginVersion: String = "8.7.
|
|
101
|
+
private let pluginVersion: String = "8.7.8"
|
|
102
102
|
public let identifier = "CapgoInAppBrowserPlugin"
|
|
103
103
|
public let jsName = "CapgoInAppBrowser"
|
|
104
104
|
public let pluginMethods: [CAPPluginMethod] = [
|
|
@@ -832,6 +832,17 @@ public class CapgoInAppBrowserPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
832
832
|
|
|
833
833
|
}
|
|
834
834
|
|
|
835
|
+
private func webSource(for urlString: String) -> WKWebSource? {
|
|
836
|
+
if let html = HtmlDataUrlSupport.parseHtml(from: urlString) {
|
|
837
|
+
return .string(html, base: nil)
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
guard let url = URL(string: urlString) else {
|
|
841
|
+
return nil
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
return .remote(url)
|
|
845
|
+
}
|
|
835
846
|
@objc func openWebView(_ call: CAPPluginCall) {
|
|
836
847
|
if !self.isSetupDone {
|
|
837
848
|
self.setup()
|
|
@@ -1087,7 +1098,7 @@ public class CapgoInAppBrowserPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1087
1098
|
}
|
|
1088
1099
|
|
|
1089
1100
|
DispatchQueue.main.async {
|
|
1090
|
-
guard let
|
|
1101
|
+
guard let webSource = self.webSource(for: urlString) else {
|
|
1091
1102
|
call.reject("Invalid URL format")
|
|
1092
1103
|
return
|
|
1093
1104
|
}
|
|
@@ -1116,7 +1127,7 @@ public class CapgoInAppBrowserPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1116
1127
|
webViewController.blankNavigationTab = toolbarType == "blank"
|
|
1117
1128
|
webViewController.enabledSafeBottomMargin = enabledSafeBottomMargin
|
|
1118
1129
|
webViewController.enabledSafeTopMargin = enabledSafeTopMargin
|
|
1119
|
-
webViewController.source =
|
|
1130
|
+
webViewController.source = webSource
|
|
1120
1131
|
webViewController.setCredentials(credentials: credentials)
|
|
1121
1132
|
webViewController.allowWebViewJsVisibilityControl = allowWebViewJsVisibilityControl
|
|
1122
1133
|
webViewController.allowScreenshotsFromWebPage = allowScreenshotsFromWebPage
|
|
@@ -1185,7 +1196,7 @@ public class CapgoInAppBrowserPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1185
1196
|
}
|
|
1186
1197
|
}
|
|
1187
1198
|
|
|
1188
|
-
webViewController.source =
|
|
1199
|
+
webViewController.source = webSource
|
|
1189
1200
|
webViewController.leftNavigationBarItemTypes = []
|
|
1190
1201
|
|
|
1191
1202
|
// Configure close button based on showArrow
|
|
@@ -1505,17 +1516,25 @@ public class CapgoInAppBrowserPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1505
1516
|
return
|
|
1506
1517
|
}
|
|
1507
1518
|
|
|
1508
|
-
guard let url = URL(string: urlString) else {
|
|
1509
|
-
call.reject("Invalid URL")
|
|
1510
|
-
return
|
|
1511
|
-
}
|
|
1512
|
-
|
|
1513
1519
|
let targetId = call.getString("id")
|
|
1514
1520
|
guard let webViewController = self.resolveWebViewController(for: targetId) else {
|
|
1515
1521
|
call.reject("WebView is not initialized")
|
|
1516
1522
|
return
|
|
1517
1523
|
}
|
|
1518
1524
|
|
|
1525
|
+
if let html = HtmlDataUrlSupport.parseHtml(from: urlString) {
|
|
1526
|
+
webViewController.source = .string(html, base: nil)
|
|
1527
|
+
webViewController.load(string: html, base: nil)
|
|
1528
|
+
call.resolve()
|
|
1529
|
+
return
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
guard let url = URL(string: urlString) else {
|
|
1533
|
+
call.reject("Invalid URL")
|
|
1534
|
+
return
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
webViewController.source = .remote(url)
|
|
1519
1538
|
webViewController.load(remote: url)
|
|
1520
1539
|
call.resolve()
|
|
1521
1540
|
}
|
|
@@ -1985,12 +2004,14 @@ public class CapgoInAppBrowserPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1985
2004
|
let credentials = self.readCredentials(call)
|
|
1986
2005
|
|
|
1987
2006
|
DispatchQueue.main.async {
|
|
1988
|
-
guard let
|
|
2007
|
+
guard let webSource = self.webSource(for: urlString) else {
|
|
1989
2008
|
call.reject("Invalid URL format")
|
|
1990
2009
|
return
|
|
1991
2010
|
}
|
|
1992
2011
|
|
|
1993
|
-
|
|
2012
|
+
let initialUrl = webSource.remoteURL ?? URL(string: "about:blank")!
|
|
2013
|
+
|
|
2014
|
+
self.webViewController = WKWebViewController.init(url: initialUrl, headers: headers, isInspectable: isInspectable, credentials: credentials, preventDeeplink: preventDeeplink, blankNavigationTab: true, enabledSafeBottomMargin: false, enabledSafeTopMargin: true)
|
|
1994
2015
|
|
|
1995
2016
|
guard let webViewController = self.webViewController else {
|
|
1996
2017
|
call.reject("Failed to initialize WebViewController")
|
|
@@ -2007,7 +2028,7 @@ public class CapgoInAppBrowserPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
2007
2028
|
}
|
|
2008
2029
|
}
|
|
2009
2030
|
|
|
2010
|
-
webViewController.source =
|
|
2031
|
+
webViewController.source = webSource
|
|
2011
2032
|
webViewController.leftNavigationBarItemTypes = [.back, .forward, .reload]
|
|
2012
2033
|
webViewController.capBrowserPlugin = self
|
|
2013
2034
|
webViewController.hasDynamicTitle = true
|
|
@@ -16,7 +16,7 @@ private let cookieKey = "Cookie"
|
|
|
16
16
|
|
|
17
17
|
enum CustomSchemeInterceptSupport {
|
|
18
18
|
static let standardHandledSchemes = ["tel", "mailto", "sms"]
|
|
19
|
-
private static let webSchemes = ["http", "https", "file"]
|
|
19
|
+
private static let webSchemes = ["http", "https", "file", "data"]
|
|
20
20
|
|
|
21
21
|
static func shouldEmitInterceptEvent(for url: URL) -> Bool {
|
|
22
22
|
guard let scheme = url.scheme?.lowercased(), !scheme.isEmpty else {
|
|
@@ -42,6 +42,20 @@ private enum DownloadReservationStore {
|
|
|
42
42
|
static let lock = NSLock()
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
private enum BlobDownloadSupport {
|
|
46
|
+
static let maxLegacyBytes = 512 * 1024
|
|
47
|
+
static let chunkBytes = 64 * 1024
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private struct BlobDownloadSession {
|
|
51
|
+
let destinationURL: URL
|
|
52
|
+
let sourceURL: String?
|
|
53
|
+
let mimeType: String?
|
|
54
|
+
let fileHandle: FileHandle
|
|
55
|
+
let expectedSize: Int64?
|
|
56
|
+
var bytesWritten: Int64 = 0
|
|
57
|
+
}
|
|
58
|
+
|
|
45
59
|
public struct WKWebViewCredentials {
|
|
46
60
|
var username: String
|
|
47
61
|
var password: String
|
|
@@ -456,6 +470,7 @@ open class WKWebViewController: UIViewController, WKScriptMessageHandler {
|
|
|
456
470
|
|
|
457
471
|
private var lastInjectedSafeAreaInsets: InjectedSafeAreaInsets?
|
|
458
472
|
private var downloadStates: [ObjectIdentifier: WKDownloadState] = [:]
|
|
473
|
+
private var blobDownloadSessions: [String: BlobDownloadSession] = [:]
|
|
459
474
|
private var previewItemURL: URL?
|
|
460
475
|
|
|
461
476
|
func setHeaders(headers: [String: String]) {
|
|
@@ -496,7 +511,22 @@ open class WKWebViewController: UIViewController, WKScriptMessageHandler {
|
|
|
496
511
|
return nil
|
|
497
512
|
}
|
|
498
513
|
|
|
499
|
-
|
|
514
|
+
if let value = httpResponse.value(forHTTPHeaderField: "Content-Disposition"),
|
|
515
|
+
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
516
|
+
return value
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
for (key, value) in httpResponse.allHeaderFields {
|
|
520
|
+
guard let headerName = key as? String,
|
|
521
|
+
headerName.lowercased() == "content-disposition",
|
|
522
|
+
let headerValue = value as? String,
|
|
523
|
+
!headerValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
524
|
+
continue
|
|
525
|
+
}
|
|
526
|
+
return headerValue
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return nil
|
|
500
530
|
}
|
|
501
531
|
|
|
502
532
|
private func attachmentDispositionType(_ response: URLResponse) -> String? {
|
|
@@ -703,6 +733,329 @@ open class WKWebViewController: UIViewController, WKScriptMessageHandler {
|
|
|
703
733
|
}
|
|
704
734
|
}
|
|
705
735
|
|
|
736
|
+
|
|
737
|
+
private func parseBlobBridgePayload(_ payload: Any) -> [String: Any]? {
|
|
738
|
+
if let dictionary = payload as? [String: Any] {
|
|
739
|
+
return dictionary
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if let jsonString = payload as? String,
|
|
743
|
+
let data = jsonString.data(using: .utf8),
|
|
744
|
+
let dictionary = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
745
|
+
return dictionary
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
return nil
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
private func blobDownloadFileName(from payload: [String: Any], fallback: String = "download") -> String {
|
|
752
|
+
sanitizeDownloadFilename(payload["fileName"] as? String ?? fallback)
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
private func cleanupBlobDownloadSession(_ session: BlobDownloadSession?, deleteFile: Bool) {
|
|
756
|
+
guard let session else {
|
|
757
|
+
return
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
try? session.fileHandle.close()
|
|
761
|
+
if deleteFile {
|
|
762
|
+
try? FileManager.default.removeItem(at: session.destinationURL)
|
|
763
|
+
releaseDownloadDestination(session.destinationURL)
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
private func abortBlobDownloadSession(sessionId: String, deleteFile: Bool) -> BlobDownloadSession? {
|
|
768
|
+
guard let session = blobDownloadSessions.removeValue(forKey: sessionId) else {
|
|
769
|
+
return nil
|
|
770
|
+
}
|
|
771
|
+
cleanupBlobDownloadSession(session, deleteFile: deleteFile)
|
|
772
|
+
return session
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
private func handleLegacyBlobDownloadPayload(_ payload: Any) {
|
|
776
|
+
guard handleDownloads else {
|
|
777
|
+
return
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
guard let jsonPayload = parseBlobBridgePayload(payload) else {
|
|
781
|
+
emitDownloadFailed(sourceURL: nil, error: "Blob download payload is missing")
|
|
782
|
+
return
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
let base64 = jsonPayload["base64"] as? String ?? ""
|
|
786
|
+
guard !base64.isEmpty else {
|
|
787
|
+
emitDownloadFailed(sourceURL: jsonPayload["sourceUrl"] as? String, error: "Blob download payload is empty")
|
|
788
|
+
return
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
let declaredSize = (jsonPayload["size"] as? NSNumber)?.int64Value
|
|
792
|
+
let estimatedSize = Int64((Double(base64.count) * 3.0 / 4.0).rounded(.up))
|
|
793
|
+
let decodedSize = max(declaredSize ?? estimatedSize, estimatedSize)
|
|
794
|
+
if decodedSize > Int64(BlobDownloadSupport.maxLegacyBytes) {
|
|
795
|
+
emitDownloadFailed(
|
|
796
|
+
sourceURL: jsonPayload["sourceUrl"] as? String,
|
|
797
|
+
fileName: blobDownloadFileName(from: jsonPayload),
|
|
798
|
+
mimeType: jsonPayload["mimeType"] as? String,
|
|
799
|
+
error: "Blob download is too large for the legacy bridge"
|
|
800
|
+
)
|
|
801
|
+
return
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
do {
|
|
805
|
+
guard let data = Data(base64Encoded: base64) else {
|
|
806
|
+
throw NSError(domain: "InAppBrowser", code: 1, userInfo: [NSLocalizedDescriptionKey: "Blob download payload is invalid"])
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
let fileName = blobDownloadFileName(from: jsonPayload)
|
|
810
|
+
let destinationURL = try uniqueDownloadDestination(for: fileName)
|
|
811
|
+
try data.write(to: destinationURL, options: .atomic)
|
|
812
|
+
let mimeType = jsonPayload["mimeType"] as? String
|
|
813
|
+
let sourceURL = jsonPayload["sourceUrl"] as? String
|
|
814
|
+
previewDownloadedFile(destinationURL, mimeType: mimeType, sourceURL: sourceURL)
|
|
815
|
+
} catch {
|
|
816
|
+
emitDownloadFailed(
|
|
817
|
+
sourceURL: jsonPayload["sourceUrl"] as? String,
|
|
818
|
+
fileName: blobDownloadFileName(from: jsonPayload),
|
|
819
|
+
mimeType: jsonPayload["mimeType"] as? String,
|
|
820
|
+
error: "Failed to save blob download: \(error.localizedDescription)"
|
|
821
|
+
)
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
private func startBlobDownloadPayload(_ payload: Any) {
|
|
826
|
+
guard handleDownloads else {
|
|
827
|
+
return
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
do {
|
|
831
|
+
guard let jsonPayload = parseBlobBridgePayload(payload) else {
|
|
832
|
+
throw NSError(domain: "InAppBrowser", code: 1, userInfo: [NSLocalizedDescriptionKey: "Blob download start payload is missing"])
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
let sessionId = jsonPayload["sessionId"] as? String ?? ""
|
|
836
|
+
guard !sessionId.isEmpty else {
|
|
837
|
+
throw NSError(domain: "InAppBrowser", code: 1, userInfo: [NSLocalizedDescriptionKey: "Blob download session id is missing"])
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if blobDownloadSessions[sessionId] != nil {
|
|
841
|
+
throw NSError(domain: "InAppBrowser", code: 1, userInfo: [NSLocalizedDescriptionKey: "Blob download session already exists"])
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
let fileName = blobDownloadFileName(from: jsonPayload)
|
|
845
|
+
let destinationURL = try uniqueDownloadDestination(for: fileName)
|
|
846
|
+
let fileHandle = try FileHandle(forWritingTo: destinationURL)
|
|
847
|
+
let expectedSize = (jsonPayload["size"] as? NSNumber)?.int64Value
|
|
848
|
+
blobDownloadSessions[sessionId] = BlobDownloadSession(
|
|
849
|
+
destinationURL: destinationURL,
|
|
850
|
+
sourceURL: jsonPayload["sourceUrl"] as? String,
|
|
851
|
+
mimeType: jsonPayload["mimeType"] as? String,
|
|
852
|
+
fileHandle: fileHandle,
|
|
853
|
+
expectedSize: expectedSize
|
|
854
|
+
)
|
|
855
|
+
} catch {
|
|
856
|
+
emitDownloadFailed(sourceURL: nil, error: "Failed to start blob download: \(error.localizedDescription)")
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
private func appendBlobDownloadChunkPayload(_ payload: Any) {
|
|
861
|
+
guard handleDownloads else {
|
|
862
|
+
return
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
guard let jsonPayload = parseBlobBridgePayload(payload),
|
|
866
|
+
let sessionId = jsonPayload["sessionId"] as? String,
|
|
867
|
+
!sessionId.isEmpty,
|
|
868
|
+
var session = blobDownloadSessions[sessionId] else {
|
|
869
|
+
return
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
let base64 = jsonPayload["base64"] as? String ?? ""
|
|
873
|
+
guard !base64.isEmpty, let data = Data(base64Encoded: base64) else {
|
|
874
|
+
abortBlobDownloadSession(sessionId: sessionId, deleteFile: true)
|
|
875
|
+
emitDownloadFailed(sourceURL: session.sourceURL, error: "Failed to save blob download")
|
|
876
|
+
return
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
do {
|
|
880
|
+
if let expectedSize = session.expectedSize, session.bytesWritten + Int64(data.count) > expectedSize {
|
|
881
|
+
throw NSError(domain: "InAppBrowser", code: 1, userInfo: [NSLocalizedDescriptionKey: "Blob download exceeded expected size"])
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
try session.fileHandle.write(contentsOf: data)
|
|
885
|
+
session.bytesWritten += Int64(data.count)
|
|
886
|
+
blobDownloadSessions[sessionId] = session
|
|
887
|
+
} catch {
|
|
888
|
+
abortBlobDownloadSession(sessionId: sessionId, deleteFile: true)
|
|
889
|
+
emitDownloadFailed(sourceURL: session.sourceURL, error: "Failed to save blob download: \(error.localizedDescription)")
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
private func finishBlobDownloadPayload(_ payload: Any) {
|
|
894
|
+
guard handleDownloads else {
|
|
895
|
+
return
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
guard let jsonPayload = parseBlobBridgePayload(payload),
|
|
899
|
+
let sessionId = jsonPayload["sessionId"] as? String,
|
|
900
|
+
!sessionId.isEmpty,
|
|
901
|
+
let session = blobDownloadSessions.removeValue(forKey: sessionId) else {
|
|
902
|
+
emitDownloadFailed(sourceURL: nil, error: "Blob download session was not initialized")
|
|
903
|
+
return
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
defer {
|
|
907
|
+
try? session.fileHandle.close()
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
if let expectedSize = session.expectedSize, session.bytesWritten != expectedSize {
|
|
911
|
+
cleanupBlobDownloadSession(session, deleteFile: true)
|
|
912
|
+
emitDownloadFailed(
|
|
913
|
+
sourceURL: session.sourceURL,
|
|
914
|
+
fileName: session.destinationURL.lastPathComponent,
|
|
915
|
+
mimeType: session.mimeType,
|
|
916
|
+
error: "Blob download size mismatch"
|
|
917
|
+
)
|
|
918
|
+
return
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
previewDownloadedFile(session.destinationURL, mimeType: session.mimeType, sourceURL: session.sourceURL)
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
private func abortBlobDownloadPayload(_ payload: Any) {
|
|
925
|
+
guard handleDownloads else {
|
|
926
|
+
return
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
let jsonPayload = parseBlobBridgePayload(payload)
|
|
930
|
+
let sessionId = jsonPayload?["sessionId"] as? String ?? ""
|
|
931
|
+
let session = abortBlobDownloadSession(sessionId: sessionId, deleteFile: true)
|
|
932
|
+
let reason = jsonPayload?["reason"] as? String ?? "Blob download aborted"
|
|
933
|
+
emitDownloadFailed(
|
|
934
|
+
sourceURL: session?.sourceURL ?? jsonPayload?["sourceUrl"] as? String,
|
|
935
|
+
fileName: session?.destinationURL.lastPathComponent ?? jsonPayload?["fileName"] as? String,
|
|
936
|
+
mimeType: session?.mimeType ?? jsonPayload?["mimeType"] as? String,
|
|
937
|
+
error: reason
|
|
938
|
+
)
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
private func handleBlobDownloadFromPage(blobUrl: String, mimeType: String?, contentDisposition: String?) {
|
|
942
|
+
guard handleDownloads, let webView else {
|
|
943
|
+
emitDownloadFailed(sourceURL: blobUrl, error: "Blob download requires an active WebView")
|
|
944
|
+
return
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
let fallbackMimeType = normalizedMimeType(mimeType, fileURL: URL(fileURLWithPath: "download")) ?? "application/octet-stream"
|
|
948
|
+
let fallbackFileName = sanitizeDownloadFilename(
|
|
949
|
+
contentDisposition?.components(separatedBy: "filename=").last?
|
|
950
|
+
.trimmingCharacters(in: CharacterSet(charactersIn: "\"' "))
|
|
951
|
+
?? "download"
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
let params: [String: Any] = [
|
|
955
|
+
"blobUrl": blobUrl,
|
|
956
|
+
"fallbackMimeType": fallbackMimeType,
|
|
957
|
+
"fallbackFileName": fallbackFileName,
|
|
958
|
+
"legacyMaxBytes": BlobDownloadSupport.maxLegacyBytes,
|
|
959
|
+
"chunkSize": BlobDownloadSupport.chunkBytes
|
|
960
|
+
]
|
|
961
|
+
|
|
962
|
+
guard JSONSerialization.isValidJSONObject(params),
|
|
963
|
+
let paramsData = try? JSONSerialization.data(withJSONObject: params),
|
|
964
|
+
let paramsJson = String(data: paramsData, encoding: .utf8) else {
|
|
965
|
+
emitDownloadFailed(sourceURL: blobUrl, error: "Failed to prepare blob download script")
|
|
966
|
+
return
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
let script = """
|
|
970
|
+
(function(params) {
|
|
971
|
+
const blobUrl = params.blobUrl;
|
|
972
|
+
const fallbackMimeType = params.fallbackMimeType;
|
|
973
|
+
const fallbackFileName = params.fallbackFileName;
|
|
974
|
+
const legacyMaxBytes = params.legacyMaxBytes;
|
|
975
|
+
const chunkSize = params.chunkSize;
|
|
976
|
+
const matchingLink = Array.from(document.querySelectorAll('a[download]')).find(function(link) { return link.href === blobUrl; });
|
|
977
|
+
const bridge = (window.mobileApp && window.mobileApp.startBlobDownload && window.mobileApp.appendBlobDownloadChunk && window.mobileApp.finishBlobDownload)
|
|
978
|
+
? window.mobileApp
|
|
979
|
+
: (window.mobileApp && window.mobileApp.handleBlobDownload)
|
|
980
|
+
? window.mobileApp
|
|
981
|
+
: null;
|
|
982
|
+
const sessionId = 'blob-download-' + Date.now() + '-' + Math.random().toString(36).slice(2);
|
|
983
|
+
let fileName = fallbackFileName;
|
|
984
|
+
const readChunkAsBase64 = function(chunk) {
|
|
985
|
+
return new Promise(function(resolve, reject) {
|
|
986
|
+
const reader = new FileReader();
|
|
987
|
+
reader.onloadend = function() {
|
|
988
|
+
const dataUrl = reader.result || '';
|
|
989
|
+
resolve(String(dataUrl).split(',').pop() || '');
|
|
990
|
+
};
|
|
991
|
+
reader.onerror = function() {
|
|
992
|
+
reject(reader.error || new Error('Failed to read blob chunk'));
|
|
993
|
+
};
|
|
994
|
+
reader.readAsDataURL(chunk);
|
|
995
|
+
});
|
|
996
|
+
};
|
|
997
|
+
|
|
998
|
+
return (async function() {
|
|
999
|
+
if (!bridge) {
|
|
1000
|
+
throw new Error('Blob download bridge is not available');
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const response = await fetch(blobUrl);
|
|
1004
|
+
const blob = await response.blob();
|
|
1005
|
+
fileName = (matchingLink && matchingLink.getAttribute('download')) || fallbackFileName;
|
|
1006
|
+
|
|
1007
|
+
if (bridge.startBlobDownload && bridge.appendBlobDownloadChunk && bridge.finishBlobDownload) {
|
|
1008
|
+
bridge.startBlobDownload(JSON.stringify({
|
|
1009
|
+
sessionId: sessionId,
|
|
1010
|
+
fileName: fileName,
|
|
1011
|
+
sourceUrl: blobUrl,
|
|
1012
|
+
mimeType: blob.type || fallbackMimeType,
|
|
1013
|
+
size: blob.size
|
|
1014
|
+
}));
|
|
1015
|
+
for (let offset = 0; offset < blob.size; offset += chunkSize) {
|
|
1016
|
+
const base64 = await readChunkAsBase64(blob.slice(offset, offset + chunkSize));
|
|
1017
|
+
bridge.appendBlobDownloadChunk(JSON.stringify({ sessionId: sessionId, base64: base64 }));
|
|
1018
|
+
}
|
|
1019
|
+
bridge.finishBlobDownload(JSON.stringify({ sessionId: sessionId }));
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
if (blob.size > legacyMaxBytes) {
|
|
1024
|
+
throw new Error('Blob download is too large for the legacy bridge');
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const base64 = await readChunkAsBase64(blob);
|
|
1028
|
+
bridge.handleBlobDownload(JSON.stringify({
|
|
1029
|
+
fileName: fileName,
|
|
1030
|
+
sourceUrl: blobUrl,
|
|
1031
|
+
mimeType: blob.type || fallbackMimeType,
|
|
1032
|
+
size: blob.size,
|
|
1033
|
+
base64: base64
|
|
1034
|
+
}));
|
|
1035
|
+
})().catch(function(error) {
|
|
1036
|
+
if (bridge && bridge.abortBlobDownload) {
|
|
1037
|
+
bridge.abortBlobDownload(JSON.stringify({
|
|
1038
|
+
sessionId: sessionId,
|
|
1039
|
+
fileName: fileName,
|
|
1040
|
+
sourceUrl: blobUrl,
|
|
1041
|
+
mimeType: fallbackMimeType,
|
|
1042
|
+
reason: String((error && error.message) || error || 'Blob download failed')
|
|
1043
|
+
}));
|
|
1044
|
+
}
|
|
1045
|
+
console.error('Failed to capture blob download', error);
|
|
1046
|
+
});
|
|
1047
|
+
})(\(paramsJson));
|
|
1048
|
+
"""
|
|
1049
|
+
|
|
1050
|
+
DispatchQueue.main.async {
|
|
1051
|
+
webView.evaluateJavaScript(script) { _, error in
|
|
1052
|
+
if let error {
|
|
1053
|
+
self.emitDownloadFailed(sourceURL: blobUrl, error: "Failed to capture blob download: \(error.localizedDescription)")
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
706
1059
|
internal var customUserAgent: String? {
|
|
707
1060
|
didSet {
|
|
708
1061
|
guard let agent = userAgent else {
|
|
@@ -1301,6 +1654,16 @@ open class WKWebViewController: UIViewController, WKScriptMessageHandler {
|
|
|
1301
1654
|
print("Received non-dictionary message from JavaScript:", message.body)
|
|
1302
1655
|
emit("messageFromWebview", data: ["rawMessage": String(describing: message.body)])
|
|
1303
1656
|
}
|
|
1657
|
+
} else if message.name == "blobDownload" {
|
|
1658
|
+
handleLegacyBlobDownloadPayload(message.body)
|
|
1659
|
+
} else if message.name == "blobDownloadStart" {
|
|
1660
|
+
startBlobDownloadPayload(message.body)
|
|
1661
|
+
} else if message.name == "blobDownloadChunk" {
|
|
1662
|
+
appendBlobDownloadChunkPayload(message.body)
|
|
1663
|
+
} else if message.name == "blobDownloadFinish" {
|
|
1664
|
+
finishBlobDownloadPayload(message.body)
|
|
1665
|
+
} else if message.name == "blobDownloadAbort" {
|
|
1666
|
+
abortBlobDownloadPayload(message.body)
|
|
1304
1667
|
} else if message.name == "consoleMessageHandler" {
|
|
1305
1668
|
if let messageBody = message.body as? [String: Any] {
|
|
1306
1669
|
emit("consoleMessage", data: ConsoleMessageSupport.normalizePayload(from: messageBody))
|
|
@@ -1446,6 +1809,21 @@ open class WKWebViewController: UIViewController, WKScriptMessageHandler {
|
|
|
1446
1809
|
},
|
|
1447
1810
|
close: function() {
|
|
1448
1811
|
window.webkit.messageHandlers.close.postMessage(null);
|
|
1812
|
+
},
|
|
1813
|
+
handleBlobDownload: function(payload) {
|
|
1814
|
+
window.webkit.messageHandlers.blobDownload.postMessage(payload);
|
|
1815
|
+
},
|
|
1816
|
+
startBlobDownload: function(payload) {
|
|
1817
|
+
window.webkit.messageHandlers.blobDownloadStart.postMessage(payload);
|
|
1818
|
+
},
|
|
1819
|
+
appendBlobDownloadChunk: function(payload) {
|
|
1820
|
+
window.webkit.messageHandlers.blobDownloadChunk.postMessage(payload);
|
|
1821
|
+
},
|
|
1822
|
+
finishBlobDownload: function(payload) {
|
|
1823
|
+
window.webkit.messageHandlers.blobDownloadFinish.postMessage(payload);
|
|
1824
|
+
},
|
|
1825
|
+
abortBlobDownload: function(payload) {
|
|
1826
|
+
window.webkit.messageHandlers.blobDownloadAbort.postMessage(payload);
|
|
1449
1827
|
}\(extraControls)\(screenshotControls)
|
|
1450
1828
|
});
|
|
1451
1829
|
if (!window.__capgoInAppBrowserWindowCloseInstalled) {
|
|
@@ -1575,6 +1953,11 @@ open class WKWebViewController: UIViewController, WKScriptMessageHandler {
|
|
|
1575
1953
|
userContentController.add(weakHandler, name: "close")
|
|
1576
1954
|
userContentController.add(weakHandler, name: "hide")
|
|
1577
1955
|
userContentController.add(weakHandler, name: "show")
|
|
1956
|
+
userContentController.add(weakHandler, name: "blobDownload")
|
|
1957
|
+
userContentController.add(weakHandler, name: "blobDownloadStart")
|
|
1958
|
+
userContentController.add(weakHandler, name: "blobDownloadChunk")
|
|
1959
|
+
userContentController.add(weakHandler, name: "blobDownloadFinish")
|
|
1960
|
+
userContentController.add(weakHandler, name: "blobDownloadAbort")
|
|
1578
1961
|
if allowScreenshotsFromWebPage {
|
|
1579
1962
|
userContentController.add(weakHandler, name: "takeScreenshot")
|
|
1580
1963
|
}
|
|
@@ -3001,7 +3384,10 @@ extension WKWebViewController: WKNavigationDelegate {
|
|
|
3001
3384
|
}
|
|
3002
3385
|
|
|
3003
3386
|
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
|
3004
|
-
|
|
3387
|
+
if handleDownloads && navigationAction.shouldPerformDownload {
|
|
3388
|
+
decisionHandler(.download)
|
|
3389
|
+
return
|
|
3390
|
+
}
|
|
3005
3391
|
|
|
3006
3392
|
var actionPolicy: WKNavigationActionPolicy = self.preventDeeplink ? .preventDeeplinkActionPolicy : .allow
|
|
3007
3393
|
|
|
@@ -3011,6 +3397,16 @@ extension WKWebViewController: WKNavigationDelegate {
|
|
|
3011
3397
|
return
|
|
3012
3398
|
}
|
|
3013
3399
|
|
|
3400
|
+
if handleDownloads, url.scheme?.lowercased() == "blob" {
|
|
3401
|
+
handleBlobDownloadFromPage(
|
|
3402
|
+
blobUrl: url.absoluteString,
|
|
3403
|
+
mimeType: navigationAction.request.value(forHTTPHeaderField: "Content-Type"),
|
|
3404
|
+
contentDisposition: navigationAction.request.value(forHTTPHeaderField: "Content-Disposition")
|
|
3405
|
+
)
|
|
3406
|
+
decisionHandler(.cancel)
|
|
3407
|
+
return
|
|
3408
|
+
}
|
|
3409
|
+
|
|
3014
3410
|
if url.absoluteString.contains("apps.apple.com") {
|
|
3015
3411
|
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
|
3016
3412
|
decisionHandler(.cancel)
|
|
@@ -3067,11 +3463,6 @@ extension WKWebViewController: WKNavigationDelegate {
|
|
|
3067
3463
|
actionPolicy = result ? .allow : .cancel
|
|
3068
3464
|
}
|
|
3069
3465
|
|
|
3070
|
-
if shouldForceDownload, actionPolicy != .cancel {
|
|
3071
|
-
decisionHandler(.download)
|
|
3072
|
-
return
|
|
3073
|
-
}
|
|
3074
|
-
|
|
3075
3466
|
self.injectJavaScriptInterface()
|
|
3076
3467
|
decisionHandler(actionPolicy)
|
|
3077
3468
|
}
|