Skip to content
Open
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
45 changes: 45 additions & 0 deletions Flow.Launcher.Core/Plugin/PluginInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,51 @@ public static async Task InstallPluginAndCheckRestartAsync(string filePath)
await InstallPluginAndCheckRestartAsync(plugin);
}

/// <summary>
/// Installs a plugin from a direct zip download URL and restarts the application if required by settings.
/// Applies the unknown source warning and prompts user for confirmation before installing.
/// </summary>
/// <param name="url">The https URL of the plugin zip file.</param>
/// <returns>A Task representing the asynchronous install operation.</returns>
public static async Task InstallPluginFromWebAndCheckRestartAsync(string url)
{
// Derive the filename from the URI path so query strings (e.g. plugin.zip?token=x) don't end up
// as part of a temp filename, which is invalid on Windows. Fall back to the naive split if the
// URL somehow fails to parse as a URI (callers already validate https, this is defense in depth).
// uri.LocalPath is percent-decoded, so it can still contain characters that are invalid in
// Windows filenames (e.g. a literal "?" from an encoded "%3F"); strip those out.
var filename = Uri.TryCreate(url, UriKind.Absolute, out var uri)
? Path.GetFileName(uri.LocalPath)
: url.Split('/').Last();
foreach (var c in Path.GetInvalidFileNameChars())

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: Installing a plugin from a web URL can produce an empty or misleading plugin name when the URL path contains percent-encoded characters that decode to invalid Windows filename characters (e.g., %3F?). After Path.GetFileName and the invalid-character stripping loop, a basename like ?.zip collapses to .zip, and removing the extension yields an empty string. That empty value flows into UserPlugin.Name and Version, so user-facing prompts show a blank plugin name, and the temp download filename becomes -$'{Guid.NewGuid()}.zip. Consider validating the sanitized result and falling back to a safe default (for example, "plugin" or the last non-empty path segment) before constructing the UserPlugin.

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

<comment>Installing a plugin from a web URL can produce an empty or misleading plugin name when the URL path contains percent-encoded characters that decode to invalid Windows filename characters (e.g., `%3F` → `?`). After `Path.GetFileName` and the invalid-character stripping loop, a basename like `?.zip` collapses to `.zip`, and removing the extension yields an empty string. That empty value flows into `UserPlugin.Name` and `Version`, so user-facing prompts show a blank plugin name, and the temp download filename becomes `-$'{Guid.NewGuid()}.zip`. Consider validating the sanitized result and falling back to a safe default (for example, `"plugin"` or the last non-empty path segment) before constructing the `UserPlugin`.</comment>

<file context>
@@ -159,9 +159,15 @@ public static async Task InstallPluginFromWebAndCheckRestartAsync(string url)
         var filename = Uri.TryCreate(url, UriKind.Absolute, out var uri)
             ? Path.GetFileName(uri.LocalPath)
             : url.Split('/').Last();
+        foreach (var c in Path.GetInvalidFileNameChars())
+        {
+            filename = filename.Replace(c.ToString(), string.Empty);
</file context>

{
filename = filename.Replace(c.ToString(), string.Empty);
}
var name = filename.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
? filename[..^".zip".Length]
: filename;

var plugin = new UserPlugin
{
ID = string.Empty,
Name = name,
Version = string.Empty,
Author = Localize.UnknownPluginAuthor(),
UrlDownload = url
};

if (Settings.ShowUnknownSourceWarning)
{
if (!InstallSourceKnown(url)
&& PublicApi.Instance.ShowMsgBox(Localize.InstallFromUnknownSourceSubtitle(Environment.NewLine),
Localize.InstallFromUnknownSourceTitle(),
MessageBoxButton.YesNo) == MessageBoxResult.No)
return;
}

await InstallPluginAndCheckRestartAsync(plugin);
}

/// <summary>
/// Uninstalls a plugin and restarts the application if required by settings. Prompts user for confirmation and whether to keep plugin settings.
/// </summary>
Expand Down
1 change: 1 addition & 0 deletions Flow.Launcher.Infrastructure/UserSettings/Settings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ public bool ShowHistoryResultsForHomePage

public bool AutoRestartAfterChanging { get; set; } = false;
public bool ShowUnknownSourceWarning { get; set; } = true;
public bool EnableDeepLinkProtocol { get; set; } = true;
public bool AutoUpdatePlugins { get; set; } = true;

public int CustomExplorerIndex { get; set; } = 0;
Expand Down
90 changes: 90 additions & 0 deletions Flow.Launcher.Test/DeepLinkTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using System;
using Flow.Launcher.Helper;
using NUnit.Framework;

namespace Flow.Launcher.Test
{
[TestFixture]
public class DeepLinkTest
{
[Test]
public void FromCommandLineArgs_QueryFlag_NormalizesToQueryUri()
{
var result = DeepLink.FromCommandLineArgs(new[] { "--query", "foo bar" }, true);
Assert.That(result, Is.EqualTo("flow-launcher://query?q=foo%20bar"));
}

[Test]
public void FromCommandLineArgs_SingleDashQueryFlag_NormalizesToQueryUri()
{
var result = DeepLink.FromCommandLineArgs(new[] { "-q", "foo" }, true);
Assert.That(result, Is.EqualTo("flow-launcher://query?q=foo"));
}

[Test]
public void FromCommandLineArgs_FlowPluginPath_NormalizesToInstallUri()
{
var path = OperatingSystem.IsWindows() ? @"C:\tmp\My Plugin.flowplugin" : "/tmp/My Plugin.flowplugin";
var result = DeepLink.FromCommandLineArgs(new[] { path }, true);
Assert.That(result, Does.StartWith("flow-launcher://plugin/install?path="));
Assert.That(Uri.UnescapeDataString(result.Split("path=")[1]), Does.EndWith("My Plugin.flowplugin"));
}

[Test]
public void FromCommandLineArgs_RawSchemeUri_PassedThroughWhenAllowed()
{
var result = DeepLink.FromCommandLineArgs(new[] { "flow-launcher://settings" }, true);
Assert.That(result, Is.EqualTo("flow-launcher://settings"));
}

[Test]
public void FromCommandLineArgs_RawSchemeUri_DroppedWhenDisallowed()
{
var result = DeepLink.FromCommandLineArgs(new[] { "flow-launcher://settings" }, false);
Assert.That(result, Is.Null);
}

[Test]
public void FromCommandLineArgs_FlowPluginPath_StillWorksWhenSchemeDisallowed()
{
var result = DeepLink.FromCommandLineArgs(new[] { "plugin.flowplugin" }, false);
Assert.That(result, Does.StartWith("flow-launcher://plugin/install?path="));
}

[Test]
public void FromCommandLineArgs_NoRelevantArgs_ReturnsNull()
{
Assert.That(DeepLink.FromCommandLineArgs(Array.Empty<string>(), true), Is.Null);
Assert.That(DeepLink.FromCommandLineArgs(new[] { "--unrelated" }, true), Is.Null);
Assert.That(DeepLink.FromCommandLineArgs(new[] { "--query" }, true), Is.Null); // flag without value
}

[Test]
public void FromCommandLineArgs_QueryFlagWithEmptyValue_ReturnsNull()
{
Assert.That(DeepLink.FromCommandLineArgs(new[] { "--query", "" }, true), Is.Null);
Assert.That(DeepLink.FromCommandLineArgs(new[] { "-q", "" }, true), Is.Null);
}

[TestCase("flow-launcher://query?q=hello%20world", "query", "q", "hello world")]
[TestCase("flow-launcher://plugin/install?id=abc123", "plugin/install", "id", "abc123")]
[TestCase("FLOW-LAUNCHER://SETTINGS", "settings", null, null)]
public void TryParse_ValidUris_ExtractsVerbAndParameters(string payload, string expectedVerb, string paramKey, string paramValue)
{
var ok = DeepLink.TryParse(payload, out var verb, out var parameters);
Assert.That(ok, Is.True);
Assert.That(verb, Is.EqualTo(expectedVerb));
if (paramKey != null)
Assert.That(parameters[paramKey], Is.EqualTo(paramValue));
}

[TestCase(null)]
[TestCase("")]
[TestCase("just a plain query string")]
[TestCase("https://example.com/notourscheme")]
public void TryParse_InvalidPayloads_ReturnsFalse(string payload)
{
Assert.That(DeepLink.TryParse(payload, out _, out _), Is.False);
}
}
}
26 changes: 22 additions & 4 deletions Flow.Launcher/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public partial class App : IDisposable, ISingleInstanceApp

private static bool _disposed;
private static Settings _settings;
private static string _pendingDeepLink;
private static MainWindow _mainWindow;
private readonly MainViewModel _mainVM;
private readonly Internationalization _internationalization;
Expand Down Expand Up @@ -126,7 +127,7 @@ public App()
#region Main

[STAThread]
public static void Main()
public static void Main(string[] args)
{
// Initialize settings so that we can get language code
try
Expand All @@ -151,8 +152,10 @@ public static void Main()
}

// Start the application as a single instance
if (SingleInstance<App>.InitializeAsFirstInstance())
var deepLink = DeepLink.FromCommandLineArgs(args, _settings.EnableDeepLinkProtocol);
if (SingleInstance<App>.InitializeAsFirstInstance(deepLink))
{
_pendingDeepLink = deepLink;
using var application = new App();
application.InitializeComponent();
application.Run();
Expand Down Expand Up @@ -243,6 +246,8 @@ await API.StopwatchLogInfoAsync(ClassName, "Startup cost", async () =>

RegisterExitEvents();

DeepLinkRegistration.EnsureRegistered(_settings.EnableDeepLinkProtocol);

AutoStartup();
AutoUpdates();

Expand All @@ -262,6 +267,13 @@ await API.StopwatchLogInfoAsync(ClassName, "Startup cost", async () =>
// Refresh the history results after plugins are initialized so that we can parse the absolute icon paths
_mainVM.RefreshLastOpenedHistoryResults();

// Dispatch the deep link passed on the command line now that plugins can answer it
if (!string.IsNullOrEmpty(_pendingDeepLink))
{
DeepLink.Dispatch(_pendingDeepLink);
_pendingDeepLink = null;
}

// Refresh home page after plugins are initialized because users may open main window during plugin initialization
// And home page is created without full plugin list
if (_settings.ShowHomePage && _mainVM.QueryResultsSelected() && string.IsNullOrEmpty(_mainVM.QueryText))
Expand Down Expand Up @@ -465,9 +477,15 @@ public void Dispose()

#region ISingleInstanceApp

public void OnSecondAppStarted()
public void OnSecondAppStarted(string payload)
{
API.ShowMainWindow();
if (string.IsNullOrEmpty(payload))
{
API.ShowMainWindow();
return;
}

DeepLink.Dispatch(payload);
}

#endregion
Expand Down
Loading
Loading