Skip to content

Commit f7b5858

Browse files
committed
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
1 parent 39e544a commit f7b5858

File tree

3 files changed

+120
-27
lines changed

3 files changed

+120
-27
lines changed
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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.Text.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+
await using var json = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false);
40+
41+
this.plugins = await JsonSerializer.DeserializeAsync<List<UserPlugin>>(json, cancellationToken: token).ConfigureAwait(false);
42+
this.latestEtag = response.Headers.ETag.Tag;
43+
44+
Log.Info(nameof(CommunityPluginSource), $"Loaded {this.plugins.Count} plugins from {ManifestFileUrl}");
45+
return this.plugins;
46+
}
47+
else if (response.StatusCode == HttpStatusCode.NotModified)
48+
{
49+
Log.Info(nameof(CommunityPluginSource), $"Resource {ManifestFileUrl} has not been modified.");
50+
return this.plugins;
51+
}
52+
else
53+
{
54+
Log.Warn(nameof(CommunityPluginSource), $"Failed to load resource {ManifestFileUrl} with response {response.StatusCode}");
55+
throw new Exception($"Failed to load resource {ManifestFileUrl} with response {response.StatusCode}");
56+
}
57+
}
58+
}
59+
}
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)
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 = pluginSources
31+
.Select(pluginSource => pluginSource.FetchAsync(cts.Token))
32+
.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: 7 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,30 @@
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://cdn.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@plugin_api_v2/plugins.json");
1614

1715
private static readonly SemaphoreSlim manifestUpdateLock = new(1);
1816

19-
private static string latestEtag = "";
20-
21-
public static List<UserPlugin> UserPlugins { get; private set; } = new List<UserPlugin>();
17+
public static List<UserPlugin> UserPlugins { get; private set; }
2218

2319
public static async Task UpdateManifestAsync(CancellationToken token = default)
2420
{
2521
try
2622
{
2723
await manifestUpdateLock.WaitAsync(token).ConfigureAwait(false);
2824

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)
35-
{
36-
Log.Info($"|PluginsManifest.{nameof(UpdateManifestAsync)}|Fetched plugins from manifest repo");
37-
38-
await using var json = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false);
39-
40-
UserPlugins = await JsonSerializer.DeserializeAsync<List<UserPlugin>>(json, cancellationToken: token).ConfigureAwait(false);
25+
var results = await mainPluginStore.FetchAsync(token).ConfigureAwait(false);
4126

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}");
47-
}
27+
UserPlugins = results;
4828
}
4929
catch (Exception e)
5030
{

0 commit comments

Comments
 (0)