[PM-3530][PM-8588] persist extension route history (#9556)

Save the extension popup route history and restore it after closing and re-opening the popup.

---------

Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
This commit is contained in:
Will Martin 2024-08-12 17:26:47 -04:00 committed by GitHub
parent b2db633714
commit 295fb8f7a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 356 additions and 12 deletions

View File

@ -28,6 +28,7 @@ import { UserId } from "@bitwarden/common/types/guid";
import { ButtonModule, I18nMockService } from "@bitwarden/components";
import { RegistrationCheckEmailIcon } from "../../../../../../libs/auth/src/angular/icons/registration-check-email.icon";
import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popup-router-cache.service";
import { ExtensionAnonLayoutWrapperDataService } from "./extension-anon-layout-wrapper-data.service";
import {
@ -145,7 +146,15 @@ const decorators = (options: {
],
}),
applicationConfig({
providers: [importProvidersFrom(RouterModule.forRoot(options.routes))],
providers: [
importProvidersFrom(RouterModule.forRoot(options.routes)),
{
provide: PopupRouterCacheService,
useValue: {
back() {},
} as Partial<PopupRouterCacheService>,
},
],
}),
];
};

View File

@ -236,6 +236,7 @@ import I18nService from "../platform/services/i18n.service";
import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service";
import { BackgroundPlatformUtilsService } from "../platform/services/platform-utils/background-platform-utils.service";
import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service";
import { PopupViewCacheBackgroundService } from "../platform/services/popup-view-cache-background.service";
import { BackgroundTaskSchedulerService } from "../platform/services/task-scheduler/background-task-scheduler.service";
import { ForegroundTaskSchedulerService } from "../platform/services/task-scheduler/foreground-task-scheduler.service";
import { BackgroundMemoryStorageService } from "../platform/storage/background-memory-storage.service";
@ -371,6 +372,8 @@ export default class MainBackground {
private isSafari: boolean;
private nativeMessagingBackground: NativeMessagingBackground;
private popupViewCacheBackgroundService: PopupViewCacheBackgroundService;
constructor(public popupOnlyContext: boolean = false) {
// Services
const lockedCallback = async (userId?: string) => {
@ -571,6 +574,10 @@ export default class MainBackground {
logoutCallback,
);
this.popupViewCacheBackgroundService = new PopupViewCacheBackgroundService(
this.globalStateProvider,
);
const migrationRunner = new MigrationRunner(
this.storageService,
this.logService,
@ -1201,6 +1208,8 @@ export default class MainBackground {
await (this.i18nService as I18nService).init();
(this.eventUploadService as EventUploadService).init(true);
this.popupViewCacheBackgroundService.startObservingTabChanges();
if (this.popupOnlyContext) {
return;
}
@ -1295,6 +1304,7 @@ export default class MainBackground {
}),
),
);
await this.popupViewCacheBackgroundService.clearState();
await this.accountService.switchAccount(userId);
await switchPromise;
// Clear sequentialized caches
@ -1383,6 +1393,7 @@ export default class MainBackground {
this.vaultTimeoutSettingsService.clear(userBeingLoggedOut),
this.vaultFilterService.clear(),
this.biometricStateService.logout(userBeingLoggedOut),
this.popupViewCacheBackgroundService.clearState(),
/* We intentionally do not clear:
* - autofillSettingsService
* - badgeSettingsService

View File

@ -1,5 +1,5 @@
import { BooleanInput, coerceBooleanProperty } from "@angular/cdk/coercion";
import { CommonModule, Location } from "@angular/common";
import { CommonModule } from "@angular/common";
import { Component, Input, Signal, inject } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@ -10,6 +10,8 @@ import {
TypographyModule,
} from "@bitwarden/components";
import { PopupRouterCacheService } from "../view-cache/popup-router-cache.service";
import { PopupPageComponent } from "./popup-page.component";
@Component({
@ -19,6 +21,7 @@ import { PopupPageComponent } from "./popup-page.component";
imports: [TypographyModule, CommonModule, IconButtonModule, JslibModule, AsyncActionsModule],
})
export class PopupHeaderComponent {
private popupRouterCacheService = inject(PopupRouterCacheService);
protected pageContentScrolled: Signal<boolean> = inject(PopupPageComponent).isScrolled;
/** Background color */
@ -46,8 +49,6 @@ export class PopupHeaderComponent {
**/
@Input()
backAction: FunctionReturningAwaitable = async () => {
this.location.back();
return this.popupRouterCacheService.back();
};
constructor(private location: Location) {}
}

View File

@ -16,6 +16,8 @@ import {
SectionComponent,
} from "@bitwarden/components";
import { PopupRouterCacheService } from "../view-cache/popup-router-cache.service";
import { PopupFooterComponent } from "./popup-footer.component";
import { PopupHeaderComponent } from "./popup-header.component";
import { PopupPageComponent } from "./popup-page.component";
@ -334,6 +336,12 @@ export default {
{ useHash: true },
),
),
{
provide: PopupRouterCacheService,
useValue: {
back() {},
} as Partial<PopupRouterCacheService>,
},
],
}),
],

