[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:
parent
574c18ba3f
commit
d79fd7f417
|
@ -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
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export type CachedServices = Record<string, any>;
|
||||
export type CachedServices = Record<string, unknown>;
|
||||
|
||||
export type FactoryOptions = {
|
||||
alwaysInitializeNewService?: boolean;
|
||||
|
|
|
@ -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[]> {
|
||||
|
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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(
|
|
@ -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",
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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]);
|
||||
}
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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> => {
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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">;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>;
|
||||
|
|
Loading…
Reference in New Issue