Files
vscode/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts
2025-11-27 08:56:44 +00:00

499 lines
17 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { BrowserType, IElementData, INativeBrowserElementsService } from '../common/browserElements.js';
import { CancellationToken } from '../../../base/common/cancellation.js';
import { IRectangle } from '../../window/common/window.js';
import { BrowserWindow, webContents } from 'electron';
import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryWindow.js';
import { ICodeWindow } from '../../window/electron-main/window.js';
import { IAuxiliaryWindowsMainService } from '../../auxiliaryWindow/electron-main/auxiliaryWindows.js';
import { IWindowsMainService } from '../../windows/electron-main/windows.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';
import { Disposable } from '../../../base/common/lifecycle.js';
import { AddFirstParameterToFunctions } from '../../../base/common/types.js';
export const INativeBrowserElementsMainService = createDecorator<INativeBrowserElementsMainService>('browserElementsMainService');
export interface INativeBrowserElementsMainService extends AddFirstParameterToFunctions<INativeBrowserElementsService, Promise<unknown> /* only methods, not events */, number | undefined /* window ID */> { }
interface NodeDataResponse {
outerHTML: string;
computedStyle: string;
bounds: IRectangle;
}
export class NativeBrowserElementsMainService extends Disposable implements INativeBrowserElementsMainService {
_serviceBrand: undefined;
currentLocalAddress: string | undefined;
constructor(
@IWindowsMainService private readonly windowsMainService: IWindowsMainService,
@IAuxiliaryWindowsMainService private readonly auxiliaryWindowsMainService: IAuxiliaryWindowsMainService,
) {
super();
}
get windowId(): never { throw new Error('Not implemented in electron-main'); }
async findWebviewTarget(debuggers: Electron.Debugger, windowId: number, browserType: BrowserType): Promise<string | undefined> {
const { targetInfos } = await debuggers.sendCommand('Target.getTargets');
let target: typeof targetInfos[number] | undefined = undefined;
const matchingTarget = targetInfos.find((targetInfo: { url: string }) => {
try {
const url = new URL(targetInfo.url);
if (browserType === BrowserType.LiveServer) {
return url.searchParams.get('id') && url.searchParams.get('extensionId') === 'ms-vscode.live-server';
} else if (browserType === BrowserType.SimpleBrowser) {
return url.searchParams.get('parentId') === windowId.toString() && url.searchParams.get('extensionId') === 'vscode.simple-browser';
}
return false;
} catch (err) {
return false;
}
});
// search for webview via search parameters
if (matchingTarget) {
let resultId: string | undefined;
let url: URL | undefined;
try {
url = new URL(matchingTarget.url);
resultId = url.searchParams.get('id')!;
} catch (e) {
return undefined;
}
target = targetInfos.find((targetInfo: { url: string }) => {
try {
const url = new URL(targetInfo.url);
const isLiveServer = browserType === BrowserType.LiveServer && url.searchParams.get('serverWindowId') === resultId;
const isSimpleBrowser = browserType === BrowserType.SimpleBrowser && url.searchParams.get('id') === resultId && url.searchParams.has('vscodeBrowserReqId');
if (isLiveServer || isSimpleBrowser) {
this.currentLocalAddress = url.origin;
return true;
}
return false;
} catch (e) {
return false;
}
});
if (target) {
return target.targetId;
}
}
// fallback: search for webview without parameters based on current origin
target = targetInfos.find((targetInfo: { url: string }) => {
try {
const url = new URL(targetInfo.url);
return (this.currentLocalAddress === url.origin);
} catch (e) {
return false;
}
});
if (!target) {
return undefined;
}
return target.targetId;
}
async waitForWebviewTargets(debuggers: Electron.Debugger, windowId: number, browserType: BrowserType): Promise<string | undefined> {
const start = Date.now();
const timeout = 10000;
while (Date.now() - start < timeout) {
const targetId = await this.findWebviewTarget(debuggers, windowId, browserType);
if (targetId) {
return targetId;
}
// Wait for a short period before checking again
await new Promise(resolve => setTimeout(resolve, 500));
}
debuggers.detach();
return undefined;
}
async startDebugSession(windowId: number | undefined, token: CancellationToken, browserType: BrowserType, cancelAndDetachId?: number): Promise<void> {
const window = this.windowById(windowId);
if (!window?.win) {
return undefined;
}
// Find the simple browser webview
const allWebContents = webContents.getAllWebContents();
const simpleBrowserWebview = allWebContents.find(webContent => webContent.id === window.id);
if (!simpleBrowserWebview) {
return undefined;
}
const debuggers = simpleBrowserWebview.debugger;
if (!debuggers.isAttached()) {
debuggers.attach();
}
try {
const matchingTargetId = await this.waitForWebviewTargets(debuggers, windowId!, browserType);
if (!matchingTargetId) {
if (debuggers.isAttached()) {
debuggers.detach();
}
throw new Error('No target found');
}
} catch (e) {
if (debuggers.isAttached()) {
debuggers.detach();
}
throw new Error('No target found');
}
window.win.webContents.on('ipc-message', async (event, channel, closedCancelAndDetachId) => {
if (channel === `vscode:cancelCurrentSession${cancelAndDetachId}`) {
if (cancelAndDetachId !== closedCancelAndDetachId) {
return;
}
if (debuggers.isAttached()) {
debuggers.detach();
}
if (window.win) {
window.win.webContents.removeAllListeners('ipc-message');
}
}
});
}
async finishOverlay(debuggers: Electron.Debugger, sessionId: string | undefined): Promise<void> {
if (debuggers.isAttached() && sessionId) {
await debuggers.sendCommand('Overlay.setInspectMode', {
mode: 'none',
highlightConfig: {
showInfo: false,
showStyles: false
}
}, sessionId);
await debuggers.sendCommand('Overlay.hideHighlight', {}, sessionId);
await debuggers.sendCommand('Overlay.disable', {}, sessionId);
debuggers.detach();
}
}
async getElementData(windowId: number | undefined, rect: IRectangle, token: CancellationToken, browserType: BrowserType, cancellationId?: number): Promise<IElementData | undefined> {
const window = this.windowById(windowId);
if (!window?.win) {
return undefined;
}
// Find the simple browser webview
const allWebContents = webContents.getAllWebContents();
const simpleBrowserWebview = allWebContents.find(webContent => webContent.id === window.id);
if (!simpleBrowserWebview) {
return undefined;
}
const debuggers = simpleBrowserWebview.debugger;
if (!debuggers.isAttached()) {
debuggers.attach();
}
let targetSessionId: string | undefined = undefined;
try {
const targetId = await this.findWebviewTarget(debuggers, windowId!, browserType);
const { sessionId } = await debuggers.sendCommand('Target.attachToTarget', {
targetId: targetId,
flatten: true,
});
targetSessionId = sessionId;
await debuggers.sendCommand('DOM.enable', {}, sessionId);
await debuggers.sendCommand('CSS.enable', {}, sessionId);
await debuggers.sendCommand('Overlay.enable', {}, sessionId);
await debuggers.sendCommand('Debugger.enable', {}, sessionId);
await debuggers.sendCommand('Runtime.enable', {}, sessionId);
await debuggers.sendCommand('Runtime.evaluate', {
expression: `(function() {
const style = document.createElement('style');
style.id = '__pseudoBlocker__';
style.textContent = '*::before, *::after { pointer-events: none !important; }';
document.head.appendChild(style);
})();`,
}, sessionId);
// slightly changed default CDP debugger inspect colors
await debuggers.sendCommand('Overlay.setInspectMode', {
mode: 'searchForNode',
highlightConfig: {
showInfo: true,
showRulers: false,
showStyles: true,
showAccessibilityInfo: true,
showExtensionLines: false,
contrastAlgorithm: 'aa',
contentColor: { r: 173, g: 216, b: 255, a: 0.8 },
paddingColor: { r: 150, g: 200, b: 255, a: 0.5 },
borderColor: { r: 120, g: 180, b: 255, a: 0.7 },
marginColor: { r: 200, g: 220, b: 255, a: 0.4 },
eventTargetColor: { r: 130, g: 160, b: 255, a: 0.8 },
shapeColor: { r: 130, g: 160, b: 255, a: 0.8 },
shapeMarginColor: { r: 130, g: 160, b: 255, a: 0.5 },
gridHighlightConfig: {
rowGapColor: { r: 140, g: 190, b: 255, a: 0.3 },
rowHatchColor: { r: 140, g: 190, b: 255, a: 0.7 },
columnGapColor: { r: 140, g: 190, b: 255, a: 0.3 },
columnHatchColor: { r: 140, g: 190, b: 255, a: 0.7 },
rowLineColor: { r: 120, g: 180, b: 255 },
columnLineColor: { r: 120, g: 180, b: 255 },
rowLineDash: true,
columnLineDash: true
},
flexContainerHighlightConfig: {
containerBorder: {
color: { r: 120, g: 180, b: 255 },
pattern: 'solid'
},
itemSeparator: {
color: { r: 140, g: 190, b: 255 },
pattern: 'solid'
},
lineSeparator: {
color: { r: 140, g: 190, b: 255 },
pattern: 'solid'
},
mainDistributedSpace: {
hatchColor: { r: 140, g: 190, b: 255, a: 0.7 },
fillColor: { r: 140, g: 190, b: 255, a: 0.4 }
},
crossDistributedSpace: {
hatchColor: { r: 140, g: 190, b: 255, a: 0.7 },
fillColor: { r: 140, g: 190, b: 255, a: 0.4 }
},
rowGapSpace: {
hatchColor: { r: 140, g: 190, b: 255, a: 0.7 },
fillColor: { r: 140, g: 190, b: 255, a: 0.4 }
},
columnGapSpace: {
hatchColor: { r: 140, g: 190, b: 255, a: 0.7 },
fillColor: { r: 140, g: 190, b: 255, a: 0.4 }
}
},
flexItemHighlightConfig: {
baseSizeBox: {
hatchColor: { r: 130, g: 170, b: 255, a: 0.6 }
},
baseSizeBorder: {
color: { r: 120, g: 180, b: 255 },
pattern: 'solid'
},
flexibilityArrow: {
color: { r: 130, g: 190, b: 255 }
}
},
},
}, sessionId);
} catch (e) {
debuggers.detach();
throw new Error('No target found', e);
}
if (!targetSessionId) {
debuggers.detach();
throw new Error('No target session id found');
}
const nodeData = await this.getNodeData(targetSessionId, debuggers, window.win, cancellationId);
await this.finishOverlay(debuggers, targetSessionId);
const zoomFactor = simpleBrowserWebview.getZoomFactor();
const absoluteBounds = {
x: rect.x + nodeData.bounds.x,
y: rect.y + nodeData.bounds.y,
width: nodeData.bounds.width,
height: nodeData.bounds.height
};
const clippedBounds = {
x: Math.max(absoluteBounds.x, rect.x),
y: Math.max(absoluteBounds.y, rect.y),
width: Math.max(0, Math.min(absoluteBounds.x + absoluteBounds.width, rect.x + rect.width) - Math.max(absoluteBounds.x, rect.x)),
height: Math.max(0, Math.min(absoluteBounds.y + absoluteBounds.height, rect.y + rect.height) - Math.max(absoluteBounds.y, rect.y))
};
const scaledBounds = {
x: clippedBounds.x * zoomFactor,
y: clippedBounds.y * zoomFactor,
width: clippedBounds.width * zoomFactor,
height: clippedBounds.height * zoomFactor
};
return { outerHTML: nodeData.outerHTML, computedStyle: nodeData.computedStyle, bounds: scaledBounds };
}
async getNodeData(sessionId: string, debuggers: Electron.Debugger, window: BrowserWindow, cancellationId?: number): Promise<NodeDataResponse> {
return new Promise((resolve, reject) => {
const onMessage = async (event: Electron.Event, method: string, params: { backendNodeId: number }) => {
if (method === 'Overlay.inspectNodeRequested') {
debuggers.off('message', onMessage);
await debuggers.sendCommand('Runtime.evaluate', {
expression: `(() => {
const style = document.getElementById('__pseudoBlocker__');
if (style) style.remove();
})();`,
}, sessionId);
const backendNodeId = params?.backendNodeId;
if (!backendNodeId) {
throw new Error('Missing backendNodeId in inspectNodeRequested event');
}
try {
await debuggers.sendCommand('DOM.getDocument', {}, sessionId);
const { nodeIds } = await debuggers.sendCommand('DOM.pushNodesByBackendIdsToFrontend', { backendNodeIds: [backendNodeId] }, sessionId);
if (!nodeIds || nodeIds.length === 0) {
throw new Error('Failed to get node IDs.');
}
const nodeId = nodeIds[0];
const { model } = await debuggers.sendCommand('DOM.getBoxModel', { nodeId }, sessionId);
if (!model) {
throw new Error('Failed to get box model.');
}
const content = model.content;
const margin = model.margin;
const x = Math.min(margin[0], content[0]);
const y = Math.min(margin[1], content[1]) + 32.4; // 32.4 is height of the title bar
const width = Math.max(margin[2] - margin[0], content[2] - content[0]);
const height = Math.max(margin[5] - margin[1], content[5] - content[1]);
const matched = await debuggers.sendCommand('CSS.getMatchedStylesForNode', { nodeId }, sessionId);
if (!matched) {
throw new Error('Failed to get matched css.');
}
const formatted = this.formatMatchedStyles(matched);
const { outerHTML } = await debuggers.sendCommand('DOM.getOuterHTML', { nodeId }, sessionId);
if (!outerHTML) {
throw new Error('Failed to get outerHTML.');
}
resolve({
outerHTML,
computedStyle: formatted,
bounds: { x, y, width, height }
});
} catch (err) {
debuggers.off('message', onMessage);
debuggers.detach();
reject(err);
}
}
};
window.webContents.on('ipc-message', async (event, channel, closedCancellationId) => {
if (channel === `vscode:cancelElementSelection${cancellationId}`) {
if (cancellationId !== closedCancellationId) {
return;
}
debuggers.off('message', onMessage);
await this.finishOverlay(debuggers, sessionId);
window.webContents.removeAllListeners('ipc-message');
}
});
debuggers.on('message', onMessage);
});
}
formatMatchedStyles(matched: { inlineStyle?: { cssProperties?: Array<{ name: string; value: string }> }; matchedCSSRules?: Array<{ rule: { selectorList: { selectors: Array<{ text: string }> }; origin: string; style: { cssProperties: Array<{ name: string; value: string }> } } }>; inherited?: Array<{ matchedCSSRules?: Array<{ rule: { selectorList: { selectors: Array<{ text: string }> }; origin: string; style: { cssProperties: Array<{ name: string; value: string }> } } }> }> }): string {
const lines: string[] = [];
// inline
if (matched.inlineStyle?.cssProperties?.length) {
lines.push('/* Inline style */');
lines.push('element {');
for (const prop of matched.inlineStyle.cssProperties) {
if (prop.name && prop.value) {
lines.push(` ${prop.name}: ${prop.value};`);
}
}
lines.push('}\n');
}
// matched
if (matched.matchedCSSRules?.length) {
for (const ruleEntry of matched.matchedCSSRules) {
const rule = ruleEntry.rule;
const selectors = rule.selectorList.selectors.map(s => s.text).join(', ');
lines.push(`/* Matched Rule from ${rule.origin} */`);
lines.push(`${selectors} {`);
for (const prop of rule.style.cssProperties) {
if (prop.name && prop.value) {
lines.push(` ${prop.name}: ${prop.value};`);
}
}
lines.push('}\n');
}
}
// inherited rules
if (matched.inherited?.length) {
let level = 1;
for (const inherited of matched.inherited) {
const rules = inherited.matchedCSSRules || [];
for (const ruleEntry of rules) {
const rule = ruleEntry.rule;
const selectors = rule.selectorList.selectors.map(s => s.text).join(', ');
lines.push(`/* Inherited from ancestor level ${level} (${rule.origin}) */`);
lines.push(`${selectors} {`);
for (const prop of rule.style.cssProperties) {
if (prop.name && prop.value) {
lines.push(` ${prop.name}: ${prop.value};`);
}
}
lines.push('}\n');
}
level++;
}
}
return '\n' + lines.join('\n');
}
private windowById(windowId: number | undefined, fallbackCodeWindowId?: number): ICodeWindow | IAuxiliaryWindow | undefined {
return this.codeWindowById(windowId) ?? this.auxiliaryWindowById(windowId) ?? this.codeWindowById(fallbackCodeWindowId);
}
private codeWindowById(windowId: number | undefined): ICodeWindow | undefined {
if (typeof windowId !== 'number') {
return undefined;
}
return this.windowsMainService.getWindowById(windowId);
}
private auxiliaryWindowById(windowId: number | undefined): IAuxiliaryWindow | undefined {
if (typeof windowId !== 'number') {
return undefined;
}
const contents = webContents.fromId(windowId);
if (!contents) {
return undefined;
}
return this.auxiliaryWindowsMainService.getWindowByWebContents(contents);
}
}