Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions playground/AspireWithMaui/AspireWithMaui.AppHost/AppHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

var weatherApi = builder.AddProject("webapi", @"../AspireWithMaui.WeatherApi/AspireWithMaui.WeatherApi.csproj");

var publicDevTunnel = builder.AddDevTunnel("devtunnel-public")
.WithAnonymousAccess() // All ports on this tunnel default to allowing anonymous access
.WithReference(weatherApi.GetEndpoint("https"));

var mauiapp = builder.AddMauiProject("mauiapp", @"../AspireWithMaui.MauiClient/AspireWithMaui.MauiClient.csproj");

mauiapp.AddWindowsDevice()
Expand All @@ -10,4 +14,9 @@
mauiapp.AddMacCatalystDevice()
.WithReference(weatherApi);

// Add Android emulator with default emulator (uses running or default emulator)
mauiapp.AddAndroidEmulator()
.WithOtlpDevTunnel() // Needed to get the OpenTelemetry data to "localhost"
Copy link
Member

@JamesNK JamesNK Oct 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not have this on by default? OTEL that just works by default is one of Aspire's most popular features.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess I built it like this with the option in mind to provide a custom endpoint later or even use something entirely different like ngrok?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Experimented with this a little but didn't get it quite right yet. My idea would then be: automatic dev tunnel when nothing is configured and when we detect a custom OTLP endpoint env var, then no dev tunnel and we just use that value.

People can then set it through SetEnvironment() or just as an env var on the host machine directly or if they choose to build some ngrok integration, they need to make sure to set it from there. Does that make sense?

If we want to explore down that path I think it might be good to do that in a follow-up PR.

.WithReference(weatherApi, publicDevTunnel); // Needs a dev tunnel to reach "localhost"

builder.Build().Run();
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ private void LoadAspireEnvironmentVariables()
var variables = Environment.GetEnvironmentVariables()
.Cast<DictionaryEntry>()
.Select(entry => new KeyValuePair<string, string>(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)
Expand Down Expand Up @@ -62,18 +61,4 @@ private static string DecodeValue(string? 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);
}
83 changes: 78 additions & 5 deletions playground/AspireWithMaui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,19 +67,86 @@ After running the restore script with `-restore-maui`, you can build and run the
The playground demonstrates Aspire's ability to manage MAUI apps on multiple platforms:
- **Windows**: Configures the MAUI app with `.AddWindowsDevice()`
- **Mac Catalyst**: Configures the MAUI app with `.AddMacCatalystDevice()`
- **Android Device**: Configures the MAUI app with `.AddAndroidDevice()` to run on physical Android devices
- Use `.AddAndroidDevice()` to target the only attached device (default, requires exactly one device)
- Use `.AddAndroidDevice("device-name", "abc12345")` to target a specific device by serial number or IP
- Works with USB-connected devices and WiFi debugging (e.g., "192.168.1.100:5555")
- Get device IDs from `adb devices` command
- Use `.WithOtlpDevTunnel()` to send telemetry to the dashboard (Android cannot reach localhost)
- **Android Emulator**: Configures the MAUI app with `.AddAndroidEmulator()` to run on Android emulators
- Use `.AddAndroidEmulator()` to target the only running emulator (default)
- Use `.AddAndroidEmulator("emulator-name", "Pixel_5_API_33")` to target a specific emulator by AVD name
- Can also use emulator serial number like "emulator-5554"
- Get emulator names from `adb devices` or `emulator -list-avds` command
- Use `.WithOtlpDevTunnel()` to send telemetry to the dashboard (emulators cannot reach localhost)
- Automatically detects platform-specific target frameworks from the project file
- Shows "Unsupported" state in dashboard when running on incompatible host OS
- 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.
The MAUI client uses OpenTelemetry to send traces and metrics to the Aspire dashboard. For mobile platforms that cannot reach `localhost`, the playground demonstrates using dev tunnels to expose the dashboard's OTLP endpoint:

```csharp
// Android devices and emulators need dev tunnel for OTLP
mauiapp.AddAndroidEmulator()
.WithOtlpDevTunnel() // Automatically creates and configures a dev tunnel for telemetry
.WithReference(weatherApi, publicDevTunnel); // Dev tunnel for API communication
```

The `.WithOtlpDevTunnel()` method:
- Automatically resolves the dashboard's OTLP endpoint from configuration
- Creates a dev tunnel for the OTLP endpoint
- Configures the MAUI platform to send telemetry through the tunnel
- Handles all service discovery and environment variable setup

