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

[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
This commit is contained in:
Cesar Gonzalez 2024-03-06 10:33:38 -06:00 committed by GitHub
parent 940fd21ac4
commit 51f482dde9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 936 additions and 139 deletions

View File

@ -1259,7 +1259,7 @@ describe("OverlayBackground", () => {
});
await flushPromises();
expect(copyToClipboardSpy).toHaveBeenCalledWith("totp-code", { window });
expect(copyToClipboardSpy).toHaveBeenCalledWith("totp-code");
});
});

View File

@ -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]);

View File

@ -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();

View File

@ -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);
}

View File

@ -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,15 +353,13 @@ export default class MainBackground {
this.keyGenerationService = new KeyGenerationService(this.cryptoFunctionService);
this.storageService = new BrowserLocalStorageService();
this.secureStorageService = new BrowserLocalStorageService();
this.memoryStorageService =
BrowserApi.manifestVersion === 3
this.memoryStorageService = BrowserApi.isManifestVersion(3)
? new LocalBackedSessionStorageService(
new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, false),
this.keyGenerationService,
)
: new MemoryStorageService();
this.memoryStorageForStateProviders =
BrowserApi.manifestVersion === 3
this.memoryStorageForStateProviders = BrowserApi.isManifestVersion(3)
? new LocalBackedSessionStorageService(
new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, false),
this.keyGenerationService,
@ -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");

View File

@ -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

View File

@ -72,7 +72,8 @@
"clipboardWrite",
"idle",
"alarms",
"scripting"
"scripting",
"offscreen"
],
"optional_permissions": ["nativeMessaging", "privacy"],
"host_permissions": ["http://*/*", "https://*/*"],

View File

@ -22,7 +22,7 @@ const alarmState: AlarmState = {
*/
export async function getAlarmTime(commandName: AlarmKeys): Promise<number> {
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<void> {
}
async function setAlarmTimeInternal(commandName: AlarmKeys, time: number): Promise<void> {
if (BrowserApi.manifestVersion == 3) {
if (BrowserApi.isManifestVersion(3)) {
await chrome.storage.session.set({ [commandName]: time });
} else {
alarmState[commandName] = time;

View File

@ -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);

View File

@ -52,7 +52,7 @@ export function memoryStorageServiceFactory(
opts: MemoryStorageServiceInitOptions,
): Promise<AbstractMemoryStorageService> {
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),

View File

@ -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();
});
});
});

View File

@ -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<chrome.tabs.Tab> | 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<unknown> {
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();
}
});
}
}

View File

@ -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 });

View File

@ -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;

View File

@ -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,
};

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
/>
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Bitwarden Offscreen Document</title>
</head>
<body></body>
</html>

View File

@ -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);
});
});
});
});

View File

@ -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();
})();

View File

@ -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);
}
/**

View File

@ -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();
});
});
});

View File

@ -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<string> {
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;

View File

@ -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", () => {

View File

@ -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<boolean>,
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;
/**
* 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);
}
const clearing = options ? !!options.clearing : false;
const clearMs: number = options && options.clearMs ? options.clearMs : null;
};
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);
void SafariApp.sendMessageToApp("copyToClipboard", text).then(handleClipboardWriteCallback);
return;
}
});
} 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";
}
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();
if (this.isChrome() && BrowserApi.isManifestVersion(3)) {
void this.triggerOffscreenCopyToClipboard(text).then(handleClipboardWriteCallback);
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<string> {
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;
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<string> {
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);
if (this.isChrome() && BrowserApi.isManifestVersion(3)) {
return await this.triggerOffscreenReadFromClipboard();
}
}
return null;
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 "";
}
}

View File

@ -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;

View File

@ -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}