Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Maui.ApplicationModel;
using Microsoft.Maui.Dispatching;
using Microsoft.Maui.Hosting;
using Microsoft.Maui.LifecycleEvents;
#if ANDROID
Expand Down Expand Up @@ -82,6 +83,8 @@ internal static MauiAppBuilder UseEssentials(this MauiAppBuilder builder)
#endif
});

builder.Services.TryAddEnumerable(ServiceDescriptor.Transient<IMauiInitializeService, EssentialsInitializer>());

return builder;
}

Expand All @@ -91,6 +94,7 @@ public static MauiAppBuilder ConfigureEssentials(this MauiAppBuilder builder, Ac
{
builder.Services.AddSingleton<EssentialsRegistration>(new EssentialsRegistration(configureDelegate));
}

builder.Services.TryAddEnumerable(ServiceDescriptor.Transient<IMauiInitializeService, EssentialsInitializer>());

return builder;
Expand Down Expand Up @@ -135,6 +139,10 @@ public void Initialize(IServiceProvider services)
}
}

#if !(ANDROID || __IOS__ || __MACCATALYST__ || WINDOWS || TIZEN)
BridgeMainThreadFromDispatcher(services);
#endif

#if WINDOWS
ApplicationModel.Platform.MapServiceToken = _essentialsBuilder.MapServiceToken;
#endif
Expand All @@ -152,6 +160,25 @@ public void Initialize(IServiceProvider services)
VersionTracking.Track();
}

/// <summary>
/// Bridges the MAUI application dispatcher to MainThread so that
/// MainThread.BeginInvokeOnMainThread and MainThread.IsMainThread work
/// on custom platform backends / external TFMs where no native
/// MainThread implementation exists.
/// </summary>
#if !(ANDROID || __IOS__ || __MACCATALYST__ || WINDOWS || TIZEN)
static void BridgeMainThreadFromDispatcher(IServiceProvider services)
{
var dispatcher = services.GetOptionalApplicationDispatcher();
if (dispatcher is null)
return;

MainThread.SetCustomImplementation(
isMainThread: () => !dispatcher.IsDispatchRequired,
beginInvokeOnMainThread: action => dispatcher.Dispatch(action));
}
#endif

