1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-22 11:45:59 +01:00

[PM-3914] Refactor Browser Extension Popout Windows (#6296)

* [PM-3914] Refactor Browser Extension Popouts

* [PM-3914] Refactor Browser Extension Popouts

* [PM-3914] Refactor Browser Extension Popouts

* [PM-3914] Adding enums for the browser popout type

* [PM-3914] Making the methods for getting a window in a targeted manner public

* [PM-3914] Refactoing implementation

* [PM-3914] Updating deprecated api call

* [PM-3914] Fixing issues found when testing behavior

* [PM-3914] Reimplementing behavior based on feedback from platform team

* [PM-3914] Adding method of ensuring previously opened single action window is force closed for vault item password reprompts

* [PM-3914] Taking into consideration feedback regarding the browser popup utils service and implementating requested changes

* [PM-3914] Removing unnecesssary class dependencies

* [PM-3914] Adding method for uniquely setting up password reprompt windows

* [PM-3914] Modifying method

* [PM-3914] Adding jest tests and documentation for AuthPopoutWindow util

* [PM-3914] Adding jest tests and documentation for VaultPopoutWindow

* [PM-3914] Adding jest tests for the debouncing method within autofill service

* [PM-3914] Adding jest tests for the new BrowserApi methods

* [PM-3914] Adding jest tests to the BrowserPopupUtils class

* [PM-3914] Updating inPrivateMode reference

* [PM-3914] Updating inPrivateMode reference

* [PM-3914] Modifying comment

* [PM-3914] Moviing implementation for openCurrentPagePopout to the BrowserPopupUtils

* [PM-3914] Applying feedback

* [PM-3914] Applying feedback

* [PM-3914] Applying feedback

* [PM-3983] Refactoring implementation of `setContentScrollY` to facilitate having a potential delay

* [PM-3914] Applying feedback regarding setContentScrollY to the implementation

* [PM-3914] Modifying early return within the run method of the ContextMenuClickedHandler

* [PM-3914] Adding test for VaultPopoutWindow

* [PM-3914] Applying work done within PM-4366 to facilitate opening the popout window as a popup rather than a normal window

* [PM-3914] Updating the BrowserApi.removeTab method to leverage a callback structure for the promise rather than an async away structure

* [PM-3036] Adding jest tests for added passkeys popout windows

* [PM-3914] Adjsuting logic for turning off the warning when FIDO2 credentials are saved

* [PM-3914] Fixing height to design

* [PM-3914] Fixing call to Fido2 Popout

* [PM-3914] Fixing add/edit from fido2 popout

* [PM-3914] Fixing add/edit from fido2 popout

* [PM-3914] Fixing jest tests for updated elements

* [PM-3914] Reverting how context menu actions are passed to the view component

* [PM-3914] Reverting re-instantiation of config service within main.background.ts

* [PM-3914] Adding jest test for BrowserAPI removeTab method

* [PM-3914] Adding method to handle parsing the popout url path

* [PM-3914] Removing JSDOC comment elements

* [PM-3914] Removing await from method call

* [PM-3914] Simplifying implementation on add/edit

* [PM-3032] Adding more direct reference to view item action in context menus

* [PM-3914] Adjusting routing on Fido2 component to pass the singleActionPopout param to the route when opening the add-edit component

* [PM-3914] Adding singleActionPopout param to the fido2 component routing

* [PM-3914] Updating implementation details for how we build the extension url path

* [PM-3914] Reworking implementation for isSingleActionPopoutOpen to clean up iterative logic

* [PM-3914] Merging work from master and fixing merge conflicts

* [PM-3914] Fixing merge conflict introduced from master

* [PM-3914] Reworking closure of single action popouts to ensure they close the window instead of attempting to close the tab

* [PM-3914] Fixing issue within Opera where lock and login routes can persist if user opens the extension popout in a new window before locking or logging out

* [PM-3914] Setting the extensionUrls that are cheked as a variable outside of the scope fo the openUlockPopout method to ensure it does not have to be rebuilt each time the method is called
This commit is contained in:
Cesar Gonzalez 2023-11-08 12:57:44 -06:00 committed by GitHub
parent 16c567ab59
commit cf6ada531e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 1579 additions and 677 deletions

View File

@ -22,7 +22,9 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv
import { DialogService } from "@bitwarden/components";
import { BrowserApi } from "../../platform/browser/browser-api";
import { PopupUtilsService } from "../../popup/services/popup-utils.service";
import BrowserPopupUtils from "../../platform/popup/browser-popup-utils";
import { closeTwoFactorAuthPopout } from "./utils/auth-popout-window";
const BroadcasterSubscriptionId = "TwoFactorComponent";
@ -31,8 +33,6 @@ const BroadcasterSubscriptionId = "TwoFactorComponent";
templateUrl: "two-factor.component.html",
})
export class TwoFactorComponent extends BaseTwoFactorComponent {
showNewWindowMessage = false;
constructor(
authService: AuthService,
router: Router,
@ -42,7 +42,6 @@ export class TwoFactorComponent extends BaseTwoFactorComponent {
private syncService: SyncService,
environmentService: EnvironmentService,
private broadcasterService: BroadcasterService,
private popupUtilsService: PopupUtilsService,
stateService: StateService,
route: ActivatedRoute,
private messagingService: MessagingService,
@ -115,7 +114,7 @@ export class TwoFactorComponent extends BaseTwoFactorComponent {
if (
this.selectedProviderType === TwoFactorProviderType.Email &&
this.popupUtilsService.inPopup(window)
BrowserPopupUtils.inPopup(window)
) {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "warning" },
@ -123,7 +122,7 @@ export class TwoFactorComponent extends BaseTwoFactorComponent {
type: "warning",
});
if (confirmed) {
this.popupUtilsService.popOut(window);
BrowserPopupUtils.openCurrentPagePopout(window);
}
}
@ -142,7 +141,7 @@ export class TwoFactorComponent extends BaseTwoFactorComponent {
// We don't need this window anymore because the intent is for the user to be left
// on the web vault screen which tells them to continue in the browser extension (sidebar or popup)
BrowserApi.closeBitwardenExtensionTab();
await closeTwoFactorAuthPopout();
};
}
});

View File

@ -0,0 +1,103 @@
import { createChromeTabMock } from "../../../autofill/jest/autofill-mocks";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
import {
AuthPopoutType,
openUnlockPopout,
closeUnlockPopout,
openSsoAuthResultPopout,
openTwoFactorAuthPopout,
closeTwoFactorAuthPopout,
} from "./auth-popout-window";
describe("AuthPopoutWindow", () => {
const openPopoutSpy = jest.spyOn(BrowserPopupUtils, "openPopout").mockImplementation();
const sendMessageDataSpy = jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation();
const closeSingleActionPopoutSpy = jest
.spyOn(BrowserPopupUtils, "closeSingleActionPopout")
.mockImplementation();
afterEach(() => {
jest.clearAllMocks();
});
describe("openUnlockPopout", () => {
it("opens a single action popup that allows the user to unlock the extension and sends a `bgUnlockPopoutOpened` message", async () => {
jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([]);
const senderTab = { windowId: 1 } as chrome.tabs.Tab;
await openUnlockPopout(senderTab);
expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html", {
singleActionKey: AuthPopoutType.unlockExtension,
senderWindowId: 1,
});
expect(sendMessageDataSpy).toHaveBeenCalledWith(senderTab, "bgUnlockPopoutOpened");
});
it("closes any existing popup window types that are open to the unlock extension route", async () => {
const unlockTab = createChromeTabMock({
url: chrome.runtime.getURL("popup/index.html#/lock"),
});
jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([unlockTab]);
jest.spyOn(BrowserApi, "removeWindow");
const senderTab = { windowId: 1 } as chrome.tabs.Tab;
await openUnlockPopout(senderTab);
expect(BrowserApi.tabsQuery).toHaveBeenCalledWith({ windowType: "popup" });
expect(BrowserApi.removeWindow).toHaveBeenCalledWith(unlockTab.windowId);
});
it("closes any existing popup window types that are open to the login extension route", async () => {
const loginTab = createChromeTabMock({
url: chrome.runtime.getURL("popup/index.html#/home"),
});
jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([loginTab]);
jest.spyOn(BrowserApi, "removeWindow");
const senderTab = { windowId: 1 } as chrome.tabs.Tab;
await openUnlockPopout(senderTab);
expect(BrowserApi.removeWindow).toHaveBeenCalledWith(loginTab.windowId);
});
});
describe("closeUnlockPopout", () => {
it("closes the unlock extension popout window", () => {
closeUnlockPopout();
expect(closeSingleActionPopoutSpy).toHaveBeenCalledWith("auth_unlockExtension");
});
});
describe("openSsoAuthResultPopout", () => {
it("opens a window that facilitates presentation of the results for SSO authentication", () => {
openSsoAuthResultPopout({ code: "code", state: "state" });
expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/sso?code=code&state=state", {
singleActionKey: AuthPopoutType.ssoAuthResult,
});
});
});
describe("openTwoFactorAuthPopout", () => {
it("opens a window that facilitates two factor authentication", () => {
openTwoFactorAuthPopout({ data: "data", remember: "remember" });
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/2fa;webAuthnResponse=data;remember=remember",
{ singleActionKey: AuthPopoutType.twoFactorAuth }
);
});
});
describe("closeTwoFactorAuthPopout", () => {
it("closes the two-factor authentication window", () => {
closeTwoFactorAuthPopout();
expect(closeSingleActionPopoutSpy).toHaveBeenCalledWith("auth_twoFactorAuth");
});
});
});

View File

@ -0,0 +1,87 @@
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
const AuthPopoutType = {
unlockExtension: "auth_unlockExtension",
ssoAuthResult: "auth_ssoAuthResult",
twoFactorAuth: "auth_twoFactorAuth",
} as const;
const extensionUnlockUrls = new Set([
chrome.runtime.getURL("popup/index.html#/lock"),
chrome.runtime.getURL("popup/index.html#/home"),
]);
/**
* Opens a window that facilitates unlocking / logging into the extension.
*
* @param senderTab - Used to determine the windowId of the sender.
*/
async function openUnlockPopout(senderTab: chrome.tabs.Tab) {
const existingPopoutWindowTabs = await BrowserApi.tabsQuery({ windowType: "popup" });
existingPopoutWindowTabs.forEach((tab) => {
if (extensionUnlockUrls.has(tab.url)) {
BrowserApi.removeWindow(tab.windowId);
}
});
await BrowserPopupUtils.openPopout("popup/index.html", {
singleActionKey: AuthPopoutType.unlockExtension,
senderWindowId: senderTab.windowId,
});
await BrowserApi.tabSendMessageData(senderTab, "bgUnlockPopoutOpened");
}
/**
* Closes the unlock popout window.
*/
async function closeUnlockPopout() {
await BrowserPopupUtils.closeSingleActionPopout(AuthPopoutType.unlockExtension);
}
/**
* Opens a window that facilitates presenting the results for SSO authentication.
*
* @param resultData - The result data from the SSO authentication.
*/
async function openSsoAuthResultPopout(resultData: { code: string; state: string }) {
const { code, state } = resultData;
const authResultUrl = `popup/index.html#/sso?code=${encodeURIComponent(
code
)}&state=${encodeURIComponent(state)}`;
await BrowserPopupUtils.openPopout(authResultUrl, {
singleActionKey: AuthPopoutType.ssoAuthResult,
});
}
/**
* Opens a window that facilitates two-factor authentication.
*
* @param twoFactorAuthData - The data from the two-factor authentication.
*/
async function openTwoFactorAuthPopout(twoFactorAuthData: { data: string; remember: string }) {
const { data, remember } = twoFactorAuthData;
const params =
`webAuthnResponse=${encodeURIComponent(data)};` + `remember=${encodeURIComponent(remember)}`;
const twoFactorUrl = `popup/index.html#/2fa;${params}`;
await BrowserPopupUtils.openPopout(twoFactorUrl, {
singleActionKey: AuthPopoutType.twoFactorAuth,
});
}
/**
* Closes the two-factor authentication popout window.
*/
async function closeTwoFactorAuthPopout() {
await BrowserPopupUtils.closeSingleActionPopout(AuthPopoutType.twoFactorAuth);
}
export {
AuthPopoutType,
openUnlockPopout,
closeUnlockPopout,
openSsoAuthResultPopout,
openTwoFactorAuthPopout,
closeTwoFactorAuthPopout,
};

View File