View File

@ -0,0 +1,131 @@
import { Location } from "@angular/common";
import { Injectable, inject } from "@angular/core";
import {
ActivatedRouteSnapshot,
CanActivateFn,
NavigationEnd,
Router,
UrlSerializer,
} from "@angular/router";
import { filter, first, firstValueFrom, map, Observable, of, switchMap } from "rxjs";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
import { POPUP_ROUTE_HISTORY_KEY } from "../../../platform/services/popup-view-cache-background.service";
import BrowserPopupUtils from "../browser-popup-utils";
/**
* Preserves route history when opening and closing the popup
*
* Routes marked with `doNotSaveUrl` will not be stored
**/
@Injectable({
providedIn: "root",
})
export class PopupRouterCacheService {
private router = inject(Router);
private state = inject(GlobalStateProvider).get(POPUP_ROUTE_HISTORY_KEY);
private location = inject(Location);
constructor() {
// init history with existing state
this.history$()
.pipe(first())
.subscribe((history) => history.forEach((location) => this.location.go(location)));
// update state when route change occurs
this.router.events
.pipe(
filter((event) => event instanceof NavigationEnd),
filter((_event: NavigationEnd) => {
const state: ActivatedRouteSnapshot = this.router.routerState.snapshot.root;
let child = state.firstChild;
while (child.firstChild) {
child = child.firstChild;
}
return !child?.data?.doNotSaveUrl ?? true;
}),
switchMap((event) => this.push(event.url)),
)
.subscribe();
}
history$(): Observable<string[]> {
return this.state.state$;
}
async setHistory(state: string[]): Promise<string[]> {
return this.state.update(() => state);
}
/** Get the last item from the history stack, or `null` if empty */
last$(): Observable<string | null> {
return this.history$().pipe(
map((history) => {
if (!history || history.length === 0) {
return null;
}
return history[history.length - 1];
}),
);
}
/**
* If in browser popup, push new route onto history stack
*/
private async push(url: string): Promise<boolean> {
if (!BrowserPopupUtils.inPopup(window) || url === (await firstValueFrom(this.last$()))) {
return;
}
await this.state.update((prevState) => (prevState == null ? [url] : prevState.concat(url)));
}
/**
* Navigate back in history
*/
async back() {
await this.state.update((prevState) => prevState.slice(0, -1));
const url = this.router.url;
this.location.back();
if (url !== this.router.url) {
return;
}
// if no history is present, fallback to vault page
await this.router.navigate([""]);
}
}
/**
* Redirect to the last visited route. Should be applied to root route.
*
* If `FeatureFlag.PersistPopupView` is disabled, do nothing.
**/
export const popupRouterCacheGuard = (() => {
const configService = inject(ConfigService);
const popupHistoryService = inject(PopupRouterCacheService);
const urlSerializer = inject(UrlSerializer);
return configService.getFeatureFlag$(FeatureFlag.PersistPopupView).pipe(
switchMap((featureEnabled) => {
if (!featureEnabled) {
return of(true);
}
return popupHistoryService.last$().pipe(
map((url: string) => {
if (!url) {
return true;
}
return urlSerializer.parse(url);
}),
);
}),
);
}) satisfies CanActivateFn;

View File

