From be630f1ab5d6429ace26062e05d799dc2b731eeb Mon Sep 17 00:00:00 2001 From: VictoriousRaptor <10308169+VictoriousRaptor@users.noreply.github.com> Date: Sun, 28 Dec 2025 10:43:38 +0800 Subject: [PATCH 01/14] Update UrlPattern to include private addresses and update test cases --- Flow.Launcher.Test/Plugins/UrlPluginTest.cs | 27 +++++++++ Plugins/Flow.Launcher.Plugin.Url/Main.cs | 65 +++++++++++---------- 2 files changed, 60 insertions(+), 32 deletions(-) diff --git a/Flow.Launcher.Test/Plugins/UrlPluginTest.cs b/Flow.Launcher.Test/Plugins/UrlPluginTest.cs index 0dd1fe4895a..b6d3bea0bfd 100644 --- a/Flow.Launcher.Test/Plugins/UrlPluginTest.cs +++ b/Flow.Launcher.Test/Plugins/UrlPluginTest.cs @@ -14,20 +14,47 @@ public void URLMatchTest() ClassicAssert.IsTrue(plugin.IsURL("http://www.google.com")); ClassicAssert.IsTrue(plugin.IsURL("https://www.google.com")); ClassicAssert.IsTrue(plugin.IsURL("http://google.com")); + ClassicAssert.IsTrue(plugin.IsURL("ftp://google.com")); ClassicAssert.IsTrue(plugin.IsURL("www.google.com")); ClassicAssert.IsTrue(plugin.IsURL("google.com")); ClassicAssert.IsTrue(plugin.IsURL("http://localhost")); ClassicAssert.IsTrue(plugin.IsURL("https://localhost")); ClassicAssert.IsTrue(plugin.IsURL("http://localhost:80")); ClassicAssert.IsTrue(plugin.IsURL("https://localhost:80")); + ClassicAssert.IsTrue(plugin.IsURL("localhost")); + ClassicAssert.IsTrue(plugin.IsURL("localhost:8080")); ClassicAssert.IsTrue(plugin.IsURL("http://110.10.10.10")); ClassicAssert.IsTrue(plugin.IsURL("110.10.10.10")); + ClassicAssert.IsTrue(plugin.IsURL("110.10.10.10:8080")); + ClassicAssert.IsTrue(plugin.IsURL("192.168.1.1")); + ClassicAssert.IsTrue(plugin.IsURL("192.168.1.1:3000")); ClassicAssert.IsTrue(plugin.IsURL("ftp://110.10.10.10")); + ClassicAssert.IsTrue(plugin.IsURL("[2001:db8::1]")); + ClassicAssert.IsTrue(plugin.IsURL("[2001:db8::1]:8080")); + ClassicAssert.IsTrue(plugin.IsURL("http://[2001:db8::1]")); + ClassicAssert.IsTrue(plugin.IsURL("https://[2001:db8::1]:8080")); + ClassicAssert.IsTrue(plugin.IsURL("[::1]")); + ClassicAssert.IsTrue(plugin.IsURL("[::1]:8080")); + ClassicAssert.IsTrue(plugin.IsURL("2001:db8::1")); + ClassicAssert.IsTrue(plugin.IsURL("::1")); + ClassicAssert.IsTrue(plugin.IsURL("HTTP://EXAMPLE.COM")); + ClassicAssert.IsTrue(plugin.IsURL("HTTPS://EXAMPLE.COM")); + ClassicAssert.IsTrue(plugin.IsURL("EXAMPLE.COM")); + ClassicAssert.IsTrue(plugin.IsURL("LOCALHOST")); ClassicAssert.IsFalse(plugin.IsURL("wwww")); ClassicAssert.IsFalse(plugin.IsURL("wwww.c")); ClassicAssert.IsFalse(plugin.IsURL("wwww.c")); + ClassicAssert.IsFalse(plugin.IsURL("not a url")); + ClassicAssert.IsFalse(plugin.IsURL("just text")); + ClassicAssert.IsFalse(plugin.IsURL("http://")); + ClassicAssert.IsFalse(plugin.IsURL("://example.com")); + ClassicAssert.IsFalse(plugin.IsURL("0.0.0.0")); // Pattern excludes 0.0.0.0 + ClassicAssert.IsFalse(plugin.IsURL("256.1.1.1")); // Invalid IPv4 + ClassicAssert.IsFalse(plugin.IsURL("example")); // No TLD + ClassicAssert.IsFalse(plugin.IsURL(".com")); + ClassicAssert.IsFalse(plugin.IsURL("http://.com")); } } } diff --git a/Plugins/Flow.Launcher.Plugin.Url/Main.cs b/Plugins/Flow.Launcher.Plugin.Url/Main.cs index c40b278e596..943095cf8d7 100644 --- a/Plugins/Flow.Launcher.Plugin.Url/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Url/Main.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Collections.Generic; +using System.Linq; using System.Text.RegularExpressions; using System.Windows.Controls; using Flow.Launcher.Plugin.SharedCommands; @@ -15,19 +16,26 @@ public class Main : IPlugin, IPluginI18n, ISettingProvider // user:pass authentication "(?:\\S+(?::\\S*)?@)?" + "(?:" + - // IP address exclusion - // private & local networks - "(?!(?:10|127)(?:\\.\\d{1,3}){3})" + - "(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})" + - "(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})" + - // IP address dotted notation octets - // excludes loopback network 0.0.0.0 - // excludes reserved space >= 224.0.0.0 - // excludes network & broacast addresses - // (first & last IP address of each class) - "(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])" + - "(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}" + - "(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))" + + // IPv6 address with optional brackets (brackets required if followed by port) + // IPv6 with brackets + "(?:\\[(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\\]|" + // standard IPv6 + "\\[(?:[0-9a-fA-F]{1,4}:){1,7}:\\]|" + // IPv6 with trailing :: + "\\[(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}\\]|" + // IPv6 compressed + "\\[::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}\\]|" + // IPv6 with leading :: + "\\[::1\\])" + // IPv6 loopback + "|" + + // IPv6 without brackets (only when no port follows) + "(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|" + // standard IPv6 + "(?:[0-9a-fA-F]{1,4}:){1,7}:|" + // IPv6 with trailing :: + "(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|" + // IPv6 compressed + "::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}|" + // IPv6 with leading :: + "::1)(?!:[0-9])" + // IPv6 loopback (not followed by port) + "|" + + // IPv4 address - all valid addresses including private networks (excluding 0.0.0.0) + "(?:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|[1-9])\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d))" + + "|" + + // localhost + "localhost" + "|" + // host name "(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)" + @@ -37,7 +45,7 @@ public class Main : IPlugin, IPluginI18n, ISettingProvider "(?:\\.(?:[a-z\\u00a1-\\uffff]{2,}))" + ")" + // port number - "(?::\\d{2,5})?" + + "(?::\\d{1,5})?" + // resource path "(?:/\\S*)?" + "$"; @@ -45,12 +53,17 @@ public class Main : IPlugin, IPluginI18n, ISettingProvider internal static PluginInitContext Context { get; private set; } internal static Settings Settings { get; private set; } + private static readonly string[] UrlSchemes = ["http://", "https://", "ftp://"]; + public List Query(Query query) { var raw = query.Search; - if (IsURL(raw)) + if (!IsURL(raw)) { - return + return []; + } + + return [ new() { @@ -60,7 +73,8 @@ public List Query(Query query) Score = 8, Action = _ => { - if (!raw.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !raw.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + // not a recognized scheme, add preferred http scheme + if (!UrlSchemes.Any(scheme => raw.StartsWith(scheme, StringComparison.OrdinalIgnoreCase))) { raw = GetHttpPreference() + "://" + raw; } @@ -92,9 +106,6 @@ public List Query(Query query) } } ]; - } - - return []; } private static string GetHttpPreference() @@ -104,17 +115,7 @@ private static string GetHttpPreference() public bool IsURL(string raw) { - raw = raw.ToLower(); - - if (UrlRegex.Match(raw).Value == raw) return true; - - if (raw == "localhost" || raw.StartsWith("localhost:") || - raw == "http://localhost" || raw.StartsWith("http://localhost:") || - raw == "https://localhost" || raw.StartsWith("https://localhost:") - ) - { - return true; - } + if (UrlRegex.Match(raw.ToLower()).Value == raw) return true; return false; } From e9a68d25637315ae79ebf75c57e5d2dec84c7593 Mon Sep 17 00:00:00 2001 From: VictoriousRaptor <10308169+VictoriousRaptor@users.noreply.github.com> Date: Sun, 28 Dec 2025 10:56:27 +0800 Subject: [PATCH 02/14] Fix match logic --- Plugins/Flow.Launcher.Plugin.Url/Main.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Plugins/Flow.Launcher.Plugin.Url/Main.cs b/Plugins/Flow.Launcher.Plugin.Url/Main.cs index 943095cf8d7..f6d5e3fcfb4 100644 --- a/Plugins/Flow.Launcher.Plugin.Url/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Url/Main.cs @@ -115,7 +115,9 @@ private static string GetHttpPreference() public bool IsURL(string raw) { - if (UrlRegex.Match(raw.ToLower()).Value == raw) return true; + raw = raw.ToLower(); + + if (UrlRegex.Match(raw).Value == raw) return true; return false; } From d0a274a668d9e22cc15fbbf49b4c7ef6c1a10196 Mon Sep 17 00:00:00 2001 From: VictoriousRaptor <10308169+VictoriousRaptor@users.noreply.github.com> Date: Sun, 28 Dec 2025 13:52:01 +0800 Subject: [PATCH 03/14] Add test case --- Flow.Launcher.Test/Plugins/UrlPluginTest.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Flow.Launcher.Test/Plugins/UrlPluginTest.cs b/Flow.Launcher.Test/Plugins/UrlPluginTest.cs index b6d3bea0bfd..36844453926 100644 --- a/Flow.Launcher.Test/Plugins/UrlPluginTest.cs +++ b/Flow.Launcher.Test/Plugins/UrlPluginTest.cs @@ -36,6 +36,7 @@ public void URLMatchTest() ClassicAssert.IsTrue(plugin.IsURL("[::1]")); ClassicAssert.IsTrue(plugin.IsURL("[::1]:8080")); ClassicAssert.IsTrue(plugin.IsURL("2001:db8::1")); + ClassicAssert.IsTrue(plugin.IsURL("fe80:1:2::3:4")); ClassicAssert.IsTrue(plugin.IsURL("::1")); ClassicAssert.IsTrue(plugin.IsURL("HTTP://EXAMPLE.COM")); ClassicAssert.IsTrue(plugin.IsURL("HTTPS://EXAMPLE.COM")); From 77f81cfb16925c8143ce8d3d27083d3e22752885 Mon Sep 17 00:00:00 2001 From: VictoriousRaptor <10308169+VictoriousRaptor@users.noreply.github.com> Date: Sun, 28 Dec 2025 22:04:37 +0800 Subject: [PATCH 04/14] Fix matching pattern for IPv6 addresses with consecutive ":" --- Plugins/Flow.Launcher.Plugin.Url/Main.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Plugins/Flow.Launcher.Plugin.Url/Main.cs b/Plugins/Flow.Launcher.Plugin.Url/Main.cs index f6d5e3fcfb4..2833a8d32f9 100644 --- a/Plugins/Flow.Launcher.Plugin.Url/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Url/Main.cs @@ -22,6 +22,7 @@ public class Main : IPlugin, IPluginI18n, ISettingProvider "\\[(?:[0-9a-fA-F]{1,4}:){1,7}:\\]|" + // IPv6 with trailing :: "\\[(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}\\]|" + // IPv6 compressed "\\[::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}\\]|" + // IPv6 with leading :: + "\\[(?:(?:[0-9a-fA-F]{1,4}:){1,6}|:):(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}\\]|" + // IPv6 with :: in the middle "\\[::1\\])" + // IPv6 loopback "|" + // IPv6 without brackets (only when no port follows) @@ -29,6 +30,7 @@ public class Main : IPlugin, IPluginI18n, ISettingProvider "(?:[0-9a-fA-F]{1,4}:){1,7}:|" + // IPv6 with trailing :: "(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|" + // IPv6 compressed "::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}|" + // IPv6 with leading :: + "(?:(?:[0-9a-fA-F]{1,4}:){1,6}|:):(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}|" + // IPv6 with :: in the middle "::1)(?!:[0-9])" + // IPv6 loopback (not followed by port) "|" + // IPv4 address - all valid addresses including private networks (excluding 0.0.0.0) From a8e0d65e39f3a062f51d2cb4224bd7ec998c22cc Mon Sep 17 00:00:00 2001 From: VictoriousRaptor <10308169+VictoriousRaptor@users.noreply.github.com> Date: Mon, 29 Dec 2025 22:06:05 +0800 Subject: [PATCH 05/14] Refactor URL plugin test --- Flow.Launcher.Test/Plugins/UrlPluginTest.cs | 106 +++++++++++--------- 1 file changed, 59 insertions(+), 47 deletions(-) diff --git a/Flow.Launcher.Test/Plugins/UrlPluginTest.cs b/Flow.Launcher.Test/Plugins/UrlPluginTest.cs index 36844453926..43cf6281d5b 100644 --- a/Flow.Launcher.Test/Plugins/UrlPluginTest.cs +++ b/Flow.Launcher.Test/Plugins/UrlPluginTest.cs @@ -1,61 +1,73 @@ using NUnit.Framework; -using NUnit.Framework.Legacy; using Flow.Launcher.Plugin.Url; +using System.Reflection; namespace Flow.Launcher.Test.Plugins { [TestFixture] public class UrlPluginTest { - [Test] - public void URLMatchTest() + private static Main plugin; + + [OneTimeSetUp] + public void OneTimeSetup() { - var plugin = new Main(); - ClassicAssert.IsTrue(plugin.IsURL("http://www.google.com")); - ClassicAssert.IsTrue(plugin.IsURL("https://www.google.com")); - ClassicAssert.IsTrue(plugin.IsURL("http://google.com")); - ClassicAssert.IsTrue(plugin.IsURL("ftp://google.com")); - ClassicAssert.IsTrue(plugin.IsURL("www.google.com")); - ClassicAssert.IsTrue(plugin.IsURL("google.com")); - ClassicAssert.IsTrue(plugin.IsURL("http://localhost")); - ClassicAssert.IsTrue(plugin.IsURL("https://localhost")); - ClassicAssert.IsTrue(plugin.IsURL("http://localhost:80")); - ClassicAssert.IsTrue(plugin.IsURL("https://localhost:80")); - ClassicAssert.IsTrue(plugin.IsURL("localhost")); - ClassicAssert.IsTrue(plugin.IsURL("localhost:8080")); - ClassicAssert.IsTrue(plugin.IsURL("http://110.10.10.10")); - ClassicAssert.IsTrue(plugin.IsURL("110.10.10.10")); - ClassicAssert.IsTrue(plugin.IsURL("110.10.10.10:8080")); - ClassicAssert.IsTrue(plugin.IsURL("192.168.1.1")); - ClassicAssert.IsTrue(plugin.IsURL("192.168.1.1:3000")); - ClassicAssert.IsTrue(plugin.IsURL("ftp://110.10.10.10")); - ClassicAssert.IsTrue(plugin.IsURL("[2001:db8::1]")); - ClassicAssert.IsTrue(plugin.IsURL("[2001:db8::1]:8080")); - ClassicAssert.IsTrue(plugin.IsURL("http://[2001:db8::1]")); - ClassicAssert.IsTrue(plugin.IsURL("https://[2001:db8::1]:8080")); - ClassicAssert.IsTrue(plugin.IsURL("[::1]")); - ClassicAssert.IsTrue(plugin.IsURL("[::1]:8080")); - ClassicAssert.IsTrue(plugin.IsURL("2001:db8::1")); - ClassicAssert.IsTrue(plugin.IsURL("fe80:1:2::3:4")); - ClassicAssert.IsTrue(plugin.IsURL("::1")); - ClassicAssert.IsTrue(plugin.IsURL("HTTP://EXAMPLE.COM")); - ClassicAssert.IsTrue(plugin.IsURL("HTTPS://EXAMPLE.COM")); - ClassicAssert.IsTrue(plugin.IsURL("EXAMPLE.COM")); - ClassicAssert.IsTrue(plugin.IsURL("LOCALHOST")); + var settingsField = typeof(Main).GetField("Settings", BindingFlags.NonPublic | BindingFlags.Static); + settingsField?.SetValue(null, new Settings()); + + plugin = new Main(); + } + [TestCase("http://www.google.com")] + [TestCase("https://www.google.com")] + [TestCase("http://google.com")] + [TestCase("ftp://google.com")] + [TestCase("www.google.com")] + [TestCase("google.com")] + [TestCase("http://localhost")] + [TestCase("https://localhost")] + [TestCase("http://localhost:80")] + [TestCase("https://localhost:80")] + [TestCase("localhost")] + [TestCase("localhost:8080")] + [TestCase("http://110.10.10.10")] + [TestCase("110.10.10.10")] + [TestCase("110.10.10.10:8080")] + [TestCase("192.168.1.1")] + [TestCase("192.168.1.1:3000")] + [TestCase("ftp://110.10.10.10")] + [TestCase("[2001:db8::1]")] + [TestCase("[2001:db8::1]:8080")] + [TestCase("http://[2001:db8::1]")] + [TestCase("https://[2001:db8::1]:8080")] + [TestCase("[::1]")] + [TestCase("[::1]:8080")] + [TestCase("2001:db8::1")] + [TestCase("fe80:1:2::3:4")] + [TestCase("::1")] + [TestCase("HTTP://EXAMPLE.COM")] + [TestCase("HTTPS://EXAMPLE.COM")] + [TestCase("EXAMPLE.COM")] + [TestCase("LOCALHOST")] + public void WhenValidUrlThenIsUrlReturnsTrue(string url) + { + Assert.That(plugin.IsURL(url), Is.True); + } - ClassicAssert.IsFalse(plugin.IsURL("wwww")); - ClassicAssert.IsFalse(plugin.IsURL("wwww.c")); - ClassicAssert.IsFalse(plugin.IsURL("wwww.c")); - ClassicAssert.IsFalse(plugin.IsURL("not a url")); - ClassicAssert.IsFalse(plugin.IsURL("just text")); - ClassicAssert.IsFalse(plugin.IsURL("http://")); - ClassicAssert.IsFalse(plugin.IsURL("://example.com")); - ClassicAssert.IsFalse(plugin.IsURL("0.0.0.0")); // Pattern excludes 0.0.0.0 - ClassicAssert.IsFalse(plugin.IsURL("256.1.1.1")); // Invalid IPv4 - ClassicAssert.IsFalse(plugin.IsURL("example")); // No TLD - ClassicAssert.IsFalse(plugin.IsURL(".com")); - ClassicAssert.IsFalse(plugin.IsURL("http://.com")); + [TestCase("wwww")] + [TestCase("wwww.c")] + [TestCase("not a url")] + [TestCase("just text")] + [TestCase("http://")] + [TestCase("://example.com")] + [TestCase("0.0.0.0")] // Pattern excludes 0.0.0.0 + [TestCase("256.1.1.1")] // Invalid IPv4 + [TestCase("example")] // No TLD + [TestCase(".com")] + [TestCase("http://.com")] + public void WhenInvalidUrlThenIsUrlReturnsFalse(string url) + { + Assert.That(plugin.IsURL(url), Is.False); } } } From 187275554002501e446041a0204608cccc37a365 Mon Sep 17 00:00:00 2001 From: VictoriousRaptor <10308169+VictoriousRaptor@users.noreply.github.com> Date: Thu, 26 Feb 2026 00:40:00 +0800 Subject: [PATCH 06/14] Refactor URL match logic --- Flow.Launcher.Test/Plugins/UrlPluginTest.cs | 4 +- Plugins/Flow.Launcher.Plugin.Url/Main.cs | 108 +++++++++++--------- 2 files changed, 63 insertions(+), 49 deletions(-) diff --git a/Flow.Launcher.Test/Plugins/UrlPluginTest.cs b/Flow.Launcher.Test/Plugins/UrlPluginTest.cs index 43cf6281d5b..9b8953361d5 100644 --- a/Flow.Launcher.Test/Plugins/UrlPluginTest.cs +++ b/Flow.Launcher.Test/Plugins/UrlPluginTest.cs @@ -12,8 +12,8 @@ public class UrlPluginTest [OneTimeSetUp] public void OneTimeSetup() { - var settingsField = typeof(Main).GetField("Settings", BindingFlags.NonPublic | BindingFlags.Static); - settingsField?.SetValue(null, new Settings()); + var settingsProperty = typeof(Main).GetProperty("Settings", BindingFlags.NonPublic | BindingFlags.Static); + settingsProperty?.SetValue(null, new Settings()); plugin = new Main(); } diff --git a/Plugins/Flow.Launcher.Plugin.Url/Main.cs b/Plugins/Flow.Launcher.Plugin.Url/Main.cs index 2833a8d32f9..9d46e2d3529 100644 --- a/Plugins/Flow.Launcher.Plugin.Url/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Url/Main.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text.RegularExpressions; +using System.Net; using System.Windows.Controls; using Flow.Launcher.Plugin.SharedCommands; @@ -9,49 +9,6 @@ namespace Flow.Launcher.Plugin.Url { public class Main : IPlugin, IPluginI18n, ISettingProvider { - //based on https://gist.github.com/dperini/729294 - private const string UrlPattern = "^" + - // protocol identifier - "(?:(?:https?|ftp)://|)" + - // user:pass authentication - "(?:\\S+(?::\\S*)?@)?" + - "(?:" + - // IPv6 address with optional brackets (brackets required if followed by port) - // IPv6 with brackets - "(?:\\[(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\\]|" + // standard IPv6 - "\\[(?:[0-9a-fA-F]{1,4}:){1,7}:\\]|" + // IPv6 with trailing :: - "\\[(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}\\]|" + // IPv6 compressed - "\\[::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}\\]|" + // IPv6 with leading :: - "\\[(?:(?:[0-9a-fA-F]{1,4}:){1,6}|:):(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}\\]|" + // IPv6 with :: in the middle - "\\[::1\\])" + // IPv6 loopback - "|" + - // IPv6 without brackets (only when no port follows) - "(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|" + // standard IPv6 - "(?:[0-9a-fA-F]{1,4}:){1,7}:|" + // IPv6 with trailing :: - "(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|" + // IPv6 compressed - "::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}|" + // IPv6 with leading :: - "(?:(?:[0-9a-fA-F]{1,4}:){1,6}|:):(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}|" + // IPv6 with :: in the middle - "::1)(?!:[0-9])" + // IPv6 loopback (not followed by port) - "|" + - // IPv4 address - all valid addresses including private networks (excluding 0.0.0.0) - "(?:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|[1-9])\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d))" + - "|" + - // localhost - "localhost" + - "|" + - // host name - "(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)" + - // domain name - "(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*" + - // TLD identifier - "(?:\\.(?:[a-z\\u00a1-\\uffff]{2,}))" + - ")" + - // port number - "(?::\\d{1,5})?" + - // resource path - "(?:/\\S*)?" + - "$"; - private readonly Regex UrlRegex = new(UrlPattern, RegexOptions.Compiled | RegexOptions.IgnoreCase); internal static PluginInitContext Context { get; private set; } internal static Settings Settings { get; private set; } @@ -117,11 +74,68 @@ private static string GetHttpPreference() public bool IsURL(string raw) { - raw = raw.ToLower(); + if (string.IsNullOrWhiteSpace(raw)) + return false; - if (UrlRegex.Match(raw).Value == raw) return true; + var input = raw.Trim(); - return false; + // Exclude numbers (e.g. 1.2345) + if (decimal.TryParse(input, out _)) + return false; + + // Check if it's a bare IP address (without protocol) + var inputHost = Uri.TryCreate(input, UriKind.Absolute, out var tempUri) ? tempUri.Host : input.Split(['/', ':'])[0].Trim('[', ']'); + if (IPAddress.TryParse(inputHost, out var ip)) + { + // Exclude invalid address 0.0.0.0 + if (ip.Equals(IPAddress.Any)) + return false; + + return true; + } + + // Check if it's a bare IPv6 address (contains multiple colons but no protocol) + if (input.Count(c => c == ':') > 1 && !input.Contains("://")) + { + var ipv6Part = input.Split('/')[0].Trim('[', ']'); + if (IPAddress.TryParse(ipv6Part, out _)) + return true; + } + + // Validate using Uri after adding protocol + var urlToValidate = input; + if (!UrlSchemes.Any(s => input.StartsWith(s, StringComparison.OrdinalIgnoreCase))) + { + urlToValidate = GetHttpPreference() + "://" + input; + } + + if (!Uri.TryCreate(urlToValidate, UriKind.Absolute, out var uri)) + return false; + + // Validate protocol + if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeFtp) + return false; + + // Validate host: must contain a dot (domain), be localhost, or be a valid IP + var host = uri.Host; + if (host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) + return true; + + if (IPAddress.TryParse(host, out var hostIp)) + return !hostIp.Equals(IPAddress.Any); + + // Domain must contain at least one dot, and dot cannot be at the start or end + if (!host.Contains('.')) + return false; + + // Ensure valid domain format (not starting or ending with dot, TLD at least 2 characters) + var parts = host.Split('.'); + if (parts.Length < 2 || parts.Any(string.IsNullOrEmpty)) + return false; + + // TLD must be at least 2 characters + var tld = parts[^1]; + return tld.Length >= 2 && tld.All(char.IsLetter); } public void Init(PluginInitContext context) From 4942eab9ef029a60242650ce8d2681ab2442f514 Mon Sep 17 00:00:00 2001 From: VictoriousRaptor <10308169+VictoriousRaptor@users.noreply.github.com> Date: Thu, 26 Feb 2026 00:57:16 +0800 Subject: [PATCH 07/14] Simplify parsing logic --- Plugins/Flow.Launcher.Plugin.Url/Main.cs | 42 +++++++----------------- 1 file changed, 12 insertions(+), 30 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.Url/Main.cs b/Plugins/Flow.Launcher.Plugin.Url/Main.cs index 9d46e2d3529..326bb4a30c5 100644 --- a/Plugins/Flow.Launcher.Plugin.Url/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Url/Main.cs @@ -83,31 +83,15 @@ public bool IsURL(string raw) if (decimal.TryParse(input, out _)) return false; - // Check if it's a bare IP address (without protocol) - var inputHost = Uri.TryCreate(input, UriKind.Absolute, out var tempUri) ? tempUri.Host : input.Split(['/', ':'])[0].Trim('[', ']'); - if (IPAddress.TryParse(inputHost, out var ip)) - { - // Exclude invalid address 0.0.0.0 - if (ip.Equals(IPAddress.Any)) - return false; - + // Check if it's a bare IP address with optional port and path + var ipPart = input.Split('/')[0]; // Remove path + if (IPEndPoint.TryParse(ipPart, out var endpoint) && !endpoint.Address.Equals(IPAddress.Any)) return true; - } - // Check if it's a bare IPv6 address (contains multiple colons but no protocol) - if (input.Count(c => c == ':') > 1 && !input.Contains("://")) - { - var ipv6Part = input.Split('/')[0].Trim('[', ']'); - if (IPAddress.TryParse(ipv6Part, out _)) - return true; - } - - // Validate using Uri after adding protocol - var urlToValidate = input; - if (!UrlSchemes.Any(s => input.StartsWith(s, StringComparison.OrdinalIgnoreCase))) - { - urlToValidate = GetHttpPreference() + "://" + input; - } + // Add protocol if missing for Uri validation + var urlToValidate = UrlSchemes.Any(s => input.StartsWith(s, StringComparison.OrdinalIgnoreCase)) + ? input + : GetHttpPreference() + "://" + input; if (!Uri.TryCreate(urlToValidate, UriKind.Absolute, out var uri)) return false; @@ -116,24 +100,22 @@ public bool IsURL(string raw) if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeFtp) return false; - // Validate host: must contain a dot (domain), be localhost, or be a valid IP var host = uri.Host; + + // localhost is valid if (host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) return true; + // Valid IP address (excluding 0.0.0.0) if (IPAddress.TryParse(host, out var hostIp)) return !hostIp.Equals(IPAddress.Any); - // Domain must contain at least one dot, and dot cannot be at the start or end - if (!host.Contains('.')) - return false; - - // Ensure valid domain format (not starting or ending with dot, TLD at least 2 characters) + // Domain must have valid format with TLD var parts = host.Split('.'); if (parts.Length < 2 || parts.Any(string.IsNullOrEmpty)) return false; - // TLD must be at least 2 characters + // TLD must be at least 2 letters var tld = parts[^1]; return tld.Length >= 2 && tld.All(char.IsLetter); } From 73cc5a91bc75e8d57a5bfd0ed50f35aab5cdd849 Mon Sep 17 00:00:00 2001 From: VictoriousRaptor <10308169+VictoriousRaptor@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:26:20 +0800 Subject: [PATCH 08/14] Fix ipv6 any address and numeric TLD Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Plugins/Flow.Launcher.Plugin.Url/Main.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.Url/Main.cs b/Plugins/Flow.Launcher.Plugin.Url/Main.cs index 326bb4a30c5..5232a00ff47 100644 --- a/Plugins/Flow.Launcher.Plugin.Url/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Url/Main.cs @@ -83,9 +83,9 @@ public bool IsURL(string raw) if (decimal.TryParse(input, out _)) return false; - // Check if it's a bare IP address with optional port and path - var ipPart = input.Split('/')[0]; // Remove path - if (IPEndPoint.TryParse(ipPart, out var endpoint) && !endpoint.Address.Equals(IPAddress.Any)) + // Check if it's a bare IP address with optional port, path, query, or fragment + var ipPart = input.Split('/', '?', '#')[0]; // Remove path, query, and fragment + if (IPEndPoint.TryParse(ipPart, out var endpoint) && !endpoint.Address.Equals(IPAddress.Any) && !endpoint.Address.Equals(IPAddress.IPv6Any)) return true; // Add protocol if missing for Uri validation @@ -108,16 +108,16 @@ public bool IsURL(string raw) // Valid IP address (excluding 0.0.0.0) if (IPAddress.TryParse(host, out var hostIp)) - return !hostIp.Equals(IPAddress.Any); + return !hostIp.Equals(IPAddress.Any) && !hostIp.Equals(IPAddress.IPv6Any); // Domain must have valid format with TLD var parts = host.Split('.'); if (parts.Length < 2 || parts.Any(string.IsNullOrEmpty)) return false; - // TLD must be at least 2 letters + // TLD must be at least 2 characters, allowing letters and digits var tld = parts[^1]; - return tld.Length >= 2 && tld.All(char.IsLetter); + return tld.Length >= 2 && tld.All(char.IsLetterOrDigit); } public void Init(PluginInitContext context) From ffc9b81d7b846d5b8e53bd63416358876603d324 Mon Sep 17 00:00:00 2001 From: VictoriousRaptor <10308169+VictoriousRaptor@users.noreply.github.com> Date: Fri, 27 Feb 2026 09:09:33 +0800 Subject: [PATCH 09/14] Use invariant culture to prevent unintentional IP parsing Some cultures uses comma as decimal separator which escapes this decimal check. Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- Plugins/Flow.Launcher.Plugin.Url/Main.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugins/Flow.Launcher.Plugin.Url/Main.cs b/Plugins/Flow.Launcher.Plugin.Url/Main.cs index 5232a00ff47..482a628000d 100644 --- a/Plugins/Flow.Launcher.Plugin.Url/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Url/Main.cs @@ -80,7 +80,7 @@ public bool IsURL(string raw) var input = raw.Trim(); // Exclude numbers (e.g. 1.2345) - if (decimal.TryParse(input, out _)) + if (decimal.TryParse(input, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out _)) return false; // Check if it's a bare IP address with optional port, path, query, or fragment From f2f14b13979557e4fc374642a63c7779e1728b2e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 13:23:43 +0800 Subject: [PATCH 10/14] Add URL path test coverage to UrlPluginTest (#4307) * Initial plan * Add test cases for URLs with paths Co-authored-by: VictoriousRaptor <10308169+VictoriousRaptor@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: VictoriousRaptor <10308169+VictoriousRaptor@users.noreply.github.com> --- Flow.Launcher.Test/Plugins/UrlPluginTest.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Flow.Launcher.Test/Plugins/UrlPluginTest.cs b/Flow.Launcher.Test/Plugins/UrlPluginTest.cs index 9b8953361d5..f0d919bbe1f 100644 --- a/Flow.Launcher.Test/Plugins/UrlPluginTest.cs +++ b/Flow.Launcher.Test/Plugins/UrlPluginTest.cs @@ -49,6 +49,14 @@ public void OneTimeSetup() [TestCase("HTTPS://EXAMPLE.COM")] [TestCase("EXAMPLE.COM")] [TestCase("LOCALHOST")] + [TestCase("example.com/path")] + [TestCase("example.com/path/to/resource")] + [TestCase("http://example.com/path")] + [TestCase("https://example.com/path?query=1")] + [TestCase("192.168.1.1/path/to/resource")] + [TestCase("localhost:8080/api/endpoint")] + [TestCase("http://localhost/path")] + [TestCase("[::1]/path")] public void WhenValidUrlThenIsUrlReturnsTrue(string url) { Assert.That(plugin.IsURL(url), Is.True); From 09d310e227e56c8d0eaede0a15821839bfb5b085 Mon Sep 17 00:00:00 2001 From: VictoriousRaptor <10308169+VictoriousRaptor@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:05:58 +0800 Subject: [PATCH 11/14] Enclose IPv6 addresses in brackets for URL formatting --- Plugins/Flow.Launcher.Plugin.Url/Main.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Plugins/Flow.Launcher.Plugin.Url/Main.cs b/Plugins/Flow.Launcher.Plugin.Url/Main.cs index 482a628000d..249e60f7b8a 100644 --- a/Plugins/Flow.Launcher.Plugin.Url/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Url/Main.cs @@ -22,6 +22,15 @@ public List Query(Query query) return []; } + if (IPEndPoint.TryParse(raw, out var endpoint)) + { + if (endpoint.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6) + { + // Enclose IPv6 addresses in brackets for URL formatting + raw = $"[{raw}]"; + } + } + return [ new() From f6ca3c850b24209188345e6873ac859fc8a70db7 Mon Sep 17 00:00:00 2001 From: VictoriousRaptor <10308169+VictoriousRaptor@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:15:12 +0800 Subject: [PATCH 12/14] Don't apply brackets for already closed --- Plugins/Flow.Launcher.Plugin.Url/Main.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugins/Flow.Launcher.Plugin.Url/Main.cs b/Plugins/Flow.Launcher.Plugin.Url/Main.cs index 249e60f7b8a..02b649742cb 100644 --- a/Plugins/Flow.Launcher.Plugin.Url/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Url/Main.cs @@ -24,7 +24,7 @@ public List Query(Query query) if (IPEndPoint.TryParse(raw, out var endpoint)) { - if (endpoint.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6) + if (endpoint.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 && raw[0] != '[' && raw[^1] != ']') { // Enclose IPv6 addresses in brackets for URL formatting raw = $"[{raw}]"; From 74c18d81954fc39855d5edf0942506f938550ed4 Mon Sep 17 00:00:00 2001 From: VictoriousRaptor <10308169+VictoriousRaptor@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:20:28 +0800 Subject: [PATCH 13/14] Use IPEndPoint to avoid potential URL with IP ports --- Plugins/Flow.Launcher.Plugin.Url/Main.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.Url/Main.cs b/Plugins/Flow.Launcher.Plugin.Url/Main.cs index 02b649742cb..c5036baa616 100644 --- a/Plugins/Flow.Launcher.Plugin.Url/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Url/Main.cs @@ -116,8 +116,8 @@ public bool IsURL(string raw) return true; // Valid IP address (excluding 0.0.0.0) - if (IPAddress.TryParse(host, out var hostIp)) - return !hostIp.Equals(IPAddress.Any) && !hostIp.Equals(IPAddress.IPv6Any); + if (IPEndPoint.TryParse(host, out endpoint)) + return !endpoint.Address.Equals(IPAddress.Any) && !endpoint.Address.Equals(IPAddress.IPv6Any); // Domain must have valid format with TLD var parts = host.Split('.'); From 9017ce6b9d150fcb4385fa3bf00e2cf8ca6ebb3b Mon Sep 17 00:00:00 2001 From: VictoriousRaptor <10308169+VictoriousRaptor@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:52:51 +0800 Subject: [PATCH 14/14] Ensure IPv6 are bracketed if it's with query or path --- Flow.Launcher.Test/Plugins/UrlPluginTest.cs | 3 +++ Plugins/Flow.Launcher.Plugin.Url/Main.cs | 20 +++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/Flow.Launcher.Test/Plugins/UrlPluginTest.cs b/Flow.Launcher.Test/Plugins/UrlPluginTest.cs index f0d919bbe1f..38d94264c0a 100644 --- a/Flow.Launcher.Test/Plugins/UrlPluginTest.cs +++ b/Flow.Launcher.Test/Plugins/UrlPluginTest.cs @@ -54,14 +54,17 @@ public void OneTimeSetup() [TestCase("http://example.com/path")] [TestCase("https://example.com/path?query=1")] [TestCase("192.168.1.1/path/to/resource")] + [TestCase("192.168.1.1/path/to/resource?query=1")] [TestCase("localhost:8080/api/endpoint")] [TestCase("http://localhost/path")] [TestCase("[::1]/path")] + [TestCase("[2001:db8::1]/path?query=1")] public void WhenValidUrlThenIsUrlReturnsTrue(string url) { Assert.That(plugin.IsURL(url), Is.True); } + [TestCase("2001:db8::1/path")] [TestCase("wwww")] [TestCase("wwww.c")] [TestCase("not a url")] diff --git a/Plugins/Flow.Launcher.Plugin.Url/Main.cs b/Plugins/Flow.Launcher.Plugin.Url/Main.cs index c5036baa616..cabf0beea29 100644 --- a/Plugins/Flow.Launcher.Plugin.Url/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Url/Main.cs @@ -94,8 +94,25 @@ public bool IsURL(string raw) // Check if it's a bare IP address with optional port, path, query, or fragment var ipPart = input.Split('/', '?', '#')[0]; // Remove path, query, and fragment - if (IPEndPoint.TryParse(ipPart, out var endpoint) && !endpoint.Address.Equals(IPAddress.Any) && !endpoint.Address.Equals(IPAddress.IPv6Any)) + if (IPEndPoint.TryParse(ipPart, out var endpoint)) + { + switch (endpoint.AddressFamily) + { + case System.Net.Sockets.AddressFamily.InterNetwork: + return !endpoint.Address.Equals(IPAddress.Any); + case System.Net.Sockets.AddressFamily.InterNetworkV6: + if (input.Contains('/') || input.Contains('?') || input.Contains('#')) + { + // Check if IPv6 address is properly bracketed + var bracketStart = input.IndexOf('['); + var bracketEnd = input.IndexOf(']'); + if (bracketStart == -1 || bracketEnd == -1 || bracketStart > bracketEnd) + return false; + } + return !endpoint.Address.Equals(IPAddress.IPv6Any); + } return true; + } // Add protocol if missing for Uri validation var urlToValidate = UrlSchemes.Any(s => input.StartsWith(s, StringComparison.OrdinalIgnoreCase)) @@ -104,6 +121,7 @@ public bool IsURL(string raw) if (!Uri.TryCreate(urlToValidate, UriKind.Absolute, out var uri)) return false; + // Validate protocol if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeFtp)