Skip to content

Commit 826449c

Browse files
authored
Merge pull request #2222 from JohnTheGr8/plugin_manifest_enhancements
Rework how we fetch community plugin data
2 parents 0429cef + df149fa commit 826449c

File tree

5 files changed

+134
-53
lines changed

5 files changed

+134
-53
lines changed
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
using Flow.Launcher.Infrastructure.Http;
2+
using Flow.Launcher.Infrastructure.Logger;
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Net;
6+
using System.Net.Http;
7+
using System.Net.Http.Json;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
11+
namespace Flow.Launcher.Core.ExternalPlugins
12+
{
13+
public record CommunityPluginSource(string ManifestFileUrl)
14+
{
15+
private string latestEtag = "";
16+
17+
private List<UserPlugin> plugins = new();
18+
19+
/// <summary>
20+
/// Fetch and deserialize the contents of a plugins.json file found at <see cref="ManifestFileUrl"/>.
21+
/// We use conditional http requests to keep repeat requests fast.
22+
/// </summary>
23+
/// <remarks>
24+
/// This method will only return plugin details when the underlying http request is successful (200 or 304).
25+
/// In any other case, an exception is raised
26+
/// </remarks>
27+
public async Task<List<UserPlugin>> FetchAsync(CancellationToken token)
28+
{
29+
Log.Info(nameof(CommunityPluginSource), $"Loading plugins from {ManifestFileUrl}");
30+
31+
var request = new HttpRequestMessage(HttpMethod.Get, ManifestFileUrl);
32+
33+
request.Headers.Add("If-None-Match", latestEtag);
34+
35+
using var response = await Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false);
36+
37+
if (response.StatusCode == HttpStatusCode.OK)
38+
{
39+
this.plugins = await response.Content.ReadFromJsonAsync<List<UserPlugin>>(cancellationToken: token).ConfigureAwait(false);
40+
this.latestEtag = response.Headers.ETag.Tag;
41+
42+
Log.Info(nameof(CommunityPluginSource), $"Loaded {this.plugins.Count} plugins from {ManifestFileUrl}");
43+
return this.plugins;
44+
}
45+
else if (response.StatusCode == HttpStatusCode.NotModified)
46+
{
47+
Log.Info(nameof(CommunityPluginSource), $"Resource {ManifestFileUrl} has not been modified.");
48+
return this.plugins;
49+
}
50+
else
51+
{
52+
Log.Warn(nameof(CommunityPluginSource), $"Failed to load resource {ManifestFileUrl} with response {response.StatusCode}");
53+
throw new Exception($"Failed to load resource {ManifestFileUrl} with response {response.StatusCode}");
54+
}
55+
}
56+
}
57+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
6+
namespace Flow.Launcher.Core.ExternalPlugins
7+
{
8+
/// <summary>
9+
/// Describes a store of community-made plugins.
10+
/// The provided URLs should point to a json file, whose content
11+
/// is deserializable as a <see cref="UserPlugin"/> array.
12+
/// </summary>
13+
/// <param name="primaryUrl">Primary URL to the manifest json file.</param>
14+
/// <param name="secondaryUrls">Secondary URLs to access the <paramref name="primaryUrl"/>, for example CDN links</param>
15+
public record CommunityPluginStore(string primaryUrl, params string[] secondaryUrls)
16+
{
17+
private readonly List<CommunityPluginSource> pluginSources =
18+
secondaryUrls
19+
.Append(primaryUrl)
20+
.Select(url => new CommunityPluginSource(url))
21+
.ToList();
22+
23+
public async Task<List<UserPlugin>> FetchAsync(CancellationToken token, bool onlyFromPrimaryUrl = false)
24+
{
25+
// we create a new cancellation token source linked to the given token.
26+
// Once any of the http requests completes successfully, we call cancel
27+
// to stop the rest of the running http requests.
28+
var cts = CancellationTokenSource.CreateLinkedTokenSource(token);
29+
30+
var tasks = onlyFromPrimaryUrl
31+
? new() { pluginSources.Last().FetchAsync(cts.Token) }
32+
: pluginSources.Select(pluginSource => pluginSource.FetchAsync(cts.Token)).ToList();
33+
34+
var pluginResults = new List<UserPlugin>();
35+
36+
// keep going until all tasks have completed
37+
while (tasks.Any())
38+
{
39+
var completedTask = await Task.WhenAny(tasks);
40+
if (completedTask.IsCompletedSuccessfully)
41+
{
42+
// one of the requests completed successfully; keep its results
43+
// and cancel the remaining http requests.
44+
pluginResults = await completedTask;
45+
cts.Cancel();
46+
}
47+
tasks.Remove(completedTask);
48+
}
49+
50+
// all tasks have finished
51+
return pluginResults;
52+
}
53+
}
54+
}

Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs

