Skip to content
Open
25 changes: 18 additions & 7 deletions Flow.Launcher.Core/ExternalPlugins/CommunityPluginSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserPlugin> plugins = [];
Expand All @@ -36,7 +38,7 @@ public record CommunityPluginSource(string ManifestFileUrl)
/// </remarks>
public async Task<List<UserPlugin>> 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);

Expand All @@ -54,36 +56,36 @@ public async Task<List<UserPlugin>> 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
{
Expand All @@ -92,5 +94,14 @@ public async Task<List<UserPlugin>> 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);
}
}
}
46 changes: 39 additions & 7 deletions Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,16 @@
using System.Threading.Tasks;
using Flow.Launcher.Plugin;
using Flow.Launcher.Core.Plugin;
using Flow.Launcher.Infrastructure.UserSettings;

namespace Flow.Launcher.Core.ExternalPlugins
{
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);

Expand All @@ -24,14 +22,48 @@ public static class PluginsManifest

public static List<UserPlugin> UserPlugins { get; private set; }

public static async Task<bool> UpdateManifestAsync(bool usePrimaryUrlOnly = false, CancellationToken token = default)
public static async Task<bool> 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)
Comment thread
Jack251970 marked this conversation as resolved.
{
Comment on lines +43 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear cached plugins when switching manifest sources

When the setting changes from the default or a previous custom source, this replaces mainPluginStore but leaves UserPlugins populated. If the new endpoint is unavailable, returns a non-success response, or serves an empty manifest, the fetch returns false while callers such as CheckForPluginUpdatesAsync ignore that result and continue consuming the old entries and download URLs through GetPluginManifest(). Clear or segregate the cached plugins when the source identity changes so selecting a private catalog cannot silently leave the previous catalog active.

Useful? React with 👍 / 👎.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Sanitize the invalid manifest URL warning

When the user enters a rejected URL containing credentials or a token, such as ftp://user:password@example.com/plugins.json?token=secret, this warning writes the complete value to persistent diagnostic logs. Fresh evidence in this revision is that the source-fetch log paths are now sanitized, but this validation path still interpolates the raw setting; apply the same sanitization or omit the URL from this warning.

Useful? React with 👍 / 👎.

mainPluginStore = new(defaultUrls[0], defaultUrls[1..]);
}
}
else
{
mainPluginStore = new(defaultUrls[0], defaultUrls[1..]);
}
lastCustomUrl = customUrl;
Comment thread
Jack251970 marked this conversation as resolved.
lastFetchedAt = DateTime.MinValue;
}

if (UserPlugins == null || usePrimaryUrlOnly || DateTime.Now.Subtract(lastFetchedAt) >= fetchTimeout)
{
var results = await mainPluginStore.FetchAsync(token, usePrimaryUrlOnly).ConfigureAwait(false);
Expand Down
2 changes: 1 addition & 1 deletion Flow.Launcher.Infrastructure/Constant.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Diagnostics;
using System.Diagnostics;
using System.IO;
using System.Reflection;

Expand Down
10 changes: 10 additions & 0 deletions Flow.Launcher.Infrastructure/UserSettings/PluginSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ public string NodeExecutablePath
}
}

private string pluginsManifestUrl = string.Empty;
public string PluginsManifestUrl
{
get => pluginsManifestUrl;
set
{
pluginsManifestUrl = value;
}
}

/// <summary>
/// Only used for serialization
/// </summary>
Expand Down
21 changes: 21 additions & 0 deletions Flow.Launcher.Test/CommunityPluginSourceTest.cs
Original file line number Diff line number Diff line change
@@ -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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: This expected value will not match the implementation. ManifestFileUrlForLogging builds it with UriComponents.SchemeAndServer | UriComponents.Path, but SchemeAndServer (0xF) includes UserInfo, so username:password@ is kept and the sanitized URL is https://username:password@example.com:8443/private/plugins.json. The assertion fails, and, worse, the commit's goal of protecting credentials in logs is not met. Fix SanitizeUrlForLogging to exclude UserInfo by using UriComponents.Scheme | UriComponents.Host | UriComponents.Port | UriComponents.Path, then this expected value becomes correct.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Flow.Launcher.Test/CommunityPluginSourceTest.cs, line 18:

<comment>This expected value will not match the implementation. `ManifestFileUrlForLogging` builds it with `UriComponents.SchemeAndServer | UriComponents.Path`, but `SchemeAndServer` (0xF) includes `UserInfo`, so `username:password@` is kept and the sanitized URL is `https://username:password@example.com:8443/private/plugins.json`. The assertion fails, and, worse, the commit's goal of protecting credentials in logs is not met. Fix `SanitizeUrlForLogging` to exclude `UserInfo` by using `UriComponents.Scheme | UriComponents.Host | UriComponents.Port | UriComponents.Path`, then this expected value becomes correct.</comment>

