From 27d911c5e955820ef541b69e146334d123e32c04 Mon Sep 17 00:00:00 2001 From: Garulf <535299+Garulf@users.noreply.github.com> Date: Tue, 7 Jul 2026 07:46:15 +0000 Subject: [PATCH 01/10] Add --query command line argument to open Flow Launcher with a query Support `Flow.Launcher.exe --query "text"` (also `-query`). On cold start the query is applied once plugin initialization completes; when an instance is already running, the second process sends the query to it over the existing single-instance named pipe (which previously carried no payload) and the first instance shows the window with the query searched. Launching with no arguments behaves exactly as before. --- Flow.Launcher/App.xaml.cs | 35 ++++++++++++++++-- Flow.Launcher/Helper/SingleInstance.cs | 50 +++++++++++++++++++++----- 2 files changed, 74 insertions(+), 11 deletions(-) diff --git a/Flow.Launcher/App.xaml.cs b/Flow.Launcher/App.xaml.cs index da11380b861..fcd50243c14 100644 --- a/Flow.Launcher/App.xaml.cs +++ b/Flow.Launcher/App.xaml.cs @@ -44,6 +44,7 @@ public partial class App : IDisposable, ISingleInstanceApp private static bool _disposed; private static Settings _settings; + private static string _pendingQuery; private static MainWindow _mainWindow; private readonly MainViewModel _mainVM; private readonly Internationalization _internationalization; @@ -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 @@ -151,14 +152,28 @@ public static void Main() } // Start the application as a single instance - if (SingleInstance.InitializeAsFirstInstance()) + var query = ParseQueryArg(args); + if (SingleInstance.InitializeAsFirstInstance(query)) { + _pendingQuery = query; using var application = new App(); application.InitializeComponent(); application.Run(); } } + private static string ParseQueryArg(string[] args) + { + for (var i = 0; i < args.Length; i++) + { + if ((args[i] == "--query" || args[i] == "-query") && i + 1 < args.Length) + { + return args[i + 1]; + } + } + return null; + } + #endregion #region Fail Fast @@ -262,6 +277,14 @@ 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(); + // Apply the query passed via the --query command line argument now that plugins can answer it + if (!string.IsNullOrEmpty(_pendingQuery)) + { + API.ChangeQuery(_pendingQuery, true); + API.ShowMainWindow(); + _pendingQuery = 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)) @@ -465,9 +488,15 @@ public void Dispose() #region ISingleInstanceApp - public void OnSecondAppStarted() + public void OnSecondAppStarted(string query) { API.ShowMainWindow(); + if (!string.IsNullOrEmpty(query)) + { + // Make sure to go back to the query results page first since it can cause issues if current page is context menu + API.BackToQueryResults(); + API.ChangeQuery(query, true); + } } #endregion diff --git a/Flow.Launcher/Helper/SingleInstance.cs b/Flow.Launcher/Helper/SingleInstance.cs index de2579b6290..1c57bdb01b6 100644 --- a/Flow.Launcher/Helper/SingleInstance.cs +++ b/Flow.Launcher/Helper/SingleInstance.cs @@ -1,5 +1,7 @@ using System; +using System.IO; using System.IO.Pipes; +using System.Text; using System.Threading; using System.Threading.Tasks; using System.Windows; @@ -10,7 +12,7 @@ namespace Flow.Launcher.Helper { public interface ISingleInstanceApp { - void OnSecondAppStarted(); + void OnSecondAppStarted(string query); } /// @@ -53,7 +55,7 @@ public static class SingleInstance where TApplication : Applicatio /// If not, activates the first instance. /// /// True if this is the first instance of the application. - public static bool InitializeAsFirstInstance() + public static bool InitializeAsFirstInstance(string args = null) { // Build unique application Id and the IPC channel name. string applicationIdentifier = InstanceMutexName + Environment.UserName; @@ -69,7 +71,16 @@ public static bool InitializeAsFirstInstance() } else { - _ = SignalFirstInstanceAsync(channelName); + try + { + // Block until the signal and query payload are delivered, + // because the second instance exits right after this returns + SignalFirstInstanceAsync(channelName, args).Wait(TimeSpan.FromSeconds(3)); + } + catch + { + // If the first instance cannot be reached there is nothing more to do + } return false; } } @@ -99,8 +110,24 @@ private static async Task CreateRemoteServiceAsync(string channelName) // Wait for connection to the pipe await pipeServer.WaitForConnectionAsync(); + string query = null; + try + { + using var reader = new StreamReader(pipeServer, Encoding.UTF8, false, 1024, leaveOpen: true); + var readTask = reader.ReadLineAsync(); + // Guard against a client that connects but never writes or closes + if (await Task.WhenAny(readTask, Task.Delay(2000)) == readTask) + { + query = await readTask; // null when the client wrote nothing (plain activation) + } + } + catch + { + // Treat any pipe read failure as a plain activation; never let it kill the server loop + } + // Do an asynchronous call to ActivateFirstInstance function - Application.Current?.Dispatcher.Invoke(ActivateFirstInstance); + Application.Current?.Dispatcher.Invoke(() => ActivateFirstInstance(query)); // Disconect client pipeServer.Disconnect(); @@ -114,20 +141,27 @@ private static async Task CreateRemoteServiceAsync(string channelName) /// /// Command line arguments for the second instance, passed to the first instance to take appropriate action. /// - private static async Task SignalFirstInstanceAsync(string channelName) + private static async Task SignalFirstInstanceAsync(string channelName, string args) { // Create a client pipe connected to server using NamedPipeClientStream pipeClient = new NamedPipeClientStream(".", channelName, PipeDirection.Out); // Connect to the available pipe - await pipeClient.ConnectAsync(0); + await pipeClient.ConnectAsync(1000); + + // Send the query to the first instance if there is one + if (!string.IsNullOrEmpty(args)) + { + using var writer = new StreamWriter(pipeClient, Encoding.UTF8) { AutoFlush = true }; + await writer.WriteLineAsync(args); + } } /// /// Activates the first instance of the application with arguments from a second instance. /// /// List of arguments to supply the first instance of the application. - private static void ActivateFirstInstance() + private static void ActivateFirstInstance(string args) { // Set main window state and process command line args if (Application.Current == null) @@ -135,7 +169,7 @@ private static void ActivateFirstInstance() return; } - ((TApplication)Application.Current).OnSecondAppStarted(); + ((TApplication)Application.Current).OnSecondAppStarted(args); } #endregion From cb73cd85ffd3981dcdf70c6a6f37d4ad33bb9130 Mon Sep 17 00:00:00 2001 From: Garulf <535299+Garulf@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:21:46 +0000 Subject: [PATCH 02/10] Add DeepLink parser and command line normalization with tests --- Flow.Launcher.Test/DeepLinkTest.cs | 83 ++++++++++++++++++++++++++++++ Flow.Launcher/Helper/DeepLink.cs | 75 +++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 Flow.Launcher.Test/DeepLinkTest.cs create mode 100644 Flow.Launcher/Helper/DeepLink.cs diff --git a/Flow.Launcher.Test/DeepLinkTest.cs b/Flow.Launcher.Test/DeepLinkTest.cs new file mode 100644 index 00000000000..e0be00fd722 --- /dev/null +++ b/Flow.Launcher.Test/DeepLinkTest.cs @@ -0,0 +1,83 @@ +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[] { "-query", "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(), true), Is.Null); + Assert.That(DeepLink.FromCommandLineArgs(new[] { "--unrelated" }, true), Is.Null); + Assert.That(DeepLink.FromCommandLineArgs(new[] { "--query" }, true), Is.Null); // flag without value + } + + [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); + } + } +} diff --git a/Flow.Launcher/Helper/DeepLink.cs b/Flow.Launcher/Helper/DeepLink.cs new file mode 100644 index 00000000000..74442ea0016 --- /dev/null +++ b/Flow.Launcher/Helper/DeepLink.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Specialized; +using System.IO; +using System.Web; + +namespace Flow.Launcher.Helper; + +/// +/// Normalizes command line arguments into flow-launcher:// deep link URIs +/// and routes them to the matching verb handler. +/// +public static class DeepLink +{ + public const string Scheme = "flow-launcher"; + public const string SchemePrefix = Scheme + "://"; + public const string PluginFileExtension = ".flowplugin"; + + /// + /// Normalizes command line arguments to a single deep link URI string, or null for a normal launch. + /// Raw flow-launcher:// arguments are dropped when is false + /// (URI scheme disabled in settings); --query and .flowplugin paths are always honored. + /// + public static string FromCommandLineArgs(string[] args, bool allowSchemeArgs) + { + for (var i = 0; i < args.Length; i++) + { + if ((args[i] == "--query" || args[i] == "-query") && i + 1 < args.Length) + { + return $"{SchemePrefix}query?q={Uri.EscapeDataString(args[i + 1])}"; + } + + if (args[i].StartsWith(SchemePrefix, StringComparison.OrdinalIgnoreCase)) + { + return allowSchemeArgs ? args[i] : null; + } + + if (args[i].EndsWith(PluginFileExtension, StringComparison.OrdinalIgnoreCase)) + { + return $"{SchemePrefix}plugin/install?path={Uri.EscapeDataString(Path.GetFullPath(args[i]))}"; + } + } + + return null; + } + + /// + /// Parses a deep link URI into a lowercase verb (host plus optional path, e.g. "plugin/install") + /// and its query parameters. Returns false for anything that is not a flow-launcher:// URI. + /// + public static bool TryParse(string payload, out string verb, out NameValueCollection parameters) + { + verb = null; + parameters = null; + + if (string.IsNullOrEmpty(payload) || !Uri.TryCreate(payload, UriKind.Absolute, out var uri)) + { + return false; + } + + if (!string.Equals(uri.Scheme, Scheme, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + verb = uri.Host.ToLowerInvariant(); + var subPath = uri.AbsolutePath.Trim('/'); + if (!string.IsNullOrEmpty(subPath)) + { + verb = $"{verb}/{subPath.ToLowerInvariant()}"; + } + + parameters = HttpUtility.ParseQueryString(uri.Query); + return true; + } +} From 8574152e2d52012f803e148f913f8fec55bb1534 Mon Sep 17 00:00:00 2001 From: Garulf <535299+Garulf@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:26:09 +0000 Subject: [PATCH 03/10] Add HKCU registration helper for .flowplugin and flow-launcher:// scheme --- Flow.Launcher/Helper/DeepLinkRegistration.cs | 76 ++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 Flow.Launcher/Helper/DeepLinkRegistration.cs diff --git a/Flow.Launcher/Helper/DeepLinkRegistration.cs b/Flow.Launcher/Helper/DeepLinkRegistration.cs new file mode 100644 index 00000000000..c6a9d3700a8 --- /dev/null +++ b/Flow.Launcher/Helper/DeepLinkRegistration.cs @@ -0,0 +1,76 @@ +using System; +using Flow.Launcher.Infrastructure; +using Microsoft.Win32; + +namespace Flow.Launcher.Helper; + +/// +/// Registers the .flowplugin file association and the flow-launcher:// URI scheme +/// under HKCU\Software\Classes. Writes are idempotent, so calling on every startup +/// self-heals stale executable paths after updates. No admin rights required. +/// +public static class DeepLinkRegistration +{ + private static readonly string ClassName = nameof(DeepLinkRegistration); + + private const string ClassesPath = @"Software\Classes"; + private const string ProgId = "Flow.Launcher.PluginPackage"; + + private static string OpenCommand => $"\"{Constant.ExecutablePath}\" \"%1\""; + private static string DefaultIcon => $"\"{Constant.ExecutablePath}\",0"; + + public static void EnsureRegistered(bool uriSchemeEnabled) + { + try + { + RegisterFileExtension(); + + if (uriSchemeEnabled) + { + RegisterUriScheme(); + } + else + { + UnregisterUriScheme(); + } + } + catch (Exception e) + { + // Locked-down environments may forbid registry writes; deep links are then unavailable + App.API.LogError(ClassName, $"Failed to register deep link handlers: {e}"); + } + } + + private static void RegisterFileExtension() + { + using var extensionKey = Registry.CurrentUser.CreateSubKey($@"{ClassesPath}\{DeepLink.PluginFileExtension}"); + extensionKey.SetValue(null, ProgId); + + using var progIdKey = Registry.CurrentUser.CreateSubKey($@"{ClassesPath}\{ProgId}"); + progIdKey.SetValue(null, "Flow Launcher Plugin"); + + using var iconKey = progIdKey.CreateSubKey("DefaultIcon"); + iconKey.SetValue(null, DefaultIcon); + + using var commandKey = progIdKey.CreateSubKey(@"shell\open\command"); + commandKey.SetValue(null, OpenCommand); + } + + public static void RegisterUriScheme() + { + using var schemeKey = Registry.CurrentUser.CreateSubKey($@"{ClassesPath}\{DeepLink.Scheme}"); + schemeKey.SetValue(null, "URL:Flow Launcher Protocol"); + schemeKey.SetValue("URL Protocol", ""); + + using var iconKey = schemeKey.CreateSubKey("DefaultIcon"); + iconKey.SetValue(null, DefaultIcon); + + using var commandKey = schemeKey.CreateSubKey(@"shell\open\command"); + commandKey.SetValue(null, OpenCommand); + } + + public static void UnregisterUriScheme() + { + Registry.CurrentUser.DeleteSubKeyTree($@"{ClassesPath}\{DeepLink.Scheme}", throwOnMissingSubKey: false); + } +} From e8eb0c79c4af028c58e356d5eb01ee7c54e54cde Mon Sep 17 00:00:00 2001 From: Garulf <535299+Garulf@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:29:18 +0000 Subject: [PATCH 04/10] Add PluginInstaller method to install a plugin from a web url --- Flow.Launcher.Core/Plugin/PluginInstaller.cs | 34 ++++++++++++++++++++ Flow.Launcher/Languages/en.xaml | 1 + 2 files changed, 35 insertions(+) diff --git a/Flow.Launcher.Core/Plugin/PluginInstaller.cs b/Flow.Launcher.Core/Plugin/PluginInstaller.cs index 6027b712e73..3802d6402b5 100644 --- a/Flow.Launcher.Core/Plugin/PluginInstaller.cs +++ b/Flow.Launcher.Core/Plugin/PluginInstaller.cs @@ -148,6 +148,40 @@ public static async Task InstallPluginAndCheckRestartAsync(string filePath) await InstallPluginAndCheckRestartAsync(plugin); } + /// + /// 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. + /// + /// The https URL of the plugin zip file. + /// A Task representing the asynchronous install operation. + public static async Task InstallPluginFromWebAndCheckRestartAsync(string url) + { + var filename = url.Split('/').Last(); + var name = filename.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) + ? 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); + } + /// /// Uninstalls a plugin and restarts the application if required by settings. Prompts user for confirmation and whether to keep plugin settings. /// diff --git a/Flow.Launcher/Languages/en.xaml b/Flow.Launcher/Languages/en.xaml index cf67bffeafa..b271b70e178 100644 --- a/Flow.Launcher/Languages/en.xaml +++ b/Flow.Launcher/Languages/en.xaml @@ -278,6 +278,7 @@ Zip file does not have a valid plugin.json configuration Installing from an unknown source This plugin is from an unknown source and it may contain potential risks!{0}{0}Please ensure you understand where this plugin is from and that it is safe.{0}{0}Would you like to continue still?{0}{0}(You can switch off this warning in general section of setting window) + Unknown Zip files Please select zip file Install plugin from local path From bcc24feda297beba552ab0b991932fe6bd863388 Mon Sep 17 00:00:00 2001 From: Garulf <535299+Garulf@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:32:59 +0000 Subject: [PATCH 05/10] Add deep link verb dispatch for query, settings and plugin install --- Flow.Launcher/Helper/DeepLink.cs | 111 +++++++++++++++++++++++++++++++ Flow.Launcher/Languages/en.xaml | 7 ++ 2 files changed, 118 insertions(+) diff --git a/Flow.Launcher/Helper/DeepLink.cs b/Flow.Launcher/Helper/DeepLink.cs index 74442ea0016..9313619e30b 100644 --- a/Flow.Launcher/Helper/DeepLink.cs +++ b/Flow.Launcher/Helper/DeepLink.cs @@ -1,7 +1,11 @@ using System; +using System.Collections.Generic; using System.Collections.Specialized; using System.IO; +using System.Linq; +using System.Threading.Tasks; using System.Web; +using Flow.Launcher.Core.Plugin; namespace Flow.Launcher.Helper; @@ -15,6 +19,15 @@ public static class DeepLink public const string SchemePrefix = Scheme + "://"; public const string PluginFileExtension = ".flowplugin"; + private static readonly string ClassName = nameof(DeepLink); + + private static readonly Dictionary> Handlers = new() + { + ["query"] = HandleQuery, + ["settings"] = HandleSettings, + ["plugin/install"] = HandlePluginInstall, + }; + /// /// Normalizes command line arguments to a single deep link URI string, or null for a normal launch. /// Raw flow-launcher:// arguments are dropped when is false @@ -72,4 +85,102 @@ public static bool TryParse(string payload, out string verb, out NameValueCollec parameters = HttpUtility.ParseQueryString(uri.Query); return true; } + + /// + /// Routes a deep link payload to its verb handler. Payloads that are not + /// flow-launcher:// URIs are treated as plain query text for backward compatibility + /// with older second instances that sent the raw --query value. + /// + public static void Dispatch(string payload) + { + if (string.IsNullOrEmpty(payload)) return; + + if (!TryParse(payload, out var verb, out var parameters)) + { + ChangeQueryAndShow(payload); + return; + } + + if (Handlers.TryGetValue(verb, out var handler)) + { + handler(parameters); + } + else + { + App.API.LogWarn(ClassName, $"Unrecognized deep link verb <{verb}> in <{payload}>"); + App.API.ShowMsgError(Localize.deepLinkUnrecognizedTitle(), Localize.deepLinkUnrecognizedSubtitle(payload)); + } + } + + private static void HandleQuery(NameValueCollection parameters) + { + ChangeQueryAndShow(parameters["q"]); + } + + private static void ChangeQueryAndShow(string query) + { + App.API.ShowMainWindow(); + if (string.IsNullOrEmpty(query)) return; + + // Make sure to go back to the query results page first since it can cause issues if current page is context menu + App.API.BackToQueryResults(); + App.API.ChangeQuery(query, true); + } + + private static void HandleSettings(NameValueCollection parameters) + { + App.API.OpenSettingDialog(); + } + + private static void HandlePluginInstall(NameValueCollection parameters) + { + var path = parameters["path"]; + var id = parameters["id"]; + var url = parameters["url"]; + + if (new[] { path, id, url }.Count(x => !string.IsNullOrEmpty(x)) != 1) + { + App.API.ShowMsgError(Localize.deepLinkInstallInvalidTitle(), Localize.deepLinkInstallInvalidSubtitle()); + return; + } + + if (!string.IsNullOrEmpty(path)) + { + if (!File.Exists(path)) + { + App.API.ShowMsgError(Localize.deepLinkInstallInvalidTitle(), Localize.deepLinkInstallFileNotFound(path)); + return; + } + + _ = PluginInstaller.InstallPluginAndCheckRestartAsync(path); + } + else if (!string.IsNullOrEmpty(id)) + { + _ = InstallByIdAsync(id); + } + else + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var downloadUri) || downloadUri.Scheme != Uri.UriSchemeHttps) + { + App.API.ShowMsgError(Localize.deepLinkInstallInvalidTitle(), Localize.deepLinkInstallHttpsOnly()); + return; + } + + _ = PluginInstaller.InstallPluginFromWebAndCheckRestartAsync(url); + } + } + + private static async Task InstallByIdAsync(string id) + { + await App.API.UpdatePluginManifestAsync(); + var plugin = App.API.GetPluginManifest() + .FirstOrDefault(x => string.Equals(x.ID, id, StringComparison.OrdinalIgnoreCase)); + if (plugin == null) + { + App.API.ShowMsgError(Localize.deepLinkInstallInvalidTitle(), Localize.deepLinkInstallPluginNotFound(id)); + return; + } + + await PluginInstaller.InstallPluginAndCheckRestartAsync(plugin); + } } diff --git a/Flow.Launcher/Languages/en.xaml b/Flow.Launcher/Languages/en.xaml index b271b70e178..3be2c7743be 100644 --- a/Flow.Launcher/Languages/en.xaml +++ b/Flow.Launcher/Languages/en.xaml @@ -42,6 +42,13 @@ Failed to register hotkey "{0}". The hotkey may be in use by another program. Change to a different hotkey, or exit another program. Failed to unregister hotkey "{0}". Please try again or see log for details Flow Launcher + Unrecognized Flow Launcher link + The link "{0}" is not supported by this version of Flow Launcher. + Invalid plugin install link + A plugin install link must contain exactly one of: path, id, or url. + Plugin package not found: {0} + No plugin with id "{0}" was found in the plugin store. + Plugin install links must use an https download URL. Could not start {0} Invalid Flow Launcher plugin file format Set as topmost in this query From 02396e11de8b085fcbbf46e89df239138b411cda Mon Sep 17 00:00:00 2001 From: Garulf <535299+Garulf@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:36:24 +0000 Subject: [PATCH 06/10] Route command line args through deep link dispatch and register handlers at startup --- .../UserSettings/Settings.cs | 1 + Flow.Launcher/App.xaml.cs | 43 +++++++------------ 2 files changed, 17 insertions(+), 27 deletions(-) diff --git a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs index fd535f21a6e..3bad1e7ac4a 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs @@ -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; diff --git a/Flow.Launcher/App.xaml.cs b/Flow.Launcher/App.xaml.cs index fcd50243c14..9eb2d9a2b2c 100644 --- a/Flow.Launcher/App.xaml.cs +++ b/Flow.Launcher/App.xaml.cs @@ -44,7 +44,7 @@ public partial class App : IDisposable, ISingleInstanceApp private static bool _disposed; private static Settings _settings; - private static string _pendingQuery; + private static string _pendingDeepLink; private static MainWindow _mainWindow; private readonly MainViewModel _mainVM; private readonly Internationalization _internationalization; @@ -152,28 +152,16 @@ public static void Main(string[] args) } // Start the application as a single instance - var query = ParseQueryArg(args); - if (SingleInstance.InitializeAsFirstInstance(query)) + var deepLink = DeepLink.FromCommandLineArgs(args, _settings.EnableDeepLinkProtocol); + if (SingleInstance.InitializeAsFirstInstance(deepLink)) { - _pendingQuery = query; + _pendingDeepLink = deepLink; using var application = new App(); application.InitializeComponent(); application.Run(); } } - private static string ParseQueryArg(string[] args) - { - for (var i = 0; i < args.Length; i++) - { - if ((args[i] == "--query" || args[i] == "-query") && i + 1 < args.Length) - { - return args[i + 1]; - } - } - return null; - } - #endregion #region Fail Fast @@ -258,6 +246,8 @@ await API.StopwatchLogInfoAsync(ClassName, "Startup cost", async () => RegisterExitEvents(); + DeepLinkRegistration.EnsureRegistered(_settings.EnableDeepLinkProtocol); + AutoStartup(); AutoUpdates(); @@ -277,12 +267,11 @@ 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(); - // Apply the query passed via the --query command line argument now that plugins can answer it - if (!string.IsNullOrEmpty(_pendingQuery)) + // Dispatch the deep link passed on the command line now that plugins can answer it + if (!string.IsNullOrEmpty(_pendingDeepLink)) { - API.ChangeQuery(_pendingQuery, true); - API.ShowMainWindow(); - _pendingQuery = null; + DeepLink.Dispatch(_pendingDeepLink); + _pendingDeepLink = null; } // Refresh home page after plugins are initialized because users may open main window during plugin initialization @@ -488,15 +477,15 @@ public void Dispose() #region ISingleInstanceApp - public void OnSecondAppStarted(string query) + public void OnSecondAppStarted(string payload) { - API.ShowMainWindow(); - if (!string.IsNullOrEmpty(query)) + if (string.IsNullOrEmpty(payload)) { - // Make sure to go back to the query results page first since it can cause issues if current page is context menu - API.BackToQueryResults(); - API.ChangeQuery(query, true); + API.ShowMainWindow(); + return; } + + DeepLink.Dispatch(payload); } #endregion From 19a98fc7fe1c3e949fbbabd20967836e5021a90f Mon Sep 17 00:00:00 2001 From: Garulf <535299+Garulf@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:39:50 +0000 Subject: [PATCH 07/10] Add settings toggle for the flow-launcher:// URI scheme --- Flow.Launcher/Languages/en.xaml | 3 ++ .../SettingsPaneGeneralViewModel.cs | 28 +++++++++++++++++++ .../Views/SettingsPaneGeneral.xaml | 14 ++++++++++ 3 files changed, 45 insertions(+) diff --git a/Flow.Launcher/Languages/en.xaml b/Flow.Launcher/Languages/en.xaml index 3be2c7743be..b36ed73cacc 100644 --- a/Flow.Launcher/Languages/en.xaml +++ b/Flow.Launcher/Languages/en.xaml @@ -194,6 +194,9 @@ Restart Flow Launcher automatically after installing/uninstalling/updating plugin via Plugin Store Show unknown source warning Show warning when installing plugins from unknown sources + Allow websites to open Flow Launcher + Handle flow-launcher:// links from browsers and other applications. Plugin installs always ask for confirmation. + Error setting deep link protocol registration Auto update plugins Automatically check plugin updates and notify if there are any updates available diff --git a/Flow.Launcher/SettingPages/ViewModels/SettingsPaneGeneralViewModel.cs b/Flow.Launcher/SettingPages/ViewModels/SettingsPaneGeneralViewModel.cs index c91b0a43f24..ef251fb804a 100644 --- a/Flow.Launcher/SettingPages/ViewModels/SettingsPaneGeneralViewModel.cs +++ b/Flow.Launcher/SettingPages/ViewModels/SettingsPaneGeneralViewModel.cs @@ -17,6 +17,8 @@ namespace Flow.Launcher.SettingPages.ViewModels; public partial class SettingsPaneGeneralViewModel : BaseModel { + private static readonly string ClassName = nameof(SettingsPaneGeneralViewModel); + public Settings Settings { get; } private readonly Updater _updater; @@ -98,6 +100,32 @@ public bool UseLogonTaskForStartup } } + public bool EnableDeepLinkProtocol + { + get => Settings.EnableDeepLinkProtocol; + set + { + Settings.EnableDeepLinkProtocol = value; + + try + { + if (value) + { + DeepLinkRegistration.RegisterUriScheme(); + } + else + { + DeepLinkRegistration.UnregisterUriScheme(); + } + } + catch (Exception e) + { + App.API.LogError(ClassName, $"Failed to update deep link protocol registration: {e}"); + App.API.ShowMsgError(Localize.enableDeepLinkProtocolFailed(), e.Message); + } + } + } + public List SearchWindowScreens { get; } = DropdownDataGeneric.GetValues("SearchWindowScreen"); diff --git a/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml b/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml index 5779071534f..cab343770cc 100644 --- a/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml +++ b/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml @@ -299,6 +299,20 @@ OnContent="{DynamicResource enable}" /> + + + + + + + + Date: Tue, 7 Jul 2026 08:54:56 +0000 Subject: [PATCH 08/10] Address review findings: async pipe activation, manifest error handling, url filename derivation --- Flow.Launcher.Core/Plugin/PluginInstaller.cs | 7 ++++- Flow.Launcher/Helper/DeepLink.cs | 19 ++++++++++++-- Flow.Launcher/Helper/SingleInstance.cs | 27 ++++++++++---------- Flow.Launcher/Languages/en.xaml | 1 + 4 files changed, 38 insertions(+), 16 deletions(-) diff --git a/Flow.Launcher.Core/Plugin/PluginInstaller.cs b/Flow.Launcher.Core/Plugin/PluginInstaller.cs index 3802d6402b5..991176f760d 100644 --- a/Flow.Launcher.Core/Plugin/PluginInstaller.cs +++ b/Flow.Launcher.Core/Plugin/PluginInstaller.cs @@ -156,7 +156,12 @@ public static async Task InstallPluginAndCheckRestartAsync(string filePath) /// A Task representing the asynchronous install operation. public static async Task InstallPluginFromWebAndCheckRestartAsync(string url) { - var filename = url.Split('/').Last(); + // 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). + var filename = Uri.TryCreate(url, UriKind.Absolute, out var uri) + ? Path.GetFileName(uri.LocalPath) + : url.Split('/').Last(); var name = filename.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) ? filename[..^".zip".Length] : filename; diff --git a/Flow.Launcher/Helper/DeepLink.cs b/Flow.Launcher/Helper/DeepLink.cs index 9313619e30b..784b8e6e2a0 100644 --- a/Flow.Launcher/Helper/DeepLink.cs +++ b/Flow.Launcher/Helper/DeepLink.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using System.Web; using Flow.Launcher.Core.Plugin; +using Flow.Launcher.Infrastructure.Logger; namespace Flow.Launcher.Helper; @@ -44,7 +45,15 @@ public static string FromCommandLineArgs(string[] args, bool allowSchemeArgs) if (args[i].StartsWith(SchemePrefix, StringComparison.OrdinalIgnoreCase)) { - return allowSchemeArgs ? args[i] : null; + if (!allowSchemeArgs) + { + // This runs in Main before App/App.API exist, so we use the static infrastructure + // logger directly (same pattern as ErrorReporting.cs) instead of App.API.LogWarn. + Log.Warn(ClassName, $"Dropped raw deep link arg <{args[i]}> because the URI scheme protocol is disabled in settings."); + return null; + } + + return args[i]; } if (args[i].EndsWith(PluginFileExtension, StringComparison.OrdinalIgnoreCase)) @@ -172,7 +181,13 @@ private static void HandlePluginInstall(NameValueCollection parameters) private static async Task InstallByIdAsync(string id) { - await App.API.UpdatePluginManifestAsync(); + var manifestUpdated = await App.API.UpdatePluginManifestAsync(); + if (!manifestUpdated && App.API.GetPluginManifest().Count == 0) + { + App.API.ShowMsgError(Localize.deepLinkInstallInvalidTitle(), Localize.deepLinkManifestUpdateFailed()); + return; + } + var plugin = App.API.GetPluginManifest() .FirstOrDefault(x => string.Equals(x.ID, id, StringComparison.OrdinalIgnoreCase)); if (plugin == null) diff --git a/Flow.Launcher/Helper/SingleInstance.cs b/Flow.Launcher/Helper/SingleInstance.cs index 1c57bdb01b6..38d009e0d5b 100644 --- a/Flow.Launcher/Helper/SingleInstance.cs +++ b/Flow.Launcher/Helper/SingleInstance.cs @@ -12,7 +12,7 @@ namespace Flow.Launcher.Helper { public interface ISingleInstanceApp { - void OnSecondAppStarted(string query); + void OnSecondAppStarted(string payload); } /// @@ -73,7 +73,7 @@ public static bool InitializeAsFirstInstance(string args = null) { try { - // Block until the signal and query payload are delivered, + // Block until the signal and deep link payload are delivered, // because the second instance exits right after this returns SignalFirstInstanceAsync(channelName, args).Wait(TimeSpan.FromSeconds(3)); } @@ -110,7 +110,7 @@ private static async Task CreateRemoteServiceAsync(string channelName) // Wait for connection to the pipe await pipeServer.WaitForConnectionAsync(); - string query = null; + string payload = null; try { using var reader = new StreamReader(pipeServer, Encoding.UTF8, false, 1024, leaveOpen: true); @@ -118,7 +118,7 @@ private static async Task CreateRemoteServiceAsync(string channelName) // Guard against a client that connects but never writes or closes if (await Task.WhenAny(readTask, Task.Delay(2000)) == readTask) { - query = await readTask; // null when the client wrote nothing (plain activation) + payload = await readTask; // null when the client wrote nothing (plain activation) } } catch @@ -126,8 +126,9 @@ private static async Task CreateRemoteServiceAsync(string channelName) // Treat any pipe read failure as a plain activation; never let it kill the server loop } - // Do an asynchronous call to ActivateFirstInstance function - Application.Current?.Dispatcher.Invoke(() => ActivateFirstInstance(query)); + // Do an asynchronous call to ActivateFirstInstance function so a deep-link handler + // showing modal prompts cannot block this pipe accept loop and drop later activations + Application.Current?.Dispatcher.InvokeAsync(() => ActivateFirstInstance(payload)); // Disconect client pipeServer.Disconnect(); @@ -139,7 +140,7 @@ private static async Task CreateRemoteServiceAsync(string channelName) /// /// Application's IPC channel name. /// - /// Command line arguments for the second instance, passed to the first instance to take appropriate action. + /// The deep link payload from the second instance, passed to the first instance to take appropriate action. /// private static async Task SignalFirstInstanceAsync(string channelName, string args) { @@ -149,7 +150,7 @@ private static async Task SignalFirstInstanceAsync(string channelName, string ar // Connect to the available pipe await pipeClient.ConnectAsync(1000); - // Send the query to the first instance if there is one + // Send the deep link payload to the first instance if there is one if (!string.IsNullOrEmpty(args)) { using var writer = new StreamWriter(pipeClient, Encoding.UTF8) { AutoFlush = true }; @@ -158,18 +159,18 @@ private static async Task SignalFirstInstanceAsync(string channelName, string ar } /// - /// Activates the first instance of the application with arguments from a second instance. + /// Activates the first instance of the application with the deep link payload from a second instance. /// - /// List of arguments to supply the first instance of the application. - private static void ActivateFirstInstance(string args) + /// The deep link payload to supply the first instance of the application. + private static void ActivateFirstInstance(string payload) { - // Set main window state and process command line args + // Set main window state and process the deep link payload if (Application.Current == null) { return; } - ((TApplication)Application.Current).OnSecondAppStarted(args); + ((TApplication)Application.Current).OnSecondAppStarted(payload); } #endregion diff --git a/Flow.Launcher/Languages/en.xaml b/Flow.Launcher/Languages/en.xaml index b36ed73cacc..ca964f5dd5d 100644 --- a/Flow.Launcher/Languages/en.xaml +++ b/Flow.Launcher/Languages/en.xaml @@ -49,6 +49,7 @@ Plugin package not found: {0} No plugin with id "{0}" was found in the plugin store. Plugin install links must use an https download URL. + Could not load the plugin store manifest. Check your internet connection and try again. Could not start {0} Invalid Flow Launcher plugin file format Set as topmost in this query From da750b06602099a9d9a4c5b1fb506a5cd37a175b Mon Sep 17 00:00:00 2001 From: Garulf <535299+Garulf@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:33:18 +0000 Subject: [PATCH 09/10] Address second round of PR review findings - Rename -query to -q to match single-dash single-character convention (per review discussion) and treat an empty query value as no query - Cancel the pending pipe read on timeout instead of abandoning it, to avoid an unobserved task exception; widen the client connect timeout past the server's read guard so a stalled client can't starve a legitimate second launch; observe exceptions from the fire-and-forget dispatcher activation - Revert the deep link protocol setting if registry (un)registration fails, instead of persisting a value that disagrees with the OS state - Sanitize the filename derived from a plugin download URL so a percent-decoded path segment can't reintroduce characters that are invalid in Windows filenames - Stop logging full deep link payloads (query text, paths, URLs) at Warn level; keep only the actionable fact at Warn and move the raw payload to Debug --- Flow.Launcher.Core/Plugin/PluginInstaller.cs | 6 +++++ Flow.Launcher.Test/DeepLinkTest.cs | 9 +++++++- Flow.Launcher/Helper/DeepLink.cs | 17 ++++++++++---- Flow.Launcher/Helper/SingleInstance.cs | 23 +++++++++++-------- .../SettingsPaneGeneralViewModel.cs | 5 ++-- 5 files changed, 43 insertions(+), 17 deletions(-) diff --git a/Flow.Launcher.Core/Plugin/PluginInstaller.cs b/Flow.Launcher.Core/Plugin/PluginInstaller.cs index 991176f760d..59c76a1d64a 100644 --- a/Flow.Launcher.Core/Plugin/PluginInstaller.cs +++ b/Flow.Launcher.Core/Plugin/PluginInstaller.cs @@ -159,9 +159,15 @@ 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()) + { + filename = filename.Replace(c.ToString(), string.Empty); + } var name = filename.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) ? filename[..^".zip".Length] : filename; diff --git a/Flow.Launcher.Test/DeepLinkTest.cs b/Flow.Launcher.Test/DeepLinkTest.cs index e0be00fd722..bf4bc98dace 100644 --- a/Flow.Launcher.Test/DeepLinkTest.cs +++ b/Flow.Launcher.Test/DeepLinkTest.cs @@ -17,7 +17,7 @@ public void FromCommandLineArgs_QueryFlag_NormalizesToQueryUri() [Test] public void FromCommandLineArgs_SingleDashQueryFlag_NormalizesToQueryUri() { - var result = DeepLink.FromCommandLineArgs(new[] { "-query", "foo" }, true); + var result = DeepLink.FromCommandLineArgs(new[] { "-q", "foo" }, true); Assert.That(result, Is.EqualTo("flow-launcher://query?q=foo")); } @@ -59,6 +59,13 @@ public void FromCommandLineArgs_NoRelevantArgs_ReturnsNull() 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)] diff --git a/Flow.Launcher/Helper/DeepLink.cs b/Flow.Launcher/Helper/DeepLink.cs index 784b8e6e2a0..ca686ca6d07 100644 --- a/Flow.Launcher/Helper/DeepLink.cs +++ b/Flow.Launcher/Helper/DeepLink.cs @@ -32,14 +32,19 @@ public static class DeepLink /// /// Normalizes command line arguments to a single deep link URI string, or null for a normal launch. /// Raw flow-launcher:// arguments are dropped when is false - /// (URI scheme disabled in settings); --query and .flowplugin paths are always honored. + /// (URI scheme disabled in settings); --query/-q and .flowplugin paths are always honored. /// public static string FromCommandLineArgs(string[] args, bool allowSchemeArgs) { for (var i = 0; i < args.Length; i++) { - if ((args[i] == "--query" || args[i] == "-query") && i + 1 < args.Length) + if (args[i] == "--query" || args[i] == "-q") { + if (i + 1 >= args.Length || string.IsNullOrEmpty(args[i + 1])) + { + return null; + } + return $"{SchemePrefix}query?q={Uri.EscapeDataString(args[i + 1])}"; } @@ -49,7 +54,9 @@ public static string FromCommandLineArgs(string[] args, bool allowSchemeArgs) { // This runs in Main before App/App.API exist, so we use the static infrastructure // logger directly (same pattern as ErrorReporting.cs) instead of App.API.LogWarn. - Log.Warn(ClassName, $"Dropped raw deep link arg <{args[i]}> because the URI scheme protocol is disabled in settings."); + // Only the fact of the drop is logged at Warn; the full arg may carry user data. + Log.Warn(ClassName, "Dropped a raw deep link arg because the URI scheme protocol is disabled in settings."); + Log.Debug(ClassName, $"Dropped raw deep link arg <{args[i]}>."); return null; } @@ -116,7 +123,9 @@ public static void Dispatch(string payload) } else { - App.API.LogWarn(ClassName, $"Unrecognized deep link verb <{verb}> in <{payload}>"); + // Only the verb is logged at Warn; the full payload may carry user query text, paths, or URLs + App.API.LogWarn(ClassName, $"Unrecognized deep link verb <{verb}>"); + App.API.LogDebug(ClassName, $"Unrecognized deep link payload <{payload}>"); App.API.ShowMsgError(Localize.deepLinkUnrecognizedTitle(), Localize.deepLinkUnrecognizedSubtitle(payload)); } } diff --git a/Flow.Launcher/Helper/SingleInstance.cs b/Flow.Launcher/Helper/SingleInstance.cs index 38d009e0d5b..699cdc03a65 100644 --- a/Flow.Launcher/Helper/SingleInstance.cs +++ b/Flow.Launcher/Helper/SingleInstance.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using System.Windows; +using Flow.Launcher.Infrastructure.Logger; // http://blogs.microsoft.co.il/arik/2010/05/28/wpf-single-instance-application/ // modified to allow single instace restart @@ -113,22 +114,23 @@ private static async Task CreateRemoteServiceAsync(string channelName) string payload = null; try { + // Guard against a client that connects but never writes or closes; cancelling the + // read (rather than abandoning it) avoids it faulting later against the disposed reader + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); using var reader = new StreamReader(pipeServer, Encoding.UTF8, false, 1024, leaveOpen: true); - var readTask = reader.ReadLineAsync(); - // Guard against a client that connects but never writes or closes - if (await Task.WhenAny(readTask, Task.Delay(2000)) == readTask) - { - payload = await readTask; // null when the client wrote nothing (plain activation) - } + payload = await reader.ReadLineAsync(cts.Token); // null when the client wrote nothing (plain activation) } catch { - // Treat any pipe read failure as a plain activation; never let it kill the server loop + // Treat any pipe read failure/timeout as a plain activation; never let it kill the server loop } // Do an asynchronous call to ActivateFirstInstance function so a deep-link handler // showing modal prompts cannot block this pipe accept loop and drop later activations - Application.Current?.Dispatcher.InvokeAsync(() => ActivateFirstInstance(payload)); + var activation = Application.Current?.Dispatcher.InvokeAsync(() => ActivateFirstInstance(payload)); + activation?.Task.ContinueWith( + t => Log.Exception("SingleInstance", "Failed to activate first instance from second app", t.Exception), + TaskContinuationOptions.OnlyOnFaulted); // Disconect client pipeServer.Disconnect(); @@ -147,8 +149,9 @@ private static async Task SignalFirstInstanceAsync(string channelName, string ar // Create a client pipe connected to server using NamedPipeClientStream pipeClient = new NamedPipeClientStream(".", channelName, PipeDirection.Out); - // Connect to the available pipe - await pipeClient.ConnectAsync(1000); + // Connect to the available pipe. Longer than the server's 2s read timeout so a stalled + // prior client can't starve this connection attempt. + await pipeClient.ConnectAsync(3000); // Send the deep link payload to the first instance if there is one if (!string.IsNullOrEmpty(args)) diff --git a/Flow.Launcher/SettingPages/ViewModels/SettingsPaneGeneralViewModel.cs b/Flow.Launcher/SettingPages/ViewModels/SettingsPaneGeneralViewModel.cs index ef251fb804a..8495a642c44 100644 --- a/Flow.Launcher/SettingPages/ViewModels/SettingsPaneGeneralViewModel.cs +++ b/Flow.Launcher/SettingPages/ViewModels/SettingsPaneGeneralViewModel.cs @@ -105,8 +105,6 @@ public bool EnableDeepLinkProtocol get => Settings.EnableDeepLinkProtocol; set { - Settings.EnableDeepLinkProtocol = value; - try { if (value) @@ -117,11 +115,14 @@ public bool EnableDeepLinkProtocol { DeepLinkRegistration.UnregisterUriScheme(); } + + Settings.EnableDeepLinkProtocol = value; } catch (Exception e) { App.API.LogError(ClassName, $"Failed to update deep link protocol registration: {e}"); App.API.ShowMsgError(Localize.enableDeepLinkProtocolFailed(), e.Message); + OnPropertyChanged(); } } } From ab882e69fa7f0d3010c08dfca0a7b7a489e84bbe Mon Sep 17 00:00:00 2001 From: Garulf <535299+Garulf@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:11:54 +0000 Subject: [PATCH 10/10] Fix single-instance signal timeout budget and log genuine pipe read failures - Widen the outer signal-delivery wait from 3s to 5s so a slow ConnectAsync(3000ms) can no longer consume the entire budget and starve the subsequent payload write, silently dropping a deep link - Distinguish the expected read-timeout (client connected but never wrote) from a genuine pipe read failure; log the latter instead of swallowing it silently --- Flow.Launcher/Helper/SingleInstance.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Flow.Launcher/Helper/SingleInstance.cs b/Flow.Launcher/Helper/SingleInstance.cs index 699cdc03a65..d4855eaeade 100644 --- a/Flow.Launcher/Helper/SingleInstance.cs +++ b/Flow.Launcher/Helper/SingleInstance.cs @@ -75,8 +75,10 @@ public static bool InitializeAsFirstInstance(string args = null) try { // Block until the signal and deep link payload are delivered, - // because the second instance exits right after this returns - SignalFirstInstanceAsync(channelName, args).Wait(TimeSpan.FromSeconds(3)); + // because the second instance exits right after this returns. + // Budget beyond the 3s connect timeout below so a slow connect still + // leaves room for the write to complete. + SignalFirstInstanceAsync(channelName, args).Wait(TimeSpan.FromSeconds(5)); } catch { @@ -120,9 +122,14 @@ private static async Task CreateRemoteServiceAsync(string channelName) using var reader = new StreamReader(pipeServer, Encoding.UTF8, false, 1024, leaveOpen: true); payload = await reader.ReadLineAsync(cts.Token); // null when the client wrote nothing (plain activation) } - catch + catch (OperationCanceledException) + { + // Client connected but never wrote/closed before the timeout; treat as a plain activation + } + catch (Exception e) { - // Treat any pipe read failure/timeout as a plain activation; never let it kill the server loop + // Never let a genuine pipe read failure kill the server loop, but still surface it + Log.Exception("SingleInstance", "Failed to read deep link payload from second instance", e); } // Do an asynchronous call to ActivateFirstInstance function so a deep-link handler