[PS-1306] Context Menu for MV3 (#3910)

* Add combine helper

* Helper for running multiple actions with single service cache

* Remove unneeded any

* Send identifier through callback

* Extend Tab Message

* Split out ContextMenu logic

* Add tests for ContextMenu actions

* Context Menu Fixes

* Await call to menu handler
* set onUpdatedRan to false when it's ran

* Switch to using new cache per run

* Fix Generate Password Test

* Remove old file from whitelist

* Remove Useless never from Generic

* Update apps/browser/src/background/main.background.ts

Co-authored-by: Matt Gibson <mgibson@bitwarden.com>

* Address PR Feedback

* Specify a Document Url for Context Menu Items

* Update Test

* Use Generate Password Callback

* Remove DocumentUrlPatterns

Co-authored-by: Matt Gibson <mgibson@bitwarden.com>
This commit is contained in:
Justin Baur 2023-01-06 19:31:32 -05:00 committed by GitHub
parent 574c18ba3f
commit d79fd7f417
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1360 additions and 460 deletions

View File

@ -207,7 +207,6 @@
./apps/browser/src/safari/safari/SafariWebExtensionHandler.swift
./apps/browser/src/safari/safari/Info.plist
./apps/browser/src/safari/desktop.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
./apps/browser/src/commands/autoFillActiveTabCommand.ts
./apps/browser/src/listeners/onCommandListener.ts
./apps/browser/src/listeners/onInstallListener.ts
./apps/browser/src/services/browserFileDownloadService.ts

View File

@ -2,30 +2,26 @@ import { onAlarmListener } from "./alarms/on-alarm-listener";
import { registerAlarms } from "./alarms/register-alarms";
import MainBackground from "./background/main.background";
import { BrowserApi } from "./browser/browserApi";
import { onCommandListener } from "./listeners/onCommandListener";
import { onInstallListener } from "./listeners/onInstallListener";
import { UpdateBadge } from "./listeners/update-badge";
const manifestV3MessageListeners: ((
serviceCache: Record<string, unknown>,
message: { command: string }
) => void | Promise<void>)[] = [UpdateBadge.messageListener];
import {
contextMenusClickedListener,
onCommandListener,
onInstallListener,
runtimeMessageListener,
tabsOnActivatedListener,
tabsOnReplacedListener,
tabsOnUpdatedListener,
} from "./listeners";
if (BrowserApi.manifestVersion === 3) {
chrome.commands.onCommand.addListener(onCommandListener);
chrome.runtime.onInstalled.addListener(onInstallListener);
chrome.alarms.onAlarm.addListener(onAlarmListener);
registerAlarms();
chrome.tabs.onActivated.addListener(UpdateBadge.tabsOnActivatedListener);
chrome.tabs.onReplaced.addListener(UpdateBadge.tabsOnReplacedListener);
chrome.tabs.onUpdated.addListener(UpdateBadge.tabsOnUpdatedListener);
BrowserApi.messageListener("runtime.background", (message) => {
const serviceCache = {};
manifestV3MessageListeners.forEach((listener) => {
listener(serviceCache, message);
});
});
chrome.tabs.onActivated.addListener(tabsOnActivatedListener);
chrome.tabs.onReplaced.addListener(tabsOnReplacedListener);
chrome.tabs.onUpdated.addListener(tabsOnUpdatedListener);
chrome.contextMenus.onClicked.addListener(contextMenusClickedListener);
BrowserApi.messageListener("runtime.background", runtimeMessageListener);
} else {
const bitwardenMain = ((window as any).bitwardenMain = new MainBackground());
bitwardenMain.bootstrap().then(() => {

View File

@ -1,146 +1,38 @@
import { AuthService } from "@bitwarden/common/abstractions/auth.service";
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { PasswordGenerationService } from "@bitwarden/common/abstractions/passwordGeneration.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { TotpService } from "@bitwarden/common/abstractions/totp.service";
import { AuthenticationStatus } from "@bitwarden/common/enums/authenticationStatus";
import { CipherRepromptType } from "@bitwarden/common/enums/cipherRepromptType";
import { EventType } from "@bitwarden/common/enums/eventType";
import { CipherView } from "@bitwarden/common/models/view/cipher.view";
import { BrowserApi } from "../browser/browserApi";
import { ContextMenuClickedHandler } from "../browser/context-menu-clicked-handler";
import MainBackground from "./main.background";
import LockedVaultPendingNotificationsItem from "./models/lockedVaultPendingNotificationsItem";
export default class ContextMenusBackground {
private readonly noopCommandSuffix = "noop";
private contextMenus: any;
private contextMenus: typeof chrome.contextMenus;
constructor(
private main: MainBackground,
private cipherService: CipherService,
private passwordGenerationService: PasswordGenerationService,
private platformUtilsService: PlatformUtilsService,
private authService: AuthService,
private eventCollectionService: EventCollectionService,
private totpService: TotpService
) {
constructor(private contextMenuClickedHandler: ContextMenuClickedHandler) {
this.contextMenus = chrome.contextMenus;
}
async init() {
init() {
if (!this.contextMenus) {
return;
}
this.contextMenus.onClicked.addListener(
async (info: chrome.contextMenus.OnClickData, tab: chrome.tabs.Tab) => {
if (info.menuItemId === "generate-password") {
await this.generatePasswordToClipboard();
} else if (info.menuItemId === "copy-identifier") {
await this.getClickedElement(tab, info.frameId);
} else if (
info.parentMenuItemId === "autofill" ||
info.parentMenuItemId === "copy-username" ||
info.parentMenuItemId === "copy-password" ||
info.parentMenuItemId === "copy-totp"
) {
await this.cipherAction(tab, info);
}
}
this.contextMenus.onClicked.addListener((info, tab) =>
this.contextMenuClickedHandler.run(info, tab)
);
BrowserApi.messageListener(
"contextmenus.background",
async (msg: any, sender: chrome.runtime.MessageSender, sendResponse: any) => {
async (
msg: { command: string; data: LockedVaultPendingNotificationsItem },
sender: chrome.runtime.MessageSender,
sendResponse: any
) => {
if (msg.command === "unlockCompleted" && msg.data.target === "contextmenus.background") {
await this.cipherAction(
msg.data.commandToRetry.sender.tab,
msg.data.commandToRetry.msg.data
await this.contextMenuClickedHandler.cipherAction(
msg.data.commandToRetry.msg.data,
msg.data.commandToRetry.sender.tab
);
}
}
);
}
private async generatePasswordToClipboard() {
const options = (await this.passwordGenerationService.getOptions())?.[0] ?? {};
const password = await this.passwordGenerationService.generatePassword(options);
this.platformUtilsService.copyToClipboard(password, { window: window });
this.passwordGenerationService.addHistory(password);
}
private async getClickedElement(tab: chrome.tabs.Tab, frameId: number) {
if (tab == null) {
return;
}
BrowserApi.tabSendMessage(tab, { command: "getClickedElement" }, { frameId: frameId });
}
private async cipherAction(tab: chrome.tabs.Tab, info: chrome.contextMenus.OnClickData) {
if (typeof info.menuItemId !== "string") {
return;
}
const id = info.menuItemId.split("_")[1];
if ((await this.authService.getAuthStatus()) < AuthenticationStatus.Unlocked) {
const retryMessage: LockedVaultPendingNotificationsItem = {
commandToRetry: {
msg: { command: this.noopCommandSuffix, data: info },
sender: { tab: tab },
},
target: "contextmenus.background",
};
await BrowserApi.tabSendMessageData(
tab,
"addToLockedVaultPendingNotifications",
retryMessage
);
BrowserApi.tabSendMessageData(tab, "promptForLogin");
return;
}
let cipher: CipherView;
if (id === this.noopCommandSuffix) {
const ciphers = await this.cipherService.getAllDecryptedForUrl(tab.url);
cipher = ciphers.find((c) => c.reprompt === CipherRepromptType.None);
} else {
const ciphers = await this.cipherService.getAllDecrypted();
cipher = ciphers.find((c) => c.id === id);
}
if (cipher == null) {
return;
}
if (info.parentMenuItemId === "autofill") {
await this.startAutofillPage(tab, cipher);
} else if (info.parentMenuItemId === "copy-username") {
this.platformUtilsService.copyToClipboard(cipher.login.username, { window: window });
} else if (info.parentMenuItemId === "copy-password") {
this.platformUtilsService.copyToClipboard(cipher.login.password, { window: window });
this.eventCollectionService.collect(EventType.Cipher_ClientCopiedPassword, cipher.id);
} else if (info.parentMenuItemId === "copy-totp") {
const totpValue = await this.totpService.getCode(cipher.login.totp);
this.platformUtilsService.copyToClipboard(totpValue, { window: window });
}
}
private async startAutofillPage(tab: chrome.tabs.Tab, cipher: CipherView) {
this.main.loginToAutoFill = cipher;
if (tab == null) {
return;
}
BrowserApi.tabSendMessage(tab, {
command: "collectPageDetails",
tab: tab,
sender: "contextMenu",
});
}
}

View File

@ -40,9 +40,6 @@ import { UserVerificationService as UserVerificationServiceAbstraction } from "@
import { UsernameGenerationService as UsernameGenerationServiceAbstraction } from "@bitwarden/common/abstractions/usernameGeneration.service";
import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service";
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeoutSettings.service";
import { AuthenticationStatus } from "@bitwarden/common/enums/authenticationStatus";
import { CipherRepromptType } from "@bitwarden/common/enums/cipherRepromptType";
import { CipherType } from "@bitwarden/common/enums/cipherType";
import { StateFactory } from "@bitwarden/common/factories/stateFactory";
import { GlobalState } from "@bitwarden/common/models/domain/global-state";
import { CipherView } from "@bitwarden/common/models/view/cipher.view";
@ -84,7 +81,11 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vaultTim
import { WebCryptoFunctionService } from "@bitwarden/common/services/webCryptoFunction.service";
import { BrowserApi } from "../browser/browserApi";
import { CipherContextMenuHandler } from "../browser/cipher-context-menu-handler";
import { ContextMenuClickedHandler } from "../browser/context-menu-clicked-handler";
import { MainContextMenuHandler } from "../browser/main-context-menu-handler";
import { SafariApp } from "../browser/safariApp";
import { AutofillTabCommand } from "../commands/autofill-tab-command";
import { flagEnabled } from "../flags";
import { UpdateBadge } from "../listeners/update-badge";
import { Account } from "../models/account";
@ -112,7 +113,6 @@ import VaultTimeoutService from "../services/vaultTimeout/vaultTimeout.service";
import CommandsBackground from "./commands.background";
import ContextMenusBackground from "./contextMenus.background";
import IdleBackground from "./idle.background";
import IconDetails from "./models/iconDetails";
import { NativeMessagingBackground } from "./nativeMessaging.background";
import NotificationBackground from "./notification.background";
import RuntimeBackground from "./runtime.background";
@ -171,6 +171,8 @@ export default class MainBackground {
userVerificationApiService: UserVerificationApiServiceAbstraction;
syncNotifierService: SyncNotifierServiceAbstraction;
avatarUpdateService: AvatarUpdateServiceAbstraction;
mainContextMenuHandler: MainContextMenuHandler;
cipherContextMenuHandler: CipherContextMenuHandler;
// Passed to the popup for Safari to workaround issues with theming, downloading, etc.
backgroundWindow = window;
@ -188,8 +190,6 @@ export default class MainBackground {
private webRequestBackground: WebRequestBackground;
private sidebarAction: any;
private buildingContextMenu: boolean;
private menuOptionsLoaded: any[] = [];
private syncTimeout: any;
private isSafari: boolean;
private nativeMessagingBackground: NativeMessagingBackground;
@ -536,15 +536,25 @@ export default class MainBackground {
);
this.tabsBackground = new TabsBackground(this, this.notificationBackground);
this.contextMenusBackground = new ContextMenusBackground(
this,
this.cipherService,
this.passwordGenerationService,
this.platformUtilsService,
this.authService,
this.eventCollectionService,
this.totpService
);
if (!this.popupOnlyContext) {
const contextMenuClickedHandler = new ContextMenuClickedHandler(
(options) => this.platformUtilsService.copyToClipboard(options.text, { window: self }),
async (_tab) => {
const options = (await this.passwordGenerationService.getOptions())?.[0] ?? {};
const password = await this.passwordGenerationService.generatePassword(options);
this.platformUtilsService.copyToClipboard(password, { window: window });
this.passwordGenerationService.addHistory(password);
},
this.authService,
this.cipherService,
new AutofillTabCommand(this.autofillService),
this.totpService,
this.eventCollectionService
);
this.contextMenusBackground = new ContextMenusBackground(contextMenuClickedHandler);
}
this.idleBackground = new IdleBackground(
this.vaultTimeoutService,
this.stateService,
@ -563,6 +573,16 @@ export default class MainBackground {
);
this.avatarUpdateService = new AvatarUpdateService(this.apiService, this.stateService);
if (!this.popupOnlyContext) {
this.mainContextMenuHandler = new MainContextMenuHandler(this.stateService, this.i18nService);
this.cipherContextMenuHandler = new CipherContextMenuHandler(
this.mainContextMenuHandler,
this.authService,
this.cipherService
);
}
}
async bootstrap() {
@ -580,7 +600,9 @@ export default class MainBackground {
this.twoFactorService.init();
await this.tabsBackground.init();
await this.contextMenusBackground.init();
if (!this.popupOnlyContext) {
this.contextMenusBackground?.init();
}
await this.idleBackground.init();
await this.webRequestBackground.init();
@ -620,22 +642,20 @@ export default class MainBackground {
return;
}
const menuDisabled = await this.stateService.getDisableContextMenuItem();
if (!menuDisabled) {
await this.buildContextMenu();
} else {
await this.contextMenusRemoveAll();
}
await MainContextMenuHandler.removeAll();
if (forLocked) {
await this.loadMenuForNoAccessState(!menuDisabled);
await this.mainContextMenuHandler?.noAccess();
this.onUpdatedRan = this.onReplacedRan = false;
return;
}
await this.mainContextMenuHandler?.init();
const tab = await BrowserApi.getTabFromCurrentWindow();
if (tab) {
await this.contextMenuReady(tab, !menuDisabled);
await this.cipherContextMenuHandler?.update(tab.url);
this.onUpdatedRan = this.onReplacedRan = false;
}
}
@ -667,7 +687,7 @@ export default class MainBackground {
BrowserApi.sendMessage("updateBadge");
}
await this.refreshBadge();
await this.refreshMenu(true);
await this.mainContextMenuHandler.noAccess();
await this.reseedStorage();
this.notificationsService.updateConnection(false);
await this.systemService.clearPendingClipboard();
@ -741,204 +761,6 @@ export default class MainBackground {
}
}
private async buildContextMenu() {
if (!chrome.contextMenus || this.buildingContextMenu) {
return;
}
this.buildingContextMenu = true;
await this.contextMenusRemoveAll();
await this.contextMenusCreate({
type: "normal",
id: "root",
contexts: ["all"],
title: "Bitwarden",
});
await this.contextMenusCreate({
type: "normal",
id: "autofill",
parentId: "root",
contexts: ["all"],
title: this.i18nService.t("autoFill"),
});
await this.contextMenusCreate({
type: "normal",
id: "copy-username",
parentId: "root",
contexts: ["all"],
title: this.i18nService.t("copyUsername"),
});
await this.contextMenusCreate({
type: "normal",
id: "copy-password",
parentId: "root",
contexts: ["all"],
title: this.i18nService.t("copyPassword"),
});
if (await this.stateService.getCanAccessPremium()) {
await this.contextMenusCreate({
type: "normal",
id: "copy-totp",
parentId: "root",
contexts: ["all"],
title: this.i18nService.t("copyVerificationCode"),
});
}
await this.contextMenusCreate({
type: "separator",
parentId: "root",
});
await this.contextMenusCreate({
type: "normal",
id: "generate-password",
parentId: "root",
contexts: ["all"],
title: this.i18nService.t("generatePasswordCopied"),
});
await this.contextMenusCreate({
type: "normal",
id: "copy-identifier",
parentId: "root",
contexts: ["all"],
title: this.i18nService.t("copyElementIdentifier"),
});
this.buildingContextMenu = false;
}
private async contextMenuReady(tab: any, contextMenuEnabled: boolean) {
await this.loadMenu(tab.url, tab.id, contextMenuEnabled);
this.onUpdatedRan = this.onReplacedRan = false;
}
private async loadMenu(url: string, tabId: number, contextMenuEnabled: boolean) {
if (!url || (!chrome.browserAction && !this.sidebarAction)) {
return;
}
this.menuOptionsLoaded = [];
const authStatus = await this.authService.getAuthStatus();
if (authStatus === AuthenticationStatus.Unlocked) {
try {
const ciphers = await this.cipherService.getAllDecryptedForUrl(url);
ciphers.sort((a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b));
if (contextMenuEnabled) {
ciphers.forEach((cipher) => {
this.loadLoginContextMenuOptions(cipher);
});
}
if (contextMenuEnabled && ciphers.length === 0) {
await this.loadNoLoginsContextMenuOptions(this.i18nService.t("noMatchingLogins"));
}
return;
} catch (e) {
this.logService.error(e);
}
}
await this.loadMenuForNoAccessState(contextMenuEnabled);
}
private async loadMenuForNoAccessState(contextMenuEnabled: boolean) {
if (contextMenuEnabled) {
const authed = await this.stateService.getIsAuthenticated();
await this.loadNoLoginsContextMenuOptions(
this.i18nService.t(authed ? "unlockVaultMenu" : "loginToVaultMenu")
);
}
}
private async loadLoginContextMenuOptions(cipher: any) {
if (
cipher == null ||
cipher.type !== CipherType.Login ||
cipher.reprompt !== CipherRepromptType.None
) {
return;
}
let title = cipher.name;
if (cipher.login.username && cipher.login.username !== "") {
title += " (" + cipher.login.username + ")";
}
await this.loadContextMenuOptions(title, cipher.id, cipher);
}
private async loadNoLoginsContextMenuOptions(noLoginsMessage: string) {
await this.loadContextMenuOptions(noLoginsMessage, "noop", null);
}
private async loadContextMenuOptions(title: string, idSuffix: string, cipher: any) {
if (
!chrome.contextMenus ||
this.menuOptionsLoaded.indexOf(idSuffix) > -1 ||
(cipher != null && cipher.type !== CipherType.Login)
) {
return;
}
this.menuOptionsLoaded.push(idSuffix);
if (cipher == null || (cipher.login.password && cipher.login.password !== "")) {
await this.contextMenusCreate({
type: "normal",
id: "autofill_" + idSuffix,
parentId: "autofill",
contexts: ["all"],
title: this.sanitizeContextMenuTitle(title),
});
}
if (cipher == null || (cipher.login.username && cipher.login.username !== "")) {
await this.contextMenusCreate({
type: "normal",
id: "copy-username_" + idSuffix,
parentId: "copy-username",
contexts: ["all"],
title: this.sanitizeContextMenuTitle(title),
});
}
if (
cipher == null ||
(cipher.login.password && cipher.login.password !== "" && cipher.viewPassword)
) {
await this.contextMenusCreate({
type: "normal",
id: "copy-password_" + idSuffix,
parentId: "copy-password",
contexts: ["all"],
title: this.sanitizeContextMenuTitle(title),
});
}
const canAccessPremium = await this.stateService.getCanAccessPremium();
if (canAccessPremium && (cipher == null || (cipher.login.totp && cipher.login.totp !== ""))) {
await this.contextMenusCreate({
type: "normal",
id: "copy-totp_" + idSuffix,
parentId: "copy-totp",
contexts: ["all"],
title: this.sanitizeContextMenuTitle(title),
});
}
}
private sanitizeContextMenuTitle(title: string): string {
return title.replace(/&/g, "&&");
}
private async fullSync(override = false) {
const syncInternal = 6 * 60 * 60 * 1000; // 6 hours
const lastSync = await this.syncService.getLastSync();
@ -963,54 +785,4 @@ export default class MainBackground {
this.syncTimeout = setTimeout(async () => await this.fullSync(), 5 * 60 * 1000); // check every 5 minutes
}
// Browser API Helpers
private contextMenusRemoveAll() {
return new Promise<void>((resolve) => {
chrome.contextMenus.removeAll(() => {
resolve();
if (chrome.runtime.lastError) {
return;
}
});
});
}
private contextMenusCreate(options: any) {
return new Promise<void>((resolve) => {
chrome.contextMenus.create(options, () => {
resolve();
if (chrome.runtime.lastError) {
return;
}
});
});
}
private async actionSetIcon(theAction: any, suffix: string, windowId?: number): Promise<any> {
if (!theAction || !theAction.setIcon) {
return;
}
const options: IconDetails = {
path: {
19: "images/icon19" + suffix + ".png",
38: "images/icon38" + suffix + ".png",
},
};
if (this.platformUtilsService.isFirefox()) {
options.windowId = windowId;
await theAction.setIcon(options);
} else if (this.platformUtilsService.isSafari()) {
// Workaround since Safari 14.0.3 returns a pending promise
// which doesn't resolve within a reasonable time.
theAction.setIcon(options);
} else {
return new Promise<void>((resolve) => {
theAction.setIcon(options, () => resolve());
});
}
}
}

View File

@ -47,7 +47,7 @@ export function cipherServiceFactory(
await fileUploadServiceFactory(cache, opts),
await i18nServiceFactory(cache, opts),
opts.cipherServiceOptions?.searchServiceFactory === undefined
? () => cache.searchService
? () => cache.searchService as SearchService
: opts.cipherServiceOptions.searchServiceFactory,
await logServiceFactory(cache, opts),
await stateServiceFactory(cache, opts),

View File

@ -1,7 +1,4 @@
import { EncryptServiceImplementation } from "@bitwarden/common/services/cryptography/encrypt.service.implementation";
import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/services/cryptography/multithread-encrypt.service.implementation";
import { flagEnabled } from "../../flags";
import {
cryptoFunctionServiceFactory,
@ -24,17 +21,15 @@ export function encryptServiceFactory(
cache: { encryptService?: EncryptServiceImplementation } & CachedServices,
opts: EncryptServiceInitOptions
): Promise<EncryptServiceImplementation> {
return factory(cache, "encryptService", opts, async () =>
flagEnabled("multithreadDecryption")
? new MultithreadEncryptServiceImplementation(
await cryptoFunctionServiceFactory(cache, opts),
await logServiceFactory(cache, opts),
opts.encryptServiceOptions.logMacFailures
)
: new EncryptServiceImplementation(
await cryptoFunctionServiceFactory(cache, opts),
await logServiceFactory(cache, opts),
opts.encryptServiceOptions.logMacFailures
)
return factory(
cache,
"encryptService",
opts,
async () =>
new EncryptServiceImplementation(
await cryptoFunctionServiceFactory(cache, opts),
await logServiceFactory(cache, opts),
opts.encryptServiceOptions.logMacFailures
)
);
}

View File

@ -1,4 +1,4 @@
export type CachedServices = Record<string, any>;
export type CachedServices = Record<string, unknown>;
export type FactoryOptions = {
alwaysInitializeNewService?: boolean;

View File

@ -44,7 +44,7 @@ export class BrowserApi {
static async tabsQuery(options: chrome.tabs.QueryInfo): Promise<chrome.tabs.Tab[]> {
return new Promise((resolve) => {
chrome.tabs.query(options, (tabs: any[]) => {
chrome.tabs.query(options, (tabs) => {
resolve(tabs);
});
});
@ -63,7 +63,7 @@ export class BrowserApi {
tab: chrome.tabs.Tab,
command: string,
data: any = null
): Promise<any[]> {
): Promise<void> {
const obj: any = {
command: command,
};
@ -75,11 +75,11 @@ export class BrowserApi {
return BrowserApi.tabSendMessage(tab, obj);
}
static async tabSendMessage(
static async tabSendMessage<T>(
tab: chrome.tabs.Tab,
obj: any,
obj: T,
options: chrome.tabs.MessageSendOptions = null
): Promise<any> {
): Promise<void> {
if (!tab || !tab.id) {
return;
}
@ -94,12 +94,13 @@ export class BrowserApi {
});
}
static sendTabsMessage<T = never>(
static sendTabsMessage<T>(
tabId: number,
message: TabMessage,
options?: chrome.tabs.MessageSendOptions,
responseCallback?: (response: T) => void
) {
chrome.tabs.sendMessage<TabMessage, T>(tabId, message, responseCallback);
chrome.tabs.sendMessage<TabMessage, T>(tabId, message, options, responseCallback);
}
static async getPrivateModeWindows(): Promise<browser.windows.Window[]> {

View File

@ -0,0 +1,109 @@
import { mock, MockProxy } from "jest-mock-extended";
import { AuthService } from "@bitwarden/common/abstractions/auth.service";
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
import { AuthenticationStatus } from "@bitwarden/common/enums/authenticationStatus";
import { CipherRepromptType } from "@bitwarden/common/enums/cipherRepromptType";
import { CipherType } from "@bitwarden/common/enums/cipherType";
import { CipherContextMenuHandler } from "./cipher-context-menu-handler";
import { MainContextMenuHandler } from "./main-context-menu-handler";
describe("CipherContextMenuHandler", () => {
let mainContextMenuHandler: MockProxy<MainContextMenuHandler>;
let authService: MockProxy<AuthService>;
let cipherService: MockProxy<CipherService>;
let sut: CipherContextMenuHandler;
beforeEach(() => {
mainContextMenuHandler = mock();
authService = mock();
cipherService = mock();
jest.spyOn(MainContextMenuHandler, "removeAll").mockResolvedValue();
sut = new CipherContextMenuHandler(mainContextMenuHandler, authService, cipherService);
});
afterEach(() => jest.resetAllMocks());
describe("update", () => {
it("locked, updates for no access", async () => {
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Locked);
await sut.update("https://test.com");
expect(mainContextMenuHandler.noAccess).toHaveBeenCalledTimes(1);
});
it("logged out, updates for no access", async () => {
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.LoggedOut);
await sut.update("https://test.com");
expect(mainContextMenuHandler.noAccess).toHaveBeenCalledTimes(1);
});
it("has menu disabled, does not load anything", async () => {
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Unlocked);
await sut.update("https://test.com");
expect(mainContextMenuHandler.loadOptions).not.toHaveBeenCalled();
expect(mainContextMenuHandler.noAccess).not.toHaveBeenCalled();
expect(mainContextMenuHandler.noLogins).not.toHaveBeenCalled();
});
it("has no ciphers, add no ciphers item", async () => {
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Unlocked);
mainContextMenuHandler.init.mockResolvedValue(true);
cipherService.getAllDecryptedForUrl.mockResolvedValue([]);
await sut.update("https://test.com");
expect(mainContextMenuHandler.noLogins).toHaveBeenCalledTimes(1);
});
it("only adds valid ciphers", async () => {
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Unlocked);
mainContextMenuHandler.init.mockResolvedValue(true);
const realCipher = {
id: "5",
type: CipherType.Login,
reprompt: CipherRepromptType.None,
name: "Test Cipher",
login: { username: "Test Username" },
};
cipherService.getAllDecryptedForUrl.mockResolvedValue([
null,
undefined,
{ type: CipherType.Card },
{ type: CipherType.Login, reprompt: CipherRepromptType.Password },
realCipher,
] as any[]);
await sut.update("https://test.com");
expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledTimes(1);
expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith("https://test.com");
expect(mainContextMenuHandler.loadOptions).toHaveBeenCalledTimes(1);
expect(mainContextMenuHandler.loadOptions).toHaveBeenCalledWith(
"Test Cipher (Test Username)",
"5",
"https://test.com",
realCipher
);
});
});
});

View File

@ -0,0 +1,185 @@
import { AuthService } from "@bitwarden/common/abstractions/auth.service";
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { AuthenticationStatus } from "@bitwarden/common/enums/authenticationStatus";
import { CipherRepromptType } from "@bitwarden/common/enums/cipherRepromptType";
import { CipherType } from "@bitwarden/common/enums/cipherType";
import { StateFactory } from "@bitwarden/common/factories/stateFactory";
import { Utils } from "@bitwarden/common/misc/utils";
import { GlobalState } from "@bitwarden/common/models/domain/global-state";
import { CipherView } from "@bitwarden/common/models/view/cipher.view";
import {
authServiceFactory,
AuthServiceInitOptions,
} from "../background/service_factories/auth-service.factory";
import {
cipherServiceFactory,
CipherServiceInitOptions,
} from "../background/service_factories/cipher-service.factory";
import { CachedServices } from "../background/service_factories/factory-options";
import { searchServiceFactory } from "../background/service_factories/search-service.factory";
import { Account } from "../models/account";
import { BrowserApi } from "./browserApi";
import { MainContextMenuHandler } from "./main-context-menu-handler";
const NOT_IMPLEMENTED = (..._args: unknown[]) => Promise.resolve();
const LISTENED_TO_COMMANDS = [
"loggedIn",
"unlocked",
"syncCompleted",
"bgUpdateContextMenu",
"editedCipher",
"addedCipher",
"deletedCipher",
];
export class CipherContextMenuHandler {
constructor(
private mainContextMenuHandler: MainContextMenuHandler,
private authService: AuthService,
private cipherService: CipherService
) {}
static async create(cachedServices: CachedServices) {
const stateFactory = new StateFactory(GlobalState, Account);
let searchService: SearchService | null = null;
const serviceOptions: AuthServiceInitOptions & CipherServiceInitOptions = {
apiServiceOptions: {
logoutCallback: NOT_IMPLEMENTED,
},
cipherServiceOptions: {
searchServiceFactory: () => searchService,
},
cryptoFunctionServiceOptions: {
win: self,
},
encryptServiceOptions: {
logMacFailures: false,
},
i18nServiceOptions: {
systemLanguage: chrome.i18n.getUILanguage(),
},
keyConnectorServiceOptions: {
logoutCallback: NOT_IMPLEMENTED,
},
logServiceOptions: {
isDev: false,
},
platformUtilsServiceOptions: {
biometricCallback: () => Promise.resolve(false),
clipboardWriteCallback: NOT_IMPLEMENTED,
win: self,
},
stateMigrationServiceOptions: {
stateFactory: stateFactory,
},
stateServiceOptions: {
stateFactory: stateFactory,
},
};
searchService = await searchServiceFactory(cachedServices, serviceOptions);
return new CipherContextMenuHandler(
await MainContextMenuHandler.mv3Create(cachedServices),
await authServiceFactory(cachedServices, serviceOptions),
await cipherServiceFactory(cachedServices, serviceOptions)
);
}
static async tabsOnActivatedListener(
activeInfo: chrome.tabs.TabActiveInfo,
serviceCache: CachedServices
) {
const cipherContextMenuHandler = await CipherContextMenuHandler.create(serviceCache);
const tab = await BrowserApi.getTab(activeInfo.tabId);
await cipherContextMenuHandler.update(tab.url);
}
static async tabsOnReplacedListener(
addedTabId: number,
removedTabId: number,
serviceCache: CachedServices
) {
const cipherContextMenuHandler = await CipherContextMenuHandler.create(serviceCache);
const tab = await BrowserApi.getTab(addedTabId);
await cipherContextMenuHandler.update(tab.url);
}
static async tabsOnUpdatedListener(
tabId: number,
changeInfo: chrome.tabs.TabChangeInfo,
tab: chrome.tabs.Tab,
serviceCache: CachedServices
) {
if (changeInfo.status !== "complete") {
return;
}
const cipherContextMenuHandler = await CipherContextMenuHandler.create(serviceCache);
await cipherContextMenuHandler.update(tab.url);
}
static async messageListener(message: { command: string }, cachedServices: CachedServices) {
const cipherContextMenuHandler = await CipherContextMenuHandler.create(cachedServices);
await cipherContextMenuHandler.messageListener(message);
}
async messageListener(message: { command: string }) {
if (!LISTENED_TO_COMMANDS.includes(message.command)) {
return;
}
const activeTabs = await BrowserApi.getActiveTabs();
if (!activeTabs || activeTabs.length === 0) {
return;
}
await this.update(activeTabs[0].url);
}
async update(url: string) {
const authStatus = await this.authService.getAuthStatus();
await MainContextMenuHandler.removeAll();
if (authStatus !== AuthenticationStatus.Unlocked) {
// Should I pass in the auth status or even have two seperate methods for this
// on MainContextMenuHandler
await this.mainContextMenuHandler.noAccess();
return;
}
const menuEnabled = await this.mainContextMenuHandler.init();
if (!menuEnabled) {
return;
}
const ciphers = await this.cipherService.getAllDecryptedForUrl(url);
ciphers.sort((a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b));
if (ciphers.length === 0) {
await this.mainContextMenuHandler.noLogins(url);
return;
}
for (const cipher of ciphers) {
await this.updateForCipher(url, cipher);
}
}
private async updateForCipher(url: string, cipher: CipherView) {
if (
cipher == null ||
cipher.type !== CipherType.Login ||
cipher.reprompt !== CipherRepromptType.None
) {
return;
}
let title = cipher.name;
if (!Utils.isNullOrEmpty(title)) {
title += ` (${cipher.login.username})`;
}
await this.mainContextMenuHandler.loadOptions(title, cipher.id, url, cipher);
}
}

View File

@ -0,0 +1,193 @@
import { mock, MockProxy } from "jest-mock-extended";
import { AuthService } from "@bitwarden/common/abstractions/auth.service";
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { TotpService } from "@bitwarden/common/abstractions/totp.service";
import { CipherRepromptType } from "@bitwarden/common/enums/cipherRepromptType";
import { CipherType } from "@bitwarden/common/enums/cipherType";
import { Cipher } from "@bitwarden/common/models/domain/cipher";
import { CipherView } from "@bitwarden/common/models/view/cipher.view";
import { AutofillTabCommand } from "../commands/autofill-tab-command";
import {
CopyToClipboardAction,
ContextMenuClickedHandler,
CopyToClipboardOptions,
GeneratePasswordToClipboardAction,
} from "./context-menu-clicked-handler";
import {
AUTOFILL_ID,
COPY_PASSWORD_ID,
COPY_USERNAME_ID,
COPY_VERIFICATIONCODE_ID,
GENERATE_PASSWORD_ID,
} from "./main-context-menu-handler";
describe("ContextMenuClickedHandler", () => {
const createData = (
menuItemId: chrome.contextMenus.OnClickData["menuItemId"],
parentMenuItemId?: chrome.contextMenus.OnClickData["parentMenuItemId"]
): chrome.contextMenus.OnClickData => {
return {
menuItemId: menuItemId,
parentMenuItemId: parentMenuItemId,
editable: false,
pageUrl: "something",
};
};
const createCipher = (data?: {
id?: CipherView["id"];
username?: CipherView["login"]["username"];
password?: CipherView["login"]["password"];
totp?: CipherView["login"]["totp"];
}): CipherView => {
const { id, username, password, totp } = data || {};
const cipherView = new CipherView(
new Cipher({
id: id ?? "1",
type: CipherType.Login,
} as any)
);
cipherView.login.username = username ?? "USERNAME";
cipherView.login.password = password ?? "PASSWORD";
cipherView.login.totp = totp ?? "TOTP";
return cipherView;
};
let copyToClipboard: CopyToClipboardAction;
let generatePasswordToClipboard: GeneratePasswordToClipboardAction;
let authService: MockProxy<AuthService>;
let cipherService: MockProxy<CipherService>;
let autofillTabCommand: MockProxy<AutofillTabCommand>;
let totpService: MockProxy<TotpService>;
let eventCollectionService: MockProxy<EventCollectionService>;
let sut: ContextMenuClickedHandler;
beforeEach(() => {
copyToClipboard = jest.fn<void, [CopyToClipboardOptions]>();
generatePasswordToClipboard = jest.fn<Promise<void>, [tab: chrome.tabs.Tab]>();
authService = mock();
cipherService = mock();
autofillTabCommand = mock();
totpService = mock();
eventCollectionService = mock();
sut = new ContextMenuClickedHandler(
copyToClipboard,
generatePasswordToClipboard,
authService,
cipherService,
autofillTabCommand,
totpService,
eventCollectionService
);
});
afterEach(() => jest.resetAllMocks());
describe("run", () => {
it("can generate password", async () => {
await sut.run(createData(GENERATE_PASSWORD_ID), { id: 5 } as any);
expect(generatePasswordToClipboard).toBeCalledTimes(1);
expect(generatePasswordToClipboard).toBeCalledWith({
id: 5,
});
});
it("attempts to autofill the correct cipher", async () => {
const cipher = createCipher();
cipherService.getAllDecrypted.mockResolvedValue([cipher]);
await sut.run(createData("T_1", AUTOFILL_ID), { id: 5 } as any);
expect(autofillTabCommand.doAutofillTabWithCipherCommand).toBeCalledTimes(1);
expect(autofillTabCommand.doAutofillTabWithCipherCommand).toBeCalledWith({ id: 5 }, cipher);
});
it("copies username to clipboard", async () => {
cipherService.getAllDecrypted.mockResolvedValue([
createCipher({ username: "TEST_USERNAME" }),
]);
await sut.run(createData("T_1", COPY_USERNAME_ID));
expect(copyToClipboard).toBeCalledTimes(1);
expect(copyToClipboard).toHaveBeenCalledWith({ text: "TEST_USERNAME", options: undefined });
});
it("copies password to clipboard", async () => {
cipherService.getAllDecrypted.mockResolvedValue([
createCipher({ password: "TEST_PASSWORD" }),
]);
await sut.run(createData("T_1", COPY_PASSWORD_ID));
expect(copyToClipboard).toBeCalledTimes(1);
expect(copyToClipboard).toHaveBeenCalledWith({ text: "TEST_PASSWORD", options: undefined });
});
it("copies totp code to clipboard", async () => {
cipherService.getAllDecrypted.mockResolvedValue([createCipher({ totp: "TEST_TOTP_SEED" })]);
totpService.getCode.mockImplementation((seed) => {
if (seed === "TEST_TOTP_SEED") {
return Promise.resolve("123456");
}
return Promise.resolve("654321");
});
await sut.run(createData("T_1", COPY_VERIFICATIONCODE_ID));
expect(totpService.getCode).toHaveBeenCalledTimes(1);
expect(copyToClipboard).toHaveBeenCalledWith({ text: "123456" });
});
it("attempts to find a cipher when noop but unlocked", async () => {
cipherService.getAllDecryptedForUrl.mockResolvedValue([
{
...createCipher({ username: "NOOP_USERNAME" }),
reprompt: CipherRepromptType.None,
} as any,
]);
await sut.run(createData("T_noop", COPY_USERNAME_ID), { url: "https://test.com" } as any);
expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledTimes(1);
expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith("https://test.com");
expect(copyToClipboard).toHaveBeenCalledTimes(1);
expect(copyToClipboard).toHaveBeenCalledWith({
text: "NOOP_USERNAME",
tab: { url: "https://test.com" },
});
});
it("attempts to find a cipher when noop but unlocked", async () => {
cipherService.getAllDecryptedForUrl.mockResolvedValue([
{
...createCipher({ username: "NOOP_USERNAME" }),
reprompt: CipherRepromptType.Password,
} as any,
]);
await sut.run(createData("T_noop", COPY_USERNAME_ID), { url: "https://test.com" } as any);
expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledTimes(1);
expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith("https://test.com");
});
});
});

View File

@ -0,0 +1,239 @@
import { AuthService } from "@bitwarden/common/abstractions/auth.service";
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { TotpService } from "@bitwarden/common/abstractions/totp.service";
import { AuthenticationStatus } from "@bitwarden/common/enums/authenticationStatus";
import { CipherRepromptType } from "@bitwarden/common/enums/cipherRepromptType";
import { EventType } from "@bitwarden/common/enums/eventType";
import { StateFactory } from "@bitwarden/common/factories/stateFactory";
import { GlobalState } from "@bitwarden/common/models/domain/global-state";
import { CipherView } from "@bitwarden/common/models/view/cipher.view";
import LockedVaultPendingNotificationsItem from "../background/models/lockedVaultPendingNotificationsItem";
import {
authServiceFactory,
AuthServiceInitOptions,
} from "../background/service_factories/auth-service.factory";
import { autofillServiceFactory } from "../background/service_factories/autofill-service.factory";
import {
cipherServiceFactory,
CipherServiceInitOptions,
} from "../background/service_factories/cipher-service.factory";
import { eventCollectionServiceFactory } from "../background/service_factories/event-collection-service.factory";
import { CachedServices } from "../background/service_factories/factory-options";
import { passwordGenerationServiceFactory } from "../background/service_factories/password-generation-service.factory";
import { searchServiceFactory } from "../background/service_factories/search-service.factory";
import { stateServiceFactory } from "../background/service_factories/state-service.factory";
import { totpServiceFactory } from "../background/service_factories/totp-service.factory";
import { BrowserApi } from "../browser/browserApi";
import { copyToClipboard, GeneratePasswordToClipboardCommand } from "../clipboard";
import { AutofillTabCommand } from "../commands/autofill-tab-command";
import { Account } from "../models/account";
import {
AUTOFILL_ID,
COPY_IDENTIFIER_ID,
COPY_PASSWORD_ID,
COPY_USERNAME_ID,
COPY_VERIFICATIONCODE_ID,
GENERATE_PASSWORD_ID,
NOOP_COMMAND_SUFFIX,
} from "./main-context-menu-handler";
export type CopyToClipboardOptions = { text: string; tab: chrome.tabs.Tab };
export type CopyToClipboardAction = (options: CopyToClipboardOptions) => void;
export type GeneratePasswordToClipboardAction = (tab: chrome.tabs.Tab) => Promise<void>;
const NOT_IMPLEMENTED = (..._args: unknown[]) =>
Promise.reject<never>("This action is not implemented inside of a service worker context.");
export class ContextMenuClickedHandler {
constructor(
private copyToClipboard: CopyToClipboardAction,
private generatePasswordToClipboard: GeneratePasswordToClipboardAction,
private authService: AuthService,
private cipherService: CipherService,
private autofillTabCommand: AutofillTabCommand,
private totpService: TotpService,
private eventCollectionService: EventCollectionService
) {}
static async mv3Create(cachedServices: CachedServices) {
const stateFactory = new StateFactory(GlobalState, Account);
let searchService: SearchService | null = null;
const serviceOptions: AuthServiceInitOptions & CipherServiceInitOptions = {
apiServiceOptions: {
logoutCallback: NOT_IMPLEMENTED,
},
cipherServiceOptions: {
searchServiceFactory: () => searchService,
},
cryptoFunctionServiceOptions: {
win: self,
},
encryptServiceOptions: {
logMacFailures: false,
},
i18nServiceOptions: {
systemLanguage: chrome.i18n.getUILanguage(),
},
keyConnectorServiceOptions: {
logoutCallback: NOT_IMPLEMENTED,
},
logServiceOptions: {
isDev: false,
},
platformUtilsServiceOptions: {
biometricCallback: NOT_IMPLEMENTED,
clipboardWriteCallback: NOT_IMPLEMENTED,
win: self,
},
stateMigrationServiceOptions: {
stateFactory: stateFactory,
},
stateServiceOptions: {
stateFactory: stateFactory,
},
};
searchService = await searchServiceFactory(cachedServices, serviceOptions);
const generatePasswordToClipboardCommand = new GeneratePasswordToClipboardCommand(
await passwordGenerationServiceFactory(cachedServices, serviceOptions),
await stateServiceFactory(cachedServices, serviceOptions)
);
return new ContextMenuClickedHandler(
(options) => copyToClipboard(options.tab, options.text),
(tab) => generatePasswordToClipboardCommand.generatePasswordToClipboard(tab),
await authServiceFactory(cachedServices, serviceOptions),
await cipherServiceFactory(cachedServices, serviceOptions),
new AutofillTabCommand(await autofillServiceFactory(cachedServices, serviceOptions)),
await totpServiceFactory(cachedServices, serviceOptions),
await eventCollectionServiceFactory(cachedServices, serviceOptions)
);
}
static async onClickedListener(
info: chrome.contextMenus.OnClickData,
tab?: chrome.tabs.Tab,
cachedServices: CachedServices = {}
) {
const contextMenuClickedHandler = await ContextMenuClickedHandler.mv3Create(cachedServices);
await contextMenuClickedHandler.run(info, tab);
}
static async messageListener(
message: { command: string; data: LockedVaultPendingNotificationsItem },
cachedServices: CachedServices
) {
if (
message.command !== "unlockCompleted" ||
message.data.target !== "contextmenus.background"
) {
return;
}
const contextMenuClickedHandler = await ContextMenuClickedHandler.mv3Create(cachedServices);
await contextMenuClickedHandler.run(
message.data.commandToRetry.msg.data,
message.data.commandToRetry.sender.tab
);
}
async run(info: chrome.contextMenus.OnClickData, tab?: chrome.tabs.Tab) {
switch (info.menuItemId) {
case GENERATE_PASSWORD_ID:
if (!tab) {
return;
}
await this.generatePasswordToClipboard(tab);
break;
case COPY_IDENTIFIER_ID:
if (!tab) {
return;
}
this.copyToClipboard({ text: await this.getIdentifier(tab, info), tab: tab });
break;
default:
await this.cipherAction(info, tab);
}
}
async cipherAction(info: chrome.contextMenus.OnClickData, tab?: chrome.tabs.Tab) {
if ((await this.authService.getAuthStatus()) < AuthenticationStatus.Unlocked) {
const retryMessage: LockedVaultPendingNotificationsItem = {
commandToRetry: {
msg: { command: NOOP_COMMAND_SUFFIX, data: info },
sender: { tab: tab },
},
target: "contextmenus.background",
};
await BrowserApi.tabSendMessageData(
tab,
"addToLockedVaultPendingNotifications",
retryMessage
);
await BrowserApi.tabSendMessageData(tab, "promptForLogin");
return;
}
// NOTE: We don't actually use the first part of this ID, we further switch based on the parentMenuItemId
// I would really love to not add it but that is a departure from how it currently works.
const id = (info.menuItemId as string).split("_")[1]; // We create all the ids, we can guarantee they are strings
let cipher: CipherView | undefined;
if (id === NOOP_COMMAND_SUFFIX) {
// This NOOP item has come through which is generally only for no access state but since we got here
// we are actually unlocked we will do our best to find a good match of an item to autofill this is useful
// in scenarios like unlock on autofill
const ciphers = await this.cipherService.getAllDecryptedForUrl(tab.url);
cipher = ciphers.find((c) => c.reprompt === CipherRepromptType.None);
} else {
const ciphers = await this.cipherService.getAllDecrypted();
cipher = ciphers.find((c) => c.id === id);
}
if (cipher == null) {
return;
}
switch (info.parentMenuItemId) {
case AUTOFILL_ID:
if (tab == null) {
return;
}
await this.autofillTabCommand.doAutofillTabWithCipherCommand(tab, cipher);
break;
case COPY_USERNAME_ID:
this.copyToClipboard({ text: cipher.login.username, tab: tab });
break;
case COPY_PASSWORD_ID:
this.copyToClipboard({ text: cipher.login.password, tab: tab });
this.eventCollectionService.collect(EventType.Cipher_ClientCopiedPassword, cipher.id);
break;
case COPY_VERIFICATIONCODE_ID:
this.copyToClipboard({ text: await this.totpService.getCode(cipher.login.totp), tab: tab });
break;
}
}
private async getIdentifier(tab: chrome.tabs.Tab, info: chrome.contextMenus.OnClickData) {
return new Promise<string>((resolve, reject) => {
BrowserApi.sendTabsMessage(
tab.id,
{ command: "getClickedElement" },
{ frameId: info.frameId },
(identifier: string) => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError);
return;
}
resolve(identifier);
}
);
});
}
}

View File

@ -0,0 +1,137 @@
import { mock, MockProxy } from "jest-mock-extended";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { CipherType } from "@bitwarden/common/enums/cipherType";
import { Cipher } from "@bitwarden/common/models/domain/cipher";
import { CipherView } from "@bitwarden/common/models/view/cipher.view";
import { BrowserStateService } from "../services/abstractions/browser-state.service";
import { MainContextMenuHandler } from "./main-context-menu-handler";
describe("context-menu", () => {
let stateService: MockProxy<BrowserStateService>;
let i18nService: MockProxy<I18nService>;
let removeAllSpy: jest.SpyInstance<void, [callback?: () => void]>;
let createSpy: jest.SpyInstance<
string | number,
[createProperties: chrome.contextMenus.CreateProperties, callback?: () => void]
>;
let sut: MainContextMenuHandler;
beforeEach(() => {
stateService = mock();
i18nService = mock();
removeAllSpy = jest
.spyOn(chrome.contextMenus, "removeAll")
.mockImplementation((callback) => callback());
createSpy = jest.spyOn(chrome.contextMenus, "create").mockImplementation((props, callback) => {
if (callback) {
callback();
}
return props.id;
});
sut = new MainContextMenuHandler(stateService, i18nService);
});
afterEach(() => jest.resetAllMocks());
describe("init", () => {
it("has menu disabled", async () => {
stateService.getDisableContextMenuItem.mockResolvedValue(true);
const createdMenu = await sut.init();
expect(createdMenu).toBeFalsy();
expect(removeAllSpy).toHaveBeenCalledTimes(1);
});
it("has menu enabled, but does not have premium", async () => {
stateService.getDisableContextMenuItem.mockResolvedValue(false);
stateService.getCanAccessPremium.mockResolvedValue(false);
const createdMenu = await sut.init();
expect(createdMenu).toBeTruthy();
expect(createSpy).toHaveBeenCalledTimes(7);
});
it("has menu enabled and has premium", async () => {
stateService.getDisableContextMenuItem.mockResolvedValue(false);
stateService.getCanAccessPremium.mockResolvedValue(true);
const createdMenu = await sut.init();
expect(createdMenu).toBeTruthy();
expect(createSpy).toHaveBeenCalledTimes(8);
});
});
describe("loadOptions", () => {
const createCipher = (data?: {
id?: CipherView["id"];
username?: CipherView["login"]["username"];
password?: CipherView["login"]["password"];
totp?: CipherView["login"]["totp"];
viewPassword?: CipherView["viewPassword"];
}): CipherView => {
const { id, username, password, totp, viewPassword } = data || {};
const cipherView = new CipherView(
new Cipher({
id: id ?? "1",
type: CipherType.Login,
viewPassword: viewPassword ?? true,
} as any)
);
cipherView.login.username = username ?? "USERNAME";
cipherView.login.password = password ?? "PASSWORD";
cipherView.login.totp = totp ?? "TOTP";
return cipherView;
};
it("is not a login cipher", async () => {
await sut.loadOptions("TEST_TITLE", "1", "", {
...createCipher(),
type: CipherType.SecureNote,
} as any);
expect(createSpy).not.toHaveBeenCalled();
});
it("creates item for autofill", async () => {
await sut.loadOptions(
"TEST_TITLE",
"1",
"",
createCipher({
username: "",
totp: "",
viewPassword: false,
})
);
expect(createSpy).toHaveBeenCalledTimes(1);
});
it("create entry for each cipher piece", async () => {
stateService.getCanAccessPremium.mockResolvedValue(true);
await sut.loadOptions("TEST_TITLE", "1", "", createCipher());
// One for autofill, copy username, copy password, and copy totp code
expect(createSpy).toHaveBeenCalledTimes(4);
});
it("creates noop item for no cipher", async () => {
stateService.getCanAccessPremium.mockResolvedValue(true);
await sut.loadOptions("TEST_TITLE", "NOOP", "");
expect(createSpy).toHaveBeenCalledTimes(4);
});
});
});

View File

@ -0,0 +1,241 @@
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { CipherType } from "@bitwarden/common/enums/cipherType";
import { StateFactory } from "@bitwarden/common/factories/stateFactory";
import { Utils } from "@bitwarden/common/misc/utils";
import { GlobalState } from "@bitwarden/common/models/domain/global-state";
import { CipherView } from "@bitwarden/common/models/view/cipher.view";
import { CachedServices } from "../background/service_factories/factory-options";
import {
i18nServiceFactory,
I18nServiceInitOptions,
} from "../background/service_factories/i18n-service.factory";
import {
stateServiceFactory,
StateServiceInitOptions,
} from "../background/service_factories/state-service.factory";
import { Account } from "../models/account";
import { BrowserStateService } from "../services/abstractions/browser-state.service";
export const ROOT_ID = "root";
export const AUTOFILL_ID = "autofill";
export const COPY_USERNAME_ID = "copy-username";
export const COPY_PASSWORD_ID = "copy-password";
export const COPY_VERIFICATIONCODE_ID = "copy-totp";
export const COPY_IDENTIFIER_ID = "copy-identifier";
const SEPARATOR_ID = "separator";
export const GENERATE_PASSWORD_ID = "generate-password";
export const NOOP_COMMAND_SUFFIX = "noop";
export class MainContextMenuHandler {
//
private initRunning = false;
create: (options: chrome.contextMenus.CreateProperties) => Promise<void>;
constructor(private stateService: BrowserStateService, private i18nService: I18nService) {
if (chrome.contextMenus) {
this.create = (options) => {
return new Promise<void>((resolve, reject) => {
chrome.contextMenus.create(options, () => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError);
return;
}
resolve();
});
});
};
} else {
this.create = (_options) => Promise.resolve();
}
}
static async mv3Create(cachedServices: CachedServices) {
const stateFactory = new StateFactory(GlobalState, Account);
const serviceOptions: StateServiceInitOptions & I18nServiceInitOptions = {
cryptoFunctionServiceOptions: {
win: self,
},
encryptServiceOptions: {
logMacFailures: false,
},
i18nServiceOptions: {
systemLanguage: chrome.i18n.getUILanguage(),
},
logServiceOptions: {
isDev: false,
},
stateMigrationServiceOptions: {
stateFactory: stateFactory,
},
stateServiceOptions: {
stateFactory: stateFactory,
},
};
return new MainContextMenuHandler(
await stateServiceFactory(cachedServices, serviceOptions),
await i18nServiceFactory(cachedServices, serviceOptions)
);
}
/**
*
* @returns a boolean showing whether or not items were created
*/
async init(): Promise<boolean> {
const menuDisabled = await this.stateService.getDisableContextMenuItem();
if (this.initRunning) {
return menuDisabled;
}
try {
if (menuDisabled) {
await MainContextMenuHandler.removeAll();
return false;
}
const create = async (options: Omit<chrome.contextMenus.CreateProperties, "contexts">) => {
await this.create({ ...options, contexts: ["all"] });
};
await create({
id: ROOT_ID,
title: "Bitwarden",
});
await create({
id: AUTOFILL_ID,
parentId: ROOT_ID,
title: this.i18nService.t("autoFill"),
});
await create({
id: COPY_USERNAME_ID,
parentId: ROOT_ID,
title: this.i18nService.t("copyUsername"),
});
await create({
id: COPY_PASSWORD_ID,
parentId: ROOT_ID,
title: this.i18nService.t("copyPassword"),
});
if (await this.stateService.getCanAccessPremium()) {
await create({
id: COPY_VERIFICATIONCODE_ID,
parentId: ROOT_ID,
title: this.i18nService.t("copyVerificationCode"),
});
}
await create({
id: SEPARATOR_ID,
type: "separator",
parentId: ROOT_ID,
});
await create({
id: GENERATE_PASSWORD_ID,
parentId: ROOT_ID,
title: this.i18nService.t("generatePasswordCopied"),
});
await create({
id: COPY_IDENTIFIER_ID,
parentId: ROOT_ID,
title: this.i18nService.t("copyElementIdentifier"),
});
return true;
} finally {
this.initRunning = false;
}
}
static async removeAll() {
return new Promise<void>((resolve, reject) => {
chrome.contextMenus.removeAll(() => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError);
return;
}
resolve();
});
});
}
static remove(menuItemId: string) {
return new Promise<void>((resolve, reject) => {
chrome.contextMenus.remove(menuItemId, () => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError);
return;
}
resolve();
});
});
}
async loadOptions(title: string, id: string, url: string, cipher?: CipherView | undefined) {
if (cipher != null && cipher.type !== CipherType.Login) {
return;
}
const sanitizedTitle = MainContextMenuHandler.sanitizeContextMenuTitle(title);
const createChildItem = async (parent: string) => {
const menuItemId = `${parent}_${id}`;
return await this.create({
type: "normal",
id: menuItemId,
parentId: parent,
title: sanitizedTitle,
contexts: ["all"],
});
};
if (cipher == null || !Utils.isNullOrEmpty(cipher.login.password)) {
await createChildItem(AUTOFILL_ID);
if (cipher?.viewPassword ?? true) {
await createChildItem(COPY_PASSWORD_ID);
}
}
if (cipher == null || !Utils.isNullOrEmpty(cipher.login.username)) {
await createChildItem(COPY_USERNAME_ID);
}
const canAccessPremium = await this.stateService.getCanAccessPremium();
if (canAccessPremium && (cipher == null || !Utils.isNullOrEmpty(cipher.login.totp))) {
await createChildItem(COPY_VERIFICATIONCODE_ID);
}
}
static sanitizeContextMenuTitle(title: string): string {
return title.replace(/&/g, "&&");
}
async noAccess() {
if (await this.init()) {
const authed = await this.stateService.getIsAuthenticated();
await this.loadOptions(
this.i18nService.t(authed ? "unlockVaultMenu" : "loginToVaultMenu"),
NOOP_COMMAND_SUFFIX,
"<all_urls>"
);
}
}
async noLogins(url: string) {
await this.loadOptions(this.i18nService.t("noMatchingLogins"), NOOP_COMMAND_SUFFIX, url);
}
}

View File

@ -1,10 +1,12 @@
import { CipherView } from "@bitwarden/common/models/view/cipher.view";
import AutofillPageDetails from "../models/autofillPageDetails";
import { AutofillService } from "../services/abstractions/autofill.service";
export class AutoFillActiveTabCommand {
export class AutofillTabCommand {
constructor(private autofillService: AutofillService) {}
async doAutoFillActiveTabCommand(tab: chrome.tabs.Tab) {
async doAutofillTabCommand(tab: chrome.tabs.Tab) {
if (!tab.id) {
throw new Error("Tab does not have an id, cannot complete autofill.");
}
@ -23,6 +25,30 @@ export class AutoFillActiveTabCommand {
);
}
async doAutofillTabWithCipherCommand(tab: chrome.tabs.Tab, cipher: CipherView) {
if (!tab.id) {
throw new Error("Tab does not have an id, cannot complete autofill.");
}
const details = await this.collectPageDetails(tab.id);
await this.autofillService.doAutoFill({
tab: tab,
cipher: cipher,
pageDetails: [
{
frameId: 0,
tab: tab,
details: details,
},
],
skipLastUsed: false,
skipUsernameOnlyFill: false,
onlyEmptyFields: false,
onlyVisibleFields: false,
fillNewPassword: true,
});
}
private async collectPageDetails(tabId: number): Promise<AutofillPageDetails> {
return new Promise((resolve, reject) => {
chrome.tabs.sendMessage(

View File

@ -54,9 +54,12 @@ document.addEventListener("contextmenu", (event) => {
});
// Runs when the 'Copy Custom Field Name' context menu item is actually clicked.
chrome.runtime.onMessage.addListener((event) => {
chrome.runtime.onMessage.addListener((event, _sender, sendResponse) => {
if (event.command === "getClickedElement") {
const identifier = getClickedElementIdentifier();
if (sendResponse) {
sendResponse(identifier);
}
chrome.runtime.sendMessage({
command: "getClickedElementResponse",
sender: "contextMenuHandler",

View File

@ -0,0 +1,25 @@
import { combine } from "./combine";
describe("combine", () => {
it("runs", () => {
const combined = combine([
(arg: Record<string, unknown>, serviceCache: Record<string, unknown>) => {
arg["one"] = true;
serviceCache["one"] = true;
},
(arg: Record<string, unknown>, serviceCache: Record<string, unknown>) => {
if (serviceCache["one"] !== true) {
throw new Error("One should have ran.");
}
arg["two"] = true;
},
]);
const arg: Record<string, unknown> = {};
combined(arg);
expect(arg["one"]).toBeTruthy();
expect(arg["two"]).toBeTruthy();
});
});

View File

@ -0,0 +1,15 @@
import { CachedServices } from "../background/service_factories/factory-options";
type Listener<T extends unknown[]> = (...args: [...T, CachedServices]) => void;
export const combine = <T extends unknown[]>(
listeners: Listener<T>[],
startingServices: CachedServices = {}
) => {
return (...args: T) => {
const cachedServices = { ...startingServices };
for (const listener of listeners) {
listener(...[...args, cachedServices]);
}
};
};

View File

@ -0,0 +1,40 @@
import { CipherContextMenuHandler } from "../browser/cipher-context-menu-handler";
import { ContextMenuClickedHandler } from "../browser/context-menu-clicked-handler";
import { combine } from "./combine";
import { onCommandListener } from "./onCommandListener";
import { onInstallListener } from "./onInstallListener";
import { UpdateBadge } from "./update-badge";
const tabsOnActivatedListener = combine([
UpdateBadge.tabsOnActivatedListener,
CipherContextMenuHandler.tabsOnActivatedListener,
]);
const tabsOnReplacedListener = combine([
UpdateBadge.tabsOnReplacedListener,
CipherContextMenuHandler.tabsOnReplacedListener,
]);
const tabsOnUpdatedListener = combine([
UpdateBadge.tabsOnUpdatedListener,
CipherContextMenuHandler.tabsOnUpdatedListener,
]);
const contextMenusClickedListener = ContextMenuClickedHandler.onClickedListener;
const runtimeMessageListener = combine([
UpdateBadge.messageListener,
CipherContextMenuHandler.messageListener,
ContextMenuClickedHandler.messageListener,
]);
export {
tabsOnActivatedListener,
tabsOnReplacedListener,
tabsOnUpdatedListener,
contextMenusClickedListener,
runtimeMessageListener,
onCommandListener,
onInstallListener,
};

View File

@ -14,7 +14,7 @@ import {
import { stateServiceFactory } from "../background/service_factories/state-service.factory";
import { BrowserApi } from "../browser/browserApi";
import { GeneratePasswordToClipboardCommand } from "../clipboard";
import { AutoFillActiveTabCommand } from "../commands/autoFillActiveTabCommand";
import { AutofillTabCommand } from "../commands/autofill-tab-command";
import { Account } from "../models/account";
export const onCommandListener = async (command: string, tab: chrome.tabs.Tab) => {
@ -75,8 +75,8 @@ const doAutoFillLogin = async (tab: chrome.tabs.Tab): Promise<void> => {
return;
}
const command = new AutoFillActiveTabCommand(autofillService);
await command.doAutoFillActiveTabCommand(tab);
const command = new AutofillTabCommand(autofillService);
await command.doAutofillTabCommand(tab);
};
const doGeneratePasswordToClipboard = async (tab: chrome.tabs.Tab): Promise<void> => {

View File

@ -1,13 +1,16 @@
import { StateFactory } from "@bitwarden/common/factories/stateFactory";
import { GlobalState } from "@bitwarden/common/models/domain/global-state";
import { environmentServiceFactory } from "../background/service_factories/environment-service.factory";
import {
environmentServiceFactory,
EnvironmentServiceInitOptions,
} from "../background/service_factories/environment-service.factory";
import { BrowserApi } from "../browser/browserApi";
import { Account } from "../models/account";
export async function onInstallListener(details: chrome.runtime.InstalledDetails) {
const cache = {};
const opts = {
const opts: EnvironmentServiceInitOptions = {
encryptServiceOptions: {
logMacFailures: false,
},
@ -27,7 +30,7 @@ export async function onInstallListener(details: chrome.runtime.InstalledDetails
const environmentService = await environmentServiceFactory(cache, opts);
setTimeout(async () => {
if (details.reason != null && details.reason === "install") {
if (details.reason != null && details.reason === chrome.runtime.OnInstalledReason.INSTALL) {
BrowserApi.createNewTab("https://bitwarden.com/browser-start/");
if (await environmentService.hasManagedEnvironment()) {

View File

@ -43,31 +43,47 @@ export class UpdateBadge {
"deletedCipher",
];
static async tabsOnActivatedListener(activeInfo: chrome.tabs.TabActiveInfo) {
await new UpdateBadge(self).run({ tabId: activeInfo.tabId, windowId: activeInfo.windowId });
static async tabsOnActivatedListener(
activeInfo: chrome.tabs.TabActiveInfo,
serviceCache: Record<string, unknown>
) {
await new UpdateBadge(self).run({
tabId: activeInfo.tabId,
existingServices: serviceCache,
windowId: activeInfo.windowId,
});
}
static async tabsOnReplacedListener(addedTabId: number, removedTabId: number) {
await new UpdateBadge(self).run({ tabId: addedTabId });
static async tabsOnReplacedListener(
addedTabId: number,
removedTabId: number,
serviceCache: Record<string, unknown>
) {
await new UpdateBadge(self).run({ tabId: addedTabId, existingServices: serviceCache });
}
static async tabsOnUpdatedListener(
tabId: number,
changeInfo: chrome.tabs.TabChangeInfo,
tab: chrome.tabs.Tab
tab: chrome.tabs.Tab,
serviceCache: Record<string, unknown>
) {
await new UpdateBadge(self).run({ tabId, windowId: tab.windowId });
await new UpdateBadge(self).run({
tabId,
existingServices: serviceCache,
windowId: tab.windowId,
});
}
static async messageListener(
serviceCache: Record<string, unknown>,
message: { command: string; tabId: number }
message: { command: string; tabId: number },
serviceCache: Record<string, unknown>
) {
if (!UpdateBadge.listenedToCommands.includes(message.command)) {
return;
}
await new UpdateBadge(self).run();
await new UpdateBadge(self).run({ existingServices: serviceCache });
}
constructor(win: Window & typeof globalThis) {

View File

@ -1,9 +1,16 @@
export type TabMessage = CopyTextTabMessage | TabMessageBase<"clearClipboard">;
export type TabMessage =
| CopyTextTabMessage
| ClearClipboardTabMessage
| GetClickedElementTabMessage;
export type TabMessageBase<T extends string> = {
command: T;
};
export type CopyTextTabMessage = TabMessageBase<"copyText"> & {
type CopyTextTabMessage = TabMessageBase<"copyText"> & {
text: string;
};
type ClearClipboardTabMessage = TabMessageBase<"clearClipboard">;
type GetClickedElementTabMessage = TabMessageBase<"getClickedElement">;

View File

@ -25,8 +25,14 @@ const runtime = {
getManifest: jest.fn(),
};
const contextMenus = {
create: jest.fn(),
removeAll: jest.fn(),
};
// set chrome
global.chrome = {
storage,
runtime,
contextMenus,
} as any;

View File

@ -66,8 +66,8 @@ export abstract class CipherService {
deleteManyWithServer: (ids: string[]) => Promise<any>;
deleteAttachment: (id: string, attachmentId: string) => Promise<void>;
deleteAttachmentWithServer: (id: string, attachmentId: string) => Promise<void>;
sortCiphersByLastUsed: (a: any, b: any) => number;
sortCiphersByLastUsedThenName: (a: any, b: any) => number;
sortCiphersByLastUsed: (a: CipherView, b: CipherView) => number;
sortCiphersByLastUsedThenName: (a: CipherView, b: CipherView) => number;
getLocaleSortingFunction: () => (a: CipherView, b: CipherView) => number;
softDelete: (id: string | string[]) => Promise<any>;
softDeleteWithServer: (id: string) => Promise<any>;