@ -0,0 +1,113 @@
import { Component } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { Router, UrlSerializer, UrlTree } from "@angular/router";
import { RouterTestingModule } from "@angular/router/testing";
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
import { FakeGlobalStateProvider } from "@bitwarden/common/spec";
import { PopupRouterCacheService, popupRouterCacheGuard } from "./popup-router-cache.service";
const flushPromises = async () => await new Promise(process.nextTick);
@Component({ template: "" })
export class EmptyComponent {}
describe("Popup router cache guard", () => {
const configServiceMock = mock<ConfigService>();
const fakeGlobalStateProvider = new FakeGlobalStateProvider();
let testBed: TestBed;
let serializer: UrlSerializer;
let router: Router;
let service: PopupRouterCacheService;
beforeEach(async () => {
jest.spyOn(configServiceMock, "getFeatureFlag$").mockReturnValue(of(true));
testBed = TestBed.configureTestingModule({
imports: [
RouterTestingModule.withRoutes([
{ path: "a", component: EmptyComponent },
{ path: "b", component: EmptyComponent },
{
path: "c",
component: EmptyComponent,
data: { doNotSaveUrl: true },
},
]),
],
providers: [
{ provide: ConfigService, useValue: configServiceMock },
{ provide: GlobalStateProvider, useValue: fakeGlobalStateProvider },
],
});
await testBed.compileComponents();
router = testBed.inject(Router);
serializer = testBed.inject(UrlSerializer);
service = testBed.inject(PopupRouterCacheService);
await service.setHistory([]);
});
it("returns true if the history stack is empty", async () => {
const response = await firstValueFrom(
testBed.runInInjectionContext(() => popupRouterCacheGuard()),
);
expect(response).toBe(true);
});
it("redirects to the latest stored route", async () => {
await router.navigate(["a"]);
await router.navigate(["b"]);
const response = (await firstValueFrom(
testBed.runInInjectionContext(() => popupRouterCacheGuard()),
)) as UrlTree;
expect(serializer.serialize(response)).toBe("/b");
});
it("back method redirects to the previous route", async () => {
await router.navigate(["a"]);
await router.navigate(["b"]);
// wait for router events subscription
await flushPromises();
expect(await firstValueFrom(service.history$())).toEqual(["/a", "/b"]);
await service.back();
expect(await firstValueFrom(service.history$())).toEqual(["/a"]);
});
it("does not save ignored routes", async () => {
await router.navigate(["a"]);
await router.navigate(["b"]);
await router.navigate(["c"]);
const response = (await firstValueFrom(
testBed.runInInjectionContext(() => popupRouterCacheGuard()),
)) as UrlTree;
expect(serializer.serialize(response)).toBe("/b");
});
it("does not save duplicate routes", async () => {
await router.navigate(["a"]);
await router.navigate(["a"]);
await flushPromises();
expect(await firstValueFrom(service.history$())).toEqual(["/a"]);
});
});

View File

@ -0,0 +1,63 @@
import { switchMap, merge, delay, filter, map } from "rxjs";
import {
POPUP_VIEW_MEMORY,
KeyDefinition,
GlobalStateProvider,
} from "@bitwarden/common/platform/state";
import { fromChromeEvent } from "../browser/from-chrome-event";
const popupClosedPortName = "new_popup";
export const POPUP_ROUTE_HISTORY_KEY = new KeyDefinition<string[]>(
POPUP_VIEW_MEMORY,
"popup-route-history",
{
deserializer: (jsonValue) => jsonValue,
},
);
export class PopupViewCacheBackgroundService {
private popupRouteHistoryState = this.globalStateProvider.get(POPUP_ROUTE_HISTORY_KEY);
constructor(private globalStateProvider: GlobalStateProvider) {}
startObservingTabChanges() {
merge(
// on tab changed, excluding extension tabs
fromChromeEvent(chrome.tabs.onActivated).pipe(
switchMap(([tabInfo]) => chrome.tabs.get(tabInfo.tabId)),
map((tab) => tab.url || tab.pendingUrl),
filter((url) => !url.startsWith(chrome.runtime.getURL(""))),
),
// on popup closed, with 2 minute delay that is cancelled by re-opening the popup
fromChromeEvent(chrome.runtime.onConnect).pipe(
filter(([port]) => port.name === popupClosedPortName),
switchMap(([port]) => fromChromeEvent(port.onDisconnect).pipe(delay(1000 * 60 * 2))),
),
)
.pipe(switchMap(() => this.clearState()))
.subscribe();
}
async clearState() {
return Promise.all([
this.popupRouteHistoryState.update(() => [], { shouldUpdate: this.objNotEmpty }),
]);
}
private objNotEmpty(obj: object): boolean {
return Object.keys(obj ?? {}).length !== 0;
}
}
/**
* Communicates to {@link PopupViewCacheBackgroundService} that the extension popup has been closed.
*
* Call in the foreground.
**/
export const initPopupClosedListener = () => {
chrome.runtime.connect({ name: popupClosedPortName });
};

View File

@ -48,6 +48,7 @@ import { NotificationsSettingsV1Component } from "../autofill/popup/settings/not
import { NotificationsSettingsComponent } from "../autofill/popup/settings/notifications.component";
import { PremiumComponent } from "../billing/popup/settings/premium.component";
import BrowserPopupUtils from "../platform/popup/browser-popup-utils";
import { popupRouterCacheGuard } from "../platform/popup/view-cache/popup-router-cache.service";
import { GeneratorComponent } from "../tools/popup/generator/generator.component";
import { PasswordGeneratorHistoryComponent } from "../tools/popup/generator/password-generator-history.component";
import { SendAddEditComponent } from "../tools/popup/send/send-add-edit.component";
@ -105,6 +106,7 @@ const routes: Routes = [
pathMatch: "full",
children: [], // Children lets us have an empty component.
canActivate: [
popupRouterCacheGuard,
redirectGuard({ loggedIn: "/tabs/current", loggedOut: "/home", locked: "/lock" }),
],
},

