Fix plugin images not loading (refactor PluginImageCache) (#905)

This commit is contained in:
kizer 2022-06-30 01:12:57 +09:00 committed by GitHub
parent d9c38a9813
commit e114f8a597
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 224 additions and 199 deletions

View file

@ -4,9 +4,9 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Net.Http;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Dalamud.Game; using Dalamud.Game;
using Dalamud.Plugin.Internal; using Dalamud.Plugin.Internal;
using Dalamud.Plugin.Internal.Types; using Dalamud.Plugin.Internal.Types;
@ -50,8 +50,8 @@ namespace Dalamud.Interface.Internal.Windows
private readonly Task downloadTask; private readonly Task downloadTask;
private readonly Task loadTask; private readonly Task loadTask;
private readonly Dictionary<string, TextureWrap?> pluginIconMap = new(); private readonly ConcurrentDictionary<string, TextureWrap?> pluginIconMap = new();
private readonly Dictionary<string, TextureWrap?[]> pluginImagesMap = new(); private readonly ConcurrentDictionary<string, TextureWrap?[]?> pluginImagesMap = new();
private readonly Task<TextureWrap> emptyTextureTask; private readonly Task<TextureWrap> emptyTextureTask;
private readonly Task<TextureWrap> defaultIconTask; private readonly Task<TextureWrap> defaultIconTask;
@ -209,26 +209,26 @@ namespace Dalamud.Interface.Internal.Windows
/// <returns>True if an entry exists, may be null if currently downloading.</returns> /// <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) public bool TryGetIcon(LocalPlugin? plugin, PluginManifest manifest, bool isThirdParty, out TextureWrap? iconTexture)
{ {
if (this.pluginIconMap.TryGetValue(manifest.InternalName, out iconTexture)) if (!this.pluginIconMap.TryAdd(manifest.InternalName, null))
{
iconTexture = this.pluginIconMap[manifest.InternalName];
return true; return true;
}
this.pluginIconMap.Add(manifest.InternalName, null); iconTexture = null;
var requestedFrame = Service<DalamudInterface>.GetNullable()?.FrameCount ?? 0;
Task.Run(async () =>
{
try try
{ {
if (!this.downloadQueue.IsCompleted) this.pluginIconMap[manifest.InternalName] =
await this.DownloadPluginIconAsync(plugin, manifest, isThirdParty, requestedFrame);
}
catch (Exception ex)
{ {
this.downloadQueue.Add( Log.Error(ex, $"An unexpected error occurred with the icon for {manifest.InternalName}");
Tuple.Create(
Service<DalamudInterface>.GetNullable()?.FrameCount ?? 0,
() => this.DownloadPluginIconAsync(plugin, manifest, isThirdParty)),
this.cancelToken.Token);
}
}
catch (ObjectDisposedException)
{
// pass
} }
});
return false; return false;
} }
@ -244,33 +244,35 @@ namespace Dalamud.Interface.Internal.Windows
/// <returns>True if the image array exists, may be empty if currently downloading.</returns> /// <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) public bool TryGetImages(LocalPlugin? plugin, PluginManifest manifest, bool isThirdParty, out TextureWrap?[] imageTextures)
{ {
if (this.pluginImagesMap.TryGetValue(manifest.InternalName, out imageTextures)) if (!this.pluginImagesMap.TryAdd(manifest.InternalName, null))
{
var found = this.pluginImagesMap[manifest.InternalName];
imageTextures = found ?? Array.Empty<TextureWrap?>();
return true; return true;
}
imageTextures = Array.Empty<TextureWrap>(); var target = new TextureWrap?[5];
this.pluginImagesMap.Add(manifest.InternalName, imageTextures); this.pluginImagesMap[manifest.InternalName] = target;
imageTextures = target;
var requestedFrame = Service<DalamudInterface>.GetNullable()?.FrameCount ?? 0;
Task.Run(async () =>
{
try try
{ {
if (!this.downloadQueue.IsCompleted) await this.DownloadPluginImagesAsync(target, plugin, manifest, isThirdParty, requestedFrame);
}
catch (Exception ex)
{ {
this.downloadQueue.Add( Log.Error(ex, $"An unexpected error occurred with the images for {manifest.InternalName}");
Tuple.Create(
Service<DalamudInterface>.GetNullable()?.FrameCount ?? 0,
() => this.DownloadPluginImagesAsync(plugin, manifest, isThirdParty)),
this.cancelToken.Token);
}
}
catch (ObjectDisposedException)
{
// pass
} }
});
return false; return false;
} }
private static async Task<TextureWrap?> TryLoadIcon( private static async Task<TextureWrap?> TryLoadImage(
byte[] bytes, byte[]? bytes,
string name, string name,
string? loc, string? loc,
PluginManifest manifest, PluginManifest manifest,
@ -278,14 +280,17 @@ namespace Dalamud.Interface.Internal.Windows
int maxHeight, int maxHeight,
bool requireSquare) bool requireSquare)
{ {
if (bytes == null)
return null;
var interfaceManager = (await Service<InterfaceManager.InterfaceManagerWithScene>.GetAsync()).Manager; var interfaceManager = (await Service<InterfaceManager.InterfaceManagerWithScene>.GetAsync()).Manager;
var framework = await Service<Framework>.GetAsync(); var framework = await Service<Framework>.GetAsync();
TextureWrap? icon; 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. // 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 try
{ {
icon = interfaceManager.LoadImage(bytes); image = interfaceManager.LoadImage(bytes);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -293,7 +298,7 @@ namespace Dalamud.Interface.Internal.Windows
try try
{ {
icon = await framework.RunOnFrameworkThread(() => interfaceManager.LoadImage(bytes)); image = await framework.RunOnFrameworkThread(() => interfaceManager.LoadImage(bytes));
} }
catch (Exception ex2) catch (Exception ex2)
{ {
@ -302,27 +307,61 @@ namespace Dalamud.Interface.Internal.Windows
} }
} }
if (icon == null) if (image == null)
{ {
Log.Error($"Could not load {name} for {manifest.InternalName} at {loc}"); Log.Error($"Could not load {name} for {manifest.InternalName} at {loc}");
return null; return null;
} }
if (icon.Width > maxWidth || icon.Height > maxHeight) if (image.Width > maxWidth || image.Height > maxHeight)
{ {
Log.Error($"Plugin {name} for {manifest.InternalName} at {loc} was larger than the maximum allowed resolution ({maxWidth}x{maxHeight})."); Log.Error($"Plugin {name} for {manifest.InternalName} at {loc} was larger than the maximum allowed resolution ({image.Width}x{image.Height} > {maxWidth}x{maxHeight}).");
icon.Dispose(); image.Dispose();
return null; return null;
} }
if (requireSquare && icon.Height != icon.Width) if (requireSquare && image.Height != image.Width)
{ {
Log.Error($"Plugin {name} for {manifest.InternalName} at {loc} was not square."); Log.Error($"Plugin {name} for {manifest.InternalName} at {loc} was not square.");
icon.Dispose(); image.Dispose();
return null; return null;
} }
return icon!; return image!;
}
private Task<T> RunInDownloadQueue<T>(Func<Task<T>> func, ulong requestedFrame)
{
var tcs = new TaskCompletionSource<T>();
this.downloadQueue.Add(Tuple.Create(requestedFrame, async () =>
{
try
{
tcs.SetResult(await func());
}
catch (Exception e)
{
tcs.SetException(e);
}
}));
return tcs.Task;
}
private Task<T> RunInLoadQueue<T>(Func<Task<T>> func)
{
var tcs = new TaskCompletionSource<T>();
this.loadQueue.Add(async () =>
{
try
{
tcs.SetResult(await func());
}
catch (Exception e)
{
tcs.SetException(e);
}
});
return tcs.Task;
} }
private async Task DownloadTask(int concurrency) private async Task DownloadTask(int concurrency)
@ -422,24 +461,32 @@ namespace Dalamud.Interface.Internal.Windows
Log.Debug("Plugin image loader has shutdown"); Log.Debug("Plugin image loader has shutdown");
} }
private async Task DownloadPluginIconAsync(LocalPlugin? plugin, PluginManifest manifest, bool isThirdParty) private async Task<TextureWrap?> DownloadPluginIconAsync(LocalPlugin? plugin, PluginManifest manifest, bool isThirdParty, ulong requestedFrame)
{ {
if (plugin != null && plugin.IsDev) if (plugin is { IsDev: true })
{ {
var file = this.GetPluginIconFileInfo(plugin); var file = this.GetPluginIconFileInfo(plugin);
if (file != null) if (file != null)
{ {
Log.Verbose($"Fetching icon for {manifest.InternalName} from {file.FullName}"); Log.Verbose($"Fetching icon for {manifest.InternalName} from {file.FullName}");
var bytes = await File.ReadAllBytesAsync(file.FullName); var fileBytes = await this.RunInDownloadQueue(
var icon = await TryLoadIcon(bytes, "icon", file.FullName, manifest, PluginIconWidth, PluginIconHeight, true); () => File.ReadAllBytesAsync(file.FullName),
if (icon == null) requestedFrame);
return; var fileIcon = await this.RunInLoadQueue(
() => TryLoadImage(
this.pluginIconMap[manifest.InternalName] = icon; fileBytes,
"icon",
file.FullName,
manifest,
PluginIconWidth,
PluginIconHeight,
true));
if (fileIcon != null)
{
Log.Verbose($"Plugin icon for {manifest.InternalName} loaded from disk"); Log.Verbose($"Plugin icon for {manifest.InternalName} loaded from disk");
return fileIcon;
return; }
} }
// Dev plugins are likely going to look like a main repo plugin, the InstalledFrom field is going to be null. // Dev plugins are likely going to look like a main repo plugin, the InstalledFrom field is going to be null.
@ -450,88 +497,84 @@ namespace Dalamud.Interface.Internal.Windows
var useTesting = PluginManager.UseTesting(manifest); var useTesting = PluginManager.UseTesting(manifest);
var url = this.GetPluginIconUrl(manifest, isThirdParty, useTesting); var url = this.GetPluginIconUrl(manifest, isThirdParty, useTesting);
if (!url.IsNullOrEmpty()) if (url.IsNullOrEmpty())
{ {
Log.Verbose($"Plugin icon for {manifest.InternalName} is not available");
return null;
}
Log.Verbose($"Downloading icon for {manifest.InternalName} from {url}"); Log.Verbose($"Downloading icon for {manifest.InternalName} from {url}");
HttpResponseMessage data; // ReSharper disable once RedundantTypeArgumentsOfMethod
try var bytes = await this.RunInDownloadQueue<byte[]?>(
async () =>
{ {
data = await Util.HttpClient.GetAsync(url); var 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) if (data.StatusCode == HttpStatusCode.NotFound)
return; return null;
data.EnsureSuccessStatusCode(); data.EnsureSuccessStatusCode();
return await data.Content.ReadAsByteArrayAsync();
},
requestedFrame);
var bytes = await data.Content.ReadAsByteArrayAsync(); if (bytes == null)
this.loadQueue.Add(async () => return null;
{
var icon = await TryLoadIcon(bytes, "icon", url, manifest, PluginIconWidth, PluginIconHeight, true);
if (icon == null)
return;
this.pluginIconMap[manifest.InternalName] = icon; var icon = await this.RunInLoadQueue(
Log.Verbose($"Plugin icon for {manifest.InternalName} downloaded"); () => TryLoadImage(bytes, "icon", url, manifest, PluginIconWidth, PluginIconHeight, true));
}); if (icon != null)
Log.Verbose($"Plugin icon for {manifest.InternalName} loaded");
return; return icon;
} }
Log.Verbose($"Plugin icon for {manifest.InternalName} is not available"); private async Task DownloadPluginImagesAsync(TextureWrap?[] pluginImages, LocalPlugin? plugin, PluginManifest manifest, bool isThirdParty, ulong requestedFrame)
}
private async Task DownloadPluginImagesAsync(LocalPlugin? plugin, PluginManifest manifest, bool isThirdParty)
{ {
if (plugin is { IsDev: true }) if (plugin is { IsDev: true })
{ {
var files = this.GetPluginImageFileInfos(plugin); var fileTasks = new List<Task>();
var files = this.GetPluginImageFileInfos(plugin)
var didAny = false; .Where(x => x is { Exists: true })
var pluginImages = new TextureWrap[files.Count]; .Select(x => (FileInfo)x!)
for (var i = 0; i < files.Count; i++) .ToList();
for (var i = 0; i < files.Count && i < pluginImages.Length; i++)
{ {
var file = files[i]; var file = files[i];
var i2 = i;
if (file == null) fileTasks.Add(Task.Run(async () =>
continue;
Log.Verbose($"Loading image{i + 1} for {manifest.InternalName} from {file.FullName}");
var bytes = await File.ReadAllBytesAsync(file.FullName);
var image = await TryLoadIcon(bytes, $"image{i + 1}", file.FullName, manifest, PluginImageWidth, PluginImageHeight, true);
if (image == null)
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"); var bytes = await this.RunInDownloadQueue(
() => File.ReadAllBytesAsync(file.FullName),
if (pluginImages.Contains(null)) requestedFrame);
pluginImages = pluginImages.Where(image => image != null).ToArray(); var image = await this.RunInLoadQueue(
() => TryLoadImage(
this.pluginImagesMap[manifest.InternalName] = pluginImages; bytes,
$"image{i2 + 1}",
file.FullName,
manifest,
PluginImageWidth,
PluginImageHeight,
false));
if (image == null)
return; return;
Log.Verbose($"Plugin image{i2 + 1} for {manifest.InternalName} loaded from disk");
pluginImages[i2] = image;
}));
} }
try
{
await Task.WhenAll(fileTasks);
}
catch (Exception ex)
{
Log.Error(ex, $"Failed to load at least one plugin image from filesystem");
}
if (pluginImages.Any(x => x != null))
return;
// Dev plugins are likely going to look like a main repo plugin, the InstalledFrom field is going to be null. // 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. // So instead, set the value manually so we download from the urls specified.
isThirdParty = true; isThirdParty = true;
@ -539,79 +582,61 @@ namespace Dalamud.Interface.Internal.Windows
var useTesting = PluginManager.UseTesting(manifest); var useTesting = PluginManager.UseTesting(manifest);
var urls = this.GetPluginImageUrls(manifest, isThirdParty, useTesting); var urls = this.GetPluginImageUrls(manifest, isThirdParty, useTesting);
urls = urls?.Where(x => !string.IsNullOrEmpty(x)).ToList();
if (urls != null) if (urls?.Any() != true)
{ {
var imageBytes = new byte[urls.Count][]; Log.Verbose($"Images for {manifest.InternalName} are not available");
return;
}
var didAny = false; var tasks = new List<Task>();
for (var i = 0; i < urls.Count && i < pluginImages.Length; i++)
for (var i = 0; i < urls.Count; i++)
{ {
var i2 = i;
var url = urls[i]; var url = urls[i];
tasks.Add(Task.Run(async () =>
{
Log.Verbose($"Downloading image{i2 + 1} for {manifest.InternalName} from {url}");
// ReSharper disable once RedundantTypeArgumentsOfMethod
var bytes = await this.RunInDownloadQueue<byte[]?>(
async () =>
{
var data = await Util.HttpClient.GetAsync(url);
if (data.StatusCode == HttpStatusCode.NotFound)
return null;
if (url.IsNullOrEmpty()) data.EnsureSuccessStatusCode();
continue; return await data.Content.ReadAsByteArrayAsync();
},
requestedFrame);
Log.Verbose($"Downloading image{i + 1} for {manifest.InternalName} from {url}"); if (bytes == null)
return;
var image = await TryLoadImage(
bytes,
$"image{i2 + 1}",
"queue",
manifest,
PluginImageWidth,
PluginImageHeight,
false);
if (image == null)
return;
Log.Verbose($"Image{i2 + 1} for {manifest.InternalName} loaded");
pluginImages[i2] = image;
}));
}
HttpResponseMessage data;
try try
{ {
data = await Util.HttpClient.GetAsync(url); await Task.WhenAll(tasks);
}
catch (InvalidOperationException)
{
Log.Error($"Plugin image{i + 1} for {manifest.InternalName} has an Invalid URI");
continue;
} }
catch (Exception ex) catch (Exception ex)
{ {
Log.Error(ex, $"An unexpected error occurred with image{i + 1} for {manifest.InternalName}"); Log.Error(ex, "Failed to load at least one plugin image from network.");
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(async () =>
{
var pluginImages = new TextureWrap[urls.Count];
for (var i = 0; i < imageBytes.Length; i++)
{
var bytes = imageBytes[i];
var image = await TryLoadIcon(bytes, $"image{i + 1}", "queue", manifest, PluginImageWidth, PluginImageHeight, true);
if (image == null)
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) private string? GetPluginIconUrl(PluginManifest manifest, bool isThirdParty, bool isTesting)

View file

@ -142,12 +142,12 @@ namespace Dalamud.Interface.Internal.Windows.PluginInstaller
/// <inheritdoc/> /// <inheritdoc/>
public void Dispose() public void Dispose()
{ {
var pluginManager = Service<PluginManager>.Get(); var pluginManager = Service<PluginManager>.GetNullable();
if (pluginManager != null)
{
pluginManager.OnAvailablePluginsChanged -= this.OnAvailablePluginsChanged; pluginManager.OnAvailablePluginsChanged -= this.OnAvailablePluginsChanged;
pluginManager.OnInstalledPluginsChanged -= this.OnInstalledPluginsChanged; pluginManager.OnInstalledPluginsChanged -= this.OnInstalledPluginsChanged;
}
this.imageCache?.Dispose();
} }
/// <inheritdoc/> /// <inheritdoc/>
@ -1964,7 +1964,7 @@ namespace Dalamud.Interface.Internal.Windows.PluginInstaller
private bool DrawPluginImages(LocalPlugin? plugin, PluginManifest manifest, bool isThirdParty, int index) private bool DrawPluginImages(LocalPlugin? plugin, PluginManifest manifest, bool isThirdParty, int index)
{ {
var hasImages = this.imageCache.TryGetImages(plugin, manifest, isThirdParty, out var imageTextures); var hasImages = this.imageCache.TryGetImages(plugin, manifest, isThirdParty, out var imageTextures);
if (!hasImages || imageTextures.Length == 0) if (!hasImages || imageTextures.All(x => x == null))
return false; return false;
const float thumbFactor = 2.7f; const float thumbFactor = 2.7f;