Compare commits

...

11 commits

Author SHA1 Message Date
VictoriousRaptor
bb62639efb
Merge 4942eab9ef into 10fcbb9990 2026-02-26 00:57:26 +08:00
VictoriousRaptor
4942eab9ef Simplify parsing logic 2026-02-26 00:57:16 +08:00
VictoriousRaptor
1872755540 Refactor URL match logic 2026-02-26 00:40:00 +08:00
Jeremy Wu
10fcbb9990
Merge pull request #4292 from Flow-Launcher/merge_2_1_0
Some checks failed
Build / build (push) Has been cancelled
Merge v2.1.0 back to dev
2026-02-25 22:54:55 +11:00
Jeremy Wu
980c1a1060
Merge branch 'dev' into merge_2_1_0 2026-02-25 22:45:03 +11:00
Jeremy Wu
cd9825380d
Release 2.1.0 | Plugin 5.2.0 (#4276)
Some checks failed
Publish Default Plugins / publish (push) Has been cancelled
Build / build (push) Has been cancelled
2026-02-24 22:52:23 +11:00
VictoriousRaptor
a8e0d65e39 Refactor URL plugin test 2025-12-29 22:06:05 +08:00
VictoriousRaptor
77f81cfb16 Fix matching pattern for IPv6 addresses with consecutive ":" 2025-12-28 22:04:37 +08:00
VictoriousRaptor
d0a274a668 Add test case 2025-12-28 13:52:01 +08:00
VictoriousRaptor
e9a68d2563 Fix match logic 2025-12-28 10:56:27 +08:00
VictoriousRaptor
be630f1ab5 Update UrlPattern to include private addresses and update test cases 2025-12-28 10:43:38 +08:00
5 changed files with 117 additions and 76 deletions

View file

@ -15,10 +15,10 @@
</PropertyGroup>
<PropertyGroup>
<Version>5.1.0</Version>
<PackageVersion>5.1.0</PackageVersion>
<AssemblyVersion>5.1.0</AssemblyVersion>
<FileVersion>5.1.0</FileVersion>
<Version>5.2.0</Version>
<PackageVersion>5.2.0</PackageVersion>
<AssemblyVersion>5.2.0</AssemblyVersion>
<FileVersion>5.2.0</FileVersion>
<PackageId>Flow.Launcher.Plugin</PackageId>
<Authors>Flow-Launcher</Authors>
<PackageLicenseExpression>MIT</PackageLicenseExpression>

View file

@ -1,33 +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("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("http://110.10.10.10"));
ClassicAssert.IsTrue(plugin.IsURL("110.10.10.10"));
ClassicAssert.IsTrue(plugin.IsURL("ftp://110.10.10.10"));
var settingsProperty = typeof(Main).GetProperty("Settings", BindingFlags.NonPublic | BindingFlags.Static);
settingsProperty?.SetValue(null, new Settings());
plugin = new Main();
}
ClassicAssert.IsFalse(plugin.IsURL("wwww"));
ClassicAssert.IsFalse(plugin.IsURL("wwww.c"));
ClassicAssert.IsFalse(plugin.IsURL("wwww.c"));
[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);
}
[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);
}
}
}

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;

View file

@ -1,6 +1,7 @@
using System;
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Linq;
using System.Net;
using System.Windows.Controls;
using Flow.Launcher.Plugin.SharedCommands;
@ -8,49 +9,20 @@ 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*)?@)?" +
"(?:" +
// 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]))" +
"|" +
// 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{2,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; }
private static readonly string[] UrlSchemes = ["http://", "https://", "ftp://"];
public List<Result> Query(Query query)
{
var raw = query.Search;
if (IsURL(raw))
if (!IsURL(raw))
{
return
return [];
}
return
[
new()
{
@ -60,7 +32,8 @@ namespace Flow.Launcher.Plugin.Url
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 +65,6 @@ namespace Flow.Launcher.Plugin.Url
}
}
];
}
return [];
}
private static string GetHttpPreference()
@ -104,19 +74,50 @@ namespace Flow.Launcher.Plugin.Url
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();
if (raw == "localhost" || raw.StartsWith("localhost:") ||
raw == "http://localhost" || raw.StartsWith("http://localhost:") ||
raw == "https://localhost" || raw.StartsWith("https://localhost:")
)
{
// Exclude numbers (e.g. 1.2345)
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))
return true;
}
return false;
// 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;
// Validate protocol
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeFtp)
return false;
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 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
var tld = parts[^1];
return tld.Length >= 2 && tld.All(char.IsLetter);
}
public void Init(PluginInitContext context)

View file

@ -1,4 +1,4 @@
version: '2.0.3.{build}'
version: '2.1.0.{build}'
# Do not build on tags because we create a release on merge to master. Otherwise will upload artifacts twice changing the hash, as well as triggering duplicate GitHub release action & NuGet deployments.
skip_tags: true