@capgo/capacitor-inappbrowser 8.7.7 → 8.7.9

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.
@@ -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<>(Arrays.asList("http", "https", "file", "tel", "mailto", "sms"));
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
+ }
@@ -62,7 +62,7 @@ final class ProxyRequestSupport {
62
62
  private ProxyRequestSupport() {}
63
63
 
64
64
  static boolean shouldInjectBridge(Options options) {
65
- return options != null && usesLegacyJsProxyMode(options);
65
+ return usesLegacyJsProxyMode(options);
66
66
  }
67
67
 
68
68
  static boolean usesLegacyJsProxyMode(Options options) {
@@ -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 (shouldBootstrapInitialLegacyProxyLoad()) {
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.7"
101
+ private let pluginVersion: String = "8.7.9"
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 url = URL(string: urlString) else {
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 = .remote(url)
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 = .remote(url)
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 url = URL(string: urlString) else {
2007
+ guard let webSource = self.webSource(for: urlString) else {
1989
2008
  call.reject("Invalid URL format")
1990
2009
  return
1991
2010
  }
1992
2011
 
1993
- self.webViewController = WKWebViewController.init(url: url, headers: headers, isInspectable: isInspectable, credentials: credentials, preventDeeplink: preventDeeplink, blankNavigationTab: true, enabledSafeBottomMargin: false, enabledSafeTopMargin: true)
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 = .remote(url)
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
- return httpResponse.value(forHTTPHeaderField: "Content-Disposition")
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
- let shouldForceDownload = handleDownloads && navigationAction.shouldPerformDownload
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-inappbrowser",
3
- "version": "8.7.7",
3
+ "version": "8.7.9",
4
4
  "description": "Capacitor plugin in app browser",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",