diff --git a/browser/extension/background.js b/browser/extension/background.js index 4ff07ad..4572290 100644 --- a/browser/extension/background.js +++ b/browser/extension/background.js @@ -3,7 +3,8 @@ const nativeHost = "com.keepassgo.browser"; const isNodeTestEnv = typeof module !== "undefined" && module.exports; const usePromiseAPI = typeof globalThis.browser !== "undefined"; const defaultSettings = { - bearerToken: "" + bearerToken: "", + bestMatchOnly: false }; const pageStatePrefix = "keepassgo-page-state:"; const matchCacheTTL = 30 * 1000; @@ -173,9 +174,10 @@ function connectNative(message) { } async function loadSettings() { - const stored = await storageGet(["bearerToken"]); + const stored = await storageGet(["bearerToken", "bestMatchOnly"]); return { - bearerToken: (stored.bearerToken || defaultSettings.bearerToken).trim() + bearerToken: (stored.bearerToken || defaultSettings.bearerToken).trim(), + bestMatchOnly: Boolean(stored.bestMatchOnly ?? defaultSettings.bestMatchOnly) }; } @@ -238,6 +240,30 @@ function matchHost(rawURL) { } } +function qualityRank(quality) { + switch (String(quality || "").trim().toLowerCase()) { + case "exact": + return 0; + case "scheme": + return 1; + case "host": + return 2; + default: + return 3; + } +} + +function applyBestMatchOnly(matches, enabled) { + if (!Array.isArray(matches) || !enabled) { + return Array.isArray(matches) ? [...matches] : []; + } + if (matches.length === 0) { + return []; + } + const bestRank = Math.min(...matches.map((match) => qualityRank(match?.quality))); + return matches.filter((match) => qualityRank(match?.quality) === bestRank); +} + function defaultObservedTitle(observed) { if (observed?.title) { return observed.title; @@ -632,7 +658,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: applyBestMatchOnly(matches?.matches, settings.bestMatchOnly), error: matches?.error ?? "", updatedAt: Date.now() }; @@ -813,6 +839,7 @@ async function saveObservedLogin(tabId, selectedMatch = null) { } const backgroundTestExports = { + applyBestMatchOnly, normalizePageState, actionPresentationForState, shouldReuseMatches, @@ -853,7 +880,8 @@ 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) }); await refreshActivePage({ force: true }).catch(() => null); sendResponse({ success: true }); @@ -868,7 +896,7 @@ if (isNodeTestEnv) { sendResponse({ success: Boolean(response?.success), error: response?.error || "", - results: Array.isArray(response?.searchResults) ? response.searchResults : [], + results: applyBestMatchOnly(response?.searchResults, settings.bestMatchOnly), status: response?.status ?? null }); return; diff --git a/browser/extension/background.test.cjs b/browser/extension/background.test.cjs index b030502..3bae90e 100644 --- a/browser/extension/background.test.cjs +++ b/browser/extension/background.test.cjs @@ -69,6 +69,7 @@ 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); }); test("savePlanForObservedLogin prefers updating an exact username match", () => { @@ -129,3 +130,22 @@ test("savePlanForObservedLogin falls back to saving into the current page group" url: "https://vault.example.invalid/login" }); }); + +test("applyBestMatchOnly keeps only the strongest quality band when enabled", () => { + const filtered = background.applyBestMatchOnly([ + { id: "livingston", title: "Livingston Dell", quality: "exact" }, + { id: "rusty", title: "Rusty Ryan", quality: "host" }, + { id: "linus", title: "Linus Caldwell", quality: "scheme" } + ], true); + + assert.deepEqual(filtered.map((match) => match.id), ["livingston"]); +}); + +test("applyBestMatchOnly preserves all matches when disabled", () => { + const filtered = background.applyBestMatchOnly([ + { id: "livingston", title: "Livingston Dell", quality: "exact" }, + { id: "rusty", title: "Rusty Ryan", quality: "host" } + ], false); + + assert.deepEqual(filtered.map((match) => match.id), ["livingston", "rusty"]); +}); diff --git a/browser/extension/options.html b/browser/extension/options.html index 1f9a6b1..6bd602c 100644 --- a/browser/extension/options.html +++ b/browser/extension/options.html @@ -19,6 +19,13 @@ API token +
+ Browser Matching + +
diff --git a/browser/extension/options.js b/browser/extension/options.js index 2cdd4ed..284763c 100644 --- a/browser/extension/options.js +++ b/browser/extension/options.js @@ -23,6 +23,7 @@ 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); } async function saveSettings(event) { @@ -33,7 +34,8 @@ 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 } }); if (!response?.success) { diff --git a/browser/extension/style.css b/browser/extension/style.css index 584d46a..c9204b8 100644 --- a/browser/extension/style.css +++ b/browser/extension/style.css @@ -187,6 +187,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..45bba9e 100644 --- a/docs/browser-extension.md +++ b/docs/browser-extension.md @@ -103,6 +103,8 @@ User story: access. - Browser matching must treat common KeePass data conventions as real browser targets, not just the primary `URL` field. +- Users who prefer narrow suggestions can ask the extension to show only the + strongest match quality returned by KeePassGO. Expected behavior: @@ -114,6 +116,9 @@ 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 `Best match only`. +- When `Best match only` is enabled, page suggestions and popup search results + only show the strongest quality band returned by KeePassGO. ## Locked Vault Workflow