Lines changed: 14 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,38 @@
1-
using Flow.Launcher.Infrastructure.Http;
2-
using Flow.Launcher.Infrastructure.Logger;
1+
using Flow.Launcher.Infrastructure.Logger;
32
using System;
43
using System.Collections.Generic;
5-
using System.Net;
6-
using System.Net.Http;
7-
using System.Text.Json;
84
using System.Threading;
95
using System.Threading.Tasks;
106

117
namespace Flow.Launcher.Core.ExternalPlugins
128
{
139
public static class PluginsManifest
1410
{
15-
private const string manifestFileUrl = "https://cdn.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@plugin_api_v2/plugins.json";
11+
private static readonly CommunityPluginStore mainPluginStore =
12+
new("https://raw.githubusercontent.com/Flow-Launcher/Flow.Launcher.PluginsManifest/plugin_api_v2/plugins.json",
13+
"https://fastly.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@plugin_api_v2/plugins.json",
14+
"https://gcore.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@plugin_api_v2/plugins.json",
15+
"https://cdn.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@plugin_api_v2/plugins.json");
1616

1717
private static readonly SemaphoreSlim manifestUpdateLock = new(1);
1818

19-
private static string latestEtag = "";
19+
private static DateTime lastFetchedAt = DateTime.MinValue;
20+
private static TimeSpan fetchTimeout = TimeSpan.FromMinutes(2);
2021

21-
public static List<UserPlugin> UserPlugins { get; private set; } = new List<UserPlugin>();
22+
public static List<UserPlugin> UserPlugins { get; private set; }
2223

23-
public static async Task UpdateManifestAsync(CancellationToken token = default)
24+
public static async Task UpdateManifestAsync(CancellationToken token = default, bool usePrimaryUrlOnly = false)
2425
{
2526
try
2627
{
2728
await manifestUpdateLock.WaitAsync(token).ConfigureAwait(false);
2829

29-
var request = new HttpRequestMessage(HttpMethod.Get, manifestFileUrl);
30-
request.Headers.Add("If-None-Match", latestEtag);
31-
32-
using var response = await Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false);
33-
34-
if (response.StatusCode == HttpStatusCode.OK)
30+
if (UserPlugins == null || usePrimaryUrlOnly || DateTime.Now.Subtract(lastFetchedAt) >= fetchTimeout)
3531
{
36-
Log.Info($"|PluginsManifest.{nameof(UpdateManifestAsync)}|Fetched plugins from manifest repo");
37-
38-
await using var json = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false);
32+
var results = await mainPluginStore.FetchAsync(token, usePrimaryUrlOnly).ConfigureAwait(false);
3933

40-
UserPlugins = await JsonSerializer.DeserializeAsync<List<UserPlugin>>(json, cancellationToken: token).ConfigureAwait(false);
41-
42-
latestEtag = response.Headers.ETag.Tag;
43-
}
44-
else if (response.StatusCode != HttpStatusCode.NotModified)
45-
{
46-
Log.Warn($"|PluginsManifest.{nameof(UpdateManifestAsync)}|Http response for manifest file was {response.StatusCode}");
34+
UserPlugins = results;
35+
lastFetchedAt = DateTime.Now;
4736
}
4837
}
4938
catch (Exception e)

Plugins/Flow.Launcher.Plugin.PluginsManager/Main.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using Flow.Launcher.Plugin.PluginsManager.ViewModels;
1+
using Flow.Launcher.Core.ExternalPlugins;
2+
using Flow.Launcher.Plugin.PluginsManager.ViewModels;
23
using Flow.Launcher.Plugin.PluginsManager.Views;
34
using System.Collections.Generic;
45
using System.Linq;
@@ -34,7 +35,7 @@ public async Task InitAsync(PluginInitContext context)
3435
contextMenu = new ContextMenu(Context);
3536
pluginManager = new PluginsManager(Context, Settings);
3637

37-
_ = pluginManager.UpdateManifestAsync();
38+
await PluginsManifest.UpdateManifestAsync();
3839
}
3940