View File

@ -20,6 +20,7 @@ import {
} from "@bitwarden/components";
import { BrowserApi } from "../platform/browser/browser-api";
import { initPopupClosedListener } from "../platform/services/popup-view-cache-background.service";
import { BrowserSendStateService } from "../tools/popup/services/browser-send-state.service";
import { VaultBrowserStateService } from "../vault/services/vault-browser-state.service";
@ -59,6 +60,8 @@ export class AppComponent implements OnInit, OnDestroy {
) {}
async ngOnInit() {
initPopupClosedListener();
// Component states must not persist between closing and reopening the popup, otherwise they become dead objects
// Clear them aggressively to make sure this doesn't occur
await this.clearComponentStates();

View File

@ -34,6 +34,7 @@ import { CurrentAccountComponent } from "../../../auth/popup/account-switching/c
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popup-router-cache.service";
import { SendV2Component, SendState } from "./send-v2.component";
@ -102,6 +103,7 @@ describe("SendV2Component", () => {
{ provide: SendItemsService, useValue: sendItemsService },
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: SendListFiltersService, useValue: sendListFiltersService },
{ provide: PopupRouterCacheService, useValue: mock<PopupRouterCacheService>() },
],
}).compileComponents();

View File

@ -7,6 +7,8 @@ import { mock, MockProxy } from "jest-mock-extended";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherFormGeneratorComponent } from "@bitwarden/vault";
import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service";
import {
GeneratorDialogParams,
GeneratorDialogResult,
@ -39,6 +41,7 @@ describe("VaultGeneratorDialogComponent", () => {
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: DIALOG_DATA, useValue: dialogData },
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: PopupRouterCacheService, useValue: mock<PopupRouterCacheService>() },
],
})
.overrideComponent(VaultGeneratorDialogComponent, {

View File

@ -13,6 +13,7 @@ export enum FeatureFlag {
AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section",
EnableDeleteProvider = "AC-1218-delete-provider",
ExtensionRefresh = "extension-refresh",
PersistPopupView = "persist-popup-view",
RestrictProviderAccess = "restrict-provider-access",
PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service",
UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection",
@ -54,6 +55,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.AC1795_UpdatedSubscriptionStatusSection]: FALSE,
[FeatureFlag.EnableDeleteProvider]: FALSE,
[FeatureFlag.ExtensionRefresh]: FALSE,
[FeatureFlag.PersistPopupView]: FALSE,
[FeatureFlag.RestrictProviderAccess]: FALSE,
[FeatureFlag.PM4154_BulkEncryptionService]: FALSE,
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,

View File

@ -111,6 +111,9 @@ export const CRYPTO_MEMORY = new StateDefinition("crypto", "memory");
export const DESKTOP_SETTINGS_DISK = new StateDefinition("desktopSettings", "disk");
export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk");
export const ENVIRONMENT_MEMORY = new StateDefinition("environment", "memory");
export const POPUP_VIEW_MEMORY = new StateDefinition("popupView", "memory", {
browser: "memory-large-object",
});
export const SYNC_DISK = new StateDefinition("sync", "disk", { web: "memory" });
export const THEMING_DISK = new StateDefinition("theming", "disk", { web: "disk-local" });
export const TRANSLATION_DISK = new StateDefinition("translation", "disk", { web: "disk-local" });

View File

@ -13,10 +13,6 @@ import { CollectionView } from "@bitwarden/common/vault/models/view/collection.v
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { SearchModule } from "@bitwarden/components";
import { PopupFooterComponent } from "../../../../apps/browser/src/platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../../../../apps/browser/src/platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../../apps/browser/src/platform/popup/layout/popup-page.component";
import { AdditionalOptionsComponent } from "./additional-options/additional-options.component";
import { AttachmentsV2ViewComponent } from "./attachments/attachments-v2-view.component";
import { AutofillOptionsViewComponent } from "./autofill-options/autofill-options-view.component";
@ -35,9 +31,6 @@ import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-ide
CommonModule,
SearchModule,
JslibModule,
PopupPageComponent,
PopupHeaderComponent,
PopupFooterComponent,
ItemDetailsV2Component,
AdditionalOptionsComponent,
AttachmentsV2ViewComponent,