**Requirements for dev tunnels:**
- Dev tunnel CLI must be installed (automatic prompt if missing)
- User must be logged in to dev tunnel service (automatic prompt if needed)

### Service Discovery
The MAUI app discovers and connects to backend services (WeatherApi) using Aspire's service discovery.

### Environment Variables

All MAUI platform resources support environment variables using the standard `.WithEnvironment()` method. Environment variables are automatically forwarded to the MAUI application regardless of platform:

```csharp
// For Windows and Mac Catalyst, environment variables are passed directly:
mauiapp.AddWindowsDevice()
.WithEnvironment("DEBUG_MODE", "true")
.WithEnvironment("API_TIMEOUT", "30");

// For Android, environment variables are passed via an intermediate MSBuild targets file, but the syntax is identical:
mauiapp.AddAndroidDevice("my-device", "abc12345")
.WithEnvironment("DEBUG_MODE", "true")
.WithEnvironment("API_TIMEOUT", "30")
.WithEnvironment("LOG_LEVEL", "Debug");

mauiapp.AddAndroidEmulator("my-emulator", "Pixel_5_API_33")
.WithEnvironment("CUSTOM_VAR", "value")
.WithReference(weatherApi); // Service discovery environment variables also forwarded
```

#### What Gets Forwarded

**ALL Aspire-managed environment variables** are automatically forwarded to MAUI applications:
- **Custom variables**: Set via `.WithEnvironment(key, value)`
- **Service discovery**: Connection strings and endpoints from `.WithReference(service)`
- **OpenTelemetry**: OTEL configuration from `.WithOtlpExporter()`
- **Resource metadata**: Automatically added by Aspire

#### Platform-Specific Implementation

- **Windows & Mac Catalyst**: Environment variables are passed directly through the process environment when launching via `dotnet run`.
- **Android**: Due to Android platform limitations, environment variables are written to a temporary MSBuild targets file that gets imported during the build. The targets file is generated automatically before each build and cleaned up after 24 hours (when a next build happens). Environment variable names are normalized to UPPERCASE (Android requirement), and semicolons are encoded as `%3B`.
- **iOS**: (Coming soon) Will use a similar approach to Android with MSBuild targets file.

Environment variables are available in your MAUI app code regardless of platform through standard .NET environment APIs (`Environment.GetEnvironmentVariable()`).

### Future Platform Support
The architecture is designed to support additional platforms (Android, iOS) through:
- `.AddAndroidDevice()`, `.AddIosDevice()` extension methods (coming in future updates)
The architecture is designed to support additional platforms:
- Android support: `.AddAndroidDevice()` for physical devices, `.AddAndroidEmulator()` for emulators (implemented)
- iOS support: `.AddIosDevice()` extension method (coming in future updates)
- Parallel extension patterns for each platform

## Troubleshooting
Expand All @@ -99,22 +166,28 @@ If you encounter build errors:
### Platform-Specific Issues
- **Windows**: Requires Windows 10 build 19041 or higher for WinUI support. Mac Catalyst devices will show as "Unsupported" when running on Windows.
- **Mac Catalyst**: Requires macOS to run. Windows devices will show as "Unsupported" when running on macOS.
- **Android**: Not yet implemented in this playground (coming soon)
- **Android Device**: Requires a physical Android device connected via USB/WiFi debugging. Ensure the device is visible via `adb devices`. Works on Windows, macOS, and Linux.
- **Android Emulator**: Requires an Android emulator running and visible via `adb devices`. To target a specific emulator:
1. List available emulators: `adb devices` (shows emulator IDs like "emulator-5554")
2. Or list AVDs: `emulator -list-avds` (shows AVD names like "Pixel_5_API_33")
3. Use either ID format in code: `.AddAndroidEmulator(emulatorId: "Pixel_5_API_33")` or `.AddAndroidEmulator(emulatorId: "emulator-5554")`
4. Works on Windows, macOS, and Linux.
- **iOS**: Not yet implemented in this playground (coming soon)

## Current Status

✅ **Implemented:**
- Windows platform support via `AddWindowsDevice()`
- Mac Catalyst platform support via `AddMacCatalystDevice()`
- Android device support via `AddAndroidDevice()`
- Android emulator support via `AddAndroidEmulator()`
- Automatic platform-specific TFM detection from project file
- Platform validation with "Unsupported" state for incompatible hosts
- Dev tunnel configuration for MAUI-to-backend communication
- Service discovery integration
- OpenTelemetry integration

