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 +
+ Browser Matching + + + +
diff --git a/browser/extension/options.js b/browser/extension/options.js index 2cdd4ed..faee9ec 100644 --- a/browser/extension/options.js +++ b/browser/extension/options.js @@ -23,6 +23,9 @@ async function loadSettings() { throw new Error(response?.error || "Could not load settings."); } document.getElementById("bearer-token").value = response.settings.bearerToken || ""; + document.getElementById("best-match-only").checked = Boolean(response.settings.bestMatchOnly); + document.getElementById("require-scheme-match").checked = Boolean(response.settings.requireSchemeMatch); + document.getElementById("sort-results").value = response.settings.sortResults || "quality"; } async function saveSettings(event) { @@ -33,7 +36,10 @@ async function saveSettings(event) { const response = await runtimeSend({ type: "keepassgo-save-settings", settings: { - bearerToken: document.getElementById("bearer-token").value + bearerToken: document.getElementById("bearer-token").value, + bestMatchOnly: document.getElementById("best-match-only").checked, + requireSchemeMatch: document.getElementById("require-scheme-match").checked, + sortResults: document.getElementById("sort-results").value } }); if (!response?.success) { diff --git a/browser/extension/style.css b/browser/extension/style.css index 584d46a..9d4ed52 100644 --- a/browser/extension/style.css +++ b/browser/extension/style.css @@ -177,7 +177,8 @@ label { } input, -textarea { +textarea, +select { width: 100%; padding: 10px 12px; border: 1px solid var(--line); @@ -187,6 +188,33 @@ textarea { font: inherit; } +fieldset { + margin: 0; + padding: 12px; + border: 1px solid var(--line); + border-radius: 12px; + display: grid; + gap: 12px; +} + +legend { + padding: 0 6px; + color: var(--ink-soft); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.checkbox-row { + display: flex; + align-items: center; + gap: 10px; +} + +.checkbox-row input { + width: auto; +} + button, .link-button { padding: 10px 14px; diff --git a/docs/browser-extension.md b/docs/browser-extension.md index 0c5c9c9..5ced05b 100644 --- a/docs/browser-extension.md +++ b/docs/browser-extension.md @@ -103,6 +103,9 @@ User story: access. - Browser matching must treat common KeePass data conventions as real browser targets, not just the primary `URL` field. +- Users need explicit control over how aggressive browser matching should be so + they can prefer narrow page suggestions or broader candidate lists without + reconfiguring KeePassGO itself. Expected behavior: @@ -114,6 +117,20 @@ Expected behavior: - scheme-less host values such as `gitlab.com` - custom URL fields such as `URL1`, `URL2`, and similar KeePass-style URL slots +- The extension settings page exposes browser match controls for: + - `Best match only` + - `Require current scheme` + - `Sort results` +- `Best match only` limits page suggestions to the strongest quality band + returned by KeePassGO instead of showing every candidate for the host. +- `Require current scheme` hides `http` credentials on `https` pages and + vice versa when an entry stores an explicit scheme, while still allowing + scheme-less host entries to match either page. +- `Sort results` affects both page suggestions and popup search results so the + user can prefer: + - KeePassGO match quality first + - title order + - path order ## Locked Vault Workflow