@ -12,6 +12,7 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { openUnlockPopout } from "../../auth/popup/utils/auth-popout-window";
import AddUnlockVaultQueueMessage from "../../background/models/add-unlock-vault-queue-message";
import AddChangePasswordQueueMessage from "../../background/models/addChangePasswordQueueMessage";
import AddLoginQueueMessage from "../../background/models/addLoginQueueMessage";
@ -21,6 +22,7 @@ import LockedVaultPendingNotificationsItem from "../../background/models/lockedV
import { NotificationQueueMessageType } from "../../background/models/notificationQueueMessageType";
import { BrowserApi } from "../../platform/browser/browser-api";
import { BrowserStateService } from "../../platform/services/abstractions/browser-state.service";
import { openAddEditVaultItemPopout } from "../../vault/popup/utils/vault-popout-window";
import { AutofillService } from "../services/abstractions/autofill.service";
export default class NotificationBackground {
@ -96,7 +98,7 @@ export default class NotificationBackground {
"addToLockedVaultPendingNotifications",
retryMessage
);
await BrowserApi.tabSendMessageData(sender.tab, "promptForLogin");
await openUnlockPopout(sender.tab);
return;
}
await this.saveOrUpdateCredentials(sender.tab, msg.edit, msg.folder);
@ -118,9 +120,12 @@ export default class NotificationBackground {
break;
}
break;
case "promptForLogin":
case "bgUnlockPopoutOpened":
await this.unlockVault(sender.tab);
break;
case "bgReopenUnlockPopout":
await openUnlockPopout(sender.tab);
break;
default:
break;
}
@ -447,9 +452,7 @@ export default class NotificationBackground {
collectionIds: cipherView.collectionIds,
});
await BrowserApi.tabSendMessageData(senderTab, "openAddEditCipher", {
cipherId: cipherView.id,
});
await openAddEditVaultItemPopout(senderTab, { cipherId: cipherView.id });
}
private async folderExists(folderId: string) {

View File

@ -18,6 +18,7 @@ import {
} from "../../auth/background/service-factories/auth-service.factory";
import { totpServiceFactory } from "../../auth/background/service-factories/totp-service.factory";
import { userVerificationServiceFactory } from "../../auth/background/service-factories/user-verification-service.factory";
import { openUnlockPopout } from "../../auth/popup/utils/auth-popout-window";
import LockedVaultPendingNotificationsItem from "../../background/models/lockedVaultPendingNotificationsItem";
import { eventCollectionServiceFactory } from "../../background/service-factories/event-collection-service.factory";
import { Account } from "../../models/account";
@ -29,6 +30,10 @@ import {
cipherServiceFactory,
CipherServiceInitOptions,
} from "../../vault/background/service_factories/cipher-service.factory";
import {
openAddEditVaultItemPopout,
openVaultItemPasswordRepromptPopout,
} from "../../vault/popup/utils/vault-popout-window";
import { autofillServiceFactory } from "../background/service_factories/autofill-service.factory";
import { copyToClipboard, GeneratePasswordToClipboardCommand } from "../clipboard";
import { AutofillTabCommand } from "../commands/autofill-tab-command";
@ -187,7 +192,7 @@ export class ContextMenuClickedHandler {
retryMessage
);
await BrowserApi.tabSendMessageData(tab, "promptForLogin");
await openUnlockPopout(tab);
return;
}
@ -235,14 +240,12 @@ export class ContextMenuClickedHandler {
const cipherType = this.getCipherCreationType(menuItemId);
if (cipherType) {
await BrowserApi.tabSendMessageData(tab, "openAddEditCipher", {
cipherType,
});
await openAddEditVaultItemPopout(tab, { cipherType });
break;
}
if (await this.isPasswordRepromptRequired(cipher)) {
await BrowserApi.tabSendMessageData(tab, "passwordReprompt", {
await openVaultItemPasswordRepromptPopout(tab, {
cipherId: cipher.id,
// The action here is passed on to the single-use reprompt window and doesn't change based on cipher type
action: AUTOFILL_ID,
@ -255,9 +258,7 @@ export class ContextMenuClickedHandler {
}
case COPY_USERNAME_ID:
if (menuItemId === CREATE_LOGIN_ID) {
await BrowserApi.tabSendMessageData(tab, "openAddEditCipher", {
cipherType: CipherType.Login,
});
await openAddEditVaultItemPopout(tab, { cipherType: CipherType.Login });
break;
}
@ -265,16 +266,14 @@ export class ContextMenuClickedHandler {
break;
case COPY_PASSWORD_ID:
if (menuItemId === CREATE_LOGIN_ID) {
await BrowserApi.tabSendMessageData(tab, "openAddEditCipher", {
cipherType: CipherType.Login,
});
await openAddEditVaultItemPopout(tab, { cipherType: CipherType.Login });
break;
}
if (await this.isPasswordRepromptRequired(cipher)) {
await BrowserApi.tabSendMessageData(tab, "passwordReprompt", {
await openVaultItemPasswordRepromptPopout(tab, {
cipherId: cipher.id,
action: info.parentMenuItemId,
action: COPY_PASSWORD_ID,
});
} else {
this.copyToClipboard({ text: cipher.login.password, tab: tab });
@ -284,16 +283,14 @@ export class ContextMenuClickedHandler {
break;
case COPY_VERIFICATIONCODE_ID:
if (menuItemId === CREATE_LOGIN_ID) {
await BrowserApi.tabSendMessageData(tab, "openAddEditCipher", {
cipherType: CipherType.Login,
});
await openAddEditVaultItemPopout(tab, { cipherType: CipherType.Login });
break;
}
if (await this.isPasswordRepromptRequired(cipher)) {
await BrowserApi.tabSendMessageData(tab, "passwordReprompt", {
await openVaultItemPasswordRepromptPopout(tab, {
cipherId: cipher.id,
action: info.parentMenuItemId,
action: COPY_VERIFICATIONCODE_ID,
});
} else {
this.copyToClipboard({

View File

@ -28,12 +28,10 @@ window.addEventListener(
);
const forwardCommands = [
"promptForLogin",
"passwordReprompt",
"bgUnlockPopoutOpened",
"addToLockedVaultPendingNotifications",
"unlockCompleted",
"addedCipher",
"openAddEditCipher",
];
chrome.runtime.onMessage.addListener((event) => {

View File

@ -189,7 +189,7 @@ function handleTypeUnlock() {
const unlockButton = document.getElementById("unlock-vault");
unlockButton.addEventListener("click", (e) => {
sendPlatformMessage({
command: "bgReopenPromptForLogin",
command: "bgReopenUnlockPopout",
});
});
}

View File

@ -752,13 +752,15 @@ describe("AutofillService", () => {
jest
.spyOn(userVerificationService, "hasMasterPasswordAndMasterKeyHash")
.mockResolvedValueOnce(true);
jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation();
jest
.spyOn(autofillService as any, "openVaultItemPasswordRepromptPopout")
.mockImplementation();
const result = await autofillService.doAutoFillOnTab(pageDetails, tab, true);
expect(cipherService.getNextCipherForUrl).toHaveBeenCalledWith(tab.url);
expect(userVerificationService.hasMasterPasswordAndMasterKeyHash).toHaveBeenCalled();
expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith(tab, "passwordReprompt", {
expect(autofillService["openVaultItemPasswordRepromptPopout"]).toHaveBeenCalledWith(tab, {
cipherId: cipher.id,
action: "autofill",
});
@ -4254,4 +4256,24 @@ describe("AutofillService", () => {
expect(result).toBe(false);
});
});
describe("isDebouncingPasswordRepromptPopout", () => {
it("returns false and sets up the debounce if a master password reprompt window is not currently opening", () => {
jest.spyOn(globalThis, "setTimeout");
const result = autofillService["isDebouncingPasswordRepromptPopout"]();
expect(result).toBe(false);
expect(globalThis.setTimeout).toHaveBeenCalledWith(expect.any(Function), 100);
expect(autofillService["currentlyOpeningPasswordRepromptPopout"]).toBe(true);
});
it("returns true if a master password reprompt window is currently opening", () => {
autofillService["currentlyOpeningPasswordRepromptPopout"] = true;
const result = autofillService["isDebouncingPasswordRepromptPopout"]();
expect(result).toBe(true);
});
});
});

View File

@ -12,6 +12,7 @@ import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
import { BrowserApi } from "../../platform/browser/browser-api";
import { BrowserStateService } from "../../platform/services/abstractions/browser-state.service";
import { openVaultItemPasswordRepromptPopout } from "../../vault/popup/utils/vault-popout-window";
import AutofillField from "../models/autofill-field";
import AutofillPageDetails from "../models/autofill-page-details";
import AutofillScript from "../models/autofill-script";
@ -30,6 +31,10 @@ import {
} from "./autofill-constants";
export default class AutofillService implements AutofillServiceInterface {
private openVaultItemPasswordRepromptPopout = openVaultItemPasswordRepromptPopout;
private openPasswordRepromptPopoutDebounce: NodeJS.Timeout;
private currentlyOpeningPasswordRepromptPopout = false;
constructor(
private cipherService: CipherService,
private stateService: BrowserStateService,
@ -272,13 +277,14 @@ export default class AutofillService implements AutofillServiceInterface {
if (
cipher.reprompt === CipherRepromptType.Password &&
// If the master password has is not available, reprompt will error
(await this.userVerificationService.hasMasterPasswordAndMasterKeyHash())
(await this.userVerificationService.hasMasterPasswordAndMasterKeyHash()) &&
!this.isDebouncingPasswordRepromptPopout()
) {
if (fromCommand) {
this.cipherService.updateLastUsedIndexForUrl(tab.url);
}
await BrowserApi.tabSendMessageData(tab, "passwordReprompt", {
await this.openVaultItemPasswordRepromptPopout(tab, {
cipherId: cipher.id,
action: "autofill",
});
@ -1828,4 +1834,22 @@ export default class AutofillService implements AutofillServiceInterface {
static forCustomFieldsOnly(field: AutofillField): boolean {
return field.tagName === "span";
}
/**
* Handles debouncing the opening of the master password reprompt popout.
*/
private isDebouncingPasswordRepromptPopout() {
if (this.currentlyOpeningPasswordRepromptPopout) {
return true;
}
this.currentlyOpeningPasswordRepromptPopout = true;
clearTimeout(this.openPasswordRepromptPopoutDebounce);
this.openPasswordRepromptPopoutDebounce = setTimeout(() => {
this.currentlyOpeningPasswordRepromptPopout = false;
}, 100);
return false;
}
}

View File

@ -4,6 +4,7 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
import { openUnlockPopout } from "../auth/popup/utils/auth-popout-window";
import { BrowserApi } from "../platform/browser/browser-api";
import MainBackground from "./main.background";
@ -87,7 +88,7 @@ export default class CommandsBackground {
retryMessage
);
BrowserApi.tabSendMessageData(tab, "promptForLogin");
await openUnlockPopout(tab);
return;
}

View File

@ -132,7 +132,6 @@ import { Account } from "../models/account";
import { BrowserApi } from "../platform/browser/browser-api";
import { flagEnabled } from "../platform/flags";
import { UpdateBadge } from "../platform/listeners/update-badge";
import BrowserPopoutWindowService from "../platform/popup/browser-popout-window.service";
import { BrowserStateService as StateServiceAbstraction } from "../platform/services/abstractions/browser-state.service";
import { BrowserConfigService } from "../platform/services/browser-config.service";
import { BrowserCryptoService } from "../platform/services/browser-crypto.service";
@ -145,7 +144,6 @@ import BrowserPlatformUtilsService from "../platform/services/browser-platform-u
import { BrowserStateService } from "../platform/services/browser-state.service";
import { KeyGenerationService } from "../platform/services/key-generation.service";
import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service";
import { PopupUtilsService } from "../popup/services/popup-utils.service";
import { BrowserSendService } from "../services/browser-send.service";
import { BrowserSettingsService } from "../services/browser-settings.service";
import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service";
@ -225,8 +223,6 @@ export default class MainBackground {
devicesService: DevicesServiceAbstraction;
deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction;
authRequestCryptoService: AuthRequestCryptoServiceAbstraction;
popupUtilsService: PopupUtilsService;
browserPopoutWindowService: BrowserPopoutWindowService;
accountService: AccountServiceAbstraction;
// Passed to the popup for Safari to workaround issues with theming, downloading, etc.
@ -583,12 +579,7 @@ export default class MainBackground {
this.messagingService
);
this.browserPopoutWindowService = new BrowserPopoutWindowService();
this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(
this.browserPopoutWindowService,
this.authService
);
this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService);
this.fido2AuthenticatorService = new Fido2AuthenticatorService(
this.cipherService,
this.fido2UserInterfaceService,
@ -634,8 +625,7 @@ export default class MainBackground {
this.environmentService,
this.messagingService,
this.logService,
this.configService,
this.browserPopoutWindowService
this.configService
);
this.nativeMessagingBackground = new NativeMessagingBackground(
this.cryptoService,

View File

@ -8,9 +8,13 @@ import { SystemService } from "@bitwarden/common/platform/abstractions/system.se
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import {
closeUnlockPopout,
openSsoAuthResultPopout,
openTwoFactorAuthPopout,
} from "../auth/popup/utils/auth-popout-window";
import { AutofillService } from "../autofill/services/abstractions/autofill.service";
import { BrowserApi } from "../platform/browser/browser-api";
import { BrowserPopoutWindowService } from "../platform/popup/abstractions/browser-popout-window.service";
import { BrowserStateService } from "../platform/services/abstractions/browser-state.service";
import { BrowserEnvironmentService } from "../platform/services/browser-environment.service";
import BrowserPlatformUtilsService from "../platform/services/browser-platform-utils.service";
@ -37,8 +41,7 @@ export default class RuntimeBackground {
private environmentService: BrowserEnvironmentService,
private messagingService: MessagingService,
private logService: LogService,
private configService: ConfigServiceAbstraction,
private browserPopoutWindowService: BrowserPopoutWindowService
private configService: ConfigServiceAbstraction
) {
// onInstalled listener must be wired up before anything else, so we do it in the ctor
chrome.runtime.onInstalled.addListener((details: any) => {
@ -82,8 +85,6 @@ export default class RuntimeBackground {
}
async processMessage(msg: any, sender: chrome.runtime.MessageSender) {
const cipherId = msg.data?.cipherId;
switch (msg.command) {
case "loggedIn":
case "unlocked": {
@ -91,7 +92,7 @@ export default class RuntimeBackground {
if (this.lockedVaultPendingNotifications?.length > 0) {
item = this.lockedVaultPendingNotifications.pop();
await this.browserPopoutWindowService.closeUnlockPrompt();
await closeUnlockPopout();
}
await this.main.refreshBadge();
@ -129,49 +130,6 @@ export default class RuntimeBackground {
case "openPopup":
await this.main.openPopup();
break;
case "promptForLogin":
case "bgReopenPromptForLogin":
await this.browserPopoutWindowService.openUnlockPrompt(sender.tab?.windowId);
break;
case "passwordReprompt":
if (cipherId) {
await this.browserPopoutWindowService.openPasswordRepromptPrompt(sender.tab?.windowId, {
cipherId: cipherId,
senderTabId: sender.tab.id,
action: msg.data?.action,
});
}
break;
case "openAddEditCipher": {
const isNewCipher = !cipherId;
const cipherType = msg.data?.cipherType;
const senderTab = sender.tab;
if (!senderTab) {
break;
}
if (isNewCipher) {
await this.browserPopoutWindowService.openCipherCreation(senderTab.windowId, {
cipherType,
senderTabId: senderTab.id,
senderTabURI: senderTab.url,
});
} else {
await this.browserPopoutWindowService.openCipherEdit(senderTab.windowId, {
cipherId,
senderTabId: senderTab.id,
senderTabURI: senderTab.url,
});
}
break;
}
case "closeTab":
setTimeout(() => {
BrowserApi.closeBitwardenExtensionTab();
}, msg.delay ?? 0);
break;
case "triggerAutofillScriptInjection":
await this.autofillService.injectAutofillScripts(
sender,
@ -266,12 +224,7 @@ export default class RuntimeBackground {
});
} else {
try {
BrowserApi.createNewTab(
"popup/index.html?uilocation=popout#/sso?code=" +
encodeURIComponent(msg.code) +
"&state=" +
encodeURIComponent(msg.state)
);
await openSsoAuthResultPopout(msg);
} catch {
this.logService.error("Unable to open sso popout tab");
}
@ -285,10 +238,7 @@ export default class RuntimeBackground {
return;
}
const params =
`webAuthnResponse=${encodeURIComponent(msg.data)};` +
`remember=${encodeURIComponent(msg.remember)}`;
BrowserApi.openBitwardenExtensionTab(`popup/index.html#/2fa;${params}`, false);
await openTwoFactorAuthPopout(msg);
break;
}
case "reloadPopup":

View File

@ -9,6 +9,89 @@ describe("BrowserApi", () => {
jest.clearAllMocks();
});
describe("getWindow", () => {
it("will get the current window if a window id is not provided", () => {
BrowserApi.getWindow();
expect(chrome.windows.getCurrent).toHaveBeenCalledWith({ populate: true }, expect.anything());
});
it("will get the window with the provided id if one is provided", () => {
const windowId = 1;
BrowserApi.getWindow(windowId);
expect(chrome.windows.get).toHaveBeenCalledWith(
windowId,
{ populate: true },
expect.anything()
);
});
});
describe("getCurrentWindow", () => {
it("will get the current window", () => {
BrowserApi.getCurrentWindow();
expect(chrome.windows.getCurrent).toHaveBeenCalledWith({ populate: true }, expect.anything());
});
});
describe("getWindowById", () => {
it("will get the window associated with the passed window id", () => {
const windowId = 1;
BrowserApi.getWindowById(windowId);
expect(chrome.windows.get).toHaveBeenCalledWith(
windowId,
{ populate: true },
expect.anything()
);
});
});
describe("removeWindow", () => {
it("removes the window based on the passed window id", () => {
const windowId = 10;
BrowserApi.removeWindow(windowId);
expect(chrome.windows.remove).toHaveBeenCalledWith(windowId, expect.anything());
});
});
describe("updateWindowProperties", () => {
it("will update the window with the provided window options", () => {
const windowId = 1;
const windowOptions: chrome.windows.UpdateInfo = {
focused: true,
};
BrowserApi.updateWindowProperties(windowId, windowOptions);
expect(chrome.windows.update).toHaveBeenCalledWith(
windowId,
windowOptions,
expect.anything()
);
});
});
describe("focusWindow", () => {
it("will focus the window with the provided window id", () => {
const windowId = 1;
BrowserApi.focusWindow(windowId);
expect(chrome.windows.update).toHaveBeenCalledWith(
windowId,
{ focused: true },
expect.anything()
);
});
});
describe("executeScriptInTab", () => {
it("calls to the extension api to execute a script within the give tabId", async () => {
const tabId = 1;

View File

@ -19,14 +19,33 @@ export class BrowserApi {
return chrome.runtime.getManifest().manifest_version;
}
static getWindow(windowId?: number): Promise<chrome.windows.Window> | void {
/**
* Gets the current window or the window with the given id.
*
* @param windowId - The id of the window to get. If not provided, the current window is returned.
*/
static async getWindow(windowId?: number): Promise<chrome.windows.Window> {
if (!windowId) {
return;
return BrowserApi.getCurrentWindow();
}
return new Promise((resolve) =>
chrome.windows.get(windowId, { populate: true }, (window) => resolve(window))
);
return await BrowserApi.getWindowById(windowId);
}
/**
* Gets the currently active browser window.
*/
static async getCurrentWindow(): Promise<chrome.windows.Window> {
return new Promise((resolve) => chrome.windows.getCurrent({ populate: true }, resolve));
}
/**
* Gets the window with the given id.
*
* @param windowId - The id of the window to get.
*/
static async getWindowById(windowId: number): Promise<chrome.windows.Window> {
return new Promise((resolve) => chrome.windows.get(windowId, { populate: true }, resolve));
}
static async createWindow(options: chrome.windows.CreateData): Promise<chrome.windows.Window> {
@ -37,8 +56,39 @@ export class BrowserApi {
);
}
static async removeWindow(windowId: number) {
await chrome.windows.remove(windowId);
/**
* Removes the window with the given id.
*
* @param windowId - The id of the window to remove.
*/
static async removeWindow(windowId: number): Promise<void> {
return new Promise((resolve) => chrome.windows.remove(windowId, () => resolve()));
}
/**
* Updates the properties of the window with the given id.
*
* @param windowId - The id of the window to update.
* @param options - The window properties to update.
*/
static async updateWindowProperties(
windowId: number,
options: chrome.windows.UpdateInfo
): Promise<void> {
return new Promise((resolve) =>
chrome.windows.update(windowId, options, () => {
resolve();
})
);
}
/**
* Focuses the window with the given id.
*
* @param windowId - The id of the window to focus.
*/
static async focusWindow(windowId: number) {
await BrowserApi.updateWindowProperties(windowId, { focused: true });
}
static async getTabFromCurrentWindowId(): Promise<chrome.tabs.Tab> | null {
@ -138,10 +188,6 @@ export class BrowserApi {
chrome.tabs.sendMessage<TabMessage, T>(tabId, message, options, responseCallback);
}
static async removeTab(tabId: number) {
await chrome.tabs.remove(tabId);
}
static async getPrivateModeWindows(): Promise<browser.windows.Window[]> {
return (await browser.windows.getAll()).filter((win) => win.incognito);
}
@ -172,47 +218,6 @@ export class BrowserApi {
);
}
static async focusWindow(windowId: number) {
await chrome.windows.update(windowId, { focused: true });
}
static async openBitwardenExtensionTab(relativeUrl: string, active = true) {
let url = relativeUrl;
if (!relativeUrl.includes("uilocation=tab")) {
const fullUrl = chrome.extension.getURL(relativeUrl);
const parsedUrl = new URL(fullUrl);
parsedUrl.searchParams.set("uilocation", "tab");
url = parsedUrl.toString();
}
const createdTab = await this.createNewTab(url, active);
this.focusWindow(createdTab.windowId);
}
static async closeBitwardenExtensionTab() {
const tabs = await BrowserApi.tabsQuery({
active: true,
title: "Bitwarden",
windowType: "normal",
currentWindow: true,
});
if (tabs.length === 0) {
return;
}
const tabToClose = tabs[tabs.length - 1];
BrowserApi.removeTab(tabToClose.id);
}
static createNewWindow(
url: string,
focused = true,
type: chrome.windows.createTypeEnum = "normal"
) {
chrome.windows.create({ url, focused, type });
}
// Keep track of all the events registered in a Safari popup so we can remove
// them when the popup gets unloaded, otherwise we cause a memory leak
private static registeredMessageListeners: any[] = [];

View File

@ -1,42 +0,0 @@
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
interface BrowserPopoutWindowService {
openUnlockPrompt(senderWindowId: number): Promise<void>;
closeUnlockPrompt(): Promise<void>;
openPasswordRepromptPrompt(
senderWindowId: number,
promptData: {
action: string;
cipherId: string;
senderTabId: number;
}
): Promise<void>;
openCipherCreation(
senderWindowId: number,
promptData: {
cipherType?: CipherType;
senderTabId: number;
senderTabURI: string;
}
): Promise<void>;
openCipherEdit(
senderWindowId: number,
promptData: {
cipherId: string;
senderTabId: number;
senderTabURI: string;
}
): Promise<void>;
closePasswordRepromptPrompt(): Promise<void>;
openFido2Popout(
senderWindow: chrome.tabs.Tab,
promptData: {
sessionId: string;
senderTabId: number;
fallbackSupported: boolean;
}
): Promise<number>;
closeFido2Popout(): Promise<void>;
}
export { BrowserPopoutWindowService };

View File

@ -0,0 +1,6 @@
type ScrollOptions = {
delay: number;
containerSelector: string;
};
export { ScrollOptions };

View File

@ -1,174 +0,0 @@
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { BrowserApi } from "../browser/browser-api";
import { BrowserPopoutWindowService as BrowserPopupWindowServiceInterface } from "./abstractions/browser-popout-window.service";
class BrowserPopoutWindowService implements BrowserPopupWindowServiceInterface {
private singleActionPopoutTabIds: Record<string, number> = {};
private defaultPopoutWindowOptions: chrome.windows.CreateData = {
type: "popup",
focused: true,
width: 380,
height: 630,
};
async openUnlockPrompt(senderWindowId: number) {
await this.openSingleActionPopout(
senderWindowId,
"popup/index.html?uilocation=popout",
"unlockPrompt"
);
}
async closeUnlockPrompt() {
await this.closeSingleActionPopout("unlockPrompt");
}
async openPasswordRepromptPrompt(
senderWindowId: number,
{
cipherId,
senderTabId,
action,
}: {
cipherId: string;
senderTabId: number;
action: string;
}
) {
const promptWindowPath =
"popup/index.html#/view-cipher" +
"?uilocation=popout" +
`&cipherId=${cipherId}` +
`&senderTabId=${senderTabId}` +
`&action=${action}`;
await this.openSingleActionPopout(senderWindowId, promptWindowPath, "passwordReprompt");
}
async openCipherCreation(
senderWindowId: number,
{
cipherType = CipherType.Login,
senderTabId,
senderTabURI,
}: {
cipherType?: CipherType;
senderTabId: number;
senderTabURI: string;
}
) {
const promptWindowPath =
"popup/index.html#/edit-cipher" +
"?uilocation=popout" +
`&type=${cipherType}` +
`&senderTabId=${senderTabId}` +
`&uri=${senderTabURI}`;
await this.openSingleActionPopout(senderWindowId, promptWindowPath, "cipherCreation");
}
async openCipherEdit(
senderWindowId: number,
{
cipherId,
senderTabId,
senderTabURI,
}: {
cipherId: string;
senderTabId: number;
senderTabURI: string;
}
) {
const promptWindowPath =
"popup/index.html#/edit-cipher" +
"?uilocation=popout" +
`&cipherId=${cipherId}` +
`&senderTabId=${senderTabId}` +
`&uri=${senderTabURI}`;
await this.openSingleActionPopout(senderWindowId, promptWindowPath, "cipherEdit");
}
async closePasswordRepromptPrompt() {
await this.closeSingleActionPopout("passwordReprompt");
}
async openFido2Popout(
senderWindow: chrome.tabs.Tab,
{
sessionId,
senderTabId,
fallbackSupported,
}: {
sessionId: string;
senderTabId: number;
fallbackSupported: boolean;
}
): Promise<number> {
await this.closeFido2Popout();
const promptWindowPath =
"popup/index.html#/fido2" +
"?uilocation=popout" +
`&sessionId=${sessionId}` +
`&fallbackSupported=${fallbackSupported}` +
`&senderTabId=${senderTabId}` +
`&senderUrl=${encodeURIComponent(senderWindow.url)}`;
return await this.openSingleActionPopout(
senderWindow.windowId,
promptWindowPath,
"fido2Popout",
{
height: 450,
}
);
}
async closeFido2Popout(): Promise<void> {
await this.closeSingleActionPopout("fido2Popout");
}
private async openSingleActionPopout(
senderWindowId: number,
popupWindowURL: string,
singleActionPopoutKey: string,
options: chrome.windows.CreateData = {}
): Promise<number> {
const senderWindow = senderWindowId && (await BrowserApi.getWindow(senderWindowId));
const url = chrome.extension.getURL(popupWindowURL);
const offsetRight = 15;
const offsetTop = 90;
/// Use overrides in `options` if provided, otherwise use default
const popupWidth = options?.width || this.defaultPopoutWindowOptions.width;
const windowOptions = senderWindow
? {
...this.defaultPopoutWindowOptions,
left: senderWindow.left + senderWindow.width - popupWidth - offsetRight,
top: senderWindow.top + offsetTop,
...options,
url,
}
: { ...this.defaultPopoutWindowOptions, url, ...options };
const popupWindow = await BrowserApi.createWindow(windowOptions);
await this.closeSingleActionPopout(singleActionPopoutKey);
this.singleActionPopoutTabIds[singleActionPopoutKey] = popupWindow?.tabs[0].id;
return popupWindow.id;
}
private async closeSingleActionPopout(popoutKey: string) {
const tabId = this.singleActionPopoutTabIds[popoutKey];
if (tabId) {
await BrowserApi.removeTab(tabId);
}
this.singleActionPopoutTabIds[popoutKey] = null;
}
}
export default BrowserPopoutWindowService;

View File

@ -0,0 +1,448 @@
import { createChromeTabMock } from "../../autofill/jest/autofill-mocks";
import { BrowserApi } from "../browser/browser-api";
import BrowserPopupUtils from "./browser-popup-utils";
describe("BrowserPopupUtils", () => {
afterEach(() => {
jest.clearAllMocks();
});
describe("inSidebar", () => {
it("should return true if the window contains the sidebar query param", () => {
const win = { location: { href: "https://jest-testing.com?uilocation=sidebar" } } as Window;
expect(BrowserPopupUtils.inSidebar(win)).toBe(true);
});
it("should return false if the window does not contain the sidebar query param", () => {
const win = { location: { href: "https://jest-testing.com?uilocation=popout" } } as Window;
expect(BrowserPopupUtils.inSidebar(win)).toBe(false);
});
});
describe("inPopout", () => {
it("should return true if the window contains the popout query param", () => {
const win = { location: { href: "https://jest-testing.com?uilocation=popout" } } as Window;
expect(BrowserPopupUtils.inPopout(win)).toBe(true);
});
it("should return false if the window does not contain the popout query param", () => {
const win = { location: { href: "https://jest-testing.com?uilocation=sidebar" } } as Window;
expect(BrowserPopupUtils.inPopout(win)).toBe(false);
});
});
describe("inSingleActionPopout", () => {
it("should return true if the window contains the singleActionPopout query param", () => {
const win = {
location: { href: "https://jest-testing.com?singleActionPopout=123" },
} as Window;
expect(BrowserPopupUtils.inSingleActionPopout(win, "123")).toBe(true);
});
it("should return false if the window does not contain the singleActionPopout query param", () => {
const win = { location: { href: "https://jest-testing.com" } } as Window;
expect(BrowserPopupUtils.inSingleActionPopout(win, "123")).toBe(false);
});
});
describe("inPopup", () => {
it("should return true if the window does not contain the popup query param", () => {
const win = { location: { href: "https://jest-testing.com" } } as Window;
expect(BrowserPopupUtils.inPopup(win)).toBe(true);
});
it("should return true if the window contains the popup query param", () => {
const win = { location: { href: "https://jest-testing.com?uilocation=popup" } } as Window;
expect(BrowserPopupUtils.inPopup(win)).toBe(true);
});
it("should return false if the window does not contain the popup query param", () => {
const win = { location: { href: "https://jest-testing.com?uilocation=sidebar" } } as Window;
expect(BrowserPopupUtils.inPopup(win)).toBe(false);
});
});
describe("getContentScrollY", () => {
it("should return the scroll position of the popup", () => {
const win = {
document: { getElementsByTagName: () => [{ scrollTop: 100 }] },
} as unknown as Window;
expect(BrowserPopupUtils.getContentScrollY(win)).toBe(100);
});
});
describe("setContentScrollY", () => {
it("should set the scroll position of the popup", async () => {
window.document.body.innerHTML = `
<main>
<div></div>
</main>
`;
await BrowserPopupUtils.setContentScrollY(window, 200);
expect(window.document.getElementsByTagName("main")[0].scrollTop).toBe(200);
});
it("should not set the scroll position of the popup if the scrollY is null", async () => {
window.document.body.innerHTML = `
<main>
<div></div>
</main>
`;
await BrowserPopupUtils.setContentScrollY(window, null);
expect(window.document.getElementsByTagName("main")[0].scrollTop).toBe(0);
});
it("will set the scroll position of the popup after the provided delay", async () => {
jest.useRealTimers();
window.document.body.innerHTML = `
<div class="scrolling-container">
<div></div>
</div>
`;
await BrowserPopupUtils.setContentScrollY(window, 300, {
delay: 200,
containerSelector: ".scrolling-container",
});
expect(window.document.querySelector(".scrolling-container").scrollTop).toBe(300);
});
});
describe("backgroundInitializationRequired", () => {
it("return true if the background page is a null value", () => {
jest.spyOn(BrowserApi, "getBackgroundPage").mockReturnValue(null);
expect(BrowserPopupUtils.backgroundInitializationRequired()).toBe(true);
});
it("return false if the background page is not a null value", () => {
jest.spyOn(BrowserApi, "getBackgroundPage").mockReturnValue({});
expect(BrowserPopupUtils.backgroundInitializationRequired()).toBe(false);
});
});
describe("inPrivateMode", () => {
it("returns false if the background requires initialization", () => {
jest.spyOn(BrowserPopupUtils, "backgroundInitializationRequired").mockReturnValue(false);
expect(BrowserPopupUtils.inPrivateMode()).toBe(false);
});
it("returns false if the manifest version is for version 3", () => {
jest.spyOn(BrowserPopupUtils, "backgroundInitializationRequired").mockReturnValue(true);
jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(3);
expect(BrowserPopupUtils.inPrivateMode()).toBe(false);
});
it("returns true if the background does not require initalization and the manifest version is version 2", () => {
jest.spyOn(BrowserPopupUtils, "backgroundInitializationRequired").mockReturnValue(true);
jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(2);
expect(BrowserPopupUtils.inPrivateMode()).toBe(true);
});
});
describe("openPopout", () => {
beforeEach(() => {
jest.spyOn(BrowserApi, "getWindow").mockResolvedValueOnce({
id: 1,
left: 100,
top: 100,
focused: false,
alwaysOnTop: false,
incognito: false,
width: 380,
});
jest.spyOn(BrowserApi, "createWindow").mockImplementation();
});
it("creates a window with the default window options", async () => {
const url = "popup/index.html";
jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false);
await BrowserPopupUtils.openPopout(url);
expect(BrowserApi.createWindow).toHaveBeenCalledWith({
type: "popup",
focused: true,
width: 380,
height: 630,
left: 85,
top: 190,
url: `chrome-extension://id/${url}?uilocation=popout`,
});
});
it("replaces any existing `uilocation=` query params within the passed extension url path to state the the uilocaiton is a popup", async () => {
const url = "popup/index.html#/tabs/vault?uilocation=sidebar";
jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false);
await BrowserPopupUtils.openPopout(url);
expect(BrowserApi.createWindow).toHaveBeenCalledWith({
type: "popup",
focused: true,
width: 380,
height: 630,
left: 85,
top: 190,
url: `chrome-extension://id/popup/index.html#/tabs/vault?uilocation=popout`,
});
});
it("appends the uilocation to the search params if an existing param is passed with the extension url path", async () => {
const url = "popup/index.html#/tabs/vault?existingParam=123";
jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false);
await BrowserPopupUtils.openPopout(url);
expect(BrowserApi.createWindow).toHaveBeenCalledWith({
type: "popup",
focused: true,
width: 380,
height: 630,
left: 85,
top: 190,
url: `chrome-extension://id/${url}&uilocation=popout`,
});
});
it("creates a single action popout window", async () => {
const url = "popup/index.html";
jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false);
await BrowserPopupUtils.openPopout(url, { singleActionKey: "123" });
expect(BrowserApi.createWindow).toHaveBeenCalledWith({
type: "popup",
focused: true,
width: 380,
height: 630,
left: 85,
top: 190,
url: `chrome-extension://id/${url}?uilocation=popout&singleActionPopout=123`,
});
});
it("does not create a single action popout window if it is already open", async () => {
const url = "popup/index.html";
jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(true);
await BrowserPopupUtils.openPopout(url, { singleActionKey: "123" });
expect(BrowserApi.createWindow).not.toHaveBeenCalled();
});
it("creates a window with the provided window options", async () => {
const url = "popup/index.html";
jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false);
await BrowserPopupUtils.openPopout(url, {
windowOptions: {
type: "popup",
focused: false,
width: 100,
height: 100,
},
});
expect(BrowserApi.createWindow).toHaveBeenCalledWith({
type: "popup",
focused: false,
width: 100,
height: 100,
left: 85,
top: 190,
url: `chrome-extension://id/${url}?uilocation=popout`,
});
});
it("opens a single action window if the forceCloseExistingWindows param is true", async () => {
const url = "popup/index.html";
jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(true);
await BrowserPopupUtils.openPopout(url, {
singleActionKey: "123",
forceCloseExistingWindows: true,
});
expect(BrowserApi.createWindow).toHaveBeenCalledWith({
type: "popup",
focused: true,
width: 380,
height: 630,
left: 85,
top: 190,
url: `chrome-extension://id/${url}?uilocation=popout&singleActionPopout=123`,
});
});
});
describe("openCurrentPagePopout", () => {
it("opens a popout window for the current page", async () => {
const win = { location: { href: "https://example.com#/tabs/current" } } as Window;
jest.spyOn(BrowserPopupUtils, "openPopout").mockImplementation();
jest.spyOn(BrowserApi, "closePopup").mockImplementation();
jest.spyOn(BrowserPopupUtils, "inPopup").mockReturnValue(false);
await BrowserPopupUtils.openCurrentPagePopout(win);
expect(BrowserPopupUtils.openPopout).toHaveBeenCalledWith("/#/tabs/vault");
expect(BrowserApi.closePopup).not.toHaveBeenCalled();
});
it("opens a popout window for the specified URL", async () => {
const win = {} as Window;
jest.spyOn(BrowserPopupUtils, "openPopout").mockImplementation();
jest.spyOn(BrowserPopupUtils, "inPopup").mockReturnValue(false);
await BrowserPopupUtils.openCurrentPagePopout(win, "https://example.com#/settings");
expect(BrowserPopupUtils.openPopout).toHaveBeenCalledWith("/#/settings");
});
it("opens a popout window for the current page and closes the popup window", async () => {
const win = { location: { href: "https://example.com/#/tabs/vault" } } as Window;
jest.spyOn(BrowserPopupUtils, "openPopout").mockImplementation();
jest.spyOn(BrowserApi, "closePopup").mockImplementation();
jest.spyOn(BrowserPopupUtils, "inPopup").mockReturnValue(true);
await BrowserPopupUtils.openCurrentPagePopout(win);
expect(BrowserPopupUtils.openPopout).toHaveBeenCalledWith("/#/tabs/vault");
expect(BrowserApi.closePopup).toHaveBeenCalledWith(win);
});
});
describe("closeSingleActionPopout", () => {
it("closes any existing single action popouts", async () => {
const url = "popup/index.html";
jest.useFakeTimers();
jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValueOnce([
createChromeTabMock({
id: 10,
url: `chrome-extension://id/${url}?uilocation=popout&singleActionPopout=123`,
windowId: 11,
}),
createChromeTabMock({
id: 20,
url: `chrome-extension://id/${url}?uilocation=popout&singleActionPopout=123`,
windowId: 21,
}),
createChromeTabMock({
id: 30,
url: `chrome-extension://id/${url}?uilocation=popout&singleActionPopout=456`,
windowId: 31,
}),
]);
jest.spyOn(BrowserApi, "removeWindow").mockResolvedValueOnce();
await BrowserPopupUtils.closeSingleActionPopout("123");
jest.runOnlyPendingTimers();
expect(BrowserApi.removeWindow).toHaveBeenNthCalledWith(1, 11);
expect(BrowserApi.removeWindow).toHaveBeenNthCalledWith(2, 21);
expect(BrowserApi.removeWindow).not.toHaveBeenCalledWith(31);
});
});
describe("isSingleActionPopoutOpen", () => {
const windowOptions = {
id: 1,
left: 100,
top: 100,
focused: false,
alwaysOnTop: false,
incognito: false,
width: 500,
height: 800,
};
beforeEach(() => {
jest.spyOn(BrowserApi, "updateWindowProperties").mockImplementation();
jest.spyOn(BrowserApi, "removeWindow").mockImplementation();
});
it("returns false if the popoutKey is not provided", async () => {
await expect(BrowserPopupUtils["isSingleActionPopoutOpen"](undefined, {})).resolves.toBe(
false
);
});
it("returns false if no popout windows are found", async () => {
jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValueOnce([]);
await expect(
BrowserPopupUtils["isSingleActionPopoutOpen"]("123", windowOptions)
).resolves.toBe(false);
});
it("returns false if no single action popout is found relating to the popoutKey", async () => {
jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValueOnce([
createChromeTabMock({
id: 10,
url: `chrome-extension://id/popup/index.html?uilocation=popout&singleActionPopout=123`,
}),
createChromeTabMock({
id: 20,
url: `chrome-extension://id/popup/index.html?uilocation=popout&singleActionPopout=123`,
}),
createChromeTabMock({
id: 30,
url: `chrome-extension://id/popup/index.html?uilocation=popout&singleActionPopout=456`,
}),
]);
await expect(
BrowserPopupUtils["isSingleActionPopoutOpen"]("789", windowOptions)
).resolves.toBe(false);
});
it("returns true if a single action popout is found relating to the popoutKey", async () => {
jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValueOnce([
createChromeTabMock({
id: 10,
url: `chrome-extension://id/popup/index.html?uilocation=popout&singleActionPopout=123`,
}),
createChromeTabMock({
id: 20,
url: `chrome-extension://id/popup/index.html?uilocation=popout&singleActionPopout=123`,
}),
createChromeTabMock({
id: 30,
url: `chrome-extension://id/popup/index.html?uilocation=popout&singleActionPopout=456`,
}),
]);
await expect(
BrowserPopupUtils["isSingleActionPopoutOpen"]("123", windowOptions)
).resolves.toBe(true);
expect(BrowserApi.updateWindowProperties).toHaveBeenCalledWith(2, {
focused: true,
width: 500,
height: 800,
top: 100,
left: 100,
});
expect(BrowserApi.removeWindow).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -0,0 +1,275 @@
import { BrowserApi } from "../browser/browser-api";
import { ScrollOptions } from "./abstractions/browser-popup-utils.abstractions";
class BrowserPopupUtils {
/**
* Identifies if the popup is within the sidebar.
*
* @param win - The passed window object.
*/
static inSidebar(win: Window): boolean {
return BrowserPopupUtils.urlContainsSearchParams(win, "uilocation", "sidebar");
}
/**
* Identifies if the popup is within the popout.
*
* @param win - The passed window object.
*/
static inPopout(win: Window): boolean {
return BrowserPopupUtils.urlContainsSearchParams(win, "uilocation", "popout");
}
/**
* Identifies if the popup is within the single action popout.
*
* @param win - The passed window object.
* @param popoutKey - The single action popout key used to identify the popout.
*/
static inSingleActionPopout(win: Window, popoutKey: string): boolean {
return BrowserPopupUtils.urlContainsSearchParams(win, "singleActionPopout", popoutKey);
}
/**
* Identifies if the popup is within the popup.
*
* @param win - The passed window object.
*/
static inPopup(win: Window): boolean {
return (
win.location.href.indexOf("uilocation=") === -1 ||
win.location.href.indexOf("uilocation=popup") > -1
);
}
/**
* Gets the scroll position of the popup.
*
* @param win - The passed window object.
* @param scrollingContainer - Element tag name of the scrolling container.
*/
static getContentScrollY(win: Window, scrollingContainer = "main"): number {
const content = win.document.getElementsByTagName(scrollingContainer)[0];
return content.scrollTop;
}
/**
* Sets the scroll position of the popup.
*
* @param win - The passed window object.
* @param scrollYAmount - The amount to scroll the popup.
* @param options - Allows for setting the delay in ms to wait before scrolling the popup and the scrolling container tag name.
*/
static async setContentScrollY(
win: Window,
scrollYAmount: number | undefined,
options: ScrollOptions = {
delay: 0,
containerSelector: "main",
}
) {
const { delay, containerSelector } = options;
return new Promise<void>((resolve) =>
win.setTimeout(() => {
const container = win.document.querySelector(containerSelector);
if (!isNaN(scrollYAmount) && container) {
container.scrollTop = scrollYAmount;
}
resolve();
}, delay)
);
}
/**
* Identifies if the background page needs to be initialized.
*/
static backgroundInitializationRequired() {
return BrowserApi.getBackgroundPage() === null;
}
/**
* Identifies if the popup is loading in private mode.
*/
static inPrivateMode() {
return BrowserPopupUtils.backgroundInitializationRequired() && BrowserApi.manifestVersion !== 3;
}
/**
* Opens a popout window of any extension page. If the popout window is already open, it will be focused.
*
* @param extensionUrlPath - A relative path to the extension page. Example: "popup/index.html#/tabs/vault"
* @param options - Options for the popout window that overrides the default options.
*/
static async openPopout(
extensionUrlPath: string,
options: {
senderWindowId?: number;
singleActionKey?: string;
forceCloseExistingWindows?: boolean;
windowOptions?: Partial<chrome.windows.CreateData>;
} = {}
) {
const { senderWindowId, singleActionKey, forceCloseExistingWindows, windowOptions } = options;
const defaultPopoutWindowOptions: chrome.windows.CreateData = {
type: "popup",
focused: true,
width: 380,
height: 630,
};
const offsetRight = 15;
const offsetTop = 90;
const popupWidth = defaultPopoutWindowOptions.width;
const senderWindow = await BrowserApi.getWindow(senderWindowId);
const popoutWindowOptions = {
left: senderWindow.left + senderWindow.width - popupWidth - offsetRight,
top: senderWindow.top + offsetTop,
...defaultPopoutWindowOptions,
...windowOptions,
url: chrome.runtime.getURL(
BrowserPopupUtils.buildPopoutUrlPath(extensionUrlPath, singleActionKey)
),
};
if (
(await BrowserPopupUtils.isSingleActionPopoutOpen(
singleActionKey,
popoutWindowOptions,
forceCloseExistingWindows
)) &&
!forceCloseExistingWindows
) {
return;
}
return await BrowserApi.createWindow(popoutWindowOptions);
}
/**
* Closes the single action popout window.
*
* @param popoutKey - The single action popout key used to identify the popout.
* @param delayClose - The amount of time to wait before closing the popout. Defaults to 0.
*/
static async closeSingleActionPopout(popoutKey: string, delayClose = 0): Promise<void> {
const extensionUrl = chrome.runtime.getURL("popup/index.html");
const tabs = await BrowserApi.tabsQuery({ url: `${extensionUrl}*` });
for (const tab of tabs) {
if (!tab.url.includes(`singleActionPopout=${popoutKey}`)) {
continue;
}
setTimeout(() => BrowserApi.removeWindow(tab.windowId), delayClose);
}
}
/**
* Opens a popout window for the current page.
* If the current page is set for the current tab, then the
* popout window will be set for the vault items listing tab.
*
* @param win - The passed window object.
* @param href - The href to open in the popout window.
*/
static async openCurrentPagePopout(win: Window, href: string = null) {
const popoutUrl = href || win.location.href;
const parsedUrl = new URL(popoutUrl);
let hashRoute = parsedUrl.hash;
if (hashRoute.startsWith("#/tabs/current")) {
hashRoute = "#/tabs/vault";
}
await BrowserPopupUtils.openPopout(`${parsedUrl.pathname}${hashRoute}`);
if (BrowserPopupUtils.inPopup(win)) {
BrowserApi.closePopup(win);
}
}
/**
* Identifies if a single action window is open based on the passed popoutKey.
* Will focus the existing window, and close any other windows that might exist
* with the same popout key.
*
* @param popoutKey - The single action popout key used to identify the popout.
* @param windowInfo - The window info to use to update the existing window.
* @param forceCloseExistingWindows - Identifies if the existing windows should be closed.
*/
private static async isSingleActionPopoutOpen(
popoutKey: string | undefined,
windowInfo: chrome.windows.CreateData,
forceCloseExistingWindows = false
) {
if (!popoutKey) {
return false;
}
const extensionUrl = chrome.runtime.getURL("popup/index.html");
const popoutTabs = (await BrowserApi.tabsQuery({ url: `${extensionUrl}*` })).filter((tab) =>
tab.url.includes(`singleActionPopout=${popoutKey}`)
);
if (popoutTabs.length === 0) {
return false;
}
if (!forceCloseExistingWindows) {
// Update first, remove it from list
const tab = popoutTabs.shift();
await BrowserApi.updateWindowProperties(tab.windowId, {
focused: true,
width: windowInfo.width,
height: windowInfo.height,
top: windowInfo.top,
left: windowInfo.left,
});
}
popoutTabs.forEach((tab) => BrowserApi.removeWindow(tab.windowId));
return true;
}
/**
* Identifies if the url contains the specified search param and value.
*
* @param win - The passed window object.
* @param searchParam - The search param to identify.
* @param searchValue - The search value to identify.
*/
private static urlContainsSearchParams(
win: Window,
searchParam: string,
searchValue: string
): boolean {
return win.location.href.indexOf(`${searchParam}=${searchValue}`) > -1;
}
/**
* Builds the popout url path. Ensures that the uilocation param is set to
* `popout` and that the singleActionPopout param is set to the passed singleActionKey.
*
* @param extensionUrlPath - A relative path to the extension page. Example: "popup/index.html#/tabs/vault"
* @param singleActionKey - The single action popout key used to identify the popout.
*/
private static buildPopoutUrlPath(extensionUrlPath: string, singleActionKey: string) {
let formattedExtensionUrlPath = extensionUrlPath;
if (formattedExtensionUrlPath.includes("uilocation=")) {
formattedExtensionUrlPath = formattedExtensionUrlPath.replace(
/uilocation=[^&]*/g,
"uilocation=popout"
);
} else {
formattedExtensionUrlPath +=
(formattedExtensionUrlPath.includes("?") ? "&" : "?") + "uilocation=popout";
}
if (singleActionKey) {
formattedExtensionUrlPath += `&singleActionPopout=${singleActionKey}`;
}
return formattedExtensionUrlPath;
}
}
export default BrowserPopupUtils;

View File

@ -2,7 +2,7 @@ import { Component, Input, OnInit } from "@angular/core";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { PopupUtilsService } from "../services/popup-utils.service";
import BrowserPopupUtils from "../../platform/popup/browser-popup-utils";
@Component({
selector: "app-pop-out",
@ -11,16 +11,13 @@ import { PopupUtilsService } from "../services/popup-utils.service";
export class PopOutComponent implements OnInit {
@Input() show = true;
constructor(
private platformUtilsService: PlatformUtilsService,
private popupUtilsService: PopupUtilsService
) {}
constructor(private platformUtilsService: PlatformUtilsService) {}
ngOnInit() {
if (this.show) {
if (
(this.popupUtilsService.inSidebar(window) && this.platformUtilsService.isFirefox()) ||
this.popupUtilsService.inPopout(window)
(BrowserPopupUtils.inSidebar(window) && this.platformUtilsService.isFirefox()) ||
BrowserPopupUtils.inPopout(window)
) {
this.show = false;
}
@ -28,6 +25,6 @@ export class PopOutComponent implements OnInit {
}
expand() {
this.popupUtilsService.popOut(window);
BrowserPopupUtils.openCurrentPagePopout(window);
}
}

View File

@ -1,6 +1,6 @@
import { Component, OnInit } from "@angular/core";
import { PopupUtilsService } from "../services/popup-utils.service";
import BrowserPopupUtils from "../../platform/popup/browser-popup-utils";
@Component({
selector: "app-private-mode-warning",
@ -9,9 +9,7 @@ import { PopupUtilsService } from "../services/popup-utils.service";
export class PrivateModeWarningComponent implements OnInit {
showWarning = false;
constructor(private popupUtilsService: PopupUtilsService) {}
ngOnInit() {
this.showWarning = this.popupUtilsService.inPrivateMode();
this.showWarning = BrowserPopupUtils.inPrivateMode();
}
}

View File

@ -6,16 +6,14 @@ import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ConfigService } from "@bitwarden/common/platform/services/config/config.service";
import BrowserPopupUtils from "../../platform/popup/browser-popup-utils";
import { BrowserStateService as StateServiceAbstraction } from "../../platform/services/abstractions/browser-state.service";
import { PopupUtilsService } from "./popup-utils.service";
@Injectable()
export class InitService {
constructor(
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private popupUtilsService: PopupUtilsService,
private stateService: StateServiceAbstraction,
private logService: LogServiceAbstraction,
private themingService: AbstractThemingService,
@ -26,7 +24,7 @@ export class InitService {
return async () => {
await this.stateService.init();
if (!this.popupUtilsService.inPopup(window)) {
if (!BrowserPopupUtils.inPopup(window)) {
window.document.body.classList.add("body-full");
} else if (window.screen.availHeight < 600) {
window.document.body.classList.add("body-xs");
@ -43,7 +41,7 @@ export class InitService {
if (
this.platformUtilsService.isChrome() &&
navigator.platform.indexOf("Mac") > -1 &&
this.popupUtilsService.inPopup(window) &&
BrowserPopupUtils.inPopup(window) &&
(window.screenLeft < 0 ||
window.screenTop < 0 ||
window.screenLeft > window.screen.width ||

View File

@ -0,0 +1,39 @@
import { Injectable } from "@angular/core";
import { fromEvent, Subscription } from "rxjs";
@Injectable()
export class PopupCloseWarningService {
private unloadSubscription: Subscription;
/**
* Enables a pop-up warning before the user exits the window/tab, or navigates away.
* This warns the user that they may lose unsaved data if they leave the page.
* (Note: navigating within the Angular app will not trigger it because it's an SPA.)
* Make sure you call `PopupCloseWarningService.disable` when it is no longer relevant.
*/
enable() {
this.disable();
this.unloadSubscription = fromEvent(window, "beforeunload").subscribe(
(e: BeforeUnloadEvent) => {
// Recommended method but not widely supported
e.preventDefault();
// Modern browsers do not display this message, it just needs to be a non-nullish value
// Exact wording is determined by the browser
const confirmationMessage = "";
// Older methods with better support
e.returnValue = confirmationMessage;
return confirmationMessage;
}
);
}
/**
* Disables the warning enabled by PopupCloseWarningService.enable.
*/
disable() {
this.unloadSubscription?.unsubscribe();
}
}

View File

@ -1,151 +0,0 @@
import { Injectable } from "@angular/core";
import { fromEvent, Subscription } from "rxjs";
import { BrowserApi } from "../../platform/browser/browser-api";
export type Popout =
| {
type: "window";
window: chrome.windows.Window;
}
| {
type: "tab";
tab: chrome.tabs.Tab;
};
@Injectable()
export class PopupUtilsService {
private unloadSubscription: Subscription;
constructor(private privateMode: boolean = false) {}
inSidebar(win: Window): boolean {
return win.location.search !== "" && win.location.search.indexOf("uilocation=sidebar") > -1;
}
inTab(win: Window): boolean {
return win.location.search !== "" && win.location.search.indexOf("uilocation=tab") > -1;
}
inPopout(win: Window): boolean {
return win.location.search !== "" && win.location.search.indexOf("uilocation=popout") > -1;
}
inPopup(win: Window): boolean {
return (
win.location.search === "" ||
win.location.search.indexOf("uilocation=") === -1 ||
win.location.search.indexOf("uilocation=popup") > -1
);
}
inPrivateMode(): boolean {
return this.privateMode;
}
getContentScrollY(win: Window, scrollingContainer = "main"): number {
const content = win.document.getElementsByTagName(scrollingContainer)[0];
return content.scrollTop;
}
setContentScrollY(win: Window, scrollY: number, scrollingContainer = "main"): void {
if (scrollY != null) {
const content = win.document.getElementsByTagName(scrollingContainer)[0];
content.scrollTop = scrollY;
}
}
async popOut(
win: Window,
href: string = null,
options: { center?: boolean } = {}
): Promise<Popout> {
if (href === null) {
href = win.location.href;
}
if (typeof chrome !== "undefined" && chrome?.windows?.create != null) {
if (href.indexOf("?uilocation=") > -1) {
href = href
.replace("uilocation=popup", "uilocation=popout")
.replace("uilocation=tab", "uilocation=popout")
.replace("uilocation=sidebar", "uilocation=popout");
} else {
const hrefParts = href.split("#");
href =
hrefParts[0] + "?uilocation=popout" + (hrefParts.length > 0 ? "#" + hrefParts[1] : "");
}
const bodyRect = document.querySelector("body").getBoundingClientRect();
const width = Math.round(bodyRect.width ? bodyRect.width + 60 : 375);
const height = Math.round(bodyRect.height || 600);
const top = options.center ? Math.round((screen.height - height) / 2) : undefined;
const left = options.center ? Math.round((screen.width - width) / 2) : undefined;
const window = await BrowserApi.createWindow({
url: href,
type: "popup",
width,
height,
top,
left,
});
if (win && this.inPopup(win)) {
BrowserApi.closePopup(win);
}
return { type: "window", window };
} else if (chrome?.tabs?.create != null) {
href = href
.replace("uilocation=popup", "uilocation=tab")
.replace("uilocation=popout", "uilocation=tab")
.replace("uilocation=sidebar", "uilocation=tab");
const tab = await BrowserApi.createNewTab(href);
return { type: "tab", tab };
} else {
throw new Error("Cannot open tab or window");
}
}
closePopOut(popout: Popout): Promise<void> {
switch (popout.type) {
case "window":
return BrowserApi.removeWindow(popout.window.id);
case "tab":
return BrowserApi.removeTab(popout.tab.id);
}
}
/**
* Enables a pop-up warning before the user exits the window/tab, or navigates away.
* This warns the user that they may lose unsaved data if they leave the page.
* (Note: navigating within the Angular app will not trigger it because it's an SPA.)
* Make sure you call `disableTabCloseWarning` when it is no longer relevant.
*/
enableCloseTabWarning() {
this.disableCloseTabWarning();
this.unloadSubscription = fromEvent(window, "beforeunload").subscribe(
(e: BeforeUnloadEvent) => {
// Recommended method but not widely supported
e.preventDefault();
// Modern browsers do not display this message, it just needs to be a non-nullish value
// Exact wording is determined by the browser
const confirmationMessage = "";
// Older methods with better support
e.returnValue = confirmationMessage;
return confirmationMessage;
}
);
}
/**
* Disables the warning enabled by enableCloseTabWarning.
*/
disableCloseTabWarning() {
this.unloadSubscription?.unsubscribe();
}
}

View File

@ -94,6 +94,7 @@ import { AutofillService } from "../../autofill/services/abstractions/autofill.s
import MainBackground from "../../background/main.background";
import { Account } from "../../models/account";
import { BrowserApi } from "../../platform/browser/browser-api";
import BrowserPopupUtils from "../../platform/popup/browser-popup-utils";
import { BrowserStateService as StateServiceAbstraction } from "../../platform/services/abstractions/browser-state.service";
import { BrowserConfigService } from "../../platform/services/browser-config.service";
import { BrowserEnvironmentService } from "../../platform/services/browser-environment.service";
@ -110,11 +111,11 @@ import { VaultFilterService } from "../../vault/services/vault-filter.service";
import { DebounceNavigationService } from "./debounceNavigationService";
import { InitService } from "./init.service";
import { PopupCloseWarningService } from "./popup-close-warning.service";
import { PopupSearchService } from "./popup-search.service";
import { PopupUtilsService } from "./popup-utils.service";
const needsBackgroundInit = BrowserApi.getBackgroundPage() == null;
const isPrivateMode = needsBackgroundInit && BrowserApi.manifestVersion !== 3;
const needsBackgroundInit = BrowserPopupUtils.backgroundInitializationRequired();
const isPrivateMode = BrowserPopupUtils.inPrivateMode();
const mainBackground: MainBackground = needsBackgroundInit
? createLocalBgService()
: BrowserApi.getBackgroundPage().bitwardenMain;
@ -138,6 +139,7 @@ function getBgService<T>(service: keyof MainBackground) {
InitService,
DebounceNavigationService,
DialogService,
PopupCloseWarningService,
{
provide: LOCALE_ID,
useFactory: () => getBgService<I18nServiceAbstraction>("i18nService")().translationLocale,
@ -150,7 +152,6 @@ function getBgService<T>(service: keyof MainBackground) {
multi: true,
},
{ provide: BaseUnauthGuardService, useClass: UnauthGuardService },
{ provide: PopupUtilsService, useFactory: () => new PopupUtilsService(isPrivateMode) },
{
provide: MessagingService,
useFactory: () => {
@ -523,13 +524,10 @@ function getBgService<T>(service: keyof MainBackground) {
},
{
provide: FilePopoutUtilsService,
useFactory: (
platformUtilsService: PlatformUtilsService,
popupUtilsService: PopupUtilsService
) => {
return new FilePopoutUtilsService(platformUtilsService, popupUtilsService);
useFactory: (platformUtilsService: PlatformUtilsService) => {
return new FilePopoutUtilsService(platformUtilsService);
},
deps: [PlatformUtilsService, PopupUtilsService],
deps: [PlatformUtilsService],
},
],
})

View File

@ -36,7 +36,7 @@ import { DialogService } from "@bitwarden/components";
import { SetPinComponent } from "../../auth/popup/components/set-pin.component";
import { BiometricErrors, BiometricErrorTypes } from "../../models/biometricErrors";
import { BrowserApi } from "../../platform/browser/browser-api";
import { PopupUtilsService } from "../services/popup-utils.service";
import BrowserPopupUtils from "../../platform/popup/browser-popup-utils";
import { AboutComponent } from "./about.component";
import { AwaitDesktopDialogComponent } from "./await-desktop-dialog.component";
@ -95,7 +95,6 @@ export class SettingsComponent implements OnInit {
private environmentService: EnvironmentService,
private cryptoService: CryptoService,
private stateService: StateService,
private popupUtilsService: PopupUtilsService,
private modalService: ModalService,
private userVerificationService: UserVerificationService,
private dialogService: DialogService,
@ -335,7 +334,7 @@ export class SettingsComponent implements OnInit {
// eslint-disable-next-line
console.error(e);
if (this.platformUtilsService.isFirefox() && this.popupUtilsService.inSidebar(window)) {
if (this.platformUtilsService.isFirefox() && BrowserPopupUtils.inSidebar(window)) {
await this.dialogService.openSimpleDialog({
title: { key: "nativeMessaginPermissionSidebarTitle" },
content: { key: "nativeMessaginPermissionSidebarDesc" },
@ -474,7 +473,7 @@ export class SettingsComponent implements OnInit {
async import() {
await this.router.navigate(["/import"]);
if (await BrowserApi.isPopupOpen()) {
this.popupUtilsService.popOut(window);
BrowserPopupUtils.openCurrentPagePopout(window);
}
}

View File

@ -1,6 +1,6 @@
import { Component, OnInit } from "@angular/core";
import { PopupUtilsService } from "./services/popup-utils.service";
import BrowserPopupUtils from "../platform/popup/browser-popup-utils";
@Component({
selector: "app-tabs",
@ -9,9 +9,7 @@ import { PopupUtilsService } from "./services/popup-utils.service";
export class TabsComponent implements OnInit {
showCurrentTab = true;
constructor(private popupUtilsService: PopupUtilsService) {}
ngOnInit() {
this.showCurrentTab = !this.popupUtilsService.inPopout(window);
this.showCurrentTab = !BrowserPopupUtils.inPopout(window);
}
}

View File

@ -4,7 +4,7 @@ import { Component, OnInit } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { CalloutModule } from "@bitwarden/components";
import { PopupUtilsService } from "../../../popup/services/popup-utils.service";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
import { FilePopoutUtilsService } from "../services/file-popout-utils.service";
@Component({
@ -19,10 +19,7 @@ export class FilePopoutCalloutComponent implements OnInit {
protected showSafariFileWarning: boolean;
protected showChromiumFileWarning: boolean;
constructor(
private popupUtilsService: PopupUtilsService,
private filePopoutUtilsService: FilePopoutUtilsService
) {}
constructor(private filePopoutUtilsService: FilePopoutUtilsService) {}
ngOnInit() {
this.showFilePopoutMessage = this.filePopoutUtilsService.showFilePopoutMessage(window);
@ -32,6 +29,6 @@ export class FilePopoutCalloutComponent implements OnInit {
}
popOutWindow() {
this.popupUtilsService.popOut(window);
BrowserPopupUtils.openCurrentPagePopout(window);
}
}

View File

@ -15,8 +15,8 @@ import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.s
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { DialogService } from "@bitwarden/components";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
import { BrowserStateService } from "../../../platform/services/abstractions/browser-state.service";
import { PopupUtilsService } from "../../../popup/services/popup-utils.service";
import { FilePopoutUtilsService } from "../services/file-popout-utils.service";
@Component({
@ -44,7 +44,6 @@ export class SendAddEditComponent extends BaseAddEditComponent {
private route: ActivatedRoute,
private router: Router,
private location: Location,
private popupUtilsService: PopupUtilsService,
logService: LogService,
sendApiService: SendApiService,
dialogService: DialogService,
@ -68,14 +67,14 @@ export class SendAddEditComponent extends BaseAddEditComponent {
}
popOutWindow() {
this.popupUtilsService.popOut(window);
BrowserPopupUtils.openCurrentPagePopout(window);
}
async ngOnInit() {
// File visibility
this.showFileSelector =
!this.editMode && !this.filePopoutUtilsService.showFilePopoutMessage(window);
this.inPopout = this.popupUtilsService.inPopout(window);
this.inPopout = BrowserPopupUtils.inPopout(window);
this.isFirefox = this.platformUtilsService.isFirefox();
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe

View File

@ -17,8 +17,8 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv
import { DialogService } from "@bitwarden/components";
import { BrowserSendComponentState } from "../../../models/browserSendComponentState";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
import { BrowserStateService } from "../../../platform/services/abstractions/browser-state.service";
import { PopupUtilsService } from "../../../popup/services/popup-utils.service";
const ComponentId = "SendComponent";
@ -43,7 +43,6 @@ export class SendGroupingsComponent extends BaseSendComponent {
ngZone: NgZone,
policyService: PolicyService,
searchService: SearchService,
private popupUtils: PopupUtilsService,
private stateService: BrowserStateService,
private router: Router,
private syncService: SyncService,
@ -74,7 +73,7 @@ export class SendGroupingsComponent extends BaseSendComponent {
async ngOnInit() {
// Determine Header details
this.showLeftHeader = !(
this.popupUtils.inSidebar(window) && this.platformUtilsService.isFirefox()
BrowserPopupUtils.inSidebar(window) && this.platformUtilsService.isFirefox()
);
// Clear state of Send Type Component
await this.stateService.setBrowserSendTypeComponentState(null);
@ -97,7 +96,7 @@ export class SendGroupingsComponent extends BaseSendComponent {
}
if (!this.syncService.syncInProgress || restoredScopeState) {
window.setTimeout(() => this.popupUtils.setContentScrollY(window, this.state?.scrollY), 0);
BrowserPopupUtils.setContentScrollY(window, this.state?.scrollY);
}
// Load all sends if sync completed in background
@ -172,7 +171,7 @@ export class SendGroupingsComponent extends BaseSendComponent {
private async saveState() {
this.state = Object.assign(new BrowserSendComponentState(), {
scrollY: this.popupUtils.getContentScrollY(window),
scrollY: BrowserPopupUtils.getContentScrollY(window),
searchText: this.searchText,
sends: this.sends,
typeCounts: this.typeCounts,

View File

@ -18,8 +18,8 @@ import { SendService } from "@bitwarden/common/tools/send/services/send.service.
import { DialogService } from "@bitwarden/components";
import { BrowserComponentState } from "../../../models/browserComponentState";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
import { BrowserStateService } from "../../../platform/services/abstractions/browser-state.service";
import { PopupUtilsService } from "../../../popup/services/popup-utils.service";
const ComponentId = "SendTypeComponent";
@ -42,7 +42,6 @@ export class SendTypeComponent extends BaseSendComponent {
ngZone: NgZone,
policyService: PolicyService,
searchService: SearchService,
private popupUtils: PopupUtilsService,
private stateService: BrowserStateService,
private route: ActivatedRoute,
private location: Location,
@ -102,7 +101,7 @@ export class SendTypeComponent extends BaseSendComponent {
// Restore state and remove reference
if (this.applySavedState && this.state != null) {
window.setTimeout(() => this.popupUtils.setContentScrollY(window, this.state?.scrollY), 0);
BrowserPopupUtils.setContentScrollY(window, this.state?.scrollY);
}
this.stateService.setBrowserSendTypeComponentState(null);
});
@ -163,7 +162,7 @@ export class SendTypeComponent extends BaseSendComponent {
private async saveState() {
this.state = {
scrollY: this.popupUtils.getContentScrollY(window),
scrollY: BrowserPopupUtils.getContentScrollY(window),
searchText: this.searchText,
};
await this.stateService.setBrowserSendTypeComponentState(this.state);

View File

@ -2,7 +2,7 @@ import { Injectable } from "@angular/core";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { PopupUtilsService } from "../../../popup/services/popup-utils.service";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
/**
* Service for determining whether to display file popout callout messages.
@ -12,10 +12,7 @@ export class FilePopoutUtilsService {
/**
* Creates an instance of FilePopoutUtilsService.
*/
constructor(
private platformUtilsService: PlatformUtilsService,
private popupUtilsService: PopupUtilsService
) {}
constructor(private platformUtilsService: PlatformUtilsService) {}
/**
* Determines whether to show any file popout callout message in the current browser.
@ -38,7 +35,7 @@ export class FilePopoutUtilsService {
showFirefoxFileWarning(win: Window): boolean {
return (
this.platformUtilsService.isFirefox() &&
!(this.popupUtilsService.inSidebar(win) || this.popupUtilsService.inPopout(win))
!(BrowserPopupUtils.inSidebar(win) || BrowserPopupUtils.inPopout(win))
);
}
@ -48,7 +45,7 @@ export class FilePopoutUtilsService {
* @returns True if the extension is not in a popout; otherwise, false.
*/
showSafariFileWarning(win: Window): boolean {
return this.platformUtilsService.isSafari() && !this.popupUtilsService.inPopout(win);
return this.platformUtilsService.isSafari() && !BrowserPopupUtils.inPopout(win);
}
/**
@ -60,7 +57,7 @@ export class FilePopoutUtilsService {
return (
(this.isLinux(win) || this.isUnsupportedMac(win)) &&
!this.platformUtilsService.isFirefox() &&
!(this.popupUtilsService.inSidebar(win) || this.popupUtilsService.inPopout(win))
!(BrowserPopupUtils.inSidebar(win) || BrowserPopupUtils.inPopout(win))
);
}

View File

@ -29,7 +29,7 @@ import {
} from "@bitwarden/common/vault/abstractions/fido2/fido2-user-interface.service.abstraction";
import { BrowserApi } from "../../platform/browser/browser-api";
import { BrowserPopoutWindowService } from "../../platform/popup/abstractions/browser-popout-window.service";
import { closeFido2Popout, openFido2Popout } from "../popup/utils/vault-popout-window";
const BrowserFido2MessageName = "BrowserFido2UserInterfaceServiceMessage";
@ -116,10 +116,7 @@ export type BrowserFido2Message = { sessionId: string } & (
* The user interface is implemented as a popout and the service uses the browser's messaging API to communicate with it.
*/
export class BrowserFido2UserInterfaceService implements Fido2UserInterfaceServiceAbstraction {
constructor(
private browserPopoutWindowService: BrowserPopoutWindowService,
private authService: AuthService
) {}
constructor(private authService: AuthService) {}
async newSession(
fallbackSupported: boolean,
@ -127,7 +124,6 @@ export class BrowserFido2UserInterfaceService implements Fido2UserInterfaceServi
abortController?: AbortController
): Promise<Fido2UserInterfaceSession> {
return await BrowserFido2UserInterfaceSession.create(
this.browserPopoutWindowService,
this.authService,
fallbackSupported,
tab,
@ -138,14 +134,12 @@ export class BrowserFido2UserInterfaceService implements Fido2UserInterfaceServi
export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSession {
static async create(
browserPopoutWindowService: BrowserPopoutWindowService,
authService: AuthService,
fallbackSupported: boolean,
tab: chrome.tabs.Tab,
abortController?: AbortController
): Promise<BrowserFido2UserInterfaceSession> {
return new BrowserFido2UserInterfaceSession(
browserPopoutWindowService,
authService,
fallbackSupported,
tab,
@ -183,7 +177,6 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
private destroy$ = new Subject<void>();
private constructor(
private readonly browserPopoutWindowService: BrowserPopoutWindowService,
private readonly authService: AuthService,
private readonly fallbackSupported: boolean,
private readonly tab: chrome.tabs.Tab,
@ -304,7 +297,7 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
}
async close() {
await this.browserPopoutWindowService.closeFido2Popout();
await closeFido2Popout(this.sessionId);
this.closed = true;
this.destroy$.next();
this.destroy$.complete();
@ -354,9 +347,8 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
)
);
const popoutId = await this.browserPopoutWindowService.openFido2Popout(this.tab, {
const popoutId = await openFido2Popout(this.tab, {
sessionId: this.sessionId,
senderTabId: this.tab.id,
fallbackSupported: this.fallbackSupported,
});

View File

@ -35,6 +35,7 @@ import {
BrowserFido2Message,
BrowserFido2UserInterfaceSession,
} from "../../../fido2/browser-fido2-user-interface.service";
import { VaultPopoutType } from "../../utils/vault-popout-window";
interface ViewData {
message: BrowserFido2Message;
@ -280,6 +281,7 @@ export class Fido2Component implements OnInit, OnDestroy {
uilocation: "popout",
senderTabId: this.senderTabId,
sessionId: this.sessionId,
singleActionPopout: `${VaultPopoutType.fido2Popout}_${this.sessionId}`,
},
});
}
@ -299,6 +301,7 @@ export class Fido2Component implements OnInit, OnDestroy {
senderTabId: this.senderTabId,
sessionId: this.sessionId,
userVerification: data.userVerification,
singleActionPopout: `${VaultPopoutType.fido2Popout}_${this.sessionId}`,
},
});
}

View File

@ -24,11 +24,13 @@ import { DialogService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { BrowserApi } from "../../../../platform/browser/browser-api";
import { PopupUtilsService } from "../../../../popup/services/popup-utils.service";
import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils";
import { PopupCloseWarningService } from "../../../../popup/services/popup-close-warning.service";
import {
BrowserFido2UserInterfaceSession,
fido2PopoutSessionData$,
} from "../../../fido2/browser-fido2-user-interface.service";
import { VaultPopoutType, closeAddEditVaultItemPopout } from "../../utils/vault-popout-window";
@Component({
selector: "app-vault-add-edit",
@ -40,9 +42,7 @@ export class AddEditComponent extends BaseAddEditComponent {
showAttachments = true;
openAttachmentsInPopup: boolean;
showAutoFillOnPageLoadOptions: boolean;
senderTabId?: number;
uilocation?: "popout" | "popup" | "sidebar" | "tab";
inPopout = false;
private singleActionKey: string;
private fido2PopoutSessionData$ = fido2PopoutSessionData$();
@ -60,7 +60,7 @@ export class AddEditComponent extends BaseAddEditComponent {
private location: Location,
eventCollectionService: EventCollectionService,
policyService: PolicyService,
private popupUtilsService: PopupUtilsService,
private popupCloseWarningService: PopupCloseWarningService,
organizationService: OrganizationService,
passwordRepromptService: PasswordRepromptService,
logService: LogService,
@ -91,9 +91,6 @@ export class AddEditComponent extends BaseAddEditComponent {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.queryParams.pipe(first()).subscribe(async (params) => {
this.senderTabId = parseInt(params?.senderTabId, 10) || undefined;
this.uilocation = params?.uilocation;
if (params.cipherId) {
this.cipherId = params.cipherId;
}
@ -119,18 +116,16 @@ export class AddEditComponent extends BaseAddEditComponent {
if (params.selectedVault) {
this.organizationId = params.selectedVault;
}
if (params.singleActionKey) {
this.singleActionKey = params.singleActionKey;
}
await this.load();
if (!this.editMode || this.cloneMode) {
if (
!this.popupUtilsService.inPopout(window) &&
params.name &&
(this.cipher.name == null || this.cipher.name === "")
) {
if (params.name && (this.cipher.name == null || this.cipher.name === "")) {
this.cipher.name = params.name;
}
if (
!this.popupUtilsService.inPopout(window) &&
params.uri &&
(this.cipher.login.uris[0].uri == null || this.cipher.login.uris[0].uri === "")
) {
@ -138,11 +133,9 @@ export class AddEditComponent extends BaseAddEditComponent {
}
}
this.openAttachmentsInPopup = this.popupUtilsService.inPopup(window);
this.openAttachmentsInPopup = BrowserPopupUtils.inPopup(window);
});
this.inPopout = this.uilocation === "popout" || this.popupUtilsService.inPopout(window);
if (!this.editMode) {
const tabs = await BrowserApi.tabsQuery({ windowType: "normal" });
this.currentUris =
@ -153,8 +146,8 @@ export class AddEditComponent extends BaseAddEditComponent {
this.setFocus();
if (this.popupUtilsService.inTab(window)) {
this.popupUtilsService.enableCloseTabWarning();
if (BrowserPopupUtils.inPopout(window)) {
this.popupCloseWarningService.enable();
}
}
@ -167,12 +160,10 @@ export class AddEditComponent extends BaseAddEditComponent {
async submit(): Promise<boolean> {
const fido2SessionData = await firstValueFrom(this.fido2PopoutSessionData$);
// Would be refactored after rework is done on the windows popout service
const { isFido2Session, sessionId, userVerification } = fido2SessionData;
const inFido2PopoutWindow = BrowserPopupUtils.inPopout(window) && isFido2Session;
if (
this.inPopout &&
isFido2Session &&
inFido2PopoutWindow &&
!(await this.handleFido2UserVerification(sessionId, userVerification))
) {
return false;
@ -183,7 +174,11 @@ export class AddEditComponent extends BaseAddEditComponent {
return false;
}
if (this.inPopout && isFido2Session) {
if (BrowserPopupUtils.inPopout(window)) {
this.popupCloseWarningService.disable();
}
if (inFido2PopoutWindow) {
BrowserFido2UserInterfaceSession.confirmNewCredentialResponse(
sessionId,
this.cipher.id,
@ -192,14 +187,8 @@ export class AddEditComponent extends BaseAddEditComponent {
return true;
}
if (this.popupUtilsService.inTab(window)) {
this.popupUtilsService.disableCloseTabWarning();
this.messagingService.send("closeTab", { delay: 1000 });
return true;
}
if (this.senderTabId && this.inPopout) {
setTimeout(() => this.close(), 1000);
if (this.inAddEditPopoutWindow()) {
await closeAddEditVaultItemPopout(1000);
return true;
}
@ -219,7 +208,7 @@ export class AddEditComponent extends BaseAddEditComponent {
.createUrlTree(["/attachments"], { queryParams: { cipherId: this.cipher.id } })
.toString();
const currentBaseUrl = window.location.href.replace(this.router.url, "");
this.popupUtilsService.popOut(window, currentBaseUrl + destinationUrl);
BrowserPopupUtils.openCurrentPagePopout(window, currentBaseUrl + destinationUrl);
} else {
this.router.navigate(["/attachments"], { queryParams: { cipherId: this.cipher.id } });
}
@ -235,34 +224,21 @@ export class AddEditComponent extends BaseAddEditComponent {
async cancel() {
super.cancel();
// Would be refactored after rework is done on the windows popout service
const sessionData = await firstValueFrom(this.fido2PopoutSessionData$);
if (this.inPopout && sessionData.isFido2Session) {
if (BrowserPopupUtils.inPopout(window) && sessionData.isFido2Session) {
this.popupCloseWarningService.disable();
BrowserFido2UserInterfaceSession.abortPopout(sessionData.sessionId);
return;
}
if (this.senderTabId && this.inPopout) {
this.close();
return;
}
if (this.popupUtilsService.inTab(window)) {
this.messagingService.send("closeTab");
if (this.inAddEditPopoutWindow()) {
closeAddEditVaultItemPopout();
return;
}
this.location.back();
}
// Used for closing single-action views
close() {
BrowserApi.focusTab(this.senderTabId);
window.close();
return;
}
async generateUsername(): Promise<boolean> {
const confirmed = await super.generateUsername();
if (confirmed) {
@ -356,4 +332,11 @@ export class AddEditComponent extends BaseAddEditComponent {
this.i18nService.t("autofillOnPageLoadSetToDefault")
);
}
private inAddEditPopoutWindow() {
return BrowserPopupUtils.inSingleActionPopout(
window,
this.singleActionKey || VaultPopoutType.addEditVaultItem
);
}
}

View File

@ -19,7 +19,7 @@ import { PasswordRepromptService } from "@bitwarden/vault";
import { AutofillService } from "../../../../autofill/services/abstractions/autofill.service";
import { BrowserApi } from "../../../../platform/browser/browser-api";
import { PopupUtilsService } from "../../../../popup/services/popup-utils.service";
import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils";
import { VaultFilterService } from "../../../services/vault-filter.service";
const BroadcasterSubscriptionId = "CurrentTabComponent";
@ -55,7 +55,6 @@ export class CurrentTabComponent implements OnInit, OnDestroy {
constructor(
private platformUtilsService: PlatformUtilsService,
private cipherService: CipherService,
private popupUtilsService: PopupUtilsService,
private autofillService: AutofillService,
private i18nService: I18nService,
private router: Router,
@ -72,7 +71,7 @@ export class CurrentTabComponent implements OnInit, OnDestroy {
async ngOnInit() {
this.searchTypeSearch = !this.platformUtilsService.isSafari();
this.inSidebar = this.popupUtilsService.inSidebar(window);
this.inSidebar = BrowserPopupUtils.inSidebar(window);
this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => {
this.ngZone.run(async () => {
@ -185,7 +184,7 @@ export class CurrentTabComponent implements OnInit, OnDestroy {
if (this.totpCode != null) {
this.platformUtilsService.copyToClipboard(this.totpCode, { window: window });
}
if (this.popupUtilsService.inPopup(window)) {
if (BrowserPopupUtils.inPopup(window)) {
if (!closePopupDelay) {
if (this.platformUtilsService.isFirefox() || this.platformUtilsService.isSafari()) {
BrowserApi.closePopup(window);

View File

@ -19,8 +19,8 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { BrowserGroupingsComponentState } from "../../../../models/browserGroupingsComponentState";
import { BrowserApi } from "../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils";
import { BrowserStateService } from "../../../../platform/services/abstractions/browser-state.service";
import { PopupUtilsService } from "../../../../popup/services/popup-utils.service";
import { VaultFilterService } from "../../../services/vault-filter.service";
const ComponentId = "VaultComponent";
@ -80,7 +80,6 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
private broadcasterService: BroadcasterService,
private changeDetectorRef: ChangeDetectorRef,
private route: ActivatedRoute,
private popupUtils: PopupUtilsService,
private syncService: SyncService,
private platformUtilsService: PlatformUtilsService,
private searchService: SearchService,
@ -94,7 +93,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
async ngOnInit() {
this.searchTypeSearch = !this.platformUtilsService.isSafari();
this.showLeftHeader = !(
this.popupUtils.inSidebar(window) && this.platformUtilsService.isFirefox()
BrowserPopupUtils.inSidebar(window) && this.platformUtilsService.isFirefox()
);
await this.browserStateService.setBrowserVaultItemsComponentState(null);
@ -136,7 +135,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
}
if (!this.syncService.syncInProgress || restoredScopeState) {
window.setTimeout(() => this.popupUtils.setContentScrollY(window, this.state?.scrollY), 0);
BrowserPopupUtils.setContentScrollY(window, this.state?.scrollY);
}
});
}
@ -265,7 +264,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
this.preventSelected = true;
await this.cipherService.updateLastLaunchedDate(cipher.id);
BrowserApi.createNewTab(cipher.login.launchUri);
if (this.popupUtils.inPopup(window)) {
if (BrowserPopupUtils.inPopup(window)) {
BrowserApi.closePopup(window);
}
}
@ -376,7 +375,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
private async saveState() {
this.state = Object.assign(new BrowserGroupingsComponentState(), {
scrollY: this.popupUtils.getContentScrollY(window),
scrollY: BrowserPopupUtils.getContentScrollY(window),
searchText: this.searchText,
favoriteCiphers: this.favoriteCiphers,
noFolderCiphers: this.noFolderCiphers,

View File

@ -13,7 +13,6 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
@ -21,8 +20,8 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { BrowserComponentState } from "../../../../models/browserComponentState";
import { BrowserApi } from "../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils";
import { BrowserStateService } from "../../../../platform/services/abstractions/browser-state.service";
import { PopupUtilsService } from "../../../../popup/services/popup-utils.service";
import { VaultFilterService } from "../../../services/vault-filter.service";
const ComponentId = "VaultItemsComponent";
@ -61,9 +60,7 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn
private broadcasterService: BroadcasterService,
private changeDetectorRef: ChangeDetectorRef,
private stateService: BrowserStateService,
private popupUtils: PopupUtilsService,
private i18nService: I18nService,
private folderService: FolderService,
private collectionService: CollectionService,
private platformUtilsService: PlatformUtilsService,
cipherService: CipherService,
@ -155,11 +152,10 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn
}
if (this.applySavedState && this.state != null) {
window.setTimeout(
() =>
this.popupUtils.setContentScrollY(window, this.state.scrollY, this.scrollingContainer),
0
);
BrowserPopupUtils.setContentScrollY(window, this.state.scrollY, {
delay: 0,
containerSelector: this.scrollingContainer,
});
}
await this.stateService.setBrowserVaultItemsComponentState(null);
});
@ -219,7 +215,7 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn
this.preventSelected = true;
await this.cipherService.updateLastLaunchedDate(cipher.id);
BrowserApi.createNewTab(cipher.login.launchUri);
if (this.popupUtils.inPopup(window)) {
if (BrowserPopupUtils.inPopup(window)) {
BrowserApi.closePopup(window);
}
}
@ -288,7 +284,7 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn
private async saveState() {
this.state = {
scrollY: this.popupUtils.getContentScrollY(window, this.scrollingContainer),
scrollY: BrowserPopupUtils.getContentScrollY(window, this.scrollingContainer),
searchText: this.searchText,
};
await this.stateService.setBrowserVaultItemsComponentState(this.state);

View File

@ -28,7 +28,7 @@ import { PasswordRepromptService } from "@bitwarden/vault";
import { AutofillService } from "../../../../autofill/services/abstractions/autofill.service";
import { BrowserApi } from "../../../../platform/browser/browser-api";
import { PopupUtilsService } from "../../../../popup/services/popup-utils.service";
import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils";
import {
BrowserFido2UserInterfaceSession,
fido2PopoutSessionData$,
@ -84,7 +84,6 @@ export class ViewComponent extends BaseViewComponent {
eventCollectionService: EventCollectionService,
private autofillService: AutofillService,
private messagingService: MessagingService,
private popupUtilsService: PopupUtilsService,
apiService: ApiService,
passwordRepromptService: PasswordRepromptService,
logService: LogService,
@ -121,7 +120,7 @@ export class ViewComponent extends BaseViewComponent {
this.uilocation = value?.uilocation;
});
this.inPopout = this.uilocation === "popout" || this.popupUtilsService.inPopout(window);
this.inPopout = this.uilocation === "popout" || BrowserPopupUtils.inPopout(window);
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.queryParams.pipe(first()).subscribe(async (params) => {

View File

@ -0,0 +1,149 @@
import { mock } from "jest-mock-extended";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
import {
closeAddEditVaultItemPopout,
closeFido2Popout,
openAddEditVaultItemPopout,
openFido2Popout,
openVaultItemPasswordRepromptPopout,
VaultPopoutType,
} from "./vault-popout-window";
describe("VaultPopoutWindow", () => {
const openPopoutSpy = jest
.spyOn(BrowserPopupUtils, "openPopout")
.mockResolvedValue(mock<chrome.windows.Window>({ id: 10 }));
const closeSingleActionPopoutSpy = jest
.spyOn(BrowserPopupUtils, "closeSingleActionPopout")
.mockImplementation();
afterEach(() => {
jest.clearAllMocks();
});
describe("openVaultItemPasswordRepromptPopout", () => {
it("opens a popout window that facilitates re-prompting for the password of a vault item", async () => {
const senderTab = { windowId: 1 } as chrome.tabs.Tab;
await openVaultItemPasswordRepromptPopout(senderTab, {
cipherId: "cipherId",
action: "action",
});
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/view-cipher?cipherId=cipherId&senderTabId=undefined&action=action",
{
singleActionKey: `${VaultPopoutType.vaultItemPasswordReprompt}_cipherId`,
senderWindowId: 1,
forceCloseExistingWindows: true,
}
);
});
});
describe("openAddEditVaultItemPopout", () => {
it("opens a popout window that facilitates adding a vault item", async () => {
await openAddEditVaultItemPopout(
mock<chrome.tabs.Tab>({ windowId: 1, url: "https://tacos.com" })
);
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/edit-cipher?uilocation=popout&uri=https://tacos.com",
{
singleActionKey: VaultPopoutType.addEditVaultItem,
senderWindowId: 1,
}
);
});
it("opens a popout window that facilitates adding a specific type of vault item", () => {
openAddEditVaultItemPopout(mock<chrome.tabs.Tab>({ windowId: 1, url: "https://tacos.com" }), {
cipherType: CipherType.Identity,
});
expect(openPopoutSpy).toHaveBeenCalledWith(
`popup/index.html#/edit-cipher?uilocation=popout&type=${CipherType.Identity}&uri=https://tacos.com`,
{
singleActionKey: `${VaultPopoutType.addEditVaultItem}_${CipherType.Identity}`,
senderWindowId: 1,
}
);
});
it("opens a popout window that facilitates editing a vault item", async () => {
await openAddEditVaultItemPopout(
mock<chrome.tabs.Tab>({ windowId: 1, url: "https://tacos.com" }),
{
cipherId: "cipherId",
}
);
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/edit-cipher?uilocation=popout&cipherId=cipherId&uri=https://tacos.com",
{
singleActionKey: `${VaultPopoutType.addEditVaultItem}_cipherId`,
senderWindowId: 1,
}
);
});
});
describe("closeAddEditVaultItemPopout", () => {
it("closes the add/edit vault item popout window", () => {
closeAddEditVaultItemPopout();
expect(closeSingleActionPopoutSpy).toHaveBeenCalledWith(VaultPopoutType.addEditVaultItem, 0);
});
it("closes the add/edit vault item popout window after a delay", () => {
closeAddEditVaultItemPopout(1000);
expect(closeSingleActionPopoutSpy).toHaveBeenCalledWith(
VaultPopoutType.addEditVaultItem,
1000
);
});
});
describe("openFido2Popout", () => {
it("opens a popout window that facilitates FIDO2 authentication workflows", async () => {
const senderTab = mock<chrome.tabs.Tab>({
windowId: 1,
url: "https://jest-testing.com",
id: 2,
});
const returnedWindowId = await openFido2Popout(senderTab, {
sessionId: "sessionId",
fallbackSupported: true,
});
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/fido2?sessionId=sessionId&fallbackSupported=true&senderTabId=2&senderUrl=https%3A%2F%2Fjest-testing.com",
{
singleActionKey: `${VaultPopoutType.fido2Popout}_sessionId`,
senderWindowId: 1,
forceCloseExistingWindows: true,
windowOptions: { height: 450 },
}
);
expect(returnedWindowId).toEqual(10);
});
});
describe("closeFido2Popout", () => {
it("closes the fido2 popout window", () => {
const sessionId = "sessionId";
closeFido2Popout(sessionId);
expect(closeSingleActionPopoutSpy).toHaveBeenCalledWith(
`${VaultPopoutType.fido2Popout}_${sessionId}`
);
});
});
});

View File

@ -0,0 +1,129 @@
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
const VaultPopoutType = {
vaultItemPasswordReprompt: "vault_PasswordReprompt",
addEditVaultItem: "vault_AddEditVaultItem",
fido2Popout: "vault_Fido2Popout",
} as const;
/**
* Opens a popout window that facilitates re-prompting for
* the password of a vault item.
*
* @param senderTab - The tab that sent the request.
* @param cipherOptions - The cipher id and action to perform.
*/
async function openVaultItemPasswordRepromptPopout(
senderTab: chrome.tabs.Tab,
cipherOptions: {
cipherId: string;
action: string;
}
) {
const { cipherId, action } = cipherOptions;
const promptWindowPath =
"popup/index.html#/view-cipher" +
`?cipherId=${cipherId}` +
`&senderTabId=${senderTab.id}` +
`&action=${action}`;
await BrowserPopupUtils.openPopout(promptWindowPath, {
singleActionKey: `${VaultPopoutType.vaultItemPasswordReprompt}_${cipherId}`,
senderWindowId: senderTab.windowId,
forceCloseExistingWindows: true,
});
}
/**
* Opens a popout window that facilitates adding or editing a vault item.
*
* @param senderTab - The window id of the sender.
* @param cipherOptions - Options passed as query params to the popout.
*/
async function openAddEditVaultItemPopout(
senderTab: chrome.tabs.Tab,
cipherOptions: { cipherId?: string; cipherType?: CipherType } = {}
) {
const { cipherId, cipherType } = cipherOptions;
const { url, windowId } = senderTab;
let singleActionKey = VaultPopoutType.addEditVaultItem;
let addEditCipherUrl = "popup/index.html#/edit-cipher?uilocation=popout";
if (cipherId && !cipherType) {
singleActionKey += `_${cipherId}`;
addEditCipherUrl += `&cipherId=${cipherId}`;
}
if (cipherType && !cipherId) {
singleActionKey += `_${cipherType}`;
addEditCipherUrl += `&type=${cipherType}`;
}
if (senderTab.url) {
addEditCipherUrl += `&uri=${url}`;
}
await BrowserPopupUtils.openPopout(addEditCipherUrl, {
singleActionKey,
senderWindowId: windowId,
});
}
/**
* Closes the add/edit vault item popout window.
*
* @param delayClose - The amount of time to wait before closing the popout. Defaults to 0.
*/
async function closeAddEditVaultItemPopout(delayClose = 0) {
await BrowserPopupUtils.closeSingleActionPopout(VaultPopoutType.addEditVaultItem, delayClose);
}
/**
* Opens a popout window that facilitates FIDO2
* authentication and passkey management.
*
* @param senderTab - The tab that sent the request.
* @param options - Options passed as query params to the popout.
*/
async function openFido2Popout(
senderTab: chrome.tabs.Tab,
options: {
sessionId: string;
fallbackSupported: boolean;
}
): Promise<chrome.windows.Window["id"]> {
const { sessionId, fallbackSupported } = options;
const promptWindowPath =
"popup/index.html#/fido2" +
`?sessionId=${sessionId}` +
`&fallbackSupported=${fallbackSupported}` +
`&senderTabId=${senderTab.id}` +
`&senderUrl=${encodeURIComponent(senderTab.url)}`;
const popoutWindow = await BrowserPopupUtils.openPopout(promptWindowPath, {
singleActionKey: `${VaultPopoutType.fido2Popout}_${sessionId}`,
senderWindowId: senderTab.windowId,
forceCloseExistingWindows: true,
windowOptions: { height: 450 },
});
return popoutWindow.id;
}
/**
* Closes the FIDO2 popout window associated with the passed session ID.
*
* @param sessionId - The session ID of the popout to close.
*/
async function closeFido2Popout(sessionId: string): Promise<void> {
await BrowserPopupUtils.closeSingleActionPopout(`${VaultPopoutType.fido2Popout}_${sessionId}`);
}
export {
VaultPopoutType,
openVaultItemPasswordRepromptPopout,
openAddEditVaultItemPopout,
closeAddEditVaultItemPopout,
openFido2Popout,
closeFido2Popout,
};

View File

@ -23,6 +23,7 @@ const runtime = {
},
sendMessage: jest.fn(),
getManifest: jest.fn(),
getURL: jest.fn((path) => `chrome-extension://id/${path}`),
};
const contextMenus = {
@ -37,12 +38,21 @@ const i18n = {
const tabs = {
executeScript: jest.fn(),
sendMessage: jest.fn(),
query: jest.fn(),
};
const scripting = {
executeScript: jest.fn(),
};
const windows = {
create: jest.fn(),
get: jest.fn(),
getCurrent: jest.fn(),
update: jest.fn(),
remove: jest.fn(),
};
// set chrome
global.chrome = {
i18n,
@ -51,4 +61,5 @@ global.chrome = {
contextMenus,
tabs,
scripting,
windows,
} as any;