diff --git a/ExampleWrappedHybridWebViewLibrary/EmbeddedImage.png b/ExampleWrappedHybridWebViewLibrary/EmbeddedImage.png new file mode 100644 index 0000000..aecf8d1 Binary files /dev/null and b/ExampleWrappedHybridWebViewLibrary/EmbeddedImage.png differ diff --git a/ExampleWrappedHybridWebViewLibrary/EmbeddedWebPageResource.html b/ExampleWrappedHybridWebViewLibrary/EmbeddedWebPageResource.html new file mode 100644 index 0000000..7adafcb --- /dev/null +++ b/ExampleWrappedHybridWebViewLibrary/EmbeddedWebPageResource.html @@ -0,0 +1,45 @@ + + + + + + + + + + +

HybridWebView demo: Embedded Resource page

+
+ This web page is an embedded resource. It can access resources from in the + same way as normal via the HybridAssetRoot (not an embedded resrouce) and other embedded resources. + Native/JS communication works as expected. + This is useful when using the HybridWebView in a .NET Maui library when wrapping a web app that is + to be conusmed by other .NET Maui app. + +

+ +

+ +

+ +

+ The following image is loaded from an embedded resource via the proxy: +
+
+ +

+
+ + + + diff --git a/ExampleWrappedHybridWebViewLibrary/ExampleWrappedHybridWebViewLibrary.csproj b/ExampleWrappedHybridWebViewLibrary/ExampleWrappedHybridWebViewLibrary.csproj new file mode 100644 index 0000000..c2fdf7c --- /dev/null +++ b/ExampleWrappedHybridWebViewLibrary/ExampleWrappedHybridWebViewLibrary.csproj @@ -0,0 +1,40 @@ + + + + net8.0-android;net8.0-ios;net8.0-maccatalyst + $(TargetFrameworks);net8.0-windows10.0.19041.0 + + + true + true + enable + enable + + 11.0 + 13.1 + 21.0 + 10.0.17763.0 + 10.0.17763.0 + 6.5 + + + + + + + + + + + + + + + + + + + + + + diff --git a/ExampleWrappedHybridWebViewLibrary/MyCustomControl.cs b/ExampleWrappedHybridWebViewLibrary/MyCustomControl.cs new file mode 100644 index 0000000..b6788f6 --- /dev/null +++ b/ExampleWrappedHybridWebViewLibrary/MyCustomControl.cs @@ -0,0 +1,122 @@ +using HybridWebView; + +namespace ExampleWrappedHybridWebViewLibrary +{ + /// + /// A custom control that wraps a hybrid web view and loads embedded resources in addition to supporting the + /// Hybrid asset root and all other capabilites of the HybridWebView. + /// + public class MyCustomControl: Grid + { + private readonly HybridWebView.HybridWebView _webView; + + #region Constructor + + public MyCustomControl() : base() + { + bool enableWebDevTools = false; + +#if DEBUG + //Enable web dev tools when in debug mode. + enableWebDevTools = true; +#endif + + //Create a web view control. + _webView = new HybridWebView.HybridWebView + { + HybridAssetRoot = HybridAssetRoot ?? "hybrid_root", + MainFile = "proxy?operation=embeddedResource&resourceName=EmbeddedWebPageResource.html", + EnableWebDevTools = enableWebDevTools + }; + + //Set the target for JavaScript interop. + _webView.JSInvokeTarget = new MyJSInvokeTarget(); + + //Monitor proxy requests. + _webView.ProxyRequestReceived += WebView_ProxyRequestReceived; + +#if WINDOWS + //In Windows, disable manual user zooming of web pages. + _webView.HybridWebViewInitialized += (s, e) => + { + //Disable the user manually zooming. Don't want the user accidentally zooming the HTML page. + e.WebView.CoreWebView2.Settings.IsZoomControlEnabled = false; + }; +#endif + + //Add the web view to the control. + this.Children.Insert(0, _webView); + } + + #endregion + + #region Public Properties + + public string? HybridAssetRoot { get; set; } = null; + + #endregion + + #region Private Methods + + private async Task WebView_ProxyRequestReceived(HybridWebView.HybridWebViewProxyEventArgs args) + { + // Check to see if our custom parameter is present. + if (args.QueryParams.ContainsKey("operation")) + { + switch (args.QueryParams["operation"]) + { + case "embeddedResource": + if (args.QueryParams.TryGetValue("resourceName", out string? resourceName) && !string.IsNullOrWhiteSpace(resourceName)) + { + var thisAssembly = typeof(MyCustomControl).Assembly; + var assemblyName = thisAssembly.GetName().Name; + using (var fs = thisAssembly.GetManifestResourceStream($"{assemblyName}.{resourceName.Replace("/", ".")}")) + { + if (fs != null) + { + var ms = new MemoryStream(); + await fs.CopyToAsync(ms); + ms.Position = 0; + + args.ResponseStream = ms; + args.ResponseContentType = PathUtils.GetMimeType(resourceName); + } + } + } + break; + default: + break; + } + } + } + + private sealed class MyJSInvokeTarget + { + public MyJSInvokeTarget() + { + } + + /// + /// An example of a round trip method that takes an input parameter and returns a simple value type (number). + /// + /// + /// + public double Fibonacci(int n) + { + if (n == 0) return 0; + + int prev = 0; + int next = 1; + for (int i = 1; i < n; i++) + { + int sum = prev + next; + prev = next; + next = sum; + } + return next; + } + } + + #endregion + } +} diff --git a/ExampleWrappedHybridWebViewLibrary/Platforms/Android/PlatformClass1.cs b/ExampleWrappedHybridWebViewLibrary/Platforms/Android/PlatformClass1.cs new file mode 100644 index 0000000..f822afc --- /dev/null +++ b/ExampleWrappedHybridWebViewLibrary/Platforms/Android/PlatformClass1.cs @@ -0,0 +1,7 @@ +namespace ExampleWrappedHybridWebViewLibrary +{ + // All the code in this file is only included on Android. + public class PlatformClass1 + { + } +} diff --git a/ExampleWrappedHybridWebViewLibrary/Platforms/MacCatalyst/PlatformClass1.cs b/ExampleWrappedHybridWebViewLibrary/Platforms/MacCatalyst/PlatformClass1.cs new file mode 100644 index 0000000..98e6f32 --- /dev/null +++ b/ExampleWrappedHybridWebViewLibrary/Platforms/MacCatalyst/PlatformClass1.cs @@ -0,0 +1,7 @@ +namespace ExampleWrappedHybridWebViewLibrary +{ + // All the code in this file is only included on Mac Catalyst. + public class PlatformClass1 + { + } +} diff --git a/ExampleWrappedHybridWebViewLibrary/Platforms/Tizen/PlatformClass1.cs b/ExampleWrappedHybridWebViewLibrary/Platforms/Tizen/PlatformClass1.cs new file mode 100644 index 0000000..5bc2f1e --- /dev/null +++ b/ExampleWrappedHybridWebViewLibrary/Platforms/Tizen/PlatformClass1.cs @@ -0,0 +1,9 @@ +using System; + +namespace ExampleWrappedHybridWebViewLibrary +{ + // All the code in this file is only included on Tizen. + public class PlatformClass1 + { + } +} \ No newline at end of file diff --git a/ExampleWrappedHybridWebViewLibrary/Platforms/Windows/PlatformClass1.cs b/ExampleWrappedHybridWebViewLibrary/Platforms/Windows/PlatformClass1.cs new file mode 100644 index 0000000..f21c4ea --- /dev/null +++ b/ExampleWrappedHybridWebViewLibrary/Platforms/Windows/PlatformClass1.cs @@ -0,0 +1,7 @@ +namespace ExampleWrappedHybridWebViewLibrary +{ + // All the code in this file is only included on Windows. + public class PlatformClass1 + { + } +} diff --git a/ExampleWrappedHybridWebViewLibrary/Platforms/iOS/PlatformClass1.cs b/ExampleWrappedHybridWebViewLibrary/Platforms/iOS/PlatformClass1.cs new file mode 100644 index 0000000..68b9c82 --- /dev/null +++ b/ExampleWrappedHybridWebViewLibrary/Platforms/iOS/PlatformClass1.cs @@ -0,0 +1,7 @@ +namespace ExampleWrappedHybridWebViewLibrary +{ + // All the code in this file is only included on iOS. + public class PlatformClass1 + { + } +} diff --git a/HybridWebView/HybridWebViewProxyEventArgs.cs b/HybridWebView/HybridWebViewProxyEventArgs.cs index 0f6299d..77385f1 100644 --- a/HybridWebView/HybridWebViewProxyEventArgs.cs +++ b/HybridWebView/HybridWebViewProxyEventArgs.cs @@ -15,6 +15,18 @@ public HybridWebViewProxyEventArgs(string fullUrl) QueryParams = QueryStringHelper.GetKeyValuePairs(fullUrl); } + /// + /// Creates a new instance of . + /// + /// The full request URL. + /// The estimated response content type based on uri parsing. + public HybridWebViewProxyEventArgs(string fullUrl, string contentType) + { + Url = fullUrl; + ResponseContentType = contentType; + QueryParams = QueryStringHelper.GetKeyValuePairs(fullUrl); + } + /// /// The full request URL. /// @@ -35,5 +47,11 @@ public HybridWebViewProxyEventArgs(string fullUrl) /// The response stream to be used to respond to the request. /// public Stream? ResponseStream { get; set; } = null; + + /// + /// Additional headers to be added to the response. + /// Useful for things like adding a cache control header. + /// + public IDictionary? CustomResponseHeaders { get; set; } } } diff --git a/HybridWebView/PathUtils.cs b/HybridWebView/PathUtils.cs index 2a92cee..d137e13 100644 --- a/HybridWebView/PathUtils.cs +++ b/HybridWebView/PathUtils.cs @@ -1,10 +1,224 @@ -namespace HybridWebView +using System; + +namespace HybridWebView { - internal static class PathUtils + public static class PathUtils { + public const string PlanTextMimeType = "text/plain"; + public const string HtmlMimeType = "text/html"; + public static string NormalizePath(string filename) => filename .Replace('\\', Path.DirectorySeparatorChar) .Replace('/', Path.DirectorySeparatorChar); + + public static void GetRelativePathAndContentType(Uri appOriginUri, Uri requestUri, string originalUrl, string? mainFileName, out string relativePath, out string contentType, out string fullUrl) + { + relativePath = appOriginUri.MakeRelativeUri(requestUri).ToString().Replace('/', '\\'); + fullUrl = originalUrl; + + if (string.IsNullOrEmpty(relativePath)) + { + //The main file may be a URL that has a query string. For example if we want the main page to go through the proxy. + if (!string.IsNullOrEmpty(mainFileName)) + { + relativePath = QueryStringHelper.RemovePossibleQueryString(mainFileName); + fullUrl = mainFileName; + } + + //Try and get the mime type from the full URL (main file could be a URL that has a query string pointing to a file such as a PDF). + string? minType; + if (PathUtils.TryGetMimeType(fullUrl, out minType)) + { + if (!string.IsNullOrEmpty(minType)) + { + contentType = minType; + } + else + { + contentType = PathUtils.HtmlMimeType; + } + } + else + { + contentType = PathUtils.HtmlMimeType; + } + } + else + { + contentType = PathUtils.GetMimeType(fullUrl); + } + } + + /// + /// Tries to get the mime type from a file name or URL by looking for valid file extensions or mime types in a data URI. + /// Input can be a file name, url (with query string), a data uri, mime type, or file extension. + /// + /// A file name, url (with query string), a data uri, mime type, or file extension. + /// The determined mime type. Fallback to "text/plain" + public static string GetMimeType(string fileNameOrUrl) + { + string? ext; + string? mimeType; + + //Check for a mime type in a data uri. + if (fileNameOrUrl.Contains("data:") && fileNameOrUrl.Contains(";base64,")) + { + ext = fileNameOrUrl.Substring(5, fileNameOrUrl.IndexOf(";base64,") - 5); + + if (TryGetMimeType(ext, out mimeType)) + { + return mimeType ?? PlanTextMimeType; + } + } + + //Seperate out query string if it exists. + string queryString = string.Empty; + + if (fileNameOrUrl.Contains("?")) + { + queryString = fileNameOrUrl.Substring(fileNameOrUrl.IndexOf("?")); + fileNameOrUrl = fileNameOrUrl.Substring(0, fileNameOrUrl.IndexOf("?")); + } + + //If there is still a url or file name, check it for a valid file extension. + if (!string.IsNullOrWhiteSpace(fileNameOrUrl)) + { + ext = Path.GetExtension(fileNameOrUrl); + + if (TryGetMimeType(ext, out mimeType)) + { + return mimeType ?? PlanTextMimeType; + } + + //Try passing the whole file name to see if it is itself a valid mime type. This would work if only a file extension or mimetype was passed in. + if (TryGetMimeType(fileNameOrUrl, out mimeType)) + { + return mimeType ?? PlanTextMimeType; + } + } + + //If there is a query string, check it's parameter values to see if it contains something with a valid file extension. + if (!string.IsNullOrWhiteSpace(queryString)) + { + var queryParameters = queryString.Split('&'); + + foreach (var param in queryParameters) + { + if (param.Contains("=")) + { + ext = param.Substring(param.IndexOf("=") + 1); + + if (TryGetMimeType(ext, out mimeType)) + { + return mimeType ?? PlanTextMimeType; + } + } + } + } + + //If get here, return plain text mime type. + return PlanTextMimeType; + } + + /// + /// Looks up a mime type based on a file extension or mime type. + /// + /// A mimeType or file extension to validate and get the mime type for. + /// A boolean indicating if it found a supported mime type. + public static bool TryGetMimeType(string? mimeTypeOrFileExtension, out string? mimeType) + { + mimeType = null; + + if (string.IsNullOrWhiteSpace(mimeTypeOrFileExtension)) + { + return false; + } + + //If content type starts with a period, remove it. File extension may have been passed in. + if (mimeTypeOrFileExtension.StartsWith(".")) + { + mimeTypeOrFileExtension = mimeTypeOrFileExtension.Substring(1); + } + + //For simplirity, if the content type contains a slash, assume it is a file path extension. + if (mimeTypeOrFileExtension.Contains("/")) + { + //Return the string after the last index of the slash. + mimeTypeOrFileExtension = mimeTypeOrFileExtension.Substring(mimeTypeOrFileExtension.LastIndexOf("/") + 1); + } + + //Sanitize the content type. + mimeType = mimeTypeOrFileExtension.ToLowerInvariant() switch + { + //WebAssembly file types + "wasm" => "application/wasm", + + //Image file types + "png" => "image/png", + "jpg" or "jpeg" or "jfif" or "pjpeg" or "pjp" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "svg" or "svg+xml" => "image/svg+xml", + "ico" or "x-icon" => "image/x-icon", + "bmp" => "image/bmp", + "tif" or "tiff" => "image/tiff", + "avif" => "image/avif", + "apng" => "image/apng", + + //Video file types + "mp4" => "video/mp4", + "webm" => "video/webm", + "mpeg" => "video/mpeg", + + //Audio file types + "mp3" => "audio/mpeg", + "wav" => "audio/wav", + + //Font file types + "woff" => "font/woff", + "woff2" => "font/woff2", + "otf" => "font/otf", + + //JSON and XML based file types + "json" or "geojson" or "geojsonseq" or "topojson" => "application/json", + "gpx" or "georss" or "gml" or "citygml" or "czml" or "xml" => "application/xml", + "kml" or "kml+xml" or "vnd.google-earth.kml+xml" => "application/vnd.google-earth.kml+xml", + + //Office file types + "doc" or "docx" or "msword" => "application/msword", + "xls" or "xlsx" or "vnd.ms-excel" => "application/vnd.ms-excel", + "ppt" or "pptx" or "vnd.ms-powerpoint" => "application/vnd.ms-powerpoint", + + //3D model file types commonly used in web. + "gltf" or "gltf+json" => "model/gltf+json", + "glb" or "gltf-binary" => "model/gltf-binary", + "dae" => "model/vnd.collada+xml", + + //Other binary file types + "zip" => "application/zip", + "pbf" or "x-protobuf" => "application/x-protobuf", + "mvt" or "vnd.mapbox-vector-tile" => "application/vnd.mapbox-vector-tile", + "kmz" or "vnd.google-earth.kmz" or "shp" or "dbf" or "bin" or "b3dm" or "i3dm" or "pnts" or "subtree" or "octet-stream" => "application/octet-stream", + "pdf" => "application/pdf", + + //Other map tile file types + "terrian" => "application/vnd.quantized-mesh", + "pmtiles" => "application/vnd.pmtiles", + + //Text based file types + "htm" or "html" => "text/html", + "xhtml" or "xhtml+xml" => "application/xhtml+xml", + "js" or "javascript" => "text/javascript", + "css" => "text/css", + "csv" => "text/csv", + "md" => "text/markdown", + "plain" or "txt" or "wat" => "text/plain", + + _ => null, + }; + + return mimeType != null; + } } } diff --git a/HybridWebView/Platforms/Android/AndroidHybridWebViewClient.cs b/HybridWebView/Platforms/Android/AndroidHybridWebViewClient.cs index f0f6619..35592af 100644 --- a/HybridWebView/Platforms/Android/AndroidHybridWebViewClient.cs +++ b/HybridWebView/Platforms/Android/AndroidHybridWebViewClient.cs @@ -14,53 +14,38 @@ public AndroidHybridWebViewClient(HybridWebViewHandler handler) : base(handler) { _handler = handler; } + public override WebResourceResponse? ShouldInterceptRequest(AWebView? view, IWebResourceRequest? request) { - var fullUrl = request?.Url?.ToString(); - var requestUri = QueryStringHelper.RemovePossibleQueryString(fullUrl); + var originalUrl = request?.Url?.ToString(); + var requestUri = QueryStringHelper.RemovePossibleQueryString(originalUrl); var webView = (HybridWebView)_handler.VirtualView; - if (new Uri(requestUri) is Uri uri && HybridWebView.AppOriginUri.IsBaseOf(uri)) + if (!string.IsNullOrEmpty(originalUrl) && new Uri(requestUri) is Uri uri && HybridWebView.AppOriginUri.IsBaseOf(uri)) { - var relativePath = HybridWebView.AppOriginUri.MakeRelativeUri(uri).ToString().Replace('/', '\\'); - - string contentType; - if (string.IsNullOrEmpty(relativePath)) - { - relativePath = webView.MainFile; - contentType = "text/html"; - } - else - { - var requestExtension = Path.GetExtension(relativePath); - contentType = requestExtension switch - { - ".htm" or ".html" => "text/html", - ".js" => "application/javascript", - ".css" => "text/css", - _ => "text/plain", - }; - } - + PathUtils.GetRelativePathAndContentType(HybridWebView.AppOriginUri, uri, originalUrl, webView.MainFile, out string relativePath, out string contentType, out string fullUrl); + Stream? contentStream = null; + IDictionary? customHeaders = null; // Check to see if the request is a proxy request. - if (relativePath == HybridWebView.ProxyRequestPath) + if (!string.IsNullOrEmpty(relativePath) && relativePath.Equals(HybridWebView.ProxyRequestPath)) { - var args = new HybridWebViewProxyEventArgs(fullUrl); + var args = new HybridWebViewProxyEventArgs(fullUrl, contentType); // TODO: Don't block async. Consider making this an async call, and then calling DidFinish when done webView.OnProxyRequestMessage(args).Wait(); if (args.ResponseStream != null) { - contentType = args.ResponseContentType ?? "text/plain"; + contentType = args.ResponseContentType ?? PathUtils.PlanTextMimeType; contentStream = args.ResponseStream; + customHeaders = args.CustomResponseHeaders; } } - if (contentStream == null) + if (contentStream is null) { contentStream = KnownStaticFileProvider.GetKnownResourceStream(relativePath!); } @@ -78,12 +63,12 @@ public AndroidHybridWebViewClient(HybridWebViewHandler handler) : base(handler) var notFoundByteArray = Encoding.UTF8.GetBytes(notFoundContent); var notFoundContentStream = new MemoryStream(notFoundByteArray); - return new WebResourceResponse("text/plain", "UTF-8", 404, "Not Found", GetHeaders("text/plain"), notFoundContentStream); + return new WebResourceResponse("text/plain", "UTF-8", 404, "Not Found", GetHeaders("text/plain", null), notFoundContentStream); } else { // TODO: We don't know the content length because Android doesn't tell us. Seems to work without it! - return new WebResourceResponse(contentType, "UTF-8", 200, "OK", GetHeaders(contentType), contentStream); + return new WebResourceResponse(contentType, "UTF-8", 200, "OK", GetHeaders(contentType, customHeaders), contentStream); } } else @@ -106,9 +91,29 @@ public AndroidHybridWebViewClient(HybridWebViewHandler handler) : base(handler) } } - private protected static IDictionary GetHeaders(string contentType) => - new Dictionary { - { "Content-Type", contentType }, - }; + private protected static IDictionary GetHeaders(string contentType, IDictionary? customHeaders) + { + var headers = new Dictionary(); + + if (customHeaders != null) + { + foreach (var header in customHeaders) + { + // Add custom headers to the response. Skip the Content-Length and Content-Type headers. + if (header.Key != "Content-Length" && header.Key != "Content-Type") + { + headers[header.Key] = header.Value; + } + } + } + + //If a custom header hasn't specified a content type, use the one we've determined. + if (!headers.ContainsKey("Content-Type")) + { + headers.Add("Content-Type", contentType); + } + + return headers; + } } } diff --git a/HybridWebView/Platforms/MacCatalyst/HybridWebViewHandler.MacCatalyst.cs b/HybridWebView/Platforms/MacCatalyst/HybridWebViewHandler.MacCatalyst.cs index d31f86f..b7a38a6 100644 --- a/HybridWebView/Platforms/MacCatalyst/HybridWebViewHandler.MacCatalyst.cs +++ b/HybridWebView/Platforms/MacCatalyst/HybridWebViewHandler.MacCatalyst.cs @@ -1,5 +1,7 @@ using Foundation; +using Intents; using Microsoft.Maui.Platform; +using System; using System.Drawing; using System.Globalization; using System.Runtime.Versioning; @@ -78,8 +80,26 @@ public async void StartUrlSchemeTask(WKWebView webView, IWKUrlSchemeTask urlSche { dic.Add((NSString)"Content-Length", (NSString)(responseData.ResponseBytes.Length.ToString(CultureInfo.InvariantCulture))); dic.Add((NSString)"Content-Type", (NSString)responseData.ContentType); - // Disable local caching. This will prevent user scripts from executing correctly. - dic.Add((NSString)"Cache-Control", (NSString)"no-cache, max-age=0, must-revalidate, no-store"); + + if (responseData.CustomHeaders != null) + { + foreach (var header in responseData.CustomHeaders) + { + // Add custom headers to the response. Skip the Content-Length and Content-Type headers. + if (header.Key != "Content-Length" && header.Key != "Content-Type") + { + dic.Add((NSString)header.Key, (NSString)header.Value); + } + } + } + + //Ensure that the Cache-Control header is not set in the custom headers. + if(responseData.CustomHeaders == null || responseData.CustomHeaders.ContainsKey("Cache-Control")) + { + // Disable local caching. This will prevent user scripts from executing correctly. + dic.Add((NSString)"Cache-Control", (NSString)"no-cache, max-age=0, must-revalidate, no-store"); + } + if (urlSchemeTask.Request.Url != null) { using var response = new NSHttpUrlResponse(urlSchemeTask.Request.Url, responseData.StatusCode, "HTTP/1.1", dic); @@ -92,55 +112,36 @@ public async void StartUrlSchemeTask(WKWebView webView, IWKUrlSchemeTask urlSche } } - private async Task<(byte[] ResponseBytes, string ContentType, int StatusCode)> GetResponseBytes(string? url) + private async Task<(byte[] ResponseBytes, string ContentType, int StatusCode, IDictionary? CustomHeaders)> GetResponseBytes(string? url) { - string contentType; + //string contentType; - string fullUrl = url; + string? originalUrl = url; url = QueryStringHelper.RemovePossibleQueryString(url); - if (new Uri(url) is Uri uri && HybridWebView.AppOriginUri.IsBaseOf(uri)) + if (!string.IsNullOrEmpty(originalUrl) && new Uri(url) is Uri uri && HybridWebView.AppOriginUri.IsBaseOf(uri)) { - var relativePath = HybridWebView.AppOriginUri.MakeRelativeUri(uri).ToString().Replace('\\', '/'); - var hwv = (HybridWebView)_webViewHandler.VirtualView; - - var bundleRootDir = Path.Combine(NSBundle.MainBundle.ResourcePath, hwv.HybridAssetRoot!); - - if (string.IsNullOrEmpty(relativePath)) - { - relativePath = hwv.MainFile!.Replace('\\', '/'); - contentType = "text/html"; - } - else - { - var requestExtension = Path.GetExtension(relativePath); - contentType = requestExtension switch - { - ".htm" or ".html" => "text/html", - ".js" => "application/javascript", - ".css" => "text/css", - _ => "text/plain", - }; - } - + PathUtils.GetRelativePathAndContentType(HybridWebView.AppOriginUri, uri, originalUrl, hwv.MainFile, out string relativePath, out string contentType, out string fullUrl); + Stream? contentStream = null; + IDictionary? customHeaders = null; // Check to see if the request is a proxy request. if (relativePath == HybridWebView.ProxyRequestPath) { - var args = new HybridWebViewProxyEventArgs(fullUrl); - + var args = new HybridWebViewProxyEventArgs(fullUrl, contentType); await hwv.OnProxyRequestMessage(args); if (args.ResponseStream != null) { - contentType = args.ResponseContentType ?? "text/plain"; + contentType = args.ResponseContentType ?? PathUtils.PlanTextMimeType; contentStream = args.ResponseStream; + customHeaders = args.CustomResponseHeaders; } } - if (contentStream == null) + if (contentStream is null) { contentStream = KnownStaticFileProvider.GetKnownResourceStream(relativePath!); } @@ -149,18 +150,19 @@ public async void StartUrlSchemeTask(WKWebView webView, IWKUrlSchemeTask urlSche { using var ms = new MemoryStream(); contentStream.CopyTo(ms); - return (ms.ToArray(), contentType, StatusCode: 200); + return (ms.ToArray(), contentType, StatusCode: 200, CustomHeaders: customHeaders); } - var assetPath = Path.Combine(bundleRootDir, relativePath); + string bundleRootDir = Path.Combine(NSBundle.MainBundle.ResourcePath, hwv.HybridAssetRoot ?? ""); + string assetPath = Path.Combine(bundleRootDir, relativePath); if (File.Exists(assetPath)) { - return (File.ReadAllBytes(assetPath), contentType, StatusCode: 200); + return (File.ReadAllBytes(assetPath), contentType, StatusCode: 200, CustomHeaders: null); } } - return (Array.Empty(), ContentType: string.Empty, StatusCode: 404); + return (Array.Empty(), ContentType: string.Empty, StatusCode: 404, CustomHeaders: null); } [Export("webView:stopURLSchemeTask:")] diff --git a/HybridWebView/Platforms/Windows/HybridWebView.Windows.cs b/HybridWebView/Platforms/Windows/HybridWebView.Windows.cs index dfa037e..0b2c70b 100644 --- a/HybridWebView/Platforms/Windows/HybridWebView.Windows.cs +++ b/HybridWebView/Platforms/Windows/HybridWebView.Windows.cs @@ -46,45 +46,27 @@ private async void CoreWebView2_WebResourceRequested(CoreWebView2 sender, CoreWe { // Get a deferral object so that WebView2 knows there's some async stuff going on. We call Complete() at the end of this method. using var deferral = eventArgs.GetDeferral(); - + var requestUri = QueryStringHelper.RemovePossibleQueryString(eventArgs.Request.Uri); - + if (new Uri(requestUri) is Uri uri && AppOriginUri.IsBaseOf(uri)) { - var relativePath = AppOriginUri.MakeRelativeUri(uri).ToString().Replace('/', '\\'); - - string contentType; - if (string.IsNullOrEmpty(relativePath)) - { - relativePath = MainFile; - contentType = "text/html"; - } - else - { - var requestExtension = Path.GetExtension(relativePath); - contentType = requestExtension switch - { - ".htm" or ".html" => "text/html", - ".js" => "application/javascript", - ".css" => "text/css", - _ => "text/plain", - }; - } - + PathUtils.GetRelativePathAndContentType(HybridWebView.AppOriginUri, uri, eventArgs.Request.Uri, MainFile, out string relativePath, out string contentType, out string fullUrl); + Stream? contentStream = null; + IDictionary? customHeaders = null; // Check to see if the request is a proxy request if (relativePath == ProxyRequestPath) { - var fullUrl = eventArgs.Request.Uri; - - var args = new HybridWebViewProxyEventArgs(fullUrl); + var args = new HybridWebViewProxyEventArgs(fullUrl, contentType); await OnProxyRequestMessage(args); if (args.ResponseStream != null) { - contentType = args.ResponseContentType ?? "text/plain"; + contentType = args.ResponseContentType ?? PathUtils.PlanTextMimeType; contentStream = args.ResponseStream; + customHeaders = args.CustomResponseHeaders; } } @@ -106,7 +88,7 @@ private async void CoreWebView2_WebResourceRequested(CoreWebView2 sender, CoreWe Content: null, StatusCode: 404, ReasonPhrase: "Not Found", - Headers: GetHeaderString("text/plain", notFoundContent.Length) + Headers: GetHeaderString("text/plain", notFoundContent.Length, null) ); } else @@ -115,7 +97,7 @@ private async void CoreWebView2_WebResourceRequested(CoreWebView2 sender, CoreWe Content: await CopyContentToRandomAccessStreamAsync(contentStream), StatusCode: 200, ReasonPhrase: "OK", - Headers: GetHeaderString(contentType, (int)contentStream.Length) + Headers: GetHeaderString(contentType, (int)contentStream.Length, customHeaders) ); } @@ -135,9 +117,34 @@ async Task CopyContentToRandomAccessStreamAsync(Stream cont } } - private protected static string GetHeaderString(string contentType, int contentLength) => -$@"Content-Type: {contentType} -Content-Length: {contentLength}"; + private protected static string GetHeaderString(string contentType, int contentLength, IDictionary? customHeaders) + { + StringBuilder sb = new StringBuilder(); + + sb.AppendLine($"Content-Type: {contentType}"); + sb.AppendLine($"Content-Length: {contentLength}"); + + if (customHeaders != null) + { + foreach (var header in customHeaders) + { + // Add custom headers to the response. Skip the Content-Length and Content-Type headers. + if (header.Key != "Content-Length" && header.Key != "Content-Type") + { + sb.AppendLine($"{header.Key}: {header.Value}"); + } + } + } + + // Ensure that the Cache-Control header is not set in the custom headers. + if (customHeaders == null || !customHeaders.ContainsKey("Cache-Control")) + { + // Disable local caching. This will prevent user scripts from executing correctly. + sb.AppendLine("Cache-Control: no-cache, max-age=0, must-revalidate, no-store"); + } + + return sb.ToString(); + } private void Wv2_WebMessageReceived(Microsoft.UI.Xaml.Controls.WebView2 sender, CoreWebView2WebMessageReceivedEventArgs args) { diff --git a/HybridWebView/Platforms/iOS/HybridWebViewHandler.iOS.cs b/HybridWebView/Platforms/iOS/HybridWebViewHandler.iOS.cs index a5fcf9f..5dd342a 100644 --- a/HybridWebView/Platforms/iOS/HybridWebViewHandler.iOS.cs +++ b/HybridWebView/Platforms/iOS/HybridWebViewHandler.iOS.cs @@ -1,5 +1,7 @@ using Foundation; +using Intents; using Microsoft.Maui.Platform; +using System; using System.Drawing; using System.Globalization; using System.Reflection.Metadata; @@ -79,8 +81,26 @@ public async void StartUrlSchemeTask(WKWebView webView, IWKUrlSchemeTask urlSche { dic.Add((NSString)"Content-Length", (NSString)(responseData.ResponseBytes.Length.ToString(CultureInfo.InvariantCulture))); dic.Add((NSString)"Content-Type", (NSString)responseData.ContentType); - // Disable local caching. This will prevent user scripts from executing correctly. - dic.Add((NSString)"Cache-Control", (NSString)"no-cache, max-age=0, must-revalidate, no-store"); + + if (responseData.CustomHeaders != null) + { + foreach (var header in responseData.CustomHeaders) + { + // Add custom headers to the response. Skip the Content-Length and Content-Type headers. + if (header.Key != "Content-Length" && header.Key != "Content-Type") + { + dic.Add((NSString)header.Key, (NSString)header.Value); + } + } + } + + //Ensure that the Cache-Control header is not set in the custom headers. + if (responseData.CustomHeaders == null || responseData.CustomHeaders.ContainsKey("Cache-Control")) + { + // Disable local caching. This will prevent user scripts from executing correctly. + dic.Add((NSString)"Cache-Control", (NSString)"no-cache, max-age=0, must-revalidate, no-store"); + } + if (urlSchemeTask.Request.Url != null) { using var response = new NSHttpUrlResponse(urlSchemeTask.Request.Url, responseData.StatusCode, "HTTP/1.1", dic); @@ -93,55 +113,37 @@ public async void StartUrlSchemeTask(WKWebView webView, IWKUrlSchemeTask urlSche } } - private async Task<(byte[] ResponseBytes, string ContentType, int StatusCode)> GetResponseBytes(string? url) + private async Task<(byte[] ResponseBytes, string ContentType, int StatusCode, IDictionary? CustomHeaders)> GetResponseBytes(string? url) { - string contentType; + //string contentType; - string fullUrl = url; + string? originalUrl = url; url = QueryStringHelper.RemovePossibleQueryString(url); - if (new Uri(url) is Uri uri && HybridWebView.AppOriginUri.IsBaseOf(uri)) + if (!string.IsNullOrEmpty(originalUrl) && new Uri(url) is Uri uri && HybridWebView.AppOriginUri.IsBaseOf(uri)) { - var relativePath = HybridWebView.AppOriginUri.MakeRelativeUri(uri).ToString().Replace('\\', '/'); - var hwv = (HybridWebView)_webViewHandler.VirtualView; - - var bundleRootDir = Path.Combine(NSBundle.MainBundle.ResourcePath, hwv.HybridAssetRoot!); - - if (string.IsNullOrEmpty(relativePath)) - { - relativePath = hwv.MainFile!.Replace('\\', '/'); - contentType = "text/html"; - } - else - { - var requestExtension = Path.GetExtension(relativePath); - contentType = requestExtension switch - { - ".htm" or ".html" => "text/html", - ".js" => "application/javascript", - ".css" => "text/css", - _ => "text/plain", - }; - } + PathUtils.GetRelativePathAndContentType(HybridWebView.AppOriginUri, uri, originalUrl, hwv.MainFile, out string relativePath, out string contentType, out string fullUrl); Stream? contentStream = null; + IDictionary? customHeaders = null; // Check to see if the request is a proxy request. if (relativePath == HybridWebView.ProxyRequestPath) { - var args = new HybridWebViewProxyEventArgs(fullUrl); + var args = new HybridWebViewProxyEventArgs(fullUrl, contentType); await hwv.OnProxyRequestMessage(args); if (args.ResponseStream != null) { - contentType = args.ResponseContentType ?? "text/plain"; + contentType = args.ResponseContentType ?? PathUtils.PlanTextMimeType; contentStream = args.ResponseStream; + customHeaders = args.CustomResponseHeaders; } } - if (contentStream == null) + if (contentStream is null) { contentStream = KnownStaticFileProvider.GetKnownResourceStream(relativePath!); } @@ -150,18 +152,19 @@ public async void StartUrlSchemeTask(WKWebView webView, IWKUrlSchemeTask urlSche { using var ms = new MemoryStream(); contentStream.CopyTo(ms); - return (ms.ToArray(), contentType, StatusCode: 200); + return (ms.ToArray(), contentType, StatusCode: 200, CustomHeaders: customHeaders); } - var assetPath = Path.Combine(bundleRootDir, relativePath); + string bundleRootDir = Path.Combine(NSBundle.MainBundle.ResourcePath, hwv.HybridAssetRoot ?? ""); + string assetPath = Path.Combine(bundleRootDir, relativePath); if (File.Exists(assetPath)) { - return (File.ReadAllBytes(assetPath), contentType, StatusCode: 200); + return (File.ReadAllBytes(assetPath), contentType, StatusCode: 200, CustomHeaders: null); } } - return (Array.Empty(), ContentType: string.Empty, StatusCode: 404); + return (Array.Empty(), ContentType: string.Empty, StatusCode: 404, CustomHeaders: null); } [Export("webView:stopURLSchemeTask:")] diff --git a/HybridWebView/QueryStringHelper.cs b/HybridWebView/QueryStringHelper.cs index 007cc12..3780c53 100644 --- a/HybridWebView/QueryStringHelper.cs +++ b/HybridWebView/QueryStringHelper.cs @@ -27,11 +27,10 @@ public static Dictionary GetKeyValuePairs(string? url) var result = new Dictionary(); if (!string.IsNullOrEmpty(url)) { - var query = new Uri(url).Query; - if (query != null && query.Length > 1) + string query = url.Substring(url.IndexOf("?") + 1); + if (!string.IsNullOrWhiteSpace(query)) { result = query - .Substring(1) .Split('&') .Select(p => p.Split('=')) .ToDictionary(p => p[0], p => Uri.UnescapeDataString(p[1])); diff --git a/MauiCSharpInteropWebView.sln b/MauiCSharpInteropWebView.sln index ce38ce3..48e39a6 100644 --- a/MauiCSharpInteropWebView.sln +++ b/MauiCSharpInteropWebView.sln @@ -16,6 +16,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution README.md = README.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExampleWrappedHybridWebViewLibrary", "ExampleWrappedHybridWebViewLibrary\ExampleWrappedHybridWebViewLibrary.csproj", "{7C75B779-B7C2-4321-AE13-24470017AFC2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -38,6 +40,10 @@ Global {9B164FED-BBE0-4E85-957D-7DEC74C6C792}.Release|Any CPU.ActiveCfg = Release|Any CPU {9B164FED-BBE0-4E85-957D-7DEC74C6C792}.Release|Any CPU.Build.0 = Release|Any CPU {9B164FED-BBE0-4E85-957D-7DEC74C6C792}.Release|Any CPU.Deploy.0 = Release|Any CPU + {7C75B779-B7C2-4321-AE13-24470017AFC2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C75B779-B7C2-4321-AE13-24470017AFC2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C75B779-B7C2-4321-AE13-24470017AFC2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C75B779-B7C2-4321-AE13-24470017AFC2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/MauiCSharpInteropWebView/AppShell.xaml b/MauiCSharpInteropWebView/AppShell.xaml index 212a40d..1a25a9a 100644 --- a/MauiCSharpInteropWebView/AppShell.xaml +++ b/MauiCSharpInteropWebView/AppShell.xaml @@ -4,11 +4,11 @@ xmlns="http://schemas.microsoft.com/dotnet/2021/maui" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:local="clr-namespace:MauiCSharpInteropWebView" - Shell.FlyoutBehavior="Disabled"> - - + Shell.FlyoutBehavior="Flyout"> + + + + + diff --git a/MauiCSharpInteropWebView/EmbeddedResourceSample.xaml b/MauiCSharpInteropWebView/EmbeddedResourceSample.xaml new file mode 100644 index 0000000..6d70ff5 --- /dev/null +++ b/MauiCSharpInteropWebView/EmbeddedResourceSample.xaml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/MauiCSharpInteropWebView/EmbeddedResourceSample.xaml.cs b/MauiCSharpInteropWebView/EmbeddedResourceSample.xaml.cs new file mode 100644 index 0000000..507f7a0 --- /dev/null +++ b/MauiCSharpInteropWebView/EmbeddedResourceSample.xaml.cs @@ -0,0 +1,13 @@ +using HybridWebView; +using System.Globalization; +using System.IO.Compression; + +namespace MauiCSharpInteropWebView; + +public partial class EmbeddedResourceSample : ContentPage +{ + public EmbeddedResourceSample() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/MauiCSharpInteropWebView/LoadFromFileSystemSample.xaml b/MauiCSharpInteropWebView/LoadFromFileSystemSample.xaml new file mode 100644 index 0000000..80e1d45 --- /dev/null +++ b/MauiCSharpInteropWebView/LoadFromFileSystemSample.xaml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/MauiCSharpInteropWebView/LoadFromFileSystemSample.xaml.cs b/MauiCSharpInteropWebView/LoadFromFileSystemSample.xaml.cs new file mode 100644 index 0000000..36bd411 --- /dev/null +++ b/MauiCSharpInteropWebView/LoadFromFileSystemSample.xaml.cs @@ -0,0 +1,118 @@ +using Microsoft.Maui.Storage; +using SQLite; + +namespace MauiCSharpInteropWebView; + +public partial class LoadFromFileSystemSample : ContentPage +{ + public LoadFromFileSystemSample() + { + //For this sample we need a HTML file in the file system. + //We will copy the file from the app package Raw folder to the app data storage. If it isn't already there. + + LoadFileIntoFileSystem("SampleFileSystemPage.html"); + LoadFileIntoFileSystem("SampleFileSystemImage.png"); + + InitializeComponent(); + + //Set the target for JavaScript interop. + myHybridWebView.JSInvokeTarget = new MyJSInvokeTarget(); + + //Monitor proxy requests. + myHybridWebView.ProxyRequestReceived += WebView_ProxyRequestReceived; + } + + #region Private Methods + + private async void LoadFileIntoFileSystem(string assetPath) + { + //Get local file path to app data storage. + var localPath = Path.Combine(FileSystem.AppDataDirectory, Path.GetFileName(assetPath)); + + //Check to see if the file exists in the app data storage already. + if (!File.Exists(localPath)) + { + //If it doesn't, assume it is an asset and copy the file to local app data storage access Raw folder. + using (var asset = await FileSystem.OpenAppPackageFileAsync(assetPath)) + { + using (var file = File.Create(localPath)) + { + asset.CopyTo(file); + } + } + } + } + + private async Task WebView_ProxyRequestReceived(HybridWebView.HybridWebViewProxyEventArgs args) + { + // Check to see if our custom parameter is present. + if (args.QueryParams.ContainsKey("operation")) + { + switch (args.QueryParams["operation"]) + { + case "loadFromFileSystem": + if (args.QueryParams.TryGetValue("fileName", out string fileName) && !string.IsNullOrWhiteSpace(fileName)) + { + var filePath = System.IO.Path.Combine(FileSystem.Current.AppDataDirectory, Path.GetFileName(fileName)); + + //Check to see if the file exists. + if (File.Exists(filePath)) + { + try + { + using (var fs = System.IO.File.OpenRead(filePath)) + { + var ms = new MemoryStream(); + await fs.CopyToAsync(ms); + ms.Position = 0; + + args.ResponseStream = ms; + } + } + catch (Exception ex) + { + Console.Write(ex.Message); + } + } + } + break; + default: + break; + } + } + } + + private sealed class MyJSInvokeTarget + { + public MyJSInvokeTarget() + { + } + + /// + /// An example of a round trip method that takes an input parameter and returns a simple value type (number). + /// + /// + /// + public double Fibonacci(int n) + { + if (n == 0) return 0; + + int prev = 0; + int next = 1; + for (int i = 1; i < n; i++) + { + int sum = prev + next; + prev = next; + next = sum; + } + return next; + } + } + + #endregion + + private void OnHybridWebViewRawMessageReceived(object sender, HybridWebView.HybridWebViewRawMessageReceivedEventArgs e) + { + + } +} \ No newline at end of file diff --git a/MauiCSharpInteropWebView/MainPage.xaml.cs b/MauiCSharpInteropWebView/MainPage.xaml.cs index 9a528dd..be65e34 100644 --- a/MauiCSharpInteropWebView/MainPage.xaml.cs +++ b/MauiCSharpInteropWebView/MainPage.xaml.cs @@ -1,4 +1,5 @@ using HybridWebView; +using System.Diagnostics; using System.Globalization; using System.IO.Compression; using System.Text; @@ -83,6 +84,36 @@ private async Task MyHybridWebView_OnProxyRequestReceived(HybridWebView.HybridWe { switch (args.QueryParams["operation"]) { + case "fetchWithCors": + if(args.QueryParams.TryGetValue("url", out string urlParam) && !string.IsNullOrWhiteSpace(urlParam)) + { + // Fetch a URL with CORS enabled. +#if ANDROID + var client = new HttpClient(new Xamarin.Android.Net.AndroidMessageHandler()); +#else + var client = new HttpClient(); +#endif + //Enable Cors + client.DefaultRequestHeaders.Add("Access-Control-Allow-Origin", "*"); + + try + { + var response = await client.GetAsync(urlParam); + if (response.IsSuccessStatusCode) + { + args.ResponseStream = await response.Content.ReadAsStreamAsync(); + if (response.Content?.Headers?.ContentType != null) + { + args.ResponseContentType = response.Content.Headers.ContentType.MediaType; + } + } + } + catch (Exception ex) + { + Debug.WriteLine("Error proxying request: " + ex.Message); + } + } + break; case "loadImageFromZip": // Ensure the file name parameter is present. if (args.QueryParams.TryGetValue("fileName", out string fileName) && fileName != null) diff --git a/MauiCSharpInteropWebView/MauiCSharpInteropWebView.csproj b/MauiCSharpInteropWebView/MauiCSharpInteropWebView.csproj index 36cb1ec..6ed7b3d 100644 --- a/MauiCSharpInteropWebView/MauiCSharpInteropWebView.csproj +++ b/MauiCSharpInteropWebView/MauiCSharpInteropWebView.csproj @@ -48,6 +48,11 @@ + + + + + @@ -57,7 +62,23 @@ + + + + LoadFromFileSystemSample.xaml + + + + + + MSBuild:Compile + + + MSBuild:Compile + + + diff --git a/MauiCSharpInteropWebView/Resources/Raw/SampleFileSystemImage.png b/MauiCSharpInteropWebView/Resources/Raw/SampleFileSystemImage.png new file mode 100644 index 0000000..dcfdf0b Binary files /dev/null and b/MauiCSharpInteropWebView/Resources/Raw/SampleFileSystemImage.png differ diff --git a/MauiCSharpInteropWebView/Resources/Raw/SampleFileSystemPage.html b/MauiCSharpInteropWebView/Resources/Raw/SampleFileSystemPage.html new file mode 100644 index 0000000..6203f9b --- /dev/null +++ b/MauiCSharpInteropWebView/Resources/Raw/SampleFileSystemPage.html @@ -0,0 +1,45 @@ + + + + + + + + + + +

HybridWebView demo: Local File system page

+
+ This web page is loaded from the local file system (FileSystem.Current.AppDataDirectory). + It can access resources from in the same way as normal via the HybridAssetRoot and other embedded resources. + Native/JS communication works as expected. The proxy can be further leveraged to reference + local file system files within the web pages (e.g. images). This is useful when you have resources that have + been downloaded and saved to the file system (app data directory). + +

+ +

+ +

+ +

+ The following image is via the local file system use the proxy: +
+
+ +

+
+ + + + diff --git a/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/proxy.html b/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/proxy.html index d663807..076da0b 100644 --- a/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/proxy.html +++ b/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/proxy.html @@ -25,6 +25,34 @@ document.getElementById('proxyImage').src = `${window.location.origin}/proxy?operation=loadImageFromWeb&tileId=${randomInt}`; } + //GitHub does not enable CORS on raw files. This will fail normally in a browser. + var noCorsEnabledFile = 'https://github.com/Eilon/MauiHybridWebView/blob/main/HybridWebView/HybridWebView.cs'; + + function fetchNoCors() { + fetchUrl(noCorsEnabledFile); + } + + function fetchWithCorsEnabledProxy() { + fetchUrl(`${window.location.origin}/proxy?operation=fetchWithCors&url=` + encodeURIComponent(noCorsEnabledFile)); + } + + function fetchUrl(url) { + var outputElm = document.getElementById('fetchResponseTextArea'); + outputElm.value = 'Fetching...'; + + fetch(url).then(response => { + if (!response.ok) { + outputElm.value = 'Error: Network response was not ok'; + } + + return response.text(); + }).then(data => { + outputElm.value = data; + }).catch(error => { + outputElm.value = 'There was a problem with the fetch operation: ' + error; + }); + } + //Load the map. window.onload = function () { var map = new maplibregl.Map({ @@ -76,12 +104,19 @@

HybridWebView demo: Proxy



-

The image below uses an img tag with a proxy URL in it's HTML like <img src="/proxy?operation=loadImageFromZip&fileName=happy.jpeg" /> -
+

+

+ Leverage the proxy to access resources that are on non-CORS enabled domains. +

+ + +
+
+