[PS-1854] Split services between background and visualizations (#4075)

* Elevate Map <-> Record JSON helpers to Utils

* Build Account from a StateService provided AccountDeserializer

* Allow Manifest V2 usage of session sync

Expands use of SessionSyncer to all Subject types. Correctly handles
replay buffer for each type to ignore the flood of data upon
subscription to each Subject type.

* Create browser-synced Policy Service

* Move BrowserFolderService

* Libs account serialization improvements

* Serialize Browser Accounts

* Separate StateService in background/visualizations

Visualizer state services share storages with background page, which
nicely emulates mv3 synchronization through session/local storage. There
should not be multithreading issues since all of these services are
still running through a single thread, we just now have multiple places
we are reading/writing data from.

Smaller improvements
* Rename browser's state service to BrowserStateService
* Remove unused WithPrototype decorator :celebrate:
* Removed conversion on withPrototypeForArrayMembers. It's reasonable to
think that if the type is maintained, it doesn't need conversion.

Eventually, we should be able to remove the withPrototypeForArrayMembers
decorator as well, but that will require a bit more work on
(de)serialization of the Accounts.data property.

* Make Record <-> Map idempotent

Should we get in a situation where we _think_ an object has been
jsonified, but hasn't been, we need to correctly deal with the object
received to create our target.

* Check all requirements while duck typing

* Name client services after the client

* Use union type to limit initialize options

* Fixup usages of `initializeAs`

* Add OrganizationService to synced services

Co-Authored-By: Daniel James Smith <djsmith85@users.noreply.github.com>

* Add Settings service to synced services

Co-Authored-By: Daniel James Smith <djsmith85@users.noreply.github.com>

* Add missing BrowserStateService

* Fix factories to use browser-specific service overides

* Fix org-service registration in services.module

* Revert "Add missing BrowserStateService"

This reverts commit 81cf384e87.

* Fix session syncer tests

* Fix synced item metadata tests

* Early return null json objects

* Prefer abstract service dependencies

* Prefer minimal browser service overrides

* [SG-632] - Change forwarded providers radio buttons list to dropdown (#4045)

* SG-632 - Changed forwarded providers list of radio buttons to dropdown

* SG-632 - Added role attributes to improve accessibility.

* SG-632 - Added sorting to array and empty option

* SG-632 - Fix styling to match standards.

* rename cipehrs component to vault items component (#4081)

* Update the version hash for the QA Web build artifact to follow SemVer syntax (#4102)

* Remove extra call to toJSON() (#4101)

Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>
Co-authored-by: Daniel James Smith <djsmith@web.de>
Co-authored-by: Carlos Gonçalves <carlosmaccam@gmail.com>
Co-authored-by: Jake Fink <jfink@bitwarden.com>
Co-authored-by: Joseph Flinn <58369717+joseph-flinn@users.noreply.github.com>
Co-authored-by: Robyn MacCallum <robyntmaccallum@gmail.com>
This commit is contained in:
Matt Gibson 2022-11-23 17:26:57 -05:00 committed by GitHub
parent be56d29ba0
commit 7fbfce953d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 671 additions and 241 deletions

View File

@ -1,7 +1,7 @@
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service";
import { StateService } from "../services/abstractions/state.service";
import { BrowserStateService } from "../services/abstractions/browser-state.service";
const IdleInterval = 60 * 5; // 5 minutes
@ -12,7 +12,7 @@ export default class IdleBackground {
constructor(
private vaultTimeoutService: VaultTimeoutService,
private stateService: StateService,
private stateService: BrowserStateService,
private notificationsService: NotificationsService
) {
this.idle = chrome.idle || (browser != null ? browser.idle : null);

View File

@ -61,14 +61,11 @@ import { FolderApiService } from "@bitwarden/common/services/folder/folder-api.s
import { KeyConnectorService } from "@bitwarden/common/services/keyConnector.service";
import { MemoryStorageService } from "@bitwarden/common/services/memoryStorage.service";
import { NotificationsService } from "@bitwarden/common/services/notifications.service";
import { OrganizationService } from "@bitwarden/common/services/organization/organization.service";
import { PasswordGenerationService } from "@bitwarden/common/services/passwordGeneration.service";
import { PolicyApiService } from "@bitwarden/common/services/policy/policy-api.service";
import { PolicyService } from "@bitwarden/common/services/policy/policy.service";
import { ProviderService } from "@bitwarden/common/services/provider.service";
import { SearchService } from "@bitwarden/common/services/search.service";
import { SendService } from "@bitwarden/common/services/send.service";
import { SettingsService } from "@bitwarden/common/services/settings.service";
import { StateMigrationService } from "@bitwarden/common/services/stateMigration.service";
import { SyncService } from "@bitwarden/common/services/sync/sync.service";
import { SyncNotifierService } from "@bitwarden/common/services/sync/syncNotifier.service";
@ -89,19 +86,22 @@ import { UpdateBadge } from "../listeners/update-badge";
import { Account } from "../models/account";
import { PopupUtilsService } from "../popup/services/popup-utils.service";
import { AutofillService as AutofillServiceAbstraction } from "../services/abstractions/autofill.service";
import { StateService as StateServiceAbstraction } from "../services/abstractions/state.service";
import { BrowserStateService as StateServiceAbstraction } from "../services/abstractions/browser-state.service";
import AutofillService from "../services/autofill.service";
import { BrowserEnvironmentService } from "../services/browser-environment.service";
import { BrowserFolderService } from "../services/browser-folder.service";
import { BrowserOrganizationService } from "../services/browser-organization.service";
import { BrowserPolicyService } from "../services/browser-policy.service";
import { BrowserSettingsService } from "../services/browser-settings.service";
import { BrowserStateService } from "../services/browser-state.service";
import { BrowserCryptoService } from "../services/browserCrypto.service";
import BrowserLocalStorageService from "../services/browserLocalStorage.service";
import BrowserMessagingService from "../services/browserMessaging.service";
import BrowserMessagingPrivateModeBackgroundService from "../services/browserMessagingPrivateModeBackground.service";
import BrowserPlatformUtilsService from "../services/browserPlatformUtils.service";
import { FolderService } from "../services/folders/folder.service";
import I18nService from "../services/i18n.service";
import { KeyGenerationService } from "../services/keyGeneration.service";
import { LocalBackedSessionStorageService } from "../services/localBackedSessionStorage.service";
import { StateService } from "../services/state.service";
import { VaultFilterService } from "../services/vaultFilter.service";
import VaultTimeoutService from "../services/vaultTimeout/vaultTimeout.service";
@ -227,7 +227,7 @@ export default class MainBackground {
this.secureStorageService,
new StateFactory(GlobalState, Account)
);
this.stateService = new StateService(
this.stateService = new BrowserStateService(
this.storageService,
this.secureStorageService,
this.memoryStorageService,
@ -282,7 +282,7 @@ export default class MainBackground {
this.appIdService,
(expired: boolean) => this.logout(expired)
);
this.settingsService = new SettingsService(this.stateService);
this.settingsService = new BrowserSettingsService(this.stateService);
this.fileUploadService = new FileUploadService(this.logService, this.apiService);
this.cipherService = new CipherService(
this.cryptoService,
@ -295,7 +295,7 @@ export default class MainBackground {
this.stateService,
this.encryptService
);
this.folderService = new FolderService(
this.folderService = new BrowserFolderService(
this.cryptoService,
this.i18nService,
this.cipherService,
@ -317,8 +317,8 @@ export default class MainBackground {
this.stateService
);
this.syncNotifierService = new SyncNotifierService();
this.organizationService = new OrganizationService(this.stateService);
this.policyService = new PolicyService(this.stateService, this.organizationService);
this.organizationService = new BrowserOrganizationService(this.stateService);
this.policyService = new BrowserPolicyService(this.stateService, this.organizationService);
this.policyApiService = new PolicyApiService(
this.policyService,
this.apiService,

View File

@ -15,7 +15,7 @@ import { LoginView } from "@bitwarden/common/models/view/login.view";
import { BrowserApi } from "../browser/browserApi";
import { AutofillService } from "../services/abstractions/autofill.service";
import { StateService } from "../services/abstractions/state.service";
import { BrowserStateService } from "../services/abstractions/browser-state.service";
import AddChangePasswordQueueMessage from "./models/addChangePasswordQueueMessage";
import AddLoginQueueMessage from "./models/addLoginQueueMessage";
@ -33,7 +33,7 @@ export default class NotificationBackground {
private authService: AuthService,
private policyService: PolicyService,
private folderService: FolderService,
private stateService: StateService
private stateService: BrowserStateService
) {}
async init() {

View File

@ -1,6 +1,6 @@
import { FolderService as AbstractFolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction";
import { FolderService } from "../../services/folders/folder.service";
import { BrowserFolderService } from "../../services/browser-folder.service";
import { cipherServiceFactory, CipherServiceInitOptions } from "./cipher-service.factory";
import { cryptoServiceFactory, CryptoServiceInitOptions } from "./crypto-service.factory";
@ -28,7 +28,7 @@ export function folderServiceFactory(
"folderService",
opts,
async () =>
new FolderService(
new BrowserFolderService(
await cryptoServiceFactory(cache, opts),
await i18nServiceFactory(cache, opts),
await cipherServiceFactory(cache, opts),

View File

@ -1,5 +1,6 @@
import { OrganizationService as AbstractOrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { OrganizationService } from "@bitwarden/common/services/organization/organization.service";
import { BrowserOrganizationService } from "../../services/browser-organization.service";
import { FactoryOptions, CachedServices, factory } from "./factory-options";
import { stateServiceFactory, StateServiceInitOptions } from "./state-service.factory";
@ -17,6 +18,6 @@ export function organizationServiceFactory(
cache,
"organizationService",
opts,
async () => new OrganizationService(await stateServiceFactory(cache, opts))
async () => new BrowserOrganizationService(await stateServiceFactory(cache, opts))
);
}

View File

@ -1,5 +1,6 @@
import { PolicyService as AbstractPolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction";
import { PolicyService } from "@bitwarden/common/services/policy/policy.service";
import { BrowserPolicyService } from "../../services/browser-policy.service";
import { CachedServices, factory, FactoryOptions } from "./factory-options";
import {
@ -26,7 +27,7 @@ export function policyServiceFactory(
"policyService",
opts,
async () =>
new PolicyService(
new BrowserPolicyService(
await stateServiceFactory(cache, opts),
await organizationServiceFactory(cache, opts)
)

View File

@ -1,5 +1,6 @@
import { SettingsService as AbstractSettingsService } from "@bitwarden/common/abstractions/settings.service";
import { SettingsService } from "@bitwarden/common/services/settings.service";
import { BrowserSettingsService } from "../../services/browser-settings.service";
import { FactoryOptions, CachedServices, factory } from "./factory-options";
import { stateServiceFactory, StateServiceInitOptions } from "./state-service.factory";
@ -16,6 +17,6 @@ export function settingsServiceFactory(
cache,
"settingsService",
opts,
async () => new SettingsService(await stateServiceFactory(cache, opts))
async () => new BrowserSettingsService(await stateServiceFactory(cache, opts))
);
}

View File

@ -2,7 +2,7 @@ import { StateFactory } from "@bitwarden/common/factories/stateFactory";
import { GlobalState } from "@bitwarden/common/models/domain/global-state";
import { Account } from "../../models/account";
import { StateService } from "../../services/state.service";
import { BrowserStateService } from "../../services/browser-state.service";
import { CachedServices, factory, FactoryOptions } from "./factory-options";
import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory";
@ -34,15 +34,15 @@ export type StateServiceInitOptions = StateServiceFactoryOptions &
StateMigrationServiceInitOptions;
export async function stateServiceFactory(
cache: { stateService?: StateService } & CachedServices,
cache: { stateService?: BrowserStateService } & CachedServices,
opts: StateServiceInitOptions
): Promise<StateService> {
): Promise<BrowserStateService> {
const service = await factory(
cache,
"stateService",
opts,
async () =>
await new StateService(
await new BrowserStateService(
await diskStorageServiceFactory(cache, opts),
await secureStorageServiceFactory(cache, opts),
await memoryStorageServiceFactory(cache, opts),

View File

@ -1,10 +1,10 @@
import { StateService } from "../services/abstractions/state.service";
import { BrowserStateService } from "../services/abstractions/browser-state.service";
const clearClipboardStorageKey = "clearClipboardTime";
export const getClearClipboardTime = async (stateService: StateService) => {
export const getClearClipboardTime = async (stateService: BrowserStateService) => {
return await stateService.getFromSessionMemory<number>(clearClipboardStorageKey);
};
export const setClearClipboardTime = async (stateService: StateService, time: number) => {
export const setClearClipboardTime = async (stateService: BrowserStateService, time: number) => {
await stateService.setInSessionMemory(clearClipboardStorageKey, time);
};

View File

@ -3,7 +3,7 @@ import { mock, MockProxy } from "jest-mock-extended";
import { PasswordGenerationService } from "@bitwarden/common/abstractions/passwordGeneration.service";
import { BrowserApi } from "../browser/browserApi";
import { StateService } from "../services/abstractions/state.service";
import { BrowserStateService } from "../services/abstractions/browser-state.service";
import { setClearClipboardTime } from "./clipboard-state";
import { GeneratePasswordToClipboardCommand } from "./generate-password-to-clipboard-command";
@ -19,13 +19,13 @@ const setClearClipboardTimeMock = setClearClipboardTime as jest.Mock;
describe("GeneratePasswordToClipboardCommand", () => {
let passwordGenerationService: MockProxy<PasswordGenerationService>;
let stateService: MockProxy<StateService>;
let stateService: MockProxy<BrowserStateService>;
let sut: GeneratePasswordToClipboardCommand;
beforeEach(() => {
passwordGenerationService = mock<PasswordGenerationService>();
stateService = mock<StateService>();
stateService = mock<BrowserStateService>();
passwordGenerationService.getOptions.mockResolvedValue([{ length: 8 }, {} as any]);

View File

@ -1,6 +1,6 @@
import { PasswordGenerationService } from "@bitwarden/common/abstractions/passwordGeneration.service";
import { StateService } from "../services/abstractions/state.service";
import { BrowserStateService } from "../services/abstractions/browser-state.service";
import { setClearClipboardTime } from "./clipboard-state";
import { copyToClipboard } from "./copy-to-clipboard-command";
@ -8,7 +8,7 @@ import { copyToClipboard } from "./copy-to-clipboard-command";
export class GeneratePasswordToClipboardCommand {
constructor(
private passwordGenerationService: PasswordGenerationService,
private stateService: StateService
private stateService: BrowserStateService
) {}
async generatePasswordToClipboard(tab: chrome.tabs.Tab) {

View File

@ -1,6 +1,6 @@
import { BehaviorSubject } from "rxjs";
import { StateService } from "../../services/state.service";
import { BrowserStateService } from "../../services/browser-state.service";
import { browserSession } from "./browser-session.decorator";
import { SessionStorable } from "./session-storable";
@ -22,25 +22,25 @@ describe("browserSession decorator", () => {
});
it("should create if StateService is a constructor argument", () => {
const stateService = Object.create(StateService.prototype, {});
const stateService = Object.create(BrowserStateService.prototype, {});
@browserSession
class TestClass {
constructor(private stateService: StateService) {}
constructor(private stateService: BrowserStateService) {}
}
expect(new TestClass(stateService)).toBeDefined();
});
describe("interaction with @sessionSync decorator", () => {
let stateService: StateService;
let stateService: BrowserStateService;
@browserSession
class TestClass {
@sessionSync({ initializer: (s: string) => s })
private behaviorSubject = new BehaviorSubject("");
constructor(private stateService: StateService) {}
constructor(private stateService: BrowserStateService) {}
fromJSON(json: any) {
this.behaviorSubject.next(json);
@ -48,7 +48,7 @@ describe("browserSession decorator", () => {
}
beforeEach(() => {
stateService = Object.create(StateService.prototype, {}) as StateService;
stateService = Object.create(BrowserStateService.prototype, {}) as BrowserStateService;
});
it("should create a session syncer", () => {

View File

@ -1,6 +1,6 @@
import { Constructor } from "type-fest";
import { StateService } from "../../services/state.service";
import { BrowserStateService } from "../../services/browser-state.service";
import { SessionStorable } from "./session-storable";
import { SessionSyncer } from "./session-syncer";
@ -22,7 +22,13 @@ export function browserSession<TCtor extends Constructor<any>>(constructor: TCto
super(...args);
// Require state service to be injected
const stateService = args.find((arg) => arg instanceof StateService);
const stateService: BrowserStateService = [this as any]
.concat(args)
.find(
(arg) =>
typeof arg.setInSessionMemory === "function" &&
typeof arg.getFromSessionMemory === "function"
);
if (!stateService) {
throw new Error(
`Cannot decorate ${constructor.name} with browserSession, Browser's StateService must be injected`
@ -38,7 +44,7 @@ export function browserSession<TCtor extends Constructor<any>>(constructor: TCto
);
}
buildSyncer(metadata: SyncedItemMetadata, stateService: StateService) {
buildSyncer(metadata: SyncedItemMetadata, stateService: BrowserStateService) {
const syncer = new SessionSyncer((this as any)[metadata.propertyKey], stateService, metadata);
syncer.init();
return syncer;

View File

@ -1,11 +1,12 @@
import { Jsonify } from "type-fest";
import { SessionStorable } from "./session-storable";
import { InitializeOptions } from "./sync-item-metadata";
class BuildOptions<T> {
class BuildOptions<T, TJson = Jsonify<T>> {
ctor?: new () => T;
initializer?: (keyValuePair: Jsonify<T>) => T;
initializeAsArray? = false;
initializer?: (keyValuePair: TJson) => T;
initializeAs?: InitializeOptions;
}
/**
@ -20,10 +21,10 @@ class BuildOptions<T> {
* @param buildOptions
* Builders for the value, requires either a constructor (ctor) for your BehaviorSubject type or an
* initializer function that takes a key value pair representation of the BehaviorSubject data
* and returns your instantiated BehaviorSubject value. `initializeAsArray can optionally be used to indicate
* and returns your instantiated BehaviorSubject value. `initializeAs can optionally be used to indicate
* the provided initializer function should be used to build an array of values. For example,
* ```ts
* \@sessionSync({ initializer: Foo.fromJSON, initializeAsArray: true })
* \@sessionSync({ initializer: Foo.fromJSON, initializeAs: 'array' })
* ```
* is equivalent to
* ```
@ -46,7 +47,7 @@ export function sessionSync<T>(buildOptions: BuildOptions<T>) {
sessionKey: `${prototype.constructor.name}_${propertyKey}`,
ctor: buildOptions.ctor,
initializer: buildOptions.initializer,
initializeAsArray: buildOptions.initializeAsArray,
initializeAs: buildOptions.initializeAs ?? "object",
});
};
}

View File

@ -1,8 +1,9 @@
import { awaitAsync as flushAsyncObservables } from "@bitwarden/angular/../test-utils";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { BehaviorSubject, ReplaySubject } from "rxjs";
import { BrowserApi } from "../../browser/browserApi";
import { StateService } from "../../services/abstractions/state.service";
import { BrowserStateService } from "../../services/abstractions/browser-state.service";
import { SessionSyncer } from "./session-syncer";
import { SyncedItemMetadata } from "./sync-item-metadata";
@ -10,8 +11,13 @@ import { SyncedItemMetadata } from "./sync-item-metadata";
describe("session syncer", () => {
const propertyKey = "behaviorSubject";
const sessionKey = "Test__" + propertyKey;
const metaData = { propertyKey, sessionKey, initializer: (s: string) => s };
let stateService: MockProxy<StateService>;
const metaData: SyncedItemMetadata = {
propertyKey,
sessionKey,
initializer: (s: string) => s,
initializeAs: "object",
};
let stateService: MockProxy<BrowserStateService>;
let sut: SessionSyncer;
let behaviorSubject: BehaviorSubject<string>;
@ -23,7 +29,7 @@ describe("session syncer", () => {
manifest_version: 3,
});
stateService = mock<StateService>();
stateService = mock<BrowserStateService>();
sut = new SessionSyncer(behaviorSubject, stateService, metaData);
});
@ -34,53 +40,85 @@ describe("session syncer", () => {
});
describe("constructor", () => {
it("should throw if behaviorSubject is not an instance of BehaviorSubject", () => {
it("should throw if subject is not an instance of Subject", () => {
expect(() => {
new SessionSyncer({} as any, stateService, null);
}).toThrowError("behaviorSubject must be an instance of BehaviorSubject");
}).toThrowError("subject must inherit from Subject");
});
it("should create if either ctor or initializer is provided", () => {
expect(
new SessionSyncer(behaviorSubject, stateService, { propertyKey, sessionKey, ctor: String })
new SessionSyncer(behaviorSubject, stateService, {
propertyKey,
sessionKey,
ctor: String,
initializeAs: "object",
})
).toBeDefined();
expect(
new SessionSyncer(behaviorSubject, stateService, {
propertyKey,
sessionKey,
initializer: (s: any) => s,
initializeAs: "object",
})
).toBeDefined();
});
it("should throw if neither ctor or initializer is provided", () => {
expect(() => {
new SessionSyncer(behaviorSubject, stateService, { propertyKey, sessionKey });
new SessionSyncer(behaviorSubject, stateService, {
propertyKey,
sessionKey,
initializeAs: "object",
});
}).toThrowError("ctor or initializer must be provided");
});
});
describe("manifest v2 init", () => {
let observeSpy: jest.SpyInstance;
let listenForUpdatesSpy: jest.SpyInstance;
beforeEach(() => {
observeSpy = jest.spyOn(behaviorSubject, "subscribe").mockReturnThis();
listenForUpdatesSpy = jest.spyOn(BrowserApi, "messageListener").mockReturnValue();
jest.spyOn(chrome.runtime, "getManifest").mockReturnValue({
name: "bitwarden-test",
version: "0.0.0",
manifest_version: 2,
});
describe("init", () => {
it("should ignore all updates currently in a ReplaySubject's buffer", () => {
const replaySubject = new ReplaySubject<string>(Infinity);
replaySubject.next("1");
replaySubject.next("2");
replaySubject.next("3");
sut = new SessionSyncer(replaySubject, stateService, metaData);
// block observing the subject
jest.spyOn(sut as any, "observe").mockImplementation();
sut.init();
expect(sut["ignoreNUpdates"]).toBe(3);
});
it("should not start observing", () => {
expect(observeSpy).not.toHaveBeenCalled();
it("should ignore BehaviorSubject's initial value", () => {
const behaviorSubject = new BehaviorSubject<string>("initial");
sut = new SessionSyncer(behaviorSubject, stateService, metaData);
// block observing the subject
jest.spyOn(sut as any, "observe").mockImplementation();
sut.init();
expect(sut["ignoreNUpdates"]).toBe(1);
});
it("should not start listening", () => {
expect(listenForUpdatesSpy).not.toHaveBeenCalled();
it("should grab an initial value from storage if it exists", () => {
stateService.hasInSessionMemory.mockResolvedValue(true);
//Block a call to update
const updateSpy = jest.spyOn(sut as any, "update").mockImplementation();
sut.init();
expect(updateSpy).toHaveBeenCalledWith();
});
it("should not grab an initial value from storage if it does not exist", () => {
stateService.hasInSessionMemory.mockResolvedValue(false);
//Block a call to update
const updateSpy = jest.spyOn(sut as any, "update").mockImplementation();
sut.init();
expect(updateSpy).toHaveBeenCalledWith();
});
});
@ -146,6 +184,7 @@ describe("session syncer", () => {
stateService.getFromSessionMemory.mockResolvedValue("test");
await sut.updateFromMessage({ command: `${sessionKey}_update`, id: "different_id" });
await flushAsyncObservables();
expect(stateService.getFromSessionMemory).toHaveBeenCalledTimes(1);
expect(stateService.getFromSessionMemory).toHaveBeenCalledWith(sessionKey, builder);

View File

@ -1,9 +1,9 @@
import { BehaviorSubject, concatMap, Subscription } from "rxjs";
import { BehaviorSubject, concatMap, ReplaySubject, Subject, Subscription } from "rxjs";
import { Utils } from "@bitwarden/common/misc/utils";
import { BrowserApi } from "../../browser/browserApi";
import { StateService } from "../../services/abstractions/state.service";
import { BrowserStateService } from "../../services/abstractions/browser-state.service";
import { SyncedItemMetadata } from "./sync-item-metadata";
@ -11,16 +11,16 @@ export class SessionSyncer {
subscription: Subscription;
id = Utils.newGuid();
// everyone gets the same initial values
private ignoreNextUpdate = true;
// ignore initial values
private ignoreNUpdates = 0;
constructor(
private behaviorSubject: BehaviorSubject<any>,
private stateService: StateService,
private subject: Subject<any>,
private stateService: BrowserStateService,
private metaData: SyncedItemMetadata
) {
if (!(behaviorSubject instanceof BehaviorSubject)) {
throw new Error("behaviorSubject must be an instance of BehaviorSubject");
if (!(subject instanceof Subject)) {
throw new Error("subject must inherit from Subject");
}
if (metaData.ctor == null && metaData.initializer == null) {
@ -29,11 +29,23 @@ export class SessionSyncer {
}
init() {
if (BrowserApi.manifestVersion !== 3) {
return;
switch (this.subject.constructor) {
case ReplaySubject:
// ignore all updates currently in the buffer
this.ignoreNUpdates = (this.subject as any)._buffer.length;
break;
case BehaviorSubject:
this.ignoreNUpdates = 1;
break;
default:
break;
}
this.observe();
if (this.stateService.hasInSessionMemory(this.metaData.sessionKey)) {
this.update();
}
this.listenForUpdates();
}
@ -41,11 +53,11 @@ export class SessionSyncer {
// This may be a memory leak.
// There is no good time to unsubscribe from this observable. Hopefully Manifest V3 clears memory from temporary
// contexts. If so, this is handled by destruction of the context.
this.subscription = this.behaviorSubject
this.subscription = this.subject
.pipe(
concatMap(async (next) => {
if (this.ignoreNextUpdate) {
this.ignoreNextUpdate = false;
if (this.ignoreNUpdates > 0) {
this.ignoreNUpdates -= 1;
return;
}
await this.updateSession(next);
@ -66,10 +78,14 @@ export class SessionSyncer {
if (message.command != this.updateMessageCommand || message.id === this.id) {
return;
}
this.update();
}
async update() {
const builder = SyncedItemMetadata.builder(this.metaData);
const value = await this.stateService.getFromSessionMemory(this.metaData.sessionKey, builder);
this.ignoreNextUpdate = true;
this.behaviorSubject.next(value);
this.ignoreNUpdates = 1;
this.subject.next(value);
}
private async updateSession(value: any) {

View File

@ -1,17 +1,27 @@
export type InitializeOptions = "array" | "record" | "object";
export class SyncedItemMetadata {
propertyKey: string;
sessionKey: string;
ctor?: new () => any;
initializer?: (keyValuePair: any) => any;
initializeAsArray?: boolean;
initializeAs: InitializeOptions;
static builder(metadata: SyncedItemMetadata): (o: any) => any {
const itemBuilder =
metadata.initializer != null
? metadata.initializer
: (o: any) => Object.assign(new metadata.ctor(), o);
if (metadata.initializeAsArray) {
if (metadata.initializeAs === "array") {
return (keyValuePair: any) => keyValuePair.map((o: any) => itemBuilder(o));
} else if (metadata.initializeAs === "record") {
return (keyValuePair: any) => {
const record: Record<any, any> = {};
for (const key in keyValuePair) {
record[key] = itemBuilder(keyValuePair[key]);
}
return record;
};
} else {
return (keyValuePair: any) => itemBuilder(keyValuePair);
}

View File

@ -8,32 +8,60 @@ describe("builder", () => {
const ctor = TestClass;
it("should use initializer if provided", () => {
const metadata = { propertyKey, sessionKey: key, initializer };
const metadata: SyncedItemMetadata = {
propertyKey,
sessionKey: key,
initializer,
initializeAs: "object",
};
const builder = SyncedItemMetadata.builder(metadata);
expect(builder({})).toBe("used initializer");
});
it("should use ctor if initializer is not provided", () => {
const metadata = { propertyKey, sessionKey: key, ctor };
const metadata: SyncedItemMetadata = {
propertyKey,
sessionKey: key,
ctor,
initializeAs: "object",
};
const builder = SyncedItemMetadata.builder(metadata);
expect(builder({})).toBeInstanceOf(TestClass);
});
it("should prefer initializer over ctor", () => {
const metadata = { propertyKey, sessionKey: key, ctor, initializer };
const metadata: SyncedItemMetadata = {
propertyKey,
sessionKey: key,
ctor,
initializer,
initializeAs: "object",
};
const builder = SyncedItemMetadata.builder(metadata);
expect(builder({})).toBe("used initializer");
});
it("should honor initialize as array", () => {
const metadata = {
const metadata: SyncedItemMetadata = {
propertyKey,
sessionKey: key,
initializer: initializer,
initializeAsArray: true,
initializeAs: "array",
};
const builder = SyncedItemMetadata.builder(metadata);
expect(builder([{}])).toBeInstanceOf(Array);
expect(builder([{}])[0]).toBe("used initializer");
});
it("should honor initialize as record", () => {
const metadata: SyncedItemMetadata = {
propertyKey,
sessionKey: key,
initializer: initializer,
initializeAs: "record",
};
const builder = SyncedItemMetadata.builder(metadata);
expect(builder({ key: "" })).toBeInstanceOf(Object);
expect(builder({ key: "" })).toStrictEqual({ key: "used initializer" });
});
});

View File

@ -15,7 +15,7 @@ import { searchServiceFactory } from "../background/service_factories/search-ser
import { stateServiceFactory } from "../background/service_factories/state-service.factory";
import { BrowserApi } from "../browser/browserApi";
import { Account } from "../models/account";
import { StateService } from "../services/abstractions/state.service";
import { BrowserStateService } from "../services/abstractions/browser-state.service";
import BrowserPlatformUtilsService from "../services/browserPlatformUtils.service";
export type BadgeOptions = {
@ -25,7 +25,7 @@ export type BadgeOptions = {
export class UpdateBadge {
private authService: AuthService;
private stateService: StateService;
private stateService: BrowserStateService;
private cipherService: CipherService;
private badgeAction: typeof chrome.action;
private sidebarAction: OperaSidebarAction | FirefoxSidebarAction;

View File

@ -1,3 +1,5 @@
import { Jsonify } from "type-fest";
import {
Account as BaseAccount,
AccountSettings as BaseAccountSettings,
@ -9,6 +11,14 @@ import { BrowserSendComponentState } from "./browserSendComponentState";
export class AccountSettings extends BaseAccountSettings {
vaultTimeout = -1; // On Restart
static fromJSON(json: Jsonify<AccountSettings>): AccountSettings {
if (json == null) {
return null;
}
return Object.assign(new AccountSettings(), json, super.fromJSON(json));
}
}
export class Account extends BaseAccount {
@ -29,4 +39,18 @@ export class Account extends BaseAccount {
this.ciphers = init?.ciphers ?? new BrowserComponentState();
this.sendType = init?.sendType ?? new BrowserComponentState();
}
static fromJSON(json: Jsonify<Account>): Account {
if (json == null) {
return null;
}
return Object.assign(new Account({}), json, super.fromJSON(json), {
settings: AccountSettings.fromJSON(json.settings),
groupings: BrowserGroupingsComponentState.fromJSON(json.groupings),
send: BrowserSendComponentState.fromJSON(json.send),
ciphers: BrowserComponentState.fromJSON(json.ciphers),
sendType: BrowserComponentState.fromJSON(json.sendType),
});
}
}

View File

@ -1,4 +1,14 @@
import { Jsonify } from "type-fest";
export class BrowserComponentState {
scrollY: number;
searchText: string;
static fromJSON(json: Jsonify<BrowserComponentState>) {
if (json == null) {
return null;
}
return Object.assign(new BrowserComponentState(), json);
}
}

View File

@ -1,7 +1,9 @@
import { CipherType } from "@bitwarden/common/enums/cipherType";
import { Utils } from "@bitwarden/common/misc/utils";
import { CipherView } from "@bitwarden/common/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/models/view/collection.view";
import { FolderView } from "@bitwarden/common/models/view/folder.view";
import { DeepJsonify } from "@bitwarden/common/types/deep-jsonify";
import { BrowserComponentState } from "./browserComponentState";
@ -15,4 +17,28 @@ export class BrowserGroupingsComponentState extends BrowserComponentState {
folders: FolderView[];
collections: CollectionView[];
deletedCount: number;
toJSON() {
return Utils.merge(this, {
collectionCounts: Utils.mapToRecord(this.collectionCounts),
folderCounts: Utils.mapToRecord(this.folderCounts),
typeCounts: Utils.mapToRecord(this.typeCounts),
});
}
static fromJSON(json: DeepJsonify<BrowserGroupingsComponentState>) {
if (json == null) {
return null;
}
return Object.assign(new BrowserGroupingsComponentState(), json, {
favoriteCiphers: json.favoriteCiphers?.map((c) => CipherView.fromJSON(c)),
noFolderCiphers: json.noFolderCiphers?.map((c) => CipherView.fromJSON(c)),
ciphers: json.ciphers?.map((c) => CipherView.fromJSON(c)),
collectionCounts: Utils.recordToMap(json.collectionCounts),
folderCounts: Utils.recordToMap(json.folderCounts),
typeCounts: Utils.recordToMap(json.typeCounts),
folders: json.folders?.map((f) => FolderView.fromJSON(f)),
});
}
}

View File

@ -1,9 +1,28 @@
import { SendType } from "@bitwarden/common/enums/sendType";
import { Utils } from "@bitwarden/common/misc/utils";
import { SendView } from "@bitwarden/common/models/view/send.view";
import { DeepJsonify } from "@bitwarden/common/types/deep-jsonify";
import { BrowserComponentState } from "./browserComponentState";
export class BrowserSendComponentState extends BrowserComponentState {
sends: SendView[];
typeCounts: Map<SendType, number>;
toJSON() {
return Utils.merge(this, {
typeCounts: Utils.mapToRecord(this.typeCounts),
});
}
static fromJSON(json: DeepJsonify<BrowserSendComponentState>) {
if (json == null) {
return null;
}
return Object.assign(new BrowserSendComponentState(), json, {
sends: json.sends?.map((s) => SendView.fromJSON(s)),
typeCounts: Utils.recordToMap(json.typeCounts),
});
}
}

View File

@ -19,7 +19,7 @@ import { MessagingService } from "@bitwarden/common/abstractions/messaging.servi
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { BrowserApi } from "../browser/browserApi";
import { StateService } from "../services/abstractions/state.service";
import { BrowserStateService } from "../services/abstractions/browser-state.service";
import { routerTransition } from "./app-routing.animations";
@ -43,7 +43,7 @@ export class AppComponent implements OnInit, OnDestroy {
private authService: AuthService,
private i18nService: I18nService,
private router: Router,
private stateService: StateService,
private stateService: BrowserStateService,
private messagingService: MessagingService,
private changeDetectorRef: ChangeDetectorRef,
private ngZone: NgZone,

View File

@ -12,7 +12,7 @@ import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUti
import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction";
import { SendService } from "@bitwarden/common/abstractions/send.service";
import { StateService } from "../../services/abstractions/state.service";
import { BrowserStateService } from "../../services/abstractions/browser-state.service";
import { PopupUtilsService } from "../services/popup-utils.service";
@Component({
@ -33,7 +33,7 @@ export class SendAddEditComponent extends BaseAddEditComponent {
constructor(
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
stateService: StateService,
stateService: BrowserStateService,
messagingService: MessagingService,
policyService: PolicyService,
environmentService: EnvironmentService,

View File

@ -15,7 +15,7 @@ import { SendType } from "@bitwarden/common/enums/sendType";
import { SendView } from "@bitwarden/common/models/view/send.view";
import { BrowserSendComponentState } from "../../models/browserSendComponentState";
import { StateService } from "../../services/abstractions/state.service";
import { BrowserStateService } from "../../services/abstractions/browser-state.service";
import { PopupUtilsService } from "../services/popup-utils.service";
const ComponentId = "SendComponent";
@ -42,7 +42,7 @@ export class SendGroupingsComponent extends BaseSendComponent {
policyService: PolicyService,
searchService: SearchService,
private popupUtils: PopupUtilsService,
private stateService: StateService,
private stateService: BrowserStateService,
private router: Router,
private syncService: SyncService,
private changeDetectorRef: ChangeDetectorRef,
@ -165,12 +165,12 @@ export class SendGroupingsComponent extends BaseSendComponent {
}
private async saveState() {
this.state = {
this.state = Object.assign(new BrowserSendComponentState(), {
scrollY: this.popupUtils.getContentScrollY(window),
searchText: this.searchText,
sends: this.sends,
typeCounts: this.typeCounts,
};
});
await this.stateService.setBrowserSendComponentState(this.state);
}

View File

@ -16,7 +16,7 @@ import { SendType } from "@bitwarden/common/enums/sendType";
import { SendView } from "@bitwarden/common/models/view/send.view";
import { BrowserComponentState } from "../../models/browserComponentState";
import { StateService } from "../../services/abstractions/state.service";
import { BrowserStateService } from "../../services/abstractions/browser-state.service";
import { PopupUtilsService } from "../services/popup-utils.service";
const ComponentId = "SendTypeComponent";
@ -41,7 +41,7 @@ export class SendTypeComponent extends BaseSendComponent {
policyService: PolicyService,
searchService: SearchService,
private popupUtils: PopupUtilsService,
private stateService: StateService,
private stateService: BrowserStateService,
private route: ActivatedRoute,
private location: Location,
private changeDetectorRef: ChangeDetectorRef,

View File

@ -5,7 +5,7 @@ import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService as LogServiceAbstraction } from "@bitwarden/common/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { StateService as StateServiceAbstraction } from "../../services/abstractions/state.service";
import { BrowserStateService as StateServiceAbstraction } from "../../services/abstractions/browser-state.service";
import { PopupUtilsService } from "./popup-utils.service";

View File

@ -38,6 +38,7 @@ import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abs
import { SendService } from "@bitwarden/common/abstractions/send.service";
import { SettingsService } from "@bitwarden/common/abstractions/settings.service";
import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/abstractions/state.service";
import { StateMigrationService } from "@bitwarden/common/abstractions/stateMigration.service";
import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service";
import { SyncService } from "@bitwarden/common/abstractions/sync/sync.service.abstraction";
import { TokenService } from "@bitwarden/common/abstractions/token.service";
@ -47,6 +48,8 @@ import { UserVerificationService } from "@bitwarden/common/abstractions/userVeri
import { UsernameGenerationService } from "@bitwarden/common/abstractions/usernameGeneration.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeoutSettings.service";
import { StateFactory } from "@bitwarden/common/factories/stateFactory";
import { GlobalState } from "@bitwarden/common/models/domain/global-state";
import { AuthService } from "@bitwarden/common/services/auth.service";
import { ConsoleLogService } from "@bitwarden/common/services/consoleLog.service";
import { LoginService } from "@bitwarden/common/services/login.service";
@ -54,9 +57,14 @@ import { SearchService } from "@bitwarden/common/services/search.service";
import MainBackground from "../../background/main.background";
import { BrowserApi } from "../../browser/browserApi";
import { Account } from "../../models/account";
import { AutofillService } from "../../services/abstractions/autofill.service";
import { StateService as StateServiceAbstraction } from "../../services/abstractions/state.service";
import { BrowserStateService as StateServiceAbstraction } from "../../services/abstractions/browser-state.service";
import { BrowserEnvironmentService } from "../../services/browser-environment.service";
import { BrowserOrganizationService } from "../../services/browser-organization.service";
import { BrowserPolicyService } from "../../services/browser-policy.service";
import { BrowserSettingsService } from "../../services/browser-settings.service";
import { BrowserStateService } from "../../services/browser-state.service";
import { BrowserFileDownloadService } from "../../services/browserFileDownloadService";
import BrowserMessagingService from "../../services/browserMessaging.service";
import BrowserMessagingPrivateModePopupService from "../../services/browserMessagingPrivateModePopup.service";
@ -190,8 +198,13 @@ function getBgService<T>(service: keyof MainBackground) {
{ provide: EventService, useFactory: getBgService<EventService>("eventService"), deps: [] },
{
provide: PolicyService,
useFactory: getBgService<PolicyService>("policyService"),
deps: [],
useFactory: (
stateService: StateServiceAbstraction,
organizationService: OrganizationService
) => {
return new BrowserPolicyService(stateService, organizationService);
},
deps: [StateServiceAbstraction, OrganizationService],
},
{
provide: PolicyApiServiceAbstraction,
@ -212,8 +225,10 @@ function getBgService<T>(service: keyof MainBackground) {
{ provide: SyncService, useFactory: getBgService<SyncService>("syncService"), deps: [] },
{
provide: SettingsService,
useFactory: getBgService<SettingsService>("settingsService"),
deps: [],
useFactory: (stateService: StateServiceAbstraction) => {
return new BrowserSettingsService(stateService);
},
deps: [StateServiceAbstraction],
},
{
provide: AbstractStorageService,
@ -261,8 +276,10 @@ function getBgService<T>(service: keyof MainBackground) {
{ provide: PasswordRepromptServiceAbstraction, useClass: PasswordRepromptService },
{
provide: OrganizationService,
useFactory: getBgService<OrganizationService>("organizationService"),
deps: [],
useFactory: (stateService: StateServiceAbstraction) => {
return new BrowserOrganizationService(stateService);
},
deps: [StateServiceAbstraction],
},
{
provide: VaultFilterService,
@ -293,10 +310,36 @@ function getBgService<T>(service: keyof MainBackground) {
useFactory: getBgService<AbstractStorageService>("memoryStorageService"),
},
{
provide: StateServiceAbstraction,
useFactory: getBgService<StateServiceAbstraction>("stateService"),
provide: StateMigrationService,
useFactory: getBgService<StateMigrationService>("stateMigrationService"),
deps: [],
},
{
provide: StateServiceAbstraction,
useFactory: (
storageService: AbstractStorageService,
secureStorageService: AbstractStorageService,
memoryStorageService: AbstractStorageService,
logService: LogServiceAbstraction,
stateMigrationService: StateMigrationService
) => {
return new BrowserStateService(
storageService,
secureStorageService,
memoryStorageService,
logService,
stateMigrationService,
new StateFactory(GlobalState, Account)
);
},
deps: [
AbstractStorageService,
SECURE_STORAGE,
MEMORY_STORAGE,
LogServiceAbstraction,
StateMigrationService,
],
},
{
provide: UsernameGenerationService,
useFactory: getBgService<UsernameGenerationService>("usernameGenerationService"),
@ -317,17 +360,19 @@ function getBgService<T>(service: keyof MainBackground) {
},
{
provide: AbstractThemingService,
useFactory: () => {
useFactory: (
stateService: StateServiceAbstraction,
platformUtilsService: PlatformUtilsService
) => {
return new ThemingService(
getBgService<StateServiceAbstraction>("stateService")(),
stateService,
// Safari doesn't properly handle the (prefers-color-scheme) media query in the popup window, it always returns light.
// In Safari we have to use the background page instead, which comes with limitations like not dynamically changing the extension theme when the system theme is changed.
getBgService<PlatformUtilsService>("platformUtilsService")().isSafari()
? getBgService<Window>("backgroundWindow")()
: window,
platformUtilsService.isSafari() ? getBgService<Window>("backgroundWindow")() : window,
document
);
},
deps: [StateServiceAbstraction, PlatformUtilsService],
},
],
})

View File

@ -18,7 +18,7 @@ import { FolderView } from "@bitwarden/common/models/view/folder.view";
import { BrowserApi } from "../../browser/browserApi";
import { BrowserGroupingsComponentState } from "../../models/browserGroupingsComponentState";
import { StateService } from "../../services/abstractions/state.service";
import { BrowserStateService } from "../../services/abstractions/browser-state.service";
import { VaultFilterService } from "../../services/vaultFilter.service";
import { PopupUtilsService } from "../services/popup-utils.service";
@ -83,7 +83,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
private platformUtilsService: PlatformUtilsService,
private searchService: SearchService,
private location: Location,
private browserStateService: StateService,
private browserStateService: BrowserStateService,
private vaultFilterService: VaultFilterService
) {
this.noFolderListSize = 100;
@ -373,7 +373,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
}
private async saveState() {
this.state = {
this.state = Object.assign(new BrowserGroupingsComponentState(), {
scrollY: this.popupUtils.getContentScrollY(window),
searchText: this.searchText,
favoriteCiphers: this.favoriteCiphers,
@ -385,7 +385,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
folders: this.folders,
collections: this.collections,
deletedCount: this.deletedCount,
};
});
await this.browserStateService.setBrowserGroupingComponentState(this.state);
}

View File

@ -21,7 +21,7 @@ import { FolderView } from "@bitwarden/common/models/view/folder.view";
import { BrowserApi } from "../../browser/browserApi";
import { BrowserComponentState } from "../../models/browserComponentState";
import { StateService } from "../../services/abstractions/state.service";
import { BrowserStateService } from "../../services/abstractions/browser-state.service";
import { VaultFilterService } from "../../services/vaultFilter.service";
import { PopupUtilsService } from "../services/popup-utils.service";
@ -60,7 +60,7 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn
private ngZone: NgZone,
private broadcasterService: BroadcasterService,
private changeDetectorRef: ChangeDetectorRef,
private stateService: StateService,
private stateService: BrowserStateService,
private popupUtils: PopupUtilsService,
private i18nService: I18nService,
private folderService: FolderService,

View File

@ -8,7 +8,8 @@ import { BrowserComponentState } from "../../models/browserComponentState";
import { BrowserGroupingsComponentState } from "../../models/browserGroupingsComponentState";
import { BrowserSendComponentState } from "../../models/browserSendComponentState";
export abstract class StateService extends BaseStateServiceAbstraction<Account> {
export abstract class BrowserStateService extends BaseStateServiceAbstraction<Account> {
abstract hasInSessionMemory(key: string): Promise<boolean>;
abstract getFromSessionMemory<T>(key: string, deserializer?: (obj: Jsonify<T>) => T): Promise<T>;
abstract setInSessionMemory(key: string, value: any): Promise<void>;
getBrowserGroupingComponentState: (

View File

@ -14,7 +14,6 @@ import { BrowserApi } from "../browser/browserApi";
import AutofillField from "../models/autofillField";
import AutofillPageDetails from "../models/autofillPageDetails";
import AutofillScript from "../models/autofillScript";
import { StateService } from "../services/abstractions/state.service";
import {
AutoFillOptions,
@ -22,6 +21,7 @@ import {
PageDetail,
FormData,
} from "./abstractions/autofill.service";
import { BrowserStateService } from "./abstractions/browser-state.service";
import {
AutoFillConstants,
CreditCardAutoFillConstants,
@ -39,7 +39,7 @@ export interface GenerateFillScriptOptions {
export default class AutofillService implements AutofillServiceInterface {
constructor(
private cipherService: CipherService,
private stateService: StateService,
private stateService: BrowserStateService,
private totpService: TotpService,
private eventService: EventService,
private logService: LogService

View File

@ -4,12 +4,12 @@ import { Folder } from "@bitwarden/common/models/domain/folder";
import { FolderView } from "@bitwarden/common/models/view/folder.view";
import { FolderService as BaseFolderService } from "@bitwarden/common/services/folder/folder.service";
import { browserSession, sessionSync } from "../../decorators/session-sync-observable";
import { browserSession, sessionSync } from "../decorators/session-sync-observable";
@browserSession
export class FolderService extends BaseFolderService {
@sessionSync({ initializer: Folder.fromJSON, initializeAsArray: true })
export class BrowserFolderService extends BaseFolderService {
@sessionSync({ initializer: Folder.fromJSON, initializeAs: "array" })
protected _folders: BehaviorSubject<Folder[]>;
@sessionSync({ initializer: FolderView.fromJSON, initializeAsArray: true })
@sessionSync({ initializer: FolderView.fromJSON, initializeAs: "array" })
protected _folderViews: BehaviorSubject<FolderView[]>;
}

View File

@ -0,0 +1,12 @@
import { BehaviorSubject } from "rxjs";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { OrganizationService } from "@bitwarden/common/services/organization/organization.service";
import { browserSession, sessionSync } from "../decorators/session-sync-observable";
@browserSession
export class BrowserOrganizationService extends OrganizationService {
@sessionSync({ initializer: Organization.fromJSON, initializeAs: "array" })
protected _organizations: BehaviorSubject<Organization[]>;
}

View File

@ -0,0 +1,12 @@
import { BehaviorSubject } from "rxjs";
import { Policy } from "@bitwarden/common/models/domain/policy";
import { PolicyService } from "@bitwarden/common/services/policy/policy.service";
import { browserSession, sessionSync } from "../decorators/session-sync-observable";
@browserSession
export class BrowserPolicyService extends PolicyService {
@sessionSync({ ctor: Policy, initializeAs: "array" })
protected _policies: BehaviorSubject<Policy[]>;
}

View File

@ -0,0 +1,11 @@
import { BehaviorSubject } from "rxjs";
import { AccountSettingsSettings } from "@bitwarden/common/models/domain/account";
import { SettingsService } from "@bitwarden/common/services/settings.service";
import { sessionSync } from "../decorators/session-sync-observable";
export class BrowserSettingsService extends SettingsService {
@sessionSync({ initializer: (obj: string[][]) => obj })
protected _settings: BehaviorSubject<AccountSettingsSettings>;
}

View File

@ -1,6 +1,6 @@
// eslint-disable-next-line no-restricted-imports
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
import { mock, MockProxy } from "jest-mock-extended";
import { EncryptService } from "@bitwarden/common/abstractions/encrypt.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import {
MemoryStorageServiceInterface,
@ -18,28 +18,29 @@ import { BrowserComponentState } from "../models/browserComponentState";
import { BrowserGroupingsComponentState } from "../models/browserGroupingsComponentState";
import { BrowserSendComponentState } from "../models/browserSendComponentState";
import { AbstractKeyGenerationService } from "./abstractions/abstractKeyGeneration.service";
import { BrowserStateService } from "./browser-state.service";
import { LocalBackedSessionStorageService } from "./localBackedSessionStorage.service";
import { StateService } from "./state.service";
describe("Browser State Service", () => {
let secureStorageService: SubstituteOf<AbstractStorageService>;
let diskStorageService: SubstituteOf<AbstractStorageService>;
let logService: SubstituteOf<LogService>;
let stateMigrationService: SubstituteOf<StateMigrationService>;
let stateFactory: SubstituteOf<StateFactory<GlobalState, Account>>;
let secureStorageService: MockProxy<AbstractStorageService>;
let diskStorageService: MockProxy<AbstractStorageService>;
let logService: MockProxy<LogService>;
let stateMigrationService: MockProxy<StateMigrationService>;
let stateFactory: MockProxy<StateFactory<GlobalState, Account>>;
let useAccountCache: boolean;
let state: State<GlobalState, Account>;
const userId = "userId";
let sut: StateService;
let sut: BrowserStateService;
beforeEach(() => {
secureStorageService = Substitute.for();
diskStorageService = Substitute.for();
logService = Substitute.for();
stateMigrationService = Substitute.for();
stateFactory = Substitute.for();
secureStorageService = mock();
diskStorageService = mock();
logService = mock();
stateMigrationService = mock();
stateFactory = mock();
useAccountCache = true;
state = new State(new GlobalState());
@ -54,9 +55,12 @@ describe("Browser State Service", () => {
beforeEach(() => {
// We need `AbstractCachedStorageService` in the prototype chain to correctly test cache bypass.
memoryStorageService = Object.create(LocalBackedSessionStorageService.prototype);
memoryStorageService = new LocalBackedSessionStorageService(
mock<EncryptService>(),
mock<AbstractKeyGenerationService>()
);
sut = new StateService(
sut = new BrowserStateService(
diskStorageService,
secureStorageService,
memoryStorageService,
@ -80,14 +84,14 @@ describe("Browser State Service", () => {
});
describe("state methods", () => {
let memoryStorageService: SubstituteOf<AbstractStorageService & MemoryStorageServiceInterface>;
let memoryStorageService: MockProxy<AbstractStorageService & MemoryStorageServiceInterface>;
beforeEach(() => {
memoryStorageService = Substitute.for();
const stateGetter = (key: string) => Promise.resolve(JSON.parse(JSON.stringify(state)));
memoryStorageService.get("state", Arg.any()).mimicks(stateGetter);
memoryStorageService = mock();
const stateGetter = (key: string) => Promise.resolve(state);
memoryStorageService.get.mockImplementation(stateGetter);
sut = new StateService(
sut = new BrowserStateService(
diskStorageService,
secureStorageService,
memoryStorageService,
@ -128,6 +132,7 @@ describe("Browser State Service", () => {
[SendType.Text, 5],
]);
state.accounts[userId].send = sendState;
(global as any)["watch"] = state;
const actual = await sut.getBrowserSendComponentState();
expect(actual).toBeInstanceOf(BrowserSendComponentState);

View File

@ -1,24 +1,40 @@
import { BehaviorSubject } from "rxjs";
import { Jsonify } from "type-fest";
import { AbstractCachedStorageService } from "@bitwarden/common/abstractions/storage.service";
import { GlobalState } from "@bitwarden/common/models/domain/global-state";
import { StorageOptions } from "@bitwarden/common/models/domain/storage-options";
import {
StateService as BaseStateService,
withPrototype,
} from "@bitwarden/common/services/state.service";
import { StateService as BaseStateService } from "@bitwarden/common/services/state.service";
import { browserSession, sessionSync } from "../decorators/session-sync-observable";
import { Account } from "../models/account";
import { BrowserComponentState } from "../models/browserComponentState";
import { BrowserGroupingsComponentState } from "../models/browserGroupingsComponentState";
import { BrowserSendComponentState } from "../models/browserSendComponentState";
import { StateService as StateServiceAbstraction } from "./abstractions/state.service";
import { BrowserStateService as StateServiceAbstraction } from "./abstractions/browser-state.service";
export class StateService
@browserSession
export class BrowserStateService
extends BaseStateService<GlobalState, Account>
implements StateServiceAbstraction
{
@sessionSync({
initializer: Account.fromJSON as any, // TODO: Remove this any when all any types are removed from Account
initializeAs: "record",
})
protected accountsSubject: BehaviorSubject<{ [userId: string]: Account }>;
@sessionSync({ ctor: String })
protected activeAccountSubject: BehaviorSubject<string>;
@sessionSync({ ctor: Boolean })
protected activeAccountUnlockedSubject: BehaviorSubject<boolean>;
protected accountDeserializer = Account.fromJSON;
async hasInSessionMemory(key: string): Promise<boolean> {
return await this.memoryStorageService.has(key);
}
async getFromSessionMemory<T>(key: string, deserializer?: (obj: Jsonify<T>) => T): Promise<T> {
return this.memoryStorageService instanceof AbstractCachedStorageService
? await this.memoryStorageService.getBypassCache<T>(key, { deserializer: deserializer })
@ -44,7 +60,6 @@ export class StateService
);
}
@withPrototype(BrowserGroupingsComponentState)
async getBrowserGroupingComponentState(
options?: StorageOptions
): Promise<BrowserGroupingsComponentState> {
@ -67,7 +82,6 @@ export class StateService
);
}
@withPrototype(BrowserComponentState)
async getBrowserVaultItemsComponentState(
options?: StorageOptions
): Promise<BrowserComponentState> {
@ -90,7 +104,6 @@ export class StateService
);
}
@withPrototype(BrowserSendComponentState)
async getBrowserSendComponentState(options?: StorageOptions): Promise<BrowserSendComponentState> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
@ -111,7 +124,6 @@ export class StateService
);
}
@withPrototype(BrowserComponentState)
async getBrowserSendTypeComponentState(options?: StorageOptions): Promise<BrowserComponentState> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))

View File

@ -0,0 +1,3 @@
export async function awaitAsync(ms = 0) {
await new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@ -241,4 +241,72 @@ describe("Utils Service", () => {
expect(Utils.fromByteStringToArray(null)).toEqual(null);
});
});
describe("mapToRecord", () => {
it("should handle null", () => {
expect(Utils.mapToRecord(null)).toEqual(null);
});
it("should handle empty map", () => {
expect(Utils.mapToRecord(new Map())).toEqual({});
});
it("should handle convert a Map to a Record", () => {
const map = new Map([
["key1", "value1"],
["key2", "value2"],
]);
expect(Utils.mapToRecord(map)).toEqual({ key1: "value1", key2: "value2" });
});
it("should handle convert a Map to a Record with non-string keys", () => {
const map = new Map([
[1, "value1"],
[2, "value2"],
]);
const result = Utils.mapToRecord(map);
expect(result).toEqual({ 1: "value1", 2: "value2" });
expect(Utils.recordToMap(result)).toEqual(map);
});
it("should not convert an object if it's not a map", () => {
const obj = { key1: "value1", key2: "value2" };
expect(Utils.mapToRecord(obj as any)).toEqual(obj);
});
});
describe("recordToMap", () => {
it("should handle null", () => {
expect(Utils.recordToMap(null)).toEqual(null);
});
it("should handle empty record", () => {
expect(Utils.recordToMap({})).toEqual(new Map());
});
it("should handle convert a Record to a Map", () => {
const record = { key1: "value1", key2: "value2" };
expect(Utils.recordToMap(record)).toEqual(new Map(Object.entries(record)));
});
it("should handle convert a Record to a Map with non-string keys", () => {
const record = { 1: "value1", 2: "value2" };
const result = Utils.recordToMap(record);
expect(result).toEqual(
new Map([
[1, "value1"],
[2, "value2"],
])
);
expect(Utils.mapToRecord(result)).toEqual(record);
});
it("should not convert an object if already a map", () => {
const map = new Map([
["key1", "value1"],
["key2", "value2"],
]);
expect(Utils.recordToMap(map as any)).toEqual(map);
});
});
});

View File

@ -1,5 +1,6 @@
/* eslint-disable no-useless-escape */
import { getHostname, parse } from "tldts";
import { Merge } from "type-fest";
import { CryptoService } from "../abstractions/crypto.service";
import { EncryptService } from "../abstractions/encrypt.service";
@ -55,6 +56,10 @@ export class Utils {
}
static fromB64ToArray(str: string): Uint8Array {
if (str == null) {
return null;
}
if (Utils.isNode) {
return new Uint8Array(Buffer.from(str, "base64"));
} else {
@ -108,6 +113,9 @@ export class Utils {
}
static fromBufferToB64(buffer: ArrayBuffer): string {
if (buffer == null) {
return null;
}
if (Utils.isNode) {
return Buffer.from(buffer).toString("base64");
} else {
@ -423,6 +431,57 @@ export class Utils {
return this.global.bitwardenContainerService;
}
/**
* Converts map to a Record<string, V> with the same data. Inverse of recordToMap
* Useful in toJSON methods, since Maps are not serializable
* @param map
* @returns
*/
static mapToRecord<K extends string | number, V>(map: Map<K, V>): Record<string, V> {
if (map == null) {
return null;
}
if (!(map instanceof Map)) {
return map;
}
return Object.fromEntries(map);
}
/**
* Converts record to a Map<string, V> with the same data. Inverse of mapToRecord
* Useful in fromJSON methods, since Maps are not serializable
*
* Warning: If the record has string keys that are numbers, they will be converted to numbers in the map
* @param record
* @returns
*/
static recordToMap<K extends string | number, V>(record: Record<K, V>): Map<K, V> {
if (record == null) {
return null;
} else if (record instanceof Map) {
return record;
}
const entries = Object.entries(record);
if (entries.length === 0) {
return new Map();
}
if (isNaN(Number(entries[0][0]))) {
return new Map(entries) as Map<K, V>;
} else {
return new Map(entries.map((e) => [Number(e[0]), e[1]])) as Map<K, V>;
}
}
/** Applies Object.assign, but converts the type nicely using Type-Fest Merge<Destination, Source> */
static merge<Destination, Source>(
destination: Destination,
source: Source
): Merge<Destination, Source> {
return Object.assign(destination, source) as unknown as Merge<Destination, Source>;
}
private static isMobile(win: Window) {
let mobile = false;
((a) => {

View File

@ -1,4 +1,4 @@
import { Except, Jsonify } from "type-fest";
import { Jsonify } from "type-fest";
import { AuthenticationStatus } from "../../enums/authenticationStatus";
import { KdfType } from "../../enums/kdfType";
@ -40,7 +40,7 @@ export class EncryptionPair<TEncrypted, TDecrypted> {
}
static fromJSON<TEncrypted, TDecrypted>(
obj: Jsonify<EncryptionPair<Jsonify<TEncrypted>, Jsonify<TDecrypted>>>,
obj: { encrypted?: Jsonify<TEncrypted>; decrypted?: string | Jsonify<TDecrypted> },
decryptedFromJson?: (decObj: Jsonify<TDecrypted> | string) => TDecrypted,
encryptedFromJson?: (encObj: Jsonify<TEncrypted>) => TEncrypted
) {
@ -123,7 +123,7 @@ export class AccountKeys {
apiKeyClientSecret?: string;
toJSON() {
return Object.assign(this as Except<AccountKeys, "publicKey">, {
return Utils.merge(this, {
publicKey: Utils.fromBufferToByteString(this.publicKey),
});
}
@ -251,7 +251,7 @@ export class AccountSettings {
}
export type AccountSettingsSettings = {
equivalentDomains?: { [id: string]: any };
equivalentDomains?: string[][];
};
export class AccountTokens {

View File

@ -1,3 +1,5 @@
import { Jsonify } from "type-fest";
import { OrganizationUserStatusType } from "../../enums/organizationUserStatusType";
import { OrganizationUserType } from "../../enums/organizationUserType";
import { ProductType } from "../../enums/productType";
@ -201,4 +203,15 @@ export class Organization {
get hasProvider() {
return this.providerId != null || this.providerName != null;
}
static fromJSON(json: Jsonify<Organization>) {
if (json == null) {
return null;
}
return Object.assign(new Organization(), json, {
familySponsorshipLastSyncDate: new Date(json.familySponsorshipLastSyncDate),
familySponsorshipValidUntil: new Date(json.familySponsorshipValidUntil),
});
}
}

View File

@ -4,22 +4,25 @@ import { State } from "./state";
describe("state", () => {
describe("fromJSON", () => {
it("should deserialize to an instance of itself", () => {
expect(State.fromJSON({})).toBeInstanceOf(State);
expect(State.fromJSON({}, () => new Account({}))).toBeInstanceOf(State);
});
it("should always assign an object to accounts", () => {
const state = State.fromJSON({});
const state = State.fromJSON({}, () => new Account({}));
expect(state.accounts).not.toBeNull();
expect(state.accounts).toEqual({});
});
it("should build an account map", () => {
const accountsSpy = jest.spyOn(Account, "fromJSON");
const state = State.fromJSON({
accounts: {
userId: {},
const state = State.fromJSON(
{
accounts: {
userId: {},
},
},
});
Account.fromJSON
);
expect(state.accounts["userId"]).toBeInstanceOf(Account);
expect(accountsSpy).toHaveBeenCalled();

View File

@ -19,26 +19,28 @@ export class State<
// TODO, make Jsonify<State,TGlobalState,TAccount> work. It currently doesn't because Globals doesn't implement Jsonify.
static fromJSON<TGlobalState extends GlobalState, TAccount extends Account>(
obj: any
obj: any,
accountDeserializer: (json: Jsonify<TAccount>) => TAccount
): State<TGlobalState, TAccount> {
if (obj == null) {
return null;
}
return Object.assign(new State(null), obj, {
accounts: State.buildAccountMapFromJSON(obj?.accounts),
accounts: State.buildAccountMapFromJSON(obj?.accounts, accountDeserializer),
});
}
private static buildAccountMapFromJSON(
jsonAccounts: Jsonify<{ [userId: string]: Jsonify<Account> }>
private static buildAccountMapFromJSON<TAccount extends Account>(
jsonAccounts: { [userId: string]: Jsonify<TAccount> },
accountDeserializer: (json: Jsonify<TAccount>) => TAccount
) {
if (!jsonAccounts) {
return {};
}
const accounts: { [userId: string]: Account } = {};
const accounts: { [userId: string]: TAccount } = {};
for (const userId in jsonAccounts) {
accounts[userId] = Account.fromJSON(jsonAccounts[userId]);
accounts[userId] = accountDeserializer(jsonAccounts[userId]);
}
return accounts;
}

View File

@ -1,3 +1,4 @@
import { DeepJsonify } from "../../types/deep-jsonify";
import { SendFile } from "../domain/send-file";
import { View } from "./view";
@ -28,4 +29,12 @@ export class SendFileView implements View {
}
return 0;
}
static fromJSON(json: DeepJsonify<SendFileView>) {
if (json == null) {
return null;
}
return Object.assign(new SendFileView(), json);
}
}

View File

@ -1,3 +1,4 @@
import { DeepJsonify } from "../../types/deep-jsonify";
import { SendText } from "../domain/send-text";
import { View } from "./view";
@ -17,4 +18,12 @@ export class SendTextView implements View {
get maskedText(): string {
return this.text != null ? "••••••••" : null;
}
static fromJSON(json: DeepJsonify<SendTextView>) {
if (json == null) {
return null;
}
return Object.assign(new SendTextView(), json);
}
}

View File

@ -1,5 +1,6 @@
import { SendType } from "../../enums/sendType";
import { Utils } from "../../misc/utils";
import { DeepJsonify } from "../../types/deep-jsonify";
import { Send } from "../domain/send";
import { SymmetricCryptoKey } from "../domain/symmetric-crypto-key";
@ -65,4 +66,26 @@ export class SendView implements View {
get pendingDelete(): boolean {
return this.deletionDate <= new Date();
}
toJSON() {
return Utils.merge(this, {
key: Utils.fromBufferToB64(this.key),
});
}
static fromJSON(json: DeepJsonify<SendView>) {
if (json == null) {
return null;
}
return Object.assign(new SendView(), json, {
key: Utils.fromB64ToArray(json.key)?.buffer,
cryptoKey: SymmetricCryptoKey.fromJSON(json.cryptoKey),
text: SendTextView.fromJSON(json.text),
file: SendFileView.fromJSON(json.file),
revisionDate: json.revisionDate == null ? null : new Date(json.revisionDate),
deletionDate: json.deletionDate == null ? null : new Date(json.deletionDate),
expirationDate: json.expirationDate == null ? null : new Date(json.expirationDate),
});
}
}

View File

@ -412,7 +412,7 @@ export class CipherService implements CipherServiceAbstraction {
: firstValueFrom(this.settingsService.settings$).then(
(settings: AccountSettingsSettings) => {
let matches: any[] = [];
settings.equivalentDomains?.forEach((eqDomain: any) => {
settings?.equivalentDomains?.forEach((eqDomain: any) => {
if (eqDomain.length && eqDomain.indexOf(domain) >= 0) {
matches = matches.concat(eqDomain);
}

View File

@ -6,7 +6,7 @@ import { OrganizationData } from "../../models/data/organization.data";
import { Organization } from "../../models/domain/organization";
export class OrganizationService implements InternalOrganizationServiceAbstraction {
private _organizations = new BehaviorSubject<Organization[]>([]);
protected _organizations = new BehaviorSubject<Organization[]>([]);
organizations$ = this._organizations.asObservable();

View File

@ -16,7 +16,7 @@ import { ListResponse } from "../../models/response/list.response";
import { PolicyResponse } from "../../models/response/policy.response";
export class PolicyService implements InternalPolicyServiceAbstraction {
private _policies: BehaviorSubject<Policy[]> = new BehaviorSubject([]);
protected _policies: BehaviorSubject<Policy[]> = new BehaviorSubject([]);
policies$ = this._policies.asObservable();

View File

@ -6,7 +6,7 @@ import { Utils } from "../misc/utils";
import { AccountSettingsSettings } from "../models/domain/account";
export class SettingsService implements SettingsServiceAbstraction {
private _settings: BehaviorSubject<AccountSettingsSettings> = new BehaviorSubject({});
protected _settings: BehaviorSubject<AccountSettingsSettings> = new BehaviorSubject({});
settings$ = this._settings.asObservable();

View File

@ -1,4 +1,5 @@
import { BehaviorSubject, concatMap } from "rxjs";
import { Jsonify } from "type-fest";
import { LogService } from "../abstractions/log.service";
import { StateService as StateServiceAbstraction } from "../abstractions/state.service";
@ -13,6 +14,7 @@ import { StorageLocation } from "../enums/storageLocation";
import { ThemeType } from "../enums/themeType";
import { UriMatchType } from "../enums/uriMatchType";
import { StateFactory } from "../factories/stateFactory";
import { Utils } from "../misc/utils";
import { CipherData } from "../models/data/cipher.data";
import { CollectionData } from "../models/data/collection.data";
import { EncryptedOrganizationKeyData } from "../models/data/encrypted-organization-key.data";
@ -65,13 +67,13 @@ export class StateService<
TAccount extends Account = Account
> implements StateServiceAbstraction<TAccount>
{
private accountsSubject = new BehaviorSubject<{ [userId: string]: TAccount }>({});
protected accountsSubject = new BehaviorSubject<{ [userId: string]: TAccount }>({});
accounts$ = this.accountsSubject.asObservable();
private activeAccountSubject = new BehaviorSubject<string | null>(null);
protected activeAccountSubject = new BehaviorSubject<string | null>(null);
activeAccount$ = this.activeAccountSubject.asObservable();
private activeAccountUnlockedSubject = new BehaviorSubject<boolean>(false);
protected activeAccountUnlockedSubject = new BehaviorSubject<boolean>(false);
activeAccountUnlocked$ = this.activeAccountUnlockedSubject.asObservable();
private hasBeenInited = false;
@ -79,6 +81,9 @@ export class StateService<
private accountDiskCache = new Map<string, TAccount>();
// default account serializer, must be overridden by child class
protected accountDeserializer = Account.fromJSON as (json: Jsonify<TAccount>) => TAccount;
constructor(
protected storageService: AbstractStorageService,
protected secureStorageService: AbstractStorageService,
@ -676,7 +681,7 @@ export class StateService<
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultInMemoryOptions())
);
return this.recordToMap(account?.keys?.organizationKeys?.decrypted);
return Utils.recordToMap(account?.keys?.organizationKeys?.decrypted);
}
async setDecryptedOrganizationKeys(
@ -686,7 +691,7 @@ export class StateService<
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultInMemoryOptions())
);
account.keys.organizationKeys.decrypted = this.mapToRecord(value);
account.keys.organizationKeys.decrypted = Utils.mapToRecord(value);
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultInMemoryOptions())
@ -774,7 +779,7 @@ export class StateService<
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultInMemoryOptions())
);
return this.recordToMap(account?.keys?.providerKeys?.decrypted);
return Utils.recordToMap(account?.keys?.providerKeys?.decrypted);
}
async setDecryptedProviderKeys(
@ -784,7 +789,7 @@ export class StateService<
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultInMemoryOptions())
);
account.keys.providerKeys.decrypted = this.mapToRecord(value);
account.keys.providerKeys.decrypted = Utils.mapToRecord(value);
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultInMemoryOptions())
@ -2744,7 +2749,7 @@ export class StateService<
protected async state(): Promise<State<TGlobalState, TAccount>> {
const state = await this.memoryStorageService.get<State<TGlobalState, TAccount>>(keys.state, {
deserializer: (s) => State.fromJSON(s),
deserializer: (s) => State.fromJSON(s, this.accountDeserializer),
});
return state;
}
@ -2765,50 +2770,6 @@ export class StateService<
await this.setState(updatedState);
});
}
private mapToRecord<V>(map: Map<string, V>): Record<string, V> {
return map == null ? null : Object.fromEntries(map);
}
private recordToMap<V>(record: Record<string, V>): Map<string, V> {
return record == null ? null : new Map(Object.entries(record));
}
}
export function withPrototype<T>(
constructor: new (...args: any[]) => T,
converter: (input: any) => T = (i) => i
): (
target: any,
propertyKey: string | symbol,
descriptor: PropertyDescriptor
) => { value: (...args: any[]) => Promise<T> } {
return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
const originalMethod = descriptor.value;
return {
value: function (...args: any[]) {
const originalResult: Promise<T> = originalMethod.apply(this, args);
if (!(originalResult instanceof Promise)) {
throw new Error(
`Error applying prototype to stored value -- result is not a promise for method ${String(
propertyKey
)}`
);
}
return originalResult.then((result) => {
return result == null ||
result.constructor.name === constructor.prototype.constructor.name
? converter(result as T)
: converter(
Object.create(constructor.prototype, Object.getOwnPropertyDescriptors(result)) as T
);
});
},
};
};
}
function withPrototypeForArrayMembers<T>(
@ -2847,7 +2808,7 @@ function withPrototypeForArrayMembers<T>(
return result.map((r) => {
return r == null ||
r.constructor.name === memberConstructor.prototype.constructor.name
? memberConverter(r)
? r
: memberConverter(
Object.create(memberConstructor.prototype, Object.getOwnPropertyDescriptors(r))
);