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}" /> + + + + + + + +