- {{ "sendLinuxChromiumFileWarning" | i18n }}
- {{ "sendFirefoxFileWarning" | i18n }}
- {{ "sendSafariFileWarning" | i18n }}
-
diff --git a/apps/browser/src/tools/popup/components/file-popout-callout.component.ts b/apps/browser/src/tools/popup/components/file-popout-callout.component.ts
deleted file mode 100644
index 33044b79351..00000000000
--- a/apps/browser/src/tools/popup/components/file-popout-callout.component.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { CommonModule } from "@angular/common";
-import { Component, OnInit } from "@angular/core";
-
-import { JslibModule } from "@bitwarden/angular/jslib.module";
-import { CalloutModule } from "@bitwarden/components";
-
-import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
-import { FilePopoutUtilsService } from "../services/file-popout-utils.service";
-
-// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
-// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
-@Component({
- selector: "tools-file-popout-callout",
- templateUrl: "file-popout-callout.component.html",
- imports: [CommonModule, JslibModule, CalloutModule],
-})
-export class FilePopoutCalloutComponent implements OnInit {
- protected showFilePopoutMessage: boolean = false;
- protected showFirefoxFileWarning: boolean = false;
- protected showSafariFileWarning: boolean = false;
- protected showChromiumFileWarning: boolean = false;
-
- constructor(private filePopoutUtilsService: FilePopoutUtilsService) {}
-
- ngOnInit() {
- this.showFilePopoutMessage = this.filePopoutUtilsService.showFilePopoutMessage(window);
- this.showFirefoxFileWarning = this.filePopoutUtilsService.showFirefoxFileWarning(window);
- this.showSafariFileWarning = this.filePopoutUtilsService.showSafariFileWarning(window);
- this.showChromiumFileWarning = this.filePopoutUtilsService.showChromiumFileWarning(window);
- }
-
- popOutWindow() {
- // 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
- BrowserPopupUtils.openCurrentPagePopout(window);
- }
-}
diff --git a/apps/browser/src/tools/popup/guards/file-picker-popout.guard.spec.ts b/apps/browser/src/tools/popup/guards/file-picker-popout.guard.spec.ts
new file mode 100644
index 00000000000..14fa300c3c1
--- /dev/null
+++ b/apps/browser/src/tools/popup/guards/file-picker-popout.guard.spec.ts
@@ -0,0 +1,380 @@
+import { TestBed } from "@angular/core/testing";
+import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router";
+
+import { DeviceType } from "@bitwarden/common/enums";
+
+import { BrowserApi } from "../../../platform/browser/browser-api";
+import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
+import { BrowserPlatformUtilsService } from "../../../platform/services/platform-utils/browser-platform-utils.service";
+
+import { filePickerPopoutGuard } from "./file-picker-popout.guard";
+
+describe("filePickerPopoutGuard", () => {
+ let getDeviceSpy: jest.SpyInstance;
+ let inPopoutSpy: jest.SpyInstance;
+ let inSidebarSpy: jest.SpyInstance;
+ let openPopoutSpy: jest.SpyInstance;
+ let closePopupSpy: jest.SpyInstance;
+
+ const mockRoute = {} as ActivatedRouteSnapshot;
+ const mockState: RouterStateSnapshot = {
+ url: "/add-send?type=1",
+ } as RouterStateSnapshot;
+
+ beforeEach(() => {
+ getDeviceSpy = jest.spyOn(BrowserPlatformUtilsService, "getDevice");
+ inPopoutSpy = jest.spyOn(BrowserPopupUtils, "inPopout");
+ inSidebarSpy = jest.spyOn(BrowserPopupUtils, "inSidebar");
+ openPopoutSpy = jest.spyOn(BrowserPopupUtils, "openPopout").mockImplementation();
+ closePopupSpy = jest.spyOn(BrowserApi, "closePopup").mockImplementation();
+
+ TestBed.configureTestingModule({});
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("Firefox browser", () => {
+ beforeEach(() => {
+ getDeviceSpy.mockReturnValue(DeviceType.FirefoxExtension);
+ inPopoutSpy.mockReturnValue(false);
+ inSidebarSpy.mockReturnValue(false);
+ });
+
+ it("should open popout and block navigation when not in popout or sidebar", async () => {
+ const guard = filePickerPopoutGuard();
+ const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
+
+ expect(getDeviceSpy).toHaveBeenCalledWith(window);
+ expect(inPopoutSpy).toHaveBeenCalledWith(window);
+ expect(inSidebarSpy).toHaveBeenCalledWith(window);
+ expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=1");
+ expect(closePopupSpy).toHaveBeenCalledWith(window);
+ expect(result).toBe(false);
+ });
+
+ it("should allow navigation when already in popout", async () => {
+ inPopoutSpy.mockReturnValue(true);
+
+ const guard = filePickerPopoutGuard();
+ const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
+
+ expect(openPopoutSpy).not.toHaveBeenCalled();
+ expect(closePopupSpy).not.toHaveBeenCalled();
+ expect(result).toBe(true);
+ });
+
+ it("should allow navigation when already in sidebar", async () => {
+ inSidebarSpy.mockReturnValue(true);
+
+ const guard = filePickerPopoutGuard();
+ const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
+
+ expect(openPopoutSpy).not.toHaveBeenCalled();
+ expect(closePopupSpy).not.toHaveBeenCalled();
+ expect(result).toBe(true);
+ });
+ });
+
+ describe("Safari browser", () => {
+ beforeEach(() => {
+ getDeviceSpy.mockReturnValue(DeviceType.SafariExtension);
+ inPopoutSpy.mockReturnValue(false);
+ inSidebarSpy.mockReturnValue(false);
+ });
+
+ it("should open popout and block navigation when not in popout", async () => {
+ const guard = filePickerPopoutGuard();
+ const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
+
+ expect(getDeviceSpy).toHaveBeenCalledWith(window);
+ expect(inPopoutSpy).toHaveBeenCalledWith(window);
+ expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=1");
+ expect(closePopupSpy).toHaveBeenCalledWith(window);
+ expect(result).toBe(false);
+ });
+
+ it("should allow navigation when already in popout", async () => {
+ inPopoutSpy.mockReturnValue(true);
+
+ const guard = filePickerPopoutGuard();
+ const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
+
+ expect(openPopoutSpy).not.toHaveBeenCalled();
+ expect(closePopupSpy).not.toHaveBeenCalled();
+ expect(result).toBe(true);
+ });
+
+ it("should not allow sidebar bypass (Safari doesn't support sidebar)", async () => {
+ inSidebarSpy.mockReturnValue(true);
+ inPopoutSpy.mockReturnValue(false);
+
+ const guard = filePickerPopoutGuard();
+ const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
+
+ // Safari requires popout, sidebar is not sufficient
+ expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=1");
+ expect(closePopupSpy).toHaveBeenCalledWith(window);
+ expect(result).toBe(false);
+ });
+ });
+
+ describe("Chromium browsers on Linux", () => {
+ beforeEach(() => {
+ inPopoutSpy.mockReturnValue(false);
+ inSidebarSpy.mockReturnValue(false);
+ Object.defineProperty(window, "navigator", {
+ value: {
+ userAgent: "Mozilla/5.0 (X11; Linux x86_64)",
+ appVersion: "5.0 (X11; Linux x86_64)",
+ },
+ configurable: true,
+ writable: true,
+ });
+ });
+
+ it.each([
+ { deviceType: DeviceType.ChromeExtension, name: "Chrome" },
+ { deviceType: DeviceType.EdgeExtension, name: "Edge" },
+ { deviceType: DeviceType.OperaExtension, name: "Opera" },
+ { deviceType: DeviceType.VivaldiExtension, name: "Vivaldi" },
+ ])(
+ "should open popout and block navigation for $name on Linux when not in popout or sidebar",
+ async ({ deviceType }) => {
+ getDeviceSpy.mockReturnValue(deviceType);
+
+ const guard = filePickerPopoutGuard();
+ const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
+
+ expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=1");
+ expect(closePopupSpy).toHaveBeenCalledWith(window);
+ expect(result).toBe(false);
+ },
+ );
+
+ it("should allow navigation when in popout", async () => {
+ getDeviceSpy.mockReturnValue(DeviceType.ChromeExtension);
+ inPopoutSpy.mockReturnValue(true);
+
+ const guard = filePickerPopoutGuard();
+ const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
+
+ expect(openPopoutSpy).not.toHaveBeenCalled();
+ expect(closePopupSpy).not.toHaveBeenCalled();
+ expect(result).toBe(true);
+ });
+
+ it("should allow navigation when in sidebar", async () => {
+ getDeviceSpy.mockReturnValue(DeviceType.ChromeExtension);
+ inSidebarSpy.mockReturnValue(true);
+
+ const guard = filePickerPopoutGuard();
+ const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
+
+ expect(openPopoutSpy).not.toHaveBeenCalled();
+ expect(closePopupSpy).not.toHaveBeenCalled();
+ expect(result).toBe(true);
+ });
+ });
+
+ describe("Chromium browsers on Mac", () => {
+ beforeEach(() => {
+ inPopoutSpy.mockReturnValue(false);
+ inSidebarSpy.mockReturnValue(false);
+ Object.defineProperty(window, "navigator", {
+ value: {
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
+ appVersion: "5.0 (Macintosh; Intel Mac OS X 10_15_7)",
+ },
+ configurable: true,
+ writable: true,
+ });
+ });
+
+ it("should open popout and block navigation for Chrome on Mac when not in popout or sidebar", async () => {
+ getDeviceSpy.mockReturnValue(DeviceType.ChromeExtension);
+
+ const guard = filePickerPopoutGuard();
+ const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
+
+ expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=1");
+ expect(closePopupSpy).toHaveBeenCalledWith(window);
+ expect(result).toBe(false);
+ });
+
+ it("should not require popout for Edge on Mac (not Chrome)", async () => {
+ getDeviceSpy.mockReturnValue(DeviceType.EdgeExtension);
+
+ const guard = filePickerPopoutGuard();
+ const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
+
+ // Edge/Opera/Vivaldi on Mac don't match the Chrome-specific Mac check
+ expect(openPopoutSpy).not.toHaveBeenCalled();
+ expect(closePopupSpy).not.toHaveBeenCalled();
+ expect(result).toBe(true);
+ });
+
+ it("should allow navigation when in popout", async () => {
+ getDeviceSpy.mockReturnValue(DeviceType.ChromeExtension);
+ inPopoutSpy.mockReturnValue(true);
+
+ const guard = filePickerPopoutGuard();
+ const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
+
+ expect(openPopoutSpy).not.toHaveBeenCalled();
+ expect(closePopupSpy).not.toHaveBeenCalled();
+ expect(result).toBe(true);
+ });
+
+ it("should allow navigation when in sidebar", async () => {
+ getDeviceSpy.mockReturnValue(DeviceType.ChromeExtension);
+ inSidebarSpy.mockReturnValue(true);
+
+ const guard = filePickerPopoutGuard();
+ const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
+
+ expect(openPopoutSpy).not.toHaveBeenCalled();
+ expect(closePopupSpy).not.toHaveBeenCalled();
+ expect(result).toBe(true);
+ });
+ });
+
+ describe("Chromium browsers on Windows", () => {
+ beforeEach(() => {
+ getDeviceSpy.mockReturnValue(DeviceType.ChromeExtension);
+ inPopoutSpy.mockReturnValue(false);
+ inSidebarSpy.mockReturnValue(false);
+ Object.defineProperty(window, "navigator", {
+ value: {
+ userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
+ appVersion: "5.0 (Windows NT 10.0; Win64; x64)",
+ },
+ configurable: true,
+ writable: true,
+ });
+ });
+
+ it("should allow navigation without popout on Windows", async () => {
+ const guard = filePickerPopoutGuard();
+ const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
+
+ expect(getDeviceSpy).toHaveBeenCalledWith(window);
+ expect(openPopoutSpy).not.toHaveBeenCalled();
+ expect(closePopupSpy).not.toHaveBeenCalled();
+ expect(result).toBe(true);
+ });
+ });
+
+ describe("file picker routes", () => {
+ beforeEach(() => {
+ getDeviceSpy.mockReturnValue(DeviceType.FirefoxExtension);
+ inPopoutSpy.mockReturnValue(false);
+ inSidebarSpy.mockReturnValue(false);
+ });
+
+ it("should open popout for /import route", async () => {
+ const importState: RouterStateSnapshot = {
+ url: "/import",
+ } as RouterStateSnapshot;
+
+ const guard = filePickerPopoutGuard();
+ const result = await TestBed.runInInjectionContext(() => guard(mockRoute, importState));
+
+ expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/import");
+ expect(closePopupSpy).toHaveBeenCalledWith(window);
+ expect(result).toBe(false);
+ });
+
+ it("should open popout for /add-send route", async () => {
+ const addSendState: RouterStateSnapshot = {
+ url: "/add-send",
+ } as RouterStateSnapshot;
+
+ const guard = filePickerPopoutGuard();
+ const result = await TestBed.runInInjectionContext(() => guard(mockRoute, addSendState));
+
+ expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send");
+ expect(closePopupSpy).toHaveBeenCalledWith(window);
+ expect(result).toBe(false);
+ });
+
+ it("should open popout for /edit-send route", async () => {
+ const editSendState: RouterStateSnapshot = {
+ url: "/edit-send",
+ } as RouterStateSnapshot;
+
+ const guard = filePickerPopoutGuard();
+ const result = await TestBed.runInInjectionContext(() => guard(mockRoute, editSendState));
+
+ expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/edit-send");
+ expect(closePopupSpy).toHaveBeenCalledWith(window);
+ expect(result).toBe(false);
+ });
+
+ it("should open popout for /attachments route", async () => {
+ const attachmentsState: RouterStateSnapshot = {
+ url: "/attachments?cipherId=123",
+ } as RouterStateSnapshot;
+
+ const guard = filePickerPopoutGuard();
+ const result = await TestBed.runInInjectionContext(() => guard(mockRoute, attachmentsState));
+
+ expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/attachments?cipherId=123");
+ expect(closePopupSpy).toHaveBeenCalledWith(window);
+ expect(result).toBe(false);
+ });
+
+ it("should preserve query parameters on file picker routes", async () => {
+ const editSendWithParams: RouterStateSnapshot = {
+ url: "/edit-send?sendId=123&type=1",
+ } as RouterStateSnapshot;
+
+ const guard = filePickerPopoutGuard();
+ await TestBed.runInInjectionContext(() => guard(mockRoute, editSendWithParams));
+
+ expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/edit-send?sendId=123&type=1");
+ expect(closePopupSpy).toHaveBeenCalledWith(window);
+ });
+ });
+
+ describe("url handling", () => {
+ beforeEach(() => {
+ getDeviceSpy.mockReturnValue(DeviceType.FirefoxExtension);
+ inPopoutSpy.mockReturnValue(false);
+ inSidebarSpy.mockReturnValue(false);
+ });
+
+ it("should preserve query parameters in the popout url", async () => {
+ const stateWithQuery: RouterStateSnapshot = {
+ url: "/import?foo=bar&baz=qux",
+ } as RouterStateSnapshot;
+
+ const guard = filePickerPopoutGuard();
+ await TestBed.runInInjectionContext(() => guard(mockRoute, stateWithQuery));
+
+ expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/import?foo=bar&baz=qux");
+ expect(closePopupSpy).toHaveBeenCalledWith(window);
+ });
+
+ it("should handle urls without query parameters", async () => {
+ const stateWithoutQuery: RouterStateSnapshot = {
+ url: "/simple-path",
+ } as RouterStateSnapshot;
+
+ const guard = filePickerPopoutGuard();
+ await TestBed.runInInjectionContext(() => guard(mockRoute, stateWithoutQuery));
+
+ expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/simple-path");
+ expect(closePopupSpy).toHaveBeenCalledWith(window);
+ });
+
+ it("should not add autoClosePopout parameter to the url", async () => {
+ const guard = filePickerPopoutGuard();
+ await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
+
+ expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=1");
+ expect(openPopoutSpy).not.toHaveBeenCalledWith(expect.stringContaining("autoClosePopout"));
+ });
+ });
+});
diff --git a/apps/browser/src/tools/popup/guards/file-picker-popout.guard.ts b/apps/browser/src/tools/popup/guards/file-picker-popout.guard.ts
new file mode 100644
index 00000000000..e321910153c
--- /dev/null
+++ b/apps/browser/src/tools/popup/guards/file-picker-popout.guard.ts
@@ -0,0 +1,78 @@
+import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from "@angular/router";
+
+import { BrowserApi } from "@bitwarden/browser/platform/browser/browser-api";
+import BrowserPopupUtils from "@bitwarden/browser/platform/browser/browser-popup-utils";
+import { BrowserPlatformUtilsService } from "@bitwarden/browser/platform/services/platform-utils/browser-platform-utils.service";
+import { DeviceType } from "@bitwarden/common/enums";
+
+/**
+ * Composite guard that handles file picker popout requirements for all browsers.
+ * Forces a popout window when file pickers could be exposed on browsers that require it.
+ *
+ * Browser-specific requirements:
+ * - Firefox: Requires sidebar OR popout (crashes with file picker in popup: https://bugzilla.mozilla.org/show_bug.cgi?id=1292701)
+ * - Safari: Requires popout only
+ * - Chromium on Linux/Mac: Requires sidebar OR popout
+ * - Chromium on Windows: No special requirement
+ *
+ * @returns CanActivateFn that opens popout and blocks navigation when file picker access is needed
+ */
+export function filePickerPopoutGuard(): CanActivateFn {
+ return async (_route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => {
+ // Check if browser is one that needs popout for file pickers
+ const deviceType = BrowserPlatformUtilsService.getDevice(window);
+
+ // Check current context
+ const inPopout = BrowserPopupUtils.inPopout(window);
+ const inSidebar = BrowserPopupUtils.inSidebar(window);
+
+ let needsPopout = false;
+
+ // Firefox: needs sidebar OR popout to avoid crash with file picker
+ if (deviceType === DeviceType.FirefoxExtension && !inPopout && !inSidebar) {
+ needsPopout = true;
+ }
+
+ // Safari: needs popout only (sidebar not available)
+ if (deviceType === DeviceType.SafariExtension && !inPopout) {
+ needsPopout = true;
+ }
+
+ // Chromium on Linux: needs sidebar OR popout for file picker access
+ // All Chromium-based browsers (Chrome, Edge, Opera, Vivaldi) on Linux
+ const isChromiumBased = [
+ DeviceType.ChromeExtension,
+ DeviceType.EdgeExtension,
+ DeviceType.OperaExtension,
+ DeviceType.VivaldiExtension,
+ ].includes(deviceType);
+
+ const isLinux = window?.navigator?.userAgent?.indexOf("Linux") !== -1;
+
+ if (isChromiumBased && isLinux && !inPopout && !inSidebar) {
+ needsPopout = true;
+ }
+
+ // Chrome (specifically) on Mac: needs sidebar OR popout for file picker access
+ const isMac =
+ deviceType === DeviceType.ChromeExtension &&
+ window?.navigator?.appVersion.includes("Mac OS X");
+
+ if (isMac && !inPopout && !inSidebar) {
+ needsPopout = true;
+ }
+
+ // Open popout if needed
+ if (needsPopout) {
+ // Don't add autoClosePopout for file picker scenarios - user should manually close
+ await BrowserPopupUtils.openPopout(`popup/index.html#${state.url}`);
+
+ // Close the original popup window
+ BrowserApi.closePopup(window);
+
+ return false; // Block navigation - popout will reload
+ }
+
+ return true; // Allow navigation
+ };
+}
diff --git a/apps/browser/src/tools/popup/guards/firefox-popout.guard.spec.ts b/apps/browser/src/tools/popup/guards/firefox-popout.guard.spec.ts
deleted file mode 100644
index fcc7af6441f..00000000000
--- a/apps/browser/src/tools/popup/guards/firefox-popout.guard.spec.ts
+++ /dev/null
@@ -1,208 +0,0 @@
-import { TestBed } from "@angular/core/testing";
-import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router";
-
-import { DeviceType } from "@bitwarden/common/enums";
-
-import { BrowserApi } from "../../../platform/browser/browser-api";
-import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
-import { BrowserPlatformUtilsService } from "../../../platform/services/platform-utils/browser-platform-utils.service";
-
-import { firefoxPopoutGuard } from "./firefox-popout.guard";
-
-describe("firefoxPopoutGuard", () => {
- let getDeviceSpy: jest.SpyInstance;
- let inPopoutSpy: jest.SpyInstance;
- let inSidebarSpy: jest.SpyInstance;
- let openPopoutSpy: jest.SpyInstance;
- let closePopupSpy: jest.SpyInstance;
-
- const mockRoute = {} as ActivatedRouteSnapshot;
- const mockState: RouterStateSnapshot = {
- url: "/import?param=value",
- } as RouterStateSnapshot;
-
- beforeEach(() => {
- getDeviceSpy = jest.spyOn(BrowserPlatformUtilsService, "getDevice");
- inPopoutSpy = jest.spyOn(BrowserPopupUtils, "inPopout");
- inSidebarSpy = jest.spyOn(BrowserPopupUtils, "inSidebar");
- openPopoutSpy = jest.spyOn(BrowserPopupUtils, "openPopout").mockImplementation();
- closePopupSpy = jest.spyOn(BrowserApi, "closePopup").mockImplementation();
-
- TestBed.configureTestingModule({});
- });
-
- afterEach(() => {
- jest.clearAllMocks();
- });
-
- describe("when browser is Firefox", () => {
- beforeEach(() => {
- getDeviceSpy.mockReturnValue(DeviceType.FirefoxExtension);
- inPopoutSpy.mockReturnValue(false);
- inSidebarSpy.mockReturnValue(false);
- });
-
- it("should open popout and block navigation when not already in popout or sidebar", async () => {
- const guard = firefoxPopoutGuard();
- const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
-
- expect(getDeviceSpy).toHaveBeenCalledWith(window);
- expect(inPopoutSpy).toHaveBeenCalledWith(window);
- expect(inSidebarSpy).toHaveBeenCalledWith(window);
- expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/import?param=value");
- expect(closePopupSpy).toHaveBeenCalledWith(window);
- expect(result).toBe(false);
- });
-
- it("should not add autoClosePopout parameter to the url", async () => {
- const guard = firefoxPopoutGuard();
- await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
-
- expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/import?param=value");
- expect(openPopoutSpy).not.toHaveBeenCalledWith(expect.stringContaining("autoClosePopout"));
- });
-
- it("should allow navigation when already in popout", async () => {
- inPopoutSpy.mockReturnValue(true);
-
- const guard = firefoxPopoutGuard();
- const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
-
- expect(openPopoutSpy).not.toHaveBeenCalled();
- expect(closePopupSpy).not.toHaveBeenCalled();
- expect(result).toBe(true);
- });
-
- it("should allow navigation when already in sidebar", async () => {
- inSidebarSpy.mockReturnValue(true);
-
- const guard = firefoxPopoutGuard();
- const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
-
- expect(openPopoutSpy).not.toHaveBeenCalled();
- expect(closePopupSpy).not.toHaveBeenCalled();
- expect(result).toBe(true);
- });
- });
-
- describe("when browser is not Firefox", () => {
- beforeEach(() => {
- inPopoutSpy.mockReturnValue(false);
- inSidebarSpy.mockReturnValue(false);
- });
-
- it.each([
- { deviceType: DeviceType.ChromeExtension, name: "ChromeExtension" },
- { deviceType: DeviceType.EdgeExtension, name: "EdgeExtension" },
- { deviceType: DeviceType.OperaExtension, name: "OperaExtension" },
- { deviceType: DeviceType.SafariExtension, name: "SafariExtension" },
- { deviceType: DeviceType.VivaldiExtension, name: "VivaldiExtension" },
- ])(
- "should allow navigation without opening popout when device is $name",
- async ({ deviceType }) => {
- getDeviceSpy.mockReturnValue(deviceType);
-
- const guard = firefoxPopoutGuard();
- const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
-
- expect(getDeviceSpy).toHaveBeenCalledWith(window);
- expect(openPopoutSpy).not.toHaveBeenCalled();
- expect(closePopupSpy).not.toHaveBeenCalled();
- expect(result).toBe(true);
- },
- );
- });
-
- describe("file picker routes", () => {
- beforeEach(() => {
- getDeviceSpy.mockReturnValue(DeviceType.FirefoxExtension);
- inPopoutSpy.mockReturnValue(false);
- inSidebarSpy.mockReturnValue(false);
- });
-
- it("should open popout for /import route", async () => {
- const importState: RouterStateSnapshot = {
- url: "/import",
- } as RouterStateSnapshot;
-
- const guard = firefoxPopoutGuard();
- const result = await TestBed.runInInjectionContext(() => guard(mockRoute, importState));
-
- expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/import");
- expect(closePopupSpy).toHaveBeenCalledWith(window);
- expect(result).toBe(false);
- });
-
- it("should open popout for /add-send route", async () => {
- const addSendState: RouterStateSnapshot = {
- url: "/add-send",
- } as RouterStateSnapshot;
-
- const guard = firefoxPopoutGuard();
- const result = await TestBed.runInInjectionContext(() => guard(mockRoute, addSendState));
-
- expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send");
- expect(closePopupSpy).toHaveBeenCalledWith(window);
- expect(result).toBe(false);
- });
-
- it("should open popout for /edit-send route", async () => {
- const editSendState: RouterStateSnapshot = {
- url: "/edit-send",
- } as RouterStateSnapshot;
-
- const guard = firefoxPopoutGuard();
- const result = await TestBed.runInInjectionContext(() => guard(mockRoute, editSendState));
-
- expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/edit-send");
- expect(closePopupSpy).toHaveBeenCalledWith(window);
- expect(result).toBe(false);
- });
-
- it("should preserve query parameters on file picker routes", async () => {
- const editSendWithParams: RouterStateSnapshot = {
- url: "/edit-send?sendId=123&mode=edit",
- } as RouterStateSnapshot;
-
- const guard = firefoxPopoutGuard();
- await TestBed.runInInjectionContext(() => guard(mockRoute, editSendWithParams));
-
- expect(openPopoutSpy).toHaveBeenCalledWith(
- "popup/index.html#/edit-send?sendId=123&mode=edit",
- );
- expect(closePopupSpy).toHaveBeenCalledWith(window);
- });
- });
-
- describe("url handling", () => {
- beforeEach(() => {
- getDeviceSpy.mockReturnValue(DeviceType.FirefoxExtension);
- inPopoutSpy.mockReturnValue(false);
- inSidebarSpy.mockReturnValue(false);
- });
-
- it("should preserve query parameters in the popout url", async () => {
- const stateWithQuery: RouterStateSnapshot = {
- url: "/import?foo=bar&baz=qux",
- } as RouterStateSnapshot;
-
- const guard = firefoxPopoutGuard();
- await TestBed.runInInjectionContext(() => guard(mockRoute, stateWithQuery));
-
- expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/import?foo=bar&baz=qux");
- expect(closePopupSpy).toHaveBeenCalledWith(window);
- });
-
- it("should handle urls without query parameters", async () => {
- const stateWithoutQuery: RouterStateSnapshot = {
- url: "/simple-path",
- } as RouterStateSnapshot;
-
- const guard = firefoxPopoutGuard();
- await TestBed.runInInjectionContext(() => guard(mockRoute, stateWithoutQuery));
-
- expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/simple-path");
- expect(closePopupSpy).toHaveBeenCalledWith(window);
- });
- });
-});
diff --git a/apps/browser/src/tools/popup/guards/firefox-popout.guard.ts b/apps/browser/src/tools/popup/guards/firefox-popout.guard.ts
deleted file mode 100644
index 821f1b7a5bc..00000000000
--- a/apps/browser/src/tools/popup/guards/firefox-popout.guard.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from "@angular/router";
-
-import { BrowserApi } from "@bitwarden/browser/platform/browser/browser-api";
-import BrowserPopupUtils from "@bitwarden/browser/platform/browser/browser-popup-utils";
-import { BrowserPlatformUtilsService } from "@bitwarden/browser/platform/services/platform-utils/browser-platform-utils.service";
-import { DeviceType } from "@bitwarden/common/enums";
-
-/**
- * Guard that forces a popout window on Firefox browser when a file picker could be exposed.
- * Necessary to avoid a crash: https://bugzilla.mozilla.org/show_bug.cgi?id=1292701
- * Also disallows the user from closing a popout and re-opening the view exposing the file picker.
- *
- * @returns CanActivateFn that opens popout and blocks navigation on Firefox
- */
-export function firefoxPopoutGuard(): CanActivateFn {
- return async (_route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => {
- // Check if browser is Firefox using the platform utils service
- const deviceType = BrowserPlatformUtilsService.getDevice(window);
- const isFirefox = deviceType === DeviceType.FirefoxExtension;
-
- // Check if already in popout/sidebar
- const inPopout = BrowserPopupUtils.inPopout(window);
- const inSidebar = BrowserPopupUtils.inSidebar(window);
-
- // Open popout if on Firefox and not already in popout/sidebar
- if (isFirefox && !inPopout && !inSidebar) {
- // Don't add autoClosePopout for file picker scenarios - user should manually close
- await BrowserPopupUtils.openPopout(`popup/index.html#${state.url}`);
-
- // Close the original popup window
- BrowserApi.closePopup(window);
-
- return false; // Block navigation - popout will reload
- }
-
- return true; // Allow navigation
- };
-}
diff --git a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html
index a72847a5bf2..2d588a9ee78 100644
--- a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html
+++ b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html
@@ -10,8 +10,6 @@
>
-