Complete browser extension gRPC flow
This commit is contained in:
@@ -2,10 +2,26 @@
|
||||
|
||||
Shared extension assets for Firefox and Chromium-based browsers live here.
|
||||
|
||||
The Arch package installs this directory under `/usr/share/keepassgo/browser-extension/`. On Linux desktop builds, launching KeePassGO refreshes the user-scoped native messaging manifests for Firefox and for any installed Chrome or Chromium `KeePassGO Browser` extension ids it can discover from browser profiles.
|
||||
|
||||
- `manifest.firefox.json` uses the fixed Firefox extension id `browser@keepassgo.com`
|
||||
- `manifest.chromium.json` is the Chromium/Chrome manifest template
|
||||
- `background.js` talks to the native messaging host `com.keepassgo.browser`
|
||||
- `content.js` fills username and password fields on the current page
|
||||
- `background.js` caches per-tab match state, updates the toolbar badge, keeps token-scoped approval state visible, and talks to the native messaging host `com.keepassgo.browser`
|
||||
- `content.js` fills username and password fields on the current page, keeps fills tied to the focused form when possible, and shows inline KeePassGO field affordances when matches exist
|
||||
- `options.html` stores the local gRPC address and API token in browser extension storage
|
||||
|
||||
The extension sends the API token to the native host on each request. The bridge does not store the token on disk.
|
||||
|
||||
Quick extension-side checks:
|
||||
|
||||
```bash
|
||||
node --test browser/extension/background.test.cjs browser/extension/content.test.cjs
|
||||
```
|
||||
|
||||
Reproducible Chromium validation:
|
||||
|
||||
```bash
|
||||
make browser-extension-validate
|
||||
```
|
||||
|
||||
That command validates Firefox by default. Use `make browser-extension-validate BROWSER=chromium` for the Chromium harness.
|
||||
|
||||
+637
-82
@@ -1,11 +1,22 @@
|
||||
const ext = globalThis.browser ?? globalThis.chrome;
|
||||
const nativeHost = "com.keepassgo.browser";
|
||||
const isNodeTestEnv = typeof module !== "undefined" && module.exports;
|
||||
const usePromiseAPI = typeof globalThis.browser !== "undefined";
|
||||
const defaultSettings = {
|
||||
grpcAddress: "",
|
||||
bearerToken: ""
|
||||
};
|
||||
const pageStatePrefix = "keepassgo-page-state:";
|
||||
const matchCacheTTL = 30 * 1000;
|
||||
const pendingPollMillis = 1500;
|
||||
const pageStates = new Map();
|
||||
const refreshJobs = new Map();
|
||||
const pendingPollers = new Map();
|
||||
|
||||
function storageGet(keys) {
|
||||
if (usePromiseAPI) {
|
||||
return ext.storage.local.get(keys);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
ext.storage.local.get(keys, (value) => {
|
||||
const error = ext.runtime.lastError;
|
||||
@@ -19,6 +30,9 @@ function storageGet(keys) {
|
||||
}
|
||||
|
||||
function storageSet(value) {
|
||||
if (usePromiseAPI) {
|
||||
return ext.storage.local.set(value);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
ext.storage.local.set(value, () => {
|
||||
const error = ext.runtime.lastError;
|
||||
@@ -31,7 +45,74 @@ function storageSet(value) {
|
||||
});
|
||||
}
|
||||
|
||||
function sessionArea() {
|
||||
return ext.storage?.session ?? null;
|
||||
}
|
||||
|
||||
function sessionStorageGet(keys) {
|
||||
const area = sessionArea();
|
||||
if (!area) {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
if (usePromiseAPI) {
|
||||
return area.get(keys).then((value) => value || {});
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
area.get(keys, (value) => {
|
||||
const error = ext.runtime.lastError;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve(value || {});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sessionStorageSet(value) {
|
||||
const area = sessionArea();
|
||||
if (!area) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (usePromiseAPI) {
|
||||
return area.set(value);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
area.set(value, () => {
|
||||
const error = ext.runtime.lastError;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sessionStorageRemove(keys) {
|
||||
const area = sessionArea();
|
||||
if (!area) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (usePromiseAPI) {
|
||||
return area.remove(keys);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
area.remove(keys, () => {
|
||||
const error = ext.runtime.lastError;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function tabsQuery(query) {
|
||||
if (usePromiseAPI) {
|
||||
return ext.tabs.query(query);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
ext.tabs.query(query, (tabs) => {
|
||||
const error = ext.runtime.lastError;
|
||||
@@ -44,7 +125,26 @@ function tabsQuery(query) {
|
||||
});
|
||||
}
|
||||
|
||||
function tabsGet(tabId) {
|
||||
if (usePromiseAPI) {
|
||||
return ext.tabs.get(tabId);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
ext.tabs.get(tabId, (tab) => {
|
||||
const error = ext.runtime.lastError;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve(tab);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function tabsSendMessage(tabId, message) {
|
||||
if (usePromiseAPI) {
|
||||
return ext.tabs.sendMessage(tabId, message);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
ext.tabs.sendMessage(tabId, message, (response) => {
|
||||
const error = ext.runtime.lastError;
|
||||
@@ -58,6 +158,9 @@ function tabsSendMessage(tabId, message) {
|
||||
}
|
||||
|
||||
function connectNative(message) {
|
||||
if (usePromiseAPI) {
|
||||
return ext.runtime.sendNativeMessage(nativeHost, message);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
ext.runtime.sendNativeMessage(nativeHost, message, (response) => {
|
||||
const error = ext.runtime.lastError;
|
||||
@@ -74,122 +177,574 @@ async function loadSettings() {
|
||||
const stored = await storageGet(["grpcAddress", "bearerToken"]);
|
||||
return {
|
||||
grpcAddress: (stored.grpcAddress || defaultSettings.grpcAddress).trim(),
|
||||
bearerToken: (stored.bearerToken || "").trim()
|
||||
bearerToken: (stored.bearerToken || defaultSettings.bearerToken).trim()
|
||||
};
|
||||
}
|
||||
|
||||
function supportsPageStateURL(rawURL) {
|
||||
return typeof rawURL === "string" && /^https?:\/\//i.test(rawURL);
|
||||
}
|
||||
|
||||
function pageStateKey(tabId) {
|
||||
return `${pageStatePrefix}${String(tabId)}`;
|
||||
}
|
||||
|
||||
function cloneTarget(target) {
|
||||
return target && typeof target === "object" ? { ...target } : null;
|
||||
}
|
||||
|
||||
function normalizePageState(state) {
|
||||
return {
|
||||
tabId: Number.isInteger(state?.tabId) ? state.tabId : null,
|
||||
pageUrl: typeof state?.pageUrl === "string" ? state.pageUrl : "",
|
||||
configured: Boolean(state?.configured),
|
||||
success: state?.success !== false,
|
||||
status: state?.status ?? null,
|
||||
matches: Array.isArray(state?.matches) ? state.matches : [],
|
||||
error: typeof state?.error === "string" ? state.error : "",
|
||||
pageHasLoginForm: Boolean(state?.pageHasLoginForm),
|
||||
signature: typeof state?.signature === "string" ? state.signature : "",
|
||||
focusTarget: cloneTarget(state?.focusTarget),
|
||||
pendingFill: Boolean(state?.pendingFill),
|
||||
pendingEntryId: typeof state?.pendingEntryId === "string" ? state.pendingEntryId : "",
|
||||
pendingTarget: cloneTarget(state?.pendingTarget),
|
||||
pendingMessage: typeof state?.pendingMessage === "string" ? state.pendingMessage : "",
|
||||
lastFilledEntryId: typeof state?.lastFilledEntryId === "string" ? state.lastFilledEntryId : "",
|
||||
updatedAt: Number.isFinite(state?.updatedAt) ? state.updatedAt : 0
|
||||
};
|
||||
}
|
||||
|
||||
function defaultPageState(tabId, pageUrl) {
|
||||
return normalizePageState({
|
||||
tabId,
|
||||
pageUrl,
|
||||
configured: true,
|
||||
success: true,
|
||||
status: null,
|
||||
matches: [],
|
||||
error: "",
|
||||
pageHasLoginForm: false,
|
||||
signature: "",
|
||||
focusTarget: null,
|
||||
pendingFill: false,
|
||||
pendingEntryId: "",
|
||||
pendingTarget: null,
|
||||
pendingMessage: "",
|
||||
lastFilledEntryId: "",
|
||||
updatedAt: 0
|
||||
});
|
||||
}
|
||||
|
||||
async function getPageState(tabId, pageUrl) {
|
||||
if (!Number.isInteger(tabId)) {
|
||||
return defaultPageState(null, pageUrl || "");
|
||||
}
|
||||
const existing = pageStates.get(tabId);
|
||||
if (existing && (!pageUrl || existing.pageUrl === pageUrl)) {
|
||||
return normalizePageState(existing);
|
||||
}
|
||||
const stored = await sessionStorageGet(pageStateKey(tabId));
|
||||
const state = normalizePageState(stored[pageStateKey(tabId)] || defaultPageState(tabId, pageUrl || ""));
|
||||
if (pageUrl && state.pageUrl !== pageUrl) {
|
||||
return defaultPageState(tabId, pageUrl);
|
||||
}
|
||||
pageStates.set(tabId, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
async function setPageState(tabId, nextState) {
|
||||
const state = normalizePageState({ ...nextState, tabId });
|
||||
if (!Number.isInteger(tabId)) {
|
||||
return state;
|
||||
}
|
||||
pageStates.set(tabId, state);
|
||||
await sessionStorageSet({ [pageStateKey(tabId)]: state });
|
||||
await updateActionState(tabId, state);
|
||||
await notifyContentState(tabId, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function clearPendingPoll(tabId) {
|
||||
const timer = pendingPollers.get(tabId);
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
pendingPollers.delete(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearPageState(tabId) {
|
||||
if (!Number.isInteger(tabId)) {
|
||||
return;
|
||||
}
|
||||
pageStates.delete(tabId);
|
||||
refreshJobs.delete(tabId);
|
||||
clearPendingPoll(tabId);
|
||||
await sessionStorageRemove(pageStateKey(tabId));
|
||||
await clearActionState(tabId);
|
||||
}
|
||||
|
||||
function describeError(error) {
|
||||
return error instanceof Error ? error.message : String(error || "Unknown error");
|
||||
}
|
||||
|
||||
function approvalHintForState(state) {
|
||||
if (!state.pendingFill) {
|
||||
return "";
|
||||
}
|
||||
return state.pendingMessage || "Approve or deny the fill request in KeePassGO.";
|
||||
}
|
||||
|
||||
function schedulePendingPoll(tabId, pageUrl) {
|
||||
if (!Number.isInteger(tabId)) {
|
||||
return;
|
||||
}
|
||||
clearPendingPoll(tabId);
|
||||
const timer = setTimeout(() => {
|
||||
pendingPollers.delete(tabId);
|
||||
void refreshPageState(tabId, pageUrl, { force: true }).catch(() => null);
|
||||
}, pendingPollMillis);
|
||||
pendingPollers.set(tabId, timer);
|
||||
}
|
||||
|
||||
async function notifyContentState(tabId, state) {
|
||||
if (!Number.isInteger(tabId)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await tabsSendMessage(tabId, {
|
||||
type: "keepassgo-page-state",
|
||||
state
|
||||
});
|
||||
} catch (_error) {
|
||||
// Ignore pages without a ready content script.
|
||||
}
|
||||
}
|
||||
|
||||
async function clearActionState(tabId) {
|
||||
if (!Number.isInteger(tabId) || !ext.action) {
|
||||
return;
|
||||
}
|
||||
await Promise.allSettled([
|
||||
ext.action.setBadgeText({ tabId, text: "" }),
|
||||
ext.action.setTitle({ tabId, title: "KeePassGO Browser" })
|
||||
]);
|
||||
}
|
||||
|
||||
function actionPresentationForState(state) {
|
||||
let badgeText = "";
|
||||
let title = "KeePassGO Browser";
|
||||
let color = "#255f4a";
|
||||
|
||||
if (state.pendingFill) {
|
||||
badgeText = "!";
|
||||
color = "#9f5f0e";
|
||||
title = approvalHintForState(state) || "KeePassGO approval needed for this page";
|
||||
} else if (!state.configured) {
|
||||
title = "Configure KeePassGO Browser in extension settings";
|
||||
} else if (!state.success) {
|
||||
badgeText = "!";
|
||||
color = "#9f2f2f";
|
||||
title = state.error || "KeePassGO is unavailable for this page";
|
||||
} else if (state.status?.locked) {
|
||||
title = "Unlock KeePassGO to fill this page";
|
||||
} else if (state.pageHasLoginForm && state.matches.length > 0) {
|
||||
badgeText = String(Math.min(state.matches.length, 9));
|
||||
title = `KeePassGO found ${state.matches.length} matching entr${state.matches.length === 1 ? "y" : "ies"} on this page`;
|
||||
} else if (state.pageHasLoginForm) {
|
||||
title = "KeePassGO found no matching entries on this page";
|
||||
}
|
||||
|
||||
return { badgeText, title, color };
|
||||
}
|
||||
|
||||
async function updateActionState(tabId, state) {
|
||||
if (!Number.isInteger(tabId) || !ext.action) {
|
||||
return;
|
||||
}
|
||||
const presentation = actionPresentationForState(state);
|
||||
await Promise.allSettled([
|
||||
ext.action.setBadgeText({ tabId, text: presentation.badgeText }),
|
||||
ext.action.setBadgeBackgroundColor({ tabId, color: presentation.color }),
|
||||
ext.action.setTitle({ tabId, title: presentation.title })
|
||||
]);
|
||||
}
|
||||
|
||||
async function activePageContext() {
|
||||
const [tab] = await tabsQuery({ active: true, currentWindow: true });
|
||||
return {
|
||||
tabId: tab?.id ?? null,
|
||||
tabId: Number.isInteger(tab?.id) ? tab.id : null,
|
||||
url: typeof tab?.url === "string" ? tab.url : ""
|
||||
};
|
||||
}
|
||||
|
||||
async function statusForPage() {
|
||||
const settings = await loadSettings();
|
||||
const page = await activePageContext();
|
||||
async function scanTabForLoginForm(tabId) {
|
||||
if (!Number.isInteger(tabId)) {
|
||||
return { pageHasLoginForm: false, focusTarget: null, signature: "" };
|
||||
}
|
||||
try {
|
||||
const response = await tabsSendMessage(tabId, { type: "keepassgo-page-scan" });
|
||||
return {
|
||||
pageHasLoginForm: Boolean(response?.pageHasLoginForm),
|
||||
focusTarget: cloneTarget(response?.focusTarget),
|
||||
signature: typeof response?.signature === "string" ? response.signature : ""
|
||||
};
|
||||
} catch (_error) {
|
||||
return { pageHasLoginForm: false, focusTarget: null, signature: "" };
|
||||
}
|
||||
}
|
||||
|
||||
function shouldReuseMatches(state, force) {
|
||||
if (force || state.pendingFill) {
|
||||
return false;
|
||||
}
|
||||
if (!state.pageHasLoginForm || !Array.isArray(state.matches)) {
|
||||
return false;
|
||||
}
|
||||
return Date.now() - (state.updatedAt || 0) < matchCacheTTL;
|
||||
}
|
||||
|
||||
function tokenPendingApprovalCount(status) {
|
||||
return Number(status?.tokenPendingApprovalCount || 0);
|
||||
}
|
||||
|
||||
async function fetchStatus(settings) {
|
||||
if (!settings.bearerToken) {
|
||||
return {
|
||||
success: false,
|
||||
configured: false,
|
||||
status: null,
|
||||
pageUrl: page.url,
|
||||
matches: [],
|
||||
error: "Set an API token in extension settings."
|
||||
};
|
||||
}
|
||||
|
||||
const status = await connectNative({
|
||||
action: "status",
|
||||
grpcAddress: settings.grpcAddress,
|
||||
bearerToken: settings.bearerToken
|
||||
});
|
||||
if (!status.success || status.status?.locked || !page.url.startsWith("http")) {
|
||||
return {
|
||||
success: status.success,
|
||||
return {
|
||||
success: Boolean(status?.success),
|
||||
configured: true,
|
||||
status: status?.status ?? null,
|
||||
error: status?.error ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshPageState(tabId, pageUrl, options = {}) {
|
||||
if (!Number.isInteger(tabId)) {
|
||||
return defaultPageState(null, pageUrl || "");
|
||||
}
|
||||
const force = Boolean(options.force);
|
||||
const existingJob = refreshJobs.get(tabId);
|
||||
if (existingJob && !force) {
|
||||
return existingJob;
|
||||
}
|
||||
|
||||
const job = (async () => {
|
||||
let resolvedURL = typeof pageUrl === "string" ? pageUrl : "";
|
||||
if (!supportsPageStateURL(resolvedURL)) {
|
||||
const tab = await tabsGet(tabId).catch(() => null);
|
||||
resolvedURL = typeof tab?.url === "string" ? tab.url : resolvedURL;
|
||||
}
|
||||
if (!supportsPageStateURL(resolvedURL)) {
|
||||
await clearPageState(tabId);
|
||||
return defaultPageState(tabId, resolvedURL || "");
|
||||
}
|
||||
|
||||
let state = await getPageState(tabId, resolvedURL);
|
||||
if (state.pageUrl !== resolvedURL) {
|
||||
state = defaultPageState(tabId, resolvedURL);
|
||||
}
|
||||
|
||||
const scan = typeof options.pageHasLoginForm === "boolean"
|
||||
? {
|
||||
pageHasLoginForm: options.pageHasLoginForm,
|
||||
focusTarget: cloneTarget(options.focusTarget) || state.focusTarget,
|
||||
signature: typeof options.signature === "string" ? options.signature : state.signature
|
||||
}
|
||||
: await scanTabForLoginForm(tabId);
|
||||
|
||||
state = {
|
||||
...state,
|
||||
pageUrl: resolvedURL,
|
||||
pageHasLoginForm: scan.pageHasLoginForm,
|
||||
focusTarget: cloneTarget(scan.focusTarget) || state.focusTarget,
|
||||
signature: typeof scan.signature === "string" ? scan.signature : state.signature
|
||||
};
|
||||
|
||||
const settings = await loadSettings();
|
||||
const statusInfo = await fetchStatus(settings).catch((error) => ({
|
||||
success: false,
|
||||
configured: true,
|
||||
status: status.status ?? null,
|
||||
pageUrl: page.url,
|
||||
matches: [],
|
||||
error: status.error ?? ""
|
||||
status: null,
|
||||
error: describeError(error)
|
||||
}));
|
||||
|
||||
state = {
|
||||
...state,
|
||||
configured: statusInfo.configured,
|
||||
success: statusInfo.success,
|
||||
status: statusInfo.status,
|
||||
pendingFill: state.pendingFill || tokenPendingApprovalCount(statusInfo.status) > 0,
|
||||
pendingMessage: tokenPendingApprovalCount(statusInfo.status) > 0
|
||||
? approvalHintForState(state) || "Approve or deny the browser fill request in KeePassGO."
|
||||
: "",
|
||||
error: statusInfo.error
|
||||
};
|
||||
|
||||
if (!statusInfo.configured || !statusInfo.success || statusInfo.status?.locked || !state.pageHasLoginForm) {
|
||||
state.matches = [];
|
||||
state.updatedAt = Date.now();
|
||||
const saved = await setPageState(tabId, state);
|
||||
if (saved.pendingFill) {
|
||||
schedulePendingPoll(tabId, resolvedURL);
|
||||
} else {
|
||||
clearPendingPoll(tabId);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
if (shouldReuseMatches(state, force)) {
|
||||
const saved = await setPageState(tabId, state);
|
||||
if (saved.pendingFill) {
|
||||
schedulePendingPoll(tabId, resolvedURL);
|
||||
} else {
|
||||
clearPendingPoll(tabId);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
const matches = await connectNative({
|
||||
action: "find-logins",
|
||||
grpcAddress: settings.grpcAddress,
|
||||
bearerToken: settings.bearerToken,
|
||||
url: resolvedURL
|
||||
});
|
||||
|
||||
state = {
|
||||
...state,
|
||||
success: Boolean(matches?.success),
|
||||
status: matches?.status ?? state.status,
|
||||
pendingFill: state.pendingFill || tokenPendingApprovalCount(matches?.status ?? state.status) > 0,
|
||||
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 : [],
|
||||
error: matches?.error ?? "",
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
const saved = await setPageState(tabId, state);
|
||||
if (saved.pendingFill) {
|
||||
schedulePendingPoll(tabId, resolvedURL);
|
||||
} else {
|
||||
clearPendingPoll(tabId);
|
||||
}
|
||||
return saved;
|
||||
})().finally(() => {
|
||||
if (refreshJobs.get(tabId) === job) {
|
||||
refreshJobs.delete(tabId);
|
||||
}
|
||||
});
|
||||
|
||||
refreshJobs.set(tabId, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
async function statusForPage(options = {}) {
|
||||
let page = await activePageContext();
|
||||
if (Number.isInteger(options.tabId)) {
|
||||
const tab = await tabsGet(options.tabId).catch(() => null);
|
||||
page = {
|
||||
tabId: options.tabId,
|
||||
url: typeof tab?.url === "string" ? tab.url : ""
|
||||
};
|
||||
}
|
||||
|
||||
const matches = await connectNative({
|
||||
action: "find-logins",
|
||||
grpcAddress: settings.grpcAddress,
|
||||
bearerToken: settings.bearerToken,
|
||||
url: page.url
|
||||
});
|
||||
return {
|
||||
success: matches.success,
|
||||
configured: true,
|
||||
status: matches.status ?? status.status ?? null,
|
||||
pageUrl: page.url,
|
||||
matches: matches.matches ?? [],
|
||||
error: matches.error ?? ""
|
||||
};
|
||||
if (page.tabId == null) {
|
||||
return defaultPageState(null, page.url);
|
||||
}
|
||||
if (!options.force) {
|
||||
const cached = await getPageState(page.tabId, page.url);
|
||||
if (cached.pageUrl === page.url && cached.updatedAt && !cached.pendingFill) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
return refreshPageState(page.tabId, page.url, options);
|
||||
}
|
||||
|
||||
async function fillLogin(entryId) {
|
||||
const settings = await loadSettings();
|
||||
const page = await activePageContext();
|
||||
if (!settings.bearerToken) {
|
||||
throw new Error("API token is not configured.");
|
||||
}
|
||||
if (page.tabId == null) {
|
||||
async function fillLogin(tabId, entryId) {
|
||||
if (!Number.isInteger(tabId)) {
|
||||
throw new Error("No active tab is available.");
|
||||
}
|
||||
|
||||
const response = await connectNative({
|
||||
action: "get-login",
|
||||
grpcAddress: settings.grpcAddress,
|
||||
bearerToken: settings.bearerToken,
|
||||
entryId,
|
||||
url: page.url
|
||||
});
|
||||
if (!response.success || !response.credential) {
|
||||
throw new Error(response.error || "KeePassGO did not return a credential.");
|
||||
const tab = await tabsGet(tabId);
|
||||
const pageUrl = typeof tab?.url === "string" ? tab.url : "";
|
||||
if (!supportsPageStateURL(pageUrl)) {
|
||||
throw new Error("This page cannot be filled.");
|
||||
}
|
||||
|
||||
const fillResponse = await tabsSendMessage(page.tabId, {
|
||||
type: "keepassgo-fill-credential",
|
||||
credential: response.credential
|
||||
let state = await getPageState(tabId, pageUrl);
|
||||
state = await setPageState(tabId, {
|
||||
...state,
|
||||
pageUrl,
|
||||
pendingFill: true,
|
||||
pendingEntryId: String(entryId || "").trim(),
|
||||
pendingTarget: cloneTarget(state.focusTarget),
|
||||
pendingMessage: "Approve or deny the browser fill request in KeePassGO.",
|
||||
error: "",
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
if (!fillResponse?.ok) {
|
||||
throw new Error(fillResponse?.error || "The current page could not be filled.");
|
||||
schedulePendingPoll(tabId, pageUrl);
|
||||
|
||||
try {
|
||||
const settings = await loadSettings();
|
||||
if (!settings.bearerToken) {
|
||||
throw new Error("API token is not configured.");
|
||||
}
|
||||
|
||||
const response = await connectNative({
|
||||
action: "get-login",
|
||||
grpcAddress: settings.grpcAddress,
|
||||
bearerToken: settings.bearerToken,
|
||||
entryId,
|
||||
url: pageUrl
|
||||
});
|
||||
if (!response?.success || !response.credential) {
|
||||
throw new Error(response?.error || "KeePassGO did not return a credential.");
|
||||
}
|
||||
|
||||
const fillResponse = await tabsSendMessage(tabId, {
|
||||
type: "keepassgo-fill-credential",
|
||||
credential: response.credential,
|
||||
target: state.pendingTarget
|
||||
});
|
||||
if (!fillResponse?.ok) {
|
||||
throw new Error(fillResponse?.error || "The current page could not be filled.");
|
||||
}
|
||||
|
||||
state = await setPageState(tabId, {
|
||||
...state,
|
||||
pendingFill: false,
|
||||
pendingEntryId: "",
|
||||
pendingTarget: null,
|
||||
pendingMessage: "",
|
||||
lastFilledEntryId: String(entryId || "").trim(),
|
||||
error: "",
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
clearPendingPoll(tabId);
|
||||
return {
|
||||
credential: response.credential,
|
||||
pageUrl,
|
||||
state
|
||||
};
|
||||
} catch (error) {
|
||||
state = await setPageState(tabId, {
|
||||
...state,
|
||||
pendingFill: false,
|
||||
pendingEntryId: "",
|
||||
pendingTarget: null,
|
||||
pendingMessage: "",
|
||||
error: describeError(error),
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
clearPendingPoll(tabId);
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
credential: response.credential,
|
||||
pageUrl: page.url
|
||||
};
|
||||
}
|
||||
|
||||
ext.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
(async () => {
|
||||
switch (message?.type) {
|
||||
case "keepassgo-popup-state":
|
||||
sendResponse(await statusForPage());
|
||||
return;
|
||||
case "keepassgo-fill-entry":
|
||||
sendResponse({ success: true, ...(await fillLogin(message.entryId)) });
|
||||
return;
|
||||
case "keepassgo-load-settings":
|
||||
sendResponse({ success: true, settings: await loadSettings() });
|
||||
return;
|
||||
case "keepassgo-save-settings":
|
||||
await storageSet({
|
||||
grpcAddress: String(message.settings?.grpcAddress || defaultSettings.grpcAddress).trim(),
|
||||
bearerToken: String(message.settings?.bearerToken || "").trim()
|
||||
});
|
||||
sendResponse({ success: true });
|
||||
return;
|
||||
default:
|
||||
sendResponse({ success: false, error: `Unsupported message ${message?.type || ""}`.trim() });
|
||||
}
|
||||
})().catch((error) => {
|
||||
sendResponse({ success: false, error: error instanceof Error ? error.message : String(error) });
|
||||
async function refreshActivePage(options = {}) {
|
||||
const page = await activePageContext();
|
||||
if (page.tabId == null) {
|
||||
return defaultPageState(null, page.url);
|
||||
}
|
||||
return refreshPageState(page.tabId, page.url, options);
|
||||
}
|
||||
|
||||
const backgroundTestExports = {
|
||||
normalizePageState,
|
||||
actionPresentationForState,
|
||||
shouldReuseMatches,
|
||||
tokenPendingApprovalCount,
|
||||
defaultSettings
|
||||
};
|
||||
|
||||
if (isNodeTestEnv) {
|
||||
module.exports = backgroundTestExports;
|
||||
} else {
|
||||
ext.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
(async () => {
|
||||
switch (message?.type) {
|
||||
case "keepassgo-popup-state":
|
||||
sendResponse(await statusForPage({
|
||||
force: Boolean(message.force),
|
||||
tabId: Number.isInteger(message?.tabId) ? message.tabId : null
|
||||
}));
|
||||
return;
|
||||
case "keepassgo-fill-entry": {
|
||||
const targetTabID = Number.isInteger(message?.tabId)
|
||||
? message.tabId
|
||||
: (Number.isInteger(sender?.tab?.id) ? sender.tab.id : (await activePageContext()).tabId);
|
||||
if (Number.isInteger(targetTabID) && message.target) {
|
||||
const targetState = await getPageState(targetTabID, "");
|
||||
await setPageState(targetTabID, {
|
||||
...targetState,
|
||||
focusTarget: cloneTarget(message.target)
|
||||
});
|
||||
}
|
||||
sendResponse({ success: true, ...(await fillLogin(targetTabID, message.entryId)) });
|
||||
return;
|
||||
}
|
||||
case "keepassgo-load-settings":
|
||||
sendResponse({ success: true, settings: await loadSettings() });
|
||||
return;
|
||||
case "keepassgo-save-settings":
|
||||
await storageSet({
|
||||
grpcAddress: String(message.settings?.grpcAddress || defaultSettings.grpcAddress).trim(),
|
||||
bearerToken: String(message.settings?.bearerToken || "").trim()
|
||||
});
|
||||
await refreshActivePage({ force: true }).catch(() => null);
|
||||
sendResponse({ success: true });
|
||||
return;
|
||||
case "keepassgo-page-ready":
|
||||
if (Number.isInteger(sender?.tab?.id)) {
|
||||
sendResponse(await refreshPageState(sender.tab.id, sender.tab.url, {
|
||||
force: Boolean(message.force),
|
||||
pageHasLoginForm: Boolean(message.pageHasLoginForm),
|
||||
focusTarget: cloneTarget(message.focusTarget),
|
||||
signature: typeof message.signature === "string" ? message.signature : ""
|
||||
}));
|
||||
return;
|
||||
}
|
||||
sendResponse(defaultPageState(null, ""));
|
||||
return;
|
||||
case "keepassgo-refresh-page-state":
|
||||
if (Number.isInteger(sender?.tab?.id)) {
|
||||
sendResponse(await refreshPageState(sender.tab.id, sender.tab.url, { force: true }));
|
||||
return;
|
||||
}
|
||||
sendResponse(defaultPageState(null, ""));
|
||||
return;
|
||||
default:
|
||||
sendResponse({ success: false, error: `Unsupported message ${message?.type || ""}`.trim() });
|
||||
}
|
||||
})().catch((error) => {
|
||||
sendResponse({ success: false, error: describeError(error) });
|
||||
});
|
||||
return true;
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
ext.tabs?.onActivated?.addListener(({ tabId }) => {
|
||||
void refreshPageState(tabId, "", { force: false }).catch(() => null);
|
||||
});
|
||||
|
||||
ext.tabs?.onUpdated?.addListener((tabId, changeInfo, tab) => {
|
||||
if (typeof changeInfo.url === "string") {
|
||||
void clearPageState(tabId).catch(() => null);
|
||||
}
|
||||
if (changeInfo.status === "complete") {
|
||||
void refreshPageState(tabId, tab?.url || changeInfo.url || "", { force: false }).catch(() => null);
|
||||
}
|
||||
});
|
||||
|
||||
ext.tabs?.onRemoved?.addListener((tabId) => {
|
||||
void clearPageState(tabId).catch(() => null);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const background = require("./background.js");
|
||||
|
||||
test("normalizePageState preserves focused and pending field targets", () => {
|
||||
const state = background.normalizePageState({
|
||||
tabId: 7,
|
||||
pageUrl: "https://vault.example.invalid/login",
|
||||
focusTarget: { role: "username", formIndex: 0, fieldIndex: 1 },
|
||||
pendingTarget: { role: "password", formIndex: 0, fieldIndex: 2 }
|
||||
});
|
||||
|
||||
assert.deepEqual(state.focusTarget, { role: "username", formIndex: 0, fieldIndex: 1 });
|
||||
assert.deepEqual(state.pendingTarget, { role: "password", formIndex: 0, fieldIndex: 2 });
|
||||
});
|
||||
|
||||
test("shouldReuseMatches only reuses recent non-pending page matches", () => {
|
||||
const recentState = {
|
||||
pageHasLoginForm: true,
|
||||
matches: [{ id: "vault-console" }],
|
||||
pendingFill: false,
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
assert.equal(background.shouldReuseMatches(recentState, false), true);
|
||||
|
||||
assert.equal(background.shouldReuseMatches({ ...recentState, pendingFill: true }, false), false);
|
||||
assert.equal(background.shouldReuseMatches({ ...recentState, pageHasLoginForm: false }, false), false);
|
||||
assert.equal(background.shouldReuseMatches(recentState, true), false);
|
||||
});
|
||||
|
||||
test("actionPresentationForState prioritizes approval visibility", () => {
|
||||
const presentation = background.actionPresentationForState({
|
||||
pendingFill: true,
|
||||
pendingMessage: "Approve the browser fill request in KeePassGO.",
|
||||
configured: true,
|
||||
success: true,
|
||||
pageHasLoginForm: true,
|
||||
matches: [{ id: "vault-console" }]
|
||||
});
|
||||
|
||||
assert.equal(presentation.badgeText, "!");
|
||||
assert.equal(presentation.color, "#9f5f0e");
|
||||
assert.match(presentation.title, /approve/i);
|
||||
});
|
||||
|
||||
test("tokenPendingApprovalCount reads token-scoped approval state", () => {
|
||||
assert.equal(background.tokenPendingApprovalCount({ tokenPendingApprovalCount: 2 }), 2);
|
||||
assert.equal(background.tokenPendingApprovalCount({}), 0);
|
||||
});
|
||||
|
||||
test("default settings include a blank bearer token that can be overridden by harness patching", () => {
|
||||
assert.equal(background.defaultSettings.bearerToken, "");
|
||||
});
|
||||
+615
-26
@@ -1,3 +1,23 @@
|
||||
const ext = globalThis.browser ?? globalThis.chrome;
|
||||
const isNodeTestEnv = typeof module !== "undefined" && module.exports;
|
||||
const usePromiseAPI = typeof globalThis.browser !== "undefined";
|
||||
|
||||
function runtimeSend(message) {
|
||||
if (usePromiseAPI) {
|
||||
return ext.runtime.sendMessage(message);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
ext.runtime.sendMessage(message, (response) => {
|
||||
const error = ext.runtime.lastError;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isVisibleInput(input) {
|
||||
if (!(input instanceof HTMLInputElement)) {
|
||||
return false;
|
||||
@@ -12,6 +32,38 @@ function isVisibleInput(input) {
|
||||
return input.offsetParent !== null || style.position === "fixed";
|
||||
}
|
||||
|
||||
function normalizeRole(rawRole) {
|
||||
switch (String(rawRole || "").trim().toLowerCase()) {
|
||||
case "password":
|
||||
return "password";
|
||||
default:
|
||||
return "username";
|
||||
}
|
||||
}
|
||||
|
||||
function describeFieldRole(input) {
|
||||
const type = String(input?.getAttribute?.("type") || "").toLowerCase();
|
||||
if (type === "password") {
|
||||
return "password";
|
||||
}
|
||||
const autocomplete = String(input?.autocomplete || "").toLowerCase();
|
||||
if (autocomplete.includes("username") || autocomplete.includes("email")) {
|
||||
return "username";
|
||||
}
|
||||
return "username";
|
||||
}
|
||||
|
||||
function isUsernameCandidate(input) {
|
||||
if (!isVisibleInput(input)) {
|
||||
return false;
|
||||
}
|
||||
return describeFieldRole(input) === "username";
|
||||
}
|
||||
|
||||
function isPasswordCandidate(input) {
|
||||
return isVisibleInput(input) && describeFieldRole(input) === "password";
|
||||
}
|
||||
|
||||
function dispatchFillEvents(input) {
|
||||
if (typeof InputEvent === "function") {
|
||||
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: input.value, inputType: "insertText" }));
|
||||
@@ -31,31 +83,145 @@ function setInputValue(input, value) {
|
||||
input.value = value;
|
||||
}
|
||||
|
||||
function findPasswordInput() {
|
||||
return Array.from(document.querySelectorAll('input[type="password"]')).find(isVisibleInput) || null;
|
||||
function visibleInputs(scope) {
|
||||
return Array.from(scope.querySelectorAll("input")).filter(isVisibleInput);
|
||||
}
|
||||
|
||||
function findUsernameInput(passwordInput) {
|
||||
const form = passwordInput?.form || null;
|
||||
function resolveFormInputs(anchorInput) {
|
||||
if (anchorInput?.form instanceof HTMLFormElement) {
|
||||
return visibleInputs(anchorInput.form);
|
||||
}
|
||||
return visibleInputs(document);
|
||||
}
|
||||
|
||||
function firstVisiblePassword(scope) {
|
||||
return visibleInputs(scope).find(isPasswordCandidate) || null;
|
||||
}
|
||||
|
||||
function firstVisibleUsername(scope) {
|
||||
return visibleInputs(scope).find(isUsernameCandidate) || null;
|
||||
}
|
||||
|
||||
function associatedFieldsForAnchor(anchorInput) {
|
||||
const scopeInputs = resolveFormInputs(anchorInput);
|
||||
const passwordInput = scopeInputs.find(isPasswordCandidate) || firstVisiblePassword(document);
|
||||
const usernameInScope = scopeInputs.filter(isUsernameCandidate);
|
||||
let usernameInput = usernameInScope[0] || null;
|
||||
if (passwordInput && usernameInScope.length !== 0) {
|
||||
const priorSibling = usernameInScope.find((input) =>
|
||||
typeof input.compareDocumentPosition === "function" &&
|
||||
Boolean(input.compareDocumentPosition(passwordInput) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
);
|
||||
usernameInput = priorSibling || usernameInScope[0] || null;
|
||||
}
|
||||
if (!usernameInput) {
|
||||
usernameInput = firstVisibleUsername(document);
|
||||
}
|
||||
return { usernameInput, passwordInput };
|
||||
}
|
||||
|
||||
function buildFieldDescriptor(input, role) {
|
||||
if (!(input instanceof HTMLInputElement)) {
|
||||
return null;
|
||||
}
|
||||
const normalizedRole = normalizeRole(role || describeFieldRole(input));
|
||||
const form = input.form instanceof HTMLFormElement ? input.form : null;
|
||||
const scope = form || document;
|
||||
const candidates = Array.from(scope.querySelectorAll('input[type="text"], input[type="email"], input:not([type]), input[autocomplete="username"], input[autocomplete="email"]'))
|
||||
.filter(isVisibleInput);
|
||||
if (passwordInput) {
|
||||
const sameForm = candidates.filter((input) => input.form === passwordInput.form);
|
||||
if (sameForm.length !== 0) {
|
||||
const priorSibling = sameForm.find((input) =>
|
||||
typeof input.compareDocumentPosition === "function" &&
|
||||
Boolean(input.compareDocumentPosition(passwordInput) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
);
|
||||
return priorSibling || sameForm[0];
|
||||
const inputs = visibleInputs(scope);
|
||||
const fieldIndex = inputs.indexOf(input);
|
||||
const forms = Array.from(document.forms || []);
|
||||
return {
|
||||
role: normalizedRole,
|
||||
formIndex: form ? forms.indexOf(form) : -1,
|
||||
fieldIndex,
|
||||
id: String(input.id || ""),
|
||||
name: String(input.name || ""),
|
||||
autocomplete: String(input.autocomplete || "").toLowerCase()
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFieldDescriptor(descriptor) {
|
||||
if (!descriptor || typeof descriptor !== "object") {
|
||||
return null;
|
||||
}
|
||||
const normalizedRole = normalizeRole(descriptor.role);
|
||||
const forms = Array.from(document.forms || []);
|
||||
const form = Number.isInteger(descriptor.formIndex) && descriptor.formIndex >= 0 ? forms[descriptor.formIndex] || null : null;
|
||||
const scope = form || document;
|
||||
const inputs = visibleInputs(scope);
|
||||
if (Number.isInteger(descriptor.fieldIndex) && descriptor.fieldIndex >= 0 && descriptor.fieldIndex < inputs.length) {
|
||||
const candidate = inputs[descriptor.fieldIndex];
|
||||
if (describeFieldRole(candidate) === normalizedRole) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return candidates[0] || null;
|
||||
if (descriptor.id) {
|
||||
const byID = scope.querySelector(`#${CSS.escape(descriptor.id)}`);
|
||||
if (byID instanceof HTMLInputElement && isVisibleInput(byID) && describeFieldRole(byID) === normalizedRole) {
|
||||
return byID;
|
||||
}
|
||||
}
|
||||
if (descriptor.name) {
|
||||
const byName = visibleInputs(scope).find((input) => input.name === descriptor.name && describeFieldRole(input) === normalizedRole);
|
||||
if (byName) {
|
||||
return byName;
|
||||
}
|
||||
}
|
||||
if (normalizedRole === "password") {
|
||||
return firstVisiblePassword(scope) || firstVisiblePassword(document);
|
||||
}
|
||||
return firstVisibleUsername(scope) || firstVisibleUsername(document);
|
||||
}
|
||||
|
||||
function fillCredential(credential) {
|
||||
const passwordInput = findPasswordInput();
|
||||
const usernameInput = findUsernameInput(passwordInput);
|
||||
function chooseFillTargets(targetDescriptor) {
|
||||
const anchorInput = resolveFieldDescriptor(targetDescriptor) || (document.activeElement instanceof HTMLInputElement ? document.activeElement : null);
|
||||
const associated = associatedFieldsForAnchor(anchorInput);
|
||||
if (normalizeRole(targetDescriptor?.role) === "password" && anchorInput instanceof HTMLInputElement) {
|
||||
return {
|
||||
usernameInput: associated.usernameInput,
|
||||
passwordInput: isPasswordCandidate(anchorInput) ? anchorInput : associated.passwordInput,
|
||||
anchorInput
|
||||
};
|
||||
}
|
||||
if (normalizeRole(targetDescriptor?.role) === "username" && anchorInput instanceof HTMLInputElement) {
|
||||
return {
|
||||
usernameInput: isUsernameCandidate(anchorInput) ? anchorInput : associated.usernameInput,
|
||||
passwordInput: associated.passwordInput,
|
||||
anchorInput
|
||||
};
|
||||
}
|
||||
return {
|
||||
usernameInput: associated.usernameInput,
|
||||
passwordInput: associated.passwordInput,
|
||||
anchorInput: anchorInput || associated.passwordInput || associated.usernameInput || null
|
||||
};
|
||||
}
|
||||
|
||||
function scanLoginFields() {
|
||||
const activeElement = document.activeElement instanceof HTMLInputElement ? document.activeElement : null;
|
||||
const activeUsable = activeElement && isVisibleInput(activeElement) ? activeElement : null;
|
||||
const targets = chooseFillTargets(buildFieldDescriptor(activeUsable, describeFieldRole(activeUsable)));
|
||||
const anchorInput = activeUsable || targets.passwordInput || targets.usernameInput;
|
||||
const focusTarget = buildFieldDescriptor(anchorInput, describeFieldRole(anchorInput));
|
||||
const allVisible = visibleInputs(document);
|
||||
const roles = allVisible
|
||||
.filter((input) => isUsernameCandidate(input) || isPasswordCandidate(input))
|
||||
.map((input) => {
|
||||
const descriptor = buildFieldDescriptor(input, describeFieldRole(input));
|
||||
return `${descriptor.formIndex}:${descriptor.fieldIndex}:${descriptor.role}`;
|
||||
});
|
||||
return {
|
||||
pageHasLoginForm: Boolean(targets.usernameInput || targets.passwordInput),
|
||||
usernameInput: targets.usernameInput,
|
||||
passwordInput: targets.passwordInput,
|
||||
anchorInput,
|
||||
focusTarget,
|
||||
signature: roles.join("|")
|
||||
};
|
||||
}
|
||||
|
||||
function fillCredential(credential, targetDescriptor) {
|
||||
const { passwordInput, usernameInput } = chooseFillTargets(targetDescriptor);
|
||||
|
||||
if (usernameInput && credential.username) {
|
||||
usernameInput.focus();
|
||||
@@ -74,14 +240,437 @@ function fillCredential(credential) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
(globalThis.browser ?? globalThis.chrome).runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
if (message?.type !== "keepassgo-fill-credential") {
|
||||
function domainLabel(rawURL) {
|
||||
try {
|
||||
return new URL(rawURL).host || "";
|
||||
} catch (_error) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function inlineMatchSummary(match) {
|
||||
const parts = [];
|
||||
if (match.username) {
|
||||
parts.push(match.username);
|
||||
}
|
||||
if (match.url) {
|
||||
const host = domainLabel(match.url);
|
||||
if (host) {
|
||||
parts.push(host);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(match.path) && match.path.length !== 0) {
|
||||
parts.push(match.path.join(" / "));
|
||||
}
|
||||
return parts.join(" · ") || "No username";
|
||||
}
|
||||
|
||||
function shouldShowInlineOverlay(state, hasTarget, suppressed) {
|
||||
if (suppressed || !hasTarget) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
sendResponse(fillCredential(message.credential || {}));
|
||||
} catch (error) {
|
||||
sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
||||
return Boolean(
|
||||
state?.pageHasLoginForm &&
|
||||
(
|
||||
state?.pendingFill ||
|
||||
(state?.configured && state?.success && !state?.status?.locked && Array.isArray(state?.matches) && state.matches.length > 0)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const contentTestExports = {
|
||||
normalizeRole,
|
||||
describeFieldRole,
|
||||
buildFieldDescriptor,
|
||||
resolveFieldDescriptor,
|
||||
chooseFillTargets,
|
||||
inlineMatchSummary,
|
||||
domainLabel,
|
||||
shouldShowInlineOverlay
|
||||
};
|
||||
|
||||
if (isNodeTestEnv) {
|
||||
module.exports = contentTestExports;
|
||||
} else {
|
||||
let pageState = {
|
||||
configured: true,
|
||||
success: true,
|
||||
matches: [],
|
||||
pageHasLoginForm: false,
|
||||
pendingFill: false,
|
||||
error: "",
|
||||
focusTarget: null
|
||||
};
|
||||
let chooserOpen = false;
|
||||
let inlineSuppressed = false;
|
||||
let refreshTimer = null;
|
||||
let lastReportedSignature = "";
|
||||
let lastReportedTarget = "";
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.id = "keepassgo-inline-root";
|
||||
root.setAttribute("aria-live", "polite");
|
||||
const shadow = root.attachShadow({ mode: "open" });
|
||||
shadow.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
all: initial;
|
||||
}
|
||||
.dock {
|
||||
position: fixed;
|
||||
z-index: 2147483647;
|
||||
display: none;
|
||||
min-width: 220px;
|
||||
max-width: min(340px, calc(100vw - 24px));
|
||||
font: 13px/1.35 "Noto Sans", "Liberation Sans", sans-serif;
|
||||
color: #214f44;
|
||||
}
|
||||
.dock[data-open="true"] .panel {
|
||||
display: block;
|
||||
}
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #c6d8cf;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, #ffffff, #edf5f0);
|
||||
color: #214f44;
|
||||
box-shadow: 0 12px 26px rgba(33, 79, 68, 0.16);
|
||||
cursor: pointer;
|
||||
}
|
||||
.trigger[data-tone="warning"] {
|
||||
border-color: #e4d0ae;
|
||||
background: linear-gradient(180deg, #fff8ed, #f9edd6);
|
||||
color: #7f4b09;
|
||||
}
|
||||
.trigger[data-tone="error"] {
|
||||
border-color: #e4bcbc;
|
||||
background: linear-gradient(180deg, #fff5f5, #f9e7e7);
|
||||
color: #8c2f2f;
|
||||
}
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.meta {
|
||||
color: #4d6d66;
|
||||
font-size: 12px;
|
||||
}
|
||||
.panel {
|
||||
display: none;
|
||||
margin-top: 8px;
|
||||
border: 1px solid #d7e3dc;
|
||||
border-radius: 16px;
|
||||
background: #fffdfa;
|
||||
box-shadow: 0 18px 42px rgba(33, 79, 68, 0.22);
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-header {
|
||||
padding: 12px 14px 10px;
|
||||
border-bottom: 1px solid #e6efea;
|
||||
background: linear-gradient(180deg, #f8fbf8, #f1f6f3);
|
||||
}
|
||||
.panel-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
.panel-copy {
|
||||
margin-top: 4px;
|
||||
color: #4d6d66;
|
||||
font-size: 12px;
|
||||
}
|
||||
.match-list {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
}
|
||||
.match {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 0;
|
||||
border-top: 1px solid #eef4f0;
|
||||
background: #fffdfa;
|
||||
color: #214f44;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.match:hover,
|
||||
.match:focus-visible {
|
||||
background: #edf5f0;
|
||||
outline: none;
|
||||
}
|
||||
.match strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: #e7f1eb;
|
||||
color: #255f4a;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.subtle {
|
||||
color: #4d6d66;
|
||||
font-size: 12px;
|
||||
}
|
||||
.empty {
|
||||
padding: 12px 14px;
|
||||
color: #4d6d66;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
<div class="dock" data-open="false">
|
||||
<button type="button" class="trigger" data-tone="ready">
|
||||
<span class="brand">KeePassGO</span>
|
||||
<span class="meta">Checking this form</span>
|
||||
</button>
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title">KeePassGO suggestions</div>
|
||||
<div class="panel-copy">Select a matching login for this field.</div>
|
||||
</div>
|
||||
<div class="match-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const dock = shadow.querySelector(".dock");
|
||||
const trigger = shadow.querySelector(".trigger");
|
||||
const meta = shadow.querySelector(".meta");
|
||||
const matchList = shadow.querySelector(".match-list");
|
||||
const panelCopy = shadow.querySelector(".panel-copy");
|
||||
|
||||
function ensureRootMounted() {
|
||||
if (!root.isConnected) {
|
||||
document.documentElement.appendChild(root);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
function currentTarget() {
|
||||
return chooseFillTargets(pageState.focusTarget).anchorInput;
|
||||
}
|
||||
|
||||
function hideDock() {
|
||||
chooserOpen = false;
|
||||
dock.style.display = "none";
|
||||
dock.dataset.open = "false";
|
||||
}
|
||||
|
||||
function positionDock() {
|
||||
const anchor = currentTarget();
|
||||
if (!anchor || dock.style.display === "none") {
|
||||
return;
|
||||
}
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const width = Math.min(340, Math.max(220, rect.width));
|
||||
const left = Math.min(window.innerWidth - width - 12, Math.max(12, rect.left));
|
||||
const top = Math.min(window.innerHeight - 16, rect.bottom + 8);
|
||||
dock.style.left = `${left}px`;
|
||||
dock.style.top = `${top}px`;
|
||||
dock.style.width = `${width}px`;
|
||||
}
|
||||
|
||||
function renderMatches() {
|
||||
matchList.textContent = "";
|
||||
if (pageState.pendingFill) {
|
||||
const pending = document.createElement("div");
|
||||
pending.className = "empty";
|
||||
pending.textContent = pageState.pendingMessage || "Approve or deny the fill request in KeePassGO.";
|
||||
matchList.appendChild(pending);
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(pageState.matches) || pageState.matches.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "empty";
|
||||
empty.textContent = pageState.error || "No matching entries were found for this page.";
|
||||
matchList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const match of pageState.matches) {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "match";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = match.title;
|
||||
const summary = document.createElement("span");
|
||||
summary.className = "subtle";
|
||||
summary.textContent = inlineMatchSummary(match);
|
||||
const quality = document.createElement("span");
|
||||
quality.className = "pill";
|
||||
quality.textContent = match.quality || "Candidate";
|
||||
row.appendChild(title);
|
||||
row.appendChild(summary);
|
||||
row.appendChild(quality);
|
||||
row.addEventListener("click", async () => {
|
||||
row.disabled = true;
|
||||
inlineSuppressed = true;
|
||||
hideDock();
|
||||
try {
|
||||
await runtimeSend({
|
||||
type: "keepassgo-fill-entry",
|
||||
entryId: match.id,
|
||||
target: pageState.focusTarget
|
||||
});
|
||||
} catch (_error) {
|
||||
pageState = {
|
||||
...pageState,
|
||||
pendingFill: false,
|
||||
error: "KeePassGO could not fill this page."
|
||||
};
|
||||
renderInlineState();
|
||||
} finally {
|
||||
row.disabled = false;
|
||||
}
|
||||
});
|
||||
matchList.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function renderInlineState() {
|
||||
const target = currentTarget();
|
||||
const shouldShow = shouldShowInlineOverlay(pageState, Boolean(target), inlineSuppressed);
|
||||
|
||||
if (!shouldShow) {
|
||||
hideDock();
|
||||
return;
|
||||
}
|
||||
|
||||
ensureRootMounted();
|
||||
dock.style.display = "block";
|
||||
trigger.dataset.tone = pageState.pendingFill ? "warning" : (pageState.error ? "error" : "ready");
|
||||
if (pageState.pendingFill) {
|
||||
meta.textContent = "Approval needed in KeePassGO";
|
||||
panelCopy.textContent = pageState.pendingMessage || "Approve or deny the fill request in KeePassGO.";
|
||||
} else {
|
||||
const count = Array.isArray(pageState.matches) ? pageState.matches.length : 0;
|
||||
meta.textContent = count === 1 ? "1 login ready" : `${count} logins ready`;
|
||||
panelCopy.textContent = "Select a matching login for this field.";
|
||||
}
|
||||
dock.dataset.open = chooserOpen ? "true" : "false";
|
||||
renderMatches();
|
||||
positionDock();
|
||||
}
|
||||
|
||||
function reportFieldState(force) {
|
||||
const scan = scanLoginFields();
|
||||
pageState = {
|
||||
...pageState,
|
||||
pageHasLoginForm: scan.pageHasLoginForm,
|
||||
focusTarget: scan.focusTarget
|
||||
};
|
||||
renderInlineState();
|
||||
const nextTarget = JSON.stringify(scan.focusTarget || null);
|
||||
if (!force && scan.signature === lastReportedSignature && nextTarget === lastReportedTarget) {
|
||||
return;
|
||||
}
|
||||
lastReportedSignature = scan.signature;
|
||||
lastReportedTarget = nextTarget;
|
||||
void runtimeSend({
|
||||
type: "keepassgo-page-ready",
|
||||
force: Boolean(force),
|
||||
pageHasLoginForm: scan.pageHasLoginForm,
|
||||
focusTarget: scan.focusTarget,
|
||||
signature: scan.signature
|
||||
}).then((response) => {
|
||||
if (response && typeof response === "object" && !("success" in response && response.success === false)) {
|
||||
pageState = {
|
||||
...pageState,
|
||||
...response,
|
||||
pageHasLoginForm: Boolean(response.pageHasLoginForm),
|
||||
focusTarget: response.focusTarget || pageState.focusTarget
|
||||
};
|
||||
renderInlineState();
|
||||
}
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
function scheduleRefresh(force) {
|
||||
if (refreshTimer !== null) {
|
||||
clearTimeout(refreshTimer);
|
||||
}
|
||||
refreshTimer = window.setTimeout(() => {
|
||||
refreshTimer = null;
|
||||
reportFieldState(force);
|
||||
}, force ? 0 : 120);
|
||||
}
|
||||
|
||||
trigger.addEventListener("click", () => {
|
||||
chooserOpen = !chooserOpen;
|
||||
renderInlineState();
|
||||
});
|
||||
|
||||
document.addEventListener("focusin", () => {
|
||||
scheduleRefresh(false);
|
||||
});
|
||||
|
||||
document.addEventListener("input", () => {
|
||||
scheduleRefresh(false);
|
||||
}, true);
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!root.contains(event.target)) {
|
||||
chooserOpen = false;
|
||||
renderInlineState();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("scroll", () => {
|
||||
positionDock();
|
||||
}, true);
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
positionDock();
|
||||
});
|
||||
|
||||
const observer = new MutationObserver((records) => {
|
||||
if (records.some((record) => record.type === "childList")) {
|
||||
scheduleRefresh(false);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
ext.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
if (message?.type === "keepassgo-fill-credential") {
|
||||
try {
|
||||
sendResponse(fillCredential(message.credential || {}, message.target || pageState.focusTarget));
|
||||
} catch (error) {
|
||||
sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (message?.type === "keepassgo-page-state") {
|
||||
pageState = {
|
||||
...pageState,
|
||||
...(message.state || {})
|
||||
};
|
||||
renderInlineState();
|
||||
sendResponse({ ok: true });
|
||||
return false;
|
||||
}
|
||||
if (message?.type === "keepassgo-page-scan") {
|
||||
const scan = scanLoginFields();
|
||||
sendResponse({
|
||||
pageHasLoginForm: scan.pageHasLoginForm,
|
||||
focusTarget: scan.focusTarget,
|
||||
signature: scan.signature
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
reportFieldState(true);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const content = require("./content.js");
|
||||
|
||||
test("inlineMatchSummary includes username, host, and path context", () => {
|
||||
const summary = content.inlineMatchSummary({
|
||||
username: "dannyocean",
|
||||
url: "https://vault.example.invalid/login",
|
||||
path: ["Root", "Crew"]
|
||||
});
|
||||
|
||||
assert.equal(summary, "dannyocean · vault.example.invalid · Root / Crew");
|
||||
});
|
||||
|
||||
test("domainLabel tolerates invalid URLs", () => {
|
||||
assert.equal(content.domainLabel("https://vault.example.invalid"), "vault.example.invalid");
|
||||
assert.equal(content.domainLabel("not-a-url"), "");
|
||||
});
|
||||
|
||||
test("shouldShowInlineOverlay hides the page overlay after it is suppressed", () => {
|
||||
const state = {
|
||||
pageHasLoginForm: true,
|
||||
configured: true,
|
||||
success: true,
|
||||
status: { locked: false },
|
||||
matches: [{ id: "vault-console" }],
|
||||
pendingFill: false
|
||||
};
|
||||
|
||||
assert.equal(content.shouldShowInlineOverlay(state, true, false), true);
|
||||
assert.equal(content.shouldShowInlineOverlay(state, true, true), false);
|
||||
});
|
||||
@@ -1,15 +1,20 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"manifest_version": 2,
|
||||
"name": "KeePassGO Browser",
|
||||
"version": "0.1.0",
|
||||
"description": "Fill credentials from KeePassGO over the local gRPC API.",
|
||||
"permissions": ["activeTab", "nativeMessaging", "storage", "tabs"],
|
||||
"host_permissions": ["http://*/*", "https://*/*"],
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"nativeMessaging",
|
||||
"storage",
|
||||
"tabs",
|
||||
"http://*/*",
|
||||
"https://*/*"
|
||||
],
|
||||
"background": {
|
||||
"scripts": ["background.js"],
|
||||
"service_worker": "background.js"
|
||||
"scripts": ["background.js"]
|
||||
},
|
||||
"action": {
|
||||
"browser_action": {
|
||||
"default_title": "KeePassGO Browser",
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
const extOptions = globalThis.browser ?? globalThis.chrome;
|
||||
const usePromiseAPI = typeof globalThis.browser !== "undefined";
|
||||
|
||||
function runtimeSend(message) {
|
||||
if (usePromiseAPI) {
|
||||
return extOptions.runtime.sendMessage(message);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
extOptions.runtime.sendMessage(message, (response) => {
|
||||
const error = extOptions.runtime.lastError;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<strong id="status-title">Loading</strong>
|
||||
<p id="status-message" class="subtle">Checking KeePassGO.</p>
|
||||
</section>
|
||||
<p id="page-hint" class="inline-hint subtle">Loading page state.</p>
|
||||
<section>
|
||||
<h2>Matches</h2>
|
||||
<div id="matches" class="match-list"></div>
|
||||
|
||||
+86
-14
@@ -1,6 +1,10 @@
|
||||
const extPopup = globalThis.browser ?? globalThis.chrome;
|
||||
const usePromiseAPI = typeof globalThis.browser !== "undefined";
|
||||
|
||||
function runtimeSend(message) {
|
||||
if (usePromiseAPI) {
|
||||
return extPopup.runtime.sendMessage(message);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
extPopup.runtime.sendMessage(message, (response) => {
|
||||
const error = extPopup.runtime.lastError;
|
||||
@@ -28,13 +32,27 @@ function setStatus(title, message, tone) {
|
||||
document.getElementById("status-message").textContent = message;
|
||||
}
|
||||
|
||||
function matchSubtitle(match) {
|
||||
const parts = [];
|
||||
if (match.username) {
|
||||
parts.push(match.username);
|
||||
}
|
||||
if (Array.isArray(match.path) && match.path.length !== 0) {
|
||||
parts.push(match.path.join(" / "));
|
||||
}
|
||||
return parts.join(" · ") || "No username";
|
||||
}
|
||||
|
||||
function renderMatches(state) {
|
||||
const root = document.getElementById("matches");
|
||||
const targetTabID = popupTabID();
|
||||
root.textContent = "";
|
||||
if (!Array.isArray(state.matches) || state.matches.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "subtle";
|
||||
empty.textContent = "No matching entries for this page.";
|
||||
empty.textContent = state.pageHasLoginForm
|
||||
? "No matching entries for this page."
|
||||
: "No login fields detected on this page.";
|
||||
root.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
@@ -43,17 +61,29 @@ function renderMatches(state) {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "match-row";
|
||||
row.innerHTML = `
|
||||
<span class="match-main">
|
||||
<strong>${match.title}</strong>
|
||||
<span class="subtle">${match.username || "No username"}</span>
|
||||
</span>
|
||||
<span class="quality">${match.quality || ""}</span>
|
||||
`;
|
||||
const main = document.createElement("span");
|
||||
main.className = "match-main";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = match.title;
|
||||
const subtitle = document.createElement("span");
|
||||
subtitle.className = "subtle";
|
||||
subtitle.textContent = matchSubtitle(match);
|
||||
const quality = document.createElement("span");
|
||||
quality.className = "quality";
|
||||
quality.textContent = match.quality || "";
|
||||
main.appendChild(title);
|
||||
main.appendChild(subtitle);
|
||||
row.appendChild(main);
|
||||
row.appendChild(quality);
|
||||
row.addEventListener("click", async () => {
|
||||
row.disabled = true;
|
||||
setStatus("Approval may be required", "KeePassGO will prompt if this token needs approval before fill.", "warning");
|
||||
try {
|
||||
const result = await runtimeSend({ type: "keepassgo-fill-entry", entryId: match.id });
|
||||
const result = await runtimeSend({
|
||||
type: "keepassgo-fill-entry",
|
||||
entryId: match.id,
|
||||
tabId: targetTabID
|
||||
});
|
||||
if (!result?.success) {
|
||||
throw new Error(result?.error || "Fill failed.");
|
||||
}
|
||||
@@ -68,29 +98,71 @@ function renderMatches(state) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderPageHint(state) {
|
||||
const hint = document.getElementById("page-hint");
|
||||
if (state.pendingFill) {
|
||||
hint.textContent = "Approval is pending in KeePassGO.";
|
||||
return;
|
||||
}
|
||||
if (state.pageHasLoginForm && Array.isArray(state.matches) && state.matches.length > 0) {
|
||||
hint.textContent = "Inline KeePassGO suggestions are available on the page.";
|
||||
return;
|
||||
}
|
||||
if (state.pageHasLoginForm) {
|
||||
hint.textContent = "KeePassGO checked this login form already.";
|
||||
return;
|
||||
}
|
||||
hint.textContent = "Open a sign-in page to see KeePassGO suggestions here.";
|
||||
}
|
||||
|
||||
function popupTabID() {
|
||||
const rawValue = new URLSearchParams(window.location.search).get("tabId");
|
||||
if (rawValue === null) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseInt(rawValue, 10);
|
||||
return Number.isInteger(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const state = await runtimeSend({ type: "keepassgo-popup-state" });
|
||||
const state = await runtimeSend({
|
||||
type: "keepassgo-popup-state",
|
||||
force: true,
|
||||
tabId: popupTabID()
|
||||
});
|
||||
document.getElementById("page-host").textContent = hostFromURL(state.pageUrl || "");
|
||||
renderPageHint(state);
|
||||
|
||||
if (!state.configured) {
|
||||
setStatus("Configure access", state.error || "Set the API token in extension settings.", "warning");
|
||||
renderMatches({ matches: [] });
|
||||
return;
|
||||
}
|
||||
if (state.pendingFill) {
|
||||
setStatus("Approval needed", state.pendingMessage || "Approve or deny the fill request in KeePassGO.", "warning");
|
||||
renderMatches(state);
|
||||
return;
|
||||
}
|
||||
if (!state.success) {
|
||||
setStatus("KeePassGO unavailable", state.error || "The native host could not reach KeePassGO.", "error");
|
||||
renderMatches({ matches: [] });
|
||||
renderMatches(state);
|
||||
return;
|
||||
}
|
||||
if (state.status?.locked) {
|
||||
setStatus("Vault locked", "Unlock KeePassGO, then open the popup again.", "warning");
|
||||
renderMatches({ matches: [] });
|
||||
setStatus("Vault locked", "Unlock KeePassGO, then try the page again.", "warning");
|
||||
renderMatches(state);
|
||||
return;
|
||||
}
|
||||
|
||||
const count = Array.isArray(state.matches) ? state.matches.length : 0;
|
||||
setStatus("Ready", count === 0 ? "KeePassGO is connected." : `${count} matching entr${count === 1 ? "y" : "ies"} found.`, "ready");
|
||||
if (!state.pageHasLoginForm) {
|
||||
setStatus("Ready", "KeePassGO is connected. Open a login form to check for matches.", "ready");
|
||||
} else if (count === 0) {
|
||||
setStatus("Checked this page", "KeePassGO did not find a matching login for this form.", "ready");
|
||||
} else {
|
||||
setStatus("Page suggestions ready", count === 1 ? "1 matching entry is ready on this page." : `${count} matching entries are ready on this page.`, "ready");
|
||||
}
|
||||
renderMatches(state);
|
||||
} catch (error) {
|
||||
setStatus("Error", error instanceof Error ? error.message : String(error), "error");
|
||||
|
||||
@@ -86,6 +86,10 @@ h2 {
|
||||
background: #fcf1f1;
|
||||
}
|
||||
|
||||
.inline-hint {
|
||||
margin: -6px 0 16px;
|
||||
}
|
||||
|
||||
.match-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user