From 5d16790a6f7172d960cc051b66e4ed77aa83d8a0 Mon Sep 17 00:00:00 2001 From: Tom Brewer Date: Sat, 18 Apr 2026 07:45:08 -0600 Subject: [PATCH 01/14] feat(mobile): scaffold mobile project & solution --- Hermes.Mobile.sln | 84 ++++++++++++++++++++++++++ src/Hermes.Mobile/Hermes.Mobile.csproj | 38 ++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 Hermes.Mobile.sln create mode 100644 src/Hermes.Mobile/Hermes.Mobile.csproj diff --git a/Hermes.Mobile.sln b/Hermes.Mobile.sln new file mode 100644 index 0000000..7064573 --- /dev/null +++ b/Hermes.Mobile.sln @@ -0,0 +1,84 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hermes.Contracts", "src\Hermes.Contracts\Hermes.Contracts.csproj", "{6E8D13E2-B327-4709-8E3D-E8EDE7D58A25}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hermes", "src\Hermes\Hermes.csproj", "{6111B780-0E6D-4756-8830-7C1CB930A85D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hermes.Blazor", "src\Hermes.Blazor\Hermes.Blazor.csproj", "{C37C83EB-4BD7-4FBA-855C-AF016D498C57}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hermes.Mobile", "src\Hermes.Mobile\Hermes.Mobile.csproj", "{16737429-333A-428D-91C3-04067413D953}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6E8D13E2-B327-4709-8E3D-E8EDE7D58A25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6E8D13E2-B327-4709-8E3D-E8EDE7D58A25}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6E8D13E2-B327-4709-8E3D-E8EDE7D58A25}.Debug|x64.ActiveCfg = Debug|Any CPU + {6E8D13E2-B327-4709-8E3D-E8EDE7D58A25}.Debug|x64.Build.0 = Debug|Any CPU + {6E8D13E2-B327-4709-8E3D-E8EDE7D58A25}.Debug|x86.ActiveCfg = Debug|Any CPU + {6E8D13E2-B327-4709-8E3D-E8EDE7D58A25}.Debug|x86.Build.0 = Debug|Any CPU + {6E8D13E2-B327-4709-8E3D-E8EDE7D58A25}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6E8D13E2-B327-4709-8E3D-E8EDE7D58A25}.Release|Any CPU.Build.0 = Release|Any CPU + {6E8D13E2-B327-4709-8E3D-E8EDE7D58A25}.Release|x64.ActiveCfg = Release|Any CPU + {6E8D13E2-B327-4709-8E3D-E8EDE7D58A25}.Release|x64.Build.0 = Release|Any CPU + {6E8D13E2-B327-4709-8E3D-E8EDE7D58A25}.Release|x86.ActiveCfg = Release|Any CPU + {6E8D13E2-B327-4709-8E3D-E8EDE7D58A25}.Release|x86.Build.0 = Release|Any CPU + {6111B780-0E6D-4756-8830-7C1CB930A85D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6111B780-0E6D-4756-8830-7C1CB930A85D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6111B780-0E6D-4756-8830-7C1CB930A85D}.Debug|x64.ActiveCfg = Debug|Any CPU + {6111B780-0E6D-4756-8830-7C1CB930A85D}.Debug|x64.Build.0 = Debug|Any CPU + {6111B780-0E6D-4756-8830-7C1CB930A85D}.Debug|x86.ActiveCfg = Debug|Any CPU + {6111B780-0E6D-4756-8830-7C1CB930A85D}.Debug|x86.Build.0 = Debug|Any CPU + {6111B780-0E6D-4756-8830-7C1CB930A85D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6111B780-0E6D-4756-8830-7C1CB930A85D}.Release|Any CPU.Build.0 = Release|Any CPU + {6111B780-0E6D-4756-8830-7C1CB930A85D}.Release|x64.ActiveCfg = Release|Any CPU + {6111B780-0E6D-4756-8830-7C1CB930A85D}.Release|x64.Build.0 = Release|Any CPU + {6111B780-0E6D-4756-8830-7C1CB930A85D}.Release|x86.ActiveCfg = Release|Any CPU + {6111B780-0E6D-4756-8830-7C1CB930A85D}.Release|x86.Build.0 = Release|Any CPU + {C37C83EB-4BD7-4FBA-855C-AF016D498C57}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C37C83EB-4BD7-4FBA-855C-AF016D498C57}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C37C83EB-4BD7-4FBA-855C-AF016D498C57}.Debug|x64.ActiveCfg = Debug|Any CPU + {C37C83EB-4BD7-4FBA-855C-AF016D498C57}.Debug|x64.Build.0 = Debug|Any CPU + {C37C83EB-4BD7-4FBA-855C-AF016D498C57}.Debug|x86.ActiveCfg = Debug|Any CPU + {C37C83EB-4BD7-4FBA-855C-AF016D498C57}.Debug|x86.Build.0 = Debug|Any CPU + {C37C83EB-4BD7-4FBA-855C-AF016D498C57}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C37C83EB-4BD7-4FBA-855C-AF016D498C57}.Release|Any CPU.Build.0 = Release|Any CPU + {C37C83EB-4BD7-4FBA-855C-AF016D498C57}.Release|x64.ActiveCfg = Release|Any CPU + {C37C83EB-4BD7-4FBA-855C-AF016D498C57}.Release|x64.Build.0 = Release|Any CPU + {C37C83EB-4BD7-4FBA-855C-AF016D498C57}.Release|x86.ActiveCfg = Release|Any CPU + {C37C83EB-4BD7-4FBA-855C-AF016D498C57}.Release|x86.Build.0 = Release|Any CPU + {16737429-333A-428D-91C3-04067413D953}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {16737429-333A-428D-91C3-04067413D953}.Debug|Any CPU.Build.0 = Debug|Any CPU + {16737429-333A-428D-91C3-04067413D953}.Debug|x64.ActiveCfg = Debug|Any CPU + {16737429-333A-428D-91C3-04067413D953}.Debug|x64.Build.0 = Debug|Any CPU + {16737429-333A-428D-91C3-04067413D953}.Debug|x86.ActiveCfg = Debug|Any CPU + {16737429-333A-428D-91C3-04067413D953}.Debug|x86.Build.0 = Debug|Any CPU + {16737429-333A-428D-91C3-04067413D953}.Release|Any CPU.ActiveCfg = Release|Any CPU + {16737429-333A-428D-91C3-04067413D953}.Release|Any CPU.Build.0 = Release|Any CPU + {16737429-333A-428D-91C3-04067413D953}.Release|x64.ActiveCfg = Release|Any CPU + {16737429-333A-428D-91C3-04067413D953}.Release|x64.Build.0 = Release|Any CPU + {16737429-333A-428D-91C3-04067413D953}.Release|x86.ActiveCfg = Release|Any CPU + {16737429-333A-428D-91C3-04067413D953}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {6E8D13E2-B327-4709-8E3D-E8EDE7D58A25} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {6111B780-0E6D-4756-8830-7C1CB930A85D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {C37C83EB-4BD7-4FBA-855C-AF016D498C57} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {16737429-333A-428D-91C3-04067413D953} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + EndGlobalSection +EndGlobal diff --git a/src/Hermes.Mobile/Hermes.Mobile.csproj b/src/Hermes.Mobile/Hermes.Mobile.csproj new file mode 100644 index 0000000..b1096df --- /dev/null +++ b/src/Hermes.Mobile/Hermes.Mobile.csproj @@ -0,0 +1,38 @@ + + + net10.0-ios + 15.0 + enable + enable + true + + + + + Mythetech.Hermes.Mobile + 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 + + + + + + + + + + + + + + + + From 80e5a802a70b189688382567f2ddf8fe915028b9 Mon Sep 17 00:00:00 2001 From: Tom Brewer Date: Sat, 18 Apr 2026 07:51:15 -0600 Subject: [PATCH 02/14] feat: iOS clipboard implementation --- src/Hermes.Mobile/Plugins/IOSClipboard.cs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/Hermes.Mobile/Plugins/IOSClipboard.cs diff --git a/src/Hermes.Mobile/Plugins/IOSClipboard.cs b/src/Hermes.Mobile/Plugins/IOSClipboard.cs new file mode 100644 index 0000000..6d10bf6 --- /dev/null +++ b/src/Hermes.Mobile/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.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); +} From a5d04f7a62ab772a2fe45af49d248a486a952eff Mon Sep 17 00:00:00 2001 From: Tom Brewer Date: Sat, 18 Apr 2026 07:53:06 -0600 Subject: [PATCH 03/14] feat(mobile): MIME lookup, iOS asset file provider, Blazor init script --- src/Hermes.Mobile/WebView/BlazorInitScript.cs | 32 ++++++++++++ .../WebView/IOSAssetFileProvider.cs | 49 +++++++++++++++++++ src/Hermes.Mobile/WebView/MimeTypeLookup.cs | 39 +++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 src/Hermes.Mobile/WebView/BlazorInitScript.cs create mode 100644 src/Hermes.Mobile/WebView/IOSAssetFileProvider.cs create mode 100644 src/Hermes.Mobile/WebView/MimeTypeLookup.cs diff --git a/src/Hermes.Mobile/WebView/BlazorInitScript.cs b/src/Hermes.Mobile/WebView/BlazorInitScript.cs new file mode 100644 index 0000000..47ef8be --- /dev/null +++ b/src/Hermes.Mobile/WebView/BlazorInitScript.cs @@ -0,0 +1,32 @@ +// Copyright (c) Mythetech. Licensed under the Elastic License 2.0. +namespace Hermes.Mobile.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/WebView/IOSAssetFileProvider.cs b/src/Hermes.Mobile/WebView/IOSAssetFileProvider.cs new file mode 100644 index 0000000..0b9a10a --- /dev/null +++ b/src/Hermes.Mobile/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.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/WebView/MimeTypeLookup.cs b/src/Hermes.Mobile/WebView/MimeTypeLookup.cs new file mode 100644 index 0000000..1f1c600 --- /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; + +internal 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"; + } +} From e850e0563bbc9352b349cf443fd3d7a4204aa8e6 Mon Sep 17 00:00:00 2001 From: Tom Brewer Date: Sat, 18 Apr 2026 07:54:43 -0600 Subject: [PATCH 04/14] feat(mobile): WKScriptMessageHandler and IWKUrlSchemeHandler for app:// scheme --- src/Hermes.Mobile/WebView/AppSchemeHandler.cs | 57 +++++++++++++++++++ .../WebView/ScriptMessageHandler.cs | 29 ++++++++++ 2 files changed, 86 insertions(+) create mode 100644 src/Hermes.Mobile/WebView/AppSchemeHandler.cs create mode 100644 src/Hermes.Mobile/WebView/ScriptMessageHandler.cs diff --git a/src/Hermes.Mobile/WebView/AppSchemeHandler.cs b/src/Hermes.Mobile/WebView/AppSchemeHandler.cs new file mode 100644 index 0000000..1ea759a --- /dev/null +++ b/src/Hermes.Mobile/WebView/AppSchemeHandler.cs @@ -0,0 +1,57 @@ +// Copyright (c) Mythetech. Licensed under the Elastic License 2.0. +using System.Globalization; +using System.Runtime.Versioning; +using Foundation; +using WebKit; + +namespace Hermes.Mobile.WebView; + +/// +/// Handles app:// requests by delegating to a resolver that wraps WebViewManager.TryGetResponseContent. +/// iOS runs scheme handler callbacks on the main thread. +/// +internal sealed class AppSchemeHandler : NSObject, IWKUrlSchemeHandler +{ + 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 (statusCode, body, contentType) = _resolver(url); + + if (statusCode == 200) + { + using var headers = new NSMutableDictionary(); + headers.Add((NSString)"Content-Length", (NSString)body.Length.ToString(CultureInfo.InvariantCulture)); + headers.Add((NSString)"Content-Type", (NSString)contentType); + headers.Add((NSString)"Cache-Control", (NSString)"no-cache, max-age=0, must-revalidate, no-store"); + + using var response = new NSHttpUrlResponse(urlSchemeTask.Request.Url!, statusCode, "HTTP/1.1", headers); + urlSchemeTask.DidReceiveResponse(response); + urlSchemeTask.DidReceiveData(NSData.FromArray(body)); + urlSchemeTask.DidFinish(); + } + else + { + using var response = new NSHttpUrlResponse(urlSchemeTask.Request.Url!, statusCode, "HTTP/1.1", null); + urlSchemeTask.DidReceiveResponse(response); + 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/WebView/ScriptMessageHandler.cs b/src/Hermes.Mobile/WebView/ScriptMessageHandler.cs new file mode 100644 index 0000000..6379f38 --- /dev/null +++ b/src/Hermes.Mobile/WebView/ScriptMessageHandler.cs @@ -0,0 +1,29 @@ +// Copyright (c) Mythetech. Licensed under the Elastic License 2.0. +using Foundation; +using WebKit; + +namespace Hermes.Mobile.WebView; + +/// +/// Forwards WKWebView → native messages to the WebViewManager's MessageReceived pump. +/// Name "webwindowinterop" must match the JS side in BlazorInitScript. +/// +internal sealed class ScriptMessageHandler : NSObject, IWKScriptMessageHandler +{ + public const string Name = "webwindowinterop"; + + 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); + } +} From 94c1ac047406080ec28d04a5c87dd0852bde9918 Mon Sep 17 00:00:00 2001 From: Tom Brewer Date: Sat, 18 Apr 2026 07:56:31 -0600 Subject: [PATCH 05/14] feat(mobile): main-queue dispatcher & iOSWebViewManager --- src/Hermes.Mobile/Threading/IOSDispatcher.cs | 58 ++++++++++++++ .../WebView/IOSWebViewManager.cs | 80 +++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 src/Hermes.Mobile/Threading/IOSDispatcher.cs create mode 100644 src/Hermes.Mobile/WebView/IOSWebViewManager.cs diff --git a/src/Hermes.Mobile/Threading/IOSDispatcher.cs b/src/Hermes.Mobile/Threading/IOSDispatcher.cs new file mode 100644 index 0000000..a41e427 --- /dev/null +++ b/src/Hermes.Mobile/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.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/WebView/IOSWebViewManager.cs b/src/Hermes.Mobile/WebView/IOSWebViewManager.cs new file mode 100644 index 0000000..691a933 --- /dev/null +++ b/src/Hermes.Mobile/WebView/IOSWebViewManager.cs @@ -0,0 +1,80 @@ +// Copyright (c) Mythetech. Licensed under the Elastic License 2.0. +using System.Diagnostics.CodeAnalysis; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebView; +using Microsoft.Extensions.FileProviders; +using WebKit; + +namespace Hermes.Mobile.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}\")", + (_, _) => { }); + } + + /// 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 (int StatusCode, byte[] Body, string ContentType) ResolveRequest(string absoluteUrl) + { + var allowFallbackOnHostPage = _appBaseUri.IsBaseOf(new Uri(absoluteUrl)); + + if (TryGetResponseContent( + absoluteUrl, + allowFallbackOnHostPage, + out var statusCode, + out _, + out var content, + out var headers)) + { + using var ms = new MemoryStream(); + content.CopyTo(ms); + content.Dispose(); + + var contentType = headers.TryGetValue("Content-Type", out var ct) + ? ct + : MimeTypeLookup.GetContentType(absoluteUrl); + + return (200, ms.ToArray(), contentType); + } + + return (404, Array.Empty(), string.Empty); + } +} From 98af0ad1c08e0b0a8cd8c58f3edc9fbbd5b5a70e Mon Sep 17 00:00:00 2001 From: Tom Brewer Date: Sat, 18 Apr 2026 07:58:29 -0600 Subject: [PATCH 06/14] feat(mobile) app host & builder --- src/Hermes.Mobile/HermesMobileAppBuilder.cs | 74 ++++++++++++++ src/Hermes.Mobile/HermesMobileHost.cs | 106 ++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 src/Hermes.Mobile/HermesMobileAppBuilder.cs create mode 100644 src/Hermes.Mobile/HermesMobileHost.cs diff --git a/src/Hermes.Mobile/HermesMobileAppBuilder.cs b/src/Hermes.Mobile/HermesMobileAppBuilder.cs new file mode 100644 index 0000000..a1ad410 --- /dev/null +++ b/src/Hermes.Mobile/HermesMobileAppBuilder.cs @@ -0,0 +1,74 @@ +// Copyright (c) Mythetech. Licensed under the Elastic License 2.0. +using System.Diagnostics.CodeAnalysis; +using Hermes.Contracts.Plugins; +using Hermes.Mobile.Plugins; +using Hermes.Mobile.WebView; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; + +namespace Hermes.Mobile; + +/// +/// Builder for configuring and constructing a . +/// Mirrors the shape of HermesBlazorAppBuilder so the mental model transfers between heads. +/// +public sealed class HermesMobileAppBuilder +{ + private string _hostPage = "wwwroot/index.html"; + private const string AppScheme = "app"; + private const string AppHost = "localhost"; + + private HermesMobileAppBuilder() + { + Services = new ServiceCollection(); + Services.AddSingleton(); + } + + public static HermesMobileAppBuilder CreateDefault() => new(); + + public IServiceCollection Services { get; } + + public RootComponentCollection RootComponents { get; } = new(); + + 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 appBaseUri = new Uri($"{AppScheme}://{AppHost}/"); + + var components = RootComponents.GetComponents() + .Select(c => (c.Type, c.Selector)) + .ToList(); + + return new HermesMobileHost(provider, fileProvider, appBaseUri, hostPageRelative, components); + } +} + +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/HermesMobileHost.cs b/src/Hermes.Mobile/HermesMobileHost.cs new file mode 100644 index 0000000..f884d03 --- /dev/null +++ b/src/Hermes.Mobile/HermesMobileHost.cs @@ -0,0 +1,106 @@ +// Copyright (c) Mythetech. Licensed under the Elastic License 2.0. +using System.Diagnostics.CodeAnalysis; +using CoreGraphics; +using Foundation; +using Hermes.Mobile.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; + +/// +/// Hosts a Blazor app inside a WKWebView embedded in a UIViewController. +/// The iOS AppDelegate places the RootViewController into its UIWindow. +/// +public sealed class HermesMobileHost : IAsyncDisposable +{ + 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 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, + Uri appBaseUri, + string hostPageRelativePath, + IReadOnlyList<(Type Type, string Selector)> rootComponents) + { + _services = services; + _rootComponents = new List<(Type, string)>(rootComponents); + + var config = new WKWebViewConfiguration(); + config.AllowsInlineMediaPlayback = true; + config.MediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypes.None; + + var dispatcher = new Threading.IOSDispatcher(); + var jsComponents = new JSComponentConfigurationStore(); + + _webView = new WKWebView(CGRect.Empty, config) { AutosizesSubviews = true }; + _webView.ScrollView.Bounces = false; + + _manager = new IOSWebViewManager( + _webView, services, dispatcher, appBaseUri, fileProvider, jsComponents, hostPageRelativePath); + + var schemeHandler = new AppSchemeHandler(_manager.ResolveRequest); + config.SetUrlSchemeHandler(schemeHandler, urlScheme: appBaseUri.Scheme); + + // Wire JS→C# bridge. The IOSWebViewManager exposes an internal helper that forwards + // to the protected WebViewManager.MessageReceived base method. + var scriptHandler = new ScriptMessageHandler(appBaseUri, _manager.MessageReceivedInternal); + 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); + +#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(); + _webView.Dispose(); + } +} From fc739a519ac4814e326dff394aab7282488c632f Mon Sep 17 00:00:00 2001 From: Tom Brewer Date: Sat, 18 Apr 2026 08:01:25 -0600 Subject: [PATCH 07/14] feat: shared RCL for samples --- Hermes.Mobile.sln | 20 ++++++++++++++++ samples/Shared/Shared.App/App.razor | 10 ++++++++ .../Shared/Shared.App/Layout/MainLayout.razor | 10 ++++++++ .../Shared/Shared.App/Layout/NavMenu.razor | 5 ++++ .../Shared.App/Pages/ClipboardDemo.razor | 23 +++++++++++++++++++ samples/Shared/Shared.App/Pages/Counter.razor | 10 ++++++++ samples/Shared/Shared.App/Pages/Index.razor | 8 +++++++ samples/Shared/Shared.App/Shared.App.csproj | 16 +++++++++++++ samples/Shared/Shared.App/_Imports.razor | 10 ++++++++ samples/Shared/Shared.App/wwwroot/css/app.css | 12 ++++++++++ 10 files changed, 124 insertions(+) create mode 100644 samples/Shared/Shared.App/App.razor create mode 100644 samples/Shared/Shared.App/Layout/MainLayout.razor create mode 100644 samples/Shared/Shared.App/Layout/NavMenu.razor create mode 100644 samples/Shared/Shared.App/Pages/ClipboardDemo.razor create mode 100644 samples/Shared/Shared.App/Pages/Counter.razor create mode 100644 samples/Shared/Shared.App/Pages/Index.razor create mode 100644 samples/Shared/Shared.App/Shared.App.csproj create mode 100644 samples/Shared/Shared.App/_Imports.razor create mode 100644 samples/Shared/Shared.App/wwwroot/css/app.css diff --git a/Hermes.Mobile.sln b/Hermes.Mobile.sln index 7064573..2df6f4d 100644 --- a/Hermes.Mobile.sln +++ b/Hermes.Mobile.sln @@ -13,6 +13,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hermes.Blazor", "src\Hermes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hermes.Mobile", "src\Hermes.Mobile\Hermes.Mobile.csproj", "{16737429-333A-428D-91C3-04067413D953}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{3177BFE4-54BB-4449-CBD6-550FA3C2F073}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared.App", "samples\Shared\Shared.App\Shared.App.csproj", "{FCB1797B-0AFF-41A0-8C02-02B06E9BE125}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -71,6 +77,18 @@ Global {16737429-333A-428D-91C3-04067413D953}.Release|x64.Build.0 = Release|Any CPU {16737429-333A-428D-91C3-04067413D953}.Release|x86.ActiveCfg = Release|Any CPU {16737429-333A-428D-91C3-04067413D953}.Release|x86.Build.0 = Release|Any CPU + {FCB1797B-0AFF-41A0-8C02-02B06E9BE125}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FCB1797B-0AFF-41A0-8C02-02B06E9BE125}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FCB1797B-0AFF-41A0-8C02-02B06E9BE125}.Debug|x64.ActiveCfg = Debug|Any CPU + {FCB1797B-0AFF-41A0-8C02-02B06E9BE125}.Debug|x64.Build.0 = Debug|Any CPU + {FCB1797B-0AFF-41A0-8C02-02B06E9BE125}.Debug|x86.ActiveCfg = Debug|Any CPU + {FCB1797B-0AFF-41A0-8C02-02B06E9BE125}.Debug|x86.Build.0 = Debug|Any CPU + {FCB1797B-0AFF-41A0-8C02-02B06E9BE125}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FCB1797B-0AFF-41A0-8C02-02B06E9BE125}.Release|Any CPU.Build.0 = Release|Any CPU + {FCB1797B-0AFF-41A0-8C02-02B06E9BE125}.Release|x64.ActiveCfg = Release|Any CPU + {FCB1797B-0AFF-41A0-8C02-02B06E9BE125}.Release|x64.Build.0 = Release|Any CPU + {FCB1797B-0AFF-41A0-8C02-02B06E9BE125}.Release|x86.ActiveCfg = Release|Any CPU + {FCB1797B-0AFF-41A0-8C02-02B06E9BE125}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -80,5 +98,7 @@ Global {6111B780-0E6D-4756-8830-7C1CB930A85D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {C37C83EB-4BD7-4FBA-855C-AF016D498C57} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {16737429-333A-428D-91C3-04067413D953} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {3177BFE4-54BB-4449-CBD6-550FA3C2F073} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {FCB1797B-0AFF-41A0-8C02-02B06E9BE125} = {3177BFE4-54BB-4449-CBD6-550FA3C2F073} EndGlobalSection EndGlobal diff --git a/samples/Shared/Shared.App/App.razor b/samples/Shared/Shared.App/App.razor new file mode 100644 index 0000000..4cb0f18 --- /dev/null +++ b/samples/Shared/Shared.App/App.razor @@ -0,0 +1,10 @@ + + + + + + +

