mirror of
https://github.com/icsharpcode/ILSpy.git
synced 2026-10-12 02:15:58 +00:00
Merge pull request #4102 from icsharpcode/fix/wpf-resource-logical-names
Fix #3315: make exported WPF projects reproduce their resources
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) 2026 Siegfried Pammer
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
|
||||
// software and associated documentation files (the "Software"), to deal in the Software
|
||||
// without restriction, including without limitation the rights to use, copy, modify, merge,
|
||||
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
|
||||
// to whom the Software is furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all copies or
|
||||
// substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
|
||||
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
// DEALINGS IN THE SOFTWARE.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
using ICSharpCode.Decompiler.CSharp;
|
||||
using ICSharpCode.Decompiler.CSharp.ProjectDecompiler;
|
||||
using ICSharpCode.Decompiler.Metadata;
|
||||
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace ICSharpCode.Decompiler.Tests.ProjectDecompiler;
|
||||
|
||||
[TestFixture]
|
||||
public sealed class ProjectFileWriterSdkStyleTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Everything the export wrote to disk has to be reachable from the project file, or the
|
||||
/// exported project cannot rebuild. XAML recovered from BAML is the case that hurts: it lands
|
||||
/// in "Page" items, and a WPF project without them compiles no XAML at all.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void ItemTypesOtherThanEmbeddedResourceAreListed()
|
||||
{
|
||||
ProjectItemInfo[] files = [
|
||||
new ProjectItemInfo("Page", "Themes/Generic.xaml")
|
||||
.With("Generator", "MSBuild:Compile")
|
||||
.With("SubType", "Designer"),
|
||||
new ProjectItemInfo("Compile", "Program.cs"),
|
||||
];
|
||||
|
||||
string project = WriteProjectFile(files);
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(project, Does.Contain(@"<Page Include=""Themes/Generic.xaml"" Generator=""MSBuild:Compile"" SubType=""Designer"" />"),
|
||||
"the XAML file is a project item, metadata included");
|
||||
Assert.That(project, Does.Contain(@"<Page Remove=""Themes/Generic.xaml"" />"),
|
||||
"the SDK's own **/*.xaml glob would otherwise make the explicit item a duplicate");
|
||||
Assert.That(project.IndexOf(@"<Page Remove", StringComparison.Ordinal),
|
||||
Is.LessThan(project.IndexOf(@"<Page Include", StringComparison.Ordinal)),
|
||||
"the remove has to precede the include, or it takes the include with it");
|
||||
Assert.That(project, Does.Not.Contain("Program.cs"),
|
||||
"source files come in through the SDK's default Compile glob");
|
||||
}
|
||||
}
|
||||
|
||||
static string WriteProjectFile(ProjectItemInfo[] files)
|
||||
{
|
||||
StringWriter output = new();
|
||||
ProjectFileWriterSdkStyle.Default.Write(output, new TestProjectInfoProvider(), files,
|
||||
new PEFile("ICSharpCode.Decompiler.dll"));
|
||||
return output.ToString();
|
||||
}
|
||||
|
||||
sealed class TestProjectInfoProvider : IProjectInfoProvider
|
||||
{
|
||||
public IAssemblyResolver AssemblyResolver { get; } = new UniversalAssemblyResolver(null, false, null);
|
||||
public IAssemblyReferenceClassifier AssemblyReferenceClassifier { get; } = new AssemblyReferenceClassifier();
|
||||
public LanguageVersion LanguageVersion => LanguageVersion.Latest;
|
||||
public bool CheckForOverflowUnderflow => false;
|
||||
public Guid ProjectGuid { get; } = Guid.NewGuid();
|
||||
public string TargetDirectory { get; } = Environment.CurrentDirectory;
|
||||
public string StrongNameKeyFile => null;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Resources;
|
||||
|
||||
using ICSharpCode.Decompiler.CSharp;
|
||||
using ICSharpCode.Decompiler.CSharp.ProjectDecompiler;
|
||||
@@ -28,6 +29,9 @@ using ICSharpCode.Decompiler.CSharp.Transforms;
|
||||
using ICSharpCode.Decompiler.Metadata;
|
||||
using ICSharpCode.Decompiler.TypeSystem;
|
||||
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace ICSharpCode.Decompiler.Tests.ProjectDecompiler;
|
||||
@@ -134,6 +138,138 @@ public sealed class WholeProjectDecompilerTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WPF's build tasks key every Page and Resource item in "<AssemblyName>.g.resources" by its
|
||||
/// relative path, lower-cased and URI-escaped, so a folder named "My Folder" arrives as
|
||||
/// "my%20folder". The escapes have to be decoded before the name becomes a file name -
|
||||
/// sanitizing them instead produces a directory called "my-20folder" (issue #3315). Only the
|
||||
/// WPF-generated containers are escaped; a percent sign in any other .resources file is part
|
||||
/// of the name.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void WpfResourceIdsAreUnescapedBeforeSanitizing()
|
||||
{
|
||||
string targetDirectory = Path.Combine(Environment.CurrentDirectory, Path.GetRandomFileName());
|
||||
TestFriendlyProjectDecompiler decompiler = new(new UniversalAssemblyResolver(null, false, null));
|
||||
decompiler.CaptureResources = true;
|
||||
|
||||
using var assembly = CreateAssemblyWithResources(
|
||||
("Test.g.resources", "my%20folder/window.baml"),
|
||||
// satellite assemblies hold the localized pages in "<AssemblyName>.g.<culture>.resources"
|
||||
("Test.g.de-DE.resources", "my%20folder/other.baml"),
|
||||
("Test.resources", "100%25off/window.baml"));
|
||||
decompiler.DecompileProject(new PEFile("Test.dll", assembly), targetDirectory, new StringWriter());
|
||||
AssertDirectoryDoesntExist(targetDirectory);
|
||||
|
||||
Assert.That(decompiler.WrittenResources, Is.EquivalentTo(new[] {
|
||||
Path.Combine("my-folder", "window.baml"),
|
||||
Path.Combine("my-folder", "other.baml"),
|
||||
// not a WPF container, so "%25" is three characters of the name and not an escaped '%'
|
||||
Path.Combine("100-25off", "window.baml"),
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizing is not injective: "a+b/logo.png", "a&b/logo.png" and "a%23b/logo.png" all end up
|
||||
/// as "a-b/logo.png", and an assembly can be crafted to aim any number of entries at one name.
|
||||
/// Colliding entries used to overwrite each other silently, costing the export their contents
|
||||
/// with nothing written to the error list. Each one now gets a file of its own; the item still
|
||||
/// carries the true name, so all of them survive a rebuild.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void ResourcesThatSanitizeToTheSameNameGetSeparateFiles()
|
||||
{
|
||||
string targetDirectory = Path.Combine(Environment.CurrentDirectory, Path.GetRandomFileName());
|
||||
TestFriendlyProjectDecompiler decompiler = new(new UniversalAssemblyResolver(null, false, null));
|
||||
decompiler.CaptureResources = true;
|
||||
|
||||
using var assembly = CreateAssemblyWithResources(
|
||||
("Test.g.resources", "a+b/logo.png"),
|
||||
("Test.g.resources", "a&b/logo.png"),
|
||||
("Test.g.resources", "a%23b/logo.png"),
|
||||
// the same name in a second container is a different resource and needs its own file too
|
||||
("Test.g.de-DE.resources", "a+b/logo.png"));
|
||||
decompiler.DecompileProject(new PEFile("Test.dll", assembly), targetDirectory, new StringWriter());
|
||||
AssertDirectoryDoesntExist(targetDirectory);
|
||||
|
||||
// Entries come back in the order the .resources format stores them, not the order they were
|
||||
// written in, so which entry draws which suffix is not fixed - only that the four of them
|
||||
// occupy four files and keep their four names.
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(decompiler.WrittenResources, Is.EquivalentTo(new[] {
|
||||
Path.Combine("a-b", "logo.png"),
|
||||
Path.Combine("a-b", "logo_2.png"),
|
||||
Path.Combine("a-b", "logo_3.png"),
|
||||
Path.Combine("a-b", "logo_4.png"),
|
||||
}));
|
||||
Assert.That(decompiler.ResourceItems.Select(i => i.AdditionalProperties?["LogicalName"]),
|
||||
Is.EquivalentTo(new[] { "a+b/logo.png", "a&b/logo.png", "a#b/logo.png", "a+b/logo.png" }));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A rebuilt WPF project only resolves its own pack URIs when every entry of
|
||||
/// "<AssemblyName>.g.resources" comes back under the resource ID it had before. The file on
|
||||
/// disk cannot carry that ID - it is sanitized, and the ID is escaped - so each item pins it
|
||||
/// with a LogicalName holding the decoded name, which is what the WPF build tasks escape again.
|
||||
/// Entries no handler claimed have to be Resource items as well: as EmbeddedResource they would
|
||||
/// rebuild into a manifest resource of their own instead of landing in ".g.resources".
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void WpfResourceEntriesCarryTheirOriginalResourceIdAsLogicalName()
|
||||
{
|
||||
string targetDirectory = Path.Combine(Environment.CurrentDirectory, Path.GetRandomFileName());
|
||||
TestFriendlyProjectDecompiler decompiler = new(new UniversalAssemblyResolver(null, false, null));
|
||||
decompiler.CaptureResources = true;
|
||||
|
||||
using var assembly = CreateAssemblyWithResources(
|
||||
("Test.g.resources", "my%20folder/window.baml"),
|
||||
("Test.g.resources", "resource%20test/logo.png"),
|
||||
("Test.resources", "plain%25folder/logo.png"));
|
||||
decompiler.DecompileProject(new PEFile("Test.dll", assembly), targetDirectory, new StringWriter());
|
||||
AssertDirectoryDoesntExist(targetDirectory);
|
||||
|
||||
Assert.That(decompiler.ResourceItems.Select(i => (i.ItemType, i.FileName, i.AdditionalProperties?["LogicalName"])),
|
||||
Is.EquivalentTo(new[] {
|
||||
// the build re-derives the .baml extension from the Page item type
|
||||
("Page", Path.Combine("my-folder", "window.xaml"), "my folder/window.xaml"),
|
||||
("Resource", Path.Combine("resource-test", "logo.png"), "resource test/logo.png"),
|
||||
// not a WPF container, so the name is neither escaped nor a Resource item
|
||||
("EmbeddedResource", Path.Combine("plain-25folder", "logo.png"), "plain%25folder/logo.png"),
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits an assembly carrying one embedded .resources container per distinct container name in
|
||||
/// <paramref name="resources"/>, each holding the stream-valued entries named for it. Streams
|
||||
/// are what makes the export write the entries out as individual files.
|
||||
/// </summary>
|
||||
static Stream CreateAssemblyWithResources(params (string ContainerName, string EntryName)[] resources)
|
||||
{
|
||||
var compilation = CSharpCompilation.Create("Test",
|
||||
new[] { CSharpSyntaxTree.ParseText("[assembly: System.Runtime.Versioning.TargetFramework(\".NETCoreApp,Version=v8.0\")] class C { }") },
|
||||
new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) },
|
||||
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
|
||||
|
||||
MemoryStream assembly = new();
|
||||
var result = compilation.Emit(assembly, manifestResources: resources.GroupBy(r => r.ContainerName).Select(container => {
|
||||
MemoryStream contents = new();
|
||||
using (ResourceWriter writer = new(contents))
|
||||
{
|
||||
foreach (var entry in container)
|
||||
{
|
||||
writer.AddResource(entry.EntryName, new MemoryStream(new byte[] { 1, 2, 3 }));
|
||||
}
|
||||
}
|
||||
byte[] bytes = contents.ToArray();
|
||||
return new ResourceDescription(container.Key, () => new MemoryStream(bytes), isPublic: true);
|
||||
}).ToArray());
|
||||
Assert.That(result.Success, Is.True, () => string.Join(Environment.NewLine, result.Diagnostics));
|
||||
assembly.Position = 0;
|
||||
return assembly;
|
||||
}
|
||||
|
||||
sealed class ThrowingAstTransform(string typeName) : IAstTransform
|
||||
{
|
||||
public const string Failure = "Simulated AST transform failure";
|
||||
@@ -200,10 +336,19 @@ public sealed class WholeProjectDecompilerTests
|
||||
|
||||
public List<string> WrittenResources { get; } = [];
|
||||
|
||||
public List<ProjectItemInfo> ResourceItems { get; } = [];
|
||||
|
||||
// Resources are skipped unless a test asks for them, so the tests that only care about
|
||||
// source files neither touch the disk nor pay for decoding them.
|
||||
public bool CaptureResources { get; set; }
|
||||
|
||||
protected override IEnumerable<ProjectItemInfo> WriteResourceFilesInProject(MetadataFile module)
|
||||
{
|
||||
if (FailResourceWriting)
|
||||
return base.WriteResourceFilesInProject(module);
|
||||
if (FailResourceWriting || CaptureResources)
|
||||
{
|
||||
ResourceItems.AddRange(base.WriteResourceFilesInProject(module));
|
||||
return ResourceItems;
|
||||
}
|
||||
return FailResourceEnumeration
|
||||
? Enumerable.Range(0, 1).Select<int, ProjectItemInfo>(_ => throw new InvalidOperationException(ResourceFailure))
|
||||
: [];
|
||||
@@ -213,13 +358,19 @@ public sealed class WholeProjectDecompilerTests
|
||||
// "gave up on the rest of them".
|
||||
protected override IEnumerable<ProjectItemInfo> WriteResourceToFile(string fileName, string resourceName, Stream entryStream)
|
||||
{
|
||||
if (WrittenResources.Count == 0)
|
||||
if (FailResourceWriting && WrittenResources.Count == 0)
|
||||
{
|
||||
WrittenResources.Add(fileName);
|
||||
throw new InvalidOperationException(ResourceFailure);
|
||||
}
|
||||
WrittenResources.Add(fileName);
|
||||
return new[] { new ProjectItemInfo("EmbeddedResource", fileName) };
|
||||
// Stands in for the BAML resource-file handlers the real hosts plug in: a .baml entry
|
||||
// is decompiled into a .xaml file referenced by a <Page> item, and those handlers
|
||||
// attach no LogicalName. Everything else keeps the base class' behaviour of naming the
|
||||
// resource entry as it is stored in the assembly.
|
||||
return fileName.EndsWith(".baml", StringComparison.OrdinalIgnoreCase)
|
||||
? new[] { new ProjectItemInfo("Page", Path.ChangeExtension(fileName, ".xaml")) }
|
||||
: new[] { new ProjectItemInfo("EmbeddedResource", fileName).With("LogicalName", resourceName) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,18 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler
|
||||
"System.Xaml",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Item types that must not be listed as project items: source files are covered by the
|
||||
/// SDK's default Compile glob, the icon and the manifest are written as properties, and
|
||||
/// embedded resources have their own remove/include phase.
|
||||
/// </summary>
|
||||
static readonly HashSet<string> ItemTypesWrittenElsewhere = new HashSet<string> {
|
||||
"ApplicationIcon",
|
||||
"ApplicationManifest",
|
||||
"Compile",
|
||||
"EmbeddedResource",
|
||||
};
|
||||
|
||||
enum ProjectType { Default, WinForms, Wpf, Web }
|
||||
|
||||
/// <summary>
|
||||
@@ -287,6 +299,38 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler
|
||||
}
|
||||
xml.WriteEndElement();
|
||||
}
|
||||
|
||||
// Every other item type the export produced, "Page" for XAML recovered from BAML above
|
||||
// all. Files the project does not list are files the exported project cannot build.
|
||||
foreach (var group in files.Where(t => !ItemTypesWrittenElsewhere.Contains(t.ItemType))
|
||||
.GroupBy(t => t.ItemType).OrderBy(g => g.Key, StringComparer.Ordinal))
|
||||
{
|
||||
var items = group.OrderBy(t => t.FileName, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
// remove phase: an SDK glob may already have claimed these files - a WPF project
|
||||
// (UseWPF) globs **/*.xaml into Page - and the same file in an item type twice is
|
||||
// a build error. Removing first, in the same item group, keeps the explicit item
|
||||
// with its metadata and drops the globbed one.
|
||||
foreach (var item in items)
|
||||
{
|
||||
xml.WriteStartElement(group.Key);
|
||||
xml.WriteAttributeString("Remove", item.FileName);
|
||||
xml.WriteEndElement();
|
||||
}
|
||||
|
||||
// include phase
|
||||
foreach (var item in items)
|
||||
{
|
||||
xml.WriteStartElement(group.Key);
|
||||
xml.WriteAttributeString("Include", item.FileName);
|
||||
if (item.AdditionalProperties != null)
|
||||
{
|
||||
foreach (var (key, value) in item.AdditionalProperties)
|
||||
xml.WriteAttributeString(key, value);
|
||||
}
|
||||
xml.WriteEndElement();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void WriteReferences(XmlTextWriter xml, MetadataFile module, IProjectInfoProvider project)
|
||||
|
||||
@@ -20,6 +20,7 @@ using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection.Metadata;
|
||||
@@ -151,6 +152,7 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler
|
||||
|
||||
// per-run members
|
||||
HashSet<string> directories = new HashSet<string>(Platform.FileNameComparer);
|
||||
readonly Dictionary<string, int> resourceFileNames = new Dictionary<string, int>(Platform.FileNameComparer);
|
||||
readonly List<DecompilerException> errors = new List<DecompilerException>();
|
||||
readonly IProjectFileWriter projectWriter;
|
||||
|
||||
@@ -256,6 +258,7 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler
|
||||
{
|
||||
TargetDirectory = targetDirectory;
|
||||
directories.Clear();
|
||||
resourceFileNames.Clear();
|
||||
errors.Clear();
|
||||
var resources = RecordingErrors(WriteResourceFilesInProject(file), file, "resource files").ToList();
|
||||
resourceFileCount = resources.Count;
|
||||
@@ -539,20 +542,27 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler
|
||||
var resourcesFile = new ResourcesFile(stream);
|
||||
if (resourcesFile.AllEntriesAreStreams())
|
||||
{
|
||||
bool entryNamesAreEscaped = IsWpfGeneratedResourceContainer(r.Name);
|
||||
foreach (var (name, value) in resourcesFile)
|
||||
{
|
||||
string fileName = SanitizeFileName(name);
|
||||
string fileName = ReserveResourceFileName(SanitizeFileName(entryNamesAreEscaped ? Uri.UnescapeDataString(name) : name));
|
||||
string? dirName = Path.GetDirectoryName(fileName);
|
||||
if (!string.IsNullOrEmpty(dirName) && directories.Add(dirName))
|
||||
{
|
||||
CreateDirectory(Path.Combine(TargetDirectory, dirName));
|
||||
}
|
||||
Stream entryStream = (Stream)value!;
|
||||
entryStream.Position = 0;
|
||||
try
|
||||
{
|
||||
individualResources.AddRange(
|
||||
WriteResourceToFile(fileName, name, entryStream));
|
||||
// Inside the recovery, because an entry named after a directory another
|
||||
// entry needs makes this throw, and that must cost the one entry rather
|
||||
// than every entry left in the container.
|
||||
if (!string.IsNullOrEmpty(dirName) && !directories.Contains(dirName))
|
||||
{
|
||||
CreateDirectory(Path.Combine(TargetDirectory, dirName));
|
||||
directories.Add(dirName);
|
||||
}
|
||||
foreach (var item in WriteResourceToFile(fileName, name, entryStream))
|
||||
{
|
||||
individualResources.Add(entryNamesAreEscaped ? ToWpfProjectItem(item, name) : item);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (!(ex is OperationCanceledException))
|
||||
{
|
||||
@@ -587,7 +597,7 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler
|
||||
else
|
||||
{
|
||||
stream.Position = 0;
|
||||
string fileName = GetFileNameForResource(r.Name);
|
||||
string fileName = ReserveResourceFileName(GetFileNameForResource(r.Name));
|
||||
foreach (var entry in WriteResourceToFile(fileName, r.Name, stream))
|
||||
{
|
||||
yield return entry;
|
||||
@@ -596,7 +606,7 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler
|
||||
}
|
||||
else
|
||||
{
|
||||
string fileName = GetFileNameForResource(r.Name);
|
||||
string fileName = ReserveResourceFileName(GetFileNameForResource(r.Name));
|
||||
using (FileStream fs = new FileStream(Path.Combine(TargetDirectory, fileName), FileMode.Create, FileAccess.Write))
|
||||
{
|
||||
stream.Position = 0;
|
||||
@@ -639,6 +649,104 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler
|
||||
return new[] { new ProjectItemInfo("EmbeddedResource", fileName).With("LogicalName", resourceName) };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adjusts a project item produced from an entry of a WPF-generated ".g.resources" container
|
||||
/// so that rebuilding the exported project puts the entry back under its original resource
|
||||
/// ID. The file on disk cannot carry that ID - it is sanitized, and the ID is escaped - so
|
||||
/// the item pins it with a LogicalName holding the decoded name: the WPF build tasks
|
||||
/// lower-case and escape the LogicalName again, arriving back at the original ID. Entries
|
||||
/// that no handler turned into some other item type are WPF Resource items; leaving them as
|
||||
/// EmbeddedResource would rebuild them into a manifest resource of their own instead of
|
||||
/// putting them into ".g.resources".
|
||||
/// </summary>
|
||||
static ProjectItemInfo ToWpfProjectItem(ProjectItemInfo item, string escapedEntryName)
|
||||
{
|
||||
string logicalName = Uri.UnescapeDataString(escapedEntryName);
|
||||
if (item.FileName.EndsWith(".xaml", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// A decompiled BAML page is written as .xaml, and the build derives the .baml
|
||||
// extension of the resource ID from the Page item type, not from the LogicalName.
|
||||
logicalName = Path.ChangeExtension(logicalName, ".xaml");
|
||||
}
|
||||
var result = item with {
|
||||
ItemType = item.ItemType == "EmbeddedResource" ? "Resource" : item.ItemType
|
||||
};
|
||||
result.AdditionalProperties ??= new Dictionary<string, string>();
|
||||
result.AdditionalProperties["LogicalName"] = logicalName;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Claims <paramref name="fileName"/> for one resource, appending "_2", "_3", ... until the
|
||||
/// name is free. Sanitizing is not injective - "a+b/logo.png" and "a&b/logo.png" both come out
|
||||
/// as "a-b/logo.png" - and the writers create files with FileMode.Create, so without this the
|
||||
/// entries after the first are lost silently, and an assembly can be built to make that happen
|
||||
/// to as many of them as it likes. The exported project keeps the true name in the item's
|
||||
/// LogicalName, so the file on disk only has to be unique, not faithful.
|
||||
/// </summary>
|
||||
string ReserveResourceFileName(string fileName)
|
||||
{
|
||||
if (!resourceFileNames.TryGetValue(fileName, out int lastSuffix))
|
||||
{
|
||||
resourceFileNames.Add(fileName, 1);
|
||||
return fileName;
|
||||
}
|
||||
// Resuming the count where the last collision on this name left off keeps the export
|
||||
// linear in the number of colliding entries; restarting at 2 each time would make it
|
||||
// quadratic, which is worth something when the count is the assembly's to choose.
|
||||
string candidate;
|
||||
do
|
||||
{
|
||||
candidate = AppendFileNameSuffix(fileName, ++lastSuffix);
|
||||
}
|
||||
while (resourceFileNames.ContainsKey(candidate));
|
||||
resourceFileNames[fileName] = lastSuffix;
|
||||
resourceFileNames.Add(candidate, 1);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts "_<paramref name="suffix"/>" before the extension, trimming the name if the segment
|
||||
/// would otherwise outgrow what the file system takes - a name already at the limit is an
|
||||
/// ordinary thing to find in an assembly, and the write would throw.
|
||||
/// </summary>
|
||||
static string AppendFileNameSuffix(string fileName, int suffix)
|
||||
{
|
||||
string directory = Path.GetDirectoryName(fileName) ?? string.Empty;
|
||||
string name = Path.GetFileNameWithoutExtension(fileName);
|
||||
string extension = Path.GetExtension(fileName);
|
||||
string marker = "_" + suffix.ToString(CultureInfo.InvariantCulture);
|
||||
// Trimming whole characters removes at least as many bytes as it has to, so measuring the
|
||||
// overflow the way CleanUpName measures a segment cannot leave the result over the limit.
|
||||
int overflow = SegmentLength(name) + SegmentLength(marker) + SegmentLength(extension) - maxSegmentLength;
|
||||
if (overflow > 0)
|
||||
name = name.Substring(0, Math.Max(0, name.Length - overflow));
|
||||
return Path.Combine(directory, name + marker + extension);
|
||||
}
|
||||
|
||||
static int SegmentLength(string text)
|
||||
{
|
||||
return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? text.Length : Encoding.UTF8.GetByteCount(text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WPF's build tasks put every Page and Resource item into "<AssemblyName>.g.resources"
|
||||
/// (and into "<AssemblyName>.g.<culture>.resources" in satellite assemblies), keyed by
|
||||
/// the item's relative path, lower-cased and URI-escaped: a folder named "My Images" becomes
|
||||
/// "my%20images". Those escapes are not part of the name and have to be decoded before the
|
||||
/// name is turned into a file name, otherwise the percent sign is sanitized away and
|
||||
/// "my%20images/logo.png" lands in a directory called "my-20images".
|
||||
/// </summary>
|
||||
static bool IsWpfGeneratedResourceContainer(string resourceName)
|
||||
{
|
||||
const string extension = ".resources";
|
||||
if (!resourceName.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
string name = resourceName.Substring(0, resourceName.Length - extension.Length);
|
||||
return name.EndsWith(".g", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.Substring(0, Math.Max(0, name.LastIndexOf('.'))).EndsWith(".g", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
string GetFileNameForResource(string fullName)
|
||||
{
|
||||
// Clean up the name first and ensure the length does not exceed the maximum length
|
||||
|
||||
Reference in New Issue
Block a user