Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
93 changes: 90 additions & 3 deletions documentation/specs/dotnet-run-for-maui.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ to subsequent build, deploy, and run steps._

* `build`: unchanged, but is passed `-p:Device` and optionally `-p:RuntimeIdentifier`
if the selected device provided a `%(RuntimeIdentifier)` metadata value.
Environment variables from `-e` are passed as `@(RuntimeEnvironmentVariable)` items.

* `deploy`

Expand All @@ -96,12 +97,15 @@ to subsequent build, deploy, and run steps._
`-p:Device` global MSBuild property, and optionally `-p:RuntimeIdentifier`
if the selected device provided a `%(RuntimeIdentifier)` metadata value.

* Environment variables from `-e` are passed as `@(RuntimeEnvironmentVariable)` items.

* This step needs to run, even with `--no-build`, as you may have
selected a different device.

* `ComputeRunArguments`: unchanged, but is passed `-p:Device` and optionally
`-p:RuntimeIdentifier` if the selected device provided a `%(RuntimeIdentifier)`
metadata value.
* `ComputeRunArguments`: unchanged, but is passed `-p:Device` and
optionally `-p:RuntimeIdentifier` if the selected device provided a
`%(RuntimeIdentifier)` metadata value. Environment variables from
`-e` are passed as `@(RuntimeEnvironmentVariable)` items.

* `run`: unchanged. `ComputeRunArguments` should have set a valid
`$(RunCommand)` and `$(RunArguments)` using the value supplied by
Expand Down Expand Up @@ -146,6 +150,89 @@ A new `--device` switch will:
* The iOS and Android workloads will know how to interpret `$(Device)`
to select an appropriate device, emulator, or simulator.

## Environment Variables

The `dotnet run` command supports passing environment variables via the
`-e` or `--environment` option:

```dotnetcli
dotnet run -e FOO=BAR -e ANOTHER=VALUE
```

These environment variables are:

1. **Passed to the running application** - as process environment
variables when the app is launched.

2. **Passed to MSBuild during build, deploy, and ComputeRunArguments** -
as `@(RuntimeEnvironmentVariable)` items that workloads can consume.
**This behavior is opt-in**: projects must set `$(UseRuntimeEnvironmentVariableItems)=true`
to receive these items.

```xml
<ItemGroup>
<RuntimeEnvironmentVariable Include="FOO" Value="BAR" />
<RuntimeEnvironmentVariable Include="ANOTHER" Value="VALUE" />
</ItemGroup>
```

This allows workloads (iOS, Android, etc.) to access environment
variables during the `build`, `DeployToDevice`, and `ComputeRunArguments` target execution.

### Opting In

To receive environment variables as MSBuild items, projects must opt in by setting
the `UseRuntimeEnvironmentVariableItems` property:

```xml
<PropertyGroup>
<UseRuntimeEnvironmentVariableItems>true</UseRuntimeEnvironmentVariableItems>
</PropertyGroup>
```

Mobile workloads (iOS, Android, etc.) should set this property in their SDK targets
so that all projects using those workloads automatically opt in.

Workloads can consume these items in their MSBuild targets:

```xml
<Target Name="DeployToDevice">
<!-- Access environment variables from dotnet run -e -->
<Message Text="Environment: @(RuntimeEnvironmentVariable->'%(Identity)=%(Value)')" />
</Target>
```

### Implementation Details

For the **build step**, which uses out-of-process MSBuild via `dotnet build`,
environment variables are injected by creating a temporary `.props` file.
The file is created in the project's `$(IntermediateOutputPath)` directory
(e.g., `obj/Debug/net11.0-android/dotnet-run-env.props`). The path is
obtained from the project evaluation performed during target framework and
device selection. If `IntermediateOutputPath` is not available, the file
falls back to the `obj/` directory.

The file is passed to MSBuild via the `CustomBeforeMicrosoftCommonProps` property,
ensuring the items are available early in evaluation.
The temporary file is automatically deleted after the build completes.

The generated props file looks like:

```xml
<Project>
<ItemGroup>
<RuntimeEnvironmentVariable Include="FOO" Value="BAR" />
<RuntimeEnvironmentVariable Include="ANOTHER" Value="VALUE" />
</ItemGroup>
</Project>
```

For the **deploy step** (`DeployToDevice` target) and
**ComputeRunArguments target**, which use in-process MSBuild,
environment variables are added directly as
`@(RuntimeEnvironmentVariable)` items to the `ProjectInstance` before
invoking the target.

