mirror of
https://github.com/microsoft/vscode.git
synced 2025-12-20 02:08:47 +00:00
499 lines
17 KiB
TypeScript
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);
|
|
}
|
|
}
|