1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 05:43:41 +00:00

feat(extension-login-approvals): [Auth/PM-14939] devices list view for browser (#14620)

Creates a new `DeviceManagementComponent` that fetches devices and formats them before handing them off to a view component for display.

View components:

- `DeviceManagementTableComponent` - displays on medium to large screens
- `DeviceManagementItemGroupComponent` - displays on small screens

Feature flag: `PM14938_BrowserExtensionLoginApproval`
This commit is contained in:
Alec Rippberger
2025-07-17 12:43:49 -05:00
committed by GitHub
parent 250e46ee70
commit 00b6b0224e
28 changed files with 872 additions and 26 deletions

View File

@@ -0,0 +1,15 @@
import { DeviceManagementComponentServiceAbstraction } from "./device-management-component.service.abstraction";
/**
* Default implementation of the device management component service
*/
export class DefaultDeviceManagementComponentService
implements DeviceManagementComponentServiceAbstraction
{
/**
* Show header information in web client
*/
showHeaderInformation(): boolean {
return true;
}
}

View File

@@ -0,0 +1,11 @@
/**
* Service abstraction for device management component
* Used to determine client-specific behavior
*/
export abstract class DeviceManagementComponentServiceAbstraction {
/**
* Whether to show header information (title, description, etc.) in the device management component
* @returns true if header information should be shown, false otherwise
*/
abstract showHeaderInformation(): boolean;
}

View File

@@ -0,0 +1,63 @@
<bit-item-group>
<bit-item *ngFor="let device of devices">
@if (device.pendingAuthRequest) {
<button
class="tw-relative"
bit-item-content
type="button"
[attr.tabindex]="device.pendingAuthRequest != null ? 0 : null"
(click)="approveOrDenyAuthRequest(device.pendingAuthRequest)"
(keydown.enter)="approveOrDenyAuthRequest(device.pendingAuthRequest)"
>
<!-- Default Content -->
<span class="tw-text-base">{{ device.displayName }}</span>
<!-- Default Trailing Content -->
<span class="tw-absolute tw-top-[6px] tw-right-3" slot="default-trailing">
<span bitBadge variant="warning">
{{ "requestPending" | i18n }}
</span>
</span>
<!-- Secondary Content -->
<span slot="secondary" class="tw-text-sm">
<span>{{ "needsApproval" | i18n }}</span>
<div>
<span class="tw-font-semibold"> {{ "firstLogin" | i18n }}: </span>
<span>{{ device.firstLogin | date: "medium" }}</span>
</div>
</span>
</button>
} @else {
<bit-item-content ngClass="tw-relative">
<!-- Default Content -->
<span class="tw-text-base">{{ device.displayName }}</span>
<!-- Default Trailing Content -->
<div
*ngIf="device.isCurrentDevice"
class="tw-absolute tw-top-[6px] tw-right-3"
slot="default-trailing"
>
<span bitBadge variant="primary">
{{ "currentSession" | i18n }}
</span>
</div>
<!-- Secondary Content -->
<div slot="secondary" class="tw-text-sm">
@if (device.isTrusted) {
<span>{{ "trusted" | i18n }}</span>
} @else {
<br />
}
<div>
<span class="tw-font-semibold">{{ "firstLogin" | i18n }}: </span>
<span>{{ device.firstLogin | date: "medium" }}</span>
</div>
</div>
</bit-item-content>
}
</bit-item>
</bit-item-group>

View File

@@ -0,0 +1,44 @@
import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
import { firstValueFrom } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { LoginApprovalComponent } from "@bitwarden/auth/angular";
import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
import { BadgeModule, DialogService, ItemModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { DeviceDisplayData } from "./device-management.component";
import { clearAuthRequestAndResortDevices } from "./resort-devices.helper";
/** Displays user devices in an item list view */
@Component({
standalone: true,
selector: "auth-device-management-item-group",
templateUrl: "./device-management-item-group.component.html",
imports: [BadgeModule, CommonModule, ItemModule, I18nPipe],
})
export class DeviceManagementItemGroupComponent {
@Input() devices: DeviceDisplayData[] = [];
constructor(private dialogService: DialogService) {}
protected async approveOrDenyAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) {
if (pendingAuthRequest == null) {
return;
}
const loginApprovalDialog = LoginApprovalComponent.open(this.dialogService, {
notificationId: pendingAuthRequest.id,
});
const result = await firstValueFrom(loginApprovalDialog.closed);
if (result !== undefined && typeof result === "boolean") {
// Auth request was approved or denied, so clear the
// pending auth request and re-sort the device array
this.devices = clearAuthRequestAndResortDevices(this.devices, pendingAuthRequest);
}
}
}

View File

@@ -0,0 +1,62 @@
<bit-table-scroll [dataSource]="tableDataSource" [rowSize]="50">
<!-- Table Header -->
<ng-container header>
<th
*ngFor="let column of columnConfig"
[class]="column.headerClass"
bitCell
[bitSortable]="column.sortable ? column.name : ''"
[default]="column.name === 'loginStatus' ? 'desc' : false"
scope="col"
role="columnheader"
>
{{ column.title }}
</th>
</ng-container>
<!-- Table Rows -->
<ng-template bitRowDef let-device>
<!-- Column: Device Name -->
<td bitCell class="tw-flex tw-gap-2">
<div class="tw-flex tw-items-center tw-justify-center tw-w-10">
<i [class]="device.icon" class="bwi-lg" aria-hidden="true"></i>
</div>
<div>
@if (device.pendingAuthRequest) {
<a
bitLink
href="#"
appStopClick
(click)="approveOrDenyAuthRequest(device.pendingAuthRequest)"
>
{{ device.displayName }}
</a>
<div class="tw-text-sm tw-text-muted">
{{ "needsApproval" | i18n }}
</div>
} @else {
<span>{{ device.displayName }}</span>
<div *ngIf="device.isTrusted" class="tw-text-sm tw-text-muted">
{{ "trusted" | i18n }}
</div>
}
</div>
</td>
<!-- Column: Login Status -->
<td bitCell>
<div class="tw-flex tw-gap-1">
<span *ngIf="device.isCurrentDevice" bitBadge variant="primary">
{{ "currentSession" | i18n }}
</span>
<span *ngIf="device.pendingAuthRequest" bitBadge variant="warning">
{{ "requestPending" | i18n }}
</span>
</div>
</td>
<!-- Column: First Login -->
<td bitCell>{{ device.firstLogin | date: "medium" }}</td>
</ng-template>
</bit-table-scroll>

View File

@@ -0,0 +1,86 @@
import { CommonModule } from "@angular/common";
import { Component, Input, OnChanges, SimpleChanges } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { LoginApprovalComponent } from "@bitwarden/auth/angular";
import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
BadgeModule,
ButtonModule,
DialogService,
LinkModule,
TableDataSource,
TableModule,
} from "@bitwarden/components";
import { DeviceDisplayData } from "./device-management.component";
import { clearAuthRequestAndResortDevices } from "./resort-devices.helper";
/** Displays user devices in a sortable table view */
@Component({
standalone: true,
selector: "auth-device-management-table",
templateUrl: "./device-management-table.component.html",
imports: [BadgeModule, ButtonModule, CommonModule, JslibModule, LinkModule, TableModule],
})
export class DeviceManagementTableComponent implements OnChanges {
@Input() devices: DeviceDisplayData[] = [];
protected tableDataSource = new TableDataSource<DeviceDisplayData>();
protected readonly columnConfig = [
{
name: "displayName",
title: this.i18nService.t("device"),
headerClass: "tw-w-1/3",
sortable: true,
},
{
name: "loginStatus",
title: this.i18nService.t("loginStatus"),
headerClass: "tw-w-1/3",
sortable: true,
},
{
name: "firstLogin",
title: this.i18nService.t("firstLogin"),
headerClass: "tw-w-1/3",
sortable: true,
},
];
constructor(
private i18nService: I18nService,
private dialogService: DialogService,
) {}
ngOnChanges(changes: SimpleChanges): void {
if (changes.devices) {
this.tableDataSource.data = this.devices;
}
}
protected async approveOrDenyAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) {
if (pendingAuthRequest == null) {
return;
}
const loginApprovalDialog = LoginApprovalComponent.open(this.dialogService, {
notificationId: pendingAuthRequest.id,
});
const result = await firstValueFrom(loginApprovalDialog.closed);
if (result !== undefined && typeof result === "boolean") {
// Auth request was approved or denied, so clear the
// pending auth request and re-sort the device array
this.tableDataSource.data = clearAuthRequestAndResortDevices(
this.devices,
pendingAuthRequest,
);
}
}
}

