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/Aspire.slnx b/Aspire.slnx
index 2dab93a8f6a..c2e83311af1 100644
--- a/Aspire.slnx
+++ b/Aspire.slnx
@@ -55,6 +55,7 @@
+
diff --git a/AspireWithMaui.slnx b/AspireWithMaui.slnx
new file mode 100644
index 00000000000..6e84d6544d3
--- /dev/null
+++ b/AspireWithMaui.slnx
@@ -0,0 +1,479 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/NuGet.config b/NuGet.config
index bae60ec60c9..04aedeae849 100644
--- a/NuGet.config
+++ b/NuGet.config
@@ -16,6 +16,7 @@
+
@@ -35,6 +36,9 @@
+
+
+
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..9f1d2aedbc5
--- /dev/null
+++ b/playground/AspireWithMaui/AspireWithMaui.MauiServiceDefaults/Extensions.cs
@@ -0,0 +1,128 @@
+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();
+ }
+
+ 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..1fc3811d453
--- /dev/null
+++ b/playground/AspireWithMaui/README.md
@@ -0,0 +1,121 @@
+# AspireWithMaui Playground
+
+This playground demonstrates .NET Aspire integration with .NET MAUI applications.
+
+## Prerequisites
+
+- .NET 10 or later
+- .NET MAUI workload
+
+## Getting Started
+
+### Initial Setup
+
+Before building or running the playground, you must restore dependencies and install the MAUI workload.
+
+Run the following commands from the repository root:
+
+**Windows:**
+```cmd
+.\restore.cmd -restore-maui
+```
+
+**Linux/macOS:**
+```bash
+./restore.sh --restore-maui
+```
+
+This will:
+1. Restore all Aspire dependencies and set up the local .dotnet SDK
+2. Install the MAUI workload into the repository's 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.
+> This also means that you will still need to do this even if you have the MAUI workload already installed in your system-wide .NET installation.
+
+### Running the Playground
+
+After running the restore script with `-restore-maui`, you can build and run the playground:
+
+**Using Visual Studio:**
+1. Run `.\restore.cmd -restore-maui` from the repository root (Windows)
+2. Open `AspireWithMaui.AppHost` project
+3. Set it as the startup project
+4. Press F5 to run
+
+**Using VS Code:**
+1. Run `.\restore.cmd -restore-maui` (Windows) or `./restore.sh --restore-maui` (Linux/macOS) from the repository root
+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 -restore-maui` (Windows) or `./restore.sh --restore-maui` (Linux/macOS) from the repository root
+2. Navigate to `playground/AspireWithMaui/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 (Windows platform only in this playground)
+- **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 Windows Platform Support
+The playground demonstrates Aspire's ability to manage MAUI apps on Windows:
+- Configures the MAUI app with `.AddMauiWindows()`
+- Automatically detects the Windows target framework from the project file
+- Sets up dev tunnels for MAUI app communication with backend services
+
+### 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.
+
+### Future Platform Support
+The architecture is designed to support additional platforms (Android, iOS, macCatalyst) through:
+- `.AddMauiAndroid()`, `.AddMauiIos()`, `.AddMauiMacCatalyst()` extension methods (coming in future updates)
+- Parallel extension patterns for each platform
+
+## Troubleshooting
+
+### "MAUI workload not detected" Warning
+If you see this warning in the Aspire dashboard:
+1. Make sure you ran `.\restore.cmd -restore-maui` or `./restore.sh --restore-maui` from the repository root
+2. The warning indicates the MAUI workload is not installed in the local `.dotnet` folder
+3. Re-run the restore command with the `-restore-maui` or `--restore-maui` flag
+
+### Build Errors
+If you encounter build errors:
+1. Ensure you ran the restore script with the MAUI flag first: `.\restore.cmd -restore-maui`
+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**: Not yet implemented in this playground (coming soon)
+- **iOS/macCatalyst**: Not yet implemented in this playground (coming soon)
+
+## Current Status
+
+✅ **Implemented:**
+- Windows platform support via `AddMauiWindows()`
+- Automatic Windows TFM detection from project file
+- Dev tunnel configuration for MAUI-to-backend communication
+- Service discovery integration
+- OpenTelemetry integration
+
+🚧 **Coming Soon:**
+- Android platform support
+- iOS platform support
+- macCatalyst platform support
+- Multi-platform simultaneous debugging
+
+## 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..d77c25b33d8
--- /dev/null
+++ b/playground/AspireWithMaui/restore.cmd
@@ -0,0 +1,26 @@
+@ECHO OFF
+SETLOCAL EnableDelayedExpansion
+
+ECHO.
+ECHO ============================================================
+ECHO Restoring AspireWithMaui Playground
+ECHO ============================================================
+ECHO.
+
+REM Run the main Aspire restore with MAUI workload installation
+ECHO Running main Aspire restore with MAUI workload installation...
+CALL "%~dp0..\..\restore.cmd" -installMaui
+IF ERRORLEVEL 1 (
+ ECHO ERROR: Failed to restore Aspire. Please check the output above.
+ EXIT /B 1
+)
+
+ECHO.
+ECHO ============================================================
+ECHO Restore complete!
+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..a15533d81c9
--- /dev/null
+++ b/playground/AspireWithMaui/restore.sh
@@ -0,0 +1,27 @@
+#!/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/../.."
+
+# Run the main Aspire restore with MAUI workload installation
+echo "Running main Aspire restore with MAUI workload installation..."
+"$repo_root/restore.sh" --install-maui
+
+echo ""
+echo "============================================================"
+echo "Restore complete!"
+echo "============================================================"
+echo ""
+echo "You can now build and run the AspireWithMaui playground."
+echo ""
+
+exit 0
diff --git a/src/Aspire.Hosting.Maui/Annotations/UnsupportedPlatformAnnotation.cs b/src/Aspire.Hosting.Maui/Annotations/UnsupportedPlatformAnnotation.cs
new file mode 100644
index 00000000000..8a5e159f704
--- /dev/null
+++ b/src/Aspire.Hosting.Maui/Annotations/UnsupportedPlatformAnnotation.cs
@@ -0,0 +1,19 @@
+// 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.Annotations;
+
+///
+/// Annotation to mark a resource as running on an unsupported platform.
+/// This prevents lifecycle commands and sets the state to "Unsupported".
+///
+/// The reason why the platform is unsupported.
+internal sealed class UnsupportedPlatformAnnotation(string reason) : IResourceAnnotation
+{
+ ///
+ /// Gets the reason why the platform is unsupported.
+ ///
+ public string Reason { get; } = reason;
+}
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..e29e204cad8
--- /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/Lifecycle/UnsupportedPlatformEventSubscriber.cs b/src/Aspire.Hosting.Maui/Lifecycle/UnsupportedPlatformEventSubscriber.cs
new file mode 100644
index 00000000000..1a3ee3e711b
--- /dev/null
+++ b/src/Aspire.Hosting.Maui/Lifecycle/UnsupportedPlatformEventSubscriber.cs
@@ -0,0 +1,40 @@
+// 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;
+using Aspire.Hosting.Maui.Annotations;
+
+namespace Aspire.Hosting.Maui.Lifecycle;
+
+///
+/// Event subscriber that sets the "Unsupported" state for MAUI platform resources
+/// marked with .
+///
+/// The notification service for publishing resource state updates.
+internal sealed class UnsupportedPlatformEventSubscriber(ResourceNotificationService notificationService) : IDistributedApplicationEventingSubscriber
+{
+ ///
+ public Task SubscribeAsync(IDistributedApplicationEventing eventing, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken)
+ {
+ eventing.Subscribe(async (@event, ct) =>
+ {
+ // Find all MAUI platform resources with the UnsupportedPlatformAnnotation
+ foreach (var resource in @event.Model.Resources)
+ {
+ if (resource is MauiWindowsPlatformResource &&
+ resource.TryGetLastAnnotation(out var annotation))
+ {
+ // Set the state to "Unsupported" with a warning style and the reason
+ await notificationService.PublishUpdateAsync(resource, s => s with
+ {
+ State = new ResourceStateSnapshot($"Unsupported: {annotation.Reason}", "warning")
+ }).ConfigureAwait(false);
+ }
+ }
+ });
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Aspire.Hosting.Maui/MauiProjectMetadata.cs b/src/Aspire.Hosting.Maui/MauiProjectMetadata.cs
new file mode 100644
index 00000000000..460db4def4a
--- /dev/null
+++ b/src/Aspire.Hosting.Maui/MauiProjectMetadata.cs
@@ -0,0 +1,14 @@
+// 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;
+
+///
+/// Project metadata for MAUI projects.
+///
+/// The path to the MAUI project file.
+internal sealed class MauiProjectMetadata(string projectPath) : IProjectMetadata
+{
+ ///
+ public string ProjectPath { get; } = projectPath;
+}
diff --git a/src/Aspire.Hosting.Maui/MauiProjectResource.cs b/src/Aspire.Hosting.Maui/MauiProjectResource.cs
new file mode 100644
index 00000000000..f844dad0e5f
--- /dev/null
+++ b/src/Aspire.Hosting.Maui/MauiProjectResource.cs
@@ -0,0 +1,27 @@
+// 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;
+
+///
+/// Represents a .NET MAUI project resource in the distributed application model.
+///
+/// The name of the resource.
+/// The path to the .NET MAUI project file.
+///
+/// This resource serves as a parent for platform-specific MAUI resources (Windows, Android, iOS, macOS).
+/// Use extension methods like AddWindowsDevice to add platform-specific instances.
+///
+/// MAUI projects are built on-demand when the platform-specific resource is started, avoiding long
+/// AppHost startup times while still allowing incremental builds during development.
+///
+///
+public class MauiProjectResource(string name, string projectPath) : Resource(name)
+{
+ ///
+ /// Gets the path to the .NET MAUI project file.
+ ///
+ public string ProjectPath { get; } = projectPath ?? throw new ArgumentNullException(nameof(projectPath));
+}
diff --git a/src/Aspire.Hosting.Maui/MauiProjectResourceExtensions.cs b/src/Aspire.Hosting.Maui/MauiProjectResourceExtensions.cs
new file mode 100644
index 00000000000..f2449563554
--- /dev/null
+++ b/src/Aspire.Hosting.Maui/MauiProjectResourceExtensions.cs
@@ -0,0 +1,60 @@
+// 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.Maui;
+
+namespace Aspire.Hosting;
+
+///
+/// Provides extension methods for adding .NET MAUI projects to the application model.
+///
+public static class MauiProjectExtensions
+{
+ ///
+ /// Adds a .NET MAUI project to the application model. This resource can be used to create platform-specific resources.
+ ///
+ /// The builder for the distributed application.
+ /// The name of the resource.
+ /// The path to the .NET MAUI project file (.csproj). This can be a relative or absolute path.
+ /// A reference to the .
+ ///
+ /// This method creates a parent MAUI project resource that serves as a container for platform-specific
+ /// resources such as Windows, Android, iOS, and macOS. The actual platform instances are added using
+ /// extension methods like AddWindowsDevice.
+ ///
+ /// The MAUI project is not built immediately when the AppHost starts. Instead, builds are deferred
+ /// until a platform-specific resource is started, allowing faster AppHost startup and enabling
+ /// incremental builds during development.
+ ///
+ ///
+ ///
+ /// Add a MAUI project with Windows support:
+ ///
+ /// var builder = DistributedApplication.CreateBuilder(args);
+ ///
+ /// var weatherApi = builder.AddProject<Projects.WeatherApi>("api");
+ ///
+ /// var maui = builder.AddMauiProject("mauiapp", "../MyMauiApp/MyMauiApp.csproj");
+ /// var windowsDevice = maui.AddWindowsDevice()
+ /// .WithReference(weatherApi);
+ ///
+ /// builder.Build().Run();
+ ///
+ ///
+ public static IResourceBuilder AddMauiProject(
+ this IDistributedApplicationBuilder builder,
+ [ResourceName] string name,
+ string projectPath)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(name);
+ ArgumentException.ThrowIfNullOrEmpty(projectPath);
+
+ // Create the MAUI project resource and configuration
+ // Do not register the logical grouping resource with AddResource so it stays invisible in the dashboard
+ // Only MAUI project targets added through their extension methods will show up
+ var resource = new MauiProjectResource(name, projectPath);
+ return builder.CreateResourceBuilder(resource);
+ }
+}
diff --git a/src/Aspire.Hosting.Maui/MauiWindowsExtensions.cs b/src/Aspire.Hosting.Maui/MauiWindowsExtensions.cs
new file mode 100644
index 00000000000..1729a3549be
--- /dev/null
+++ b/src/Aspire.Hosting.Maui/MauiWindowsExtensions.cs
@@ -0,0 +1,164 @@
+// 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 Aspire.Hosting.Maui.Annotations;
+using Aspire.Hosting.Maui.Lifecycle;
+using Aspire.Hosting.Maui.Utilities;
+using Aspire.Hosting.Utils;
+
+namespace Aspire.Hosting;
+
+///
+/// Provides extension methods for adding Windows platform resources to MAUI projects.
+///
+public static class MauiWindowsExtensions
+{
+ ///
+ /// Adds a Windows device resource to run the MAUI application on the Windows platform.
+ ///
+ /// The MAUI project resource builder.
+ /// A reference to the .
+ ///
+ /// This method creates a new Windows platform resource that will run the MAUI application
+ /// targeting the Windows platform using dotnet run. The resource does not auto-start
+ /// and must be explicitly started from the dashboard by clicking the start button.
+ ///
+ /// The resource name will default to "{projectName}-windows".
+ ///
+ ///
+ ///
+ /// Add a Windows device to a MAUI project:
+ ///
+ /// var builder = DistributedApplication.CreateBuilder(args);
+ ///
+ /// var maui = builder.AddMauiProject("mauiapp", "../MyMauiApp/MyMauiApp.csproj");
+ /// var windowsDevice = maui.AddWindowsDevice();
+ ///
+ /// builder.Build().Run();
+ ///
+ ///
+ public static IResourceBuilder AddWindowsDevice(
+ this IResourceBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ var name = $"{builder.Resource.Name}-windows";
+ return builder.AddWindowsDevice(name);
+ }
+
+ ///
+ /// Adds a Windows device resource to run the MAUI application on the Windows platform with a specific name.
+ ///
+ /// The MAUI project resource builder.
+ /// The name of the Windows device resource.
+ /// A reference to the .
+ ///
+ /// This method creates a new Windows platform resource that will run the MAUI application
+ /// targeting the Windows platform using dotnet run. The resource does not auto-start
+ /// and must be explicitly started from the dashboard by clicking the start button.
+ ///
+ /// Multiple Windows device resources can be added to the same MAUI project if needed, each with
+ /// a unique name.
+ ///
+ ///
+ ///
+ /// Add multiple Windows devices to a MAUI project:
+ ///
+ /// var builder = DistributedApplication.CreateBuilder(args);
+ ///
+ /// var maui = builder.AddMauiProject("mauiapp", "../MyMauiApp/MyMauiApp.csproj");
+ /// var windowsDevice1 = maui.AddWindowsDevice("windows-device-1");
+ /// var windowsDevice2 = maui.AddWindowsDevice("windows-device-2");
+ ///
+ /// builder.Build().Run();
+ ///
+ ///
+ public static IResourceBuilder AddWindowsDevice(
+ this IResourceBuilder builder,
+ [ResourceName] string name)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+
+ // Check if a Windows device with this name already exists in the application model
+ var existingWindowsDevice = builder.ApplicationBuilder.Resources
+ .OfType()
+ .FirstOrDefault(r => r.Parent == builder.Resource &&
+ string.Equals(r.Name, name, StringComparisons.ResourceName));
+
+ if (existingWindowsDevice is not null)
+ {
+ throw new DistributedApplicationException(
+ $"Windows device with name '{name}' already exists on MAUI project '{builder.Resource.Name}'. " +
+ $"Provide a unique name parameter when calling AddWindowsDevice() to add multiple Windows devices.");
+ }
+
+ // Get the absolute project path and working directory
+ var projectPath = builder.Resource.ProjectPath;
+ if (!Path.IsPathRooted(projectPath))
+ {
+ projectPath = PathNormalizer.NormalizePathForCurrentPlatform(
+ Path.Combine(builder.ApplicationBuilder.AppHostDirectory, projectPath));
+ }
+
+ var workingDirectory = Path.GetDirectoryName(projectPath)
+ ?? throw new InvalidOperationException($"Unable to determine directory from project path: {projectPath}");
+
+ // Check if the project has the Windows TFM and get the actual TFM value
+ var windowsTfm = ProjectFileReader.GetPlatformTargetFramework(projectPath, "windows");
+
+ var windowsResource = new MauiWindowsPlatformResource(name, builder.Resource);
+
+ var resourceBuilder = builder.ApplicationBuilder.AddResource(windowsResource)
+ .WithAnnotation(new MauiProjectMetadata(projectPath))
+ .WithAnnotation(new ExecutableAnnotation
+ {
+ Command = "dotnet",
+ WorkingDirectory = workingDirectory
+ })
+ .WithArgs(context =>
+ {
+ context.Args.Add("run");
+ if (!string.IsNullOrEmpty(windowsTfm))
+ {
+ context.Args.Add("-f");
+ context.Args.Add(windowsTfm);
+ }
+ })
+ .WithOtlpExporter()
+ .WithIconName("Desktop")
+ .WithExplicitStart();
+
+ // Validate the Windows TFM when the resource is about to start
+ resourceBuilder.OnBeforeResourceStarted((resource, eventing, ct) =>
+ {
+ // If we couldn't detect the TFM earlier, fail the resource start
+ if (string.IsNullOrEmpty(windowsTfm))
+ {
+ throw new DistributedApplicationException(
+ $"Unable to detect Windows target framework in project '{projectPath}'. " +
+ "Ensure the project file contains a TargetFramework or TargetFrameworks element with a Windows target framework (e.g., net10.0-windows10.0.19041.0) " +
+ "or remove the AddWindowsDevice() call from your AppHost.");
+ }
+
+ return Task.CompletedTask;
+ });
+
+ // Check if Windows platform is supported on the current host
+ if (!OperatingSystem.IsWindows())
+ {
+ var reason = "Windows platform not available on this host";
+
+ // Mark as unsupported
+ resourceBuilder.WithAnnotation(new UnsupportedPlatformAnnotation(reason), ResourceAnnotationMutationBehavior.Append);
+
+ // Add an event subscriber to set the "Unsupported" state after orchestrator initialization
+ builder.ApplicationBuilder.Services.TryAddEventingSubscriber();
+ }
+
+ return resourceBuilder;
+ }
+}
diff --git a/src/Aspire.Hosting.Maui/MauiWindowsPlatformResource.cs b/src/Aspire.Hosting.Maui/MauiWindowsPlatformResource.cs
new file mode 100644
index 00000000000..7d31af624db
--- /dev/null
+++ b/src/Aspire.Hosting.Maui/MauiWindowsPlatformResource.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;
+
+///
+/// Represents a Windows platform instance of a .NET MAUI project.
+///
+/// The name of the resource.
+/// The parent MAUI project resource.
+///
+/// This resource represents a MAUI application running on the Windows platform.
+/// The actual build and deployment happens when the resource is started, allowing for
+/// incremental builds during development without blocking AppHost startup.
+///
+/// Use
+/// to add this resource to a MAUI project.
+///
+///
+public class MauiWindowsPlatformResource(string name, MauiProjectResource parent)
+ : ProjectResource(name), IResourceWithParent
+{
+ ///
+ /// Gets the parent MAUI project resource.
+ ///
+ public MauiProjectResource Parent { get; } = parent ?? throw new ArgumentNullException(nameof(parent));
+}
diff --git a/src/Aspire.Hosting.Maui/Utilities/ProjectFileReader.cs b/src/Aspire.Hosting.Maui/Utilities/ProjectFileReader.cs
new file mode 100644
index 00000000000..b9b1ca3b2ba
--- /dev/null
+++ b/src/Aspire.Hosting.Maui/Utilities/ProjectFileReader.cs
@@ -0,0 +1,119 @@
+// 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 System.Diagnostics;
+using System.Text.Json;
+
+namespace Aspire.Hosting.Maui.Utilities;
+
+///
+/// Provides utilities for reading and parsing MAUI project files.
+///
+internal static class ProjectFileReader
+{
+ // Cache results per project path to avoid repeated MSBuild invocations
+ private static readonly ConcurrentDictionary> s_projectCache = new(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// Gets the target framework matching the specified platform from the project file.
+ ///
+ /// The path to the project file to parse.
+ /// The platform identifier to search for (e.g., "windows", "android", "ios", "maccatalyst").
+ /// The matching TFM if found, otherwise null.
+ ///
+ /// This method uses MSBuild to evaluate the project and retrieve TargetFramework and TargetFrameworks properties.
+ /// It searches for a target framework containing the specified platform identifier (case-insensitive) and returns the first match.
+ /// Results are cached per project path to avoid repeated MSBuild evaluations.
+ ///
+ public static string? GetPlatformTargetFramework(string projectPath, string platformIdentifier)
+ {
+ // Get or create cache entry for this project
+ var platformCache = s_projectCache.GetOrAdd(projectPath, _ => new Dictionary(StringComparer.OrdinalIgnoreCase));
+
+ // Check cache first
+ lock (platformCache)
+ {
+ if (platformCache.TryGetValue(platformIdentifier, out var cachedTfm))
+ {
+ return cachedTfm;
+ }
+ }
+
+ // Not cached, evaluate the project
+ var tfm = EvaluateProjectForPlatform(projectPath, platformIdentifier);
+
+ // Cache the result
+ lock (platformCache)
+ {
+ platformCache[platformIdentifier] = tfm;
+ }
+
+ return tfm;
+ }
+
+ private static string? EvaluateProjectForPlatform(string projectPath, string platformIdentifier)
+ {
+ try
+ {
+ // Use dotnet msbuild to get both TargetFramework and TargetFrameworks properties
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = "dotnet",
+ Arguments = $"msbuild \"{projectPath}\" -getProperty:TargetFramework,TargetFrameworks -nologo",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+
+ using var process = Process.Start(startInfo);
+ if (process is null)
+ {
+ return null;
+ }
+
+ var output = process.StandardOutput.ReadToEnd();
+ process.WaitForExit();
+
+ if (process.ExitCode != 0 || string.IsNullOrWhiteSpace(output))
+ {
+ return null;
+ }
+
+ // Parse the JSON output from msbuild -getProperty
+ var jsonDoc = JsonDocument.Parse(output);
+ var properties = jsonDoc.RootElement.GetProperty("Properties");
+
+ // Check both TargetFramework and TargetFrameworks properties
+ var targetFrameworksValue = string.Empty;
+
+ if (properties.TryGetProperty("TargetFrameworks", out var targetFrameworks))
+ {
+ targetFrameworksValue = targetFrameworks.GetString() ?? string.Empty;
+ }
+
+ if (string.IsNullOrWhiteSpace(targetFrameworksValue) &&
+ properties.TryGetProperty("TargetFramework", out var targetFramework))
+ {
+ targetFrameworksValue = targetFramework.GetString() ?? string.Empty;
+ }
+
+ if (string.IsNullOrWhiteSpace(targetFrameworksValue))
+ {
+ return null;
+ }
+
+ // Split by semicolon and find the first TFM containing the platform identifier
+ var platformTfm = targetFrameworksValue.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .FirstOrDefault(tfm => tfm.Contains($"-{platformIdentifier}", StringComparison.OrdinalIgnoreCase));
+
+ return platformTfm;
+ }
+ catch
+ {
+ // If we can't evaluate the project, return null to indicate unknown
+ return null;
+ }
+ }
+}
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..294ac15343e
--- /dev/null
+++ b/src/Aspire.Hosting.Maui/api/Aspire.Hosting.Maui.cs
@@ -0,0 +1,39 @@
+//------------------------------------------------------------------------------
+//
+// 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 ApplicationModel.IResourceBuilder AddMauiProject(this IDistributedApplicationBuilder builder, string name, string projectPath) { throw null; }
+ }
+
+ public static partial class MauiWindowsExtensions
+ {
+ public static ApplicationModel.IResourceBuilder AddWindowsDevice(this ApplicationModel.IResourceBuilder builder, string name) { throw null; }
+
+ public static ApplicationModel.IResourceBuilder AddWindowsDevice(this ApplicationModel.IResourceBuilder builder) { throw null; }
+ }
+}
+
+namespace Aspire.Hosting.Maui
+{
+ public partial class MauiProjectResource : ApplicationModel.Resource
+ {
+ public MauiProjectResource(string name, string projectPath) : base(default!) { }
+
+ public string ProjectPath { get { throw null; } }
+ }
+
+ public partial class MauiWindowsPlatformResource : ApplicationModel.ProjectResource, ApplicationModel.IResourceWithParent, ApplicationModel.IResourceWithParent, ApplicationModel.IResource
+ {
+ public MauiWindowsPlatformResource(string name, MauiProjectResource parent) : base(default!) { }
+
+ public MauiProjectResource Parent { 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/MauiWindowsExtensionsTests.cs b/tests/Aspire.Hosting.Maui.Tests/MauiWindowsExtensionsTests.cs
new file mode 100644
index 00000000000..ad57d63d771
--- /dev/null
+++ b/tests/Aspire.Hosting.Maui.Tests/MauiWindowsExtensionsTests.cs
@@ -0,0 +1,384 @@
+// 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.Maui;
+using Aspire.Hosting.Maui.Utilities;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Aspire.Hosting.Tests;
+
+public class MauiWindowsExtensionsTests
+{
+ [Fact]
+ public void AddWindowsDevice_CreatesResource()
+ {
+ // Arrange - Create a temporary project file with Windows TFM
+ var projectContent = """
+
+
+ net10.0-android;net10.0-ios;net10.0-windows10.0.19041.0
+
+
+ """;
+ var tempFile = CreateTempProjectFile(projectContent);
+
+ try
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var maui = appBuilder.AddMauiProject("mauiapp", tempFile);
+
+ // Act
+ var windows = maui.AddWindowsDevice();
+
+ // Assert
+ Assert.NotNull(windows);
+ Assert.Equal("mauiapp-windows", windows.Resource.Name);
+ Assert.Same(maui.Resource, windows.Resource.Parent);
+
+ // Verify the resource is in the application model
+ var windowsDeviceInModel = appBuilder.Resources
+ .OfType()
+ .FirstOrDefault(r => r.Name == "mauiapp-windows");
+ Assert.NotNull(windowsDeviceInModel);
+ Assert.Same(windows.Resource, windowsDeviceInModel);
+ }
+ finally
+ {
+ CleanupTempFile(tempFile);
+ }
+ }
+
+ [Fact]
+ public void AddWindowsDevice_WithCustomName_UsesProvidedName()
+ {
+ // Arrange - Create a temporary project file with Windows TFM
+ var projectContent = """
+
+
+ net10.0-android;net10.0-ios;net10.0-windows10.0.19041.0
+
+
+ """;
+ var tempFile = CreateTempProjectFile(projectContent);
+
+ try
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var maui = appBuilder.AddMauiProject("mauiapp", tempFile);
+
+ // Act
+ var windows = maui.AddWindowsDevice("custom-windows");
+
+ // Assert
+ Assert.Equal("custom-windows", windows.Resource.Name);
+ }
+ finally
+ {
+ CleanupTempFile(tempFile);
+ }
+ }
+
+ [Fact]
+ public void AddWindowsDevice_DuplicateName_ThrowsException()
+ {
+ // Arrange - Create a temporary project file with Windows TFM
+ var projectContent = """
+
+
+ net10.0-android;net10.0-ios;net10.0-windows10.0.19041.0
+
+
+ """;
+ var tempFile = CreateTempProjectFile(projectContent);
+
+ try
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var maui = appBuilder.AddMauiProject("mauiapp", tempFile);
+ maui.AddWindowsDevice("device1");
+
+ // Act & Assert
+ var exception = Assert.Throws(() => maui.AddWindowsDevice("device1"));
+ Assert.Contains("already exists", exception.Message);
+ }
+ finally
+ {
+ CleanupTempFile(tempFile);
+ }
+ }
+
+ [Fact]
+ public void AddWindowsDevice_MultipleDevices_AllCreated()
+ {
+ // Arrange - Create a temporary project file with Windows TFM
+ var projectContent = """
+
+
+ net10.0-android;net10.0-ios;net10.0-windows10.0.19041.0
+
+
+ """;
+ var tempFile = CreateTempProjectFile(projectContent);
+
+ try
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var maui = appBuilder.AddMauiProject("mauiapp", tempFile);
+
+ // Act
+ var windows1 = maui.AddWindowsDevice("device1");
+ var windows2 = maui.AddWindowsDevice("device2");
+
+ // Assert
+ var windowsDevices = appBuilder.Resources
+ .OfType()
+ .Where(r => r.Parent == maui.Resource)
+ .ToList();
+
+ Assert.Equal(2, windowsDevices.Count);
+ Assert.Contains(windows1.Resource, windowsDevices);
+ Assert.Contains(windows2.Resource, windowsDevices);
+ }
+ finally
+ {
+ CleanupTempFile(tempFile);
+ }
+ }
+
+ [Fact]
+ public void AddWindowsDevice_HasCorrectConfiguration()
+ {
+ // Arrange - Create a temporary project file with Windows TFM
+ var projectContent = """
+
+
+ net10.0-android;net10.0-ios;net10.0-windows10.0.19041.0
+
+
+ """;
+ var tempFile = CreateTempProjectFile(projectContent);
+
+ try
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var maui = appBuilder.AddMauiProject("mauiapp", tempFile);
+
+ // Act
+ var windows = maui.AddWindowsDevice();
+
+ // Assert
+ var resource = windows.Resource;
+
+ // Check ExecutableAnnotation
+ var execAnnotation = resource.Annotations.OfType().FirstOrDefault();
+ Assert.NotNull(execAnnotation);
+ Assert.Equal("dotnet", execAnnotation.Command);
+
+ // Check for MauiProjectMetadata annotation
+ var projectMetadata = resource.Annotations.OfType().FirstOrDefault();
+ Assert.NotNull(projectMetadata);
+ Assert.Equal(tempFile, projectMetadata.ProjectPath);
+
+ // Check for explicit start annotation
+ var hasExplicitStart = resource.TryGetAnnotationsOfType(out _);
+ Assert.True(hasExplicitStart);
+ }
+ finally
+ {
+ CleanupTempFile(tempFile);
+ }
+ }
+
+ [Fact]
+ public async Task AddWindowsDevice_WithoutWindowsTfm_ThrowsOnBeforeStartEvent()
+ {
+ // Arrange - Create a temporary project file without Windows TFM
+ var projectContent = """
+
+
+ net10.0-android;net10.0-ios;net10.0-maccatalyst
+
+
+ """;
+ var tempFile = CreateTempProjectFile(projectContent);
+
+ try
+ {
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var maui = appBuilder.AddMauiProject("mauiapp", tempFile);
+
+ // Act - Adding the device should succeed (validation deferred to start)
+ var windows = maui.AddWindowsDevice();
+
+ // Assert - Resource is created
+ Assert.NotNull(windows);
+ Assert.Equal("mauiapp-windows", windows.Resource.Name);
+
+ // Build the app to get access to eventing
+ await using var app = appBuilder.Build();
+
+ // Trigger the BeforeResourceStartedEvent which should throw
+ var exception = await Assert.ThrowsAsync(async () =>
+ {
+ await app.Services.GetRequiredService()
+ .PublishAsync(new BeforeResourceStartedEvent(windows.Resource, app.Services), CancellationToken.None);
+ });
+
+ Assert.Contains("Unable to detect Windows target framework", exception.Message);
+ Assert.Contains(tempFile, exception.Message);
+ }
+ finally
+ {
+ CleanupTempFile(tempFile);
+ }
+ }
+
+ private static string CreateTempProjectFile(string content)
+ {
+ var tempFile = Path.GetTempFileName();
+ var tempProjectFile = Path.ChangeExtension(tempFile, ".csproj");
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ File.WriteAllText(tempProjectFile, content);
+ return tempProjectFile;
+ }
+
+ private static void CleanupTempFile(string filePath)
+ {
+ if (File.Exists(filePath))
+ {
+ File.Delete(filePath);
+ }
+ }
+
+ [Fact]
+ public async Task GetWindowsTargetFramework_WithWindowsTfm_ReturnsCorrectTfm()
+ {
+ // Arrange
+ var projectContent = """
+
+
+ net10.0-android;net10.0-ios;net10.0-windows10.0.19041.0
+
+
+ """;
+ var tempFile = CreateTempProjectFile(projectContent);
+
+ try
+ {
+ // Act
+ var tfm = ProjectFileReader.GetPlatformTargetFramework(tempFile, "windows");
+
+ // Assert
+ Assert.Equal("net10.0-windows10.0.19041.0", tfm);
+ }
+ finally
+ {
+ CleanupTempFile(tempFile);
+ }
+ }
+
+ [Fact]
+ public async Task GetWindowsTargetFramework_WithConditionalWindowsTfm_ReturnsCorrectTfm()
+ {
+ if (!System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows))
+ {
+ Assert.Skip("This test requires Windows because MSBuild only evaluates the conditional Windows TFM on Windows platforms.");
+ }
+
+ // Arrange
+ var projectContent = """
+
+
+ net10.0-android;net10.0-ios
+ $(TargetFrameworks);net10.0-windows10.0.19041.0
+
+
+ """;
+ var tempFile = CreateTempProjectFile(projectContent);
+
+ try
+ {
+ // Act
+ var tfm = ProjectFileReader.GetPlatformTargetFramework(tempFile, "windows");
+
+ // Assert
+ Assert.Equal("net10.0-windows10.0.19041.0", tfm);
+ }
+ finally
+ {
+ CleanupTempFile(tempFile);
+ }
+ }
+
+ [Fact]
+ public async Task GetWindowsTargetFramework_WithoutWindowsTfm_ReturnsNull()
+ {
+ // Arrange
+ var projectContent = """
+
+
+ net10.0-android;net10.0-ios;net10.0-maccatalyst
+
+
+ """;
+ var tempFile = CreateTempProjectFile(projectContent);
+
+ try
+ {
+ // Act
+ var tfm = ProjectFileReader.GetPlatformTargetFramework(tempFile, "windows");
+
+ // Assert
+ Assert.Null(tfm);
+ }
+ finally
+ {
+ CleanupTempFile(tempFile);
+ }
+ }
+
+ [Fact]
+ public async Task GetWindowsTargetFramework_WithSingleWindowsTfm_ReturnsCorrectTfm()
+ {
+ // Arrange
+ var projectContent = """
+
+
+ net10.0-windows10.0.19041.0
+
+
+ """;
+ var tempFile = CreateTempProjectFile(projectContent);
+
+ try
+ {
+ // Act
+ var tfm = ProjectFileReader.GetPlatformTargetFramework(tempFile, "windows");
+
+ // Assert
+ Assert.Equal("net10.0-windows10.0.19041.0", tfm);
+ }
+ finally
+ {
+ CleanupTempFile(tempFile);
+ }
+ }
+
+ [Fact]
+ public async Task GetWindowsTargetFramework_InvalidFile_ReturnsNull()
+ {
+ // Arrange
+ var nonExistentFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".csproj");
+
+ // Act
+ var tfm = ProjectFileReader.GetPlatformTargetFramework(nonExistentFile, "windows");
+
+ // Assert - returns null when file can't be read
+ Assert.Null(tfm);
+ }
+}