Merge branch 'main' of https://github.com/bitwarden/clients into pm-7231-product-switcher-navigation

This commit is contained in:
nick-livefront 2024-04-26 16:39:10 -05:00
commit d748d006b8
No known key found for this signature in database
GPG Key ID: FF670021ABCAB82E
25 changed files with 136 additions and 301 deletions

View File

@ -164,6 +164,10 @@ jobs:
run: npm run dist:mv3 run: npm run dist:mv3
working-directory: browser-source/apps/browser working-directory: browser-source/apps/browser
- name: Build Chrome Manifest v3 Beta
run: npm run dist:chrome:beta
working-directory: browser-source/apps/browser
- name: Gulp - name: Gulp
run: gulp ci run: gulp ci
working-directory: browser-source/apps/browser working-directory: browser-source/apps/browser
@ -196,6 +200,13 @@ jobs:
path: browser-source/apps/browser/dist/dist-chrome-mv3.zip path: browser-source/apps/browser/dist/dist-chrome-mv3.zip
if-no-files-found: error if-no-files-found: error
- name: Upload Chrome MV3 Beta artifact (DO NOT USE FOR PROD)
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: DO-NOT-USE-FOR-PROD-dist-chrome-MV3-beta-${{ env._BUILD_NUMBER }}.zip
path: browser-source/apps/browser/dist/dist-chrome-mv3-beta.zip
if-no-files-found: error
- name: Upload Firefox artifact - name: Upload Firefox artifact
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with: with:

View File

@ -35,6 +35,9 @@ function buildString() {
if (process.env.MANIFEST_VERSION) { if (process.env.MANIFEST_VERSION) {
build = `-mv${process.env.MANIFEST_VERSION}`; build = `-mv${process.env.MANIFEST_VERSION}`;
} }
if (process.env.BETA_BUILD === "1") {
build += "-beta";
}
if (process.env.BUILD_NUMBER && process.env.BUILD_NUMBER !== "") { if (process.env.BUILD_NUMBER && process.env.BUILD_NUMBER !== "") {
build = `-${process.env.BUILD_NUMBER}`; build = `-${process.env.BUILD_NUMBER}`;
} }
@ -65,6 +68,9 @@ function distFirefox() {
manifest.optional_permissions = manifest.optional_permissions.filter( manifest.optional_permissions = manifest.optional_permissions.filter(
(permission) => permission !== "privacy", (permission) => permission !== "privacy",
); );
if (process.env.BETA_BUILD === "1") {
manifest = applyBetaLabels(manifest);
}
return manifest; return manifest;
}); });
} }
@ -72,6 +78,9 @@ function distFirefox() {
function distOpera() { function distOpera() {
return dist("opera", (manifest) => { return dist("opera", (manifest) => {
delete manifest.applications; delete manifest.applications;
if (process.env.BETA_BUILD === "1") {
manifest = applyBetaLabels(manifest);
}
return manifest; return manifest;
}); });
} }
@ -81,6 +90,9 @@ function distChrome() {
delete manifest.applications; delete manifest.applications;
delete manifest.sidebar_action; delete manifest.sidebar_action;
delete manifest.commands._execute_sidebar_action; delete manifest.commands._execute_sidebar_action;
if (process.env.BETA_BUILD === "1") {
manifest = applyBetaLabels(manifest);
}
return manifest; return manifest;
}); });
} }
@ -90,6 +102,9 @@ function distEdge() {
delete manifest.applications; delete manifest.applications;
delete manifest.sidebar_action; delete manifest.sidebar_action;
delete manifest.commands._execute_sidebar_action; delete manifest.commands._execute_sidebar_action;
if (process.env.BETA_BUILD === "1") {
manifest = applyBetaLabels(manifest);
}
return manifest; return manifest;
}); });
} }
@ -210,6 +225,9 @@ async function safariCopyBuild(source, dest) {
delete manifest.commands._execute_sidebar_action; delete manifest.commands._execute_sidebar_action;
delete manifest.optional_permissions; delete manifest.optional_permissions;
manifest.permissions.push("nativeMessaging"); manifest.permissions.push("nativeMessaging");
if (process.env.BETA_BUILD === "1") {
manifest = applyBetaLabels(manifest);
}
return manifest; return manifest;
}), }),
), ),
@ -235,6 +253,19 @@ async function ciCoverage(cb) {
.pipe(gulp.dest(paths.coverage)); .pipe(gulp.dest(paths.coverage));
} }
function applyBetaLabels(manifest) {
manifest.name = "Bitwarden Password Manager BETA";
manifest.short_name = "Bitwarden BETA";
manifest.description = "THIS EXTENSION IS FOR BETA TESTING BITWARDEN.";
if (process.env.GITHUB_RUN_ID) {
manifest.version_name = `${manifest.version} beta - ${process.env.GITHUB_SHA.slice(0, 8)}`;
manifest.version = `${manifest.version}.${parseInt(process.env.GITHUB_RUN_ID.slice(-4))}`;
} else {
manifest.version = `${manifest.version}.0`;
}
return manifest;
}
exports["dist:firefox"] = distFirefox; exports["dist:firefox"] = distFirefox;
exports["dist:chrome"] = distChrome; exports["dist:chrome"] = distChrome;
exports["dist:opera"] = distOpera; exports["dist:opera"] = distOpera;

View File

