Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// 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;

/// <summary>
/// Annotation that registers a callback used to inspect or modify the arguments passed to
/// <c>dotnet</c> for a particular <see cref="MauiBuildStep"/> of a MAUI platform resource.
/// </summary>
internal sealed class MauiBuildArgumentsCallbackAnnotation(
MauiBuildStep step,
Func<MauiBuildArgumentsCallbackContext, Task> callback) : IResourceAnnotation
{
/// <summary>
/// Gets the build step this callback participates in.
/// </summary>
public MauiBuildStep Step { get; } = step;

/// <summary>
/// Gets the callback invoked with the mutable argument list for <see cref="Step"/>.
/// </summary>
public Func<MauiBuildArgumentsCallbackContext, Task> Callback { get; } = callback;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// 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 Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Eventing;
Expand Down Expand Up @@ -31,6 +32,12 @@ internal class MauiBuildQueueEventSubscriber(
private static readonly ResourceStateSnapshot s_buildingState = new("Building", KnownResourceStateStyles.Info);
private static readonly ResourceStateSnapshot s_cancelledState = new(KnownResourceStates.Exited, KnownResourceStateStyles.Warn);

/// <summary>
/// Caches the pristine launch-argument-override arguments per resource so that launch callbacks
/// are always applied against the original base, preventing edits from accumulating across restarts.
/// </summary>
private readonly ConcurrentDictionary<string, IReadOnlyList<string>> _originalLaunchArgs = new();

/// <summary>
/// Maximum time to wait for a <c>dotnet build</c> process before cancelling.
/// Prevents a hung build from blocking the queue indefinitely.
Expand Down Expand Up @@ -101,6 +108,10 @@ await notificationService.PublishUpdateAsync(resource, s => s with

await RunBuildAsync(resource, logger, resourceCts.Token).ConfigureAwait(false);

// Allow consumers to inspect/modify the launch arguments (dotnet build --no-restore
// /t:Run -p:NoBuild=true) before DCP reads the override annotation to start the process.
await ApplyLaunchArgumentCallbacksAsync(resource, resourceCts.Token).ConfigureAwait(false);

// Build succeeded. Keep the semaphore held until DCP starts the launch process.
// After this handler returns, DCP invokes `dotnet build --no-restore /t:Run -p:NoBuild=true`
// with the same configuration used here. The no-build/no-restore flags are important:
Expand Down Expand Up @@ -160,6 +171,9 @@ internal virtual async Task RunBuildAsync(IResource resource, ILogger logger, Ca

args.AddRange(buildInfo.AdditionalBuildArguments);

// Allow consumers to inspect/modify the compile arguments.
await ApplyBuildArgumentCallbacksAsync(resource, args, cancellationToken).ConfigureAwait(false);
Comment thread
frederikstonge marked this conversation as resolved.
Outdated
Comment thread
frederikstonge marked this conversation as resolved.
Outdated

var psi = new ProcessStartInfo("dotnet")
{
WorkingDirectory = buildInfo.WorkingDirectory,
Expand Down Expand Up @@ -221,6 +235,74 @@ internal virtual async Task RunBuildAsync(IResource resource, ILogger logger, Ca
logger.LogInformation("Build succeeded for resource '{ResourceName}'.", resource.Name);
}

/// <summary>
/// Invokes any registered <see cref="MauiBuildStep.Build"/> callbacks, letting consumers
/// mutate the compile <paramref name="arguments"/> in place.
/// </summary>
private static async Task ApplyBuildArgumentCallbacksAsync(
IResource resource, IList<string> arguments, CancellationToken cancellationToken)
{
var callbacks = resource.Annotations
.OfType<MauiBuildArgumentsCallbackAnnotation>()
.Where(a => a.Step == MauiBuildStep.Build)
.ToArray();

if (callbacks.Length == 0)
{
return;
}

var context = new MauiBuildArgumentsCallbackContext(MauiBuildStep.Build, arguments, resource, cancellationToken);

foreach (var callback in callbacks)
{
await callback.Callback(context).ConfigureAwait(false);
}
}

/// <summary>
/// Applies any registered <see cref="MauiBuildStep.Launch"/> callbacks to the resource's
/// <see cref="ProjectLaunchArgsOverrideAnnotation"/> before DCP reads it to build the launch command.
/// </summary>
/// <remarks>
/// The override annotation's arguments are immutable, so callbacks operate on a copy of the pristine
/// base arguments (cached per resource) and the annotation is swapped for one carrying the result.
/// Caching the base makes repeated starts idempotent — edits never accumulate across restarts.
/// </remarks>
private async Task ApplyLaunchArgumentCallbacksAsync(IResource resource, CancellationToken cancellationToken)
{
var callbacks = resource.Annotations
.OfType<MauiBuildArgumentsCallbackAnnotation>()
.Where(a => a.Step == MauiBuildStep.Launch)
.ToArray();

if (callbacks.Length == 0)
{
return;
}

if (!resource.TryGetLastAnnotation<ProjectLaunchArgsOverrideAnnotation>(out var launchOverride))
{
return;
}

// Apply callbacks against the pristine base arguments so restarts do not accumulate edits.
var baseArgs = _originalLaunchArgs.GetOrAdd(resource.Name, _ => launchOverride.Arguments);
var arguments = new List<string>(baseArgs);

var context = new MauiBuildArgumentsCallbackContext(MauiBuildStep.Launch, arguments, resource, cancellationToken);

foreach (var callback in callbacks)
{
await callback.Callback(context).ConfigureAwait(false);
}

// Swap the immutable annotation for one carrying the updated arguments; DCP reads the last
// override annotation when it assembles the launch command after this handler returns.
resource.Annotations.Remove(launchOverride);
resource.Annotations.Add(new ProjectLaunchArgsOverrideAnnotation(arguments, launchOverride.LeadingResourceArgumentToRemove));
Comment thread
frederikstonge marked this conversation as resolved.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This callback edits only ProjectLaunchArgsOverrideAnnotation, not the complete launch argument list. DCP emits that override before resource arguments, while ConfigurePlatformResource adds the MAUI TFM/device properties through WithArgs. A callback that adds -p:AdbTarget=..., -p:_DeviceName=..., or -p:RuntimeIdentifier=... is therefore followed by MAUI's original value and loses under MSBuild's last-value-wins behavior. Please apply the callback to the complete evaluated launch arguments, or otherwise ensure callback edits are emitted last; as written, callers cannot reliably override existing MAUI launch properties.

}

private static async Task PipeOutputAsync(System.IO.StreamReader reader, ILogger logger, LogLevel level, CancellationToken cancellationToken)
{
try
Expand Down Expand Up @@ -272,7 +354,7 @@ private static void TryKillProcess(Process process, ILogger logger)
/// Without this guard a restart could match on a stale snapshot.
/// </para>
/// <para>
/// Including "Running" in the predicate is intentional: the pre-build step already compiled
/// Including "Running" in the predicate is intentional: the build step already compiled
/// the project for the same configuration that DCP will pass to the launch command, and DCP's
/// <c>dotnet build --no-restore /t:Run -p:NoBuild=true</c> launch command is configured not to
/// restore or build. Waiting for a terminal state would hold the semaphore for the entire app
Expand Down
53 changes: 53 additions & 0 deletions src/Aspire.Hosting.Maui/MauiBuildArgumentsCallbackContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// 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>
/// Context passed to build-argument callbacks registered with
/// <see cref="MauiBuildArgumentsExtensions.WithMauiBuildArguments{T}(IResourceBuilder{T}, Func{MauiBuildArgumentsCallbackContext, System.Threading.Tasks.Task})"/>
/// and
/// <see cref="MauiBuildArgumentsExtensions.WithMauiLaunchArguments{T}(IResourceBuilder{T}, Func{MauiBuildArgumentsCallbackContext, System.Threading.Tasks.Task})"/>.
/// </summary>
/// <remarks>
/// Mutate <see cref="Arguments"/> in place to add, remove, or replace the arguments that will be
/// passed to <c>dotnet</c> for the <see cref="Step"/> this callback is registered for.
/// </remarks>
[AspireExport(ExposeProperties = true)]
public sealed class MauiBuildArgumentsCallbackContext
{
internal MauiBuildArgumentsCallbackContext(
MauiBuildStep step,
IList<string> arguments,
IResource resource,
CancellationToken cancellationToken)
{
Step = step;
Arguments = arguments;
Resource = resource;
CancellationToken = cancellationToken;
}

/// <summary>
/// Gets the build step this callback is participating in.
/// </summary>
public MauiBuildStep Step { get; }

/// <summary>
/// Gets the mutable list of arguments passed to <c>dotnet</c> for the current <see cref="Step"/>.
/// Add, remove, or replace entries to influence the command that is executed.
/// </summary>
public IList<string> Arguments { get; }
Comment thread
frederikstonge marked this conversation as resolved.
Outdated

/// <summary>
/// Gets the MAUI platform resource the arguments apply to.
/// </summary>
public IResource Resource { get; }

/// <summary>
/// Gets a token that is cancelled if the resource start is cancelled.
/// </summary>
public CancellationToken CancellationToken { get; }
}
164 changes: 164 additions & 0 deletions src/Aspire.Hosting.Maui/MauiBuildArgumentsExtensions.cs
Original file line number Diff line number Diff line change
@@ -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.Maui;
using Aspire.Hosting.Maui.Annotations;

namespace Aspire.Hosting;

/// <summary>
/// Provides extension methods for customizing the build and launch arguments of MAUI platform resources.
/// </summary>
public static class MauiBuildArgumentsExtensions
{
/// <summary>
/// Registers a callback that can inspect or modify the arguments used for the serialized
/// compile (<c>dotnet build</c>) that runs before the app is launched.
/// </summary>
/// <typeparam name="T">The type of the MAUI platform resource.</typeparam>
/// <param name="builder">The MAUI platform resource builder.</param>
/// <param name="callback">
/// A callback invoked with a <see cref="MauiBuildArgumentsCallbackContext"/> whose
/// <see cref="MauiBuildArgumentsCallbackContext.Arguments"/> can be mutated to influence the
/// <c>dotnet build</c> command. The arguments contain the full command (verb, project path,
/// target framework, configuration, and any additional MSBuild properties).
/// </param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// Multiple callbacks can be registered; they are invoked in registration order and share the
/// same mutable argument list.
/// </remarks>
/// <example>
/// Add an MSBuild property to the compile:
/// <code lang="csharp">
/// maui.AddAndroidEmulator("emulator")
/// .WithMauiBuildArguments(context => context.Arguments.Add("-p:MyProperty=Value"));
/// </code>
/// </example>
[AspireExport]
public static IResourceBuilder<T> WithMauiBuildArguments<T>(
this IResourceBuilder<T> builder,
Func<MauiBuildArgumentsCallbackContext, Task> callback)
Comment thread
frederikstonge marked this conversation as resolved.
where T : IMauiPlatformResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(callback);

return builder.WithAnnotation(new MauiBuildArgumentsCallbackAnnotation(MauiBuildStep.Build, callback));
}

/// <summary>
/// Registers a synchronous callback that can inspect or modify the arguments used for the
/// serialized compile (<c>dotnet build</c>) that runs before the app is launched.
/// </summary>
/// <typeparam name="T">The type of the MAUI platform resource.</typeparam>
/// <param name="builder">The MAUI platform resource builder.</param>
/// <param name="callback">
/// A callback invoked with a <see cref="MauiBuildArgumentsCallbackContext"/> whose
/// <see cref="MauiBuildArgumentsCallbackContext.Arguments"/> can be mutated to influence the
/// <c>dotnet build</c> command.
/// </param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// Multiple callbacks can be registered; they are invoked in registration order and share the
/// same mutable argument list.
/// </remarks>
/// <example>
/// Add an MSBuild property to the compile:
/// <code lang="csharp">
/// maui.AddAndroidEmulator("emulator")
/// .WithMauiBuildArguments(context => context.Arguments.Add("-p:MyProperty=Value"));
/// </code>
/// </example>
[AspireExportIgnore(Reason = "Convenience overload. Use the asynchronous overload instead.")]
public static IResourceBuilder<T> WithMauiBuildArguments<T>(
this IResourceBuilder<T> builder,
Action<MauiBuildArgumentsCallbackContext> callback)
where T : IMauiPlatformResource
{
ArgumentNullException.ThrowIfNull(callback);

return builder.WithMauiBuildArguments(context =>
{
callback(context);
return Task.CompletedTask;
});
}

/// <summary>
/// Registers a callback that can inspect or modify the arguments used for the launch command
/// that starts the already-built app (<c>dotnet build --no-restore /t:Run -p:NoBuild=true</c>).
/// </summary>
/// <typeparam name="T">The type of the MAUI platform resource.</typeparam>
/// <param name="builder">The MAUI platform resource builder.</param>
/// <param name="callback">
/// A callback invoked with a <see cref="MauiBuildArgumentsCallbackContext"/> whose
/// <see cref="MauiBuildArgumentsCallbackContext.Arguments"/> can be mutated to influence the
/// launch command. The arguments contain the verb and options that replace DCP's default
/// <c>run</c> verb; the project path and <c>--configuration</c> are appended by the host.
/// </param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// Multiple callbacks can be registered; they are invoked in registration order and share the
/// same mutable argument list. Callbacks are applied against the pristine launch arguments on
/// every start, so edits do not accumulate across restarts.
/// </remarks>
/// <example>
/// Force a build during launch by overriding the default <c>-p:NoBuild=true</c>:
/// <code lang="csharp">
/// maui.AddAndroidEmulator("emulator")
/// .WithMauiLaunchArguments(context => context.Arguments.Add("-p:NoBuild=false"));
/// </code>
/// </example>
[AspireExport]
public static IResourceBuilder<T> WithMauiLaunchArguments<T>(
this IResourceBuilder<T> builder,
Func<MauiBuildArgumentsCallbackContext, Task> callback)
where T : IMauiPlatformResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(callback);

return builder.WithAnnotation(new MauiBuildArgumentsCallbackAnnotation(MauiBuildStep.Launch, callback));
}

/// <summary>
/// Registers a synchronous callback that can inspect or modify the arguments used for the launch
/// command that starts the already-built app (<c>dotnet build --no-restore /t:Run -p:NoBuild=true</c>).
/// </summary>
/// <typeparam name="T">The type of the MAUI platform resource.</typeparam>
/// <param name="builder">The MAUI platform resource builder.</param>
/// <param name="callback">
/// A callback invoked with a <see cref="MauiBuildArgumentsCallbackContext"/> whose
/// <see cref="MauiBuildArgumentsCallbackContext.Arguments"/> can be mutated to influence the
/// launch command.
/// </param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// Multiple callbacks can be registered; they are invoked in registration order and share the
/// same mutable argument list. Callbacks are applied against the pristine launch arguments on
/// every start, so edits do not accumulate across restarts.
/// </remarks>
/// <example>
/// Force a build during launch by overriding the default <c>-p:NoBuild=true</c>:
/// <code lang="csharp">
/// maui.AddAndroidEmulator("emulator")
/// .WithMauiLaunchArguments(context => context.Arguments.Add("-p:NoBuild=false"));
/// </code>
/// </example>
[AspireExportIgnore(Reason = "Convenience overload. Use the asynchronous overload instead.")]
public static IResourceBuilder<T> WithMauiLaunchArguments<T>(
this IResourceBuilder<T> builder,
Action<MauiBuildArgumentsCallbackContext> callback)
where T : IMauiPlatformResource
{
ArgumentNullException.ThrowIfNull(callback);

return builder.WithMauiLaunchArguments(context =>
{
callback(context);
return Task.CompletedTask;
});
}
}
27 changes: 27 additions & 0 deletions src/Aspire.Hosting.Maui/MauiBuildStep.cs
Original file line number Diff line number Diff line change
@@ -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.

namespace Aspire.Hosting.Maui;

/// <summary>
/// Identifies the stage of the MAUI resource startup pipeline that a build-argument
/// callback participates in.
/// </summary>
public enum MauiBuildStep
{
/// <summary>
/// The serialized compile (<c>dotnet build</c>) that runs before the app is launched.
/// Arguments passed at this stage form the complete <c>dotnet build</c> command used to compile
/// the project for the target platform (verb, project path, target framework, configuration, and
/// any additional MSBuild properties).
/// </summary>
Build,

/// <summary>
/// The launch command that starts the already-built app
/// (<c>dotnet build --no-restore /t:Run -p:NoBuild=true</c>). Arguments passed at this stage are the
/// verb and options that replace DCP's default <c>run</c> verb; the project path and
/// <c>--configuration</c> are appended by the host and are not part of the editable arguments.
/// </summary>
Launch
}
Loading