diff --git a/apps/browser/src/auth/services/auth-status-badge-updater.service.ts b/apps/browser/src/auth/services/auth-status-badge-updater.service.ts new file mode 100644 index 0000000000..4205ebc665 --- /dev/null +++ b/apps/browser/src/auth/services/auth-status-badge-updater.service.ts @@ -0,0 +1,56 @@ +import { mergeMap, of, switchMap } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; + +import { BadgeService } from "../../platform/badge/badge.service"; +import { BadgeIcon } from "../../platform/badge/icon"; +import { BadgeStatePriority } from "../../platform/badge/priority"; +import { Unset } from "../../platform/badge/state"; + +const StateName = "auth-status"; + +export class AuthStatusBadgeUpdaterService { + constructor( + private badgeService: BadgeService, + private accountService: AccountService, + private authService: AuthService, + ) { + this.accountService.activeAccount$ + .pipe( + switchMap((account) => + account + ? this.authService.authStatusFor$(account.id) + : of(AuthenticationStatus.LoggedOut), + ), + mergeMap(async (authStatus) => { + switch (authStatus) { + case AuthenticationStatus.LoggedOut: { + await this.badgeService.setState(StateName, BadgeStatePriority.High, { + icon: BadgeIcon.LoggedOut, + backgroundColor: Unset, + text: Unset, + }); + break; + } + case AuthenticationStatus.Locked: { + await this.badgeService.setState(StateName, BadgeStatePriority.High, { + icon: BadgeIcon.Locked, + backgroundColor: Unset, + text: Unset, + }); + break; + } + case AuthenticationStatus.Unlocked: { + await this.badgeService.setState(StateName, BadgeStatePriority.Low, { + icon: BadgeIcon.Unlocked, + }); + break; + } + } + }), + ) + .subscribe(); + } +} diff --git a/apps/browser/src/autofill/background/tabs.background.spec.ts b/apps/browser/src/autofill/background/tabs.background.spec.ts index 4473eb452f..635ab8504a 100644 --- a/apps/browser/src/autofill/background/tabs.background.spec.ts +++ b/apps/browser/src/autofill/background/tabs.background.spec.ts @@ -73,7 +73,6 @@ describe("TabsBackground", () => { triggerWindowOnFocusedChangedEvent(10); await flushPromises(); - expect(mainBackground.refreshBadge).toHaveBeenCalled(); expect(mainBackground.refreshMenu).toHaveBeenCalled(); expect(overlayBackground.updateOverlayCiphers).toHaveBeenCalled(); }); @@ -91,7 +90,6 @@ describe("TabsBackground", () => { triggerTabOnActivatedEvent({ tabId: 10, windowId: 20 }); await flushPromises(); - expect(mainBackground.refreshBadge).toHaveBeenCalled(); expect(mainBackground.refreshMenu).toHaveBeenCalled(); expect(overlayBackground.updateOverlayCiphers).toHaveBeenCalled(); }); @@ -127,7 +125,6 @@ describe("TabsBackground", () => { triggerTabOnReplacedEvent(10, 20); await flushPromises(); - expect(mainBackground.refreshBadge).toHaveBeenCalled(); expect(mainBackground.refreshMenu).toHaveBeenCalled(); expect(overlayBackground.updateOverlayCiphers).toHaveBeenCalled(); }); @@ -160,7 +157,6 @@ describe("TabsBackground", () => { triggerTabOnUpdatedEvent(focusedWindowId, { status: "loading" }, tab); await flushPromises(); - expect(mainBackground.refreshBadge).not.toHaveBeenCalled(); expect(mainBackground.refreshMenu).not.toHaveBeenCalled(); expect(overlayBackground.updateOverlayCiphers).not.toHaveBeenCalled(); }); @@ -170,7 +166,6 @@ describe("TabsBackground", () => { triggerTabOnUpdatedEvent(focusedWindowId, { status: "loading" }, tab); await flushPromises(); - expect(mainBackground.refreshBadge).not.toHaveBeenCalled(); expect(mainBackground.refreshMenu).not.toHaveBeenCalled(); expect(overlayBackground.updateOverlayCiphers).not.toHaveBeenCalled(); }); @@ -180,7 +175,6 @@ describe("TabsBackground", () => { triggerTabOnUpdatedEvent(focusedWindowId, { status: "loading" }, tab); await flushPromises(); - expect(mainBackground.refreshBadge).not.toHaveBeenCalled(); expect(mainBackground.refreshMenu).not.toHaveBeenCalled(); expect(overlayBackground.updateOverlayCiphers).not.toHaveBeenCalled(); }); @@ -190,7 +184,6 @@ describe("TabsBackground", () => { triggerTabOnUpdatedEvent(focusedWindowId, { status: "loading" }, tab); await flushPromises(); - expect(mainBackground.refreshBadge).not.toHaveBeenCalled(); expect(mainBackground.refreshMenu).not.toHaveBeenCalled(); }); @@ -205,7 +198,6 @@ describe("TabsBackground", () => { triggerTabOnUpdatedEvent(focusedWindowId, { status: "loading" }, tab); await flushPromises(); - expect(mainBackground.refreshBadge).toHaveBeenCalled(); expect(mainBackground.refreshMenu).toHaveBeenCalled(); expect(overlayBackground.updateOverlayCiphers).toHaveBeenCalled(); }); diff --git a/apps/browser/src/autofill/background/tabs.background.ts b/apps/browser/src/autofill/background/tabs.background.ts index c093f1a3b0..4d52068098 100644 --- a/apps/browser/src/autofill/background/tabs.background.ts +++ b/apps/browser/src/autofill/background/tabs.background.ts @@ -102,7 +102,6 @@ export default class TabsBackground { this.main.onUpdatedRan = true; await this.notificationBackground.checkNotificationQueue(tab); - await this.main.refreshBadge(); await this.main.refreshMenu(); this.main.messagingService.send("tabChanged"); }; @@ -122,7 +121,6 @@ export default class TabsBackground { */ private updateCurrentTabData = async () => { await Promise.all([ - this.main.refreshBadge(), this.main.refreshMenu(), this.overlayBackground.updateOverlayCiphers(false), ]); diff --git a/apps/browser/src/autofill/services/autofill-badge-updater.service.ts b/apps/browser/src/autofill/services/autofill-badge-updater.service.ts new file mode 100644 index 0000000000..42cb888621 --- /dev/null +++ b/apps/browser/src/autofill/services/autofill-badge-updater.service.ts @@ -0,0 +1,163 @@ +import { combineLatest, distinctUntilChanged, mergeMap, of, Subject, switchMap } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BadgeSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/badge-settings.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; + +import { BadgeService } from "../../platform/badge/badge.service"; +import { BadgeStatePriority } from "../../platform/badge/priority"; +import { BrowserApi } from "../../platform/browser/browser-api"; + +const StateName = (tabId: number) => `autofill-badge-${tabId}`; + +export class AutofillBadgeUpdaterService { + private tabReplaced$ = new Subject<{ addedTab: chrome.tabs.Tab; removedTabId: number }>(); + private tabUpdated$ = new Subject(); + private tabRemoved$ = new Subject(); + + constructor( + private badgeService: BadgeService, + private accountService: AccountService, + private cipherService: CipherService, + private badgeSettingsService: BadgeSettingsServiceAbstraction, + private logService: LogService, + ) { + const cipherViews$ = this.accountService.activeAccount$.pipe( + switchMap((account) => (account?.id ? this.cipherService.cipherViews$(account?.id) : of([]))), + ); + + combineLatest({ + account: this.accountService.activeAccount$, + enableBadgeCounter: + this.badgeSettingsService.enableBadgeCounter$.pipe(distinctUntilChanged()), + ciphers: cipherViews$, + }) + .pipe( + mergeMap(async ({ account, enableBadgeCounter, ciphers }) => { + if (!account) { + return; + } + + const tabs = await BrowserApi.tabsQuery({}); + for (const tab of tabs) { + if (!tab.id) { + continue; + } + + if (enableBadgeCounter) { + await this.setTabState(tab, account.id); + } else { + await this.clearTabState(tab.id); + } + } + }), + ) + .subscribe(); + + combineLatest({ + account: this.accountService.activeAccount$, + enableBadgeCounter: this.badgeSettingsService.enableBadgeCounter$, + replaced: this.tabReplaced$, + ciphers: cipherViews$, + }) + .pipe( + mergeMap(async ({ account, enableBadgeCounter, replaced }) => { + if (!account || !enableBadgeCounter) { + return; + } + + await this.clearTabState(replaced.removedTabId); + await this.setTabState(replaced.addedTab, account.id); + }), + ) + .subscribe(); + + combineLatest({ + account: this.accountService.activeAccount$, + enableBadgeCounter: this.badgeSettingsService.enableBadgeCounter$, + tab: this.tabUpdated$, + ciphers: cipherViews$, + }) + .pipe( + mergeMap(async ({ account, enableBadgeCounter, tab }) => { + if (!account || !enableBadgeCounter) { + return; + } + + await this.setTabState(tab, account.id); + }), + ) + .subscribe(); + + combineLatest({ + account: this.accountService.activeAccount$, + enableBadgeCounter: this.badgeSettingsService.enableBadgeCounter$, + tabId: this.tabRemoved$, + ciphers: cipherViews$, + }) + .pipe( + mergeMap(async ({ account, enableBadgeCounter, tabId }) => { + if (!account || !enableBadgeCounter) { + return; + } + + await this.clearTabState(tabId); + }), + ) + .subscribe(); + } + + init() { + BrowserApi.addListener(chrome.tabs.onReplaced, async (addedTabId, removedTabId) => { + const newTab = await BrowserApi.getTab(addedTabId); + if (!newTab) { + this.logService.warning( + `Tab replaced event received but new tab not found (id: ${addedTabId})`, + ); + return; + } + + this.tabReplaced$.next({ + removedTabId, + addedTab: newTab, + }); + }); + BrowserApi.addListener(chrome.tabs.onUpdated, (_, changeInfo, tab) => { + if (changeInfo.url) { + this.tabUpdated$.next(tab); + } + }); + BrowserApi.addListener(chrome.tabs.onRemoved, (tabId, _) => this.tabRemoved$.next(tabId)); + } + + private async setTabState(tab: chrome.tabs.Tab, userId: UserId) { + if (!tab.id) { + this.logService.warning("Tab event received but tab id is undefined"); + return; + } + + const ciphers = tab.url ? await this.cipherService.getAllDecryptedForUrl(tab.url, userId) : []; + const cipherCount = ciphers.length; + + if (cipherCount === 0) { + await this.clearTabState(tab.id); + return; + } + + const countText = cipherCount > 9 ? "9+" : cipherCount.toString(); + await this.badgeService.setState( + StateName(tab.id), + BadgeStatePriority.Default, + { + text: countText, + }, + tab.id, + ); + } + + private async clearTabState(tabId: number) { + await this.badgeService.clearState(StateName(tabId)); + } +} diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 16149ea0fb..3f29151a1b 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -242,6 +242,7 @@ import { VaultExportServiceAbstraction, } from "@bitwarden/vault-export-core"; +import { AuthStatusBadgeUpdaterService } from "../auth/services/auth-status-badge-updater.service"; import { OverlayNotificationsBackground as OverlayNotificationsBackgroundInterface } from "../autofill/background/abstractions/overlay-notifications.background"; import { OverlayBackground as OverlayBackgroundInterface } from "../autofill/background/abstractions/overlay.background"; import { AutoSubmitLoginBackground } from "../autofill/background/auto-submit-login.background"; @@ -261,16 +262,18 @@ import { BrowserFido2UserInterfaceService, } from "../autofill/fido2/services/browser-fido2-user-interface.service"; import { AutofillService as AutofillServiceAbstraction } from "../autofill/services/abstractions/autofill.service"; +import { AutofillBadgeUpdaterService } from "../autofill/services/autofill-badge-updater.service"; import AutofillService from "../autofill/services/autofill.service"; import { InlineMenuFieldQualificationService } from "../autofill/services/inline-menu-field-qualification.service"; import { SafariApp } from "../browser/safariApp"; import { BackgroundBrowserBiometricsService } from "../key-management/biometrics/background-browser-biometrics.service"; import VaultTimeoutService from "../key-management/vault-timeout/vault-timeout.service"; +import { DefaultBadgeBrowserApi } from "../platform/badge/badge-browser-api"; +import { BadgeService } from "../platform/badge/badge.service"; import { BrowserApi } from "../platform/browser/browser-api"; import { flagEnabled } from "../platform/flags"; import { IpcBackgroundService } from "../platform/ipc/ipc-background.service"; import { IpcContentScriptManagerService } from "../platform/ipc/ipc-content-script-manager.service"; -import { UpdateBadge } from "../platform/listeners/update-badge"; /* eslint-disable no-restricted-imports */ import { ChromeMessageSender } from "../platform/messaging/chrome-message.sender"; /* eslint-enable no-restricted-imports */ @@ -421,6 +424,10 @@ export default class MainBackground { ipcContentScriptManagerService: IpcContentScriptManagerService; ipcService: IpcService; + badgeService: BadgeService; + authStatusBadgeUpdaterService: AuthStatusBadgeUpdaterService; + autofillBadgeUpdaterService: AutofillBadgeUpdaterService; + onUpdatedRan: boolean; onReplacedRan: boolean; loginToAutoFill: CipherView = null; @@ -444,7 +451,6 @@ export default class MainBackground { constructor() { // Services const lockedCallback = async (userId: UserId) => { - await this.refreshBadge(); await this.refreshMenu(true); if (this.systemService != null) { await this.systemService.clearPendingClipboard(); @@ -1363,6 +1369,16 @@ export default class MainBackground { this.authService, this.logService, ); + + this.badgeService = new BadgeService( + this.stateProvider, + new DefaultBadgeBrowserApi(this.platformUtilsService), + ); + this.authStatusBadgeUpdaterService = new AuthStatusBadgeUpdaterService( + this.badgeService, + this.accountService, + this.authService, + ); } async bootstrap() { @@ -1437,10 +1453,10 @@ export default class MainBackground { await this.initOverlayAndTabsBackground(); await this.ipcService.init(); + this.badgeService.startListening(); return new Promise((resolve) => { setTimeout(async () => { - await this.refreshBadge(); await this.fullSync(false); this.backgroundSyncService.init(); this.notificationsService.startListening(); @@ -1455,10 +1471,6 @@ export default class MainBackground { }); } - async refreshBadge() { - await new UpdateBadge(self, this).run(); - } - async refreshMenu(forLocked = false) { if (!chrome.windows || !chrome.contextMenus) { return; @@ -1530,7 +1542,6 @@ export default class MainBackground { await switchPromise; if (userId == null) { - await this.refreshBadge(); await this.refreshMenu(); await this.updateOverlayCiphers(); this.messagingService.send("goHome"); @@ -1547,7 +1558,6 @@ export default class MainBackground { this.messagingService.send("locked", { userId: userId }); } else { this.messagingService.send("unlocked", { userId: userId }); - await this.refreshBadge(); await this.refreshMenu(); await this.updateOverlayCiphers(); await this.syncService.fullSync(false); @@ -1640,7 +1650,6 @@ export default class MainBackground { // eslint-disable-next-line @typescript-eslint/no-floating-promises BrowserApi.sendMessage("updateBadge"); } - await this.refreshBadge(); await this.mainContextMenuHandler?.noAccess(); await this.systemService.clearPendingClipboard(); await this.processReloadService.startProcessReload(this.authService); @@ -1805,6 +1814,14 @@ export default class MainBackground { (password) => this.addPasswordToHistory(password), ); + this.autofillBadgeUpdaterService = new AutofillBadgeUpdaterService( + this.badgeService, + this.accountService, + this.cipherService, + this.badgeSettingsService, + this.logService, + ); + this.tabsBackground = new TabsBackground( this, this.notificationBackground, @@ -1813,6 +1830,7 @@ export default class MainBackground { await this.overlayBackground.init(); await this.tabsBackground.init(); + await this.autofillBadgeUpdaterService.init(); } generatePassword = async (): Promise => { diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index 54fb8326cf..1e7a014002 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -256,7 +256,6 @@ export default class RuntimeBackground { // @TODO these need to happen last to avoid blocking `tabSendMessageData` above // The underlying cause exists within `cipherService.getAllDecrypted` via // `getAllDecryptedForUrl` and is anticipated to be refactored - await this.main.refreshBadge(); await this.main.refreshMenu(false); await this.autofillService.setAutoFillOnPageLoadOrgPolicy(); @@ -280,7 +279,6 @@ export default class RuntimeBackground { case "syncCompleted": if (msg.successfully) { setTimeout(async () => { - await this.main.refreshBadge(); await this.main.refreshMenu(); }, 2000); await this.configService.ensureConfigFetched(); @@ -304,7 +302,6 @@ export default class RuntimeBackground { case "editedCipher": case "addedCipher": case "deletedCipher": - await this.main.refreshBadge(); await this.main.refreshMenu(); break; case "bgReseedStorage": { diff --git a/apps/browser/src/platform/badge/array-utils.spec.ts b/apps/browser/src/platform/badge/array-utils.spec.ts new file mode 100644 index 0000000000..3f41019f02 --- /dev/null +++ b/apps/browser/src/platform/badge/array-utils.spec.ts @@ -0,0 +1,17 @@ +import { difference } from "./array-utils"; + +describe("array-utils", () => { + describe("difference", () => { + it.each([ + [new Set([1, 2, 3]), new Set([]), new Set([1, 2, 3]), new Set([])], + [new Set([]), new Set([1, 2, 3]), new Set([]), new Set([1, 2, 3])], + [new Set([1, 2, 3]), new Set([2, 3, 5]), new Set([1]), new Set([5])], + [new Set([1, 2, 3]), new Set([1, 2, 3]), new Set([]), new Set([])], + ])("returns elements that are unique to each set", (A, B, onlyA, onlyB) => { + const [resultA, resultB] = difference(A, B); + + expect(resultA).toEqual(onlyA); + expect(resultB).toEqual(onlyB); + }); + }); +}); diff --git a/apps/browser/src/platform/badge/array-utils.ts b/apps/browser/src/platform/badge/array-utils.ts new file mode 100644 index 0000000000..699006e615 --- /dev/null +++ b/apps/browser/src/platform/badge/array-utils.ts @@ -0,0 +1,16 @@ +/** + * Returns the difference between two sets. + * @param a First set + * @param b Second set + * @returns A tuple containing two sets: + * - The first set contains elements unique to `a`. + * - The second set contains elements unique to `b`. + * - If an element is present in both sets, it will not be included in either set. + */ +export function difference(a: Set, b: Set): [Set, Set] { + const intersection = new Set([...a].filter((x) => b.has(x))); + a = new Set([...a].filter((x) => !intersection.has(x))); + b = new Set([...b].filter((x) => !intersection.has(x))); + + return [a, b]; +} diff --git a/apps/browser/src/platform/badge/badge-browser-api.ts b/apps/browser/src/platform/badge/badge-browser-api.ts new file mode 100644 index 0000000000..9febaf8d39 --- /dev/null +++ b/apps/browser/src/platform/badge/badge-browser-api.ts @@ -0,0 +1,119 @@ +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +import { BrowserApi } from "../browser/browser-api"; + +import { BadgeIcon, IconPaths } from "./icon"; + +export interface RawBadgeState { + tabId?: string; + text: string; + backgroundColor: string; + icon: BadgeIcon; +} + +export interface BadgeBrowserApi { + setState(state: RawBadgeState, tabId?: number): Promise; + getTabs(): Promise; +} + +export class DefaultBadgeBrowserApi implements BadgeBrowserApi { + private badgeAction = BrowserApi.getBrowserAction(); + private sidebarAction = BrowserApi.getSidebarAction(self); + + constructor(private platformUtilsService: PlatformUtilsService) {} + + async setState(state: RawBadgeState, tabId?: number): Promise { + await Promise.all([ + state.backgroundColor !== undefined ? this.setIcon(state.icon, tabId) : undefined, + this.setText(state.text, tabId), + state.backgroundColor !== undefined + ? this.setBackgroundColor(state.backgroundColor, tabId) + : undefined, + ]); + } + + async getTabs(): Promise { + return (await BrowserApi.tabsQuery({})).map((tab) => tab.id).filter((tab) => tab !== undefined); + } + + private setIcon(icon: IconPaths, tabId?: number) { + return Promise.all([this.setActionIcon(icon, tabId), this.setSidebarActionIcon(icon, tabId)]); + } + + private setText(text: string, tabId?: number) { + return Promise.all([this.setActionText(text, tabId), this.setSideBarText(text, tabId)]); + } + + private async setActionIcon(path: IconPaths, tabId?: number) { + if (!this.badgeAction?.setIcon) { + return; + } + + if (this.useSyncApiCalls) { + await this.badgeAction.setIcon({ path, tabId }); + } else { + await new Promise((resolve) => this.badgeAction.setIcon({ path, tabId }, resolve)); + } + } + + private async setSidebarActionIcon(path: IconPaths, tabId?: number) { + if (!this.sidebarAction?.setIcon) { + return; + } + + if ("opr" in self && BrowserApi.isManifestVersion(3)) { + // setIcon API is currenly broken for Opera MV3 extensions + // https://forums.opera.com/topic/75680/opr-sidebaraction-seticon-api-is-broken-access-to-extension-api-denied?_=1738349261570 + // The API currently crashes on MacOS + return; + } + + if (this.isOperaSidebar(this.sidebarAction)) { + await new Promise((resolve) => + (this.sidebarAction as OperaSidebarAction).setIcon({ path, tabId }, () => resolve()), + ); + } else { + await this.sidebarAction.setIcon({ path, tabId }); + } + } + + private async setActionText(text: string, tabId?: number) { + if (this.badgeAction?.setBadgeText) { + await this.badgeAction.setBadgeText({ text, tabId }); + } + } + + private async setSideBarText(text: string, tabId?: number) { + if (!this.sidebarAction) { + return; + } + + if (this.isOperaSidebar(this.sidebarAction)) { + this.sidebarAction.setBadgeText({ text, tabId }); + } else if (this.sidebarAction) { + // Firefox + const title = `Bitwarden${Utils.isNullOrEmpty(text) ? "" : ` [${text}]`}`; + await this.sidebarAction.setTitle({ title, tabId }); + } + } + + private async setBackgroundColor(color: string, tabId?: number) { + if (this.badgeAction && this.badgeAction?.setBadgeBackgroundColor) { + await this.badgeAction.setBadgeBackgroundColor({ color, tabId }); + } + if (this.sidebarAction && this.isOperaSidebar(this.sidebarAction)) { + this.sidebarAction.setBadgeBackgroundColor({ color, tabId }); + } + } + + private get useSyncApiCalls() { + return this.platformUtilsService.isFirefox() || this.platformUtilsService.isSafari(); + } + + private isOperaSidebar( + action: OperaSidebarAction | FirefoxSidebarAction, + ): action is OperaSidebarAction { + return action != null && (action as OperaSidebarAction).setBadgeText != null; + } +} diff --git a/apps/browser/src/platform/badge/badge.service.spec.ts b/apps/browser/src/platform/badge/badge.service.spec.ts new file mode 100644 index 0000000000..2a7ba2ce39 --- /dev/null +++ b/apps/browser/src/platform/badge/badge.service.spec.ts @@ -0,0 +1,562 @@ +import { Subscription } from "rxjs"; + +import { FakeAccountService, FakeStateProvider } from "@bitwarden/common/spec"; + +import { RawBadgeState } from "./badge-browser-api"; +import { BadgeService } from "./badge.service"; +import { DefaultBadgeState } from "./consts"; +import { BadgeIcon } from "./icon"; +import { BadgeStatePriority } from "./priority"; +import { BadgeState, Unset } from "./state"; +import { MockBadgeBrowserApi } from "./test/mock-badge-browser-api"; + +describe("BadgeService", () => { + let badgeApi: MockBadgeBrowserApi; + let stateProvider: FakeStateProvider; + let badgeService!: BadgeService; + + let badgeServiceSubscription: Subscription; + + beforeEach(() => { + badgeApi = new MockBadgeBrowserApi(); + stateProvider = new FakeStateProvider(new FakeAccountService({})); + + badgeService = new BadgeService(stateProvider, badgeApi); + }); + + afterEach(() => { + badgeServiceSubscription?.unsubscribe(); + }); + + describe("calling without tabId", () => { + const tabId = 1; + + describe("given a single tab is open", () => { + beforeEach(() => { + badgeApi.tabs = [1]; + badgeServiceSubscription = badgeService.startListening(); + }); + + // This relies on the state provider to auto-emit + it("sets default values on startup", async () => { + expect(badgeApi.generalState).toEqual(DefaultBadgeState); + }); + + it("sets provided state when no other state has been set", async () => { + const state: BadgeState = { + text: "text", + backgroundColor: "color", + icon: BadgeIcon.Locked, + }; + + await badgeService.setState("state-name", BadgeStatePriority.Default, state); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.generalState).toEqual(state); + expect(badgeApi.specificStates[tabId]).toEqual(state); + }); + + it("sets default values when none are provided", async () => { + // This is a bit of a weird thing to do, but I don't think it's something we need to prohibit + const state: BadgeState = {}; + + await badgeService.setState("state-name", BadgeStatePriority.Default, state); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.generalState).toEqual(DefaultBadgeState); + expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); + }); + + it("merges states when multiple same-priority states have been set", async () => { + await badgeService.setState("state-1", BadgeStatePriority.Default, { text: "text" }); + await badgeService.setState("state-2", BadgeStatePriority.Default, { + backgroundColor: "#fff", + }); + await badgeService.setState("state-3", BadgeStatePriority.Default, { + icon: BadgeIcon.Locked, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + const expectedState: RawBadgeState = { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }; + expect(badgeApi.generalState).toEqual(expectedState); + expect(badgeApi.specificStates[tabId]).toEqual(expectedState); + }); + + it("overrides previous lower-priority state when higher-priority state is set", async () => { + await badgeService.setState("state-1", BadgeStatePriority.Low, { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }); + await badgeService.setState("state-2", BadgeStatePriority.Default, { + text: "override", + }); + await badgeService.setState("state-3", BadgeStatePriority.High, { + backgroundColor: "#aaa", + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + const expectedState: RawBadgeState = { + text: "override", + backgroundColor: "#aaa", + icon: BadgeIcon.Locked, + }; + expect(badgeApi.generalState).toEqual(expectedState); + expect(badgeApi.specificStates[tabId]).toEqual(expectedState); + }); + + it("removes override when a previously high-priority state is cleared", async () => { + await badgeService.setState("state-1", BadgeStatePriority.Low, { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }); + await badgeService.setState("state-2", BadgeStatePriority.Default, { + text: "override", + }); + await badgeService.clearState("state-2"); + + await new Promise((resolve) => setTimeout(resolve, 0)); + const expectedState: RawBadgeState = { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }; + expect(badgeApi.generalState).toEqual(expectedState); + expect(badgeApi.specificStates[tabId]).toEqual(expectedState); + }); + + it("sets default values when all states have been cleared", async () => { + await badgeService.setState("state-1", BadgeStatePriority.Low, { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }); + await badgeService.setState("state-2", BadgeStatePriority.Default, { + text: "override", + }); + await badgeService.setState("state-3", BadgeStatePriority.High, { + backgroundColor: "#aaa", + }); + await badgeService.clearState("state-1"); + await badgeService.clearState("state-2"); + await badgeService.clearState("state-3"); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.generalState).toEqual(DefaultBadgeState); + expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); + }); + + it("sets default value high-priority state contains Unset", async () => { + await badgeService.setState("state-1", BadgeStatePriority.Low, { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }); + await badgeService.setState("state-3", BadgeStatePriority.High, { + icon: Unset, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + const expectedState: RawBadgeState = { + text: "text", + backgroundColor: "#fff", + icon: DefaultBadgeState.icon, + }; + expect(badgeApi.generalState).toEqual(expectedState); + expect(badgeApi.specificStates[tabId]).toEqual(expectedState); + }); + + it("ignores medium-priority Unset when high-priority contains a value", async () => { + await badgeService.setState("state-1", BadgeStatePriority.Low, { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }); + await badgeService.setState("state-3", BadgeStatePriority.Default, { + icon: Unset, + }); + await badgeService.setState("state-3", BadgeStatePriority.High, { + icon: BadgeIcon.Unlocked, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + const expectedState: RawBadgeState = { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Unlocked, + }; + expect(badgeApi.generalState).toEqual(expectedState); + expect(badgeApi.specificStates[tabId]).toEqual(expectedState); + }); + }); + + describe("given multiple tabs are open", () => { + const tabIds = [1, 2, 3]; + + beforeEach(() => { + badgeApi.tabs = tabIds; + badgeServiceSubscription = badgeService.startListening(); + }); + + it("sets default values for each tab on startup", async () => { + expect(badgeApi.generalState).toEqual(DefaultBadgeState); + for (const tabId of tabIds) { + expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); + } + }); + + it("sets state for each tab when no other state has been set", async () => { + const state: BadgeState = { + text: "text", + backgroundColor: "color", + icon: BadgeIcon.Locked, + }; + + await badgeService.setState("state-name", BadgeStatePriority.Default, state); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.generalState).toEqual(state); + expect(badgeApi.specificStates).toEqual({ + 1: state, + 2: state, + 3: state, + }); + }); + }); + }); + + describe("calling with tabId", () => { + describe("given a single tab is open", () => { + const tabId = 1; + + beforeEach(() => { + badgeApi.tabs = [tabId]; + badgeServiceSubscription = badgeService.startListening(); + }); + + it("sets provided state when no other state has been set", async () => { + const state: BadgeState = { + text: "text", + backgroundColor: "color", + icon: BadgeIcon.Locked, + }; + + await badgeService.setState("state-name", BadgeStatePriority.Default, state, tabId); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.generalState).toEqual(DefaultBadgeState); + expect(badgeApi.specificStates[tabId]).toEqual(state); + }); + + it("sets default values when none are provided", async () => { + // This is a bit of a weird thing to do, but I don't think it's something we need to prohibit + const state: BadgeState = {}; + + await badgeService.setState("state-name", BadgeStatePriority.Default, state, tabId); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.generalState).toEqual(DefaultBadgeState); + expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); + }); + + it("merges tabId specific state with general states", async () => { + await badgeService.setState("general-state", BadgeStatePriority.Default, { text: "text" }); + await badgeService.setState( + "specific-state", + BadgeStatePriority.Default, + { + backgroundColor: "#fff", + }, + tabId, + ); + await badgeService.setState("general-state-2", BadgeStatePriority.Default, { + icon: BadgeIcon.Locked, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.generalState).toEqual({ + ...DefaultBadgeState, + text: "text", + icon: BadgeIcon.Locked, + }); + expect(badgeApi.specificStates[tabId]).toEqual({ + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }); + }); + + it("merges states when multiple same-priority states with the same tabId have been set", async () => { + await badgeService.setState("state-1", BadgeStatePriority.Default, { text: "text" }, tabId); + await badgeService.setState( + "state-2", + BadgeStatePriority.Default, + { + backgroundColor: "#fff", + }, + tabId, + ); + await badgeService.setState( + "state-3", + BadgeStatePriority.Default, + { + icon: BadgeIcon.Locked, + }, + tabId, + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + const expectedState: RawBadgeState = { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }; + expect(badgeApi.generalState).toEqual(DefaultBadgeState); + expect(badgeApi.specificStates[tabId]).toEqual(expectedState); + }); + + it("overrides previous lower-priority state when higher-priority state with the same tabId is set", async () => { + await badgeService.setState( + "state-1", + BadgeStatePriority.Low, + { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }, + tabId, + ); + await badgeService.setState( + "state-2", + BadgeStatePriority.Default, + { + text: "override", + }, + tabId, + ); + await badgeService.setState( + "state-3", + BadgeStatePriority.High, + { + backgroundColor: "#aaa", + }, + tabId, + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + const expectedState: RawBadgeState = { + text: "override", + backgroundColor: "#aaa", + icon: BadgeIcon.Locked, + }; + expect(badgeApi.generalState).toEqual(DefaultBadgeState); + expect(badgeApi.specificStates[tabId]).toEqual(expectedState); + }); + + it("overrides lower-priority tab-specific state when higher-priority general state is set", async () => { + await badgeService.setState( + "state-1", + BadgeStatePriority.Low, + { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }, + tabId, + ); + await badgeService.setState("state-2", BadgeStatePriority.Default, { + text: "override", + }); + await badgeService.setState("state-3", BadgeStatePriority.High, { + backgroundColor: "#aaa", + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.generalState).toEqual({ + text: "override", + backgroundColor: "#aaa", + icon: DefaultBadgeState.icon, + }); + expect(badgeApi.specificStates[tabId]).toEqual({ + text: "override", + backgroundColor: "#aaa", + icon: BadgeIcon.Locked, + }); + }); + + it("removes override when a previously high-priority state with the same tabId is cleared", async () => { + await badgeService.setState( + "state-1", + BadgeStatePriority.Low, + { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }, + tabId, + ); + await badgeService.setState( + "state-2", + BadgeStatePriority.Default, + { + text: "override", + }, + tabId, + ); + await badgeService.clearState("state-2"); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.generalState).toEqual(DefaultBadgeState); + expect(badgeApi.specificStates[tabId]).toEqual({ + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }); + }); + + it("sets default state when all states with the same tabId have been cleared", async () => { + await badgeService.setState( + "state-1", + BadgeStatePriority.Low, + { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }, + tabId, + ); + await badgeService.setState( + "state-2", + BadgeStatePriority.Default, + { + text: "override", + }, + tabId, + ); + await badgeService.setState( + "state-3", + BadgeStatePriority.High, + { + backgroundColor: "#aaa", + }, + tabId, + ); + await badgeService.clearState("state-1"); + await badgeService.clearState("state-2"); + await badgeService.clearState("state-3"); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.generalState).toEqual(DefaultBadgeState); + expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState); + }); + + it("sets default value when high-priority state contains Unset", async () => { + await badgeService.setState( + "state-1", + BadgeStatePriority.Low, + { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }, + tabId, + ); + await badgeService.setState( + "state-3", + BadgeStatePriority.High, + { + icon: Unset, + }, + tabId, + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.generalState).toEqual(DefaultBadgeState); + expect(badgeApi.specificStates[tabId]).toEqual({ + text: "text", + backgroundColor: "#fff", + icon: DefaultBadgeState.icon, + }); + }); + + it("ignores medium-priority Unset when high-priority contains a value", async () => { + await badgeService.setState( + "state-1", + BadgeStatePriority.Low, + { + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Locked, + }, + tabId, + ); + await badgeService.setState( + "state-3", + BadgeStatePriority.Default, + { + icon: Unset, + }, + tabId, + ); + await badgeService.setState( + "state-3", + BadgeStatePriority.High, + { + icon: BadgeIcon.Unlocked, + }, + tabId, + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.generalState).toEqual(DefaultBadgeState); + expect(badgeApi.specificStates[tabId]).toEqual({ + text: "text", + backgroundColor: "#fff", + icon: BadgeIcon.Unlocked, + }); + }); + }); + + describe("given multiple tabs are open", () => { + const tabIds = [1, 2, 3]; + + beforeEach(() => { + badgeApi.tabs = tabIds; + badgeServiceSubscription = badgeService.startListening(); + }); + + it("sets tab-specific state for provided tab and general state for the others", async () => { + const generalState: BadgeState = { + text: "general-text", + backgroundColor: "general-color", + icon: BadgeIcon.Unlocked, + }; + const specificState: BadgeState = { + text: "tab-text", + icon: BadgeIcon.Locked, + }; + + await badgeService.setState("general-state", BadgeStatePriority.Default, generalState); + await badgeService.setState( + "tab-state", + BadgeStatePriority.Default, + specificState, + tabIds[0], + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(badgeApi.generalState).toEqual(generalState); + expect(badgeApi.specificStates).toEqual({ + [tabIds[0]]: { ...specificState, backgroundColor: "general-color" }, + [tabIds[1]]: generalState, + [tabIds[2]]: generalState, + }); + }); + }); + }); +}); diff --git a/apps/browser/src/platform/badge/badge.service.ts b/apps/browser/src/platform/badge/badge.service.ts new file mode 100644 index 0000000000..d48150ac51 --- /dev/null +++ b/apps/browser/src/platform/badge/badge.service.ts @@ -0,0 +1,182 @@ +import { + defer, + distinctUntilChanged, + filter, + map, + mergeMap, + pairwise, + startWith, + Subscription, + switchMap, +} from "rxjs"; + +import { + BADGE_MEMORY, + GlobalState, + KeyDefinition, + StateProvider, +} from "@bitwarden/common/platform/state"; + +import { difference } from "./array-utils"; +import { BadgeBrowserApi, RawBadgeState } from "./badge-browser-api"; +import { DefaultBadgeState } from "./consts"; +import { BadgeStatePriority } from "./priority"; +import { BadgeState, Unset } from "./state"; + +interface StateSetting { + priority: BadgeStatePriority; + state: BadgeState; + tabId?: number; +} + +const BADGE_STATES = new KeyDefinition(BADGE_MEMORY, "badgeStates", { + deserializer: (value: Record) => value ?? {}, +}); + +export class BadgeService { + private states: GlobalState>; + + constructor( + private stateProvider: StateProvider, + private badgeApi: BadgeBrowserApi, + ) { + this.states = this.stateProvider.getGlobal(BADGE_STATES); + } + + /** + * Start listening for badge state changes. + * Without this the service will not be able to update the badge state. + */ + startListening(): Subscription { + const initialSetup$ = defer(async () => { + const openTabs = await this.badgeApi.getTabs(); + await this.badgeApi.setState(DefaultBadgeState); + for (const tabId of openTabs) { + await this.badgeApi.setState(DefaultBadgeState, tabId); + } + }); + + return initialSetup$ + .pipe( + switchMap(() => this.states.state$), + startWith({}), + distinctUntilChanged(), + map((states) => new Set(states ? Object.values(states) : [])), + pairwise(), + map(([previous, current]) => { + const [removed, added] = difference(previous, current); + return { states: current, removed, added }; + }), + filter(({ removed, added }) => removed.size > 0 || added.size > 0), + mergeMap(async ({ states, removed, added }) => { + const changed = [...removed, ...added]; + const changedTabIds = new Set( + changed.map((s) => s.tabId).filter((tabId) => tabId !== undefined), + ); + const onlyTabSpecificStatesChanged = changed.every((s) => s.tabId != undefined); + if (onlyTabSpecificStatesChanged) { + // If only tab-specific states changed then we only need to update those specific tabs. + for (const tabId of changedTabIds) { + const newState = this.calculateState(states, tabId); + await this.badgeApi.setState(newState, tabId); + } + return; + } + + // If there are any general states that changed then we need to update all tabs. + const openTabs = await this.badgeApi.getTabs(); + const generalState = this.calculateState(states); + await this.badgeApi.setState(generalState); + for (const tabId of openTabs) { + const newState = this.calculateState(states, tabId); + await this.badgeApi.setState(newState, tabId); + } + }), + ) + .subscribe(); + } + + /** + * Inform badge service of a new state that the badge should reflect. + * + * This will merge the new state with any existing states: + * - If the new state has a higher priority, it will override any lower priority states. + * - If the new state has a lower priority, it will be ignored. + * - If the name of the state is already in use, it will be updated. + * - If the state has a `tabId` set, it will only apply to that tab. + * - States with `tabId` can still be overridden by states without `tabId` if they have a higher priority. + * + * @param name The name of the state. This is used to identify the state and will be used to clear it later. + * @param priority The priority of the state (higher numbers are higher priority, but setting arbitrary numbers is not supported). + * @param state The state to set. + * @param tabId Limit this badge state to a specific tab. If this is not set, the state will be applied to all tabs. + */ + async setState(name: string, priority: BadgeStatePriority, state: BadgeState, tabId?: number) { + await this.states.update((s) => ({ ...s, [name]: { priority, state, tabId } })); + } + + /** + * Clear the state with the given name. + * + * This will remove the state from the badge service and clear it from the badge. + * If the state is not found, nothing will happen. + * + * @param name The name of the state to clear. + */ + async clearState(name: string) { + await this.states.update((s) => { + const newStates = { ...s }; + delete newStates[name]; + return newStates; + }); + } + + private calculateState(states: Set, tabId?: number): RawBadgeState { + const sortedStates = [...states].sort((a, b) => a.priority - b.priority); + + let filteredStates = sortedStates; + if (tabId !== undefined) { + // Filter out states that are not applicable to the current tab. + // If a state has no tabId, it is considered applicable to all tabs. + // If a state has a tabId, it is only applicable to that tab. + filteredStates = sortedStates.filter((s) => s.tabId === tabId || s.tabId === undefined); + } else { + // If no tabId is provided, we only want states that are not tab-specific. + filteredStates = sortedStates.filter((s) => s.tabId === undefined); + } + + const mergedState = filteredStates + .map((s) => s.state) + .reduce>((acc: Partial, state: BadgeState) => { + const newState = { ...acc }; + + for (const k in state) { + const key = k as keyof BadgeState & keyof RawBadgeState; + setStateValue(newState, state, key); + } + + return newState; + }, DefaultBadgeState); + + return { + ...DefaultBadgeState, + ...mergedState, + }; + } +} + +/** + * Helper value to modify the state variable. + * TS doesn't like it when this is being doine inline. + */ +function setStateValue( + newState: Partial, + state: BadgeState, + key: Key, +) { + if (state[key] === Unset) { + delete newState[key]; + } else if (state[key] !== undefined) { + newState[key] = state[key] as RawBadgeState[Key]; + } +} diff --git a/apps/browser/src/platform/badge/consts.ts b/apps/browser/src/platform/badge/consts.ts new file mode 100644 index 0000000000..67cb4b1035 --- /dev/null +++ b/apps/browser/src/platform/badge/consts.ts @@ -0,0 +1,9 @@ +import { RawBadgeState } from "./badge-browser-api"; +import { BadgeIcon } from "./icon"; +import { BadgeState } from "./state"; + +export const DefaultBadgeState: RawBadgeState & BadgeState = { + text: "", + backgroundColor: "#294e5f", + icon: BadgeIcon.LoggedOut, +}; diff --git a/apps/browser/src/platform/badge/icon.ts b/apps/browser/src/platform/badge/icon.ts new file mode 100644 index 0000000000..d6dcdcc5f7 --- /dev/null +++ b/apps/browser/src/platform/badge/icon.ts @@ -0,0 +1,21 @@ +export const BadgeIcon = { + LoggedOut: { + 19: "/images/icon19_gray.png", + 38: "/images/icon38_gray.png", + } as IconPaths, + Locked: { + 19: "/images/icon19_locked.png", + 38: "/images/icon38_locked.png", + } as IconPaths, + Unlocked: { + 19: "/images/icon19.png", + 38: "/images/icon38.png", + } as IconPaths, +} as const satisfies Record; + +export type BadgeIcon = (typeof BadgeIcon)[keyof typeof BadgeIcon]; + +export type IconPaths = { + 19: string; + 38: string; +}; diff --git a/apps/browser/src/platform/badge/priority.ts b/apps/browser/src/platform/badge/priority.ts new file mode 100644 index 0000000000..6870e571e6 --- /dev/null +++ b/apps/browser/src/platform/badge/priority.ts @@ -0,0 +1,7 @@ +export const BadgeStatePriority = { + Low: 0, + Default: 100, + High: 200, +} as const; + +export type BadgeStatePriority = (typeof BadgeStatePriority)[keyof typeof BadgeStatePriority]; diff --git a/apps/browser/src/platform/badge/state.ts b/apps/browser/src/platform/badge/state.ts new file mode 100644 index 0000000000..0731ad81f4 --- /dev/null +++ b/apps/browser/src/platform/badge/state.ts @@ -0,0 +1,32 @@ +import { BadgeIcon } from "./icon"; + +export const Unset = Symbol("Unset badge state"); +export type Unset = typeof Unset; + +export type BadgeState = { + /** + * The text to display in the badge. + * If this is set to `Unset`, any text set by a lower priority state will be cleared. + * If this is set to `undefined`, a lower priority state may be used. + * If no lower priority state is set, no text will be displayed. + */ + text?: string | Unset; + + /** + * The background color of the badge. + * This should be a 3 or 6 character hex color code (e.g. `#f00` or `#ff0000`). + * If this is set to `Unset`, any color set by a lower priority state will be cleared/ + * If this is set to `undefined`, a lower priority state may be used. + * If no lower priority state is set, the default color will be used. + */ + backgroundColor?: string | Unset; + + /** + * The icon to display in the badge. + * This should be a URL to an image file. + * If this is set to `Unset`, any icon set by a lower priority state will be cleared. + * If this is set to `undefined`, a lower priority state may be used. + * If no lower priority state is set, the default icon will be used. + */ + icon?: Unset | BadgeIcon; +}; diff --git a/apps/browser/src/platform/badge/test/mock-badge-browser-api.ts b/apps/browser/src/platform/badge/test/mock-badge-browser-api.ts new file mode 100644 index 0000000000..19bde1e1fd --- /dev/null +++ b/apps/browser/src/platform/badge/test/mock-badge-browser-api.ts @@ -0,0 +1,21 @@ +import { BadgeBrowserApi, RawBadgeState } from "../badge-browser-api"; + +export class MockBadgeBrowserApi implements BadgeBrowserApi { + specificStates: Record = {}; + generalState?: RawBadgeState; + tabs: number[] = []; + + setState(state: RawBadgeState, tabId?: number): Promise { + if (tabId !== undefined) { + this.specificStates[tabId] = state; + } else { + this.generalState = state; + } + + return Promise.resolve(); + } + + getTabs(): Promise { + return Promise.resolve(this.tabs); + } +} diff --git a/apps/browser/src/platform/browser/browser-api.ts b/apps/browser/src/platform/browser/browser-api.ts index 4ef72fa007..d0bdaa504b 100644 --- a/apps/browser/src/platform/browser/browser-api.ts +++ b/apps/browser/src/platform/browser/browser-api.ts @@ -437,7 +437,7 @@ export class BrowserApi { * @param event - The event in which to add the listener to. * @param callback - The callback you want registered onto the event. */ - static addListener unknown>( + static addListener any>( event: chrome.events.Event, callback: T, ) { diff --git a/apps/browser/src/platform/listeners/update-badge.ts b/apps/browser/src/platform/listeners/update-badge.ts deleted file mode 100644 index c168ae44f3..0000000000 --- a/apps/browser/src/platform/listeners/update-badge.ts +++ /dev/null @@ -1,217 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { firstValueFrom } from "rxjs"; - -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; -import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { getOptionalUserId } from "@bitwarden/common/auth/services/account.service"; -import { BadgeSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/badge-settings.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; - -import MainBackground from "../../background/main.background"; -import IconDetails from "../../vault/background/models/icon-details"; -import { BrowserApi } from "../browser/browser-api"; - -export type BadgeOptions = { - tab?: chrome.tabs.Tab; - windowId?: number; -}; - -export class UpdateBadge { - private authService: AuthService; - private badgeSettingsService: BadgeSettingsServiceAbstraction; - private cipherService: CipherService; - private accountService: AccountService; - private badgeAction: typeof chrome.action | typeof chrome.browserAction; - private sidebarAction: OperaSidebarAction | FirefoxSidebarAction; - private win: Window & typeof globalThis; - private platformUtilsService: PlatformUtilsService; - - constructor(win: Window & typeof globalThis, services: MainBackground) { - this.badgeAction = BrowserApi.getBrowserAction(); - this.sidebarAction = BrowserApi.getSidebarAction(self); - this.win = win; - - this.badgeSettingsService = services.badgeSettingsService; - this.authService = services.authService; - this.cipherService = services.cipherService; - this.accountService = services.accountService; - this.platformUtilsService = services.platformUtilsService; - } - - async run(opts?: { tabId?: number; windowId?: number }): Promise { - const authStatus = await this.authService.getAuthStatus(); - - await this.setBadgeBackgroundColor(); - - switch (authStatus) { - case AuthenticationStatus.LoggedOut: { - await this.setLoggedOut(); - break; - } - case AuthenticationStatus.Locked: { - await this.setLocked(); - break; - } - case AuthenticationStatus.Unlocked: { - const tab = await this.getTab(opts?.tabId, opts?.windowId); - await this.setUnlocked({ tab, windowId: tab?.windowId }); - break; - } - } - } - - async setLoggedOut(): Promise { - await this.setBadgeIcon("_gray"); - await this.clearBadgeText(); - } - - async setLocked() { - await this.setBadgeIcon("_locked"); - await this.clearBadgeText(); - } - - private async clearBadgeText() { - const tabs = await BrowserApi.getActiveTabs(); - if (tabs != null) { - tabs.forEach(async (tab) => { - if (tab.id != null) { - await this.setBadgeText("", tab.id); - } - }); - } - } - - async setUnlocked(opts: BadgeOptions) { - await this.setBadgeIcon(""); - - const enableBadgeCounter = await firstValueFrom(this.badgeSettingsService.enableBadgeCounter$); - if (!enableBadgeCounter) { - return; - } - - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(getOptionalUserId), - ); - if (!activeUserId) { - return; - } - - const ciphers = await this.cipherService.getAllDecryptedForUrl(opts?.tab?.url, activeUserId); - let countText = ciphers.length == 0 ? "" : ciphers.length.toString(); - if (ciphers.length > 9) { - countText = "9+"; - } - await this.setBadgeText(countText, opts?.tab?.id); - } - - setBadgeBackgroundColor(color = "#294e5f") { - if (this.badgeAction?.setBadgeBackgroundColor) { - // 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.badgeAction.setBadgeBackgroundColor({ color }); - } - if (this.isOperaSidebar(this.sidebarAction)) { - this.sidebarAction.setBadgeBackgroundColor({ color }); - } - } - - setBadgeText(text: string, tabId?: number) { - this.setActionText(text, tabId); - this.setSideBarText(text, tabId); - } - - async setBadgeIcon(iconSuffix: string, windowId?: number) { - const options: IconDetails = { - path: { - 19: "/images/icon19" + iconSuffix + ".png", - 38: "/images/icon38" + iconSuffix + ".png", - }, - }; - if (windowId && this.platformUtilsService.isFirefox()) { - options.windowId = windowId; - } - - await this.setActionIcon(options); - await this.setSidebarActionIcon(options); - } - - private setActionText(text: string, tabId?: number) { - if (this.badgeAction?.setBadgeText) { - // 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.badgeAction.setBadgeText({ text, tabId }); - } - } - - private setSideBarText(text: string, tabId?: number) { - if (this.isOperaSidebar(this.sidebarAction)) { - this.sidebarAction.setBadgeText({ text, tabId }); - } else if (this.sidebarAction) { - // Firefox - const title = `Bitwarden${Utils.isNullOrEmpty(text) ? "" : ` [${text}]`}`; - // 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.sidebarAction.setTitle({ title, tabId }); - } - } - - private async setActionIcon(options: IconDetails) { - if (!this.badgeAction?.setIcon) { - return; - } - - if (this.useSyncApiCalls) { - // 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.badgeAction.setIcon(options); - } else { - await new Promise((resolve) => this.badgeAction.setIcon(options, () => resolve())); - } - } - - private async setSidebarActionIcon(options: IconDetails) { - if (!this.sidebarAction?.setIcon) { - return; - } - - if ("opr" in this.win && BrowserApi.isManifestVersion(3)) { - // setIcon API is currenly broken for Opera MV3 extensions - // https://forums.opera.com/topic/75680/opr-sidebaraction-seticon-api-is-broken-access-to-extension-api-denied?_=1738349261570 - // The API currently crashes on MacOS - return; - } - - if (this.isOperaSidebar(this.sidebarAction)) { - await new Promise((resolve) => - (this.sidebarAction as OperaSidebarAction).setIcon(options, () => resolve()), - ); - } else { - await this.sidebarAction.setIcon(options); - } - } - - private async getTab(tabId?: number, windowId?: number) { - return ( - (await BrowserApi.getTab(tabId)) ?? - (windowId - ? await BrowserApi.tabsQueryFirst({ active: true, windowId }) - : await BrowserApi.tabsQueryFirst({ active: true, currentWindow: true })) ?? - (await BrowserApi.tabsQueryFirst({ active: true, lastFocusedWindow: true })) ?? - (await BrowserApi.tabsQueryFirst({ active: true })) - ); - } - - private get useSyncApiCalls() { - return this.platformUtilsService.isFirefox() || this.platformUtilsService.isSafari(); - } - - private isOperaSidebar( - action: OperaSidebarAction | FirefoxSidebarAction, - ): action is OperaSidebarAction { - return action != null && (action as OperaSidebarAction).setBadgeText != null; - } -} diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 593a28d04c..7472520189 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -106,6 +106,9 @@ export const NEW_WEB_LAYOUT_BANNER_DISK = new StateDefinition("newWebLayoutBanne export const APPLICATION_ID_DISK = new StateDefinition("applicationId", "disk", { web: "disk-local", }); +export const BADGE_MEMORY = new StateDefinition("badge", "memory", { + browser: "memory-large-object", +}); export const BIOMETRIC_SETTINGS_DISK = new StateDefinition("biometricSettings", "disk"); export const CLEAR_EVENT_DISK = new StateDefinition("clearEvent", "disk"); export const CONFIG_DISK = new StateDefinition("config", "disk", {