## Binary Logs for Device Selection

When using `-bl` with `dotnet run`, all MSBuild operations are logged to a single
Expand Down
13 changes: 13 additions & 0 deletions src/Cli/Microsoft.DotNet.Cli.Utils/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,23 @@ public static class Constants
public const string DeployToDevice = nameof(DeployToDevice);
public const string CoreCompile = nameof(CoreCompile);

// MSBuild items
internal const string RuntimeEnvironmentVariable = nameof(RuntimeEnvironmentVariable);

// MSBuild item metadata
public const string Identity = nameof(Identity);
public const string FullPath = nameof(FullPath);

// MSBuild properties
public const string CustomBeforeMicrosoftCommonProps = nameof(CustomBeforeMicrosoftCommonProps);
public const string IntermediateOutputPath = nameof(IntermediateOutputPath);

/// <summary>
/// Property that workloads set to opt in to receiving environment variables as MSBuild items.
/// When true, 'dotnet run -e' will pass environment variables as @(RuntimeEnvironmentVariable) items.
/// </summary>
public const string UseRuntimeEnvironmentVariableItems = nameof(UseRuntimeEnvironmentVariableItems);

// MSBuild CLI flags

/// <summary>
Expand Down
147 changes: 147 additions & 0 deletions src/Cli/dotnet/Commands/Run/EnvironmentVariablesToMSBuild.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// 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.ObjectModel;
using System.Xml;
using Microsoft.Build.Execution;
using Microsoft.DotNet.Cli.Utils;

namespace Microsoft.DotNet.Cli.Commands.Run;

