From f7b585803d120c68685c7ac646e90ea3968d43ca Mon Sep 17 00:00:00 2001 From: Ioannis G Date: Tue, 4 Jul 2023 23:44:35 +0300 Subject: [PATCH 1/6] rework how we fetch plugins from manifest URLs Introduces the concept of a store of community plugins, which is currently limited to the official PluginsManifest repository. Each store can support more than one manifest file URLs. When fetching, all URLs are used until one of them succeeds. This fixes issues with geo-blocking such as #2195 Plugin stores can be expanded in the future to be user-configurable, see #2178 --- .../ExternalPlugins/CommunityPluginSource.cs | 59 +++++++++++++++++++ .../ExternalPlugins/CommunityPluginStore.cs | 54 +++++++++++++++++ .../ExternalPlugins/PluginsManifest.cs | 34 +++-------- 3 files changed, 120 insertions(+), 27 deletions(-) create mode 100644 Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs create mode 100644 Flow.Launcher.Core/ExternalPlugins/CommunityPluginStore.cs diff --git a/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs b/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs new file mode 100644 index 00000000000..80dc6137415 --- /dev/null +++ b/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs @@ -0,0 +1,59 @@ +using Flow.Launcher.Infrastructure.Http; +using Flow.Launcher.Infrastructure.Logger; +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Flow.Launcher.Core.ExternalPlugins +{ + public record CommunityPluginSource(string ManifestFileUrl) + { + private string latestEtag = ""; + + private List plugins = new(); + + /// + /// Fetch and deserialize the contents of a plugins.json file found at . + /// We use conditional http requests to keep repeat requests fast. + /// + /// + /// This method will only return plugin details when the underlying http request is successful (200 or 304). + /// In any other case, an exception is raised + /// + public async Task> FetchAsync(CancellationToken token) + { + Log.Info(nameof(CommunityPluginSource), $"Loading plugins from {ManifestFileUrl}"); + + var request = new HttpRequestMessage(HttpMethod.Get, ManifestFileUrl); + + request.Headers.Add("If-None-Match", latestEtag); + + using var response = await Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false); + + if (response.StatusCode == HttpStatusCode.OK) + { + await using var json = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false); + + this.plugins = await JsonSerializer.DeserializeAsync>(json, cancellationToken: token).ConfigureAwait(false); + this.latestEtag = response.Headers.ETag.Tag; + + Log.Info(nameof(CommunityPluginSource), $"Loaded {this.plugins.Count} plugins from {ManifestFileUrl}"); + return this.plugins; + } + else if (response.StatusCode == HttpStatusCode.NotModified) + { + Log.Info(nameof(CommunityPluginSource), $"Resource {ManifestFileUrl} has not been modified."); + return this.plugins; + } + else + { + Log.Warn(nameof(CommunityPluginSource), $"Failed to load resource {ManifestFileUrl} with response {response.StatusCode}"); + throw new Exception($"Failed to load resource {ManifestFileUrl} with response {response.StatusCode}"); + } + } + } +} diff --git a/Flow.Launcher.Core/ExternalPlugins/CommunityPluginStore.cs b/Flow.Launcher.Core/ExternalPlugins/CommunityPluginStore.cs new file mode 100644 index 00000000000..0bcfb236bf5 --- /dev/null +++ b/Flow.Launcher.Core/ExternalPlugins/CommunityPluginStore.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Flow.Launcher.Core.ExternalPlugins +{ + /// + /// Describes a store of community-made plugins. + /// The provided URLs should point to a json file, whose content + /// is deserializable as a array. + /// + /// Primary URL to the manifest json file. + /// Secondary URLs to access the , for example CDN links + public record CommunityPluginStore(string primaryUrl, params string[] secondaryUrls) + { + private readonly List pluginSources = + secondaryUrls + .Append(primaryUrl) + .Select(url => new CommunityPluginSource(url)) + .ToList(); + + public async Task> FetchAsync(CancellationToken token) + { + // we create a new cancellation token source linked to the given token. + // Once any of the http requests completes successfully, we call cancel + // to stop the rest of the running http requests. + var cts = CancellationTokenSource.CreateLinkedTokenSource(token); + + var tasks = pluginSources + .Select(pluginSource => pluginSource.FetchAsync(cts.Token)) + .ToList(); + + var pluginResults = new List(); + + // keep going until all tasks have completed + while (tasks.Any()) + { + var completedTask = await Task.WhenAny(tasks); + if (completedTask.IsCompletedSuccessfully) + { + // one of the requests completed successfully; keep its results + // and cancel the remaining http requests. + pluginResults = await completedTask; + cts.Cancel(); + } + tasks.Remove(completedTask); + } + + // all tasks have finished + return pluginResults; + } + } +} diff --git a/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs b/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs index e3f0e2a2f28..3449596321a 100644 --- a/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs +++ b/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs @@ -1,10 +1,6 @@ -using Flow.Launcher.Infrastructure.Http; -using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Infrastructure.Logger; using System; using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -12,13 +8,13 @@ namespace Flow.Launcher.Core.ExternalPlugins { public static class PluginsManifest { - private const string manifestFileUrl = "https://cdn.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@plugin_api_v2/plugins.json"; + private static readonly CommunityPluginStore mainPluginStore = + new("https://raw.githubusercontent.com/Flow-Launcher/Flow.Launcher.PluginsManifest/plugin_api_v2/plugins.json", + "https://cdn.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@plugin_api_v2/plugins.json"); private static readonly SemaphoreSlim manifestUpdateLock = new(1); - private static string latestEtag = ""; - - public static List UserPlugins { get; private set; } = new List(); + public static List UserPlugins { get; private set; } public static async Task UpdateManifestAsync(CancellationToken token = default) { @@ -26,25 +22,9 @@ public static async Task UpdateManifestAsync(CancellationToken token = default) { await manifestUpdateLock.WaitAsync(token).ConfigureAwait(false); - var request = new HttpRequestMessage(HttpMethod.Get, manifestFileUrl); - request.Headers.Add("If-None-Match", latestEtag); - - using var response = await Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false); - - if (response.StatusCode == HttpStatusCode.OK) - { - Log.Info($"|PluginsManifest.{nameof(UpdateManifestAsync)}|Fetched plugins from manifest repo"); - - await using var json = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false); - - UserPlugins = await JsonSerializer.DeserializeAsync>(json, cancellationToken: token).ConfigureAwait(false); + var results = await mainPluginStore.FetchAsync(token).ConfigureAwait(false); - latestEtag = response.Headers.ETag.Tag; - } - else if (response.StatusCode != HttpStatusCode.NotModified) - { - Log.Warn($"|PluginsManifest.{nameof(UpdateManifestAsync)}|Http response for manifest file was {response.StatusCode}"); - } + UserPlugins = results; } catch (Exception e) { From 194dbabbde914b7f076869bde73f9fb9d1cf78d2 Mon Sep 17 00:00:00 2001 From: Ioannis G Date: Wed, 5 Jul 2023 00:01:13 +0300 Subject: [PATCH 2/6] add more manifest file fallback URLs --- Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs b/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs index 3449596321a..2f12f3d6f77 100644 --- a/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs +++ b/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs @@ -10,6 +10,8 @@ public static class PluginsManifest { private static readonly CommunityPluginStore mainPluginStore = new("https://raw.githubusercontent.com/Flow-Launcher/Flow.Launcher.PluginsManifest/plugin_api_v2/plugins.json", + "https://fastly.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@plugin_api_v2/plugins.json", + "https://gcore.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@plugin_api_v2/plugins.json", "https://cdn.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@plugin_api_v2/plugins.json"); private static readonly SemaphoreSlim manifestUpdateLock = new(1); From 64f0da456ff1822d131fff45d93f195fda0163b4 Mon Sep 17 00:00:00 2001 From: Ioannis G Date: Wed, 5 Jul 2023 00:37:31 +0300 Subject: [PATCH 3/6] refactor CommunityPluginSource.FetchAsync --- Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs b/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs index 80dc6137415..d3ee4695cc2 100644 --- a/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs +++ b/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs @@ -4,7 +4,7 @@ using System.Collections.Generic; using System.Net; using System.Net.Http; -using System.Text.Json; +using System.Net.Http.Json; using System.Threading; using System.Threading.Tasks; @@ -36,9 +36,7 @@ public async Task> FetchAsync(CancellationToken token) if (response.StatusCode == HttpStatusCode.OK) { - await using var json = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false); - - this.plugins = await JsonSerializer.DeserializeAsync>(json, cancellationToken: token).ConfigureAwait(false); + this.plugins = await response.Content.ReadFromJsonAsync>(cancellationToken: token).ConfigureAwait(false); this.latestEtag = response.Headers.ETag.Tag; Log.Info(nameof(CommunityPluginSource), $"Loaded {this.plugins.Count} plugins from {ManifestFileUrl}"); From f03ac7649413344c5c8e2e2b93fed020d84d0283 Mon Sep 17 00:00:00 2001 From: Ioannis G Date: Wed, 5 Jul 2023 16:54:50 +0300 Subject: [PATCH 4/6] throttle PluginsManifest.UpdateManifestAsync avoid repeatedly fetching manifest data while the user is typing a `pm` query --- .../ExternalPlugins/PluginsManifest.cs | 13 +++++++--- .../Main.cs | 5 ++-- .../PluginsManager.cs | 26 +++---------------- 3 files changed, 16 insertions(+), 28 deletions(-) diff --git a/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs b/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs index 2f12f3d6f77..a9322b41807 100644 --- a/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs +++ b/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs @@ -1,4 +1,4 @@ -using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Infrastructure.Logger; using System; using System.Collections.Generic; using System.Threading; @@ -16,6 +16,9 @@ public static class PluginsManifest private static readonly SemaphoreSlim manifestUpdateLock = new(1); + private static DateTime lastFetchedAt = DateTime.MinValue; + private static TimeSpan fetchTimeout = TimeSpan.FromSeconds(10); + public static List UserPlugins { get; private set; } public static async Task UpdateManifestAsync(CancellationToken token = default) @@ -24,9 +27,13 @@ public static async Task UpdateManifestAsync(CancellationToken token = default) { await manifestUpdateLock.WaitAsync(token).ConfigureAwait(false); - var results = await mainPluginStore.FetchAsync(token).ConfigureAwait(false); + if (UserPlugins == null || DateTime.Now.Subtract(lastFetchedAt) >= fetchTimeout) + { + var results = await mainPluginStore.FetchAsync(token).ConfigureAwait(false); - UserPlugins = results; + UserPlugins = results; + lastFetchedAt = DateTime.Now; + } } catch (Exception e) { diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/Main.cs b/Plugins/Flow.Launcher.Plugin.PluginsManager/Main.cs index cd554e4d0a7..52e8a8c2642 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/Main.cs @@ -1,4 +1,5 @@ -using Flow.Launcher.Plugin.PluginsManager.ViewModels; +using Flow.Launcher.Core.ExternalPlugins; +using Flow.Launcher.Plugin.PluginsManager.ViewModels; using Flow.Launcher.Plugin.PluginsManager.Views; using System.Collections.Generic; using System.Linq; @@ -34,7 +35,7 @@ public async Task InitAsync(PluginInitContext context) contextMenu = new ContextMenu(Context); pluginManager = new PluginsManager(Context, Settings); - _ = pluginManager.UpdateManifestAsync(); + await PluginsManifest.UpdateManifestAsync(); } public List LoadContextMenus(Result selectedResult) diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs b/Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs index d74ec70b595..163c4751a15 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs @@ -1,4 +1,4 @@ -using Flow.Launcher.Core.ExternalPlugins; +using Flow.Launcher.Core.ExternalPlugins; using Flow.Launcher.Core.Plugin; using Flow.Launcher.Infrastructure; using Flow.Launcher.Infrastructure.Http; @@ -49,26 +49,6 @@ internal PluginsManager(PluginInitContext context, Settings settings) Settings = settings; } - private Task _downloadManifestTask = Task.CompletedTask; - - internal Task UpdateManifestAsync(CancellationToken token = default, bool silent = false) - { - if (_downloadManifestTask.Status == TaskStatus.Running) - { - return _downloadManifestTask; - } - else - { - _downloadManifestTask = PluginsManifest.UpdateManifestAsync(token); - if (!silent) - _downloadManifestTask.ContinueWith(_ => - Context.API.ShowMsg(Context.API.GetTranslation("plugin_pluginsmanager_update_failed_title"), - Context.API.GetTranslation("plugin_pluginsmanager_update_failed_subtitle"), icoPath, false), - TaskContinuationOptions.OnlyOnFaulted); - return _downloadManifestTask; - } - } - internal List GetDefaultHotKeys() { return new List() @@ -184,7 +164,7 @@ internal async Task InstallOrUpdateAsync(UserPlugin plugin) internal async ValueTask> RequestUpdateAsync(string search, CancellationToken token) { - await UpdateManifestAsync(token); + await PluginsManifest.UpdateManifestAsync(token); var resultsForUpdate = from existingPlugin in Context.API.GetAllPlugins() @@ -359,7 +339,7 @@ private bool InstallSourceKnown(string url) internal async ValueTask> RequestInstallOrUpdate(string search, CancellationToken token) { - await UpdateManifestAsync(token); + await PluginsManifest.UpdateManifestAsync(token); if (Uri.IsWellFormedUriString(search, UriKind.Absolute) && search.Split('.').Last() == zip) From 2d6b4766853c2e809cb90591f1dc7443fc000c58 Mon Sep 17 00:00:00 2001 From: Ioannis G Date: Wed, 5 Jul 2023 17:01:12 +0300 Subject: [PATCH 5/6] use Ctrl+R to fetch primary manifest source from a `pm install` or `pm update` query should help with #2048 --- .../ExternalPlugins/CommunityPluginStore.cs | 8 ++++---- Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs | 8 ++++---- Plugins/Flow.Launcher.Plugin.PluginsManager/Main.cs | 6 +++--- .../PluginsManager.cs | 10 +++++----- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Flow.Launcher.Core/ExternalPlugins/CommunityPluginStore.cs b/Flow.Launcher.Core/ExternalPlugins/CommunityPluginStore.cs index 0bcfb236bf5..affd7c31207 100644 --- a/Flow.Launcher.Core/ExternalPlugins/CommunityPluginStore.cs +++ b/Flow.Launcher.Core/ExternalPlugins/CommunityPluginStore.cs @@ -20,16 +20,16 @@ public record CommunityPluginStore(string primaryUrl, params string[] secondaryU .Select(url => new CommunityPluginSource(url)) .ToList(); - public async Task> FetchAsync(CancellationToken token) + public async Task> FetchAsync(CancellationToken token, bool onlyFromPrimaryUrl = false) { // we create a new cancellation token source linked to the given token. // Once any of the http requests completes successfully, we call cancel // to stop the rest of the running http requests. var cts = CancellationTokenSource.CreateLinkedTokenSource(token); - var tasks = pluginSources - .Select(pluginSource => pluginSource.FetchAsync(cts.Token)) - .ToList(); + var tasks = onlyFromPrimaryUrl + ? new() { pluginSources.Last().FetchAsync(cts.Token) } + : pluginSources.Select(pluginSource => pluginSource.FetchAsync(cts.Token)).ToList(); var pluginResults = new List(); diff --git a/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs b/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs index a9322b41807..7b4e983ede4 100644 --- a/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs +++ b/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs @@ -1,4 +1,4 @@ -using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Infrastructure.Logger; using System; using System.Collections.Generic; using System.Threading; @@ -21,15 +21,15 @@ public static class PluginsManifest public static List UserPlugins { get; private set; } - public static async Task UpdateManifestAsync(CancellationToken token = default) + public static async Task UpdateManifestAsync(CancellationToken token = default, bool usePrimaryUrlOnly = false) { try { await manifestUpdateLock.WaitAsync(token).ConfigureAwait(false); - if (UserPlugins == null || DateTime.Now.Subtract(lastFetchedAt) >= fetchTimeout) + if (UserPlugins == null || usePrimaryUrlOnly || DateTime.Now.Subtract(lastFetchedAt) >= fetchTimeout) { - var results = await mainPluginStore.FetchAsync(token).ConfigureAwait(false); + var results = await mainPluginStore.FetchAsync(token, usePrimaryUrlOnly).ConfigureAwait(false); UserPlugins = results; lastFetchedAt = DateTime.Now; diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/Main.cs b/Plugins/Flow.Launcher.Plugin.PluginsManager/Main.cs index 52e8a8c2642..bec84f48410 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/Main.cs @@ -1,4 +1,4 @@ -using Flow.Launcher.Core.ExternalPlugins; +using Flow.Launcher.Core.ExternalPlugins; using Flow.Launcher.Plugin.PluginsManager.ViewModels; using Flow.Launcher.Plugin.PluginsManager.Views; using System.Collections.Generic; @@ -51,9 +51,9 @@ public async Task> QueryAsync(Query query, CancellationToken token) return query.FirstSearch.ToLower() switch { //search could be url, no need ToLower() when passed in - Settings.InstallCommand => await pluginManager.RequestInstallOrUpdate(query.SecondToEndSearch, token), + Settings.InstallCommand => await pluginManager.RequestInstallOrUpdate(query.SecondToEndSearch, token, query.IsReQuery), Settings.UninstallCommand => pluginManager.RequestUninstall(query.SecondToEndSearch), - Settings.UpdateCommand => await pluginManager.RequestUpdateAsync(query.SecondToEndSearch, token), + Settings.UpdateCommand => await pluginManager.RequestUpdateAsync(query.SecondToEndSearch, token, query.IsReQuery), _ => pluginManager.GetDefaultHotKeys().Where(hotkey => { hotkey.Score = StringMatcher.FuzzySearch(query.Search, hotkey.Title).Score; diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs b/Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs index 163c4751a15..0298a2aeb45 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs @@ -1,4 +1,4 @@ -using Flow.Launcher.Core.ExternalPlugins; +using Flow.Launcher.Core.ExternalPlugins; using Flow.Launcher.Core.Plugin; using Flow.Launcher.Infrastructure; using Flow.Launcher.Infrastructure.Http; @@ -162,9 +162,9 @@ internal async Task InstallOrUpdateAsync(UserPlugin plugin) Context.API.RestartApp(); } - internal async ValueTask> RequestUpdateAsync(string search, CancellationToken token) + internal async ValueTask> RequestUpdateAsync(string search, CancellationToken token, bool usePrimaryUrlOnly = false) { - await PluginsManifest.UpdateManifestAsync(token); + await PluginsManifest.UpdateManifestAsync(token, usePrimaryUrlOnly); var resultsForUpdate = from existingPlugin in Context.API.GetAllPlugins() @@ -337,9 +337,9 @@ private bool InstallSourceKnown(string url) return url.StartsWith(acceptedSource) && Context.API.GetAllPlugins().Any(x => x.Metadata.Website.StartsWith(contructedUrlPart)); } - internal async ValueTask> RequestInstallOrUpdate(string search, CancellationToken token) + internal async ValueTask> RequestInstallOrUpdate(string search, CancellationToken token, bool usePrimaryUrlOnly = false) { - await PluginsManifest.UpdateManifestAsync(token); + await PluginsManifest.UpdateManifestAsync(token, usePrimaryUrlOnly); if (Uri.IsWellFormedUriString(search, UriKind.Absolute) && search.Split('.').Last() == zip) From df149fae8a99e019cd26e149b522a9fa6b1d5287 Mon Sep 17 00:00:00 2001 From: Ioannis G Date: Sat, 8 Jul 2023 13:41:52 +0300 Subject: [PATCH 6/6] increase plugin manifest fetch timeout to 2m --- Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs b/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs index 7b4e983ede4..c4dcef3e394 100644 --- a/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs +++ b/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs @@ -17,7 +17,7 @@ public static class PluginsManifest private static readonly SemaphoreSlim manifestUpdateLock = new(1); private static DateTime lastFetchedAt = DateTime.MinValue; - private static TimeSpan fetchTimeout = TimeSpan.FromSeconds(10); + private static TimeSpan fetchTimeout = TimeSpan.FromMinutes(2); public static List UserPlugins { get; private set; }