private static async void SetAppActions(IServiceProvider services, List<AppAction> appActions)
{
try
Expand Down
233 changes: 233 additions & 0 deletions src/Core/tests/UnitTests/Hosting/MainThreadBridgeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
#nullable enable

using System;
using Microsoft.Maui.ApplicationModel;
using Microsoft.Maui.Dispatching;
using Microsoft.Maui.Hosting;
using Xunit;

namespace Microsoft.Maui.UnitTests.Hosting
{
[Category(TestCategory.Core, TestCategory.Hosting)]
public class MainThreadBridgeTests : IDisposable
{
public MainThreadBridgeTests()
{
// Ensure clean state before each test
MainThread.ClearCustomImplementation();
}

public void Dispose()
{
MainThread.ClearCustomImplementation();
DispatcherProvider.SetCurrent(null);
}
Comment thread
Redth marked this conversation as resolved.

[Fact]
public void WithoutCustomImpl_IsMainThread_Throws()
{
// On netstandard with no backing implementation, IsMainThread should throw
Assert.Throws<NotImplementedInReferenceAssemblyException>(
() => _ = MainThread.IsMainThread);
}

[Fact]
public void WithoutCustomImpl_BeginInvoke_Throws()
{
// On netstandard with no backing implementation, BeginInvokeOnMainThread should throw
Assert.Throws<NotImplementedInReferenceAssemblyException>(
() => MainThread.BeginInvokeOnMainThread(() => { }));
}

[Fact]
public void WithCustomImpl_IsMainThread_UsesBackingImpl()
{
MainThread.SetCustomImplementation(
isMainThread: () => true,
beginInvokeOnMainThread: _ => { });

Assert.True(MainThread.IsMainThread);
}

[Fact]
public void WithCustomImpl_IsMainThread_ReturnsFalse()
{
MainThread.SetCustomImplementation(
isMainThread: () => false,
beginInvokeOnMainThread: _ => { });

Assert.False(MainThread.IsMainThread);
}

[Fact]
public void WithCustomImpl_BeginInvoke_CallsBackingImpl()
{
var invoked = false;
Action? capturedAction = null;

MainThread.SetCustomImplementation(
isMainThread: () => false,
beginInvokeOnMainThread: action => capturedAction = action);

MainThread.BeginInvokeOnMainThread(() => invoked = true);

// The custom impl captured the action; execute it
Assert.NotNull(capturedAction);
capturedAction!();
Assert.True(invoked);
}

[Fact]
public void BeginInvoke_WhenOnMainThread_InvokesDirectly()
{
var invoked = false;

MainThread.SetCustomImplementation(
isMainThread: () => true,
beginInvokeOnMainThread: _ => throw new InvalidOperationException("Should not be called"));

// When IsMainThread returns true, the shared code invokes the action directly
MainThread.BeginInvokeOnMainThread(() => invoked = true);

Assert.True(invoked);
}

[Fact]
public void ClearCustomImpl_RestoresThrowBehavior()
{
MainThread.SetCustomImplementation(
isMainThread: () => true,
beginInvokeOnMainThread: _ => { });

Assert.True(MainThread.IsMainThread);

MainThread.ClearCustomImplementation();

Assert.Throws<NotImplementedInReferenceAssemblyException>(
() => _ = MainThread.IsMainThread);
}

[Fact]
public void SetCustomImpl_NullIsMainThread_Throws()
{
Assert.Throws<ArgumentNullException>(
() => MainThread.SetCustomImplementation(null!, _ => { }));
}

[Fact]
public void SetCustomImpl_NullBeginInvoke_Throws()
{
Assert.Throws<ArgumentNullException>(
() => MainThread.SetCustomImplementation(() => true, null!));
}

[Fact]
public void MauiAppBuild_BridgesDispatcherToMainThread()
{
// Set up a dispatcher provider that returns a real dispatcher stub
var dispatcherStub = new DispatcherStub(
isInvokeRequired: () => false,
invokeOnMainThread: null);

var dispatcherProvider = new TestDispatcherProvider(dispatcherStub);
DispatcherProvider.SetCurrent(dispatcherProvider);

try
{
var builder = MauiApp.CreateBuilder();
using var app = builder.Build();

// After MauiApp.Build(), the bridge should have connected MainThread
// to the dispatcher. IsMainThread should return true (since IsDispatchRequired is false).
Assert.True(MainThread.IsMainThread);
}
finally
{
DispatcherProvider.SetCurrent(null);
}
}

[Fact]
public void MauiAppBuild_BeginInvoke_DispatchesToDispatcher()
{
var dispatched = false;
var dispatcherStub = new DispatcherStub(
isInvokeRequired: () => true,
invokeOnMainThread: action => { dispatched = true; action(); });

var dispatcherProvider = new TestDispatcherProvider(dispatcherStub);
DispatcherProvider.SetCurrent(dispatcherProvider);

try
{
var builder = MauiApp.CreateBuilder();
using var app = builder.Build();

var actionExecuted = false;
MainThread.BeginInvokeOnMainThread(() => actionExecuted = true);

Assert.True(dispatched);
Assert.True(actionExecuted);
}
finally
{
DispatcherProvider.SetCurrent(null);
}
}

[Fact]
public void MauiAppBuild_UsesCachedApplicationDispatcherForBridge()
{
var dispatcherStub = new DispatcherStub(
isInvokeRequired: () => false,
invokeOnMainThread: action => action());

var dispatcherProvider = new SequencedDispatcherProvider(dispatcherStub, null);
DispatcherProvider.SetCurrent(dispatcherProvider);

try
{
var builder = MauiApp.CreateBuilder();
using var app = builder.Build();

Assert.True(MainThread.IsMainThread);
}
finally
{
DispatcherProvider.SetCurrent(null);
}
}

class TestDispatcherProvider : IDispatcherProvider
{
readonly IDispatcher _dispatcher;

public TestDispatcherProvider(IDispatcher dispatcher)
{
_dispatcher = dispatcher;
}

public IDispatcher? GetForCurrentThread() => _dispatcher;
}

class SequencedDispatcherProvider : IDispatcherProvider
{
readonly IDispatcher?[] _dispatchers;
int _index;

public SequencedDispatcherProvider(params IDispatcher?[] dispatchers)
{
_dispatchers = dispatchers;
}

public IDispatcher? GetForCurrentThread()
{
var index = _index++;
if (index >= _dispatchers.Length)
index = _dispatchers.Length - 1;

return index >= 0 ? _dispatchers[index] : null;
}
}
}
}
2 changes: 2 additions & 0 deletions src/Essentials/src/AssemblyInfo/AssemblyInfo.shared.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("Microsoft.Maui")]
[assembly: InternalsVisibleTo("Microsoft.Maui.Essentials.DeviceTests")]
[assembly: InternalsVisibleTo("Microsoft.Maui.Essentials.UnitTests")]
[assembly: InternalsVisibleTo("EssentialsTests")]
Expand All @@ -18,3 +19,4 @@
[assembly: InternalsVisibleTo("Microsoft.Maui.TestUtils")]
[assembly: InternalsVisibleTo("Microsoft.Maui.TestUtils.DeviceTests")]
[assembly: InternalsVisibleTo("Microsoft.Maui.TestUtils.DeviceTests.Runners")]
[assembly: InternalsVisibleTo("Microsoft.Maui.UnitTests")]
24 changes: 20 additions & 4 deletions src/Essentials/src/MainThread/MainThread.netstandard.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,29 @@
using System;
using System.Threading;

