From e6fe0d1d13a69436efabaa8d89a065755add0cd2 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Wed, 13 Mar 2024 10:25:39 -0500 Subject: [PATCH] [PM-5539] Migrate ThemingService (#8219) * Update ThemingService * Finish ThemingService * Lint * More Tests & Docs * Refactor to ThemeStateService * Rename File * Fix Import * Remove `type` added to imports * Update InitServices * Fix Test * Remove Unreferenced Code * Remove Unneeded Null Check * Add Ticket Link * Add Back THEMING_DISK * Fix Desktop * Create SYSTEM_THEME_OBSERVABLE * Fix Browser Injection * Update Desktop Manual Access * Fix Default Theme * Update Test --- .eslintignore | 1 - .../notification.background.spec.ts | 3 + .../background/notification.background.ts | 4 +- .../background/overlay.background.spec.ts | 8 +- .../autofill/background/overlay.background.ts | 4 +- .../browser/src/background/main.background.ts | 6 ++ .../src/popup/services/init.service.ts | 6 +- .../src/popup/services/services.module.ts | 15 ++-- .../src/popup/settings/options.component.ts | 8 +- .../src/app/accounts/settings.component.ts | 8 +- .../app/services/desktop-theming.service.ts | 12 --- apps/desktop/src/app/services/init.service.ts | 4 +- .../src/app/services/services.module.ts | 16 ++-- apps/desktop/src/main/window.main.ts | 3 +- .../platform/utils/from-ipc-system-theme.ts | 15 ++++ apps/web/src/app/core/core.module.ts | 12 +++ apps/web/src/app/core/init.service.ts | 4 +- apps/web/src/app/core/state/global-state.ts | 2 - .../src/app/settings/preferences.component.ts | 13 +--- apps/web/src/theme.js | 24 ------ apps/web/src/theme.ts | 37 +++++++++ apps/web/tsconfig.json | 7 +- apps/web/webpack.config.js | 2 +- .../theming/angular-theming.service.ts | 61 +++++++++++++++ .../services/theming/theme-builder.ts | 22 ------ .../src/platform/services/theming/theme.ts | 6 -- .../theming/theming.service.abstraction.ts | 24 ++++-- .../services/theming/theming.service.ts | 69 ---------------- libs/angular/src/services/injection-tokens.ts | 5 ++ .../src/services/jslib-services.module.ts | 28 +++++-- .../platform/abstractions/state.service.ts | 4 +- .../src/platform/services/state.service.ts | 19 +---- .../src/platform/state/state-definitions.ts | 1 + .../platform/theming/theme-state.service.ts | 38 +++++++++ libs/common/src/state-migrations/migrate.ts | 6 +- .../state-migrations/migration-helper.spec.ts | 12 ++- .../35-move-theme-to-state-providers.spec.ts | 78 +++++++++++++++++++ .../35-move-theme-to-state-providers.ts | 31 ++++++++ 38 files changed, 396 insertions(+), 222 deletions(-) delete mode 100644 apps/desktop/src/app/services/desktop-theming.service.ts create mode 100644 apps/desktop/src/platform/utils/from-ipc-system-theme.ts delete mode 100644 apps/web/src/theme.js create mode 100644 apps/web/src/theme.ts create mode 100644 libs/angular/src/platform/services/theming/angular-theming.service.ts delete mode 100644 libs/angular/src/platform/services/theming/theme-builder.ts delete mode 100644 libs/angular/src/platform/services/theming/theme.ts delete mode 100644 libs/angular/src/platform/services/theming/theming.service.ts create mode 100644 libs/common/src/platform/theming/theme-state.service.ts create mode 100644 libs/common/src/state-migrations/migrations/35-move-theme-to-state-providers.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/35-move-theme-to-state-providers.ts diff --git a/.eslintignore b/.eslintignore index 6649dabbc1..68d426174a 100644 --- a/.eslintignore +++ b/.eslintignore @@ -18,7 +18,6 @@ apps/desktop/src/auth/scripts/duo.js apps/web/config.js apps/web/scripts/*.js -apps/web/src/theme.js apps/web/tailwind.config.js apps/cli/config/config.js diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index 7d168780a1..0d8d52df30 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -8,6 +8,7 @@ import { DomainSettingsService } from "@bitwarden/common/autofill/services/domai import { UserNotificationSettingsService } from "@bitwarden/common/autofill/services/user-notification-settings.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service"; +import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; @@ -51,6 +52,7 @@ describe("NotificationBackground", () => { const domainSettingsService = mock(); const environmentService = mock(); const logService = mock(); + const themeStateService = mock(); beforeEach(() => { notificationBackground = new NotificationBackground( @@ -64,6 +66,7 @@ describe("NotificationBackground", () => { domainSettingsService, environmentService, logService, + themeStateService, ); }); diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index 522109f0ed..ddabb01158 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -11,6 +11,7 @@ import { NeverDomains } from "@bitwarden/common/models/domain/domain-service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -77,6 +78,7 @@ export default class NotificationBackground { private domainSettingsService: DomainSettingsService, private environmentService: EnvironmentService, private logService: LogService, + private themeStateService: ThemeStateService, ) {} async init() { @@ -165,7 +167,7 @@ export default class NotificationBackground { const notificationType = notificationQueueMessage.type; const typeData: Record = { isVaultLocked: notificationQueueMessage.wasVaultLocked, - theme: await this.stateService.getTheme(), + theme: await firstValueFrom(this.themeStateService.selectedTheme$), }; switch (notificationType) { diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index 1e4d0c3677..f0da1af11d 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -1,4 +1,5 @@ import { mock, mockReset } from "jest-mock-extended"; +import { of } from "rxjs"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; @@ -10,6 +11,7 @@ import { AutofillSettingsService } from "@bitwarden/common/autofill/services/aut import { ThemeType } from "@bitwarden/common/platform/enums"; import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service"; import { I18nService } from "@bitwarden/common/platform/services/i18n.service"; +import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { SettingsService } from "@bitwarden/common/services/settings.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; @@ -53,6 +55,7 @@ describe("OverlayBackground", () => { const autofillSettingsService = mock(); const i18nService = mock(); const platformUtilsService = mock(); + const themeStateService = mock(); const initOverlayElementPorts = async (options = { initList: true, initButton: true }) => { const { initList, initButton } = options; if (initButton) { @@ -79,12 +82,15 @@ describe("OverlayBackground", () => { autofillSettingsService, i18nService, platformUtilsService, + themeStateService, ); jest .spyOn(overlayBackground as any, "getOverlayVisibility") .mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus); + themeStateService.selectedTheme$ = of(ThemeType.Light); + void overlayBackground.init(); }); @@ -993,7 +999,7 @@ describe("OverlayBackground", () => { }); it("gets the system theme", async () => { - jest.spyOn(overlayBackground["stateService"], "getTheme").mockResolvedValue(ThemeType.System); + themeStateService.selectedTheme$ = of(ThemeType.System); await initOverlayElementPorts({ initList: true, initButton: false }); await flushPromises(); diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 49d27391cf..00be50c328 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -11,6 +11,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { buildCipherIcon } from "@bitwarden/common/vault/icon/build-cipher-icon"; @@ -96,6 +97,7 @@ class OverlayBackground implements OverlayBackgroundInterface { private autofillSettingsService: AutofillSettingsServiceAbstraction, private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, + private themeStateService: ThemeStateService, ) { this.iconsServerUrl = this.environmentService.getIconsUrl(); } @@ -695,7 +697,7 @@ class OverlayBackground implements OverlayBackgroundInterface { command: `initAutofillOverlay${isOverlayListPort ? "List" : "Button"}`, authStatus: await this.getAuthStatus(), styleSheetUrl: chrome.runtime.getURL(`overlay/${isOverlayListPort ? "list" : "button"}.css`), - theme: await this.stateService.getTheme(), + theme: await firstValueFrom(this.themeStateService.selectedTheme$), translations: this.getTranslations(), ciphers: isOverlayListPort ? this.getOverlayCipherData() : null, }); diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 0f4ddd8a20..23160f8a14 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -116,6 +116,7 @@ import { DefaultSingleUserStateProvider } from "@bitwarden/common/platform/state import { DefaultStateProvider } from "@bitwarden/common/platform/state/implementations/default-state.provider"; import { StateEventRegistrarService } from "@bitwarden/common/platform/state/state-event-registrar.service"; /* eslint-enable import/no-restricted-paths */ +import { DefaultThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service"; import { ApiService } from "@bitwarden/common/services/api.service"; import { AuditService } from "@bitwarden/common/services/audit.service"; @@ -450,6 +451,9 @@ export default class MainBackground { async () => this.biometricUnlock(), self, ); + + const themeStateService = new DefaultThemeStateService(this.globalStateProvider); + this.i18nService = new I18nService(BrowserApi.getUILanguage(), this.globalStateProvider); this.cryptoService = new BrowserCryptoService( this.keyGenerationService, @@ -858,6 +862,7 @@ export default class MainBackground { this.domainSettingsService, this.environmentService, this.logService, + themeStateService, ); this.overlayBackground = new OverlayBackground( this.cipherService, @@ -869,6 +874,7 @@ export default class MainBackground { this.autofillSettingsService, this.i18nService, this.platformUtilsService, + themeStateService, ); this.filelessImporterBackground = new FilelessImporterBackground( this.configService, diff --git a/apps/browser/src/popup/services/init.service.ts b/apps/browser/src/popup/services/init.service.ts index aeb5785845..197854f59f 100644 --- a/apps/browser/src/popup/services/init.service.ts +++ b/apps/browser/src/popup/services/init.service.ts @@ -1,4 +1,5 @@ -import { Injectable } from "@angular/core"; +import { DOCUMENT } from "@angular/common"; +import { Inject, Injectable } from "@angular/core"; import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -19,6 +20,7 @@ export class InitService { private logService: LogServiceAbstraction, private themingService: AbstractThemingService, private configService: ConfigService, + @Inject(DOCUMENT) private document: Document, ) {} init() { @@ -34,7 +36,7 @@ export class InitService { } const htmlEl = window.document.documentElement; - await this.themingService.monitorThemeChanges(); + this.themingService.applyThemeChangesTo(this.document); htmlEl.classList.add("locale_" + this.i18nService.translationLocale); // Workaround for slow performance on external monitors on Chrome + MacOS diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 041e260492..5f97b57882 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -3,13 +3,13 @@ import { DomSanitizer } from "@angular/platform-browser"; import { ToastrService } from "ngx-toastr"; import { UnauthGuard as BaseUnauthGuardService } from "@bitwarden/angular/auth/guards"; -import { ThemingService } from "@bitwarden/angular/platform/services/theming/theming.service"; -import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction"; +import { AngularThemingService } from "@bitwarden/angular/platform/services/theming/angular-theming.service"; import { MEMORY_STORAGE, SECURE_STORAGE, OBSERVABLE_DISK_STORAGE, OBSERVABLE_MEMORY_STORAGE, + SYSTEM_THEME_OBSERVABLE, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { @@ -488,11 +488,8 @@ function getBgService(service: keyof MainBackground) { deps: [StateServiceAbstraction], }, { - provide: AbstractThemingService, - useFactory: ( - stateService: StateServiceAbstraction, - platformUtilsService: PlatformUtilsService, - ) => { + provide: SYSTEM_THEME_OBSERVABLE, + useFactory: (platformUtilsService: PlatformUtilsService) => { // Safari doesn't properly handle the (prefers-color-scheme) media query in the popup window, it always returns light. // In Safari, we have to use the background page instead, which comes with limitations like not dynamically changing the extension theme when the system theme is changed. let windowContext = window; @@ -501,9 +498,9 @@ function getBgService(service: keyof MainBackground) { windowContext = backgroundWindow; } - return new ThemingService(stateService, windowContext, document); + return AngularThemingService.createSystemThemeFromWindow(windowContext); }, - deps: [StateServiceAbstraction, PlatformUtilsService], + deps: [PlatformUtilsService], }, { provide: ConfigService, diff --git a/apps/browser/src/popup/settings/options.component.ts b/apps/browser/src/popup/settings/options.component.ts index eca37e3315..813eeda144 100644 --- a/apps/browser/src/popup/settings/options.component.ts +++ b/apps/browser/src/popup/settings/options.component.ts @@ -1,7 +1,6 @@ import { Component, OnInit } from "@angular/core"; import { firstValueFrom } from "rxjs"; -import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction"; import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { BadgeSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/badge-settings.service"; @@ -16,6 +15,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { ThemeType } from "@bitwarden/common/platform/enums"; +import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { enableAccountSwitching } from "../../platform/flags"; @@ -57,7 +57,7 @@ export class OptionsComponent implements OnInit { private domainSettingsService: DomainSettingsService, private badgeSettingsService: BadgeSettingsServiceAbstraction, i18nService: I18nService, - private themingService: AbstractThemingService, + private themeStateService: ThemeStateService, private settingsService: SettingsService, private vaultSettingsService: VaultSettingsService, ) { @@ -125,7 +125,7 @@ export class OptionsComponent implements OnInit { this.enablePasskeys = await firstValueFrom(this.vaultSettingsService.enablePasskeys$); - this.theme = await this.stateService.getTheme(); + this.theme = await firstValueFrom(this.themeStateService.selectedTheme$); const defaultUriMatch = await firstValueFrom( this.domainSettingsService.defaultUriMatchStrategy$, @@ -186,7 +186,7 @@ export class OptionsComponent implements OnInit { } async saveTheme() { - await this.themingService.updateConfiguredTheme(this.theme); + await this.themeStateService.setSelectedTheme(this.theme); } async saveClearClipboard() { diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index c594d5aced..e7c860b01a 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -3,7 +3,6 @@ import { FormBuilder } from "@angular/forms"; import { BehaviorSubject, firstValueFrom, Observable, Subject } from "rxjs"; import { concatMap, debounceTime, filter, map, switchMap, takeUntil, tap } from "rxjs/operators"; -import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; @@ -21,6 +20,7 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; import { ThemeType, KeySuffixOptions } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { DialogService } from "@bitwarden/components"; import { SetPinComponent } from "../../auth/components/set-pin.component"; @@ -116,7 +116,7 @@ export class SettingsComponent implements OnInit { private messagingService: MessagingService, private cryptoService: CryptoService, private modalService: ModalService, - private themingService: AbstractThemingService, + private themeStateService: ThemeStateService, private settingsService: SettingsService, private dialogService: DialogService, private userVerificationService: UserVerificationServiceAbstraction, @@ -263,7 +263,7 @@ export class SettingsComponent implements OnInit { await this.stateService.getEnableBrowserIntegrationFingerprint(), enableDuckDuckGoBrowserIntegration: await this.stateService.getEnableDuckDuckGoBrowserIntegration(), - theme: await this.stateService.getTheme(), + theme: await firstValueFrom(this.themeStateService.selectedTheme$), locale: await firstValueFrom(this.i18nService.locale$), }; this.form.setValue(initialValues, { emitEvent: false }); @@ -557,7 +557,7 @@ export class SettingsComponent implements OnInit { } async saveTheme() { - await this.themingService.updateConfiguredTheme(this.form.value.theme); + await this.themeStateService.setSelectedTheme(this.form.value.theme); } async saveMinOnCopyToClipboard() { diff --git a/apps/desktop/src/app/services/desktop-theming.service.ts b/apps/desktop/src/app/services/desktop-theming.service.ts deleted file mode 100644 index 005ef4ccc0..0000000000 --- a/apps/desktop/src/app/services/desktop-theming.service.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ThemingService } from "@bitwarden/angular/platform/services/theming/theming.service"; -import { ThemeType } from "@bitwarden/common/platform/enums"; - -export class DesktopThemingService extends ThemingService { - protected async getSystemTheme(): Promise { - return await ipc.platform.getSystemTheme(); - } - - protected monitorSystemThemeChanges(): void { - ipc.platform.onSystemThemeUpdated((theme: ThemeType) => this.updateSystemTheme(theme)); - } -} diff --git a/apps/desktop/src/app/services/init.service.ts b/apps/desktop/src/app/services/init.service.ts index 56361499ea..a29dadff30 100644 --- a/apps/desktop/src/app/services/init.service.ts +++ b/apps/desktop/src/app/services/init.service.ts @@ -1,3 +1,4 @@ +import { DOCUMENT } from "@angular/common"; import { Inject, Injectable } from "@angular/core"; import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction"; @@ -38,6 +39,7 @@ export class InitService { private themingService: AbstractThemingService, private encryptService: EncryptService, private configService: ConfigService, + @Inject(DOCUMENT) private document: Document, ) {} init() { @@ -58,7 +60,7 @@ export class InitService { setTimeout(() => this.notificationsService.init(), 3000); const htmlEl = this.win.document.documentElement; htmlEl.classList.add("os_" + this.platformUtilsService.getDeviceString()); - await this.themingService.monitorThemeChanges(); + this.themingService.applyThemeChangesTo(this.document); let installAction = null; const installedVersion = await this.stateService.getInstalledVersion(); const currentVersion = await this.platformUtilsService.getApplicationVersion(); diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 7a64408b5e..38a44401ee 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -1,8 +1,5 @@ -import { DOCUMENT } from "@angular/common"; import { APP_INITIALIZER, InjectionToken, NgModule } from "@angular/core"; -import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction"; -import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { SECURE_STORAGE, STATE_FACTORY, @@ -13,7 +10,7 @@ import { OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE, WINDOW, - SafeInjectionToken, + SYSTEM_THEME_OBSERVABLE, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; @@ -63,13 +60,13 @@ import { ElectronRendererSecureStorageService } from "../../platform/services/el import { ElectronRendererStorageService } from "../../platform/services/electron-renderer-storage.service"; import { ElectronStateService } from "../../platform/services/electron-state.service"; import { I18nRendererService } from "../../platform/services/i18n.renderer.service"; +import { fromIpcSystemTheme } from "../../platform/utils/from-ipc-system-theme"; import { EncryptedMessageHandlerService } from "../../services/encrypted-message-handler.service"; import { NativeMessageHandlerService } from "../../services/native-message-handler.service"; import { NativeMessagingService } from "../../services/native-messaging.service"; import { SearchBarService } from "../layout/search/search-bar.service"; import { DesktopFileDownloadService } from "./desktop-file-download.service"; -import { DesktopThemingService } from "./desktop-theming.service"; import { InitService } from "./init.service"; import { RendererCryptoFunctionService } from "./renderer-crypto-function.service"; @@ -151,11 +148,10 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK"); provide: FileDownloadService, useClass: DesktopFileDownloadService, }, - safeProvider({ - provide: AbstractThemingService, - useClass: DesktopThemingService, - deps: [StateServiceAbstraction, WINDOW, DOCUMENT as SafeInjectionToken], - }), + { + provide: SYSTEM_THEME_OBSERVABLE, + useFactory: () => fromIpcSystemTheme(), + }, { provide: EncryptedMessageHandlerService, deps: [ diff --git a/apps/desktop/src/main/window.main.ts b/apps/desktop/src/main/window.main.ts index 644e4d5f7d..cdce2a692c 100644 --- a/apps/desktop/src/main/window.main.ts +++ b/apps/desktop/src/main/window.main.ts @@ -246,8 +246,7 @@ export class WindowMain { // Retrieve the background color // Resolves background color missmatch when starting the application. async getBackgroundColor(): Promise { - const data: { theme?: string } = await this.storageService.get("global"); - let theme = data?.theme; + let theme = await this.storageService.get("global_theming_selection"); if (theme == null || theme === "system") { theme = nativeTheme.shouldUseDarkColors ? "dark" : "light"; diff --git a/apps/desktop/src/platform/utils/from-ipc-system-theme.ts b/apps/desktop/src/platform/utils/from-ipc-system-theme.ts new file mode 100644 index 0000000000..fe0e05a620 --- /dev/null +++ b/apps/desktop/src/platform/utils/from-ipc-system-theme.ts @@ -0,0 +1,15 @@ +import { defer, fromEventPattern, merge } from "rxjs"; + +import { ThemeType } from "@bitwarden/common/platform/enums"; + +/** + * @returns An observable watching the system theme via IPC channels + */ +export const fromIpcSystemTheme = () => { + return merge( + defer(() => ipc.platform.getSystemTheme()), + fromEventPattern((handler) => + ipc.platform.onSystemThemeUpdated((theme) => handler(theme)), + ), + ); +}; diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 556cfadd35..f44ab9f09d 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -23,6 +23,7 @@ import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/comm import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; +import { ThemeType } from "@bitwarden/common/platform/enums"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; @@ -32,6 +33,10 @@ import { StorageServiceProvider } from "@bitwarden/common/platform/services/stor import { GlobalStateProvider } from "@bitwarden/common/platform/state"; import { MemoryStorageService as MemoryStorageServiceForStateProviders } from "@bitwarden/common/platform/state/storage/memory-storage.service"; /* eslint-enable import/no-restricted-paths -- Implementation for memory storage */ +import { + DefaultThemeStateService, + ThemeStateService, +} from "@bitwarden/common/platform/theming/theme-state.service"; import { PolicyListService } from "../admin-console/core/policy-list.service"; import { HtmlStorageService } from "../core/html-storage.service"; @@ -133,6 +138,13 @@ import { WebPlatformUtilsService } from "./web-platform-utils.service"; OBSERVABLE_DISK_LOCAL_STORAGE, ], }, + { + provide: ThemeStateService, + useFactory: (globalStateProvider: GlobalStateProvider) => + // Web chooses to have Light as the default theme + new DefaultThemeStateService(globalStateProvider, ThemeType.Light), + deps: [GlobalStateProvider], + }, ], }) export class CoreModule { diff --git a/apps/web/src/app/core/init.service.ts b/apps/web/src/app/core/init.service.ts index 8a834e25b4..f4768e0ee6 100644 --- a/apps/web/src/app/core/init.service.ts +++ b/apps/web/src/app/core/init.service.ts @@ -1,3 +1,4 @@ +import { DOCUMENT } from "@angular/common"; import { Inject, Injectable } from "@angular/core"; import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction"; @@ -35,6 +36,7 @@ export class InitService { private themingService: AbstractThemingService, private encryptService: EncryptService, private configService: ConfigService, + @Inject(DOCUMENT) private document: Document, ) {} init() { @@ -55,7 +57,7 @@ export class InitService { this.twoFactorService.init(); const htmlEl = this.win.document.documentElement; htmlEl.classList.add("locale_" + this.i18nService.translationLocale); - await this.themingService.monitorThemeChanges(); + this.themingService.applyThemeChangesTo(this.document); const containerService = new ContainerService(this.cryptoService, this.encryptService); containerService.attachToGlobal(this.win); diff --git a/apps/web/src/app/core/state/global-state.ts b/apps/web/src/app/core/state/global-state.ts index 9bdcb2f879..49e7608749 100644 --- a/apps/web/src/app/core/state/global-state.ts +++ b/apps/web/src/app/core/state/global-state.ts @@ -1,7 +1,5 @@ -import { ThemeType } from "@bitwarden/common/platform/enums"; import { GlobalState as BaseGlobalState } from "@bitwarden/common/platform/models/domain/global-state"; export class GlobalState extends BaseGlobalState { - theme?: ThemeType = ThemeType.Light; rememberEmail = true; } diff --git a/apps/web/src/app/settings/preferences.component.ts b/apps/web/src/app/settings/preferences.component.ts index 7047430dff..dab89819fc 100644 --- a/apps/web/src/app/settings/preferences.component.ts +++ b/apps/web/src/app/settings/preferences.component.ts @@ -2,7 +2,6 @@ import { Component, OnInit } from "@angular/core"; import { FormBuilder } from "@angular/forms"; import { concatMap, filter, firstValueFrom, map, Observable, Subject, takeUntil, tap } from "rxjs"; -import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction"; import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -14,6 +13,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { ThemeType } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { DialogService } from "@bitwarden/components"; @Component({ @@ -35,7 +35,6 @@ export class PreferencesComponent implements OnInit { themeOptions: any[]; private startingLocale: string; - private startingTheme: ThemeType; private destroy$ = new Subject(); form = this.formBuilder.group({ @@ -54,7 +53,7 @@ export class PreferencesComponent implements OnInit { private vaultTimeoutSettingsService: VaultTimeoutSettingsService, private platformUtilsService: PlatformUtilsService, private messagingService: MessagingService, - private themingService: AbstractThemingService, + private themeStateService: ThemeStateService, private settingsService: SettingsService, private dialogService: DialogService, ) { @@ -141,11 +140,10 @@ export class PreferencesComponent implements OnInit { this.vaultTimeoutSettingsService.vaultTimeoutAction$(), ), enableFavicons: !(await this.settingsService.getDisableFavicon()), - theme: await this.stateService.getTheme(), + theme: await firstValueFrom(this.themeStateService.selectedTheme$), locale: (await firstValueFrom(this.i18nService.locale$)) ?? null, }; this.startingLocale = initialFormValues.locale; - this.startingTheme = initialFormValues.theme; this.form.setValue(initialFormValues, { emitEvent: false }); } @@ -165,10 +163,7 @@ export class PreferencesComponent implements OnInit { values.vaultTimeoutAction, ); await this.settingsService.setDisableFavicon(!values.enableFavicons); - if (values.theme !== this.startingTheme) { - await this.themingService.updateConfiguredTheme(values.theme); - this.startingTheme = values.theme; - } + await this.themeStateService.setSelectedTheme(values.theme); await this.i18nService.setLocale(values.locale); if (values.locale !== this.startingLocale) { window.location.reload(); diff --git a/apps/web/src/theme.js b/apps/web/src/theme.js deleted file mode 100644 index 3fa49c6b37..0000000000 --- a/apps/web/src/theme.js +++ /dev/null @@ -1,24 +0,0 @@ -// Set theme on page load -// This is done outside the Angular app to avoid a flash of unthemed content before it loads -// The defaultTheme is also set in the html itself to make sure that some theming is always applied -(function () { - const defaultTheme = "light"; - const htmlEl = document.documentElement; - let theme = defaultTheme; - - const globalState = window.localStorage.getItem("global"); - if (globalState != null) { - const globalStateJson = JSON.parse(globalState); - if (globalStateJson != null && globalStateJson.theme != null) { - if (globalStateJson.theme.indexOf("system") > -1) { - theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; - } else if (globalStateJson.theme.indexOf("dark") > -1) { - theme = "dark"; - } - } - if (!htmlEl.classList.contains("theme_" + theme)) { - htmlEl.classList.remove("theme_" + defaultTheme); - htmlEl.classList.add("theme_" + theme); - } - } -})(); diff --git a/apps/web/src/theme.ts b/apps/web/src/theme.ts new file mode 100644 index 0000000000..57193fadee --- /dev/null +++ b/apps/web/src/theme.ts @@ -0,0 +1,37 @@ +// Set theme on page load +// This is done outside the Angular app to avoid a flash of unthemed content before it loads +const setTheme = () => { + const getLegacyTheme = (): string | null => { + // MANUAL-STATE-ACCESS: Calling global to get setting before migration + // has had a chance to run, can be remove in the future. + // Tracking Issue: https://bitwarden.atlassian.net/browse/PM-6676 + const globalState = window.localStorage.getItem("global"); + + const parsedGlobalState = JSON.parse(globalState) as { theme?: string } | null; + return parsedGlobalState ? parsedGlobalState.theme : null; + }; + + const defaultTheme = "light"; + const htmlEl = document.documentElement; + let theme = defaultTheme; + + // First try the new state providers location, then the legacy location + const themeFromState = + window.localStorage.getItem("global_theming_selection") ?? getLegacyTheme(); + + if (themeFromState) { + if (themeFromState.indexOf("system") > -1) { + theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } else if (themeFromState.indexOf("dark") > -1) { + theme = "dark"; + } + } + + if (!htmlEl.classList.contains("theme_" + theme)) { + // The defaultTheme is also set in the html itself to make sure that some theming is always applied + htmlEl.classList.remove("theme_" + defaultTheme); + htmlEl.classList.add("theme_" + theme); + } +}; + +setTheme(); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 3add11f84c..ba8060b93a 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -26,7 +26,12 @@ "strictTemplates": true, "preserveWhitespaces": true }, - "files": ["src/polyfills.ts", "src/main.ts", "../../bitwarden_license/bit-web/src/main.ts"], + "files": [ + "src/polyfills.ts", + "src/main.ts", + "../../bitwarden_license/bit-web/src/main.ts", + "src/theme.ts" + ], "include": [ "src/connectors/*.ts", "src/**/*.stories.ts", diff --git a/apps/web/webpack.config.js b/apps/web/webpack.config.js index aeaf4c577c..b62299a7a7 100644 --- a/apps/web/webpack.config.js +++ b/apps/web/webpack.config.js @@ -323,7 +323,7 @@ const webpackConfig = { "connectors/sso": "./src/connectors/sso.ts", "connectors/captcha": "./src/connectors/captcha.ts", "connectors/duo-redirect": "./src/connectors/duo-redirect.ts", - theme_head: "./src/theme.js", + theme_head: "./src/theme.ts", }, optimization: { splitChunks: { diff --git a/libs/angular/src/platform/services/theming/angular-theming.service.ts b/libs/angular/src/platform/services/theming/angular-theming.service.ts new file mode 100644 index 0000000000..d0f96eb4a7 --- /dev/null +++ b/libs/angular/src/platform/services/theming/angular-theming.service.ts @@ -0,0 +1,61 @@ +import { Inject, Injectable } from "@angular/core"; +import { fromEvent, map, merge, Observable, of, Subscription, switchMap } from "rxjs"; + +import { ThemeType } from "@bitwarden/common/platform/enums"; +import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; + +import { SYSTEM_THEME_OBSERVABLE } from "../../../services/injection-tokens"; + +import { AbstractThemingService } from "./theming.service.abstraction"; + +@Injectable() +export class AngularThemingService implements AbstractThemingService { + /** + * Creates a system theme observable based on watching the given window. + * @param window The window that should be watched for system theme changes. + * @returns An observable that will track the system theme. + */ + static createSystemThemeFromWindow(window: Window): Observable { + return merge( + // This observable should always emit at least once, so go and get the current system theme designation + of( + window.matchMedia("(prefers-color-scheme: dark)").matches + ? ThemeType.Dark + : ThemeType.Light, + ), + // Start listening to changes + fromEvent( + window.matchMedia("(prefers-color-scheme: dark)"), + "change", + ).pipe(map((event) => (event.matches ? ThemeType.Dark : ThemeType.Light))), + ); + } + + readonly theme$ = this.themeStateService.selectedTheme$.pipe( + switchMap((configuredTheme) => { + if (configuredTheme === ThemeType.System) { + return this.systemTheme$; + } + + return of(configuredTheme); + }), + ); + + constructor( + private themeStateService: ThemeStateService, + @Inject(SYSTEM_THEME_OBSERVABLE) + private systemTheme$: Observable, + ) {} + + applyThemeChangesTo(document: Document): Subscription { + return this.theme$.subscribe((theme) => { + document.documentElement.classList.remove( + "theme_" + ThemeType.Light, + "theme_" + ThemeType.Dark, + "theme_" + ThemeType.Nord, + "theme_" + ThemeType.SolarizedDark, + ); + document.documentElement.classList.add("theme_" + theme); + }); + } +} diff --git a/libs/angular/src/platform/services/theming/theme-builder.ts b/libs/angular/src/platform/services/theming/theme-builder.ts deleted file mode 100644 index 01ba66093a..0000000000 --- a/libs/angular/src/platform/services/theming/theme-builder.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ThemeType } from "@bitwarden/common/platform/enums"; - -import { Theme } from "./theme"; - -export class ThemeBuilder implements Theme { - get effectiveTheme(): ThemeType { - return this.configuredTheme != ThemeType.System ? this.configuredTheme : this.systemTheme; - } - - constructor( - readonly configuredTheme: ThemeType, - readonly systemTheme: ThemeType, - ) {} - - updateSystemTheme(systemTheme: ThemeType): ThemeBuilder { - return new ThemeBuilder(this.configuredTheme, systemTheme); - } - - updateConfiguredTheme(configuredTheme: ThemeType): ThemeBuilder { - return new ThemeBuilder(configuredTheme, this.systemTheme); - } -} diff --git a/libs/angular/src/platform/services/theming/theme.ts b/libs/angular/src/platform/services/theming/theme.ts deleted file mode 100644 index 87b9f0c216..0000000000 --- a/libs/angular/src/platform/services/theming/theme.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ThemeType } from "@bitwarden/common/platform/enums"; - -export interface Theme { - configuredTheme: ThemeType; - effectiveTheme: ThemeType; -} diff --git a/libs/angular/src/platform/services/theming/theming.service.abstraction.ts b/libs/angular/src/platform/services/theming/theming.service.abstraction.ts index aaf67a9252..9a012a7f75 100644 --- a/libs/angular/src/platform/services/theming/theming.service.abstraction.ts +++ b/libs/angular/src/platform/services/theming/theming.service.abstraction.ts @@ -1,12 +1,22 @@ -import { Observable } from "rxjs"; +import { Observable, Subscription } from "rxjs"; import { ThemeType } from "@bitwarden/common/platform/enums"; -import { Theme } from "./theme"; - +/** + * A service for managing and observing the current application theme. + */ +// FIXME: Rename to ThemingService export abstract class AbstractThemingService { - theme$: Observable; - monitorThemeChanges: () => Promise; - updateSystemTheme: (systemTheme: ThemeType) => void; - updateConfiguredTheme: (theme: ThemeType) => Promise; + /** + * The effective theme based on the user configured choice and the current system theme if + * the configured choice is {@link ThemeType.System}. + */ + theme$: Observable; + /** + * Listens for effective theme changes and applies changes to the provided document. + * @param document The document that should have theme classes applied to it. + * + * @returns A subscription that can be unsubscribed from to cancel the application of theme classes. + */ + applyThemeChangesTo: (document: Document) => Subscription; } diff --git a/libs/angular/src/platform/services/theming/theming.service.ts b/libs/angular/src/platform/services/theming/theming.service.ts deleted file mode 100644 index 5274abb607..0000000000 --- a/libs/angular/src/platform/services/theming/theming.service.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { BehaviorSubject, filter, fromEvent, Observable } from "rxjs"; - -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { ThemeType } from "@bitwarden/common/platform/enums"; - -import { Theme } from "./theme"; -import { ThemeBuilder } from "./theme-builder"; -import { AbstractThemingService } from "./theming.service.abstraction"; - -export class ThemingService implements AbstractThemingService { - private _theme = new BehaviorSubject(null); - theme$: Observable = this._theme.pipe(filter((x) => x !== null)); - - constructor( - private stateService: StateService, - private window: Window, - private document: Document, - ) { - // 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.monitorThemeChanges(); - } - - async monitorThemeChanges(): Promise { - this._theme.next( - new ThemeBuilder(await this.stateService.getTheme(), await this.getSystemTheme()), - ); - this.monitorConfiguredThemeChanges(); - this.monitorSystemThemeChanges(); - } - - updateSystemTheme(systemTheme: ThemeType): void { - this._theme.next(this._theme.getValue().updateSystemTheme(systemTheme)); - } - - async updateConfiguredTheme(theme: ThemeType): Promise { - await this.stateService.setTheme(theme); - this._theme.next(this._theme.getValue().updateConfiguredTheme(theme)); - } - - protected monitorConfiguredThemeChanges(): void { - this.theme$.subscribe((theme: Theme) => { - this.document.documentElement.classList.remove( - "theme_" + ThemeType.Light, - "theme_" + ThemeType.Dark, - "theme_" + ThemeType.Nord, - "theme_" + ThemeType.SolarizedDark, - ); - this.document.documentElement.classList.add("theme_" + theme.effectiveTheme); - }); - } - - // We use a media match query for monitoring the system theme on web and browser, but this doesn't work for electron apps on Linux. - // In desktop we override these methods to track systemTheme with the electron renderer instead, which works for all OSs. - protected async getSystemTheme(): Promise { - return this.window.matchMedia("(prefers-color-scheme: dark)").matches - ? ThemeType.Dark - : ThemeType.Light; - } - - protected monitorSystemThemeChanges(): void { - fromEvent( - window.matchMedia("(prefers-color-scheme: dark)"), - "change", - ).subscribe((event) => { - this.updateSystemTheme(event.matches ? ThemeType.Dark : ThemeType.Light); - }); - } -} diff --git a/libs/angular/src/services/injection-tokens.ts b/libs/angular/src/services/injection-tokens.ts index b2c2299c34..50aafc2326 100644 --- a/libs/angular/src/services/injection-tokens.ts +++ b/libs/angular/src/services/injection-tokens.ts @@ -1,10 +1,12 @@ import { InjectionToken } from "@angular/core"; +import { Observable } from "rxjs"; import { AbstractMemoryStorageService, AbstractStorageService, ObservableStorageService, } from "@bitwarden/common/platform/abstractions/storage.service"; +import { ThemeType } from "@bitwarden/common/platform/enums"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; declare const tag: unique symbol; @@ -43,3 +45,6 @@ export const LOCKED_CALLBACK = new SafeInjectionToken<(userId?: string) => Promi export const LOCALES_DIRECTORY = new SafeInjectionToken("LOCALES_DIRECTORY"); export const SYSTEM_LANGUAGE = new SafeInjectionToken("SYSTEM_LANGUAGE"); export const LOG_MAC_FAILURES = new SafeInjectionToken("LOG_MAC_FAILURES"); +export const SYSTEM_THEME_OBSERVABLE = new SafeInjectionToken>( + "SYSTEM_THEME_OBSERVABLE", +); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index a568befc71..743aead0ca 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -1,4 +1,3 @@ -import { DOCUMENT } from "@angular/common"; import { LOCALE_ID, NgModule } from "@angular/core"; import { UnwrapOpaque } from "type-fest"; @@ -160,6 +159,10 @@ import { DefaultStateProvider } from "@bitwarden/common/platform/state/implement import { StateEventRegistrarService } from "@bitwarden/common/platform/state/state-event-registrar.service"; import { StateEventRunnerService } from "@bitwarden/common/platform/state/state-event-runner.service"; /* eslint-enable import/no-restricted-paths */ +import { + DefaultThemeStateService, + ThemeStateService, +} from "@bitwarden/common/platform/theming/theme-state.service"; import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service"; import { ApiService } from "@bitwarden/common/services/api.service"; import { AuditService } from "@bitwarden/common/services/audit.service"; @@ -231,7 +234,7 @@ import { UnauthGuard } from "../auth/guards/unauth.guard"; import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service"; import { BroadcasterService } from "../platform/services/broadcaster.service"; import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service"; -import { ThemingService } from "../platform/services/theming/theming.service"; +import { AngularThemingService } from "../platform/services/theming/angular-theming.service"; import { AbstractThemingService } from "../platform/services/theming/theming.service.abstraction"; import { safeProvider, SafeProvider } from "../platform/utils/safe-provider"; @@ -248,6 +251,7 @@ import { STATE_FACTORY, STATE_SERVICE_USE_CACHE, SYSTEM_LANGUAGE, + SYSTEM_THEME_OBSERVABLE, WINDOW, } from "./injection-tokens"; import { ModalService } from "./modal.service"; @@ -300,6 +304,21 @@ const typesafeProviders: Array = [ provide: LOG_MAC_FAILURES, useValue: true, }), + safeProvider({ + provide: SYSTEM_THEME_OBSERVABLE, + useFactory: (window: Window) => AngularThemingService.createSystemThemeFromWindow(window), + deps: [WINDOW], + }), + safeProvider({ + provide: ThemeStateService, + useClass: DefaultThemeStateService, + deps: [GlobalStateProvider], + }), + safeProvider({ + provide: AbstractThemingService, + useClass: AngularThemingService, + deps: [ThemeStateService, SYSTEM_THEME_OBSERVABLE], + }), safeProvider({ provide: AppIdServiceAbstraction, useClass: AppIdService, @@ -772,11 +791,6 @@ const typesafeProviders: Array = [ useClass: TwoFactorService, deps: [I18nServiceAbstraction, PlatformUtilsServiceAbstraction], }), - safeProvider({ - provide: AbstractThemingService, - useClass: ThemingService, - deps: [StateServiceAbstraction, WINDOW, DOCUMENT as SafeInjectionToken], - }), safeProvider({ provide: FormValidationErrorsServiceAbstraction, useClass: FormValidationErrorsService, diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index a1dbd52d3b..d9b18e509b 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -18,7 +18,7 @@ import { CipherData } from "../../vault/models/data/cipher.data"; import { LocalData } from "../../vault/models/data/local.data"; import { CipherView } from "../../vault/models/view/cipher.view"; import { AddEditCipherInfo } from "../../vault/types/add-edit-cipher-info"; -import { KdfType, ThemeType } from "../enums"; +import { KdfType } from "../enums"; import { ServerConfigData } from "../models/data/server-config.data"; import { Account, AccountDecryptionOptions } from "../models/domain/account"; import { EncString } from "../models/domain/enc-string"; @@ -342,8 +342,6 @@ export abstract class StateService { setRememberedEmail: (value: string, options?: StorageOptions) => Promise; getSecurityStamp: (options?: StorageOptions) => Promise; setSecurityStamp: (value: string, options?: StorageOptions) => Promise; - getTheme: (options?: StorageOptions) => Promise; - setTheme: (value: ThemeType, options?: StorageOptions) => Promise; getTwoFactorToken: (options?: StorageOptions) => Promise; setTwoFactorToken: (value: string, options?: StorageOptions) => Promise; getUserId: (options?: StorageOptions) => Promise; diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index 53a2a92f97..d7d302db4c 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -32,7 +32,7 @@ import { AbstractMemoryStorageService, AbstractStorageService, } from "../abstractions/storage.service"; -import { HtmlStorageLocation, KdfType, StorageLocation, ThemeType } from "../enums"; +import { HtmlStorageLocation, KdfType, StorageLocation } from "../enums"; import { StateFactory } from "../factories/state-factory"; import { Utils } from "../misc/utils"; import { ServerConfigData } from "../models/data/server-config.data"; @@ -1754,23 +1754,6 @@ export class StateService< ); } - async getTheme(options?: StorageOptions): Promise { - return ( - await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())) - )?.theme; - } - - async setTheme(value: ThemeType, options?: StorageOptions): Promise { - const globals = await this.getGlobals( - this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()), - ); - globals.theme = value; - await this.saveGlobals( - globals, - this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()), - ); - } - async getTwoFactorToken(options?: StorageOptions): Promise { return ( await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())) diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 9eb8e6d669..ef5d9d7721 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -63,6 +63,7 @@ export const CLEAR_EVENT_DISK = new StateDefinition("clearEvent", "disk"); export const CRYPTO_DISK = new StateDefinition("crypto", "disk"); export const CRYPTO_MEMORY = new StateDefinition("crypto", "memory"); export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk"); +export const THEMING_DISK = new StateDefinition("theming", "disk"); export const TRANSLATION_DISK = new StateDefinition("translation", "disk"); // Secrets Manager diff --git a/libs/common/src/platform/theming/theme-state.service.ts b/libs/common/src/platform/theming/theme-state.service.ts new file mode 100644 index 0000000000..42b5b1770c --- /dev/null +++ b/libs/common/src/platform/theming/theme-state.service.ts @@ -0,0 +1,38 @@ +import { Observable, map } from "rxjs"; + +import { ThemeType } from "../enums"; +import { GlobalStateProvider, KeyDefinition, THEMING_DISK } from "../state"; + +export abstract class ThemeStateService { + /** + * The users selected theme. + */ + selectedTheme$: Observable; + + /** + * A method for updating the current users configured theme. + * @param theme The chosen user theme. + */ + setSelectedTheme: (theme: ThemeType) => Promise; +} + +const THEME_SELECTION = new KeyDefinition(THEMING_DISK, "selection", { + deserializer: (s) => s, +}); + +export class DefaultThemeStateService implements ThemeStateService { + private readonly selectedThemeState = this.globalStateProvider.get(THEME_SELECTION); + + selectedTheme$ = this.selectedThemeState.state$.pipe(map((theme) => theme ?? this.defaultTheme)); + + constructor( + private globalStateProvider: GlobalStateProvider, + private defaultTheme: ThemeType = ThemeType.System, + ) {} + + async setSelectedTheme(theme: ThemeType): Promise { + await this.selectedThemeState.update(() => theme, { + shouldUpdate: (currentTheme) => currentTheme !== theme, + }); + } +} diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index 5064968ef5..6fb7c0288c 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -30,6 +30,7 @@ import { EnableContextMenuMigrator } from "./migrations/31-move-enable-context-m import { PreferredLanguageMigrator } from "./migrations/32-move-preferred-language"; import { AppIdMigrator } from "./migrations/33-move-app-id-to-state-providers"; import { DomainSettingsMigrator } from "./migrations/34-move-domain-settings-to-state-providers"; +import { MoveThemeToStateProviderMigrator } from "./migrations/35-move-theme-to-state-providers"; import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked"; import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; @@ -39,7 +40,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 2; -export const CURRENT_VERSION = 34; +export const CURRENT_VERSION = 35; export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { @@ -76,7 +77,8 @@ export function createMigrationBuilder() { .with(EnableContextMenuMigrator, 30, 31) .with(PreferredLanguageMigrator, 31, 32) .with(AppIdMigrator, 32, 33) - .with(DomainSettingsMigrator, 33, CURRENT_VERSION); + .with(DomainSettingsMigrator, 33, 34) + .with(MoveThemeToStateProviderMigrator, 34, 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 5736bd7e6c..3bcf99b2b6 100644 --- a/libs/common/src/state-migrations/migration-helper.spec.ts +++ b/libs/common/src/state-migrations/migration-helper.spec.ts @@ -286,7 +286,11 @@ function expectInjectedData( export async function runMigrator< TMigrator extends Migrator, TUsers extends readonly string[] = string[], ->(migrator: TMigrator, initalData?: InitialDataHint): Promise> { +>( + migrator: TMigrator, + initalData?: InitialDataHint, + direction: "migrate" | "rollback" = "migrate", +): Promise> { // Inject fake data at every level of the object const allInjectedData = injectData(initalData, []); @@ -294,7 +298,11 @@ export async function runMigrator< const helper = new MigrationHelper(migrator.fromVersion, fakeStorageService, mock()); // Run their migrations - await migrator.migrate(helper); + if (direction === "rollback") { + await migrator.rollback(helper); + } else { + await migrator.migrate(helper); + } const [data, leftoverInjectedData] = expectInjectedData( fakeStorageService.internalStore, allInjectedData, diff --git a/libs/common/src/state-migrations/migrations/35-move-theme-to-state-providers.spec.ts b/libs/common/src/state-migrations/migrations/35-move-theme-to-state-providers.spec.ts new file mode 100644 index 0000000000..14a8b3403d --- /dev/null +++ b/libs/common/src/state-migrations/migrations/35-move-theme-to-state-providers.spec.ts @@ -0,0 +1,78 @@ +import { runMigrator } from "../migration-helper.spec"; + +import { MoveThemeToStateProviderMigrator } from "./35-move-theme-to-state-providers"; + +describe("MoveThemeToStateProviders", () => { + const sut = new MoveThemeToStateProviderMigrator(34, 35); + + describe("migrate", () => { + it("migrates global theme and deletes it", async () => { + const output = await runMigrator(sut, { + global: { + theme: "dark", + }, + }); + + expect(output).toEqual({ + global_theming_selection: "dark", + global: {}, + }); + }); + + it.each([{}, null])( + "doesn't touch it if global state looks like: '%s'", + async (globalState) => { + const output = await runMigrator(sut, { + global: globalState, + }); + + expect(output).toEqual({ + global: globalState, + }); + }, + ); + }); + + describe("rollback", () => { + it("migrates state provider theme back to original location when no global", async () => { + const output = await runMigrator( + sut, + { + global_theming_selection: "disk", + }, + "rollback", + ); + + expect(output).toEqual({ + global: { + theme: "disk", + }, + }); + }); + + it("migrates state provider theme back to legacy location when there is an existing global object", async () => { + const output = await runMigrator( + sut, + { + global_theming_selection: "disk", + global: { + other: "stuff", + }, + }, + "rollback", + ); + + expect(output).toEqual({ + global: { + theme: "disk", + other: "stuff", + }, + }); + }); + + it("does nothing if no theme in state provider location", async () => { + const output = await runMigrator(sut, {}, "rollback"); + expect(output).toEqual({}); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/35-move-theme-to-state-providers.ts b/libs/common/src/state-migrations/migrations/35-move-theme-to-state-providers.ts new file mode 100644 index 0000000000..7811cdb3c7 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/35-move-theme-to-state-providers.ts @@ -0,0 +1,31 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +type ExpectedGlobal = { theme?: string }; + +const THEME_SELECTION: KeyDefinitionLike = { + key: "selection", + stateDefinition: { name: "theming" }, +}; + +export class MoveThemeToStateProviderMigrator extends Migrator<34, 35> { + async migrate(helper: MigrationHelper): Promise { + const legacyGlobalState = await helper.get("global"); + const theme = legacyGlobalState?.theme; + if (theme != null) { + await helper.setToGlobal(THEME_SELECTION, theme); + delete legacyGlobalState.theme; + await helper.set("global", legacyGlobalState); + } + } + + async rollback(helper: MigrationHelper): Promise { + const theme = await helper.getFromGlobal(THEME_SELECTION); + if (theme != null) { + const legacyGlobal = (await helper.get("global")) ?? {}; + legacyGlobal.theme = theme; + await helper.set("global", legacyGlobal); + await helper.removeFromGlobal(THEME_SELECTION); + } + } +}