mirror of
https://github.com/goatcorp/Dalamud.git
synced 2025-12-12 18:27:23 +01:00
582 lines
21 KiB
C#
582 lines
21 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Net.Http;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
using Dalamud.Game;
|
|
using Dalamud.Plugin.Internal;
|
|
using Dalamud.Plugin.Internal.Types;
|
|
using Dalamud.Utility;
|
|
using ImGuiScene;
|
|
using Serilog;
|
|
|
|
namespace Dalamud.Interface.Internal.Windows
|
|
{
|
|
/// <summary>
|
|
/// A cache for plugin icons and images.
|
|
/// </summary>
|
|
internal class PluginImageCache : IDisposable
|
|
{
|
|
/// <summary>
|
|
/// Maximum plugin image width.
|
|
/// </summary>
|
|
public const int PluginImageWidth = 730;
|
|
|
|
/// <summary>
|
|
/// Maximum plugin image height.
|
|
/// </summary>
|
|
public const int PluginImageHeight = 380;
|
|
|
|
/// <summary>
|
|
/// Maximum plugin icon width.
|
|
/// </summary>
|
|
public const int PluginIconWidth = 512;
|
|
|
|
/// <summary>
|
|
/// Maximum plugin height.
|
|
/// </summary>
|
|
public const int PluginIconHeight = 512;
|
|
|
|
private const string MainRepoImageUrl = "https://raw.githubusercontent.com/goatcorp/DalamudPlugins/api6/{0}/{1}/images/{2}";
|
|
|
|
private BlockingCollection<Func<Task>> downloadQueue = new();
|
|
private BlockingCollection<Action> loadQueue = new();
|
|
private CancellationTokenSource downloadToken = new();
|
|
private Thread downloadThread;
|
|
|
|
private Dictionary<string, TextureWrap?> pluginIconMap = new();
|
|
private Dictionary<string, TextureWrap?[]> pluginImagesMap = new();
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="PluginImageCache"/> class.
|
|
/// </summary>
|
|
public PluginImageCache()
|
|
{
|
|
var dalamud = Service<Dalamud>.Get();
|
|
var interfaceManager = Service<InterfaceManager>.Get();
|
|
|
|
this.DefaultIcon = interfaceManager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "defaultIcon.png"))!;
|
|
this.TroubleIcon = interfaceManager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "troubleIcon.png"))!;
|
|
this.UpdateIcon = interfaceManager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "updateIcon.png"))!;
|
|
this.InstalledIcon = interfaceManager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "installedIcon.png"))!;
|
|
this.ThirdIcon = interfaceManager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "thirdIcon.png"))!;
|
|
this.ThirdInstalledIcon = interfaceManager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "thirdInstalledIcon.png"))!;
|
|
this.CorePluginIcon = interfaceManager.LoadImage(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "tsmLogo.png"))!;
|
|
|
|
if (this.DefaultIcon == null || this.TroubleIcon == null || this.UpdateIcon == null || this.InstalledIcon == null ||
|
|
this.ThirdIcon == null || this.ThirdInstalledIcon == null || this.CorePluginIcon == null)
|
|
{
|
|
throw new Exception("Plugin overlay images could not be loaded.");
|
|
}
|
|
|
|
this.downloadThread = new Thread(this.DownloadTask);
|
|
this.downloadThread.Start();
|
|
|
|
var framework = Service<Framework>.Get();
|
|
framework.Update += this.FrameworkOnUpdate;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the default plugin icon.
|
|
/// </summary>
|
|
public TextureWrap DefaultIcon { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the plugin trouble icon overlay.
|
|
/// </summary>
|
|
public TextureWrap TroubleIcon { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the plugin update icon overlay.
|
|
/// </summary>
|
|
public TextureWrap UpdateIcon { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the plugin installed icon overlay.
|
|
/// </summary>
|
|
public TextureWrap InstalledIcon { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the third party plugin icon overlay.
|
|
/// </summary>
|
|
public TextureWrap ThirdIcon { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the installed third party plugin icon overlay.
|
|
/// </summary>
|
|
public TextureWrap ThirdInstalledIcon { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the core plugin icon.
|
|
/// </summary>
|
|
public TextureWrap CorePluginIcon { get; }
|
|
|
|
/// <inheritdoc/>
|
|
public void Dispose()
|
|
{
|
|
var framework = Service<Framework>.Get();
|
|
framework.Update -= this.FrameworkOnUpdate;
|
|
|
|
this.DefaultIcon?.Dispose();
|
|
this.TroubleIcon?.Dispose();
|
|
this.UpdateIcon?.Dispose();
|
|
this.InstalledIcon?.Dispose();
|
|
this.ThirdIcon?.Dispose();
|
|
this.ThirdInstalledIcon?.Dispose();
|
|
this.CorePluginIcon?.Dispose();
|
|
|
|
this.downloadToken?.Cancel();
|
|
|
|
if (!this.downloadThread.Join(4000))
|
|
{
|
|
Log.Error("Plugin Image Download thread has not cancelled in time");
|
|
}
|
|
|
|
this.downloadToken?.Dispose();
|
|
this.downloadQueue?.CompleteAdding();
|
|
this.downloadQueue?.Dispose();
|
|
|
|
foreach (var icon in this.pluginIconMap.Values)
|
|
{
|
|
icon?.Dispose();
|
|
}
|
|
|
|
foreach (var images in this.pluginImagesMap.Values)
|
|
{
|
|
foreach (var image in images)
|
|
{
|
|
image?.Dispose();
|
|
}
|
|
}
|
|
|
|
this.pluginIconMap.Clear();
|
|
this.pluginImagesMap.Clear();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clear the cache of downloaded icons.
|
|
/// </summary>
|
|
public void ClearIconCache()
|
|
{
|
|
this.pluginIconMap.Clear();
|
|
this.pluginImagesMap.Clear();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Try to get the icon associated with the internal name of a plugin.
|
|
/// Uses the name within the manifest to search.
|
|
/// </summary>
|
|
/// <param name="plugin">The installed plugin, if available.</param>
|
|
/// <param name="manifest">The plugin manifest.</param>
|
|
/// <param name="isThirdParty">If the plugin was third party sourced.</param>
|
|
/// <param name="iconTexture">Cached image textures, or an empty array.</param>
|
|
/// <returns>True if an entry exists, may be null if currently downloading.</returns>
|
|
public bool TryGetIcon(LocalPlugin? plugin, PluginManifest manifest, bool isThirdParty, out TextureWrap? iconTexture)
|
|
{
|
|
if (this.pluginIconMap.TryGetValue(manifest.InternalName, out iconTexture))
|
|
return true;
|
|
|
|
iconTexture = null;
|
|
this.pluginIconMap.Add(manifest.InternalName, iconTexture);
|
|
|
|
if (!this.downloadQueue.IsCompleted)
|
|
{
|
|
this.downloadQueue.Add(async () => await this.DownloadPluginIconAsync(plugin, manifest, isThirdParty));
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Try to get any images associated with the internal name of a plugin.
|
|
/// Uses the name within the manifest to search.
|
|
/// </summary>
|
|
/// <param name="plugin">The installed plugin, if available.</param>
|
|
/// <param name="manifest">The plugin manifest.</param>
|
|
/// <param name="isThirdParty">If the plugin was third party sourced.</param>
|
|
/// <param name="imageTextures">Cached image textures, or an empty array.</param>
|
|
/// <returns>True if the image array exists, may be empty if currently downloading.</returns>
|
|
public bool TryGetImages(LocalPlugin? plugin, PluginManifest manifest, bool isThirdParty, out TextureWrap?[] imageTextures)
|
|
{
|
|
if (this.pluginImagesMap.TryGetValue(manifest.InternalName, out imageTextures))
|
|
return true;
|
|
|
|
imageTextures = Array.Empty<TextureWrap>();
|
|
this.pluginImagesMap.Add(manifest.InternalName, imageTextures);
|
|
|
|
if (!this.downloadQueue.IsCompleted)
|
|
{
|
|
this.downloadQueue.Add(async () => await this.DownloadPluginImagesAsync(plugin, manifest, isThirdParty));
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private void FrameworkOnUpdate(Framework framework)
|
|
{
|
|
try
|
|
{
|
|
if (!this.loadQueue.TryTake(out var loadAction, 0, this.downloadToken.Token))
|
|
return;
|
|
|
|
loadAction.Invoke();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, "An unhandled exception occurred in image loader framework dispatcher");
|
|
}
|
|
}
|
|
|
|
private async void DownloadTask()
|
|
{
|
|
while (!this.downloadToken.Token.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
if (!this.downloadQueue.TryTake(out var task, -1, this.downloadToken.Token))
|
|
return;
|
|
|
|
await task.Invoke();
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Shutdown signal.
|
|
break;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, "An unhandled exception occurred in the plugin image downloader");
|
|
}
|
|
}
|
|
|
|
Log.Debug("Plugin image downloader has shutdown");
|
|
}
|
|
|
|
private async Task DownloadPluginIconAsync(LocalPlugin? plugin, PluginManifest manifest, bool isThirdParty)
|
|
{
|
|
var interfaceManager = Service<InterfaceManager>.Get();
|
|
var pluginManager = Service<PluginManager>.Get();
|
|
|
|
static bool TryLoadIcon(byte[] bytes, string? loc, PluginManifest manifest, InterfaceManager interfaceManager, out TextureWrap? icon)
|
|
{
|
|
// FIXME(goat): This is a hack around this call failing randomly in certain situations. Might be related to not being called on the main thread.
|
|
try
|
|
{
|
|
icon = interfaceManager.LoadImage(bytes);
|
|
}
|
|
catch (AccessViolationException ex)
|
|
{
|
|
Log.Error(ex, "Access violation during load plugin icon from {Loc}", loc);
|
|
|
|
icon = null;
|
|
return false;
|
|
}
|
|
|
|
if (icon == null)
|
|
{
|
|
Log.Error($"Could not load icon for {manifest.InternalName} at {loc}");
|
|
return false;
|
|
}
|
|
|
|
if (icon.Width > PluginIconWidth || icon.Height > PluginIconHeight)
|
|
{
|
|
Log.Error($"Icon for {manifest.InternalName} at {loc} was larger than the maximum allowed resolution ({PluginIconWidth}x{PluginIconHeight}).");
|
|
return false;
|
|
}
|
|
|
|
if (icon.Height != icon.Width)
|
|
{
|
|
Log.Error($"Icon for {manifest.InternalName} at {loc} was not square.");
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
if (plugin != null && plugin.IsDev)
|
|
{
|
|
var file = this.GetPluginIconFileInfo(plugin);
|
|
if (file != null)
|
|
{
|
|
Log.Verbose($"Fetching icon for {manifest.InternalName} from {file.FullName}");
|
|
|
|
var bytes = await File.ReadAllBytesAsync(file.FullName);
|
|
if (!TryLoadIcon(bytes, file.FullName, manifest, interfaceManager, out var icon))
|
|
return;
|
|
|
|
this.pluginIconMap[manifest.InternalName] = icon;
|
|
Log.Verbose($"Plugin icon for {manifest.InternalName} loaded from disk");
|
|
|
|
return;
|
|
}
|
|
|
|
// Dev plugins are likely going to look like a main repo plugin, the InstalledFrom field is going to be null.
|
|
// So instead, set the value manually so we download from the urls specified.
|
|
isThirdParty = true;
|
|
}
|
|
|
|
var useTesting = PluginManager.UseTesting(manifest);
|
|
var url = this.GetPluginIconUrl(manifest, isThirdParty, useTesting);
|
|
|
|
if (!url.IsNullOrEmpty())
|
|
{
|
|
Log.Verbose($"Downloading icon for {manifest.InternalName} from {url}");
|
|
|
|
HttpResponseMessage data;
|
|
try
|
|
{
|
|
data = await Util.HttpClient.GetAsync(url);
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
Log.Error($"Plugin icon for {manifest.InternalName} has an Invalid URI");
|
|
return;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, $"An unexpected error occurred with the icon for {manifest.InternalName}");
|
|
return;
|
|
}
|
|
|
|
if (data.StatusCode == HttpStatusCode.NotFound)
|
|
return;
|
|
|
|
data.EnsureSuccessStatusCode();
|
|
|
|
var bytes = await data.Content.ReadAsByteArrayAsync();
|
|
this.loadQueue.Add(() =>
|
|
{
|
|
if (!TryLoadIcon(bytes, url, manifest, interfaceManager, out var icon))
|
|
return;
|
|
|
|
this.pluginIconMap[manifest.InternalName] = icon;
|
|
Log.Verbose($"Plugin icon for {manifest.InternalName} downloaded");
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
Log.Verbose($"Plugin icon for {manifest.InternalName} is not available");
|
|
}
|
|
|
|
private async Task DownloadPluginImagesAsync(LocalPlugin? plugin, PluginManifest manifest, bool isThirdParty)
|
|
{
|
|
var interfaceManager = Service<InterfaceManager>.Get();
|
|
var pluginManager = Service<PluginManager>.Get();
|
|
|
|
static bool TryLoadImage(int i, byte[] bytes, string loc, PluginManifest manifest, InterfaceManager interfaceManager, out TextureWrap? image)
|
|
{
|
|
// FIXME(goat): This is a hack around this call failing randomly in certain situations. Might be related to not being called on the main thread.
|
|
try
|
|
{
|
|
image = interfaceManager.LoadImage(bytes);
|
|
}
|
|
catch (AccessViolationException ex)
|
|
{
|
|
Log.Error(ex, "Access violation during load plugin image from {Loc}", loc);
|
|
|
|
image = null;
|
|
return false;
|
|
}
|
|
|
|
if (image == null)
|
|
{
|
|
Log.Error($"Could not load image{i + 1} for {manifest.InternalName} at {loc}");
|
|
return false;
|
|
}
|
|
|
|
if (image.Width > PluginImageWidth || image.Height > PluginImageHeight)
|
|
{
|
|
Log.Error($"Plugin image{i + 1} for {manifest.InternalName} at {loc} was larger than the maximum allowed resolution ({PluginImageWidth}x{PluginImageHeight}).");
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
if (plugin is { IsDev: true })
|
|
{
|
|
var files = this.GetPluginImageFileInfos(plugin);
|
|
|
|
var didAny = false;
|
|
var pluginImages = new TextureWrap[files.Count];
|
|
for (var i = 0; i < files.Count; i++)
|
|
{
|
|
var file = files[i];
|
|
|
|
if (file == null)
|
|
continue;
|
|
|
|
Log.Verbose($"Loading image{i + 1} for {manifest.InternalName} from {file.FullName}");
|
|
var bytes = await File.ReadAllBytesAsync(file.FullName);
|
|
|
|
if (!TryLoadImage(i, bytes, file.FullName, manifest, interfaceManager, out var image))
|
|
continue;
|
|
|
|
Log.Verbose($"Plugin image{i + 1} for {manifest.InternalName} loaded from disk");
|
|
pluginImages[i] = image;
|
|
|
|
didAny = true;
|
|
}
|
|
|
|
if (didAny)
|
|
{
|
|
Log.Verbose($"Plugin images for {manifest.InternalName} loaded from disk");
|
|
|
|
if (pluginImages.Contains(null))
|
|
pluginImages = pluginImages.Where(image => image != null).ToArray();
|
|
|
|
this.pluginImagesMap[manifest.InternalName] = pluginImages;
|
|
|
|
return;
|
|
}
|
|
|
|
// Dev plugins are likely going to look like a main repo plugin, the InstalledFrom field is going to be null.
|
|
// So instead, set the value manually so we download from the urls specified.
|
|
isThirdParty = true;
|
|
}
|
|
|
|
var useTesting = PluginManager.UseTesting(manifest);
|
|
var urls = this.GetPluginImageUrls(manifest, isThirdParty, useTesting);
|
|
|
|
if (urls != null)
|
|
{
|
|
var imageBytes = new byte[urls.Count][];
|
|
|
|
var didAny = false;
|
|
|
|
for (var i = 0; i < urls.Count; i++)
|
|
{
|
|
var url = urls[i];
|
|
|
|
if (url.IsNullOrEmpty())
|
|
continue;
|
|
|
|
Log.Verbose($"Downloading image{i + 1} for {manifest.InternalName} from {url}");
|
|
|
|
HttpResponseMessage data;
|
|
try
|
|
{
|
|
data = await Util.HttpClient.GetAsync(url);
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
Log.Error($"Plugin image{i + 1} for {manifest.InternalName} has an Invalid URI");
|
|
continue;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, $"An unexpected error occurred with image{i + 1} for {manifest.InternalName}");
|
|
continue;
|
|
}
|
|
|
|
if (data.StatusCode == HttpStatusCode.NotFound)
|
|
continue;
|
|
|
|
data.EnsureSuccessStatusCode();
|
|
|
|
var bytes = await data.Content.ReadAsByteArrayAsync();
|
|
imageBytes[i] = bytes;
|
|
|
|
Log.Verbose($"Plugin image{i + 1} for {manifest.InternalName} downloaded");
|
|
|
|
didAny = true;
|
|
}
|
|
|
|
if (didAny)
|
|
{
|
|
this.loadQueue.Add(() =>
|
|
{
|
|
var pluginImages = new TextureWrap[urls.Count];
|
|
|
|
for (var i = 0; i < imageBytes.Length; i++)
|
|
{
|
|
var bytes = imageBytes[i];
|
|
if (bytes == null)
|
|
continue;
|
|
|
|
if (!TryLoadImage(i, bytes, "queue", manifest, interfaceManager, out var image))
|
|
continue;
|
|
|
|
pluginImages[i] = image;
|
|
}
|
|
|
|
Log.Verbose($"Plugin images for {manifest.InternalName} downloaded");
|
|
|
|
if (pluginImages.Contains(null))
|
|
pluginImages = pluginImages.Where(image => image != null).ToArray();
|
|
|
|
this.pluginImagesMap[manifest.InternalName] = pluginImages;
|
|
});
|
|
}
|
|
}
|
|
|
|
Log.Verbose($"Images for {manifest.InternalName} are not available");
|
|
}
|
|
|
|
private string? GetPluginIconUrl(PluginManifest manifest, bool isThirdParty, bool isTesting)
|
|
{
|
|
if (isThirdParty)
|
|
return manifest.IconUrl;
|
|
|
|
return MainRepoImageUrl.Format(isTesting ? "testing" : "plugins", manifest.InternalName, "icon.png");
|
|
}
|
|
|
|
private List<string?>? GetPluginImageUrls(PluginManifest manifest, bool isThirdParty, bool isTesting)
|
|
{
|
|
if (isThirdParty)
|
|
{
|
|
if (manifest.ImageUrls?.Count > 5)
|
|
{
|
|
Log.Warning($"Plugin {manifest.InternalName} has too many images");
|
|
return manifest.ImageUrls.Take(5).ToList();
|
|
}
|
|
|
|
return manifest.ImageUrls;
|
|
}
|
|
|
|
var output = new List<string>();
|
|
for (var i = 1; i <= 5; i++)
|
|
{
|
|
output.Add(MainRepoImageUrl.Format(isTesting ? "testing" : "plugins", manifest.InternalName, $"image{i}.png"));
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
private FileInfo? GetPluginIconFileInfo(LocalPlugin? plugin)
|
|
{
|
|
var pluginDir = plugin.DllFile.Directory;
|
|
|
|
var devUrl = new FileInfo(Path.Combine(pluginDir.FullName, "images", "icon.png"));
|
|
if (devUrl.Exists)
|
|
return devUrl;
|
|
|
|
return null;
|
|
}
|
|
|
|
private List<FileInfo?> GetPluginImageFileInfos(LocalPlugin? plugin)
|
|
{
|
|
var pluginDir = plugin.DllFile.Directory;
|
|
var output = new List<FileInfo>();
|
|
for (var i = 1; i <= 5; i++)
|
|
{
|
|
var devUrl = new FileInfo(Path.Combine(pluginDir.FullName, "images", $"image{i}.png"));
|
|
if (devUrl.Exists)
|
|
{
|
|
output.Add(devUrl);
|
|
continue;
|
|
}
|
|
|
|
output.Add(null);
|
|
}
|
|
|
|
return output;
|
|
}
|
|
}
|
|
}
|