diff --git a/docs/building-apps/build-properties.md b/docs/building-apps/build-properties.md index 8bf0f20ba52..64ffcc4f377 100644 --- a/docs/building-apps/build-properties.md +++ b/docs/building-apps/build-properties.md @@ -1235,6 +1235,31 @@ only scan libraries with the `[LinkWith]` attribute for Objective-C classes: ``` +## ResolveResourceItemsRelativeToProject + +This property determines whether Content and BundleResource items have their +logical names computed relative to the project file or relative to the file +that declared them. + +When set to `true`, item paths are always computed relative to the project +file. This fixes issues where SDKs (such as the Razor SDK) add Content items +from a directory far from the project, producing incorrect paths that get +rejected at build time. + +When set to `false` (the default for .NET 10 and .NET 11), the legacy behavior +is preserved: paths are computed relative to the file that declared the item. + +| .NET version | Default | +|---|---| +| .NET 10 - .NET 11 | `false` (opt-in) | +| .NET 12+ | `true` (opt-out) | + +```xml + + true + +``` + ## RunWithOpen This property determines whether apps are launched using the `open` command on diff --git a/msbuild/Xamarin.MacDev.Tasks/BundleResource.cs b/msbuild/Xamarin.MacDev.Tasks/BundleResource.cs index 6b58fffb930..3974c1feedf 100644 --- a/msbuild/Xamarin.MacDev.Tasks/BundleResource.cs +++ b/msbuild/Xamarin.MacDev.Tasks/BundleResource.cs @@ -87,38 +87,49 @@ public static string GetVirtualProjectPath (T task, ITaskItem item) where T : // Note that '/' is a valid path separator on Windows (in addition to '\'), so canonicalize the paths to use '/' as the path separator. - var isDefaultItem = item.GetMetadata ("IsDefaultItem") == "true"; var localMSBuildProjectFullPath = item.GetMetadata ("LocalMSBuildProjectFullPath").Replace ('\\', '/'); var localDefiningProjectFullPath = item.GetMetadata ("LocalDefiningProjectFullPath").Replace ('\\', '/'); - if (string.IsNullOrEmpty (localDefiningProjectFullPath)) { - task.Log.LogError (null, null, null, item.ItemSpec, 0, 0, 0, 0, MSBStrings.E7133 /* The item '{0}' does not have a '{1}' value set. */, item.ItemSpec, "LocalDefiningProjectFullPath"); - return "placeholder"; - } if (string.IsNullOrEmpty (localMSBuildProjectFullPath)) { task.Log.LogError (null, null, null, item.ItemSpec, 0, 0, 0, 0, MSBStrings.E7133 /* The item '{0}' does not have a '{1}' value set. */, item.ItemSpec, "LocalMSBuildProjectFullPath"); return "placeholder"; } - // * If we're not a default item, compute the path relative to the - // file that declared the item in question. - // * If we're a default item (IsDefaultItem=true), compute - // relative to the user's project file (because the file that - // declared the item is our Microsoft.Sdk.DefaultItems.template.props file, - // and the path relative to that file is certainly not what we want). + // Check if the task has opted into resolving items relative to + // the project file instead of the defining project file. + // Ref: https://github.com/dotnet/macios/issues/23898 + var resolveRelativeToProject = task is IHasResolveResourceItemsRelativeToProject rr && rr.ResolveResourceItemsRelativeToProject; + + // When resolveRelativeToProject is true, always compute + // relative to the user's project file. This is important + // because SDKs (such as the Razor SDK) may add items from a + // directory far from the project, and computing relative to + // the SDK directory would produce nonsensical paths. + // + // When resolveRelativeToProject is false, use the legacy + // behavior: compute relative to the file that declared the + // item (DefiningProjectFullPath), unless the item is a default + // item (IsDefaultItem=true), in which case compute relative to + // the user's project file. // // We use the 'LocalMSBuildProjectFullPath' and // 'LocalDefiningProjectFullPath' metadata because the - // 'MSBuildProjectFullPath' and 'DefiningProjectFullPath' are not - // necessarily correct when building remotely (the relative path - // between files might not be the same on macOS once XVS has - // copied them there, in particular for files outside the project - // directory). + // 'MSBuildProjectFullPath' and 'DefiningProjectFullPath' are + // not necessarily correct when building remotely (the relative + // path between files might not be the same on macOS once XVS + // has copied them there, in particular for files outside the + // project directory). // - // The 'LocalMSBuildProjectFullPath' and 'LocalDefiningProjectFullPath' - // values are set to the Windows version of 'MSBuildProjectFullPath' - // and 'DefiningProjectFullPath' when building remotely, and the macOS - // version when building on macOS. + // The 'LocalMSBuildProjectFullPath' and + // 'LocalDefiningProjectFullPath' values are set to the Windows + // version of 'MSBuildProjectFullPath' and + // 'DefiningProjectFullPath' when building remotely, and the + // macOS version when building on macOS. + + if (!resolveRelativeToProject && string.IsNullOrEmpty (localDefiningProjectFullPath)) { + task.Log.LogError (null, null, null, item.ItemSpec, 0, 0, 0, 0, MSBStrings.E7133 /* The item '{0}' does not have a '{1}' value set. */, item.ItemSpec, "LocalDefiningProjectFullPath"); + return "placeholder"; + } // First find the absolute path to the item var projectAbsoluteDir = task.ProjectDir; @@ -133,10 +144,15 @@ public static string GetVirtualProjectPath (T task, ITaskItem item) where T : // Then find the directory we should use to compute the result relative to. string relativeToDirectory; // this is an absolute path. - if (isDefaultItem) { + if (resolveRelativeToProject) { relativeToDirectory = Path.GetDirectoryName (localMSBuildProjectFullPath)!; } else { - relativeToDirectory = Path.GetDirectoryName (localDefiningProjectFullPath)!; + var isDefaultItem = item.GetMetadata ("IsDefaultItem") == "true"; + if (isDefaultItem) { + relativeToDirectory = Path.GetDirectoryName (localMSBuildProjectFullPath)!; + } else { + relativeToDirectory = Path.GetDirectoryName (localDefiningProjectFullPath)!; + } } var originalRelativeToDirectory = relativeToDirectory; @@ -159,7 +175,7 @@ public static string GetVirtualProjectPath (T task, ITaskItem item) where T : Trace (task, $"BundleResource.GetVirtualProjectPath ({item.ItemSpec}) => {rv}\n" + $"\t\t\tprojectAbsoluteDir={projectAbsoluteDir}\n" + $"\t\t\tIsRemoteBuild={isRemoteBuild}\n" + - $"\t\t\tisDefaultItem={isDefaultItem}\n" + + $"\t\t\tresolveRelativeToProject={resolveRelativeToProject}\n" + $"\t\t\tLocalMSBuildProjectFullPath={localMSBuildProjectFullPath}\n" + $"\t\t\tLocalDefiningProjectFullPath={localDefiningProjectFullPath}\n" + $"\t\t\toriginalItemAbsolutePath={originalItemAbsolutePath}\n" + diff --git a/msbuild/Xamarin.MacDev.Tasks/Tasks/CollectBundleResources.cs b/msbuild/Xamarin.MacDev.Tasks/Tasks/CollectBundleResources.cs index 32feaa6761f..06cf25eea29 100644 --- a/msbuild/Xamarin.MacDev.Tasks/Tasks/CollectBundleResources.cs +++ b/msbuild/Xamarin.MacDev.Tasks/Tasks/CollectBundleResources.cs @@ -11,7 +11,7 @@ using Xamarin.Utils; namespace Xamarin.MacDev.Tasks { - public class CollectBundleResources : XamarinTask, ICancelableTask, IHasProjectDir, IHasResourcePrefix { + public class CollectBundleResources : XamarinTask, ICancelableTask, IHasProjectDir, IHasResourcePrefix, IHasResolveResourceItemsRelativeToProject { #region Inputs public ITaskItem [] BundleResources { get; set; } = Array.Empty (); @@ -26,6 +26,8 @@ public class CollectBundleResources : XamarinTask, ICancelableTask, IHasProjectD [Required] public string ResourcePrefix { get; set; } = string.Empty; + public bool ResolveResourceItemsRelativeToProject { get; set; } + #endregion #region Outputs diff --git a/msbuild/Xamarin.MacDev.Tasks/Tasks/Interfaces.cs b/msbuild/Xamarin.MacDev.Tasks/Tasks/Interfaces.cs index 4ccf4f12f78..2d3b69048a0 100644 --- a/msbuild/Xamarin.MacDev.Tasks/Tasks/Interfaces.cs +++ b/msbuild/Xamarin.MacDev.Tasks/Tasks/Interfaces.cs @@ -10,4 +10,8 @@ public interface IHasProjectDir { public interface IHasSessionId { string SessionId { get; set; } } + + public interface IHasResolveResourceItemsRelativeToProject { + bool ResolveResourceItemsRelativeToProject { get; set; } + } } diff --git a/msbuild/Xamarin.Shared/Xamarin.Shared.props b/msbuild/Xamarin.Shared/Xamarin.Shared.props index 43b3f5fa009..627e7f6731c 100644 --- a/msbuild/Xamarin.Shared/Xamarin.Shared.props +++ b/msbuild/Xamarin.Shared/Xamarin.Shared.props @@ -200,6 +200,13 @@ Copyright (C) 2020 Microsoft. All rights reserved. <_BundleOriginalResources Condition="'$(OutputType)' == 'Library' And '$(IsAppExtension)' != 'true' And '$(BundleOriginalResources)' == 'true'">true + + true + false + true diff --git a/msbuild/Xamarin.Shared/Xamarin.Shared.targets b/msbuild/Xamarin.Shared/Xamarin.Shared.targets index 5accac63eb0..ba24190cea8 100644 --- a/msbuild/Xamarin.Shared/Xamarin.Shared.targets +++ b/msbuild/Xamarin.Shared/Xamarin.Shared.targets @@ -461,6 +461,7 @@ Copyright (C) 2018 Microsoft. All rights reserved. BundleResources="@(Content);@(BundleResource)" ProjectDir="$(MSBuildProjectDirectory)" ResourcePrefix="$(_ResourcePrefix)" + ResolveResourceItemsRelativeToProject="$(ResolveResourceItemsRelativeToProject)" TargetFrameworkMoniker="$(_ComputedTargetFrameworkMoniker)" UnpackedResources="@(_UnpackedBundleResourceWithLogicalName)" > diff --git a/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/CollectBundleResourcesTaskTests.cs b/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/CollectBundleResourcesTaskTests.cs index e83faf0659e..6fcae7bdb98 100644 --- a/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/CollectBundleResourcesTaskTests.cs +++ b/tests/msbuild/Xamarin.MacDev.Tasks.Tests/TaskTests/CollectBundleResourcesTaskTests.cs @@ -36,5 +36,77 @@ public void LogicalNameOutsideAppBundle () Environment.CurrentDirectory = currentDirectory; } } + + // Ref: https://github.com/dotnet/macios/issues/23898 + // Items defined by an SDK (not default items) where the defining + // project is in a completely different directory tree than the + // project should still resolve their LogicalName correctly + // when ResolveResourceItemsRelativeToProject is enabled. + [TestCase (true)] // .NET 12+ default: resolve relative to project + [TestCase (false)] // .NET 10/11 default: resolve relative to defining project (legacy behavior) + public void ContentDefinedBySdkFarFromProject (bool resolveRelativeToProject) + { + var currentDirectory = Environment.CurrentDirectory; + try { + var tmpdir = Cache.CreateTemporaryDirectory (); + + // Simulate the project directory + var projDir = Path.Combine (tmpdir, "src", "MyProject"); + Directory.CreateDirectory (projDir); + + // Simulate an SDK directory far from the project (like a Razor/Blazor SDK) + var sdkDir = Path.Combine (tmpdir, "sdk", "packs", "Microsoft.NET.Sdk.Razor", "10.0.0", "build"); + Directory.CreateDirectory (sdkDir); + + Environment.CurrentDirectory = projDir; + + // Create several content files in the project directory + var files = new [] { "wwwroot/background.png", "wwwroot/script.js", "Component1.razor" }; + var items = new List (); + foreach (var file in files) { + var fullPath = Path.Combine (projDir, file); + Directory.CreateDirectory (Path.GetDirectoryName (fullPath)!); + File.WriteAllText (fullPath, "content"); + + var item = new TaskItem (file); + // The defining project is the SDK file, not a default item + item.SetMetadata ("LocalDefiningProjectFullPath", Path.Combine (sdkDir, "Microsoft.NET.Sdk.Razor.DefaultItems.props")); + item.SetMetadata ("LocalMSBuildProjectFullPath", Path.Combine (projDir, "MyProject.csproj")); + items.Add (item); + } + + var task = CreateTask (); + task.ProjectDir = projDir + Path.DirectorySeparatorChar; + task.ResourcePrefix = ""; + task.ResolveResourceItemsRelativeToProject = resolveRelativeToProject; + task.BundleResources = items.ToArray (); + ExecuteTask (task); + + if (resolveRelativeToProject) { + // With ResolveResourceItemsRelativeToProject enabled, the + // LogicalName is computed relative to the project directory, + // so items are correctly included. + Assert.That (Engine.Logger.WarningsEvents.Count, Is.EqualTo (0), $"Warnings: {string.Join (", ", Engine.Logger.WarningsEvents.Select (e => e.Message))}"); + Assert.That (Engine.Logger.ErrorEvents.Count, Is.EqualTo (0), $"Errors: {string.Join (", ", Engine.Logger.ErrorEvents.Select (e => e.Message))}"); + Assert.That (task.BundleResourcesWithLogicalNames.Length, Is.EqualTo (3), "BundleResourcesWithLogicalNames count"); + + var logicalNames = task.BundleResourcesWithLogicalNames + .Select (i => i.GetMetadata ("LogicalName")) + .OrderBy (n => n) + .ToArray (); + Assert.That (logicalNames [0], Is.EqualTo ("Component1.razor"), "LogicalName[0]"); + Assert.That (logicalNames [1], Is.EqualTo ("wwwroot/background.png"), "LogicalName[1]"); + Assert.That (logicalNames [2], Is.EqualTo ("wwwroot/script.js"), "LogicalName[2]"); + } else { + // Without ResolveResourceItemsRelativeToProject, the LogicalName + // is computed relative to the defining project (SDK) directory, + // which produces paths outside the app bundle. + Assert.That (Engine.Logger.WarningsEvents.Count, Is.EqualTo (3), $"Warnings: {string.Join (", ", Engine.Logger.WarningsEvents.Select (e => e.Message))}"); + Assert.That (task.BundleResourcesWithLogicalNames.Length, Is.EqualTo (0), "BundleResourcesWithLogicalNames count"); + } + } finally { + Environment.CurrentDirectory = currentDirectory; + } + } } }