View File

@@ -0,0 +1,40 @@
<div *ngIf="showHeaderInfo" class="tw-mt-6 tw-mb-2 tw-pb-2.5">
<div class="tw-flex tw-items-center tw-gap-2 tw-mb-5">
<h1 class="tw-m-0">{{ "devices" | i18n }}</h1>
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-600 tw-flex tw-items-center tw-size-4"
[bitPopoverTriggerFor]="infoPopover"
position="right-start"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</button>
<bit-popover [title]="'whatIsADevice' | i18n" #infoPopover>
<p>{{ "aDeviceIs" | i18n }}</p>
</bit-popover>
</div>
<p>
{{ "deviceListDescriptionTemp" | i18n }}
</p>
</div>
@if (initializing) {
<div class="tw-flex tw-justify-center tw-items-center tw-p-4">
<i class="bwi bwi-spinner bwi-spin tw-text-2xl" aria-hidden="true"></i>
</div>
} @else {
<!-- Table View: displays on medium to large screens -->
<auth-device-management-table
ngClass="tw-hidden md:tw-block"
[devices]="devices"
></auth-device-management-table>
<!-- List View: displays on small screens -->
<auth-device-management-item-group
ngClass="md:tw-hidden"
[devices]="devices"
></auth-device-management-item-group>
}

