From 51f482dde9a98a3780dc0ffd8a2666dbc00b7794 Mon Sep 17 00:00:00 2001 From: Cesar Gonzalez Date: Wed, 6 Mar 2024 10:33:38 -0600 Subject: [PATCH] [PM-5880] Refactor browser platform utils service to remove `window` references (#7885) * [PM-5880] Refactor Browser Platform Utils Service to Remove Window Service * [PM-5880] Implementing BrowserClipboardService to handle clipboard logic between the BrowserPlatformUtils and offscreen document * [PM-5880] Adjusting how readText is handled within BrowserClipboardService * [PM-5880] Adjusting how readText is handled within BrowserClipboardService * [PM-5880] Working through implementation of chrome offscreen API usage * [PM-5880] Implementing jest tests for the methods added to the BrowserApi class * [PM-5880] Implementing jest tests for the OffscreenDocument class * [PM-5880] Working through jest tests for BrowserClipboardService * [PM-5880] Adding typing information to the clipboard methods present within the BrowserPlatformUtilsService * [PM-5880] Working on adding ServiceWorkerGlobalScope typing information * [PM-5880] Updating window references when calling BrowserPlatformUtils methods * [PM-5880] Finishing out jest tests for the BrowserClipboardService * [PM-5880] Finishing out jest tests for the BrowserClipboardService * [PM-5880] Implementing jest tests to validate the changes within BrowserApi * [PM-5880] Implementing jest tests to ensure coverage within OffscreenDocument * [PM-5880] Implementing jest tests for the BrowserPlatformUtilsService * [PM-5880] Removing unused catch statements * [PM-5880] Implementing jest tests for the BrowserPlatformUtilsService * [PM-5880] Implementing jest tests for the BrowserPlatformUtilsService * [PM-5880] Fixing broken tests --- .../background/overlay.background.spec.ts | 2 +- .../autofill/background/overlay.background.ts | 2 +- .../background/web-request.background.ts | 2 +- .../src/background/commands.background.ts | 2 +- .../browser/src/background/main.background.ts | 36 ++- .../src/background/runtime.background.ts | 6 +- apps/browser/src/manifest.v3.json | 3 +- .../src/platform/alarms/alarm-state.ts | 4 +- apps/browser/src/platform/background.ts | 2 +- .../storage-service.factory.ts | 2 +- .../src/platform/browser/browser-api.spec.ts | 94 ++++++++ .../src/platform/browser/browser-api.ts | 51 ++++- .../session-sync-observable/session-syncer.ts | 4 +- apps/browser/src/platform/globals.d.ts | 6 + .../abstractions/offscreen-document.ts | 26 +++ .../platform/offscreen-document/index.html | 13 ++ .../offscreen-document.spec.ts | 62 ++++++ .../offscreen-document/offscreen-document.ts | 83 +++++++ .../src/platform/popup/browser-popup-utils.ts | 2 +- .../browser-clipboard.service.spec.ts | 111 ++++++++++ .../services/browser-clipboard.service.ts | 130 +++++++++++ .../browser-platform-utils.service.spec.ts | 208 +++++++++++++++++- .../browser-platform-utils.service.ts | 200 ++++++++--------- apps/browser/test.setup.ts | 14 ++ apps/browser/webpack.config.js | 10 + 25 files changed, 936 insertions(+), 139 deletions(-) create mode 100644 apps/browser/src/platform/offscreen-document/abstractions/offscreen-document.ts create mode 100644 apps/browser/src/platform/offscreen-document/index.html create mode 100644 apps/browser/src/platform/offscreen-document/offscreen-document.spec.ts create mode 100644 apps/browser/src/platform/offscreen-document/offscreen-document.ts create mode 100644 apps/browser/src/platform/services/browser-clipboard.service.spec.ts create mode 100644 apps/browser/src/platform/services/browser-clipboard.service.ts diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index 5aab76c3dd8d..2820ed6cd10f 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -1259,7 +1259,7 @@ describe("OverlayBackground", () => { }); await flushPromises(); - expect(copyToClipboardSpy).toHaveBeenCalledWith("totp-code", { window }); + expect(copyToClipboardSpy).toHaveBeenCalledWith("totp-code"); }); }); diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index c2eb12041a61..583e22fbe850 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -241,7 +241,7 @@ class OverlayBackground implements OverlayBackgroundInterface { }); if (totpCode) { - this.platformUtilsService.copyToClipboard(totpCode, { window }); + this.platformUtilsService.copyToClipboard(totpCode); } this.overlayLoginCiphers = new Map([[overlayCipherId, cipher], ...this.overlayLoginCiphers]); diff --git a/apps/browser/src/autofill/background/web-request.background.ts b/apps/browser/src/autofill/background/web-request.background.ts index 7bb3e679c799..e8f77a6d47f2 100644 --- a/apps/browser/src/autofill/background/web-request.background.ts +++ b/apps/browser/src/autofill/background/web-request.background.ts @@ -16,7 +16,7 @@ export default class WebRequestBackground { private cipherService: CipherService, private authService: AuthService, ) { - if (BrowserApi.manifestVersion === 2) { + if (BrowserApi.isManifestVersion(2)) { this.webRequest = (window as any).chrome.webRequest; } this.isFirefox = platformUtilsService.isFirefox(); diff --git a/apps/browser/src/background/commands.background.ts b/apps/browser/src/background/commands.background.ts index 233341caa8cd..10395e66e5a8 100644 --- a/apps/browser/src/background/commands.background.ts +++ b/apps/browser/src/background/commands.background.ts @@ -64,7 +64,7 @@ export default class CommandsBackground { private async generatePasswordToClipboard() { const options = (await this.passwordGenerationService.getOptions())?.[0] ?? {}; const password = await this.passwordGenerationService.generatePassword(options); - this.platformUtilsService.copyToClipboard(password, { window: window }); + this.platformUtilsService.copyToClipboard(password); await this.passwordGenerationService.addHistory(password); } diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 94e466b94349..ce0c67efa111 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -325,7 +325,7 @@ export default class MainBackground { popupOnlyContext: boolean; constructor(public isPrivateMode: boolean = false) { - this.popupOnlyContext = isPrivateMode || BrowserApi.manifestVersion === 3; + this.popupOnlyContext = isPrivateMode || BrowserApi.isManifestVersion(3); // Services const lockedCallback = async (userId?: string) => { @@ -353,20 +353,18 @@ export default class MainBackground { this.keyGenerationService = new KeyGenerationService(this.cryptoFunctionService); this.storageService = new BrowserLocalStorageService(); this.secureStorageService = new BrowserLocalStorageService(); - this.memoryStorageService = - BrowserApi.manifestVersion === 3 - ? new LocalBackedSessionStorageService( - new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, false), - this.keyGenerationService, - ) - : new MemoryStorageService(); - this.memoryStorageForStateProviders = - BrowserApi.manifestVersion === 3 - ? new LocalBackedSessionStorageService( - new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, false), - this.keyGenerationService, - ) - : new BackgroundMemoryStorageService(); + this.memoryStorageService = BrowserApi.isManifestVersion(3) + ? new LocalBackedSessionStorageService( + new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, false), + this.keyGenerationService, + ) + : new MemoryStorageService(); + this.memoryStorageForStateProviders = BrowserApi.isManifestVersion(3) + ? new LocalBackedSessionStorageService( + new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, false), + this.keyGenerationService, + ) + : new BackgroundMemoryStorageService(); const storageServiceProvider = new StorageServiceProvider( this.storageService as BrowserLocalStorageService, @@ -462,7 +460,7 @@ export default class MainBackground { return promise.then((result) => result.response === "unlocked"); } }, - window, + self, ); this.i18nService = new BrowserI18nService(BrowserApi.getUILanguage(), this.stateService); this.cryptoService = new BrowserCryptoService( @@ -898,11 +896,11 @@ export default class MainBackground { ); if (!this.popupOnlyContext) { const contextMenuClickedHandler = new ContextMenuClickedHandler( - (options) => this.platformUtilsService.copyToClipboard(options.text, { window: self }), + (options) => this.platformUtilsService.copyToClipboard(options.text), async (_tab) => { const options = (await this.passwordGenerationService.getOptions())?.[0] ?? {}; const password = await this.passwordGenerationService.generatePassword(options); - this.platformUtilsService.copyToClipboard(password, { window: window }); + this.platformUtilsService.copyToClipboard(password); // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises this.passwordGenerationService.addHistory(password); @@ -1143,7 +1141,7 @@ export default class MainBackground { await this.reseedStorage(); } - if (BrowserApi.manifestVersion === 3) { + if (BrowserApi.isManifestVersion(3)) { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises BrowserApi.sendMessage("updateBadge"); diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index 436515d7ccd7..fd735966fb13 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -172,7 +172,7 @@ export default class RuntimeBackground { msg.sender === "autofill_cmd", ); if (totpCode != null) { - this.platformUtilsService.copyToClipboard(totpCode, { window: window }); + this.platformUtilsService.copyToClipboard(totpCode); } break; } @@ -261,7 +261,7 @@ export default class RuntimeBackground { }); break; case "getClickedElementResponse": - this.platformUtilsService.copyToClipboard(msg.identifier, { window: window }); + this.platformUtilsService.copyToClipboard(msg.identifier); break; case "triggerFido2ContentScriptInjection": await this.fido2Service.injectFido2ContentScripts(sender); @@ -319,7 +319,7 @@ export default class RuntimeBackground { }); if (totpCode != null) { - this.platformUtilsService.copyToClipboard(totpCode, { window: window }); + this.platformUtilsService.copyToClipboard(totpCode); } // reset diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index 7bbf95234b5e..de4137ec244e 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -72,7 +72,8 @@ "clipboardWrite", "idle", "alarms", - "scripting" + "scripting", + "offscreen" ], "optional_permissions": ["nativeMessaging", "privacy"], "host_permissions": ["http://*/*", "https://*/*"], diff --git a/apps/browser/src/platform/alarms/alarm-state.ts b/apps/browser/src/platform/alarms/alarm-state.ts index c58d60351752..fa18e26ed1cf 100644 --- a/apps/browser/src/platform/alarms/alarm-state.ts +++ b/apps/browser/src/platform/alarms/alarm-state.ts @@ -22,7 +22,7 @@ const alarmState: AlarmState = { */ export async function getAlarmTime(commandName: AlarmKeys): Promise { let alarmTime: number; - if (BrowserApi.manifestVersion == 3) { + if (BrowserApi.isManifestVersion(3)) { const fromSessionStore = await chrome.storage.session.get(commandName); alarmTime = fromSessionStore[commandName]; } else { @@ -58,7 +58,7 @@ export async function clearAlarmTime(commandName: AlarmKeys): Promise { } async function setAlarmTimeInternal(commandName: AlarmKeys, time: number): Promise { - if (BrowserApi.manifestVersion == 3) { + if (BrowserApi.isManifestVersion(3)) { await chrome.storage.session.set({ [commandName]: time }); } else { alarmState[commandName] = time; diff --git a/apps/browser/src/platform/background.ts b/apps/browser/src/platform/background.ts index 3b1ba8f6bbb3..b71b4d96b01d 100644 --- a/apps/browser/src/platform/background.ts +++ b/apps/browser/src/platform/background.ts @@ -14,7 +14,7 @@ import { tabsOnUpdatedListener, } from "./listeners"; -if (BrowserApi.manifestVersion === 3) { +if (BrowserApi.isManifestVersion(3)) { chrome.commands.onCommand.addListener(onCommandListener); chrome.runtime.onInstalled.addListener(onInstallListener); chrome.alarms.onAlarm.addListener(onAlarmListener); diff --git a/apps/browser/src/platform/background/service-factories/storage-service.factory.ts b/apps/browser/src/platform/background/service-factories/storage-service.factory.ts index 226025c1f618..6a854255f5e2 100644 --- a/apps/browser/src/platform/background/service-factories/storage-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/storage-service.factory.ts @@ -52,7 +52,7 @@ export function memoryStorageServiceFactory( opts: MemoryStorageServiceInitOptions, ): Promise { return factory(cache, "memoryStorageService", opts, async () => { - if (BrowserApi.manifestVersion === 3) { + if (BrowserApi.isManifestVersion(3)) { return new LocalBackedSessionStorageService( await encryptServiceFactory(cache, opts), await keyGenerationServiceFactory(cache, opts), diff --git a/apps/browser/src/platform/browser/browser-api.spec.ts b/apps/browser/src/platform/browser/browser-api.spec.ts index 3761f4210851..a1dafb38ec0d 100644 --- a/apps/browser/src/platform/browser/browser-api.spec.ts +++ b/apps/browser/src/platform/browser/browser-api.spec.ts @@ -9,6 +9,24 @@ describe("BrowserApi", () => { jest.clearAllMocks(); }); + describe("isManifestVersion", () => { + beforeEach(() => { + jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(3); + }); + + it("returns true if the manifest version matches the provided version", () => { + const result = BrowserApi.isManifestVersion(3); + + expect(result).toBe(true); + }); + + it("returns false if the manifest version does not match the provided version", () => { + const result = BrowserApi.isManifestVersion(2); + + expect(result).toBe(false); + }); + }); + describe("getWindow", () => { it("will get the current window if a window id is not provided", () => { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. @@ -106,6 +124,38 @@ describe("BrowserApi", () => { }); }); + describe("getTab", () => { + it("returns `null` if the tabId is a falsy value", async () => { + const result = await BrowserApi.getTab(null); + + expect(result).toBeNull(); + }); + + it("returns the tab within manifest v3", async () => { + const tabId = 1; + jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(3); + (chrome.tabs.get as jest.Mock).mockImplementation( + (tabId) => ({ id: tabId }) as chrome.tabs.Tab, + ); + + const result = await BrowserApi.getTab(tabId); + + expect(result).toEqual({ id: tabId }); + }); + + it("returns the tab within manifest v2", async () => { + const tabId = 1; + jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(2); + (chrome.tabs.get as jest.Mock).mockImplementation((tabId, callback) => + callback({ id: tabId } as chrome.tabs.Tab), + ); + + const result = BrowserApi.getTab(tabId); + + await expect(result).resolves.toEqual({ id: tabId }); + }); + }); + describe("getBackgroundPage", () => { it("returns a null value if the `getBackgroundPage` method is not available", () => { chrome.extension.getBackgroundPage = undefined; @@ -280,6 +330,24 @@ describe("BrowserApi", () => { }); }); + describe("getBrowserAction", () => { + it("returns the `chrome.action` API if the extension manifest is for version 3", () => { + jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(3); + + const result = BrowserApi.getBrowserAction(); + + expect(result).toEqual(chrome.action); + }); + + it("returns the `chrome.browserAction` API if the extension manifest is for version 2", () => { + jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(2); + + const result = BrowserApi.getBrowserAction(); + + expect(result).toEqual(chrome.browserAction); + }); + }); + describe("executeScriptInTab", () => { it("calls to the extension api to execute a script within the give tabId", async () => { const tabId = 1; @@ -456,4 +524,30 @@ describe("BrowserApi", () => { }); }); }); + + describe("createOffscreenDocument", () => { + it("creates the offscreen document with the supplied reasons and justification", async () => { + const reasons = [chrome.offscreen.Reason.CLIPBOARD]; + const justification = "justification"; + + await BrowserApi.createOffscreenDocument(reasons, justification); + + expect(chrome.offscreen.createDocument).toHaveBeenCalledWith({ + url: "offscreen-document/index.html", + reasons, + justification, + }); + }); + }); + + describe("closeOffscreenDocument", () => { + it("closes the offscreen document", () => { + const callbackMock = jest.fn(); + + BrowserApi.closeOffscreenDocument(callbackMock); + + expect(chrome.offscreen.closeDocument).toHaveBeenCalled(); + expect(callbackMock).toHaveBeenCalled(); + }); + }); }); diff --git a/apps/browser/src/platform/browser/browser-api.ts b/apps/browser/src/platform/browser/browser-api.ts index b09f3e026644..5e490a99f4f0 100644 --- a/apps/browser/src/platform/browser/browser-api.ts +++ b/apps/browser/src/platform/browser/browser-api.ts @@ -19,6 +19,15 @@ export class BrowserApi { return chrome.runtime.getManifest().manifest_version; } + /** + * Determines if the extension manifest version is the given version. + * + * @param expectedVersion - The expected manifest version to check against. + */ + static isManifestVersion(expectedVersion: 2 | 3) { + return BrowserApi.manifestVersion === expectedVersion; + } + /** * Gets the current window or the window with the given id. * @@ -98,12 +107,17 @@ export class BrowserApi { }); } + /** + * Gets the tab with the given id. + * + * @param tabId - The id of the tab to get. + */ static async getTab(tabId: number): Promise | null { if (!tabId) { return null; } - if (BrowserApi.manifestVersion === 3) { + if (BrowserApi.isManifestVersion(3)) { return await chrome.tabs.get(tabId); } @@ -453,8 +467,11 @@ export class BrowserApi { }); } + /** + * Returns the supported BrowserAction API based on the manifest version. + */ static getBrowserAction() { - return BrowserApi.manifestVersion === 3 ? chrome.action : chrome.browserAction; + return BrowserApi.isManifestVersion(3) ? chrome.action : chrome.browserAction; } static getSidebarAction( @@ -488,7 +505,7 @@ export class BrowserApi { world: chrome.scripting.ExecutionWorld; }, ): Promise { - if (BrowserApi.manifestVersion === 3) { + if (BrowserApi.isManifestVersion(3)) { return chrome.scripting.executeScript({ target: { tabId: tabId, @@ -546,4 +563,32 @@ export class BrowserApi { chrome.privacy.services.autofillCreditCardEnabled.set({ value }); chrome.privacy.services.passwordSavingEnabled.set({ value }); } + + /** + * Opens the offscreen document with the given reasons and justification. + * + * @param reasons - List of reasons for opening the offscreen document. + * @see https://developer.chrome.com/docs/extensions/reference/api/offscreen#type-Reason + * @param justification - Custom written justification for opening the offscreen document. + */ + static async createOffscreenDocument(reasons: chrome.offscreen.Reason[], justification: string) { + await chrome.offscreen.createDocument({ + url: "offscreen-document/index.html", + reasons, + justification, + }); + } + + /** + * Closes the offscreen document. + * + * @param callback - Optional callback to execute after the offscreen document is closed. + */ + static closeOffscreenDocument(callback?: () => void) { + chrome.offscreen.closeDocument(() => { + if (callback) { + callback(); + } + }); + } } diff --git a/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.ts b/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.ts index a91798a9d0be..692e33bcce57 100644 --- a/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.ts +++ b/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.ts @@ -94,7 +94,7 @@ export class SessionSyncer { async update(serializedValue: any) { const unBuiltValue = JSON.parse(serializedValue); - if (BrowserApi.manifestVersion !== 3 && BrowserApi.isBackgroundPage(self)) { + if (!BrowserApi.isManifestVersion(3) && BrowserApi.isBackgroundPage(self)) { await this.memoryStorageService.save(this.metaData.sessionKey, serializedValue); } const builder = SyncedItemMetadata.builder(this.metaData); @@ -105,7 +105,7 @@ export class SessionSyncer { private async updateSession(value: any) { const serializedValue = JSON.stringify(value); - if (BrowserApi.manifestVersion === 3 || BrowserApi.isBackgroundPage(self)) { + if (BrowserApi.isManifestVersion(3) || BrowserApi.isBackgroundPage(self)) { await this.memoryStorageService.save(this.metaData.sessionKey, serializedValue); } await BrowserApi.sendMessage(this.updateMessageCommand, { id: this.id, serializedValue }); diff --git a/apps/browser/src/platform/globals.d.ts b/apps/browser/src/platform/globals.d.ts index 9e53d898027d..a45700188e41 100644 --- a/apps/browser/src/platform/globals.d.ts +++ b/apps/browser/src/platform/globals.d.ts @@ -117,6 +117,12 @@ interface Window { opera: unknown; } +interface ServiceWorkerGlobalScope { + chrome?: typeof chrome; + opr?: Opera | undefined; + opera?: unknown; +} + declare let opr: Opera | undefined; declare let opera: unknown | undefined; declare let safari: any; diff --git a/apps/browser/src/platform/offscreen-document/abstractions/offscreen-document.ts b/apps/browser/src/platform/offscreen-document/abstractions/offscreen-document.ts new file mode 100644 index 000000000000..e5aa8c86f507 --- /dev/null +++ b/apps/browser/src/platform/offscreen-document/abstractions/offscreen-document.ts @@ -0,0 +1,26 @@ +type OffscreenDocumentExtensionMessage = { + [key: string]: any; + command: string; + text?: string; +}; + +type OffscreenExtensionMessageEventParams = { + message: OffscreenDocumentExtensionMessage; + sender: chrome.runtime.MessageSender; +}; + +type OffscreenDocumentExtensionMessageHandlers = { + [key: string]: ({ message, sender }: OffscreenExtensionMessageEventParams) => any; + offscreenCopyToClipboard: ({ message }: OffscreenExtensionMessageEventParams) => any; + offscreenReadFromClipboard: () => any; +}; + +interface OffscreenDocument { + init(): void; +} + +export { + OffscreenDocumentExtensionMessage, + OffscreenDocumentExtensionMessageHandlers, + OffscreenDocument, +}; diff --git a/apps/browser/src/platform/offscreen-document/index.html b/apps/browser/src/platform/offscreen-document/index.html new file mode 100644 index 000000000000..78c828043e55 --- /dev/null +++ b/apps/browser/src/platform/offscreen-document/index.html @@ -0,0 +1,13 @@ + + + + + + + Bitwarden Offscreen Document + + + diff --git a/apps/browser/src/platform/offscreen-document/offscreen-document.spec.ts b/apps/browser/src/platform/offscreen-document/offscreen-document.spec.ts new file mode 100644 index 000000000000..1cbcc7a94c8d --- /dev/null +++ b/apps/browser/src/platform/offscreen-document/offscreen-document.spec.ts @@ -0,0 +1,62 @@ +import { flushPromises, sendExtensionRuntimeMessage } from "../../autofill/spec/testing-utils"; +import { BrowserApi } from "../browser/browser-api"; +import BrowserClipboardService from "../services/browser-clipboard.service"; + +describe("OffscreenDocument", () => { + const browserApiMessageListenerSpy = jest.spyOn(BrowserApi, "messageListener"); + const browserClipboardServiceCopySpy = jest.spyOn(BrowserClipboardService, "copy"); + const browserClipboardServiceReadSpy = jest.spyOn(BrowserClipboardService, "read"); + const consoleErrorSpy = jest.spyOn(console, "error"); + + require("../offscreen-document/offscreen-document"); + + describe("init", () => { + it("sets up a `chrome.runtime.onMessage` listener", () => { + expect(browserApiMessageListenerSpy).toHaveBeenCalledWith( + "offscreen-document", + expect.any(Function), + ); + }); + }); + + describe("extension message handlers", () => { + it("ignores messages that do not have a handler registered with the corresponding command", () => { + sendExtensionRuntimeMessage({ command: "notAValidCommand" }); + + expect(browserClipboardServiceCopySpy).not.toHaveBeenCalled(); + expect(browserClipboardServiceReadSpy).not.toHaveBeenCalled(); + }); + + it("shows a console message if the handler throws an error", async () => { + browserClipboardServiceCopySpy.mockRejectedValueOnce(new Error("test error")); + + sendExtensionRuntimeMessage({ command: "offscreenCopyToClipboard", text: "test" }); + await flushPromises(); + + expect(browserClipboardServiceCopySpy).toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Error resolving extension message response: Error: test error", + ); + }); + + describe("handleOffscreenCopyToClipboard", () => { + it("copies the message text", async () => { + const text = "test"; + + sendExtensionRuntimeMessage({ command: "offscreenCopyToClipboard", text }); + await flushPromises(); + + expect(browserClipboardServiceCopySpy).toHaveBeenCalledWith(window, text); + }); + }); + + describe("handleOffscreenReadFromClipboard", () => { + it("reads the value from the clipboard service", async () => { + sendExtensionRuntimeMessage({ command: "offscreenReadFromClipboard" }); + await flushPromises(); + + expect(browserClipboardServiceReadSpy).toHaveBeenCalledWith(window); + }); + }); + }); +}); diff --git a/apps/browser/src/platform/offscreen-document/offscreen-document.ts b/apps/browser/src/platform/offscreen-document/offscreen-document.ts new file mode 100644 index 000000000000..02ae5cdb236a --- /dev/null +++ b/apps/browser/src/platform/offscreen-document/offscreen-document.ts @@ -0,0 +1,83 @@ +import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; + +import { BrowserApi } from "../browser/browser-api"; +import BrowserClipboardService from "../services/browser-clipboard.service"; + +import { + OffscreenDocumentExtensionMessage, + OffscreenDocumentExtensionMessageHandlers, + OffscreenDocument as OffscreenDocumentInterface, +} from "./abstractions/offscreen-document"; + +class OffscreenDocument implements OffscreenDocumentInterface { + private consoleLogService: ConsoleLogService = new ConsoleLogService(false); + private readonly extensionMessageHandlers: OffscreenDocumentExtensionMessageHandlers = { + offscreenCopyToClipboard: ({ message }) => this.handleOffscreenCopyToClipboard(message), + offscreenReadFromClipboard: () => this.handleOffscreenReadFromClipboard(), + }; + + /** + * Initializes the offscreen document extension. + */ + init() { + this.setupExtensionMessageListener(); + } + + /** + * Copies the given text to the user's clipboard. + * + * @param message - The extension message containing the text to copy + */ + private async handleOffscreenCopyToClipboard(message: OffscreenDocumentExtensionMessage) { + await BrowserClipboardService.copy(window, message.text); + } + + /** + * Reads the user's clipboard and returns the text. + */ + private async handleOffscreenReadFromClipboard() { + return await BrowserClipboardService.read(window); + } + + /** + * Sets up the listener for extension messages. + */ + private setupExtensionMessageListener() { + BrowserApi.messageListener("offscreen-document", this.handleExtensionMessage); + } + + /** + * Handles extension messages sent to the extension background. + * + * @param message - The message received from the extension + * @param sender - The sender of the message + * @param sendResponse - The response to send back to the sender + */ + private handleExtensionMessage = ( + message: OffscreenDocumentExtensionMessage, + sender: chrome.runtime.MessageSender, + sendResponse: (response?: any) => void, + ) => { + const handler: CallableFunction | undefined = this.extensionMessageHandlers[message?.command]; + if (!handler) { + return; + } + + const messageResponse = handler({ message, sender }); + if (!messageResponse) { + return; + } + + Promise.resolve(messageResponse) + .then((response) => sendResponse(response)) + .catch((error) => + this.consoleLogService.error(`Error resolving extension message response: ${error}`), + ); + return true; + }; +} + +(() => { + const offscreenDocument = new OffscreenDocument(); + offscreenDocument.init(); +})(); diff --git a/apps/browser/src/platform/popup/browser-popup-utils.ts b/apps/browser/src/platform/popup/browser-popup-utils.ts index 8adc6b67cb88..2e087db83efa 100644 --- a/apps/browser/src/platform/popup/browser-popup-utils.ts +++ b/apps/browser/src/platform/popup/browser-popup-utils.ts @@ -93,7 +93,7 @@ class BrowserPopupUtils { * Identifies if the popup is loading in private mode. */ static inPrivateMode() { - return BrowserPopupUtils.backgroundInitializationRequired() && BrowserApi.manifestVersion !== 3; + return BrowserPopupUtils.backgroundInitializationRequired() && !BrowserApi.isManifestVersion(3); } /** diff --git a/apps/browser/src/platform/services/browser-clipboard.service.spec.ts b/apps/browser/src/platform/services/browser-clipboard.service.spec.ts new file mode 100644 index 000000000000..cf0d7c460041 --- /dev/null +++ b/apps/browser/src/platform/services/browser-clipboard.service.spec.ts @@ -0,0 +1,111 @@ +import BrowserClipboardService from "./browser-clipboard.service"; + +describe("BrowserClipboardService", () => { + let windowMock: any; + const consoleWarnSpy = jest.spyOn(console, "warn"); + + beforeEach(() => { + windowMock = { + navigator: { + clipboard: { + writeText: jest.fn(), + readText: jest.fn(), + }, + }, + document: { + body: { + appendChild: jest.fn((element) => document.body.appendChild(element)), + removeChild: jest.fn((element) => document.body.removeChild(element)), + }, + createElement: jest.fn((tagName) => document.createElement(tagName)), + execCommand: jest.fn(), + queryCommandSupported: jest.fn(), + }, + }; + }); + + describe("copy", () => { + it("uses the legacy copy method if the clipboard API is not available", async () => { + const text = "test"; + windowMock.navigator.clipboard = {}; + windowMock.document.queryCommandSupported.mockReturnValue(true); + + await BrowserClipboardService.copy(windowMock as Window, text); + + expect(windowMock.document.execCommand).toHaveBeenCalledWith("copy"); + }); + + it("uses the legacy copy method if the clipboard API throws an error", async () => { + windowMock.document.queryCommandSupported.mockReturnValue(true); + windowMock.navigator.clipboard.writeText.mockRejectedValue(new Error("test")); + + await BrowserClipboardService.copy(windowMock as Window, "test"); + + expect(windowMock.document.execCommand).toHaveBeenCalledWith("copy"); + }); + + it("copies the given text to the clipboard", async () => { + const text = "test"; + + await BrowserClipboardService.copy(windowMock as Window, text); + + expect(windowMock.navigator.clipboard.writeText).toHaveBeenCalledWith(text); + }); + + it("prints an warning message to the console if both the clipboard api and legacy method throw an error", async () => { + windowMock.document.queryCommandSupported.mockReturnValue(true); + windowMock.navigator.clipboard.writeText.mockRejectedValue(new Error("test")); + windowMock.document.execCommand.mockImplementation(() => { + throw new Error("test"); + }); + + await BrowserClipboardService.copy(windowMock as Window, ""); + + expect(consoleWarnSpy).toHaveBeenCalled(); + }); + }); + + describe("read", () => { + it("uses the legacy read method if the clipboard API is not available", async () => { + const testValue = "test"; + windowMock.navigator.clipboard = {}; + windowMock.document.queryCommandSupported.mockReturnValue(true); + windowMock.document.execCommand.mockImplementation(() => { + document.querySelector("textarea").value = testValue; + return true; + }); + + const returnValue = await BrowserClipboardService.read(windowMock as Window); + + expect(windowMock.document.execCommand).toHaveBeenCalledWith("paste"); + expect(returnValue).toBe(testValue); + }); + + it("uses the legacy read method if the clipboard API throws an error", async () => { + windowMock.document.queryCommandSupported.mockReturnValue(true); + windowMock.navigator.clipboard.readText.mockRejectedValue(new Error("test")); + + await BrowserClipboardService.read(windowMock as Window); + + expect(windowMock.document.execCommand).toHaveBeenCalledWith("paste"); + }); + + it("reads the text from the clipboard", async () => { + await BrowserClipboardService.read(windowMock as Window); + + expect(windowMock.navigator.clipboard.readText).toHaveBeenCalled(); + }); + + it("prints a warning message to the console if both the clipboard api and legacy method throw an error", async () => { + windowMock.document.queryCommandSupported.mockReturnValue(true); + windowMock.navigator.clipboard.readText.mockRejectedValue(new Error("test")); + windowMock.document.execCommand.mockImplementation(() => { + throw new Error("test"); + }); + + await BrowserClipboardService.read(windowMock as Window); + + expect(consoleWarnSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/browser/src/platform/services/browser-clipboard.service.ts b/apps/browser/src/platform/services/browser-clipboard.service.ts new file mode 100644 index 000000000000..8fb5a6d124a6 --- /dev/null +++ b/apps/browser/src/platform/services/browser-clipboard.service.ts @@ -0,0 +1,130 @@ +import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; + +class BrowserClipboardService { + private static consoleLogService: ConsoleLogService = new ConsoleLogService(false); + + /** + * Copies the given text to the user's clipboard. + * + * @param globalContext - The global window context. + * @param text - The text to copy. + */ + static async copy(globalContext: Window, text: string) { + if (!BrowserClipboardService.isClipboardApiSupported(globalContext, "writeText")) { + this.useLegacyCopyMethod(globalContext, text); + return; + } + + try { + await globalContext.navigator.clipboard.writeText(text); + } catch (error) { + BrowserClipboardService.consoleLogService.debug( + `Error copying to clipboard using the clipboard API, attempting legacy method: ${error}`, + ); + + this.useLegacyCopyMethod(globalContext, text); + } + } + + /** + * Reads the user's clipboard and returns the text. + * + * @param globalContext - The global window context. + */ + static async read(globalContext: Window): Promise { + if (!BrowserClipboardService.isClipboardApiSupported(globalContext, "readText")) { + return this.useLegacyReadMethod(globalContext); + } + + try { + return await globalContext.navigator.clipboard.readText(); + } catch (error) { + BrowserClipboardService.consoleLogService.debug( + `Error reading from clipboard using the clipboard API, attempting legacy method: ${error}`, + ); + + return this.useLegacyReadMethod(globalContext); + } + } + + /** + * Copies the given text to the user's clipboard using the legacy `execCommand` method. This + * method is used as a fallback when the clipboard API is not supported or fails. + * + * @param globalContext - The global window context. + * @param text - The text to copy. + */ + private static useLegacyCopyMethod(globalContext: Window, text: string) { + if (!BrowserClipboardService.isLegacyClipboardMethodSupported(globalContext, "copy")) { + BrowserClipboardService.consoleLogService.warning("Legacy copy method not supported"); + return; + } + + const textareaElement = globalContext.document.createElement("textarea"); + textareaElement.textContent = !text ? " " : text; + textareaElement.style.position = "fixed"; + globalContext.document.body.appendChild(textareaElement); + textareaElement.select(); + + try { + globalContext.document.execCommand("copy"); + } catch (error) { + BrowserClipboardService.consoleLogService.warning(`Error writing to clipboard: ${error}`); + } finally { + globalContext.document.body.removeChild(textareaElement); + } + } + + /** + * Reads the user's clipboard using the legacy `execCommand` method. This method is used as a + * fallback when the clipboard API is not supported or fails. + * + * @param globalContext - The global window context. + */ + private static useLegacyReadMethod(globalContext: Window): string { + if (!BrowserClipboardService.isLegacyClipboardMethodSupported(globalContext, "paste")) { + BrowserClipboardService.consoleLogService.warning("Legacy paste method not supported"); + return ""; + } + + const textareaElement = globalContext.document.createElement("textarea"); + textareaElement.style.position = "fixed"; + globalContext.document.body.appendChild(textareaElement); + textareaElement.focus(); + + try { + return globalContext.document.execCommand("paste") ? textareaElement.value : ""; + } catch (error) { + BrowserClipboardService.consoleLogService.warning(`Error reading from clipboard: ${error}`); + } finally { + globalContext.document.body.removeChild(textareaElement); + } + + return ""; + } + + /** + * Checks if the clipboard API is supported in the current environment. + * + * @param globalContext - The global window context. + * @param method - The clipboard API method to check for support. + */ + private static isClipboardApiSupported(globalContext: Window, method: "writeText" | "readText") { + return "clipboard" in globalContext.navigator && method in globalContext.navigator.clipboard; + } + + /** + * Checks if the legacy clipboard method is supported in the current environment. + * + * @param globalContext - The global window context. + * @param method - The legacy clipboard method to check for support. + */ + private static isLegacyClipboardMethodSupported(globalContext: Window, method: "copy" | "paste") { + return ( + "queryCommandSupported" in globalContext.document && + globalContext.document.queryCommandSupported(method) + ); + } +} + +export default BrowserClipboardService; diff --git a/apps/browser/src/platform/services/browser-platform-utils.service.spec.ts b/apps/browser/src/platform/services/browser-platform-utils.service.spec.ts index f9687d58662f..e70a641624b9 100644 --- a/apps/browser/src/platform/services/browser-platform-utils.service.spec.ts +++ b/apps/browser/src/platform/services/browser-platform-utils.service.spec.ts @@ -1,14 +1,24 @@ import { DeviceType } from "@bitwarden/common/enums"; +import { flushPromises } from "../../autofill/spec/testing-utils"; +import { SafariApp } from "../../browser/safariApp"; import { BrowserApi } from "../browser/browser-api"; +import BrowserClipboardService from "./browser-clipboard.service"; import BrowserPlatformUtilsService from "./browser-platform-utils.service"; describe("Browser Utils Service", () => { let browserPlatformUtilsService: BrowserPlatformUtilsService; + const clipboardWriteCallbackSpy = jest.fn(); + beforeEach(() => { (window as any).matchMedia = jest.fn().mockReturnValueOnce({}); - browserPlatformUtilsService = new BrowserPlatformUtilsService(null, null, null, window); + browserPlatformUtilsService = new BrowserPlatformUtilsService( + null, + clipboardWriteCallbackSpy, + null, + window, + ); }); describe("getBrowser", () => { @@ -26,7 +36,6 @@ describe("Browser Utils Service", () => { afterEach(() => { window.matchMedia = undefined; - (window as any).chrome = undefined; (BrowserPlatformUtilsService as any).deviceCache = null; }); @@ -37,8 +46,6 @@ describe("Browser Utils Service", () => { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36", }); - (window as any).chrome = {}; - expect(browserPlatformUtilsService.getDevice()).toBe(DeviceType.ChromeExtension); }); @@ -90,6 +97,29 @@ describe("Browser Utils Service", () => { expect(browserPlatformUtilsService.getDevice()).toBe(DeviceType.VivaldiExtension); }); + + it("returns a previously determined device using a cached value", () => { + Object.defineProperty(navigator, "userAgent", { + configurable: true, + value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:58.0) Gecko/20100101 Firefox/58.0", + }); + jest.spyOn(BrowserPlatformUtilsService, "isFirefox"); + + browserPlatformUtilsService.getDevice(); + + expect(browserPlatformUtilsService.getDevice()).toBe(DeviceType.FirefoxExtension); + expect(BrowserPlatformUtilsService.isFirefox).toHaveBeenCalledTimes(1); + }); + }); + + describe("getDeviceString", () => { + it("returns a string value indicating the device type", () => { + jest + .spyOn(browserPlatformUtilsService, "getDevice") + .mockReturnValue(DeviceType.ChromeExtension); + + expect(browserPlatformUtilsService.getDeviceString()).toBe("chrome"); + }); }); describe("isViewOpen", () => { @@ -113,6 +143,176 @@ describe("Browser Utils Service", () => { expect(isViewOpen).toBe(true); }); }); + + describe("copyToClipboard", () => { + const getManifestVersionSpy = jest.spyOn(BrowserApi, "manifestVersion", "get"); + const sendMessageToAppSpy = jest.spyOn(SafariApp, "sendMessageToApp"); + const clipboardServiceCopySpy = jest.spyOn(BrowserClipboardService, "copy"); + let triggerOffscreenCopyToClipboardSpy: jest.SpyInstance; + + beforeEach(() => { + getManifestVersionSpy.mockReturnValue(2); + triggerOffscreenCopyToClipboardSpy = jest.spyOn( + browserPlatformUtilsService as any, + "triggerOffscreenCopyToClipboard", + ); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("sends a copy to clipboard message to the desktop application if a user is using the safari browser", async () => { + const text = "test"; + const clearMs = 1000; + sendMessageToAppSpy.mockResolvedValueOnce("success"); + jest + .spyOn(browserPlatformUtilsService, "getDevice") + .mockReturnValue(DeviceType.SafariExtension); + + browserPlatformUtilsService.copyToClipboard(text, { clearMs }); + await flushPromises(); + + expect(sendMessageToAppSpy).toHaveBeenCalledWith("copyToClipboard", text); + expect(clipboardWriteCallbackSpy).toHaveBeenCalledWith(text, clearMs); + expect(clipboardServiceCopySpy).not.toHaveBeenCalled(); + expect(triggerOffscreenCopyToClipboardSpy).not.toHaveBeenCalled(); + }); + + it("sets the copied text to a unicode placeholder when the user is using Chrome if the passed text is an empty string", async () => { + const text = ""; + jest + .spyOn(browserPlatformUtilsService, "getDevice") + .mockReturnValue(DeviceType.ChromeExtension); + + browserPlatformUtilsService.copyToClipboard(text); + await flushPromises(); + + expect(clipboardServiceCopySpy).toHaveBeenCalledWith(window, "\u0000"); + }); + + it("copies the passed text using the BrowserClipboardService", async () => { + const text = "test"; + jest + .spyOn(browserPlatformUtilsService, "getDevice") + .mockReturnValue(DeviceType.ChromeExtension); + + browserPlatformUtilsService.copyToClipboard(text, { window: self }); + await flushPromises(); + + expect(clipboardServiceCopySpy).toHaveBeenCalledWith(self, text); + expect(triggerOffscreenCopyToClipboardSpy).not.toHaveBeenCalled(); + }); + + it("copies the passed text using the offscreen document if the extension is using manifest v3", async () => { + const text = "test"; + jest + .spyOn(browserPlatformUtilsService, "getDevice") + .mockReturnValue(DeviceType.ChromeExtension); + getManifestVersionSpy.mockReturnValue(3); + jest.spyOn(BrowserApi, "createOffscreenDocument"); + jest.spyOn(BrowserApi, "sendMessageWithResponse").mockResolvedValue(undefined); + jest.spyOn(BrowserApi, "closeOffscreenDocument"); + + browserPlatformUtilsService.copyToClipboard(text); + await flushPromises(); + + expect(triggerOffscreenCopyToClipboardSpy).toHaveBeenCalledWith(text); + expect(clipboardServiceCopySpy).not.toHaveBeenCalled(); + expect(BrowserApi.createOffscreenDocument).toHaveBeenCalledWith( + [chrome.offscreen.Reason.CLIPBOARD], + "Write text to the clipboard.", + ); + expect(BrowserApi.sendMessageWithResponse).toHaveBeenCalledWith("offscreenCopyToClipboard", { + text, + }); + expect(BrowserApi.closeOffscreenDocument).toHaveBeenCalled(); + }); + + it("skips the clipboardWriteCallback if the clipboard is clearing", async () => { + jest + .spyOn(browserPlatformUtilsService, "getDevice") + .mockReturnValue(DeviceType.ChromeExtension); + + browserPlatformUtilsService.copyToClipboard("test", { window: self, clearing: true }); + await flushPromises(); + + expect(clipboardWriteCallbackSpy).not.toHaveBeenCalled(); + }); + }); + + describe("readFromClipboard", () => { + const getManifestVersionSpy = jest.spyOn(BrowserApi, "manifestVersion", "get"); + const sendMessageToAppSpy = jest.spyOn(SafariApp, "sendMessageToApp"); + const clipboardServiceReadSpy = jest.spyOn(BrowserClipboardService, "read"); + + beforeEach(() => { + getManifestVersionSpy.mockReturnValue(2); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("sends a ready from clipboard message to the desktop application if a user is using the safari browser", async () => { + sendMessageToAppSpy.mockResolvedValueOnce("test"); + jest + .spyOn(browserPlatformUtilsService, "getDevice") + .mockReturnValue(DeviceType.SafariExtension); + + const result = await browserPlatformUtilsService.readFromClipboard(); + + expect(sendMessageToAppSpy).toHaveBeenCalledWith("readFromClipboard"); + expect(clipboardServiceReadSpy).not.toHaveBeenCalled(); + expect(result).toBe("test"); + }); + + it("reads text from the clipboard using the ClipboardService", async () => { + jest + .spyOn(browserPlatformUtilsService, "getDevice") + .mockReturnValue(DeviceType.ChromeExtension); + clipboardServiceReadSpy.mockResolvedValueOnce("test"); + + const result = await browserPlatformUtilsService.readFromClipboard({ window: self }); + + expect(clipboardServiceReadSpy).toHaveBeenCalledWith(self); + expect(sendMessageToAppSpy).not.toHaveBeenCalled(); + expect(result).toBe("test"); + }); + + it("reads the clipboard text using the offscreen document", async () => { + jest + .spyOn(browserPlatformUtilsService, "getDevice") + .mockReturnValue(DeviceType.ChromeExtension); + getManifestVersionSpy.mockReturnValue(3); + jest.spyOn(BrowserApi, "createOffscreenDocument"); + jest.spyOn(BrowserApi, "sendMessageWithResponse").mockResolvedValue("test"); + jest.spyOn(BrowserApi, "closeOffscreenDocument"); + + await browserPlatformUtilsService.readFromClipboard(); + + expect(BrowserApi.createOffscreenDocument).toHaveBeenCalledWith( + [chrome.offscreen.Reason.CLIPBOARD], + "Read text from the clipboard.", + ); + expect(BrowserApi.sendMessageWithResponse).toHaveBeenCalledWith("offscreenReadFromClipboard"); + expect(BrowserApi.closeOffscreenDocument).toHaveBeenCalled(); + }); + + it("returns an empty string from the offscreen document if the response is not of type string", async () => { + jest + .spyOn(browserPlatformUtilsService, "getDevice") + .mockReturnValue(DeviceType.ChromeExtension); + getManifestVersionSpy.mockReturnValue(3); + jest.spyOn(BrowserApi, "createOffscreenDocument"); + jest.spyOn(BrowserApi, "sendMessageWithResponse").mockResolvedValue(1); + jest.spyOn(BrowserApi, "closeOffscreenDocument"); + + const result = await browserPlatformUtilsService.readFromClipboard(); + + expect(result).toBe(""); + }); + }); }); describe("Safari Height Fix", () => { diff --git a/apps/browser/src/platform/services/browser-platform-utils.service.ts b/apps/browser/src/platform/services/browser-platform-utils.service.ts index c3ce8adda1dc..a9841db95868 100644 --- a/apps/browser/src/platform/services/browser-platform-utils.service.ts +++ b/apps/browser/src/platform/services/browser-platform-utils.service.ts @@ -1,10 +1,15 @@ import { ClientType, DeviceType } from "@bitwarden/common/enums"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { + ClipboardOptions, + PlatformUtilsService, +} from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SafariApp } from "../../browser/safariApp"; import { BrowserApi } from "../browser/browser-api"; +import BrowserClipboardService from "./browser-clipboard.service"; + export default class BrowserPlatformUtilsService implements PlatformUtilsService { private static deviceCache: DeviceType = null; @@ -12,25 +17,25 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService private messagingService: MessagingService, private clipboardWriteCallback: (clipboardValue: string, clearMs: number) => void, private biometricCallback: () => Promise, - private win: Window & typeof globalThis, + private globalContext: Window | ServiceWorkerGlobalScope, ) {} - static getDevice(win: Window & typeof globalThis): DeviceType { + static getDevice(globalContext: Window | ServiceWorkerGlobalScope): DeviceType { if (this.deviceCache) { return this.deviceCache; } if (BrowserPlatformUtilsService.isFirefox()) { this.deviceCache = DeviceType.FirefoxExtension; - } else if (BrowserPlatformUtilsService.isOpera(win)) { + } else if (BrowserPlatformUtilsService.isOpera(globalContext)) { this.deviceCache = DeviceType.OperaExtension; } else if (BrowserPlatformUtilsService.isEdge()) { this.deviceCache = DeviceType.EdgeExtension; } else if (BrowserPlatformUtilsService.isVivaldi()) { this.deviceCache = DeviceType.VivaldiExtension; - } else if (BrowserPlatformUtilsService.isChrome(win)) { + } else if (BrowserPlatformUtilsService.isChrome(globalContext)) { this.deviceCache = DeviceType.ChromeExtension; - } else if (BrowserPlatformUtilsService.isSafari(win)) { + } else if (BrowserPlatformUtilsService.isSafari(globalContext)) { this.deviceCache = DeviceType.SafariExtension; } @@ -38,7 +43,7 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService } getDevice(): DeviceType { - return BrowserPlatformUtilsService.getDevice(this.win); + return BrowserPlatformUtilsService.getDevice(this.globalContext); } getDeviceString(): string { @@ -67,8 +72,8 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService /** * @deprecated Do not call this directly, use getDevice() instead */ - private static isChrome(win: Window & typeof globalThis): boolean { - return win.chrome && navigator.userAgent.indexOf(" Chrome/") !== -1; + private static isChrome(globalContext: Window | ServiceWorkerGlobalScope): boolean { + return globalContext.chrome && navigator.userAgent.indexOf(" Chrome/") !== -1; } isChrome(): boolean { @@ -89,9 +94,11 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService /** * @deprecated Do not call this directly, use getDevice() instead */ - private static isOpera(win: Window & typeof globalThis): boolean { + private static isOpera(globalContext: Window | ServiceWorkerGlobalScope): boolean { return ( - (!!win.opr && !!win.opr.addons) || !!win.opera || navigator.userAgent.indexOf(" OPR/") >= 0 + !!globalContext.opr?.addons || + !!globalContext.opera || + navigator.userAgent.indexOf(" OPR/") >= 0 ); } @@ -113,10 +120,11 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService /** * @deprecated Do not call this directly, use getDevice() instead */ - static isSafari(win: Window & typeof globalThis): boolean { + static isSafari(globalContext: Window | ServiceWorkerGlobalScope): boolean { // Opera masquerades as Safari, so make sure we're not there first return ( - !BrowserPlatformUtilsService.isOpera(win) && navigator.userAgent.indexOf(" Safari/") !== -1 + !BrowserPlatformUtilsService.isOpera(globalContext) && + navigator.userAgent.indexOf(" Safari/") !== -1 ); } @@ -128,8 +136,8 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService * Safari previous to version 16.1 had a bug which caused artifacts on hover in large extension popups. * https://bugs.webkit.org/show_bug.cgi?id=218704 */ - static shouldApplySafariHeightFix(win: Window & typeof globalThis): boolean { - if (BrowserPlatformUtilsService.getDevice(win) !== DeviceType.SafariExtension) { + static shouldApplySafariHeightFix(globalContext: Window | ServiceWorkerGlobalScope): boolean { + if (BrowserPlatformUtilsService.getDevice(globalContext) !== DeviceType.SafariExtension) { return false; } @@ -207,99 +215,66 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService return false; } - copyToClipboard(text: string, options?: any): void { - let win = this.win; - let doc = this.win.document; - if (options && (options.window || options.win)) { - win = options.window || options.win; - doc = win.document; - } else if (options && options.doc) { - doc = options.doc; - } - const clearing = options ? !!options.clearing : false; - const clearMs: number = options && options.clearMs ? options.clearMs : null; + /** + * Copies the passed text to the clipboard. For Safari, this will use + * the native messaging API to send the text to the Bitwarden app. If + * the extension is using manifest v3, the offscreen document API will + * be used to copy the text to the clipboard. Otherwise, the browser's + * clipboard API will be used. + * + * @param text - The text to copy to the clipboard. + * @param options - Options for the clipboard operation. + */ + copyToClipboard(text: string, options?: ClipboardOptions): void { + const windowContext = options?.window || (this.globalContext as Window); + const clearing = Boolean(options?.clearing); + const clearMs: number = options?.clearMs || null; + const handleClipboardWriteCallback = () => { + if (!clearing && this.clipboardWriteCallback != null) { + this.clipboardWriteCallback(text, clearMs); + } + }; if (this.isSafari()) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - SafariApp.sendMessageToApp("copyToClipboard", text).then(() => { - if (!clearing && this.clipboardWriteCallback != null) { - this.clipboardWriteCallback(text, clearMs); - } - }); - } else if ( - this.isFirefox() && - (win as any).navigator.clipboard && - (win as any).navigator.clipboard.writeText - ) { - (win as any).navigator.clipboard.writeText(text).then(() => { - if (!clearing && this.clipboardWriteCallback != null) { - this.clipboardWriteCallback(text, clearMs); - } - }); - } else if (doc.queryCommandSupported && doc.queryCommandSupported("copy")) { - if (this.isChrome() && text === "") { - text = "\u0000"; - } + void SafariApp.sendMessageToApp("copyToClipboard", text).then(handleClipboardWriteCallback); - const textarea = doc.createElement("textarea"); - textarea.textContent = text == null || text === "" ? " " : text; - // Prevent scrolling to bottom of page in MS Edge. - textarea.style.position = "fixed"; - doc.body.appendChild(textarea); - textarea.select(); - - try { - // Security exception may be thrown by some browsers. - if (doc.execCommand("copy") && !clearing && this.clipboardWriteCallback != null) { - this.clipboardWriteCallback(text, clearMs); - } - } catch (e) { - // eslint-disable-next-line - console.warn("Copy to clipboard failed.", e); - } finally { - doc.body.removeChild(textarea); - } + return; } - } - async readFromClipboard(options?: any): Promise { - let win = this.win; - let doc = this.win.document; - if (options && (options.window || options.win)) { - win = options.window || options.win; - doc = win.document; - } else if (options && options.doc) { - doc = options.doc; + if (this.isChrome() && text === "") { + text = "\u0000"; } + if (this.isChrome() && BrowserApi.isManifestVersion(3)) { + void this.triggerOffscreenCopyToClipboard(text).then(handleClipboardWriteCallback); + + return; + } + + void BrowserClipboardService.copy(windowContext, text).then(handleClipboardWriteCallback); + } + + /** + * Reads the text from the clipboard. For Safari, this will use the + * native messaging API to request the text from the Bitwarden app. If + * the extension is using manifest v3, the offscreen document API will + * be used to read the text from the clipboard. Otherwise, the browser's + * clipboard API will be used. + * + * @param options - Options for the clipboard operation. + */ + async readFromClipboard(options?: ClipboardOptions): Promise { + const windowContext = options?.window || (this.globalContext as Window); + if (this.isSafari()) { return await SafariApp.sendMessageToApp("readFromClipboard"); - } else if ( - this.isFirefox() && - (win as any).navigator.clipboard && - (win as any).navigator.clipboard.readText - ) { - return await (win as any).navigator.clipboard.readText(); - } else if (doc.queryCommandSupported && doc.queryCommandSupported("paste")) { - const textarea = doc.createElement("textarea"); - // Prevent scrolling to bottom of page in MS Edge. - textarea.style.position = "fixed"; - doc.body.appendChild(textarea); - textarea.focus(); - try { - // Security exception may be thrown by some browsers. - if (doc.execCommand("paste")) { - return textarea.value; - } - } catch (e) { - // eslint-disable-next-line - console.warn("Read from clipboard failed.", e); - } finally { - doc.body.removeChild(textarea); - } } - return null; + + if (this.isChrome() && BrowserApi.isManifestVersion(3)) { + return await this.triggerOffscreenReadFromClipboard(); + } + + return await BrowserClipboardService.read(windowContext); } async supportsBiometric() { @@ -345,4 +320,33 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService } return autofillCommand; } + + /** + * Triggers the offscreen document API to copy the text to the clipboard. + */ + private async triggerOffscreenCopyToClipboard(text: string) { + await BrowserApi.createOffscreenDocument( + [chrome.offscreen.Reason.CLIPBOARD], + "Write text to the clipboard.", + ); + await BrowserApi.sendMessageWithResponse("offscreenCopyToClipboard", { text }); + BrowserApi.closeOffscreenDocument(); + } + + /** + * Triggers the offscreen document API to read the text from the clipboard. + */ + private async triggerOffscreenReadFromClipboard() { + await BrowserApi.createOffscreenDocument( + [chrome.offscreen.Reason.CLIPBOARD], + "Read text from the clipboard.", + ); + const response = await BrowserApi.sendMessageWithResponse("offscreenReadFromClipboard"); + BrowserApi.closeOffscreenDocument(); + if (typeof response === "string") { + return response; + } + + return ""; + } } diff --git a/apps/browser/test.setup.ts b/apps/browser/test.setup.ts index 88725b14ce69..1031268186e4 100644 --- a/apps/browser/test.setup.ts +++ b/apps/browser/test.setup.ts @@ -44,6 +44,7 @@ const i18n = { }; const tabs = { + get: jest.fn(), executeScript: jest.fn(), sendMessage: jest.fn(), query: jest.fn(), @@ -111,6 +112,18 @@ const extension = { getViews: jest.fn(), }; +const offscreen = { + createDocument: jest.fn(), + closeDocument: jest.fn((callback) => { + if (callback) { + callback(); + } + }), + Reason: { + CLIPBOARD: "clipboard", + }, +}; + // set chrome global.chrome = { i18n, @@ -123,4 +136,5 @@ global.chrome = { port, privacy, extension, + offscreen, } as any; diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index b9c482eaf3e5..3f712d9c1083 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -286,6 +286,16 @@ if (manifestVersion == 2) { // Manifest v3 needs an extra helper for utilities in the content script. // The javascript output of this should be added to manifest.v3.json mainConfig.entry["content/misc-utils"] = "./src/autofill/content/misc-utils.ts"; + mainConfig.entry["offscreen-document/offscreen-document"] = + "./src/platform/offscreen-document/offscreen-document.ts"; + + mainConfig.plugins.push( + new HtmlWebpackPlugin({ + template: "./src/platform/offscreen-document/index.html", + filename: "offscreen-document/index.html", + chunks: ["offscreen-document/offscreen-document"], + }), + ); /** * @type {import("webpack").Configuration}