mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 05:43:41 +00:00
[PM-2348] Close popup window before process reload (#16795)
* Close popup window before process reload * unit test coverage
This commit is contained in:
@@ -300,6 +300,7 @@ import { BrowserActionsService } from "../platform/actions/browser-actions.servi
|
|||||||
import { DefaultBadgeBrowserApi } from "../platform/badge/badge-browser-api";
|
import { DefaultBadgeBrowserApi } from "../platform/badge/badge-browser-api";
|
||||||
import { BadgeService } from "../platform/badge/badge.service";
|
import { BadgeService } from "../platform/badge/badge.service";
|
||||||
import { BrowserApi } from "../platform/browser/browser-api";
|
import { BrowserApi } from "../platform/browser/browser-api";
|
||||||
|
import BrowserPopupUtils from "../platform/browser/browser-popup-utils";
|
||||||
import { flagEnabled } from "../platform/flags";
|
import { flagEnabled } from "../platform/flags";
|
||||||
import { IpcBackgroundService } from "../platform/ipc/ipc-background.service";
|
import { IpcBackgroundService } from "../platform/ipc/ipc-background.service";
|
||||||
import { IpcContentScriptManagerService } from "../platform/ipc/ipc-content-script-manager.service";
|
import { IpcContentScriptManagerService } from "../platform/ipc/ipc-content-script-manager.service";
|
||||||
@@ -1237,6 +1238,12 @@ export default class MainBackground {
|
|||||||
|
|
||||||
const systemUtilsServiceReloadCallback = async () => {
|
const systemUtilsServiceReloadCallback = async () => {
|
||||||
await this.taskSchedulerService.clearAllScheduledTasks();
|
await this.taskSchedulerService.clearAllScheduledTasks();
|
||||||
|
|
||||||
|
// Close browser action popup before reloading to prevent zombie popup with invalidated context.
|
||||||
|
// The 'reloadProcess' message is sent by ProcessReloadService before this callback runs,
|
||||||
|
// and popups will close themselves upon receiving it. Poll to verify popup is actually closed.
|
||||||
|
await BrowserPopupUtils.waitForAllPopupsClose();
|
||||||
|
|
||||||
BrowserApi.reloadExtension();
|
BrowserApi.reloadExtension();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -337,6 +337,68 @@ describe("BrowserPopupUtils", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("waitForAllPopupsClose", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should resolve immediately if no popups are open", async () => {
|
||||||
|
jest.spyOn(BrowserApi, "isPopupOpen").mockResolvedValue(false);
|
||||||
|
|
||||||
|
const promise = BrowserPopupUtils.waitForAllPopupsClose();
|
||||||
|
jest.advanceTimersByTime(100);
|
||||||
|
|
||||||
|
await expect(promise).resolves.toBeUndefined();
|
||||||
|
expect(BrowserApi.isPopupOpen).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should resolve after timeout if popup never closes when using custom timeout", async () => {
|
||||||
|
jest.spyOn(BrowserApi, "isPopupOpen").mockResolvedValue(true);
|
||||||
|
|
||||||
|
const promise = BrowserPopupUtils.waitForAllPopupsClose(500);
|
||||||
|
|
||||||
|
// Advance past the timeout
|
||||||
|
jest.advanceTimersByTime(600);
|
||||||
|
|
||||||
|
await expect(promise).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should resolve after timeout if popup never closes when using default timeout", async () => {
|
||||||
|
jest.spyOn(BrowserApi, "isPopupOpen").mockResolvedValue(true);
|
||||||
|
|
||||||
|
const promise = BrowserPopupUtils.waitForAllPopupsClose();
|
||||||
|
|
||||||
|
// Advance past the default timeout
|
||||||
|
jest.advanceTimersByTime(1100);
|
||||||
|
|
||||||
|
await expect(promise).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should stop polling after popup closes before timeout", async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
jest.spyOn(BrowserApi, "isPopupOpen").mockImplementation(async () => {
|
||||||
|
callCount++;
|
||||||
|
return callCount <= 2;
|
||||||
|
});
|
||||||
|
|
||||||
|
const promise = BrowserPopupUtils.waitForAllPopupsClose(1000);
|
||||||
|
|
||||||
|
// Advance to when popup closes (300ms)
|
||||||
|
jest.advanceTimersByTime(300);
|
||||||
|
|
||||||
|
await expect(promise).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
// Advance further to ensure no more calls are made
|
||||||
|
jest.advanceTimersByTime(1000);
|
||||||
|
|
||||||
|
expect(BrowserApi.isPopupOpen).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("isSingleActionPopoutOpen", () => {
|
describe("isSingleActionPopoutOpen", () => {
|
||||||
const windowOptions = {
|
const windowOptions = {
|
||||||
id: 1,
|
id: 1,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// FIXME: Update this file to be type safe and remove this and next line
|
// FIXME: Update this file to be type safe and remove this and next line
|
||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
|
import { filter, firstValueFrom, interval, of, switchMap, takeWhile, timeout } from "rxjs";
|
||||||
|
|
||||||
import { ScrollOptions } from "./abstractions/browser-popup-utils.abstractions";
|
import { ScrollOptions } from "./abstractions/browser-popup-utils.abstractions";
|
||||||
import { BrowserApi } from "./browser-api";
|
import { BrowserApi } from "./browser-api";
|
||||||
@@ -212,6 +213,27 @@ export default class BrowserPopupUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits for all browser action popups to close, polling up to the specified timeout.
|
||||||
|
* Used before extension reload to prevent zombie popups with invalidated contexts.
|
||||||
|
*
|
||||||
|
* @param timeoutMs - Maximum time to wait in milliseconds. Defaults to 1 second.
|
||||||
|
* @returns Promise that resolves when all popups are closed or timeout is reached.
|
||||||
|
*/
|
||||||
|
static async waitForAllPopupsClose(timeoutMs = 1000): Promise<void> {
|
||||||
|
await firstValueFrom(
|
||||||
|
interval(100).pipe(
|
||||||
|
switchMap(() => BrowserApi.isPopupOpen()),
|
||||||
|
takeWhile((isOpen) => isOpen, true),
|
||||||
|
filter((isOpen) => !isOpen),
|
||||||
|
timeout({
|
||||||
|
first: timeoutMs,
|
||||||
|
with: () => of(true),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Identifies if a single action window is open based on the passed popoutKey.
|
* Identifies if a single action window is open based on the passed popoutKey.
|
||||||
* Will focus the existing window, and close any other windows that might exist
|
* Will focus the existing window, and close any other windows that might exist
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
LogoutReason,
|
LogoutReason,
|
||||||
UserDecryptionOptionsServiceAbstraction,
|
UserDecryptionOptionsServiceAbstraction,
|
||||||
} from "@bitwarden/auth/common";
|
} from "@bitwarden/auth/common";
|
||||||
|
import { BrowserApi } from "@bitwarden/browser/platform/browser/browser-api";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||||
@@ -58,6 +59,7 @@ import {
|
|||||||
} from "@bitwarden/components";
|
} from "@bitwarden/components";
|
||||||
import { BiometricsService, BiometricStateService, KeyService } from "@bitwarden/key-management";
|
import { BiometricsService, BiometricStateService, KeyService } from "@bitwarden/key-management";
|
||||||
|
|
||||||
|
import BrowserPopupUtils from "../platform/browser/browser-popup-utils";
|
||||||
import { PopupCompactModeService } from "../platform/popup/layout/popup-compact-mode.service";
|
import { PopupCompactModeService } from "../platform/popup/layout/popup-compact-mode.service";
|
||||||
import { PopupSizeService } from "../platform/popup/layout/popup-size.service";
|
import { PopupSizeService } from "../platform/popup/layout/popup-size.service";
|
||||||
import { initPopupClosedListener } from "../platform/services/popup-view-cache-background.service";
|
import { initPopupClosedListener } from "../platform/services/popup-view-cache-background.service";
|
||||||
@@ -286,6 +288,13 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
await this.biometricStateService.updateLastProcessReload();
|
await this.biometricStateService.updateLastProcessReload();
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
// Close browser action popup before extension reload to prevent zombie popup with invalidated context.
|
||||||
|
// This issue occurs in Chromium-based browsers (Chrome, Vivaldi, etc.) where chrome.runtime.reload()
|
||||||
|
// invalidates extension contexts before popup can close naturally
|
||||||
|
if (BrowserPopupUtils.inPopup(window)) {
|
||||||
|
BrowserApi.closePopup(window);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (msg.command === "reloadPopup") {
|
} else if (msg.command === "reloadPopup") {
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||||
|
|||||||
Reference in New Issue
Block a user