diff --git a/README.md b/README.md index a9b78558..2c0c8ec1 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ In order to use Browserpass you must also install a [companion native messaging - [Organizing password store](#organizing-password-store) - [First steps in browser extension](#first-steps-in-browser-extension) - [Available keyboard shortcuts](#available-keyboard-shortcuts) + - [Usage via right-click menu](#usage-via-right-click-menu) - [Password matching and sorting](#password-matching-and-sorting) - [Searching password entries](#searching-password-entries) - [OpenID authentication](#openid-authentication) @@ -160,6 +161,12 @@ Note: If the cursor is located in the search input field, every shortcut that wo | Ctrl+Shift+G | Open URL in the new tab | | Backspace (with no search text entered) | Search passwords in the entire password store | +### Usage via right-click menu + +You can right-click anywhere a visited website and there will appear a menu with an option `Browserpass - entries`, where `n` is the number of entries that match the host of the visited website. When you select an entry, that one gets automatically filled in, equivalent to the behavior when an entry is selected from the Browserpass popup. This can be helpful if you want to fill credentials in a browser popup window without extension buttons. Selecting single form fields and choosing values to fill in is currently not supported + +![The right-click menu of browserpass](https://user-images.githubusercontent.com/15818773/155025065-15cdc54e-2d24-46fc-886d-c83881d2ea76.gif) + ### Password matching and sorting When you first open the Browserpass popup, you will see a badge with the current domain name in the search input field: @@ -300,6 +307,7 @@ Browserpass extension requests the following permissions: | `tabs` | To get URL of a given tab, used for example to set count of the matching passwords for a given tab | | `clipboardRead` | To ensure only copied credentials and not other content is cleared from the clipboard after 60 seconds | | `clipboardWrite` | For "Copy password" and "Copy username" functionality | +| `contextMenus` | To create a context menu, also called right-click menu | | `nativeMessaging` | To allow communication with the native app | | `notifications` | To show browser notifications on install or update | | `webRequest` | For modal HTTP authentication | diff --git a/src/background.js b/src/background.js index 046e061e..3da00ebd 100644 --- a/src/background.js +++ b/src/background.js @@ -10,6 +10,10 @@ const helpers = require("./helpers"); // native application id var appID = "com.github.browserpass.native"; +const INTERNAL_PAGES = /^(chrome|about):/; +const CACHE_TTL_MS = 60 * 1000; +const CONTEXT_MENU_PARENT = "ContextMenuParent"; + // default settings var defaultSettings = { autoSubmit: false, @@ -30,6 +34,9 @@ var badgeCache = { isRefreshing: false, }; +// stores login data per tab, for use in context menu +let contextMenuCache = {}; + // the last text copied to the clipboard is stored here in order to be cleared after 60 seconds let lastCopiedText = null; @@ -38,7 +45,7 @@ chrome.browserAction.setBadgeBackgroundColor({ }); // watch for tab updates -chrome.tabs.onUpdated.addListener((tabId, info) => { +chrome.tabs.onUpdated.addListener(async (tabId, info) => { // unregister any auth listeners for this tab if (info.status === "complete") { if (authListeners[tabId]) { @@ -49,8 +56,204 @@ chrome.tabs.onUpdated.addListener((tabId, info) => { // redraw badge counter updateMatchingPasswordsCount(tabId); + + // update context menu + await updateContextMenu(tabId.toString()); }); +chrome.contextMenus.create({ + contexts: ["all"], + id: CONTEXT_MENU_PARENT, + title: "Browserpass", + type: "normal", + visible: false, +}); + +chrome.tabs.onActivated.addListener(async (activeInfo) => { + await chrome.contextMenus.update(CONTEXT_MENU_PARENT, { + visible: false, + }); + await updateContextMenu(activeInfo.tabId.toString()); +}); + +/** + * Update the context menu + * + * @since 3.8.0 + * + * @param string tabId ID of the Tab + * @return void + */ + +async function updateContextMenu(tabId) { + let tabUrl = null; + + await chrome.tabs.query({ currentWindow: true, active: true }, function (tabs) { + tabUrl = tabs[0].url; + }); + + if ( + (contextMenuCache[tabId]?.tabUrl !== tabUrl && tabUrl.match(INTERNAL_PAGES)) || + tabUrl.match(INTERNAL_PAGES) + ) { + await chrome.contextMenus.update(CONTEXT_MENU_PARENT, { + visible: false, + }); + return; + } + + if (contextMenuCache[tabId]?.isRefreshing || tabUrl === "") { + return; + } + + contextMenuCache[tabId] = { ...contextMenuCache[tabId], isRefreshing: true }; + + if ( + (contextMenuCache[tabId]?.tabUrl !== tabUrl && contextMenuCache[tabId]?.children) || + Date.now() >= contextMenuCache[tabId]?.expires + ) { + const oldChildren = contextMenuCache[tabId].children; + + if (oldChildren?.length) { + await Promise.all( + oldChildren.map(async (children) => { + await chrome.contextMenus.remove(children.id); + }) + ); + } + contextMenuCache[tabId].children = []; + contextMenuCache[tabId].expires = Date.now(); + } + + if ( + contextMenuCache[tabId]?.tabUrl === tabUrl && + Date.now() < contextMenuCache[tabId]?.expires + ) { + await changeContextMenuChildrenVisibility(tabId); + contextMenuCache[tabId].isRefreshing = false; + return; + } + + contextMenuCache[tabId] = { + ...contextMenuCache[tabId], + expires: Date.now() + CACHE_TTL_MS, + tabUrl, + }; + + const settings = await getFullSettings(); + const response = await hostAction(settings, "list"); + + if (response.status != "ok") { + throw new Error(JSON.stringify(response)); + } + + const files = helpers.ignoreFiles(response.data.files, settings); + const logins = helpers.prepareLogins(files, settings); + const loginsForThisHost = helpers.filterSortLogins(logins, "", true); + + await createContextMenuChildren(tabId, settings, loginsForThisHost); + contextMenuCache[tabId].isRefreshing = false; +} + +/** + * Create context menu children + * + * @since 3.8.0 + * + * @param string tabId ID of the Tab + * @param object settings Full settings object + * @param object loginsForThisHost Login object + * @return void + */ +async function createContextMenuChildren(tabId, settings, loginsForThisHost) { + if (loginsForThisHost.length > 0) { + try { + contextMenuCache[tabId].children = []; + + await Promise.all( + loginsForThisHost.map(async (logins, index) => { + const contextMenuChild = { + contexts: ["all"], + id: `child_${tabId}_${index}`, + onclick: () => clickMenuEntry(settings, logins), + parentId: CONTEXT_MENU_PARENT, + title: logins.login, + type: "normal", + visible: true, + }; + + await chrome.contextMenus.create(contextMenuChild); + contextMenuCache[tabId].children.push(contextMenuChild); + }) + ); + } catch (e) { + console.log(e); + } + } + + await changeContextMenuChildrenVisibility(tabId); +} + +/** + * Change the visibility of the context menu's child items + * + * @since 3.8.0 + * + * @param string tabId ID of the Tab + * @return void + */ +async function changeContextMenuChildrenVisibility(tabId) { + const keys = Object.keys(contextMenuCache); + let isParentVisible = false; + + await Promise.all( + keys.map(async (key) => { + const children = contextMenuCache[key].children; + if (children === undefined || children?.length === 0) { + return; + } + + const visible = key === tabId; + + if (visible) { + await chrome.contextMenus.update(CONTEXT_MENU_PARENT, { + visible, + }); + isParentVisible = true; + } + + await Promise.all( + children.map(async (c) => { + await chrome.contextMenus.update(c.id, { visible }); + }) + ); + }) + ); + + if (!isParentVisible) { + await chrome.contextMenus.update(CONTEXT_MENU_PARENT, { + visible: false, + }); + } +} + +/** + * Handle the click of a context menu item + * + * @since 3.8.0 + * + * @param object settings Full settings object + * @param object login Login object + * @return void + */ +async function clickMenuEntry(settings, login) { + await handleMessage(settings, { action: "fill", login }, (response) => { + if (response.status != "ok") { + throw new Error(JSON.stringify(response)); + } + }); +} + // handle incoming messages chrome.runtime.onMessage.addListener(function (message, sender, sendResponse) { receiveMessage(message, sender, sendResponse); @@ -65,7 +268,7 @@ chrome.commands.onCommand.addListener(async (command) => { case "fillBest": try { const settings = await getFullSettings(); - if (settings.tab.url.match(/^(chrome|about):/)) { + if (settings.tab.url.match(INTERNAL_PAGES)) { // only fill on real domains return; } @@ -121,7 +324,6 @@ async function updateMatchingPasswordsCount(tabId, forceRefresh = false) { throw new Error(JSON.stringify(response)); } - const CACHE_TTL_MS = 60 * 1000; badgeCache = { files: response.data.files, settings: settings, diff --git a/src/manifest-chromium.json b/src/manifest-chromium.json index c3e5134f..f5b70dfa 100644 --- a/src/manifest-chromium.json +++ b/src/manifest-chromium.json @@ -37,7 +37,8 @@ "webRequest", "webRequestBlocking", "http://*/*", - "https://*/*" + "https://*/*", + "contextMenus" ], "content_security_policy": "default-src 'none'; font-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self'", "commands": { diff --git a/src/manifest-firefox.json b/src/manifest-firefox.json index a7481aa2..3214cbef 100644 --- a/src/manifest-firefox.json +++ b/src/manifest-firefox.json @@ -35,7 +35,8 @@ "webRequest", "webRequestBlocking", "http://*/*", - "https://*/*" + "https://*/*", + "contextMenus" ], "content_security_policy": "default-src 'none'; font-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self'", "applications": {