From e7aad3829eb8831452d1cc233fd22ec9ab1021fd Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Thu, 21 Mar 2024 17:26:59 +0100 Subject: [PATCH 1/7] Update supported languages for web (#8382) Co-authored-by: Daniel James Smith --- apps/web/src/translation-constants.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/web/src/translation-constants.ts b/apps/web/src/translation-constants.ts index dfdcc7c1f52..aa18b319bb3 100644 --- a/apps/web/src/translation-constants.ts +++ b/apps/web/src/translation-constants.ts @@ -10,6 +10,7 @@ export const SupportedTranslationLocales: string[] = [ "bs", "ca", "cs", + "cy", "da", "de", "el", @@ -19,9 +20,11 @@ export const SupportedTranslationLocales: string[] = [ "es", "et", "eu", + "fa", "fi", "fil", "fr", + "gl", "he", "hi", "hr", @@ -35,9 +38,13 @@ export const SupportedTranslationLocales: string[] = [ "ko", "lv", "ml", + "mr", + "my", "nb", + "ne", "nl", "nn", + "or", "pl", "pt-PT", "pt-BR", @@ -48,6 +55,8 @@ export const SupportedTranslationLocales: string[] = [ "sl", "sr", "sv", + "te", + "th", "tr", "uk", "vi", From 05609a814c036a2fe34612f11df9dd38769c1bf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Thu, 21 Mar 2024 12:44:42 -0400 Subject: [PATCH 2/7] [PM-6847] `SecretState` array and record support (#8378) --- libs/common/src/platform/state/index.ts | 2 +- .../state/secret-key-definition.spec.ts | 186 ++++++++++++++++++ .../generator/state/secret-key-definition.ts | 92 +++++++++ .../generator/state/secret-state.spec.ts | 105 ++++++---- .../src/tools/generator/state/secret-state.ts | 125 +++++++----- .../state/user-encryptor.abstraction.ts | 18 +- .../state/user-key-encryptor.spec.ts | 69 ++----- .../generator/state/user-key-encryptor.ts | 33 +--- .../username/forwarder-generator-strategy.ts | 29 ++- 9 files changed, 485 insertions(+), 174 deletions(-) create mode 100644 libs/common/src/tools/generator/state/secret-key-definition.spec.ts create mode 100644 libs/common/src/tools/generator/state/secret-key-definition.ts diff --git a/libs/common/src/platform/state/index.ts b/libs/common/src/platform/state/index.ts index 79f5b4172fd..dd14aaf329d 100644 --- a/libs/common/src/platform/state/index.ts +++ b/libs/common/src/platform/state/index.ts @@ -6,7 +6,7 @@ export { StateProvider } from "./state.provider"; export { GlobalStateProvider } from "./global-state.provider"; export { ActiveUserState, SingleUserState, CombinedState } from "./user-state"; export { ActiveUserStateProvider, SingleUserStateProvider } from "./user-state.provider"; -export { KeyDefinition } from "./key-definition"; +export { KeyDefinition, KeyDefinitionOptions } from "./key-definition"; export { StateUpdateOptions } from "./state-update-options"; export { UserKeyDefinition } from "./user-key-definition"; export { StateEventRunnerService } from "./state-event-runner.service"; diff --git a/libs/common/src/tools/generator/state/secret-key-definition.spec.ts b/libs/common/src/tools/generator/state/secret-key-definition.spec.ts new file mode 100644 index 00000000000..20bc1f5ee17 --- /dev/null +++ b/libs/common/src/tools/generator/state/secret-key-definition.spec.ts @@ -0,0 +1,186 @@ +import { GENERATOR_DISK } from "../../../platform/state"; + +import { SecretClassifier } from "./secret-classifier"; +import { SecretKeyDefinition } from "./secret-key-definition"; + +describe("SecretKeyDefinition", () => { + const classifier = SecretClassifier.allSecret<{ foo: boolean }>(); + const options = { deserializer: (v: any) => v }; + + describe("value", () => { + it("returns an initialized SecretKeyDefinition", () => { + const definition = SecretKeyDefinition.value(GENERATOR_DISK, "key", classifier, options); + + expect(definition).toBeInstanceOf(SecretKeyDefinition); + expect(definition.stateDefinition).toBe(GENERATOR_DISK); + expect(definition.key).toBe("key"); + expect(definition.classifier).toBe(classifier); + }); + + it("deconstruct returns an array with a single item", () => { + const definition = SecretKeyDefinition.value(GENERATOR_DISK, "key", classifier, options); + const value = { foo: true }; + + const result = definition.deconstruct(value); + + expect(result).toEqual([[null, value]]); + }); + + it("reconstruct returns the inner value", () => { + const definition = SecretKeyDefinition.value(GENERATOR_DISK, "key", classifier, options); + const value = { foo: true }; + + const result = definition.reconstruct([[null, value]]); + + expect(result).toBe(value); + }); + }); + + describe("array", () => { + it("returns an initialized SecretKeyDefinition", () => { + const definition = SecretKeyDefinition.array(GENERATOR_DISK, "key", classifier, options); + + expect(definition).toBeInstanceOf(SecretKeyDefinition); + expect(definition.stateDefinition).toBe(GENERATOR_DISK); + expect(definition.key).toBe("key"); + expect(definition.classifier).toBe(classifier); + }); + + describe("deconstruct", () => { + it("over a 0-length array returns an empty array", () => { + const definition = SecretKeyDefinition.array(GENERATOR_DISK, "key", classifier, options); + const value: { foo: boolean }[] = []; + + const result = definition.deconstruct(value); + + expect(result).toStrictEqual([]); + }); + + it("over a 1-length array returns a pair of indices and values", () => { + const definition = SecretKeyDefinition.array(GENERATOR_DISK, "key", classifier, options); + const value = [{ foo: true }]; + + const result = definition.deconstruct(value); + + expect(result).toStrictEqual([[0, value[0]]]); + }); + + it("over an n-length array returns n pairs of indices and values", () => { + const definition = SecretKeyDefinition.array(GENERATOR_DISK, "key", classifier, options); + const value = [{ foo: true }, { foo: false }]; + + const result = definition.deconstruct(value); + + expect(result).toStrictEqual([ + [0, value[0]], + [1, value[1]], + ]); + }); + }); + + describe("deconstruct", () => { + it("over a 0-length array of entries returns an empty array", () => { + const definition = SecretKeyDefinition.array(GENERATOR_DISK, "key", classifier, options); + + const result = definition.reconstruct([]); + + expect(result).toStrictEqual([]); + }); + + it("over a 1-length array of entries returns a 1-length array", () => { + const definition = SecretKeyDefinition.array(GENERATOR_DISK, "key", classifier, options); + const value = [{ foo: true }]; + + const result = definition.reconstruct([[0, value[0]]]); + + expect(result).toStrictEqual(value); + }); + + it("over an n-length array of entries returns an n-length array", () => { + const definition = SecretKeyDefinition.array(GENERATOR_DISK, "key", classifier, options); + const value = [{ foo: true }, { foo: false }]; + + const result = definition.reconstruct([ + [0, value[0]], + [1, value[1]], + ]); + + expect(result).toStrictEqual(value); + }); + }); + }); + + describe("record", () => { + it("returns an initialized SecretKeyDefinition", () => { + const definition = SecretKeyDefinition.record(GENERATOR_DISK, "key", classifier, options); + + expect(definition).toBeInstanceOf(SecretKeyDefinition); + expect(definition.stateDefinition).toBe(GENERATOR_DISK); + expect(definition.key).toBe("key"); + expect(definition.classifier).toBe(classifier); + }); + + describe("deconstruct", () => { + it("over a 0-key record returns an empty array", () => { + const definition = SecretKeyDefinition.record(GENERATOR_DISK, "key", classifier, options); + const value: Record = {}; + + const result = definition.deconstruct(value); + + expect(result).toStrictEqual([]); + }); + + it("over a 1-key record returns a pair of indices and values", () => { + const definition = SecretKeyDefinition.record(GENERATOR_DISK, "key", classifier, options); + const value = { foo: { foo: true } }; + + const result = definition.deconstruct(value); + + expect(result).toStrictEqual([["foo", value["foo"]]]); + }); + + it("over an n-key record returns n pairs of indices and values", () => { + const definition = SecretKeyDefinition.record(GENERATOR_DISK, "key", classifier, options); + const value = { foo: { foo: true }, bar: { foo: false } }; + + const result = definition.deconstruct(value); + + expect(result).toStrictEqual([ + ["foo", value["foo"]], + ["bar", value["bar"]], + ]); + }); + }); + + describe("deconstruct", () => { + it("over a 0-key record of entries returns an empty array", () => { + const definition = SecretKeyDefinition.record(GENERATOR_DISK, "key", classifier, options); + + const result = definition.reconstruct([]); + + expect(result).toStrictEqual({}); + }); + + it("over a 1-key record of entries returns a 1-length record", () => { + const definition = SecretKeyDefinition.record(GENERATOR_DISK, "key", classifier, options); + const value = { foo: { foo: true } }; + + const result = definition.reconstruct([["foo", value["foo"]]]); + + expect(result).toStrictEqual(value); + }); + + it("over an n-key record of entries returns an n-length record", () => { + const definition = SecretKeyDefinition.record(GENERATOR_DISK, "key", classifier, options); + const value = { foo: { foo: true }, bar: { foo: false } }; + + const result = definition.reconstruct([ + ["foo", value["foo"]], + ["bar", value["bar"]], + ]); + + expect(result).toStrictEqual(value); + }); + }); + }); +}); diff --git a/libs/common/src/tools/generator/state/secret-key-definition.ts b/libs/common/src/tools/generator/state/secret-key-definition.ts new file mode 100644 index 00000000000..eb139efbe7a --- /dev/null +++ b/libs/common/src/tools/generator/state/secret-key-definition.ts @@ -0,0 +1,92 @@ +import { KeyDefinitionOptions } from "../../../platform/state"; +// eslint-disable-next-line -- `StateDefinition` used as an argument +import { StateDefinition } from "../../../platform/state/state-definition"; +import { SecretClassifier } from "./secret-classifier"; + +/** Encryption and storage settings for data stored by a `SecretState`. + */ +export class SecretKeyDefinition { + private constructor( + readonly stateDefinition: StateDefinition, + readonly key: string, + readonly classifier: SecretClassifier, + readonly options: KeyDefinitionOptions, + // type erasure is necessary here because typescript doesn't support + // higher kinded types that generalize over collections. The invariants + // needed to make this typesafe are maintained by the static factories. + readonly deconstruct: (value: any) => [Id, any][], + readonly reconstruct: ([inners, ids]: (readonly [Id, any])[]) => Outer, + ) {} + + /** + * Define a secret state for a single value + * @param stateDefinition The domain of the secret's durable state. + * @param key Domain key that identifies the stored value. This key must not be reused + * in any capacity. + * @param classifier Partitions the value into encrypted, discarded, and public data. + * @param options Configures the operation of the secret state. + */ + static value( + stateDefinition: StateDefinition, + key: string, + classifier: SecretClassifier, + options: KeyDefinitionOptions, + ) { + return new SecretKeyDefinition( + stateDefinition, + key, + classifier, + options, + (value) => [[null, value]], + ([[, inner]]) => inner, + ); + } + + /** + * Define a secret state for an array of values. Each item is encrypted separately. + * @param stateDefinition The domain of the secret's durable state. + * @param key Domain key that identifies the stored items. This key must not be reused + * in any capacity. + * @param classifier Partitions each item into encrypted, discarded, and public data. + * @param options Configures the operation of the secret state. + */ + static array( + stateDefinition: StateDefinition, + key: string, + classifier: SecretClassifier, + options: KeyDefinitionOptions, + ) { + return new SecretKeyDefinition( + stateDefinition, + key, + classifier, + options, + (value) => value.map((v: any, id: number) => [id, v]), + (values) => values.map(([, v]) => v), + ); + } + + /** + * Define a secret state for a record. Each property is encrypted separately. + * @param stateDefinition The domain of the secret's durable state. + * @param key Domain key that identifies the stored properties. This key must not be reused + * in any capacity. + * @param classifier Partitions each property into encrypted, discarded, and public data. + * @param options Configures the operation of the secret state. + */ + static record( + stateDefinition: StateDefinition, + key: string, + classifier: SecretClassifier, + options: KeyDefinitionOptions, + ) { + return new SecretKeyDefinition, Id, Item, Disclosed, Secret>( + stateDefinition, + key, + classifier, + options, + (value) => Object.entries(value) as [Id, Item][], + (values) => Object.fromEntries(values) as Record, + ); + } +} diff --git a/libs/common/src/tools/generator/state/secret-state.spec.ts b/libs/common/src/tools/generator/state/secret-state.spec.ts index d4804fdb9b8..364116fed3b 100644 --- a/libs/common/src/tools/generator/state/secret-state.spec.ts +++ b/libs/common/src/tools/generator/state/secret-state.spec.ts @@ -9,15 +9,18 @@ import { awaitAsync, } from "../../../../spec"; import { EncString } from "../../../platform/models/domain/enc-string"; -import { KeyDefinition, GENERATOR_DISK } from "../../../platform/state"; +import { GENERATOR_DISK } from "../../../platform/state"; import { UserId } from "../../../types/guid"; +import { SecretClassifier } from "./secret-classifier"; +import { SecretKeyDefinition } from "./secret-key-definition"; import { SecretState } from "./secret-state"; import { UserEncryptor } from "./user-encryptor.abstraction"; type FooBar = { foo: boolean; bar: boolean; date?: Date }; -const FOOBAR_KEY = new KeyDefinition(GENERATOR_DISK, "fooBar", { - deserializer: (fb) => { +const classifier = SecretClassifier.allSecret(); +const options: any = { + deserializer: (fb: FooBar) => { const result: FooBar = { foo: fb.foo, bar: fb.bar }; if (fb.date) { @@ -26,23 +29,27 @@ const FOOBAR_KEY = new KeyDefinition(GENERATOR_DISK, "fooBar", { return result; }, -}); +}; +const FOOBAR_VALUE = SecretKeyDefinition.value(GENERATOR_DISK, "fooBar", classifier, options); +const FOOBAR_ARRAY = SecretKeyDefinition.array(GENERATOR_DISK, "fooBar", classifier, options); +const FOOBAR_RECORD = SecretKeyDefinition.record(GENERATOR_DISK, "fooBar", classifier, options); + const SomeUser = "some user" as UserId; -function mockEncryptor(fooBar: FooBar[] = []): UserEncryptor> { +function mockEncryptor(fooBar: FooBar[] = []): UserEncryptor { // stores "encrypted values" so that they can be "decrypted" later // while allowing the operations to be interleaved. const encrypted = new Map>( fooBar.map((fb) => [toKey(fb).encryptedString, toValue(fb)] as const), ); - const result = mock>>({ + const result = mock>({ encrypt(value: FooBar, user: UserId) { const encString = toKey(value); encrypted.set(encString.encryptedString, toValue(value)); - return Promise.resolve({ secret: encString, disclosed: {} }); + return Promise.resolve(encString); }, - decrypt(secret: EncString, disclosed: Record, userId: UserId) { + decrypt(secret: EncString, userId: UserId) { const decString = encrypted.get(toValue(secret.encryptedString)); return Promise.resolve(decString); }, @@ -59,9 +66,9 @@ function mockEncryptor(fooBar: FooBar[] = []): UserEncryptor>; + return result as unknown as UserEncryptor; } async function fakeStateProvider() { @@ -76,7 +83,7 @@ describe("UserEncryptor", () => { const provider = await fakeStateProvider(); const encryptor = mockEncryptor(); - const result = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor); + const result = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor); expect(result).toBeInstanceOf(SecretState); }); @@ -87,7 +94,7 @@ describe("UserEncryptor", () => { const provider = await fakeStateProvider(); const encryptor = mockEncryptor(); - const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor); + const state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor); expect(state.userId).toEqual(SomeUser); }); @@ -95,7 +102,7 @@ describe("UserEncryptor", () => { it("state$ gets a set value", async () => { const provider = await fakeStateProvider(); const encryptor = mockEncryptor(); - const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor); + const state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor); const value = { foo: true, bar: false }; await state.update(() => value); @@ -105,10 +112,55 @@ describe("UserEncryptor", () => { expect(result).toEqual(value); }); + it("round-trips json-serializable values", async () => { + const provider = await fakeStateProvider(); + const encryptor = mockEncryptor(); + const state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor); + const value = { foo: true, bar: true, date: new Date(1) }; + + await state.update(() => value); + await awaitAsync(); + const result = await firstValueFrom(state.state$); + + expect(result).toEqual(value); + }); + + it("state$ gets a set array", async () => { + const provider = await fakeStateProvider(); + const encryptor = mockEncryptor(); + const state = SecretState.from(SomeUser, FOOBAR_ARRAY, provider, encryptor); + const array = [ + { foo: true, bar: false, date: new Date(1) }, + { foo: false, bar: true }, + ]; + + await state.update(() => array); + await awaitAsync(); + const result = await firstValueFrom(state.state$); + + expect(result).toStrictEqual(array); + }); + + it("state$ gets a set record", async () => { + const provider = await fakeStateProvider(); + const encryptor = mockEncryptor(); + const state = SecretState.from(SomeUser, FOOBAR_RECORD, provider, encryptor); + const record = { + baz: { foo: true, bar: false, date: new Date(1) }, + biz: { foo: false, bar: true }, + }; + + await state.update(() => record); + await awaitAsync(); + const result = await firstValueFrom(state.state$); + + expect(result).toStrictEqual(record); + }); + it("combinedState$ gets a set value with the userId", async () => { const provider = await fakeStateProvider(); const encryptor = mockEncryptor(); - const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor); + const state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor); const value = { foo: true, bar: false }; await state.update(() => value); @@ -119,23 +171,10 @@ describe("UserEncryptor", () => { expect(userId).toEqual(SomeUser); }); - it("round-trips json-serializable values", async () => { - const provider = await fakeStateProvider(); - const encryptor = mockEncryptor(); - const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor); - const value = { foo: true, bar: true, date: new Date(1) }; - - await state.update(() => value); - await awaitAsync(); - const result = await firstValueFrom(state.state$); - - expect(result).toEqual(value); - }); - it("gets the last set value", async () => { const provider = await fakeStateProvider(); const encryptor = mockEncryptor(); - const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor); + const state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor); const initialValue = { foo: true, bar: false }; const replacementValue = { foo: false, bar: false }; @@ -150,7 +189,7 @@ describe("UserEncryptor", () => { it("interprets shouldUpdate option", async () => { const provider = await fakeStateProvider(); const encryptor = mockEncryptor(); - const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor); + const state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor); const initialValue = { foo: true, bar: false }; const replacementValue = { foo: false, bar: false }; @@ -164,7 +203,7 @@ describe("UserEncryptor", () => { it("sets the state to `null` when `update` returns `null`", async () => { const provider = await fakeStateProvider(); const encryptor = mockEncryptor(); - const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor); + const state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor); const value = { foo: true, bar: false }; await state.update(() => value); @@ -178,7 +217,7 @@ describe("UserEncryptor", () => { it("sets the state to `null` when `update` returns `undefined`", async () => { const provider = await fakeStateProvider(); const encryptor = mockEncryptor(); - const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor); + const state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor); const value = { foo: true, bar: false }; await state.update(() => value); @@ -192,7 +231,7 @@ describe("UserEncryptor", () => { it("sends rxjs observables into the shouldUpdate method", async () => { const provider = await fakeStateProvider(); const encryptor = mockEncryptor(); - const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor); + const state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor); const combinedWith$ = from([1]); let combinedShouldUpdate = 0; @@ -210,7 +249,7 @@ describe("UserEncryptor", () => { it("sends rxjs observables into the update method", async () => { const provider = await fakeStateProvider(); const encryptor = mockEncryptor(); - const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor); + const state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor); const combinedWith$ = from([1]); let combinedUpdate = 0; diff --git a/libs/common/src/tools/generator/state/secret-state.ts b/libs/common/src/tools/generator/state/secret-state.ts index 62855c3280b..a879b9f7889 100644 --- a/libs/common/src/tools/generator/state/secret-state.ts +++ b/libs/common/src/tools/generator/state/secret-state.ts @@ -13,21 +13,27 @@ import { } from "../../../platform/state"; import { UserId } from "../../../types/guid"; +import { SecretKeyDefinition } from "./secret-key-definition"; import { UserEncryptor } from "./user-encryptor.abstraction"; /** Describes the structure of data stored by the SecretState's * encrypted state. Notably, this interface ensures that `Disclosed` - * round trips through JSON serialization. + * round trips through JSON serialization. It also preserves the + * Id. + * @remarks Tuple representation chosen because it matches + * `Object.entries` format. */ -type ClassifiedFormat = { +type ClassifiedFormat = { + /** Identifies records. `null` when storing a `value` */ + readonly id: Id | null; /** Serialized {@link EncString} of the secret state's * secret-level classified data. */ - secret: string; + readonly secret: string; /** serialized representation of the secret state's * disclosed-level classified data. */ - disclosed: Jsonify; + readonly disclosed: Jsonify; }; /** Stores account-specific secrets protected by a UserKeyEncryptor. @@ -38,15 +44,16 @@ type ClassifiedFormat = { * * DO NOT USE THIS for synchronized data. */ -export class SecretState - implements SingleUserState<Plaintext> +export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret> + implements SingleUserState<Outer> { // The constructor is private to avoid creating a circular dependency when // wiring the derived and secret states together. private constructor( - private readonly encryptor: UserEncryptor<Plaintext, Disclosed>, - private readonly encrypted: SingleUserState<ClassifiedFormat<Disclosed>>, - private readonly plaintext: DerivedState<Plaintext>, + private readonly key: SecretKeyDefinition<Outer, Id, Plaintext, Disclosed, Secret>, + private readonly encryptor: UserEncryptor<Secret>, + private readonly encrypted: SingleUserState<ClassifiedFormat<Id, Disclosed>[]>, + private readonly plaintext: DerivedState<Outer>, ) { this.state$ = plaintext.state$; this.combinedState$ = plaintext.state$.pipe(map((state) => [this.encrypted.userId, state])); @@ -61,10 +68,10 @@ export class SecretState<Plaintext extends object, Disclosed> * updates after the secret has been recorded to state storage. * @returns `undefined` when the account is locked. */ - readonly state$: Observable<Plaintext>; + readonly state$: Observable<Outer>; /** {@link SingleUserState.combinedState$} */ - readonly combinedState$: Observable<CombinedState<Plaintext>>; + readonly combinedState$: Observable<CombinedState<Outer>>; /** Creates a secret state bound to an account encryptor. The account must be unlocked * when this method is called. @@ -78,24 +85,28 @@ export class SecretState<Plaintext extends object, Disclosed> * encrypted, and stored in a `secret` property. Disclosed-classification data is stored * in a `disclosed` property. Omitted-classification data is not stored. */ - static from<TFrom extends object, Disclosed>( + static from<Outer, Id, TFrom extends object, Disclosed, Secret>( userId: UserId, - key: KeyDefinition<TFrom>, + key: SecretKeyDefinition<Outer, Id, TFrom, Disclosed, Secret>, provider: StateProvider, - encryptor: UserEncryptor<TFrom, Disclosed>, + encryptor: UserEncryptor<Secret>, ) { // construct encrypted backing store while avoiding collisions between the derived key and the // backing storage key. - const secretKey = new KeyDefinition<ClassifiedFormat<Disclosed>>(key.stateDefinition, key.key, { - cleanupDelayMs: key.cleanupDelayMs, - // FIXME: When the fakes run deserializers and serialization can be guaranteed through - // state providers, decode `jsonValue.secret` instead of it running in `derive`. - deserializer: (jsonValue) => jsonValue as ClassifiedFormat<Disclosed>, - }); + const secretKey = new KeyDefinition<ClassifiedFormat<Id, Disclosed>[]>( + key.stateDefinition, + key.key, + { + cleanupDelayMs: key.options.cleanupDelayMs, + // FIXME: When the fakes run deserializers and serialization can be guaranteed through + // state providers, decode `jsonValue.secret` instead of it running in `derive`. + deserializer: (jsonValue) => jsonValue as ClassifiedFormat<Id, Disclosed>[], + }, + ); const encryptedState = provider.getUser(userId, secretKey); // construct plaintext store - const plaintextDefinition = DeriveDefinition.from<ClassifiedFormat<Disclosed>, TFrom>( + const plaintextDefinition = DeriveDefinition.from<ClassifiedFormat<Id, Disclosed>[], Outer>( secretKey, { derive: async (from) => { @@ -104,23 +115,38 @@ export class SecretState<Plaintext extends object, Disclosed> return null; } - // otherwise forward the decrypted data to the caller's derive implementation - const secret = EncString.fromJSON(from.secret); - const decrypted = await encryptor.decrypt(secret, from.disclosed, encryptedState.userId); - const result = key.deserializer(decrypted) as TFrom; + // decrypt each item + const decryptTasks = from.map(async ({ id, secret, disclosed }) => { + const encrypted = EncString.fromJSON(secret); + const decrypted = await encryptor.decrypt(encrypted, encryptedState.userId); + + const declassified = key.classifier.declassify(disclosed, decrypted); + const result = key.options.deserializer(declassified); + + return [id, result] as const; + }); + + // reconstruct expected type + const results = await Promise.all(decryptTasks); + const result = key.reconstruct(results); return result; }, // wire in the caller's deserializer for memory serialization - deserializer: key.deserializer, + deserializer: (d) => { + const items = key.deconstruct(d); + const results = items.map(([k, v]) => [k, key.options.deserializer(v)] as const); + const result = key.reconstruct(results); + return result; + }, // cache the decrypted data in memory - cleanupDelayMs: key.cleanupDelayMs, + cleanupDelayMs: key.options.cleanupDelayMs, }, ); const plaintextState = provider.getDerived(encryptedState.state$, plaintextDefinition, null); // wrap the encrypted and plaintext states in a `SecretState` facade - const secretState = new SecretState(encryptor, encryptedState, plaintextState); + const secretState = new SecretState(key, encryptor, encryptedState, plaintextState); return secretState; } @@ -138,9 +164,9 @@ export class SecretState<Plaintext extends object, Disclosed> * they can be lost when the secret state updates its backing store. */ async update<TCombine>( - configureState: (state: Plaintext, dependencies: TCombine) => Plaintext, - options: StateUpdateOptions<Plaintext, TCombine> = null, - ): Promise<Plaintext> { + configureState: (state: Outer, dependencies: TCombine) => Outer, + options: StateUpdateOptions<Outer, TCombine> = null, + ): Promise<Outer> { // reactively grab the latest state from the caller. `zip` requires each // observable has a value, so `combined$` provides a default if necessary. const combined$ = options?.combineLatestWith ?? of(undefined); @@ -155,7 +181,7 @@ export class SecretState<Plaintext extends object, Disclosed> ); // update the backing store - let latestValue: Plaintext = null; + let latestValue: Outer = null; await this.encrypted.update((_, [, newStoredState]) => newStoredState, { combineLatestWith: newState$, shouldUpdate: (_, [shouldUpdate, , newState]) => { @@ -171,10 +197,10 @@ export class SecretState<Plaintext extends object, Disclosed> } private async prepareCryptoState( - currentState: Plaintext, + currentState: Outer, shouldUpdate: () => boolean, - configureState: () => Plaintext, - ): Promise<[boolean, ClassifiedFormat<Disclosed>, Plaintext]> { + configureState: () => Outer, + ): Promise<[boolean, ClassifiedFormat<Id, Disclosed>[], Outer]> { // determine whether an update is necessary if (!shouldUpdate()) { return [false, undefined, currentState]; @@ -186,18 +212,25 @@ export class SecretState<Plaintext extends object, Disclosed> return [true, newState as any, newState]; } - // the encrypt format *is* the storage format, so if the shape of that data changes, - // this needs to map it explicitly for compatibility purposes. - const newStoredState = await this.encryptor.encrypt(newState, this.encrypted.userId); + // convert the object to a list format so that all encrypt and decrypt + // operations are self-similar + const desconstructed = this.key.deconstruct(newState); - // the deserializer in the plaintextState's `derive` configuration always runs, but - // `encryptedState` is not guaranteed to serialize the data, so it's necessary to - // round-trip it proactively. This will cause some duplicate work in those situations - // where the backing store does deserialize the data. - // - // FIXME: Once there's a backing store configuration setting guaranteeing serialization, - // remove this code and configure the backing store as appropriate. - const serializedState = JSON.parse(JSON.stringify(newStoredState)); + // encrypt each value individually + const encryptTasks = desconstructed.map(async ([id, state]) => { + const classified = this.key.classifier.classify(state); + const encrypted = await this.encryptor.encrypt(classified.secret, this.encrypted.userId); + + // the deserializer in the plaintextState's `derive` configuration always runs, but + // `encryptedState` is not guaranteed to serialize the data, so it's necessary to + // round-trip it proactively. This will cause some duplicate work in those situations + // where the backing store does deserialize the data. + const serialized = JSON.parse( + JSON.stringify({ id, secret: encrypted, disclosed: classified.disclosed }), + ); + return serialized as ClassifiedFormat<Id, Disclosed>; + }); + const serializedState = await Promise.all(encryptTasks); return [true, serializedState, newState]; } diff --git a/libs/common/src/tools/generator/state/user-encryptor.abstraction.ts b/libs/common/src/tools/generator/state/user-encryptor.abstraction.ts index 88a8bbe589c..2009c6f255f 100644 --- a/libs/common/src/tools/generator/state/user-encryptor.abstraction.ts +++ b/libs/common/src/tools/generator/state/user-encryptor.abstraction.ts @@ -7,9 +7,9 @@ import { UserId } from "../../../types/guid"; * user-specific information. The specific kind of information is * determined by the classification strategy. */ -export abstract class UserEncryptor<State extends object, Disclosed> { +export abstract class UserEncryptor<Secret> { /** Protects secrets in `value` with a user-specific key. - * @param value the object to protect. This object is mutated during encryption. + * @param secret the object to protect. This object is mutated during encryption. * @param userId identifies the user-specific information used to protect * the secret. * @returns a promise that resolves to a tuple. The tuple's first property contains @@ -17,15 +17,11 @@ export abstract class UserEncryptor<State extends object, Disclosed> { * properties. * @throws If `value` is `null` or `undefined`, the promise rejects with an error. */ - abstract encrypt( - value: State, - userId: UserId, - ): Promise<{ secret: EncString; disclosed: Disclosed }>; + abstract encrypt(secret: Secret, userId: UserId): Promise<EncString>; /** Combines protected secrets and disclosed data into a type that can be * rehydrated into a domain object. - * @param secret an encrypted JSON payload containing State's secrets. - * @param disclosed a data object containing State's disclosed properties. + * @param secret an encrypted JSON payload containing encrypted secrets. * @param userId identifies the user-specific information used to protect * the secret. * @returns a promise that resolves to the raw state. This state *is not* a @@ -34,9 +30,5 @@ export abstract class UserEncryptor<State extends object, Disclosed> { * @throws If `secret` or `disclosed` is `null` or `undefined`, the promise * rejects with an error. */ - abstract decrypt( - secret: EncString, - disclosed: Jsonify<Disclosed>, - userId: UserId, - ): Promise<Jsonify<State>>; + abstract decrypt(secret: EncString, userId: UserId): Promise<Jsonify<Secret>>; } diff --git a/libs/common/src/tools/generator/state/user-key-encryptor.spec.ts b/libs/common/src/tools/generator/state/user-key-encryptor.spec.ts index e91cbe6b6b8..9289086986b 100644 --- a/libs/common/src/tools/generator/state/user-key-encryptor.spec.ts +++ b/libs/common/src/tools/generator/state/user-key-encryptor.spec.ts @@ -9,7 +9,6 @@ import { UserId } from "../../../types/guid"; import { UserKey } from "../../../types/key"; import { DataPacker } from "./data-packer.abstraction"; -import { SecretClassifier } from "./secret-classifier"; import { UserKeyEncryptor } from "./user-key-encryptor"; describe("UserKeyEncryptor", () => { @@ -38,20 +37,18 @@ describe("UserKeyEncryptor", () => { describe("encrypt", () => { it("should throw if value was not supplied", async () => { - const classifier = SecretClassifier.allSecret<object>(); - const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker); + const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker); await expect(encryptor.encrypt(null, anyUserId)).rejects.toThrow( - "value cannot be null or undefined", + "secret cannot be null or undefined", ); await expect(encryptor.encrypt(undefined, anyUserId)).rejects.toThrow( - "value cannot be null or undefined", + "secret cannot be null or undefined", ); }); it("should throw if userId was not supplied", async () => { - const classifier = SecretClassifier.allSecret<object>(); - const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker); + const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker); await expect(encryptor.encrypt({} as any, null)).rejects.toThrow( "userId cannot be null or undefined", @@ -61,80 +58,54 @@ describe("UserKeyEncryptor", () => { ); }); - it("should classify data into a disclosed value and an encrypted packed value using the user's key", async () => { - const classifier = SecretClassifier.allSecret<object>(); - const classifierClassify = jest.spyOn(classifier, "classify"); - const disclosed = {} as any; - const secret = {} as any; - classifierClassify.mockReturnValue({ disclosed, secret }); - - const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker); + it("should encrypt a packed value using the user's key", async () => { + const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker); const value = { foo: true }; const result = await encryptor.encrypt(value, anyUserId); - expect(classifierClassify).toHaveBeenCalledWith(value); + // these are data flow expectations; the operations all all pass-through mocks expect(keyService.getUserKey).toHaveBeenCalledWith(anyUserId); - expect(dataPacker.pack).toHaveBeenCalledWith(secret); - expect(encryptService.encrypt).toHaveBeenCalledWith(secret, userKey); - expect(result.secret).toBe(secret); - expect(result.disclosed).toBe(disclosed); + expect(dataPacker.pack).toHaveBeenCalledWith(value); + expect(encryptService.encrypt).toHaveBeenCalledWith(value, userKey); + expect(result).toBe(value); }); }); describe("decrypt", () => { it("should throw if secret was not supplied", async () => { - const classifier = SecretClassifier.allSecret<object>(); - const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker); + const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker); - await expect(encryptor.decrypt(null, {} as any, anyUserId)).rejects.toThrow( + await expect(encryptor.decrypt(null, anyUserId)).rejects.toThrow( "secret cannot be null or undefined", ); - await expect(encryptor.decrypt(undefined, {} as any, anyUserId)).rejects.toThrow( + await expect(encryptor.decrypt(undefined, anyUserId)).rejects.toThrow( "secret cannot be null or undefined", ); }); - it("should throw if disclosed was not supplied", async () => { - const classifier = SecretClassifier.allSecret<object>(); - const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker); - - await expect(encryptor.decrypt({} as any, null, anyUserId)).rejects.toThrow( - "disclosed cannot be null or undefined", - ); - await expect(encryptor.decrypt({} as any, undefined, anyUserId)).rejects.toThrow( - "disclosed cannot be null or undefined", - ); - }); - it("should throw if userId was not supplied", async () => { - const classifier = SecretClassifier.allSecret<object>(); - const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker); + const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker); - await expect(encryptor.decrypt({} as any, {} as any, null)).rejects.toThrow( + await expect(encryptor.decrypt({} as any, null)).rejects.toThrow( "userId cannot be null or undefined", ); - await expect(encryptor.decrypt({} as any, {} as any, undefined)).rejects.toThrow( + await expect(encryptor.decrypt({} as any, undefined)).rejects.toThrow( "userId cannot be null or undefined", ); }); it("should declassify a decrypted packed value using the user's key", async () => { - const classifier = SecretClassifier.allSecret<object>(); - const classifierDeclassify = jest.spyOn(classifier, "declassify"); - const declassified = {} as any; - classifierDeclassify.mockReturnValue(declassified); - const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker); + const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker); const secret = "encrypted" as any; - const disclosed = {} as any; - const result = await encryptor.decrypt(secret, disclosed, anyUserId); + const result = await encryptor.decrypt(secret, anyUserId); + // these are data flow expectations; the operations all all pass-through mocks expect(keyService.getUserKey).toHaveBeenCalledWith(anyUserId); expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(secret, userKey); expect(dataPacker.unpack).toHaveBeenCalledWith(secret); - expect(classifierDeclassify).toHaveBeenCalledWith(disclosed, secret); - expect(result).toBe(declassified); + expect(result).toBe(secret); }); }); }); diff --git a/libs/common/src/tools/generator/state/user-key-encryptor.ts b/libs/common/src/tools/generator/state/user-key-encryptor.ts index 8b78f5e229b..22dbd41140b 100644 --- a/libs/common/src/tools/generator/state/user-key-encryptor.ts +++ b/libs/common/src/tools/generator/state/user-key-encryptor.ts @@ -6,59 +6,44 @@ import { EncString } from "../../../platform/models/domain/enc-string"; import { UserId } from "../../../types/guid"; import { DataPacker } from "./data-packer.abstraction"; -import { SecretClassifier } from "./secret-classifier"; import { UserEncryptor } from "./user-encryptor.abstraction"; /** A classification strategy that protects a type's secrets by encrypting them * with a `UserKey` */ -export class UserKeyEncryptor<State extends object, Disclosed, Secret> extends UserEncryptor< - State, - Disclosed -> { +export class UserKeyEncryptor<Secret> extends UserEncryptor<Secret> { /** Instantiates the encryptor * @param encryptService protects properties of `Secret`. * @param keyService looks up the user key when protecting data. - * @param classifier partitions secrets and disclosed information. * @param dataPacker packs and unpacks data classified as secrets. */ constructor( private readonly encryptService: EncryptService, private readonly keyService: CryptoService, - private readonly classifier: SecretClassifier<State, Disclosed, Secret>, private readonly dataPacker: DataPacker, ) { super(); } /** {@link UserEncryptor.encrypt} */ - async encrypt( - value: State, - userId: UserId, - ): Promise<{ secret: EncString; disclosed: Disclosed }> { - this.assertHasValue("value", value); + async encrypt(secret: Secret, userId: UserId): Promise<EncString> { + this.assertHasValue("secret", secret); this.assertHasValue("userId", userId); - const classified = this.classifier.classify(value); - let packed = this.dataPacker.pack(classified.secret); + let packed = this.dataPacker.pack(secret); // encrypt the data and drop the key let key = await this.keyService.getUserKey(userId); - const secret = await this.encryptService.encrypt(packed, key); + const encrypted = await this.encryptService.encrypt(packed, key); packed = null; key = null; - return { ...classified, secret }; + return encrypted; } /** {@link UserEncryptor.decrypt} */ - async decrypt( - secret: EncString, - disclosed: Jsonify<Disclosed>, - userId: UserId, - ): Promise<Jsonify<State>> { + async decrypt(secret: EncString, userId: UserId): Promise<Jsonify<Secret>> { this.assertHasValue("secret", secret); - this.assertHasValue("disclosed", disclosed); this.assertHasValue("userId", userId); // decrypt the data and drop the key @@ -70,9 +55,7 @@ export class UserKeyEncryptor<State extends object, Disclosed, Secret> extends U const unpacked = this.dataPacker.unpack<Secret>(decrypted); decrypted = null; - const jsonValue = this.classifier.declassify(disclosed, unpacked); - - return jsonValue; + return unpacked; } private assertHasValue(name: string, value: any) { diff --git a/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts b/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts index 554bbfca62a..b0717695e05 100644 --- a/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts +++ b/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts @@ -2,13 +2,14 @@ import { PolicyType } from "../../../admin-console/enums"; import { Policy } from "../../../admin-console/models/domain/policy"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { EncryptService } from "../../../platform/abstractions/encrypt.service"; -import { KeyDefinition, StateProvider } from "../../../platform/state"; +import { KeyDefinition, SingleUserState, StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { GeneratorStrategy } from "../abstractions"; import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; import { NoPolicy } from "../no-policy"; import { PaddedDataPacker } from "../state/padded-data-packer"; import { SecretClassifier } from "../state/secret-classifier"; +import { SecretKeyDefinition } from "../state/secret-key-definition"; import { SecretState } from "../state/secret-state"; import { UserKeyEncryptor } from "../state/user-key-encryptor"; @@ -39,7 +40,7 @@ export abstract class ForwarderGeneratorStrategy< this.cache_ms = ONE_MINUTE; } - private durableStates = new Map<UserId, SecretState<Options, Record<string, never>>>(); + private durableStates = new Map<UserId, SingleUserState<Options>>(); /** {@link GeneratorStrategy.durableState} */ durableState = (userId: UserId) => { @@ -47,7 +48,24 @@ export abstract class ForwarderGeneratorStrategy< if (!state) { const encryptor = this.createEncryptor(); - state = SecretState.from(userId, this.key, this.stateProvider, encryptor); + // always exclude request properties + const classifier = SecretClassifier.allSecret<Options>().exclude("website"); + + // Derive the secret key definition + const key = SecretKeyDefinition.value(this.key.stateDefinition, this.key.key, classifier, { + deserializer: (d) => this.key.deserializer(d), + cleanupDelayMs: this.key.cleanupDelayMs, + }); + + // the type parameter is explicit because type inference fails for `Omit<Options, "website">` + state = SecretState.from< + Options, + void, + Options, + Record<keyof Options, never>, + Omit<Options, "website"> + >(userId, key, this.stateProvider, encryptor); + this.durableStates.set(userId, state); } @@ -55,12 +73,9 @@ export abstract class ForwarderGeneratorStrategy< }; private createEncryptor() { - // always exclude request properties - const classifier = SecretClassifier.allSecret<Options>().exclude("website"); - // construct the encryptor const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE); - return new UserKeyEncryptor(this.encryptService, this.keyService, classifier, packer); + return new UserKeyEncryptor(this.encryptService, this.keyService, packer); } /** Determine where forwarder configuration is stored */ From 600cc080b82519f71875b7b58d06543f7edc89f1 Mon Sep 17 00:00:00 2001 From: Matt Gibson <mgibson@bitwarden.com> Date: Thu, 21 Mar 2024 12:02:04 -0500 Subject: [PATCH 3/7] Ps/fix biometric prompt error on close (#8353) * Fix error on close due to context differences in background Desktop background does not have active user information. Also, we want to delete _all_ prompt cancelled data, not just that for the active user. Storing this on global and manipulating observables to active achieves this without needing any user information in the background. * Remove potentially orphaned data * Throw nice error if prompt cancelled used without active user * Register migration * split prompt cancelled reset to user-specific and global --- apps/desktop/src/main/window.main.ts | 2 +- .../src/auth/components/lock.component.ts | 4 +- libs/common/spec/fake-account-service.ts | 3 + .../biometric-state.service.spec.ts | 87 +++++++++++++++++-- .../biometrics/biometric-state.service.ts | 66 +++++++++++--- .../biometrics/biometric.state.spec.ts | 2 +- .../platform/biometrics/biometric.state.ts | 3 +- libs/common/src/state-migrations/migrate.ts | 7 +- ...ete-orphaned-biometric-prompt-data.spec.ts | 28 ++++++ ...6-delete-orphaned-biometric-prompt-data.ts | 23 +++++ 10 files changed, 197 insertions(+), 28 deletions(-) create mode 100644 libs/common/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.ts diff --git a/apps/desktop/src/main/window.main.ts b/apps/desktop/src/main/window.main.ts index cdce2a692c2..9771006c8ad 100644 --- a/apps/desktop/src/main/window.main.ts +++ b/apps/desktop/src/main/window.main.ts @@ -94,7 +94,7 @@ export class WindowMain { // down the application. app.on("before-quit", async () => { // Allow biometric to auto-prompt on reload - await this.biometricStateService.resetPromptCancelled(); + await this.biometricStateService.resetAllPromptCancelled(); this.isQuitting = true; }); diff --git a/libs/angular/src/auth/components/lock.component.ts b/libs/angular/src/auth/components/lock.component.ts index 7c5d3aace5d..c21ba1a75a1 100644 --- a/libs/angular/src/auth/components/lock.component.ts +++ b/libs/angular/src/auth/components/lock.component.ts @@ -119,7 +119,7 @@ export class LockComponent implements OnInit, OnDestroy { return; } - await this.biometricStateService.setPromptCancelled(); + await this.biometricStateService.setUserPromptCancelled(); const userKey = await this.cryptoService.getUserKeyFromStorage(KeySuffixOptions.Biometric); if (userKey) { @@ -276,7 +276,7 @@ export class LockComponent implements OnInit, OnDestroy { private async doContinue(evaluatePasswordAfterUnlock: boolean) { await this.stateService.setEverBeenUnlocked(true); - await this.biometricStateService.resetPromptCancelled(); + await this.biometricStateService.resetUserPromptCancelled(); this.messagingService.send("unlocked"); if (evaluatePasswordAfterUnlock) { diff --git a/libs/common/spec/fake-account-service.ts b/libs/common/spec/fake-account-service.ts index 1364127f656..2f33d9cf023 100644 --- a/libs/common/spec/fake-account-service.ts +++ b/libs/common/spec/fake-account-service.ts @@ -70,6 +70,9 @@ export class FakeAccountService implements AccountService { } async switchAccount(userId: UserId): Promise<void> { + const next = + userId == null ? null : { id: userId, ...this.accountsSubject["_buffer"]?.[0]?.[userId] }; + this.activeAccountSubject.next(next); await this.mock.switchAccount(userId); } } diff --git a/libs/common/src/platform/biometrics/biometric-state.service.spec.ts b/libs/common/src/platform/biometrics/biometric-state.service.spec.ts index 716ad627c12..097428e16af 100644 --- a/libs/common/src/platform/biometrics/biometric-state.service.spec.ts +++ b/libs/common/src/platform/biometrics/biometric-state.service.spec.ts @@ -1,8 +1,8 @@ import { firstValueFrom } from "rxjs"; -import { makeEncString } from "../../../spec"; -import { mockAccountServiceWith } from "../../../spec/fake-account-service"; -import { FakeSingleUserState } from "../../../spec/fake-state"; +import { makeEncString, trackEmissions } from "../../../spec"; +import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service"; +import { FakeGlobalState, FakeSingleUserState } from "../../../spec/fake-state"; import { FakeStateProvider } from "../../../spec/fake-state-provider"; import { UserId } from "../../types/guid"; import { EncryptedString } from "../models/domain/enc-string"; @@ -23,10 +23,11 @@ describe("BiometricStateService", () => { const userId = "userId" as UserId; const encClientKeyHalf = makeEncString(); const encryptedClientKeyHalf = encClientKeyHalf.encryptedString; - const accountService = mockAccountServiceWith(userId); + let accountService: FakeAccountService; let stateProvider: FakeStateProvider; beforeEach(() => { + accountService = mockAccountServiceWith(userId); stateProvider = new FakeStateProvider(accountService); sut = new DefaultBiometricStateService(stateProvider); @@ -145,19 +146,89 @@ describe("BiometricStateService", () => { }); describe("setPromptCancelled", () => { + let existingState: Record<UserId, boolean>; + + beforeEach(() => { + existingState = { ["otherUser" as UserId]: false }; + stateProvider.global.getFake(PROMPT_CANCELLED).stateSubject.next(existingState); + }); + test("observable is updated", async () => { - await sut.setPromptCancelled(); + await sut.setUserPromptCancelled(); expect(await firstValueFrom(sut.promptCancelled$)).toBe(true); }); it("updates state", async () => { - await sut.setPromptCancelled(); + await sut.setUserPromptCancelled(); - const nextMock = stateProvider.activeUser.getFake(PROMPT_CANCELLED).nextMock; - expect(nextMock).toHaveBeenCalledWith([userId, true]); + const nextMock = stateProvider.global.getFake(PROMPT_CANCELLED).nextMock; + expect(nextMock).toHaveBeenCalledWith({ ...existingState, [userId]: true }); expect(nextMock).toHaveBeenCalledTimes(1); }); + + it("throws when called with no active user", async () => { + await accountService.switchAccount(null); + await expect(sut.setUserPromptCancelled()).rejects.toThrow( + "Cannot update biometric prompt cancelled state without an active user", + ); + const nextMock = stateProvider.global.getFake(PROMPT_CANCELLED).nextMock; + expect(nextMock).not.toHaveBeenCalled(); + }); + }); + + describe("resetAllPromptCancelled", () => { + it("deletes all prompt cancelled state", async () => { + await sut.resetAllPromptCancelled(); + + const nextMock = stateProvider.global.getFake(PROMPT_CANCELLED).nextMock; + expect(nextMock).toHaveBeenCalledWith(null); + expect(nextMock).toHaveBeenCalledTimes(1); + }); + + it("updates observable to false", async () => { + const emissions = trackEmissions(sut.promptCancelled$); + + await sut.setUserPromptCancelled(); + + await sut.resetAllPromptCancelled(); + + expect(emissions).toEqual([false, true, false]); + }); + }); + + describe("resetUserPromptCancelled", () => { + let existingState: Record<UserId, boolean>; + let state: FakeGlobalState<Record<UserId, boolean>>; + + beforeEach(async () => { + await accountService.switchAccount(userId); + existingState = { [userId]: true, ["otherUser" as UserId]: false }; + state = stateProvider.global.getFake(PROMPT_CANCELLED); + state.stateSubject.next(existingState); + }); + + it("deletes specified user prompt cancelled state", async () => { + await sut.resetUserPromptCancelled("otherUser" as UserId); + + expect(state.nextMock).toHaveBeenCalledWith({ [userId]: true }); + expect(state.nextMock).toHaveBeenCalledTimes(1); + }); + + it("deletes active user when called with no user", async () => { + await sut.resetUserPromptCancelled(); + + expect(state.nextMock).toHaveBeenCalledWith({ ["otherUser" as UserId]: false }); + expect(state.nextMock).toHaveBeenCalledTimes(1); + }); + + it("updates observable to false", async () => { + const emissions = trackEmissions(sut.promptCancelled$); + + await sut.resetUserPromptCancelled(); + + expect(emissions).toEqual([true, false]); + }); }); describe("setPromptAutomatically", () => { diff --git a/libs/common/src/platform/biometrics/biometric-state.service.ts b/libs/common/src/platform/biometrics/biometric-state.service.ts index 2047d137b53..82c05542b4e 100644 --- a/libs/common/src/platform/biometrics/biometric-state.service.ts +++ b/libs/common/src/platform/biometrics/biometric-state.service.ts @@ -1,4 +1,4 @@ -import { Observable, firstValueFrom, map } from "rxjs"; +import { Observable, firstValueFrom, map, combineLatest } from "rxjs"; import { UserId } from "../../types/guid"; import { EncryptedString, EncString } from "../models/domain/enc-string"; @@ -81,13 +81,18 @@ export abstract class BiometricStateService { */ abstract setDismissedRequirePasswordOnStartCallout(): Promise<void>; /** - * Updates the active user's state to reflect that they've cancelled the biometric prompt this lock. + * Updates the active user's state to reflect that they've cancelled the biometric prompt. */ - abstract setPromptCancelled(): Promise<void>; + abstract setUserPromptCancelled(): Promise<void>; /** - * Resets the active user's state to reflect that they haven't cancelled the biometric prompt this lock. + * Resets the given user's state to reflect that they haven't cancelled the biometric prompt. + * @param userId the user to reset the prompt cancelled state for. If not provided, the currently active user will be used. */ - abstract resetPromptCancelled(): Promise<void>; + abstract resetUserPromptCancelled(userId?: UserId): Promise<void>; + /** + * Resets all user's state to reflect that they haven't cancelled the biometric prompt. + */ + abstract resetAllPromptCancelled(): Promise<void>; /** * Updates the currently active user's setting for auto prompting for biometrics on application start and lock * @param prompt Whether or not to prompt for biometrics on application start. @@ -107,7 +112,7 @@ export class DefaultBiometricStateService implements BiometricStateService { private requirePasswordOnStartState: ActiveUserState<boolean>; private encryptedClientKeyHalfState: ActiveUserState<EncryptedString | undefined>; private dismissedRequirePasswordOnStartCalloutState: ActiveUserState<boolean>; - private promptCancelledState: ActiveUserState<boolean>; + private promptCancelledState: GlobalState<Record<UserId, boolean>>; private promptAutomaticallyState: ActiveUserState<boolean>; private fingerprintValidatedState: GlobalState<boolean>; biometricUnlockEnabled$: Observable<boolean>; @@ -138,8 +143,15 @@ export class DefaultBiometricStateService implements BiometricStateService { this.dismissedRequirePasswordOnStartCallout$ = this.dismissedRequirePasswordOnStartCalloutState.state$.pipe(map(Boolean)); - this.promptCancelledState = this.stateProvider.getActive(PROMPT_CANCELLED); - this.promptCancelled$ = this.promptCancelledState.state$.pipe(map(Boolean)); + this.promptCancelledState = this.stateProvider.getGlobal(PROMPT_CANCELLED); + this.promptCancelled$ = combineLatest([ + this.stateProvider.activeUserId$, + this.promptCancelledState.state$, + ]).pipe( + map(([userId, record]) => { + return record?.[userId] ?? false; + }), + ); this.promptAutomaticallyState = this.stateProvider.getActive(PROMPT_AUTOMATICALLY); this.promptAutomatically$ = this.promptAutomaticallyState.state$.pipe(map(Boolean)); @@ -202,7 +214,7 @@ export class DefaultBiometricStateService implements BiometricStateService { async logout(userId: UserId): Promise<void> { await this.stateProvider.getUser(userId, ENCRYPTED_CLIENT_KEY_HALF).update(() => null); - await this.stateProvider.getUser(userId, PROMPT_CANCELLED).update(() => null); + await this.resetUserPromptCancelled(userId); // Persist auto prompt setting through logout // Persist dismissed require password on start callout through logout } @@ -211,11 +223,41 @@ export class DefaultBiometricStateService implements BiometricStateService { await this.dismissedRequirePasswordOnStartCalloutState.update(() => true); } - async setPromptCancelled(): Promise<void> { - await this.promptCancelledState.update(() => true); + async resetUserPromptCancelled(userId: UserId): Promise<void> { + await this.stateProvider.getGlobal(PROMPT_CANCELLED).update( + (data, activeUserId) => { + delete data[userId ?? activeUserId]; + return data; + }, + { + combineLatestWith: this.stateProvider.activeUserId$, + shouldUpdate: (data, activeUserId) => data?.[userId ?? activeUserId] != null, + }, + ); } - async resetPromptCancelled(): Promise<void> { + async setUserPromptCancelled(): Promise<void> { + await this.promptCancelledState.update( + (record, userId) => { + record ??= {}; + record[userId] = true; + return record; + }, + { + combineLatestWith: this.stateProvider.activeUserId$, + shouldUpdate: (_, userId) => { + if (userId == null) { + throw new Error( + "Cannot update biometric prompt cancelled state without an active user", + ); + } + return true; + }, + }, + ); + } + + async resetAllPromptCancelled(): Promise<void> { await this.promptCancelledState.update(() => null); } diff --git a/libs/common/src/platform/biometrics/biometric.state.spec.ts b/libs/common/src/platform/biometrics/biometric.state.spec.ts index a3b110c77cf..420a0fb86e2 100644 --- a/libs/common/src/platform/biometrics/biometric.state.spec.ts +++ b/libs/common/src/platform/biometrics/biometric.state.spec.ts @@ -14,7 +14,7 @@ import { describe.each([ [ENCRYPTED_CLIENT_KEY_HALF, "encryptedClientKeyHalf"], [DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT, true], - [PROMPT_CANCELLED, true], + [PROMPT_CANCELLED, { userId1: true, userId2: false }], [PROMPT_AUTOMATICALLY, true], [REQUIRE_PASSWORD_ON_START, true], [BIOMETRIC_UNLOCK_ENABLED, true], diff --git a/libs/common/src/platform/biometrics/biometric.state.ts b/libs/common/src/platform/biometrics/biometric.state.ts index a5041ca8d01..aa16e14baa1 100644 --- a/libs/common/src/platform/biometrics/biometric.state.ts +++ b/libs/common/src/platform/biometrics/biometric.state.ts @@ -1,3 +1,4 @@ +import { UserId } from "../../types/guid"; import { EncryptedString } from "../models/domain/enc-string"; import { KeyDefinition, BIOMETRIC_SETTINGS_DISK } from "../state"; @@ -56,7 +57,7 @@ export const DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT = new KeyDefinition<boo * Stores whether the user has elected to cancel the biometric prompt. This is stored on disk due to process-reload * wiping memory state. We don't want to prompt the user again if they've elected to cancel. */ -export const PROMPT_CANCELLED = new KeyDefinition<boolean>( +export const PROMPT_CANCELLED = KeyDefinition.record<boolean, UserId>( BIOMETRIC_SETTINGS_DISK, "promptCancelled", { diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index 7fc4fb6de29..35a53c23413 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -41,6 +41,7 @@ import { EnableFaviconMigrator } from "./migrations/42-move-enable-favicon-to-do import { AutoConfirmFingerPrintsMigrator } from "./migrations/43-move-auto-confirm-finger-prints-to-state-provider"; import { UserDecryptionOptionsMigrator } from "./migrations/44-move-user-decryption-options-to-state-provider"; import { MergeEnvironmentState } from "./migrations/45-merge-environment-state"; +import { DeleteBiometricPromptCancelledData } from "./migrations/46-delete-orphaned-biometric-prompt-data"; import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account"; @@ -49,8 +50,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 3; -export const CURRENT_VERSION = 45; - +export const CURRENT_VERSION = 46; export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { @@ -97,7 +97,8 @@ export function createMigrationBuilder() { .with(EnableFaviconMigrator, 41, 42) .with(AutoConfirmFingerPrintsMigrator, 42, 43) .with(UserDecryptionOptionsMigrator, 43, 44) - .with(MergeEnvironmentState, 44, CURRENT_VERSION); + .with(MergeEnvironmentState, 44, 45) + .with(DeleteBiometricPromptCancelledData, 45, CURRENT_VERSION); } export async function currentVersion( diff --git a/libs/common/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.spec.ts b/libs/common/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.spec.ts new file mode 100644 index 00000000000..744f39709d6 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.spec.ts @@ -0,0 +1,28 @@ +import { runMigrator } from "../migration-helper.spec"; +import { IRREVERSIBLE } from "../migrator"; + +import { DeleteBiometricPromptCancelledData } from "./46-delete-orphaned-biometric-prompt-data"; + +describe("MoveThemeToStateProviders", () => { + const sut = new DeleteBiometricPromptCancelledData(45, 46); + + describe("migrate", () => { + it("deletes promptCancelled from all users", async () => { + const output = await runMigrator(sut, { + authenticatedAccounts: ["user-1", "user-2"], + "user_user-1_biometricSettings_promptCancelled": true, + "user_user-2_biometricSettings_promptCancelled": false, + }); + + expect(output).toEqual({ + authenticatedAccounts: ["user-1", "user-2"], + }); + }); + }); + + describe("rollback", () => { + it("is irreversible", async () => { + await expect(runMigrator(sut, {}, "rollback")).rejects.toThrow(IRREVERSIBLE); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.ts b/libs/common/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.ts new file mode 100644 index 00000000000..a919e999e1f --- /dev/null +++ b/libs/common/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.ts @@ -0,0 +1,23 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { IRREVERSIBLE, Migrator } from "../migrator"; + +export const PROMPT_CANCELLED: KeyDefinitionLike = { + key: "promptCancelled", + stateDefinition: { name: "biometricSettings" }, +}; + +export class DeleteBiometricPromptCancelledData extends Migrator<45, 46> { + async migrate(helper: MigrationHelper): Promise<void> { + await Promise.all( + (await helper.getAccounts()).map(async ({ userId }) => { + if (helper.getFromUser(userId, PROMPT_CANCELLED) != null) { + await helper.removeFromUser(userId, PROMPT_CANCELLED); + } + }), + ); + } + + async rollback(helper: MigrationHelper): Promise<void> { + throw IRREVERSIBLE; + } +} From b9f9ad029f46a0675841a12b7cd7f743b472ae38 Mon Sep 17 00:00:00 2001 From: Cesar Gonzalez <cesar.a.gonzalezcs@gmail.com> Date: Thu, 21 Mar 2024 12:41:26 -0500 Subject: [PATCH 4/7] [PM-6921] Optimize methodology for storing page details within inline menu background processes (#8385) * [PM-6921] Optimize methodology for storing page details within inline menu background processes * [PM-6921] Incorporating method for ensuring that we clear the Map datastructure when the page details are being removed * [PM-6921] Adjusting method to ensure that page details always remain up to date for when processed --- .../background/overlay.background.spec.ts | 77 +++++++++++++++---- .../autofill/background/overlay.background.ts | 22 ++++-- 2 files changed, 76 insertions(+), 23 deletions(-) diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index 56cacb37e50..c06df6603b9 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -125,7 +125,8 @@ describe("OverlayBackground", () => { describe("removePageDetails", () => { it("removes the page details for a specific tab from the pageDetailsForTab object", () => { const tabId = 1; - overlayBackground["pageDetailsForTab"][tabId] = [createPageDetailMock()]; + const frameId = 2; + overlayBackground["pageDetailsForTab"][tabId] = new Map([[frameId, createPageDetailMock()]]); overlayBackground.removePageDetails(tabId); expect(overlayBackground["pageDetailsForTab"][tabId]).toBeUndefined(); @@ -864,29 +865,40 @@ describe("OverlayBackground", () => { sender, ); - expect(overlayBackground["pageDetailsForTab"][sender.tab.id]).toStrictEqual([ - { frameId: sender.frameId, tab: sender.tab, details: pageDetails1 }, - ]); + expect(overlayBackground["pageDetailsForTab"][sender.tab.id]).toStrictEqual( + new Map([ + [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails1 }], + ]), + ); }); it("updates the page details for a tab that already has a set of page details stored ", () => { - overlayBackground["pageDetailsForTab"][sender.tab.id] = [ - { - frameId: sender.frameId, - tab: sender.tab, - details: pageDetails1, - }, - ]; + const secondFrameSender = mock<chrome.runtime.MessageSender>({ + tab: { id: 1 }, + frameId: 3, + }); + overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ + [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails1 }], + ]); sendExtensionRuntimeMessage( { command: "collectPageDetailsResponse", details: pageDetails2 }, - sender, + secondFrameSender, ); - expect(overlayBackground["pageDetailsForTab"][sender.tab.id]).toStrictEqual([ - { frameId: sender.frameId, tab: sender.tab, details: pageDetails1 }, - { frameId: sender.frameId, tab: sender.tab, details: pageDetails2 }, - ]); + expect(overlayBackground["pageDetailsForTab"][sender.tab.id]).toStrictEqual( + new Map([ + [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails1 }], + [ + secondFrameSender.frameId, + { + frameId: secondFrameSender.frameId, + tab: secondFrameSender.tab, + details: pageDetails2, + }, + ], + ]), + ); }); }); @@ -1196,6 +1208,10 @@ describe("OverlayBackground", () => { let getLoginCiphersSpy: jest.SpyInstance; let isPasswordRepromptRequiredSpy: jest.SpyInstance; let doAutoFillSpy: jest.SpyInstance; + let sender: chrome.runtime.MessageSender; + const pageDetails = createAutofillPageDetailsMock({ + login: { username: "username1", password: "password1" }, + }); beforeEach(() => { getLoginCiphersSpy = jest.spyOn(overlayBackground["overlayLoginCiphers"], "get"); @@ -1204,6 +1220,7 @@ describe("OverlayBackground", () => { "isPasswordRepromptRequired", ); doAutoFillSpy = jest.spyOn(overlayBackground["autofillService"], "doAutoFill"); + sender = mock<chrome.runtime.MessageSender>({ tab: { id: 1 } }); }); it("ignores the fill request if the overlay cipher id is not provided", async () => { @@ -1215,12 +1232,27 @@ describe("OverlayBackground", () => { expect(doAutoFillSpy).not.toHaveBeenCalled(); }); + it("ignores the fill request if the tab does not contain any identified page details", async () => { + sendPortMessage(listPortSpy, { + command: "fillSelectedListItem", + overlayCipherId: "overlay-cipher-1", + }); + await flushPromises(); + + expect(getLoginCiphersSpy).not.toHaveBeenCalled(); + expect(isPasswordRepromptRequiredSpy).not.toHaveBeenCalled(); + expect(doAutoFillSpy).not.toHaveBeenCalled(); + }); + it("ignores the fill request if a master password reprompt is required", async () => { const cipher = mock<CipherView>({ reprompt: CipherRepromptType.Password, type: CipherType.Login, }); overlayBackground["overlayLoginCiphers"] = new Map([["overlay-cipher-1", cipher]]); + overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ + [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails }], + ]); getLoginCiphersSpy = jest.spyOn(overlayBackground["overlayLoginCiphers"], "get"); isPasswordRepromptRequiredSpy.mockResolvedValue(true); @@ -1247,6 +1279,14 @@ describe("OverlayBackground", () => { ["overlay-cipher-2", cipher2], ["overlay-cipher-3", cipher3], ]); + const pageDetailsForTab = { + frameId: sender.frameId, + tab: sender.tab, + details: pageDetails, + }; + overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ + [sender.frameId, pageDetailsForTab], + ]); isPasswordRepromptRequiredSpy.mockResolvedValue(false); sendPortMessage(listPortSpy, { @@ -1262,7 +1302,7 @@ describe("OverlayBackground", () => { expect(doAutoFillSpy).toHaveBeenCalledWith({ tab: listPortSpy.sender.tab, cipher: cipher2, - pageDetails: undefined, + pageDetails: [pageDetailsForTab], fillNewPassword: true, allowTotpAutofill: true, }); @@ -1278,6 +1318,9 @@ describe("OverlayBackground", () => { it("copies the cipher's totp code to the clipboard after filling", async () => { const cipher1 = mock<CipherView>({ id: "overlay-cipher-1" }); overlayBackground["overlayLoginCiphers"] = new Map([["overlay-cipher-1", cipher1]]); + overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ + [sender.frameId, { frameId: sender.frameId, tab: sender.tab, details: pageDetails }], + ]); isPasswordRepromptRequiredSpy.mockResolvedValue(false); const copyToClipboardSpy = jest .spyOn(overlayBackground["platformUtilsService"], "copyToClipboard") diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 63111fb6c35..7b43756553b 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -47,7 +47,10 @@ class OverlayBackground implements OverlayBackgroundInterface { private readonly openViewVaultItemPopout = openViewVaultItemPopout; private readonly openAddEditVaultItemPopout = openAddEditVaultItemPopout; private overlayLoginCiphers: Map<string, CipherView> = new Map(); - private pageDetailsForTab: Record<number, PageDetail[]> = {}; + private pageDetailsForTab: Record< + chrome.runtime.MessageSender["tab"]["id"], + Map<chrome.runtime.MessageSender["frameId"], PageDetail> + > = {}; private userAuthStatus: AuthenticationStatus = AuthenticationStatus.LoggedOut; private overlayButtonPort: chrome.runtime.Port; private overlayListPort: chrome.runtime.Port; @@ -107,6 +110,11 @@ class OverlayBackground implements OverlayBackgroundInterface { * @param tabId - Used to reference the page details of a specific tab */ removePageDetails(tabId: number) { + if (!this.pageDetailsForTab[tabId]) { + return; + } + + this.pageDetailsForTab[tabId].clear(); delete this.pageDetailsForTab[tabId]; } @@ -203,12 +211,13 @@ class OverlayBackground implements OverlayBackgroundInterface { details: message.details, }; - if (this.pageDetailsForTab[sender.tab.id]?.length) { - this.pageDetailsForTab[sender.tab.id].push(pageDetails); + const pageDetailsMap = this.pageDetailsForTab[sender.tab.id]; + if (!pageDetailsMap) { + this.pageDetailsForTab[sender.tab.id] = new Map([[sender.frameId, pageDetails]]); return; } - this.pageDetailsForTab[sender.tab.id] = [pageDetails]; + pageDetailsMap.set(sender.frameId, pageDetails); } /** @@ -222,7 +231,8 @@ class OverlayBackground implements OverlayBackgroundInterface { { overlayCipherId }: OverlayPortMessage, { sender }: chrome.runtime.Port, ) { - if (!overlayCipherId) { + const pageDetails = this.pageDetailsForTab[sender.tab.id]; + if (!overlayCipherId || !pageDetails?.size) { return; } @@ -234,7 +244,7 @@ class OverlayBackground implements OverlayBackgroundInterface { const totpCode = await this.autofillService.doAutoFill({ tab: sender.tab, cipher: cipher, - pageDetails: this.pageDetailsForTab[sender.tab.id], + pageDetails: Array.from(pageDetails.values()), fillNewPassword: true, allowTotpAutofill: true, }); From b450b31ec4c455621c795d87aa74564033bbb4ca Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 21 Mar 2024 12:53:12 -0500 Subject: [PATCH 5/7] [PM-5540] `DesktopSettingsService` (#8369) * WIP: First Try at making DesktopSettingsService Does not work, migrations are ran in renderer but the values are read in main. * Update window$ retrieval * Fix DesktopSettings * Rename Migration * Add Migration to Builder * Cleanup * Update Comments * Update `migrate.ts` * Catch Unawaited Promises * Remove Comments * Update Tests * Rename Migration * Add `alwaysOnTop` * Make `init` async * Fix Desktop Build --- .../src/app/accounts/settings.component.ts | 38 ++-- apps/desktop/src/main.ts | 18 +- apps/desktop/src/main/messaging.main.ts | 12 +- apps/desktop/src/main/tray.main.ts | 18 +- apps/desktop/src/main/window.main.ts | 17 +- .../platform}/models/domain/window-state.ts | 2 +- .../services/desktop-settings.service.ts | 158 +++++++++++++++- .../platform/abstractions/state.service.ts | 19 -- .../src/platform/models/domain/account.ts | 1 - .../platform/models/domain/global-state.ts | 9 - .../src/platform/services/state.service.ts | 174 ------------------ libs/common/src/state-migrations/migrate.ts | 6 +- .../state-migrations/migration-helper.spec.ts | 6 +- .../47-move-desktop-settings.spec.ts | 116 ++++++++++++ .../migrations/47-move-desktop-settings.ts | 128 +++++++++++++ 15 files changed, 459 insertions(+), 263 deletions(-) rename {libs/common/src => apps/desktop/src/platform}/models/domain/window-state.ts (89%) create mode 100644 libs/common/src/state-migrations/migrations/47-move-desktop-settings.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/47-move-desktop-settings.ts diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index bf1102ab549..4e6af9bff44 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -120,8 +120,8 @@ export class SettingsComponent implements OnInit { private domainSettingsService: DomainSettingsService, private dialogService: DialogService, private userVerificationService: UserVerificationServiceAbstraction, - private biometricStateService: BiometricStateService, private desktopSettingsService: DesktopSettingsService, + private biometricStateService: BiometricStateService, ) { const isMac = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop; @@ -253,12 +253,12 @@ export class SettingsComponent implements OnInit { clearClipboard: await firstValueFrom(this.autofillSettingsService.clearClipboardDelay$), minimizeOnCopyToClipboard: await this.stateService.getMinimizeOnCopyToClipboard(), enableFavicons: await firstValueFrom(this.domainSettingsService.showFavicons$), - enableTray: await this.stateService.getEnableTray(), - enableMinToTray: await this.stateService.getEnableMinimizeToTray(), - enableCloseToTray: await this.stateService.getEnableCloseToTray(), - startToTray: await this.stateService.getEnableStartToTray(), - openAtLogin: await this.stateService.getOpenAtLogin(), - alwaysShowDock: await this.stateService.getAlwaysShowDock(), + enableTray: await firstValueFrom(this.desktopSettingsService.trayEnabled$), + enableMinToTray: await firstValueFrom(this.desktopSettingsService.minimizeToTray$), + enableCloseToTray: await firstValueFrom(this.desktopSettingsService.closeToTray$), + startToTray: await firstValueFrom(this.desktopSettingsService.startToTray$), + openAtLogin: await firstValueFrom(this.desktopSettingsService.openAtLogin$), + alwaysShowDock: await firstValueFrom(this.desktopSettingsService.alwaysShowDock$), enableBrowserIntegration: await this.stateService.getEnableBrowserIntegration(), enableBrowserIntegrationFingerprint: await this.stateService.getEnableBrowserIntegrationFingerprint(), @@ -507,16 +507,16 @@ export class SettingsComponent implements OnInit { } async saveMinToTray() { - await this.stateService.setEnableMinimizeToTray(this.form.value.enableMinToTray); + await this.desktopSettingsService.setMinimizeToTray(this.form.value.enableMinToTray); } async saveCloseToTray() { if (this.requireEnableTray) { this.form.controls.enableTray.setValue(true); - await this.stateService.setEnableTray(this.form.value.enableTray); + await this.desktopSettingsService.setTrayEnabled(this.form.value.enableTray); } - await this.stateService.setEnableCloseToTray(this.form.value.enableCloseToTray); + await this.desktopSettingsService.setCloseToTray(this.form.value.enableCloseToTray); } async saveTray() { @@ -533,9 +533,9 @@ export class SettingsComponent implements OnInit { if (confirm) { this.form.controls.startToTray.setValue(false, { emitEvent: false }); - await this.stateService.setEnableStartToTray(this.form.value.startToTray); + await this.desktopSettingsService.setStartToTray(this.form.value.startToTray); this.form.controls.enableCloseToTray.setValue(false, { emitEvent: false }); - await this.stateService.setEnableCloseToTray(this.form.value.enableCloseToTray); + await this.desktopSettingsService.setCloseToTray(this.form.value.enableCloseToTray); } else { this.form.controls.enableTray.setValue(true); } @@ -543,17 +543,18 @@ export class SettingsComponent implements OnInit { return; } - await this.stateService.setEnableTray(this.form.value.enableTray); + await this.desktopSettingsService.setTrayEnabled(this.form.value.enableTray); + // TODO: Ideally the DesktopSettingsService.trayEnabled$ could be subscribed to instead of using messaging. this.messagingService.send(this.form.value.enableTray ? "showTray" : "removeTray"); } async saveStartToTray() { if (this.requireEnableTray) { this.form.controls.enableTray.setValue(true); - await this.stateService.setEnableTray(this.form.value.enableTray); + await this.desktopSettingsService.setTrayEnabled(this.form.value.enableTray); } - await this.stateService.setEnableStartToTray(this.form.value.startToTray); + await this.desktopSettingsService.setStartToTray(this.form.value.startToTray); } async saveLocale() { @@ -573,13 +574,12 @@ export class SettingsComponent implements OnInit { } async saveAlwaysShowDock() { - await this.stateService.setAlwaysShowDock(this.form.value.alwaysShowDock); + await this.desktopSettingsService.setAlwaysShowDock(this.form.value.alwaysShowDock); } async saveOpenAtLogin() { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.stateService.setOpenAtLogin(this.form.value.openAtLogin); + await this.desktopSettingsService.setOpenAtLogin(this.form.value.openAtLogin); + // TODO: Ideally DesktopSettingsService.openAtLogin$ could be subscribed to directly rather than sending a message this.messagingService.send( this.form.value.openAtLogin ? "addOpenAtLogin" : "removeOpenAtLogin", ); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b7ba2faf797..ee36d49dd9f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -57,8 +57,8 @@ export class Main { environmentService: DefaultEnvironmentService; mainCryptoFunctionService: MainCryptoFunctionService; desktopCredentialStorageListener: DesktopCredentialStorageListener; - migrationRunner: MigrationRunner; desktopSettingsService: DesktopSettingsService; + migrationRunner: MigrationRunner; tokenService: TokenServiceAbstraction; windowMain: WindowMain; @@ -179,6 +179,8 @@ export class Main { false, // Do not use disk caching because this will get out of sync with the renderer service ); + this.desktopSettingsService = new DesktopSettingsService(stateProvider); + const biometricStateService = new DefaultBiometricStateService(stateProvider); this.windowMain = new WindowMain( @@ -186,13 +188,13 @@ export class Main { biometricStateService, this.logService, this.storageService, + this.desktopSettingsService, (arg) => this.processDeepLink(arg), (win) => this.trayMain.setupWindowListeners(win), ); - this.messagingMain = new MessagingMain(this, this.stateService); + this.messagingMain = new MessagingMain(this, this.stateService, this.desktopSettingsService); this.updaterMain = new UpdaterMain(this.i18nService, this.windowMain); - this.trayMain = new TrayMain(this.windowMain, this.i18nService, this.stateService); - this.desktopSettingsService = new DesktopSettingsService(stateProvider); + this.trayMain = new TrayMain(this.windowMain, this.i18nService, this.desktopSettingsService); this.messagingService = new ElectronMainMessagingService(this.windowMain, (message) => { this.messagingMain.onMessage(message); @@ -244,7 +246,7 @@ export class Main { await this.toggleHardwareAcceleration(); await this.windowMain.init(); await this.i18nService.init(); - this.messagingMain.init(); + await this.messagingMain.init(); // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises this.menuMain.init(); @@ -256,10 +258,8 @@ export class Main { click: () => this.messagingService.send("lockVault"), }, ]); - if (await this.stateService.getEnableStartToTray()) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.trayMain.hideToTray(); + if (await firstValueFrom(this.desktopSettingsService.startToTray$)) { + await this.trayMain.hideToTray(); } this.powerMonitorMain.init(); await this.updaterMain.init(); diff --git a/apps/desktop/src/main/messaging.main.ts b/apps/desktop/src/main/messaging.main.ts index 44184e6f844..cc67e312b53 100644 --- a/apps/desktop/src/main/messaging.main.ts +++ b/apps/desktop/src/main/messaging.main.ts @@ -6,6 +6,7 @@ import { app, ipcMain } from "electron"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { Main } from "../main"; +import { DesktopSettingsService } from "../platform/services/desktop-settings.service"; import { MenuUpdateRequest } from "./menu/menu.updater"; @@ -17,19 +18,16 @@ export class MessagingMain { constructor( private main: Main, private stateService: StateService, + private desktopSettingsService: DesktopSettingsService, ) {} - init() { + async init() { this.scheduleNextSync(); if (process.platform === "linux") { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.stateService.setOpenAtLogin(fs.existsSync(this.linuxStartupFile())); + await this.desktopSettingsService.setOpenAtLogin(fs.existsSync(this.linuxStartupFile())); } else { const loginSettings = app.getLoginItemSettings(); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.stateService.setOpenAtLogin(loginSettings.openAtLogin); + await this.desktopSettingsService.setOpenAtLogin(loginSettings.openAtLogin); } ipcMain.on("messagingService", async (event: any, message: any) => this.onMessage(message)); } diff --git a/apps/desktop/src/main/tray.main.ts b/apps/desktop/src/main/tray.main.ts index 3f8ad4eedf9..948c48f519a 100644 --- a/apps/desktop/src/main/tray.main.ts +++ b/apps/desktop/src/main/tray.main.ts @@ -1,9 +1,11 @@ import * as path from "path"; import { app, BrowserWindow, Menu, MenuItemConstructorOptions, nativeImage, Tray } from "electron"; +import { firstValueFrom } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; + +import { DesktopSettingsService } from "../platform/services/desktop-settings.service"; import { WindowMain } from "./window.main"; @@ -18,7 +20,7 @@ export class TrayMain { constructor( private windowMain: WindowMain, private i18nService: I18nService, - private stateService: StateService, + private desktopSettingsService: DesktopSettingsService, ) { if (process.platform === "win32") { this.icon = path.join(__dirname, "/images/icon.ico"); @@ -54,14 +56,14 @@ export class TrayMain { } this.contextMenu = Menu.buildFromTemplate(menuItemOptions); - if (await this.stateService.getEnableTray()) { + if (await firstValueFrom(this.desktopSettingsService.trayEnabled$)) { this.showTray(); } } setupWindowListeners(win: BrowserWindow) { win.on("minimize", async (e: Event) => { - if (await this.stateService.getEnableMinimizeToTray()) { + if (await firstValueFrom(this.desktopSettingsService.minimizeToTray$)) { e.preventDefault(); // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -70,7 +72,7 @@ export class TrayMain { }); win.on("close", async (e: Event) => { - if (await this.stateService.getEnableCloseToTray()) { + if (await firstValueFrom(this.desktopSettingsService.closeToTray$)) { if (!this.windowMain.isQuitting) { e.preventDefault(); // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. @@ -81,7 +83,7 @@ export class TrayMain { }); win.on("show", async () => { - const enableTray = await this.stateService.getEnableTray(); + const enableTray = await firstValueFrom(this.desktopSettingsService.trayEnabled$); if (!enableTray) { setTimeout(() => this.removeTray(false), 100); } @@ -106,7 +108,7 @@ export class TrayMain { if (this.windowMain.win != null) { this.windowMain.win.hide(); } - if (this.isDarwin() && !(await this.stateService.getAlwaysShowDock())) { + if (this.isDarwin() && !(await firstValueFrom(this.desktopSettingsService.alwaysShowDock$))) { this.hideDock(); } } @@ -176,7 +178,7 @@ export class TrayMain { } if (this.windowMain.win.isVisible()) { this.windowMain.win.hide(); - if (this.isDarwin() && !(await this.stateService.getAlwaysShowDock())) { + if (this.isDarwin() && !(await firstValueFrom(this.desktopSettingsService.alwaysShowDock$))) { this.hideDock(); } } else { diff --git a/apps/desktop/src/main/window.main.ts b/apps/desktop/src/main/window.main.ts index 9771006c8ad..64b4bc48d28 100644 --- a/apps/desktop/src/main/window.main.ts +++ b/apps/desktop/src/main/window.main.ts @@ -3,13 +3,15 @@ import * as path from "path"; import * as url from "url"; import { app, BrowserWindow, ipcMain, nativeTheme, screen, session } from "electron"; +import { firstValueFrom } from "rxjs"; -import { WindowState } from "@bitwarden/common/models/domain/window-state"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; +import { WindowState } from "../platform/models/domain/window-state"; +import { DesktopSettingsService } from "../platform/services/desktop-settings.service"; import { cleanUserAgent, isDev, @@ -40,6 +42,7 @@ export class WindowMain { private biometricStateService: BiometricStateService, private logService: LogService, private storageService: AbstractStorageService, + private desktopSettingsService: DesktopSettingsService, private argvCallback: (argv: string[]) => void = null, private createWindowCallback: (win: BrowserWindow) => void, ) {} @@ -121,7 +124,7 @@ export class WindowMain { app.on("activate", async () => { // On OS X it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. - if (this.win === null) { + if (this.win == null) { await this.createWindow(); } else { // Show the window when clicking on Dock icon @@ -141,7 +144,7 @@ export class WindowMain { this.defaultWidth, this.defaultHeight, ); - this.enableAlwaysOnTop = await this.stateService.getEnableAlwaysOnTop(); + this.enableAlwaysOnTop = await firstValueFrom(this.desktopSettingsService.alwaysOnTop$); this.session = session.fromPartition("persist:bitwarden", { cache: false }); @@ -265,7 +268,7 @@ export class WindowMain { async toggleAlwaysOnTop() { this.enableAlwaysOnTop = !this.win.isAlwaysOnTop(); this.win.setAlwaysOnTop(this.enableAlwaysOnTop); - await this.stateService.setEnableAlwaysOnTop(this.enableAlwaysOnTop); + await this.desktopSettingsService.setAlwaysOnTop(this.enableAlwaysOnTop); } private windowStateChangeHandler(configKey: string, win: BrowserWindow) { @@ -284,7 +287,7 @@ export class WindowMain { const bounds = win.getBounds(); if (this.windowStates[configKey] == null) { - this.windowStates[configKey] = await this.stateService.getWindow(); + this.windowStates[configKey] = await firstValueFrom(this.desktopSettingsService.window$); if (this.windowStates[configKey] == null) { this.windowStates[configKey] = <WindowState>{}; } @@ -304,14 +307,14 @@ export class WindowMain { this.windowStates[configKey].zoomFactor = win.webContents.zoomFactor; } - await this.stateService.setWindow(this.windowStates[configKey]); + await this.desktopSettingsService.setWindow(this.windowStates[configKey]); } catch (e) { this.logService.error(e); } } private async getWindowState(defaultWidth: number, defaultHeight: number) { - const state = await this.stateService.getWindow(); + const state = await firstValueFrom(this.desktopSettingsService.window$); const isValid = state != null && (this.stateHasBounds(state) || state.isMaximized); let displayBounds: Electron.Rectangle = null; diff --git a/libs/common/src/models/domain/window-state.ts b/apps/desktop/src/platform/models/domain/window-state.ts similarity index 89% rename from libs/common/src/models/domain/window-state.ts rename to apps/desktop/src/platform/models/domain/window-state.ts index 5bd8e86eff1..aba1cdb470e 100644 --- a/libs/common/src/models/domain/window-state.ts +++ b/apps/desktop/src/platform/models/domain/window-state.ts @@ -4,7 +4,7 @@ export class WindowState { isMaximized?: boolean; // TODO: displayBounds is an Electron.Rectangle. // We need to establish some kind of client-specific global state, similar to the way we already extend a base Account. - displayBounds: any; + displayBounds: Electron.Rectangle; x?: number; y?: number; zoomFactor?: number; diff --git a/apps/desktop/src/platform/services/desktop-settings.service.ts b/apps/desktop/src/platform/services/desktop-settings.service.ts index 791e5a2c5d0..d967e5fb1d9 100644 --- a/apps/desktop/src/platform/services/desktop-settings.service.ts +++ b/apps/desktop/src/platform/services/desktop-settings.service.ts @@ -1,4 +1,4 @@ -import { map } from "rxjs"; +import { Observable, map } from "rxjs"; import { DESKTOP_SETTINGS_DISK, @@ -6,6 +6,8 @@ import { StateProvider, } from "@bitwarden/common/platform/state"; +import { WindowState } from "../models/domain/window-state"; + export const HARDWARE_ACCELERATION = new KeyDefinition<boolean>( DESKTOP_SETTINGS_DISK, "hardwareAcceleration", @@ -14,13 +16,165 @@ export const HARDWARE_ACCELERATION = new KeyDefinition<boolean>( }, ); +const WINDOW_KEY = new KeyDefinition<WindowState | null>(DESKTOP_SETTINGS_DISK, "window", { + deserializer: (s) => s, +}); + +const CLOSE_TO_TRAY_KEY = new KeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "closeToTray", { + deserializer: (b) => b, +}); + +const MINIMIZE_TO_TRAY_KEY = new KeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "minimizeToTray", { + deserializer: (b) => b, +}); + +const START_TO_TRAY_KEY = new KeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "startToTray", { + deserializer: (b) => b, +}); + +const TRAY_ENABLED_KEY = new KeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "trayEnabled", { + deserializer: (b) => b, +}); + +const OPEN_AT_LOGIN_KEY = new KeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "openAtLogin", { + deserializer: (b) => b, +}); + +const ALWAYS_SHOW_DOCK_KEY = new KeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "alwaysShowDock", { + deserializer: (b) => b, +}); + +const ALWAYS_ON_TOP_KEY = new KeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "alwaysOnTop", { + deserializer: (b) => b, +}); + +/** + * Various settings for controlling application behavior specific to the desktop client. + */ export class DesktopSettingsService { private hwState = this.stateProvider.getGlobal(HARDWARE_ACCELERATION); hardwareAcceleration$ = this.hwState.state$.pipe(map((v) => v ?? true)); - constructor(private stateProvider: StateProvider) {} + private readonly windowState = this.stateProvider.getGlobal(WINDOW_KEY); + + private readonly closeToTrayState = this.stateProvider.getGlobal(CLOSE_TO_TRAY_KEY); + /** + * Tha applications setting for whether or not to close the application into the system tray. + */ + closeToTray$ = this.closeToTrayState.state$.pipe(map((value) => value ?? false)); + + private readonly minimizeToTrayState = this.stateProvider.getGlobal(MINIMIZE_TO_TRAY_KEY); + /** + * The application setting for whether or not to minimize the applicaiton into the system tray. + */ + minimizeToTray$ = this.minimizeToTrayState.state$.pipe(map((value) => value ?? false)); + + private readonly startToTrayState = this.stateProvider.getGlobal(START_TO_TRAY_KEY); + /** + * The application setting for whether or not to start the application into the system tray. + */ + startToTray$ = this.startToTrayState.state$.pipe(map((value) => value ?? false)); + + private readonly trayEnabledState = this.stateProvider.getGlobal(TRAY_ENABLED_KEY); + /** + * Whether or not the system tray has been enabled. + */ + trayEnabled$ = this.trayEnabledState.state$.pipe(map((value) => value ?? false)); + + private readonly openAtLoginState = this.stateProvider.getGlobal(OPEN_AT_LOGIN_KEY); + /** + * The application setting for whether or not the application should open at system login. + */ + openAtLogin$ = this.openAtLoginState.state$.pipe(map((value) => value ?? false)); + + private readonly alwaysShowDockState = this.stateProvider.getGlobal(ALWAYS_SHOW_DOCK_KEY); + /** + * The application setting for whether or not the application should show up in the dock. + */ + alwaysShowDock$ = this.alwaysShowDockState.state$.pipe(map((value) => value ?? false)); + + private readonly alwaysOnTopState = this.stateProvider.getGlobal(ALWAYS_ON_TOP_KEY); + + alwaysOnTop$ = this.alwaysOnTopState.state$.pipe(map((value) => value ?? false)); + + constructor(private stateProvider: StateProvider) { + this.window$ = this.windowState.state$.pipe( + map((window) => + window != null && Object.keys(window).length > 0 ? window : new WindowState(), + ), + ); + } async setHardwareAcceleration(enabled: boolean) { await this.hwState.update(() => enabled); } + + /** + * The applications current window state. + */ + window$: Observable<WindowState>; + + /** + * Updates the window state of the application so that the application can reopen in the same place as it was closed from. + * @param windowState The window state to set. + */ + async setWindow(windowState: WindowState) { + await this.windowState.update(() => windowState); + } + + /** + * Sets the setting for whether or not the application should go into the system tray when closed. + * @param value `true` if the application should go into the system tray when closed, `false` if it should not. + */ + async setCloseToTray(value: boolean) { + await this.closeToTrayState.update(() => value); + } + + /** + * Sets the setting for whether or not the application should go into the tray when minimized. + * @param value `true` if the application should minimize into the system tray, `false` if it should not. + */ + async setMinimizeToTray(value: boolean) { + await this.minimizeToTrayState.update(() => value); + } + + /** + * Sets the setting for whether or not the application should be started into the system tray. + * @param value `true` if the application should be started to the tray`, `false` if it should not. + */ + async setStartToTray(value: boolean) { + await this.startToTrayState.update(() => value); + } + + /** + * Sets the setting for whether or not the application be shown in the system tray. + * @param value `true` if the application should show in the tray, `false` if it should not. + */ + async setTrayEnabled(value: boolean) { + await this.trayEnabledState.update(() => value); + } + + /** + * Sets the setting for whether or not the application should open at login of the computer. + * @param value `true` if the application should open at login, `false` if it should not. + */ + async setOpenAtLogin(value: boolean) { + await this.openAtLoginState.update(() => value); + } + + /** + * Sets the setting for whether or not the application should be shown in the dock. + * @param value `true` if the application should should in the dock, `false` if it should not. + */ + async setAlwaysShowDock(value: boolean) { + await this.alwaysShowDockState.update(() => value); + } + + /** + * Sets the setting for whether or not the application should stay on top of all other windows. + * @param value `true` if the application should stay on top, `false` if it should not. + */ + async setAlwaysOnTop(value: boolean) { + await this.alwaysOnTopState.update(() => value); + } } diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index e0228ee062c..29a6752e652 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -4,7 +4,6 @@ import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-re import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason"; import { KdfConfig } from "../../auth/models/domain/kdf-config"; import { BiometricKey } from "../../auth/types/biometric-key"; -import { WindowState } from "../../models/domain/window-state"; import { GeneratorOptions } from "../../tools/generator/generator-options"; import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password"; import { UsernameGeneratorOptions } from "../../tools/generator/username"; @@ -52,8 +51,6 @@ export abstract class StateService<T extends Account = Account> { getAddEditCipherInfo: (options?: StorageOptions) => Promise<AddEditCipherInfo>; setAddEditCipherInfo: (value: AddEditCipherInfo, options?: StorageOptions) => Promise<void>; - getAlwaysShowDock: (options?: StorageOptions) => Promise<boolean>; - setAlwaysShowDock: (value: boolean, options?: StorageOptions) => Promise<void>; getBiometricFingerprintValidated: (options?: StorageOptions) => Promise<boolean>; setBiometricFingerprintValidated: (value: boolean, options?: StorageOptions) => Promise<void>; getConvertAccountToKeyConnector: (options?: StorageOptions) => Promise<boolean>; @@ -184,8 +181,6 @@ export abstract class StateService<T extends Account = Account> { setEmail: (value: string, options?: StorageOptions) => Promise<void>; getEmailVerified: (options?: StorageOptions) => Promise<boolean>; setEmailVerified: (value: boolean, options?: StorageOptions) => Promise<void>; - getEnableAlwaysOnTop: (options?: StorageOptions) => Promise<boolean>; - setEnableAlwaysOnTop: (value: boolean, options?: StorageOptions) => Promise<void>; getEnableBrowserIntegration: (options?: StorageOptions) => Promise<boolean>; setEnableBrowserIntegration: (value: boolean, options?: StorageOptions) => Promise<void>; getEnableBrowserIntegrationFingerprint: (options?: StorageOptions) => Promise<boolean>; @@ -193,19 +188,11 @@ export abstract class StateService<T extends Account = Account> { value: boolean, options?: StorageOptions, ) => Promise<void>; - getEnableCloseToTray: (options?: StorageOptions) => Promise<boolean>; - setEnableCloseToTray: (value: boolean, options?: StorageOptions) => Promise<void>; getEnableDuckDuckGoBrowserIntegration: (options?: StorageOptions) => Promise<boolean>; setEnableDuckDuckGoBrowserIntegration: ( value: boolean, options?: StorageOptions, ) => Promise<void>; - getEnableMinimizeToTray: (options?: StorageOptions) => Promise<boolean>; - setEnableMinimizeToTray: (value: boolean, options?: StorageOptions) => Promise<void>; - getEnableStartToTray: (options?: StorageOptions) => Promise<boolean>; - setEnableStartToTray: (value: boolean, options?: StorageOptions) => Promise<void>; - getEnableTray: (options?: StorageOptions) => Promise<boolean>; - setEnableTray: (value: boolean, options?: StorageOptions) => Promise<void>; getEncryptedCiphers: (options?: StorageOptions) => Promise<{ [id: string]: CipherData }>; setEncryptedCiphers: ( value: { [id: string]: CipherData }, @@ -261,12 +248,8 @@ export abstract class StateService<T extends Account = Account> { ) => Promise<void>; getLocale: (options?: StorageOptions) => Promise<string>; setLocale: (value: string, options?: StorageOptions) => Promise<void>; - getMainWindowSize: (options?: StorageOptions) => Promise<number>; - setMainWindowSize: (value: number, options?: StorageOptions) => Promise<void>; getMinimizeOnCopyToClipboard: (options?: StorageOptions) => Promise<boolean>; setMinimizeOnCopyToClipboard: (value: boolean, options?: StorageOptions) => Promise<void>; - getOpenAtLogin: (options?: StorageOptions) => Promise<boolean>; - setOpenAtLogin: (value: boolean, options?: StorageOptions) => Promise<void>; getOrganizationInvitation: (options?: StorageOptions) => Promise<any>; setOrganizationInvitation: (value: any, options?: StorageOptions) => Promise<void>; getPasswordGenerationOptions: (options?: StorageOptions) => Promise<PasswordGeneratorOptions>; @@ -302,8 +285,6 @@ export abstract class StateService<T extends Account = Account> { setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise<void>; getApproveLoginRequests: (options?: StorageOptions) => Promise<boolean>; setApproveLoginRequests: (value: boolean, options?: StorageOptions) => Promise<void>; - getWindow: () => Promise<WindowState>; - setWindow: (value: WindowState) => Promise<void>; /** * @deprecated Do not call this directly, use ConfigService */ diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts index c8d903cfea3..2657467ae6a 100644 --- a/libs/common/src/platform/models/domain/account.ts +++ b/libs/common/src/platform/models/domain/account.ts @@ -186,7 +186,6 @@ export class AccountProfile { export class AccountSettings { defaultUriMatch?: UriMatchStrategySetting; disableGa?: boolean; - enableAlwaysOnTop?: boolean; enableBiometric?: boolean; minimizeOnCopyToClipboard?: boolean; passwordGenerationOptions?: PasswordGeneratorOptions; diff --git a/libs/common/src/platform/models/domain/global-state.ts b/libs/common/src/platform/models/domain/global-state.ts index b27bac3bd42..b5a12d05558 100644 --- a/libs/common/src/platform/models/domain/global-state.ts +++ b/libs/common/src/platform/models/domain/global-state.ts @@ -1,26 +1,17 @@ -import { WindowState } from "../../../models/domain/window-state"; import { ThemeType } from "../../enums"; export class GlobalState { - enableAlwaysOnTop?: boolean; installedVersion?: string; locale?: string; organizationInvitation?: any; rememberedEmail?: string; theme?: ThemeType = ThemeType.System; - window?: WindowState = new WindowState(); twoFactorToken?: string; biometricFingerprintValidated?: boolean; vaultTimeout?: number; vaultTimeoutAction?: string; loginRedirect?: any; mainWindowSize?: number; - enableTray?: boolean; - enableMinimizeToTray?: boolean; - enableCloseToTray?: boolean; - enableStartToTray?: boolean; - openAtLogin?: boolean; - alwaysShowDock?: boolean; enableBrowserIntegration?: boolean; enableBrowserIntegrationFingerprint?: boolean; enableDuckDuckGoBrowserIntegration?: boolean; diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index fc548b562ea..3fef9785062 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -8,7 +8,6 @@ import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-re import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason"; import { KdfConfig } from "../../auth/models/domain/kdf-config"; import { BiometricKey } from "../../auth/types/biometric-key"; -import { WindowState } from "../../models/domain/window-state"; import { GeneratorOptions } from "../../tools/generator/generator-options"; import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password"; import { UsernameGeneratorOptions } from "../../tools/generator/username"; @@ -277,24 +276,6 @@ export class StateService< ); } - async getAlwaysShowDock(options?: StorageOptions): Promise<boolean> { - return ( - (await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) - ?.alwaysShowDock ?? false - ); - } - - async setAlwaysShowDock(value: boolean, options?: StorageOptions): Promise<void> { - const globals = await this.getGlobals( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - globals.alwaysShowDock = value; - await this.saveGlobals( - globals, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - async getBiometricFingerprintValidated(options?: StorageOptions): Promise<boolean> { return ( (await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) @@ -847,36 +828,6 @@ export class StateService< ); } - async getEnableAlwaysOnTop(options?: StorageOptions): Promise<boolean> { - const accountPreference = ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) - )?.settings?.enableAlwaysOnTop; - const globalPreference = ( - await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())) - )?.enableAlwaysOnTop; - return accountPreference ?? globalPreference ?? false; - } - - async setEnableAlwaysOnTop(value: boolean, options?: StorageOptions): Promise<void> { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - account.settings.enableAlwaysOnTop = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - - const globals = await this.getGlobals( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - globals.enableAlwaysOnTop = value; - await this.saveGlobals( - globals, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - async getEnableBrowserIntegration(options?: StorageOptions): Promise<boolean> { return ( (await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) @@ -916,24 +867,6 @@ export class StateService< ); } - async getEnableCloseToTray(options?: StorageOptions): Promise<boolean> { - return ( - (await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) - ?.enableCloseToTray ?? false - ); - } - - async setEnableCloseToTray(value: boolean, options?: StorageOptions): Promise<void> { - const globals = await this.getGlobals( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - globals.enableCloseToTray = value; - await this.saveGlobals( - globals, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - async getEnableDuckDuckGoBrowserIntegration(options?: StorageOptions): Promise<boolean> { return ( (await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) @@ -955,60 +888,6 @@ export class StateService< ); } - async getEnableMinimizeToTray(options?: StorageOptions): Promise<boolean> { - return ( - (await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) - ?.enableMinimizeToTray ?? false - ); - } - - async setEnableMinimizeToTray(value: boolean, options?: StorageOptions): Promise<void> { - const globals = await this.getGlobals( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - globals.enableMinimizeToTray = value; - await this.saveGlobals( - globals, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - - async getEnableStartToTray(options?: StorageOptions): Promise<boolean> { - return ( - (await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) - ?.enableStartToTray ?? false - ); - } - - async setEnableStartToTray(value: boolean, options?: StorageOptions): Promise<void> { - const globals = await this.getGlobals( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - globals.enableStartToTray = value; - await this.saveGlobals( - globals, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - - async getEnableTray(options?: StorageOptions): Promise<boolean> { - return ( - (await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) - ?.enableTray ?? false - ); - } - - async setEnableTray(value: boolean, options?: StorageOptions): Promise<void> { - const globals = await this.getGlobals( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - globals.enableTray = value; - await this.saveGlobals( - globals, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - @withPrototypeForObjectValues(CipherData) async getEncryptedCiphers(options?: StorageOptions): Promise<{ [id: string]: CipherData }> { return ( @@ -1309,23 +1188,6 @@ export class StateService< ); } - async getMainWindowSize(options?: StorageOptions): Promise<number> { - return ( - await this.getGlobals(this.reconcileOptions(options, await this.defaultInMemoryOptions())) - )?.mainWindowSize; - } - - async setMainWindowSize(value: number, options?: StorageOptions): Promise<void> { - const globals = await this.getGlobals( - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - globals.mainWindowSize = value; - await this.saveGlobals( - globals, - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - } - async getMinimizeOnCopyToClipboard(options?: StorageOptions): Promise<boolean> { return ( (await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) @@ -1344,24 +1206,6 @@ export class StateService< ); } - async getOpenAtLogin(options?: StorageOptions): Promise<boolean> { - return ( - (await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) - ?.openAtLogin ?? false - ); - } - - async setOpenAtLogin(value: boolean, options?: StorageOptions): Promise<void> { - const globals = await this.getGlobals( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - globals.openAtLogin = value; - await this.saveGlobals( - globals, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - async getOrganizationInvitation(options?: StorageOptions): Promise<any> { return ( await this.getGlobals(this.reconcileOptions(options, await this.defaultInMemoryOptions())) @@ -1571,24 +1415,6 @@ export class StateService< ); } - async getWindow(): Promise<WindowState> { - const globals = await this.getGlobals(await this.defaultOnDiskOptions()); - return globals?.window != null && Object.keys(globals.window).length > 0 - ? globals.window - : new WindowState(); - } - - async setWindow(value: WindowState, options?: StorageOptions): Promise<void> { - const globals = await this.getGlobals( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - globals.window = value; - return await this.saveGlobals( - globals, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - async setServerConfig(value: ServerConfigData, options?: StorageOptions): Promise<void> { const account = await this.getAccount( this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()), diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index 35a53c23413..3c03854780e 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -42,6 +42,7 @@ import { AutoConfirmFingerPrintsMigrator } from "./migrations/43-move-auto-confi import { UserDecryptionOptionsMigrator } from "./migrations/44-move-user-decryption-options-to-state-provider"; import { MergeEnvironmentState } from "./migrations/45-merge-environment-state"; import { DeleteBiometricPromptCancelledData } from "./migrations/46-delete-orphaned-biometric-prompt-data"; +import { MoveDesktopSettingsMigrator } from "./migrations/47-move-desktop-settings"; import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account"; @@ -50,7 +51,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 3; -export const CURRENT_VERSION = 46; +export const CURRENT_VERSION = 47; export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { @@ -98,7 +99,8 @@ export function createMigrationBuilder() { .with(AutoConfirmFingerPrintsMigrator, 42, 43) .with(UserDecryptionOptionsMigrator, 43, 44) .with(MergeEnvironmentState, 44, 45) - .with(DeleteBiometricPromptCancelledData, 45, CURRENT_VERSION); + .with(DeleteBiometricPromptCancelledData, 45, 46) + .with(MoveDesktopSettingsMigrator, 46, CURRENT_VERSION); } export async function currentVersion( diff --git a/libs/common/src/state-migrations/migration-helper.spec.ts b/libs/common/src/state-migrations/migration-helper.spec.ts index 3bcf99b2b61..e929877b632 100644 --- a/libs/common/src/state-migrations/migration-helper.spec.ts +++ b/libs/common/src/state-migrations/migration-helper.spec.ts @@ -178,12 +178,9 @@ export function mockMigrationHelper( return mockHelper; } -// TODO: Use const generic for TUsers in TypeScript 5.0 so consumers don't have to `as const` themselves export type InitialDataHint<TUsers extends readonly string[]> = { /** * A string array of the users id who are authenticated - * - * NOTE: It's recommended to as const this string array so you get type help defining the users data */ authenticatedAccounts?: TUsers; /** @@ -282,10 +279,9 @@ function expectInjectedData( * @param initalData The data to start with * @returns State after your migration has ran. */ -// TODO: Use const generic for TUsers in TypeScript 5.0 so consumers don't have to `as const` themselves export async function runMigrator< TMigrator extends Migrator<number, number>, - TUsers extends readonly string[] = string[], + const TUsers extends readonly string[], >( migrator: TMigrator, initalData?: InitialDataHint<TUsers>, diff --git a/libs/common/src/state-migrations/migrations/47-move-desktop-settings.spec.ts b/libs/common/src/state-migrations/migrations/47-move-desktop-settings.spec.ts new file mode 100644 index 00000000000..41080d024ea --- /dev/null +++ b/libs/common/src/state-migrations/migrations/47-move-desktop-settings.spec.ts @@ -0,0 +1,116 @@ +import { runMigrator } from "../migration-helper.spec"; + +import { MoveDesktopSettingsMigrator } from "./47-move-desktop-settings"; + +describe("MoveDesktopSettings", () => { + const sut = new MoveDesktopSettingsMigrator(46, 47); + + it("can migrate truthy values", async () => { + const output = await runMigrator(sut, { + authenticatedAccounts: ["user1"], + global: { + window: { + width: 400, + height: 400, + displayBounds: { + height: 200, + width: 200, + x: 200, + y: 200, + }, + }, + enableAlwaysOnTop: true, + enableCloseToTray: true, + enableMinimizeToTray: true, + enableStartToTray: true, + enableTray: true, + openAtLogin: true, + alwaysShowDock: true, + }, + user1: { + settings: { + enableAlwaysOnTop: true, + }, + }, + }); + + expect(output).toEqual({ + authenticatedAccounts: ["user1"], + global: {}, + global_desktopSettings_window: { + width: 400, + height: 400, + displayBounds: { + height: 200, + width: 200, + x: 200, + y: 200, + }, + }, + global_desktopSettings_closeToTray: true, + global_desktopSettings_minimizeToTray: true, + global_desktopSettings_startToTray: true, + global_desktopSettings_trayEnabled: true, + global_desktopSettings_openAtLogin: true, + global_desktopSettings_alwaysShowDock: true, + global_desktopSettings_alwaysOnTop: true, + user1: { + settings: {}, + }, + }); + }); + + it("can migrate falsey values", async () => { + const output = await runMigrator(sut, { + authenticatedAccounts: ["user1"], + global: { + window: null, + enableCloseToTray: false, + enableMinimizeToTray: false, + enableStartToTray: false, + enableTray: false, + openAtLogin: false, + alwaysShowDock: false, + enableAlwaysOnTop: false, + }, + user1: { + settings: { + enableAlwaysOnTop: false, + }, + }, + }); + + expect(output).toEqual({ + authenticatedAccounts: ["user1"], + global: {}, + global_desktopSettings_window: null, + global_desktopSettings_closeToTray: false, + global_desktopSettings_minimizeToTray: false, + global_desktopSettings_startToTray: false, + global_desktopSettings_trayEnabled: false, + global_desktopSettings_openAtLogin: false, + global_desktopSettings_alwaysShowDock: false, + global_desktopSettings_alwaysOnTop: false, + user1: { + settings: {}, + }, + }); + }); + + it("can migrate even if none of our values are found", async () => { + // + const output = await runMigrator(sut, { + authenticatedAccounts: ["user1"] as const, + global: { + anotherSetting: "", + }, + }); + + expect(output).toEqual({ + authenticatedAccounts: ["user1"] as const, + global: { + anotherSetting: "", + }, + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/47-move-desktop-settings.ts b/libs/common/src/state-migrations/migrations/47-move-desktop-settings.ts new file mode 100644 index 00000000000..f6f3ebdfc22 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/47-move-desktop-settings.ts @@ -0,0 +1,128 @@ +import { KeyDefinitionLike, MigrationHelper, StateDefinitionLike } from "../migration-helper"; +import { IRREVERSIBLE, Migrator } from "../migrator"; + +type ExpectedGlobalType = { + window?: object; + enableTray?: boolean; + enableMinimizeToTray?: boolean; + enableCloseToTray?: boolean; + enableStartToTray?: boolean; + openAtLogin?: boolean; + alwaysShowDock?: boolean; + enableAlwaysOnTop?: boolean; +}; + +type ExpectedAccountType = { + settings?: { + enableAlwaysOnTop?: boolean; + }; +}; + +const DESKTOP_SETTINGS_STATE: StateDefinitionLike = { name: "desktopSettings" }; + +const WINDOW_KEY: KeyDefinitionLike = { key: "window", stateDefinition: DESKTOP_SETTINGS_STATE }; + +const CLOSE_TO_TRAY_KEY: KeyDefinitionLike = { + key: "closeToTray", + stateDefinition: DESKTOP_SETTINGS_STATE, +}; +const MINIMIZE_TO_TRAY_KEY: KeyDefinitionLike = { + key: "minimizeToTray", + stateDefinition: DESKTOP_SETTINGS_STATE, +}; +const START_TO_TRAY_KEY: KeyDefinitionLike = { + key: "startToTray", + stateDefinition: DESKTOP_SETTINGS_STATE, +}; +const TRAY_ENABLED_KEY: KeyDefinitionLike = { + key: "trayEnabled", + stateDefinition: DESKTOP_SETTINGS_STATE, +}; +const OPEN_AT_LOGIN_KEY: KeyDefinitionLike = { + key: "openAtLogin", + stateDefinition: DESKTOP_SETTINGS_STATE, +}; +const ALWAYS_SHOW_DOCK_KEY: KeyDefinitionLike = { + key: "alwaysShowDock", + stateDefinition: DESKTOP_SETTINGS_STATE, +}; + +const ALWAYS_ON_TOP_KEY: KeyDefinitionLike = { + key: "alwaysOnTop", + stateDefinition: DESKTOP_SETTINGS_STATE, +}; + +export class MoveDesktopSettingsMigrator extends Migrator<46, 47> { + async migrate(helper: MigrationHelper): Promise<void> { + const legacyGlobal = await helper.get<ExpectedGlobalType>("global"); + + let updatedGlobal = false; + if (legacyGlobal?.window !== undefined) { + await helper.setToGlobal(WINDOW_KEY, legacyGlobal.window); + updatedGlobal = true; + delete legacyGlobal.window; + } + + if (legacyGlobal?.enableCloseToTray != null) { + await helper.setToGlobal(CLOSE_TO_TRAY_KEY, legacyGlobal.enableCloseToTray); + updatedGlobal = true; + delete legacyGlobal.enableCloseToTray; + } + + if (legacyGlobal?.enableMinimizeToTray != null) { + await helper.setToGlobal(MINIMIZE_TO_TRAY_KEY, legacyGlobal.enableMinimizeToTray); + updatedGlobal = true; + delete legacyGlobal.enableMinimizeToTray; + } + + if (legacyGlobal?.enableStartToTray != null) { + await helper.setToGlobal(START_TO_TRAY_KEY, legacyGlobal.enableStartToTray); + updatedGlobal = true; + delete legacyGlobal.enableStartToTray; + } + + if (legacyGlobal?.enableTray != null) { + await helper.setToGlobal(TRAY_ENABLED_KEY, legacyGlobal.enableTray); + updatedGlobal = true; + delete legacyGlobal.enableTray; + } + + if (legacyGlobal?.openAtLogin != null) { + await helper.setToGlobal(OPEN_AT_LOGIN_KEY, legacyGlobal.openAtLogin); + updatedGlobal = true; + delete legacyGlobal.openAtLogin; + } + + if (legacyGlobal?.alwaysShowDock != null) { + await helper.setToGlobal(ALWAYS_SHOW_DOCK_KEY, legacyGlobal.alwaysShowDock); + updatedGlobal = true; + delete legacyGlobal.alwaysShowDock; + } + + if (legacyGlobal?.enableAlwaysOnTop != null) { + await helper.setToGlobal(ALWAYS_ON_TOP_KEY, legacyGlobal.enableAlwaysOnTop); + updatedGlobal = true; + delete legacyGlobal.enableAlwaysOnTop; + } + + if (updatedGlobal) { + await helper.set("global", legacyGlobal); + } + + async function migrateAccount(userId: string, account: ExpectedAccountType) { + // We only migrate the global setting for this, if we find it on the account object + // just delete it. + if (account?.settings?.enableAlwaysOnTop != null) { + delete account.settings.enableAlwaysOnTop; + await helper.set(userId, account); + } + } + + const accounts = await helper.getAccounts<ExpectedAccountType>(); + await Promise.all(accounts.map(({ userId, account }) => migrateAccount(userId, account))); + } + + rollback(helper: MigrationHelper): Promise<void> { + throw IRREVERSIBLE; + } +} From 19c97fb7965164551b04a986b61af1ee58761c37 Mon Sep 17 00:00:00 2001 From: Cesar Gonzalez <cesar.a.gonzalezcs@gmail.com> Date: Thu, 21 Mar 2024 13:40:37 -0500 Subject: [PATCH 6/7] [PM-6948] Fix race condition that breaks autofill within the current tab component (#8393) --- .../components/vault/current-tab.component.ts | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault/current-tab.component.ts b/apps/browser/src/vault/popup/components/vault/current-tab.component.ts index c2817ed50a4..d9cf6550fa5 100644 --- a/apps/browser/src/vault/popup/components/vault/current-tab.component.ts +++ b/apps/browser/src/vault/popup/components/vault/current-tab.component.ts @@ -262,14 +262,6 @@ export class CurrentTabComponent implements OnInit, OnDestroy { this.hostname = Utils.getHostname(this.url); this.pageDetails = []; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - BrowserApi.tabSendMessage(this.tab, { - command: "collectPageDetails", - tab: this.tab, - sender: BroadcasterSubscriptionId, - }); - const otherTypes: CipherType[] = []; const dontShowCards = !(await firstValueFrom(this.vaultSettingsService.showCardsCurrentTab$)); const dontShowIdentities = !(await firstValueFrom( @@ -310,9 +302,18 @@ export class CurrentTabComponent implements OnInit, OnDestroy { } }); - this.loginCiphers = this.loginCiphers.sort((a, b) => - this.cipherService.sortCiphersByLastUsedThenName(a, b), - ); + if (this.loginCiphers.length) { + void BrowserApi.tabSendMessage(this.tab, { + command: "collectPageDetails", + tab: this.tab, + sender: BroadcasterSubscriptionId, + }); + + this.loginCiphers = this.loginCiphers.sort((a, b) => + this.cipherService.sortCiphersByLastUsedThenName(a, b), + ); + } + this.isLoading = this.loaded = true; } From 9a70e63e734af4dd475b38fd34099601eb037d68 Mon Sep 17 00:00:00 2001 From: Matt Gibson <mgibson@bitwarden.com> Date: Thu, 21 Mar 2024 13:42:09 -0500 Subject: [PATCH 7/7] Delete missed state in #8122 (#8428) --- .../src/platform/abstractions/state.service.ts | 2 -- .../src/platform/models/domain/global-state.ts | 1 - .../src/platform/services/state.service.ts | 17 ----------------- 3 files changed, 20 deletions(-) diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index 29a6752e652..b795db73fca 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -246,8 +246,6 @@ export abstract class StateService<T extends Account = Account> { value: { [cipherId: string]: LocalData }, options?: StorageOptions, ) => Promise<void>; - getLocale: (options?: StorageOptions) => Promise<string>; - setLocale: (value: string, options?: StorageOptions) => Promise<void>; getMinimizeOnCopyToClipboard: (options?: StorageOptions) => Promise<boolean>; setMinimizeOnCopyToClipboard: (value: boolean, options?: StorageOptions) => Promise<void>; getOrganizationInvitation: (options?: StorageOptions) => Promise<any>; diff --git a/libs/common/src/platform/models/domain/global-state.ts b/libs/common/src/platform/models/domain/global-state.ts index b5a12d05558..3fd6f38200c 100644 --- a/libs/common/src/platform/models/domain/global-state.ts +++ b/libs/common/src/platform/models/domain/global-state.ts @@ -2,7 +2,6 @@ import { ThemeType } from "../../enums"; export class GlobalState { installedVersion?: string; - locale?: string; organizationInvitation?: any; rememberedEmail?: string; theme?: ThemeType = ThemeType.System; diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index 3fef9785062..6ff1c63b507 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -1171,23 +1171,6 @@ export class StateService< ); } - async getLocale(options?: StorageOptions): Promise<string> { - return ( - await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())) - )?.locale; - } - - async setLocale(value: string, options?: StorageOptions): Promise<void> { - const globals = await this.getGlobals( - this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()), - ); - globals.locale = value; - await this.saveGlobals( - globals, - this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()), - ); - } - async getMinimizeOnCopyToClipboard(options?: StorageOptions): Promise<boolean> { return ( (await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))