diff --git a/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs b/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs new file mode 100644 index 00000000000..d3ee4695cc2 --- /dev/null +++ b/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs @@ -0,0 +1,57 @@ +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.Net.Http.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) + { + 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}"); + 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..affd7c31207 --- /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, 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 = onlyFromPrimaryUrl + ? new() { pluginSources.Last().FetchAsync(cts.Token) } + : 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..c4dcef3e394 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,38 +8,31 @@ 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://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); - private static string latestEtag = ""; + private static DateTime lastFetchedAt = DateTime.MinValue; + private static TimeSpan fetchTimeout = TimeSpan.FromMinutes(2); - public static List UserPlugins { get; private set; } = new List(); + 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); - 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) + if (UserPlugins == null || usePrimaryUrlOnly || DateTime.Now.Subtract(lastFetchedAt) >= fetchTimeout) { - Log.Info($"|PluginsManifest.{nameof(UpdateManifestAsync)}|Fetched plugins from manifest repo"); - - await using var json = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false); + var results = await mainPluginStore.FetchAsync(token, usePrimaryUrlOnly).ConfigureAwait(false); - UserPlugins = await JsonSerializer.DeserializeAsync>(json, cancellationToken: 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; + 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..bec84f48410 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) @@ -50,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 d74ec70b595..0298a2aeb45 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs @@ -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() @@ -182,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 UpdateManifestAsync(token); + await PluginsManifest.UpdateManifestAsync(token, usePrimaryUrlOnly); var resultsForUpdate = from existingPlugin in Context.API.GetAllPlugins() @@ -357,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 UpdateManifestAsync(token); + await PluginsManifest.UpdateManifestAsync(token, usePrimaryUrlOnly); if (Uri.IsWellFormedUriString(search, UriKind.Absolute) && search.Split('.').Last() == zip)