diff --git a/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs b/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs index 7c0290b2a53..9dc95c41880 100644 --- a/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs +++ b/Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs @@ -17,6 +17,8 @@ public record CommunityPluginSource(string ManifestFileUrl) { private static readonly string ClassName = nameof(CommunityPluginSource); + internal string ManifestFileUrlForLogging => SanitizeUrlForLogging(ManifestFileUrl); + private string latestEtag = ""; private List plugins = []; @@ -36,7 +38,7 @@ public record CommunityPluginSource(string ManifestFileUrl) /// public async Task> FetchAsync(CancellationToken token) { - PublicApi.Instance.LogInfo(ClassName, $"Loading plugins from {ManifestFileUrl}"); + PublicApi.Instance.LogInfo(ClassName, $"Loading plugins from {ManifestFileUrlForLogging}"); var request = new HttpRequestMessage(HttpMethod.Get, ManifestFileUrl); @@ -54,36 +56,36 @@ public async Task> FetchAsync(CancellationToken token) .ConfigureAwait(false); latestEtag = response.Headers.ETag?.Tag; - PublicApi.Instance.LogInfo(ClassName, $"Loaded {plugins.Count} plugins from {ManifestFileUrl}"); + PublicApi.Instance.LogInfo(ClassName, $"Loaded {plugins.Count} plugins from {ManifestFileUrlForLogging}"); return plugins; } else if (response.StatusCode == HttpStatusCode.NotModified) { - PublicApi.Instance.LogInfo(ClassName, $"Resource {ManifestFileUrl} has not been modified."); + PublicApi.Instance.LogInfo(ClassName, $"Resource {ManifestFileUrlForLogging} has not been modified."); return plugins; } else { - PublicApi.Instance.LogWarn(ClassName, $"Failed to load resource {ManifestFileUrl} with response {response.StatusCode}"); + PublicApi.Instance.LogWarn(ClassName, $"Failed to load resource {ManifestFileUrlForLogging} with response {response.StatusCode}"); return null; } } catch (OperationCanceledException) when (token.IsCancellationRequested) { - PublicApi.Instance.LogDebug(ClassName, $"Fetching from {ManifestFileUrl} was cancelled by caller."); + PublicApi.Instance.LogDebug(ClassName, $"Fetching from {ManifestFileUrlForLogging} was cancelled by caller."); return null; } catch (TaskCanceledException) { // Likely an HttpClient timeout or external cancellation not requested by our token - PublicApi.Instance.LogWarn(ClassName, $"Fetching from {ManifestFileUrl} timed out."); + PublicApi.Instance.LogWarn(ClassName, $"Fetching from {ManifestFileUrlForLogging} timed out."); return null; } catch (Exception e) { if (e is HttpRequestException or WebException or SocketException || e.InnerException is TimeoutException) { - PublicApi.Instance.LogException(ClassName, $"Check your connection and proxy settings to {ManifestFileUrl}.", e); + PublicApi.Instance.LogException(ClassName, $"Check your connection and proxy settings to {ManifestFileUrlForLogging}.", e); } else { @@ -92,5 +94,14 @@ public async Task> FetchAsync(CancellationToken token) return null; } } + + private static string SanitizeUrlForLogging(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + return "[invalid manifest URL]"; + + const UriComponents components = UriComponents.SchemeAndServer | UriComponents.Path; + return uri.GetComponents(components, UriFormat.UriEscaped); + } } } diff --git a/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs b/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs index ba332d0a439..dd274ee8b40 100644 --- a/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs +++ b/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using Flow.Launcher.Plugin; using Flow.Launcher.Core.Plugin; +using Flow.Launcher.Infrastructure.UserSettings; namespace Flow.Launcher.Core.ExternalPlugins { @@ -11,11 +12,8 @@ public static class PluginsManifest { private static readonly string ClassName = nameof(PluginsManifest); - private static readonly CommunityPluginStore mainPluginStore = - new("https://raw.githubusercontent.com/Flow-Launcher/Flow.Launcher.PluginsManifest/main/plugins.json", - "https://fastly.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@main/plugins.json", - "https://gcore.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@main/plugins.json", - "https://cdn.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@main/plugins.json"); + private static CommunityPluginStore mainPluginStore; + private static string lastCustomUrl = string.Empty; private static readonly SemaphoreSlim manifestUpdateLock = new(1); @@ -24,14 +22,48 @@ public static class PluginsManifest public static List UserPlugins { get; private set; } - public static async Task UpdateManifestAsync(bool usePrimaryUrlOnly = false, CancellationToken token = default) + public static async Task UpdateManifestAsync(Settings settings, bool usePrimaryUrlOnly = false, CancellationToken token = default) { - bool lockAcquired = false; + var lockAcquired = false; try { + var defaultUrls = new[] + { + "https://raw.githubusercontent.com/Flow-Launcher/Flow.Launcher.PluginsManifest/main/plugins.json", + "https://fastly.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@main/plugins.json", + "https://gcore.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@main/plugins.json", + "https://cdn.jsdelivr.net/gh/Flow-Launcher/Flow.Launcher.PluginsManifest@main/plugins.json" + }; + await manifestUpdateLock.WaitAsync(token).ConfigureAwait(false); lockAcquired = true; + var customUrl = settings.PluginSettings.PluginsManifestUrl?.Trim() ?? string.Empty; + + if (mainPluginStore == null || lastCustomUrl != customUrl) + { + if (!string.IsNullOrEmpty(customUrl)) + { + if (Uri.TryCreate(customUrl, UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) + && !string.IsNullOrEmpty(uri.Host)) + { + mainPluginStore = new(customUrl); + } + else + { + PublicApi.Instance.LogWarn(ClassName, $"Invalid custom plugins manifest URL: {customUrl}. Using default URLs."); + mainPluginStore = new(defaultUrls[0], defaultUrls[1..]); + } + } + else + { + mainPluginStore = new(defaultUrls[0], defaultUrls[1..]); + } + lastCustomUrl = customUrl; + lastFetchedAt = DateTime.MinValue; + } + if (UserPlugins == null || usePrimaryUrlOnly || DateTime.Now.Subtract(lastFetchedAt) >= fetchTimeout) { var results = await mainPluginStore.FetchAsync(token, usePrimaryUrlOnly).ConfigureAwait(false); diff --git a/Flow.Launcher.Infrastructure/Constant.cs b/Flow.Launcher.Infrastructure/Constant.cs index 7e3499ac520..1335609001f 100644 --- a/Flow.Launcher.Infrastructure/Constant.cs +++ b/Flow.Launcher.Infrastructure/Constant.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using System.IO; using System.Reflection; diff --git a/Flow.Launcher.Infrastructure/UserSettings/PluginSettings.cs b/Flow.Launcher.Infrastructure/UserSettings/PluginSettings.cs index 920abc28426..334e082eea6 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/PluginSettings.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/PluginSettings.cs @@ -28,6 +28,16 @@ public string NodeExecutablePath } } + private string pluginsManifestUrl = string.Empty; + public string PluginsManifestUrl + { + get => pluginsManifestUrl; + set + { + pluginsManifestUrl = value; + } + } + /// /// Only used for serialization /// diff --git a/Flow.Launcher.Test/CommunityPluginSourceTest.cs b/Flow.Launcher.Test/CommunityPluginSourceTest.cs new file mode 100644 index 00000000000..3c28c25e8e6 --- /dev/null +++ b/Flow.Launcher.Test/CommunityPluginSourceTest.cs @@ -0,0 +1,21 @@ +using Flow.Launcher.Core.ExternalPlugins; +using NUnit.Framework; +using NUnit.Framework.Legacy; + +namespace Flow.Launcher.Test; + +public class CommunityPluginSourceTest +{ + [Test] + public void ManifestFileUrlForLogging_OmitsCredentialsQueryAndFragment() + { + const string manifestUrl = + "https://username:password@example.com:8443/private/plugins.json?token=secret#fragment"; + var source = new CommunityPluginSource(manifestUrl); + + ClassicAssert.AreEqual(manifestUrl, source.ManifestFileUrl); + ClassicAssert.AreEqual( + "https://example.com:8443/private/plugins.json", + source.ManifestFileUrlForLogging); + } +} diff --git a/Flow.Launcher/Languages/en.xaml b/Flow.Launcher/Languages/en.xaml index 984b919310a..6a87018bf7e 100644 --- a/Flow.Launcher/Languages/en.xaml +++ b/Flow.Launcher/Languages/en.xaml @@ -114,6 +114,7 @@ Setting for New Tab, New Window, Private Mode. Python Path Node.js Path + Custom plugins manifest URL Please select the Node.js executable Please select pythonw.exe Always Start Typing in English Mode diff --git a/Flow.Launcher/PublicAPIInstance.cs b/Flow.Launcher/PublicAPIInstance.cs index d3b84b937c0..f47d91cfc41 100644 --- a/Flow.Launcher/PublicAPIInstance.cs +++ b/Flow.Launcher/PublicAPIInstance.cs @@ -611,7 +611,7 @@ public ValueTask LoadImageAsync(string path, bool loadFullImage = f ImageLoader.LoadAsync(path, loadFullImage, cacheImage); public Task UpdatePluginManifestAsync(bool usePrimaryUrlOnly = false, CancellationToken token = default) => - PluginsManifest.UpdateManifestAsync(usePrimaryUrlOnly, token); + PluginsManifest.UpdateManifestAsync(_settings, usePrimaryUrlOnly, token); public IReadOnlyList GetPluginManifest() => PluginsManifest.UserPlugins ?? []; diff --git a/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml b/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml index 5779071534f..fe5f9708f54 100644 --- a/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml +++ b/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml @@ -504,6 +504,15 @@ + + + + + +