diff --git a/Flow.Launcher.Core/Plugin/PluginInstaller.cs b/Flow.Launcher.Core/Plugin/PluginInstaller.cs
index 6027b712e73..59c76a1d64a 100644
--- a/Flow.Launcher.Core/Plugin/PluginInstaller.cs
+++ b/Flow.Launcher.Core/Plugin/PluginInstaller.cs
@@ -148,6 +148,51 @@ 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)
+ {
+ // 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;
+
+ 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.Infrastructure/UserSettings/Settings.cs b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs
index 62651a1ef66..6cfad67b7f7 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.Test/DeepLinkTest.cs b/Flow.Launcher.Test/DeepLinkTest.cs
new file mode 100644
index 00000000000..bf4bc98dace
--- /dev/null
+++ b/Flow.Launcher.Test/DeepLinkTest.cs
@@ -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(), 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);
+ }
+ }
+}
diff --git a/Flow.Launcher/App.xaml.cs b/Flow.Launcher/App.xaml.cs
index da11380b861..9eb2d9a2b2c 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 _pendingDeepLink;
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,8 +152,10 @@ public static void Main()
}
// Start the application as a single instance
- if (SingleInstance.InitializeAsFirstInstance())
+ var deepLink = DeepLink.FromCommandLineArgs(args, _settings.EnableDeepLinkProtocol);
+ if (SingleInstance.InitializeAsFirstInstance(deepLink))
{
+ _pendingDeepLink = deepLink;
using var application = new App();
application.InitializeComponent();
application.Run();
@@ -243,6 +246,8 @@ await API.StopwatchLogInfoAsync(ClassName, "Startup cost", async () =>
RegisterExitEvents();
+ DeepLinkRegistration.EnsureRegistered(_settings.EnableDeepLinkProtocol);
+
AutoStartup();
AutoUpdates();
@@ -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))
@@ -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
diff --git a/Flow.Launcher/Helper/DeepLink.cs b/Flow.Launcher/Helper/DeepLink.cs
new file mode 100644
index 00000000000..ca686ca6d07
--- /dev/null
+++ b/Flow.Launcher/Helper/DeepLink.cs
@@ -0,0 +1,210 @@
+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;
+using Flow.Launcher.Infrastructure.Logger;
+
+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";
+
+ 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
+ /// (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] == "-q")
+ {
+ if (i + 1 >= args.Length || string.IsNullOrEmpty(args[i + 1]))
+ {
+ return null;
+ }
+
+ return $"{SchemePrefix}query?q={Uri.EscapeDataString(args[i + 1])}";
+ }
+
+ if (args[i].StartsWith(SchemePrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ 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.
+ // 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;
+ }
+
+ return args[i];
+ }
+
+ 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;
+ }
+
+ ///
+ /// 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
+ {
+ // 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));
+ }
+ }
+
+ 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)
+ {
+ 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)
+ {
+ App.API.ShowMsgError(Localize.deepLinkInstallInvalidTitle(), Localize.deepLinkInstallPluginNotFound(id));
+ return;
+ }
+
+ await PluginInstaller.InstallPluginAndCheckRestartAsync(plugin);
+ }
+}
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);
+ }
+}
diff --git a/Flow.Launcher/Helper/SingleInstance.cs b/Flow.Launcher/Helper/SingleInstance.cs
index de2579b6290..d4855eaeade 100644
--- a/Flow.Launcher/Helper/SingleInstance.cs
+++ b/Flow.Launcher/Helper/SingleInstance.cs
@@ -1,8 +1,11 @@
using System;
+using System.IO;
using System.IO.Pipes;
+using System.Text;
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
@@ -10,7 +13,7 @@ namespace Flow.Launcher.Helper
{
public interface ISingleInstanceApp
{
- void OnSecondAppStarted();
+ void OnSecondAppStarted(string payload);
}
///
@@ -53,7 +56,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 +72,18 @@ public static bool InitializeAsFirstInstance()
}
else
{
- _ = SignalFirstInstanceAsync(channelName);
+ try
+ {
+ // Block until the signal and deep link payload are delivered,
+ // 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
+ {
+ // If the first instance cannot be reached there is nothing more to do
+ }
return false;
}
}
@@ -99,8 +113,31 @@ private static async Task CreateRemoteServiceAsync(string channelName)
// Wait for connection to the pipe
await pipeServer.WaitForConnectionAsync();
- // Do an asynchronous call to ActivateFirstInstance function
- Application.Current?.Dispatcher.Invoke(ActivateFirstInstance);
+ 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);
+ payload = await reader.ReadLineAsync(cts.Token); // null when the client wrote nothing (plain activation)
+ }
+ catch (OperationCanceledException)
+ {
+ // Client connected but never wrote/closed before the timeout; treat as a plain activation
+ }
+ catch (Exception e)
+ {
+ // 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
+ // showing modal prompts cannot block this pipe accept loop and drop later activations
+ 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();
@@ -112,30 +149,38 @@ 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)
+ 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);
+ // 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))
+ {
+ 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.
+ /// 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()
+ /// 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();
+ ((TApplication)Application.Current).OnSecondAppStarted(payload);
}
#endregion
diff --git a/Flow.Launcher/Languages/en.xaml b/Flow.Launcher/Languages/en.xaml
index cf67bffeafa..ca964f5dd5d 100644
--- a/Flow.Launcher/Languages/en.xaml
+++ b/Flow.Launcher/Languages/en.xaml
@@ -42,6 +42,14 @@
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 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
@@ -187,6 +195,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
@@ -278,6 +289,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
diff --git a/Flow.Launcher/SettingPages/ViewModels/SettingsPaneGeneralViewModel.cs b/Flow.Launcher/SettingPages/ViewModels/SettingsPaneGeneralViewModel.cs
index c91b0a43f24..8495a642c44 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,33 @@ public bool UseLogonTaskForStartup
}
}
+ public bool EnableDeepLinkProtocol
+ {
+ get => Settings.EnableDeepLinkProtocol;
+ set
+ {
+ try
+ {
+ if (value)
+ {
+ DeepLinkRegistration.RegisterUriScheme();
+ }
+ else
+ {
+ 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();
+ }
+ }
+ }
+
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}" />
+
+
+
+
+
+
+
+