/// <summary>
/// Provides utilities for passing environment variables to MSBuild as items.
/// Environment variables specified via <c>dotnet run -e NAME=VALUE</c> are passed
/// as <c>&lt;RuntimeEnvironmentVariable Include="NAME" Value="VALUE" /&gt;</c> items.
/// </summary>
internal static class EnvironmentVariablesToMSBuild
{
private const string PropsFileName = "dotnet-run-env.props";

/// <summary>
/// Adds environment variables as MSBuild items to a ProjectInstance.
/// Use this for in-process MSBuild operations (e.g., DeployToDevice target).
/// </summary>
/// <param name="projectInstance">The MSBuild project instance to add items to.</param>
/// <param name="environmentVariables">The environment variables to add.</param>
public static void AddAsItems(ProjectInstance projectInstance, IReadOnlyDictionary<string, string> environmentVariables)
{
foreach (var (name, value) in environmentVariables)
{
projectInstance.AddItem(Constants.RuntimeEnvironmentVariable, name, new Dictionary<string, string>
{
["Value"] = value
});
}
}

/// <summary>
/// Creates a temporary .props file containing environment variables as MSBuild items.
/// Use this for out-of-process MSBuild operations where you need to inject items via
/// <c>CustomBeforeMicrosoftCommonProps</c> property.
/// </summary>
/// <param name="projectFilePath">The full path to the project file. If null or empty, returns null.</param>
/// <param name="environmentVariables">The environment variables to include.</param>
/// <param name="intermediateOutputPath">
/// Optional intermediate output path where the file will be created.
/// If null or empty, defaults to "obj" subdirectory of the project directory.
/// </param>
/// <returns>The full path to the created props file, or null if no environment variables were specified or projectFilePath is null.</returns>
public static string? CreatePropsFile(string? projectFilePath, IReadOnlyDictionary<string, string> environmentVariables, string? intermediateOutputPath = null)
{
if (string.IsNullOrEmpty(projectFilePath) || environmentVariables.Count == 0)
{
return null;
}

string projectDirectory = Path.GetDirectoryName(projectFilePath) ?? "";

// Normalize path separators - MSBuild may return paths with backslashes on non-Windows
string normalized = intermediateOutputPath?.Replace('\\', Path.DirectorySeparatorChar) ?? "";
string objDir = string.IsNullOrEmpty(normalized)
? Path.Combine(projectDirectory, Constants.ObjDirectoryName)
: Path.IsPathRooted(normalized)
? normalized
: Path.Combine(projectDirectory, normalized);
Directory.CreateDirectory(objDir);

// Ensure we return a full path for MSBuild property usage
string propsFilePath = Path.GetFullPath(Path.Combine(objDir, PropsFileName));
using (var stream = File.Create(propsFilePath))
{
WritePropsFileContent(stream, environmentVariables);
}

return propsFilePath;
}

/// <summary>
/// Deletes the temporary environment variables props file if it exists.
/// </summary>
/// <param name="propsFilePath">The path to the props file to delete.</param>
public static void DeletePropsFile(string? propsFilePath)
{
if (propsFilePath is not null && File.Exists(propsFilePath))
{
try
{
File.Delete(propsFilePath);
}
catch (Exception ex)
{
// Best effort cleanup - don't fail the build if we can't delete the temp file
Reporter.Verbose.WriteLine($"Failed to delete temporary props file '{propsFilePath}': {ex.Message}");
}
}
}

/// <summary>
/// Adds the props file property to the MSBuild arguments.
/// This uses <c>CustomBeforeMicrosoftCommonProps</c> to inject the props file early in evaluation.
/// </summary>
/// <param name="msbuildArgs">The base MSBuild arguments.</param>
/// <param name="propsFilePath">The path to the props file (from <see cref="CreatePropsFile"/>).</param>
/// <returns>The MSBuild arguments with the props file property added, or the original args if propsFilePath is null.</returns>
public static MSBuildArgs AddPropsFileToArgs(MSBuildArgs msbuildArgs, string? propsFilePath)
{
if (propsFilePath is null)
{
return msbuildArgs;
}

// Add the props file via CustomBeforeMicrosoftCommonProps.
// This ensures the items are available early in evaluation, similar to how we add items
// directly to ProjectInstance for in-process target invocations.
var additionalProperties = new ReadOnlyDictionary<string, string>(new Dictionary<string, string>
{
[Constants.CustomBeforeMicrosoftCommonProps] = propsFilePath
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.

I'm a little worried about this negatively impacting existing build processes - but I don't really have a way to validate how widely used this is.

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.

A quick search around github shows ~250 instances, but none really appear foundational and some are in unrelated tooling. I'd expect large 1P customers to have some kind of use of this though, because they use all of the weird extensiblity points. I suppose if MSbuild ever makes a first-class way of injecting Items we could move to that.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This was a spot I didn't like either, here is a GitHub search:

We could introduce a new property?

I really wanted to avoid creating a temp .props file but saw no other way

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.

I think this is fine to get in for the first revision and revisit. If we did a new property we'd need to add 'massaging' of that property into Items, and that can also be an error-prone activity due to weird user inputs.

});

return msbuildArgs.CloneWithAdditionalProperties(additionalProperties);
}

/// <summary>
/// Writes the content of the .props file containing environment variables as items.
/// </summary>
private static void WritePropsFileContent(Stream stream, IReadOnlyDictionary<string, string> environmentVariables)
{
using var writer = XmlWriter.Create(stream, new XmlWriterSettings
{
OmitXmlDeclaration = true,
Indent = true
});

writer.WriteStartElement("Project");
writer.WriteStartElement("ItemGroup");

foreach (var (name, value) in environmentVariables)
{
writer.WriteStartElement(Constants.RuntimeEnvironmentVariable);
writer.WriteAttributeString("Include", name);
writer.WriteAttributeString("Value", value);
writer.WriteEndElement();
}

writer.WriteEndElement(); // ItemGroup
writer.WriteEndElement(); // Project
}
}
43 changes: 33 additions & 10 deletions src/Cli/dotnet/Commands/Run/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ public int Execute()
{
// Pre-run evaluation: Handle target framework and device selection for project-based scenarios
using var selector = ProjectFileFullPath is not null
? new RunCommandSelector(ProjectFileFullPath, Interactive, MSBuildArgs, logger)
? new RunCommandSelector(ProjectFileFullPath, Interactive, MSBuildArgs, EnvironmentVariables, logger)
: null;
if (selector is not null && !TrySelectTargetFrameworkAndDeviceIfNeeded(selector))
{
Expand Down Expand Up @@ -186,7 +186,7 @@ public int Execute()
Reporter.Output.WriteLine(CliCommandStrings.RunCommandBuilding);
}

EnsureProjectIsBuilt(out projectFactory, out cachedRunProperties, out projectBuilder);
EnsureProjectIsBuilt(out projectFactory, out cachedRunProperties, out projectBuilder, selector?.IntermediateOutputPath, selector?.UseRuntimeEnvironmentVariableItems ?? false);
}
else if (EntryPointFileFullPath is not null && launchProfileParseResult.Profile is not ExecutableLaunchProfile)
{
Expand Down Expand Up @@ -472,7 +472,7 @@ internal LaunchProfileParseResult ReadLaunchProfileSettings()
return LaunchSettings.ReadProfileSettingsFromFile(launchSettingsPath, LaunchProfile);
}

