mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-21 11:35:34 +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:
parent
940fd21ac4
commit
51f482dde9
@ -1259,7 +1259,7 @@ describe("OverlayBackground", () => {
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(copyToClipboardSpy).toHaveBeenCalledWith("totp-code", { window });
|
||||
expect(copyToClipboardSpy).toHaveBeenCalledWith("totp-code");
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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]);
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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");
|
||||
|
@ -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
|
||||
|
@ -72,7 +72,8 @@
|
||||
"clipboardWrite",
|
||||
"idle",
|
||||
"alarms",
|
||||
"scripting"
|
||||
"scripting",
|
||||
"offscreen"
|
||||
],
|
||||
"optional_permissions": ["nativeMessaging", "privacy"],
|
||||
"host_permissions": ["http://*/*", "https://*/*"],
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|
@ -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),
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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 });
|
||||
|
6
apps/browser/src/platform/globals.d.ts
vendored
6
apps/browser/src/platform/globals.d.ts
vendored
@ -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;
|
||||
|
@ -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,
|
||||
};
|
13
apps/browser/src/platform/offscreen-document/index.html
Normal file
13
apps/browser/src/platform/offscreen-document/index.html
Normal 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>
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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();
|
||||
})();
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
130
apps/browser/src/platform/services/browser-clipboard.service.ts
Normal file
130
apps/browser/src/platform/services/browser-clipboard.service.ts
Normal 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;
|
@ -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", () => {
|
||||
|
@ -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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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}
|
||||
|
Loading…
Reference in New Issue
Block a user