("#app");
+
+ _host = builder.Build();
+ SetContentView(_host.RootView);
+ _host.Start();
+ }
+
+ protected override async void OnDestroy()
+ {
+ if (_host is not null)
+ await _host.DisposeAsync();
+ base.OnDestroy();
+ }
+}
diff --git a/samples/Mobile/Shared.Android/MainApplication.cs b/samples/Mobile/Shared.Android/MainApplication.cs
new file mode 100644
index 0000000..2dbe08b
--- /dev/null
+++ b/samples/Mobile/Shared.Android/MainApplication.cs
@@ -0,0 +1,14 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using Android.App;
+using Android.Runtime;
+
+namespace Shared.Android;
+
+[Application]
+public class MainApplication : Application
+{
+ public MainApplication(IntPtr handle, JniHandleOwnership transfer)
+ : base(handle, transfer)
+ {
+ }
+}
diff --git a/samples/Mobile/Shared.Android/Resources/values/styles.xml b/samples/Mobile/Shared.Android/Resources/values/styles.xml
new file mode 100644
index 0000000..b8eb3aa
--- /dev/null
+++ b/samples/Mobile/Shared.Android/Resources/values/styles.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/samples/Mobile/Shared.Android/Resources/wwwroot/index.html b/samples/Mobile/Shared.Android/Resources/wwwroot/index.html
new file mode 100644
index 0000000..98c95af
--- /dev/null
+++ b/samples/Mobile/Shared.Android/Resources/wwwroot/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+ Shared Blazor — Android
+
+
+
+
+
+
+
+
diff --git a/samples/Mobile/Shared.Android/Shared.Android.csproj b/samples/Mobile/Shared.Android/Shared.Android.csproj
new file mode 100644
index 0000000..877b036
--- /dev/null
+++ b/samples/Mobile/Shared.Android/Shared.Android.csproj
@@ -0,0 +1,55 @@
+
+
+ net10.0-android
+ 24
+ Exe
+ enable
+ enable
+ Shared.Android
+ com.mythetech.hermes.shared.android
+ Shared Android
+ 1.0
+ 1
+
+
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ wwwroot/%(RecursiveDir)%(Filename)%(Extension)
+
+
+
+ wwwroot/_framework/blazor.webview.js
+
+
+ wwwroot/_framework/blazor.modules.json
+
+
+
+ wwwroot/_content/Shared.App/%(RecursiveDir)%(Filename)%(Extension)
+
+
+
diff --git a/samples/Mobile/Shared.App/App.razor b/samples/Mobile/Shared.App/App.razor
new file mode 100644
index 0000000..4cb0f18
--- /dev/null
+++ b/samples/Mobile/Shared.App/App.razor
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+ Page not found
+
+
+
diff --git a/samples/Mobile/Shared.App/Layout/MainLayout.razor b/samples/Mobile/Shared.App/Layout/MainLayout.razor
new file mode 100644
index 0000000..b8f2c5c
--- /dev/null
+++ b/samples/Mobile/Shared.App/Layout/MainLayout.razor
@@ -0,0 +1,10 @@
+@inherits LayoutComponentBase
+
+
+
+
+ @Body
+
+
diff --git a/samples/Mobile/Shared.App/Layout/NavMenu.razor b/samples/Mobile/Shared.App/Layout/NavMenu.razor
new file mode 100644
index 0000000..10f0b65
--- /dev/null
+++ b/samples/Mobile/Shared.App/Layout/NavMenu.razor
@@ -0,0 +1,5 @@
+
diff --git a/samples/Mobile/Shared.App/Pages/ClipboardDemo.razor b/samples/Mobile/Shared.App/Pages/ClipboardDemo.razor
new file mode 100644
index 0000000..8bcd97e
--- /dev/null
+++ b/samples/Mobile/Shared.App/Pages/ClipboardDemo.razor
@@ -0,0 +1,51 @@
+@page "/clipboard"
+@inject IClipboard Clipboard
+
+Clipboard Demo
+
+
+
+
+
+
+
+
+
+Last read value: @_lastRead
+
+@if (!string.IsNullOrEmpty(_error))
+{
+ @_error
+}
+
+@code {
+ private string _text = "hello from hermes";
+ private string? _lastRead;
+ private string? _error;
+
+ private async Task SetAsync()
+ {
+ _error = null;
+ try
+ {
+ await Clipboard.SetTextAsync(_text);
+ }
+ catch (Exception ex)
+ {
+ _error = $"SetTextAsync failed: {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}";
+ }
+ }
+
+ private async Task GetAsync()
+ {
+ _error = null;
+ try
+ {
+ _lastRead = await Clipboard.GetTextAsync();
+ }
+ catch (Exception ex)
+ {
+ _error = $"GetTextAsync failed: {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}";
+ }
+ }
+}
diff --git a/samples/Mobile/Shared.App/Pages/Counter.razor b/samples/Mobile/Shared.App/Pages/Counter.razor
new file mode 100644
index 0000000..53e5fde
--- /dev/null
+++ b/samples/Mobile/Shared.App/Pages/Counter.razor
@@ -0,0 +1,10 @@
+@page "/counter"
+
+Counter
+Current count: @currentCount
+
+
+@code {
+ private int currentCount = 0;
+ private void IncrementCount() => currentCount++;
+}
diff --git a/samples/Mobile/Shared.App/Pages/Index.razor b/samples/Mobile/Shared.App/Pages/Index.razor
new file mode 100644
index 0000000..d2c843b
--- /dev/null
+++ b/samples/Mobile/Shared.App/Pages/Index.razor
@@ -0,0 +1,8 @@
+@page "/"
+
+Hermes Shared Blazor
+This Razor Class Library runs unmodified on desktop (via Hermes.Blazor) and iOS (via Hermes.Mobile).
+
+ - Counter — proves the JS↔C# bridge round-trips.
+ - Clipboard demo — proves the IClipboard plugin works per-platform.
+
diff --git a/samples/Mobile/Shared.App/Shared.App.csproj b/samples/Mobile/Shared.App/Shared.App.csproj
new file mode 100644
index 0000000..e04ac57
--- /dev/null
+++ b/samples/Mobile/Shared.App/Shared.App.csproj
@@ -0,0 +1,16 @@
+
+
+ net10.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Mobile/Shared.App/_Imports.razor b/samples/Mobile/Shared.App/_Imports.razor
new file mode 100644
index 0000000..b2b5b18
--- /dev/null
+++ b/samples/Mobile/Shared.App/_Imports.razor
@@ -0,0 +1,10 @@
+@using System.Net.Http
+@using Microsoft.AspNetCore.Components
+@using Microsoft.AspNetCore.Components.Forms
+@using Microsoft.AspNetCore.Components.Routing
+@using Microsoft.AspNetCore.Components.Web
+@using Microsoft.JSInterop
+@using Hermes.Contracts.Plugins
+@using Shared.App
+@using Shared.App.Pages
+@using Shared.App.Layout
diff --git a/samples/Mobile/Shared.App/wwwroot/css/app.css b/samples/Mobile/Shared.App/wwwroot/css/app.css
new file mode 100644
index 0000000..70ce5e9
--- /dev/null
+++ b/samples/Mobile/Shared.App/wwwroot/css/app.css
@@ -0,0 +1,12 @@
+body { font-family: -apple-system, system-ui, sans-serif; margin: 0; padding: 0; }
+.layout { display: flex; min-height: 100vh; }
+.sidebar { width: 160px; padding: 16px; background: #f5f5f7; border-right: 1px solid #d2d2d7; }
+.sidebar nav { display: flex; flex-direction: column; gap: 8px; }
+.sidebar a { color: #0a84ff; text-decoration: none; }
+.sidebar a:hover { text-decoration: underline; }
+.content { flex: 1; padding: 24px; }
+.btn-primary { padding: 8px 16px; background: #0a84ff; color: white; border: none; border-radius: 6px; cursor: pointer; }
+.btn-primary:hover { background: #006edc; }
+.actions { display: flex; gap: 8px; margin: 12px 0; }
+input { padding: 8px; font-size: 14px; border: 1px solid #d2d2d7; border-radius: 6px; width: 240px; }
+code { background: #f5f5f7; padding: 2px 6px; border-radius: 4px; }
diff --git a/samples/Mobile/Shared.Desktop/Program.cs b/samples/Mobile/Shared.Desktop/Program.cs
new file mode 100644
index 0000000..267188a
--- /dev/null
+++ b/samples/Mobile/Shared.Desktop/Program.cs
@@ -0,0 +1,21 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using Hermes;
+using Hermes.Blazor;
+using Shared.App;
+
+HermesWindow.Prewarm();
+
+var builder = HermesBlazorAppBuilder.CreateDefault(args);
+builder.ConfigureWindow(opts =>
+{
+ opts.Title = "Shared Blazor — Desktop";
+ opts.Width = 900;
+ opts.Height = 700;
+ opts.CenterOnScreen = true;
+ opts.DevToolsEnabled = true;
+});
+builder.RootComponents.Add("#app");
+
+var app = builder.Build();
+app.Run();
+await app.DisposeAsync();
diff --git a/samples/Mobile/Shared.Desktop/Shared.Desktop.csproj b/samples/Mobile/Shared.Desktop/Shared.Desktop.csproj
new file mode 100644
index 0000000..e878e29
--- /dev/null
+++ b/samples/Mobile/Shared.Desktop/Shared.Desktop.csproj
@@ -0,0 +1,25 @@
+
+
+ WinExe
+ net10.0
+ enable
+ enable
+ Shared.Desktop
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Mobile/Shared.Desktop/wwwroot/index.html b/samples/Mobile/Shared.Desktop/wwwroot/index.html
new file mode 100644
index 0000000..dcabab1
--- /dev/null
+++ b/samples/Mobile/Shared.Desktop/wwwroot/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+ Shared Blazor — Desktop
+
+
+
+
+
+
+
+
diff --git a/samples/Mobile/Shared.Mobile/AppDelegate.cs b/samples/Mobile/Shared.Mobile/AppDelegate.cs
new file mode 100644
index 0000000..808bc1a
--- /dev/null
+++ b/samples/Mobile/Shared.Mobile/AppDelegate.cs
@@ -0,0 +1,32 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using Foundation;
+using Hermes.Mobile.iOS;
+using UIKit;
+
+namespace Shared.Mobile;
+
+[Register("AppDelegate")]
+public class AppDelegate : UIApplicationDelegate
+{
+ public override UIWindow? Window { get; set; }
+
+ private HermesMobileHost? _host;
+
+ public override bool FinishedLaunching(UIApplication application, NSDictionary launchOptions)
+ {
+ var builder = HermesMobileAppBuilder.CreateDefault();
+ builder.RootComponents.Add("#app");
+ // IClipboard is auto-registered to IOSClipboard in CreateDefault.
+
+ _host = builder.Build();
+
+ Window = new UIWindow(UIScreen.MainScreen.Bounds)
+ {
+ RootViewController = _host.RootViewController
+ };
+ Window.MakeKeyAndVisible();
+
+ _host.Start();
+ return true;
+ }
+}
diff --git a/samples/Mobile/Shared.Mobile/Info.plist b/samples/Mobile/Shared.Mobile/Info.plist
new file mode 100644
index 0000000..f5947da
--- /dev/null
+++ b/samples/Mobile/Shared.Mobile/Info.plist
@@ -0,0 +1,39 @@
+
+
+
+
+ CFBundleIdentifier
+ com.mythetech.hermes.shared.mobile
+ CFBundleName
+ Shared Mobile
+ CFBundleDisplayName
+ Shared Mobile
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ LSRequiresIPhoneOS
+
+ UILaunchScreen
+
+ UIColorName
+ systemBackgroundColor
+
+ NSAppTransportSecurity
+
+ NSAllowsLocalNetworking
+
+
+ MinimumOSVersion
+ 15.0
+ UIDeviceFamily
+
+ 1
+ 2
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+
+
+
diff --git a/samples/Mobile/Shared.Mobile/Program.cs b/samples/Mobile/Shared.Mobile/Program.cs
new file mode 100644
index 0000000..22d4fb1
--- /dev/null
+++ b/samples/Mobile/Shared.Mobile/Program.cs
@@ -0,0 +1,12 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using UIKit;
+
+namespace Shared.Mobile;
+
+public static class Program
+{
+ public static void Main(string[] args)
+ {
+ UIApplication.Main(args, null, typeof(AppDelegate));
+ }
+}
diff --git a/samples/Mobile/Shared.Mobile/Resources/wwwroot/index.html b/samples/Mobile/Shared.Mobile/Resources/wwwroot/index.html
new file mode 100644
index 0000000..4172dd5
--- /dev/null
+++ b/samples/Mobile/Shared.Mobile/Resources/wwwroot/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+ Shared Blazor — iOS
+
+
+
+
+
+
+
+
diff --git a/samples/Mobile/Shared.Mobile/Shared.Mobile.csproj b/samples/Mobile/Shared.Mobile/Shared.Mobile.csproj
new file mode 100644
index 0000000..444d189
--- /dev/null
+++ b/samples/Mobile/Shared.Mobile/Shared.Mobile.csproj
@@ -0,0 +1,60 @@
+
+
+ net10.0-ios
+ 15.0
+ Exe
+ enable
+ enable
+ Shared.Mobile
+ com.mythetech.hermes.shared.mobile
+ Shared Mobile
+ 1.0
+ 1
+
+ static
+
+
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ wwwroot/_framework/blazor.webview.js
+
+
+ wwwroot/_framework/blazor.modules.json
+
+
+
+
+ wwwroot/_content/Shared.App/%(RecursiveDir)%(Filename)%(Extension)
+
+
+
diff --git a/src/Hermes.Mobile.Android/Hermes.Mobile.Android.csproj b/src/Hermes.Mobile.Android/Hermes.Mobile.Android.csproj
new file mode 100644
index 0000000..28abfe6
--- /dev/null
+++ b/src/Hermes.Mobile.Android/Hermes.Mobile.Android.csproj
@@ -0,0 +1,38 @@
+
+
+ net10.0-android
+ 24
+ enable
+ enable
+ true
+
+
+
+
+ Mythetech.Hermes.Mobile.Android
+ Mythetech
+ Android host for Hermes Blazor apps — hosts a Blazor app in an Android WebView, plugin-compatible with Hermes.Contracts interfaces.
+ hermes;mobile;android;blazor;webview
+ https://github.com/Mythetech/Hermes
+ https://github.com/Mythetech/Hermes.git
+ git
+ LICENSE
+ README.md
+ true
+ snupkg
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Hermes.Mobile.Android/HermesMobileAndroidBuilder.cs b/src/Hermes.Mobile.Android/HermesMobileAndroidBuilder.cs
new file mode 100644
index 0000000..65d398a
--- /dev/null
+++ b/src/Hermes.Mobile.Android/HermesMobileAndroidBuilder.cs
@@ -0,0 +1,63 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using System.Diagnostics.CodeAnalysis;
+using Android.Content;
+using Hermes.Contracts.Plugins;
+using Hermes.Mobile.Android.Plugins;
+using Hermes.Mobile.Android.WebView;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.WebView;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Hermes.Mobile.Android;
+
+public sealed class HermesMobileAndroidBuilder : IMobileBuilder
+{
+ private readonly Context _context;
+ private string _hostPage = "wwwroot/index.html";
+
+ private HermesMobileAndroidBuilder(Context context)
+ {
+ _context = context;
+ Services = new ServiceCollection();
+ Services.AddBlazorWebView();
+ Services.AddSingleton(new AndroidClipboard(context));
+ }
+
+ public static HermesMobileAndroidBuilder CreateDefault(Context context) => new(context);
+
+ public IServiceCollection Services { get; }
+
+ public RootComponentCollection RootComponents { get; } = new();
+
+ IMobileBuilder IMobileBuilder.UseHostPage(string hostPage)
+ {
+ _hostPage = hostPage;
+ return this;
+ }
+
+ public HermesMobileAndroidBuilder UseHostPage(string hostPage)
+ {
+ _hostPage = hostPage;
+ return this;
+ }
+
+ [RequiresDynamicCode("Blazor WebView requires dynamic code for component rendering")]
+ [RequiresUnreferencedCode("Blazor WebView uses reflection for component instantiation")]
+ public HermesMobileAndroidHost Build()
+ {
+ var provider = Services.BuildServiceProvider();
+
+ var contentRoot = Path.GetDirectoryName(_hostPage) ?? string.Empty;
+ var hostPageRelative = Path.GetRelativePath(
+ string.IsNullOrEmpty(contentRoot) ? "." : contentRoot,
+ _hostPage);
+
+ var fileProvider = new AndroidAssetFileProvider(_context.Assets!, contentRoot);
+
+ var components = RootComponents.GetComponents()
+ .Select(c => (c.Type, c.Selector))
+ .ToList();
+
+ return new HermesMobileAndroidHost(_context, provider, fileProvider, hostPageRelative, components);
+ }
+}
diff --git a/src/Hermes.Mobile.Android/HermesMobileAndroidHost.cs b/src/Hermes.Mobile.Android/HermesMobileAndroidHost.cs
new file mode 100644
index 0000000..3a8bbbd
--- /dev/null
+++ b/src/Hermes.Mobile.Android/HermesMobileAndroidHost.cs
@@ -0,0 +1,88 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using System.Diagnostics.CodeAnalysis;
+using Android.Content;
+using Android.Views;
+using Hermes.Mobile.Android.WebView;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Web;
+using Microsoft.Extensions.FileProviders;
+
+namespace Hermes.Mobile.Android;
+
+public sealed class HermesMobileAndroidHost : IMobileHost
+{
+ private readonly IServiceProvider _services;
+ private readonly global::Android.Webkit.WebView _webView;
+ private readonly AndroidWebViewManager _manager;
+ private readonly List<(Type Type, string Selector)> _rootComponents;
+
+ private bool _started;
+
+ private static readonly Uri AppBaseUri = new("https://0.0.0.0/");
+
+ [RequiresDynamicCode("Blazor WebView requires dynamic code for component rendering")]
+ [RequiresUnreferencedCode("Blazor WebView uses reflection for component instantiation")]
+ internal HermesMobileAndroidHost(
+ Context context,
+ IServiceProvider services,
+ IFileProvider fileProvider,
+ string hostPageRelativePath,
+ IReadOnlyList<(Type Type, string Selector)> rootComponents)
+ {
+ _services = services;
+ _rootComponents = new List<(Type, string)>(rootComponents);
+
+ _webView = new global::Android.Webkit.WebView(context);
+ var settings = _webView.Settings;
+ settings.JavaScriptEnabled = true;
+ settings.DomStorageEnabled = true;
+ settings.AllowFileAccess = true;
+
+#if DEBUG
+ global::Android.Webkit.WebView.SetWebContentsDebuggingEnabled(true);
+#endif
+
+ var dispatcher = new Threading.AndroidDispatcher();
+ var jsComponents = new JSComponentConfigurationStore();
+
+ _manager = new AndroidWebViewManager(
+ _webView, services, dispatcher, AppBaseUri, fileProvider, jsComponents, hostPageRelativePath);
+
+ var bridge = new JsBridge(message =>
+ _manager.MessageReceivedInternal(AppBaseUri, message));
+ _webView.AddJavascriptInterface(bridge, JsBridge.Name);
+
+ var webViewClient = new HermesWebViewClient(OnPageFinished);
+ webViewClient.SetManager(_manager);
+ _webView.SetWebViewClient(webViewClient);
+ _webView.SetWebChromeClient(new HermesWebChromeClient());
+ }
+
+ public View RootView => _webView;
+
+ [RequiresDynamicCode("Blazor WebView requires dynamic code for component rendering")]
+ [RequiresUnreferencedCode("Blazor WebView uses reflection for component instantiation")]
+ public void Start()
+ {
+ if (_started) return;
+ _started = true;
+
+ foreach (var (type, selector) in _rootComponents)
+ {
+ _manager.AddRootComponentAsync(type, selector, ParameterView.Empty).GetAwaiter().GetResult();
+ }
+
+ _manager.Navigate("/");
+ }
+
+ private void OnPageFinished()
+ {
+ _webView.EvaluateJavascript(BlazorInitScript.Contents, null);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _manager.DisposeAsync();
+ _webView.Dispose();
+ }
+}
diff --git a/src/Hermes.Mobile.Android/Plugins/AndroidClipboard.cs b/src/Hermes.Mobile.Android/Plugins/AndroidClipboard.cs
new file mode 100644
index 0000000..948a12a
--- /dev/null
+++ b/src/Hermes.Mobile.Android/Plugins/AndroidClipboard.cs
@@ -0,0 +1,33 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using Android.Content;
+using Hermes.Contracts.Plugins;
+
+namespace Hermes.Mobile.Android.Plugins;
+
+public sealed class AndroidClipboard : IClipboard
+{
+ private readonly ClipboardManager _clipboard;
+
+ public AndroidClipboard(Context context)
+ {
+ _clipboard = (ClipboardManager)context.GetSystemService(Context.ClipboardService)!;
+ }
+
+ public Task SetTextAsync(string text, CancellationToken ct = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(text);
+ var clip = ClipData.NewPlainText("Hermes", text);
+ _clipboard.PrimaryClip = clip;
+ return Task.CompletedTask;
+ }
+
+ public Task GetTextAsync(CancellationToken ct = default)
+ {
+ var clip = _clipboard.PrimaryClip;
+ if (clip is null || clip.ItemCount == 0)
+ return Task.FromResult(null);
+
+ var text = clip.GetItemAt(0)?.Text;
+ return Task.FromResult(text);
+ }
+}
diff --git a/src/Hermes.Mobile.Android/Threading/AndroidDispatcher.cs b/src/Hermes.Mobile.Android/Threading/AndroidDispatcher.cs
new file mode 100644
index 0000000..e359617
--- /dev/null
+++ b/src/Hermes.Mobile.Android/Threading/AndroidDispatcher.cs
@@ -0,0 +1,74 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using Android.OS;
+using Microsoft.AspNetCore.Components;
+
+namespace Hermes.Mobile.Android.Threading;
+
+internal sealed class AndroidDispatcher : Dispatcher
+{
+ private readonly Handler _handler = new(Looper.MainLooper!);
+
+ public override bool CheckAccess() => Looper.MyLooper() == Looper.MainLooper;
+
+ public override Task InvokeAsync(Action workItem)
+ {
+ if (CheckAccess())
+ {
+ try { workItem(); return Task.CompletedTask; }
+ catch (Exception ex) { return Task.FromException(ex); }
+ }
+
+ var tcs = new TaskCompletionSource();
+ _handler.Post(() =>
+ {
+ try { workItem(); tcs.SetResult(); }
+ catch (Exception ex) { tcs.SetException(ex); }
+ });
+ return tcs.Task;
+ }
+
+ public override Task InvokeAsync(Func workItem)
+ {
+ if (CheckAccess())
+ return workItem();
+
+ var tcs = new TaskCompletionSource();
+ _handler.Post(async () =>
+ {
+ try { await workItem().ConfigureAwait(false); tcs.SetResult(); }
+ catch (Exception ex) { tcs.SetException(ex); }
+ });
+ return tcs.Task;
+ }
+
+ public override Task InvokeAsync(Func workItem)
+ {
+ if (CheckAccess())
+ {
+ try { return Task.FromResult(workItem()); }
+ catch (Exception ex) { return Task.FromException(ex); }
+ }
+
+ var tcs = new TaskCompletionSource();
+ _handler.Post(() =>
+ {
+ try { tcs.SetResult(workItem()); }
+ catch (Exception ex) { tcs.SetException(ex); }
+ });
+ return tcs.Task;
+ }
+
+ public override Task InvokeAsync(Func> workItem)
+ {
+ if (CheckAccess())
+ return workItem();
+
+ var tcs = new TaskCompletionSource();
+ _handler.Post(async () =>
+ {
+ try { tcs.SetResult(await workItem().ConfigureAwait(false)); }
+ catch (Exception ex) { tcs.SetException(ex); }
+ });
+ return tcs.Task;
+ }
+}
diff --git a/src/Hermes.Mobile.Android/WebView/AndroidAssetFileProvider.cs b/src/Hermes.Mobile.Android/WebView/AndroidAssetFileProvider.cs
new file mode 100644
index 0000000..7f31d8f
--- /dev/null
+++ b/src/Hermes.Mobile.Android/WebView/AndroidAssetFileProvider.cs
@@ -0,0 +1,66 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using Android.Content.Res;
+using Microsoft.Extensions.FileProviders;
+using Microsoft.Extensions.Primitives;
+
+namespace Hermes.Mobile.Android.WebView;
+
+internal sealed class AndroidAssetFileProvider : IFileProvider
+{
+ private readonly AssetManager _assets;
+ private readonly string _root;
+
+ public AndroidAssetFileProvider(AssetManager assets, string contentRoot)
+ {
+ _assets = assets ?? throw new ArgumentNullException(nameof(assets));
+ _root = contentRoot.TrimEnd('/');
+ }
+
+ public IFileInfo GetFileInfo(string subpath)
+ {
+ if (string.IsNullOrEmpty(subpath))
+ return new NotFoundFileInfo(subpath);
+
+ var normalized = subpath.TrimStart('/');
+
+ if (normalized.Contains(".."))
+ return new NotFoundFileInfo(subpath);
+
+ var assetPath = string.IsNullOrEmpty(_root) ? normalized : $"{_root}/{normalized}";
+
+ try
+ {
+ var stream = _assets.Open(assetPath);
+ return new AndroidAssetFileInfo(stream, Path.GetFileName(normalized));
+ }
+ catch (Java.IO.FileNotFoundException)
+ {
+ return new NotFoundFileInfo(subpath);
+ }
+ }
+
+ public IDirectoryContents GetDirectoryContents(string subpath)
+ => NotFoundDirectoryContents.Singleton;
+
+ public IChangeToken Watch(string filter) => NullChangeToken.Singleton;
+
+ private sealed class AndroidAssetFileInfo : IFileInfo
+ {
+ private readonly System.IO.Stream _stream;
+
+ public AndroidAssetFileInfo(System.IO.Stream stream, string name)
+ {
+ _stream = stream;
+ Name = name;
+ }
+
+ public bool Exists => true;
+ public long Length => -1;
+ public string? PhysicalPath => null;
+ public string Name { get; }
+ public DateTimeOffset LastModified => DateTimeOffset.MinValue;
+ public bool IsDirectory => false;
+
+ public System.IO.Stream CreateReadStream() => _stream;
+ }
+}
diff --git a/src/Hermes.Mobile.Android/WebView/AndroidWebViewManager.cs b/src/Hermes.Mobile.Android/WebView/AndroidWebViewManager.cs
new file mode 100644
index 0000000..5744708
--- /dev/null
+++ b/src/Hermes.Mobile.Android/WebView/AndroidWebViewManager.cs
@@ -0,0 +1,97 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Encodings.Web;
+using Android.OS;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Web;
+using Microsoft.AspNetCore.Components.WebView;
+using Microsoft.Extensions.FileProviders;
+
+using Hermes.Mobile.WebView;
+
+namespace Hermes.Mobile.Android.WebView;
+
+internal sealed class AndroidWebViewManager : WebViewManager
+{
+ private readonly global::Android.Webkit.WebView _webView;
+ private readonly Uri _appBaseUri;
+ private readonly Handler _handler = new(Looper.MainLooper!);
+
+ [RequiresDynamicCode("Blazor WebView requires dynamic code for component rendering")]
+ [RequiresUnreferencedCode("Blazor WebView uses reflection for component instantiation")]
+ public AndroidWebViewManager(
+ global::Android.Webkit.WebView webView,
+ IServiceProvider services,
+ Dispatcher dispatcher,
+ Uri appBaseUri,
+ IFileProvider fileProvider,
+ JSComponentConfigurationStore jsComponents,
+ string hostPageRelativePath)
+ : base(services, dispatcher, appBaseUri, fileProvider, jsComponents, hostPageRelativePath)
+ {
+ _webView = webView;
+ _appBaseUri = appBaseUri;
+ }
+
+ protected override void NavigateCore(Uri absoluteUri)
+ {
+ // Load the host page HTML directly via LoadDataWithBaseURL so the WebView
+ // doesn't attempt a real network request to the synthetic origin.
+ // Sub-resource requests (JS, CSS, etc.) go through ShouldInterceptRequest.
+ if (TryGetResponseContent(
+ absoluteUri.ToString(),
+ allowFallbackOnHostPage: true,
+ out _,
+ out _,
+ out var content,
+ out _))
+ {
+ using var reader = new StreamReader(content);
+ var html = reader.ReadToEnd();
+ content.Dispose();
+
+ _webView.LoadDataWithBaseURL(
+ _appBaseUri.ToString(),
+ html,
+ "text/html",
+ "UTF-8",
+ null);
+ }
+ }
+
+ protected override void SendMessage(string message)
+ {
+ var encoded = JavaScriptEncoder.Default.Encode(message);
+ var js = $"__dispatchMessageCallback(\"{encoded}\")";
+
+ if (Looper.MyLooper() == Looper.MainLooper)
+ {
+ _webView.EvaluateJavascript(js, null);
+ }
+ else
+ {
+ _handler.Post(() => _webView.EvaluateJavascript(js, null));
+ }
+ }
+
+ internal void MessageReceivedInternal(Uri sourceUri, string message)
+ => MessageReceived(sourceUri, message);
+
+ internal WebViewResponse ResolveRequest(string absoluteUrl)
+ {
+ var allowFallbackOnHostPage = _appBaseUri.IsBaseOf(new Uri(absoluteUrl));
+
+ if (TryGetResponseContent(
+ absoluteUrl,
+ allowFallbackOnHostPage,
+ out var statusCode,
+ out _,
+ out var content,
+ out var headers))
+ {
+ return WebViewResolveHelper.ToResponse(statusCode, content, headers, absoluteUrl);
+ }
+
+ return WebViewResponse.NotFound;
+ }
+}
diff --git a/src/Hermes.Mobile.Android/WebView/BlazorInitScript.cs b/src/Hermes.Mobile.Android/WebView/BlazorInitScript.cs
new file mode 100644
index 0000000..0d7ccb2
--- /dev/null
+++ b/src/Hermes.Mobile.Android/WebView/BlazorInitScript.cs
@@ -0,0 +1,30 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+namespace Hermes.Mobile.Android.WebView;
+
+internal static class BlazorInitScript
+{
+ public const string Contents = """
+ if (!window.__hermesInitialized) {
+ window.__hermesInitialized = true;
+
+ window.__receiveMessageCallbacks = [];
+ window.__dispatchMessageCallback = function(message) {
+ window.__receiveMessageCallbacks.forEach(function(callback) { callback(message); });
+ };
+ window.external = {
+ sendMessage: function(message) {
+ HermesBridge.postMessage(message);
+ },
+ receiveMessage: function(callback) {
+ window.__receiveMessageCallbacks.push(callback);
+ }
+ };
+
+ Blazor.start();
+
+ window.onpageshow = function(event) {
+ if (event.persisted) { window.location.reload(); }
+ };
+ }
+ """;
+}
diff --git a/src/Hermes.Mobile.Android/WebView/HermesWebChromeClient.cs b/src/Hermes.Mobile.Android/WebView/HermesWebChromeClient.cs
new file mode 100644
index 0000000..d8d6e8e
--- /dev/null
+++ b/src/Hermes.Mobile.Android/WebView/HermesWebChromeClient.cs
@@ -0,0 +1,23 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using Android.Webkit;
+
+namespace Hermes.Mobile.Android.WebView;
+
+internal sealed class HermesWebChromeClient : WebChromeClient
+{
+ public override bool OnConsoleMessage(ConsoleMessage? consoleMessage)
+ {
+ if (consoleMessage is null)
+ return base.OnConsoleMessage(consoleMessage);
+
+ var messageLevel = consoleMessage.InvokeMessageLevel();
+ var level = "LOG";
+ if (messageLevel == ConsoleMessage.MessageLevel.Error)
+ level = "ERROR";
+ else if (messageLevel == ConsoleMessage.MessageLevel.Warning)
+ level = "WARN";
+
+ Console.WriteLine($"[Hermes.Mobile.Android] [{level}] {consoleMessage.Message()} ({consoleMessage.SourceId()}:{consoleMessage.LineNumber()})");
+ return true;
+ }
+}
diff --git a/src/Hermes.Mobile.Android/WebView/HermesWebViewClient.cs b/src/Hermes.Mobile.Android/WebView/HermesWebViewClient.cs
new file mode 100644
index 0000000..88ccf34
--- /dev/null
+++ b/src/Hermes.Mobile.Android/WebView/HermesWebViewClient.cs
@@ -0,0 +1,69 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using Android.Webkit;
+using Hermes.Mobile.WebView;
+
+namespace Hermes.Mobile.Android.WebView;
+
+internal sealed class HermesWebViewClient : WebViewClient
+{
+ private readonly Action _onPageFinished;
+ private AndroidWebViewManager? _manager;
+
+ public HermesWebViewClient(Action onPageFinished)
+ {
+ _onPageFinished = onPageFinished;
+ }
+
+ internal void SetManager(AndroidWebViewManager manager) => _manager = manager;
+
+ public override bool ShouldOverrideUrlLoading(
+ global::Android.Webkit.WebView? view, IWebResourceRequest? request)
+ {
+ if (request?.Url is null)
+ return false;
+
+ var url = request.Url.ToString();
+ if (url is not null && url.StartsWith("https://0.0.0.0/", StringComparison.Ordinal))
+ return true;
+
+ return false;
+ }
+
+ public override WebResourceResponse? ShouldInterceptRequest(
+ global::Android.Webkit.WebView? view, IWebResourceRequest? request)
+ {
+ if (request?.Url is null || _manager is null)
+ return base.ShouldInterceptRequest(view, request);
+
+ var url = request.Url.ToString();
+ if (url is null)
+ return base.ShouldInterceptRequest(view, request);
+
+ var response = _manager.ResolveRequest(url);
+ if (response.StatusCode == 200 && response.Body.Length > 0)
+ {
+ return new WebResourceResponse(
+ response.ContentType,
+ "UTF-8",
+ response.StatusCode,
+ "OK",
+ new Dictionary { ["Cache-Control"] = "no-cache" },
+ new MemoryStream(response.Body));
+ }
+
+ return base.ShouldInterceptRequest(view, request);
+ }
+
+ public override void OnPageFinished(global::Android.Webkit.WebView? view, string? url)
+ {
+ base.OnPageFinished(view, url);
+ _onPageFinished();
+ }
+
+ public override void OnReceivedError(
+ global::Android.Webkit.WebView? view, IWebResourceRequest? request, WebResourceError? error)
+ {
+ Console.WriteLine($"[Hermes.Mobile.Android] WebView error: {error?.Description} (code {error?.ErrorCode})");
+ base.OnReceivedError(view, request, error);
+ }
+}
diff --git a/src/Hermes.Mobile.Android/WebView/JsBridge.cs b/src/Hermes.Mobile.Android/WebView/JsBridge.cs
new file mode 100644
index 0000000..255a391
--- /dev/null
+++ b/src/Hermes.Mobile.Android/WebView/JsBridge.cs
@@ -0,0 +1,28 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using System.Diagnostics.CodeAnalysis;
+using Android.OS;
+using Android.Webkit;
+using Java.Interop;
+
+namespace Hermes.Mobile.Android.WebView;
+
+internal sealed class JsBridge : Java.Lang.Object
+{
+ public const string Name = "HermesBridge";
+
+ private readonly Action _onMessage;
+ private readonly Handler _handler = new(Looper.MainLooper!);
+
+ public JsBridge(Action onMessage)
+ {
+ _onMessage = onMessage;
+ }
+
+ [JavascriptInterface]
+ [Export("postMessage")]
+ [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Export is required for addJavascriptInterface bridge")]
+ public void PostMessage(string message)
+ {
+ _handler.Post(() => _onMessage(message!));
+ }
+}
diff --git a/src/Hermes.Mobile.iOS/Hermes.Mobile.iOS.csproj b/src/Hermes.Mobile.iOS/Hermes.Mobile.iOS.csproj
new file mode 100644
index 0000000..c91491d
--- /dev/null
+++ b/src/Hermes.Mobile.iOS/Hermes.Mobile.iOS.csproj
@@ -0,0 +1,38 @@
+
+
+ net10.0-ios
+ 15.0
+ enable
+ enable
+ true
+
+
+
+
+ Mythetech.Hermes.Mobile.iOS
+ Mythetech
+ iOS host for Hermes Blazor apps — hosts a Blazor app in a WKWebView, plugin-compatible with Hermes.Contracts interfaces.
+ hermes;mobile;ios;blazor;webview
+ https://github.com/Mythetech/Hermes
+ https://github.com/Mythetech/Hermes.git
+ git
+ LICENSE
+ README.md
+ true
+ snupkg
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Hermes.Mobile.iOS/HermesMobileAppBuilder.cs b/src/Hermes.Mobile.iOS/HermesMobileAppBuilder.cs
new file mode 100644
index 0000000..6cdd097
--- /dev/null
+++ b/src/Hermes.Mobile.iOS/HermesMobileAppBuilder.cs
@@ -0,0 +1,64 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using System.Diagnostics.CodeAnalysis;
+using Hermes.Contracts.Plugins;
+using Hermes.Mobile.iOS.Plugins;
+using Hermes.Mobile.iOS.WebView;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.WebView;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Hermes.Mobile.iOS;
+
+///
+/// Builder for configuring and constructing a .
+/// Mirrors the shape of HermesBlazorAppBuilder so the mental model transfers between heads.
+///
+public sealed class HermesMobileAppBuilder : IMobileBuilder
+{
+ private string _hostPage = "wwwroot/index.html";
+
+ private HermesMobileAppBuilder()
+ {
+ Services = new ServiceCollection();
+ Services.AddBlazorWebView();
+ Services.AddSingleton();
+ }
+
+ public static HermesMobileAppBuilder CreateDefault() => new();
+
+ public IServiceCollection Services { get; }
+
+ public RootComponentCollection RootComponents { get; } = new();
+
+ IMobileBuilder IMobileBuilder.UseHostPage(string hostPage)
+ {
+ _hostPage = hostPage;
+ return this;
+ }
+
+ public HermesMobileAppBuilder UseHostPage(string hostPage)
+ {
+ _hostPage = hostPage;
+ return this;
+ }
+
+ [RequiresDynamicCode("Blazor WebView requires dynamic code for component rendering")]
+ [RequiresUnreferencedCode("Blazor WebView uses reflection for component instantiation")]
+ public HermesMobileHost Build()
+ {
+ var provider = Services.BuildServiceProvider();
+
+ var contentRoot = Path.GetDirectoryName(_hostPage) ?? string.Empty;
+ var hostPageRelative = Path.GetRelativePath(
+ string.IsNullOrEmpty(contentRoot) ? "." : contentRoot,
+ _hostPage);
+
+ var fileProvider = new IOSAssetFileProvider(contentRoot);
+
+ var components = RootComponents.GetComponents()
+ .Select(c => (c.Type, c.Selector))
+ .ToList();
+
+ return new HermesMobileHost(provider, fileProvider, hostPageRelative, components);
+ }
+}
diff --git a/src/Hermes.Mobile.iOS/HermesMobileHost.cs b/src/Hermes.Mobile.iOS/HermesMobileHost.cs
new file mode 100644
index 0000000..25d3314
--- /dev/null
+++ b/src/Hermes.Mobile.iOS/HermesMobileHost.cs
@@ -0,0 +1,128 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using System.Diagnostics.CodeAnalysis;
+using CoreGraphics;
+using Foundation;
+using Hermes.Mobile.iOS.WebView;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Web;
+using Microsoft.AspNetCore.Components.WebView;
+using Microsoft.Extensions.FileProviders;
+using UIKit;
+using WebKit;
+
+namespace Hermes.Mobile.iOS;
+
+///
+/// Hosts a Blazor app inside a WKWebView embedded in a UIViewController.
+/// The iOS AppDelegate places the RootViewController into its UIWindow.
+///
+///
+/// Asset serving uses an embedded HTTP server on localhost instead of WKURLSchemeHandler.
+/// The scheme handler approach is broken on .NET iOS due to a macios registrar regression
+/// (dotnet/macios#23002). The JS↔C# bridge uses WKScriptMessageHandler which works
+/// correctly with the static registrar + ProtocolAdoption workaround.
+///
+public sealed class HermesMobileHost : IMobileHost
+{
+ private readonly IServiceProvider _services;
+ private readonly WKWebView _webView;
+ private readonly IOSWebViewManager _manager;
+ private readonly UIViewController _rootViewController;
+ private readonly List<(Type Type, string Selector)> _rootComponents;
+ private readonly EmbeddedFileServer _fileServer;
+
+ // NSObject-bridged handlers must be rooted for the lifetime of the host; otherwise
+ // the GC will collect them and native callbacks silently stop firing.
+ private readonly ScriptMessageHandler _scriptHandler;
+ private readonly AllowAllNavigationDelegate _navDelegate;
+
+ private bool _started;
+
+ [RequiresDynamicCode("Blazor WebView requires dynamic code for component rendering")]
+ [RequiresUnreferencedCode("Blazor WebView uses reflection for component instantiation")]
+ internal HermesMobileHost(
+ IServiceProvider services,
+ IFileProvider fileProvider,
+ string hostPageRelativePath,
+ IReadOnlyList<(Type Type, string Selector)> rootComponents)
+ {
+ _services = services;
+ _rootComponents = new List<(Type, string)>(rootComponents);
+
+ // Start embedded HTTP server to serve Blazor assets from the app bundle.
+ // WKURLSchemeHandler is broken on .NET iOS, so we serve over localhost HTTP.
+ _fileServer = EmbeddedFileServer.Start(fileProvider);
+ var appBaseUri = new Uri($"{_fileServer.BaseUrl}/");
+
+ var config = new WKWebViewConfiguration();
+ config.AllowsInlineMediaPlayback = true;
+ config.MediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypes.None;
+
+ var dispatcher = new Threading.IOSDispatcher();
+ var jsComponents = new JSComponentConfigurationStore();
+
+ // Script message handler for JS↔C# bridge (Blazor interop).
+ // Late-bind to IOSWebViewManager to break the circular dependency:
+ // handler needs manager, manager needs webview, webview needs config with handler.
+ IOSWebViewManager? pendingManager = null;
+ _scriptHandler = new ScriptMessageHandler(appBaseUri, (uri, msg) =>
+ pendingManager?.MessageReceivedInternal(uri, msg));
+ config.UserContentController.AddScriptMessageHandler(_scriptHandler, ScriptMessageHandler.Name);
+
+ using var scriptSource = new NSString(BlazorInitScript.Contents);
+ var userScript = new WKUserScript(scriptSource, WKUserScriptInjectionTime.AtDocumentEnd, isForMainFrameOnly: true);
+ config.UserContentController.AddUserScript(userScript);
+
+ _webView = new WKWebView(CGRect.Empty, config) { AutosizesSubviews = true };
+ _webView.ScrollView.Bounces = false;
+
+ _navDelegate = new AllowAllNavigationDelegate();
+ _webView.NavigationDelegate = _navDelegate;
+
+ _manager = new IOSWebViewManager(
+ _webView, services, dispatcher, appBaseUri, fileProvider, jsComponents, hostPageRelativePath);
+ pendingManager = _manager;
+
+#if DEBUG
+ if (OperatingSystem.IsIOSVersionAtLeast(16, 4))
+ {
+ _webView.SetValueForKey(NSObject.FromObject(true), (NSString)"inspectable");
+ }
+#endif
+
+ _rootViewController = new UIViewController();
+ var rootView = _rootViewController.View!;
+ rootView.BackgroundColor = UIColor.SystemBackground;
+ rootView.AddSubview(_webView);
+
+ _webView.TranslatesAutoresizingMaskIntoConstraints = false;
+ _webView.TopAnchor.ConstraintEqualTo(rootView.SafeAreaLayoutGuide.TopAnchor).Active = true;
+ _webView.BottomAnchor.ConstraintEqualTo(rootView.SafeAreaLayoutGuide.BottomAnchor).Active = true;
+ _webView.LeadingAnchor.ConstraintEqualTo(rootView.SafeAreaLayoutGuide.LeadingAnchor).Active = true;
+ _webView.TrailingAnchor.ConstraintEqualTo(rootView.SafeAreaLayoutGuide.TrailingAnchor).Active = true;
+ }
+
+ public UIViewController RootViewController => _rootViewController;
+
+ [RequiresDynamicCode("Blazor WebView requires dynamic code for component rendering")]
+ [RequiresUnreferencedCode("Blazor WebView uses reflection for component instantiation")]
+ public void Start()
+ {
+ if (_started) return;
+ _started = true;
+
+ foreach (var (type, selector) in _rootComponents)
+ {
+ _manager.AddRootComponentAsync(type, selector, ParameterView.Empty).GetAwaiter().GetResult();
+ }
+
+ _manager.Navigate("/");
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _manager.DisposeAsync();
+ _fileServer.Dispose();
+ _webView.Dispose();
+ }
+}
diff --git a/src/Hermes.Mobile.iOS/Plugins/IOSClipboard.cs b/src/Hermes.Mobile.iOS/Plugins/IOSClipboard.cs
new file mode 100644
index 0000000..7b49a0a
--- /dev/null
+++ b/src/Hermes.Mobile.iOS/Plugins/IOSClipboard.cs
@@ -0,0 +1,21 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using Hermes.Contracts.Plugins;
+using UIKit;
+
+namespace Hermes.Mobile.iOS.Plugins;
+
+///
+/// iOS implementation of backed by .
+///
+public sealed class IOSClipboard : IClipboard
+{
+ public Task SetTextAsync(string text, CancellationToken ct = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(text);
+ UIPasteboard.General.String = text;
+ return Task.CompletedTask;
+ }
+
+ public Task GetTextAsync(CancellationToken ct = default)
+ => Task.FromResult(UIPasteboard.General.String);
+}
diff --git a/src/Hermes.Mobile.iOS/Threading/IOSDispatcher.cs b/src/Hermes.Mobile.iOS/Threading/IOSDispatcher.cs
new file mode 100644
index 0000000..956e38a
--- /dev/null
+++ b/src/Hermes.Mobile.iOS/Threading/IOSDispatcher.cs
@@ -0,0 +1,58 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using CoreFoundation;
+using Foundation;
+using Microsoft.AspNetCore.Components;
+
+namespace Hermes.Mobile.iOS.Threading;
+
+///
+/// Marshals Blazor component work onto the iOS main queue (UI thread).
+///
+internal sealed class IOSDispatcher : Dispatcher
+{
+ public override bool CheckAccess() => NSThread.IsMain;
+
+ public override Task InvokeAsync(Action workItem)
+ {
+ var tcs = new TaskCompletionSource();
+ DispatchQueue.MainQueue.DispatchAsync(() =>
+ {
+ try { workItem(); tcs.SetResult(); }
+ catch (Exception ex) { tcs.SetException(ex); }
+ });
+ return tcs.Task;
+ }
+
+ public override Task InvokeAsync(Func workItem)
+ {
+ var tcs = new TaskCompletionSource();
+ DispatchQueue.MainQueue.DispatchAsync(async () =>
+ {
+ try { await workItem().ConfigureAwait(false); tcs.SetResult(); }
+ catch (Exception ex) { tcs.SetException(ex); }
+ });
+ return tcs.Task;
+ }
+
+ public override Task InvokeAsync(Func workItem)
+ {
+ var tcs = new TaskCompletionSource();
+ DispatchQueue.MainQueue.DispatchAsync(() =>
+ {
+ try { tcs.SetResult(workItem()); }
+ catch (Exception ex) { tcs.SetException(ex); }
+ });
+ return tcs.Task;
+ }
+
+ public override Task InvokeAsync(Func> workItem)
+ {
+ var tcs = new TaskCompletionSource();
+ DispatchQueue.MainQueue.DispatchAsync(async () =>
+ {
+ try { tcs.SetResult(await workItem().ConfigureAwait(false)); }
+ catch (Exception ex) { tcs.SetException(ex); }
+ });
+ return tcs.Task;
+ }
+}
diff --git a/src/Hermes.Mobile.iOS/WebView/AllowAllNavigationDelegate.cs b/src/Hermes.Mobile.iOS/WebView/AllowAllNavigationDelegate.cs
new file mode 100644
index 0000000..b96f023
--- /dev/null
+++ b/src/Hermes.Mobile.iOS/WebView/AllowAllNavigationDelegate.cs
@@ -0,0 +1,36 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using Foundation;
+using ObjCRuntime;
+using WebKit;
+
+namespace Hermes.Mobile.iOS.WebView;
+
+///
+/// Minimal WKNavigationDelegate that allows all navigation. Without an explicit delegate
+/// WKWebView's default policy rejects custom-scheme navigation with requestURLIsValid=0,
+/// producing a blank webview.
+///
+[Adopts("WKNavigationDelegate")]
+internal sealed class AllowAllNavigationDelegate : NSObject, IWKNavigationDelegate
+{
+ static AllowAllNavigationDelegate()
+ => ProtocolAdoption.Ensure("WKNavigationDelegate");
+
+ [Export("webView:decidePolicyForNavigationAction:decisionHandler:")]
+ public void DecidePolicy(WKWebView webView, WKNavigationAction navigationAction, Action decisionHandler)
+ {
+ decisionHandler(WKNavigationActionPolicy.Allow);
+ }
+
+ [Export("webView:didFailNavigation:withError:")]
+ public void DidFailNavigation(WKWebView webView, WKNavigation navigation, NSError error)
+ {
+ Console.WriteLine($"[Hermes.Mobile] didFailNavigation: {error.LocalizedDescription}");
+ }
+
+ [Export("webView:didFailProvisionalNavigation:withError:")]
+ public void DidFailProvisionalNavigation(WKWebView webView, WKNavigation navigation, NSError error)
+ {
+ Console.WriteLine($"[Hermes.Mobile] didFailProvisionalNavigation: {error.LocalizedDescription}");
+ }
+}
diff --git a/src/Hermes.Mobile.iOS/WebView/AppSchemeHandler.cs b/src/Hermes.Mobile.iOS/WebView/AppSchemeHandler.cs
new file mode 100644
index 0000000..d42b0bb
--- /dev/null
+++ b/src/Hermes.Mobile.iOS/WebView/AppSchemeHandler.cs
@@ -0,0 +1,64 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using System.Globalization;
+using System.Runtime.Versioning;
+using Foundation;
+using Hermes.Mobile.WebView;
+using ObjCRuntime;
+using WebKit;
+
+namespace Hermes.Mobile.iOS.WebView;
+
+///
+/// Handles app:// requests by delegating to a resolver that wraps WebViewManager.TryGetResponseContent.
+/// iOS runs scheme handler callbacks on the main thread.
+///
+[Register("HermesAppSchemeHandler")]
+[Adopts("WKURLSchemeHandler")]
+internal sealed class AppSchemeHandler : NSObject, IWKUrlSchemeHandler
+{
+ static AppSchemeHandler()
+ => ProtocolAdoption.Ensure("WKURLSchemeHandler");
+
+ private readonly Func _resolver;
+
+ public AppSchemeHandler(Func resolver)
+ {
+ _resolver = resolver;
+ }
+
+ [Export("webView:startURLSchemeTask:")]
+ [SupportedOSPlatform("ios11.0")]
+ public void StartUrlSchemeTask(WKWebView webView, IWKUrlSchemeTask urlSchemeTask)
+ {
+ var url = urlSchemeTask.Request.Url?.AbsoluteString;
+ if (string.IsNullOrEmpty(url))
+ return;
+
+ var response = _resolver(url);
+
+ if (response.StatusCode == 200)
+ {
+ using var headers = new NSMutableDictionary();
+ headers.Add((NSString)"Content-Length", (NSString)response.Body.Length.ToString(CultureInfo.InvariantCulture));
+ headers.Add((NSString)"Content-Type", (NSString)response.ContentType);
+ headers.Add((NSString)"Cache-Control", (NSString)"no-cache, max-age=0, must-revalidate, no-store");
+
+ using var httpResponse = new NSHttpUrlResponse(urlSchemeTask.Request.Url!, response.StatusCode, "HTTP/1.1", headers);
+ urlSchemeTask.DidReceiveResponse(httpResponse);
+ urlSchemeTask.DidReceiveData(NSData.FromArray(response.Body));
+ urlSchemeTask.DidFinish();
+ }
+ else
+ {
+ using var httpResponse = new NSHttpUrlResponse(urlSchemeTask.Request.Url!, response.StatusCode, "HTTP/1.1", null);
+ urlSchemeTask.DidReceiveResponse(httpResponse);
+ urlSchemeTask.DidFinish();
+ }
+ }
+
+ [Export("webView:stopURLSchemeTask:")]
+ public void StopUrlSchemeTask(WKWebView webView, IWKUrlSchemeTask urlSchemeTask)
+ {
+ // No-op. Do NOT touch urlSchemeTask after DidFinish has been called upstream.
+ }
+}
diff --git a/src/Hermes.Mobile.iOS/WebView/BlazorInitScript.cs b/src/Hermes.Mobile.iOS/WebView/BlazorInitScript.cs
new file mode 100644
index 0000000..62f139b
--- /dev/null
+++ b/src/Hermes.Mobile.iOS/WebView/BlazorInitScript.cs
@@ -0,0 +1,32 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+namespace Hermes.Mobile.iOS.WebView;
+
+internal static class BlazorInitScript
+{
+ ///
+ /// Injected at document-end into every page. Wires window.external.sendMessage/receiveMessage
+ /// to the WKWebView WebKit bridge. The JS contract matches Hermes desktop and MAUI's BlazorWebView.
+ ///
+ public const string Contents = """
+ window.__receiveMessageCallbacks = [];
+ window.__dispatchMessageCallback = function(message) {
+ window.__receiveMessageCallbacks.forEach(function(callback) { callback(message); });
+ };
+ window.external = {
+ sendMessage: function(message) {
+ window.webkit.messageHandlers.webwindowinterop.postMessage(message);
+ },
+ receiveMessage: function(callback) {
+ window.__receiveMessageCallbacks.push(callback);
+ }
+ };
+
+ Blazor.start();
+
+ (function () {
+ window.onpageshow = function(event) {
+ if (event.persisted) { window.location.reload(); }
+ };
+ })();
+ """;
+}
diff --git a/src/Hermes.Mobile.iOS/WebView/EmbeddedFileServer.cs b/src/Hermes.Mobile.iOS/WebView/EmbeddedFileServer.cs
new file mode 100644
index 0000000..f142ed1
--- /dev/null
+++ b/src/Hermes.Mobile.iOS/WebView/EmbeddedFileServer.cs
@@ -0,0 +1,129 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using System.Net;
+using System.Net.Sockets;
+using Hermes.Mobile.WebView;
+using Microsoft.Extensions.FileProviders;
+
+namespace Hermes.Mobile.iOS.WebView;
+
+///
+/// Minimal HTTP server that serves static files from an on localhost.
+/// WKWebView custom scheme handlers are broken on .NET iOS (dotnet/macios regression),
+/// so we serve Blazor assets over HTTP and rely on the working WKScriptMessageHandler
+/// for the JS↔C# bridge.
+///
+internal sealed class EmbeddedFileServer : IDisposable
+{
+ private readonly HttpListener _listener;
+ private readonly IFileProvider _fileProvider;
+ private readonly CancellationTokenSource _cts = new();
+
+ public int Port { get; }
+ public string BaseUrl => $"http://localhost:{Port}";
+
+ private EmbeddedFileServer(HttpListener listener, IFileProvider fileProvider, int port)
+ {
+ _listener = listener;
+ _fileProvider = fileProvider;
+ Port = port;
+ }
+
+ internal static EmbeddedFileServer Start(IFileProvider fileProvider)
+ {
+ var port = FindFreePort();
+ var prefix = $"http://localhost:{port}/";
+
+ var listener = new HttpListener();
+ listener.Prefixes.Add(prefix);
+ listener.Start();
+
+ var server = new EmbeddedFileServer(listener, fileProvider, port);
+ _ = Task.Run(() => server.AcceptLoopAsync());
+
+ Console.WriteLine($"[Hermes.Mobile] Embedded file server listening on {prefix}");
+ return server;
+ }
+
+ private static int FindFreePort()
+ {
+ using var tcp = new TcpListener(IPAddress.Loopback, 0);
+ tcp.Start();
+ var port = ((IPEndPoint)tcp.LocalEndpoint).Port;
+ tcp.Stop();
+ return port;
+ }
+
+ private async Task AcceptLoopAsync()
+ {
+ while (!_cts.IsCancellationRequested)
+ {
+ HttpListenerContext context;
+ try
+ {
+ context = await _listener.GetContextAsync();
+ }
+ catch (ObjectDisposedException) { break; }
+ catch (HttpListenerException) { break; }
+
+ try
+ {
+ HandleRequest(context);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[Hermes.Mobile] File server request error: {ex.Message}");
+ try { context.Response.StatusCode = 500; context.Response.Close(); }
+ catch { /* best effort */ }
+ }
+ }
+ }
+
+ private void HandleRequest(HttpListenerContext context)
+ {
+ var path = context.Request.Url?.AbsolutePath?.TrimStart('/') ?? "";
+ if (string.IsNullOrEmpty(path))
+ path = "index.html";
+
+ var fileInfo = _fileProvider.GetFileInfo(path);
+
+ // blazor.modules.json is required by Blazor but may not be in the bundle.
+ // Return an empty array to satisfy the framework.
+ if (!fileInfo.Exists && path.EndsWith("blazor.modules.json", StringComparison.OrdinalIgnoreCase))
+ {
+ context.Response.ContentType = "application/json";
+ context.Response.StatusCode = 200;
+ var bytes = System.Text.Encoding.UTF8.GetBytes("[]");
+ context.Response.OutputStream.Write(bytes, 0, bytes.Length);
+ context.Response.Close();
+ return;
+ }
+
+ // SPA fallback: serve index.html for paths that don't resolve to a file
+ if (!fileInfo.Exists && !Path.HasExtension(path))
+ fileInfo = _fileProvider.GetFileInfo("index.html");
+
+ if (fileInfo.Exists)
+ {
+ var contentType = MimeTypeLookup.GetContentType(path);
+ using var stream = fileInfo.CreateReadStream();
+ context.Response.ContentType = contentType;
+ context.Response.StatusCode = 200;
+ context.Response.Headers.Set("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store");
+ stream.CopyTo(context.Response.OutputStream);
+ }
+ else
+ {
+ context.Response.StatusCode = 404;
+ }
+
+ context.Response.Close();
+ }
+
+ public void Dispose()
+ {
+ _cts.Cancel();
+ _listener.Stop();
+ _listener.Close();
+ _cts.Dispose();
+ }
+}
diff --git a/src/Hermes.Mobile.iOS/WebView/IOSAssetFileProvider.cs b/src/Hermes.Mobile.iOS/WebView/IOSAssetFileProvider.cs
new file mode 100644
index 0000000..c755249
--- /dev/null
+++ b/src/Hermes.Mobile.iOS/WebView/IOSAssetFileProvider.cs
@@ -0,0 +1,49 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using Foundation;
+using Microsoft.Extensions.FileProviders;
+using Microsoft.Extensions.FileProviders.Physical;
+using Microsoft.Extensions.Primitives;
+
+namespace Hermes.Mobile.iOS.WebView;
+
+///
+/// Serves static assets from the app bundle's wwwroot folder.
+///
+///
+/// The .app bundle is a directory on disk; NSBundle.MainBundle.ResourcePath points at
+/// its Resources root. Blazor's published wwwroot ends up under {bundle}/wwwroot/ when
+/// BundleResource is configured to preserve that layout.
+///
+internal sealed class IOSAssetFileProvider : IFileProvider
+{
+ private readonly string _bundleRootDir;
+
+ public IOSAssetFileProvider(string contentRootDir)
+ {
+ var resourcePath = NSBundle.MainBundle.ResourcePath
+ ?? throw new InvalidOperationException("NSBundle.MainBundle.ResourcePath is null");
+ _bundleRootDir = Path.Combine(resourcePath, contentRootDir);
+ }
+
+ public IFileInfo GetFileInfo(string subpath)
+ {
+ if (string.IsNullOrEmpty(subpath))
+ return new NotFoundFileInfo(subpath);
+
+ var normalized = subpath.TrimStart('/');
+ var candidate = Path.GetFullPath(Path.Combine(_bundleRootDir, normalized));
+
+ // Path-traversal guard: resolved path must stay within the bundle root.
+ if (!candidate.StartsWith(_bundleRootDir, StringComparison.Ordinal))
+ return new NotFoundFileInfo(subpath);
+
+ return File.Exists(candidate)
+ ? new PhysicalFileInfo(new FileInfo(candidate))
+ : new NotFoundFileInfo(subpath);
+ }
+
+ public IDirectoryContents GetDirectoryContents(string subpath)
+ => NotFoundDirectoryContents.Singleton;
+
+ public IChangeToken Watch(string filter) => NullChangeToken.Singleton;
+}
diff --git a/src/Hermes.Mobile.iOS/WebView/IOSWebViewManager.cs b/src/Hermes.Mobile.iOS/WebView/IOSWebViewManager.cs
new file mode 100644
index 0000000..c5ae38d
--- /dev/null
+++ b/src/Hermes.Mobile.iOS/WebView/IOSWebViewManager.cs
@@ -0,0 +1,77 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Encodings.Web;
+using Hermes.Mobile.WebView;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Web;
+using Microsoft.AspNetCore.Components.WebView;
+using Microsoft.Extensions.FileProviders;
+using WebKit;
+
+namespace Hermes.Mobile.iOS.WebView;
+
+///
+/// WebViewManager subclass that binds a WKWebView's navigation + messaging to the Blazor pipeline.
+///
+internal sealed class IOSWebViewManager : WebViewManager
+{
+ private readonly WKWebView _webView;
+ private readonly Uri _appBaseUri;
+
+ [RequiresDynamicCode("Blazor WebView requires dynamic code for component rendering")]
+ [RequiresUnreferencedCode("Blazor WebView uses reflection for component instantiation")]
+ public IOSWebViewManager(
+ WKWebView webView,
+ IServiceProvider services,
+ Dispatcher dispatcher,
+ Uri appBaseUri,
+ IFileProvider fileProvider,
+ JSComponentConfigurationStore jsComponents,
+ string hostPageRelativePath)
+ : base(services, dispatcher, appBaseUri, fileProvider, jsComponents, hostPageRelativePath)
+ {
+ _webView = webView;
+ _appBaseUri = appBaseUri;
+ }
+
+ protected override void NavigateCore(Uri absoluteUri)
+ {
+ using var url = new Foundation.NSUrl(absoluteUri.ToString());
+ using var request = new Foundation.NSUrlRequest(url);
+ _webView.LoadRequest(request);
+ }
+
+ protected override void SendMessage(string message)
+ {
+ var encoded = JavaScriptEncoder.Default.Encode(message);
+ _webView.EvaluateJavaScript(
+ $"__dispatchMessageCallback(\"{encoded}\")",
+ (result, error) =>
+ {
+ if (error is not null)
+ Console.WriteLine($"[Hermes.Mobile] C#→JS EvaluateJavaScript error: {error.LocalizedDescription}");
+ });
+ }
+
+ /// Public pathway for the ScriptMessageHandler to feed JS→C# messages into the base pump.
+ internal void MessageReceivedInternal(Uri sourceUri, string message)
+ => MessageReceived(sourceUri, message);
+
+ internal WebViewResponse ResolveRequest(string absoluteUrl)
+ {
+ var allowFallbackOnHostPage = _appBaseUri.IsBaseOf(new Uri(absoluteUrl));
+
+ if (TryGetResponseContent(
+ absoluteUrl,
+ allowFallbackOnHostPage,
+ out var statusCode,
+ out _,
+ out var content,
+ out var headers))
+ {
+ return WebViewResolveHelper.ToResponse(statusCode, content, headers, absoluteUrl);
+ }
+
+ return WebViewResponse.NotFound;
+ }
+}
diff --git a/src/Hermes.Mobile.iOS/WebView/ProtocolAdoption.cs b/src/Hermes.Mobile.iOS/WebView/ProtocolAdoption.cs
new file mode 100644
index 0000000..65ceb95
--- /dev/null
+++ b/src/Hermes.Mobile.iOS/WebView/ProtocolAdoption.cs
@@ -0,0 +1,57 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using System.Runtime.InteropServices;
+using Foundation;
+using ObjCRuntime;
+
+namespace Hermes.Mobile.iOS.WebView;
+
+///
+/// Registers Obj-C protocol conformance on a managed NSObject subclass by calling
+/// class_addProtocol directly on the native Class at type-initialisation time.
+///
+///
+/// Works around a .NET iOS 26.2 regression (dotnet/macios PR #23002, which reworked
+/// the conformsToProtocol: dispatch path to resolve the managed peer via a GCHandle
+/// stored on the native instance). When WebKit probes a handler during
+/// AddScriptMessageHandler / SetUrlSchemeHandler the managed override can return NO
+/// before the peer's GCHandle is attached, so WKWebView silently drops the handler
+/// and the page loads blank with no logs.
+///
+/// Advertising the protocol on the native Class makes class_conformsToProtocol
+/// answer YES purely in Obj-C, bypassing the managed override entirely. The call is
+/// idempotent and works with the dynamic, static, and managed-static registrars.
+///
+/// The [Adopts(...)] attribute alone is insufficient on this runtime because it
+/// only stores managed metadata consulted by the NSObject conformsToProtocol:
+/// override, which is exactly the path that regressed.
+///
+internal static class ProtocolAdoption
+{
+ private const string LibObjC = "/usr/lib/libobjc.dylib";
+
+ [DllImport(LibObjC, EntryPoint = "class_addProtocol")]
+ private static extern byte class_addProtocol(IntPtr cls, IntPtr protocol);
+
+ public static void Ensure(params string[] protocolNames) where T : NSObject
+ {
+ var cls = Class.GetHandle(typeof(T));
+ if (cls == IntPtr.Zero)
+ {
+ Console.WriteLine($"[Hermes.Mobile] ProtocolAdoption: no class handle for {typeof(T).FullName}, skipping");
+ return;
+ }
+
+ foreach (var name in protocolNames)
+ {
+ var protocol = Protocol.GetHandle(name);
+ if (protocol == IntPtr.Zero)
+ {
+ Console.WriteLine($"[Hermes.Mobile] ProtocolAdoption: protocol '{name}' not found, skipping");
+ continue;
+ }
+
+ var added = class_addProtocol(cls, protocol) != 0;
+ Console.WriteLine($"[Hermes.Mobile] ProtocolAdoption: {typeof(T).Name} adopts '{name}' ({(added ? "added" : "already present")})");
+ }
+ }
+}
diff --git a/src/Hermes.Mobile.iOS/WebView/ScriptMessageHandler.cs b/src/Hermes.Mobile.iOS/WebView/ScriptMessageHandler.cs
new file mode 100644
index 0000000..f183194
--- /dev/null
+++ b/src/Hermes.Mobile.iOS/WebView/ScriptMessageHandler.cs
@@ -0,0 +1,34 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using Foundation;
+using ObjCRuntime;
+using WebKit;
+
+namespace Hermes.Mobile.iOS.WebView;
+
+///
+/// Forwards WKWebView → native messages to the WebViewManager's MessageReceived pump.
+/// Name "webwindowinterop" must match the JS side in BlazorInitScript.
+///
+[Adopts("WKScriptMessageHandler")]
+internal sealed class ScriptMessageHandler : NSObject, IWKScriptMessageHandler
+{
+ public const string Name = "webwindowinterop";
+
+ static ScriptMessageHandler()
+ => ProtocolAdoption.Ensure("WKScriptMessageHandler");
+
+ private readonly Action _onMessage;
+ private readonly Uri _appOrigin;
+
+ public ScriptMessageHandler(Uri appOrigin, Action onMessage)
+ {
+ _appOrigin = appOrigin;
+ _onMessage = onMessage;
+ }
+
+ public void DidReceiveScriptMessage(WKUserContentController userContentController, WKScriptMessage message)
+ {
+ var body = ((NSString)message.Body).ToString();
+ _onMessage(_appOrigin, body);
+ }
+}
diff --git a/src/Hermes.Mobile/Hermes.Mobile.csproj b/src/Hermes.Mobile/Hermes.Mobile.csproj
new file mode 100644
index 0000000..5560ae0
--- /dev/null
+++ b/src/Hermes.Mobile/Hermes.Mobile.csproj
@@ -0,0 +1,42 @@
+
+
+ net10.0
+ Hermes.Mobile
+ enable
+ enable
+ true
+
+
+
+ Mythetech.Hermes.Mobile
+ Mythetech
+ Shared abstractions and utilities for Hermes mobile platforms (iOS, Android). Defines IMobileHost, IMobileBuilder, and common WebView helpers.
+ hermes;mobile;blazor;webview;abstractions
+ https://github.com/Mythetech/Hermes
+ https://github.com/Mythetech/Hermes.git
+ git
+ LICENSE
+ README.md
+ true
+ snupkg
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Hermes.Mobile/IMobileBuilder.cs b/src/Hermes.Mobile/IMobileBuilder.cs
new file mode 100644
index 0000000..4ce144a
--- /dev/null
+++ b/src/Hermes.Mobile/IMobileBuilder.cs
@@ -0,0 +1,16 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Hermes.Mobile;
+
+public interface IMobileBuilder where THost : IMobileHost
+{
+ IServiceCollection Services { get; }
+ RootComponentCollection RootComponents { get; }
+ IMobileBuilder UseHostPage(string hostPage);
+
+ [RequiresDynamicCode("Blazor WebView requires dynamic code for component rendering")]
+ [RequiresUnreferencedCode("Blazor WebView uses reflection for component instantiation")]
+ THost Build();
+}
diff --git a/src/Hermes.Mobile/IMobileHost.cs b/src/Hermes.Mobile/IMobileHost.cs
new file mode 100644
index 0000000..6c587de
--- /dev/null
+++ b/src/Hermes.Mobile/IMobileHost.cs
@@ -0,0 +1,11 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using System.Diagnostics.CodeAnalysis;
+
+namespace Hermes.Mobile;
+
+public interface IMobileHost : IAsyncDisposable
+{
+ [RequiresDynamicCode("Blazor WebView requires dynamic code for component rendering")]
+ [RequiresUnreferencedCode("Blazor WebView uses reflection for component instantiation")]
+ void Start();
+}
diff --git a/src/Hermes.Mobile/RootComponentCollection.cs b/src/Hermes.Mobile/RootComponentCollection.cs
new file mode 100644
index 0000000..bc26555
--- /dev/null
+++ b/src/Hermes.Mobile/RootComponentCollection.cs
@@ -0,0 +1,20 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Components;
+
+namespace Hermes.Mobile;
+
+public sealed class RootComponentCollection
+{
+ private readonly List<(Type Type, string Selector)> _components = new();
+
+ public void Add<[DynamicallyAccessedMembers(
+ DynamicallyAccessedMemberTypes.PublicConstructors |
+ DynamicallyAccessedMemberTypes.PublicProperties)] TComponent>(string selector)
+ where TComponent : IComponent
+ {
+ _components.Add((typeof(TComponent), selector));
+ }
+
+ internal IEnumerable<(Type Type, string Selector)> GetComponents() => _components;
+}
diff --git a/src/Hermes.Mobile/WebView/MimeTypeLookup.cs b/src/Hermes.Mobile/WebView/MimeTypeLookup.cs
new file mode 100644
index 0000000..6c9dcfb
--- /dev/null
+++ b/src/Hermes.Mobile/WebView/MimeTypeLookup.cs
@@ -0,0 +1,39 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+namespace Hermes.Mobile.WebView;
+
+public static class MimeTypeLookup
+{
+ private static readonly Dictionary Map = new(StringComparer.OrdinalIgnoreCase)
+ {
+ [".html"] = "text/html",
+ [".htm"] = "text/html",
+ [".js"] = "text/javascript",
+ [".mjs"] = "text/javascript",
+ [".css"] = "text/css",
+ [".json"] = "application/json",
+ [".webmanifest"] = "application/manifest+json",
+ [".map"] = "application/json",
+ [".wasm"] = "application/wasm",
+ [".dll"] = "application/octet-stream",
+ [".pdb"] = "application/octet-stream",
+ [".blat"] = "application/octet-stream",
+ [".dat"] = "application/octet-stream",
+ [".woff"] = "font/woff",
+ [".woff2"] = "font/woff2",
+ [".ttf"] = "font/ttf",
+ [".otf"] = "font/otf",
+ [".png"] = "image/png",
+ [".jpg"] = "image/jpeg",
+ [".jpeg"] = "image/jpeg",
+ [".gif"] = "image/gif",
+ [".svg"] = "image/svg+xml",
+ [".ico"] = "image/x-icon",
+ [".txt"] = "text/plain",
+ };
+
+ public static string GetContentType(string path)
+ {
+ var ext = Path.GetExtension(path);
+ return Map.TryGetValue(ext, out var ct) ? ct : "application/octet-stream";
+ }
+}
diff --git a/src/Hermes.Mobile/WebView/WebViewResolveHelper.cs b/src/Hermes.Mobile/WebView/WebViewResolveHelper.cs
new file mode 100644
index 0000000..acedc39
--- /dev/null
+++ b/src/Hermes.Mobile/WebView/WebViewResolveHelper.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+namespace Hermes.Mobile.WebView;
+
+public static class WebViewResolveHelper
+{
+ public static WebViewResponse ToResponse(
+ int statusCode, Stream content, IDictionary headers, string url)
+ {
+ using var ms = new MemoryStream();
+ content.CopyTo(ms);
+ content.Dispose();
+
+ var contentType = headers.TryGetValue("Content-Type", out var ct)
+ ? ct
+ : MimeTypeLookup.GetContentType(url);
+
+ return new WebViewResponse
+ {
+ StatusCode = statusCode,
+ Body = ms.ToArray(),
+ ContentType = contentType
+ };
+ }
+}
diff --git a/src/Hermes.Mobile/WebView/WebViewResponse.cs b/src/Hermes.Mobile/WebView/WebViewResponse.cs
new file mode 100644
index 0000000..23f5916
--- /dev/null
+++ b/src/Hermes.Mobile/WebView/WebViewResponse.cs
@@ -0,0 +1,16 @@
+// Copyright (c) Mythetech. Licensed under the Elastic License 2.0.
+namespace Hermes.Mobile.WebView;
+
+public sealed class WebViewResponse
+{
+ public required int StatusCode { get; init; }
+ public required byte[] Body { get; init; }
+ public required string ContentType { get; init; }
+
+ public static WebViewResponse NotFound => new()
+ {
+ StatusCode = 404,
+ Body = Array.Empty(),
+ ContentType = string.Empty
+ };
+}