diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..2168df8 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,39 @@ +# Copilot Instructions + +## Project + +This is a .NET MAUI class library (`Com.Bnotech.ExtendedWebView`) providing an extended WebView control with JavaScript interop, navigation events, cookie management, and URL change detection. It targets `net10.0-android` and `net10.0-ios`. + +## Code Style + +- Use file-scoped namespaces (`namespace X;`) +- Use primary constructors where applicable (see `SourceChangedEventArgs`, `UrlChangedEventArgs`) +- Nullable reference types are enabled — respect nullability annotations +- Platform-specific code lives under `Platforms/{Android,iOS}/Handlers/` and uses `#if ANDROID` / `#elif IOS` conditional compilation in shared files +- All public types and members must have XML doc comments in English + +## Architecture Rules + +- All public API surfaces go through the `IExtWebView` interface (`Interfaces/IExtWebView.cs`) — add new members there first, then implement in `ExtWebView` +- EventArgs classes go in `Events/` as individual files, one class per file +- Platform handlers extend `ViewHandler` with `CreatePlatformView`, `ConnectHandler`, `DisconnectHandler` +- JS bridge classes must use `WeakReference` to avoid preventing GC of the handler +- Event subscriptions in `ConnectHandler` must be unsubscribed in `DisconnectHandler` +- Register new handlers in `AppBuilderExtensions.cs` behind platform `#if` directives +- New cross-platform methods follow the event-driven pattern: method on `ExtWebView` fires a `Request*` event, platform handler subscribes and executes via `SynchronizationContext.Post` + +## Build + +```bash +dotnet build ExtendedWebView/ExtendedWebView.csproj +dotnet pack ExtendedWebView/ExtendedWebView.csproj -c Release +``` + +## .NET Skills + +This project benefits from the following skills from [dotnet/skills](https://github.com/dotnet/skills): + +- **dotnet-maui** — `dotnet-maui-doctor`, `maui-app-lifecycle`, `maui-dependency-injection` +- **dotnet-msbuild** — `msbuild-antipatterns` + +Install via Copilot CLI: `/plugin marketplace add dotnet/skills` then `/plugin install dotnet-maui@dotnet-agent-skills` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40ae8f1..c1d0523 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v2 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - name: Install MAUI workload run: dotnet workload install maui diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 85cce1f..c7c968e 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -35,7 +35,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v2 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - name: Install MAUI workload run: dotnet workload install maui diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d287741..85b2a24 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,7 +35,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v2 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - name: Install MAUI workload run: dotnet workload install maui diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..32f807d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,56 @@ +# AGENTS.md + +## Project Overview + +.NET MAUI library (`Com.Bnotech.ExtendedWebView`) providing an enhanced WebView control with JavaScript interop, navigation events, cookie management, and URL change detection for Android and iOS. Published as NuGet package. + +## Architecture + +- **`Interfaces/IExtWebView.cs`** — Public API contract. All new members must be added here first. +- **`ExtWebView.cs`** — Cross-platform control implementing `IExtWebView`. Extends MAUI `View` with `BindableProperty` for `Source`, event-driven JS bridge (`InvokeAction`/`JavaScriptAction`), navigation events, and cookie management (`SetCookieAsync`/`UpdateCookieAsync`/`RemoveCookieAsync`/`RemoveAllCookiesAsync`). +- **`Events/`** — EventArgs classes, one per file: `SourceChangedEventArgs`, `UrlChangedEventArgs`, `JavaScriptActionEventArgs`, `SetCookieRequestEventArgs`, `RemoveCookieRequestEventArgs`, `RemoveAllCookiesRequestEventArgs`. +- **`Platforms/Android/Handlers/ExtWebViewHandler.cs`** — Android handler using `Android.Webkit.WebView`. JS bridge via `@JavascriptInterface` + `AddJavascriptInterface`. URL changes detected via `DoUpdateVisitedHistory`. Cookie management via `CookieManager`. Includes `MultiWindowWebChromeClient` for popup/multi-window support (renders child WebView in an `AlertDialog`). Enables `WebContentsDebuggingEnabled` in `DEBUG` builds. +- **`Platforms/iOS/Handlers/ExtWebViewHandler.cs`** — iOS handler using `WKWebView`. JS bridge via `WKScriptMessageHandler`. URL changes detected via KVO observer on `"URL"` property. Cookie management via `WKHttpCookieStore` + `NSHttpCookieStorage.SharedStorage`. Includes `NavigationDelegate` (cookie syncing, navigation events, and a keep-alive timer via `Dispatcher.StartTimer` with `EvaluateJavaScript("1+1;")` every 500ms in `DidFinishNavigation`). +- **`AppBuilderExtensions.cs`** — Registration via `builder.UseExtendedWebView()` using `#if ANDROID`/`#elif IOS` conditional compilation. + +## Key Patterns + +- **Handler pattern**: Each platform has a `ViewHandler` subclass with `CreatePlatformView`, `ConnectHandler`, `DisconnectHandler` lifecycle. +- **JS → C# bridge**: Web page calls `invokeCSharpAction(data)` which routes to `IExtWebView.InvokeAction(data)` → `JavaScriptAction` event. The JS function is injected differently per platform (Android: `OnPageStarted`, iOS: `WKUserScript` at document end). +- **C# → JS**: Use `EvaluateJavaScriptAsync(EvaluateJavaScriptAsyncRequest)` which fires `RequestEvaluateJavaScript` event, handled by platform handler via `SynchronizationContext.Post`. +- **Source changes** propagate through `BindableProperty.propertyChanged` → `SourceChanged` event → handler loads URL/HTML. iOS handler deduplicates identical source updates. +- **Cookie management**: `SetCookieAsync`/`UpdateCookieAsync`/`RemoveCookieAsync`/`RemoveAllCookiesAsync` follow the same event-driven pattern as `EvaluateJavaScriptAsync`. `UpdateCookieAsync` delegates to `SetCookieAsync` (setting a cookie with the same name/domain/path overwrites it). On Android, removal sets an expired cookie; on iOS, cookies are matched and deleted from both `WKHttpCookieStore` and `NSHttpCookieStorage`. + +## Build & Configuration + +- **Targets**: `net10.0-android;net10.0-ios` (MAUI 10.0.41) +- **Namespace**: `Com.Bnotech.ExtendedWebView` +- **Build**: `dotnet build ExtendedWebView/ExtendedWebView.csproj` +- **Pack**: `dotnet pack -c Release` → produces `Com.Bnotech.ExtendedWebView.{version}.nupkg` +- No test project exists in the solution. + +## Recommended .NET Skills + +Install relevant skills from [dotnet/skills](https://github.com/dotnet/skills) for enhanced AI agent support: + +- **`dotnet-maui`** — `dotnet-maui-doctor` (environment diagnostics), `maui-app-lifecycle` (handler lifecycle patterns), `maui-dependency-injection` (service registration via `MauiAppBuilder`) +- **`dotnet-msbuild`** — `msbuild-antipatterns` (csproj hygiene) + +Install: `/plugin marketplace add dotnet/skills` → `/plugin install dotnet-maui@dotnet-agent-skills` + +## Code Style + +- Use file-scoped namespaces (`namespace X;`) +- Use primary constructors where applicable (see `SourceChangedEventArgs`, `UrlChangedEventArgs`) +- Nullable reference types are enabled — respect nullability annotations +- All public types and members must have XML doc comments in English + +## When Making Changes + +- Any new platform handler must follow the existing `ViewHandler` pattern and be registered in `AppBuilderExtensions.cs` behind the appropriate `#if` directive. +- New events/methods must be added to both `IExtWebView` interface and `ExtWebView` class, then handled in each platform handler. +- New cross-platform methods follow the event-driven pattern: method on `ExtWebView` fires a `Request*` event, platform handler subscribes and executes via `SynchronizationContext.Post`. +- The `JSBridge` classes use `WeakReference` to avoid preventing GC of the handler — maintain this pattern. +- Resource cleanup happens in `DisconnectHandler` — always unsubscribe events and dispose native resources there. +- New EventArgs classes go in `Events/` as individual files with the same namespace (`Com.Bnotech.ExtendedWebView`). +- All public types and members must have XML doc comments in English. diff --git a/ExtendedWebView/Events/JavaScriptActionEventArgs.cs b/ExtendedWebView/Events/JavaScriptActionEventArgs.cs new file mode 100644 index 0000000..9d3cc50 --- /dev/null +++ b/ExtendedWebView/Events/JavaScriptActionEventArgs.cs @@ -0,0 +1,15 @@ +namespace Com.Bnotech.ExtendedWebView; + +/// +/// Event data for . Contains the string payload +/// sent from JavaScript via invokeCSharpAction(data). +/// +public class JavaScriptActionEventArgs : EventArgs +{ + public string Payload { get; private set; } + + public JavaScriptActionEventArgs(string payload) + { + Payload = payload; + } +} diff --git a/ExtendedWebView/Events/RemoveAllCookiesRequestEventArgs.cs b/ExtendedWebView/Events/RemoveAllCookiesRequestEventArgs.cs new file mode 100644 index 0000000..a683ec7 --- /dev/null +++ b/ExtendedWebView/Events/RemoveAllCookiesRequestEventArgs.cs @@ -0,0 +1,8 @@ +namespace Com.Bnotech.ExtendedWebView; + +/// +/// Event data for . Signals the platform handler +/// to remove all cookies from both the WebView cookie store and shared storage. +/// +public class RemoveAllCookiesRequestEventArgs : EventArgs; + diff --git a/ExtendedWebView/Events/RemoveCookieRequestEventArgs.cs b/ExtendedWebView/Events/RemoveCookieRequestEventArgs.cs new file mode 100644 index 0000000..93dc06e --- /dev/null +++ b/ExtendedWebView/Events/RemoveCookieRequestEventArgs.cs @@ -0,0 +1,12 @@ +namespace Com.Bnotech.ExtendedWebView; + +/// +/// Event data for . Identifies a specific cookie +/// by name, domain, and path for removal from the platform WebView's cookie store. +/// +public class RemoveCookieRequestEventArgs(string name, string domain, string path) : EventArgs +{ + public string Name { get; private set; } = name; + public string Domain { get; private set; } = domain; + public string Path { get; private set; } = path; +} diff --git a/ExtendedWebView/Events/SetCookieRequestEventArgs.cs b/ExtendedWebView/Events/SetCookieRequestEventArgs.cs new file mode 100644 index 0000000..80e800e --- /dev/null +++ b/ExtendedWebView/Events/SetCookieRequestEventArgs.cs @@ -0,0 +1,16 @@ +namespace Com.Bnotech.ExtendedWebView; + +/// +/// Event data for . Contains all properties +/// needed to create a cookie on the platform WebView's cookie store. +/// +public class SetCookieRequestEventArgs(string name, string value, string domain, string path, DateTimeOffset? expires = null, bool isSecure = false, bool isHttpOnly = false) : EventArgs +{ + public string Name { get; private set; } = name; + public string Value { get; private set; } = value; + public string Domain { get; private set; } = domain; + public string Path { get; private set; } = path; + public DateTimeOffset? Expires { get; private set; } = expires; + public bool IsSecure { get; private set; } = isSecure; + public bool IsHttpOnly { get; private set; } = isHttpOnly; +} diff --git a/ExtendedWebView/Events/SourceChangedEventArgs.cs b/ExtendedWebView/Events/SourceChangedEventArgs.cs new file mode 100644 index 0000000..f001a59 --- /dev/null +++ b/ExtendedWebView/Events/SourceChangedEventArgs.cs @@ -0,0 +1,13 @@ +namespace Com.Bnotech.ExtendedWebView; + +/// +/// Event data for . Contains the new . +/// +public class SourceChangedEventArgs(WebViewSource source) : EventArgs +{ + public WebViewSource Source + { + get; + private set; + } = source; +} diff --git a/ExtendedWebView/Events/UrlChangedEventArgs.cs b/ExtendedWebView/Events/UrlChangedEventArgs.cs new file mode 100644 index 0000000..4852b96 --- /dev/null +++ b/ExtendedWebView/Events/UrlChangedEventArgs.cs @@ -0,0 +1,14 @@ +namespace Com.Bnotech.ExtendedWebView; + +/// +/// Event data for . Contains the new URL string +/// detected via platform-specific URL change observation (SPA/PWA route changes). +/// +public class UrlChangedEventArgs(string url) : EventArgs +{ + public string Url + { + get; + private set; + } = url; +} diff --git a/ExtendedWebView/ExtWebView.cs b/ExtendedWebView/ExtWebView.cs index 4c6ca4d..95745ff 100644 --- a/ExtendedWebView/ExtWebView.cs +++ b/ExtendedWebView/ExtWebView.cs @@ -1,70 +1,33 @@ namespace Com.Bnotech.ExtendedWebView; -public class SourceChangedEventArgs(WebViewSource source) : EventArgs -{ - public WebViewSource Source - { - get; - private set; - } = source; -} - -public class UrlChangedEventArgs(string url) : EventArgs -{ - public string Url - { - get; - private set; - } = url; -} - -public class JavaScriptActionEventArgs : EventArgs -{ - public string Payload { get; private set; } - - public JavaScriptActionEventArgs(string payload) - { - Payload = payload; - } -} - -public interface IExtWebView : IView -{ - event EventHandler? SourceChanged; - event EventHandler? JavaScriptAction; - event EventHandler? RequestEvaluateJavaScript; - event EventHandler? WebViewNavigated; - event EventHandler? Navigating; - event EventHandler? Navigated; - event EventHandler? UrlChanged; - - void Refresh(); - - Task EvaluateJavaScriptAsync(EvaluateJavaScriptAsyncRequest request); - - WebViewSource Source { get; set; } - - void Cleanup(); - - void InvokeAction(string data); - - void SendWebViewNavigated(WebNavigatingEventArgs e); - void SendNavigated(WebNavigatedEventArgs e); - void SendNavigating(WebNavigatingEventArgs e); - void SendUrlChanged(UrlChangedEventArgs e); -} - - +/// +/// Cross-platform extended WebView control with JavaScript interop, navigation events, +/// cookie management, and URL change detection. Implements . +/// public class ExtWebView : View, IExtWebView { + /// public event EventHandler? SourceChanged; + /// public event EventHandler? JavaScriptAction; + /// public event EventHandler? RequestEvaluateJavaScript; + /// public event EventHandler? WebViewNavigated; + /// public event EventHandler? Navigating; + /// public event EventHandler? Navigated; + /// public event EventHandler? UrlChanged; + /// + public event EventHandler? RequestSetCookie; + /// + public event EventHandler? RequestRemoveCookie; + /// + public event EventHandler? RequestRemoveAllCookies; + /// public async Task EvaluateJavaScriptAsync(EvaluateJavaScriptAsyncRequest request) { await Task.Run(() => @@ -73,6 +36,7 @@ await Task.Run(() => }); } + /// public void Refresh() { if (Source == null) return; @@ -81,12 +45,50 @@ public void Refresh() Source = s; } + /// + public async Task SetCookieAsync(string name, string value, string domain, string path, DateTimeOffset? expires = null, bool isSecure = false, bool isHttpOnly = false) + { + await Task.Run(() => + { + RequestSetCookie?.Invoke(this, new SetCookieRequestEventArgs(name, value, domain, path, expires, isSecure, isHttpOnly)); + }); + } + + /// + public Task UpdateCookieAsync(string name, string value, string domain, string path, DateTimeOffset? expires = null, bool isSecure = false, bool isHttpOnly = false) + => SetCookieAsync(name, value, domain, path, expires, isSecure, isHttpOnly); + + /// + public async Task RemoveCookieAsync(string name, string domain, string path) + { + await Task.Run(() => + { + RequestRemoveCookie?.Invoke(this, new RemoveCookieRequestEventArgs(name, domain, path)); + }); + } + + /// + public async Task RemoveAllCookiesAsync() + { + await Task.Run(() => + { + RequestRemoveAllCookies?.Invoke(this, new RemoveAllCookiesRequestEventArgs()); + }); + } + + /// + /// The web content source (URL or HTML) displayed in the WebView. + /// Changes trigger the event, which platform handlers use to load content. + /// public WebViewSource Source { get { return (WebViewSource)GetValue(SourceProperty); } set { SetValue(SourceProperty, value); } } + /// + /// Bindable property for . Default value is about:blank. + /// public static readonly BindableProperty SourceProperty = BindableProperty.Create( propertyName: "Source", returnType: typeof(WebViewSource), @@ -94,6 +96,9 @@ public WebViewSource Source defaultValue: new UrlWebViewSource() { Url = "about:blank" }, propertyChanged: OnSourceChanged); + /// + /// Callback for changes. Dispatches the event on the UI thread. + /// private static void OnSourceChanged(BindableObject bindable, object oldValue, object newValue) { var view = bindable as ExtWebView; @@ -104,34 +109,39 @@ private static void OnSourceChanged(BindableObject bindable, object oldValue, ob }); } + /// public void Cleanup() { JavaScriptAction = null; } + /// public void InvokeAction(string data) { JavaScriptAction?.Invoke(this, new JavaScriptActionEventArgs(data)); } + /// public void SendWebViewNavigated(WebNavigatingEventArgs e) { WebViewNavigated?.Invoke(this, e); } + /// public void SendNavigated(WebNavigatedEventArgs e) { Navigated?.Invoke(this, e); } + /// public void SendNavigating(WebNavigatingEventArgs e) { Navigating?.Invoke(this, e); } + /// public void SendUrlChanged(UrlChangedEventArgs e) { UrlChanged?.Invoke(this, e); } } - diff --git a/ExtendedWebView/ExtendedWebView.csproj b/ExtendedWebView/ExtendedWebView.csproj index 5fc0811..15a2bf1 100644 --- a/ExtendedWebView/ExtendedWebView.csproj +++ b/ExtendedWebView/ExtendedWebView.csproj @@ -1,13 +1,13 @@ - net9.0-android;net9.0-ios + net10.0-android;net10.0-ios true true enable enable Com.Bnotech.ExtendedWebView - 9.0.120 + 10.0.41 12.2 13.1 diff --git a/ExtendedWebView/Interfaces/IExtWebView.cs b/ExtendedWebView/Interfaces/IExtWebView.cs new file mode 100644 index 0000000..0e0f115 --- /dev/null +++ b/ExtendedWebView/Interfaces/IExtWebView.cs @@ -0,0 +1,75 @@ +namespace Com.Bnotech.ExtendedWebView; + +/// +/// Defines the contract for the extended WebView control. +/// All public API surfaces must be added here first, then implemented in +/// and handled in each platform handler. +/// +public interface IExtWebView : IView +{ + /// Raised when the bindable property changes. + event EventHandler? SourceChanged; + /// Raised when JavaScript calls invokeCSharpAction(data) from the web page. + event EventHandler? JavaScriptAction; + /// Raised to request JavaScript evaluation on the platform WebView. Handled by platform handlers. + event EventHandler? RequestEvaluateJavaScript; + /// Raised after a web navigation completes (legacy event using ). + event EventHandler? WebViewNavigated; + /// Raised when a navigation is about to start. + event EventHandler? Navigating; + /// Raised when a navigation has completed. + event EventHandler? Navigated; + /// Raised when the displayed URL changes (e.g., SPA/PWA route changes). + event EventHandler? UrlChanged; + /// Raised to request setting a cookie on the platform WebView. Handled by platform handlers. + event EventHandler? RequestSetCookie; + /// Raised to request removal of a specific cookie. Handled by platform handlers. + event EventHandler? RequestRemoveCookie; + /// Raised to request removal of all cookies. Handled by platform handlers. + event EventHandler? RequestRemoveAllCookies; + + /// Forces a reload of the current by resetting and re-applying it. + void Refresh(); + + /// Sets a cookie in the platform WebView's cookie store. + /// Cookie name. + /// Cookie value. + /// Cookie domain (e.g., .example.com). + /// Cookie path (e.g., /). + /// Optional expiration date. Omit for session cookies. + /// Whether the cookie requires HTTPS. + /// Whether the cookie is inaccessible to JavaScript. + Task SetCookieAsync(string name, string value, string domain, string path, DateTimeOffset? expires = null, bool isSecure = false, bool isHttpOnly = false); + + /// Updates an existing cookie. Equivalent to (overwrites by name/domain/path). + Task UpdateCookieAsync(string name, string value, string domain, string path, DateTimeOffset? expires = null, bool isSecure = false, bool isHttpOnly = false); + + /// Removes a specific cookie identified by name, domain, and path. + Task RemoveCookieAsync(string name, string domain, string path); + + /// Removes all cookies from the platform WebView's cookie store. + Task RemoveAllCookiesAsync(); + + /// Evaluates a JavaScript expression in the WebView. + /// The JavaScript evaluation request containing the script to execute. + Task EvaluateJavaScriptAsync(EvaluateJavaScriptAsyncRequest request); + + /// The web content source (URL or HTML) to display. + WebViewSource Source { get; set; } + + /// Cleans up event handlers. Called during handler disconnection. + void Cleanup(); + + /// Invoked by the JS bridge when JavaScript calls invokeCSharpAction(data). + /// The string payload sent from JavaScript. + void InvokeAction(string data); + + /// Sends a web navigation event from the platform handler to subscribers. + void SendWebViewNavigated(WebNavigatingEventArgs e); + /// Sends a navigated event from the platform handler to subscribers. + void SendNavigated(WebNavigatedEventArgs e); + /// Sends a navigating event from the platform handler to subscribers. + void SendNavigating(WebNavigatingEventArgs e); + /// Sends a URL changed event from the platform handler to subscribers. + void SendUrlChanged(UrlChangedEventArgs e); +} diff --git a/ExtendedWebView/Platforms/Android/Handlers/ExtWebViewHandler.cs b/ExtendedWebView/Platforms/Android/Handlers/ExtWebViewHandler.cs index e163616..f19693e 100644 --- a/ExtendedWebView/Platforms/Android/Handlers/ExtWebViewHandler.cs +++ b/ExtendedWebView/Platforms/Android/Handlers/ExtWebViewHandler.cs @@ -10,10 +10,16 @@ using Android.OS; namespace Com.Bnotech.ExtendedWebView.Platforms.Android.Handlers; + + /// + /// Android platform handler for . Uses + /// with a JavaScript bridge, cookie management, multi-window support, and URL change detection. + /// public class ExtWebViewHandler : ViewHandler { public static PropertyMapper ExtWebViewMapper = new PropertyMapper(ViewHandler.ViewMapper); + /// JavaScript function injected into every page to enable the JS → C# bridge. const string JavascriptFunction = "function invokeCSharpAction(data){jsBridge.invokeAction(data);}"; private JSBridge? _jsBridgeHandler; @@ -24,11 +30,16 @@ public ExtWebViewHandler() : base(ExtWebViewMapper) _sync = SynchronizationContext.Current; } + /// Handles source changes by loading the new content into the native WebView. private void VirtualView_SourceChanged(object sender, SourceChangedEventArgs e) { LoadSource(e.Source, PlatformView); } + /// + /// Creates the native Android WebView with JavaScript enabled, DOM storage, + /// JS bridge interface, multi-window chrome client, third-party cookies, and debug settings. + /// protected override AWebKit.WebView CreatePlatformView() { _sync = _sync ?? SynchronizationContext.Current; @@ -59,6 +70,9 @@ protected override AWebKit.WebView CreatePlatformView() return webView; } + /// + /// Subscribes to all virtual view events and loads the initial source if set. + /// protected override void ConnectHandler(AWebKit.WebView platformView) { base.ConnectHandler(platformView); @@ -70,24 +84,84 @@ protected override void ConnectHandler(AWebKit.WebView platformView) VirtualView.SourceChanged += VirtualView_SourceChanged!; VirtualView.RequestEvaluateJavaScript += VirtualView_RequestEvaluateJavaScript!; + VirtualView.RequestSetCookie += VirtualView_RequestSetCookie!; + VirtualView.RequestRemoveCookie += VirtualView_RequestRemoveCookie!; + VirtualView.RequestRemoveAllCookies += VirtualView_RequestRemoveAllCookies!; } + /// Evaluates JavaScript on the native WebView via the UI synchronization context. private void VirtualView_RequestEvaluateJavaScript(object sender, EvaluateJavaScriptAsyncRequest e) { _sync?.Post((o) => PlatformView.EvaluateJavascript(e.Script, null), null); } + /// Sets a cookie using Android's and flushes to persistent storage. + private void VirtualView_RequestSetCookie(object sender, SetCookieRequestEventArgs e) + { + _sync?.Post((o) => + { + var cookieManager = AWebKit.CookieManager.Instance; + if (cookieManager == null) return; + + var cookieString = $"{e.Name}={e.Value}; Domain={e.Domain}; Path={e.Path}"; + if (e.Expires.HasValue) + cookieString += $"; Expires={e.Expires.Value.UtcDateTime:R}"; + if (e.IsSecure) + cookieString += "; Secure"; + if (e.IsHttpOnly) + cookieString += "; HttpOnly"; + + var url = $"https://{e.Domain}{e.Path}"; + cookieManager.SetCookie(url, cookieString); + cookieManager.Flush(); + }, null); + } + + /// Removes a cookie by setting it with a past expiration date via . + private void VirtualView_RequestRemoveCookie(object sender, RemoveCookieRequestEventArgs e) + { + _sync?.Post((o) => + { + var cookieManager = AWebKit.CookieManager.Instance; + if (cookieManager == null) return; + + var url = $"https://{e.Domain}{e.Path}"; + var cookieString = $"{e.Name}=; Domain={e.Domain}; Path={e.Path}; Expires={DateTime.UtcNow.AddDays(-1):R}"; + cookieManager.SetCookie(url, cookieString); + cookieManager.Flush(); + }, null); + } + + /// Removes all cookies via . + private void VirtualView_RequestRemoveAllCookies(object sender, RemoveAllCookiesRequestEventArgs e) + { + _sync?.Post((o) => + { + var cookieManager = AWebKit.CookieManager.Instance; + cookieManager?.RemoveAllCookies(null); + cookieManager?.Flush(); + }, null); + } + + /// + /// Unsubscribes all events, cleans up the JS bridge, and disposes native resources. + /// protected override void DisconnectHandler(AWebKit.WebView platformView) { base.DisconnectHandler(platformView); VirtualView.SourceChanged -= VirtualView_SourceChanged!; + VirtualView.RequestEvaluateJavaScript -= VirtualView_RequestEvaluateJavaScript!; + VirtualView.RequestSetCookie -= VirtualView_RequestSetCookie!; + VirtualView.RequestRemoveCookie -= VirtualView_RequestRemoveCookie!; + VirtualView.RequestRemoveAllCookies -= VirtualView_RequestRemoveAllCookies!; VirtualView.Cleanup(); _jsBridgeHandler?.Dispose(); _jsBridgeHandler = null; } + /// Loads a (URL or HTML) into the native WebView. private static void LoadSource(WebViewSource source, AWebKit.WebView control) { try @@ -105,6 +179,10 @@ private static void LoadSource(WebViewSource source, AWebKit.WebView control) } } + /// + /// Custom that injects the JS bridge function on every page start + /// and detects URL changes via . + /// public class JavascriptWebViewClient : AWebKit.WebViewClient { private readonly string _javascript; @@ -116,12 +194,14 @@ public JavascriptWebViewClient(string javascript, object virtualView) _virtualView = virtualView; } + /// Injects the JavaScript bridge function when a page starts loading. public override void OnPageStarted(AWebKit.WebView? view, string? url, Bitmap? favicon) { base.OnPageStarted(view, url, favicon); view?.EvaluateJavascript(_javascript, null); } + /// Detects URL changes (including SPA/PWA navigation) and notifies the virtual view. public override void DoUpdateVisitedHistory(AWebKit.WebView? view, string? url, bool isReload) { base.DoUpdateVisitedHistory(view, url, isReload); @@ -132,6 +212,10 @@ public override void DoUpdateVisitedHistory(AWebKit.WebView? view, string? url, } } + /// + /// Android JavaScript bridge exposed as jsBridge to web content. + /// Uses to the handler to avoid preventing GC. + /// public class JSBridge : Java.Lang.Object { readonly WeakReference _extWebViewRenderer; @@ -141,6 +225,7 @@ internal JSBridge(ExtWebViewHandler hybridRenderer) _extWebViewRenderer = new WeakReference(hybridRenderer); } + /// Called from JavaScript via jsBridge.invokeAction(data). Routes to . [AWebKit.JavascriptInterface] [Export("invokeAction")] public void InvokeAction(string data) @@ -152,10 +237,15 @@ public void InvokeAction(string data) } } + /// + /// Chrome client that supports multi-window/popup requests by rendering a child WebView + /// inside an . + /// public class MultiWindowWebChromeClient : AWebKit.WebChromeClient { private AApp.AlertDialog? _dialog; + /// Creates a new WebView for popup windows and displays it in a dialog. public override bool OnCreateWindow(AWebKit.WebView view, bool isDialog, bool isUserGesture, AOS.Message resultMsg) { var newWebView = new AWebKit.WebView(view.Context); @@ -176,6 +266,7 @@ public override bool OnCreateWindow(AWebKit.WebView view, bool isDialog, bool is return true; } + /// Destroys the popup WebView and dismisses the dialog. public override void OnCloseWindow(AWebKit.WebView window) { window.Destroy(); diff --git a/ExtendedWebView/Platforms/iOS/Handlers/ExtWebViewHandler.cs b/ExtendedWebView/Platforms/iOS/Handlers/ExtWebViewHandler.cs index 69277c3..a2a9c41 100644 --- a/ExtendedWebView/Platforms/iOS/Handlers/ExtWebViewHandler.cs +++ b/ExtendedWebView/Platforms/iOS/Handlers/ExtWebViewHandler.cs @@ -11,10 +11,17 @@ using ObjCRuntime; namespace Com.Bnotech.ExtendedWebView.Platforms.iOS.Handlers; + + /// + /// iOS platform handler for . Uses + /// with a JavaScript bridge via , cookie management, + /// KVO-based URL change detection, and a navigation delegate for cookie syncing and keep-alive. + /// public class ExtWebViewHandler : ViewHandler { public static PropertyMapper ExtWebViewMapper = new PropertyMapper(ViewHandler.ViewMapper); + /// JavaScript function injected at document end to enable the JS → C# bridge via WKScriptMessageHandler. const string JavaScriptFunction = "function invokeCSharpAction(data){window.webkit.messageHandlers.invokeAction.postMessage(data);}"; private WKUserContentController? _userController; @@ -28,6 +35,10 @@ public ExtWebViewHandler() : base(ExtWebViewMapper) _sync = SynchronizationContext.Current; } + /// + /// Handles source changes with deduplication — skips reload if the new source + /// is identical to the current one (compares HTML content or URL string). + /// private void VirtualView_SourceChanged(object sender, SourceChangedEventArgs e) { var source = e.Source; @@ -46,6 +57,10 @@ private void VirtualView_SourceChanged(object sender, SourceChangedEventArgs e) LoadSource(e.Source, PlatformView); } + /// + /// Creates the native WKWebView with user content controller, JS bridge script, + /// JavaScript preferences (iOS 14+ vs legacy), and cookie acceptance policy. + /// protected override WKWebView CreatePlatformView() { _sync = _sync ?? SynchronizationContext.Current; @@ -79,6 +94,10 @@ protected override WKWebView CreatePlatformView() return webView; } + /// + /// Sets up the navigation delegate, KVO observer for URL changes, + /// loads initial source, and subscribes to all virtual view events. + /// protected override void ConnectHandler(WKWebView platformView) { base.ConnectHandler(platformView); @@ -104,8 +123,12 @@ protected override void ConnectHandler(WKWebView platformView) VirtualView.SourceChanged += VirtualView_SourceChanged!; VirtualView.RequestEvaluateJavaScript += VirtualView_RequestEvaluateJavaScript!; + VirtualView.RequestSetCookie += VirtualView_RequestSetCookie!; + VirtualView.RequestRemoveCookie += VirtualView_RequestRemoveCookie!; + VirtualView.RequestRemoveAllCookies += VirtualView_RequestRemoveAllCookies!; } + /// Evaluates JavaScript on the native WKWebView via the UI synchronization context. private void VirtualView_RequestEvaluateJavaScript(object sender, EvaluateJavaScriptAsyncRequest e) { if (_sync == null) return; @@ -115,11 +138,97 @@ private void VirtualView_RequestEvaluateJavaScript(object sender, EvaluateJavaSc }, null); } + /// + /// Sets a cookie using properties and adds it to both + /// the WKWebView's and . + /// + private void VirtualView_RequestSetCookie(object sender, SetCookieRequestEventArgs e) + { + if (_sync == null) return; + _sync.Post((o) => + { + var properties = new NSMutableDictionary(); + properties.SetValueForKey(new NSString(e.Name), NSHttpCookie.KeyName); + properties.SetValueForKey(new NSString(e.Value), NSHttpCookie.KeyValue); + properties.SetValueForKey(new NSString(e.Domain), NSHttpCookie.KeyDomain); + properties.SetValueForKey(new NSString(e.Path), NSHttpCookie.KeyPath); + if (e.Expires.HasValue) + properties.SetValueForKey((NSDate)e.Expires.Value.UtcDateTime, NSHttpCookie.KeyExpires); + if (e.IsSecure) + properties.SetValueForKey(NSObject.FromObject(true), NSHttpCookie.KeySecure); + if (e.IsHttpOnly) + properties.SetValueForKey(NSObject.FromObject(true), new NSString("HttpOnly")); + + var cookie = NSHttpCookie.CookieFromProperties(properties); + if (cookie == null) return; + + var cookieStore = PlatformView.Configuration.WebsiteDataStore.HttpCookieStore; + cookieStore.SetCookie(cookie, null); + NSHttpCookieStorage.SharedStorage.SetCookie(cookie); + }, null); + } + + /// + /// Removes a specific cookie by matching name, domain, and path from both + /// and . + /// + private void VirtualView_RequestRemoveCookie(object sender, RemoveCookieRequestEventArgs e) + { + if (_sync == null) return; + _sync.Post((o) => + { + var cookieStore = PlatformView.Configuration.WebsiteDataStore.HttpCookieStore; + cookieStore.GetAllCookies(cookies => + { + foreach (var cookie in cookies) + { + if (cookie.Name == e.Name && cookie.Domain == e.Domain && cookie.Path == e.Path) + { + cookieStore.DeleteCookie(cookie, null); + NSHttpCookieStorage.SharedStorage.DeleteCookie(cookie); + } + } + }); + }, null); + } + + /// + /// Removes all cookies from both and . + /// + private void VirtualView_RequestRemoveAllCookies(object sender, RemoveAllCookiesRequestEventArgs e) + { + if (_sync == null) return; + _sync.Post((o) => + { + var cookieStore = PlatformView.Configuration.WebsiteDataStore.HttpCookieStore; + cookieStore.GetAllCookies(cookies => + { + foreach (var cookie in cookies) + { + cookieStore.DeleteCookie(cookie, null); + } + }); + + foreach (var cookie in NSHttpCookieStorage.SharedStorage.Cookies ?? []) + { + NSHttpCookieStorage.SharedStorage.DeleteCookie(cookie); + } + }, null); + } + + /// + /// Unsubscribes all events, disposes the KVO observer, removes user scripts + /// and script message handlers, and cleans up the JS bridge. + /// protected override void DisconnectHandler(WKWebView platformView) { base.DisconnectHandler(platformView); VirtualView.SourceChanged -= VirtualView_SourceChanged!; + VirtualView.RequestEvaluateJavaScript -= VirtualView_RequestEvaluateJavaScript!; + VirtualView.RequestSetCookie -= VirtualView_RequestSetCookie!; + VirtualView.RequestRemoveCookie -= VirtualView_RequestRemoveCookie!; + VirtualView.RequestRemoveAllCookies -= VirtualView_RequestRemoveAllCookies!; _observer?.Dispose(); _userController?.RemoveAllUserScripts(); @@ -130,6 +239,7 @@ protected override void DisconnectHandler(WKWebView platformView) } + /// Loads a (URL or HTML) into the native WKWebView. private static void LoadSource(WebViewSource source, WKWebView control) { if (source is HtmlWebViewSource html) @@ -147,6 +257,11 @@ private static void LoadSource(WebViewSource source, WKWebView control) } + /// + /// iOS JavaScript bridge implementing . + /// Receives messages from web content via window.webkit.messageHandlers.invokeAction.postMessage(data). + /// Uses to the handler to avoid preventing GC. + /// public class JSBridge : NSObject, IWKScriptMessageHandler { readonly WeakReference _extWebViewRenderer; @@ -156,6 +271,7 @@ internal JSBridge(ExtWebViewHandler hybridRenderer) _extWebViewRenderer = new WeakReference(hybridRenderer); } + /// Called by WebKit when JavaScript posts a message to the invokeAction handler. public void DidReceiveScriptMessage(WKUserContentController userContentController, WKScriptMessage message) { if (_extWebViewRenderer.TryGetTarget(out var hybridRenderer)) @@ -165,6 +281,11 @@ public void DidReceiveScriptMessage(WKUserContentController userContentControlle } } + /// + /// WKWebView navigation delegate that handles cookie syncing between + /// and , fires navigation events, and runs a + /// keep-alive timer (EvaluateJavaScript("1+1;") every 500ms) after each navigation. + /// public class NavigationDelegate : WKNavigationDelegate { NSMutableArray multiCookieArr = new NSMutableArray(); @@ -178,6 +299,10 @@ public NavigationDelegate(IExtWebView virtualView) VirtualView = virtualView; } + /// + /// Called when navigation completes. Fires navigated events and starts a keep-alive + /// timer that periodically evaluates a no-op JavaScript expression. + /// public override void DidFinishNavigation(WKWebView webView, WKNavigation navigation) { System.Diagnostics.Debug.WriteLine("DidFinishNavigation: " + webView?.Url?.ToString()); @@ -193,18 +318,24 @@ public override void DidFinishNavigation(WKWebView webView, WKNavigation navigat }); } + /// Fires the event when provisional navigation starts. public override void DidStartProvisionalNavigation(WKWebView webView, WKNavigation navigation) { VirtualView.SendNavigating(new WebNavigatingEventArgs(WebNavigationEvent.NewPage, VirtualView.Source, webView?.Url?.ToString())); } + /// Logs provisional navigation failures for debugging. public override void DidFailProvisionalNavigation(WKWebView webView, WKNavigation navigation, NSError error) { System.Diagnostics.Debug.WriteLine( $"{error.Code} {error.Domain} {error.HelpAnchor} {error.LocalizedFailureReason}"); } + /// + /// Syncs cookies from to + /// on every navigation response (iOS 12+), or parses response headers for older versions. + /// [Foundation.Export("webView:decidePolicyForNavigationResponse:decisionHandler:")] public override void DecidePolicy(WKWebView webView, WKNavigationResponse navigationResponse, [BlockProxy(typeof(Action))] Action decisionHandler) diff --git a/README.md b/README.md index 237462b..1708ab5 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,189 @@ # Extended WebView for .NET MAUI -Features: +A .NET MAUI class library providing an enhanced WebView control with JavaScript interop, navigation events, cookie management, and URL change detection for Android and iOS. -- Execute JavaScript via Evaluate JavaScript -- React to JavaScript Actions -- React to WebView Events via Event Handlers -- React to Url changes in PWAs and SPAs, thanks to https://github.com/dotnet/maui/issues/19232 \ No newline at end of file +[![NuGet](https://img.shields.io/nuget/v/Com.Bnotech.ExtendedWebView)](https://www.nuget.org/packages/Com.Bnotech.ExtendedWebView) + +## Features + +- Execute JavaScript from C# via `EvaluateJavaScriptAsync` +- Receive JavaScript calls in C# via `invokeCSharpAction(data)` bridge +- Navigation events: `Navigating`, `Navigated`, `WebViewNavigated` +- URL change detection for SPAs and PWAs via `UrlChanged` (addresses [dotnet/maui#19232](https://github.com/dotnet/maui/issues/19232)) +- Cookie management: `SetCookieAsync`, `UpdateCookieAsync`, `RemoveCookieAsync`, `RemoveAllCookiesAsync` +- Multi-window / popup support on Android +- Force reload via `Refresh()` + +## Supported Platforms + +| Platform | Minimum OS Version | +|----------|-------------------| +| Android | 21.0 | +| iOS | 12.2 | + +**Target Frameworks:** `net10.0-android`, `net10.0-ios` (MAUI 10.0.41) + +## Installation + +```bash +dotnet add package Com.Bnotech.ExtendedWebView +``` + +## Setup + +Register the handler in your `MauiProgram.cs`: + +```csharp +using Com.Bnotech.ExtendedWebView; + +public static class MauiProgram +{ + public static MauiApp CreateMauiApp() + { + var builder = MauiApp.CreateBuilder(); + builder + .UseMauiApp() + .UseExtendedWebView(); // <-- Add this + + return builder.Build(); + } +} +``` + +## Usage + +### Basic — Load a URL + +```xml + + +``` + +```csharp +using Com.Bnotech.ExtendedWebView; +``` + +Or set the source in code: + +```csharp +webView.Source = new UrlWebViewSource { Url = "https://example.com" }; +``` + +### Execute JavaScript (C# → JS) + +```csharp +var request = new EvaluateJavaScriptAsyncRequest("document.title"); +await webView.EvaluateJavaScriptAsync(request); +``` + +### Receive JavaScript Calls (JS → C#) + +The control automatically injects a `invokeCSharpAction(data)` function into every page. Subscribe to the event in C#: + +```csharp +webView.JavaScriptAction += (sender, e) => +{ + string payload = e.Payload; + // Handle the data sent from JavaScript +}; +``` + +Then call it from your web page: + +```javascript +invokeCSharpAction("Hello from JavaScript!"); +``` + +### Navigation Events + +```csharp +webView.Navigating += (sender, e) => +{ + // Fires before navigation starts +}; + +webView.Navigated += (sender, e) => +{ + // Fires after navigation completes +}; +``` + +### URL Change Detection (SPA/PWA) + +Detects route changes in single-page applications that don't trigger traditional navigation events: + +```csharp +webView.UrlChanged += (sender, e) => +{ + string newUrl = e.Url; +}; +``` + +### Cookie Management + +```csharp +// Set a cookie +await webView.SetCookieAsync( + name: "session", + value: "abc123", + domain: ".example.com", + path: "/", + expires: DateTimeOffset.UtcNow.AddDays(7), + isSecure: true, + isHttpOnly: true); + +// Update a cookie (overwrites by name/domain/path) +await webView.UpdateCookieAsync( + name: "session", + value: "newvalue", + domain: ".example.com", + path: "/"); + +// Remove a specific cookie +await webView.RemoveCookieAsync("session", ".example.com", "/"); + +// Remove all cookies +await webView.RemoveAllCookiesAsync(); +``` + +### Refresh + +Force a reload of the current source: + +```csharp +webView.Refresh(); +``` + +## API Reference + +### `ExtWebView` — Properties + +| Property | Type | Description | +|----------|------|-------------| +| `Source` | `WebViewSource` | The URL or HTML content to display. Default: `about:blank` | + +### `ExtWebView` — Methods + +| Method | Description | +|--------|-------------| +| `EvaluateJavaScriptAsync(request)` | Executes JavaScript in the WebView | +| `SetCookieAsync(...)` | Sets a cookie in the platform cookie store | +| `UpdateCookieAsync(...)` | Updates (overwrites) an existing cookie | +| `RemoveCookieAsync(name, domain, path)` | Removes a specific cookie | +| `RemoveAllCookiesAsync()` | Removes all cookies | +| `Refresh()` | Forces a reload of the current source | + +### `ExtWebView` — Events + +| Event | EventArgs | Description | +|-------|-----------|-------------| +| `JavaScriptAction` | `JavaScriptActionEventArgs` | JS called `invokeCSharpAction(data)` | +| `Navigating` | `WebNavigatingEventArgs` | Navigation is about to start | +| `Navigated` | `WebNavigatedEventArgs` | Navigation completed | +| `UrlChanged` | `UrlChangedEventArgs` | Displayed URL changed (SPA/PWA) | +| `SourceChanged` | `SourceChangedEventArgs` | `Source` property was changed | + +## License + +See [LICENSE](LICENSE).