From 14d32336ee30264152f0d51a692bc6926aba5566 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Wed, 8 Apr 2026 19:54:47 +0200 Subject: [PATCH 1/7] [Windows] Lifecycle: add app activation event Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../samples/Controls.Sample/MauiProgram.cs | 32 ++++++++++++ .../Windows/WindowsLifecycle.cs | 5 +- .../WindowsLifecycleBuilderExtensions.cs | 1 + .../Platform/Windows/MauiWinUIApplication.cs | 52 ++++++++++++++++++- .../net-windows/PublicAPI.Unshipped.txt | 6 ++- .../LifecycleEvents/LifecycleEventsTests.cs | 43 +++++++++++++++ 6 files changed, 135 insertions(+), 4 deletions(-) diff --git a/src/Controls/samples/Controls.Sample/MauiProgram.cs b/src/Controls/samples/Controls.Sample/MauiProgram.cs index b7f16cf1bd54..975a2a683177 100644 --- a/src/Controls/samples/Controls.Sample/MauiProgram.cs +++ b/src/Controls/samples/Controls.Sample/MauiProgram.cs @@ -4,6 +4,7 @@ using System.Diagnostics.Metrics; using System.Linq; using System.Runtime.CompilerServices; +using System.Threading.Tasks; using Maui.Controls.Sample.Controls; using Maui.Controls.Sample.Pages; using Maui.Controls.Sample.Services; @@ -29,6 +30,8 @@ #if ANDROID using Android.Gms.Common; using Android.Gms.Maps; +#elif WINDOWS +using Microsoft.Windows.AppLifecycle; #endif namespace Maui.Controls.Sample @@ -286,6 +289,7 @@ static string GetTags(IEnumerable> tags) => events.AddWindows(windows => windows // .OnPlatformMessage((a, b) => // LogEvent(nameof(WindowsLifecycle.OnPlatformMessage))) + .OnAppActivation((application, args) => HandleWindowsAppActivation(application, args)) .OnActivated((a, b) => LogEvent(nameof(WindowsLifecycle.OnActivated))) .OnClosed((a, b) => LogEvent(nameof(WindowsLifecycle.OnClosed))) .OnLaunched((a, b) => LogEvent(nameof(WindowsLifecycle.OnLaunched))) @@ -310,6 +314,34 @@ static bool LogEvent(string eventName, string? type = null) Debug.WriteLine($"Lifecycle event: {eventName}{(type == null ? "" : $" ({type})")}"); return true; } + +#if WINDOWS + static bool HandleWindowsAppActivation(Microsoft.UI.Xaml.Application application, AppActivationArguments args) + { + LogEvent(nameof(WindowsLifecycle.OnAppActivation), args.Kind.ToString()); + + // This sample opts into single-instancing from the MAUI lifecycle callback + // instead of a custom Program.cs entry point. + var keyInstance = AppInstance.FindOrRegisterForKey("Maui.Controls.Sample"); + + if (!keyInstance.IsCurrent) + { + // The WinAppSDK single-instance guidance redirects the activation and then + // terminates the losing instance immediately. Using Kill here avoids leaving + // a headless process around that can continue to hold build outputs open. + keyInstance.RedirectActivationToAsync(args).AsTask().GetAwaiter().GetResult(); + Process.GetCurrentProcess().Kill(); + return true; + } + + if (Application.Current?.Windows.FirstOrDefault() is Window window) + // Redirected activations can arrive off the UI thread, so hop through the + // MAUI window dispatcher before re-activating the existing window. + window.Dispatcher.Dispatch(() => Application.Current.ActivateWindow(window)); + + return false; + } +#endif }); // Adapt to dual-screen and foldable Android devices like Surface Duo, includes TwoPaneView layout control diff --git a/src/Core/src/LifecycleEvents/Windows/WindowsLifecycle.cs b/src/Core/src/LifecycleEvents/Windows/WindowsLifecycle.cs index f1fcbe71cfc6..557c63ee783d 100644 --- a/src/Core/src/LifecycleEvents/Windows/WindowsLifecycle.cs +++ b/src/Core/src/LifecycleEvents/Windows/WindowsLifecycle.cs @@ -1,7 +1,10 @@ -namespace Microsoft.Maui.LifecycleEvents +using Microsoft.Windows.AppLifecycle; + +namespace Microsoft.Maui.LifecycleEvents { public static class WindowsLifecycle { + public delegate bool OnAppActivation(UI.Xaml.Application application, AppActivationArguments args); public delegate void OnActivated(UI.Xaml.Window window, UI.Xaml.WindowActivatedEventArgs args); public delegate void OnClosed(UI.Xaml.Window window, UI.Xaml.WindowEventArgs args); public delegate void OnLaunched(UI.Xaml.Application application, UI.Xaml.LaunchActivatedEventArgs args); diff --git a/src/Core/src/LifecycleEvents/Windows/WindowsLifecycleBuilderExtensions.cs b/src/Core/src/LifecycleEvents/Windows/WindowsLifecycleBuilderExtensions.cs index eeb18a5758bd..6fb0b611899c 100644 --- a/src/Core/src/LifecycleEvents/Windows/WindowsLifecycleBuilderExtensions.cs +++ b/src/Core/src/LifecycleEvents/Windows/WindowsLifecycleBuilderExtensions.cs @@ -2,6 +2,7 @@ { public static class WindowsLifecycleBuilderExtensions { + public static IWindowsLifecycleBuilder OnAppActivation(this IWindowsLifecycleBuilder lifecycle, WindowsLifecycle.OnAppActivation del) => lifecycle.OnEvent(del); public static IWindowsLifecycleBuilder OnActivated(this IWindowsLifecycleBuilder lifecycle, WindowsLifecycle.OnActivated del) => lifecycle.OnEvent(del); public static IWindowsLifecycleBuilder OnClosed(this IWindowsLifecycleBuilder lifecycle, WindowsLifecycle.OnClosed del) => lifecycle.OnEvent(del); public static IWindowsLifecycleBuilder OnLaunching(this IWindowsLifecycleBuilder lifecycle, WindowsLifecycle.OnLaunching del) => lifecycle.OnEvent(del); diff --git a/src/Core/src/Platform/Windows/MauiWinUIApplication.cs b/src/Core/src/Platform/Windows/MauiWinUIApplication.cs index ce39fd4bd2d5..e09b650d0e71 100644 --- a/src/Core/src/Platform/Windows/MauiWinUIApplication.cs +++ b/src/Core/src/Platform/Windows/MauiWinUIApplication.cs @@ -1,7 +1,8 @@ -using System; +using System; using Microsoft.Extensions.DependencyInjection; using Microsoft.Maui.Hosting; using Microsoft.Maui.LifecycleEvents; +using Microsoft.Windows.AppLifecycle; namespace Microsoft.Maui { @@ -20,9 +21,17 @@ public abstract class MauiWinUIApplication : UI.Xaml.Application, IPlatformAppli protected override void OnLaunched(UI.Xaml.LaunchActivatedEventArgs args) { - // Windows running on a different thread will "launch" the app again + LaunchActivatedEventArgs = args; + + var launchActivation = AppInstance.GetCurrent().GetActivatedEventArgs(); + + // A running WinUI app can be activated again without rebuilding the MAUI application. + // Reuse the existing services and let activation handlers short-circuit the relaunch path. if (_application != null && _services != null) { + if (launchActivation is AppActivationArguments activatedEventArgs && OnAppActivation(activatedEventArgs)) + return; + _services.InvokeLifecycleEvents(del => del(this, args)); _services.InvokeLifecycleEvents(del => del(this, args)); return; @@ -37,6 +46,14 @@ protected override void OnLaunched(UI.Xaml.LaunchActivatedEventArgs args) _services = applicationContext.Services; + // Future AppInstance activation callbacks need the app-level services to exist first. + RegisterForAppActivation(); + + // Run the initial activation after services are available, but before OnLaunching/window creation, + // so handlers can redirect or suppress the default startup flow. + if (launchActivation is AppActivationArguments initialActivation && OnAppActivation(initialActivation)) + return; + _services.InvokeLifecycleEvents(del => del(this, args)); _application = _services.GetRequiredService(); @@ -48,10 +65,41 @@ protected override void OnLaunched(UI.Xaml.LaunchActivatedEventArgs args) _services.InvokeLifecycleEvents(del => del(this, args)); } + protected virtual bool OnAppActivation(AppActivationArguments args) + { + var wasHandled = false; + + _services?.InvokeLifecycleEvents(del => + { + // Preserve any earlier "handled" result so multiple listeners can participate safely. + wasHandled = del(this, args) || wasHandled; + }); + + return wasHandled; + } + + void RegisterForAppActivation() + { + if (_isRegisteredForAppActivation) + return; + + _isRegisteredForAppActivation = true; + + // After startup, later file/protocol/redirected activations are delivered through AppInstance. + AppInstance.GetCurrent().Activated += OnAppInstanceActivated; + + void OnAppInstanceActivated(object? sender, AppActivationArguments args) + { + OnAppActivation(args); + } + } + public static new MauiWinUIApplication Current => (MauiWinUIApplication)UI.Xaml.Application.Current; public UI.Xaml.LaunchActivatedEventArgs LaunchActivatedEventArgs { get; protected set; } = null!; + bool _isRegisteredForAppActivation; + IServiceProvider? _services; IApplication? _application; diff --git a/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt index f02df3a74f88..3dbc9916393e 100644 --- a/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt @@ -1,2 +1,6 @@ -#nullable enable +#nullable enable +Microsoft.Maui.LifecycleEvents.WindowsLifecycle.OnAppActivation override Microsoft.Maui.Platform.MauiPasswordTextBox.OnCreateAutomationPeer() -> Microsoft.UI.Xaml.Automation.Peers.AutomationPeer! +static Microsoft.Maui.LifecycleEvents.WindowsLifecycleBuilderExtensions.OnAppActivation(this Microsoft.Maui.LifecycleEvents.IWindowsLifecycleBuilder! lifecycle, Microsoft.Maui.LifecycleEvents.WindowsLifecycle.OnAppActivation! del) -> Microsoft.Maui.LifecycleEvents.IWindowsLifecycleBuilder! +virtual Microsoft.Maui.LifecycleEvents.WindowsLifecycle.OnAppActivation.Invoke(Microsoft.UI.Xaml.Application! application, Microsoft.Windows.AppLifecycle.AppActivationArguments! args) -> bool +virtual Microsoft.Maui.MauiWinUIApplication.OnAppActivation(Microsoft.Windows.AppLifecycle.AppActivationArguments! args) -> bool diff --git a/src/Core/tests/UnitTests/LifecycleEvents/LifecycleEventsTests.cs b/src/Core/tests/UnitTests/LifecycleEvents/LifecycleEventsTests.cs index ae08390f4e03..e0791a48f099 100644 --- a/src/Core/tests/UnitTests/LifecycleEvents/LifecycleEventsTests.cs +++ b/src/Core/tests/UnitTests/LifecycleEvents/LifecycleEventsTests.cs @@ -161,6 +161,49 @@ public void CanAddMultipleEventsViaBuilder() Assert.Equal(1, event2Fired); } +#if WINDOWS + [Fact] + public void CanAddWindowsOnAppActivationLifecycleEvent() + { + var firstHandlerCalled = false; + var secondHandlerCalled = false; + var wasHandled = false; + + var mauiApp = MauiApp.CreateBuilder() + .ConfigureLifecycleEvents(builder => + { + builder.AddWindows(windows => + { + windows.OnAppActivation((application, args) => + { + firstHandlerCalled = true; + return false; + }); + + windows.OnAppActivation((application, args) => + { + secondHandlerCalled = true; + return true; + }); + }); + }) + .Build(); + + var service = mauiApp.Services.GetRequiredService(); + + Assert.True(service.ContainsEvent(nameof(WindowsLifecycle.OnAppActivation))); + + service.InvokeEvents(nameof(WindowsLifecycle.OnAppActivation), del => + { + wasHandled = del(null!, null!) || wasHandled; + }); + + Assert.True(firstHandlerCalled); + Assert.True(secondHandlerCalled); + Assert.True(wasHandled); + } +#endif + #if ANDROID [Fact] public void CanAddAndroidOnKeyDownLifecycleEvent() From 69050ebdedc336dba47f331dae550ad2b3c011d8 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Wed, 8 Apr 2026 23:47:07 +0200 Subject: [PATCH 2/7] Add Windows lifecycle event order device tests Register OnAppActivation, OnLaunching, OnWindowCreated, and OnLaunched handlers during Core.DeviceTests startup that record event names into a static log. Three automated tests validate that all four events fire, fire in the correct order, and OnAppActivation fires exactly once. This replaces the unit test that was behind #if WINDOWS in a net10.0 console project (which never actually ran) with real device tests that execute in a Windows app context. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DeviceTests.Shared/MauiProgramDefaults.cs | 4 +- .../LifecycleEventOrderTests.Windows.cs | 47 +++++++++++++++++++ src/Core/tests/DeviceTests/MauiProgram.cs | 28 +++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 src/Core/tests/DeviceTests/LifecycleEventOrderTests.Windows.cs diff --git a/src/Core/tests/DeviceTests.Shared/MauiProgramDefaults.cs b/src/Core/tests/DeviceTests.Shared/MauiProgramDefaults.cs index 2c32b2c65f17..05de74e8b89e 100644 --- a/src/Core/tests/DeviceTests.Shared/MauiProgramDefaults.cs +++ b/src/Core/tests/DeviceTests.Shared/MauiProgramDefaults.cs @@ -27,7 +27,7 @@ public static MauiApp CreateMauiApp(List testAssemblies) }); } - public static MauiApp CreateMauiApp(Func options) + public static MauiApp CreateMauiApp(Func options, Action configureBuilder = null) { var appBuilder = MauiApp.CreateBuilder(); @@ -95,6 +95,8 @@ public static MauiApp CreateMauiApp(Func options) ValidateScopes = true, })); + configureBuilder?.Invoke(appBuilder); + var mauiApp = appBuilder.Build(); DefaultTestApp = mauiApp.Services.GetRequiredService(); diff --git a/src/Core/tests/DeviceTests/LifecycleEventOrderTests.Windows.cs b/src/Core/tests/DeviceTests/LifecycleEventOrderTests.Windows.cs new file mode 100644 index 000000000000..209c3e76abe9 --- /dev/null +++ b/src/Core/tests/DeviceTests/LifecycleEventOrderTests.Windows.cs @@ -0,0 +1,47 @@ +using System.Linq; +using Microsoft.Maui.LifecycleEvents; +using Xunit; + +namespace Microsoft.Maui.DeviceTests +{ + [Category(TestCategory.Application)] + public class LifecycleEventOrderTests + { + [Fact(DisplayName = "Windows lifecycle events fire during startup")] + public void WindowsLifecycleEventsFireDuringStartup() + { + var log = MauiProgram.LifecycleEventLog; + + Assert.Contains(nameof(WindowsLifecycle.OnAppActivation), log); + Assert.Contains(nameof(WindowsLifecycle.OnLaunching), log); + Assert.Contains(nameof(WindowsLifecycle.OnLaunched), log); + Assert.Contains(nameof(WindowsLifecycle.OnWindowCreated), log); + } + + [Fact(DisplayName = "Windows lifecycle events fire in correct order")] + public void WindowsLifecycleEventsFireInCorrectOrder() + { + var log = MauiProgram.LifecycleEventLog; + + var activationIndex = log.IndexOf(nameof(WindowsLifecycle.OnAppActivation)); + var launchingIndex = log.IndexOf(nameof(WindowsLifecycle.OnLaunching)); + var windowCreatedIndex = log.IndexOf(nameof(WindowsLifecycle.OnWindowCreated)); + var launchedIndex = log.IndexOf(nameof(WindowsLifecycle.OnLaunched)); + + // Expected startup order: OnAppActivation → OnLaunching → OnWindowCreated → OnLaunched + Assert.True(activationIndex < launchingIndex, + $"Expected OnAppActivation before OnLaunching. Log: [{string.Join(", ", log)}]"); + Assert.True(launchingIndex < windowCreatedIndex, + $"Expected OnLaunching before OnWindowCreated. Log: [{string.Join(", ", log)}]"); + Assert.True(windowCreatedIndex < launchedIndex, + $"Expected OnWindowCreated before OnLaunched. Log: [{string.Join(", ", log)}]"); + } + + [Fact(DisplayName = "OnAppActivation fires exactly once during startup")] + public void OnAppActivationFiresExactlyOnce() + { + var count = MauiProgram.LifecycleEventLog.Count(e => e == nameof(WindowsLifecycle.OnAppActivation)); + Assert.Equal(1, count); + } + } +} diff --git a/src/Core/tests/DeviceTests/MauiProgram.cs b/src/Core/tests/DeviceTests/MauiProgram.cs index 7998575a036a..9e9e28ab2a50 100644 --- a/src/Core/tests/DeviceTests/MauiProgram.cs +++ b/src/Core/tests/DeviceTests/MauiProgram.cs @@ -12,6 +12,12 @@ public static class MauiProgram public static global::Android.Content.Context DefaultContext => MauiProgramDefaults.DefaultContext; #elif WINDOWS public static UI.Xaml.Window DefaultWindow => MauiProgramDefaults.DefaultWindow; + + /// + /// Records Windows lifecycle event names in the order they fire during app startup. + /// Used by LifecycleEventOrderTests to validate event ordering. + /// + public static List LifecycleEventLog { get; } = new(); #endif public static IApplication DefaultTestApp { get; private set; } @@ -29,6 +35,28 @@ public static MauiApp CreateMauiApp() => }; return options; + }, + configureBuilder: builder => + { +#if WINDOWS + builder.ConfigureLifecycleEvents(life => + { + life.AddWindows(windows => + { + windows.OnAppActivation((app, args) => + { + LifecycleEventLog.Add(nameof(WindowsLifecycle.OnAppActivation)); + return false; + }); + windows.OnLaunching((app, args) => + LifecycleEventLog.Add(nameof(WindowsLifecycle.OnLaunching))); + windows.OnLaunched((app, args) => + LifecycleEventLog.Add(nameof(WindowsLifecycle.OnLaunched))); + windows.OnWindowCreated(w => + LifecycleEventLog.Add(nameof(WindowsLifecycle.OnWindowCreated))); + }); + }); +#endif }); } } \ No newline at end of file From ec30eb113c9d69ec87a3948b9a00ef787c1ec790 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Wed, 8 Apr 2026 23:55:49 +0200 Subject: [PATCH 3/7] Rename OnAppActivation to OnAppInstanceActivated, add cross-platform lifecycle tests Rename the lifecycle delegate and all references from OnAppActivation to OnAppInstanceActivated to match the underlying AppInstance.Activated event name. Remove the #if WINDOWS unit test from LifecycleEventsTests.cs that never actually ran in the net10.0 console test leg. Add cross-platform lifecycle event order device tests: - Windows: OnAppInstanceActivated -> OnLaunching -> OnWindowCreated -> OnLaunched - Android: OnCreate -> OnStart -> OnResume - iOS/MacCatalyst: FinishedLaunching -> OnActivated Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../samples/Controls.Sample/MauiProgram.cs | 6 +-- .../Windows/WindowsLifecycle.cs | 2 +- .../WindowsLifecycleBuilderExtensions.cs | 2 +- .../Platform/Windows/MauiWinUIApplication.cs | 24 +++++------ .../net-windows/PublicAPI.Unshipped.txt | 8 ++-- .../LifecycleEventOrderTests.Android.cs | 43 +++++++++++++++++++ .../LifecycleEventOrderTests.Windows.cs | 18 ++++---- .../LifecycleEventOrderTests.iOS.cs | 41 ++++++++++++++++++ src/Core/tests/DeviceTests/MauiProgram.cs | 35 ++++++++++++--- src/Core/tests/DeviceTests/TestCategory.cs | 1 + .../LifecycleEvents/LifecycleEventsTests.cs | 43 ------------------- 11 files changed, 144 insertions(+), 79 deletions(-) create mode 100644 src/Core/tests/DeviceTests/LifecycleEventOrderTests.Android.cs create mode 100644 src/Core/tests/DeviceTests/LifecycleEventOrderTests.iOS.cs diff --git a/src/Controls/samples/Controls.Sample/MauiProgram.cs b/src/Controls/samples/Controls.Sample/MauiProgram.cs index 975a2a683177..a15ecad8e2ee 100644 --- a/src/Controls/samples/Controls.Sample/MauiProgram.cs +++ b/src/Controls/samples/Controls.Sample/MauiProgram.cs @@ -289,7 +289,7 @@ static string GetTags(IEnumerable> tags) => events.AddWindows(windows => windows // .OnPlatformMessage((a, b) => // LogEvent(nameof(WindowsLifecycle.OnPlatformMessage))) - .OnAppActivation((application, args) => HandleWindowsAppActivation(application, args)) + .OnAppInstanceActivated((application, args) => HandleWindowsAppInstanceActivated(application, args)) .OnActivated((a, b) => LogEvent(nameof(WindowsLifecycle.OnActivated))) .OnClosed((a, b) => LogEvent(nameof(WindowsLifecycle.OnClosed))) .OnLaunched((a, b) => LogEvent(nameof(WindowsLifecycle.OnLaunched))) @@ -316,9 +316,9 @@ static bool LogEvent(string eventName, string? type = null) } #if WINDOWS - static bool HandleWindowsAppActivation(Microsoft.UI.Xaml.Application application, AppActivationArguments args) + static bool HandleWindowsAppInstanceActivated(Microsoft.UI.Xaml.Application application, AppActivationArguments args) { - LogEvent(nameof(WindowsLifecycle.OnAppActivation), args.Kind.ToString()); + LogEvent(nameof(WindowsLifecycle.OnAppInstanceActivated), args.Kind.ToString()); // This sample opts into single-instancing from the MAUI lifecycle callback // instead of a custom Program.cs entry point. diff --git a/src/Core/src/LifecycleEvents/Windows/WindowsLifecycle.cs b/src/Core/src/LifecycleEvents/Windows/WindowsLifecycle.cs index 557c63ee783d..34a815a443bd 100644 --- a/src/Core/src/LifecycleEvents/Windows/WindowsLifecycle.cs +++ b/src/Core/src/LifecycleEvents/Windows/WindowsLifecycle.cs @@ -4,7 +4,7 @@ namespace Microsoft.Maui.LifecycleEvents { public static class WindowsLifecycle { - public delegate bool OnAppActivation(UI.Xaml.Application application, AppActivationArguments args); + public delegate bool OnAppInstanceActivated(UI.Xaml.Application application, AppActivationArguments args); public delegate void OnActivated(UI.Xaml.Window window, UI.Xaml.WindowActivatedEventArgs args); public delegate void OnClosed(UI.Xaml.Window window, UI.Xaml.WindowEventArgs args); public delegate void OnLaunched(UI.Xaml.Application application, UI.Xaml.LaunchActivatedEventArgs args); diff --git a/src/Core/src/LifecycleEvents/Windows/WindowsLifecycleBuilderExtensions.cs b/src/Core/src/LifecycleEvents/Windows/WindowsLifecycleBuilderExtensions.cs index 6fb0b611899c..5d14f9c36a69 100644 --- a/src/Core/src/LifecycleEvents/Windows/WindowsLifecycleBuilderExtensions.cs +++ b/src/Core/src/LifecycleEvents/Windows/WindowsLifecycleBuilderExtensions.cs @@ -2,7 +2,7 @@ { public static class WindowsLifecycleBuilderExtensions { - public static IWindowsLifecycleBuilder OnAppActivation(this IWindowsLifecycleBuilder lifecycle, WindowsLifecycle.OnAppActivation del) => lifecycle.OnEvent(del); + public static IWindowsLifecycleBuilder OnAppInstanceActivated(this IWindowsLifecycleBuilder lifecycle, WindowsLifecycle.OnAppInstanceActivated del) => lifecycle.OnEvent(del); public static IWindowsLifecycleBuilder OnActivated(this IWindowsLifecycleBuilder lifecycle, WindowsLifecycle.OnActivated del) => lifecycle.OnEvent(del); public static IWindowsLifecycleBuilder OnClosed(this IWindowsLifecycleBuilder lifecycle, WindowsLifecycle.OnClosed del) => lifecycle.OnEvent(del); public static IWindowsLifecycleBuilder OnLaunching(this IWindowsLifecycleBuilder lifecycle, WindowsLifecycle.OnLaunching del) => lifecycle.OnEvent(del); diff --git a/src/Core/src/Platform/Windows/MauiWinUIApplication.cs b/src/Core/src/Platform/Windows/MauiWinUIApplication.cs index e09b650d0e71..63dc70c43b55 100644 --- a/src/Core/src/Platform/Windows/MauiWinUIApplication.cs +++ b/src/Core/src/Platform/Windows/MauiWinUIApplication.cs @@ -29,7 +29,7 @@ protected override void OnLaunched(UI.Xaml.LaunchActivatedEventArgs args) // Reuse the existing services and let activation handlers short-circuit the relaunch path. if (_application != null && _services != null) { - if (launchActivation is AppActivationArguments activatedEventArgs && OnAppActivation(activatedEventArgs)) + if (launchActivation is AppActivationArguments activatedEventArgs && OnAppInstanceActivated(activatedEventArgs)) return; _services.InvokeLifecycleEvents(del => del(this, args)); @@ -47,11 +47,11 @@ protected override void OnLaunched(UI.Xaml.LaunchActivatedEventArgs args) _services = applicationContext.Services; // Future AppInstance activation callbacks need the app-level services to exist first. - RegisterForAppActivation(); + RegisterForAppInstanceActivated(); // Run the initial activation after services are available, but before OnLaunching/window creation, // so handlers can redirect or suppress the default startup flow. - if (launchActivation is AppActivationArguments initialActivation && OnAppActivation(initialActivation)) + if (launchActivation is AppActivationArguments initialActivation && OnAppInstanceActivated(initialActivation)) return; _services.InvokeLifecycleEvents(del => del(this, args)); @@ -65,11 +65,11 @@ protected override void OnLaunched(UI.Xaml.LaunchActivatedEventArgs args) _services.InvokeLifecycleEvents(del => del(this, args)); } - protected virtual bool OnAppActivation(AppActivationArguments args) + protected virtual bool OnAppInstanceActivated(AppActivationArguments args) { var wasHandled = false; - _services?.InvokeLifecycleEvents(del => + _services?.InvokeLifecycleEvents(del => { // Preserve any earlier "handled" result so multiple listeners can participate safely. wasHandled = del(this, args) || wasHandled; @@ -78,19 +78,19 @@ protected virtual bool OnAppActivation(AppActivationArguments args) return wasHandled; } - void RegisterForAppActivation() + void RegisterForAppInstanceActivated() { - if (_isRegisteredForAppActivation) + if (_isRegisteredForAppInstanceActivated) return; - _isRegisteredForAppActivation = true; + _isRegisteredForAppInstanceActivated = true; // After startup, later file/protocol/redirected activations are delivered through AppInstance. - AppInstance.GetCurrent().Activated += OnAppInstanceActivated; + AppInstance.GetCurrent().Activated += HandleAppInstanceActivated; - void OnAppInstanceActivated(object? sender, AppActivationArguments args) + void HandleAppInstanceActivated(object? sender, AppActivationArguments args) { - OnAppActivation(args); + OnAppInstanceActivated(args); } } @@ -98,7 +98,7 @@ void OnAppInstanceActivated(object? sender, AppActivationArguments args) public UI.Xaml.LaunchActivatedEventArgs LaunchActivatedEventArgs { get; protected set; } = null!; - bool _isRegisteredForAppActivation; + bool _isRegisteredForAppInstanceActivated; IServiceProvider? _services; diff --git a/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt index 3dbc9916393e..f91aee90dd21 100644 --- a/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt @@ -1,6 +1,6 @@ #nullable enable -Microsoft.Maui.LifecycleEvents.WindowsLifecycle.OnAppActivation +Microsoft.Maui.LifecycleEvents.WindowsLifecycle.OnAppInstanceActivated override Microsoft.Maui.Platform.MauiPasswordTextBox.OnCreateAutomationPeer() -> Microsoft.UI.Xaml.Automation.Peers.AutomationPeer! -static Microsoft.Maui.LifecycleEvents.WindowsLifecycleBuilderExtensions.OnAppActivation(this Microsoft.Maui.LifecycleEvents.IWindowsLifecycleBuilder! lifecycle, Microsoft.Maui.LifecycleEvents.WindowsLifecycle.OnAppActivation! del) -> Microsoft.Maui.LifecycleEvents.IWindowsLifecycleBuilder! -virtual Microsoft.Maui.LifecycleEvents.WindowsLifecycle.OnAppActivation.Invoke(Microsoft.UI.Xaml.Application! application, Microsoft.Windows.AppLifecycle.AppActivationArguments! args) -> bool -virtual Microsoft.Maui.MauiWinUIApplication.OnAppActivation(Microsoft.Windows.AppLifecycle.AppActivationArguments! args) -> bool +static Microsoft.Maui.LifecycleEvents.WindowsLifecycleBuilderExtensions.OnAppInstanceActivated(this Microsoft.Maui.LifecycleEvents.IWindowsLifecycleBuilder! lifecycle, Microsoft.Maui.LifecycleEvents.WindowsLifecycle.OnAppInstanceActivated! del) -> Microsoft.Maui.LifecycleEvents.IWindowsLifecycleBuilder! +virtual Microsoft.Maui.LifecycleEvents.WindowsLifecycle.OnAppInstanceActivated.Invoke(Microsoft.UI.Xaml.Application! application, Microsoft.Windows.AppLifecycle.AppActivationArguments! args) -> bool +virtual Microsoft.Maui.MauiWinUIApplication.OnAppInstanceActivated(Microsoft.Windows.AppLifecycle.AppActivationArguments! args) -> bool diff --git a/src/Core/tests/DeviceTests/LifecycleEventOrderTests.Android.cs b/src/Core/tests/DeviceTests/LifecycleEventOrderTests.Android.cs new file mode 100644 index 000000000000..a2cfdac9033b --- /dev/null +++ b/src/Core/tests/DeviceTests/LifecycleEventOrderTests.Android.cs @@ -0,0 +1,43 @@ +using System.Linq; +using Microsoft.Maui.LifecycleEvents; +using Xunit; + +namespace Microsoft.Maui.DeviceTests +{ + [Category(TestCategory.Lifecycle)] + public class LifecycleEventOrderTests + { + [Fact(DisplayName = "Android lifecycle events fire during startup")] + public void AndroidLifecycleEventsFireDuringStartup() + { + var log = MauiProgram.LifecycleEventLog; + + Assert.Contains(nameof(AndroidLifecycle.OnCreate), log); + Assert.Contains(nameof(AndroidLifecycle.OnStart), log); + Assert.Contains(nameof(AndroidLifecycle.OnResume), log); + } + + [Fact(DisplayName = "Android lifecycle events fire in correct order")] + public void AndroidLifecycleEventsFireInCorrectOrder() + { + var log = MauiProgram.LifecycleEventLog; + + var createIndex = log.IndexOf(nameof(AndroidLifecycle.OnCreate)); + var startIndex = log.IndexOf(nameof(AndroidLifecycle.OnStart)); + var resumeIndex = log.IndexOf(nameof(AndroidLifecycle.OnResume)); + + // Expected startup order: OnCreate → OnStart → OnResume + Assert.True(createIndex < startIndex, + $"Expected OnCreate before OnStart. Log: [{string.Join(", ", log)}]"); + Assert.True(startIndex < resumeIndex, + $"Expected OnStart before OnResume. Log: [{string.Join(", ", log)}]"); + } + + [Fact(DisplayName = "OnCreate fires exactly once during startup")] + public void OnCreateFiresExactlyOnce() + { + var count = MauiProgram.LifecycleEventLog.Count(e => e == nameof(AndroidLifecycle.OnCreate)); + Assert.Equal(1, count); + } + } +} diff --git a/src/Core/tests/DeviceTests/LifecycleEventOrderTests.Windows.cs b/src/Core/tests/DeviceTests/LifecycleEventOrderTests.Windows.cs index 209c3e76abe9..732e3c7ae0d8 100644 --- a/src/Core/tests/DeviceTests/LifecycleEventOrderTests.Windows.cs +++ b/src/Core/tests/DeviceTests/LifecycleEventOrderTests.Windows.cs @@ -4,7 +4,7 @@ namespace Microsoft.Maui.DeviceTests { - [Category(TestCategory.Application)] + [Category(TestCategory.Lifecycle)] public class LifecycleEventOrderTests { [Fact(DisplayName = "Windows lifecycle events fire during startup")] @@ -12,7 +12,7 @@ public void WindowsLifecycleEventsFireDuringStartup() { var log = MauiProgram.LifecycleEventLog; - Assert.Contains(nameof(WindowsLifecycle.OnAppActivation), log); + Assert.Contains(nameof(WindowsLifecycle.OnAppInstanceActivated), log); Assert.Contains(nameof(WindowsLifecycle.OnLaunching), log); Assert.Contains(nameof(WindowsLifecycle.OnLaunched), log); Assert.Contains(nameof(WindowsLifecycle.OnWindowCreated), log); @@ -23,24 +23,24 @@ public void WindowsLifecycleEventsFireInCorrectOrder() { var log = MauiProgram.LifecycleEventLog; - var activationIndex = log.IndexOf(nameof(WindowsLifecycle.OnAppActivation)); + var activatedIndex = log.IndexOf(nameof(WindowsLifecycle.OnAppInstanceActivated)); var launchingIndex = log.IndexOf(nameof(WindowsLifecycle.OnLaunching)); var windowCreatedIndex = log.IndexOf(nameof(WindowsLifecycle.OnWindowCreated)); var launchedIndex = log.IndexOf(nameof(WindowsLifecycle.OnLaunched)); - // Expected startup order: OnAppActivation → OnLaunching → OnWindowCreated → OnLaunched - Assert.True(activationIndex < launchingIndex, - $"Expected OnAppActivation before OnLaunching. Log: [{string.Join(", ", log)}]"); + // Expected startup order: OnAppInstanceActivated → OnLaunching → OnWindowCreated → OnLaunched + Assert.True(activatedIndex < launchingIndex, + $"Expected OnAppInstanceActivated before OnLaunching. Log: [{string.Join(", ", log)}]"); Assert.True(launchingIndex < windowCreatedIndex, $"Expected OnLaunching before OnWindowCreated. Log: [{string.Join(", ", log)}]"); Assert.True(windowCreatedIndex < launchedIndex, $"Expected OnWindowCreated before OnLaunched. Log: [{string.Join(", ", log)}]"); } - [Fact(DisplayName = "OnAppActivation fires exactly once during startup")] - public void OnAppActivationFiresExactlyOnce() + [Fact(DisplayName = "OnAppInstanceActivated fires exactly once during startup")] + public void OnAppInstanceActivatedFiresExactlyOnce() { - var count = MauiProgram.LifecycleEventLog.Count(e => e == nameof(WindowsLifecycle.OnAppActivation)); + var count = MauiProgram.LifecycleEventLog.Count(e => e == nameof(WindowsLifecycle.OnAppInstanceActivated)); Assert.Equal(1, count); } } diff --git a/src/Core/tests/DeviceTests/LifecycleEventOrderTests.iOS.cs b/src/Core/tests/DeviceTests/LifecycleEventOrderTests.iOS.cs new file mode 100644 index 000000000000..ba5d87f967e6 --- /dev/null +++ b/src/Core/tests/DeviceTests/LifecycleEventOrderTests.iOS.cs @@ -0,0 +1,41 @@ +using System.Linq; +using Microsoft.Maui.LifecycleEvents; +using Xunit; + +namespace Microsoft.Maui.DeviceTests +{ + [Category(TestCategory.Lifecycle)] + public class LifecycleEventOrderTests + { + [Fact(DisplayName = "iOS lifecycle events fire during startup")] + public void iOSLifecycleEventsFireDuringStartup() + { + var log = MauiProgram.LifecycleEventLog; + + Assert.Contains(nameof(iOSLifecycle.FinishedLaunching), log); + } + + [Fact(DisplayName = "FinishedLaunching fires before OnActivated")] + public void FinishedLaunchingFiresBeforeOnActivated() + { + var log = MauiProgram.LifecycleEventLog; + + var launchIndex = log.IndexOf(nameof(iOSLifecycle.FinishedLaunching)); + Assert.True(launchIndex >= 0, "FinishedLaunching should have fired"); + + var activatedIndex = log.IndexOf(nameof(iOSLifecycle.OnActivated)); + if (activatedIndex >= 0) + { + Assert.True(launchIndex < activatedIndex, + $"Expected FinishedLaunching before OnActivated. Log: [{string.Join(", ", log)}]"); + } + } + + [Fact(DisplayName = "FinishedLaunching fires exactly once during startup")] + public void FinishedLaunchingFiresExactlyOnce() + { + var count = MauiProgram.LifecycleEventLog.Count(e => e == nameof(iOSLifecycle.FinishedLaunching)); + Assert.Equal(1, count); + } + } +} diff --git a/src/Core/tests/DeviceTests/MauiProgram.cs b/src/Core/tests/DeviceTests/MauiProgram.cs index 9e9e28ab2a50..5b972e86fb54 100644 --- a/src/Core/tests/DeviceTests/MauiProgram.cs +++ b/src/Core/tests/DeviceTests/MauiProgram.cs @@ -12,13 +12,13 @@ public static class MauiProgram public static global::Android.Content.Context DefaultContext => MauiProgramDefaults.DefaultContext; #elif WINDOWS public static UI.Xaml.Window DefaultWindow => MauiProgramDefaults.DefaultWindow; +#endif /// - /// Records Windows lifecycle event names in the order they fire during app startup. + /// Records platform lifecycle event names in the order they fire during app startup. /// Used by LifecycleEventOrderTests to validate event ordering. /// public static List LifecycleEventLog { get; } = new(); -#endif public static IApplication DefaultTestApp { get; private set; } @@ -38,14 +38,37 @@ public static MauiApp CreateMauiApp() => }, configureBuilder: builder => { -#if WINDOWS builder.ConfigureLifecycleEvents(life => { +#if ANDROID + life.AddAndroid(android => + { + android.OnCreate((a, b) => + LifecycleEventLog.Add(nameof(AndroidLifecycle.OnCreate))); + android.OnStart(a => + LifecycleEventLog.Add(nameof(AndroidLifecycle.OnStart))); + android.OnResume(a => + LifecycleEventLog.Add(nameof(AndroidLifecycle.OnResume))); + android.OnPostResume(a => + LifecycleEventLog.Add(nameof(AndroidLifecycle.OnPostResume))); + }); +#elif IOS || MACCATALYST + life.AddiOS(ios => + { + ios.FinishedLaunching((app, options) => + { + LifecycleEventLog.Add(nameof(iOSLifecycle.FinishedLaunching)); + return true; + }); + ios.OnActivated(app => + LifecycleEventLog.Add(nameof(iOSLifecycle.OnActivated))); + }); +#elif WINDOWS life.AddWindows(windows => { - windows.OnAppActivation((app, args) => + windows.OnAppInstanceActivated((app, args) => { - LifecycleEventLog.Add(nameof(WindowsLifecycle.OnAppActivation)); + LifecycleEventLog.Add(nameof(WindowsLifecycle.OnAppInstanceActivated)); return false; }); windows.OnLaunching((app, args) => @@ -55,8 +78,8 @@ public static MauiApp CreateMauiApp() => windows.OnWindowCreated(w => LifecycleEventLog.Add(nameof(WindowsLifecycle.OnWindowCreated))); }); - }); #endif + }); }); } } \ No newline at end of file diff --git a/src/Core/tests/DeviceTests/TestCategory.cs b/src/Core/tests/DeviceTests/TestCategory.cs index 3c0f2ebdadcc..1670a6743e64 100644 --- a/src/Core/tests/DeviceTests/TestCategory.cs +++ b/src/Core/tests/DeviceTests/TestCategory.cs @@ -51,5 +51,6 @@ public static class TestCategory public const string View = "View"; public const string WebView = "WebView"; public const string Window = "Window"; + public const string Lifecycle = "Lifecycle"; } } \ No newline at end of file diff --git a/src/Core/tests/UnitTests/LifecycleEvents/LifecycleEventsTests.cs b/src/Core/tests/UnitTests/LifecycleEvents/LifecycleEventsTests.cs index e0791a48f099..ae08390f4e03 100644 --- a/src/Core/tests/UnitTests/LifecycleEvents/LifecycleEventsTests.cs +++ b/src/Core/tests/UnitTests/LifecycleEvents/LifecycleEventsTests.cs @@ -161,49 +161,6 @@ public void CanAddMultipleEventsViaBuilder() Assert.Equal(1, event2Fired); } -#if WINDOWS - [Fact] - public void CanAddWindowsOnAppActivationLifecycleEvent() - { - var firstHandlerCalled = false; - var secondHandlerCalled = false; - var wasHandled = false; - - var mauiApp = MauiApp.CreateBuilder() - .ConfigureLifecycleEvents(builder => - { - builder.AddWindows(windows => - { - windows.OnAppActivation((application, args) => - { - firstHandlerCalled = true; - return false; - }); - - windows.OnAppActivation((application, args) => - { - secondHandlerCalled = true; - return true; - }); - }); - }) - .Build(); - - var service = mauiApp.Services.GetRequiredService(); - - Assert.True(service.ContainsEvent(nameof(WindowsLifecycle.OnAppActivation))); - - service.InvokeEvents(nameof(WindowsLifecycle.OnAppActivation), del => - { - wasHandled = del(null!, null!) || wasHandled; - }); - - Assert.True(firstHandlerCalled); - Assert.True(secondHandlerCalled); - Assert.True(wasHandled); - } -#endif - #if ANDROID [Fact] public void CanAddAndroidOnKeyDownLifecycleEvent() From e4b684f22f0a231993d44352c05d01d11c0757d1 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Thu, 9 Apr 2026 02:25:50 +0200 Subject: [PATCH 4/7] Clear LifecycleEventLog at startup for test stability Address review feedback: clear the static event log at the start of CreateMauiApp() so test host reuse cannot cause false failures in the exactly-once assertions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Core/tests/DeviceTests/MauiProgram.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Core/tests/DeviceTests/MauiProgram.cs b/src/Core/tests/DeviceTests/MauiProgram.cs index 5b972e86fb54..aac0f4820228 100644 --- a/src/Core/tests/DeviceTests/MauiProgram.cs +++ b/src/Core/tests/DeviceTests/MauiProgram.cs @@ -22,8 +22,11 @@ public static class MauiProgram public static IApplication DefaultTestApp { get; private set; } - public static MauiApp CreateMauiApp() => - MauiProgramDefaults.CreateMauiApp((sp) => + public static MauiApp CreateMauiApp() + { + LifecycleEventLog.Clear(); + + return MauiProgramDefaults.CreateMauiApp((sp) => { var options = new TestOptions { @@ -81,5 +84,6 @@ public static MauiApp CreateMauiApp() => #endif }); }); + } } } \ No newline at end of file From b27df66f295390438c91eebb90c979da352c3aea Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Thu, 9 Apr 2026 02:30:42 +0200 Subject: [PATCH 5/7] Show alert when app is re-activated via redirect Display a user-visible alert in the sample app when a redirected activation brings the window to the foreground, so testers can confirm single-instance behavior without a debugger. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../samples/Controls.Sample/MauiProgram.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Controls/samples/Controls.Sample/MauiProgram.cs b/src/Controls/samples/Controls.Sample/MauiProgram.cs index a15ecad8e2ee..bce38fce23dc 100644 --- a/src/Controls/samples/Controls.Sample/MauiProgram.cs +++ b/src/Controls/samples/Controls.Sample/MauiProgram.cs @@ -335,9 +335,21 @@ static bool HandleWindowsAppInstanceActivated(Microsoft.UI.Xaml.Application appl } if (Application.Current?.Windows.FirstOrDefault() is Window window) + { // Redirected activations can arrive off the UI thread, so hop through the // MAUI window dispatcher before re-activating the existing window. - window.Dispatcher.Dispatch(() => Application.Current.ActivateWindow(window)); + window.Dispatcher.Dispatch(() => + { + Application.Current.ActivateWindow(window); + + if (window.Page is Page page) + { + _ = page.DisplayAlertAsync("App Activated", + $"This window was brought to the foreground because the app was re-launched. " + + $"Activation kind: {args.Kind}", "OK"); + } + }); + } return false; } From c1554db94f8c1b68c3da1dfe8cadaecbda516cf3 Mon Sep 17 00:00:00 2001 From: Andrea Galvani <17992288+IlGalvo@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:32:52 +0200 Subject: [PATCH 6/7] [Windows] WebAuthenticator: add protocol callback support via app activation (#36415) > [!NOTE] > Are you waiting for the changes in this PR to be merged? > It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you! ### Root Cause `WebAuthenticator` is unsupported on Windows. A custom-protocol callback may launch a second app process, so Windows needs both protocol activation handling and cross-instance routing back to the process that owns the pending authentication. Windows App SDK also cannot reliably re-register an `AppInstance` after `UnregisterKey()` ([microsoft/WindowsAppSDK#4420](https://github.com/microsoft/WindowsAppSDK/issues/4420)). Releasing the route after the first authentication caused a subsequent callback process to find no route owner. After a redirected callback completes, the existing MAUI window is not automatically restored or brought in front of the browser. The request must retain its originating window and perform a best-effort foreground activation only after a valid callback completes it. ### Description of Change Adds a Windows implementation that preserves the existing cross-platform contract: caller-provided authentication URL and callback URL in, `WebAuthenticatorResult` out. - opens the system browser with `Launcher.LaunchUriAsync` - handles protocol callbacks through `OnAppInstanceActivated` from #34883 - registers a WebAuthenticator-specific `AppInstance` route for the callback scheme - redirects transient callback processes to the instance that owns that route - keeps the route registered for the lifetime of the process while keeping pending authentication state request-scoped - validates packaged manifest and unpackaged protocol registration - forwards callbacks through the existing `IPlatformWebAuthenticatorCallback` - captures the `AppWindow` that started each authentication so multi-window callbacks return to the correct window - restores minimized or hidden windows without intermediate activation, then makes one best-effort foreground request This intentionally does **not** use `OAuth2Manager`. The caller still owns `state`, PKCE, provider parameters, and token exchange. ### Key Technical Details - route keys use `Microsoft.Maui.WebAuthenticator:{scheme}` - application-owned `AppInstance` keys are preserved - transient processes use `AppInstance.GetInstances()` to find the route owner without registering another key - final callback matching still uses `WebUtils.CanHandleCallback(...)` - only one WebAuthenticator request can be pending per app instance, matching the existing platform model - redirect completion is awaited before the transient callback process exits - the route remains registered for the process lifetime to avoid the `UnregisterKey()` re-registration issue - the persistent route is routing infrastructure; the TCS and expected callback URI determine whether a callback is currently handled - only the callback that wins `TrySetResult` may restore and foreground its captured window - cancellation, invalid callbacks, duplicate callbacks, and late callbacks do not request foreground activation - minimized windows use `OverlappedPresenter.Restore(false)` and hidden windows use `AppWindow.Show(false)` so `SetForegroundWindow` is the only explicit activation request - foreground work is isolated behind best-effort exception handling and never changes the OAuth result `RedirectActivationToAsync` already requests foreground permission for the route-owner process through `AllowSetForegroundWindow` in Windows App SDK [`AppInstance::QueueRequest`](https://github.com/microsoft/WindowsAppSDK/blob/91160b078668051e58ba1e19eb699f4efc8c5b20/dev/AppLifecycle/AppInstance.cpp). MAUI therefore does not duplicate that call. The foreground path intentionally does not use `AppWindow.DispatcherQueue`. [`AppWindow`](https://learn.microsoft.com/windows/windows-app-sdk/api/winrt/microsoft.ui.windowing.appwindow?view=windows-app-sdk-1.8) and [`OverlappedPresenter`](https://learn.microsoft.com/windows/windows-app-sdk/api/winrt/microsoft.ui.windowing.overlappedpresenter?view=windows-app-sdk-1.8) are agile, and a dispatcher queue is not guaranteed to be available in the redirected activation context. Packaged apps declare the callback scheme in `AppxManifest.xml`; unpackaged apps register it with `ActivationRegistrationManager.RegisterForProtocolActivation(...)`. ### Foreground Behavior Windows ultimately decides whether [`SetForegroundWindow`](https://learn.microsoft.com/windows/win32/api/winuser/nf-winuser-setforegroundwindow) succeeds. Foreground activation is therefore best effort by design: failure is diagnostic only and never changes a successful authentication result. ### Testing The routing revision passed Unit, Public API, Windows build, sample build, and all 14 WebAuthenticator Windows device tests. The foreground revision was validated with: - `Microsoft.Maui.Essentials` Windows build: 0 warnings, 0 errors - manual OAuth callback verification confirming that the existing MAUI window returns in front of the browser - runtime verification that accessing `AppWindow.DispatcherQueue` from the redirected activation context can fail with `COMException`; the final implementation does not depend on it Additional human-interaction validation remains useful for minimized, maximized, multi-window, cancellation followed by retry, invalid callback, and consecutive-authentication scenarios. ### Breaking Changes None. ### Issues Fixed - Alternative implementation to #34887 - Related to #30056 - Stacked on #34883 - Related Windows App SDK behavior: [microsoft/WindowsAppSDK#4420](https://github.com/microsoft/WindowsAppSDK/issues/4420) Looking forward to your feedback, thanks! @mattleibow @dotMorten Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../EssentialsMauiAppBuilderExtensions.cs | 4 + .../src/Platform/Platform.shared.cs | 9 + .../src/Platform/PlatformMethods.windows.cs | 4 + .../net-windows/PublicAPI.Unshipped.txt | 3 + .../WebAuthenticator.shared.cs | 27 +- .../WebAuthenticator.windows.cs | 319 +++++++++++++++++- .../Platforms/Windows/Package.appxmanifest | 5 + .../Tests/WebAuthenticator_Tests.cs | 25 -- .../Tests/WebAuthenticator_Windows_Tests.cs | 52 +++ .../test/UnitTests/WebUtils_Tests.cs | 17 + 10 files changed, 421 insertions(+), 44 deletions(-) create mode 100644 src/Essentials/test/DeviceTests/Tests/WebAuthenticator_Windows_Tests.cs diff --git a/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs b/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs index d18996432efd..35573dd3002d 100644 --- a/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs +++ b/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs @@ -66,6 +66,10 @@ internal static MauiAppBuilder UseEssentials(this MauiAppBuilder builder) })); #elif WINDOWS life.AddWindows(windows => windows + .OnAppInstanceActivated((application, args) => + { + return ApplicationModel.Platform.OnAppInstanceActivated(application, args); + }) .OnActivated((window, args) => { ApplicationModel.Platform.OnActivated(window, args); diff --git a/src/Essentials/src/Platform/Platform.shared.cs b/src/Essentials/src/Platform/Platform.shared.cs index 3fa0f8d0877d..dea6c36dc4de 100644 --- a/src/Essentials/src/Platform/Platform.shared.cs +++ b/src/Essentials/src/Platform/Platform.shared.cs @@ -154,6 +154,15 @@ public static void OnPlatformWindowInitialized(UI.Xaml.Window window) => public static void OnActivated(UI.Xaml.Window window, UI.Xaml.WindowActivatedEventArgs args) => WindowStateManager.Default.OnActivated(window, args); + /// + /// Called when the Windows application receives activation arguments that may be handled by platform features. + /// + /// The application instance that received the activation. + /// The activation arguments. + /// if a platform feature handled the activation; otherwise, . + public static bool OnAppInstanceActivated(UI.Xaml.Application application, Microsoft.Windows.AppLifecycle.AppActivationArguments args) => + WebAuthenticator.Default.OnAppInstanceActivated(args); + #elif TIZEN /// /// Gets a object with information about the current application package. diff --git a/src/Essentials/src/Platform/PlatformMethods.windows.cs b/src/Essentials/src/Platform/PlatformMethods.windows.cs index 2eb120101bfc..ece07c1d4f76 100644 --- a/src/Essentials/src/Platform/PlatformMethods.windows.cs +++ b/src/Essentials/src/Platform/PlatformMethods.windows.cs @@ -77,6 +77,10 @@ public static long GetWindowLongPtr(IntPtr hWnd, WindowLongFlags nIndex) [DllImport("user32.dll")] public static extern IntPtr CallWindowProc(IntPtr lpPrevWndFunc, IntPtr hWnd, uint uMsg, IntPtr wParam, IntPtr lParam); + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, SpecialWindowHandles hWndInsertAfter, int x, int y, int width, int height, SetWindowPosFlags uFlags); diff --git a/src/Essentials/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt b/src/Essentials/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt index c85f863d2957..30a4bb38c75d 100644 --- a/src/Essentials/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt +++ b/src/Essentials/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt @@ -1,4 +1,7 @@ #nullable enable +Microsoft.Maui.Authentication.IPlatformWebAuthenticatorCallback.OnAppInstanceActivatedCallback(Microsoft.Windows.AppLifecycle.AppActivationArguments! args) -> bool +static Microsoft.Maui.ApplicationModel.Platform.OnAppInstanceActivated(Microsoft.UI.Xaml.Application! application, Microsoft.Windows.AppLifecycle.AppActivationArguments! args) -> bool +static Microsoft.Maui.Authentication.WebAuthenticatorExtensions.OnAppInstanceActivated(this Microsoft.Maui.Authentication.IWebAuthenticator! webAuthenticator, Microsoft.Windows.AppLifecycle.AppActivationArguments! args) -> bool *REMOVED*Microsoft.Maui.Storage.IFilePicker.PickMultipleAsync(Microsoft.Maui.Storage.PickOptions? options = null) -> System.Threading.Tasks.Task!>! Microsoft.Maui.Storage.IFilePicker.PickMultipleAsync(Microsoft.Maui.Storage.PickOptions? options = null) -> System.Threading.Tasks.Task?>! *REMOVED*static Microsoft.Maui.Storage.FilePicker.PickMultipleAsync(Microsoft.Maui.Storage.PickOptions? options = null) -> System.Threading.Tasks.Task!>! diff --git a/src/Essentials/src/WebAuthenticator/WebAuthenticator.shared.cs b/src/Essentials/src/WebAuthenticator/WebAuthenticator.shared.cs index 6b256deb6c6e..ea06692a5772 100644 --- a/src/Essentials/src/WebAuthenticator/WebAuthenticator.shared.cs +++ b/src/Essentials/src/WebAuthenticator/WebAuthenticator.shared.cs @@ -22,10 +22,10 @@ public interface IWebAuthenticator /// A instance containing additional configuration for this authentication call. /// A object with the results of this operation. /// Thrown when the user canceled the authentication flow. - /// Windows: Thrown when called on Windows. /// iOS/macOS: Thrown when iOS version is less than 13 is used or macOS less than 13.1 is used. /// /// Android: Thrown when the no IntentFilter has been created for the callback URL. + /// Windows: Thrown when the callback URL is invalid, its protocol is not registered, the browser cannot be launched, or another app instance is already waiting for the callback scheme. /// Task AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions); @@ -36,10 +36,10 @@ public interface IWebAuthenticator /// A to monitor for cancellation requests. /// A object with the results of this operation. /// Thrown when the user canceled the authentication flow. - /// Windows: Thrown when called on Windows. /// iOS/macOS: Thrown when iOS version is less than 13 is used or macOS less than 13.1 is used. /// /// Android: Thrown when the no IntentFilter has been created for the callback URL. + /// Windows: Thrown when the callback URL is invalid, its protocol is not registered, the browser cannot be launched, or another app instance is already waiting for the callback scheme. /// Task AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions, CancellationToken cancellationToken); @@ -64,6 +64,13 @@ public interface IPlatformWebAuthenticatorCallback /// An object containing additional data about this resume operation. /// when the callback can be processed, otherwise . bool OnResumeCallback(Intent intent); +#elif WINDOWS + /// + /// Called when the app receives an activation that may complete an authentication flow. + /// + /// The activation arguments delivered by the OS. + /// when the activation was handled, otherwise . + bool OnAppInstanceActivatedCallback(Microsoft.Windows.AppLifecycle.AppActivationArguments args); #endif } @@ -93,9 +100,6 @@ public static class WebAuthenticator /// Url to navigate to, beginning the authentication flow. /// Expected callback url that the navigation flow will eventually redirect to. /// Returns a result parsed out from the callback url. -#if !NETSTANDARD - [System.Runtime.Versioning.UnsupportedOSPlatform("windows")] -#endif public static Task AuthenticateAsync(Uri url, Uri callbackUrl) => Current.AuthenticateAsync(url, callbackUrl); @@ -104,18 +108,12 @@ public static Task AuthenticateAsync(Uri url, Uri callba /// Expected callback url that the navigation flow will eventually redirect to. /// A to monitor for cancellation requests. /// Returns a result parsed out from the callback url. -#if !NETSTANDARD - [System.Runtime.Versioning.UnsupportedOSPlatform("windows")] -#endif public static Task AuthenticateAsync(Uri url, Uri callbackUrl, CancellationToken cancellationToken) => Current.AuthenticateAsync(url, callbackUrl, cancellationToken); /// Begin an authentication flow by navigating to the specified url and waiting for a callback/redirect to the callbackUrl scheme.The start url and callbackUrl are specified in the webAuthenticatorOptions. /// Options to configure the authentication request. /// Returns a result parsed out from the callback url. -#if !NETSTANDARD - [System.Runtime.Versioning.UnsupportedOSPlatform("windows")] -#endif public static Task AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions) => Current.AuthenticateAsync(webAuthenticatorOptions); @@ -123,9 +121,6 @@ public static Task AuthenticateAsync(WebAuthenticatorOpt /// Options to configure the authentication request. /// A to monitor for cancellation requests. /// Returns a result parsed out from the callback url. -#if !NETSTANDARD - [System.Runtime.Versioning.UnsupportedOSPlatform("windows")] -#endif public static Task AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions, CancellationToken cancellationToken) => Current.AuthenticateAsync(webAuthenticatorOptions, cancellationToken); @@ -210,6 +205,10 @@ public static bool ContinueUserActivity(this IWebAuthenticator webAuthenticator, /// public static bool OnResume(this IWebAuthenticator webAuthenticator, Intent intent) => webAuthenticator.AsPlatformCallback().OnResumeCallback(intent); +#elif WINDOWS + /// + public static bool OnAppInstanceActivated(this IWebAuthenticator webAuthenticator, Microsoft.Windows.AppLifecycle.AppActivationArguments args) => + webAuthenticator.AsPlatformCallback().OnAppInstanceActivatedCallback(args); #endif } diff --git a/src/Essentials/src/WebAuthenticator/WebAuthenticator.windows.cs b/src/Essentials/src/WebAuthenticator/WebAuthenticator.windows.cs index 05782395a151..c4e9ad9a7ff3 100644 --- a/src/Essentials/src/WebAuthenticator/WebAuthenticator.windows.cs +++ b/src/Essentials/src/WebAuthenticator/WebAuthenticator.windows.cs @@ -1,18 +1,327 @@ +#nullable enable using System; +using System.Diagnostics; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using System.Xml; +using System.Xml.Linq; +using System.Xml.XPath; +using Microsoft.Maui.ApplicationModel; +using Microsoft.Maui.Storage; +using Microsoft.UI.Windowing; +using Microsoft.Windows.AppLifecycle; +using Windows.ApplicationModel.Activation; namespace Microsoft.Maui.Authentication { - partial class WebAuthenticatorImplementation : IWebAuthenticator + partial class WebAuthenticatorImplementation : IWebAuthenticator, IPlatformWebAuthenticatorCallback { - public Task AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions) + const string CallbackRouteKeyPrefix = "Microsoft.Maui.WebAuthenticator:"; + + readonly object locker = new(); + + TaskCompletionSource? tcsResponse; + Uri? currentRedirectUri; + WebAuthenticatorOptions? currentOptions; + AppWindow? currentAppWindow; + + public bool OnAppInstanceActivatedCallback(AppActivationArguments args) + { + if (args.Kind != ExtendedActivationKind.Protocol || + args.Data is not IProtocolActivatedEventArgs protocolArgs) + { + return false; + } + + var callbackUri = protocolArgs.Uri; + + TaskCompletionSource? response; + Uri? redirectUri; + WebAuthenticatorOptions? options; + AppWindow? appWindow; + + bool isLocalCallbackRoute; + + lock (locker) + { + response = tcsResponse; + redirectUri = currentRedirectUri; + options = currentOptions; + appWindow = currentAppWindow; + + isLocalCallbackRoute = redirectUri is not null && IsSameCallbackRoute(redirectUri, callbackUri); + } + + if (response?.Task.IsCompleted == false && + redirectUri is not null && + WebUtils.CanHandleCallback(redirectUri, callbackUri)) + { + try + { + var result = new WebAuthenticatorResult(callbackUri, options?.ResponseDecoder); + + // Only the callback that completes the request may restore its window. + if (response.TrySetResult(result)) + TryBringToForeground(appWindow); + } + catch (Exception ex) + { + response.TrySetException(ex); + } + + return true; + } + + // A callback can have the expected scheme and still fail host/path validation. + // Leave the active route registered so a later valid callback can complete it. + if (isLocalCallbackRoute) + return false; + + var routeOwner = FindCallbackRouteOwner(callbackUri); + if (routeOwner is null) + return false; + + return RedirectActivationAndExit(routeOwner, args); + } + + public async Task AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions) + => await AuthenticateAsync(webAuthenticatorOptions, CancellationToken.None); + + public async Task AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions, CancellationToken cancellationToken) { - throw new PlatformNotSupportedException("This implementation of WebAuthenticator does not support Windows. See https://github.com/microsoft/WindowsAppSDK/issues/441 for more details."); + ArgumentNullException.ThrowIfNull(webAuthenticatorOptions); + + var url = webAuthenticatorOptions.Url ?? + throw new ArgumentNullException(nameof(webAuthenticatorOptions.Url)); + var callbackUrl = webAuthenticatorOptions.CallbackUrl ?? + throw new ArgumentNullException(nameof(webAuthenticatorOptions.CallbackUrl)); + + ValidateCallbackUrl(callbackUrl); + + var response = new TaskCompletionSource(); + TaskCompletionSource? previousResponse; + + // Capture the request window so a multi-window app restores the same window + // after authentication completes. + var appWindow = TryGetActiveAppWindow(); + + lock (locker) + { + RegisterCallbackRoute(callbackUrl); + + previousResponse = tcsResponse; + tcsResponse = response; + currentRedirectUri = callbackUrl; + currentOptions = webAuthenticatorOptions; + currentAppWindow = appWindow; + } + + previousResponse?.TrySetCanceled(); + + using (cancellationToken.Register(() => response.TrySetCanceled())) + { + try + { + var launched = await global::Windows.System.Launcher.LaunchUriAsync(url); + if (!launched) + throw new InvalidOperationException("Failed to launch the browser for authentication."); + + return await response.Task; + } + finally + { + lock (locker) + { + if (ReferenceEquals(tcsResponse, response)) + ClearCurrentAuthentication(); + } + } + } } - public Task AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions, CancellationToken cancellationToken) + + void ClearCurrentAuthentication() + { + // Keep the route for the process lifetime. Windows App SDK cannot re-register an + // AppInstance after UnregisterKey (microsoft/WindowsAppSDK#4420). + tcsResponse = null; + currentRedirectUri = null; + currentOptions = null; + currentAppWindow = null; + } + + static AppWindow? TryGetActiveAppWindow() + { + try + { + return WindowStateManager.Default.GetActiveAppWindow(false); + } + catch (Exception ex) + { + Debug.WriteLine($"Unable to identify the WebAuthenticator window: {ex}"); + return null; + } + } + + static void TryBringToForeground(AppWindow? appWindow) + { + if (appWindow is null) + return; + + try + { + var windowHandle = UI.Win32Interop.GetWindowFromWindowId(appWindow.Id); + if (windowHandle == IntPtr.Zero) + { + Debug.WriteLine("Unable to retrieve the WebAuthenticator window handle."); + return; + } + + // AppWindow APIs are agile. Restore or show without activation so the native + // call below makes the only best-effort foreground request. + if (appWindow.Presenter is OverlappedPresenter presenter && + presenter.State == OverlappedPresenterState.Minimized) + { + presenter.Restore(false); + } + + if (!appWindow.IsVisible) + appWindow.Show(false); + + // RedirectActivationToAsync attempts to transfer foreground permission to the + // route owner, but Windows can still deny this request. + if (!PlatformMethods.SetForegroundWindow(windowHandle)) + Debug.WriteLine("Windows denied the WebAuthenticator window foreground activation."); + } + catch (Exception ex) + { + Debug.WriteLine($"Unable to bring the WebAuthenticator window to the foreground: {ex}"); + } + } + + static void ValidateCallbackUrl(Uri callbackUrl) + { + if (!callbackUrl.IsAbsoluteUri) + throw new InvalidOperationException("The callback URI must be absolute."); + + if (callbackUrl.Scheme is "http" or "https") + { + throw new InvalidOperationException( + $"{callbackUrl.Scheme}:// schemes are not supported for the callback URI on Windows. " + + "Use a custom URI scheme instead."); + } + + if (AppInfoUtils.IsPackagedApp) + { + if (!IsUriProtocolDeclared(callbackUrl.Scheme)) + { + throw new InvalidOperationException( + $"You need to declare the windows.protocol usage of the " + + $"protocol/scheme `{callbackUrl.Scheme}` in your AppxManifest.xml file."); + } + } + else if (!IsRegistryDeclared(callbackUrl.Scheme)) + { + throw new InvalidOperationException( + $"The URI scheme '{callbackUrl.Scheme}' is not registered. " + + "Call ActivationRegistrationManager.RegisterForProtocolActivation when running unpackaged."); + } + } + + static void RegisterCallbackRoute(Uri callbackUrl) + { + var currentInstance = AppInstance.GetCurrent(); + var routeKey = CreateCallbackRouteKey(callbackUrl); + var currentKey = currentInstance.Key; + + if (string.Equals(currentKey, routeKey, StringComparison.Ordinal)) + return; + + // Preserve application-owned AppInstance routing. + if (!CanRegisterCallbackRoute(currentKey)) + return; + + var routeOwner = AppInstance.FindOrRegisterForKey(routeKey); + if (routeOwner is null) + { + throw new InvalidOperationException( + $"Unable to register the callback route for scheme '{callbackUrl.Scheme}'."); + } + + if (!routeOwner.IsCurrent) + { + throw new InvalidOperationException( + $"Another app instance is already waiting for the callback scheme '{callbackUrl.Scheme}'."); + } + + if (!string.Equals(routeOwner.Key, routeKey, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Unable to register the callback route for scheme '{callbackUrl.Scheme}'."); + } + } + + static AppInstance? FindCallbackRouteOwner(Uri callbackUri) + { + var routeKey = CreateCallbackRouteKey(callbackUri); + + return AppInstance.GetInstances().FirstOrDefault(instance => + !instance.IsCurrent && + string.Equals(instance.Key, routeKey, StringComparison.Ordinal)); + } + + static bool RedirectActivationAndExit(AppInstance routeOwner, AppActivationArguments args) + { + try + { + // Complete redirection before terminating this transient callback process. + routeOwner.RedirectActivationToAsync(args).AsTask().GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Debug.WriteLine($"Unable to redirect WebAuthenticator callback activation: {ex}"); + return false; + } + + Process.GetCurrentProcess().Kill(); + return true; + } + + // AppInstance keys are app-defined, so keep WebAuthenticator routes separate from application-owned keys. + internal static string CreateCallbackRouteKey(Uri callbackUrl) => + $"{CallbackRouteKeyPrefix}{callbackUrl.Scheme}"; + + internal static bool CanRegisterCallbackRoute(string? currentKey) => + string.IsNullOrEmpty(currentKey) || + currentKey.StartsWith(CallbackRouteKeyPrefix, StringComparison.Ordinal); + + internal static bool IsSameCallbackRoute(Uri expectedCallbackUrl, Uri callbackUrl) => + string.Equals( + CreateCallbackRouteKey(expectedCallbackUrl), + CreateCallbackRouteKey(callbackUrl), + StringComparison.Ordinal); + + static bool IsUriProtocolDeclared(string scheme) + { + var docPath = FileSystemUtils.PlatformGetFullAppPackageFilePath(PlatformUtils.AppManifestFilename); + var doc = XDocument.Load(docPath, LoadOptions.None); + + using var reader = doc.CreateReader(); + var namespaceManager = new XmlNamespaceManager(reader.NameTable); + namespaceManager.AddNamespace("uap", PlatformUtils.AppManifestUapXmlns); + + var root = doc.Root ?? throw new InvalidOperationException("The app manifest could not be loaded."); + var declarations = root.XPathSelectElements( + $"//uap:Extension[@Category='windows.protocol']/uap:Protocol[@Name='{scheme}']", + namespaceManager); + + return declarations.Any(); + } + + static bool IsRegistryDeclared(string scheme) { - throw new PlatformNotSupportedException("This implementation of WebAuthenticator does not support Windows. See https://github.com/microsoft/WindowsAppSDK/issues/441 for more details."); + using var key = Win32.Registry.ClassesRoot.OpenSubKey(scheme); + return key?.GetValue("URL Protocol") is not null; } } } diff --git a/src/Essentials/test/DeviceTests/Platforms/Windows/Package.appxmanifest b/src/Essentials/test/DeviceTests/Platforms/Windows/Package.appxmanifest index 36fd8a868108..fe11bebaeadb 100644 --- a/src/Essentials/test/DeviceTests/Platforms/Windows/Package.appxmanifest +++ b/src/Essentials/test/DeviceTests/Platforms/Windows/Package.appxmanifest @@ -33,6 +33,11 @@ + + + + + diff --git a/src/Essentials/test/DeviceTests/Tests/WebAuthenticator_Tests.cs b/src/Essentials/test/DeviceTests/Tests/WebAuthenticator_Tests.cs index 06f150b3b067..890af8d02d0d 100644 --- a/src/Essentials/test/DeviceTests/Tests/WebAuthenticator_Tests.cs +++ b/src/Essentials/test/DeviceTests/Tests/WebAuthenticator_Tests.cs @@ -22,21 +22,15 @@ public class WebAuthenticator_Tests [Trait(Traits.InteractionType, Traits.InteractionTypes.Human)] public async Task Redirect(string urlBase, string callbackScheme, string accessToken, string refreshToken, int expires) { -#pragma warning disable CA1416 // Validate platform compatibility: Not supported on Windows var authenticationTask = WebAuthenticator.AuthenticateAsync( new Uri($"{urlBase}?access_token={accessToken}&refresh_token={refreshToken}&expires={expires}"), new Uri($"{callbackScheme}://")); -#pragma warning restore CA1416 // Validate platform compatibility -#if WINDOWS - var exception = await Assert.ThrowsAsync(async () => await authenticationTask); -#else var r = await authenticationTask.ConfigureAwait(false); Assert.Equal(accessToken, r?.AccessToken); Assert.Equal(refreshToken, r?.RefreshToken); Assert.NotNull(r?.ExpiresIn); Assert.True(r?.ExpiresIn > DateTime.UtcNow); -#endif } [Theory] @@ -50,24 +44,18 @@ public async Task Redirect(string urlBase, string callbackScheme, string accessT public async Task RedirectWithResponseDecoder(string urlBase, string callbackScheme, string accessToken, string refreshToken, int expires) { var responseDecoder = new TestResponseDecoder(); -#pragma warning disable CA1416 // Validate platform compatibility: Not supported on Windows var authenticationTask = WebAuthenticator.AuthenticateAsync(new WebAuthenticatorOptions { Url = new Uri($"{urlBase}?access_token={accessToken}&refresh_token={refreshToken}&expires={expires}"), CallbackUrl = new Uri($"{callbackScheme}://"), ResponseDecoder = responseDecoder }); -#pragma warning restore CA1416 // Validate platform compatibility -#if WINDOWS - var exception = await Assert.ThrowsAsync(async () => await authenticationTask); -#else var r = await authenticationTask.ConfigureAwait(false); Assert.Equal(accessToken, r?.AccessToken); Assert.Equal(refreshToken, r?.RefreshToken); Assert.NotNull(r?.ExpiresIn); Assert.True(r?.ExpiresIn > DateTime.UtcNow); Assert.Equal(1, responseDecoder.CallCount); -#endif } @@ -81,23 +69,16 @@ public async Task RedirectWithResponseDecoder(string urlBase, string callbackSch [Trait(Traits.InteractionType, Traits.InteractionTypes.Human)] public async Task Redirect_WithCancellation(string urlBase, string callbackScheme, string accessToken, string refreshToken, int expires) { -#pragma warning disable CA1416 // Validate platform compatibility: Not supported on Windows using var cts = new CancellationTokenSource(); var authenticationTask = WebAuthenticator.AuthenticateAsync( new Uri($"{urlBase}?access_token={accessToken}&refresh_token={refreshToken}&expires={expires}"), new Uri($"{callbackScheme}://"), cts.Token); -#pragma warning restore CA1416 // Validate platform compatibility - -#if WINDOWS - var exception = await Assert.ThrowsAsync(async () => await authenticationTask); -#else var r = await authenticationTask; Assert.Equal(accessToken, r?.AccessToken); Assert.Equal(refreshToken, r?.RefreshToken); Assert.NotNull(r?.ExpiresIn); Assert.True(r?.ExpiresIn > DateTime.UtcNow); -#endif } [Theory] @@ -111,7 +92,6 @@ public async Task Redirect_WithCancellation(string urlBase, string callbackSchem public async Task RedirectWithResponseDecoder_WithCancellation(string urlBase, string callbackScheme, string accessToken, string refreshToken, int expires) { var responseDecoder = new TestResponseDecoder(); -#pragma warning disable CA1416 // Validate platform compatibility: Not supported on Windows using var cts = new CancellationTokenSource(); var authenticationTask = WebAuthenticator.AuthenticateAsync(new WebAuthenticatorOptions { @@ -119,17 +99,12 @@ public async Task RedirectWithResponseDecoder_WithCancellation(string urlBase, s CallbackUrl = new Uri($"{callbackScheme}://"), ResponseDecoder = responseDecoder }, cts.Token); -#pragma warning restore CA1416 // Validate platform compatibility -#if WINDOWS - var exception = await Assert.ThrowsAsync(async () => await authenticationTask); -#else var r = await authenticationTask; Assert.Equal(accessToken, r?.AccessToken); Assert.Equal(refreshToken, r?.RefreshToken); Assert.NotNull(r?.ExpiresIn); Assert.True(r?.ExpiresIn > DateTime.UtcNow); Assert.Equal(1, responseDecoder.CallCount); -#endif } diff --git a/src/Essentials/test/DeviceTests/Tests/WebAuthenticator_Windows_Tests.cs b/src/Essentials/test/DeviceTests/Tests/WebAuthenticator_Windows_Tests.cs new file mode 100644 index 000000000000..03bcb7b1560e --- /dev/null +++ b/src/Essentials/test/DeviceTests/Tests/WebAuthenticator_Windows_Tests.cs @@ -0,0 +1,52 @@ +#if WINDOWS +using System; +using Microsoft.Maui.Authentication; +using Xunit; + +namespace Microsoft.Maui.Essentials.DeviceTests +{ + [Category("WebAuthenticator")] + public class WebAuthenticator_Windows_Tests + { + [Theory] + [InlineData("maui-auth://", "Microsoft.Maui.WebAuthenticator:maui-auth")] + [InlineData("MAUI-AUTH://", "Microsoft.Maui.WebAuthenticator:maui-auth")] + [InlineData("maui-auth://callback", "Microsoft.Maui.WebAuthenticator:maui-auth")] + [InlineData("maui-auth://other/path?code=123", "Microsoft.Maui.WebAuthenticator:maui-auth")] + public void CreateCallbackRouteKeyUsesSchemeOnly(string callbackUrl, string expected) + { + var routeKey = WebAuthenticatorImplementation.CreateCallbackRouteKey(new Uri(callbackUrl)); + + Assert.Equal(expected, routeKey); + } + + [Theory] + [InlineData(null, true)] + [InlineData("", true)] + [InlineData("Maui.App", false)] + [InlineData("Microsoft.Maui.WebAuthenticator:maui-auth", true)] + [InlineData("Microsoft.Maui.WebAuthenticator:other-auth", true)] + [InlineData("Microsoft.Maui.WebAuthenticator2:maui-auth", false)] + public void CanRegisterCallbackRoutePreservesApplicationKeys(string currentKey, bool expected) + { + var actual = WebAuthenticatorImplementation.CanRegisterCallbackRoute(currentKey); + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("maui-auth://callback", "maui-auth://callback", true)] + [InlineData("maui-auth://callback", "maui-auth://other/path", true)] + [InlineData("MAUI-AUTH://callback", "maui-auth://callback", true)] + [InlineData("maui-auth://callback", "other-auth://callback", false)] + public void IsSameCallbackRouteUsesSchemeOnly(string expectedCallbackUrl, string callbackUrl, bool expected) + { + var actual = WebAuthenticatorImplementation.IsSameCallbackRoute( + new Uri(expectedCallbackUrl), + new Uri(callbackUrl)); + + Assert.Equal(expected, actual); + } + } +} +#endif diff --git a/src/Essentials/test/UnitTests/WebUtils_Tests.cs b/src/Essentials/test/UnitTests/WebUtils_Tests.cs index 6a4ef2653ce9..97f2eaa3de4b 100644 --- a/src/Essentials/test/UnitTests/WebUtils_Tests.cs +++ b/src/Essentials/test/UnitTests/WebUtils_Tests.cs @@ -115,5 +115,22 @@ public void RemovePossibleQueryString_ReturnsExpected(string? input, string expe var result = Microsoft.Maui.WebUtils.RemovePossibleQueryString(input); Assert.Equal(expected, result); } + + // ============================================================ + // CanHandleCallback + // ============================================================ + + [Theory] + [InlineData("maui-auth://", "maui-auth://callback?code=123", true)] + [InlineData("MAUI-AUTH://", "maui-auth://callback?code=123", true)] + [InlineData("maui-auth://callback", "MAUI-AUTH://CALLBACK?code=123", true)] + [InlineData("maui-auth://callback", "maui-auth://other?code=123", false)] + [InlineData("maui-auth://callback", "other-auth://callback?code=123", false)] + public void CanHandleCallback_ReturnsExpected(string expectedUrl, string callbackUrl, bool expected) + { + var result = Microsoft.Maui.WebUtils.CanHandleCallback(new Uri(expectedUrl), new Uri(callbackUrl)); + + Assert.Equal(expected, result); + } } } From 144023a2a2988fa8b8f378a8b4dfe73ccc113246 Mon Sep 17 00:00:00 2001 From: Andrea Galvani <17992288+IlGalvo@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:30:07 +0200 Subject: [PATCH 7/7] [Windows] Address AppInstance activation lifecycle review feedback (#36597) ## Summary Follow-up to #34883 addressing the remaining review feedback without changing the public surface introduced by the parent PR. - Dispatches later `AppInstance.Activated` notifications through the MAUI application dispatcher while keeping the initial activation synchronous and able to short-circuit window creation. - Restores the four missing Core Windows Public API baseline entries. - Removes the now-redundant window-level dispatch from the Controls Sample. - Hardens the Windows lifecycle order test against missing-event false positives. - Limits the new lifecycle device-test coverage to Windows. - Clarifies the Windows WebAuthenticator callback-routing contract and moves its Windows-only tests into the existing `Tests/Windows/` layout. ## Threading and lifecycle behavior `MauiWinUIApplication` captures the application dispatcher after app-level services are available. Initial activation continues to run synchronously before `OnLaunching` and window creation, preserving the existing `bool handled` contract. Only later `AppInstance.Activated` callbacks are dispatched when required; if dispatch is rejected during shutdown, the callback is not run off-thread. ## Test scope The Android and iOS/MacCatalyst lifecycle tests added by the parent PR are intentionally removed: - The change is Windows-specific, while those tests cover pre-existing platform lifecycle events. - The Android headless runner can begin executing tests before `OnStart`/`OnResume` delivery completes, creating a startup race. - The Apple headless runner uses `MauiTestApplicationDelegate` rather than the normal `MauiUIApplicationDelegate` lifecycle path. Those files do not exist on `main`, so this does not remove established MAUI coverage. Addressing the cross-platform runners belongs in a separate change. ## Intentional review decisions - No exception is added for application-owned `AppInstance` keys. Single-instance apps preserve their own key and redirect protocol activations to the original instance, where the MAUI lifecycle callback completes WebAuthenticator. Throwing would break that supported pattern. - `System.Threading.Tasks` remains in the Controls Sample because it provides the `.AsTask()` extension used with `RedirectActivationToAsync`. - `MauiProgramDefaults` remains unchanged because `Core.DeviceTests.Shared` has nullable annotations disabled; its optional delegate follows the existing project convention. ## Validation Automated/local: - Core Windows build: passed - Essentials Windows build: passed - Controls Sample Windows build: passed - `WebUtils_Tests`: 20/20 passed - Core Windows lifecycle device tests: 3/3 passed - Essentials Windows WebAuthenticator helper device tests: 14/14 passed - Core and Essentials Windows DeviceTests projects: built successfully Manual Windows validation: - A true second Controls Sample instance redirected to the original instance and terminated; the original window handled the activation on the UI thread. - The real WebAuthenticator browser/callback flow was validated on Windows. Android, iOS, and MacCatalyst runtime tests were not run for this Windows-only follow-up. Thanks in advance, @mattleibow @kubaflo --- .../samples/Controls.Sample/MauiProgram.cs | 19 +++----- .../Platform/Windows/MauiWinUIApplication.cs | 10 +++++ .../net-windows/PublicAPI.Unshipped.txt | 4 ++ .../LifecycleEventOrderTests.Android.cs | 43 ------------------- .../LifecycleEventOrderTests.Windows.cs | 19 ++++++-- .../LifecycleEventOrderTests.iOS.cs | 41 ------------------ src/Core/tests/DeviceTests/MauiProgram.cs | 29 ++----------- .../WebAuthenticator.windows.cs | 5 ++- .../WebAuthenticator_Windows_Tests.cs | 2 - 9 files changed, 42 insertions(+), 130 deletions(-) delete mode 100644 src/Core/tests/DeviceTests/LifecycleEventOrderTests.Android.cs delete mode 100644 src/Core/tests/DeviceTests/LifecycleEventOrderTests.iOS.cs rename src/Essentials/test/DeviceTests/Tests/{ => Windows}/WebAuthenticator_Windows_Tests.cs (98%) diff --git a/src/Controls/samples/Controls.Sample/MauiProgram.cs b/src/Controls/samples/Controls.Sample/MauiProgram.cs index bce38fce23dc..d6622f495c50 100644 --- a/src/Controls/samples/Controls.Sample/MauiProgram.cs +++ b/src/Controls/samples/Controls.Sample/MauiProgram.cs @@ -336,19 +336,14 @@ static bool HandleWindowsAppInstanceActivated(Microsoft.UI.Xaml.Application appl if (Application.Current?.Windows.FirstOrDefault() is Window window) { - // Redirected activations can arrive off the UI thread, so hop through the - // MAUI window dispatcher before re-activating the existing window. - window.Dispatcher.Dispatch(() => + Application.Current.ActivateWindow(window); + + if (window.Page is Page page) { - Application.Current.ActivateWindow(window); - - if (window.Page is Page page) - { - _ = page.DisplayAlertAsync("App Activated", - $"This window was brought to the foreground because the app was re-launched. " + - $"Activation kind: {args.Kind}", "OK"); - } - }); + _ = page.DisplayAlertAsync("App Activated", + $"This window was brought to the foreground because the app was re-launched. " + + $"Activation kind: {args.Kind}", "OK"); + } } return false; diff --git a/src/Core/src/Platform/Windows/MauiWinUIApplication.cs b/src/Core/src/Platform/Windows/MauiWinUIApplication.cs index 63dc70c43b55..ac13d33500dd 100644 --- a/src/Core/src/Platform/Windows/MauiWinUIApplication.cs +++ b/src/Core/src/Platform/Windows/MauiWinUIApplication.cs @@ -83,6 +83,8 @@ void RegisterForAppInstanceActivated() if (_isRegisteredForAppInstanceActivated) return; + var dispatcher = _services!.GetRequiredApplicationDispatcher(); + _isRegisteredForAppInstanceActivated = true; // After startup, later file/protocol/redirected activations are delivered through AppInstance. @@ -90,6 +92,14 @@ void RegisterForAppInstanceActivated() void HandleAppInstanceActivated(object? sender, AppActivationArguments args) { + // WinAppSDK delivers redirected activations on a worker thread, while MAUI + // lifecycle handlers are UI-facing. + if (dispatcher.IsDispatchRequired) + { + dispatcher.Dispatch(() => OnAppInstanceActivated(args)); + return; + } + OnAppInstanceActivated(args); } } diff --git a/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt index 4151228a7928..c6bd3d023bcc 100644 --- a/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt @@ -1,5 +1,9 @@ #nullable enable +Microsoft.Maui.LifecycleEvents.WindowsLifecycle.OnAppInstanceActivated override Microsoft.Maui.Platform.LayoutPanel.OnCreateAutomationPeer() -> Microsoft.UI.Xaml.Automation.Peers.AutomationPeer! override Microsoft.Maui.Platform.MauiPasswordTextBox.OnCreateAutomationPeer() -> Microsoft.UI.Xaml.Automation.Peers.AutomationPeer! +static Microsoft.Maui.LifecycleEvents.WindowsLifecycleBuilderExtensions.OnAppInstanceActivated(this Microsoft.Maui.LifecycleEvents.IWindowsLifecycleBuilder! lifecycle, Microsoft.Maui.LifecycleEvents.WindowsLifecycle.OnAppInstanceActivated! del) -> Microsoft.Maui.LifecycleEvents.IWindowsLifecycleBuilder! +virtual Microsoft.Maui.LifecycleEvents.WindowsLifecycle.OnAppInstanceActivated.Invoke(Microsoft.UI.Xaml.Application! application, Microsoft.Windows.AppLifecycle.AppActivationArguments! args) -> bool +virtual Microsoft.Maui.MauiWinUIApplication.OnAppInstanceActivated(Microsoft.Windows.AppLifecycle.AppActivationArguments! args) -> bool override Microsoft.Maui.Platform.ContentPanel.MeasureOverride(Windows.Foundation.Size availableSize) -> Windows.Foundation.Size override Microsoft.Maui.Platform.ContentPanel.OnCreateAutomationPeer() -> Microsoft.UI.Xaml.Automation.Peers.AutomationPeer! diff --git a/src/Core/tests/DeviceTests/LifecycleEventOrderTests.Android.cs b/src/Core/tests/DeviceTests/LifecycleEventOrderTests.Android.cs deleted file mode 100644 index a2cfdac9033b..000000000000 --- a/src/Core/tests/DeviceTests/LifecycleEventOrderTests.Android.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Linq; -using Microsoft.Maui.LifecycleEvents; -using Xunit; - -namespace Microsoft.Maui.DeviceTests -{ - [Category(TestCategory.Lifecycle)] - public class LifecycleEventOrderTests - { - [Fact(DisplayName = "Android lifecycle events fire during startup")] - public void AndroidLifecycleEventsFireDuringStartup() - { - var log = MauiProgram.LifecycleEventLog; - - Assert.Contains(nameof(AndroidLifecycle.OnCreate), log); - Assert.Contains(nameof(AndroidLifecycle.OnStart), log); - Assert.Contains(nameof(AndroidLifecycle.OnResume), log); - } - - [Fact(DisplayName = "Android lifecycle events fire in correct order")] - public void AndroidLifecycleEventsFireInCorrectOrder() - { - var log = MauiProgram.LifecycleEventLog; - - var createIndex = log.IndexOf(nameof(AndroidLifecycle.OnCreate)); - var startIndex = log.IndexOf(nameof(AndroidLifecycle.OnStart)); - var resumeIndex = log.IndexOf(nameof(AndroidLifecycle.OnResume)); - - // Expected startup order: OnCreate → OnStart → OnResume - Assert.True(createIndex < startIndex, - $"Expected OnCreate before OnStart. Log: [{string.Join(", ", log)}]"); - Assert.True(startIndex < resumeIndex, - $"Expected OnStart before OnResume. Log: [{string.Join(", ", log)}]"); - } - - [Fact(DisplayName = "OnCreate fires exactly once during startup")] - public void OnCreateFiresExactlyOnce() - { - var count = MauiProgram.LifecycleEventLog.Count(e => e == nameof(AndroidLifecycle.OnCreate)); - Assert.Equal(1, count); - } - } -} diff --git a/src/Core/tests/DeviceTests/LifecycleEventOrderTests.Windows.cs b/src/Core/tests/DeviceTests/LifecycleEventOrderTests.Windows.cs index 732e3c7ae0d8..51e741b04af1 100644 --- a/src/Core/tests/DeviceTests/LifecycleEventOrderTests.Windows.cs +++ b/src/Core/tests/DeviceTests/LifecycleEventOrderTests.Windows.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Linq; using Microsoft.Maui.LifecycleEvents; using Xunit; @@ -23,10 +24,10 @@ public void WindowsLifecycleEventsFireInCorrectOrder() { var log = MauiProgram.LifecycleEventLog; - var activatedIndex = log.IndexOf(nameof(WindowsLifecycle.OnAppInstanceActivated)); - var launchingIndex = log.IndexOf(nameof(WindowsLifecycle.OnLaunching)); - var windowCreatedIndex = log.IndexOf(nameof(WindowsLifecycle.OnWindowCreated)); - var launchedIndex = log.IndexOf(nameof(WindowsLifecycle.OnLaunched)); + var activatedIndex = AssertEventIndex(log, nameof(WindowsLifecycle.OnAppInstanceActivated)); + var launchingIndex = AssertEventIndex(log, nameof(WindowsLifecycle.OnLaunching)); + var windowCreatedIndex = AssertEventIndex(log, nameof(WindowsLifecycle.OnWindowCreated)); + var launchedIndex = AssertEventIndex(log, nameof(WindowsLifecycle.OnLaunched)); // Expected startup order: OnAppInstanceActivated → OnLaunching → OnWindowCreated → OnLaunched Assert.True(activatedIndex < launchingIndex, @@ -35,6 +36,16 @@ public void WindowsLifecycleEventsFireInCorrectOrder() $"Expected OnLaunching before OnWindowCreated. Log: [{string.Join(", ", log)}]"); Assert.True(windowCreatedIndex < launchedIndex, $"Expected OnWindowCreated before OnLaunched. Log: [{string.Join(", ", log)}]"); + + static int AssertEventIndex(IList log, string eventName) + { + var index = log.IndexOf(eventName); + + Assert.True(index >= 0, + $"Expected {eventName} in the lifecycle log. Log: [{string.Join(", ", log)}]"); + + return index; + } } [Fact(DisplayName = "OnAppInstanceActivated fires exactly once during startup")] diff --git a/src/Core/tests/DeviceTests/LifecycleEventOrderTests.iOS.cs b/src/Core/tests/DeviceTests/LifecycleEventOrderTests.iOS.cs deleted file mode 100644 index ba5d87f967e6..000000000000 --- a/src/Core/tests/DeviceTests/LifecycleEventOrderTests.iOS.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Linq; -using Microsoft.Maui.LifecycleEvents; -using Xunit; - -namespace Microsoft.Maui.DeviceTests -{ - [Category(TestCategory.Lifecycle)] - public class LifecycleEventOrderTests - { - [Fact(DisplayName = "iOS lifecycle events fire during startup")] - public void iOSLifecycleEventsFireDuringStartup() - { - var log = MauiProgram.LifecycleEventLog; - - Assert.Contains(nameof(iOSLifecycle.FinishedLaunching), log); - } - - [Fact(DisplayName = "FinishedLaunching fires before OnActivated")] - public void FinishedLaunchingFiresBeforeOnActivated() - { - var log = MauiProgram.LifecycleEventLog; - - var launchIndex = log.IndexOf(nameof(iOSLifecycle.FinishedLaunching)); - Assert.True(launchIndex >= 0, "FinishedLaunching should have fired"); - - var activatedIndex = log.IndexOf(nameof(iOSLifecycle.OnActivated)); - if (activatedIndex >= 0) - { - Assert.True(launchIndex < activatedIndex, - $"Expected FinishedLaunching before OnActivated. Log: [{string.Join(", ", log)}]"); - } - } - - [Fact(DisplayName = "FinishedLaunching fires exactly once during startup")] - public void FinishedLaunchingFiresExactlyOnce() - { - var count = MauiProgram.LifecycleEventLog.Count(e => e == nameof(iOSLifecycle.FinishedLaunching)); - Assert.Equal(1, count); - } - } -} diff --git a/src/Core/tests/DeviceTests/MauiProgram.cs b/src/Core/tests/DeviceTests/MauiProgram.cs index aac0f4820228..072a30513cac 100644 --- a/src/Core/tests/DeviceTests/MauiProgram.cs +++ b/src/Core/tests/DeviceTests/MauiProgram.cs @@ -41,32 +41,9 @@ public static MauiApp CreateMauiApp() }, configureBuilder: builder => { +#if WINDOWS builder.ConfigureLifecycleEvents(life => { -#if ANDROID - life.AddAndroid(android => - { - android.OnCreate((a, b) => - LifecycleEventLog.Add(nameof(AndroidLifecycle.OnCreate))); - android.OnStart(a => - LifecycleEventLog.Add(nameof(AndroidLifecycle.OnStart))); - android.OnResume(a => - LifecycleEventLog.Add(nameof(AndroidLifecycle.OnResume))); - android.OnPostResume(a => - LifecycleEventLog.Add(nameof(AndroidLifecycle.OnPostResume))); - }); -#elif IOS || MACCATALYST - life.AddiOS(ios => - { - ios.FinishedLaunching((app, options) => - { - LifecycleEventLog.Add(nameof(iOSLifecycle.FinishedLaunching)); - return true; - }); - ios.OnActivated(app => - LifecycleEventLog.Add(nameof(iOSLifecycle.OnActivated))); - }); -#elif WINDOWS life.AddWindows(windows => { windows.OnAppInstanceActivated((app, args) => @@ -81,9 +58,9 @@ public static MauiApp CreateMauiApp() windows.OnWindowCreated(w => LifecycleEventLog.Add(nameof(WindowsLifecycle.OnWindowCreated))); }); -#endif }); +#endif }); } } -} \ No newline at end of file +} diff --git a/src/Essentials/src/WebAuthenticator/WebAuthenticator.windows.cs b/src/Essentials/src/WebAuthenticator/WebAuthenticator.windows.cs index c4e9ad9a7ff3..3dc19b2abf07 100644 --- a/src/Essentials/src/WebAuthenticator/WebAuthenticator.windows.cs +++ b/src/Essentials/src/WebAuthenticator/WebAuthenticator.windows.cs @@ -73,7 +73,7 @@ redirectUri is not null && return true; } - // A callback can have the expected scheme and still fail host/path validation. + // A callback can have the expected scheme and still fail host validation. // Leave the active route registered so a later valid callback can complete it. if (isLocalCallbackRoute) return false; @@ -237,7 +237,8 @@ static void RegisterCallbackRoute(Uri callbackUrl) if (string.Equals(currentKey, routeKey, StringComparison.Ordinal)) return; - // Preserve application-owned AppInstance routing. + // Preserve application-owned AppInstance routing. Such apps must redirect protocol + // activations to this instance so the lifecycle callback above can complete the request. if (!CanRegisterCallbackRoute(currentKey)) return; diff --git a/src/Essentials/test/DeviceTests/Tests/WebAuthenticator_Windows_Tests.cs b/src/Essentials/test/DeviceTests/Tests/Windows/WebAuthenticator_Windows_Tests.cs similarity index 98% rename from src/Essentials/test/DeviceTests/Tests/WebAuthenticator_Windows_Tests.cs rename to src/Essentials/test/DeviceTests/Tests/Windows/WebAuthenticator_Windows_Tests.cs index 03bcb7b1560e..3475503cfeb3 100644 --- a/src/Essentials/test/DeviceTests/Tests/WebAuthenticator_Windows_Tests.cs +++ b/src/Essentials/test/DeviceTests/Tests/Windows/WebAuthenticator_Windows_Tests.cs @@ -1,4 +1,3 @@ -#if WINDOWS using System; using Microsoft.Maui.Authentication; using Xunit; @@ -49,4 +48,3 @@ public void IsSameCallbackRouteUsesSchemeOnly(string expectedCallbackUrl, string } } } -#endif