| // 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 Common from '../../core/common/common.js'; |
| import * as Platform from '../../core/platform/platform.js'; |
| import type * as Root from '../../core/root/root.js'; |
| import * as SDK from '../../core/sdk/sdk.js'; |
| |
| /** The security origin for all DevTools (front-end) resources. */ |
| const DEVTOOLS_SECURITY_ORIGIN = 'devtools://devtools'; |
| |
| /** The (absolute) path to the project settings file. */ |
| const WELL_KNOWN_DEVTOOLS_JSON_PATH = '/.well-known/appspecific/com.chrome.devtools.json'; |
| |
| /** |
| * Checks if the origin of the `url` is `devtools://devtools` (meaning that it's |
| * served by the `DevToolsDataSource` in Chromium) and it's path starts with |
| * `/bundled/`. |
| * |
| * @param url the URL string to check. |
| * @returns `true` if `url` refers to a resource in the Chromium DevTools bundle. |
| */ |
| function isDevToolsBundledURL(url: string): boolean { |
| return url.startsWith(`${DEVTOOLS_SECURITY_ORIGIN}/bundled/`); |
| } |
| |
| /** |
| * Checks if the `frame` should be considered local and safe for loading the |
| * project settings from. |
| * |
| * This checks the security origin of `frame` for whether Chromium considers it |
| * to be localhost. It also supports special logic for when the origin of the |
| * `frame` is `'devtools://devtools'`, in which case we check whether the path |
| * starts with `'/bundled/'` and `debugFrontend=true` is passed as a query |
| * parameter (indicating that `--custom-devtools-frontend=` command line option |
| * was used). |
| * |
| * @param frame the `ResourceTreeFrame` to check. |
| * @returns `true` if `frame` is considered safe for loading the project settings. |
| * @see https://goo.gle/devtools-json-design |
| */ |
| function isLocalFrame(frame: SDK.ResourceTreeModel.ResourceTreeFrame|null|undefined): |
| frame is SDK.ResourceTreeModel.ResourceTreeFrame { |
| if (!frame) { |
| return false; |
| } |
| if (isDevToolsBundledURL(frame.url)) { |
| return new URL(frame.url).searchParams.get('debugFrontend') === 'true'; |
| } |
| return frame.securityOriginDetails?.isLocalhost ?? false; |
| } |
| |
| /** |
| * The structure of the project settings. |
| * |
| * @see https://goo.gle/devtools-json-design |
| */ |
| export interface ProjectSettings { |
| readonly workspace?: {readonly root: Platform.DevToolsPath.RawPathString, readonly uuid: string}; |
| } |
| |
| /** |
| * Indicates the availability of the project settings feature. |
| * |
| * `'available'` means that the feature is enabled, the origin of the inspected |
| * page is `localhost`. It doesn't however indicate whether or not the page is |
| * actually providing a `com.chrome.devtools.json` or not. |
| */ |
| export type ProjectSettingsAvailability = 'available'|'unavailable'; |
| |
| const EMPTY_PROJECT_SETTINGS: ProjectSettings = Object.freeze({}); |
| const IDLE_PROMISE: Promise<void> = Promise.resolve(); |
| |
| let projectSettingsModelInstance: ProjectSettingsModel|undefined; |
| |
| export class ProjectSettingsModel extends Common.ObjectWrapper.ObjectWrapper<EventTypes> { |
| readonly #pageResourceLoader: SDK.PageResourceLoader.PageResourceLoader; |
| readonly #targetManager: SDK.TargetManager.TargetManager; |
| #availability: ProjectSettingsAvailability = 'unavailable'; |
| #projectSettings: ProjectSettings = EMPTY_PROJECT_SETTINGS; |
| #promise: Promise<void> = IDLE_PROMISE; |
| |
| /** |
| * Yields the availability of the project settings feature. |
| * |
| * `'available'` means that the feature is enabled, the origin of the inspected |
| * page is `localhost`. It doesn't however indicate whether or not the page is |
| * actually providing a `com.chrome.devtools.json` or not. |
| * |
| * @returns `'available'` if the feature is enabled and the inspected page is |
| * `localhost`, otherwise `'unavailable'`. |
| */ |
| get availability(): ProjectSettingsAvailability { |
| return this.#availability; |
| } |
| |
| /** |
| * Yields the current project settings. |
| * |
| * @returns the current project settings. |
| */ |
| get projectSettings(): ProjectSettings { |
| return this.#projectSettings; |
| } |
| |
| get projectSettingsPromise(): Promise<ProjectSettings> { |
| return this.#promise.then(() => this.#projectSettings); |
| } |
| |
| private constructor( |
| hostConfig: Root.Runtime.HostConfig, |
| pageResourceLoader: SDK.PageResourceLoader.PageResourceLoader, |
| targetManager: SDK.TargetManager.TargetManager, |
| ) { |
| super(); |
| this.#pageResourceLoader = pageResourceLoader; |
| this.#targetManager = targetManager; |
| if (hostConfig.devToolsWellKnown?.enabled) { |
| this.#targetManager.addEventListener( |
| SDK.TargetManager.Events.INSPECTED_URL_CHANGED, |
| this.#inspectedURLChanged, |
| this, |
| ); |
| const target = this.#targetManager.primaryPageTarget(); |
| if (target !== null) { |
| this.#inspectedURLChanged({data: target}); |
| } |
| } |
| } |
| |
| /** |
| * Yields the `ProjectSettingsModel` singleton. |
| * |
| * @returns the singleton. |
| */ |
| static instance({forceNew, hostConfig, pageResourceLoader, targetManager}: { |
| forceNew: boolean|null, |
| hostConfig: Root.Runtime.HostConfig|null, |
| pageResourceLoader: SDK.PageResourceLoader.PageResourceLoader|null, |
| targetManager: SDK.TargetManager.TargetManager|null, |
| }): ProjectSettingsModel { |
| if (!projectSettingsModelInstance || forceNew) { |
| if (!hostConfig || !pageResourceLoader || !targetManager) { |
| throw new Error( |
| 'Unable to create ProjectSettingsModel: ' + |
| 'hostConfig, pageResourceLoader, and targetManager must be provided'); |
| } |
| projectSettingsModelInstance = new ProjectSettingsModel(hostConfig, pageResourceLoader, targetManager); |
| } |
| return projectSettingsModelInstance; |
| } |
| |
| /** |
| * Clears the `ProjectSettingsModel` singleton (if any). |
| */ |
| static removeInstance(): void { |
| if (projectSettingsModelInstance) { |
| projectSettingsModelInstance.#dispose(); |
| projectSettingsModelInstance = undefined; |
| } |
| } |
| |
| #dispose(): void { |
| this.#targetManager.removeEventListener( |
| SDK.TargetManager.Events.INSPECTED_URL_CHANGED, |
| this.#inspectedURLChanged, |
| this, |
| ); |
| } |
| |
| #inspectedURLChanged(event: Common.EventTarget.EventTargetEvent<SDK.Target.Target>): void { |
| const target = event.data; |
| |
| const promise = this.#promise = this.#promise.then(async(): Promise<void> => { |
| let projectSettings: ProjectSettings = EMPTY_PROJECT_SETTINGS; |
| try { |
| projectSettings = await this.#loadAndValidateProjectSettings(target); |
| } catch (error) { |
| // eslint-disable-next-line no-console |
| console.debug(`Could not load project settings for ${target.inspectedURL()}: ${error.message}`); |
| } |
| if (this.#promise === promise) { |
| if (this.#projectSettings !== projectSettings) { |
| this.#projectSettings = projectSettings; |
| this.dispatchEventToListeners(Events.PROJECT_SETTINGS_CHANGED, projectSettings); |
| } |
| this.#promise = IDLE_PROMISE; |
| } |
| }); |
| } |
| |
| async #loadAndValidateProjectSettings(target: SDK.Target.Target): Promise<ProjectSettings> { |
| const frame = target.model(SDK.ResourceTreeModel.ResourceTreeModel)?.mainFrame; |
| if (!isLocalFrame(frame)) { |
| if (this.#availability !== 'unavailable') { |
| this.#availability = 'unavailable'; |
| this.dispatchEventToListeners(Events.AVAILABILITY_CHANGED, this.#availability); |
| } |
| return EMPTY_PROJECT_SETTINGS; |
| } |
| if (this.#availability !== 'available') { |
| this.#availability = 'available'; |
| this.dispatchEventToListeners(Events.AVAILABILITY_CHANGED, this.#availability); |
| } |
| const initiatorUrl = frame.url; |
| const frameId = frame.id; |
| let url = WELL_KNOWN_DEVTOOLS_JSON_PATH; |
| if (isDevToolsBundledURL(initiatorUrl)) { |
| url = '/bundled' + url; |
| } |
| url = new URL(url, initiatorUrl).toString(); |
| const {content} = await this.#pageResourceLoader.loadResource( |
| Platform.DevToolsPath.urlString`${url}`, |
| {target, frameId, initiatorUrl}, |
| ); |
| const devtoolsJSON = JSON.parse(content); |
| if (typeof devtoolsJSON.workspace !== 'undefined') { |
| const {workspace} = devtoolsJSON; |
| if (typeof workspace !== 'object' || workspace === null) { |
| throw new Error('Invalid "workspace" field'); |
| } |
| if (typeof workspace.root !== 'string') { |
| throw new Error('Invalid or missing "workspace.root" field'); |
| } |
| if (typeof workspace.uuid !== 'string') { |
| throw new Error('Invalid or missing "workspace.uuid" field'); |
| } |
| } |
| return Object.freeze(devtoolsJSON); |
| } |
| } |
| |
| /** |
| * Events emitted by the `ProjectSettingsModel`. |
| */ |
| export const enum Events { |
| /** |
| * Emitted whenever the `availability` property of the |
| * `ProjectSettingsModel` changes. |
| */ |
| AVAILABILITY_CHANGED = 'AvailabilityChanged', |
| |
| /** |
| * Emitted whenever the `projectSettings` property of the |
| * `ProjectSettingsModel` changes. |
| */ |
| PROJECT_SETTINGS_CHANGED = 'ProjectSettingsChanged', |
| } |
| |
| /** |
| * @internal |
| */ |
| export interface EventTypes { |
| [Events.AVAILABILITY_CHANGED]: ProjectSettingsAvailability; |
| [Events.PROJECT_SETTINGS_CHANGED]: ProjectSettings; |
| } |