chrome-devtools-frontend
Version:
Chrome DevTools UI
272 lines (236 loc) • 8.52 kB
text/typescript
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as Root from '../root/root.js';
import * as DispatchHttpRequestClient from './DispatchHttpRequestClient.js';
import type {DispatchHttpRequestRequest} from './InspectorFrontendHostAPI.js';
export enum SubscriptionStatus {
ENABLED = 'SUBSCRIPTION_STATE_ENABLED',
PENDING = 'SUBSCRIPTION_STATE_PENDING',
CANCELED = 'SUBSCRIPTION_STATE_CANCELED',
REFUNDED = 'SUBSCRIPTION_STATE_REFUNDED',
AWAITING_FIX = 'SUBSCRIPTION_STATE_AWAITING_FIX',
ON_HOLD = 'SUBSCRIPTION_STATE_ACCOUNT_ON_HOLD',
}
export enum SubscriptionTier {
PREMIUM_ANNUAL = 'SUBSCRIPTION_TIER_PREMIUM_ANNUAL',
PREMIUM_MONTHLY = 'SUBSCRIPTION_TIER_PREMIUM_MONTHLY',
PRO_ANNUAL = 'SUBSCRIPTION_TIER_PRO_ANNUAL',
PRO_MONTHLY = 'SUBSCRIPTION_TIER_PRO_MONTHLY',
}
export enum EligibilityStatus {
ELIGIBLE = 'ELIGIBLE',
NOT_ELIGIBLE = 'NOT_ELIGIBLE',
}
export enum EmailPreference {
ENABLED = 'ENABLED',
DISABLED = 'DISABLED',
}
interface CheckElibigilityResponse {
createProfile: EligibilityStatus;
}
interface BatchGetAwardsResponse {
awards?: Award[];
}
export interface Award {
name: string;
badge: {
title: string,
description: string,
imageUri: string,
deletableByUser: boolean,
};
title: string;
description: string;
imageUri: string;
createTime: string;
awardingUri: string;
}
export interface Profile {
// Resource name of the profile.
// Format: profiles/{obfuscated_profile_id}
name: string;
activeSubscription?: {
subscriptionStatus: SubscriptionStatus,
// To ensure forward compatibility, we accept any string, allowing the server to
// introduce new subscription tiers without breaking older clients.
subscriptionTier: SubscriptionTier|string,
};
}
export interface GetProfileResponse {
profile: Profile|null;
isEligible: boolean;
}
/**
* The `batchGet` awards endpoint returns badge names with an
* obfuscated user ID (e.g., `profiles/12345/awards/badge-name`).
* This function normalizes them to use `me` instead of the ID
* (e.g., `profiles/me/awards/badge-path`) to match the format
* used for client-side requests.
**/
function normalizeBadgeName(name: string): string {
return name.replace(/profiles\/[^/]+\/awards\//, 'profiles/me/awards/');
}
export const GOOGLE_DEVELOPER_PROGRAM_PROFILE_LINK = 'https://developers.google.com/profile/u/me';
const ORIGIN_APPLICATION_NAME = 'APPLICATION_CHROME_DEVTOOLS';
async function makeHttpRequest<R>(request: DispatchHttpRequestRequest): Promise<R> {
if (!isGdpProfilesAvailable()) {
throw new DispatchHttpRequestClient.DispatchHttpRequestError(
DispatchHttpRequestClient.ErrorType.HTTP_RESPONSE_UNAVAILABLE);
}
const response = await DispatchHttpRequestClient.makeHttpRequest(request) as R;
return response;
}
const SERVICE_NAME = 'gdpService';
let gdpClientInstance: GdpClient|null = null;
export class GdpClient {
#cachedProfilePromise?: Promise<Profile>;
#cachedEligibilityPromise?: Promise<CheckElibigilityResponse>;
private constructor() {
}
static instance({forceNew}: {
forceNew: boolean,
} = {forceNew: false}): GdpClient {
if (!gdpClientInstance || forceNew) {
gdpClientInstance = new GdpClient();
}
return gdpClientInstance;
}
/**
* Fetches the user's GDP profile and eligibility status.
*
* It first attempts to fetch the profile. If the profile is not found
* (a `NOT_FOUND` error), this is handled gracefully by treating the profile
* as `null` and then proceeding to check for eligibility.
*
* @returns A promise that resolves with an object containing the `profile`
* and `isEligible` status, or `null` if an unexpected error occurs.
*/
async getProfile(): Promise<GetProfileResponse|null> {
try {
const profile = await this.#getProfile();
return {
profile,
isEligible: true,
};
} catch (err: unknown) {
if (err instanceof DispatchHttpRequestClient.DispatchHttpRequestError &&
err.type === DispatchHttpRequestClient.ErrorType.HTTP_RESPONSE_UNAVAILABLE) {
return null;
}
}
try {
const checkEligibilityResponse = await this.#checkEligibility();
return {
profile: null,
isEligible: checkEligibilityResponse.createProfile === EligibilityStatus.ELIGIBLE,
};
} catch {
return null;
}
}
async #getProfile(): Promise<Profile> {
if (this.#cachedProfilePromise) {
return await this.#cachedProfilePromise;
}
this.#cachedProfilePromise = makeHttpRequest<Profile>({
service: SERVICE_NAME,
path: '/v1beta1/profile:get',
method: 'GET',
}).then(profile => {
this.#cachedEligibilityPromise = Promise.resolve({createProfile: EligibilityStatus.ELIGIBLE});
return profile;
});
return await this.#cachedProfilePromise;
}
async #checkEligibility(): Promise<CheckElibigilityResponse> {
if (this.#cachedEligibilityPromise) {
return await this.#cachedEligibilityPromise;
}
this.#cachedEligibilityPromise =
makeHttpRequest({service: SERVICE_NAME, path: '/v1beta1/eligibility:check', method: 'GET'});
return await this.#cachedEligibilityPromise;
}
/**
* @returns null if the request fails, the awarded badge names otherwise.
*/
async getAwardedBadgeNames({names}: {names: string[]}): Promise<Set<string>|null> {
try {
const response = await makeHttpRequest<BatchGetAwardsResponse>({
service: SERVICE_NAME,
path: '/v1beta1/profiles/me/awards:batchGet',
method: 'GET',
queryParams: {
allowMissing: 'true',
names,
}
});
return new Set(response.awards?.map(award => normalizeBadgeName(award.name)) ?? []);
} catch {
return null;
}
}
async createProfile({user, emailPreference}: {user: string, emailPreference: EmailPreference}):
Promise<Profile|null> {
try {
const response = await makeHttpRequest<Profile>({
service: SERVICE_NAME,
path: '/v1beta1/profiles',
method: 'POST',
body: JSON.stringify({
user,
newsletter_email: emailPreference,
creation_origin: {
origin_application: ORIGIN_APPLICATION_NAME,
}
}),
});
this.#clearCache();
return response;
} catch {
return null;
}
}
#clearCache(): void {
this.#cachedProfilePromise = undefined;
this.#cachedEligibilityPromise = undefined;
}
async createAward({name}: {name: string}): Promise<Award|null> {
try {
const response = await makeHttpRequest<Award>({
service: SERVICE_NAME,
path: '/v1beta1/profiles/me/awards',
method: 'POST',
body: JSON.stringify({
awardingUri: 'devtools://devtools',
name,
})
});
return response;
} catch {
return null;
}
}
}
export function isGdpProfilesAvailable(): boolean {
const isBaseFeatureEnabled = Boolean(Root.Runtime.hostConfig.devToolsGdpProfiles?.enabled);
const isBrandedBuild = Boolean(Root.Runtime.hostConfig.devToolsGdpProfilesAvailability?.enabled);
const isOffTheRecordProfile = Root.Runtime.hostConfig.isOffTheRecord;
const isDisabledByEnterprisePolicy =
getGdpProfilesEnterprisePolicy() === Root.Runtime.GdpProfilesEnterprisePolicyValue.DISABLED;
return isBaseFeatureEnabled && isBrandedBuild && !isOffTheRecordProfile && !isDisabledByEnterprisePolicy;
}
export function getGdpProfilesEnterprisePolicy(): Root.Runtime.GdpProfilesEnterprisePolicyValue {
return (
Root.Runtime.hostConfig.devToolsGdpProfilesAvailability?.enterprisePolicyValue ??
Root.Runtime.GdpProfilesEnterprisePolicyValue.DISABLED);
}
export function isBadgesEnabled(): boolean {
const isBadgesEnabledByEnterprisePolicy =
getGdpProfilesEnterprisePolicy() === Root.Runtime.GdpProfilesEnterprisePolicyValue.ENABLED;
const isBadgesEnabledByFeatureFlag = Boolean(Root.Runtime.hostConfig.devToolsGdpProfiles?.badgesEnabled);
return isBadgesEnabledByEnterprisePolicy && isBadgesEnabledByFeatureFlag;
}
export function isStarterBadgeEnabled(): boolean {
return Boolean(Root.Runtime.hostConfig.devToolsGdpProfiles?.starterBadgeEnabled);
}