diff --git a/Hermes.Mobile.sln b/Hermes.Mobile.sln new file mode 100644 index 0000000..5c53a36 --- /dev/null +++ b/Hermes.Mobile.sln @@ -0,0 +1,164 @@ + +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.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}") = "Mobile", "Mobile", "{3177BFE4-54BB-4449-CBD6-550FA3C2F073}" +EndProject +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\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\Mobile\Shared.Android\Shared.Android.csproj", "{68C2D957-DD59-4FD2-AA60-AD82BF792A77}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hermes.Mobile", "src\Hermes.Mobile\Hermes.Mobile.csproj", "{E93D2228-2DCF-4A55-9E06-55F1B870DBB2}" +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 + {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 + {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 + {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 + {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 + 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} + {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} + {E93D2228-2DCF-4A55-9E06-55F1B870DBB2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + EndGlobalSection +EndGlobal 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/docs/Architecture/ANDROID-ARCHITECTURE.md b/docs/Architecture/ANDROID-ARCHITECTURE.md new file mode 100644 index 0000000..c793888 --- /dev/null +++ b/docs/Architecture/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/ARCHITECTURE.md b/docs/Architecture/ARCHITECTURE.md similarity index 100% rename from ARCHITECTURE.md rename to docs/Architecture/ARCHITECTURE.md diff --git a/docs/Architecture/IOS-ARCHITECTURE.md b/docs/Architecture/IOS-ARCHITECTURE.md new file mode 100644 index 0000000..4716209 --- /dev/null +++ b/docs/Architecture/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/samples/Mobile/Shared.Android/AndroidManifest.xml b/samples/Mobile/Shared.Android/AndroidManifest.xml new file mode 100644 index 0000000..3d151ca --- /dev/null +++ b/samples/Mobile/Shared.Android/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + diff --git a/samples/Mobile/Shared.Android/MainActivity.cs b/samples/Mobile/Shared.Android/MainActivity.cs new file mode 100644 index 0000000..da2c6c3 --- /dev/null +++ b/samples/Mobile/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/Mobile/Shared.Android/MainApplication.cs b/samples/Mobile/Shared.Android/MainApplication.cs new file mode 100644 index 0000000..2dbe08b --- /dev/null +++ b/samples/Mobile/Shared.Android/MainApplication.cs @@ -0,0 +1,14 @@ +// Copyright (c) Mythetech. Licensed under the Elastic License 2.0. +using Android.App; +using Android.Runtime; + +namespace Shared.Android; + +[Application] +public class MainApplication : Application +{ + public MainApplication(IntPtr handle, JniHandleOwnership transfer) + : base(handle, transfer) + { + } +} diff --git a/samples/Mobile/Shared.Android/Resources/values/styles.xml b/samples/Mobile/Shared.Android/Resources/values/styles.xml new file mode 100644 index 0000000..b8eb3aa --- /dev/null +++ b/samples/Mobile/Shared.Android/Resources/values/styles.xml @@ -0,0 +1,7 @@ + + + + diff --git a/samples/Mobile/Shared.Android/Resources/wwwroot/index.html b/samples/Mobile/Shared.Android/Resources/wwwroot/index.html new file mode 100644 index 0000000..98c95af --- /dev/null +++ b/samples/Mobile/Shared.Android/Resources/wwwroot/index.html @@ -0,0 +1,14 @@ + + + + + + Shared Blazor — Android + + + + +
+ + + diff --git a/samples/Mobile/Shared.Android/Shared.Android.csproj b/samples/Mobile/Shared.Android/Shared.Android.csproj new file mode 100644 index 0000000..877b036 --- /dev/null +++ b/samples/Mobile/Shared.Android/Shared.Android.csproj @@ -0,0 +1,55 @@ + + + net10.0-android + 24 + Exe + enable + enable + Shared.Android + com.mythetech.hermes.shared.android + Shared Android + 1.0 + 1 + + + + + true + + + + + + + + + + + + + + + + + + + + + + + wwwroot/%(RecursiveDir)%(Filename)%(Extension) + + + + wwwroot/_framework/blazor.webview.js + + + wwwroot/_framework/blazor.modules.json + + + + wwwroot/_content/Shared.App/%(RecursiveDir)%(Filename)%(Extension) + + + diff --git a/samples/Mobile/Shared.App/App.razor b/samples/Mobile/Shared.App/App.razor new file mode 100644 index 0000000..4cb0f18 --- /dev/null +++ b/samples/Mobile/Shared.App/App.razor @@ -0,0 +1,10 @@ + + + + + + +

Page not found

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

Clipboard Demo

+ + + + +
+ + +
+ +

Last read value: @_lastRead

+ +@if (!string.IsNullOrEmpty(_error)) +{ +
@_error
+} + +@code { + private string _text = "hello from hermes"; + private string? _lastRead; + private string? _error; + + private async Task SetAsync() + { + _error = null; + try + { + await Clipboard.SetTextAsync(_text); + } + catch (Exception ex) + { + _error = $"SetTextAsync failed: {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}"; + } + } + + private async Task GetAsync() + { + _error = null; + try + { + _lastRead = await Clipboard.GetTextAsync(); + } + catch (Exception ex) + { + _error = $"GetTextAsync failed: {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}"; + } + } +} diff --git a/samples/Mobile/Shared.App/Pages/Counter.razor b/samples/Mobile/Shared.App/Pages/Counter.razor new file mode 100644 index 0000000..53e5fde --- /dev/null +++ b/samples/Mobile/Shared.App/Pages/Counter.razor @@ -0,0 +1,10 @@ +@page "/counter" + +

Counter

+

Current count: @currentCount

+ + +@code { + private int currentCount = 0; + private void IncrementCount() => currentCount++; +} diff --git a/samples/Mobile/Shared.App/Pages/Index.razor b/samples/Mobile/Shared.App/Pages/Index.razor new file mode 100644 index 0000000..d2c843b --- /dev/null +++ b/samples/Mobile/Shared.App/Pages/Index.razor @@ -0,0 +1,8 @@ +@page "/" + +

Hermes Shared Blazor

+

This Razor Class Library runs unmodified on desktop (via Hermes.Blazor) and iOS (via Hermes.Mobile).

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