Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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