commit 1627a32bbb190bc880602d85be8938f929e73816
parent bb48c9ac0e4d1374d85e1890b307363f8052d875
Author: alex <alex.the.stout5@gmail.com>
Date: Tue, 21 Oct 2025 14:31:29 +0000
Bug 694856 - Allow Open Link context menu items to be shown in more situations when selecting plain text links. r=urlbar-reviewers,Standard8
Replace SelectionUtils regex logic with looksLikeUrl and looksLikeOrigin methods from UrlUtils to improve recognition of plaintext links for the context menu.
Differential Revision: https://phabricator.services.mozilla.com/D258780
Diffstat:
4 files changed, 363 insertions(+), 9 deletions(-)
diff --git a/browser/base/content/test/contextMenu/browser_contextmenu_plaintextlinks.js b/browser/base/content/test/contextMenu/browser_contextmenu_plaintextlinks.js
@@ -32,6 +32,32 @@ add_task(async function () {
<div id="block2">
<p id="mainDomain">main.example.com</p>
</div>
+ <div id="block3">
+ <p id="ipURL">http://192.168.0.1/</p>
+ <p id="ipWithPath">http://192.168.0.1/hello</p>
+ <p id="hostWithSlash">example.com/</p>
+ <p id="hostWithPath">example.com/hello</p>
+ <p id="looksLikeURL">http://cheese/hello</p>
+ <p id="usernamePasswordURL">hello:password@google.com</p>
+ <p id="numberInHost">exam4ple.com</p>
+ <p id="hostWithPort">example.com:8080</p>
+ </div>
+ <div id="block4">
+ <p id="email">hello@google.com</p>
+ <p id="justCheese">cheese</p>
+ <p id="colonSeparated">hello:this</p>
+ <p id="atSymbolSeparated">hello@this</p>
+ <p id="noHostWithNumber">hello/1</p>
+ <p id="noHostWithText">hello/cheese</p>
+ <p id="noHostWithModulo">hello/%</p>
+ <p id="noHostWithQuestion">hello/?</p>
+ <p id="noHostWithPound">hello/#</p>
+ <p id="noHostWithSlashOnly">hello/</p>
+ <p id="topDomainIllegalNumberEnd">example.com4</p>
+ <p id="topDomainIllegalNumberStart">example.4com</p>
+ <p id="topDomainIllegalNumberMid">example.co4m</p>
+ <p id="illegalNumberBeforePort">example.com4:8080</p>
+ </div>
</div>
`;
@@ -136,6 +162,139 @@ add_task(async function () {
expectedLink: "http://open-suse.ru/",
message: "Link options should show for open-suse.ru",
},
+ {
+ id: "ip-address-only",
+ selection: {
+ startNode: "ipURL",
+ startIndex: "http://".length,
+ endNode: "ipURL",
+ endIndex: "http://192.168.0.1".length,
+ },
+ expectLinks: true,
+ expectedLink: "http://192.168.0.1/",
+ message: "Link options should show for ip 192.168.0.1",
+ },
+ {
+ id: "ip-with-protocol",
+ selection: {
+ startNode: "ipURL",
+ startIndex: 0,
+ endNode: "ipURL",
+ endIndex: "http://192.168.0.1".length,
+ },
+ expectLinks: true,
+ expectedLink: "http://192.168.0.1/",
+ message: "Link options should show for ip http://192.168.0.1",
+ },
+ {
+ id: "ip-with-protocol-and-slash",
+ selection: {
+ startNode: "ipURL",
+ startIndex: 0,
+ endNode: "ipURL",
+ endIndex: "http://192.168.0.1/".length,
+ },
+ expectLinks: true,
+ expectedLink: "http://192.168.0.1/",
+ message: "Link options should show for ip http://192.168.0.1/",
+ },
+ {
+ id: "ip-with-path",
+ selection: {
+ startNode: "ipWithPath",
+ startIndex: "http://".length,
+ endNode: "ipWithPath",
+ endIndex: "http://192.168.0.1/hello".length,
+ },
+ expectLinks: true,
+ expectedLink: "http://192.168.0.1/hello",
+ message: "Link options should show for ip 192.168.0.1/hello",
+ },
+ {
+ id: "ip-with-protocol-and-path",
+ selection: {
+ startNode: "ipWithPath",
+ startIndex: 0,
+ endNode: "ipWithPath",
+ endIndex: "http://192.168.0.1/hello".length,
+ },
+ expectLinks: true,
+ expectedLink: "http://192.168.0.1/hello",
+ message: "Link options should show for ip http://192.168.0.1/hello",
+ },
+ {
+ id: "host-with-slash-only",
+ selection: {
+ startNode: "hostWithSlash",
+ startIndex: 0,
+ endNode: "hostWithSlash",
+ endIndex: "example.com/".length,
+ },
+ expectLinks: true,
+ expectedLink: "http://example.com/",
+ message: "Link options should show for example.com/",
+ },
+ {
+ id: "host-with-path",
+ selection: {
+ startNode: "hostWithPath",
+ startIndex: 0,
+ endNode: "hostWithPath",
+ endIndex: "example.com/hello".length,
+ },
+ expectLinks: true,
+ expectedLink: "http://example.com/hello",
+ message: "Link options should show for example.com/hello",
+ },
+ {
+ id: "looks-like-URL",
+ selection: {
+ startNode: "looksLikeURL",
+ startIndex: 0,
+ endNode: "looksLikeURL",
+ endIndex: "http://cheese/hello".length,
+ },
+ expectLinks: true,
+ expectedLink: "http://cheese/hello",
+ message:
+ "Link options should show for malformed but plausible url http://cheese/hello",
+ },
+ {
+ id: "username-password-URL",
+ selection: {
+ startNode: "usernamePasswordURL",
+ startIndex: 0,
+ endNode: "usernamePasswordURL",
+ endIndex: "hello:password@google.com".length,
+ },
+ expectLinks: true,
+ expectedLink: "http://hello:password@google.com/",
+ message: "Link options should show for hello:password@google.com",
+ },
+ {
+ id: "host-with-number-in-name",
+ selection: {
+ startNode: "numberInHost",
+ startIndex: 0,
+ endNode: "numberInHost",
+ endIndex: "exam4ple.com".length,
+ },
+ expectLinks: true,
+ expectedLink: "http://exam4ple.com/",
+ message: "Link options should show for exam4ple.com",
+ },
+ {
+ id: "host-with-port",
+ selection: {
+ startNode: "hostWithPort",
+ startIndex: 0,
+ endNode: "hostWithPort",
+ endIndex: "example.com:8080".length,
+ },
+ expectLinks: true,
+ expectedLink: "http://example.com:8080/",
+ message: "Link options should show for example.com:8080",
+ },
// ---- Non-URL selections ----
{
@@ -196,6 +355,160 @@ add_task(async function () {
expectLinks: false,
message: "Link options should not show for 'open-suse.ru)'",
},
+ {
+ id: "email", // Emails are intentionally handled differently from URLs.
+ selection: {
+ startNode: "email",
+ startIndex: 0,
+ endNode: "email",
+ endIndex: "hello@google.com".length,
+ },
+ expectLinks: false,
+ message: "Link options should not show for hello@google.com",
+ },
+ {
+ id: "just-a-word",
+ selection: {
+ startNode: "justCheese",
+ startIndex: 0,
+ endNode: "justCheese",
+ endIndex: "cheese".length,
+ },
+ expectLinks: false,
+ message: "Link options should not show for cheese",
+ },
+ {
+ id: "colon-separated-words",
+ selection: {
+ startNode: "colonSeparated",
+ startIndex: 0,
+ endNode: "colonSeparated",
+ endIndex: "hello:this".length,
+ },
+ expectLinks: false,
+ message: "Link options should not show for hello:this",
+ },
+ {
+ id: "at-symbol-separated-words",
+ selection: {
+ startNode: "atSymbolSeparated",
+ startIndex: 0,
+ endNode: "atSymbolSeparated",
+ endIndex: "hello@this".length,
+ },
+ expectLinks: false,
+ message: "Link options should not show for hello@this",
+ },
+ {
+ id: "host-with-number-after-slash",
+ selection: {
+ startNode: "noHostWithNumber",
+ startIndex: 0,
+ endNode: "noHostWithNumber",
+ endIndex: "hello/1".length,
+ },
+ expectLinks: false,
+ message: "Link options should not show for hello/1",
+ },
+ {
+ id: "host-with-word-after-slash",
+ selection: {
+ startNode: "noHostWithText",
+ startIndex: 0,
+ endNode: "noHostWithText",
+ endIndex: "hello/cheese".length,
+ },
+ expectLinks: false,
+ message: "Link options should not show for hello/cheese",
+ },
+ {
+ id: "host-with-modulo-symbol-after-slash",
+ selection: {
+ startNode: "noHostWithModulo",
+ startIndex: 0,
+ endNode: "noHostWithModulo",
+ endIndex: "hello/%".length,
+ },
+ expectLinks: false,
+ message: "Link options should not show for hello/%",
+ },
+ {
+ id: "host-with-question-mark-after-slash",
+ selection: {
+ startNode: "noHostWithQuestion",
+ startIndex: 0,
+ endNode: "noHostWithQuestion",
+ endIndex: "hello/?".length,
+ },
+ expectLinks: false,
+ message: "Link options should not show for hello/?",
+ },
+ {
+ id: "host-with-pound-sign-after-slash",
+ selection: {
+ startNode: "noHostWithPound",
+ startIndex: 0,
+ endNode: "noHostWithPound",
+ endIndex: "hello/#".length,
+ },
+ expectLinks: false,
+ message: "Link options should not show for hello/#",
+ },
+ {
+ id: "host-with-nothing-after-slash",
+ selection: {
+ startNode: "noHostWithSlashOnly",
+ startIndex: 0,
+ endNode: "noHostWithSlashOnly",
+ endIndex: "hello/".length,
+ },
+ expectLinks: false,
+ message: "Link options should not show for hello/",
+ },
+ {
+ id: "top-level-domain-with-illegal-number-at-end",
+ selection: {
+ startNode: "topDomainIllegalNumberEnd",
+ startIndex: 1,
+ endNode: "topDomainIllegalNumberEnd",
+ endIndex: "example.com4".length,
+ },
+ expectLinks: false,
+ message: "Link options should not show for example.com4",
+ },
+ {
+ id: "top-level-domain-with-illegal-number-at-start",
+ selection: {
+ startNode: "topDomainIllegalNumberStart",
+ startIndex: 1,
+ endNode: "topDomainIllegalNumberStart",
+ endIndex: "example.4com".length,
+ },
+ expectLinks: false,
+ message: "Link options should not show for example.4com",
+ },
+ {
+ id: "top-level-domain-with-illegal-number-in-middle",
+ selection: {
+ startNode: "topDomainIllegalNumberMid",
+ startIndex: 1,
+ endNode: "topDomainIllegalNumberMid",
+ endIndex: "example.co4m".length,
+ },
+ expectLinks: false,
+ message: "Link options should not show for example.co4m",
+ },
+ {
+ id: "top-level-domain-with-illegal-number-before-port",
+ selection: {
+ startNode: "illegalNumberBeforePort",
+ startIndex: 1,
+ endNode: "illegalNumberBeforePort",
+ endIndex: "example.com4:8080".length,
+ },
+ expectLinks: false,
+ message: "Link options should not show for example.com4:8080",
+ },
];
await BrowserTestUtils.openNewForegroundTab(
diff --git a/browser/components/urlbar/UrlbarProviderAutofill.sys.mjs b/browser/components/urlbar/UrlbarProviderAutofill.sys.mjs
@@ -992,6 +992,7 @@ export class UrlbarProviderAutofill extends UrlbarProvider {
if (
lazy.UrlUtils.looksLikeOrigin(this._searchString, {
ignoreKnownDomains: true,
+ allowPartialNumericalTLDs: true,
})
) {
[query, params] = this._getOriginQuery(queryContext);
diff --git a/toolkit/modules/SelectionUtils.sys.mjs b/toolkit/modules/SelectionUtils.sys.mjs
@@ -3,6 +3,12 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ UrlUtils: "resource://gre/modules/UrlUtils.sys.mjs",
+});
+
export var SelectionUtils = {
/**
* Trim the selection text to a reasonable size and sanitize it to make it
@@ -81,12 +87,13 @@ export var SelectionUtils = {
// Have some text, let's figure out if it looks like a URL that isn't
// actually a link.
linkText = selectionStr.trim();
- if (/^(?:https?|ftp):/i.test(linkText)) {
- try {
- url = Services.io.newURI(linkText);
- } catch (ex) {}
- } else if (/^(?:[a-z\d-]+\.)+[a-z]+$/i.test(linkText)) {
- // Check if this could be a valid url, just missing the protocol.
+
+ if (
+ lazy.UrlUtils.looksLikeUrl(linkText, {
+ requirePath: false,
+ validateOrigin: true,
+ })
+ ) {
// Now let's see if this is an intentional link selection. Our guess is
// based on whether the selection begins/ends with whitespace or is
// preceded/followed by a non-word character.
@@ -138,7 +145,13 @@ export var SelectionUtils = {
selectionStr = this.trimSelection(selectionStr, aCharLen);
}
- if (url && !url.host) {
+ // This try catch is necessary since in rare cases, we think some text is a
+ // link, but the host is actually invalid so we fail in nsIURI.host
+ try {
+ if (url && !url.host) {
+ url = null;
+ }
+ } catch (ex) {
url = null;
}
diff --git a/toolkit/modules/UrlUtils.sys.mjs b/toolkit/modules/UrlUtils.sys.mjs
@@ -22,6 +22,7 @@ export const UrlUtils = {
REGEXP_LIKE_PROTOCOL: /^[A-Z+.-]+:\/*(?!\/)/i,
REGEXP_USERINFO_INVALID_CHARS: /[^\w.~%!$&'()*+,;=:-]/,
REGEXP_HOSTPORT_INVALID_CHARS: /[^\[\]A-Z0-9.:-]/i,
+ REGEXP_HOSTPORT_INVALID_TLD_NUM: /\.\w*\d\w*(:\d+)?$/,
REGEXP_SINGLE_WORD_HOST: /^[^.:]+$/i,
REGEXP_HOSTPORT_IP_LIKE: /^(?=(.*[.:].*){2})[a-f0-9\.\[\]:]+$/i,
// This accepts partial IPv4.
@@ -49,12 +50,18 @@ export const UrlUtils = {
* @param {object} [options]
* @param {boolean} [options.requirePath]
* The url must have a path
+ * @param {boolean} [options.validateOrigin]
+ * The prepath must look like an origin
* @param {ConsoleInstance} [logger]
* Optional logger for debugging
* @returns {boolean}
* Whether the token looks like a URL
*/
- looksLikeUrl(token, { requirePath = false } = {}, logger) {
+ looksLikeUrl(
+ token,
+ { requirePath = false, validateOrigin = false } = {},
+ logger
+ ) {
if (token.length < 2) {
return false;
}
@@ -77,6 +84,17 @@ export const UrlUtils = {
return false;
}
+ // Check if prePath looks like origin.
+ if (validateOrigin) {
+ const result = this.looksLikeOrigin(prePath, {
+ ignoreKnownDomains: false,
+ });
+ if (result !== this.LOOKS_LIKE_ORIGIN.NONE) {
+ return true;
+ }
+ return false;
+ }
+
let path = slashIndex != -1 ? token.slice(slashIndex) : "";
logger?.debug("path", path);
if (requirePath && !path) {
@@ -134,6 +152,8 @@ export const UrlUtils = {
* If true, the origin cannot be an IP address
* @param {boolean} [options.noPort]
* If true, the origin cannot have a port number
+ * @param {boolean} [options.allowPartialNumericalTLDs]
+ * If true, the origin can have numbers in its top level domain
* @param {ConsoleInstance} [logger]
* Optional logger for debugging
* @returns {number}
@@ -141,7 +161,12 @@ export const UrlUtils = {
*/
looksLikeOrigin(
token,
- { ignoreKnownDomains = false, noIp = false, noPort = false } = {},
+ {
+ ignoreKnownDomains = false,
+ noIp = false,
+ noPort = false,
+ allowPartialNumericalTLDs = false,
+ } = {},
logger
) {
if (!token.length) {
@@ -174,6 +199,8 @@ export const UrlUtils = {
this.REGEXP_LIKE_PROTOCOL.test(hostPort) ||
this.REGEXP_USERINFO_INVALID_CHARS.test(userinfo) ||
this.REGEXP_HOSTPORT_INVALID_CHARS.test(hostPort) ||
+ (!allowPartialNumericalTLDs &&
+ this.REGEXP_HOSTPORT_INVALID_TLD_NUM.test(hostPort)) ||
(!this.REGEXP_SINGLE_WORD_HOST.test(hostPort) &&
this.REGEXP_HOSTPORT_IP_LIKE.test(hostPort) &&
this.REGEXP_HOSTPORT_INVALID_IP.test(hostPort))