mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
[PM-25488] Badge stays after lock when using pin (#16436)
* wip * feat: add dynamic states * feat: re-implement badge service with dynamic state functions * feat: completely remove old static states * feat: debounce calls to badge api per tab * feat: use group-by to avoid re-setting all tabs on 1 tab change * feat: simplify autofill badge updater * feat: add hanging function test * chore: clean up badge service * feat: simplify private updateBadge * feat: remove unnecessary Set usage * fix: tests that broke after setState rename * chore: clean up badge api
This commit is contained in:
@@ -17,8 +17,8 @@ export class AuthStatusBadgeUpdaterService {
|
||||
private accountService: AccountService,
|
||||
private authService: AuthService,
|
||||
) {
|
||||
this.accountService.activeAccount$
|
||||
.pipe(
|
||||
this.badgeService.setState(StateName, (_tab) =>
|
||||
this.accountService.activeAccount$.pipe(
|
||||
switchMap((account) =>
|
||||
account
|
||||
? this.authService.authStatusFor$(account.id)
|
||||
@@ -27,30 +27,36 @@ export class AuthStatusBadgeUpdaterService {
|
||||
mergeMap(async (authStatus) => {
|
||||
switch (authStatus) {
|
||||
case AuthenticationStatus.LoggedOut: {
|
||||
await this.badgeService.setState(StateName, BadgeStatePriority.High, {
|
||||
icon: BadgeIcon.LoggedOut,
|
||||
backgroundColor: Unset,
|
||||
text: Unset,
|
||||
});
|
||||
break;
|
||||
return {
|
||||
priority: BadgeStatePriority.High,
|
||||
state: {
|
||||
icon: BadgeIcon.LoggedOut,
|
||||
backgroundColor: Unset,
|
||||
text: Unset,
|
||||
},
|
||||
};
|
||||
}
|
||||
case AuthenticationStatus.Locked: {
|
||||
await this.badgeService.setState(StateName, BadgeStatePriority.High, {
|
||||
icon: BadgeIcon.Locked,
|
||||
backgroundColor: Unset,
|
||||
text: Unset,
|
||||
});
|
||||
break;
|
||||
return {
|
||||
priority: BadgeStatePriority.High,
|
||||
state: {
|
||||
icon: BadgeIcon.Locked,
|
||||
backgroundColor: Unset,
|
||||
text: Unset,
|
||||
},
|
||||
};
|
||||
}
|
||||
case AuthenticationStatus.Unlocked: {
|
||||
await this.badgeService.setState(StateName, BadgeStatePriority.Low, {
|
||||
icon: BadgeIcon.Unlocked,
|
||||
});
|
||||
break;
|
||||
return {
|
||||
priority: BadgeStatePriority.Low,
|
||||
state: {
|
||||
icon: BadgeIcon.Unlocked,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { combineLatest, distinctUntilChanged, mergeMap, of, switchMap, withLatestFrom } from "rxjs";
|
||||
import { combineLatest, delay, distinctUntilChanged, mergeMap, of, switchMap } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BadgeSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/badge-settings.service";
|
||||
@@ -10,7 +10,7 @@ import { Tab } from "../../platform/badge/badge-browser-api";
|
||||
import { BadgeService } from "../../platform/badge/badge.service";
|
||||
import { BadgeStatePriority } from "../../platform/badge/priority";
|
||||
|
||||
const StateName = (tabId: number) => `autofill-badge-${tabId}`;
|
||||
const StateName = "autofill-badge-updater";
|
||||
|
||||
export class AutofillBadgeUpdaterService {
|
||||
constructor(
|
||||
@@ -26,56 +26,30 @@ export class AutofillBadgeUpdaterService {
|
||||
switchMap((account) => (account?.id ? this.cipherService.ciphers$(account?.id) : of([]))),
|
||||
);
|
||||
|
||||
// Recalculate badges for all active tabs when ciphers or active account changes
|
||||
combineLatest({
|
||||
account: this.accountService.activeAccount$,
|
||||
enableBadgeCounter:
|
||||
this.badgeSettingsService.enableBadgeCounter$.pipe(distinctUntilChanged()),
|
||||
ciphers: ciphers$,
|
||||
})
|
||||
.pipe(
|
||||
this.badgeService.setState(StateName, (tab) => {
|
||||
return combineLatest({
|
||||
account: this.accountService.activeAccount$,
|
||||
enableBadgeCounter:
|
||||
this.badgeSettingsService.enableBadgeCounter$.pipe(distinctUntilChanged()),
|
||||
ciphers: ciphers$.pipe(delay(100)), // Delay to allow cipherService.getAllDecryptedForUrl to pick up changes
|
||||
}).pipe(
|
||||
mergeMap(async ({ account, enableBadgeCounter }) => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tabs = await this.badgeService.getActiveTabs();
|
||||
|
||||
for (const tab of tabs) {
|
||||
if (!tab.tabId) {
|
||||
continue;
|
||||
}
|
||||
if (enableBadgeCounter) {
|
||||
await this.setTabState(tab, account.id);
|
||||
} else {
|
||||
await this.clearTabState(tab.tabId);
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
// Recalculate badge for a specific tab when it becomes active
|
||||
this.badgeService.activeTabsUpdated$
|
||||
.pipe(
|
||||
withLatestFrom(
|
||||
this.accountService.activeAccount$,
|
||||
this.badgeSettingsService.enableBadgeCounter$,
|
||||
),
|
||||
mergeMap(async ([tabs, account, enableBadgeCounter]) => {
|
||||
if (!account || !enableBadgeCounter) {
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const tab of tabs) {
|
||||
await this.setTabState(tab, account.id);
|
||||
}
|
||||
return {
|
||||
state: {
|
||||
text: await this.calculateCountText(tab, account.id),
|
||||
},
|
||||
priority: BadgeStatePriority.Default,
|
||||
};
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async setTabState(tab: Tab, userId: UserId) {
|
||||
private async calculateCountText(tab: Tab, userId: UserId) {
|
||||
if (!tab.tabId) {
|
||||
this.logService.warning("Tab event received but tab id is undefined");
|
||||
return;
|
||||
@@ -85,22 +59,9 @@ export class AutofillBadgeUpdaterService {
|
||||
const cipherCount = ciphers.length;
|
||||
|
||||
if (cipherCount === 0) {
|
||||
await this.clearTabState(tab.tabId);
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const countText = cipherCount > 9 ? "9+" : cipherCount.toString();
|
||||
await this.badgeService.setState(
|
||||
StateName(tab.tabId),
|
||||
BadgeStatePriority.Default,
|
||||
{
|
||||
text: countText,
|
||||
},
|
||||
tab.tabId,
|
||||
);
|
||||
}
|
||||
|
||||
private async clearTabState(tabId: number) {
|
||||
await this.badgeService.clearState(StateName(tabId));
|
||||
return cipherCount > 9 ? "9+" : cipherCount.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1435,7 +1435,6 @@ export default class MainBackground {
|
||||
);
|
||||
|
||||
this.badgeService = new BadgeService(
|
||||
this.stateProvider,
|
||||
new DefaultBadgeBrowserApi(this.platformUtilsService),
|
||||
this.logService,
|
||||
);
|
||||
@@ -1925,7 +1924,6 @@ export default class MainBackground {
|
||||
this.badgeService,
|
||||
this.accountService,
|
||||
this.cipherService,
|
||||
this.logService,
|
||||
this.taskService,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import { concat, defer, filter, map, merge, Observable, shareReplay, switchMap } from "rxjs";
|
||||
import {
|
||||
concat,
|
||||
concatMap,
|
||||
defer,
|
||||
filter,
|
||||
map,
|
||||
merge,
|
||||
Observable,
|
||||
of,
|
||||
pairwise,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
@@ -28,13 +40,37 @@ function tabFromChromeTab(tab: chrome.tabs.Tab): Tab {
|
||||
}
|
||||
|
||||
export interface BadgeBrowserApi {
|
||||
activeTabsUpdated$: Observable<Tab[]>;
|
||||
/**
|
||||
* An observable that emits all currently active tabs whenever one or more active tabs change.
|
||||
*/
|
||||
activeTabs$: Observable<Tab[]>;
|
||||
|
||||
/**
|
||||
* An observable that emits tab events such as updates and activations.
|
||||
*/
|
||||
tabEvents$: Observable<TabEvent>;
|
||||
|
||||
/**
|
||||
* Set the badge state for a specific tab.
|
||||
* If the tabId is undefined the state will be applied to the browser action in general.
|
||||
*/
|
||||
setState(state: RawBadgeState, tabId?: number): Promise<void>;
|
||||
getTabs(): Promise<number[]>;
|
||||
getActiveTabs(): Promise<Tab[]>;
|
||||
}
|
||||
|
||||
export type TabEvent =
|
||||
| {
|
||||
type: "updated";
|
||||
tab: Tab;
|
||||
}
|
||||
| {
|
||||
type: "activated";
|
||||
tab: Tab;
|
||||
}
|
||||
| {
|
||||
type: "deactivated";
|
||||
tabId: number;
|
||||
};
|
||||
|
||||
export class DefaultBadgeBrowserApi implements BadgeBrowserApi {
|
||||
private badgeAction = BrowserApi.getBrowserAction();
|
||||
private sidebarAction = BrowserApi.getSidebarAction(self);
|
||||
@@ -44,18 +80,25 @@ export class DefaultBadgeBrowserApi implements BadgeBrowserApi {
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
);
|
||||
|
||||
activeTabsUpdated$ = concat(
|
||||
defer(async () => await this.getActiveTabs()),
|
||||
private createdOrUpdatedTabEvents$ = concat(
|
||||
defer(async () => await this.getActiveTabs()).pipe(
|
||||
switchMap((activeTabs) => {
|
||||
const tabEvents: TabEvent[] = activeTabs.map((tab) => ({
|
||||
type: "activated",
|
||||
tab,
|
||||
}));
|
||||
return of(...tabEvents);
|
||||
}),
|
||||
),
|
||||
merge(
|
||||
this.onTabActivated$.pipe(
|
||||
switchMap(async (activeInfo) => {
|
||||
const tab = await BrowserApi.getTab(activeInfo.tabId);
|
||||
|
||||
if (tab == undefined || tab.id == undefined || tab.url == undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [tabFromChromeTab(tab)];
|
||||
switchMap(async (activeInfo) => await BrowserApi.getTab(activeInfo.tabId)),
|
||||
filter(
|
||||
(tab): tab is chrome.tabs.Tab =>
|
||||
!(tab == undefined || tab.id == undefined || tab.url == undefined),
|
||||
),
|
||||
switchMap(async (tab) => {
|
||||
return { type: "activated", tab: tabFromChromeTab(tab) } satisfies TabEvent;
|
||||
}),
|
||||
),
|
||||
fromChromeEvent(chrome.tabs.onUpdated).pipe(
|
||||
@@ -64,22 +107,58 @@ export class DefaultBadgeBrowserApi implements BadgeBrowserApi {
|
||||
// Only emit if the url was updated
|
||||
changeInfo.url != undefined,
|
||||
),
|
||||
map(([_tabId, _changeInfo, tab]) => [tabFromChromeTab(tab)]),
|
||||
map(
|
||||
([_tabId, _changeInfo, tab]) =>
|
||||
({ type: "updated", tab: tabFromChromeTab(tab) }) satisfies TabEvent,
|
||||
),
|
||||
),
|
||||
fromChromeEvent(chrome.webNavigation.onCommitted).pipe(
|
||||
filter(([details]) => details.transitionType === "reload"),
|
||||
map(([details]) => {
|
||||
const toReturn: Tab[] =
|
||||
details.transitionType === "reload" ? [{ tabId: details.tabId, url: details.url }] : [];
|
||||
return toReturn;
|
||||
return {
|
||||
type: "updated",
|
||||
tab: { tabId: details.tabId, url: details.url },
|
||||
} satisfies TabEvent;
|
||||
}),
|
||||
),
|
||||
// NOTE: We're only sharing the active tab changes, not the full list of active tabs.
|
||||
// This is so that any new subscriber will get the latest active tabs immediately, but
|
||||
// doesn't re-subscribe to chrome events.
|
||||
).pipe(shareReplay({ bufferSize: 1, refCount: true })),
|
||||
).pipe(filter((tabs) => tabs.length > 0));
|
||||
);
|
||||
|
||||
async getActiveTabs(): Promise<Tab[]> {
|
||||
tabEvents$ = merge(
|
||||
this.createdOrUpdatedTabEvents$,
|
||||
this.createdOrUpdatedTabEvents$.pipe(
|
||||
concatMap(async () => {
|
||||
return this.getActiveTabs();
|
||||
}),
|
||||
pairwise(),
|
||||
map(([previousTabs, currentTabs]) => {
|
||||
const previousTabIds = previousTabs.map((t) => t.tabId);
|
||||
const currentTabIds = currentTabs.map((t) => t.tabId);
|
||||
|
||||
const deactivatedTabIds = previousTabIds.filter((id) => !currentTabIds.includes(id));
|
||||
|
||||
return deactivatedTabIds.map(
|
||||
(tabId) =>
|
||||
({
|
||||
type: "deactivated",
|
||||
tabId,
|
||||
}) satisfies TabEvent,
|
||||
);
|
||||
}),
|
||||
switchMap((events) => of(...events)),
|
||||
),
|
||||
);
|
||||
|
||||
activeTabs$ = this.tabEvents$.pipe(
|
||||
concatMap(async () => {
|
||||
return this.getActiveTabs();
|
||||
}),
|
||||
);
|
||||
|
||||
private async getActiveTabs(): Promise<Tab[]> {
|
||||
const tabs = await BrowserApi.getActiveTabs();
|
||||
return tabs.filter((tab) => tab.id != undefined && tab.url != undefined).map(tabFromChromeTab);
|
||||
}
|
||||
@@ -96,10 +175,6 @@ export class DefaultBadgeBrowserApi implements BadgeBrowserApi {
|
||||
]);
|
||||
}
|
||||
|
||||
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)]);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,69 +1,100 @@
|
||||
import { concatMap, filter, Subscription, withLatestFrom } from "rxjs";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
combineLatestWith,
|
||||
concatMap,
|
||||
debounceTime,
|
||||
filter,
|
||||
groupBy,
|
||||
map,
|
||||
mergeMap,
|
||||
Observable,
|
||||
of,
|
||||
startWith,
|
||||
Subscription,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
} from "rxjs";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import {
|
||||
BADGE_MEMORY,
|
||||
GlobalState,
|
||||
KeyDefinition,
|
||||
StateProvider,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
|
||||
import { BadgeBrowserApi, RawBadgeState, Tab } from "./badge-browser-api";
|
||||
import { DefaultBadgeState } from "./consts";
|
||||
import { BadgeStatePriority } from "./priority";
|
||||
import { BadgeState, Unset } from "./state";
|
||||
|
||||
interface StateSetting {
|
||||
const BADGE_UPDATE_DEBOUNCE_MS = 100;
|
||||
|
||||
export interface BadgeStateSetting {
|
||||
priority: BadgeStatePriority;
|
||||
state: BadgeState;
|
||||
tabId?: number;
|
||||
}
|
||||
|
||||
const BADGE_STATES = new KeyDefinition(BADGE_MEMORY, "badgeStates", {
|
||||
deserializer: (value: Record<string, StateSetting>) => value ?? {},
|
||||
cleanupDelayMs: 0,
|
||||
});
|
||||
/**
|
||||
* A function that returns the badge state for a specific tab.
|
||||
* Return `undefined` to clear any previously set state for the tab.
|
||||
*/
|
||||
export type BadgeStateFunction = (tab: Tab) => Observable<BadgeStateSetting | undefined>;
|
||||
|
||||
export class BadgeService {
|
||||
private serviceState: GlobalState<Record<string, StateSetting>>;
|
||||
|
||||
/**
|
||||
* An observable that emits whenever one or multiple tabs are updated and might need its state updated.
|
||||
* Use this to know exactly which tabs to calculate the badge state for.
|
||||
* This is not the same as `onActivated` which only emits when the active tab changes.
|
||||
*/
|
||||
activeTabsUpdated$ = this.badgeApi.activeTabsUpdated$;
|
||||
|
||||
getActiveTabs(): Promise<Tab[]> {
|
||||
return this.badgeApi.getActiveTabs();
|
||||
}
|
||||
private stateFunctions = new BehaviorSubject<Record<string, BadgeStateFunction>>({});
|
||||
|
||||
constructor(
|
||||
private stateProvider: StateProvider,
|
||||
private badgeApi: BadgeBrowserApi,
|
||||
private logService: LogService,
|
||||
) {
|
||||
this.serviceState = this.stateProvider.getGlobal(BADGE_STATES);
|
||||
}
|
||||
private debounceTimeMs: number = BADGE_UPDATE_DEBOUNCE_MS,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Start listening for badge state changes.
|
||||
* Without this the service will not be able to update the badge state.
|
||||
*/
|
||||
startListening(): Subscription {
|
||||
// React to tab changes
|
||||
return this.badgeApi.activeTabsUpdated$
|
||||
// Default state function that always returns an empty state with lowest priority.
|
||||
// This will ensure that there is always at least one state to consider when calculating the final badge state,
|
||||
// so that the badge is cleared/set to default when no other states are set.
|
||||
const defaultTabStateFunction: BadgeStateFunction = (_tab) =>
|
||||
of({
|
||||
priority: BadgeStatePriority.Low,
|
||||
state: {},
|
||||
});
|
||||
|
||||
return this.badgeApi.tabEvents$
|
||||
.pipe(
|
||||
withLatestFrom(this.serviceState.state$),
|
||||
filter(([activeTabs]) => activeTabs.length > 0),
|
||||
concatMap(async ([activeTabs, serviceState]) => {
|
||||
await Promise.all(activeTabs.map((tab) => this.updateBadge(serviceState, tab.tabId)));
|
||||
groupBy((event) => (event.type === "deactivated" ? event.tabId : event.tab.tabId), {
|
||||
duration: (group$) =>
|
||||
// Allow clean up of group when deactivated event arrives for this tabId
|
||||
group$.pipe(filter((evt) => evt.type === "deactivated")),
|
||||
}),
|
||||
mergeMap((group$) =>
|
||||
group$.pipe(
|
||||
// ignore deactivation events, only handle updates/activations
|
||||
filter((evt) => evt.type !== "deactivated"),
|
||||
map((evt) => evt.tab),
|
||||
combineLatestWith(this.stateFunctions),
|
||||
switchMap(([tab, dynamicStateFunctions]) => {
|
||||
const functions = [...Object.values(dynamicStateFunctions), defaultTabStateFunction];
|
||||
|
||||
return combineLatest(functions.map((f) => f(tab).pipe(startWith(undefined)))).pipe(
|
||||
map((states) => ({
|
||||
tab,
|
||||
states: states.filter((s): s is BadgeStateSetting => s !== undefined),
|
||||
})),
|
||||
debounceTime(this.debounceTimeMs),
|
||||
);
|
||||
}),
|
||||
takeUntil(group$.pipe(filter((evt) => evt.type === "deactivated"))),
|
||||
),
|
||||
),
|
||||
|
||||
concatMap(async (tabUpdate) => {
|
||||
await this.updateBadge(tabUpdate.states, tabUpdate.tab.tabId);
|
||||
}),
|
||||
)
|
||||
.subscribe({
|
||||
error: (error: unknown) => {
|
||||
this.logService.error(
|
||||
"Fatal error in badge service observable, badge will fail to update",
|
||||
"BadgeService: Fatal error updating badge state. Badge will no longer be updated.",
|
||||
error,
|
||||
);
|
||||
},
|
||||
@@ -71,68 +102,45 @@ export class BadgeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inform badge service of a new state that the badge should reflect.
|
||||
* Register a function that takes an observable of active tab updates and returns an observable of state settings.
|
||||
* This can be used to create dynamic badge states that react to tab changes.
|
||||
* The returned observable should emit a new state setting whenever the badge state should be updated.
|
||||
*
|
||||
* This will merge the new state with any existing states:
|
||||
* This will merge all 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) {
|
||||
const newServiceState = await this.serviceState.update((s) => ({
|
||||
...s,
|
||||
[name]: { priority, state, tabId },
|
||||
}));
|
||||
await this.updateBadge(newServiceState, tabId);
|
||||
setState(name: string, stateFunction: BadgeStateFunction) {
|
||||
this.stateFunctions.next({
|
||||
...this.stateFunctions.value,
|
||||
[name]: stateFunction,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the state with the given name.
|
||||
* Clear a state function previously registered with `setState`.
|
||||
*
|
||||
* This will remove the state from the badge service and clear it from the badge.
|
||||
* If the state is not found, nothing will happen.
|
||||
* This will:
|
||||
* - Stop the function from being called on future tab changes
|
||||
* - Unsubscribe from any existing observables created by the function.
|
||||
* - Clear any badge state previously set by the function.
|
||||
*
|
||||
* @param name The name of the state to clear.
|
||||
* @param name The name of the state function to clear.
|
||||
*/
|
||||
async clearState(name: string) {
|
||||
let clearedState: StateSetting | undefined;
|
||||
|
||||
const newServiceState = await this.serviceState.update((s) => {
|
||||
clearedState = s?.[name];
|
||||
|
||||
const newStates = { ...s };
|
||||
delete newStates[name];
|
||||
return newStates;
|
||||
});
|
||||
|
||||
if (clearedState === undefined) {
|
||||
return;
|
||||
}
|
||||
await this.updateBadge(newServiceState, clearedState.tabId);
|
||||
clearState(name: string) {
|
||||
const currentDynamicStateFunctions = this.stateFunctions.value;
|
||||
const newDynamicStateFunctions = { ...currentDynamicStateFunctions };
|
||||
delete newDynamicStateFunctions[name];
|
||||
this.stateFunctions.next(newDynamicStateFunctions);
|
||||
}
|
||||
|
||||
private calculateState(states: Set<StateSetting>, tabId?: number): RawBadgeState {
|
||||
const sortedStates = [...states].sort((a, b) => a.priority - b.priority);
|
||||
private calculateState(states: BadgeStateSetting[]): 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
|
||||
const mergedState = sortedStates
|
||||
.map((s) => s.state)
|
||||
.reduce<Partial<RawBadgeState>>((acc: Partial<RawBadgeState>, state: BadgeState) => {
|
||||
const newState = { ...acc };
|
||||
@@ -156,43 +164,16 @@ export class BadgeService {
|
||||
* This will only update the badge if the active tab is the same as the tabId of the latest change.
|
||||
* If the active tab is not set, it will not update the badge.
|
||||
*
|
||||
* @param activeTab The currently active tab.
|
||||
* @param serviceState The current state of the badge service. If this is null or undefined, an empty set will be assumed.
|
||||
* @param tabId Tab id for which the the latest state change applied to. Set this to activeTab.tabId to force an update.
|
||||
* @param activeTabs The currently active tabs. If not provided, it will be fetched from the badge API.
|
||||
*/
|
||||
private async updateBadge(
|
||||
serviceState: Record<string, StateSetting> | null | undefined,
|
||||
tabId: number | undefined,
|
||||
) {
|
||||
const activeTabs = await this.badgeApi.getActiveTabs();
|
||||
if (tabId !== undefined && !activeTabs.some((tab) => tab.tabId === tabId)) {
|
||||
return; // No need to update the badge if the state is not for the active tab.
|
||||
}
|
||||
|
||||
const tabIdsToUpdate = tabId ? [tabId] : activeTabs.map((tab) => tab.tabId);
|
||||
|
||||
for (const tabId of tabIdsToUpdate) {
|
||||
if (tabId === undefined) {
|
||||
continue; // Skip if tab id is undefined.
|
||||
}
|
||||
|
||||
const newBadgeState = this.calculateState(new Set(Object.values(serviceState ?? {})), tabId);
|
||||
try {
|
||||
await this.badgeApi.setState(newBadgeState, tabId);
|
||||
} catch (error) {
|
||||
this.logService.error("Failed to set badge state", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (tabId === undefined) {
|
||||
// If no tabId was provided we should also update the general badge state
|
||||
const newBadgeState = this.calculateState(new Set(Object.values(serviceState ?? {})));
|
||||
|
||||
try {
|
||||
await this.badgeApi.setState(newBadgeState, tabId);
|
||||
} catch (error) {
|
||||
this.logService.error("Failed to set general badge state", error);
|
||||
}
|
||||
private async updateBadge(serviceState: BadgeStateSetting[], tabId: number) {
|
||||
const newBadgeState = this.calculateState(serviceState);
|
||||
try {
|
||||
await this.badgeApi.setState(newBadgeState, tabId);
|
||||
} catch (error) {
|
||||
this.logService.error("Failed to set badge state", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
23
apps/browser/src/platform/badge/scope.ts
Normal file
23
apps/browser/src/platform/badge/scope.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export const BadgeStateScope = {
|
||||
/**
|
||||
* The state is global and applies to all users.
|
||||
*/
|
||||
Global: { type: "global" } satisfies BadgeStateScope,
|
||||
/**
|
||||
* The state is for a specific user and only applies to that user when they are unlocked.
|
||||
*/
|
||||
UserUnlocked: (userId: string) =>
|
||||
({
|
||||
type: "user_unlocked",
|
||||
userId,
|
||||
}) satisfies BadgeStateScope,
|
||||
} as const;
|
||||
|
||||
export type BadgeStateScope =
|
||||
| {
|
||||
type: "global";
|
||||
}
|
||||
| {
|
||||
type: "user_unlocked";
|
||||
userId: string;
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import { BadgeIcon } from "./icon";
|
||||
|
||||
export const Unset = Symbol("Unset badge state");
|
||||
const UnsetValue = Symbol("Unset badge state");
|
||||
|
||||
export const Unset = UnsetValue as typeof UnsetValue;
|
||||
export type Unset = typeof Unset;
|
||||
|
||||
export type BadgeState = {
|
||||
|
||||
@@ -1,33 +1,50 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { BehaviorSubject, concat, defer, of, Subject, switchMap } from "rxjs";
|
||||
|
||||
import { BadgeBrowserApi, RawBadgeState, Tab } from "../badge-browser-api";
|
||||
import { BadgeBrowserApi, RawBadgeState, Tab, TabEvent } from "../badge-browser-api";
|
||||
|
||||
export class MockBadgeBrowserApi implements BadgeBrowserApi {
|
||||
private _activeTabsUpdated$ = new BehaviorSubject<Tab[]>([]);
|
||||
activeTabsUpdated$ = this._activeTabsUpdated$.asObservable();
|
||||
private _activeTabs$ = new BehaviorSubject<Tab[]>([]);
|
||||
private _tabEvents$ = new Subject<TabEvent>();
|
||||
activeTabs$ = this._activeTabs$.asObservable();
|
||||
|
||||
specificStates: Record<number, RawBadgeState> = {};
|
||||
generalState?: RawBadgeState;
|
||||
tabs: number[] = [];
|
||||
activeTabs: number[] = [];
|
||||
|
||||
getActiveTabs(): Promise<Tab[]> {
|
||||
return Promise.resolve(
|
||||
this.activeTabs.map(
|
||||
(tabId) =>
|
||||
({
|
||||
tabId,
|
||||
url: `https://example.com/${tabId}`,
|
||||
}) satisfies Tab,
|
||||
),
|
||||
);
|
||||
tabEvents$ = concat(
|
||||
defer(() => [this.activeTabs]).pipe(
|
||||
switchMap((activeTabs) => {
|
||||
const tabEvents: TabEvent[] = activeTabs.map((tab) => ({
|
||||
type: "activated",
|
||||
tab,
|
||||
}));
|
||||
return of(...tabEvents);
|
||||
}),
|
||||
),
|
||||
this._tabEvents$.asObservable(),
|
||||
);
|
||||
|
||||
get activeTabs() {
|
||||
return this._activeTabs$.value;
|
||||
}
|
||||
|
||||
setActiveTabs(tabs: number[]) {
|
||||
this.activeTabs = tabs;
|
||||
this._activeTabsUpdated$.next(
|
||||
tabs.map((tabId) => ({ tabId, url: `https://example.com/${tabId}` })),
|
||||
);
|
||||
this._activeTabs$.next(tabs.map((tabId) => ({ tabId, url: `https://example.com/${tabId}` })));
|
||||
|
||||
tabs.forEach((tabId) => {
|
||||
this._tabEvents$.next({
|
||||
type: "activated",
|
||||
tab: { tabId, url: `https://example.com/${tabId}` },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updateTab(tabId: number) {
|
||||
this._tabEvents$.next({ type: "updated", tab: { tabId, url: `https://example.com/${tabId}` } });
|
||||
}
|
||||
|
||||
deactivateTab(tabId: number) {
|
||||
this._tabEvents$.next({ type: "deactivated", tabId });
|
||||
}
|
||||
|
||||
setState = jest.fn().mockImplementation((state: RawBadgeState, tabId?: number): Promise<void> => {
|
||||
@@ -39,8 +56,4 @@ export class MockBadgeBrowserApi implements BadgeBrowserApi {
|
||||
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
getTabs(): Promise<number[]> {
|
||||
return Promise.resolve(this.tabs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { BehaviorSubject, firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { SecurityTask, TaskService } from "@bitwarden/common/vault/tasks";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
import { SecurityTask, SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
|
||||
|
||||
import { BadgeService } from "../../platform/badge/badge.service";
|
||||
import { Tab } from "../../platform/badge/badge-browser-api";
|
||||
import { BadgeService, BadgeStateFunction } from "../../platform/badge/badge.service";
|
||||
import { BadgeIcon } from "../../platform/badge/icon";
|
||||
import { BadgeStatePriority } from "../../platform/badge/priority";
|
||||
import { Unset } from "../../platform/badge/state";
|
||||
@@ -18,34 +17,32 @@ describe("AtRiskCipherBadgeUpdaterService", () => {
|
||||
let service: AtRiskCipherBadgeUpdaterService;
|
||||
|
||||
let setState: jest.Mock;
|
||||
let clearState: jest.Mock;
|
||||
let warning: jest.Mock;
|
||||
let getAllDecryptedForUrl: jest.Mock;
|
||||
let getTab: jest.Mock;
|
||||
let addListener: jest.Mock;
|
||||
|
||||
const activeAccount$ = new BehaviorSubject({ id: "test-account-id" });
|
||||
const cipherViews$ = new BehaviorSubject([]);
|
||||
const pendingTasks$ = new BehaviorSubject<SecurityTask[]>([]);
|
||||
const userId = "test-user-id" as UserId;
|
||||
let activeAccount$: BehaviorSubject<{ id: string }>;
|
||||
let cipherViews$: BehaviorSubject<Array<{ id: string; isDeleted?: boolean }>>;
|
||||
let pendingTasks$: BehaviorSubject<SecurityTask[]>;
|
||||
|
||||
beforeEach(async () => {
|
||||
setState = jest.fn().mockResolvedValue(undefined);
|
||||
clearState = jest.fn().mockResolvedValue(undefined);
|
||||
warning = jest.fn();
|
||||
getAllDecryptedForUrl = jest.fn().mockResolvedValue([]);
|
||||
getTab = jest.fn();
|
||||
addListener = jest.fn();
|
||||
|
||||
activeAccount$ = new BehaviorSubject({ id: "test-account-id" });
|
||||
cipherViews$ = new BehaviorSubject<Array<{ id: string; isDeleted?: boolean }>>([]);
|
||||
pendingTasks$ = new BehaviorSubject<SecurityTask[]>([]);
|
||||
|
||||
jest.spyOn(BrowserApi, "addListener").mockImplementation(addListener);
|
||||
jest.spyOn(BrowserApi, "getTab").mockImplementation(getTab);
|
||||
|
||||
service = new AtRiskCipherBadgeUpdaterService(
|
||||
{ setState, clearState } as unknown as BadgeService,
|
||||
{ setState } as unknown as BadgeService,
|
||||
{ activeAccount$ } as unknown as AccountService,
|
||||
{ cipherViews$, getAllDecryptedForUrl } as unknown as CipherService,
|
||||
{ warning } as unknown as LogService,
|
||||
{ pendingTasks$ } as unknown as TaskService,
|
||||
{ cipherViews$: () => cipherViews$, getAllDecryptedForUrl } as unknown as CipherService,
|
||||
{ pendingTasks$: () => pendingTasks$ } as unknown as TaskService,
|
||||
);
|
||||
|
||||
await service.init();
|
||||
@@ -55,30 +52,41 @@ describe("AtRiskCipherBadgeUpdaterService", () => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("registers dynamic state function on init", () => {
|
||||
expect(setState).toHaveBeenCalledWith("at-risk-cipher-badge", expect.any(Function));
|
||||
});
|
||||
|
||||
it("clears the tab state when there are no ciphers and no pending tasks", async () => {
|
||||
const tab = { id: 1 } as chrome.tabs.Tab;
|
||||
const tab: Tab = { tabId: 1, url: "https://bitwarden.com" };
|
||||
const stateFunction = setState.mock.calls[0][1];
|
||||
|
||||
await service["setTabState"](tab, userId, []);
|
||||
const state = await firstValueFrom(stateFunction(tab));
|
||||
|
||||
expect(clearState).toHaveBeenCalledWith("at-risk-cipher-badge-1");
|
||||
expect(state).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets state when there are pending tasks for the tab", async () => {
|
||||
const tab = { id: 3, url: "https://bitwarden.com" } as chrome.tabs.Tab;
|
||||
const pendingTasks: SecurityTask[] = [{ id: "task1", cipherId: "cipher1" } as SecurityTask];
|
||||
const tab: Tab = { tabId: 3, url: "https://bitwarden.com" };
|
||||
const stateFunction: BadgeStateFunction = setState.mock.calls[0][1];
|
||||
const pendingTasks: SecurityTask[] = [
|
||||
{
|
||||
id: "task1",
|
||||
cipherId: "cipher1",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
} as SecurityTask,
|
||||
];
|
||||
pendingTasks$.next(pendingTasks);
|
||||
getAllDecryptedForUrl.mockResolvedValueOnce([{ id: "cipher1" }]);
|
||||
|
||||
await service["setTabState"](tab, userId, pendingTasks);
|
||||
const state = await firstValueFrom(stateFunction(tab));
|
||||
|
||||
expect(setState).toHaveBeenCalledWith(
|
||||
"at-risk-cipher-badge-3",
|
||||
BadgeStatePriority.High,
|
||||
{
|
||||
expect(state).toEqual({
|
||||
priority: BadgeStatePriority.High,
|
||||
state: {
|
||||
icon: BadgeIcon.Berry,
|
||||
text: Unset,
|
||||
backgroundColor: Unset,
|
||||
},
|
||||
3,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
import { combineLatest, map, mergeMap, of, Subject, switchMap } from "rxjs";
|
||||
import { combineLatest, concatMap, map, of, switchMap } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.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 { SecurityTask, SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
|
||||
import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
|
||||
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
|
||||
|
||||
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";
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
|
||||
const StateName = (tabId: number) => `at-risk-cipher-badge-${tabId}`;
|
||||
const StateName = "at-risk-cipher-badge";
|
||||
|
||||
export class AtRiskCipherBadgeUpdaterService {
|
||||
private tabReplaced$ = new Subject<{ addedTab: chrome.tabs.Tab; removedTabId: number }>();
|
||||
private tabUpdated$ = new Subject<chrome.tabs.Tab>();
|
||||
private tabRemoved$ = new Subject<number>();
|
||||
private tabActivated$ = new Subject<chrome.tabs.Tab>();
|
||||
|
||||
private activeUserData$ = this.accountService.activeAccount$.pipe(
|
||||
filterOutNullish(),
|
||||
switchMap((user) =>
|
||||
@@ -40,124 +32,36 @@ export class AtRiskCipherBadgeUpdaterService {
|
||||
private badgeService: BadgeService,
|
||||
private accountService: AccountService,
|
||||
private cipherService: CipherService,
|
||||
private logService: LogService,
|
||||
private taskService: TaskService,
|
||||
) {
|
||||
combineLatest({
|
||||
replaced: this.tabReplaced$,
|
||||
activeUserData: this.activeUserData$,
|
||||
})
|
||||
.pipe(
|
||||
mergeMap(async ({ replaced, activeUserData: [userId, pendingTasks] }) => {
|
||||
await this.clearTabState(replaced.removedTabId);
|
||||
await this.setTabState(replaced.addedTab, userId, pendingTasks);
|
||||
}),
|
||||
)
|
||||
.subscribe(() => {});
|
||||
|
||||
combineLatest({
|
||||
tab: this.tabActivated$,
|
||||
activeUserData: this.activeUserData$,
|
||||
})
|
||||
.pipe(
|
||||
mergeMap(async ({ tab, activeUserData: [userId, pendingTasks] }) => {
|
||||
await this.setTabState(tab, userId, pendingTasks);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
combineLatest({
|
||||
tab: this.tabUpdated$,
|
||||
activeUserData: this.activeUserData$,
|
||||
})
|
||||
.pipe(
|
||||
mergeMap(async ({ tab, activeUserData: [userId, pendingTasks] }) => {
|
||||
await this.setTabState(tab, userId, pendingTasks);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.tabRemoved$
|
||||
.pipe(
|
||||
mergeMap(async (tabId) => {
|
||||
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.badgeService.setState(StateName, (tab) => {
|
||||
return this.activeUserData$.pipe(
|
||||
concatMap(async ([userId, pendingTasks]) => {
|
||||
const ciphers = tab.url
|
||||
? await this.cipherService.getAllDecryptedForUrl(tab.url, userId, [], undefined, true)
|
||||
: [];
|
||||
|
||||
this.tabReplaced$.next({
|
||||
removedTabId,
|
||||
addedTab: newTab,
|
||||
});
|
||||
const hasPendingTasksForTab = pendingTasks.some((task) =>
|
||||
ciphers.some((cipher) => cipher.id === task.cipherId && !cipher.isDeleted),
|
||||
);
|
||||
|
||||
if (!hasPendingTasksForTab) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
priority: BadgeStatePriority.High,
|
||||
state: {
|
||||
icon: BadgeIcon.Berry,
|
||||
// Unset text and background color to use default badge appearance
|
||||
text: Unset,
|
||||
backgroundColor: Unset,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
BrowserApi.addListener(chrome.tabs.onUpdated, (_, changeInfo, tab) => {
|
||||
if (changeInfo.url) {
|
||||
this.tabUpdated$.next(tab);
|
||||
}
|
||||
});
|
||||
|
||||
BrowserApi.addListener(chrome.tabs.onActivated, async (activeInfo) => {
|
||||
const tab = await BrowserApi.getTab(activeInfo.tabId);
|
||||
if (!tab) {
|
||||
this.logService.warning(
|
||||
`Tab activated event received but tab not found (id: ${activeInfo.tabId})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.tabActivated$.next(tab);
|
||||
});
|
||||
|
||||
BrowserApi.addListener(chrome.tabs.onRemoved, (tabId, _) => this.tabRemoved$.next(tabId));
|
||||
}
|
||||
|
||||
/** Sets the pending task state for the tab */
|
||||
private async setTabState(tab: chrome.tabs.Tab, userId: UserId, pendingTasks: SecurityTask[]) {
|
||||
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, [], undefined, true)
|
||||
: [];
|
||||
|
||||
const hasPendingTasksForTab = pendingTasks.some((task) =>
|
||||
ciphers.some((cipher) => cipher.id === task.cipherId && !cipher.isDeleted),
|
||||
);
|
||||
|
||||
if (!hasPendingTasksForTab) {
|
||||
await this.clearTabState(tab.id);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.badgeService.setState(
|
||||
StateName(tab.id),
|
||||
BadgeStatePriority.High,
|
||||
{
|
||||
icon: BadgeIcon.Berry,
|
||||
// Unset text and background color to use default badge appearance
|
||||
text: Unset,
|
||||
backgroundColor: Unset,
|
||||
},
|
||||
tab.id,
|
||||
);
|
||||
}
|
||||
|
||||
/** Clears the pending task state from a tab */
|
||||
private async clearTabState(tabId: number) {
|
||||
await this.badgeService.clearState(StateName(tabId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,9 +111,6 @@ 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", {
|
||||
|
||||
Reference in New Issue
Block a user