private void EnsureProjectIsBuilt(out Func<ProjectCollection, ProjectInstance>? projectFactory, out RunProperties? cachedRunProperties, out VirtualProjectBuildingCommand? projectBuilder)
private void EnsureProjectIsBuilt(out Func<ProjectCollection, ProjectInstance>? projectFactory, out RunProperties? cachedRunProperties, out VirtualProjectBuildingCommand? projectBuilder, string? intermediateOutputPath, bool useRuntimeEnvironmentVariableItems)
{
int buildResult;
if (EntryPointFileFullPath is not null)
Expand All @@ -489,11 +489,28 @@ private void EnsureProjectIsBuilt(out Func<ProjectCollection, ProjectInstance>?
projectFactory = null;
cachedRunProperties = null;
projectBuilder = null;
buildResult = new RestoringCommand(
MSBuildArgs.CloneWithExplicitArgs([ProjectFileFullPath, .. MSBuildArgs.OtherMSBuildArgs]),
NoRestore || _restoreDoneForDeviceSelection,
advertiseWorkloadUpdates: false
).Execute();

// Create temporary props file for environment variables only if the project has opted in.
// This avoids invalidating incremental builds for projects that don't consume the items.
// Use IntermediateOutputPath from earlier project evaluation (via RunCommandSelector), defaulting to "obj" if not available.
string? envPropsFile = useRuntimeEnvironmentVariableItems
? EnvironmentVariablesToMSBuild.CreatePropsFile(ProjectFileFullPath, EnvironmentVariables, intermediateOutputPath)
: null;
try
{
var buildArgs = MSBuildArgs.CloneWithExplicitArgs([ProjectFileFullPath, .. MSBuildArgs.OtherMSBuildArgs]);
buildArgs = EnvironmentVariablesToMSBuild.AddPropsFileToArgs(buildArgs, envPropsFile);
buildResult = new RestoringCommand(
buildArgs,
NoRestore || _restoreDoneForDeviceSelection,
advertiseWorkloadUpdates: false
).Execute();
}
finally
{
// Clean up temporary props file
EnvironmentVariablesToMSBuild.DeletePropsFile(envPropsFile);
}
}

if (buildResult != 0)
Expand Down Expand Up @@ -575,7 +592,7 @@ private ICommand GetTargetCommandForProject(ProjectLaunchProfile? launchSettings

var project = EvaluateProject(ProjectFileFullPath, projectFactory, MSBuildArgs, logger);
ValidatePreconditions(project);
InvokeRunArgumentsTarget(project, NoBuild, logger, MSBuildArgs);
InvokeRunArgumentsTarget(project, NoBuild, logger, MSBuildArgs, EnvironmentVariables);

var runProperties = RunProperties.FromProject(project).WithApplicationArguments(ApplicationArgs);
command = CreateCommandFromRunProperties(runProperties);
Expand Down Expand Up @@ -663,8 +680,14 @@ static ICommand CreateCommandForCscBuiltProgram(string entryPointFileFullPath, s
return command;
}

static void InvokeRunArgumentsTarget(ProjectInstance project, bool noBuild, FacadeLogger? binaryLogger, MSBuildArgs buildArgs)
static void InvokeRunArgumentsTarget(ProjectInstance project, bool noBuild, FacadeLogger? binaryLogger, MSBuildArgs buildArgs, IReadOnlyDictionary<string, string> environmentVariables)
{
// Only add environment variables as MSBuild items if the project has opted in
if (string.Equals(project.GetPropertyValue(Constants.UseRuntimeEnvironmentVariableItems), "true", StringComparison.OrdinalIgnoreCase))
{
EnvironmentVariablesToMSBuild.AddAsItems(project, environmentVariables);
}

List<ILogger> loggersForBuild = [
CommonRunHelpers.GetConsoleLogger(
buildArgs.CloneWithExplicitArgs([$"--verbosity:{LoggerVerbosity.Quiet.ToString().ToLowerInvariant()}", ..buildArgs.OtherMSBuildArgs])
Expand Down
Loading
Loading