1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-24 12:06:15 +01:00

[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
This commit is contained in:
Justin Baur 2024-03-13 10:25:39 -05:00 committed by GitHub
parent 531ae3184f
commit e6fe0d1d13
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 396 additions and 222 deletions

View File

@ -18,7 +18,6 @@ apps/desktop/src/auth/scripts/duo.js
apps/web/config.js apps/web/config.js
apps/web/scripts/*.js apps/web/scripts/*.js
apps/web/src/theme.js
apps/web/tailwind.config.js apps/web/tailwind.config.js
apps/cli/config/config.js apps/cli/config/config.js

View File

@ -8,6 +8,7 @@ import { DomainSettingsService } from "@bitwarden/common/autofill/services/domai
import { UserNotificationSettingsService } from "@bitwarden/common/autofill/services/user-notification-settings.service"; import { UserNotificationSettingsService } from "@bitwarden/common/autofill/services/user-notification-settings.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { EnvironmentService } from "@bitwarden/common/platform/services/environment.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 { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
@ -51,6 +52,7 @@ describe("NotificationBackground", () => {
const domainSettingsService = mock<DomainSettingsService>(); const domainSettingsService = mock<DomainSettingsService>();
const environmentService = mock<EnvironmentService>(); const environmentService = mock<EnvironmentService>();
const logService = mock<LogService>(); const logService = mock<LogService>();
const themeStateService = mock<ThemeStateService>();
beforeEach(() => { beforeEach(() => {
notificationBackground = new NotificationBackground( notificationBackground = new NotificationBackground(
@ -64,6 +66,7 @@ describe("NotificationBackground", () => {
domainSettingsService, domainSettingsService,
environmentService, environmentService,
logService, logService,
themeStateService,
); );
}); });

View File

@ -11,6 +11,7 @@ import { NeverDomains } from "@bitwarden/common/models/domain/domain-service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils"; 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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
@ -77,6 +78,7 @@ export default class NotificationBackground {
private domainSettingsService: DomainSettingsService, private domainSettingsService: DomainSettingsService,
private environmentService: EnvironmentService, private environmentService: EnvironmentService,
private logService: LogService, private logService: LogService,
private themeStateService: ThemeStateService,
) {} ) {}
async init() { async init() {
@ -165,7 +167,7 @@ export default class NotificationBackground {
const notificationType = notificationQueueMessage.type; const notificationType = notificationQueueMessage.type;
const typeData: Record<string, any> = { const typeData: Record<string, any> = {
isVaultLocked: notificationQueueMessage.wasVaultLocked, isVaultLocked: notificationQueueMessage.wasVaultLocked,
theme: await this.stateService.getTheme(), theme: await firstValueFrom(this.themeStateService.selectedTheme$),
}; };
switch (notificationType) { switch (notificationType) {

View File

@ -1,4 +1,5 @@
import { mock, mockReset } from "jest-mock-extended"; import { mock, mockReset } from "jest-mock-extended";
import { of } from "rxjs";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { AuthService } from "@bitwarden/common/auth/services/auth.service"; 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 { ThemeType } from "@bitwarden/common/platform/enums";
import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service";
import { I18nService } from "@bitwarden/common/platform/services/i18n.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 { SettingsService } from "@bitwarden/common/services/settings.service";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
@ -53,6 +55,7 @@ describe("OverlayBackground", () => {
const autofillSettingsService = mock<AutofillSettingsService>(); const autofillSettingsService = mock<AutofillSettingsService>();
const i18nService = mock<I18nService>(); const i18nService = mock<I18nService>();
const platformUtilsService = mock<BrowserPlatformUtilsService>(); const platformUtilsService = mock<BrowserPlatformUtilsService>();
const themeStateService = mock<ThemeStateService>();
const initOverlayElementPorts = async (options = { initList: true, initButton: true }) => { const initOverlayElementPorts = async (options = { initList: true, initButton: true }) => {
const { initList, initButton } = options; const { initList, initButton } = options;
if (initButton) { if (initButton) {
@ -79,12 +82,15 @@ describe("OverlayBackground", () => {
autofillSettingsService, autofillSettingsService,
i18nService, i18nService,
platformUtilsService, platformUtilsService,
themeStateService,
); );
jest jest
.spyOn(overlayBackground as any, "getOverlayVisibility") .spyOn(overlayBackground as any, "getOverlayVisibility")
.mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus); .mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus);
themeStateService.selectedTheme$ = of(ThemeType.Light);
void overlayBackground.init(); void overlayBackground.init();
}); });
@ -993,7 +999,7 @@ describe("OverlayBackground", () => {
}); });
it("gets the system theme", async () => { 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 initOverlayElementPorts({ initList: true, initButton: false });
await flushPromises(); await flushPromises();

View File

@ -11,6 +11,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils"; 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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
import { buildCipherIcon } from "@bitwarden/common/vault/icon/build-cipher-icon"; import { buildCipherIcon } from "@bitwarden/common/vault/icon/build-cipher-icon";
@ -96,6 +97,7 @@ class OverlayBackground implements OverlayBackgroundInterface {
private autofillSettingsService: AutofillSettingsServiceAbstraction, private autofillSettingsService: AutofillSettingsServiceAbstraction,
private i18nService: I18nService, private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private themeStateService: ThemeStateService,
) { ) {
this.iconsServerUrl = this.environmentService.getIconsUrl(); this.iconsServerUrl = this.environmentService.getIconsUrl();
} }
@ -695,7 +697,7 @@ class OverlayBackground implements OverlayBackgroundInterface {
command: `initAutofillOverlay${isOverlayListPort ? "List" : "Button"}`, command: `initAutofillOverlay${isOverlayListPort ? "List" : "Button"}`,
authStatus: await this.getAuthStatus(), authStatus: await this.getAuthStatus(),
styleSheetUrl: chrome.runtime.getURL(`overlay/${isOverlayListPort ? "list" : "button"}.css`), styleSheetUrl: chrome.runtime.getURL(`overlay/${isOverlayListPort ? "list" : "button"}.css`),
theme: await this.stateService.getTheme(), theme: await firstValueFrom(this.themeStateService.selectedTheme$),
translations: this.getTranslations(), translations: this.getTranslations(),
ciphers: isOverlayListPort ? this.getOverlayCipherData() : null, ciphers: isOverlayListPort ? this.getOverlayCipherData() : null,
}); });

View File

@ -116,6 +116,7 @@ import { DefaultSingleUserStateProvider } from "@bitwarden/common/platform/state
import { DefaultStateProvider } from "@bitwarden/common/platform/state/implementations/default-state.provider"; import { DefaultStateProvider } from "@bitwarden/common/platform/state/implementations/default-state.provider";
import { StateEventRegistrarService } from "@bitwarden/common/platform/state/state-event-registrar.service"; import { StateEventRegistrarService } from "@bitwarden/common/platform/state/state-event-registrar.service";
/* eslint-enable import/no-restricted-paths */ /* 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 { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service";
import { ApiService } from "@bitwarden/common/services/api.service"; import { ApiService } from "@bitwarden/common/services/api.service";
import { AuditService } from "@bitwarden/common/services/audit.service"; import { AuditService } from "@bitwarden/common/services/audit.service";
@ -450,6 +451,9 @@ export default class MainBackground {
async () => this.biometricUnlock(), async () => this.biometricUnlock(),
self, self,
); );
const themeStateService = new DefaultThemeStateService(this.globalStateProvider);
this.i18nService = new I18nService(BrowserApi.getUILanguage(), this.globalStateProvider); this.i18nService = new I18nService(BrowserApi.getUILanguage(), this.globalStateProvider);
this.cryptoService = new BrowserCryptoService( this.cryptoService = new BrowserCryptoService(
this.keyGenerationService, this.keyGenerationService,
@ -858,6 +862,7 @@ export default class MainBackground {
this.domainSettingsService, this.domainSettingsService,
this.environmentService, this.environmentService,
this.logService, this.logService,
themeStateService,
); );
this.overlayBackground = new OverlayBackground( this.overlayBackground = new OverlayBackground(
this.cipherService, this.cipherService,
@ -869,6 +874,7 @@ export default class MainBackground {
this.autofillSettingsService, this.autofillSettingsService,
this.i18nService, this.i18nService,
this.platformUtilsService, this.platformUtilsService,
themeStateService,
); );
this.filelessImporterBackground = new FilelessImporterBackground( this.filelessImporterBackground = new FilelessImporterBackground(
this.configService, this.configService,

View File

@ -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 { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -19,6 +20,7 @@ export class InitService {
private logService: LogServiceAbstraction, private logService: LogServiceAbstraction,
private themingService: AbstractThemingService, private themingService: AbstractThemingService,
private configService: ConfigService, private configService: ConfigService,
@Inject(DOCUMENT) private document: Document,
) {} ) {}
init() { init() {
@ -34,7 +36,7 @@ export class InitService {
} }
const htmlEl = window.document.documentElement; const htmlEl = window.document.documentElement;
await this.themingService.monitorThemeChanges(); this.themingService.applyThemeChangesTo(this.document);
htmlEl.classList.add("locale_" + this.i18nService.translationLocale); htmlEl.classList.add("locale_" + this.i18nService.translationLocale);
// Workaround for slow performance on external monitors on Chrome + MacOS // Workaround for slow performance on external monitors on Chrome + MacOS

View File

@ -3,13 +3,13 @@ import { DomSanitizer } from "@angular/platform-browser";
import { ToastrService } from "ngx-toastr"; import { ToastrService } from "ngx-toastr";
import { UnauthGuard as BaseUnauthGuardService } from "@bitwarden/angular/auth/guards"; import { UnauthGuard as BaseUnauthGuardService } from "@bitwarden/angular/auth/guards";
import { ThemingService } from "@bitwarden/angular/platform/services/theming/theming.service"; import { AngularThemingService } from "@bitwarden/angular/platform/services/theming/angular-theming.service";
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
import { import {
MEMORY_STORAGE, MEMORY_STORAGE,
SECURE_STORAGE, SECURE_STORAGE,
OBSERVABLE_DISK_STORAGE, OBSERVABLE_DISK_STORAGE,
OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_MEMORY_STORAGE,
SYSTEM_THEME_OBSERVABLE,
} from "@bitwarden/angular/services/injection-tokens"; } from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import { import {
@ -488,11 +488,8 @@ function getBgService<T>(service: keyof MainBackground) {
deps: [StateServiceAbstraction], deps: [StateServiceAbstraction],
}, },
{ {
provide: AbstractThemingService, provide: SYSTEM_THEME_OBSERVABLE,
useFactory: ( useFactory: (platformUtilsService: PlatformUtilsService) => {
stateService: StateServiceAbstraction,
platformUtilsService: PlatformUtilsService,
) => {
// Safari doesn't properly handle the (prefers-color-scheme) media query in the popup window, it always returns light. // 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. // 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; let windowContext = window;
@ -501,9 +498,9 @@ function getBgService<T>(service: keyof MainBackground) {
windowContext = backgroundWindow; windowContext = backgroundWindow;
} }
return new ThemingService(stateService, windowContext, document); return AngularThemingService.createSystemThemeFromWindow(windowContext);
}, },
deps: [StateServiceAbstraction, PlatformUtilsService], deps: [PlatformUtilsService],
}, },
{ {
provide: ConfigService, provide: ConfigService,

View File

@ -1,7 +1,6 @@
import { Component, OnInit } from "@angular/core"; import { Component, OnInit } from "@angular/core";
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { SettingsService } from "@bitwarden/common/abstractions/settings.service";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { BadgeSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/badge-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 { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { ThemeType } from "@bitwarden/common/platform/enums"; 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 { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { enableAccountSwitching } from "../../platform/flags"; import { enableAccountSwitching } from "../../platform/flags";
@ -57,7 +57,7 @@ export class OptionsComponent implements OnInit {
private domainSettingsService: DomainSettingsService, private domainSettingsService: DomainSettingsService,
private badgeSettingsService: BadgeSettingsServiceAbstraction, private badgeSettingsService: BadgeSettingsServiceAbstraction,
i18nService: I18nService, i18nService: I18nService,
private themingService: AbstractThemingService, private themeStateService: ThemeStateService,
private settingsService: SettingsService, private settingsService: SettingsService,
private vaultSettingsService: VaultSettingsService, private vaultSettingsService: VaultSettingsService,
) { ) {
@ -125,7 +125,7 @@ export class OptionsComponent implements OnInit {
this.enablePasskeys = await firstValueFrom(this.vaultSettingsService.enablePasskeys$); this.enablePasskeys = await firstValueFrom(this.vaultSettingsService.enablePasskeys$);
this.theme = await this.stateService.getTheme(); this.theme = await firstValueFrom(this.themeStateService.selectedTheme$);
const defaultUriMatch = await firstValueFrom( const defaultUriMatch = await firstValueFrom(
this.domainSettingsService.defaultUriMatchStrategy$, this.domainSettingsService.defaultUriMatchStrategy$,
@ -186,7 +186,7 @@ export class OptionsComponent implements OnInit {
} }
async saveTheme() { async saveTheme() {
await this.themingService.updateConfiguredTheme(this.theme); await this.themeStateService.setSelectedTheme(this.theme);
} }
async saveClearClipboard() { async saveClearClipboard() {

View File

@ -3,7 +3,6 @@ import { FormBuilder } from "@angular/forms";
import { BehaviorSubject, firstValueFrom, Observable, Subject } from "rxjs"; import { BehaviorSubject, firstValueFrom, Observable, Subject } from "rxjs";
import { concatMap, debounceTime, filter, map, switchMap, takeUntil, tap } from "rxjs/operators"; 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 { ModalService } from "@bitwarden/angular/services/modal.service";
import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { SettingsService } from "@bitwarden/common/abstractions/settings.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-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 { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service";
import { ThemeType, KeySuffixOptions } from "@bitwarden/common/platform/enums"; import { ThemeType, KeySuffixOptions } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { SetPinComponent } from "../../auth/components/set-pin.component"; import { SetPinComponent } from "../../auth/components/set-pin.component";
@ -116,7 +116,7 @@ export class SettingsComponent implements OnInit {
private messagingService: MessagingService, private messagingService: MessagingService,
private cryptoService: CryptoService, private cryptoService: CryptoService,
private modalService: ModalService, private modalService: ModalService,
private themingService: AbstractThemingService, private themeStateService: ThemeStateService,
private settingsService: SettingsService, private settingsService: SettingsService,
private dialogService: DialogService, private dialogService: DialogService,
private userVerificationService: UserVerificationServiceAbstraction, private userVerificationService: UserVerificationServiceAbstraction,
@ -263,7 +263,7 @@ export class SettingsComponent implements OnInit {
await this.stateService.getEnableBrowserIntegrationFingerprint(), await this.stateService.getEnableBrowserIntegrationFingerprint(),
enableDuckDuckGoBrowserIntegration: enableDuckDuckGoBrowserIntegration:
await this.stateService.getEnableDuckDuckGoBrowserIntegration(), await this.stateService.getEnableDuckDuckGoBrowserIntegration(),
theme: await this.stateService.getTheme(), theme: await firstValueFrom(this.themeStateService.selectedTheme$),
locale: await firstValueFrom(this.i18nService.locale$), locale: await firstValueFrom(this.i18nService.locale$),
}; };
this.form.setValue(initialValues, { emitEvent: false }); this.form.setValue(initialValues, { emitEvent: false });
@ -557,7 +557,7 @@ export class SettingsComponent implements OnInit {
} }
async saveTheme() { async saveTheme() {
await this.themingService.updateConfiguredTheme(this.form.value.theme); await this.themeStateService.setSelectedTheme(this.form.value.theme);
} }
async saveMinOnCopyToClipboard() { async saveMinOnCopyToClipboard() {

View File

@ -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<ThemeType> {
return await ipc.platform.getSystemTheme();
}
protected monitorSystemThemeChanges(): void {
ipc.platform.onSystemThemeUpdated((theme: ThemeType) => this.updateSystemTheme(theme));
}
}

View File

@ -1,3 +1,4 @@
import { DOCUMENT } from "@angular/common";
import { Inject, Injectable } from "@angular/core"; import { Inject, Injectable } from "@angular/core";
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction"; import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
@ -38,6 +39,7 @@ export class InitService {
private themingService: AbstractThemingService, private themingService: AbstractThemingService,
private encryptService: EncryptService, private encryptService: EncryptService,
private configService: ConfigService, private configService: ConfigService,
@Inject(DOCUMENT) private document: Document,
) {} ) {}
init() { init() {
@ -58,7 +60,7 @@ export class InitService {
setTimeout(() => this.notificationsService.init(), 3000); setTimeout(() => this.notificationsService.init(), 3000);
const htmlEl = this.win.document.documentElement; const htmlEl = this.win.document.documentElement;
htmlEl.classList.add("os_" + this.platformUtilsService.getDeviceString()); htmlEl.classList.add("os_" + this.platformUtilsService.getDeviceString());
await this.themingService.monitorThemeChanges(); this.themingService.applyThemeChangesTo(this.document);
let installAction = null; let installAction = null;
const installedVersion = await this.stateService.getInstalledVersion(); const installedVersion = await this.stateService.getInstalledVersion();
const currentVersion = await this.platformUtilsService.getApplicationVersion(); const currentVersion = await this.platformUtilsService.getApplicationVersion();

View File

@ -1,8 +1,5 @@
import { DOCUMENT } from "@angular/common";
import { APP_INITIALIZER, InjectionToken, NgModule } from "@angular/core"; 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 { import {
SECURE_STORAGE, SECURE_STORAGE,
STATE_FACTORY, STATE_FACTORY,
@ -13,7 +10,7 @@ import {
OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_MEMORY_STORAGE,
OBSERVABLE_DISK_STORAGE, OBSERVABLE_DISK_STORAGE,
WINDOW, WINDOW,
SafeInjectionToken, SYSTEM_THEME_OBSERVABLE,
} from "@bitwarden/angular/services/injection-tokens"; } from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; 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 { ElectronRendererStorageService } from "../../platform/services/electron-renderer-storage.service";
import { ElectronStateService } from "../../platform/services/electron-state.service"; import { ElectronStateService } from "../../platform/services/electron-state.service";
import { I18nRendererService } from "../../platform/services/i18n.renderer.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 { EncryptedMessageHandlerService } from "../../services/encrypted-message-handler.service";
import { NativeMessageHandlerService } from "../../services/native-message-handler.service"; import { NativeMessageHandlerService } from "../../services/native-message-handler.service";
import { NativeMessagingService } from "../../services/native-messaging.service"; import { NativeMessagingService } from "../../services/native-messaging.service";
import { SearchBarService } from "../layout/search/search-bar.service"; import { SearchBarService } from "../layout/search/search-bar.service";
import { DesktopFileDownloadService } from "./desktop-file-download.service"; import { DesktopFileDownloadService } from "./desktop-file-download.service";
import { DesktopThemingService } from "./desktop-theming.service";
import { InitService } from "./init.service"; import { InitService } from "./init.service";
import { RendererCryptoFunctionService } from "./renderer-crypto-function.service"; import { RendererCryptoFunctionService } from "./renderer-crypto-function.service";
@ -151,11 +148,10 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK");
provide: FileDownloadService, provide: FileDownloadService,
useClass: DesktopFileDownloadService, useClass: DesktopFileDownloadService,
}, },
safeProvider({ {
provide: AbstractThemingService, provide: SYSTEM_THEME_OBSERVABLE,
useClass: DesktopThemingService, useFactory: () => fromIpcSystemTheme(),
deps: [StateServiceAbstraction, WINDOW, DOCUMENT as SafeInjectionToken<Document>], },
}),
{ {
provide: EncryptedMessageHandlerService, provide: EncryptedMessageHandlerService,
deps: [ deps: [

View File

@ -246,8 +246,7 @@ export class WindowMain {
// Retrieve the background color // Retrieve the background color
// Resolves background color missmatch when starting the application. // Resolves background color missmatch when starting the application.
async getBackgroundColor(): Promise<string> { async getBackgroundColor(): Promise<string> {
const data: { theme?: string } = await this.storageService.get("global"); let theme = await this.storageService.get("global_theming_selection");
let theme = data?.theme;
if (theme == null || theme === "system") { if (theme == null || theme === "system") {
theme = nativeTheme.shouldUseDarkColors ? "dark" : "light"; theme = nativeTheme.shouldUseDarkColors ? "dark" : "light";

View File

@ -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<ThemeType>((handler) =>
ipc.platform.onSystemThemeUpdated((theme) => handler(theme)),
),
);
};

View File

@ -23,6 +23,7 @@ import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/comm
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.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 { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.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 { GlobalStateProvider } from "@bitwarden/common/platform/state";
import { MemoryStorageService as MemoryStorageServiceForStateProviders } from "@bitwarden/common/platform/state/storage/memory-storage.service"; import { MemoryStorageService as MemoryStorageServiceForStateProviders } from "@bitwarden/common/platform/state/storage/memory-storage.service";
/* eslint-enable import/no-restricted-paths -- Implementation for memory storage */ /* 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 { PolicyListService } from "../admin-console/core/policy-list.service";
import { HtmlStorageService } from "../core/html-storage.service"; import { HtmlStorageService } from "../core/html-storage.service";
@ -133,6 +138,13 @@ import { WebPlatformUtilsService } from "./web-platform-utils.service";
OBSERVABLE_DISK_LOCAL_STORAGE, 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 { export class CoreModule {

View File

@ -1,3 +1,4 @@
import { DOCUMENT } from "@angular/common";
import { Inject, Injectable } from "@angular/core"; import { Inject, Injectable } from "@angular/core";
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction"; import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
@ -35,6 +36,7 @@ export class InitService {
private themingService: AbstractThemingService, private themingService: AbstractThemingService,
private encryptService: EncryptService, private encryptService: EncryptService,
private configService: ConfigService, private configService: ConfigService,
@Inject(DOCUMENT) private document: Document,
) {} ) {}
init() { init() {
@ -55,7 +57,7 @@ export class InitService {
this.twoFactorService.init(); this.twoFactorService.init();
const htmlEl = this.win.document.documentElement; const htmlEl = this.win.document.documentElement;
htmlEl.classList.add("locale_" + this.i18nService.translationLocale); htmlEl.classList.add("locale_" + this.i18nService.translationLocale);
await this.themingService.monitorThemeChanges(); this.themingService.applyThemeChangesTo(this.document);
const containerService = new ContainerService(this.cryptoService, this.encryptService); const containerService = new ContainerService(this.cryptoService, this.encryptService);
containerService.attachToGlobal(this.win); containerService.attachToGlobal(this.win);

View File

@ -1,7 +1,5 @@
import { ThemeType } from "@bitwarden/common/platform/enums";
import { GlobalState as BaseGlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { GlobalState as BaseGlobalState } from "@bitwarden/common/platform/models/domain/global-state";
export class GlobalState extends BaseGlobalState { export class GlobalState extends BaseGlobalState {
theme?: ThemeType = ThemeType.Light;
rememberEmail = true; rememberEmail = true;
} }

View File

@ -2,7 +2,6 @@ import { Component, OnInit } from "@angular/core";
import { FormBuilder } from "@angular/forms"; import { FormBuilder } from "@angular/forms";
import { concatMap, filter, firstValueFrom, map, Observable, Subject, takeUntil, tap } from "rxjs"; 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 { SettingsService } from "@bitwarden/common/abstractions/settings.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-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"; 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 { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { ThemeType } from "@bitwarden/common/platform/enums"; import { ThemeType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
@Component({ @Component({
@ -35,7 +35,6 @@ export class PreferencesComponent implements OnInit {
themeOptions: any[]; themeOptions: any[];
private startingLocale: string; private startingLocale: string;
private startingTheme: ThemeType;
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
form = this.formBuilder.group({ form = this.formBuilder.group({
@ -54,7 +53,7 @@ export class PreferencesComponent implements OnInit {
private vaultTimeoutSettingsService: VaultTimeoutSettingsService, private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private messagingService: MessagingService, private messagingService: MessagingService,
private themingService: AbstractThemingService, private themeStateService: ThemeStateService,
private settingsService: SettingsService, private settingsService: SettingsService,
private dialogService: DialogService, private dialogService: DialogService,
) { ) {
@ -141,11 +140,10 @@ export class PreferencesComponent implements OnInit {
this.vaultTimeoutSettingsService.vaultTimeoutAction$(), this.vaultTimeoutSettingsService.vaultTimeoutAction$(),
), ),
enableFavicons: !(await this.settingsService.getDisableFavicon()), enableFavicons: !(await this.settingsService.getDisableFavicon()),
theme: await this.stateService.getTheme(), theme: await firstValueFrom(this.themeStateService.selectedTheme$),
locale: (await firstValueFrom(this.i18nService.locale$)) ?? null, locale: (await firstValueFrom(this.i18nService.locale$)) ?? null,
}; };
this.startingLocale = initialFormValues.locale; this.startingLocale = initialFormValues.locale;
this.startingTheme = initialFormValues.theme;
this.form.setValue(initialFormValues, { emitEvent: false }); this.form.setValue(initialFormValues, { emitEvent: false });
} }
@ -165,10 +163,7 @@ export class PreferencesComponent implements OnInit {
values.vaultTimeoutAction, values.vaultTimeoutAction,
); );
await this.settingsService.setDisableFavicon(!values.enableFavicons); await this.settingsService.setDisableFavicon(!values.enableFavicons);
if (values.theme !== this.startingTheme) { await this.themeStateService.setSelectedTheme(values.theme);
await this.themingService.updateConfiguredTheme(values.theme);
this.startingTheme = values.theme;
}
await this.i18nService.setLocale(values.locale); await this.i18nService.setLocale(values.locale);
if (values.locale !== this.startingLocale) { if (values.locale !== this.startingLocale) {
window.location.reload(); window.location.reload();

View File

@ -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);
}
}
})();

37
apps/web/src/theme.ts Normal file
View File

@ -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();

View File

@ -26,7 +26,12 @@
"strictTemplates": true, "strictTemplates": true,
"preserveWhitespaces": 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": [ "include": [
"src/connectors/*.ts", "src/connectors/*.ts",
"src/**/*.stories.ts", "src/**/*.stories.ts",

View File

@ -323,7 +323,7 @@ const webpackConfig = {
"connectors/sso": "./src/connectors/sso.ts", "connectors/sso": "./src/connectors/sso.ts",
"connectors/captcha": "./src/connectors/captcha.ts", "connectors/captcha": "./src/connectors/captcha.ts",
"connectors/duo-redirect": "./src/connectors/duo-redirect.ts", "connectors/duo-redirect": "./src/connectors/duo-redirect.ts",
theme_head: "./src/theme.js", theme_head: "./src/theme.ts",
}, },
optimization: { optimization: {
splitChunks: { splitChunks: {

View File

@ -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<ThemeType> {
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<MediaQueryListEvent>(
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<ThemeType>,
) {}
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);
});
}
}