namespace Microsoft.Maui.ApplicationModel
{
public static partial class MainThread
{
static void PlatformBeginInvokeOnMainThread(Action action) =>
throw ExceptionUtils.NotSupportedOrImplementedException;
static bool PlatformIsMainThread
{
get
{
var implementation = Volatile.Read(ref s_mainThreadImplementation);
if (implementation is not null)
return implementation.IsMainThread();

static bool PlatformIsMainThread =>
throw ExceptionUtils.NotSupportedOrImplementedException;
throw ExceptionUtils.NotSupportedOrImplementedException;
}
}

static void PlatformBeginInvokeOnMainThread(Action action)
{
var implementation = Volatile.Read(ref s_mainThreadImplementation);
if (implementation is not null)
implementation.BeginInvokeOnMainThread(action);
else
throw ExceptionUtils.NotSupportedOrImplementedException;
}
}
}
33 changes: 33 additions & 0 deletions src/Essentials/src/MainThread/MainThread.shared.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,39 @@ namespace Microsoft.Maui.ApplicationModel
/// </summary>
public static partial class MainThread
{
// Internal backing for custom platform backends and dispatcher fallback.
// On supported platforms (Android, iOS, Windows), the Platform* methods are used directly.
// On netstandard/external TFMs, this provides the implementation as a single atomic state object.
static MainThreadImplementation s_mainThreadImplementation;

sealed class MainThreadImplementation
{
readonly Func<bool> _isMainThread;
readonly Action<Action> _beginInvokeOnMainThread;

public MainThreadImplementation(Func<bool> isMainThread, Action<Action> beginInvokeOnMainThread)
{
_isMainThread = isMainThread;
_beginInvokeOnMainThread = beginInvokeOnMainThread;
}

public bool IsMainThread() => _isMainThread();

public void BeginInvokeOnMainThread(Action action) => _beginInvokeOnMainThread(action);
}

internal static void SetCustomImplementation(Func<bool> isMainThread, Action<Action> beginInvokeOnMainThread)
{
Volatile.Write(ref s_mainThreadImplementation, new MainThreadImplementation(
isMainThread ?? throw new ArgumentNullException(nameof(isMainThread)),
beginInvokeOnMainThread ?? throw new ArgumentNullException(nameof(beginInvokeOnMainThread))));
}

internal static void ClearCustomImplementation()
{
Volatile.Write(ref s_mainThreadImplementation, null);
}

/// <summary>
/// True if the current thread is the UI thread.
/// </summary>
Expand Down
Loading