@ -7,10 +7,14 @@
"build:watch": "webpack --watch", "build:watch": "webpack --watch",
"build:watch:mv3": "cross-env MANIFEST_VERSION=3 webpack --watch", "build:watch:mv3": "cross-env MANIFEST_VERSION=3 webpack --watch",
"build:prod": "cross-env NODE_ENV=production webpack", "build:prod": "cross-env NODE_ENV=production webpack",
"build:prod:beta": "cross-env BETA_BUILD=1 NODE_ENV=production webpack",
"build:prod:watch": "cross-env NODE_ENV=production webpack --watch", "build:prod:watch": "cross-env NODE_ENV=production webpack --watch",
"dist": "npm run build:prod && gulp dist", "dist": "npm run build:prod && gulp dist",
"dist:beta": "npm run build:prod:beta && cross-env BETA_BUILD=1 gulp dist",
"dist:mv3": "cross-env MANIFEST_VERSION=3 npm run build:prod && cross-env MANIFEST_VERSION=3 gulp dist", "dist:mv3": "cross-env MANIFEST_VERSION=3 npm run build:prod && cross-env MANIFEST_VERSION=3 gulp dist",
"dist:mv3:beta": "cross-env MANIFEST_VERSION=3 npm run build:prod:beta && cross-env MANIFEST_VERSION=3 BETA_BUILD=1 gulp dist",
"dist:chrome": "npm run build:prod && gulp dist:chrome", "dist:chrome": "npm run build:prod && gulp dist:chrome",
"dist:chrome:beta": "cross-env MANIFEST_VERSION=3 npm run build:prod:beta && cross-env MANIFEST_VERSION=3 BETA_BUILD=1 gulp dist:chrome",
"dist:firefox": "npm run build:prod && gulp dist:firefox", "dist:firefox": "npm run build:prod && gulp dist:firefox",
"dist:opera": "npm run build:prod && gulp dist:opera", "dist:opera": "npm run build:prod && gulp dist:opera",
"dist:safari": "npm run build:prod && gulp dist:safari", "dist:safari": "npm run build:prod && gulp dist:safari",

View File

@ -490,7 +490,7 @@ export default class MainBackground {
this.accountService, this.accountService,
this.singleUserStateProvider, this.singleUserStateProvider,
); );
this.derivedStateProvider = new BackgroundDerivedStateProvider(storageServiceProvider); this.derivedStateProvider = new BackgroundDerivedStateProvider();
this.stateProvider = new DefaultStateProvider( this.stateProvider = new DefaultStateProvider(
this.activeUserStateProvider, this.activeUserStateProvider,
this.singleUserStateProvider, this.singleUserStateProvider,

View File

@ -3,15 +3,10 @@ import { DerivedStateProvider } from "@bitwarden/common/platform/state";
import { BackgroundDerivedStateProvider } from "../../state/background-derived-state.provider"; import { BackgroundDerivedStateProvider } from "../../state/background-derived-state.provider";
import { CachedServices, FactoryOptions, factory } from "./factory-options"; import { CachedServices, FactoryOptions, factory } from "./factory-options";
import {
StorageServiceProviderInitOptions,
storageServiceProviderFactory,
} from "./storage-service-provider.factory";
type DerivedStateProviderFactoryOptions = FactoryOptions; type DerivedStateProviderFactoryOptions = FactoryOptions;
export type DerivedStateProviderInitOptions = DerivedStateProviderFactoryOptions & export type DerivedStateProviderInitOptions = DerivedStateProviderFactoryOptions;
StorageServiceProviderInitOptions;
export async function derivedStateProviderFactory( export async function derivedStateProviderFactory(
cache: { derivedStateProvider?: DerivedStateProvider } & CachedServices, cache: { derivedStateProvider?: DerivedStateProvider } & CachedServices,
@ -21,7 +16,6 @@ export async function derivedStateProviderFactory(
cache, cache,
"derivedStateProvider", "derivedStateProvider",
opts, opts,
async () => async () => new BackgroundDerivedStateProvider(),
new BackgroundDerivedStateProvider(await storageServiceProviderFactory(cache, opts)),
); );
} }

View File

@ -1,9 +1,5 @@
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import {
AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { DeriveDefinition, DerivedState } from "@bitwarden/common/platform/state"; import { DeriveDefinition, DerivedState } from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- extending this class for this client // eslint-disable-next-line import/no-restricted-paths -- extending this class for this client
import { DefaultDerivedStateProvider } from "@bitwarden/common/platform/state/implementations/default-derived-state.provider"; import { DefaultDerivedStateProvider } from "@bitwarden/common/platform/state/implementations/default-derived-state.provider";
@ -16,14 +12,11 @@ export class BackgroundDerivedStateProvider extends DefaultDerivedStateProvider
parentState$: Observable<TFrom>, parentState$: Observable<TFrom>,
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>, deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
dependencies: TDeps, dependencies: TDeps,
storageLocation: [string, AbstractStorageService & ObservableStorageService],
): DerivedState<TTo> { ): DerivedState<TTo> {
const [location, storageService] = storageLocation;
return new BackgroundDerivedState( return new BackgroundDerivedState(
parentState$, parentState$,
deriveDefinition, deriveDefinition,
storageService, deriveDefinition.buildCacheKey(),
deriveDefinition.buildCacheKey(location),
dependencies, dependencies,
); );
} }

View File

