UNPKG

chrome-devtools-frontend

Version:
243 lines (213 loc) • 7.9 kB
// Copyright 2024 The Chromium Authors. All rights reserved. // 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 SDK from '../../core/sdk/sdk.js'; import type * as Protocol from '../../generated/protocol.js'; import * as UI from '../../ui/legacy/legacy.js'; import {AI_ASSISTANCE_CSS_CLASS_NAME, type ChangeManager} from './ChangeManager.js'; export const FREESTYLER_WORLD_NAME = 'devtools_freestyler'; export const FREESTYLER_BINDING_NAME = '__freestyler'; /** * Injects Freestyler extension functions in to the isolated world. */ export class ExtensionScope { #listeners: Array<(event: { data: Protocol.Runtime.BindingCalledEvent, }) => Promise<void>> = []; #changeManager: ChangeManager; #agentId: string; #frameId?: Protocol.Page.FrameId|null; #target?: SDK.Target.Target; readonly #bindingMutex = new Common.Mutex.Mutex(); constructor(changes: ChangeManager, agentId: string) { this.#changeManager = changes; const selectedNode = UI.Context.Context.instance().flavor(SDK.DOMModel.DOMNode); const frameId = selectedNode?.frameId(); const target = selectedNode?.domModel().target(); this.#agentId = agentId; this.#target = target; this.#frameId = frameId; } get target(): SDK.Target.Target { if (this.#target) { return this.#target; } const target = UI.Context.Context.instance().flavor(SDK.Target.Target); if (!target) { throw new Error('Target is not found for executing code'); } return target; } get frameId(): Protocol.Page.FrameId { if (this.#frameId) { return this.#frameId; } const resourceTreeModel = this.target.model(SDK.ResourceTreeModel.ResourceTreeModel); if (!resourceTreeModel?.mainFrame) { throw new Error('Main frame is not found for executing code'); } return resourceTreeModel.mainFrame.id; } async install(): Promise<void> { const runtimeModel = this.target.model(SDK.RuntimeModel.RuntimeModel); const pageAgent = this.target.pageAgent(); // This returns previously created world if it exists for the frame. const {executionContextId} = await pageAgent.invoke_createIsolatedWorld({frameId: this.frameId, worldName: FREESTYLER_WORLD_NAME}); const isolatedWorldContext = runtimeModel?.executionContext(executionContextId); if (!isolatedWorldContext) { throw new Error('Execution context is not found for executing code'); } const handler = this.#bindingCalled.bind(this, isolatedWorldContext); runtimeModel?.addEventListener(SDK.RuntimeModel.Events.BindingCalled, handler); this.#listeners.push(handler); await this.target.runtimeAgent().invoke_addBinding({ name: FREESTYLER_BINDING_NAME, executionContextId, }); await this.#simpleEval(isolatedWorldContext, freestylerBinding); await this.#simpleEval(isolatedWorldContext, functions); } async uninstall(): Promise<void> { const runtimeModel = this.target.model(SDK.RuntimeModel.RuntimeModel); for (const handler of this.#listeners) { runtimeModel?.removeEventListener(SDK.RuntimeModel.Events.BindingCalled, handler); } this.#listeners = []; await this.target.runtimeAgent().invoke_removeBinding({ name: FREESTYLER_BINDING_NAME, }); } async #simpleEval(context: SDK.RuntimeModel.ExecutionContext, expression: string): Promise<{ object: SDK.RemoteObject.RemoteObject, exceptionDetails?: Protocol.Runtime.ExceptionDetails, }> { const response = await context.evaluate( { expression, replMode: true, includeCommandLineAPI: false, returnByValue: true, silent: false, generatePreview: false, allowUnsafeEvalBlockedByCSP: true, throwOnSideEffect: false, }, /* userGesture */ false, /* awaitPromise */ true); if (!response) { throw new Error('Response is not found'); } if ('error' in response) { throw new Error(response.error); } if (response.exceptionDetails) { const exceptionDescription = response.exceptionDetails.exception?.description; throw new Error(exceptionDescription || 'JS exception'); } return response; } async #bindingCalled(executionContext: SDK.RuntimeModel.ExecutionContext, event: { data: Protocol.Runtime.BindingCalledEvent, }): Promise<void> { const {data} = event; if (data.name !== FREESTYLER_BINDING_NAME) { return; } await this.#bindingMutex.run(async () => { const id = data.payload; const {object} = await this.#simpleEval(executionContext, `freestyler.getArgs(${id})`); const arg = JSON.parse(object.value); const selector = arg.selector; const className = arg.className; const cssModel = this.target.model(SDK.CSSModel.CSSModel); if (!cssModel) { throw new Error('CSSModel is not found'); } const styleChanges = await this.#changeManager.addChange(cssModel, this.frameId, { groupId: this.#agentId, selector, className, styles: arg.styles, }); await this.#simpleEval(executionContext, `freestyler.respond(${id}, ${JSON.stringify(styleChanges)})`); }); } } const freestylerBinding = `if (!globalThis.freestyler) { globalThis.freestyler = (args) => { const {resolve, promise } = Promise.withResolvers(); freestyler.callbacks.set(freestyler.id , { args: JSON.stringify(args), callbackId: freestyler.id, resolve, }); ${FREESTYLER_BINDING_NAME}(String(freestyler.id)); freestyler.id++; return promise; } freestyler.id = 1; freestyler.callbacks = new Map(); freestyler.getArgs = (callbackId) => { return freestyler.callbacks.get(callbackId).args; } freestyler.respond = (callbackId, styleChanges) => { freestyler.callbacks.get(callbackId).resolve(styleChanges); freestyler.callbacks.delete(callbackId); } }`; const functions = `async function setElementStyles(el, styles) { let selector = el.tagName.toLowerCase(); if (el.id) { selector = '#' + el.id; } else if (el.classList.length) { const parts = []; for (const cls of el.classList) { if (cls.startsWith('${AI_ASSISTANCE_CSS_CLASS_NAME}')) { continue; } parts.push('.' + cls); } if (parts.length) { selector = parts.join(''); } } // __freestylerClassName is not exposed to the page due to this being // run in the isolated world. const className = el.__freestylerClassName ?? '${AI_ASSISTANCE_CSS_CLASS_NAME}-' + freestyler.id; el.__freestylerClassName = className; el.classList.add(className); // Remove inline styles with the same keys so that the edit applies. for (const [key, value] of Object.entries(styles)) { // if it's kebab case. el.style.removeProperty(key); // If it's camel case. el.style[key] = ''; } const result = await freestyler({ method: 'setElementStyles', selector: selector, className, styles }); let rootNode = el.getRootNode(); if (rootNode instanceof ShadowRoot) { let stylesheets = rootNode.adoptedStyleSheets; let hasAiStyleChange = false; let stylesheet = new CSSStyleSheet(); for (let i = 0; i < stylesheets.length; i++) { const sheet = stylesheets[i]; for (let j = 0; j < sheet.cssRules.length; j++) { hasAiStyleChange = sheet.cssRules[j].selectorText.startsWith('.${AI_ASSISTANCE_CSS_CLASS_NAME}'); if (hasAiStyleChange) { stylesheet = sheet; break; } } } stylesheet.replaceSync(result); if (!hasAiStyleChange) { rootNode.adoptedStyleSheets = [...stylesheets, stylesheet]; } } }`;