Merge pull request #2222 from JohnTheGr8/plugin_manifest_enhancements

Rework how we fetch community plugin data
This commit is contained in:
Jeremy Wu 2023-07-10 13:33:12 +09:30 committed by GitHub
commit 826449cad5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 134 additions and 53 deletions

View file

@ -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<UserPlugin> plugins = new();
/// <summary>
/// Fetch and deserialize the contents of a plugins.json file found at <see cref="ManifestFileUrl"/>.
/// We use conditional http requests to keep repeat requests fast.
/// </summary>
/// <remarks>
/// 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
/// </remarks>
public async Task<List<UserPlugin>> 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<List<UserPlugin>>(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}");
}
}
}
}

View file

@ -0,0 +1,54 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Flow.Launcher.Core.ExternalPlugins
{
/// <summary>
/// Describes a store of community-made plugins.
/// The provided URLs should point to a json file, whose content
/// is deserializable as a <see cref="UserPlugin"/> array.
/// </summary>
/// <param name="primaryUrl">Primary URL to the manifest json file.</param>
/// <param name="secondaryUrls">Secondary URLs to access the <paramref name="primaryUrl"/>, for example CDN links</param>
public record CommunityPluginStore(string primaryUrl, params string[] secondaryUrls)
{
private readonly List<CommunityPluginSource> pluginSources =
secondaryUrls
.Append(primaryUrl)
.Select(url => new CommunityPluginSource(url))
.ToList();
public async Task<List<UserPlugin>> 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<UserPlugin>();
// 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;
}
}
}

View file

@ -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<UserPlugin> UserPlugins { get; private set; } = new List<UserPlugin>();
public static List<UserPlugin> 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");
var results = await mainPluginStore.FetchAsync(token, usePrimaryUrlOnly).ConfigureAwait(false);
await using var json = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false);
UserPlugins = await JsonSerializer.DeserializeAsync<List<UserPlugin>>(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)

View file

@ -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 @@ namespace Flow.Launcher.Plugin.PluginsManager
contextMenu = new ContextMenu(Context);
pluginManager = new PluginsManager(Context, Settings);
_ = pluginManager.UpdateManifestAsync();
await PluginsManifest.UpdateManifestAsync();
}
public List<Result> LoadContextMenus(Result selectedResult)
@ -50,9 +51,9 @@ namespace Flow.Launcher.Plugin.PluginsManager
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;

View file

@ -49,26 +49,6 @@ namespace Flow.Launcher.Plugin.PluginsManager
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<Result> GetDefaultHotKeys()
{
return new List<Result>()
@ -182,9 +162,9 @@ namespace Flow.Launcher.Plugin.PluginsManager
Context.API.RestartApp();
}
internal async ValueTask<List<Result>> RequestUpdateAsync(string search, CancellationToken token)
internal async ValueTask<List<Result>> 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 @@ namespace Flow.Launcher.Plugin.PluginsManager
return url.StartsWith(acceptedSource) && Context.API.GetAllPlugins().Any(x => x.Metadata.Website.StartsWith(contructedUrlPart));
}
internal async ValueTask<List<Result>> RequestInstallOrUpdate(string search, CancellationToken token)
internal async ValueTask<List<Result>> 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)