-
Notifications
You must be signed in to change notification settings - Fork 788
Expand file tree
/
Copy pathIconCacheEngine.cs
More file actions
437 lines (386 loc) · 17.8 KB
/
IconCacheEngine.cs
File metadata and controls
437 lines (386 loc) · 17.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
extern alias DrawingCommon;
using System.Collections.ObjectModel;
using System.Security.Cryptography;
using PhotoSauce.MagicScaler;
using UniGetUI.Core.Classes;
using UniGetUI.Core.Data;
using UniGetUI.Core.Logging;
using UniGetUI.Core.Tools;
namespace UniGetUI.Core.IconEngine
{
public enum IconValidationMethod
{
SHA256,
FileSize,
PackageVersion,
UriSource
}
public readonly struct CacheableIcon
{
public readonly Uri Url;
public readonly byte[] SHA256 = [];
public readonly string Version = "";
public readonly long Size = -1;
private readonly int _hashCode = -1;
public readonly bool IsLocalPath = false;
public readonly string LocalPath = "";
public readonly IconValidationMethod ValidationMethod;
/// <summary>
/// Build a cacheable icon with SHA256 verification
/// </summary>
/// <param name="uri"></param>
/// <param name="Sha256"></param>
public CacheableIcon(Uri uri, byte[] Sha256)
{
Url = uri;
this.SHA256 = Sha256;
ValidationMethod = IconValidationMethod.SHA256;
_hashCode = uri.ToString().GetHashCode() + Sha256[0] + Sha256[1] + Sha256[2] + Sha256[3];
}
/// <summary>
/// Build a cacheable icon with Version verification
/// </summary>
/// <param name="uri"></param>
/// <param name="version"></param>
public CacheableIcon(Uri uri, string version)
{
Url = uri;
Version = version;
ValidationMethod = IconValidationMethod.PackageVersion;
_hashCode = uri.ToString().GetHashCode() + version.GetHashCode();
}
/// <summary>
/// Build a cacheable icon with Size verification
/// </summary>
/// <param name="uri"></param>
/// <param name="size"></param>
public CacheableIcon(Uri uri, long size)
{
Url = uri;
Size = size;
ValidationMethod = IconValidationMethod.FileSize;
_hashCode = uri.ToString().GetHashCode() + (int)size;
}
/// <summary>
/// Build a cacheable icon with Uri verification
/// </summary>
/// <param name="uri"></param>
public CacheableIcon(Uri uri)
{
Url = uri;
ValidationMethod = IconValidationMethod.UriSource;
_hashCode = uri.ToString().GetHashCode();
}
public CacheableIcon(string path)
{
IsLocalPath = true;
LocalPath = path;
Url = new Uri(path);
}
public override int GetHashCode()
{
return _hashCode;
}
}
public static class IconCacheEngine
{
/// <summary>
/// Returns the path to the icon file, downloading it if necessary
/// </summary>
/// <param name="icon">a CacheableIcon object representing the object</param>
/// <param name="ManagerName">The name of the PackageManager</param>
/// <param name="PackageId">the Id of the package</param>
/// <param name="cacheInterval">the Time to store the icons on the TaskRecycler cache</param>
/// <returns>A path to a local icon file</returns>
public static string? GetCacheOrDownloadIcon(CacheableIcon? icon, string ManagerName, string PackageId, int cacheInterval = 30)
=> TaskRecycler<string?>.RunOrAttach(_getCacheOrDownloadIcon, icon, ManagerName, PackageId, cacheInterval);
private static string? _getCacheOrDownloadIcon(CacheableIcon? _icon, string ManagerName, string PackageId)
{
if (_icon is null)
return null;
var icon = _icon.Value;
if(icon.IsLocalPath)
return icon.LocalPath;
string iconLocation = Path.Join(CoreData.UniGetUICacheDirectory_Icons, ManagerName, PackageId);
if (!Directory.Exists(iconLocation)) Directory.CreateDirectory(iconLocation);
string iconVersionFile = Path.Join(iconLocation, $"icon.version");
string iconUriFile = Path.Join(iconLocation, $"icon.uri");
// Get a local cache, if any
string? cachedIconFile = GetLocalCachedFile(icon, iconLocation);
bool isLocalCacheValid = // Verify if the cached icon exists and is valid
cachedIconFile is not null &&
icon.ValidationMethod switch
{
IconValidationMethod.FileSize => ValidateByImageSize(icon, cachedIconFile),
IconValidationMethod.SHA256 => ValidateBySHA256(icon, cachedIconFile),
IconValidationMethod.PackageVersion => ValidateByVersion(icon, iconVersionFile),
IconValidationMethod.UriSource => ValidateByUri(icon, iconUriFile),
_ => throw new InvalidDataException("Invalid icon validation method"),
};
// If a valid cache was found, return that cache
if (isLocalCacheValid)
{
Logger.Debug($"Cached icon for id={PackageId} is valid and won't be downloaded again ({icon.ValidationMethod})");
return cachedIconFile;
}
if (cachedIconFile is not null)
Logger.ImportantInfo($"Cached icon for id={PackageId} is INVALID ({icon.ValidationMethod})");
return SaveIconToCacheAndGetPath(icon, iconLocation);
}
private static string? GetLocalCachedFile(CacheableIcon icon, string iconLocation)
{
if (!Directory.Exists(iconLocation))
return null; // The directory does not exist
string iconFileMime = Path.Join(iconLocation, $"icon.mime");
if (!File.Exists(iconFileMime))
return null; // If there is no mimetype for the saved icon
if (!MimeToExtension.TryGetValue(File.ReadAllText(iconFileMime), out string? extension))
return null; // If the saved mimetype is not valid
string cachedIconFile = Path.Join(iconLocation, $"icon.{extension}");
if (!File.Exists(cachedIconFile))
return null; // If there is no saved file cache
string iconVersionFile = Path.Join(iconLocation, $"icon.version");
string iconUriFile = Path.Join(iconLocation, $"icon.uri");
if (icon.ValidationMethod is IconValidationMethod.PackageVersion && !File.Exists(iconVersionFile))
return null; // If version file does not exist and icon is versioned
if (icon.ValidationMethod is IconValidationMethod.UriSource && !File.Exists(iconUriFile))
return null; // If uri file does not exist and icon is versioned
return cachedIconFile;
}
private static string? SaveIconToCacheAndGetPath(CacheableIcon icon, string iconLocation)
{
try
{
string iconVersionFile = Path.Join(iconLocation, $"icon.version");
string iconUriFile = Path.Join(iconLocation, $"icon.uri");
// If the cache is determined to NOT be valid, delete cache
DeteteCachedFiles(iconLocation);
// After discarding the cache, regenerate it
using HttpClient client = new(CoreTools.GenericHttpClientParameters);
client.DefaultRequestHeaders.UserAgent.ParseAdd(CoreData.UserAgentString);
using HttpResponseMessage response = client.GetAsync(icon.Url, HttpCompletionOption.ResponseHeadersRead).GetAwaiter().GetResult();
if (!response.IsSuccessStatusCode)
{
Logger.Warn($"Icon download attempt at {icon.Url} failed with code {response.StatusCode}");
return null;
}
string? mimeType = response.Content.Headers.ContentType?.MediaType;
if (string.IsNullOrWhiteSpace(mimeType))
{
Logger.Warn($"No Content-Type was returned for icon {icon.Url}, aborting download");
return null;
}
if (!MimeToExtension.TryGetValue(mimeType, out string? extension))
{
Logger.Warn($"Unknown mimetype {mimeType} for icon {icon.Url}, aborting download");
return null;
}
string cachedIconFile = Path.Join(iconLocation, $"icon.{extension}");
string iconFileMime = Path.Join(iconLocation, $"icon.mime");
File.WriteAllText(iconFileMime, mimeType);
using (Stream stream = response.Content.ReadAsStream())
using (FileStream fileStream = File.Create(cachedIconFile))
{
stream.CopyTo(fileStream);
}
if (icon.ValidationMethod is IconValidationMethod.PackageVersion)
File.WriteAllText(iconVersionFile, icon.Version);
if (icon.ValidationMethod is IconValidationMethod.UriSource)
File.WriteAllText(iconUriFile, icon.Url.ToString());
// Ensure the new icon has been properly downloaded
bool isNewCacheValid = icon.ValidationMethod switch
{
IconValidationMethod.FileSize => ValidateByImageSize(icon, cachedIconFile),
IconValidationMethod.SHA256 => ValidateBySHA256(icon, cachedIconFile),
IconValidationMethod.PackageVersion => true, // The validation result would be always true
IconValidationMethod.UriSource => true, // The validation result would be always true
_ => throw new InvalidDataException("Invalid icon validation method"),
};
if (isNewCacheValid)
{
if (icon.ValidationMethod is IconValidationMethod.PackageVersion or IconValidationMethod.UriSource
&& new[] { "png", "webp", "tif", "avif" }.Contains(extension))
{
DownsizeImage(cachedIconFile, extension);
}
Logger.Debug($"Icon for Location={iconLocation} has been downloaded and verified properly (if applicable) ({icon.ValidationMethod})");
return cachedIconFile;
}
Logger.Warn($"NEWLY DOWNLOADED Icon for Location={iconLocation} Uri={icon.Url} is NOT VALID and will be discarded (verification method is {icon.ValidationMethod})");
DeteteCachedFiles(iconLocation);
return null;
}
catch (HttpRequestException ex)
{
string socketData = "";
if (ex.InnerException is IOException ioEx && ioEx.InnerException is System.Net.Sockets.SocketException socketEx)
{
socketData = $" [SocketErrorCode={socketEx.SocketErrorCode}, NativeError={socketEx.NativeErrorCode}]";
}
Logger.Warn($"Failed to download icon from {icon.Url}: {ex.Message}{socketData}");
return null;
}
catch (IOException ex)
{
Logger.Warn($"I/O error while saving icon from {icon.Url}: {ex.Message}");
return null;
}
catch (Exception ex)
{
Logger.Error(ex);
return null;
}
}
/// <summary>
/// The given image will be downsized to the expected size of an icon, if required
/// </summary>
private static void DownsizeImage(string cachedIconFile, string extension)
{ // Yes, the extension parameter could be extracted from cachedIconFile
try
{
const int MAX_SIDE = 192;
int width, height;
using (var fileStream = new FileStream(cachedIconFile, FileMode.Open, FileAccess.Read, FileShare.Read))
using (var image = DrawingCommon.System.Drawing.Image.FromStream(fileStream, false, false))
{
height = image.Height;
width = image.Width;
}
// Calculate target size for the icon to be at max 192x192.
if (width > MAX_SIDE || height > MAX_SIDE)
{
File.Move(cachedIconFile, $"{cachedIconFile}.copy");
var image = MagicImageProcessor.BuildPipeline($"{cachedIconFile}.copy", new ProcessImageSettings
{
Width = MAX_SIDE,
Height = MAX_SIDE,
ResizeMode = CropScaleMode.Contain,
});
// Apply changes and save the image to disk
using (FileStream fileStream = File.Create(cachedIconFile))
{
image.WriteOutput(fileStream);
}
Logger.Debug($"File {cachedIconFile} was downsized from {width}x{height} to {image.Settings.Width}x{image.Settings.Height}");
image.Dispose();
File.Delete($"{cachedIconFile}.copy");
}
else
{
Logger.Debug($"File {cachedIconFile} had an already appropiate size of {width}x{height}");
}
}
catch (Exception ex)
{
Logger.Error($"An error occurred while downsizing the image file {cachedIconFile}");
Logger.Error(ex);
}
}
/// <summary>
/// Checks whether a cached image is valid or not depending on the size (in bytes) of the image
/// </summary>
private static bool ValidateByImageSize(CacheableIcon icon, string cachedIconPath)
{
try
{
FileInfo fileInfo = new FileInfo(cachedIconPath);
return icon.Size == fileInfo.Length;
}
catch (Exception e)
{
Logger.Warn($"Failed to verify icon file size for {icon.Url} via FileSize with error {e.Message}");
return false;
}
}
/// <summary>
/// Checks whether a cached image is valid or not depending on its SHA256 hash
/// </summary>
private static bool ValidateBySHA256(CacheableIcon icon, string cachedIconPath)
{
try
{
using FileStream stream = File.OpenRead(cachedIconPath);
using SHA256 sha256 = SHA256.Create();
return (sha256.ComputeHash(stream)).SequenceEqual(icon.SHA256);
}
catch (Exception e)
{
Logger.Warn($"Failed to verify icon file size for {icon.Url} via Sha256 with error {e.Message}");
return false;
}
}
/// <summary>
/// Checks whether a cached image is valid or not depending on the package version it was pulled from
/// </summary>
private static bool ValidateByVersion(CacheableIcon icon, string versionPath)
{
try
{
return File.Exists(versionPath) && CoreTools.VersionStringToStruct(File.ReadAllText(versionPath)) >= CoreTools.VersionStringToStruct(icon.Version);
}
catch (Exception e)
{
Logger.Warn($"Failed to verify icon file size for {icon.Url} via PackageVersion with error {e.Message}");
return false;
}
}
/// <summary>
/// Checks whether a cached image is valid or not depending on the URI it was pulled from
/// </summary>
private static bool ValidateByUri(CacheableIcon icon, string uriPath)
{
try
{
return File.Exists(uriPath) && File.ReadAllText(uriPath) == icon.Url.ToString();
}
catch (Exception e)
{
Logger.Warn($"Failed to verify icon file size for {icon.Url} via UriSource with error {e.Message}");
return false;
}
}
/// <summary>
/// Deletes all the cache files for a [icon] directory
/// </summary>
private static void DeteteCachedFiles(string iconLocation)
{
try
{
foreach (string file in Directory.GetFiles(iconLocation))
{
File.Delete(file);
}
}
catch (Exception e)
{
Logger.Warn($"An error occurred while deleting old icon cache: {e.Message}");
}
}
public static readonly ReadOnlyDictionary<string, string> MimeToExtension = new ReadOnlyDictionary<string, string>(new Dictionary<string, string>
{
{"image/avif", "avif"},
{"image/gif", "gif"},
// {"image/bmp", "bmp"}, Should non-transparent types be allowed?
// {"image/jpeg", "jpg"},
{"image/png", "png"},
{"image/webp", "webp"},
{"image/svg+xml", "svg"},
{"image/vnd.microsoft.icon", "ico"},
{"application/octet-stream", "ico"},
{"image/image/x-icon", "ico"},
{"image/tiff", "tif"},
});
public static readonly ReadOnlyDictionary<string, string> ExtensionToMime = new ReadOnlyDictionary<string, string>(new Dictionary<string, string>
{
{"avif", "image/avif"},
{"gif", "image/gif"},
// {"bmp", "image/bmp"}, Should non-transparent types be allowed
// {"jpg", "image/jpeg"},
{"png", "image/png"},
{"webp", "image/webp"},
{"svg", "image/svg+xml"},
{"ico", "image/image/x-icon"},
{"tif", "image/tiff"},
});
}
}