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
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
@@ -0,0 +1,18 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Hosting.ApplicationModel;

namespace Aspire.Hosting.Maui.Annotations;

/// <summary>
/// Captures the pristine <see cref="ProjectLaunchArgsOverrideAnnotation"/> arguments before any
/// launch-argument callbacks run, so re-applying the callbacks stays idempotent across restarts.
/// </summary>
internal sealed class MauiLaunchArgsBaselineAnnotation(IReadOnlyList<string> arguments) : IResourceAnnotation
{
/// <summary>
/// Gets the untouched launch arguments captured before the first callback pass.
/// </summary>
public IReadOnlyList<string> Arguments { get; } = arguments.ToArray();
}
106 changes: 104 additions & 2 deletions src/Aspire.Hosting.Maui/Lifecycle/MauiBuildQueueEventSubscriber.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,24 @@ internal class MauiBuildQueueEventSubscriber(
/// <inheritdoc/>
public Task SubscribeAsync(IDistributedApplicationEventing eventing, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken)
{
// Launch callbacks must run before DCP renders the executables. DCP reads
// ProjectLaunchArgsOverrideAnnotation during PrepareProjectExecutables and bakes the
// resulting launch command into the DCP Executable, which happens after BeforeStartEvent
// but before BeforeResourceStartedEvent. Applying launch edits at BeforeResourceStartedEvent
// would therefore be ignored, so they are applied here instead.
eventing.Subscribe<BeforeStartEvent>(OnBeforeStartAsync);
eventing.Subscribe<BeforeResourceStartedEvent>(OnBeforeResourceStartedAsync);
return Task.CompletedTask;
}

private async Task OnBeforeStartAsync(BeforeStartEvent @event, CancellationToken cancellationToken)
{
foreach (var resource in @event.Model.Resources.OfType<IMauiPlatformResource>())
{
await ApplyLaunchArgumentCallbacksAsync(resource, cancellationToken).ConfigureAwait(false);
}
}

private async Task OnBeforeResourceStartedAsync(BeforeResourceStartedEvent @event, CancellationToken cancellationToken)
{
if (@event.Resource is not IMauiPlatformResource mauiResource)
Expand Down Expand Up @@ -160,6 +174,9 @@ internal virtual async Task RunBuildAsync(IResource resource, ILogger logger, Ca

args.AddRange(buildInfo.AdditionalBuildArguments);

// Allow consumers to inspect/modify the compile arguments.
var callbackContext = await ApplyBuildArgumentCallbacksAsync(resource, args, cancellationToken).ConfigureAwait(false);

var psi = new ProcessStartInfo("dotnet")
{
WorkingDirectory = buildInfo.WorkingDirectory,
Expand All @@ -174,7 +191,10 @@ internal virtual async Task RunBuildAsync(IResource resource, ILogger logger, Ca
psi.ArgumentList.Add(arg);
}

logger.LogInformation("Running: dotnet {Arguments}", string.Join(" ", args));
// Redact any arguments the callback marked sensitive so signing passwords and similar secrets
// do not leak into the resource logs. The process itself still receives the real values.
var displayArgs = callbackContext?.GetRedactedArguments() ?? args;
logger.LogInformation("Running: dotnet {Arguments}", string.Join(" ", displayArgs));

// Apply a timeout so that a hung build does not block the queue forever.
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Expand Down Expand Up @@ -221,6 +241,88 @@ 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>
/// <returns>
/// The context the callbacks ran against (used to redact sensitive arguments from logs), or
/// <see langword="null"/> when no build callbacks are registered.
/// </returns>
private static async Task<MauiBuildArgumentsCallbackContext?> 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 null;
}

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

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

return context;
}

/// <summary>
/// Applies any registered <see cref="MauiBuildStep.Launch"/> callbacks to the resource's
/// <see cref="ProjectLaunchArgsOverrideAnnotation"/> before DCP renders it into the launch command.
/// </summary>
/// <remarks>
/// This runs during <see cref="BeforeStartEvent"/> — before DCP's <c>PrepareProjectExecutables</c>
/// reads the override annotation and bakes the launch command into the DCP executable. The override
/// annotation's arguments are immutable, so callbacks operate on a copy and the annotation is swapped
/// for one carrying the result.
/// </remarks>
private static 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;
}

// BeforeStartEvent can run more than once (for example across restarts). Capture the pristine
// override arguments on the first pass and always start from that baseline, otherwise each pass
// would re-append the callback edits to the already-edited override and accumulate them.
if (!resource.TryGetLastAnnotation<MauiLaunchArgsBaselineAnnotation>(out var baseline))
{
baseline = new MauiLaunchArgsBaselineAnnotation(launchOverride.Arguments);
resource.Annotations.Add(baseline);
}

var arguments = new List<string>(baseline.Arguments);

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 renders the launch command during PrepareProjectExecutables.
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 +374,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
86 changes: 86 additions & 0 deletions src/Aspire.Hosting.Maui/MauiBuildArgumentsCallbackContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// 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 the 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. For arguments
/// that carry secrets (for example an MSBuild property holding a signing password), add them with
/// <see cref="AddArgument(string, bool)"/> so their values are redacted from the arguments the
/// build pipeline writes to the resource logs.
/// </remarks>
[AspireExport(ExposeProperties = true)]
public sealed class MauiBuildArgumentsCallbackContext
{
// Tracks the exact argument strings added via AddSensitiveArgument so the build pipeline can
// redact them before logging. Ordinal comparison because these are literal command-line tokens.
private readonly HashSet<string> _sensitiveArguments = new(StringComparer.Ordinal);

private readonly IList<string> _arguments;

internal MauiBuildArgumentsCallbackContext(
MauiBuildStep step,
IList<string> arguments,
IResource resource,
CancellationToken cancellationToken)
{
_arguments = arguments;
Step = step;
Resource = resource;
CancellationToken = cancellationToken;
}

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

/// <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; }

/// <summary>
/// Appends an argument whose value is sensitive (for example <c>-p:AndroidSigningKeyPass=…</c>).
/// </summary>
/// <param name="argument">The full argument to add.</param>
/// <param name="isSensitive"></param>
Comment on lines +57 to +61
/// <remarks>
/// The argument is passed to <c>dotnet</c> verbatim so the build and launch still work, but the MAUI
/// build pipeline replaces its value with a placeholder in the arguments it logs to the resource
/// output. Launch-step arguments are additionally masked by the dashboard's command-line display.
/// </remarks>
public void AddArgument(string argument, bool isSensitive = false)
{
ArgumentNullException.ThrowIfNull(argument);

_arguments.Add(argument);
if (isSensitive)
{
_sensitiveArguments.Add(argument);
}
Comment on lines +72 to +75
}

/// <summary>
/// Produces a display-safe rendering of the arguments with values added through
/// <see cref="AddArgument(string, bool)"/> replaced by a redaction placeholder.
/// </summary>
internal IEnumerable<string> GetRedactedArguments()
=> _sensitiveArguments.Count == 0
? _arguments
: _arguments.Select(argument => _sensitiveArguments.Contains(argument) ? "[REDACTED]" : argument);
}
Loading