4041
public List<Result> LoadContextMenus(Result selectedResult)
@@ -50,9 +51,9 @@ public async Task<List<Result>> QueryAsync(Query query, CancellationToken token)
5051
return query.FirstSearch.ToLower() switch
5152
{
5253
//search could be url, no need ToLower() when passed in
53-
Settings.InstallCommand => await pluginManager.RequestInstallOrUpdate(query.SecondToEndSearch, token),
54+
Settings.InstallCommand => await pluginManager.RequestInstallOrUpdate(query.SecondToEndSearch, token, query.IsReQuery),
5455
Settings.UninstallCommand => pluginManager.RequestUninstall(query.SecondToEndSearch),
55-
Settings.UpdateCommand => await pluginManager.RequestUpdateAsync(query.SecondToEndSearch, token),
56+
Settings.UpdateCommand => await pluginManager.RequestUpdateAsync(query.SecondToEndSearch, token, query.IsReQuery),
5657
_ => pluginManager.GetDefaultHotKeys().Where(hotkey =>
5758
{
5859
hotkey.Score = StringMatcher.FuzzySearch(query.Search, hotkey.Title).Score;

Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -49,26 +49,6 @@ internal PluginsManager(PluginInitContext context, Settings settings)
4949
Settings = settings;
5050
}
5151

52-
private Task _downloadManifestTask = Task.CompletedTask;
53-
54-
internal Task UpdateManifestAsync(CancellationToken token = default, bool silent = false)
55-
{
56-
if (_downloadManifestTask.Status == TaskStatus.Running)
57-
{
58-
return _downloadManifestTask;
59-
}
60-
else
61-
{
62-
_downloadManifestTask = PluginsManifest.UpdateManifestAsync(token);
63-
if (!silent)
64-
_downloadManifestTask.ContinueWith(_ =>
65-
Context.API.ShowMsg(Context.API.GetTranslation("plugin_pluginsmanager_update_failed_title"),
66-
Context.API.GetTranslation("plugin_pluginsmanager_update_failed_subtitle"), icoPath, false),
67-
TaskContinuationOptions.OnlyOnFaulted);
68-
return _downloadManifestTask;
69-
}
70-
}
71-
7252
internal List<Result> GetDefaultHotKeys()
7353
{
7454
return new List<Result>()
@@ -182,9 +162,9 @@ internal async Task InstallOrUpdateAsync(UserPlugin plugin)
182162
Context.API.RestartApp();
183163
}
184164

185-
internal async ValueTask<List<Result>> RequestUpdateAsync(string search, CancellationToken token)
165+
internal async ValueTask<List<Result>> RequestUpdateAsync(string search, CancellationToken token, bool usePrimaryUrlOnly = false)
186166
{
187-
await UpdateManifestAsync(token);
167+
await PluginsManifest.UpdateManifestAsync(token, usePrimaryUrlOnly);
188168

189169
var resultsForUpdate =
190170
from existingPlugin in Context.API.GetAllPlugins()
@@ -357,9 +337,9 @@ private bool InstallSourceKnown(string url)
357337
return url.StartsWith(acceptedSource) && Context.API.GetAllPlugins().Any(x => x.Metadata.Website.StartsWith(contructedUrlPart));
358338
}
359339

360-
internal async ValueTask<List<Result>> RequestInstallOrUpdate(string search, CancellationToken token)
340+
internal async ValueTask<List<Result>> RequestInstallOrUpdate(string search, CancellationToken token, bool usePrimaryUrlOnly = false)
361341
{
362-
await UpdateManifestAsync(token);
342+
await PluginsManifest.UpdateManifestAsync(token, usePrimaryUrlOnly);
363343

364344
if (Uri.IsWellFormedUriString(search, UriKind.Absolute)
365345
&& search.Split('.').Last() == zip)

0 commit comments

Comments
 (0)