diff --git a/browser/extension/background.js b/browser/extension/background.js index 4ff07ad..5ccb0f7 100644 --- a/browser/extension/background.js +++ b/browser/extension/background.js @@ -3,7 +3,10 @@ const nativeHost = "com.keepassgo.browser"; const isNodeTestEnv = typeof module !== "undefined" && module.exports; const usePromiseAPI = typeof globalThis.browser !== "undefined"; const defaultSettings = { - bearerToken: "" + bearerToken: "", + bestMatchOnly: false, + requireSchemeMatch: false, + sortResults: "quality" }; const pageStatePrefix = "keepassgo-page-state:"; const matchCacheTTL = 30 * 1000; @@ -173,12 +176,20 @@ function connectNative(message) { } async function loadSettings() { - const stored = await storageGet(["bearerToken"]); + const stored = await storageGet(["bearerToken", "bestMatchOnly", "requireSchemeMatch", "sortResults"]); + const sortResults = normalizeSortResults(stored.sortResults); return { - bearerToken: (stored.bearerToken || defaultSettings.bearerToken).trim() + bearerToken: (stored.bearerToken || defaultSettings.bearerToken).trim(), + bestMatchOnly: Boolean(stored.bestMatchOnly ?? defaultSettings.bestMatchOnly), + requireSchemeMatch: Boolean(stored.requireSchemeMatch ?? defaultSettings.requireSchemeMatch), + sortResults }; } +function normalizeSortResults(value) { + return ["quality", "title", "path"].includes(value) ? value : defaultSettings.sortResults; +} + function supportsPageStateURL(rawURL) { return typeof rawURL === "string" && /^https?:\/\//i.test(rawURL); } @@ -238,6 +249,95 @@ function matchHost(rawURL) { } } +function matchScheme(rawURL) { + if (typeof rawURL !== "string") { + return ""; + } + const trimmed = rawURL.trim(); + if (!trimmed) { + return ""; + } + try { + return new URL(trimmed).protocol.toLowerCase(); + } catch (_error) { + return ""; + } +} + +function qualityRank(quality) { + switch (String(quality || "").trim().toLowerCase()) { + case "exact": + return 0; + case "scheme": + return 1; + case "host": + return 2; + default: + return 3; + } +} + +function compareMatchText(left, right) { + return String(left || "").localeCompare(String(right || ""), undefined, { sensitivity: "base" }); +} + +function sortMatches(matches, sortResults) { + const sorted = [...matches]; + switch (normalizeSortResults(sortResults)) { + case "title": + sorted.sort((left, right) => + compareMatchText(left?.title, right?.title) || + compareMatchText(left?.username, right?.username) || + compareMatchText(left?.path?.join("/"), right?.path?.join("/")) + ); + return sorted; + case "path": + sorted.sort((left, right) => + compareMatchText(left?.path?.join("/"), right?.path?.join("/")) || + compareMatchText(left?.title, right?.title) || + compareMatchText(left?.username, right?.username) + ); + return sorted; + default: + sorted.sort((left, right) => + qualityRank(left?.quality) - qualityRank(right?.quality) || + compareMatchText(left?.title, right?.title) || + compareMatchText(left?.username, right?.username) + ); + return sorted; + } +} + +function filterSchemeMatches(matches, pageURL) { + const pageScheme = matchScheme(pageURL); + if (!pageScheme) { + return [...matches]; + } + return matches.filter((match) => { + const entryScheme = matchScheme(match?.url); + return !entryScheme || entryScheme === pageScheme; + }); +} + +function filterBestQuality(matches) { + if (!Array.isArray(matches) || matches.length === 0) { + return []; + } + const bestRank = Math.min(...matches.map((match) => qualityRank(match?.quality))); + return matches.filter((match) => qualityRank(match?.quality) === bestRank); +} + +function applyMatchControls(matches, settings, pageURL) { + let filtered = Array.isArray(matches) ? [...matches] : []; + if (settings?.requireSchemeMatch) { + filtered = filterSchemeMatches(filtered, pageURL); + } + if (settings?.bestMatchOnly) { + filtered = filterBestQuality(filtered); + } + return sortMatches(filtered, settings?.sortResults); +} + function defaultObservedTitle(observed) { if (observed?.title) { return observed.title; @@ -623,6 +723,11 @@ async function refreshPageState(tabId, pageUrl, options = {}) { bearerToken: settings.bearerToken, url: resolvedURL }); + const filteredMatches = applyMatchControls( + Array.isArray(matches?.matches) ? matches.matches : [], + settings, + resolvedURL + ); state = { ...state, @@ -632,7 +737,7 @@ async function refreshPageState(tabId, pageUrl, options = {}) { pendingMessage: tokenPendingApprovalCount(matches?.status ?? state.status) > 0 ? approvalHintForState(state) || "Approve or deny the browser fill request in KeePassGO." : "", - matches: Array.isArray(matches?.matches) ? matches.matches : [], + matches: filteredMatches, error: matches?.error ?? "", updatedAt: Date.now() }; @@ -813,6 +918,7 @@ async function saveObservedLogin(tabId, selectedMatch = null) { } const backgroundTestExports = { + applyMatchControls, normalizePageState, actionPresentationForState, shouldReuseMatches, @@ -853,22 +959,31 @@ if (isNodeTestEnv) { return; case "keepassgo-save-settings": await storageSet({ - bearerToken: String(message.settings?.bearerToken || "").trim() + bearerToken: String(message.settings?.bearerToken || "").trim(), + bestMatchOnly: Boolean(message.settings?.bestMatchOnly), + requireSchemeMatch: Boolean(message.settings?.requireSchemeMatch), + sortResults: normalizeSortResults(message.settings?.sortResults) }); await refreshActivePage({ force: true }).catch(() => null); sendResponse({ success: true }); return; case "keepassgo-search-logins": { const settings = await loadSettings(); + const page = await activePageContext(); const response = await connectNative({ action: "search-logins", bearerToken: settings.bearerToken, query: String(message?.query || "").trim() }); + const searchResults = applyMatchControls( + Array.isArray(response?.searchResults) ? response.searchResults : [], + settings, + page.url + ); sendResponse({ success: Boolean(response?.success), error: response?.error || "", - results: Array.isArray(response?.searchResults) ? response.searchResults : [], + results: searchResults, status: response?.status ?? null }); return; diff --git a/browser/extension/background.test.cjs b/browser/extension/background.test.cjs index b030502..ee48b2b 100644 --- a/browser/extension/background.test.cjs +++ b/browser/extension/background.test.cjs @@ -69,6 +69,9 @@ test("shouldContinueWatchingState keeps polling locked login pages", () => { test("default settings include a blank bearer token that can be overridden by harness patching", () => { assert.equal(background.defaultSettings.bearerToken, ""); + assert.equal(background.defaultSettings.bestMatchOnly, false); + assert.equal(background.defaultSettings.requireSchemeMatch, false); + assert.equal(background.defaultSettings.sortResults, "quality"); }); test("savePlanForObservedLogin prefers updating an exact username match", () => { @@ -129,3 +132,45 @@ test("savePlanForObservedLogin falls back to saving into the current page group" url: "https://vault.example.invalid/login" }); }); + +test("applyMatchControls keeps only the strongest quality band when best-match-only is enabled", () => { + const filtered = background.applyMatchControls([ + { id: "livingston", title: "Livingston Dell", quality: "exact" }, + { id: "rusty", title: "Rusty Ryan", quality: "host" }, + { id: "linus", title: "Linus Caldwell", quality: "scheme" } + ], { + bestMatchOnly: true, + requireSchemeMatch: false, + sortResults: "quality" + }, "https://vault.example.invalid/login"); + + assert.deepEqual(filtered.map((match) => match.id), ["livingston"]); +}); + +test("applyMatchControls removes explicit scheme mismatches but keeps scheme-less matches", () => { + const filtered = background.applyMatchControls([ + { id: "yen", title: "The Amazing Yen", url: "https://vault.example.invalid/login", quality: "exact" }, + { id: "saul", title: "Saul Bloom", url: "http://vault.example.invalid/login", quality: "exact" }, + { id: "basher", title: "Basher Tarr", url: "vault.example.invalid", quality: "host" } + ], { + bestMatchOnly: false, + requireSchemeMatch: true, + sortResults: "quality" + }, "https://vault.example.invalid/login"); + + assert.deepEqual(filtered.map((match) => match.id), ["yen", "basher"]); +}); + +test("applyMatchControls sorts by path when requested", () => { + const filtered = background.applyMatchControls([ + { id: "linus", title: "Linus Caldwell", path: ["Crew", "Inside"] }, + { id: "rusty", title: "Rusty Ryan", path: ["Crew", "Casino"] }, + { id: "danny", title: "Danny Ocean", path: ["Crew"] } + ], { + bestMatchOnly: false, + requireSchemeMatch: false, + sortResults: "path" + }, "https://vault.example.invalid/login"); + + assert.deepEqual(filtered.map((match) => match.id), ["danny", "rusty", "linus"]); +}); diff --git a/browser/extension/options.html b/browser/extension/options.html index 1f9a6b1..ce5631c 100644 --- a/browser/extension/options.html +++ b/browser/extension/options.html @@ -19,6 +19,25 @@ API token +