View File

@@ -0,0 +1,230 @@
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { firstValueFrom } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { AuthRequestApiServiceAbstraction } from "@bitwarden/auth/common";
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
import {
DevicePendingAuthRequest,
DeviceResponse,
} from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
import { DeviceView } from "@bitwarden/common/auth/abstractions/devices/views/device.view";
import { DeviceType, DeviceTypeMetadata } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { MessageListener } from "@bitwarden/common/platform/messaging";
import { ButtonModule, PopoverModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { DeviceManagementComponentServiceAbstraction } from "./device-management-component.service.abstraction";
import { DeviceManagementItemGroupComponent } from "./device-management-item-group.component";
import { DeviceManagementTableComponent } from "./device-management-table.component";
export interface DeviceDisplayData {
displayName: string;
firstLogin: Date;
icon: string;
id: string;
identifier: string;
isCurrentDevice: boolean;
isTrusted: boolean;
loginStatus: string;
pendingAuthRequest: DevicePendingAuthRequest | null;
}
/**
* The `DeviceManagementComponent` fetches user devices and passes them down
* to a child component for display.
*
* The specific child component that gets displayed depends on the viewport width:
* - Medium to Large screens = `bit-table` view
* - Small screens = `bit-item-group` view
*/
@Component({
standalone: true,
selector: "auth-device-management",
templateUrl: "./device-management.component.html",
imports: [
ButtonModule,
CommonModule,
DeviceManagementItemGroupComponent,
DeviceManagementTableComponent,
I18nPipe,
PopoverModule,
],
})
export class DeviceManagementComponent implements OnInit {
protected devices: DeviceDisplayData[] = [];
protected initializing = true;
protected showHeaderInfo = false;
constructor(
private authRequestApiService: AuthRequestApiServiceAbstraction,
private destroyRef: DestroyRef,
private deviceManagementComponentService: DeviceManagementComponentServiceAbstraction,
private devicesService: DevicesServiceAbstraction,
private i18nService: I18nService,
private messageListener: MessageListener,
private validationService: ValidationService,
) {
this.showHeaderInfo = this.deviceManagementComponentService.showHeaderInformation();
}
async ngOnInit() {
await this.loadDevices();
this.messageListener.allMessages$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((message) => {
if (
message.command === "openLoginApproval" &&
message.notificationId &&
typeof message.notificationId === "string"
) {
void this.upsertDeviceWithPendingAuthRequest(message.notificationId);
}
});
}
async loadDevices() {
try {
const devices = await firstValueFrom(this.devicesService.getDevices$());
const currentDevice = await firstValueFrom(this.devicesService.getCurrentDevice$());
if (!devices || !currentDevice) {
return;
}
this.devices = this.mapDevicesToDisplayData(devices, currentDevice);
} catch (e) {
this.validationService.showError(e);
} finally {
this.initializing = false;
}
}
private mapDevicesToDisplayData(
devices: DeviceView[],
currentDevice: DeviceResponse,
): DeviceDisplayData[] {
return devices
.map((device): DeviceDisplayData | null => {
if (!device.id) {
this.validationService.showError(new Error(this.i18nService.t("deviceIdMissing")));
return null;
}
if (device.type == undefined) {
this.validationService.showError(new Error(this.i18nService.t("deviceTypeMissing")));
return null;
}
if (!device.creationDate) {
this.validationService.showError(
new Error(this.i18nService.t("deviceCreationDateMissing")),
);
return null;
}
return {
displayName: this.devicesService.getReadableDeviceTypeName(device.type),
firstLogin: device.creationDate ? new Date(device.creationDate) : new Date(),
icon: this.getDeviceIcon(device.type),
id: device.id || "",
identifier: device.identifier ?? "",
isCurrentDevice: this.isCurrentDevice(device, currentDevice),
isTrusted: device.response?.isTrusted ?? false,
loginStatus: this.getLoginStatus(device, currentDevice),
pendingAuthRequest: device.response?.devicePendingAuthRequest ?? null,
};
})
.filter((device) => device !== null);
}
private async upsertDeviceWithPendingAuthRequest(authRequestId: string) {
const authRequestResponse = await this.authRequestApiService.getAuthRequest(authRequestId);
if (!authRequestResponse) {
return;
}
const upsertDevice: DeviceDisplayData = {
displayName: this.devicesService.getReadableDeviceTypeName(
authRequestResponse.requestDeviceTypeValue,
),
firstLogin: new Date(authRequestResponse.creationDate),
icon: this.getDeviceIcon(authRequestResponse.requestDeviceTypeValue),
id: "",
identifier: authRequestResponse.requestDeviceIdentifier,
isCurrentDevice: false,
isTrusted: false,
loginStatus: this.i18nService.t("requestPending"),
pendingAuthRequest: {
id: authRequestResponse.id,
creationDate: authRequestResponse.creationDate,
},
};
// If the device already exists in the DB, update the device id and first login date
if (authRequestResponse.requestDeviceIdentifier) {
const existingDevice = await firstValueFrom(
this.devicesService.getDeviceByIdentifier$(authRequestResponse.requestDeviceIdentifier),
);
if (existingDevice?.id && existingDevice.creationDate) {
upsertDevice.id = existingDevice.id;
upsertDevice.firstLogin = new Date(existingDevice.creationDate);
}
}
const existingDeviceIndex = this.devices.findIndex(
(device) => device.identifier === upsertDevice.identifier,
);
if (existingDeviceIndex >= 0) {
// Update existing device in device list
this.devices[existingDeviceIndex] = upsertDevice;
this.devices = [...this.devices];
} else {
// Add new device to device list
this.devices = [upsertDevice, ...this.devices];
}
}
private getLoginStatus(device: DeviceView, currentDevice: DeviceResponse): string {
if (this.isCurrentDevice(device, currentDevice)) {
return this.i18nService.t("currentSession");
}
if (this.hasPendingAuthRequest(device)) {
return this.i18nService.t("requestPending");
}
return "";
}
private isCurrentDevice(device: DeviceView, currentDevice: DeviceResponse): boolean {
return device.id === currentDevice.id;
}
private hasPendingAuthRequest(device: DeviceView): boolean {
return device.response?.devicePendingAuthRequest != null;
}
private getDeviceIcon(type: DeviceType): string {
const defaultIcon = "bwi bwi-desktop";
const categoryIconMap: Record<string, string> = {
webApp: "bwi bwi-browser",
desktop: "bwi bwi-desktop",
mobile: "bwi bwi-mobile",
cli: "bwi bwi-cli",
extension: "bwi bwi-puzzle",
sdk: "bwi bwi-desktop",
};
const metadata = DeviceTypeMetadata[type];
return metadata ? (categoryIconMap[metadata.category] ?? defaultIcon) : defaultIcon;
}
}

View File

@@ -0,0 +1,53 @@
import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
import { DeviceDisplayData } from "./device-management.component";
export function clearAuthRequestAndResortDevices(
devices: DeviceDisplayData[],
pendingAuthRequest: DevicePendingAuthRequest,
): DeviceDisplayData[] {
return devices
.map((device) => {
if (device.pendingAuthRequest?.id === pendingAuthRequest.id) {
device.pendingAuthRequest = null;
device.loginStatus = "";
}
return device;
})
.sort(resortDevices);
}
/**
* After a device is approved/denied, it will still be at the beginning of the array,
* so we must resort the array to ensure it is in the correct order.
*
* This is a helper function that gets passed to the `Array.sort()` method
*/
function resortDevices(deviceA: DeviceDisplayData, deviceB: DeviceDisplayData) {
// Devices with a pending auth request should be first
if (deviceA.pendingAuthRequest) {
return -1;
}
if (deviceB.pendingAuthRequest) {
return 1;
}
// Next is the current device
if (deviceA.isCurrentDevice) {
return -1;
}
if (deviceB.isCurrentDevice) {
return 1;
}
// Then sort the rest by display name (alphabetically)
if (deviceA.displayName < deviceB.displayName) {
return -1;
}
if (deviceA.displayName > deviceB.displayName) {
return 1;
}
// Default
return 0;
}

View File

@@ -1188,7 +1188,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: DevicesServiceAbstraction,
useClass: DevicesServiceImplementation,
deps: [DevicesApiServiceAbstraction, AppIdServiceAbstraction],
deps: [AppIdServiceAbstraction, DevicesApiServiceAbstraction, I18nServiceAbstraction],
}),
safeProvider({
provide: AuthRequestApiServiceAbstraction,

View File

@@ -1,5 +1,7 @@
import { Observable } from "rxjs";
import { DeviceType } from "@bitwarden/common/enums";
import { DeviceResponse } from "./responses/device.response";
import { DeviceView } from "./views/device.view";
@@ -15,4 +17,5 @@ export abstract class DevicesServiceAbstraction {
): Observable<DeviceView>;
abstract deactivateDevice$(deviceId: string): Observable<void>;
abstract getCurrentDevice$(): Observable<DeviceResponse>;
abstract getReadableDeviceTypeName(deviceType: DeviceType): string;
}

View File

@@ -1,5 +1,8 @@
import { Observable, defer, map } from "rxjs";
import { DeviceType, DeviceTypeMetadata } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ListResponse } from "../../../models/response/list.response";
import { AppIdService } from "../../../platform/abstractions/app-id.service";
import { DevicesServiceAbstraction } from "../../abstractions/devices/devices.service.abstraction";
@@ -17,8 +20,9 @@ import { DevicesApiServiceAbstraction } from "../../abstractions/devices-api.ser
*/
export class DevicesServiceImplementation implements DevicesServiceAbstraction {
constructor(
private devicesApiService: DevicesApiServiceAbstraction,
private appIdService: AppIdService,
private devicesApiService: DevicesApiServiceAbstraction,
private i18nService: I18nService,
) {}
/**
@@ -86,4 +90,23 @@ export class DevicesServiceImplementation implements DevicesServiceAbstraction {
return this.devicesApiService.getDeviceByIdentifier(deviceIdentifier);
});
}
/**
* @description Gets a human readable string of the device type name
*/
getReadableDeviceTypeName(type: DeviceType): string {
if (type === undefined) {
return this.i18nService.t("unknownDevice");
}
const metadata = DeviceTypeMetadata[type];
if (!metadata) {
return this.i18nService.t("unknownDevice");
}
const platform =
metadata.platform === "Unknown" ? this.i18nService.t("unknown") : metadata.platform;
const category = this.i18nService.t(metadata.category);
return platform ? `${category} - ${platform}` : category;
}
}

