Compare commits

..

1 Commits

Author SHA1 Message Date
Joe Julian 0538cd2feb Add browser match controls
ci / lint-test (pull_request) Successful in 7m19s
ci / build (pull_request) Successful in 7m13s
2026-04-23 23:10:05 -07:00
6 changed files with 168 additions and 27 deletions
+98 -11
View File
@@ -4,7 +4,9 @@ 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;
@@ -174,13 +176,20 @@ function connectNative(message) {
} }
async function loadSettings() { async function loadSettings() {
const stored = await storageGet(["bearerToken", "bestMatchOnly"]); const stored = await storageGet(["bearerToken", "bestMatchOnly", "requireSchemeMatch", "sortResults"]);
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);
} }
@@ -240,6 +249,21 @@ 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":
@@ -253,17 +277,67 @@ function qualityRank(quality) {
} }
} }
function applyBestMatchOnly(matches, enabled) { function compareMatchText(left, right) {
if (!Array.isArray(matches) || !enabled) { return String(left || "").localeCompare(String(right || ""), undefined, { sensitivity: "base" });
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;
@@ -649,6 +723,11 @@ 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,
@@ -658,7 +737,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: applyBestMatchOnly(matches?.matches, settings.bestMatchOnly), matches: filteredMatches,
error: matches?.error ?? "", error: matches?.error ?? "",
updatedAt: Date.now() updatedAt: Date.now()
}; };
@@ -839,7 +918,7 @@ async function saveObservedLogin(tabId, selectedMatch = null) {
} }
const backgroundTestExports = { const backgroundTestExports = {
applyBestMatchOnly, applyMatchControls,
normalizePageState, normalizePageState,
actionPresentationForState, actionPresentationForState,
shouldReuseMatches, shouldReuseMatches,
@@ -881,22 +960,30 @@ 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: applyBestMatchOnly(response?.searchResults, settings.bestMatchOnly), results: searchResults,
status: response?.status ?? null status: response?.status ?? null
}); });
return; return;
+34 -9
View File
@@ -70,6 +70,8 @@ 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", () => {
@@ -131,21 +133,44 @@ test("savePlanForObservedLogin falls back to saving into the current page group"
}); });
}); });
test("applyBestMatchOnly keeps only the strongest quality band when enabled", () => { test("applyMatchControls keeps only the strongest quality band when best-match-only is enabled", () => {
const filtered = background.applyBestMatchOnly([ const filtered = background.applyMatchControls([
{ 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("applyBestMatchOnly preserves all matches when disabled", () => { test("applyMatchControls removes explicit scheme mismatches but keeps scheme-less matches", () => {
const filtered = background.applyBestMatchOnly([ const filtered = background.applyMatchControls([
{ id: "livingston", title: "Livingston Dell", quality: "exact" }, { id: "yen", title: "The Amazing Yen", url: "https://vault.example.invalid/login", quality: "exact" },
{ id: "rusty", title: "Rusty Ryan", quality: "host" } { id: "saul", title: "Saul Bloom", url: "http://vault.example.invalid/login", quality: "exact" },
], false); { 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), ["livingston", "rusty"]); 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"]);
}); });
+12
View File
@@ -25,6 +25,18 @@
<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>
+5 -1
View File
@@ -24,6 +24,8 @@ 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) {
@@ -35,7 +37,9 @@ 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) {
+2 -1
View File
@@ -177,7 +177,8 @@ 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);
+17 -5
View File
@@ -103,8 +103,9 @@ 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 who prefer narrow suggestions can ask the extension to show only the - Users need explicit control over how aggressive browser matching should be so
strongest match quality returned by KeePassGO. they can prefer narrow page suggestions or broader candidate lists without
reconfiguring KeePassGO itself.
Expected behavior: Expected behavior:
@@ -116,9 +117,20 @@ 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 `Best match only`. - The extension settings page exposes browser match controls for:
- When `Best match only` is enabled, page suggestions and popup search results - `Best match only`
only show the strongest quality band returned by KeePassGO. - `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 ## Locked Vault Workflow