@ -1,10 +1,7 @@
import { Observable, Subscription } from "rxjs"; import { Observable, Subscription, concatMap } from "rxjs";
import { Jsonify } from "type-fest"; import { Jsonify } from "type-fest";
import { import { Utils } from "@bitwarden/common/platform/misc/utils";
AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { DeriveDefinition } from "@bitwarden/common/platform/state"; import { DeriveDefinition } from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- extending this class for this client // eslint-disable-next-line import/no-restricted-paths -- extending this class for this client
import { DefaultDerivedState } from "@bitwarden/common/platform/state/implementations/default-derived-state"; import { DefaultDerivedState } from "@bitwarden/common/platform/state/implementations/default-derived-state";
@ -22,11 +19,10 @@ export class BackgroundDerivedState<
constructor( constructor(
parentState$: Observable<TFrom>, parentState$: Observable<TFrom>,
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>, deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
memoryStorage: AbstractStorageService & ObservableStorageService,
portName: string, portName: string,
dependencies: TDeps, dependencies: TDeps,
) { ) {
super(parentState$, deriveDefinition, memoryStorage, dependencies); super(parentState$, deriveDefinition, dependencies);
// listen for foreground derived states to connect // listen for foreground derived states to connect
BrowserApi.addListener(chrome.runtime.onConnect, (port) => { BrowserApi.addListener(chrome.runtime.onConnect, (port) => {
@ -42,7 +38,20 @@ export class BackgroundDerivedState<
}); });
port.onMessage.addListener(listenerCallback); port.onMessage.addListener(listenerCallback);
const stateSubscription = this.state$.subscribe(); const stateSubscription = this.state$
.pipe(
concatMap(async (state) => {
await this.sendMessage(
{
action: "nextState",
data: JSON.stringify(state),
id: Utils.newGuid(),
},
port,
);
}),
)
.subscribe();
this.portSubscriptions.set(port, stateSubscription); this.portSubscriptions.set(port, stateSubscription);
}); });

View File

