mirror of
https://github.com/bitwarden/browser.git
synced 2025-01-31 22:51:28 +01:00
[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
1
.github/whitelist-capital-letters.txt
vendored
1
.github/whitelist-capital-letters.txt
vendored
@ -207,7 +207,6 @@
|
|||||||
./apps/browser/src/safari/safari/SafariWebExtensionHandler.swift
|
./apps/browser/src/safari/safari/SafariWebExtensionHandler.swift
|
||||||
./apps/browser/src/safari/safari/Info.plist
|
./apps/browser/src/safari/safari/Info.plist
|
||||||
./apps/browser/src/safari/desktop.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.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/onCommandListener.ts
|
||||||
./apps/browser/src/listeners/onInstallListener.ts
|
./apps/browser/src/listeners/onInstallListener.ts
|
||||||
./apps/browser/src/services/browserFileDownloadService.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 { registerAlarms } from "./alarms/register-alarms";
|
||||||
import MainBackground from "./background/main.background";
|
import MainBackground from "./background/main.background";
|
||||||
import { BrowserApi } from "./browser/browserApi";
|
import { BrowserApi } from "./browser/browserApi";
|
||||||
import { onCommandListener } from "./listeners/onCommandListener";
|
import {
|
||||||
import { onInstallListener } from "./listeners/onInstallListener";
|
contextMenusClickedListener,
|
||||||
import { UpdateBadge } from "./listeners/update-badge";
|
onCommandListener,
|
||||||
|
onInstallListener,
|
||||||
const manifestV3MessageListeners: ((
|
runtimeMessageListener,
|
||||||
serviceCache: Record<string, unknown>,
|
tabsOnActivatedListener,
|
||||||
message: { command: string }
|
tabsOnReplacedListener,
|
||||||
) => void | Promise<void>)[] = [UpdateBadge.messageListener];
|
tabsOnUpdatedListener,
|
||||||
|
} from "./listeners";
|
||||||
|
|
||||||
if (BrowserApi.manifestVersion === 3) {
|
if (BrowserApi.manifestVersion === 3) {
|
||||||
chrome.commands.onCommand.addListener(onCommandListener);
|
chrome.commands.onCommand.addListener(onCommandListener);
|
||||||
chrome.runtime.onInstalled.addListener(onInstallListener);
|
chrome.runtime.onInstalled.addListener(onInstallListener);
|
||||||
chrome.alarms.onAlarm.addListener(onAlarmListener);
|
chrome.alarms.onAlarm.addListener(onAlarmListener);
|
||||||
registerAlarms();
|
registerAlarms();
|
||||||
chrome.tabs.onActivated.addListener(UpdateBadge.tabsOnActivatedListener);
|
chrome.tabs.onActivated.addListener(tabsOnActivatedListener);
|
||||||
chrome.tabs.onReplaced.addListener(UpdateBadge.tabsOnReplacedListener);
|
chrome.tabs.onReplaced.addListener(tabsOnReplacedListener);
|
||||||
chrome.tabs.onUpdated.addListener(UpdateBadge.tabsOnUpdatedListener);
|
chrome.tabs.onUpdated.addListener(tabsOnUpdatedListener);
|
||||||
BrowserApi.messageListener("runtime.background", (message) => {
|
chrome.contextMenus.onClicked.addListener(contextMenusClickedListener);
|
||||||
const serviceCache = {};
|
BrowserApi.messageListener("runtime.background", runtimeMessageListener);
|
||||||
|
|
||||||
manifestV3MessageListeners.forEach((listener) => {
|
|
||||||
listener(serviceCache, message);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
const bitwardenMain = ((window as any).bitwardenMain = new MainBackground());
|
const bitwardenMain = ((window as any).bitwardenMain = new MainBackground());
|
||||||
bitwardenMain.bootstrap().then(() => {
|
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 { BrowserApi } from "../browser/browserApi";
|
||||||
|
import { ContextMenuClickedHandler } from "../browser/context-menu-clicked-handler";
|
||||||
|
|
||||||
import MainBackground from "./main.background";
|
|
||||||
import LockedVaultPendingNotificationsItem from "./models/lockedVaultPendingNotificationsItem";
|
import LockedVaultPendingNotificationsItem from "./models/lockedVaultPendingNotificationsItem";
|
||||||
|
|
||||||
export default class ContextMenusBackground {
|
export default class ContextMenusBackground {
|
||||||
private readonly noopCommandSuffix = "noop";
|
private contextMenus: typeof chrome.contextMenus;
|
||||||
private contextMenus: any;
|
|
||||||
|
|
||||||
constructor(
|
constructor(private contextMenuClickedHandler: ContextMenuClickedHandler) {
|
||||||
private main: MainBackground,
|
|
||||||
private cipherService: CipherService,
|
|
||||||
private passwordGenerationService: PasswordGenerationService,
|
|
||||||
private platformUtilsService: PlatformUtilsService,
|
|
||||||
private authService: AuthService,
|
|
||||||
private eventCollectionService: EventCollectionService,
|
|
||||||
private totpService: TotpService
|
|
||||||
) {
|
|
||||||
this.contextMenus = chrome.contextMenus;
|
this.contextMenus = chrome.contextMenus;
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
init() {
|
||||||
if (!this.contextMenus) {
|
if (!this.contextMenus) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.contextMenus.onClicked.addListener(
|
this.contextMenus.onClicked.addListener((info, tab) =>
|
||||||
async (info: chrome.contextMenus.OnClickData, tab: chrome.tabs.Tab) => {
|
this.contextMenuClickedHandler.run(info, 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
BrowserApi.messageListener(
|
BrowserApi.messageListener(
|
||||||
"contextmenus.background",
|
"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") {
|
if (msg.command === "unlockCompleted" && msg.data.target === "contextmenus.background") {
|
||||||
await this.cipherAction(
|
await this.contextMenuClickedHandler.cipherAction(
|
||||||
msg.data.commandToRetry.sender.tab,
|
msg.data.commandToRetry.msg.data,
|
||||||
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 { UsernameGenerationService as UsernameGenerationServiceAbstraction } from "@bitwarden/common/abstractions/usernameGeneration.service";
|
||||||
import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service";
|
import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service";
|
||||||
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeoutSettings.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 { StateFactory } from "@bitwarden/common/factories/stateFactory";
|
||||||
import { GlobalState } from "@bitwarden/common/models/domain/global-state";
|
import { GlobalState } from "@bitwarden/common/models/domain/global-state";
|
||||||
import { CipherView } from "@bitwarden/common/models/view/cipher.view";
|
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 { WebCryptoFunctionService } from "@bitwarden/common/services/webCryptoFunction.service";
|
||||||
|
|
||||||
import { BrowserApi } from "../browser/browserApi";
|
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 { SafariApp } from "../browser/safariApp";
|
||||||
|
import { AutofillTabCommand } from "../commands/autofill-tab-command";
|
||||||
import { flagEnabled } from "../flags";
|
import { flagEnabled } from "../flags";
|
||||||
import { UpdateBadge } from "../listeners/update-badge";
|
import { UpdateBadge } from "../listeners/update-badge";
|
||||||
import { Account } from "../models/account";
|
import { Account } from "../models/account";
|
||||||
@ -112,7 +113,6 @@ import VaultTimeoutService from "../services/vaultTimeout/vaultTimeout.service";
|
|||||||
import CommandsBackground from "./commands.background";
|
import CommandsBackground from "./commands.background";
|
||||||
import ContextMenusBackground from "./contextMenus.background";
|
import ContextMenusBackground from "./contextMenus.background";
|
||||||
import IdleBackground from "./idle.background";
|
import IdleBackground from "./idle.background";
|
||||||
import IconDetails from "./models/iconDetails";
|
|
||||||
import { NativeMessagingBackground } from "./nativeMessaging.background";
|
import { NativeMessagingBackground } from "./nativeMessaging.background";
|
||||||
import NotificationBackground from "./notification.background";
|
import NotificationBackground from "./notification.background";
|
||||||
import RuntimeBackground from "./runtime.background";
|
import RuntimeBackground from "./runtime.background";
|
||||||
@ -171,6 +171,8 @@ export default class MainBackground {
|
|||||||
userVerificationApiService: UserVerificationApiServiceAbstraction;
|
userVerificationApiService: UserVerificationApiServiceAbstraction;
|
||||||
syncNotifierService: SyncNotifierServiceAbstraction;
|
syncNotifierService: SyncNotifierServiceAbstraction;
|
||||||
avatarUpdateService: AvatarUpdateServiceAbstraction;
|
avatarUpdateService: AvatarUpdateServiceAbstraction;
|
||||||
|
mainContextMenuHandler: MainContextMenuHandler;
|
||||||
|
cipherContextMenuHandler: CipherContextMenuHandler;
|
||||||
|
|
||||||
// Passed to the popup for Safari to workaround issues with theming, downloading, etc.
|
// Passed to the popup for Safari to workaround issues with theming, downloading, etc.
|
||||||
backgroundWindow = window;
|
backgroundWindow = window;
|
||||||
@ -188,8 +190,6 @@ export default class MainBackground {
|
|||||||
private webRequestBackground: WebRequestBackground;
|
private webRequestBackground: WebRequestBackground;
|
||||||
|
|
||||||
private sidebarAction: any;
|
private sidebarAction: any;
|
||||||
private buildingContextMenu: boolean;
|
|
||||||
private menuOptionsLoaded: any[] = [];
|
|
||||||
private syncTimeout: any;
|
private syncTimeout: any;
|
||||||
private isSafari: boolean;
|
private isSafari: boolean;
|
||||||
private nativeMessagingBackground: NativeMessagingBackground;
|
private nativeMessagingBackground: NativeMessagingBackground;
|
||||||
@ -536,15 +536,25 @@ export default class MainBackground {
|
|||||||
);
|
);
|
||||||
|
|
||||||
this.tabsBackground = new TabsBackground(this, this.notificationBackground);
|
this.tabsBackground = new TabsBackground(this, this.notificationBackground);
|
||||||
this.contextMenusBackground = new ContextMenusBackground(
|
if (!this.popupOnlyContext) {
|
||||||
this,
|
const contextMenuClickedHandler = new ContextMenuClickedHandler(
|
||||||
this.cipherService,
|
(options) => this.platformUtilsService.copyToClipboard(options.text, { window: self }),
|
||||||
this.passwordGenerationService,
|
async (_tab) => {
|
||||||
this.platformUtilsService,
|
const options = (await this.passwordGenerationService.getOptions())?.[0] ?? {};
|
||||||
this.authService,
|
const password = await this.passwordGenerationService.generatePassword(options);
|
||||||
this.eventCollectionService,
|
this.platformUtilsService.copyToClipboard(password, { window: window });
|
||||||
this.totpService
|
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.idleBackground = new IdleBackground(
|
||||||
this.vaultTimeoutService,
|
this.vaultTimeoutService,
|
||||||
this.stateService,
|
this.stateService,
|
||||||
@ -563,6 +573,16 @@ export default class MainBackground {
|
|||||||
);
|
);
|
||||||
|
|
||||||
this.avatarUpdateService = new AvatarUpdateService(this.apiService, this.stateService);
|
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() {
|
async bootstrap() {
|
||||||
@ -580,7 +600,9 @@ export default class MainBackground {
|
|||||||
this.twoFactorService.init();
|
this.twoFactorService.init();
|
||||||
|
|
||||||
await this.tabsBackground.init();
|
await this.tabsBackground.init();
|
||||||
await this.contextMenusBackground.init();
|
if (!this.popupOnlyContext) {
|
||||||
|
this.contextMenusBackground?.init();
|
||||||
|
}
|
||||||
await this.idleBackground.init();
|
await this.idleBackground.init();
|
||||||
await this.webRequestBackground.init();
|
await this.webRequestBackground.init();
|
||||||
|
|
||||||
@ -620,22 +642,20 @@ export default class MainBackground {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const menuDisabled = await this.stateService.getDisableContextMenuItem();
|
await MainContextMenuHandler.removeAll();
|
||||||
if (!menuDisabled) {
|
|
||||||
await this.buildContextMenu();
|
|
||||||
} else {
|
|
||||||
await this.contextMenusRemoveAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (forLocked) {
|
if (forLocked) {
|
||||||
await this.loadMenuForNoAccessState(!menuDisabled);
|
await this.mainContextMenuHandler?.noAccess();
|
||||||
this.onUpdatedRan = this.onReplacedRan = false;
|
this.onUpdatedRan = this.onReplacedRan = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.mainContextMenuHandler?.init();
|
||||||
|
|
||||||
const tab = await BrowserApi.getTabFromCurrentWindow();
|
const tab = await BrowserApi.getTabFromCurrentWindow();
|
||||||
if (tab) {
|
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");
|
BrowserApi.sendMessage("updateBadge");
|
||||||
}
|
}
|
||||||
await this.refreshBadge();
|
await this.refreshBadge();
|
||||||
await this.refreshMenu(true);
|
await this.mainContextMenuHandler.noAccess();
|
||||||
await this.reseedStorage();
|
await this.reseedStorage();
|
||||||
this.notificationsService.updateConnection(false);
|
this.notificationsService.updateConnection(false);
|
||||||
await this.systemService.clearPendingClipboard();
|
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) {
|
private async fullSync(override = false) {
|
||||||
const syncInternal = 6 * 60 * 60 * 1000; // 6 hours
|
const syncInternal = 6 * 60 * 60 * 1000; // 6 hours
|
||||||
const lastSync = await this.syncService.getLastSync();
|
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
|
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 fileUploadServiceFactory(cache, opts),
|
||||||
await i18nServiceFactory(cache, opts),
|
await i18nServiceFactory(cache, opts),
|
||||||
opts.cipherServiceOptions?.searchServiceFactory === undefined
|
opts.cipherServiceOptions?.searchServiceFactory === undefined
|
||||||
? () => cache.searchService
|
? () => cache.searchService as SearchService
|
||||||
: opts.cipherServiceOptions.searchServiceFactory,
|
: opts.cipherServiceOptions.searchServiceFactory,
|
||||||
await logServiceFactory(cache, opts),
|
await logServiceFactory(cache, opts),
|
||||||
await stateServiceFactory(cache, opts),
|
await stateServiceFactory(cache, opts),
|
||||||
|
@ -1,7 +1,4 @@
|
|||||||
import { EncryptServiceImplementation } from "@bitwarden/common/services/cryptography/encrypt.service.implementation";
|
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 {
|
import {
|
||||||
cryptoFunctionServiceFactory,
|
cryptoFunctionServiceFactory,
|
||||||
@ -24,17 +21,15 @@ export function encryptServiceFactory(
|
|||||||
cache: { encryptService?: EncryptServiceImplementation } & CachedServices,
|
cache: { encryptService?: EncryptServiceImplementation } & CachedServices,
|
||||||
opts: EncryptServiceInitOptions
|
opts: EncryptServiceInitOptions
|
||||||
): Promise<EncryptServiceImplementation> {
|
): Promise<EncryptServiceImplementation> {
|
||||||
return factory(cache, "encryptService", opts, async () =>
|
return factory(
|
||||||
flagEnabled("multithreadDecryption")
|
cache,
|
||||||
? new MultithreadEncryptServiceImplementation(
|
"encryptService",
|
||||||
await cryptoFunctionServiceFactory(cache, opts),
|
opts,
|
||||||
await logServiceFactory(cache, opts),
|
async () =>
|
||||||
opts.encryptServiceOptions.logMacFailures
|
new EncryptServiceImplementation(
|
||||||
)
|
await cryptoFunctionServiceFactory(cache, opts),
|
||||||
: new EncryptServiceImplementation(
|
await logServiceFactory(cache, opts),
|
||||||
await cryptoFunctionServiceFactory(cache, opts),
|
opts.encryptServiceOptions.logMacFailures
|
||||||
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 = {
|
export type FactoryOptions = {
|
||||||
alwaysInitializeNewService?: boolean;
|
alwaysInitializeNewService?: boolean;
|
||||||
|
@ -44,7 +44,7 @@ export class BrowserApi {
|
|||||||
|
|
||||||
static async tabsQuery(options: chrome.tabs.QueryInfo): Promise<chrome.tabs.Tab[]> {
|
static async tabsQuery(options: chrome.tabs.QueryInfo): Promise<chrome.tabs.Tab[]> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
chrome.tabs.query(options, (tabs: any[]) => {
|
chrome.tabs.query(options, (tabs) => {
|
||||||
resolve(tabs);
|
resolve(tabs);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -63,7 +63,7 @@ export class BrowserApi {
|
|||||||
tab: chrome.tabs.Tab,
|
tab: chrome.tabs.Tab,
|
||||||
command: string,
|
command: string,
|
||||||
data: any = null
|
data: any = null
|
||||||
): Promise<any[]> {
|
): Promise<void> {
|
||||||
const obj: any = {
|
const obj: any = {
|
||||||
command: command,
|
command: command,
|
||||||
};
|
};
|
||||||
@ -75,11 +75,11 @@ export class BrowserApi {
|
|||||||
return BrowserApi.tabSendMessage(tab, obj);
|
return BrowserApi.tabSendMessage(tab, obj);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async tabSendMessage(
|
static async tabSendMessage<T>(
|
||||||
tab: chrome.tabs.Tab,
|
tab: chrome.tabs.Tab,
|
||||||
obj: any,
|
obj: T,
|
||||||
options: chrome.tabs.MessageSendOptions = null
|
options: chrome.tabs.MessageSendOptions = null
|
||||||
): Promise<any> {
|
): Promise<void> {
|
||||||
if (!tab || !tab.id) {
|
if (!tab || !tab.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -94,12 +94,13 @@ export class BrowserApi {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static sendTabsMessage<T = never>(
|
static sendTabsMessage<T>(
|
||||||
tabId: number,
|
tabId: number,
|
||||||
message: TabMessage,
|
message: TabMessage,
|
||||||
|
options?: chrome.tabs.MessageSendOptions,
|
||||||
responseCallback?: (response: T) => void
|
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[]> {
|
static async getPrivateModeWindows(): Promise<browser.windows.Window[]> {
|
||||||
|
109
apps/browser/src/browser/cipher-context-menu-handler.spec.ts
Normal file
109
apps/browser/src/browser/cipher-context-menu-handler.spec.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { mock, MockProxy } from "jest-mock-extended";
|
||||||
|
|
||||||
|
import { AuthService } from "@bitwarden/common/abstractions/auth.service";
|
||||||
|
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
|
||||||
|
import { AuthenticationStatus } from "@bitwarden/common/enums/authenticationStatus";
|
||||||
|
import { CipherRepromptType } from "@bitwarden/common/enums/cipherRepromptType";
|
||||||
|
import { CipherType } from "@bitwarden/common/enums/cipherType";
|
||||||
|
|
||||||
|
import { CipherContextMenuHandler } from "./cipher-context-menu-handler";
|
||||||
|
import { MainContextMenuHandler } from "./main-context-menu-handler";
|
||||||
|
|
||||||
|
describe("CipherContextMenuHandler", () => {
|
||||||
|
let mainContextMenuHandler: MockProxy<MainContextMenuHandler>;
|
||||||
|
let authService: MockProxy<AuthService>;
|
||||||
|
let cipherService: MockProxy<CipherService>;
|
||||||
|
|
||||||
|
let sut: CipherContextMenuHandler;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mainContextMenuHandler = mock();
|
||||||
|
authService = mock();
|
||||||
|
cipherService = mock();
|
||||||
|
|
||||||
|
jest.spyOn(MainContextMenuHandler, "removeAll").mockResolvedValue();
|
||||||
|
|
||||||
|
sut = new CipherContextMenuHandler(mainContextMenuHandler, authService, cipherService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => jest.resetAllMocks());
|
||||||
|
|
||||||
|
describe("update", () => {
|
||||||
|
it("locked, updates for no access", async () => {
|
||||||
|
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Locked);
|
||||||
|
|
||||||
|
await sut.update("https://test.com");
|
||||||
|
|
||||||
|
expect(mainContextMenuHandler.noAccess).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logged out, updates for no access", async () => {
|
||||||
|
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.LoggedOut);
|
||||||
|
|
||||||
|
await sut.update("https://test.com");
|
||||||
|
|
||||||
|
expect(mainContextMenuHandler.noAccess).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has menu disabled, does not load anything", async () => {
|
||||||
|
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Unlocked);
|
||||||
|
|
||||||
|
await sut.update("https://test.com");
|
||||||
|
|
||||||
|
expect(mainContextMenuHandler.loadOptions).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(mainContextMenuHandler.noAccess).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(mainContextMenuHandler.noLogins).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no ciphers, add no ciphers item", async () => {
|
||||||
|
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Unlocked);
|
||||||
|
|
||||||
|
mainContextMenuHandler.init.mockResolvedValue(true);
|
||||||
|
|
||||||
|
cipherService.getAllDecryptedForUrl.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await sut.update("https://test.com");
|
||||||
|
|
||||||
|
expect(mainContextMenuHandler.noLogins).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only adds valid ciphers", async () => {
|
||||||
|
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Unlocked);
|
||||||
|
|
||||||
|
mainContextMenuHandler.init.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const realCipher = {
|
||||||
|
id: "5",
|
||||||
|
type: CipherType.Login,
|
||||||
|
reprompt: CipherRepromptType.None,
|
||||||
|
name: "Test Cipher",
|
||||||
|
login: { username: "Test Username" },
|
||||||
|
};
|
||||||
|
|
||||||
|
cipherService.getAllDecryptedForUrl.mockResolvedValue([
|
||||||
|
null,
|
||||||
|
undefined,
|
||||||
|
{ type: CipherType.Card },
|
||||||
|
{ type: CipherType.Login, reprompt: CipherRepromptType.Password },
|
||||||
|
realCipher,
|
||||||
|
] as any[]);
|
||||||
|
|
||||||
|
await sut.update("https://test.com");
|
||||||
|
|
||||||
|
expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith("https://test.com");
|
||||||
|
|
||||||
|
expect(mainContextMenuHandler.loadOptions).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(mainContextMenuHandler.loadOptions).toHaveBeenCalledWith(
|
||||||
|
"Test Cipher (Test Username)",
|
||||||
|
"5",
|
||||||
|
"https://test.com",
|
||||||
|
realCipher
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
185
apps/browser/src/browser/cipher-context-menu-handler.ts
Normal file
185
apps/browser/src/browser/cipher-context-menu-handler.ts
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
import { AuthService } from "@bitwarden/common/abstractions/auth.service";
|
||||||
|
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
|
||||||
|
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||||
|
import { AuthenticationStatus } from "@bitwarden/common/enums/authenticationStatus";
|
||||||
|
import { CipherRepromptType } from "@bitwarden/common/enums/cipherRepromptType";
|
||||||
|
import { CipherType } from "@bitwarden/common/enums/cipherType";
|
||||||
|
import { StateFactory } from "@bitwarden/common/factories/stateFactory";
|
||||||
|
import { Utils } from "@bitwarden/common/misc/utils";
|
||||||
|
import { GlobalState } from "@bitwarden/common/models/domain/global-state";
|
||||||
|
import { CipherView } from "@bitwarden/common/models/view/cipher.view";
|
||||||
|
|
||||||
|
import {
|
||||||
|
authServiceFactory,
|
||||||
|
AuthServiceInitOptions,
|
||||||
|
} from "../background/service_factories/auth-service.factory";
|
||||||
|
import {
|
||||||
|
cipherServiceFactory,
|
||||||
|
CipherServiceInitOptions,
|
||||||
|
} from "../background/service_factories/cipher-service.factory";
|
||||||
|
import { CachedServices } from "../background/service_factories/factory-options";
|
||||||
|
import { searchServiceFactory } from "../background/service_factories/search-service.factory";
|
||||||
|
import { Account } from "../models/account";
|
||||||
|
|
||||||
|
import { BrowserApi } from "./browserApi";
|
||||||
|
import { MainContextMenuHandler } from "./main-context-menu-handler";
|
||||||
|
|
||||||
|
const NOT_IMPLEMENTED = (..._args: unknown[]) => Promise.resolve();
|
||||||
|
|
||||||
|
const LISTENED_TO_COMMANDS = [
|
||||||
|
"loggedIn",
|
||||||
|
"unlocked",
|
||||||
|
"syncCompleted",
|
||||||
|
"bgUpdateContextMenu",
|
||||||
|
"editedCipher",
|
||||||
|
"addedCipher",
|
||||||
|
"deletedCipher",
|
||||||
|
];
|
||||||
|
|
||||||
|
export class CipherContextMenuHandler {
|
||||||
|
constructor(
|
||||||
|
private mainContextMenuHandler: MainContextMenuHandler,
|
||||||
|
private authService: AuthService,
|
||||||
|
private cipherService: CipherService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static async create(cachedServices: CachedServices) {
|
||||||
|
const stateFactory = new StateFactory(GlobalState, Account);
|
||||||
|
let searchService: SearchService | null = null;
|
||||||
|
const serviceOptions: AuthServiceInitOptions & CipherServiceInitOptions = {
|
||||||
|
apiServiceOptions: {
|
||||||
|
logoutCallback: NOT_IMPLEMENTED,
|
||||||
|
},
|
||||||
|
cipherServiceOptions: {
|
||||||
|
searchServiceFactory: () => searchService,
|
||||||
|
},
|
||||||
|
cryptoFunctionServiceOptions: {
|
||||||
|
win: self,
|
||||||
|
},
|
||||||
|
encryptServiceOptions: {
|
||||||
|
logMacFailures: false,
|
||||||
|
},
|
||||||
|
i18nServiceOptions: {
|
||||||
|
systemLanguage: chrome.i18n.getUILanguage(),
|
||||||
|
},
|
||||||
|
keyConnectorServiceOptions: {
|
||||||
|
logoutCallback: NOT_IMPLEMENTED,
|
||||||
|
},
|
||||||
|
logServiceOptions: {
|
||||||
|
isDev: false,
|
||||||
|
},
|
||||||
|
platformUtilsServiceOptions: {
|
||||||
|
biometricCallback: () => Promise.resolve(false),
|
||||||
|
clipboardWriteCallback: NOT_IMPLEMENTED,
|
||||||
|
win: self,
|
||||||
|
},
|
||||||
|
stateMigrationServiceOptions: {
|
||||||
|
stateFactory: stateFactory,
|
||||||
|
},
|
||||||
|
stateServiceOptions: {
|
||||||
|
stateFactory: stateFactory,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
searchService = await searchServiceFactory(cachedServices, serviceOptions);
|
||||||
|
return new CipherContextMenuHandler(
|
||||||
|
await MainContextMenuHandler.mv3Create(cachedServices),
|
||||||
|
await authServiceFactory(cachedServices, serviceOptions),
|
||||||
|
await cipherServiceFactory(cachedServices, serviceOptions)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async tabsOnActivatedListener(
|
||||||
|
activeInfo: chrome.tabs.TabActiveInfo,
|
||||||
|
serviceCache: CachedServices
|
||||||
|
) {
|
||||||
|
const cipherContextMenuHandler = await CipherContextMenuHandler.create(serviceCache);
|
||||||
|
const tab = await BrowserApi.getTab(activeInfo.tabId);
|
||||||
|
await cipherContextMenuHandler.update(tab.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async tabsOnReplacedListener(
|
||||||
|
addedTabId: number,
|
||||||
|
removedTabId: number,
|
||||||
|
serviceCache: CachedServices
|
||||||
|
) {
|
||||||
|
const cipherContextMenuHandler = await CipherContextMenuHandler.create(serviceCache);
|
||||||
|
const tab = await BrowserApi.getTab(addedTabId);
|
||||||
|
await cipherContextMenuHandler.update(tab.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async tabsOnUpdatedListener(
|
||||||
|
tabId: number,
|
||||||
|
changeInfo: chrome.tabs.TabChangeInfo,
|
||||||
|
tab: chrome.tabs.Tab,
|
||||||
|
serviceCache: CachedServices
|
||||||
|
) {
|
||||||
|
if (changeInfo.status !== "complete") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cipherContextMenuHandler = await CipherContextMenuHandler.create(serviceCache);
|
||||||
|
await cipherContextMenuHandler.update(tab.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async messageListener(message: { command: string }, cachedServices: CachedServices) {
|
||||||
|
const cipherContextMenuHandler = await CipherContextMenuHandler.create(cachedServices);
|
||||||
|
await cipherContextMenuHandler.messageListener(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async messageListener(message: { command: string }) {
|
||||||
|
if (!LISTENED_TO_COMMANDS.includes(message.command)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTabs = await BrowserApi.getActiveTabs();
|
||||||
|
if (!activeTabs || activeTabs.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.update(activeTabs[0].url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(url: string) {
|
||||||
|
const authStatus = await this.authService.getAuthStatus();
|
||||||
|
await MainContextMenuHandler.removeAll();
|
||||||
|
if (authStatus !== AuthenticationStatus.Unlocked) {
|
||||||
|
// Should I pass in the auth status or even have two seperate methods for this
|
||||||
|
// on MainContextMenuHandler
|
||||||
|
await this.mainContextMenuHandler.noAccess();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuEnabled = await this.mainContextMenuHandler.init();
|
||||||
|
if (!menuEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ciphers = await this.cipherService.getAllDecryptedForUrl(url);
|
||||||
|
ciphers.sort((a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b));
|
||||||
|
|
||||||
|
if (ciphers.length === 0) {
|
||||||
|
await this.mainContextMenuHandler.noLogins(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cipher of ciphers) {
|
||||||
|
await this.updateForCipher(url, cipher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateForCipher(url: string, cipher: CipherView) {
|
||||||
|
if (
|
||||||
|
cipher == null ||
|
||||||
|
cipher.type !== CipherType.Login ||
|
||||||
|
cipher.reprompt !== CipherRepromptType.None
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = cipher.name;
|
||||||
|
if (!Utils.isNullOrEmpty(title)) {
|
||||||
|
title += ` (${cipher.login.username})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.mainContextMenuHandler.loadOptions(title, cipher.id, url, cipher);
|
||||||
|
}
|
||||||
|
}
|
193
apps/browser/src/browser/context-menu-clicked-handler.spec.ts
Normal file
193
apps/browser/src/browser/context-menu-clicked-handler.spec.ts
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
import { mock, MockProxy } from "jest-mock-extended";
|
||||||
|
|
||||||
|
import { AuthService } from "@bitwarden/common/abstractions/auth.service";
|
||||||
|
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
|
||||||
|
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||||
|
import { TotpService } from "@bitwarden/common/abstractions/totp.service";
|
||||||
|
import { CipherRepromptType } from "@bitwarden/common/enums/cipherRepromptType";
|
||||||
|
import { CipherType } from "@bitwarden/common/enums/cipherType";
|
||||||
|
import { Cipher } from "@bitwarden/common/models/domain/cipher";
|
||||||
|
import { CipherView } from "@bitwarden/common/models/view/cipher.view";
|
||||||
|
|
||||||
|
import { AutofillTabCommand } from "../commands/autofill-tab-command";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CopyToClipboardAction,
|
||||||
|
ContextMenuClickedHandler,
|
||||||
|
CopyToClipboardOptions,
|
||||||
|
GeneratePasswordToClipboardAction,
|
||||||
|
} from "./context-menu-clicked-handler";
|
||||||
|
import {
|
||||||
|
AUTOFILL_ID,
|
||||||
|
COPY_PASSWORD_ID,
|
||||||
|
COPY_USERNAME_ID,
|
||||||
|
COPY_VERIFICATIONCODE_ID,
|
||||||
|
GENERATE_PASSWORD_ID,
|
||||||
|
} from "./main-context-menu-handler";
|
||||||
|
|
||||||
|
describe("ContextMenuClickedHandler", () => {
|
||||||
|
const createData = (
|
||||||
|
menuItemId: chrome.contextMenus.OnClickData["menuItemId"],
|
||||||
|
parentMenuItemId?: chrome.contextMenus.OnClickData["parentMenuItemId"]
|
||||||
|
): chrome.contextMenus.OnClickData => {
|
||||||
|
return {
|
||||||
|
menuItemId: menuItemId,
|
||||||
|
parentMenuItemId: parentMenuItemId,
|
||||||
|
editable: false,
|
||||||
|
pageUrl: "something",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createCipher = (data?: {
|
||||||
|
id?: CipherView["id"];
|
||||||
|
username?: CipherView["login"]["username"];
|
||||||
|
password?: CipherView["login"]["password"];
|
||||||
|
totp?: CipherView["login"]["totp"];
|
||||||
|
}): CipherView => {
|
||||||
|
const { id, username, password, totp } = data || {};
|
||||||
|
const cipherView = new CipherView(
|
||||||
|
new Cipher({
|
||||||
|
id: id ?? "1",
|
||||||
|
type: CipherType.Login,
|
||||||
|
} as any)
|
||||||
|
);
|
||||||
|
cipherView.login.username = username ?? "USERNAME";
|
||||||
|
cipherView.login.password = password ?? "PASSWORD";
|
||||||
|
cipherView.login.totp = totp ?? "TOTP";
|
||||||
|
return cipherView;
|
||||||
|
};
|
||||||
|
|
||||||
|
let copyToClipboard: CopyToClipboardAction;
|
||||||
|
let generatePasswordToClipboard: GeneratePasswordToClipboardAction;
|
||||||
|
let authService: MockProxy<AuthService>;
|
||||||
|
let cipherService: MockProxy<CipherService>;
|
||||||
|
let autofillTabCommand: MockProxy<AutofillTabCommand>;
|
||||||
|
let totpService: MockProxy<TotpService>;
|
||||||
|
let eventCollectionService: MockProxy<EventCollectionService>;
|
||||||
|
|
||||||
|
let sut: ContextMenuClickedHandler;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
copyToClipboard = jest.fn<void, [CopyToClipboardOptions]>();
|
||||||
|
generatePasswordToClipboard = jest.fn<Promise<void>, [tab: chrome.tabs.Tab]>();
|
||||||
|
authService = mock();
|
||||||
|
cipherService = mock();
|
||||||
|
autofillTabCommand = mock();
|
||||||
|
totpService = mock();
|
||||||
|
eventCollectionService = mock();
|
||||||
|
|
||||||
|
sut = new ContextMenuClickedHandler(
|
||||||
|
copyToClipboard,
|
||||||
|
generatePasswordToClipboard,
|
||||||
|
authService,
|
||||||
|
cipherService,
|
||||||
|
autofillTabCommand,
|
||||||
|
totpService,
|
||||||
|
eventCollectionService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => jest.resetAllMocks());
|
||||||
|
|
||||||
|
describe("run", () => {
|
||||||
|
it("can generate password", async () => {
|
||||||
|
await sut.run(createData(GENERATE_PASSWORD_ID), { id: 5 } as any);
|
||||||
|
|
||||||
|
expect(generatePasswordToClipboard).toBeCalledTimes(1);
|
||||||
|
|
||||||
|
expect(generatePasswordToClipboard).toBeCalledWith({
|
||||||
|
id: 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attempts to autofill the correct cipher", async () => {
|
||||||
|
const cipher = createCipher();
|
||||||
|
cipherService.getAllDecrypted.mockResolvedValue([cipher]);
|
||||||
|
|
||||||
|
await sut.run(createData("T_1", AUTOFILL_ID), { id: 5 } as any);
|
||||||
|
|
||||||
|
expect(autofillTabCommand.doAutofillTabWithCipherCommand).toBeCalledTimes(1);
|
||||||
|
|
||||||
|
expect(autofillTabCommand.doAutofillTabWithCipherCommand).toBeCalledWith({ id: 5 }, cipher);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies username to clipboard", async () => {
|
||||||
|
cipherService.getAllDecrypted.mockResolvedValue([
|
||||||
|
createCipher({ username: "TEST_USERNAME" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await sut.run(createData("T_1", COPY_USERNAME_ID));
|
||||||
|
|
||||||
|
expect(copyToClipboard).toBeCalledTimes(1);
|
||||||
|
|
||||||
|
expect(copyToClipboard).toHaveBeenCalledWith({ text: "TEST_USERNAME", options: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies password to clipboard", async () => {
|
||||||
|
cipherService.getAllDecrypted.mockResolvedValue([
|
||||||
|
createCipher({ password: "TEST_PASSWORD" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await sut.run(createData("T_1", COPY_PASSWORD_ID));
|
||||||
|
|
||||||
|
expect(copyToClipboard).toBeCalledTimes(1);
|
||||||
|
|
||||||
|
expect(copyToClipboard).toHaveBeenCalledWith({ text: "TEST_PASSWORD", options: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies totp code to clipboard", async () => {
|
||||||
|
cipherService.getAllDecrypted.mockResolvedValue([createCipher({ totp: "TEST_TOTP_SEED" })]);
|
||||||
|
|
||||||
|
totpService.getCode.mockImplementation((seed) => {
|
||||||
|
if (seed === "TEST_TOTP_SEED") {
|
||||||
|
return Promise.resolve("123456");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve("654321");
|
||||||
|
});
|
||||||
|
|
||||||
|
await sut.run(createData("T_1", COPY_VERIFICATIONCODE_ID));
|
||||||
|
|
||||||
|
expect(totpService.getCode).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(copyToClipboard).toHaveBeenCalledWith({ text: "123456" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attempts to find a cipher when noop but unlocked", async () => {
|
||||||
|
cipherService.getAllDecryptedForUrl.mockResolvedValue([
|
||||||
|
{
|
||||||
|
...createCipher({ username: "NOOP_USERNAME" }),
|
||||||
|
reprompt: CipherRepromptType.None,
|
||||||
|
} as any,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await sut.run(createData("T_noop", COPY_USERNAME_ID), { url: "https://test.com" } as any);
|
||||||
|
|
||||||
|
expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith("https://test.com");
|
||||||
|
|
||||||
|
expect(copyToClipboard).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(copyToClipboard).toHaveBeenCalledWith({
|
||||||
|
text: "NOOP_USERNAME",
|
||||||
|
tab: { url: "https://test.com" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attempts to find a cipher when noop but unlocked", async () => {
|
||||||
|
cipherService.getAllDecryptedForUrl.mockResolvedValue([
|
||||||
|
{
|
||||||
|
...createCipher({ username: "NOOP_USERNAME" }),
|
||||||
|
reprompt: CipherRepromptType.Password,
|
||||||
|
} as any,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await sut.run(createData("T_noop", COPY_USERNAME_ID), { url: "https://test.com" } as any);
|
||||||
|
|
||||||
|
expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith("https://test.com");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
239
apps/browser/src/browser/context-menu-clicked-handler.ts
Normal file
239
apps/browser/src/browser/context-menu-clicked-handler.ts
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
import { AuthService } from "@bitwarden/common/abstractions/auth.service";
|
||||||
|
import { CipherService } from "@bitwarden/common/abstractions/cipher.service";
|
||||||
|
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||||
|
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||||
|
import { TotpService } from "@bitwarden/common/abstractions/totp.service";
|
||||||
|
import { AuthenticationStatus } from "@bitwarden/common/enums/authenticationStatus";
|
||||||
|
import { CipherRepromptType } from "@bitwarden/common/enums/cipherRepromptType";
|
||||||
|
import { EventType } from "@bitwarden/common/enums/eventType";
|
||||||
|
import { StateFactory } from "@bitwarden/common/factories/stateFactory";
|
||||||
|
import { GlobalState } from "@bitwarden/common/models/domain/global-state";
|
||||||
|
import { CipherView } from "@bitwarden/common/models/view/cipher.view";
|
||||||
|
|
||||||
|
import LockedVaultPendingNotificationsItem from "../background/models/lockedVaultPendingNotificationsItem";
|
||||||
|
import {
|
||||||
|
authServiceFactory,
|
||||||
|
AuthServiceInitOptions,
|
||||||
|
} from "../background/service_factories/auth-service.factory";
|
||||||
|
import { autofillServiceFactory } from "../background/service_factories/autofill-service.factory";
|
||||||
|
import {
|
||||||
|
cipherServiceFactory,
|
||||||
|
CipherServiceInitOptions,
|
||||||
|
} from "../background/service_factories/cipher-service.factory";
|
||||||
|
import { eventCollectionServiceFactory } from "../background/service_factories/event-collection-service.factory";
|
||||||
|
import { CachedServices } from "../background/service_factories/factory-options";
|
||||||
|
import { passwordGenerationServiceFactory } from "../background/service_factories/password-generation-service.factory";
|
||||||
|
import { searchServiceFactory } from "../background/service_factories/search-service.factory";
|
||||||
|
import { stateServiceFactory } from "../background/service_factories/state-service.factory";
|
||||||
|
import { totpServiceFactory } from "../background/service_factories/totp-service.factory";
|
||||||
|
import { BrowserApi } from "../browser/browserApi";
|
||||||
|
import { copyToClipboard, GeneratePasswordToClipboardCommand } from "../clipboard";
|
||||||
|
import { AutofillTabCommand } from "../commands/autofill-tab-command";
|
||||||
|
import { Account } from "../models/account";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AUTOFILL_ID,
|
||||||
|
COPY_IDENTIFIER_ID,
|
||||||
|
COPY_PASSWORD_ID,
|
||||||
|
COPY_USERNAME_ID,
|
||||||
|
COPY_VERIFICATIONCODE_ID,
|
||||||
|
GENERATE_PASSWORD_ID,
|
||||||
|
NOOP_COMMAND_SUFFIX,
|
||||||
|
} from "./main-context-menu-handler";
|
||||||
|
|
||||||
|
export type CopyToClipboardOptions = { text: string; tab: chrome.tabs.Tab };
|
||||||
|
export type CopyToClipboardAction = (options: CopyToClipboardOptions) => void;
|
||||||
|
|
||||||
|
export type GeneratePasswordToClipboardAction = (tab: chrome.tabs.Tab) => Promise<void>;
|
||||||
|
|
||||||
|
const NOT_IMPLEMENTED = (..._args: unknown[]) =>
|
||||||
|
Promise.reject<never>("This action is not implemented inside of a service worker context.");
|
||||||
|
|
||||||
|
export class ContextMenuClickedHandler {
|
||||||
|
constructor(
|
||||||
|
private copyToClipboard: CopyToClipboardAction,
|
||||||
|
private generatePasswordToClipboard: GeneratePasswordToClipboardAction,
|
||||||
|
private authService: AuthService,
|
||||||
|
private cipherService: CipherService,
|
||||||
|
private autofillTabCommand: AutofillTabCommand,
|
||||||
|
private totpService: TotpService,
|
||||||
|
private eventCollectionService: EventCollectionService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static async mv3Create(cachedServices: CachedServices) {
|
||||||
|
const stateFactory = new StateFactory(GlobalState, Account);
|
||||||
|
let searchService: SearchService | null = null;
|
||||||
|
const serviceOptions: AuthServiceInitOptions & CipherServiceInitOptions = {
|
||||||
|
apiServiceOptions: {
|
||||||
|
logoutCallback: NOT_IMPLEMENTED,
|
||||||
|
},
|
||||||
|
cipherServiceOptions: {
|
||||||
|
searchServiceFactory: () => searchService,
|
||||||
|
},
|
||||||
|
cryptoFunctionServiceOptions: {
|
||||||
|
win: self,
|
||||||
|
},
|
||||||
|
encryptServiceOptions: {
|
||||||
|
logMacFailures: false,
|
||||||
|
},
|
||||||
|
i18nServiceOptions: {
|
||||||
|
systemLanguage: chrome.i18n.getUILanguage(),
|
||||||
|
},
|
||||||
|
keyConnectorServiceOptions: {
|
||||||
|
logoutCallback: NOT_IMPLEMENTED,
|
||||||
|
},
|
||||||
|
logServiceOptions: {
|
||||||
|
isDev: false,
|
||||||
|
},
|
||||||
|
platformUtilsServiceOptions: {
|
||||||
|
biometricCallback: NOT_IMPLEMENTED,
|
||||||
|
clipboardWriteCallback: NOT_IMPLEMENTED,
|
||||||
|
win: self,
|
||||||
|
},
|
||||||
|
stateMigrationServiceOptions: {
|
||||||
|
stateFactory: stateFactory,
|
||||||
|
},
|
||||||
|
stateServiceOptions: {
|
||||||
|
stateFactory: stateFactory,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
searchService = await searchServiceFactory(cachedServices, serviceOptions);
|
||||||
|
|
||||||
|
const generatePasswordToClipboardCommand = new GeneratePasswordToClipboardCommand(
|
||||||
|
await passwordGenerationServiceFactory(cachedServices, serviceOptions),
|
||||||
|
await stateServiceFactory(cachedServices, serviceOptions)
|
||||||
|
);
|
||||||
|
|
||||||
|
return new ContextMenuClickedHandler(
|
||||||
|
(options) => copyToClipboard(options.tab, options.text),
|
||||||
|
(tab) => generatePasswordToClipboardCommand.generatePasswordToClipboard(tab),
|
||||||
|
await authServiceFactory(cachedServices, serviceOptions),
|
||||||
|
await cipherServiceFactory(cachedServices, serviceOptions),
|
||||||
|
new AutofillTabCommand(await autofillServiceFactory(cachedServices, serviceOptions)),
|
||||||
|
await totpServiceFactory(cachedServices, serviceOptions),
|
||||||
|
await eventCollectionServiceFactory(cachedServices, serviceOptions)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async onClickedListener(
|
||||||
|
info: chrome.contextMenus.OnClickData,
|
||||||
|
tab?: chrome.tabs.Tab,
|
||||||
|
cachedServices: CachedServices = {}
|
||||||
|
) {
|
||||||
|
const contextMenuClickedHandler = await ContextMenuClickedHandler.mv3Create(cachedServices);
|
||||||
|
await contextMenuClickedHandler.run(info, tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async messageListener(
|
||||||
|
message: { command: string; data: LockedVaultPendingNotificationsItem },
|
||||||
|
cachedServices: CachedServices
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
message.command !== "unlockCompleted" ||
|
||||||
|
message.data.target !== "contextmenus.background"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextMenuClickedHandler = await ContextMenuClickedHandler.mv3Create(cachedServices);
|
||||||
|
await contextMenuClickedHandler.run(
|
||||||
|
message.data.commandToRetry.msg.data,
|
||||||
|
message.data.commandToRetry.sender.tab
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(info: chrome.contextMenus.OnClickData, tab?: chrome.tabs.Tab) {
|
||||||
|
switch (info.menuItemId) {
|
||||||
|
case GENERATE_PASSWORD_ID:
|
||||||
|
if (!tab) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.generatePasswordToClipboard(tab);
|
||||||
|
break;
|
||||||
|
case COPY_IDENTIFIER_ID:
|
||||||
|
if (!tab) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.copyToClipboard({ text: await this.getIdentifier(tab, info), tab: tab });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
await this.cipherAction(info, tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cipherAction(info: chrome.contextMenus.OnClickData, tab?: chrome.tabs.Tab) {
|
||||||
|
if ((await this.authService.getAuthStatus()) < AuthenticationStatus.Unlocked) {
|
||||||
|
const retryMessage: LockedVaultPendingNotificationsItem = {
|
||||||
|
commandToRetry: {
|
||||||
|
msg: { command: NOOP_COMMAND_SUFFIX, data: info },
|
||||||
|
sender: { tab: tab },
|
||||||
|
},
|
||||||
|
target: "contextmenus.background",
|
||||||
|
};
|
||||||
|
await BrowserApi.tabSendMessageData(
|
||||||
|
tab,
|
||||||
|
"addToLockedVaultPendingNotifications",
|
||||||
|
retryMessage
|
||||||
|
);
|
||||||
|
|
||||||
|
await BrowserApi.tabSendMessageData(tab, "promptForLogin");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: We don't actually use the first part of this ID, we further switch based on the parentMenuItemId
|
||||||
|
// I would really love to not add it but that is a departure from how it currently works.
|
||||||
|
const id = (info.menuItemId as string).split("_")[1]; // We create all the ids, we can guarantee they are strings
|
||||||
|
let cipher: CipherView | undefined;
|
||||||
|
if (id === NOOP_COMMAND_SUFFIX) {
|
||||||
|
// This NOOP item has come through which is generally only for no access state but since we got here
|
||||||
|
// we are actually unlocked we will do our best to find a good match of an item to autofill this is useful
|
||||||
|
// in scenarios like unlock on autofill
|
||||||
|
const ciphers = await this.cipherService.getAllDecryptedForUrl(tab.url);
|
||||||
|
cipher = ciphers.find((c) => c.reprompt === CipherRepromptType.None);
|
||||||
|
} else {
|
||||||
|
const ciphers = await this.cipherService.getAllDecrypted();
|
||||||
|
cipher = ciphers.find((c) => c.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cipher == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (info.parentMenuItemId) {
|
||||||
|
case AUTOFILL_ID:
|
||||||
|
if (tab == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.autofillTabCommand.doAutofillTabWithCipherCommand(tab, cipher);
|
||||||
|
break;
|
||||||
|
case COPY_USERNAME_ID:
|
||||||
|
this.copyToClipboard({ text: cipher.login.username, tab: tab });
|
||||||
|
break;
|
||||||
|
case COPY_PASSWORD_ID:
|
||||||
|
this.copyToClipboard({ text: cipher.login.password, tab: tab });
|
||||||
|
this.eventCollectionService.collect(EventType.Cipher_ClientCopiedPassword, cipher.id);
|
||||||
|
break;
|
||||||
|
case COPY_VERIFICATIONCODE_ID:
|
||||||
|
this.copyToClipboard({ text: await this.totpService.getCode(cipher.login.totp), tab: tab });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getIdentifier(tab: chrome.tabs.Tab, info: chrome.contextMenus.OnClickData) {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
BrowserApi.sendTabsMessage(
|
||||||
|
tab.id,
|
||||||
|
{ command: "getClickedElement" },
|
||||||
|
{ frameId: info.frameId },
|
||||||
|
(identifier: string) => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
reject(chrome.runtime.lastError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(identifier);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
137
apps/browser/src/browser/main-context-menu-handler.spec.ts
Normal file
137
apps/browser/src/browser/main-context-menu-handler.spec.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { mock, MockProxy } from "jest-mock-extended";
|
||||||
|
|
||||||
|
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||||
|
import { CipherType } from "@bitwarden/common/enums/cipherType";
|
||||||
|
import { Cipher } from "@bitwarden/common/models/domain/cipher";
|
||||||
|
import { CipherView } from "@bitwarden/common/models/view/cipher.view";
|
||||||
|
|
||||||
|
import { BrowserStateService } from "../services/abstractions/browser-state.service";
|
||||||
|
|
||||||
|
import { MainContextMenuHandler } from "./main-context-menu-handler";
|
||||||
|
|
||||||
|
describe("context-menu", () => {
|
||||||
|
let stateService: MockProxy<BrowserStateService>;
|
||||||
|
let i18nService: MockProxy<I18nService>;
|
||||||
|
|
||||||
|
let removeAllSpy: jest.SpyInstance<void, [callback?: () => void]>;
|
||||||
|
let createSpy: jest.SpyInstance<
|
||||||
|
string | number,
|
||||||
|
[createProperties: chrome.contextMenus.CreateProperties, callback?: () => void]
|
||||||
|
>;
|
||||||
|
|
||||||
|
let sut: MainContextMenuHandler;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stateService = mock();
|
||||||
|
i18nService = mock();
|
||||||
|
|
||||||
|
removeAllSpy = jest
|
||||||
|
.spyOn(chrome.contextMenus, "removeAll")
|
||||||
|
.mockImplementation((callback) => callback());
|
||||||
|
|
||||||
|
createSpy = jest.spyOn(chrome.contextMenus, "create").mockImplementation((props, callback) => {
|
||||||
|
if (callback) {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
return props.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
sut = new MainContextMenuHandler(stateService, i18nService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => jest.resetAllMocks());
|
||||||
|
|
||||||
|
describe("init", () => {
|
||||||
|
it("has menu disabled", async () => {
|
||||||
|
stateService.getDisableContextMenuItem.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const createdMenu = await sut.init();
|
||||||
|
expect(createdMenu).toBeFalsy();
|
||||||
|
expect(removeAllSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has menu enabled, but does not have premium", async () => {
|
||||||
|
stateService.getDisableContextMenuItem.mockResolvedValue(false);
|
||||||
|
|
||||||
|
stateService.getCanAccessPremium.mockResolvedValue(false);
|
||||||
|
|
||||||
|
const createdMenu = await sut.init();
|
||||||
|
expect(createdMenu).toBeTruthy();
|
||||||
|
expect(createSpy).toHaveBeenCalledTimes(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has menu enabled and has premium", async () => {
|
||||||
|
stateService.getDisableContextMenuItem.mockResolvedValue(false);
|
||||||
|
|
||||||
|
stateService.getCanAccessPremium.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const createdMenu = await sut.init();
|
||||||
|
expect(createdMenu).toBeTruthy();
|
||||||
|
expect(createSpy).toHaveBeenCalledTimes(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadOptions", () => {
|
||||||
|
const createCipher = (data?: {
|
||||||
|
id?: CipherView["id"];
|
||||||
|
username?: CipherView["login"]["username"];
|
||||||
|
password?: CipherView["login"]["password"];
|
||||||
|
totp?: CipherView["login"]["totp"];
|
||||||
|
viewPassword?: CipherView["viewPassword"];
|
||||||
|
}): CipherView => {
|
||||||
|
const { id, username, password, totp, viewPassword } = data || {};
|
||||||
|
const cipherView = new CipherView(
|
||||||
|
new Cipher({
|
||||||
|
id: id ?? "1",
|
||||||
|
type: CipherType.Login,
|
||||||
|
viewPassword: viewPassword ?? true,
|
||||||
|
} as any)
|
||||||
|
);
|
||||||
|
cipherView.login.username = username ?? "USERNAME";
|
||||||
|
cipherView.login.password = password ?? "PASSWORD";
|
||||||
|
cipherView.login.totp = totp ?? "TOTP";
|
||||||
|
return cipherView;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("is not a login cipher", async () => {
|
||||||
|
await sut.loadOptions("TEST_TITLE", "1", "", {
|
||||||
|
...createCipher(),
|
||||||
|
type: CipherType.SecureNote,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(createSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates item for autofill", async () => {
|
||||||
|
await sut.loadOptions(
|
||||||
|
"TEST_TITLE",
|
||||||
|
"1",
|
||||||
|
"",
|
||||||
|
createCipher({
|
||||||
|
username: "",
|
||||||
|
totp: "",
|
||||||
|
viewPassword: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(createSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("create entry for each cipher piece", async () => {
|
||||||
|
stateService.getCanAccessPremium.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await sut.loadOptions("TEST_TITLE", "1", "", createCipher());
|
||||||
|
|
||||||
|
// One for autofill, copy username, copy password, and copy totp code
|
||||||
|
expect(createSpy).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates noop item for no cipher", async () => {
|
||||||
|
stateService.getCanAccessPremium.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await sut.loadOptions("TEST_TITLE", "NOOP", "");
|
||||||
|
|
||||||
|
expect(createSpy).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
241
apps/browser/src/browser/main-context-menu-handler.ts
Normal file
241
apps/browser/src/browser/main-context-menu-handler.ts
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||||
|
import { CipherType } from "@bitwarden/common/enums/cipherType";
|
||||||
|
import { StateFactory } from "@bitwarden/common/factories/stateFactory";
|
||||||
|
import { Utils } from "@bitwarden/common/misc/utils";
|
||||||
|
import { GlobalState } from "@bitwarden/common/models/domain/global-state";
|
||||||
|
import { CipherView } from "@bitwarden/common/models/view/cipher.view";
|
||||||
|
|
||||||
|
import { CachedServices } from "../background/service_factories/factory-options";
|
||||||
|
import {
|
||||||
|
i18nServiceFactory,
|
||||||
|
I18nServiceInitOptions,
|
||||||
|
} from "../background/service_factories/i18n-service.factory";
|
||||||
|
import {
|
||||||
|
stateServiceFactory,
|
||||||
|
StateServiceInitOptions,
|
||||||
|
} from "../background/service_factories/state-service.factory";
|
||||||
|
import { Account } from "../models/account";
|
||||||
|
import { BrowserStateService } from "../services/abstractions/browser-state.service";
|
||||||
|
|
||||||
|
export const ROOT_ID = "root";
|
||||||
|
|
||||||
|
export const AUTOFILL_ID = "autofill";
|
||||||
|
export const COPY_USERNAME_ID = "copy-username";
|
||||||
|
export const COPY_PASSWORD_ID = "copy-password";
|
||||||
|
export const COPY_VERIFICATIONCODE_ID = "copy-totp";
|
||||||
|
export const COPY_IDENTIFIER_ID = "copy-identifier";
|
||||||
|
|
||||||
|
const SEPARATOR_ID = "separator";
|
||||||
|
export const GENERATE_PASSWORD_ID = "generate-password";
|
||||||
|
|
||||||
|
export const NOOP_COMMAND_SUFFIX = "noop";
|
||||||
|
|
||||||
|
export class MainContextMenuHandler {
|
||||||
|
//
|
||||||
|
private initRunning = false;
|
||||||
|
|
||||||
|
create: (options: chrome.contextMenus.CreateProperties) => Promise<void>;
|
||||||
|
|
||||||
|
constructor(private stateService: BrowserStateService, private i18nService: I18nService) {
|
||||||
|
if (chrome.contextMenus) {
|
||||||
|
this.create = (options) => {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
chrome.contextMenus.create(options, () => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
reject(chrome.runtime.lastError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this.create = (_options) => Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async mv3Create(cachedServices: CachedServices) {
|
||||||
|
const stateFactory = new StateFactory(GlobalState, Account);
|
||||||
|
const serviceOptions: StateServiceInitOptions & I18nServiceInitOptions = {
|
||||||
|
cryptoFunctionServiceOptions: {
|
||||||
|
win: self,
|
||||||
|
},
|
||||||
|
encryptServiceOptions: {
|
||||||
|
logMacFailures: false,
|
||||||
|
},
|
||||||
|
i18nServiceOptions: {
|
||||||
|
systemLanguage: chrome.i18n.getUILanguage(),
|
||||||
|
},
|
||||||
|
logServiceOptions: {
|
||||||
|
isDev: false,
|
||||||
|
},
|
||||||
|
stateMigrationServiceOptions: {
|
||||||
|
stateFactory: stateFactory,
|
||||||
|
},
|
||||||
|
stateServiceOptions: {
|
||||||
|
stateFactory: stateFactory,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return new MainContextMenuHandler(
|
||||||
|
await stateServiceFactory(cachedServices, serviceOptions),
|
||||||
|
await i18nServiceFactory(cachedServices, serviceOptions)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @returns a boolean showing whether or not items were created
|
||||||
|
*/
|
||||||
|
async init(): Promise<boolean> {
|
||||||
|
const menuDisabled = await this.stateService.getDisableContextMenuItem();
|
||||||
|
|
||||||
|
if (this.initRunning) {
|
||||||
|
return menuDisabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (menuDisabled) {
|
||||||
|
await MainContextMenuHandler.removeAll();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const create = async (options: Omit<chrome.contextMenus.CreateProperties, "contexts">) => {
|
||||||
|
await this.create({ ...options, contexts: ["all"] });
|
||||||
|
};
|
||||||
|
|
||||||
|
await create({
|
||||||
|
id: ROOT_ID,
|
||||||
|
title: "Bitwarden",
|
||||||
|
});
|
||||||
|
|
||||||
|
await create({
|
||||||
|
id: AUTOFILL_ID,
|
||||||
|
parentId: ROOT_ID,
|
||||||
|
title: this.i18nService.t("autoFill"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await create({
|
||||||
|
id: COPY_USERNAME_ID,
|
||||||
|
parentId: ROOT_ID,
|
||||||
|
title: this.i18nService.t("copyUsername"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await create({
|
||||||
|
id: COPY_PASSWORD_ID,
|
||||||
|
parentId: ROOT_ID,
|
||||||
|
title: this.i18nService.t("copyPassword"),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (await this.stateService.getCanAccessPremium()) {
|
||||||
|
await create({
|
||||||
|
id: COPY_VERIFICATIONCODE_ID,
|
||||||
|
parentId: ROOT_ID,
|
||||||
|
title: this.i18nService.t("copyVerificationCode"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await create({
|
||||||
|
id: SEPARATOR_ID,
|
||||||
|
type: "separator",
|
||||||
|
parentId: ROOT_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
await create({
|
||||||
|
id: GENERATE_PASSWORD_ID,
|
||||||
|
parentId: ROOT_ID,
|
||||||
|
title: this.i18nService.t("generatePasswordCopied"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await create({
|
||||||
|
id: COPY_IDENTIFIER_ID,
|
||||||
|
parentId: ROOT_ID,
|
||||||
|
title: this.i18nService.t("copyElementIdentifier"),
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
this.initRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async removeAll() {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
chrome.contextMenus.removeAll(() => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
reject(chrome.runtime.lastError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static remove(menuItemId: string) {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
chrome.contextMenus.remove(menuItemId, () => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
reject(chrome.runtime.lastError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadOptions(title: string, id: string, url: string, cipher?: CipherView | undefined) {
|
||||||
|
if (cipher != null && cipher.type !== CipherType.Login) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedTitle = MainContextMenuHandler.sanitizeContextMenuTitle(title);
|
||||||
|
|
||||||
|
const createChildItem = async (parent: string) => {
|
||||||
|
const menuItemId = `${parent}_${id}`;
|
||||||
|
return await this.create({
|
||||||
|
type: "normal",
|
||||||
|
id: menuItemId,
|
||||||
|
parentId: parent,
|
||||||
|
title: sanitizedTitle,
|
||||||
|
contexts: ["all"],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cipher == null || !Utils.isNullOrEmpty(cipher.login.password)) {
|
||||||
|
await createChildItem(AUTOFILL_ID);
|
||||||
|
if (cipher?.viewPassword ?? true) {
|
||||||
|
await createChildItem(COPY_PASSWORD_ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cipher == null || !Utils.isNullOrEmpty(cipher.login.username)) {
|
||||||
|
await createChildItem(COPY_USERNAME_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canAccessPremium = await this.stateService.getCanAccessPremium();
|
||||||
|
if (canAccessPremium && (cipher == null || !Utils.isNullOrEmpty(cipher.login.totp))) {
|
||||||
|
await createChildItem(COPY_VERIFICATIONCODE_ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static sanitizeContextMenuTitle(title: string): string {
|
||||||
|
return title.replace(/&/g, "&&");
|
||||||
|
}
|
||||||
|
|
||||||
|
async noAccess() {
|
||||||
|
if (await this.init()) {
|
||||||
|
const authed = await this.stateService.getIsAuthenticated();
|
||||||
|
await this.loadOptions(
|
||||||
|
this.i18nService.t(authed ? "unlockVaultMenu" : "loginToVaultMenu"),
|
||||||
|
NOOP_COMMAND_SUFFIX,
|
||||||
|
"<all_urls>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async noLogins(url: string) {
|
||||||
|
await this.loadOptions(this.i18nService.t("noMatchingLogins"), NOOP_COMMAND_SUFFIX, url);
|
||||||
|
}
|
||||||
|
}
|
@ -1,10 +1,12 @@
|
|||||||
|
import { CipherView } from "@bitwarden/common/models/view/cipher.view";
|
||||||
|
|
||||||
import AutofillPageDetails from "../models/autofillPageDetails";
|
import AutofillPageDetails from "../models/autofillPageDetails";
|
||||||
import { AutofillService } from "../services/abstractions/autofill.service";
|
import { AutofillService } from "../services/abstractions/autofill.service";
|
||||||
|
|
||||||
export class AutoFillActiveTabCommand {
|
export class AutofillTabCommand {
|
||||||
constructor(private autofillService: AutofillService) {}
|
constructor(private autofillService: AutofillService) {}
|
||||||
|
|
||||||
async doAutoFillActiveTabCommand(tab: chrome.tabs.Tab) {
|
async doAutofillTabCommand(tab: chrome.tabs.Tab) {
|
||||||
if (!tab.id) {
|
if (!tab.id) {
|
||||||
throw new Error("Tab does not have an id, cannot complete autofill.");
|
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> {
|
private async collectPageDetails(tabId: number): Promise<AutofillPageDetails> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
chrome.tabs.sendMessage(
|
chrome.tabs.sendMessage(
|
@ -54,9 +54,12 @@ document.addEventListener("contextmenu", (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Runs when the 'Copy Custom Field Name' context menu item is actually clicked.
|
// 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") {
|
if (event.command === "getClickedElement") {
|
||||||
const identifier = getClickedElementIdentifier();
|
const identifier = getClickedElementIdentifier();
|
||||||
|
if (sendResponse) {
|
||||||
|
sendResponse(identifier);
|
||||||
|
}
|
||||||
chrome.runtime.sendMessage({
|
chrome.runtime.sendMessage({
|
||||||
command: "getClickedElementResponse",
|
command: "getClickedElementResponse",
|
||||||
sender: "contextMenuHandler",
|
sender: "contextMenuHandler",
|
||||||
|
25
apps/browser/src/listeners/combine.spec.ts
Normal file
25
apps/browser/src/listeners/combine.spec.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { combine } from "./combine";
|
||||||
|
|
||||||
|
describe("combine", () => {
|
||||||
|
it("runs", () => {
|
||||||
|
const combined = combine([
|
||||||
|
(arg: Record<string, unknown>, serviceCache: Record<string, unknown>) => {
|
||||||
|
arg["one"] = true;
|
||||||
|
serviceCache["one"] = true;
|
||||||
|
},
|
||||||
|
(arg: Record<string, unknown>, serviceCache: Record<string, unknown>) => {
|
||||||
|
if (serviceCache["one"] !== true) {
|
||||||
|
throw new Error("One should have ran.");
|
||||||
|
}
|
||||||
|
arg["two"] = true;
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const arg: Record<string, unknown> = {};
|
||||||
|
combined(arg);
|
||||||
|
|
||||||
|
expect(arg["one"]).toBeTruthy();
|
||||||
|
|
||||||
|
expect(arg["two"]).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
15
apps/browser/src/listeners/combine.ts
Normal file
15
apps/browser/src/listeners/combine.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { CachedServices } from "../background/service_factories/factory-options";
|
||||||
|
|
||||||
|
type Listener<T extends unknown[]> = (...args: [...T, CachedServices]) => void;
|
||||||
|
|
||||||
|
export const combine = <T extends unknown[]>(
|
||||||
|
listeners: Listener<T>[],
|
||||||
|
startingServices: CachedServices = {}
|
||||||
|
) => {
|
||||||
|
return (...args: T) => {
|
||||||
|
const cachedServices = { ...startingServices };
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener(...[...args, cachedServices]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
40
apps/browser/src/listeners/index.ts
Normal file
40
apps/browser/src/listeners/index.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { CipherContextMenuHandler } from "../browser/cipher-context-menu-handler";
|
||||||
|
import { ContextMenuClickedHandler } from "../browser/context-menu-clicked-handler";
|
||||||
|
|
||||||
|
import { combine } from "./combine";
|
||||||
|
import { onCommandListener } from "./onCommandListener";
|
||||||
|
import { onInstallListener } from "./onInstallListener";
|
||||||
|
import { UpdateBadge } from "./update-badge";
|
||||||
|
|
||||||
|
const tabsOnActivatedListener = combine([
|
||||||
|
UpdateBadge.tabsOnActivatedListener,
|
||||||
|
CipherContextMenuHandler.tabsOnActivatedListener,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const tabsOnReplacedListener = combine([
|
||||||
|
UpdateBadge.tabsOnReplacedListener,
|
||||||
|
CipherContextMenuHandler.tabsOnReplacedListener,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const tabsOnUpdatedListener = combine([
|
||||||
|
UpdateBadge.tabsOnUpdatedListener,
|
||||||
|
CipherContextMenuHandler.tabsOnUpdatedListener,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const contextMenusClickedListener = ContextMenuClickedHandler.onClickedListener;
|
||||||
|
|
||||||
|
const runtimeMessageListener = combine([
|
||||||
|
UpdateBadge.messageListener,
|
||||||
|
CipherContextMenuHandler.messageListener,
|
||||||
|
ContextMenuClickedHandler.messageListener,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export {
|
||||||
|
tabsOnActivatedListener,
|
||||||
|
tabsOnReplacedListener,
|
||||||
|
tabsOnUpdatedListener,
|
||||||
|
contextMenusClickedListener,
|
||||||
|
runtimeMessageListener,
|
||||||
|
onCommandListener,
|
||||||
|
onInstallListener,
|
||||||
|
};
|
@ -14,7 +14,7 @@ import {
|
|||||||
import { stateServiceFactory } from "../background/service_factories/state-service.factory";
|
import { stateServiceFactory } from "../background/service_factories/state-service.factory";
|
||||||
import { BrowserApi } from "../browser/browserApi";
|
import { BrowserApi } from "../browser/browserApi";
|
||||||
import { GeneratePasswordToClipboardCommand } from "../clipboard";
|
import { GeneratePasswordToClipboardCommand } from "../clipboard";
|
||||||
import { AutoFillActiveTabCommand } from "../commands/autoFillActiveTabCommand";
|
import { AutofillTabCommand } from "../commands/autofill-tab-command";
|
||||||
import { Account } from "../models/account";
|
import { Account } from "../models/account";
|
||||||
|
|
||||||
export const onCommandListener = async (command: string, tab: chrome.tabs.Tab) => {
|
export const onCommandListener = async (command: string, tab: chrome.tabs.Tab) => {
|
||||||
@ -75,8 +75,8 @@ const doAutoFillLogin = async (tab: chrome.tabs.Tab): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const command = new AutoFillActiveTabCommand(autofillService);
|
const command = new AutofillTabCommand(autofillService);
|
||||||
await command.doAutoFillActiveTabCommand(tab);
|
await command.doAutofillTabCommand(tab);
|
||||||
};
|
};
|
||||||
|
|
||||||
const doGeneratePasswordToClipboard = async (tab: chrome.tabs.Tab): Promise<void> => {
|
const doGeneratePasswordToClipboard = async (tab: chrome.tabs.Tab): Promise<void> => {
|
||||||
|
@ -1,13 +1,16 @@
|
|||||||
import { StateFactory } from "@bitwarden/common/factories/stateFactory";
|
import { StateFactory } from "@bitwarden/common/factories/stateFactory";
|
||||||
import { GlobalState } from "@bitwarden/common/models/domain/global-state";
|
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 { BrowserApi } from "../browser/browserApi";
|
||||||
import { Account } from "../models/account";
|
import { Account } from "../models/account";
|
||||||
|
|
||||||
export async function onInstallListener(details: chrome.runtime.InstalledDetails) {
|
export async function onInstallListener(details: chrome.runtime.InstalledDetails) {
|
||||||
const cache = {};
|
const cache = {};
|
||||||
const opts = {
|
const opts: EnvironmentServiceInitOptions = {
|
||||||
encryptServiceOptions: {
|
encryptServiceOptions: {
|
||||||
logMacFailures: false,
|
logMacFailures: false,
|
||||||
},
|
},
|
||||||
@ -27,7 +30,7 @@ export async function onInstallListener(details: chrome.runtime.InstalledDetails
|
|||||||
const environmentService = await environmentServiceFactory(cache, opts);
|
const environmentService = await environmentServiceFactory(cache, opts);
|
||||||
|
|
||||||
setTimeout(async () => {
|
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/");
|
BrowserApi.createNewTab("https://bitwarden.com/browser-start/");
|
||||||
|
|
||||||
if (await environmentService.hasManagedEnvironment()) {
|
if (await environmentService.hasManagedEnvironment()) {
|
||||||
|
@ -43,31 +43,47 @@ export class UpdateBadge {
|
|||||||
"deletedCipher",
|
"deletedCipher",
|
||||||
];
|
];
|
||||||
|
|
||||||
static async tabsOnActivatedListener(activeInfo: chrome.tabs.TabActiveInfo) {
|
static async tabsOnActivatedListener(
|
||||||
await new UpdateBadge(self).run({ tabId: activeInfo.tabId, windowId: activeInfo.windowId });
|
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) {
|
static async tabsOnReplacedListener(
|
||||||
await new UpdateBadge(self).run({ tabId: addedTabId });
|
addedTabId: number,
|
||||||
|
removedTabId: number,
|
||||||
|
serviceCache: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
await new UpdateBadge(self).run({ tabId: addedTabId, existingServices: serviceCache });
|
||||||
}
|
}
|
||||||
|
|
||||||
static async tabsOnUpdatedListener(
|
static async tabsOnUpdatedListener(
|
||||||
tabId: number,
|
tabId: number,
|
||||||
changeInfo: chrome.tabs.TabChangeInfo,
|
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(
|
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)) {
|
if (!UpdateBadge.listenedToCommands.includes(message.command)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await new UpdateBadge(self).run();
|
await new UpdateBadge(self).run({ existingServices: serviceCache });
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(win: Window & typeof globalThis) {
|
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> = {
|
export type TabMessageBase<T extends string> = {
|
||||||
command: T;
|
command: T;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CopyTextTabMessage = TabMessageBase<"copyText"> & {
|
type CopyTextTabMessage = TabMessageBase<"copyText"> & {
|
||||||
text: string;
|
text: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ClearClipboardTabMessage = TabMessageBase<"clearClipboard">;
|
||||||
|
|
||||||
|
type GetClickedElementTabMessage = TabMessageBase<"getClickedElement">;
|
||||||
|
@ -25,8 +25,14 @@ const runtime = {
|
|||||||
getManifest: jest.fn(),
|
getManifest: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const contextMenus = {
|
||||||
|
create: jest.fn(),
|
||||||
|
removeAll: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
// set chrome
|
// set chrome
|
||||||
global.chrome = {
|
global.chrome = {
|
||||||
storage,
|
storage,
|
||||||
runtime,
|
runtime,
|
||||||
|
contextMenus,
|
||||||
} as any;
|
} as any;
|
||||||
|
@ -66,8 +66,8 @@ export abstract class CipherService {
|
|||||||
deleteManyWithServer: (ids: string[]) => Promise<any>;
|
deleteManyWithServer: (ids: string[]) => Promise<any>;
|
||||||
deleteAttachment: (id: string, attachmentId: string) => Promise<void>;
|
deleteAttachment: (id: string, attachmentId: string) => Promise<void>;
|
||||||
deleteAttachmentWithServer: (id: string, attachmentId: string) => Promise<void>;
|
deleteAttachmentWithServer: (id: string, attachmentId: string) => Promise<void>;
|
||||||
sortCiphersByLastUsed: (a: any, b: any) => number;
|
sortCiphersByLastUsed: (a: CipherView, b: CipherView) => number;
|
||||||
sortCiphersByLastUsedThenName: (a: any, b: any) => number;
|
sortCiphersByLastUsedThenName: (a: CipherView, b: CipherView) => number;
|
||||||
getLocaleSortingFunction: () => (a: CipherView, b: CipherView) => number;
|
getLocaleSortingFunction: () => (a: CipherView, b: CipherView) => number;
|
||||||
softDelete: (id: string | string[]) => Promise<any>;
|
softDelete: (id: string | string[]) => Promise<any>;
|
||||||
softDeleteWithServer: (id: string) => Promise<any>;
|
softDeleteWithServer: (id: string) => Promise<any>;
|
||||||
|
Loading…
Reference in New Issue
Block a user