Page not found

+
+
+
diff --git a/samples/Shared/Shared.App/Layout/MainLayout.razor b/samples/Shared/Shared.App/Layout/MainLayout.razor new file mode 100644 index 0000000..b8f2c5c --- /dev/null +++ b/samples/Shared/Shared.App/Layout/MainLayout.razor @@ -0,0 +1,10 @@ +@inherits LayoutComponentBase + +
+ +
+ @Body +
+
diff --git a/samples/Shared/Shared.App/Layout/NavMenu.razor b/samples/Shared/Shared.App/Layout/NavMenu.razor new file mode 100644 index 0000000..10f0b65 --- /dev/null +++ b/samples/Shared/Shared.App/Layout/NavMenu.razor @@ -0,0 +1,5 @@ + diff --git a/samples/Shared/Shared.App/Pages/ClipboardDemo.razor b/samples/Shared/Shared.App/Pages/ClipboardDemo.razor new file mode 100644 index 0000000..3e93e7b --- /dev/null +++ b/samples/Shared/Shared.App/Pages/ClipboardDemo.razor @@ -0,0 +1,23 @@ +@page "/clipboard" +@inject IClipboard Clipboard + +

Clipboard Demo

+ + + + +
+ + +
+ +

Last read value: @_lastRead

+ +@code { + private string _text = "hello from hermes"; + private string? _lastRead; + + private async Task SetAsync() => await Clipboard.SetTextAsync(_text); + + private async Task GetAsync() => _lastRead = await Clipboard.GetTextAsync(); +} diff --git a/samples/Shared/Shared.App/Pages/Counter.razor b/samples/Shared/Shared.App/Pages/Counter.razor new file mode 100644 index 0000000..53e5fde --- /dev/null +++ b/samples/Shared/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/Shared/Shared.App/Pages/Index.razor b/samples/Shared/Shared.App/Pages/Index.razor new file mode 100644 index 0000000..d2c843b --- /dev/null +++ b/samples/Shared/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/Shared/Shared.App/Shared.App.csproj b/samples/Shared/Shared.App/Shared.App.csproj new file mode 100644 index 0000000..e04ac57 --- /dev/null +++ b/samples/Shared/Shared.App/Shared.App.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + true + + + + + + + + + + diff --git a/samples/Shared/Shared.App/_Imports.razor b/samples/Shared/Shared.App/_Imports.razor new file mode 100644 index 0000000..b2b5b18 --- /dev/null +++ b/samples/Shared/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/Shared/Shared.App/wwwroot/css/app.css b/samples/Shared/Shared.App/wwwroot/css/app.css new file mode 100644 index 0000000..70ce5e9 --- /dev/null +++ b/samples/Shared/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; } From f8216b25455faf9824d22697962b3dec5a245ed4 Mon Sep 17 00:00:00 2001 From: Tom Brewer Date: Sat, 18 Apr 2026 08:11:01 -0600 Subject: [PATCH 08/14] Suggested commit: feat(samples): Shared.Desktop head with DevTools enabled and clipboard error surface --- Hermes.Mobile.sln | 15 +++++++++ .../Shared.App/Pages/ClipboardDemo.razor | 32 +++++++++++++++++-- samples/Shared/Shared.Desktop/Program.cs | 21 ++++++++++++ .../Shared.Desktop/Shared.Desktop.csproj | 25 +++++++++++++++ .../Shared/Shared.Desktop/wwwroot/index.html | 14 ++++++++ 5 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 samples/Shared/Shared.Desktop/Program.cs create mode 100644 samples/Shared/Shared.Desktop/Shared.Desktop.csproj create mode 100644 samples/Shared/Shared.Desktop/wwwroot/index.html diff --git a/Hermes.Mobile.sln b/Hermes.Mobile.sln index 2df6f4d..e00bccd 100644 --- a/Hermes.Mobile.sln +++ b/Hermes.Mobile.sln @@ -19,6 +19,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{3177BF EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared.App", "samples\Shared\Shared.App\Shared.App.csproj", "{FCB1797B-0AFF-41A0-8C02-02B06E9BE125}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared.Desktop", "samples\Shared\Shared.Desktop\Shared.Desktop.csproj", "{0CF73C00-BAD5-492A-B6CE-94C484243F7F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -89,6 +91,18 @@ Global {FCB1797B-0AFF-41A0-8C02-02B06E9BE125}.Release|x64.Build.0 = Release|Any CPU {FCB1797B-0AFF-41A0-8C02-02B06E9BE125}.Release|x86.ActiveCfg = Release|Any CPU {FCB1797B-0AFF-41A0-8C02-02B06E9BE125}.Release|x86.Build.0 = Release|Any CPU + {0CF73C00-BAD5-492A-B6CE-94C484243F7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0CF73C00-BAD5-492A-B6CE-94C484243F7F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0CF73C00-BAD5-492A-B6CE-94C484243F7F}.Debug|x64.ActiveCfg = Debug|Any CPU + {0CF73C00-BAD5-492A-B6CE-94C484243F7F}.Debug|x64.Build.0 = Debug|Any CPU + {0CF73C00-BAD5-492A-B6CE-94C484243F7F}.Debug|x86.ActiveCfg = Debug|Any CPU + {0CF73C00-BAD5-492A-B6CE-94C484243F7F}.Debug|x86.Build.0 = Debug|Any CPU + {0CF73C00-BAD5-492A-B6CE-94C484243F7F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0CF73C00-BAD5-492A-B6CE-94C484243F7F}.Release|Any CPU.Build.0 = Release|Any CPU + {0CF73C00-BAD5-492A-B6CE-94C484243F7F}.Release|x64.ActiveCfg = Release|Any CPU + {0CF73C00-BAD5-492A-B6CE-94C484243F7F}.Release|x64.Build.0 = Release|Any CPU + {0CF73C00-BAD5-492A-B6CE-94C484243F7F}.Release|x86.ActiveCfg = Release|Any CPU + {0CF73C00-BAD5-492A-B6CE-94C484243F7F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -100,5 +114,6 @@ Global {16737429-333A-428D-91C3-04067413D953} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {3177BFE4-54BB-4449-CBD6-550FA3C2F073} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} {FCB1797B-0AFF-41A0-8C02-02B06E9BE125} = {3177BFE4-54BB-4449-CBD6-550FA3C2F073} + {0CF73C00-BAD5-492A-B6CE-94C484243F7F} = {3177BFE4-54BB-4449-CBD6-550FA3C2F073} EndGlobalSection EndGlobal diff --git a/samples/Shared/Shared.App/Pages/ClipboardDemo.razor b/samples/Shared/Shared.App/Pages/ClipboardDemo.razor index 3e93e7b..8bcd97e 100644 --- a/samples/Shared/Shared.App/Pages/ClipboardDemo.razor +++ b/samples/Shared/Shared.App/Pages/ClipboardDemo.razor @@ -13,11 +13,39 @@

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() => await Clipboard.SetTextAsync(_text); + 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() => _lastRead = await Clipboard.GetTextAsync(); + 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/Shared/Shared.Desktop/Program.cs b/samples/Shared/Shared.Desktop/Program.cs new file mode 100644 index 0000000..267188a --- /dev/null +++ b/samples/Shared/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/Shared/Shared.Desktop/Shared.Desktop.csproj b/samples/Shared/Shared.Desktop/Shared.Desktop.csproj new file mode 100644 index 0000000..e878e29 --- /dev/null +++ b/samples/Shared/Shared.Desktop/Shared.Desktop.csproj @@ -0,0 +1,25 @@ + + + WinExe + net10.0 + enable + enable + Shared.Desktop + + + + + + + + + + + + + + + + diff --git a/samples/Shared/Shared.Desktop/wwwroot/index.html b/samples/Shared/Shared.Desktop/wwwroot/index.html new file mode 100644 index 0000000..dcabab1 --- /dev/null +++ b/samples/Shared/Shared.Desktop/wwwroot/index.html @@ -0,0 +1,14 @@ + + + + + + Shared Blazor — Desktop + + + + +
+ + + From d263a0bac7aebac20b9a91868cf0cd6e547f59d7 Mon Sep 17 00:00:00 2001 From: Tom Brewer Date: Sat, 18 Apr 2026 12:36:45 -0600 Subject: [PATCH 09/14] feat: package + rcl + desktop + mobilescaffold + binding fix --- samples/Shared/Shared.Mobile/AppDelegate.cs | 32 ++++++++++++ samples/Shared/Shared.Mobile/Info.plist | 34 +++++++++++++ samples/Shared/Shared.Mobile/Program.cs | 12 +++++ .../Resources/wwwroot/index.html | 14 ++++++ .../Shared/Shared.Mobile/Shared.Mobile.csproj | 49 +++++++++++++++++++ src/Hermes.Mobile/HermesMobileAppBuilder.cs | 5 +- src/Hermes.Mobile/HermesMobileHost.cs | 49 ++++++++++++++----- .../WebView/AllowAllNavigationDelegate.cs | 33 +++++++++++++ src/Hermes.Mobile/WebView/AppSchemeHandler.cs | 8 +++ .../WebView/IOSWebViewManager.cs | 22 +++++++-- .../WebView/ScriptMessageHandler.cs | 3 ++ 11 files changed, 242 insertions(+), 19 deletions(-) create mode 100644 samples/Shared/Shared.Mobile/AppDelegate.cs create mode 100644 samples/Shared/Shared.Mobile/Info.plist create mode 100644 samples/Shared/Shared.Mobile/Program.cs create mode 100644 samples/Shared/Shared.Mobile/Resources/wwwroot/index.html create mode 100644 samples/Shared/Shared.Mobile/Shared.Mobile.csproj create mode 100644 src/Hermes.Mobile/WebView/AllowAllNavigationDelegate.cs diff --git a/samples/Shared/Shared.Mobile/AppDelegate.cs b/samples/Shared/Shared.Mobile/AppDelegate.cs new file mode 100644 index 0000000..0e53d5b --- /dev/null +++ b/samples/Shared/Shared.Mobile/AppDelegate.cs @@ -0,0 +1,32 @@ +// Copyright (c) Mythetech. Licensed under the Elastic License 2.0. +using Foundation; +using Hermes.Mobile; +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/Shared/Shared.Mobile/Info.plist b/samples/Shared/Shared.Mobile/Info.plist new file mode 100644 index 0000000..c25dfd6 --- /dev/null +++ b/samples/Shared/Shared.Mobile/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleIdentifier + com.mythetech.hermes.shared.mobile + CFBundleName + Shared Mobile + CFBundleDisplayName + Shared Mobile + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchScreen + + UIColorName + systemBackgroundColor + + MinimumOSVersion + 15.0 + UIDeviceFamily + + 1 + 2 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + diff --git a/samples/Shared/Shared.Mobile/Program.cs b/samples/Shared/Shared.Mobile/Program.cs new file mode 100644 index 0000000..22d4fb1 --- /dev/null +++ b/samples/Shared/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/Shared/Shared.Mobile/Resources/wwwroot/index.html b/samples/Shared/Shared.Mobile/Resources/wwwroot/index.html new file mode 100644 index 0000000..3e366aa --- /dev/null +++ b/samples/Shared/Shared.Mobile/Resources/wwwroot/index.html @@ -0,0 +1,14 @@ + + + + + + Shared Blazor — iOS + + + + +
+ + + diff --git a/samples/Shared/Shared.Mobile/Shared.Mobile.csproj b/samples/Shared/Shared.Mobile/Shared.Mobile.csproj new file mode 100644 index 0000000..f731644 --- /dev/null +++ b/samples/Shared/Shared.Mobile/Shared.Mobile.csproj @@ -0,0 +1,49 @@ + + + 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/_content/Shared.App/%(RecursiveDir)%(Filename)%(Extension) + + + diff --git a/src/Hermes.Mobile/HermesMobileAppBuilder.cs b/src/Hermes.Mobile/HermesMobileAppBuilder.cs index a1ad410..7d93e8b 100644 --- a/src/Hermes.Mobile/HermesMobileAppBuilder.cs +++ b/src/Hermes.Mobile/HermesMobileAppBuilder.cs @@ -15,8 +15,9 @@ namespace Hermes.Mobile; public sealed class HermesMobileAppBuilder { private string _hostPage = "wwwroot/index.html"; - private const string AppScheme = "app"; - private const string AppHost = "localhost"; + private const string AppScheme = "hermes"; + // WKWebView rejects app://localhost/ as invalid (requestURLIsValid=0). MAUI uses 0.0.0.0. + private const string AppHost = "0.0.0.0"; private HermesMobileAppBuilder() { diff --git a/src/Hermes.Mobile/HermesMobileHost.cs b/src/Hermes.Mobile/HermesMobileHost.cs index f884d03..1c8fc89 100644 --- a/src/Hermes.Mobile/HermesMobileHost.cs +++ b/src/Hermes.Mobile/HermesMobileHost.cs @@ -23,6 +23,13 @@ public sealed class HermesMobileHost : IAsyncDisposable private readonly IOSWebViewManager _manager; private readonly UIViewController _rootViewController; private readonly List<(Type Type, string Selector)> _rootComponents; + + // 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 AppSchemeHandler _schemeHandler; + private readonly ScriptMessageHandler _scriptHandler; + private readonly AllowAllNavigationDelegate _navDelegate; + private bool _started; [RequiresDynamicCode("Blazor WebView requires dynamic code for component rendering")] @@ -44,30 +51,46 @@ internal HermesMobileHost( var dispatcher = new Threading.IOSDispatcher(); var jsComponents = new JSComponentConfigurationStore(); - _webView = new WKWebView(CGRect.Empty, config) { AutosizesSubviews = true }; - _webView.ScrollView.Bounces = false; - - _manager = new IOSWebViewManager( - _webView, services, dispatcher, appBaseUri, fileProvider, jsComponents, hostPageRelativePath); + // All scheme handlers, user content controllers, and user scripts MUST be registered + // on the config BEFORE creating the WKWebView — WKWebView copies the config at + // construction and later mutations are ignored. - var schemeHandler = new AppSchemeHandler(_manager.ResolveRequest); - config.SetUrlSchemeHandler(schemeHandler, urlScheme: appBaseUri.Scheme); + // Scheme handler needs to delegate to IOSWebViewManager, which needs the WKWebView + // itself (circular). Use a late-bound resolver that captures _manager after assignment. + IOSWebViewManager? pendingManager = null; + _schemeHandler = new AppSchemeHandler(url => + pendingManager?.ResolveRequest(url) ?? (404, Array.Empty(), string.Empty)); + config.SetUrlSchemeHandler(_schemeHandler, urlScheme: appBaseUri.Scheme); - // Wire JS→C# bridge. The IOSWebViewManager exposes an internal helper that forwards - // to the protected WebViewManager.MessageReceived base method. - var scriptHandler = new ScriptMessageHandler(appBaseUri, _manager.MessageReceivedInternal); - config.UserContentController.AddScriptMessageHandler(scriptHandler, ScriptMessageHandler.Name); + // Script message handler — same late-bind pattern. + _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); -#if DEBUG + _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; + + // Always-on for PoC. iOS 16.4+ only. if (OperatingSystem.IsIOSVersionAtLeast(16, 4)) { _webView.SetValueForKey(NSObject.FromObject(true), (NSString)"inspectable"); + Console.WriteLine("[Hermes.Mobile] webview inspectable = true"); + } + else + { + Console.WriteLine("[Hermes.Mobile] iOS < 16.4; webview not inspectable"); } -#endif _rootViewController = new UIViewController(); var rootView = _rootViewController.View!; diff --git a/src/Hermes.Mobile/WebView/AllowAllNavigationDelegate.cs b/src/Hermes.Mobile/WebView/AllowAllNavigationDelegate.cs new file mode 100644 index 0000000..6ab1e31 --- /dev/null +++ b/src/Hermes.Mobile/WebView/AllowAllNavigationDelegate.cs @@ -0,0 +1,33 @@ +// Copyright (c) Mythetech. Licensed under the Elastic License 2.0. +using Foundation; +using ObjCRuntime; +using WebKit; + +namespace Hermes.Mobile.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 +{ + [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/WebView/AppSchemeHandler.cs b/src/Hermes.Mobile/WebView/AppSchemeHandler.cs index 1ea759a..f870da7 100644 --- a/src/Hermes.Mobile/WebView/AppSchemeHandler.cs +++ b/src/Hermes.Mobile/WebView/AppSchemeHandler.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Runtime.Versioning; using Foundation; +using ObjCRuntime; using WebKit; namespace Hermes.Mobile.WebView; @@ -10,12 +11,15 @@ namespace Hermes.Mobile.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 { private readonly Func _resolver; public AppSchemeHandler(Func resolver) { + Console.WriteLine("[Hermes.Mobile] AppSchemeHandler constructed"); _resolver = resolver; } @@ -25,9 +29,13 @@ public void StartUrlSchemeTask(WKWebView webView, IWKUrlSchemeTask urlSchemeTask { var url = urlSchemeTask.Request.Url?.AbsoluteString; if (string.IsNullOrEmpty(url)) + { + Console.WriteLine("[Hermes.Mobile] scheme handler: empty URL, ignoring"); return; + } var (statusCode, body, contentType) = _resolver(url); + Console.WriteLine($"[Hermes.Mobile] scheme handler: {url} → {statusCode} ({contentType}, {body.Length} bytes)"); if (statusCode == 200) { diff --git a/src/Hermes.Mobile/WebView/IOSWebViewManager.cs b/src/Hermes.Mobile/WebView/IOSWebViewManager.cs index 691a933..fd64053 100644 --- a/src/Hermes.Mobile/WebView/IOSWebViewManager.cs +++ b/src/Hermes.Mobile/WebView/IOSWebViewManager.cs @@ -35,17 +35,31 @@ public IOSWebViewManager( protected override void NavigateCore(Uri absoluteUri) { - using var url = new Foundation.NSUrl(absoluteUri.ToString()); - using var request = new Foundation.NSUrlRequest(url); - _webView.LoadRequest(request); + // Custom URL scheme handlers don't fire in current .NET iOS bindings, so we load the + // index.html directly from the app bundle via file:// and let Blazor's JS bridge + // (window.webkit.messageHandlers) take over from there. Static assets (blazor.webview.js, + // _content/Shared.App/*) resolve via relative paths in the HTML. + var bundleRoot = Foundation.NSBundle.MainBundle.ResourcePath!; + var indexPath = Path.Combine(bundleRoot, "wwwroot", "index.html"); + var readAccessDir = Path.Combine(bundleRoot, "wwwroot"); + + Console.WriteLine($"[Hermes.Mobile] NavigateCore: file={indexPath}, readAccess={readAccessDir}"); + using var fileUrl = Foundation.NSUrl.FromFilename(indexPath); + using var readUrl = Foundation.NSUrl.FromFilename(readAccessDir); + _webView.LoadFileUrl(fileUrl, readUrl); } protected override void SendMessage(string message) { var encoded = JavaScriptEncoder.Default.Encode(message); + Console.WriteLine($"[Hermes.Mobile] C#→JS SendMessage ({message.Length} chars)"); _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. diff --git a/src/Hermes.Mobile/WebView/ScriptMessageHandler.cs b/src/Hermes.Mobile/WebView/ScriptMessageHandler.cs index 6379f38..a6adb0e 100644 --- a/src/Hermes.Mobile/WebView/ScriptMessageHandler.cs +++ b/src/Hermes.Mobile/WebView/ScriptMessageHandler.cs @@ -1,5 +1,6 @@ // Copyright (c) Mythetech. Licensed under the Elastic License 2.0. using Foundation; +using ObjCRuntime; using WebKit; namespace Hermes.Mobile.WebView; @@ -8,6 +9,7 @@ namespace Hermes.Mobile.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"; @@ -24,6 +26,7 @@ public ScriptMessageHandler(Uri appOrigin, Action onMessage) public void DidReceiveScriptMessage(WKUserContentController userContentController, WKScriptMessage message) { var body = ((NSString)message.Body).ToString(); + Console.WriteLine($"[Hermes.Mobile] JS→C# message ({body.Length} chars): {(body.Length > 120 ? body.Substring(0, 120) + "..." : body)}"); _onMessage(_appOrigin, body); } } From 416afb2807b8b4c76f5580e594e65baa4ae8a8eb Mon Sep 17 00:00:00 2001 From: Tom Brewer Date: Sat, 18 Apr 2026 12:36:59 -0600 Subject: [PATCH 10/14] More script handling fixes --- .../Resources/wwwroot/index.html | 2 +- src/Hermes.Mobile/HermesMobileHost.cs | 9 +-- .../WebView/AllowAllNavigationDelegate.cs | 3 + src/Hermes.Mobile/WebView/AppSchemeHandler.cs | 8 +-- .../WebView/IOSWebViewManager.cs | 16 +----- src/Hermes.Mobile/WebView/ProtocolAdoption.cs | 57 +++++++++++++++++++ .../WebView/ScriptMessageHandler.cs | 4 +- 7 files changed, 73 insertions(+), 26 deletions(-) create mode 100644 src/Hermes.Mobile/WebView/ProtocolAdoption.cs diff --git a/samples/Shared/Shared.Mobile/Resources/wwwroot/index.html b/samples/Shared/Shared.Mobile/Resources/wwwroot/index.html index 3e366aa..4172dd5 100644 --- a/samples/Shared/Shared.Mobile/Resources/wwwroot/index.html +++ b/samples/Shared/Shared.Mobile/Resources/wwwroot/index.html @@ -4,7 +4,7 @@ Shared Blazor — iOS - + diff --git a/src/Hermes.Mobile/HermesMobileHost.cs b/src/Hermes.Mobile/HermesMobileHost.cs index 1c8fc89..d4fae56 100644 --- a/src/Hermes.Mobile/HermesMobileHost.cs +++ b/src/Hermes.Mobile/HermesMobileHost.cs @@ -81,16 +81,13 @@ internal HermesMobileHost( _webView, services, dispatcher, appBaseUri, fileProvider, jsComponents, hostPageRelativePath); pendingManager = _manager; - // Always-on for PoC. iOS 16.4+ only. + // Enable Safari Web Inspector attachment in Debug. iOS 16.4+ only. +#if DEBUG if (OperatingSystem.IsIOSVersionAtLeast(16, 4)) { _webView.SetValueForKey(NSObject.FromObject(true), (NSString)"inspectable"); - Console.WriteLine("[Hermes.Mobile] webview inspectable = true"); - } - else - { - Console.WriteLine("[Hermes.Mobile] iOS < 16.4; webview not inspectable"); } +#endif _rootViewController = new UIViewController(); var rootView = _rootViewController.View!; diff --git a/src/Hermes.Mobile/WebView/AllowAllNavigationDelegate.cs b/src/Hermes.Mobile/WebView/AllowAllNavigationDelegate.cs index 6ab1e31..85ae7f4 100644 --- a/src/Hermes.Mobile/WebView/AllowAllNavigationDelegate.cs +++ b/src/Hermes.Mobile/WebView/AllowAllNavigationDelegate.cs @@ -13,6 +13,9 @@ namespace Hermes.Mobile.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) { diff --git a/src/Hermes.Mobile/WebView/AppSchemeHandler.cs b/src/Hermes.Mobile/WebView/AppSchemeHandler.cs index f870da7..fa0fdfa 100644 --- a/src/Hermes.Mobile/WebView/AppSchemeHandler.cs +++ b/src/Hermes.Mobile/WebView/AppSchemeHandler.cs @@ -15,11 +15,13 @@ namespace Hermes.Mobile.WebView; [Adopts("WKURLSchemeHandler")] internal sealed class AppSchemeHandler : NSObject, IWKUrlSchemeHandler { + static AppSchemeHandler() + => ProtocolAdoption.Ensure("WKURLSchemeHandler"); + private readonly Func _resolver; public AppSchemeHandler(Func resolver) { - Console.WriteLine("[Hermes.Mobile] AppSchemeHandler constructed"); _resolver = resolver; } @@ -29,13 +31,9 @@ public void StartUrlSchemeTask(WKWebView webView, IWKUrlSchemeTask urlSchemeTask { var url = urlSchemeTask.Request.Url?.AbsoluteString; if (string.IsNullOrEmpty(url)) - { - Console.WriteLine("[Hermes.Mobile] scheme handler: empty URL, ignoring"); return; - } var (statusCode, body, contentType) = _resolver(url); - Console.WriteLine($"[Hermes.Mobile] scheme handler: {url} → {statusCode} ({contentType}, {body.Length} bytes)"); if (statusCode == 200) { diff --git a/src/Hermes.Mobile/WebView/IOSWebViewManager.cs b/src/Hermes.Mobile/WebView/IOSWebViewManager.cs index fd64053..b5df497 100644 --- a/src/Hermes.Mobile/WebView/IOSWebViewManager.cs +++ b/src/Hermes.Mobile/WebView/IOSWebViewManager.cs @@ -35,24 +35,14 @@ public IOSWebViewManager( protected override void NavigateCore(Uri absoluteUri) { - // Custom URL scheme handlers don't fire in current .NET iOS bindings, so we load the - // index.html directly from the app bundle via file:// and let Blazor's JS bridge - // (window.webkit.messageHandlers) take over from there. Static assets (blazor.webview.js, - // _content/Shared.App/*) resolve via relative paths in the HTML. - var bundleRoot = Foundation.NSBundle.MainBundle.ResourcePath!; - var indexPath = Path.Combine(bundleRoot, "wwwroot", "index.html"); - var readAccessDir = Path.Combine(bundleRoot, "wwwroot"); - - Console.WriteLine($"[Hermes.Mobile] NavigateCore: file={indexPath}, readAccess={readAccessDir}"); - using var fileUrl = Foundation.NSUrl.FromFilename(indexPath); - using var readUrl = Foundation.NSUrl.FromFilename(readAccessDir); - _webView.LoadFileUrl(fileUrl, readUrl); + 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); - Console.WriteLine($"[Hermes.Mobile] C#→JS SendMessage ({message.Length} chars)"); _webView.EvaluateJavaScript( $"__dispatchMessageCallback(\"{encoded}\")", (result, error) => diff --git a/src/Hermes.Mobile/WebView/ProtocolAdoption.cs b/src/Hermes.Mobile/WebView/ProtocolAdoption.cs new file mode 100644 index 0000000..ba20791 --- /dev/null +++ b/src/Hermes.Mobile/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.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/WebView/ScriptMessageHandler.cs b/src/Hermes.Mobile/WebView/ScriptMessageHandler.cs index a6adb0e..acfe642 100644 --- a/src/Hermes.Mobile/WebView/ScriptMessageHandler.cs +++ b/src/Hermes.Mobile/WebView/ScriptMessageHandler.cs @@ -14,6 +14,9 @@ internal sealed class ScriptMessageHandler : NSObject, IWKScriptMessageHandler { public const string Name = "webwindowinterop"; + static ScriptMessageHandler() + => ProtocolAdoption.Ensure("WKScriptMessageHandler"); + private readonly Action _onMessage; private readonly Uri _appOrigin; @@ -26,7 +29,6 @@ public ScriptMessageHandler(Uri appOrigin, Action onMessage) public void DidReceiveScriptMessage(WKUserContentController userContentController, WKScriptMessage message) { var body = ((NSString)message.Body).ToString(); - Console.WriteLine($"[Hermes.Mobile] JS→C# message ({body.Length} chars): {(body.Length > 120 ? body.Substring(0, 120) + "..." : body)}"); _onMessage(_appOrigin, body); } } From b5239656f4284226f5ac6d095932cdd84411444b Mon Sep 17 00:00:00 2001 From: Tom Brewer Date: Sat, 18 Apr 2026 13:37:58 -0600 Subject: [PATCH 11/14] feat: kestral to work around native binding issue Fully working E2E iOS mobile sample with plugin proof in clipboard --- IOS-ARCHITECTURE.md | 155 ++++++++++++++++++ README.md | 18 ++ samples/Shared/Shared.Mobile/Info.plist | 5 + .../Shared/Shared.Mobile/Shared.Mobile.csproj | 13 +- src/Hermes.Mobile/HermesMobileAppBuilder.cs | 8 +- src/Hermes.Mobile/HermesMobileHost.cs | 30 ++-- .../WebView/EmbeddedFileServer.cs | 128 +++++++++++++++ 7 files changed, 337 insertions(+), 20 deletions(-) create mode 100644 IOS-ARCHITECTURE.md create mode 100644 src/Hermes.Mobile/WebView/EmbeddedFileServer.cs diff --git a/IOS-ARCHITECTURE.md b/IOS-ARCHITECTURE.md new file mode 100644 index 0000000..4716209 --- /dev/null +++ b/IOS-ARCHITECTURE.md @@ -0,0 +1,155 @@ +# Hermes.Mobile iOS Architecture + +## Lifecycle Inversion + +Desktop Hermes follows an executable-first model: C# `Main` owns the process, creates the native window via `IHermesWindowBackend`, and spins up the WKWebView itself. iOS does not allow this. The `UIApplicationDelegate` owns the app lifecycle, and the `UIWindow` / `UIViewController` owns the view hierarchy. + +Hermes.Mobile inverts the relationship: the iOS shell drives the lifecycle, and Hermes becomes a library that hosts Blazor inside a `WKWebView` provided by the shell. + +``` +Desktop: C# Main() → HermesWindow → IHermesWindowBackend → native window + webview +iOS: Swift/ObjC AppDelegate → UIWindow → HermesMobileHost → WKWebView + Blazor +``` + +The public API mirrors the desktop builder pattern: + +```csharp +var builder = HermesMobileAppBuilder.CreateDefault(); +builder.RootComponents.Add("#app"); +var host = builder.Build(); +Window = new UIWindow { RootViewController = host.RootViewController }; +host.Start(); +``` + +## Asset Serving + +Blazor apps need an HTML host page, `blazor.webview.js`, CSS, and static assets served to the webview. On desktop, Hermes uses a custom URL scheme handler (`app://localhost/`) backed by `WebViewManager.TryGetResponseContent`. On iOS, this approach is broken (see Known Issues), so assets are served via an embedded HTTP server on localhost. + +`EmbeddedFileServer` is a minimal `HttpListener`-based server that: +- Binds to `http://localhost:0/` (OS-assigned port) +- Serves files from `IOSAssetFileProvider`, which reads from `NSBundle.MainBundle.ResourcePath` +- Synthesizes an empty `_framework/blazor.modules.json` if it's missing from the bundle +- Falls back to `index.html` for extensionless paths (SPA routing) + +The webview navigates to `http://localhost:{port}/` and loads Blazor assets over HTTP. iOS allows localhost HTTP without App Transport Security exceptions, though `NSAllowsLocalNetworking` is set in Info.plist for explicitness. + +## JS-C# Bridge + +The bridge protocol is identical to desktop Hermes and MAUI's BlazorWebView: + +``` +JS → C#: window.external.sendMessage(json) + → WKScriptMessageHandler ("webwindowinterop") + → ScriptMessageHandler.DidReceiveScriptMessage + → WebViewManager.MessageReceived + +C# → JS: WebViewManager.SendMessage(json) + → WKWebView.EvaluateJavaScript("__dispatchMessageCallback(\"...\")") + → window.__dispatchMessageCallback + → registered receive callbacks +``` + +A `WKUserScript` injected at document-end wires up `window.external.sendMessage/receiveMessage` and calls `Blazor.start()` (the host page uses `autostart="false"`). + +## Initialization Order + +Several constraints force a specific initialization sequence in `HermesMobileHost`: + +1. **Start `EmbeddedFileServer`** with `IOSAssetFileProvider` to get the assigned port +2. **Compute `appBaseUri`** as `http://localhost:{port}/` +3. **Register `ScriptMessageHandler`** on `WKWebViewConfiguration.UserContentController` +4. **Inject `BlazorInitScript`** as a `WKUserScript` at `AtDocumentEnd` +5. **Create `WKWebView`** with the fully configured `WKWebViewConfiguration` +6. **Create `IOSWebViewManager`** with the webview and `appBaseUri` +7. **Wire late-bound closure** so the ScriptMessageHandler can forward messages to the manager + +Steps 3-4 must happen before step 5 because `WKWebView` copies its configuration at construction, ignoring later mutations. The ScriptMessageHandler and IOSWebViewManager have a circular dependency (handler needs manager, manager needs webview, webview needs config with handler), broken by a late-bound closure that captures `pendingManager`. + +NSObject-bridged handlers (`ScriptMessageHandler`, `AllowAllNavigationDelegate`) are stored as instance fields on `HermesMobileHost` to prevent GC collection, which would silently stop native callbacks. + +## DI Requirements + +`HermesMobileAppBuilder.CreateDefault()` calls `services.AddBlazorWebView()` from the `Microsoft.AspNetCore.Components.WebView` package. This registers `NavigationManager`, `IJSRuntime`, and other services that the `WebViewManager` base class expects. Missing this call results in `No service for type 'NavigationManager' has been registered` at runtime. + +## iOS App Bundle Structure + +Static assets must be included as `BundleResource` items in the app project's csproj with `LogicalName` mappings that preserve the expected URL paths: + +``` +wwwroot/ + index.html ← app's host page + _framework/blazor.webview.js ← from Microsoft.AspNetCore.Components.WebView NuGet + _framework/blazor.modules.json ← from same NuGet (or synthesized by file server) + _content/Shared.App/css/app.css ← from Shared.App RCL wwwroot +``` + +The `$(PkgMicrosoft_AspNetCore_Components_WebView)` MSBuild property (via `GeneratePathProperty="true"` on the PackageReference) resolves the NuGet package path for `blazor.webview.js`. + +## Known Issues and Workarounds + +### WKURLSchemeHandler Not Invoked (.NET iOS Registrar Regression) + +**Issue:** `WKURLSchemeHandler` registered via `SetUrlSchemeHandler` is never invoked by `WKWebView`. The handler registers correctly, `ProtocolAdoption.Ensure` confirms protocol conformance, but `StartUrlSchemeTask` is never called. Navigation lands on `about:blank`. + +**Root cause:** .NET iOS 26.2 dynamic registrar does not emit Obj-C protocol conformance metadata for managed classes implementing protocol interfaces. Related: dotnet/macios#23002, dotnet/maui#32894. + +**Workaround:** Embedded HTTP server on localhost for asset serving. The scheme handler code (`AppSchemeHandler.cs`) is retained for when the upstream fix lands. + +### WKScriptMessageHandler Requires Static Registrar + Runtime Protocol Adoption + +**Issue:** `WKScriptMessageHandler` conformance is not recognized by WKWebView when using the default dynamic registrar. + +**Workaround:** Two-part fix: +1. `static` in the app csproj forces static Obj-C class/protocol metadata emission +2. `ProtocolAdoption.Ensure()` calls `class_addProtocol` via P/Invoke at type initialization to register protocol conformance at runtime + +```csharp +[DllImport("/usr/lib/libobjc.dylib", EntryPoint = "class_addProtocol")] +private static extern byte class_addProtocol(IntPtr cls, IntPtr protocol); +``` + +This is applied to `ScriptMessageHandler`, `AllowAllNavigationDelegate`, and `AppSchemeHandler`. + +### iOS Linker Strips Blazor Component Constructors + +**Issue:** The iOS linker (ILLink) trims constructors from Blazor component types because they're only instantiated via reflection. This causes `A suitable constructor for type 'MainLayout' could not be located` at runtime. + +**Workaround:** Add `TrimmerRootAssembly` entries for assemblies containing Blazor components: + +```xml + + + +``` + +### ibtool LaunchScreen.storyboard Failure + +**Issue:** `ibtool` fails with "iOS 26.2 Platform Not Installed" when compiling `LaunchScreen.storyboard`, even with Xcode and the iOS SDK installed. + +**Workaround:** Replace the storyboard with a `UILaunchScreen` dictionary in Info.plist: + +```xml +UILaunchScreen + + UIColorName + systemBackgroundColor + +``` + +## Dependencies + +`Hermes.Mobile` depends on: +- `Hermes.Contracts` (plugin interfaces like `IClipboard`) +- `Microsoft.AspNetCore.Components.WebView` (WebViewManager base class, `AddBlazorWebView()`) + +It does **not** depend on: +- `Hermes` or `Hermes.Blazor` (both assume desktop window backends) +- `Microsoft.AspNetCore.Components.WebView.Maui` (avoids the MAUI dependency chain) +- Any native Obj-C/Swift code beyond the .NET iOS bindings + +## Future Work + +- **WKURLSchemeHandler revival:** Once the macios registrar regression is fixed, replace the embedded HTTP server with the scheme handler for a cleaner, no-network-listener approach +- **Shared message pump:** Extract the bounded-channel pattern from desktop `HermesWebViewManager` into a shared `Hermes.Blazor.Core` for both desktop and mobile +- **Android head:** Same lifecycle inversion pattern, targeting `WebView` via .NET Android bindings +- **NativeAOT validation:** `PublishAot=true` publish has not been tested yet diff --git a/README.md b/README.md index 133422c..e3a51fb 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,24 @@ Designed for .NET 10 and modern development workflows, Hermes prioritizes AOT co | Windows 10/11 | .NET 10 | WebView2 | Supported | | macOS 12+ | .NET 10 | WKWebView | Supported | | Linux (x64) | .NET 10 | WebKitGTK 4.x | Supported | +| iOS 15+ | .NET 10 | WKWebView | Preview | + +### iOS Preview + +`Hermes.Mobile` brings Blazor apps to iOS using the same component library and plugin interfaces as desktop. A single Razor Class Library targets both heads, with platform-specific plugin implementations (e.g., `IClipboard` backed by `UIPasteboard` on iOS, native clipboard on desktop). + +The iOS host inverts the desktop lifecycle: instead of C# owning the process, the iOS `UIApplicationDelegate` owns the app and Hermes becomes a library hosting Blazor inside a `WKWebView`. See `samples/Shared/` for a working example with desktop and iOS heads sharing the same `Shared.App` RCL. + +**Prerequisites:** .NET 10 iOS workload (`dotnet workload install ios`), Xcode with iOS Simulator runtime. + +```shell +# Run on iOS Simulator +cd samples/Hermes-mobile-ios-poc +dotnet build samples/Shared/Shared.Mobile/Shared.Mobile.csproj +dotnet build samples/Shared/Shared.Mobile/Shared.Mobile.csproj -t:Run +``` + +> **Note:** iOS support requires `static` in the app's csproj due to a .NET iOS registrar regression (dotnet/macios#23002). Asset serving uses an embedded HTTP server on localhost as a workaround for WKURLSchemeHandler not being invoked by the current .NET iOS runtime. These workarounds will be revisited as upstream fixes land. ## Getting Started diff --git a/samples/Shared/Shared.Mobile/Info.plist b/samples/Shared/Shared.Mobile/Info.plist index c25dfd6..f5947da 100644 --- a/samples/Shared/Shared.Mobile/Info.plist +++ b/samples/Shared/Shared.Mobile/Info.plist @@ -19,6 +19,11 @@ UIColorName systemBackgroundColor + NSAppTransportSecurity + + NSAllowsLocalNetworking + + MinimumOSVersion 15.0 UIDeviceFamily diff --git a/samples/Shared/Shared.Mobile/Shared.Mobile.csproj b/samples/Shared/Shared.Mobile/Shared.Mobile.csproj index f731644..e483e7b 100644 --- a/samples/Shared/Shared.Mobile/Shared.Mobile.csproj +++ b/samples/Shared/Shared.Mobile/Shared.Mobile.csproj @@ -25,6 +25,14 @@ + + + + + + + @@ -36,10 +44,13 @@ - + wwwroot/_framework/blazor.webview.js + + wwwroot/_framework/blazor.modules.json + diff --git a/src/Hermes.Mobile/HermesMobileAppBuilder.cs b/src/Hermes.Mobile/HermesMobileAppBuilder.cs index 7d93e8b..05580ee 100644 --- a/src/Hermes.Mobile/HermesMobileAppBuilder.cs +++ b/src/Hermes.Mobile/HermesMobileAppBuilder.cs @@ -4,6 +4,7 @@ using Hermes.Mobile.Plugins; using Hermes.Mobile.WebView; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.WebView; using Microsoft.Extensions.DependencyInjection; namespace Hermes.Mobile; @@ -15,13 +16,11 @@ namespace Hermes.Mobile; public sealed class HermesMobileAppBuilder { private string _hostPage = "wwwroot/index.html"; - private const string AppScheme = "hermes"; - // WKWebView rejects app://localhost/ as invalid (requestURLIsValid=0). MAUI uses 0.0.0.0. - private const string AppHost = "0.0.0.0"; private HermesMobileAppBuilder() { Services = new ServiceCollection(); + Services.AddBlazorWebView(); Services.AddSingleton(); } @@ -49,13 +48,12 @@ public HermesMobileHost Build() _hostPage); var fileProvider = new IOSAssetFileProvider(contentRoot); - var appBaseUri = new Uri($"{AppScheme}://{AppHost}/"); var components = RootComponents.GetComponents() .Select(c => (c.Type, c.Selector)) .ToList(); - return new HermesMobileHost(provider, fileProvider, appBaseUri, hostPageRelative, components); + return new HermesMobileHost(provider, fileProvider, hostPageRelative, components); } } diff --git a/src/Hermes.Mobile/HermesMobileHost.cs b/src/Hermes.Mobile/HermesMobileHost.cs index d4fae56..99a2895 100644 --- a/src/Hermes.Mobile/HermesMobileHost.cs +++ b/src/Hermes.Mobile/HermesMobileHost.cs @@ -16,6 +16,12 @@ namespace Hermes.Mobile; /// 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 : IAsyncDisposable { private readonly IServiceProvider _services; @@ -23,10 +29,10 @@ public sealed class HermesMobileHost : IAsyncDisposable 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 AppSchemeHandler _schemeHandler; private readonly ScriptMessageHandler _scriptHandler; private readonly AllowAllNavigationDelegate _navDelegate; @@ -37,13 +43,17 @@ public sealed class HermesMobileHost : IAsyncDisposable internal HermesMobileHost( IServiceProvider services, IFileProvider fileProvider, - Uri appBaseUri, 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; @@ -51,18 +61,10 @@ internal HermesMobileHost( var dispatcher = new Threading.IOSDispatcher(); var jsComponents = new JSComponentConfigurationStore(); - // All scheme handlers, user content controllers, and user scripts MUST be registered - // on the config BEFORE creating the WKWebView — WKWebView copies the config at - // construction and later mutations are ignored. - - // Scheme handler needs to delegate to IOSWebViewManager, which needs the WKWebView - // itself (circular). Use a late-bound resolver that captures _manager after assignment. + // 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; - _schemeHandler = new AppSchemeHandler(url => - pendingManager?.ResolveRequest(url) ?? (404, Array.Empty(), string.Empty)); - config.SetUrlSchemeHandler(_schemeHandler, urlScheme: appBaseUri.Scheme); - - // Script message handler — same late-bind pattern. _scriptHandler = new ScriptMessageHandler(appBaseUri, (uri, msg) => pendingManager?.MessageReceivedInternal(uri, msg)); config.UserContentController.AddScriptMessageHandler(_scriptHandler, ScriptMessageHandler.Name); @@ -81,7 +83,6 @@ internal HermesMobileHost( _webView, services, dispatcher, appBaseUri, fileProvider, jsComponents, hostPageRelativePath); pendingManager = _manager; - // Enable Safari Web Inspector attachment in Debug. iOS 16.4+ only. #if DEBUG if (OperatingSystem.IsIOSVersionAtLeast(16, 4)) { @@ -121,6 +122,7 @@ public void Start() public async ValueTask DisposeAsync() { await _manager.DisposeAsync(); + _fileServer.Dispose(); _webView.Dispose(); } } diff --git a/src/Hermes.Mobile/WebView/EmbeddedFileServer.cs b/src/Hermes.Mobile/WebView/EmbeddedFileServer.cs new file mode 100644 index 0000000..454b931 --- /dev/null +++ b/src/Hermes.Mobile/WebView/EmbeddedFileServer.cs @@ -0,0 +1,128 @@ +// Copyright (c) Mythetech. Licensed under the Elastic License 2.0. +using System.Net; +using System.Net.Sockets; +using Microsoft.Extensions.FileProviders; + +namespace Hermes.Mobile.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(); + } +} From d0ca9ae76f28762d06ce9ff2f3a1fe860f8537f8 Mon Sep 17 00:00:00 2001 From: Tom Brewer Date: Sun, 19 Apr 2026 08:28:58 -0600 Subject: [PATCH 12/14] feat: add android PoC support --- ANDROID-ARCHITECTURE.md | 134 ++++++++++++++++++ Hermes.Mobile.sln | 30 ++++ .../Shared/Shared.Android/AndroidManifest.xml | 8 ++ samples/Shared/Shared.Android/MainActivity.cs | 31 ++++ .../Shared/Shared.Android/MainApplication.cs | 14 ++ .../Resources/values/styles.xml | 7 + .../Resources/wwwroot/index.html | 14 ++ .../Shared.Android/Shared.Android.csproj | 55 +++++++ .../Hermes.Mobile.Android.csproj | 38 +++++ .../HermesMobileAndroidBuilder.cs | 72 ++++++++++ .../HermesMobileAndroidHost.cs | 88 ++++++++++++ .../Plugins/AndroidClipboard.cs | 33 +++++ .../Threading/AndroidDispatcher.cs | 74 ++++++++++ .../WebView/AndroidAssetFileProvider.cs | 66 +++++++++ .../WebView/AndroidWebViewManager.cs | 103 ++++++++++++++ .../WebView/BlazorInitScript.cs | 30 ++++ .../WebView/HermesWebChromeClient.cs | 23 +++ .../WebView/HermesWebViewClient.cs | 68 +++++++++ src/Hermes.Mobile.Android/WebView/JsBridge.cs | 28 ++++ 19 files changed, 916 insertions(+) create mode 100644 ANDROID-ARCHITECTURE.md create mode 100644 samples/Shared/Shared.Android/AndroidManifest.xml create mode 100644 samples/Shared/Shared.Android/MainActivity.cs create mode 100644 samples/Shared/Shared.Android/MainApplication.cs create mode 100644 samples/Shared/Shared.Android/Resources/values/styles.xml create mode 100644 samples/Shared/Shared.Android/Resources/wwwroot/index.html create mode 100644 samples/Shared/Shared.Android/Shared.Android.csproj create mode 100644 src/Hermes.Mobile.Android/Hermes.Mobile.Android.csproj create mode 100644 src/Hermes.Mobile.Android/HermesMobileAndroidBuilder.cs create mode 100644 src/Hermes.Mobile.Android/HermesMobileAndroidHost.cs create mode 100644 src/Hermes.Mobile.Android/Plugins/AndroidClipboard.cs create mode 100644 src/Hermes.Mobile.Android/Threading/AndroidDispatcher.cs create mode 100644 src/Hermes.Mobile.Android/WebView/AndroidAssetFileProvider.cs create mode 100644 src/Hermes.Mobile.Android/WebView/AndroidWebViewManager.cs create mode 100644 src/Hermes.Mobile.Android/WebView/BlazorInitScript.cs create mode 100644 src/Hermes.Mobile.Android/WebView/HermesWebChromeClient.cs create mode 100644 src/Hermes.Mobile.Android/WebView/HermesWebViewClient.cs create mode 100644 src/Hermes.Mobile.Android/WebView/JsBridge.cs diff --git a/ANDROID-ARCHITECTURE.md b/ANDROID-ARCHITECTURE.md new file mode 100644 index 0000000..c793888 --- /dev/null +++ b/ANDROID-ARCHITECTURE.md @@ -0,0 +1,134 @@ +# Hermes.Mobile.Android Architecture + +Android host for Hermes Blazor apps. Hosts Blazor inside an Android `WebView` using `ShouldInterceptRequest` + `TryGetResponseContent` for asset serving and `addJavascriptInterface` for the JS-C# bridge. + +## Overview + +Like the iOS PoC, the Android PoC inverts the desktop lifecycle: the native Android `Activity` owns the process, and Hermes is a hosted library. + +``` +Desktop: C# Main() -> HermesWindow -> IHermesWindowBackend -> native window +Android: Activity.OnCreate() -> HermesMobileAndroidBuilder -> HermesMobileAndroidHost -> WebView +iOS: AppDelegate.FinishedLaunching() -> HermesMobileAppBuilder -> HermesMobileHost -> WKWebView +``` + +## Asset Serving + +| | iOS | Android | +|---|---|---| +| **Mechanism** | Embedded `HttpListener` on localhost | `ShouldInterceptRequest` + `TryGetResponseContent` | +| **Why** | `WKURLSchemeHandler` broken (.NET iOS registrar regression) | `WebViewAssetLoader` path mismatches with Blazor's `Navigate("/")` | +| **Host page loading** | `HttpListener` serves HTML | `LoadDataWithBaseURL` injects HTML directly | +| **Sub-resource loading** | `HttpListener` serves files | `ShouldInterceptRequest` resolves via `IFileProvider` | +| **Asset origin** | `http://localhost:{port}/` | `https://0.0.0.0/` (synthetic) | +| **Base href** | `/` | `/` | + +The initial host page is loaded via `LoadDataWithBaseURL` to avoid real network requests to the synthetic origin. All sub-resource requests (JS, CSS, static assets) are intercepted by `HermesWebViewClient.ShouldInterceptRequest`, which delegates to `AndroidWebViewManager.ResolveRequest` using the base `WebViewManager.TryGetResponseContent`. This mirrors MAUI's approach and the iOS PoC's `ResolveRequest` pattern. + +`ShouldOverrideUrlLoading` intercepts internal URL navigations to prevent full-page reloads, keeping Blazor's client-side router in control. + +## JS-C# Bridge + +| | iOS | Android | +|---|---|---| +| **JS to C#** | `WKScriptMessageHandler` + `ProtocolAdoption` workaround | `addJavascriptInterface` + `[JavascriptInterface]` | +| **C# to JS** | `WKWebView.EvaluateJavaScript()` | `WebView.EvaluateJavascript()` | +| **Init script injection** | `WKUserScript` at document-end | `EvaluateJavascript()` on `OnPageFinished` | +| **Workarounds needed** | Static registrar + `class_addProtocol` P/Invoke | None | + +The `JsBridge` class is registered via `webView.AddJavascriptInterface(bridge, "HermesBridge")`. JavaScript calls `HermesBridge.postMessage(message)` to send messages to C#. The `[JavascriptInterface]` attribute marks the method as callable from JS. `PostMessage` is called on a WebView background thread, so it dispatches to the main thread via `Handler(Looper.MainLooper)` before forwarding to the Blazor pipeline. Similarly, `SendMessage` (C# to JS) dispatches `EvaluateJavascript` to the main thread when called off-thread. + +The init script is guarded against double execution (`OnPageFinished` can fire multiple times) to prevent resetting Blazor's registered message callbacks. + +## Component Mapping + +| iOS | Android | Purpose | +|-----|---------|---------| +| `HermesMobileHost` | `HermesMobileAndroidHost` | Orchestrator | +| `HermesMobileAppBuilder` | `HermesMobileAndroidBuilder` | Builder pattern | +| `IOSWebViewManager` | `AndroidWebViewManager` | `WebViewManager` subclass | +| `EmbeddedFileServer` | `ShouldInterceptRequest` (in WebViewClient) | Asset serving | +| `IOSAssetFileProvider` | `AndroidAssetFileProvider` | `IFileProvider` implementation | +| `ScriptMessageHandler` | `JsBridge` | JS-to-C# bridge | +| `BlazorInitScript` | `BlazorInitScript` | Init script (different JS) | +| `AllowAllNavigationDelegate` | `HermesWebViewClient` | Asset interception, navigation control | +| (none) | `HermesWebChromeClient` | Console log forwarding | +| `IOSDispatcher` | `AndroidDispatcher` | Main thread marshalling | +| `IOSClipboard` | `AndroidClipboard` | `IClipboard` plugin | +| `ProtocolAdoption` | (not needed) | Obj-C runtime workaround | +| `AppSchemeHandler` | (not needed) | Custom scheme (future iOS) | +| `MimeTypeLookup` | (not needed) | Content-Type resolved by `TryGetResponseContent` headers | + +## Project Structure + +``` +src/Hermes.Mobile.Android/ + Hermes.Mobile.Android.csproj net10.0-android, min API 24 + HermesMobileAndroidBuilder.cs Builder + RootComponentCollection + HermesMobileAndroidHost.cs Orchestrator, creates WebView + wires manager + WebView/ + AndroidWebViewManager.cs Extends WebViewManager + BlazorInitScript.cs JS bridge setup + Blazor.start() + JsBridge.cs [JavascriptInterface] for JS-to-C# messages + HermesWebViewClient.cs Asset interception + init script on page load + HermesWebChromeClient.cs Console message forwarding + AndroidAssetFileProvider.cs IFileProvider over AssetManager + Threading/ + AndroidDispatcher.cs Handler(Looper.MainLooper) dispatcher + Plugins/ + AndroidClipboard.cs IClipboard via ClipboardManager + +samples/Shared/Shared.Android/ + Shared.Android.csproj App project, references lib + Shared.App + MainActivity.cs Entry point, creates host + MainApplication.cs [Application] class + AndroidManifest.xml App config + Resources/ + values/styles.xml Material theme + wwwroot/index.html Host page +``` + +## Build Requirements + +- .NET 10 SDK with Android workload: `dotnet workload install android` +- Android SDK (installed via Android Studio or `dotnet android sdk install`) +- JDK (Android Studio bundles JBR) +- Environment variables: + ``` + export ANDROID_HOME=$HOME/Library/Android/sdk + export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" + ``` + +## Build Commands + +```bash +# Build library +dotnet build src/Hermes.Mobile.Android/Hermes.Mobile.Android.csproj + +# Build sample app +dotnet build samples/Shared/Shared.Android/Shared.Android.csproj + +# Deploy to emulator/device +dotnet build samples/Shared/Shared.Android/Shared.Android.csproj -t:Run + +# Debug with Chrome DevTools +# Open chrome://inspect in Chrome, the WebView appears as inspectable target +``` + +## Known Limitations + +- No back button navigation handling (see Next Steps) +- No deep linking or intent handling +- No runtime permission handling for clipboard on Android 13+ +- `AndroidAssetFileProvider` reports `Length = -1` since `AssetManager` doesn't expose file sizes +- Trimmer warnings from Blazor source generator (IL2111, IL2110) are benign, same as iOS + +## Next Steps + +1. **Back button navigation** - Override `OnBackPressed()` in Activity, call `webView.GoBack()` if `webView.CanGoBack()`. Critical Android UX expectation. +2. **Deep linking / intent handling** - Register URL schemes via intent filters in `AndroidManifest.xml` +3. **Status bar theming** - Match status bar color to Blazor app theme dynamically +4. **Soft keyboard handling** - Configure `windowSoftInputMode` for proper input field behavior +5. **Runtime permissions** - Request `android.permission.READ_CLIPBOARD` on Android 13+ (API 33+) +6. **Rename Hermes.Mobile to Hermes.Mobile.iOS** - Consolidate naming after both PoCs are proven +7. **Extract shared Hermes.Mobile** - Common abstractions (builder pattern, `RootComponentCollection`) once patterns stabilize diff --git a/Hermes.Mobile.sln b/Hermes.Mobile.sln index e00bccd..179e4af 100644 --- a/Hermes.Mobile.sln +++ b/Hermes.Mobile.sln @@ -21,6 +21,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared.App", "samples\Share EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared.Desktop", "samples\Shared\Shared.Desktop\Shared.Desktop.csproj", "{0CF73C00-BAD5-492A-B6CE-94C484243F7F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hermes.Mobile.Android", "src\Hermes.Mobile.Android\Hermes.Mobile.Android.csproj", "{FC73AC17-2A85-4633-84B1-E8935203778A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared.Android", "samples\Shared\Shared.Android\Shared.Android.csproj", "{68C2D957-DD59-4FD2-AA60-AD82BF792A77}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -103,6 +107,30 @@ Global {0CF73C00-BAD5-492A-B6CE-94C484243F7F}.Release|x64.Build.0 = Release|Any CPU {0CF73C00-BAD5-492A-B6CE-94C484243F7F}.Release|x86.ActiveCfg = Release|Any CPU {0CF73C00-BAD5-492A-B6CE-94C484243F7F}.Release|x86.Build.0 = Release|Any CPU + {FC73AC17-2A85-4633-84B1-E8935203778A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FC73AC17-2A85-4633-84B1-E8935203778A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FC73AC17-2A85-4633-84B1-E8935203778A}.Debug|x64.ActiveCfg = Debug|Any CPU + {FC73AC17-2A85-4633-84B1-E8935203778A}.Debug|x64.Build.0 = Debug|Any CPU + {FC73AC17-2A85-4633-84B1-E8935203778A}.Debug|x86.ActiveCfg = Debug|Any CPU + {FC73AC17-2A85-4633-84B1-E8935203778A}.Debug|x86.Build.0 = Debug|Any CPU + {FC73AC17-2A85-4633-84B1-E8935203778A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FC73AC17-2A85-4633-84B1-E8935203778A}.Release|Any CPU.Build.0 = Release|Any CPU + {FC73AC17-2A85-4633-84B1-E8935203778A}.Release|x64.ActiveCfg = Release|Any CPU + {FC73AC17-2A85-4633-84B1-E8935203778A}.Release|x64.Build.0 = Release|Any CPU + {FC73AC17-2A85-4633-84B1-E8935203778A}.Release|x86.ActiveCfg = Release|Any CPU + {FC73AC17-2A85-4633-84B1-E8935203778A}.Release|x86.Build.0 = Release|Any CPU + {68C2D957-DD59-4FD2-AA60-AD82BF792A77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {68C2D957-DD59-4FD2-AA60-AD82BF792A77}.Debug|Any CPU.Build.0 = Debug|Any CPU + {68C2D957-DD59-4FD2-AA60-AD82BF792A77}.Debug|x64.ActiveCfg = Debug|Any CPU + {68C2D957-DD59-4FD2-AA60-AD82BF792A77}.Debug|x64.Build.0 = Debug|Any CPU + {68C2D957-DD59-4FD2-AA60-AD82BF792A77}.Debug|x86.ActiveCfg = Debug|Any CPU + {68C2D957-DD59-4FD2-AA60-AD82BF792A77}.Debug|x86.Build.0 = Debug|Any CPU + {68C2D957-DD59-4FD2-AA60-AD82BF792A77}.Release|Any CPU.ActiveCfg = Release|Any CPU + {68C2D957-DD59-4FD2-AA60-AD82BF792A77}.Release|Any CPU.Build.0 = Release|Any CPU + {68C2D957-DD59-4FD2-AA60-AD82BF792A77}.Release|x64.ActiveCfg = Release|Any CPU + {68C2D957-DD59-4FD2-AA60-AD82BF792A77}.Release|x64.Build.0 = Release|Any CPU + {68C2D957-DD59-4FD2-AA60-AD82BF792A77}.Release|x86.ActiveCfg = Release|Any CPU + {68C2D957-DD59-4FD2-AA60-AD82BF792A77}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -115,5 +143,7 @@ Global {3177BFE4-54BB-4449-CBD6-550FA3C2F073} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} {FCB1797B-0AFF-41A0-8C02-02B06E9BE125} = {3177BFE4-54BB-4449-CBD6-550FA3C2F073} {0CF73C00-BAD5-492A-B6CE-94C484243F7F} = {3177BFE4-54BB-4449-CBD6-550FA3C2F073} + {FC73AC17-2A85-4633-84B1-E8935203778A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {68C2D957-DD59-4FD2-AA60-AD82BF792A77} = {3177BFE4-54BB-4449-CBD6-550FA3C2F073} EndGlobalSection EndGlobal diff --git a/samples/Shared/Shared.Android/AndroidManifest.xml b/samples/Shared/Shared.Android/AndroidManifest.xml new file mode 100644 index 0000000..3d151ca --- /dev/null +++ b/samples/Shared/Shared.Android/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + diff --git a/samples/Shared/Shared.Android/MainActivity.cs b/samples/Shared/Shared.Android/MainActivity.cs new file mode 100644 index 0000000..da2c6c3 --- /dev/null +++ b/samples/Shared/Shared.Android/MainActivity.cs @@ -0,0 +1,31 @@ +// Copyright (c) Mythetech. Licensed under the Elastic License 2.0. +using Android.App; +using Android.OS; +using Hermes.Mobile.Android; + +namespace Shared.Android; + +[Activity(Label = "Shared Android", MainLauncher = true)] +public class MainActivity : Activity +{ + private HermesMobileAndroidHost? _host; + + protected override void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + + var builder = HermesMobileAndroidBuilder.CreateDefault(this); + builder.RootComponents.Add("#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/Shared/Shared.Android/MainApplication.cs b/samples/Shared/Shared.Android/MainApplication.cs new file mode 100644 index 0000000..2dbe08b --- /dev/null +++ b/samples/Shared/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/Shared/Shared.Android/Resources/values/styles.xml b/samples/Shared/Shared.Android/Resources/values/styles.xml new file mode 100644 index 0000000..b8eb3aa --- /dev/null +++ b/samples/Shared/Shared.Android/Resources/values/styles.xml @@ -0,0 +1,7 @@ + + + + diff --git a/samples/Shared/Shared.Android/Resources/wwwroot/index.html b/samples/Shared/Shared.Android/Resources/wwwroot/index.html new file mode 100644 index 0000000..98c95af --- /dev/null +++ b/samples/Shared/Shared.Android/Resources/wwwroot/index.html @@ -0,0 +1,14 @@ + + + + + + Shared Blazor — Android + + + + +
+ + + diff --git a/samples/Shared/Shared.Android/Shared.Android.csproj b/samples/Shared/Shared.Android/Shared.Android.csproj new file mode 100644 index 0000000..877b036 --- /dev/null +++ b/samples/Shared/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/src/Hermes.Mobile.Android/Hermes.Mobile.Android.csproj b/src/Hermes.Mobile.Android/Hermes.Mobile.Android.csproj new file mode 100644 index 0000000..6483059 --- /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..011ba0b --- /dev/null +++ b/src/Hermes.Mobile.Android/HermesMobileAndroidBuilder.cs @@ -0,0 +1,72 @@ +// 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 +{ + 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(); + + 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); + } +} + +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.Android/HermesMobileAndroidHost.cs b/src/Hermes.Mobile.Android/HermesMobileAndroidHost.cs new file mode 100644 index 0000000..03e629b --- /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 : IAsyncDisposable +{ + 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..7a68dec --- /dev/null +++ b/src/Hermes.Mobile.Android/WebView/AndroidWebViewManager.cs @@ -0,0 +1,103 @@ +// 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; + +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 (int StatusCode, byte[] Body, string ContentType) ResolveRequest(string absoluteUrl) + { + var allowFallbackOnHostPage = _appBaseUri.IsBaseOf(new Uri(absoluteUrl)); + + if (TryGetResponseContent( + absoluteUrl, + allowFallbackOnHostPage, + out var statusCode, + out _, + out var content, + out var headers)) + { + using var ms = new MemoryStream(); + content.CopyTo(ms); + content.Dispose(); + + var contentType = headers.TryGetValue("Content-Type", out var ct) + ? ct + : "application/octet-stream"; + + return (statusCode, ms.ToArray(), contentType); + } + + return (404, Array.Empty(), string.Empty); + } +} 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..102673e --- /dev/null +++ b/src/Hermes.Mobile.Android/WebView/HermesWebViewClient.cs @@ -0,0 +1,68 @@ +// Copyright (c) Mythetech. Licensed under the Elastic License 2.0. +using Android.Webkit; + +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 (statusCode, body, contentType) = _manager.ResolveRequest(url); + if (statusCode == 200 && body.Length > 0) + { + return new WebResourceResponse( + contentType, + "UTF-8", + statusCode, + "OK", + new Dictionary { ["Cache-Control"] = "no-cache" }, + new MemoryStream(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!)); + } +} From 953b2c28171c605442750006c7be815323a1ded1 Mon Sep 17 00:00:00 2001 From: Tom Brewer Date: Sun, 19 Apr 2026 11:22:54 -0600 Subject: [PATCH 13/14] refactor iOS project to specific OS project like android --- Hermes.Mobile.sln | 25 ++++++++--- .../Architecture/ANDROID-ARCHITECTURE.md | 0 .../Architecture/ARCHITECTURE.md | 0 .../Architecture/IOS-ARCHITECTURE.md | 0 .../Shared.Android/AndroidManifest.xml | 0 .../Shared.Android/MainActivity.cs | 0 .../Shared.Android/MainApplication.cs | 0 .../Resources/values/styles.xml | 0 .../Resources/wwwroot/index.html | 0 .../Shared.Android/Shared.Android.csproj | 0 .../{Shared => Mobile}/Shared.App/App.razor | 0 .../Shared.App/Layout/MainLayout.razor | 0 .../Shared.App/Layout/NavMenu.razor | 0 .../Shared.App/Pages/ClipboardDemo.razor | 0 .../Shared.App/Pages/Counter.razor | 0 .../Shared.App/Pages/Index.razor | 0 .../Shared.App/Shared.App.csproj | 0 .../Shared.App/_Imports.razor | 0 .../Shared.App/wwwroot/css/app.css | 0 .../Shared.Desktop/Program.cs | 0 .../Shared.Desktop/Shared.Desktop.csproj | 0 .../Shared.Desktop/wwwroot/index.html | 0 .../Shared.Mobile/AppDelegate.cs | 2 +- .../Shared.Mobile/Info.plist | 0 .../Shared.Mobile/Program.cs | 0 .../Resources/wwwroot/index.html | 0 .../Shared.Mobile/Shared.Mobile.csproj | 4 +- .../Hermes.Mobile.Android.csproj | 2 +- .../HermesMobileAndroidBuilder.cs | 23 ++++------ .../HermesMobileAndroidHost.cs | 2 +- .../WebView/AndroidWebViewManager.cs | 16 +++---- .../WebView/HermesWebViewClient.cs | 11 ++--- src/Hermes.Mobile.Shared/Hermes.Mobile.csproj | 42 +++++++++++++++++++ src/Hermes.Mobile.Shared/IMobileBuilder.cs | 16 +++++++ src/Hermes.Mobile.Shared/IMobileHost.cs | 11 +++++ .../RootComponentCollection.cs | 20 +++++++++ .../WebView/MimeTypeLookup.cs | 2 +- .../WebView/WebViewResolveHelper.cs | 24 +++++++++++ .../WebView/WebViewResponse.cs | 16 +++++++ .../Hermes.Mobile.iOS.csproj} | 4 +- .../HermesMobileAppBuilder.cs | 29 +++++-------- .../HermesMobileHost.cs | 6 +-- .../Plugins/IOSClipboard.cs | 2 +- .../Threading/IOSDispatcher.cs | 2 +- .../WebView/AllowAllNavigationDelegate.cs | 2 +- .../WebView/AppSchemeHandler.cs | 25 +++++------ .../WebView/BlazorInitScript.cs | 2 +- .../WebView/EmbeddedFileServer.cs | 3 +- .../WebView/IOSAssetFileProvider.cs | 2 +- .../WebView/IOSWebViewManager.cs | 17 +++----- .../WebView/ProtocolAdoption.cs | 2 +- .../WebView/ScriptMessageHandler.cs | 2 +- 52 files changed, 215 insertions(+), 99 deletions(-) rename ANDROID-ARCHITECTURE.md => docs/Architecture/ANDROID-ARCHITECTURE.md (100%) rename ARCHITECTURE.md => docs/Architecture/ARCHITECTURE.md (100%) rename IOS-ARCHITECTURE.md => docs/Architecture/IOS-ARCHITECTURE.md (100%) rename samples/{Shared => Mobile}/Shared.Android/AndroidManifest.xml (100%) rename samples/{Shared => Mobile}/Shared.Android/MainActivity.cs (100%) rename samples/{Shared => Mobile}/Shared.Android/MainApplication.cs (100%) rename samples/{Shared => Mobile}/Shared.Android/Resources/values/styles.xml (100%) rename samples/{Shared => Mobile}/Shared.Android/Resources/wwwroot/index.html (100%) rename samples/{Shared => Mobile}/Shared.Android/Shared.Android.csproj (100%) rename samples/{Shared => Mobile}/Shared.App/App.razor (100%) rename samples/{Shared => Mobile}/Shared.App/Layout/MainLayout.razor (100%) rename samples/{Shared => Mobile}/Shared.App/Layout/NavMenu.razor (100%) rename samples/{Shared => Mobile}/Shared.App/Pages/ClipboardDemo.razor (100%) rename samples/{Shared => Mobile}/Shared.App/Pages/Counter.razor (100%) rename samples/{Shared => Mobile}/Shared.App/Pages/Index.razor (100%) rename samples/{Shared => Mobile}/Shared.App/Shared.App.csproj (100%) rename samples/{Shared => Mobile}/Shared.App/_Imports.razor (100%) rename samples/{Shared => Mobile}/Shared.App/wwwroot/css/app.css (100%) rename samples/{Shared => Mobile}/Shared.Desktop/Program.cs (100%) rename samples/{Shared => Mobile}/Shared.Desktop/Shared.Desktop.csproj (100%) rename samples/{Shared => Mobile}/Shared.Desktop/wwwroot/index.html (100%) rename samples/{Shared => Mobile}/Shared.Mobile/AppDelegate.cs (97%) rename samples/{Shared => Mobile}/Shared.Mobile/Info.plist (100%) rename samples/{Shared => Mobile}/Shared.Mobile/Program.cs (100%) rename samples/{Shared => Mobile}/Shared.Mobile/Resources/wwwroot/index.html (100%) rename samples/{Shared => Mobile}/Shared.Mobile/Shared.Mobile.csproj (95%) create mode 100644 src/Hermes.Mobile.Shared/Hermes.Mobile.csproj create mode 100644 src/Hermes.Mobile.Shared/IMobileBuilder.cs create mode 100644 src/Hermes.Mobile.Shared/IMobileHost.cs create mode 100644 src/Hermes.Mobile.Shared/RootComponentCollection.cs rename src/{Hermes.Mobile => Hermes.Mobile.Shared}/WebView/MimeTypeLookup.cs (97%) create mode 100644 src/Hermes.Mobile.Shared/WebView/WebViewResolveHelper.cs create mode 100644 src/Hermes.Mobile.Shared/WebView/WebViewResponse.cs rename src/{Hermes.Mobile/Hermes.Mobile.csproj => Hermes.Mobile.iOS/Hermes.Mobile.iOS.csproj} (91%) rename src/{Hermes.Mobile => Hermes.Mobile.iOS}/HermesMobileAppBuilder.cs (74%) rename src/{Hermes.Mobile => Hermes.Mobile.iOS}/HermesMobileHost.cs (97%) rename src/{Hermes.Mobile => Hermes.Mobile.iOS}/Plugins/IOSClipboard.cs (94%) rename src/{Hermes.Mobile => Hermes.Mobile.iOS}/Threading/IOSDispatcher.cs (97%) rename src/{Hermes.Mobile => Hermes.Mobile.iOS}/WebView/AllowAllNavigationDelegate.cs (97%) rename src/{Hermes.Mobile => Hermes.Mobile.iOS}/WebView/AppSchemeHandler.cs (62%) rename src/{Hermes.Mobile => Hermes.Mobile.iOS}/WebView/BlazorInitScript.cs (96%) rename src/{Hermes.Mobile => Hermes.Mobile.iOS}/WebView/EmbeddedFileServer.cs (98%) rename src/{Hermes.Mobile => Hermes.Mobile.iOS}/WebView/IOSAssetFileProvider.cs (97%) rename src/{Hermes.Mobile => Hermes.Mobile.iOS}/WebView/IOSWebViewManager.cs (82%) rename src/{Hermes.Mobile => Hermes.Mobile.iOS}/WebView/ProtocolAdoption.cs (98%) rename src/{Hermes.Mobile => Hermes.Mobile.iOS}/WebView/ScriptMessageHandler.cs (96%) diff --git a/Hermes.Mobile.sln b/Hermes.Mobile.sln index 179e4af..e962a01 100644 --- a/Hermes.Mobile.sln +++ b/Hermes.Mobile.sln @@ -11,19 +11,21 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hermes", "src\Hermes\Hermes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hermes.Blazor", "src\Hermes.Blazor\Hermes.Blazor.csproj", "{C37C83EB-4BD7-4FBA-855C-AF016D498C57}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hermes.Mobile", "src\Hermes.Mobile\Hermes.Mobile.csproj", "{16737429-333A-428D-91C3-04067413D953}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hermes.Mobile.iOS", "src\Hermes.Mobile.iOS\Hermes.Mobile.iOS.csproj", "{16737429-333A-428D-91C3-04067413D953}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{3177BFE4-54BB-4449-CBD6-550FA3C2F073}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Mobile", "Mobile", "{3177BFE4-54BB-4449-CBD6-550FA3C2F073}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared.App", "samples\Shared\Shared.App\Shared.App.csproj", "{FCB1797B-0AFF-41A0-8C02-02B06E9BE125}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared.App", "samples\Mobile\Shared.App\Shared.App.csproj", "{FCB1797B-0AFF-41A0-8C02-02B06E9BE125}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared.Desktop", "samples\Shared\Shared.Desktop\Shared.Desktop.csproj", "{0CF73C00-BAD5-492A-B6CE-94C484243F7F}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared.Desktop", "samples\Mobile\Shared.Desktop\Shared.Desktop.csproj", "{0CF73C00-BAD5-492A-B6CE-94C484243F7F}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hermes.Mobile.Android", "src\Hermes.Mobile.Android\Hermes.Mobile.Android.csproj", "{FC73AC17-2A85-4633-84B1-E8935203778A}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared.Android", "samples\Shared\Shared.Android\Shared.Android.csproj", "{68C2D957-DD59-4FD2-AA60-AD82BF792A77}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared.Android", "samples\Mobile\Shared.Android\Shared.Android.csproj", "{68C2D957-DD59-4FD2-AA60-AD82BF792A77}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hermes.Mobile", "src\Hermes.Mobile.Shared\Hermes.Mobile.csproj", "{E93D2228-2DCF-4A55-9E06-55F1B870DBB2}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -131,6 +133,18 @@ Global {68C2D957-DD59-4FD2-AA60-AD82BF792A77}.Release|x64.Build.0 = Release|Any CPU {68C2D957-DD59-4FD2-AA60-AD82BF792A77}.Release|x86.ActiveCfg = Release|Any CPU {68C2D957-DD59-4FD2-AA60-AD82BF792A77}.Release|x86.Build.0 = Release|Any CPU + {E93D2228-2DCF-4A55-9E06-55F1B870DBB2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E93D2228-2DCF-4A55-9E06-55F1B870DBB2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E93D2228-2DCF-4A55-9E06-55F1B870DBB2}.Debug|x64.ActiveCfg = Debug|Any CPU + {E93D2228-2DCF-4A55-9E06-55F1B870DBB2}.Debug|x64.Build.0 = Debug|Any CPU + {E93D2228-2DCF-4A55-9E06-55F1B870DBB2}.Debug|x86.ActiveCfg = Debug|Any CPU + {E93D2228-2DCF-4A55-9E06-55F1B870DBB2}.Debug|x86.Build.0 = Debug|Any CPU + {E93D2228-2DCF-4A55-9E06-55F1B870DBB2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E93D2228-2DCF-4A55-9E06-55F1B870DBB2}.Release|Any CPU.Build.0 = Release|Any CPU + {E93D2228-2DCF-4A55-9E06-55F1B870DBB2}.Release|x64.ActiveCfg = Release|Any CPU + {E93D2228-2DCF-4A55-9E06-55F1B870DBB2}.Release|x64.Build.0 = Release|Any CPU + {E93D2228-2DCF-4A55-9E06-55F1B870DBB2}.Release|x86.ActiveCfg = Release|Any CPU + {E93D2228-2DCF-4A55-9E06-55F1B870DBB2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -145,5 +159,6 @@ Global {0CF73C00-BAD5-492A-B6CE-94C484243F7F} = {3177BFE4-54BB-4449-CBD6-550FA3C2F073} {FC73AC17-2A85-4633-84B1-E8935203778A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {68C2D957-DD59-4FD2-AA60-AD82BF792A77} = {3177BFE4-54BB-4449-CBD6-550FA3C2F073} + {E93D2228-2DCF-4A55-9E06-55F1B870DBB2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/ANDROID-ARCHITECTURE.md b/docs/Architecture/ANDROID-ARCHITECTURE.md similarity index 100% rename from ANDROID-ARCHITECTURE.md rename to docs/Architecture/ANDROID-ARCHITECTURE.md diff --git a/ARCHITECTURE.md b/docs/Architecture/ARCHITECTURE.md similarity index 100% rename from ARCHITECTURE.md rename to docs/Architecture/ARCHITECTURE.md diff --git a/IOS-ARCHITECTURE.md b/docs/Architecture/IOS-ARCHITECTURE.md similarity index 100% rename from IOS-ARCHITECTURE.md rename to docs/Architecture/IOS-ARCHITECTURE.md diff --git a/samples/Shared/Shared.Android/AndroidManifest.xml b/samples/Mobile/Shared.Android/AndroidManifest.xml similarity index 100% rename from samples/Shared/Shared.Android/AndroidManifest.xml rename to samples/Mobile/Shared.Android/AndroidManifest.xml diff --git a/samples/Shared/Shared.Android/MainActivity.cs b/samples/Mobile/Shared.Android/MainActivity.cs similarity index 100% rename from samples/Shared/Shared.Android/MainActivity.cs rename to samples/Mobile/Shared.Android/MainActivity.cs diff --git a/samples/Shared/Shared.Android/MainApplication.cs b/samples/Mobile/Shared.Android/MainApplication.cs similarity index 100% rename from samples/Shared/Shared.Android/MainApplication.cs rename to samples/Mobile/Shared.Android/MainApplication.cs diff --git a/samples/Shared/Shared.Android/Resources/values/styles.xml b/samples/Mobile/Shared.Android/Resources/values/styles.xml similarity index 100% rename from samples/Shared/Shared.Android/Resources/values/styles.xml rename to samples/Mobile/Shared.Android/Resources/values/styles.xml diff --git a/samples/Shared/Shared.Android/Resources/wwwroot/index.html b/samples/Mobile/Shared.Android/Resources/wwwroot/index.html similarity index 100% rename from samples/Shared/Shared.Android/Resources/wwwroot/index.html rename to samples/Mobile/Shared.Android/Resources/wwwroot/index.html diff --git a/samples/Shared/Shared.Android/Shared.Android.csproj b/samples/Mobile/Shared.Android/Shared.Android.csproj similarity index 100% rename from samples/Shared/Shared.Android/Shared.Android.csproj rename to samples/Mobile/Shared.Android/Shared.Android.csproj diff --git a/samples/Shared/Shared.App/App.razor b/samples/Mobile/Shared.App/App.razor similarity index 100% rename from samples/Shared/Shared.App/App.razor rename to samples/Mobile/Shared.App/App.razor diff --git a/samples/Shared/Shared.App/Layout/MainLayout.razor b/samples/Mobile/Shared.App/Layout/MainLayout.razor similarity index 100% rename from samples/Shared/Shared.App/Layout/MainLayout.razor rename to samples/Mobile/Shared.App/Layout/MainLayout.razor diff --git a/samples/Shared/Shared.App/Layout/NavMenu.razor b/samples/Mobile/Shared.App/Layout/NavMenu.razor similarity index 100% rename from samples/Shared/Shared.App/Layout/NavMenu.razor rename to samples/Mobile/Shared.App/Layout/NavMenu.razor diff --git a/samples/Shared/Shared.App/Pages/ClipboardDemo.razor b/samples/Mobile/Shared.App/Pages/ClipboardDemo.razor similarity index 100% rename from samples/Shared/Shared.App/Pages/ClipboardDemo.razor rename to samples/Mobile/Shared.App/Pages/ClipboardDemo.razor diff --git a/samples/Shared/Shared.App/Pages/Counter.razor b/samples/Mobile/Shared.App/Pages/Counter.razor similarity index 100% rename from samples/Shared/Shared.App/Pages/Counter.razor rename to samples/Mobile/Shared.App/Pages/Counter.razor diff --git a/samples/Shared/Shared.App/Pages/Index.razor b/samples/Mobile/Shared.App/Pages/Index.razor similarity index 100% rename from samples/Shared/Shared.App/Pages/Index.razor rename to samples/Mobile/Shared.App/Pages/Index.razor diff --git a/samples/Shared/Shared.App/Shared.App.csproj b/samples/Mobile/Shared.App/Shared.App.csproj similarity index 100% rename from samples/Shared/Shared.App/Shared.App.csproj rename to samples/Mobile/Shared.App/Shared.App.csproj diff --git a/samples/Shared/Shared.App/_Imports.razor b/samples/Mobile/Shared.App/_Imports.razor similarity index 100% rename from samples/Shared/Shared.App/_Imports.razor rename to samples/Mobile/Shared.App/_Imports.razor diff --git a/samples/Shared/Shared.App/wwwroot/css/app.css b/samples/Mobile/Shared.App/wwwroot/css/app.css similarity index 100% rename from samples/Shared/Shared.App/wwwroot/css/app.css rename to samples/Mobile/Shared.App/wwwroot/css/app.css diff --git a/samples/Shared/Shared.Desktop/Program.cs b/samples/Mobile/Shared.Desktop/Program.cs similarity index 100% rename from samples/Shared/Shared.Desktop/Program.cs rename to samples/Mobile/Shared.Desktop/Program.cs diff --git a/samples/Shared/Shared.Desktop/Shared.Desktop.csproj b/samples/Mobile/Shared.Desktop/Shared.Desktop.csproj similarity index 100% rename from samples/Shared/Shared.Desktop/Shared.Desktop.csproj rename to samples/Mobile/Shared.Desktop/Shared.Desktop.csproj diff --git a/samples/Shared/Shared.Desktop/wwwroot/index.html b/samples/Mobile/Shared.Desktop/wwwroot/index.html similarity index 100% rename from samples/Shared/Shared.Desktop/wwwroot/index.html rename to samples/Mobile/Shared.Desktop/wwwroot/index.html diff --git a/samples/Shared/Shared.Mobile/AppDelegate.cs b/samples/Mobile/Shared.Mobile/AppDelegate.cs similarity index 97% rename from samples/Shared/Shared.Mobile/AppDelegate.cs rename to samples/Mobile/Shared.Mobile/AppDelegate.cs index 0e53d5b..808bc1a 100644 --- a/samples/Shared/Shared.Mobile/AppDelegate.cs +++ b/samples/Mobile/Shared.Mobile/AppDelegate.cs @@ -1,6 +1,6 @@ // Copyright (c) Mythetech. Licensed under the Elastic License 2.0. using Foundation; -using Hermes.Mobile; +using Hermes.Mobile.iOS; using UIKit; namespace Shared.Mobile; diff --git a/samples/Shared/Shared.Mobile/Info.plist b/samples/Mobile/Shared.Mobile/Info.plist similarity index 100% rename from samples/Shared/Shared.Mobile/Info.plist rename to samples/Mobile/Shared.Mobile/Info.plist diff --git a/samples/Shared/Shared.Mobile/Program.cs b/samples/Mobile/Shared.Mobile/Program.cs similarity index 100% rename from samples/Shared/Shared.Mobile/Program.cs rename to samples/Mobile/Shared.Mobile/Program.cs diff --git a/samples/Shared/Shared.Mobile/Resources/wwwroot/index.html b/samples/Mobile/Shared.Mobile/Resources/wwwroot/index.html similarity index 100% rename from samples/Shared/Shared.Mobile/Resources/wwwroot/index.html rename to samples/Mobile/Shared.Mobile/Resources/wwwroot/index.html diff --git a/samples/Shared/Shared.Mobile/Shared.Mobile.csproj b/samples/Mobile/Shared.Mobile/Shared.Mobile.csproj similarity index 95% rename from samples/Shared/Shared.Mobile/Shared.Mobile.csproj rename to samples/Mobile/Shared.Mobile/Shared.Mobile.csproj index e483e7b..444d189 100644 --- a/samples/Shared/Shared.Mobile/Shared.Mobile.csproj +++ b/samples/Mobile/Shared.Mobile/Shared.Mobile.csproj @@ -21,7 +21,7 @@ - + @@ -29,7 +29,7 @@ instantiate components and the iOS linker strips their constructors otherwise. --> - + diff --git a/src/Hermes.Mobile.Android/Hermes.Mobile.Android.csproj b/src/Hermes.Mobile.Android/Hermes.Mobile.Android.csproj index 6483059..502eef5 100644 --- a/src/Hermes.Mobile.Android/Hermes.Mobile.Android.csproj +++ b/src/Hermes.Mobile.Android/Hermes.Mobile.Android.csproj @@ -33,6 +33,6 @@ - + diff --git a/src/Hermes.Mobile.Android/HermesMobileAndroidBuilder.cs b/src/Hermes.Mobile.Android/HermesMobileAndroidBuilder.cs index 011ba0b..65d398a 100644 --- a/src/Hermes.Mobile.Android/HermesMobileAndroidBuilder.cs +++ b/src/Hermes.Mobile.Android/HermesMobileAndroidBuilder.cs @@ -10,7 +10,7 @@ namespace Hermes.Mobile.Android; -public sealed class HermesMobileAndroidBuilder +public sealed class HermesMobileAndroidBuilder : IMobileBuilder { private readonly Context _context; private string _hostPage = "wwwroot/index.html"; @@ -29,6 +29,12 @@ private HermesMobileAndroidBuilder(Context context) public RootComponentCollection RootComponents { get; } = new(); + IMobileBuilder IMobileBuilder.UseHostPage(string hostPage) + { + _hostPage = hostPage; + return this; + } + public HermesMobileAndroidBuilder UseHostPage(string hostPage) { _hostPage = hostPage; @@ -55,18 +61,3 @@ public HermesMobileAndroidHost Build() return new HermesMobileAndroidHost(_context, provider, fileProvider, hostPageRelative, components); } } - -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.Android/HermesMobileAndroidHost.cs b/src/Hermes.Mobile.Android/HermesMobileAndroidHost.cs index 03e629b..3a8bbbd 100644 --- a/src/Hermes.Mobile.Android/HermesMobileAndroidHost.cs +++ b/src/Hermes.Mobile.Android/HermesMobileAndroidHost.cs @@ -9,7 +9,7 @@ namespace Hermes.Mobile.Android; -public sealed class HermesMobileAndroidHost : IAsyncDisposable +public sealed class HermesMobileAndroidHost : IMobileHost { private readonly IServiceProvider _services; private readonly global::Android.Webkit.WebView _webView; diff --git a/src/Hermes.Mobile.Android/WebView/AndroidWebViewManager.cs b/src/Hermes.Mobile.Android/WebView/AndroidWebViewManager.cs index 7a68dec..5744708 100644 --- a/src/Hermes.Mobile.Android/WebView/AndroidWebViewManager.cs +++ b/src/Hermes.Mobile.Android/WebView/AndroidWebViewManager.cs @@ -7,6 +7,8 @@ using Microsoft.AspNetCore.Components.WebView; using Microsoft.Extensions.FileProviders; +using Hermes.Mobile.WebView; + namespace Hermes.Mobile.Android.WebView; internal sealed class AndroidWebViewManager : WebViewManager @@ -75,7 +77,7 @@ protected override void SendMessage(string message) internal void MessageReceivedInternal(Uri sourceUri, string message) => MessageReceived(sourceUri, message); - internal (int StatusCode, byte[] Body, string ContentType) ResolveRequest(string absoluteUrl) + internal WebViewResponse ResolveRequest(string absoluteUrl) { var allowFallbackOnHostPage = _appBaseUri.IsBaseOf(new Uri(absoluteUrl)); @@ -87,17 +89,9 @@ internal void MessageReceivedInternal(Uri sourceUri, string message) out var content, out var headers)) { - using var ms = new MemoryStream(); - content.CopyTo(ms); - content.Dispose(); - - var contentType = headers.TryGetValue("Content-Type", out var ct) - ? ct - : "application/octet-stream"; - - return (statusCode, ms.ToArray(), contentType); + return WebViewResolveHelper.ToResponse(statusCode, content, headers, absoluteUrl); } - return (404, Array.Empty(), string.Empty); + return WebViewResponse.NotFound; } } diff --git a/src/Hermes.Mobile.Android/WebView/HermesWebViewClient.cs b/src/Hermes.Mobile.Android/WebView/HermesWebViewClient.cs index 102673e..88ccf34 100644 --- a/src/Hermes.Mobile.Android/WebView/HermesWebViewClient.cs +++ b/src/Hermes.Mobile.Android/WebView/HermesWebViewClient.cs @@ -1,5 +1,6 @@ // Copyright (c) Mythetech. Licensed under the Elastic License 2.0. using Android.Webkit; +using Hermes.Mobile.WebView; namespace Hermes.Mobile.Android.WebView; @@ -38,16 +39,16 @@ public override bool ShouldOverrideUrlLoading( if (url is null) return base.ShouldInterceptRequest(view, request); - var (statusCode, body, contentType) = _manager.ResolveRequest(url); - if (statusCode == 200 && body.Length > 0) + var response = _manager.ResolveRequest(url); + if (response.StatusCode == 200 && response.Body.Length > 0) { return new WebResourceResponse( - contentType, + response.ContentType, "UTF-8", - statusCode, + response.StatusCode, "OK", new Dictionary { ["Cache-Control"] = "no-cache" }, - new MemoryStream(body)); + new MemoryStream(response.Body)); } return base.ShouldInterceptRequest(view, request); diff --git a/src/Hermes.Mobile.Shared/Hermes.Mobile.csproj b/src/Hermes.Mobile.Shared/Hermes.Mobile.csproj new file mode 100644 index 0000000..5560ae0 --- /dev/null +++ b/src/Hermes.Mobile.Shared/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.Shared/IMobileBuilder.cs b/src/Hermes.Mobile.Shared/IMobileBuilder.cs new file mode 100644 index 0000000..4ce144a --- /dev/null +++ b/src/Hermes.Mobile.Shared/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.Shared/IMobileHost.cs b/src/Hermes.Mobile.Shared/IMobileHost.cs new file mode 100644 index 0000000..6c587de --- /dev/null +++ b/src/Hermes.Mobile.Shared/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.Shared/RootComponentCollection.cs b/src/Hermes.Mobile.Shared/RootComponentCollection.cs new file mode 100644 index 0000000..bc26555 --- /dev/null +++ b/src/Hermes.Mobile.Shared/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.Shared/WebView/MimeTypeLookup.cs similarity index 97% rename from src/Hermes.Mobile/WebView/MimeTypeLookup.cs rename to src/Hermes.Mobile.Shared/WebView/MimeTypeLookup.cs index 1f1c600..6c9dcfb 100644 --- a/src/Hermes.Mobile/WebView/MimeTypeLookup.cs +++ b/src/Hermes.Mobile.Shared/WebView/MimeTypeLookup.cs @@ -1,7 +1,7 @@ // Copyright (c) Mythetech. Licensed under the Elastic License 2.0. namespace Hermes.Mobile.WebView; -internal static class MimeTypeLookup +public static class MimeTypeLookup { private static readonly Dictionary Map = new(StringComparer.OrdinalIgnoreCase) { diff --git a/src/Hermes.Mobile.Shared/WebView/WebViewResolveHelper.cs b/src/Hermes.Mobile.Shared/WebView/WebViewResolveHelper.cs new file mode 100644 index 0000000..acedc39 --- /dev/null +++ b/src/Hermes.Mobile.Shared/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.Shared/WebView/WebViewResponse.cs b/src/Hermes.Mobile.Shared/WebView/WebViewResponse.cs new file mode 100644 index 0000000..23f5916 --- /dev/null +++ b/src/Hermes.Mobile.Shared/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 + }; +} diff --git a/src/Hermes.Mobile/Hermes.Mobile.csproj b/src/Hermes.Mobile.iOS/Hermes.Mobile.iOS.csproj similarity index 91% rename from src/Hermes.Mobile/Hermes.Mobile.csproj rename to src/Hermes.Mobile.iOS/Hermes.Mobile.iOS.csproj index b1096df..899a5f3 100644 --- a/src/Hermes.Mobile/Hermes.Mobile.csproj +++ b/src/Hermes.Mobile.iOS/Hermes.Mobile.iOS.csproj @@ -9,7 +9,7 @@ - Mythetech.Hermes.Mobile + 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 @@ -33,6 +33,6 @@ - + diff --git a/src/Hermes.Mobile/HermesMobileAppBuilder.cs b/src/Hermes.Mobile.iOS/HermesMobileAppBuilder.cs similarity index 74% rename from src/Hermes.Mobile/HermesMobileAppBuilder.cs rename to src/Hermes.Mobile.iOS/HermesMobileAppBuilder.cs index 05580ee..6cdd097 100644 --- a/src/Hermes.Mobile/HermesMobileAppBuilder.cs +++ b/src/Hermes.Mobile.iOS/HermesMobileAppBuilder.cs @@ -1,19 +1,19 @@ // Copyright (c) Mythetech. Licensed under the Elastic License 2.0. using System.Diagnostics.CodeAnalysis; using Hermes.Contracts.Plugins; -using Hermes.Mobile.Plugins; -using Hermes.Mobile.WebView; +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; +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 +public sealed class HermesMobileAppBuilder : IMobileBuilder { private string _hostPage = "wwwroot/index.html"; @@ -30,6 +30,12 @@ private HermesMobileAppBuilder() public RootComponentCollection RootComponents { get; } = new(); + IMobileBuilder IMobileBuilder.UseHostPage(string hostPage) + { + _hostPage = hostPage; + return this; + } + public HermesMobileAppBuilder UseHostPage(string hostPage) { _hostPage = hostPage; @@ -56,18 +62,3 @@ public HermesMobileHost Build() return new HermesMobileHost(provider, fileProvider, hostPageRelative, components); } } - -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/HermesMobileHost.cs b/src/Hermes.Mobile.iOS/HermesMobileHost.cs similarity index 97% rename from src/Hermes.Mobile/HermesMobileHost.cs rename to src/Hermes.Mobile.iOS/HermesMobileHost.cs index 99a2895..25d3314 100644 --- a/src/Hermes.Mobile/HermesMobileHost.cs +++ b/src/Hermes.Mobile.iOS/HermesMobileHost.cs @@ -2,7 +2,7 @@ using System.Diagnostics.CodeAnalysis; using CoreGraphics; using Foundation; -using Hermes.Mobile.WebView; +using Hermes.Mobile.iOS.WebView; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebView; @@ -10,7 +10,7 @@ using UIKit; using WebKit; -namespace Hermes.Mobile; +namespace Hermes.Mobile.iOS; /// /// Hosts a Blazor app inside a WKWebView embedded in a UIViewController. @@ -22,7 +22,7 @@ namespace Hermes.Mobile; /// (dotnet/macios#23002). The JS↔C# bridge uses WKScriptMessageHandler which works /// correctly with the static registrar + ProtocolAdoption workaround. /// -public sealed class HermesMobileHost : IAsyncDisposable +public sealed class HermesMobileHost : IMobileHost { private readonly IServiceProvider _services; private readonly WKWebView _webView; diff --git a/src/Hermes.Mobile/Plugins/IOSClipboard.cs b/src/Hermes.Mobile.iOS/Plugins/IOSClipboard.cs similarity index 94% rename from src/Hermes.Mobile/Plugins/IOSClipboard.cs rename to src/Hermes.Mobile.iOS/Plugins/IOSClipboard.cs index 6d10bf6..7b49a0a 100644 --- a/src/Hermes.Mobile/Plugins/IOSClipboard.cs +++ b/src/Hermes.Mobile.iOS/Plugins/IOSClipboard.cs @@ -2,7 +2,7 @@ using Hermes.Contracts.Plugins; using UIKit; -namespace Hermes.Mobile.Plugins; +namespace Hermes.Mobile.iOS.Plugins; /// /// iOS implementation of backed by . diff --git a/src/Hermes.Mobile/Threading/IOSDispatcher.cs b/src/Hermes.Mobile.iOS/Threading/IOSDispatcher.cs similarity index 97% rename from src/Hermes.Mobile/Threading/IOSDispatcher.cs rename to src/Hermes.Mobile.iOS/Threading/IOSDispatcher.cs index a41e427..956e38a 100644 --- a/src/Hermes.Mobile/Threading/IOSDispatcher.cs +++ b/src/Hermes.Mobile.iOS/Threading/IOSDispatcher.cs @@ -3,7 +3,7 @@ using Foundation; using Microsoft.AspNetCore.Components; -namespace Hermes.Mobile.Threading; +namespace Hermes.Mobile.iOS.Threading; /// /// Marshals Blazor component work onto the iOS main queue (UI thread). diff --git a/src/Hermes.Mobile/WebView/AllowAllNavigationDelegate.cs b/src/Hermes.Mobile.iOS/WebView/AllowAllNavigationDelegate.cs similarity index 97% rename from src/Hermes.Mobile/WebView/AllowAllNavigationDelegate.cs rename to src/Hermes.Mobile.iOS/WebView/AllowAllNavigationDelegate.cs index 85ae7f4..b96f023 100644 --- a/src/Hermes.Mobile/WebView/AllowAllNavigationDelegate.cs +++ b/src/Hermes.Mobile.iOS/WebView/AllowAllNavigationDelegate.cs @@ -3,7 +3,7 @@ using ObjCRuntime; using WebKit; -namespace Hermes.Mobile.WebView; +namespace Hermes.Mobile.iOS.WebView; /// /// Minimal WKNavigationDelegate that allows all navigation. Without an explicit delegate diff --git a/src/Hermes.Mobile/WebView/AppSchemeHandler.cs b/src/Hermes.Mobile.iOS/WebView/AppSchemeHandler.cs similarity index 62% rename from src/Hermes.Mobile/WebView/AppSchemeHandler.cs rename to src/Hermes.Mobile.iOS/WebView/AppSchemeHandler.cs index fa0fdfa..d42b0bb 100644 --- a/src/Hermes.Mobile/WebView/AppSchemeHandler.cs +++ b/src/Hermes.Mobile.iOS/WebView/AppSchemeHandler.cs @@ -2,10 +2,11 @@ using System.Globalization; using System.Runtime.Versioning; using Foundation; +using Hermes.Mobile.WebView; using ObjCRuntime; using WebKit; -namespace Hermes.Mobile.WebView; +namespace Hermes.Mobile.iOS.WebView; /// /// Handles app:// requests by delegating to a resolver that wraps WebViewManager.TryGetResponseContent. @@ -18,9 +19,9 @@ internal sealed class AppSchemeHandler : NSObject, IWKUrlSchemeHandler static AppSchemeHandler() => ProtocolAdoption.Ensure("WKURLSchemeHandler"); - private readonly Func _resolver; + private readonly Func _resolver; - public AppSchemeHandler(Func resolver) + public AppSchemeHandler(Func resolver) { _resolver = resolver; } @@ -33,24 +34,24 @@ public void StartUrlSchemeTask(WKWebView webView, IWKUrlSchemeTask urlSchemeTask if (string.IsNullOrEmpty(url)) return; - var (statusCode, body, contentType) = _resolver(url); + var response = _resolver(url); - if (statusCode == 200) + if (response.StatusCode == 200) { using var headers = new NSMutableDictionary(); - headers.Add((NSString)"Content-Length", (NSString)body.Length.ToString(CultureInfo.InvariantCulture)); - headers.Add((NSString)"Content-Type", (NSString)contentType); + 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 response = new NSHttpUrlResponse(urlSchemeTask.Request.Url!, statusCode, "HTTP/1.1", headers); - urlSchemeTask.DidReceiveResponse(response); - urlSchemeTask.DidReceiveData(NSData.FromArray(body)); + 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 response = new NSHttpUrlResponse(urlSchemeTask.Request.Url!, statusCode, "HTTP/1.1", null); - urlSchemeTask.DidReceiveResponse(response); + using var httpResponse = new NSHttpUrlResponse(urlSchemeTask.Request.Url!, response.StatusCode, "HTTP/1.1", null); + urlSchemeTask.DidReceiveResponse(httpResponse); urlSchemeTask.DidFinish(); } } diff --git a/src/Hermes.Mobile/WebView/BlazorInitScript.cs b/src/Hermes.Mobile.iOS/WebView/BlazorInitScript.cs similarity index 96% rename from src/Hermes.Mobile/WebView/BlazorInitScript.cs rename to src/Hermes.Mobile.iOS/WebView/BlazorInitScript.cs index 47ef8be..62f139b 100644 --- a/src/Hermes.Mobile/WebView/BlazorInitScript.cs +++ b/src/Hermes.Mobile.iOS/WebView/BlazorInitScript.cs @@ -1,5 +1,5 @@ // Copyright (c) Mythetech. Licensed under the Elastic License 2.0. -namespace Hermes.Mobile.WebView; +namespace Hermes.Mobile.iOS.WebView; internal static class BlazorInitScript { diff --git a/src/Hermes.Mobile/WebView/EmbeddedFileServer.cs b/src/Hermes.Mobile.iOS/WebView/EmbeddedFileServer.cs similarity index 98% rename from src/Hermes.Mobile/WebView/EmbeddedFileServer.cs rename to src/Hermes.Mobile.iOS/WebView/EmbeddedFileServer.cs index 454b931..f142ed1 100644 --- a/src/Hermes.Mobile/WebView/EmbeddedFileServer.cs +++ b/src/Hermes.Mobile.iOS/WebView/EmbeddedFileServer.cs @@ -1,9 +1,10 @@ // 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.WebView; +namespace Hermes.Mobile.iOS.WebView; /// /// Minimal HTTP server that serves static files from an on localhost. diff --git a/src/Hermes.Mobile/WebView/IOSAssetFileProvider.cs b/src/Hermes.Mobile.iOS/WebView/IOSAssetFileProvider.cs similarity index 97% rename from src/Hermes.Mobile/WebView/IOSAssetFileProvider.cs rename to src/Hermes.Mobile.iOS/WebView/IOSAssetFileProvider.cs index 0b9a10a..c755249 100644 --- a/src/Hermes.Mobile/WebView/IOSAssetFileProvider.cs +++ b/src/Hermes.Mobile.iOS/WebView/IOSAssetFileProvider.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.FileProviders.Physical; using Microsoft.Extensions.Primitives; -namespace Hermes.Mobile.WebView; +namespace Hermes.Mobile.iOS.WebView; /// /// Serves static assets from the app bundle's wwwroot folder. diff --git a/src/Hermes.Mobile/WebView/IOSWebViewManager.cs b/src/Hermes.Mobile.iOS/WebView/IOSWebViewManager.cs similarity index 82% rename from src/Hermes.Mobile/WebView/IOSWebViewManager.cs rename to src/Hermes.Mobile.iOS/WebView/IOSWebViewManager.cs index b5df497..c5ae38d 100644 --- a/src/Hermes.Mobile/WebView/IOSWebViewManager.cs +++ b/src/Hermes.Mobile.iOS/WebView/IOSWebViewManager.cs @@ -1,13 +1,14 @@ // 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.WebView; +namespace Hermes.Mobile.iOS.WebView; /// /// WebViewManager subclass that binds a WKWebView's navigation + messaging to the Blazor pipeline. @@ -56,7 +57,7 @@ protected override void SendMessage(string message) internal void MessageReceivedInternal(Uri sourceUri, string message) => MessageReceived(sourceUri, message); - internal (int StatusCode, byte[] Body, string ContentType) ResolveRequest(string absoluteUrl) + internal WebViewResponse ResolveRequest(string absoluteUrl) { var allowFallbackOnHostPage = _appBaseUri.IsBaseOf(new Uri(absoluteUrl)); @@ -68,17 +69,9 @@ internal void MessageReceivedInternal(Uri sourceUri, string message) out var content, out var headers)) { - using var ms = new MemoryStream(); - content.CopyTo(ms); - content.Dispose(); - - var contentType = headers.TryGetValue("Content-Type", out var ct) - ? ct - : MimeTypeLookup.GetContentType(absoluteUrl); - - return (200, ms.ToArray(), contentType); + return WebViewResolveHelper.ToResponse(statusCode, content, headers, absoluteUrl); } - return (404, Array.Empty(), string.Empty); + return WebViewResponse.NotFound; } } diff --git a/src/Hermes.Mobile/WebView/ProtocolAdoption.cs b/src/Hermes.Mobile.iOS/WebView/ProtocolAdoption.cs similarity index 98% rename from src/Hermes.Mobile/WebView/ProtocolAdoption.cs rename to src/Hermes.Mobile.iOS/WebView/ProtocolAdoption.cs index ba20791..65ceb95 100644 --- a/src/Hermes.Mobile/WebView/ProtocolAdoption.cs +++ b/src/Hermes.Mobile.iOS/WebView/ProtocolAdoption.cs @@ -3,7 +3,7 @@ using Foundation; using ObjCRuntime; -namespace Hermes.Mobile.WebView; +namespace Hermes.Mobile.iOS.WebView; /// /// Registers Obj-C protocol conformance on a managed NSObject subclass by calling diff --git a/src/Hermes.Mobile/WebView/ScriptMessageHandler.cs b/src/Hermes.Mobile.iOS/WebView/ScriptMessageHandler.cs similarity index 96% rename from src/Hermes.Mobile/WebView/ScriptMessageHandler.cs rename to src/Hermes.Mobile.iOS/WebView/ScriptMessageHandler.cs index acfe642..f183194 100644 --- a/src/Hermes.Mobile/WebView/ScriptMessageHandler.cs +++ b/src/Hermes.Mobile.iOS/WebView/ScriptMessageHandler.cs @@ -3,7 +3,7 @@ using ObjCRuntime; using WebKit; -namespace Hermes.Mobile.WebView; +namespace Hermes.Mobile.iOS.WebView; /// /// Forwards WKWebView → native messages to the WebViewManager's MessageReceived pump. From b13920540c7b5c6435d6d1b624a5a7ea81493791 Mon Sep 17 00:00:00 2001 From: Tom Brewer Date: Sun, 19 Apr 2026 11:29:46 -0600 Subject: [PATCH 14/14] refactor: rename Hermes.Mobile.Shared folder to Hermes.Mobile matching project --- Hermes.Mobile.sln | 2 +- src/Hermes.Mobile.Android/Hermes.Mobile.Android.csproj | 2 +- src/Hermes.Mobile.iOS/Hermes.Mobile.iOS.csproj | 2 +- .../Hermes.Mobile.csproj | 0 src/{Hermes.Mobile.Shared => Hermes.Mobile}/IMobileBuilder.cs | 0 src/{Hermes.Mobile.Shared => Hermes.Mobile}/IMobileHost.cs | 0 .../RootComponentCollection.cs | 0 .../WebView/MimeTypeLookup.cs | 0 .../WebView/WebViewResolveHelper.cs | 0 .../WebView/WebViewResponse.cs | 0 10 files changed, 3 insertions(+), 3 deletions(-) rename src/{Hermes.Mobile.Shared => Hermes.Mobile}/Hermes.Mobile.csproj (100%) rename src/{Hermes.Mobile.Shared => Hermes.Mobile}/IMobileBuilder.cs (100%) rename src/{Hermes.Mobile.Shared => Hermes.Mobile}/IMobileHost.cs (100%) rename src/{Hermes.Mobile.Shared => Hermes.Mobile}/RootComponentCollection.cs (100%) rename src/{Hermes.Mobile.Shared => Hermes.Mobile}/WebView/MimeTypeLookup.cs (100%) rename src/{Hermes.Mobile.Shared => Hermes.Mobile}/WebView/WebViewResolveHelper.cs (100%) rename src/{Hermes.Mobile.Shared => Hermes.Mobile}/WebView/WebViewResponse.cs (100%) diff --git a/Hermes.Mobile.sln b/Hermes.Mobile.sln index e962a01..5c53a36 100644 --- a/Hermes.Mobile.sln +++ b/Hermes.Mobile.sln @@ -25,7 +25,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hermes.Mobile.Android", "sr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared.Android", "samples\Mobile\Shared.Android\Shared.Android.csproj", "{68C2D957-DD59-4FD2-AA60-AD82BF792A77}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hermes.Mobile", "src\Hermes.Mobile.Shared\Hermes.Mobile.csproj", "{E93D2228-2DCF-4A55-9E06-55F1B870DBB2}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hermes.Mobile", "src\Hermes.Mobile\Hermes.Mobile.csproj", "{E93D2228-2DCF-4A55-9E06-55F1B870DBB2}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/src/Hermes.Mobile.Android/Hermes.Mobile.Android.csproj b/src/Hermes.Mobile.Android/Hermes.Mobile.Android.csproj index 502eef5..28abfe6 100644 --- a/src/Hermes.Mobile.Android/Hermes.Mobile.Android.csproj +++ b/src/Hermes.Mobile.Android/Hermes.Mobile.Android.csproj @@ -33,6 +33,6 @@ - + diff --git a/src/Hermes.Mobile.iOS/Hermes.Mobile.iOS.csproj b/src/Hermes.Mobile.iOS/Hermes.Mobile.iOS.csproj index 899a5f3..c91491d 100644 --- a/src/Hermes.Mobile.iOS/Hermes.Mobile.iOS.csproj +++ b/src/Hermes.Mobile.iOS/Hermes.Mobile.iOS.csproj @@ -33,6 +33,6 @@ - + diff --git a/src/Hermes.Mobile.Shared/Hermes.Mobile.csproj b/src/Hermes.Mobile/Hermes.Mobile.csproj similarity index 100% rename from src/Hermes.Mobile.Shared/Hermes.Mobile.csproj rename to src/Hermes.Mobile/Hermes.Mobile.csproj diff --git a/src/Hermes.Mobile.Shared/IMobileBuilder.cs b/src/Hermes.Mobile/IMobileBuilder.cs similarity index 100% rename from src/Hermes.Mobile.Shared/IMobileBuilder.cs rename to src/Hermes.Mobile/IMobileBuilder.cs diff --git a/src/Hermes.Mobile.Shared/IMobileHost.cs b/src/Hermes.Mobile/IMobileHost.cs similarity index 100% rename from src/Hermes.Mobile.Shared/IMobileHost.cs rename to src/Hermes.Mobile/IMobileHost.cs diff --git a/src/Hermes.Mobile.Shared/RootComponentCollection.cs b/src/Hermes.Mobile/RootComponentCollection.cs similarity index 100% rename from src/Hermes.Mobile.Shared/RootComponentCollection.cs rename to src/Hermes.Mobile/RootComponentCollection.cs diff --git a/src/Hermes.Mobile.Shared/WebView/MimeTypeLookup.cs b/src/Hermes.Mobile/WebView/MimeTypeLookup.cs similarity index 100% rename from src/Hermes.Mobile.Shared/WebView/MimeTypeLookup.cs rename to src/Hermes.Mobile/WebView/MimeTypeLookup.cs diff --git a/src/Hermes.Mobile.Shared/WebView/WebViewResolveHelper.cs b/src/Hermes.Mobile/WebView/WebViewResolveHelper.cs similarity index 100% rename from src/Hermes.Mobile.Shared/WebView/WebViewResolveHelper.cs rename to src/Hermes.Mobile/WebView/WebViewResolveHelper.cs diff --git a/src/Hermes.Mobile.Shared/WebView/WebViewResponse.cs b/src/Hermes.Mobile/WebView/WebViewResponse.cs similarity index 100% rename from src/Hermes.Mobile.Shared/WebView/WebViewResponse.cs rename to src/Hermes.Mobile/WebView/WebViewResponse.cs