Browse Source

Unpack !AvaloniaResources into per-file resource tree nodes

Avalonia apps pack every resource (compiled XAML, image/SVG assets, ...)
behind a single !AvaloniaResources manifest blob, which until now fell
through to the generic resource node and could not be browsed. Parse the
blob's index and expose each packed file as its own entry, mirroring how
.resources files are unpacked, so individual files can be viewed and
saved. The reader is bounds-checked against crafted offsets/sizes in the
same defensive spirit as the recent .rsrc parsing guards.

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3829/head
Siegfried Pammer 1 week ago committed by Siegfried Pammer
parent
commit
9b993522e2
  1. 269
      ICSharpCode.Decompiler.Tests/Util/AvaloniaResourcesFileTests.cs
  2. 164
      ICSharpCode.Decompiler/Util/AvaloniaResourcesFile.cs
  3. 52
      ILSpy.Tests/Resources/ResourceFactoryTests.cs
  4. 74
      ILSpy/TreeNodes/AvaloniaResourcesFileTreeNode.cs

269
ICSharpCode.Decompiler.Tests/Util/AvaloniaResourcesFileTests.cs

@ -0,0 +1,269 @@ @@ -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("<Application/>")),
("Views/MainWindow.axaml", Encoding.UTF8.GetBytes("<Window/>")),
("/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("<Application/>")));
Assert.That(entries[1].Value, Is.EqualTo(Encoding.UTF8.GetBytes("<Window/>")));
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<BadImageFormatException>(() => 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<BadImageFormatException>(() => 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<BadImageFormatException>(() => 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<BadImageFormatException>(() => 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<BadImageFormatException>(() => 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<BadImageFormatException>(() => 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);
}
}
}

164
ICSharpCode.Decompiler/Util/AvaloniaResourcesFile.cs

@ -0,0 +1,164 @@ @@ -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
{
/// <summary>
/// Reader for the <c>!AvaloniaResources</c> 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 <c>.resources</c> file packs a managed
/// resource set.
/// </summary>
/// <remarks>
/// Layout (all integers little-endian):
/// <list type="bullet">
/// <item><description>Int32 indexLength: byte length of the index section that follows.</description></item>
/// <item><description>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.</description></item>
/// <item><description>Data section: concatenated file bytes; each entry's offset is relative to the
/// start of this section (i.e. to <c>indexLength + 4</c>).</description></item>
/// </list>
/// </remarks>
public sealed class AvaloniaResourcesFile : IEnumerable<KeyValuePair<string, byte[]>>
{
/// <summary>
/// Name of the manifest resource that holds the packed Avalonia resources.
/// </summary>
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<KeyValuePair<string, byte[]>> entries = new();
/// <summary>
/// Returns <see langword="true"/> if <paramref name="resourceName"/> is the manifest
/// resource name Avalonia uses for its packed resources.
/// </summary>
public static bool IsAvaloniaResourcesEntry(string resourceName)
=> resourceName == ResourceName;
/// <summary>
/// Parses the <c>!AvaloniaResources</c> blob read from <paramref name="stream"/>.
/// </summary>
/// <exception cref="BadImageFormatException">The blob is truncated, uses an unsupported
/// index version, or contains an entry that points outside the data section.</exception>
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<long, byte[]>();
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<string, byte[]>(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<KeyValuePair<string, byte[]>> GetEnumerator() => entries.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => entries.GetEnumerator();
}
}

52
ILSpy.Tests/Resources/ResourceFactoryTests.cs

@ -62,6 +62,7 @@ public class ResourceFactoryTests @@ -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 @@ -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("<Window/>")),
("/App.axaml", Encoding.UTF8.GetBytes("<Application/>")),
("/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<ResourceEntryNode>().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();

74
ILSpy/TreeNodes/AvaloniaResourcesFileTreeNode.cs

@ -0,0 +1,74 @@ @@ -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;
}
}
/// <summary>
/// The <c>!AvaloniaResources</c> 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 <see cref="ResourceEntryNode"/> child that can be viewed and saved
/// individually.
/// </summary>
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 */ }
}
}
}
Loading…
Cancel
Save