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); + } + } +}