mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-28 12:45:45 +01:00
Ps/pm 2910/add browser storage services (#6849)
* Allow for update logic in state update callbacks * Prefer reading updates to sending in stream * Inform state providers when they must deserialize * Update DefaultGlobalState to act more like DefaultUserState * Fully Implement AbstractStorageService * Add KeyDefinitionOptions * Address PR feedback * Prefer testing interactions for ports * Synced memory storage for browser * Fix port handling * Do not stringify port message data * Use messaging storage * Initialize new foreground memory storage services This will need to be rethought for short-lived background pages, but for now the background is the source of truth for memory storage * Use global state for account service * Use BrowserApi listener to avoid safari memory leaks * Fix build errors: debugging and missed impls * Prefer bound arrow functions * JSON Stringify Messages * Prefer `useClass` * Use noop services * extract storage observable to new interface This also reverts changes for the existing services to use foreground/background services. Those are now used only in state providers * Fix web DI * Prefer initializing observable in constructor * Do not use jsonify as equality operator * Remove port listener to avoid memory leaks * Fix logic and type issues --------- Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
This commit is contained in:
parent
1ecf019397
commit
24c240d0d4
@ -6,6 +6,10 @@ import {
|
|||||||
CachedServices,
|
CachedServices,
|
||||||
factory,
|
factory,
|
||||||
} from "../../../platform/background/service-factories/factory-options";
|
} from "../../../platform/background/service-factories/factory-options";
|
||||||
|
import {
|
||||||
|
GlobalStateProviderInitOptions,
|
||||||
|
globalStateProviderFactory,
|
||||||
|
} from "../../../platform/background/service-factories/global-state-provider.factory";
|
||||||
import {
|
import {
|
||||||
LogServiceInitOptions,
|
LogServiceInitOptions,
|
||||||
logServiceFactory,
|
logServiceFactory,
|
||||||
@ -19,7 +23,8 @@ type AccountServiceFactoryOptions = FactoryOptions;
|
|||||||
|
|
||||||
export type AccountServiceInitOptions = AccountServiceFactoryOptions &
|
export type AccountServiceInitOptions = AccountServiceFactoryOptions &
|
||||||
MessagingServiceInitOptions &
|
MessagingServiceInitOptions &
|
||||||
LogServiceInitOptions;
|
LogServiceInitOptions &
|
||||||
|
GlobalStateProviderInitOptions;
|
||||||
|
|
||||||
export function accountServiceFactory(
|
export function accountServiceFactory(
|
||||||
cache: { accountService?: AccountService } & CachedServices,
|
cache: { accountService?: AccountService } & CachedServices,
|
||||||
@ -32,7 +37,8 @@ export function accountServiceFactory(
|
|||||||
async () =>
|
async () =>
|
||||||
new AccountServiceImplementation(
|
new AccountServiceImplementation(
|
||||||
await messagingServiceFactory(cache, opts),
|
await messagingServiceFactory(cache, opts),
|
||||||
await logServiceFactory(cache, opts)
|
await logServiceFactory(cache, opts),
|
||||||
|
await globalStateProviderFactory(cache, opts)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -60,9 +60,11 @@ import { ContainerService } from "@bitwarden/common/platform/services/container.
|
|||||||
import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation";
|
import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation";
|
||||||
import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/multithread-encrypt.service.implementation";
|
import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/multithread-encrypt.service.implementation";
|
||||||
import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service";
|
import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service";
|
||||||
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
|
|
||||||
import { SystemService } from "@bitwarden/common/platform/services/system.service";
|
import { SystemService } from "@bitwarden/common/platform/services/system.service";
|
||||||
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
|
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
|
||||||
|
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
|
||||||
|
// eslint-disable-next-line import/no-restricted-paths -- We need the implementation to inject, but generally this should not be accessed
|
||||||
|
import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/implementations/default-global-state.provider";
|
||||||
import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service";
|
import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service";
|
||||||
import { ApiService } from "@bitwarden/common/services/api.service";
|
import { ApiService } from "@bitwarden/common/services/api.service";
|
||||||
import { AuditService } from "@bitwarden/common/services/audit.service";
|
import { AuditService } from "@bitwarden/common/services/audit.service";
|
||||||
@ -145,6 +147,7 @@ import BrowserPlatformUtilsService from "../platform/services/browser-platform-u
|
|||||||
import { BrowserStateService } from "../platform/services/browser-state.service";
|
import { BrowserStateService } from "../platform/services/browser-state.service";
|
||||||
import { KeyGenerationService } from "../platform/services/key-generation.service";
|
import { KeyGenerationService } from "../platform/services/key-generation.service";
|
||||||
import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service";
|
import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service";
|
||||||
|
import { BackgroundMemoryStorageService } from "../platform/storage/background-memory-storage.service";
|
||||||
import { BrowserSendService } from "../services/browser-send.service";
|
import { BrowserSendService } from "../services/browser-send.service";
|
||||||
import { BrowserSettingsService } from "../services/browser-settings.service";
|
import { BrowserSettingsService } from "../services/browser-settings.service";
|
||||||
import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service";
|
import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service";
|
||||||
@ -225,6 +228,7 @@ export default class MainBackground {
|
|||||||
deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction;
|
deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction;
|
||||||
authRequestCryptoService: AuthRequestCryptoServiceAbstraction;
|
authRequestCryptoService: AuthRequestCryptoServiceAbstraction;
|
||||||
accountService: AccountServiceAbstraction;
|
accountService: AccountServiceAbstraction;
|
||||||
|
globalStateProvider: GlobalStateProvider;
|
||||||
|
|
||||||
// Passed to the popup for Safari to workaround issues with theming, downloading, etc.
|
// Passed to the popup for Safari to workaround issues with theming, downloading, etc.
|
||||||
backgroundWindow = window;
|
backgroundWindow = window;
|
||||||
@ -279,8 +283,16 @@ export default class MainBackground {
|
|||||||
new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, false),
|
new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, false),
|
||||||
new KeyGenerationService(this.cryptoFunctionService)
|
new KeyGenerationService(this.cryptoFunctionService)
|
||||||
)
|
)
|
||||||
: new MemoryStorageService();
|
: new BackgroundMemoryStorageService();
|
||||||
this.accountService = new AccountServiceImplementation(this.messagingService, this.logService);
|
this.globalStateProvider = new DefaultGlobalStateProvider(
|
||||||
|
this.memoryStorageService as BackgroundMemoryStorageService,
|
||||||
|
this.storageService as BrowserLocalStorageService
|
||||||
|
);
|
||||||
|
this.accountService = new AccountServiceImplementation(
|
||||||
|
this.messagingService,
|
||||||
|
this.logService,
|
||||||
|
this.globalStateProvider
|
||||||
|
);
|
||||||
this.stateService = new BrowserStateService(
|
this.stateService = new BrowserStateService(
|
||||||
this.storageService,
|
this.storageService,
|
||||||
this.secureStorageService,
|
this.secureStorageService,
|
||||||
|
@ -0,0 +1,33 @@
|
|||||||
|
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
|
||||||
|
// eslint-disable-next-line import/no-restricted-paths -- We need the implementation to inject, but generally this should not be accessed
|
||||||
|
import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/implementations/default-global-state.provider";
|
||||||
|
|
||||||
|
import { CachedServices, FactoryOptions, factory } from "./factory-options";
|
||||||
|
import {
|
||||||
|
DiskStorageServiceInitOptions,
|
||||||
|
MemoryStorageServiceInitOptions,
|
||||||
|
observableDiskStorageServiceFactory,
|
||||||
|
observableMemoryStorageServiceFactory,
|
||||||
|
} from "./storage-service.factory";
|
||||||
|
|
||||||
|
type GlobalStateProviderFactoryOptions = FactoryOptions;
|
||||||
|
|
||||||
|
export type GlobalStateProviderInitOptions = GlobalStateProviderFactoryOptions &
|
||||||
|
MemoryStorageServiceInitOptions &
|
||||||
|
DiskStorageServiceInitOptions;
|
||||||
|
|
||||||
|
export async function globalStateProviderFactory(
|
||||||
|
cache: { globalStateProvider?: GlobalStateProvider } & CachedServices,
|
||||||
|
opts: GlobalStateProviderInitOptions
|
||||||
|
): Promise<GlobalStateProvider> {
|
||||||
|
return factory(
|
||||||
|
cache,
|
||||||
|
"globalStateProvider",
|
||||||
|
opts,
|
||||||
|
async () =>
|
||||||
|
new DefaultGlobalStateProvider(
|
||||||
|
await observableMemoryStorageServiceFactory(cache, opts),
|
||||||
|
await observableDiskStorageServiceFactory(cache, opts)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
@ -1,12 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
AbstractMemoryStorageService,
|
AbstractMemoryStorageService,
|
||||||
AbstractStorageService,
|
AbstractStorageService,
|
||||||
|
ObservableStorageService,
|
||||||
} from "@bitwarden/common/platform/abstractions/storage.service";
|
} from "@bitwarden/common/platform/abstractions/storage.service";
|
||||||
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
|
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
|
||||||
|
|
||||||
import { BrowserApi } from "../../browser/browser-api";
|
import { BrowserApi } from "../../browser/browser-api";
|
||||||
import BrowserLocalStorageService from "../../services/browser-local-storage.service";
|
import BrowserLocalStorageService from "../../services/browser-local-storage.service";
|
||||||
import { LocalBackedSessionStorageService } from "../../services/local-backed-session-storage.service";
|
import { LocalBackedSessionStorageService } from "../../services/local-backed-session-storage.service";
|
||||||
|
import { BackgroundMemoryStorageService } from "../../storage/background-memory-storage.service";
|
||||||
|
|
||||||
import { EncryptServiceInitOptions, encryptServiceFactory } from "./encrypt-service.factory";
|
import { EncryptServiceInitOptions, encryptServiceFactory } from "./encrypt-service.factory";
|
||||||
import { CachedServices, factory, FactoryOptions } from "./factory-options";
|
import { CachedServices, factory, FactoryOptions } from "./factory-options";
|
||||||
@ -29,6 +31,14 @@ export function diskStorageServiceFactory(
|
|||||||
): Promise<AbstractStorageService> {
|
): Promise<AbstractStorageService> {
|
||||||
return factory(cache, "diskStorageService", opts, () => new BrowserLocalStorageService());
|
return factory(cache, "diskStorageService", opts, () => new BrowserLocalStorageService());
|
||||||
}
|
}
|
||||||
|
export function observableDiskStorageServiceFactory(
|
||||||
|
cache: {
|
||||||
|
diskStorageService?: AbstractStorageService & ObservableStorageService;
|
||||||
|
} & CachedServices,
|
||||||
|
opts: DiskStorageServiceInitOptions
|
||||||
|
): Promise<AbstractStorageService & ObservableStorageService> {
|
||||||
|
return factory(cache, "diskStorageService", opts, () => new BrowserLocalStorageService());
|
||||||
|
}
|
||||||
|
|
||||||
export function secureStorageServiceFactory(
|
export function secureStorageServiceFactory(
|
||||||
cache: { secureStorageService?: AbstractStorageService } & CachedServices,
|
cache: { secureStorageService?: AbstractStorageService } & CachedServices,
|
||||||
@ -51,3 +61,14 @@ export function memoryStorageServiceFactory(
|
|||||||
return new MemoryStorageService();
|
return new MemoryStorageService();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function observableMemoryStorageServiceFactory(
|
||||||
|
cache: {
|
||||||
|
memoryStorageService?: AbstractMemoryStorageService & ObservableStorageService;
|
||||||
|
} & CachedServices,
|
||||||
|
opts: MemoryStorageServiceInitOptions
|
||||||
|
): Promise<AbstractMemoryStorageService & ObservableStorageService> {
|
||||||
|
return factory(cache, "memoryStorageService", opts, async () => {
|
||||||
|
return new BackgroundMemoryStorageService();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
@ -1,21 +1,20 @@
|
|||||||
import { Observable, mergeMap } from "rxjs";
|
import { mergeMap } from "rxjs";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AbstractStorageService,
|
AbstractStorageService,
|
||||||
StorageUpdate,
|
ObservableStorageService,
|
||||||
StorageUpdateType,
|
StorageUpdateType,
|
||||||
} from "@bitwarden/common/platform/abstractions/storage.service";
|
} from "@bitwarden/common/platform/abstractions/storage.service";
|
||||||
|
|
||||||
import { fromChromeEvent } from "../../browser/from-chrome-event";
|
import { fromChromeEvent } from "../../browser/from-chrome-event";
|
||||||
|
|
||||||
export default abstract class AbstractChromeStorageService implements AbstractStorageService {
|
export default abstract class AbstractChromeStorageService
|
||||||
constructor(protected chromeStorageApi: chrome.storage.StorageArea) {}
|
implements AbstractStorageService, ObservableStorageService
|
||||||
|
{
|
||||||
|
updates$;
|
||||||
|
|
||||||
get valuesRequireDeserialization(): boolean {
|
constructor(protected chromeStorageApi: chrome.storage.StorageArea) {
|
||||||
return true;
|
this.updates$ = fromChromeEvent(this.chromeStorageApi.onChanged).pipe(
|
||||||
}
|
|
||||||
get updates$(): Observable<StorageUpdate> {
|
|
||||||
return fromChromeEvent(this.chromeStorageApi.onChanged).pipe(
|
|
||||||
mergeMap(([changes]) => {
|
mergeMap(([changes]) => {
|
||||||
return Object.entries(changes).map(([key, change]) => {
|
return Object.entries(changes).map(([key, change]) => {
|
||||||
// The `newValue` property isn't on the StorageChange object
|
// The `newValue` property isn't on the StorageChange object
|
||||||
@ -37,6 +36,10 @@ export default abstract class AbstractChromeStorageService implements AbstractSt
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get valuesRequireDeserialization(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async get<T>(key: string): Promise<T> {
|
async get<T>(key: string): Promise<T> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
this.chromeStorageApi.get(key, (obj: any) => {
|
this.chromeStorageApi.get(key, (obj: any) => {
|
||||||
|
@ -27,22 +27,20 @@ export class LocalBackedSessionStorageService extends AbstractMemoryStorageServi
|
|||||||
private localStorage = new BrowserLocalStorageService();
|
private localStorage = new BrowserLocalStorageService();
|
||||||
private sessionStorage = new BrowserMemoryStorageService();
|
private sessionStorage = new BrowserMemoryStorageService();
|
||||||
private updatesSubject = new Subject<StorageUpdate>();
|
private updatesSubject = new Subject<StorageUpdate>();
|
||||||
|
updates$;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private encryptService: EncryptService,
|
private encryptService: EncryptService,
|
||||||
private keyGenerationService: AbstractKeyGenerationService
|
private keyGenerationService: AbstractKeyGenerationService
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
this.updates$ = this.updatesSubject.asObservable();
|
||||||
}
|
}
|
||||||
|
|
||||||
get valuesRequireDeserialization(): boolean {
|
get valuesRequireDeserialization(): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
get updates$() {
|
|
||||||
return this.updatesSubject.asObservable();
|
|
||||||
}
|
|
||||||
|
|
||||||
async get<T>(key: string, options?: MemoryStorageOptions<T>): Promise<T> {
|
async get<T>(key: string, options?: MemoryStorageOptions<T>): Promise<T> {
|
||||||
if (this.cache.has(key)) {
|
if (this.cache.has(key)) {
|
||||||
return this.cache.get(key) as T;
|
return this.cache.get(key) as T;
|
||||||
|
@ -0,0 +1,78 @@
|
|||||||
|
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
|
||||||
|
|
||||||
|
import { BrowserApi } from "../browser/browser-api";
|
||||||
|
|
||||||
|
import { MemoryStoragePortMessage } from "./port-messages";
|
||||||
|
import { portName } from "./port-name";
|
||||||
|
|
||||||
|
export class BackgroundMemoryStorageService extends MemoryStorageService {
|
||||||
|
private _ports: chrome.runtime.Port[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
BrowserApi.addListener(chrome.runtime.onConnect, (port) => {
|
||||||
|
if (port.name !== portName(chrome.storage.session)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._ports.push(port);
|
||||||
|
|
||||||
|
const listenerCallback = this.onMessageFromForeground.bind(this);
|
||||||
|
port.onDisconnect.addListener(() => {
|
||||||
|
this._ports.splice(this._ports.indexOf(port), 1);
|
||||||
|
port.onMessage.removeListener(listenerCallback);
|
||||||
|
});
|
||||||
|
port.onMessage.addListener(listenerCallback);
|
||||||
|
// Initialize the new memory storage service with existing data
|
||||||
|
this.sendMessage({
|
||||||
|
action: "initialization",
|
||||||
|
data: Array.from(this.store.keys()),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.updates$.subscribe((update) => {
|
||||||
|
this.sendMessage({
|
||||||
|
action: "subject_update",
|
||||||
|
data: update,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async onMessageFromForeground(message: MemoryStoragePortMessage) {
|
||||||
|
if (message.originator === "background") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: unknown = null;
|
||||||
|
|
||||||
|
switch (message.action) {
|
||||||
|
case "get":
|
||||||
|
case "getBypassCache":
|
||||||
|
case "has": {
|
||||||
|
result = await this[message.action](message.key);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "save":
|
||||||
|
await this.save(message.key, JSON.parse(message.data as string) as unknown);
|
||||||
|
break;
|
||||||
|
case "remove":
|
||||||
|
await this.remove(message.key);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sendMessage({
|
||||||
|
id: message.id,
|
||||||
|
key: message.key,
|
||||||
|
data: JSON.stringify(result),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendMessage(data: Omit<MemoryStoragePortMessage, "originator">) {
|
||||||
|
this._ports.forEach((port) => {
|
||||||
|
port.postMessage({
|
||||||
|
...data,
|
||||||
|
originator: "background",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,113 @@
|
|||||||
|
import { Observable, Subject, filter, firstValueFrom, map } from "rxjs";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AbstractMemoryStorageService,
|
||||||
|
StorageUpdate,
|
||||||
|
} from "@bitwarden/common/platform/abstractions/storage.service";
|
||||||
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
|
|
||||||
|
import { fromChromeEvent } from "../browser/from-chrome-event";
|
||||||
|
|
||||||
|
import { MemoryStoragePortMessage } from "./port-messages";
|
||||||
|
import { portName } from "./port-name";
|
||||||
|
|
||||||
|
export class ForegroundMemoryStorageService extends AbstractMemoryStorageService {
|
||||||
|
private _port: chrome.runtime.Port;
|
||||||
|
private _backgroundResponses$: Observable<MemoryStoragePortMessage>;
|
||||||
|
private updatesSubject = new Subject<StorageUpdate>();
|
||||||
|
|
||||||
|
get valuesRequireDeserialization(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
updates$;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.updates$ = this.updatesSubject.asObservable();
|
||||||
|
|
||||||
|
this._port = chrome.runtime.connect({ name: portName(chrome.storage.session) });
|
||||||
|
this._backgroundResponses$ = fromChromeEvent(this._port.onMessage).pipe(
|
||||||
|
map(([message]) => message),
|
||||||
|
filter((message) => message.originator === "background")
|
||||||
|
);
|
||||||
|
|
||||||
|
this._backgroundResponses$
|
||||||
|
.pipe(
|
||||||
|
filter(
|
||||||
|
(message) => message.action === "subject_update" || message.action === "initialization"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.subscribe((message) => {
|
||||||
|
switch (message.action) {
|
||||||
|
case "initialization":
|
||||||
|
this.handleInitialize(message.data as string[]); // Map entries as array
|
||||||
|
break;
|
||||||
|
case "subject_update":
|
||||||
|
this.handleSubjectUpdate(message.data as StorageUpdate);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown action: ${message.action}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<T>(key: string): Promise<T> {
|
||||||
|
return await this.delegateToBackground<T>("get", key);
|
||||||
|
}
|
||||||
|
async getBypassCache<T>(key: string): Promise<T> {
|
||||||
|
return await this.delegateToBackground<T>("getBypassCache", key);
|
||||||
|
}
|
||||||
|
async has(key: string): Promise<boolean> {
|
||||||
|
return await this.delegateToBackground<boolean>("has", key);
|
||||||
|
}
|
||||||
|
async save<T>(key: string, obj: T): Promise<void> {
|
||||||
|
await this.delegateToBackground<T>("save", key, obj);
|
||||||
|
}
|
||||||
|
async remove(key: string): Promise<void> {
|
||||||
|
await this.delegateToBackground<void>("remove", key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async delegateToBackground<T>(
|
||||||
|
action: MemoryStoragePortMessage["action"],
|
||||||
|
key: string,
|
||||||
|
data?: T
|
||||||
|
): Promise<T> {
|
||||||
|
const id = Utils.newGuid();
|
||||||
|
// listen for response before request
|
||||||
|
const response = firstValueFrom(
|
||||||
|
this._backgroundResponses$.pipe(
|
||||||
|
filter((message) => message.id === id),
|
||||||
|
map((message) => JSON.parse(message.data as string) as T)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.sendMessage({
|
||||||
|
id: id,
|
||||||
|
key: key,
|
||||||
|
action: action,
|
||||||
|
data: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendMessage(data: Omit<MemoryStoragePortMessage, "originator">) {
|
||||||
|
this._port.postMessage({
|
||||||
|
...data,
|
||||||
|
originator: "foreground",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSubjectUpdate(data: StorageUpdate) {
|
||||||
|
this.updatesSubject.next(data);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,61 @@
|
|||||||
|
import { trackEmissions } from "@bitwarden/common/../spec/utils";
|
||||||
|
|
||||||
|
import { BackgroundMemoryStorageService } from "./background-memory-storage.service";
|
||||||
|
import { ForegroundMemoryStorageService } from "./foreground-memory-storage.service";
|
||||||
|
import { mockPort } from "./mock-port.spec-util";
|
||||||
|
import { portName } from "./port-name";
|
||||||
|
|
||||||
|
describe("foreground background memory storage interaction", () => {
|
||||||
|
let foreground: ForegroundMemoryStorageService;
|
||||||
|
let background: BackgroundMemoryStorageService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPort(portName(chrome.storage.session));
|
||||||
|
|
||||||
|
background = new BackgroundMemoryStorageService();
|
||||||
|
foreground = new ForegroundMemoryStorageService();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["has", "get", "getBypassCache"])(
|
||||||
|
"background should respond with the correct value for %s",
|
||||||
|
async (action: "get" | "has" | "getBypassCache") => {
|
||||||
|
const key = "key";
|
||||||
|
const value = "value";
|
||||||
|
background[action] = jest.fn().mockResolvedValue(value);
|
||||||
|
|
||||||
|
const result = await foreground[action](key);
|
||||||
|
expect(result).toEqual(value);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
test("background should call save from foreground", async () => {
|
||||||
|
const key = "key";
|
||||||
|
const value = "value";
|
||||||
|
const actionSpy = jest.spyOn(background, "save");
|
||||||
|
await foreground.save(key, value);
|
||||||
|
|
||||||
|
expect(actionSpy).toHaveBeenCalledWith(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("background should call remove from foreground", async () => {
|
||||||
|
const key = "key";
|
||||||
|
const actionSpy = jest.spyOn(background, "remove");
|
||||||
|
await foreground.remove(key);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
expect(emissions).toEqual([{ key, updateType }]);
|
||||||
|
});
|
||||||
|
});
|
25
apps/browser/src/platform/storage/mock-port.spec-util.ts
Normal file
25
apps/browser/src/platform/storage/mock-port.spec-util.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { mockDeep } from "jest-mock-extended";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mocks a chrome.runtime.Port set up to send messages through `postMessage` to `onMessage.addListener` callbacks.
|
||||||
|
* @returns a mock chrome.runtime.Port
|
||||||
|
*/
|
||||||
|
export function mockPort(name: string) {
|
||||||
|
const port = mockDeep<chrome.runtime.Port>();
|
||||||
|
// notify listeners of a new port
|
||||||
|
(chrome.runtime.connect as jest.Mock).mockImplementation((portInfo) => {
|
||||||
|
port.name = portInfo.name;
|
||||||
|
(chrome.runtime.onConnect.addListener as jest.Mock).mock.calls.forEach(([callbackFn]) => {
|
||||||
|
callbackFn(port);
|
||||||
|
});
|
||||||
|
return port;
|
||||||
|
});
|
||||||
|
|
||||||
|
// set message broadcast
|
||||||
|
(port.postMessage as jest.Mock).mockImplementation((message) => {
|
||||||
|
(port.onMessage.addListener as jest.Mock).mock.calls.forEach(([callbackFn]) => {
|
||||||
|
callbackFn(message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return port;
|
||||||
|
}
|
20
apps/browser/src/platform/storage/port-messages.d.ts
vendored
Normal file
20
apps/browser/src/platform/storage/port-messages.d.ts
vendored
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import {
|
||||||
|
AbstractMemoryStorageService,
|
||||||
|
StorageUpdate,
|
||||||
|
} from "@bitwarden/common/platform/abstractions/storage.service";
|
||||||
|
|
||||||
|
type MemoryStoragePortMessage = {
|
||||||
|
id?: string;
|
||||||
|
key?: string;
|
||||||
|
/**
|
||||||
|
* We allow sending a string[] array since it is JSON safe and StorageUpdate since it's
|
||||||
|
* a simple object with just two properties that are strings. Everything else is expected to
|
||||||
|
* be JSON-ified.
|
||||||
|
*/
|
||||||
|
data: string | string[] | StorageUpdate;
|
||||||
|
originator: "foreground" | "background";
|
||||||
|
action?:
|
||||||
|
| keyof Pick<AbstractMemoryStorageService, "get" | "getBypassCache" | "has" | "save" | "remove">
|
||||||
|
| "subject_update"
|
||||||
|
| "initialization";
|
||||||
|
};
|
12
apps/browser/src/platform/storage/port-name.ts
Normal file
12
apps/browser/src/platform/storage/port-name.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export function portName(storageLocation: chrome.storage.StorageArea) {
|
||||||
|
switch (storageLocation) {
|
||||||
|
case chrome.storage.local:
|
||||||
|
return "local";
|
||||||
|
case chrome.storage.sync:
|
||||||
|
return "sync";
|
||||||
|
case chrome.storage.session:
|
||||||
|
return "session";
|
||||||
|
default:
|
||||||
|
throw new Error("Unknown storage location");
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,12 @@
|
|||||||
import { APP_INITIALIZER, LOCALE_ID, NgModule } from "@angular/core";
|
import { APP_INITIALIZER, LOCALE_ID, NgModule } from "@angular/core";
|
||||||
|
|
||||||
import { UnauthGuard as BaseUnauthGuardService } from "@bitwarden/angular/auth/guards";
|
import { UnauthGuard as BaseUnauthGuardService } from "@bitwarden/angular/auth/guards";
|
||||||
import { MEMORY_STORAGE, SECURE_STORAGE } from "@bitwarden/angular/services/injection-tokens";
|
import {
|
||||||
|
MEMORY_STORAGE,
|
||||||
|
OBSERVABLE_DISK_STORAGE,
|
||||||
|
OBSERVABLE_MEMORY_STORAGE,
|
||||||
|
SECURE_STORAGE,
|
||||||
|
} from "@bitwarden/angular/services/injection-tokens";
|
||||||
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
|
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
|
||||||
import { ThemingService } from "@bitwarden/angular/services/theming/theming.service";
|
import { ThemingService } from "@bitwarden/angular/services/theming/theming.service";
|
||||||
import { AbstractThemingService } from "@bitwarden/angular/services/theming/theming.service.abstraction";
|
import { AbstractThemingService } from "@bitwarden/angular/services/theming/theming.service.abstraction";
|
||||||
@ -100,9 +105,11 @@ import { BrowserConfigService } from "../../platform/services/browser-config.ser
|
|||||||
import { BrowserEnvironmentService } from "../../platform/services/browser-environment.service";
|
import { BrowserEnvironmentService } from "../../platform/services/browser-environment.service";
|
||||||
import { BrowserFileDownloadService } from "../../platform/services/browser-file-download.service";
|
import { BrowserFileDownloadService } from "../../platform/services/browser-file-download.service";
|
||||||
import { BrowserI18nService } from "../../platform/services/browser-i18n.service";
|
import { BrowserI18nService } from "../../platform/services/browser-i18n.service";
|
||||||
|
import BrowserLocalStorageService from "../../platform/services/browser-local-storage.service";
|
||||||
import BrowserMessagingPrivateModePopupService from "../../platform/services/browser-messaging-private-mode-popup.service";
|
import BrowserMessagingPrivateModePopupService from "../../platform/services/browser-messaging-private-mode-popup.service";
|
||||||
import BrowserMessagingService from "../../platform/services/browser-messaging.service";
|
import BrowserMessagingService from "../../platform/services/browser-messaging.service";
|
||||||
import { BrowserStateService } from "../../platform/services/browser-state.service";
|
import { BrowserStateService } from "../../platform/services/browser-state.service";
|
||||||
|
import { ForegroundMemoryStorageService } from "../../platform/storage/foreground-memory-storage.service";
|
||||||
import { BrowserSendService } from "../../services/browser-send.service";
|
import { BrowserSendService } from "../../services/browser-send.service";
|
||||||
import { BrowserSettingsService } from "../../services/browser-settings.service";
|
import { BrowserSettingsService } from "../../services/browser-settings.service";
|
||||||
import { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-utils.service";
|
import { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-utils.service";
|
||||||
@ -361,7 +368,7 @@ function getBgService<T>(service: keyof MainBackground) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: AbstractStorageService,
|
provide: AbstractStorageService,
|
||||||
useFactory: getBgService<AbstractStorageService>("storageService"),
|
useClass: BrowserLocalStorageService,
|
||||||
deps: [],
|
deps: [],
|
||||||
},
|
},
|
||||||
{ provide: AppIdService, useFactory: getBgService<AppIdService>("appIdService"), deps: [] },
|
{ provide: AppIdService, useFactory: getBgService<AppIdService>("appIdService"), deps: [] },
|
||||||
@ -444,12 +451,20 @@ function getBgService<T>(service: keyof MainBackground) {
|
|||||||
{
|
{
|
||||||
provide: SECURE_STORAGE,
|
provide: SECURE_STORAGE,
|
||||||
useFactory: getBgService<AbstractStorageService>("secureStorageService"),
|
useFactory: getBgService<AbstractStorageService>("secureStorageService"),
|
||||||
deps: [],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: MEMORY_STORAGE,
|
provide: MEMORY_STORAGE,
|
||||||
useFactory: getBgService<AbstractStorageService>("memoryStorageService"),
|
useFactory: getBgService<AbstractStorageService>("memoryStorageService"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: OBSERVABLE_MEMORY_STORAGE,
|
||||||
|
useClass: ForegroundMemoryStorageService,
|
||||||
|
deps: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: OBSERVABLE_DISK_STORAGE,
|
||||||
|
useExisting: AbstractStorageService,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: StateServiceAbstraction,
|
provide: StateServiceAbstraction,
|
||||||
useFactory: (
|
useFactory: (
|
||||||
|
@ -25,6 +25,7 @@ const runtime = {
|
|||||||
sendMessage: jest.fn(),
|
sendMessage: jest.fn(),
|
||||||
getManifest: jest.fn(),
|
getManifest: jest.fn(),
|
||||||
getURL: jest.fn((path) => `chrome-extension://id/${path}`),
|
getURL: jest.fn((path) => `chrome-extension://id/${path}`),
|
||||||
|
connect: jest.fn(),
|
||||||
onConnect: {
|
onConnect: {
|
||||||
addListener: jest.fn(),
|
addListener: jest.fn(),
|
||||||
},
|
},
|
||||||
|
@ -45,6 +45,9 @@ import { FileUploadService } from "@bitwarden/common/platform/services/file-uplo
|
|||||||
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
|
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
|
||||||
import { NoopMessagingService } from "@bitwarden/common/platform/services/noop-messaging.service";
|
import { NoopMessagingService } from "@bitwarden/common/platform/services/noop-messaging.service";
|
||||||
import { StateService } from "@bitwarden/common/platform/services/state.service";
|
import { StateService } from "@bitwarden/common/platform/services/state.service";
|
||||||
|
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
|
||||||
|
// eslint-disable-next-line import/no-restricted-paths -- We need the implementation to inject, but generally this should not be accessed
|
||||||
|
import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/implementations/default-global-state.provider";
|
||||||
import { AuditService } from "@bitwarden/common/services/audit.service";
|
import { AuditService } from "@bitwarden/common/services/audit.service";
|
||||||
import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service";
|
import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service";
|
||||||
import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service";
|
import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service";
|
||||||
@ -161,6 +164,7 @@ export class Main {
|
|||||||
configApiService: ConfigApiServiceAbstraction;
|
configApiService: ConfigApiServiceAbstraction;
|
||||||
configService: CliConfigService;
|
configService: CliConfigService;
|
||||||
accountService: AccountService;
|
accountService: AccountService;
|
||||||
|
globalStateProvider: GlobalStateProvider;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
let p = null;
|
let p = null;
|
||||||
@ -200,7 +204,18 @@ export class Main {
|
|||||||
|
|
||||||
this.memoryStorageService = new MemoryStorageService();
|
this.memoryStorageService = new MemoryStorageService();
|
||||||
|
|
||||||
this.accountService = new AccountServiceImplementation(null, this.logService);
|
this.globalStateProvider = new DefaultGlobalStateProvider(
|
||||||
|
this.memoryStorageService,
|
||||||
|
this.storageService
|
||||||
|
);
|
||||||
|
|
||||||
|
this.messagingService = new NoopMessagingService();
|
||||||
|
|
||||||
|
this.accountService = new AccountServiceImplementation(
|
||||||
|
this.messagingService,
|
||||||
|
this.logService,
|
||||||
|
this.globalStateProvider
|
||||||
|
);
|
||||||
|
|
||||||
this.stateService = new StateService(
|
this.stateService = new StateService(
|
||||||
this.storageService,
|
this.storageService,
|
||||||
@ -221,7 +236,6 @@ export class Main {
|
|||||||
|
|
||||||
this.appIdService = new AppIdService(this.storageService);
|
this.appIdService = new AppIdService(this.storageService);
|
||||||
this.tokenService = new TokenService(this.stateService);
|
this.tokenService = new TokenService(this.stateService);
|
||||||
this.messagingService = new NoopMessagingService();
|
|
||||||
this.environmentService = new EnvironmentService(this.stateService);
|
this.environmentService = new EnvironmentService(this.stateService);
|
||||||
|
|
||||||
const customUserAgent =
|
const customUserAgent =
|
||||||
|
@ -29,6 +29,7 @@ export class LowdbStorageService implements AbstractStorageService {
|
|||||||
private defaults: any;
|
private defaults: any;
|
||||||
private ready = false;
|
private ready = false;
|
||||||
private updatesSubject = new Subject<StorageUpdate>();
|
private updatesSubject = new Subject<StorageUpdate>();
|
||||||
|
updates$;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected logService: LogService,
|
protected logService: LogService,
|
||||||
@ -38,6 +39,7 @@ export class LowdbStorageService implements AbstractStorageService {
|
|||||||
private requireLock = false
|
private requireLock = false
|
||||||
) {
|
) {
|
||||||
this.defaults = defaults;
|
this.defaults = defaults;
|
||||||
|
this.updates$ = this.updatesSubject.asObservable();
|
||||||
}
|
}
|
||||||
|
|
||||||
@sequentialize(() => "lowdbStorageInit")
|
@sequentialize(() => "lowdbStorageInit")
|
||||||
@ -110,9 +112,6 @@ export class LowdbStorageService implements AbstractStorageService {
|
|||||||
get valuesRequireDeserialization(): boolean {
|
get valuesRequireDeserialization(): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
get updates$() {
|
|
||||||
return this.updatesSubject.asObservable();
|
|
||||||
}
|
|
||||||
|
|
||||||
async get<T>(key: string): Promise<T> {
|
async get<T>(key: string): Promise<T> {
|
||||||
await this.waitForReady();
|
await this.waitForReady();
|
||||||
|
@ -7,6 +7,8 @@ import {
|
|||||||
LOCALES_DIRECTORY,
|
LOCALES_DIRECTORY,
|
||||||
SYSTEM_LANGUAGE,
|
SYSTEM_LANGUAGE,
|
||||||
MEMORY_STORAGE,
|
MEMORY_STORAGE,
|
||||||
|
OBSERVABLE_MEMORY_STORAGE,
|
||||||
|
OBSERVABLE_DISK_STORAGE,
|
||||||
} from "@bitwarden/angular/services/injection-tokens";
|
} from "@bitwarden/angular/services/injection-tokens";
|
||||||
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
|
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
|
||||||
import { AbstractThemingService } from "@bitwarden/angular/services/theming/theming.service.abstraction";
|
import { AbstractThemingService } from "@bitwarden/angular/services/theming/theming.service.abstraction";
|
||||||
@ -102,6 +104,8 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK");
|
|||||||
{ provide: AbstractStorageService, useClass: ElectronRendererStorageService },
|
{ provide: AbstractStorageService, useClass: ElectronRendererStorageService },
|
||||||
{ provide: SECURE_STORAGE, useClass: ElectronRendererSecureStorageService },
|
{ provide: SECURE_STORAGE, useClass: ElectronRendererSecureStorageService },
|
||||||
{ provide: MEMORY_STORAGE, useClass: MemoryStorageService },
|
{ provide: MEMORY_STORAGE, useClass: MemoryStorageService },
|
||||||
|
{ provide: OBSERVABLE_MEMORY_STORAGE, useExisting: MEMORY_STORAGE },
|
||||||
|
{ provide: OBSERVABLE_DISK_STORAGE, useExisting: AbstractStorageService },
|
||||||
{
|
{
|
||||||
provide: SystemServiceAbstraction,
|
provide: SystemServiceAbstraction,
|
||||||
useClass: SystemService,
|
useClass: SystemService,
|
||||||
|
@ -6,6 +6,9 @@ import { AccountServiceImplementation } from "@bitwarden/common/auth/services/ac
|
|||||||
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
|
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
|
||||||
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
|
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
|
||||||
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
|
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
|
||||||
|
import { NoopMessagingService } from "@bitwarden/common/platform/services/noop-messaging.service";
|
||||||
|
// eslint-disable-next-line import/no-restricted-paths -- We need the implementation to inject, but generally this should not be accessed
|
||||||
|
import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/implementations/default-global-state.provider";
|
||||||
|
|
||||||
import { MenuMain } from "./main/menu/menu.main";
|
import { MenuMain } from "./main/menu/menu.main";
|
||||||
import { MessagingMain } from "./main/messaging.main";
|
import { MessagingMain } from "./main/messaging.main";
|
||||||
@ -85,6 +88,10 @@ export class Main {
|
|||||||
storageDefaults["global.vaultTimeoutAction"] = "lock";
|
storageDefaults["global.vaultTimeoutAction"] = "lock";
|
||||||
this.storageService = new ElectronStorageService(app.getPath("userData"), storageDefaults);
|
this.storageService = new ElectronStorageService(app.getPath("userData"), storageDefaults);
|
||||||
this.memoryStorageService = new MemoryStorageService();
|
this.memoryStorageService = new MemoryStorageService();
|
||||||
|
const globalStateProvider = new DefaultGlobalStateProvider(
|
||||||
|
this.memoryStorageService,
|
||||||
|
this.storageService
|
||||||
|
);
|
||||||
|
|
||||||
// TODO: this state service will have access to on disk storage, but not in memory storage.
|
// TODO: this state service will have access to on disk storage, but not in memory storage.
|
||||||
// If we could get this to work using the stateService singleton that the rest of the app uses we could save
|
// If we could get this to work using the stateService singleton that the rest of the app uses we could save
|
||||||
@ -95,7 +102,11 @@ export class Main {
|
|||||||
this.memoryStorageService,
|
this.memoryStorageService,
|
||||||
this.logService,
|
this.logService,
|
||||||
new StateFactory(GlobalState, Account),
|
new StateFactory(GlobalState, Account),
|
||||||
new AccountServiceImplementation(null, this.logService), // will not broadcast logouts. This is a hack until we can remove messaging dependency
|
new AccountServiceImplementation(
|
||||||
|
new NoopMessagingService(),
|
||||||
|
this.logService,
|
||||||
|
globalStateProvider
|
||||||
|
), // will not broadcast logouts. This is a hack until we can remove messaging dependency
|
||||||
false // Do not use disk caching because this will get out of sync with the renderer service
|
false // Do not use disk caching because this will get out of sync with the renderer service
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -11,8 +11,10 @@ export class ElectronRendererStorageService implements AbstractStorageService {
|
|||||||
get valuesRequireDeserialization(): boolean {
|
get valuesRequireDeserialization(): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
get updates$() {
|
updates$;
|
||||||
return this.updatesSubject.asObservable();
|
|
||||||
|
constructor() {
|
||||||
|
this.updates$ = this.updatesSubject.asObservable();
|
||||||
}
|
}
|
||||||
|
|
||||||
get<T>(key: string): Promise<T> {
|
get<T>(key: string): Promise<T> {
|
||||||
|
@ -40,6 +40,7 @@ type Options = BaseOptions<"get"> | BaseOptions<"has"> | SaveOptions | BaseOptio
|
|||||||
export class ElectronStorageService implements AbstractStorageService {
|
export class ElectronStorageService implements AbstractStorageService {
|
||||||
private store: ElectronStore;
|
private store: ElectronStore;
|
||||||
private updatesSubject = new Subject<StorageUpdate>();
|
private updatesSubject = new Subject<StorageUpdate>();
|
||||||
|
updates$;
|
||||||
|
|
||||||
constructor(dir: string, defaults = {}) {
|
constructor(dir: string, defaults = {}) {
|
||||||
if (!fs.existsSync(dir)) {
|
if (!fs.existsSync(dir)) {
|
||||||
@ -50,6 +51,7 @@ export class ElectronStorageService implements AbstractStorageService {
|
|||||||
name: "data",
|
name: "data",
|
||||||
};
|
};
|
||||||
this.store = new Store(storeConfig);
|
this.store = new Store(storeConfig);
|
||||||
|
this.updates$ = this.updatesSubject.asObservable();
|
||||||
|
|
||||||
ipcMain.handle("storageService", (event, options: Options) => {
|
ipcMain.handle("storageService", (event, options: Options) => {
|
||||||
switch (options.action) {
|
switch (options.action) {
|
||||||
@ -68,9 +70,6 @@ export class ElectronStorageService implements AbstractStorageService {
|
|||||||
get valuesRequireDeserialization(): boolean {
|
get valuesRequireDeserialization(): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
get updates$() {
|
|
||||||
return this.updatesSubject.asObservable();
|
|
||||||
}
|
|
||||||
|
|
||||||
get<T>(key: string): Promise<T> {
|
get<T>(key: string): Promise<T> {
|
||||||
const val = this.store.get(key) as T;
|
const val = this.store.get(key) as T;
|
||||||
|
@ -8,6 +8,8 @@ import {
|
|||||||
LOCALES_DIRECTORY,
|
LOCALES_DIRECTORY,
|
||||||
SYSTEM_LANGUAGE,
|
SYSTEM_LANGUAGE,
|
||||||
MEMORY_STORAGE,
|
MEMORY_STORAGE,
|
||||||
|
OBSERVABLE_MEMORY_STORAGE,
|
||||||
|
OBSERVABLE_DISK_STORAGE,
|
||||||
} from "@bitwarden/angular/services/injection-tokens";
|
} from "@bitwarden/angular/services/injection-tokens";
|
||||||
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
|
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
|
||||||
import { ModalService as ModalServiceAbstraction } from "@bitwarden/angular/services/modal.service";
|
import { ModalService as ModalServiceAbstraction } from "@bitwarden/angular/services/modal.service";
|
||||||
@ -74,6 +76,8 @@ import { WebPlatformUtilsService } from "./web-platform-utils.service";
|
|||||||
provide: MEMORY_STORAGE,
|
provide: MEMORY_STORAGE,
|
||||||
useClass: MemoryStorageService,
|
useClass: MemoryStorageService,
|
||||||
},
|
},
|
||||||
|
{ provide: OBSERVABLE_MEMORY_STORAGE, useExisting: MEMORY_STORAGE },
|
||||||
|
{ provide: OBSERVABLE_DISK_STORAGE, useExisting: AbstractStorageService },
|
||||||
{
|
{
|
||||||
provide: PlatformUtilsServiceAbstraction,
|
provide: PlatformUtilsServiceAbstraction,
|
||||||
useClass: WebPlatformUtilsService,
|
useClass: WebPlatformUtilsService,
|
||||||
|
@ -19,8 +19,10 @@ export class HtmlStorageService implements AbstractStorageService {
|
|||||||
get valuesRequireDeserialization(): boolean {
|
get valuesRequireDeserialization(): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
get updates$() {
|
updates$;
|
||||||
return this.updatesSubject.asObservable();
|
|
||||||
|
constructor() {
|
||||||
|
this.updates$ = this.updatesSubject.asObservable();
|
||||||
}
|
}
|
||||||
|
|
||||||
get<T>(key: string, options: StorageOptions = this.defaultOptions): Promise<T> {
|
get<T>(key: string, options: StorageOptions = this.defaultOptions): Promise<T> {
|
||||||
|
@ -3,10 +3,17 @@ import { InjectionToken } from "@angular/core";
|
|||||||
import {
|
import {
|
||||||
AbstractMemoryStorageService,
|
AbstractMemoryStorageService,
|
||||||
AbstractStorageService,
|
AbstractStorageService,
|
||||||
|
ObservableStorageService,
|
||||||
} from "@bitwarden/common/platform/abstractions/storage.service";
|
} from "@bitwarden/common/platform/abstractions/storage.service";
|
||||||
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
|
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
|
||||||
|
|
||||||
export const WINDOW = new InjectionToken<Window>("WINDOW");
|
export const WINDOW = new InjectionToken<Window>("WINDOW");
|
||||||
|
export const OBSERVABLE_MEMORY_STORAGE = new InjectionToken<
|
||||||
|
AbstractMemoryStorageService & ObservableStorageService
|
||||||
|
>("OBSERVABLE_MEMORY_STORAGE");
|
||||||
|
export const OBSERVABLE_DISK_STORAGE = new InjectionToken<
|
||||||
|
AbstractStorageService & ObservableStorageService
|
||||||
|
>("OBSERVABLE_DISK_STORAGE");
|
||||||
export const MEMORY_STORAGE = new InjectionToken<AbstractMemoryStorageService>("MEMORY_STORAGE");
|
export const MEMORY_STORAGE = new InjectionToken<AbstractMemoryStorageService>("MEMORY_STORAGE");
|
||||||
export const SECURE_STORAGE = new InjectionToken<AbstractStorageService>("SECURE_STORAGE");
|
export const SECURE_STORAGE = new InjectionToken<AbstractStorageService>("SECURE_STORAGE");
|
||||||
export const STATE_FACTORY = new InjectionToken<StateFactory>("STATE_FACTORY");
|
export const STATE_FACTORY = new InjectionToken<StateFactory>("STATE_FACTORY");
|
||||||
|
@ -101,6 +101,9 @@ import { NoopNotificationsService } from "@bitwarden/common/platform/services/no
|
|||||||
import { StateService } from "@bitwarden/common/platform/services/state.service";
|
import { StateService } from "@bitwarden/common/platform/services/state.service";
|
||||||
import { ValidationService } from "@bitwarden/common/platform/services/validation.service";
|
import { ValidationService } from "@bitwarden/common/platform/services/validation.service";
|
||||||
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
|
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
|
||||||
|
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
|
||||||
|
// eslint-disable-next-line import/no-restricted-paths -- We need the implementation to inject, but generally this should not be accessed
|
||||||
|
import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/implementations/default-global-state.provider";
|
||||||
import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service";
|
import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service";
|
||||||
import { AnonymousHubService } from "@bitwarden/common/services/anonymousHub.service";
|
import { AnonymousHubService } from "@bitwarden/common/services/anonymousHub.service";
|
||||||
import { ApiService } from "@bitwarden/common/services/api.service";
|
import { ApiService } from "@bitwarden/common/services/api.service";
|
||||||
@ -170,6 +173,8 @@ import {
|
|||||||
LOG_MAC_FAILURES,
|
LOG_MAC_FAILURES,
|
||||||
LOGOUT_CALLBACK,
|
LOGOUT_CALLBACK,
|
||||||
MEMORY_STORAGE,
|
MEMORY_STORAGE,
|
||||||
|
OBSERVABLE_DISK_STORAGE,
|
||||||
|
OBSERVABLE_MEMORY_STORAGE,
|
||||||
SECURE_STORAGE,
|
SECURE_STORAGE,
|
||||||
STATE_FACTORY,
|
STATE_FACTORY,
|
||||||
STATE_SERVICE_USE_CACHE,
|
STATE_SERVICE_USE_CACHE,
|
||||||
@ -337,7 +342,7 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
|
|||||||
{
|
{
|
||||||
provide: AccountServiceAbstraction,
|
provide: AccountServiceAbstraction,
|
||||||
useClass: AccountServiceImplementation,
|
useClass: AccountServiceImplementation,
|
||||||
deps: [MessagingServiceAbstraction, LogService],
|
deps: [MessagingServiceAbstraction, LogService, GlobalStateProvider],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: InternalAccountService,
|
provide: InternalAccountService,
|
||||||
@ -747,6 +752,11 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
|
|||||||
useClass: AuthRequestCryptoServiceImplementation,
|
useClass: AuthRequestCryptoServiceImplementation,
|
||||||
deps: [CryptoServiceAbstraction],
|
deps: [CryptoServiceAbstraction],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: GlobalStateProvider,
|
||||||
|
useClass: DefaultGlobalStateProvider,
|
||||||
|
deps: [OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class JslibServicesModule {}
|
export class JslibServicesModule {}
|
||||||
|
@ -3,12 +3,20 @@ import { Observable } from "rxjs";
|
|||||||
import { UserId } from "../../types/guid";
|
import { UserId } from "../../types/guid";
|
||||||
import { AuthenticationStatus } from "../enums/authentication-status";
|
import { AuthenticationStatus } from "../enums/authentication-status";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds information about an account for use in the AccountService
|
||||||
|
* if more information is added, be sure to update the equality method.
|
||||||
|
*/
|
||||||
export type AccountInfo = {
|
export type AccountInfo = {
|
||||||
status: AuthenticationStatus;
|
status: AuthenticationStatus;
|
||||||
email: string;
|
email: string;
|
||||||
name: string | undefined;
|
name: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function accountInfoEqual(a: AccountInfo, b: AccountInfo) {
|
||||||
|
return a.status == b.status && a.email == b.email && a.name == b.name;
|
||||||
|
}
|
||||||
|
|
||||||
export abstract class AccountService {
|
export abstract class AccountService {
|
||||||
accounts$: Observable<Record<UserId, AccountInfo>>;
|
accounts$: Observable<Record<UserId, AccountInfo>>;
|
||||||
activeAccount$: Observable<{ id: UserId | undefined } & AccountInfo>;
|
activeAccount$: Observable<{ id: UserId | undefined } & AccountInfo>;
|
||||||
|
@ -1,9 +1,15 @@
|
|||||||
import { MockProxy, mock } from "jest-mock-extended";
|
import { MockProxy, mock } from "jest-mock-extended";
|
||||||
import { firstValueFrom } from "rxjs";
|
import { BehaviorSubject, firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
import { trackEmissions } from "../../../spec/utils";
|
import { trackEmissions } from "../../../spec/utils";
|
||||||
import { LogService } from "../../platform/abstractions/log.service";
|
import { LogService } from "../../platform/abstractions/log.service";
|
||||||
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
||||||
|
import {
|
||||||
|
ACCOUNT_ACCOUNTS,
|
||||||
|
ACCOUNT_ACTIVE_ACCOUNT_ID,
|
||||||
|
GlobalState,
|
||||||
|
GlobalStateProvider,
|
||||||
|
} from "../../platform/state";
|
||||||
import { UserId } from "../../types/guid";
|
import { UserId } from "../../types/guid";
|
||||||
import { AccountInfo } from "../abstractions/account.service";
|
import { AccountInfo } from "../abstractions/account.service";
|
||||||
import { AuthenticationStatus } from "../enums/authentication-status";
|
import { AuthenticationStatus } from "../enums/authentication-status";
|
||||||
@ -13,6 +19,11 @@ import { AccountServiceImplementation } from "./account.service";
|
|||||||
describe("accountService", () => {
|
describe("accountService", () => {
|
||||||
let messagingService: MockProxy<MessagingService>;
|
let messagingService: MockProxy<MessagingService>;
|
||||||
let logService: MockProxy<LogService>;
|
let logService: MockProxy<LogService>;
|
||||||
|
let globalStateProvider: MockProxy<GlobalStateProvider>;
|
||||||
|
let accountsState: MockProxy<GlobalState<Record<UserId, AccountInfo>>>;
|
||||||
|
let accountsSubject: BehaviorSubject<Record<UserId, AccountInfo>>;
|
||||||
|
let activeAccountIdState: MockProxy<GlobalState<UserId>>;
|
||||||
|
let activeAccountIdSubject: BehaviorSubject<UserId>;
|
||||||
let sut: AccountServiceImplementation;
|
let sut: AccountServiceImplementation;
|
||||||
const userId = "userId" as UserId;
|
const userId = "userId" as UserId;
|
||||||
function userInfo(status: AuthenticationStatus): AccountInfo {
|
function userInfo(status: AuthenticationStatus): AccountInfo {
|
||||||
@ -20,10 +31,29 @@ describe("accountService", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
messagingService = mock<MessagingService>();
|
messagingService = mock();
|
||||||
logService = mock<LogService>();
|
logService = mock();
|
||||||
|
globalStateProvider = mock();
|
||||||
|
accountsState = mock();
|
||||||
|
activeAccountIdState = mock();
|
||||||
|
|
||||||
sut = new AccountServiceImplementation(messagingService, logService);
|
accountsSubject = new BehaviorSubject<Record<UserId, AccountInfo>>(null);
|
||||||
|
accountsState.state$ = accountsSubject.asObservable();
|
||||||
|
activeAccountIdSubject = new BehaviorSubject<UserId>(null);
|
||||||
|
activeAccountIdState.state$ = activeAccountIdSubject.asObservable();
|
||||||
|
|
||||||
|
globalStateProvider.get.mockImplementation((keyDefinition) => {
|
||||||
|
switch (keyDefinition) {
|
||||||
|
case ACCOUNT_ACCOUNTS:
|
||||||
|
return accountsState;
|
||||||
|
case ACCOUNT_ACTIVE_ACCOUNT_ID:
|
||||||
|
return activeAccountIdState;
|
||||||
|
default:
|
||||||
|
throw new Error("Unknown key definition");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sut = new AccountServiceImplementation(messagingService, logService, globalStateProvider);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -39,8 +69,8 @@ describe("accountService", () => {
|
|||||||
|
|
||||||
it("should emit the active account and status", async () => {
|
it("should emit the active account and status", async () => {
|
||||||
const emissions = trackEmissions(sut.activeAccount$);
|
const emissions = trackEmissions(sut.activeAccount$);
|
||||||
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
|
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
|
||||||
sut.switchAccount(userId);
|
activeAccountIdSubject.next(userId);
|
||||||
|
|
||||||
expect(emissions).toEqual([
|
expect(emissions).toEqual([
|
||||||
undefined, // initial value
|
undefined, // initial value
|
||||||
@ -48,9 +78,21 @@ describe("accountService", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should update the status if the account status changes", async () => {
|
||||||
|
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
|
||||||
|
activeAccountIdSubject.next(userId);
|
||||||
|
const emissions = trackEmissions(sut.activeAccount$);
|
||||||
|
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Locked) });
|
||||||
|
|
||||||
|
expect(emissions).toEqual([
|
||||||
|
{ id: userId, ...userInfo(AuthenticationStatus.Unlocked) },
|
||||||
|
{ id: userId, ...userInfo(AuthenticationStatus.Locked) },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("should remember the last emitted value", async () => {
|
it("should remember the last emitted value", async () => {
|
||||||
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
|
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
|
||||||
sut.switchAccount(userId);
|
activeAccountIdSubject.next(userId);
|
||||||
|
|
||||||
expect(await firstValueFrom(sut.activeAccount$)).toEqual({
|
expect(await firstValueFrom(sut.activeAccount$)).toEqual({
|
||||||
id: userId,
|
id: userId,
|
||||||
@ -59,77 +101,98 @@ describe("accountService", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("accounts$", () => {
|
||||||
|
it("should maintain an accounts cache", async () => {
|
||||||
|
expect(await firstValueFrom(sut.accounts$)).toEqual({});
|
||||||
|
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
|
||||||
|
expect(await firstValueFrom(sut.accounts$)).toEqual({
|
||||||
|
[userId]: userInfo(AuthenticationStatus.Unlocked),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("addAccount", () => {
|
describe("addAccount", () => {
|
||||||
it("should emit the new account", () => {
|
it("should emit the new account", () => {
|
||||||
const emissions = trackEmissions(sut.accounts$);
|
|
||||||
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
|
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
|
||||||
|
|
||||||
expect(emissions).toEqual([
|
expect(accountsState.update).toHaveBeenCalledTimes(1);
|
||||||
{}, // initial value
|
const callback = accountsState.update.mock.calls[0][0];
|
||||||
{ [userId]: userInfo(AuthenticationStatus.Unlocked) },
|
expect(callback({}, null)).toEqual({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("setAccountName", () => {
|
describe("setAccountName", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
|
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should emit the updated account", () => {
|
it("should update the account", async () => {
|
||||||
const emissions = trackEmissions(sut.accounts$);
|
|
||||||
sut.setAccountName(userId, "new name");
|
sut.setAccountName(userId, "new name");
|
||||||
|
|
||||||
expect(emissions).toEqual([
|
const callback = accountsState.update.mock.calls[0][0];
|
||||||
{ [userId]: { ...userInfo(AuthenticationStatus.Unlocked), name: "name" } },
|
|
||||||
{ [userId]: { ...userInfo(AuthenticationStatus.Unlocked), name: "new name" } },
|
expect(callback(accountsSubject.value, null)).toEqual({
|
||||||
]);
|
[userId]: { ...userInfo(AuthenticationStatus.Unlocked), name: "new name" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not update if the name is the same", async () => {
|
||||||
|
sut.setAccountName(userId, "name");
|
||||||
|
|
||||||
|
const callback = accountsState.update.mock.calls[0][1].shouldUpdate;
|
||||||
|
|
||||||
|
expect(callback(accountsSubject.value, null)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("setAccountEmail", () => {
|
describe("setAccountEmail", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
|
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should emit the updated account", () => {
|
it("should update the account", () => {
|
||||||
const emissions = trackEmissions(sut.accounts$);
|
|
||||||
sut.setAccountEmail(userId, "new email");
|
sut.setAccountEmail(userId, "new email");
|
||||||
|
|
||||||
expect(emissions).toEqual([
|
const callback = accountsState.update.mock.calls[0][0];
|
||||||
{ [userId]: { ...userInfo(AuthenticationStatus.Unlocked), email: "email" } },
|
|
||||||
{ [userId]: { ...userInfo(AuthenticationStatus.Unlocked), email: "new email" } },
|
expect(callback(accountsSubject.value, null)).toEqual({
|
||||||
]);
|
[userId]: { ...userInfo(AuthenticationStatus.Unlocked), email: "new email" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not update if the email is the same", () => {
|
||||||
|
sut.setAccountEmail(userId, "email");
|
||||||
|
|
||||||
|
const callback = accountsState.update.mock.calls[0][1].shouldUpdate;
|
||||||
|
|
||||||
|
expect(callback(accountsSubject.value, null)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("setAccountStatus", () => {
|
describe("setAccountStatus", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
|
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not emit if the status is the same", async () => {
|
it("should update the account", () => {
|
||||||
const emissions = trackEmissions(sut.accounts$);
|
|
||||||
sut.setAccountStatus(userId, AuthenticationStatus.Unlocked);
|
|
||||||
sut.setAccountStatus(userId, AuthenticationStatus.Unlocked);
|
|
||||||
|
|
||||||
expect(emissions).toEqual([{ userId: userInfo(AuthenticationStatus.Unlocked) }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should maintain an accounts cache", async () => {
|
|
||||||
expect(await firstValueFrom(sut.accounts$)).toEqual({
|
|
||||||
[userId]: userInfo(AuthenticationStatus.Unlocked),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should emit if the status is different", () => {
|
|
||||||
const emissions = trackEmissions(sut.accounts$);
|
|
||||||
sut.setAccountStatus(userId, AuthenticationStatus.Locked);
|
sut.setAccountStatus(userId, AuthenticationStatus.Locked);
|
||||||
|
|
||||||
expect(emissions).toEqual([
|
const callback = accountsState.update.mock.calls[0][0];
|
||||||
{ userId: userInfo(AuthenticationStatus.Unlocked) }, // initial value from beforeEach
|
|
||||||
{ userId: userInfo(AuthenticationStatus.Locked) },
|
expect(callback(accountsSubject.value, null)).toEqual({
|
||||||
]);
|
[userId]: {
|
||||||
|
...userInfo(AuthenticationStatus.Unlocked),
|
||||||
|
status: AuthenticationStatus.Locked,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not update if the status is the same", () => {
|
||||||
|
sut.setAccountStatus(userId, AuthenticationStatus.Unlocked);
|
||||||
|
|
||||||
|
const callback = accountsState.update.mock.calls[0][1].shouldUpdate;
|
||||||
|
|
||||||
|
expect(callback(accountsSubject.value, null)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should emit logout if the status is logged out", () => {
|
it("should emit logout if the status is logged out", () => {
|
||||||
@ -148,34 +211,20 @@ describe("accountService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("switchAccount", () => {
|
describe("switchAccount", () => {
|
||||||
let emissions: { id: string; status: AuthenticationStatus }[];
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
emissions = [];
|
accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) });
|
||||||
sut.activeAccount$.subscribe((value) => emissions.push(value));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should emit undefined if no account is provided", () => {
|
it("should emit undefined if no account is provided", () => {
|
||||||
sut.switchAccount(undefined);
|
sut.switchAccount(null);
|
||||||
|
const callback = activeAccountIdState.update.mock.calls[0][0];
|
||||||
expect(emissions).toEqual([undefined]);
|
expect(callback(userId, accountsSubject.value)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should emit the active account and status", () => {
|
it("should throw if the account does not exist", () => {
|
||||||
sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked));
|
sut.switchAccount("unknown" as UserId);
|
||||||
sut.switchAccount(userId);
|
const callback = activeAccountIdState.update.mock.calls[0][0];
|
||||||
sut.setAccountStatus(userId, AuthenticationStatus.Locked);
|
expect(() => callback(userId, accountsSubject.value)).toThrowError("Account does not exist");
|
||||||
sut.switchAccount(undefined);
|
|
||||||
sut.switchAccount(undefined);
|
|
||||||
expect(emissions).toEqual([
|
|
||||||
undefined, // initial value
|
|
||||||
{ id: userId, ...userInfo(AuthenticationStatus.Unlocked) },
|
|
||||||
{ id: userId, ...userInfo(AuthenticationStatus.Locked) },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw if switched to an unknown account", () => {
|
|
||||||
expect(() => sut.switchAccount(userId)).toThrowError("Account does not exist");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,50 +1,80 @@
|
|||||||
import {
|
import { Subject, combineLatestWith, map, distinctUntilChanged, shareReplay } from "rxjs";
|
||||||
BehaviorSubject,
|
import { Jsonify } from "type-fest";
|
||||||
Subject,
|
|
||||||
combineLatestWith,
|
|
||||||
map,
|
|
||||||
distinctUntilChanged,
|
|
||||||
shareReplay,
|
|
||||||
} from "rxjs";
|
|
||||||
|
|
||||||
import { AccountInfo, InternalAccountService } from "../../auth/abstractions/account.service";
|
import {
|
||||||
|
AccountInfo,
|
||||||
|
InternalAccountService,
|
||||||
|
accountInfoEqual,
|
||||||
|
} from "../../auth/abstractions/account.service";
|
||||||
import { LogService } from "../../platform/abstractions/log.service";
|
import { LogService } from "../../platform/abstractions/log.service";
|
||||||
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
||||||
|
import {
|
||||||
|
ACCOUNT_ACCOUNTS,
|
||||||
|
ACCOUNT_ACTIVE_ACCOUNT_ID,
|
||||||
|
GlobalState,
|
||||||
|
GlobalStateProvider,
|
||||||
|
} from "../../platform/state";
|
||||||
import { UserId } from "../../types/guid";
|
import { UserId } from "../../types/guid";
|
||||||
import { AuthenticationStatus } from "../enums/authentication-status";
|
import { AuthenticationStatus } from "../enums/authentication-status";
|
||||||
|
|
||||||
|
export function AccountsDeserializer(
|
||||||
|
accounts: Jsonify<Record<UserId, AccountInfo> | null>
|
||||||
|
): Record<UserId, AccountInfo> {
|
||||||
|
if (accounts == null) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return accounts;
|
||||||
|
}
|
||||||
|
|
||||||
export class AccountServiceImplementation implements InternalAccountService {
|
export class AccountServiceImplementation implements InternalAccountService {
|
||||||
private accounts = new BehaviorSubject<Record<UserId, AccountInfo>>({});
|
|
||||||
private activeAccountId = new BehaviorSubject<UserId | undefined>(undefined);
|
|
||||||
private lock = new Subject<UserId>();
|
private lock = new Subject<UserId>();
|
||||||
private logout = new Subject<UserId>();
|
private logout = new Subject<UserId>();
|
||||||
|
private accountsState: GlobalState<Record<UserId, AccountInfo>>;
|
||||||
|
private activeAccountIdState: GlobalState<UserId | undefined>;
|
||||||
|
|
||||||
accounts$ = this.accounts.asObservable();
|
accounts$;
|
||||||
activeAccount$ = this.activeAccountId.pipe(
|
activeAccount$;
|
||||||
|
accountLock$ = this.lock.asObservable();
|
||||||
|
accountLogout$ = this.logout.asObservable();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private messagingService: MessagingService,
|
||||||
|
private logService: LogService,
|
||||||
|
private globalStateProvider: GlobalStateProvider
|
||||||
|
) {
|
||||||
|
this.accountsState = this.globalStateProvider.get(ACCOUNT_ACCOUNTS);
|
||||||
|
this.activeAccountIdState = this.globalStateProvider.get(ACCOUNT_ACTIVE_ACCOUNT_ID);
|
||||||
|
|
||||||
|
this.accounts$ = this.accountsState.state$.pipe(
|
||||||
|
map((accounts) => (accounts == null ? {} : accounts))
|
||||||
|
);
|
||||||
|
this.activeAccount$ = this.activeAccountIdState.state$.pipe(
|
||||||
combineLatestWith(this.accounts$),
|
combineLatestWith(this.accounts$),
|
||||||
map(([id, accounts]) => (id ? { id, ...accounts[id] } : undefined)),
|
map(([id, accounts]) => (id ? { id, ...accounts[id] } : undefined)),
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
shareReplay({ bufferSize: 1, refCount: false })
|
shareReplay({ bufferSize: 1, refCount: false })
|
||||||
);
|
);
|
||||||
accountLock$ = this.lock.asObservable();
|
}
|
||||||
accountLogout$ = this.logout.asObservable();
|
|
||||||
constructor(private messagingService: MessagingService, private logService: LogService) {}
|
|
||||||
|
|
||||||
addAccount(userId: UserId, accountData: AccountInfo): void {
|
addAccount(userId: UserId, accountData: AccountInfo): void {
|
||||||
this.accounts.value[userId] = accountData;
|
this.accountsState.update((accounts) => {
|
||||||
this.accounts.next(this.accounts.value);
|
accounts ||= {};
|
||||||
|
accounts[userId] = accountData;
|
||||||
|
return accounts;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setAccountName(userId: UserId, name: string): void {
|
setAccountName(userId: UserId, name: string): void {
|
||||||
this.setAccountInfo(userId, { ...this.accounts.value[userId], name });
|
this.setAccountInfo(userId, { name });
|
||||||
}
|
}
|
||||||
|
|
||||||
setAccountEmail(userId: UserId, email: string): void {
|
setAccountEmail(userId: UserId, email: string): void {
|
||||||
this.setAccountInfo(userId, { ...this.accounts.value[userId], email });
|
this.setAccountInfo(userId, { email });
|
||||||
}
|
}
|
||||||
|
|
||||||
setAccountStatus(userId: UserId, status: AuthenticationStatus): void {
|
setAccountStatus(userId: UserId, status: AuthenticationStatus): void {
|
||||||
this.setAccountInfo(userId, { ...this.accounts.value[userId], status });
|
this.setAccountInfo(userId, { status });
|
||||||
|
|
||||||
if (status === AuthenticationStatus.LoggedOut) {
|
if (status === AuthenticationStatus.LoggedOut) {
|
||||||
this.logout.next(userId);
|
this.logout.next(userId);
|
||||||
@ -54,16 +84,22 @@ export class AccountServiceImplementation implements InternalAccountService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switchAccount(userId: UserId) {
|
switchAccount(userId: UserId) {
|
||||||
|
this.activeAccountIdState.update(
|
||||||
|
(_, accounts) => {
|
||||||
if (userId == null) {
|
if (userId == null) {
|
||||||
// indicates no account is active
|
// indicates no account is active
|
||||||
this.activeAccountId.next(undefined);
|
return undefined;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.accounts.value[userId] == null) {
|
if (accounts?.[userId] == null) {
|
||||||
throw new Error("Account does not exist");
|
throw new Error("Account does not exist");
|
||||||
}
|
}
|
||||||
this.activeAccountId.next(userId);
|
return userId;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combineLatestWith: this.accounts$,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: update to use our own account status settings. Requires inverting direction of state service accounts flow
|
// TODO: update to use our own account status settings. Requires inverting direction of state service accounts flow
|
||||||
@ -76,18 +112,26 @@ export class AccountServiceImplementation implements InternalAccountService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private setAccountInfo(userId: UserId, accountInfo: AccountInfo) {
|
private setAccountInfo(userId: UserId, update: Partial<AccountInfo>) {
|
||||||
if (this.accounts.value[userId] == null) {
|
function newAccountInfo(oldAccountInfo: AccountInfo): AccountInfo {
|
||||||
|
return { ...oldAccountInfo, ...update };
|
||||||
|
}
|
||||||
|
this.accountsState.update(
|
||||||
|
(accounts) => {
|
||||||
|
accounts[userId] = newAccountInfo(accounts[userId]);
|
||||||
|
return accounts;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Avoid unnecessary updates
|
||||||
|
// TODO: Faster comparison, maybe include a hash on the objects?
|
||||||
|
shouldUpdate: (accounts) => {
|
||||||
|
if (accounts?.[userId] == null) {
|
||||||
throw new Error("Account does not exist");
|
throw new Error("Account does not exist");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avoid unnecessary updates
|
return !accountInfoEqual(accounts[userId], newAccountInfo(accounts[userId]));
|
||||||
// TODO: Faster comparison, maybe include a hash on the objects?
|
},
|
||||||
if (JSON.stringify(this.accounts.value[userId]) === JSON.stringify(accountInfo)) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
this.accounts.value[userId] = accountInfo;
|
|
||||||
this.accounts.next(this.accounts.value);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,14 +8,17 @@ export type StorageUpdate = {
|
|||||||
updateType: StorageUpdateType;
|
updateType: StorageUpdateType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export abstract class AbstractStorageService {
|
export interface ObservableStorageService {
|
||||||
abstract get valuesRequireDeserialization(): boolean;
|
|
||||||
/**
|
/**
|
||||||
* Provides an {@link Observable} that represents a stream of updates that
|
* Provides an {@link Observable} that represents a stream of updates that
|
||||||
* have happened in this storage service or in the storage this service provides
|
* have happened in this storage service or in the storage this service provides
|
||||||
* an interface to.
|
* an interface to.
|
||||||
*/
|
*/
|
||||||
abstract get updates$(): Observable<StorageUpdate>;
|
get updates$(): Observable<StorageUpdate>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class AbstractStorageService {
|
||||||
|
abstract get valuesRequireDeserialization(): boolean;
|
||||||
abstract get<T>(key: string, options?: StorageOptions): Promise<T>;
|
abstract get<T>(key: string, options?: StorageOptions): Promise<T>;
|
||||||
abstract has(key: string, options?: StorageOptions): Promise<boolean>;
|
abstract has(key: string, options?: StorageOptions): Promise<boolean>;
|
||||||
abstract save<T>(key: string, obj: T, options?: StorageOptions): Promise<void>;
|
abstract save<T>(key: string, obj: T, options?: StorageOptions): Promise<void>;
|
||||||
|
@ -3,7 +3,7 @@ import { Subject } from "rxjs";
|
|||||||
import { AbstractMemoryStorageService, StorageUpdate } from "../abstractions/storage.service";
|
import { AbstractMemoryStorageService, StorageUpdate } from "../abstractions/storage.service";
|
||||||
|
|
||||||
export class MemoryStorageService extends AbstractMemoryStorageService {
|
export class MemoryStorageService extends AbstractMemoryStorageService {
|
||||||
private store = new Map<string, unknown>();
|
protected store = new Map<string, unknown>();
|
||||||
private updatesSubject = new Subject<StorageUpdate>();
|
private updatesSubject = new Subject<StorageUpdate>();
|
||||||
|
|
||||||
get valuesRequireDeserialization(): boolean {
|
get valuesRequireDeserialization(): boolean {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
AbstractMemoryStorageService,
|
AbstractMemoryStorageService,
|
||||||
AbstractStorageService,
|
AbstractStorageService,
|
||||||
|
ObservableStorageService,
|
||||||
} from "../../abstractions/storage.service";
|
} from "../../abstractions/storage.service";
|
||||||
import { GlobalState } from "../global-state";
|
import { GlobalState } from "../global-state";
|
||||||
import { GlobalStateProvider } from "../global-state.provider";
|
import { GlobalStateProvider } from "../global-state.provider";
|
||||||
@ -13,8 +14,8 @@ export class DefaultGlobalStateProvider implements GlobalStateProvider {
|
|||||||
private globalStateCache: Record<string, GlobalState<unknown>> = {};
|
private globalStateCache: Record<string, GlobalState<unknown>> = {};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private memoryStorage: AbstractMemoryStorageService,
|
private memoryStorage: AbstractMemoryStorageService & ObservableStorageService,
|
||||||
private diskStorage: AbstractStorageService
|
private diskStorage: AbstractStorageService & ObservableStorageService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
|
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
|
||||||
|
@ -10,7 +10,10 @@ import {
|
|||||||
timeout,
|
timeout,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
|
|
||||||
import { AbstractStorageService } from "../../abstractions/storage.service";
|
import {
|
||||||
|
AbstractStorageService,
|
||||||
|
ObservableStorageService,
|
||||||
|
} from "../../abstractions/storage.service";
|
||||||
import { GlobalState } from "../global-state";
|
import { GlobalState } from "../global-state";
|
||||||
import { KeyDefinition, globalKeyBuilder } from "../key-definition";
|
import { KeyDefinition, globalKeyBuilder } from "../key-definition";
|
||||||
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
|
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
|
||||||
@ -29,7 +32,7 @@ export class DefaultGlobalState<T> implements GlobalState<T> {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private keyDefinition: KeyDefinition<T>,
|
private keyDefinition: KeyDefinition<T>,
|
||||||
private chosenLocation: AbstractStorageService
|
private chosenLocation: AbstractStorageService & ObservableStorageService
|
||||||
) {
|
) {
|
||||||
this.storageKey = globalKeyBuilder(this.keyDefinition);
|
this.storageKey = globalKeyBuilder(this.keyDefinition);
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@ import { EncryptService } from "../../abstractions/encrypt.service";
|
|||||||
import {
|
import {
|
||||||
AbstractMemoryStorageService,
|
AbstractMemoryStorageService,
|
||||||
AbstractStorageService,
|
AbstractStorageService,
|
||||||
|
ObservableStorageService,
|
||||||
} from "../../abstractions/storage.service";
|
} from "../../abstractions/storage.service";
|
||||||
import { KeyDefinition } from "../key-definition";
|
import { KeyDefinition } from "../key-definition";
|
||||||
import { StorageLocation } from "../state-definition";
|
import { StorageLocation } from "../state-definition";
|
||||||
@ -17,8 +18,8 @@ export class DefaultUserStateProvider implements UserStateProvider {
|
|||||||
constructor(
|
constructor(
|
||||||
protected accountService: AccountService,
|
protected accountService: AccountService,
|
||||||
protected encryptService: EncryptService,
|
protected encryptService: EncryptService,
|
||||||
protected memoryStorage: AbstractMemoryStorageService,
|
protected memoryStorage: AbstractMemoryStorageService & ObservableStorageService,
|
||||||
protected diskStorage: AbstractStorageService
|
protected diskStorage: AbstractStorageService & ObservableStorageService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get<T>(keyDefinition: KeyDefinition<T>): UserState<T> {
|
get<T>(keyDefinition: KeyDefinition<T>): UserState<T> {
|
||||||
|
@ -15,7 +15,10 @@ import {
|
|||||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||||
import { UserId } from "../../../types/guid";
|
import { UserId } from "../../../types/guid";
|
||||||
import { EncryptService } from "../../abstractions/encrypt.service";
|
import { EncryptService } from "../../abstractions/encrypt.service";
|
||||||
import { AbstractStorageService } from "../../abstractions/storage.service";
|
import {
|
||||||
|
AbstractStorageService,
|
||||||
|
ObservableStorageService,
|
||||||
|
} from "../../abstractions/storage.service";
|
||||||
import { DerivedUserState } from "../derived-user-state";
|
import { DerivedUserState } from "../derived-user-state";
|
||||||
import { KeyDefinition, userKeyBuilder } from "../key-definition";
|
import { KeyDefinition, userKeyBuilder } from "../key-definition";
|
||||||
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
|
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
|
||||||
@ -40,7 +43,7 @@ export class DefaultUserState<T> implements UserState<T> {
|
|||||||
protected keyDefinition: KeyDefinition<T>,
|
protected keyDefinition: KeyDefinition<T>,
|
||||||
private accountService: AccountService,
|
private accountService: AccountService,
|
||||||
private encryptService: EncryptService,
|
private encryptService: EncryptService,
|
||||||
private chosenStorageLocation: AbstractStorageService
|
private chosenStorageLocation: AbstractStorageService & ObservableStorageService
|
||||||
) {
|
) {
|
||||||
this.formattedKey$ = this.accountService.activeAccount$.pipe(
|
this.formattedKey$ = this.accountService.activeAccount$.pipe(
|
||||||
map((account) =>
|
map((account) =>
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
export { DerivedUserState } from "./derived-user-state";
|
export { DerivedUserState } from "./derived-user-state";
|
||||||
export { DefaultGlobalStateProvider } from "./implementations/default-global-state.provider";
|
export { GlobalState } from "./global-state";
|
||||||
export { DefaultUserStateProvider } from "./implementations/default-user-state.provider";
|
export { GlobalStateProvider } from "./global-state.provider";
|
||||||
|
export { UserState } from "./user-state";
|
||||||
|
export { UserStateProvider } from "./user-state.provider";
|
||||||
|
|
||||||
|
export * from "./key-definitions";
|
||||||
|
18
libs/common/src/platform/state/key-definitions.ts
Normal file
18
libs/common/src/platform/state/key-definitions.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { AccountInfo } from "../../auth/abstractions/account.service";
|
||||||
|
import { AccountsDeserializer } from "../../auth/services/account.service";
|
||||||
|
import { UserId } from "../../types/guid";
|
||||||
|
|
||||||
|
import { KeyDefinition } from "./key-definition";
|
||||||
|
import { StateDefinition } from "./state-definition";
|
||||||
|
|
||||||
|
const ACCOUNT_MEMORY = new StateDefinition("account", "memory");
|
||||||
|
export const ACCOUNT_ACCOUNTS = new KeyDefinition<Record<UserId, AccountInfo>>(
|
||||||
|
ACCOUNT_MEMORY,
|
||||||
|
"accounts",
|
||||||
|
{
|
||||||
|
deserializer: (obj) => AccountsDeserializer(obj),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
export const ACCOUNT_ACTIVE_ACCOUNT_ID = new KeyDefinition(ACCOUNT_MEMORY, "activeAccountId", {
|
||||||
|
deserializer: (id: UserId) => id,
|
||||||
|
});
|
@ -34,5 +34,5 @@
|
|||||||
"useDefineForClassFields": false
|
"useDefineForClassFields": false
|
||||||
},
|
},
|
||||||
"include": ["apps/web/src/**/*", "libs/*/src/**/*", "bitwarden_license/bit-web/src/**/*"],
|
"include": ["apps/web/src/**/*", "libs/*/src/**/*", "bitwarden_license/bit-web/src/**/*"],
|
||||||
"exclude": ["apps/web/src/**/*.spec.ts", "libs/*/src/**/*.spec.ts"]
|
"exclude": ["apps/web/src/**/*.spec.ts", "libs/*/src/**/*.spec.ts", "**/*.spec-util.ts"]
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user