Compare commits

..

2 Commits

Author SHA1 Message Date
joejulian ab9214af99 Merge pull request 'Add browser best match option' (#10) from feature/browser-best-match-only into main
ci / lint-test (push) Successful in 4m24s
ci / build (push) Failing after 6m18s
Reviewed-on: #10
2026-04-25 21:03:43 +00:00
Joe Julian d1f30f5936 Add browser best match option
ci / lint-test (pull_request) Successful in 5m1s
ci / build (pull_request) Successful in 7m28s
2026-04-25 11:42:43 -07:00
6 changed files with 27 additions and 168 deletions
+11 -98
View File
@@ -4,9 +4,7 @@ const isNodeTestEnv = typeof module !== "undefined" && module.exports;
const usePromiseAPI = typeof globalThis.browser !== "undefined"; const usePromiseAPI = typeof globalThis.browser !== "undefined";
const defaultSettings = { const defaultSettings = {
bearerToken: "", bearerToken: "",
bestMatchOnly: false, bestMatchOnly: false
requireSchemeMatch: false,
sortResults: "quality"
}; };
const pageStatePrefix = "keepassgo-page-state:"; const pageStatePrefix = "keepassgo-page-state:";
const matchCacheTTL = 30 * 1000; const matchCacheTTL = 30 * 1000;
@@ -176,20 +174,13 @@ function connectNative(message) {
} }
async function loadSettings() { async function loadSettings() {
const stored = await storageGet(["bearerToken", "bestMatchOnly", "requireSchemeMatch", "sortResults"]); const stored = await storageGet(["bearerToken", "bestMatchOnly"]);
const sortResults = normalizeSortResults(stored.sortResults);
return { return {
bearerToken: (stored.bearerToken || defaultSettings.bearerToken).trim(), bearerToken: (stored.bearerToken || defaultSettings.bearerToken).trim(),
bestMatchOnly: Boolean(stored.bestMatchOnly ?? defaultSettings.bestMatchOnly), 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) { function supportsPageStateURL(rawURL) {
return typeof rawURL === "string" && /^https?:\/\//i.test(rawURL); return typeof rawURL === "string" && /^https?:\/\//i.test(rawURL);
} }
@@ -249,21 +240,6 @@ 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) { function qualityRank(quality) {
switch (String(quality || "").trim().toLowerCase()) { switch (String(quality || "").trim().toLowerCase()) {
case "exact": case "exact":
@@ -277,67 +253,17 @@ function qualityRank(quality) {
} }
} }
function compareMatchText(left, right) { function applyBestMatchOnly(matches, enabled) {
return String(left || "").localeCompare(String(right || ""), undefined, { sensitivity: "base" }); if (!Array.isArray(matches) || !enabled) {
} return Array.isArray(matches) ? [...matches] : [];
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;
} }
} if (matches.length === 0) {
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 []; return [];
} }
const bestRank = Math.min(...matches.map((match) => qualityRank(match?.quality))); const bestRank = Math.min(...matches.map((match) => qualityRank(match?.quality)));
return matches.filter((match) => qualityRank(match?.quality) === bestRank); 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) { function defaultObservedTitle(observed) {
if (observed?.title) { if (observed?.title) {
return observed.title; return observed.title;
@@ -723,11 +649,6 @@ async function refreshPageState(tabId, pageUrl, options = {}) {
bearerToken: settings.bearerToken, bearerToken: settings.bearerToken,
url: resolvedURL url: resolvedURL
}); });
const filteredMatches = applyMatchControls(
Array.isArray(matches?.matches) ? matches.matches : [],
settings,
resolvedURL
);
state = { state = {
...state, ...state,
@@ -737,7 +658,7 @@ async function refreshPageState(tabId, pageUrl, options = {}) {
pendingMessage: tokenPendingApprovalCount(matches?.status ?? state.status) > 0 pendingMessage: tokenPendingApprovalCount(matches?.status ?? state.status) > 0
? approvalHintForState(state) || "Approve or deny the browser fill request in KeePassGO." ? approvalHintForState(state) || "Approve or deny the browser fill request in KeePassGO."
: "", : "",
matches: filteredMatches, matches: applyBestMatchOnly(matches?.matches, settings.bestMatchOnly),
error: matches?.error ?? "", error: matches?.error ?? "",
updatedAt: Date.now() updatedAt: Date.now()
}; };
@@ -918,7 +839,7 @@ async function saveObservedLogin(tabId, selectedMatch = null) {
} }
const backgroundTestExports = { const backgroundTestExports = {
applyMatchControls, applyBestMatchOnly,
normalizePageState, normalizePageState,
actionPresentationForState, actionPresentationForState,
shouldReuseMatches, shouldReuseMatches,
@@ -960,30 +881,22 @@ if (isNodeTestEnv) {
case "keepassgo-save-settings": case "keepassgo-save-settings":
await storageSet({ await storageSet({
bearerToken: String(message.settings?.bearerToken || "").trim(), bearerToken: String(message.settings?.bearerToken || "").trim(),
bestMatchOnly: Boolean(message.settings?.bestMatchOnly), bestMatchOnly: Boolean(message.settings?.bestMatchOnly)
requireSchemeMatch: Boolean(message.settings?.requireSchemeMatch),
sortResults: normalizeSortResults(message.settings?.sortResults)
}); });
await refreshActivePage({ force: true }).catch(() => null); await refreshActivePage({ force: true }).catch(() => null);
sendResponse({ success: true }); sendResponse({ success: true });
return; return;
case "keepassgo-search-logins": { case "keepassgo-search-logins": {
const settings = await loadSettings(); const settings = await loadSettings();
const page = await activePageContext();
const response = await connectNative({ const response = await connectNative({
action: "search-logins", action: "search-logins",
bearerToken: settings.bearerToken, bearerToken: settings.bearerToken,
query: String(message?.query || "").trim() query: String(message?.query || "").trim()
}); });
const searchResults = applyMatchControls(
Array.isArray(response?.searchResults) ? response.searchResults : [],
settings,
page.url
);
sendResponse({ sendResponse({
success: Boolean(response?.success), success: Boolean(response?.success),
error: response?.error || "", error: response?.error || "",
results: searchResults, results: applyBestMatchOnly(response?.searchResults, settings.bestMatchOnly),
status: response?.status ?? null status: response?.status ?? null
}); });
return; return;
+9 -34
View File
@@ -70,8 +70,6 @@ test("shouldContinueWatchingState keeps polling locked login pages", () => {
test("default settings include a blank bearer token that can be overridden by harness patching", () => { test("default settings include a blank bearer token that can be overridden by harness patching", () => {
assert.equal(background.defaultSettings.bearerToken, ""); assert.equal(background.defaultSettings.bearerToken, "");
assert.equal(background.defaultSettings.bestMatchOnly, false); 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", () => { test("savePlanForObservedLogin prefers updating an exact username match", () => {
@@ -133,44 +131,21 @@ test("savePlanForObservedLogin falls back to saving into the current page group"
}); });
}); });
test("applyMatchControls keeps only the strongest quality band when best-match-only is enabled", () => { test("applyBestMatchOnly keeps only the strongest quality band when enabled", () => {
const filtered = background.applyMatchControls([ const filtered = background.applyBestMatchOnly([
{ id: "livingston", title: "Livingston Dell", quality: "exact" }, { id: "livingston", title: "Livingston Dell", quality: "exact" },
{ id: "rusty", title: "Rusty Ryan", quality: "host" }, { id: "rusty", title: "Rusty Ryan", quality: "host" },
{ id: "linus", title: "Linus Caldwell", quality: "scheme" } { id: "linus", title: "Linus Caldwell", quality: "scheme" }
], { ], true);
bestMatchOnly: true,
requireSchemeMatch: false,
sortResults: "quality"
}, "https://vault.example.invalid/login");
assert.deepEqual(filtered.map((match) => match.id), ["livingston"]); assert.deepEqual(filtered.map((match) => match.id), ["livingston"]);
}); });
test("applyMatchControls removes explicit scheme mismatches but keeps scheme-less matches", () => { test("applyBestMatchOnly preserves all matches when disabled", () => {
const filtered = background.applyMatchControls([ const filtered = background.applyBestMatchOnly([
{ id: "yen", title: "The Amazing Yen", url: "https://vault.example.invalid/login", quality: "exact" }, { id: "livingston", title: "Livingston Dell", quality: "exact" },
{ id: "saul", title: "Saul Bloom", url: "http://vault.example.invalid/login", quality: "exact" }, { id: "rusty", title: "Rusty Ryan", quality: "host" }
{ id: "basher", title: "Basher Tarr", url: "vault.example.invalid", quality: "host" } ], false);
], {
bestMatchOnly: false,
requireSchemeMatch: true,
sortResults: "quality"
}, "https://vault.example.invalid/login");
assert.deepEqual(filtered.map((match) => match.id), ["yen", "basher"]); assert.deepEqual(filtered.map((match) => match.id), ["livingston", "rusty"]);
});
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"]);
}); });
-12
View File
@@ -25,18 +25,6 @@
<input id="best-match-only" name="best-match-only" type="checkbox"> <input id="best-match-only" name="best-match-only" type="checkbox">
<span>Best match only</span> <span>Best match only</span>
</label> </label>
<label class="checkbox-row">
<input id="require-scheme-match" name="require-scheme-match" type="checkbox">
<span>Require current scheme</span>
</label>
<label>
<span>Sort results</span>
<select id="sort-results" name="sort-results">
<option value="quality">KeePassGO match quality</option>
<option value="title">Title</option>
<option value="path">Path</option>
</select>
</label>
</fieldset> </fieldset>
<div class="actions"> <div class="actions">
<button type="submit">Save</button> <button type="submit">Save</button>
+1 -5
View File
@@ -24,8 +24,6 @@ async function loadSettings() {
} }
document.getElementById("bearer-token").value = response.settings.bearerToken || ""; document.getElementById("bearer-token").value = response.settings.bearerToken || "";
document.getElementById("best-match-only").checked = Boolean(response.settings.bestMatchOnly); 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) { async function saveSettings(event) {
@@ -37,9 +35,7 @@ async function saveSettings(event) {
type: "keepassgo-save-settings", type: "keepassgo-save-settings",
settings: { settings: {
bearerToken: document.getElementById("bearer-token").value, bearerToken: document.getElementById("bearer-token").value,
bestMatchOnly: document.getElementById("best-match-only").checked, bestMatchOnly: document.getElementById("best-match-only").checked
requireSchemeMatch: document.getElementById("require-scheme-match").checked,
sortResults: document.getElementById("sort-results").value
} }
}); });
if (!response?.success) { if (!response?.success) {
+1 -2
View File
@@ -177,8 +177,7 @@ label {
} }
input, input,
textarea, textarea {
select {
width: 100%; width: 100%;
padding: 10px 12px; padding: 10px 12px;
border: 1px solid var(--line); border: 1px solid var(--line);
+5 -17
View File
@@ -103,9 +103,8 @@ User story:
access. access.
- Browser matching must treat common KeePass data conventions as real browser - Browser matching must treat common KeePass data conventions as real browser
targets, not just the primary `URL` field. targets, not just the primary `URL` field.
- Users need explicit control over how aggressive browser matching should be so - Users who prefer narrow suggestions can ask the extension to show only the
they can prefer narrow page suggestions or broader candidate lists without strongest match quality returned by KeePassGO.
reconfiguring KeePassGO itself.
Expected behavior: Expected behavior:
@@ -117,20 +116,9 @@ Expected behavior:
- scheme-less host values such as `gitlab.com` - scheme-less host values such as `gitlab.com`
- custom URL fields such as `URL1`, `URL2`, and similar KeePass-style URL - custom URL fields such as `URL1`, `URL2`, and similar KeePass-style URL
slots slots
- The extension settings page exposes browser match controls for: - The extension settings page exposes `Best match only`.
- `Best match only` - When `Best match only` is enabled, page suggestions and popup search results
- `Require current scheme` only show the strongest quality band returned by KeePassGO.
- `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 ## Locked Vault Workflow