Skip to content
25 changes: 25 additions & 0 deletions docs/building-apps/build-properties.md
Original file line number Diff line number Diff line change
Expand Up @@ -1235,6 +1235,31 @@ only scan libraries with the `[LinkWith]` attribute for Objective-C classes:
</PropertyGroup>
```

## 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
<PropertyGroup>
<ResolveResourceItemsRelativeToProject>true</ResolveResourceItemsRelativeToProject>
</PropertyGroup>
```

## RunWithOpen

This property determines whether apps are launched using the `open` command on
Expand Down
62 changes: 39 additions & 23 deletions msbuild/Xamarin.MacDev.Tasks/BundleResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,38 +87,49 @@ public static string GetVirtualProjectPath<T> (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;
Expand All @@ -133,10 +144,15 @@ public static string GetVirtualProjectPath<T> (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;

Expand All @@ -159,7 +175,7 @@ public static string GetVirtualProjectPath<T> (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" +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ITaskItem> ();
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions msbuild/Xamarin.MacDev.Tasks/Tasks/Interfaces.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,8 @@ public interface IHasProjectDir {
public interface IHasSessionId {
string SessionId { get; set; }
}

public interface IHasResolveResourceItemsRelativeToProject {
bool ResolveResourceItemsRelativeToProject { get; set; }
}
}
7 changes: 7 additions & 0 deletions msbuild/Xamarin.Shared/Xamarin.Shared.props
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,13 @@ Copyright (C) 2020 Microsoft. All rights reserved.
<!-- that also encapsulates whether we're a library or not (this makes conditions simpler) -->
<_BundleOriginalResources Condition="'$(OutputType)' == 'Library' And '$(IsAppExtension)' != 'true' And '$(BundleOriginalResources)' == 'true'">true</_BundleOriginalResources>

<!-- Resolve Content and BundleResource items relative to the project file instead of the defining project file.
This fixes issues where SDKs (like the Razor SDK) add items from a directory far from the project,
producing nonsensical paths. Ref: https://github.com/dotnet/macios/issues/23898
Opt-in for .NET 10 and .NET 11, opt-out for .NET 12, gone in .NET 13. -->
<ResolveResourceItemsRelativeToProject Condition="'$(ResolveResourceItemsRelativeToProject)' == '' And $([MSBuild]::VersionGreaterThanOrEquals($(TargetFrameworkVersion), 12.0))">true</ResolveResourceItemsRelativeToProject>
<ResolveResourceItemsRelativeToProject Condition="'$(ResolveResourceItemsRelativeToProject)' == ''">false</ResolveResourceItemsRelativeToProject>

<EnableDiagnostics Condition="'$(EnableDiagnostics)' == '' And ('$(DiagnosticConfiguration)' != '' Or '$(DiagnosticAddress)' != '' Or '$(DiagnosticPort)' != '' Or '$(DiagnosticSuspend)' != '' Or '$(DiagnosticListenMode)' != '')">true</EnableDiagnostics>

<!-- Set the name of the native executable -->
Expand Down
1 change: 1 addition & 0 deletions msbuild/Xamarin.Shared/Xamarin.Shared.targets
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ Copyright (C) 2018 Microsoft. All rights reserved.
BundleResources="@(Content);@(BundleResource)"
ProjectDir="$(MSBuildProjectDirectory)"
ResourcePrefix="$(_ResourcePrefix)"
ResolveResourceItemsRelativeToProject="$(ResolveResourceItemsRelativeToProject)"
TargetFrameworkMoniker="$(_ComputedTargetFrameworkMoniker)"
UnpackedResources="@(_UnpackedBundleResourceWithLogicalName)"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ITaskItem> ();
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<CollectBundleResources> ();
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;
}
}
}
}
Loading