1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-11 22:13:32 +00:00

Implement get$

This commit is contained in:
Justin Baur
2024-12-20 09:30:33 -05:00
parent e129e90faa
commit 2c61bfd0e5
15 changed files with 170 additions and 124 deletions

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Subject, filter, firstValueFrom, map, merge, timeout } from "rxjs";
import { Subject, defer, filter, firstValueFrom, map, merge, shareReplay, timeout } from "rxjs";
import { CollectionService, DefaultCollectionService } from "@bitwarden/admin-console/common";
import {
@@ -103,7 +103,6 @@ import { StateFactory } from "@bitwarden/common/platform/factories/state-factory
import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging";
// eslint-disable-next-line no-restricted-imports -- Used for dependency creation
import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal";
import { Lazy } from "@bitwarden/common/platform/misc/lazy";
import { clearCaches } from "@bitwarden/common/platform/misc/sequentialize";
import { Account } from "@bitwarden/common/platform/models/domain/account";
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
@@ -262,7 +261,6 @@ import { BrowserPlatformUtilsService } from "../platform/services/platform-utils
import { PopupViewCacheBackgroundService } from "../platform/services/popup-view-cache-background.service";
import { BrowserSdkClientFactory } from "../platform/services/sdk/browser-sdk-client-factory";
import { BackgroundTaskSchedulerService } from "../platform/services/task-scheduler/background-task-scheduler.service";
import { BackgroundMemoryStorageService } from "../platform/storage/background-memory-storage.service";
import { BrowserStorageServiceProvider } from "../platform/storage/browser-storage-service.provider";
import { OffscreenStorageService } from "../platform/storage/offscreen-storage.service";
import { SyncServiceListener } from "../platform/sync/sync-service.listener";
@@ -463,20 +461,9 @@ export default class MainBackground {
this.offscreenDocumentService,
);
this.secureStorageService = this.storageService; // secure storage is not supported in browsers, so we use local storage and warn users when it is used
if (BrowserApi.isManifestVersion(3)) {
// manifest v3 can reuse the same storage. They are split for v2 due to lacking a good sync mechanism, which isn't true for v3
this.memoryStorageForStateProviders = new BrowserMemoryStorageService(); // mv3 stores to storage.session
this.memoryStorageService = this.memoryStorageForStateProviders;
} else {
this.memoryStorageForStateProviders = new BackgroundMemoryStorageService(); // mv2 stores to memory
this.memoryStorageService = this.memoryStorageForStateProviders;
}
if (BrowserApi.isManifestVersion(3)) {
// Creates a session key for mv3 storage of large memory items
const sessionKey = new Lazy(async () => {
const sessionKey = defer(async () => {
// Key already in session storage
const sessionStorage = new BrowserMemoryStorageService();
const existingKey = await sessionStorage.get<SymmetricCryptoKey>("session-key");
@@ -495,7 +482,7 @@ export default class MainBackground {
);
await sessionStorage.save("session-key", derivedKey);
return derivedKey;
});
}).pipe(shareReplay({ bufferSize: 1, refCount: false }));
this.largeObjectMemoryStorageForStateProviders = new LocalBackedSessionStorageService(
sessionKey,

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { filter, mergeMap } from "rxjs";
import { concat, filter, map, mergeMap, Observable, share } from "rxjs";
import {
AbstractStorageService,
@@ -41,9 +41,14 @@ export const objToStore = (obj: any) => {
export default abstract class AbstractChromeStorageService
implements AbstractStorageService, ObservableStorageService
{
onChanged$: Observable<{ [key: string]: chrome.storage.StorageChange }>;
updates$;
constructor(protected chromeStorageApi: chrome.storage.StorageArea) {
this.onChanged$ = fromChromeEvent(this.chromeStorageApi.onChanged).pipe(
map(([change]) => change),
share(),
);
this.updates$ = fromChromeEvent(this.chromeStorageApi.onChanged).pipe(
filter(([changes]) => {
// Our storage services support changing only one key at a time. If more are changed, it's due to
@@ -75,6 +80,32 @@ export default abstract class AbstractChromeStorageService
return true;
}
get$<T>(key: string): Observable<T | null> {
const initialValue$ = new Observable<T>((subscriber) => {
this.chromeStorageApi.get(key, (obj) => {
if (chrome.runtime.lastError) {
subscriber.error(chrome.runtime.lastError);
return;
}
if (obj != null && obj[key] != null) {
subscriber.next(this.processGetObject<T>(obj[key]));
} else {
subscriber.next(null);
}
subscriber.complete();
});
});
const keyUpdates$: Observable<T> = this.onChanged$.pipe(
filter((change) => change[key] != null),
map((change) => change[key].newValue ?? null),
);
return concat(initialValue$, keyUpdates$);
}
async get<T>(key: string): Promise<T> {
return new Promise((resolve, reject) => {
this.chromeStorageApi.get(key, (obj) => {

View File

@@ -1,9 +1,9 @@
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Lazy } from "@bitwarden/common/platform/misc/lazy";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { FakeStorageService, makeEncString } from "@bitwarden/common/spec";
@@ -28,7 +28,7 @@ describe("LocalBackedSessionStorage", () => {
logService = mock<LogService>();
sut = new LocalBackedSessionStorageService(
new Lazy(async () => sessionKey),
of(sessionKey),
localStorage,
encryptService,
platformUtilsService,

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Subject } from "rxjs";
import { concat, EMPTY, firstValueFrom, Observable, Subject } from "rxjs";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -11,7 +11,6 @@ import {
StorageUpdate,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { compareValues } from "@bitwarden/common/platform/misc/compare-values";
import { Lazy } from "@bitwarden/common/platform/misc/lazy";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
@@ -31,7 +30,7 @@ export class LocalBackedSessionStorageService
updates$ = this.updatesSubject.asObservable();
constructor(
private readonly sessionKey: Lazy<Promise<SymmetricCryptoKey>>,
private readonly sessionKey$: Observable<SymmetricCryptoKey>,
private readonly localStorage: AbstractStorageService,
private readonly encryptService: EncryptService,
private readonly platformUtilsService: PlatformUtilsService,
@@ -71,12 +70,28 @@ export class LocalBackedSessionStorageService
return this.cache[key] as T;
}
const value = await this.getLocalSessionValue(await this.sessionKey.get(), key);
const value = await this.getLocalSessionValue(await firstValueFrom(this.sessionKey$), key);
this.cache[key] = value;
return value as T;
}
get$<T>(key: string) {
const initialValue$ = new Observable<T>((subscriber) => {
//
const cachedValue = this.cache[key] as T;
if (cachedValue !== undefined) {
subscriber.next(cachedValue);
subscriber.complete();
}
// Get value from session
});
// TODO: Connect something to get updates from elsewhere
return concat(initialValue$, EMPTY);
}
async has(key: string): Promise<boolean> {
return (await this.get(key)) != null;
}
@@ -104,13 +119,13 @@ export class LocalBackedSessionStorageService
this.cache[key] = obj;
await this.updateLocalSessionValue(key, obj);
this.updatesSubject.next({ key, updateType: "save" });
// this.updatesSubject.next({ key, updateType: "save" });
}
async remove(key: string): Promise<void> {
this.cache[key] = null;
await this.updateLocalSessionValue(key, null);
this.updatesSubject.next({ key, updateType: "remove" });
// this.updatesSubject.next({ key, updateType: "remove" });
}
private async getLocalSessionValue(encKey: SymmetricCryptoKey, key: string): Promise<unknown> {
@@ -140,7 +155,10 @@ export class LocalBackedSessionStorageService
}
const valueJson = JSON.stringify(value);
const encValue = await this.encryptService.encrypt(valueJson, await this.sessionKey.get());
const encValue = await this.encryptService.encrypt(
valueJson,
await firstValueFrom(this.sessionKey$),
);
await this.localStorage.save(this.sessionStorageKey(key), encValue.encryptedString);
}

View File

@@ -33,12 +33,6 @@ export class BackgroundMemoryStorageService extends MemoryStorageService {
data: Array.from(Object.keys(this.store)),
});
});
this.updates$.subscribe((update) => {
this.broadcastMessage({
action: "subject_update",
data: update,
});
});
}
private async onMessageFromForeground(

View File

@@ -1,7 +1,8 @@
import { Observable, Subject, filter, firstValueFrom, map } from "rxjs";
import { EMPTY, Observable, Subject, concat, filter, firstValueFrom, map } from "rxjs";
import {
AbstractStorageService,
ObservableStorageService,
StorageUpdate,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -11,7 +12,10 @@ import { fromChromeEvent } from "../browser/from-chrome-event";
import { MemoryStoragePortMessage } from "./port-messages";
import { portName } from "./port-name";
export class ForegroundMemoryStorageService extends AbstractStorageService {
export class ForegroundMemoryStorageService
extends AbstractStorageService
implements ObservableStorageService
{
private _port: chrome.runtime.Port;
private _backgroundResponses$: Observable<MemoryStoragePortMessage>;
private updatesSubject = new Subject<StorageUpdate>();
@@ -19,13 +23,10 @@ export class ForegroundMemoryStorageService extends AbstractStorageService {
get valuesRequireDeserialization(): boolean {
return true;
}
updates$;
constructor(private partitionName?: string) {
super();
this.updates$ = this.updatesSubject.asObservable();
let name = portName(chrome.storage.session);
if (this.partitionName) {
name = `${name}_${this.partitionName}`;
@@ -59,6 +60,23 @@ export class ForegroundMemoryStorageService extends AbstractStorageService {
async get<T>(key: string): Promise<T> {
return await this.delegateToBackground<T>("get", key);
}
get$<T>(key: string) {
return concat(
new Observable<T>((subscriber) => {
this.delegateToBackground<T>("get", key)
.then((value) => {
subscriber.next(value);
subscriber.complete();
})
.catch((err) => {
subscriber.error(err);
});
}),
EMPTY, // TODO: Make a connection to background to hear about updates
);
}
async has(key: string): Promise<boolean> {
return await this.delegateToBackground<boolean>("has", key);
}
@@ -104,7 +122,7 @@ export class ForegroundMemoryStorageService extends AbstractStorageService {
private handleInitialize(data: string[]) {
// TODO: this isn't a save, but we don't have a better indicator for this
data.forEach((key) => {
this.updatesSubject.next({ key, updateType: "save" });
// this.updatesSubject.next({ key, updateType: "save" });
});
}

View File

@@ -3,8 +3,6 @@
* @jest-environment ../../libs/shared/test.environment.ts
*/
import { trackEmissions } from "@bitwarden/common/../spec/utils";
import { mockPorts } from "../../../spec/mock-port.spec-util";
import { BackgroundMemoryStorageService } from "./background-memory-storage.service";
@@ -54,15 +52,15 @@ describe("foreground background memory storage interaction", () => {
expect(actionSpy).toHaveBeenCalledWith(key);
});
test("background updates push to foreground", async () => {
const key = "key";
const value = "value";
const updateType = "save";
const emissions = trackEmissions(foreground.updates$);
await background.save(key, value);
// test("background updates push to foreground", async () => {
// const key = "key";
// const value = "value";
// const updateType = "save";
// const emissions = trackEmissions(foreground.updates$);
// await background.save(key, value);
expect(emissions).toEqual([{ key, updateType }]);
});
// expect(emissions).toEqual([{ key, updateType }]);
// });
test("background should message only the requesting foreground", async () => {
const secondForeground = new ForegroundMemoryStorageService();

View File

@@ -1,19 +1,13 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Injectable } from "@angular/core";
import { Subject } from "rxjs";
import {
AbstractStorageService,
StorageUpdate,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import { HtmlStorageLocation } from "@bitwarden/common/platform/enums";
import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options";
@Injectable()
export class HtmlStorageService implements AbstractStorageService {
private updatesSubject = new Subject<StorageUpdate>();
get defaultOptions(): StorageOptions {
return { htmlStorageLocation: HtmlStorageLocation.Session };
}
@@ -21,11 +15,6 @@ export class HtmlStorageService implements AbstractStorageService {
get valuesRequireDeserialization(): boolean {
return true;
}
updates$;
constructor() {
this.updates$ = this.updatesSubject.asObservable();
}
get<T>(key: string, options: StorageOptions = this.defaultOptions): Promise<T> {
let json: string = null;
@@ -69,7 +58,6 @@ export class HtmlStorageService implements AbstractStorageService {
window.sessionStorage.setItem(key, json);
break;
}
this.updatesSubject.next({ key, updateType: "save" });
return Promise.resolve();
}
@@ -83,7 +71,6 @@ export class HtmlStorageService implements AbstractStorageService {
window.sessionStorage.removeItem(key);
break;
}
this.updatesSubject.next({ key, updateType: "remove" });
return Promise.resolve();
}
}

View File

@@ -1,5 +1,5 @@
import { MockProxy, mock } from "jest-mock-extended";
import { Subject } from "rxjs";
import { concat, filter, map, Observable, of, Subject } from "rxjs";
import {
AbstractStorageService,
@@ -20,11 +20,11 @@ export class FakeStorageService implements AbstractStorageService, ObservableSto
* amount of calls. It is not recommended to use this to mock implementations as
* they are not respected.
*/
mock: MockProxy<AbstractStorageService>;
mock: MockProxy<AbstractStorageService & ObservableStorageService>;
constructor(initial?: Record<string, unknown>) {
this.store = initial ?? {};
this.mock = mock<AbstractStorageService>();
this.mock = mock<AbstractStorageService & ObservableStorageService>();
}
/**
@@ -48,8 +48,15 @@ export class FakeStorageService implements AbstractStorageService, ObservableSto
return this._valuesRequireDeserialization;
}
get updates$() {
return this.updatesSubject.asObservable();
get$<T>(key: string): Observable<T> {
this.mock.get$(key);
return concat(
of((this.store[key] as T) ?? null),
this.updatesSubject.pipe(
filter((update) => update.key == key),
map((update) => (this.store[key] as T) ?? null),
),
);
}
get<T>(key: string, options?: StorageOptions): Promise<T> {

View File

@@ -9,12 +9,7 @@ export type StorageUpdate = {
};
export interface ObservableStorageService {
/**
* Provides an {@link Observable} that represents a stream of updates that
* have happened in this storage service or in the storage this service provides
* an interface to.
*/
get updates$(): Observable<StorageUpdate>;
get$<T>(key: string): Observable<T>;
}
export abstract class AbstractStorageService {

View File

@@ -1,19 +1,13 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Subject } from "rxjs";
import { AbstractStorageService, StorageUpdate } from "../abstractions/storage.service";
import { AbstractStorageService } from "../abstractions/storage.service";
export class MemoryStorageService extends AbstractStorageService {
protected store = new Map<string, unknown>();
private updatesSubject = new Subject<StorageUpdate>();
get valuesRequireDeserialization(): boolean {
return false;
}
get updates$() {
return this.updatesSubject.asObservable();
}
get<T>(key: string): Promise<T> {
if (this.store.has(key)) {
@@ -35,13 +29,11 @@ export class MemoryStorageService extends AbstractStorageService {
// Needed to ensure ownership of all memory by the context running the storage service
const toStore = structuredClone(obj);
this.store.set(key, toStore);
this.updatesSubject.next({ key, updateType: "save" });
return Promise.resolve();
}
remove(key: string): Promise<void> {
this.store.delete(key);
this.updatesSubject.next({ key, updateType: "remove" });
return Promise.resolve();
}
}

View File

@@ -1,18 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
Observable,
ReplaySubject,
defer,
filter,
firstValueFrom,
merge,
share,
switchMap,
tap,
timeout,
timer,
} from "rxjs";
import { Observable, ReplaySubject, firstValueFrom, map, share, tap, timeout, timer } from "rxjs";
import { Jsonify } from "type-fest";
import { StorageKey } from "../../../types/state";
@@ -44,22 +32,16 @@ export abstract class StateBase<T, KeyDef extends KeyDefinitionRequirements<T>>
protected readonly keyDefinition: KeyDef,
protected readonly logService: LogService,
) {
const storageUpdate$ = storageService.updates$.pipe(
filter((storageUpdate) => storageUpdate.key === key),
switchMap(async (storageUpdate) => {
if (storageUpdate.updateType === "remove") {
return null;
let state$ = this.storageService.get$<T>(key).pipe(
map((value) => {
if (this.storageService.valuesRequireDeserialization) {
return this.keyDefinition.deserializer(value as Jsonify<T>);
}
return await getStoredValue(key, storageService, keyDefinition.deserializer);
return value;
}),
);
let state$ = merge(
defer(() => getStoredValue(key, storageService, keyDefinition.deserializer)),
storageUpdate$,
);
if (keyDefinition.debug.enableRetrievalLogging) {
state$ = state$.pipe(
tap({

View File

@@ -1,11 +1,11 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Subject } from "rxjs";
import { concat, filter, map, of, Subject } from "rxjs";
import {
AbstractStorageService,
ObservableStorageService,
StorageUpdate,
StorageUpdateType,
} from "../../abstractions/storage.service";
export class MemoryStorageService
@@ -13,14 +13,11 @@ export class MemoryStorageService
implements ObservableStorageService
{
protected store: Record<string, string> = {};
private updatesSubject = new Subject<StorageUpdate>();
private updatesSubject = new Subject<{ key: string; updateType: StorageUpdateType }>();
get valuesRequireDeserialization(): boolean {
return true;
}
get updates$() {
return this.updatesSubject.asObservable();
}
get<T>(key: string): Promise<T> {
const json = this.store[key];
@@ -31,6 +28,31 @@ export class MemoryStorageService
return Promise.resolve(null);
}
private getValue<T>(key: string): T | null {
const json = this.store[key];
if (json) {
return JSON.parse(json) as T;
}
return null;
}
get$<T>(key: string) {
return concat(
of(this.getValue<T>(key)),
this.updatesSubject.pipe(
filter((update) => update.key === key),
map((update) => {
if (update.updateType === "remove") {
return null;
}
return this.getValue<T>(key);
}),
),
);
}
async has(key: string): Promise<boolean> {
return (await this.get(key)) != null;
}

View File

@@ -5,7 +5,6 @@ export class PrimarySecondaryStorageService
implements AbstractStorageService, ObservableStorageService
{
// Only follow the primary storage service as updates should all be done to both
updates$ = this.primaryStorageService.updates$;
constructor(
private readonly primaryStorageService: AbstractStorageService & ObservableStorageService,
@@ -25,6 +24,11 @@ export class PrimarySecondaryStorageService
return this.primaryStorageService.valuesRequireDeserialization;
}
get$<T>(key: string) {
// TODO: What is best here?
return this.primaryStorageService.get$<T>(key);
}
async get<T>(key: string, options?: StorageOptions): Promise<T> {
const primaryValue = await this.primaryStorageService.get<T>(key, options);

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable, Subject } from "rxjs";
import { concat, filter, map, Observable, of, Subject } from "rxjs";
import {
AbstractStorageService,
@@ -10,24 +10,35 @@ import {
import { StorageOptions } from "../models/domain/storage-options";
export class WindowStorageService implements AbstractStorageService, ObservableStorageService {
private readonly updatesSubject = new Subject<StorageUpdate>();
private readonly updatesSubject = new Subject<StorageUpdate & { value: unknown }>();
updates$: Observable<StorageUpdate>;
constructor(private readonly storage: Storage) {
this.updates$ = this.updatesSubject.asObservable();
}
constructor(private readonly storage: Storage) {}
get valuesRequireDeserialization(): boolean {
return true;
}
get<T>(key: string, options?: StorageOptions): Promise<T> {
get$<T>(key: string): Observable<T> {
return concat(
of(this.getValue(key)),
this.updatesSubject.pipe(
filter((update) => update.key === key),
map((update) => update.value as T),
),
);
}
private getValue<T>(key: string) {
const jsonValue = this.storage.getItem(key);
if (jsonValue != null) {
return Promise.resolve(JSON.parse(jsonValue) as T);
return JSON.parse(jsonValue) as T;
}
return Promise.resolve(null);
return null;
}
get<T>(key: string, options?: StorageOptions): Promise<T> {
return Promise.resolve(this.getValue(key));
}
async has(key: string, options?: StorageOptions): Promise<boolean> {
@@ -44,12 +55,12 @@ export class WindowStorageService implements AbstractStorageService, ObservableS
}
this.storage.setItem(key, JSON.stringify(obj));
this.updatesSubject.next({ key, updateType: "save" });
this.updatesSubject.next({ key, updateType: "save", value: obj });
}
remove(key: string, options?: StorageOptions): Promise<void> {
this.storage.removeItem(key);
this.updatesSubject.next({ key, updateType: "remove" });
this.updatesSubject.next({ key, updateType: "remove", value: null });
return Promise.resolve();
}