1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-16 16:23:44 +00:00

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.
This commit is contained in:
Matt Gibson
2022-11-16 18:54:54 -05:00
parent 04f5e6a144
commit 430e10c221
9 changed files with 116 additions and 43 deletions

View File

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

View File

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

View File

@@ -2,10 +2,11 @@ import { Jsonify } from "type-fest";
import { SessionStorable } from "./session-storable"; import { SessionStorable } from "./session-storable";
class BuildOptions<T> { class BuildOptions<T, TJson = Jsonify<T>> {
ctor?: new () => T; ctor?: new () => T;
initializer?: (keyValuePair: Jsonify<T>) => T; initializer?: (keyValuePair: TJson) => T;
initializeAsArray? = false; initializeAsArray? = false;
initializeAsRecord? = false;
} }
/** /**
@@ -47,6 +48,7 @@ export function sessionSync<T>(buildOptions: BuildOptions<T>) {
ctor: buildOptions.ctor, ctor: buildOptions.ctor,
initializer: buildOptions.initializer, initializer: buildOptions.initializer,
initializeAsArray: buildOptions.initializeAsArray, initializeAsArray: buildOptions.initializeAsArray,
initializeAsRecord: buildOptions.initializeAsRecord,
}); });
}; };
} }

View File

@@ -1,5 +1,6 @@
import { awaitAsync as flushAsyncObservables } from "@bitwarden/angular/../test-utils";
import { mock, MockProxy } from "jest-mock-extended"; import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject, ReplaySubject } from "rxjs";
import { BrowserApi } from "../../browser/browserApi"; import { BrowserApi } from "../../browser/browserApi";
import { StateService } from "../../services/abstractions/state.service"; import { StateService } from "../../services/abstractions/state.service";
@@ -34,10 +35,10 @@ describe("session syncer", () => {
}); });
describe("constructor", () => { 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(() => { expect(() => {
new SessionSyncer({} as any, stateService, null); 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", () => { it("should create if either ctor or initializer is provided", () => {
@@ -59,28 +60,50 @@ describe("session syncer", () => {
}); });
}); });
describe("manifest v2 init", () => { describe("init", () => {
let observeSpy: jest.SpyInstance; it("should ignore all updates currently in a ReplaySubject's buffer", () => {
let listenForUpdatesSpy: jest.SpyInstance; const replaySubject = new ReplaySubject<string>(Infinity);
replaySubject.next("1");
beforeEach(() => { replaySubject.next("2");
observeSpy = jest.spyOn(behaviorSubject, "subscribe").mockReturnThis(); replaySubject.next("3");
listenForUpdatesSpy = jest.spyOn(BrowserApi, "messageListener").mockReturnValue(); sut = new SessionSyncer(replaySubject, stateService, metaData);
jest.spyOn(chrome.runtime, "getManifest").mockReturnValue({ // block observing the subject
name: "bitwarden-test", jest.spyOn(sut as any, "observe").mockImplementation();
version: "0.0.0",
manifest_version: 2,
});
sut.init(); sut.init();
expect(sut["ignoreNUpdates"]).toBe(3);
}); });
it("should not start observing", () => { it("should ignore BehaviorSubject's initial value", () => {
expect(observeSpy).not.toHaveBeenCalled(); 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", () => { it("should grab an initial value from storage if it exists", () => {
expect(listenForUpdatesSpy).not.toHaveBeenCalled(); 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 +169,7 @@ describe("session syncer", () => {
stateService.getFromSessionMemory.mockResolvedValue("test"); stateService.getFromSessionMemory.mockResolvedValue("test");
await sut.updateFromMessage({ command: `${sessionKey}_update`, id: "different_id" }); await sut.updateFromMessage({ command: `${sessionKey}_update`, id: "different_id" });
await flushAsyncObservables();
expect(stateService.getFromSessionMemory).toHaveBeenCalledTimes(1); expect(stateService.getFromSessionMemory).toHaveBeenCalledTimes(1);
expect(stateService.getFromSessionMemory).toHaveBeenCalledWith(sessionKey, builder); expect(stateService.getFromSessionMemory).toHaveBeenCalledWith(sessionKey, builder);

View File

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

View File

@@ -4,14 +4,27 @@ export class SyncedItemMetadata {
ctor?: new () => any; ctor?: new () => any;
initializer?: (keyValuePair: any) => any; initializer?: (keyValuePair: any) => any;
initializeAsArray?: boolean; initializeAsArray?: boolean;
initializeAsRecord?: boolean;
static builder(metadata: SyncedItemMetadata): (o: any) => any { static builder(metadata: SyncedItemMetadata): (o: any) => any {
if (metadata.initializeAsArray && metadata.initializeAsRecord) {
throw new Error("initializeAsArray and initializeAsRecord cannot both be true");
}
const itemBuilder = const itemBuilder =
metadata.initializer != null metadata.initializer != null
? metadata.initializer ? metadata.initializer
: (o: any) => Object.assign(new metadata.ctor(), o); : (o: any) => Object.assign(new metadata.ctor(), o);
if (metadata.initializeAsArray) { if (metadata.initializeAsArray) {
return (keyValuePair: any) => keyValuePair.map((o: any) => itemBuilder(o)); return (keyValuePair: any) => keyValuePair.map((o: any) => itemBuilder(o));
} else if (metadata.initializeAsRecord) {
return (keyValuePair: any) => {
const record: Record<any, any> = {};
for (const key in keyValuePair) {
record[key] = itemBuilder(keyValuePair[key]);
}
return record;
};
} else { } else {
return (keyValuePair: any) => itemBuilder(keyValuePair); return (keyValuePair: any) => itemBuilder(keyValuePair);
} }

View File

@@ -36,4 +36,16 @@ describe("builder", () => {
expect(builder([{}])).toBeInstanceOf(Array); expect(builder([{}])).toBeInstanceOf(Array);
expect(builder([{}])[0]).toBe("used initializer"); expect(builder([{}])[0]).toBe("used initializer");
}); });
it("should honor initialize as record", () => {
const metadata = {
propertyKey,
sessionKey: key,
initializer: initializer,
initializeAsRecord: true,
};
const builder = SyncedItemMetadata.builder(metadata);
expect(builder({ key: "" })).toBeInstanceOf(Object);
expect(builder({ key: "" })).toStrictEqual({ key: "used initializer" });
});
}); });

View File

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

View File

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