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