Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create right-click menu #284

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -160,6 +161,12 @@ Note: If the cursor is located in the search input field, every shortcut that wo
| <kbd>Ctrl+Shift+G</kbd> | Open URL in the new tab |
| <kbd>Backspace</kbd> (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 - <n> 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:
Expand Down Expand Up @@ -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 |
Expand Down
208 changes: 205 additions & 3 deletions src/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;

Expand All @@ -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]) {
Expand All @@ -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) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This just triggers the standard 'fill' action, however given that it's for a context menu, there needs to be additional logic that ensures the field that has been clicked on is the one that gets filled. Some pages have multiple candidate forms, and the one that Browsewrpass picks by default for the fill action may not be the one that the user right-clicked for the context menu.

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);
Expand All @@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/manifest-chromium.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 2 additions & 1 deletion src/manifest-firefox.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down