<file context>
@@ -0,0 +1,21 @@
+
+        ClassicAssert.AreEqual(manifestUrl, source.ManifestFileUrl);
+        ClassicAssert.AreEqual(
+            "https://example.com:8443/private/plugins.json",
+            source.ManifestFileUrlForLogging);
+    }
</file context>

source.ManifestFileUrlForLogging);
}
}
1 change: 1 addition & 0 deletions Flow.Launcher/Languages/en.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
<system:String x:Key="defaultBrowserToolTip">Setting for New Tab, New Window, Private Mode.</system:String>
<system:String x:Key="pythonFilePath">Python Path</system:String>
<system:String x:Key="nodeFilePath">Node.js Path</system:String>
<system:String x:Key="pluginManifestUrl">Custom plugins manifest URL</system:String>
<system:String x:Key="selectNodeExecutable">Please select the Node.js executable</system:String>
<system:String x:Key="selectPythonExecutable">Please select pythonw.exe</system:String>
<system:String x:Key="typingStartEn">Always Start Typing in English Mode</system:String>
Expand Down
2 changes: 1 addition & 1 deletion Flow.Launcher/PublicAPIInstance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -611,7 +611,7 @@ public ValueTask<ImageSource> LoadImageAsync(string path, bool loadFullImage = f
ImageLoader.LoadAsync(path, loadFullImage, cacheImage);

public Task<bool> UpdatePluginManifestAsync(bool usePrimaryUrlOnly = false, CancellationToken token = default) =>
PluginsManifest.UpdateManifestAsync(usePrimaryUrlOnly, token);
PluginsManifest.UpdateManifestAsync(_settings, usePrimaryUrlOnly, token);

public IReadOnlyList<UserPlugin> GetPluginManifest() => PluginsManifest.UserPlugins ?? [];

Expand Down
9 changes: 9 additions & 0 deletions Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,15 @@
</StackPanel>
</ui:SettingsCard>

<ui:SettingsCard Margin="0 4 0 0" Header="{DynamicResource pluginManifestUrl}">
<StackPanel Orientation="Horizontal">
<TextBox
Width="370"
Height="34"
Text="{Binding Settings.PluginSettings.PluginsManifestUrl, TargetNullValue='None', UpdateSourceTrigger=PropertyChanged}" />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: This binding only writes the typed URL into the settings object; nothing reacts to PluginsManifestUrl changing, so the plugin catalog is not refreshed when the user edits the URL (UpdatePluginManifestAsync is only called at startup, from the Plugin Store refresh button, and from the plugins settings pane). The user keeps seeing the stale catalog until a manual refresh or restart. Also, with UpdateSourceTrigger=PropertyChanged each keystroke commits a partial URL into the in-memory settings, which is then persisted when the settings window closes (SettingWindow.xaml.cs OnClosed calls _settings.Save()), so half-typed values can be written to disk.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml, line 512:

<comment>This binding only writes the typed URL into the settings object; nothing reacts to PluginsManifestUrl changing, so the plugin catalog is not refreshed when the user edits the URL (UpdatePluginManifestAsync is only called at startup, from the Plugin Store refresh button, and from the plugins settings pane). The user keeps seeing the stale catalog until a manual refresh or restart. Also, with UpdateSourceTrigger=PropertyChanged each keystroke commits a partial URL into the in-memory settings, which is then persisted when the settings window closes (SettingWindow.xaml.cs OnClosed calls _settings.Save()), so half-typed values can be written to disk.</comment>

<file context>
@@ -509,7 +509,7 @@
                         Width="370"
                         Height="34"
-                        Text="{Binding Settings.PluginSettings.PluginsManifestUrl,TargetNullValue='None'}" />
+                        Text="{Binding Settings.PluginSettings.PluginsManifestUrl, TargetNullValue='None', UpdateSourceTrigger=PropertyChanged}" />
                 </StackPanel>
             </ui:SettingsCard>
</file context>

</StackPanel>
</ui:SettingsCard>

<ui:SettingsCard
Margin="0 14 0 0"
Description="{DynamicResource typingStartEnTooltip}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ protected override void OnNavigatedTo(NavigationEventArgs e)
UpdateCategoryGrouping();
_viewModel.PropertyChanged += ViewModel_PropertyChanged;
base.OnNavigatedTo(e);

// Refresh on entry so a manifest URL committed in General settings is
// fetched and reflected by the store without requiring a restart or a
// manual refresh. The manifest service skips the fetch while its cache
// is still valid unless the configured URL changed.
_viewModel.RefreshExternalPluginsCommand.Execute(null);
}

private void ViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e)
Expand Down
Loading