View File

@@ -35,7 +35,7 @@ export enum DeviceType {
* Each device type has a category corresponding to the client type and platform (Android, iOS, Chrome, Firefox, etc.)
*/
interface DeviceTypeMetadata {
category: "mobile" | "extension" | "webVault" | "desktop" | "cli" | "sdk" | "server";
category: "mobile" | "extension" | "webApp" | "desktop" | "cli" | "sdk" | "server";
platform: string;
}
@@ -49,15 +49,15 @@ export const DeviceTypeMetadata: Record<DeviceType, DeviceTypeMetadata> = {
[DeviceType.EdgeExtension]: { category: "extension", platform: "Edge" },
[DeviceType.VivaldiExtension]: { category: "extension", platform: "Vivaldi" },
[DeviceType.SafariExtension]: { category: "extension", platform: "Safari" },
[DeviceType.ChromeBrowser]: { category: "webVault", platform: "Chrome" },
[DeviceType.FirefoxBrowser]: { category: "webVault", platform: "Firefox" },
[DeviceType.OperaBrowser]: { category: "webVault", platform: "Opera" },
[DeviceType.EdgeBrowser]: { category: "webVault", platform: "Edge" },
[DeviceType.IEBrowser]: { category: "webVault", platform: "IE" },
[DeviceType.SafariBrowser]: { category: "webVault", platform: "Safari" },
[DeviceType.VivaldiBrowser]: { category: "webVault", platform: "Vivaldi" },
[DeviceType.DuckDuckGoBrowser]: { category: "webVault", platform: "DuckDuckGo" },
[DeviceType.UnknownBrowser]: { category: "webVault", platform: "Unknown" },
[DeviceType.ChromeBrowser]: { category: "webApp", platform: "Chrome" },
[DeviceType.FirefoxBrowser]: { category: "webApp", platform: "Firefox" },
[DeviceType.OperaBrowser]: { category: "webApp", platform: "Opera" },
[DeviceType.EdgeBrowser]: { category: "webApp", platform: "Edge" },
[DeviceType.IEBrowser]: { category: "webApp", platform: "IE" },
[DeviceType.SafariBrowser]: { category: "webApp", platform: "Safari" },
[DeviceType.VivaldiBrowser]: { category: "webApp", platform: "Vivaldi" },
[DeviceType.DuckDuckGoBrowser]: { category: "webApp", platform: "DuckDuckGo" },
[DeviceType.UnknownBrowser]: { category: "webApp", platform: "Unknown" },
[DeviceType.WindowsDesktop]: { category: "desktop", platform: "Windows" },
[DeviceType.MacOsDesktop]: { category: "desktop", platform: "macOS" },
[DeviceType.LinuxDesktop]: { category: "desktop", platform: "Linux" },