Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs
Original file line number Diff line number Diff line change
@@ -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 changes: 54 additions & 0 deletions Flow.Launcher.Core/ExternalPlugins/CommunityPluginStore.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
39 changes: 14 additions & 25 deletions Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs
Original file line number Diff line number Diff line change
@@ -1,49 +1,38 @@
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;

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");

await using var json = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false);
var results = await mainPluginStore.FetchAsync(token, usePrimaryUrlOnly).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)
Expand Down
9 changes: 5 additions & 4 deletions Plugins/Flow.Launcher.Plugin.PluginsManager/Main.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<Result> LoadContextMenus(Result selectedResult)
Expand All @@ -50,9 +51,9 @@ public async Task<List<Result>> 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),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why requery means use the primary url?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to add a way to fetch fresh data (not cached data from a CDN) and this seemed a reasonable solution, if you think of Ctrl+R as a refresh mechanism.

This is mostly a temporary hack for #2048 honestly, I think we can rethink this as part of #2178

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. I previously thought Last() refers to cdn.

_ => pluginManager.GetDefaultHotKeys().Where(hotkey =>
{
hotkey.Score = StringMatcher.FuzzySearch(query.Search, hotkey.Title).Score;
Expand Down
28 changes: 4 additions & 24 deletions Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Result> GetDefaultHotKeys()
{
return new List<Result>()
Expand Down Expand Up @@ -182,9 +162,9 @@ internal async Task InstallOrUpdateAsync(UserPlugin plugin)
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()
Expand Down Expand Up @@ -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<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);
Copy link
Member

@taooceros taooceros Jul 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we fetch result every time, the ctrl+r will only refresh the result on the first one, but later one will still use the manifest in cdn? I believe we should have local cache for this. (Inserting another character will revert back to the old results).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't fetch every time, I have added a 10-second timeout so that we don't fetch repeatedly while a user is typing. Hitting Ctrl+R will bypass the timeout and fetch from the primary url.

Inserting another character will revert back to the old results

so this is only an issue if the user waits 10 seconds before inserting that extra character.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

10 seconds seems to be pretty short? As we have the ctrl+r features, why don't we add a couple minutes timeout?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

10 seconds seems to be pretty short?

it's only meant to avoid multiple http requests while a user is typing

As we have the ctrl+r features, why don't we add a couple minutes timeout?

I don't see a reason to: fetching is very fast with the CDN links, and I've added 3 CDN links now so that fetching should be fast for users anywhere in the world (the fastest cdn link will be used in each user's case).

It takes around 80ms for me, a 2-minute timeout for a call this quick seems a bit unreasonable 😂

Copy link
Member

@taooceros taooceros Jul 7, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you are in China, the cdn is not so fast. It takes around 1s to download the manifest for me without proxy (with proxy it shall be fast).

image

the other cdn takes 600ms, but sometimes it doesn't work.
image

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I can increase the timeout to a couple minutes. Does the rest of the PR look good to you?

the other cdn takes 600ms, but sometimes it doesn't work.

yeah that's served by cloudflare, don't think they're very solid in China

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rest looks good to me. I will take a detail look today. Previously jsdelivr has license in China, so they are able to deliver cdn that located in China, which is much much faster and stabler.


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