mirror of
https://github.com/Flow-Launcher/Flow.Launcher.git
synced 2026-03-11 08:54:32 +00:00
Merge pull request #2222 from JohnTheGr8/plugin_manifest_enhancements
Rework how we fetch community plugin data
This commit is contained in:
commit
826449cad5
5 changed files with 134 additions and 53 deletions
57
Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs
Normal file
57
Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
54
Flow.Launcher.Core/ExternalPlugins/CommunityPluginStore.cs
Normal file
54
Flow.Launcher.Core/ExternalPlugins/CommunityPluginStore.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue