diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index fc8874e7fa8..0995b202f4a 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -6,10 +6,12 @@
/src/Aspire.Hosting.Azure.AppContainers @captainsafia @eerhardt
/src/Aspire.Hosting.Azure.AppService @captainsafia @eerhardt
/src/Aspire.Hosting.Docker @captainsafia
+/src/Aspire.Hosting.Maui @jfversluis
# tests
/tests/Aspire.EndToEnd.Tests @radical @eerhardt
+/tests/Aspire.Hosting.Maui.Tests @jfversluis
/tests/Aspire.Hosting.Testing.Tests @reubenbond
/tests/Aspire.Hosting.Tests @mitchdenny
/tests/Aspire.Templates.Tests @radical @eerhardt
@@ -20,3 +22,4 @@
# playground apps
/playground/deployers @captainsafia
/playground/publishers @captainsafia
+/playground/AspireWithMaui @jfversluis
diff --git a/eng/Build.props b/eng/Build.props
index ea01d331e94..f07ca2fe49f 100644
--- a/eng/Build.props
+++ b/eng/Build.props
@@ -28,7 +28,9 @@
-
+
+
+
+
+ Exe
+ AspireWithMaui.MauiClient
+ true
+ true
+ enable
+ enable
+
+
+ false
+
+
+ $(NoWarn);CS8002
+
+
+ $(NoWarn);IDE0005
+
+
+ AspireWithMaui.MauiClient
+
+
+ com.companyname.aspirewithmaui.mauiclient
+
+
+ 1.0
+ 1
+
+
+ None
+
+ 15.0
+ 15.0
+ 21.0
+ 10.0.17763.0
+ 10.0.17763.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/EnvironmentPage.xaml b/playground/AspireWithMaui/AspireWithMaui.MauiClient/EnvironmentPage.xaml
new file mode 100644
index 00000000000..946c5d642fc
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/EnvironmentPage.xaml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/EnvironmentPage.xaml.cs b/playground/AspireWithMaui/AspireWithMaui.MauiClient/EnvironmentPage.xaml.cs
new file mode 100644
index 00000000000..f9a1325aa41
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/EnvironmentPage.xaml.cs
@@ -0,0 +1,79 @@
+using System.Collections;
+using System.Collections.ObjectModel;
+
+namespace AspireWithMaui.MauiClient;
+
+public partial class EnvironmentPage : ContentPage
+{
+ public ObservableCollection> AspireEnvironmentVariables { get; } = new();
+
+ public EnvironmentPage()
+ {
+ InitializeComponent();
+ BindingContext = this;
+ }
+
+ protected override void OnAppearing()
+ {
+ base.OnAppearing();
+ LoadAspireEnvironmentVariables();
+ }
+
+ private void LoadAspireEnvironmentVariables()
+ {
+ AspireEnvironmentVariables.Clear();
+
+ var variables = Environment.GetEnvironmentVariables()
+ .Cast()
+ .Select(entry => new KeyValuePair(entry.Key?.ToString() ?? string.Empty, DecodeValue(entry.Value?.ToString())))
+ .Where(item => IsAspireVariable(item.Key))
+ .OrderBy(item => item.Key, StringComparer.OrdinalIgnoreCase);
+
+ foreach (var variable in variables)
+ {
+ AspireEnvironmentVariables.Add(variable);
+ }
+ }
+
+ private static string DecodeValue(string? value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return string.Empty;
+ }
+
+ try
+ {
+ var decoded = Uri.UnescapeDataString(value);
+
+ // Validate that the decoded string doesn't contain control characters that could indicate malicious content
+ // Allow only printable characters, tabs, and newlines
+ if (decoded.Any(c => char.IsControl(c) && c != '\t' && c != '\n' && c != '\r'))
+ {
+ // If suspicious control characters found, return the original encoded value for safety
+ return value;
+ }
+
+ return decoded;
+ }
+ catch (UriFormatException)
+ {
+ // If decoding fails, return the original value
+ return value;
+ }
+ }
+
+ private static bool IsAspireVariable(string key)
+ => key.StartsWith("services__", StringComparison.OrdinalIgnoreCase)
+ || key.StartsWith("connectionstrings__", StringComparison.OrdinalIgnoreCase)
+ || key.StartsWith("ASPIRE_", StringComparison.OrdinalIgnoreCase)
+ || key.StartsWith("AppHost__", StringComparison.OrdinalIgnoreCase)
+ || key.StartsWith("OTEL_", StringComparison.OrdinalIgnoreCase)
+ || key.StartsWith("LOGGING__CONSOLE", StringComparison.OrdinalIgnoreCase)
+ || key.Equals("ASPNETCORE_ENVIRONMENT", StringComparison.OrdinalIgnoreCase)
+ || key.Equals("ASPNETCORE_URLS", StringComparison.OrdinalIgnoreCase)
+ || key.Equals("DOTNET_ENVIRONMENT", StringComparison.OrdinalIgnoreCase)
+ || key.Equals("DOTNET_URLS", StringComparison.OrdinalIgnoreCase)
+ || key.Equals("DOTNET_LAUNCH_PROFILE", StringComparison.OrdinalIgnoreCase)
+ || key.Equals("DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION", StringComparison.OrdinalIgnoreCase);
+}
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/MainPage.xaml b/playground/AspireWithMaui/AspireWithMaui.MauiClient/MainPage.xaml
new file mode 100644
index 00000000000..6e8ebf4d413
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/MainPage.xaml
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/MainPage.xaml.cs b/playground/AspireWithMaui/AspireWithMaui.MauiClient/MainPage.xaml.cs
new file mode 100644
index 00000000000..2ec0a70018b
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/MainPage.xaml.cs
@@ -0,0 +1,47 @@
+using System.Collections.ObjectModel;
+using AspireWithMaui.MauiClient.Services;
+
+namespace AspireWithMaui.MauiClient;
+
+public partial class MainPage : ContentPage
+{
+ private readonly IWeatherService _weatherService;
+ public ObservableCollection WeatherData { get; set; } = new();
+
+ public MainPage(IWeatherService weatherService)
+ {
+ _weatherService = weatherService;
+ InitializeComponent();
+ BindingContext = this;
+ }
+
+ private async void OnLoadWeatherClicked(object? sender, EventArgs e)
+ {
+ try
+ {
+ StatusLabel.Text = "Loading weather data...";
+ LoadWeatherBtn.IsEnabled = false;
+
+ var weatherData = await _weatherService.GetWeatherForecastAsync();
+
+ WeatherData.Clear();
+ foreach (var item in weatherData)
+ {
+ WeatherData.Add(item);
+ }
+
+ StatusLabel.Text = weatherData.Length > 0
+ ? $"Loaded {weatherData.Length} weather forecasts"
+ : "No weather data available";
+ }
+ catch (Exception ex)
+ {
+ StatusLabel.Text = $"Error: {ex.Message}";
+ }
+ finally
+ {
+ LoadWeatherBtn.IsEnabled = true;
+ }
+ }
+
+}
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/MauiProgram.cs b/playground/AspireWithMaui/AspireWithMaui.MauiClient/MauiProgram.cs
new file mode 100644
index 00000000000..8cc82666213
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/MauiProgram.cs
@@ -0,0 +1,40 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Hosting;
+using AspireWithMaui.MauiClient.Services;
+
+namespace AspireWithMaui.MauiClient;
+
+public static class MauiProgram
+{
+ public static MauiApp CreateMauiApp()
+ {
+ var builder = MauiApp.CreateBuilder();
+ builder
+ .UseMauiApp()
+ .ConfigureFonts(fonts =>
+ {
+ fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
+ fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
+ });
+
+ // Add service defaults & Aspire components.
+ builder.AddServiceDefaults();
+
+ // Register services
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+
+ // Configure HTTP client for weather API
+ builder.Services.AddHttpClient(client =>
+ {
+ // This will be resolved via service discovery when running with Aspire
+ client.BaseAddress = new Uri("https://webapi");
+ });
+
+#if DEBUG
+ builder.Logging.AddDebug();
+#endif
+
+ return builder.Build();
+ }
+}
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Models/WeatherForecast.cs b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Models/WeatherForecast.cs
new file mode 100644
index 00000000000..38533f54d74
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Models/WeatherForecast.cs
@@ -0,0 +1,9 @@
+namespace AspireWithMaui.MauiClient;
+
+public class WeatherForecast
+{
+ public DateOnly Date { get; set; }
+ public int TemperatureC { get; set; }
+ public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
+ public string? Summary { get; set; }
+}
\ No newline at end of file
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/Android/AndroidManifest.xml b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/Android/AndroidManifest.xml
new file mode 100644
index 00000000000..e9937ad77d5
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/Android/AndroidManifest.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/Android/MainActivity.cs b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/Android/MainActivity.cs
new file mode 100644
index 00000000000..4fe743c8c05
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/Android/MainActivity.cs
@@ -0,0 +1,10 @@
+using Android.App;
+using Android.Content.PM;
+using Android.OS;
+
+namespace AspireWithMaui.MauiClient;
+
+[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
+public class MainActivity : MauiAppCompatActivity
+{
+}
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/Android/MainApplication.cs b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/Android/MainApplication.cs
new file mode 100644
index 00000000000..57d3aad17c0
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/Android/MainApplication.cs
@@ -0,0 +1,15 @@
+using Android.App;
+using Android.Runtime;
+
+namespace AspireWithMaui.MauiClient;
+
+[Application]
+public class MainApplication : MauiApplication
+{
+ public MainApplication(IntPtr handle, JniHandleOwnership ownership)
+ : base(handle, ownership)
+ {
+ }
+
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/Android/Resources/values/colors.xml b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/Android/Resources/values/colors.xml
new file mode 100644
index 00000000000..c04d7492abf
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/Android/Resources/values/colors.xml
@@ -0,0 +1,6 @@
+
+
+ #512BD4
+ #2B0B98
+ #2B0B98
+
\ No newline at end of file
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/MacCatalyst/AppDelegate.cs b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/MacCatalyst/AppDelegate.cs
new file mode 100644
index 00000000000..645c5383324
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/MacCatalyst/AppDelegate.cs
@@ -0,0 +1,9 @@
+using Foundation;
+
+namespace AspireWithMaui.MauiClient;
+
+[Register("AppDelegate")]
+public class AppDelegate : MauiUIApplicationDelegate
+{
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/MacCatalyst/Entitlements.plist b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/MacCatalyst/Entitlements.plist
new file mode 100644
index 00000000000..de4adc94a9c
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/MacCatalyst/Entitlements.plist
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ com.apple.security.app-sandbox
+
+
+ com.apple.security.network.client
+
+
+
+
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/MacCatalyst/Info.plist b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/MacCatalyst/Info.plist
new file mode 100644
index 00000000000..72689771518
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/MacCatalyst/Info.plist
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ UIDeviceFamily
+
+ 2
+
+ UIRequiredDeviceCapabilities
+
+ arm64
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ XSAppIconAssets
+ Assets.xcassets/appicon.appiconset
+
+
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/MacCatalyst/Program.cs b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/MacCatalyst/Program.cs
new file mode 100644
index 00000000000..72261da3037
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/MacCatalyst/Program.cs
@@ -0,0 +1,15 @@
+using ObjCRuntime;
+using UIKit;
+
+namespace AspireWithMaui.MauiClient;
+
+public class Program
+{
+ // This is the main entry point of the application.
+ static void Main(string[] args)
+ {
+ // if you want to use a different Application Delegate class from "AppDelegate"
+ // you can specify it here.
+ UIApplication.Main(args, null, typeof(AppDelegate));
+ }
+}
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/Windows/App.xaml b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/Windows/App.xaml
new file mode 100644
index 00000000000..b6cb18598ae
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/Windows/App.xaml
@@ -0,0 +1,8 @@
+
+
+
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/Windows/App.xaml.cs b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/Windows/App.xaml.cs
new file mode 100644
index 00000000000..cae0e867000
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/Windows/App.xaml.cs
@@ -0,0 +1,24 @@
+using Microsoft.UI.Xaml;
+
+// To learn more about WinUI, the WinUI project structure,
+// and more about our project templates, see: http://aka.ms/winui-project-info.
+
+namespace AspireWithMaui.MauiClient.WinUI;
+
+///
+/// Provides application-specific behavior to supplement the default Application class.
+///
+public partial class App : MauiWinUIApplication
+{
+ ///
+ /// Initializes the singleton application object. This is the first line of authored code
+ /// executed, and as such is the logical equivalent of main() or WinMain().
+ ///
+ public App()
+ {
+ this.InitializeComponent();
+ }
+
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
+
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/Windows/Package.appxmanifest b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/Windows/Package.appxmanifest
new file mode 100644
index 00000000000..8042602c07f
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/Windows/Package.appxmanifest
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+ $placeholder$
+ User Name
+ $placeholder$.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/Windows/app.manifest b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/Windows/app.manifest
new file mode 100644
index 00000000000..d4a8e475553
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/Windows/app.manifest
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+ true/PM
+ PerMonitorV2, PerMonitor
+
+
+
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/iOS/AppDelegate.cs b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/iOS/AppDelegate.cs
new file mode 100644
index 00000000000..645c5383324
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/iOS/AppDelegate.cs
@@ -0,0 +1,9 @@
+using Foundation;
+
+namespace AspireWithMaui.MauiClient;
+
+[Register("AppDelegate")]
+public class AppDelegate : MauiUIApplicationDelegate
+{
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/iOS/Info.plist b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/iOS/Info.plist
new file mode 100644
index 00000000000..0004a4fdee5
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/iOS/Info.plist
@@ -0,0 +1,32 @@
+
+
+
+
+ LSRequiresIPhoneOS
+
+ UIDeviceFamily
+
+ 1
+ 2
+
+ UIRequiredDeviceCapabilities
+
+ arm64
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ XSAppIconAssets
+ Assets.xcassets/appicon.appiconset
+
+
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/iOS/Program.cs b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/iOS/Program.cs
new file mode 100644
index 00000000000..72261da3037
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/iOS/Program.cs
@@ -0,0 +1,15 @@
+using ObjCRuntime;
+using UIKit;
+
+namespace AspireWithMaui.MauiClient;
+
+public class Program
+{
+ // This is the main entry point of the application.
+ static void Main(string[] args)
+ {
+ // if you want to use a different Application Delegate class from "AppDelegate"
+ // you can specify it here.
+ UIApplication.Main(args, null, typeof(AppDelegate));
+ }
+}
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/iOS/Resources/PrivacyInfo.xcprivacy b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/iOS/Resources/PrivacyInfo.xcprivacy
new file mode 100644
index 00000000000..24ab3b4334c
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Platforms/iOS/Resources/PrivacyInfo.xcprivacy
@@ -0,0 +1,51 @@
+
+
+
+
+
+ NSPrivacyAccessedAPITypes
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryFileTimestamp
+ NSPrivacyAccessedAPITypeReasons
+
+ C617.1
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategorySystemBootTime
+ NSPrivacyAccessedAPITypeReasons
+
+ 35F9.1
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryDiskSpace
+ NSPrivacyAccessedAPITypeReasons
+
+ E174.1
+
+
+
+
+
+
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/AppIcon/appicon.svg b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/AppIcon/appicon.svg
new file mode 100644
index 00000000000..9d63b6513a1
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/AppIcon/appicon.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/AppIcon/appiconfg.svg b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/AppIcon/appiconfg.svg
new file mode 100644
index 00000000000..21dfb25f187
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/AppIcon/appiconfg.svg
@@ -0,0 +1,8 @@
+
+
+
\ No newline at end of file
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Fonts/OpenSans-Regular.ttf b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Fonts/OpenSans-Regular.ttf
new file mode 100644
index 00000000000..29bfd35a2bf
Binary files /dev/null and b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Fonts/OpenSans-Regular.ttf differ
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Fonts/OpenSans-Semibold.ttf b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Fonts/OpenSans-Semibold.ttf
new file mode 100644
index 00000000000..54e7059cf36
Binary files /dev/null and b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Fonts/OpenSans-Semibold.ttf differ
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Images/aspire_outline.svg b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Images/aspire_outline.svg
new file mode 100644
index 00000000000..67bffec9701
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Images/aspire_outline.svg
@@ -0,0 +1,8 @@
+
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Images/dotnet_bot.png b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Images/dotnet_bot.png
new file mode 100644
index 00000000000..8e003edf960
Binary files /dev/null and b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Images/dotnet_bot.png differ
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Images/weather.png b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Images/weather.png
new file mode 100644
index 00000000000..319a9d95942
Binary files /dev/null and b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Images/weather.png differ
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Raw/AboutAssets.txt b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Raw/AboutAssets.txt
new file mode 100644
index 00000000000..89dc758d6e0
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Raw/AboutAssets.txt
@@ -0,0 +1,15 @@
+Any raw assets you want to be deployed with your application can be placed in
+this directory (and child directories). Deployment of the asset to your application
+is automatically handled by the following `MauiAsset` Build Action within your `.csproj`.
+
+
+
+These files will be deployed with your package and will be accessible using Essentials:
+
+ async Task LoadMauiAsset()
+ {
+ using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt");
+ using var reader = new StreamReader(stream);
+
+ var contents = reader.ReadToEnd();
+ }
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Splash/splash.svg b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Splash/splash.svg
new file mode 100644
index 00000000000..21dfb25f187
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Splash/splash.svg
@@ -0,0 +1,8 @@
+
+
+
\ No newline at end of file
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Styles/Colors.xaml b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Styles/Colors.xaml
new file mode 100644
index 00000000000..30307a5ddc3
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Styles/Colors.xaml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+ #512BD4
+ #ac99ea
+ #242424
+ #DFD8F7
+ #9880e5
+ #2B0B98
+
+ White
+ Black
+ #D600AA
+ #190649
+ #1f1f1f
+
+ #E1E1E1
+ #C8C8C8
+ #ACACAC
+ #919191
+ #6E6E6E
+ #404040
+ #212121
+ #141414
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Styles/Styles.xaml b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Styles/Styles.xaml
new file mode 100644
index 00000000000..63627e216dc
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Resources/Styles/Styles.xaml
@@ -0,0 +1,456 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Services/IWeatherService.cs b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Services/IWeatherService.cs
new file mode 100644
index 00000000000..2f062b03cfd
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Services/IWeatherService.cs
@@ -0,0 +1,6 @@
+namespace AspireWithMaui.MauiClient.Services;
+
+public interface IWeatherService
+{
+ Task GetWeatherForecastAsync();
+}
\ No newline at end of file
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/Services/WeatherService.cs b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Services/WeatherService.cs
new file mode 100644
index 00000000000..c6f13d7db92
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/Services/WeatherService.cs
@@ -0,0 +1,29 @@
+using System.Net.Http.Json;
+
+namespace AspireWithMaui.MauiClient.Services;
+
+public class WeatherService : IWeatherService
+{
+ private readonly HttpClient _httpClient;
+
+ public WeatherService(HttpClient httpClient)
+ {
+ _httpClient = httpClient;
+ }
+
+ public async Task GetWeatherForecastAsync()
+ {
+ try
+ {
+ // Make request to the weather API via service discovery
+ var response = await _httpClient.GetFromJsonAsync("WeatherForecast");
+ return response ?? Array.Empty();
+ }
+ catch (Exception ex)
+ {
+ // In a real app, you'd want better error handling
+ System.Diagnostics.Debug.WriteLine($"Error getting weather: {ex.Message}");
+ return Array.Empty();
+ }
+ }
+}
\ No newline at end of file
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiServiceDefaults/AspireWithMaui.MauiServiceDefaults.csproj b/playground/AspireWithMaui/AspireWithMaui.MauiServiceDefaults/AspireWithMaui.MauiServiceDefaults.csproj
new file mode 100644
index 00000000000..8196bd8d803
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiServiceDefaults/AspireWithMaui.MauiServiceDefaults.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net10.0
+ enable
+ enable
+ true
+
+ false
+
+ $(NoWarn);IDE0005;CS8002
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiServiceDefaults/Extensions.cs b/playground/AspireWithMaui/AspireWithMaui.MauiServiceDefaults/Extensions.cs
new file mode 100644
index 00000000000..d9ac7fb3e28
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiServiceDefaults/Extensions.cs
@@ -0,0 +1,137 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Http;
+using Microsoft.Extensions.Logging;
+using Microsoft.Maui.Hosting;
+using Microsoft.Maui.LifecycleEvents;
+using OpenTelemetry;
+using OpenTelemetry.Logs;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Trace;
+using System.Text.RegularExpressions;
+
+namespace Microsoft.Extensions.Hosting;
+
+// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
+// This project should be referenced by each service project in your solution.
+// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
+public static class Extensions
+{
+ public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.ConfigureOpenTelemetry();
+
+ builder.Services.AddServiceDiscovery();
+
+ builder.Services.ConfigureHttpClientDefaults(http =>
+ {
+ // Turn on resilience by default
+ http.AddStandardResilienceHandler();
+
+ // Turn on service discovery by default
+ http.AddServiceDiscovery();
+ });
+
+ // Uncomment the following to restrict the allowed schemes for service discovery.
+ // builder.Services.Configure(options =>
+ // {
+ // options.AllowedSchemes = ["https"];
+ // });
+
+ return builder;
+ }
+
+ public static MauiAppBuilder AddServiceDefaults(this MauiAppBuilder builder)
+ {
+ builder.ConfigureOpenTelemetry();
+
+ builder.Services.AddServiceDiscovery();
+
+ builder.Services.ConfigureHttpClientDefaults(http =>
+ {
+ // Turn on resilience by default
+ http.AddStandardResilienceHandler();
+
+ // Turn on service discovery by default
+ http.AddServiceDiscovery();
+ });
+
+ builder.Services.TryAddEnumerable(
+ ServiceDescriptor.Transient(_ => new OpenTelemetryInitializer()));
+
+ // Uncomment the following to restrict the allowed schemes for service discovery.
+ // builder.Services.Configure(options =>
+ // {
+ // options.AllowedSchemes = ["https"];
+ // });
+
+ return builder;
+ }
+
+ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.Logging.AddOpenTelemetry(logging =>
+ {
+ logging.IncludeFormattedMessage = true;
+ logging.IncludeScopes = true;
+ });
+
+ builder.Services.AddOpenTelemetry()
+ .WithMetrics(metrics =>
+ {
+ // Uncomment the following line to enable reporting metrics coming from the .NET MAUI SDK, this might cause a lot of added telemetry
+ //metrics.AddMeter("Microsoft.Maui");
+
+ metrics.AddHttpClientInstrumentation()
+ .AddRuntimeInstrumentation();
+ })
+ .WithTracing(tracing =>
+ {
+ // Uncomment the following line to enable reporting tracing coming from the .NET MAUI SDK, this might cause a lot of added telemetry
+ //tracing.AddSource("Microsoft.Maui");
+
+ tracing.AddSource(builder.Environment.ApplicationName)
+ // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
+ //.AddGrpcClientInstrumentation()
+ .AddHttpClientInstrumentation();
+ });
+
+ builder.AddOpenTelemetryExporters();
+
+ return builder;
+ }
+
+ // OpenTelemetry initializer for MAUI
+ private sealed class OpenTelemetryInitializer : IMauiInitializeService
+ {
+ public void Initialize(IServiceProvider services)
+ {
+ services.GetService();
+ services.GetService();
+ services.GetService();
+ }
+ }
+
+ private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
+
+ if (useOtlpExporter)
+ {
+ builder.Services.AddOpenTelemetry().UseOtlpExporter();
+ }
+
+ // TODO MAUI: this code comes from the Aspire service defaults, we will want to check if this works for us and if yes
+ // how integration works for us because the AspNetCore package cannot be added to a MAUI project and we can't read the connection string like this
+ // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
+ //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
+ //{
+ // builder.Services.AddOpenTelemetry()
+ // .UseAzureMonitor();
+ //}
+
+ return builder;
+ }
+}
diff --git a/playground/AspireWithMaui/AspireWithMaui.ServiceDefaults/AspireWithMaui.ServiceDefaults.csproj b/playground/AspireWithMaui/AspireWithMaui.ServiceDefaults/AspireWithMaui.ServiceDefaults.csproj
new file mode 100644
index 00000000000..eeddadf5965
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.ServiceDefaults/AspireWithMaui.ServiceDefaults.csproj
@@ -0,0 +1,22 @@
+
+
+
+ Library
+ $(DefaultTargetFramework)
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/playground/AspireWithMaui/AspireWithMaui.ServiceDefaults/Extensions.cs b/playground/AspireWithMaui/AspireWithMaui.ServiceDefaults/Extensions.cs
new file mode 100644
index 00000000000..1390809c7c9
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.ServiceDefaults/Extensions.cs
@@ -0,0 +1,126 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Diagnostics.HealthChecks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Logging;
+using OpenTelemetry;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Trace;
+
+namespace Microsoft.Extensions.Hosting;
+
+// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
+// This project should be referenced by each service project in your solution.
+// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
+public static class Extensions
+{
+ private const string HealthEndpointPath = "/health";
+ private const string AlivenessEndpointPath = "/alive";
+
+ public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.ConfigureOpenTelemetry();
+
+ builder.AddDefaultHealthChecks();
+
+ builder.Services.AddServiceDiscovery();
+
+ builder.Services.ConfigureHttpClientDefaults(http =>
+ {
+ // Turn on resilience by default
+ http.AddStandardResilienceHandler();
+
+ // Turn on service discovery by default
+ http.AddServiceDiscovery();
+ });
+
+ // Uncomment the following to restrict the allowed schemes for service discovery.
+ // builder.Services.Configure(options =>
+ // {
+ // options.AllowedSchemes = ["https"];
+ // });
+
+ return builder;
+ }
+
+ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.Logging.AddOpenTelemetry(logging =>
+ {
+ logging.IncludeFormattedMessage = true;
+ logging.IncludeScopes = true;
+ });
+
+ builder.Services.AddOpenTelemetry()
+ .WithMetrics(metrics =>
+ {
+ metrics.AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddRuntimeInstrumentation();
+ })
+ .WithTracing(tracing =>
+ {
+ tracing.AddSource(builder.Environment.ApplicationName)
+ .AddAspNetCoreInstrumentation(tracing =>
+ // Exclude health check requests from tracing
+ tracing.Filter = context =>
+ !context.Request.Path.StartsWithSegments(HealthEndpointPath)
+ && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)
+ )
+ // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
+ //.AddGrpcClientInstrumentation()
+ .AddHttpClientInstrumentation();
+ });
+
+ builder.AddOpenTelemetryExporters();
+
+ return builder;
+ }
+
+ private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
+
+ if (useOtlpExporter)
+ {
+ builder.Services.AddOpenTelemetry().UseOtlpExporter();
+ }
+
+ // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
+ //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
+ //{
+ // builder.Services.AddOpenTelemetry()
+ // .UseAzureMonitor();
+ //}
+
+ return builder;
+ }
+
+ public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.Services.AddHealthChecks()
+ // Add a default liveness check to ensure app is responsive
+ .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
+
+ return builder;
+ }
+
+ public static WebApplication MapDefaultEndpoints(this WebApplication app)
+ {
+ // Adding health checks endpoints to applications in non-development environments has security implications.
+ // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
+ if (app.Environment.IsDevelopment())
+ {
+ // All health checks must pass for app to be considered ready to accept traffic after starting
+ app.MapHealthChecks(HealthEndpointPath);
+
+ // Only health checks tagged with the "live" tag must pass for app to be considered alive
+ app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
+ {
+ Predicate = r => r.Tags.Contains("live")
+ });
+ }
+
+ return app;
+ }
+}
diff --git a/playground/AspireWithMaui/AspireWithMaui.WeatherApi/AspireWithMaui.WeatherApi.csproj b/playground/AspireWithMaui/AspireWithMaui.WeatherApi/AspireWithMaui.WeatherApi.csproj
new file mode 100644
index 00000000000..b90e17af798
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.WeatherApi/AspireWithMaui.WeatherApi.csproj
@@ -0,0 +1,13 @@
+
+
+
+ $(DefaultTargetFramework)
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/playground/AspireWithMaui/AspireWithMaui.WeatherApi/AspireWithMaui.WeatherApi.http b/playground/AspireWithMaui/AspireWithMaui.WeatherApi/AspireWithMaui.WeatherApi.http
new file mode 100644
index 00000000000..b5513e5fbfd
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.WeatherApi/AspireWithMaui.WeatherApi.http
@@ -0,0 +1,6 @@
+@AspireWithMaui.WeatherApi_HostAddress = http://localhost:5221
+
+GET {{AspireWithMaui.WeatherApi_HostAddress}}/weatherforecast/
+Accept: application/json
+
+###
diff --git a/playground/AspireWithMaui/AspireWithMaui.WeatherApi/Controllers/WeatherForecastController.cs b/playground/AspireWithMaui/AspireWithMaui.WeatherApi/Controllers/WeatherForecastController.cs
new file mode 100644
index 00000000000..c92da138b1c
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.WeatherApi/Controllers/WeatherForecastController.cs
@@ -0,0 +1,25 @@
+using Microsoft.AspNetCore.Mvc;
+
+namespace AspireWithMaui.WeatherApi.Controllers;
+
+[ApiController]
+[Route("[controller]")]
+public class WeatherForecastController : ControllerBase
+{
+ private static readonly string[] s_summaries =
+ [
+ "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
+ ];
+
+ [HttpGet(Name = "GetWeatherForecast")]
+ public IEnumerable Get()
+ {
+ return Enumerable.Range(1, 5).Select(index => new WeatherForecast
+ {
+ Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
+ TemperatureC = Random.Shared.Next(-20, 55),
+ Summary = s_summaries[Random.Shared.Next(s_summaries.Length)]
+ })
+ .ToArray();
+ }
+}
diff --git a/playground/AspireWithMaui/AspireWithMaui.WeatherApi/Program.cs b/playground/AspireWithMaui/AspireWithMaui.WeatherApi/Program.cs
new file mode 100644
index 00000000000..64e50972c49
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.WeatherApi/Program.cs
@@ -0,0 +1,20 @@
+var builder = WebApplication.CreateBuilder(args);
+
+// Add service defaults & Aspire components.
+builder.AddServiceDefaults();
+
+// Add services to the container.
+builder.Services.AddControllers();
+
+var app = builder.Build();
+
+// Configure the HTTP request pipeline.
+app.MapDefaultEndpoints();
+
+app.UseHttpsRedirection();
+
+app.UseAuthorization();
+
+app.MapControllers();
+
+app.Run();
diff --git a/playground/AspireWithMaui/AspireWithMaui.WeatherApi/Properties/launchSettings.json b/playground/AspireWithMaui/AspireWithMaui.WeatherApi/Properties/launchSettings.json
new file mode 100644
index 00000000000..eadd491a03d
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.WeatherApi/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5221",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7196;http://localhost:5221",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/playground/AspireWithMaui/AspireWithMaui.WeatherApi/WeatherForecast.cs b/playground/AspireWithMaui/AspireWithMaui.WeatherApi/WeatherForecast.cs
new file mode 100644
index 00000000000..c767f373ee3
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.WeatherApi/WeatherForecast.cs
@@ -0,0 +1,12 @@
+namespace AspireWithMaui.WeatherApi;
+
+public class WeatherForecast
+{
+ public DateOnly Date { get; set; }
+
+ public int TemperatureC { get; set; }
+
+ public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
+
+ public string? Summary { get; set; }
+}
diff --git a/playground/AspireWithMaui/AspireWithMaui.WeatherApi/appsettings.Development.json b/playground/AspireWithMaui/AspireWithMaui.WeatherApi/appsettings.Development.json
new file mode 100644
index 00000000000..0c208ae9181
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.WeatherApi/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/playground/AspireWithMaui/AspireWithMaui.WeatherApi/appsettings.json b/playground/AspireWithMaui/AspireWithMaui.WeatherApi/appsettings.json
new file mode 100644
index 00000000000..10f68b8c8b4
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.WeatherApi/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/playground/AspireWithMaui/README.md b/playground/AspireWithMaui/README.md
new file mode 100644
index 00000000000..269eaca0f8f
--- /dev/null
+++ b/playground/AspireWithMaui/README.md
@@ -0,0 +1,105 @@
+# AspireWithMaui Playground
+
+This playground demonstrates .NET Aspire integration with .NET MAUI applications.
+
+## Prerequisites
+
+- .NET 10 RC or later
+- .NET MAUI workload (will be installed automatically by the restore script)
+
+## Getting Started
+
+### Initial Setup
+
+Before building or running the playground, you must restore dependencies and install the MAUI workload:
+
+**Windows:**
+```cmd
+restore.cmd
+```
+
+**Linux/macOS:**
+```bash
+./restore.sh
+```
+
+This script will:
+1. Run the main Aspire restore to set up the local .dotnet SDK
+2. Install the MAUI workload into the local `.dotnet` folder (does not affect your global installation)
+
+> **Note:** The MAUI workload is installed only in the repository's local `.dotnet` folder and will not interfere with your system-wide .NET installation.
+
+### Running the Playground
+
+After running the restore script, you can build and run the playground:
+
+**Using Visual Studio:**
+1. Run `restore.cmd` (Windows) or `./restore.sh` (Linux/macOS)
+2. Open `AspireWithMaui.AppHost` project
+3. Set it as the startup project
+4. Press F5 to run
+
+**Using VS Code:**
+1. Run `restore.cmd` (Windows) or `./restore.sh` (Linux/macOS)
+2. From the repository root, run: `./start-code.sh` or `start-code.cmd`
+3. Open the `AspireWithMaui` folder
+4. Use the debugger to run the AppHost
+
+**Using Command Line:**
+1. Run `restore.cmd` (Windows) or `./restore.sh` (Linux/macOS)
+2. Navigate to `AspireWithMaui.AppHost` directory
+3. Run: `dotnet run`
+
+## What's Included
+
+- **AspireWithMaui.AppHost** - The Aspire app host that orchestrates all services
+- **AspireWithMaui.MauiClient** - A .NET MAUI application that connects to the backend
+- **AspireWithMaui.WeatherApi** - An ASP.NET Core Web API providing weather data
+- **AspireWithMaui.ServiceDefaults** - Shared service defaults for non-MAUI projects
+- **AspireWithMaui.MauiServiceDefaults** - Shared service defaults specific to MAUI projects
+
+## Features Demonstrated
+
+### MAUI Platform Support
+The playground demonstrates Aspire's ability to manage MAUI apps across multiple platforms:
+- Windows
+- Android
+- iOS
+- macCatalyst
+
+### OpenTelemetry Integration
+The MAUI client uses OpenTelemetry to send traces and metrics to the Aspire dashboard via dev tunnels.
+
+### Service Discovery
+The MAUI app discovers and connects to backend services (WeatherApi) using Aspire's service discovery.
+
+### Multi-Platform Development
+The AppHost shows how to:
+- Configure different platforms with `.WithWindows()`, `.WithAndroid()`, `.WithiOS()`, `.WithMacCatalyst()`
+- Set up dev tunnels for MAUI app communication
+- Reference backend services from MAUI apps
+
+## Troubleshooting
+
+### "MAUI workload not detected" Warning
+If you see this warning in the Aspire dashboard:
+1. Make sure you ran `restore.cmd` or `./restore.sh` in the `playground/AspireWithMaui` directory
+2. The warning indicates the MAUI workload is not installed in the local `.dotnet` folder
+3. Re-run the restore script to install it
+
+### Build Errors
+If you encounter build errors:
+1. Ensure you ran the restore script first
+2. Make sure you're using .NET 10 RC or later
+3. Try running `dotnet build` from the repository root first
+
+### Platform-Specific Issues
+- **Windows**: Requires Windows 10 build 19041 or higher for WinUI support
+- **Android**: Requires Android SDK and emulator/device
+- **iOS/macCatalyst**: Requires macOS with Xcode installed
+
+## Learn More
+
+- [.NET Aspire Documentation](https://learn.microsoft.com/dotnet/aspire/)
+- [.NET MAUI Documentation](https://learn.microsoft.com/dotnet/maui/)
+- [OpenTelemetry in .NET](https://learn.microsoft.com/dotnet/core/diagnostics/observability-with-otel)
diff --git a/playground/AspireWithMaui/restore.cmd b/playground/AspireWithMaui/restore.cmd
new file mode 100644
index 00000000000..e32dfe0bd4e
--- /dev/null
+++ b/playground/AspireWithMaui/restore.cmd
@@ -0,0 +1,50 @@
+@ECHO OFF
+SETLOCAL EnableDelayedExpansion
+
+ECHO.
+ECHO ============================================================
+ECHO Restoring AspireWithMaui Playground
+ECHO ============================================================
+ECHO.
+
+REM First, run the main Aspire restore to set up the local .dotnet SDK
+ECHO [1/2] Running main Aspire restore to set up local SDK...
+CALL "%~dp0..\..\restore.cmd"
+IF ERRORLEVEL 1 (
+ ECHO ERROR: Failed to restore Aspire. Please check the output above.
+ EXIT /B 1
+)
+
+ECHO.
+ECHO [2/2] Installing MAUI workload into local .dotnet...
+
+REM Get the absolute path to the repo root (2 levels up from this script's directory)
+PUSHD "%~dp0..\.."
+SET "REPO_ROOT=%CD%"
+POPD
+
+REM Use the local dotnet from the repo root
+SET "DOTNET_ROOT=%REPO_ROOT%\.dotnet"
+SET "PATH=%DOTNET_ROOT%;%PATH%"
+
+REM Install the MAUI workload using the local dotnet
+"%DOTNET_ROOT%\dotnet.exe" workload install maui
+IF ERRORLEVEL 1 (
+ ECHO.
+ ECHO WARNING: Failed to install MAUI workload.
+ ECHO You may need to run this command manually:
+ ECHO "%DOTNET_ROOT%\dotnet.exe" workload install maui
+ ECHO.
+ ECHO The playground may not work without the MAUI workload installed.
+ EXIT /B 1
+)
+
+ECHO.
+ECHO ============================================================
+ECHO Restore complete! MAUI workload is installed.
+ECHO ============================================================
+ECHO.
+ECHO You can now build and run the AspireWithMaui playground.
+ECHO.
+
+EXIT /B 0
diff --git a/playground/AspireWithMaui/restore.sh b/playground/AspireWithMaui/restore.sh
new file mode 100644
index 00000000000..e7628b7e51e
--- /dev/null
+++ b/playground/AspireWithMaui/restore.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+
+set -e
+
+echo ""
+echo "============================================================"
+echo "Restoring AspireWithMaui Playground"
+echo "============================================================"
+echo ""
+
+# Get the directory where this script is located
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+repo_root="$script_dir/../.."
+
+# First, run the main Aspire restore to set up the local .dotnet SDK
+echo "[1/2] Running main Aspire restore to set up local SDK..."
+"$repo_root/restore.sh"
+
+echo ""
+echo "[2/2] Installing MAUI workload into local .dotnet..."
+
+# Use the local dotnet from the repo root
+export DOTNET_ROOT="$repo_root/.dotnet"
+export PATH="$DOTNET_ROOT:$PATH"
+
+# Install the MAUI workload using the local dotnet
+if ! "$DOTNET_ROOT/dotnet" workload install maui; then
+ echo ""
+ echo "WARNING: Failed to install MAUI workload."
+ echo "You may need to run this command manually:"
+ echo " $DOTNET_ROOT/dotnet workload install maui"
+ echo ""
+ echo "The playground may not work without the MAUI workload installed."
+ exit 1
+fi
+
+echo ""
+echo "============================================================"
+echo "Restore complete! MAUI workload is installed."
+echo "============================================================"
+echo ""
+echo "You can now build and run the AspireWithMaui playground."
+echo ""
+
+exit 0
diff --git a/src/Aspire.Hosting.Maui/Aspire.Hosting.Maui.csproj b/src/Aspire.Hosting.Maui/Aspire.Hosting.Maui.csproj
new file mode 100644
index 00000000000..12178a1ead2
--- /dev/null
+++ b/src/Aspire.Hosting.Maui/Aspire.Hosting.Maui.csproj
@@ -0,0 +1,25 @@
+
+
+ $(DefaultTargetFramework)
+ enable
+ enable
+ preview
+ MAUI integration for Aspire (local dev only)
+ true
+ true
+ aspire maui hosting
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Aspire.Hosting.Maui/DevTunnels/OtlpEndpointResolver.cs b/src/Aspire.Hosting.Maui/DevTunnels/OtlpEndpointResolver.cs
new file mode 100644
index 00000000000..01ae8591720
--- /dev/null
+++ b/src/Aspire.Hosting.Maui/DevTunnels/OtlpEndpointResolver.cs
@@ -0,0 +1,35 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Configuration;
+
+namespace Aspire.Hosting.Maui.DevTunnels;
+
+///
+/// Resolves the OTLP exporter scheme and port from Aspire dashboard configuration.
+/// Priority: unified endpoint -> HTTP-specific -> gRPC-specific -> defaults.
+///
+internal static class OtlpEndpointResolver
+{
+ public static (string Scheme, int Port) Resolve(IConfiguration configuration)
+ {
+ var unifiedUrl = configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"]; // launchSettings uses this key
+ var httpUrl = configuration["ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"]; // newer split key
+ var grpcUrl = configuration["ASPIRE_DASHBOARD_OTLP_GRPC_ENDPOINT_URL"]; // newer split key
+
+ if (Uri.TryCreate(unifiedUrl, UriKind.Absolute, out var unified))
+ {
+ return (unified.Scheme, unified.IsDefaultPort ? (unified.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) ? 443 : 80) : unified.Port);
+ }
+ if (Uri.TryCreate(httpUrl, UriKind.Absolute, out var http))
+ {
+ return (http.Scheme, http.IsDefaultPort ? (http.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) ? 443 : 80) : http.Port);
+ }
+ if (Uri.TryCreate(grpcUrl, UriKind.Absolute, out var grpc))
+ {
+ // gRPC exporter commonly uses 4317 as the default when unspecified.
+ return (grpc.Scheme, grpc.IsDefaultPort ? 4317 : grpc.Port);
+ }
+ return ("http", 18889); // Fallback defaults (match dashboard defaults for self-hosted collector)
+ }
+}
diff --git a/src/Aspire.Hosting.Maui/DevTunnels/OtlpLoopbackResource.cs b/src/Aspire.Hosting.Maui/DevTunnels/OtlpLoopbackResource.cs
new file mode 100644
index 00000000000..87b6f9bd130
--- /dev/null
+++ b/src/Aspire.Hosting.Maui/DevTunnels/OtlpLoopbackResource.cs
@@ -0,0 +1,29 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting.Maui.DevTunnels;
+
+///
+/// Synthetic hidden resource exposing the local collector OTLP port so it can be tunneled to devices.
+///
+internal sealed class OtlpLoopbackResource : Resource, IResourceWithEndpoints
+{
+ public OtlpLoopbackResource(string name, int port, string scheme) : base(name)
+ {
+ if (port <= 0 || port > 65535)
+ {
+ throw new ArgumentOutOfRangeException(nameof(port));
+ }
+ if (string.IsNullOrWhiteSpace(scheme))
+ {
+ scheme = "http";
+ }
+ // Stable endpoint name 'otlp' so service discovery key is services__{stubName}__otlp__0 regardless of scheme.
+ Annotations.Add(new EndpointAnnotation(System.Net.Sockets.ProtocolType.Tcp, uriScheme: scheme, name: "otlp", port: port, isProxied: false)
+ {
+ TargetHost = "localhost"
+ });
+ }
+}
diff --git a/src/Aspire.Hosting.Maui/MauiAnnotations.cs b/src/Aspire.Hosting.Maui/MauiAnnotations.cs
new file mode 100644
index 00000000000..e4b1673ed4c
--- /dev/null
+++ b/src/Aspire.Hosting.Maui/MauiAnnotations.cs
@@ -0,0 +1,34 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting.Maui;
+
+///
+/// Annotation applied to platform resources that were auto-detected from the MAUI project's target frameworks.
+/// Used only for user-facing warnings encouraging explicit platform configuration.
+///
+internal sealed class MauiAutoDetectedPlatformAnnotation : IResourceAnnotation { }
+
+///
+/// Annotation applied to platform resources that are not supported on the current host OS.
+/// Carries a human-readable reason used in warnings and startup validation.
+///
+/// Explanation why the platform cannot run on this host.
+internal sealed class MauiUnsupportedPlatformAnnotation(string reason) : IResourceAnnotation
+{
+ public string Reason { get; } = reason;
+}
+
+///
+/// Annotation applied to platform resources that were requested but don't have a matching TFM in the project.
+/// Used to display warnings in the dashboard and logs.
+///
+/// The platform moniker that is missing (e.g., "android", "ios").
+/// Detailed message explaining the missing TFM.
+internal sealed class MauiMissingTfmAnnotation(string platformMoniker, string warningMessage) : IResourceAnnotation
+{
+ public string PlatformMoniker { get; } = platformMoniker;
+ public string WarningMessage { get; } = warningMessage;
+}
diff --git a/src/Aspire.Hosting.Maui/MauiPlatformConfiguration.cs b/src/Aspire.Hosting.Maui/MauiPlatformConfiguration.cs
new file mode 100644
index 00000000000..21fbbb37252
--- /dev/null
+++ b/src/Aspire.Hosting.Maui/MauiPlatformConfiguration.cs
@@ -0,0 +1,80 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting.Maui;
+
+///
+/// Configuration for a specific MAUI platform (Windows, Android, iOS, MacCatalyst).
+///
+internal sealed class MauiPlatformConfiguration
+{
+ ///
+ /// The platform moniker (e.g., "windows", "android", "ios", "maccatalyst").
+ ///
+ public required string Moniker { get; init; }
+
+ ///
+ /// The FluentUI icon name to display in the dashboard for this platform.
+ ///
+ public required string IconName { get; init; }
+
+ ///
+ /// Determines if this platform is supported on the current host OS.
+ ///
+ public required Func IsSupportedOnCurrentHost { get; init; }
+
+ ///
+ /// Human-readable reason when the platform is not supported on the current host.
+ ///
+ public required string UnsupportedReason { get; init; }
+
+ ///
+ /// Well-known platform configurations for all MAUI platforms.
+ ///
+ public static class KnownPlatforms
+ {
+ public static readonly MauiPlatformConfiguration Windows = new()
+ {
+ Moniker = "windows",
+ IconName = "Desktop",
+ IsSupportedOnCurrentHost = OperatingSystem.IsWindows,
+ UnsupportedReason = "Windows platform requires running on a Windows host."
+ };
+
+ public static readonly MauiPlatformConfiguration Android = new()
+ {
+ Moniker = "android",
+ IconName = "PhoneTablet",
+ IsSupportedOnCurrentHost = () => true, // Android build tools can run on both Windows and macOS
+ UnsupportedReason = "Android platform is not supported on this host."
+ };
+
+ public static readonly MauiPlatformConfiguration iOS = new()
+ {
+ Moniker = "ios",
+ IconName = "PhoneTablet",
+ IsSupportedOnCurrentHost = OperatingSystem.IsMacOS,
+ UnsupportedReason = "iOS platform requires running on a macOS host with appropriate tooling."
+ };
+
+ public static readonly MauiPlatformConfiguration MacCatalyst = new()
+ {
+ Moniker = "maccatalyst",
+ IconName = "DesktopMac",
+ IsSupportedOnCurrentHost = OperatingSystem.IsMacOS,
+ UnsupportedReason = "MacCatalyst platform requires running on a macOS host."
+ };
+
+ ///
+ /// Gets the platform configuration for the specified moniker.
+ ///
+ public static MauiPlatformConfiguration? GetByMoniker(string moniker) => moniker.ToLowerInvariant() switch
+ {
+ "windows" => Windows,
+ "android" => Android,
+ "ios" => iOS,
+ "maccatalyst" => MacCatalyst,
+ _ => null
+ };
+ }
+}
diff --git a/src/Aspire.Hosting.Maui/MauiPlatformDetection.cs b/src/Aspire.Hosting.Maui/MauiPlatformDetection.cs
new file mode 100644
index 00000000000..a52fa5f7eb8
--- /dev/null
+++ b/src/Aspire.Hosting.Maui/MauiPlatformDetection.cs
@@ -0,0 +1,71 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Xml.Linq;
+
+namespace Aspire.Hosting.Maui;
+
+///
+/// Helper responsible for reading target frameworks from a MAUI project and selecting platforms for auto-detection.
+///
+internal static class MauiPlatformDetection
+{
+ ///
+ /// Loads all target frameworks (single and multi) declared in the project file.
+ ///
+ public static HashSet LoadTargetFrameworks(string projectPath)
+ {
+ var doc = XDocument.Load(projectPath);
+ var ns = doc.Root?.Name.Namespace ?? XNamespace.None;
+ var list = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var tf in doc.Descendants(ns + "TargetFramework").Select(e => e.Value.Split(';', StringSplitOptions.RemoveEmptyEntries)))
+ {
+ foreach (var t in tf)
+ {
+ list.Add(t.Trim());
+ }
+ }
+ foreach (var tfs in doc.Descendants(ns + "TargetFrameworks").Select(e => e.Value.Split(';', StringSplitOptions.RemoveEmptyEntries)))
+ {
+ foreach (var t in tfs)
+ {
+ list.Add(t.Trim());
+ }
+ }
+ return list;
+ }
+
+ ///
+ /// Determines which platforms should be auto-detected based on the current host OS and available TFMs.
+ /// Calls for each candidate; if it returns true, the platform is considered added.
+ ///
+ public static List AutoDetect(HashSet availableTfms, Func tryAdd)
+ {
+ var added = new List();
+
+ if (OperatingSystem.IsWindows())
+ {
+ Try("windows");
+ Try("android");
+ }
+ else if (OperatingSystem.IsMacOS())
+ {
+ Try("maccatalyst");
+ Try("ios");
+ Try("android");
+ }
+
+ void Try(string moniker)
+ {
+ if (availableTfms.Any(t => t.Contains('-') && t.Split('-')[1].StartsWith(moniker, StringComparison.OrdinalIgnoreCase)))
+ {
+ if (tryAdd(moniker))
+ {
+ added.Add(moniker);
+ }
+ }
+ }
+
+ return added;
+ }
+}
diff --git a/src/Aspire.Hosting.Maui/MauiProjectConfiguration.cs b/src/Aspire.Hosting.Maui/MauiProjectConfiguration.cs
new file mode 100644
index 00000000000..717f7b39f58
--- /dev/null
+++ b/src/Aspire.Hosting.Maui/MauiProjectConfiguration.cs
@@ -0,0 +1,57 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Concurrent;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.DevTunnels;
+using Aspire.Hosting.Maui.DevTunnels;
+
+namespace Aspire.Hosting.Maui;
+
+///
+/// Internal annotation that holds configuration state for a MAUI project resource.
+///
+internal sealed class MauiProjectConfiguration : IResourceAnnotation
+{
+ private readonly object _autoDetectLock = new();
+ private bool _autoDetectionAttempted;
+
+ public MauiProjectConfiguration(string projectPath, HashSet availableTfms)
+ {
+ ProjectPath = projectPath;
+ AvailableTfms = availableTfms;
+ }
+
+ public string ProjectPath { get; }
+ public HashSet AvailableTfms { get; }
+ public List> PlatformResources { get; } = [];
+ public HashSet ReferencedEndpointResources { get; } = [];
+ public IResourceBuilder? OtlpDevTunnel { get; set; }
+ public OtlpLoopbackResource? OtlpStub { get; set; }
+ public int OtlpStubPort { get; set; }
+ public string? OtlpStubName { get; set; }
+ public bool EnableOtelDebug { get; set; }
+
+ public static readonly ConcurrentDictionary> Builds = new();
+
+ ///
+ /// Returns the set of platforms that were auto-detected during this call (empty if already done or none found).
+ ///
+ internal List EnsureAutoDetection(IDistributedApplicationBuilder _, Func tryAddPlatform)
+ {
+ if (PlatformResources.Count != 0)
+ {
+ return [];
+ }
+
+ lock (_autoDetectLock)
+ {
+ if (_autoDetectionAttempted || PlatformResources.Count != 0)
+ {
+ return [];
+ }
+ _autoDetectionAttempted = true;
+ return MauiPlatformDetection.AutoDetect(AvailableTfms, tryAddPlatform);
+ }
+ }
+}
diff --git a/src/Aspire.Hosting.Maui/MauiProjectExtensions.cs b/src/Aspire.Hosting.Maui/MauiProjectExtensions.cs
new file mode 100644
index 00000000000..3553d2ec634
--- /dev/null
+++ b/src/Aspire.Hosting.Maui/MauiProjectExtensions.cs
@@ -0,0 +1,52 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Lifecycle;
+using Aspire.Hosting.Maui;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace Aspire.Hosting;
+
+///
+/// Extension methods to add .NET MAUI projects to an Aspire application.
+///
+public static class MauiProjectExtensions
+{
+ ///
+ /// Adds a MAUI project (logical grouping) to the application model. Individual platform resources
+ /// must be enabled with platform specific methods on the returned builder.
+ ///
+ /// The distributed application builder.
+ /// Logical name for the MAUI project.
+ /// Relative path to the .csproj file.
+ /// A reference to the .
+ public static IResourceBuilder AddMauiProject(this IDistributedApplicationBuilder builder, [ResourceName] string name, string projectPath)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(name);
+ ArgumentException.ThrowIfNullOrEmpty(projectPath);
+
+ // Ensure lifecycle tracker registered once; harmless if added multiple times.
+ builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton());
+
+ // Normalize the project path relative to the AppHost directory using shared PathNormalizer
+ projectPath = Hosting.Utils.PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, projectPath));
+
+ // Create the MAUI project resource and configuration
+ // Do not register the logical grouping resource with AddResource so it stays invisible in the dashboard
+ var resource = new MauiProjectResource(name, projectPath);
+ var availableTfms = MauiPlatformDetection.LoadTargetFrameworks(projectPath);
+ var configuration = new MauiProjectConfiguration(projectPath, availableTfms);
+
+ // Add configuration as annotation to the resource
+ resource.Annotations.Add(configuration);
+
+ // Create the resource builder without adding to the model
+ var resourceBuilder = builder.CreateResourceBuilder(resource);
+
+ // Register event handlers
+ return resourceBuilder.WithMauiEventHandlers();
+ }
+}
diff --git a/src/Aspire.Hosting.Maui/MauiProjectResource.cs b/src/Aspire.Hosting.Maui/MauiProjectResource.cs
new file mode 100644
index 00000000000..00cc2e66346
--- /dev/null
+++ b/src/Aspire.Hosting.Maui/MauiProjectResource.cs
@@ -0,0 +1,18 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting.Maui;
+
+///
+/// Logical placeholder resource representing a multi-target .NET MAUI project. Not started directly.
+/// Platform specific child instances are created for each selected target.
+///
+public sealed class MauiProjectResource(string name, string projectPath) : Resource(name)
+{
+ ///
+ /// Gets the path to the underlying multi-target .NET MAUI project (.csproj) file.
+ ///
+ public string ProjectPath { get; } = projectPath;
+}
diff --git a/src/Aspire.Hosting.Maui/MauiResourceBuilderExtensions.cs b/src/Aspire.Hosting.Maui/MauiResourceBuilderExtensions.cs
new file mode 100644
index 00000000000..e7c4ce7d09e
--- /dev/null
+++ b/src/Aspire.Hosting.Maui/MauiResourceBuilderExtensions.cs
@@ -0,0 +1,683 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.DevTunnels;
+using Aspire.Hosting.Maui;
+using Aspire.Hosting.Maui.DevTunnels;
+using Aspire.Hosting.Maui.Platforms.Android;
+using Aspire.Hosting.Maui.Platforms.iOS;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Aspire.Hosting;
+
+///
+/// Extension methods for configuring .NET MAUI project resources.
+///
+public static class MauiResourceBuilderExtensions
+{
+ ///
+ /// Subscribes to lifecycle events for MAUI resource management.
+ ///
+ internal static IResourceBuilder WithMauiEventHandlers(this IResourceBuilder builder)
+ {
+ var appBuilder = builder.ApplicationBuilder;
+ var configuration = GetConfiguration(builder);
+ var resource = builder.Resource;
+
+ appBuilder.Eventing.Subscribe((evt, ct) =>
+ {
+ var loggerFactory = evt.Services.GetService();
+ var logger = loggerFactory?.CreateLogger(typeof(MauiResourceBuilderExtensions));
+
+ // Log warnings for explicitly configured unsupported platforms
+ foreach (var platformResource in configuration.PlatformResources)
+ {
+ var unsupported = platformResource.Resource.Annotations.OfType().FirstOrDefault();
+ if (unsupported is not null)
+ {
+ logger?.LogWarning(
+ "MAUI platform '{Platform}' was explicitly configured but is not supported on this host: {Reason}",
+ platformResource.Resource.Name,
+ unsupported.Reason);
+ }
+ }
+
+ // Auto-detect platforms if none explicitly configured
+ var autoDetected = configuration.EnsureAutoDetection(appBuilder, moniker => builder.TryAddAutoDetectedPlatform(moniker));
+ if (autoDetected.Count > 0)
+ {
+ logger?.LogWarning(
+ "Auto-detected .NET MAUI platform resources: {Platforms}. " +
+ "Use WithWindows(), WithAndroid(), WithiOS(), or WithMacCatalyst() to explicitly specify platforms.",
+ string.Join(", ", autoDetected));
+ }
+ else if (configuration.PlatformResources.Count == 0)
+ {
+ // Auto-detection ran but found no platforms (e.g., on Linux where no platforms are supported)
+ logger?.LogWarning(
+ "No .NET MAUI platform resources were configured for '{ResourceName}'. " +
+ "Use WithWindows(), WithAndroid(), WithiOS(), or WithMacCatalyst() to add platforms.",
+ resource.Name);
+ }
+
+ return Task.CompletedTask;
+ });
+
+ return builder;
+ }
+
+ ///
+ /// Adds a Windows platform resource if the MAUI project targets windows.
+ ///
+ public static IResourceBuilder WithWindows(this IResourceBuilder builder, string? runtimeIdentifier = null)
+ {
+ builder.AddPlatform("windows", runtimeIdentifier);
+ return builder;
+ }
+
+ ///
+ /// Adds an Android platform resource if the MAUI project targets Android.
+ ///
+ /// The MAUI project resource builder.
+ /// Optional adb target passed as an MSBuild property (e.g. -p:AdbTarget=-e) to select a specific emulator or device.
+ ///
+ /// Android support is currently limited to adding the platform resource without additional provisioning logic.
+ /// If is provided it is forwarded as an MSBuild property when the project is started.
+ ///
+ public static IResourceBuilder WithAndroid(this IResourceBuilder builder, string? adbTarget = null)
+ {
+ builder.AddPlatform("android", msbuildProperty: adbTarget is null ? null : $"AdbTarget={adbTarget}");
+ return builder;
+ }
+
+ ///
+ /// Adds an iOS platform resource if the MAUI project targets iOS.
+ ///
+ /// The MAUI project resource builder.
+ /// Optional _DeviceName UDID (simulator or device) passed as -p:_DeviceName=<UDID>
+ public static IResourceBuilder WithiOS(this IResourceBuilder builder, string? deviceUdid = null)
+ {
+ builder.AddPlatform("ios", msbuildProperty: deviceUdid is null ? null : $"_DeviceName={deviceUdid}");
+ return builder;
+ }
+
+ ///
+ /// Adds a MacCatalyst platform resource if the MAUI project targets MacCatalyst.
+ ///
+ /// The MAUI project resource builder.
+ /// Optional runtime identifier (e.g. maccatalyst-x64 or maccatalyst-arm64).
+ public static IResourceBuilder WithMacCatalyst(this IResourceBuilder builder, string? runtimeIdentifier = null)
+ {
+ builder.AddPlatform("maccatalyst", runtimeIdentifier);
+ return builder;
+ }
+
+ ///
+ /// Propagates a reference to another resource to all platform resources.
+ ///
+ /// The type of the source resource.
+ /// The MAUI project resource builder.
+ /// The resource builder for the source resource.
+ /// Optional connection name.
+ public static IResourceBuilder WithReference(this IResourceBuilder builder, IResourceBuilder source, string? connectionName = null)
+ where TSource : IResource
+ {
+ ArgumentNullException.ThrowIfNull(source);
+
+ var configuration = GetConfiguration(builder);
+
+ // Ensure platforms are materialized early so service discovery / connection string references propagate.
+ configuration.EnsureAutoDetection(builder.ApplicationBuilder, builder.TryAddAutoDetectedPlatform);
+
+ if (source.Resource is IResourceWithEndpoints endpointsResource && !configuration.ReferencedEndpointResources.Contains(endpointsResource))
+ {
+ configuration.ReferencedEndpointResources.Add(endpointsResource);
+ }
+
+ foreach (var pr in configuration.PlatformResources)
+ {
+ if (source.Resource is IResourceWithConnectionString && pr.Resource is IResourceWithEnvironment)
+ {
+ pr.WithReference((IResourceBuilder)source, connectionName);
+ }
+ else if (source.Resource is IResourceWithServiceDiscovery && pr.Resource is IResourceWithEnvironment)
+ {
+ pr.WithReference((IResourceBuilder)source);
+ }
+ }
+
+ return builder;
+ }
+
+ ///
+ /// Propagates service discovery variables for the specified endpoint resource through the provided dev tunnel
+ /// into all MAUI platform resources (tunneled service discovery). This allows a device/emulator to reach
+ /// the service via the tunnel host instead of localhost.
+ ///
+ /// The MAUI project resource builder.
+ /// The endpoint-providing resource to expose via the tunnel.
+ /// The dev tunnel resource already configured to reference .
+ ///
+ /// This keeps fluent syntax in the AppHost: .WithReference(weatherApi, publicDevTunnel) without requiring
+ /// callers to access individual platform project resources.
+ ///
+ public static IResourceBuilder WithReference(this IResourceBuilder builder, IResourceBuilder source, IResourceBuilder tunnel)
+ {
+ ArgumentNullException.ThrowIfNull(source);
+ ArgumentNullException.ThrowIfNull(tunnel);
+
+ var configuration = GetConfiguration(builder);
+ configuration.EnsureAutoDetection(builder.ApplicationBuilder, builder.TryAddAutoDetectedPlatform);
+
+ foreach (var pr in configuration.PlatformResources)
+ {
+ if (pr.Resource is IResourceWithEnvironment)
+ {
+ pr.WithReference(source, tunnel);
+ }
+ }
+
+ return builder;
+ }
+
+ ///
+ /// Creates a single Dev Tunnel exposing only the local OTLP port and rewrites the OTLP exporter endpoint
+ /// to use the tunneled address for device/simulator telemetry.
+ ///
+ /// The MAUI project resource builder.
+ /// Optional tunnel resource name; defaults to <logical-name>-otlp.
+ /// When true, injects verbose OTEL exporter debug env vars for troubleshooting (defaults to false).
+ public static IResourceBuilder WithOtlpDevTunnel(this IResourceBuilder builder, string? tunnelName = null, bool enableOtelDebug = false)
+ {
+ var configuration = GetConfiguration(builder);
+ var appBuilder = builder.ApplicationBuilder;
+
+ if (configuration.OtlpDevTunnel is not null)
+ {
+ return builder; // already configured
+ }
+
+ // Use a stable dev tunnel name distinct from the OTLP concept.
+ tunnelName ??= builder.Resource.Name + "-devtunnel";
+ configuration.OtlpDevTunnel = appBuilder.AddDevTunnel(tunnelName).WithAnonymousAccess();
+ configuration.EnableOtelDebug = enableOtelDebug;
+
+ // Resolve OTLP endpoint (scheme & port) from configuration.
+ var (otlpScheme, otlpPort) = OtlpEndpointResolver.Resolve(appBuilder.Configuration);
+
+ // Create synthetic hidden stub resource referenced only for tunneling.
+ configuration.OtlpStubName = builder.Resource.Name + "-otlpstub";
+ if (appBuilder.Resources.Any(r => string.Equals(r.Name, configuration.OtlpStubName, StringComparison.OrdinalIgnoreCase)))
+ {
+ return builder; // defensive (should not occur normally)
+ }
+
+ configuration.OtlpStub = new OtlpLoopbackResource(configuration.OtlpStubName, otlpPort, otlpScheme);
+ configuration.OtlpStubPort = otlpPort;
+ var stubBuilder = appBuilder.AddResource(configuration.OtlpStub)
+ .ExcludeFromManifest();
+
+ // Hide synthetic stub (dashboard omission) while still allowing the Dev Tunnel to attach to an endpoint-providing resource.
+ stubBuilder.WithInitialState(new CustomResourceSnapshot
+ {
+ ResourceType = nameof(OtlpLoopbackResource),
+ Properties = [],
+ IsHidden = true
+ });
+
+ // Prefer nesting the synthetic stub under the logical MAUI resource (rather than the dev tunnel) so that
+ // it's grouped with the MAUI app conceptually. If the logical MAUI resource is not surfaced in the dashboard,
+ // this may still appear near the top level, but avoids coupling its hierarchy to the tunnel itself.
+ try
+ {
+ stubBuilder.WithParentRelationship(builder);
+ }
+ catch
+ {
+ // Fallback: if for some reason we cannot create the logical parent builder, do nothing.
+ }
+
+ // Force the dev tunnel port protocol to HTTPS regardless of the local collector's scheme so the
+ // public tunnel endpoint is always an https:// URL (Dev Tunnels surface TLS endpoints).
+ configuration.OtlpDevTunnel.WithReference(stubBuilder, new DevTunnelPortOptions { Protocol = "https" });
+ var stubEndpointsBuilder = appBuilder.CreateResourceBuilder(configuration.OtlpStub);
+
+ // Ensure the stub endpoint appears allocated before the tunnel starts (the dev tunnel waits on allocation events).
+ // We synthesize the allocation and raise ResourceEndpointsAllocatedEvent early.
+ appBuilder.Eventing.Subscribe((evt, ct) =>
+ {
+ if (configuration.OtlpStub is null)
+ {
+ return Task.CompletedTask;
+ }
+ var endpoint = configuration.OtlpStub.Annotations.OfType().FirstOrDefault();
+ if (endpoint is null)
+ {
+ return Task.CompletedTask;
+ }
+ if (endpoint.AllocatedEndpoint is null)
+ {
+ endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", configuration.OtlpStubPort);
+ return appBuilder.Eventing.PublishAsync(new(configuration.OtlpStub, evt.Services), ct);
+ }
+ return Task.CompletedTask;
+ });
+
+ // Ensure platforms exist (auto-detect if user hasn't added explicitly yet) so we can wire env vars.
+ configuration.EnsureAutoDetection(appBuilder, builder.TryAddAutoDetectedPlatform);
+
+ foreach (var pr in configuration.PlatformResources.Where(p => p.Resource is IResourceWithEnvironment))
+ {
+ pr.ApplyOtlpConfigurationToPlatform(appBuilder, configuration);
+ }
+
+ return builder;
+ }
+
+ private static MauiProjectConfiguration GetConfiguration(IResourceBuilder builder)
+ {
+ return builder.Resource.Annotations.OfType().FirstOrDefault()
+ ?? throw new InvalidOperationException($"MauiProjectConfiguration not found on resource '{builder.Resource.Name}'");
+ }
+
+ private static void AddPlatform(this IResourceBuilder builder, string platformMoniker, string? runtimeIdentifier = null, string? msbuildProperty = null)
+ {
+ var configuration = GetConfiguration(builder);
+ var appBuilder = builder.ApplicationBuilder;
+ var mauiResource = builder.Resource;
+
+ var platformConfig = MauiPlatformConfiguration.KnownPlatforms.GetByMoniker(platformMoniker);
+ if (platformConfig is null)
+ {
+ return; // Unknown platform moniker
+ }
+
+ var resourceName = $"{mauiResource.Name}-{platformMoniker}";
+
+ // Check if this platform has already been added (duplicate guard)
+ if (configuration.PlatformResources.Any(pr => pr.Resource.Name.Equals(resourceName, StringComparison.OrdinalIgnoreCase)))
+ {
+ // Platform already added - log a warning and skip
+ var loggerFactory = appBuilder.Services.BuildServiceProvider().GetService();
+ var logger = loggerFactory?.CreateLogger(typeof(MauiResourceBuilderExtensions));
+ logger?.LogWarning("Platform '{Platform}' has already been added to MAUI project '{Project}'. Ignoring duplicate call to With{PlatformTitle}().",
+ platformMoniker, mauiResource.Name, char.ToUpper(platformMoniker[0]) + platformMoniker[1..]);
+ return;
+ }
+
+ // Identify TFM prefix e.g. net10.0-windows, net10.0-android
+ var tfm = configuration.AvailableTfms.FirstOrDefault(t => t.Contains('-') && t.Split('-')[1].StartsWith(platformMoniker, StringComparison.OrdinalIgnoreCase));
+ if (tfm is null)
+ {
+ // Platform was requested but project doesn't target this TFM - create a warning resource
+ builder.ConfigureMissingTfmPlatform(platformMoniker, platformConfig, configuration);
+ return;
+ }
+
+ // Use existing AddProject API to create the platform-specific resource.
+ var platformBuilder = appBuilder.AddProject(resourceName, configuration.ProjectPath)
+ .WithExplicitStart()
+ .WithAnnotation(ManifestPublishingCallbackAnnotation.Ignore);
+
+ // Configure OpenTelemetry service identification for this platform.
+ platformBuilder.ConfigureOpenTelemetryEnvironment(resourceName);
+
+ // Add platform-specific icon for dashboard visualization.
+ platformBuilder.WithAnnotation(new ResourceIconAnnotation(platformConfig.IconName, IconVariant.Filled));
+
+ // Check if the platform is supported on the current host OS.
+ var supported = platformConfig.IsSupportedOnCurrentHost();
+
+ if (!supported)
+ {
+ platformBuilder.ConfigureUnsupportedPlatform(appBuilder, platformConfig);
+ }
+
+ // Pass framework & device specific msbuild properties via args so launching uses correct target
+ platformBuilder.WithArgs(async context =>
+ {
+ context.Args.Add("-f");
+ context.Args.Add(tfm);
+ if (!string.IsNullOrEmpty(runtimeIdentifier))
+ {
+ context.Args.Add("-p:RuntimeIdentifier=" + runtimeIdentifier);
+ }
+ if (!string.IsNullOrEmpty(msbuildProperty))
+ {
+ context.Args.Add("-p:" + msbuildProperty);
+ }
+
+ // Mac Catalyst requires passing -W via OpenArguments so the launched app stays running and doesn't immediately detach.
+ if (platformMoniker.Equals("maccatalyst", StringComparison.OrdinalIgnoreCase))
+ {
+ // Avoid duplicating if user already supplied it via msbuildProperty.
+ var alreadyHas = context.Args.Any(a => a is string s && s.StartsWith("-p:OpenArguments=", StringComparison.OrdinalIgnoreCase));
+ if (!alreadyHas)
+ {
+ context.Args.Add("-p:OpenArguments=-W");
+ }
+ }
+
+ if (platformMoniker.Equals("ios", StringComparison.OrdinalIgnoreCase))
+ {
+ await iOSMlaunchEnvironmentTargetGenerator.AppendEnvironmentTargetsAsync(context).ConfigureAwait(false);
+ }
+
+ if (platformMoniker.Equals("android", StringComparison.OrdinalIgnoreCase))
+ {
+ await AndroidEnvironmentTargetGenerator.AppendEnvironmentTargetsAsync(context).ConfigureAwait(false);
+ }
+ });
+
+ configuration.PlatformResources.Add(platformBuilder);
+
+ // If OTLP tunneling already configured, wire the new platform to the stub & tunnel now.
+ platformBuilder.ApplyOtlpConfigurationIfNeeded(appBuilder, configuration);
+
+ // Conditional build hook executed right before the resource starts (per explicit start invocation).
+ platformBuilder.OnBeforeResourceStarted(async (res, evt, ct) =>
+ {
+ var loggerService = evt.Services.GetService(typeof(ResourceLoggerService)) as ResourceLoggerService;
+ var logger = loggerService?.GetLogger(res);
+
+ // Silently prevent starting unsupported platforms - the "Unsupported" state already indicates why.
+ // Don't throw an exception as that causes "Failed to start" which is misleading.
+ if (res.Annotations.OfType().Any())
+ {
+ logger?.LogInformation("MAUI platform '{Resource}' is unsupported on this host and will not start.", res.Name);
+ return;
+ }
+
+ // Skip build work during initial AppHost startup; only build on user-initiated explicit start later.
+ if (!MauiStartupPhaseTracker.StartupPhaseComplete)
+ {
+ return;
+ }
+
+ // Defensive: ensure still an explicit-start resource.
+ if (!res.Annotations.OfType().Any())
+ {
+ return;
+ }
+ // Determine configuration from environment (fallback Debug)
+ var config = Environment.GetEnvironmentVariable("CONFIGURATION");
+ if (string.IsNullOrWhiteSpace(config))
+ {
+ config = "Debug";
+ }
+
+ // Compose output directory (best-effort) - MAUI layout: bin///
+ var tfmDir = Path.Combine(Path.GetDirectoryName(configuration.ProjectPath) ?? string.Empty, "bin", config, tfm);
+ bool NeedsBuild()
+ {
+ try
+ {
+ if (!Directory.Exists(tfmDir))
+ {
+ return true;
+ }
+ if (!Directory.EnumerateFileSystemEntries(tfmDir).Any())
+ {
+ return true;
+ }
+ }
+ catch
+ {
+ return true;
+ }
+ return false;
+ }
+
+ if (!NeedsBuild())
+ {
+ return; // artifacts present
+ }
+
+ var key = string.Join('|', configuration.ProjectPath, tfm, config);
+ var lazy = MauiProjectConfiguration.Builds.GetOrAdd(key, _ => new Lazy(() => RunBuildAsync(ct)));
+ try
+ {
+ await lazy.Value.ConfigureAwait(false);
+ }
+ catch
+ {
+ // If failed, remove so a retry start attempt can rebuild
+ MauiProjectConfiguration.Builds.TryRemove(key, out _);
+ throw;
+ }
+
+ async Task RunBuildAsync(CancellationToken token)
+ {
+ logger?.LogInformation("Artifacts missing; building {Tfm}...", tfm);
+
+ var psi = new ProcessStartInfo
+ {
+ FileName = "dotnet",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false
+ };
+
+ psi.ArgumentList.Add("build");
+ psi.ArgumentList.Add(configuration.ProjectPath);
+ psi.ArgumentList.Add("-f");
+ psi.ArgumentList.Add(tfm);
+
+ if (!string.IsNullOrEmpty(runtimeIdentifier))
+ {
+ psi.ArgumentList.Add("-p:RuntimeIdentifier=" + runtimeIdentifier);
+ }
+
+ if (!string.IsNullOrEmpty(msbuildProperty))
+ {
+ psi.ArgumentList.Add("-p:" + msbuildProperty);
+ }
+
+ var sw = Stopwatch.StartNew();
+ using var proc = Process.Start(psi)!;
+ var stdoutTask = proc.StandardOutput.ReadToEndAsync(token);
+ var stderrTask = proc.StandardError.ReadToEndAsync(token);
+ await proc.WaitForExitAsync(token).ConfigureAwait(false);
+
+ var stdout = await stdoutTask.ConfigureAwait(false);
+ var stderr = await stderrTask.ConfigureAwait(false);
+ sw.Stop();
+
+ if (!string.IsNullOrWhiteSpace(stdout))
+ {
+ logger?.LogDebug("{Stdout}", stdout.TrimEnd());
+ }
+
+ if (!string.IsNullOrWhiteSpace(stderr))
+ {
+ logger?.LogDebug("{Stderr}", stderr.TrimEnd());
+ }
+
+ if (proc.ExitCode != 0)
+ {
+ logger?.LogError("Build failed (exit {ExitCode}) for {Tfm}.", proc.ExitCode, tfm);
+ throw new InvalidOperationException($"MAUI build failed for {resourceName} ({tfm}).");
+ }
+
+ logger?.LogInformation("Build succeeded in {Seconds}s for {Tfm}.", sw.Elapsed.TotalSeconds.ToString("F1", System.Globalization.CultureInfo.InvariantCulture), tfm);
+ }
+ });
+ }
+
+ ///
+ /// Configures OpenTelemetry environment variables for the platform resource.
+ /// Overrides DCP interpolation templates with concrete values for local MAUI projects.
+ ///
+ private static void ConfigureOpenTelemetryEnvironment(this IResourceBuilder builder, string resourceName)
+ {
+ // Override OTEL_SERVICE_NAME placeholder (the generic OTLP configuration sets a DCP interpolation template
+ // like {{- index .Annotations "otel-service-name" -}} which is never resolved for a local MAUI project).
+ // Provide a stable concrete service name instead so the exporter doesn't emit the literal template.
+ builder.WithEnvironment("OTEL_SERVICE_NAME", resourceName);
+
+ // Override OTEL_RESOURCE_ATTRIBUTES placeholder (the generic OTLP configuration sets a DCP interpolation template
+ // like {{- index .Annotations "otel-service-instance-id" -}} which is never resolved for a local MAUI project).
+ // Provide a unique service instance ID for this platform resource. Each device/emulator running this app
+ // will have a distinct instance ID, allowing proper telemetry tracking in the dashboard.
+ builder.WithEnvironment("OTEL_RESOURCE_ATTRIBUTES", "service.instance.id=" + Guid.NewGuid().ToString());
+ }
+
+ ///
+ /// Configures an unsupported platform to display a warning state in the dashboard
+ /// and prevent lifecycle operations.
+ ///
+ private static void ConfigureUnsupportedPlatform(this IResourceBuilder builder, IDistributedApplicationBuilder appBuilder, MauiPlatformConfiguration platformConfig)
+ {
+ builder.WithAnnotation(new MauiUnsupportedPlatformAnnotation(platformConfig.UnsupportedReason));
+
+ // Publish the unsupported state after resources are created.
+ // The "Unsupported" state prevents the platform from being started.
+ // Note: Dashboard expects "warning" (not "warn" from KnownResourceStateStyles.Warn) for the warning icon to display.
+ // This is a known inconsistency in the Aspire codebase between the hosting and dashboard layers.
+ var resource = builder.Resource;
+ appBuilder.Eventing.Subscribe((evt, ct) =>
+ {
+ var notificationService = evt.Services.GetService();
+ if (notificationService is not null)
+ {
+ _ = notificationService.PublishUpdateAsync(resource, s => s with
+ {
+ State = new ResourceStateSnapshot("Unsupported", "warning")
+ });
+ }
+
+ return Task.CompletedTask;
+ });
+ }
+
+ ///
+ /// Configures a platform that was requested but doesn't have a matching TFM in the project.
+ /// Creates a warning resource in the dashboard to inform the developer.
+ ///
+ private static void ConfigureMissingTfmPlatform(this IResourceBuilder builder, string platformMoniker, MauiPlatformConfiguration platformConfig, MauiProjectConfiguration configuration)
+ {
+ var appBuilder = builder.ApplicationBuilder;
+ var resourceName = $"{builder.Resource.Name}-{platformMoniker}";
+
+ // Create a placeholder project resource to show in the dashboard
+ var platformBuilder = appBuilder.AddProject(resourceName, configuration.ProjectPath)
+ .WithExplicitStart()
+ .WithAnnotation(ManifestPublishingCallbackAnnotation.Ignore);
+
+ // Add platform-specific icon for dashboard visualization
+ platformBuilder.WithAnnotation(new ResourceIconAnnotation(platformConfig.IconName, IconVariant.Filled));
+
+ // Track that this platform is missing the required TFM
+ var warningMessage = $"Project does not target {platformMoniker}. Add 'net10.0-{platformMoniker}' to TargetFrameworks in the project file.";
+ platformBuilder.WithAnnotation(new MauiMissingTfmAnnotation(platformMoniker, warningMessage));
+
+ // Publish the warning state after resources are created
+ // Note: Dashboard expects "warning" (not "warn" from KnownResourceStateStyles.Warn) for the warning icon to display.
+ var resource = platformBuilder.Resource;
+ appBuilder.Eventing.Subscribe((evt, ct) =>
+ {
+ var notificationService = evt.Services.GetService();
+ var loggerService = evt.Services.GetService();
+
+ if (notificationService is not null)
+ {
+ _ = notificationService.PublishUpdateAsync(resource, s => s with
+ {
+ State = new ResourceStateSnapshot("Missing TFM", "warning")
+ });
+ }
+
+ // Also log a warning to help developers discover the issue
+ if (loggerService is not null)
+ {
+ var logger = loggerService.GetLogger(resource);
+ logger?.LogWarning("Platform '{Platform}' was requested but the project '{ProjectPath}' does not include 'net10.0-{Platform}' in its TargetFrameworks. Add it to the project file to enable this platform.",
+ platformMoniker, configuration.ProjectPath, platformMoniker);
+ }
+
+ return Task.CompletedTask;
+ });
+
+ // Prevent this platform from being started
+ platformBuilder.OnBeforeResourceStarted((res, evt, ct) =>
+ {
+ var loggerService = evt.Services.GetService(typeof(ResourceLoggerService)) as ResourceLoggerService;
+ var logger = loggerService?.GetLogger(res);
+
+ logger?.LogWarning("Cannot start platform '{Platform}' because it is not included in the project's TargetFrameworks.", platformMoniker);
+
+ return Task.CompletedTask;
+ });
+
+ configuration.PlatformResources.Add(platformBuilder);
+ }
+
+ ///
+ /// Applies OTLP dev tunnel configuration to a platform if OTLP dev tunnel is enabled.
+ ///
+ private static void ApplyOtlpConfigurationIfNeeded(this IResourceBuilder builder, IDistributedApplicationBuilder appBuilder, MauiProjectConfiguration configuration)
+ {
+ if (configuration.OtlpDevTunnel is null || configuration.OtlpStub is null || builder.Resource is not IResourceWithEnvironment)
+ {
+ return;
+ }
+
+ builder.ApplyOtlpConfigurationToPlatform(appBuilder, configuration);
+ }
+
+ ///
+ /// Applies OTLP dev tunnel configuration to a specific platform resource builder.
+ ///
+ private static void ApplyOtlpConfigurationToPlatform(this IResourceBuilder builder, IDistributedApplicationBuilder appBuilder, MauiProjectConfiguration configuration)
+ {
+ if (configuration.OtlpDevTunnel is null || configuration.OtlpStub is null)
+ {
+ return;
+ }
+
+ var stubEndpointsBuilder = appBuilder.CreateResourceBuilder(configuration.OtlpStub);
+ builder.WithReference(stubEndpointsBuilder, configuration.OtlpDevTunnel);
+
+ // iOS and Android require the stub name to locate the OTLP endpoint after dev tunnel allocation.
+ var platformMoniker = ExtractPlatformMonikerFromResourceName(builder.Resource.Name);
+ if (configuration.OtlpStubName is not null && (platformMoniker == "ios" || platformMoniker == "android"))
+ {
+ builder.WithEnvironment("ASPIRE_MAUI_OTLP_STUB_NAME", configuration.OtlpStubName);
+ }
+
+ if (configuration.EnableOtelDebug)
+ {
+ builder.WithEnvironment("OTEL_LOG_LEVEL", "debug")
+ .WithEnvironment("OTEL_BSP_SCHEDULE_DELAY", "200")
+ .WithEnvironment("OTEL_BSP_MAX_EXPORT_BATCH_SIZE", "1");
+ }
+ }
+
+ ///
+ /// Extracts the platform moniker from a resource name (e.g., "myapp-ios" -> "ios").
+ ///
+ private static string ExtractPlatformMonikerFromResourceName(string resourceName)
+ {
+ var idx = resourceName.LastIndexOf('-');
+ return idx >= 0 && idx < resourceName.Length - 1 ? resourceName[(idx + 1)..] : string.Empty;
+ }
+
+ ///
+ /// Attempt to add platform; returns true if a new platform resource was created and annotated as auto-detected.
+ ///
+ private static bool TryAddAutoDetectedPlatform(this IResourceBuilder builder, string moniker)
+ {
+ var configuration = GetConfiguration(builder);
+ var before = configuration.PlatformResources.Count;
+ builder.AddPlatform(moniker);
+ if (configuration.PlatformResources.Count > before)
+ {
+ configuration.PlatformResources[^1].WithAnnotation(new MauiAutoDetectedPlatformAnnotation());
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/src/Aspire.Hosting.Maui/MauiStartupPhaseTracker.cs b/src/Aspire.Hosting.Maui/MauiStartupPhaseTracker.cs
new file mode 100644
index 00000000000..2e484c4e616
--- /dev/null
+++ b/src/Aspire.Hosting.Maui/MauiStartupPhaseTracker.cs
@@ -0,0 +1,31 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Eventing;
+using Aspire.Hosting.Lifecycle;
+
+namespace Aspire.Hosting.Maui;
+
+///
+/// Tracks completion of the initial AppHost startup phase so MAUI platform resources can
+/// defer expensive build work until the user explicitly starts them later.
+///
+internal sealed class MauiStartupPhaseTracker : IDistributedApplicationEventingSubscriber
+{
+ public static volatile bool StartupPhaseComplete;
+
+ public Task SubscribeAsync(IDistributedApplicationEventing eventing, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken)
+ {
+ eventing.Subscribe(OnAfterResourcesCreatedAsync);
+ return Task.CompletedTask;
+ }
+
+ private static Task OnAfterResourcesCreatedAsync(AfterResourcesCreatedEvent e, CancellationToken cancellationToken)
+ {
+ _ = e;
+ _ = cancellationToken;
+ StartupPhaseComplete = true;
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Aspire.Hosting.Maui/Platforms/Android/AndroidEnvironmentTargetGenerator.cs b/src/Aspire.Hosting.Maui/Platforms/Android/AndroidEnvironmentTargetGenerator.cs
new file mode 100644
index 00000000000..605e915f41b
--- /dev/null
+++ b/src/Aspire.Hosting.Maui/Platforms/Android/AndroidEnvironmentTargetGenerator.cs
@@ -0,0 +1,278 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Globalization;
+using System.Xml.Linq;
+using Aspire.Hosting.ApplicationModel;
+using Microsoft.Extensions.Logging;
+
+namespace Aspire.Hosting.Maui.Platforms.Android;
+
+internal static class AndroidEnvironmentTargetGenerator
+{
+ private const string PlatformMoniker = "android";
+
+ public static async Task AppendEnvironmentTargetsAsync(CommandLineArgsCallbackContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ if (context.Resource is not ProjectResource projectResource)
+ {
+ return;
+ }
+
+ var generator = new Generator(context, projectResource);
+ await generator.ExecuteAsync(context.CancellationToken).ConfigureAwait(false);
+ }
+
+ private sealed class Generator
+ {
+ private readonly CommandLineArgsCallbackContext _context;
+ private readonly ProjectResource _projectResource;
+ private readonly Dictionary _environment = new(StringComparer.OrdinalIgnoreCase);
+ private readonly HashSet _encodedKeys = new(StringComparer.OrdinalIgnoreCase);
+
+ public Generator(CommandLineArgsCallbackContext context, ProjectResource projectResource)
+ {
+ _context = context;
+ _projectResource = projectResource;
+ }
+
+ public async Task ExecuteAsync(CancellationToken cancellationToken)
+ {
+ await CollectEnvironmentAsync(cancellationToken).ConfigureAwait(false);
+
+ if (_environment.Count == 0)
+ {
+ return;
+ }
+
+ var logger = _context.Logger;
+
+ var targetsPath = CreateTargetsFile(logger);
+ _context.Args.Add("-p:CustomAfterMicrosoftCommonTargets=" + targetsPath);
+
+ logger.LogInformation(
+ "Forwarding {EnvironmentVariableCount} environment variable(s) to the {Platform} launcher using targets file '{TargetsFile}'.",
+ _environment.Count,
+ PlatformMoniker,
+ targetsPath);
+
+ if (_encodedKeys.Count > 0)
+ {
+ logger.LogInformation(
+ "Encoded semicolons for environment variables {EnvironmentVariables} when forwarding to '{Resource}'.",
+ string.Join(", ", _encodedKeys),
+ _projectResource.Name);
+ }
+ }
+
+ private async Task CollectEnvironmentAsync(CancellationToken cancellationToken)
+ {
+ await _projectResource.ProcessEnvironmentVariableValuesAsync(
+ _context.ExecutionContext,
+ (key, _, processed, exception) =>
+ {
+ if (exception is not null || string.IsNullOrEmpty(key) || processed is not string value)
+ {
+ return;
+ }
+
+ if (!ShouldForwardToAndroid(key))
+ {
+ return;
+ }
+
+ // Android environment variables must be uppercase to be properly read by the runtime.
+ // See: https://developer.android.com/reference/java/lang/System#getenv(java.lang.String)
+ // and https://github.com/xamarin/xamarin-android/issues/7536
+ // Many Android tools and the Mono runtime expect environment variable names to be uppercase.
+ var normalizedKey = key.ToUpperInvariant();
+
+ var encodedValue = EncodeSemicolons(value, out var wasEncoded);
+ _environment[normalizedKey] = encodedValue;
+ if (wasEncoded)
+ {
+ _encodedKeys.Add(normalizedKey);
+ }
+ },
+ _context.Logger,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ // OTLP tunneled endpoint substitution: if a stub name and its service discovery var exist, override OTEL_EXPORTER_OTLP_ENDPOINT
+ if (_environment.TryGetValue("ASPIRE_MAUI_OTLP_STUB_NAME", out var stubName))
+ {
+ // Service discovery variable from tunnel injection uses the original endpoint scheme (http) even though
+ // the public dev tunnel endpoint is only reachable via HTTPS. Replace the scheme if needed.
+ // New stable endpoint name is 'otlp' (independent of scheme) so look that up first, then fall back.
+ // Note: Keys are uppercase since we normalized them above
+ var sdKey = $"SERVICES__{stubName.ToUpperInvariant()}__OTLP__0";
+ if (!_environment.ContainsKey(sdKey))
+ {
+ // Backward compatibility with earlier prototype where endpoint name equaled scheme 'http'.
+ var legacyKey = $"SERVICES__{stubName.ToUpperInvariant()}__HTTP__0";
+ if (_environment.ContainsKey(legacyKey))
+ {
+ sdKey = legacyKey;
+ }
+ }
+ if (_environment.TryGetValue(sdKey, out var tunneledUrl))
+ {
+ if (Uri.TryCreate(tunneledUrl, UriKind.Absolute, out var u) && u.Host.EndsWith(".devtunnels.ms", StringComparison.OrdinalIgnoreCase))
+ {
+ var builder = new UriBuilder(u);
+ // Always use https for dev tunnel hosts
+ builder.Scheme = "https";
+ // Remove default port 443 (and any trailing slash after ToString())
+ if (builder.Port == 443)
+ {
+ builder.Port = -1; // clears explicit :443
+ }
+ tunneledUrl = builder.Uri.ToString().TrimEnd('/');
+ }
+ _environment["OTEL_EXPORTER_OTLP_ENDPOINT"] = tunneledUrl;
+ }
+ }
+ }
+
+ private string CreateTargetsFile(ILogger logger)
+ {
+ var tempDirectory = Path.Combine(Path.GetTempPath(), "aspire", "maui", "android-env");
+ Directory.CreateDirectory(tempDirectory);
+
+ PruneOldTargets(tempDirectory, logger);
+
+ var sanitizedName = SanitizeFileName(_projectResource.Name + "-" + PlatformMoniker);
+ var uniqueId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
+ var targetsPath = Path.Combine(tempDirectory, $"{sanitizedName}-{uniqueId}.targets");
+
+ var projectElement = new XElement("Project");
+ projectElement.Add(new XElement(
+ "Import",
+ new XAttribute("Project", "$(MSBuildExtensionsPath)/v$(MSBuildToolsVersion)/Custom.After.Microsoft.Common.targets"),
+ new XAttribute("Condition", "Exists('$(MSBuildExtensionsPath)/v$(MSBuildToolsVersion)/Custom.After.Microsoft.Common.targets')")));
+
+ // Create an ItemGroup for AndroidEnvironment files to be generated
+ var itemGroup = new XElement("ItemGroup");
+ foreach (var (key, value) in _environment.OrderBy(static kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
+ {
+ itemGroup.Add(new XElement("_GeneratedAndroidEnvironment", new XAttribute("Include", $"{key}={value}")));
+ }
+
+ projectElement.Add(itemGroup);
+
+ // Add target to generate environment file(s)
+ var targetElement = new XElement(
+ "Target",
+ new XAttribute("Name", "AspireGenerateAndroidEnvironmentFiles"),
+ new XAttribute("BeforeTargets", "_GenerateEnvironmentFiles"),
+ new XAttribute("Condition", "'@(_GeneratedAndroidEnvironment)' != ''"));
+
+ // Write environment variables to a temporary file in IntermediateOutputPath
+ targetElement.Add(new XElement(
+ "WriteLinesToFile",
+ new XAttribute("File", "$(IntermediateOutputPath)__aspire_environment__.txt"),
+ new XAttribute("Lines", "@(_GeneratedAndroidEnvironment)"),
+ new XAttribute("Overwrite", "True"),
+ new XAttribute("WriteOnlyWhenDifferent", "True")));
+
+ // Add the file to AndroidEnvironment items
+ targetElement.Add(new XElement(
+ "ItemGroup",
+ new XElement("AndroidEnvironment", new XAttribute("Include", "$(IntermediateOutputPath)__aspire_environment__.txt"))));
+
+ // Add the file to FileWrites for clean
+ targetElement.Add(new XElement(
+ "ItemGroup",
+ new XElement("FileWrites", new XAttribute("Include", "$(IntermediateOutputPath)__aspire_environment__.txt"))));
+
+ // Force the GeneratePackageManagerJava target to re-run by deleting its stamp file
+ // This ensures environment changes trigger a rebuild of the Java environment files
+ targetElement.Add(new XElement(
+ "Delete",
+ new XAttribute("Files", "$(_AndroidStampDirectory)_GeneratePackageManagerJava.stamp")));
+
+ projectElement.Add(targetElement);
+
+ var document = new XDocument(new XDeclaration("1.0", "utf-8", "yes"), projectElement);
+ document.Save(targetsPath);
+
+ return targetsPath;
+ }
+
+ private static void PruneOldTargets(string directory, ILogger logger)
+ {
+ var expiration = DateTimeOffset.UtcNow - TimeSpan.FromDays(1);
+ var deletedFiles = new List();
+
+ foreach (var file in Directory.EnumerateFiles(directory, "*.targets", SearchOption.TopDirectoryOnly))
+ {
+ try
+ {
+ var info = new FileInfo(file);
+ if (info.Exists && info.LastWriteTimeUtc < expiration)
+ {
+ info.Delete();
+ deletedFiles.Add(info.Name);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogDebug(ex, "Failed to prune stale Android environment targets file '{TargetsFile}'.", file);
+ }
+ }
+
+ if (deletedFiles.Count > 0)
+ {
+ logger.LogDebug("Pruned {Count} stale Android environment targets file(s) from '{Directory}': {Files}.", deletedFiles.Count, directory, string.Join(", ", deletedFiles));
+ }
+ }
+
+ private static bool ShouldForwardToAndroid(string key)
+ {
+ return key.StartsWith("services__", StringComparison.OrdinalIgnoreCase)
+ || key.StartsWith("connectionstrings__", StringComparison.OrdinalIgnoreCase)
+ || key.StartsWith("ASPIRE_", StringComparison.OrdinalIgnoreCase)
+ || key.StartsWith("AppHost__", StringComparison.OrdinalIgnoreCase)
+ || key.StartsWith("OTEL_", StringComparison.OrdinalIgnoreCase)
+ || key.StartsWith("LOGGING__CONSOLE", StringComparison.OrdinalIgnoreCase)
+ || key.Equals("ASPNETCORE_ENVIRONMENT", StringComparison.OrdinalIgnoreCase)
+ || key.Equals("ASPNETCORE_URLS", StringComparison.OrdinalIgnoreCase)
+ || key.Equals("DOTNET_ENVIRONMENT", StringComparison.OrdinalIgnoreCase)
+ || key.Equals("DOTNET_URLS", StringComparison.OrdinalIgnoreCase)
+ || key.Equals("DOTNET_LAUNCH_PROFILE", StringComparison.OrdinalIgnoreCase)
+ || key.Equals("DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string SanitizeFileName(string name)
+ {
+ var invalidCharacters = Path.GetInvalidFileNameChars();
+ if (name.IndexOfAny(invalidCharacters) < 0)
+ {
+ return name;
+ }
+
+ var chars = name.ToCharArray();
+ for (var i = 0; i < chars.Length; i++)
+ {
+ if (Array.IndexOf(invalidCharacters, chars[i]) >= 0)
+ {
+ chars[i] = '_';
+ }
+ }
+
+ return new string(chars);
+ }
+
+ private static string EncodeSemicolons(string value, out bool wasEncoded)
+ {
+ wasEncoded = value.Contains(';', StringComparison.Ordinal);
+ if (!wasEncoded)
+ {
+ return value;
+ }
+
+ return value.Replace(";", "%3B", StringComparison.Ordinal);
+ }
+ }
+}
diff --git a/src/Aspire.Hosting.Maui/Platforms/iOS/iOSMlaunchEnvironmentTargetGenerator.cs b/src/Aspire.Hosting.Maui/Platforms/iOS/iOSMlaunchEnvironmentTargetGenerator.cs
new file mode 100644
index 00000000000..3fad259cb47
--- /dev/null
+++ b/src/Aspire.Hosting.Maui/Platforms/iOS/iOSMlaunchEnvironmentTargetGenerator.cs
@@ -0,0 +1,248 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Globalization;
+using System.Xml.Linq;
+using Aspire.Hosting.ApplicationModel;
+using Microsoft.Extensions.Logging;
+
+namespace Aspire.Hosting.Maui.Platforms.iOS;
+
+internal static class iOSMlaunchEnvironmentTargetGenerator
+{
+ private const string PlatformMoniker = "ios";
+
+ public static async Task AppendEnvironmentTargetsAsync(CommandLineArgsCallbackContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ if (context.Resource is not ProjectResource projectResource)
+ {
+ return;
+ }
+
+ var generator = new Generator(context, projectResource);
+ await generator.ExecuteAsync(context.CancellationToken).ConfigureAwait(false);
+ }
+
+ private sealed class Generator
+ {
+ private readonly CommandLineArgsCallbackContext _context;
+ private readonly ProjectResource _projectResource;
+ private readonly Dictionary _environment = new(StringComparer.OrdinalIgnoreCase);
+ private readonly HashSet _encodedKeys = new(StringComparer.OrdinalIgnoreCase);
+
+ public Generator(CommandLineArgsCallbackContext context, ProjectResource projectResource)
+ {
+ _context = context;
+ _projectResource = projectResource;
+ }
+
+ public async Task ExecuteAsync(CancellationToken cancellationToken)
+ {
+ await CollectEnvironmentAsync(cancellationToken).ConfigureAwait(false);
+
+ if (_environment.Count == 0)
+ {
+ return;
+ }
+
+ var logger = _context.Logger;
+
+ var targetsPath = CreateTargetsFile(logger);
+ _context.Args.Add("-p:CustomAfterMicrosoftCommonTargets=" + targetsPath);
+
+ logger.LogInformation(
+ "Forwarding {EnvironmentVariableCount} environment variable(s) to the {Platform} launcher using targets file '{TargetsFile}'.",
+ _environment.Count,
+ PlatformMoniker,
+ targetsPath);
+
+ if (_encodedKeys.Count > 0)
+ {
+ logger.LogInformation(
+ "Encoded semicolons for environment variables {EnvironmentVariables} when forwarding to '{Resource}'.",
+ string.Join(", ", _encodedKeys),
+ _projectResource.Name);
+ }
+ }
+
+ private async Task CollectEnvironmentAsync(CancellationToken cancellationToken)
+ {
+ await _projectResource.ProcessEnvironmentVariableValuesAsync(
+ _context.ExecutionContext,
+ (key, _, processed, exception) =>
+ {
+ if (exception is not null || string.IsNullOrEmpty(key) || processed is not string value)
+ {
+ return;
+ }
+
+ if (!ShouldForwardToMlaunch(key))
+ {
+ return;
+ }
+
+ var encodedValue = EncodeSemicolons(value, out var wasEncoded);
+ _environment[key] = encodedValue;
+ if (wasEncoded)
+ {
+ _encodedKeys.Add(key);
+ }
+ },
+ _context.Logger,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ // OTLP tunneled endpoint substitution: if a stub name and its service discovery var exist, override OTEL_EXPORTER_OTLP_ENDPOINT
+ if (_environment.TryGetValue("ASPIRE_MAUI_OTLP_STUB_NAME", out var stubName))
+ {
+ // Service discovery variable from tunnel injection uses the original endpoint scheme (http) even though
+ // the public dev tunnel endpoint is only reachable via HTTPS. Replace the scheme if needed.
+ // New stable endpoint name is 'otlp' (independent of scheme) so look that up first, then fall back.
+ var sdKey = $"services__{stubName}__otlp__0";
+ if (!_environment.ContainsKey(sdKey))
+ {
+ // Backward compatibility with earlier prototype where endpoint name equaled scheme 'http'.
+ var legacyKey = $"services__{stubName}__http__0";
+ if (_environment.ContainsKey(legacyKey))
+ {
+ sdKey = legacyKey;
+ }
+ }
+ if (_environment.TryGetValue(sdKey, out var tunneledUrl))
+ {
+ if (Uri.TryCreate(tunneledUrl, UriKind.Absolute, out var u) && u.Host.EndsWith(".devtunnels.ms", StringComparison.OrdinalIgnoreCase))
+ {
+ var builder = new UriBuilder(u);
+ // Always use https for dev tunnel hosts
+ builder.Scheme = "https";
+ // Remove default port 443 (and any trailing slash after ToString())
+ if (builder.Port == 443)
+ {
+ builder.Port = -1; // clears explicit :443
+ }
+ tunneledUrl = builder.Uri.ToString().TrimEnd('/');
+ }
+ _environment["OTEL_EXPORTER_OTLP_ENDPOINT"] = tunneledUrl;
+ }
+ }
+ }
+
+ private string CreateTargetsFile(ILogger logger)
+ {
+ var tempDirectory = Path.Combine(Path.GetTempPath(), "aspire", "maui", "mlaunch-env");
+ Directory.CreateDirectory(tempDirectory);
+
+ PruneOldTargets(tempDirectory, logger);
+
+ var sanitizedName = SanitizeFileName(_projectResource.Name + "-" + PlatformMoniker);
+ var uniqueId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
+ var targetsPath = Path.Combine(tempDirectory, $"{sanitizedName}-{uniqueId}.targets");
+
+ var projectElement = new XElement("Project");
+ projectElement.Add(new XElement(
+ "Import",
+ new XAttribute("Project", "$(MSBuildExtensionsPath)/v$(MSBuildToolsVersion)/Custom.After.Microsoft.Common.targets"),
+ new XAttribute("Condition", "Exists('$(MSBuildExtensionsPath)/v$(MSBuildToolsVersion)/Custom.After.Microsoft.Common.targets')")));
+
+ var itemGroup = new XElement("ItemGroup");
+ foreach (var (key, value) in _environment.OrderBy(static kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
+ {
+ itemGroup.Add(new XElement("MlaunchEnvironmentVariables", new XAttribute("Include", $"{key}={value}")));
+ }
+
+ projectElement.Add(itemGroup);
+
+ projectElement.Add(new XElement(
+ "Target",
+ new XAttribute("Name", "AspireLogMlaunchEnvironmentVariables"),
+ new XAttribute("AfterTargets", "PrepareForBuild"),
+ new XAttribute("Condition", "'@(MlaunchEnvironmentVariables)' != ''"),
+ new XElement(
+ "Message",
+ new XAttribute("Importance", "High"),
+ new XAttribute("Text", "Aspire forwarding mlaunch environment variables: @(MlaunchEnvironmentVariables, ', ')")
+ )));
+
+ var document = new XDocument(new XDeclaration("1.0", "utf-8", "yes"), projectElement);
+ document.Save(targetsPath);
+
+ return targetsPath;
+ }
+
+ private static void PruneOldTargets(string directory, ILogger logger)
+ {
+ var expiration = DateTimeOffset.UtcNow - TimeSpan.FromDays(1);
+ var deletedFiles = new List();
+
+ foreach (var file in Directory.EnumerateFiles(directory, "*.targets", SearchOption.TopDirectoryOnly))
+ {
+ try
+ {
+ var info = new FileInfo(file);
+ if (info.Exists && info.LastWriteTimeUtc < expiration)
+ {
+ info.Delete();
+ deletedFiles.Add(info.Name);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogDebug(ex, "Failed to prune stale mlaunch targets file '{TargetsFile}'.", file);
+ }
+ }
+
+ if (deletedFiles.Count > 0)
+ {
+ logger.LogDebug("Pruned {Count} stale mlaunch targets file(s) from '{Directory}': {Files}.", deletedFiles.Count, directory, string.Join(", ", deletedFiles));
+ }
+ }
+
+ private static bool ShouldForwardToMlaunch(string key)
+ {
+ return key.StartsWith("services__", StringComparison.OrdinalIgnoreCase)
+ || key.StartsWith("connectionstrings__", StringComparison.OrdinalIgnoreCase)
+ || key.StartsWith("ASPIRE_", StringComparison.OrdinalIgnoreCase)
+ || key.StartsWith("AppHost__", StringComparison.OrdinalIgnoreCase)
+ || key.StartsWith("OTEL_", StringComparison.OrdinalIgnoreCase)
+ || key.StartsWith("LOGGING__CONSOLE", StringComparison.OrdinalIgnoreCase)
+ || key.Equals("ASPNETCORE_ENVIRONMENT", StringComparison.OrdinalIgnoreCase)
+ || key.Equals("ASPNETCORE_URLS", StringComparison.OrdinalIgnoreCase)
+ || key.Equals("DOTNET_ENVIRONMENT", StringComparison.OrdinalIgnoreCase)
+ || key.Equals("DOTNET_URLS", StringComparison.OrdinalIgnoreCase)
+ || key.Equals("DOTNET_LAUNCH_PROFILE", StringComparison.OrdinalIgnoreCase)
+ || key.Equals("DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string SanitizeFileName(string name)
+ {
+ var invalidCharacters = Path.GetInvalidFileNameChars();
+ if (name.IndexOfAny(invalidCharacters) < 0)
+ {
+ return name;
+ }
+
+ var chars = name.ToCharArray();
+ for (var i = 0; i < chars.Length; i++)
+ {
+ if (Array.IndexOf(invalidCharacters, chars[i]) >= 0)
+ {
+ chars[i] = '_';
+ }
+ }
+
+ return new string(chars);
+ }
+
+ private static string EncodeSemicolons(string value, out bool wasEncoded)
+ {
+ wasEncoded = value.Contains(';', StringComparison.Ordinal);
+ if (!wasEncoded)
+ {
+ return value;
+ }
+
+ return value.Replace(";", "%3B", StringComparison.Ordinal);
+ }
+ }
+}
diff --git a/src/Aspire.Hosting.Maui/api/Aspire.Hosting.Maui.cs b/src/Aspire.Hosting.Maui/api/Aspire.Hosting.Maui.cs
new file mode 100644
index 00000000000..f4a3044382e
--- /dev/null
+++ b/src/Aspire.Hosting.Maui/api/Aspire.Hosting.Maui.cs
@@ -0,0 +1,47 @@
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+namespace Aspire.Hosting
+{
+ public static partial class MauiProjectExtensions
+ {
+ public static Maui.MauiProjectBuilder AddMauiProject(this IDistributedApplicationBuilder builder, string name, string projectPath) { throw null; }
+ }
+}
+
+namespace Aspire.Hosting.Maui
+{
+ public sealed partial class MauiProjectBuilder
+ {
+ internal MauiProjectBuilder() { }
+
+ public string ProjectPath { get { throw null; } }
+
+ public MauiProjectBuilder WithAndroid(string? adbTarget = null) { throw null; }
+
+ public MauiProjectBuilder WithiOS(string? deviceUdid = null) { throw null; }
+
+ public MauiProjectBuilder WithMacCatalyst(string? runtimeIdentifier = null) { throw null; }
+
+ public MauiProjectBuilder WithOtlpDevTunnel(string? tunnelName = null, bool enableOtelDebug = false) { throw null; }
+
+ public MauiProjectBuilder WithReference(ApplicationModel.IResourceBuilder source, ApplicationModel.IResourceBuilder tunnel) { throw null; }
+
+ public MauiProjectBuilder WithReference(ApplicationModel.IResourceBuilder source, string? connectionName = null)
+ where TSource : ApplicationModel.IResource { throw null; }
+
+ public MauiProjectBuilder WithWindows(string? runtimeIdentifier = null) { throw null; }
+ }
+
+ public sealed partial class MauiProjectResource : ApplicationModel.Resource
+ {
+ public MauiProjectResource(string name, string projectPath) : base(default!) { }
+
+ public string ProjectPath { get { throw null; } }
+ }
+}
\ No newline at end of file
diff --git a/tests/Aspire.Hosting.Maui.Tests/Aspire.Hosting.Maui.Tests.csproj b/tests/Aspire.Hosting.Maui.Tests/Aspire.Hosting.Maui.Tests.csproj
new file mode 100644
index 00000000000..4d0aa5ad81c
--- /dev/null
+++ b/tests/Aspire.Hosting.Maui.Tests/Aspire.Hosting.Maui.Tests.csproj
@@ -0,0 +1,23 @@
+
+
+
+ $(DefaultTargetFramework)
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Aspire.Hosting.Maui.Tests/MauiDuplicatePlatformTests.cs b/tests/Aspire.Hosting.Maui.Tests/MauiDuplicatePlatformTests.cs
new file mode 100644
index 00000000000..84f4d39aba9
--- /dev/null
+++ b/tests/Aspire.Hosting.Maui.Tests/MauiDuplicatePlatformTests.cs
@@ -0,0 +1,116 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting.Maui.Tests;
+
+public class MauiDuplicatePlatformTests
+{
+ [Xunit.Fact]
+ public void DuplicatePlatform_LogsWarningAndIgnoresSecondCall()
+ {
+ // Arrange
+ var csproj = MauiTestHelpers.CreateProject("net10.0-windows10.0.19041.0", "net10.0-android");
+ var testSink = new Microsoft.Extensions.Logging.Testing.TestSink();
+ var builder = Hosting.DistributedApplication.CreateBuilder(new Hosting.DistributedApplicationOptions
+ {
+ DisableDashboard = true
+ });
+ builder.Services.Add(new Microsoft.Extensions.DependencyInjection.ServiceDescriptor(
+ typeof(Microsoft.Extensions.Logging.ILoggerProvider),
+ new Microsoft.Extensions.Logging.Testing.TestLoggerProvider(testSink)));
+
+ // Act - call WithWindows twice
+ builder.AddMauiProject("maui", csproj)
+ .WithWindows()
+ .WithWindows(); // Duplicate!
+
+ using var app = builder.Build();
+ var model = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService(app.Services);
+
+ // Assert - only one Windows resource should exist
+ var windowsResources = model.Resources.OfType()
+ .Where(r => r.Name == "maui-windows")
+ .ToList();
+
+ Assert.Single(windowsResources);
+
+ // Assert - warning was logged
+ var warnings = testSink.Writes.Where(w =>
+ w.LogLevel == Microsoft.Extensions.Logging.LogLevel.Warning &&
+ w.Message != null &&
+ w.Message.Contains("Platform") &&
+ w.Message.Contains("already been added")).ToList();
+
+ Assert.NotEmpty(warnings);
+ var warning = warnings.First();
+ Assert.Contains("windows", warning.Message, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("WithWindows", warning.Message);
+ }
+
+ [Xunit.Fact]
+ public void MultipleDifferentPlatforms_AllAdded()
+ {
+ // Arrange
+ var csproj = MauiTestHelpers.CreateProject("net10.0-windows10.0.19041.0", "net10.0-android", "net10.0-maccatalyst");
+ var builder = Hosting.DistributedApplication.CreateBuilder(new Hosting.DistributedApplicationOptions
+ {
+ DisableDashboard = true
+ });
+
+ // Act - add three different platforms
+ builder.AddMauiProject("maui", csproj)
+ .WithWindows()
+ .WithAndroid()
+ .WithMacCatalyst();
+
+ using var app = builder.Build();
+ var model = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService(app.Services);
+
+ // Assert - all three resources should exist
+ var resources = model.Resources.OfType().ToList();
+
+ Assert.Contains(resources, r => r.Name == "maui-windows");
+ Assert.Contains(resources, r => r.Name == "maui-android");
+ Assert.Contains(resources, r => r.Name == "maui-maccatalyst");
+ Assert.Equal(3, resources.Count);
+ }
+
+ [Xunit.Fact]
+ public void DuplicateAndroid_LogsWarningAndIgnoresSecondCall()
+ {
+ // Arrange
+ var csproj = MauiTestHelpers.CreateProject("net10.0-android");
+ var testSink = new Microsoft.Extensions.Logging.Testing.TestSink();
+ var builder = Hosting.DistributedApplication.CreateBuilder(new Hosting.DistributedApplicationOptions
+ {
+ DisableDashboard = true
+ });
+ builder.Services.Add(new Microsoft.Extensions.DependencyInjection.ServiceDescriptor(
+ typeof(Microsoft.Extensions.Logging.ILoggerProvider),
+ new Microsoft.Extensions.Logging.Testing.TestLoggerProvider(testSink)));
+
+ // Act - call WithAndroid twice
+ builder.AddMauiProject("maui", csproj)
+ .WithAndroid()
+ .WithAndroid("emulator-5554"); // Duplicate with different parameter!
+
+ using var app = builder.Build();
+ var model = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService(app.Services);
+
+ // Assert - only one Android resource should exist (first call wins)
+ var androidResources = model.Resources.OfType()
+ .Where(r => r.Name == "maui-android")
+ .ToList();
+
+ Assert.Single(androidResources);
+
+ // Assert - warning was logged
+ var warnings = testSink.Writes.Where(w =>
+ w.LogLevel == Microsoft.Extensions.Logging.LogLevel.Warning &&
+ w.Message != null &&
+ w.Message.Contains("android", StringComparison.OrdinalIgnoreCase) &&
+ w.Message.Contains("already been added")).ToList();
+
+ Assert.NotEmpty(warnings);
+ }
+}
diff --git a/tests/Aspire.Hosting.Maui.Tests/MauiMacCatalystTests.cs b/tests/Aspire.Hosting.Maui.Tests/MauiMacCatalystTests.cs
new file mode 100644
index 00000000000..ff8a1d9feb0
--- /dev/null
+++ b/tests/Aspire.Hosting.Maui.Tests/MauiMacCatalystTests.cs
@@ -0,0 +1,43 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting.Maui.Tests;
+
+public class MauiMacCatalystTests
+{
+ [Xunit.Fact]
+ public void AddsOpenArgumentsFlagWhenNotProvided()
+ {
+ var csproj = MauiTestHelpers.CreateProject("net10.0-maccatalyst", "net10.0-windows10.0.19041.0");
+ var builder = Hosting.DistributedApplication.CreateBuilder();
+ builder.AddMauiProject("maui", csproj).WithMacCatalyst();
+ using var app = builder.Build();
+
+ var model = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService(app.Services);
+ var macRes = Assert.Single(model.Resources.OfType(), r => r.Name == "maui-maccatalyst");
+
+ // Verify the CommandLineArgsCallbackAnnotation contains the OpenArguments flag
+ var argsAnnotations = macRes.Annotations.OfType().ToList();
+ Assert.NotEmpty(argsAnnotations);
+
+ // Create a mock context to invoke the callbacks and collect the arguments
+ var args = new List