View File

@ -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);
}
}

View File

@ -1,6 +0,0 @@
import { ThemeType } from "@bitwarden/common/platform/enums";
export interface Theme {
configuredTheme: ThemeType;
effectiveTheme: ThemeType;
}

View File

@ -1,12 +1,22 @@
import { Observable } from "rxjs"; import { Observable, Subscription } from "rxjs";
import { ThemeType } from "@bitwarden/common/platform/enums"; 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 { export abstract class AbstractThemingService {
theme$: Observable<Theme>; /**
monitorThemeChanges: () => Promise<void>; * The effective theme based on the user configured choice and the current system theme if
updateSystemTheme: (systemTheme: ThemeType) => void; * the configured choice is {@link ThemeType.System}.
updateConfiguredTheme: (theme: ThemeType) => Promise<void>; */
theme$: Observable<ThemeType>;
/**
* 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;
} }

View File

@ -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<ThemeBuilder | null>(null);
theme$: Observable<Theme> = 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<void> {
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<void> {
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<ThemeType> {
return this.window.matchMedia("(prefers-color-scheme: dark)").matches
? ThemeType.Dark
: ThemeType.Light;
}
protected monitorSystemThemeChanges(): void {
fromEvent<MediaQueryListEvent>(
window.matchMedia("(prefers-color-scheme: dark)"),
"change",
).subscribe((event) => {
this.updateSystemTheme(event.matches ? ThemeType.Dark : ThemeType.Light);
});
}
}

View File

@ -1,10 +1,12 @@
import { InjectionToken } from "@angular/core"; import { InjectionToken } from "@angular/core";
import { Observable } from "rxjs";
import { import {
AbstractMemoryStorageService, AbstractMemoryStorageService,
AbstractStorageService, AbstractStorageService,
ObservableStorageService, ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service"; } from "@bitwarden/common/platform/abstractions/storage.service";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
declare const tag: unique symbol; declare const tag: unique symbol;
@ -43,3 +45,6 @@ export const LOCKED_CALLBACK = new SafeInjectionToken<(userId?: string) => Promi
export const LOCALES_DIRECTORY = new SafeInjectionToken<string>("LOCALES_DIRECTORY"); export const LOCALES_DIRECTORY = new SafeInjectionToken<string>("LOCALES_DIRECTORY");
export const SYSTEM_LANGUAGE = new SafeInjectionToken<string>("SYSTEM_LANGUAGE"); export const SYSTEM_LANGUAGE = new SafeInjectionToken<string>("SYSTEM_LANGUAGE");
export const LOG_MAC_FAILURES = new SafeInjectionToken<boolean>("LOG_MAC_FAILURES"); export const LOG_MAC_FAILURES = new SafeInjectionToken<boolean>("LOG_MAC_FAILURES");
export const SYSTEM_THEME_OBSERVABLE = new SafeInjectionToken<Observable<ThemeType>>(
"SYSTEM_THEME_OBSERVABLE",
);

View File

@ -1,4 +1,3 @@
import { DOCUMENT } from "@angular/common";
import { LOCALE_ID, NgModule } from "@angular/core"; import { LOCALE_ID, NgModule } from "@angular/core";
import { UnwrapOpaque } from "type-fest"; 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 { StateEventRegistrarService } from "@bitwarden/common/platform/state/state-event-registrar.service";
import { StateEventRunnerService } from "@bitwarden/common/platform/state/state-event-runner.service"; import { StateEventRunnerService } from "@bitwarden/common/platform/state/state-event-runner.service";
/* eslint-enable import/no-restricted-paths */ /* 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 { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service";
import { ApiService } from "@bitwarden/common/services/api.service"; import { ApiService } from "@bitwarden/common/services/api.service";
import { AuditService } from "@bitwarden/common/services/audit.service"; import { AuditService } from "@bitwarden/common/services/audit.service";
@ -231,7 +234,7 @@ import { UnauthGuard } from "../auth/guards/unauth.guard";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service"; import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
import { BroadcasterService } from "../platform/services/broadcaster.service"; import { BroadcasterService } from "../platform/services/broadcaster.service";
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.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 { AbstractThemingService } from "../platform/services/theming/theming.service.abstraction";
import { safeProvider, SafeProvider } from "../platform/utils/safe-provider"; import { safeProvider, SafeProvider } from "../platform/utils/safe-provider";
@ -248,6 +251,7 @@ import {
STATE_FACTORY, STATE_FACTORY,
STATE_SERVICE_USE_CACHE, STATE_SERVICE_USE_CACHE,
SYSTEM_LANGUAGE, SYSTEM_LANGUAGE,
SYSTEM_THEME_OBSERVABLE,
WINDOW, WINDOW,
} from "./injection-tokens"; } from "./injection-tokens";
import { ModalService } from "./modal.service"; import { ModalService } from "./modal.service";
@ -300,6 +304,21 @@ const typesafeProviders: Array<SafeProvider> = [
provide: LOG_MAC_FAILURES, provide: LOG_MAC_FAILURES,
useValue: true, 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({ safeProvider({
provide: AppIdServiceAbstraction, provide: AppIdServiceAbstraction,
useClass: AppIdService, useClass: AppIdService,
@ -772,11 +791,6 @@ const typesafeProviders: Array<SafeProvider> = [
useClass: TwoFactorService, useClass: TwoFactorService,
deps: [I18nServiceAbstraction, PlatformUtilsServiceAbstraction], deps: [I18nServiceAbstraction, PlatformUtilsServiceAbstraction],
}), }),
safeProvider({
provide: AbstractThemingService,
useClass: ThemingService,
deps: [StateServiceAbstraction, WINDOW, DOCUMENT as SafeInjectionToken<Document>],
}),
safeProvider({ safeProvider({
provide: FormValidationErrorsServiceAbstraction, provide: FormValidationErrorsServiceAbstraction,
useClass: FormValidationErrorsService, useClass: FormValidationErrorsService,

View File

@ -18,7 +18,7 @@ import { CipherData } from "../../vault/models/data/cipher.data";
import { LocalData } from "../../vault/models/data/local.data"; import { LocalData } from "../../vault/models/data/local.data";
import { CipherView } from "../../vault/models/view/cipher.view"; import { CipherView } from "../../vault/models/view/cipher.view";
import { AddEditCipherInfo } from "../../vault/types/add-edit-cipher-info"; 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 { ServerConfigData } from "../models/data/server-config.data";
import { Account, AccountDecryptionOptions } from "../models/domain/account"; import { Account, AccountDecryptionOptions } from "../models/domain/account";
import { EncString } from "../models/domain/enc-string"; import { EncString } from "../models/domain/enc-string";
@ -342,8 +342,6 @@ export abstract class StateService<T extends Account = Account> {
setRememberedEmail: (value: string, options?: StorageOptions) => Promise<void>; setRememberedEmail: (value: string, options?: StorageOptions) => Promise<void>;
getSecurityStamp: (options?: StorageOptions) => Promise<string>; getSecurityStamp: (options?: StorageOptions) => Promise<string>;
setSecurityStamp: (value: string, options?: StorageOptions) => Promise<void>; setSecurityStamp: (value: string, options?: StorageOptions) => Promise<void>;
getTheme: (options?: StorageOptions) => Promise<ThemeType>;
setTheme: (value: ThemeType, options?: StorageOptions) => Promise<void>;
getTwoFactorToken: (options?: StorageOptions) => Promise<string>; getTwoFactorToken: (options?: StorageOptions) => Promise<string>;
setTwoFactorToken: (value: string, options?: StorageOptions) => Promise<void>; setTwoFactorToken: (value: string, options?: StorageOptions) => Promise<void>;
getUserId: (options?: StorageOptions) => Promise<string>; getUserId: (options?: StorageOptions) => Promise<string>;

View File

@ -32,7 +32,7 @@ import {
AbstractMemoryStorageService, AbstractMemoryStorageService,
AbstractStorageService, AbstractStorageService,
} from "../abstractions/storage.service"; } from "../abstractions/storage.service";
import { HtmlStorageLocation, KdfType, StorageLocation, ThemeType } from "../enums"; import { HtmlStorageLocation, KdfType, StorageLocation } from "../enums";
import { StateFactory } from "../factories/state-factory"; import { StateFactory } from "../factories/state-factory";
import { Utils } from "../misc/utils"; import { Utils } from "../misc/utils";
import { ServerConfigData } from "../models/data/server-config.data"; import { ServerConfigData } from "../models/data/server-config.data";
@ -1754,23 +1754,6 @@ export class StateService<
); );
} }
async getTheme(options?: StorageOptions): Promise<ThemeType> {
return (
await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
)?.theme;
}
async setTheme(value: ThemeType, options?: StorageOptions): Promise<void> {
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<string> { async getTwoFactorToken(options?: StorageOptions): Promise<string> {
return ( return (
await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())) await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))

View File

@ -63,6 +63,7 @@ export const CLEAR_EVENT_DISK = new StateDefinition("clearEvent", "disk");
export const CRYPTO_DISK = new StateDefinition("crypto", "disk"); export const CRYPTO_DISK = new StateDefinition("crypto", "disk");
export const CRYPTO_MEMORY = new StateDefinition("crypto", "memory"); export const CRYPTO_MEMORY = new StateDefinition("crypto", "memory");
export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk"); export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk");
export const THEMING_DISK = new StateDefinition("theming", "disk");
export const TRANSLATION_DISK = new StateDefinition("translation", "disk"); export const TRANSLATION_DISK = new StateDefinition("translation", "disk");
// Secrets Manager // Secrets Manager

View File

@ -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<ThemeType>;
/**
* A method for updating the current users configured theme.
* @param theme The chosen user theme.
*/
setSelectedTheme: (theme: ThemeType) => Promise<void>;
}
const THEME_SELECTION = new KeyDefinition<ThemeType>(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<void> {
await this.selectedThemeState.update(() => theme, {
shouldUpdate: (currentTheme) => currentTheme !== theme,
});
}
}

View File

@ -30,6 +30,7 @@ import { EnableContextMenuMigrator } from "./migrations/31-move-enable-context-m
import { PreferredLanguageMigrator } from "./migrations/32-move-preferred-language"; import { PreferredLanguageMigrator } from "./migrations/32-move-preferred-language";
import { AppIdMigrator } from "./migrations/33-move-app-id-to-state-providers"; import { AppIdMigrator } from "./migrations/33-move-app-id-to-state-providers";
import { DomainSettingsMigrator } from "./migrations/34-move-domain-settings-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 { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked";
import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys"; import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys";
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; 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"; import { MinVersionMigrator } from "./migrations/min-version";
export const MIN_VERSION = 2; export const MIN_VERSION = 2;
export const CURRENT_VERSION = 34; export const CURRENT_VERSION = 35;
export type MinVersion = typeof MIN_VERSION; export type MinVersion = typeof MIN_VERSION;
export function createMigrationBuilder() { export function createMigrationBuilder() {
@ -76,7 +77,8 @@ export function createMigrationBuilder() {
.with(EnableContextMenuMigrator, 30, 31) .with(EnableContextMenuMigrator, 30, 31)
.with(PreferredLanguageMigrator, 31, 32) .with(PreferredLanguageMigrator, 31, 32)
.with(AppIdMigrator, 32, 33) .with(AppIdMigrator, 32, 33)
.with(DomainSettingsMigrator, 33, CURRENT_VERSION); .with(DomainSettingsMigrator, 33, 34)
.with(MoveThemeToStateProviderMigrator, 34, CURRENT_VERSION);
} }
export async function currentVersion( export async function currentVersion(

View File

@ -286,7 +286,11 @@ function expectInjectedData(
export async function runMigrator< export async function runMigrator<
TMigrator extends Migrator<number, number>, TMigrator extends Migrator<number, number>,
TUsers extends readonly string[] = string[], TUsers extends readonly string[] = string[],
>(migrator: TMigrator, initalData?: InitialDataHint<TUsers>): Promise<Record<string, unknown>> { >(
migrator: TMigrator,
initalData?: InitialDataHint<TUsers>,
direction: "migrate" | "rollback" = "migrate",
): Promise<Record<string, unknown>> {
// Inject fake data at every level of the object // Inject fake data at every level of the object
const allInjectedData = injectData(initalData, []); const allInjectedData = injectData(initalData, []);
@ -294,7 +298,11 @@ export async function runMigrator<
const helper = new MigrationHelper(migrator.fromVersion, fakeStorageService, mock()); const helper = new MigrationHelper(migrator.fromVersion, fakeStorageService, mock());
// Run their migrations // Run their migrations
await migrator.migrate(helper); if (direction === "rollback") {
await migrator.rollback(helper);
} else {
await migrator.migrate(helper);
}
const [data, leftoverInjectedData] = expectInjectedData( const [data, leftoverInjectedData] = expectInjectedData(
fakeStorageService.internalStore, fakeStorageService.internalStore,
allInjectedData, allInjectedData,

View File

@ -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({});
});
});
});

View File

@ -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<void> {
const legacyGlobalState = await helper.get<ExpectedGlobal>("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<void> {
const theme = await helper.getFromGlobal<string>(THEME_SELECTION);
if (theme != null) {
const legacyGlobal = (await helper.get<ExpectedGlobal>("global")) ?? {};
legacyGlobal.theme = theme;
await helper.set("global", legacyGlobal);
await helper.removeFromGlobal(THEME_SELECTION);
}
}
}