From 04f9c0b5b6ea217e0df3fb47fc7ad2633e01b130 Mon Sep 17 00:00:00 2001 From: Rustam Sayfutdinov Date: Fri, 18 Mar 2022 15:13:57 +0300 Subject: [PATCH] Fix getting paths from Uri --- Avalonia.sln | 27 ++ src/Avalonia.Base/Properties/AssemblyInfo.cs | 1 + src/Avalonia.Base/Utilities/UriExtensions.cs | 70 +++++ src/Avalonia.PlatformSupport/AssetLoader.cs | 241 +++--------------- .../Avalonia.PlatformSupport.csproj | 5 + .../Internal/AssemblyDescriptor.cs | 42 +++ .../Internal/AssemblyDescriptorResolver.cs | 42 +++ .../Internal/AssetDescriptor.cs | 52 ++++ .../Internal/Constants.cs | 6 + .../Internal/SlicedStream.cs | 59 +++++ .../Media/Fonts/FontFamilyLoader.cs | 150 +++++++---- .../Utilities/UriExtensionsTests.cs | 29 +++ .../AssetLoaderTests.cs | 62 +++++ .../Avalonia.PlatformSupport.UnitTests.csproj | 24 ++ tests/Avalonia.UnitTests/MockAssetLoader.cs | 5 +- .../Media/Fonts/FontFamilyLoaderTests.cs | 45 +++- 16 files changed, 583 insertions(+), 277 deletions(-) create mode 100644 src/Avalonia.Base/Utilities/UriExtensions.cs create mode 100644 src/Avalonia.PlatformSupport/Internal/AssemblyDescriptor.cs create mode 100644 src/Avalonia.PlatformSupport/Internal/AssemblyDescriptorResolver.cs create mode 100644 src/Avalonia.PlatformSupport/Internal/AssetDescriptor.cs create mode 100644 src/Avalonia.PlatformSupport/Internal/Constants.cs create mode 100644 src/Avalonia.PlatformSupport/Internal/SlicedStream.cs create mode 100644 tests/Avalonia.Base.UnitTests/Utilities/UriExtensionsTests.cs create mode 100644 tests/Avalonia.PlatformSupport.UnitTests/AssetLoaderTests.cs create mode 100644 tests/Avalonia.PlatformSupport.UnitTests/Avalonia.PlatformSupport.UnitTests.csproj diff --git a/Avalonia.sln b/Avalonia.sln index a989fb828d..a792774d94 100644 --- a/Avalonia.sln +++ b/Avalonia.sln @@ -234,6 +234,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.PlatformSupport", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ControlCatalog.iOS", "samples\ControlCatalog.iOS\ControlCatalog.iOS.csproj", "{70B9F5CC-E2F9-4314-9514-EDE762ACCC4B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.PlatformSupport.UnitTests", "tests\Avalonia.PlatformSupport.UnitTests\Avalonia.PlatformSupport.UnitTests.csproj", "{CE910927-CE5A-456F-BC92-E4C757354A5C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Ad-Hoc|Any CPU = Ad-Hoc|Any CPU @@ -2212,6 +2214,30 @@ Global {70B9F5CC-E2F9-4314-9514-EDE762ACCC4B}.Release|iPhone.Build.0 = Release|Any CPU {70B9F5CC-E2F9-4314-9514-EDE762ACCC4B}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU {70B9F5CC-E2F9-4314-9514-EDE762ACCC4B}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {CE910927-CE5A-456F-BC92-E4C757354A5C}.Ad-Hoc|Any CPU.ActiveCfg = Debug|Any CPU + {CE910927-CE5A-456F-BC92-E4C757354A5C}.Ad-Hoc|Any CPU.Build.0 = Debug|Any CPU + {CE910927-CE5A-456F-BC92-E4C757354A5C}.Ad-Hoc|iPhone.ActiveCfg = Debug|Any CPU + {CE910927-CE5A-456F-BC92-E4C757354A5C}.Ad-Hoc|iPhone.Build.0 = Debug|Any CPU + {CE910927-CE5A-456F-BC92-E4C757354A5C}.Ad-Hoc|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {CE910927-CE5A-456F-BC92-E4C757354A5C}.Ad-Hoc|iPhoneSimulator.Build.0 = Debug|Any CPU + {CE910927-CE5A-456F-BC92-E4C757354A5C}.AppStore|Any CPU.ActiveCfg = Debug|Any CPU + {CE910927-CE5A-456F-BC92-E4C757354A5C}.AppStore|Any CPU.Build.0 = Debug|Any CPU + {CE910927-CE5A-456F-BC92-E4C757354A5C}.AppStore|iPhone.ActiveCfg = Debug|Any CPU + {CE910927-CE5A-456F-BC92-E4C757354A5C}.AppStore|iPhone.Build.0 = Debug|Any CPU + {CE910927-CE5A-456F-BC92-E4C757354A5C}.AppStore|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {CE910927-CE5A-456F-BC92-E4C757354A5C}.AppStore|iPhoneSimulator.Build.0 = Debug|Any CPU + {CE910927-CE5A-456F-BC92-E4C757354A5C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CE910927-CE5A-456F-BC92-E4C757354A5C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CE910927-CE5A-456F-BC92-E4C757354A5C}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {CE910927-CE5A-456F-BC92-E4C757354A5C}.Debug|iPhone.Build.0 = Debug|Any CPU + {CE910927-CE5A-456F-BC92-E4C757354A5C}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {CE910927-CE5A-456F-BC92-E4C757354A5C}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {CE910927-CE5A-456F-BC92-E4C757354A5C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CE910927-CE5A-456F-BC92-E4C757354A5C}.Release|Any CPU.Build.0 = Release|Any CPU + {CE910927-CE5A-456F-BC92-E4C757354A5C}.Release|iPhone.ActiveCfg = Release|Any CPU + {CE910927-CE5A-456F-BC92-E4C757354A5C}.Release|iPhone.Build.0 = Release|Any CPU + {CE910927-CE5A-456F-BC92-E4C757354A5C}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {CE910927-CE5A-456F-BC92-E4C757354A5C}.Release|iPhoneSimulator.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -2276,6 +2302,7 @@ Global {26A98DA1-D89D-4A95-8152-349F404DA2E2} = {A0CC0258-D18C-4AB3-854F-7101680FC3F9} {A0D0A6A4-5C72-4ADA-9B27-621C7D94F270} = {9B9E3891-2366-4253-A952-D08BCEB71098} {70B9F5CC-E2F9-4314-9514-EDE762ACCC4B} = {9B9E3891-2366-4253-A952-D08BCEB71098} + {CE910927-CE5A-456F-BC92-E4C757354A5C} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {87366D66-1391-4D90-8999-95A620AD786A} diff --git a/src/Avalonia.Base/Properties/AssemblyInfo.cs b/src/Avalonia.Base/Properties/AssemblyInfo.cs index 053c7a7547..9ffb5872f0 100644 --- a/src/Avalonia.Base/Properties/AssemblyInfo.cs +++ b/src/Avalonia.Base/Properties/AssemblyInfo.cs @@ -11,4 +11,5 @@ using Avalonia.Metadata; [assembly: InternalsVisibleTo("Avalonia.Controls.DataGrid, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")] [assembly: InternalsVisibleTo("Avalonia.Markup.Xaml.UnitTests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")] [assembly: InternalsVisibleTo("Avalonia.Visuals, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")] +[assembly: InternalsVisibleTo("Avalonia.PlatformSupport, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")] diff --git a/src/Avalonia.Base/Utilities/UriExtensions.cs b/src/Avalonia.Base/Utilities/UriExtensions.cs new file mode 100644 index 0000000000..c706f72a63 --- /dev/null +++ b/src/Avalonia.Base/Utilities/UriExtensions.cs @@ -0,0 +1,70 @@ +using System; + +namespace Avalonia.Utilities; + +internal static class UriExtensions +{ + public static bool IsAbsoluteResm(this Uri uri) => + uri.IsAbsoluteUri && uri.IsResm(); + + public static bool IsResm(this Uri uri) => uri.Scheme == "resm"; + + public static bool IsAvares(this Uri uri) => uri.Scheme == "avares"; + + public static Uri EnsureAbsolute(this Uri uri, Uri? baseUri) + { + if (uri.IsAbsoluteUri) + return uri; + if(baseUri == null) + throw new ArgumentException($"Relative uri {uri} without base url"); + if (!baseUri.IsAbsoluteUri) + throw new ArgumentException($"Base uri {baseUri} is relative"); + if (baseUri.IsResm()) + throw new ArgumentException( + $"Relative uris for 'resm' scheme aren't supported; {baseUri} uses resm"); + return new Uri(baseUri, uri); + } + + public static string GetUnescapeAbsolutePath(this Uri uri) => + Uri.UnescapeDataString(uri.AbsolutePath); + + public static string GetUnescapeAbsoluteUri(this Uri uri) => + Uri.UnescapeDataString(uri.AbsoluteUri); + + public static string GetAssemblyNameFromQuery(this Uri uri) + { + const string assembly = "assembly"; + + var query = Uri.UnescapeDataString(uri.Query); + + // Skip the '?' + var currentIndex = 1; + while (currentIndex < query.Length) + { + var isFind = false; + for (var i = 0; i < assembly.Length; ++currentIndex, ++i) + if (query[currentIndex] == assembly[i]) + { + isFind = i == assembly.Length - 1; + } + else + { + break; + } + + // Skip the '=' + ++currentIndex; + + var beginIndex = currentIndex; + while (currentIndex < query.Length && query[currentIndex] != '&') + ++currentIndex; + + if (isFind) + return query.Substring(beginIndex, currentIndex - beginIndex); + + ++currentIndex; + } + + return ""; + } +} diff --git a/src/Avalonia.PlatformSupport/AssetLoader.cs b/src/Avalonia.PlatformSupport/AssetLoader.cs index 7220694d7b..fb03ec2f6e 100644 --- a/src/Avalonia.PlatformSupport/AssetLoader.cs +++ b/src/Avalonia.PlatformSupport/AssetLoader.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Reflection; using Avalonia.Platform; +using Avalonia.PlatformSupport.Internal; using Avalonia.Utilities; namespace Avalonia.PlatformSupport @@ -13,12 +14,16 @@ namespace Avalonia.PlatformSupport /// public class AssetLoader : IAssetLoader { - private const string AvaloniaResourceName = "!AvaloniaResources"; - private static readonly Dictionary AssemblyNameCache - = new Dictionary(); + private static AssemblyDescriptorResolver s_assemblyDescriptorResolver = new(); private AssemblyDescriptor? _defaultResmAssembly; + /// + /// Introduced for tests. + /// + internal static void SetAssemblyDescriptorResolver(AssemblyDescriptorResolver resolver) => + s_assemblyDescriptorResolver = resolver; + /// /// Initializes a new instance of the class. /// @@ -109,17 +114,18 @@ namespace Avalonia.PlatformSupport /// All matching assets as a tuple of the absolute path to the asset and the assembly containing the asset public IEnumerable GetAssets(Uri uri, Uri? baseUri) { - if (uri.IsAbsoluteUri && uri.Scheme == "resm") + if (uri.IsAbsoluteResm()) { var assembly = GetAssembly(uri); - return assembly?.Resources?.Where(x => x.Key.Contains(uri.AbsolutePath)) + return assembly?.Resources? + .Where(x => x.Key.IndexOf(uri.GetUnescapeAbsolutePath(), StringComparison.Ordinal) >= 0) .Select(x =>new Uri($"resm:{x.Key}?assembly={assembly.Name}")) ?? Enumerable.Empty(); } - uri = EnsureAbsolute(uri, baseUri); - if (uri.Scheme == "avares") + uri = uri.EnsureAbsolute(baseUri); + if (uri.IsAvares()) { var (asm, path) = GetResAsmAndPath(uri); if (asm == null) @@ -129,33 +135,23 @@ namespace Avalonia.PlatformSupport "don't know where to look up for the resource, try specifying assembly explicitly."); } - if (asm?.AvaloniaResources == null) + if (asm.AvaloniaResources == null) return Enumerable.Empty(); - path = path.TrimEnd('/') + '/'; - return asm.AvaloniaResources.Where(r => r.Key.StartsWith(path)) + + if (path[path.Length - 1] != '/') + path += '/'; + + return asm.AvaloniaResources + .Where(r => r.Key.StartsWith(path, StringComparison.Ordinal)) .Select(x => new Uri($"avares://{asm.Name}{x.Key}")); } return Enumerable.Empty(); } - - private Uri EnsureAbsolute(Uri uri, Uri? baseUri) - { - if (uri.IsAbsoluteUri) - return uri; - if(baseUri == null) - throw new ArgumentException($"Relative uri {uri} without base url"); - if (!baseUri.IsAbsoluteUri) - throw new ArgumentException($"Base uri {baseUri} is relative"); - if (baseUri.Scheme == "resm") - throw new ArgumentException( - $"Relative uris for 'resm' scheme aren't supported; {baseUri} uses resm"); - return new Uri(baseUri, uri); - } private IAssetDescriptor? GetAsset(Uri uri, Uri? baseUri) { - if (uri.IsAbsoluteUri && uri.Scheme == "resm") + if (uri.IsAbsoluteResm()) { var asm = GetAssembly(uri) ?? GetAssembly(baseUri) ?? _defaultResmAssembly; @@ -172,9 +168,9 @@ namespace Avalonia.PlatformSupport return rv; } - uri = EnsureAbsolute(uri, baseUri); + uri = uri.EnsureAbsolute(baseUri); - if (uri.Scheme == "avares") + if (uri.IsAvares()) { var (asm, path) = GetResAsmAndPath(uri); if (asm.AvaloniaResources == null) @@ -188,8 +184,8 @@ namespace Avalonia.PlatformSupport private (AssemblyDescriptor asm, string path) GetResAsmAndPath(Uri uri) { - var asm = GetAssembly(uri.Authority); - return (asm, uri.AbsolutePath); + var asm = s_assemblyDescriptorResolver.GetAssembly(uri.Authority); + return (asm, uri.GetUnescapeAbsolutePath()); } private AssemblyDescriptor? GetAssembly(Uri? uri) @@ -198,197 +194,20 @@ namespace Avalonia.PlatformSupport { if (!uri.IsAbsoluteUri) return null; - if (uri.Scheme == "avares") + if (uri.IsAvares()) return GetResAsmAndPath(uri).asm; - if (uri.Scheme == "resm") + if (uri.IsResm()) { - var qs = ParseQueryString(uri); - - if (qs.TryGetValue("assembly", out var assemblyName)) - { - return GetAssembly(assemblyName); - } + var assemblyName = uri.GetAssemblyNameFromQuery(); + if (assemblyName.Length > 0) + return s_assemblyDescriptorResolver.GetAssembly(assemblyName); } } return null; } - private AssemblyDescriptor GetAssembly(string name) - { - if (name == null) - throw new ArgumentNullException(nameof(name)); - - if (!AssemblyNameCache.TryGetValue(name, out var rv)) - { - var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies(); - var match = loadedAssemblies.FirstOrDefault(a => a.GetName().Name == name); - if (match != null) - { - AssemblyNameCache[name] = rv = new AssemblyDescriptor(match); - } - else - { - // iOS does not support loading assemblies dynamically! -#if NET6_0_OR_GREATER - if (OperatingSystem.IsIOS()) - { - throw new InvalidOperationException( - $"Assembly {name} needs to be referenced and explicitly loaded before loading resources"); - } -#endif - name = Uri.UnescapeDataString(name); - AssemblyNameCache[name] = rv = new AssemblyDescriptor(Assembly.Load(name)); - } - } - - return rv; - } - - private Dictionary ParseQueryString(Uri uri) - { - return uri.Query.TrimStart('?') - .Split(new[] { '&' }, StringSplitOptions.RemoveEmptyEntries) - .Select(p => p.Split('=')) - .ToDictionary(p => p[0], p => p[1]); - } - - private interface IAssetDescriptor - { - Stream GetStream(); - Assembly Assembly { get; } - } - - private class AssemblyResourceDescriptor : IAssetDescriptor - { - private readonly Assembly _asm; - private readonly string _name; - - public AssemblyResourceDescriptor(Assembly asm, string name) - { - _asm = asm; - _name = name; - } - - public Stream GetStream() - { - var s = _asm.GetManifestResourceStream(_name); - return s ?? throw new InvalidOperationException($"Could not find manifest resource stream '{_name}',"); - } - - public Assembly Assembly => _asm; - } - - private class AvaloniaResourceDescriptor : IAssetDescriptor - { - private readonly int _offset; - private readonly int _length; - public Assembly Assembly { get; } - - public AvaloniaResourceDescriptor(Assembly asm, int offset, int length) - { - _offset = offset; - _length = length; - Assembly = asm; - } - - public Stream GetStream() - { - var s = Assembly.GetManifestResourceStream(AvaloniaResourceName) ?? - throw new InvalidOperationException($"Could not find manifest resource stream '{AvaloniaResourceName}',"); - return new SlicedStream(s, _offset, _length); - } - } - - class SlicedStream : Stream - { - private readonly Stream _baseStream; - private readonly int _from; - - public SlicedStream(Stream baseStream, int from, int length) - { - Length = length; - _baseStream = baseStream; - _from = from; - _baseStream.Position = from; - } - public override void Flush() - { - } - - public override int Read(byte[] buffer, int offset, int count) - { - return _baseStream.Read(buffer, offset, (int)Math.Min(count, Length - Position)); - } - - public override long Seek(long offset, SeekOrigin origin) - { - if (origin == SeekOrigin.Begin) - Position = offset; - if (origin == SeekOrigin.End) - Position = _from + Length + offset; - if (origin == SeekOrigin.Current) - Position = Position + offset; - return Position; - } - - public override void SetLength(long value) => throw new NotSupportedException(); - - public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - - public override bool CanRead => true; - public override bool CanSeek => _baseStream.CanRead; - public override bool CanWrite => false; - public override long Length { get; } - public override long Position - { - get => _baseStream.Position - _from; - set => _baseStream.Position = value + _from; - } - - protected override void Dispose(bool disposing) - { - if (disposing) - _baseStream.Dispose(); - } - - public override void Close() => _baseStream.Close(); - } - - private class AssemblyDescriptor - { - public AssemblyDescriptor(Assembly assembly) - { - Assembly = assembly; - - if (assembly != null) - { - Resources = assembly.GetManifestResourceNames() - .ToDictionary(n => n, n => (IAssetDescriptor)new AssemblyResourceDescriptor(assembly, n)); - Name = assembly.GetName().Name; - using (var resources = assembly.GetManifestResourceStream(AvaloniaResourceName)) - { - if (resources != null) - { - Resources.Remove(AvaloniaResourceName); - - var indexLength = new BinaryReader(resources).ReadInt32(); - var index = AvaloniaResourcesIndexReaderWriter.Read(new SlicedStream(resources, 4, indexLength)); - var baseOffset = indexLength + 4; - AvaloniaResources = index.ToDictionary(r => "/" + r.Path!.TrimStart('/'), r => (IAssetDescriptor) - new AvaloniaResourceDescriptor(assembly, baseOffset + r.Offset, r.Size)); - } - } - } - } - - public Assembly Assembly { get; } - public Dictionary? Resources { get; } - public Dictionary? AvaloniaResources { get; } - public string? Name { get; } - } - public static void RegisterResUriParsers() { if (!UriParser.IsKnownScheme("avares")) diff --git a/src/Avalonia.PlatformSupport/Avalonia.PlatformSupport.csproj b/src/Avalonia.PlatformSupport/Avalonia.PlatformSupport.csproj index e08dc5e194..420ac0796c 100644 --- a/src/Avalonia.PlatformSupport/Avalonia.PlatformSupport.csproj +++ b/src/Avalonia.PlatformSupport/Avalonia.PlatformSupport.csproj @@ -16,4 +16,9 @@ + + + + + diff --git a/src/Avalonia.PlatformSupport/Internal/AssemblyDescriptor.cs b/src/Avalonia.PlatformSupport/Internal/AssemblyDescriptor.cs new file mode 100644 index 0000000000..a3de7f2b8a --- /dev/null +++ b/src/Avalonia.PlatformSupport/Internal/AssemblyDescriptor.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using Avalonia.Utilities; + +namespace Avalonia.PlatformSupport.Internal; + +internal class AssemblyDescriptor +{ + public AssemblyDescriptor(Assembly assembly) + { + Assembly = assembly; + + if (assembly != null) + { + Resources = assembly.GetManifestResourceNames() + .ToDictionary(n => n, n => (IAssetDescriptor)new AssemblyResourceDescriptor(assembly, n)); + Name = assembly.GetName().Name; + using (var resources = assembly.GetManifestResourceStream(Constants.AvaloniaResourceName)) + { + if (resources != null) + { + Resources.Remove(Constants.AvaloniaResourceName); + + var indexLength = new BinaryReader(resources).ReadInt32(); + var index = AvaloniaResourcesIndexReaderWriter.Read(new SlicedStream(resources, 4, indexLength)); + var baseOffset = indexLength + 4; + AvaloniaResources = index.ToDictionary(r => GetPathRooted(r), r => (IAssetDescriptor) + new AvaloniaResourceDescriptor(assembly, baseOffset + r.Offset, r.Size)); + } + } + } + } + + public Assembly Assembly { get; } + public Dictionary? Resources { get; } + public Dictionary? AvaloniaResources { get; } + public string? Name { get; } + private static string GetPathRooted(AvaloniaResourcesIndexEntry r) => + r.Path![0] == '/' ? r.Path : '/' + r.Path; +} diff --git a/src/Avalonia.PlatformSupport/Internal/AssemblyDescriptorResolver.cs b/src/Avalonia.PlatformSupport/Internal/AssemblyDescriptorResolver.cs new file mode 100644 index 0000000000..a78051a9c4 --- /dev/null +++ b/src/Avalonia.PlatformSupport/Internal/AssemblyDescriptorResolver.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace Avalonia.PlatformSupport.Internal; + +internal class AssemblyDescriptorResolver +{ + private readonly Dictionary _assemblyNameCache = new(); + + public AssemblyDescriptor GetAssembly(string name) + { + if (name == null) + throw new ArgumentNullException(nameof(name)); + + if (!_assemblyNameCache.TryGetValue(name, out var rv)) + { + var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies(); + var match = loadedAssemblies.FirstOrDefault(a => a.GetName().Name == name); + if (match != null) + { + _assemblyNameCache[name] = rv = new AssemblyDescriptor(match); + } + else + { + // iOS does not support loading assemblies dynamically! +#if NET6_0_OR_GREATER + if (OperatingSystem.IsIOS()) + { + throw new InvalidOperationException( + $"Assembly {name} needs to be referenced and explicitly loaded before loading resources"); + } +#endif + name = Uri.UnescapeDataString(name); + _assemblyNameCache[name] = rv = new AssemblyDescriptor(Assembly.Load(name)); + } + } + + return rv; + } +} diff --git a/src/Avalonia.PlatformSupport/Internal/AssetDescriptor.cs b/src/Avalonia.PlatformSupport/Internal/AssetDescriptor.cs new file mode 100644 index 0000000000..baae1f99e7 --- /dev/null +++ b/src/Avalonia.PlatformSupport/Internal/AssetDescriptor.cs @@ -0,0 +1,52 @@ +using System; +using System.IO; +using System.Reflection; + +namespace Avalonia.PlatformSupport.Internal; + +internal interface IAssetDescriptor +{ + Stream GetStream(); + Assembly Assembly { get; } +} + +internal class AssemblyResourceDescriptor : IAssetDescriptor +{ + private readonly Assembly _asm; + private readonly string _name; + + public AssemblyResourceDescriptor(Assembly asm, string name) + { + _asm = asm; + _name = name; + } + + public Stream GetStream() + { + var s = _asm.GetManifestResourceStream(_name); + return s ?? throw new InvalidOperationException($"Could not find manifest resource stream '{_name}',"); + } + + public Assembly Assembly => _asm; +} + +internal class AvaloniaResourceDescriptor : IAssetDescriptor +{ + private readonly int _offset; + private readonly int _length; + public Assembly Assembly { get; } + + public AvaloniaResourceDescriptor(Assembly asm, int offset, int length) + { + _offset = offset; + _length = length; + Assembly = asm; + } + + public Stream GetStream() + { + var s = Assembly.GetManifestResourceStream(Constants.AvaloniaResourceName) ?? + throw new InvalidOperationException($"Could not find manifest resource stream '{Constants.AvaloniaResourceName}',"); + return new SlicedStream(s, _offset, _length); + } +} diff --git a/src/Avalonia.PlatformSupport/Internal/Constants.cs b/src/Avalonia.PlatformSupport/Internal/Constants.cs new file mode 100644 index 0000000000..c8a0f7b1ce --- /dev/null +++ b/src/Avalonia.PlatformSupport/Internal/Constants.cs @@ -0,0 +1,6 @@ +namespace Avalonia.PlatformSupport.Internal; + +internal static class Constants +{ + public static string AvaloniaResourceName => "!AvaloniaResources"; +} diff --git a/src/Avalonia.PlatformSupport/Internal/SlicedStream.cs b/src/Avalonia.PlatformSupport/Internal/SlicedStream.cs new file mode 100644 index 0000000000..e310db964a --- /dev/null +++ b/src/Avalonia.PlatformSupport/Internal/SlicedStream.cs @@ -0,0 +1,59 @@ +using System; +using System.IO; + +namespace Avalonia.PlatformSupport.Internal; + +internal class SlicedStream : Stream +{ + private readonly Stream _baseStream; + private readonly int _from; + + public SlicedStream(Stream baseStream, int from, int length) + { + Length = length; + _baseStream = baseStream; + _from = from; + _baseStream.Position = from; + } + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + return _baseStream.Read(buffer, offset, (int)Math.Min(count, Length - Position)); + } + + public override long Seek(long offset, SeekOrigin origin) + { + if (origin == SeekOrigin.Begin) + Position = offset; + if (origin == SeekOrigin.End) + Position = _from + Length + offset; + if (origin == SeekOrigin.Current) + Position = Position + offset; + return Position; + } + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override bool CanRead => true; + public override bool CanSeek => _baseStream.CanRead; + public override bool CanWrite => false; + public override long Length { get; } + public override long Position + { + get => _baseStream.Position - _from; + set => _baseStream.Position = value + _from; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + _baseStream.Dispose(); + } + + public override void Close() => _baseStream.Close(); +} diff --git a/src/Avalonia.Visuals/Media/Fonts/FontFamilyLoader.cs b/src/Avalonia.Visuals/Media/Fonts/FontFamilyLoader.cs index a54dad7623..365fb6e412 100644 --- a/src/Avalonia.Visuals/Media/Fonts/FontFamilyLoader.cs +++ b/src/Avalonia.Visuals/Media/Fonts/FontFamilyLoader.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using Avalonia.Platform; +using Avalonia.Utilities; namespace Avalonia.Media.Fonts { @@ -12,18 +13,10 @@ namespace Avalonia.Media.Fonts /// /// /// - public static IEnumerable LoadFontAssets(FontFamilyKey fontFamilyKey) - { - var sourceWithoutArguments = fontFamilyKey.Source.OriginalString.Split('?').First(); - - if (sourceWithoutArguments.EndsWith(".ttf") - || sourceWithoutArguments.EndsWith(".otf")) - { - return GetFontAssetsByExpression(fontFamilyKey); - } - - return GetFontAssetsBySource(fontFamilyKey); - } + public static IEnumerable LoadFontAssets(FontFamilyKey fontFamilyKey) => + IsFontTtfOrOtf(fontFamilyKey.Source) ? + GetFontAssetsByExpression(fontFamilyKey) : + GetFontAssetsBySource(fontFamilyKey); /// /// Searches for font assets at a given location and returns a quantity of found assets @@ -34,11 +27,7 @@ namespace Avalonia.Media.Fonts { var assetLoader = AvaloniaLocator.Current.GetRequiredService(); var availableAssets = assetLoader.GetAssets(fontFamilyKey.Source, fontFamilyKey.BaseUri); - - var matchingAssets = - availableAssets.Where(x => x.AbsolutePath.EndsWith(".ttf") || x.AbsolutePath.EndsWith(".otf")); - - return matchingAssets; + return availableAssets.Where(x => IsFontTtfOrOtf(x)); } /// @@ -49,71 +38,124 @@ namespace Avalonia.Media.Fonts /// private static IEnumerable GetFontAssetsByExpression(FontFamilyKey fontFamilyKey) { + var (fileNameWithoutExtension, extension) = GetFileName(fontFamilyKey, out var location); + var filePattern = CreateFilePattern(fontFamilyKey, location, fileNameWithoutExtension); + var assetLoader = AvaloniaLocator.Current.GetRequiredService(); + var availableResources = assetLoader.GetAssets(location, fontFamilyKey.BaseUri); - var fileName = GetFileName(fontFamilyKey, out var fileExtension, out var location); + return availableResources.Where(x => IsContainsFile(x, filePattern, extension)); + } - var availableResources = assetLoader.GetAssets(location, fontFamilyKey.BaseUri); + private static (string fileNameWithoutExtension, string extension) GetFileName( + FontFamilyKey fontFamilyKey, out Uri location) + { + if (fontFamilyKey.Source.IsAbsoluteResm()) + { + var fileName = GetFileNameAndExtension(fontFamilyKey.Source.GetUnescapeAbsolutePath(), '.'); + + var uriLocation = fontFamilyKey.Source.GetUnescapeAbsoluteUri() + .Replace("." + fileName.fileNameWithoutExtension + fileName.extension, string.Empty); + location = new Uri(uriLocation, UriKind.RelativeOrAbsolute); + + return fileName; + } - string compareTo; + var filename = GetFileNameAndExtension(fontFamilyKey.Source.OriginalString); + var fullFilename = filename.fileNameWithoutExtension + filename.extension; - if (fontFamilyKey.Source.IsAbsoluteUri) + if (fontFamilyKey.BaseUri != null) { - if (fontFamilyKey.Source.Scheme == "resm") - { - compareTo = location.AbsolutePath + "." + fileName.Split('*').First(); - } - else - { - compareTo = location.AbsolutePath + fileName.Split('*').First(); - } + var relativePath = fontFamilyKey.Source.OriginalString + .Replace(fullFilename, string.Empty); + + location = new Uri(fontFamilyKey.BaseUri, relativePath); } else { - compareTo = location.AbsolutePath + fileName.Split('*').First(); + var uriString = fontFamilyKey.Source + .GetUnescapeAbsoluteUri() + .Replace(fullFilename, string.Empty); + location = new Uri(uriString); } - var matchingResources = availableResources.Where( - x => x.AbsolutePath.Contains(compareTo) - && x.AbsolutePath.EndsWith(fileExtension)); + return filename; + } - return matchingResources; + private static string CreateFilePattern( + FontFamilyKey fontFamilyKey, Uri location, string fileNameWithoutExtension) + { + var path = location.GetUnescapeAbsolutePath(); + var file = GetSubString(fileNameWithoutExtension, '*'); + return fontFamilyKey.Source.IsAbsoluteResm() + ? path + "." + file + : path + file; } - private static string GetFileName(FontFamilyKey fontFamilyKey, out string fileExtension, out Uri location) + private static bool IsContainsFile(Uri x, string filePattern, string fileExtension) { - if (fontFamilyKey.Source.IsAbsoluteUri && fontFamilyKey.Source.Scheme == "resm") - { - fileExtension = "." + fontFamilyKey.Source.AbsolutePath.Split('.').LastOrDefault(); + var path = x.GetUnescapeAbsolutePath(); + return path.IndexOf(filePattern, StringComparison.Ordinal) >= 0 + && path.EndsWith(fileExtension, StringComparison.Ordinal); + } - var fileName = fontFamilyKey.Source.LocalPath.Replace(fileExtension, string.Empty).Split('.').Last(); + private static bool IsFontTtfOrOtf(Uri uri) + { + var sourceWithoutArguments = GetSubString(uri.OriginalString, '?'); + return sourceWithoutArguments.EndsWith(".ttf", StringComparison.Ordinal) + || sourceWithoutArguments.EndsWith(".otf", StringComparison.Ordinal); + } - location = new Uri(fontFamilyKey.Source.AbsoluteUri.Replace("." + fileName + fileExtension, string.Empty), UriKind.RelativeOrAbsolute); + private static (string fileNameWithoutExtension, string extension) GetFileNameAndExtension( + string path, char directorySeparator = '/') + { + var pathAsSpan = path.AsSpan(); + pathAsSpan = IsPathRooted(pathAsSpan, directorySeparator) ? + pathAsSpan.Slice(1, path.Length - 1) : + pathAsSpan; - return fileName; - } + var extension = GetFileExtension(pathAsSpan); + if (extension.Length == pathAsSpan.Length) + return (extension.ToString(), string.Empty); - var pathSegments = fontFamilyKey.Source.OriginalString.Split('/'); + var fileName = GetFileName(pathAsSpan, directorySeparator, extension.Length); + return (fileName.ToString(), extension.ToString()); + } - var fileNameWithExtension = pathSegments.Last(); + private static bool IsPathRooted(ReadOnlySpan path, char directorySeparator) => + path.Length > 0 && path[0] == directorySeparator; - var fileNameSegments = fileNameWithExtension.Split('.'); + private static ReadOnlySpan GetFileExtension(ReadOnlySpan path) + { + for (var i = path.Length - 1; i > 0; --i) + { + if (path[i] == '.') + return path.Slice(i, path.Length - i); + } - fileExtension = "." + fileNameSegments.Last(); + return path; + } - if (fontFamilyKey.BaseUri != null) + private static ReadOnlySpan GetFileName(ReadOnlySpan path, char directorySeparator, int extensionLength) + { + for (var i = path.Length - extensionLength - 1; i >= 0; --i) { - var relativePath = fontFamilyKey.Source.OriginalString - .Replace(fileNameWithExtension, string.Empty); - - location = new Uri(fontFamilyKey.BaseUri, relativePath); + if (path[i] == directorySeparator) + return path.Slice(i + 1, path.Length - i - extensionLength - 1); } - else + + return path.Slice(0, path.Length - extensionLength); + } + + private static string GetSubString(string path, char separator) + { + for (var i = 0; i < path.Length; i++) { - location = new Uri(fontFamilyKey.Source.AbsolutePath.Replace(fileNameWithExtension, string.Empty)); + if (path[i] == separator) + return path.Substring(0, i); } - return fileNameSegments.First(); + return path; } } } diff --git a/tests/Avalonia.Base.UnitTests/Utilities/UriExtensionsTests.cs b/tests/Avalonia.Base.UnitTests/Utilities/UriExtensionsTests.cs new file mode 100644 index 0000000000..5c3ac6adeb --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Utilities/UriExtensionsTests.cs @@ -0,0 +1,29 @@ +using System; +using Avalonia.Utilities; +using Xunit; + +namespace Avalonia.Base.UnitTests.Utilities; + +public class UriExtensionsTests +{ + [Fact] + public void Assembly_Name_From_Query_Parsed() + { + const string key = "assembly"; + const string value = "Avalonia.Themes.Default"; + + var uri = new Uri($"resm:Avalonia.Themes.Default.Accents.BaseLight.xaml?{key}={value}"); + var name = uri.GetAssemblyNameFromQuery(); + + Assert.Equal(value, name); + } + + [Fact] + public void Assembly_Name_From_Empty_Query_Not_Parsed() + { + var uri = new Uri("resm:Avalonia.Themes.Default.Accents.BaseLight.xaml"); + var name = uri.GetAssemblyNameFromQuery(); + + Assert.Equal(string.Empty, name); + } +} diff --git a/tests/Avalonia.PlatformSupport.UnitTests/AssetLoaderTests.cs b/tests/Avalonia.PlatformSupport.UnitTests/AssetLoaderTests.cs new file mode 100644 index 0000000000..f950fb7e99 --- /dev/null +++ b/tests/Avalonia.PlatformSupport.UnitTests/AssetLoaderTests.cs @@ -0,0 +1,62 @@ +using System; +using System.Reflection; +using Avalonia.PlatformSupport.Internal; +using Moq; +using Xunit; + +namespace Avalonia.PlatformSupport.UnitTests; + +public class AssetLoaderTests +{ + public class MockAssembly : Assembly {} + + private const string AssemblyNameWithWhitespace = "Awesome Library"; + + private const string AssemblyNameWithNonAscii = "Какое-то-название"; + + static AssetLoaderTests() + { + var resolver = Mock.Of(); + + var descriptor = CreateAssemblyDescriptor(AssemblyNameWithWhitespace); + Mock.Get(resolver).Setup(x => x.GetAssembly(AssemblyNameWithWhitespace)).Returns(descriptor); + + descriptor = CreateAssemblyDescriptor(AssemblyNameWithNonAscii); + Mock.Get(resolver).Setup(x => x.GetAssembly(AssemblyNameWithNonAscii)).Returns(descriptor); + + AssetLoader.SetAssemblyDescriptorResolver(resolver); + } + + [Fact] + public void AssemblyName_With_Whitespace_Should_Load_Resm() + { + var uri = new Uri($"resm:Avalonia.Base.UnitTests.Assets.something?assembly={AssemblyNameWithWhitespace}"); + var loader = new AssetLoader(); + + var assemblyActual = loader.GetAssembly(uri, null); + + Assert.Equal(AssemblyNameWithWhitespace, assemblyActual?.FullName); + } + + [Fact] + public void AssemblyName_With_Non_ASCII_Should_Load_Avares() + { + var uri = new Uri($"avares://{AssemblyNameWithNonAscii}/Assets/something"); + var loader = new AssetLoader(); + + var assemblyActual = loader.GetAssembly(uri, null); + + Assert.Equal(AssemblyNameWithNonAscii, assemblyActual?.FullName); + } + + private static AssemblyDescriptor CreateAssemblyDescriptor(string assemblyName) + { + var assembly = Mock.Of(); + Mock.Get(assembly).Setup(x => x.GetName()).Returns(new AssemblyName(assemblyName)); + Mock.Get(assembly).Setup(x => x.FullName).Returns(assemblyName); + + var descriptor = Mock.Of(); + Mock.Get(descriptor).Setup(x => x.Assembly).Returns(assembly); + return descriptor; + } +} diff --git a/tests/Avalonia.PlatformSupport.UnitTests/Avalonia.PlatformSupport.UnitTests.csproj b/tests/Avalonia.PlatformSupport.UnitTests/Avalonia.PlatformSupport.UnitTests.csproj new file mode 100644 index 0000000000..d714941e5a --- /dev/null +++ b/tests/Avalonia.PlatformSupport.UnitTests/Avalonia.PlatformSupport.UnitTests.csproj @@ -0,0 +1,24 @@ + + + + net6.0 + enable + Library + true + latest + + + + + + + + + + + + + + + + diff --git a/tests/Avalonia.UnitTests/MockAssetLoader.cs b/tests/Avalonia.UnitTests/MockAssetLoader.cs index f591ac445f..6fe8cd54f7 100644 --- a/tests/Avalonia.UnitTests/MockAssetLoader.cs +++ b/tests/Avalonia.UnitTests/MockAssetLoader.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Reflection; using System.Text; using Avalonia.Platform; +using Avalonia.Utilities; namespace Avalonia.UnitTests { @@ -39,7 +40,9 @@ namespace Avalonia.UnitTests public IEnumerable GetAssets(Uri uri, Uri baseUri) { - return _assets.Keys.Where(x => x.AbsolutePath.Contains(uri.AbsolutePath)); + var absPath = uri.GetUnescapeAbsolutePath(); + return _assets.Keys.Where( + x => x.GetUnescapeAbsolutePath().IndexOf(absPath, StringComparison.Ordinal) >= 0); } public void SetDefaultAssembly(Assembly asm) diff --git a/tests/Avalonia.Visuals.UnitTests/Media/Fonts/FontFamilyLoaderTests.cs b/tests/Avalonia.Visuals.UnitTests/Media/Fonts/FontFamilyLoaderTests.cs index a83c0562e7..7e1cc4cd96 100644 --- a/tests/Avalonia.Visuals.UnitTests/Media/Fonts/FontFamilyLoaderTests.cs +++ b/tests/Avalonia.Visuals.UnitTests/Media/Fonts/FontFamilyLoaderTests.cs @@ -12,24 +12,28 @@ namespace Avalonia.Visuals.UnitTests.Media.Fonts public class FontFamilyLoaderTests : IDisposable { + private const string AssetMyFontRegular = AssetLocation + ".MyFont Regular.ttf" + Assembly + FontName; private const string FontName = "#MyFont"; private const string Assembly = "?assembly=Avalonia.Visuals.UnitTests"; private const string AssetLocation = "resm:Avalonia.Visuals.UnitTests.Assets"; + private const string AssetLocationAvares = "avares://Avalonia.Visuals.UnitTests"; + private const string AssetYourFileName = "/Assets/YourFont.ttf"; + private const string AssetYourFontAvares = AssetLocationAvares + AssetYourFileName; private readonly IDisposable _testApplication; public FontFamilyLoaderTests() { - const string AssetMyFontRegular = AssetLocation + ".MyFont-Regular.ttf" + Assembly + FontName; - const string AssetMyFontBold = AssetLocation + ".MyFont-Bold.ttf" + Assembly + FontName; + const string AssetMyFontBold = AssetLocation + ".MyFont Bold.ttf" + Assembly + FontName; const string AssetYourFont = AssetLocation + ".YourFont.ttf" + Assembly + FontName; var fontAssets = new[] - { - (AssetMyFontRegular, "AssetData"), - (AssetMyFontBold, "AssetData"), - (AssetYourFont, "AssetData") - }; + { + (AssetMyFontRegular, "AssetData"), + (AssetMyFontBold, "AssetData"), + (AssetYourFont, "AssetData"), + (AssetYourFontAvares, "AssetData") + }; _testApplication = StartWithResources(fontAssets); } @@ -42,10 +46,18 @@ namespace Avalonia.Visuals.UnitTests.Media.Fonts [Fact] public void Should_Load_Single_FontAsset() { - const string FontAsset = AssetLocation + ".MyFont-Regular.ttf" + Assembly + FontName; + var source = new Uri(AssetMyFontRegular, UriKind.RelativeOrAbsolute); + var key = new FontFamilyKey(source); - var source = new Uri(FontAsset, UriKind.RelativeOrAbsolute); + var fontAssets = FontFamilyLoader.LoadFontAssets(key); + Assert.Single(fontAssets); + } + + [Fact] + public void Should_Load_Single_FontAsset_Avares_Without_BaseUri() + { + var source = new Uri(AssetYourFontAvares); var key = new FontFamilyKey(source); var fontAssets = FontFamilyLoader.LoadFontAssets(key); @@ -54,10 +66,21 @@ namespace Avalonia.Visuals.UnitTests.Media.Fonts } [Fact] - public void Should_Load_Matching_Assets() + public void Should_Load_Single_FontAsset_Avares_With_BaseUri() { - var source = new Uri(AssetLocation + ".MyFont-*.ttf" + Assembly + FontName, UriKind.RelativeOrAbsolute); + var source = new Uri(AssetYourFileName, UriKind.RelativeOrAbsolute); + var baseUri = new Uri(AssetLocationAvares); + var key = new FontFamilyKey(source, baseUri); + var fontAssets = FontFamilyLoader.LoadFontAssets(key); + + Assert.Single(fontAssets); + } + + [Fact] + public void Should_Load_Matching_Assets() + { + var source = new Uri(AssetLocation + ".MyFont*.ttf" + Assembly + FontName, UriKind.RelativeOrAbsolute); var key = new FontFamilyKey(source); var fontAssets = FontFamilyLoader.LoadFontAssets(key).ToArray();