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:
@@ -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", () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: (
|
||||||
|
|||||||
3
libs/angular/test-utils.ts
Normal file
3
libs/angular/test-utils.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export async function awaitAsync(ms = 0) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user