For anyone interested, here's my full solution for downloading a file from a WebView in MAUI on iOS and showing a dialog so the user can choose what to do with it.
For me the biggest issue was that the downloads were supposed to be opened in a new window, which the web view didn't handle as I expected. So I am checking the TargetFrame
of all navigation actions and overriding it as necessary.
It also works with a custom WebViewHandler, but that solution requires more code. On the other hand, it could be re-used for multiple web views.
public partial class MainPage : ContentPage
{
public MainPage()
{
InitializeComponent();
}
protected override void OnHandlerChanged()
{
base.OnHandlerChanged();
#if IOS
var iosWebView = WebView.Handler.PlatformView as WebKit.WKWebView;
// Otherwise swiping doesn't work
iosWebView.AllowsBackForwardNavigationGestures = true;
// Custom navigation delegate for iOS
iosWebView.NavigationDelegate = new MyNavigationDelegate();
#endif
}
}
Platforms\iOS\MyNavigationDelegate.cs:
using Foundation;
using System.Text.RegularExpressions;
using WebKit;
public class MyNavigationDelegate : WKNavigationDelegate
{
private static readonly Regex _fileNameRegex = new("filename\\*?=['\"]?(?:UTF-\\d['\"]*)?([^;\\r\\n\"']*)['\"]?;?", RegexOptions.Compiled);
public override void DecidePolicy(WKWebView webView, WKNavigationAction navigationAction, Action<WKNavigationActionPolicy> decisionHandler)
{
// Can't navigate away from the main window
if (navigationAction.TargetFrame?.MainFrame != true)
{
// Cancel the original action and load the same request in the web view
decisionHandler?.Invoke(WKNavigationActionPolicy.Cancel);
webView.LoadRequest(navigationAction.Request);
return;
}
decisionHandler?.Invoke(WKNavigationActionPolicy.Allow);
}
public override void DecidePolicy(WKWebView webView, WKNavigationAction navigationAction, WKWebpagePreferences preferences, Action<WKNavigationActionPolicy, WKWebpagePreferences> decisionHandler)
{
// Can't navigate away from the main window
if (navigationAction.TargetFrame?.MainFrame != true)
{
// Cancel the original action and load the same request in the web view
decisionHandler?.Invoke(WKNavigationActionPolicy.Cancel, preferences);
webView.LoadRequest(navigationAction.Request);
return;
}
decisionHandler?.Invoke(WKNavigationActionPolicy.Allow, preferences);
}
public override void DecidePolicy(WKWebView webView, WKNavigationResponse navigationResponse, Action<WKNavigationResponsePolicy> decisionHandler)
{
// Determine whether to treat it as a download
if (navigationResponse.Response is NSHttpUrlResponse response
&& response.AllHeaderFields.TryGetValue(new NSString("Content-Disposition"), out var headerValue))
{
// Handle it as a download and prevent further navigation
StartDownload(headerValue.ToString(), navigationResponse.Response.Url);
decisionHandler?.Invoke(WKNavigationResponsePolicy.Cancel);
return;
}
decisionHandler?.Invoke(WKNavigationResponsePolicy.Allow);
}
private void StartDownload(string contentDispositionHeader, NSUrl url)
{
try
{
var message = TryGetFileNameFromContentDisposition(contentDispositionHeader, out var fileName)
? $"Downloading {fileName}..."
: "Downloading...";
// TODO: Show toast message
NSUrlSession
.FromConfiguration(NSUrlSessionConfiguration.DefaultSessionConfiguration, new MyDownloadDelegate(), null)
.CreateDownloadTask(url)
.Resume();
}
catch (NSErrorException ex)
{
// TODO: Show toast message
}
}
private bool TryGetFileNameFromContentDisposition(string contentDisposition, out string fileName)
{
if (string.IsNullOrEmpty(contentDisposition))
{
fileName = null;
return false;
}
var match = _fileNameRegex.Match(contentDisposition);
if (!match.Success)
{
fileName = null;
return false;
}
// Use first match even though there could be several matched file names
fileName = match.Groups[1].Value;
return true;
}
}
Platforms\iOS\MyDownloadDelegate.cs:
using CoreFoundation;
using Foundation;
using UIKit;
using UniformTypeIdentifiers;
public class MyDownloadDelegate : NSUrlSessionDownloadDelegate
{
public override void DidFinishDownloading(NSUrlSession session, NSUrlSessionDownloadTask downloadTask, NSUrl location)
{
try
{
if (downloadTask.Response == null)
{
return;
}
// Determine the cache folder
var fileManager = NSFileManager.DefaultManager;
var tempDir = fileManager.GetUrls(NSSearchPathDirectory.CachesDirectory, NSSearchPathDomain.User).FirstOrDefault();
if (tempDir == null)
{
return;
}
var contentType = UTType.CreateFromMimeType(downloadTask.Response.MimeType);
if (contentType == null)
{
return;
}
// Determine the file name in the cache folder
var destinationPath = tempDir.AppendPathComponent(downloadTask.Response.SuggestedFilename, contentType);
if (destinationPath == null || string.IsNullOrEmpty(destinationPath.Path))
{
return;
}
// Remove any existing files with the same name
if (fileManager.FileExists(destinationPath.Path) && !fileManager.Remove(destinationPath, out var removeError))
{
return;
}
// Copy the downloaded file from the OS temp folder to our cache folder
if (!fileManager.Copy(location, destinationPath, out var copyError))
{
return;
}
DispatchQueue.MainQueue.DispatchAsync(() =>
{
ShowFileOpenDialog(destinationPath);
});
}
catch (NSErrorException ex)
{
// TODO: Show toast message
}
}
private void ShowFileOpenDialog(NSUrl fileUrl)
{
try
{
var window = UIApplication.SharedApplication.Windows.Last(x => x.IsKeyWindow);
var viewController = window.RootViewController;
if (viewController == null || viewController.View == null)
{
return;
}
// TODO: Apps sometimes cannot open the file
var documentController = UIDocumentInteractionController.FromUrl(fileUrl);
documentController.PresentOpenInMenu(viewController.View.Frame, viewController.View, true);
}
catch (NSErrorException ex)
{
// TODO: Show toast message
}
}
}