diff --git a/dotnet/targets/Xamarin.Shared.Sdk.targets b/dotnet/targets/Xamarin.Shared.Sdk.targets
index 92a833c8baae..056654f3f880 100644
--- a/dotnet/targets/Xamarin.Shared.Sdk.targets
+++ b/dotnet/targets/Xamarin.Shared.Sdk.targets
@@ -26,6 +26,7 @@
+
@@ -1593,10 +1594,13 @@
+
+ <_MergedRuntimeConfigFilePath>$(DeviceSpecificIntermediateOutputPath)runtimeconfig.merged.json
+
<_RuntimeConfigReservedProperties Include="APP_PATHS" />
@@ -1608,9 +1612,25 @@
<_RuntimeConfigReservedProperties Include="SYSTEM_CORELIB_DIRECTORY" />
<_RuntimeConfigReservedProperties Include="STARTUP_HOOKS" />
-
+
+
+
+
+
+
+
+
diff --git a/msbuild/Xamarin.Localization.MSBuild/MSBStrings.resx b/msbuild/Xamarin.Localization.MSBuild/MSBStrings.resx
index 808b49311e39..a635570bc69b 100644
--- a/msbuild/Xamarin.Localization.MSBuild/MSBStrings.resx
+++ b/msbuild/Xamarin.Localization.MSBuild/MSBStrings.resx
@@ -1705,4 +1705,15 @@
Codesign failed with 'errSecInternalComponent'. This usually means the keychain is locked, which is common when building over SSH. Unlock the keychain first, for example by running 'security unlock-keychain ~/Library/Keychains/login.keychain-db'.
Shown when codesign fails with the 'errSecInternalComponent' error, which typically indicates a locked keychain.
+
+ The runtime configuration file '{0}' is not a valid JSON object.
+ Shown when the main runtime configuration file (*.runtimeconfig.json) does not contain a valid JSON object at its root.
+{0} - The path to the runtime configuration file.
+
+
+ Could not read the runtime configuration file '{0}': {1}
+ Shown when a runtime configuration file (*.runtimeconfig.json or *.runtimeconfig.dev.json) can't be read or parsed.
+{0} - The path to the runtime configuration file.
+{1} - The error message.
+
diff --git a/msbuild/Xamarin.MacDev.Tasks/Tasks/MergeRuntimeConfigFiles.cs b/msbuild/Xamarin.MacDev.Tasks/Tasks/MergeRuntimeConfigFiles.cs
new file mode 100644
index 000000000000..8e0b7b66d4a5
--- /dev/null
+++ b/msbuild/Xamarin.MacDev.Tasks/Tasks/MergeRuntimeConfigFiles.cs
@@ -0,0 +1,88 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.IO;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+using Microsoft.Build.Framework;
+
+#nullable enable
+
+namespace Xamarin.MacDev.Tasks {
+ // This task merges the 'runtimeOptions.configProperties' from a '*.runtimeconfig.dev.json' file on top of
+ // the ones from the main '*.runtimeconfig.json' file, writing the merged result to an output file.
+ //
+ // We do this because we hand the runtime configuration directly to the runtime (bypassing hostfxr), and
+ // hostfxr is what would normally merge the dev file (which contains Debug-only switches such as Hot Reload)
+ // into the main runtime configuration. The dev values win, matching hostfxr's behavior.
+ public class MergeRuntimeConfigFiles : XamarinTask {
+ [Required]
+ public string RuntimeConfigFile { get; set; } = "";
+
+ public string? RuntimeConfigDevFile { get; set; }
+
+ [Required]
+ public string OutputFile { get; set; } = "";
+
+ static readonly JsonDocumentOptions documentOptions = new JsonDocumentOptions {
+ AllowTrailingCommas = true,
+ CommentHandling = JsonCommentHandling.Skip,
+ };
+
+ JsonNode? TryParse (string path)
+ {
+ try {
+ using var stream = File.OpenRead (path);
+ return JsonNode.Parse (stream, documentOptions: documentOptions);
+ } catch (Exception e) {
+ Log.LogError (MSBStrings.E7186 /* Could not read the runtime configuration file '{0}': {1} */, path, e.Message);
+ return null;
+ }
+ }
+
+ public override bool Execute ()
+ {
+ if (TryParse (RuntimeConfigFile) is not JsonObject mainObject) {
+ if (!Log.HasLoggedErrors)
+ Log.LogError (MSBStrings.E7185 /* The runtime configuration file '{0}' is not a valid JSON object. */, RuntimeConfigFile);
+ return false;
+ }
+
+ var devFile = RuntimeConfigDevFile;
+ if (devFile is not null && devFile.Length > 0 && File.Exists (devFile)) {
+ var devNode = TryParse (devFile);
+ if (devNode is null)
+ return false; // TryParse already logged an error.
+ var devConfigProperties = (devNode as JsonObject)? ["runtimeOptions"]? ["configProperties"] as JsonObject;
+ if (devConfigProperties is not null && devConfigProperties.Count > 0) {
+ var runtimeOptions = mainObject ["runtimeOptions"] as JsonObject;
+ if (runtimeOptions is null) {
+ runtimeOptions = new JsonObject ();
+ mainObject ["runtimeOptions"] = runtimeOptions;
+ }
+
+ var configProperties = runtimeOptions ["configProperties"] as JsonObject;
+ if (configProperties is null) {
+ configProperties = new JsonObject ();
+ runtimeOptions ["configProperties"] = configProperties;
+ }
+
+ foreach (var property in devConfigProperties)
+ configProperties [property.Key] = property.Value?.DeepClone ();
+ }
+ }
+
+ var outputDirectory = Path.GetDirectoryName (OutputFile);
+ if (outputDirectory is not null && outputDirectory.Length > 0)
+ Directory.CreateDirectory (outputDirectory);
+
+ using (var stream = File.Create (OutputFile))
+ using (var writer = new Utf8JsonWriter (stream, new JsonWriterOptions { Indented = true }))
+ mainObject.WriteTo (writer);
+
+ return !Log.HasLoggedErrors;
+ }
+ }
+}
diff --git a/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/MergeRuntimeConfigFilesTaskTest.cs b/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/MergeRuntimeConfigFilesTaskTest.cs
new file mode 100644
index 000000000000..ded7dc3c5584
--- /dev/null
+++ b/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/MergeRuntimeConfigFilesTaskTest.cs
@@ -0,0 +1,129 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.IO;
+
+using NUnit.Framework;
+
+#nullable enable
+
+namespace Xamarin.MacDev.Tasks {
+
+ // Note: we can't use System.Text.Json types here to inspect the output, because the
+ // Xamarin.MacDev.Tasks assembly under test is ILMerged and also exposes System.Text.Json,
+ // which causes ambiguous type references. We assert on the raw JSON text instead.
+ [TestFixture]
+ public class MergeRuntimeConfigFilesTaskTest : TestBase {
+
+ string RunMerge (string mainJson, string? devJson)
+ {
+ var tmp = Cache.CreateTemporaryDirectory ();
+ var mainFile = Path.Combine (tmp, "app.runtimeconfig.json");
+ File.WriteAllText (mainFile, mainJson);
+
+ string? devFile = null;
+ if (devJson is not null) {
+ devFile = Path.Combine (tmp, "app.runtimeconfig.dev.json");
+ File.WriteAllText (devFile, devJson);
+ }
+
+ var outputFile = Path.Combine (tmp, "obj", "runtimeconfig.merged.json");
+
+ var task = CreateTask ();
+ task.RuntimeConfigFile = mainFile;
+ task.RuntimeConfigDevFile = devFile;
+ task.OutputFile = outputFile;
+
+ ExecuteTask (task);
+
+ Assert.That (outputFile, Does.Exist, "output file created");
+ return File.ReadAllText (outputFile);
+ }
+
+ [Test]
+ public void MergesDevPropertiesOntoMain ()
+ {
+ var mainJson = @"{
+ ""runtimeOptions"": {
+ ""tfm"": ""net10.0"",
+ ""configProperties"": {
+ ""OnlyInMain"": true,
+ ""InBoth"": ""main-value""
+ }
+ }
+}";
+ var devJson = @"{
+ ""runtimeOptions"": {
+ ""configProperties"": {
+ ""OnlyInDev"": ""dev-only"",
+ ""InBoth"": ""dev-value""
+ }
+ }
+}";
+
+ var merged = RunMerge (mainJson, devJson);
+
+ // (a) properties only in main are preserved
+ Assert.That (merged, Does.Contain ("\"OnlyInMain\": true"), "OnlyInMain preserved");
+ // (b) properties only in dev are added
+ Assert.That (merged, Does.Contain ("\"OnlyInDev\": \"dev-only\""), "OnlyInDev added");
+ // (c) properties in both are taken from dev (dev wins)
+ Assert.That (merged, Does.Contain ("\"InBoth\": \"dev-value\""), "dev wins for InBoth");
+ Assert.That (merged, Does.Not.Contain ("main-value"), "main value for InBoth is gone");
+ }
+
+ [Test]
+ public void NoDevFile ()
+ {
+ var mainJson = @"{
+ ""runtimeOptions"": {
+ ""configProperties"": {
+ ""OnlyInMain"": true
+ }
+ }
+}";
+
+ var merged = RunMerge (mainJson, null);
+
+ Assert.That (merged, Does.Contain ("\"OnlyInMain\": true"), "OnlyInMain preserved");
+ }
+
+ [Test]
+ public void DevFileWithoutConfigProperties ()
+ {
+ var mainJson = @"{
+ ""runtimeOptions"": {
+ ""configProperties"": {
+ ""OnlyInMain"": true
+ }
+ }
+}";
+ var devJson = @"{
+ ""runtimeOptions"": {
+ ""additionalProbingPaths"": [ ""/some/path"" ]
+ }
+}";
+
+ var merged = RunMerge (mainJson, devJson);
+
+ Assert.That (merged, Does.Contain ("\"OnlyInMain\": true"), "OnlyInMain preserved");
+ // The dev file had no configProperties, so nothing from it should have leaked in.
+ Assert.That (merged, Does.Not.Contain ("additionalProbingPaths"), "dev-only non-configProperties content is not merged");
+ }
+
+ [Test]
+ public void InvalidMainJsonLogsError ()
+ {
+ var tmp = Cache.CreateTemporaryDirectory ();
+ var mainFile = Path.Combine (tmp, "app.runtimeconfig.json");
+ File.WriteAllText (mainFile, "this is not json {");
+
+ var task = CreateTask ();
+ task.RuntimeConfigFile = mainFile;
+ task.OutputFile = Path.Combine (tmp, "obj", "runtimeconfig.merged.json");
+
+ // The task must report an MSBuild error rather than throwing.
+ ExecuteTask (task, 1);
+ }
+ }
+}