From d79fd7f417808750135821362c1cbbc76adcb3b4 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Fri, 6 Jan 2023 19:31:32 -0500 Subject: [PATCH] [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 * Address PR Feedback * Specify a Document Url for Context Menu Items * Update Test * Use Generate Password Callback * Remove DocumentUrlPatterns Co-authored-by: Matt Gibson --- .github/whitelist-capital-letters.txt | 1 - apps/browser/src/background.ts | 32 +- .../src/background/contextMenus.background.ts | 136 +------- .../browser/src/background/main.background.ts | 318 +++--------------- .../cipher-service.factory.ts | 2 +- .../encrypt-service.factory.ts | 25 +- .../service_factories/factory-options.ts | 2 +- apps/browser/src/browser/browserApi.ts | 15 +- .../cipher-context-menu-handler.spec.ts | 109 ++++++ .../browser/cipher-context-menu-handler.ts | 185 ++++++++++ .../context-menu-clicked-handler.spec.ts | 193 +++++++++++ .../browser/context-menu-clicked-handler.ts | 239 +++++++++++++ .../browser/main-context-menu-handler.spec.ts | 137 ++++++++ .../src/browser/main-context-menu-handler.ts | 241 +++++++++++++ ...eTabCommand.ts => autofill-tab-command.ts} | 30 +- .../browser/src/content/contextMenuHandler.ts | 5 +- apps/browser/src/listeners/combine.spec.ts | 25 ++ apps/browser/src/listeners/combine.ts | 15 + apps/browser/src/listeners/index.ts | 40 +++ .../src/listeners/onCommandListener.ts | 6 +- .../src/listeners/onInstallListener.ts | 9 +- apps/browser/src/listeners/update-badge.ts | 34 +- apps/browser/src/types/tab-messages.ts | 11 +- apps/browser/test.setup.ts | 6 + .../common/src/abstractions/cipher.service.ts | 4 +- 25 files changed, 1360 insertions(+), 460 deletions(-) create mode 100644 apps/browser/src/browser/cipher-context-menu-handler.spec.ts create mode 100644 apps/browser/src/browser/cipher-context-menu-handler.ts create mode 100644 apps/browser/src/browser/context-menu-clicked-handler.spec.ts create mode 100644 apps/browser/src/browser/context-menu-clicked-handler.ts create mode 100644 apps/browser/src/browser/main-context-menu-handler.spec.ts create mode 100644 apps/browser/src/browser/main-context-menu-handler.ts rename apps/browser/src/commands/{autoFillActiveTabCommand.ts => autofill-tab-command.ts} (57%) create mode 100644 apps/browser/src/listeners/combine.spec.ts create mode 100644 apps/browser/src/listeners/combine.ts create mode 100644 apps/browser/src/listeners/index.ts diff --git a/.github/whitelist-capital-letters.txt b/.github/whitelist-capital-letters.txt index af82f6b811a..a6d2f96079f 100644 --- a/.github/whitelist-capital-letters.txt +++ b/.github/whitelist-capital-letters.txt @@ -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 diff --git a/apps/browser/src/background.ts b/apps/browser/src/background.ts index fc020b19aa3..a6baca89c47 100644 --- a/apps/browser/src/background.ts +++ b/apps/browser/src/background.ts @@ -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, - message: { command: string } -) => void | Promise)[] = [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(() => { diff --git a/apps/browser/src/background/contextMenus.background.ts b/apps/browser/src/background/contextMenus.background.ts index 98223280a23..9a9ba0f5140 100644 --- a/apps/browser/src/background/contextMenus.background.ts +++ b/apps/browser/src/background/contextMenus.background.ts @@ -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", - }); - } } diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 4ef078bde5b..aea7f9600d9 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -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((resolve) => { - chrome.contextMenus.removeAll(() => { - resolve(); - if (chrome.runtime.lastError) { - return; - } - }); - }); - } - - private contextMenusCreate(options: any) { - return new Promise((resolve) => { - chrome.contextMenus.create(options, () => { - resolve(); - if (chrome.runtime.lastError) { - return; - } - }); - }); - } - - private async actionSetIcon(theAction: any, suffix: string, windowId?: number): Promise { - 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((resolve) => { - theAction.setIcon(options, () => resolve()); - }); - } - } } diff --git a/apps/browser/src/background/service_factories/cipher-service.factory.ts b/apps/browser/src/background/service_factories/cipher-service.factory.ts index 6d31ebd76f7..020c19983c2 100644 --- a/apps/browser/src/background/service_factories/cipher-service.factory.ts +++ b/apps/browser/src/background/service_factories/cipher-service.factory.ts @@ -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), diff --git a/apps/browser/src/background/service_factories/encrypt-service.factory.ts b/apps/browser/src/background/service_factories/encrypt-service.factory.ts index 6e4c104b05b..5b2a3766a3f 100644 --- a/apps/browser/src/background/service_factories/encrypt-service.factory.ts +++ b/apps/browser/src/background/service_factories/encrypt-service.factory.ts @@ -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 { - 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 + ) ); } diff --git a/apps/browser/src/background/service_factories/factory-options.ts b/apps/browser/src/background/service_factories/factory-options.ts index 12129e4e673..7dde09204ef 100644 --- a/apps/browser/src/background/service_factories/factory-options.ts +++ b/apps/browser/src/background/service_factories/factory-options.ts @@ -1,4 +1,4 @@ -export type CachedServices = Record; +export type CachedServices = Record; export type FactoryOptions = { alwaysInitializeNewService?: boolean; diff --git a/apps/browser/src/browser/browserApi.ts b/apps/browser/src/browser/browserApi.ts index d64231833df..60666d23fea 100644 --- a/apps/browser/src/browser/browserApi.ts +++ b/apps/browser/src/browser/browserApi.ts @@ -44,7 +44,7 @@ export class BrowserApi { static async tabsQuery(options: chrome.tabs.QueryInfo): Promise { 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 { + ): Promise { const obj: any = { command: command, }; @@ -75,11 +75,11 @@ export class BrowserApi { return BrowserApi.tabSendMessage(tab, obj); } - static async tabSendMessage( + static async tabSendMessage( tab: chrome.tabs.Tab, - obj: any, + obj: T, options: chrome.tabs.MessageSendOptions = null - ): Promise { + ): Promise { if (!tab || !tab.id) { return; } @@ -94,12 +94,13 @@ export class BrowserApi { }); } - static sendTabsMessage( + static sendTabsMessage( tabId: number, message: TabMessage, + options?: chrome.tabs.MessageSendOptions, responseCallback?: (response: T) => void ) { - chrome.tabs.sendMessage(tabId, message, responseCallback); + chrome.tabs.sendMessage(tabId, message, options, responseCallback); } static async getPrivateModeWindows(): Promise { diff --git a/apps/browser/src/browser/cipher-context-menu-handler.spec.ts b/apps/browser/src/browser/cipher-context-menu-handler.spec.ts new file mode 100644 index 00000000000..39b7f8e3619 --- /dev/null +++ b/apps/browser/src/browser/cipher-context-menu-handler.spec.ts @@ -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; + let authService: MockProxy; + let cipherService: MockProxy; + + 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 + ); + }); + }); +}); diff --git a/apps/browser/src/browser/cipher-context-menu-handler.ts b/apps/browser/src/browser/cipher-context-menu-handler.ts new file mode 100644 index 00000000000..ea87f8da93e --- /dev/null +++ b/apps/browser/src/browser/cipher-context-menu-handler.ts @@ -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); + } +} diff --git a/apps/browser/src/browser/context-menu-clicked-handler.spec.ts b/apps/browser/src/browser/context-menu-clicked-handler.spec.ts new file mode 100644 index 00000000000..a4d84aad126 --- /dev/null +++ b/apps/browser/src/browser/context-menu-clicked-handler.spec.ts @@ -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; + let cipherService: MockProxy; + let autofillTabCommand: MockProxy; + let totpService: MockProxy; + let eventCollectionService: MockProxy; + + let sut: ContextMenuClickedHandler; + + beforeEach(() => { + copyToClipboard = jest.fn(); + generatePasswordToClipboard = jest.fn, [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"); + }); + }); +}); diff --git a/apps/browser/src/browser/context-menu-clicked-handler.ts b/apps/browser/src/browser/context-menu-clicked-handler.ts new file mode 100644 index 00000000000..c11c6dbb94f --- /dev/null +++ b/apps/browser/src/browser/context-menu-clicked-handler.ts @@ -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; + +const NOT_IMPLEMENTED = (..._args: unknown[]) => + Promise.reject("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((resolve, reject) => { + BrowserApi.sendTabsMessage( + tab.id, + { command: "getClickedElement" }, + { frameId: info.frameId }, + (identifier: string) => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + return; + } + + resolve(identifier); + } + ); + }); + } +} diff --git a/apps/browser/src/browser/main-context-menu-handler.spec.ts b/apps/browser/src/browser/main-context-menu-handler.spec.ts new file mode 100644 index 00000000000..2dd41faf043 --- /dev/null +++ b/apps/browser/src/browser/main-context-menu-handler.spec.ts @@ -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; + let i18nService: MockProxy; + + let removeAllSpy: jest.SpyInstance 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); + }); + }); +}); diff --git a/apps/browser/src/browser/main-context-menu-handler.ts b/apps/browser/src/browser/main-context-menu-handler.ts new file mode 100644 index 00000000000..e78fb89023d --- /dev/null +++ b/apps/browser/src/browser/main-context-menu-handler.ts @@ -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; + + constructor(private stateService: BrowserStateService, private i18nService: I18nService) { + if (chrome.contextMenus) { + this.create = (options) => { + return new Promise((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 { + const menuDisabled = await this.stateService.getDisableContextMenuItem(); + + if (this.initRunning) { + return menuDisabled; + } + + try { + if (menuDisabled) { + await MainContextMenuHandler.removeAll(); + return false; + } + + const create = async (options: Omit) => { + 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((resolve, reject) => { + chrome.contextMenus.removeAll(() => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + return; + } + + resolve(); + }); + }); + } + + static remove(menuItemId: string) { + return new Promise((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, + "" + ); + } + } + + async noLogins(url: string) { + await this.loadOptions(this.i18nService.t("noMatchingLogins"), NOOP_COMMAND_SUFFIX, url); + } +} diff --git a/apps/browser/src/commands/autoFillActiveTabCommand.ts b/apps/browser/src/commands/autofill-tab-command.ts similarity index 57% rename from apps/browser/src/commands/autoFillActiveTabCommand.ts rename to apps/browser/src/commands/autofill-tab-command.ts index 74cdad55d73..cf94dc93ae3 100644 --- a/apps/browser/src/commands/autoFillActiveTabCommand.ts +++ b/apps/browser/src/commands/autofill-tab-command.ts @@ -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 { return new Promise((resolve, reject) => { chrome.tabs.sendMessage( diff --git a/apps/browser/src/content/contextMenuHandler.ts b/apps/browser/src/content/contextMenuHandler.ts index b6d0159df0f..30aa40be4ac 100644 --- a/apps/browser/src/content/contextMenuHandler.ts +++ b/apps/browser/src/content/contextMenuHandler.ts @@ -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", diff --git a/apps/browser/src/listeners/combine.spec.ts b/apps/browser/src/listeners/combine.spec.ts new file mode 100644 index 00000000000..3975d27017d --- /dev/null +++ b/apps/browser/src/listeners/combine.spec.ts @@ -0,0 +1,25 @@ +import { combine } from "./combine"; + +describe("combine", () => { + it("runs", () => { + const combined = combine([ + (arg: Record, serviceCache: Record) => { + arg["one"] = true; + serviceCache["one"] = true; + }, + (arg: Record, serviceCache: Record) => { + if (serviceCache["one"] !== true) { + throw new Error("One should have ran."); + } + arg["two"] = true; + }, + ]); + + const arg: Record = {}; + combined(arg); + + expect(arg["one"]).toBeTruthy(); + + expect(arg["two"]).toBeTruthy(); + }); +}); diff --git a/apps/browser/src/listeners/combine.ts b/apps/browser/src/listeners/combine.ts new file mode 100644 index 00000000000..278772ba214 --- /dev/null +++ b/apps/browser/src/listeners/combine.ts @@ -0,0 +1,15 @@ +import { CachedServices } from "../background/service_factories/factory-options"; + +type Listener = (...args: [...T, CachedServices]) => void; + +export const combine = ( + listeners: Listener[], + startingServices: CachedServices = {} +) => { + return (...args: T) => { + const cachedServices = { ...startingServices }; + for (const listener of listeners) { + listener(...[...args, cachedServices]); + } + }; +}; diff --git a/apps/browser/src/listeners/index.ts b/apps/browser/src/listeners/index.ts new file mode 100644 index 00000000000..b55ce884a10 --- /dev/null +++ b/apps/browser/src/listeners/index.ts @@ -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, +}; diff --git a/apps/browser/src/listeners/onCommandListener.ts b/apps/browser/src/listeners/onCommandListener.ts index c52e5cb61ac..395285d40e6 100644 --- a/apps/browser/src/listeners/onCommandListener.ts +++ b/apps/browser/src/listeners/onCommandListener.ts @@ -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 => { 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 => { diff --git a/apps/browser/src/listeners/onInstallListener.ts b/apps/browser/src/listeners/onInstallListener.ts index cc2d60594c9..92cd2fd6b78 100644 --- a/apps/browser/src/listeners/onInstallListener.ts +++ b/apps/browser/src/listeners/onInstallListener.ts @@ -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()) { diff --git a/apps/browser/src/listeners/update-badge.ts b/apps/browser/src/listeners/update-badge.ts index c7a3d32e59a..ac1ce880389 100644 --- a/apps/browser/src/listeners/update-badge.ts +++ b/apps/browser/src/listeners/update-badge.ts @@ -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 + ) { + 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 + ) { + 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 ) { - 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, - message: { command: string; tabId: number } + message: { command: string; tabId: number }, + serviceCache: Record ) { if (!UpdateBadge.listenedToCommands.includes(message.command)) { return; } - await new UpdateBadge(self).run(); + await new UpdateBadge(self).run({ existingServices: serviceCache }); } constructor(win: Window & typeof globalThis) { diff --git a/apps/browser/src/types/tab-messages.ts b/apps/browser/src/types/tab-messages.ts index 12496f5aa3d..dbedb3c4a55 100644 --- a/apps/browser/src/types/tab-messages.ts +++ b/apps/browser/src/types/tab-messages.ts @@ -1,9 +1,16 @@ -export type TabMessage = CopyTextTabMessage | TabMessageBase<"clearClipboard">; +export type TabMessage = + | CopyTextTabMessage + | ClearClipboardTabMessage + | GetClickedElementTabMessage; export type TabMessageBase = { command: T; }; -export type CopyTextTabMessage = TabMessageBase<"copyText"> & { +type CopyTextTabMessage = TabMessageBase<"copyText"> & { text: string; }; + +type ClearClipboardTabMessage = TabMessageBase<"clearClipboard">; + +type GetClickedElementTabMessage = TabMessageBase<"getClickedElement">; diff --git a/apps/browser/test.setup.ts b/apps/browser/test.setup.ts index a231353f64b..f87fa9c2c12 100644 --- a/apps/browser/test.setup.ts +++ b/apps/browser/test.setup.ts @@ -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; diff --git a/libs/common/src/abstractions/cipher.service.ts b/libs/common/src/abstractions/cipher.service.ts index b551f209910..457bbcd962b 100644 --- a/libs/common/src/abstractions/cipher.service.ts +++ b/libs/common/src/abstractions/cipher.service.ts @@ -66,8 +66,8 @@ export abstract class CipherService { deleteManyWithServer: (ids: string[]) => Promise; deleteAttachment: (id: string, attachmentId: string) => Promise; deleteAttachmentWithServer: (id: string, attachmentId: string) => Promise; - 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; softDeleteWithServer: (id: string) => Promise;