From 05556c78413a68fa3875b8421d7736f5d41a2734 Mon Sep 17 00:00:00 2001 From: zznty <94796179+zznty@users.noreply.github.com> Date: Thu, 5 Jun 2025 00:10:27 +0700 Subject: [PATCH] image loader for imgui and optional nuget icons for plugins in browse tab --- CringeBootstrap/packages.lock.json | 1 + CringeLauncher/ImGuiHandler.cs | 8 + CringeLauncher/Launcher.cs | 18 +- CringeLauncher/packages.lock.json | 1 + CringePlugins/CringePlugins.csproj | 2 + CringePlugins/Services/ImGuiImageService.cs | 243 ++++++++++++++++++++ CringePlugins/Ui/PluginListComponent.cs | 10 + CringePlugins/packages.lock.json | 60 ++++- NuGet/Models/SearchResult.cs | 4 +- 9 files changed, 338 insertions(+), 9 deletions(-) create mode 100644 CringePlugins/Services/ImGuiImageService.cs diff --git a/CringeBootstrap/packages.lock.json b/CringeBootstrap/packages.lock.json index 8be9c89..753512f 100644 --- a/CringeBootstrap/packages.lock.json +++ b/CringeBootstrap/packages.lock.json @@ -434,6 +434,7 @@ "ImGui.NET.DirectX": "[1.91.0.1, )", "JsonSchema.Net.Generation": "[5.0.2, )", "Lib.Harmony.Thin": "[2.3.4-torch, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[9.0.5, )", "NLog": "[5.4.0, )", "NuGet": "[1.0.0, )", "SharedCringe": "[1.0.0, )", diff --git a/CringeLauncher/ImGuiHandler.cs b/CringeLauncher/ImGuiHandler.cs index c8e53b3..491a85c 100644 --- a/CringeLauncher/ImGuiHandler.cs +++ b/CringeLauncher/ImGuiHandler.cs @@ -7,7 +7,9 @@ using Windows.Win32.Foundation; using Windows.Win32.UI.WindowsAndMessaging; using CringePlugins.Abstractions; using CringePlugins.Render; +using CringePlugins.Services; using ImGuiNET; +using Microsoft.Extensions.DependencyInjection; using SharpDX.Direct3D11; using static ImGuiNET.ImGui; using VRage; @@ -36,12 +38,14 @@ internal sealed class ImGuiHandler : IGuiHandler, IDisposable public static RenderTargetView? Rtv; private readonly IRootRenderComponent _renderHandler; + private readonly ImGuiImageService _imageService; private static bool _init; public ImGuiHandler(DirectoryInfo configDir) { _configDir = configDir; _renderHandler = new RenderHandler(this); + _imageService = (ImGuiImageService)GameServicesExtension.GameServices.GetRequiredService(); } public unsafe void Init(nint windowHandle, Device1 device, DeviceContext deviceContext) @@ -62,6 +66,8 @@ internal sealed class ImGuiHandler : IGuiHandler, IDisposable ImGui_ImplWin32_Init(windowHandle); ImGui_ImplDX11_Init(device.NativePointer, deviceContext.NativePointer); _init = true; + + _imageService.Initialize(); } public static void HookWindow(HWND windowHandle) @@ -117,6 +123,8 @@ internal sealed class ImGuiHandler : IGuiHandler, IDisposable UpdatePlatformWindows(); RenderPlatformWindowsDefault(); + + _imageService.Update(); } [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] diff --git a/CringeLauncher/Launcher.cs b/CringeLauncher/Launcher.cs index e48ac64..4cec06b 100644 --- a/CringeLauncher/Launcher.cs +++ b/CringeLauncher/Launcher.cs @@ -1,4 +1,5 @@ -using System.Reflection; +using System.Net; +using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Loader; @@ -161,15 +162,26 @@ public class Launcher : ICorePlugin { var services = new ServiceCollection(); + var retryPolicy = HttpPolicyExtensions.HandleTransientHttpError() + .WaitAndRetryAsync(5, _ => TimeSpan.FromSeconds(1)); + services.AddHttpClient() .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { - AutomaticDecompression = System.Net.DecompressionMethods.All + AutomaticDecompression = DecompressionMethods.All }) - .AddPolicyHandler(HttpPolicyExtensions.HandleTransientHttpError().WaitAndRetryAsync(5, _ => TimeSpan.FromSeconds(1))); + .AddPolicyHandler(retryPolicy); + + services.AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.All + }) + .AddPolicyHandler(retryPolicy); services.AddSingleton(_ => RenderHandler.Current) .AddSingleton(s => s.GetRequiredService()) + .AddSingleton(s => s.GetRequiredService()) .AddSingleton(_ => new ConfigHandler(_configDir)); return GameServicesExtension.GameServices = services.BuildServiceProvider(); diff --git a/CringeLauncher/packages.lock.json b/CringeLauncher/packages.lock.json index 69d2485..adb6e6b 100644 --- a/CringeLauncher/packages.lock.json +++ b/CringeLauncher/packages.lock.json @@ -455,6 +455,7 @@ "ImGui.NET.DirectX": "[1.91.0.1, )", "JsonSchema.Net.Generation": "[5.0.2, )", "Lib.Harmony.Thin": "[2.3.4-torch, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[9.0.5, )", "NLog": "[5.4.0, )", "NuGet": "[1.0.0, )", "SharedCringe": "[1.0.0, )", diff --git a/CringePlugins/CringePlugins.csproj b/CringePlugins/CringePlugins.csproj index bd3c025..f3d679a 100644 --- a/CringePlugins/CringePlugins.csproj +++ b/CringePlugins/CringePlugins.csproj @@ -20,11 +20,13 @@ + + diff --git a/CringePlugins/Services/ImGuiImageService.cs b/CringePlugins/Services/ImGuiImageService.cs new file mode 100644 index 0000000..e5d4267 --- /dev/null +++ b/CringePlugins/Services/ImGuiImageService.cs @@ -0,0 +1,243 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; +using System.Numerics; +using System.Security.Cryptography; +using System.Text; +using NLog; +using SharpDX.Direct3D11; +using SharpDX.DXGI; +using VRage.Collections; +using VRageRender; + +namespace CringePlugins.Services; + +public interface IImGuiImageService +{ + ImGuiImage GetFromUrl(Uri url); + ImGuiImage GetFromPath(string path); +} + +internal sealed class ImGuiImageService(HttpClient client) : IImGuiImageService +{ + private static readonly Logger Log = LogManager.GetCurrentClassLogger(); + private readonly string _dir = Directory.CreateDirectory( + Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "CringeLauncher", "cache", "images")).FullName; + private readonly CachingDictionary _images = []; + private readonly Dictionary _imageReferences = []; + private readonly Dictionary _webCacheEtag = []; + private Image? _placeholderImage; + + internal void Initialize() + { + using var tex = new Texture2D(MyRender11.DeviceInstance, new() + { + Width = 1, + Height = 1, + Format = Format.R8G8B8A8_UNorm, + MipLevels = 1, + ArraySize = 1, + SampleDescription = new() + { + Count = 1 + }, + Usage = ResourceUsage.Default, + BindFlags = BindFlags.ShaderResource, + CpuAccessFlags = CpuAccessFlags.None, + OptionFlags = ResourceOptionFlags.None, + }); + + var srv = new ShaderResourceView(MyRender11.DeviceInstance, tex); + + _placeholderImage = new Image(null!, srv, new(1, 1)); + } + + internal void Update() + { + foreach (var (identifier, image) in _images) + { + if (!image.IsUnused) + continue; + + _images.Remove(identifier); + _imageReferences.Remove(identifier); + image.Dispose(); + } + _images.ApplyRemovals(); + } + + public ImGuiImage GetFromUrl(Uri url) + { + var identifier = new WebImageIdentifier(url); + if (_images.TryGetValue(identifier, out var image)) + return image; + if (_imageReferences.TryGetValue(identifier, out var imageReference)) + return imageReference; + + var cachePath = Path.Join(_dir, + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(url.ToString())))); + + var reference = new ImageReference(_placeholderImage!); + LoadAsync(url, cachePath, reference); + _imageReferences.Add(identifier, reference); + return reference; + } + + private async void LoadAsync(Uri url, string cachePath, ImageReference reference) + { + try + { + var request = new HttpRequestMessage(HttpMethod.Get, url); + if (_webCacheEtag.TryGetValue(new(url), out var existingEtag)) + request.Headers.IfNoneMatch.Add(existingEtag); + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + + response.EnsureSuccessStatusCode(); + + if (response.Headers.ETag is { } etag) + _webCacheEtag[new(url)] = etag; + + if (!File.Exists(cachePath) || (response.StatusCode != HttpStatusCode.NotModified && + !CompareCache(cachePath, response.Headers))) + { + await using var stream = await response.Content.ReadAsStreamAsync(); + await using var file = File.Create(cachePath); + await stream.CopyToAsync(file); + } + + reference.Image = GetFromPath(cachePath); + } + catch (Exception e) + { + Log.Error(e, "Failed to load image {Url}", url); + reference.ErrorImage = null; // todo make an error image + } + } + + private static bool CompareCache(string path, HttpResponseHeaders headers) + { + if (headers.CacheControl is not { } cacheControl) + return false; + + if (cacheControl.NoCache) + return false; + + if (cacheControl.MaxAge.HasValue) + { + var responseAge = DateTimeOffset.UtcNow - cacheControl.MaxAge.Value; + return File.GetLastWriteTimeUtc(path) > responseAge; + } + + return true; + } + + public ImGuiImage GetFromPath(string path) + { + path = Path.GetFullPath(path); + var identifier = new FileImageIdentifier(path); + if (_images.TryGetValue(identifier, out var image)) + return image; + + if (!File.Exists(path)) + throw new FileNotFoundException(null, path); + + using var img = SharpDX.Toolkit.Graphics.Image.Load(path); + + var desc = img.Description; + using var tex = new Texture2D(MyRender11.DeviceInstance, new() + { + Width = desc.Width, + Height = desc.Height, + Format = desc.Format, + MipLevels = desc.MipLevels, + ArraySize = desc.ArraySize, + SampleDescription = new() + { + Count = 1 + }, + Usage = ResourceUsage.Default, + BindFlags = BindFlags.ShaderResource, + CpuAccessFlags = CpuAccessFlags.None, + OptionFlags = ResourceOptionFlags.None, + }, img.ToDataBox()); + + var srv = new ShaderResourceView(MyRender11.DeviceInstance, tex); + + image = new Image(identifier, srv, new(desc.Width, desc.Height)); + _images.Add(identifier, image, true); + return image; + } + + private class ImageReference(ImGuiImage placeholderImage) : ImGuiImage + { + public ImGuiImage? Image; + public ImGuiImage? ErrorImage; + + public override nint TextureId => Image ?? ErrorImage ?? placeholderImage; + public override Vector2 Size => Image ?? ErrorImage ?? placeholderImage; + + public override void Dispose() + { + Image?.Dispose(); + ErrorImage?.Dispose(); + } + } + + private class Image(ImageIdentifier identifier, ShaderResourceView srv, Vector2 size) : ImGuiImage + { + private bool _disposed; + private long _lastUse = Stopwatch.GetTimestamp(); + + public override nint TextureId + { + get + { + OnUse(); + return srv.NativePointer; + } + } + + public override Vector2 Size + { + get + { + OnUse(); + return size; + } + } + + public bool IsUnused => _disposed || Stopwatch.GetElapsedTime(_lastUse) > TimeSpan.FromMinutes(5); + + private void OnUse() + { + ObjectDisposedException.ThrowIf(_disposed, this); + _lastUse = Stopwatch.GetTimestamp(); + } + + public override void Dispose() + { + if (_disposed) return; + _disposed = true; + srv.Dispose(); + } + + public override string ToString() + { + return $"Image {{ {identifier} {size} }}"; + } + } + + private abstract record ImageIdentifier; + private record WebImageIdentifier(Uri Url) : ImageIdentifier; + private record FileImageIdentifier(string Path) : ImageIdentifier; +} + +public abstract class ImGuiImage : IDisposable +{ + public abstract nint TextureId { get; } + public abstract Vector2 Size { get; } + + public static implicit operator nint(ImGuiImage image) => image.TextureId; + public static implicit operator Vector2(ImGuiImage image) => image.Size; + public abstract void Dispose(); +} \ No newline at end of file diff --git a/CringePlugins/Ui/PluginListComponent.cs b/CringePlugins/Ui/PluginListComponent.cs index d36dd64..010c36d 100644 --- a/CringePlugins/Ui/PluginListComponent.cs +++ b/CringePlugins/Ui/PluginListComponent.cs @@ -7,8 +7,10 @@ using CringePlugins.Compatability; using CringePlugins.Config; using CringePlugins.Loader; using CringePlugins.Resolver; +using CringePlugins.Services; using CringePlugins.Utils; using ImGuiNET; +using Microsoft.Extensions.DependencyInjection; using NLog; using NuGet; using NuGet.Models; @@ -40,6 +42,7 @@ internal class PluginListComponent : IRenderComponent private ImmutableArray _plugins; private (SearchResultEntry entry, NuGetClient client)? _selected; private (PackageSource source, int index)? _selectedSource; + private readonly IImGuiImageService _imageService = GameServicesExtension.GameServices.GetRequiredService(); public PluginListComponent(ConfigReference packagesConfig, PackageSourceMapping sourceMapping, string gameFolder, ImmutableArray plugins) @@ -469,6 +472,13 @@ internal class PluginListComponent : IRenderComponent { var selected = _selected.Value.entry; + if (!string.IsNullOrEmpty(selected.IconUrl)) + { + var image = _imageService.GetFromUrl(new Uri(selected.IconUrl)); + Image(image, new(64, 64)); + SameLine(); + } + Text(selected.Title ?? selected.Id); SameLine(); TextColored(*GetStyleColorVec4(ImGuiCol.TextLink), selected.Version.ToString()); diff --git a/CringePlugins/packages.lock.json b/CringePlugins/packages.lock.json index 4be9367..3e17051 100644 --- a/CringePlugins/packages.lock.json +++ b/CringePlugins/packages.lock.json @@ -21,7 +21,12 @@ "type": "Direct", "requested": "[1.91.0.1, )", "resolved": "1.91.0.1", - "contentHash": "PpW1gQ9g97h6Hm/h/tkSBOmsBYgGwN8wKNmlJomcQFD/zRY1HPkJZz18XRSfRLHPmH2eeh4hhhZv1KHug7dF9g==" + "contentHash": "PpW1gQ9g97h6Hm/h/tkSBOmsBYgGwN8wKNmlJomcQFD/zRY1HPkJZz18XRSfRLHPmH2eeh4hhhZv1KHug7dF9g==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Numerics.Vectors": "4.5.0", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } }, "JsonSchema.Net.Generation": { "type": "Direct", @@ -45,9 +50,16 @@ "resolved": "2.3.4-torch", "contentHash": "UnLUnLLiXfHZdKa1zhi6w8cl8tJTrpVixLtvjFEVtlDA6Rkf06OcZ2gSidcbcgKjTcR+fk5Qsdos3mU5oohzfg==", "dependencies": { - "MonoMod.Core": "1.2.2" + "MonoMod.Core": "1.2.2", + "System.Text.Json": "9.0.0" } }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Direct", + "requested": "[9.0.5, )", + "resolved": "9.0.5", + "contentHash": "cjnRtsEAzU73aN6W7vkWy8Phj5t3Xm78HSqgrbh/O4Q9SK/yN73wZVa21QQY6amSLQRQ/M8N+koGnY6PuvKQsw==" + }, "NLog": { "type": "Direct", "requested": "[5.4.0, )", @@ -112,7 +124,9 @@ "resolved": "4.11.0", "contentHash": "djf8ujmqYImFgB04UGtcsEhHrzVqzHowS+EEl/Yunc5LdrYrZhGBWUTXoCF0NzYXJxtfuD+UVQarWpvrNc94Qg==", "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.3.4" + "Microsoft.CodeAnalysis.Analyzers": "3.3.4", + "System.Collections.Immutable": "8.0.0", + "System.Reflection.Metadata": "8.0.0" } }, "Mono.Cecil": { @@ -174,6 +188,16 @@ "resolved": "4.2.0-keen-cringe", "contentHash": "LaJN3h1Gi1FWVdef2I5WtOH9gwzKCBniH0CragarbkN2QheYY6Lqm+91PcOfp1w/4wdVb+k8Kjv3sO393Tphtw==" }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==" + }, "System.Linq.Async": { "type": "Transitive", "resolved": "6.0.1", @@ -182,6 +206,29 @@ "Microsoft.Bcl.AsyncInterfaces": "6.0.0" } }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ptvgrFh7PvWI8bcVqG5rsA/weWM09EnthFHR5SCnS6IN+P4mj6rE1lBDC4U8HL9/57htKAqy4KQ3bBj84cfYyQ==", + "dependencies": { + "System.Collections.Immutable": "8.0.0" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "js7+qAu/9mQvnhA4EfGMZNEzXtJCDxgkgj8ohuxq/Qxv+R56G+ljefhiJHOxTNiw54q8vmABCWUwkMulNdlZ4A==" + }, "cringebootstrap.abstractions": { "type": "Project" }, @@ -207,7 +254,12 @@ "type": "Direct", "requested": "[1.91.0.1, )", "resolved": "1.91.0.1", - "contentHash": "PpW1gQ9g97h6Hm/h/tkSBOmsBYgGwN8wKNmlJomcQFD/zRY1HPkJZz18XRSfRLHPmH2eeh4hhhZv1KHug7dF9g==" + "contentHash": "PpW1gQ9g97h6Hm/h/tkSBOmsBYgGwN8wKNmlJomcQFD/zRY1HPkJZz18XRSfRLHPmH2eeh4hhhZv1KHug7dF9g==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Numerics.Vectors": "4.5.0", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } }, "Steamworks.NET": { "type": "Direct", diff --git a/NuGet/Models/SearchResult.cs b/NuGet/Models/SearchResult.cs index 0d27b0d..417656e 100644 --- a/NuGet/Models/SearchResult.cs +++ b/NuGet/Models/SearchResult.cs @@ -23,11 +23,11 @@ public record SearchResultEntry( [property: JsonConverter(typeof(StringOrStringArrayConverter))] ImmutableArray? Tags, string? Title, - int? TotalDownloads, + ulong? TotalDownloads, ImmutableArray PackageTypes, bool Verified = false); public record SearchResultPackageVersion( NuGetVersion Version, - int Downloads, + ulong Downloads, [property: JsonPropertyName("@id")] Uri Registration);