diff --git a/ICSharpCode.Decompiler.Tests/Util/AvaloniaResourcesFileTests.cs b/ICSharpCode.Decompiler.Tests/Util/AvaloniaResourcesFileTests.cs
new file mode 100644
index 000000000..f4a3da7cf
--- /dev/null
+++ b/ICSharpCode.Decompiler.Tests/Util/AvaloniaResourcesFileTests.cs
@@ -0,0 +1,269 @@
+// 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.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+
+using ICSharpCode.Decompiler.Util;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.Decompiler.Tests.Util
+{
+ [TestFixture]
+ public class AvaloniaResourcesFileTests
+ {
+ [Test]
+ public void ReadsEntriesWithRootedPathsAndPayloads()
+ {
+ var blob = BuildBlob(
+ ("/App.axaml", Encoding.UTF8.GetBytes("")),
+ ("Views/MainWindow.axaml", Encoding.UTF8.GetBytes("")),
+ ("/assets/logo.png", new byte[] { 0x89, 0x50, 0x4E, 0x47 }));
+
+ var entries = new AvaloniaResourcesFile(new MemoryStream(blob)).ToList();
+
+ Assert.That(entries.Select(e => e.Key), Is.EqualTo(new[] {
+ "/App.axaml",
+ // A path without a leading slash is rooted, matching Avalonia's AssetLoader.
+ "/Views/MainWindow.axaml",
+ "/assets/logo.png",
+ }));
+ Assert.That(entries[0].Value, Is.EqualTo(Encoding.UTF8.GetBytes("")));
+ Assert.That(entries[1].Value, Is.EqualTo(Encoding.UTF8.GetBytes("")));
+ Assert.That(entries[2].Value, Is.EqualTo(new byte[] { 0x89, 0x50, 0x4E, 0x47 }));
+ }
+
+ [Test]
+ public void EmptyResourceTableYieldsNoEntries()
+ {
+ var blob = BuildBlob();
+ var entries = new AvaloniaResourcesFile(new MemoryStream(blob)).ToList();
+ Assert.That(entries, Is.Empty);
+ }
+
+ [Test]
+ public void SharedDataSectionIsReadPerEntry()
+ {
+ // The build task deduplicates identical files, so two paths can point at the same
+ // (offset, size) range in the data section. Both must read the shared bytes.
+ byte[] shared = Encoding.UTF8.GetBytes("dup");
+ var data = new MemoryStream();
+ data.Write(shared);
+ var index = new List<(string Path, int Offset, int Size)> {
+ ("/a.txt", 0, shared.Length),
+ ("/b.txt", 0, shared.Length),
+ };
+ var blob = Assemble(index, data.ToArray());
+
+ var entries = new AvaloniaResourcesFile(new MemoryStream(blob)).ToList();
+
+ Assert.That(entries.Select(e => e.Key), Is.EqualTo(new[] { "/a.txt", "/b.txt" }));
+ Assert.That(entries[0].Value, Is.EqualTo(shared));
+ Assert.That(entries[1].Value, Is.EqualTo(shared));
+ }
+
+ [Test]
+ public void AliasedEntriesShareOneBufferInsteadOfAmplifying()
+ {
+ // Many entries legitimately aliasing one deduplicated slice must not materialize a
+ // separate copy each: N entries over one D-byte slice cost O(D), not O(N * D). A crafted
+ // index exploits per-entry copies to amplify a small file into a huge allocation.
+ const int slice = 1024 * 1024;
+ byte[] data = new byte[slice];
+ var index = Enumerable.Range(0, 500)
+ .Select(i => ($"/f{i}", 0, slice))
+ .ToList<(string, int, int)>();
+ byte[] blob = Assemble(index, data);
+
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ var entries = new AvaloniaResourcesFile(new MemoryStream(blob)).ToList();
+ long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+
+ Assert.That(entries, Has.Count.EqualTo(500));
+ // Per-entry copies would be 500 * 1 MB; sharing the single slice keeps it near one copy.
+ Assert.That(allocated, Is.LessThan(16 * 1024 * 1024),
+ $"parser allocated {allocated / (1024 * 1024)} MB materializing aliased entries");
+ }
+
+ [Test]
+ public void OverlappingDataRangesAreRejected()
+ {
+ // Honest entries reference non-overlapping regions that cannot total more than the data
+ // section. Distinct, overlapping ranges that sum past it are the amplification vector and
+ // must be rejected, not copied.
+ byte[] data = new byte[1000];
+ var index = new List<(string Path, int Offset, int Size)> {
+ ("/a", 0, 1000),
+ ("/b", 0, 999), // distinct range overlapping the first; total 1999 > 1000
+ };
+ byte[] blob = Assemble(index, data);
+
+ Assert.Throws(() => new AvaloniaResourcesFile(new MemoryStream(blob)).ToList());
+ }
+
+ [Test]
+ public void UnsupportedVersionThrowsBadImageFormat()
+ {
+ var ms = new MemoryStream();
+ using (var bw = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true))
+ {
+ bw.Write(0); // index length placeholder
+ bw.Write(1); // legacy XML index version, unsupported
+ bw.Write(0); // entry count
+ }
+ PatchIndexLength(ms);
+
+ Assert.Throws(() => new AvaloniaResourcesFile(ms).ToList());
+ }
+
+ [Test]
+ public void HugeEntryCountDoesNotPreallocate()
+ {
+ // The entry count is attacker-controlled and must never drive the entry-array
+ // allocation. A tiny crafted index that claims 100 million entries has to be rejected
+ // as malformed without first reserving the ~1.6 GB such a count would otherwise pre-size.
+ var ms = new MemoryStream();
+ using (var bw = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true))
+ {
+ bw.Write(0); // index length placeholder, patched below
+ bw.Write(2); // BinaryCurrentVersion
+ bw.Write(100_000_000); // absurd entry count with no entries to back it
+ }
+ PatchIndexLength(ms);
+ byte[] blob = ms.ToArray();
+
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ Assert.Throws(() => new AvaloniaResourcesFile(new MemoryStream(blob)).ToList());
+ long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+
+ // A count-driven pre-allocation would be 100_000_000 * sizeof(reference pair); the parser
+ // must instead bound the reservation to what the few-byte index can actually hold.
+ Assert.That(allocated, Is.LessThan(16 * 1024 * 1024),
+ $"parser allocated {allocated / (1024 * 1024)} MB for a {blob.Length}-byte index; the count field drove the allocation");
+ }
+
+ [Test]
+ public void EntrySpillingPastIndexIntoDataSectionIsRejected()
+ {
+ // An entry whose path string starts inside the index but runs past the declared index
+ // end would otherwise silently read its remaining bytes (and the offset/size fields
+ // after it) out of the data section. The index boundary must hold for every byte of
+ // every entry, not just at entry starts.
+ var ms = new MemoryStream();
+ using (var bw = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true))
+ {
+ bw.Write(0); // index length, patched below
+ bw.Write(2); // BinaryCurrentVersion
+ bw.Write(1); // entry count
+ bw.Write("/a-path-longer-than-the-declared-index");
+ bw.Write(0); // offset
+ bw.Write(0); // size
+ }
+ byte[] blob = ms.ToArray();
+ // Declare the index to end in the middle of the path string; the bytes after that
+ // point are data-section bytes that happen to parse as the rest of the entry.
+ BitConverter.GetBytes(20).CopyTo(blob, 0);
+
+ Assert.Throws(() => new AvaloniaResourcesFile(new MemoryStream(blob)).ToList());
+ }
+
+ [Test]
+ public void HugeClaimedPathLengthIsRejectedWithoutHugeAllocation()
+ {
+ // The 7-bit length prefix of a path string is attacker-controlled; a prefix claiming a
+ // ~256 MB path backed by only a few real bytes must fail as malformed without the
+ // claimed length driving any allocation.
+ var ms = new MemoryStream();
+ using (var bw = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true))
+ {
+ bw.Write(0); // index length, patched below
+ bw.Write(2); // BinaryCurrentVersion
+ bw.Write(1); // entry count
+ ms.Write(new byte[] { 0xFF, 0xFF, 0xFF, 0x7F }); // 7-bit-encoded length 0x0FFFFFFF
+ ms.Write(new byte[] { (byte)'/', (byte)'a' }); // far fewer bytes than claimed
+ }
+ PatchIndexLength(ms);
+ byte[] blob = ms.ToArray();
+
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ Assert.Throws(() => new AvaloniaResourcesFile(new MemoryStream(blob)).ToList());
+ long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+
+ Assert.That(allocated, Is.LessThan(1024 * 1024),
+ $"parser allocated {allocated} bytes for a {blob.Length}-byte blob; the claimed path length drove the allocation");
+ }
+
+ [Test]
+ public void OutOfBoundsEntryThrowsBadImageFormat()
+ {
+ // A crafted index whose (offset, size) runs past the data section must be rejected,
+ // not read out of bounds.
+ var index = new List<(string Path, int Offset, int Size)> {
+ ("/evil", 0, 1024),
+ };
+ var blob = Assemble(index, new byte[] { 1, 2, 3 });
+
+ Assert.Throws(() => new AvaloniaResourcesFile(new MemoryStream(blob)).ToList());
+ }
+
+ static byte[] BuildBlob(params (string Path, byte[] Data)[] files)
+ {
+ var data = new MemoryStream();
+ var index = new List<(string Path, int Offset, int Size)>();
+ foreach (var (path, bytes) in files)
+ {
+ index.Add((path, (int)data.Position, bytes.Length));
+ data.Write(bytes);
+ }
+ return Assemble(index, data.ToArray());
+ }
+
+ static byte[] Assemble(List<(string Path, int Offset, int Size)> index, byte[] data)
+ {
+ var ms = new MemoryStream();
+ using (var bw = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true))
+ {
+ bw.Write(0); // index length placeholder, patched below
+ bw.Write(2); // BinaryCurrentVersion
+ bw.Write(index.Count);
+ foreach (var (path, offset, size) in index)
+ {
+ bw.Write(path);
+ bw.Write(offset);
+ bw.Write(size);
+ }
+ }
+ PatchIndexLength(ms);
+ ms.Position = ms.Length;
+ ms.Write(data);
+ return ms.ToArray();
+ }
+
+ static void PatchIndexLength(MemoryStream ms)
+ {
+ int indexLength = (int)(ms.Length - 4);
+ ms.Position = 0;
+ using var bw = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true);
+ bw.Write(indexLength);
+ }
+ }
+}
diff --git a/ICSharpCode.Decompiler/Util/AvaloniaResourcesFile.cs b/ICSharpCode.Decompiler/Util/AvaloniaResourcesFile.cs
new file mode 100644
index 000000000..e7ba7440b
--- /dev/null
+++ b/ICSharpCode.Decompiler/Util/AvaloniaResourcesFile.cs
@@ -0,0 +1,164 @@
+#nullable enable
+// 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.Collections;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+
+namespace ICSharpCode.Decompiler.Util
+{
+ ///
+ /// Reader for the !AvaloniaResources manifest resource that the Avalonia UI framework
+ /// embeds in compiled assemblies. The blob packs every Avalonia resource (compiled XAML,
+ /// images, ...) behind a single index, much like a .resources file packs a managed
+ /// resource set.
+ ///
+ ///
+ /// Layout (all integers little-endian):
+ ///
+ /// - Int32 indexLength: byte length of the index section that follows.
+ /// - Index section: Int32 version (must be 2), Int32 entryCount, then per entry a
+ /// 7-bit length-prefixed UTF-8 path, Int32 offset and Int32 size.
+ /// - Data section: concatenated file bytes; each entry's offset is relative to the
+ /// start of this section (i.e. to indexLength + 4).
+ ///
+ ///
+ public sealed class AvaloniaResourcesFile : IEnumerable>
+ {
+ ///
+ /// Name of the manifest resource that holds the packed Avalonia resources.
+ ///
+ public const string ResourceName = "!AvaloniaResources";
+
+ // Index format version written by Avalonia's AvaloniaResourcesIndexReaderWriter. Version 1
+ // was a legacy XML index that this reader does not support.
+ const int BinaryCurrentVersion = 2;
+
+ readonly List> entries = new();
+
+ ///
+ /// Returns if is the manifest
+ /// resource name Avalonia uses for its packed resources.
+ ///
+ public static bool IsAvaloniaResourcesEntry(string resourceName)
+ => resourceName == ResourceName;
+
+ ///
+ /// Parses the !AvaloniaResources blob read from .
+ ///
+ /// The blob is truncated, uses an unsupported
+ /// index version, or contains an entry that points outside the data section.
+ public AvaloniaResourcesFile(Stream stream)
+ {
+ if (stream == null)
+ throw new ArgumentNullException(nameof(stream));
+
+ byte[] blob;
+ using (var buffer = new MemoryStream())
+ {
+ stream.CopyTo(buffer);
+ blob = buffer.ToArray();
+ }
+
+ try
+ {
+ Parse(blob);
+ }
+ catch (EndOfStreamException ex)
+ {
+ throw new BadImageFormatException("Truncated !AvaloniaResources index.", ex);
+ }
+ }
+
+ void Parse(byte[] blob)
+ {
+ using var reader = new BinaryReader(new MemoryStream(blob), Encoding.UTF8);
+
+ int indexLength = reader.ReadInt32();
+ // The data section begins right after the 4-byte length prefix and the index.
+ long baseOffset = 4L + indexLength;
+ if (indexLength < 0 || baseOffset > blob.Length)
+ throw new BadImageFormatException("Invalid !AvaloniaResources index length.");
+
+ int version = reader.ReadInt32();
+ if (version != BinaryCurrentVersion)
+ throw new BadImageFormatException($"Unsupported !AvaloniaResources index version {version}.");
+
+ int count = reader.ReadInt32();
+ if (count < 0)
+ throw new BadImageFormatException("Invalid !AvaloniaResources entry count.");
+
+ // The count is attacker-controlled, so it must not be trusted as an allocation size: an
+ // honest entry occupies at least a 1-byte path length prefix plus two Int32s, so the index
+ // section cannot hold more than this many entries. A lying count is left to the per-entry
+ // spill guard below instead of forcing a huge pre-allocation.
+ const int MinEntrySize = 1 + 4 + 4;
+ long maxEntries = (baseOffset - reader.BaseStream.Position) / MinEntrySize;
+ entries.Capacity = (int)Math.Min(count, Math.Max(0, maxEntries));
+
+ // The build task deduplicates identical files, so many entries can legitimately alias one
+ // (offset, size) range in the data section; those share a single buffer here rather than
+ // each copying the bytes. Distinct ranges in an honest index are non-overlapping and thus
+ // cannot total more than the data section they occupy, so the sum of copied bytes is
+ // bounded by the data section length. Rejecting anything larger stops a crafted index from
+ // amplifying a small file into a huge allocation via overlapping, per-entry-copied slices.
+ long dataSectionLength = blob.Length - baseOffset;
+ long copiedBytes = 0;
+ var buffersByRange = new Dictionary();
+ for (int i = 0; i < count; i++)
+ {
+ string path = GetPathRooted(reader.ReadString());
+ int offset = reader.ReadInt32();
+ int size = reader.ReadInt32();
+
+ // Every byte of every entry must come from the index section: an entry that runs
+ // past the declared index end would otherwise silently consume data-section bytes.
+ // This also rejects a count claiming more entries than the index holds.
+ if (reader.BaseStream.Position > baseOffset)
+ throw new BadImageFormatException("Malformed !AvaloniaResources index.");
+
+ if (offset < 0 || size < 0 || baseOffset + offset + size > blob.Length)
+ throw new BadImageFormatException($"!AvaloniaResources entry '{path}' points outside the data section.");
+
+ long rangeKey = ((long)offset << 32) | (uint)size;
+ if (!buffersByRange.TryGetValue(rangeKey, out var data))
+ {
+ copiedBytes += size;
+ if (copiedBytes > dataSectionLength)
+ throw new BadImageFormatException("!AvaloniaResources index references more data than the data section contains.");
+ data = new byte[size];
+ Buffer.BlockCopy(blob, (int)baseOffset + offset, data, 0, size);
+ buffersByRange[rangeKey] = data;
+ }
+ entries.Add(new KeyValuePair(path, data));
+ }
+ }
+
+ // Avalonia's AssetLoader keys resources by a path with a leading slash; a path stored
+ // without one is rooted on read so the keys match what Avalonia itself resolves.
+ static string GetPathRooted(string path)
+ => path.Length == 0 || path[0] != '/' ? "/" + path : path;
+
+ public IEnumerator> GetEnumerator() => entries.GetEnumerator();
+
+ IEnumerator IEnumerable.GetEnumerator() => entries.GetEnumerator();
+ }
+}
diff --git a/ILSpy.Tests/Resources/ResourceFactoryTests.cs b/ILSpy.Tests/Resources/ResourceFactoryTests.cs
index fb37ee720..08f1c89d0 100644
--- a/ILSpy.Tests/Resources/ResourceFactoryTests.cs
+++ b/ILSpy.Tests/Resources/ResourceFactoryTests.cs
@@ -62,6 +62,7 @@ public class ResourceFactoryTests
[TestCase("favicon.ico")]
[TestCase("pointer.cur")]
[TestCase("strings.resources")]
+ [TestCase("!AvaloniaResources")]
public void Typed_Resource_Names_Route_To_Specialised_Node(string name)
{
// Each known resource extension (.xsd/.xml/.png/.bmp/.ico/.cur/.resources/...) has a
@@ -184,6 +185,57 @@ public class ResourceFactoryTests
types[1].Patterns.Should().BeEquivalentTo(new[] { "*.resx" });
}
+ [AvaloniaTest]
+ public void AvaloniaResources_Unpacks_Packed_Files_Into_Child_Nodes()
+ {
+ // The !AvaloniaResources blob packs every Avalonia resource behind a single index. The
+ // node must unpack it into one child per packed file (sorted naturally) so each file can
+ // be viewed and saved on its own, mirroring how .resources files are unpacked.
+
+ // Arrange — boot composition; build a blob with three packed files (deliberately out of
+ // order to exercise the natural sort) and dispatch it.
+ EnsureComposition();
+ var node = (AvaloniaResourcesFileTreeNode)ResourceEntryNode.Create(
+ new ByteArrayResource("!AvaloniaResources", BuildAvaloniaResources(
+ ("/Views/MainWindow.axaml", Encoding.UTF8.GetBytes("")),
+ ("/App.axaml", Encoding.UTF8.GetBytes("")),
+ ("/assets/logo.png", new byte[] { 0x89, 0x50, 0x4E, 0x47 }))));
+
+ // Act — force the lazy children to load.
+ node.EnsureLazyChildren();
+
+ // Assert — one child per packed file, keyed by the rooted path and naturally sorted.
+ var names = node.Children.Cast().Select(c => c.Text.ToString()).ToList();
+ names.Should().Equal("/App.axaml", "/assets/logo.png", "/Views/MainWindow.axaml");
+ }
+
+ static byte[] BuildAvaloniaResources(params (string Path, byte[] Data)[] files)
+ {
+ var ms = new MemoryStream();
+ var data = new MemoryStream();
+ using (var bw = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true))
+ {
+ bw.Write(0); // index length placeholder, patched below
+ bw.Write(2); // BinaryCurrentVersion
+ bw.Write(files.Length);
+ foreach (var (path, bytes) in files)
+ {
+ bw.Write(path);
+ bw.Write((int)data.Position);
+ bw.Write(bytes.Length);
+ data.Write(bytes, 0, bytes.Length);
+ }
+ }
+ int indexLength = (int)(ms.Length - 4);
+ ms.Position = 0;
+ using (var bw = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true))
+ bw.Write(indexLength);
+ ms.Position = ms.Length;
+ data.Position = 0;
+ data.CopyTo(ms);
+ return ms.ToArray();
+ }
+
static byte[] BuildResources((string Key, object Value)[] entries)
{
var ms = new MemoryStream();
diff --git a/ILSpy/TreeNodes/AvaloniaResourcesFileTreeNode.cs b/ILSpy/TreeNodes/AvaloniaResourcesFileTreeNode.cs
new file mode 100644
index 000000000..b7b3adc26
--- /dev/null
+++ b/ILSpy/TreeNodes/AvaloniaResourcesFileTreeNode.cs
@@ -0,0 +1,74 @@
+// 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.Composition;
+using System.IO;
+using System.Linq;
+
+using ICSharpCode.Decompiler.Metadata;
+using ICSharpCode.Decompiler.Util;
+using ICSharpCode.ILSpyX.Abstractions;
+
+namespace ICSharpCode.ILSpy.TreeNodes
+{
+ [Export(typeof(IResourceNodeFactory))]
+ [Shared]
+ sealed class AvaloniaResourcesFileTreeNodeFactory : IResourceNodeFactory
+ {
+ public ITreeNode? CreateNode(Resource resource)
+ {
+ if (AvaloniaResourcesFile.IsAvaloniaResourcesEntry(resource.Name))
+ return new AvaloniaResourcesFileTreeNode(resource);
+ return null;
+ }
+ }
+
+ ///
+ /// The !AvaloniaResources manifest resource an Avalonia application embeds. It packs
+ /// every Avalonia resource (compiled XAML, images, ...) behind a single index; each packed
+ /// file is unpacked into a child that can be viewed and saved
+ /// individually.
+ ///
+ public sealed class AvaloniaResourcesFileTreeNode : ResourceTreeNode
+ {
+ public AvaloniaResourcesFileTreeNode(Resource r) : base(r)
+ {
+ LazyLoading = true;
+ }
+
+ public override object Icon => Images.ResourceResourcesFile;
+
+ protected override void LoadChildren()
+ {
+ // Unlike ResourcesFile, AvaloniaResourcesFile copies everything out of the stream in
+ // its constructor, so the stream can be disposed as soon as parsing is done.
+ using var s = Resource.TryOpenStream();
+ if (s == null)
+ return;
+ s.Position = 0;
+ try
+ {
+ foreach (var entry in new AvaloniaResourcesFile(s).OrderBy(e => e.Key, NaturalStringComparer.Instance))
+ Children.Add(ResourceEntryNode.Create(entry.Key, entry.Value));
+ }
+ catch (BadImageFormatException) { /* malformed — ignore */ }
+ catch (EndOfStreamException) { /* truncated — ignore */ }
+ }
+ }
+}