@ -4,14 +4,13 @@
*/ */
import { NgZone } from "@angular/core"; import { NgZone } from "@angular/core";
import { FakeStorageService } from "@bitwarden/common/../spec/fake-storage.service";
import { awaitAsync, trackEmissions } from "@bitwarden/common/../spec/utils";
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { Subject, firstValueFrom } from "rxjs"; import { Subject, firstValueFrom } from "rxjs";
import { DeriveDefinition } from "@bitwarden/common/platform/state"; import { DeriveDefinition } from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- needed to define a derive definition // eslint-disable-next-line import/no-restricted-paths -- needed to define a derive definition
import { StateDefinition } from "@bitwarden/common/platform/state/state-definition"; import { StateDefinition } from "@bitwarden/common/platform/state/state-definition";
import { awaitAsync, trackEmissions, ObservableTracker } from "@bitwarden/common/spec";
import { mockPorts } from "../../../spec/mock-port.spec-util"; import { mockPorts } from "../../../spec/mock-port.spec-util";
@ -22,6 +21,7 @@ const stateDefinition = new StateDefinition("test", "memory");
const deriveDefinition = new DeriveDefinition(stateDefinition, "test", { const deriveDefinition = new DeriveDefinition(stateDefinition, "test", {
derive: (dateString: string) => (dateString == null ? null : new Date(dateString)), derive: (dateString: string) => (dateString == null ? null : new Date(dateString)),
deserializer: (dateString: string) => (dateString == null ? null : new Date(dateString)), deserializer: (dateString: string) => (dateString == null ? null : new Date(dateString)),
cleanupDelayMs: 1000,
}); });
// Mock out the runInsideAngular operator so we don't have to deal with zone.js // Mock out the runInsideAngular operator so we don't have to deal with zone.js
@ -35,7 +35,6 @@ describe("foreground background derived state interactions", () => {
let foreground: ForegroundDerivedState<Date>; let foreground: ForegroundDerivedState<Date>;
let background: BackgroundDerivedState<string, Date, Record<string, unknown>>; let background: BackgroundDerivedState<string, Date, Record<string, unknown>>;
let parentState$: Subject<string>; let parentState$: Subject<string>;
let memoryStorage: FakeStorageService;
const initialParent = "2020-01-01"; const initialParent = "2020-01-01";
const ngZone = mock<NgZone>(); const ngZone = mock<NgZone>();
const portName = "testPort"; const portName = "testPort";
@ -43,16 +42,9 @@ describe("foreground background derived state interactions", () => {
beforeEach(() => { beforeEach(() => {
mockPorts(); mockPorts();
parentState$ = new Subject<string>(); parentState$ = new Subject<string>();
memoryStorage = new FakeStorageService();
background = new BackgroundDerivedState( background = new BackgroundDerivedState(parentState$, deriveDefinition, portName, {});
parentState$, foreground = new ForegroundDerivedState(deriveDefinition, portName, ngZone);
deriveDefinition,
memoryStorage,
portName,
{},
);
foreground = new ForegroundDerivedState(deriveDefinition, memoryStorage, portName, ngZone);
}); });
afterEach(() => { afterEach(() => {
@ -72,21 +64,13 @@ describe("foreground background derived state interactions", () => {
}); });
it("should initialize a late-connected foreground", async () => { it("should initialize a late-connected foreground", async () => {
const newForeground = new ForegroundDerivedState( const newForeground = new ForegroundDerivedState(deriveDefinition, portName, ngZone);
deriveDefinition, const backgroundTracker = new ObservableTracker(background.state$);
memoryStorage,
portName,
ngZone,
);
const backgroundEmissions = trackEmissions(background.state$);
parentState$.next(initialParent); parentState$.next(initialParent);
await awaitAsync(); const foregroundTracker = new ObservableTracker(newForeground.state$);
const foregroundEmissions = trackEmissions(newForeground.state$); expect(await backgroundTracker.expectEmission()).toEqual(new Date(initialParent));
await awaitAsync(10); expect(await foregroundTracker.expectEmission()).toEqual(new Date(initialParent));
expect(backgroundEmissions).toEqual([new Date(initialParent)]);
expect(foregroundEmissions).toEqual([new Date(initialParent)]);
}); });
describe("forceValue", () => { describe("forceValue", () => {

View File

@ -1,11 +1,6 @@
import { NgZone } from "@angular/core"; import { NgZone } from "@angular/core";
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import {
AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
import { DeriveDefinition, DerivedState } from "@bitwarden/common/platform/state"; import { DeriveDefinition, DerivedState } from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- extending this class for this client // eslint-disable-next-line import/no-restricted-paths -- extending this class for this client
import { DefaultDerivedStateProvider } from "@bitwarden/common/platform/state/implementations/default-derived-state.provider"; import { DefaultDerivedStateProvider } from "@bitwarden/common/platform/state/implementations/default-derived-state.provider";
@ -14,23 +9,17 @@ import { DerivedStateDependencies } from "@bitwarden/common/src/types/state";
import { ForegroundDerivedState } from "./foreground-derived-state"; import { ForegroundDerivedState } from "./foreground-derived-state";
export class ForegroundDerivedStateProvider extends DefaultDerivedStateProvider { export class ForegroundDerivedStateProvider extends DefaultDerivedStateProvider {
constructor( constructor(private ngZone: NgZone) {
storageServiceProvider: StorageServiceProvider, super();
private ngZone: NgZone,
) {
super(storageServiceProvider);
} }
override buildDerivedState<TFrom, TTo, TDeps extends DerivedStateDependencies>( override buildDerivedState<TFrom, TTo, TDeps extends DerivedStateDependencies>(
_parentState$: Observable<TFrom>, _parentState$: Observable<TFrom>,
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>, deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
_dependencies: TDeps, _dependencies: TDeps,
storageLocation: [string, AbstractStorageService & ObservableStorageService],
): DerivedState<TTo> { ): DerivedState<TTo> {
const [location, storageService] = storageLocation;
return new ForegroundDerivedState( return new ForegroundDerivedState(
deriveDefinition, deriveDefinition,
storageService, deriveDefinition.buildCacheKey(),
deriveDefinition.buildCacheKey(location),
this.ngZone, this.ngZone,
); );
} }

View File

@ -1,11 +1,5 @@
/**
* need to update test environment so structuredClone works appropriately
* @jest-environment ../../libs/shared/test.environment.ts
*/
import { NgZone } from "@angular/core"; import { NgZone } from "@angular/core";
import { awaitAsync, trackEmissions } from "@bitwarden/common/../spec"; import { awaitAsync } from "@bitwarden/common/../spec";
import { FakeStorageService } from "@bitwarden/common/../spec/fake-storage.service";
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { DeriveDefinition } from "@bitwarden/common/platform/state"; import { DeriveDefinition } from "@bitwarden/common/platform/state";
@ -32,15 +26,12 @@ jest.mock("../browser/run-inside-angular.operator", () => {
describe("ForegroundDerivedState", () => { describe("ForegroundDerivedState", () => {
let sut: ForegroundDerivedState<Date>; let sut: ForegroundDerivedState<Date>;
let memoryStorage: FakeStorageService;
const portName = "testPort"; const portName = "testPort";
const ngZone = mock<NgZone>(); const ngZone = mock<NgZone>();
beforeEach(() => { beforeEach(() => {
memoryStorage = new FakeStorageService();
memoryStorage.internalUpdateValuesRequireDeserialization(true);
mockPorts(); mockPorts();
sut = new ForegroundDerivedState(deriveDefinition, memoryStorage, portName, ngZone); sut = new ForegroundDerivedState(deriveDefinition, portName, ngZone);
}); });
afterEach(() => { afterEach(() => {
@ -67,18 +58,4 @@ describe("ForegroundDerivedState", () => {
expect(disconnectSpy).toHaveBeenCalled(); expect(disconnectSpy).toHaveBeenCalled();
expect(sut["port"]).toBeNull(); expect(sut["port"]).toBeNull();
}); });
it("should emit when the memory storage updates", async () => {
const dateString = "2020-01-01";
const emissions = trackEmissions(sut.state$);
await memoryStorage.save(deriveDefinition.storageKey, {
derived: true,
value: new Date(dateString),
});
await awaitAsync();
expect(emissions).toEqual([new Date(dateString)]);
});
}); });

View File

@ -6,19 +6,14 @@ import {
filter, filter,
firstValueFrom, firstValueFrom,
map, map,
merge,
of, of,
share, share,
switchMap, switchMap,
tap, tap,
timer, timer,
} from "rxjs"; } from "rxjs";
import { Jsonify, JsonObject } from "type-fest"; import { Jsonify } from "type-fest";
import {
AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DeriveDefinition, DerivedState } from "@bitwarden/common/platform/state"; import { DeriveDefinition, DerivedState } from "@bitwarden/common/platform/state";
import { DerivedStateDependencies } from "@bitwarden/common/types/state"; import { DerivedStateDependencies } from "@bitwarden/common/types/state";
@ -27,41 +22,28 @@ import { fromChromeEvent } from "../browser/from-chrome-event";
import { runInsideAngular } from "../browser/run-inside-angular.operator"; import { runInsideAngular } from "../browser/run-inside-angular.operator";
export class ForegroundDerivedState<TTo> implements DerivedState<TTo> { export class ForegroundDerivedState<TTo> implements DerivedState<TTo> {
private storageKey: string;
private port: chrome.runtime.Port; private port: chrome.runtime.Port;
private backgroundResponses$: Observable<DerivedStateMessage>; private backgroundResponses$: Observable<DerivedStateMessage>;
state$: Observable<TTo>; state$: Observable<TTo>;
constructor( constructor(
private deriveDefinition: DeriveDefinition<unknown, TTo, DerivedStateDependencies>, private deriveDefinition: DeriveDefinition<unknown, TTo, DerivedStateDependencies>,
private memoryStorage: AbstractStorageService & ObservableStorageService,
private portName: string, private portName: string,
private ngZone: NgZone, private ngZone: NgZone,
) { ) {
this.storageKey = deriveDefinition.storageKey; const latestValueFromPort$ = (port: chrome.runtime.Port) => {
return fromChromeEvent(port.onMessage).pipe(
const initialStorageGet$ = defer(() => { map(([message]) => message as DerivedStateMessage),
return this.getStoredValue(); filter((message) => message.originator === "background" && message.action === "nextState"),
}).pipe( map((message) => {
filter((s) => s.derived), const json = JSON.parse(message.data) as Jsonify<TTo>;
map((s) => s.value), return this.deriveDefinition.deserialize(json);
); }),
);
const latestStorage$ = this.memoryStorage.updates$.pipe( };
filter((s) => s.key === this.storageKey),
switchMap(async (storageUpdate) => {
if (storageUpdate.updateType === "remove") {
return null;
}
return await this.getStoredValue();
}),
filter((s) => s.derived),
map((s) => s.value),
);
this.state$ = defer(() => of(this.initializePort())).pipe( this.state$ = defer(() => of(this.initializePort())).pipe(
switchMap(() => merge(initialStorageGet$, latestStorage$)), switchMap(() => latestValueFromPort$(this.port)),
share({ share({
connector: () => new ReplaySubject<TTo>(1), connector: () => new ReplaySubject<TTo>(1),
resetOnRefCountZero: () => resetOnRefCountZero: () =>
@ -130,28 +112,4 @@ export class ForegroundDerivedState<TTo> implements DerivedState<TTo> {
this.port = null; this.port = null;
this.backgroundResponses$ = null; this.backgroundResponses$ = null;
} }
protected async getStoredValue(): Promise<{ derived: boolean; value: TTo | null }> {
if (this.memoryStorage.valuesRequireDeserialization) {
const storedJson = await this.memoryStorage.get<
Jsonify<{ derived: true; value: JsonObject }>
>(this.storageKey);
if (!storedJson?.derived) {
return { derived: false, value: null };
}
const value = this.deriveDefinition.deserialize(storedJson.value as any);
return { derived: true, value };
} else {
const stored = await this.memoryStorage.get<{ derived: true; value: TTo }>(this.storageKey);
if (!stored?.derived) {
return { derived: false, value: null };
}
return { derived: true, value: stored.value };
}
}
} }

View File

@ -473,7 +473,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({ safeProvider({
provide: DerivedStateProvider, provide: DerivedStateProvider,
useClass: ForegroundDerivedStateProvider, useClass: ForegroundDerivedStateProvider,
deps: [StorageServiceProvider, NgZone], deps: [NgZone],
}), }),
safeProvider({ safeProvider({
provide: AutofillSettingsServiceAbstraction, provide: AutofillSettingsServiceAbstraction,

View File

@ -314,7 +314,7 @@ export class Main {
this.singleUserStateProvider, this.singleUserStateProvider,
); );
this.derivedStateProvider = new DefaultDerivedStateProvider(storageServiceProvider); this.derivedStateProvider = new DefaultDerivedStateProvider();
this.stateProvider = new DefaultStateProvider( this.stateProvider = new DefaultStateProvider(
this.activeUserStateProvider, this.activeUserStateProvider,

View File

@ -157,7 +157,7 @@ export class Main {
activeUserStateProvider, activeUserStateProvider,
singleUserStateProvider, singleUserStateProvider,
globalStateProvider, globalStateProvider,
new DefaultDerivedStateProvider(storageServiceProvider), new DefaultDerivedStateProvider(),
); );
this.environmentService = new DefaultEnvironmentService(stateProvider, accountService); this.environmentService = new DefaultEnvironmentService(stateProvider, accountService);

View File

@ -1047,7 +1047,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({ safeProvider({
provide: DerivedStateProvider, provide: DerivedStateProvider,
useClass: DefaultDerivedStateProvider, useClass: DefaultDerivedStateProvider,
deps: [StorageServiceProvider], deps: [],
}), }),
safeProvider({ safeProvider({
provide: StateProvider, provide: StateProvider,

View File

@ -249,11 +249,11 @@ export class FakeDerivedStateProvider implements DerivedStateProvider {
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>, deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
dependencies: TDeps, dependencies: TDeps,
): DerivedState<TTo> { ): DerivedState<TTo> {
let result = this.states.get(deriveDefinition.buildCacheKey("memory")) as DerivedState<TTo>; let result = this.states.get(deriveDefinition.buildCacheKey()) as DerivedState<TTo>;
if (result == null) { if (result == null) {
result = new FakeDerivedState(parentState$, deriveDefinition, dependencies); result = new FakeDerivedState(parentState$, deriveDefinition, dependencies);
this.states.set(deriveDefinition.buildCacheKey("memory"), result); this.states.set(deriveDefinition.buildCacheKey(), result);
} }
return result; return result;
} }

View File

@ -5,3 +5,4 @@ export * from "./fake-state-provider";
export * from "./fake-state"; export * from "./fake-state";
export * from "./fake-account-service"; export * from "./fake-account-service";
export * from "./fake-storage.service"; export * from "./fake-storage.service";
export * from "./observable-tracker";

View File

@ -16,9 +16,11 @@ export class ObservableTracker<T> {
/** /**
* Awaits the next emission from the observable, or throws if the timeout is exceeded * Awaits the next emission from the observable, or throws if the timeout is exceeded
* @param msTimeout The maximum time to wait for another emission before throwing * @param msTimeout The maximum time to wait for another emission before throwing
* @returns The next emission from the observable
* @throws If the timeout is exceeded
*/ */
async expectEmission(msTimeout = 50) { async expectEmission(msTimeout = 50): Promise<T> {
await firstValueFrom( return await firstValueFrom(
this.observable.pipe( this.observable.pipe(
timeout({ timeout({
first: msTimeout, first: msTimeout,

View File

@ -10,7 +10,7 @@ import { CryptoService } from "../abstractions/crypto.service";
import { EncryptService } from "../abstractions/encrypt.service"; import { EncryptService } from "../abstractions/encrypt.service";
import { I18nService } from "../abstractions/i18n.service"; import { I18nService } from "../abstractions/i18n.service";
const nodeURL = typeof window === "undefined" ? require("url") : null; const nodeURL = typeof self === "undefined" ? require("url") : null;
declare global { declare global {
/* eslint-disable-next-line no-var */ /* eslint-disable-next-line no-var */

View File

@ -171,8 +171,8 @@ export class DeriveDefinition<TFrom, TTo, TDeps extends DerivedStateDependencies
return this.options.clearOnCleanup ?? true; return this.options.clearOnCleanup ?? true;
} }
buildCacheKey(location: string): string { buildCacheKey(): string {
return `derived_${location}_${this.stateDefinition.name}_${this.uniqueDerivationName}`; return `derived_${this.stateDefinition.name}_${this.uniqueDerivationName}`;
} }
/** /**

View File

@ -1,11 +1,6 @@
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { DerivedStateDependencies } from "../../../types/state"; import { DerivedStateDependencies } from "../../../types/state";
import {
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { StorageServiceProvider } from "../../services/storage-service.provider";
import { DeriveDefinition } from "../derive-definition"; import { DeriveDefinition } from "../derive-definition";
import { DerivedState } from "../derived-state"; import { DerivedState } from "../derived-state";
import { DerivedStateProvider } from "../derived-state.provider"; import { DerivedStateProvider } from "../derived-state.provider";
@ -15,18 +10,14 @@ import { DefaultDerivedState } from "./default-derived-state";
export class DefaultDerivedStateProvider implements DerivedStateProvider { export class DefaultDerivedStateProvider implements DerivedStateProvider {
private cache: Record<string, DerivedState<unknown>> = {}; private cache: Record<string, DerivedState<unknown>> = {};
constructor(protected storageServiceProvider: StorageServiceProvider) {} constructor() {}
get<TFrom, TTo, TDeps extends DerivedStateDependencies>( get<TFrom, TTo, TDeps extends DerivedStateDependencies>(
parentState$: Observable<TFrom>, parentState$: Observable<TFrom>,
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>, deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
dependencies: TDeps, dependencies: TDeps,
): DerivedState<TTo> { ): DerivedState<TTo> {
// TODO: we probably want to support optional normal memory storage for browser const cacheKey = deriveDefinition.buildCacheKey();
const [location, storageService] = this.storageServiceProvider.get("memory", {
browser: "memory-large-object",
});
const cacheKey = deriveDefinition.buildCacheKey(location);
const existingDerivedState = this.cache[cacheKey]; const existingDerivedState = this.cache[cacheKey];
if (existingDerivedState != null) { if (existingDerivedState != null) {
// I have to cast out of the unknown generic but this should be safe if rules // I have to cast out of the unknown generic but this should be safe if rules
@ -34,10 +25,7 @@ export class DefaultDerivedStateProvider implements DerivedStateProvider {
return existingDerivedState as DefaultDerivedState<TFrom, TTo, TDeps>; return existingDerivedState as DefaultDerivedState<TFrom, TTo, TDeps>;
} }
const newDerivedState = this.buildDerivedState(parentState$, deriveDefinition, dependencies, [ const newDerivedState = this.buildDerivedState(parentState$, deriveDefinition, dependencies);
location,
storageService,
]);
this.cache[cacheKey] = newDerivedState; this.cache[cacheKey] = newDerivedState;
return newDerivedState; return newDerivedState;
} }
@ -46,13 +34,7 @@ export class DefaultDerivedStateProvider implements DerivedStateProvider {
parentState$: Observable<TFrom>, parentState$: Observable<TFrom>,
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>, deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
dependencies: TDeps, dependencies: TDeps,
storageLocation: [string, AbstractStorageService & ObservableStorageService],
): DerivedState<TTo> { ): DerivedState<TTo> {
return new DefaultDerivedState<TFrom, TTo, TDeps>( return new DefaultDerivedState<TFrom, TTo, TDeps>(parentState$, deriveDefinition, dependencies);
parentState$,
deriveDefinition,
storageLocation[1],
dependencies,
);
} }
} }

View File

@ -5,7 +5,6 @@
import { Subject, firstValueFrom } from "rxjs"; import { Subject, firstValueFrom } from "rxjs";
import { awaitAsync, trackEmissions } from "../../../../spec"; import { awaitAsync, trackEmissions } from "../../../../spec";
import { FakeStorageService } from "../../../../spec/fake-storage.service";
import { DeriveDefinition } from "../derive-definition"; import { DeriveDefinition } from "../derive-definition";
import { StateDefinition } from "../state-definition"; import { StateDefinition } from "../state-definition";
@ -29,7 +28,6 @@ const deriveDefinition = new DeriveDefinition<string, Date, { date: Date }>(
describe("DefaultDerivedState", () => { describe("DefaultDerivedState", () => {
let parentState$: Subject<string>; let parentState$: Subject<string>;
let memoryStorage: FakeStorageService;
let sut: DefaultDerivedState<string, Date, { date: Date }>; let sut: DefaultDerivedState<string, Date, { date: Date }>;
const deps = { const deps = {
date: new Date(), date: new Date(),
@ -38,8 +36,7 @@ describe("DefaultDerivedState", () => {
beforeEach(() => { beforeEach(() => {
callCount = 0; callCount = 0;
parentState$ = new Subject(); parentState$ = new Subject();
memoryStorage = new FakeStorageService(); sut = new DefaultDerivedState(parentState$, deriveDefinition, deps);
sut = new DefaultDerivedState(parentState$, deriveDefinition, memoryStorage, deps);
}); });
afterEach(() => { afterEach(() => {
@ -66,71 +63,33 @@ describe("DefaultDerivedState", () => {
expect(callCount).toBe(1); expect(callCount).toBe(1);
}); });
it("should store the derived state in memory", async () => {
const dateString = "2020-01-01";
trackEmissions(sut.state$);
parentState$.next(dateString);
await awaitAsync();
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
derivedValue(new Date(dateString)),
);
const calls = memoryStorage.mock.save.mock.calls;
expect(calls.length).toBe(1);
expect(calls[0][0]).toBe(deriveDefinition.storageKey);
expect(calls[0][1]).toEqual(derivedValue(new Date(dateString)));
});
describe("forceValue", () => { describe("forceValue", () => {
const initialParentValue = "2020-01-01"; const initialParentValue = "2020-01-01";
const forced = new Date("2020-02-02"); const forced = new Date("2020-02-02");
let emissions: Date[]; let emissions: Date[];
describe("without observers", () => { beforeEach(async () => {
beforeEach(async () => { emissions = trackEmissions(sut.state$);
parentState$.next(initialParentValue); parentState$.next(initialParentValue);
await awaitAsync(); await awaitAsync();
});
it("should store the forced value", async () => {
await sut.forceValue(forced);
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
derivedValue(forced),
);
});
}); });
describe("with observers", () => { it("should force the value", async () => {
beforeEach(async () => { await sut.forceValue(forced);
emissions = trackEmissions(sut.state$); expect(emissions).toEqual([new Date(initialParentValue), forced]);
parentState$.next(initialParentValue); });
await awaitAsync();
});
it("should store the forced value", async () => { it("should only force the value once", async () => {
await sut.forceValue(forced); await sut.forceValue(forced);
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
derivedValue(forced),
);
});
it("should force the value", async () => { parentState$.next(initialParentValue);
await sut.forceValue(forced); await awaitAsync();
expect(emissions).toEqual([new Date(initialParentValue), forced]);
});
it("should only force the value once", async () => { expect(emissions).toEqual([
await sut.forceValue(forced); new Date(initialParentValue),
forced,
parentState$.next(initialParentValue); new Date(initialParentValue),
await awaitAsync(); ]);
expect(emissions).toEqual([
new Date(initialParentValue),
forced,
new Date(initialParentValue),
]);
});
}); });
}); });
@ -148,42 +107,6 @@ describe("DefaultDerivedState", () => {
expect(parentState$.observed).toBe(false); expect(parentState$.observed).toBe(false);
}); });
it("should clear state after cleanup", async () => {
const subscription = sut.state$.subscribe();
parentState$.next(newDate);
await awaitAsync();
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
derivedValue(new Date(newDate)),
);
subscription.unsubscribe();
// Wait for cleanup
await awaitAsync(cleanupDelayMs * 2);
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toBeUndefined();
});
it("should not clear state after cleanup if clearOnCleanup is false", async () => {
deriveDefinition.options.clearOnCleanup = false;
const subscription = sut.state$.subscribe();
parentState$.next(newDate);
await awaitAsync();
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
derivedValue(new Date(newDate)),
);
subscription.unsubscribe();
// Wait for cleanup
await awaitAsync(cleanupDelayMs * 2);
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
derivedValue(new Date(newDate)),
);
});
it("should not cleanup if there are still subscribers", async () => { it("should not cleanup if there are still subscribers", async () => {
const subscription1 = sut.state$.subscribe(); const subscription1 = sut.state$.subscribe();
const sub2Emissions: Date[] = []; const sub2Emissions: Date[] = [];
@ -260,7 +183,3 @@ describe("DefaultDerivedState", () => {
}); });
}); });
}); });
function derivedValue<T>(value: T) {
return { derived: true, value };
}

View File

@ -1,10 +1,6 @@
import { Observable, ReplaySubject, Subject, concatMap, merge, share, timer } from "rxjs"; import { Observable, ReplaySubject, Subject, concatMap, merge, share, timer } from "rxjs";
import { DerivedStateDependencies } from "../../../types/state"; import { DerivedStateDependencies } from "../../../types/state";
import {
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { DeriveDefinition } from "../derive-definition"; import { DeriveDefinition } from "../derive-definition";
import { DerivedState } from "../derived-state"; import { DerivedState } from "../derived-state";
@ -22,7 +18,6 @@ export class DefaultDerivedState<TFrom, TTo, TDeps extends DerivedStateDependenc
constructor( constructor(
private parentState$: Observable<TFrom>, private parentState$: Observable<TFrom>,
protected deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>, protected deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
private memoryStorage: AbstractStorageService & ObservableStorageService,
private dependencies: TDeps, private dependencies: TDeps,
) { ) {
this.storageKey = deriveDefinition.storageKey; this.storageKey = deriveDefinition.storageKey;
@ -34,7 +29,6 @@ export class DefaultDerivedState<TFrom, TTo, TDeps extends DerivedStateDependenc
derivedStateOrPromise = await derivedStateOrPromise; derivedStateOrPromise = await derivedStateOrPromise;
} }
const derivedState = derivedStateOrPromise; const derivedState = derivedStateOrPromise;
await this.storeValue(derivedState);
return derivedState; return derivedState;
}), }),
); );
@ -44,26 +38,13 @@ export class DefaultDerivedState<TFrom, TTo, TDeps extends DerivedStateDependenc
connector: () => { connector: () => {
return new ReplaySubject<TTo>(1); return new ReplaySubject<TTo>(1);
}, },
resetOnRefCountZero: () => resetOnRefCountZero: () => timer(this.deriveDefinition.cleanupDelayMs),
timer(this.deriveDefinition.cleanupDelayMs).pipe(
concatMap(async () => {
if (this.deriveDefinition.clearOnCleanup) {
await this.memoryStorage.remove(this.storageKey);
}
return true;
}),
),
}), }),
); );
} }
async forceValue(value: TTo) { async forceValue(value: TTo) {
await this.storeValue(value);
this.forcedValueSubject.next(value); this.forcedValueSubject.next(value);
return value; return value;
} }
private storeValue(value: TTo) {
return this.memoryStorage.save(this.storageKey, { derived: true, value });
}
} }

10
package-lock.json generated
View File

@ -120,7 +120,7 @@
"@typescript-eslint/eslint-plugin": "7.4.0", "@typescript-eslint/eslint-plugin": "7.4.0",
"@typescript-eslint/parser": "7.4.0", "@typescript-eslint/parser": "7.4.0",
"@webcomponents/custom-elements": "1.6.0", "@webcomponents/custom-elements": "1.6.0",
"autoprefixer": "10.4.18", "autoprefixer": "10.4.19",
"base64-loader": "1.0.0", "base64-loader": "1.0.0",
"chromatic": "10.9.6", "chromatic": "10.9.6",
"concurrently": "8.2.2", "concurrently": "8.2.2",
@ -12930,9 +12930,9 @@
} }
}, },
"node_modules/autoprefixer": { "node_modules/autoprefixer": {
"version": "10.4.18", "version": "10.4.19",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.18.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz",
"integrity": "sha512-1DKbDfsr6KUElM6wg+0zRNkB/Q7WcKYAaK+pzXn+Xqmszm/5Xa9coeNdtP88Vi+dPzZnMjhge8GIV49ZQkDa+g==", "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -12950,7 +12950,7 @@
], ],
"dependencies": { "dependencies": {
"browserslist": "^4.23.0", "browserslist": "^4.23.0",
"caniuse-lite": "^1.0.30001591", "caniuse-lite": "^1.0.30001599",
"fraction.js": "^4.3.7", "fraction.js": "^4.3.7",
"normalize-range": "^0.1.2", "normalize-range": "^0.1.2",
"picocolors": "^1.0.0", "picocolors": "^1.0.0",

View File

@ -81,7 +81,7 @@
"@typescript-eslint/eslint-plugin": "7.4.0", "@typescript-eslint/eslint-plugin": "7.4.0",
"@typescript-eslint/parser": "7.4.0", "@typescript-eslint/parser": "7.4.0",
"@webcomponents/custom-elements": "1.6.0", "@webcomponents/custom-elements": "1.6.0",
"autoprefixer": "10.4.18", "autoprefixer": "10.4.19",
"base64-loader": "1.0.0", "base64-loader": "1.0.0",
"chromatic": "10.9.6", "chromatic": "10.9.6",
"concurrently": "8.2.2", "concurrently": "8.2.2",