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 = { 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; if (error) { reject(new Error(error.message)); return; } resolve(value); }); }); } 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; if (error) { reject(new Error(error.message)); return; } resolve(); }); }); } 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; if (error) { reject(new Error(error.message)); return; } resolve(tabs); }); }); } 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; if (error) { reject(new Error(error.message)); return; } resolve(response); }); }); } 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; if (error) { reject(new Error(error.message)); return; } resolve(response); }); }); } async function loadSettings() { const stored = await storageGet(["bearerToken"]); return { 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: Number.isInteger(tab?.id) ? tab.id : null, url: typeof tab?.url === "string" ? tab.url : "" }; } 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, error: "Set an API token in extension settings." }; } const status = await connectNative({ action: "status", bearerToken: settings.bearerToken }); 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: 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", 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 : "" }; } 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(tabId, entryId) { if (!Number.isInteger(tabId)) { throw new Error("No active tab is available."); } const tab = await tabsGet(tabId); const pageUrl = typeof tab?.url === "string" ? tab.url : ""; if (!supportsPageStateURL(pageUrl)) { throw new Error("This page cannot be filled."); } 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() }); 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", 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; } } 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({ 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; }); 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); }); }