1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-20210] Expand badge API (#14801)

* feat: scaffold new badge service structure

* feat: add state override

* feat: add priority-based override

* feat: implement state clearing

* feat: add docs to badge service functions

* feat: add support for setting icon

* feat: implement unsetting

* feat: implement setting text

* feat: add support for setting background

* fix: default icon

* feat: clean up old update-badge

* feat: save state using StateProvider

* feat: migrate auth status badge updating

* feat: migrate autofill badge updating

* fix: auto set to default values

* chore: woops, clean up copy-pasta

* fix: lint and types

* chore: nit updates from PR review

* feat: remove ability to send in arbitrary icons

* feat: move init to separate function

* fix: wrong import

* fix: typing issues

* fix: try again to fix typing issues

* feat: scaffold tests for new tabId-specific states

* feat: add diffence util function

* feat: add support for limiting state to tabId

* feat: re-implement autofill badge updater to only update when a tab actually changes

* feat[wip]: always set all tabs when changing the general state

* feat[wip]: implement general states for mutliple open tabs

* feat[wip]: implement fully working multi-tab functionality

* feat: optimize api calls

* feat: adjust storage

* chore: clean up old code

* chore: remove unused log service

* chore: minor tweaks

* fix: types

* fix: race condition causing wrong icon on startup

The service assumes that the first emission from the state will be an empty one and discards it
(techincally it just doesn't act on it because pairwise requires a minimum two emissions). This
caused issues when a service is able to update the state before the observable got a change to
properly initialize. To fix this we simply force an empty emission before anything else,
that way we will always react to the emission from the state provider (because that would end up
being the second emission). We then use distinctUntilChanged to avoid unecessarily acting on
an empty state.
This commit is contained in:
Andreas Coroiu
2025-07-09 21:38:33 +02:00
committed by GitHub
parent b62f6c7eb5
commit 90b7197279
19 changed files with 1237 additions and 241 deletions

View File

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

View File

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

View File

@@ -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),
]);

View File

@@ -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<chrome.tabs.Tab>();
private tabRemoved$ = new Subject<number>();
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));
}
}

View File

@@ -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<void>((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<string> => {

View File

@@ -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": {

View File

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

View File

@@ -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<T>(a: Set<T>, b: Set<T>): [Set<T>, Set<T>] {
const intersection = new Set<T>([...a].filter((x) => b.has(x)));
a = new Set<T>([...a].filter((x) => !intersection.has(x)));
b = new Set<T>([...b].filter((x) => !intersection.has(x)));
return [a, b];
}

View File

@@ -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<void>;
getTabs(): Promise<number[]>;
}
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<void> {
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<number[]> {
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<void>((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<void>((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;
}
}

View File

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

View File

@@ -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<string, StateSetting>) => value ?? {},
});
export class BadgeService {
private states: GlobalState<Record<string, StateSetting>>;
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<StateSetting>, 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<Partial<RawBadgeState>>((acc: Partial<RawBadgeState>, 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<Key extends keyof BadgeState & keyof RawBadgeState>(
newState: Partial<RawBadgeState>,
state: BadgeState,
key: Key,
) {
if (state[key] === Unset) {
delete newState[key];
} else if (state[key] !== undefined) {
newState[key] = state[key] as RawBadgeState[Key];
}
}

View File

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

View File

@@ -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<string, IconPaths>;
export type BadgeIcon = (typeof BadgeIcon)[keyof typeof BadgeIcon];
export type IconPaths = {
19: string;
38: string;
};

View File

@@ -0,0 +1,7 @@
export const BadgeStatePriority = {
Low: 0,
Default: 100,
High: 200,
} as const;
export type BadgeStatePriority = (typeof BadgeStatePriority)[keyof typeof BadgeStatePriority];

View File

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

View File

@@ -0,0 +1,21 @@
import { BadgeBrowserApi, RawBadgeState } from "../badge-browser-api";
export class MockBadgeBrowserApi implements BadgeBrowserApi {
specificStates: Record<number, RawBadgeState> = {};
generalState?: RawBadgeState;
tabs: number[] = [];
setState(state: RawBadgeState, tabId?: number): Promise<void> {
if (tabId !== undefined) {
this.specificStates[tabId] = state;
} else {
this.generalState = state;
}
return Promise.resolve();
}
getTabs(): Promise<number[]> {
return Promise.resolve(this.tabs);
}
}

View File

@@ -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<T extends (...args: readonly unknown[]) => unknown>(
static addListener<T extends (...args: readonly any[]) => any>(
event: chrome.events.Event<T>,
callback: T,
) {

View File

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

View File

@@ -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", {