diff --git a/apps/desktop/resources/entitlements.mas.autofill-enabled.plist b/apps/desktop/resources/entitlements.mas.autofill-enabled.plist
index 9b6f39457295..2a8da2f273fd 100644
--- a/apps/desktop/resources/entitlements.mas.autofill-enabled.plist
+++ b/apps/desktop/resources/entitlements.mas.autofill-enabled.plist
@@ -24,6 +24,7 @@
com.apple.security.temporary-exception.files.home-relative-path.read-write
+ /Library/Application Support/Mozilla
/Library/Application Support/Mozilla/NativeMessagingHosts/
/Library/Application Support/Google/Chrome/NativeMessagingHosts/
/Library/Application Support/Google/Chrome Beta/NativeMessagingHosts/
diff --git a/apps/desktop/resources/entitlements.mas.plist b/apps/desktop/resources/entitlements.mas.plist
index 152c075bdced..7f554f143869 100644
--- a/apps/desktop/resources/entitlements.mas.plist
+++ b/apps/desktop/resources/entitlements.mas.plist
@@ -24,6 +24,7 @@
com.apple.security.temporary-exception.files.home-relative-path.read-write
+ /Library/Application Support/Mozilla
/Library/Application Support/Mozilla/NativeMessagingHosts/
/Library/Application Support/Google/Chrome/NativeMessagingHosts/
/Library/Application Support/Google/Chrome Beta/NativeMessagingHosts/
diff --git a/apps/desktop/src/main/native-messaging.main.spec.ts b/apps/desktop/src/main/native-messaging.main.spec.ts
new file mode 100644
index 000000000000..7794dbae0bc6
--- /dev/null
+++ b/apps/desktop/src/main/native-messaging.main.spec.ts
@@ -0,0 +1,176 @@
+import { existsSync, promises as fs } from "fs";
+
+import { mock, MockProxy } from "jest-mock-extended";
+
+import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
+
+import { NativeMessagingMain } from "./native-messaging.main";
+import { WindowMain } from "./window.main";
+
+jest.mock("electron", () => ({
+ ipcMain: { handle: jest.fn(), on: jest.fn() },
+}));
+
+jest.mock("@bitwarden/desktop-napi", () => ({
+ ipc: { NativeIpcServer: { listen: jest.fn() } },
+ windows_registry: { createKey: jest.fn(), deleteKey: jest.fn() },
+}));
+
+jest.mock("fs", () => ({
+ ...jest.requireActual("fs"),
+ existsSync: jest.fn(),
+ promises: {
+ ...jest.requireActual("fs").promises,
+ mkdir: jest.fn(),
+ writeFile: jest.fn(),
+ unlink: jest.fn(),
+ },
+}));
+
+jest.mock("os", () => ({
+ ...jest.requireActual("os"),
+ homedir: jest.fn(() => "/Users/test"),
+ userInfo: jest.fn(() => ({ homedir: "/Users/test", username: "test" })),
+}));
+
+jest.mock("../utils", () => ({ isDev: () => false }));
+
+describe("NativeMessagingMain", () => {
+ const APP_SUPPORT = "/Users/test/Library/Application Support";
+ const EXE_PATH = "/Applications/Bitwarden.app/Contents/MacOS/Bitwarden";
+ const BINARY_PATH = "/Applications/Bitwarden.app/Contents/MacOS/desktop_proxy";
+
+ const FIREFOX_PROFILES = `${APP_SUPPORT}/Firefox/`;
+ const MOZILLA = `${APP_SUPPORT}/Mozilla/`;
+ const CHROME = `${APP_SUPPORT}/Google/Chrome/`;
+
+ const FIREFOX_MANIFEST = `${APP_SUPPORT}/Mozilla/NativeMessagingHosts/com.8bit.bitwarden.json`;
+ const CHROME_MANIFEST = `${APP_SUPPORT}/Google/Chrome/NativeMessagingHosts/com.8bit.bitwarden.json`;
+
+ let logService: MockProxy;
+ let sut: NativeMessagingMain;
+ let originalPlatform: PropertyDescriptor;
+
+ const givenOnDisk = (paths: string[]) => {
+ const present = new Set([BINARY_PATH, ...paths]);
+ (existsSync as jest.Mock).mockImplementation((candidate: string) => present.has(candidate));
+ };
+
+ const manifestWrittenTo = (destination: string) => {
+ const call = (fs.writeFile as jest.Mock).mock.calls.find(([target]) => target === destination);
+ return call == null ? undefined : JSON.parse(call[1]);
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
+ Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
+
+ (fs.mkdir as jest.Mock).mockResolvedValue(undefined);
+ (fs.writeFile as jest.Mock).mockResolvedValue(undefined);
+ (fs.unlink as jest.Mock).mockResolvedValue(undefined);
+
+ logService = mock();
+ sut = new NativeMessagingMain(
+ logService,
+ mock(),
+ "/user/path",
+ EXE_PATH,
+ "/app/path",
+ );
+ });
+
+ afterEach(() => {
+ Object.defineProperty(process, "platform", originalPlatform);
+ });
+
+ describe("generateManifests on macOS", () => {
+ it("installs the Firefox manifest when only the Firefox profile directory exists", async () => {
+ givenOnDisk([FIREFOX_PROFILES]);
+
+ await sut.generateManifests();
+
+ expect(fs.mkdir).toHaveBeenCalledWith(`${APP_SUPPORT}/Mozilla/NativeMessagingHosts`, {
+ recursive: true,
+ });
+ expect(manifestWrittenTo(FIREFOX_MANIFEST)).toEqual(
+ expect.objectContaining({
+ name: "com.8bit.bitwarden",
+ path: BINARY_PATH,
+ allowed_extensions: ["{446900e4-71c2-419f-a6a7-df9c091e268b}"],
+ }),
+ );
+ });
+
+ it("still installs the Firefox manifest when only Mozilla/ exists", async () => {
+ givenOnDisk([MOZILLA]);
+
+ await sut.generateManifests();
+
+ expect(manifestWrittenTo(FIREFOX_MANIFEST)).toBeDefined();
+ });
+
+ it("skips Firefox when neither the profile directory nor Mozilla/ exists", async () => {
+ givenOnDisk([CHROME]);
+
+ await sut.generateManifests();
+
+ expect(manifestWrittenTo(FIREFOX_MANIFEST)).toBeUndefined();
+ expect(logService.warning).toHaveBeenCalledWith("Firefox not found, skipping.");
+ });
+
+ it("installs a Chrome-flavoured manifest for chromium browsers", async () => {
+ givenOnDisk([CHROME]);
+
+ await sut.generateManifests();
+
+ expect(manifestWrittenTo(CHROME_MANIFEST)).toEqual(
+ expect.objectContaining({
+ allowed_origins: expect.arrayContaining([
+ "chrome-extension://nngceckbapebfimnlniiiahkandclblb/",
+ ]),
+ }),
+ );
+ });
+
+ it("still installs the other browsers when the Mozilla directory cannot be created", async () => {
+ givenOnDisk([FIREFOX_PROFILES, CHROME]);
+ (fs.mkdir as jest.Mock).mockImplementation((directory: string) =>
+ directory.includes("Mozilla")
+ ? Promise.reject(new Error("EPERM: operation not permitted"))
+ : Promise.resolve(undefined),
+ );
+
+ await expect(sut.generateManifests()).resolves.toBeUndefined();
+
+ expect(manifestWrittenTo(FIREFOX_MANIFEST)).toBeUndefined();
+ expect(manifestWrittenTo(CHROME_MANIFEST)).toBeDefined();
+ expect(logService.error).toHaveBeenCalledWith(expect.stringContaining("Firefox"));
+ });
+
+ it("still installs the other browsers when the Firefox manifest cannot be written", async () => {
+ givenOnDisk([FIREFOX_PROFILES, CHROME]);
+ (fs.writeFile as jest.Mock).mockImplementation((target: string) =>
+ target === FIREFOX_MANIFEST
+ ? Promise.reject(new Error("EACCES: permission denied"))
+ : Promise.resolve(undefined),
+ );
+
+ await expect(sut.generateManifests()).resolves.toBeUndefined();
+
+ expect(manifestWrittenTo(CHROME_MANIFEST)).toBeDefined();
+ expect(logService.error).toHaveBeenCalledWith(expect.stringContaining("Firefox"));
+ });
+ });
+
+ describe("removeManifests on macOS", () => {
+ it("removes the Firefox manifest from Mozilla/NativeMessagingHosts", async () => {
+ givenOnDisk([FIREFOX_MANIFEST]);
+
+ await sut.removeManifests();
+
+ expect(fs.unlink).toHaveBeenCalledWith(FIREFOX_MANIFEST);
+ });
+ });
+});
diff --git a/apps/desktop/src/main/native-messaging.main.ts b/apps/desktop/src/main/native-messaging.main.ts
index 0fb1e75b8b7f..838646b9b093 100644
--- a/apps/desktop/src/main/native-messaging.main.ts
+++ b/apps/desktop/src/main/native-messaging.main.ts
@@ -191,18 +191,25 @@ export class NativeMessagingMain {
case "darwin": {
const nmhs = this.getDarwinNMHS();
for (const [key, browserDirectory] of Object.entries(nmhs)) {
- if (existsSync(browserDirectory)) {
+ if (
+ this.getDarwinDetectPaths(key, browserDirectory).some((directory) =>
+ existsSync(directory),
+ )
+ ) {
const nmhsPath = path.join(browserDirectory, "NativeMessagingHosts");
const manifestPath = path.join(nmhsPath, "com.8bit.bitwarden.json");
- let manifest: any = await this.generateChromeJson(binaryPath);
- if (key === "Firefox" || key === "Zen") {
- // Only generate the NMHS dir if the browser directory exists
- await fs.mkdir(nmhsPath, { recursive: true });
- manifest = await this.generateFirefoxJson(binaryPath);
- }
+ try {
+ let manifest: any = await this.generateChromeJson(binaryPath);
+ if (key === "Firefox" || key === "Zen") {
+ await fs.mkdir(nmhsPath, { recursive: true });
+ manifest = await this.generateFirefoxJson(binaryPath);
+ }
- await this.writeManifest(manifestPath, manifest);
+ await this.writeManifest(manifestPath, manifest);
+ } catch (e) {
+ this.logService.error(`Unable to install the ${key} manifest in ${nmhsPath}: ${e}`);
+ }
} else {
this.logService.warning(`${key} not found, skipping.`);
}
@@ -416,6 +423,14 @@ export class NativeMessagingMain {
/* eslint-enable no-useless-escape */
}
+ private getDarwinDetectPaths(browser: string, browserDirectory: string): string[] {
+ if (browser === "Firefox") {
+ return [`${this.homedir()}/Library/Application Support/Firefox/`, browserDirectory];
+ }
+
+ return [browserDirectory];
+ }
+
private getLinuxNMHS() {
return {
Firefox: `${this.homedir()}/.mozilla/`,