🚧 **Coming Soon:**
- Android platform support via `AddAndroidDevice()`
- iOS platform support via `AddIosDevice()`
- Multi-platform simultaneous debugging

Expand Down
Original file line number Diff line number Diff line change
@@ -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.DevTunnels;
using Aspire.Hosting.Maui.Otlp;

namespace Aspire.Hosting.Maui.Annotations;

/// <summary>
/// Annotation that stores the OTLP dev tunnel configuration for a MAUI project.
/// This allows sharing a single dev tunnel infrastructure across multiple platform resources.
/// </summary>
internal sealed class OtlpDevTunnelConfigurationAnnotation : IResourceAnnotation
{
/// <summary>
/// The OTLP loopback stub resource that acts as the service discovery target.
/// </summary>
public OtlpLoopbackResource OtlpStub { get; }

/// <summary>
/// The resource builder for the OTLP stub (used for WithReference calls).
/// </summary>
public IResourceBuilder<OtlpLoopbackResource> OtlpStubBuilder { get; }

/// <summary>
/// The dev tunnel resource that tunnels the OTLP endpoint.
/// </summary>
public IResourceBuilder<DevTunnelResource> DevTunnel { get; }

public OtlpDevTunnelConfigurationAnnotation(
OtlpLoopbackResource otlpStub,
IResourceBuilder<OtlpLoopbackResource> otlpStubBuilder,
IResourceBuilder<DevTunnelResource> devTunnel)
{
OtlpStub = otlpStub;
OtlpStubBuilder = otlpStubBuilder;
DevTunnel = devTunnel;
}
}
4 changes: 4 additions & 0 deletions src/Aspire.Hosting.Maui/Aspire.Hosting.Maui.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,15 @@
</ItemGroup>

<ItemGroup>
<Compile Include="..\Shared\IConfigurationExtensions.cs" Link="Shared\IConfigurationExtensions.cs" />
<Compile Include="..\Shared\KnownConfigNames.cs" Link="Shared\KnownConfigNames.cs" />
<Compile Include="..\Shared\OtlpEndpointResolver.cs" Link="Shared\OtlpEndpointResolver.cs" />
<Compile Include="..\Shared\PathNormalizer.cs" Link="Shared\PathNormalizer.cs" />
<Compile Include="..\Shared\StringComparers.cs" Link="Shared\StringComparers.cs" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Aspire.Hosting\Aspire.Hosting.csproj" />
<ProjectReference Include="..\Aspire.Hosting.DevTunnels\Aspire.Hosting.DevTunnels.csproj" />
</ItemGroup>
</Project>
5 changes: 4 additions & 1 deletion src/Aspire.Hosting.Maui/IMauiPlatformResource.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// 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;

/// <summary>
Expand All @@ -9,7 +11,8 @@ namespace Aspire.Hosting.Maui;
/// <remarks>
/// This interface is used to identify resources that represent a specific platform instance
/// of a MAUI application, allowing for common handling across all MAUI platforms.
/// All MAUI platform resources have a parent <see cref="MauiProjectResource"/>.
/// </remarks>
internal interface IMauiPlatformResource
public interface IMauiPlatformResource : IResourceWithParent<MauiProjectResource>
{
}
20 changes: 20 additions & 0 deletions src/Aspire.Hosting.Maui/MauiAndroidDeviceResource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// 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;

/// <summary>
/// A resource that represents an Android physical device for running a .NET MAUI application.
/// </summary>
/// <param name="name">The name of the Android device resource.</param>
/// <param name="parent">The parent MAUI project resource.</param>
public sealed class MauiAndroidDeviceResource(string name, MauiProjectResource parent)
: ProjectResource(name), IMauiPlatformResource
{
/// <summary>
/// Gets the parent MAUI project resource.
/// </summary>
public MauiProjectResource Parent { get; } = parent;
}
20 changes: 20 additions & 0 deletions src/Aspire.Hosting.Maui/MauiAndroidEmulatorResource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// 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;

/// <summary>
/// A resource that represents an Android emulator for running a .NET MAUI application.
/// </summary>
/// <param name="name">The name of the Android emulator resource.</param>
/// <param name="parent">The parent MAUI project resource.</param>
public sealed class MauiAndroidEmulatorResource(string name, MauiProjectResource parent)
: ProjectResource(name), IMauiPlatformResource
{
/// <summary>
/// Gets the parent MAUI project resource.
/// </summary>
public MauiProjectResource Parent { get; } = parent;
}
Loading